import { createHash } from "node:crypto"; import path from "node:path"; import matter from "gray-matter"; import type { JsonObject, JsonValue, MarkdownHeading, SpecDocumentKind, SpecMarkdownDocument, } from "./types.js"; export function parseSpecMarkdown(input: { absolutePath: string; relativePath: string; raw: string; mtimeMs: number; }): SpecMarkdownDocument { const parsed = matter(input.raw); const frontmatter = normalizeFrontmatter(parsed.data); const fallbackId = stripMarkdownExtension(toPosix(input.relativePath)); const id = getString(frontmatter.id) ?? fallbackId; const headings = extractHeadings(parsed.content); const title = getString(frontmatter.title) ?? headings.find((heading) => heading.depth === 1)?.text ?? titleFromPath(input.relativePath); const kind = normalizeKind(getString(frontmatter.kind)); const status = getString(frontmatter.status); return { id, path: toPosix(input.relativePath), title, kind, ...(status === undefined ? {} : { status }), frontmatter, body: parsed.content.trimStart(), raw: input.raw, headings, sha256: sha256(input.raw), sizeBytes: Buffer.byteLength(input.raw), mtimeMs: input.mtimeMs, }; } export function extractHeadings(markdown: string): MarkdownHeading[] { const headings: MarkdownHeading[] = []; let inFence = false; for (const line of markdown.split(/\r?\n/)) { if (/^\s*```/.test(line) || /^\s*~~~/.test(line)) { inFence = !inFence; continue; } if (inFence) continue; const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); if (!match) continue; const marker = match[1]; const text = match[2]; if (!marker || !text) continue; headings.push({ depth: marker.length, text: text.trim(), slug: slugify(text), }); } return headings; } export function documentIdFromPath(relativePath: string): string { return stripMarkdownExtension(toPosix(relativePath)); } function normalizeKind(value: string | undefined): SpecDocumentKind { if ( value === "architecture" || value === "decision" || value === "task" || value === "glossary" || value === "experience" || value === "note" ) { return value; } return "unknown"; } function getString(value: JsonValue | undefined): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } function normalizeFrontmatter(value: unknown): JsonObject { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const result: JsonObject = {}; for (const [key, rawValue] of Object.entries(value)) { result[key] = normalizeJsonValue(rawValue); } return result; } function normalizeJsonValue(value: unknown): JsonValue { if (value === null) return null; if (value instanceof Date) return value.toISOString(); if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value; if (Array.isArray(value)) return value.map(normalizeJsonValue); if (typeof value === "object") { const objectValue: JsonObject = {}; for (const [key, nested] of Object.entries(value as Record)) { objectValue[key] = normalizeJsonValue(nested); } return objectValue; } return String(value); } function titleFromPath(relativePath: string): string { const base = path.basename(relativePath, path.extname(relativePath)); return base .split(/[-_\s]+/) .filter(Boolean) .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) .join(" "); } function stripMarkdownExtension(relativePath: string): string { return relativePath.replace(/\.md$/i, ""); } function toPosix(filePath: string): string { return filePath.split(path.sep).join(path.posix.sep); } function sha256(input: string): string { return createHash("sha256").update(input).digest("hex"); } function slugify(input: string): string { return input .trim() .toLowerCase() .replace(/[`*_~()[\]{}]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); }