openvibers/openvibe.reviews Commits
Render Reviews pages through openvibe-publishing/layout shell
Changes
nnew file mode 100644@@@ -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).@@@ -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 recommendationshare 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 feedslist 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@@@ -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": [@@@ -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"@@@ -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": {@@@ -183,14 +183,11 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limiif (!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) => {@@@ -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 };@@@ -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(); }++});@@@ -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, // 1cssRawKB: 7.5, // 6.7cssBrotliKB: 2, // 1.7
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -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).
@@ -115,7 +115,7 @@ Old slugs 301, merged entities 301, deleted ones 410.
@@ -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.
`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
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
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 →
(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
`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
list published summaries, `robots.txt` and `llms.txt` are served, and Search receives
@@ -228,7 +228,7 @@ by editors; signals arrive only from Sources.
@@ -228,7 +228,7 @@ by editors; signals arrive only from Sources.
- OpenVibe.AI — optional, proposes summaries through `reviews.summary.propose`
- OpenVibe.AI — optional, proposes summaries through `reviews.summary.propose`
- OpenVibe.Search — consumes the index events
- 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)
- 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
## Capabilities
@@ -9,8 +9,8 @@
@@ -9,8 +9,8 @@
"domain": "openvibe.reviews",
"domain": "openvibe.reviews",
"contracts": "openvibe-contracts v0.76.0",
"contracts": "openvibe-contracts v0.76.0",
"packages": [
"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"
"openvibe-sdk v0.26.0"
],
],
"capabilities": [
"capabilities": [
@@ -15,9 +15,9 @@
@@ -15,9 +15,9 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.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-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"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1149,9 +1149,8 @@
@@ -1149,9 +1149,8 @@
}
}
},
},
"node_modules/openvibe-publishing": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -1196,9 +1195,9 @@
@@ -1196,9 +1195,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -25,9 +25,9 @@
@@ -25,9 +25,9 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.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-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"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -183,14 +183,11 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -183,14 +183,11 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
if (!e) return;
if (!e) return;
const p = await svc.page(e, req.actor);
const p = await svc.page(e, req.actor);
const discussion = await discussionFor(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.`,
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),
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) => {
router.get('/e/:slug/history', async (req, res) => {
@@ -1,17 +1,16 @@
@@ -1,17 +1,16 @@
'use strict';
'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.
* Nothing on the page needs JavaScript to be read, navigated, corrected or edited.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
-const ovServe = require('openvibe-shared/serve');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
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 frame = require('openvibe-shared/frame');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
@@ -57,59 +56,47 @@ function navConfig(o, config) {
@@ -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) {
function renderPage(o) {
const { config } = 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 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 = [
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 actor = o.actor || { kind: 'anonymous' };
const who = actor.kind === 'user'
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>`
? `<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>`;
: `<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 };
module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
@@ -2,10 +2,13 @@
@@ -2,10 +2,13 @@
/**
/**
* The page shell carries what openvibe-shared/boost needs to move between pages in place (plan T11): an
* 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
* <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 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');
const t = suite('layout');
@@ -31,3 +34,55 @@ t('the shared navbar config uses the {path} sign-in template', async () => {
@@ -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}');
assert.strictEqual(page.navbar.loginUrl, '/auth/login?next={path}');
} finally { await h.stop(); }
} 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(); }
+});
@@ -14,9 +14,9 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -14,9 +14,9 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
htmlRawKB: 20, // measured 17.8 (fresh database)
htmlRawKB: 20, // measured 17.8 (fresh database)
htmlBrotliKB: 5.5, // 4.6
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
cssFiles: 2, // 1
cssRawKB: 7.5, // 6.7
cssRawKB: 7.5, // 6.7
cssBrotliKB: 2, // 1.7
cssBrotliKB: 2, // 1.7