// 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 path from 'node:path'; 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); // M3 (A1): vokab-term matcher kun som HELT ord (ikke substring -- kompound som // «Statusnotat» skal ikke snappe til Status). Ord-grense = ikke-bokstav/-siffer // paa begge sider (\b haandterer ikke ae/oe/aa -- derfor \p{L}\p{N}-lookaround). function termMatches(hay, term) { const esc = term.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(`(? kanonisk type. Full sti deltar ALDRI // (M3: et /okr/-katalogsegment skal ikke forgifte routeLevel-rutingen). function deriveType(title, sourcePath) { const hay = `${title} ${path.basename(String(sourcePath))}`.toLowerCase(); for (const t of TYPE_BY_LENGTH) { if (termMatches(hay, t)) 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 } = {}) { // B2: timestamp er mtime-basert (idempotens by construction) -- en manglende/ // ugyldig sourceMtime skal feile beskrivende her, ikke som naken RangeError // fra toISOString (og ALDRI falle tilbake til veggklokka). const mtime = new Date(sourceMtime ?? NaN); if (Number.isNaN(mtime.getTime())) { throw new TypeError( `projectFrontmatter: opts.sourceMtime maa vaere gyldig Date/ms-epoch (fikk: ${sourceMtime})`, ); } const resource = String(sourcePath ?? ''); const type = deriveType(concept.title, resource); const description = deriveDescription(concept.body); const tags = deriveTags(concept.title); const timestamp = mtime.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, }; }