domma-cms 0.89.0 → 0.90.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.
Files changed (41) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/page-editor-arrange.js +1 -1
  5. package/admin/js/lib/page-editor-reference.js +153 -0
  6. package/admin/js/lib/seo-analyse.js +1 -0
  7. package/admin/js/lib/seo-redirects.js +1 -0
  8. package/admin/js/lib/seo-schema.js +2 -0
  9. package/admin/js/lib/seo-settings.js +2 -0
  10. package/admin/js/templates/page-editor.html +119 -80
  11. package/{plugins/seo/admin → admin/js}/templates/seo.html +1 -2
  12. package/admin/js/views/index.js +1 -1
  13. package/admin/js/views/page-editor.js +62 -90
  14. package/admin/js/views/seo.js +144 -0
  15. package/package.json +2 -2
  16. package/plugins/free-tier.lock.json +0 -19
  17. package/server/routes/api/pages.js +10 -13
  18. package/server/routes/api/seo.js +201 -0
  19. package/server/server.js +12 -1
  20. package/server/services/menus.js +43 -0
  21. package/server/services/permissionRegistry.js +11 -0
  22. package/server/services/plugins.js +12 -2
  23. package/{plugins/seo/server → server/services/seo}/audit.js +2 -2
  24. package/server/services/seo/index.js +345 -0
  25. package/{plugins/seo/server → server/services/seo}/registry.js +5 -3
  26. package/server/services/seo/store.js +110 -0
  27. package/server/services/siteGitignore.js +4 -1
  28. package/server/services/tools.js +2 -1
  29. package/admin/js/templates/navigation.html +0 -226
  30. package/admin/js/views/navigation.js +0 -23
  31. package/plugins/seo/CLAUDE.md +0 -48
  32. package/plugins/seo/admin/lib/analyse.js +0 -286
  33. package/plugins/seo/admin/lib/redirects.js +0 -163
  34. package/plugins/seo/admin/lib/schema.js +0 -113
  35. package/plugins/seo/admin/lib/settings.js +0 -141
  36. package/plugins/seo/admin/views/seo.js +0 -912
  37. package/plugins/seo/config.js +0 -7
  38. package/plugins/seo/plugin.js +0 -477
  39. package/plugins/seo/plugin.json +0 -64
  40. package/plugins/seo/server/store.js +0 -76
  41. package/plugins/seo/tests/seo.test.js +0 -192
