Something went wrong. Try again.
An agent harness for spec-driven development.
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145import { 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<string, unknown>)) { 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, "");}