Files
TrueNews/scripts/site.mjs
T

72 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import matter from 'gray-matter';
import { marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
export const root = fileURLToPath(new URL('../', import.meta.url));
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
export function articleUrl(value) {
const url = '/' + String(value).replace(/^\/+|\/+$/g, '');
if (!/^\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+$/.test(url) || ['/assets', '/404', '/api'].some(p => url === p || url.startsWith(p + '/'))) throw new Error(`Invalid or reserved article URL: ${value}`);
return url + '/';
}
export async function readArticles(directory = path.join(root, 'pages')) {
const names = (await fs.readdir(directory)).filter(f => f.endsWith('.md'));
const articles = await Promise.all(names.map(async name => {
const {data, content} = matter(await fs.readFile(path.join(directory, name), 'utf8'));
const title = data.title || content.match(/^#\s+(.+)$/m)?.[1] || name.replace(/\.md$/, '').replace(/-/g, ' ');
const body = content.replace(/^#\s+.+\r?\n/, '');
const date = data.date ? new Date(data.date) : null;
if (date && Number.isNaN(date.getTime())) throw new Error(`Invalid date in ${name}`);
const plain = body.replace(/[#*_>`\[\]]/g, '').replace(/\s+/g, ' ').trim();
return {title: String(title), url: articleUrl(data.url || data.slug || name.replace(/\.md$/, '')), category: String(data.category || 'Stories'), description: String(data.description || plain.slice(0, 155)), author: String(data.author || 'TrueNews editorial'), date: date?.toISOString().slice(0,10) || '', image: typeof data.image === 'string' && /^(https?:\/\/|\/assets\/)/.test(data.image) ? data.image : '', minutes: Math.max(1, Math.ceil(plain.split(/\s+/).length / 220)), html: sanitizeHtml(marked.parse(body), {allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'h1', 'h2']), allowedAttributes: {...sanitizeHtml.defaults.allowedAttributes, img: ['src', 'alt', 'title', 'width', 'height']}})};
}));
const urls = new Set();
for (const a of articles) { if (urls.has(a.url)) throw new Error(`Duplicate article URL: ${a.url}`); urls.add(a.url); }
return articles.sort((a,b) => b.date.localeCompare(a.date) || a.title.localeCompare(b.title));
}
const dateLabel = date => date ? new Date(date).toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric', timeZone:'UTC'}) : '';
const meta = a => `${escape(dateLabel(a.date))}${a.date ? ' <span>·</span> ' : ''}${a.minutes} min read`;
const visual = (a, cls = '') => `<div class="visual ${cls}">${a.image ? `<img src="${escape(a.image)}" alt="" loading="${cls === 'hero-image' ? 'eager' : 'lazy'}">` : '<div class="abstract"><span></span><span></span><span></span></div>'}<span class="image-label">${escape(a.category)}</span></div>`;
const shell = (title, body, description = 'Independent perspectives. Stories that matter.') => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="${escape(description)}"><title>${escape(title)} — TrueNews</title><link rel="icon" href="/assets/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="/assets/style.css"><link rel="manifest" href="/manifest.webmanifest"><meta name="theme-color" content="#214d3e"><link rel="apple-touch-icon" href="/assets/icon-192.png"><script src="/assets/site.js" defer></script><script src="/assets/notifications.js" defer></script></head><body><a class="skip" href="#main">Skip to content</a><header><div class="masthead wrap"><a class="brand" href="/" aria-label="TrueNews home">true<span>news</span><i></i></a><p>Independent perspectives.<br>Stories that matter.</p><a class="edition" href="/#latest">The daily perspective <span>↗</span></a></div></header>${body}<footer class="wrap"><a class="brand" href="/">true<span>news</span><i></i></a><p>A little context. A wider perspective.</p><button id="push-toggle" class="push-toggle" type="button" hidden>Get new stories</button><span>Made for curious minds.</span></footer><section id="push-prompt" class="push-panel" aria-labelledby="push-heading" hidden><h2 id="push-heading">A story worth opening.</h2><p id="push-message">Get a browser notification when a new story is published. You can turn this off anytime.</p><div class="push-actions"><button id="push-enable" type="button">Enable notifications</button><button id="push-dismiss" type="button">Not now</button></div><p id="push-status" role="status"></p></section></body></html>`;
function card(a) { return `<article class="story" data-category="${escape(a.category)}" data-search="${escape((a.title+' '+a.description+' '+a.category).toLowerCase())}"><a href="${a.url}" class="card-image" tabindex="-1" aria-hidden="true">${visual(a)}</a><p class="eyebrow">${escape(a.category)}</p><h3><a href="${a.url}">${escape(a.title)}</a></h3><p class="description">${escape(a.description)}</p><p class="meta">${meta(a)}</p></article>`; }
export function home(articles) {
const [lead, ...others] = articles;
const categories = [...new Set(articles.map(a => a.category))];
return shell('A wider perspective', `<nav class="nav wrap" aria-label="Main navigation"><a class="active" href="/">The front page</a><a href="#latest">Latest stories</a><span class="nav-note"><b></b> Always curious. Always independent.</span></nav><main id="main" class="wrap"><section class="intro"><p class="eyebrow">THE TRUENEWS JOURNAL</p><h1>A world of stories.<br><em>A wider perspective.</em></h1><p>Ideas, discoveries, and the people shaping what comes next.<br>A thoughtful take on a changing world.</p></section>${lead ? `<section class="lead" aria-label="Featured story"><a href="${lead.url}" class="lead-visual" aria-label="${escape(lead.title)}">${visual(lead, 'hero-image')}</a><div class="lead-copy"><p class="eyebrow"><span class="dot"></span> THE BIG PICTURE <span class="tag">${escape(lead.category)}</span></p><h2><a href="${lead.url}">${escape(lead.title)}</a></h2><p>${escape(lead.description)}</p><div class="lead-bottom"><span class="meta">${meta(lead)}</span><a class="round-link" href="${lead.url}" aria-label="Read ${escape(lead.title)}">↗</a></div></div></section>` : '<p class="empty">Your next story starts here. Add a Markdown file to pages/.</p>'}<section id="latest" class="latest"><div class="section-top"><div><p class="eyebrow">KEEP EXPLORING</p><h2>The latest stories<span>.</span></h2></div><label class="search"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="Find a story…" aria-label="Search stories"><kbd>/</kbd></label></div><div class="filters" aria-label="Filter stories"><button class="selected" data-filter="all" aria-pressed="true">All stories</button>${categories.map(c=>`<button data-filter="${escape(c)}" aria-pressed="false">${escape(c)}</button>`).join('')}<span id="count">${articles.length} stories</span></div><div class="grid">${others.concat(lead || []).map(card).join('')}</div><p id="no-results" class="empty" hidden>No stories found. Try another search or category.</p><p id="results-status" class="sr-only" role="status"></p></section><aside class="manifesto"><span class="asterisk">✳</span><div><p class="eyebrow">A MOMENT OF PERSPECTIVE</p><h2>Stay curious.<br>There’s always more to the story.</h2></div><p>Good stories open doors.<br>Take your time. Explore a little.<br>See the world a little differently.</p></aside></main>`);
}
export function articlePage(a, articles) {
return shell(a.title, `<main id="main" class="wrap"><div class="article-nav"><a href="/">← Back to the front page</a><span>${escape(a.category)}</span></div><article><div class="article-header"><p class="eyebrow">${escape(a.category)}</p><h1>${escape(a.title)}</h1><p class="dek">${escape(a.description)}</p><div class="byline"><span class="avatar">TN</span><div><strong>${escape(a.author)}</strong><p class="meta">${meta(a)}</p></div></div></div>${visual(a, 'article-image')}<div class="prose">${a.html}</div></article><section class="related"><p class="eyebrow">KEEP YOUR CURIOSITY GOING</p><h2>More to discover</h2><div class="grid">${articles.filter(b=>b.url !== a.url).slice(0,3).map(card).join('')}</div></section></main>`, a.description);
}
export async function build() {
// The generator and development watcher may request a build at the same time.
const lock = path.join(root, '.truenews-build.lock');
const deadline = Date.now() + 30000;
while (true) {
try { await fs.mkdir(lock); break; }
catch (error) {
if (error.code !== 'EEXIST') throw error;
if (Date.now() > deadline) throw new Error('Build lock timed out. If no build is running, remove .truenews-build.lock and retry.');
await new Promise(resolve => setTimeout(resolve, 100));
}
}
try { return await buildSite(); }
finally { await fs.rmdir(lock); }
}
async function buildSite() {
const articles = await readArticles();
const output = path.join(root, 'dist');
await fs.rm(output, {recursive:true, force:true});
await fs.mkdir(output, {recursive:true});
await fs.cp(path.join(root,'public'), path.join(output,'assets'), {recursive:true});
await fs.copyFile(path.join(root,'public/sw.js'),path.join(output,'sw.js'));
await fs.copyFile(path.join(root,'public/manifest.webmanifest'),path.join(output,'manifest.webmanifest'));
await fs.writeFile(path.join(output,'index.html'), home(articles));
await fs.writeFile(path.join(output,'404.html'), shell('Page not found', '<main id="main" class="wrap empty"><h1>This story is still unwritten.</h1><p>The page you’re looking for doesn’t exist.</p><a href="/">Return to the front page →</a></main>'));
for (const a of articles) { const dir = path.join(output, a.url); await fs.mkdir(dir,{recursive:true}); await fs.writeFile(path.join(dir,'index.html'), articlePage(a,articles)); }
console.log(`Built ${articles.length} articles in dist/`);
return articles;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) await build();