refactor(docforge): slice 3 — VariableResolver interface + ResolverSet (t-paliad-349)

Move the variable-bag contract (PlaceholderMap, MissingPlaceholderFn,
DefaultMissingMarker) up to the pkg/docforge root (placeholder.go) — it is
format-neutral, consumed by the resolver layer and any future exporter.
The {{key}} substitution grammar (placeholderRegex, PUA preview sentinels,
replacePlaceholders) stays in pkg/docforge/docx: it is the .docx renderer's
own machinery, not a root concern.

New at the root (vars.go):
  - VariableResolver{Namespace() string; Populate(bag PlaceholderMap)} —
    a PUSH interface, deliberately not pull Resolve(key): some namespaces
    emit a data-dependent key set (parties.claimant.0.name, .1.name, … one
    per party) that a fixed key-by-key pull can't enumerate.
  - ResolverSet + BuildBag() — composes resolvers into one bag, replacing
    the hard-coded addFooVars-then-addBarVars sequencing in Build.

paliad side (submission_vars_resolvers.go): seven resolver types wrap the
UNCHANGED addXxxVars push-builders (firm/today/user/procedural_event/
project/parties/deadline), each capturing the entity it needs. The builder
bodies are byte-for-byte untouched, so the bag is identical by
construction; SubmissionVarsService.Build now wires the applicable
resolvers and calls ResolverSet.BuildBag(). Resolvers stay in paliad
because they read paliad's domain model; a second docforge consumer plugs
its own resolvers into a ResolverSet the same way.

Keys()/Catalogue() (the static key list that will data-drive the authoring
palette + kill the hardcoded VARIABLE_GROUPS in submission-draft.ts) is
deferred to the UI slice that consumes it, sourced from the frontend's
existing labels — building it now, ahead of its consumer, would be
speculative (PRD §4 B3 principle).

Verification: go build ./... clean, go vet clean, full module test green.
Alias-parity (procedural_event ≡ rule) and party-form tests pass unchanged
= bag byte-identical.

m/paliad#157
This commit is contained in:
mAi
2026-05-29 15:16:02 +02:00
parent f8067c2fe5
commit 8ea78fd376
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 // refactored to call docforge directly through the neutral model and the
// VariableResolver interface. // 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), // PlaceholderMap is the variable bag (dotted-key → substituted value),
// built by SubmissionVarsService and consumed by the renderer. // built by SubmissionVarsService and consumed by the renderer. The
type PlaceholderMap = docx.PlaceholderMap // 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 // MissingPlaceholderFn translates an unbound placeholder key into the
// in-document marker token. // in-document marker token.
type MissingPlaceholderFn = docx.MissingPlaceholderFn type MissingPlaceholderFn = docforge.MissingPlaceholderFn
// SubmissionRenderer renders a .docx template by substituting // SubmissionRenderer renders a .docx template by substituting
// {{placeholder}} tokens. Stateless; safe for concurrent use. // {{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 // DefaultMissingMarker returns the standard missing-value marker for the
// given UI language ("[KEIN WERT: <key>]" / "[NO VALUE: <key>]"). // 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 // RenderMarkdownToOOXML renders Markdown source into OOXML paragraph
// elements using a single paragraph style. // 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/branding"
"mgit.msbls.de/m/paliad/internal/models" "mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/pkg/docforge"
) )
// SubmissionVarsService assembles the placeholder map. // SubmissionVarsService assembles the placeholder map.
@@ -151,17 +152,20 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
if lang == "" { if lang == "" {
lang = "de" lang = "de"
} }
bag := PlaceholderMap{} // firm / today / user / procedural_event apply to every render,
addFirmVars(bag) // project-bound or not. Each resolver wraps the matching addXxxVars
addTodayVars(bag, time.Now()) // builder (unchanged); ResolverSet.BuildBag runs them into one bag.
addUserVars(bag, user) resolvers := []docforge.VariableResolver{
addRuleVars(bag, rule, lang) firmResolver{},
todayResolver{now: time.Now()},
userResolver{user: user},
proceduralEventResolver{rule: rule, lang: lang},
}
out := &SubmissionVarsResult{ out := &SubmissionVarsResult{
Placeholders: bag, User: user,
User: user, Rule: rule,
Rule: rule, Lang: lang,
Lang: lang,
} }
if in.ProjectID == nil { 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 // deadline state to resolve. The lawyer's overrides will fill
// the placeholder map; missing keys render as // the placeholder map; missing keys render as
// [KEIN WERT: …] / [NO VALUE: …] in the preview. // [KEIN WERT: …] / [NO VALUE: …] in the preview.
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
return out, nil return out, nil
} }
@@ -195,14 +200,17 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return nil, err return nil, err
} }
addProjectVars(bag, project, pt, lang) resolvers = append(resolvers,
addPartyVars(bag, filterPartiesBySelection(parties, in.SelectedParties)) projectResolver{project: project, pt: pt, lang: lang},
addDeadlineVars(bag, next, project, lang) partiesResolver{parties: filterPartiesBySelection(parties, in.SelectedParties)},
deadlineResolver{deadline: next, project: project, lang: lang},
)
out.Project = project out.Project = project
out.ProceedingType = pt out.ProceedingType = pt
out.Parties = parties out.Parties = parties
out.NextDeadline = next out.NextDeadline = next
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
return out, nil 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" "sort"
"strings" "strings"
"time" "time"
"mgit.msbls.de/m/paliad/pkg/docforge"
) )
// Composer assembles base + sections into a final .docx. // 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 // Vars is the merged placeholder bag the v1 renderer pass
// substitutes after the composer assembly. Passed straight through // substitutes after the composer assembly. Passed straight through
// to SubmissionRenderer.Render. // to SubmissionRenderer.Render.
Vars PlaceholderMap Vars docforge.PlaceholderMap
// Missing translates an unbound placeholder key into the marker // Missing translates an unbound placeholder key into the marker
// the lawyer sees in Word. Passed straight to the renderer. // 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. // Compose runs the full pipeline and returns the merged .docx bytes.

