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
53 changes: 51 additions & 2 deletions src/core/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import type { FrontmatterMap, FrontmatterValue, VaultPage } from "./types.ts";

const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
const KEY_VALUE_RE = /^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*?)\s*$/;
// Block-sequence dash item: `- item` or a bare `-` (empty item). The
// leading whitespace is required so `-foo` (a dash-prefixed string, not a
// list item) is NOT matched. Captures the item text after the whitespace.
const DASH_ITEM_RE = /^-(?:\s+(.*))?$/;
const PLAIN_SCALAR_RE = /^[A-Za-z0-9_./-](?:[A-Za-z0-9_./ -]*[A-Za-z0-9_./-])?$/;
const CODE_BLOCK_RE = /```[\s\S]*?```|`[^`]+`/g;
const SLUG_INVALID_RE = /[^a-z0-9]+/g;
Expand Down Expand Up @@ -81,14 +85,59 @@ export function parseFrontmatterText(text: string): readonly [FrontmatterMap, st
const body = text.slice(match[0].length).trim();
const metadata: FrontmatterMap = {};

for (const rawLine of fmBlock.split("\n")) {
const line = rawLine.trim();
// Block-sequence state: when a key is opened with an empty value
// (`key:`) and the following lines are dash items (`- a`), we assemble
// them into an array. This mirrors standard YAML block lists, which is
// what Obsidian's Properties editor and several writers emit — the
// original inline-array-only parser silently dropped such lists and
// surfaced spurious "missing field" errors in `o2b brain doctor`.
const lines = fmBlock.split("\n");
let blockKey: string | null = null;

for (let i = 0; i < lines.length; i++) {
const line = lines[i]!.trim();
if (!line || line.startsWith("#")) continue;

const dash = DASH_ITEM_RE.exec(line);
if (dash && blockKey !== null) {
const arr = (metadata[blockKey] as string[] | undefined) ?? [];
arr.push(stripQuotes(dash[1] ?? ""));
metadata[blockKey] = arr;
continue;
}

// Any non-dash line terminates an open block sequence.
blockKey = null;

const kv = KEY_VALUE_RE.exec(line);
if (!kv) continue;
const key = kv[1]!;
let value = kv[2]!.trim();

if (value === "") {
// Empty value. Peek at the next meaningful line: if it is a dash
// item this is a block-sequence header, otherwise keep the original
// empty-string behaviour so a genuine null scalar round-trips.
let j = i + 1;
let nextMeaningful: string | null = null;
while (j < lines.length) {
const cand = lines[j]!.trim();
if (!cand || cand.startsWith("#")) {
j++;
continue;
}
nextMeaningful = cand;
break;
}
if (nextMeaningful !== null && DASH_ITEM_RE.test(nextMeaningful)) {
metadata[key] = [];
blockKey = key;
} else {
metadata[key] = "";
}
continue;
}

if (value.startsWith("[") && value.endsWith("]")) {
const inner = value.slice(1, -1).trim();
metadata[key] = inner ? splitInlineArray(inner) : [];
Expand Down
68 changes: 68 additions & 0 deletions tests/core/vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,74 @@ describe("parseFrontmatter", () => {
expect(body).toBe("Just a note.");
});

test("parses block-style YAML list into an array", () => {
// Regression: the frontmatter reader only understood inline arrays
// (`tags: [a, b]`) and silently dropped block sequences
// (`tags:\n - a\n - b`). Block lists are what Obsidian's Properties
// editor and several writers emit, so the dropped `tags` surfaced as
// spurious `signal missing field: tags` errors in `o2b brain doctor`.
const path = join(tmp, "note.md");
writeFileSync(
path,
"---\nkind: brain-signal\ntags:\n - brain\n - brain/signal\n - brain/topic/foo\ntopic: foo\nsignal: positive\n---\n\nBody.\n",
);
const [meta, body] = parseFrontmatter(path);
expect(meta["kind"]).toBe("brain-signal");
expect(meta["tags"]).toEqual(["brain", "brain/signal", "brain/topic/foo"]);
expect(meta["topic"]).toBe("foo");
expect(meta["signal"]).toBe("positive");
expect(body).toBe("Body.");
});

test("block list with a bare empty dash item is not silently dropped", () => {
// Regression for the CodeRabbit edge case: an isolated `-` (or `- `)
// must NOT reset blockKey and drop the rest of the list. The empty
// item surfaces as an empty-string element, and subsequent items are
// still captured.
const path = join(tmp, "note.md");
writeFileSync(path, "---\ntags:\n - brain\n -\n - brain/signal\n---\n\nBody.\n");
const [meta] = parseFrontmatter(path);
expect(meta["tags"]).toEqual(["brain", "", "brain/signal"]);
});

test("dash-prefixed string without whitespace is not a list item", () => {
// `-foo` has no space after the dash, so it must NOT be parsed as a
// block-list item (only `- foo` / bare `-` count). As a lone line it
// is simply ignored as a non-key/value line.
const path = join(tmp, "note.md");
writeFileSync(path, "---\nkey: value\n-foo\nother: end\n---\n\nBody.\n");
const [meta] = parseFrontmatter(path);
expect(meta["key"]).toBe("value");
expect(meta["other"]).toBe("end");
expect(meta["-foo"]).toBeUndefined();
});

test("block-style list with quoted items parses as strings", () => {
const path = join(tmp, "note.md");
writeFileSync(path, '---\ntags:\n - plain\n - "needs, comma"\ntitle: Mixed\n---\n\nBody.\n');
const [meta] = parseFrontmatter(path);
expect(meta["tags"]).toEqual(["plain", "needs, comma"]);
});

test("empty scalar key (no following dash list) round-trips as empty string", () => {
// A genuine null scalar (`key:` with no block list after it) must
// stay an empty string, not be misread as a block-sequence header.
const path = join(tmp, "note.md");
writeFileSync(path, "---\ntitle: Hello\nsummary:\nbody: A note\n---\n\nBody.\n");
const [meta] = parseFrontmatter(path);
expect(meta["summary"]).toBe("");
expect(meta["body"]).toBe("A note");
});

test("block list and inline array interleave correctly", () => {
const path = join(tmp, "note.md");
writeFileSync(path, "---\ninline: [x, y]\nblock:\n - a\n - b\ntail: end\n---\n\nBody.\n");
const [meta] = parseFrontmatter(path);
expect(meta["inline"]).toEqual(["x", "y"]);
expect(meta["block"]).toEqual(["a", "b"]);
expect(meta["tail"]).toBe("end");
});

test("handles missing file gracefully", () => {
const [meta, body] = parseFrontmatter("/nonexistent/file.md");
expect(meta).toEqual({});
Expand Down
Loading