();
data.steps.forEach((s) => {
let k = s.key;
let n = 2;
while (seen.has(k)) k = `${s.key}-${n++}`;
s.key = k;
seen.add(k);
});
return { plan: data, rawProse: before };
} catch {
return { plan: null, rawProse: text };
}
}
// ============================================================
// Tiny markdown renderer
// Supports: headings, paragraphs, lists, blockquotes, fenced code,
// tables, inline code/strong/em/links, line breaks.
// ============================================================
function renderMarkdown(src: string): string {
if (!src) return "";
const lines = src.replace(/\r\n/g, "\n").split("\n");
const out: string[] = [];
let i = 0;
function inline(s: string): string {
// Escape first, then re-introduce inline markup.
let t = escHtml(s);
// Code (must come before * / _)
t = t.replace(/`([^`]+)`/g, "$1");
// Bold
t = t.replace(/\*\*([^*]+)\*\*/g, "$1");
t = t.replace(/__([^_]+)__/g, "$1");
// Italic (basic)
t = t.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1$2");
t = t.replace(/(^|[\s(])_([^_\n]+)_/g, "$1$2");
// Links
t = t.replace(
/\[([^\]]+)\]\(([^)\s]+)\)/g,
(_m, label, url) =>
`${label}`,
);
return t;
}
while (i < lines.length) {
const line = lines[i];
if (/^\s*$/.test(line)) { i++; continue; }
// Headings
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
const level = Math.min(h[1].length, 4) + 2; // h1→h3, h2→h4, …
out.push(`${inline(h[2])}`);
i++; continue;
}
// Fenced code
const fence = line.match(/^```(\w+)?\s*$/);
if (fence) {
const lang = fence[1] || "";
const code: string[] = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
code.push(lines[i]); i++;
}
i++; // consume closing fence
out.push(renderCodeBlock({ lang, source: code.join("\n") }));
continue;
}
// Tables (very simple: pipe-delimited with header sep row)
if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?\s*[-:]+\s*\|/.test(lines[i + 1] || "")) {
const headers = line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
i += 2;
const rows: string[][] = [];
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
rows.push(
lines[i].trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()),
);
i++;
}
out.push("");
headers.forEach((h) => out.push(`| ${inline(h)} | `));
out.push("
");
rows.forEach((r) => {
out.push("");
r.forEach((c) => out.push(`| ${inline(c)} | `));
out.push("
");
});
out.push("
");
continue;
}
// Blockquote
if (/^\s*>/.test(line)) {
const buf: string[] = [];
while (i < lines.length && /^\s*>/.test(lines[i])) {
buf.push(lines[i].replace(/^\s*>\s?/, ""));
i++;
}
out.push(`${inline(buf.join(" "))}
`);
continue;
}
// Unordered list
if (/^\s*[-*+]\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*+]\s+/, ""));
i++;
}
out.push("");
items.forEach((t) => out.push(`- ${inline(t)}
`));
out.push("
");
continue;
}
// Ordered list
if (/^\s*\d+[.)]\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+[.)]\s+/, ""));
i++;
}
out.push("");
items.forEach((t) => out.push(`- ${inline(t)}
`));
out.push("
");
continue;
}
// Paragraph (collapse subsequent non-empty, non-block lines)
const buf: string[] = [line];
i++;
while (
i < lines.length &&
!/^\s*$/.test(lines[i]) &&
!/^(#{1,6}\s|```|\s*[-*+]\s|\s*\d+[.)]\s|\s*>|\s*\|)/.test(lines[i])
) {
buf.push(lines[i]); i++;
}
out.push(`${inline(buf.join(" "))}
`);
}
return out.join("\n");
}
// ============================================================
// Component renderers
// ============================================================
function renderChip(label: string, tone?: string): string {
const toneClass = tone ? ` pds-chip--${escAttr(tone)}` : "";
return `${escHtml(label)}`;
}
function renderHeaderChips(chips?: Array<{ label: string; tone?: string }>): string {
if (!chips || !chips.length) return "";
return chips.map((c) => renderChip(c.label, c.tone)).join("");
}
function renderFiles(files?: string[]): string {
if (!files || !files.length) return "";
return `${files
.map((f) => `${escHtml(f)}`)
.join("")}
`;
}
const CALLOUT_ICONS: Record = {
note: "i",
warn: "!",
risk: "!",
danger: "!",
ok: "✓",
success: "✓",
};
function renderCallout(c: { tone?: string; title?: string; body: string }): string {
const tone = (c.tone || "note").toLowerCase();
const icon = CALLOUT_ICONS[tone] || "•";
const title = c.title ? `${escHtml(c.title)} ` : "";
return ``;
}
function renderCodeBlock(c: { lang?: string; label?: string; source: string }): string {
const lang = (c.lang || "").toLowerCase();
const langClass = lang ? ` class="language-${escAttr(lang)}"` : "";
const label = c.label ? escHtml(c.label) : (lang || "code");
return `
${label}
${escHtml(c.source)}
`;
}
// --- Mockup preset cache ---
let MOCKUP_PRESET_CACHE: string | null = null;
function loadMockupPreset(): string {
if (MOCKUP_PRESET_CACHE != null) return MOCKUP_PRESET_CACHE;
const p = join(DESIGN_SYSTEM_DIR, "vendor", "mockup-preset.css");
try {
MOCKUP_PRESET_CACHE = readFileSync(p, "utf8");
} catch {
MOCKUP_PRESET_CACHE = "";
}
return MOCKUP_PRESET_CACHE;
}
// Encode for a double-quoted srcdoc attribute. Leaves `<`/`>` alone so
// the iframe parses the contained HTML normally.
function escSrcdoc(s: string): string {
return s.replace(/&/g, "&").replace(/"/g, """);
}
function stripScripts(html: string): string {
return html.replace(/