From 81510297dbe78659bf81f80c660343cfe67475cd Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 17 Jul 2026 03:22:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20N3.5=20=E2=80=94=20MR-?= =?UTF-8?q?F8=20build-html-forsoning=20(blockquote+lenker+FIGUR->SVG=20por?= =?UTF-8?q?t=20+=20paritetstest)=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin eier hele render/ (KTG-go 16.07, eierskapsvalg a). Portet fra maskinrommets tools/build-html.mjs (read-only): flerlinjers blockquote (avsnitt i quote), [tekst](url)-lenker med http/https/mailto-whitelist, FIGUR-markoer -> inline SVG med figcaption + fallback til blockquote. CSS for de tre konstruktene. 7 nye paritetstester (repoets testtilfeller kopiert + med-SVG-case); paritetsbevis: samme fixture gjennom begge motorer = byte-identisk parser-HTML. Render-floor 53 -> 60. Co-Authored-By: Claude Fable 5 Claude-Session: c61b308e-e4fa-4a49-9ff0-a3ce725cf703 --- render/__tests__/build-html.test.mjs | 67 ++++++++++++++++ .../figur-serie/figurer/fig1-demo.svg | 5 ++ render/build-html.mjs | 80 +++++++++++++++++-- 3 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 render/__tests__/fixtures/figur-serie/figurer/fig1-demo.svg diff --git a/render/__tests__/build-html.test.mjs b/render/__tests__/build-html.test.mjs index 1ae6cad..a762b69 100644 --- a/render/__tests__/build-html.test.mjs +++ b/render/__tests__/build-html.test.mjs @@ -1,7 +1,11 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { markdownToHtml, inline } from '../build-html.mjs'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + describe('markdownToHtml — tables (beslutning H)', () => { test('converts a pipe table to //
', () => { const md = [ @@ -63,3 +67,66 @@ describe('inline — backtick code span (beslutning H)', () => { assert.match(inline('**fet** og *kursiv*'), /kursiv<\/em>/); }); }); + +// Paritetstilfeller portet fra maskinrommets tools/__tests__/build-html.test.mjs +// (N3.5/MR-F8): lenker, flerlinjers blockquote, FIGUR→SVG. Samme markdown skal +// gi samme parser-HTML i begge motorer. +describe('inline — lenker [tekst](url) (N3.5 MR-F8)', () => { + test('renders [tekst](url) as a link with target/rel', () => { + const out = inline('se [Reuters](https://www.reuters.com/x) for detaljer'); + assert.match(out, /]*>Reuters<\/a>/); + assert.match(out, /target="_blank"/); + assert.match(out, /rel="noopener"/); + }); + + test('mailto is allowed', () => { + assert.match(inline('[skriv](mailto:x@y.no)'), / { + const out = inline('[klikk](javascript:alert(1))'); + assert.doesNotMatch(out, / { + test('a blockquote keeps its paragraph breaks', () => { + const md = ['> Første avsnitt.', '>', '> Andre avsnitt.'].join('\n'); + const html = markdownToHtml(md); + assert.match(html, /
/); + // two separate

inside the quote — the old parser collapsed these into one + const paras = html.match(/

(.*?)<\/blockquote>/s)[1].match(/

/g) || []; + assert.equal(paras.length, 2); + }); + + test('a single-paragraph blockquote still renders one

', () => { + const html = markdownToHtml('> Bare ett avsnitt her.'); + const paras = html.match(/

(.*?)<\/blockquote>/s)[1].match(/

/g) || []; + assert.equal(paras.length, 1); + }); +}); + +describe('markdownToHtml — FIGUR→SVG (N3.5 MR-F8)', () => { + test('a FIGUR blockquote with no matching SVG falls back to a plain blockquote', () => { + const html = markdownToHtml('> **[FIGUR 9 — «finnes ikke»]**'); + assert.match(html, /

/); + assert.doesNotMatch(html, /
', () => { + const prev = process.cwd(); + process.chdir(path.join(__dirname, 'fixtures', 'figur-serie')); + try { + const html = markdownToHtml('> **[FIGUR 1 — «Demo-figur»]**'); + assert.match(html, /
/); + assert.match(html, /Figur 1 — Demo-figur<\/figcaption>/); + // xml-deklarasjonen i SVG-fila skal strippes før inlining + assert.doesNotMatch(html, /<\?xml/); + assert.doesNotMatch(html, /
/); + } finally { + process.chdir(prev); + } + }); +}); diff --git a/render/__tests__/fixtures/figur-serie/figurer/fig1-demo.svg b/render/__tests__/fixtures/figur-serie/figurer/fig1-demo.svg new file mode 100644 index 0000000..e466e4d --- /dev/null +++ b/render/__tests__/fixtures/figur-serie/figurer/fig1-demo.svg @@ -0,0 +1,5 @@ + + + + fixture + diff --git a/render/build-html.mjs b/render/build-html.mjs index dcad980..ca5d0f6 100644 --- a/render/build-html.mjs +++ b/render/build-html.mjs @@ -42,13 +42,19 @@ function esc(s) { } // --------------------------------------------------------------------------- -// Inline markdown: `kode`, **fet**, *kursiv*. «» og — beholdes uendret. +// Inline markdown: `kode`, [lenke](url), **fet**, *kursiv*. «» og — beholdes. // Tar uescapet tekst, returnerer escaped HTML med inline-tagger. // --------------------------------------------------------------------------- export function inline(text) { let out = esc(text); // `kode` først, slik at * og ** inni en kode-span ikke tolkes som fet/kursiv out = out.replace(/`([^`]+)`/g, (_, c) => `${c}`); + // [tekst](url) — kun http(s)/mailto får href; andre skjemaer beholder teksten + out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => + /^(https?:|mailto:)/i.test(url) + ? `${label}` + : label + ); // **fet** før *kursiv* for å unngå konflikt out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c}`); out = out.replace(/\*([^*]+)\*/g, (_, c) => `${c}`); @@ -118,10 +124,25 @@ function isTableLine(t) { // --------------------------------------------------------------------------- // Kompakt markdown -> HTML for body. // Håndterer: # .. #### overskrifter, | tabeller |, - punktlister, -// 1. nummererte lister, > blockquote, --- horisontal linje, `kode`, og -// avsnitt (blanklinje-separert). +// 1. nummererte lister, > blockquote (flerlinjers, med FIGUR-markør), +// [lenker](url), --- horisontal linje, `kode`, og avsnitt (blanklinje-separert). // Første avsnitt får drop-cap-klasse. Avsnitt etter det første: .indent. // --------------------------------------------------------------------------- + +// Figur-markør: les figurer/figN*.svg fra serie-mappa (process.cwd()). +// Returnerer rå SVG-streng, eller null hvis ingen matchende fil finnes. +function readFigureSvg(n) { + const figDir = path.join(process.cwd(), "figurer"); + if (!fs.existsSync(figDir)) return null; + const re = new RegExp("^fig" + n + "(?:[-.].*)?\\.svg$", "i"); + const match = fs.readdirSync(figDir).filter((f) => re.test(f)).sort()[0]; + if (!match) return null; + return fs + .readFileSync(path.join(figDir, match), "utf8") + .replace(/<\?xml[^>]*\?>\s*/i, "") + .trim(); +} + export function markdownToHtml(body) { const lines = body.replace(/\r\n/g, "\n").split("\n"); const blocks = []; @@ -167,14 +188,41 @@ export function markdownToHtml(body) { continue; } - // Blockquote (sammenhengende > -linjer) + // Blockquote (sammenhengende > -linjer; tom > -linje = nytt avsnitt i samme quote) if (/^>\s?/.test(trimmed)) { - const qbuf = []; + const qlines = []; while (i < lines.length && /^>\s?/.test(lines[i].trim())) { - qbuf.push(lines[i].trim().replace(/^>\s?/, "")); + qlines.push(lines[i].trim().replace(/^>\s?/, "")); i++; } - blocks.push(`

