docs: add README, dual license files, AGENTS.md; fix tsup DTS build

- Add README.md with full API reference and usage examples for npm publishing
- Add LICENSE-MIT and LICENSE-APACHE for dual licensing (MIT OR Apache-2.0)
- Update AGENTS.md with project instructions for AI agents
- Fix tsconfig.json: remove declaration, declarationMap, outDir, and sourceMap
  which conflicted with tsup's own DTS generation (TS5055 overwrite errors)
This commit is contained in:
2026-05-19 07:06:56 +00:00
parent 32b2e20c54
commit 743265c34a
5 changed files with 707 additions and 4 deletions

358
README.md Normal file
View File

@@ -0,0 +1,358 @@
# @alkdev/ujsx
Universal JSX — runtime-agnostic reactive tree primitives with TypeBox schemas.
UJSX treats JSX as an intermediate representation for multi-target rendering. The same declarative tree can target different hosts (markdown, graph structures, DOM, workflow engines) through a `HostConfig` adapter. No `onClick`, no `className`, no `style` — the tree is a pure data structure, and hosts are interpreters.
## Install
```bash
npm install @alkdev/ujsx
```
Requires Node.js 18+. Dual ESM/CJS output with TypeScript declarations.
## Quick Start
### Element Construction
```typescript
import { h, createRoot, createComponent } from "@alkdev/ujsx";
import type { UNode, UElement, URoot } from "@alkdev/ujsx";
const el: UElement = h("div", { class: "container" }, "hello", h("span", null, "world"));
const root: URoot = createRoot("app", h("h1", null, "Title"));
const MyComp = createComponent("MyComp", (props) => h("div", null, props.text as string));
```
### HostConfig Rendering
```typescript
import { createRoot as createHostRoot } from "@alkdev/ujsx/host";
import type { HostConfig } from "@alkdev/ujsx/host";
import { h } from "@alkdev/ujsx/h";
const host: HostConfig<string, MyInstance, MyRootCtx> = {
name: "my-host",
createRootContext: (container) => ({ container }),
createInstance: (tag, props, ctx) => /* create your instance */,
createTextInstance: (text, ctx) => /* create text instance */,
appendChild: (parent, child, ctx) => /* attach child to parent */,
};
const root = createHostRoot(host, container);
root.render(h("div", { color: "red" }, "hello"));
root.unmount();
```
### Reactive Trees
```typescript
import { ReactiveRoot, signal, reactiveComponent } from "@alkdev/ujsx/reactive";
import { h, createComponent } from "@alkdev/ujsx/h";
const r = new ReactiveRoot(h("div", null, "initial"));
r.update((prev) => h("div", null, "updated"));
const unsub = r.subscribe((node) => console.log(node));
r.render((event) => console.log(event));
unsub();
r.dispose();
```
### JSX Configuration
Set `jsxImportSource` in `tsconfig.json` to use JSX syntax directly:
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@alkdev/ujsx"
}
}
```
Then write JSX that produces `UElement` trees:
```tsx
const tree = <div class="container"><span>hello</span></div>;
```
### Bi-directional Transforms
```typescript
import { TransformRegistry, childCtx, ctx as transformCtx } from "@alkdev/ujsx/transform";
import type { TransformRule, Direction } from "@alkdev/ujsx/transform";
const registry = new TransformRegistry<UNode, unknown, unknown>();
registry.register({
name: "div-to-paragraph",
direction: "ujsx→mdast" as Direction,
match: (n) => isUElement(n) && n.type === "div",
transform: (n, ctx, next) => ({
type: "paragraph",
children: (n as UElement).children.map((c, i) => next(c, childCtx(n, ctx, i))),
}),
priority: 1,
});
const result = registry.transform(myNode, transformCtx("ujsx→mdast" as Direction));
```
## Sub-path Exports
Tree-shakeable imports — only pull in what you use:
| Sub-path | Source | Key Exports |
|----------|--------|-------------|
| `@alkdev/ujsx` | `src/mod.ts` | All exports (barrel) |
| `@alkdev/ujsx/schema` | `src/core/schema.ts` | `UJSX`, `UElement`, `URoot`, `UNode`, `UPrimitive`, `isUElement`, `isURoot`, `isUPrimitive` |
| `@alkdev/ujsx/h` | `src/core/h.ts` | `h`, `createRoot`, `createComponent`, `Fragment`, `jsx`, `jsxs`, `jsxDEV` |
| `@alkdev/ujsx/reactive` | `src/core/reactive.ts` | `ReactiveRoot`, `reactiveComponent`, `reactiveElement`, `signal`, `computed`, `effect`, `batch` |
| `@alkdev/ujsx/context` | `src/core/context.ts` | `Context`, `Density`, `Direction`, `RenderContext` |
| `@alkdev/ujsx/events` | `src/core/events.ts` | `EventEnvelope`, `PubSubLike`, `UjsxEventMap`, `createPubSubEmitter`, `proxyEventEmitter` |
| `@alkdev/ujsx/pointer` | `src/core/pointer.ts` | `ValuePointer`, `selectNode`, `setNode` |
| `@alkdev/ujsx/host` | `src/host/config.ts` | `HostConfig`, `Root`, `createRoot` |
| `@alkdev/ujsx/transform` | `src/transform/registry.ts` | `TransformRegistry`, `TransformRule`, `TransformContext`, `TransformFn`, `childCtx`, `matchesSchema`, `ctx` |
| `@alkdev/ujsx/jsx-runtime` | `src/core/jsx-runtime.ts` | `jsx`, `jsxs`, `jsxDEV`, `Fragment` |
## Core Types
### UNode (union type)
The fundamental tree node type. Every value in a UJSX tree is a `UNode`:
```typescript
type UPrimitive = string | number | boolean | null;
type UElement = {
type: string; // tag name or component function
props: UniversalProps; // Record<string, PropValue | undefined>
children: UNode[];
key?: string; // extracted from props, not in props
};
type URoot = {
type: "root";
props: UniversalProps;
children: UNode[];
};
type UNode = UPrimitive | UElement | URoot;
```
### PropValue
```typescript
type PropValue = string | number | boolean | null | unknown[] | UNode | Record<string, unknown> | ((...args: unknown[]) => unknown);
```
### ComponentFn & UComponent
```typescript
type ComponentFn = (props: UniversalProps & { children?: UNode[] }) => UNode;
interface UComponent<P extends UniversalProps = UniversalProps> {
(props: P & { children?: UNode[] }): UNode;
displayName?: string;
targets?: string[];
}
```
### Type Guards
```typescript
function isUElement(node: UNode): node is UElement;
function isURoot(node: UNode): node is URoot;
function isUPrimitive(node: UNode): node is UPrimitive;
```
## HostConfig Interface
The `HostConfig<TTag, Instance, RootCtx>` interface defines how UJSX interacts with a target platform:
```typescript
interface HostConfig<TTag extends string, Instance, RootCtx> {
name: string;
createRootContext(container: unknown, options?: Record<string, unknown>, context?: Context): RootCtx;
finalizeRoot?(ctx: RootCtx): void;
createInstance(tag: TTag, props: Record<string, unknown>, ctx: RootCtx, parent?: Instance): Instance;
createTextInstance(text: string, ctx: RootCtx, parent?: Instance): Instance;
appendChild(parent: Instance, child: Instance, ctx: RootCtx): void;
insertBefore?(parent: Instance, child: Instance, before: Instance, ctx: RootCtx): void;
removeChild?(parent: Instance, child: Instance, ctx: RootCtx): void;
prepareUpdate?(instance: Instance, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): unknown | null;
commitUpdate?(instance: Instance, payload: unknown, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): void;
emit?(type: string, id: string, payload: unknown): void;
finalizeInstance?(instance: Instance, ctx: RootCtx): void;
}
```
**Type parameters:**
- `TTag` — string literal union constraining allowed element types
- `Instance` — host-specific instance type (e.g. `HTMLElement`, `Object3D`)
- `RootCtx` — host-specific root context (carries refs, handles, etc.)
**Required methods:** `name`, `createRootContext`, `createInstance`, `createTextInstance`, `appendChild`
**Optional methods:** `finalizeRoot`, `insertBefore`, `removeChild`, `prepareUpdate`, `commitUpdate`, `emit`, `finalizeInstance`
## Reconciler
The reconciler manages fiber tree diffing, key-based children reconciliation, and signal-driven updates:
| Export | Purpose |
|--------|---------|
| `scheduleUpdate(fiber, nextNode, host, ctx)` | Queue a fiber update (via `queueMicrotask`) |
| `flushUpdates(host, ctx)` | Process all pending updates |
| `reconcileProps(fiber, nextNode, host, ctx)` | Diff props using `Value.Diff` / `Value.Equal` / `Value.Hash` |
| `reconcileChildren(oldFibers, newChildren)` | Key-based classification into matched/added/removed/moves |
| `commitMutations(parentFiber, classification, commitCtx)` | Apply insertions, moves, removals to host instances |
| `commitEffects(fiber, host, ctx)` | Walk fiber tree and call `commitUpdate` for pending effects |
| `wireSignalToFiber(fiber, signalGetter, host, ctx)` | Bind a Preact signal to a fiber for automatic updates |
| `longestIncreasingSubsequence(arr)` | LIS algorithm for minimum-move reorder detection |
### Fiber Type
```typescript
interface Fiber<I> {
instance: I;
tag: string;
props: Record<string, unknown>;
key: string | undefined;
children: Fiber<I>[];
parent: Fiber<I> | null;
effect: Effect<I> | null;
signalDisposers: (() => void)[];
prevProps: Record<string, unknown> | null;
disposed: boolean;
cachedNode: UNode | null;
hash: bigint | null;
}
type Effect<I> =
| { type: "update"; payload: unknown }
| { type: "insert"; before: Fiber<I> | null }
| { type: "move"; before: Fiber<I> | null }
| { type: "remove" };
```
### Children Reconciliation
`reconcileChildren` uses key-based matching with LIS (Longest Increasing Subsequence) to minimize DOM moves:
- **Keyed children** are matched by `key` across old and new lists
- **Unkeyed children** are matched positionally (left-to-right, first-available)
- The LIS of matched indices identifies children that don't need moving
- Non-LIS matched children are marked as moves
- Unmatched old children are removed; unmatched new children are added
## Reactive Root
```typescript
class ReactiveRoot {
constructor(initial: UNode);
get value(): ReadonlySignal<UNode>;
update(fn: (current: UNode) => UNode): void;
subscribe(listener: (node: UNode) => void): () => void;
render(emit: (event: { type: string; id: string; payload: unknown }) => void): () => void;
dispose(): void;
}
```
## Context
```typescript
class Context {
constructor(initial?: Partial<ContextValue>);
get(): ContextValue;
get signal(): ReadonlySignal<ContextValue>;
set(partial: Partial<ContextValue>): void;
subscribe(fn: (value: ContextValue) => void): () => void;
fork(overrides: Partial<ContextValue>): Context;
}
type Density = "full" | "compact" | "minimal";
type Direction = "ujsx→mdast" | "mdast→ujsx" | "ujsx→jpath" | "jpath→ujsx" | "ujsx→hast" | "hast→ujsx";
interface ContextValue { density: Density; target: string; metadata: Record<string, unknown>; }
```
## Events
```typescript
interface EventEnvelope<TType extends string = string, TPayload = unknown> {
readonly type: TType;
readonly id: string;
readonly payload: TPayload;
}
interface PubSubLike<TEventMap> {
publish<TType>(type: TType, id: string, payload: TEventMap[TType]): void;
subscribe<TType>(type: TType, id: string): AsyncIterable<EventEnvelope<TType, TEventMap[TType]>>;
}
type UjsxEventMap = {
"root.render": { childCount: number };
"root.unmount": Record<string, unknown>;
"instance.create": { kind: "text" | "element"; tag?: string; value?: string; props?: Record<string, unknown> };
"component.invoke": { type: string };
"type.call": { objectName: string; methodName: string; args: unknown[] };
"transform.apply": { ruleName: string; direction: string };
};
```
## Tree Pointers
```typescript
class ValuePointer<T> {
constructor(initial: T, path?: string[]);
get value(): T;
set value(v: T);
get reactive(): ReadonlySignal<T>;
get path(): string[];
}
function selectNode(root: UNode, path: string[]): UNode | undefined;
function setNode(root: UNode, path: string[], value: UNode): UNode;
```
## TypeBox Runtime Validation
The `UJSX` export is a `Type.Module` from `@alkdev/typebox`. Use it with `Value.Check` for runtime validation:
```typescript
import { UJSX } from "@alkdev/ujsx/schema";
import { Value } from "@alkdev/typebox/value";
const UElementSchema = UJSX.Import("UElement");
Value.Check(UElementSchema, myElement); // true | false
```
Available schema keys: `UPrimitive`, `PropValue`, `UniversalProps`, `UElement`, `URoot`, `UNode`.
## Design Principles
1. **The tree is the truth. Hosts are interpreters.** UJSX defines what a tree looks like, not what it means.
2. **HTML-agnostic core.** No DOM-specific props. `onClick`, `className`, `style` are not special.
3. **TypeBox Module IS the type registry.** Runtime validation via `Value.Check`, compile-time types via TypeScript.
4. **Preact signals for reactivity.** Signal-driven updates for props, reconciliation for structure.
5. **`key` as first-class field.** Extracted from props, promoted to `UElement.key` — not stored in `props`.
## Dependencies
| Package | Version | Role |
|---------|---------|------|
| `@alkdev/typebox` | `^0.34.49` | Schema definition and runtime validation |
| `@preact/signals-core` | `^1.14.1` | Reactive primitives (`signal`, `effect`, `computed`, `batch`) |
| `@alkdev/pubsub` | `^0.1.0` | `PubSubLike` interface for event system |
## Scripts
| Command | Description |
|---------|-------------|
| `npm run build` | tsup production build (ESM + CJS) |
| `npm run build:tsc` | Type checking only (`tsc --noEmit`) |
| `npm run lint` | Type checking (`tsc --noEmit`) |
| `npm run test` | Run tests with Vitest |
| `npm run test:watch` | Vitest in watch mode |
| `npm run test:coverage` | Vitest with V8 coverage |
## License
Dual-licensed under [MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE) at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project shall be dual-licensed as above, without any additional terms or conditions.