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 ? `` : 'Your next story starts here. Add a Markdown file to pages/.
'}${categories.map(c=>``).join('')}${articles.length} stories
${others.concat(lead || []).map(card).join('')}
No stories found. Try another search or category.
`);
+}
+export function articlePage(a, articles) {
+ return shell(a.title, `${escape(a.category)}
${escape(a.title)}
${escape(a.description)}
TN${escape(a.author)}${meta(a)}
${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();
diff --git a/search/settings.yml b/search/settings.yml
new file mode 100644
index 0000000..265cfc1
--- /dev/null
+++ b/search/settings.yml
@@ -0,0 +1,13 @@
+use_default_settings: true
+general:
+ instance_name: TrueNews source search
+search:
+ formats:
+ - html
+ - json
+server:
+ limiter: false
+ public_instance: false
+outgoing:
+ request_timeout: 8.0
+ max_request_timeout: 15.0
diff --git a/tests/browser/notifications.spec.mjs b/tests/browser/notifications.spec.mjs
new file mode 100644
index 0000000..e285bd6
--- /dev/null
+++ b/tests/browser/notifications.spec.mjs
@@ -0,0 +1,61 @@
+import {test,expect} from '@playwright/test';
+// Mock permission/subscription APIs, not the page UI or backend subscription API.
+// Browser vendor push enrollment requires real user/device consent and credentials.
+async function mockPush(page,{denied=false,failSave=false}={}){
+ await page.addInitScript(({denied})=>{
+ let current=null;
+ let permission=denied?'denied':'default';
+ const encode=bytes=>btoa(String.fromCharCode(...bytes)).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
+ const key=new Uint8Array(65);key[0]=4;key.fill(1,1);
+ const subscription={endpoint:'https://fcm.googleapis.com/fcm/send/browser-test',keys:{p256dh:encode(key),auth:encode(new Uint8Array(16).fill(2))}};
+ const manager={getSubscription:async()=>current,subscribe:async()=>{
+ current={toJSON:()=>subscription,unsubscribe:async()=>{current=null;return true;}};return current;
+ }};
+ const registration={pushManager:manager};
+ Object.defineProperty(Notification,'permission',{get:()=>permission});
+ Notification.requestPermission=async()=>{window.permissionRequests=(window.permissionRequests||0)+1;permission=denied?'denied':'granted';return permission;};
+ Object.defineProperty(navigator.serviceWorker,'register',{value:async()=>registration});
+ Object.defineProperty(navigator.serviceWorker,'ready',{value:Promise.resolve(registration)});
+ window.PushManager ||= function(){};
+ },{denied});
+ if(failSave)await page.route('**/api/push/subscribe',route=>route.fulfill({status:500,body:'{}',contentType:'application/json'}));
+}
+test('invitation is dismissible, fits mobile, and never asks permission on page load',async({page})=>{
+ await page.setViewportSize({width:390,height:844});await mockPush(page);await page.goto('/');
+ await expect(page.locator('#push-prompt')).toBeVisible();
+ expect(await page.evaluate(()=>window.permissionRequests||0)).toBe(0);
+ const box=await page.locator('#push-prompt').boundingBox();expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(390);
+ await page.getByRole('button',{name:'Not now'}).click();await expect(page.locator('#push-prompt')).toBeHidden();
+ await page.reload();await expect(page.locator('#push-toggle')).toBeVisible();await expect(page.locator('#push-prompt')).toBeHidden();
+ await page.locator('#push-toggle').click();await expect(page.locator('#push-prompt')).toBeVisible();
+});
+test('enable and disable save preferences through the real API',async({page})=>{
+ await mockPush(page);await page.goto('/');await page.getByRole('button',{name:'Enable notifications',exact:true}).click();
+ await expect(page.locator('#push-status')).toContainText('You’re subscribed');expect(await page.evaluate(()=>window.permissionRequests)).toBe(1);
+ await expect(page.locator('#push-toggle')).toHaveText('Notifications on');
+ await page.getByRole('button',{name:'Turn off notifications'}).click();await expect(page.locator('#push-status')).toContainText('turned off');
+});
+test('denied permission is not prompted again on load',async({page})=>{
+ await mockPush(page,{denied:true});await page.goto('/');await expect(page.locator('#push-toggle')).toBeVisible();
+ await expect(page.locator('#push-prompt')).toBeHidden();expect(await page.evaluate(()=>window.permissionRequests||0)).toBe(0);
+ await page.locator('#push-toggle').click();await page.getByRole('button',{name:'Enable notifications',exact:true}).click();
+ await expect(page.locator('#push-status')).toContainText('browser’s site settings');
+});
+test('failed subscription persistence never claims success',async({page})=>{
+ await mockPush(page,{failSave:true});await page.goto('/');await page.getByRole('button',{name:'Enable notifications',exact:true}).click();
+ await expect(page.locator('#push-status')).toContainText('Could not save');await expect(page.locator('#push-toggle')).toHaveText('Get new stories');
+});
+test('service worker and app manifest are served from the root',async({request})=>{
+ const worker=await request.get('/sw.js');expect(worker.status()).toBe(200);expect(worker.headers()['content-type']).toContain('javascript');expect(worker.headers()['cache-control']).toBe('no-cache');
+ const manifest=await request.get('/manifest.webmanifest');expect(manifest.status()).toBe(200);expect((await manifest.json()).display).toBe('standalone');
+ const privateData=await request.get('/.data/push/vapid.json');expect(privateData.status()).toBe(404);
+});
+test('a real service worker registers with site-wide scope',async({page})=>{
+ await page.goto('/');
+ const registration=await page.evaluate(async()=>{
+ const worker=await navigator.serviceWorker.ready;
+ return {scope:worker.scope,script:worker.active.scriptURL};
+ });
+ expect(new URL(registration.scope).pathname).toBe('/');
+ expect(new URL(registration.script).pathname).toBe('/sw.js');
+});
diff --git a/tests/generate.test.mjs b/tests/generate.test.mjs
new file mode 100644
index 0000000..8002b1c
--- /dev/null
+++ b/tests/generate.test.mjs
@@ -0,0 +1,130 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import matter from 'gray-matter';
+import {MODEL,extractSource,collectSources,discoverSources,validateDraft,writeDraft,saveArticle} from '../scripts/generate.mjs';
+import {readArticles,articlePage} from '../scripts/site.mjs';
+const source={id:1,title:'Original reporting',url:'https://example.org/report',text:'An original source. '.repeat(40),published:'2026-09-10',retrievedAt:'2026-09-12T12:00:00Z'};
+const draft={title:'An accurate summary',description:'A short introduction.',category:'Technology',paragraphs:Array.from({length:3},()=>({text:'A factual paragraph grounded in the supplied original source.',sources:[1]}))};
+test('extracts article content and removes navigation and scripts',()=>{
+ const s=extractSource(`