openvibers/openvibe.news Commits

News: the wire — what the sources report, grouped, on the front page and at /wire (labelled, never News' writing) (#49)

OP OpenVibers committed 30 days ago · 733706a9e0 · 7 files, +127 −7

OpenVibe.News's front page said only "No stories have been published yet", although 70 source items in 18 clusters were waiting. Stories stay an editor's act, as the README requires: no AI-written or unreviewed text is published.

**The wire**
- `/wire`, and "From the wire" on the front page (the latest 8 clusters, one line each), shows what the sources report, grouped by cluster.
- Each line is the outlet's own headline, outlet, date and link: only the fields the licensing rules let News keep.
- It is labelled "Nothing here is written by OpenVibe.News".
- Duplicates and reports removed upstream leave it. Only http(s) URLs become links (`rel=nofollow noopener`).
- `/wire` is `noindex, follow`. With clusters to show, the front page is no longer `noindex` before its first story.
- "Wire" is added to the site navigation.

**Tests:** `wire` (labelled listing, no `javascript:` links, robots, cache, front page, upstream removal); the perf budget holds (CSS 17.2 KB of 17.5). Suite: 26/26.

Changes

ddiff --git a/README.md b/README.md
iindex fe11c6a..b57a0d5 100644
---- a/README.md
++++ b/README.md
@@@ -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,
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
ddiff --git a/public/css/news.css b/public/css/news.css
iindex c1b7b6a..a4d500d 100644
---- a/public/css/news.css
++++ b/public/css/news.css
@@@ -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); }
ddiff --git a/server/domain/clusters.js b/server/domain/clusters.js
iindex dee0c30..522b36f 100644
---- a/server/domain/clusters.js
++++ b/server/domain/clusters.js
@@@ -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);
ddiff --git a/server/http/public.js b/server/http/public.js
iindex dcadcef..1ebb8bb 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -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 });
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 76396ad..ba23991 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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();
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 85e591e..4bf1b16 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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 };
ddiff --git a/test/wire.test.js b/test/wire.test.js
nnew file mode 100644
iindex 0000000..b063ac7
---- /dev/null
++++ b/test/wire.test.js
@@@ -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); });