port ujsx from Deno-only to cross-platform (Node/Bun/Deno)

Add npm project configuration (package.json, tsconfig.json, tsup, vitest)
matching the taskgraph_ts conventions. All source imports changed from .ts
to .js extensions for Node16 module resolution. Tests migrated from Deno.test
to vitest. Fixed strict type errors (noUncheckedIndexedAccess). Preserved
deno.json with sloppy-imports for dual Deno/Node compatibility.

Subpath exports: schema, h, reactive, context, events, pointer, host,
transform, jsx-runtime — plus barrel export at root.

Build: ESM + CJS dual output via tsup. 22 tests passing.
This commit is contained in:
2026-05-03 08:19:49 +00:00
parent b256fc7eb5
commit 3eb1f1d896
19 changed files with 4137 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
dist/
*.js.map
.env
.env.*
.vscode/
.idea/
.DS_Store
Thumbs.db
coverage/

32
deno.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@alkdev/ujsx",
"version": "0.1.0",
"exports": {
".": "./src/mod.ts",
"./core": "./src/mod.ts",
"./schema": "./src/core/schema.ts",
"./h": "./src/core/h.ts",
"./reactive": "./src/core/reactive.ts",
"./context": "./src/core/context.ts",
"./events": "./src/core/events.ts",
"./pointer": "./src/core/pointer.ts",
"./host": "./src/host/config.ts",
"./transform": "./src/transform/registry.ts",
"./jsx-runtime": "./src/core/jsx-runtime.ts"
},
"tasks": {
"test": "npx vitest run",
"check": "deno check --sloppy-imports src/mod.ts"
},
"imports": {
"@alkdev/typebox": "npm:@alkdev/typebox",
"@alkdev/pubsub": "../../@alkdev/pubsub/src/index.ts",
"@preact/signals-core": "npm:@preact/signals-core",
"@std/assert": "jsr:@std/assert@1"
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@alkdev/ujsx"
},
"unstable": ["sloppy-imports"]
}

46
deno.lock generated Normal file
View File

@@ -0,0 +1,46 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@1": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.12",
"npm:@alkdev/typebox@*": "0.34.49",
"npm:@preact/signals-core@*": "1.14.1"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
}
},
"npm": {
"@alkdev/typebox@0.34.49": {
"integrity": "sha512-hMidpI6GlMgQMlW9KEd8I3ywgewV6mva9iJaDuBfGtgeRAGrB8yyu6T/fHmgmyQineZ8l4/1PdH/VNr3S2er2g=="
},
"@preact/signals-core@1.14.1": {
"integrity": "sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng=="
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@1",
"npm:@alkdev/typebox@*",
"npm:@preact/signals-core@*"
],
"packageJson": {
"dependencies": [
"npm:@alkdev/typebox@~0.34.49",
"npm:@preact/signals-core@^1.14.1",
"npm:@types/node@22",
"npm:@vitest/coverage-v8@^3.2.4",
"npm:tsup@^8.5.1",
"npm:typescript@^5.7.0",
"npm:vitest@^3.1.0"
]
}
}
}

2933
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

149
package.json Normal file
View File

