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.
158 lines
4.7 KiB
JavaScript
158 lines
4.7 KiB
JavaScript
// 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 } };
|
||
}
|