domma-cms 0.88.1 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * SEO's door for extensions - how SEO Pro adds to the free SEO without
3
+ * replacing it (the Shopping Cart's pattern, lib/extensions.js there).
4
+ *
5
+ * It lives on `globalThis` under a registered symbol, so an extension reaches
6
+ * it WITHOUT importing a file from this plugin. An extension registers from its
7
+ * `onReady` hook, when every plugin has loaded:
8
+ *
9
+ * const seo = globalThis[Symbol.for('domma.seo')];
10
+ * if (seo?.version >= 1) seo.register('seo-pro', {
11
+ * label: 'SEO Pro',
12
+ * tabs: [{key, label, icon, module, help}] // admin tabs in the SEO screen
13
+ * });
14
+ * seo.on('audit', (audit) => ...); // after every audit
15
+ *
16
+ * and reads through `seo.services` (listEntries, auditList, fetchPage,
17
+ * scanHtml, analyse, audit {latest, summary, run}, redirects {list, match, add}).
18
+ * A lapsed licence means the extension never loads and SEO carries on alone.
19
+ */
20
+ export const KEY = Symbol.for('domma.seo');
21
+ export const VERSION = 1;
22
+
23
+ export function seoRegistry() {
24
+ if (globalThis[KEY]?.version === VERSION) return globalThis[KEY];
25
+ const extensions = new Map();
26
+ const listeners = new Map();
27
+ const reg = {
28
+ version: VERSION,
29
+ services: {},
30
+ register(owner, ext = {}) {
31
+ if (!/^[a-z0-9-]{2,60}$/.test(String(owner))) throw new Error('An extension needs its plugin name.');
32
+ extensions.set(owner, {
33
+ owner,
34
+ label: String(ext.label || owner),
35
+ tabs: (Array.isArray(ext.tabs) ? ext.tabs : []).filter(t => t && /^[a-z0-9-]+$/.test(t.key) && typeof t.module === 'string' && t.module.startsWith('/plugins/')),
36
+ order: Number(ext.order) || 0
37
+ });
38
+ },
39
+ unregister(owner) {
40
+ extensions.delete(owner);
41
+ for (const set of listeners.values()) for (const l of [...set]) if (l.owner === owner) set.delete(l);
42
+ },
43
+ list() { return [...extensions.values()].sort((a, b) => a.order - b.order); },
44
+ on(event, fn, owner = '') {
45
+ if (typeof fn !== 'function') return;
46
+ if (!listeners.has(event)) listeners.set(event, new Set());
47
+ listeners.get(event).add({fn, owner});
48
+ },
49
+ emit(event, payload) {
50
+ for (const {fn} of listeners.get(event) || []) {
51
+ try { Promise.resolve(fn(payload)).catch(() => {}); } catch { /* an extension never breaks SEO */ }
52
+ }
53
+ }
54
+ };
55
+ globalThis[KEY] = reg;
56
+ return reg;
57
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * SEO - the plugin's own data, in plugins/seo/data (kept by updates, never
3
+ * bundled). Held in memory - the redirect guard and the 'seo:meta' transform
4
+ * run on every request and must not touch the disk - and written back a moment
5
+ * after each change. A file changed on disk (a restore, another tool) is read
6
+ * again within a few seconds.
7
+ *
8
+ * redirects.json {rules: [...]} see admin/lib/redirects.js
9
+ * overrides.json {"/path": {title, description, image, noindex, focus}}
10
+ * audit.json {at, origin, results: [...]} the last audit
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ function jsonFile(file, empty) {
16
+ let value = read();
17
+ let mtime = stat();
18
+ let timer = null;
19
+ let checked = Date.now();
20
+
21
+ function stat() { try { return fs.statSync(file).mtimeMs; } catch { return 0; } }
22
+ function read() {
23
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return structuredClone(empty); }
24
+ }
25
+ function flush() {
26
+ timer = null;
27
+ try {
28
+ fs.mkdirSync(path.dirname(file), {recursive: true});
29
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(value, null, 1));
30
+ fs.renameSync(`${file}.tmp`, file);
31
+ mtime = stat();
32
+ } catch { /* tried again with the next change */ }
33
+ }
34
+ return {
35
+ get() {
36
+ const now = Date.now();
37
+ if (!timer && now - checked > 3000) {
38
+ checked = now;
39
+ const m = stat();
40
+ if (m && m !== mtime) { value = read(); mtime = m; }
41
+ }
42
+ return value;
43
+ },
44
+ set(next, {soon = 500} = {}) {
45
+ value = next;
46
+ if (timer) clearTimeout(timer);
47
+ timer = setTimeout(flush, soon);
48
+ timer.unref?.();
49
+ return value;
50
+ },
51
+ flush() { if (timer) { clearTimeout(timer); flush(); } }
52
+ };
53
+ }
54
+
55
+ export function createStore(dataDir) {
56
+ const redirects = jsonFile(path.join(dataDir, 'redirects.json'), {rules: []});
57
+ const overrides = jsonFile(path.join(dataDir, 'overrides.json'), {});
58
+ const audit = jsonFile(path.join(dataDir, 'audit.json'), {at: null, origin: '', results: []});
59
+ return {
60
+ rules: () => (Array.isArray(redirects.get().rules) ? redirects.get().rules : []),
61
+ setRules: (rules, opts) => redirects.set({rules}, opts),
62
+ overrides: () => overrides.get() || {},
63
+ override: (urlPath) => overrides.get()?.[urlPath] || null,
64
+ setOverride(urlPath, fields) {
65
+ const all = {...overrides.get()};
66
+ const clean = Object.fromEntries(Object.entries(fields || {}).filter(([, v]) => v !== '' && v !== null && v !== undefined && v !== false));
67
+ if (Object.keys(clean).length) all[urlPath] = clean;
68
+ else delete all[urlPath];
69
+ overrides.set(all);
70
+ return all[urlPath] || null;
71
+ },
72
+ audit: () => audit.get(),
73
+ setAudit: (a) => audit.set(a, {soon: 100}),
74
+ close() { redirects.flush(); overrides.flush(); audit.flush(); }
75
+ };
76
+ }
@@ -0,0 +1,192 @@
1
+ /** SEO - the pure parts: redirects, reading and judging a page, settings, structured data, robots. */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {chains, checkRule, matchRedirect, normalisePath, onMoved} from '../admin/lib/redirects.js';
5
+ import {analyse, attrs, decode, scanHtml, siteIssues, snippet} from '../admin/lib/analyse.js';
6
+ import {checkSettings, DEFAULT_SETTINGS, excluded, mergeDefaults} from '../admin/lib/settings.js';
7
+ import {applySeo, organisationLd, robotsLines} from '../admin/lib/schema.js';
8
+
9
+ const rule = (from, to, code = 301, id = from) => ({id, from, to, code});
10
+
11
+ test('paths: trailing slashes, case, doubled slashes and queries do not matter', () => {
12
+ assert.equal(normalisePath('/About/'), '/about');
13
+ assert.equal(normalisePath('about'), '/about');
14
+ assert.equal(normalisePath('//a///b/?x=1#y'), '/a/b');
15
+ assert.equal(normalisePath('/'), '/');
16
+ assert.equal(normalisePath(''), '');
17
+ assert.equal(normalisePath('/caf%C3%A9'), '/café');
18
+ });
19
+
20
+ test('redirects: exact before prefix, the longest prefix wins, the query travels', () => {
21
+ const rules = [rule('/old', '/new'), rule('/news/*', '/blog/*'), rule('/news/2019/*', '/archive', 302), rule('/gone', '', 410)];
22
+ assert.deepEqual(matchRedirect(rules, '/OLD/?utm=x').location, '/new?utm=x');
23
+ assert.equal(matchRedirect(rules, '/news/hello').location, '/blog/hello');
24
+ assert.equal(matchRedirect(rules, '/news').location, '/blog', 'the prefix itself goes to the target root');
25
+ assert.equal(matchRedirect(rules, '/news/2019/x').location, '/archive');
26
+ assert.equal(matchRedirect(rules, '/news/2019/x').code, 302);
27
+ assert.equal(matchRedirect(rules, '/gone').code, 410);
28
+ assert.equal(matchRedirect(rules, '/newsletter'), null, 'a prefix matches whole segments');
29
+ assert.equal(matchRedirect([rule('/a', '/b?ref=1')], '/a?x=2').location, '/b?ref=1', 'a target with its own query keeps it');
30
+ assert.equal(matchRedirect([rule('/a', 'https://ex.com/b')], '/a').location, 'https://ex.com/b');
31
+ });
32
+
33
+ test('redirect rules are checked: self, loops, duplicates, the admin, bad targets', () => {
34
+ const ok = checkRule({from: '/Old-Page/', to: '/new-page'});
35
+ assert.deepEqual(ok.errors, {});
36
+ assert.equal(ok.rule.from, '/old-page');
37
+ assert.equal(checkRule({from: '/a', to: '/a'}).errors.to, 'It would redirect to itself.');
38
+ assert.match(checkRule({from: '/b', to: '/a'}, [rule('/a', '/b')]).errors.to, /loop/);
39
+ assert.match(checkRule({from: '/c', to: '/a'}, [rule('/a', '/b'), rule('/b', '/c')]).errors.to, /loop/, 'a longer loop too');
40
+ assert.match(checkRule({from: '/a', to: '/x'}, [rule('/a', '/b')]).errors.from, /already/);
41
+ assert.ok(checkRule({from: '/admin/x', to: '/y'}).errors.from);
42
+ assert.ok(checkRule({from: '/*', to: '/y'}).errors.from);
43
+ assert.ok(checkRule({from: '/a', to: 'javascript:alert(1)'}).errors.to);
44
+ assert.ok(checkRule({from: '/a', to: '//evil.example'}).errors.to);
45
+ assert.ok(checkRule({from: '/a', to: '/b/*'}).errors.to, 'a * needs a prefix rule');
46
+ assert.deepEqual(checkRule({from: '/a/*', to: '/b/*'}).errors, {});
47
+ assert.deepEqual(checkRule({from: '/a', code: 410}).errors, {}, 'gone needs no target');
48
+ assert.ok(checkRule({from: '/a', to: '/b', code: 303}).errors.code);
49
+ });
50
+
51
+ test('a moved page: a redirect is added, older ones re-aimed, none from the new address', () => {
52
+ let rules = [rule('/older', '/old', 301, 'r1'), rule('/new', '/elsewhere', 301, 'r2')];
53
+ rules = onMoved(rules, '/old', '/new', {id: 'r3', now: 'T'});
54
+ assert.deepEqual(rules.map(r => [r.from, r.to]), [['/older', '/new'], ['/old', '/new']]);
55
+ assert.equal(rules.at(-1).auto, true);
56
+ assert.deepEqual(chains(rules), [], 'no chains left');
57
+ assert.deepEqual(chains([rule('/a', '/b', 301, 'a'), rule('/b', '/c', 301, 'b')]).map(c => [c.rule.id, c.via.id]), [['a', 'b']]);
58
+ assert.equal(onMoved(rules, '/x', '/x'), rules, 'no move, no change');
59
+ });
60
+
61
+ const PAGE = `<!doctype html><html lang="en"><head>
62
+ <title>Garden tools &amp; seeds | Shop</title>
63
+ <meta name="description" content="Everything for the garden: garden tools, seeds and advice from people who grow things for a living.">
64
+ <link rel="canonical" href="https://ex.com/garden-tools">
65
+ <meta property="og:image" content="https://ex.com/a.jpg">
66
+ <script type="application/ld+json">{"@type":"WebPage"}</script>
67
+ <script type="application/ld+json">{"@graph":[{"@type":"BreadcrumbList"}]}</script>
68
+ </head><body>
69
+ <!-- <h1>not this</h1> -->
70
+ <script>document.write('<h1>nor this</h1>')</script>
71
+ <h1>Garden tools</h1><p>Our garden tools are made to last. Choose from forks, spades and trowels.</p>
72
+ <h2>Forks</h2><h4>Skipped a level</h4>
73
+ <img src="/a.jpg" alt="A fork"><img src="/b.jpg"><img src="/c.jpg" alt=""><img src="/d.jpg" role="presentation">
74
+ <a href="/spades">Spades</a> <a href="https://other.example/">Other</a> <a href="#top">Top</a>
75
+ </body></html>`;
76
+
77
+ test('scanHtml: what a crawler sees, ignoring scripts and comments', () => {
78
+ const f = scanHtml(PAGE);
79
+ assert.equal(f.title, 'Garden tools & seeds | Shop');
80
+ assert.match(f.description, /^Everything for the garden/);
81
+ assert.equal(f.canonical, 'https://ex.com/garden-tools');
82
+ assert.equal(f.lang, 'en');
83
+ assert.deepEqual(f.headings.map(h => `${h.level}:${h.text}`), ['1:Garden tools', '2:Forks', '4:Skipped a level']);
84
+ assert.deepEqual(f.images.map(i => i.alt), ['A fork', null, '', null]);
85
+ assert.equal(f.links.length, 3);
86
+ assert.deepEqual(f.jsonLdTypes, ['WebPage', 'BreadcrumbList']);
87
+ assert.ok(f.words > 10);
88
+ assert.match(f.firstPara, /made to last/);
89
+ assert.deepEqual(attrs('<meta name=x content=\'a "b"\' async>'), {name: 'x', content: 'a "b"', async: ''});
90
+ assert.equal(decode('&lt;a&gt; &#39;x&#x27; &pound;&nbsp;'), '<a> \'x\' £ ');
91
+ });
92
+
93
+ test('analyse: the checks, a focus keyphrase, and the score', () => {
94
+ const f = scanHtml(PAGE);
95
+ const r = analyse(f, {urlPath: '/garden-tools', focus: 'garden tools', origin: 'https://ex.com'});
96
+ const by = Object.fromEntries(r.checks.map(c => [c.id, c.status]));
97
+ assert.equal(by.title, 'warn', '27 characters is short');
98
+ assert.equal(by.h1, 'pass');
99
+ assert.equal(by.headings, 'warn', 'H2 -> H4 is a skip');
100
+ assert.equal(by.alt, 'fail', 'one image has no alt; decoration and alt="" are fine');
101
+ assert.equal(by.links, 'pass');
102
+ assert.equal(by.focus, 'pass');
103
+ assert.equal(by.canonical, 'pass');
104
+ assert.equal(by.schema, 'pass');
105
+ assert.equal(by.words, 'warn', 'a few dozen words');
106
+ assert.ok(r.score > 0 && r.score < 100);
107
+ assert.equal(r.failing, 1);
108
+
109
+ const bare = analyse(scanHtml('<html><head></head><body><p>hi</p></body></html>'), {urlPath: '/x'});
110
+ const b = Object.fromEntries(bare.checks.map(c => [c.id, c.status]));
111
+ assert.equal(b.title, 'fail');
112
+ assert.equal(b.description, 'fail');
113
+ assert.equal(b.h1, 'fail');
114
+ assert.equal(b.lang, 'warn');
115
+ assert.equal(b.focus, 'info');
116
+
117
+ const missing = analyse(f, {urlPath: '/garden-tools', focus: 'lawn mower'});
118
+ assert.equal(missing.checks.find(c => c.id === 'focus').status, 'fail');
119
+ assert.equal(analyse(scanHtml('<html><head><meta name="robots" content="noindex"></head></html>')).checks.find(c => c.id === 'index').status, 'info');
120
+ assert.equal(analyse(scanHtml('<script type="application/ld+json">{bad</script>')).checks.find(c => c.id === 'schema').status, 'fail');
121
+ });
122
+
123
+ test('siteIssues: duplicate titles and descriptions, hidden pages left out', () => {
124
+ const r = siteIssues([
125
+ {urlPath: '/a', title: 'Home', description: 'Same'},
126
+ {urlPath: '/b', title: 'home', description: 'Same'},
127
+ {urlPath: '/c', title: 'Home', noindex: true},
128
+ {urlPath: '/d', title: 'Other', description: ''}
129
+ ]);
130
+ assert.deepEqual(r.duplicateTitles.map(d => d.paths), [['/a', '/b']]);
131
+ assert.deepEqual(r.duplicateDescriptions.map(d => d.paths), [['/a', '/b']]);
132
+ });
133
+
134
+ test('snippet: cut where search results cut, crumbs from the address', () => {
135
+ const s = snippet({title: 'x'.repeat(40) + ' ' + 'y'.repeat(30), description: 'short', url: 'https://ex.com/a/b'});
136
+ assert.equal(s.titleCut, true);
137
+ assert.ok(s.title.endsWith('…'));
138
+ assert.equal(s.crumbs, 'ex.com › a › b');
139
+ assert.equal(s.descriptionCut, false);
140
+ });
141
+
142
+ test('settings: checked, merged, and a missing organisation name refused', () => {
143
+ const {errors, settings} = checkSettings({audit: {titleMax: 20}, sitemap: {exclude: 'thanks\n/private/'}, organisation: {enabled: true}});
144
+ assert.ok(errors['audit.titleMax']);
145
+ assert.ok(errors['sitemap.exclude']);
146
+ assert.ok(errors['organisation.name']);
147
+ assert.equal(settings.audit.titleMax, DEFAULT_SETTINGS.audit.titleMax);
148
+ const ok = checkSettings({sitemap: {exclude: '/private/\n/thanks', sources: {blog: false}}, robots: {extra: 'Disallow: /x/\n# note'}, organisation: {sameAs: 'https://x.com/a'}});
149
+ assert.deepEqual(ok.errors, {});
150
+ assert.deepEqual(ok.settings.sitemap.exclude, ['/private/', '/thanks']);
151
+ assert.equal(ok.settings.sitemap.sources.blog, false);
152
+ assert.ok(checkSettings({robots: {extra: 'nonsense line'}}).errors['robots.extra']);
153
+ assert.deepEqual(mergeDefaults({audit: {everyHours: 6}}).audit.titleMax, DEFAULT_SETTINGS.audit.titleMax);
154
+ assert.equal(excluded('/private/x', ['/private/']), true);
155
+ assert.equal(excluded('/private', ['/private/']), true);
156
+ assert.equal(excluded('/privateer', ['/private/']), false);
157
+ assert.equal(excluded('/thanks', ['/thanks']), true);
158
+ assert.equal(excluded('/thanks/more', ['/thanks']), false);
159
+ });
160
+
161
+ test('structured data: the organisation on the home page, BlogPosting on posts, overrides applied', () => {
162
+ const settings = mergeDefaults({organisation: {enabled: true, type: 'LocalBusiness', name: 'Acme', logo: '/logo.png', address: {locality: 'Leeds'}}});
163
+ assert.equal(organisationLd({enabled: false}), null);
164
+ const ld = organisationLd(settings.organisation, {origin: 'https://ex.com'});
165
+ assert.equal(ld.logo, 'https://ex.com/logo.png');
166
+ assert.equal(ld.address.addressLocality, 'Leeds');
167
+
168
+ const base = {title: 'T', description: 'D', image: '', ogType: 'website', noindex: false, canonical: 'https://ex.com/', og: {}, twitter: {}, jsonLd: [{'@type': 'WebPage'}], extra: []};
169
+ const home = applySeo(base, {page: {urlPath: '/'}, kind: 'page', origin: 'https://ex.com', settings});
170
+ assert.deepEqual(home.jsonLd.map(x => x['@type']), ['WebPage', 'LocalBusiness']);
171
+ assert.deepEqual(base.jsonLd.length, 1, 'core\'s model is not changed in place');
172
+
173
+ const post = applySeo({...base, canonical: 'https://ex.com/blog/p'}, {page: {urlPath: '/blog/p', item: {type: 'blog:post'}}, kind: 'plugin', origin: 'https://ex.com', settings,
174
+ override: {title: 'Better', description: 'Better D', image: '/p.jpg', noindex: true}});
175
+ assert.equal(post.title, 'Better');
176
+ assert.equal(post.image, 'https://ex.com/p.jpg');
177
+ assert.equal(post.noindex, true);
178
+ assert.equal(post.ogType, 'article');
179
+ const bp = post.jsonLd.find(x => x['@type'] === 'BlogPosting');
180
+ assert.equal(bp.headline, 'Better');
181
+ assert.equal(bp.publisher.name, 'Acme');
182
+ assert.equal(post.jsonLd[0].description, 'Better D');
183
+ });
184
+
185
+ test('robots: extra rules join the * group; their own groups go before Sitemap; block all', () => {
186
+ const core = ['User-agent: *', 'Allow: /', 'Disallow: /admin/', 'Disallow: /api/', '', 'Sitemap: https://ex.com/sitemap.xml'];
187
+ assert.deepEqual(robotsLines(core, {extra: 'Disallow: /tmp/'}), ['User-agent: *', 'Allow: /', 'Disallow: /admin/', 'Disallow: /api/', 'Disallow: /tmp/', '', 'Sitemap: https://ex.com/sitemap.xml']);
188
+ const g = robotsLines(core, {extra: 'User-agent: GPTBot\nDisallow: /'});
189
+ assert.deepEqual(g.slice(5), ['User-agent: GPTBot', 'Disallow: /', '', 'Sitemap: https://ex.com/sitemap.xml']);
190
+ assert.deepEqual(robotsLines(core, {blockAll: true}).slice(1), ['User-agent: *', 'Disallow: /']);
191
+ assert.equal(robotsLines(core, {}), core);
192
+ });
@@ -101,13 +101,14 @@ export async function publicRoutes(fastify) {
101
101
 
102
102
  // SEO: sitemap.xml - cached per origin (so a single CMS responding on
103
103
  // multiple domains gets the right absolute URLs); invalidated by page
104
- // CRUD via the 'sitemap' tag.
104
+ // CRUD via the 'sitemap' tag, and by plugins (hooks.sitemapChanged). The
105
+ // ten-minute ceiling covers a plugin whose content changes without telling.
105
106
  fastify.get('/sitemap.xml', async (request, reply) => {
106
107
  const baseUrl = getBaseUrl(request);
107
108
  const xml = await cache.wrap(
108
109
  `sitemap:xml:${baseUrl}`,
109
- () => generateSitemap(baseUrl),
110
- {tags: ['sitemap']}
110
+ () => generateSitemap(baseUrl, {log: fastify.log}),
111
+ {tags: ['sitemap'], ttlSeconds: 600}
111
112
  );
112
113
  return reply.type('application/xml').send(xml);
113
114
  });
@@ -198,6 +198,15 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
198
198
  // asking for the page's tags to be emptied.
199
199
  if ('tags' in meta) meta.tags = cleanTags(meta.tags);
200
200
 
201
+ // `seo` is merged, not replaced: the page editor sends only title and
202
+ // description, and a save from it must not wipe what the SEO Tool set
203
+ // (image, noindex, focus keyword...). A key sent as null is removed.
204
+ if (frontmatter?.seo && typeof frontmatter.seo === 'object' && !Array.isArray(frontmatter.seo)) {
205
+ const merged = {...(existingMeta.seo && typeof existingMeta.seo === 'object' ? existingMeta.seo : {}), ...frontmatter.seo};
206
+ for (const [k, v] of Object.entries(merged)) if (v === null) delete merged[k];
207
+ meta.seo = merged;
208
+ }
209
+
201
210
  if (existingMeta.createdAt) meta.createdAt = existingMeta.createdAt;
202
211
 
203
212
  // '' is the editor's "inherit by rootUrl" choice - drop the key so the
@@ -237,6 +246,8 @@ export async function renamePage(oldUrlPath, newUrlPath) {
237
246
  } catch {
238
247
  // Version directory move failure is non-fatal
239
248
  }
249
+ // The old URL may be linked from anywhere - the SEO plugin offers a redirect.
250
+ hooks.emit('content:pageRenamed', {from: oldUrlPath, to: newUrlPath});
240
251
  }
