openvibers/openvibe.news Commits
News: the wire — what the sources report, grouped, on the front page and at /wire (labelled, never News' writing) (#49)
Changes
@@@ -24,7 +24,7 @@ When a source changes or is withdrawn upstream, the story is flagged and a pendiprepared; published text never changes silently. Full article text from sources is never stored,shown, fed or indexed.--News publishes nothing until an editor does: the only seed is the topic list.++News publishes nothing until an editor does: the only seed is the topic list. Until then, and beside the stories after, **the wire** (`/wire`, and "From the wire" on the front page) shows what the sources report, grouped by cluster: each line is the outlet's own headline, outlet, date and link (the fields the licensing rules let News keep), never News' writing; duplicates and reports removed upstream leave it; `/wire` is `noindex, follow`.## Owns@@@ -66,3 +66,8 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }.ov-diff { white-space: pre-wrap; }@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }@media (max-width: 600px) { h1 { font-size: 1.7rem; } body { font-size: 16px; } table { font-size: .85rem; } }++.wire { margin-top: 32px; border-top: 1px solid var(--border, #1f2d47); }++.wire-list, .wire-items { list-style: none; padding: 0; }++.wire-cluster { padding: 10px 0; border-bottom: 1px solid var(--border, #1f2d47); overflow-wrap: anywhere; }++.wire-lead { margin: 0; font-weight: 600; }++.wire a { color: var(--text-primary); }@@@ -174,6 +174,24 @@ function createClusters({ store, config, outbox }) {audit: async (id) => (await q.auditFor.all(id, id)).map(shapeAudit),auditEntry: async (id) => shapeAudit(await q.audit.get(String(id || ''))),++ /**++ * The public wire: open clusters, newest activity first, each with its active items (no duplicates, nothing++ * removed upstream) as the sources state them: headline, outlet, date and link. Clusters left with no active++ * item are skipped. Only the fields the licensing rules let News store are read.++ */++ async wire({ limit = 30 } = {}) {++ const out = [];++ for (const c of await api.recent({ limit: limit * 2 })) {++ const items = (await q.members.all(c.id)).filter((i) => i.status === 'active' && !i.removed_at && i.canonical_url)++ .map((i) => ({ id: i.id, headline: i.headline, url: i.canonical_url, outlet: i.outlet, published_at: i.published_at }));++ if (!items.length) continue;++ items.sort((a, b) => String(b.published_at || '').localeCompare(String(a.published_at || '')));++ out.push({ id: c.id, label: c.label, updated: c.window_end, items });++ if (out.length >= limit) break;++ }++ return out;++ },++/** Open clusters, newest activity first (the editor dashboard). */async recent({ limit = 50 } = {}) {return await db.prepare("SELECT * FROM news_story_clusters WHERE status = 'open' ORDER BY window_end DESC, id DESC LIMIT ?").all(limit);@@@ -5,6 +5,8 @@** GET / published stories, newest first (retracted ones stay, labelled)* GET /topics, /topics/:slug topics and each topic's stories++ * GET /wire the wire: what the sources report, grouped, as the outlets' own headlines and links++ * (the front page shows its latest clusters); labelled, noindex, follow* GET /stories/:slug a story (…/:slug.json: the same story as data); ?group=outlet|perspective* lets the reader choose how the source table is framed* POST /stories/:slug/comments comment (the story's Community thread) as the signed-in member@@@ -34,7 +36,7 @@ const { SITE_SUMMARY } = require('./discovery');const PER_PAGE = 20;function createPublicRoutes(ctx) {-- const { config, store, stories, publication, reading, topics, viewers, community, discussion } = ctx;++ const { config, store, stories, publication, reading, topics, viewers, community, discussion, clusters } = ctx;const router = express.Router();router.use(viewers.middleware({ services: false }));@@@ -95,6 +97,8 @@ function createPublicRoutes(ctx) {const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });if (pager.outOfRange && total) return notFound(req, res);const items = await reading.listItems(rows);++ // The front page's wire (render/pages.js): with clusters to show, the page is not empty even before a story.++ const wire = !topic && page === 1 ? await clusters.wire({ limit: 8 }) : [];const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });const feeds = feedsFor(path);const crumbs = topic ? [{ name: 'OpenVibe.News', url: '/' }, { name: 'Topics', url: '/topics' }, { name: topic.name }] : null;@@@ -102,7 +106,7 @@ function createPublicRoutes(ctx) {title: topic ? topic.name : null,description: topic ? (topic.description || `Stories on ${topic.name}.`) : 'Source-backed stories: every paragraph cites the reports it rests on.',summary: topic ? undefined : SITE_SUMMARY,-- decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),++ decision: pageDecision(canonical, { empty: total === 0 && !wire.length, query: ['page'] }),canonical, feeds,prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,jsonLd: topic@@@ -111,7 +115,7 @@ function createPublicRoutes(ctx) {// The front page opens with what OpenVibe.News is for (openvibe-shared/showcase), then the stories....(!topic && pager.page === 1 ? { styles: [showcase.STYLESHEET] } : {}),body: topic ? pages.topicPage({ topic, items, pager, feeds, breadcrumbs: crumbs })-- : (pager.page === 1 ? newsShowcase() : '') + pages.home({ items, pager, topics: await topics.list(), feeds, showcase: pager.page === 1 }),++ : (pager.page === 1 ? newsShowcase() : '') + pages.home({ items, pager, topics: await topics.list(), feeds, showcase: pager.page === 1, wire: pages.wireSection(wire) }),}, { cacheable: true });}@@@ -122,6 +126,15 @@ function createPublicRoutes(ctx) {body: frame.updatesBody({ service: 'news', siteName: 'OpenVibe.News' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,}, { cacheable: true }));router.get('/', wrap(async (req, res) => await listing(req, res, {})));++ // The wire (render/pages.js): what the sources report, grouped, with the outlets' own headlines and links. Not++ // indexed: it is other publishers' headlines, not News' writing; its links are followed.++ router.get('/wire', wrap(async (req, res) => {++ const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(config.baseUrl, '/wire'), wordCount: 0, noindex: true }, { policy: { minWords: 0 }, now: store.now() });++ send(req, res, 200, {++ title: 'The wire', description: 'What the sources OpenVibe.News follows report now, grouped, before an editor writes a story.',++ decision, canonical: `${config.baseUrl}/wire`, body: pages.wirePage({ clusters: await clusters.wire({ limit: 40 }) }),++ }, { cacheable: true });++ }));router.get('/topics', wrap(async (req, res) => {send(req, res, 200, { title: 'Topics', description: 'Topics on OpenVibe.News.', decision: pageDecision('/topics'), canonical: publication.abs('/topics'), body: pages.topicsIndex({ topics: await topics.list() }) }, { cacheable: true });@@@ -21,6 +21,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');const NAV_LINKS = [{ label: 'News', href: '/' },{ label: 'Topics', href: '/topics' },++ { label: 'Wire', href: '/wire' },];const hashes = new Map();@@@ -37,7 +37,7 @@ function feedLinksHtml(feeds) {// With the showcase above it (page 1), the showcase hero is the page's h1 and says what the site is,// so the list header is a plain "Latest stories" h2; later pages keep the site's own h1 and lede.--function home({ items, pager, topics, feeds, showcase = false }) {++function home({ items, pager, topics, feeds, showcase = false, wire = '' }) {return String(h`${showcase ? h`<header class="site-header"><h2>Latest stories</h2>${raw(feedLinksHtml(feeds))}</header>` : h`<header class="site-header">@@@ -45,11 +45,43 @@ ${raw(feedLinksHtml(feeds))}<p class="lede">Source-backed stories. Every paragraph cites the reports it rests on; corrections and retractions stay on the record.</p>${raw(feedLinksHtml(feeds))}</header>`}--<section id="stories" aria-label="Stories">${storyList(items, 'No stories have been published yet. OpenVibe.News publishes only what an editor has written from its sources.')}</section>++<section id="stories" aria-label="Stories">${storyList(items, wire ? 'No stories have been published yet: editors write them from the sources, and every paragraph cites them. Until then, the wire below shows what the sources report.' : 'No stories have been published yet. OpenVibe.News publishes only what an editor has written from its sources.')}</section>${raw(ssr.paginationHtml(pager))}++${raw(wire)}${topics.length ? h`<section class="side" aria-label="Topics"><h2>Topics</h2><ul class="topic-list">${topics.map((t) => h`<li><a href="/topics/${t.slug}">${t.name}</a></li>`)}</ul></section>` : ''}`);}++// The wire: what the sources report, grouped, before (or without) an editor's story. Each line is the outlet's own++// headline and link; nothing here is written by News.++const WIRE_NOTE = 'Grouped automatically from the reports OpenVibe.Sources collects. Nothing here is written by OpenVibe.News: each line is the outlet\'s own headline and links to its report.';++++function wireCluster(c, { brief = false } = {}) {++ const items = brief ? c.items.slice(0, 1) : c.items;++ const outlets = [...new Set(c.items.map((i) => i.outlet).filter(Boolean))];++ return h`<li class="wire-cluster" id="wire-${c.id}">++${brief ? h`<p class="wire-lead">${wireLink(items[0])}</p><p class="meta">${time(items[0].published_at)} · ${c.items.length} ${c.items.length === 1 ? 'report' : 'reports'}${outlets.length ? h` (${outlets.slice(0, 4).join(', ')}${outlets.length > 4 ? ` and ${outlets.length - 4} more` : ''})` : ''}</p>`++ : h`<ul class="wire-items">${items.map((i) => h`<li>${wireLink(i)} <span class="meta">${i.outlet ? h`${i.outlet} · ` : ''}${time(i.published_at)}</span></li>`)}</ul>`}++</li>`;++}++function wireLink(i) {++ const href = httpHref(i.url);++ return href ? h`<a href="${href}" rel="nofollow noopener" target="_blank">${i.headline}</a>` : h`${i.headline}`;++}++++/** The front page's wire section: the latest clusters, one line each. */++function wireSection(clusters) {++ if (!clusters.length) return '';++ return String(h`<section class="wire" id="wire" aria-labelledby="wire-h"><h2 id="wire-h">From the wire</h2>++<p class="meta">${WIRE_NOTE}</p>++<ol class="wire-list">${clusters.map((c) => wireCluster(c, { brief: true }))}</ol>++<p><a href="/wire">The whole wire</a></p></section>`);++}++++function wirePage({ clusters }) {++ return String(h`<header class="site-header"><h1>The wire</h1><p class="lede">${WIRE_NOTE} Stories, which editors write with every paragraph cited, are on <a href="/">the front page</a>.</p></header>++${clusters.length ? h`<ol class="wire-list wide">${clusters.map((c) => wireCluster(c))}</ol>` : h`<p class="empty">The sources have not reported anything new yet.</p>`}`);++}++function topicsIndex({ topics }) {return String(h`<header class="site-header"><h1>Topics</h1></header><ul class="topic-list wide">${topics.map((t) => h`<li><a href="/topics/${t.slug}">${t.name}</a>${t.description ? h` <span class="meta">— ${t.description}</span>` : ''}</li>`)}</ul>`);@@@ -159,4 +191,4 @@ function message({ heading, text, action }) {return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);}--module.exports = { home, topicsIndex, topicPage, storyPage, commentsSection, message, storyList, time, dateLabel, cites, httpHref };++module.exports = { home, topicsIndex, topicPage, storyPage, commentsSection, message, storyList, time, dateLabel, cites, httpHref, wireSection, wirePage };nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++/**++ * The wire (GET /wire, and "From the wire" on the front page): what the sources report, grouped by cluster, with each++ * outlet's own headline and link, labelled as not written by News. A report removed upstream leaves it; a javascript:++ * link never becomes one; the page is not indexed but its links are followed; the front page still says no story is++ * published, and points at the wire.++ */++const assert = require('assert');++const { boot, check, done, launchReports } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const reports = launchReports(t.sources);++ const [A, B, C] = ['https://wire-a.example/space/europa-clipper-launch', 'https://paper-b.example/2026/09/22/europa-clipper', 'https://site-c.example/europa-clipper-why'];++ t.sources.addSource('odd', { name: 'Odd' });++ t.sources.addItem({ source_key: 'odd', title: 'A link that is not a web address', url: 'javascript:alert(1)', published_at: '2026-09-22T11:00:00Z' });++ t.sources.addItem({ source_key: 'site-c', title: 'Local bakery wins regional sourdough prize', url: 'https://site-c.example/bakery', published_at: '2026-09-22T10:40:00Z' });++ await t.pull();++++ try {++ await check('the wire lists every cluster with the outlets’ own headlines and links, labelled, not indexed', async () => {++ const r = await t.get('/wire');++ assert.strictEqual(r.status, 200);++ [A, B, C].forEach((u) => assert.ok(r.text.includes(u), u));++ assert.ok(r.text.includes('Local bakery wins regional sourdough prize'));++ assert.ok(r.text.includes('Nothing here is written by OpenVibe.News'));++ assert.ok(!r.text.includes('href="javascript:'), 'never a javascript: link');++ assert.match(r.headers.get('x-robots-tag') || '', /noindex, follow/);++ assert.match(r.headers.get('cache-control') || '', /public/);++ });++++ await check('the front page keeps "no stories yet", and shows the latest clusters from the wire', async () => {++ const r = await t.get('/');++ assert.ok(r.text.includes('No stories have been published yet'));++ assert.ok(r.text.includes('From the wire') && r.text.includes('href="/wire"'));++ assert.ok(r.text.includes('Europa Clipper'));++ assert.ok(!/noindex/.test(r.headers.get('x-robots-tag') || ''), 'with the wire, the front page is not empty: it is indexed');++ });++++ await check('a report removed upstream leaves the wire', async () => {++ t.sources.removeItem(reports.b.id, 'licence review');++ await t.pull();++ const r = await t.get('/wire');++ assert.ok(!r.text.includes(B));++ assert.ok(r.text.includes(A));++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -24,7 +24,7 @@ When a source changes or is withdrawn upstream, the story is flagged and a pendi
@@ -24,7 +24,7 @@ When a source changes or is withdrawn upstream, the story is flagged and a pendi
prepared; published text never changes silently. Full article text from sources is never stored,
prepared; published text never changes silently. Full article text from sources is never stored,
shown, fed or indexed.
shown, fed or indexed.
-News publishes nothing until an editor does: the only seed is the topic list.
+News publishes nothing until an editor does: the only seed is the topic list. Until then, and beside the stories after, **the wire** (`/wire`, and "From the wire" on the front page) shows what the sources report, grouped by cluster: each line is the outlet's own headline, outlet, date and link (the fields the licensing rules let News keep), never News' writing; duplicates and reports removed upstream leave it; `/wire` is `noindex, follow`.
## Owns
## Owns
@@ -66,3 +66,8 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
@@ -66,3 +66,8 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
.ov-diff { white-space: pre-wrap; }
.ov-diff { white-space: pre-wrap; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
@media (max-width: 600px) { h1 { font-size: 1.7rem; } body { font-size: 16px; } table { font-size: .85rem; } }
@media (max-width: 600px) { h1 { font-size: 1.7rem; } body { font-size: 16px; } table { font-size: .85rem; } }
+.wire { margin-top: 32px; border-top: 1px solid var(--border, #1f2d47); }
+.wire-list, .wire-items { list-style: none; padding: 0; }
+.wire-cluster { padding: 10px 0; border-bottom: 1px solid var(--border, #1f2d47); overflow-wrap: anywhere; }
+.wire-lead { margin: 0; font-weight: 600; }
+.wire a { color: var(--text-primary); }
@@ -174,6 +174,24 @@ function createClusters({ store, config, outbox }) {
@@ -174,6 +174,24 @@ function createClusters({ store, config, outbox }) {
audit: async (id) => (await q.auditFor.all(id, id)).map(shapeAudit),
audit: async (id) => (await q.auditFor.all(id, id)).map(shapeAudit),
auditEntry: async (id) => shapeAudit(await q.audit.get(String(id || ''))),
auditEntry: async (id) => shapeAudit(await q.audit.get(String(id || ''))),
+ /**
+ * The public wire: open clusters, newest activity first, each with its active items (no duplicates, nothing
+ * removed upstream) as the sources state them: headline, outlet, date and link. Clusters left with no active
+ * item are skipped. Only the fields the licensing rules let News store are read.
+ */
+ async wire({ limit = 30 } = {}) {
+ const out = [];
+ for (const c of await api.recent({ limit: limit * 2 })) {
+ const items = (await q.members.all(c.id)).filter((i) => i.status === 'active' && !i.removed_at && i.canonical_url)
+ .map((i) => ({ id: i.id, headline: i.headline, url: i.canonical_url, outlet: i.outlet, published_at: i.published_at }));
+ if (!items.length) continue;
+ items.sort((a, b) => String(b.published_at || '').localeCompare(String(a.published_at || '')));
+ out.push({ id: c.id, label: c.label, updated: c.window_end, items });
+ if (out.length >= limit) break;
+ }
+ return out;
+ },
+
/** Open clusters, newest activity first (the editor dashboard). */
/** Open clusters, newest activity first (the editor dashboard). */
async recent({ limit = 50 } = {}) {
async recent({ limit = 50 } = {}) {
return await db.prepare("SELECT * FROM news_story_clusters WHERE status = 'open' ORDER BY window_end DESC, id DESC LIMIT ?").all(limit);
return await db.prepare("SELECT * FROM news_story_clusters WHERE status = 'open' ORDER BY window_end DESC, id DESC LIMIT ?").all(limit);
@@ -5,6 +5,8 @@
@@ -5,6 +5,8 @@
*
*
* GET / published stories, newest first (retracted ones stay, labelled)
* GET / published stories, newest first (retracted ones stay, labelled)
* GET /topics, /topics/:slug topics and each topic's stories
* GET /topics, /topics/:slug topics and each topic's stories
+ * GET /wire the wire: what the sources report, grouped, as the outlets' own headlines and links
+ * (the front page shows its latest clusters); labelled, noindex, follow
* GET /stories/:slug a story (…/:slug.json: the same story as data); ?group=outlet|perspective
* GET /stories/:slug a story (…/:slug.json: the same story as data); ?group=outlet|perspective
* lets the reader choose how the source table is framed
* lets the reader choose how the source table is framed
* POST /stories/:slug/comments comment (the story's Community thread) as the signed-in member
* POST /stories/:slug/comments comment (the story's Community thread) as the signed-in member
@@ -34,7 +36,7 @@ const { SITE_SUMMARY } = require('./discovery');
@@ -34,7 +36,7 @@ const { SITE_SUMMARY } = require('./discovery');
const PER_PAGE = 20;
const PER_PAGE = 20;
function createPublicRoutes(ctx) {
function createPublicRoutes(ctx) {
- const { config, store, stories, publication, reading, topics, viewers, community, discussion } = ctx;
+ const { config, store, stories, publication, reading, topics, viewers, community, discussion, clusters } = ctx;
const router = express.Router();
const router = express.Router();
router.use(viewers.middleware({ services: false }));
router.use(viewers.middleware({ services: false }));
@@ -95,6 +97,8 @@ function createPublicRoutes(ctx) {
@@ -95,6 +97,8 @@ function createPublicRoutes(ctx) {
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
if (pager.outOfRange && total) return notFound(req, res);
if (pager.outOfRange && total) return notFound(req, res);
const items = await reading.listItems(rows);
const items = await reading.listItems(rows);
+ // The front page's wire (render/pages.js): with clusters to show, the page is not empty even before a story.
+ const wire = !topic && page === 1 ? await clusters.wire({ limit: 8 }) : [];
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const feeds = feedsFor(path);
const feeds = feedsFor(path);
const crumbs = topic ? [{ name: 'OpenVibe.News', url: '/' }, { name: 'Topics', url: '/topics' }, { name: topic.name }] : null;
const crumbs = topic ? [{ name: 'OpenVibe.News', url: '/' }, { name: 'Topics', url: '/topics' }, { name: topic.name }] : null;
@@ -102,7 +106,7 @@ function createPublicRoutes(ctx) {
@@ -102,7 +106,7 @@ function createPublicRoutes(ctx) {
title: topic ? topic.name : null,
title: topic ? topic.name : null,
description: topic ? (topic.description || `Stories on ${topic.name}.`) : 'Source-backed stories: every paragraph cites the reports it rests on.',
description: topic ? (topic.description || `Stories on ${topic.name}.`) : 'Source-backed stories: every paragraph cites the reports it rests on.',
summary: topic ? undefined : SITE_SUMMARY,
summary: topic ? undefined : SITE_SUMMARY,
- decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),
+ decision: pageDecision(canonical, { empty: total === 0 && !wire.length, query: ['page'] }),
canonical, feeds,
canonical, feeds,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
jsonLd: topic
jsonLd: topic
@@ -111,7 +115,7 @@ function createPublicRoutes(ctx) {
@@ -111,7 +115,7 @@ function createPublicRoutes(ctx) {
// The front page opens with what OpenVibe.News is for (openvibe-shared/showcase), then the stories.
// The front page opens with what OpenVibe.News is for (openvibe-shared/showcase), then the stories.
...(!topic && pager.page === 1 ? { styles: [showcase.STYLESHEET] } : {}),
...(!topic && pager.page === 1 ? { styles: [showcase.STYLESHEET] } : {}),
body: topic ? pages.topicPage({ topic, items, pager, feeds, breadcrumbs: crumbs })
body: topic ? pages.topicPage({ topic, items, pager, feeds, breadcrumbs: crumbs })
- : (pager.page === 1 ? newsShowcase() : '') + pages.home({ items, pager, topics: await topics.list(), feeds, showcase: pager.page === 1 }),
+ : (pager.page === 1 ? newsShowcase() : '') + pages.home({ items, pager, topics: await topics.list(), feeds, showcase: pager.page === 1, wire: pages.wireSection(wire) }),
}, { cacheable: true });
}, { cacheable: true });
}
}
@@ -122,6 +126,15 @@ function createPublicRoutes(ctx) {
@@ -122,6 +126,15 @@ function createPublicRoutes(ctx) {
body: frame.updatesBody({ service: 'news', siteName: 'OpenVibe.News' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
body: frame.updatesBody({ service: 'news', siteName: 'OpenVibe.News' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}, { cacheable: true }));
}, { cacheable: true }));
router.get('/', wrap(async (req, res) => await listing(req, res, {})));
router.get('/', wrap(async (req, res) => await listing(req, res, {})));
+ // The wire (render/pages.js): what the sources report, grouped, with the outlets' own headlines and links. Not
+ // indexed: it is other publishers' headlines, not News' writing; its links are followed.
+ router.get('/wire', wrap(async (req, res) => {
+ const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(config.baseUrl, '/wire'), wordCount: 0, noindex: true }, { policy: { minWords: 0 }, now: store.now() });
+ send(req, res, 200, {
+ title: 'The wire', description: 'What the sources OpenVibe.News follows report now, grouped, before an editor writes a story.',
+ decision, canonical: `${config.baseUrl}/wire`, body: pages.wirePage({ clusters: await clusters.wire({ limit: 40 }) }),
+ }, { cacheable: true });
+ }));
router.get('/topics', wrap(async (req, res) => {
router.get('/topics', wrap(async (req, res) => {
send(req, res, 200, { title: 'Topics', description: 'Topics on OpenVibe.News.', decision: pageDecision('/topics'), canonical: publication.abs('/topics'), body: pages.topicsIndex({ topics: await topics.list() }) }, { cacheable: true });
send(req, res, 200, { title: 'Topics', description: 'Topics on OpenVibe.News.', decision: pageDecision('/topics'), canonical: publication.abs('/topics'), body: pages.topicsIndex({ topics: await topics.list() }) }, { cacheable: true });
@@ -21,6 +21,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -21,6 +21,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV_LINKS = [
const NAV_LINKS = [
{ label: 'News', href: '/' },
{ label: 'News', href: '/' },
{ label: 'Topics', href: '/topics' },
{ label: 'Topics', href: '/topics' },
+ { label: 'Wire', href: '/wire' },
];
];
const hashes = new Map();
const hashes = new Map();
@@ -37,7 +37,7 @@ function feedLinksHtml(feeds) {
@@ -37,7 +37,7 @@ function feedLinksHtml(feeds) {
// With the showcase above it (page 1), the showcase hero is the page's h1 and says what the site is,
// With the showcase above it (page 1), the showcase hero is the page's h1 and says what the site is,
// so the list header is a plain "Latest stories" h2; later pages keep the site's own h1 and lede.
// so the list header is a plain "Latest stories" h2; later pages keep the site's own h1 and lede.
-function home({ items, pager, topics, feeds, showcase = false }) {
+function home({ items, pager, topics, feeds, showcase = false, wire = '' }) {
return String(h`${showcase ? h`<header class="site-header"><h2>Latest stories</h2>
return String(h`${showcase ? h`<header class="site-header"><h2>Latest stories</h2>
${raw(feedLinksHtml(feeds))}
${raw(feedLinksHtml(feeds))}
</header>` : h`<header class="site-header">
</header>` : h`<header class="site-header">
@@ -45,11 +45,43 @@ ${raw(feedLinksHtml(feeds))}
@@ -45,11 +45,43 @@ ${raw(feedLinksHtml(feeds))}
<p class="lede">Source-backed stories. Every paragraph cites the reports it rests on; corrections and retractions stay on the record.</p>
<p class="lede">Source-backed stories. Every paragraph cites the reports it rests on; corrections and retractions stay on the record.</p>
${raw(feedLinksHtml(feeds))}
${raw(feedLinksHtml(feeds))}
</header>`}
</header>`}
-<section id="stories" aria-label="Stories">${storyList(items, 'No stories have been published yet. OpenVibe.News publishes only what an editor has written from its sources.')}</section>
+<section id="stories" aria-label="Stories">${storyList(items, wire ? 'No stories have been published yet: editors write them from the sources, and every paragraph cites them. Until then, the wire below shows what the sources report.' : 'No stories have been published yet. OpenVibe.News publishes only what an editor has written from its sources.')}</section>
${raw(ssr.paginationHtml(pager))}
${raw(ssr.paginationHtml(pager))}
+${raw(wire)}
${topics.length ? h`<section class="side" aria-label="Topics"><h2>Topics</h2><ul class="topic-list">${topics.map((t) => h`<li><a href="/topics/${t.slug}">${t.name}</a></li>`)}</ul></section>` : ''}`);
${topics.length ? h`<section class="side" aria-label="Topics"><h2>Topics</h2><ul class="topic-list">${topics.map((t) => h`<li><a href="/topics/${t.slug}">${t.name}</a></li>`)}</ul></section>` : ''}`);
}
}
+// The wire: what the sources report, grouped, before (or without) an editor's story. Each line is the outlet's own
+// headline and link; nothing here is written by News.
+const WIRE_NOTE = 'Grouped automatically from the reports OpenVibe.Sources collects. Nothing here is written by OpenVibe.News: each line is the outlet\'s own headline and links to its report.';
+
+function wireCluster(c, { brief = false } = {}) {
+ const items = brief ? c.items.slice(0, 1) : c.items;
+ const outlets = [...new Set(c.items.map((i) => i.outlet).filter(Boolean))];
+ return h`<li class="wire-cluster" id="wire-${c.id}">
+${brief ? h`<p class="wire-lead">${wireLink(items[0])}</p><p class="meta">${time(items[0].published_at)} · ${c.items.length} ${c.items.length === 1 ? 'report' : 'reports'}${outlets.length ? h` (${outlets.slice(0, 4).join(', ')}${outlets.length > 4 ? ` and ${outlets.length - 4} more` : ''})` : ''}</p>`
+ : h`<ul class="wire-items">${items.map((i) => h`<li>${wireLink(i)} <span class="meta">${i.outlet ? h`${i.outlet} · ` : ''}${time(i.published_at)}</span></li>`)}</ul>`}
+</li>`;
+}
+function wireLink(i) {
+ const href = httpHref(i.url);
+ return href ? h`<a href="${href}" rel="nofollow noopener" target="_blank">${i.headline}</a>` : h`${i.headline}`;
+}
+
+/** The front page's wire section: the latest clusters, one line each. */
+function wireSection(clusters) {
+ if (!clusters.length) return '';
+ return String(h`<section class="wire" id="wire" aria-labelledby="wire-h"><h2 id="wire-h">From the wire</h2>
+<p class="meta">${WIRE_NOTE}</p>
+<ol class="wire-list">${clusters.map((c) => wireCluster(c, { brief: true }))}</ol>
+<p><a href="/wire">The whole wire</a></p></section>`);
+}
+
+function wirePage({ clusters }) {
+ return String(h`<header class="site-header"><h1>The wire</h1><p class="lede">${WIRE_NOTE} Stories, which editors write with every paragraph cited, are on <a href="/">the front page</a>.</p></header>
+${clusters.length ? h`<ol class="wire-list wide">${clusters.map((c) => wireCluster(c))}</ol>` : h`<p class="empty">The sources have not reported anything new yet.</p>`}`);
+}
+
function topicsIndex({ topics }) {
function topicsIndex({ topics }) {
return String(h`<header class="site-header"><h1>Topics</h1></header>
return String(h`<header class="site-header"><h1>Topics</h1></header>
<ul class="topic-list wide">${topics.map((t) => h`<li><a href="/topics/${t.slug}">${t.name}</a>${t.description ? h` <span class="meta">— ${t.description}</span>` : ''}</li>`)}</ul>`);
<ul class="topic-list wide">${topics.map((t) => h`<li><a href="/topics/${t.slug}">${t.name}</a>${t.description ? h` <span class="meta">— ${t.description}</span>` : ''}</li>`)}</ul>`);
@@ -159,4 +191,4 @@ function message({ heading, text, action }) {
@@ -159,4 +191,4 @@ function message({ heading, text, action }) {
return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);
return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);
}
}
-module.exports = { home, topicsIndex, topicPage, storyPage, commentsSection, message, storyList, time, dateLabel, cites, httpHref };
+module.exports = { home, topicsIndex, topicPage, storyPage, commentsSection, message, storyList, time, dateLabel, cites, httpHref, wireSection, wirePage };
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+/**
+ * The wire (GET /wire, and "From the wire" on the front page): what the sources report, grouped by cluster, with each
+ * outlet's own headline and link, labelled as not written by News. A report removed upstream leaves it; a javascript:
+ * link never becomes one; the page is not indexed but its links are followed; the front page still says no story is
+ * published, and points at the wire.
+ */
+const assert = require('assert');
+const { boot, check, done, launchReports } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const reports = launchReports(t.sources);
+ const [A, B, C] = ['https://wire-a.example/space/europa-clipper-launch', 'https://paper-b.example/2026/09/22/europa-clipper', 'https://site-c.example/europa-clipper-why'];
+ t.sources.addSource('odd', { name: 'Odd' });
+ t.sources.addItem({ source_key: 'odd', title: 'A link that is not a web address', url: 'javascript:alert(1)', published_at: '2026-09-22T11:00:00Z' });
+ t.sources.addItem({ source_key: 'site-c', title: 'Local bakery wins regional sourdough prize', url: 'https://site-c.example/bakery', published_at: '2026-09-22T10:40:00Z' });
+ await t.pull();
+
+ try {
+ await check('the wire lists every cluster with the outlets’ own headlines and links, labelled, not indexed', async () => {
+ const r = await t.get('/wire');
+ assert.strictEqual(r.status, 200);
+ [A, B, C].forEach((u) => assert.ok(r.text.includes(u), u));
+ assert.ok(r.text.includes('Local bakery wins regional sourdough prize'));
+ assert.ok(r.text.includes('Nothing here is written by OpenVibe.News'));
+ assert.ok(!r.text.includes('href="javascript:'), 'never a javascript: link');
+ assert.match(r.headers.get('x-robots-tag') || '', /noindex, follow/);
+ assert.match(r.headers.get('cache-control') || '', /public/);
+ });
+
+ await check('the front page keeps "no stories yet", and shows the latest clusters from the wire', async () => {
+ const r = await t.get('/');
+ assert.ok(r.text.includes('No stories have been published yet'));
+ assert.ok(r.text.includes('From the wire') && r.text.includes('href="/wire"'));
+ assert.ok(r.text.includes('Europa Clipper'));
+ assert.ok(!/noindex/.test(r.headers.get('x-robots-tag') || ''), 'with the wire, the front page is not empty: it is indexed');
+ });
+
+ await check('a report removed upstream leaves the wire', async () => {
+ t.sources.removeItem(reports.b.id, 'licence review');
+ await t.pull();
+ const r = await t.get('/wire');
+ assert.ok(!r.text.includes(B));
+ assert.ok(r.text.includes(A));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });