openvibers/openvibe.inventory Commits
Inventory: the sitemap and llms.txt list every item, kind and the public API (#3)
Changes
@@@ -5,7 +5,9 @@* llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in and* the API are not.*-- * The product extends PAGE_TEXT and publicPages() with its own pages; the routes and headers here stay.++ * The product extends PAGE_TEXT and publicPages() with its own pages; the routes and headers here stay. The catalog is++ * read through ctx.inv: the sitemap lists /items, a page per kind with items and every published item; llms.txt maps the++ * kinds and the public read API; llms-full.txt spells out every item. seo.sitemapXml escapes each loc ('&' → '&').*/const fs = require('fs');const path = require('path');@@@ -16,10 +18,14 @@ const { asyncRouter } = require('./router');const SITE_NAME = 'OpenVibe.Inventory';const DESCRIPTION = 'OpenVibe.Inventory — Your items, on every OpenVibe site.';const DISALLOW = ['/auth/', '/api/'];++// The two routes that need a subject point at the API reference, not at a URL that answers 400.++const API_DOC = 'https://github.com/OpenVibers/OpenVibe.Inventory#api-apiv1-problemjson-errors';++const RARITY_LABEL = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };const PAGE_TEXT = {'/': ['OpenVibe.Inventory home', 'OpenVibe.Inventory: Your items, on every OpenVibe site.'],'/updates': ['What shipped on OpenVibe.Inventory', 'This site\'s update log, from the network changelog feed.'],++ '/items': ['Every item', 'Every published item on OpenVibe.Inventory, by kind, with its rarity.'],};function dayOf(ts) {@@@ -44,8 +50,31 @@ const publicPages = () => [{ path: '/updates', changefreq: 'daily', priority: 0.5 },];++const CATALOG_PAGE = { path: '/items', changefreq: 'daily', priority: 0.7 };++const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;++const kindPath = (id) => `/items?kind=${encodeURIComponent(id)}`;++++/** The catalog read straight from ctx.inv: kinds, their published items, and the items grouped by kind. */++async function catalog(inv) {++ const [kinds, defs] = await Promise.all([inv.listKinds(), inv.listDefinitions({ limit: 500 })]);++ const byKind = new Map(kinds.map((k) => [k.id, []]));++ for (const d of defs) if (byKind.has(d.kind)) byKind.get(d.kind).push(d);++ return { kinds, defs, byKind };++}++const kindsWithItems = ({ kinds, byKind }) => kinds.filter((k) => byKind.get(k.id).length);++++/** Every public URL: the static pages, /items, a page per kind with items, and each published item. */++function catalogPages({ kinds, defs, byKind }) {++ return [++ ...publicPages(),++ CATALOG_PAGE,++ ...kindsWithItems({ kinds, byKind }).map((k) => ({ path: kindPath(k.id), changefreq: 'weekly', priority: 0.6 })),++ ...defs.map((d) => ({ path: `/items/${d.id}`, changefreq: 'monthly', priority: 0.4 })),++ ];++}++function createDiscoveryRoutes(ctx) {-- const { config } = ctx;++ const { config, inv } = ctx;const r = asyncRouter();const site = String(config.baseUrl).replace(/\/+$/, '');const abs = (p) => `${site}${p}`;@@@ -57,16 +86,30 @@ function createDiscoveryRoutes(ctx) {+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));});-- r.get('/llms.txt', (_req, res) => {++ r.get('/llms.txt', async (_req, res) => {++ const { kinds, defs, byKind } = await catalog(inv);res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({name: SITE_NAME,summary: 'OpenVibe.Inventory: Your items, on every OpenVibe site.',-- details: 'Every page is server-rendered and readable without JavaScript.',++ details: 'One inventory for an OpenVibe account: items earned or given by sites, games and apps, worn in chat, on '++ + 'stream overlays and on your profile. Nothing here is sold, bought, traded or converted. '++ + 'Every page is server-rendered and readable without JavaScript.',sections: [{ title: 'Start here', links: [{ title: 'OpenVibe.Inventory', url: abs('/'), note: 'Your items, on every OpenVibe site.' },{ title: 'What shipped on OpenVibe.Inventory', url: abs('/updates') },] },++ { title: 'Browse the inventory', links: [++ { title: 'Every item', url: abs('/items'), note: `${plural(defs.length, 'published item', 'published items')}` },++ // Every kind, its own page and count; a kind with nothing in it yet shows 0.++ ...kinds.map((k) => ({ title: k.name, url: abs(kindPath(k.id)), note: plural(byKind.get(k.id).length, 'item', 'items') })),++ ] },++ { title: 'API', links: [++ { title: 'GET /api/v1/kinds', url: abs('/api/v1/kinds'), note: 'every item kind, with where it is worn and shown' },++ { title: 'GET /api/v1/definitions', url: abs('/api/v1/definitions'), note: 'published items; filter with ?kind= or ?issuer=' },++ { title: 'GET /api/v1/people/:subject/items', url: API_DOC, note: 'the items one person owns' },++ { title: 'GET /api/v1/equipped?subjects=…', url: API_DOC, note: 'up to 100 equipped sets in one call' },++ ] },{ title: 'Machine-readable', links: [{ title: 'Sitemap', url: abs('/sitemap.xml') },{ title: 'Full text for language models', url: abs('/llms-full.txt') },@@@ -80,20 +123,28 @@ function createDiscoveryRoutes(ctx) {}));});-- r.get('/llms-full.txt', (_req, res) => {-- const pages = publicPages().map((p) => ({ url: p.path, title: PAGE_TEXT[p.path][0], text: PAGE_TEXT[p.path][1] }));++ r.get('/llms-full.txt', async (_req, res) => {++ const { kinds, defs, byKind } = await catalog(inv);++ const pages = [...publicPages(), CATALOG_PAGE].map((e) => ({ url: e.path, title: PAGE_TEXT[e.path][0], text: PAGE_TEXT[e.path][1] }));++ // Every published item, one line each (name, rarity, kind and URL), grouped by kind.++ const itemSections = kindsWithItems({ kinds, byKind }).map((k) => {++ const items = byKind.get(k.id);++ pages.push({ url: kindPath(k.id), title: `${k.name} items`, text: `${plural(items.length, 'item', 'items')}, with their rarity.` });++ return { title: k.name, pages: items.map((d) => ({ title: d.name, url: `/items/${d.id}`, text: `${RARITY_LABEL[d.rarity] || d.rarity} ${k.name}` })) };++ });res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({site: SITE_NAME,summary: 'Every public page of OpenVibe.Inventory, one line each.',base: site,maxBytes: 64 * 1024,-- sections: [{ title: 'Pages', pages }],++ sections: [{ title: 'Pages', pages }, ...itemSections],}));});-- r.get('/sitemap.xml', (_req, res) => {++ r.get('/sitemap.xml', async (_req, res) => {const lastmod = siteUpdated();-- const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));++ // seo.sitemapXml escapes every loc, so a query string's '&' comes out as '&'.++ const urls = catalogPages(await catalog(inv)).map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));});@@@ -34,6 +34,14 @@ const PRIVATE = ['/auth/', '/api/'];assert.match(r.headers.get('content-type'), /^application\/xml/);assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);for (const p of ['/', '/updates']) assert.ok(r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `no ${p} entry`);++ // The catalog: /items, each kind with items, and every published item.++ assert.ok(r.text.includes('<loc>https://inventory.openvibe.network/items</loc>'), 'no /items entry');++ // This kind's URL has no '&', so it must appear exactly, unescaped — and with no stray '&'.++ assert.ok(r.text.includes('<loc>https://inventory.openvibe.network/items?kind=live.hat</loc>'), 'no /items?kind=live.hat entry');++ assert.ok(!r.text.includes('items?kind=live.hat&'), 'that URL must not gain an entity');++ const items = [...r.text.matchAll(/<loc>https:\/\/inventory\.openvibe\.network\/items\/itd_/g)];++ assert.strictEqual(items.length, 70, `expected 70 item entries, got ${items.length}`);++ for (const p of ['/me', '/u/', '/api/']) assert.ok(!r.text.includes(`<loc>https://inventory.openvibe.network${p}`), `private path in sitemap: ${p}`);for (const p of ['/auth/login', '/api/v1/ping']) assert.ok(!r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `${p} is not a public page`);const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);assert.ok(lastmods.length > 0, 'no lastmod anywhere');@@@ -52,6 +60,10 @@ const PRIVATE = ['/auth/', '/api/'];assert.ok(r.text.includes('(https://openvibe.services)'), 'no pointer to the developer platform');assert.ok(r.text.includes('https://inventory.openvibe.network/sitemap.xml'), 'sitemap not listed');assert.ok(r.text.includes('(https://inventory.openvibe.network/llms-full.txt)'), 'llms-full.txt not listed');++ // The catalog and the public read API are named for language models on purpose.++ assert.ok(r.text.includes('(https://inventory.openvibe.network/items)'), 'no /items link');++ assert.ok(r.text.includes('https://inventory.openvibe.network/items?kind=live.hat'), 'no kind link');++ assert.ok(r.text.includes('GET /api/v1/kinds'), 'the API section is missing');for (const p of ['/auth/']) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms.txt: ${p}`);});@@@ -66,6 +78,11 @@ const PRIVATE = ['/auth/', '/api/'];assert.ok(locs.length >= 2, `sitemap too small: ${locs.length}`);for (const loc of locs) assert.ok(r.text.includes(loc), `llms-full.txt is missing ${loc}`);assert.ok(r.text.includes('URL: https://inventory.openvibe.network/updates\n'), 'no /updates entry');++ // Every published item: its name, rarity, kind and URL, grouped by kind.++ assert.ok(r.text.includes('Royal Crown'), 'no item names');++ assert.ok(/### Royal Crown\n\nURL: https:\/\/inventory\.openvibe\.network\/items\/itd_[0-9A-HJKMNP-TV-Z]{26}\n/.test(r.text), 'no Royal Crown item line');++ assert.ok(r.text.includes('\n## Hat\n'), 'items are not grouped by kind');++ assert.ok(r.text.includes('Epic Hat'), 'the item line lacks its rarity and kind');for (const p of PRIVATE) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms-full.txt: ${p}`);});
@@ -5,7 +5,9 @@
@@ -5,7 +5,9 @@
* llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in and
* llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in and
* the API are not.
* the API are not.
*
*
- * The product extends PAGE_TEXT and publicPages() with its own pages; the routes and headers here stay.
+ * The product extends PAGE_TEXT and publicPages() with its own pages; the routes and headers here stay. The catalog is
+ * read through ctx.inv: the sitemap lists /items, a page per kind with items and every published item; llms.txt maps the
+ * kinds and the public read API; llms-full.txt spells out every item. seo.sitemapXml escapes each loc ('&' → '&').
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -16,10 +18,14 @@ const { asyncRouter } = require('./router');
@@ -16,10 +18,14 @@ const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Inventory';
const SITE_NAME = 'OpenVibe.Inventory';
const DESCRIPTION = 'OpenVibe.Inventory — Your items, on every OpenVibe site.';
const DESCRIPTION = 'OpenVibe.Inventory — Your items, on every OpenVibe site.';
const DISALLOW = ['/auth/', '/api/'];
const DISALLOW = ['/auth/', '/api/'];
+// The two routes that need a subject point at the API reference, not at a URL that answers 400.
+const API_DOC = 'https://github.com/OpenVibers/OpenVibe.Inventory#api-apiv1-problemjson-errors';
+const RARITY_LABEL = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
const PAGE_TEXT = {
const PAGE_TEXT = {
'/': ['OpenVibe.Inventory home', 'OpenVibe.Inventory: Your items, on every OpenVibe site.'],
'/': ['OpenVibe.Inventory home', 'OpenVibe.Inventory: Your items, on every OpenVibe site.'],
'/updates': ['What shipped on OpenVibe.Inventory', 'This site\'s update log, from the network changelog feed.'],
'/updates': ['What shipped on OpenVibe.Inventory', 'This site\'s update log, from the network changelog feed.'],
+ '/items': ['Every item', 'Every published item on OpenVibe.Inventory, by kind, with its rarity.'],
};
};
function dayOf(ts) {
function dayOf(ts) {
@@ -44,8 +50,31 @@ const publicPages = () => [
@@ -44,8 +50,31 @@ const publicPages = () => [
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
];
];
+const CATALOG_PAGE = { path: '/items', changefreq: 'daily', priority: 0.7 };
+const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
+const kindPath = (id) => `/items?kind=${encodeURIComponent(id)}`;
+
+/** The catalog read straight from ctx.inv: kinds, their published items, and the items grouped by kind. */
+async function catalog(inv) {
+ const [kinds, defs] = await Promise.all([inv.listKinds(), inv.listDefinitions({ limit: 500 })]);
+ const byKind = new Map(kinds.map((k) => [k.id, []]));
+ for (const d of defs) if (byKind.has(d.kind)) byKind.get(d.kind).push(d);
+ return { kinds, defs, byKind };
+}
+const kindsWithItems = ({ kinds, byKind }) => kinds.filter((k) => byKind.get(k.id).length);
+
+/** Every public URL: the static pages, /items, a page per kind with items, and each published item. */
+function catalogPages({ kinds, defs, byKind }) {
+ return [
+ ...publicPages(),
+ CATALOG_PAGE,
+ ...kindsWithItems({ kinds, byKind }).map((k) => ({ path: kindPath(k.id), changefreq: 'weekly', priority: 0.6 })),
+ ...defs.map((d) => ({ path: `/items/${d.id}`, changefreq: 'monthly', priority: 0.4 })),
+ ];
+}
+
function createDiscoveryRoutes(ctx) {
function createDiscoveryRoutes(ctx) {
- const { config } = ctx;
+ const { config, inv } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const site = String(config.baseUrl).replace(/\/+$/, '');
const site = String(config.baseUrl).replace(/\/+$/, '');
const abs = (p) => `${site}${p}`;
const abs = (p) => `${site}${p}`;
@@ -57,16 +86,30 @@ function createDiscoveryRoutes(ctx) {
@@ -57,16 +86,30 @@ function createDiscoveryRoutes(ctx) {
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
});
});
- r.get('/llms.txt', (_req, res) => {
+ r.get('/llms.txt', async (_req, res) => {
+ const { kinds, defs, byKind } = await catalog(inv);
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
summary: 'OpenVibe.Inventory: Your items, on every OpenVibe site.',
summary: 'OpenVibe.Inventory: Your items, on every OpenVibe site.',
- details: 'Every page is server-rendered and readable without JavaScript.',
+ details: 'One inventory for an OpenVibe account: items earned or given by sites, games and apps, worn in chat, on '
+ + 'stream overlays and on your profile. Nothing here is sold, bought, traded or converted. '
+ + 'Every page is server-rendered and readable without JavaScript.',
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Inventory', url: abs('/'), note: 'Your items, on every OpenVibe site.' },
{ title: 'OpenVibe.Inventory', url: abs('/'), note: 'Your items, on every OpenVibe site.' },
{ title: 'What shipped on OpenVibe.Inventory', url: abs('/updates') },
{ title: 'What shipped on OpenVibe.Inventory', url: abs('/updates') },
] },
] },
+ { title: 'Browse the inventory', links: [
+ { title: 'Every item', url: abs('/items'), note: `${plural(defs.length, 'published item', 'published items')}` },
+ // Every kind, its own page and count; a kind with nothing in it yet shows 0.
+ ...kinds.map((k) => ({ title: k.name, url: abs(kindPath(k.id)), note: plural(byKind.get(k.id).length, 'item', 'items') })),
+ ] },
+ { title: 'API', links: [
+ { title: 'GET /api/v1/kinds', url: abs('/api/v1/kinds'), note: 'every item kind, with where it is worn and shown' },
+ { title: 'GET /api/v1/definitions', url: abs('/api/v1/definitions'), note: 'published items; filter with ?kind= or ?issuer=' },
+ { title: 'GET /api/v1/people/:subject/items', url: API_DOC, note: 'the items one person owns' },
+ { title: 'GET /api/v1/equipped?subjects=…', url: API_DOC, note: 'up to 100 equipped sets in one call' },
+ ] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
@@ -80,20 +123,28 @@ function createDiscoveryRoutes(ctx) {
@@ -80,20 +123,28 @@ function createDiscoveryRoutes(ctx) {
}));
}));
});
});
- r.get('/llms-full.txt', (_req, res) => {
- const pages = publicPages().map((p) => ({ url: p.path, title: PAGE_TEXT[p.path][0], text: PAGE_TEXT[p.path][1] }));
+ r.get('/llms-full.txt', async (_req, res) => {
+ const { kinds, defs, byKind } = await catalog(inv);
+ const pages = [...publicPages(), CATALOG_PAGE].map((e) => ({ url: e.path, title: PAGE_TEXT[e.path][0], text: PAGE_TEXT[e.path][1] }));
+ // Every published item, one line each (name, rarity, kind and URL), grouped by kind.
+ const itemSections = kindsWithItems({ kinds, byKind }).map((k) => {
+ const items = byKind.get(k.id);
+ pages.push({ url: kindPath(k.id), title: `${k.name} items`, text: `${plural(items.length, 'item', 'items')}, with their rarity.` });
+ return { title: k.name, pages: items.map((d) => ({ title: d.name, url: `/items/${d.id}`, text: `${RARITY_LABEL[d.rarity] || d.rarity} ${k.name}` })) };
+ });
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
site: SITE_NAME,
summary: 'Every public page of OpenVibe.Inventory, one line each.',
summary: 'Every public page of OpenVibe.Inventory, one line each.',
base: site,
base: site,
maxBytes: 64 * 1024,
maxBytes: 64 * 1024,
- sections: [{ title: 'Pages', pages }],
+ sections: [{ title: 'Pages', pages }, ...itemSections],
}));
}));
});
});
- r.get('/sitemap.xml', (_req, res) => {
+ r.get('/sitemap.xml', async (_req, res) => {
const lastmod = siteUpdated();
const lastmod = siteUpdated();
- const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
+ // seo.sitemapXml escapes every loc, so a query string's '&' comes out as '&'.
+ const urls = catalogPages(await catalog(inv)).map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
});
@@ -34,6 +34,14 @@ const PRIVATE = ['/auth/', '/api/'];
@@ -34,6 +34,14 @@ const PRIVATE = ['/auth/', '/api/'];
assert.match(r.headers.get('content-type'), /^application\/xml/);
assert.match(r.headers.get('content-type'), /^application\/xml/);
assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
for (const p of ['/', '/updates']) assert.ok(r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `no ${p} entry`);
for (const p of ['/', '/updates']) assert.ok(r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `no ${p} entry`);
+ // The catalog: /items, each kind with items, and every published item.
+ assert.ok(r.text.includes('<loc>https://inventory.openvibe.network/items</loc>'), 'no /items entry');
+ // This kind's URL has no '&', so it must appear exactly, unescaped — and with no stray '&'.
+ assert.ok(r.text.includes('<loc>https://inventory.openvibe.network/items?kind=live.hat</loc>'), 'no /items?kind=live.hat entry');
+ assert.ok(!r.text.includes('items?kind=live.hat&'), 'that URL must not gain an entity');
+ const items = [...r.text.matchAll(/<loc>https:\/\/inventory\.openvibe\.network\/items\/itd_/g)];
+ assert.strictEqual(items.length, 70, `expected 70 item entries, got ${items.length}`);
+ for (const p of ['/me', '/u/', '/api/']) assert.ok(!r.text.includes(`<loc>https://inventory.openvibe.network${p}`), `private path in sitemap: ${p}`);
for (const p of ['/auth/login', '/api/v1/ping']) assert.ok(!r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `${p} is not a public page`);
for (const p of ['/auth/login', '/api/v1/ping']) assert.ok(!r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `${p} is not a public page`);
const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
assert.ok(lastmods.length > 0, 'no lastmod anywhere');
assert.ok(lastmods.length > 0, 'no lastmod anywhere');
@@ -52,6 +60,10 @@ const PRIVATE = ['/auth/', '/api/'];
@@ -52,6 +60,10 @@ const PRIVATE = ['/auth/', '/api/'];
assert.ok(r.text.includes('(https://openvibe.services)'), 'no pointer to the developer platform');
assert.ok(r.text.includes('(https://openvibe.services)'), 'no pointer to the developer platform');
assert.ok(r.text.includes('https://inventory.openvibe.network/sitemap.xml'), 'sitemap not listed');
assert.ok(r.text.includes('https://inventory.openvibe.network/sitemap.xml'), 'sitemap not listed');
assert.ok(r.text.includes('(https://inventory.openvibe.network/llms-full.txt)'), 'llms-full.txt not listed');
assert.ok(r.text.includes('(https://inventory.openvibe.network/llms-full.txt)'), 'llms-full.txt not listed');
+ // The catalog and the public read API are named for language models on purpose.
+ assert.ok(r.text.includes('(https://inventory.openvibe.network/items)'), 'no /items link');
+ assert.ok(r.text.includes('https://inventory.openvibe.network/items?kind=live.hat'), 'no kind link');
+ assert.ok(r.text.includes('GET /api/v1/kinds'), 'the API section is missing');
for (const p of ['/auth/']) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms.txt: ${p}`);
for (const p of ['/auth/']) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms.txt: ${p}`);
});
});
@@ -66,6 +78,11 @@ const PRIVATE = ['/auth/', '/api/'];
@@ -66,6 +78,11 @@ const PRIVATE = ['/auth/', '/api/'];
assert.ok(locs.length >= 2, `sitemap too small: ${locs.length}`);
assert.ok(locs.length >= 2, `sitemap too small: ${locs.length}`);
for (const loc of locs) assert.ok(r.text.includes(loc), `llms-full.txt is missing ${loc}`);
for (const loc of locs) assert.ok(r.text.includes(loc), `llms-full.txt is missing ${loc}`);
assert.ok(r.text.includes('URL: https://inventory.openvibe.network/updates\n'), 'no /updates entry');
assert.ok(r.text.includes('URL: https://inventory.openvibe.network/updates\n'), 'no /updates entry');
+ // Every published item: its name, rarity, kind and URL, grouped by kind.
+ assert.ok(r.text.includes('Royal Crown'), 'no item names');
+ assert.ok(/### Royal Crown\n\nURL: https:\/\/inventory\.openvibe\.network\/items\/itd_[0-9A-HJKMNP-TV-Z]{26}\n/.test(r.text), 'no Royal Crown item line');
+ assert.ok(r.text.includes('\n## Hat\n'), 'items are not grouped by kind');
+ assert.ok(r.text.includes('Epic Hat'), 'the item line lacks its rarity and kind');
for (const p of PRIVATE) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms-full.txt: ${p}`);
for (const p of PRIVATE) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms-full.txt: ${p}`);
});
});