openvibers/openvibe.shared Commits

Render navbar links in server HTML (#405)

OP OpenVibers committed 3 days ago · 40803ba46e · 9 files, +63 −13

* Render navbar links in server HTML

* Keep server navigation visible without JavaScript

* Reuse navLinks in client navbar boot

* Align release metadata for 4.181.0

* Merge origin/main into nav HTML branch

* Share navbar default links with server rendering

* Merge origin/main and bump Shared to 4.184.0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8653545..1e6f96f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex c770fbe..202c82d 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex acca2f6..e46fe57 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/frame.js b/frame.js
iindex c32558b..23c6db6 100644
---- a/frame.js
++++ b/frame.js
@@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
ddiff --git a/navbar.js b/navbar.js
iindex 575586d..7a88051 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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 body
const 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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 9aaf899..9e2ae7c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -6,7 +6,7 @@
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.183.0",
++ "version": "4.184.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex e31176f..9dd2faa 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/shell.js b/shell.js
iindex 8f201dc..1d0c6d9 100644
---- a/shell.js
++++ b/shell.js
@@@ -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>',
ddiff --git a/test/shell.test.js b/test/shell.test.js
iindex 69f6b32..25d3eb1 100644
---- a/test/shell.test.js
++++ b/test/shell.test.js
@@@ -24,12 +24,12 @@ 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, /<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&quot; onload=&quot;b" data-x="&quot; y=&quot;" 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/' });