diff --git a/internal/services/docforge_shims.go b/internal/services/docforge_shims.go index 3599ce5..1438f76 100644 --- a/internal/services/docforge_shims.go +++ b/internal/services/docforge_shims.go @@ -13,15 +13,20 @@ package services // refactored to call docforge directly through the neutral model and the // VariableResolver interface. -import "mgit.msbls.de/m/paliad/pkg/docforge/docx" +import ( + "mgit.msbls.de/m/paliad/pkg/docforge" + "mgit.msbls.de/m/paliad/pkg/docforge/docx" +) // PlaceholderMap is the variable bag (dotted-key → substituted value), -// built by SubmissionVarsService and consumed by the renderer. -type PlaceholderMap = docx.PlaceholderMap +// built by SubmissionVarsService and consumed by the renderer. The +// canonical type lives in the docforge root (the format-neutral +// variable-bag contract). +type PlaceholderMap = docforge.PlaceholderMap // MissingPlaceholderFn translates an unbound placeholder key into the // in-document marker token. -type MissingPlaceholderFn = docx.MissingPlaceholderFn +type MissingPlaceholderFn = docforge.MissingPlaceholderFn // SubmissionRenderer renders a .docx template by substituting // {{placeholder}} tokens. Stateless; safe for concurrent use. @@ -36,7 +41,9 @@ func NewSubmissionRenderer() *SubmissionRenderer { return docx.NewSubmissionRend // DefaultMissingMarker returns the standard missing-value marker for the // given UI language ("[KEIN WERT: ]" / "[NO VALUE: ]"). -func DefaultMissingMarker(lang string) MissingPlaceholderFn { return docx.DefaultMissingMarker(lang) } +func DefaultMissingMarker(lang string) MissingPlaceholderFn { + return docforge.DefaultMissingMarker(lang) +} // RenderMarkdownToOOXML renders Markdown source into OOXML paragraph // elements using a single paragraph style. diff --git a/internal/services/submission_vars.go b/internal/services/submission_vars.go index 5f237b9..c9fde89 100644 --- a/internal/services/submission_vars.go +++ b/internal/services/submission_vars.go @@ -47,6 +47,7 @@ import ( "mgit.msbls.de/m/paliad/internal/branding" "mgit.msbls.de/m/paliad/internal/models" + "mgit.msbls.de/m/paliad/pkg/docforge" ) // SubmissionVarsService assembles the placeholder map. @@ -151,17 +152,20 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont if lang == "" { lang = "de" } - bag := PlaceholderMap{} - addFirmVars(bag) - addTodayVars(bag, time.Now()) - addUserVars(bag, user) - addRuleVars(bag, rule, lang) + // firm / today / user / procedural_event apply to every render, + // project-bound or not. Each resolver wraps the matching addXxxVars + // builder (unchanged); ResolverSet.BuildBag runs them into one bag. + resolvers := []docforge.VariableResolver{ + firmResolver{}, + todayResolver{now: time.Now()}, + userResolver{user: user}, + proceduralEventResolver{rule: rule, lang: lang}, + } out := &SubmissionVarsResult{ - Placeholders: bag, - User: user, - Rule: rule, - Lang: lang, + User: user, + Rule: rule, + Lang: lang, } if in.ProjectID == nil { @@ -169,6 +173,7 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont // deadline state to resolve. The lawyer's overrides will fill // the placeholder map; missing keys render as // [KEIN WERT: …] / [NO VALUE: …] in the preview. + out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag() return out, nil } @@ -195,14 +200,17 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont return nil, err } - addProjectVars(bag, project, pt, lang) - addPartyVars(bag, filterPartiesBySelection(parties, in.SelectedParties)) - addDeadlineVars(bag, next, project, lang) + resolvers = append(resolvers, + projectResolver{project: project, pt: pt, lang: lang}, + partiesResolver{parties: filterPartiesBySelection(parties, in.SelectedParties)}, + deadlineResolver{deadline: next, project: project, lang: lang}, + ) out.Project = project out.ProceedingType = pt out.Parties = parties out.NextDeadline = next + out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag() return out, nil } diff --git a/internal/services/submission_vars_resolvers.go b/internal/services/submission_vars_resolvers.go new file mode 100644 index 0000000..05e6d39 --- /dev/null +++ b/internal/services/submission_vars_resolvers.go @@ -0,0 +1,87 @@ +package services + +// Variable resolvers — the paliad-side implementations of +// docforge.VariableResolver (t-paliad-349 slice 3). Each wraps one of the +// addXxxVars push-builders, capturing the entity it needs, so the proven +// builder bodies stay byte-for-byte unchanged while the composition moves +// behind the docforge.ResolverSet seam. SubmissionVarsService.Build wires +// the applicable resolvers and calls ResolverSet.BuildBag(). +// +// These live in paliad (not docforge) because they read paliad's domain +// model — branding, user, project, parties, deadline_rules, deadlines. A +// second docforge consumer implements its own resolvers against its own +// data and plugs them into a ResolverSet the same way. + +import ( + "time" + + "mgit.msbls.de/m/paliad/internal/models" + "mgit.msbls.de/m/paliad/pkg/docforge" +) + +// Compile-time conformance: each resolver satisfies docforge.VariableResolver. +var ( + _ docforge.VariableResolver = firmResolver{} + _ docforge.VariableResolver = todayResolver{} + _ docforge.VariableResolver = userResolver{} + _ docforge.VariableResolver = proceduralEventResolver{} + _ docforge.VariableResolver = projectResolver{} + _ docforge.VariableResolver = partiesResolver{} + _ docforge.VariableResolver = deadlineResolver{} +) + +// firmResolver populates firm.* from process-wide branding. +type firmResolver struct{} + +func (firmResolver) Namespace() string { return "firm" } +func (firmResolver) Populate(bag PlaceholderMap) { addFirmVars(bag) } + +// todayResolver populates today.* from the build-time clock. +type todayResolver struct{ now time.Time } + +func (todayResolver) Namespace() string { return "today" } +func (r todayResolver) Populate(bag PlaceholderMap) { addTodayVars(bag, r.now) } + +// userResolver populates user.* from the caller's row. +type userResolver struct{ user *models.User } + +func (userResolver) Namespace() string { return "user" } +func (r userResolver) Populate(bag PlaceholderMap) { addUserVars(bag, r.user) } + +// proceduralEventResolver populates procedural_event.* and the legacy +// rule.* alias from the published deadline_rule. +type proceduralEventResolver struct { + rule *models.DeadlineRule + lang string +} + +func (proceduralEventResolver) Namespace() string { return "procedural_event" } +func (r proceduralEventResolver) Populate(bag PlaceholderMap) { addRuleVars(bag, r.rule, r.lang) } + +// projectResolver populates project.* from the project + its proceeding type. +type projectResolver struct { + project *models.Project + pt *models.ProceedingType + lang string +} + +func (projectResolver) Namespace() string { return "project" } +func (r projectResolver) Populate(bag PlaceholderMap) { addProjectVars(bag, r.project, r.pt, r.lang) } + +// partiesResolver populates parties.* from the (already filtered) party list. +type partiesResolver struct{ parties []models.Party } + +func (partiesResolver) Namespace() string { return "parties" } +func (r partiesResolver) Populate(bag PlaceholderMap) { addPartyVars(bag, r.parties) } + +// deadlineResolver populates deadline.* from the next pending deadline. +type deadlineResolver struct { + deadline *models.Deadline + project *models.Project + lang string +} + +func (deadlineResolver) Namespace() string { return "deadline" } +func (r deadlineResolver) Populate(bag PlaceholderMap) { + addDeadlineVars(bag, r.deadline, r.project, r.lang) +} diff --git a/pkg/docforge/docx/compose.go b/pkg/docforge/docx/compose.go index 1b3f4b2..a343d3a 100644 --- a/pkg/docforge/docx/compose.go +++ b/pkg/docforge/docx/compose.go @@ -42,6 +42,8 @@ import ( "sort" "strings" "time" + + "mgit.msbls.de/m/paliad/pkg/docforge" ) // Composer assembles base + sections into a final .docx. @@ -114,11 +116,11 @@ type ComposeOptions struct { // Vars is the merged placeholder bag the v1 renderer pass // substitutes after the composer assembly. Passed straight through // to SubmissionRenderer.Render. - Vars PlaceholderMap + Vars docforge.PlaceholderMap // Missing translates an unbound placeholder key into the marker // the lawyer sees in Word. Passed straight to the renderer. - Missing MissingPlaceholderFn + Missing docforge.MissingPlaceholderFn } // Compose runs the full pipeline and returns the merged .docx bytes. diff --git a/pkg/docforge/docx/merge.go b/pkg/docforge/docx/merge.go index 95a4124..8391ba7 100644 --- a/pkg/docforge/docx/merge.go +++ b/pkg/docforge/docx/merge.go @@ -24,7 +24,7 @@ package docx // {{project.case_number}}). // // Missing-value behaviour: when a placeholder has no binding in the -// PlaceholderMap, the renderer emits a marker token so the lawyer sees +// docforge.PlaceholderMap, the renderer emits a marker token so the lawyer sees // the gap in Word rather than failing the request. import ( @@ -34,18 +34,15 @@ import ( "io" "regexp" "strings" + + "mgit.msbls.de/m/paliad/pkg/docforge" ) -// PlaceholderMap is the variable bag built by SubmissionVarsService. -// Keys are dotted paths without braces (e.g. "project.case_number"). -// Values are the substituted text — already locale-aware, pretty- -// printed, and sanitised by the caller. -type PlaceholderMap map[string]string - -// MissingPlaceholderFn translates an unbound placeholder key into the -// in-document marker token. The default in DefaultMissingMarker is -// "[KEIN WERT: ]" / "[NO VALUE: ]" depending on lang. -type MissingPlaceholderFn func(key string) string +// docforge.PlaceholderMap, docforge.MissingPlaceholderFn, and docforge.DefaultMissingMarker — the +// format-neutral variable-bag contract — live in the docforge root +// package (placeholder.go). This adapter consumes them; the {{key}} +// substitution grammar below (placeholderRegex, replacePlaceholders, the +// PUA preview sentinels) is the .docx renderer's own machinery. // valueWrapperFn wraps a substituted value with a marker the HTML // preview emitter can recognise — used by RenderHTML to turn each @@ -74,18 +71,6 @@ func htmlPreviewWrapper(key, value string) string { return previewVarBegin + key + previewVarMid + value + previewVarEnd } -// DefaultMissingMarker returns the standard missing-value marker for -// the given UI language. -func DefaultMissingMarker(lang string) MissingPlaceholderFn { - prefix := "KEIN WERT" - if strings.EqualFold(lang, "en") { - prefix = "NO VALUE" - } - return func(key string) string { - return "[" + prefix + ": " + key + "]" - } -} - // placeholderRegex matches a single placeholder. The capture group // extracts the key name without braces or surrounding whitespace. // @@ -95,7 +80,7 @@ func DefaultMissingMarker(lang string) MissingPlaceholderFn { var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`) // SubmissionRenderer renders a .docx template into a .docx output by -// substituting {{placeholder}} tokens with values from a PlaceholderMap. +// substituting {{placeholder}} tokens with values from a docforge.PlaceholderMap. // Stateless; safe for concurrent use. type SubmissionRenderer struct{} @@ -112,9 +97,9 @@ func NewSubmissionRenderer() *SubmissionRenderer { // Pre-pass: ConvertDotmToDocx is called on the input so a .dotm // template (macro-bearing) is downgraded to a plain .docx before the // merge step runs. Idempotent on inputs that are already plain .docx. -func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) ([]byte, error) { +func (r *SubmissionRenderer) Render(templateBytes []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn) ([]byte, error) { if missing == nil { - missing = DefaultMissingMarker("de") + missing = docforge.DefaultMissingMarker("de") } cleanBytes, err := ConvertDotmToDocx(templateBytes) if err != nil { @@ -166,9 +151,9 @@ func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, m // Returns escaped HTML safe to inject into the page via dangerouslySet // or innerHTML. The caller is responsible for wrapping in an outer // container; this method emits only the body fragment. -func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) (string, error) { +func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn) (string, error) { if missing == nil { - missing = DefaultMissingMarker("de") + missing = docforge.DefaultMissingMarker("de") } cleanBytes, err := ConvertDotmToDocx(templateBytes) if err != nil { @@ -241,7 +226,7 @@ func readMergeZipEntry(f *zip.File) ([]byte, error) { // paragraph, run the replacement on the merged text, and rewrite // the paragraph's runs as a single using // the formatting properties of the first run. -func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte { +func substituteInDocumentXML(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte { replaced := substituteInTextNodes(body, vars, missing, wrap) if !needsCrossRunMerge(replaced) { return replaced @@ -256,7 +241,7 @@ var wTextNodeRegex = regexp.MustCompile(`]*)?>([^<]*)`) // substituteInTextNodes runs the placeholder replacement inside each // text node independently. Format-preserving for single-run // placeholders. -func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte { +func substituteInTextNodes(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte { return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte { sub := wTextNodeRegex.FindSubmatch(match) attrs := string(sub[1]) @@ -297,7 +282,7 @@ var wParagraphPropsRegex = regexp.MustCompile(`(?s).*?`) // substituteAcrossRuns is pass 2: concatenate every text node in a // fragmented-placeholder paragraph, run replacement, rewrite as one run. -func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte { +func substituteAcrossRuns(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte { return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte { textNodes := wTextNodeRegex.FindAllSubmatch(para, -1) if len(textNodes) == 0 { @@ -340,7 +325,7 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace // emit clickable spans around every substituted placeholder, including // missing ones (clicking a missing marker jumps to the corresponding // sidebar input). -func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) string { +func replacePlaceholders(s string, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) string { return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string { sub := placeholderRegex.FindStringSubmatch(match) if len(sub) < 2 { diff --git a/pkg/docforge/docx/merge_test.go b/pkg/docforge/docx/merge_test.go index 99f2513..ab6b357 100644 --- a/pkg/docforge/docx/merge_test.go +++ b/pkg/docforge/docx/merge_test.go @@ -12,6 +12,8 @@ import ( "io" "strings" "testing" + + "mgit.msbls.de/m/paliad/pkg/docforge" ) // minimalMergeDOCX builds a tiny .docx zip with one document.xml that @@ -74,7 +76,7 @@ func TestRender_SingleRunPlaceholder(t *testing.T) { doc := `{{firm.name}}` tmpl := minimalMergeDOCX(t, doc) r := NewSubmissionRenderer() - out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil) + out, err := r.Render(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil) if err != nil { t.Fatalf("render: %v", err) } @@ -91,7 +93,7 @@ func TestRender_MultiplePlaceholdersPerRun(t *testing.T) { doc := `{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}` tmpl := minimalMergeDOCX(t, doc) r := NewSubmissionRenderer() - out, err := r.Render(tmpl, PlaceholderMap{ + out, err := r.Render(tmpl, docforge.PlaceholderMap{ "parties.claimant.name": "Acme Inc.", "parties.claimant.representative": "Kanzlei Müller", }, nil) @@ -111,7 +113,7 @@ func TestRender_MissingMarker(t *testing.T) { doc := `{{project.case_number}}` tmpl := minimalMergeDOCX(t, doc) r := NewSubmissionRenderer() - out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de")) + out, err := r.Render(tmpl, docforge.PlaceholderMap{}, docforge.DefaultMissingMarker("de")) if err != nil { t.Fatalf("render: %v", err) } @@ -119,7 +121,7 @@ func TestRender_MissingMarker(t *testing.T) { if !strings.Contains(body, "[KEIN WERT: project.case_number]") { t.Errorf("expected KEIN WERT marker, got %q", body) } - outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en")) + outEN, err := r.Render(tmpl, docforge.PlaceholderMap{}, docforge.DefaultMissingMarker("en")) if err != nil { t.Fatalf("render en: %v", err) } @@ -133,7 +135,7 @@ func TestRender_CrossRunPlaceholder(t *testing.T) { doc := `Hello {{project.case_number}}!` tmpl := minimalMergeDOCX(t, doc) r := NewSubmissionRenderer() - out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil) + out, err := r.Render(tmpl, docforge.PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil) if err != nil { t.Fatalf("render: %v", err) } @@ -150,7 +152,7 @@ func TestRender_XMLEscaping(t *testing.T) { doc := `{{user.display_name}}` tmpl := minimalMergeDOCX(t, doc) r := NewSubmissionRenderer() - out, err := r.Render(tmpl, PlaceholderMap{ + out, err := r.Render(tmpl, docforge.PlaceholderMap{ "user.display_name": `Müller & Söhne "Special"`, }, nil) if err != nil { @@ -203,7 +205,7 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) { `` tmpl := minimalMergeDOCX(t, doc) r := NewSubmissionRenderer() - html, err := r.RenderHTML(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil) + html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil) if err != nil { t.Fatalf("render html: %v", err) } @@ -225,7 +227,7 @@ func TestRenderHTML_EscapesContent(t *testing.T) { doc := `{{user.display_name}}` tmpl := minimalMergeDOCX(t, doc) r := NewSubmissionRenderer() - html, err := r.RenderHTML(tmpl, PlaceholderMap{ + html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{ "user.display_name": `M&S "X"`, }, nil) if err != nil { @@ -244,7 +246,7 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) { doc := `{{project.case_number}}` tmpl := minimalMergeDOCX(t, doc) r := NewSubmissionRenderer() - html, err := r.RenderHTML(tmpl, PlaceholderMap{}, nil) + html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{}, nil) if err != nil { t.Fatalf("render html: %v", err) } @@ -262,7 +264,7 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) { // value. There is no distinction at the renderer level between a value // that came from the resolved bag (project / parties / deadline lookups) // and a value the lawyer typed into the sidebar — both arrive in the -// same PlaceholderMap and both must be wrapped. +// same docforge.PlaceholderMap and both must be wrapped. func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) { doc := `` + `{{project.case_number}} / {{firm.name}}` + @@ -271,7 +273,7 @@ func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) { r := NewSubmissionRenderer() // project.case_number is the typed-by-lawyer override. // firm.name is the always-resolved value from the firm bag. - html, err := r.RenderHTML(tmpl, PlaceholderMap{ + html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{ "project.case_number": "UPC_CFI_42/2026", "firm.name": "HLC", }, nil) @@ -297,7 +299,7 @@ func TestRender_DocxOutputUnchangedByPreviewWrap(t *testing.T) { doc := `{{firm.name}}` tmpl := minimalMergeDOCX(t, doc) r := NewSubmissionRenderer() - out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil) + out, err := r.Render(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil) if err != nil { t.Fatalf("render docx: %v", err) } diff --git a/pkg/docforge/placeholder.go b/pkg/docforge/placeholder.go new file mode 100644 index 0000000..32ccd0d --- /dev/null +++ b/pkg/docforge/placeholder.go @@ -0,0 +1,33 @@ +package docforge + +import "strings" + +// PlaceholderMap is the variable bag a ResolverSet builds and a format +// exporter fills into a template. Keys are dotted paths without braces +// (e.g. "project.case_number"); values are the substituted text — already +// locale-aware, pretty-printed, and sanitised by the resolvers that +// produced them. +// +// It is format-neutral: the .docx exporter substitutes these into OOXML, +// but a future PDF/HTML/Markdown exporter consumes the same bag. The +// {{key}} substitution grammar itself is the exporter's concern and lives +// with the adapter (pkg/docforge/docx), not here. +type PlaceholderMap map[string]string + +// MissingPlaceholderFn translates an unbound placeholder key into the +// in-document marker token. DefaultMissingMarker returns the standard +// "[KEIN WERT: ]" / "[NO VALUE: ]" form. +type MissingPlaceholderFn func(key string) string + +// DefaultMissingMarker returns the standard missing-value marker for the +// given UI language. Unbound placeholders render this marker inline so the +// lawyer sees the gap in the document rather than the render failing. +func DefaultMissingMarker(lang string) MissingPlaceholderFn { + prefix := "KEIN WERT" + if strings.EqualFold(lang, "en") { + prefix = "NO VALUE" + } + return func(key string) string { + return "[" + prefix + ": " + key + "]" + } +} diff --git a/pkg/docforge/vars.go b/pkg/docforge/vars.go new file mode 100644 index 0000000..8b18179 --- /dev/null +++ b/pkg/docforge/vars.go @@ -0,0 +1,56 @@ +package docforge + +// VariableResolver populates one namespace of the placeholder bag. +// +// Each resolver owns a dotted namespace (e.g. "project", "parties") and +// pushes its keys into a shared PlaceholderMap. The push model — rather +// than a pull Resolve(key) — is deliberate: some namespaces emit a +// data-dependent set of keys (a multi-party suit produces +// parties.claimant.0.name, .1.name, … one per party), which a fixed +// key-by-key pull interface can't enumerate cleanly. Populate lets each +// resolver decide its own (possibly dynamic) key set in one pass. +// +// The consuming application implements concrete resolvers against its own +// data sources (paliad resolves project/party/deadline state from its +// Postgres database); docforge owns only the interface and the +// composition machinery (ResolverSet). This is the seam a second consumer +// (e.g. upc-commentary) plugs its own resolvers into without touching the +// engine. +type VariableResolver interface { + // Namespace returns the dotted prefix this resolver owns, e.g. + // "project". Informational — used for diagnostics and (later) the + // authoring variable palette's grouping. + Namespace() string + + // Populate writes this resolver's keys into bag. Resolvers own + // disjoint namespaces, so population order across resolvers does not + // affect the final bag. + Populate(bag PlaceholderMap) +} + +// ResolverSet composes an ordered list of VariableResolvers into a single +// PlaceholderMap. It is the replacement for hard-coded "call addFooVars, +// then addBarVars, …" sequencing: a consumer registers the resolvers that +// apply to a given render and calls BuildBag. +type ResolverSet struct { + resolvers []VariableResolver +} + +// NewResolverSet builds a set from the given resolvers, in order. +func NewResolverSet(resolvers ...VariableResolver) *ResolverSet { + return &ResolverSet{resolvers: resolvers} +} + +// Add appends a resolver to the set. +func (s *ResolverSet) Add(r VariableResolver) { s.resolvers = append(s.resolvers, r) } + +// BuildBag runs every resolver's Populate into a fresh PlaceholderMap and +// returns it. Because resolvers own disjoint namespaces, the result is +// independent of resolver order. +func (s *ResolverSet) BuildBag() PlaceholderMap { + bag := PlaceholderMap{} + for _, r := range s.resolvers { + r.Populate(bag) + } + return bag +}