openvibers/openvibe.shared shell.js

shell.js

main 11.5 KB · 203 lines · javascript

1'use strict';
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 */
28const seo = require('./seo');
29const frame = require('./frame');
30const serve = require('./serve');
31const social = require('./social');
32const appIcons = require('./app-icons');
33
34const esc = seo.esc;
35
36/** JSON that is safe inside a <script>: `<` is escaped so nothing can close the element. */
37function json(value) {
38 return JSON.stringify(value).replace(/</g, '\\u003c');
39}
40
41function 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. */
48function 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 */
64function 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.
102function 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.
107const 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.
112function 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.
124function 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.
138function 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.
154const 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.
158const OWN_MOUNT = /<div id="navbar-mount"(?:\s[^>]*)?>\s*<\/div>/;
159function oneMount(nav, body) {
160 return OWN_MOUNT.test(body) ? [body.replace(OWN_MOUNT, () => nav)] : [nav, body];
161}
162
163function 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
202module.exports = { page, scripts };
203