/** * lib/personalization.mjs — Read-only section inspection for modes/_profile.md * * Core module for identifying the 8 structured profile sections, scanning * markdown heading boundaries, normalizing bodies (stripping comments and * whitespace noise), and determining whether sections are still template content. */ /** The eight template sections, in template order. `id` is the API/JSON key. */ export const SECTIONS = [ { id: "targetRoles", heading: "## Your Target Roles" }, { id: "adaptiveFraming", heading: "## Your Adaptive Framing" }, { id: "exitNarrative", heading: "## Your Exit Narrative" }, { id: "crossCuttingAdvantage", heading: "## Your Cross-cutting Advantage" }, { id: "portfolio", heading: "## Your Portfolio / Demo" }, { id: "compTargets", heading: "## Your Comp Targets" }, { id: "negotiationScripts", heading: "## Your Negotiation Scripts" }, { id: "locationPolicy", heading: "## Your Location Policy" }, ]; /** * Locate every `# ` / `## ` heading by SOURCE OFFSET — nothing is normalized, * so a caller can splice the original string and keep every other byte. * A `## ` line inside a code fence is not a heading. `unsure` names the first * thing that makes the boundaries ambiguous (a heading-looking line inside an * HTML comment, a comment or fence that never closes), or is null. */ export function scanHeadings(text) { const heads = []; let unsure = null; let fence = null; let inComment = false; for (let pos = 0; pos < text.length; ) { const nl = text.indexOf("\n", pos); const end = nl === -1 ? text.length : nl + 1; const line = text.slice(pos, end).replace(/\r?\n$/, ""); const mark = /^ {0,3}(`{3,}|~{3,})/.exec(line); if (fence) { if (mark && mark[1][0] === fence[0] && mark[1].length >= fence.length && !line.slice(mark[0].length).trim()) fence = null; } else { const heading = /^##? /.test(line); if (heading && inComment) unsure ??= `a heading-looking line ("${line.trim().slice(0, 60)}") sits inside an HTML comment`; else if (heading) heads.push({ level: line.startsWith("## ") ? 2 : 1, heading: line.trim(), start: pos, bodyStart: end }); else if (mark && !inComment) fence = mark[1]; if (!fence) { for (let i = 0; i < line.length; ) { const j = line.indexOf(inComment ? "-->" : "-- b -->` leaves // a fresh `", start + 4); if (end === -1) break; from = end + 3; } if (out === s) break; s = out; } return s.replace(/\s+/g, " ").trim(); } /** `## ` sections of a document as [heading, normalized body] pairs. */ export function sectionBodies(md) { const text = String(md ?? ""); return scanHeadings(text).heads.filter((h) => h.level === 2).map((h) => [h.heading, normBody(text.slice(h.bodyStart, h.end))]); } /** * Per-section state of a user's _profile.md against the shipped template: * "filled" (differs from the template), "template" (still the shipped text), * or "missing" (heading absent or body empty). */ export function sectionState(userMd, templateMd) { const user = new Map(sectionBodies(userMd)); const tpl = new Map(sectionBodies(templateMd)); return SECTIONS.map((s) => { if (!user.has(s.heading)) return { id: s.id, heading: s.heading, state: 'missing' }; const userBody = user.get(s.heading); if (!userBody) return { id: s.id, heading: s.heading, state: 'missing' }; const same = userBody === (tpl.get(s.heading) ?? ''); return { id: s.id, heading: s.heading, state: same ? 'template' : 'filled' }; }); }