openvibers/openvibe.live Commits

Every page that names a Markdown twin has one; llms.txt links the twins (AGT-5) (#601)

OP OpenVibers committed 10 days ago · ac2b0a2527 · 12 files, +166 −33

The SEO renderer's Link header named a twin on /content, /moments, /rundown, /chat, /documentation and /updates,
and the agent layer answered each with 404: it twinned only the home, channels, /vods and /clips. Now every SEO route
is twinned (recordings, clips and pastes stay handed over). The docs' twins are their own Markdown files
(/docs/<name>.md, /docs.md), with relative links made absolute, and each docs page names its twin.

llms.txt links each of Live's pages at its twin (openvibe-shared v4.108.0 markdown mode leaves files and other
sites' links alone), marks the broadcaster as HTML, and moves the sitemap, robots, llms-full, updates and the network
descriptor into ## Optional.

Changes

ddiff --git a/README.md b/README.md
iindex 9fe66e5d..a90874e4 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 5ab0a180..873bf735 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/package-lock.json b/package-lock.json
iindex 0666491d..3c1d95ce 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -25,7 +25,7 @@
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.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",
ddiff --git a/package.json b/package.json
iindex 21b64bcc..6d1504b1 100644
---- a/package.json
++++ b/package.json
@@@ -44,7 +44,7 @@
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.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",
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex 5455587f..7490c12f 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -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);
ddiff --git a/server/docs/routes.js b/server/docs/routes.js
iindex 2c39e01d..eceec07e 100644
---- a/server/docs/routes.js
++++ b/server/docs/routes.js
@@@ -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;
ddiff --git a/server/index.js b/server/index.js
iindex 5c2b4e43..a59f2200 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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,
ddiff --git a/server/seo/discovery.js b/server/seo/discovery.js
iindex c03033d8..3f9abe8b 100644
---- a/server/seo/discovery.js
++++ b/server/seo/discovery.js
@@@ -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' },
],
});
}
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex e969f68c..7801eda3 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -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 };
ddiff --git a/test/agent-pages.test.js b/test/agent-pages.test.js
nnew file mode 100644
iindex 00000000..b41ef0f4
---- /dev/null
++++ b/test/agent-pages.test.js
@@@ -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); });
ddiff --git a/test/docs-render.test.js b/test/docs-render.test.js
iindex 652c7e26..08e20fd1 100644
---- a/test/docs-render.test.js
++++ b/test/docs-render.test.js
@@@ -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');
ddiff --git a/test/llms-txt.test.js b/test/llms-txt.test.js
iindex a1371f72..d77facf4 100644
---- a/test/llms-txt.test.js
++++ b/test/llms-txt.test.js
@@@ -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);