openvibers/openvibe.live Commits
Agent layer: /.well-known/agent.json and Markdown twins of the home, channel pages and VOD/clip lists (#435)
Changes
@@@ -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",@@@ -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",@@@ -399,7 +399,7 @@"html","img","input",-- "main#app",++ "#app","select","select.form-input","textarea"@@@ -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; }nnew file mode 100644@@@ -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',++ },++};nnew file mode 100644@@@ -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 };@@@ -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) => {@@@ -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 ─────────@@@ -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);
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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-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",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -42,6 +42,12 @@
@@ -42,6 +42,12 @@
"node": ">=18.0.0"
"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": {
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
@@ -988,6 +994,19 @@
@@ -988,6 +994,19 @@
"npm": "1.2.8000 || >= 1.4.16"
"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": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -1202,6 +1221,39 @@
@@ -1202,6 +1221,39 @@
"url": "https://opencollective.com/express"
"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": {
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -1907,6 +1959,12 @@
@@ -1907,6 +1959,12 @@
"node": ">=16.0.0"
"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": {
"node_modules/htmlparser2": {
"version": "12.0.0",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
@@ -2129,6 +2187,128 @@
@@ -2129,6 +2187,128 @@
"node": ">=0.2.0"
"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": {
"node_modules/lodash": {
"version": "4.18.1",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -2578,6 +2758,22 @@
@@ -2578,6 +2758,22 @@
"url": "https://github.com/sponsors/isaacs"
"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": {
"node_modules/object-assign": {
"version": "4.1.1",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -2688,12 +2884,13 @@
@@ -2688,12 +2884,13 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -3428,6 +3625,12 @@
@@ -3428,6 +3625,12 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
"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": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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-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",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -399,7 +399,7 @@
@@ -399,7 +399,7 @@
"html",
"html",
"img",
"img",
"input",
"input",
- "main#app",
+ "#app",
"select",
"select",
"select.form-input",
"select.form-input",
"textarea"
"textarea"
@@ -136,7 +136,7 @@ a:hover { color: var(--accent-light); }
@@ -136,7 +136,7 @@ a:hover { color: var(--accent-light); }
img { max-width: 100%; }
img { max-width: 100%; }
button { cursor: pointer; font-family: inherit; }
button { cursor: pointer; font-family: inherit; }
input, select, textarea { font-family: inherit; }
input, select, textarea { font-family: inherit; }
-main#app { position: relative; z-index: 1; }
+#app { position: relative; z-index: 1; }
/* ── Scrollbar ────────────────────────────────────────────────── */
/* ── Scrollbar ────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar { width: 6px; height: 6px; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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',
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -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 };
@@ -653,6 +653,14 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
@@ -653,6 +653,14 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
require('./web/paste-handover').register(app);
require('./web/paste-handover').register(app);
require('./web/video-handover').register(app);
require('./web/video-handover').register(app);
try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
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
// 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.
// as the SPA shell, so their script and stylesheet URLs can never drift out of date again.
app.use((req, res, next) => {
app.use((req, res, next) => {
@@ -99,7 +99,8 @@ async function sitemapHandler(req, res) {
@@ -99,7 +99,8 @@ async function sitemapHandler(req, res) {
// ── /robots.txt: every crawler welcome, search and AI alike, pointed at the sitemap ────────
// ── /robots.txt: every crawler welcome, search and AI alike, pointed at the sitemap ────────
function robotsTxt() {
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 ─────────
// ── /llms.txt: what this site is, for language models and other automated readers ─────────
@@ -241,6 +241,10 @@ async function _channelAccount(username) {
@@ -241,6 +241,10 @@ async function _channelAccount(username) {
let user = null;
let user = null;
try { user = await db.getUserById(ch ? ch.user_id : (await db.getUserByUsername(username) || {}).id); } catch { user = null; }
try { user = await db.getUserById(ch ? ch.user_id : (await db.getUserByUsername(username) || {}).id); } catch { user = null; }
if (!ch && !user) return 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 {
return {
userId: ch ? ch.user_id : user.id,
userId: ch ? ch.user_id : user.id,
username: (ch && ch.username) || user.username,
username: (ch && ch.username) || user.username,
@@ -248,6 +252,7 @@ async function _channelAccount(username) {
@@ -248,6 +252,7 @@ async function _channelAccount(username) {
avatarUrl: (ch && ch.avatar_url) || (user && user.avatar_url) || null,
avatarUrl: (ch && ch.avatar_url) || (user && user.avatar_url) || null,
bio: (ch && ch.bio) || (user && user.bio) || '',
bio: (ch && ch.bio) || (user && user.bio) || '',
banned: !!(user && (user.is_banned === 1 || user.is_banned === true)),
banned: !!(user && (user.is_banned === 1 || user.is_banned === true)),
+ followLinks,
};
};
}
}
@@ -312,7 +317,10 @@ async function _channelMeta(username, page = 1) {
@@ -312,7 +317,10 @@ async function _channelMeta(username, page = 1) {
// The page body, for crawlers and for anyone reading without JavaScript.
// The page body, for crawlers and for anyone reading without JavaScript.
let body = `<article><h1>${esc(`${name} (${handle})`)}</h1>`;
let body = `<article><h1>${esc(`${name} (${handle})`)}</h1>`;
const facts = [followers ? `${followers} follower${followers === 1 ? '' : 's'} on ${SITE_NAME}` : null];
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 (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 (acct.banned) body += '<p>This account is suspended.</p>';
if (bio) body += `<p>${esc(bio)}</p>`;
if (bio) body += `<p>${esc(bio)}</p>`;
@@ -329,6 +337,11 @@ async function _channelMeta(username, page = 1) {
@@ -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(' · ') })),
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>`);
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>`;
body += `<p><a href="${esc(abs(basePath))}">Visit ${esc(name)}'s channel</a> on ${SITE_NAME}.</p></article>`;
return {
return {
@@ -767,7 +780,7 @@ function _headBlock(meta) {
@@ -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.
// 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))}">`);
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) {
if (meta.video && meta.video.duration) {
parts.push(`<meta property="og:video:duration" content="${meta.video.duration}">`);
parts.push(`<meta property="og:video:duration" content="${meta.video.duration}">`);
parts.push(`<meta property="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
@@ -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).
// 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>`;
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) {
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
// 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.
// <section data-ov-summary> after the snapshot, the same on every OpenVibe site.
@@ -896,6 +914,8 @@ function _pageParam(p, query) {
@@ -896,6 +914,8 @@ function _pageParam(p, query) {
async function middleware(req, res, next) {
async function middleware(req, res, next) {
if (req.method !== 'GET') return 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(/\/+$/, '') || '/';
const p = (req.path || '/').replace(/\/+$/, '') || '/';
if (!SEO_ROUTE_RE.test(p)) return next();
if (!SEO_ROUTE_RE.test(p)) return next();
// A paste Community owns is never rendered here (the handover answers it first; this keeps it
// 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) {
@@ -911,6 +931,8 @@ async function middleware(req, res, next) {
req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;
req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;
const send = (html, ttl) => {
const send = (html, ttl) => {
res.set('Cache-Control', `public, max-age=${Math.max(0, Math.round(ttl / 1000))}, must-revalidate`);
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.set('Content-Security-Policy-Report-Only', assets.cspReportOnly(html));
res.type('html');
res.type('html');
return res.send(html);
return res.send(html);