feat: frontmatter parse/resolve/serialize
Co-Authored-By: Claude
This commit is contained in:
54
src/frontmatter.test.ts
Normal file
54
src/frontmatter.test.ts
Normal file
@@ -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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/frontmatter.ts
Normal file
52
src/frontmatter.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
export interface Pair {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNote(text: string): { frontmatter: Record<string, unknown>; 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<string, unknown>) : {};
|
||||||
|
return { frontmatter, body: stripped.slice(m[0].length) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFrontmatter(
|
||||||
|
presets: Pair[],
|
||||||
|
docFrontmatter: Record<string, unknown>,
|
||||||
|
custom: Pair[],
|
||||||
|
mergeDoc: boolean
|
||||||
|
): Pair[] {
|
||||||
|
const out: Pair[] = [];
|
||||||
|
const idx = new Map<string, number>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user