diff --git a/lib/innboks-frontmatter.mjs b/lib/innboks-frontmatter.mjs new file mode 100644 index 0000000..5bd8f2f --- /dev/null +++ b/lib/innboks-frontmatter.mjs @@ -0,0 +1,75 @@ +// innboks-frontmatter.mjs +// Step 5: deterministisk frontmatter-projeksjon for et konsept (fra splitConcepts). +// Regel-basert (INGEN LLM): type/tags utledes fra title+sti og snappes mot det +// lukkede vokabularet (okf-vocab); resource = original relativ sti (kanonisk navn); +// description = foerste ikke-tomme avsnitt (whitespace kollapset, trunkert); +// timestamp = ISO-8601 av original-fil-mtime (IKKE veggklokke -> idempotens by +// construction per maskin); kilde:innboks provenans-markoer (extension key for +// retrieval-rangering). Beregner ogsaa konseptets mal-sti +// (concept.destRel = routeLevel(type)/slug.md) FOER relasjons-steget (Step 6), +// jf. Pass-2 ordering-fiks (Revisions #21). Serialiserer via writeFrontmatter +// (additiv array-gren fra Step 3). Zero npm dependencies. + +import { writeFrontmatter } from './frontmatter.mjs'; +import { snapType, snapTags, routeLevel, TYPE_VOCAB, TAGS_VOCAB } from './okf-vocab.mjs'; + +const DESCRIPTION_MAX = 240; + +// Vokab sortert lengst-foerst saa "Overordnede OKR" matcher foer "OKR". +const TYPE_BY_LENGTH = [...TYPE_VOCAB].sort((a, b) => b.length - a.length); + +// Regel-utledning: foerste vokab-term som forekommer i title+sti -> kanonisk type. +function deriveType(title, sourcePath) { + const hay = `${title} ${sourcePath}`.toLowerCase(); + for (const t of TYPE_BY_LENGTH) { + if (hay.includes(t.toLowerCase())) return snapType(t); + } + return snapType(''); +} + +// Tags: vokab-termer som forekommer i title (vokab-rekkefolge bevart), saa snappet. +function deriveTags(title) { + const hay = String(title).toLowerCase(); + return snapTags(TAGS_VOCAB.filter((t) => hay.includes(t.toLowerCase()))); +} + +// Foerste ikke-tomme avsnitt, whitespace kollapset, trunkert deterministisk. +function deriveDescription(body) { + const para = String(body) + .split(/\n\s*\n/) + .map((p) => p.replace(/\s+/g, ' ').trim()) + .find((p) => p !== ''); + if (!para) return ''; + return para.length > DESCRIPTION_MAX ? `${para.slice(0, DESCRIPTION_MAX).trimEnd()}...` : para; +} + +// concept (fra splitConcepts) -> beriket konsept med OKF-frontmatter + destRel. +export function projectFrontmatter(concept, { sourcePath, sourceMtime } = {}) { + const resource = String(sourcePath ?? ''); + const type = deriveType(concept.title, resource); + const description = deriveDescription(concept.body); + const tags = deriveTags(concept.title); + const timestamp = new Date(sourceMtime).toISOString(); + const destRel = `${routeLevel(type)}/${concept.slug}.md`; + + // Kanonisk noekkel-rekkefolge; description/tags utelates naar tomme. + const fields = { type, resource, title: concept.title }; + if (description) fields.description = description; + if (tags.length > 0) fields.tags = tags; + fields.timestamp = timestamp; + fields.kilde = 'innboks'; + + const frontmatter = writeFrontmatter(fields); + + return { + ...concept, + type, + resource, + description, + tags, + timestamp, + kilde: 'innboks', + destRel, + frontmatter, + }; +} diff --git a/tests/innboks-frontmatter.test.mjs b/tests/innboks-frontmatter.test.mjs new file mode 100644 index 0000000..7d223c9 --- /dev/null +++ b/tests/innboks-frontmatter.test.mjs @@ -0,0 +1,124 @@ +// innboks-frontmatter.test.mjs +// Step 5 (SC idempotens): deterministisk frontmatter-projeksjon. projectFrontmatter +// utleder type/tags regel-basert (INGEN LLM) og snapper mot lukket vokab; resource = +// original relativ sti; description = foerste ikke-tomme avsnitt; timestamp = ISO av +// original-mtime (IKKE veggklokke); kilde:innboks; og fester concept.destRel = +// routeLevel(type)/slug.md FOER relasjons-steget. Asserterer paa parseFrontmatter(). +// get() (aldri substring for verdier). Moenster: tests/oppsett-okf-write.test.mjs:93. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseFrontmatter } from '../lib/frontmatter.mjs'; +import { TYPE_VOCAB, routeLevel } from '../lib/okf-vocab.mjs'; +import { splitConcepts } from '../lib/innboks-split.mjs'; +import { projectFrontmatter } from '../lib/innboks-frontmatter.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIX = join(HERE, 'fixtures', 'inbox-converted'); +const dokA = readFileSync(join(FIX, 'dok-a.md'), 'utf8'); + +const MTIME = new Date('2026-03-15T08:30:00.000Z'); +const OPTS = { sourcePath: 'innboks/dok-a.txt', sourceMtime: MTIME }; + +// Frontmatter-noekler i rekkefolge (kun linjestart-noekler; list-elementer hoppes over). +function fmKeyOrder(fm) { + const { raw } = parseFrontmatter(fm); + return (raw || '') + .split('\n') + .map((l) => { + const m = l.match(/^([a-z_]+):/); + return m ? m[1] : null; + }) + .filter(Boolean); +} + +test('projectFrontmatter: type snappes til lukket vokab; keyword i title -> kanonisk type', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + const { get } = parseFrontmatter(e.frontmatter); + assert.ok(TYPE_VOCAB.includes(get('type')), 'type i lukket vokab'); + assert.equal(get('type'), 'Tildelingsbrev', 'keyword "Tildelingsbrev" i title -> kanonisk type'); +}); + +test('projectFrontmatter: ukjent emne -> safe default Dokument (negativ vokab-sjekk)', () => { + const concept = { + sourceSlug: 'x', + title: 'Helt annerledes emne', + slug: 'helt-annerledes-emne', + level: 1, + body: 'Innhold uten vokab-noekkelord.', + }; + const e = projectFrontmatter(concept, { sourcePath: 'innboks/x.txt', sourceMtime: MTIME }); + assert.equal(parseFrontmatter(e.frontmatter).get('type'), 'Dokument'); + assert.doesNotMatch( + e.frontmatter, + /^type: (Tildelingsbrev|Virksomhetsplan|OKR)$/m, + 'ikke feil-snappet til en foeringstype', + ); +}); + +test('projectFrontmatter: resource = original relativ sti (kanonisk navn, ikke "source")', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + const { get } = parseFrontmatter(e.frontmatter); + assert.equal(get('resource'), 'innboks/dok-a.txt'); + assert.equal(get('source'), null, 'noekkelen heter resource, ikke source'); +}); + +test('projectFrontmatter: description deterministisk (2 kjoeringer byte-identiske, foerste avsnitt)', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e1 = projectFrontmatter(c1, OPTS); + const e2 = projectFrontmatter(c1, OPTS); + assert.equal(e1.frontmatter, e2.frontmatter, 'byte-identisk projeksjon'); + assert.match(parseFrontmatter(e1.frontmatter).get('description'), /Tildelingsbrevet gir overordnede/); +}); + +test('projectFrontmatter: lang body -> description trunkeres deterministisk', () => { + const longBody = 'A'.repeat(500); + const concept = { sourceSlug: 'lang', title: 'Lang', slug: 'lang', level: 1, body: longBody }; + const e = projectFrontmatter(concept, { sourcePath: 'innboks/lang.txt', sourceMtime: MTIME }); + const desc = parseFrontmatter(e.frontmatter).get('description'); + assert.ok(desc.length <= 244, 'trunkert til <= 240 + ellipsis'); + assert.match(desc, /\.\.\.$/, 'ellipsis-markoer ved trunkering'); +}); + +test('projectFrontmatter: timestamp = ISO av sourceMtime (idempotent paa tvers av veggklokke)', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + assert.equal(parseFrontmatter(e.frontmatter).get('timestamp'), MTIME.toISOString()); +}); + +test('projectFrontmatter: kilde:innboks provenans-markoer alltid satt', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + assert.equal(parseFrontmatter(e.frontmatter).get('kilde'), 'innboks'); + assert.equal(e.kilde, 'innboks', 'kilde ogsaa paa konsept-objektet'); +}); + +test('projectFrontmatter: concept.destRel satt + konsistent med routeLevel(type)', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + assert.ok(e.destRel, 'destRel satt FOER relasjons-steget'); + assert.equal(e.destRel, `${routeLevel(e.type)}/${c1.slug}.md`); + assert.equal(e.destRel, 'strategisk-kontekst/tildelingsbrev-2026.md', 'Tildelingsbrev -> strategisk-kontekst'); +}); + +test('projectFrontmatter: kanonisk noekkel-rekkefolge (type/resource/title/desc/tags/timestamp/kilde)', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + const order = fmKeyOrder(e.frontmatter); + const canon = ['type', 'resource', 'title', 'description', 'tags', 'timestamp', 'kilde']; + assert.deepEqual(order, canon.filter((k) => order.includes(k)), 'present keys i kanonisk rekkefolge'); + assert.equal(order[0], 'type', 'type foerst'); + assert.equal(order[order.length - 1], 'kilde', 'kilde sist'); +}); + +test('projectFrontmatter: tags er multi-linje liste (writeFrontmatter array-gren) + i vokab', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + assert.match(e.frontmatter, /^tags:$/m, 'multi-linje tags-noekkel'); + assert.match(e.frontmatter, /^ {2}- Tildelingsbrev$/m, 'tag-element i vokab'); +});