ktg-plugin-marketplace/plugins/ultraplan-local/lib/util/frontmatter.mjs
Kjell Tore Guttormsen 205cdbf77f feat(ultraplan-local): Spor 1 wave 1 — lib/parsers + 66 tests grønn
7 nye moduler:
- lib/util/result.mjs — Result-shape m/ ok/fail/combine helpers
- lib/util/frontmatter.mjs — håndruller YAML-frontmatter-parser (subset, zero deps)
- lib/parsers/plan-schema.mjs — v1.7 step-regex + forbidden-heading-deteksjon (Fase/Phase/Stage/Steg)
- lib/parsers/manifest-yaml.mjs — per-step Manifest YAML-ekstraksjon m/ regex-validering
- lib/parsers/project-discovery.mjs — finn brief/research/architecture/plan/progress i prosjektmappe
- lib/parsers/arg-parser.mjs — $ARGUMENTS for alle 4 commands m/ flag-schema
- lib/parsers/bash-normalize.mjs — løftet fra hooks/scripts/pre-bash-executor.mjs

6 test-filer (66 tester totalt) — alle grønn:
- frontmatter (CRLF/BOM, scalars, lister, indent-rejection)
- plan-schema (positive Step-form, negative Fase/Phase/Stage/Steg, numbering, slicing)
- manifest-yaml (extraction, parsing, regex-validering, missing-key detection)
- project-discovery (sortert research, architecture-detection, phase-requirements)
- arg-parser (boolean/valued/multi-value flags, kvotert positional, ukjente flag)
- bash-normalize (\${x}/\\\\evasion, ANSI-stripping, full canonicalize-pipeline)

Forbereder Wave 2 (validators) og Spor 1-wiring inn i commands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 05:35:28 +02:00

138 lines
3.8 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 m2 = next.match(/^\s+-\s+(.*)$/);
if (!m2) break;
list.push(parseScalar(m2[1]));
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 } };
}