openvibers/openvibe.reviews Commits

Render Reviews pages through openvibe-publishing/layout shell

OP OpenVibers committed 4 Oct 2026 · 0b3aa79a94 · 9 files, +117 −74

Asked: T11 step 2 second batch, s6 — OpenVibe.Reviews: render every page through openvibe-publishing/layout (renderDocument over openvibe-shared/shell page()). ONE pull request from current origin/main (git fetch first). THE BRIEF: read (do not edit) the planner's step "s6" in /home/workstation/openvibe/a…
Harness job reviews-t11-step-2-second-batch-132723 (plan T11).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..6434faa
---- /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 is still exactly what each route passes (the entity page now passes the indexability gate's decision instead of a prebuilt head block). The shell adds `web-runtime.js`, so the home page's JS budget is raised to 257 KB, 61.5 KB brotli (measured 239.1 / 56.3, was 212.2 / 49.9).
ddiff --git a/README.md b/README.md
iindex e24eccd..dda2874 100644
---- a/README.md
++++ b/README.md
@@@ -115,7 +115,7 @@ Old slugs 301, merged entities 301, deleted ones 410.
`AggregateRating`, and a `Review` for a summary, are emitted **only when the aggregate exists** (i.e.
real signals back it); without signals the page carries breadcrumbs only (tested). A recommendation
share is expressed as `ratingValue` 0–100 with `bestRating: 100` and the real count; a summary's
--`Review` never has a `reviewRating`. The openvibe-publishing v1.1.0 gate decides robots per entity
++`Review` never has a `reviewRating`. The openvibe-publishing v1.2.0 gate decides robots per entity
(no live signal → `unsourced`, summary under the word minimum → `thin`, unsupported points →
`unsupported_claims`, unreviewed AI → hidden); sitemaps list indexable entities only, Atom/JSON feeds
list published summaries, `robots.txt` and `llms.txt` are served, and Search receives
@@@ -228,7 +228,7 @@ by editors; signals arrive only from Sources.
- OpenVibe.AI — optional, proposes summaries through `reviews.summary.propose`
- OpenVibe.Search — consumes the index events
- PostgreSQL 18 and Valkey 9 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional)
--- packages: openvibe-publishing v1.1.0, openvibe-contracts v0.76.0, openvibe-shared v2.5.0, openvibe-sdk v0.26.0
++- packages: openvibe-publishing v1.2.0, openvibe-contracts v0.76.0, openvibe-shared v2.6.0, openvibe-sdk v0.26.0
## Capabilities
ddiff --git a/STATUS.json b/STATUS.json
iindex aa36234..c41757d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,8 +9,8 @@
"domain": "openvibe.reviews",
"contracts": "openvibe-contracts v0.76.0",
"packages": [
-- "openvibe-publishing v1.1.0",
-- "openvibe-shared v2.5.0",
++ "openvibe-publishing v1.2.0",
++ "openvibe-shared v2.6.0",
"openvibe-sdk v0.26.0"
],
"capabilities": [
ddiff --git a/package-lock.json b/package-lock.json
iindex b04d6eb..475b731 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,9 +15,9 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.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": {
@@@ -1149,9 +1149,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"
@@@ -1196,9 +1195,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 03e68f1..53b9762 100644
---- a/package.json
++++ b/package.json
@@@ -25,9 +25,9 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.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"
},
"engines": {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 00d4a72..a57496f 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -183,14 +183,11 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
if (!e) return;
const p = await svc.page(e, req.actor);
const discussion = await discussionFor(e, req.actor);
-- const head = seo.metaTags({
-- decision: p.decision, title: `${e.name} · OpenVibe.Reviews`, siteName: 'OpenVibe.Reviews', type: 'website',
++ send(req, res, 200, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null }), {
++ decision: p.decision, title: e.name, cache: discussion.state === 'ok' ? null : 'public', path: svc.entityPath(e),
description: e.description || `${e.name}: review signals from ${p.sources.length ? p.sources.map((s) => s.name || s.key).join(', ') : 'no source yet'}, with provenance.`,
canonical: p.entity.url, jsonLd: structuredData(p, config),
});
-- send(req, res, 200, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null }), {
-- head, title: e.name, cache: discussion.state === 'ok' ? null : 'public', path: svc.entityPath(e),
-- });
}));
router.get('/e/:slug/history', async (req, res) => {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 4b93ec7..5083355 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -1,17 +1,16 @@
'use strict';
/**
-- * Page shell: every page is server-rendered through this. The <head> SEO block comes from
-- * openvibe-shared/seo (robots always explicit: from the indexability gate for entity pages,
-- * noindex for editing surfaces), the OpenVibe Frame from openvibe-shared (app icon, SSR footer, a
-- * <noscript> navigation) plus the Network's navbar.js/footer.js as progressive enhancement.
++ * Page shell: every page is server-rendered through openvibe-publishing/layout (openvibe-shared/shell
++ * page()). Robots is always explicit (from the indexability gate for entity pages, noindex for
++ * editing surfaces); the document carries the SEO head, the app icon, the feed links, the OpenVibe
++ * Frame (SSR footer, a <noscript> navigation) plus the Network's navbar.js/footer.js as progressive
++ * enhancement.
* Nothing on the page needs JavaScript to be read, navigated, corrected or edited.
*/
const crypto = require('crypto');
--const ovServe = require('openvibe-shared/serve');
const fs = require('fs');
const path = require('path');
--const sharedSeo = require('openvibe-shared/seo');
--const appIcon = require('openvibe-shared/app-icon');
++const layout = require('openvibe-publishing/layout');
const frame = require('openvibe-shared/frame');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
@@@ -57,59 +56,47 @@ function navConfig(o, config) {
}
/**
-- * o: title, description, path (canonical path), robots (required unless head), head (extra head
-- * HTML built from the gate), jsonLd, body, active, actor, config, editor
++ * o: title, description, path (canonical path), canonical (absolute, defaults to the path on this
++ * site), robots or decision (the indexability gate's; one is required, there is no default that makes
++ * a page indexable), head (extra head HTML), jsonLd [], body, active, actor, config, editor
*/
function renderPage(o) {
const { config } = o;
-- if (!o.robots && !o.head) throw new TypeError('renderPage needs explicit robots (or a head block built from the gate)');
++ if (!o.robots && !o.decision) throw new TypeError('renderPage needs explicit robots (or the gate decision)');
const title = o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME;
-- const head = o.head || sharedSeo.headTags({
-- title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.baseUrl}${o.path || '/'}`,
-- robots: o.robots, siteName: SITE_NAME, type: o.ogType || 'website', jsonLd: o.jsonLd || null,
-- });
const feeds = [
-- { type: 'application/atom+xml', title: `${SITE_NAME}: published summaries (Atom)`, href: '/feed.atom' },
-- { type: 'application/feed+json', title: `${SITE_NAME}: published summaries (JSON Feed)`, href: '/feed.json' },
++ { type: 'atom', title: `${SITE_NAME}: published summaries (Atom)`, href: '/feed.atom' },
++ { type: 'json', title: `${SITE_NAME}: published summaries (JSON Feed)`, href: '/feed.json' },
];
const actor = o.actor || { kind: 'anonymous' };
const who = actor.kind === 'user'
? `<span class="rv-who">Signed in as ${esc((actor.user && (actor.user.display_name || actor.user.username)) || 'you')}</span>${o.editor ? ' · <a href="/editor">Editor desk</a>' : ''} · <a href="/auth/logout?next=${encodeURIComponent(o.path || '/')}">Sign out</a>`
: `<a href="/auth/login?next=${encodeURIComponent(o.path || '/')}">Sign in with OpenVibe</a>`;
-- return `<!DOCTYPE html>
--<html lang="en">
--<head>
--<meta charset="utf-8">
--<meta name="viewport" content="width=device-width, initial-scale=1">
--${head}
--${appIcon.headTags({ site: 'network' })}
--${feeds.map((f) => `<link rel="alternate" type="${f.type}" title="${esc(f.title)}" href="${f.href}">`).join('\n')}
--<link rel="stylesheet" href="${asset('css/reviews.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="reviews@${esc(RELEASE)}">
--<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>
--</head>
--<body>
--<a class="rv-skip" href="#main">Skip to content</a>
--<div id="navbar-mount"></div>
--${frame.noscriptNav({ name: SITE_NAME, home: '/', links: LINKS.map((l) => ({ label: l.label, href: l.href })) })}
--<header class="rv-bar"><a class="rv-brand" href="/">${SITE_NAME}</a><form class="rv-search" action="/search" method="get" role="search"><label for="rv-q" class="rv-sr">Search entities</label><input id="rv-q" name="q" type="search" placeholder="Search entities" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="rv-account">${who}</span></noscript></header>
--<main id="main" class="rv-main">
--${o.body || ''}
--${o.path === '/' ? frame.shipped({ service: 'reviews', title: `Recently shipped on ${SITE_NAME}` }) : ''}
--</main>
--${frame.footer({ service: 'reviews', variant: 'full', updates: '/updates' })}
--<script>
--window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'reviews', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' } }).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: 'reviews',
++ siteName: SITE_NAME,
++ lang: 'en',
++ title,
++ description: o.description || DEFAULT_DESCRIPTION,
++ canonical: o.canonical || `${config.baseUrl}${o.path || '/'}`,
++ decision: o.decision,
++ robots: o.robots,
++ type: o.ogType || 'website',
++ jsonLd: [].concat(o.jsonLd || []),
++ head: o.head,
++ feeds,
++ iconSite: 'network',
++ navbar: navConfig(o, config),
++ footer: { service: 'reviews', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' },
++ navLinks: LINKS.map((l) => ({ label: l.label, href: l.href })),
++ home: '/',
++ css: asset('css/reviews.css'),
++ release: RELEASE,
++ mainClass: 'rv-main',
++ header: `<header class="rv-bar"><a class="rv-brand" href="/">${SITE_NAME}</a><form class="rv-search" action="/search" method="get" role="search"><label for="rv-q" class="rv-sr">Search entities</label><input id="rv-q" name="q" type="search" placeholder="Search entities" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="rv-account">${who}</span></noscript></header>`,
++ body: o.body,
++ shipped: o.path === '/' ? frame.shipped({ service: 'reviews', title: `Recently shipped on ${SITE_NAME}` }) : '',
++ });
}
module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex 5569bc9..07d9c8b 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -2,10 +2,13 @@
/**
* The page shell carries what openvibe-shared/boost needs to move between pages in place (plan T11): an
* <meta name="ov-boost"> release marker on every page and the boost script with data-main="#main"; the shared
-- * navbar's sign-in returns to whatever page is showing ({path} is filled per page).
++ * navbar's sign-in returns to whatever page is showing ({path} is filled per page). The document is
++ * openvibe-publishing/layout's (openvibe-shared/shell page()): one title, the canonical and the exact
++ * robots the route passed, the JSON-LD, feeds, app icon, stylesheet, the Frame and the footer's init.
*/
const assert = require('assert');
--const { boot, req, suite } = require('./helpers');
++const { boot, req, suite, createEntity, cookieFor, editorToken } = require('./helpers');
++const { renderPage } = require('../server/render/layout');
const t = suite('layout');
@@@ -31,3 +34,55 @@ t('the shared navbar config uses the {path} sign-in template', async () => {
assert.strictEqual(page.navbar.loginUrl, '/auth/login?next={path}');
} finally { await h.stop(); }
});
++
++const count = (html, re) => (html.match(re) || []).length;
++
++t('a page needs explicit robots: there is no default that makes it indexable', () => {
++ assert.throws(() => renderPage({ title: 'x', body: '', config: { baseUrl: 'https://reviews.test' } }), TypeError);
++});
++
++t('the document head and frame come from openvibe-publishing/layout, robots exactly as the route passed it', async () => {
++ const h = await boot();
++ try {
++ const e = await createEntity(h, { name: 'Widget 3000', kind: 'product', aliases: [{ type: 'url', value: 'https://shop.example/p/widget' }] });
++ const ed = cookieFor(editorToken());
++ const pages = [
++ [`/e/${e.slug}`, 'noindex, follow', {}],
++ ['/about', 'index, follow', {}],
++ ['/search?q=widget', 'noindex, follow', {}],
++ ['/editor', 'noindex, nofollow', { cookie: ed }],
++ [`/e/${e.slug}/correct`, 'noindex, nofollow', { cookie: ed }],
++ ];
++ for (const [path, robots, opts] of pages) {
++ const r = await req(h, 'GET', path, opts);
++ assert.strictEqual(r.status, 200, `${path} → ${r.status}`);
++ 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, `${path}: exactly one <title>`);
++ assert.match(head, /<title>[^<]+ · OpenVibe\.Reviews<\/title>|<title>OpenVibe\.Reviews<\/title>/, `${path}: the composed title`);
++ assert.strictEqual(count(head, /<link rel="canonical"/g), 1, `${path}: one canonical`);
++ assert.ok(head.includes(`<link rel="canonical" href="http://reviews.test${path.split('?')[0]}">`), `${path}: the canonical`);
++ assert.strictEqual(count(head, /<meta name="robots"/g), 1, `${path}: one robots meta`);
++ assert.ok(head.includes(`<meta name="robots" content="${robots}">`), `${path}: robots ${robots}`);
++ assert.ok(head.includes('<link rel="alternate" type="application/atom+xml" href="/feed.atom" title="OpenVibe.Reviews: published summaries (Atom)">'), `${path}: Atom feed link`);
++ assert.ok(head.includes('<link rel="alternate" type="application/feed+json" href="/feed.json" title="OpenVibe.Reviews: published summaries (JSON Feed)">'), `${path}: JSON feed link`);
++ assert.match(head, /<link rel="stylesheet" href="\/css\/reviews\.css\?v=[0-9a-f]+">/, `${path}: the reviews stylesheet`);
++ assert.match(head, /<meta name="ov-boost" content="reviews@/, `${path}: the boost marker`);
++ assert.ok(head.includes('/shared/web-runtime.js'), `${path}: the web runtime`);
++ assert.ok(body.includes('<div id="navbar-mount"></div>'), `${path}: the navbar mount`);
++ assert.ok(body.includes('<nav aria-label="Site"'), `${path}: the noscript navigation`);
++ assert.ok(body.includes('class="rv-bar"'), `${path}: the site header`);
++ assert.ok(body.includes('<main id="main" class="rv-main">'), `${path}: the main element`);
++ assert.ok(body.includes('id="ov-footer"'), `${path}: the server-rendered footer`);
++ assert.ok(body.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), `${path}: the footer is initialised`);
++ }
++ const entity = (await req(h, 'GET', `/e/${e.slug}`)).text;
++ assert.strictEqual(count(entity.slice(0, entity.indexOf('</head>')), /<script type="application\/ld\+json">/g), 1, 'the entity page carries its JSON-LD');
++ const home = (await req(h, 'GET', '/')).text;
++ const homeHead = home.slice(0, home.indexOf('</head>'));
++ assert.ok(homeHead.includes('<meta name="robots" content="index, follow">'));
++ assert.strictEqual(count(homeHead, /<script type="application\/ld\+json">/g), 1, 'the home page JSON-LD');
++ assert.ok(home.includes('Recently shipped on OpenVibe.Reviews'), 'the shipped line stays on the home page');
++ } finally { await h.stop(); }
++});
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex c9d5003..35511bd 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: 20, // measured 17.8 (fresh database)
htmlBrotliKB: 5.5, // 4.6
-- jsFiles: 5, // 4
-- jsRawKB: 230, // 212.2
-- jsBrotliKB: 55, // 49.9
++ jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime)
++ jsRawKB: 257, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
++ jsBrotliKB: 61.5, // 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 1
cssRawKB: 7.5, // 6.7
cssBrotliKB: 2, // 1.7