openvibers/openvibe.shared Commits

T11 lane D step 1 — openvibe-shared/shell, the server-rendered page… (#12)

OP OpenVibers committed 3 Oct 2026 · a567e37728 · 9 files, +294 −26

checks run by GitHub CI (test) · CI ✓ 3/3 · review SHIP by deepseek-v4.1-flash-fast

https://github.com/OpenVibers/OpenVibe.Shared/pull/12

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0082918..a3ad0a7 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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?,
ddiff --git a/README.md b/README.md
iindex f734ff9..f54142e 100644
---- a/README.md
++++ b/README.md
@@@ -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 (insta
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`)
```text
ddiff --git a/docs/migration-plan.md b/docs/migration-plan.md
iindex c9888fc..422472c 100644
---- a/docs/migration-plan.md
++++ b/docs/migration-plan.md
@@@ -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`.
ddiff --git a/package-lock.json b/package-lock.json
iindex c478749..78792de 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex bbdb1e6..34ac524 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/shell.js b/shell.js
nnew file mode 100644
iindex 0000000..3996229
---- /dev/null
++++ b/shell.js
@@@ -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 };
ddiff --git a/templates/site/server/app.js b/templates/site/server/app.js
iindex 0c0f54b..5def4df 100644
---- a/templates/site/server/app.js
++++ b/templates/site/server/app.js
@@@ -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;
ddiff --git a/templates/site/test/smoke.test.js b/templates/site/test/smoke.test.js
iindex 0add8b9..56a5a51 100644
---- a/templates/site/test/smoke.test.js
++++ b/templates/site/test/smoke.test.js
@@@ -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');
ddiff --git a/test/shell.test.js b/test/shell.test.js
nnew file mode 100644
iindex 0000000..ac53f30
---- /dev/null
++++ b/test/shell.test.js
@@@ -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 &lt;site&gt;<\/title>/, 'title escaped');
++assert.match(html, /<meta name="description" content="A &lt;b&gt;description&lt;\/b&gt;">/, 'description escaped');
++assert.match(html, /<link rel="canonical" href="https:\/\/example\.com\/">/, 'canonical present');
++assert.match(html, /<meta property="og:title" content="Example &lt;site&gt;">/, '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&quot; onload=&quot;b" data-x="&quot; y=&quot;" 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');