Skip to content

move to Convention based templates - #74

Merged
SimonCropp merged 18 commits into
mainfrom
convention-based-templates
Aug 6, 2026
Merged

move to Convention based templates#74
SimonCropp merged 18 commits into
mainfrom
convention-based-templates

Conversation

@SimonCropp

@SimonCropp SimonCropp commented Aug 5, 2026

Copy link
Copy Markdown
Member

Parchment 5.0.0

Templates are now found by convention, embedded into your assembly at compile time, and rendered by model type. There is no registration call, no template file to deploy, and no template name to repeat. Model binding is entirely source-generated — the runtime reflection layer is gone.

This is a breaking release. Every consumer needs the migration steps below.

Templates are found by convention

[ParchmentModel] takes no arguments. The template is the AdditionalFiles entry named after the model type — Invoice.docx or Invoice.md — wherever it sits in the project.

// before
[ParchmentModel("Templates/invoice.docx")]
public partial class Invoice {}

// after — rename the file to Templates/Invoice.docx
[ParchmentModel]
public partial class Invoice {}

No match is PARCH004; more than one (a .docx and a .md, or namesakes in different folders) is PARCH020. Both are reported rather than guessed at.

Templates are embedded; nothing to register, nothing to deploy

The generator embeds the template into the generated source and registers it from a [ModuleInitializer] when your assembly loads. A TemplateStore materializes it on the model's first render.

// before
var store = new TemplateStore();
Invoice.RegisterWith(store, basePath: AppContext.BaseDirectory);
await store.Render("Invoice", invoice, stream);

// after
var store = new TemplateStore();
await store.Render(invoice, stream);

The generated RegisterWith, TemplatePath and TemplateName members are removed, as is the <ParchmentEmbeddedTemplate> MSBuild item and its manifest-resource staging — every template is embedded now. <ParchmentTemplate> remains, and no longer sets CopyToOutputDirectory.

Rendering is keyed by model type

All name-based overloads are removed. A model has exactly one template; re-registering replaces it.

store.RegisterDocxTemplate<Invoice>("invoice", path);   // before
store.RegisterDocxTemplate<Invoice>(path);              // after

await store.Render("invoice", model, stream);           // before
await store.Render(model, stream);                      // after

If you relied on several templates per model, give each its own model type — a thin wrapper class holding the shared data is enough. Registering against an abstract base still accepts subclass instances at render.

Style documents move to .dotx and are found by convention

A markdown template's style source is resolved in order: TypeName.dotx, else the nearest parchment.dotx walking up the directory tree from the template, else the built-in blank. Declare them as AdditionalFiles like templates; the resolved file is embedded alongside. Two TypeName.dotx matches is the new PARCH021. Docx templates are unaffected — they carry their own styles.

No runtime reflection over models

Every binding model now goes through the generator, which emits its member accessors and per-template maps at compile time. There is no reflection fallback: a model the generator never saw is rejected at registration (and at extraction) with a message naming the fix.

Hand registration is still supported for templates the generator cannot see — content produced at runtime, per-tenant templates. The model just has to be marked:

[ParchmentBindable]
public partial class Invoice {}

store.RegisterMarkdownTemplate<Invoice>(runtimeMarkdown);
await store.Render(invoice, stream);

A model that cannot carry the attribute (a third-party or generated type) is no longer registrable — wrap it in a thin partial model class.

Trimming and NativeAOT: with the reflection walks gone, model binding is fully static.

New diagnostics

PARCH021 model matches more than one style document
PARCH022 [EditableField] collection element isn't constructable, has no editable members, or nests another editable collection
PARCH023 conflicting [Html] / [Markdown] / [StringSyntax] markers on one member

PARCH022 and PARCH023 were previously runtime registration exceptions; they now fail the build. PARCH019 (render attribute on a static member) is compile-time only — the corresponding ILogger warning at registration is removed.

Fixed

Four model-binding bugs surfaced while making the generator the only path — all of them affected [ParchmentModel] users already:

  • Array-typed members emitted accessors for System.Array's own members, producing source that could not compile.
  • Dotted map keys were mangled under the netstandard2.0 analyzer build (StringBuilder.AppendJoin binding to the params-object overload).
  • Repeating-section element factories used new T(), which cannot compile for element types with required members.
  • IDictionary<K,V> members did not bind on the generated path; KeyValuePair is now part of the emitted shape.

Also

  • ParchmentExtractor.Extract<T> distinguishes "model was never source-generated" from "model declares no [EditableField] members" instead of reporting the latter for both.
  • Internal Resolve* helpers across the runtime and generator moved to the Try* + [NotNullWhen(true)] pattern.

…ates

Breaking change simplifying the whole registration surface:

- TemplateStore is keyed by model type. Named registration and the
  name-based Render overloads are gone; Render<TModel> is the whole
  render surface, and a template registered against an abstract base
  accepts subclass instances.
- ParchmentModelAttribute is parameterless. The template is found by
  convention: the AdditionalFiles entry named after the type
  (TypeName.docx or TypeName.md). None is PARCH004, more than one is
  PARCH020.
- Style documents move to .dotx, markdown flow only: TypeName.dotx wins
  (two of them is the new PARCH021), else the nearest parchment.dotx up
  the directory tree, else the built-in blank.