@@ -1,7 +0,0 @@
1
- /**
2
- * SEO - defaults. The site's own choices are saved from the settings
3
- * slideover into config/plugins.json (see admin/lib/settings.js).
4
- */
5
- import {DEFAULT_SETTINGS} from './admin/lib/settings.js';
6
-
7
- export default DEFAULT_SETTINGS;
@@ -1,477 +0,0 @@
1
- /**
2
- * SEO (free) - how a Domma CMS site looks to search engines, and what to fix.
3
- *
4
- * - Audit: every URL in the sitemap fetched in-process and judged (title,
5
- * description, headings, image descriptions, links, focus keyphrase,
6
- * canonical, share image, structured data), plus titles and descriptions
7
- * used on more than one page. Runs every `audit.everyHours`, or on demand.
8
- * - An editor per URL: the search result and share card as they will look,
9
- * the checks, and the fields. Core pages are edited in place (page.seo);
10
- * anything a plugin serves (a blog post) gets an override kept here.
11
- * - Redirects (301/302/307/308, 410 gone; "/old/*" prefixes), added for you
12
- * when a page is renamed, re-aimed so they never chain.
13
- * - The sitemap (core 0.89 sources) and robots.txt, shaped by the settings;
14
- * Organization / LocalBusiness JSON-LD on the home page, BlogPosting on posts.
15
- *
16
- * Needs core 0.89 (seo:meta, sitemap sources); on an older engine the audit,
17
- * redirects and editor work and the rest says it is waiting for an update.
18
- *
19
- * Global (fastify-plugin): the redirect guard sees every request.
20
- *
21
- * Routes (/api/plugins/seo; the `seo` permission: read / manage):
22
- * GET /overview · GET /count · GET /audit · POST /audit · GET|PUT /item
23
- * GET|POST /redirects · PUT|DELETE /redirects/:id · POST /redirects/import
24
- * GET /redirects/test · GET /sitemap · GET|PUT /settings · GET /extensions
25
- */
26
- import path from 'node:path';
27
- import {fileURLToPath} from 'node:url';
28
- import fp from 'fastify-plugin';
29
- import defaultConfig from './config.js';
30
- import {createStore} from './server/store.js';
31
- import {auditAll, auditOne, AUDIT_UA, fetchLocal, hostOf} from './server/audit.js';
32
- import {analyse, scanHtml, siteIssues} from './admin/lib/analyse.js';
33
- import {chains, checkRule, matchRedirect, MAX_RULES, normalisePath, onMoved} from './admin/lib/redirects.js';
34
- import {applySeo, robotsLines} from './admin/lib/schema.js';
35
- import {checkSettings, excluded, mergeDefaults} from './admin/lib/settings.js';
36
- import {seoRegistry} from './server/registry.js';
37
- import {getConfig, saveConfig} from '../../server/config.js';
38
- import {getPage, listPages, updatePage} from '../../server/services/content.js';
39
-
40
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
41
- const NAME = 'seo';
42
- const SKIP = /^\/(api|admin)(\/|$)/;
43
-
44
- async function seo(fastify, options = {}) {
45
- let cfg = mergeDefaults({...defaultConfig, ...(options.settings ?? {})});
46
- const hooks = options.hooks ?? {};
47
- const log = fastify.log;
48
- const store = createStore(path.join(__dirname, 'data'));
49
- const hasCore = typeof hooks.registerSitemapSource === 'function';
50
- const sitemap = hasCore ? await import('../../server/services/sitemap.js').catch(() => null) : null;
51
- const {getBaseUrl} = await import('../../server/routes/public.js').catch(() => ({}));
52
- const coreHooks = await import('../../server/services/hooks.js').catch(() => ({}));
53
- const sourcesNow = () => { try { return coreHooks.getSitemapSources?.() || []; } catch { return []; } };
54
- const site = () => getConfig('site') || {};
55
- const originOf = (request) => {
56
- try { return String(getBaseUrl?.(request) || site().baseUrl || '').replace(/\/+$/, ''); } catch { return ''; }
57
- };
58
-
59
- // ---------------------------------------------------------------------
60
- // 1. Redirects - ahead of everything else
61
- // ---------------------------------------------------------------------
62
- let hitsDirty = false;
63
- fastify.addHook('onRequest', async (request, reply) => {
64
- try {
65
- if (request.method !== 'GET' && request.method !== 'HEAD') return;
66
- const url = String(request.url || '');
67
- if (SKIP.test(url)) return;
68
- const rules = store.rules();
69
- if (!rules.length) return;
70
- const hit = matchRedirect(rules, url);
71
- if (!hit) return;
72
- if (request.headers['x-domma-seo'] !== 'audit') {
73
- hit.rule.hits = (hit.rule.hits || 0) + 1;
74
- hit.rule.lastHit = new Date().toISOString();
75
- hitsDirty = true;
76
- }
77
- if (hit.code === 410) {
78
- reply.code(410).header('content-type', 'text/plain; charset=utf-8').send('Gone');
79
- return reply;
80
- }
81
- reply.code(hit.code).header('location', hit.location).header('cache-control', hit.code === 301 || hit.code === 308 ? 'public, max-age=3600' : 'no-store').send();
82
- return reply;
83
- } catch (err) {
84
- log.warn(`[seo] redirect skipped: ${err.message}`);
85
- }
86
- });
87
- const saveHits = setInterval(() => { if (hitsDirty) { hitsDirty = false; store.setRules(store.rules(), {soon: 0}); } }, 60_000);
88
- saveHits.unref?.();
89
-
90
- hooks.on?.('content:pageRenamed', ({from, to} = {}) => {
91
- if (!cfg.redirects.onRename || !from || !to) return;
92
- try {
93
- store.setRules(onMoved(store.rules(), from, to, {id: newId()}));
94
- log.info(`[seo] ${from} moved to ${to}: redirect added.`);
95
- } catch (err) {
96
- log.warn(`[seo] no redirect for the move of ${from}: ${err.message}`);
97
- }
98
- });
99
-
100
- // ---------------------------------------------------------------------
101
- // 2. What search engines are given (core 0.89 transforms)
102
- // ---------------------------------------------------------------------
103
- if (hasCore && typeof hooks.registerTransform === 'function') {
104
- hooks.registerTransform('seo:meta', (meta, ctx) => {
105
- try {
106
- const origin = String(ctx?.baseUrl || '').replace(/\/+$/, '');
107
- return applySeo(meta, {page: ctx?.page, kind: ctx?.kind, origin, siteTitle: ctx?.site?.title || '', settings: cfg,
108
- override: store.override(normalisePath(ctx?.page?.urlPath || '/') || '/')});
109
- } catch (err) {
110
- log.warn(`[seo] page tags left as they were: ${err.message}`);
111
- return meta;
112
- }
113
- });
114
- hooks.registerTransform('sitemap:entries', (entries) => {
115
- const rules = store.rules();
116
- const over = store.overrides();
117
- return entries.filter(e => !excluded(e.urlPath, cfg.sitemap.exclude)
118
- && cfg.sitemap.sources[e.source] !== false
119
- && over[normalisePath(e.urlPath)]?.noindex !== true
120
- && !(rules.length && matchRedirect(rules, e.urlPath)));
121
- });
122
- hooks.registerTransform('robots:lines', (lines, ctx) => robotsLines(lines, cfg.robots, ctx?.sitemapUrl));
123
- } else {
124
- log.warn('[seo] this engine has no SEO hooks (needs Domma CMS 0.89+): the sitemap, robots.txt and page tags are core\'s own until it is updated.');
125
- }
126
- const sitemapChanged = () => { try { hooks.sitemapChanged?.(); } catch { /* next rebuild */ } };
127
-
128
- // ---------------------------------------------------------------------
129
- // 3. The audit
130
- // ---------------------------------------------------------------------
131
- let running = null;
132
- let progress = {done: 0, total: 0};
133
-
134
- /** What to audit: the sitemap, plus published pages hidden from search engines (so they show as hidden). */
135
- async function auditList(origin) {
136
- const list = [];
137
- const seen = new Set();
138
- if (sitemap?.listSitemapEntries) {
139
- for (const e of await sitemap.listSitemapEntries(origin, {log})) {
140
- seen.add(e.urlPath);
141
- list.push({urlPath: e.urlPath, source: e.source, title: e.title});
142
- }
143
- }
144
- for (const p of await listPages()) {
145
- if (seen.has(p.urlPath) || p.status !== 'published' || (p.visibility || 'public') !== 'public' || p.urlPath === '/404') continue;
146
- list.push({urlPath: p.urlPath, source: 'pages', title: p.title, noindex: p.seo?.noindex === true, ...(sitemap && {unlisted: true})});
147
- }
148
- return list;
149
- }
150
- async function focusMap() {
151
- const map = {};
152
- for (const p of await listPages()) if (p.seo?.focus) map[p.urlPath] = p.seo.focus;
153
- for (const [p, o] of Object.entries(store.overrides())) if (o.focus) map[p] = o.focus;
154
- return map;
155
- }
156
- async function runAudit(origin) {
157
- if (running) return running;
158
- running = (async () => {
159
- const started = Date.now();
160
- try {
161
- const o = origin || store.audit().origin || String(site().baseUrl || '').replace(/\/+$/, '');
162
- const [list, focus] = await Promise.all([auditList(o), focusMap()]);
163
- progress = {done: 0, total: list.length};
164
- const results = await auditAll(fastify, list, {origin: o, focusOf: (p) => focus[p] || '', limits: cfg.audit,
165
- onProgress: (done, total) => { progress = {done, total}; }});
166
- store.setAudit({at: new Date().toISOString(), ms: Date.now() - started, origin: o, results});
167
- registry.emit('audit', store.audit());
168
- return store.audit();
169
- } catch (err) {
170
- log.warn(`[seo] audit failed: ${err.message}`);
171
- return store.audit();
172
- } finally {
173
- running = null;
174
- }
175
- })();
176
- return running;
177
- }
178
- function summary(a = store.audit()) {
179
- const rows = a.results || [];
180
- const judged = rows.filter(r => typeof r.score === 'number' && !r.noindex);
181
- const issues = siteIssues(rows);
182
- return {
183
- at: a.at, ms: a.ms || 0, origin: a.origin || '', running: Boolean(running), progress,
184
- pages: rows.length,
185
- score: judged.length ? Math.round(judged.reduce((n, r) => n + r.score, 0) / judged.length) : null,
186
- failing: rows.filter(r => r.error || r.failing > 0).length,
187
- broken: rows.filter(r => r.error).length,
188
- hidden: rows.filter(r => r.noindex).length,
189
- duplicates: issues.duplicateTitles.length + issues.duplicateDescriptions.length,
190
- issues
191
- };
192
- }
193
-
194
- // ---------------------------------------------------------------------
195
- // 4. Extensions (SEO Pro) - see server/registry.js
196
- // ---------------------------------------------------------------------
197
- const registry = seoRegistry();
198
- Object.assign(registry.services, {
199
- version: 1,
200
- settings: () => cfg,
201
- listEntries: async (origin) => (sitemap?.listSitemapEntries ? sitemap.listSitemapEntries(origin, {log}) : []),
202
- auditList,
203
- fetchPage: (urlPath, origin) => fetchLocal(fastify, urlPath, hostOf(origin)),
204
- scanHtml, analyse,
205
- audit: {latest: () => store.audit(), summary: () => summary(), run: (origin) => runAudit(origin)},
206
- redirects: {
207
- list: () => store.rules(),
208
- match: (p) => matchRedirect(store.rules(), p),
209
- add: (input) => addRule(input)
210
- },
211
- auditUserAgent: AUDIT_UA
212
- });
213
-
214
- function addRule(input) {
215
- const rules = store.rules();
216
- if (rules.length >= MAX_RULES) return {errors: {from: `There are ${MAX_RULES} redirects already.`}};
217
- const {errors, rule} = checkRule(input, rules);
218
- if (Object.keys(errors).length) return {errors};
219
- const full = {id: newId(), ...rule, hits: 0, lastHit: null, at: new Date().toISOString()};
220
- store.setRules([...rules, full]);
221
- sitemapChanged();
222
- return {rule: full};
223
- }
224
-
225
- // ---------------------------------------------------------------------
226
- // 5. The API
227
- // ---------------------------------------------------------------------
228
- fastify.register(async (routes) => {
229
- const {authenticate, requirePermission} = options.auth ?? {};
230
- const may = (action) => ({preHandler: [authenticate, requirePermission('seo', action)]});
231
-
232
- routes.get('/overview', may('read'), async (request) => {
233
- const s = summary();
234
- const rules = store.rules();
235
- return {...s, redirects: rules.length, chains: chains(rules).length, hooks: hasCore,
236
- origin: s.origin || originOf(request), siteSeo: site().seo || {}};
237
- });
238
-
239
- routes.get('/count', may('read'), async () => {
240
- const s = summary();
241
- if (!s.at) return {count: 0, label: 'Not checked yet'};
242
- const bad = (store.audit().results || []).filter(r => r.error || r.failing > 0);
243
- return {
244
- count: bad.length,
245
- label: bad.length ? `${bad.length} ${bad.length === 1 ? 'page has' : 'pages have'} SEO problems` : 'No SEO problems found',
246
- ...(s.broken && {tone: 'danger'}),
247
- details: {title: 'SEO', rows: [{label: 'Score', value: s.score === null ? '—' : `${s.score}%`}, {label: 'Pages', value: String(s.pages)}],
248
- items: bad.slice(0, 5).map(r => ({text: r.urlPath, meta: r.error ? 'broken' : `${r.failing} failing`})),
249
- empty: 'Every page passes.'}
250
- };
251
- });
252
-
253
- routes.get('/audit', may('read'), async () => {
254
- const a = store.audit();
255
- return {...summary(a), results: (a.results || []).map(r => ({...r, checks: undefined, problems: (r.checks || []).filter(c => c.status === 'fail' || c.status === 'warn').map(c => c.title)}))};
256
- });
257
-
258
- routes.post('/audit', may('read'), async (request) => {
259
- const origin = originOf(request);
260
- const p = runAudit(origin);
261
- if (request.body?.wait === true) await p;
262
- return summary();
263
- });
264
-
265
- routes.get('/item', may('read'), async (request, reply) => {
266
- const urlPath = normalisePath(request.query?.path || '') || '/';
267
- const origin = originOf(request);
268
- const page = await getPage(urlPath).catch(() => null);
269
- const override = store.override(urlPath) || {};
270
- const fields = page
271
- ? {title: page.seo?.title || '', description: page.seo?.description || '', image: page.seo?.image || '', noindex: page.seo?.noindex === true, focus: page.seo?.focus || ''}
272
- : {title: override.title || '', description: override.description || '', image: override.image || '', noindex: override.noindex === true, focus: override.focus || ''};
273
- const fetched = await fetchLocal(fastify, urlPath, hostOf(origin)).catch(err => ({status: 0, error: err.message}));
274
- if (!page && fetched.status === 404) return reply.code(404).send({error: `Nothing is served at ${urlPath}.`});
275
- // Only a page that is really served is judged - a draft answers with the 404 page.
276
- const facts = fetched.status === 200 && fetched.html ? scanHtml(fetched.html) : null;
277
- const judged = facts ? analyse(facts, {urlPath, focus: fields.focus, limits: cfg.audit, origin}) : null;
278
- const s = site();
279
- return {
280
- urlPath, origin, kind: page ? 'page' : 'url',
281
- page: page ? {title: page.title, status: page.status, description: page.description || ''} : null,
282
- fields,
283
- live: facts ? {title: facts.title, description: facts.description || '', image: facts.og.image, h1: facts.headings.find(h => h.level === 1)?.text || '', words: facts.words, canonical: facts.canonical} : null,
284
- status: fetched.status || 0,
285
- defaults: {separator: s.seo?.titleSeparator || ' | ', siteTitle: s.seo?.defaultTitle || s.title || '', description: s.seo?.defaultDescription || '', image: s.seo?.defaultImage || ''},
286
- analysis: judged,
287
- limits: cfg.audit,
288
- redirect: matchRedirect(store.rules(), urlPath)?.rule || null
289
- };
290
- });
291
-
292
- routes.put('/item', may('manage'), async (request, reply) => {
293
- const b = request.body ?? {};
294
- const urlPath = normalisePath(b.path || '') || '/';
295
- const clip = (v, n) => String(v ?? '').trim().slice(0, n);
296
- const fields = {title: clip(b.title, 200), description: clip(b.description, 400), image: clip(b.image, 500), noindex: b.noindex === true, focus: clip(b.focus, 100)};
297
- if (fields.image && !/^(https?:\/\/|\/)/i.test(fields.image)) return reply.code(400).send({error: 'The image: a path like /media/photo.jpg, or a full web address.'});
298
- const page = await getPage(urlPath).catch(() => null);
299
- if (page) {
300
- // Empty means "use the default": sent as null, the core's merge removes the key.
301
- const seoFields = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, v === '' || v === false ? null : v]));
302
- await updatePage(urlPath, {seo: seoFields}, undefined, {author: request.user?.email || request.user?.name});
303
- } else {
304
- store.setOverride(urlPath, fields);
305
- }
306
- sitemapChanged();
307
- // The audit row for this URL, judged again now.
308
- const a = store.audit();
309
- if (a.results?.length) {
310
- const at = a.results.findIndex(r => r.urlPath === urlPath);
311
- if (at >= 0) {
312
- const row = await auditOne(fastify, a.results[at], {origin: a.origin || originOf(request), focus: fields.focus, limits: cfg.audit});
313
- const results = [...a.results];
314
- results[at] = row;
315
- store.setAudit({...a, results});
316
- }
317
- }
318
- return {ok: true, kind: page ? 'page' : 'url'};
319
- });
320
-
321
- // Redirects
322
- routes.get('/redirects', may('read'), async () => {
323
- const rules = store.rules();
324
- return {rules, chains: chains(rules).map(c => ({id: c.rule.id, via: c.via.id})), max: MAX_RULES, onRename: cfg.redirects.onRename};
325
- });
326
- routes.post('/redirects', may('manage'), async (request, reply) => {
327
- const r = addRule(request.body ?? {});
328
- if (r.errors) return reply.code(400).send({error: Object.values(r.errors).join(' '), errors: r.errors});
329
- return r;
330
- });
331
- routes.put('/redirects/:id', may('manage'), async (request, reply) => {
332
- const rules = store.rules();
333
- const old = rules.find(r => r.id === request.params.id);
334
- if (!old) return reply.code(404).send({error: 'No such redirect.'});
335
- const others = rules.filter(r => r !== old);
336
- const {errors, rule} = checkRule({...old, ...request.body, auto: false}, others);
337
- if (Object.keys(errors).length) return reply.code(400).send({error: Object.values(errors).join(' '), errors});
338
- const next = {...old, ...rule};
339
- delete next.auto;
340
- store.setRules(rules.map(r => (r === old ? next : r)));
341
- sitemapChanged();
342
- return {rule: next};
343
- });
344
- routes.delete('/redirects/:id', may('manage'), async (request) => {
345
- const ids = new Set(String(request.params.id).split(','));
346
- store.setRules(store.rules().filter(r => !ids.has(r.id)));
347
- sitemapChanged();
348
- return {ok: true};
349
- });
350
- routes.post('/redirects/import', may('manage'), async (request) => {
351
- const text = String(request.body?.csv ?? '').slice(0, 1_000_000);
352
- const added = [];
353
- const skipped = [];
354
- for (const [i, line] of text.split(/\r?\n/).entries()) {
355
- const cells = line.split(/[,\t]/).map(c => c.trim().replace(/^"|"$/g, ''));
356
- if (!cells[0] || cells[0].startsWith('#') || /^(from|source|old)/i.test(cells[0])) continue;
357
- const r = addRule({from: cells[0], to: cells[1], code: cells[2] || 301, note: cells[3] || 'Imported'});
358
- if (r.errors) skipped.push({line: i + 1, from: cells[0], why: Object.values(r.errors).join(' ')});
359
- else added.push(r.rule);
360
- if (added.length + skipped.length >= 2000) break;
361
- }
362
- return {added: added.length, skipped};
363
- });
364
- routes.get('/redirects/test', may('read'), async (request) => {
365
- const hops = [];
366
- let at = String(request.query?.path || '');
367
- const rules = store.rules();
368
- for (let i = 0; i < 10 && at; i++) {
369
- const hit = matchRedirect(rules, at);
370
- if (!hit) break;
371
- hops.push({from: at, to: hit.location, code: hit.code, id: hit.rule.id});
372
- if (hit.code === 410 || /^https?:/i.test(hit.location)) break;
373
- at = hit.location;
374
- }
375
- return {hops};
376
- });
377
-
378
- // Sitemap and robots
379
- routes.get('/sitemap', may('read'), async (request) => {
380
- const origin = originOf(request);
381
- let entries = [];
382
- let raw = [];
383
- if (sitemap?.listSitemapEntries) {
384
- entries = await sitemap.listSitemapEntries(origin, {log});
385
- // Before this plugin's own filter, to show what it leaves out and why.
386
- raw = await rawEntries(origin);
387
- }
388
- const kept = new Set(entries.map(e => e.urlPath));
389
- const sources = {};
390
- for (const e of raw) {
391
- sources[e.source] ??= {id: e.source, count: 0, listed: 0, on: cfg.sitemap.sources[e.source] !== false};
392
- sources[e.source].count++;
393
- if (kept.has(e.urlPath)) sources[e.source].listed++;
394
- }
395
- const rules = store.rules();
396
- const over = store.overrides();
397
- const left = raw.filter(e => !kept.has(e.urlPath)).map(e => ({...e,
398
- why: cfg.sitemap.sources[e.source] === false ? 'Its source is switched off'
399
- : excluded(e.urlPath, cfg.sitemap.exclude) ? 'Excluded in the settings'
400
- : over[e.urlPath]?.noindex ? 'Hidden from search engines'
401
- : matchRedirect(rules, e.urlPath) ? 'Redirected' : 'Left out'}));
402
- const {buildRobotsTxt} = sitemap || {};
403
- return {hooks: hasCore, origin, entries, left, sources: Object.values(sources),
404
- labels: {pages: 'Pages', ...Object.fromEntries(sourcesNow().map(s => [s.id, s.label]))},
405
- robots: buildRobotsTxt ? buildRobotsTxt(origin) : ''};
406
- });
407
-
408
- routes.get('/settings', may('read'), async () => ({settings: cfg, hooks: hasCore}));
409
- routes.put('/settings', may('manage'), async (request, reply) => {
410
- const {errors, settings} = checkSettings(request.body ?? {}, cfg);
411
- if (Object.keys(errors).length) return reply.code(400).send({error: Object.values(errors).join(' '), errors});
412
- const plugins = getConfig('plugins') ?? {};
413
- plugins[NAME] ??= {};
414
- plugins[NAME].settings = settings;
415
- saveConfig('plugins', plugins);
416
- cfg = settings;
417
- sitemapChanged();
418
- schedule();
419
- return {settings: cfg, hooks: hasCore};
420
- });
421
-
422
- routes.get('/extensions', may('read'), async () => ({
423
- tabs: registry.list().flatMap(x => x.tabs.map(t => ({...t, owner: x.owner, ownerLabel: x.label})))
424
- }));
425
- }, {prefix: `/api/plugins/${NAME}`});
426
-
427
- /** The sitemap entries before this plugin's filter (core's sources + pages). */
428
- async function rawEntries(origin) {
429
- const pages = (await listPages()).filter(p => p.status === 'published' && (p.visibility || 'public') === 'public' && p.urlPath !== '/404' && !p.seo?.noindex)
430
- .map(p => ({urlPath: p.urlPath, title: p.title, source: 'pages'}));
431
- const seen = new Set(pages.map(p => p.urlPath));
432
- const out = [...pages];
433
- for (const s of sourcesNow()) {
434
- try {
435
- for (const e of (await s.list({baseUrl: origin})) || []) {
436
- if (!e?.urlPath || seen.has(e.urlPath)) continue;
437
- seen.add(e.urlPath);
438
- out.push({urlPath: e.urlPath, title: e.title || '', source: s.id});
439
- }
440
- } catch { /* shown as nothing from that source */ }
441
- }
442
- return out;
443
- }
444
-
445
- // ---------------------------------------------------------------------
446
- // 6. On a schedule
447
- // ---------------------------------------------------------------------
448
- let timer = null;
449
- function schedule() {
450
- if (timer) clearInterval(timer);
451
- timer = null;
452
- const hours = cfg.audit.everyHours;
453
- if (!hours) return;
454
- timer = setInterval(() => { runAudit().catch(() => {}); }, hours * 3_600_000);
455
- timer.unref?.();
456
- }
457
- schedule();
458
- // The first audit a few minutes after start, if the last is older than the interval.
459
- const first = setTimeout(() => {
460
- const at = Date.parse(store.audit().at || 0) || 0;
461
- if (cfg.audit.everyHours && Date.now() - at > cfg.audit.everyHours * 3_600_000) runAudit().catch(() => {});
462
- }, 3 * 60_000);
463
- first.unref?.();
464
-
465
- fastify.addHook('onClose', (_i, done) => {
466
- clearInterval(timer); clearInterval(saveHits); clearTimeout(first);
467
- if (hitsDirty) store.setRules(store.rules(), {soon: 0});
468
- store.close();
469
- done();
470
- });
471
- }
472
-
473
- function newId() {
474
- return `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
475
- }
476
-
477
- export default fp(seo, {name: 'seo'});
@@ -1,64 +0,0 @@
1
- {
2
- "name": "seo",
3
- "displayName": "SEO",
4
- "version": "1.0.0",
5
- "tier": "free",
6
- "description": "How the site looks to search engines, and what to fix: an audit of every page (titles, descriptions, headings, image descriptions, links, a focus keyphrase), the search result and share card as they will look, redirects that are added for you when a page moves, and control of the sitemap, robots.txt and structured data.",
7
- "author": "Domma CMS",
8
- "date": "2026-09-27",
9
- "icon": "search",
10
- "permissions": [
11
- {
12
- "key": "seo",
13
- "label": "SEO",
14
- "group": "Plugins",
15
- "description": "See the audit, redirects and sitemap (read) or change titles, descriptions, redirects and settings (manage).",
16
- "actions": [
17
- {
18
- "key": "read",
19
- "label": "View",
20
- "description": "See the audit, redirects and sitemap; run the audit"
21
- },
22
- {
23
- "key": "manage",
24
- "label": "Manage",
25
- "description": "Change page titles and descriptions, redirects and settings"
26
- }
27
- ],
28
- "grant": [
29
- "admin"
30
- ]
31
- }
32
- ],
33
- "admin": {
34
- "sidebar": [
35
- {
36
- "id": "seo",
37
- "text": "SEO",
38
- "icon": "search",
39
- "url": "#/plugins/seo",
40
- "section": "#/plugins/seo",
41
- "permission": "seo",
42
- "countUrl": "/api/plugins/seo/count"
43
- }
44
- ],
45
- "routes": [
46
- {
47
- "path": "/plugins/seo",
48
- "view": "seo-main",
49
- "title": "SEO"
50
- },
51
- {
52
- "path": "/plugins/seo/:tab",
53
- "view": "seo-main",
54
- "title": "SEO"
55
- }
56
- ],
57
- "views": {
58
- "seo-main": {
59
- "entry": "seo/admin/views/seo.js",
60
- "exportName": "seoView"
61
- }
62
- }
63
- }
64
- }
@@ -1,76 +0,0 @@
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
- }