/** * T-148 — inline atoms in block text (date / status / label). * * The editor's text storage model is a STRING (the InlineEditor round-trips * content as text/HTML). Inline atoms are therefore stored as PLAIN-TEXT * tokens embedded in the string: * * @date(2026-10-11) @status(done) @label(Q3 Planning) * * `parseInlineAtoms` splits a text string into render segments (text or * atom). The renderer ESCAPES the text segments (XSS — SECURITY_REVIEW §2) * and renders atoms as styled spans. A string with no tokens parses to a * single text segment, so legacy docs render byte-for-byte unchanged. * * This is the shared source of truth (editor + web + markdown export use * the same parse), so the token grammar lives here, not in a component. */ export const STATUS_VALUES = ["to-do", "in-progress", "done", "blocked", "pending"] as const; export type StatusValue = (typeof STATUS_VALUES)[number]; export interface DateAtom { kind: "date"; value: string; // YYYY-MM-DD } export interface StatusAtom { kind: "status"; value: StatusValue; } export interface LabelAtom { kind: "label"; value: string; } export type InlineAtom = DateAtom | StatusAtom | LabelAtom; export type InlineSegment = { type: "text"; text: string } | ({ type: "atom" } & InlineAtom); const TOKEN_RE = /@(date)\((\d{4}-\d{2}-\d{2})\)|@(status)\((to-do|in-progress|done|blocked|pending)\)|@(label)\(([^()]{1,100})\)/g; /** * Parse a block text string into segments. Unknown @-tokens are left as * literal text (forward-compatible — a future atom type never breaks * existing text). */ export function parseInlineAtoms(text: string): InlineSegment[] { if (typeof text !== "string" || text.length === 0) return text ? [{ type: "text", text }] : []; const segments: InlineSegment[] = []; let last = 0; let m: RegExpExecArray | null; TOKEN_RE.lastIndex = 0; while ((m = TOKEN_RE.exec(text)) !== null) { if (m.index > last) segments.push({ type: "text", text: text.slice(last, m.index) }); // group indices: 1=date literal, 2=iso, 3=status literal, 4=status value, // 5=label literal, 6=label value (each group is only populated by its branch) if (m[1] === "date") { const iso = m[2] as string; if (!Number.isNaN(Date.parse(iso))) { segments.push({ type: "atom", kind: "date", value: iso }); } else { segments.push({ type: "text", text: m[0] }); // rejected date → literal } } else if (m[3] === "status") { segments.push({ type: "atom", kind: "status", value: m[4] as StatusValue }); } else if (m[5] === "label") { segments.push({ type: "atom", kind: "label", value: m[6] as string }); } else { segments.push({ type: "text", text: m[0] }); } last = m.index + m[0].length; } if (last < text.length) segments.push({ type: "text", text: text.slice(last) }); // merge adjacent text segments (a rejected token sits between two texts) const merged: InlineSegment[] = []; for (const s of segments) { const prev = merged[merged.length - 1]; if (s.type === "text" && prev && prev.type === "text") prev.text += s.text; else merged.push(s); } return merged; } /** Serialize segments back to the token string (round-trips). */ export function atomsToText(segments: InlineSegment[]): string { return segments .map((s) => { if (s.type === "text") return s.text; if (s.kind === "date") return `@date(${s.value})`; if (s.kind === "status") return `@status(${s.value})`; return `@label(${s.value})`; }) .join(""); } /** Status → palette key (the renderer maps key → color). */ export const STATUS_COLORS: Record = { "to-do": "gray", "in-progress": "blue", done: "green", blocked: "red", pending: "yellow" }; /** Display label for a date atom (ISO → localized short form). */ export function formatDateAtom(iso: string): string { const d = new Date(`${iso}T00:00:00Z`); return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 10); }