72 lines
10 KiB
JavaScript
72 lines
10 KiB
JavaScript
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|