Jay-Html-Compiler Refactor
Design Log #118 — jay-html-compiler.ts Refactor
Written for AI agents. See Log Methodology Note below for details.
Background
jay-html-compiler.ts is 4,813 lines containing 5 compilation targets (element, bridge, hydrate, server, sandbox) mixed together with shared utilities. The file is difficult to navigate and — more critically — contains duplicated algorithms across targets that drift out of sync when features are added.
Problem
Two interrelated problems:
- File size: 4,813 lines in a single file makes navigation and ownership difficult
- Cross-target duplication: Similar algorithms are copy-pasted across targets with subtle inconsistencies already present (e.g., missing validation in server slowForEach, inconsistent null-safety in child filtering)
The duplication means adding a new directive or feature requires updating 3-4 places with no compiler enforcement that they stay consistent.
Duplicated Patterns Identified
| Pattern | Occurrences | Example inconsistency |
|---|---|---|
| Child node filtering (remove whitespace text) | 14x | Element uses _.innerText.trim(), hydrate/server use (_.innerText || '').trim() |
| Accessor validation (parse → JayUnknown → isArray) | 8x | — |
| Attribute skip list | 4x | Server adds data-jay-dynamic, others don't |
| forEach setup (validate + child vars + fragment) | 4x | — |
| Headless instance setup (find import, names, coords) | 3x | — |
| Contract type replacement (2→5 param) | 3x | Definition file uses string match, others use regex |
| renderNestedComponent | 2x | Element has secureChildComp branch, bridge doesn't |
| Headless coordinate extraction | 3x | Nearly identical across element, hydrate, server |
| Headless ref map building (collectContractRefs) | 2x | Identical in element and hydrate |
Design
Extract shared algorithms into reusable helpers FIRST (small, safe steps), then split the file by target. Each step is independently testable — run yarn test in the package after each.
Shared helpers to extract
All go into jay-html-compiler-shared.ts:
a) filterContentNodes(nodes) — replaces 14 occurrences of the child-filtering pattern. Uses the safe (_.innerText || '').trim() variant everywhere.
b) isDirectiveAttribute(attrCanonical) — replaces the 4 skip-list checks. Returns true for if, forEach, trackBy, ref, slowForEach, jayIndex, jayTrackBy, jay-coordinate-base, data-jay-dynamic, and the async directives. Each call site can pass an optional extra set for target-specific additions.
c) validateForEachAccessor(forEach, variables) — replaces the 4 forEach validation blocks. Returns { accessor, variables, fragment, error? }.
d) validateSlowForEachAccessor(arrayName, variables) — replaces the 2 slowForEach validation blocks (and adds the missing validation to server target).
e) validateAsyncAccessor(property, variables) — replaces the 2 async validation blocks.
f) replaceContractType(renderedElement, baseName, jayFile) — replaces the 3 contract-type replacement blocks.
g) resolveHeadlessImport(contractName, headlessImports) — replaces the 3 "find import or return error" blocks.
h) generateHeadlessTypeNames(contractName, idx) — replaces the 3 blocks that compute pascal, componentSymbol, renderFnName, type names.
i) extractHeadlessCoordinate(element, contractName) — replaces the 3 blocks that read jay-coordinate-base, split segments, extract suffix.
j) buildContractRefMap(refsTree) — replaces the 2 identical collectContractRefs implementations.
Implementation Plan
Phase 1: Extract leaf helpers (no structural changes)
Each step: extract one helper, replace all call sites, run tests.
Step 1: filterContentNodes
- Create
jay-html-compiler-shared.tswith this function - Replace all 14 occurrences
- Fix inconsistency: use safe
(_.innerText || '').trim()everywhere - Validate:
yarn test
Step 2: isDirectiveAttribute
- Add to shared module
- Replace 4 skip-list blocks
- Validate:
yarn test
Step 3: validateForEachAccessor
- Add to shared module
- Replace 4 forEach validation blocks
- Validate:
yarn test
Step 4: validateSlowForEachAccessor
- Add to shared module
- Replace 2 slowForEach validation blocks
- Validate:
yarn test
Step 5: validateAsyncAccessor
- Add to shared module
- Replace 2 async validation blocks
- Validate:
yarn test
Step 6: replaceContractType
- Add to shared module
- Replace 3 contract-type replacement blocks
- Validate:
yarn test
Step 7: Headless helpers (resolveHeadlessImport, generateHeadlessTypeNames, extractHeadlessCoordinate, buildContractRefMap)
- Add all 4 to shared module
- Replace call sites in element, hydrate, server targets
- Validate:
yarn test
Phase 2: Extract phase helpers
Step 8: jay-html-compiler-phase.ts
- Move
buildInteractivePaths,textHasInteractiveBindings,extractConditionIdentifiers,conditionIsInteractive,simplifyConditionForHydrate - Update imports in main file
- Validate:
yarn test
Phase 3: Split by target
Each step: move one target's functions to its own file, update imports, keep public API in main file.
Step 9: Extract server target → jay-html-compiler-server.ts
- Move:
ServerContext,getCoordinateExpr,w,renderServerNode,renderServerElement,renderServerHeadlessInstance,renderServerElementContent,renderServerOpenTag,renderServerAttributes,renderServerNodeAsString,renderServerElementAsString,renderServerForEachAsString,renderServerAttributesAsString,collectAsyncGroups,renderServerAsyncGroup,hasDynamicAttributeBindings,hasInteractiveChildElements,hasMixedContentDynamicText,hasMixedContentDynamicTextInteractive,mergeServerFragments,voidElements - Keep
generateServerElementFilein main file (thin orchestrator) or move with it - Validate:
yarn test
Step 10: Extract hydrate target → jay-html-compiler-hydrate.ts
- Move:
HydrateContext,mergeHydrateFragments,renderHydrateNode,buildRenderContext,renderHydrateElement,renderHydrateHeadlessInstance,renderHydrateElementContent,renderHydrate - Keep
generateElementHydrateFilein main file or move with it - Validate:
yarn test
Step 11: Extract bridge/sandbox target → jay-html-compiler-bridge.ts
- Move:
renderElementBridgeNode,renderBridge,renderSandboxRoot - Keep
generateElementBridgeFile,generateSandboxRootFilein main file or move with it - Validate:
yarn test
Step 12: Extract element target → jay-html-compiler-element.ts
- Move:
renderNode(with nested functions),renderFunctionImplementation,generatePhaseSpecificTypes,generateRecursiveFunctions - Keep
generateElementFile,generateElementDefinitionFilein main file or move with it - Validate:
yarn test
Step 13: Thin main file
jay-html-compiler.tsbecomes a thin orchestrator: public API functions + re-exports- All internal logic lives in the target-specific modules
- Validate:
yarn test
Phase 4: Full validation
Step 14: Full project validation
yarn confirmfrom root (rebuild + type check + test + format)
Verification Criteria
- All existing tests pass without modification at every step
- Public API unchanged —
index.tsexports remain the same - No duplicated algorithm remains across target files — shared patterns live in shared module
- Each target file is self-contained: one file per compilation target
yarn confirmpasses at the end
Trade-offs
- More files: 7 files instead of 1 — but each is 250-1300 lines and focused
- Import overhead: Target files import from shared module — but this is standard module organization
- Shared helpers may over-abstract: We keep helpers concrete (return the same types, use the same signatures) rather than introducing generic abstractions
- Phase 1 helpers are the real win: Even without the file split, extracting shared algorithms prevents cross-target drift
Implementation Results
What was done
Phase 1 (Steps 1-7): Shared helper extraction — all completed.
| Helper | Occurrences replaced | Notes |
|---|---|---|
filterContentNodes |
14 | Fixed null-safety inconsistency (element target used _.innerText.trim(), now all use (_.innerText || '').trim()) |
isDirectiveAttribute |
5 | Accepts ...extra for target-specific additions (e.g. 'data-jay-dynamic' in server) |
validateForEachAccessor |
5 | (not 4 — renderServerForEachAsString had one too) |
validateSlowForEachAccessor |
3 | Added missing validation to server target (was skipping JayUnknown/isArrayType checks) |
validateAsyncAccessor |
2 | |
expandContractType |
4 | Unified definition file's string-match approach with the regex approach |
resolveHeadlessImport |
3 | |
extractHeadlessCoordinate |
2 | (element target uses different coordinate logic, not extracted) |
buildContractRefMap |
2 | |
generateHeadlessTypeNames |
skipped | The 3 targets diverge enough that a shared helper would be forced |
Also moved to shared: textEscape, propertyMapping, PROPERTY, BOOLEAN_ATTRIBUTE, attributesRequiresQuotes, COORD_ATTR.
Phase 2 (Step 8): Phase helpers — completed. Extracted to jay-html-compiler-phase.ts.
Phase 3 (Steps 9-11): Target extraction — completed for server, hydrate, and bridge.
| Target | New file | Lines |
|---|---|---|
| Server | jay-html-compiler-server.ts |
1,235 |
| Hydrate | jay-html-compiler-hydrate.ts |
1,300 |
| Bridge/Sandbox | jay-html-compiler-bridge.ts |
294 |
Steps 12-13: Element target extraction — skipped. renderNode uses deeply nested closures that capture outer function scope. Extracting it would require restructuring the function itself, not just moving code. The main file at 1,784 lines is manageable.
Step 14: Full validation — yarn confirm passes.
Deviations from design
generateServerElementFilemoved to the server file (not kept in main).index.tsimports directly fromjay-html-compiler-server.ts.generateElementHydrateFile,generateElementBridgeFile,generateSandboxRootFilekept in main file — they sharerenderFunctionImplementationand other helpers withgenerateElementFile.isValidationErrormade generic (<T>(result: T | RenderFragment)) to work with all union return types.- Two
itemTypeaccesses needed explicit cast toJayPromiseTypeafter validation (the helper returnsAccessorwhich loses the type narrowing fromisPromiseType).
Final file structure
jay-html-compiler.ts 1,784 lines Element target + shared renderers + public generators
jay-html-compiler-shared.ts 284 lines Shared utilities (filtering, validation, constants)
jay-html-compiler-phase.ts 97 lines Phase-aware helpers (interactive path detection)
jay-html-compiler-bridge.ts 294 lines Bridge/sandbox compilation target
jay-html-compiler-hydrate.ts 1,300 lines Hydrate compilation target
jay-html-compiler-server.ts 1,235 lines Server compilation target
Verification
- All 617 tests pass (23 test files, 4 skipped)
- Zero type errors in lib/ files
yarn confirmpasses (rebuild + type check + test + format)- Public API unchanged —
index.tsexports the same symbols
Bug fix: SVG attribute case preservation (2026-08-26)
All renderAttributes functions (renderAttributes, renderDynamicAttributes, renderServerAttributes, renderServerAttributesAsString) lowercased attribute names via attrCanonical and used that as the output key. This broke SVG attributes like viewBox → viewbox, preserveAspectRatio → preserveaspectratio which are case-sensitive in the SVG namespace.
Fix: Use attrCanonical (lowercased) only for directive checks (if, forEach, trackBy) and propertyMapping lookups. Use the original attrName as the output key in generated code. This is correct for both HTML (case-insensitive, so original case is harmless) and SVG (case-sensitive, so original case is required).
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.