From f2edd9b0ab44b4ecd2e4a63e870cb5013c658602 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 01:44:24 +0000 Subject: [PATCH] feat: frontmatter parse/resolve/serialize Co-Authored-By: Claude --- src/frontmatter.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++ src/frontmatter.ts | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/frontmatter.test.ts create mode 100644 src/frontmatter.ts diff --git a/src/frontmatter.test.ts b/src/frontmatter.test.ts new file mode 100644 index 0000000..0699ed2 --- /dev/null +++ b/src/frontmatter.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; +import { parseNote, resolveFrontmatter, serializeFrontmatter } from "./frontmatter"; + +describe("parseNote", () => { + test("splits frontmatter and body, strips BOM", () => { + const { frontmatter, body } = parseNote("---\ntitle: Hi\n---\nHello\n"); + expect(frontmatter).toEqual({ title: "Hi" }); + expect(body).toBe("Hello\n"); + }); + test("no frontmatter returns empty object and full body", () => { + expect(parseNote("Just text")).toEqual({ frontmatter: {}, body: "Just text" }); + }); +}); + +describe("resolveFrontmatter", () => { + const presets = [{ key: "layout", value: "post" }, { key: "kind", value: "essay" }]; + test("presets only when mergeDoc is false", () => { + expect(resolveFrontmatter(presets, { title: "X" }, [], false)).toEqual(presets); + }); + test("merge appends doc keys and doc overrides preset value", () => { + const r = resolveFrontmatter(presets, { kind: "note", title: "X" }, [], true); + expect(r).toEqual([ + { key: "layout", value: "post" }, + { key: "kind", value: "note" }, + { key: "title", value: "X" }, + ]); + }); + test("custom rows come last and override everything", () => { + const r = resolveFrontmatter(presets, {}, [{ key: "layout", value: "page" }], false); + expect(r[0]).toEqual({ key: "layout", value: "page" }); + expect(r).toHaveLength(2); + }); + test("date object is rendered as YYYY-MM-DD", () => { + const r = resolveFrontmatter([], { date: new Date("2026-06-11T00:00:00Z") }, [], true); + expect(r).toEqual([{ key: "date", value: "2026-06-11" }]); + }); +}); + +describe("serializeFrontmatter", () => { + test("bare-safe values unquoted, others JSON-quoted", () => { + const out = serializeFrontmatter([ + { key: "layout", value: "post" }, + { key: "date", value: "2026-06-11" }, + { key: "title", value: "On making a game" }, + { key: "description", value: "And how it's different" }, + ]); + expect(out).toBe( + `---\nlayout: post\ndate: 2026-06-11\ntitle: On making a game\ndescription: "And how it's different"\n---\n` + ); + }); + test("empty value becomes quoted empty string", () => { + expect(serializeFrontmatter([{ key: "tags", value: "" }])).toBe(`---\ntags: ""\n---\n`); + }); +}); diff --git a/src/frontmatter.ts b/src/frontmatter.ts new file mode 100644 index 0000000..abc5a4e --- /dev/null +++ b/src/frontmatter.ts @@ -0,0 +1,52 @@ +import yaml from "js-yaml"; + +export interface Pair { + key: string; + value: string; +} + +export function parseNote(text: string): { frontmatter: Record; body: string } { + const stripped = text.replace(/^/, ""); + const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(stripped); + if (!m) return { frontmatter: {}, body: stripped }; + const loaded = yaml.load(m[1]); + const frontmatter = loaded && typeof loaded === "object" ? (loaded as Record) : {}; + return { frontmatter, body: stripped.slice(m[0].length) }; +} + +export function resolveFrontmatter( + presets: Pair[], + docFrontmatter: Record, + custom: Pair[], + mergeDoc: boolean +): Pair[] { + const out: Pair[] = []; + const idx = new Map(); + const put = (key: string, value: string) => { + const at = idx.get(key); + if (at !== undefined) out[at] = { key, value }; + else { idx.set(key, out.length); out.push({ key, value }); } + }; + for (const p of presets) put(p.key, p.value); + if (mergeDoc) for (const [k, v] of Object.entries(docFrontmatter)) put(k, stringifyScalar(v)); + for (const c of custom) put(c.key, c.value); + return out; +} + +function stringifyScalar(v: unknown): string { + if (v == null) return ""; + if (v instanceof Date) return v.toISOString().slice(0, 10); + if (typeof v === "object") return yaml.dump(v).trim(); + return String(v); +} + +export function serializeFrontmatter(pairs: Pair[]): string { + const lines = pairs.map((p) => `${p.key}: ${formatValue(p.value)}`); + return `---\n${lines.join("\n")}\n---\n`; +} + +function formatValue(v: string): string { + if (v === "") return '""'; + const bareSafe = /^[A-Za-z0-9_./-][A-Za-z0-9_./ -]*$/.test(v) && !/^\s|\s$/.test(v); + return bareSafe ? v : JSON.stringify(v); +}