openvibers/openvibe.live Commits

Agent layer: /.well-known/agent.json and Markdown twins of the home, channel pages and VOD/clip lists (#435)

OP OpenVibers committed 15 days ago · 65d28dd18e · 9 files, +297 −12

openvibe-shared v4.34.0 (was v4.33.0). The descriptor is what openvibe.live really serves: API /api, API docs
/documentation, llms.txt/llms-full.txt/sitemap and the API's own limits; no OpenAPI document is published.
server/agent-pages.js twins the home, /@<channel> and the /vods, /clips lists through the same SEO page builder
the HTML routes use. The channel twin carries the live stream's real title and category and the follow links
(the streamer's OpenVibe profile, connected platforms and saved links), with Japanese titles and bios kept as
they are. The server-rendered snapshot is now the page's <main> (the app keeps id="app" role="main"), so the
twin reads the page's own headings and links; HTML gains the discovery <link> tags and Link header, robots.txt
the manifest comment. Recordings and clips moved to OpenVibe.Video (/vod and /clip 301 there), so Live twins
the lists it still serves and Video owns the watch-page twins.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 8ba84092..a94f3eeb 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -25,7 +25,7 @@
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
@@@ -42,6 +42,12 @@
"node": ">=18.0.0"
}
},
++ "node_modules/@acemir/cssom": {
++ "version": "0.9.32",
++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
++ "license": "MIT"
++ },
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
@@@ -988,6 +994,19 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "node_modules/boolbase": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@@ -1202,6 +1221,39 @@
"url": "https://opencollective.com/express"
}
},
++ "node_modules/css-select": {
++ "version": "7.0.0",
++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0",
++ "css-what": "^8.0.0",
++ "domhandler": "^6.0.1",
++ "domutils": "^4.0.2",
++ "nth-check": "^3.0.1"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/css-what": {
++ "version": "8.0.0",
++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@@ -1907,6 +1959,12 @@
"node": ">=16.0.0"
}
},
++ "node_modules/html-escaper": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
++ "license": "MIT"
++ },
"node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
@@@ -2129,6 +2187,128 @@
"node": ">=0.2.0"
}
},
++ "node_modules/linkedom": {
++ "version": "0.18.14",
++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
++ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
++ "license": "ISC",
++ "dependencies": {
++ "@acemir/cssom": "^0.9.32",
++ "css-select": "^7.0.0",
++ "html-escaper": "^3.0.3",
++ "htmlparser2": "^10.1.0",
++ "uhyphen": "^0.2.0"
++ },
++ "engines": {
++ "node": ">=16"
++ },
++ "peerDependencies": {
++ "canvas": ">= 2"
++ },
++ "peerDependenciesMeta": {
++ "canvas": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/linkedom/node_modules/dom-serializer": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.2",
++ "entities": "^4.2.0"
++ },
++ "funding": {
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/linkedom/node_modules/dom-serializer/node_modules/entities": {
++ "version": "4.5.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/linkedom/node_modules/domelementtype": {
++ "version": "2.3.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause"
++ },
++ "node_modules/linkedom/node_modules/domhandler": {
++ "version": "5.0.3",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^2.3.0"
++ },
++ "engines": {
++ "node": ">= 4"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/linkedom/node_modules/domutils": {
++ "version": "3.2.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^2.0.0",
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "node_modules/linkedom/node_modules/entities": {
++ "version": "7.0.1",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/linkedom/node_modules/htmlparser2": {
++ "version": "10.1.0",
++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
++ "funding": [
++ "https://github.com/fb55/htmlparser2?sponsor=1",
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3",
++ "domutils": "^3.2.2",
++ "entities": "^7.0.1"
++ }
++ },
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@@ -2578,6 +2758,22 @@
"url": "https://github.com/sponsors/isaacs"
}
},
++ "node_modules/nth-check": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/nth-check?sponsor=1"
++ }
++ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@@ -2688,12 +2884,13 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.33.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
-- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",
++ "version": "4.34.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
++ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==",
"license": "MIT",
"dependencies": {
-- "jsonwebtoken": "^9.0.0"
++ "jsonwebtoken": "^9.0.0",
++ "linkedom": "^0.18.13"
},
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
@@@ -3428,6 +3625,12 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
++ "node_modules/uhyphen": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
++ "license": "ISC"
++ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
ddiff --git a/package.json b/package.json
iindex 469dbdb1..3e450378 100644
---- a/package.json
++++ b/package.json
@@@ -44,7 +44,7 @@
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
ddiff --git a/public/css/critical-selectors.json b/public/css/critical-selectors.json
iindex a2bfaa50..22e3348f 100644
---- a/public/css/critical-selectors.json
++++ b/public/css/critical-selectors.json
@@@ -399,7 +399,7 @@
"html",
"img",
"input",
-- "main#app",
++ "#app",
"select",
"select.form-input",
"textarea"
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 5b730cc8..495b779c 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -136,7 +136,7 @@ a:hover { color: var(--accent-light); }
img { max-width: 100%; }
button { cursor: pointer; font-family: inherit; }
input, select, textarea { font-family: inherit; }
--main#app { position: relative; z-index: 1; }
++#app { position: relative; z-index: 1; }
/* ── Scrollbar ────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 00000000..29a0ebda
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,17 @@
++'use strict';
++// What openvibe.live tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this site really serves: the JSON API behind the SPA, the API docs page,
++// llms.txt/llms-full.txt/sitemap, and the API's own per-minute limits.
++module.exports = {
++ id: 'live',
++ name: 'OpenVibe.Live',
++ purpose: 'Open-source, community-run live streaming: go live from a browser or OBS, keep every stream as a VOD, clip it, and chat in real time.',
++ home: 'https://openvibe.live',
++ site: 'openvibe.live',
++ api: '/api',
++ docs: '/documentation',
++ policy: {
++ auth: ['none', 'Network OIDC'],
++ rateLimit: '900 GET/HEAD API requests a minute per address, 180 writes; sign-in and registration attempts are limited to 20 per 15 minutes',
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 00000000..5455587f
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,34 @@
++'use strict';
++// The public pages the agent layer serves as Markdown twins (openvibe-shared/agent-layer), rendered by the
++// same SEO middleware builder the HTML routes use, so a twin cannot drift from its page. A twin is English
++// because the page itself is (the SPA translates in place in the browser); Japanese titles, bios and stream
++// facts stay in Japanese in the Markdown, exactly as they are in the HTML.
++//
++// Recordings and clips are watched on OpenVibe.Video now: /vod/:id and /clip/:id 301 there (web/video-handover.js,
++// mounted before this), so Live twins the home, the channel pages and the VOD/clip list pages it still serves.
++const pages = require('./seo/pages');
++
++const CHANNEL_PATH_RE = /^\/@([A-Za-z0-9_]{3,24})$/;
++const LIST_PATHS = new Set(['/vods', '/clips']);
++const CHANNEL_MAX_PAGE = 500;
++
++function pageOf(query) {
++ const raw = query && query.get('page');
++ return /^\d{1,4}$/.test(String(raw || '')) ? Math.min(CHANNEL_MAX_PAGE, Math.max(1, parseInt(raw, 10))) : 1;
++}
++
++function createAgentPages() {
++ async function renderPublicPage(path, { query } = {}) {
++ const p = String(path || '/').replace(/\/+$/, '') || '/';
++ const page = CHANNEL_PATH_RE.test(p) ? pageOf(query) : 1;
++ if (p !== '/' && !CHANNEL_PATH_RE.test(p) && !LIST_PATHS.has(p)) return null;
++ const meta = await pages._pageMeta(p, { page });
++ if (!meta) return null;
++ const html = pages.render(meta, p);
++ return html ? { html, title: meta.title, updated: null } : null;
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages };
ddiff --git a/server/index.js b/server/index.js
iindex 2dc7865c..79fc4900 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -653,6 +653,14 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
require('./web/paste-handover').register(app);
require('./web/video-handover').register(app);
try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
++// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the home,
++// channel pages and VOD/clip lists. Mounted after the SEO middleware, so a browser keeps the page's own head and
++// cache (which now names the twin) and only a twin request reaches this renderer; before the static files and the
++// SPA fallback, so an unknown .md is a 404 rather than a page.
++require('openvibe-shared/agent-layer').mount(app, {
++ descriptor: require('./agent-descriptor'),
++ render: require('./agent-pages').createAgentPages().renderPublicPage,
++});
// Standalone HTML pages (popout chat, kiosk, legal, OBS overlays…) go through the same asset rewrite
// as the SPA shell, so their script and stylesheet URLs can never drift out of date again.
app.use((req, res, next) => {
ddiff --git a/server/seo/discovery.js b/server/seo/discovery.js
iindex b5640fc3..c79f3375 100644
---- a/server/seo/discovery.js
++++ b/server/seo/discovery.js
@@@ -99,7 +99,8 @@ async function sitemapHandler(req, res) {
// ── /robots.txt: every crawler welcome, search and AI alike, pointed at the sitemap ────────
function robotsTxt() {
-- return seo.robotsTxt({ sitemaps: [`${baseUrl()}/sitemap.xml`] });
++ return seo.robotsTxt({ sitemaps: [`${baseUrl()}/sitemap.xml`] })
++ + `# Agent manifest: ${baseUrl()}/.well-known/agent.json\n`;
}
// ── /llms.txt: what this site is, for language models and other automated readers ─────────
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex a89c68dd..e95d8a14 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -241,6 +241,10 @@ async function _channelAccount(username) {
let user = null;
try { user = await db.getUserById(ch ? ch.user_id : (await db.getUserByUsername(username) || {}).id); } catch { user = null; }
if (!ch && !user) return null;
++ // The links a viewer can follow the channel on (its OpenVibe profile, the platforms it restreams to, and
++ // the links the streamer saved): the same list the channel page renders as pills, for the Markdown twin.
++ let followLinks = [];
++ try { if (ch) followLinks = (await require('../social/links').channelSocialLinks(ch, db)).links || []; } catch { followLinks = []; }
return {
userId: ch ? ch.user_id : user.id,
username: (ch && ch.username) || user.username,
@@@ -248,6 +252,7 @@ async function _channelAccount(username) {
avatarUrl: (ch && ch.avatar_url) || (user && user.avatar_url) || null,
bio: (ch && ch.bio) || (user && user.bio) || '',
banned: !!(user && (user.is_banned === 1 || user.is_banned === true)),
++ followLinks,
};
}
@@@ -312,7 +317,10 @@ async function _channelMeta(username, page = 1) {
// The page body, for crawlers and for anyone reading without JavaScript.
let body = `<article><h1>${esc(`${name} (${handle})`)}</h1>`;
const facts = [followers ? `${followers} follower${followers === 1 ? '' : 's'} on ${SITE_NAME}` : null];
-- if (live.length) facts.push(`Live now: ${clean(live[0].title || 'streaming', 100)}`);
++ if (live.length) {
++ const category = clean(live[0].ai_category || live[0].category || '', 40);
++ facts.push(`Live now: ${clean(live[0].title || 'streaming', 100)}${category ? ` · ${category}` : ''}`);
++ }
if (facts.filter(Boolean).length) body += `<p class="byline">${esc(facts.filter(Boolean).join(' · '))}</p>`;
if (acct.banned) body += '<p>This account is suspended.</p>';
if (bio) body += `<p>${esc(bio)}</p>`;
@@@ -329,6 +337,11 @@ async function _channelMeta(username, page = 1) {
body += _itemSection('AI Moments', aiClips.map((c) => ({ url: videoUrl('clip', c.id), name: c.title || 'AI clip', meta: ['AI clip', _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })),
null, `<p class="ai-disclosure">Clips OpenVibe's AI cut from ${esc(name)}'s streams when chat reacted. No one clipped these.</p>`);
}
++ if (acct.followLinks.length) {
++ body += `<section><h2>Follow ${esc(name)}</h2><ul>`
++ + acct.followLinks.map((l) => `<li><a href="${esc(l.url)}">${esc(l.label || l.name || l.kind)}</a></li>`).join('')
++ + '</ul></section>';
++ }
body += `<p><a href="${esc(abs(basePath))}">Visit ${esc(name)}'s channel</a> on ${SITE_NAME}.</p></article>`;
return {
@@@ -767,7 +780,7 @@ function _headBlock(meta) {
});
// og:url is the page itself: a shared AI clip previews as that clip, while its canonical names the source.
if (meta.ogUrlPath) tags = tags.replace(`<meta property="og:url" content="${esc(canonical)}">`, `<meta property="og:url" content="${esc(abs(meta.ogUrlPath))}">`);
-- const parts = [PASTES_META, VIDEO_META, ...tags.split('\n')];
++ const parts = [PASTES_META, VIDEO_META, ...tags.split('\n'), seo.linkHeaderTags(canonical, `${baseUrl()}/llms.txt`).head];
if (meta.video && meta.video.duration) {
parts.push(`<meta property="og:video:duration" content="${meta.video.duration}">`);
parts.push(`<meta property="video:duration" content="${meta.video.duration}">`);
@@@ -784,7 +797,12 @@ const NOJS_STYLE = '<noscript><style>#seo-prerender{position:static!important;wi
// The site's own links, for a reader without JavaScript (the navbar is built by a script).
const NOJS_NAV = `<nav aria-label="${SITE_NAME}"><a href="/">${SITE_NAME}</a> · <a href="/content">Content</a> · <a href="/moments">AI Moments</a> · <a href="/chat">Chat</a> · <a href="/documentation">API docs</a></nav>`;
function _prerender(html, snapshot) {
-- return html.replace(/(<body[^>]*>)/i, `$1\n<div id="seo-prerender" style="${PRERENDER_STYLE}">${NOJS_NAV}${snapshot}</div>`);
++ // The snapshot is the page's <main> on the server, so openvibe-shared/agent-layer's Markdown twin (which
++ // reads <main>) carries the page's own headings, links and language, and the SPA's empty placeholders stay
++ // out of it. The app keeps its id and gets role="main"; the SPA removes #seo-prerender when it boots.
++ html = html.replace(/<main id="app">/i, '<div id="app" role="main">').replace(/<\/main>/i, '</div>');
++ const block = `<div id="seo-prerender" style="${PRERENDER_STYLE}">${NOJS_NAV}<main>${snapshot}</main></div>`;
++ return html.replace(/(<body[^>]*>)/i, `$1\n${block}`);
}
// The page's one-paragraph summary (openvibe-shared/seo pageSummary): a hidden, machine-readable
// <section data-ov-summary> after the snapshot, the same on every OpenVibe site.
@@@ -896,6 +914,8 @@ function _pageParam(p, query) {
async function middleware(req, res, next) {
if (req.method !== 'GET') return next();
++ // A Markdown twin request (openvibe-shared/agent-layer answers it after this middleware).
++ if ((req.query && req.query.format === 'markdown') || req.accepts(['html', 'text/markdown']) === 'text/markdown') return next();
const p = (req.path || '/').replace(/\/+$/, '') || '/';
if (!SEO_ROUTE_RE.test(p)) return next();
// A paste Community owns is never rendered here (the handover answers it first; this keeps it
@@@ -911,6 +931,8 @@ async function middleware(req, res, next) {
req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;
const send = (html, ttl) => {
res.set('Cache-Control', `public, max-age=${Math.max(0, Math.round(ttl / 1000))}, must-revalidate`);
++ res.set('Link', seo.linkHeaderTags(abs(key), `${baseUrl()}/llms.txt`).header);
++ res.set('Vary', 'Accept');
res.set('Content-Security-Policy-Report-Only', assets.cspReportOnly(html));
res.type('html');
return res.send(html);