fix: improve frontmatter parsing

This commit is contained in:
Peter Steinberger
2026-01-17 19:48:26 +00:00
parent 35a1d81518
commit 1e2ab8bf1e
6 changed files with 184 additions and 127 deletions

View File

@@ -1,5 +1,7 @@
import JSON5 from "json5";
import type { Skill } from "@mariozechner/pi-coding-agent";
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
import type {
ClawdbotSkillMetadata,
ParsedSkillFrontmatter,
@@ -8,32 +10,8 @@ import type {
SkillInvocationPolicy,
} from "./types.js";
function stripQuotes(value: string): string {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}
export function parseFrontmatter(content: string): ParsedSkillFrontmatter {
const frontmatter: ParsedSkillFrontmatter = {};
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return frontmatter;
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return frontmatter;
const block = normalized.slice(4, endIndex);
for (const line of block.split("\n")) {
const match = line.match(/^([\w-]+):\s*(.*)$/);
if (!match) continue;
const key = match[1];
const value = stripQuotes(match[2].trim());
if (!key || !value) continue;
frontmatter[key] = value;
}
return frontmatter;
return parseFrontmatterBlock(content);
}
function normalizeStringList(input: unknown): string[] {
@@ -99,7 +77,7 @@ export function resolveClawdbotMetadata(
const raw = getFrontmatterValue(frontmatter, "metadata");
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw) as { clawdbot?: unknown };
const parsed = JSON5.parse(raw) as { clawdbot?: unknown };
if (!parsed || typeof parsed !== "object") return undefined;
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;
if (!clawdbot || typeof clawdbot !== "object") return undefined;

View File

@@ -1,4 +1,6 @@
import JSON5 from "json5";
import { parseFrontmatterBlock } from "../markdown/frontmatter.js";
import type {
ClawdbotHookMetadata,
HookEntry,
@@ -7,107 +9,8 @@ import type {
ParsedHookFrontmatter,
} from "./types.js";
function stripQuotes(value: string): string {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}
/**
* Extract a multi-line block value from frontmatter lines.
* Handles indented continuation lines (YAML-style multi-line values).
*
* @param lines - All lines in the frontmatter block
* @param startIndex - Index of the line containing the key
* @returns The combined multi-line value and the number of lines consumed
*/
function extractMultiLineValue(
lines: string[],
startIndex: number,
): { value: string; linesConsumed: number } {
const startLine = lines[startIndex];
const match = startLine.match(/^([\w-]+):\s*(.*)$/);
if (!match) return { value: "", linesConsumed: 1 };
const inlineValue = match[2].trim();
// If there's a value on the same line, return it (single-line case)
if (inlineValue) {
return { value: inlineValue, linesConsumed: 1 };
}
// Multi-line case: collect indented continuation lines
const valueLines: string[] = [];
let i = startIndex + 1;
while (i < lines.length) {
const line = lines[i];
// Stop if we hit a non-indented line (new key or empty line without indent)
if (line.length > 0 && !line.startsWith(" ") && !line.startsWith("\t")) {
break;
}
valueLines.push(line);
i++;
}
// Join and trim the multi-line value
const combined = valueLines.join("\n").trim();
return { value: combined, linesConsumed: i - startIndex };
}
export function parseFrontmatter(content: string): ParsedHookFrontmatter {
const frontmatter: ParsedHookFrontmatter = {};
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return frontmatter;
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return frontmatter;
const block = normalized.slice(4, endIndex);
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++;
continue;
}
const key = match[1];
const inlineValue = match[2].trim();
if (!key) {
i++;
continue;
}
// Check if this is a multi-line value (no inline value and next line is indented)
if (!inlineValue && i + 1 < lines.length) {
const nextLine = lines[i + 1];
if (nextLine.startsWith(" ") || nextLine.startsWith("\t")) {
// Multi-line value
const { value, linesConsumed } = extractMultiLineValue(lines, i);
if (value) {
frontmatter[key] = value;
}
i += linesConsumed;
continue;
}
}
// Single-line value
const value = stripQuotes(inlineValue);
if (value) {
frontmatter[key] = value;
}
i++;
}
return frontmatter;
return parseFrontmatterBlock(content);
}
function normalizeStringList(input: unknown): string[] {
@@ -172,7 +75,6 @@ export function resolveClawdbotMetadata(
const raw = getFrontmatterValue(frontmatter, "metadata");
if (!raw) return undefined;
try {
// Use JSON5 to handle trailing commas and other relaxed JSON syntax
const parsed = JSON5.parse(raw) as { clawdbot?: unknown };
if (!parsed || typeof parsed !== "object") return undefined;
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;

View File

@@ -0,0 +1,44 @@
import JSON5 from "json5";
import { describe, expect, it } from "vitest";
import { parseFrontmatterBlock } from "./frontmatter.js";
describe("parseFrontmatterBlock", () => {
it("parses YAML block scalars", () => {
const content = `---
name: yaml-hook
description: |
line one
line two
---
`;
const result = parseFrontmatterBlock(content);
expect(result.name).toBe("yaml-hook");
expect(result.description).toBe("line one\nline two");
});
it("handles JSON5-style multi-line metadata", () => {
const content = `---
name: session-memory
metadata:
{
"clawdbot":
{
"emoji": "disk",
"events": ["command:new"],
},
}
---
`;
const result = parseFrontmatterBlock(content);
expect(result.metadata).toBeDefined();
const parsed = JSON5.parse(result.metadata ?? "") as { clawdbot?: { emoji?: string } };
expect(parsed.clawdbot?.emoji).toBe("disk");
});
it("returns empty when frontmatter is missing", () => {
const content = "# No frontmatter";
expect(parseFrontmatterBlock(content)).toEqual({});
});
});

129
src/markdown/frontmatter.ts Normal file
View File

@@ -0,0 +1,129 @@
import YAML from "yaml";
export type ParsedFrontmatter = Record<string, string>;
function stripQuotes(value: string): string {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}
function coerceFrontmatterValue(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return undefined;
}
}
return undefined;
}
function parseYamlFrontmatter(block: string): ParsedFrontmatter | null {
try {
const parsed = YAML.parse(block) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const result: ParsedFrontmatter = {};
for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
const key = rawKey.trim();
if (!key) continue;
const coerced = coerceFrontmatterValue(value);
if (coerced === undefined) continue;
result[key] = coerced;
}
return result;
} catch {
return null;
}
}
function extractMultiLineValue(
lines: string[],
startIndex: number,
): { value: string; linesConsumed: number } {
const startLine = lines[startIndex];
const match = startLine.match(/^([\w-]+):\s*(.*)$/);
if (!match) return { value: "", linesConsumed: 1 };
const inlineValue = match[2].trim();
if (inlineValue) {
return { value: inlineValue, linesConsumed: 1 };
}
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++;
}
const combined = valueLines.join("\n").trim();
return { value: combined, linesConsumed: i - startIndex };
}
function parseLineFrontmatter(block: string): ParsedFrontmatter {
const frontmatter: ParsedFrontmatter = {};
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++;
continue;
}
const key = match[1];
const inlineValue = match[2].trim();
if (!key) {
i++;
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) {
frontmatter[key] = value;
}
i += linesConsumed;
continue;
}
}
const value = stripQuotes(inlineValue);
if (value) {
frontmatter[key] = value;
}
i++;
}
return frontmatter;
}
export function parseFrontmatterBlock(content: string): ParsedFrontmatter {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return {};
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return {};
const block = normalized.slice(4, endIndex);
const yamlParsed = parseYamlFrontmatter(block);
if (yamlParsed !== null) return yamlParsed;
return parseLineFrontmatter(block);
}