openvibers/openvibe.reviews Commits
Sitemaps: every entry carries an honest lastmod (item changes, release time for static pages) (#43)
Changes
@@@ -138,6 +138,8 @@ secret, Community and editors reported), `/release.json`, `/metrics` (outbox and| `POST /internal/events` | OpenVibe.Events deliveries (`sources.item.*`), HMAC-signed; not proxied publicly || `/auth/*` | Network SSO (client `reviews`) |++Sitemap entries carry lastmod from entity updates, the newest entity on the home page, or the release commit for `/about`.++## API `/api/v1`People use their Network JWT; services use a client-credentials token for audience@@@ -156,7 +156,7 @@ function createApp({ config, svc, viewers, platform, sources, sync, keys, db, lores.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === require('./render/layout').assetVersion(rel) }));},}));-- app.use(createMachine({ svc, config }));++ app.use(createMachine({ svc, config, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));app.post(['/e/*', '/editor/*'], limiter(10 * 60000, 120));app.post('/e/:slug/correct', limiter(60 * 60000, 20));@@@ -19,7 +19,7 @@ const { SITE_SUMMARY } = require('../render/layout');const PER_SITEMAP = 45000;const ALL_SUMMARIES = 50000;--function createMachine({ svc, config }) {++function createMachine({ svc, config, releaseLastmod }) {const router = express.Router();const origin = config.baseUrl;const setCache = (res, s = 300) => res.set('Cache-Control', cache.htmlHeaders({ maxAge: s }));@@@ -36,14 +36,23 @@ function createMachine({ svc, config }) {}router.get('/sitemap.xml', async (_req, res) => {-- const pages = seo.sitemap(await entityEntries(), { maxUrls: PER_SITEMAP });-- const maps = [{ loc: `${origin}/sitemaps/pages.xml` }];-- pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml` }));++ const entries = (await entityEntries()).filter((e) => e.decision.indexable);++ const pages = seo.sitemap(entries, { maxUrls: PER_SITEMAP });++ const updated = entries.map((e) => e.lastmod).filter(Boolean);++ const homeLastmod = updated.length ? new Date(Math.max(...updated)).toISOString() : releaseLastmod;++ const pagesLastmod = [homeLastmod, releaseLastmod].filter(Boolean).sort().pop();++ const maps = [{ loc: `${origin}/sitemaps/pages.xml`, lastmod: pagesLastmod }];++ pages.files.forEach((_f, i) => {++ const times = entries.slice(i * PER_SITEMAP, (i + 1) * PER_SITEMAP).map((e) => e.lastmod).filter(Boolean);++ maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml`, lastmod: times.length ? new Date(Math.max(...times)).toISOString() : undefined });++ });setCache(res).type('application/xml').send(seo.sitemapIndex(maps));});-- router.get('/sitemaps/pages.xml', (_req, res) => {++ router.get('/sitemaps/pages.xml', async (_req, res) => {++ const updated = (await entityEntries()).filter((e) => e.decision.indexable && e.lastmod).map((e) => e.lastmod);++ const homeLastmod = updated.length ? new Date(Math.max(...updated)).toISOString() : releaseLastmod;const policy = { minWords: 0, requireSources: false };-- const entries = ['/', '/about'].map((p) => ({ loc: seo.canonicalUrl(origin, p), decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(origin, p), wordCount: 0 }, { policy, now: Date.now() }) }));++ const entries = ['/', '/about'].map((p) => ({ loc: seo.canonicalUrl(origin, p), lastmod: p === '/' ? homeLastmod : releaseLastmod, decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(origin, p), wordCount: 0 }, { policy, now: Date.now() }) }));setCache(res).type('application/xml').send(seo.sitemap(entries).files[0]);});router.get('/sitemaps/entities-:n.xml', async (req, res) => {@@@ -5,6 +5,17 @@* JSON-LD is absent without signals.*/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, req, suite, steamItem, productItem, importItem, createEntity, deliver, sourcesEvent, cookieFor, editorToken, readerToken, fakeCommunity,} = require('./helpers');@@@ -87,8 +98,13 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots followassert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));sm = await req(h, 'GET', '/sitemaps/entities-1.xml');assert.ok(sm.text.includes(`http://reviews.test/e/${e.slug}`));++ assertSitemapLastmods(sm.text);++ const entityRow = await h.db.prepare('SELECT updated_at FROM review_entities WHERE id = ?').get(e.id);++ assert.ok(sm.text.includes(`<lastmod>${new Date(entityRow.updated_at).toISOString().slice(0, 10)}</lastmod>`));const idx = await req(h, 'GET', '/sitemap.xml');assert.match(idx.text, /sitemaps\/entities-1\.xml/);++ assertSitemapLastmods(idx.text, 'sitemap');++ assertSitemapLastmods((await req(h, 'GET', '/sitemaps/pages.xml')).text);const atom = await req(h, 'GET', '/feed.atom');assert.strictEqual(atom.status, 200);assert.match(atom.text, /Portal 2: summary/);
@@ -138,6 +138,8 @@ secret, Community and editors reported), `/release.json`, `/metrics` (outbox and
@@ -138,6 +138,8 @@ secret, Community and editors reported), `/release.json`, `/metrics` (outbox and
| `POST /internal/events` | OpenVibe.Events deliveries (`sources.item.*`), HMAC-signed; not proxied publicly |
| `POST /internal/events` | OpenVibe.Events deliveries (`sources.item.*`), HMAC-signed; not proxied publicly |
| `/auth/*` | Network SSO (client `reviews`) |
| `/auth/*` | Network SSO (client `reviews`) |
+Sitemap entries carry lastmod from entity updates, the newest entity on the home page, or the release commit for `/about`.
+
## API `/api/v1`
## API `/api/v1`
People use their Network JWT; services use a client-credentials token for audience
People use their Network JWT; services use a client-credentials token for audience
@@ -156,7 +156,7 @@ function createApp({ config, svc, viewers, platform, sources, sync, keys, db, lo
@@ -156,7 +156,7 @@ function createApp({ config, svc, viewers, platform, sources, sync, keys, db, lo
res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === require('./render/layout').assetVersion(rel) }));
res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === require('./render/layout').assetVersion(rel) }));
},
},
}));
}));
- app.use(createMachine({ svc, config }));
+ app.use(createMachine({ svc, config, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));
app.post(['/e/*', '/editor/*'], limiter(10 * 60000, 120));
app.post(['/e/*', '/editor/*'], limiter(10 * 60000, 120));
app.post('/e/:slug/correct', limiter(60 * 60000, 20));
app.post('/e/:slug/correct', limiter(60 * 60000, 20));
@@ -19,7 +19,7 @@ const { SITE_SUMMARY } = require('../render/layout');
@@ -19,7 +19,7 @@ const { SITE_SUMMARY } = require('../render/layout');
const PER_SITEMAP = 45000;
const PER_SITEMAP = 45000;
const ALL_SUMMARIES = 50000;
const ALL_SUMMARIES = 50000;
-function createMachine({ svc, config }) {
+function createMachine({ svc, config, releaseLastmod }) {
const router = express.Router();
const router = express.Router();
const origin = config.baseUrl;
const origin = config.baseUrl;
const setCache = (res, s = 300) => res.set('Cache-Control', cache.htmlHeaders({ maxAge: s }));
const setCache = (res, s = 300) => res.set('Cache-Control', cache.htmlHeaders({ maxAge: s }));
@@ -36,14 +36,23 @@ function createMachine({ svc, config }) {
@@ -36,14 +36,23 @@ function createMachine({ svc, config }) {
}
}
router.get('/sitemap.xml', async (_req, res) => {
router.get('/sitemap.xml', async (_req, res) => {
- const pages = seo.sitemap(await entityEntries(), { maxUrls: PER_SITEMAP });
- const maps = [{ loc: `${origin}/sitemaps/pages.xml` }];
- pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml` }));
+ const entries = (await entityEntries()).filter((e) => e.decision.indexable);
+ const pages = seo.sitemap(entries, { maxUrls: PER_SITEMAP });
+ const updated = entries.map((e) => e.lastmod).filter(Boolean);
+ const homeLastmod = updated.length ? new Date(Math.max(...updated)).toISOString() : releaseLastmod;
+ const pagesLastmod = [homeLastmod, releaseLastmod].filter(Boolean).sort().pop();
+ const maps = [{ loc: `${origin}/sitemaps/pages.xml`, lastmod: pagesLastmod }];
+ pages.files.forEach((_f, i) => {
+ const times = entries.slice(i * PER_SITEMAP, (i + 1) * PER_SITEMAP).map((e) => e.lastmod).filter(Boolean);
+ maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml`, lastmod: times.length ? new Date(Math.max(...times)).toISOString() : undefined });
+ });
setCache(res).type('application/xml').send(seo.sitemapIndex(maps));
setCache(res).type('application/xml').send(seo.sitemapIndex(maps));
});
});
- router.get('/sitemaps/pages.xml', (_req, res) => {
+ router.get('/sitemaps/pages.xml', async (_req, res) => {
+ const updated = (await entityEntries()).filter((e) => e.decision.indexable && e.lastmod).map((e) => e.lastmod);
+ const homeLastmod = updated.length ? new Date(Math.max(...updated)).toISOString() : releaseLastmod;
const policy = { minWords: 0, requireSources: false };
const policy = { minWords: 0, requireSources: false };
- const entries = ['/', '/about'].map((p) => ({ loc: seo.canonicalUrl(origin, p), decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(origin, p), wordCount: 0 }, { policy, now: Date.now() }) }));
+ const entries = ['/', '/about'].map((p) => ({ loc: seo.canonicalUrl(origin, p), lastmod: p === '/' ? homeLastmod : releaseLastmod, decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(origin, p), wordCount: 0 }, { policy, now: Date.now() }) }));
setCache(res).type('application/xml').send(seo.sitemap(entries).files[0]);
setCache(res).type('application/xml').send(seo.sitemap(entries).files[0]);
});
});
router.get('/sitemaps/entities-:n.xml', async (req, res) => {
router.get('/sitemaps/entities-:n.xml', async (req, res) => {
@@ -5,6 +5,17 @@
@@ -5,6 +5,17 @@
* JSON-LD is absent without signals.
* JSON-LD is absent without signals.
*/
*/
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 {
const {
boot, req, suite, steamItem, productItem, importItem, createEntity, deliver, sourcesEvent, cookieFor, editorToken, readerToken, fakeCommunity,
boot, req, suite, steamItem, productItem, importItem, createEntity, deliver, sourcesEvent, cookieFor, editorToken, readerToken, fakeCommunity,
} = require('./helpers');
} = require('./helpers');
@@ -87,8 +98,13 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow
@@ -87,8 +98,13 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow
assert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));
assert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));
sm = await req(h, 'GET', '/sitemaps/entities-1.xml');
sm = await req(h, 'GET', '/sitemaps/entities-1.xml');
assert.ok(sm.text.includes(`http://reviews.test/e/${e.slug}`));
assert.ok(sm.text.includes(`http://reviews.test/e/${e.slug}`));
+ assertSitemapLastmods(sm.text);
+ const entityRow = await h.db.prepare('SELECT updated_at FROM review_entities WHERE id = ?').get(e.id);
+ assert.ok(sm.text.includes(`<lastmod>${new Date(entityRow.updated_at).toISOString().slice(0, 10)}</lastmod>`));
const idx = await req(h, 'GET', '/sitemap.xml');
const idx = await req(h, 'GET', '/sitemap.xml');
assert.match(idx.text, /sitemaps\/entities-1\.xml/);
assert.match(idx.text, /sitemaps\/entities-1\.xml/);
+ assertSitemapLastmods(idx.text, 'sitemap');
+ assertSitemapLastmods((await req(h, 'GET', '/sitemaps/pages.xml')).text);
const atom = await req(h, 'GET', '/feed.atom');
const atom = await req(h, 'GET', '/feed.atom');
assert.strictEqual(atom.status, 200);
assert.strictEqual(atom.status, 200);
assert.match(atom.text, /Portal 2: summary/);
assert.match(atom.text, /Portal 2: summary/);