From d9f2848d9092c46c851f5db6c6e9564ecd33bfae Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 14:19:20 +0200 Subject: [PATCH] feat(okr): delt frontmatter-parse/skrive-modul + #-strip-fix (SC2) [skip-docs] --- lib/frontmatter.mjs | 55 +++++++++++++++++++ tests/frontmatter.test.mjs | 109 +++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 lib/frontmatter.mjs create mode 100644 tests/frontmatter.test.mjs diff --git a/lib/frontmatter.mjs b/lib/frontmatter.mjs new file mode 100644 index 0000000..58d07ba --- /dev/null +++ b/lib/frontmatter.mjs @@ -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'; +} diff --git a/tests/frontmatter.test.mjs b/tests/frontmatter.test.mjs new file mode 100644 index 0000000..5121785 --- /dev/null +++ b/tests/frontmatter.test.mjs @@ -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'); +});