openvibers/openvibe.wiki Commits
Render every page through openvibe-publishing/layout on the shared shell
Changes
nnew file mode 100644@@@ -0,0 +1,5 @@++# Changelog++++## Unreleased++++- Every page is rendered through `openvibe-publishing/layout` (`openvibe-shared/shell` page()) instead of a local template: the same head tags, stylesheets, feed links, app icon and Frame, with robots still explicit on every page (the gate's decision on an article, a string elsewhere); `openvibe-shared` v2.5.0 → v2.6.0 and `openvibe-publishing` v1.1.0 → v1.2.0, and the JS size budgets rise by the shell's `web-runtime.js` (5 files, 245 KB raw, 59 KB brotli).@@@ -278,8 +278,8 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit- 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: without `VALKEY_URL` they count per process).--- `openvibe-publishing` v1.1.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`-- v0.76.0, `openvibe-shared` v2.5.0++- `openvibe-publishing` v1.2.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`++ v0.76.0, `openvibe-shared` v2.6.0(Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.26.0 (db, auth, PostgreSQLevents outbox, per-actor limits, testing) — pinned release tarballs.- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,@@@ -9,8 +9,8 @@"domain": "openvibe.wiki","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"@@@ -26,9 +26,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": {@@@ -349,9 +349,8 @@ function createPages({ svc, viewers, platform, config, log = console, limits })}),seo.structuredData.breadcrumbs(crumbsLd),].filter(Boolean) : null;-- const head = seo.metaTags({ decision, title: `${v.revision.fields.title} · OpenVibe.Wiki`, description, siteName: 'OpenVibe.Wiki', type: 'article', jsonLd });send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => infoboxLinks.get(title) || { href: null, exists: false }, flash: req.query.saved ? 'Saved.' : null }), {-- head, path: svc.pagePath(space, page), cache: publicCache,++ decision, title: v.revision.fields.title, description, ogType: 'article', jsonLd, path: svc.pagePath(space, page), cache: publicCache,});}));@@@ -1,23 +1,27 @@'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 articles, 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. Nothing on the-- * page needs JavaScript to be read, navigated, edited or submitted.++ * Page shell: every page is server-rendered through this, as openvibe-publishing/layout's document++ * (openvibe-shared/shell page()). The <head> SEO block has robots always explicit: the indexability++ * gate's decision for articles, an explicit string for the rest (noindex for editing surfaces). The++ * OpenVibe Frame (app icon, SSR footer, a <noscript> navigation) comes with the shell, plus the++ * Network's navbar.js/footer.js as progressive enhancement. Nothing on the page needs JavaScript++ * to be read, navigated, edited or submitted.*/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 frame = require('openvibe-shared/frame');++const layout = require('openvibe-publishing/layout');const { escapeHtml: esc } = require('openvibe-publishing/ssr');const SITE_NAME = 'OpenVibe.Wiki';const NETWORK_URL = 'https://openvibe.network';const DEFAULT_DESCRIPTION = 'OpenVibe.Wiki: wiki spaces with page trees, revision history, citations and discussion, part of the OpenVibe network.';++const NAV_LINKS = [++ { label: 'Spaces', href: '/' },++ { label: 'Recent changes', href: '/recent' },++ { label: 'Search', href: '/search' },++];++const FOOTER = { service: 'wiki', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');const hashes = new Map();@@@ -56,59 +60,46 @@ function navConfig(o, config) {}/**-- * o: title, description, path (canonical path), robots (required), head (extra head HTML, e.g. the-- * gate's metaTags), jsonLd, body, active, actor, config, feeds++ * o: title, description, path (canonical path), robots or decision (one is required: the gate's++ * decision for an article, an explicit string for every other page), head (extra head HTML, added++ * after the shared tags), jsonLd, ogType, body, active, actor, config, feeds, styles, query*/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)');-- 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,-- });++ if (!o.robots && !o.decision) throw new TypeError('renderPage needs explicit robots (or the gate decision)');const feeds = o.feeds === false ? [] : [-- { type: 'application/atom+xml', title: `${SITE_NAME}: recent changes (Atom)`, href: '/feed.atom' },-- { type: 'application/feed+json', title: `${SITE_NAME}: recent changes (JSON Feed)`, href: '/feed.json' },++ { type: 'atom', title: `${SITE_NAME}: recent changes (Atom)`, href: '/feed.atom' },++ { type: 'json', title: `${SITE_NAME}: recent changes (JSON Feed)`, href: '/feed.json' },];const actor = o.actor || { kind: 'anonymous' };const who = actor.kind === 'user'? `<span class="wk-who">Signed in as ${esc((actor.user && (actor.user.display_name || actor.user.username)) || 'you')}</span> <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/wiki.css')}">--${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}--<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="wiki@${esc(RELEASE)}">--<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>--</head>--<body>--<a class="wk-skip" href="#main">Skip to content</a>--<div id="navbar-mount"></div>--${frame.noscriptNav({ name: SITE_NAME, home: '/', links: [{ label: 'Spaces', href: '/' }, { label: 'Recent changes', href: '/recent' }, { label: 'Search', href: '/search' }] })}--<header class="wk-bar"><a class="wk-brand" href="/">${SITE_NAME}</a><form class="wk-search" action="/search" method="get" role="search"><label for="wk-q" class="wk-sr">Search the wiki</label><input id="wk-q" name="q" type="search" placeholder="Search the wiki" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="wk-account">${who}</span></noscript></header>--<main id="main" class="wk-main">--${o.body || ''}--</main>--${frame.footer({ service: 'wiki', variant: 'full', updates: '/updates' })}--<script>--window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'wiki', 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: 'wiki',++ siteName: SITE_NAME,++ lang: 'en',++ title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,++ description: o.description || DEFAULT_DESCRIPTION,++ canonical: `${config.baseUrl}${o.path || '/'}`,++ decision: o.decision,++ robots: o.robots,++ type: o.ogType || 'website',++ jsonLd: [].concat(o.jsonLd || []),++ feeds,++ navbar: navConfig(o, config),++ footer: FOOTER,++ navLinks: NAV_LINKS,++ home: '/',++ iconSite: 'network',++ css: asset('css/wiki.css'),++ styles: o.styles,++ release: RELEASE,++ mainClass: 'wk-main',++ header: `<header class="wk-bar"><a class="wk-brand" href="/">${SITE_NAME}</a><form class="wk-search" action="/search" method="get" role="search"><label for="wk-q" class="wk-sr">Search the wiki</label><input id="wk-q" name="q" type="search" placeholder="Search the wiki" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="wk-account">${who}</span></noscript></header>`,++ body: o.body,++ head: o.head,++ });}module.exports = { renderPage, asset, setRelease, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL, assetVersion };nnew file mode 100644@@@ -0,0 +1,95 @@++'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, robots exactly as the page passed it (the++ * gate's decision for an article, noindex for editing surfaces), JSON-LD, the feed links, the++ * stylesheets, the network app icon, the Frame (navbar mount, noscript navigation, the++ * server-rendered footer and its init) and the boost marker.++ */++const assert = require('assert');++const H = require('./helpers');++const { renderPage } = require('../server/render/layout');++++const count = (s, re) => (s.match(re) || []).length;++const split = (html) => ({ head: html.slice(0, html.indexOf('</head>')), body: html.slice(html.indexOf('</head>')) });++++/** What every page carries, whatever its robots. */++function frameOf(html, where) {++ const { head, body } = split(html);++ assert.strictEqual(count(html, /<title>/g), 1, `${where}: exactly one <title>`);++ assert.strictEqual(count(head, /<link rel="canonical"/g), 1, `${where}: one canonical`);++ assert.strictEqual(count(head, /<meta name="robots"/g), 1, `${where}: one robots meta`);++ assert.ok(head.includes('<link rel="alternate" type="application/atom+xml" href="/feed.atom" title="OpenVibe.Wiki: recent changes (Atom)">'), `${where}: Atom feed link`);++ assert.ok(head.includes('<link rel="alternate" type="application/feed+json" href="/feed.json" title="OpenVibe.Wiki: recent changes (JSON Feed)">'), `${where}: JSON feed link`);++ assert.ok(/<link rel="stylesheet" href="\/css\/wiki\.css\?v=[0-9a-z]+">/.test(head), `${where}: the wiki stylesheet`);++ assert.ok(/<link rel="[^"]*icon[^"]*"/.test(head), `${where}: the app icon tags`);++ assert.ok(/<meta name="ov-boost" content="wiki@[^"]+">/.test(head), `${where}: the boost marker`);++ assert.ok(/<script src="\/shared\/boost\.js\?v=[0-9a-f]+" data-main="#main" defer><\/script>/.test(head), `${where}: the boost script`);++ assert.ok(body.includes('<div id="navbar-mount"></div>'), `${where}: the navbar mount`);++ assert.ok(body.includes('<noscript><nav'), `${where}: the noscript navigation`);++ assert.ok(body.includes('id="ov-footer"'), `${where}: the server-rendered footer`);++ assert.ok(body.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), `${where}: the footer is initialised`);++ assert.ok(body.includes('<header class="wk-bar">'), `${where}: the site header`);++ assert.ok(body.includes('<main id="main" class="wk-main">'), `${where}: the swappable <main>`);++ assert.ok(body.includes('"loginUrl":"/auth/login?next={path}"'), `${where}: the navbar signs in through the {path} template`);++ return { head, body };++}++const robotsOf = (head) => /<meta name="robots" content="([^"]*)">/.exec(head)[1];++++(async () => {++ const h = await H.boot();++ const me = H.subject();++ const cookie = H.cookieFor(H.userToken({ subject: me, username: 'ana' }));++ try {++ let r = await H.req(h, 'POST', '/new-space', { cookie, form: { name: 'Board games', slug: 'board-games', visibility: 'public', description: 'Rules and notes' } });++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++ r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: {++ op: 'publish', title: 'Chess', body: `Chess is played on a board of sixty-four squares. ${H.LONG}`, summary: 'A two-player board game.',++ cite_url_0: 'https://example.org/chess', cite_title_0: 'Chess rules', cite_retrieved_0: '2026-09-20',++ } });++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++++ // The home page: indexable by an explicit robots string, plus the showcase kit's stylesheet.++ r = await H.req(h, 'GET', '/');++ let { head } = frameOf(r.text, '/');++ assert.ok(head.includes('<title>OpenVibe.Wiki</title>'), 'the home title is the site name');++ assert.ok(/<link rel="canonical" href="https?:\/\/[^"\/]+\/">/.test(head), 'the home canonical');++ assert.strictEqual(robotsOf(head), 'index, follow');++ assert.ok(/<link rel="stylesheet" href="\/shared\/[^"]*showcase[^"]*">/.test(head), 'the extra openvibe-shared stylesheet');++ assert.ok(head.indexOf('/css/wiki.css') < head.indexOf('showcase'), 'the extra stylesheet follows the wiki stylesheet');++++ // A public space: its breadcrumb JSON-LD (a single object, not an array) reaches the head.++ r = await H.req(h, 'GET', '/s/board-games');++ ({ head } = frameOf(r.text, '/s/board-games'));++ assert.strictEqual(robotsOf(head), 'index, follow');++ assert.ok(head.includes('<title>Board games · OpenVibe.Wiki</title>'), 'the composed title');++ assert.ok(/<link rel="canonical" href="[^"]+\/s\/board-games">/.test(head));++ assert.ok(count(head, /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD');++++ // An article: robots from the gate's decision, canonical, article JSON-LD, Open Graph type.++ r = await H.req(h, 'GET', '/w/board-games/chess');++ assert.strictEqual(r.status, 200);++ ({ head } = frameOf(r.text, 'article'));++ assert.ok(head.includes('<title>Chess · OpenVibe.Wiki</title>'), 'the composed title');++ assert.ok(/<link rel="canonical" href="[^"]+\/w\/board-games\/chess">/.test(head), 'the canonical');++ assert.ok(/^(no)?index, (no)?follow$/.test(robotsOf(head)), `robots from the decision: ${robotsOf(head)}`);++ assert.ok(count(head, /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD');++ assert.ok(head.includes('<meta property="og:type" content="article">'), 'Open Graph article type');++++ // Editing surfaces and the sign-in page stay noindex.++ for (const [p, o] of [['/new-space', { cookie }], ['/s/board-games/new', { cookie }], ['/new-space', {}]]) {++ r = await H.req(h, 'GET', p, o);++ ({ head } = frameOf(r.text, p));++ assert.strictEqual(robotsOf(head), 'noindex, nofollow', `${p}: noindex`);++ }++ r = await H.req(h, 'GET', '/search?q=chess');++ assert.strictEqual(robotsOf(frameOf(r.text, '/search').head), 'noindex, follow');++++ // A page needs robots or the gate's decision: no default makes it indexable.++ assert.throws(() => renderPage({ title: 'x', body: '', config: { baseUrl: 'https://openvibe.wiki' } }), TypeError);++ assert.throws(() => renderPage({ title: 'x', body: '', head: '<meta name="robots" content="index">', config: { baseUrl: 'https://openvibe.wiki' } }), TypeError);++ } finally {++ await h.stop();++ }++ console.log('layout: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -14,9 +14,9 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {htmlRawKB: 27.5, // measured 24.9 (fresh database; 2026-09-29 the showcase hero and features with six inline ring icons)htmlBrotliKB: 7, // 6.3-- jsFiles: 4, // 3-- jsRawKB: 230, // 197.7-- jsBrotliKB: 53, // 45.6++ jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)++ jsRawKB: 245, // 239.1 (197.7 before openvibe-shared/shell's web-runtime.js)++ jsBrotliKB: 59, // 56.3 (45.6 before web-runtime.js)cssFiles: 2, // 1cssRawKB: 18, // 16.3 (wiki.css + the cached /shared/showcase.css)cssBrotliKB: 4.5, // 4.1
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` (`openvibe-shared/shell` page()) instead of a local template: the same head tags, stylesheets, feed links, app icon and Frame, with robots still explicit on every page (the gate's decision on an article, a string elsewhere); `openvibe-shared` v2.5.0 → v2.6.0 and `openvibe-publishing` v1.1.0 → v1.2.0, and the JS size budgets rise by the shell's `web-runtime.js` (5 files, 245 KB raw, 59 KB brotli).
@@ -278,8 +278,8 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
@@ -278,8 +278,8 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
- PostgreSQL 18 and Valkey 9 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
- 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: without `VALKEY_URL` they count per process).
`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
-- `openvibe-publishing` v1.1.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`
- v0.76.0, `openvibe-shared` v2.5.0
+- `openvibe-publishing` v1.2.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`
+ v0.76.0, `openvibe-shared` v2.6.0
(Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.26.0 (db, auth, PostgreSQL
(Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.26.0 (db, auth, PostgreSQL
events outbox, per-actor limits, testing) — pinned release tarballs.
events outbox, per-actor limits, testing) — pinned release tarballs.
- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
@@ -9,8 +9,8 @@
@@ -9,8 +9,8 @@
"domain": "openvibe.wiki",
"domain": "openvibe.wiki",
"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"
@@ -26,9 +26,9 @@
@@ -26,9 +26,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": {
@@ -349,9 +349,8 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -349,9 +349,8 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
}),
}),
seo.structuredData.breadcrumbs(crumbsLd),
seo.structuredData.breadcrumbs(crumbsLd),
].filter(Boolean) : null;
].filter(Boolean) : null;
- const head = seo.metaTags({ decision, title: `${v.revision.fields.title} · OpenVibe.Wiki`, description, siteName: 'OpenVibe.Wiki', type: 'article', jsonLd });
send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => infoboxLinks.get(title) || { href: null, exists: false }, flash: req.query.saved ? 'Saved.' : null }), {
send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => infoboxLinks.get(title) || { href: null, exists: false }, flash: req.query.saved ? 'Saved.' : null }), {
- head, path: svc.pagePath(space, page), cache: publicCache,
+ decision, title: v.revision.fields.title, description, ogType: 'article', jsonLd, path: svc.pagePath(space, page), cache: publicCache,
});
});
}));
}));
@@ -1,23 +1,27 @@
@@ -1,23 +1,27 @@
'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 articles, 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. Nothing on the
- * page needs JavaScript to be read, navigated, edited or submitted.
+ * Page shell: every page is server-rendered through this, as openvibe-publishing/layout's document
+ * (openvibe-shared/shell page()). The <head> SEO block has robots always explicit: the indexability
+ * gate's decision for articles, an explicit string for the rest (noindex for editing surfaces). The
+ * OpenVibe Frame (app icon, SSR footer, a <noscript> navigation) comes with the shell, plus the
+ * Network's navbar.js/footer.js as progressive enhancement. Nothing on the page needs JavaScript
+ * to be read, navigated, edited or submitted.
*/
*/
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 frame = require('openvibe-shared/frame');
+const layout = require('openvibe-publishing/layout');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const SITE_NAME = 'OpenVibe.Wiki';
const SITE_NAME = 'OpenVibe.Wiki';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const DEFAULT_DESCRIPTION = 'OpenVibe.Wiki: wiki spaces with page trees, revision history, citations and discussion, part of the OpenVibe network.';
const DEFAULT_DESCRIPTION = 'OpenVibe.Wiki: wiki spaces with page trees, revision history, citations and discussion, part of the OpenVibe network.';
+const NAV_LINKS = [
+ { label: 'Spaces', href: '/' },
+ { label: 'Recent changes', href: '/recent' },
+ { label: 'Search', href: '/search' },
+];
+const FOOTER = { service: 'wiki', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const hashes = new Map();
const hashes = new Map();
@@ -56,59 +60,46 @@ function navConfig(o, config) {
@@ -56,59 +60,46 @@ function navConfig(o, config) {
}
}
/**
/**
- * o: title, description, path (canonical path), robots (required), head (extra head HTML, e.g. the
- * gate's metaTags), jsonLd, body, active, actor, config, feeds
+ * o: title, description, path (canonical path), robots or decision (one is required: the gate's
+ * decision for an article, an explicit string for every other page), head (extra head HTML, added
+ * after the shared tags), jsonLd, ogType, body, active, actor, config, feeds, styles, query
*/
*/
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)');
- 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,
- });
+ if (!o.robots && !o.decision) throw new TypeError('renderPage needs explicit robots (or the gate decision)');
const feeds = o.feeds === false ? [] : [
const feeds = o.feeds === false ? [] : [
- { type: 'application/atom+xml', title: `${SITE_NAME}: recent changes (Atom)`, href: '/feed.atom' },
- { type: 'application/feed+json', title: `${SITE_NAME}: recent changes (JSON Feed)`, href: '/feed.json' },
+ { type: 'atom', title: `${SITE_NAME}: recent changes (Atom)`, href: '/feed.atom' },
+ { type: 'json', title: `${SITE_NAME}: recent changes (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="wk-who">Signed in as ${esc((actor.user && (actor.user.display_name || actor.user.username)) || 'you')}</span> <a href="/auth/logout?next=${encodeURIComponent(o.path || '/')}">Sign out</a>`
? `<span class="wk-who">Signed in as ${esc((actor.user && (actor.user.display_name || actor.user.username)) || 'you')}</span> <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/wiki.css')}">
-${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
-<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="wiki@${esc(RELEASE)}">
-<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>
-</head>
-<body>
-<a class="wk-skip" href="#main">Skip to content</a>
-<div id="navbar-mount"></div>
-${frame.noscriptNav({ name: SITE_NAME, home: '/', links: [{ label: 'Spaces', href: '/' }, { label: 'Recent changes', href: '/recent' }, { label: 'Search', href: '/search' }] })}
-<header class="wk-bar"><a class="wk-brand" href="/">${SITE_NAME}</a><form class="wk-search" action="/search" method="get" role="search"><label for="wk-q" class="wk-sr">Search the wiki</label><input id="wk-q" name="q" type="search" placeholder="Search the wiki" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="wk-account">${who}</span></noscript></header>
-<main id="main" class="wk-main">
-${o.body || ''}
-</main>
-${frame.footer({ service: 'wiki', variant: 'full', updates: '/updates' })}
-<script>
-window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'wiki', 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: 'wiki',
+ siteName: SITE_NAME,
+ lang: 'en',
+ title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,
+ description: o.description || DEFAULT_DESCRIPTION,
+ canonical: `${config.baseUrl}${o.path || '/'}`,
+ decision: o.decision,
+ robots: o.robots,
+ type: o.ogType || 'website',
+ jsonLd: [].concat(o.jsonLd || []),
+ feeds,
+ navbar: navConfig(o, config),
+ footer: FOOTER,
+ navLinks: NAV_LINKS,
+ home: '/',
+ iconSite: 'network',
+ css: asset('css/wiki.css'),
+ styles: o.styles,
+ release: RELEASE,
+ mainClass: 'wk-main',
+ header: `<header class="wk-bar"><a class="wk-brand" href="/">${SITE_NAME}</a><form class="wk-search" action="/search" method="get" role="search"><label for="wk-q" class="wk-sr">Search the wiki</label><input id="wk-q" name="q" type="search" placeholder="Search the wiki" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="wk-account">${who}</span></noscript></header>`,
+ body: o.body,
+ head: o.head,
+ });
}
}
module.exports = { renderPage, asset, setRelease, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL, assetVersion };
module.exports = { renderPage, asset, setRelease, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL, assetVersion };
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+'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, robots exactly as the page passed it (the
+ * gate's decision for an article, noindex for editing surfaces), JSON-LD, the feed links, the
+ * stylesheets, the network app icon, the Frame (navbar mount, noscript navigation, the
+ * server-rendered footer and its init) and the boost marker.
+ */
+const assert = require('assert');
+const H = require('./helpers');
+const { renderPage } = require('../server/render/layout');
+
+const count = (s, re) => (s.match(re) || []).length;
+const split = (html) => ({ head: html.slice(0, html.indexOf('</head>')), body: html.slice(html.indexOf('</head>')) });
+
+/** What every page carries, whatever its robots. */
+function frameOf(html, where) {
+ const { head, body } = split(html);
+ assert.strictEqual(count(html, /<title>/g), 1, `${where}: exactly one <title>`);
+ assert.strictEqual(count(head, /<link rel="canonical"/g), 1, `${where}: one canonical`);
+ assert.strictEqual(count(head, /<meta name="robots"/g), 1, `${where}: one robots meta`);
+ assert.ok(head.includes('<link rel="alternate" type="application/atom+xml" href="/feed.atom" title="OpenVibe.Wiki: recent changes (Atom)">'), `${where}: Atom feed link`);
+ assert.ok(head.includes('<link rel="alternate" type="application/feed+json" href="/feed.json" title="OpenVibe.Wiki: recent changes (JSON Feed)">'), `${where}: JSON feed link`);
+ assert.ok(/<link rel="stylesheet" href="\/css\/wiki\.css\?v=[0-9a-z]+">/.test(head), `${where}: the wiki stylesheet`);
+ assert.ok(/<link rel="[^"]*icon[^"]*"/.test(head), `${where}: the app icon tags`);
+ assert.ok(/<meta name="ov-boost" content="wiki@[^"]+">/.test(head), `${where}: the boost marker`);
+ assert.ok(/<script src="\/shared\/boost\.js\?v=[0-9a-f]+" data-main="#main" defer><\/script>/.test(head), `${where}: the boost script`);
+ assert.ok(body.includes('<div id="navbar-mount"></div>'), `${where}: the navbar mount`);
+ assert.ok(body.includes('<noscript><nav'), `${where}: the noscript navigation`);
+ assert.ok(body.includes('id="ov-footer"'), `${where}: the server-rendered footer`);
+ assert.ok(body.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), `${where}: the footer is initialised`);
+ assert.ok(body.includes('<header class="wk-bar">'), `${where}: the site header`);
+ assert.ok(body.includes('<main id="main" class="wk-main">'), `${where}: the swappable <main>`);
+ assert.ok(body.includes('"loginUrl":"/auth/login?next={path}"'), `${where}: the navbar signs in through the {path} template`);
+ return { head, body };
+}
+const robotsOf = (head) => /<meta name="robots" content="([^"]*)">/.exec(head)[1];
+
+(async () => {
+ const h = await H.boot();
+ const me = H.subject();
+ const cookie = H.cookieFor(H.userToken({ subject: me, username: 'ana' }));
+ try {
+ let r = await H.req(h, 'POST', '/new-space', { cookie, form: { name: 'Board games', slug: 'board-games', visibility: 'public', description: 'Rules and notes' } });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+ r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: {
+ op: 'publish', title: 'Chess', body: `Chess is played on a board of sixty-four squares. ${H.LONG}`, summary: 'A two-player board game.',
+ cite_url_0: 'https://example.org/chess', cite_title_0: 'Chess rules', cite_retrieved_0: '2026-09-20',
+ } });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+
+ // The home page: indexable by an explicit robots string, plus the showcase kit's stylesheet.
+ r = await H.req(h, 'GET', '/');
+ let { head } = frameOf(r.text, '/');
+ assert.ok(head.includes('<title>OpenVibe.Wiki</title>'), 'the home title is the site name');
+ assert.ok(/<link rel="canonical" href="https?:\/\/[^"\/]+\/">/.test(head), 'the home canonical');
+ assert.strictEqual(robotsOf(head), 'index, follow');
+ assert.ok(/<link rel="stylesheet" href="\/shared\/[^"]*showcase[^"]*">/.test(head), 'the extra openvibe-shared stylesheet');
+ assert.ok(head.indexOf('/css/wiki.css') < head.indexOf('showcase'), 'the extra stylesheet follows the wiki stylesheet');
+
+ // A public space: its breadcrumb JSON-LD (a single object, not an array) reaches the head.
+ r = await H.req(h, 'GET', '/s/board-games');
+ ({ head } = frameOf(r.text, '/s/board-games'));
+ assert.strictEqual(robotsOf(head), 'index, follow');
+ assert.ok(head.includes('<title>Board games · OpenVibe.Wiki</title>'), 'the composed title');
+ assert.ok(/<link rel="canonical" href="[^"]+\/s\/board-games">/.test(head));
+ assert.ok(count(head, /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD');
+
+ // An article: robots from the gate's decision, canonical, article JSON-LD, Open Graph type.
+ r = await H.req(h, 'GET', '/w/board-games/chess');
+ assert.strictEqual(r.status, 200);
+ ({ head } = frameOf(r.text, 'article'));
+ assert.ok(head.includes('<title>Chess · OpenVibe.Wiki</title>'), 'the composed title');
+ assert.ok(/<link rel="canonical" href="[^"]+\/w\/board-games\/chess">/.test(head), 'the canonical');
+ assert.ok(/^(no)?index, (no)?follow$/.test(robotsOf(head)), `robots from the decision: ${robotsOf(head)}`);
+ assert.ok(count(head, /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD');
+ assert.ok(head.includes('<meta property="og:type" content="article">'), 'Open Graph article type');
+
+ // Editing surfaces and the sign-in page stay noindex.
+ for (const [p, o] of [['/new-space', { cookie }], ['/s/board-games/new', { cookie }], ['/new-space', {}]]) {
+ r = await H.req(h, 'GET', p, o);
+ ({ head } = frameOf(r.text, p));
+ assert.strictEqual(robotsOf(head), 'noindex, nofollow', `${p}: noindex`);
+ }
+ r = await H.req(h, 'GET', '/search?q=chess');
+ assert.strictEqual(robotsOf(frameOf(r.text, '/search').head), 'noindex, follow');
+
+ // A page needs robots or the gate's decision: no default makes it indexable.
+ assert.throws(() => renderPage({ title: 'x', body: '', config: { baseUrl: 'https://openvibe.wiki' } }), TypeError);
+ assert.throws(() => renderPage({ title: 'x', body: '', head: '<meta name="robots" content="index">', config: { baseUrl: 'https://openvibe.wiki' } }), TypeError);
+ } finally {
+ await h.stop();
+ }
+ console.log('layout: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -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: 27.5, // measured 24.9 (fresh database; 2026-09-29 the showcase hero and features with six inline ring icons)
htmlRawKB: 27.5, // measured 24.9 (fresh database; 2026-09-29 the showcase hero and features with six inline ring icons)
htmlBrotliKB: 7, // 6.3
htmlBrotliKB: 7, // 6.3
- jsFiles: 4, // 3
- jsRawKB: 230, // 197.7
- jsBrotliKB: 53, // 45.6
+ jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
+ jsRawKB: 245, // 239.1 (197.7 before openvibe-shared/shell's web-runtime.js)
+ jsBrotliKB: 59, // 56.3 (45.6 before web-runtime.js)
cssFiles: 2, // 1
cssFiles: 2, // 1
cssRawKB: 18, // 16.3 (wiki.css + the cached /shared/showcase.css)
cssRawKB: 18, // 16.3 (wiki.css + the cached /shared/showcase.css)
cssBrotliKB: 4.5, // 4.1
cssBrotliKB: 4.5, // 4.1