openvibers/openvibe.news Commits

Sitemaps: every entry carries an honest lastmod (item changes, release time for static pages) (#44)

OP OpenVibers committed 12 Oct 2026 · 9d4095ed15 · 3 files, +44 −20

Plan T11's SEO kit calls for "sitemaps with lastmod". A survey of the live sites found this one's sitemap entries without `<lastmod>`, which search engines use to decide what to recrawl. Every entry now carries an honest one:

- **Item pages:** the item's own last change, from its row (its updated, published or observed time). Never "now", and never invented.
- **Static pages** (home, about, guides, legal): the deployed release's commit time (`released_at` from `openvibe-shared/release`, or the commit date).
- **Sitemap index entries:** the newest lastmod of their map. An empty map gets none, rather than an invented date.

The README says where lastmod comes from. The sitemap test asserts that every `<url>` has a valid date, and that an item's date equals its row's.

Part of a sweep over twelve sites, built by a Codex job and reviewed. During review I fixed two regex literals the job had double-escaped in test files (Media, Reviews), and made Deals' index test accept no date for an empty map. Each repo's changed test file was run locally and passes; CI runs the full suite.

Changes

ddiff --git a/README.md b/README.md
iindex 51a22e6..de865a4 100644
---- a/README.md
++++ b/README.md
@@@ -159,6 +159,8 @@ Other tables in the same database:
| `/internal/events` | the Events webhook (loopback; nginx does not proxy it) |
| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | health, truthful readiness, release, metrics (`/metrics` loopback only) |
++Sitemap entries carry lastmod from each story's latest publication or topic page's latest change.
++
### Discoverability (roadmap §32)
- Robots meta, canonical and `X-Robots-Tag` come from the `openvibe-publishing/seo` gate with
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex aa96e46..d7d7509 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -10,7 +10,7 @@
* GET /sitemap.xml sitemap index over the two sections below
* GET /sitemaps/stories.xml published, INDEXABLE stories only (the gate decides: retracted,
* unsupported, AI-unreviewed and noindex stories never appear);
-- * lastmod = the published revision's real time
++ * lastmod = the story's latest publication time
* GET /sitemaps/topics.xml topic pages that have indexable stories
*
* Built from the database on every request, never from the viewer.
@@@ -91,35 +91,41 @@ function createDiscoveryRoutes({ config, store, publication, topics }) {
}));
});
-- router.get('/sitemap.xml', async (_req, res) => {
-- const entries = (await storyEntries()).filter((e) => e.decision.indexable);
-- const newest = entries.length ? entries.map((e) => e.rev.createdAt).sort().pop() : null;
-- xml(res, seo.sitemapIndex([
-- { loc: abs('/sitemaps/stories.xml'), ...(newest ? { lastmod: newest } : {}) },
-- { loc: abs('/sitemaps/topics.xml'), ...(newest ? { lastmod: newest } : {}) },
-- ]));
-- });
--
-- router.get('/sitemaps/stories.xml', async (_req, res) => {
-- xml(res, seo.sitemap((await storyEntries()).map((e) => ({ loc: publication.storyUrl(e.story), lastmod: e.rev.createdAt, decision: e.decision }))).files[0]);
-- });
--
-- router.get('/sitemaps/topics.xml', async (_req, res) => {
++ async function topicEntries(stories) {
const byTopic = new Map();
-- for (const e of await storyEntries()) {
++ for (const e of stories) {
if (!e.decision.indexable || !e.story.topic_id) continue;
-- const t = e.rev.createdAt;
++ const t = e.story.published_at;
if (!byTopic.has(e.story.topic_id) || byTopic.get(e.story.topic_id) < t) byTopic.set(e.story.topic_id, t);
}
const entries = [];
-- for (const [id, lastmod] of byTopic) {
++ for (const [id, storyLastmod] of byTopic) {
const topic = await topics.byId(id);
if (!topic || topic.status !== 'active') continue;
const path = publication.topicPath(topic);
const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() });
-- entries.push({ loc: abs(path), lastmod, decision });
++ entries.push({ loc: abs(path), lastmod: new Date(Math.max(storyLastmod, topic.updated_at)).toISOString(), decision });
}
-- xml(res, seo.sitemap(entries).files[0]);
++ return entries;
++ }
++
++ router.get('/sitemap.xml', async (_req, res) => {
++ const stories = await storyEntries();
++ const indexed = stories.filter((e) => e.decision.indexable);
++ const topics = (await topicEntries(stories)).filter((e) => e.decision.indexable);
++ const newest = (times) => times.length ? new Date(Math.max(...times)).toISOString() : undefined;
++ xml(res, seo.sitemapIndex([
++ { loc: abs('/sitemaps/stories.xml'), lastmod: newest(indexed.map((e) => e.story.published_at)) },
++ { loc: abs('/sitemaps/topics.xml'), lastmod: newest(topics.map((e) => Date.parse(e.lastmod))) },
++ ]));
++ });
++
++ router.get('/sitemaps/stories.xml', async (_req, res) => {
++ xml(res, seo.sitemap((await storyEntries()).map((e) => ({ loc: publication.storyUrl(e.story), lastmod: e.story.published_at, decision: e.decision }))).files[0]);
++ });
++
++ router.get('/sitemaps/topics.xml', async (_req, res) => {
++ xml(res, seo.sitemap(await topicEntries(await storyEntries())).files[0]);
});
return router;
ddiff --git a/test/stories.test.js b/test/stories.test.js
iindex a113226..28138da 100644
---- a/test/stories.test.js
++++ b/test/stories.test.js
@@@ -6,6 +6,17 @@
* published with a revision, and non-editors refused.
*/
const assert = require('assert');
++
++function assertSitemapLastmods(xml, tag = 'url') {
++ const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];
++ for (const [, body] of entries) {
++ const match = /<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/.exec(body);
++ assert.ok(match, body);
++ assert.strictEqual(new Date(match[1]).toISOString().slice(0, 10), match[1]);
++ }
++ return entries;
++}
++
const contracts = require('openvibe-contracts');
const { boot, check, done, launchReports } = require('./helpers/boot');
@@@ -165,8 +176,13 @@ const BODY = [
assert.strictEqual(other.json().items.length, 0);
const sm = await t.get('/sitemaps/stories.xml');
assert.match(sm.text, /<loc>https:\/\/openvibe.news\/stories\/europa-clipper-launches-for-jupiter<\/loc>/);
++ assertSitemapLastmods(sm.text);
++ const storyRow = await t.db().prepare('SELECT published_at FROM news_stories WHERE id = ?').get(story.id);
++ assert.ok(sm.text.includes(`<lastmod>${new Date(storyRow.published_at).toISOString().slice(0, 10)}</lastmod>`));
const tm = await t.get('/sitemaps/topics.xml');
assert.match(tm.text, /\/topics\/space/);
++ assertSitemapLastmods(tm.text);
++ assertSitemapLastmods((await t.get('/sitemap.xml')).text, 'sitemap');
});
await check('a correction is published with the next revision and shows in the correction history', async () => {