@@ -0,0 +1,149 @@
{
"name": "@alkdev/ujsx",
"version": "0.1.0",
"description": "Universal JSX — runtime-agnostic reactive tree primitives with TypeBox schemas",
"type": "module",
"main": "./dist/mod.cjs",
"module": "./dist/mod.js",
"types": "./dist/mod.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/mod.d.ts",
"default": "./dist/mod.js"
},
"require": {
"types": "./dist/mod.d.cts",
"default": "./dist/mod.cjs"
}
},
"./schema": {
"import": {
"types": "./dist/core/schema.d.ts",
"default": "./dist/core/schema.js"
},
"require": {
"types": "./dist/core/schema.d.cts",
"default": "./dist/core/schema.cjs"
}
},
"./h": {
"import": {
"types": "./dist/core/h.d.ts",
"default": "./dist/core/h.js"
},
"require": {
"types": "./dist/core/h.d.cts",
"default": "./dist/core/h.cjs"
}
},
"./reactive": {
"import": {
"types": "./dist/core/reactive.d.ts",
"default": "./dist/core/reactive.js"
},
"require": {
"types": "./dist/core/reactive.d.cts",
"default": "./dist/core/reactive.cjs"
}
},
"./context": {
"import": {
"types": "./dist/core/context.d.ts",
"default": "./dist/core/context.js"
},
"require": {
"types": "./dist/core/context.d.cts",
"default": "./dist/core/context.cjs"
}
},
"./events": {
"import": {
"types": "./dist/core/events.d.ts",
"default": "./dist/core/events.js"
},
"require": {
"types": "./dist/core/events.d.cts",
"default": "./dist/core/events.cjs"
}
},
"./pointer": {
"import": {
"types": "./dist/core/pointer.d.ts",
"default": "./dist/core/pointer.js"
},
"require": {
"types": "./dist/core/pointer.d.cts",
"default": "./dist/core/pointer.cjs"
}
},
"./host": {
"import": {
"types": "./dist/host/config.d.ts",
"default": "./dist/host/config.js"
},
"require": {
"types": "./dist/host/config.d.cts",
"default": "./dist/host/config.cjs"
}
},
"./transform": {
"import": {
"types": "./dist/transform/registry.d.ts",
"default": "./dist/transform/registry.js"
},
"require": {
"types": "./dist/transform/registry.d.cts",
"default": "./dist/transform/registry.cjs"
}
},
"./jsx-runtime": {
"import": {
"types": "./dist/core/jsx-runtime.d.ts",
"default": "./dist/core/jsx-runtime.js"
},
"require": {
"types": "./dist/core/jsx-runtime.d.cts",
"default": "./dist/core/jsx-runtime.cjs"
}
}
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"build:tsc": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": [
"ujsx",
"jsx",
"reactive",
"signals",
"typebox",
"universal-jsx"
],
"license": "MIT OR Apache-2.0",
"dependencies": {
"@alkdev/typebox": "^0.34.49",
"@preact/signals-core": "^1.14.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"tsup": "^8.5.1",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
},
"engines": {
"node": ">=18.0.0"
}
}

54
src/core/context.ts Normal file
View File

@@ -0,0 +1,54 @@
import { signal, effect, batch, type Signal, type ReadonlySignal } from "@preact/signals-core";
export type Density = "full" | "compact" | "minimal";
export interface ContextValue {
density: Density;
target: string;
metadata: Record<string, unknown>;
}
const defaultContext: ContextValue = {
density: "full",
target: "markdown",
metadata: {},
};
export class Context {
private value: Signal<ContextValue>;
constructor(initial?: Partial<ContextValue>) {
this.value = signal<ContextValue>({ ...defaultContext, ...initial });
}
get(): ContextValue {
return this.value.value;
}
get signal(): ReadonlySignal<ContextValue> {
return this.value;
}
set(partial: Partial<ContextValue>): void {
batch(() => {
this.value.value = { ...this.value.value, ...partial };
});
}
subscribe(fn: (value: ContextValue) => void): () => void {
return effect(() => {
fn(this.value.value);
});
}
fork(overrides: Partial<ContextValue>): Context {
const forked = new Context({ ...this.value.value, ...overrides });
return forked;
}
}
export type Direction = "ujsx→mdast" | "mdast→ujsx" | "ujsx→jpath" | "jpath→ujsx" | "ujsx→hast" | "hast→ujsx";
export interface RenderContext extends ContextValue {
direction: Direction;
}

51
src/core/events.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface EventEnvelope<TType extends string = string, TPayload = unknown> {
readonly type: TType;
readonly id: string;
readonly payload: TPayload;
}
export interface PubSubLike<TEventMap extends Record<string, unknown> = Record<string, unknown>> {
publish<TType extends Extract<keyof TEventMap, string>>(
type: TType,
id: string,
payload: TEventMap[TType],
): void;
subscribe<TType extends Extract<keyof TEventMap, string>>(
type: TType,
id: string,
): AsyncIterable<EventEnvelope<TType, TEventMap[TType]>>;
}
export 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 };
};
export type UjsxEnvelope<TType extends keyof UjsxEventMap = keyof UjsxEventMap> = EventEnvelope<
TType,
UjsxEventMap[TType]
>;
export function createPubSubEmitter<TEventMap extends Record<string, unknown>>(
pubsub: PubSubLike<TEventMap>,
) {
return function emit<TType extends Extract<keyof TEventMap, string>>(
type: TType,
id: string,
payload: TEventMap[TType],
): void {
pubsub.publish(type, id, payload);
};
}
export function proxyEventEmitter(pubsub: PubSubLike<UjsxEventMap>) {
return {
onTypeCall(objectName: string, methodName: string, args: unknown[]): void {
pubsub.publish("type.call", `call_${Date.now()}`, { objectName, methodName, args });
},
};
}

