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:
358
README.md
Normal file
358
README.md
Normal 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.
|
||||
Reference in New Issue
Block a user