openvibers/openvibe.news Commits
The news front page shows the newsroom: today's numbers, the latest AI briefs as cards, the wire, every topic with what was reported on it, the outlets it reads, how a brief is made, questions and links around the site (#82)
Changes
@@@ -78,3 +78,43 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }.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); }++/* The front page (render/home.js): a wider column, the numbers beside the hero, brief cards, topics, outlets. */++body.news-home .page { max-width: 1120px; }++.nh-panel { padding: 18px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-card, #131c2e); }++.page .nh-panel-h { margin: 0 0 12px; font-size: 1rem; }++.nh-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }++.nh-stats div { display: flex; flex-direction: column-reverse; padding: 10px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-primary, #0a0f1c); }++.nh-stats dt { font-size: 13px; line-height: 1.35; color: var(--text-secondary, #96a7c2); }++.nh-stats dd { margin: 0; font-size: 1.55rem; font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; }++.nh-live { margin: 12px 0 0; font-size: .95rem; }++.live-dot { display: inline-block; width: .55em; height: .55em; border-radius: 50%; background: #ef4444; vertical-align: middle; }++.badge-ai { background: color-mix(in srgb, #a78bfa 18%, transparent); color: color-mix(in srgb, #8b5cf6 70%, var(--text-primary, #e6edf7)); border: 1px solid color-mix(in srgb, #a78bfa 45%, transparent); }++.nh-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); }++.nh-card { display: flex; flex-direction: column; min-width: 0; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }++.nh-card h3 { margin: 4px 0; font-size: 1.05rem; line-height: 1.35; }++.nh-card h3 a { color: var(--text-primary, #e6edf7); text-decoration: none; }++.nh-card h3 a:hover { text-decoration: underline; }++.nh-card .meta { margin: 0; color: var(--text-secondary, #96a7c2); }++.nh-kicker { margin: 0; font-size: .85rem; color: var(--text-secondary, #96a7c2); }++.nh-kicker .badge-ai { color: color-mix(in srgb, #8b5cf6 55%, var(--text-primary, #e6edf7)); } /* 4.49:1 at 70% on a card */++.nh-sum { margin: 6px 0 0; font-size: .95rem; color: var(--text-secondary, #96a7c2); display: -webkit-box; -webkit-line-clamp: 4; line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }++.nh-more { margin: 14px 0 0; }++.nh-wire .wire { margin-top: 0; border-top: 0; }++.nh-wire .meta { color: var(--text-secondary, #96a7c2); }++.nh-topics, .nh-outlets { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); }++.nh-topics li, .nh-outlets li { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); overflow-wrap: anywhere; }++.nh-topics h3 { margin: 0; font-size: 1.05rem; }++.nh-topics p { margin: 0; }++.nh-count { font-size: .88rem; color: var(--text-secondary, #96a7c2); }++.nh-latest { font-size: .92rem; line-height: 1.45; }++.nh-latest a { color: var(--text-primary, #e6edf7); }++.nh-latest .meta, .nh-topics .meta { color: var(--text-secondary, #96a7c2); font-size: .85rem; }++.nh-faq details { border-bottom: 1px solid var(--border, #1f2d47); padding: 10px 0; }++.nh-faq summary { cursor: pointer; font-weight: 600; }++.nh-faq details p { margin: 8px 0 0; color: var(--text-secondary, #96a7c2); max-width: 760px; }++.nh-links { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr)); }++.nh-links a { display: block; height: 100%; padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; }++.nh-links a:hover { border-color: var(--accent, #3b82f6); }++.nh-links b { display: block; }++.nh-links small { display: block; color: var(--text-secondary, #96a7c2); font-size: .88rem; line-height: 1.45; }++@media (max-width: 560px) { .nh-stats dd { font-size: 1.3rem; } .nh-card .nh-sum { -webkit-line-clamp: 3; line-clamp: 3; } }nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++/**++ * What the front page counts and groups, besides the stories, briefs and wire it already lists. Read from News'++ * own database; only live reports (active, not removed) and open clusters count, as on the wire:++ *++ * stats() reports first seen in the last day and the last seven, the outlets that reported in the last 30 days,++ * the AI briefs there are, the topics and the published stories++ * outlets() each outlet of the last 30 days: its reports this week and in those 30 days, and its newest report++ * topicTally() each topic's subjects and reports over the last 30 days (server/domain/topic-match.js, the topic++ * pages' own rule) and its newest subject with that subject's newest report++ *++ * The front page is cached for everyone: nothing here depends on who is looking.++ */++const { topicsFor } = require('./topic-match');++++const DAY = 86400000;++const LIVE = "i.status = 'active' AND i.removed_at IS NULL";++++function createHome({ store }) {++ const { db } = store;++ const q = {++ stats: db.prepare(`SELECT (SELECT COUNT(*) FROM news_source_items i WHERE ${LIVE} AND i.first_seen_at >= ?) AS day,++ (SELECT COUNT(*) FROM news_source_items i WHERE ${LIVE} AND i.first_seen_at >= ?) AS week,++ (SELECT COUNT(DISTINCT i.outlet) FROM news_source_items i WHERE ${LIVE} AND i.first_seen_at >= ?) AS outlets,++ (SELECT COUNT(*) FROM news_cluster_briefs b JOIN news_story_clusters c ON c.id = b.cluster_id++ WHERE b.kind = 'summary' AND b.tone = 'neutral' AND b.stub = false AND c.status = 'open') AS briefs,++ (SELECT COUNT(*) FROM news_topics WHERE status = 'active') AS topics,++ (SELECT COUNT(*) FROM news_stories WHERE state = 'published') AS stories`),++ outlets: db.prepare(`SELECT i.outlet, COUNT(*) AS month, COUNT(*) FILTER (WHERE i.first_seen_at >= ?) AS week++ FROM news_source_items i WHERE ${LIVE} AND i.first_seen_at >= ? GROUP BY i.outlet ORDER BY week DESC, month DESC, i.outlet`),++ newestByOutlet: db.prepare(`SELECT DISTINCT ON (i.outlet) i.outlet, i.headline, i.canonical_url, i.published_at, i.first_seen_at++ FROM news_source_items i WHERE ${LIVE} AND i.first_seen_at >= ? AND i.canonical_url IS NOT NULL++ ORDER BY i.outlet, i.first_seen_at DESC, i.id DESC`),++ recentMembers: db.prepare(`SELECT c.id AS cid, c.label, c.window_end, i.headline, i.canonical_url, i.outlet, i.published_at, i.source_key++ FROM news_story_clusters c JOIN news_source_items i ON i.cluster_id = c.id++ WHERE c.status = 'open' AND c.window_end >= ? AND ${LIVE}++ ORDER BY c.window_end DESC, c.id DESC`),++ };++ const n = (v) => Number(v) || 0;++ return {++ async stats() {++ const now = store.now();++ const r = (await q.stats.get(now - DAY, now - 7 * DAY, now - 30 * DAY)) || {};++ return { day: n(r.day), week: n(r.week), outlets: n(r.outlets), briefs: n(r.briefs), topics: n(r.topics), stories: n(r.stories) };++ },++ async outlets() {++ const now = store.now();++ const [rows, newest] = await Promise.all([q.outlets.all(now - 7 * DAY, now - 30 * DAY), q.newestByOutlet.all(now - 30 * DAY)]);++ const latest = new Map(newest.map((r) => [r.outlet, { headline: r.headline, url: r.canonical_url, published_at: r.published_at }]));++ return rows.map((r) => ({ outlet: r.outlet, week: n(r.week), month: n(r.month), latest: latest.get(r.outlet) || null }));++ },++ /** Map slug → { subjects, reports, newest: { id, label, item } } over the last 30 days. */++ async topicTally({ days = 30 } = {}) {++ const rows = await q.recentMembers.all(store.now() - days * DAY);++ const byCluster = new Map();++ for (const r of rows) {++ if (!byCluster.has(r.cid)) byCluster.set(r.cid, { id: r.cid, label: r.label, updated: n(r.window_end), items: [], sources: new Set() });++ const c = byCluster.get(r.cid);++ c.sources.add(r.source_key);++ c.items.push({ headline: r.headline, url: r.canonical_url, outlet: r.outlet, published_at: r.published_at });++ }++ const out = new Map();++ for (const c of byCluster.values()) { // newest activity first, as the query orders them++ for (const slug of topicsFor({ label: c.label, headlines: c.items.map((i) => i.headline), sources: [...c.sources] })) {++ if (!out.has(slug)) {++ const item = c.items.filter((i) => i.url).sort((a, b) => String(b.published_at || '').localeCompare(String(a.published_at || '')))[0] || null;++ out.set(slug, { subjects: 0, reports: 0, newest: { id: c.id, label: c.label, item } });++ }++ const t = out.get(slug);++ t.subjects += 1;++ t.reports += c.items.length;++ }++ }++ return out;++ },++ };++}++++module.exports = { createHome };@@@ -3,7 +3,8 @@* Which topics a wire cluster belongs to, so a topic page carries the AI briefs and reports on it (not only the* stories editors filed under it). Deterministic and explainable, like clustering:*-- * a source can place everything it reports (NASA's releases are Space, the FDA's recalls are Health);++ * a source can place everything it reports (NASA's releases are Space, NIST's Science, the FDA's recalls and the++ * CDC's newsroom Health, the Federal Reserve Board's releases Business);* otherwise a topic's words must appear: once in the cluster's label, or twice (two different words) across the* label and its reports' headlines.*@@@ -11,10 +12,10 @@*/const RULES = Object.freeze({space: { sources: ['nasa-news-releases'], words: ['nasa', 'spacecraft', 'astronaut', 'astronauts', 'orbit', 'orbital', 'rocket', 'mars', 'moon', 'lunar', 'satellite', 'satellites', 'telescope', 'space station', 'asteroid', 'artemis', 'esa', 'spaceflight', 'launch site', 'uk space agency'] },-- science: { words: ['research', 'researchers', 'scientists', 'scientific', 'science', 'study', 'discovery', 'physics', 'chemistry', 'biology', 'laboratory', 'genome', 'fossil', 'quantum', 'experiment'] },-- health: { sources: ['fda-food-recalls'], words: ['health', 'hospital', 'hospitals', 'nhs', 'disease', 'vaccine', 'vaccines', 'virus', 'outbreak', 'cancer', 'medicine', 'medicines', 'medical', 'patients', 'mental health', 'food safety', 'allergen', 'salmonella', 'listeria', 'recall', 'pharmaceutical', 'care homes', 'gp'] },++ science: { sources: ['nist-news'], words: ['research', 'researchers', 'scientists', 'scientific', 'science', 'study', 'discovery', 'physics', 'chemistry', 'biology', 'laboratory', 'genome', 'fossil', 'quantum', 'experiment'] },++ health: { sources: ['fda-food-recalls', 'cdc-newsroom'], words: ['health', 'hospital', 'hospitals', 'nhs', 'disease', 'vaccine', 'vaccines', 'virus', 'outbreak', 'cancer', 'medicine', 'medicines', 'medical', 'patients', 'mental health', 'food safety', 'allergen', 'salmonella', 'listeria', 'recall', 'pharmaceutical', 'care homes', 'gp'] },climate: { words: ['climate', 'emissions', 'net zero', 'carbon', 'flood', 'flooding', 'floods', 'heatwave', 'drought', 'wildfire', 'wildfires', 'storm', 'environment', 'environmental', 'renewable', 'renewables', 'wind farm', 'solar', 'biodiversity', 'pollution', 'energy transition', 'green'] },-- business: { words: ['economy', 'economic', 'business', 'businesses', 'trade', 'tariff', 'tariffs', 'market', 'markets', 'investment', 'investors', 'inflation', 'jobs', 'employment', 'industry', 'industrial', 'budget', 'tax', 'taxes', 'bank', 'banks', 'competition', 'merger', 'consumers', 'retail', 'exports', 'growth', 'small firms'] },++ business: { sources: ['federal-reserve-press'], words: ['economy', 'economic', 'business', 'businesses', 'trade', 'tariff', 'tariffs', 'market', 'markets', 'investment', 'investors', 'inflation', 'jobs', 'employment', 'industry', 'industrial', 'budget', 'tax', 'taxes', 'bank', 'banks', 'competition', 'merger', 'consumers', 'retail', 'exports', 'growth', 'small firms'] },security: { words: ['cyber', 'cybersecurity', 'cyber security', 'hacker', 'hackers', 'hacked', 'ransomware', 'vulnerability', 'vulnerabilities', 'data breach', 'breach', 'malware', 'phishing', 'ncsc', 'cisa', 'enisa', 'fraud', 'scam', 'scams'] },technology: { words: ['technology', 'tech', 'digital', 'artificial intelligence', 'ai', 'software', 'semiconductor', 'semiconductors', 'chips', 'broadband', '5g', 'computing', 'robotics', 'data centre', 'data centres', 'data center', 'innovation', 'startups'] },'internet-policy': { words: ['online safety', 'digital services act', 'digital markets act', 'data protection', 'gdpr', 'privacy', 'net neutrality', 'online platforms', 'ofcom', 'copyright', 'content moderation', 'encryption', 'disinformation', 'age verification'] },@@@ -21,7 +21,7 @@ const sharedSeo = require('openvibe-shared/seo');const cache = require('openvibe-shared/cache-policy');// The one-line site summary /llms.txt, /llms-full.txt and the home page all carry.--const SITE_SUMMARY = 'A source-backed publication: stories written by OpenVibe.News editors from source items collected by OpenVibe.Sources. Every paragraph cites the sources it rests on.';++const SITE_SUMMARY = 'Source-backed news: AI briefs and editors\' stories where every paragraph cites the reports it rests on, a live wire of what public sources (collected by OpenVibe.Sources) report, and topic pages.';function createDiscoveryRoutes({ config, store, publication, topics }) {const router = express.Router();@@@ -33,11 +33,17 @@ const access = require('../domain/access');const { csrfToken, checkCsrf } = require('../auth/forms');const cache = require('openvibe-shared/cache-policy');const { SITE_SUMMARY } = require('./discovery');++const homePage = require('../render/home');++const { createHome } = require('../domain/home');++++// The front page in search results and share cards (≤ 160 characters).++const HOME_DESCRIPTION = 'News you can check: AI briefs and stories where every paragraph links the reports it rests on, a live wire of public sources, and topic pages.';const PER_PAGE = 20;function createPublicRoutes(ctx) {const { config, store, stories, publication, reading, topics, viewers, community, discussion, clusters, briefs } = ctx;++ const homeData = ctx.home || createHome({ store });const router = express.Router();router.use(viewers.middleware({ services: false }));@@@ -73,24 +79,6 @@ function createPublicRoutes(ctx) {const FEEDS = [{ type: 'rss', label: 'RSS', file: 'feed.xml' }, { type: 'atom', label: 'Atom', file: 'atom.xml' }, { type: 'json', label: 'JSON Feed', file: 'feed.json' }];const feedsFor = (base) => FEEDS.map((f) => ({ type: f.type, label: f.label, href: `${base === '/' ? '' : base}/${f.file}`, title: f.label }));-- /** The product's own words above the front page's stories: only what OpenVibe.News does today. */-- function newsShowcase() {-- return showcase.hero({-- eyebrow: 'OpenVibe.News',-- title: 'News you can', accent: 'check',-- lede: 'Every paragraph links the reports it rests on. Corrections and retractions stay on the record, and anyone can read without an account.',-- actions: [{ label: 'Read the stories', href: '#stories', primary: true }, { label: 'Browse topics', href: '/topics' }],-- }) + showcase.features({-- title: 'How a story is built',-- items: [-- { icon: 'ov:news', title: 'Cited paragraphs', text: 'Each paragraph names the sources behind it, with outlet, authors and date.' },-- { icon: 'ov:history', title: 'Revisions on the record', text: 'Corrections and retractions are kept and shown, never quietly edited away.' },-- { icon: 'ov:search', title: 'Source checks', text: 'When a source changes or is removed upstream, the story says so while editors check it.' },-- { icon: 'ov:community', title: 'Discussion', text: 'Talk about a story on OpenVibe.Community with your OpenVibe account.' },-- ],-- });-- }--// A topic page also carries the AI briefs and the wire on it (server/domain/topic-match.js); with nothing on it in// the last 30 days, the latest from the wire overall, said as such.async function topicExtras(topic) {@@@ -112,6 +100,23 @@ function createPublicRoutes(ctx) {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);++ // The front page (page 1): what OpenVibe.News is for, then everything it has to read (render/home.js).++ if (!topic && pager.page === 1) {++ const [stats, latestBriefs, topicList, tally, outlets] = await Promise.all([++ homeData.stats(), briefs ? briefs.latest(12) : [], topics.list(), homeData.topicTally(), homeData.outlets(),++ ]);++ return send(req, res, 200, {++ title: null, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,++ decision: pageDecision(canonical, { empty: total === 0 && !wire.length && !stats.week, query: ['page'] }),++ canonical, feeds, next: pager.next ? pager.next.href : null,++ jsonLd: homePage.homeJsonLd({ abs: (p) => publication.abs(p), topics: topicList, outlets }),++ styles: [showcase.STYLESHEET], bodyClass: 'news-home',++ body: homePage.homeBody({++ items, pager, feeds, stats, wire: pages.wireSection(wire), topics: topicList, tally, outlets,++ briefs: latestBriefs.filter((b) => b.kind === 'summary' && !b.stub && b.paragraphs.length).slice(0, 6),++ }),++ }, { cacheable: true });++ }const crumbs = topic ? [{ name: 'OpenVibe.News', url: '/' }, { name: 'Topics', url: '/topics' }, { name: topic.name }] : null;send(req, res, 200, {title: topic ? topic.name : null,@@@ -123,10 +128,8 @@ function createPublicRoutes(ctx) {jsonLd: topic? [seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : canonical })))]: [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.News', url: publication.abs('/') }],-- // 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 ? await topicExtras(topic) : {}) })-- : (pager.page === 1 ? newsShowcase() : '') + pages.home({ items, pager, topics: await topics.list(), feeds, showcase: pager.page === 1, wire: pages.wireSection(wire), briefs: pager.page === 1 && briefs ? (await briefs.latest(12)).filter((b) => b.kind === 'summary' && !b.stub && b.paragraphs.length).slice(0, 4) : [] }),++ : pages.home({ items, pager, topics: await topics.list(), feeds }),}, { cacheable: true });}nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * The front page (/ page 1): what OpenVibe.News is for, then real content only. The numbers, editors' stories, the++ * latest AI briefs (labelled), the wire, every topic with what was reported on it, the outlets News reads, how it++ * works, questions and the ways around the site. A section with nothing to show is left out.++ */++const ssr = require('openvibe-publishing/ssr');++const showcase = require('openvibe-shared/showcase');++const { time, httpHref, storyList } = require('./pages');++++const { html: h, raw } = ssr;++const num = (n) => Number(n || 0).toLocaleString('en-US');++const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;++const list = (xs) => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`);++const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1).replace(/\s+\S*$/, '')}…` : s);++++/** The questions the front page answers (shown, and given as FAQPage); the outlets one names today's outlets. */++function faq(outlets) {++ const names = outlets.map((o) => o.outlet);++ return [++ { q: 'Who writes OpenVibe.News?', a: 'Two kinds of text, each labelled. AI briefs are written by OpenVibe.AI from the reports on one subject, with every paragraph linked to the reports it rests on. Stories are written by editors, and every paragraph cites its sources. The wire and the Live page show the outlets\' own headlines, linked to their reports.' },++ { q: 'Where do the reports come from?', a: `From public feeds that OpenVibe.Sources collects after checking each publisher's terms and robots.txt.${names.length ? ` In the last 30 days: ${list(names)}.` : ''} News keeps headlines, links and short summaries only, and links every report back to its publisher.` },++ { q: 'Can I change how a brief is told?', a: 'Yes. A brief comes in brief, summary or explained form. When two or more outlets reported on a subject, AI can also compare how each one puts it.' },++ { q: 'What does Breaking mean?', a: 'On the Live page, Breaking marks a subject that three or more reports covered in the last two hours. The page refreshes itself every minute.' },++ { q: 'Why are AI briefs not in search results?', a: 'No editor has reviewed them, so they are kept out of search engines until a person does. Editors\' stories and topic pages are indexed.' },++ { q: 'What happens when a story is wrong?', a: 'Corrections and retractions stay on the record. A story shows its correction history, and a retracted story stays up, labelled, with the reason.' },++ ];++}++++function statsPanel(stats) {++ const tiles = [++ stats.stories ? ['Stories published', stats.stories] : null,++ ['Reports in the last day', stats.day],++ ['Reports this week', stats.week],++ stats.outlets ? ['Outlets read this month', stats.outlets] : null,++ stats.briefs ? ['AI briefs', stats.briefs] : null,++ stats.stories && stats.topics ? ['Topics', stats.topics] : null, // with the stories tile, six in two columns++ ].filter(Boolean);++ if (!stats.week && !stats.stories) return '';++ return String(h`<div class="nh-panel"><h2 class="nh-panel-h">The newsroom today</h2><dl class="nh-stats">${tiles.map(([k, v]) => h`<div><dt>${k}</dt><dd>${num(v)}</dd></div>`)}</dl>++<p class="nh-live"><span class="live-dot" aria-hidden="true"></span> <a href="/live">Watch reports arrive live</a></p></div>`);++}++++function briefCard(b) {++ const first = b.paragraphs[0] ? b.paragraphs[0].text : '';++ return h`<li class="nh-card"><p class="nh-kicker"><span class="badge badge-ai">AI brief</span> from ${plural(b.item_count, 'report')}</p>++<h3><a href="/briefs/${b.cluster_id}">${b.headline || b.label}</a></h3><p class="meta">${time(b.window_end)}</p>${first ? h`<p class="nh-sum">${clip(first, 260)}</p>` : ''}</li>`;++}++++/**++ * o: items (editors' stories, page 1), pager, feeds, stats, briefs, wire (pages.wireSection HTML), topics (active),++ * tally (Map slug → { subjects, reports, newest }), outlets++ */++function homeBody(o) {++ const { items, pager, feeds, stats, briefs = [], wire = '', topics = [], tally = new Map(), outlets = [] } = o;++ const questions = faq(outlets);++ return String(h`${raw(showcase.hero({++ eyebrow: 'OpenVibe.News',++ title: 'News you can', accent: 'check',++ lede: 'Every paragraph links the reports it rests on. Read AI briefs that cite every claim, watch reports arrive live, and check any of it yourself. No account needed.',++ actions: [{ label: 'Read the latest', href: '#latest', primary: true }, { label: 'Live', href: '/live' }, { label: 'Browse topics', href: '#topics' }],++ aside: stats ? { html: statsPanel(stats) } : undefined,++ }))}++<div id="latest"></div>++${items.length ? h`<section class="sc-sec nh-sec" id="stories" aria-labelledby="nh-stories-h"><h2 id="nh-stories-h">Latest stories</h2>++${feeds.length ? h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}">${f.label}</a> `)}</p>` : ''}++${storyList(items, '')}${raw(ssr.paginationHtml(pager))}</section>` : ''}++${briefs.length ? h`<section class="sc-sec nh-sec" aria-labelledby="nh-briefs-h"><h2 id="nh-briefs-h">${items.length ? 'AI briefs' : 'Latest AI briefs'}</h2>++<p class="sc-lede">What the reports on each subject say, written by OpenVibe.AI with every paragraph linked to its reports. Not written or reviewed by an editor.</p>++<ol class="nh-cards">${briefs.map(briefCard)}</ol><p class="nh-more"><a href="/briefs">All AI briefs</a></p></section>` : ''}++${wire ? h`<div class="sc-sec nh-sec nh-wire">${raw(wire)}<p class="nh-more"><a href="/live">Live: the newest reports, refreshed every minute</a></p></div>` : ''}++${topics.length ? h`<section class="sc-sec nh-sec" id="topics" aria-labelledby="nh-topics-h"><h2 id="nh-topics-h">Topics</h2><p class="sc-lede">What the sources reported on each topic in the last 30 days.</p>++<ul class="nh-topics">${topics.map((t) => {++ const x = tally.get(t.slug);++ const item = x && x.newest && x.newest.item;++ const href = item ? httpHref(item.url) : null;++ return h`<li><h3><a href="/topics/${t.slug}">${t.name}</a></h3>${x ? h`<p class="nh-count">${plural(x.subjects, 'subject')}, ${plural(x.reports, 'report')}</p>` : ''}++${item ? h`<p class="nh-latest">${href ? h`<a href="${href}" rel="nofollow noopener" target="_blank">${item.headline}</a>` : item.headline} <span class="meta">${item.outlet}</span></p>` : (t.description ? h`<p class="nh-latest meta">${t.description}</p>` : '')}</li>`;++ })}</ul></section>` : ''}++${outlets.length ? h`<section class="sc-sec nh-sec" aria-labelledby="nh-outlets-h"><h2 id="nh-outlets-h">The sources we read</h2>++<p class="sc-lede">Public feeds, collected by OpenVibe.Sources with each publisher's terms and robots.txt checked. Headlines, links and short summaries only.</p>++<ul class="nh-outlets">${outlets.map((x) => h`<li><b>${x.outlet}</b><span class="nh-count">${plural(x.week, 'report')} this week · ${num(x.month)} in 30 days</span>${x.latest ? h`<span class="nh-latest">Newest: ${httpHref(x.latest.url) ? h`<a href="${httpHref(x.latest.url)}" rel="nofollow noopener" target="_blank">${x.latest.headline}</a>` : x.latest.headline}</span>` : ''}</li>`)}</ul></section>` : ''}++${raw(showcase.features({++ title: 'How a story is built',++ items: [++ { icon: 'ov:news', title: 'Cited paragraphs', text: 'Each paragraph names the sources behind it, with outlet, authors and date.' },++ { icon: 'ov:history', title: 'Revisions on the record', text: 'Corrections and retractions are kept and shown, never quietly edited away.' },++ { icon: 'ov:search', title: 'Source checks', text: 'When a source changes or is removed upstream, the story says so while editors check it.' },++ { icon: 'ov:community', title: 'Discussion', text: 'Talk about a story on OpenVibe.Community with your OpenVibe account.' },++ ],++ }))}++${raw(showcase.steps({++ title: 'From report to brief',++ items: [++ { title: 'Public feeds come in', text: 'OpenVibe.Sources reads each publisher\'s own feed, within its terms, and hands News the headline, link and short summary.' },++ { title: 'Reports are grouped by subject', text: 'Reports that share names and words become one subject. Every grouping can be explained, and editors can split it.' },++ { title: 'AI writes a cited brief', text: 'Once enough reports cover a subject, OpenVibe.AI summarises them. Every paragraph links the reports it used.', href: '/briefs' },++ { title: 'Editors write the stories', text: 'Stories cite every paragraph. Corrections and retractions stay on the record.' },++ ],++ }))}++<section class="sc-sec nh-sec nh-faq" aria-labelledby="nh-faq-h"><h2 id="nh-faq-h">Questions</h2>${questions.map((f) => h`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>++<section class="sc-sec nh-sec" aria-labelledby="nh-links-h"><h2 id="nh-links-h">Find your way around</h2><ul class="nh-links">++<li><a href="/live"><b>Live</b><small>The newest reports as they arrive, with what is breaking.</small></a></li>++<li><a href="/briefs"><b>AI briefs</b><small>Every subject's brief, in three tones.</small></a></li>++<li><a href="/wire"><b>The wire</b><small>Every subject the sources reported, grouped.</small></a></li>++<li><a href="/topics"><b>Topics</b><small>${plural(topics.length, 'topic')}, each with its briefs and wire.</small></a></li>++<li><a href="/feed.xml"><b>Feeds</b><small>RSS, Atom and JSON Feed for the stories and each topic.</small></a></li>++<li><a href="/updates"><b>What shipped</b><small>Every change to OpenVibe.News itself.</small></a></li>++</ul></section>`);++}++++/** Structured data for the front page: the site, its topic pages as an ItemList (indexed pages), the questions shown. */++function homeJsonLd({ abs, topics, outlets }) {++ const out = [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.News', url: abs('/') }];++ if (topics.length) out.push({ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Topics on OpenVibe.News', numberOfItems: topics.length, itemListElement: topics.map((t, i) => ({ '@type': 'ListItem', position: i + 1, name: t.name, url: abs(`/topics/${t.slug}`) })) });++ out.push({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq(outlets).map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) });++ return out;++}++++module.exports = { homeBody, homeJsonLd, faq };@@@ -35,21 +35,15 @@ function feedLinksHtml(feeds) {return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}">${f.label}</a> `)}</p>`;}--// 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, wire = '', briefs = [] }) {-- return String(h`${showcase ? h`<header class="site-header"><h2>Latest stories</h2>--${raw(feedLinksHtml(feeds))}--</header>` : h`<header class="site-header">++// The front page's later pages (page 1 is render/home.js): the site's own h1 and lede, then the stories.++function home({ items, pager, topics, feeds }) {++ return String(h`<header class="site-header"><h1>OpenVibe.News</h1><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>`}--${items.length || !showcase ? h`<section id="stories" aria-label="Stories">${storyList(items, 'No stories on this page.')}</section>--${raw(ssr.paginationHtml(pager))}` : ''}--${raw(briefsSection(briefs, { lead: showcase && !items.length }))}--${showcase && !items.length && !briefs.length && !wire ? h`<p class="empty">The first reports are on their way: the AI briefs and the wire fill in as the sources report.</p>` : ''}--${raw(wire)}++</header>++<section id="stories" aria-label="Stories">${storyList(items, 'No stories on this page.')}</section>++${raw(ssr.paginationHtml(pager))}${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>` : ''}`);}@@@ -46,9 +46,9 @@ const SAME = { 'sec-fetch-site': 'same-origin' };const list = await t.get('/briefs');assert.ok(list.text.includes(`/briefs/${clusterId}`));const home = await t.get('/');-- // With no editor's story, the briefs are the front page's latest stories: under "Latest stories", labelled AI.-- assert.ok(home.text.includes('<h2>Latest stories</h2>') && home.text.includes(`href="/briefs/${clusterId}"`), 'the front page leads with the latest briefs');-- assert.ok(home.text.includes('Written by AI, not by an editor; stories by editors appear here first') && home.text.includes('<span class="badge">AI</span>'));++ // With no editor's story, the briefs lead the front page: "Latest AI briefs", each labelled AI.++ assert.ok(home.text.includes('>Latest AI briefs</h2>') && home.text.includes(`href="/briefs/${clusterId}"`), 'the front page leads with the latest briefs');++ assert.ok(home.text.includes('Not written or reviewed by an editor') && home.text.includes('<span class="badge badge-ai">AI brief</span>'));assert.ok(!home.text.includes('No stories have been published yet'));});nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * The front page (/ page 1): its own description and one h1, the numbers (reports today and this week, outlets of++ * the last 30 days), the wire, every topic with what was reported on it, the outlets News reads with their newest++ * report, the questions shown and given as FAQPage (the outlets answer names today's outlets), WebSite + the topics'++ * ItemList, and the links around the site. The new sources' reports are placed in their topics.++ */++const assert = require('assert');++const { boot, check, done, launchReports } = require('./helpers/boot');++const { topicsFor } = require('../server/domain/topic-match');++const { createHome } = require('../server/domain/home');++++const ld = (text) => [...text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++++(async () => {++ const t = await boot();++ launchReports(t.sources);++ t.sources.addSource('nist-news', { name: 'NIST news' });++ t.sources.addItem({ source_key: 'nist-news', title: 'New chip-based tape measure for light', url: 'https://www.nist.gov/news-events/news/2026/09/tape-measure', published_at: '2026-09-22T08:00:00Z' });++ await t.pull();++ try {++ let home;++ await check('the front page: its own description, one h1, the numbers', async () => {++ home = await t.get('/');++ assert.strictEqual(home.status, 200);++ assert.match(home.text, /<title>OpenVibe\.News: source-backed news, every claim cited<\/title>/);++ const desc = /<meta name="description" content="([^"]*)"/.exec(home.text)[1];++ assert.ok(desc.length <= 160 && /every paragraph links the reports/.test(desc), desc);++ assert.strictEqual((home.text.match(/<h1\b/g) || []).length, 1);++ const stats = await createHome({ store: t.ctx.store }).stats();++ assert.deepStrictEqual({ day: stats.day, week: stats.week, outlets: stats.outlets, stories: stats.stories }, { day: 4, week: 4, outlets: 4, stories: 0 });++ assert.match(home.text, /<dt>Reports this week<\/dt><dd>4<\/dd>/);++ assert.ok(!home.text.includes('<dt>Stories published</dt>'), 'no story yet: no stories tile');++ });++++ await check('the wire, the topics with what was reported on them, the outlets with their newest report', async () => {++ const text = home.text;++ assert.ok(text.includes('From the wire') && text.includes('Europa Clipper'));++ const space = (/<li><h3><a href="\/topics\/space">Space<\/a><\/h3>[\s\S]*?<\/li>/.exec(text) || [''])[0];++ assert.match(space, /1 subject, 3 reports/);++ const science = (/<li><h3><a href="\/topics\/science">Science<\/a><\/h3>[\s\S]*?<\/li>/.exec(text) || [''])[0];++ assert.ok(science.includes('New chip-based tape measure for light'), 'NIST places its reports in Science');++ assert.ok(text.includes('<b>NIST news</b>') && text.includes('<b>Wire A</b>'));++ assert.match(text, /<b>Wire A<\/b><span class="nh-count">1 report this week · 1 in 30 days<\/span>/);++ });++++ await check('questions, links and structured data', async () => {++ const text = home.text;++ const blocks = ld(text);++ assert.ok(blocks.find((b) => b['@type'] === 'WebSite'));++ const topics = blocks.find((b) => b['@type'] === 'ItemList');++ assert.ok(topics.itemListElement.some((x) => /\/topics\/space$/.test(x.url)));++ const faq = blocks.find((b) => b['@type'] === 'FAQPage');++ for (const q of faq.mainEntity) assert.ok(text.includes(`<summary>${q.name}</summary>`), q.name);++ assert.ok(faq.mainEntity.find((q) => q.name === 'Where do the reports come from?').acceptedAnswer.text.includes('NIST news'));++ ['/live', '/briefs', '/wire', '/topics', '/feed.xml', '/updates'].forEach((p) => assert.ok(text.includes(`href="${p}"`), p));++ });++++ await check('the new sources place their reports in their topics', async () => {++ assert.deepStrictEqual(topicsFor({ label: 'tape measure', headlines: [], sources: ['nist-news'] }), ['science']);++ assert.deepStrictEqual(topicsFor({ label: 'newsroom', headlines: [], sources: ['cdc-newsroom'] }), ['health']);++ assert.deepStrictEqual(topicsFor({ label: 'enforcement action', headlines: [], sources: ['federal-reserve-press'] }), ['business']);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -16,7 +16,8 @@ const { boot, check, done, launchReports } = require('./helpers/boot');assert.ok(await count('news_topics') >= 10);for (const table of ['news_stories', 'news_source_items', 'news_story_clusters', 'news_story_revisions', 'news_editorial_flags']) assert.strictEqual(await count(table), 0, table);const home = await t.get('/');-- assert.match(home.text, /The first reports are on their way/, 'an empty front page says what comes, not that nothing was published');++ assert.ok(home.text.includes('href="/topics/space"') && !/have been published yet/.test(home.text), 'an empty front page shows its topics, not that nothing was published');++ assert.ok(!home.text.includes('The newsroom today'), 'no reports yet: no numbers panel');});let reports;@@@ -93,7 +93,8 @@ const count = (html, re) => (html.match(re) || []).length;const head = home.slice(0, home.indexOf('</head>'));assert.strictEqual(count(head, /<title>/g), 1);assert.ok(head.includes('<link rel="canonical" href="https://openvibe.news/">'));-- assert.strictEqual(count(head, /<script type="application\/ld\+json">/g), 2, 'the home WebSite JSON-LD and the AI-summary WebPage JSON-LD');++ assert.strictEqual(count(head, /<script type="application\/ld\+json">/g), 4, 'the home WebSite, the topics ItemList, the FAQPage and the AI-summary WebPage JSON-LD');++ assert.ok(head.includes('"@type":"ItemList"') && head.includes('"@type":"FAQPage"'), 'the topics and the questions shown');assert.ok(head.includes('"@type":"WebSite"'), 'the site JSON-LD');assert.ok(head.includes('"@type":"WebPage"'), 'the AI summary WebPage JSON-LD');assert.ok(head.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml" title="Atom">'));@@@ -44,7 +44,7 @@ const { boot, check, done } = require('./helpers/boot');assert.match(full.headers.get('content-type'), /^text\/plain/);assert.strictEqual(full.headers.get('cache-control'), 'public, max-age=3600, stale-while-revalidate=3600');assert.match(full.text, /^# OpenVibe\.News/);-- assert.match(full.text, /source-backed publication/);++ assert.match(full.text, /Source-backed news: AI briefs and editors' stories/);assert.ok(Buffer.byteLength(full.text, 'utf8') <= 512 * 1024, 'no stories: within maxBytes');const idx = (await t.get('/sitemap.xml')).text;assert.match(idx, /<sitemapindex/);@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 27, // measured 24.4 with the home showcase (17.4 before it; fresh database): raised as a decision, as Blog did-- htmlBrotliKB: 7.5, // 6.3 (4.5 before the showcase)++ htmlRawKB: 36, // 33.2 on 2026-10-22: the front page's topics (12 cards), steps, questions (and their FAQPage and topics ItemList JSON-LD) and links; 24.4 with the home showcase (17.4 before it; fresh database)++ htmlBrotliKB: 9, // 8.0 (2026-10-22 the richer front page); 6.3 (4.5 before the showcase)jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)cssFiles: 2, // 2 (news.css + showcase.css, home page 1 only)-- cssRawKB: 18.5, // 15.5 (5.2 before showcase.css); 18.2 with the front page aligned to the showcase (2026-10-15)-- cssBrotliKB: 4.5, // 3.9 (1.4 before showcase.css)++ cssRawKB: 24, // 22.2 on 2026-10-22 (the front page's numbers, brief cards, topics, outlets, questions and links); 18.2 with the front page aligned to the showcase (2026-10-15); 15.5 (5.2 before showcase.css)++ cssBrotliKB: 5.5, // 5.2 (2026-10-22); 3.9 (1.4 before showcase.css)externalFiles: 1, // 0};@@@ -163,7 +163,7 @@ const BODY = [assert.match(home.text, /<link rel="stylesheet" href="[^"]*showcase\.css[^"]*">/);assert.strictEqual((home.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on the home page');assert.ok(home.text.indexOf('class="sc-hero') < home.text.indexOf('Europa Clipper launches for Jupiter'), 'the showcase sits above the stories');-- assert.match(home.text, /<h2>Latest stories<\/h2>/);++ assert.match(home.text, /<h2 id="nh-stories-h">Latest stories<\/h2>/);const topic = await t.get('/topics/space');assert.match(topic.text, /Europa Clipper launches for Jupiter/);assert.ok(!topic.text.includes('sc-hero') && !topic.text.includes('showcase.css'), 'a topic page has no showcase');
@@ -78,3 +78,43 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
@@ -78,3 +78,43 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
.wire-cluster { padding: 10px 0; border-bottom: 1px solid var(--border, #1f2d47); overflow-wrap: anywhere; }
.wire-cluster { padding: 10px 0; border-bottom: 1px solid var(--border, #1f2d47); overflow-wrap: anywhere; }
.wire-lead { margin: 0; font-weight: 600; }
.wire-lead { margin: 0; font-weight: 600; }
.wire a { color: var(--text-primary); }
.wire a { color: var(--text-primary); }
+/* The front page (render/home.js): a wider column, the numbers beside the hero, brief cards, topics, outlets. */
+body.news-home .page { max-width: 1120px; }
+.nh-panel { padding: 18px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-card, #131c2e); }
+.page .nh-panel-h { margin: 0 0 12px; font-size: 1rem; }
+.nh-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }
+.nh-stats div { display: flex; flex-direction: column-reverse; padding: 10px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-primary, #0a0f1c); }
+.nh-stats dt { font-size: 13px; line-height: 1.35; color: var(--text-secondary, #96a7c2); }
+.nh-stats dd { margin: 0; font-size: 1.55rem; font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; }
+.nh-live { margin: 12px 0 0; font-size: .95rem; }
+.live-dot { display: inline-block; width: .55em; height: .55em; border-radius: 50%; background: #ef4444; vertical-align: middle; }
+.badge-ai { background: color-mix(in srgb, #a78bfa 18%, transparent); color: color-mix(in srgb, #8b5cf6 70%, var(--text-primary, #e6edf7)); border: 1px solid color-mix(in srgb, #a78bfa 45%, transparent); }
+.nh-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); }
+.nh-card { display: flex; flex-direction: column; min-width: 0; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
+.nh-card h3 { margin: 4px 0; font-size: 1.05rem; line-height: 1.35; }
+.nh-card h3 a { color: var(--text-primary, #e6edf7); text-decoration: none; }
+.nh-card h3 a:hover { text-decoration: underline; }
+.nh-card .meta { margin: 0; color: var(--text-secondary, #96a7c2); }
+.nh-kicker { margin: 0; font-size: .85rem; color: var(--text-secondary, #96a7c2); }
+.nh-kicker .badge-ai { color: color-mix(in srgb, #8b5cf6 55%, var(--text-primary, #e6edf7)); } /* 4.49:1 at 70% on a card */
+.nh-sum { margin: 6px 0 0; font-size: .95rem; color: var(--text-secondary, #96a7c2); display: -webkit-box; -webkit-line-clamp: 4; line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
+.nh-more { margin: 14px 0 0; }
+.nh-wire .wire { margin-top: 0; border-top: 0; }
+.nh-wire .meta { color: var(--text-secondary, #96a7c2); }
+.nh-topics, .nh-outlets { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); }
+.nh-topics li, .nh-outlets li { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); overflow-wrap: anywhere; }
+.nh-topics h3 { margin: 0; font-size: 1.05rem; }
+.nh-topics p { margin: 0; }
+.nh-count { font-size: .88rem; color: var(--text-secondary, #96a7c2); }
+.nh-latest { font-size: .92rem; line-height: 1.45; }
+.nh-latest a { color: var(--text-primary, #e6edf7); }
+.nh-latest .meta, .nh-topics .meta { color: var(--text-secondary, #96a7c2); font-size: .85rem; }
+.nh-faq details { border-bottom: 1px solid var(--border, #1f2d47); padding: 10px 0; }
+.nh-faq summary { cursor: pointer; font-weight: 600; }
+.nh-faq details p { margin: 8px 0 0; color: var(--text-secondary, #96a7c2); max-width: 760px; }
+.nh-links { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr)); }
+.nh-links a { display: block; height: 100%; padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; }
+.nh-links a:hover { border-color: var(--accent, #3b82f6); }
+.nh-links b { display: block; }
+.nh-links small { display: block; color: var(--text-secondary, #96a7c2); font-size: .88rem; line-height: 1.45; }
+@media (max-width: 560px) { .nh-stats dd { font-size: 1.3rem; } .nh-card .nh-sum { -webkit-line-clamp: 3; line-clamp: 3; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+/**
+ * What the front page counts and groups, besides the stories, briefs and wire it already lists. Read from News'
+ * own database; only live reports (active, not removed) and open clusters count, as on the wire:
+ *
+ * stats() reports first seen in the last day and the last seven, the outlets that reported in the last 30 days,
+ * the AI briefs there are, the topics and the published stories
+ * outlets() each outlet of the last 30 days: its reports this week and in those 30 days, and its newest report
+ * topicTally() each topic's subjects and reports over the last 30 days (server/domain/topic-match.js, the topic
+ * pages' own rule) and its newest subject with that subject's newest report
+ *
+ * The front page is cached for everyone: nothing here depends on who is looking.
+ */
+const { topicsFor } = require('./topic-match');
+
+const DAY = 86400000;
+const LIVE = "i.status = 'active' AND i.removed_at IS NULL";
+
+function createHome({ store }) {
+ const { db } = store;
+ const q = {
+ stats: db.prepare(`SELECT (SELECT COUNT(*) FROM news_source_items i WHERE ${LIVE} AND i.first_seen_at >= ?) AS day,
+ (SELECT COUNT(*) FROM news_source_items i WHERE ${LIVE} AND i.first_seen_at >= ?) AS week,
+ (SELECT COUNT(DISTINCT i.outlet) FROM news_source_items i WHERE ${LIVE} AND i.first_seen_at >= ?) AS outlets,
+ (SELECT COUNT(*) FROM news_cluster_briefs b JOIN news_story_clusters c ON c.id = b.cluster_id
+ WHERE b.kind = 'summary' AND b.tone = 'neutral' AND b.stub = false AND c.status = 'open') AS briefs,
+ (SELECT COUNT(*) FROM news_topics WHERE status = 'active') AS topics,
+ (SELECT COUNT(*) FROM news_stories WHERE state = 'published') AS stories`),
+ outlets: db.prepare(`SELECT i.outlet, COUNT(*) AS month, COUNT(*) FILTER (WHERE i.first_seen_at >= ?) AS week
+ FROM news_source_items i WHERE ${LIVE} AND i.first_seen_at >= ? GROUP BY i.outlet ORDER BY week DESC, month DESC, i.outlet`),
+ newestByOutlet: db.prepare(`SELECT DISTINCT ON (i.outlet) i.outlet, i.headline, i.canonical_url, i.published_at, i.first_seen_at
+ FROM news_source_items i WHERE ${LIVE} AND i.first_seen_at >= ? AND i.canonical_url IS NOT NULL
+ ORDER BY i.outlet, i.first_seen_at DESC, i.id DESC`),
+ recentMembers: db.prepare(`SELECT c.id AS cid, c.label, c.window_end, i.headline, i.canonical_url, i.outlet, i.published_at, i.source_key
+ FROM news_story_clusters c JOIN news_source_items i ON i.cluster_id = c.id
+ WHERE c.status = 'open' AND c.window_end >= ? AND ${LIVE}
+ ORDER BY c.window_end DESC, c.id DESC`),
+ };
+ const n = (v) => Number(v) || 0;
+ return {
+ async stats() {
+ const now = store.now();
+ const r = (await q.stats.get(now - DAY, now - 7 * DAY, now - 30 * DAY)) || {};
+ return { day: n(r.day), week: n(r.week), outlets: n(r.outlets), briefs: n(r.briefs), topics: n(r.topics), stories: n(r.stories) };
+ },
+ async outlets() {
+ const now = store.now();
+ const [rows, newest] = await Promise.all([q.outlets.all(now - 7 * DAY, now - 30 * DAY), q.newestByOutlet.all(now - 30 * DAY)]);
+ const latest = new Map(newest.map((r) => [r.outlet, { headline: r.headline, url: r.canonical_url, published_at: r.published_at }]));
+ return rows.map((r) => ({ outlet: r.outlet, week: n(r.week), month: n(r.month), latest: latest.get(r.outlet) || null }));
+ },
+ /** Map slug → { subjects, reports, newest: { id, label, item } } over the last 30 days. */
+ async topicTally({ days = 30 } = {}) {
+ const rows = await q.recentMembers.all(store.now() - days * DAY);
+ const byCluster = new Map();
+ for (const r of rows) {
+ if (!byCluster.has(r.cid)) byCluster.set(r.cid, { id: r.cid, label: r.label, updated: n(r.window_end), items: [], sources: new Set() });
+ const c = byCluster.get(r.cid);
+ c.sources.add(r.source_key);
+ c.items.push({ headline: r.headline, url: r.canonical_url, outlet: r.outlet, published_at: r.published_at });
+ }
+ const out = new Map();
+ for (const c of byCluster.values()) { // newest activity first, as the query orders them
+ for (const slug of topicsFor({ label: c.label, headlines: c.items.map((i) => i.headline), sources: [...c.sources] })) {
+ if (!out.has(slug)) {
+ const item = c.items.filter((i) => i.url).sort((a, b) => String(b.published_at || '').localeCompare(String(a.published_at || '')))[0] || null;
+ out.set(slug, { subjects: 0, reports: 0, newest: { id: c.id, label: c.label, item } });
+ }
+ const t = out.get(slug);
+ t.subjects += 1;
+ t.reports += c.items.length;
+ }
+ }
+ return out;
+ },
+ };
+}
+
+module.exports = { createHome };
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
* Which topics a wire cluster belongs to, so a topic page carries the AI briefs and reports on it (not only the
* Which topics a wire cluster belongs to, so a topic page carries the AI briefs and reports on it (not only the
* stories editors filed under it). Deterministic and explainable, like clustering:
* stories editors filed under it). Deterministic and explainable, like clustering:
*
*
- * a source can place everything it reports (NASA's releases are Space, the FDA's recalls are Health);
+ * a source can place everything it reports (NASA's releases are Space, NIST's Science, the FDA's recalls and the
+ * CDC's newsroom Health, the Federal Reserve Board's releases Business);
* otherwise a topic's words must appear: once in the cluster's label, or twice (two different words) across the
* otherwise a topic's words must appear: once in the cluster's label, or twice (two different words) across the
* label and its reports' headlines.
* label and its reports' headlines.
*
*
@@ -11,10 +12,10 @@
@@ -11,10 +12,10 @@
*/
*/
const RULES = Object.freeze({
const RULES = Object.freeze({
space: { sources: ['nasa-news-releases'], words: ['nasa', 'spacecraft', 'astronaut', 'astronauts', 'orbit', 'orbital', 'rocket', 'mars', 'moon', 'lunar', 'satellite', 'satellites', 'telescope', 'space station', 'asteroid', 'artemis', 'esa', 'spaceflight', 'launch site', 'uk space agency'] },
space: { sources: ['nasa-news-releases'], words: ['nasa', 'spacecraft', 'astronaut', 'astronauts', 'orbit', 'orbital', 'rocket', 'mars', 'moon', 'lunar', 'satellite', 'satellites', 'telescope', 'space station', 'asteroid', 'artemis', 'esa', 'spaceflight', 'launch site', 'uk space agency'] },
- science: { words: ['research', 'researchers', 'scientists', 'scientific', 'science', 'study', 'discovery', 'physics', 'chemistry', 'biology', 'laboratory', 'genome', 'fossil', 'quantum', 'experiment'] },
- health: { sources: ['fda-food-recalls'], words: ['health', 'hospital', 'hospitals', 'nhs', 'disease', 'vaccine', 'vaccines', 'virus', 'outbreak', 'cancer', 'medicine', 'medicines', 'medical', 'patients', 'mental health', 'food safety', 'allergen', 'salmonella', 'listeria', 'recall', 'pharmaceutical', 'care homes', 'gp'] },
+ science: { sources: ['nist-news'], words: ['research', 'researchers', 'scientists', 'scientific', 'science', 'study', 'discovery', 'physics', 'chemistry', 'biology', 'laboratory', 'genome', 'fossil', 'quantum', 'experiment'] },
+ health: { sources: ['fda-food-recalls', 'cdc-newsroom'], words: ['health', 'hospital', 'hospitals', 'nhs', 'disease', 'vaccine', 'vaccines', 'virus', 'outbreak', 'cancer', 'medicine', 'medicines', 'medical', 'patients', 'mental health', 'food safety', 'allergen', 'salmonella', 'listeria', 'recall', 'pharmaceutical', 'care homes', 'gp'] },
climate: { words: ['climate', 'emissions', 'net zero', 'carbon', 'flood', 'flooding', 'floods', 'heatwave', 'drought', 'wildfire', 'wildfires', 'storm', 'environment', 'environmental', 'renewable', 'renewables', 'wind farm', 'solar', 'biodiversity', 'pollution', 'energy transition', 'green'] },
climate: { words: ['climate', 'emissions', 'net zero', 'carbon', 'flood', 'flooding', 'floods', 'heatwave', 'drought', 'wildfire', 'wildfires', 'storm', 'environment', 'environmental', 'renewable', 'renewables', 'wind farm', 'solar', 'biodiversity', 'pollution', 'energy transition', 'green'] },
- business: { words: ['economy', 'economic', 'business', 'businesses', 'trade', 'tariff', 'tariffs', 'market', 'markets', 'investment', 'investors', 'inflation', 'jobs', 'employment', 'industry', 'industrial', 'budget', 'tax', 'taxes', 'bank', 'banks', 'competition', 'merger', 'consumers', 'retail', 'exports', 'growth', 'small firms'] },
+ business: { sources: ['federal-reserve-press'], words: ['economy', 'economic', 'business', 'businesses', 'trade', 'tariff', 'tariffs', 'market', 'markets', 'investment', 'investors', 'inflation', 'jobs', 'employment', 'industry', 'industrial', 'budget', 'tax', 'taxes', 'bank', 'banks', 'competition', 'merger', 'consumers', 'retail', 'exports', 'growth', 'small firms'] },
security: { words: ['cyber', 'cybersecurity', 'cyber security', 'hacker', 'hackers', 'hacked', 'ransomware', 'vulnerability', 'vulnerabilities', 'data breach', 'breach', 'malware', 'phishing', 'ncsc', 'cisa', 'enisa', 'fraud', 'scam', 'scams'] },
security: { words: ['cyber', 'cybersecurity', 'cyber security', 'hacker', 'hackers', 'hacked', 'ransomware', 'vulnerability', 'vulnerabilities', 'data breach', 'breach', 'malware', 'phishing', 'ncsc', 'cisa', 'enisa', 'fraud', 'scam', 'scams'] },
technology: { words: ['technology', 'tech', 'digital', 'artificial intelligence', 'ai', 'software', 'semiconductor', 'semiconductors', 'chips', 'broadband', '5g', 'computing', 'robotics', 'data centre', 'data centres', 'data center', 'innovation', 'startups'] },
technology: { words: ['technology', 'tech', 'digital', 'artificial intelligence', 'ai', 'software', 'semiconductor', 'semiconductors', 'chips', 'broadband', '5g', 'computing', 'robotics', 'data centre', 'data centres', 'data center', 'innovation', 'startups'] },
'internet-policy': { words: ['online safety', 'digital services act', 'digital markets act', 'data protection', 'gdpr', 'privacy', 'net neutrality', 'online platforms', 'ofcom', 'copyright', 'content moderation', 'encryption', 'disinformation', 'age verification'] },
'internet-policy': { words: ['online safety', 'digital services act', 'digital markets act', 'data protection', 'gdpr', 'privacy', 'net neutrality', 'online platforms', 'ofcom', 'copyright', 'content moderation', 'encryption', 'disinformation', 'age verification'] },
@@ -21,7 +21,7 @@ const sharedSeo = require('openvibe-shared/seo');
@@ -21,7 +21,7 @@ const sharedSeo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
// The one-line site summary /llms.txt, /llms-full.txt and the home page all carry.
// The one-line site summary /llms.txt, /llms-full.txt and the home page all carry.
-const SITE_SUMMARY = 'A source-backed publication: stories written by OpenVibe.News editors from source items collected by OpenVibe.Sources. Every paragraph cites the sources it rests on.';
+const SITE_SUMMARY = 'Source-backed news: AI briefs and editors\' stories where every paragraph cites the reports it rests on, a live wire of what public sources (collected by OpenVibe.Sources) report, and topic pages.';
function createDiscoveryRoutes({ config, store, publication, topics }) {
function createDiscoveryRoutes({ config, store, publication, topics }) {
const router = express.Router();
const router = express.Router();
@@ -33,11 +33,17 @@ const access = require('../domain/access');
@@ -33,11 +33,17 @@ const access = require('../domain/access');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { csrfToken, checkCsrf } = require('../auth/forms');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { SITE_SUMMARY } = require('./discovery');
const { SITE_SUMMARY } = require('./discovery');
+const homePage = require('../render/home');
+const { createHome } = require('../domain/home');
+
+// The front page in search results and share cards (≤ 160 characters).
+const HOME_DESCRIPTION = 'News you can check: AI briefs and stories where every paragraph links the reports it rests on, a live wire of public sources, and topic pages.';
const PER_PAGE = 20;
const PER_PAGE = 20;
function createPublicRoutes(ctx) {
function createPublicRoutes(ctx) {
const { config, store, stories, publication, reading, topics, viewers, community, discussion, clusters, briefs } = ctx;
const { config, store, stories, publication, reading, topics, viewers, community, discussion, clusters, briefs } = ctx;
+ const homeData = ctx.home || createHome({ store });
const router = express.Router();
const router = express.Router();
router.use(viewers.middleware({ services: false }));
router.use(viewers.middleware({ services: false }));
@@ -73,24 +79,6 @@ function createPublicRoutes(ctx) {
@@ -73,24 +79,6 @@ function createPublicRoutes(ctx) {
const FEEDS = [{ type: 'rss', label: 'RSS', file: 'feed.xml' }, { type: 'atom', label: 'Atom', file: 'atom.xml' }, { type: 'json', label: 'JSON Feed', file: 'feed.json' }];
const FEEDS = [{ type: 'rss', label: 'RSS', file: 'feed.xml' }, { type: 'atom', label: 'Atom', file: 'atom.xml' }, { type: 'json', label: 'JSON Feed', file: 'feed.json' }];
const feedsFor = (base) => FEEDS.map((f) => ({ type: f.type, label: f.label, href: `${base === '/' ? '' : base}/${f.file}`, title: f.label }));
const feedsFor = (base) => FEEDS.map((f) => ({ type: f.type, label: f.label, href: `${base === '/' ? '' : base}/${f.file}`, title: f.label }));
- /** The product's own words above the front page's stories: only what OpenVibe.News does today. */
- function newsShowcase() {
- return showcase.hero({
- eyebrow: 'OpenVibe.News',
- title: 'News you can', accent: 'check',
- lede: 'Every paragraph links the reports it rests on. Corrections and retractions stay on the record, and anyone can read without an account.',
- actions: [{ label: 'Read the stories', href: '#stories', primary: true }, { label: 'Browse topics', href: '/topics' }],
- }) + showcase.features({
- title: 'How a story is built',
- items: [
- { icon: 'ov:news', title: 'Cited paragraphs', text: 'Each paragraph names the sources behind it, with outlet, authors and date.' },
- { icon: 'ov:history', title: 'Revisions on the record', text: 'Corrections and retractions are kept and shown, never quietly edited away.' },
- { icon: 'ov:search', title: 'Source checks', text: 'When a source changes or is removed upstream, the story says so while editors check it.' },
- { icon: 'ov:community', title: 'Discussion', text: 'Talk about a story on OpenVibe.Community with your OpenVibe account.' },
- ],
- });
- }
-
// A topic page also carries the AI briefs and the wire on it (server/domain/topic-match.js); with nothing on it in
// A topic page also carries the AI briefs and the wire on it (server/domain/topic-match.js); with nothing on it in
// the last 30 days, the latest from the wire overall, said as such.
// the last 30 days, the latest from the wire overall, said as such.
async function topicExtras(topic) {
async function topicExtras(topic) {
@@ -112,6 +100,23 @@ function createPublicRoutes(ctx) {
@@ -112,6 +100,23 @@ function createPublicRoutes(ctx) {
const wire = !topic && page === 1 ? await clusters.wire({ limit: 8 }) : [];
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);
+ // The front page (page 1): what OpenVibe.News is for, then everything it has to read (render/home.js).
+ if (!topic && pager.page === 1) {
+ const [stats, latestBriefs, topicList, tally, outlets] = await Promise.all([
+ homeData.stats(), briefs ? briefs.latest(12) : [], topics.list(), homeData.topicTally(), homeData.outlets(),
+ ]);
+ return send(req, res, 200, {
+ title: null, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,
+ decision: pageDecision(canonical, { empty: total === 0 && !wire.length && !stats.week, query: ['page'] }),
+ canonical, feeds, next: pager.next ? pager.next.href : null,
+ jsonLd: homePage.homeJsonLd({ abs: (p) => publication.abs(p), topics: topicList, outlets }),
+ styles: [showcase.STYLESHEET], bodyClass: 'news-home',
+ body: homePage.homeBody({
+ items, pager, feeds, stats, wire: pages.wireSection(wire), topics: topicList, tally, outlets,
+ briefs: latestBriefs.filter((b) => b.kind === 'summary' && !b.stub && b.paragraphs.length).slice(0, 6),
+ }),
+ }, { cacheable: true });
+ }
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;
send(req, res, 200, {
send(req, res, 200, {
title: topic ? topic.name : null,
title: topic ? topic.name : null,
@@ -123,10 +128,8 @@ function createPublicRoutes(ctx) {
@@ -123,10 +128,8 @@ function createPublicRoutes(ctx) {
jsonLd: topic
jsonLd: topic
? [seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : canonical })))]
? [seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : canonical })))]
: [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.News', url: publication.abs('/') }],
: [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.News', url: publication.abs('/') }],
- // 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 ? await topicExtras(topic) : {}) })
body: topic ? pages.topicPage({ topic, items, pager, feeds, breadcrumbs: crumbs, ...(pager.page === 1 ? await topicExtras(topic) : {}) })
- : (pager.page === 1 ? newsShowcase() : '') + pages.home({ items, pager, topics: await topics.list(), feeds, showcase: pager.page === 1, wire: pages.wireSection(wire), briefs: pager.page === 1 && briefs ? (await briefs.latest(12)).filter((b) => b.kind === 'summary' && !b.stub && b.paragraphs.length).slice(0, 4) : [] }),
+ : pages.home({ items, pager, topics: await topics.list(), feeds }),
}, { cacheable: true });
}, { cacheable: true });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * The front page (/ page 1): what OpenVibe.News is for, then real content only. The numbers, editors' stories, the
+ * latest AI briefs (labelled), the wire, every topic with what was reported on it, the outlets News reads, how it
+ * works, questions and the ways around the site. A section with nothing to show is left out.
+ */
+const ssr = require('openvibe-publishing/ssr');
+const showcase = require('openvibe-shared/showcase');
+const { time, httpHref, storyList } = require('./pages');
+
+const { html: h, raw } = ssr;
+const num = (n) => Number(n || 0).toLocaleString('en-US');
+const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
+const list = (xs) => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`);
+const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1).replace(/\s+\S*$/, '')}…` : s);
+
+/** The questions the front page answers (shown, and given as FAQPage); the outlets one names today's outlets. */
+function faq(outlets) {
+ const names = outlets.map((o) => o.outlet);
+ return [
+ { q: 'Who writes OpenVibe.News?', a: 'Two kinds of text, each labelled. AI briefs are written by OpenVibe.AI from the reports on one subject, with every paragraph linked to the reports it rests on. Stories are written by editors, and every paragraph cites its sources. The wire and the Live page show the outlets\' own headlines, linked to their reports.' },
+ { q: 'Where do the reports come from?', a: `From public feeds that OpenVibe.Sources collects after checking each publisher's terms and robots.txt.${names.length ? ` In the last 30 days: ${list(names)}.` : ''} News keeps headlines, links and short summaries only, and links every report back to its publisher.` },
+ { q: 'Can I change how a brief is told?', a: 'Yes. A brief comes in brief, summary or explained form. When two or more outlets reported on a subject, AI can also compare how each one puts it.' },
+ { q: 'What does Breaking mean?', a: 'On the Live page, Breaking marks a subject that three or more reports covered in the last two hours. The page refreshes itself every minute.' },
+ { q: 'Why are AI briefs not in search results?', a: 'No editor has reviewed them, so they are kept out of search engines until a person does. Editors\' stories and topic pages are indexed.' },
+ { q: 'What happens when a story is wrong?', a: 'Corrections and retractions stay on the record. A story shows its correction history, and a retracted story stays up, labelled, with the reason.' },
+ ];
+}
+
+function statsPanel(stats) {
+ const tiles = [
+ stats.stories ? ['Stories published', stats.stories] : null,
+ ['Reports in the last day', stats.day],
+ ['Reports this week', stats.week],
+ stats.outlets ? ['Outlets read this month', stats.outlets] : null,
+ stats.briefs ? ['AI briefs', stats.briefs] : null,
+ stats.stories && stats.topics ? ['Topics', stats.topics] : null, // with the stories tile, six in two columns
+ ].filter(Boolean);
+ if (!stats.week && !stats.stories) return '';
+ return String(h`<div class="nh-panel"><h2 class="nh-panel-h">The newsroom today</h2><dl class="nh-stats">${tiles.map(([k, v]) => h`<div><dt>${k}</dt><dd>${num(v)}</dd></div>`)}</dl>
+<p class="nh-live"><span class="live-dot" aria-hidden="true"></span> <a href="/live">Watch reports arrive live</a></p></div>`);
+}
+
+function briefCard(b) {
+ const first = b.paragraphs[0] ? b.paragraphs[0].text : '';
+ return h`<li class="nh-card"><p class="nh-kicker"><span class="badge badge-ai">AI brief</span> from ${plural(b.item_count, 'report')}</p>
+<h3><a href="/briefs/${b.cluster_id}">${b.headline || b.label}</a></h3><p class="meta">${time(b.window_end)}</p>${first ? h`<p class="nh-sum">${clip(first, 260)}</p>` : ''}</li>`;
+}
+
+/**
+ * o: items (editors' stories, page 1), pager, feeds, stats, briefs, wire (pages.wireSection HTML), topics (active),
+ * tally (Map slug → { subjects, reports, newest }), outlets
+ */
+function homeBody(o) {
+ const { items, pager, feeds, stats, briefs = [], wire = '', topics = [], tally = new Map(), outlets = [] } = o;
+ const questions = faq(outlets);
+ return String(h`${raw(showcase.hero({
+ eyebrow: 'OpenVibe.News',
+ title: 'News you can', accent: 'check',
+ lede: 'Every paragraph links the reports it rests on. Read AI briefs that cite every claim, watch reports arrive live, and check any of it yourself. No account needed.',
+ actions: [{ label: 'Read the latest', href: '#latest', primary: true }, { label: 'Live', href: '/live' }, { label: 'Browse topics', href: '#topics' }],
+ aside: stats ? { html: statsPanel(stats) } : undefined,
+ }))}
+<div id="latest"></div>
+${items.length ? h`<section class="sc-sec nh-sec" id="stories" aria-labelledby="nh-stories-h"><h2 id="nh-stories-h">Latest stories</h2>
+${feeds.length ? h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}">${f.label}</a> `)}</p>` : ''}
+${storyList(items, '')}${raw(ssr.paginationHtml(pager))}</section>` : ''}
+${briefs.length ? h`<section class="sc-sec nh-sec" aria-labelledby="nh-briefs-h"><h2 id="nh-briefs-h">${items.length ? 'AI briefs' : 'Latest AI briefs'}</h2>
+<p class="sc-lede">What the reports on each subject say, written by OpenVibe.AI with every paragraph linked to its reports. Not written or reviewed by an editor.</p>
+<ol class="nh-cards">${briefs.map(briefCard)}</ol><p class="nh-more"><a href="/briefs">All AI briefs</a></p></section>` : ''}
+${wire ? h`<div class="sc-sec nh-sec nh-wire">${raw(wire)}<p class="nh-more"><a href="/live">Live: the newest reports, refreshed every minute</a></p></div>` : ''}
+${topics.length ? h`<section class="sc-sec nh-sec" id="topics" aria-labelledby="nh-topics-h"><h2 id="nh-topics-h">Topics</h2><p class="sc-lede">What the sources reported on each topic in the last 30 days.</p>
+<ul class="nh-topics">${topics.map((t) => {
+ const x = tally.get(t.slug);
+ const item = x && x.newest && x.newest.item;
+ const href = item ? httpHref(item.url) : null;
+ return h`<li><h3><a href="/topics/${t.slug}">${t.name}</a></h3>${x ? h`<p class="nh-count">${plural(x.subjects, 'subject')}, ${plural(x.reports, 'report')}</p>` : ''}
+${item ? h`<p class="nh-latest">${href ? h`<a href="${href}" rel="nofollow noopener" target="_blank">${item.headline}</a>` : item.headline} <span class="meta">${item.outlet}</span></p>` : (t.description ? h`<p class="nh-latest meta">${t.description}</p>` : '')}</li>`;
+ })}</ul></section>` : ''}
+${outlets.length ? h`<section class="sc-sec nh-sec" aria-labelledby="nh-outlets-h"><h2 id="nh-outlets-h">The sources we read</h2>
+<p class="sc-lede">Public feeds, collected by OpenVibe.Sources with each publisher's terms and robots.txt checked. Headlines, links and short summaries only.</p>
+<ul class="nh-outlets">${outlets.map((x) => h`<li><b>${x.outlet}</b><span class="nh-count">${plural(x.week, 'report')} this week · ${num(x.month)} in 30 days</span>${x.latest ? h`<span class="nh-latest">Newest: ${httpHref(x.latest.url) ? h`<a href="${httpHref(x.latest.url)}" rel="nofollow noopener" target="_blank">${x.latest.headline}</a>` : x.latest.headline}</span>` : ''}</li>`)}</ul></section>` : ''}
+${raw(showcase.features({
+ title: 'How a story is built',
+ items: [
+ { icon: 'ov:news', title: 'Cited paragraphs', text: 'Each paragraph names the sources behind it, with outlet, authors and date.' },
+ { icon: 'ov:history', title: 'Revisions on the record', text: 'Corrections and retractions are kept and shown, never quietly edited away.' },
+ { icon: 'ov:search', title: 'Source checks', text: 'When a source changes or is removed upstream, the story says so while editors check it.' },
+ { icon: 'ov:community', title: 'Discussion', text: 'Talk about a story on OpenVibe.Community with your OpenVibe account.' },
+ ],
+ }))}
+${raw(showcase.steps({
+ title: 'From report to brief',
+ items: [
+ { title: 'Public feeds come in', text: 'OpenVibe.Sources reads each publisher\'s own feed, within its terms, and hands News the headline, link and short summary.' },
+ { title: 'Reports are grouped by subject', text: 'Reports that share names and words become one subject. Every grouping can be explained, and editors can split it.' },
+ { title: 'AI writes a cited brief', text: 'Once enough reports cover a subject, OpenVibe.AI summarises them. Every paragraph links the reports it used.', href: '/briefs' },
+ { title: 'Editors write the stories', text: 'Stories cite every paragraph. Corrections and retractions stay on the record.' },
+ ],
+ }))}
+<section class="sc-sec nh-sec nh-faq" aria-labelledby="nh-faq-h"><h2 id="nh-faq-h">Questions</h2>${questions.map((f) => h`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>
+<section class="sc-sec nh-sec" aria-labelledby="nh-links-h"><h2 id="nh-links-h">Find your way around</h2><ul class="nh-links">
+<li><a href="/live"><b>Live</b><small>The newest reports as they arrive, with what is breaking.</small></a></li>
+<li><a href="/briefs"><b>AI briefs</b><small>Every subject's brief, in three tones.</small></a></li>
+<li><a href="/wire"><b>The wire</b><small>Every subject the sources reported, grouped.</small></a></li>
+<li><a href="/topics"><b>Topics</b><small>${plural(topics.length, 'topic')}, each with its briefs and wire.</small></a></li>
+<li><a href="/feed.xml"><b>Feeds</b><small>RSS, Atom and JSON Feed for the stories and each topic.</small></a></li>
+<li><a href="/updates"><b>What shipped</b><small>Every change to OpenVibe.News itself.</small></a></li>
+</ul></section>`);
+}
+
+/** Structured data for the front page: the site, its topic pages as an ItemList (indexed pages), the questions shown. */
+function homeJsonLd({ abs, topics, outlets }) {
+ const out = [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.News', url: abs('/') }];
+ if (topics.length) out.push({ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Topics on OpenVibe.News', numberOfItems: topics.length, itemListElement: topics.map((t, i) => ({ '@type': 'ListItem', position: i + 1, name: t.name, url: abs(`/topics/${t.slug}`) })) });
+ out.push({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq(outlets).map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) });
+ return out;
+}
+
+module.exports = { homeBody, homeJsonLd, faq };
@@ -35,21 +35,15 @@ function feedLinksHtml(feeds) {
@@ -35,21 +35,15 @@ function feedLinksHtml(feeds) {
return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}">${f.label}</a> `)}</p>`;
return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}">${f.label}</a> `)}</p>`;
}
}
-// 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, wire = '', briefs = [] }) {
- return String(h`${showcase ? h`<header class="site-header"><h2>Latest stories</h2>
-${raw(feedLinksHtml(feeds))}
-</header>` : h`<header class="site-header">
+// The front page's later pages (page 1 is render/home.js): the site's own h1 and lede, then the stories.
+function home({ items, pager, topics, feeds }) {
+ return String(h`<header class="site-header">
<h1>OpenVibe.News</h1>
<h1>OpenVibe.News</h1>
<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>`}
-${items.length || !showcase ? h`<section id="stories" aria-label="Stories">${storyList(items, 'No stories on this page.')}</section>
-${raw(ssr.paginationHtml(pager))}` : ''}
-${raw(briefsSection(briefs, { lead: showcase && !items.length }))}
-${showcase && !items.length && !briefs.length && !wire ? h`<p class="empty">The first reports are on their way: the AI briefs and the wire fill in as the sources report.</p>` : ''}
-${raw(wire)}
+</header>
+<section id="stories" aria-label="Stories">${storyList(items, 'No stories on this page.')}</section>
+${raw(ssr.paginationHtml(pager))}
${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>` : ''}`);
}
}
@@ -46,9 +46,9 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -46,9 +46,9 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
const list = await t.get('/briefs');
const list = await t.get('/briefs');
assert.ok(list.text.includes(`/briefs/${clusterId}`));
assert.ok(list.text.includes(`/briefs/${clusterId}`));
const home = await t.get('/');
const home = await t.get('/');
- // With no editor's story, the briefs are the front page's latest stories: under "Latest stories", labelled AI.
- assert.ok(home.text.includes('<h2>Latest stories</h2>') && home.text.includes(`href="/briefs/${clusterId}"`), 'the front page leads with the latest briefs');
- assert.ok(home.text.includes('Written by AI, not by an editor; stories by editors appear here first') && home.text.includes('<span class="badge">AI</span>'));
+ // With no editor's story, the briefs lead the front page: "Latest AI briefs", each labelled AI.
+ assert.ok(home.text.includes('>Latest AI briefs</h2>') && home.text.includes(`href="/briefs/${clusterId}"`), 'the front page leads with the latest briefs');
+ assert.ok(home.text.includes('Not written or reviewed by an editor') && home.text.includes('<span class="badge badge-ai">AI brief</span>'));
assert.ok(!home.text.includes('No stories have been published yet'));
assert.ok(!home.text.includes('No stories have been published yet'));
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * The front page (/ page 1): its own description and one h1, the numbers (reports today and this week, outlets of
+ * the last 30 days), the wire, every topic with what was reported on it, the outlets News reads with their newest
+ * report, the questions shown and given as FAQPage (the outlets answer names today's outlets), WebSite + the topics'
+ * ItemList, and the links around the site. The new sources' reports are placed in their topics.
+ */
+const assert = require('assert');
+const { boot, check, done, launchReports } = require('./helpers/boot');
+const { topicsFor } = require('../server/domain/topic-match');
+const { createHome } = require('../server/domain/home');
+
+const ld = (text) => [...text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+
+(async () => {
+ const t = await boot();
+ launchReports(t.sources);
+ t.sources.addSource('nist-news', { name: 'NIST news' });
+ t.sources.addItem({ source_key: 'nist-news', title: 'New chip-based tape measure for light', url: 'https://www.nist.gov/news-events/news/2026/09/tape-measure', published_at: '2026-09-22T08:00:00Z' });
+ await t.pull();
+ try {
+ let home;
+ await check('the front page: its own description, one h1, the numbers', async () => {
+ home = await t.get('/');
+ assert.strictEqual(home.status, 200);
+ assert.match(home.text, /<title>OpenVibe\.News: source-backed news, every claim cited<\/title>/);
+ const desc = /<meta name="description" content="([^"]*)"/.exec(home.text)[1];
+ assert.ok(desc.length <= 160 && /every paragraph links the reports/.test(desc), desc);
+ assert.strictEqual((home.text.match(/<h1\b/g) || []).length, 1);
+ const stats = await createHome({ store: t.ctx.store }).stats();
+ assert.deepStrictEqual({ day: stats.day, week: stats.week, outlets: stats.outlets, stories: stats.stories }, { day: 4, week: 4, outlets: 4, stories: 0 });
+ assert.match(home.text, /<dt>Reports this week<\/dt><dd>4<\/dd>/);
+ assert.ok(!home.text.includes('<dt>Stories published</dt>'), 'no story yet: no stories tile');
+ });
+
+ await check('the wire, the topics with what was reported on them, the outlets with their newest report', async () => {
+ const text = home.text;
+ assert.ok(text.includes('From the wire') && text.includes('Europa Clipper'));
+ const space = (/<li><h3><a href="\/topics\/space">Space<\/a><\/h3>[\s\S]*?<\/li>/.exec(text) || [''])[0];
+ assert.match(space, /1 subject, 3 reports/);
+ const science = (/<li><h3><a href="\/topics\/science">Science<\/a><\/h3>[\s\S]*?<\/li>/.exec(text) || [''])[0];
+ assert.ok(science.includes('New chip-based tape measure for light'), 'NIST places its reports in Science');
+ assert.ok(text.includes('<b>NIST news</b>') && text.includes('<b>Wire A</b>'));
+ assert.match(text, /<b>Wire A<\/b><span class="nh-count">1 report this week · 1 in 30 days<\/span>/);
+ });
+
+ await check('questions, links and structured data', async () => {
+ const text = home.text;
+ const blocks = ld(text);
+ assert.ok(blocks.find((b) => b['@type'] === 'WebSite'));
+ const topics = blocks.find((b) => b['@type'] === 'ItemList');
+ assert.ok(topics.itemListElement.some((x) => /\/topics\/space$/.test(x.url)));
+ const faq = blocks.find((b) => b['@type'] === 'FAQPage');
+ for (const q of faq.mainEntity) assert.ok(text.includes(`<summary>${q.name}</summary>`), q.name);
+ assert.ok(faq.mainEntity.find((q) => q.name === 'Where do the reports come from?').acceptedAnswer.text.includes('NIST news'));
+ ['/live', '/briefs', '/wire', '/topics', '/feed.xml', '/updates'].forEach((p) => assert.ok(text.includes(`href="${p}"`), p));
+ });
+
+ await check('the new sources place their reports in their topics', async () => {
+ assert.deepStrictEqual(topicsFor({ label: 'tape measure', headlines: [], sources: ['nist-news'] }), ['science']);
+ assert.deepStrictEqual(topicsFor({ label: 'newsroom', headlines: [], sources: ['cdc-newsroom'] }), ['health']);
+ assert.deepStrictEqual(topicsFor({ label: 'enforcement action', headlines: [], sources: ['federal-reserve-press'] }), ['business']);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -16,7 +16,8 @@ const { boot, check, done, launchReports } = require('./helpers/boot');
@@ -16,7 +16,8 @@ const { boot, check, done, launchReports } = require('./helpers/boot');
assert.ok(await count('news_topics') >= 10);
assert.ok(await count('news_topics') >= 10);
for (const table of ['news_stories', 'news_source_items', 'news_story_clusters', 'news_story_revisions', 'news_editorial_flags']) assert.strictEqual(await count(table), 0, table);
for (const table of ['news_stories', 'news_source_items', 'news_story_clusters', 'news_story_revisions', 'news_editorial_flags']) assert.strictEqual(await count(table), 0, table);
const home = await t.get('/');
const home = await t.get('/');
- assert.match(home.text, /The first reports are on their way/, 'an empty front page says what comes, not that nothing was published');
+ assert.ok(home.text.includes('href="/topics/space"') && !/have been published yet/.test(home.text), 'an empty front page shows its topics, not that nothing was published');
+ assert.ok(!home.text.includes('The newsroom today'), 'no reports yet: no numbers panel');
});
});
let reports;
let reports;
@@ -93,7 +93,8 @@ const count = (html, re) => (html.match(re) || []).length;
@@ -93,7 +93,8 @@ const count = (html, re) => (html.match(re) || []).length;
const head = home.slice(0, home.indexOf('</head>'));
const head = home.slice(0, home.indexOf('</head>'));
assert.strictEqual(count(head, /<title>/g), 1);
assert.strictEqual(count(head, /<title>/g), 1);
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.news/">'));
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.news/">'));
- assert.strictEqual(count(head, /<script type="application\/ld\+json">/g), 2, 'the home WebSite JSON-LD and the AI-summary WebPage JSON-LD');
+ assert.strictEqual(count(head, /<script type="application\/ld\+json">/g), 4, 'the home WebSite, the topics ItemList, the FAQPage and the AI-summary WebPage JSON-LD');
+ assert.ok(head.includes('"@type":"ItemList"') && head.includes('"@type":"FAQPage"'), 'the topics and the questions shown');
assert.ok(head.includes('"@type":"WebSite"'), 'the site JSON-LD');
assert.ok(head.includes('"@type":"WebSite"'), 'the site JSON-LD');
assert.ok(head.includes('"@type":"WebPage"'), 'the AI summary WebPage JSON-LD');
assert.ok(head.includes('"@type":"WebPage"'), 'the AI summary WebPage JSON-LD');
assert.ok(head.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml" title="Atom">'));
assert.ok(head.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml" title="Atom">'));
@@ -44,7 +44,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -44,7 +44,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(full.headers.get('content-type'), /^text\/plain/);
assert.match(full.headers.get('content-type'), /^text\/plain/);
assert.strictEqual(full.headers.get('cache-control'), 'public, max-age=3600, stale-while-revalidate=3600');
assert.strictEqual(full.headers.get('cache-control'), 'public, max-age=3600, stale-while-revalidate=3600');
assert.match(full.text, /^# OpenVibe\.News/);
assert.match(full.text, /^# OpenVibe\.News/);
- assert.match(full.text, /source-backed publication/);
+ assert.match(full.text, /Source-backed news: AI briefs and editors' stories/);
assert.ok(Buffer.byteLength(full.text, 'utf8') <= 512 * 1024, 'no stories: within maxBytes');
assert.ok(Buffer.byteLength(full.text, 'utf8') <= 512 * 1024, 'no stories: within maxBytes');
const idx = (await t.get('/sitemap.xml')).text;
const idx = (await t.get('/sitemap.xml')).text;
assert.match(idx, /<sitemapindex/);
assert.match(idx, /<sitemapindex/);
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 27, // measured 24.4 with the home showcase (17.4 before it; fresh database): raised as a decision, as Blog did
- htmlBrotliKB: 7.5, // 6.3 (4.5 before the showcase)
+ htmlRawKB: 36, // 33.2 on 2026-10-22: the front page's topics (12 cards), steps, questions (and their FAQPage and topics ItemList JSON-LD) and links; 24.4 with the home showcase (17.4 before it; fresh database)
+ htmlBrotliKB: 9, // 8.0 (2026-10-22 the richer front page); 6.3 (4.5 before the showcase)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (news.css + showcase.css, home page 1 only)
cssFiles: 2, // 2 (news.css + showcase.css, home page 1 only)
- cssRawKB: 18.5, // 15.5 (5.2 before showcase.css); 18.2 with the front page aligned to the showcase (2026-10-15)
- cssBrotliKB: 4.5, // 3.9 (1.4 before showcase.css)
+ cssRawKB: 24, // 22.2 on 2026-10-22 (the front page's numbers, brief cards, topics, outlets, questions and links); 18.2 with the front page aligned to the showcase (2026-10-15); 15.5 (5.2 before showcase.css)
+ cssBrotliKB: 5.5, // 5.2 (2026-10-22); 3.9 (1.4 before showcase.css)
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};
@@ -163,7 +163,7 @@ const BODY = [
@@ -163,7 +163,7 @@ const BODY = [
assert.match(home.text, /<link rel="stylesheet" href="[^"]*showcase\.css[^"]*">/);
assert.match(home.text, /<link rel="stylesheet" href="[^"]*showcase\.css[^"]*">/);
assert.strictEqual((home.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on the home page');
assert.strictEqual((home.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on the home page');
assert.ok(home.text.indexOf('class="sc-hero') < home.text.indexOf('Europa Clipper launches for Jupiter'), 'the showcase sits above the stories');
assert.ok(home.text.indexOf('class="sc-hero') < home.text.indexOf('Europa Clipper launches for Jupiter'), 'the showcase sits above the stories');
- assert.match(home.text, /<h2>Latest stories<\/h2>/);
+ assert.match(home.text, /<h2 id="nh-stories-h">Latest stories<\/h2>/);
const topic = await t.get('/topics/space');
const topic = await t.get('/topics/space');
assert.match(topic.text, /Europa Clipper launches for Jupiter/);
assert.match(topic.text, /Europa Clipper launches for Jupiter/);
assert.ok(!topic.text.includes('sc-hero') && !topic.text.includes('showcase.css'), 'a topic page has no showcase');
assert.ok(!topic.text.includes('sc-hero') && !topic.text.includes('showcase.css'), 'a topic page has no showcase');