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)
Changes
@@@ -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.@@@ -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 };@@@ -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,};@@@ -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');});@@@ -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', () => {
@@ -222,6 +222,12 @@ const appRoot = process.env.OV_APP_ROOT || path.join(__dirname, '..');
@@ -222,6 +222,12 @@ const appRoot = process.env.OV_APP_ROOT || path.join(__dirname, '..');
const release = require('openvibe-shared/release').createRelease({
const release = require('openvibe-shared/release').createRelease({
service: 'live', root: appRoot, env: releaseEnv(), components: require('./web/release-components').componentsFor(appRoot), recheckMs: 30000,
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() {
function releaseEnv() {
if (process.env.RELEASE_COMMIT) return process.env;
if (process.env.RELEASE_COMMIT) return process.env;
// The release id is the directory `current` points at (…/releases/<time>-<sha8>), read at every rebuild.
// The release id is the directory `current` points at (…/releases/<time>-<sha8>), read at every rebuild.
@@ -44,7 +44,17 @@ async function buildSitemap() {
@@ -44,7 +44,17 @@ async function buildSitemap() {
statics.push([f === 'README.md' ? '/docs' : `/docs/${f.slice(0, -3)}`, 'weekly', 0.5]);
statics.push([f === 'README.md' ? '/docs' : `/docs/${f.slice(0, -3)}`, 'weekly', 0.5]);
}
}
} catch { /* docs folder absent in some deploys */ }
} 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) => {
const page = async (fetch, per, cap, emit) => {
let off = 0;
let off = 0;
while (off < cap) {
while (off < cap) {
@@ -60,6 +70,7 @@ async function buildSitemap() {
@@ -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) => {
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);
const ch = await channelOf(v, v.user_id);
if (ch) seenChannels.add(ch);
if (ch) seenChannels.add(ch);
+ seen('vod', v, ch);
});
});
// People's clips only: AI Moments are noindex and never listed (the /moments
// 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
// collection above is their indexable form). The filter is checked again per row, so an
@@ -68,9 +79,12 @@ async function buildSitemap() {
@@ -68,9 +79,12 @@ async function buildSitemap() {
if (isAiClip(c)) return;
if (isAiClip(c)) return;
const ch = await channelOf({}, c.channel_user_id != null ? c.channel_user_id : c.user_id);
const ch = await channelOf({}, c.channel_user_id != null ? c.channel_user_id : c.user_id);
if (ch) seenChannels.add(ch);
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.
// 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);
return seo.sitemapXml(urls);
}
}
async function sitemapHandler(req, res) {
async function sitemapHandler(req, res) {
@@ -175,4 +189,13 @@ function register(app) {
@@ -175,4 +189,13 @@ function register(app) {
app.get('/llms-full.txt', textHandler(llmsFullTxt));
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 };
@@ -17,6 +17,6 @@ function register(app) {
@@ -17,6 +17,6 @@ function register(app) {
module.exports = {
module.exports = {
register,
register,
middleware: pages.middleware, shellHtml: pages.shellHtml, render: pages.render, _pageMeta: pages._pageMeta,
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,
robotsTxt: discovery.robotsTxt, llmsTxt: discovery.llmsTxt, llmsFullTxt: discovery.llmsFullTxt,
};
};
@@ -47,7 +47,7 @@ async function check(name, fn) {
@@ -47,7 +47,7 @@ async function check(name, fn) {
}
}
assert.ok(locs.every((u) => u.startsWith('https://openvibe.live/')), locs.join(' '));
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(!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');
assert.strictEqual(r.headers['cache-control'], 'public, max-age=3600, stale-while-revalidate=3600');
});
});
@@ -314,6 +314,15 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -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/@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('<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');
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', () => {
await check('server/index.js sends the fallback shell through seo.shellHtml', () => {