openvibers/openvibe.live Commits
Every page that names a Markdown twin has one; llms.txt links the twins (AGT-5) (#601)
Changes
@@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL<!-- versions:start -->- openvibe-contracts: v0.222.0- openvibe-sdk: v0.82.0--- openvibe-shared: v4.107.4++- openvibe-shared: v4.108.0<!-- versions:end -->@@@ -7,7 +7,7 @@"domain": "openvibe.live (www, ingest, whip)","contracts": "openvibe-contracts v0.222.0","sdk": "openvibe-sdk v0.82.0",-- "shared": "openvibe-shared v4.107.4",++ "shared": "openvibe-shared v4.108.0","capabilities": ["live.chat_context.read","live.chat_effects.write",@@@ -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.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.107.4",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -2884,9 +2884,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.107.4",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.107.4",-- "integrity": "sha512-tThN0m3KMncEHVcBOvE+ysHjsZTlVIDLG1P+/i2Mulmxfc/AUpebAXhpWWrut50OChZ8o+HeePxsSrPI8mUQOQ==",++ "version": "4.108.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.0",++ "integrity": "sha512-NPzwjxpzGIpFpIm4uw3+VwHfsxbJ2+cD9yuhRzSwolSwqv2kCmwaVtHJ0DA9gKO3K8PCBfIP3UpKzpuSbYEJ+g==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -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.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.107.4",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -4,24 +4,19 @@// 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.++// Every page the SEO renderer serves has a twin, since its Link header names one (11-03: /content, /moments, /rundown,++// /chat, /documentation and /updates named a twin that answered 404). Recordings, clips and pastes are not twinned++// here: /vod/:id, /clip/:id and /p/:slug 301 to OpenVibe.Video and OpenVibe.Community (mounted before this).++// The docs (/docs/<name>.md) are their own Markdown files, served by server/docs/routes.js sendTwin.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;--}++const HANDED_OVER_RE = /^\/(?:vod|clip|p)\//;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;++ if (!pages.SEO_ROUTE_RE.test(p) || HANDED_OVER_RE.test(p)) return null;++ const page = pages._pageParam(p, { page: (query && query.get('page')) || '' });const meta = await pages._pageMeta(p, { page });if (!meta) return null;const html = pages.render(meta, p);@@@ -16,6 +16,7 @@ const express = require('express');const fs = require('fs');const path = require('path');const cache = require('openvibe-shared/cache-policy');++const agentLayer = require('openvibe-shared/agent-layer');const social = require('openvibe-shared/social');const router = express.Router();@@@ -252,7 +253,8 @@ function renderPage({ name, title, html, headings }) {<title>${escapeHtml(pageTitle)}</title><link rel="icon" type="image/svg+xml" href="/assets/logo.svg"><script src="/shared/theme-loader.js"></script>-- <link rel="canonical" href="${escapeHtml(canonical)}">${description ? `++ <link rel="canonical" href="${escapeHtml(canonical)}">++ ${agentLayer.linkHeaderTags(pagePath(name)).head.replace(/\n/g, '\n ')}${description ? `<meta name="description" content="${escapeHtml(description)}"><meta property="og:type" content="article"><meta property="og:site_name" content="OpenVibe.Live">@@@ -331,16 +333,47 @@ function loadDoc(name) {}}++const pagePath = (name) => (name === 'README' ? '/docs' : `/docs/${name}`);++function sendDoc(req, res, name) {const markdown = loadDoc(name);if (markdown === null) return res.status(404).type('text/plain').send('No such doc');const rendered = renderMarkdown(markdown);res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));++ res.set('Link', agentLayer.linkHeaderTags(pagePath(name)).header);res.type('html').send(renderPage({ name, ...rendered }));}++/** A relative link in a doc's Markdown as its twin serves it: another doc is that doc's twin (whip.md → /docs/whip.md). */++function twinHref(href) {++ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(href)) return href;++ const [target, hash] = href.split('#');++ const doc = target.replace(/^\.\//, '').match(/^([a-z0-9-]+)\.md$/i);++ return doc ? `${pagePath(doc[1])}.md${hash ? `#${hash}` : ''}` : rewriteHref(href);++}++++/** A doc's own Markdown with its relative links made absolute; links inside fenced code are text and stay. */++function twinMarkdown(markdown) {++ return String(markdown).split(/(^(?:```|~~~)[^\n]*\n[\s\S]*?^(?:```|~~~)[ \t]*$)/m)++ .map((part, i) => (i % 2 ? part : part.replace(/\]\(([^)\s]+)((?:\s+"[^"]*")?)\)/g, (m, href, title) => `](${twinHref(href)}${title})`)))++ .join('');++}++++/**++ * The Markdown twin of a doc (AGT-5): the file as written, at /docs.md and /docs/<name>.md. Mounted in server/index.js++ * before the agent layer, which answers an .md it does not render with a 404.++ */++function sendTwin(req, res) {++ const name = req.params.name || 'README';++ const markdown = loadDoc(name);++ res.type('text/markdown; charset=utf-8');++ if (markdown === null) return res.status(404).send('# Not found\n');++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));++ res.set('Link', `<${publicBase()}${pagePath(name)}>; rel="canonical", </llms.txt>; rel="describedby"`);++ res.send(twinMarkdown(markdown));++}++router.get('/', (req, res) => sendDoc(req, res, 'README'));--router.get('/:name.md', (req, res) => sendDoc(req, res, req.params.name));router.get('/:name', (req, res) => sendDoc(req, res, req.params.name));module.exports = router;@@@ -348,3 +381,5 @@ module.exports._renderMarkdown = renderMarkdown;module.exports._rewriteHref = rewriteHref;module.exports._slugify = slugify;module.exports._renderPage = renderPage;++module.exports.sendTwin = sendTwin;++module.exports._twinMarkdown = twinMarkdown;@@@ -677,7 +677,8 @@ try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not regi// 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.++// SPA fallback, so an unknown .md is a 404 rather than a page. A doc's twin is its own Markdown file (AGT-5).++app.get(['/docs.md', '/docs/:name.md'], require('./docs/routes').sendTwin);require('openvibe-shared/agent-layer').mount(app, {descriptor: require('./agent-descriptor'),render: require('./agent-pages').createAgentPages().renderPublicPage,@@@ -138,16 +138,20 @@ const LLMS_DOCS = [const LLMS_SUMMARY = 'Open-source, community-run live streaming. Anyone can go live straight from a web browser, with no OBS, no downloads and no follower, subscriber or equipment requirement: press Go Live, allow camera and microphone, and the stream is live (WebRTC, under a second of delay; Chromebooks, laptops and phones). OBS (RTMP), WHIP and command-line ingest are there for produced shows. Every stream can be recorded as a VOD, clipped and discussed. Part of the OpenVibe network: one account across openvibe.network, openvibe.media, openvibe.community, openvibe.tools and the other OpenVibe sites.';const _docsPresent = () => LLMS_DOCS.filter(([name]) => { try { return fs.existsSync(path.join(DOCS_DIR, `${name}.md`)); } catch { return false; } });++// Each of Live's own pages is linked at its Markdown twin (AGT-5; openvibe-shared 4.108.0 leaves files and other sites'++// links as they are). The broadcaster is an app with no twin, so it is linked as HTML.function llmsTxt() {const b = baseUrl();return seo.llmsTxt({name: SITE_NAME,++ markdown: true,++ base: b,summary: LLMS_SUMMARY,details: 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source recording at the moment it came from, on OpenVibe.Video (https://openvibe.video/v/<id>?t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',sections: [{ title: 'Going live from a browser (no OBS)', links: [{ title: 'How to go live in your browser', url: `${b}/docs/go-live-in-your-browser`, note: `open ${b}, sign in, press Go Live, allow camera and microphone, start. No software, no follower minimum.` },-- { title: 'The broadcaster', url: `${b}/broadcast`, note: 'camera switching, screen/window/tab sharing with a camera picture-in-picture, live stats, chat beside the stream.' },++ { title: 'The broadcaster', url: `${b}/broadcast`, html: true, note: 'camera switching, screen/window/tab sharing with a camera picture-in-picture, live stats, chat beside the stream.' },{ title: 'Browser publishing for other sites', url: `${b}/docs/whip#publishing-from-a-browser`, note: 'any web page can publish to a channel over WHIP.' },] },{ title: 'What people made', links: [@@@ -170,12 +174,14 @@ function llmsTxt() {..._docsPresent().map(([name, what]) => ({ title: name, url: `${b}/docs/${name}`, note: what })),{ title: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Live' },] },-- { title: 'Discovery', links: [-- { title: 'Sitemap', url: `${b}/sitemap.xml`, note: 'people\'s work only' },-- { title: 'robots.txt', url: `${b}/robots.txt` },-- { title: 'llms-full.txt', url: `${b}/llms-full.txt`, note: 'the docs above in full, as plain text' },-- { title: 'OpenVibe platform descriptor', url: 'https://openvibe.network/.well-known/openvibe', note: 'the OpenVibe network\'s descriptor' },-- ] },++ ],++ // llmstxt.org's "## Optional": what an agent may skip when it needs a shorter context.++ optional: [++ { title: 'Sitemap', url: `${b}/sitemap.xml`, note: 'people\'s work only' },++ { title: 'robots.txt', url: `${b}/robots.txt` },++ { title: 'llms-full.txt', url: `${b}/llms-full.txt`, note: 'the docs above in full, as plain text' },++ { title: 'Updates', url: `${b}/updates`, note: 'every change deployed to Live, newest first' },++ { title: 'OpenVibe platform descriptor', url: 'https://openvibe.network/.well-known/openvibe', note: 'the OpenVibe network\'s descriptor' },],});}@@@ -980,4 +980,4 @@ async function middleware(req, res, next) {}}--module.exports = { middleware, shellHtml, _pageMeta, render, SITE_NAME, baseUrl, isAiClip, isoDate };++module.exports = { middleware, shellHtml, _pageMeta, _pageParam, render, SITE_NAME, baseUrl, isAiClip, isoDate, SEO_ROUTE_RE };nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++// Every page the SEO renderer serves has a Markdown twin, since its Link header names one (11-03: /content, /moments,++// /rundown, /chat, /documentation and /updates named twins that answered 404). Recordings, clips and pastes are handed++// over to OpenVibe.Video and OpenVibe.Community and are not twinned here; a path the renderer does not serve is not.++const assert = require('assert');++const { createAgentPages } = require('../server/agent-pages');++const seoPages = require('../server/seo/pages');++++(async () => {++ const { renderPublicPage } = createAgentPages();++ const q = (s = '') => new URLSearchParams(s);++ for (const p of ['/vod/12', '/clip/3', '/p/abc', '/settings', '/dashboard', '/nope', '/docs/whip']) {++ assert.strictEqual(await renderPublicPage(p, { query: q() }), null, `${p} has no twin here`);++ }++ for (const p of ['/chat', '/documentation', '/updates', '/chat/']) {++ const page = await renderPublicPage(p, { query: q() });++ assert.ok(page && /<\/html>/i.test(page.html) && page.title, `${p} is twinned`);++ }++ // Paging follows the renderer's own rule: a feed caps at its last page, other pages ignore ?page.++ assert.strictEqual(seoPages._pageParam('/content', { page: '999' }), 20);++ assert.strictEqual(seoPages._pageParam('/chat', { page: '3' }), 1);++ for (const p of ['/', '/content', '/moments', '/vods', '/clips', '/rundown', '/rundown/topics', '/@goosely', '/recap/12']) {++ assert.ok(seoPages.SEO_ROUTE_RE.test(p), `${p} is an SEO route, so it is twinned`);++ }++++ // Mounted as server/index.js mounts them: a doc's twin is its own Markdown, before the agent layer.++ const express = require('express');++ const http = require('http');++ const docs = require('../server/docs/routes');++ const app = express();++ app.get(['/docs.md', '/docs/:name.md'], docs.sendTwin);++ require('openvibe-shared/agent-layer').mount(app, { descriptor: require('../server/agent-descriptor'), render: renderPublicPage });++ app.use('/docs', docs);++ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ try {++ const whip = await fetch(`${base}/docs/whip.md`);++ const text = await whip.text();++ assert.strictEqual(whip.status, 200);++ assert.match(whip.headers.get('content-type'), /^text\/markdown/);++ assert.ok(text.startsWith('# ') && !/<html/i.test(text), 'the doc as written');++ assert.match(whip.headers.get('link'), /\/docs\/whip>; rel="canonical"/);++ assert.ok(!/\]\((?!https?:|\/|#|mailto:)[^)]+\)/.test(text.replace(/^(```|~~~)[\s\S]*?^\1/gm, '')), 'no relative link is left outside code');++ assert.strictEqual((await fetch(`${base}/docs.md`)).status, 200, 'the index');++ const missing = await fetch(`${base}/docs/nope.md`);++ assert.deepStrictEqual([missing.status, /markdown/.test(missing.headers.get('content-type'))], [404, true]);++ assert.strictEqual((await fetch(`${base}/docs/..%2Fpackage.md`)).status, 404, 'only a doc');++ const page = await fetch(`${base}/docs/whip`);++ assert.match(page.headers.get('link'), /<\/docs\/whip\.md>; rel="alternate"; type="text\/markdown"/, 'the page names its twin');++ assert.match(await page.text(), /<link rel="alternate" type="text\/markdown" href="\/docs\/whip\.md">/);++ const chat = await fetch(`${base}/chat.md`);++ assert.deepStrictEqual([chat.status, /markdown/.test(chat.headers.get('content-type'))], [200, true], '/chat names a twin, so it has one');++ assert.strictEqual((await fetch(`${base}/vod/1.md`)).status, 404);++ } finally {++ server.close();++ }++ console.log('agent pages: ok');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -115,4 +115,29 @@ console.log('✅ page shell: canonical and icon');console.log('✅ page shell: search title and description');}++// AGT-5: a doc's Markdown twin is the file as written, its relative links made absolute (another doc → that doc's twin),++// links inside fenced code left as text; the HTML page names the twin in its head.++{++ const { _twinMarkdown: twin, _renderPage: page } = require('../server/docs/routes');++ const md = twin([++ 'See [WHIP](whip.md#publishing-from-a-browser), [the index](README.md), [setup](../SETUP.md "Setup"),',++ '[the publisher](../public/whip-publisher.html), [abs](https://example.com/x.md), [here](#top) and [root](/broadcast).',++ '```md',++ 'a [sample](whip.md) in code',++ '```',++ 'after [api](api-tokens.md)',++ ].join('\n'));++ assert.ok(md.includes('[WHIP](/docs/whip.md#publishing-from-a-browser)'), 'another doc is its twin, anchor kept');++ assert.ok(md.includes('[the index](/docs.md)'), 'the index is /docs.md');++ assert.ok(md.includes('[setup](https://github.com/OpenVibers/OpenVibe.Live/blob/main/SETUP.md "Setup")'), 'a repo file is on GitHub, its title kept');++ assert.ok(md.includes('[the publisher](/whip-publisher.html)'));++ assert.ok(md.includes('[abs](https://example.com/x.md)') && md.includes('[here](#top)') && md.includes('[root](/broadcast)'), 'absolute links stay');++ assert.ok(md.includes('a [sample](whip.md) in code'), 'a link in a code sample is text');++ assert.ok(md.includes('after [api](/docs/api-tokens.md)'), 'links after a code block are rewritten again');++ const html = page({ name: 'whip', title: 'WHIP', html: '<p>x</p>', headings: [] });++ assert.ok(html.includes('<link rel="alternate" type="text/markdown" href="/docs/whip.md">') && html.includes('<link rel="describedby" href="/llms.txt">'), 'the page names its twin');++ assert.ok(page({ name: 'README', title: 'Docs', html: '<p>x</p>', headings: [] }).includes('href="/docs.md"'), 'the index names /docs.md');++ console.log('✅ docs twins');++}++console.log('\n✅ All docs renderer tests passed');@@@ -60,12 +60,12 @@ async function main() {const summary = text.split('\n').find((l) => l.startsWith('> '));assert.match(summary, /straight from a web browser, with no OBS, no downloads and no follower/);assert.ok(text.includes('## Going live from a browser (no OBS)'));-- assert.ok(text.includes('https://openvibe.live/docs/go-live-in-your-browser)'));++ assert.ok(text.includes('https://openvibe.live/docs/go-live-in-your-browser.md)'));});await check('links the API docs, and every docs page it links exists', () => {-- assert.ok(text.includes('[API docs](https://openvibe.live/documentation)'));-- const linked = [...text.matchAll(/https:\/\/openvibe\.live\/docs\/([a-z-]+)\)/g)].map((m) => m[1]);++ assert.ok(text.includes('[API docs](https://openvibe.live/documentation.md)'));++ const linked = [...text.matchAll(/https:\/\/openvibe\.live\/docs\/([a-z-]+)\.md\)/g)].map((m) => m[1]);assert.ok(linked.includes('whip') && linked.includes('api-tokens'), linked.join(','));for (const name of linked) assert.ok(fs.existsSync(path.join(__dirname, '../docs', `${name}.md`)), `docs/${name}.md`);});@@@ -76,6 +76,19 @@ async function main() {assert.ok(/## What people made[\s\S]*## What the AI made/.test(text), 'made, then derived');});++ await check('links each of its own pages at the Markdown twin, and ends with an Optional section (AGT-5)', () => {++ const own = [...text.matchAll(/\]\((https:\/\/openvibe\.live[^)\s]*)\)( \(HTML\))?/g)];++ assert.ok(own.length > 10, String(own.length));++ for (const [, url, html] of own) {++ const p = new URL(url).pathname;++ assert.ok(p.endsWith('.md') || /\.[a-z]+$/.test(p) || p.startsWith('/api/') || html, `${url} is a twin, a file or marked (HTML)`);++ }++ assert.ok(text.includes('[Home](https://openvibe.live/index.md)'));++ assert.ok(text.includes('[The broadcaster](https://openvibe.live/broadcast) (HTML)'), 'the broadcaster has no twin');++ assert.ok(text.includes('(https://openvibe.live/@goosely.md)') && text.includes('(https://openvibe.video/v/<id>)'), 'a channel twin; another site keeps its link');++ assert.ok(/\n## Optional\n\n[\s\S]*\[Sitemap\]\(https:\/\/openvibe\.live\/sitemap\.xml\)/.test(text) && text.lastIndexOf('\n## ') === text.indexOf('\n## Optional'), 'Optional is the last section');++ });++server.close();quiet(failures ? `\n${failures} check(s) failed` : '\nllms.txt: all checks passed');process.exit(failures ? 1 : 0);
@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.82.0
- openvibe-sdk: v0.82.0
-- openvibe-shared: v4.107.4
+- openvibe-shared: v4.108.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.82.0",
"sdk": "openvibe-sdk v0.82.0",
- "shared": "openvibe-shared v4.107.4",
+ "shared": "openvibe-shared v4.108.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
@@ -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.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.107.4",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.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",
@@ -2884,9 +2884,9 @@
@@ -2884,9 +2884,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.107.4",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.107.4",
- "integrity": "sha512-tThN0m3KMncEHVcBOvE+ysHjsZTlVIDLG1P+/i2Mulmxfc/AUpebAXhpWWrut50OChZ8o+HeePxsSrPI8mUQOQ==",
+ "version": "4.108.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.0",
+ "integrity": "sha512-NPzwjxpzGIpFpIm4uw3+VwHfsxbJ2+cD9yuhRzSwolSwqv2kCmwaVtHJ0DA9gKO3K8PCBfIP3UpKzpuSbYEJ+g==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -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.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.107.4",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.108.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",
@@ -4,24 +4,19 @@
@@ -4,24 +4,19 @@
// because the page itself is (the SPA translates in place in the browser); Japanese titles, bios and stream
// 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.
// 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.
+// Every page the SEO renderer serves has a twin, since its Link header names one (11-03: /content, /moments, /rundown,
+// /chat, /documentation and /updates named a twin that answered 404). Recordings, clips and pastes are not twinned
+// here: /vod/:id, /clip/:id and /p/:slug 301 to OpenVibe.Video and OpenVibe.Community (mounted before this).
+// The docs (/docs/<name>.md) are their own Markdown files, served by server/docs/routes.js sendTwin.
const pages = require('./seo/pages');
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;
-}
+const HANDED_OVER_RE = /^\/(?:vod|clip|p)\//;
function createAgentPages() {
function createAgentPages() {
async function renderPublicPage(path, { query } = {}) {
async function renderPublicPage(path, { query } = {}) {
const p = String(path || '/').replace(/\/+$/, '') || '/';
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;
+ if (!pages.SEO_ROUTE_RE.test(p) || HANDED_OVER_RE.test(p)) return null;
+ const page = pages._pageParam(p, { page: (query && query.get('page')) || '' });
const meta = await pages._pageMeta(p, { page });
const meta = await pages._pageMeta(p, { page });
if (!meta) return null;
if (!meta) return null;
const html = pages.render(meta, p);
const html = pages.render(meta, p);
@@ -16,6 +16,7 @@ const express = require('express');
@@ -16,6 +16,7 @@ const express = require('express');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+const agentLayer = require('openvibe-shared/agent-layer');
const social = require('openvibe-shared/social');
const social = require('openvibe-shared/social');
const router = express.Router();
const router = express.Router();
@@ -252,7 +253,8 @@ function renderPage({ name, title, html, headings }) {
@@ -252,7 +253,8 @@ function renderPage({ name, title, html, headings }) {
<title>${escapeHtml(pageTitle)}</title>
<title>${escapeHtml(pageTitle)}</title>
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<script src="/shared/theme-loader.js"></script>
<script src="/shared/theme-loader.js"></script>
- <link rel="canonical" href="${escapeHtml(canonical)}">${description ? `
+ <link rel="canonical" href="${escapeHtml(canonical)}">
+ ${agentLayer.linkHeaderTags(pagePath(name)).head.replace(/\n/g, '\n ')}${description ? `
<meta name="description" content="${escapeHtml(description)}">
<meta name="description" content="${escapeHtml(description)}">
<meta property="og:type" content="article">
<meta property="og:type" content="article">
<meta property="og:site_name" content="OpenVibe.Live">
<meta property="og:site_name" content="OpenVibe.Live">
@@ -331,16 +333,47 @@ function loadDoc(name) {
@@ -331,16 +333,47 @@ function loadDoc(name) {
}
}
}
}
+const pagePath = (name) => (name === 'README' ? '/docs' : `/docs/${name}`);
+
function sendDoc(req, res, name) {
function sendDoc(req, res, name) {
const markdown = loadDoc(name);
const markdown = loadDoc(name);
if (markdown === null) return res.status(404).type('text/plain').send('No such doc');
if (markdown === null) return res.status(404).type('text/plain').send('No such doc');
const rendered = renderMarkdown(markdown);
const rendered = renderMarkdown(markdown);
res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
+ res.set('Link', agentLayer.linkHeaderTags(pagePath(name)).header);
res.type('html').send(renderPage({ name, ...rendered }));
res.type('html').send(renderPage({ name, ...rendered }));
}
}
+/** A relative link in a doc's Markdown as its twin serves it: another doc is that doc's twin (whip.md → /docs/whip.md). */
+function twinHref(href) {
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(href)) return href;
+ const [target, hash] = href.split('#');
+ const doc = target.replace(/^\.\//, '').match(/^([a-z0-9-]+)\.md$/i);
+ return doc ? `${pagePath(doc[1])}.md${hash ? `#${hash}` : ''}` : rewriteHref(href);
+}
+
+/** A doc's own Markdown with its relative links made absolute; links inside fenced code are text and stay. */
+function twinMarkdown(markdown) {
+ return String(markdown).split(/(^(?:```|~~~)[^\n]*\n[\s\S]*?^(?:```|~~~)[ \t]*$)/m)
+ .map((part, i) => (i % 2 ? part : part.replace(/\]\(([^)\s]+)((?:\s+"[^"]*")?)\)/g, (m, href, title) => `](${twinHref(href)}${title})`)))
+ .join('');
+}
+
+/**
+ * The Markdown twin of a doc (AGT-5): the file as written, at /docs.md and /docs/<name>.md. Mounted in server/index.js
+ * before the agent layer, which answers an .md it does not render with a 404.
+ */
+function sendTwin(req, res) {
+ const name = req.params.name || 'README';
+ const markdown = loadDoc(name);
+ res.type('text/markdown; charset=utf-8');
+ if (markdown === null) return res.status(404).send('# Not found\n');
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
+ res.set('Link', `<${publicBase()}${pagePath(name)}>; rel="canonical", </llms.txt>; rel="describedby"`);
+ res.send(twinMarkdown(markdown));
+}
+
router.get('/', (req, res) => sendDoc(req, res, 'README'));
router.get('/', (req, res) => sendDoc(req, res, 'README'));
-router.get('/:name.md', (req, res) => sendDoc(req, res, req.params.name));
router.get('/:name', (req, res) => sendDoc(req, res, req.params.name));
router.get('/:name', (req, res) => sendDoc(req, res, req.params.name));
module.exports = router;
module.exports = router;
@@ -348,3 +381,5 @@ module.exports._renderMarkdown = renderMarkdown;
@@ -348,3 +381,5 @@ module.exports._renderMarkdown = renderMarkdown;
module.exports._rewriteHref = rewriteHref;
module.exports._rewriteHref = rewriteHref;
module.exports._slugify = slugify;
module.exports._slugify = slugify;
module.exports._renderPage = renderPage;
module.exports._renderPage = renderPage;
+module.exports.sendTwin = sendTwin;
+module.exports._twinMarkdown = twinMarkdown;
@@ -677,7 +677,8 @@ try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not regi
@@ -677,7 +677,8 @@ try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not regi
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the home,
// 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
// 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
// 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.
+// SPA fallback, so an unknown .md is a 404 rather than a page. A doc's twin is its own Markdown file (AGT-5).
+app.get(['/docs.md', '/docs/:name.md'], require('./docs/routes').sendTwin);
require('openvibe-shared/agent-layer').mount(app, {
require('openvibe-shared/agent-layer').mount(app, {
descriptor: require('./agent-descriptor'),
descriptor: require('./agent-descriptor'),
render: require('./agent-pages').createAgentPages().renderPublicPage,
render: require('./agent-pages').createAgentPages().renderPublicPage,
@@ -138,16 +138,20 @@ const LLMS_DOCS = [
@@ -138,16 +138,20 @@ const LLMS_DOCS = [
const LLMS_SUMMARY = 'Open-source, community-run live streaming. Anyone can go live straight from a web browser, with no OBS, no downloads and no follower, subscriber or equipment requirement: press Go Live, allow camera and microphone, and the stream is live (WebRTC, under a second of delay; Chromebooks, laptops and phones). OBS (RTMP), WHIP and command-line ingest are there for produced shows. Every stream can be recorded as a VOD, clipped and discussed. Part of the OpenVibe network: one account across openvibe.network, openvibe.media, openvibe.community, openvibe.tools and the other OpenVibe sites.';
const LLMS_SUMMARY = 'Open-source, community-run live streaming. Anyone can go live straight from a web browser, with no OBS, no downloads and no follower, subscriber or equipment requirement: press Go Live, allow camera and microphone, and the stream is live (WebRTC, under a second of delay; Chromebooks, laptops and phones). OBS (RTMP), WHIP and command-line ingest are there for produced shows. Every stream can be recorded as a VOD, clipped and discussed. Part of the OpenVibe network: one account across openvibe.network, openvibe.media, openvibe.community, openvibe.tools and the other OpenVibe sites.';
const _docsPresent = () => LLMS_DOCS.filter(([name]) => { try { return fs.existsSync(path.join(DOCS_DIR, `${name}.md`)); } catch { return false; } });
const _docsPresent = () => LLMS_DOCS.filter(([name]) => { try { return fs.existsSync(path.join(DOCS_DIR, `${name}.md`)); } catch { return false; } });
+// Each of Live's own pages is linked at its Markdown twin (AGT-5; openvibe-shared 4.108.0 leaves files and other sites'
+// links as they are). The broadcaster is an app with no twin, so it is linked as HTML.
function llmsTxt() {
function llmsTxt() {
const b = baseUrl();
const b = baseUrl();
return seo.llmsTxt({
return seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
+ markdown: true,
+ base: b,
summary: LLMS_SUMMARY,
summary: LLMS_SUMMARY,
details: 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source recording at the moment it came from, on OpenVibe.Video (https://openvibe.video/v/<id>?t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',
details: 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source recording at the moment it came from, on OpenVibe.Video (https://openvibe.video/v/<id>?t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',
sections: [
sections: [
{ title: 'Going live from a browser (no OBS)', links: [
{ title: 'Going live from a browser (no OBS)', links: [
{ title: 'How to go live in your browser', url: `${b}/docs/go-live-in-your-browser`, note: `open ${b}, sign in, press Go Live, allow camera and microphone, start. No software, no follower minimum.` },
{ title: 'How to go live in your browser', url: `${b}/docs/go-live-in-your-browser`, note: `open ${b}, sign in, press Go Live, allow camera and microphone, start. No software, no follower minimum.` },
- { title: 'The broadcaster', url: `${b}/broadcast`, note: 'camera switching, screen/window/tab sharing with a camera picture-in-picture, live stats, chat beside the stream.' },
+ { title: 'The broadcaster', url: `${b}/broadcast`, html: true, note: 'camera switching, screen/window/tab sharing with a camera picture-in-picture, live stats, chat beside the stream.' },
{ title: 'Browser publishing for other sites', url: `${b}/docs/whip#publishing-from-a-browser`, note: 'any web page can publish to a channel over WHIP.' },
{ title: 'Browser publishing for other sites', url: `${b}/docs/whip#publishing-from-a-browser`, note: 'any web page can publish to a channel over WHIP.' },
] },
] },
{ title: 'What people made', links: [
{ title: 'What people made', links: [
@@ -170,12 +174,14 @@ function llmsTxt() {
@@ -170,12 +174,14 @@ function llmsTxt() {
..._docsPresent().map(([name, what]) => ({ title: name, url: `${b}/docs/${name}`, note: what })),
..._docsPresent().map(([name, what]) => ({ title: name, url: `${b}/docs/${name}`, note: what })),
{ title: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Live' },
{ title: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Live' },
] },
] },
- { title: 'Discovery', links: [
- { title: 'Sitemap', url: `${b}/sitemap.xml`, note: 'people\'s work only' },
- { title: 'robots.txt', url: `${b}/robots.txt` },
- { title: 'llms-full.txt', url: `${b}/llms-full.txt`, note: 'the docs above in full, as plain text' },
- { title: 'OpenVibe platform descriptor', url: 'https://openvibe.network/.well-known/openvibe', note: 'the OpenVibe network\'s descriptor' },
- ] },
+ ],
+ // llmstxt.org's "## Optional": what an agent may skip when it needs a shorter context.
+ optional: [
+ { title: 'Sitemap', url: `${b}/sitemap.xml`, note: 'people\'s work only' },
+ { title: 'robots.txt', url: `${b}/robots.txt` },
+ { title: 'llms-full.txt', url: `${b}/llms-full.txt`, note: 'the docs above in full, as plain text' },
+ { title: 'Updates', url: `${b}/updates`, note: 'every change deployed to Live, newest first' },
+ { title: 'OpenVibe platform descriptor', url: 'https://openvibe.network/.well-known/openvibe', note: 'the OpenVibe network\'s descriptor' },
],
],
});
});
}
}
@@ -980,4 +980,4 @@ async function middleware(req, res, next) {
@@ -980,4 +980,4 @@ async function middleware(req, res, next) {
}
}
}
}
-module.exports = { middleware, shellHtml, _pageMeta, render, SITE_NAME, baseUrl, isAiClip, isoDate };
+module.exports = { middleware, shellHtml, _pageMeta, _pageParam, render, SITE_NAME, baseUrl, isAiClip, isoDate, SEO_ROUTE_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+// Every page the SEO renderer serves has a Markdown twin, since its Link header names one (11-03: /content, /moments,
+// /rundown, /chat, /documentation and /updates named twins that answered 404). Recordings, clips and pastes are handed
+// over to OpenVibe.Video and OpenVibe.Community and are not twinned here; a path the renderer does not serve is not.
+const assert = require('assert');
+const { createAgentPages } = require('../server/agent-pages');
+const seoPages = require('../server/seo/pages');
+
+(async () => {
+ const { renderPublicPage } = createAgentPages();
+ const q = (s = '') => new URLSearchParams(s);
+ for (const p of ['/vod/12', '/clip/3', '/p/abc', '/settings', '/dashboard', '/nope', '/docs/whip']) {
+ assert.strictEqual(await renderPublicPage(p, { query: q() }), null, `${p} has no twin here`);
+ }
+ for (const p of ['/chat', '/documentation', '/updates', '/chat/']) {
+ const page = await renderPublicPage(p, { query: q() });
+ assert.ok(page && /<\/html>/i.test(page.html) && page.title, `${p} is twinned`);
+ }
+ // Paging follows the renderer's own rule: a feed caps at its last page, other pages ignore ?page.
+ assert.strictEqual(seoPages._pageParam('/content', { page: '999' }), 20);
+ assert.strictEqual(seoPages._pageParam('/chat', { page: '3' }), 1);
+ for (const p of ['/', '/content', '/moments', '/vods', '/clips', '/rundown', '/rundown/topics', '/@goosely', '/recap/12']) {
+ assert.ok(seoPages.SEO_ROUTE_RE.test(p), `${p} is an SEO route, so it is twinned`);
+ }
+
+ // Mounted as server/index.js mounts them: a doc's twin is its own Markdown, before the agent layer.
+ const express = require('express');
+ const http = require('http');
+ const docs = require('../server/docs/routes');
+ const app = express();
+ app.get(['/docs.md', '/docs/:name.md'], docs.sendTwin);
+ require('openvibe-shared/agent-layer').mount(app, { descriptor: require('../server/agent-descriptor'), render: renderPublicPage });
+ app.use('/docs', docs);
+ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ try {
+ const whip = await fetch(`${base}/docs/whip.md`);
+ const text = await whip.text();
+ assert.strictEqual(whip.status, 200);
+ assert.match(whip.headers.get('content-type'), /^text\/markdown/);
+ assert.ok(text.startsWith('# ') && !/<html/i.test(text), 'the doc as written');
+ assert.match(whip.headers.get('link'), /\/docs\/whip>; rel="canonical"/);
+ assert.ok(!/\]\((?!https?:|\/|#|mailto:)[^)]+\)/.test(text.replace(/^(```|~~~)[\s\S]*?^\1/gm, '')), 'no relative link is left outside code');
+ assert.strictEqual((await fetch(`${base}/docs.md`)).status, 200, 'the index');
+ const missing = await fetch(`${base}/docs/nope.md`);
+ assert.deepStrictEqual([missing.status, /markdown/.test(missing.headers.get('content-type'))], [404, true]);
+ assert.strictEqual((await fetch(`${base}/docs/..%2Fpackage.md`)).status, 404, 'only a doc');
+ const page = await fetch(`${base}/docs/whip`);
+ assert.match(page.headers.get('link'), /<\/docs\/whip\.md>; rel="alternate"; type="text\/markdown"/, 'the page names its twin');
+ assert.match(await page.text(), /<link rel="alternate" type="text\/markdown" href="\/docs\/whip\.md">/);
+ const chat = await fetch(`${base}/chat.md`);
+ assert.deepStrictEqual([chat.status, /markdown/.test(chat.headers.get('content-type'))], [200, true], '/chat names a twin, so it has one');
+ assert.strictEqual((await fetch(`${base}/vod/1.md`)).status, 404);
+ } finally {
+ server.close();
+ }
+ console.log('agent pages: ok');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -115,4 +115,29 @@ console.log('✅ page shell: canonical and icon');
@@ -115,4 +115,29 @@ console.log('✅ page shell: canonical and icon');
console.log('✅ page shell: search title and description');
console.log('✅ page shell: search title and description');
}
}
+// AGT-5: a doc's Markdown twin is the file as written, its relative links made absolute (another doc → that doc's twin),
+// links inside fenced code left as text; the HTML page names the twin in its head.
+{
+ const { _twinMarkdown: twin, _renderPage: page } = require('../server/docs/routes');
+ const md = twin([
+ 'See [WHIP](whip.md#publishing-from-a-browser), [the index](README.md), [setup](../SETUP.md "Setup"),',
+ '[the publisher](../public/whip-publisher.html), [abs](https://example.com/x.md), [here](#top) and [root](/broadcast).',
+ '```md',
+ 'a [sample](whip.md) in code',
+ '```',
+ 'after [api](api-tokens.md)',
+ ].join('\n'));
+ assert.ok(md.includes('[WHIP](/docs/whip.md#publishing-from-a-browser)'), 'another doc is its twin, anchor kept');
+ assert.ok(md.includes('[the index](/docs.md)'), 'the index is /docs.md');
+ assert.ok(md.includes('[setup](https://github.com/OpenVibers/OpenVibe.Live/blob/main/SETUP.md "Setup")'), 'a repo file is on GitHub, its title kept');
+ assert.ok(md.includes('[the publisher](/whip-publisher.html)'));
+ assert.ok(md.includes('[abs](https://example.com/x.md)') && md.includes('[here](#top)') && md.includes('[root](/broadcast)'), 'absolute links stay');
+ assert.ok(md.includes('a [sample](whip.md) in code'), 'a link in a code sample is text');
+ assert.ok(md.includes('after [api](/docs/api-tokens.md)'), 'links after a code block are rewritten again');
+ const html = page({ name: 'whip', title: 'WHIP', html: '<p>x</p>', headings: [] });
+ assert.ok(html.includes('<link rel="alternate" type="text/markdown" href="/docs/whip.md">') && html.includes('<link rel="describedby" href="/llms.txt">'), 'the page names its twin');
+ assert.ok(page({ name: 'README', title: 'Docs', html: '<p>x</p>', headings: [] }).includes('href="/docs.md"'), 'the index names /docs.md');
+ console.log('✅ docs twins');
+}
+
console.log('\n✅ All docs renderer tests passed');
console.log('\n✅ All docs renderer tests passed');
@@ -60,12 +60,12 @@ async function main() {
@@ -60,12 +60,12 @@ async function main() {
const summary = text.split('\n').find((l) => l.startsWith('> '));
const summary = text.split('\n').find((l) => l.startsWith('> '));
assert.match(summary, /straight from a web browser, with no OBS, no downloads and no follower/);
assert.match(summary, /straight from a web browser, with no OBS, no downloads and no follower/);
assert.ok(text.includes('## Going live from a browser (no OBS)'));
assert.ok(text.includes('## Going live from a browser (no OBS)'));
- assert.ok(text.includes('https://openvibe.live/docs/go-live-in-your-browser)'));
+ assert.ok(text.includes('https://openvibe.live/docs/go-live-in-your-browser.md)'));
});
});
await check('links the API docs, and every docs page it links exists', () => {
await check('links the API docs, and every docs page it links exists', () => {
- assert.ok(text.includes('[API docs](https://openvibe.live/documentation)'));
- const linked = [...text.matchAll(/https:\/\/openvibe\.live\/docs\/([a-z-]+)\)/g)].map((m) => m[1]);
+ assert.ok(text.includes('[API docs](https://openvibe.live/documentation.md)'));
+ const linked = [...text.matchAll(/https:\/\/openvibe\.live\/docs\/([a-z-]+)\.md\)/g)].map((m) => m[1]);
assert.ok(linked.includes('whip') && linked.includes('api-tokens'), linked.join(','));
assert.ok(linked.includes('whip') && linked.includes('api-tokens'), linked.join(','));
for (const name of linked) assert.ok(fs.existsSync(path.join(__dirname, '../docs', `${name}.md`)), `docs/${name}.md`);
for (const name of linked) assert.ok(fs.existsSync(path.join(__dirname, '../docs', `${name}.md`)), `docs/${name}.md`);
});
});
@@ -76,6 +76,19 @@ async function main() {
@@ -76,6 +76,19 @@ async function main() {
assert.ok(/## What people made[\s\S]*## What the AI made/.test(text), 'made, then derived');
assert.ok(/## What people made[\s\S]*## What the AI made/.test(text), 'made, then derived');
});
});
+ await check('links each of its own pages at the Markdown twin, and ends with an Optional section (AGT-5)', () => {
+ const own = [...text.matchAll(/\]\((https:\/\/openvibe\.live[^)\s]*)\)( \(HTML\))?/g)];
+ assert.ok(own.length > 10, String(own.length));
+ for (const [, url, html] of own) {
+ const p = new URL(url).pathname;
+ assert.ok(p.endsWith('.md') || /\.[a-z]+$/.test(p) || p.startsWith('/api/') || html, `${url} is a twin, a file or marked (HTML)`);
+ }
+ assert.ok(text.includes('[Home](https://openvibe.live/index.md)'));
+ assert.ok(text.includes('[The broadcaster](https://openvibe.live/broadcast) (HTML)'), 'the broadcaster has no twin');
+ assert.ok(text.includes('(https://openvibe.live/@goosely.md)') && text.includes('(https://openvibe.video/v/<id>)'), 'a channel twin; another site keeps its link');
+ assert.ok(/\n## Optional\n\n[\s\S]*\[Sitemap\]\(https:\/\/openvibe\.live\/sitemap\.xml\)/.test(text) && text.lastIndexOf('\n## ') === text.indexOf('\n## Optional'), 'Optional is the last section');
+ });
+
server.close();
server.close();
quiet(failures ? `\n${failures} check(s) failed` : '\nllms.txt: all checks passed');
quiet(failures ? `\n${failures} check(s) failed` : '\nllms.txt: all checks passed');
process.exit(failures ? 1 : 0);
process.exit(failures ? 1 : 0);