Files
multica/packages/core/skills/frontmatter.ts
Bohan Jiang 0f9d9d1494 fix(skills): align Go/TS frontmatter coercion for non-scalar values (#3614)
The Go SKILL.md frontmatter parser unmarshalled into a {Name,Description}
string struct, so a non-scalar value (a list/map written where a scalar
belongs) made the whole decode fail and dropped even a valid sibling
`name`. The TS parser instead kept the name and JSON-encoded the value,
so the file-viewer (TS) and the import path (Go) could disagree about
the same SKILL.md.

Decode into a generic map and coerce per key on the Go side, mirroring
the TS coercion (scalars -> literal form, sequences/mappings -> JSON), so
both sides produce identical results and a structured value never
discards a sibling key. Rename ParseFrontmatter -> ParseSkillFrontmatter
to remove the cross-language name clash with the TS parseFrontmatter
(which returns {frontmatter, body}), and drop the unused TS
parseSkillFrontmatter export.

Add parity tests for sequence/mapping values plus name-only,
description-only, leading-blank-line and triple-dash-in-body edge cases
on both sides.

Follow-up to #3543 / MUL-2842.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 19:42:20 +08:00

50 lines
1.4 KiB
TypeScript

import { parse as parseYaml } from "yaml";
// Keeping the trailing newline inside the capture group matters: yaml's `|`
// clip chomping only preserves a final newline when the input itself contains
// one. Mirrors the regex used by the Go side in `server/internal/skill`.
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?\r?\n)---\r?\n?/;
export type SkillFrontmatter = Record<string, string>;
export interface ParsedFrontmatter {
frontmatter: SkillFrontmatter | null;
body: string;
}
export function parseFrontmatter(raw: string): ParsedFrontmatter {
const match = FRONTMATTER_RE.exec(raw);
if (!match) return { frontmatter: null, body: raw };
const yamlBlock = match[1]!;
const body = raw.slice(match[0].length);
let parsed: unknown;
try {
parsed = parseYaml(yamlBlock);
} catch {
return { frontmatter: null, body };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { frontmatter: null, body };
}
const result: SkillFrontmatter = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (value == null) continue;
if (typeof value === "string") {
result[key] = value;
} else if (typeof value === "number" || typeof value === "boolean") {
result[key] = String(value);
} else {
result[key] = JSON.stringify(value);
}
}
return {
frontmatter: Object.keys(result).length > 0 ? result : null,
body,
};
}