Submatter/packages/database/src/markdown-backlog/parse.ts

104 lines
2.9 KiB
TypeScript
Raw Normal View History

import { createHash } from "node:crypto";
import { parse as parseYaml } from "yaml";
import type { BacklogKind } from "./kinds";
import { isBacklogKind } from "./kinds";
import { epicFolderFromPath, planSlugFromPath } from "./paths";
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
export type ParsedBacklogFile = {
contentHash: string;
frontmatter: Record<string, unknown>;
bodyMarkdown: string;
kind: BacklogKind;
slug: string;
planSlug: string;
epicSlug: string | null;
title: string;
status: string | null;
priority: string | null;
owner: string | null;
};
function inferKindFromFilename(filename: string): BacklogKind | null {
if (filename.startsWith("Plan-")) return "plan";
if (filename.startsWith("Epic-")) return "epic";
if (filename.startsWith("Task-")) return "task";
return null;
}
function readString(fm: Record<string, unknown>, key: string): string | null {
const v = fm[key];
return typeof v === "string" && v.trim() ? v.trim() : null;
}
function firstHeading(markdown: string): string | null {
const m = markdown.match(/^\s*#\s+(.+)$/m);
return m?.[1]?.trim() ?? null;
}
export function hashFileContents(raw: string): string {
return createHash("sha256").update(raw, "utf8").digest("hex");
}
/**
* Parse a markdown document with optional YAML frontmatter.
* `repoPathPosix` is used to infer plan/epic segments when frontmatter omits them.
*/
export function parseBacklogMarkdown(
raw: string,
repoPathPosix: string,
): ParsedBacklogFile {
const contentHash = hashFileContents(raw);
let frontmatter: Record<string, unknown> = {};
let bodyMarkdown = raw;
const fmMatch = raw.match(FRONTMATTER);
if (fmMatch) {
try {
const parsed = parseYaml(fmMatch[1]);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
frontmatter = parsed as Record<string, unknown>;
}
} catch {
frontmatter = {};
}
bodyMarkdown = raw.slice(fmMatch[0].length);
}
const filename = repoPathPosix.split("/").pop() ?? "";
const baseName = filename.replace(/\.md$/i, "");
const fmKind = readString(frontmatter, "kind");
const inferred = inferKindFromFilename(filename);
const kind: BacklogKind =
fmKind && isBacklogKind(fmKind) ? fmKind : inferred ?? "task";
const slug =
readString(frontmatter, "slug") ??
(baseName.replace(/^(Plan|Epic|Task)-/, "") || baseName);
const planSlug =
readString(frontmatter, "plan_slug") ?? planSlugFromPath(repoPathPosix) ?? slug;
const epicSlug =
readString(frontmatter, "epic_slug") ??
(kind === "task" ? epicFolderFromPath(repoPathPosix) : null);
const title =
readString(frontmatter, "title") ?? firstHeading(bodyMarkdown) ?? baseName;
return {
contentHash,
frontmatter,
bodyMarkdown,
kind,
slug,
planSlug,
epicSlug,
title,
status: readString(frontmatter, "status"),
priority: readString(frontmatter, "priority"),
owner: readString(frontmatter, "owner"),
};
}