View File

@@ -24,7 +24,7 @@ package docx
// {{project.case_number}}). // {{project.case_number}}).
// //
// Missing-value behaviour: when a placeholder has no binding in the // 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. // the gap in Word rather than failing the request.
import ( import (
@@ -34,18 +34,15 @@ import (
"io" "io"
"regexp" "regexp"
"strings" "strings"
"mgit.msbls.de/m/paliad/pkg/docforge"
) )
// PlaceholderMap is the variable bag built by SubmissionVarsService. // docforge.PlaceholderMap, docforge.MissingPlaceholderFn, and docforge.DefaultMissingMarker — the
// Keys are dotted paths without braces (e.g. "project.case_number"). // format-neutral variable-bag contract — live in the docforge root
// Values are the substituted text — already locale-aware, pretty- // package (placeholder.go). This adapter consumes them; the {{key}}
// printed, and sanitised by the caller. // substitution grammar below (placeholderRegex, replacePlaceholders, the
type PlaceholderMap map[string]string // PUA preview sentinels) is the .docx renderer's own machinery.
// 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
// valueWrapperFn wraps a substituted value with a marker the HTML // valueWrapperFn wraps a substituted value with a marker the HTML
// preview emitter can recognise — used by RenderHTML to turn each // 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 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 // placeholderRegex matches a single placeholder. The capture group
// extracts the key name without braces or surrounding whitespace. // 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*\}\}`) var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
// SubmissionRenderer renders a .docx template into a .docx output by // 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. // Stateless; safe for concurrent use.
type SubmissionRenderer struct{} type SubmissionRenderer struct{}
@@ -112,9 +97,9 @@ func NewSubmissionRenderer() *SubmissionRenderer {
// Pre-pass: ConvertDotmToDocx is called on the input so a .dotm // Pre-pass: ConvertDotmToDocx is called on the input so a .dotm
// template (macro-bearing) is downgraded to a plain .docx before the // template (macro-bearing) is downgraded to a plain .docx before the
// merge step runs. Idempotent on inputs that are already plain .docx. // 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 { if missing == nil {
missing = DefaultMissingMarker("de") missing = docforge.DefaultMissingMarker("de")
} }
cleanBytes, err := ConvertDotmToDocx(templateBytes) cleanBytes, err := ConvertDotmToDocx(templateBytes)
if err != nil { 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 // Returns escaped HTML safe to inject into the page via dangerouslySet
// or innerHTML. The caller is responsible for wrapping in an outer // or innerHTML. The caller is responsible for wrapping in an outer
// container; this method emits only the body fragment. // 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 { if missing == nil {
missing = DefaultMissingMarker("de") missing = docforge.DefaultMissingMarker("de")
} }
cleanBytes, err := ConvertDotmToDocx(templateBytes) cleanBytes, err := ConvertDotmToDocx(templateBytes)
if err != nil { if err != nil {
@@ -241,7 +226,7 @@ func readMergeZipEntry(f *zip.File) ([]byte, error) {
// paragraph, run the replacement on the merged text, and rewrite // 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 paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
// the formatting properties of the first run. // 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) replaced := substituteInTextNodes(body, vars, missing, wrap)
if !needsCrossRunMerge(replaced) { if !needsCrossRunMerge(replaced) {
return replaced return replaced
@@ -256,7 +241,7 @@ var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
// substituteInTextNodes runs the placeholder replacement inside each // substituteInTextNodes runs the placeholder replacement inside each
// <w:t> text node independently. Format-preserving for single-run // <w:t> text node independently. Format-preserving for single-run
// placeholders. // 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 { return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
sub := wTextNodeRegex.FindSubmatch(match) sub := wTextNodeRegex.FindSubmatch(match)
attrs := string(sub[1]) 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 // substituteAcrossRuns is pass 2: concatenate every text node in a
// fragmented-placeholder paragraph, run replacement, rewrite as one run. // 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 { return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1) textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
if len(textNodes) == 0 { if len(textNodes) == 0 {
@@ -340,7 +325,7 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
// emit clickable spans around every substituted placeholder, including // emit clickable spans around every substituted placeholder, including
// missing ones (clicking a missing marker jumps to the corresponding // missing ones (clicking a missing marker jumps to the corresponding
// sidebar input). // 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 { return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
sub := placeholderRegex.FindStringSubmatch(match) sub := placeholderRegex.FindStringSubmatch(match)
if len(sub) < 2 { if len(sub) < 2 {

View File

@@ -12,6 +12,8 @@ import (
"io" "io"
"strings" "strings"
"testing" "testing"
"mgit.msbls.de/m/paliad/pkg/docforge"
) )
// minimalMergeDOCX builds a tiny .docx zip with one document.xml that // 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>` 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) tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer() 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 { if err != nil {
t.Fatalf("render: %v", err) 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>` 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) tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer() r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{ out, err := r.Render(tmpl, docforge.PlaceholderMap{
"parties.claimant.name": "Acme Inc.", "parties.claimant.name": "Acme Inc.",
"parties.claimant.representative": "Kanzlei Müller", "parties.claimant.representative": "Kanzlei Müller",
}, nil) }, 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>` 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) tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer() r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de")) out, err := r.Render(tmpl, docforge.PlaceholderMap{}, docforge.DefaultMissingMarker("de"))
if err != nil { if err != nil {
t.Fatalf("render: %v", err) t.Fatalf("render: %v", err)
} }
@@ -119,7 +121,7 @@ func TestRender_MissingMarker(t *testing.T) {
if !strings.Contains(body, "[KEIN WERT: project.case_number]") { if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
t.Errorf("expected KEIN WERT marker, got %q", body) 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 { if err != nil {
t.Fatalf("render en: %v", err) 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>` 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) tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer() 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 { if err != nil {
t.Fatalf("render: %v", err) 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>` 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) tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer() r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{ out, err := r.Render(tmpl, docforge.PlaceholderMap{
"user.display_name": `Müller & Söhne <GmbH> "Special"`, "user.display_name": `Müller & Söhne <GmbH> "Special"`,
}, nil) }, nil)
if err != nil { if err != nil {
@@ -203,7 +205,7 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
`</w:body></w:document>` `</w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc) tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer() 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 { if err != nil {
t.Fatalf("render html: %v", err) 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>` 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) tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer() r := NewSubmissionRenderer()
html, err := r.RenderHTML(tmpl, PlaceholderMap{ html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{
"user.display_name": `M&S <Inc> "X"`, "user.display_name": `M&S <Inc> "X"`,
}, nil) }, nil)
if err != 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>` 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) tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer() r := NewSubmissionRenderer()
html, err := r.RenderHTML(tmpl, PlaceholderMap{}, nil) html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{}, nil)
if err != nil { if err != nil {
t.Fatalf("render html: %v", err) 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 // value. There is no distinction at the renderer level between a value
// that came from the resolved bag (project / parties / deadline lookups) // that came from the resolved bag (project / parties / deadline lookups)
// and a value the lawyer typed into the sidebar — both arrive in the // 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) { func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
doc := `<w:document><w:body>` + doc := `<w:document><w:body>` +
`<w:p><w:r><w:t>{{project.case_number}} / {{firm.name}}</w:t></w:r></w:p>` + `<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() r := NewSubmissionRenderer()
// project.case_number is the typed-by-lawyer override. // project.case_number is the typed-by-lawyer override.
// firm.name is the always-resolved value from the firm bag. // 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", "project.case_number": "UPC_CFI_42/2026",
"firm.name": "HLC", "firm.name": "HLC",
}, nil) }, 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>` 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) tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer() 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 { if err != nil {
t.Fatalf("render docx: %v", err) 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
}