/**
* 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;
}