From f74d276e501e7079cbdf004d806460e9d425153c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 19 May 2026 02:54:21 -0300 Subject: [PATCH] feat(triage): add minimal YAML frontmatter parser/serializer --- scripts/features/lib/frontmatter.mjs | 90 +++++++++++++++++++ .../unit/feature-triage/frontmatter.test.mjs | 79 ++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 scripts/features/lib/frontmatter.mjs create mode 100644 tests/unit/feature-triage/frontmatter.test.mjs diff --git a/scripts/features/lib/frontmatter.mjs b/scripts/features/lib/frontmatter.mjs new file mode 100644 index 0000000000..e90f031b4e --- /dev/null +++ b/scripts/features/lib/frontmatter.mjs @@ -0,0 +1,90 @@ +/** + * Minimal YAML frontmatter reader/writer for idea files. + * Supports: scalars (string/number/bool), nested object (one level), inline arrays [a, b]. + * Not a general YAML lib — kept simple intentionally. + */ + +const DELIM = "---"; + +function parseScalar(raw) { + const s = raw.trim(); + if (s === "true") return true; + if (s === "false") return false; + if (s === "null") return null; + if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s); + if (s.startsWith("[") && s.endsWith("]")) { + const inner = s.slice(1, -1).trim(); + if (!inner) return []; + return inner.split(",").map((x) => parseScalar(x.trim())); + } + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + return s; +} + +export function parseFrontmatter(text) { + if (typeof text !== "string") return null; + if (!text.startsWith(`${DELIM}\n`)) return null; + const rest = text.slice(DELIM.length + 1); + const closeIdx = rest.indexOf(`\n${DELIM}`); + if (closeIdx < 0) return null; + const body = rest.slice(0, closeIdx); + + const out = {}; + let currentNested = null; + + for (const line of body.split("\n")) { + if (!line.trim()) continue; + if (line.startsWith(" ") && currentNested) { + const m = line.match(/^\s{2}([\w_-]+):\s*(.*)$/); + if (m) currentNested[m[1]] = parseScalar(m[2]); + continue; + } + const m = line.match(/^([\w_-]+):\s*(.*)$/); + if (!m) continue; + const key = m[1]; + const val = m[2]; + if (val === "") { + currentNested = {}; + out[key] = currentNested; + } else { + currentNested = null; + out[key] = parseScalar(val); + } + } + return out; +} + +function serializeScalar(v) { + if (v === null) return "null"; + if (typeof v === "boolean") return String(v); + if (typeof v === "number") return String(v); + if (Array.isArray(v)) return `[${v.map(serializeScalar).join(", ")}]`; + return String(v); +} + +export function serializeFrontmatter(meta, body) { + const lines = [DELIM]; + for (const [k, v] of Object.entries(meta)) { + if (v && typeof v === "object" && !Array.isArray(v)) { + lines.push(`${k}:`); + for (const [nk, nv] of Object.entries(v)) { + lines.push(` ${nk}: ${serializeScalar(nv)}`); + } + } else { + lines.push(`${k}: ${serializeScalar(v)}`); + } + } + lines.push(DELIM, "", body); + return lines.join("\n"); +} + +export function stripFrontmatter(text) { + if (typeof text !== "string") return text; + if (!text.startsWith(`${DELIM}\n`)) return text; + const rest = text.slice(DELIM.length + 1); + const closeIdx = rest.indexOf(`\n${DELIM}`); + if (closeIdx < 0) return text; + return rest.slice(closeIdx + DELIM.length + 1).replace(/^\n+/, ""); +} diff --git a/tests/unit/feature-triage/frontmatter.test.mjs b/tests/unit/feature-triage/frontmatter.test.mjs new file mode 100644 index 0000000000..41e37eced3 --- /dev/null +++ b/tests/unit/feature-triage/frontmatter.test.mjs @@ -0,0 +1,79 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + parseFrontmatter, + serializeFrontmatter, + stripFrontmatter, +} from "../../../scripts/features/lib/frontmatter.mjs"; + +describe("parseFrontmatter", () => { + it("returns null when no frontmatter", () => { + assert.equal(parseFrontmatter("# Heading\nbody"), null); + }); + + it("parses simple key-value frontmatter", () => { + const md = `--- +issue: 1046 +last_synced_at: 2026-05-18T14:30:00Z +last_synced_comment_id: 1234567890 +--- + +# Body`; + const r = parseFrontmatter(md); + assert.equal(r.issue, 1046); + assert.equal(r.last_synced_at, "2026-05-18T14:30:00Z"); + assert.equal(r.last_synced_comment_id, 1234567890); + }); + + it("parses nested snapshot block", () => { + const md = `--- +issue: 1046 +snapshot: + thumbs: 8 + commenters: 4 + labels: [enhancement, ui] + state: open +--- + +body`; + const r = parseFrontmatter(md); + assert.equal(r.snapshot.thumbs, 8); + assert.equal(r.snapshot.commenters, 4); + assert.deepEqual(r.snapshot.labels, ["enhancement", "ui"]); + assert.equal(r.snapshot.state, "open"); + }); + + it("returns null on malformed delimiters", () => { + assert.equal(parseFrontmatter("---\nfoo: bar\n# no close"), null); + }); +}); + +describe("serializeFrontmatter", () => { + it("round-trips a parsed object", () => { + const original = `--- +issue: 1046 +last_synced_comment_id: 1234567890 +snapshot: + thumbs: 8 + commenters: 4 + state: open +--- + +# Body +content here`; + const meta = parseFrontmatter(original); + const re = serializeFrontmatter(meta, "# Body\ncontent here"); + const parsed2 = parseFrontmatter(re); + assert.deepEqual(parsed2, meta); + }); +}); + +describe("stripFrontmatter", () => { + it("returns body after frontmatter", () => { + const md = "---\nissue: 1\n---\n\n# Body"; + assert.equal(stripFrontmatter(md), "# Body"); + }); + it("returns full text if no frontmatter", () => { + assert.equal(stripFrontmatter("# Body"), "# Body"); + }); +});