Files
ujsx/docs/architecture/transforms.md
glm-5.1 23659233ca address architecture review findings and add review document
Fixes from architecture review (4 critical, 10 warnings):

Critical:
- Fix selectNode/setNode docs to accurately describe prop-key
  navigation behavior including array support and prop-key writes
- Document RenderContext/Density exported types in host-config
- Resolve ADR dual status ambiguity with clarifying note in README
  (frontmatter status = editorial, body Status = decision)
- Effect types already addressed in prior commit

Warnings addressed:
- Add Fragment re-export note to jsx-runtime section in
  build-distribution
- Document childCtx/transformCtx helper functions in transforms.md
- Document render() accepting non-root UNode in host-config
- Add Value.Hash re-entrancy constraint to reconciler.md
- Add true-passthrough constraint and h('root') special case
  to element-factory constraints
- Add _idCounter bundling caveat note

Review document added at docs/reviews/architecture-review-2026-05-18.md
with full findings, source verification table, and recommendations.
2026-05-18 15:36:38 +00:00

8.5 KiB

status, last_updated
status last_updated
draft 2026-05-18

Transforms

The TransformRegistry, TransformRule, and TransformContext that power bi-directional tree conversion.

Overview

UJSX trees need to convert to and from multiple target formats — markdown (mdast), HTML (hast), JSON paths (jpath). The transform system provides a generic, direction-aware rule engine that finds the right handler for a node and invokes it. Rules match on both direction and an arbitrary predicate, not on type tags alone. This enables the "same registry, different direction" pattern: one set of rule definitions handles both UJSX→target and target→UJSX transforms.

The registry is intentionally generic over TInput, TOutput, and A (ancestor type). It knows nothing about UNode, UElement, or any UJSX-specific type. This allows reuse for any tree-to-tree conversion where the same rule structure applies.

TransformRule

interface TransformRule<TInput, TOutput, A> {
  name: string;
  direction: Direction;
  schema?: TSchema;
  match: (node: TInput) => boolean;
  transform: (node: TInput, ctx: TransformContext<A>, next: TransformFn<TInput, TOutput, A>) => TOutput;
  priority?: number;
}

name

Human-readable identifier. Used in error messages and in transform.apply events. Rules without meaningful names are hard to debug when the registry throws "no matching rule" errors.

direction

One of six predefined strings: "ujsx→mdast", "mdast→ujsx", "ujsx→jpath", "jpath→ujsx", "ujsx→hast", "hast→ujsx". The direction is part of the match criteria — a rule for ujsx→mdast will not match when the context direction is mdast→ujsx. This eliminates the need for separate "encode" and "decode" registries.

The Direction type is defined in context.ts, not in the transform module. This reflects that direction is a render/conversion concept that exists outside transforms — it also appears in RenderContext and event payloads.

schema

Optional TypeBox schema. When provided, it enables matchesSchema(rule.schema, node) as a match predicate. A rule author can use schema-based matching, predicate-based matching, or both. The registry does not automatically check schema during transform() — it is a convenience for rule authors to compose into their match function.

match

A predicate that returns true if this rule should handle the given node. Combined with direction, this is the full match condition. Typical implementations:

  • matchesSchema(schema, node) — TypeBox Value.Check for structural validation
  • (node) => node.type === "heading" — simple equality check
  • (node) => node.type === "heading" && node.props.level > 3 — compound logic

transform

The conversion function. Receives the node, the transform context, and a next callback. next delegates to the next matching rule in the registry — this is a chain-of-responsibility pattern:

  • Short-circuit: return a converted value without calling next. The rule handles the node completely.
  • Delegate: call next(node, ctx) to fall through to the next rule. Useful for middleware-like rules that wrap or augment another rule's output.

This pattern avoids hard-coded rule chaining — rules don't reference each other. The registry manages the chain by passing next at invocation time.

priority

Higher values are checked first. Default is 0. Rules with equal priority are checked in registration order. Priority allows "catch-all" rules (low or negative priority) to coexist with specific rules without relying on registration order.

