openvibers/openvibe.coupons Commits
Sitemaps: every entry carries an honest lastmod (item changes, release time for static pages) (#42)
Changes
@@@ -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`@@@ -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 ───────────────────────────────────────────────@@@ -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/);
@@ -170,6 +170,8 @@ The code is in `server/domain/hosts.js` and `server/domain/psl.js`. The tests ar
@@ -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. |
| `/auth/*` | Network SSO, the same session layer as Blog and Community. |
| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | `/metrics` is loopback only. |
| `/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)
### Discoverability (roadmap §32)
- Every page gets its robots meta, canonical and `X-Robots-Tag` from the `openvibe-publishing/seo`
- Every page gets its robots meta, canonical and `X-Robots-Tag` from the `openvibe-publishing/seo`
@@ -97,12 +97,24 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
@@ -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) => {
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([
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
@@ -111,14 +123,7 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
});
});
router.get('/sitemaps/merchants.xml', async (_req, res) => {
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 ───────────────────────────────────────────────
// ── Feeds ───────────────────────────────────────────────
@@ -4,6 +4,17 @@
@@ -4,6 +4,17 @@
* feeds, JSON twins, JSON-LD from real fields only), cache-safe, and honest about readiness.
* feeds, JSON twins, JSON-LD from real fields only), cache-safe, and honest about readiness.
*/
*/
const assert = require('assert');
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');
const { boot, check, done } = require('./helpers/boot');
(async () => {
(async () => {
@@ -87,6 +98,13 @@ const { boot, check, done } = require('./helpers/boot');
@@ -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('/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/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}`));
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');
const rss = await t.get('/feed.xml');
assert.match(rss.headers.get('content-type'), /rss/);
assert.match(rss.headers.get('content-type'), /rss/);
assert.match(rss.text, /VEG5/);
assert.match(rss.text, /VEG5/);