feat(linkedin): generalize build-html annotation renderer — tables, headings, inline code (S1a)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b4aaf7ae82
commit
c225ea1dba
2 changed files with 178 additions and 12 deletions
|
|
@ -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 <table>/<tr>/<td>', () => {
|
||||||
|
const md = [
|
||||||
|
'| Plattform | Pris |',
|
||||||
|
'| --- | --- |',
|
||||||
|
'| Azure | Høy |',
|
||||||
|
'| Foundry | Lav |',
|
||||||
|
].join('\n');
|
||||||
|
const html = markdownToHtml(md);
|
||||||
|
assert.match(html, /<table>/);
|
||||||
|
assert.match(html, /<tr>/);
|
||||||
|
assert.match(html, /<td>/);
|
||||||
|
// header cells render as <th>, body values as <td>
|
||||||
|
assert.match(html, /<th>Plattform<\/th>/);
|
||||||
|
assert.match(html, /<td>Azure<\/td>/);
|
||||||
|
// the separator row must NOT become a data row
|
||||||
|
assert.doesNotMatch(html, /<td>---<\/td>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty input produces no table', () => {
|
||||||
|
assert.doesNotMatch(markdownToHtml(''), /<table>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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, /<table>/);
|
||||||
|
assert.match(html, /<td>x<\/td>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markdownToHtml — heading levels # … #### (beslutning H)', () => {
|
||||||
|
test('# renders <h1>', () => {
|
||||||
|
assert.match(markdownToHtml('# Tittel'), /<h1>Tittel<\/h1>/);
|
||||||
|
});
|
||||||
|
test('## renders <h2>', () => {
|
||||||
|
assert.match(markdownToHtml('## Seksjon'), /<h2>Seksjon<\/h2>/);
|
||||||
|
});
|
||||||
|
test('### renders <h3>', () => {
|
||||||
|
assert.match(markdownToHtml('### Under'), /<h3>Under<\/h3>/);
|
||||||
|
});
|
||||||
|
test('#### renders <h4>', () => {
|
||||||
|
assert.match(markdownToHtml('#### Detalj'), /<h4>Detalj<\/h4>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inline — backtick code span (beslutning H)', () => {
|
||||||
|
test('`code` renders <code>', () => {
|
||||||
|
assert.match(inline('bruk `npm install` her'), /<code>npm install<\/code>/);
|
||||||
|
});
|
||||||
|
test('still handles **bold** and *italic*', () => {
|
||||||
|
assert.match(inline('**fet** og *kursiv*'), /<strong>fet<\/strong>/);
|
||||||
|
assert.match(inline('**fet** og *kursiv*'), /<em>kursiv<\/em>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.
|
// Tar uescapet tekst, returnerer escaped HTML med inline-tagger.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function inline(text) {
|
export function inline(text) {
|
||||||
let out = esc(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) => `<code>${c}</code>`);
|
||||||
// **fet** før *kursiv* for å unngå konflikt
|
// **fet** før *kursiv* for å unngå konflikt
|
||||||
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
||||||
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
|
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overskrift: # -> <h1> ... #### -> <h4> (klemmes til h4; dypere nivåer
|
||||||
|
// kollapser til <h4>). Tar antall #-tegn + raw heading-tekst.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function renderHeading(hashes, text) {
|
||||||
|
const level = Math.min(hashes, 4); // # .. #### -> <h1> .. <h4>
|
||||||
|
return `<h${level}>${inline(text.trim())}</h${level}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tabell: en sammenhengende blokk av `| a | b |`-rader -> <table>.
|
||||||
|
// Rad 1 = header (<th>). En påfølgende separator-rad (| --- | --- |) hoppes
|
||||||
|
// over. Øvrige rader = <td>. 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 =
|
||||||
|
"<thead><tr>" +
|
||||||
|
header.map((c) => `<th>${inline(c)}</th>`).join("") +
|
||||||
|
"</tr></thead>";
|
||||||
|
const tbody =
|
||||||
|
"<tbody>" +
|
||||||
|
bodyRows
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
"<tr>" +
|
||||||
|
splitRow(r)
|
||||||
|
.map((c) => `<td>${inline(c)}</td>`)
|
||||||
|
.join("") +
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
.join("") +
|
||||||
|
"</tbody>";
|
||||||
|
return `<table>${thead}${tbody}</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A markdown table line: starts with `|` and has at least one more `|`.
|
||||||
|
function isTableLine(t) {
|
||||||
|
return /^\|.*\|/.test(t);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Kompakt markdown -> HTML for body.
|
// Kompakt markdown -> HTML for body.
|
||||||
// Håndterer: ## / ### overskrifter, - punktlister, 1. nummererte lister,
|
// Håndterer: # .. #### overskrifter, | tabeller |, - punktlister,
|
||||||
// > blockquote, --- horisontal linje, og avsnitt (blanklinje-separert).
|
// 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.
|
// 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 lines = body.replace(/\r\n/g, "\n").split("\n");
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
@ -85,15 +148,25 @@ function markdownToHtml(body) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overskrifter
|
// Overskrifter (# .. ###### -> <h1> .. <h4>, dypere klemmes til h4)
|
||||||
let hm = trimmed.match(/^(#{2,3})\s+(.*)$/);
|
let hm = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||||
if (hm) {
|
if (hm) {
|
||||||
const level = hm[1].length; // 2 eller 3
|
blocks.push(renderHeading(hm[1].length, hm[2]));
|
||||||
blocks.push(`<h${level}>${inline(hm[2].trim())}</h${level}>`);
|
|
||||||
i++;
|
i++;
|
||||||
continue;
|
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)
|
// Blockquote (sammenhengende > -linjer)
|
||||||
if (/^>\s?/.test(trimmed)) {
|
if (/^>\s?/.test(trimmed)) {
|
||||||
const qbuf = [];
|
const qbuf = [];
|
||||||
|
|
@ -144,7 +217,8 @@ function markdownToHtml(body) {
|
||||||
if (
|
if (
|
||||||
t === "" ||
|
t === "" ||
|
||||||
/^---+$/.test(t) ||
|
/^---+$/.test(t) ||
|
||||||
/^(#{2,3})\s+/.test(t) ||
|
/^(#{1,6})\s+/.test(t) ||
|
||||||
|
isTableLine(t) ||
|
||||||
/^>\s?/.test(t) ||
|
/^>\s?/.test(t) ||
|
||||||
/^[-*]\s+/.test(t) ||
|
/^[-*]\s+/.test(t) ||
|
||||||
/^\d+\.\s+/.test(t)
|
/^\d+\.\s+/.test(t)
|
||||||
|
|
@ -259,8 +333,31 @@ h1.title {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 700;
|
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 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 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 ul, .body ol { margin: 0.8rem 0; padding-left: 1.5em; }
|
||||||
.body li { margin: 0.3rem 0; }
|
.body li { margin: 0.3rem 0; }
|
||||||
.body blockquote {
|
.body blockquote {
|
||||||
|
|
@ -933,7 +1030,7 @@ ${CLIENT_JS}
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main
|
// Main
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function main() {
|
export function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
if (!args.length) {
|
if (!args.length) {
|
||||||
console.error("Bruk: node build-html.mjs <fil.md> [flere.md ...]");
|
console.error("Bruk: node build-html.mjs <fil.md> [flere.md ...]");
|
||||||
|
|
@ -960,4 +1057,8 @@ function 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();
|
main();
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue