104 lines
2.9 KiB
TypeScript
104 lines
2.9 KiB
TypeScript
|
|
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"),
|
||
|
|
};
|
||
|
|
}
|