openvibers/openvibe.news Commits

Render every page through openvibe-publishing/layout on shared shell

OP OpenVibers committed 4 Oct 2026 · cd8d193bad · 7 files, +162 −76

Asked: T11 step 2 second batch, s5 — OpenVibe.News: render every page through the new openvibe-publishing/layout helper (built on openvibe-shared/shell page()). ONE pull request from current origin/main (git fetch first). News is the first of the seven publication sites (Blog, Coupons, Deals, Reviews, Tra…
Harness job news-t11-step-2-second-batch-121449 (plan T11).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..37b0d9e
---- /dev/null
++++ b/CHANGELOG.md
@@@ -0,0 +1,5 @@
++# Changelog
++
++## Unreleased
++
++- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision. The shell adds `web-runtime.js`, so the home page's JS budget is raised to 5 files, 245 KB, 59 KB brotli.
ddiff --git a/package-lock.json b/package-lock.json
iindex a3b6c7f..bfec24c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,9 +16,9 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.2.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1163,9 +1163,8 @@
}
},
"node_modules/openvibe-publishing": {
-- "version": "1.1.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
-- "integrity": "sha512-bJsVl82hDivazoiM/np9ckKJE4V5F4OxyIiDwB6xuhK8M6P42sGU+V88rFCkyiI4BVQiKv9xs4GVx8Fj3pyjsg==",
++ "version": "1.2.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.2.0",
"license": "MIT",
"engines": {
"node": ">=20"
@@@ -1210,9 +1209,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.5.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
-- "integrity": "sha512-fHuUS6SXM7ezatfAyqLm1V8B6puV9YXSnz6aG6XQbWx28qghRCCHeUF3lr4nxiCGdn9SyikYtIyLGL5r1ycp9Q==",
++ "version": "2.6.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
++ "integrity": "sha512-IKKvWTM/9UonQ4msrzM4yir0QcTUNbhyqeN1r5ywWavU4VTsL1sI5QdHQlxge7BAsMrQ4OvMEPlHjuAfUJM3rg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 2ce6c6d..ac85733 100644
---- a/package.json
++++ b/package.json
@@@ -30,9 +30,9 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.2.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
},
"devDependencies": {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex f428e35..ae63ab4 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -1,25 +1,27 @@
'use strict';
/**
-- * Page shell. Every page is server-rendered through this and is complete without JavaScript:
++ * Page shell. Every page is server-rendered through openvibe-publishing/layout (openvibe-shared/shell
++ * page()) and is complete without JavaScript:
* - <head>: title, description, canonical and robots from the indexability gate's decision
-- * (openvibe-publishing/seo metaTags — there is no default that makes a page indexable),
-- * Open Graph/Twitter, JSON-LD, feed links, the shared app icon
-- * - the OpenVibe Frame: navbar.js and theme-loader.js from the Network (progressive), a
-- * <noscript> navigation bar and the server-rendered shared footer (openvibe-shared)
++ * (there is no default that makes a page indexable), Open Graph/Twitter, JSON-LD, article
++ * times, prev/next, feed links, the shared app icon, the site stylesheet and the boost marker
++ * - the OpenVibe Frame: theme-loader, web runtime, navbar and footer from the Network
++ * (progressive), a <noscript> navigation bar and the server-rendered shared footer
*/
const crypto = require('crypto');
--const ovServe = require('openvibe-shared/serve');
const fs = require('fs');
const path = require('path');
--const seo = require('openvibe-publishing/seo');
--const { escapeHtml: esc } = require('openvibe-publishing/ssr');
--const appIcon = require('openvibe-shared/app-icon');
++const layout = require('openvibe-publishing/layout');
const frame = require('openvibe-shared/frame');
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.News';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const NAV_LINKS = [
++ { label: 'News', href: '/' },
++ { label: 'Topics', href: '/topics' },
++];
const hashes = new Map();
function assetVersion(rel) {
@@@ -43,29 +45,13 @@ function setRelease(id) { if (id) RELEASE = String(id); }
*/
function renderPage(o) {
if (!o.decision) throw new TypeError('renderPage needs the gate decision');
-- const head = seo.metaTags({
-- title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,
-- description: o.description || 'Source-backed news: every claim cites the reports it rests on.',
-- decision: o.decision,
-- canonical: o.canonical,
-- type: o.type || 'website',
-- siteName: SITE_NAME,
-- image: o.image || undefined,
-- author: o.author || undefined,
-- prev: o.prev || undefined,
-- next: o.next || undefined,
-- jsonLd: (o.jsonLd || []).filter(Boolean),
-- });
const viewer = o.viewer || { kind: 'anonymous' };
const signedIn = viewer.kind === 'user';
const loginNext = encodeURIComponent(o.path || '/');
const nav = {
service: 'news',
apiBase: NETWORK_URL,
-- links: [
-- { label: 'News', href: '/' },
-- { label: 'Topics', href: '/topics' },
-- ],
++ links: NAV_LINKS,
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
@@@ -80,42 +66,34 @@ function renderPage(o) {
const account = signedIn
? `${o.editor ? '<a href="/edit">Editor desk</a> · ' : ''}<a href="/auth/logout?next=${loginNext}">Sign out</a>`
: `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
-- return `<!DOCTYPE html>
--<html lang="${esc(o.lang || 'en')}">
--<head>
--<meta charset="utf-8">
--<meta name="viewport" content="width=device-width, initial-scale=1">
--${head}
--${o.published ? `<meta property="article:published_time" content="${esc(o.published)}">` : ''}
--${o.modified ? `<meta property="article:modified_time" content="${esc(o.modified)}">` : ''}
--${seo.feedLinks(o.feeds || [])}
--${appIcon.headTags({ site: 'news' })}
--<link rel="stylesheet" href="${asset('css/news.css')}">
--<script src="${ovServe.url('theme-loader.js')}" defer></script>
--<script src="${ovServe.url('navbar.js')}" defer></script>
--<script src="${ovServe.url('footer.js')}" defer></script>
--<meta name="ov-boost" content="news@${esc(RELEASE)}">
--<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>
--</head>
--<body class="${esc(o.bodyClass || '')}">
--<a class="skip" href="#main">Skip to content</a>
--<div id="navbar-mount"></div>
--${frame.noscriptNav({ name: SITE_NAME, home: '/', links: [{ label: 'News', href: '/' }, { label: 'Topics', href: '/topics' }] })}
--<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
--<main id="main" class="page">
--${o.body || ''}
--${o.path === '/' ? frame.shipped({ service: 'news', title: `Recently shipped on ${SITE_NAME}` }) : ''}
--</main>
--${frame.footer(footer)}
--<script>
--window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
--document.addEventListener('DOMContentLoaded', function () {
-- try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the Frame is optional */ }
-- try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }
--});
--</script>
--</body>
--</html>`;
++ return layout.renderDocument({
++ site: 'news',
++ siteName: SITE_NAME,
++ lang: o.lang,
++ title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,
++ description: o.description || 'Source-backed news: every claim cites the reports it rests on.',
++ canonical: o.canonical,
++ decision: o.decision,
++ type: o.type || 'website',
++ image: o.image,
++ author: o.author,
++ jsonLd: o.jsonLd,
++ feeds: o.feeds,
++ published: o.published,
++ modified: o.modified,
++ prev: o.prev,
++ next: o.next,
++ navbar: nav,
++ footer,
++ navLinks: NAV_LINKS,
++ home: '/',
++ css: asset('css/news.css'),
++ release: RELEASE,
++ account,
++ body: o.body,
++ shipped: o.path === '/' ? frame.shipped({ service: 'news', title: `Recently shipped on ${SITE_NAME}` }) : '',
++ bodyClass: o.bodyClass,
++ });
}
module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL };
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex adc0cd1..e1bf444 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -3,10 +3,33 @@
* The page shell (server/render/layout.js): every rendered page carries the boost marker (its content
* is the release the page was built from) and the shared boost script tag pointed at <main id="main">,
* so the site moves between its pages without a reload (openvibe-shared/boost, plan T11). The shared
-- * navbar's sign-in is a {path} template so it returns to whatever page is showing.
++ * navbar's sign-in is a {path} template so it returns to whatever page is showing. The document is
++ * openvibe-publishing/layout's (openvibe-shared/shell page()): one title, the canonical and robots
++ * from the gate's decision, the JSON-LD, feeds, stylesheet, the Frame and the footer's init.
*/
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
++const { renderPage } = require('../server/render/layout');
++
++const PARA = 'Officials confirmed on Tuesday that the regional water authority will open two new treatment plants next spring, adding capacity for roughly 400,000 residents across the valley and ending the seasonal restrictions of recent summers. The authority said construction is on schedule and within the approved budget.';
++
++/** A published story resting on these Sources items (cited in its one paragraph). */
++async function storyOn(t, headline, items) {
++ let r = await t.api('/stories', { json: { headline } });
++ assert.strictEqual(r.status, 201, r.text);
++ const story = r.json().story;
++ for (const it of items) {
++ r = await t.api(`/stories/${story.id}/sources`, { json: { item: it.id } });
++ assert.ok(r.status === 200 || r.status === 201, r.text);
++ }
++ r = await t.api(`/stories/${story.id}/revisions`, { json: { body: `${PARA} [${items.map((_, i) => i + 1).join(', ')}]`, expected_revision: 0 } });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await t.api(`/stories/${story.id}/publish`, { json: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ return story;
++}
++
++const count = (html, re) => (html.match(re) || []).length;
(async () => {
const t = await boot();
@@@ -26,6 +49,49 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok(home.text.includes('"loginUrl":"/auth/login?next={path}"'), 'loginUrl is a {path} template');
});
++ await check('a page needs the gate decision: there is no default that makes it indexable', async () => {
++ assert.throws(() => renderPage({ title: 'x', body: '', config: { baseUrl: 'https://openvibe.news' } }), TypeError);
++ });
++
++ await check('the document head and frame come from openvibe-publishing/layout, robots from the decision', async () => {
++ t.sources.addSource('alpha-news', { name: 'Alpha News' });
++ t.sources.addSource('beta-daily', { name: 'Beta Daily' });
++ const a = t.sources.addItem({ source_key: 'alpha-news', title: 'Water agency says two plants will open', url: 'https://alpha.example/a/water' });
++ const b = t.sources.addItem({ source_key: 'beta-daily', title: 'Budget vote clears way for valley water projects', url: 'https://beta.example/b/budget-vote' });
++ await t.pull();
++ const good = await storyOn(t, 'Two treatment plants to open next spring', [a, b]);
++ const thin = await storyOn(t, 'Single-source water story', [a]);
++ for (const [story, headline, robots] of [[good, 'Two treatment plants to open next spring', 'index, follow'], [thin, 'Single-source water story', 'noindex, follow']]) {
++ const r = await t.get(`/stories/${story.slug}`);
++ assert.strictEqual(r.status, 200, r.text);
++ const html = r.text;
++ const head = html.slice(0, html.indexOf('</head>'));
++ const body = html.slice(html.indexOf('</head>'));
++ assert.strictEqual(count(html, /<title>/g), 1, 'exactly one <title>');
++ assert.ok(head.includes(`<title>${headline} · OpenVibe.News</title>`), 'the composed title');
++ assert.ok(head.includes(`<link rel="canonical" href="https://openvibe.news/stories/${story.slug}">`), 'the canonical');
++ assert.strictEqual(count(head, /<meta name="robots"/g), 1, 'one robots meta');
++ assert.ok(head.includes(`<meta name="robots" content="${robots}">`), `${story.slug}: robots ${robots} from the decision`);
++ assert.ok(count(head, /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD');
++ assert.ok(head.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml" title="Atom">'), 'Atom feed link');
++ assert.ok(head.includes('<link rel="alternate" type="application/feed+json" href="/feed.json" title="JSON Feed">'), 'JSON feed link');
++ assert.ok(/<link rel="stylesheet" href="\/css\/news\.css\?v=[0-9a-f]+">/.test(head), 'the news stylesheet');
++ assert.ok(head.includes('<meta property="article:published_time"'), 'article published time');
++ assert.ok(body.includes('<div id="navbar-mount"></div>'), 'the navbar mount');
++ assert.ok(body.includes('<nav aria-label="Site"'), 'the noscript navigation');
++ assert.ok(body.includes('id="ov-footer"'), 'the server-rendered footer');
++ assert.ok(body.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), 'the footer is initialised');
++ }
++ const home = (await t.get('/')).text;
++ 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), 1, 'one JSON-LD script on the home page');
++ 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/feed+json" href="/feed.json" title="JSON Feed">'));
++ assert.ok(home.includes('Recently shipped on OpenVibe.News'), 'the shipped line stays on the home page');
++ });
++
await t.close();
done();
})();
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 8013001..74c8179 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -14,9 +14,9 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
htmlRawKB: 22, // measured 17.4 (fresh database)
htmlBrotliKB: 5.5, // 4.5
-- jsFiles: 5, // 4
-- jsRawKB: 240, // 212.2
-- jsBrotliKB: 55, // 49.9
++ jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
++ jsRawKB: 245, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
++ jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 1
cssRawKB: 6.5, // 5.2
cssBrotliKB: 2, // 1.4
ddiff --git a/test/zz-capture.tmp.js b/test/zz-capture.tmp.js
nnew file mode 100644
iindex 0000000..298f205
---- /dev/null
++++ b/test/zz-capture.tmp.js
@@@ -0,0 +1,38 @@
++'use strict';
++const { boot } = require('./helpers/boot');
++const PARA = 'Officials confirmed on Tuesday that the regional water authority will open two new treatment plants next spring, adding capacity for roughly 400,000 residents across the valley and ending the seasonal restrictions of recent summers. The authority said construction is on schedule and within the approved budget.';
++async function storyOn(t, headline, items) {
++ let r = await t.api('/stories', { json: { headline } });
++ const story = r.json().story;
++ for (const it of items) await t.api(`/stories/${story.id}/sources`, { json: { item: it.id } });
++ await t.api(`/stories/${story.id}/revisions`, { json: { body: `${PARA} [${items.map((_, i) => i + 1).join(', ')}]`, expected_revision: 0 } });
++ await t.api(`/stories/${story.id}/publish`, { json: {} });
++ return story;
++}
++const tags = (html) => {
++ const head = html.slice(0, html.indexOf('</head>'));
++ const out = head.match(/<(meta|link|script|title)\b[^>]*>/g) || [];
++ const body = html.slice(html.indexOf('</head>'));
++ const bodyTags = body.match(/<(script|noscript|main|div id="navbar-mount"|footer|nav)\b[^>]*>/g) || [];
++ return out.join('\n') + '\n--- body ---\n' + bodyTags.join('\n');
++};
++(async () => {
++ const t = await boot();
++ const S = t.sources;
++ S.addSource('alpha-news', { name: 'Alpha News' });
++ S.addSource('beta-daily', { name: 'Beta Daily' });
++ const a = S.addItem({ source_key: 'alpha-news', title: 'Water agency says two plants will open', url: 'https://alpha.example/a/water' });
++ const b = S.addItem({ source_key: 'beta-daily', title: 'Budget vote clears way for valley water projects', url: 'https://beta.example/b/budget-vote' });
++ await t.pull();
++ const good = await storyOn(t, 'Two treatment plants to open next spring', [a, b]);
++ const thin = await storyOn(t, 'Single-source water story', [a]);
++ const fs = require('fs');
++ const dir = process.env.CAP_DIR;
++ for (const [name, p] of [['indexable', `/stories/${good.slug}`], ['noindex', `/stories/${thin.slug}`], ['home', '/']]) {
++ const r = await t.get(p);
++ fs.writeFileSync(`${dir}/${name}.html`, r.text);
++ fs.writeFileSync(`${dir}/${name}.tags`, tags(r.text));
++ }
++ await t.close();
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });