Files
gbrain/src/core/markdown.ts
T
891c28b582 v0.22.4 feat: frontmatter-guard — 0 resolver warnings + validate/audit/install-hook CLI (#448)
* fix: resolve check-resolvable warnings on master

- skills/maintain/SKILL.md: drop "citation audit" trigger; the focused
  citation-fixer skill is the single owner. Silences the MECE overlap
  warning surfaced by src/core/check-resolvable.ts.
- skills/RESOLVER.md: add citation-audit disambiguation row pointing
  citation-fixer (focused fix) and chain-into maintain for broader audit.
  Broaden query triggers ("who is", "background on", "notes on") so
  the failing routing-eval fixtures resolve.
- skills/enrich/SKILL.md: replace inlined Citation Requirements block with
  backtick-wrapped `skills/conventions/quality.md` reference (the format
  extractDelegationTargets recognizes). Silences the dry_violation warning.
- skills/citation-fixer/routing-eval.jsonl: rewrite the two failing fixtures
  to embed "fix citations" verbatim so the substring matcher passes.
- skills/query/SKILL.md frontmatter: mirror the broadened RESOLVER.md
  triggers so the trigger round-trip test passes.

Result: gbrain check-resolvable reports 0 warnings, 0 errors against
the actual checked-in skills/ tree.

* feat: extend parseMarkdown + lint with frontmatter validation surface

Add an opt-in validation surface to parseMarkdown(): when called with
{ validate: true }, returns errors[] populated with seven canonical
ParseValidationError codes:

  MISSING_OPEN, MISSING_CLOSE, YAML_PARSE, SLUG_MISMATCH,
  NULL_BYTES, NESTED_QUOTES, EMPTY_FRONTMATTER

Existing callers are unaffected — validation is opt-in via the new
opts argument. The validation logic lives here as the single source of
truth for what counts as malformed brain-page frontmatter.

src/commands/lint.ts now consumes parseMarkdown(..., { validate: true })
and emits stable lint rule names (frontmatter-missing-close,
frontmatter-yaml-parse, frontmatter-null-bytes, frontmatter-nested-quotes,
frontmatter-slug-mismatch, frontmatter-empty). MISSING_OPEN is suppressed
to avoid double-reporting with the legacy no-frontmatter rule.

Tests: test/markdown-validation.test.ts (NEW, all 7 codes) +
test/lint-frontmatter.test.ts (NEW, lint integration + suppression).

* feat: add brain-writer.ts orchestrator (scan / autoFix / writeBrainPage)

Thin orchestrator (~280 lines) on top of parseMarkdown(..., {validate:true})
and isSyncable() (the canonical brain-page filter from src/core/sync.ts).
Three consumers call into this module: the gbrain frontmatter CLI, the
frontmatter_integrity doctor subcheck, and the v0.22.4 migration audit
phase. Single source of truth — no parallel validation stack.

Public API:
  - autoFixFrontmatter(content, opts?): { content, fixes }
    Mechanical auto-repair for the fixable subset (NULL_BYTES,
    MISSING_CLOSE, NESTED_QUOTES, SLUG_MISMATCH). Idempotent.
  - writeBrainPage(filePath, content, opts): path-guarded, .bak backup
    before any in-place mutation. Path guard refuses writes outside
    sourcePath. .bak is the safety contract for non-git brain repos.
  - scanBrainSources(engine, opts?): walks every registered source via
    direct SQL on sources.local_path, uses isSyncable() to filter,
    blocks symlinks (matches sync's no-symlink policy), respects
    AbortSignal.

The dirty-tree guard from src/core/dry-fix.ts:getWorkingTreeStatus() is
NOT used here — it rejects non-git repos as unsafe, but brain repos
aren't always git repos. .bak backups are the contract that works
universally.

Tests: test/brain-writer.test.ts (NEW, 16 cases) — autoFix idempotency,
path-guard reject, .bak backup, per-source rollup, AbortSignal mid-scan,
single-source filter, missing-source-path graceful skip, symlink no-loop.

* feat: gbrain frontmatter CLI (validate / audit / install-hook)

New top-level command surface for the frontmatter-guard feature:

  gbrain frontmatter validate <path> [--json] [--fix] [--dry-run]
    Validate one .md file or recursively scan a directory. --fix writes
    .bak then rewrites in place. No git-tree-clean guard — .bak is the
    safety contract (works for both git and non-git brain repos).

  gbrain frontmatter audit [--source <id>] [--json]
    Read-only scan via scanBrainSources(). Per-source rollup grouped by
    error code. --fix is intentionally NOT available here; use validate
    --fix on the source path to repair.

  gbrain frontmatter install-hook [--source <id>] [--force] [--uninstall]
    Drops a pre-commit hook in each source that's a git repo (skips
    non-git sources with a one-line note). Hook script gracefully
    degrades when gbrain is missing on PATH (prints a warning, exits 0).
    Refuses to clobber existing hooks without --force; writes <hook>.bak.
    --uninstall reverses cleanly.

src/cli.ts wires frontmatter through handleCliOnly so --help works
without a DB connection. The audit subcommand instantiates an engine
internally only when needed.

Tests: test/frontmatter-cli.test.ts (NEW, 9 cases) +
test/frontmatter-install-hook.test.ts (NEW, 6 cases) — --help no-DB,
clean/broken validate, --fix dry-run, --fix non-git, --json envelope,
recursive directory scan with isSyncable filter parity, hook install
+ overwrite-protection + --force + --uninstall + silent-refresh.

* feat: doctor frontmatter_integrity subcheck

Adds a frontmatter_integrity subcheck under gbrain doctor that calls
scanBrainSources() (the same shared scanner the CLI and migration use).
Reports per-source counts grouped by error code, with a fix hint
pointing at `gbrain frontmatter validate <path> --fix`. Wrapped in
a doctor progress phase with heartbeat so 50K-page brain scans stay
visible.

Tests: test/doctor.test.ts (UPDATE) — assertion that the subcheck
calls scanBrainSources and the fix hint references the correct CLI.

* feat: frontmatter-guard skill (registered in manifest + RESOLVER)

New skill at skills/frontmatter-guard/SKILL.md that wraps the gbrain
frontmatter CLI for agent-driven workflows. Agent-agnostic — no
references to private host libraries. Registered in skills/manifest.json
and skills/RESOLVER.md (the trigger row was added in the Part A commit).

Triggers: "validate frontmatter", "check frontmatter", "fix frontmatter",
"frontmatter audit", "brain lint".

Includes routing-eval fixtures that pass the substring matcher. The
SKILL.md has the conformance-required Output Format and Anti-Patterns
sections. Anti-patterns explicitly call out: don't auto-fix MISSING_OPEN
or EMPTY_FRONTMATTER without user input, don't skip .bak backups, don't
install the pre-commit hook on non-git brain dirs.

* feat: v0.22.4 migration orchestrator (audit-only, source-aware)

Adds the v0.22.4 migration that surveys every registered source for
frontmatter issues and queues per-source repair commands without ever
mutating brain content. Three idempotent phases:

  - schema: no-op (no DB changes in v0.22.4)
  - audit: scanBrainSources() across ALL registered sources; writes
    JSON report to ~/.gbrain/migrations/v0.22.4-audit.json
  - emit-todo: appends one entry per source-with-issues to
    ~/.gbrain/migrations/pending-host-work.jsonl, each with the exact
    `gbrain frontmatter validate <source-path> --fix` command

The agent reads skills/migrations/v0.22.4.md after upgrade, surfaces
the report counts to the user, and runs the fix command only with
explicit consent. `apply-migrations --yes` never silently rewrites
brain pages.

Filename convention: TS orchestrator at v0_22_4.ts (underscores, since
TS module paths can't have dots); user-facing migration doc at
skills/migrations/v0.22.4.md (dotted, matches existing convention).
The pending-host-work.jsonl skill field references the dotted-path doc.

Skips cleanly when no sources are registered (fresh install).

Tests: test/migrations-v0_22_4.test.ts (NEW, 9 cases) + updated
test/migration-orchestrator-v0_21_0.test.ts to allow v0.22.4 after,
test/apply-migrations.test.ts skippedFuture arrays extended to include
v0.22.4, test/check-resolvable.test.ts regression guard asserting the
actual checked-in skills/ tree has 0 warnings + 0 errors.

* docs: pre-commit recipe + downstream agent upgrade notes for v0.22.4

- docs/integrations/pre-commit.md (NEW): recipe doc covering install,
  bypass (`git commit --no-verify`), uninstall, and downstream-fork
  integration notes. Includes the full pipeline diagram showing how
  the hook (write-time gate), doctor (audit gate), and CLI (fix tool)
  share parseMarkdown(..., {validate:true}) as the single source of
  truth.
- docs/UPGRADING_DOWNSTREAM_AGENTS.md: append v0.22.4 section with the
  diff pattern for forks that had inline frontmatter validators. Covers
  the five upgrade actions: replace ad-hoc validators, drop
  lib/brain-writer.mjs references (it never shipped), wire the doctor
  subcheck into custom health pipelines, optionally install the
  pre-commit hook on git-backed brain repos, and walk
  pending-host-work.jsonl after apply-migrations.
- llms.txt + llms-full.txt: regenerated from build:llms script after
  the new docs landed.

* chore: bump version and changelog (v0.22.4)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: handle null loadConfig() return in frontmatter + migration paths

CI typecheck caught three call sites that passed loadConfig()'s
GBrainConfig | null result straight into toEngineConfig() (which
expects GBrainConfig, not null):

  - src/commands/frontmatter.ts:64 (audit subcommand connect)
  - src/commands/frontmatter-install-hook.ts:86 (install-hook connect)
  - src/commands/migrations/v0_22_4.ts:59 (audit phase connect)

The frontmatter CLI and install-hook paths follow the existing
src/commands/repair-jsonb.ts pattern: throw 'No brain configured. Run:
gbrain init' so users get an actionable message instead of a TS-shaped
runtime crash.

The v0.22.4 migration audit phase takes a different shape: a fresh
install or test environment running apply-migrations shouldn't fail
hard just because there's no brain to scan yet. Return a clean
'skipped: no_brain_configured' phase result so the orchestrator
continues normally and the ledger records a complete (skipped) run.

* test: add v0.22.4 migration E2E + injection point for testability

Closes plan item B14 (the E2E that was promised but not delivered before
the original ship). Runs the v0_22_4 orchestrator end-to-end on PGLite
against a fixture brain with two registered sources and synthetic
malformed pages on disk. Asserts:

  - audit phase writes ~/.gbrain/migrations/v0.22.4-audit.json with
    per-source counts (NESTED_QUOTES + NULL_BYTES on alpha,
    NESTED_QUOTES on beta)
  - emit-todo phase appends one entry per source-with-issues to
    pending-host-work.jsonl, each pointing at skills/migrations/v0.22.4.md
    with the exact `gbrain frontmatter validate <source> --fix` command
  - the migration is audit-only — no fixture page is mutated
    during apply-migrations (no .bak created, contents byte-identical)
  - re-running the orchestrator is idempotent — JSONL stays at 2 lines

Adds a small test-injection point to v0_22_4.ts:
  __setTestEngineOverride(engine: BrainEngine | null): void

Mirrors src/commands/repair-jsonb.ts pattern. When set, phaseBAudit
uses the injected engine instead of loadConfig + createEngine. Production
path is unchanged: the override is null by default and the existing
loadConfig logic runs end-to-end. Required because Bun's os.homedir()
does not observe mid-process process.env.HOME mutations, so we can't
redirect loadConfig's config-file lookup via env-var overrides; the
injection point is the only hermetic way to E2E-test the orchestrator
without writing to the user's real ~/.gbrain/config.json.

Test runs unconditionally in CI's Tier 1 (no DATABASE_URL needed,
PGLite in-memory).

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 20:45:05 -07:00

398 lines
13 KiB
TypeScript

import matter from 'gray-matter';
import type { PageType } from './types.ts';
import { slugifyPath } from './sync.ts';
export type ParseValidationCode =
| 'MISSING_OPEN'
| 'MISSING_CLOSE'
| 'YAML_PARSE'
| 'SLUG_MISMATCH'
| 'NULL_BYTES'
| 'NESTED_QUOTES'
| 'EMPTY_FRONTMATTER';
export interface ParseValidationError {
code: ParseValidationCode;
message: string;
line?: number;
}
export interface ParseOpts {
/** When true, errors[] is populated. Existing callers unaffected. */
validate?: boolean;
/** When validate is true and frontmatter has a `slug:` field that doesn't
* match expectedSlug, emits SLUG_MISMATCH. */
expectedSlug?: string;
}
export interface ParsedMarkdown {
frontmatter: Record<string, unknown>;
compiled_truth: string;
timeline: string;
slug: string;
type: PageType;
title: string;
tags: string[];
/** Present iff opts.validate. Empty array means no errors. */
errors?: ParseValidationError[];
}
/**
* Parse a markdown file with YAML frontmatter into its components.
*
* Structure:
* ---
* type: concept
* title: Do Things That Don't Scale
* tags: [startups, growth]
* ---
* Compiled truth content here...
*
* <!-- timeline -->
* Timeline content here...
*
* The first --- pair is YAML frontmatter (handled by gray-matter).
* After frontmatter, the body is split at the first recognized timeline
* sentinel: `<!-- timeline -->` (preferred), `--- timeline ---` (decorated),
* or a plain `---` immediately preceding a `## Timeline` / `## History`
* heading (backward-compat for existing files). A bare `---` in body text
* is treated as a markdown horizontal rule, not a timeline separator.
*/
export function parseMarkdown(
content: string,
filePath?: string,
opts?: ParseOpts,
): ParsedMarkdown {
const errors: ParseValidationError[] = [];
// gray-matter is forgiving: it returns empty data + original content for
// pretty much any input. The validation surface below catches the cases
// it silently swallows. Validation only runs when opts.validate is true,
// so existing callers are unaffected.
let parsed: ReturnType<typeof matter> | null = null;
let yamlParseError: Error | null = null;
try {
parsed = matter(content);
} catch (e) {
yamlParseError = e as Error;
}
if (opts?.validate) {
collectValidationErrors(content, errors, {
yamlParseError,
expectedSlug: opts.expectedSlug,
parsedFrontmatter: parsed?.data ?? {},
});
}
// When YAML parsing failed (rare; gray-matter is forgiving), fall back to
// empty frontmatter + raw content as the body so non-validate callers still
// get a usable shape.
const frontmatter = (parsed?.data ?? {}) as Record<string, unknown>;
const body = parsed?.content ?? content;
const { compiled_truth, timeline } = splitBody(body);
const type = (frontmatter.type as PageType) || inferType(filePath);
const title = (frontmatter.title as string) || inferTitle(filePath);
const tags = extractTags(frontmatter);
const slug = (frontmatter.slug as string) || inferSlug(filePath);
const cleanFrontmatter = { ...frontmatter };
delete cleanFrontmatter.type;
delete cleanFrontmatter.title;
delete cleanFrontmatter.tags;
delete cleanFrontmatter.slug;
const result: ParsedMarkdown = {
frontmatter: cleanFrontmatter,
compiled_truth: compiled_truth.trim(),
timeline: timeline.trim(),
slug,
type,
title,
tags,
};
if (opts?.validate) result.errors = errors;
return result;
}
/**
* Inspect raw content for the 7 frontmatter validation classes that gray-matter
* silently accepts. Mutates `errors` in place. The order of checks is
* deliberate: cheap byte-level checks first, then structural checks, then
* YAML-parse-dependent checks.
*/
function collectValidationErrors(
content: string,
errors: ParseValidationError[],
ctx: {
yamlParseError: Error | null;
expectedSlug?: string;
parsedFrontmatter: Record<string, unknown>;
},
): void {
// 1. NULL_BYTES — binary corruption indicator.
const nullIdx = content.indexOf('\x00');
if (nullIdx >= 0) {
const line = content.slice(0, nullIdx).split('\n').length;
errors.push({
code: 'NULL_BYTES',
message: 'Content contains null bytes (likely binary corruption)',
line,
});
}
// 2. MISSING_OPEN — first non-empty line must be `---`.
const lines = content.split('\n');
let firstNonEmpty = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().length > 0) {
firstNonEmpty = i;
break;
}
}
if (firstNonEmpty === -1) {
// Empty file: treat as MISSING_OPEN. Don't run other structural checks.
errors.push({
code: 'MISSING_OPEN',
message: 'File is empty or whitespace-only; expected frontmatter starting with ---',
line: 1,
});
return;
}
if (lines[firstNonEmpty].trim() !== '---') {
errors.push({
code: 'MISSING_OPEN',
message: 'Frontmatter must start with --- on the first non-empty line',
line: firstNonEmpty + 1,
});
// Without an opener we can't reason about MISSING_CLOSE / EMPTY_FRONTMATTER
// / NESTED_QUOTES inside frontmatter. Stop structural checks here.
return;
}
// 3. MISSING_CLOSE — find the next `---` after the opener. If a markdown
// heading appears before it, that's a strong signal the closing
// delimiter is missing (the heading was meant to be in the body).
let closeLine = -1;
let headingBeforeClose = -1;
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
const t = lines[i].trim();
if (t === '---') {
closeLine = i;
break;
}
if (/^#{1,6}\s/.test(t) && headingBeforeClose === -1) {
headingBeforeClose = i;
}
}
if (closeLine === -1) {
errors.push({
code: 'MISSING_CLOSE',
message:
headingBeforeClose >= 0
? `No closing --- before heading at line ${headingBeforeClose + 1}`
: 'No closing --- delimiter found',
line: headingBeforeClose >= 0 ? headingBeforeClose + 1 : firstNonEmpty + 1,
});
return;
}
if (headingBeforeClose >= 0 && headingBeforeClose < closeLine) {
errors.push({
code: 'MISSING_CLOSE',
message: `Heading at line ${headingBeforeClose + 1} found inside frontmatter zone (closing --- comes after)`,
line: headingBeforeClose + 1,
});
}
// 4. EMPTY_FRONTMATTER — open and close present but nothing meaningful between.
const fmBody = lines.slice(firstNonEmpty + 1, closeLine).join('\n').trim();
if (fmBody.length === 0) {
errors.push({
code: 'EMPTY_FRONTMATTER',
message: 'Frontmatter block is empty',
line: firstNonEmpty + 1,
});
}
// 5. NESTED_QUOTES — common breakage pattern: `title: "Name "Nick" Last"`.
// Detect any frontmatter `key: ...` line whose value contains 3 or more
// unescaped double-quote characters. A clean quoted value has 2.
for (let i = firstNonEmpty + 1; i < closeLine; i++) {
const line = lines[i];
const m = line.match(/^\s*[A-Za-z_][\w-]*\s*:\s*(.*)$/);
if (!m) continue;
const value = m[1];
let count = 0;
for (let j = 0; j < value.length; j++) {
if (value[j] === '"' && (j === 0 || value[j - 1] !== '\\')) count++;
}
if (count >= 3) {
errors.push({
code: 'NESTED_QUOTES',
message: 'Nested double quotes in YAML value (use single quotes for the outer)',
line: i + 1,
});
}
}
// 6. YAML_PARSE — gray-matter threw.
if (ctx.yamlParseError) {
errors.push({
code: 'YAML_PARSE',
message: `YAML parse failed: ${ctx.yamlParseError.message}`,
line: firstNonEmpty + 1,
});
}
// 7. SLUG_MISMATCH — only when expectedSlug was provided and a slug field exists.
if (ctx.expectedSlug && typeof ctx.parsedFrontmatter.slug === 'string') {
const declared = ctx.parsedFrontmatter.slug as string;
if (declared !== ctx.expectedSlug) {
errors.push({
code: 'SLUG_MISMATCH',
message: `Frontmatter slug "${declared}" does not match path-derived slug "${ctx.expectedSlug}"`,
});
}
}
}
/**
* Split body content at the first recognized timeline sentinel.
* Returns compiled_truth (before) and timeline (after).
*
* Recognized sentinels (in order of precedence):
* 1. `<!-- timeline -->` — preferred, unambiguous, what serializeMarkdown emits
* 2. `--- timeline ---` — decorated separator
* 3. `---` ONLY when the next non-empty line is `## Timeline` or `## History`
* (backward-compat fallback for older gbrain-written files)
*
* A plain `---` line is a markdown horizontal rule, NOT a timeline separator.
* Treating bare `---` as a separator caused 83% content truncation on wiki corpora.
*/
export function splitBody(body: string): { compiled_truth: string; timeline: string } {
const lines = body.split('\n');
const splitIndex = findTimelineSplitIndex(lines);
if (splitIndex === -1) {
return { compiled_truth: body, timeline: '' };
}
const compiled_truth = lines.slice(0, splitIndex).join('\n');
const timeline = lines.slice(splitIndex + 1).join('\n');
return { compiled_truth, timeline };
}
function findTimelineSplitIndex(lines: string[]): number {
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed === '<!-- timeline -->' || trimmed === '<!--timeline-->') {
return i;
}
if (trimmed === '--- timeline ---' || /^---\s+timeline\s+---$/i.test(trimmed)) {
return i;
}
if (trimmed === '---') {
const beforeContent = lines.slice(0, i).join('\n').trim();
if (beforeContent.length === 0) continue;
for (let j = i + 1; j < lines.length; j++) {
const next = lines[j].trim();
if (next.length === 0) continue;
if (/^##\s+(timeline|history)\b/i.test(next)) return i;
break;
}
}
}
return -1;
}
/**
* Serialize a page back to markdown format.
* Produces: frontmatter + compiled_truth + --- + timeline
*/
export function serializeMarkdown(
frontmatter: Record<string, unknown>,
compiled_truth: string,
timeline: string,
meta: { type: PageType; title: string; tags: string[] },
): string {
// Build full frontmatter including type, title, tags
const fullFrontmatter: Record<string, unknown> = {
type: meta.type,
title: meta.title,
...frontmatter,
};
if (meta.tags.length > 0) {
fullFrontmatter.tags = meta.tags;
}
const yamlContent = matter.stringify('', fullFrontmatter).trim();
let body = compiled_truth;
if (timeline) {
body += '\n\n<!-- timeline -->\n\n' + timeline;
}
return yamlContent + '\n\n' + body + '\n';
}
function inferType(filePath?: string): PageType {
if (!filePath) return 'concept';
// Normalize: add leading / for consistent matching.
// Wiki subtypes and /writing/ check FIRST — they're stronger signals than
// ancestor directories. e.g. `projects/blog/writing/essay.md` is a piece of
// writing, not a project page; `tech/wiki/analysis/foo.md` is analysis,
// not a hit on the broader `tech/` ancestor.
const lower = ('/' + filePath).toLowerCase();
if (lower.includes('/writing/')) return 'writing';
if (lower.includes('/wiki/analysis/')) return 'analysis';
if (lower.includes('/wiki/guides/') || lower.includes('/wiki/guide/')) return 'guide';
if (lower.includes('/wiki/hardware/')) return 'hardware';
if (lower.includes('/wiki/architecture/')) return 'architecture';
if (lower.includes('/wiki/concepts/') || lower.includes('/wiki/concept/')) return 'concept';
if (lower.includes('/people/') || lower.includes('/person/')) return 'person';
if (lower.includes('/companies/') || lower.includes('/company/')) return 'company';
if (lower.includes('/deals/') || lower.includes('/deal/')) return 'deal';
if (lower.includes('/yc/')) return 'yc';
if (lower.includes('/civic/')) return 'civic';
if (lower.includes('/projects/') || lower.includes('/project/')) return 'project';
if (lower.includes('/sources/') || lower.includes('/source/')) return 'source';
if (lower.includes('/media/')) return 'media';
// BrainBench v1 amara-life-v1 corpus directories. One-slash slug convention
// means source paths look like `emails/em-0001.md`, `slack/sl-0037.md`, etc.
if (lower.includes('/emails/') || lower.includes('/email/')) return 'email';
if (lower.includes('/slack/')) return 'slack';
if (lower.includes('/cal/') || lower.includes('/calendar/')) return 'calendar-event';
if (lower.includes('/notes/') || lower.includes('/note/')) return 'note';
if (lower.includes('/meetings/') || lower.includes('/meeting/')) return 'meeting';
return 'concept';
}
function inferTitle(filePath?: string): string {
if (!filePath) return 'Untitled';
// Extract filename without extension, convert dashes/underscores to spaces
const parts = filePath.split('/');
const filename = parts[parts.length - 1]?.replace(/\.md$/i, '') || 'Untitled';
return filename.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function inferSlug(filePath?: string): string {
if (!filePath) return 'untitled';
return slugifyPath(filePath);
}
function extractTags(frontmatter: Record<string, unknown>): string[] {
const tags = frontmatter.tags;
if (!tags) return [];
if (Array.isArray(tags)) return tags.map(String);
if (typeof tags === 'string') return tags.split(',').map(t => t.trim()).filter(Boolean);
return [];
}