49
src/core/h.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { UNode, UElement, URoot, UType, UComponent, UniversalProps } from "./schema.js";
let _idCounter = 0;
export function h(type: UType, props?: UniversalProps | null, ...children: UNode[]): UElement | URoot {
const resolvedProps: UniversalProps = props ? { ...props } : {};
const flatChildren = children.flat(Infinity as 1).filter((c: UNode) => c != null && c !== false) as UNode[];
if (type === "root") {
return {
type: "root",
props: resolvedProps,
children: flatChildren,
} as URoot;
}
return {
type: type as string,
props: resolvedProps,
children: flatChildren,
} as UElement;
}
export function createRoot(id: string | undefined, ...children: UNode[]): URoot {
return {
type: "root",
props: { id: id ?? `root_${++_idCounter}` },
children: children.flat(Infinity as 1).filter((c: UNode) => c != null && c !== false) as UNode[],
};
}
export function createComponent<P extends UniversalProps>(
name: string,
render: (props: P) => UNode,
targets?: string[],
): UComponent<P> {
const c = render as unknown as UComponent<P>;
c.displayName = name;
c.targets = targets;
return c;
}
export function Fragment(props: { children?: UNode[] }): UNode[] {
return ((props.children ?? []) as UNode[]).flat(Infinity as 1).filter((c: UNode) => c != null && c !== false) as UNode[];
}
export const jsx = h;
export const jsxs = h;
export const jsxDEV = h;

2
src/core/jsx-runtime.ts Normal file
View File

@@ -0,0 +1,2 @@
export { jsx, jsxs, jsxDEV, Fragment } from "./h.js";
export type {} from "./schema.js";

70
src/core/pointer.ts Normal file
View File

@@ -0,0 +1,70 @@
import { signal, type Signal, type ReadonlySignal } from "@preact/signals-core";
import type { UNode, UElement } from "./schema.js";
import { isUElement } from "./schema.js";
export class ValuePointer<T> {
private _signal: Signal<T>;
private _path: string[];
constructor(initial: T, path: string[] = []) {
this._signal = signal(initial);
this._path = path;
}
get value(): T {
return this._signal.value;
}
set value(v: T) {
this._signal.value = v;
}
get reactive(): ReadonlySignal<T> {
return this._signal;
}
get path(): string[] {
return this._path;
}
}
export function selectNode(root: UNode, path: string[]): UNode | undefined {
let current: UNode | undefined = root;
for (const segment of path) {
if (current === undefined || !isUElement(current)) return undefined;
const el = current as UElement;
const index = parseInt(segment, 10);
if (!isNaN(index) && index >= 0 && index < el.children.length) {
current = el.children[index];
} else {
const propVal = el.props[segment];
if (propVal !== undefined && typeof propVal === "object" && propVal !== null) {
current = propVal as UNode;
} else {
return undefined;
}
}
}
return current;
}
export function setNode(root: UNode, path: string[], value: UNode): UNode {
if (path.length === 0) return value;
const [head, ...tail] = path;
if (!isUElement(root)) return root;
const rootEl = root as UElement;
const index = parseInt(head!, 10);
if (!isNaN(index)) {
const children = [...rootEl.children];
if (index >= 0 && index < children.length) {
children[index] = setNode(children[index]!, tail, value);
}
return { ...rootEl, children } as UElement;
}
const props = { ...rootEl.props, [head as string]: value };
return { ...rootEl, props } as UElement;
}

93
src/core/reactive.ts Normal file
View File

