export interface MarkdownDoc { frontmatter: Record; body: string; hasFrontmatter: boolean; } export function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } export function asString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } export function asBoolean(value: unknown): boolean | null { return typeof value === "boolean" ? value : null; } export function asStringArray(value: unknown): string[] | null { if (value === undefined) return []; if (!Array.isArray(value)) return null; const out: string[] = []; for (const item of value) { const text = asString(item); if (!text) return null; out.push(text); } return out; } export function parseFrontmatterMarkdown(raw: string): MarkdownDoc { const normalized = raw.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) { return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false }; } const closing = normalized.indexOf("\n---\n", 4); if (closing < 0) { return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false }; } const frontmatterRaw = normalized.slice(4, closing).trim(); const body = normalized.slice(closing + 5).trim(); return { frontmatter: parseYamlFrontmatter(frontmatterRaw), body, hasFrontmatter: true, }; } function parseYamlFrontmatter(raw: string): Record { const prepared = prepareYamlLines(raw); if (prepared.length === 0) return {}; const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent); return isPlainRecord(parsed.value) ? parsed.value : {}; } function prepareYamlLines(raw: string) { return raw .split("\n") .map((line) => ({ indent: line.match(/^ */)?.[0].length ?? 0, content: line.trim(), })) .filter((line) => line.content.length > 0 && !line.content.startsWith("#")); } function parseYamlBlock( lines: Array<{ indent: number; content: string }>, startIndex: number, indentLevel: number, ): { value: unknown; nextIndex: number } { let index = startIndex; if (index >= lines.length || lines[index]!.indent < indentLevel) { return { value: {}, nextIndex: index }; } const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-"); if (isArray) { const values: unknown[] = []; while (index < lines.length) { const line = lines[index]!; if (line.indent < indentLevel) break; if (line.indent !== indentLevel || !line.content.startsWith("-")) break; const remainder = line.content.slice(1).trim(); index += 1; if (!remainder) { const nested = parseYamlBlock(lines, index, indentLevel + 2); values.push(nested.value); index = nested.nextIndex; continue; } values.push(parseYamlScalar(remainder)); } return { value: values, nextIndex: index }; } const record: Record = {}; while (index < lines.length) { const line = lines[index]!; if (line.indent < indentLevel) break; if (line.indent !== indentLevel) { index += 1; continue; } const separatorIndex = line.content.indexOf(":"); if (separatorIndex <= 0) { index += 1; continue; } const key = line.content.slice(0, separatorIndex).trim(); const remainder = line.content.slice(separatorIndex + 1).trim(); index += 1; if (!remainder) { const nested = parseYamlBlock(lines, index, indentLevel + 2); record[key] = nested.value; index = nested.nextIndex; continue; } record[key] = parseYamlScalar(remainder); } return { value: record, nextIndex: index }; } function parseYamlScalar(rawValue: string): unknown { const trimmed = rawValue.trim(); if (trimmed === "") return ""; if (trimmed === "null" || trimmed === "~") return null; if (trimmed === "true") return true; if (trimmed === "false") return false; if (trimmed === "[]") return []; if (trimmed === "{}") return {}; if (/^-?\d+(\.\d)?\d*$/.test(trimmed)) return Number(trimmed); if ( trimmed.startsWith("\"") || trimmed.startsWith("[") || trimmed.startsWith("{") ) { try { return JSON.parse(trimmed); } catch { return trimmed; } } return trimmed; }