Merge: t-paliad-349 docforge slice 3 — VariableResolver interface + ResolverSet (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-05-29 15:27:23 +02:00
8 changed files with 243 additions and 63 deletions

View File

@@ -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: <key>]" / "[NO VALUE: <key>]").
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.

View File

@@ -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,14 +152,17 @@ 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,
@@ -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
}

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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: <key>]" / "[NO VALUE: <key>]" 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 <w:r><w:t>…</w:t></w:r> 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(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
// substituteInTextNodes runs the placeholder replacement inside each
// <w:t> 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)<w:pPr>.*?</w:pPr>`)
// 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 {

View File

@@ -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 := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
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 := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
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 := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
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 := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
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 := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
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 <GmbH> "Special"`,
}, nil)
if err != nil {
@@ -203,7 +205,7 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
`</w:body></w:document>`
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 := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
html, err := r.RenderHTML(tmpl, PlaceholderMap{
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{
"user.display_name": `M&S <Inc> "X"`,
}, nil)
if err != nil {
@@ -244,7 +246,7 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
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 := `<w:document><w:body>` +
`<w:p><w:r><w:t>{{project.case_number}} / {{firm.name}}</w:t></w:r></w:p>` +
@@ -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 := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
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)
}

View File

@@ -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: <key>]" / "[NO VALUE: <key>]" 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 + "]"
}
}

56
pkg/docforge/vars.go Normal file
View File

@@ -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
}