241
252
 
242
253
  /**
@@ -152,7 +152,8 @@ const _transforms = new Map(); // hookName -> [{ fn, priority }]
152
152
  /**
153
153
  * Register a transform function at a named hook point.
154
154
  * Available hook names: 'markdown:beforeParse', 'markdown:afterParse',
155
- * 'render:beforeRender', 'render:afterRender'
155
+ * 'render:beforeRender', 'render:afterRender',
156
+ * 'seo:meta', 'sitemap:entries', 'robots:lines' (0.89 - section 8)
156
157
  *
157
158
  * @param {string} hookName
158
159
  * @param {Function} fn - (value, context) => value
@@ -305,3 +306,46 @@ export function unregisterRelatedItems(kind, id) {
305
306
  export function getRelatedProviders(kind) {
306
307
  return [...(_relatedProviders.get(kind)?.values() ?? [])];
307
308
  }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // 8. Sitemap sources (0.89) - URLs a plugin serves, in /sitemap.xml
312
+ // ---------------------------------------------------------------------------
313
+ //
314
+ // Core lists its own pages. Anything a plugin serves (blog posts, job listings)
315
+ // is invisible to it, so the plugin registers a source. `list({baseUrl})`
316
+ // resolves to `[{urlPath, title?, lastmod?, changefreq?, priority?, image?}]`,
317
+ // public items only - the sitemap is for anonymous visitors. A source that
318
+ // throws is skipped (and logged) rather than taking the sitemap down.
319
+ //
320
+ // Transforms beside it (registerTransform):
321
+ // 'sitemap:entries' (entries, {baseUrl}) - the merged list, before XML
322
+ // 'robots:lines' (lines, {baseUrl}) - robots.txt, one entry per line
323
+ // 'seo:meta' (meta, {page, site, baseUrl, kind}) - see renderer.js
324
+
325
+ const _sitemapSources = new Map(); // id -> {id, label, owner, list}
326
+
327
+ /**
328
+ * @param {{id: string, label?: string, list: Function, owner?: string}} source
329
+ * @returns {boolean} false when the source is malformed (it is then ignored)
330
+ */
331
+ export function registerSitemapSource(source = {}) {
332
+ const {id, list} = source;
333
+ if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9:-]*$/.test(id) || typeof list !== 'function') return false;
334
+ _sitemapSources.set(id, {
335
+ id,
336
+ label: typeof source.label === 'string' && source.label.trim() ? source.label.trim() : id,
337
+ owner: typeof source.owner === 'string' ? source.owner : 'core',
338
+ list
339
+ });
340
+ return true;
341
+ }
342
+
343
+ /** @param {string} id */
344
+ export function unregisterSitemapSource(id) {
345
+ _sitemapSources.delete(id);
346
+ }
347
+
348
+ /** Every registered sitemap source, in registration order. */
349
+ export function getSitemapSources() {
350
+ return [..._sitemapSources.values()];
351
+ }
@@ -20,6 +20,7 @@ import {
20
20
  hooks,
21
21
  registerCalendarSource,
22
22
  registerRelatedItems,
23
+ registerSitemapSource,
23
24
  registerMenuLocation,
24
25
  registerSanitizeRules,
25
26
  registerShortcode,
@@ -38,6 +39,7 @@ import {authenticateFor} from './pluginScope.js';
38
39
  import {getEffectiveRoles} from './userRoles.js';
39
40
  import * as defaultUsersService from './users.js';
40
41
  import {registerLoginCheck, registerPasswordCheck} from './authChecks.js';
42
+ import {invalidateTags as cacheInvalidateTags} from './cache/index.js';
41
43
  import {CORE_TOOLS, getToolStates, unmetRequirements} from './tools.js';
42
44
 
43
45
  const PLUGINS_DIR = path.resolve('plugins');
@@ -508,6 +510,10 @@ export async function registerPlugins(fastify) {
508
510
  registerCalendarSource: (source) => registerCalendarSource({...source, owner: manifest.name}),
509
511
  getCalendarSources,
510
512
  registerRelatedItems: (kind, provider) => registerRelatedItems(kind, {...provider, owner: manifest.name}),
513
+ // Sitemap sources (0.89) - the URLs a plugin serves, in /sitemap.xml. A plugin
514
+ // calls sitemapChanged() when they change; otherwise they show within ten minutes.
515
+ registerSitemapSource: (source) => registerSitemapSource({...source, owner: manifest.name}),
516
+ sitemapChanged: () => cacheInvalidateTags(['sitemap']).catch(() => {}),
511
517
  on: hooks.on.bind(hooks), ...pluginRoleHooks(manifest.name),
512
518
  // Notifications (0.80) - notify, resolveNotification, registerNotificationSource,
513
519
  // stamped with the plugin's name (its source ids are `<plugin>:<id>`).