diff --git a/plugins/linkedin-thought-leadership/render/__tests__/build-html.test.mjs b/plugins/linkedin-thought-leadership/render/__tests__/build-html.test.mjs new file mode 100644 index 0000000..1ae6cad --- /dev/null +++ b/plugins/linkedin-thought-leadership/render/__tests__/build-html.test.mjs @@ -0,0 +1,65 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { markdownToHtml, inline } from '../build-html.mjs'; + +describe('markdownToHtml — tables (beslutning H)', () => { + test('converts a pipe table to //
', () => { + const md = [ + '| Plattform | Pris |', + '| --- | --- |', + '| Azure | Høy |', + '| Foundry | Lav |', + ].join('\n'); + const html = markdownToHtml(md); + assert.match(html, //); + assert.match(html, //); + assert.match(html, /
/); + // header cells render as , body values as + assert.match(html, /Plattform<\/th>/); + assert.match(html, /Azure<\/td>/); + // the separator row must NOT become a data row + assert.doesNotMatch(html, /---<\/td>/); + }); + + test('empty input produces no table', () => { + assert.doesNotMatch(markdownToHtml(''), //); + }); + + test('tolerates a malformed row (uneven cell count) without throwing', () => { + const md = [ + '| a | b |', + '| --- | --- |', + '| only-one-cell |', + '| x | y |', + ].join('\n'); + let html; + assert.doesNotThrow(() => { html = markdownToHtml(md); }); + assert.match(html, /
/); + assert.match(html, /
x<\/td>/); + }); +}); + +describe('markdownToHtml — heading levels # … #### (beslutning H)', () => { + test('# renders

', () => { + assert.match(markdownToHtml('# Tittel'), /

Tittel<\/h1>/); + }); + test('## renders

', () => { + assert.match(markdownToHtml('## Seksjon'), /

Seksjon<\/h2>/); + }); + test('### renders

', () => { + assert.match(markdownToHtml('### Under'), /

Under<\/h3>/); + }); + test('#### renders

', () => { + assert.match(markdownToHtml('#### Detalj'), /

Detalj<\/h4>/); + }); +}); + +describe('inline — backtick code span (beslutning H)', () => { + test('`code` renders ', () => { + assert.match(inline('bruk `npm install` her'), /npm install<\/code>/); + }); + test('still handles **bold** and *italic*', () => { + assert.match(inline('**fet** og *kursiv*'), /fet<\/strong>/); + assert.match(inline('**fet** og *kursiv*'), /kursiv<\/em>/); + }); +}); diff --git a/plugins/linkedin-thought-leadership/render/build-html.mjs b/plugins/linkedin-thought-leadership/render/build-html.mjs index ce72929..9523994 100644 --- a/plugins/linkedin-thought-leadership/render/build-html.mjs +++ b/plugins/linkedin-thought-leadership/render/build-html.mjs @@ -42,24 +42,87 @@ function esc(s) { } // --------------------------------------------------------------------------- -// Inline markdown: **fet**, *kursiv*. «» og — beholdes uendret. +// Inline markdown: `kode`, **fet**, *kursiv*. «» og — beholdes uendret. // Tar uescapet tekst, returnerer escaped HTML med inline-tagger. // --------------------------------------------------------------------------- -function inline(text) { +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}`); // **fet** før *kursiv* for å unngå konflikt out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c}`); out = out.replace(/\*([^*]+)\*/g, (_, c) => `${c}`); return out; } +// --------------------------------------------------------------------------- +// Overskrift: # ->

... #### ->

(klemmes til h4; dypere nivåer +// kollapser til

). Tar antall #-tegn + raw heading-tekst. +// --------------------------------------------------------------------------- +export function renderHeading(hashes, text) { + const level = Math.min(hashes, 4); // # .. #### ->

..

