ktg-plugin-marketplace/plugins/voyage/lib/util/frontmatter.mjs
Kjell Tore Guttormsen 7a90d348ad feat(voyage)!: marketplace handoff — rename plugins/ultraplan-local to plugins/voyage [skip-docs]
Session 5 of voyage-rebrand (V6). Operator-authorized cross-plugin scope.

- git mv plugins/ultraplan-local plugins/voyage (rename detected, history preserved)
- .claude-plugin/marketplace.json: voyage entry replaces ultraplan-local
- CLAUDE.md: voyage row in plugin list, voyage in design-system consumer list
- README.md: bulk rename ultra*-local commands -> trek* commands; ultraplan-local refs -> voyage; type discriminators (type: trekbrief/trekreview); session-title pattern (voyage:<command>:<slug>); v4.0.0 release-note paragraph
- plugins/voyage/.claude-plugin/plugin.json: homepage/repository URLs point to monorepo voyage path
- plugins/voyage/verify.sh: drop URL whitelist exception (no longer needed)

Closes voyage-rebrand. bash plugins/voyage/verify.sh PASS 7/7. npm test 361/361.
2026-05-05 15:37:52 +02:00

158 lines
4.7 KiB
JavaScript
Raw Permalink 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 } };
}