fix: improve frontmatter parsing
This commit is contained in:
@@ -179,6 +179,7 @@
|
|||||||
"tslog": "^4.10.2",
|
"tslog": "^4.10.2",
|
||||||
"undici": "^7.18.2",
|
"undici": "^7.18.2",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -148,6 +148,9 @@ importers:
|
|||||||
ws:
|
ws:
|
||||||
specifier: ^8.19.0
|
specifier: ^8.19.0
|
||||||
version: 8.19.0
|
version: 8.19.0
|
||||||
|
yaml:
|
||||||
|
specifier: ^2.8.2
|
||||||
|
version: 2.8.2
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.3.5
|
version: 4.3.5
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import JSON5 from "json5";
|
||||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
|
||||||
import type {
|
import type {
|
||||||
ClawdbotSkillMetadata,
|
ClawdbotSkillMetadata,
|
||||||
ParsedSkillFrontmatter,
|
ParsedSkillFrontmatter,
|
||||||
@@ -8,32 +10,8 @@ import type {
|
|||||||
SkillInvocationPolicy,
|
SkillInvocationPolicy,
|
||||||
} from "./types.js";
|
} 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 {
|
export function parseFrontmatter(content: string): ParsedSkillFrontmatter {
|
||||||
const frontmatter: ParsedSkillFrontmatter = {};
|
return parseFrontmatterBlock(content);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStringList(input: unknown): string[] {
|
function normalizeStringList(input: unknown): string[] {
|
||||||
@@ -99,7 +77,7 @@ export function resolveClawdbotMetadata(
|
|||||||
const raw = getFrontmatterValue(frontmatter, "metadata");
|
const raw = getFrontmatterValue(frontmatter, "metadata");
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as { clawdbot?: unknown };
|
const parsed = JSON5.parse(raw) as { clawdbot?: unknown };
|
||||||
if (!parsed || typeof parsed !== "object") return undefined;
|
if (!parsed || typeof parsed !== "object") return undefined;
|
||||||
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;
|
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;
|
||||||
if (!clawdbot || typeof clawdbot !== "object") return undefined;
|
if (!clawdbot || typeof clawdbot !== "object") return undefined;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import JSON5 from "json5";
|
import JSON5 from "json5";
|
||||||
|
|
||||||
|
import { parseFrontmatterBlock } from "../markdown/frontmatter.js";
|
||||||
import type {
|
import type {
|
||||||
ClawdbotHookMetadata,
|
ClawdbotHookMetadata,
|
||||||
HookEntry,
|
HookEntry,
|
||||||
@@ -7,107 +9,8 @@ import type {
|
|||||||
ParsedHookFrontmatter,
|
ParsedHookFrontmatter,
|
||||||
} from "./types.js";
|
} 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 {
|
export function parseFrontmatter(content: string): ParsedHookFrontmatter {
|
||||||
const frontmatter: ParsedHookFrontmatter = {};
|
return parseFrontmatterBlock(content);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStringList(input: unknown): string[] {
|
function normalizeStringList(input: unknown): string[] {
|
||||||
@@ -172,7 +75,6 @@ export function resolveClawdbotMetadata(
|
|||||||
const raw = getFrontmatterValue(frontmatter, "metadata");
|
const raw = getFrontmatterValue(frontmatter, "metadata");
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
try {
|
try {
|
||||||
// Use JSON5 to handle trailing commas and other relaxed JSON syntax
|
|
||||||
const parsed = JSON5.parse(raw) as { clawdbot?: unknown };
|
const parsed = JSON5.parse(raw) as { clawdbot?: unknown };
|
||||||
if (!parsed || typeof parsed !== "object") return undefined;
|
if (!parsed || typeof parsed !== "object") return undefined;
|
||||||
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;
|
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;
|
||||||
|
|||||||
44
src/markdown/frontmatter.test.ts
Normal file
44
src/markdown/frontmatter.test.ts
Normal 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
129
src/markdown/frontmatter.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user