openvibers/openvibe.trade Commits
Render every page through openvibe-publishing/layout on shell v2.6.0
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; Trade keeps its site stylesheet, the disclaimers, the account bar and the "Recently shipped" line. Pins `openvibe-shared` v2.6.0 and `openvibe-publishing` v1.2.0.@@@ -11,8 +11,8 @@"contracts": "openvibe-contracts v0.79.0","packages": ["openvibe-contracts v0.79.0",-- "openvibe-publishing v1.1.0",-- "openvibe-shared v2.2.0",++ "openvibe-publishing v1.2.0",++ "openvibe-shared v2.6.0","openvibe-sdk v0.25.0"],"capabilities": [@@@ -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"@@@ -28,9 +28,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": {@@@ -2,21 +2,20 @@/*** Page shell. Every page is server-rendered through this and is complete without JavaScript:++ * - the document is openvibe-publishing/layout's (openvibe-shared/shell page())* - <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 from real fields only, feed links, the shared app icon++ * (there is no default that makes a page indexable), Open Graph/Twitter, JSON-LD from real++ * fields only, feed links, the shared app icon, the site stylesheet and the boost marker* - the persistent disclaimer, above the content on every page:* "Information only — not investment advice; no trading here."-- * - the OpenVibe Frame: navbar.js and theme-loader.js from the Network (progressive), a++ * - the OpenVibe Frame: theme-loader, web runtime, navbar and footer from the Network (progressive), a* <noscript> navigation bar and the server-rendered shared footer (openvibe-shared)*/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 layout = require('openvibe-publishing/layout');const { escapeHtml: esc } = require('openvibe-publishing/ssr');--const appIcon = require('openvibe-shared/app-icon');const frame = require('openvibe-shared/frame');const NETWORK_URL = 'https://openvibe.network';@@@ -51,15 +50,6 @@ const NAV_LINKS = [*/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} — sourced market information`,-- description: o.description || `Instruments, timestamped observations and filings with their sources. ${DISCLAIMER}`,-- decision: o.decision,-- canonical: o.canonical,-- type: o.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 || '/');@@@ -81,42 +71,33 @@ function renderPage(o) {const account = signedIn? `${viewer.editor ? '<a href="/editor">Editor</a> · ' : ''}<a href="/watchlists">Your watchlists</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: 'trade' })}--<link rel="stylesheet" href="${asset('css/trade.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="trade@${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_LINKS })}--<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>--<p class="disclaimer" role="note"><strong>${esc(DISCLAIMER)}</strong> Every number shows when it was observed and where it came from; stale sources are labelled as stale.</p>--<main id="main" class="page">--${o.body || ''}--${o.path === '/' ? frame.shipped({ service: 'trade', title: `Recently shipped on ${SITE_NAME}` }) : ''}--</main>--<p class="disclaimer disclaimer-foot" role="note">${esc(DISCLAIMER)} OpenVibe.Trade holds no money or assets, takes no orders and gives no personal recommendations.</p>--${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: 'trade',++ siteName: SITE_NAME,++ lang: o.lang,++ title: o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME} — sourced market information`,++ description: o.description || `Instruments, timestamped observations and filings with their sources. ${DISCLAIMER}`,++ canonical: o.canonical,++ decision: o.decision,++ type: o.type || 'website',++ image: o.image,++ jsonLd: o.jsonLd,++ feeds: o.feeds,++ navbar: nav,++ footer,++ navLinks: NAV_LINKS,++ home: '/',++ css: asset('css/trade.css'),++ release: RELEASE,++ account,++ header: `<p class="disclaimer" role="note"><strong>${esc(DISCLAIMER)}</strong> Every number shows when it was observed and where it came from; stale sources are labelled as stale.</p>`,++ body: o.body,++ shipped: [++ o.path === '/' ? frame.shipped({ service: 'trade', title: `Recently shipped on ${SITE_NAME}` }) : '',++ `<p class="disclaimer disclaimer-foot" role="note">${esc(DISCLAIMER)} OpenVibe.Trade holds no money or assets, takes no orders and gives no personal recommendations.</p>`,++ ].filter(Boolean).join('\n'),++ bodyClass: o.bodyClass,++ });}module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, DISCLAIMER };nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++/**++ * The page shell (server/render/layout.js) is openvibe-publishing/layout's document (openvibe-shared/shell++ * page()): one title, the canonical and robots from the gate's decision, the JSON-LD, feed links, the Trade++ * stylesheet, the boost marker, the Frame (navbar mount, noscript navigation, footer and its init) and the++ * disclaimers Trade shows on every page.++ */++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();++++ 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.trade' } }), TypeError);++ });++++ await check('the document head and frame come from openvibe-publishing/layout', async () => {++ await t.instrument({ symbol: 'ACME', name: 'Acme Corp' });++ for (const path of ['/', '/sources', '/i/ACME']) {++ const r = await t.get(path);++ 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.ok(/<title>[^<]*OpenVibe\.Trade[^<]*<\/title>/.test(head), `${path}: the composed title`);++ assert.strictEqual(count(head, /<link rel="canonical" href="https:\/\/[^"]+">/g), 1, `${path}: one canonical`);++ assert.strictEqual(count(head, /<meta name="robots" content="(index|noindex), (follow|nofollow)">/g), 1, `${path}: one robots meta from the decision`);++ assert.ok(/<link rel="stylesheet" href="\/css\/trade\.css\?v=[0-9a-f]+">/.test(head), `${path}: the Trade stylesheet`);++ assert.ok(/<meta name="ov-boost" content="trade@[^"]+">/.test(head), `${path}: boost marker`);++ assert.ok(head.includes('<script src="/shared/web-runtime.js'), `${path}: the shared 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('id="ov-footer"'), `${path}: the server-rendered footer`);++ assert.ok(body.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), `${path}: the footer is initialised`);++ assert.ok(body.includes('class="disclaimer"') && body.includes('disclaimer-foot'), `${path}: both disclaimers`);++ }++ const home = (await t.get('/')).text;++ const hhead = home.slice(0, home.indexOf('</head>'));++ assert.ok(hhead.includes('<link rel="alternate" type="application/rss+xml" href="/feed.xml"'), 'RSS feed link');++ assert.ok(hhead.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml"'), 'Atom feed link');++ assert.ok(hhead.includes('<link rel="alternate" type="application/feed+json" href="/feed.json"'), 'JSON feed link');++ assert.ok(home.includes('Recently shipped on OpenVibe.Trade'), 'the shipped line stays on the home page');++ const page = (await t.get('/i/ACME')).text;++ assert.ok(count(page.slice(0, page.indexOf('</head>')), /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD on the instrument page');++ });++++ await t.close();++ done();++})();@@@ -14,9 +14,9 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {htmlRawKB: 22, // measured 18.2 (fresh database)htmlBrotliKB: 5.5, // 4.7-- jsFiles: 5, // 4-- jsRawKB: 240, // 212.2-- jsBrotliKB: 56, // 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, // 1cssRawKB: 6.5, // 4.7cssBrotliKB: 2, // 1.3
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; Trade keeps its site stylesheet, the disclaimers, the account bar and the "Recently shipped" line. Pins `openvibe-shared` v2.6.0 and `openvibe-publishing` v1.2.0.
@@ -11,8 +11,8 @@
@@ -11,8 +11,8 @@
"contracts": "openvibe-contracts v0.79.0",
"contracts": "openvibe-contracts v0.79.0",
"packages": [
"packages": [
"openvibe-contracts v0.79.0",
"openvibe-contracts v0.79.0",
- "openvibe-publishing v1.1.0",
- "openvibe-shared v2.2.0",
+ "openvibe-publishing v1.2.0",
+ "openvibe-shared v2.6.0",
"openvibe-sdk v0.25.0"
"openvibe-sdk v0.25.0"
],
],
"capabilities": [
"capabilities": [
@@ -16,9 +16,9 @@
@@ -16,9 +16,9 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.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-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": {
@@ -1163,9 +1163,8 @@
@@ -1163,9 +1163,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"
@@ -1210,9 +1209,9 @@
@@ -1210,9 +1209,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"
@@ -28,9 +28,9 @@
@@ -28,9 +28,9 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.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-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": {
@@ -2,21 +2,20 @@
@@ -2,21 +2,20 @@
/**
/**
* Page shell. Every page is server-rendered through this and is complete without JavaScript:
* Page shell. Every page is server-rendered through this and is complete without JavaScript:
+ * - the document is openvibe-publishing/layout's (openvibe-shared/shell page())
* - <head>: title, description, canonical and robots from the indexability gate's decision
* - <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 from real fields only, feed links, the shared app icon
+ * (there is no default that makes a page indexable), Open Graph/Twitter, JSON-LD from real
+ * fields only, feed links, the shared app icon, the site stylesheet and the boost marker
* - the persistent disclaimer, above the content on every page:
* - the persistent disclaimer, above the content on every page:
* "Information only — not investment advice; no trading here."
* "Information only — not investment advice; no trading here."
- * - the OpenVibe Frame: navbar.js and theme-loader.js from the Network (progressive), a
+ * - the OpenVibe Frame: theme-loader, web runtime, navbar and footer from the Network (progressive), a
* <noscript> navigation bar and the server-rendered shared footer (openvibe-shared)
* <noscript> navigation bar and the server-rendered shared footer (openvibe-shared)
*/
*/
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 seo = require('openvibe-publishing/seo');
+const layout = require('openvibe-publishing/layout');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
-const appIcon = require('openvibe-shared/app-icon');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
@@ -51,15 +50,6 @@ const NAV_LINKS = [
@@ -51,15 +50,6 @@ const NAV_LINKS = [
*/
*/
function renderPage(o) {
function renderPage(o) {
if (!o.decision) throw new TypeError('renderPage needs the gate decision');
if (!o.decision) throw new TypeError('renderPage needs the gate decision');
- const head = seo.metaTags({
- title: o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME} — sourced market information`,
- description: o.description || `Instruments, timestamped observations and filings with their sources. ${DISCLAIMER}`,
- decision: o.decision,
- canonical: o.canonical,
- type: o.type || 'website',
- siteName: SITE_NAME,
- jsonLd: (o.jsonLd || []).filter(Boolean),
- });
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
const signedIn = viewer.kind === 'user';
const signedIn = viewer.kind === 'user';
const loginNext = encodeURIComponent(o.path || '/');
const loginNext = encodeURIComponent(o.path || '/');
@@ -81,42 +71,33 @@ function renderPage(o) {
@@ -81,42 +71,33 @@ function renderPage(o) {
const account = signedIn
const account = signedIn
? `${viewer.editor ? '<a href="/editor">Editor</a> · ' : ''}<a href="/watchlists">Your watchlists</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
? `${viewer.editor ? '<a href="/editor">Editor</a> · ' : ''}<a href="/watchlists">Your watchlists</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
: `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</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: 'trade' })}
-<link rel="stylesheet" href="${asset('css/trade.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="trade@${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_LINKS })}
-<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
-<p class="disclaimer" role="note"><strong>${esc(DISCLAIMER)}</strong> Every number shows when it was observed and where it came from; stale sources are labelled as stale.</p>
-<main id="main" class="page">
-${o.body || ''}
-${o.path === '/' ? frame.shipped({ service: 'trade', title: `Recently shipped on ${SITE_NAME}` }) : ''}
-</main>
-<p class="disclaimer disclaimer-foot" role="note">${esc(DISCLAIMER)} OpenVibe.Trade holds no money or assets, takes no orders and gives no personal recommendations.</p>
-${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: 'trade',
+ siteName: SITE_NAME,
+ lang: o.lang,
+ title: o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME} — sourced market information`,
+ description: o.description || `Instruments, timestamped observations and filings with their sources. ${DISCLAIMER}`,
+ canonical: o.canonical,
+ decision: o.decision,
+ type: o.type || 'website',
+ image: o.image,
+ jsonLd: o.jsonLd,
+ feeds: o.feeds,
+ navbar: nav,
+ footer,
+ navLinks: NAV_LINKS,
+ home: '/',
+ css: asset('css/trade.css'),
+ release: RELEASE,
+ account,
+ header: `<p class="disclaimer" role="note"><strong>${esc(DISCLAIMER)}</strong> Every number shows when it was observed and where it came from; stale sources are labelled as stale.</p>`,
+ body: o.body,
+ shipped: [
+ o.path === '/' ? frame.shipped({ service: 'trade', title: `Recently shipped on ${SITE_NAME}` }) : '',
+ `<p class="disclaimer disclaimer-foot" role="note">${esc(DISCLAIMER)} OpenVibe.Trade holds no money or assets, takes no orders and gives no personal recommendations.</p>`,
+ ].filter(Boolean).join('\n'),
+ bodyClass: o.bodyClass,
+ });
}
}
module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, DISCLAIMER };
module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, DISCLAIMER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+/**
+ * The page shell (server/render/layout.js) is openvibe-publishing/layout's document (openvibe-shared/shell
+ * page()): one title, the canonical and robots from the gate's decision, the JSON-LD, feed links, the Trade
+ * stylesheet, the boost marker, the Frame (navbar mount, noscript navigation, footer and its init) and the
+ * disclaimers Trade shows on every page.
+ */
+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();
+
+ 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.trade' } }), TypeError);
+ });
+
+ await check('the document head and frame come from openvibe-publishing/layout', async () => {
+ await t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
+ for (const path of ['/', '/sources', '/i/ACME']) {
+ const r = await t.get(path);
+ 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.ok(/<title>[^<]*OpenVibe\.Trade[^<]*<\/title>/.test(head), `${path}: the composed title`);
+ assert.strictEqual(count(head, /<link rel="canonical" href="https:\/\/[^"]+">/g), 1, `${path}: one canonical`);
+ assert.strictEqual(count(head, /<meta name="robots" content="(index|noindex), (follow|nofollow)">/g), 1, `${path}: one robots meta from the decision`);
+ assert.ok(/<link rel="stylesheet" href="\/css\/trade\.css\?v=[0-9a-f]+">/.test(head), `${path}: the Trade stylesheet`);
+ assert.ok(/<meta name="ov-boost" content="trade@[^"]+">/.test(head), `${path}: boost marker`);
+ assert.ok(head.includes('<script src="/shared/web-runtime.js'), `${path}: the shared 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('id="ov-footer"'), `${path}: the server-rendered footer`);
+ assert.ok(body.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), `${path}: the footer is initialised`);
+ assert.ok(body.includes('class="disclaimer"') && body.includes('disclaimer-foot'), `${path}: both disclaimers`);
+ }
+ const home = (await t.get('/')).text;
+ const hhead = home.slice(0, home.indexOf('</head>'));
+ assert.ok(hhead.includes('<link rel="alternate" type="application/rss+xml" href="/feed.xml"'), 'RSS feed link');
+ assert.ok(hhead.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml"'), 'Atom feed link');
+ assert.ok(hhead.includes('<link rel="alternate" type="application/feed+json" href="/feed.json"'), 'JSON feed link');
+ assert.ok(home.includes('Recently shipped on OpenVibe.Trade'), 'the shipped line stays on the home page');
+ const page = (await t.get('/i/ACME')).text;
+ assert.ok(count(page.slice(0, page.indexOf('</head>')), /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD on the instrument page');
+ });
+
+ await t.close();
+ done();
+})();
@@ -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: 22, // measured 18.2 (fresh database)
htmlRawKB: 22, // measured 18.2 (fresh database)
htmlBrotliKB: 5.5, // 4.7
htmlBrotliKB: 5.5, // 4.7
- jsFiles: 5, // 4
- jsRawKB: 240, // 212.2
- jsBrotliKB: 56, // 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
cssFiles: 2, // 1
cssRawKB: 6.5, // 4.7
cssRawKB: 6.5, // 4.7
cssBrotliKB: 2, // 1.3
cssBrotliKB: 2, // 1.3