mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
111 lines
4.0 KiB
TypeScript
111 lines
4.0 KiB
TypeScript
import Handlebars from 'handlebars';
|
|
|
|
/** One numbered clause of a dynamic article, with optional nested bullets. */
|
|
export interface RenderedClause {
|
|
text: string;
|
|
/** Computed outline number, e.g. "3" or "2.1.4". */
|
|
number: string;
|
|
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
|
|
depth: number;
|
|
bullets: string[];
|
|
}
|
|
|
|
/** A dynamic article ready for the Handlebars template. */
|
|
export interface RenderedArticle {
|
|
number: number;
|
|
/** Stable article id from the template (e.g. "pricing") — lets the layout
|
|
* inject the live rate schedule table under the pricing article. */
|
|
id: string;
|
|
title: string;
|
|
/** Set (instead of clauses) when the body is a single plain paragraph. */
|
|
paragraph?: string;
|
|
clauses: RenderedClause[];
|
|
}
|
|
|
|
/**
|
|
* Leading outline token on a clause line: "1.", "2)", "1.1", "1.1.1." …
|
|
* The token's segment count sets the clause depth; its digits are ignored —
|
|
* numbering is recomputed sequentially so stale numbers self-heal.
|
|
* A single-segment token requires its "."/")" ("10 tons…" is prose, "10. x"
|
|
* is clause ten); multi-segment tokens ("1.1") may omit it. A token may also
|
|
* end the line — that is an empty clause still being typed in the editor.
|
|
*/
|
|
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
|
|
|
|
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
|
|
const MAX_CLAUSE_DEPTH = 6;
|
|
|
|
/**
|
|
* Parse a template article body into clauses. Format: one clause per line.
|
|
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
|
|
* sub-clause at that depth — the typed digits are stripped and renumbered
|
|
* sequentially, so editing order never leaves stale numbers in the document.
|
|
* Lines prefixed with "- " become bullets nested under the preceding clause.
|
|
* A body that reduces to a single un-numbered clause without bullets renders
|
|
* as a plain paragraph rather than a numbered list of one.
|
|
*/
|
|
export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph' | 'clauses'> {
|
|
const lines = (body ?? '')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
|
|
const clauses: RenderedClause[] = [];
|
|
// counters[i] = current number at depth i+1; truncated when a shallower
|
|
// clause arrives so deeper numbering restarts at 1.
|
|
const counters: number[] = [];
|
|
let sawNumberToken = false;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('- ')) {
|
|
const bullet = line.slice(2).trim();
|
|
if (clauses.length === 0) {
|
|
counters.splice(0, counters.length, 1);
|
|
clauses.push({ text: bullet, number: '1', depth: 1, bullets: [] });
|
|
} else {
|
|
clauses[clauses.length - 1].bullets.push(bullet);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const match = CLAUSE_NUMBER_RE.exec(line);
|
|
const token = match ? (match[1] ?? match[2]) : null;
|
|
let depth = token ? Math.min(token.split('.').length, MAX_CLAUSE_DEPTH) : 1;
|
|
// A sub-clause can only sit directly under an existing parent — "1.1.1"
|
|
// typed as the first line clamps to whatever level is actually open.
|
|
depth = Math.min(depth, counters.length + 1);
|
|
if (match) sawNumberToken = true;
|
|
|
|
counters.splice(depth);
|
|
while (counters.length < depth) counters.push(0);
|
|
counters[depth - 1] += 1;
|
|
|
|
clauses.push({
|
|
text: match ? line.slice(match[0].length).trim() : line,
|
|
number: counters.slice(0, depth).join('.'),
|
|
depth,
|
|
bullets: [],
|
|
});
|
|
}
|
|
|
|
if (clauses.length === 1 && clauses[0].bullets.length === 0 && !sawNumberToken) {
|
|
return { paragraph: clauses[0].text, clauses: [] };
|
|
}
|
|
return { clauses };
|
|
}
|
|
|
|
/**
|
|
* Interpolate Handlebars placeholders ({{client.companyName}}, {{contractDate}},
|
|
* …) inside admin-authored template text against the contract view model.
|
|
* Malformed placeholders must never break document generation — fall back to
|
|
* the raw text.
|
|
*/
|
|
export function interpolateTemplateText(text: string, context: unknown): string {
|
|
if (!text || !text.includes('{{')) return text ?? '';
|
|
try {
|
|
return Handlebars.compile(text)(context);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|