TransformContext

interface TransformContext<A = unknown> {
  ancestors: A[];
  index: number;
  direction: Direction;
  metadata: Record<string, unknown>;
}
  • ancestors — stack of ancestor nodes, root-first. childCtx() pushes a parent onto this stack when descending. Empty at the root level.
  • index — position within the parent's child list. Used by transformAll() to pass the array index.
  • direction — the conversion direction. Matched against rule direction during transform().
  • metadata — extensible key-value bag for rules to communicate across the tree traversal. For example, a heading rule might set metadata.headingDepth for descendants to reference.

TransformRegistry

register(rule)

Adds a rule and re-sorts by priority descending. The sort happens on every registration, not just at lookup time. This is acceptable because registrations happen at setup time, not in hot loops. It guarantees that transform() always checks the highest-priority rules first.

transform(node, ctx)

Finds the first rule where rule.direction === ctx.direction && rule.match(node) returns true. Throws if no rule matches. Passes next as a callback that recursively calls transform() — this allows the matched rule to delegate to the next handler.

The "first match wins" semantics mean that priority and registration order resolve ambiguity. There is no rule composition beyond the next callback.

transformAll(nodes, ctx)

Maps transform() over an array, passing each node's index as ctx.index. This is a convenience for transforming child lists — it preserves the ancestor stack from ctx without requiring callers to manage index tracking.

Helper Functions

The transform module exports two context factory functions used alongside TransformRegistry:

ctx<A>(direction, ancestors?, index?, metadata?)

Creates a TransformContext from its arguments. The direction parameter is required; the rest default to [], 0, and {} respectively. Exported as transformCtx from the barrel (@alkdev/ujsx/transform) to avoid name collision with React's ctx naming.

childCtx<A>(parent, ctx, index)

Creates a new TransformContext with parent pushed onto the ancestors stack and index set. This is the standard way to descend into a child node during transformation — it preserves the direction and metadata from the parent context while updating traversal state.

Direction Definitions

The six directions pair into three bi-directional channels:

Channel Forward Reverse
Markdown ujsx→mdast mdast→ujsx
JSON Path ujsx→jpath jpath→ujsx
HTML ujsx→hast hast→ujsx

These are the directions currently defined. Additional directions (e.g., ujsx→dom, dom→ujsx) can be added by extending the Direction union in context.ts. The registry itself is generic and does not enumerate directions.

Known Gaps

No transform composition beyond next

Rules can only delegate via next. There is no mechanism for a rule to compose multiple sub-rules (e.g., "transform children using rule X, then apply my own logic"). Such composition must be done in application code, outside the registry.

No built-in error recovery

If transform() throws, the entire traversal aborts. There is no fallback rule, no "best effort" mode, and no way to skip a node and continue. Rule authors must handle their own error cases within the transform function.

No caching or memoization

transform() performs a linear scan of rules on every call. For large rule sets or deep trees, this could become a bottleneck. No caching of match results or memoization of previous transforms is implemented.

Constraints

  • Direction is a string union — not an enum or extensible type. Adding a new direction requires modifying the Direction type in context.ts. This is intentional: directions define the conversion contract and should be explicitly enumerated.
  • Priority is numeric — there is no guaranteed order between rules with the same priority beyond registration order. Rule authors should assign distinct priorities when order matters.
  • The registry is generic — it has no knowledge of UNode, UElement, or any UJSX type. The same registry class could transform between any tree formats. UJSX-specific semantics live in the rules, not in the registry.
  • matchesSchema is a standalone function — it is not called automatically by transform(). Rule authors opt into schema matching by including it in their match predicate.
  • next is a recursive call — calling next from within transform re-enters transform() with the same node. This is correct behavior (it finds the next matching rule), but rule authors must ensure their match predicate excludes the current rule to avoid infinite recursion.

References

  • Source: src/transform/registry.ts
  • Direction type: src/core/context.ts
  • TypeBox Value.Check: @alkdev/typebox