Files
openclaw/src/markdown/frontmatter.ts

227 lines
5.3 KiB
TypeScript

import YAML from "yaml";
export type ParsedFrontmatter = Record<string, string>;
type ParsedFrontmatterLineEntry = {
value: string;
kind: "inline" | "multiline";
rawInline: string;
};
type ParsedYamlValue = {
value: string;
kind: "scalar" | "structured";
};
function stripQuotes(value: string): string {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return 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 parseYamlFrontmatter(block: string): Record<string, ParsedYamlValue> | null {
try {
const parsed = YAML.parse(block, { schema: "core" }) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const result: Record<string, ParsedYamlValue> = {};
for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
const key = rawKey.trim();
if (!key) {
continue;
}
const coerced = coerceYamlFrontmatterValue(value);
if (!coerced) {
continue;
}
result[key] = coerced;
}
return result;
} catch {
return null;
}
}
function extractMultiLineValue(
lines: string[],
startIndex: number,
): {
value: string;
linesConsumed: number;
} {
const valueLines: string[] = [];
let i = startIndex + 1;
while (i < lines.length) {
const line = lines[i];
if (line.length > 0 && !line.startsWith(" ") && !line.startsWith("\t")) {
break;
}
valueLines.push(line);
i += 1;
}
const combined = valueLines.join("\n").trim();
return { value: combined, linesConsumed: i - startIndex };
}
function parseLineFrontmatter(block: string): Record<string, ParsedFrontmatterLineEntry> {
const result: Record<string, ParsedFrontmatterLineEntry> = {};
const lines = block.split("\n");
let i = 0;
while (i < lines.length) {
const line = lines[i];
const match = line.match(/^([\w-]+):\s*(.*)$/);
if (!match) {
i += 1;
continue;
}
const key = match[1];
const inlineValue = match[2].trim();
if (!key) {
i += 1;
continue;
}
if (!inlineValue && i + 1 < lines.length) {
const nextLine = lines[i + 1];
if (nextLine.startsWith(" ") || nextLine.startsWith("\t")) {
const { value, linesConsumed } = extractMultiLineValue(lines, i);
if (value) {
result[key] = {
value,
kind: "multiline",
rawInline: inlineValue,
};
}
i += linesConsumed;
continue;
}
}
const value = stripQuotes(inlineValue);
if (value) {
result[key] = {
value,
kind: "inline",
rawInline: inlineValue,
};
}
i += 1;
}
return result;
}
function lineFrontmatterToPlain(
parsed: Record<string, ParsedFrontmatterLineEntry>,
): ParsedFrontmatter {
const result: ParsedFrontmatter = {};
for (const [key, entry] of Object.entries(parsed)) {
result[key] = entry.value;
}
return result;
}
function isYamlBlockScalarIndicator(value: string): boolean {
return /^[|>][+-]?(\d+)?[+-]?$/.test(value);
}
function shouldPreferInlineLineValue(params: {
lineEntry: ParsedFrontmatterLineEntry;
yamlValue: ParsedYamlValue;
}): boolean {
const { lineEntry, yamlValue } = params;
if (yamlValue.kind !== "structured") {
return false;
}
if (lineEntry.kind !== "inline") {
return false;
}
if (isYamlBlockScalarIndicator(lineEntry.rawInline)) {
return false;
}
return lineEntry.value.includes(":");
}
function extractFrontmatterBlock(content: string): string | undefined {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) {
return undefined;
}
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) {
return undefined;
}
return normalized.slice(4, endIndex);
}
export function parseFrontmatterBlock(content: string): ParsedFrontmatter {
const block = extractFrontmatterBlock(content);
if (!block) {
return {};
}
const lineParsed = parseLineFrontmatter(block);
const yamlParsed = parseYamlFrontmatter(block);
if (yamlParsed === null) {
return lineFrontmatterToPlain(lineParsed);
}
const merged: ParsedFrontmatter = {};
for (const [key, yamlValue] of Object.entries(yamlParsed)) {
merged[key] = yamlValue.value;
const lineEntry = lineParsed[key];
if (!lineEntry) {
continue;
}
if (shouldPreferInlineLineValue({ lineEntry, yamlValue })) {
merged[key] = lineEntry.value;
}
}
for (const [key, lineEntry] of Object.entries(lineParsed)) {
if (!(key in merged)) {
merged[key] = lineEntry.value;
}
}
return merged;
}