import Handlebars from 'handlebars'; /** One numbered clause of a dynamic article, with optional nested bullets. */ export interface RenderedClause { text: string; /** * Computed outline marker for this clause at its own level: "3" at depth 1, * "b" at depth 2, "iv" at depth 3, cycling back to arabic at depth 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; /** 1 → "a", 2 → "b", … 27 → "aa". */ function toAlpha(n: number): string { let out = ''; let value = n; while (value > 0) { const rem = (value - 1) % 26; out = String.fromCharCode(97 + rem) + out; value = Math.floor((value - 1) / 26); } return out || 'a'; } const ROMAN: Array<[number, string]> = [ [1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'], ]; /** 1 → "i", 4 → "iv", 9 → "ix". */ function toRoman(n: number): string { let value = n; let out = ''; for (const [amount, numeral] of ROMAN) { while (value >= amount) { out += numeral; value -= amount; } } return out || 'i'; } /** * Word-processor outline markers, cycling by depth the way Quill's own list * rendering does: 1. → a. → i. → 1. … Depth 1 keeps plain arabic numerals so * top-level clauses read as "1.", "2." in the contract; the marker is the * clause's own counter at its level, NOT a dotted path — "a" under clause 2 is * "a", not "2.a". */ export function clauseMarker(counter: number, depth: number): string { const style = (depth - 1) % 3; if (style === 1) return toAlpha(counter); if (style === 2) return toRoman(counter); return String(counter); } /** * 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: clauseMarker(counters[depth - 1], depth), 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; } }