${inline(qbuf.join(" ").trim())}

`); + const paras = []; + let cur = []; + for (const ql of qlines) { + if (ql.trim() === "") { + if (cur.length) { paras.push(cur.join(" ").trim()); cur = []; } + } else cur.push(ql); + } + if (cur.length) paras.push(cur.join(" ").trim()); + + // Figur-markør: blockquote som starter med **[FIGUR N — ...]**. + // Finnes figurer/figN*.svg → inline SVG (rendres i nettleser). + // Ellers: vis spec'en som vanlig blockquote (uendret oppførsel). + const figm = paras.length && paras[0].match(/^\*\*\[FIGUR\s+(\d+)\b/); + if (figm) { + const svg = readFigureSvg(figm[1]); + if (svg) { + const capm = paras[0].match(/«([^»]+)»/); + const caption = capm ? `Figur ${figm[1]} — ${capm[1]}` : ""; + blocks.push( + `
${svg}` + + (caption ? `
${inline(caption)}
` : "") + + "
" + ); + continue; + } + } + + blocks.push("
" + paras.map((p) => `

${inline(p)}

`).join("") + "
"); continue; } @@ -337,6 +385,7 @@ h1.title { .body h2 { font-size: 1.5rem; font-weight: 700; margin: 2rem 0 0.6rem; line-height: 1.2; } .body h3 { font-size: 1.2rem; font-weight: 700; margin: 1.6rem 0 0.5rem; line-height: 1.25; } .body h4 { font-size: 1.05rem; font-weight: 700; margin: 1.3rem 0 0.4rem; line-height: 1.3; } +.body a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } .body code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.86em; @@ -367,6 +416,23 @@ h1.title { font-style: italic; color: #333; } +.body blockquote p { margin: 0 0 0.7em; } +.body blockquote p:last-child { margin-bottom: 0; } +.body figure.fig { margin: 1.9rem 0; text-align: center; } +.body figure.fig svg { + max-width: 100%; + height: auto; + border: 1px solid var(--rule); + background: #fff; + border-radius: 4px; +} +.body figure.fig figcaption { + font-family: var(--sans); + font-size: 0.8rem; + color: var(--muted); + font-style: italic; + margin-top: 0.5rem; +} .body hr { border: 0; border-top: 1px solid var(--rule);