Files
openclaw/packages/markdown-core/src/frontmatter.ts
2026-07-18 07:52:06 +01:00

260 lines
8.2 KiB
TypeScript

// Markdown Core module implements frontmatter behavior.
import { isMap, isNode, isScalar, parseDocument } from "yaml";
type ParsedFrontmatter = Record<string, string>;
export type ParsedFrontmatterBlockResult = {
frontmatter: ParsedFrontmatter;
issues: FrontmatterParseIssue[];
};
export type FrontmatterParseIssue = {
code: string;
message: string;
};
type ParsedYamlValue = {
value: string;
kind: "scalar" | "structured";
};
function stripQuotes(value: string): string {
const quote = value.at(0);
return (quote === '"' || quote === "'") && value.at(-1) === quote ? value.slice(1, -1) : value;
}
function coerceYamlFrontmatterValue(value: unknown): ParsedYamlValue | undefined {
if (value === null || value === undefined) {
return undefined;
}
if (typeof value === "string") {
return { value: value.trim(), kind: "scalar" };
}
if (typeof value === "number" || typeof value === "boolean") {
return { value: String(value), kind: "scalar" };
}
if (typeof value === "object") {
try {
return { value: JSON.stringify(value), kind: "structured" };
} catch {
return undefined;
}
}
return undefined;
}
function parseLineFrontmatter(block: string): ParsedFrontmatter {
const result: ParsedFrontmatter = {};
const lines = block.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const match = lines.at(i)?.match(/^([\w-]+):\s*(.*)$/);
const key = match?.[1];
const rawValue = match?.[2];
if (!key || rawValue === undefined) {
continue;
}
let value = rawValue.trim();
if (!value && /^[ \t]/.test(lines.at(i + 1) ?? "")) {
const valueLines: string[] = [];
while (i + 1 < lines.length) {
const line = lines.at(i + 1);
if (line === undefined || (line && !/^[ \t]/.test(line))) {
break;
}
valueLines.push(line);
i += 1;
}
value = valueLines.join("\n").trim();
} else {
value = stripQuotes(value);
}
if (value) {
result[key] = value;
}
}
return result;
}
function normalizeFreeformDescription(block: string): string {
const doc = parseDocument(block, { schema: "core", prettyErrors: false });
if (!isMap(doc.contents)) {
return block;
}
const descriptionPair = doc.contents.items.find(
(pair) => isScalar(pair.key) && pair.key.value === "description",
);
const keyStart = isNode(descriptionPair?.key) ? descriptionPair.key.range?.[0] : undefined;
if (keyStart === undefined) {
return block;
}
const lineStart = block.lastIndexOf("\n", keyStart - 1) + 1;
const lineEnd = block.indexOf("\n", keyStart);
const end = lineEnd === -1 ? block.length : lineEnd;
const line = block.slice(lineStart, end);
const match = line.match(/^(?:description|"description"|'description'):\s*(.*)$/);
const rawValue = match?.[1]?.trim();
if (!rawValue || /^[|>](?:[1-9][+-]?|[+-][1-9]?)?$/.test(rawValue)) {
return block;
}
const replacement = `description: ${JSON.stringify(stripQuotes(rawValue))}`;
return `${block.slice(0, lineStart)}${replacement}${block.slice(end)}`;
}
function parseYamlFrontmatterOnce(
block: string,
fallback: ParsedFrontmatter,
): ParsedFrontmatterBlockResult {
try {
const doc = parseDocument(block, { schema: "core", prettyErrors: false });
if (doc.errors.length > 0 || !isMap(doc.contents)) {
return {
frontmatter: fallback,
issues:
doc.errors.length > 0
? doc.errors.map((error) => ({
code: error.code ?? error.name,
message: error.message,
}))
: [{ code: "INVALID_ROOT", message: "frontmatter must be a YAML mapping" }],
};
}
const parsed = doc.toJS() as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
frontmatter: fallback,
issues: [{ code: "INVALID_ROOT", message: "frontmatter must be a YAML mapping" }],
};
}
const inlineColonKeys = new Set<string>();
for (const pair of doc.contents.items) {
if (!isNode(pair.key)) {
continue;
}
const start = pair.key.range?.[0];
if (start === undefined) {
continue;
}
const lineEnd = block.indexOf("\n", start);
const line = block.slice(start, lineEnd === -1 ? block.length : lineEnd);
const match = line.match(/^([\w-]+):\s*(.*)$/);
if (match?.[1] && match[2]?.includes(":")) {
inlineColonKeys.add(match[1]);
}
}
const result: ParsedFrontmatter = {};
for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
const key = rawKey.trim();
const coerced = key ? coerceYamlFrontmatterValue(value) : undefined;
if (!coerced) {
continue;
}
const fallbackValue = Object.hasOwn(fallback, key) ? fallback[key] : undefined;
result[key] =
coerced.kind === "structured" && inlineColonKeys.has(key) && fallbackValue !== undefined
? fallbackValue
: coerced.value;
}
for (const [key, value] of Object.entries(fallback)) {
if (!Object.hasOwn(result, key)) {
result[key] = value;
}
}
return { frontmatter: result, issues: [] };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
frontmatter: fallback,
issues: [{ code: "YAML_EXCEPTION", message }],
};
}
}
function parseYamlFrontmatter(block: string): ParsedFrontmatterBlockResult {
const fallback = parseLineFrontmatter(block);
const parsed = parseYamlFrontmatterOnce(block, fallback);
if (parsed.issues.length === 0) {
return parsed;
}
const recoveredBlock = normalizeFreeformDescription(block);
return recoveredBlock === block ? parsed : parseYamlFrontmatterOnce(recoveredBlock, fallback);
}
export type ExtractedFrontmatterBlock = {
block: string;
body: string;
};
function normalizeFrontmatterContent(content: string): string {
return content
.replace(/^\uFEFF/, "")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
}
const FRONTMATTER_CLOSING_DELIMITER = /(?:^|\n)---[^\S\n]*(?:\n|(?![\s\S]))/;
const FRONTMATTER_OPENING_DELIMITER = /^---[^\S\n]*\n/;
function extractFrontmatterBlockFromNormalized(
normalized: string,
): ExtractedFrontmatterBlock | undefined {
const opening = FRONTMATTER_OPENING_DELIMITER.exec(normalized);
if (!opening) {
return undefined;
}
const blockStart = opening[0].length;
const tail = normalized.slice(blockStart);
const closing = FRONTMATTER_CLOSING_DELIMITER.exec(tail);
if (!closing) {
return undefined;
}
return {
block: tail.slice(0, closing.index),
body: tail.slice(closing.index + closing[0].length),
};
}
/** Splits a complete leading YAML frontmatter block from its Markdown body. */
export function extractFrontmatterBlock(content: string): ExtractedFrontmatterBlock | undefined {
const normalized = normalizeFrontmatterContent(content);
return extractFrontmatterBlockFromNormalized(normalized);
}
/** Removes a leading YAML frontmatter block and returns the remaining Markdown body. */
export function stripFrontmatterBlock(content: string): string {
const normalized = normalizeFrontmatterContent(content);
return (extractFrontmatterBlockFromNormalized(normalized)?.body ?? normalized).trim();
}
/** Parses leading YAML frontmatter into string values used by skill and metadata loaders. */
export function parseFrontmatterBlock(content: string): ParsedFrontmatter {
return parseFrontmatterBlockResult(content).frontmatter;
}
/** Parses frontmatter once while retaining recoverable YAML parser issues for owning loaders. */
export function parseFrontmatterBlockResult(content: string): ParsedFrontmatterBlockResult {
const normalized = normalizeFrontmatterContent(content);
const block = extractFrontmatterBlockFromNormalized(normalized)?.block;
if (block !== undefined) {
return block ? parseYamlFrontmatter(block) : { frontmatter: {}, issues: [] };
}
return FRONTMATTER_OPENING_DELIMITER.test(normalized)
? {
frontmatter: {},
issues: [
{
code: "UNTERMINATED_FRONTMATTER",
message: "missing closing --- delimiter",
},
],
}
: { frontmatter: {}, issues: [] };
}