From 12e5029bab34a3ea95203abfe9a40f9983c59460 Mon Sep 17 00:00:00 2001 From: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:20:46 +0000 Subject: [PATCH] fix(prd): show PRD titles in the index without YAML quotes or broken cells renderPrd writes the title as a quoted YAML string so metacharacters stay valid, but listPrds read the raw line back, so the quotes and their escapes became part of the title. Every PRD moshcode created showed up as `"My title"` in prd/README.md, `moshcode prd --list`, and `/prd list`, unlike the hand-written PRDs already in the repo. A `|` in a title had a second problem: it closed its markdown table cell early and shifted every column after it. Read the front-matter scalar back to plain text (double- and single-quoted forms) and escape pipes when rendering the index row. Tests: two regression tests covering the quoted title and the pipe; both fail on the old code and pass on this one. Full suite 136/136. --- src/prd.mjs | 24 +++++++++++++++++++++--- test/prd.test.mjs | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/prd.mjs b/src/prd.mjs index 96aee1b..c6c22d9 100644 --- a/src/prd.mjs +++ b/src/prd.mjs @@ -177,6 +177,21 @@ ${INDEX_END} `; } +// Read a front-matter scalar back to its plain text. renderPrd writes the title +// as a quoted YAML string so metacharacters stay valid, so the quotes (and any +// escapes inside them) are syntax, not part of the title — strip them, or every +// listing and index row shows `"My title"` instead of `My title`. +function unquoteYaml(value) { + const raw = String(value).trim(); + if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) { + try { return JSON.parse(raw); } catch { return raw.slice(1, -1); } + } + if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) { + return raw.slice(1, -1).replace(/''/g, "'"); + } + return raw; +} + /** List numbered PRDs (NNNN-slug.md, excluding the 0000 template). */ export function listPrds(root = process.cwd()) { const base = prdDir(root); @@ -191,8 +206,8 @@ export function listPrds(root = process.cwd()) { try { const head = fs.readFileSync(file, "utf8").split(/\r?\n/).slice(0, 16); for (const l of head) { - const t = l.match(/^title:\s*(.+)$/); if (t) title = t[1].trim(); - const s = l.match(/^status:\s*(.+)$/); if (s) status = s[1].trim(); + const t = l.match(/^title:\s*(.+)$/); if (t) title = unquoteYaml(t[1]); + const s = l.match(/^status:\s*(.+)$/); if (s) status = unquoteYaml(s[1]); } } catch { continue; } out.push({ id: m[1], slug: m[2], title, status, file: name, path: file }); @@ -213,9 +228,12 @@ export function regenerateIndex(root = process.cwd()) { let body; try { body = fs.readFileSync(readme, "utf8"); } catch { return false; } const prds = listPrds(root); + // A `|` in a title would close its table cell early and shift every column + // after it, so escape it for the markdown table. + const cell = (text) => String(text).replace(/\|/g, "\\|"); const rows = prds.length ? ["| # | Title | Status |", "|---|---|---|", - ...prds.map((p) => `| [${p.id}](${p.file}) | ${p.title} | ${p.status} |`)].join("\n") + ...prds.map((p) => `| [${p.id}](${p.file}) | ${cell(p.title)} | ${cell(p.status)} |`)].join("\n") : "_No PRDs yet._"; // Use a function replacement so `$`-sequences in a PRD title (e.g. `$&`, `$1`, // `$\`` ) are inserted verbatim instead of being read as String.replace special diff --git a/test/prd.test.mjs b/test/prd.test.mjs index 45acbcb..6fc7abf 100644 --- a/test/prd.test.mjs +++ b/test/prd.test.mjs @@ -4,7 +4,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { createPrd, renderPrd } from "../src/prd.mjs"; +import { createPrd, listPrds, renderPrd } from "../src/prd.mjs"; test("renderPrd quotes titles so YAML metacharacters stay valid", () => { const body = renderPrd({ @@ -39,3 +39,41 @@ test("regenerateIndex keeps the README intact when a title holds a String.replac fs.rmSync(root, { recursive: true, force: true }); } }); + +test("listPrds reads the title back without its YAML quotes or escapes", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-prd-")); + try { + createPrd("add a dark mode toggle", root); + createPrd('ship CLI: handle "quoted" flags', root); + + const [plain, quoted] = listPrds(root); + assert.equal(plain.title, "Add a dark mode toggle"); + assert.equal(quoted.title, 'Ship CLI: handle "quoted" flags'); + assert.equal(plain.status, "Draft"); + + // The README index shows the title itself, not the YAML syntax around it. + const readme = fs.readFileSync(path.join(root, "prd", "README.md"), "utf8"); + assert.match(readme, /\| Add a dark mode toggle \|/); + assert.doesNotMatch(readme, /\| "Add a dark mode toggle" \|/); + assert.doesNotMatch(readme, /\\"quoted\\"/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("regenerateIndex escapes a pipe in a title so the row keeps its columns", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-prd-")); + try { + createPrd("support a|b routing", root); + + const readme = fs.readFileSync(path.join(root, "prd", "README.md"), "utf8"); + const row = readme.split(/\r?\n/).find((l) => l.includes("routing")); + assert.ok(row, "the PRD row must be in the index"); + // Splitting on unescaped pipes must still yield exactly three cells. + const cells = row.split(/(?