feat(okr): delt frontmatter-parse/skrive-modul + #-strip-fix (SC2) [skip-docs]

This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 14:19:20 +02:00
commit d9f2848d90
2 changed files with 164 additions and 0 deletions

55
lib/frontmatter.mjs Normal file
View file

@ -0,0 +1,55 @@
// frontmatter.mjs
// Delt frontmatter-parse/skrive for okr-pluginen. Zero npm dependencies.
//
// Konsoliderer den dupliserte flate `get()`-parseren fra inject-okr-context.mjs
// (:59-66) og coaching-hook.mjs (:20-27) til EN modul, med to korreksjoner mot
// den gamle atferden:
// 1. Linjeanker (`^\s*key:`, multiline) — key matcher kun ved linjestart
// (modulo innrykk), aldri som substring midt i en annen key/verdi. Bevarer
// first-match og nestet (innrykket) oppslag (load-bearing: inject:69).
// 2. Trailing " #kommentar" strippes KUN fra USITERTE verdier. Siterte verdier
// beholder en intern '#' ("A #B" -> A #B). Retter comment-leak-bugen der
// `okr_frikoblet_fra_loenn: true # ...` lakk kommentaren inn i verdien.
//
// Tolererer fler-linje OKF-list-verdier (f.eks. `tags:`) uten krasj: get() paa
// en list-key returnerer null (rest-av-linja er tom); list-elementer paa
// foelgende linjer konsumeres aldri (ingen konsument leser tre-filenes `tags`).
const FM_RE = /^---\n([\s\S]*?)\n---/;
export function parseFrontmatter(content) {
const match = String(content).match(FM_RE);
const raw = match ? match[1] : null;
const get = (key) => {
if (raw === null) return null;
const m = raw.match(new RegExp(`^\\s*${key}:\\s*(.*)$`, 'm'));
if (!m) return null;
let v = m[1].trim();
if (v === '') return null;
const q = v[0];
if (q === '"' || q === "'") {
const end = v.indexOf(q, 1);
if (end !== -1) return v.slice(1, end); // intern '#' bevart
v = v.slice(1); // uavsluttet quote: fall tilbake til resten
} else {
v = v.replace(/\s+#.*$/, '').trim(); // usitert: strip trailing kommentar
}
return v === '' ? null : v;
};
return { raw, get };
}
export function writeFrontmatter(fields) {
const lines = ['---'];
for (const [key, value] of Object.entries(fields)) {
const s = String(value);
// Siter naar verdien inneholder '#'/':' eller har kant-whitespace, slik at
// round-trip via parseFrontmatter bevarer den eksakt (jf. siter-#-regelen).
const needsQuote = /[#:]/.test(s) || /^\s|\s$/.test(s) || /^["']/.test(s);
lines.push(`${key}: ${needsQuote ? JSON.stringify(s) : s}`);
}
lines.push('---');
return lines.join('\n') + '\n';
}

109
tests/frontmatter.test.mjs Normal file
View file

@ -0,0 +1,109 @@
// frontmatter.test.mjs
// Tester den delte frontmatter-modulen (SC2): parseFrontmatter/get reproduserer
// hookenes flate first-match-parser MEN (1) strip trailing " #..." KUN fra
// USITERTE verdier, (2) anker key-match til linjestart (ingen midt-i-linje
// falske treff), og (3) tolererer fler-linje OKF-list-verdier (`tags:`) uten
// krasj. writeFrontmatter round-tripper og siterer `#`. Importerer modulen
// direkte (zero npm deps). Moenster: tests/coaching-hook.test.mjs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseFrontmatter, writeFrontmatter } from '../lib/frontmatter.mjs';
test('basic: sitert skalar leses', () => {
const { get } = parseFrontmatter('---\nnavn: "Org"\n---\n');
assert.equal(get('navn'), 'Org');
});
test('basic: usitert skalar leses', () => {
const { get } = parseFrontmatter('---\nnavn: Org\n---\n');
assert.equal(get('navn'), 'Org');
});
test('usitert verdi: trailing "# kommentar" strippes (comment-leak-fix)', () => {
const { get } = parseFrontmatter(
'---\nokr_frikoblet_fra_loenn: true # frikoblet fra lonn\n---\n',
);
assert.equal(get('okr_frikoblet_fra_loenn'), 'true', 'usitert: # strippes');
});
test('sitert verdi: intern "#" bevares (space-hash i quotes)', () => {
const { get } = parseFrontmatter('---\nkortform: "A #B"\n---\n');
assert.equal(get('kortform'), 'A #B', 'sitert: # er en del av verdien, ikke kommentar');
});
test('sitert verdi med kommentar UTENFOR quotes: kommentar ignoreres', () => {
const { get } = parseFrontmatter('---\nkortform: "A #B" # en note\n---\n');
assert.equal(get('kortform'), 'A #B');
});
test('first-match: forste forekomst vinner', () => {
const { get } = parseFrontmatter('---\nnavn: "Forste"\nnavn: "Andre"\n---\n');
assert.equal(get('navn'), 'Forste');
});
test('nestet: innrykket key resolver (load-bearing inject:69)', () => {
const { get } = parseFrontmatter(
'---\norganisasjon:\n navn: "NestetOrg"\n type: "offentlig"\n---\n',
);
assert.equal(get('navn'), 'NestetOrg', 'innrykket navn under organisasjon: skal resolve');
});
test('linjeanker: key matcher IKKE som substring av annen key', () => {
const { get } = parseFrontmatter('---\netternavn: "Hansen"\n---\n');
assert.equal(get('navn'), null, 'navn skal ikke matche etternavn: (anker linjestart)');
});
test('OKF type: Title-Case enkeltlinje', () => {
const { get } = parseFrontmatter('---\ntype: Organisasjonsprofil\n---\n');
assert.equal(get('type'), 'Organisasjonsprofil');
});
test('OKF resource: URL med ":" bevares', () => {
const { get } = parseFrontmatter('---\nresource: https://example.com/path\n---\n');
assert.equal(get('resource'), 'https://example.com/path');
});
test('OKF timestamp: sitert ISO-8601 med ":" og "+" bevares', () => {
const { get } = parseFrontmatter("---\ntimestamp: '2026-05-28T22:49:59+00:00'\n---\n");
assert.equal(get('timestamp'), '2026-05-28T22:49:59+00:00');
});
test('OKF fler-linje tags-liste: krasjer ikke + folgende skalar resolver', () => {
const content =
'---\n' +
'type: BigQuery Dataset\n' +
'tags:\n' +
'- ecommerce\n' +
'- web analytics\n' +
"timestamp: '2026-05-28T22:49:59+00:00'\n" +
'---\n# body\n';
const { get } = parseFrontmatter(content);
assert.doesNotThrow(() => get('tags'), 'get paa list-key skal ikke krasje');
assert.equal(get('type'), 'BigQuery Dataset');
assert.equal(
get('timestamp'),
'2026-05-28T22:49:59+00:00',
'skalar etter list-blokk skal fortsatt resolve',
);
});
test('ingen frontmatter: raw null, get returnerer null', () => {
const { raw, get } = parseFrontmatter('ingen frontmatter her\n');
assert.equal(raw, null);
assert.equal(get('navn'), null);
});
test('writeFrontmatter: round-trip via parseFrontmatter', () => {
const block = writeFrontmatter({ navn: 'Org', type: 'Organisasjonsprofil' });
const { get } = parseFrontmatter(block);
assert.equal(get('navn'), 'Org');
assert.equal(get('type'), 'Organisasjonsprofil');
});
test('writeFrontmatter: verdi med "#" siteres og round-tripper', () => {
const block = writeFrontmatter({ kortform: 'A #B' });
assert.match(block, /kortform: "A #B"/, 'verdi med # skal siteres');
const { get } = parseFrontmatter(block);
assert.equal(get('kortform'), 'A #B', 'sitert # round-tripper');
});