/** * Atom ↔ HTML bridge (T-148 web wiring). * * The canonical stored form of an atom-bearing text block is `{ text }` with * the inline-atom tokens (`@date(…)` / `@status(…)` / `@label(…)`). The web * editor renders atoms as Tiptap atom nodes (see `nodes/inline-atom.ts`), * whose HTML is `…`. * * These two pure functions convert between the two forms: * - `atomTextToHtml(text)`: `{text}` from the API → HTML for the Tiptap * editor (text segments ESCAPED — stored text is untrusted data). * - `atomHtmlToText(html)`: editor HTML → `{text}` token form for the API * (atoms become tokens; formatting tags are stripped — the stored form * is plain text by contract). * * Neither function emits executable markup: every dynamic value goes through * `escapeHtml`, and the label value in particular may contain anything * (XSS vector) — it is rendered as text, never as HTML. */ import { parseInlineAtoms, formatDateAtom, STATUS_VALUES } from "./inline-atoms.js"; function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function unescapeHtml(s: string): string { return s .replace(/"/g, '"') .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&"); } function atomSpan(kind: string, value: string): string { return `${escapeHtml( kind === "date" ? formatDateAtom(value) : kind === "status" ? value : value )}`; } /** Stored plain text (may contain atom tokens) → HTML for the Tiptap editor. */ export function atomTextToHtml(text: string): string { return parseInlineAtoms(text) .map((seg) => seg.type === "text" ? escapeHtml(seg.text) : atomSpan(seg.kind, seg.value) ) .join(""); } /** * Editor HTML → stored plain text. Atoms become tokens; other markup is * stripped (the stored form is plain text). Returns `null` when the HTML has * no atoms — the caller should then keep the original content untouched * (preserving the block's existing formatting/HTML for atom-less blocks). */ export function atomHtmlToText(html: string): string | null { if (!html.includes('class="factory-atom"')) return null; const out = html .replace( /[\s\S]*?<\/span>/g, (_m, kind: string, rawValue: string) => { const value = unescapeHtml(rawValue); if (kind === "status" && !STATUS_VALUES.includes(value as (typeof STATUS_VALUES)[number])) { return value; // unknown status → keep as plain text, don't forge a token } return `@${kind}(${value})`; } ) .replace(//gi, "\n") .replace(/<\/(p|div)>/gi, "\n") .replace(/<[^>]+>/g, "") .replace(/\n{3,}/g, "\n\n") .trim(); return out; }