mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 00:41:49 +00:00
260 lines
8.2 KiB
TypeScript
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: [] };
|
|
}
|