feat(docforge): slice 5 — docforge-editor pkg + variable catalogue SSOT (t-paliad-349)
Establish the shared frontend editor package and make the Go resolvers the
single source of truth for variable labels.
Go — catalogue SSOT:
- VariableResolver gains Keys() []VariableKey; ResolverSet gains
Catalogue(). The 7 submission resolvers implement Keys() with the
bilingual labels ported from the TS VARIABLE_LABELS table (incl. the
legacy rule.* aliases). Keys() is entity-independent, so
SubmissionVariableCatalogue() builds a metadata-only ResolverSet.
- GET /api/docforge/variables serves the catalogue (auth-gated, static).
- Tests: docforge ResolverSet (BuildBag merge + Catalogue order) and the
submission catalogue integrity (no dupes, labels present, spot-checks).
Frontend — frontend/src/lib/docforge-editor/ (new shared package):
- dom.ts: escapeHtml + cssEscape (pure), with bun tests. Dedupes the two
identical escapeHtml/escapeHTML copies + the cssEscape copy that lived
in the submission editor.
- catalogue.ts: fetchVariableCatalogue() + labelMap() — the client for
the Go catalogue.
- submission-draft.ts now imports escapeHtml/cssEscape from the lib and
fetches the catalogue on boot into state.varLabels (labelFor reads it,
falling back to the raw key if the fetch fails — graceful degrade). The
hardcoded VARIABLE_LABELS table is removed; VARIABLE_GROUPS stays
(presentation: which keys to show + how to section them, legitimately
frontend).
Scope note: the DOM-coupled editor plumbing (wireDraftVars/focus
preservation/autosave debounce) is extracted in slice 6 alongside its first
reuse — the authoring page — rather than speculatively now (extract with the
consumer; same principle as slices 2-3). Slice 5 lands the pure utilities +
the catalogue, which the slice-6 authoring palette consumes.
Verification: go build/vet/test green (Go files gofmt-clean; handlers.go
pre-existing drift, added region clean); bun run build.ts clean;
bun test 274/274 (incl. 5 new docforge-editor tests).
m/paliad#157
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { escapeHtml, cssEscape } from "../lib/docforge-editor/dom";
|
||||
import { fetchVariableCatalogue, labelMap } from "../lib/docforge-editor/catalogue";
|
||||
|
||||
// t-paliad-238 Slice A — client bundle for the dedicated
|
||||
// Submissions/Schriftsätze editor at
|
||||
@@ -153,19 +155,16 @@ function isEN(): boolean {
|
||||
return document.documentElement.lang === "en";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
// escapeHtml + cssEscape now come from ../lib/docforge-editor/dom (the
|
||||
// shared editor utilities); the local copies were removed in slice 5.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Variable contract — DE/EN labels per dotted-path placeholder.
|
||||
// Mirrors the same shape the email-template variables sidebar uses;
|
||||
// keeps the lawyer's mental model anchored on the same vocabulary.
|
||||
// Labels come from the Go-side catalogue (GET /api/docforge/variables),
|
||||
// fetched once on boot into state.varLabels. The frontend keeps only the
|
||||
// presentation grouping (VARIABLE_GROUPS) — which keys to show and how to
|
||||
// section them — not the label data itself, so labels can't drift from the
|
||||
// resolvers that produce the values (t-paliad-349 slice 5).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VariableLabel {
|
||||
@@ -186,71 +185,6 @@ interface VariableGroup {
|
||||
collapsedByDefault?: boolean;
|
||||
}
|
||||
|
||||
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
"firm.name": { de: "Kanzlei", en: "Firm" },
|
||||
"firm.signature_block": { de: "Signatur-Block", en: "Signature block" },
|
||||
"today": { de: "Heute", en: "Today" },
|
||||
"today.iso": { de: "Heute (ISO)", en: "Today (ISO)" },
|
||||
"today.long_de": { de: "Heute (DE lang)", en: "Today (DE long)" },
|
||||
"today.long_en": { de: "Heute (EN lang)", en: "Today (EN long)" },
|
||||
"user.display_name": { de: "Bearbeiter", en: "Author" },
|
||||
"user.email": { de: "E-Mail", en: "Email" },
|
||||
"user.office": { de: "Büro", en: "Office" },
|
||||
"project.title": { de: "Projekttitel", en: "Project title" },
|
||||
"project.reference": { de: "Aktenzeichen (intern)", en: "Internal reference" },
|
||||
"project.case_number": { de: "Aktenzeichen (Gericht)", en: "Court case number" },
|
||||
"project.court": { de: "Gericht", en: "Court" },
|
||||
"project.patent_number": { de: "Patentnummer", en: "Patent number" },
|
||||
"project.patent_number_upc": { de: "Patentnummer (UPC-Format)", en: "Patent number (UPC format)" },
|
||||
"project.filing_date": { de: "Anmeldedatum", en: "Filing date" },
|
||||
"project.grant_date": { de: "Erteilungsdatum", en: "Grant date" },
|
||||
"project.our_side": { de: "Unsere Seite", en: "Our side" },
|
||||
"project.our_side_de": { de: "Unsere Seite (DE)", en: "Our side (DE)" },
|
||||
"project.our_side_en": { de: "Unsere Seite (EN)", en: "Our side (EN)" },
|
||||
"project.instance_level": { de: "Instanz", en: "Instance" },
|
||||
"project.client_number": { de: "Mandantennummer", en: "Client number" },
|
||||
"project.matter_number": { de: "Matter-Nummer", en: "Matter number" },
|
||||
"project.proceeding.code": { de: "Verfahrenstyp (Code)", en: "Proceeding type (code)" },
|
||||
"project.proceeding.name": { de: "Verfahrenstyp", en: "Proceeding type" },
|
||||
"project.proceeding.name_de": { de: "Verfahrenstyp (DE)", en: "Proceeding type (DE)" },
|
||||
"project.proceeding.name_en": { de: "Verfahrenstyp (EN)", en: "Proceeding type (EN)" },
|
||||
"parties.claimant.name": { de: "Klägerin", en: "Claimant" },
|
||||
"parties.claimant.representative": { de: "Klägerin-Vertreter", en: "Claimant representative" },
|
||||
"parties.defendant.name": { de: "Beklagte", en: "Defendant" },
|
||||
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
|
||||
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
|
||||
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
|
||||
// Procedural-event namespace (t-paliad-262 Slice A, design doc
|
||||
// docs/design-procedural-events-model-2026-05-25.md). The canonical
|
||||
// placeholder names are below; the `rule.*` aliases that follow are
|
||||
// @deprecated but kept forever per m's Q7 lock — existing Word
|
||||
// templates and saved drafts authored with the old names keep
|
||||
// merging identically.
|
||||
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
|
||||
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
|
||||
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
|
||||
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
|
||||
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||||
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
|
||||
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||||
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
|
||||
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
|
||||
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
|
||||
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
|
||||
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
|
||||
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
|
||||
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
|
||||
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
|
||||
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
|
||||
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
|
||||
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
|
||||
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
|
||||
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
|
||||
"deadline.original_due_date": { de: "Ursprüngliche Frist", en: "Original deadline" },
|
||||
"deadline.computed_from": { de: "Frist berechnet aus", en: "Deadline computed from" },
|
||||
"deadline.title": { de: "Frist-Titel", en: "Deadline title" },
|
||||
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
||||
};
|
||||
|
||||
// t-paliad-287 — variable groups restructured into four lawyer-facing
|
||||
// sections: Mandant/Verfahren up top (the case identity), then Parteien
|
||||
@@ -341,7 +275,7 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
];
|
||||
|
||||
function labelFor(key: string): string {
|
||||
const entry = VARIABLE_LABELS[key];
|
||||
const entry = state.varLabels[key];
|
||||
if (!entry) return key;
|
||||
return isEN() ? entry.en : entry.de;
|
||||
}
|
||||
@@ -373,6 +307,12 @@ interface State {
|
||||
// completes) keeps the picker hidden permanently for this load.
|
||||
bases: SubmissionBaseRow[];
|
||||
basesLoaded: boolean;
|
||||
// t-paliad-349 slice 5 — variable labels fetched once on boot from the
|
||||
// Go catalogue (GET /api/docforge/variables), the single source of
|
||||
// truth. Empty until the fetch lands; labelFor falls back to the raw
|
||||
// key, so a failed fetch degrades gracefully rather than breaking the
|
||||
// form.
|
||||
varLabels: Record<string, VariableLabel>;
|
||||
}
|
||||
|
||||
type PartySide = "claimant" | "defendant" | "other";
|
||||
@@ -401,6 +341,7 @@ const state: State = {
|
||||
addPartyBusy: false,
|
||||
bases: [],
|
||||
basesLoaded: false,
|
||||
varLabels: {},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -426,6 +367,16 @@ async function boot(): Promise<void> {
|
||||
state.basesLoaded = true;
|
||||
});
|
||||
|
||||
// t-paliad-349 slice 5 — load the variable-label catalogue (Go SSOT)
|
||||
// before the first paint so the sidebar form labels render. Awaited
|
||||
// because labelFor needs it at paint time; a failure leaves varLabels
|
||||
// empty and labelFor falls back to the raw key (degraded but usable).
|
||||
try {
|
||||
state.varLabels = labelMap(await fetchVariableCatalogue());
|
||||
} catch (err) {
|
||||
console.warn("submission-draft: variable catalogue fetch failed", err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsed.mode === "global") {
|
||||
// Global path: we have a draft_id, fetch by id alone. Drafts
|
||||
@@ -1985,11 +1936,11 @@ function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec
|
||||
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
|
||||
row.innerHTML = `
|
||||
<div class="submission-bb-picker-row-head">
|
||||
<strong>${escapeHTML(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHtml(b.visibility)}">${escapeHtml(b.visibility)}</span>
|
||||
</div>
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHtml(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHtml(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
row.addEventListener("click", () => {
|
||||
void insertBlockIntoSection(b.id, sec.id, overlay);
|
||||
});
|
||||
@@ -2019,15 +1970,6 @@ async function insertBlockIntoSection(blockID: string, sectionID: string, overla
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
const draftID = state.view?.draft.id;
|
||||
@@ -2104,17 +2046,6 @@ function findVarInput(key: string): HTMLInputElement | null {
|
||||
);
|
||||
}
|
||||
|
||||
function cssEscape(s: string): string {
|
||||
// CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
|
||||
// older browsers may lack it; defensive fallback escapes characters
|
||||
// CSS treats as special. Placeholder keys never carry whitespace or
|
||||
// quotes so escaping is straightforward.
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
|
||||
function onDraftVarClick(key: string, ev: Event): void {
|
||||
const input = findVarInput(key);
|
||||
if (!input) return;
|
||||
|
||||
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal file
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// docforge-editor — the variable catalogue client.
|
||||
//
|
||||
// The catalogue (key + bilingual label + namespace group) is served by the
|
||||
// Go backend at GET /api/docforge/variables, built from the resolvers'
|
||||
// Keys() as the single source of truth. A consumer fetches it once and uses
|
||||
// labelMap() to label its sidebar form + authoring palette, instead of
|
||||
// hard-coding a parallel label table that can drift from the resolvers.
|
||||
|
||||
export interface VariableEntry {
|
||||
key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface VariablesResponse {
|
||||
variables: VariableEntry[];
|
||||
}
|
||||
|
||||
// fetchVariableCatalogue loads the catalogue from the backend. Throws on a
|
||||
// non-2xx response so the caller can decide how to degrade.
|
||||
export async function fetchVariableCatalogue(): Promise<VariableEntry[]> {
|
||||
const res = await fetch("/api/docforge/variables", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`docforge variables: HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as VariablesResponse;
|
||||
return body.variables ?? [];
|
||||
}
|
||||
|
||||
// labelMap turns a catalogue into a key → {de, en} lookup for a label
|
||||
// function. Keys absent from the map fall back to the raw key at the call
|
||||
// site, so a failed fetch degrades to dotted-key labels rather than a
|
||||
// broken form.
|
||||
export function labelMap(catalogue: VariableEntry[]): Record<string, { de: string; en: string }> {
|
||||
const out: Record<string, { de: string; en: string }> = {};
|
||||
for (const e of catalogue) {
|
||||
out[e.key] = { de: e.label_de, en: e.label_en };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal file
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { escapeHtml, cssEscape } from "./dom";
|
||||
|
||||
test("escapeHtml escapes the five HTML-significant characters", () => {
|
||||
expect(escapeHtml(`<a href="x" title='y'>& z</a>`)).toBe(
|
||||
"<a href="x" title='y'>& z</a>",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapeHtml is a no-op on plain text", () => {
|
||||
expect(escapeHtml("Aktenzeichen 4c O 12/23")).toBe("Aktenzeichen 4c O 12/23");
|
||||
});
|
||||
|
||||
test("escapeHtml escapes & first to avoid double-encoding", () => {
|
||||
expect(escapeHtml("<")).toBe("&lt;");
|
||||
});
|
||||
|
||||
test("cssEscape backslash-escapes the dots in a placeholder key", () => {
|
||||
// Both CSS.escape and the regex fallback escape '.' the same way, so the
|
||||
// result is stable across environments (bun has no CSS global → fallback).
|
||||
expect(cssEscape("project.case_number")).toBe("project\\.case_number");
|
||||
});
|
||||
|
||||
test("cssEscape leaves identifier-safe characters untouched", () => {
|
||||
expect(cssEscape("today")).toBe("today");
|
||||
});
|
||||
32
frontend/src/lib/docforge-editor/dom.ts
Normal file
32
frontend/src/lib/docforge-editor/dom.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// docforge-editor — shared, framework-agnostic editor utilities.
|
||||
//
|
||||
// Slice 5 of the docforge train (t-paliad-349 / m/paliad#157) begins
|
||||
// extracting the generic editor plumbing out of the submission-specific
|
||||
// client bundle so a second consumer (and the slice-6 authoring page) can
|
||||
// reuse it. This module holds the pure DOM-string helpers — no DOM
|
||||
// mutation, no editor state — so they unit-test cleanly under bun.
|
||||
|
||||
// escapeHtml escapes the five HTML-significant characters for safe
|
||||
// insertion into element text or an attribute value. Matches the
|
||||
// server-side emitTextWithDraftVars/htmlEscape contract so preview markup
|
||||
// round-trips identically.
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// cssEscape escapes a string for use inside a CSS attribute selector
|
||||
// (e.g. `[data-var="${cssEscape(key)}"]`). Prefers the native CSS.escape
|
||||
// and falls back to escaping CSS-special characters for older runtimes.
|
||||
// Placeholder keys ([A-Za-z][A-Za-z0-9_.]*) never carry whitespace or
|
||||
// quotes, so the fallback is straightforward.
|
||||
export function cssEscape(s: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
Reference in New Issue
Block a user