openvibers/openvibe.shared Commits
T11 lane D step 1 — openvibe-shared/shell, the server-rendered page… (#12)
Changes
@@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 2.6.0 — 2026-10-03++++- `openvibe-shared/shell` (new): `page(o)` composes a whole server-rendered document — the doctype++ and `<head>` from `seo.headTags` (plus `seo.pageSummary` when `summary` is given), the theme-loader++ before the first paint, `web-runtime.js`/`navbar.js`/`footer.js` deferred through `serve.url`, the++ no-JavaScript nav from `frame.noscriptNav`, the body and `frame.footer`. `scripts(o)` returns just++ the head scripts and the inline boot: the runtime options as JSON with `<` escaped, so a value can++ never close the `<script>`. Every interpolated value (title, description, `lang`, body attributes)++ is escaped. `templates/site/server/app.js` now calls `shell.page` instead of hand-writing the++ document. Additive: a new subpath export, nothing removed.++## 2.5.0 — 2026-10-02- `seo.feedXml(feed, { format })` takes items as `{ title, link, guid?, description?, content?, author?,@@@ -20,7 +20,7 @@ were reconciled first.| Kind | Files ||---|---|| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` || Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) || Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` || Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |@@@ -544,6 +544,53 @@ Tags the server already put in the document count as loaded. Global stubs (instahandlers working before their feature has loaded.```++A server-rendered page puts these tags in its `<head>` itself, with the SEO head and the OpenVibe++Frame around them: `openvibe-shared/shell` (`shell.page(o)`) composes exactly that — see "The page++shell" below.++++### The page shell (`shell.js`)++++```text++openvibe-shared/shell — compose a whole server-rendered page from the pieces a site already ships,++so a repo's layout.js becomes one call instead of a hand-written <!doctype html> document (the T11++sweep). It adds no markup of its own.++++ const shell = require('openvibe-shared/shell');++ res.type('html').send(shell.page({++ name: 'example', service: 'example',++ title: 'Example', siteName: 'OpenVibe.Example', description: '…',++ canonical: 'https://example.com/', image: '…', imageAlt: '…', type: 'website',++ summary: 'One line an AI can read', facts: [['Sites', 12]], url: 'https://example.com/',++ jsonLd: [seo.jsonLd.website({ … })],++ body: '<main>…</main>', // the page's main content (trusted, already escaped)++ bodyClass: 'home', bodyAttributes: { 'data-ov-content': 'pages' },++ navLinks: [{ label: 'Docs', href: '/docs' }], home: '/',++ footer: { service: 'example', variant: 'compact' },++ features: { chat: { scripts: ['/js/chat.js'] } },++ routes: [{ path: '^/chat', features: ['chat'] }],++ }));++++page(o) → the whole document: doctype, <head>, <body>. The head is seo.headTags (title,++ description, canonical, robots, Open Graph, Twitter, any jsonLd); when `summary` is++ given, seo.pageSummary adds its <meta name="ai-summary"> and WebPage JSON-LD to the++ head and its <noscript> block at the top of the body. Then theme-loader.js eagerly (it++ applies the theme before the first paint), web-runtime.js/navbar.js/footer.js deferred++ through serve.url, the frame.noscriptNav no-JavaScript nav, the body and frame.footer++ (footer.ssr). The runtime boot is inline JSON handed to++ OVWebRuntime.create(…).boot(), with the navbar init next to it.++scripts(o) → just that <head> script block (theme-loader, the deferred runtime, the boot JSON and++ the inline boot call). `routes[].path` is a string, not a RegExp.++```++++Every interpolated value is escaped: the head through `seo`, `lang` and the body attributes here, and++the runtime boot with `JSON.stringify(…).replace(/</g, '\\u003c')` so a feature name can never close++the `<script>`.++++Each repo's `layout.js` moves onto `shell.page()` in the T11 sweep; until then the shell and the++hand-written layouts produce the same document. `templates/site/server/app.js` (the scaffold every++new site starts from) already calls it. See `docs/migration-plan.md`, "T11: layout.js onto++openvibe-shared/shell".++### The navbar (`navbar.js`)```text@@@ -625,3 +625,28 @@ reason the Network keeps `/shared/<file>` on the v1 line (README, compatibility`find ~/OpenVibers -path '*/vendor/openvibe-shared' -type d` finds nothing.- Every future shared change follows the same path: a PR to OpenVibe.Shared, a `vX.Y.Z` tag,then a pin bump in Network first and in the other consumers after.++++---++++## T11: `layout.js` onto `openvibe-shared/shell`++++Shared v2.6.0 adds `openvibe-shared/shell` (`page(o)` / `scripts(o)`), the composition every++server-rendered `server/render/layout.js` repeats by hand: the doctype and `<head>` from++`seo.headTags` (and `seo.pageSummary` when `summary` is given), the theme-loader before the first++paint, `web-runtime.js`/`navbar.js`/`footer.js` deferred through `serve.url`, the++`frame.noscriptNav` no-JavaScript nav, the body and `frame.footer`. It adds no markup of its own, so++a repo's `renderPage(o)` keeps its document while its body becomes one `shell.page({ … })` call.++++The sweep (one PR per repo, after the pin is on v2.6.0):++++1. `server/render/layout.js` — require `openvibe-shared/shell`; replace the hand-written++ `<!doctype html>` document with `shell.page({ name, title, description, canonical, image,++ jsonLd, summary, body, navLinks, footer, features, routes })`. Keep the per-repo `o` mapping++ (title suffix, og type, gate decision) and pass its values through.++2. Drop the local head builders the shell now owns (`seo.metaTags`, `app-icon` head tags, the++ navbar bootstrap script with its `JSON.stringify(…).replace(/</g, '\\u003c')` guard,++ `frame.noscriptNav`, the footer call) once every page goes through the shell.++3. `ov test` in the repo; the SSR tests keep asserting the canonical, the `og:` tags and the++ no-JS nav, which the shell emits unchanged.++++`templates/site/server/app.js` is the reference: it already calls `shell.page`.@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "2.5.0",++ "version": "2.6.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "2.5.0",++ "version": "2.6.0","license": "MIT","bin": {"openvibe-build-assets": "scripts/build-assets.js"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "2.5.0",++ "version": "2.6.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -57,6 +57,7 @@"./release-watch": "./release-watch.js","./seo": "./seo.js","./serve": "./serve.js",++ "./shell": "./shell.js","./showcase": "./showcase.js","./shipped": "./shipped.js","./sso-client": "./sso-client.js",nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * openvibe-shared/shell — compose a whole server-rendered page from the pieces every OpenVibe site++ * already ships: the SEO head (`seo.headTags` / `seo.pageSummary`), the OpenVibe Frame++ * (`frame.noscriptNav`, `frame.footer`), the theme-loader before the first paint, and the deferred++ * web runtime (`web-runtime.js`, `navbar.js`, `footer.js`) booted from an escaped JSON blob.++ *++ * The shell adds no markup of its own — it is the composition the hand-written `<!doctype html>`++ * layouts repeat, in one place, so a repo's `layout.js` can become a call to it (the T11 sweep).++ *++ * const shell = require('openvibe-shared/shell');++ * res.type('html').send(shell.page({++ * name: 'example', title: 'Example', description: '…', canonical: 'https://example.com/',++ * summary: 'One line an AI can read',++ * body: '<main>…</main>',++ * features: { chat: { scripts: ['/js/chat.js'] } },++ * routes: [{ path: '^/chat', features: ['chat'] }],++ * }));++ *++ * page(o) the whole document: doctype, <head>, <body>.++ * scripts(o) just the <head> scripts (theme-loader, the deferred runtime, the boot JSON and the++ * inline boot call).++ *++ * Every value that reaches markup is escaped: `seo` escapes the head, this module escapes `lang` and++ * the body attributes, and the runtime boot is `JSON.stringify(…).replace(/</g, '\\u003c')` so a++ * feature name can never close the <script> and break out.++ */++const seo = require('./seo');++const frame = require('./frame');++const serve = require('./serve');++++const esc = seo.esc;++++/** JSON that is safe inside a <script>: `<` is escaped so nothing can close the element. */++function json(value) {++ return JSON.stringify(value).replace(/</g, '\\u003c');++}++++/** HTML attributes from an object; `true` is a bare attribute, null/false/undefined are dropped. */++function attrs(map) {++ return Object.entries(map || {})++ .filter(([, v]) => v != null && v !== false)++ .map(([k, v]) => (v === true ? ` ${esc(k)}` : ` ${esc(k)}="${esc(v)}"`))++ .join('');++}++++/**++ * The page's scripts, in order: `theme-loader.js` eagerly (it applies the theme before the first++ * paint), then `web-runtime.js`, `navbar.js` and `footer.js` deferred via `serve.url`, then one++ * inline script that waits for DOMContentLoaded, hands the escaped JSON boot to++ * `OVWebRuntime.create(…).boot()` and initialises the navbar.++ *++ * o: name/service, navbar (OpenVibeNavbar.init options, `service` defaults to name),++ * features, routes, versions (the runtime boot; `routes[].path` is a string, not a RegExp).++ */++function scripts(o = {}) {++ const name = o.name || o.service || 'OpenVibe';++ const navbar = { service: name, ...(o.navbar || {}) };++ const boot = { features: o.features || {}, routes: o.routes || [] };++ if (o.versions) boot.versions = o.versions;++ return [++ `<script src="${esc(serve.url('theme-loader.js'))}"></script>`,++ `<script src="${esc(serve.url('web-runtime.js'))}" defer></script>`,++ `<script src="${esc(serve.url('navbar.js'))}" defer></script>`,++ `<script src="${esc(serve.url('footer.js'))}" defer></script>`,++ `<script>window.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init(${json(navbar)}); OVWebRuntime.create(${json(boot)}).boot(); });</script>`,++ ].join('\n');++}++++/**++ * Compose a whole page. Options:++ * name / service the site's service name (navbar init, noscript-nav brand, footer default)++ * lang ('en') `<html lang>`++ * title, siteName, description, canonical, image, imageAlt, type, robots, keywords, author,++ * themeColor, twitterSite, locale, titleSuffix, alternates, jsonLd → `seo.headTags`++ * summary a one-line AI summary; when given, `seo.pageSummary`'s head goes in <head>++ * and its <noscript> block at the top of <body> (facts/url/updated feed it)++ * facts, url, updated the `seo.pageSummary` inputs++ * head extra <head> markup appended verbatim (already trusted/escaped)++ * body the page's main content (trusted markup — the caller escaped it)++ * bodyAttributes attributes for <body>, escaped; bodyClass is shorthand for `class`++ * navLinks, home the `frame.noscriptNav` no-JavaScript navigation++ * footer `frame.footer` config; defaults to { service: name, variant: 'compact' }++ * navbar, features, routes, versions the runtime boot (see `scripts`)++ */++function page(o = {}) {++ const name = o.name || o.service || 'OpenVibe';++ const title = o.title || name;++ const summary = o.summary == null ? null++ : seo.pageSummary({ title, summary: o.summary, facts: o.facts, url: o.url || o.canonical, updated: o.updated });++ const head = seo.headTags({++ title, siteName: o.siteName || name, description: o.description,++ canonical: o.canonical, image: o.image, imageAlt: o.imageAlt, type: o.type,++ robots: o.robots, keywords: o.keywords, author: o.author, themeColor: o.themeColor,++ twitterSite: o.twitterSite, locale: o.locale, titleSuffix: o.titleSuffix,++ alternates: o.alternates, jsonLd: o.jsonLd,++ });++ const bodyAttributes = { ...(o.bodyClass ? { class: o.bodyClass } : {}), ...(o.bodyAttributes || {}) };++ 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,++ summary ? summary.head : '',++ o.head || '',++ scripts(o),++ '</head>',++ `<body${attrs(bodyAttributes)}>`,++ summary ? summary.body : '',++ frame.noscriptNav({ name, home: o.home, links: o.navLinks }),++ o.body || '',++ frame.footer(o.footer || { service: name, variant: 'compact' }),++ '</body>',++ '</html>',++ ].filter((part) => part !== '').join('\n');++}++++module.exports = { page, scripts };@@@ -2,7 +2,7 @@const express = require('express');const serve = require('openvibe-shared/serve');--const frame = require('openvibe-shared/frame');++const shell = require('openvibe-shared/shell');const seo = require('openvibe-shared/seo');const cache = require('openvibe-shared/cache-policy');@@@ -35,26 +35,17 @@ app.get('/llms-full.txt', (req, res) => {});app.get('/', cache.applyHtml(), (req, res) => {-- const head = seo.headTags({ title: name, siteName: name, description: name, canonical: home });-- const summary = seo.pageSummary({ title: name, summary: name, url: home });-- res.type('html').send(`<!doctype html>--<html lang="en">--<head>--<meta charset="utf-8">--<meta name="viewport" content="width=device-width, initial-scale=1">--${head}--${summary}--<script src="${serve.url('web-runtime.js')}" defer></script>--<script src="${serve.url('navbar.js')}" defer></script>--<script src="${serve.url('footer.js')}" defer></script>--<script>window.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: '${name}' }); OVWebRuntime.create({ features: {}, routes: [] }).boot(); });</script>--</head>--<body>--${frame.noscriptNav({ name })}--<main><h1>${seo.esc(name)}</h1></main>--${frame.footer({ service: name, variant: 'compact' })}--</body>--</html>`);++ res.type('html').send(shell.page({++ name,++ title: name,++ siteName: name,++ description: name,++ canonical: home,++ summary: name,++ url: home,++ body: `<main><h1>${seo.esc(name)}</h1></main>`,++ footer: { service: name, variant: 'compact' },++ }));});module.exports = app;@@@ -29,7 +29,7 @@ function request(server, path) {assert.match(home.body, /<meta name="ai-summary" content="[^"]+">/);assert.match(home.body, /"@type":"WebPage"/);assert.match(home.body, /OpenVibeNavbar\.init\(/);-- assert.match(home.body, /OVWebRuntime\.create\(\{ features: \{\}, routes: \[\] \}\)\.boot\(\)/);++ assert.match(home.body, /OVWebRuntime\.create\(\{"features":\{\},"routes":\[\]\}\)\.boot\(\)/);assert.equal(home.headers['cache-control'], 'public, max-age=120, stale-while-revalidate=3600');const robots = await request(server, '/robots.txt');nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++// openvibe-shared/shell composes a whole page. These are the invariants a consumed shell must keep:++// the SEO head is present, the theme-loader runs before every other script, the runtime scripts are++// deferred and in order, the runtime boot is parseable JSON with `<` escaped, interpolated values are++// escaped, the page summary appears only when asked for, and the no-JS nav + shared footer are there.++const assert = require('node:assert/strict');++const shell = require('openvibe-shared/shell');++++// ── A whole document, in order ────────────────────────────────────────────────────────────────────++const html = shell.page({++ name: 'example',++ title: 'Example <site>',++ siteName: 'Example',++ description: 'A <b>description</b>',++ canonical: 'https://example.com/',++ lang: 'en-GB',++ body: '<main id="hero">Hello</main>',++});++assert.ok(html.startsWith('<!doctype html>\n<html lang="en-GB">'), 'doctype and <html lang>');++assert.ok(html.trimEnd().endsWith('</html>'), 'document closes');++assert.match(html, /<title>Example <site><\/title>/, 'title escaped');++assert.match(html, /<meta name="description" content="A <b>description<\/b>">/, 'description escaped');++assert.match(html, /<link rel="canonical" href="https:\/\/example\.com\/">/, 'canonical present');++assert.match(html, /<meta property="og:title" content="Example <site>">/, 'og:title present and escaped');++assert.match(html, /<noscript><nav aria-label="Site"/, 'no-JS nav present');++assert.match(html, /<footer id="ov-footer"/, 'shared footer present');++assert.ok(html.includes('<main id="hero">Hello</main>'), 'body kept verbatim');++assert.ok(html.includes('<script src="/shared/theme-loader.js?v='), 'theme-loader via serve.url');++++// ── Script order: theme-loader first (eager); the runtime after it (deferred) ─────────────────────++const scripts = shell.scripts({ name: 'example' });++assert.ok(!scripts.includes('<!doctype'), 'scripts() is the head block only');++const order = ['theme-loader.js', 'web-runtime.js', 'navbar.js', 'footer.js'].map((f) => html.indexOf(f));++assert.ok(order.every((i) => i > 0), 'every runtime script is present');++assert.deepEqual([...order].sort((a, b) => a - b), order, 'theme-loader, web-runtime, navbar, footer');++const themeTag = html.match(/<script src="[^"]*theme-loader[^"]*"[^>]*><\/script>/)[0];++assert.ok(!/ defer/.test(themeTag), 'theme-loader is eager (before first paint)');++for (const f of ['web-runtime.js', 'navbar.js', 'footer.js']) {++ assert.match(html, new RegExp(`<script src="[^"]*${f.replace('.', '\\.')}[^"]*" defer><\\/script>`), `${f} is deferred`);++}++++// ── The runtime boot is JSON, parseable, and can never close the <script> ─────────────────────────++const bootSrc = shell.page({++ name: 'example',++ features: { chat: { scripts: ['/js/chat.js'] } },++ routes: [{ path: '^/chat', features: ['chat'] }],++ versions: { '/js/chat.js': 'abc123' },++});++const m = bootSrc.match(/OVWebRuntime\.create\((\{.*?\})\)\.boot\(\);/);++assert.ok(m, 'the create(...).boot() call is present');++assert.deepEqual(JSON.parse(m[1]), {++ features: { chat: { scripts: ['/js/chat.js'] } },++ routes: [{ path: '^/chat', features: ['chat'] }],++ versions: { '/js/chat.js': 'abc123' },++});++assert.match(bootSrc, /OpenVibeNavbar\.init\(\{"service":"example"\}\);/, 'navbar init carries the service');++const evil = shell.page({ name: 'x', features: { a: '</script><script>alert(1)</script>' } });++assert.ok(!evil.includes('</script><script>alert(1)'), 'a feature name cannot break out of the boot script');++assert.ok(evil.includes('\\u003c/script>'), 'the boot escapes <');++++// ── Body attributes are escaped ───────────────────────────────────────────────────────────────────++const attrs = shell.page({ name: 'x', bodyClass: 'a" onload="b', bodyAttributes: { 'data-x': '" y="', hidden: true, skip: null } });++assert.ok(attrs.includes('<body class="a" onload="b" data-x="" y="" hidden>'), 'body attributes escaped, bare flag kept, null dropped');++++// ── The AI page summary is present only when given, and its JSON-LD comes with it ────────────────++assert.ok(!shell.page({ name: 'x' }).includes('"@type":"WebPage"'), 'no WebPage JSON-LD without a summary');++const withSummary = shell.page({ name: 'x', title: 'X', summary: 'One line', facts: [['Sites', 12]], url: 'https://example.com/' });++assert.match(withSummary, /<meta name="ai-summary" content="One line">/, 'ai-summary meta');++assert.match(withSummary, /"@type":"WebPage"/, 'WebPage JSON-LD with the summary');++assert.match(withSummary, /<noscript><section data-ai-summary>/, 'noscript summary in the body');++assert.ok(withSummary.indexOf('</head>') < withSummary.indexOf('<noscript><section data-ai-summary>'), 'summary body sits after </head>');++++console.log('shell: all checks passed');
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 2.6.0 — 2026-10-03
+
+- `openvibe-shared/shell` (new): `page(o)` composes a whole server-rendered document — the doctype
+ and `<head>` from `seo.headTags` (plus `seo.pageSummary` when `summary` is given), the theme-loader
+ before the first paint, `web-runtime.js`/`navbar.js`/`footer.js` deferred through `serve.url`, the
+ no-JavaScript nav from `frame.noscriptNav`, the body and `frame.footer`. `scripts(o)` returns just
+ the head scripts and the inline boot: the runtime options as JSON with `<` escaped, so a value can
+ never close the `<script>`. Every interpolated value (title, description, `lang`, body attributes)
+ is escaped. `templates/site/server/app.js` now calls `shell.page` instead of hand-writing the
+ document. Additive: a new subpath export, nothing removed.
+
## 2.5.0 — 2026-10-02
## 2.5.0 — 2026-10-02
- `seo.feedXml(feed, { format })` takes items as `{ title, link, guid?, description?, content?, author?,
- `seo.feedXml(feed, { format })` takes items as `{ title, link, guid?, description?, content?, author?,
@@ -20,7 +20,7 @@ were reconciled first.
@@ -20,7 +20,7 @@ were reconciled first.
| Kind | Files |
| Kind | Files |
|---|---|
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
-| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
+| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
@@ -544,6 +544,53 @@ Tags the server already put in the document count as loaded. Global stubs (insta
@@ -544,6 +544,53 @@ Tags the server already put in the document count as loaded. Global stubs (insta
handlers working before their feature has loaded.
handlers working before their feature has loaded.
```
```
+A server-rendered page puts these tags in its `<head>` itself, with the SEO head and the OpenVibe
+Frame around them: `openvibe-shared/shell` (`shell.page(o)`) composes exactly that — see "The page
+shell" below.
+
+### The page shell (`shell.js`)
+
+```text
+openvibe-shared/shell — compose a whole server-rendered page from the pieces a site already ships,
+so a repo's layout.js becomes one call instead of a hand-written <!doctype html> document (the T11
+sweep). It adds no markup of its own.
+
+ const shell = require('openvibe-shared/shell');
+ res.type('html').send(shell.page({
+ name: 'example', service: 'example',
+ title: 'Example', siteName: 'OpenVibe.Example', description: '…',
+ canonical: 'https://example.com/', image: '…', imageAlt: '…', type: 'website',
+ summary: 'One line an AI can read', facts: [['Sites', 12]], url: 'https://example.com/',
+ jsonLd: [seo.jsonLd.website({ … })],
+ body: '<main>…</main>', // the page's main content (trusted, already escaped)
+ bodyClass: 'home', bodyAttributes: { 'data-ov-content': 'pages' },
+ navLinks: [{ label: 'Docs', href: '/docs' }], home: '/',
+ footer: { service: 'example', variant: 'compact' },
+ features: { chat: { scripts: ['/js/chat.js'] } },
+ routes: [{ path: '^/chat', features: ['chat'] }],
+ }));
+
+page(o) → the whole document: doctype, <head>, <body>. The head is seo.headTags (title,
+ description, canonical, robots, Open Graph, Twitter, any jsonLd); when `summary` is
+ given, seo.pageSummary adds its <meta name="ai-summary"> and WebPage JSON-LD to the
+ head and its <noscript> block at the top of the body. Then theme-loader.js eagerly (it
+ applies the theme before the first paint), web-runtime.js/navbar.js/footer.js deferred
+ through serve.url, the frame.noscriptNav no-JavaScript nav, the body and frame.footer
+ (footer.ssr). The runtime boot is inline JSON handed to
+ OVWebRuntime.create(…).boot(), with the navbar init next to it.
+scripts(o) → just that <head> script block (theme-loader, the deferred runtime, the boot JSON and
+ the inline boot call). `routes[].path` is a string, not a RegExp.
+```
+
+Every interpolated value is escaped: the head through `seo`, `lang` and the body attributes here, and
+the runtime boot with `JSON.stringify(…).replace(/</g, '\\u003c')` so a feature name can never close
+the `<script>`.
+
+Each repo's `layout.js` moves onto `shell.page()` in the T11 sweep; until then the shell and the
+hand-written layouts produce the same document. `templates/site/server/app.js` (the scaffold every
+new site starts from) already calls it. See `docs/migration-plan.md`, "T11: layout.js onto
+openvibe-shared/shell".
+
### The navbar (`navbar.js`)
### The navbar (`navbar.js`)
```text
```text
@@ -625,3 +625,28 @@ reason the Network keeps `/shared/<file>` on the v1 line (README, compatibility
@@ -625,3 +625,28 @@ reason the Network keeps `/shared/<file>` on the v1 line (README, compatibility
`find ~/OpenVibers -path '*/vendor/openvibe-shared' -type d` finds nothing.
`find ~/OpenVibers -path '*/vendor/openvibe-shared' -type d` finds nothing.
- Every future shared change follows the same path: a PR to OpenVibe.Shared, a `vX.Y.Z` tag,
- Every future shared change follows the same path: a PR to OpenVibe.Shared, a `vX.Y.Z` tag,
then a pin bump in Network first and in the other consumers after.
then a pin bump in Network first and in the other consumers after.
+
+---
+
+## T11: `layout.js` onto `openvibe-shared/shell`
+
+Shared v2.6.0 adds `openvibe-shared/shell` (`page(o)` / `scripts(o)`), the composition every
+server-rendered `server/render/layout.js` repeats by hand: the doctype and `<head>` from
+`seo.headTags` (and `seo.pageSummary` when `summary` is given), the theme-loader before the first
+paint, `web-runtime.js`/`navbar.js`/`footer.js` deferred through `serve.url`, the
+`frame.noscriptNav` no-JavaScript nav, the body and `frame.footer`. It adds no markup of its own, so
+a repo's `renderPage(o)` keeps its document while its body becomes one `shell.page({ … })` call.
+
+The sweep (one PR per repo, after the pin is on v2.6.0):
+
+1. `server/render/layout.js` — require `openvibe-shared/shell`; replace the hand-written
+ `<!doctype html>` document with `shell.page({ name, title, description, canonical, image,
+ jsonLd, summary, body, navLinks, footer, features, routes })`. Keep the per-repo `o` mapping
+ (title suffix, og type, gate decision) and pass its values through.
+2. Drop the local head builders the shell now owns (`seo.metaTags`, `app-icon` head tags, the
+ navbar bootstrap script with its `JSON.stringify(…).replace(/</g, '\\u003c')` guard,
+ `frame.noscriptNav`, the footer call) once every page goes through the shell.
+3. `ov test` in the repo; the SSR tests keep asserting the canonical, the `og:` tags and the
+ no-JS nav, which the shell emits unchanged.
+
+`templates/site/server/app.js` is the reference: it already calls `shell.page`.
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.5.0",
+ "version": "2.6.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.5.0",
+ "version": "2.6.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.5.0",
+ "version": "2.6.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -57,6 +57,7 @@
@@ -57,6 +57,7 @@
"./release-watch": "./release-watch.js",
"./release-watch": "./release-watch.js",
"./seo": "./seo.js",
"./seo": "./seo.js",
"./serve": "./serve.js",
"./serve": "./serve.js",
+ "./shell": "./shell.js",
"./showcase": "./showcase.js",
"./showcase": "./showcase.js",
"./shipped": "./shipped.js",
"./shipped": "./shipped.js",
"./sso-client": "./sso-client.js",
"./sso-client": "./sso-client.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * openvibe-shared/shell — compose a whole server-rendered page from the pieces every OpenVibe site
+ * already ships: the SEO head (`seo.headTags` / `seo.pageSummary`), the OpenVibe Frame
+ * (`frame.noscriptNav`, `frame.footer`), the theme-loader before the first paint, and the deferred
+ * web runtime (`web-runtime.js`, `navbar.js`, `footer.js`) booted from an escaped JSON blob.
+ *
+ * The shell adds no markup of its own — it is the composition the hand-written `<!doctype html>`
+ * layouts repeat, in one place, so a repo's `layout.js` can become a call to it (the T11 sweep).
+ *
+ * const shell = require('openvibe-shared/shell');
+ * res.type('html').send(shell.page({
+ * name: 'example', title: 'Example', description: '…', canonical: 'https://example.com/',
+ * summary: 'One line an AI can read',
+ * body: '<main>…</main>',
+ * features: { chat: { scripts: ['/js/chat.js'] } },
+ * routes: [{ path: '^/chat', features: ['chat'] }],
+ * }));
+ *
+ * page(o) the whole document: doctype, <head>, <body>.
+ * scripts(o) just the <head> scripts (theme-loader, the deferred runtime, the boot JSON and the
+ * inline boot call).
+ *
+ * Every value that reaches markup is escaped: `seo` escapes the head, this module escapes `lang` and
+ * the body attributes, and the runtime boot is `JSON.stringify(…).replace(/</g, '\\u003c')` so a
+ * feature name can never close the <script> and break out.
+ */
+const seo = require('./seo');
+const frame = require('./frame');
+const serve = require('./serve');
+
+const esc = seo.esc;
+
+/** JSON that is safe inside a <script>: `<` is escaped so nothing can close the element. */
+function json(value) {
+ return JSON.stringify(value).replace(/</g, '\\u003c');
+}
+
+/** HTML attributes from an object; `true` is a bare attribute, null/false/undefined are dropped. */
+function attrs(map) {
+ return Object.entries(map || {})
+ .filter(([, v]) => v != null && v !== false)
+ .map(([k, v]) => (v === true ? ` ${esc(k)}` : ` ${esc(k)}="${esc(v)}"`))
+ .join('');
+}
+
+/**
+ * The page's scripts, in order: `theme-loader.js` eagerly (it applies the theme before the first
+ * paint), then `web-runtime.js`, `navbar.js` and `footer.js` deferred via `serve.url`, then one
+ * inline script that waits for DOMContentLoaded, hands the escaped JSON boot to
+ * `OVWebRuntime.create(…).boot()` and initialises the navbar.
+ *
+ * o: name/service, navbar (OpenVibeNavbar.init options, `service` defaults to name),
+ * features, routes, versions (the runtime boot; `routes[].path` is a string, not a RegExp).
+ */
+function scripts(o = {}) {
+ const name = o.name || o.service || 'OpenVibe';
+ const navbar = { service: name, ...(o.navbar || {}) };
+ const boot = { features: o.features || {}, routes: o.routes || [] };
+ if (o.versions) boot.versions = o.versions;
+ return [
+ `<script src="${esc(serve.url('theme-loader.js'))}"></script>`,
+ `<script src="${esc(serve.url('web-runtime.js'))}" defer></script>`,
+ `<script src="${esc(serve.url('navbar.js'))}" defer></script>`,
+ `<script src="${esc(serve.url('footer.js'))}" defer></script>`,
+ `<script>window.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init(${json(navbar)}); OVWebRuntime.create(${json(boot)}).boot(); });</script>`,
+ ].join('\n');
+}
+
+/**
+ * Compose a whole page. Options:
+ * name / service the site's service name (navbar init, noscript-nav brand, footer default)
+ * lang ('en') `<html lang>`
+ * title, siteName, description, canonical, image, imageAlt, type, robots, keywords, author,
+ * themeColor, twitterSite, locale, titleSuffix, alternates, jsonLd → `seo.headTags`
+ * summary a one-line AI summary; when given, `seo.pageSummary`'s head goes in <head>
+ * and its <noscript> block at the top of <body> (facts/url/updated feed it)
+ * facts, url, updated the `seo.pageSummary` inputs
+ * head extra <head> markup appended verbatim (already trusted/escaped)
+ * body the page's main content (trusted markup — the caller escaped it)
+ * bodyAttributes attributes for <body>, escaped; bodyClass is shorthand for `class`
+ * navLinks, home the `frame.noscriptNav` no-JavaScript navigation
+ * footer `frame.footer` config; defaults to { service: name, variant: 'compact' }
+ * navbar, features, routes, versions the runtime boot (see `scripts`)
+ */
+function page(o = {}) {
+ const name = o.name || o.service || 'OpenVibe';
+ const title = o.title || name;
+ const summary = o.summary == null ? null
+ : seo.pageSummary({ title, summary: o.summary, facts: o.facts, url: o.url || o.canonical, updated: o.updated });
+ const head = seo.headTags({
+ title, siteName: o.siteName || name, description: o.description,
+ canonical: o.canonical, image: o.image, imageAlt: o.imageAlt, type: o.type,
+ robots: o.robots, keywords: o.keywords, author: o.author, themeColor: o.themeColor,
+ twitterSite: o.twitterSite, locale: o.locale, titleSuffix: o.titleSuffix,
+ alternates: o.alternates, jsonLd: o.jsonLd,
+ });
+ const bodyAttributes = { ...(o.bodyClass ? { class: o.bodyClass } : {}), ...(o.bodyAttributes || {}) };
+ 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,
+ summary ? summary.head : '',
+ o.head || '',
+ scripts(o),
+ '</head>',
+ `<body${attrs(bodyAttributes)}>`,
+ summary ? summary.body : '',
+ frame.noscriptNav({ name, home: o.home, links: o.navLinks }),
+ o.body || '',
+ frame.footer(o.footer || { service: name, variant: 'compact' }),
+ '</body>',
+ '</html>',
+ ].filter((part) => part !== '').join('\n');
+}
+
+module.exports = { page, scripts };
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
const express = require('express');
const express = require('express');
const serve = require('openvibe-shared/serve');
const serve = require('openvibe-shared/serve');
-const frame = require('openvibe-shared/frame');
+const shell = require('openvibe-shared/shell');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
@@ -35,26 +35,17 @@ app.get('/llms-full.txt', (req, res) => {
@@ -35,26 +35,17 @@ app.get('/llms-full.txt', (req, res) => {
});
});
app.get('/', cache.applyHtml(), (req, res) => {
app.get('/', cache.applyHtml(), (req, res) => {
- const head = seo.headTags({ title: name, siteName: name, description: name, canonical: home });
- const summary = seo.pageSummary({ title: name, summary: name, url: home });
- res.type('html').send(`<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-${head}
-${summary}
-<script src="${serve.url('web-runtime.js')}" defer></script>
-<script src="${serve.url('navbar.js')}" defer></script>
-<script src="${serve.url('footer.js')}" defer></script>
-<script>window.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: '${name}' }); OVWebRuntime.create({ features: {}, routes: [] }).boot(); });</script>
-</head>
-<body>
-${frame.noscriptNav({ name })}
-<main><h1>${seo.esc(name)}</h1></main>
-${frame.footer({ service: name, variant: 'compact' })}
-</body>
-</html>`);
+ res.type('html').send(shell.page({
+ name,
+ title: name,
+ siteName: name,
+ description: name,
+ canonical: home,
+ summary: name,
+ url: home,
+ body: `<main><h1>${seo.esc(name)}</h1></main>`,
+ footer: { service: name, variant: 'compact' },
+ }));
});
});
module.exports = app;
module.exports = app;
@@ -29,7 +29,7 @@ function request(server, path) {
@@ -29,7 +29,7 @@ function request(server, path) {
assert.match(home.body, /<meta name="ai-summary" content="[^"]+">/);
assert.match(home.body, /<meta name="ai-summary" content="[^"]+">/);
assert.match(home.body, /"@type":"WebPage"/);
assert.match(home.body, /"@type":"WebPage"/);
assert.match(home.body, /OpenVibeNavbar\.init\(/);
assert.match(home.body, /OpenVibeNavbar\.init\(/);
- assert.match(home.body, /OVWebRuntime\.create\(\{ features: \{\}, routes: \[\] \}\)\.boot\(\)/);
+ assert.match(home.body, /OVWebRuntime\.create\(\{"features":\{\},"routes":\[\]\}\)\.boot\(\)/);
assert.equal(home.headers['cache-control'], 'public, max-age=120, stale-while-revalidate=3600');
assert.equal(home.headers['cache-control'], 'public, max-age=120, stale-while-revalidate=3600');
const robots = await request(server, '/robots.txt');
const robots = await request(server, '/robots.txt');
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+// openvibe-shared/shell composes a whole page. These are the invariants a consumed shell must keep:
+// the SEO head is present, the theme-loader runs before every other script, the runtime scripts are
+// deferred and in order, the runtime boot is parseable JSON with `<` escaped, interpolated values are
+// escaped, the page summary appears only when asked for, and the no-JS nav + shared footer are there.
+const assert = require('node:assert/strict');
+const shell = require('openvibe-shared/shell');
+
+// ── A whole document, in order ────────────────────────────────────────────────────────────────────
+const html = shell.page({
+ name: 'example',
+ title: 'Example <site>',
+ siteName: 'Example',
+ description: 'A <b>description</b>',
+ canonical: 'https://example.com/',
+ lang: 'en-GB',
+ body: '<main id="hero">Hello</main>',
+});
+assert.ok(html.startsWith('<!doctype html>\n<html lang="en-GB">'), 'doctype and <html lang>');
+assert.ok(html.trimEnd().endsWith('</html>'), 'document closes');
+assert.match(html, /<title>Example <site><\/title>/, 'title escaped');
+assert.match(html, /<meta name="description" content="A <b>description<\/b>">/, 'description escaped');
+assert.match(html, /<link rel="canonical" href="https:\/\/example\.com\/">/, 'canonical present');
+assert.match(html, /<meta property="og:title" content="Example <site>">/, 'og:title present and escaped');
+assert.match(html, /<noscript><nav aria-label="Site"/, 'no-JS nav present');
+assert.match(html, /<footer id="ov-footer"/, 'shared footer present');
+assert.ok(html.includes('<main id="hero">Hello</main>'), 'body kept verbatim');
+assert.ok(html.includes('<script src="/shared/theme-loader.js?v='), 'theme-loader via serve.url');
+
+// ── Script order: theme-loader first (eager); the runtime after it (deferred) ─────────────────────
+const scripts = shell.scripts({ name: 'example' });
+assert.ok(!scripts.includes('<!doctype'), 'scripts() is the head block only');
+const order = ['theme-loader.js', 'web-runtime.js', 'navbar.js', 'footer.js'].map((f) => html.indexOf(f));
+assert.ok(order.every((i) => i > 0), 'every runtime script is present');
+assert.deepEqual([...order].sort((a, b) => a - b), order, 'theme-loader, web-runtime, navbar, footer');
+const themeTag = html.match(/<script src="[^"]*theme-loader[^"]*"[^>]*><\/script>/)[0];
+assert.ok(!/ defer/.test(themeTag), 'theme-loader is eager (before first paint)');
+for (const f of ['web-runtime.js', 'navbar.js', 'footer.js']) {
+ assert.match(html, new RegExp(`<script src="[^"]*${f.replace('.', '\\.')}[^"]*" defer><\\/script>`), `${f} is deferred`);
+}
+
+// ── The runtime boot is JSON, parseable, and can never close the <script> ─────────────────────────
+const bootSrc = shell.page({
+ name: 'example',
+ features: { chat: { scripts: ['/js/chat.js'] } },
+ routes: [{ path: '^/chat', features: ['chat'] }],
+ versions: { '/js/chat.js': 'abc123' },
+});
+const m = bootSrc.match(/OVWebRuntime\.create\((\{.*?\})\)\.boot\(\);/);
+assert.ok(m, 'the create(...).boot() call is present');
+assert.deepEqual(JSON.parse(m[1]), {
+ features: { chat: { scripts: ['/js/chat.js'] } },
+ routes: [{ path: '^/chat', features: ['chat'] }],
+ versions: { '/js/chat.js': 'abc123' },
+});
+assert.match(bootSrc, /OpenVibeNavbar\.init\(\{"service":"example"\}\);/, 'navbar init carries the service');
+const evil = shell.page({ name: 'x', features: { a: '</script><script>alert(1)</script>' } });
+assert.ok(!evil.includes('</script><script>alert(1)'), 'a feature name cannot break out of the boot script');
+assert.ok(evil.includes('\\u003c/script>'), 'the boot escapes <');
+
+// ── Body attributes are escaped ───────────────────────────────────────────────────────────────────
+const attrs = shell.page({ name: 'x', bodyClass: 'a" onload="b', bodyAttributes: { 'data-x': '" y="', hidden: true, skip: null } });
+assert.ok(attrs.includes('<body class="a" onload="b" data-x="" y="" hidden>'), 'body attributes escaped, bare flag kept, null dropped');
+
+// ── The AI page summary is present only when given, and its JSON-LD comes with it ────────────────
+assert.ok(!shell.page({ name: 'x' }).includes('"@type":"WebPage"'), 'no WebPage JSON-LD without a summary');
+const withSummary = shell.page({ name: 'x', title: 'X', summary: 'One line', facts: [['Sites', 12]], url: 'https://example.com/' });
+assert.match(withSummary, /<meta name="ai-summary" content="One line">/, 'ai-summary meta');
+assert.match(withSummary, /"@type":"WebPage"/, 'WebPage JSON-LD with the summary');
+assert.match(withSummary, /<noscript><section data-ai-summary>/, 'noscript summary in the body');
+assert.ok(withSummary.indexOf('</head>') < withSummary.indexOf('<noscript><section data-ai-summary>'), 'summary body sits after </head>');
+
+console.log('shell: all checks passed');