openvibers/openvibe.coupons Commits

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

OP OpenVibers committed 12 Oct 2026 · 612dfb37f6 · 3 files, +37 −12

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 1cb0a38..8b139ee 100644
---- a/README.md
++++ b/README.md
@@@ -170,6 +170,8 @@ The code is in `server/domain/hosts.js` and `server/domain/psl.js`. The tests ar
| `/auth/*` | Network SSO, the same session layer as Blog and Community. |
| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | `/metrics` is loopback only. |
++Sitemap entries carry lastmod from each code or shop page's latest change.
++
### Discoverability (roadmap §32)
- Every page gets its robots meta, canonical and `X-Robots-Tag` from the `openvibe-publishing/seo`
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 78638dc..80079f1 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -97,12 +97,24 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
}));
});
++ function merchantEntries(entries) {
++ const byMerchant = new Map();
++ for (const e of entries) {
++ const cur = byMerchant.get(e.m.id);
++ if (!cur) byMerchant.set(e.m.id, { m: e.m, count: 1, lastmod: e.c.updated_at });
++ else { cur.count++; cur.lastmod = Math.max(cur.lastmod, e.c.updated_at); }
++ }
++ return [...byMerchant.values()].map(({ m, count, lastmod }) => ({ loc: publication.merchantUrl(m), lastmod: Math.max(lastmod, m.updated_at), decision: publication.merchantDecision(m, count) }));
++ }
++
router.get('/sitemap.xml', async (_req, res) => {
-- const entries = (await activeEntries()).filter((e) => e.decision.indexable);
-- const newest = entries.length ? Math.max(...entries.map((e) => e.c.updated_at)) : null;
++ const entries = await activeEntries();
++ const coupons = entries.filter((e) => e.decision.indexable);
++ const merchants = merchantEntries(entries).filter((e) => e.decision.indexable);
++ const newest = (rows, time) => rows.length ? new Date(Math.max(...rows.map(time))).toISOString() : undefined;
xml(res, seo.sitemapIndex([
-- { loc: abs('/sitemaps/merchants.xml'), ...(newest ? { lastmod: new Date(newest).toISOString() } : {}) },
-- { loc: abs('/sitemaps/coupons.xml'), ...(newest ? { lastmod: new Date(newest).toISOString() } : {}) },
++ { loc: abs('/sitemaps/merchants.xml'), lastmod: newest(merchants, (e) => e.lastmod) },
++ { loc: abs('/sitemaps/coupons.xml'), lastmod: newest(coupons, (e) => e.c.updated_at) },
]));
});
@@@ -111,14 +123,7 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
});
router.get('/sitemaps/merchants.xml', async (_req, res) => {
-- const byMerchant = new Map();
-- for (const e of await activeEntries()) {
-- const cur = byMerchant.get(e.m.id);
-- if (!cur) byMerchant.set(e.m.id, { m: e.m, count: 1, lastmod: e.c.updated_at });
-- else { cur.count++; cur.lastmod = Math.max(cur.lastmod, e.c.updated_at); }
-- }
-- const list = [...byMerchant.values()].map(({ m, count, lastmod }) => ({ loc: publication.merchantUrl(m), lastmod: Math.max(lastmod, m.updated_at), decision: publication.merchantDecision(m, count) }));
-- xml(res, seo.sitemap(list).files[0]);
++ xml(res, seo.sitemap(merchantEntries(await activeEntries())).files[0]);
});
// ── Feeds ───────────────────────────────────────────────
ddiff --git a/test/pages-discovery.test.js b/test/pages-discovery.test.js
iindex 57ca555..4c5d511 100644
---- a/test/pages-discovery.test.js
++++ b/test/pages-discovery.test.js
@@@ -4,6 +4,17 @@
* feeds, JSON twins, JSON-LD from real fields only), cache-safe, and honest about readiness.
*/
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 { boot, check, done } = require('./helpers/boot');
(async () => {
@@@ -87,6 +98,13 @@ const { boot, check, done } = require('./helpers/boot');
assert.match((await t.get('/sitemap.xml')).text, /sitemaps\/coupons\.xml[\s\S]*sitemaps\/merchants\.xml|sitemaps\/merchants\.xml[\s\S]*sitemaps\/coupons\.xml/);
assert.match((await t.get('/sitemaps/coupons.xml')).text, new RegExp(`https://openvibe\\.coupons/c/${codeId}`));
assert.match((await t.get('/sitemaps/merchants.xml')).text, new RegExp(`https://openvibe\\.coupons/m/${shop.slug}`));
++ const couponMap = (await t.get('/sitemaps/coupons.xml')).text;
++ const merchantMap = (await t.get('/sitemaps/merchants.xml')).text;
++ assertSitemapLastmods(couponMap);
++ assertSitemapLastmods(merchantMap);
++ const row = await t.ctx.store.db.prepare('SELECT updated_at FROM coupons WHERE id = ?').get(codeId);
++ assert.ok(couponMap.includes(`<loc>https://openvibe.coupons/c/${codeId}</loc><lastmod>${new Date(row.updated_at).toISOString().slice(0, 10)}</lastmod>`));
++ assertSitemapLastmods((await t.get('/sitemap.xml')).text, 'sitemap');
const rss = await t.get('/feed.xml');
assert.match(rss.headers.get('content-type'), /rss/);
assert.match(rss.text, /VEG5/);