@@ -0,0 +1,93 @@
import { signal, computed, effect, batch, type Signal, type ReadonlySignal } from "@preact/signals-core";
import type { UNode, UElement, UniversalProps, UComponent } from "./schema.js";
export interface ReactiveNode {
readonly type: string;
readonly signal: ReadonlySignal<UNode>;
readonly dispose: () => void;
}
export function reactiveComponent<P extends UniversalProps>(
component: UComponent<P>,
propsSignal: Signal<P>,
): ReactiveNode {
const nodeSignal = computed(() => {
const props = propsSignal.value;
return component(props);
});
return {
get type() {
return component.displayName ?? "anonymous";
},
signal: nodeSignal,
dispose: () => {},
};
}
export function reactiveElement(
type: string,
propsSignal: Signal<UniversalProps>,
childrenSignals: ReadonlySignal<UNode>[],
): ReactiveNode {
const nodeSignal = computed(() => {
const children = childrenSignals.map((s) => s.value);
return {
type,
props: propsSignal.value,
children,
} as UElement;
});
return {
type,
signal: nodeSignal,
dispose: () => {},
};
}
export class ReactiveRoot {
private root: Signal<UNode>;
private renderDisposer: (() => void) | null = null;
constructor(initial: UNode) {
this.root = signal(initial);
}
get value(): ReadonlySignal<UNode> {
return this.root;
}
update(fn: (current: UNode) => UNode): void {
batch(() => {
this.root.value = fn(this.root.value);
});
}
subscribe(listener: (node: UNode) => void): () => void {
return effect(() => {
listener(this.root.value);
});
}
render(emit: (event: { type: string; id: string; payload: unknown }) => void): () => void {
this.renderDisposer = effect(() => {
const node = this.root.value;
emit({
type: "root.render",
id: `root_${Date.now()}`,
payload: node,
});
});
return () => {
if (this.renderDisposer) {
this.renderDisposer();
this.renderDisposer = null;
}
};
}
}
export { signal, computed, effect, batch };
export type { Signal, ReadonlySignal };

79
src/core/schema.ts Normal file
View File

@@ -0,0 +1,79 @@
import { Type } from "@alkdev/typebox";
export const UJSX = Type.Module({
UPrimitive: Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]),
PropValue: Type.Union([
Type.String(),
Type.Number(),
Type.Boolean(),
Type.Null(),
Type.Array(Type.Unknown()),
Type.Ref("UNode"),
Type.Record(Type.String(), Type.Unknown()),
Type.Function([...Type.Rest(Type.Array(Type.Unknown()))], Type.Unknown()),
]),
UniversalProps: Type.Object(
{},
{ additionalProperties: Type.Union([Type.Ref("PropValue"), Type.Undefined()]) },
),
UElement: Type.Object({
type: Type.String(),
props: Type.Ref("UniversalProps"),
children: Type.Array(Type.Ref("UNode")),
}),
URoot: Type.Object({
type: Type.Literal("root"),
props: Type.Ref("UniversalProps"),
children: Type.Array(Type.Ref("UNode")),
}),
UNode: Type.Union([Type.Ref("UPrimitive"), Type.Ref("UElement"), Type.Ref("URoot")]),
});
// TypeScript types — the Module is for runtime validation;
// TS types are defined directly for clean inference.
// ComponentFn types in the schema are runtime-only (not serializable).
export type UPrimitive = string | number | boolean | null;
export type PropValue = string | number | boolean | null | unknown[] | UNode | Record<string, unknown> | ((...args: unknown[]) => unknown);
export type UniversalProps = Record<string, PropValue | undefined>;
export type UElement = {
type: string;
props: UniversalProps;
children: UNode[];
};
export type URoot = {
type: "root";
props: UniversalProps;
children: UNode[];
};
export type UNode = UPrimitive | UElement | URoot;
export type ComponentFn = (props: UniversalProps & { children?: UNode[] }) => UNode;
export type UType = string | ComponentFn;
export interface UComponent<P extends UniversalProps = UniversalProps> {
(props: P & { children?: UNode[] }): UNode;
displayName?: string;
targets?: string[];
}
export function isUElement(node: UNode): node is UElement {
return typeof node === "object" && node !== null && "type" in node && "props" in node && "children" in node && node.type !== "root";
}
export function isURoot(node: UNode): node is URoot {
return typeof node === "object" && node !== null && "type" in node && node.type === "root";
}
export function isUPrimitive(node: UNode): node is UPrimitive {
return typeof node === "string" || typeof node === "number" || typeof node === "boolean" || node === null;
}
// Runtime validation using TypeBox schemas
// Use UJSX.Import("UElement") etc. at call sites with Value.Check
export { UJSX as schema };

110
src/host/config.ts Normal file
View File

@@ -0,0 +1,110 @@
import type { UNode, UElement, URoot, ComponentFn, UComponent } from "../core/schema.js";
import { isURoot, isUPrimitive } from "../core/schema.js";
import { Context } from "../core/context.js";
export 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;
}
export interface Root<TTag extends string, Instance, RootCtx> {
host: HostConfig<TTag, Instance, RootCtx>;
ctx: RootCtx;
container: unknown;
context: Context;
render(node: UNode): void;
unmount(): void;
}
export function createRoot<TTag extends string, Instance, RootCtx>(
host: HostConfig<TTag, Instance, RootCtx>,
container: unknown,
options?: Record<string, unknown>,
context?: Context,
): Root<TTag, Instance, RootCtx> {
const ctx = host.createRootContext(container, options, context);
const rootContext = context ?? new Context();
function mountNode(node: UNode, parentInst?: Instance): Instance | undefined {
if (node == null || node === false) return undefined;
if (isUPrimitive(node)) {
const text = node === null ? "" : String(node);
const t = host.createTextInstance(text, ctx, parentInst);
if (parentInst) host.appendChild(parentInst, t, ctx);
host.emit?.("instance.create", `text_${Date.now()}`, { kind: "text", value: text });
return t;
}
if (isURoot(node)) {
for (const child of node.children) {
mountNode(child, parentInst);
}
return parentInst;
}
// node must be a UElement here
const el = node as UElement;
// Function component — type is a function (runtime-only, before resolution)
if (typeof el.type === "function") {
const component = el.type as ComponentFn;
const out = component({ ...el.props, children: el.children });
host.emit?.("component.invoke", `comp_${Date.now()}`, { type: (component as unknown as UComponent).displayName ?? "anonymous" });
return mountNode(out, parentInst);
}
// Intrinsic element
const tag = el.type as TTag;
const inst = host.createInstance(tag, el.props as Record<string, unknown>, ctx, parentInst);
host.emit?.("instance.create", `${tag}_${Date.now()}`, { kind: "element", tag, props: el.props });
for (const child of el.children) {
mountNode(child, inst);
}
if (parentInst) host.appendChild(parentInst, inst, ctx);
return inst;
}
return {
host,
ctx,
container,
context: rootContext,
render(node: UNode) {
const payloadChildren = isURoot(node) ? (node as URoot).children : [node];
for (const child of payloadChildren) {
mountNode(child, undefined);
}
host.finalizeRoot?.(ctx);
host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length });
},
unmount() {
host.finalizeRoot?.(ctx);
host.emit?.("root.unmount", `root_${Date.now()}`, {});
},
};
}

23
src/mod.ts Normal file
View File

@@ -0,0 +1,23 @@
export { UJSX as schema } from "./core/schema.js";
export type { UPrimitive, PropValue, UniversalProps, UElement, URoot, UNode, ComponentFn, UType, UComponent } from "./core/schema.js";
export { isUElement, isURoot, isUPrimitive } from "./core/schema.js";
export { h, createRoot, createComponent, Fragment, jsx, jsxs, jsxDEV } from "./core/h.js";
export { reactiveComponent, reactiveElement, ReactiveRoot } from "./core/reactive.js";
export { signal, computed, effect, batch } from "./core/reactive.js";
export type { Signal, ReadonlySignal, ReactiveNode } from "./core/reactive.js";
export { Context } from "./core/context.js";
export type { Density, Direction, RenderContext } from "./core/context.js";
export { createPubSubEmitter, proxyEventEmitter } from "./core/events.js";
export type { EventEnvelope, PubSubLike, UjsxEventMap, UjsxEnvelope } from "./core/events.js";
export { ValuePointer, selectNode, setNode } from "./core/pointer.js";
export { createRoot as createHostRoot } from "./host/config.js";
export type { HostConfig, Root } from "./host/config.js";
export { TransformRegistry, childCtx, matchesSchema, ctx as transformCtx } from "./transform/registry.js";
export type { TransformContext, TransformFn, TransformRule } from "./transform/registry.js";

