feat(linkedin): migrate render scripts + fonts into plugin (S1)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
fc351071d9
commit
b1a71e3bed
14 changed files with 2134 additions and 0 deletions
91
render/OFL.txt
Normal file
91
render/OFL.txt
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
Copyright (c) The Inter Project Authors (https://github.com/rsms/inter)
|
||||
Copyright (c) The Newsreader Project Authors (https://github.com/productiontype/Newsreader)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to any
|
||||
document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may include
|
||||
source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or
|
||||
other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or in
|
||||
the appropriate machine-readable metadata fields within text or binary
|
||||
files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name
|
||||
as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any Modified
|
||||
Version, except to acknowledge the contribution(s) of the Copyright
|
||||
Holder(s) and the Author(s) or with their explicit written permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole, must be
|
||||
distributed entirely under this license, and must not be distributed under
|
||||
any other license. The requirement for fonts to remain under this license
|
||||
does not apply to any document created using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not
|
||||
met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS
|
||||
IN THE FONT SOFTWARE.
|
||||
36
render/__tests__/weasyprint-degradation.test.mjs
Normal file
36
render/__tests__/weasyprint-degradation.test.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveWeasyprint as resolvePdf } from '../build-pdf.mjs';
|
||||
import { resolveWeasyprint as resolveCarousel } from '../build-carousel.mjs';
|
||||
|
||||
// S1 (correction #3): when weasyprint is not resolvable on PATH, the degradation
|
||||
// helper must return a skip-signal (NOT throw) and emit an install hint, so the
|
||||
// render scripts can skip the PDF step gracefully instead of crashing.
|
||||
|
||||
for (const [name, resolveWeasyprint] of [
|
||||
['build-pdf', resolvePdf],
|
||||
['build-carousel', resolveCarousel],
|
||||
]) {
|
||||
describe(`resolveWeasyprint — ${name}`, () => {
|
||||
test('returns a skip-signal (not a throw) when weasyprint is absent', () => {
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = resolveWeasyprint(() => false);
|
||||
});
|
||||
assert.equal(result.available, false);
|
||||
});
|
||||
|
||||
test('emits an install hint when absent', () => {
|
||||
const result = resolveWeasyprint(() => false);
|
||||
assert.ok(typeof result.hint === 'string' && result.hint.length > 0);
|
||||
assert.match(result.hint, /weasyprint/i);
|
||||
assert.match(result.hint, /install/i);
|
||||
});
|
||||
|
||||
test('reports available when the probe succeeds', () => {
|
||||
const result = resolveWeasyprint(() => true);
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(result.hint, undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
302
render/build-carousel.mjs
Normal file
302
render/build-carousel.mjs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
#!/usr/bin/env node
|
||||
// build-carousel.mjs — render en LinkedIn-carousel (dokument-PDF) fra slide-markdown.
|
||||
// Bruk: node build-carousel.mjs linkedin/06/carousel.md [linkedin/03/carousel.md ...]
|
||||
// Hver "## SLIDE N — ..." blir én portrett-side (1080×1350, 4:5) i PDF-en.
|
||||
// Designet typografisk deck — speiler avis-identiteten (Newsreader/Inter, off-white,
|
||||
// oxblood). Cover (slide 1) + CTA (siste slide) = oxblood-bokstøtter; de øvrige lyse
|
||||
// med bolk-kicker + footer (Maskinrommet + teller). Ingen per-slide AI-foto.
|
||||
// Krever: weasyprint på PATH. Ingen npm-avhengigheter.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// weasyprint graceful degradation (S1, correction #3)
|
||||
// Detekterer weasyprint på PATH. Returnerer et skip-signal (kaster ALDRI) når
|
||||
// verktøyet mangler, slik at PDF-steget hoppes over med en tydelig install-hint
|
||||
// i stedet for å krasje kjøringen. `probe` er injiserbar for test.
|
||||
// ---------------------------------------------------------------------------
|
||||
const WEASYPRINT_HINT =
|
||||
"weasyprint ikke funnet på PATH — hopper over PDF-steget.\n" +
|
||||
" Install: pipx install weasyprint (alternativt: brew install weasyprint)";
|
||||
|
||||
export function resolveWeasyprint(probe = defaultWeasyprintProbe) {
|
||||
if (probe()) return { available: true };
|
||||
return { available: false, hint: WEASYPRINT_HINT };
|
||||
}
|
||||
|
||||
function defaultWeasyprintProbe() {
|
||||
try {
|
||||
execFileSync("weasyprint", ["--version"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline markdown (**fet**, *kursiv*) + escaping
|
||||
// ---------------------------------------------------------------------------
|
||||
function esc(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
function inline(text) {
|
||||
let out = esc(text);
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
||||
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Valgfri YAML front matter (flate key: "value"-par) — for cover/CTA-eyebrow.
|
||||
// Felt: cover_eyebrow, cta_eyebrow. Faller tilbake til generiske default-er.
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseFrontMatter(raw) {
|
||||
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!m) return { meta: {}, rest: raw };
|
||||
const meta = {};
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const mm = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||
if (!mm) continue;
|
||||
let val = mm[2].trim();
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
meta[mm[1]] = val;
|
||||
}
|
||||
return { meta, rest: m[2] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse slide-markdown.
|
||||
// Slides skilles av "## SLIDE N — label". Innenfor en slide:
|
||||
// - linje `…` (backticks) -> kicker (bolk-merkelapp)
|
||||
// - linje "# …" -> tittel
|
||||
// - øvrige ikke-tomme -> brødtekst-avsnitt (ett per linje)
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseSlides(raw) {
|
||||
const body = raw.replace(/\r\n/g, "\n");
|
||||
// dropp ev. innledende H1 + forklarings-avsnitt før første "## SLIDE"
|
||||
const startIdx = body.indexOf("## SLIDE");
|
||||
const region = startIdx >= 0 ? body.slice(startIdx) : body;
|
||||
const chunks = region.split(/^##\s+SLIDE\b.*$/m).map((c) => c.trim()).filter(Boolean);
|
||||
|
||||
return chunks.map((chunk) => {
|
||||
const lines = chunk.split("\n");
|
||||
let kicker = null;
|
||||
let title = null;
|
||||
const bodyParas = [];
|
||||
for (const lnRaw of lines) {
|
||||
const ln = lnRaw.trim();
|
||||
if (!ln) continue;
|
||||
if (ln.startsWith("---")) continue;
|
||||
const km = ln.match(/^`([^`]+)`$/);
|
||||
if (km && !kicker && !title) {
|
||||
kicker = km[1].trim();
|
||||
continue;
|
||||
}
|
||||
if (ln.startsWith("# ")) {
|
||||
title = ln.slice(2).trim();
|
||||
continue;
|
||||
}
|
||||
bodyParas.push(ln);
|
||||
}
|
||||
return { kicker, title, bodyParas };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS — portrett 4:5, avis-identitet. Oxblood-bokstøtter for cover/CTA.
|
||||
// ---------------------------------------------------------------------------
|
||||
const FONT_DIR = path.join(__dirname, "fonts");
|
||||
const ff = (f) => `url("file://${path.join(FONT_DIR, f).replace(/ /g, "%20")}")`;
|
||||
const FONT_FACE = `
|
||||
@font-face{font-family:"Newsreader";font-style:normal;font-weight:400;src:${ff("Newsreader-400.ttf")};}
|
||||
@font-face{font-family:"Newsreader";font-style:italic;font-weight:400;src:${ff("Newsreader-400i.ttf")};}
|
||||
@font-face{font-family:"Newsreader";font-style:normal;font-weight:600;src:${ff("Newsreader-600.ttf")};}
|
||||
@font-face{font-family:"Newsreader";font-style:italic;font-weight:600;src:${ff("Newsreader-600i.ttf")};}
|
||||
@font-face{font-family:"Newsreader";font-style:normal;font-weight:700;src:${ff("Newsreader-700.ttf")};}
|
||||
@font-face{font-family:"Inter";font-style:normal;font-weight:400;src:${ff("Inter-400.ttf")};}
|
||||
@font-face{font-family:"Inter";font-style:normal;font-weight:600;src:${ff("Inter-600.ttf")};}
|
||||
@font-face{font-family:"Inter";font-style:normal;font-weight:700;src:${ff("Inter-700.ttf")};}
|
||||
`;
|
||||
|
||||
const CSS = `
|
||||
${FONT_FACE}
|
||||
:root{
|
||||
--bg:#FBFAF7; --ink:#1A1A1A; --muted:#5b5750; --accent:#9A3324;
|
||||
--rule:#d8d4cb; --cream:#F4EFE6;
|
||||
--serif:"Newsreader",Georgia,serif;
|
||||
--sans:"Inter","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
@page{ size:1080px 1350px; margin:0; }
|
||||
*{ box-sizing:border-box; margin:0; padding:0; }
|
||||
html,body{ background:var(--bg); }
|
||||
.slide{
|
||||
position:relative;
|
||||
width:1080px; height:1350px;
|
||||
padding:96px 96px 92px;
|
||||
background:var(--bg); color:var(--ink);
|
||||
font-family:var(--serif);
|
||||
page-break-after:always;
|
||||
overflow:hidden;
|
||||
}
|
||||
.slide:last-child{ page-break-after:auto; }
|
||||
|
||||
/* ---- kicker (bolk-merkelapp) ---- */
|
||||
.kicker{
|
||||
display:inline-block;
|
||||
font-family:var(--sans); font-weight:700;
|
||||
text-transform:uppercase; letter-spacing:0.14em;
|
||||
font-size:22px; color:#fff; background:var(--accent);
|
||||
padding:9px 18px; border-radius:3px;
|
||||
margin-bottom:46px;
|
||||
}
|
||||
|
||||
/* ---- tittel / brødtekst ---- */
|
||||
.title{
|
||||
font-family:var(--serif); font-weight:700;
|
||||
font-size:74px; line-height:1.06; letter-spacing:-0.012em;
|
||||
margin-bottom:40px;
|
||||
}
|
||||
.body p{
|
||||
font-family:var(--serif); font-weight:400;
|
||||
font-size:35px; line-height:1.45; color:#2b2823;
|
||||
margin-bottom:22px;
|
||||
}
|
||||
.body p strong{ font-weight:700; color:var(--ink); }
|
||||
.body p em{ font-style:italic; }
|
||||
|
||||
/* ---- footer ---- */
|
||||
.footer{
|
||||
position:absolute; left:96px; right:96px; bottom:64px;
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
border-top:1px solid var(--rule); padding-top:22px;
|
||||
font-family:var(--sans); font-size:21px; letter-spacing:0.04em;
|
||||
}
|
||||
.footer .brand{ font-weight:700; text-transform:uppercase; letter-spacing:0.13em; color:var(--accent); }
|
||||
.footer .count{ color:var(--muted); font-weight:600; }
|
||||
|
||||
/* ---- interior layout (grep): topp-justert under kicker ---- */
|
||||
.slide.interior .stage{ }
|
||||
|
||||
/* ---- cover + CTA: oxblood-bokstøtter ---- */
|
||||
.slide.bookend{
|
||||
background:var(--accent); color:var(--cream);
|
||||
display:flex; flex-direction:column; justify-content:center;
|
||||
}
|
||||
.slide.bookend .eyebrow{
|
||||
font-family:var(--sans); font-weight:700; text-transform:uppercase;
|
||||
letter-spacing:0.18em; font-size:23px; color:#F0C9B6; margin-bottom:34px;
|
||||
}
|
||||
.slide.bookend .title{ color:#fff; font-size:86px; line-height:1.02; margin-bottom:40px; }
|
||||
.slide.bookend .body p{ color:#F4E4D8; font-size:38px; line-height:1.42; }
|
||||
.slide.bookend .body p strong{ color:#fff; }
|
||||
.slide.bookend .footer{
|
||||
border-top:1px solid rgba(244,228,216,0.32);
|
||||
color:#F0C9B6;
|
||||
}
|
||||
.slide.bookend .footer .brand{ color:#fff; }
|
||||
.slide.bookend .footer .count{ color:#F0C9B6; }
|
||||
.slide.bookend .arrow{ font-size:40px; }
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render én slide til HTML
|
||||
// ---------------------------------------------------------------------------
|
||||
function slideHtml(slide, idx, total, eyebrows) {
|
||||
const isCover = idx === 0;
|
||||
const isCta = idx === total - 1;
|
||||
const bookend = isCover || isCta;
|
||||
const cls = bookend ? "slide bookend" : "slide interior";
|
||||
|
||||
const titleHtml = slide.title ? `<h1 class="title">${inline(slide.title)}</h1>` : "";
|
||||
const bodyHtml = slide.bodyParas.length
|
||||
? `<div class="body">${slide.bodyParas.map((p) => `<p>${inline(p)}</p>`).join("")}</div>`
|
||||
: "";
|
||||
|
||||
// kicker: interior bruker bolk-merkelapp; bookend bruker eyebrow (cover/CTA)
|
||||
let head = "";
|
||||
if (bookend) {
|
||||
const eyebrow = isCover ? eyebrows.cover : eyebrows.cta;
|
||||
head = `<p class="eyebrow">${esc(eyebrow)}</p>`;
|
||||
} else if (slide.kicker) {
|
||||
head = `<span class="kicker">${esc(slide.kicker)}</span>`;
|
||||
}
|
||||
|
||||
const counter = `${idx + 1} / ${total}`;
|
||||
const footer = `<div class="footer"><span class="brand">Maskinrommet</span><span class="count">${counter}</span></div>`;
|
||||
|
||||
return `<section class="${cls}">
|
||||
${head}
|
||||
${titleHtml}
|
||||
${bodyHtml}
|
||||
${footer}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function buildHtml(slides, eyebrows) {
|
||||
const total = slides.length;
|
||||
const slidesHtml = slides.map((s, i) => slideHtml(s, i, total, eyebrows)).join("\n");
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="nb">
|
||||
<head><meta charset="utf-8"><title>Carousel</title><style>${CSS}</style></head>
|
||||
<body>
|
||||
${slidesHtml}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length) {
|
||||
console.error("Bruk: node build-carousel.mjs <carousel.md> [flere.md ...]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const wp = resolveWeasyprint();
|
||||
if (!wp.available) console.warn(wp.hint);
|
||||
|
||||
for (const arg of args) {
|
||||
const inPath = path.isAbsolute(arg) ? arg : path.join(process.cwd(), arg);
|
||||
if (!fs.existsSync(inPath)) {
|
||||
console.error(`Fant ikke: ${inPath}`);
|
||||
continue;
|
||||
}
|
||||
const raw = fs.readFileSync(inPath, "utf8");
|
||||
const { meta } = parseFrontMatter(raw);
|
||||
const slides = parseSlides(raw);
|
||||
if (!slides.length) {
|
||||
console.error(`Ingen slides funnet i ${inPath}`);
|
||||
continue;
|
||||
}
|
||||
const eyebrows = {
|
||||
cover: meta.cover_eyebrow || "Maskinrommet",
|
||||
cta: meta.cta_eyebrow || "Kom i gang",
|
||||
};
|
||||
const dir = path.dirname(inPath);
|
||||
const html = buildHtml(slides, eyebrows);
|
||||
const htmlPath = path.join(dir, "carousel.html");
|
||||
const pdfPath = path.join(dir, "carousel.pdf");
|
||||
fs.writeFileSync(htmlPath, html, "utf8");
|
||||
if (wp.available) {
|
||||
execFileSync("weasyprint", [htmlPath, pdfPath], { stdio: ["ignore", "ignore", "inherit"] });
|
||||
const kb = (fs.statSync(pdfPath).size / 1024).toFixed(1);
|
||||
console.log(`Carousel: ${pdfPath} (${slides.length} slides, ${kb} KB)`);
|
||||
} else {
|
||||
console.warn(` Hoppet over PDF (weasyprint mangler): ${pdfPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
963
render/build-html.mjs
Normal file
963
render/build-html.mjs
Normal file
|
|
@ -0,0 +1,963 @@
|
|||
#!/usr/bin/env node
|
||||
// build-html.mjs — render norske kronikker som selvstendige, annoterbare HTML-filer.
|
||||
// Bruk: node build-html.mjs utkast/01-open-source-edge-modeller.md [flere.md ...]
|
||||
// Skriver til review/<samme-navn>.html. Ingen npm-avhengigheter, ingen nett.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML front matter (minimal: flate key: "value"-par mellom --- ... ---)
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseFrontMatter(raw) {
|
||||
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!m) return { meta: {}, body: raw };
|
||||
const meta = {};
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const mm = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||
if (!mm) continue;
|
||||
let val = mm[2].trim();
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
meta[mm[1]] = val;
|
||||
}
|
||||
return { meta, body: m[2].replace(/^\r?\n+/, "") };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML-escape for tekstinnhold
|
||||
// ---------------------------------------------------------------------------
|
||||
function esc(s) {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline markdown: **fet**, *kursiv*. «» og — beholdes uendret.
|
||||
// Tar uescapet tekst, returnerer escaped HTML med inline-tagger.
|
||||
// ---------------------------------------------------------------------------
|
||||
function inline(text) {
|
||||
let out = esc(text);
|
||||
// **fet** før *kursiv* for å unngå konflikt
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
||||
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kompakt markdown -> HTML for body.
|
||||
// Håndterer: ## / ### overskrifter, - punktlister, 1. nummererte lister,
|
||||
// > blockquote, --- horisontal linje, og avsnitt (blanklinje-separert).
|
||||
// Første avsnitt får drop-cap-klasse. Avsnitt etter det første: .indent.
|
||||
// ---------------------------------------------------------------------------
|
||||
function markdownToHtml(body) {
|
||||
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
||||
const blocks = [];
|
||||
let i = 0;
|
||||
let paraCount = 0;
|
||||
|
||||
function flushPara(buf) {
|
||||
if (!buf.length) return;
|
||||
const text = buf.join(" ").trim();
|
||||
if (!text) return;
|
||||
paraCount++;
|
||||
const cls = paraCount === 1 ? "lede" : "indent";
|
||||
blocks.push(`<p class="${cls}">${inline(text)}</p>`);
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Horisontal linje
|
||||
if (/^---+$/.test(trimmed)) {
|
||||
blocks.push("<hr>");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Overskrifter
|
||||
let hm = trimmed.match(/^(#{2,3})\s+(.*)$/);
|
||||
if (hm) {
|
||||
const level = hm[1].length; // 2 eller 3
|
||||
blocks.push(`<h${level}>${inline(hm[2].trim())}</h${level}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote (sammenhengende > -linjer)
|
||||
if (/^>\s?/.test(trimmed)) {
|
||||
const qbuf = [];
|
||||
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||||
qbuf.push(lines[i].trim().replace(/^>\s?/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push(`<blockquote><p>${inline(qbuf.join(" ").trim())}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Punktliste
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push(
|
||||
"<ul>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ul>"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nummerert liste
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push(
|
||||
"<ol>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ol>"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blank linje -> avsnittsgrense
|
||||
if (trimmed === "") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Vanlig avsnitt: samle til blank/strukturlinje
|
||||
const pbuf = [];
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim();
|
||||
if (
|
||||
t === "" ||
|
||||
/^---+$/.test(t) ||
|
||||
/^(#{2,3})\s+/.test(t) ||
|
||||
/^>\s?/.test(t) ||
|
||||
/^[-*]\s+/.test(t) ||
|
||||
/^\d+\.\s+/.test(t)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
pbuf.push(t);
|
||||
i++;
|
||||
}
|
||||
flushPara(pbuf);
|
||||
}
|
||||
|
||||
return blocks.join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JS-streng-escape (for ordrett innbygging av body-markdown og meta)
|
||||
// ---------------------------------------------------------------------------
|
||||
function toJsString(s) {
|
||||
return JSON.stringify(s);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS — redaksjonell avis-stil, self-contained
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSS = `
|
||||
:root {
|
||||
--bg: #FBFAF7;
|
||||
--ink: #1A1A1A;
|
||||
--muted: #555555;
|
||||
--accent: #9A3324;
|
||||
--rule: #d8d4cb;
|
||||
--serif: "Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
|
||||
--sans: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
--c-change: #e6a817;
|
||||
--c-add: #2e8b57;
|
||||
--c-remove: #c0392b;
|
||||
--c-clarify: #2d6cdf;
|
||||
--c-risk: #7d3c98;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--serif);
|
||||
font-size: 20px;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
padding: 4rem 1.5rem 8rem;
|
||||
}
|
||||
article {
|
||||
max-width: 34em;
|
||||
width: 100%;
|
||||
}
|
||||
.kicker {
|
||||
font-family: var(--sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin: 0 0 0.9rem;
|
||||
}
|
||||
h1.title {
|
||||
font-family: var(--serif);
|
||||
font-size: 2.6rem;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.subtitle {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
color: var(--muted);
|
||||
margin: 0 0 1.6rem;
|
||||
}
|
||||
.byline-wrap {
|
||||
border-top: 1px solid var(--rule);
|
||||
padding-top: 0.9rem;
|
||||
margin-bottom: 2.4rem;
|
||||
}
|
||||
.byline {
|
||||
font-family: var(--sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 0.3rem;
|
||||
}
|
||||
.meta {
|
||||
font-family: var(--sans);
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.body p { margin: 0; text-align: left; }
|
||||
.body p.indent { text-indent: 1.4em; }
|
||||
.body p.lede::first-letter {
|
||||
float: left;
|
||||
font-size: 3.1em;
|
||||
line-height: 0.82;
|
||||
padding: 0.05em 0.08em 0 0;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.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 ul, .body ol { margin: 0.8rem 0; padding-left: 1.5em; }
|
||||
.body li { margin: 0.3rem 0; }
|
||||
.body blockquote {
|
||||
margin: 1.4rem 0;
|
||||
padding-left: 1.1em;
|
||||
border-left: 3px solid var(--accent);
|
||||
font-style: italic;
|
||||
color: #333;
|
||||
}
|
||||
.body hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--rule);
|
||||
margin: 2rem auto;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
/* Annoterte markeringer */
|
||||
.anno {
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anno-change { background: rgba(230,168,23,0.30); }
|
||||
.anno-add { background: rgba(46,139,87,0.25); }
|
||||
.anno-remove { background: rgba(192,57,43,0.22); }
|
||||
.anno-clarify{ background: rgba(45,108,223,0.20); }
|
||||
.anno-risk { background: rgba(125,60,152,0.20); }
|
||||
.anno-num {
|
||||
font-family: var(--sans);
|
||||
font-size: 0.6em;
|
||||
font-weight: 700;
|
||||
vertical-align: super;
|
||||
margin-left: 1px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Flytende verktøylinje */
|
||||
.anno-toolbar {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
background: #1A1A1A;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.28);
|
||||
gap: 2px;
|
||||
}
|
||||
.anno-toolbar.show { display: flex; }
|
||||
.anno-toolbar button {
|
||||
font-family: var(--sans);
|
||||
font-size: 0.72rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
padding: 6px 9px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anno-toolbar button:hover { background: rgba(255,255,255,0.16); }
|
||||
.anno-toolbar .swatch {
|
||||
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||
margin-right: 5px; vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Kommentarboks (popover) */
|
||||
.anno-popover {
|
||||
position: absolute;
|
||||
z-index: 1001;
|
||||
display: none;
|
||||
background: #fff;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
width: 280px;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.22);
|
||||
font-family: var(--sans);
|
||||
}
|
||||
.anno-popover.show { display: block; }
|
||||
.anno-popover .ph {
|
||||
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--muted); margin-bottom: 6px; font-weight: 600;
|
||||
}
|
||||
.anno-popover textarea {
|
||||
width: 100%; min-height: 70px; font-family: var(--sans); font-size: 0.85rem;
|
||||
border: 1px solid var(--rule); border-radius: 5px; padding: 6px; resize: vertical;
|
||||
}
|
||||
.anno-popover .row { display: flex; justify-content: flex-end; gap: 6px; margin-top: 8px; }
|
||||
.anno-popover button {
|
||||
font-family: var(--sans); font-size: 0.78rem; padding: 5px 12px;
|
||||
border-radius: 5px; border: 1px solid var(--rule); background: #f4f2ec; cursor: pointer;
|
||||
}
|
||||
.anno-popover button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.anno-popover .intent-pick { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
|
||||
.anno-popover .intent-pick button {
|
||||
font-family: var(--sans); font-size: 0.72rem; padding: 4px 8px;
|
||||
border-radius: 5px; border: 1px solid var(--rule); background: #f4f2ec; cursor: pointer;
|
||||
}
|
||||
.anno-popover .intent-pick button.active { background: #1A1A1A; color: #fff; border-color: #1A1A1A; }
|
||||
.anno-popover .del-edit { margin-right: auto; color: var(--c-remove); background: #fff; }
|
||||
|
||||
/* Sidepanel */
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
flex: 0 0 320px;
|
||||
font-family: var(--sans);
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
align-self: flex-start;
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow: auto;
|
||||
}
|
||||
.sidebar h2 {
|
||||
font-family: var(--sans); font-size: 0.8rem; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; color: var(--muted); margin: 0 0 0.8rem;
|
||||
}
|
||||
.sidebar .actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 1rem; }
|
||||
.sidebar .actions button {
|
||||
font-family: var(--sans); font-size: 0.74rem; padding: 6px 10px; border-radius: 6px;
|
||||
border: 1px solid var(--rule); background: #fff; cursor: pointer;
|
||||
}
|
||||
.sidebar .actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.anno-item {
|
||||
border: 1px solid var(--rule); border-radius: 8px; padding: 9px 10px;
|
||||
margin-bottom: 8px; background: #fff;
|
||||
}
|
||||
.anno-item .top { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; }
|
||||
.anno-item .num {
|
||||
font-weight: 700; font-size: 0.72rem; background: #efece4;
|
||||
width: 20px; height: 20px; border-radius: 50%; display: inline-flex;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.anno-item .badge {
|
||||
font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
font-weight: 700; padding: 2px 7px; border-radius: 10px; color: #fff;
|
||||
}
|
||||
.badge-change { background: var(--c-change); color:#3a2a00; }
|
||||
.badge-add { background: var(--c-add); }
|
||||
.badge-remove { background: var(--c-remove); }
|
||||
.badge-clarify{ background: var(--c-clarify); }
|
||||
.badge-risk { background: var(--c-risk); }
|
||||
.anno-item .quote { font-size: 0.78rem; color:#444; font-style: italic; margin: 4px 0; }
|
||||
.anno-item .cmt { font-size: 0.84rem; color: var(--ink); }
|
||||
.anno-item .edit {
|
||||
margin-left: auto; border: 0; background: transparent; color: var(--c-clarify);
|
||||
cursor: pointer; font-size: 0.74rem;
|
||||
}
|
||||
.anno-item .del {
|
||||
margin-left: 4px; border: 0; background: transparent; color: var(--c-remove);
|
||||
cursor: pointer; font-size: 0.74rem;
|
||||
}
|
||||
.sidebar .empty { font-size: 0.8rem; color: var(--muted); font-style: italic; }
|
||||
.sidebar-toggle {
|
||||
position: fixed; top: 1rem; right: 1rem; z-index: 900;
|
||||
font-family: var(--sans); font-size: 0.74rem; padding: 7px 12px;
|
||||
border-radius: 6px; border: 1px solid var(--rule); background: #fff; cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* Eksport-overlay */
|
||||
.export-overlay {
|
||||
position: fixed; inset: 0; z-index: 2000; display: none;
|
||||
background: rgba(0,0,0,0.45); align-items: center; justify-content: center;
|
||||
}
|
||||
.export-overlay.show { display: flex; }
|
||||
.export-box {
|
||||
background: #fff; border-radius: 10px; padding: 16px; width: min(720px, 92vw);
|
||||
font-family: var(--sans); box-shadow: 0 20px 60px rgba(0,0,0,0.35);
|
||||
}
|
||||
.export-box h3 { margin: 0 0 8px; font-size: 0.95rem; }
|
||||
.export-box textarea {
|
||||
width: 100%; height: 50vh; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.8rem; border: 1px solid var(--rule); border-radius: 6px; padding: 10px;
|
||||
}
|
||||
.export-box .row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
|
||||
.export-box button {
|
||||
font-family: var(--sans); font-size: 0.82rem; padding: 7px 14px; border-radius: 6px;
|
||||
border: 1px solid var(--rule); background: #f4f2ec; cursor: pointer;
|
||||
}
|
||||
.export-box button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.sidebar { display: none; }
|
||||
.sidebar.mobile-show {
|
||||
display: block; position: fixed; right: 0; top: 0; bottom: 0; z-index: 950;
|
||||
width: 86vw; max-width: 360px; background: var(--bg); padding: 1.2rem;
|
||||
box-shadow: -6px 0 30px rgba(0,0,0,0.2); max-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.sidebar, .sidebar-toggle, .anno-toolbar, .anno-popover, .export-overlay { display: none !important; }
|
||||
.anno-num { display: none !important; }
|
||||
.anno { background: transparent !important; }
|
||||
body { font-size: 12pt; background: #fff; }
|
||||
.page { padding: 0; }
|
||||
article { max-width: 100%; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Klient-JS (annoteringsverktøy). Bygges inn ordrett.
|
||||
// ---------------------------------------------------------------------------
|
||||
const CLIENT_JS = String.raw`
|
||||
(function () {
|
||||
"use strict";
|
||||
var STORE_KEY = "anno:" + window.__ARTICLE_KEY__;
|
||||
var BODY_MD = window.__BODY_MD__;
|
||||
var SOURCE_FILE = window.__SOURCE_FILE__;
|
||||
var TITLE = window.__TITLE__;
|
||||
var INTENTS = {
|
||||
change: { label: "Endre", cls: "change", upper: "CHANGE" },
|
||||
add: { label: "Legg til", cls: "add", upper: "ADD" },
|
||||
remove: { label: "Fjern", cls: "remove", upper: "REMOVE" },
|
||||
clarify: { label: "Avklar", cls: "clarify", upper: "CLARIFY" },
|
||||
risk: { label: "Risiko", cls: "risk", upper: "RISK" }
|
||||
};
|
||||
var SWATCH = {
|
||||
change: "#e6a817", add: "#2e8b57", remove: "#c0392b", clarify: "#2d6cdf", risk: "#7d3c98"
|
||||
};
|
||||
|
||||
// Hele <article> er markerbart (tittel + ingress + brødtekst), ikke bare .body.
|
||||
var article = document.querySelector("article");
|
||||
var toolbar = document.getElementById("annoToolbar");
|
||||
var popover = document.getElementById("annoPopover");
|
||||
var sidebar = document.getElementById("annoSidebar");
|
||||
var listEl = document.getElementById("annoList");
|
||||
|
||||
var annotations = load();
|
||||
var pendingRange = null; // {text} for ny annotering
|
||||
var editingId = null; // id når en eksisterende annotering redigeres
|
||||
|
||||
function load() {
|
||||
try {
|
||||
var raw = localStorage.getItem(STORE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
function save() {
|
||||
try { localStorage.setItem(STORE_KEY, JSON.stringify(annotations)); } catch (e) {}
|
||||
}
|
||||
|
||||
// --- Markering -> verktøylinje -------------------------------------------
|
||||
document.addEventListener("mouseup", function (e) {
|
||||
if (toolbar.contains(e.target) || popover.contains(e.target)) return;
|
||||
setTimeout(handleSelection, 0);
|
||||
});
|
||||
|
||||
// Hent kontekst rundt markeringen (ordene foran/bak i samme avsnitt),
|
||||
// slik at korte markeringer (ett ord) kan plasseres entydig i eksporten.
|
||||
function getContext(range, selText) {
|
||||
var BLOCK = /^(P|LI|H1|H2|H3|H4|BLOCKQUOTE|TD)$/;
|
||||
var block = range.commonAncestorContainer;
|
||||
if (block.nodeType === 3) block = block.parentElement;
|
||||
while (block && block !== article && !BLOCK.test(block.tagName)) block = block.parentElement;
|
||||
if (!block || block === article) return "";
|
||||
try {
|
||||
var beforeR = document.createRange();
|
||||
beforeR.selectNodeContents(block);
|
||||
beforeR.setEnd(range.startContainer, range.startOffset);
|
||||
var afterR = document.createRange();
|
||||
afterR.selectNodeContents(block);
|
||||
afterR.setStart(range.endContainer, range.endOffset);
|
||||
var before = beforeR.toString().replace(/\s+/g, " ");
|
||||
var after = afterR.toString().replace(/\s+/g, " ");
|
||||
var W = 55;
|
||||
if (before.length > W) before = "…" + before.slice(-W);
|
||||
if (after.length > W) after = after.slice(0, W) + "…";
|
||||
return (before + "〈" + selText + "〉" + after).trim();
|
||||
} catch (e) { return ""; }
|
||||
}
|
||||
|
||||
function handleSelection() {
|
||||
var sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed) { hideToolbar(); return; }
|
||||
var text = sel.toString().replace(/\s+/g, " ").trim();
|
||||
if (text.length < 2) { hideToolbar(); return; }
|
||||
var range = sel.getRangeAt(0);
|
||||
if (!article.contains(range.commonAncestorContainer)) { hideToolbar(); return; }
|
||||
var rect = range.getBoundingClientRect();
|
||||
pendingRange = { text: text, context: getContext(range, text) };
|
||||
showToolbar(rect);
|
||||
}
|
||||
|
||||
function showToolbar(rect) {
|
||||
toolbar.classList.add("show");
|
||||
var tw = toolbar.offsetWidth;
|
||||
var x = window.scrollX + rect.left + rect.width / 2 - tw / 2;
|
||||
var y = window.scrollY + rect.top - toolbar.offsetHeight - 8;
|
||||
x = Math.max(8, x);
|
||||
if (y < window.scrollY + 4) y = window.scrollY + rect.bottom + 8;
|
||||
toolbar.style.left = x + "px";
|
||||
toolbar.style.top = y + "px";
|
||||
popover.classList.remove("show");
|
||||
}
|
||||
function hideToolbar() { toolbar.classList.remove("show"); }
|
||||
|
||||
// Bygg verktøylinje-knapper
|
||||
Object.keys(INTENTS).forEach(function (key) {
|
||||
var b = document.createElement("button");
|
||||
b.innerHTML = '<span class="swatch" style="background:' + SWATCH[key] + '"></span>' + INTENTS[key].label;
|
||||
b.addEventListener("mousedown", function (ev) { ev.preventDefault(); });
|
||||
b.addEventListener("click", function () { openPopover(key); });
|
||||
toolbar.appendChild(b);
|
||||
});
|
||||
|
||||
// Intent-velger i popoveren (brukes både ved ny og ved redigering)
|
||||
var pick = popover.querySelector(".intent-pick");
|
||||
Object.keys(INTENTS).forEach(function (key) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.dataset.intent = key;
|
||||
b.innerHTML = '<span class="swatch" style="background:' + SWATCH[key] + '"></span>' + INTENTS[key].label;
|
||||
b.addEventListener("mousedown", function (ev) { ev.preventDefault(); });
|
||||
b.addEventListener("click", function () {
|
||||
popover.dataset.intent = key;
|
||||
updateIntentPick(key);
|
||||
});
|
||||
pick.appendChild(b);
|
||||
});
|
||||
function updateIntentPick(sel) {
|
||||
Array.prototype.forEach.call(pick.children, function (b) {
|
||||
b.classList.toggle("active", b.dataset.intent === sel);
|
||||
});
|
||||
}
|
||||
function positionPopover(rect) {
|
||||
popover.style.left = Math.max(8, window.scrollX + rect.left) + "px";
|
||||
popover.style.top = (window.scrollY + rect.top) + "px";
|
||||
}
|
||||
|
||||
function openPopover(intentKey) {
|
||||
if (!pendingRange) return;
|
||||
editingId = null;
|
||||
popover.querySelector(".ph").textContent =
|
||||
INTENTS[intentKey].label + " — «" + truncate(pendingRange.text, 60) + "»";
|
||||
var ta = popover.querySelector("textarea");
|
||||
ta.value = "";
|
||||
popover.dataset.intent = intentKey;
|
||||
updateIntentPick(intentKey);
|
||||
popover.querySelector(".del-edit").style.display = "none";
|
||||
popover.classList.add("show");
|
||||
positionPopover(toolbar.getBoundingClientRect());
|
||||
toolbar.classList.remove("show");
|
||||
setTimeout(function () { ta.focus(); }, 10);
|
||||
}
|
||||
|
||||
// Åpne popover for å REDIGERE en eksisterende annotering
|
||||
function openEdit(id, rect) {
|
||||
var a = annotations.filter(function (x) { return x.id === id; })[0];
|
||||
if (!a) return;
|
||||
editingId = id;
|
||||
pendingRange = null;
|
||||
popover.querySelector(".ph").textContent = "Rediger — «" + truncate(a.text, 60) + "»";
|
||||
var ta = popover.querySelector("textarea");
|
||||
ta.value = a.comment || "";
|
||||
popover.dataset.intent = a.intent;
|
||||
updateIntentPick(a.intent);
|
||||
popover.querySelector(".del-edit").style.display = "";
|
||||
popover.classList.add("show");
|
||||
positionPopover(rect);
|
||||
toolbar.classList.remove("show");
|
||||
window.getSelection().removeAllRanges();
|
||||
setTimeout(function () { ta.focus(); }, 10);
|
||||
}
|
||||
|
||||
// Klikk på en markering i artikkelen -> rediger den
|
||||
article.addEventListener("click", function (e) {
|
||||
var span = e.target.closest ? e.target.closest("span.anno") : null;
|
||||
if (!span || !span.dataset.id) return;
|
||||
e.preventDefault();
|
||||
openEdit(span.dataset.id, span.getBoundingClientRect());
|
||||
});
|
||||
|
||||
popover.querySelector(".cancel").addEventListener("click", function () {
|
||||
popover.classList.remove("show"); pendingRange = null; editingId = null;
|
||||
});
|
||||
popover.querySelector(".del-edit").addEventListener("click", function () {
|
||||
if (!editingId) return;
|
||||
annotations = annotations.filter(function (x) { return x.id !== editingId; });
|
||||
editingId = null;
|
||||
save();
|
||||
popover.classList.remove("show");
|
||||
render();
|
||||
});
|
||||
popover.querySelector(".primary").addEventListener("click", function () {
|
||||
var intent = popover.dataset.intent;
|
||||
var cmt = popover.querySelector("textarea").value.trim();
|
||||
if (editingId) {
|
||||
annotations.forEach(function (a) {
|
||||
if (a.id === editingId) { a.intent = intent; a.comment = cmt; }
|
||||
});
|
||||
editingId = null;
|
||||
save();
|
||||
popover.classList.remove("show");
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (!pendingRange) return;
|
||||
annotations.push({
|
||||
id: Date.now() + "-" + Math.floor(Math.random() * 1e6),
|
||||
intent: intent,
|
||||
text: pendingRange.text,
|
||||
context: pendingRange.context || "",
|
||||
comment: cmt
|
||||
});
|
||||
save();
|
||||
popover.classList.remove("show");
|
||||
pendingRange = null;
|
||||
window.getSelection().removeAllRanges();
|
||||
render();
|
||||
});
|
||||
|
||||
// --- Rendering: marker tekst i artikkelen og bygg sidepanel --------------
|
||||
function render() {
|
||||
clearMarks();
|
||||
annotations.forEach(function (a, idx) { markInArticle(a, idx + 1); });
|
||||
renderList();
|
||||
}
|
||||
|
||||
function clearMarks() {
|
||||
var marks = article.querySelectorAll("span.anno");
|
||||
marks.forEach(function (m) {
|
||||
var parent = m.parentNode;
|
||||
while (m.firstChild) {
|
||||
if (m.firstChild.classList && m.firstChild.classList.contains("anno-num")) {
|
||||
m.removeChild(m.firstChild);
|
||||
} else {
|
||||
parent.insertBefore(m.firstChild, m);
|
||||
}
|
||||
}
|
||||
parent.removeChild(m);
|
||||
parent.normalize();
|
||||
});
|
||||
}
|
||||
|
||||
// Finn første tekst-treff i artikkelen og pakk det inn. Teller per-tekst
|
||||
// forekomster slik at like sitater markeres i rekkefølge.
|
||||
var occCounters = {};
|
||||
function markInArticle(a, num) {
|
||||
var needle = a.text;
|
||||
if (!occCounters[needle]) occCounters[needle] = 0;
|
||||
var skip = occCounters[needle];
|
||||
occCounters[needle]++;
|
||||
|
||||
var walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT, null);
|
||||
var node, found = 0;
|
||||
while ((node = walker.nextNode())) {
|
||||
var nv = node.nodeValue;
|
||||
var pos = nv.indexOf(needle);
|
||||
while (pos !== -1) {
|
||||
if (found === skip) {
|
||||
wrapTextNode(node, pos, needle.length, a, num);
|
||||
return;
|
||||
}
|
||||
found++;
|
||||
pos = nv.indexOf(needle, pos + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wrapTextNode(node, start, len, a, num) {
|
||||
var range = document.createRange();
|
||||
range.setStart(node, start);
|
||||
range.setEnd(node, start + len);
|
||||
var span = document.createElement("span");
|
||||
span.className = "anno anno-" + INTENTS[a.intent].cls;
|
||||
span.dataset.id = a.id;
|
||||
span.title = INTENTS[a.intent].label + (a.comment ? ": " + a.comment : "");
|
||||
try {
|
||||
range.surroundContents(span);
|
||||
var sup = document.createElement("sup");
|
||||
sup.className = "anno-num";
|
||||
sup.textContent = num;
|
||||
span.appendChild(sup);
|
||||
} catch (e) { /* range spente over elementgrense — hopp over markering */ }
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
occCounters = {}; // nullstill for neste render
|
||||
listEl.innerHTML = "";
|
||||
if (!annotations.length) {
|
||||
var em = document.createElement("div");
|
||||
em.className = "empty";
|
||||
em.textContent = "Ingen annoteringer ennå. Marker tekst i artikkelen for å begynne.";
|
||||
listEl.appendChild(em);
|
||||
return;
|
||||
}
|
||||
annotations.forEach(function (a, idx) {
|
||||
var item = document.createElement("div");
|
||||
item.className = "anno-item";
|
||||
var cls = INTENTS[a.intent].cls;
|
||||
item.innerHTML =
|
||||
'<div class="top">' +
|
||||
'<span class="num">' + (idx + 1) + '</span>' +
|
||||
'<span class="badge badge-' + cls + '">' + INTENTS[a.intent].label + '</span>' +
|
||||
'<button class="edit" title="Endre">Endre</button>' +
|
||||
'<button class="del" title="Slett">Slett</button>' +
|
||||
'</div>' +
|
||||
'<div class="quote">«' + escHtml(truncate(a.text, 90)) + '»</div>' +
|
||||
(a.comment ? '<div class="cmt">' + escHtml(a.comment) + '</div>' : '');
|
||||
item.querySelector(".edit").addEventListener("click", function () {
|
||||
openEdit(a.id, item.getBoundingClientRect());
|
||||
});
|
||||
item.querySelector(".del").addEventListener("click", function () {
|
||||
annotations = annotations.filter(function (x) { return x.id !== a.id; });
|
||||
save(); render();
|
||||
});
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Eksport: kompakt annoteringsliste (kun annoteringer, ikke brødtekst) -
|
||||
function buildAnnotatedMarkdown() {
|
||||
var header = "# Annoteringer — " + SOURCE_FILE + " · «" + TITLE + "»";
|
||||
if (!annotations.length) {
|
||||
return header + "\n\n(Ingen annoteringer.)\n";
|
||||
}
|
||||
function occurrences(s) {
|
||||
if (!s) return 0;
|
||||
var hay = article.textContent.replace(/\s+/g, " ");
|
||||
var n = 0, i = 0;
|
||||
while ((i = hay.indexOf(s, i)) !== -1) { n++; i += s.length; }
|
||||
return n;
|
||||
}
|
||||
var blocks = annotations.map(function (a, idx) {
|
||||
var lines = [(idx + 1) + ". [" + INTENTS[a.intent].upper + "] «" + a.text + "»"];
|
||||
// Ta med kontekst kun når markeringen er kort eller forekommer flere ganger
|
||||
// (ellers holder vi eksporten kompakt).
|
||||
if (a.context && (a.text.length < 30 || occurrences(a.text) > 1)) {
|
||||
lines.push(" ↳ i: «" + a.context + "»");
|
||||
}
|
||||
lines.push(" → " + (a.comment || ""));
|
||||
return lines.join("\n");
|
||||
});
|
||||
return header + "\n\n" + blocks.join("\n\n") + "\n";
|
||||
}
|
||||
|
||||
function showExport() {
|
||||
var overlay = document.getElementById("exportOverlay");
|
||||
var ta = document.getElementById("exportText");
|
||||
ta.value = buildAnnotatedMarkdown();
|
||||
overlay.classList.add("show");
|
||||
ta.focus(); ta.select();
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(ta.value).catch(function () {});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers --------------------------------------------------------------
|
||||
function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + "…" : s; }
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// --- Bind topp-/panel-knapper --------------------------------------------
|
||||
document.getElementById("btnExport").addEventListener("click", showExport);
|
||||
document.getElementById("btnExportTop").addEventListener("click", showExport);
|
||||
document.getElementById("btnClear").addEventListener("click", function () {
|
||||
if (!annotations.length) return;
|
||||
if (confirm("Tøm alle annoteringer for denne artikkelen?")) {
|
||||
annotations = []; save(); render();
|
||||
}
|
||||
});
|
||||
document.getElementById("exportClose").addEventListener("click", function () {
|
||||
document.getElementById("exportOverlay").classList.remove("show");
|
||||
});
|
||||
document.getElementById("exportCopy").addEventListener("click", function () {
|
||||
var ta = document.getElementById("exportText");
|
||||
ta.select();
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(ta.value);
|
||||
} else { document.execCommand("copy"); }
|
||||
});
|
||||
document.getElementById("sidebarToggle").addEventListener("click", function () {
|
||||
sidebar.classList.toggle("mobile-show");
|
||||
});
|
||||
|
||||
// Skjul verktøylinje ved klikk utenfor
|
||||
document.addEventListener("mousedown", function (e) {
|
||||
if (!toolbar.contains(e.target) && !popover.contains(e.target)) {
|
||||
if (!window.getSelection().toString().trim()) hideToolbar();
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
})();
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML-shell
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildPage(meta, body, articleKey, sourceFile) {
|
||||
const bodyHtml = markdownToHtml(body);
|
||||
const title = meta.title || "Kronikk";
|
||||
|
||||
const metaLine = [meta.serie, meta.lesetid].filter(Boolean).join(" · ");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="no">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${esc(title)}</title>
|
||||
<style>${CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="sidebar-toggle" id="sidebarToggle">Annoteringer</button>
|
||||
<div class="page">
|
||||
<article>
|
||||
${meta.kicker ? `<p class="kicker">${esc(meta.kicker)}</p>` : ""}
|
||||
<h1 class="title">${inline(title)}</h1>
|
||||
${meta.subtitle ? `<p class="subtitle">${inline(meta.subtitle)}</p>` : ""}
|
||||
<div class="byline-wrap">
|
||||
${meta.byline ? `<p class="byline">${esc(meta.byline)}</p>` : ""}
|
||||
${metaLine ? `<p class="meta">${esc(metaLine)}</p>` : ""}
|
||||
</div>
|
||||
<div class="body">
|
||||
${bodyHtml}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="sidebar" id="annoSidebar">
|
||||
<h2>Annoteringer</h2>
|
||||
<div class="actions">
|
||||
<button class="primary" id="btnExport">Kopier annoteringer</button>
|
||||
<button id="btnClear">Tøm alle</button>
|
||||
</div>
|
||||
<div id="annoList"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="anno-toolbar" id="annoToolbar"></div>
|
||||
|
||||
<div class="anno-popover" id="annoPopover">
|
||||
<div class="ph"></div>
|
||||
<div class="intent-pick"></div>
|
||||
<textarea placeholder="Skriv kommentar (valgfritt)…"></textarea>
|
||||
<div class="row">
|
||||
<button class="del-edit" style="display:none;">Slett</button>
|
||||
<button class="cancel">Avbryt</button>
|
||||
<button class="primary">Lagre</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="export-overlay" id="exportOverlay">
|
||||
<div class="export-box">
|
||||
<h3>Annoteringer — kopier og lim tilbake</h3>
|
||||
<button class="primary" id="btnExportTop" style="margin-bottom:8px;">Kopier til utklippstavle</button>
|
||||
<textarea id="exportText" readonly></textarea>
|
||||
<div class="row">
|
||||
<button id="exportCopy">Kopier</button>
|
||||
<button class="primary" id="exportClose">Lukk</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__ARTICLE_KEY__ = ${toJsString(articleKey)};
|
||||
window.__BODY_MD__ = ${toJsString(body)};
|
||||
window.__SOURCE_FILE__ = ${toJsString(sourceFile)};
|
||||
window.__TITLE__ = ${toJsString(title)};
|
||||
</script>
|
||||
<script>
|
||||
${CLIENT_JS}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length) {
|
||||
console.error("Bruk: node build-html.mjs <fil.md> [flere.md ...]");
|
||||
process.exit(1);
|
||||
}
|
||||
// Output følger serien (kjøres fra serie-mappa), ikke scriptet i tools/.
|
||||
const outDir = path.join(process.cwd(), "review");
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
for (const arg of args) {
|
||||
const inPath = path.isAbsolute(arg) ? arg : path.join(process.cwd(), arg);
|
||||
if (!fs.existsSync(inPath)) {
|
||||
console.error(`Fant ikke: ${inPath}`);
|
||||
continue;
|
||||
}
|
||||
const raw = fs.readFileSync(inPath, "utf8");
|
||||
const { meta, body } = parseFrontMatter(raw);
|
||||
const sourceFile = path.basename(inPath);
|
||||
const base = sourceFile.replace(/\.md$/i, "");
|
||||
const html = buildPage(meta, body, base, sourceFile);
|
||||
const outPath = path.join(outDir, base + ".html");
|
||||
fs.writeFileSync(outPath, html, "utf8");
|
||||
console.log(`Skrev ${outPath} (${(html.length / 1024).toFixed(1)} KB)`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
364
render/build-linkedin.mjs
Normal file
364
render/build-linkedin.mjs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
#!/usr/bin/env node
|
||||
// build-linkedin.mjs — bygger ÉN SAMLET POST.html per artikkel (publiseringsark).
|
||||
// Bruk: node build-linkedin.mjs utkast/01-...md [flere.md ...]
|
||||
// (samle/POST.html bygges alltid til slutt, uavhengig av argumentene)
|
||||
//
|
||||
// Mål (HANDOVER §13 E): alt-på-ett-sted per artikkel slik at bruker kan legge inn én edition
|
||||
// i én operasjon. POST.html åpnes i nettleser og inneholder, i publiseringsrekkefølge:
|
||||
// 1. Planlagt dato + kl. 08:00 (+ ferskvare-flagg for 01/02)
|
||||
// 2. Tittel / SEO-tittel / SEO-beskrivelse
|
||||
// 3. Cover: filnavn + credit + caption
|
||||
// 4. «Tell your network»-delingstekst (system, klikk-gatet) + hashtags
|
||||
// 5. Første kommentar
|
||||
// 6. (Del 3/6) carousel-PDF-referanse
|
||||
// 7. Brødtekst som RIK TEKST mellom streker (merk & kopier rett inn i editoren)
|
||||
//
|
||||
// Kilder:
|
||||
// - brødtekst + felt: front matter + body i utkast/NN-...md
|
||||
// - delingstekst/hashtags/første kommentar: linkedin/edition-delingstekst.md (parses)
|
||||
// - kalender + ferskvare + cover-credit/caption: konstanter nedenfor (HANDOVER §13 + image-credit-caption.md)
|
||||
// Ingen npm-avhengigheter, ingen nett. meta.md røres IKKE (håndholdt; har ekte pulse-URL).
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Output følger serien (kjøres fra serie-mappa), ikke scriptet i tools/.
|
||||
const OUT_ROOT = path.join(process.cwd(), "linkedin");
|
||||
const DELINGSTEKST_FILE = path.join(OUT_ROOT, "edition-delingstekst.md");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LÅSTE KONSTANTER (HANDOVER §13 / DREIEBOK / image-credit-caption.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
const CALENDAR = {
|
||||
"01": { dag: "Tirsdag 26.05.2026", klokke: "08:00" },
|
||||
"02": { dag: "Onsdag 27.05.2026", klokke: "08:00" },
|
||||
"03": { dag: "Torsdag 28.05.2026", klokke: "08:00" },
|
||||
"04": { dag: "Fredag 29.05.2026", klokke: "08:00" },
|
||||
"05": { dag: "Lørdag 30.05.2026", klokke: "08:00" },
|
||||
"06": { dag: "Søndag 31.05.2026", klokke: "08:00" },
|
||||
samle: { dag: "Mandag 01.06.2026", klokke: "08:00" },
|
||||
};
|
||||
|
||||
const FRESHNESS = {
|
||||
"01": "OpenAI-verdsettelse (~850 mrd USD «i mars») + «Anthropic forhandler akkurat nå om en runde» — er runden lukket? Oppdater tall/tempus FØR planlegging.",
|
||||
"02": "SWE-bench Verified (77,6 %) for Mistral Medium 3.5 — fortsatt korrekt? Vurder avrunding FØR planlegging.",
|
||||
};
|
||||
|
||||
const COVER_CREDIT = "Illustrasjon generert med Google Gemini (Nano Banana Pro)";
|
||||
const CAPTIONS = {
|
||||
"01": "Noen lover vekst — men hvem tjener på at ledergruppen tror på pitchen?",
|
||||
"02": "KI på maskiner vi styrer selv — der ingen data forlater huset.",
|
||||
"03": "Regningen kommer hver måned — og en voksende del går ut av landet.",
|
||||
"04": "Samme talent, ulik tilgang — det er der gapet begynner.",
|
||||
"05": "Å forvalte var jobben. Nå er den å lede omstilling — om igjen, hvert år.",
|
||||
"06": "Tolv grep en leder kan ta selv — de to første er allerede krysset av.",
|
||||
};
|
||||
const CAROUSEL = new Set(["03", "06"]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML front matter (flate key: "value"-par mellom --- ... ---)
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseFrontMatter(raw) {
|
||||
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!m) return { meta: {}, body: raw };
|
||||
const meta = {};
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const mm = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||
if (!mm) continue;
|
||||
let val = mm[2].trim();
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
meta[mm[1]] = val;
|
||||
}
|
||||
return { meta, body: m[2].replace(/^\r?\n+/, "") };
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Inline: **fet**, *kursiv*, bare URL → lenke. «», — beholdes.
|
||||
function inline(text) {
|
||||
let out = esc(text);
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
||||
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
|
||||
out = out.replace(
|
||||
/(https?:\/\/[^\s<]+)/g,
|
||||
(u) => `<a href="${u}">${u}</a>`
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown -> REN semantisk HTML (tagger LinkedIn-editoren kjenner igjen:
|
||||
// h2, h3, p, ul/ol/li, blockquote, strong, em, hr).
|
||||
// ---------------------------------------------------------------------------
|
||||
function markdownToBlocks(body) {
|
||||
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let para = [];
|
||||
|
||||
function flushPara() {
|
||||
if (para.length) out.push(`<p>${inline(para.join(" "))}</p>`);
|
||||
para = [];
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim();
|
||||
if (t === "") { flushPara(); i++; continue; }
|
||||
if (t === "---") { flushPara(); out.push("<hr>"); i++; continue; }
|
||||
if (/^###\s+/.test(t)) { flushPara(); out.push(`<h3>${inline(t.replace(/^###\s+/, ""))}</h3>`); i++; continue; }
|
||||
if (/^##\s+/.test(t)) { flushPara(); out.push(`<h2>${inline(t.replace(/^##\s+/, ""))}</h2>`); i++; continue; }
|
||||
if (/^>\s?/.test(t)) {
|
||||
flushPara();
|
||||
const q = [];
|
||||
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||||
q.push(lines[i].trim().replace(/^>\s?/, ""));
|
||||
i++;
|
||||
}
|
||||
out.push(`<blockquote><p>${inline(q.join(" "))}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
if (/^[-*]\s+/.test(t)) {
|
||||
flushPara();
|
||||
const items = [];
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
out.push(`<ul>${items.map((x) => `<li>${inline(x)}</li>`).join("")}</ul>`);
|
||||
continue;
|
||||
}
|
||||
if (/^\d+\.\s+/.test(t)) {
|
||||
flushPara();
|
||||
const items = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
out.push(`<ol>${items.map((x) => `<li>${inline(x)}</li>`).join("")}</ol>`);
|
||||
continue;
|
||||
}
|
||||
para.push(t);
|
||||
i++;
|
||||
}
|
||||
flushPara();
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SEO
|
||||
// ---------------------------------------------------------------------------
|
||||
function seoDescription(subtitle) {
|
||||
const s = (subtitle || "").replace(/\s+/g, " ").trim();
|
||||
if (s.length <= 160) return s;
|
||||
let cut = s.slice(0, 158);
|
||||
cut = cut.slice(0, cut.lastIndexOf(" "));
|
||||
return cut + "…";
|
||||
}
|
||||
function seoTitle(title) {
|
||||
return (title || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse edition-delingstekst.md → { "01": {share, hashtags, kommentar}, ..., samle: {...} }
|
||||
// En seksjon = «## Del N — …» eller «## Samle…». «## SYSTEM …» ignoreres.
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseDelingstekst() {
|
||||
const raw = fs.readFileSync(DELINGSTEKST_FILE, "utf8").replace(/\r\n/g, "\n");
|
||||
const lines = raw.split("\n");
|
||||
const out = {};
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const h = lines[i].match(/^##\s+(Del\s+(\d)|Samle)/i);
|
||||
if (!h) { i++; continue; }
|
||||
const key = h[2] ? h[2].padStart(2, "0") : "samle";
|
||||
i++;
|
||||
const shareLines = [];
|
||||
let hashtags = "";
|
||||
let kommentar = "";
|
||||
while (i < lines.length && !/^##\s+/.test(lines[i]) && lines[i].trim() !== "---") {
|
||||
const t = lines[i];
|
||||
const tt = t.trim();
|
||||
const km = tt.match(/^\*\*Første kommentar:\*\*\s*(.*)$/);
|
||||
if (km) { kommentar = km[1].trim(); i++; continue; }
|
||||
if (/^#\S/.test(tt)) { hashtags = tt; i++; continue; }
|
||||
if (/^>/.test(tt)) { i++; continue; } // hopp over NB-blockquote
|
||||
shareLines.push(t);
|
||||
i++;
|
||||
}
|
||||
out[key] = {
|
||||
share: shareLines.join("\n").trim(),
|
||||
hashtags,
|
||||
kommentar,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML-skall
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSS = `
|
||||
body { font: 16px/1.6 -apple-system, Segoe UI, Roboto, sans-serif; max-width: 760px;
|
||||
margin: 24px auto; padding: 0 22px 60px; color: #1a1a1a; }
|
||||
h1.sheet { font-size: 1.5em; margin: 0 0 2px; }
|
||||
.when { font-size: 1.05em; font-weight: 700; color: #9a3324; margin: 0 0 18px; }
|
||||
.fresh { background: #fff7e6; border: 1px solid #f0c97a; border-radius: 8px;
|
||||
padding: 12px 16px; font-size: 14px; color: #5a4500; margin: 0 0 20px; }
|
||||
.fld { background: #f6f6f4; border: 1px solid #e2e2dc; border-radius: 10px;
|
||||
padding: 14px 18px; margin: 0 0 16px; }
|
||||
.fld h2 { font-size: .82em; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: #777; margin: 0 0 8px; }
|
||||
.fld .label { font-size: 12px; color: #888; margin: 10px 0 1px; }
|
||||
.fld .val { font-size: 15px; }
|
||||
.warn { color: #9a3324; font-weight: 600; }
|
||||
.copybox { background: #fff; border: 1px dashed #9a3324; border-radius: 8px;
|
||||
padding: 12px 14px; white-space: pre-wrap; font-size: 15px; margin-top: 4px; }
|
||||
.marker { color: #9a3324; font-weight: 700; letter-spacing: .04em; font-size: 13px;
|
||||
text-transform: uppercase; margin: 26px 0 6px; }
|
||||
.copyzone { border-top: 2px dashed #9a3324; border-bottom: 2px dashed #9a3324; padding: 18px 0; }
|
||||
.copyzone h2 { font-size: 1.32em; margin: 1.4em 0 .4em; }
|
||||
.copyzone h3 { font-size: 1.1em; margin: 1.2em 0 .3em; }
|
||||
.copyzone blockquote { border-left: 3px solid #ccc; margin: 1em 0; padding-left: 16px; color: #444; }
|
||||
.copyzone hr { border: none; border-top: 1px solid #ddd; margin: 1.6em 0; }
|
||||
.copyzone li { margin: .2em 0; }
|
||||
code { background: #ececec; padding: 1px 5px; border-radius: 4px; font-size: 13px; }
|
||||
`;
|
||||
|
||||
function shell(title, inner) {
|
||||
return `<!doctype html>
|
||||
<html lang="nb">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${esc(title)}</title>
|
||||
<style>${CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
${inner}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edition-POST.html (Del 1–6)
|
||||
// ---------------------------------------------------------------------------
|
||||
function editionPost(nn, meta, body, share) {
|
||||
const cal = CALENDAR[nn] || { dag: "—", klokke: "08:00" };
|
||||
const sTitle = seoTitle(meta.title);
|
||||
const sDesc = seoDescription(meta.subtitle);
|
||||
const seoWarn = sTitle.length > 60 ? ` <span class="warn">⚠️ ${sTitle.length} tegn (over SEO-anbefaling 60)</span>` : ` (${sTitle.length} tegn)`;
|
||||
const fresh = FRESHNESS[nn];
|
||||
const blocks = markdownToBlocks(body);
|
||||
const subtitle = meta.subtitle ? `<p><strong>${inline(meta.subtitle)}</strong></p>` : "";
|
||||
const copyZone = [subtitle, ...blocks].filter(Boolean).join("\n ");
|
||||
const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—";
|
||||
|
||||
const carouselBlock = CAROUSEL.has(nn)
|
||||
? `<div class="fld"><h2>6 · Carousel (valgfritt rekkevidde-tillegg)</h2>
|
||||
<div class="val">Egen dokument-post, helst egen dag: last opp <code>linkedin/${nn}/carousel.pdf</code>.
|
||||
Caption = delingstekstens premiss-linje.</div></div>`
|
||||
: "";
|
||||
|
||||
const inner = `
|
||||
<h1 class="sheet">Del ${nn} — ${esc(meta.title || "")}</h1>
|
||||
<p class="when">📅 ${cal.dag} · kl. ${cal.klokke} (Schedule post → CEST)</p>
|
||||
${fresh ? `<div class="fresh"><strong>⚠️ Ferskvare før planlegging:</strong> ${esc(fresh)}</div>` : ""}
|
||||
|
||||
<div class="fld">
|
||||
<h2>1 · Felter (Settings i editoren)</h2>
|
||||
<div class="label">Tittel (${(meta.title || "").length} tegn)</div>
|
||||
<div class="val">${esc(meta.title || "")}</div>
|
||||
<div class="label">SEO-tittel${seoWarn}</div>
|
||||
<div class="val">${esc(sTitle)}</div>
|
||||
<div class="label">SEO-beskrivelse (${sDesc.length} tegn — mål 140–160)</div>
|
||||
<div class="val">${esc(sDesc)}</div>
|
||||
<div class="label">Lesetid / Serie</div>
|
||||
<div class="val">${esc(meta.lesetid || "—")} · ${esc(meta.serie || "—")}</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<h2>2 · Cover (1920×1080)</h2>
|
||||
<div class="label">Fil</div><div class="val"><code>linkedin/${nn}/cover.png</code></div>
|
||||
<div class="label">Credit (Add credit and caption)</div><div class="val">${esc(COVER_CREDIT)}</div>
|
||||
<div class="label">Caption</div><div class="val">${esc(CAPTIONS[nn] || "—")}</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<h2>3 · «Tell your network…»-delingstekst (lim i feltet over kortet)</h2>
|
||||
<div class="copybox">${esc(shareField)}</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<h2>4 · Første kommentar (legg når posten er live)</h2>
|
||||
<div class="copybox">${esc(share ? share.kommentar : "—")}</div>
|
||||
</div>
|
||||
|
||||
${carouselBlock}
|
||||
|
||||
<div class="marker">⬇︎ ${CAROUSEL.has(nn) ? "7" : "6"} · BRØDTEKST — merk alt herfra, kopier (⌘C), lim i editoren ⬇︎</div>
|
||||
<div class="copyzone">
|
||||
${copyZone}
|
||||
</div>
|
||||
<div class="marker">⬆︎ Til hit ⬆︎ (sjekk at overskrifter/lister/fet overlevde liminga)</div>`;
|
||||
|
||||
return shell(`Del ${nn} · ${meta.title || ""}`, inner);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Samle-POST.html (frittstående native post)
|
||||
// ---------------------------------------------------------------------------
|
||||
function samlePost(share) {
|
||||
const cal = CALENDAR.samle;
|
||||
const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—";
|
||||
const inner = `
|
||||
<h1 class="sheet">Samle-post — oversikt over hele serien</h1>
|
||||
<p class="when">📅 ${cal.dag} · kl. ${cal.klokke} (Schedule post → CEST)</p>
|
||||
<div class="fresh"><strong>Type:</strong> Frittstående native feed-post (ikke en edition).
|
||||
Lenken til serien legges i FØRSTE KOMMENTAR.</div>
|
||||
|
||||
<div class="marker">⬇︎ 1 · POST-TEKST — merk alt herfra, kopier, lim i en ny LinkedIn-post ⬇︎</div>
|
||||
<div class="copyzone"><div class="copybox" style="border:none;padding:0">${esc(shareField)}</div></div>
|
||||
<div class="marker">⬆︎ Til hit ⬆︎</div>
|
||||
|
||||
<div class="fld" style="margin-top:24px">
|
||||
<h2>2 · Første kommentar (legg når posten er live)</h2>
|
||||
<div class="copybox">${esc(share ? share.kommentar : "—")}</div>
|
||||
<div class="label" style="margin-top:10px">[LENKE] = index/kanonisk hjem (fromaitochitta.com hvis live) ELLER Del 1-editionen som inngang. Velg det som faktisk er publisert.</div>
|
||||
</div>`;
|
||||
return shell("Samle-post · Maskinrommet", inner);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
const files = process.argv.slice(2);
|
||||
const shareMap = parseDelingstekst();
|
||||
|
||||
for (const f of files) {
|
||||
const abs = path.isAbsolute(f) ? f : path.join(process.cwd(), f);
|
||||
const raw = fs.readFileSync(abs, "utf8");
|
||||
const { meta, body } = parseFrontMatter(raw);
|
||||
const base = path.basename(abs).replace(/\.md$/, "");
|
||||
const nn = (base.match(/^(\d{2})/) || [, base])[1];
|
||||
if (!/^\d{2}$/.test(nn)) { console.warn(`↷ hopper over ${f} (ikke NN-prefiks)`); continue; }
|
||||
const dir = path.join(OUT_ROOT, nn);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "POST.html"), editionPost(nn, meta, body, shareMap[nn]));
|
||||
console.log(`✓ linkedin/${nn}/POST.html (${meta.title || base})`);
|
||||
}
|
||||
|
||||
// Samle bygges alltid (innhold er uavhengig av utkast-filene)
|
||||
if (shareMap.samle) {
|
||||
const dir = path.join(OUT_ROOT, "samle");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "POST.html"), samlePost(shareMap.samle));
|
||||
console.log("✓ linkedin/samle/POST.html (samle-post)");
|
||||
}
|
||||
378
render/build-pdf.mjs
Normal file
378
render/build-pdf.mjs
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
#!/usr/bin/env node
|
||||
// build-pdf.mjs — render kronikkene som rene avis-PDF-er (uten annoterings-UI).
|
||||
// Bruk: node build-pdf.mjs utkast/01-....md [flere.md ...]
|
||||
// Genererer ren print-HTML i pdf/_html/<navn>.html og kjører weasyprint -> pdf/<navn>.pdf.
|
||||
// Speiler avis-stilen fra build-html.mjs, men print-tunet (A4, marger, sidetall).
|
||||
// Krever: weasyprint på PATH. Ingen npm-avhengigheter.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// weasyprint graceful degradation (S1, correction #3)
|
||||
// Detekterer weasyprint på PATH. Returnerer et skip-signal (kaster ALDRI) når
|
||||
// verktøyet mangler, slik at PDF-steget hoppes over med en tydelig install-hint
|
||||
// i stedet for å krasje kjøringen. `probe` er injiserbar for test.
|
||||
// ---------------------------------------------------------------------------
|
||||
const WEASYPRINT_HINT =
|
||||
"weasyprint ikke funnet på PATH — hopper over PDF-steget.\n" +
|
||||
" Install: pipx install weasyprint (alternativt: brew install weasyprint)";
|
||||
|
||||
export function resolveWeasyprint(probe = defaultWeasyprintProbe) {
|
||||
if (probe()) return { available: true };
|
||||
return { available: false, hint: WEASYPRINT_HINT };
|
||||
}
|
||||
|
||||
function defaultWeasyprintProbe() {
|
||||
try {
|
||||
execFileSync("weasyprint", ["--version"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML front matter (flate key: "value"-par mellom --- ... ---) — som build-html.mjs
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseFrontMatter(raw) {
|
||||
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!m) return { meta: {}, body: raw };
|
||||
const meta = {};
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const mm = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||
if (!mm) continue;
|
||||
let val = mm[2].trim();
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
meta[mm[1]] = val;
|
||||
}
|
||||
return { meta, body: m[2].replace(/^\r?\n+/, "") };
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function inline(text) {
|
||||
let out = esc(text);
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
||||
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kompakt markdown -> HTML (som build-html.mjs). Siste avsnitt-blokk som starter
|
||||
// med <em>Om tilblivelsen:</em> merkes .colophon for diskret metodenote-stil.
|
||||
// ---------------------------------------------------------------------------
|
||||
function markdownToHtml(body) {
|
||||
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
||||
const blocks = [];
|
||||
let i = 0;
|
||||
let paraCount = 0;
|
||||
|
||||
function flushPara(buf) {
|
||||
if (!buf.length) return;
|
||||
const text = buf.join(" ").trim();
|
||||
if (!text) return;
|
||||
paraCount++;
|
||||
let cls = paraCount === 1 ? "lede" : "indent";
|
||||
if (/^\*Om tilblivelsen:\*/.test(text)) cls = "colophon";
|
||||
let inner = inline(text);
|
||||
// Drop cap som ekte, floatet <span> (weasyprint krasjer på ::first-letter{float}).
|
||||
if (cls === "lede") {
|
||||
inner = inner.replace(
|
||||
/^(\s*)([A-Za-zÆØÅæøå0-9])/,
|
||||
(_, ws, ch) => `${ws}<span class="dropcap">${ch}</span>`
|
||||
);
|
||||
}
|
||||
blocks.push(`<p class="${cls}">${inner}</p>`);
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (/^---+$/.test(trimmed)) {
|
||||
blocks.push("<hr>");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let hm = trimmed.match(/^(#{2,3})\s+(.*)$/);
|
||||
if (hm) {
|
||||
const level = hm[1].length;
|
||||
blocks.push(`<h${level}>${inline(hm[2].trim())}</h${level}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^>\s?/.test(trimmed)) {
|
||||
const qbuf = [];
|
||||
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||||
qbuf.push(lines[i].trim().replace(/^>\s?/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push(`<blockquote><p>${inline(qbuf.join(" ").trim())}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push("<ul>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ul>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push("<ol>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ol>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed === "") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const pbuf = [];
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim();
|
||||
if (
|
||||
t === "" ||
|
||||
/^---+$/.test(t) ||
|
||||
/^(#{2,3})\s+/.test(t) ||
|
||||
/^>\s?/.test(t) ||
|
||||
/^[-*]\s+/.test(t) ||
|
||||
/^\d+\.\s+/.test(t)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
pbuf.push(t);
|
||||
i++;
|
||||
}
|
||||
flushPara(pbuf);
|
||||
}
|
||||
|
||||
return blocks.join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Print-CSS — avis-identitet (off-white, oxblood, drop cap, serif), A4-tunet.
|
||||
// ---------------------------------------------------------------------------
|
||||
const FONT_DIR = path.join(__dirname, "fonts");
|
||||
const ff = (f) => `url("file://${path.join(FONT_DIR, f).replace(/ /g, "%20")}")`;
|
||||
const FONT_FACE = `
|
||||
@font-face{font-family:"Newsreader";font-style:normal;font-weight:400;src:${ff("Newsreader-400.ttf")};}
|
||||
@font-face{font-family:"Newsreader";font-style:italic;font-weight:400;src:${ff("Newsreader-400i.ttf")};}
|
||||
@font-face{font-family:"Newsreader";font-style:normal;font-weight:600;src:${ff("Newsreader-600.ttf")};}
|
||||
@font-face{font-family:"Newsreader";font-style:italic;font-weight:600;src:${ff("Newsreader-600i.ttf")};}
|
||||
@font-face{font-family:"Newsreader";font-style:normal;font-weight:700;src:${ff("Newsreader-700.ttf")};}
|
||||
@font-face{font-family:"Inter";font-style:normal;font-weight:400;src:${ff("Inter-400.ttf")};}
|
||||
@font-face{font-family:"Inter";font-style:normal;font-weight:600;src:${ff("Inter-600.ttf")};}
|
||||
@font-face{font-family:"Inter";font-style:normal;font-weight:700;src:${ff("Inter-700.ttf")};}
|
||||
`;
|
||||
const PRINT_CSS = `
|
||||
${FONT_FACE}
|
||||
:root {
|
||||
--bg: #FBFAF7;
|
||||
--ink: #1A1A1A;
|
||||
--muted: #555555;
|
||||
--accent: #9A3324;
|
||||
--rule: #d8d4cb;
|
||||
--serif: "Newsreader",Georgia,"Times New Roman",serif;
|
||||
--sans: "Inter","Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
}
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 22mm 21mm 20mm;
|
||||
background: #FBFAF7;
|
||||
@bottom-center {
|
||||
content: counter(page);
|
||||
font-family: "Inter","Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
font-size: 8pt;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--bg); }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--serif);
|
||||
font-size: 12.5pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.kicker {
|
||||
font-family: var(--sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin: 0 0 8pt;
|
||||
}
|
||||
h1.title {
|
||||
font-family: var(--serif);
|
||||
font-size: 27pt;
|
||||
line-height: 1.08;
|
||||
font-weight: 700;
|
||||
margin: 0 0 9pt;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.subtitle {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 14.5pt;
|
||||
line-height: 1.4;
|
||||
color: var(--muted);
|
||||
margin: 0 0 14pt;
|
||||
}
|
||||
.byline-wrap {
|
||||
border-top: 1px solid var(--rule);
|
||||
padding-top: 7pt;
|
||||
margin-bottom: 18pt;
|
||||
}
|
||||
.byline {
|
||||
font-family: var(--sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin: 0 0 2pt;
|
||||
}
|
||||
.meta {
|
||||
font-family: var(--sans);
|
||||
font-size: 8pt;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.body p { margin: 0; text-align: justify; hyphens: auto; orphans: 3; widows: 3; }
|
||||
.body p.indent { text-indent: 1.4em; }
|
||||
.body .dropcap {
|
||||
float: left;
|
||||
font-size: 3.1em;
|
||||
line-height: 0.74;
|
||||
padding: 0.02em 0.09em 0 0;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.body h2 { font-size: 16.5pt; font-weight: 700; margin: 18pt 0 6pt; line-height: 1.2; }
|
||||
.body h3 { font-size: 13.5pt; font-weight: 700; margin: 15pt 0 4pt; line-height: 1.25; }
|
||||
.body ul, .body ol { margin: 7pt 0; padding-left: 1.4em; }
|
||||
.body li { margin: 2pt 0; }
|
||||
.body blockquote {
|
||||
margin: 12pt 0;
|
||||
padding-left: 1.1em;
|
||||
border-left: 3px solid var(--accent);
|
||||
font-style: italic;
|
||||
color: #333;
|
||||
}
|
||||
.body hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--rule);
|
||||
margin: 16pt auto;
|
||||
width: 38%;
|
||||
}
|
||||
.body strong { font-weight: 600; }
|
||||
.body p.colophon {
|
||||
font-family: var(--sans);
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
text-align: left;
|
||||
text-indent: 0;
|
||||
}
|
||||
.body h2, .body h3 { break-after: avoid; }
|
||||
.body p.lede { break-after: avoid; }
|
||||
`;
|
||||
|
||||
function buildPrintHtml(meta, body) {
|
||||
const bodyHtml = markdownToHtml(body);
|
||||
const title = meta.title || "Kronikk";
|
||||
const metaLine = [meta.serie, meta.lesetid].filter(Boolean).join(" · ");
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="nb">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${esc(title)}</title>
|
||||
<style>${PRINT_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
${meta.kicker ? `<p class="kicker">${esc(meta.kicker)}</p>` : ""}
|
||||
<h1 class="title">${inline(title)}</h1>
|
||||
${meta.subtitle ? `<p class="subtitle">${inline(meta.subtitle)}</p>` : ""}
|
||||
<div class="byline-wrap">
|
||||
${meta.byline ? `<p class="byline">${esc(meta.byline)}</p>` : ""}
|
||||
${metaLine ? `<p class="meta">${esc(metaLine)}</p>` : ""}
|
||||
</div>
|
||||
<div class="body">
|
||||
${bodyHtml}
|
||||
</div>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length) {
|
||||
console.error("Bruk: node build-pdf.mjs <fil.md> [flere.md ...]");
|
||||
process.exit(1);
|
||||
}
|
||||
// Output følger serien (kjøres fra serie-mappa); fonts følger scriptet via __dirname.
|
||||
const pdfDir = path.join(process.cwd(), "pdf");
|
||||
const htmlDir = path.join(pdfDir, "_html");
|
||||
fs.mkdirSync(htmlDir, { recursive: true });
|
||||
|
||||
const wp = resolveWeasyprint();
|
||||
if (!wp.available) console.warn(wp.hint);
|
||||
|
||||
for (const arg of args) {
|
||||
const inPath = path.isAbsolute(arg) ? arg : path.join(process.cwd(), arg);
|
||||
if (!fs.existsSync(inPath)) {
|
||||
console.error(`Fant ikke: ${inPath}`);
|
||||
continue;
|
||||
}
|
||||
const raw = fs.readFileSync(inPath, "utf8");
|
||||
const { meta, body } = parseFrontMatter(raw);
|
||||
const base = path.basename(inPath).replace(/\.md$/i, "");
|
||||
const html = buildPrintHtml(meta, body);
|
||||
const htmlPath = path.join(htmlDir, base + ".html");
|
||||
const pdfPath = path.join(pdfDir, base + ".pdf");
|
||||
fs.writeFileSync(htmlPath, html, "utf8");
|
||||
if (wp.available) {
|
||||
execFileSync("weasyprint", [htmlPath, pdfPath], { stdio: ["ignore", "ignore", "inherit"] });
|
||||
const kb = (fs.statSync(pdfPath).size / 1024).toFixed(1);
|
||||
console.log(`PDF: ${pdfPath} (${kb} KB)`);
|
||||
} else {
|
||||
console.warn(` Hoppet over PDF (weasyprint mangler): ${pdfPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
BIN
render/fonts/Inter-400.ttf
Normal file
BIN
render/fonts/Inter-400.ttf
Normal file
Binary file not shown.
BIN
render/fonts/Inter-600.ttf
Normal file
BIN
render/fonts/Inter-600.ttf
Normal file
Binary file not shown.
BIN
render/fonts/Inter-700.ttf
Normal file
BIN
render/fonts/Inter-700.ttf
Normal file
Binary file not shown.
BIN
render/fonts/Newsreader-400.ttf
Normal file
BIN
render/fonts/Newsreader-400.ttf
Normal file
Binary file not shown.
BIN
render/fonts/Newsreader-400i.ttf
Normal file
BIN
render/fonts/Newsreader-400i.ttf
Normal file
Binary file not shown.
BIN
render/fonts/Newsreader-600.ttf
Normal file
BIN
render/fonts/Newsreader-600.ttf
Normal file
Binary file not shown.
BIN
render/fonts/Newsreader-600i.ttf
Normal file
BIN
render/fonts/Newsreader-600i.ttf
Normal file
Binary file not shown.
BIN
render/fonts/Newsreader-700.ttf
Normal file
BIN
render/fonts/Newsreader-700.ttf
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue