Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/prd.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 });
Expand All @@ -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
Expand Down
40 changes: 39 additions & 1 deletion test/prd.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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(/(?<!\\)\|/).slice(1, -1);
assert.equal(cells.length, 3, `row should have 3 cells, got ${cells.length}: ${row}`);
assert.equal(cells[1].trim(), "Support a\\|b routing");
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
Loading