openvibers/openvibe.host Commits

Host: the sitemap dates catalog pages by their newest price check (#82)

OP OpenVibers committed 12 Oct 2026 · a2ea4b99c6 · 3 files, +21 −3

Host's sitemap now dates its catalog pages, which the SEO survey had flagged as missing `lastmod`:

- each plan page carries the day its price was last checked;
- each provider page carries its newest plan check;
- `/`, `/catalog` and `/catalog/providers` carry the newest check overall;
- static pages carry none.

The sitemap is built with `openvibe-shared/seo` `sitemapXml` (the hand-written XML is gone), and `sitemapPlans()` also returns `checked_at` and the provider slug.

Test: a sitemap case in `test/catalog-public.test.js`. The host-llms and host-preview tests pass.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 28cefa9..36130ad 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -242,9 +242,19 @@ async function createApp(opts = {}) {
try {
const providers = await catalog.providers();
const plans = await catalog.sitemapPlans();
-- const paths = ['/', '/hosting', ...legalPaths, '/catalog', '/catalog/promos', '/catalog/providers', ...providers.map((p) => `/catalog/providers/${p.slug}`), ...plans.map((p) => `/catalog/plans/${p.id}`)];
++ // A catalog page's lastmod is the newest price check it shows: a plan's own, a provider's latest plan, and the
++ // newest of all for the catalog and the front page. Static pages carry none.
++ const day = (ms) => (Number.isFinite(Number(ms)) && ms != null ? new Date(Number(ms)).toISOString().slice(0, 10) : undefined);
++ const latest = (rows) => rows.reduce((m, p) => Math.max(m, Number(p.checked_at) || 0), 0) || undefined;
++ const newest = day(latest(plans));
++ const urls = [
++ { path: '/', lastmod: newest }, { path: '/hosting' }, ...legalPaths.map((path) => ({ path })),
++ { path: '/catalog', lastmod: newest }, { path: '/catalog/promos' }, { path: '/catalog/providers', lastmod: newest },
++ ...providers.map((v) => ({ path: `/catalog/providers/${v.slug}`, lastmod: day(latest(plans.filter((p) => p.provider_slug === v.slug))) })),
++ ...plans.map((p) => ({ path: `/catalog/plans/${p.id}`, lastmod: day(p.checked_at) })),
++ ];
res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
-- .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${paths.map((p) => ` <url><loc>${config.baseUrl}${p}</loc></url>`).join('\n')}\n</urlset>\n`);
++ .send(sharedSeo.sitemapXml(urls.map((u) => ({ loc: `${config.baseUrl}${u.path}`, lastmod: u.lastmod }))));
} catch (err) { next(err); }
});
// /llms.txt and /llms-full.txt (llmstxt.org): a map of Host's own public pages only. A tenant site, a preview
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex 93f5538..c35b541 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -176,7 +176,8 @@ function createCatalog({ store, community = null, log = console }) {
const r = await db.prepare("SELECT COUNT(*) AS plans, COUNT(DISTINCT p.provider_id) AS providers, MAX(p.checked_at) AS last FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active'").get();
return { plans: Number(r.plans) || 0, providers: Number(r.providers) || 0, lastChecked: r.last == null ? null : Number(r.last) };
}
-- async function sitemapPlans() { return await db.prepare("SELECT p.id FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
++ // Each public plan with the day its price was last checked, and its provider's slug, so the sitemap can date pages.
++ async function sitemapPlans() { return await db.prepare("SELECT p.id, p.checked_at, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
return { onChange: (fn) => listeners.push(fn), save, get, applyItem, plans, plan, providers, promos, currencies: currenciesInUse, stats, sitemapPlans, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
}
module.exports = { createCatalog };
ddiff --git a/test/catalog-public.test.js b/test/catalog-public.test.js
iindex ea5a5cb..441c832 100644
---- a/test/catalog-public.test.js
++++ b/test/catalog-public.test.js
@@@ -56,6 +56,13 @@ const { boot, check, done } = require('./stageb/boot');
assert.ok(!types((await t.api('GET', '/catalog?min_vcpu=2')).text).includes('ItemList'), 'a filtered view carries no JSON-LD');
const promos = await t.api('GET', '/api/v1/catalog/promos'); assert.strictEqual(promos.json().promos.length, 1);
});
++ await check('the sitemap dates catalog pages by their newest price check', async () => {
++ const xml = (await t.api('GET', '/sitemap.xml')).text;
++ const planUrl = xml.match(/<url><loc>[^<]*\/catalog\/plans\/[^<]+<\/loc><lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod><\/url>/);
++ assert.ok(planUrl, `a plan page carries a lastmod: ${xml.slice(0, 400)}`);
++ assert.match(xml, /<loc>[^<]*\/catalog<\/loc><lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/);
++ assert.match(xml, /<loc>[^<]*\/terms<\/loc><\/url>/, 'a static page carries no lastmod');
++ });
} finally { await t.close(); }
done();
})().catch((e) => { console.error(e); process.exit(1); });