+ return `${inline(text.trim())}`; +} + +// --------------------------------------------------------------------------- +// Tabell: en sammenhengende blokk av `| a | b |`-rader -> . +// Rad 1 = header (" + + header.map((c) => ``).join("") + + ""; + const tbody = + "" + + bodyRows + .map( + (r) => + "" + + splitRow(r) + .map((c) => ``) + .join("") + + "" + ) + .join("") + + ""; + return `
). En påfølgende separator-rad (| --- | --- |) hoppes +// over. Øvrige rader = . Ujevn celletall tolereres (ingen kast). +// --------------------------------------------------------------------------- +function splitRow(line) { + // Fjern ledende/avsluttende pipe, del på resten. + return line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((c) => c.trim()); +} +function isSeparatorRow(line) { + // | --- | :---: | ---: | osv. + return /^\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?$/.test(line.trim()); +} +export function renderTable(rows) { + if (!rows.length) return ""; + let bodyRows = rows; + const header = splitRow(rows[0]); + let startIdx = 1; + if (rows.length > 1 && isSeparatorRow(rows[1])) startIdx = 2; + bodyRows = rows.slice(startIdx); + + const thead = + "
${inline(c)}
${inline(c)}
${thead}${tbody}
`; +} + +// A markdown table line: starts with `|` and has at least one more `|`. +function isTableLine(t) { + return /^\|.*\|/.test(t); +} + // --------------------------------------------------------------------------- // Kompakt markdown -> HTML for body. -// Håndterer: ## / ### overskrifter, - punktlister, 1. nummererte lister, -// > blockquote, --- horisontal linje, og avsnitt (blanklinje-separert). +// Håndterer: # .. #### overskrifter, | tabeller |, - punktlister, +// 1. nummererte lister, > blockquote, --- horisontal linje, `kode`, og +// avsnitt (blanklinje-separert). // Første avsnitt får drop-cap-klasse. Avsnitt etter det første: .indent. // --------------------------------------------------------------------------- -function markdownToHtml(body) { +export function markdownToHtml(body) { const lines = body.replace(/\r\n/g, "\n").split("\n"); const blocks = []; let i = 0; @@ -85,15 +148,25 @@ function markdownToHtml(body) { continue; } - // Overskrifter - let hm = trimmed.match(/^(#{2,3})\s+(.*)$/); + // Overskrifter (# .. ###### ->

..

, dypere klemmes til h4) + let hm = trimmed.match(/^(#{1,6})\s+(.*)$/); if (hm) { - const level = hm[1].length; // 2 eller 3 - blocks.push(`${inline(hm[2].trim())}`); + blocks.push(renderHeading(hm[1].length, hm[2])); i++; continue; } + // Tabell (sammenhengende | a | b | -rader) + if (isTableLine(trimmed)) { + const rows = []; + while (i < lines.length && isTableLine(lines[i].trim())) { + rows.push(lines[i].trim()); + i++; + } + blocks.push(renderTable(rows)); + continue; + } + // Blockquote (sammenhengende > -linjer) if (/^>\s?/.test(trimmed)) { const qbuf = []; @@ -144,7 +217,8 @@ function markdownToHtml(body) { if ( t === "" || /^---+$/.test(t) || - /^(#{2,3})\s+/.test(t) || + /^(#{1,6})\s+/.test(t) || + isTableLine(t) || /^>\s?/.test(t) || /^[-*]\s+/.test(t) || /^\d+\.\s+/.test(t) @@ -259,8 +333,31 @@ h1.title { color: var(--accent); font-weight: 700; } +.body h1 { font-size: 1.8rem; font-weight: 700; margin: 2.2rem 0 0.7rem; line-height: 1.15; } .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 code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.86em; + background: #efece4; + padding: 0.08em 0.35em; + border-radius: 3px; +} +.body table { + border-collapse: collapse; + width: 100%; + margin: 1.4rem 0; + font-family: var(--sans); + font-size: 0.86rem; +} +.body th, .body td { + border: 1px solid var(--rule); + padding: 0.5em 0.7em; + text-align: left; + vertical-align: top; +} +.body th { background: #efece4; font-weight: 600; } .body ul, .body ol { margin: 0.8rem 0; padding-left: 1.5em; } .body li { margin: 0.3rem 0; } .body blockquote { @@ -933,7 +1030,7 @@ ${CLIENT_JS} // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- -function main() { +export function main() { const args = process.argv.slice(2); if (!args.length) { console.error("Bruk: node build-html.mjs [flere.md ...]"); @@ -960,4 +1057,8 @@ function main() { } } -main(); +// CLI-guard: kjør kun når scriptet startes direkte, ikke ved import +// (mønster fra hooks/scripts/state-updater.mjs). +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +}