ktg-plugin-marketplace/plugins/ultraplan-local/lib/util/frontmatter.mjs
Kjell Tore Guttormsen 1a65d8e4d5 feat(graceful-handoff): 2.0 — migrate to skills/ with disable-model-invocation [skip-docs]
Step 1 of v2.0 plan. Hard cut from commands/ to skills/ per Anthropic
recommendation for new plugins. Frontmatter sets disable-model-invocation:
true and pins model: claude-sonnet-4-6. Docs (README, CLAUDE.md, root
README) deferred to Step 9 per plan.
2026-05-01 05:45:26 +02:00

158 lines
4.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/util/frontmatter.mjs
// Hand-rolled YAML-frontmatter parser.
//
// Supported subset:
// - String scalars (quoted or unquoted)
// - Numbers (integer + float)
// - Booleans (true / false)
// - null
// - Single-level dicts
// - Lists of scalars (- value)
//
// Deliberately rejects: nested dicts in lists, multi-line strings,
// anchors/aliases, tags, flow style ({...} / [...]).
//
// Why no js-yaml: zero-deps invariant. Templates emit only this subset.
import { issue, ok, fail } from './result.mjs';
const FRONTMATTER_RE = /^?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/;
/**
* Split raw markdown into { frontmatter, body }.
* Returns { hasFrontmatter: false } when no leading --- block exists.
*/
export function splitFrontmatter(text) {
if (typeof text !== 'string') return { hasFrontmatter: false, body: '' };
const stripped = text.replace(/^/, '');
const m = stripped.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
if (!m) return { hasFrontmatter: false, body: stripped };
return {
hasFrontmatter: true,
frontmatter: m[1],
body: m[2] || '',
};
}
/**
* Parse a YAML-frontmatter string into a JS object.
* @returns {import('./result.mjs').Result}
*/
export function parseFrontmatter(yamlText) {
if (typeof yamlText !== 'string') {
return fail(issue('FM_INPUT', 'Frontmatter input is not a string'));
}
const lines = yamlText.split(/\r?\n/);
const out = {};
const errors = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.trim() === '' || line.trimStart().startsWith('#')) {
i++;
continue;
}
const indentMatch = line.match(/^(\s*)/);
const indent = indentMatch ? indentMatch[0].length : 0;
if (indent > 0) {
errors.push(issue('FM_INDENT', `Unexpected indentation at line ${i + 1}`, 'Top-level keys only; nested dicts unsupported.'));
i++;
continue;
}
const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
if (!kv) {
errors.push(issue('FM_SYNTAX', `Cannot parse line ${i + 1}: ${line}`));
i++;
continue;
}
const key = kv[1];
const rest = kv[2];
if (rest === '' || rest === undefined) {
const list = [];
let j = i + 1;
while (j < lines.length) {
const next = lines[j];
if (next.trim() === '') { j++; continue; }
const itemMatch = next.match(/^(\s+)-\s+(.*)$/);
if (!itemMatch) break;
const itemIndent = itemMatch[1].length;
const firstContent = itemMatch[2];
const dictKeyMatch = firstContent.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
if (dictKeyMatch) {
const item = {};
item[dictKeyMatch[1]] = parseScalar(dictKeyMatch[2]);
let k = j + 1;
while (k < lines.length) {
const cont = lines[k];
if (cont.trim() === '') { k++; continue; }
const contMatch = cont.match(/^(\s+)([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
if (!contMatch) break;
if (contMatch[1].length <= itemIndent + 1) break;
item[contMatch[2]] = parseScalar(contMatch[3]);
k++;
}
list.push(item);
j = k;
} else {
list.push(parseScalar(firstContent));
j++;
}
}
if (list.length > 0) {
out[key] = list;
i = j;
} else {
out[key] = null;
i++;
}
continue;
}
out[key] = parseScalar(rest);
i++;
}
if (errors.length > 0) return { valid: false, errors, warnings: [], parsed: out };
return ok(out);
}
function parseScalar(raw) {
const s = raw.trim();
if (s === '') return '';
if (s === 'null' || s === '~') return null;
if (s === 'true') return true;
if (s === 'false') return false;
if (s === '[]') return [];
if (s === '{}') return {};
if (/^-?\d+$/.test(s)) return Number.parseInt(s, 10);
if (/^-?\d+\.\d+$/.test(s)) return Number.parseFloat(s);
if (s.startsWith('"') && s.endsWith('"')) {
return s.slice(1, -1).replace(/\\(.)/g, (_, ch) => {
if (ch === 'n') return '\n';
if (ch === 't') return '\t';
if (ch === 'r') return '\r';
return ch;
});
}
if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1);
return s;
}
/**
* Parse a markdown file's frontmatter directly from its full text.
* @returns {import('./result.mjs').Result}
*/
export function parseDocument(text) {
const split = splitFrontmatter(text);
if (!split.hasFrontmatter) {
return fail(issue('FM_MISSING', 'No frontmatter block found'));
}
const result = parseFrontmatter(split.frontmatter);
return { ...result, parsed: { frontmatter: result.parsed, body: split.body } };
}