openvibers/openvibe.shared Commits
4.108.0: llms.txt's markdown mode links only the site's own pages at their twins (#283)
Changes
@@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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@@@ -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 deployedconsumer 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` namesthe tag it pins.@@@ -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)",@@@ -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",@@@ -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": {@@@ -276,14 +276,34 @@ function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = truereturn 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');nnew file mode 100644@@@ -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');
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 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
- **The profile panel and the user card give a lookup eight seconds, and a failed one is not kept.** A request held
@@ -2,7 +2,7 @@
@@ -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.
> 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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.107.4",
+ "version": "4.108.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.107.4",
+ "version": "4.108.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.107.4",
+ "version": "4.108.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -276,14 +276,34 @@ function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true
@@ -276,14 +276,34 @@ function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true
return lines.join('\n') + '\n';
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()}`, ''];
const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];
if (details) out.push(String(details).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 }] : [])]) {
for (const sec of [...sections, ...(optional.length ? [{ title: 'Optional', links: optional }] : [])]) {
if (!sec || !sec.links || !sec.links.length) continue;
if (!sec || !sec.links || !sec.links.length) continue;
out.push(`## ${sec.title}`, '');
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('');
out.push('');
}
}
return out.join('\n');
return out.join('\n');
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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');