--- status: stable last_updated: 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 ```typescript interface TransformRule { name: string; direction: Direction; schema?: TSchema; match: (node: TInput) => boolean; transform: (node: TInput, ctx: TransformContext, next: TransformFn) => 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 ```typescript interface TransformContext { ancestors: A[]; index: number; direction: Direction; metadata: Record; } ``` - **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(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(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. The `→` character in direction strings is Unicode U+2192 (RIGHTWARDS ARROW), chosen for readability over alternatives like `ujsx-to-mdast`. See [host-config.md](host-config.md) for full documentation of `Direction`, `Density`, and `RenderContext`. ## 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`