openvibers/openvibe.shared Commits
Render navbar links in server HTML (#405)
Changes
@@@ -1,5 +1,11 @@# Changelog++## 4.184.0 — 2026-11-10++++- **Server-rendered navbar links.** The shared shell emits the configured navbar links in the reserved `#navbar-mount`++ so crawlers can read site navigation from the response HTML. The client replaces that node's contents atomically++ on hydration; server and browser use the same configured or default link list, with the existing 52px reserve.++## 4.183.0 — 2026-11-10**Motion: Calm reduces, only Off removes.** The owner, 2026-11-10: "Reduce motion is too much reduction, it ruins a lot@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.183.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.184.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.183.0",++ "version": "4.184.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -11,6 +11,7 @@// shippedScript() the <script> tag for shipped.js (the footer loads it lazily too; a page that shows the// log should load it itself)const footerModule = require('./footer');++const navbarModule = require('./navbar');const SITES = [['Live', 'https://openvibe.live/'], ['Tools', 'https://openvibe.tools/'], ['Community', 'https://openvibe.community/'], ['Games', 'https://openvibe.games/'], ['Media', 'https://openvibe.media/'], ['Network', 'https://openvibe.network/']];const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));@@@ -21,6 +22,16 @@ function noscriptNav({ name, home = '/', links = [] } = {}) {return `<noscript><nav aria-label="Site" style="display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:12px 20px;border-bottom:1px solid rgba(255,255,255,.1);font:500 14px/1.4 system-ui,sans-serif"><a href="${esc(home)}" style="font-weight:700;color:inherit;text-decoration:none">${esc(name || 'OpenVibe')}</a>${own}<span style="flex:1"></span>${net}</nav></noscript>`;}++// The client navbar replaces this node on its first render. Keeping configured links in the same++// mount makes the page useful to crawlers before any JavaScript runs.++function serverNav({ name, service, home = '/', links } = {}) {++ const source = Array.isArray(links) ? links : navbarModule._defaults.linksFor(service);++ const items = source.filter(l => l && (l.href || l.url) && (l.label || l.name));++ const own = items.map(l => `<a data-ov-nav-link="true" href="${esc(l.href || l.url)}">${esc(l.label || l.name)}</a>`).join('');++ const brand = `<a href="${esc(home)}" style="font-weight:700;color:inherit;text-decoration:none">${esc(name || 'OpenVibe')}</a>`;++ return `<div id="navbar-mount"><nav class="openvibe-navbar" aria-label="Site" data-ov-server-nav="true" style="box-sizing:border-box;display:flex;align-items:center;gap:8px;padding:0 16px;min-height:52px;font:500 14px/1.4 system-ui,sans-serif">${brand}${own}</nav></div>`;++}++const NETWORK = 'https://openvibe.network';const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;const svc = (s) => (SERVICE_RE.test(String(s || '')) ? String(s) : '');@@@ -59,4 +70,4 @@ function shippedScript({ base = NETWORK } = {}) {return `<script src="${esc(String(base).replace(/\/+$/, ''))}/shared/shipped.js${v ? `?v=${v}` : ''}" defer></script>`;}--module.exports = { footer: (cfg) => footerModule.ssr(cfg), noscriptNav, shipped, updatesBody, shippedScript };++module.exports = { footer: (cfg) => footerModule.ssr(cfg), noscriptNav, serverNav, shipped, updatesBody, shippedScript };@@@ -1503,7 +1503,7 @@}function currentLinks() {-- const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];++ const list = _runtimeLinks || _config.links || linksFor(_config.service);const path = (typeof location !== 'undefined' && location.pathname) || '/';const activePage = _activePage;return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (activePage ? l.page === activePage : (l.href === path && path !== '/')) }));@@@ -1593,6 +1593,11 @@],};++ // The server shell calls this same table so its HTML matches the browser defaults.++ function linksFor(service) {++ return (SERVICE_LINKS[service] || []).map(link => ({ ...link }));++ }++// Accounts seen here (no tokens).const SEEN_KEY = 'openvibe_seen_accounts';function readList(key) { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }@@@ -2272,7 +2277,9 @@// Insert into page — use navbar-mount placeholder if available, otherwise prepend to bodyconst mount = document.getElementById('navbar-mount');if (mount) {-- mount.appendChild(nav);++ // Replace the crawlable server nav atomically; never leave duplicate landmarks.++ if (mount.replaceChildren) mount.replaceChildren(nav);++ else { while (mount.firstChild) mount.removeChild(mount.firstChild); mount.appendChild(nav); }} else {document.body.prepend(nav);}@@@ -2289,7 +2296,7 @@// ── The phone app bar (appbar.js, loaded only when the site sets appBar): its main places as a bottom tab bar ──let _appBarLoading = false;-- const rawLinks = () => _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];++ const rawLinks = () => _runtimeLinks || _config.links || linksFor(_config.service);// Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.let _searchLoading = false;@@@ -2725,6 +2732,7 @@},_ask: { selectedText, askVibeHref },_wallet: { loadWallet, walletOrder, syncWalletChips, loadWalletChips, configure: (c) => { Object.assign(_config, c); _walletLoaded = false; } },++ _defaults: { linksFor },};if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;@@@ -6,7 +6,7 @@"packages": {"": {"name": "openvibe-shared",-- "version": "4.183.0",++ "version": "4.184.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.183.0",++ "version": "4.184.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -38,6 +38,12 @@ function json(value) {return JSON.stringify(value).replace(/</g, '\\u003c');}++function navbarOptions(o, name) {++ const navbar = { service: name, ...(o.navbar || {}) };++ if (navbar.links === undefined && Array.isArray(o.navLinks)) navbar.links = o.navLinks;++ return navbar;++}++/** HTML attributes from an object; `true` is a bare attribute, null/false/undefined are dropped. */function attrs(map) {return Object.entries(map || {})@@@ -57,7 +63,7 @@ function attrs(map) {*/function scripts(o = {}) {const name = o.name || o.service || 'OpenVibe';-- const navbar = { service: name, ...(o.navbar || {}) };++ const navbar = navbarOptions(o, name);const footer = o.footer || { service: name, variant: 'compact' };const boot = { features: o.features || {}, routes: o.routes || [] };if (o.versions) boot.versions = o.versions;@@@ -145,10 +151,11 @@ function faviconLink(o) {// (openvibe.events and openvibe.bot showed an 8px frame around the footer). The same page also had no font: its text fell// back to the browser's Times New Roman (five sites' /updates, 2026-11-01), so the body gets the network's type at zero// specificity, which any site's own rule beats.--const NAVBAR_RESERVE = '<style>body{margin:0}:where(body){font-family:var(--ov-font,\'Inter\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,\'Helvetica Neue\',Arial,sans-serif)}#navbar-mount{min-height:52px}</style><noscript><style>#navbar-mount{display:none}</style></noscript>';++const NAVBAR_RESERVE = '<style>body{margin:0}:where(body){font-family:var(--ov-font,\'Inter\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,\'Helvetica Neue\',Arial,sans-serif)}#navbar-mount{min-height:52px}</style>';function page(o = {}) {const name = o.name || o.service || 'OpenVibe';++ const navbar = navbarOptions(o, name);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 });@@@ -178,7 +185,7 @@ function page(o = {}) {'</head>',`<body${attrs(bodyAttributes)}>`,summary ? summary.body : '',-- frame.noscriptNav({ name, home: o.home, links: o.navLinks }),++ frame.serverNav({ name, service: navbar.service, home: o.home, links: navbar.links }),o.body || '',frame.footer(o.footer || { service: name, variant: 'compact' }),'</body>',@@@ -24,12 +24,12 @@ 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, /<div id="navbar-mount"><nav class="openvibe-navbar" aria-label="Site"/, 'server nav present in the client mount');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');// The deferred navbar lands in a #navbar-mount that already holds its height; without JavaScript the mount goes away.--assert.ok(html.includes('#navbar-mount{min-height:52px}</style><noscript><style>#navbar-mount{display:none}</style></noscript>'), 'navbar mount reserved');++assert.ok(html.includes('#navbar-mount{min-height:52px}</style>'), 'navbar mount reserved');assert.ok(html.includes('<style>body{margin:0}'), 'no browser margin around the body');// A page with no stylesheet of its own reads in the network's type, not the browser's serif (4.84.0), and at zero// specificity, so a site's own body rule always wins.@@@ -78,6 +78,24 @@ assert.ok(evil.includes('\\u003c/script>'), 'the boot escapes <');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');++// Server navigation uses the exact configured links sent to OpenVibeNavbar.init, in the mount++// that the client replaces atomically after hydration.++{++ const links = [{ label: 'Places', href: '/places' }, { label: 'Elsewhere', href: 'https://openvibe.live/' }];++ const rendered = shell.page({ name: 'example', navbar: { service: 'example', links }, body: '' });++ assert.ok(rendered.includes('<a data-ov-nav-link="true" href="/places">Places</a>'), 'server emits configured local links');++ assert.ok(rendered.includes('<a data-ov-nav-link="true" href="https://openvibe.live/">Elsewhere</a>'), 'server emits configured cross-site links');++ assert.match(rendered, /OpenVibeNavbar\.init\(\{"service":"example","links":\[\{"label":"Places","href":"\/places"\}/, 'client receives same configured list');++ assert.equal((rendered.match(/aria-label="Site"/g) || []).length, 1, 'one navigation landmark before hydration');++}++{++ const defaults = require('../navbar')._defaults.linksFor('tools');++ const rendered = shell.page({ name: 'OpenVibe.Tools', navbar: { service: 'tools' }, body: '' });++ for (const link of defaults) assert.ok(rendered.includes(`<a data-ov-nav-link="true" href="${link.href}">${link.label}</a>`), `server uses the navbar default ${link.label}`);++ assert.equal((rendered.match(/aria-label="Site"/g) || []).length, 1, 'one default navigation landmark');++ assert.match(fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8'), /mount\.replaceChildren\(nav\)/, 'hydration atomically replaces the server nav');++}++// ── 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/' });
@@ -1,5 +1,11 @@
@@ -1,5 +1,11 @@
# Changelog
# Changelog
+## 4.184.0 — 2026-11-10
+
+- **Server-rendered navbar links.** The shared shell emits the configured navbar links in the reserved `#navbar-mount`
+ so crawlers can read site navigation from the response HTML. The client replaces that node's contents atomically
+ on hydration; server and browser use the same configured or default link list, with the existing 52px reserve.
+
## 4.183.0 — 2026-11-10
## 4.183.0 — 2026-11-10
**Motion: Calm reduces, only Off removes.** The owner, 2026-11-10: "Reduce motion is too much reduction, it ruins a lot
**Motion: Calm reduces, only Off removes.** The owner, 2026-11-10: "Reduce motion is too much reduction, it ruins a lot
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.183.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.184.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.183.0",
+ "version": "4.184.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
// shippedScript() the <script> tag for shipped.js (the footer loads it lazily too; a page that shows the
// shippedScript() the <script> tag for shipped.js (the footer loads it lazily too; a page that shows the
// log should load it itself)
// log should load it itself)
const footerModule = require('./footer');
const footerModule = require('./footer');
+const navbarModule = require('./navbar');
const SITES = [['Live', 'https://openvibe.live/'], ['Tools', 'https://openvibe.tools/'], ['Community', 'https://openvibe.community/'], ['Games', 'https://openvibe.games/'], ['Media', 'https://openvibe.media/'], ['Network', 'https://openvibe.network/']];
const SITES = [['Live', 'https://openvibe.live/'], ['Tools', 'https://openvibe.tools/'], ['Community', 'https://openvibe.community/'], ['Games', 'https://openvibe.games/'], ['Media', 'https://openvibe.media/'], ['Network', 'https://openvibe.network/']];
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
@@ -21,6 +22,16 @@ function noscriptNav({ name, home = '/', links = [] } = {}) {
@@ -21,6 +22,16 @@ function noscriptNav({ name, home = '/', links = [] } = {}) {
return `<noscript><nav aria-label="Site" style="display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:12px 20px;border-bottom:1px solid rgba(255,255,255,.1);font:500 14px/1.4 system-ui,sans-serif"><a href="${esc(home)}" style="font-weight:700;color:inherit;text-decoration:none">${esc(name || 'OpenVibe')}</a>${own}<span style="flex:1"></span>${net}</nav></noscript>`;
return `<noscript><nav aria-label="Site" style="display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:12px 20px;border-bottom:1px solid rgba(255,255,255,.1);font:500 14px/1.4 system-ui,sans-serif"><a href="${esc(home)}" style="font-weight:700;color:inherit;text-decoration:none">${esc(name || 'OpenVibe')}</a>${own}<span style="flex:1"></span>${net}</nav></noscript>`;
}
}
+// The client navbar replaces this node on its first render. Keeping configured links in the same
+// mount makes the page useful to crawlers before any JavaScript runs.
+function serverNav({ name, service, home = '/', links } = {}) {
+ const source = Array.isArray(links) ? links : navbarModule._defaults.linksFor(service);
+ const items = source.filter(l => l && (l.href || l.url) && (l.label || l.name));
+ const own = items.map(l => `<a data-ov-nav-link="true" href="${esc(l.href || l.url)}">${esc(l.label || l.name)}</a>`).join('');
+ const brand = `<a href="${esc(home)}" style="font-weight:700;color:inherit;text-decoration:none">${esc(name || 'OpenVibe')}</a>`;
+ return `<div id="navbar-mount"><nav class="openvibe-navbar" aria-label="Site" data-ov-server-nav="true" style="box-sizing:border-box;display:flex;align-items:center;gap:8px;padding:0 16px;min-height:52px;font:500 14px/1.4 system-ui,sans-serif">${brand}${own}</nav></div>`;
+}
+
const NETWORK = 'https://openvibe.network';
const NETWORK = 'https://openvibe.network';
const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const svc = (s) => (SERVICE_RE.test(String(s || '')) ? String(s) : '');
const svc = (s) => (SERVICE_RE.test(String(s || '')) ? String(s) : '');
@@ -59,4 +70,4 @@ function shippedScript({ base = NETWORK } = {}) {
@@ -59,4 +70,4 @@ function shippedScript({ base = NETWORK } = {}) {
return `<script src="${esc(String(base).replace(/\/+$/, ''))}/shared/shipped.js${v ? `?v=${v}` : ''}" defer></script>`;
return `<script src="${esc(String(base).replace(/\/+$/, ''))}/shared/shipped.js${v ? `?v=${v}` : ''}" defer></script>`;
}
}
-module.exports = { footer: (cfg) => footerModule.ssr(cfg), noscriptNav, shipped, updatesBody, shippedScript };
+module.exports = { footer: (cfg) => footerModule.ssr(cfg), noscriptNav, serverNav, shipped, updatesBody, shippedScript };
@@ -1503,7 +1503,7 @@
@@ -1503,7 +1503,7 @@
}
}
function currentLinks() {
function currentLinks() {
- const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
+ const list = _runtimeLinks || _config.links || linksFor(_config.service);
const path = (typeof location !== 'undefined' && location.pathname) || '/';
const path = (typeof location !== 'undefined' && location.pathname) || '/';
const activePage = _activePage;
const activePage = _activePage;
return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (activePage ? l.page === activePage : (l.href === path && path !== '/')) }));
return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (activePage ? l.page === activePage : (l.href === path && path !== '/')) }));
@@ -1593,6 +1593,11 @@
@@ -1593,6 +1593,11 @@
],
],
};
};
+ // The server shell calls this same table so its HTML matches the browser defaults.
+ function linksFor(service) {
+ return (SERVICE_LINKS[service] || []).map(link => ({ ...link }));
+ }
+
// Accounts seen here (no tokens).
// Accounts seen here (no tokens).
const SEEN_KEY = 'openvibe_seen_accounts';
const SEEN_KEY = 'openvibe_seen_accounts';
function readList(key) { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }
function readList(key) { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }
@@ -2272,7 +2277,9 @@
@@ -2272,7 +2277,9 @@
// Insert into page — use navbar-mount placeholder if available, otherwise prepend to body
// Insert into page — use navbar-mount placeholder if available, otherwise prepend to body
const mount = document.getElementById('navbar-mount');
const mount = document.getElementById('navbar-mount');
if (mount) {
if (mount) {
- mount.appendChild(nav);
+ // Replace the crawlable server nav atomically; never leave duplicate landmarks.
+ if (mount.replaceChildren) mount.replaceChildren(nav);
+ else { while (mount.firstChild) mount.removeChild(mount.firstChild); mount.appendChild(nav); }
} else {
} else {
document.body.prepend(nav);
document.body.prepend(nav);
}
}
@@ -2289,7 +2296,7 @@
@@ -2289,7 +2296,7 @@
// ── The phone app bar (appbar.js, loaded only when the site sets appBar): its main places as a bottom tab bar ──
// ── The phone app bar (appbar.js, loaded only when the site sets appBar): its main places as a bottom tab bar ──
let _appBarLoading = false;
let _appBarLoading = false;
- const rawLinks = () => _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
+ const rawLinks = () => _runtimeLinks || _config.links || linksFor(_config.service);
// Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.
// Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.
let _searchLoading = false;
let _searchLoading = false;
@@ -2725,6 +2732,7 @@
@@ -2725,6 +2732,7 @@
},
},
_ask: { selectedText, askVibeHref },
_ask: { selectedText, askVibeHref },
_wallet: { loadWallet, walletOrder, syncWalletChips, loadWalletChips, configure: (c) => { Object.assign(_config, c); _walletLoaded = false; } },
_wallet: { loadWallet, walletOrder, syncWalletChips, loadWalletChips, configure: (c) => { Object.assign(_config, c); _walletLoaded = false; } },
+ _defaults: { linksFor },
};
};
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.183.0",
+ "version": "4.184.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.183.0",
+ "version": "4.184.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": {
@@ -38,6 +38,12 @@ function json(value) {
@@ -38,6 +38,12 @@ function json(value) {
return JSON.stringify(value).replace(/</g, '\\u003c');
return JSON.stringify(value).replace(/</g, '\\u003c');
}
}
+function navbarOptions(o, name) {
+ const navbar = { service: name, ...(o.navbar || {}) };
+ if (navbar.links === undefined && Array.isArray(o.navLinks)) navbar.links = o.navLinks;
+ return navbar;
+}
+
/** HTML attributes from an object; `true` is a bare attribute, null/false/undefined are dropped. */
/** HTML attributes from an object; `true` is a bare attribute, null/false/undefined are dropped. */
function attrs(map) {
function attrs(map) {
return Object.entries(map || {})
return Object.entries(map || {})
@@ -57,7 +63,7 @@ function attrs(map) {
@@ -57,7 +63,7 @@ function attrs(map) {
*/
*/
function scripts(o = {}) {
function scripts(o = {}) {
const name = o.name || o.service || 'OpenVibe';
const name = o.name || o.service || 'OpenVibe';
- const navbar = { service: name, ...(o.navbar || {}) };
+ const navbar = navbarOptions(o, name);
const footer = o.footer || { service: name, variant: 'compact' };
const footer = o.footer || { service: name, variant: 'compact' };
const boot = { features: o.features || {}, routes: o.routes || [] };
const boot = { features: o.features || {}, routes: o.routes || [] };
if (o.versions) boot.versions = o.versions;
if (o.versions) boot.versions = o.versions;
@@ -145,10 +151,11 @@ function faviconLink(o) {
@@ -145,10 +151,11 @@ function faviconLink(o) {
// (openvibe.events and openvibe.bot showed an 8px frame around the footer). The same page also had no font: its text fell
// (openvibe.events and openvibe.bot showed an 8px frame around the footer). The same page also had no font: its text fell
// back to the browser's Times New Roman (five sites' /updates, 2026-11-01), so the body gets the network's type at zero
// back to the browser's Times New Roman (five sites' /updates, 2026-11-01), so the body gets the network's type at zero
// specificity, which any site's own rule beats.
// specificity, which any site's own rule beats.
-const NAVBAR_RESERVE = '<style>body{margin:0}:where(body){font-family:var(--ov-font,\'Inter\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,\'Helvetica Neue\',Arial,sans-serif)}#navbar-mount{min-height:52px}</style><noscript><style>#navbar-mount{display:none}</style></noscript>';
+const NAVBAR_RESERVE = '<style>body{margin:0}:where(body){font-family:var(--ov-font,\'Inter\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,\'Helvetica Neue\',Arial,sans-serif)}#navbar-mount{min-height:52px}</style>';
function page(o = {}) {
function page(o = {}) {
const name = o.name || o.service || 'OpenVibe';
const name = o.name || o.service || 'OpenVibe';
+ const navbar = navbarOptions(o, name);
const title = o.title || name;
const title = o.title || name;
const summary = o.summary == null ? null
const summary = o.summary == null ? null
: seo.pageSummary({ title, summary: o.summary, facts: o.facts, url: o.url || o.canonical, updated: o.updated });
: seo.pageSummary({ title, summary: o.summary, facts: o.facts, url: o.url || o.canonical, updated: o.updated });
@@ -178,7 +185,7 @@ function page(o = {}) {
@@ -178,7 +185,7 @@ function page(o = {}) {
'</head>',
'</head>',
`<body${attrs(bodyAttributes)}>`,
`<body${attrs(bodyAttributes)}>`,
summary ? summary.body : '',
summary ? summary.body : '',
- frame.noscriptNav({ name, home: o.home, links: o.navLinks }),
+ frame.serverNav({ name, service: navbar.service, home: o.home, links: navbar.links }),
o.body || '',
o.body || '',
frame.footer(o.footer || { service: name, variant: 'compact' }),
frame.footer(o.footer || { service: name, variant: 'compact' }),
'</body>',
'</body>',
@@ -24,12 +24,12 @@ assert.match(html, /<title>Example <site><\/title>/, 'title escaped');
@@ -24,12 +24,12 @@ 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, /<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, /<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, /<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, /<div id="navbar-mount"><nav class="openvibe-navbar" aria-label="Site"/, 'server nav present in the client mount');
assert.match(html, /<footer id="ov-footer"/, 'shared footer 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('<main id="hero">Hello</main>'), 'body kept verbatim');
assert.ok(html.includes('<script src="/shared/theme-loader.js?v='), 'theme-loader via serve.url');
assert.ok(html.includes('<script src="/shared/theme-loader.js?v='), 'theme-loader via serve.url');
// The deferred navbar lands in a #navbar-mount that already holds its height; without JavaScript the mount goes away.
// The deferred navbar lands in a #navbar-mount that already holds its height; without JavaScript the mount goes away.
-assert.ok(html.includes('#navbar-mount{min-height:52px}</style><noscript><style>#navbar-mount{display:none}</style></noscript>'), 'navbar mount reserved');
+assert.ok(html.includes('#navbar-mount{min-height:52px}</style>'), 'navbar mount reserved');
assert.ok(html.includes('<style>body{margin:0}'), 'no browser margin around the body');
assert.ok(html.includes('<style>body{margin:0}'), 'no browser margin around the body');
// A page with no stylesheet of its own reads in the network's type, not the browser's serif (4.84.0), and at zero
// A page with no stylesheet of its own reads in the network's type, not the browser's serif (4.84.0), and at zero
// specificity, so a site's own body rule always wins.
// specificity, so a site's own body rule always wins.
@@ -78,6 +78,24 @@ assert.ok(evil.includes('\\u003c/script>'), 'the boot escapes <');
@@ -78,6 +78,24 @@ assert.ok(evil.includes('\\u003c/script>'), 'the boot escapes <');
const attrs = shell.page({ name: 'x', bodyClass: 'a" onload="b', bodyAttributes: { 'data-x': '" y="', hidden: true, skip: null } });
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');
assert.ok(attrs.includes('<body class="a" onload="b" data-x="" y="" hidden>'), 'body attributes escaped, bare flag kept, null dropped');
+// Server navigation uses the exact configured links sent to OpenVibeNavbar.init, in the mount
+// that the client replaces atomically after hydration.
+{
+ const links = [{ label: 'Places', href: '/places' }, { label: 'Elsewhere', href: 'https://openvibe.live/' }];
+ const rendered = shell.page({ name: 'example', navbar: { service: 'example', links }, body: '' });
+ assert.ok(rendered.includes('<a data-ov-nav-link="true" href="/places">Places</a>'), 'server emits configured local links');
+ assert.ok(rendered.includes('<a data-ov-nav-link="true" href="https://openvibe.live/">Elsewhere</a>'), 'server emits configured cross-site links');
+ assert.match(rendered, /OpenVibeNavbar\.init\(\{"service":"example","links":\[\{"label":"Places","href":"\/places"\}/, 'client receives same configured list');
+ assert.equal((rendered.match(/aria-label="Site"/g) || []).length, 1, 'one navigation landmark before hydration');
+}
+{
+ const defaults = require('../navbar')._defaults.linksFor('tools');
+ const rendered = shell.page({ name: 'OpenVibe.Tools', navbar: { service: 'tools' }, body: '' });
+ for (const link of defaults) assert.ok(rendered.includes(`<a data-ov-nav-link="true" href="${link.href}">${link.label}</a>`), `server uses the navbar default ${link.label}`);
+ assert.equal((rendered.match(/aria-label="Site"/g) || []).length, 1, 'one default navigation landmark');
+ assert.match(fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8'), /mount\.replaceChildren\(nav\)/, 'hydration atomically replaces the server nav');
+}
+
// ── The AI page summary is present only when given, and its JSON-LD comes with it ────────────────
// ── 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');
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/' });
const withSummary = shell.page({ name: 'x', title: 'X', summary: 'One line', facts: [['Sites', 12]], url: 'https://example.com/' });