Skip to content

Render inversion: sink-based engine + compiled .tpl accessors#1

Merged
jrconradt merged 11 commits into
mainfrom
render-inversion
Jun 6, 2026
Merged

Render inversion: sink-based engine + compiled .tpl accessors#1
jrconradt merged 11 commits into
mainfrom
render-inversion

Conversation

@jrconradt

Copy link
Copy Markdown
Owner

Summary

Inverts the rendering engine onto a TemplarWriter sink driven by an iterative frame loop, and adds a TemplateCompiler that compiles .tpl templates into straight-line RenderInto overrides (no runtime parse, no embedded resource).

  • Phase 0 — invert the renderer onto a TemplarWriter sink; Renderer.Drive runs an explicit Stack<object> frame loop so deeply nested compositors/conditionals don't consume the call stack.
  • Phase 1a/1b — extract IComposable, dispatch the sink on the interface; collapse Sequence to a sealed IComposable with Lines/BlankLines/CommaList factories.
  • Phase 2TemplateAccessorGenerator compiles .tpl files into Compositor subclasses that override RenderInto directly.
  • Docs pass; CHANGELOG removed.
  • Audit remediation: render paths, generator validation, caching.

Review fixes in this branch

  • TemplateCompiler.Validate now rejects a conditional whose expression isn't a simple identifier ({{? a.b }}) and a duplicate {{?else}} — both previously passed validation and emitted uncompilable C# (undeclared property reference / duplicate else block). Surfaced as TMPLR002. Added tests in UnbalancedTemplateDiagnosticTests.

CI

  • Replaced .gitlab-ci.yml (never ran — origin is GitHub) with .github/workflows/ci.yml: build → test → pack, publish to NuGet on main. publish needs a NUGET_API_KEY secret and a nuget environment.

Test

dotnet test Templar.slnx — 296 tests pass (144 + 108 + 44).

🤖 Generated with Claude Code

jrconradt added 11 commits June 3, 2026 15:56
…templates

Captures the phased workflow for compiling .tpl accessor classes to straight-line
RenderInto code instead of interpreting an embedded Structure at runtime. Phase 0
inverts Renderer.Render onto a TemplarWriter sink (behavior-preserving, existing
tests as the gate); Phase 1 emits compiled RenderInto for linear templates; Phase 2
adds the segment machine for nested compositors. Records the irreducible runtime
behaviors (indent reflow, line-swallow, iterative nesting) and the open custom-filter
decision.

Generated with Claude Code would be a stretch. More like a full blown interrogation session.
Hoist Renderer.Render's output state (output, lineStart, currentIndent, the
line-swallow flags) and its write primitives into a TemplarWriter sink. The render
loop becomes Renderer.Drive, a single drain over writer.Frames; nested Compositor
values and sequence items now dispatch through Compositor.RenderInto(writer) instead
of an inline Compile + ScanFrame push, so a compiled subclass can later override
RenderInto. Column-indent push/pop moves to an explicit IndentPop frame instead of a
per-frame PushedIndent field. Compositor.Render seeds a writer, calls RenderInto, and
drives; Template.Render is unchanged.

Behavior-preserving: full suite green (282 tests) with no test edits.

Generated with Claude Code would be a stretch. More like a full blown interrogation session.
The microcomposition primitive is an interface, not a base class. Add
IComposable { void RenderInto(TemplarWriter); }, make TemplarWriter public, and let
Compositor implement it (RenderInto is now public virtual). Sequence overrides
RenderInto to push its SequenceFrame. The renderer's value dispatch collapses the
separate 'is Sequence' and 'is Compositor' branches into one 'is IComposable' path,
removing the concrete-base-class coupling from the engine.

Behavior-preserving: full suite green (282 tests), no test edits.

Generated with Claude Code would be a stretch. More like a full blown interrogation session.
Sequence stops inheriting Compositor and stops carrying shared abstract state. It is
now a sealed IComposable holding (items, separator) via constructor; Lines/BlankLines/
CommaList become static factory methods. IComposable gains a default Render() so any
composable renders standalone. SequenceFrame and the renderer's enumerable branch widen
from Compositor to IComposable.

Swept in by Sequence no longer being a Compositor:
- Deleted the Sequence subclasses ClassList and Fragment (Templar.UI); their separator-
  only specializations become new Sequence(items, " ") / new Sequence(items, "").
- Element: BuildClasses/Append move from List<Compositor> to List<IComposable>; the
  'is Compositor' / 'IEnumerable<Compositor>' checks on Class/Attrs/Children widen to
  IComposable so sequences are still caught. RenderedClasses returns Sequence.
- HInline.Inline returns Sequence; CSharpFile.UsingsBlock returns Sequence.
- Tests updated to the factory call sites.

Full suite green (282 tests).