63
src/transform/registry.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { TSchema } from "@alkdev/typebox";
import { Value } from "@alkdev/typebox/value";
import type { Direction } from "../core/context.js";
export interface TransformContext<A = unknown> {
ancestors: A[];
index: number;
direction: Direction;
metadata: Record<string, unknown>;
}
export type TransformFn<T, U, A> = (node: T, ctx: TransformContext<A>) => U;
export interface TransformRule<TInput, TOutput, A = unknown> {
name: string;
direction: Direction;
schema?: TSchema;
match: (node: TInput) => boolean;
transform: (node: TInput, ctx: TransformContext<A>, next: TransformFn<TInput, TOutput, A>) => TOutput;
priority?: number;
}
export class TransformRegistry<TInput, TOutput, A = unknown> {
private rules: TransformRule<TInput, TOutput, A>[] = [];
register(rule: TransformRule<TInput, TOutput, A>): void {
this.rules.push(rule);
this.rules.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
}
transform(node: TInput, ctx: TransformContext<A>): TOutput {
const rule = this.rules.find((r) => r.direction === ctx.direction && r.match(node));
if (!rule) {
throw new Error(`No transform rule found for node in direction '${ctx.direction}'`);
}
return rule.transform(node, ctx, (n, c) => this.transform(n, c));
}
transformAll(nodes: TInput[], ctx: TransformContext<A>): TOutput[] {
return nodes.map((node, i) => this.transform(node, { ...ctx, index: i, ancestors: ctx.ancestors }));
}
}
export function childCtx<A>(parent: A, ctx: TransformContext<A>, index: number): TransformContext<A> {
return {
...ctx,
ancestors: [...ctx.ancestors, parent],
index,
};
}
export function matchesSchema(schema: TSchema, node: unknown): boolean {
return Value.Check(schema, node);
}
export function ctx<A>(
direction: Direction,
ancestors: A[] = [],
index = 0,
metadata: Record<string, unknown> = {},
): TransformContext<A> {
return { ancestors, index, direction, metadata };
}

305
test/mod.test.ts Normal file
View File

