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 ? ' · ' : ''}${a.minutes} min read`; const visual = (a, cls = '') => `
${a.image ? `` : '
'}${escape(a.category)}
`; const shell = (title, body, description = 'Independent perspectives. Stories that matter.') => `${escape(title)} — TrueNews
truenews

Independent perspectives.
Stories that matter.

The daily perspective ↗
${body}`; function card(a) { return `

${escape(a.category)}

${escape(a.title)}

${escape(a.description)}

${meta(a)}

`; } export function home(articles) { const [lead, ...others] = articles; const categories = [...new Set(articles.map(a => a.category))]; return shell('A wider perspective', `

THE TRUENEWS JOURNAL

A world of stories.
A wider perspective.

Ideas, discoveries, and the people shaping what comes next.
A thoughtful take on a changing world.

${lead ? `
${visual(lead, 'hero-image')}

THE BIG PICTURE ${escape(lead.category)}

${escape(lead.title)}

${escape(lead.description)}

${meta(lead)}↗
` : '

Your next story starts here. Add a Markdown file to pages/.

'}

KEEP EXPLORING

The latest stories.

${categories.map(c=>``).join('')}${articles.length} stories
${others.concat(lead || []).map(card).join('')}

`); } export function articlePage(a, articles) { return shell(a.title, `
← Back to the front page${escape(a.category)}

${escape(a.category)}

${escape(a.title)}

${escape(a.description)}

${visual(a, 'article-image')}
${a.html}
`, 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', '

This story is still unwritten.

The page you’re looking for doesn’t exist.

Return to the front page →
')); 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();