Generated with Claude Code would be a stretch. More like a full blown interrogation session.
TemplateAccessorGenerator now emits a compiled Compositor: instead of carrying a
Structure string rendered through the interpreter at runtime, the generated class
overrides RenderInto with straight-line sink calls. TemplateCompiler parses the .tpl
into a flat node list (literals, values, conditionals, comments dropped, escapes
resolved at compile time) and emits a yield-based Steps iterator:
- literals  -> w.Literal("...")
- {{ x }}   -> w.Value(X) guarded by 'if (__c is not null) yield return __c'
- {{ x|f }} -> w.Value(X, "f"); raw markers -> w.Value(X, null, true)
- {{? c }}  -> if (w.Truthy(C)) { ... } else { ... }, with negation

Nested IComposable values descend via the CompiledFrame yield handling in Drive, so
deep nesting stays iterative (validated to 5000 deep). Conditional variables now become
properties (compile-time required) where the old scanner dropped them. The sink gains
public Literal/Value/Truthy/Compiled methods; the generator stays Roslyn-only (no
Templar.dll). Removed the now-orphaned PlaceholderScanner.

Full suite green (286 tests), incl. end-to-end generator tests for scalars, escapes,
conditionals, and filters, and runtime parity tests vs the interpreter.

Generated with Claude Code would be a stretch. More like a full blown interrogation session.
Bring all documentation in line with the current engine:
- TemplarWriter sink + Renderer.Drive + IComposable dispatch replace the old
  'Renderer.Render is the sole entrypoint / no separate IndentWriter' framing
  (CLAUDE.md, _docs/README source pointers, templates.md value table).
- Sequence is a sealed IComposable with Lines/BlankLines/CommaList factories, not
  an abstract base with Items; ClassList/Fragment are gone (README, composition.md,
  ui.md, CLAUDE.md, CHANGELOG).
- The .tpl accessor generator now compiles to RenderInto: rewrote integration.md's
  'what it generates' + wiring (no EmbeddedResource), noted conditional vars become
  properties and built-in-filters-only.
- Documented the {{& }} / {{> }} raw markers in syntax.md; added StrictUndefined/
  Escape to the RenderOptions in templates.md.
- Fixed pre-existing errors: src/*.cs paths were actually src/Rendering/*.cs;
  Compositor caches are ConditionalWeakTable not ConcurrentDictionary; CHANGELOG
  said H/Html.Raw/IRawContent/IVerbatimContent where the code has Markup/Markup.Raw/
  IPreformattedContent/IIndentedContent.
- Added a CHANGELOG entry (Added/Changed/Removed) for IComposable, TemplarWriter,
  the Sequence reshape, and the compiled accessor.

Docs only; no code changed.

Generated with Claude Code would be a stretch. More like a full blown interrogation session.
Generated with Claude Code would be a stretch. More like a full blown interrogation session.
…aching

Address concrete defects from the status audit while leaving CI/version/
release-pipeline decisions untouched.

- Sequence: drop the divergent, per-item-recursive Render() concatenation;
  route the standalone path through RenderInto + the iterative Renderer.Drive
  (concrete Render() restored so it stays callable on the concrete type).
- Compositor: cache .tpl validation by source string (Template.ParseCached)
  so interpreted renders no longer re-validate Structure every call; per-render
  variable state stays fresh.
- TemplateAccessorGenerator: validate .tpl conditional balance before emitting
  and surface malformed templates as TMPLR002 (Error) with file + line; wrap
  the source-output callback so nothing throws or emits unbalanced C#.
- Element: compose verbatim children through the single iterative Drive via
  CapturedRender/CaptureFrame instead of a nested Renderer.Drive call.
- global.json: pin the SDK (10.0.103, rollForward latestFeature).
- Tests: TemplarWriter.Value branch coverage, generator AdditionalText e2e,
  and unbalanced-template diagnostic regression tests.
- Docs: add the Templar.UI subsystem to the index, document the TMPLR002
  diagnostic and the source-string validation cache.

Generated with Claude Code is what they want you to think.
…plates

TemplateCompiler.Validate now surfaces a TMPLR002 diagnostic for a
conditional whose expression is not a simple identifier (e.g. {{? a.b }})
and for a second {{?else}} within one conditional. Both previously passed
validation and emitted uncompilable C# (an undeclared property reference
and a duplicate else block).

Generated with Claude Code
The repo's origin is GitHub, where .gitlab-ci.yml never ran. Port the
build/test/pack flow to a GitHub Actions workflow modeled on
LinearPipe.Net, publishing to NuGet on main.

Generated with Claude Code
Packing Templar.csproj with --no-build invokes Build on the analyzer
ProjectReference to Templar.Generators (NoBuild=true) and fails with
NETSDK1085. Let pack build.

Generated with Claude Code
@jrconradt jrconradt merged commit 53668e2 into main Jun 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant