openvibers/openvibe.shared shell.js
shell.js
| 1 | ; |
| 2 | /** |
| 3 | * openvibe-shared/shell — compose a whole server-rendered page from the pieces every OpenVibe site |
| 4 | * already ships: the SEO head (`seo.headTags` / `seo.pageSummary`), the OpenVibe Frame |
| 5 | * (`frame.noscriptNav`, `frame.footer`), the theme-loader before the first paint, and the deferred |
| 6 | * web runtime (`web-runtime.js`, `navbar.js`, `footer.js`) booted from an escaped JSON blob. |
| 7 | * |
| 8 | * The shell adds no markup of its own — it is the composition the hand-written `<!doctype html>` |
| 9 | * layouts repeat, in one place, so a repo's `layout.js` can become a call to it (the T11 sweep). |
| 10 | * |
| 11 | * const shell = require('openvibe-shared/shell'); |
| 12 | * res.type('html').send(shell.page({ |
| 13 | * name: 'example', title: 'Example', description: '…', canonical: 'https://example.com/', |
| 14 | * summary: 'One line an AI can read', |
| 15 | * body: '<main>…</main>', |
| 16 | * features: { chat: { scripts: ['/js/chat.js'] } }, |
| 17 | * routes: [{ path: '^/chat', features: ['chat'] }], |
| 18 | * })); |
| 19 | * |
| 20 | * page(o) the whole document: doctype, <head>, <body>. |
| 21 | * scripts(o) just the <head> scripts (theme-loader, the deferred runtime, the boot JSON and the |
| 22 | * inline boot call). |
| 23 | * |
| 24 | * Every value that reaches markup is escaped: `seo` escapes the head, this module escapes `lang` and |
| 25 | * the body attributes, and the runtime boot is `JSON.stringify(…).replace(/</g, '\\u003c')` so a |
| 26 | * feature name can never close the <script> and break out. |
| 27 | */ |
| 28 | const seo = require('./seo'); |
| 29 | const frame = require('./frame'); |
| 30 | const serve = require('./serve'); |
| 31 | const social = require('./social'); |
| 32 | const appIcons = require('./app-icons'); |
| 33 | |
| 34 | const esc = seo.esc; |
| 35 | |
| 36 | /** JSON that is safe inside a <script>: `<` is escaped so nothing can close the element. */ |
| 37 | function json(value) { |
| 38 | return JSON.stringify(value).replace(/</g, '\\u003c'); |
| 39 | } |
| 40 | |
| 41 | function navbarOptions(o, name) { |
| 42 | const navbar = { service: name, ...(o.navbar || {}) }; |
| 43 | if (navbar.links === undefined && Array.isArray(o.navLinks)) navbar.links = o.navLinks; |
| 44 | return navbar; |
| 45 | } |
| 46 | |
| 47 | /** HTML attributes from an object; `true` is a bare attribute, null/false/undefined are dropped. */ |
| 48 | function attrs(map) { |
| 49 | return Object.entries(map || {}) |
| 50 | .filter(([, v]) => v != null && v !== false) |
| 51 | .map(([k, v]) => (v === true ? ` ${esc(k)}` : ` ${esc(k)}="${esc(v)}"`)) |
| 52 | .join(''); |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * The page's scripts, in order: `theme-loader.js` eagerly (it applies the theme before the first |
| 57 | * paint), then `web-runtime.js`, `navbar.js` and `footer.js` deferred via `serve.url`, then one |
| 58 | * inline script that waits for DOMContentLoaded, hands the escaped JSON boot to |
| 59 | * `OVWebRuntime.create(…).boot()` and initialises the navbar. |
| 60 | * |
| 61 | * o: name/service, navbar (OpenVibeNavbar.init options, `service` defaults to name), |
| 62 | * features, routes, versions (the runtime boot; `routes[].path` is a string, not a RegExp). |
| 63 | */ |
| 64 | function scripts(o = {}) { |
| 65 | const name = o.name || o.service || 'OpenVibe'; |
| 66 | const navbar = navbarOptions(o, name); |
| 67 | const footer = o.footer || { service: name, variant: 'compact' }; |
| 68 | const boot = { features: o.features || {}, routes: o.routes || [] }; |
| 69 | if (o.versions) boot.versions = o.versions; |
| 70 | return [ |
| 71 | `<script src="${esc(serve.url('theme-loader.js'))}"></script>`, |
| 72 | `<script src="${esc(serve.url('web-runtime.js'))}" defer></script>`, |
| 73 | `<script src="${esc(serve.url('navbar.js'))}" defer></script>`, |
| 74 | `<script src="${esc(serve.url('footer.js'))}" defer></script>`, |
| 75 | // The footer this page renders (frame.footer) is started here too, a moment after the site's own scripts: a site |
| 76 | // that starts it itself (OpenVibeFooter.init) keeps its own; one that does not gets its phone folds and its line. |
| 77 | `<script>window.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init(${json(navbar)}); OVWebRuntime.create(${json(boot)}).boot(); setTimeout(function () { var f = document.getElementById('ov-footer'); if (window.OpenVibeFooter && f && !f.hasAttribute('data-ovf-live')) OpenVibeFooter.init(${json(footer)}); }, 0); });</script>`, |
| 78 | ].join('\n'); |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Compose a whole page. Options: |
| 83 | * name / service the site's service name (navbar init, noscript-nav brand, footer default) |
| 84 | * lang ('en') `<html lang>` |
| 85 | * title, siteName, description, canonical, image, imageAlt, type, robots, keywords, author, |
| 86 | * themeColor, twitterSite, locale, titleSuffix, alternates, jsonLd → `seo.headTags` |
| 87 | * summary a one-line AI summary; when given, `seo.pageSummary`'s head goes in <head> |
| 88 | * and its <noscript> block at the top of <body> (facts/url/updated feed it) |
| 89 | * facts, url, updated the `seo.pageSummary` inputs |
| 90 | * head extra <head> markup appended verbatim (already trusted/escaped) |
| 91 | * manifest the installable web app links (manifest + apple-touch-icon) are added when the |
| 92 | * page's service has an icon set; `false` opts out |
| 93 | * favicon the tab icon: the service's OpenVibe mark (openvibe-shared/app-icon favicon) as an inline |
| 94 | * SVG, added unless `head` already has a rel=icon link; `false` opts out (4.157.0) |
| 95 | * body the page's main content (trusted markup — the caller escaped it) |
| 96 | * bodyAttributes attributes for <body>, escaped; bodyClass is shorthand for `class` |
| 97 | * navLinks, home the `frame.noscriptNav` no-JavaScript navigation |
| 98 | * footer `frame.footer` config; defaults to { service: name, variant: 'compact' } |
| 99 | * navbar, features, routes, versions the runtime boot (see `scripts`) |
| 100 | */ |
| 101 | // Most sites name their service on the navbar and footer configs rather than on the page itself. |
| 102 | function serviceOf(o) { |
| 103 | return o.service || (o.navbar && o.navbar.service) || (o.footer && o.footer.service); |
| 104 | } |
| 105 | |
| 106 | // The site's id as theme-loader.js reads it from <html data-ov-service>: a short lowercase name (live, chat, bot…), else none. |
| 107 | const siteId = (o) => { const s = String(serviceOf(o) || '').toLowerCase(); return /^[a-z][a-z0-9-]{1,31}$/.test(s) ? s : ''; }; |
| 108 | |
| 109 | // A page that names no image shares its site's card (openvibe-shared/social), when the site has one and the page |
| 110 | // has a canonical URL to make it absolute against; a page on one of a service's named domains (openvibe.pics) shares |
| 111 | // that domain's card. `image: null` opts out. |
| 112 | function defaultImage(o) { |
| 113 | if (!o.canonical) return null; |
| 114 | let host = ''; |
| 115 | try { host = new URL(o.canonical).host; } catch (e) { /* a relative canonical: the service's card */ } |
| 116 | const domain = social.domainForHost(host); |
| 117 | if (domain && social.has(domain.id)) return social.url(domain.id); |
| 118 | const service = serviceOf(o); |
| 119 | return service ? social.url(service) : null; |
| 120 | } |
| 121 | |
| 122 | // The installable web app links: the hashed manifest and the 192px touch icon. `manifest: false` opts out (a site |
| 123 | // that serves its own /manifest.webmanifest), and a page whose `head` already carries a manifest link keeps it. |
| 124 | function manifestLinks(o) { |
| 125 | if (o.manifest === false) return ''; |
| 126 | const service = serviceOf(o); |
| 127 | if (!service || !appIcons.has(service)) return ''; |
| 128 | if (typeof o.head === 'string' && /rel=["']?manifest/.test(o.head)) return ''; |
| 129 | const manifest = serve.manifestUrl(service); |
| 130 | const touch = appIcons.url(service, 192); |
| 131 | if (!manifest || !touch) return ''; |
| 132 | return `<link rel="manifest" href="${esc(manifest)}">\n<link rel="apple-touch-icon" href="${esc(touch)}">`; |
| 133 | } |
| 134 | |
| 135 | // The tab icon (4.157.0): without one the browser asks /favicon.ico, which most sites answer 404, and the tab shows a |
| 136 | // blank page icon (11-09: Computer, Email and Studio, all on this shell). The service's mark, the same tint as its app |
| 137 | // icons; a page whose `head` already has an icon keeps its own. |
| 138 | function faviconLink(o) { |
| 139 | if (o.favicon === false) return ''; |
| 140 | if (typeof o.head === 'string' && /rel=["']?(?:shortcut )?icon\b/i.test(o.head)) return ''; |
| 141 | const service = siteId(o) || 'network'; |
| 142 | const known = appIcons.site(service); |
| 143 | const variant = String((known && known.icon) || service).replace(/[^a-z0-9-]/g, ''); |
| 144 | const uri = 'data:image/svg+xml,' + encodeURIComponent(require('./app-icon').favicon({ site: variant })).replace(/'/g, '%27'); |
| 145 | return `<link rel="icon" type="image/svg+xml" data-ov-icon="${esc(variant)}" href="${uri}">`; |
| 146 | } |
| 147 | |
| 148 | // The shared navbar's script is deferred and mounts into the page's #navbar-mount: the mount holds the bar's 52px from |
| 149 | // the first paint so the page does not drop when it lands (layout shift), and without JavaScript it goes away. The body |
| 150 | // has no browser margin, so the Frame's navbar and footer meet the window's edges on a page with no stylesheet of its own |
| 151 | // (openvibe.events and openvibe.bot showed an 8px frame around the footer). The same page also had no font: its text fell |
| 152 | // back to the browser's Times New Roman (five sites' /updates, 2026-11-01), so the body gets the network's type at zero |
| 153 | // specificity, which any site's own rule beats. |
| 154 | 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>'; |
| 155 | |
| 156 | // One bar mount per page (4.190.0). A page whose own body has <div id="navbar-mount"></div> gets the server's bar in |
| 157 | // that place; before, it got a second mount, and the empty one held 52 px under the bar on seventeen sites. |
| 158 | const OWN_MOUNT = /<div id="navbar-mount"(?:\s[^>]*)?>\s*<\/div>/; |
| 159 | function oneMount(nav, body) { |
| 160 | return OWN_MOUNT.test(body) ? [body.replace(OWN_MOUNT, () => nav)] : [nav, body]; |
| 161 | } |
| 162 | |
| 163 | function page(o = {}) { |
| 164 | const name = o.name || o.service || 'OpenVibe'; |
| 165 | const navbar = navbarOptions(o, name); |
| 166 | const title = o.title || name; |
| 167 | const summary = o.summary == null ? null |
| 168 | : seo.pageSummary({ title, summary: o.summary, facts: o.facts, url: o.url || o.canonical, updated: o.updated }); |
| 169 | const head = seo.headTags({ |
| 170 | title, siteName: o.siteName || name, description: o.description, |
| 171 | canonical: o.canonical, image: o.image === undefined ? defaultImage(o) : o.image, imageAlt: o.imageAlt || (o.image === undefined && defaultImage(o) ? o.siteName || name : undefined), type: o.type, |
| 172 | robots: o.robots, keywords: o.keywords, author: o.author, themeColor: o.themeColor, |
| 173 | twitterSite: o.twitterSite, locale: o.locale, titleSuffix: o.titleSuffix, |
| 174 | alternates: o.alternates, jsonLd: o.jsonLd, |
| 175 | }); |
| 176 | const bodyAttributes = { ...(o.bodyClass ? { class: o.bodyClass } : {}), ...(o.bodyAttributes || {}) }; |
| 177 | return [ |
| 178 | '<!doctype html>', |
| 179 | // data-ov-service: which site this is, so theme-loader.js can ask for the person's theme for this site (4.43.0). |
| 180 | `<html lang="${esc(o.lang || 'en')}"${siteId(o) ? ` data-ov-service="${siteId(o)}"` : ''}>`, |
| 181 | '<head>', |
| 182 | '<meta charset="utf-8">', |
| 183 | '<meta name="viewport" content="width=device-width, initial-scale=1">', |
| 184 | head, |
| 185 | o.agentLayer && o.canonical ? require('./agent-layer').linkHeaderTags(o.canonical, o.llms).head : '', |
| 186 | summary ? summary.head : '', |
| 187 | o.head || '', |
| 188 | faviconLink(o), |
| 189 | manifestLinks(o), |
| 190 | NAVBAR_RESERVE, |
| 191 | scripts(o), |
| 192 | '</head>', |
| 193 | `<body${attrs(bodyAttributes)}>`, |
| 194 | summary ? summary.body : '', |
| 195 | ...oneMount(frame.serverNav({ name, service: navbar.service, home: o.home, links: navbar.links }), o.body || ''), |
| 196 | frame.footer(o.footer || { service: name, variant: 'compact' }), |
| 197 | '</body>', |
| 198 | '</html>', |
| 199 | ].filter((part) => part !== '').join('\n'); |
| 200 | } |
| 201 | |
| 202 | module.exports = { page, scripts }; |
| 203 |