openvibers/openvibe.coupons Commits

Render pages through openvibe-publishing/layout on shared shell v2.6.0

OP OpenVibers committed 4 Oct 2026 · c845eeac4f · 5 files, +106 −62

Asked: T11 step 2 second batch, s3 — OpenVibe.Coupons: 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 "s3" in /home/workstation/openvibe/a…
Harness job coupons-t11-step-2-second-batch-125051 (plan T11).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..ce3bf43
---- /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` to every page; the home page's JS budget still holds (5 files, 239.1 KB, 56.3 KB brotli), so no budget was raised.
ddiff --git a/package-lock.json b/package-lock.json
iindex a6ff8d4..fa84522 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 584756c..eb11e34 100644
---- a/package.json
++++ b/package.json
@@@ -27,9 +27,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 10600c3..8ff9c42 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -1,21 +1,19 @@
'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, JSON-LD built from real fields only, 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, JSON-LD built from real
++ * fields only, 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
* - no script of Coupons' own beyond the navbar init: copying a code is plain text selection
*/
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';
@@@ -48,15 +46,6 @@ 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 || 'Coupon codes with merchant matching, restrictions, honest expiry and people\'s validity reports.',
-- decision: o.decision,
-- canonical: o.canonical,
-- type: 'website',
-- siteName: SITE_NAME,
-- jsonLd: (o.jsonLd || []).filter(Boolean),
-- });
const viewer = o.viewer || { kind: 'anonymous' };
const signedIn = viewer.kind === 'user';
const loginNext = encodeURIComponent(o.path || '/');
@@@ -78,40 +67,27 @@ function renderPage(o) {
const account = signedIn
? `<a href="/watching">Watching</a> · <a href="/connect-extension">Browser helper</a>${viewer.staff ? ' · <a href="/staff">Staff</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="en">
--<head>
--<meta charset="utf-8">
--<meta name="viewport" content="width=device-width, initial-scale=1">
--${head}
--${seo.feedLinks(o.feeds || [])}
--${appIcon.headTags({ site: 'coupons' })}
--<link rel="stylesheet" href="${asset('css/coupons.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="coupons@${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: NAV })}
--<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: 'coupons', 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: 'coupons',
++ siteName: SITE_NAME,
++ title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,
++ description: o.description || 'Coupon codes with merchant matching, restrictions, honest expiry and people\'s validity reports.',
++ canonical: o.canonical,
++ decision: o.decision,
++ type: 'website',
++ jsonLd: o.jsonLd,
++ feeds: o.feeds,
++ navbar: nav,
++ footer,
++ navLinks: NAV,
++ home: '/',
++ css: asset('css/coupons.css'),
++ release: RELEASE,
++ account,
++ body: o.body,
++ shipped: o.path === '/' ? frame.shipped({ service: 'coupons', 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
nnew file mode 100644
iindex 0000000..d7f7943
---- /dev/null
++++ b/test/layout.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * The page shell (server/render/layout.js): every page is openvibe-publishing/layout's document
++ * (openvibe-shared/shell page()): one title, the canonical and robots from the gate's decision, the
++ * JSON-LD, feeds, the site stylesheet, the Frame (navbar mount, noscript navigation) and the
++ * server-rendered footer with its init, plus the boost marker and script.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { renderPage } = require('../server/render/layout');
++
++const count = (html, re) => (html.match(re) || []).length;
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++
++ 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.coupons' } }), TypeError);
++ });
++
++ await check('the document head and frame come from openvibe-publishing/layout, robots from the decision', async () => {
++ const empty = await t.merchant({ name: 'Quiet Shop', host: 'quietshop.co.uk' });
++ const shop = await t.merchant({ name: 'Green Grocer', host: 'greengrocer.co.uk' });
++ const sub = await t.get('/submit', { as: alice, form: { csrf: t.csrf(alice), merchant: shop.slug, code: 'VEG5', title: 'Five pounds off veg boxes' } });
++ assert.strictEqual(sub.status, 201, sub.text.slice(0, 300));
++ // A shop with no active code is thin (noindex, still served); one with a code is indexable.
++ for (const [m, robots] of [[shop, 'index, follow'], [empty, 'noindex, follow']]) {
++ const r = await t.get(`/m/${m.slug}`);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ 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>${m.name} coupon codes · OpenVibe.Coupons</title>`), 'the composed title');
++ assert.ok(head.includes(`<link rel="canonical" href="https://openvibe.coupons/m/${m.slug}">`), 'the canonical');
++ assert.strictEqual(count(head, /<meta name="robots"/g), 1, 'one robots meta');
++ assert.ok(head.includes(`<meta name="robots" content="${robots}">`), `${m.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/rss+xml" href="/m/${m.slug}/feed.xml" title="${m.name} codes (RSS)">`), 'the shop feed link');
++ assert.ok(/<link rel="stylesheet" href="\/css\/coupons\.css\?v=[0-9a-f]+">/.test(head), 'the coupons stylesheet');
++ assert.ok(/<meta name="ov-boost" content="coupons@[^"]+">/.test(head), 'the boost marker');
++ assert.ok(/<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#main" defer><\/script>/.test(head), 'the boost script');
++ 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('<main id="main" class="page">'), 'the swappable main');
++ 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');
++ assert.ok(body.includes('"loginUrl":"/auth/login?next={path}"'), 'sign-in is a {path} template');
++ }
++ 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.coupons/">'));
++ assert.strictEqual(count(head, /<script type="application\/ld\+json">/g), 1, 'one JSON-LD script on the home page');
++ for (const [type, href, title] of [['application/rss+xml', '/feed.xml', 'New codes (RSS)'], ['application/atom+xml', '/atom.xml', 'New codes (Atom)'], ['application/feed+json', '/feed.json', 'New codes (JSON Feed)']]) {
++ assert.ok(head.includes(`<link rel="alternate" type="${type}" href="${href}" title="${title}">`), `${href} feed link`);
++ }
++ assert.ok(home.includes('Recently shipped on OpenVibe.Coupons'), 'the shipped line stays on the home page');
++ });
++
++ await t.close();
++ done();
++})();