openvibers/openvibe.shared Commits

4.108.0: llms.txt's markdown mode links only the site's own pages at their twins (#283)

OP OpenVibers committed 10 days ago · 6e10c25ad4 · 7 files, +77 −8

markdown: true appended .md to every link, so /openapi.json became /openapi.json.md (and a feed, an API path).
Now files, /api/ and /.well-known/ paths, other schemes and, with the new base, other origins keep their URL.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9fcfd0a..b56a920 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 4.108.0 — 2026-11-03
++
++- **llms.txt can link a site's own pages at their Markdown twins, and only those** (AGT-5). `seo.llmsTxt({ markdown:
++ true })` used to append `.md` to every link, so `/openapi.json` became `/openapi.json.md`, and so did a feed or an
++ API path. Now only the site's own pages get a twin: `/hot` → `/hot.md`, `/` → `/index.md`. Files, `/api/` and
++ `/.well-known/` paths, other schemes and, given the new `base` (the site's origin), other sites' links keep their
++ URL. A page with no twin still takes `html: true`. `optional` writes the llmstxt.org `## Optional` section.
++ `test/llms-markdown-links.test.js`.
++- **The fire name effect reads at WCAG AA on a red-tinted row** (`items.css` #ff7a1f, from #282: it was 4.42:1 on Chat).
++
## 4.107.4 — 2026-11-03
- **The profile panel and the user card give a lookup eight seconds, and a failed one is not kept.** A request held
ddiff --git a/README.md b/README.md
iindex 6c55cd2..b5a24f5 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.107.4 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.108.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 196de0e..d013c71 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.107.4",
++ "version": "4.108.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/package-lock.json b/package-lock.json
iindex 9ce6168..b19600c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.107.4",
++ "version": "4.108.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.107.4",
++ "version": "4.108.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 2e8b4c5..a21432a 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.107.4",
++ "version": "4.108.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/seo.js b/seo.js
iindex 5942786..8f7cfd6 100644
---- a/seo.js
++++ b/seo.js
@@@ -276,14 +276,34 @@ function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true
return lines.join('\n') + '\n';
}
--/** /llms.txt (llmstxt.org): a markdown map of the site for language-model crawlers. */
--function llmsTxt({ name, summary, details, sections = [], markdown = false, optional = [] } = {}) {
++/**
++ * Whether llms.txt's markdown mode links `url` at its Markdown twin: one of the site's own pages. A file (a last path
++ * segment with an extension: /openapi.json, /feed.xml, /llms-full.txt), an /api/ or /.well-known/ path and, when
++ * `base` is given, another origin's link keep their URL (4.108.0; before, /openapi.json became /openapi.json.md).
++ */
++function twinnable(url, base) {
++ let u;
++ try { u = new URL(url, base || 'https://agent.invalid'); } catch { return false; }
++ if (!/^https?:$/.test(u.protocol)) return false;
++ if (base && /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(String(url)) && u.origin !== new URL(base).origin) return false;
++ if (/^\/(api|\.well-known)(\/|$)/.test(u.pathname)) return false;
++ return !/\.[a-z0-9]{1,8}$/i.test(u.pathname.split('/').pop());
++}
++
++/**
++ * /llms.txt (llmstxt.org): a markdown map of the site for language-model crawlers. `markdown: true` links each of the
++ * site's own pages at its Markdown twin (/hot → /hot.md, / → /index.md); `base` (the site's origin) says which absolute
++ * links are its own. A page with no twin takes `html: true` and keeps its URL, marked "(HTML)". `optional` is the
++ * llmstxt.org "## Optional" section: links an agent may skip when it needs a shorter context.
++ */
++function llmsTxt({ name, summary, details, sections = [], markdown = false, optional = [], base } = {}) {
const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];
if (details) out.push(String(details).trim(), '');
++ const href = (l) => (markdown && !l.html && twinnable(l.url, base) ? require('./agent-layer').twinUrl(l.url) : l.url);
for (const sec of [...sections, ...(optional.length ? [{ title: 'Optional', links: optional }] : [])]) {
if (!sec || !sec.links || !sec.links.length) continue;
out.push(`## ${sec.title}`, '');
-- for (const l of sec.links) out.push(`- [${l.title}](${markdown && !l.html ? require('./agent-layer').twinUrl(l.url) : l.url})${l.html ? ' (HTML)' : ''}${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);
++ for (const l of sec.links) out.push(`- [${l.title}](${href(l)})${l.html ? ' (HTML)' : ''}${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);
out.push('');
}
return out.join('\n');
ddiff --git a/test/llms-markdown-links.test.js b/test/llms-markdown-links.test.js
nnew file mode 100644
iindex 0000000..c3b6fae
---- /dev/null
++++ b/test/llms-markdown-links.test.js
@@@ -0,0 +1,39 @@
++'use strict';
++// 4.108.0 (AGT-5): llms.txt's markdown mode links the site's own pages at their Markdown twins and nothing else: a
++// file, an API path, a .well-known path and another site's link keep their URL; a page with no twin says (HTML).
++const assert = require('assert');
++const seo = require('../seo');
++
++const links = (text) => [...text.matchAll(/\]\(([^)]+)\)/g)].map((m) => m[1]);
++const txt = seo.llmsTxt({
++ name: 'Deals', summary: 'Hot deals.', markdown: true, base: 'https://openvibe.deals',
++ sections: [{ title: 'Pages', links: [
++ { title: 'Home', url: 'https://openvibe.deals/' },
++ { title: 'Hot', url: '/hot' },
++ { title: 'A deal', url: 'https://openvibe.deals/d/abc-123/' },
++ { title: 'Search', url: '/search?q=tv' },
++ { title: 'Crowdfunding', url: '/crowdfunding', html: true },
++ { title: 'OpenAPI', url: '/openapi.json' },
++ { title: 'Feed', url: 'https://openvibe.deals/feed.xml' },
++ { title: 'API', url: '/api/v1/deals/hot' },
++ { title: 'Agent', url: '/.well-known/agent.json' },
++ { title: 'Network', url: 'https://openvibe.network/' },
++ { title: 'Mail', url: 'mailto:[email protected]' },
++ ] }],
++ optional: [{ title: 'Terms', url: '/terms' }, { title: 'Everything', url: '/llms-full.txt' }],
++});
++assert.deepStrictEqual(links(txt), [
++ 'https://openvibe.deals/index.md', '/hot.md', 'https://openvibe.deals/d/abc-123.md', '/search.md?q=tv', '/crowdfunding',
++ '/openapi.json', 'https://openvibe.deals/feed.xml', '/api/v1/deals/hot', '/.well-known/agent.json',
++ 'https://openvibe.network/', 'mailto:[email protected]', '/terms.md', '/llms-full.txt',
++]);
++assert.match(txt, /- \[Crowdfunding\]\(\/crowdfunding\) \(HTML\)/, 'a page with no twin is marked');
++assert.match(txt, /## Optional\n\n- \[Terms\]\(\/terms\.md\)/, 'the Optional section is last');
++assert.strictEqual(txt.indexOf('## Optional'), txt.lastIndexOf('## '));
++
++// Without base, an absolute link is still the site's own (as before 4.108.0), unless it is a file or an API path.
++const old = seo.llmsTxt({ name: 'X', markdown: true, sections: [{ title: 'P', links: [{ title: 'Home', url: 'https://example.com/' }, { title: 'Doc', url: 'https://example.com/openapi.json' }] }] });
++assert.deepStrictEqual(links(old), ['https://example.com/index.md', 'https://example.com/openapi.json']);
++// Without markdown nothing changes.
++assert.deepStrictEqual(links(seo.llmsTxt({ name: 'X', sections: [{ title: 'P', links: [{ title: 'Hot', url: '/hot' }] }] })), ['/hot']);
++console.log('llms markdown links: ok');