- The generator embeds templates into the generated source (docx/dotx
  as base64, markdown as a string literal) and registers them from a
  module initializer. RegisterWith, TemplatePath/TemplateName, basePath,
  CopyToOutputDirectory and the ParchmentEmbeddedTemplate staging are
  gone; a TemplateStore materializes the stored definition on the
  model's first render, under its own image policies.
- SG snapshots scrub the embedded base64 (OPC packages are not
  byte-reproducible); the byte-level assertions live in
  TemplateConventionTests. The old paragraph-level docx caching test is
  inverted: byte changes must re-emit or the embedded copy goes stale.
TryResolveMember(type, name, out memberType) with [NotNullWhen(true)],
so callers branch on the bool instead of null-checking, and the path
walks in ReferenceValidator / MarkdownReferenceValidator collapse the
scope lookup and the member resolve into one guard.
ShapeResolver.ResolveMember becomes TryResolveMember with a
[NotNullWhen(true)] out MemberEntry, matching the runtime
ModelValidator.TryResolveMember shape; the three generator call sites
fold the null check into the guard. ModelSymbolResolver.ResolveMember
had no callers left and is deleted.
ModelValidator.TryResolveElementType and
ModelSymbolResolver.TryGetElementType carried the Try prefix but
returned nullable; both now use bool + [NotNullWhen(true)] out,
matching EditableMap.TryGetElementType, and the callers fold the null
checks into their guards.
TryResolveRoot(modelType, name, scope, out rootType) with
[NotNullWhen(true)], so Validate throws straight from the guard.
TryResolvePathType with [NotNullWhen(true)] in ReferenceValidator and
MarkdownReferenceValidator; the loop-source callers fold the null check
into their guards.
Resolve becomes TryResolve and GetElementType becomes TryGetElementType,
both with [NotNullWhen(true)] out string FQNs. The generator and
MarkdownValidator call sites fold their null checks into guards — the
loop-source resolve-then-element chain in each validator collapses to
one condition — and ShapeResolverTests assert the bool.
TryResolveElementType(source, loopVariable, out elementType): false is
the untyped-source case (range, assign target) where the loop variable
is accepted unchecked; a resolvable-but-not-enumerable source still
throws. VisitForStatement branches on the result, typed case first.
The "One template per model" section demonstrated manual registration
as if it were the primary flow, when the recommended path has no
registration call at all. It now opens with the [ParchmentModel] +
convention + render shape and demotes RegisterMarkdownTemplate to what
it is — the hatch for runtime-produced templates and non-partial
models. nuget-readme reordered the same way: generator first,
registering by hand second.
The source generator is now the only path for model metadata. A model
bound to a compile-time template carries [ParchmentModel]; a model
registered by hand against a runtime-supplied template carries the new
[ParchmentBindable], whose module initializer registers the same
pre-compiled accessors and per-template maps without embedding a
template. SharedFluid.EnsureModelRegistered rejects a model the
generator never saw; the maps resolve precompiled-or-empty.

Deleted outright: SharedFluid.RegisterTypeGraph, the WalkType
reflection walks in ExcelsiorTableMap / FormatMap / StringListMap /
EditableMap (setter building, NullabilityInfoContext, collection
construction), ModelGraph, and StaticRenderAttributes with its logger
warnings. Registration-time token validation stays reflective on
purpose - a runtime template still has to be checked against the model.

Shape rules the deleted walks enforced move to compile time:
- PARCH022: editable collection element must be constructable, carry an
  editable member, and not nest a further editable collection.
- PARCH023: conflicting [Html]/[Markdown]/[StringSyntax] markers.
Their runtime negative tests are deleted; SG tests cover both, plus the
bindable emission, ParchmentModel-wins, and inheritance-chain cases.

Migration surfaced three latent SG bugs, fixed here: array types walked
System.Array members into illegal accessors, StringBuilder.AppendJoin
under the netstandard2.0/Polyfill build stringified the path list, and
the repeating-section element factory used `new T()`, which cannot
compile for elements with required members (now Activator, matching the
old runtime factory). KeyValuePair<K,V> is special-cased in the shape
so dictionary iteration binds on the SG path; the root accessor block
is always emitted so even a member-less model registers as SG-known.

Test models across Parchment.Tests, Morph.Tests and IntegrationTests
now carry [ParchmentBindable] with the generator wired into each
project; obsolete runtime-only negatives (StaticRenderAttributeTests,
shape-error registration throws) are removed in favour of the compile-
time diagnostics.
An empty editable map has two causes and only one of them was named:
a model the generator never saw now gets the "no pre-compiled Parchment
accessors" message (via SharedFluid.IsModelRegistered / the shared
NotGeneratedMessage) instead of the misleading "declares no
[EditableField] members" that a genuinely field-less model earns. The
extractor also drops its private per-type map cache — EditableMap.Build
is a single dictionary lookup now, so the second layer was pure
overhead. Tests cover both messages.
@SimonCropp SimonCropp added this to the 5.0.0 milestone Aug 5, 2026
@SimonCropp
SimonCropp merged commit 9df35ee into main Aug 6, 2026
1 of 3 checks passed
@SimonCropp
SimonCropp deleted the convention-based-templates branch August 6, 2026 01:39
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