openvibers/openvibe.live Commits

Live: every sitemap entry carries an honest lastmod (newest item for lists and channels, the release's commit time otherwise) (#109)

OP OpenVibers committed 12 Oct 2026 · bad1d88665 · 5 files, +43 −5

* Live: every sitemap entry carries an honest lastmod (newest item for lists and channels, the release's commit time otherwise)

With recordings and clips moved to OpenVibe.Video's sitemap (Live#108), Live's sitemap kept only its static pages and channels, and none of them carried a `lastmod`. Now every entry carries an honest one:

- **List pages:** `/`, `/content`, `/vods` and `/clips` take their newest item's time, found during the same walk over Media's public recordings and clips that names the channels.
- **Channel pages:** their newest recording or clip.
- **Everything else** (`/chat`, `/arena`, `/moments`, `/pastes`, `/docs/*`): the release's commit time from `openvibe-shared/release`, handed over at boot through `discovery.setReleasedAt`. It is set only when the release has a real commit time, never the boot time. Shared 4.1.1 makes release-layout services read it; until that pin lands those entries carry none, rather than an invented date.

Tests: `seo-ssr.test.js` checks the VOD list's lastmod (the newest recording), a channel's, and a static page's (the release time once set). `seo-ssr`, `seo-moments` and `llms-txt` pass with `ov test`.

* seo-discovery test: the home entry may carry its lastmod

Changes

ddiff --git a/server/index.js b/server/index.js
iindex 469229c3..b532e6c5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -222,6 +222,12 @@ const appRoot = process.env.OV_APP_ROOT || path.join(__dirname, '..');
const release = require('openvibe-shared/release').createRelease({
service: 'live', root: appRoot, env: releaseEnv(), components: require('./web/release-components').componentsFor(appRoot), recheckMs: 30000,
});
++// Static pages' sitemap lastmod is the release's commit time — never the boot time (released_at falls back to it
++// when the commit time cannot be read).
++{
++ const r = release.full();
++ if (r.released_at !== r.booted_at) require('./seo/discovery').setReleasedAt(r.released_at);
++}
function releaseEnv() {
if (process.env.RELEASE_COMMIT) return process.env;
// The release id is the directory `current` points at (…/releases/<time>-<sha8>), read at every rebuild.
ddiff --git a/server/seo/discovery.js b/server/seo/discovery.js
iindex 2abd5078..f21cdacd 100644
---- a/server/seo/discovery.js
++++ b/server/seo/discovery.js
@@@ -44,7 +44,17 @@ async function buildSitemap() {
statics.push([f === 'README.md' ? '/docs' : `/docs/${f.slice(0, -3)}`, 'weekly', 0.5]);
}
} catch { /* docs folder absent in some deploys */ }
-- for (const [u, cf, pr] of statics) entry(u, null, cf, pr);
++ // Every entry carries an honest lastmod: a list page its newest item, a channel its newest recording or clip,
++ // the rest the release's commit time (setReleasedAt; none when the release has no commit time).
++ const newest = { vod: null, clip: null };
++ const channelNewest = new Map();
++ const later = (a, b) => (!a ? b : !b ? a : (a > b ? a : b));
++ const seen = (kind, row, ch) => {
++ const t = isoTime(row.created_at);
++ if (!t) return;
++ newest[kind] = later(newest[kind], t);
++ if (ch) channelNewest.set(ch, later(channelNewest.get(ch), t));
++ };
const page = async (fetch, per, cap, emit) => {
let off = 0;
while (off < cap) {
@@@ -60,6 +70,7 @@ async function buildSitemap() {
await page((l, o) => media.listVods({ limit: l, offset: o }).then(r => r?.vods || []), 200, SITEMAP_CAP, async (v) => {
const ch = await channelOf(v, v.user_id);
if (ch) seenChannels.add(ch);
++ seen('vod', v, ch);
});
// People's clips only: AI Moments are noindex and never listed (the /moments
// collection above is their indexable form). The filter is checked again per row, so an
@@@ -68,9 +79,12 @@ async function buildSitemap() {
if (isAiClip(c)) return;
const ch = await channelOf({}, c.channel_user_id != null ? c.channel_user_id : c.user_id);
if (ch) seenChannels.add(ch);
++ seen('clip', c, ch);
});
// No /p/ here: pastes are OpenVibe.Community's, and its sitemap lists them under their canonical URL.
-- for (const u of seenChannels) if (u) entry(`/@${u}`, null, 'daily', 0.6);
++ const lists = { '/': later(newest.vod, newest.clip), '/content': later(newest.vod, newest.clip), '/vods': newest.vod, '/clips': newest.clip };
++ for (const [u, cf, pr] of statics) entry(u, lists[u] || releasedAt, cf, pr);
++ for (const u of seenChannels) if (u) entry(`/@${u}`, channelNewest.get(u) || null, 'daily', 0.6);
return seo.sitemapXml(urls);
}
async function sitemapHandler(req, res) {
@@@ -175,4 +189,13 @@ function register(app) {
app.get('/llms-full.txt', textHandler(llmsFullTxt));
}
--module.exports = { register, sitemapHandler, buildSitemap, robotsTxt, llmsTxt, llmsFullTxt };
++/** The release's commit time (server/index.js, from openvibe-shared/release), for static pages' lastmod. */
++let releasedAt = null;
++function setReleasedAt(iso) { releasedAt = isoTime(iso); }
++function isoTime(v) {
++ if (!v) return null;
++ const d = new Date(String(v).includes('T') ? v : `${String(v).replace(' ', 'T')}Z`);
++ return Number.isNaN(d.getTime()) ? null : d.toISOString();
++}
++
++module.exports = { register, sitemapHandler, buildSitemap, robotsTxt, llmsTxt, llmsFullTxt, setReleasedAt };
ddiff --git a/server/seo/index.js b/server/seo/index.js
iindex 8821c48c..696f344f 100644
---- a/server/seo/index.js
++++ b/server/seo/index.js
@@@ -17,6 +17,6 @@ function register(app) {
module.exports = {
register,
middleware: pages.middleware, shellHtml: pages.shellHtml, render: pages.render, _pageMeta: pages._pageMeta,
-- sitemapHandler: discovery.sitemapHandler, buildSitemap: discovery.buildSitemap,
++ sitemapHandler: discovery.sitemapHandler, buildSitemap: discovery.buildSitemap, setReleasedAt: discovery.setReleasedAt,
robotsTxt: discovery.robotsTxt, llmsTxt: discovery.llmsTxt, llmsFullTxt: discovery.llmsFullTxt,
};
ddiff --git a/test/seo-discovery.test.js b/test/seo-discovery.test.js
iindex b8827e47..afaf58a2 100644
---- a/test/seo-discovery.test.js
++++ b/test/seo-discovery.test.js
@@@ -47,7 +47,7 @@ async function check(name, fn) {
}
assert.ok(locs.every((u) => u.startsWith('https://openvibe.live/')), locs.join(' '));
assert.ok(!locs.some((u) => u.includes('/p/')), 'pastes are Community\'s');
-- assert.ok(r.text.includes('<loc>https://openvibe.live/</loc><changefreq>daily</changefreq><priority>1.0</priority>'));
++ assert.ok(/<loc>https:\/\/openvibe\.live\/<\/loc>(<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>)?<changefreq>daily<\/changefreq><priority>1\.0<\/priority>/.test(r.text), 'the home page (with its newest item as lastmod when there is one)');
assert.strictEqual(r.headers['cache-control'], 'public, max-age=3600, stale-while-revalidate=3600');
});
ddiff --git a/test/seo-ssr.test.js b/test/seo-ssr.test.js
iindex 3a5e980e..7ee6db09 100644
---- a/test/seo-ssr.test.js
++++ b/test/seo-ssr.test.js
@@@ -314,6 +314,15 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.ok(xml.includes('<loc>https://openvibe.live/@alice</loc>'), 'the channel with videos');
assert.ok(!xml.includes('<loc>https://openvibe.live/vod/100</loc>'), 'recordings are listed by OpenVibe.Video, their canonical home');
assert.ok(!xml.includes('/vod/199<'), 'no private VOD');
++ // An honest lastmod everywhere it can be known: list pages and channels their newest item, the rest the
++ // release's commit time once server/index.js has set it.
++ const newestVod = day(30).slice(0, 10);
++ assert.ok(xml.includes(`<loc>https://openvibe.live/vods</loc><lastmod>${newestVod}</lastmod>`), 'the VOD list: its newest recording');
++ assert.ok(/<loc>https:\/\/openvibe\.live\/@alice<\/loc><lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/.test(xml), 'a channel: its newest recording or clip');
++ seo.setReleasedAt('2026-10-01T12:00:00Z');
++ const withRelease = await seo.buildSitemap();
++ assert.ok(withRelease.includes('<loc>https://openvibe.live/chat</loc><lastmod>2026-10-01</lastmod>'), 'a static page: the release\'s commit time');
++ seo.setReleasedAt(null);
});
await check('server/index.js sends the fallback shell through seo.shellHtml', () => {