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:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.js.map
|
||||
.env
|
||||
.env.*
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
coverage/
|
||||
32
deno.json
Normal file
32
deno.json
Normal 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
46
deno.lock
generated
Normal 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
2933
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
149
package.json
Normal file
149
package.json
Normal 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
54
src/core/context.ts
Normal 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
51
src/core/events.ts
Normal 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
49
src/core/h.ts
Normal 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
2
src/core/jsx-runtime.ts
Normal 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
70
src/core/pointer.ts
Normal 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
93
src/core/reactive.ts
Normal 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
79
src/core/schema.ts
Normal 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
110
src/host/config.ts
Normal 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
23
src/mod.ts
Normal 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
63
src/transform/registry.ts
Normal 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
305
test/mod.test.ts
Normal 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
28
tsconfig.json
Normal 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
21
tsup.config.ts
Normal 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
19
vitest.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user