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 { 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; } }