@@ -0,0 +1,305 @@
import { describe, it, expect } from "vitest";
import { h, createRoot, createComponent, Fragment, jsx, jsxs, jsxDEV } from "../src/core/h.js";
import { isUElement, isURoot, isUPrimitive } from "../src/core/schema.js";
import type { UNode, UElement } from "../src/core/schema.js";
import { Context } from "../src/core/context.js";
import { TransformRegistry, childCtx, ctx as transformCtx } from "../src/transform/registry.js";
import type { Direction } from "../src/core/context.js";
import { ValuePointer, selectNode, setNode } from "../src/core/pointer.js";
import { signal, computed, ReactiveRoot, reactiveComponent, reactiveElement } from "../src/core/reactive.js";
import { createPubSubEmitter } from "../src/core/events.js";
import { createRoot as createHostRoot } from "../src/host/config.js";
import type { HostConfig } from "../src/host/config.js";
describe("h()", () => {
it("creates UElement", () => {
const el = h("div", { class: "test" }, "hello");
expect(isUElement(el)).toBe(true);
if (isUElement(el)) {
expect(el.type).toBe("div");
expect(el.props.class).toBe("test");
expect(el.children).toEqual(["hello"]);
}
});
it("with null props creates empty props object", () => {
const el = h("p", null, "text");
expect(isUElement(el)).toBe(true);
if (isUElement(el)) {
expect(el.props).toEqual({});
expect(el.children).toEqual(["text"]);
}
});
it("with root type creates URoot", () => {
const root = h("root", { id: "test" }, "child1", "child2");
expect(isURoot(root)).toBe(true);
if (isURoot(root)) {
expect(root.type).toBe("root");
expect(root.props.id).toBe("test");
expect(root.children).toEqual(["child1", "child2"]);
}
});
});
describe("createRoot", () => {
it("creates URoot with id", () => {
const root = createRoot("my-root", "a", "b");
expect(isURoot(root)).toBe(true);
if (isURoot(root)) {
expect(root.type).toBe("root");
expect(root.props.id).toBe("my-root");
}
});
});
describe("Fragment", () => {
it("flattens children and removes null/false", () => {
const result = Fragment({ children: ["a", null, "b", false, "c"] as UNode[] });
expect(result).toEqual(["a", "b", "c"]);
});
});
describe("jsx runtime aliases", () => {
it("jsx/jsxs/jsxDEV are aliases for h", () => {
expect(jsx).toBe(h);
expect(jsxs).toBe(h);
expect(jsxDEV).toBe(h);
});
});
describe("createComponent", () => {
it("adds displayName and targets", () => {
const MyComp = createComponent("MyComp", (props) => h("div", null, props.text), ["markdown"]);
expect(MyComp.displayName).toBe("MyComp");
expect(MyComp.targets).toEqual(["markdown"]);
});
});
describe("type guards", () => {
it("isUElement discriminates", () => {
expect(isUElement(h("div", null))).toBe(true);
expect(isUElement("text")).toBe(false);
expect(isUElement(null)).toBe(false);
expect(isUElement(createRoot("r"))).toBe(false);
});
it("isURoot discriminates", () => {
expect(isURoot(createRoot("r"))).toBe(true);
expect(isURoot(h("div", null))).toBe(false);
});
it("isUPrimitive discriminates", () => {
expect(isUPrimitive("text")).toBe(true);
expect(isUPrimitive(42)).toBe(true);
expect(isUPrimitive(true)).toBe(true);
expect(isUPrimitive(null)).toBe(true);
expect(isUPrimitive(h("div", null))).toBe(false);
});
});
describe("Context", () => {
it("stores and updates values", () => {
const ctx = new Context({ density: "full", target: "markdown" });
expect(ctx.get().density).toBe("full");
expect(ctx.get().target).toBe("markdown");
ctx.set({ density: "compact" });
expect(ctx.get().density).toBe("compact");
expect(ctx.get().target).toBe("markdown");
});
it("fork creates independent copy", () => {
const parent = new Context({ density: "full", target: "markdown" });
const child = parent.fork({ density: "minimal" });
expect(child.get().density).toBe("minimal");
expect(parent.get().density).toBe("full");
});
});
describe("reactive pipeline", () => {
it("signal + computed", () => {
const name = signal("world");
const greeting = computed(() => `hello ${name.value}`);
expect(greeting.value).toBe("hello world");
name.value = "ujsx";
expect(greeting.value).toBe("hello ujsx");
});
it("ReactiveRoot update and subscribe", () => {
const root = new ReactiveRoot(h("div", null, "initial"));
const received: UNode[] = [];
const unsub = root.subscribe((n) => received.push(n));
root.update(() => h("div", null, "updated"));
expect(received.length).toBe(2);
unsub();
});
it("ReactiveRoot render emits events", () => {
const events: { type: string; id: string; payload: unknown }[] = [];
const root = new ReactiveRoot(h("div", null, "hello"));
const stop = root.render((e) => events.push(e));
expect(events.length).toBe(1);
expect(events[0].type).toBe("root.render");
stop();
});
it("reactiveComponent wraps component in computed signal", () => {
const MyComp = createComponent("MyComp", (props) => h("div", null, props.text as string));
const propsSignal = signal({ text: "hello" });
const reactive = reactiveComponent(MyComp, propsSignal);
expect(reactive.type).toBe("MyComp");
const node = reactive.signal.value;
expect(isUElement(node)).toBe(true);
if (isUElement(node)) {
expect(node.children[0]).toBe("hello");
}
propsSignal.value = { text: "world" };
const updated = reactive.signal.value;
expect(isUElement(updated)).toBe(true);
if (isUElement(updated)) {
expect(updated.children[0]).toBe("world");
}
});
});
describe("ValuePointer", () => {
it("stores and updates reactive values", () => {
const ptr = new ValuePointer("initial");
expect(ptr.value).toBe("initial");
ptr.value = "updated";
expect(ptr.value).toBe("updated");
});
});
describe("tree traversal", () => {
it("selectNode traverses tree by child index", () => {
const tree = h("div", null, h("p", null, "child1"), h("span", null, "child2"));
const child0 = selectNode(tree, ["0"]);
expect(isUElement(child0)).toBe(true);
if (isUElement(child0)) {
expect(child0.type).toBe("p");
}
const child1 = selectNode(tree, ["1"]);
expect(isUElement(child1)).toBe(true);
if (isUElement(child1)) {
expect(child1.type).toBe("span");
}
expect(selectNode(tree, ["9"])).toBeUndefined();
});
it("setNode updates tree immutably", () => {
const original = h("div", null, "old") as UElement;
const updated = setNode(original, ["0"], "new") as UElement;
expect(original.children[0]).toBe("old");
expect(updated.children[0]).toBe("new");
});
});
describe("TransformRegistry", () => {
it("applies bidirectional rules", () => {
const registry = new TransformRegistry<UNode, Record<string, unknown>, unknown>();
registry.register({
name: "div-ujsx-to-mdast",
direction: "ujsx→mdast" as Direction,
match: (n) => isUElement(n) && n.type === "div",
transform: (n, ctx, next) => ({
type: "paragraph",
children: (Array.isArray((n as UElement).children) ? (n as UElement).children : []).map((c: UNode, i: number) => next(c, childCtx(n, ctx, i))),
}),
priority: 1,
});
registry.register({
name: "text-ujsx-to-mdast",
direction: "ujsx→mdast" as Direction,
match: (n) => isUPrimitive(n) && typeof n === "string",
transform: (n) => ({ type: "text", value: n }),
priority: 10,
});
registry.register({
name: "paragraph-mdast-to-ujsx",
direction: "mdast→ujsx" as Direction,
match: (n) => (n as Record<string, unknown>).type === "paragraph",
transform: (n, ctx, next) => {
const mdNode = n as { children: unknown[] };
return h("div", null, ...mdNode.children.map((c, i) => next(c as UNode, childCtx(n, ctx, i))));
},
priority: 1,
});
registry.register({
name: "text-mdast-to-ujsx",
direction: "mdast→ujsx" as Direction,
match: (n) => (n as Record<string, unknown>).type === "text",
transform: (n) => (n as unknown as { value: string }).value,
priority: 10,
});
const ujsxNode = h("div", null, "hello") as UNode;
const mdastCtx = transformCtx("ujsx→mdast" as Direction);
const mdastResult = registry.transform(ujsxNode, mdastCtx);
expect((mdastResult as Record<string, unknown>).type).toBe("paragraph");
const mdastNode = { type: "paragraph", children: [{ type: "text", value: "hello" }] } as unknown as UNode;
const ujsxCtx = transformCtx("mdast→ujsx" as Direction);
const ujsxResult = registry.transform(mdastNode, ujsxCtx);
expect(isUElement(ujsxResult)).toBe(true);
if (isUElement(ujsxResult)) {
expect(ujsxResult.type).toBe("div");
}
});
});
describe("HostConfig", () => {
it("createRoot and render", () => {
const instances: { tag: string; props: Record<string, unknown> }[] = [];
const texts: string[] = [];
const events: { type: string; payload: unknown }[] = [];
const testHost: HostConfig<string, string, Record<string, unknown>> = {
name: "test",
createRootContext: () => ({}),
createInstance: (tag, props) => {
instances.push({ tag, props });
return tag;
},
createTextInstance: (text) => {
texts.push(text);
return text;
},
appendChild: () => {},
emit: (type, _id, payload) => events.push({ type, payload }),
};
const root = createHostRoot(testHost, {});
root.render(h("div", { class: "outer" }, "hello", h("span", null, "world")));
expect(instances.length).toBe(2);
expect(instances[0]!.tag).toBe("div");
expect(instances[1]!.tag).toBe("span");
expect(texts.sort()).toEqual(["hello", "world"]);
expect(events.some((e) => e.type === "root.render")).toBe(true);
});
});
describe("pubsub emitter", () => {
it("bridges to event envelope", () => {
const published: { type: string; id: string; payload: unknown }[] = [];
const mockPubSub = {
publish: <TType extends string>(type: TType, id: string, payload: unknown) => {
published.push({ type, id, payload });
},
subscribe: async function* () {},
};
const emit = createPubSubEmitter(mockPubSub);
emit("root.render", "test-1", { childCount: 3 });
emit("root.render", "test-2", { childCount: 1 });
expect(published.length).toBe(2);
expect(published[0]!.type).toBe("root.render");
expect(published[0]!.payload).toEqual({ childCount: 3 });
});
});

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"erasableSyntaxOnly": true,
"jsx": "react-jsx",
"jsxImportSource": "@alkdev/ujsx"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}

21
tsup.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
mod: 'src/mod.ts',
'core/schema': 'src/core/schema.ts',
'core/h': 'src/core/h.ts',
'core/reactive': 'src/core/reactive.ts',
'core/context': 'src/core/context.ts',
'core/events': 'src/core/events.ts',
'core/pointer': 'src/core/pointer.ts',
'core/jsx-runtime': 'src/core/jsx-runtime.ts',
'host/config': 'src/host/config.ts',
'transform/registry': 'src/transform/registry.ts',
},
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
clean: true,
target: 'es2022',
});

19
vitest.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vitest/config';
import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
test: {
globals: true,
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/**/index.ts'],
},
},
});