Skip to content

Commit 12e5029

Browse files
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.
1 parent d8206d1 commit 12e5029

2 files changed

Lines changed: 60 additions & 4 deletions

File tree

src/prd.mjs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,21 @@ ${INDEX_END}
177177
`;
178178
}
179179

180+
// Read a front-matter scalar back to its plain text. renderPrd writes the title
181+
// as a quoted YAML string so metacharacters stay valid, so the quotes (and any
182+
// escapes inside them) are syntax, not part of the title — strip them, or every
183+
// listing and index row shows `"My title"` instead of `My title`.
184+
function unquoteYaml(value) {
185+
const raw = String(value).trim();
186+
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
187+
try { return JSON.parse(raw); } catch { return raw.slice(1, -1); }
188+
}
189+
if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) {
190+
return raw.slice(1, -1).replace(/''/g, "'");
191+
}
192+
return raw;
193+
}
194+
180195
/** List numbered PRDs (NNNN-slug.md, excluding the 0000 template). */
181196
export function listPrds(root = process.cwd()) {
182197
const base = prdDir(root);
@@ -191,8 +206,8 @@ export function listPrds(root = process.cwd()) {
191206
try {
192207
const head = fs.readFileSync(file, "utf8").split(/\r?\n/).slice(0, 16);
193208
for (const l of head) {
194-
const t = l.match(/^title:\s*(.+)$/); if (t) title = t[1].trim();
195-
const s = l.match(/^status:\s*(.+)$/); if (s) status = s[1].trim();
209+
const t = l.match(/^title:\s*(.+)$/); if (t) title = unquoteYaml(t[1]);
210+
const s = l.match(/^status:\s*(.+)$/); if (s) status = unquoteYaml(s[1]);
196211
}
197212
} catch { continue; }
198213
out.push({ id: m[1], slug: m[2], title, status, file: name, path: file });
@@ -213,9 +228,12 @@ export function regenerateIndex(root = process.cwd()) {
213228
let body;
214229
try { body = fs.readFileSync(readme, "utf8"); } catch { return false; }
215230
const prds = listPrds(root);
231+
// A `|` in a title would close its table cell early and shift every column
232+
// after it, so escape it for the markdown table.
233+
const cell = (text) => String(text).replace(/\|/g, "\\|");
216234
const rows = prds.length
217235
? ["| # | Title | Status |", "|---|---|---|",
218-
...prds.map((p) => `| [${p.id}](${p.file}) | ${p.title} | ${p.status} |`)].join("\n")
236+
...prds.map((p) => `| [${p.id}](${p.file}) | ${cell(p.title)} | ${cell(p.status)} |`)].join("\n")
219237
: "_No PRDs yet._";
220238
// Use a function replacement so `$`-sequences in a PRD title (e.g. `$&`, `$1`,
221239
// `$\`` ) are inserted verbatim instead of being read as String.replace special

test/prd.test.mjs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import fs from "node:fs";
44
import os from "node:os";
55
import path from "node:path";
66

7-
import { createPrd, renderPrd } from "../src/prd.mjs";
7+
import { createPrd, listPrds, renderPrd } from "../src/prd.mjs";
88

99
test("renderPrd quotes titles so YAML metacharacters stay valid", () => {
1010
const body = renderPrd({
@@ -39,3 +39,41 @@ test("regenerateIndex keeps the README intact when a title holds a String.replac
3939
fs.rmSync(root, { recursive: true, force: true });
4040
}
4141
});
42+
43+
test("listPrds reads the title back without its YAML quotes or escapes", () => {
44+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-prd-"));
45+
try {
46+
createPrd("add a dark mode toggle", root);
47+
createPrd('ship CLI: handle "quoted" flags', root);
48+
49+
const [plain, quoted] = listPrds(root);
50+
assert.equal(plain.title, "Add a dark mode toggle");
51+
assert.equal(quoted.title, 'Ship CLI: handle "quoted" flags');
52+
assert.equal(plain.status, "Draft");
53+
54+
// The README index shows the title itself, not the YAML syntax around it.
55+
const readme = fs.readFileSync(path.join(root, "prd", "README.md"), "utf8");
56+
assert.match(readme, /\| Add a dark mode toggle \|/);
57+
assert.doesNotMatch(readme, /\| "Add a dark mode toggle" \|/);
58+
assert.doesNotMatch(readme, /\\"quoted\\"/);
59+
} finally {
60+
fs.rmSync(root, { recursive: true, force: true });
61+
}
62+
});
63+
64+
test("regenerateIndex escapes a pipe in a title so the row keeps its columns", () => {
65+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-prd-"));
66+
try {
67+
createPrd("support a|b routing", root);
68+
69+
const readme = fs.readFileSync(path.join(root, "prd", "README.md"), "utf8");
70+
const row = readme.split(/\r?\n/).find((l) => l.includes("routing"));
71+
assert.ok(row, "the PRD row must be in the index");
72+
// Splitting on unescaped pipes must still yield exactly three cells.
73+
const cells = row.split(/(?<!\\)\|/).slice(1, -1);
74+
assert.equal(cells.length, 3, `row should have 3 cells, got ${cells.length}: ${row}`);
75+
assert.equal(cells[1].trim(), "Support a\\|b routing");
76+
} finally {
77+
fs.rmSync(root, { recursive: true, force: true });
78+
}
79+
});

0 commit comments

Comments
 (0)