openvibers/openvibe.blog Commits
Render every Blog page through openvibe-publishing/layout on 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 and the canonical still come from the indexability gate's decision, and a blog's theme preset stays on its content surface. The shell adds `web-runtime.js`, so the home page's JS budget is raised to 5 files, 245 KB, 59 KB brotli (measured 239.1 KB, 56.3 KB brotli; 212.2 KB, 49.9 KB before).@@@ -251,10 +251,10 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an- **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).--- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.0.0 (async PostgreSQL stores: revisions,-- schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr), `openvibe-contracts`-- v0.79.0, `openvibe-shared` v2.3.1 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),-- `openvibe-sdk` v0.25.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing).++- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.2.0 (async PostgreSQL stores: revisions,++ schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout), `openvibe-contracts`++ v0.79.0, `openvibe-shared` v2.6.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),++ `openvibe-sdk` v0.26.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing).- **OpenVibe.Network:**- SSO: the OAuth client `blog` is already seeded with redirect`https://openvibe.blog/auth/callback`.@@@ -9,10 +9,10 @@"domain": "openvibe.blog","contracts": "openvibe-contracts v0.79.0","packages": [-- "openvibe-publishing v1.0.0",++ "openvibe-publishing v1.2.0","openvibe-contracts v0.79.0",-- "openvibe-shared v2.3.1",-- "openvibe-sdk v0.25.0"++ "openvibe-shared v2.6.0",++ "openvibe-sdk v0.26.0"],"capabilities": ["blog.blog.create",@@@ -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.0.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": {@@@ -889,9 +889,9 @@}},"node_modules/openvibe-publishing": {-- "version": "1.0.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",-- "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==",++ "version": "1.2.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.2.0",++ "integrity": "sha512-n/20yxXo5jRs0wi7KsR+R9jDkGsoBak3gqElHV6XyU+i1t3XCzaoz+sDb10G2cbc4uEaNB/F09VtQKrQzLp6YQ==","license": "MIT","engines": {"node": ">=20"@@@ -936,9 +936,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.0.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": {@@@ -1,22 +1,21 @@'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/Twitter, JSON-LD, feed links, the shared app icon and critical canvas-- * - 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/Twitter, JSON-LD, article++ * times, prev/next, feed links, the shared app icon and critical canvas, 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* - a blog's theme preset applied to its content surface only (openvibe-shared tokens)*/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';const SITE_NAME = 'OpenVibe.Blog';@@@ -51,19 +50,6 @@ function themeStyle(vars) {*/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 || 'The official OpenVibe blog and a blog for every member.',-- decision: o.decision,-- canonical: o.canonical,-- type: o.type || 'website',-- siteName: SITE_NAME,-- image: o.image || undefined,-- author: o.author || undefined,-- prev: o.prev || undefined,-- next: o.next || undefined,-- jsonLd: (o.jsonLd || []).filter(Boolean),-- });const viewer = o.viewer || { kind: 'anonymous' };const signedIn = viewer.kind === 'user';const loginNext = encodeURIComponent(o.path || '/');@@@ -86,42 +72,38 @@ function renderPage(o) {const account = signedIn? `<a href="/write">Write</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="${esc(o.lang || 'en')}">--<head>--<meta charset="utf-8">--<meta name="viewport" content="width=device-width, initial-scale=1">--${head}--${o.published ? `<meta property="article:published_time" content="${esc(o.published)}">` : ''}--${o.modified ? `<meta property="article:modified_time" content="${esc(o.modified)}">` : ''}--${seo.feedLinks(o.feeds || [])}--${appIcon.headTags({ site: 'blog' })}--<link rel="stylesheet" href="${asset('css/blog.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="blog@${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: [{ label: 'Blog', href: '/' }, { label: 'Write', href: '/write' }] })}--<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>--<main id="main" class="page blog-surface"${style}>--${o.body || ''}--</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>`;++ const html = layout.renderDocument({++ site: 'blog',++ siteName: SITE_NAME,++ lang: o.lang,++ title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,++ description: o.description || 'The official OpenVibe blog and a blog for every member.',++ canonical: o.canonical,++ decision: o.decision,++ type: o.type || 'website',++ image: o.image,++ author: o.author,++ jsonLd: o.jsonLd,++ feeds: o.feeds,++ published: o.published,++ modified: o.modified,++ prev: o.prev,++ next: o.next,++ navbar: nav,++ footer,++ navLinks: [{ label: 'Blog', href: '/' }, { label: 'Write', href: '/write' }],++ home: '/',++ css: asset('css/blog.css'),++ styles: o.styles,++ release: RELEASE,++ account,++ body: o.body,++ mainClass: 'page blog-surface',++ bodyClass: o.bodyClass,++ });++ // A blog's theme preset applies to its content surface only: the custom properties and the preset's++ // slug stay on <main> (the layout writes that tag before any page body, so the first match is it).++ return style ? html.replace('<main id="main" class="page blog-surface">', () => `<main id="main" class="page blog-surface"${style}>`) : html;}module.exports = { renderPage, asset, assetVersion, themeStyle, setRelease, SITE_NAME, NETWORK_URL };nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++/**++ * The page shell (server/render/layout.js): the document is openvibe-publishing/layout's++ * (openvibe-shared/shell page()): one title, the canonical and robots from the gate's decision, the++ * JSON-LD, the feeds, the blog stylesheet, the Frame (navbar mount, noscript navigation, the++ * server-rendered footer and its init), and a blog's theme preset on its content surface only.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { renderPage } = require('../server/render/layout');++++const LONG = Array.from({ length: 300 }, (_, i) => `w${i}`).join(' ');++const count = (html, re) => (html.match(re) || []).length;++const split = (html) => ({ head: html.slice(0, html.indexOf('</head>')), body: html.slice(html.indexOf('</head>')) });++++(async () => {++ const t = await boot();++ const eve = t.network.addUser('eve', { display_name: 'Eve' });++ const csrf = t.csrf(eve);++++ 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.blog' } }), TypeError);++ });++++ await check('a published post: head and frame from openvibe-publishing/layout, robots from the decision', async () => {++ await t.get('/write', { as: eve });++ let r = await t.get('/write/start', { as: eve, form: { _csrf: csrf, handle: 'eve', title: 'Eve notes' } });++ assert.strictEqual(r.status, 303, r.text);++ r = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'Eve notes', description: 'Field notes', language: 'en', theme: 'paper', rss: '1', atom: '1', json: '1', itemCount: '10', fullContent: '1' } });++ assert.strictEqual(r.status, 303, r.text);++ r = await t.get('/write/@eve/new', { as: eve, form: { _csrf: csrf, title: 'Shell notes', body: `# Heading\n\n${LONG}`, summary: 'A summary', visibility: 'public', allowComments: '1' } });++ assert.strictEqual(r.status, 303, r.text);++ const id = (r.headers.get('location').match(/(pst_[0-9A-Z]+)/) || [])[1];++ r = await t.get(`/write/posts/${id}/publish`, { as: eve, form: { _csrf: csrf, revision: '1' } });++ assert.strictEqual(r.status, 303, r.text);++++ r = await t.get('/@eve/shell-notes');++ assert.strictEqual(r.status, 200, r.text);++ const { head, body } = split(r.text);++ assert.strictEqual(count(r.text, /<title>/g), 1, 'exactly one <title>');++ assert.ok(head.includes('<title>Shell notes · OpenVibe.Blog</title>'), 'the composed title');++ assert.ok(head.includes('<link rel="canonical" href="https://openvibe.blog/@eve/shell-notes">'), 'the canonical');++ assert.strictEqual(count(head, /<meta name="robots"/g), 1, 'one robots meta');++ assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots from the decision');++ assert.ok(count(head, /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD');++ assert.ok(head.includes('<meta property="article:published_time"'), 'article published time');++ assert.ok(head.includes('<link rel="alternate" type="application/atom+xml" href="/@eve/atom.xml" title="Eve notes (Atom)">'), 'Atom feed link');++ assert.ok(head.includes('<link rel="alternate" type="application/feed+json" href="/@eve/feed.json" title="Eve notes (JSON Feed)">'), 'JSON feed link');++ assert.ok(/<link rel="stylesheet" href="\/css\/blog\.css\?v=[0-9a-f]+">/.test(head), 'the blog stylesheet');++ assert.ok(/<meta name="ov-boost" content="blog@[^"]+">/.test(head), 'the boost marker');++ 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('id="ov-footer"'), 'the server-rendered footer');++ assert.ok(body.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), 'the footer is initialised');++ assert.ok(/<main id="main" class="page blog-surface" style="--[^"]+" data-blog-theme="paper">/.test(body), 'the theme preset stays on the content surface');++ assert.strictEqual(count(r.text, /<main\b/g), 1, 'one <main>');++ });++++ await check('the editor stays noindex; the home page keeps its feeds and one JSON-LD', async () => {++ const { head } = split((await t.get('/write', { as: eve })).text);++ assert.strictEqual(count(head, /<title>/g), 1);++ assert.ok(/<meta name="robots" content="noindex[^"]*">/.test(head), 'the editor is noindex');++ const home = split((await t.get('/')).text).head;++ assert.strictEqual(count(home, /<title>/g), 1);++ assert.ok(home.includes('<link rel="canonical" href="https://openvibe.blog/">'));++ assert.strictEqual(count(home, /<script type="application\/ld\+json">/g), 1, 'one JSON-LD script on the home page');++ assert.ok(home.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml" title="The OpenVibe blog (Atom)">'));++ assert.ok(home.includes('<link rel="alternate" type="application/feed+json" href="/feed.json" title="The OpenVibe blog (JSON Feed)">'));++ assert.ok(/<link rel="stylesheet" href="\/shared\/showcase\.css\?v=[0-9a-f]+">/.test(home), 'the showcase kit stylesheet on the home');++ });++++ await t.close();++ done();++})();@@@ -16,9 +16,9 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {htmlRawKB: 29, // measured 17.4 (fresh database)htmlBrotliKB: 7.5, // 4.5-- 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 (212.2 before openvibe-shared/shell's web-runtime.js)++ jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)cssFiles: 2, // 1cssRawKB: 17.5, // 5.0cssBrotliKB: 4.5, // 1.4
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 and the canonical still come from the indexability gate's decision, and a blog's theme preset stays on its content surface. The shell adds `web-runtime.js`, so the home page's JS budget is raised to 5 files, 245 KB, 59 KB brotli (measured 239.1 KB, 56.3 KB brotli; 212.2 KB, 49.9 KB before).
@@ -251,10 +251,10 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an
@@ -251,10 +251,10 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an
- **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).
-- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.0.0 (async PostgreSQL stores: revisions,
- schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr), `openvibe-contracts`
- v0.79.0, `openvibe-shared` v2.3.1 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),
- `openvibe-sdk` v0.25.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing).
+- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.2.0 (async PostgreSQL stores: revisions,
+ schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout), `openvibe-contracts`
+ v0.79.0, `openvibe-shared` v2.6.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),
+ `openvibe-sdk` v0.26.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing).
- **OpenVibe.Network:**
- **OpenVibe.Network:**
- SSO: the OAuth client `blog` is already seeded with redirect
- SSO: the OAuth client `blog` is already seeded with redirect
`https://openvibe.blog/auth/callback`.
`https://openvibe.blog/auth/callback`.
@@ -9,10 +9,10 @@
@@ -9,10 +9,10 @@
"domain": "openvibe.blog",
"domain": "openvibe.blog",
"contracts": "openvibe-contracts v0.79.0",
"contracts": "openvibe-contracts v0.79.0",
"packages": [
"packages": [
- "openvibe-publishing v1.0.0",
+ "openvibe-publishing v1.2.0",
"openvibe-contracts v0.79.0",
"openvibe-contracts v0.79.0",
- "openvibe-shared v2.3.1",
- "openvibe-sdk v0.25.0"
+ "openvibe-shared v2.6.0",
+ "openvibe-sdk v0.26.0"
],
],
"capabilities": [
"capabilities": [
"blog.blog.create",
"blog.blog.create",
@@ -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.0.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": {
@@ -889,9 +889,9 @@
@@ -889,9 +889,9 @@
}
}
},
},
"node_modules/openvibe-publishing": {
"node_modules/openvibe-publishing": {
- "version": "1.0.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
- "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==",
+ "version": "1.2.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.2.0",
+ "integrity": "sha512-n/20yxXo5jRs0wi7KsR+R9jDkGsoBak3gqElHV6XyU+i1t3XCzaoz+sDb10G2cbc4uEaNB/F09VtQKrQzLp6YQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -936,9 +936,9 @@
@@ -936,9 +936,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.0.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": {
@@ -1,22 +1,21 @@
@@ -1,22 +1,21 @@
'use strict';
'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
* - <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/Twitter, JSON-LD, feed links, the shared app icon and critical canvas
- * - 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/Twitter, JSON-LD, article
+ * times, prev/next, feed links, the shared app icon and critical canvas, 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
* - a blog's theme preset applied to its content surface only (openvibe-shared tokens)
* - a blog's theme preset applied to its content surface only (openvibe-shared tokens)
*/
*/
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 NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Blog';
const SITE_NAME = 'OpenVibe.Blog';
@@ -51,19 +50,6 @@ function themeStyle(vars) {
@@ -51,19 +50,6 @@ function themeStyle(vars) {
*/
*/
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,
- description: o.description || 'The official OpenVibe blog and a blog for every member.',
- decision: o.decision,
- canonical: o.canonical,
- type: o.type || 'website',
- siteName: SITE_NAME,
- image: o.image || undefined,
- author: o.author || undefined,
- prev: o.prev || undefined,
- next: o.next || undefined,
- 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 || '/');
@@ -86,42 +72,38 @@ function renderPage(o) {
@@ -86,42 +72,38 @@ function renderPage(o) {
const account = signedIn
const account = signedIn
? `<a href="/write">Write</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
? `<a href="/write">Write</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="${esc(o.lang || 'en')}">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-${head}
-${o.published ? `<meta property="article:published_time" content="${esc(o.published)}">` : ''}
-${o.modified ? `<meta property="article:modified_time" content="${esc(o.modified)}">` : ''}
-${seo.feedLinks(o.feeds || [])}
-${appIcon.headTags({ site: 'blog' })}
-<link rel="stylesheet" href="${asset('css/blog.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="blog@${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: [{ label: 'Blog', href: '/' }, { label: 'Write', href: '/write' }] })}
-<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
-<main id="main" class="page blog-surface"${style}>
-${o.body || ''}
-</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>`;
+ const html = layout.renderDocument({
+ site: 'blog',
+ siteName: SITE_NAME,
+ lang: o.lang,
+ title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,
+ description: o.description || 'The official OpenVibe blog and a blog for every member.',
+ canonical: o.canonical,
+ decision: o.decision,
+ type: o.type || 'website',
+ image: o.image,
+ author: o.author,
+ jsonLd: o.jsonLd,
+ feeds: o.feeds,
+ published: o.published,
+ modified: o.modified,
+ prev: o.prev,
+ next: o.next,
+ navbar: nav,
+ footer,
+ navLinks: [{ label: 'Blog', href: '/' }, { label: 'Write', href: '/write' }],
+ home: '/',
+ css: asset('css/blog.css'),
+ styles: o.styles,
+ release: RELEASE,
+ account,
+ body: o.body,
+ mainClass: 'page blog-surface',
+ bodyClass: o.bodyClass,
+ });
+ // A blog's theme preset applies to its content surface only: the custom properties and the preset's
+ // slug stay on <main> (the layout writes that tag before any page body, so the first match is it).
+ return style ? html.replace('<main id="main" class="page blog-surface">', () => `<main id="main" class="page blog-surface"${style}>`) : html;
}
}
module.exports = { renderPage, asset, assetVersion, themeStyle, setRelease, SITE_NAME, NETWORK_URL };
module.exports = { renderPage, asset, assetVersion, themeStyle, setRelease, SITE_NAME, NETWORK_URL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+/**
+ * The page shell (server/render/layout.js): the document is openvibe-publishing/layout's
+ * (openvibe-shared/shell page()): one title, the canonical and robots from the gate's decision, the
+ * JSON-LD, the feeds, the blog stylesheet, the Frame (navbar mount, noscript navigation, the
+ * server-rendered footer and its init), and a blog's theme preset on its content surface only.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { renderPage } = require('../server/render/layout');
+
+const LONG = Array.from({ length: 300 }, (_, i) => `w${i}`).join(' ');
+const count = (html, re) => (html.match(re) || []).length;
+const split = (html) => ({ head: html.slice(0, html.indexOf('</head>')), body: html.slice(html.indexOf('</head>')) });
+
+(async () => {
+ const t = await boot();
+ const eve = t.network.addUser('eve', { display_name: 'Eve' });
+ const csrf = t.csrf(eve);
+
+ 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.blog' } }), TypeError);
+ });
+
+ await check('a published post: head and frame from openvibe-publishing/layout, robots from the decision', async () => {
+ await t.get('/write', { as: eve });
+ let r = await t.get('/write/start', { as: eve, form: { _csrf: csrf, handle: 'eve', title: 'Eve notes' } });
+ assert.strictEqual(r.status, 303, r.text);
+ r = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'Eve notes', description: 'Field notes', language: 'en', theme: 'paper', rss: '1', atom: '1', json: '1', itemCount: '10', fullContent: '1' } });
+ assert.strictEqual(r.status, 303, r.text);
+ r = await t.get('/write/@eve/new', { as: eve, form: { _csrf: csrf, title: 'Shell notes', body: `# Heading\n\n${LONG}`, summary: 'A summary', visibility: 'public', allowComments: '1' } });
+ assert.strictEqual(r.status, 303, r.text);
+ const id = (r.headers.get('location').match(/(pst_[0-9A-Z]+)/) || [])[1];
+ r = await t.get(`/write/posts/${id}/publish`, { as: eve, form: { _csrf: csrf, revision: '1' } });
+ assert.strictEqual(r.status, 303, r.text);
+
+ r = await t.get('/@eve/shell-notes');
+ assert.strictEqual(r.status, 200, r.text);
+ const { head, body } = split(r.text);
+ assert.strictEqual(count(r.text, /<title>/g), 1, 'exactly one <title>');
+ assert.ok(head.includes('<title>Shell notes · OpenVibe.Blog</title>'), 'the composed title');
+ assert.ok(head.includes('<link rel="canonical" href="https://openvibe.blog/@eve/shell-notes">'), 'the canonical');
+ assert.strictEqual(count(head, /<meta name="robots"/g), 1, 'one robots meta');
+ assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots from the decision');
+ assert.ok(count(head, /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD');
+ assert.ok(head.includes('<meta property="article:published_time"'), 'article published time');
+ assert.ok(head.includes('<link rel="alternate" type="application/atom+xml" href="/@eve/atom.xml" title="Eve notes (Atom)">'), 'Atom feed link');
+ assert.ok(head.includes('<link rel="alternate" type="application/feed+json" href="/@eve/feed.json" title="Eve notes (JSON Feed)">'), 'JSON feed link');
+ assert.ok(/<link rel="stylesheet" href="\/css\/blog\.css\?v=[0-9a-f]+">/.test(head), 'the blog stylesheet');
+ assert.ok(/<meta name="ov-boost" content="blog@[^"]+">/.test(head), 'the boost marker');
+ 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('id="ov-footer"'), 'the server-rendered footer');
+ assert.ok(body.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), 'the footer is initialised');
+ assert.ok(/<main id="main" class="page blog-surface" style="--[^"]+" data-blog-theme="paper">/.test(body), 'the theme preset stays on the content surface');
+ assert.strictEqual(count(r.text, /<main\b/g), 1, 'one <main>');
+ });
+
+ await check('the editor stays noindex; the home page keeps its feeds and one JSON-LD', async () => {
+ const { head } = split((await t.get('/write', { as: eve })).text);
+ assert.strictEqual(count(head, /<title>/g), 1);
+ assert.ok(/<meta name="robots" content="noindex[^"]*">/.test(head), 'the editor is noindex');
+ const home = split((await t.get('/')).text).head;
+ assert.strictEqual(count(home, /<title>/g), 1);
+ assert.ok(home.includes('<link rel="canonical" href="https://openvibe.blog/">'));
+ assert.strictEqual(count(home, /<script type="application\/ld\+json">/g), 1, 'one JSON-LD script on the home page');
+ assert.ok(home.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml" title="The OpenVibe blog (Atom)">'));
+ assert.ok(home.includes('<link rel="alternate" type="application/feed+json" href="/feed.json" title="The OpenVibe blog (JSON Feed)">'));
+ assert.ok(/<link rel="stylesheet" href="\/shared\/showcase\.css\?v=[0-9a-f]+">/.test(home), 'the showcase kit stylesheet on the home');
+ });
+
+ await t.close();
+ done();
+})();
@@ -16,9 +16,9 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -16,9 +16,9 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
htmlRawKB: 29, // measured 17.4 (fresh database)
htmlRawKB: 29, // measured 17.4 (fresh database)
htmlBrotliKB: 7.5, // 4.5
htmlBrotliKB: 7.5, // 4.5
- 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 (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: 17.5, // 5.0
cssRawKB: 17.5, // 5.0
cssBrotliKB: 4.5, // 1.4
cssBrotliKB: 4.5, // 1.4