domma-cms 0.89.1 → 0.90.1

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 (33) 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/seo-analyse.js +1 -0
  5. package/admin/js/lib/seo-redirects.js +1 -0
  6. package/admin/js/lib/seo-schema.js +2 -0
  7. package/admin/js/lib/seo-settings.js +2 -0
  8. package/{plugins/seo/admin → admin/js}/templates/seo.html +1 -2
  9. package/admin/js/views/index.js +1 -1
  10. package/admin/js/views/seo.js +144 -0
  11. package/package.json +2 -2
  12. package/plugins/free-tier.lock.json +0 -19
  13. package/server/routes/api/seo.js +201 -0
  14. package/server/server.js +12 -1
  15. package/server/services/permissionRegistry.js +11 -0
  16. package/server/services/plugins.js +12 -2
  17. package/{plugins/seo/server → server/services/seo}/audit.js +2 -2
  18. package/server/services/seo/index.js +345 -0
  19. package/{plugins/seo/server → server/services/seo}/registry.js +5 -3
  20. package/server/services/seo/store.js +110 -0
  21. package/server/services/siteGitignore.js +4 -1
  22. package/server/services/tools.js +2 -1
  23. package/plugins/seo/CLAUDE.md +0 -48
  24. package/plugins/seo/admin/lib/analyse.js +0 -286
  25. package/plugins/seo/admin/lib/redirects.js +0 -163
  26. package/plugins/seo/admin/lib/schema.js +0 -113
  27. package/plugins/seo/admin/lib/settings.js +0 -141
  28. package/plugins/seo/admin/views/seo.js +0 -912
  29. package/plugins/seo/config.js +0 -7
  30. package/plugins/seo/plugin.js +0 -477
  31. package/plugins/seo/plugin.json +0 -64
  32. package/plugins/seo/server/store.js +0 -76
  33. package/plugins/seo/tests/seo.test.js +0 -192
@@ -0,0 +1,201 @@
1
+ /**
2
+ * SEO - the API (mounted at /api/seo behind toolGate('seo'); the `seo`
3
+ * permission: read / manage). The engine is services/seo/index.js.
4
+ *
5
+ * GET /overview · GET /count · GET /audit · POST /audit · GET|PUT /item
6
+ * GET|POST /redirects · PUT|DELETE /redirects/:id · POST /redirects/import
7
+ * GET /redirects/test · GET /sitemap · GET|PUT /settings · GET /extensions
8
+ *
9
+ * Was /api/plugins/seo while SEO was a plugin (0.89.0).
10
+ */
11
+ import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
12
+ import {getConfig} from '../../config.js';
13
+ import {getPage, updatePage} from '../../services/content.js';
14
+ import {getSitemapSources} from '../../services/hooks.js';
15
+ import {seoEngine} from '../../services/seo/index.js';
16
+ import {excluded} from '../../../admin/js/lib/seo-settings.js';
17
+
18
+ /**
19
+ * @param {import('fastify').FastifyInstance} fastify
20
+ * @param {object} [opts] - authenticate / requirePermission overrides (tests)
21
+ */
22
+ export async function seoRoutes(fastify, opts = {}) {
23
+ const authenticate = opts.authenticate || defaultAuthenticate;
24
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
25
+ const may = (action) => ({preHandler: [authenticate, requirePermission('seo', action)]});
26
+ const seo = () => seoEngine();
27
+ const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
28
+ const off = (reply) => reply.code(503).send({error: 'SEO is not running on this site.'});
29
+
30
+ fastify.addHook('preHandler', async (request, reply) => {
31
+ if (!seo()) return off(reply);
32
+ });
33
+
34
+ fastify.get('/overview', may('read'), async (request) => {
35
+ const s = seo().summary();
36
+ const rules = seo().store.rules();
37
+ return {...s, redirects: rules.length, chains: seo().chains(rules).length, hooks: true,
38
+ origin: s.origin || seo().originOf(request), siteSeo: site().seo || {}};
39
+ });
40
+
41
+ fastify.get('/count', may('read'), async () => {
42
+ const s = seo().summary();
43
+ if (!s.at) return {count: 0, label: 'Not checked yet'};
44
+ const bad = (seo().store.audit().results || []).filter(r => r.error || r.failing > 0);
45
+ return {
46
+ count: bad.length,
47
+ label: bad.length ? `${bad.length} ${bad.length === 1 ? 'page has' : 'pages have'} SEO problems` : 'No SEO problems found',
48
+ ...(s.broken && {tone: 'danger'}),
49
+ details: {title: 'SEO', rows: [{label: 'Score', value: s.score === null ? '—' : `${s.score}%`}, {label: 'Pages', value: String(s.pages)}],
50
+ items: bad.slice(0, 5).map(r => ({text: r.urlPath, meta: r.error ? 'broken' : `${r.failing} failing`})),
51
+ empty: 'Every page passes.'}
52
+ };
53
+ });
54
+
55
+ fastify.get('/audit', may('read'), async () => {
56
+ const a = seo().store.audit();
57
+ return {...seo().summary(a), results: (a.results || []).map(r => ({...r, checks: undefined,
58
+ problems: (r.checks || []).filter(c => c.status === 'fail' || c.status === 'warn').map(c => c.title)}))};
59
+ });
60
+
61
+ fastify.post('/audit', may('read'), async (request) => {
62
+ const p = seo().runAudit(seo().originOf(request));
63
+ if (request.body?.wait === true) await p;
64
+ return seo().summary();
65
+ });
66
+
67
+ fastify.get('/item', may('read'), async (request, reply) => {
68
+ const e = seo();
69
+ const urlPath = e.normalisePath(request.query?.path || '') || '/';
70
+ const origin = e.originOf(request);
71
+ const page = await getPage(urlPath).catch(() => null);
72
+ const override = e.store.override(urlPath) || {};
73
+ const fields = page
74
+ ? {title: page.seo?.title || '', description: page.seo?.description || '', image: page.seo?.image || '', noindex: page.seo?.noindex === true, focus: page.seo?.focus || ''}
75
+ : {title: override.title || '', description: override.description || '', image: override.image || '', noindex: override.noindex === true, focus: override.focus || ''};
76
+ const fetched = await e.fetchLocal(urlPath, origin).catch(err => ({status: 0, error: err.message}));
77
+ if (!page && fetched.status === 404) return reply.code(404).send({error: `Nothing is served at ${urlPath}.`});
78
+ // Only a page that is really served is judged - a draft answers with the 404 page.
79
+ const facts = fetched.status === 200 && fetched.html ? e.scanHtml(fetched.html) : null;
80
+ const limits = e.settings().audit;
81
+ const judged = facts ? e.analyse(facts, {urlPath, focus: fields.focus, limits, origin}) : null;
82
+ const s = site();
83
+ return {
84
+ urlPath, origin, kind: page ? 'page' : 'url',
85
+ page: page ? {title: page.title, status: page.status, description: page.description || ''} : null,
86
+ fields,
87
+ 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,
88
+ status: fetched.status || 0,
89
+ defaults: {separator: s.seo?.titleSeparator || ' | ', siteTitle: s.seo?.defaultTitle || s.title || '', description: s.seo?.defaultDescription || '', image: s.seo?.defaultImage || ''},
90
+ analysis: judged,
91
+ limits,
92
+ redirect: e.matchRedirect(e.store.rules(), urlPath)?.rule || null
93
+ };
94
+ });
95
+
96
+ fastify.put('/item', may('manage'), async (request, reply) => {
97
+ const e = seo();
98
+ const b = request.body ?? {};
99
+ const urlPath = e.normalisePath(b.path || '') || '/';
100
+ const clip = (v, n) => String(v ?? '').trim().slice(0, n);
101
+ 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)};
102
+ 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.'});
103
+ const page = await getPage(urlPath).catch(() => null);
104
+ if (page) {
105
+ // Empty means "use the default": sent as null, updatePage's merge removes the key.
106
+ const seoFields = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, v === '' || v === false ? null : v]));
107
+ await updatePage(urlPath, {seo: seoFields}, undefined, {author: request.user?.username || request.user?.email});
108
+ } else {
109
+ e.store.setOverride(urlPath, fields);
110
+ }
111
+ e.sitemapChanged();
112
+ await e.reaudit(urlPath, {origin: e.originOf(request), focus: fields.focus}).catch(() => {});
113
+ return {ok: true, kind: page ? 'page' : 'url'};
114
+ });
115
+
116
+ // Redirects
117
+ fastify.get('/redirects', may('read'), async () => {
118
+ const rules = seo().store.rulesWithHits();
119
+ return {rules, chains: seo().chains(rules).map(c => ({id: c.rule.id, via: c.via.id})), max: seo().maxRules, onRename: seo().settings().redirects.onRename};
120
+ });
121
+ fastify.post('/redirects', may('manage'), async (request, reply) => {
122
+ const r = seo().addRule(request.body ?? {});
123
+ if (r.errors) return reply.code(400).send({error: Object.values(r.errors).join(' '), errors: r.errors});
124
+ return r;
125
+ });
126
+ fastify.put('/redirects/:id', may('manage'), async (request, reply) => {
127
+ const r = seo().updateRule(request.params.id, request.body ?? {});
128
+ if (r.missing) return reply.code(404).send({error: 'No such redirect.'});
129
+ if (r.errors) return reply.code(400).send({error: Object.values(r.errors).join(' '), errors: r.errors});
130
+ return r;
131
+ });
132
+ fastify.delete('/redirects/:id', may('manage'), async (request) => {
133
+ seo().removeRules(String(request.params.id).split(','));
134
+ return {ok: true};
135
+ });
136
+ fastify.post('/redirects/import', may('manage'), async (request) => {
137
+ const text = String(request.body?.csv ?? '').slice(0, 1_000_000);
138
+ let added = 0;
139
+ const skipped = [];
140
+ for (const [i, line] of text.split(/\r?\n/).entries()) {
141
+ const cells = line.split(/[,\t]/).map(c => c.trim().replace(/^"|"$/g, ''));
142
+ if (!cells[0] || cells[0].startsWith('#') || /^(from|source|old)/i.test(cells[0])) continue;
143
+ const r = seo().addRule({from: cells[0], to: cells[1], code: cells[2] || 301, note: cells[3] || 'Imported'});
144
+ if (r.errors) skipped.push({line: i + 1, from: cells[0], why: Object.values(r.errors).join(' ')});
145
+ else added++;
146
+ if (added + skipped.length >= 2000) break;
147
+ }
148
+ return {added, skipped};
149
+ });
150
+ fastify.get('/redirects/test', may('read'), async (request) => {
151
+ const hops = [];
152
+ let at = String(request.query?.path || '');
153
+ const rules = seo().store.rules();
154
+ for (let i = 0; i < 10 && at; i++) {
155
+ const hit = seo().matchRedirect(rules, at);
156
+ if (!hit) break;
157
+ hops.push({from: at, to: hit.location, code: hit.code, id: hit.rule.id});
158
+ if (hit.code === 410 || /^https?:/i.test(hit.location)) break;
159
+ at = hit.location;
160
+ }
161
+ return {hops};
162
+ });
163
+
164
+ // Sitemap and robots
165
+ fastify.get('/sitemap', may('read'), async (request) => {
166
+ const e = seo();
167
+ const cfg = e.settings();
168
+ const origin = e.originOf(request);
169
+ const {listSitemapEntries, buildRobotsTxt} = await import('../../services/sitemap.js');
170
+ const entries = await listSitemapEntries(origin, {log: fastify.log});
171
+ const raw = await e.rawEntries(origin);
172
+ const kept = new Set(entries.map(x => x.urlPath));
173
+ const sources = {};
174
+ for (const x of raw) {
175
+ sources[x.source] ??= {id: x.source, count: 0, listed: 0, on: cfg.sitemap.sources[x.source] !== false};
176
+ sources[x.source].count++;
177
+ if (kept.has(x.urlPath)) sources[x.source].listed++;
178
+ }
179
+ const rules = e.store.rules();
180
+ const over = e.store.overrides();
181
+ const left = raw.filter(x => !kept.has(x.urlPath)).map(x => ({...x,
182
+ why: cfg.sitemap.sources[x.source] === false ? 'Its source is switched off'
183
+ : excluded(x.urlPath, cfg.sitemap.exclude) ? 'Excluded in the settings'
184
+ : over[x.urlPath]?.noindex ? 'Hidden from search engines'
185
+ : e.matchRedirect(rules, x.urlPath) ? 'Redirected' : 'Left out'}));
186
+ return {hooks: true, origin, entries, left, sources: Object.values(sources),
187
+ labels: {pages: 'Pages', ...Object.fromEntries(getSitemapSources().map(s => [s.id, s.label]))},
188
+ robots: buildRobotsTxt(origin)};
189
+ });
190
+
191
+ fastify.get('/settings', may('read'), async () => ({settings: seo().settings(), hooks: true}));
192
+ fastify.put('/settings', may('manage'), async (request, reply) => {
193
+ const r = seo().saveSettings(request.body ?? {});
194
+ if (r.errors) return reply.code(400).send({error: Object.values(r.errors).join(' '), errors: r.errors});
195
+ return {settings: r.settings, hooks: true};
196
+ });
197
+
198
+ fastify.get('/extensions', may('read'), async () => ({
199
+ tabs: seo().registry.list().flatMap(x => x.tabs.map(t => ({...t, owner: x.owner, ownerLabel: x.label})))
200
+ }));
201
+ }
package/server/server.js CHANGED
@@ -491,6 +491,7 @@ const { notesRoutes } = await import('./routes/api/notes.js');
491
491
  const { todoRoutes } = await import('./routes/api/todo.js');
492
492
  const { analyticsRoutes } = await import('./routes/api/analytics.js');
493
493
  const { contactsRoutes } = await import('./routes/api/contacts.js');
494
+ const { seoRoutes } = await import('./routes/api/seo.js');
494
495
  const { relatedRoutes } = await import('./routes/api/related.js');
495
496
  const { pluginMarketplaceRoutes } = await import('./routes/api/plugin-marketplace.js');
496
497
  const { collectionsRoutes } = await import('./routes/api/collections.js');
@@ -548,6 +549,15 @@ await app.register(toolGate('notes', notesRoutes), {prefix: '/api/notes'});
548
549
  await app.register(toolGate('todo', todoRoutes), {prefix: '/api/todo'});
549
550
  await app.register(toolGate('analytics', analyticsRoutes), {prefix: '/api/analytics'});
550
551
  await app.register(toolGate('contacts', contactsRoutes), {prefix: '/api/contacts'});
552
+ // SEO (0.90.0; the `seo` plugin in 0.89.0): a global plugin - its redirect guard
553
+ // sees every request - plus its API. Off, the engine does nothing at all.
554
+ {
555
+ const {seoPlugin} = await import('./services/seo/index.js');
556
+ const {getBaseUrl} = await import('./routes/public.js');
557
+ const rolesService = await import('./services/roles.js');
558
+ await app.register(seoPlugin, {enabled: isToolEnabled('seo'), getBaseUrl, rolesService});
559
+ }
560
+ await app.register(toolGate('seo', seoRoutes), {prefix: '/api/seo'});
551
561
  await app.register(toolsRoutes, {prefix: '/api'});
552
562
  // What plugins show beside a contact or a note (Calendar's Events) - 0.79.
553
563
  await app.register(relatedRoutes, {prefix: '/api/related'});
@@ -576,7 +586,8 @@ const coreToolItems = [
576
586
  {text: 'Notes', url: '#/notes', icon: 'edit-3', permission: 'notes', countUrl: '/api/notes/count'},
577
587
  {text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'},
578
588
  {text: 'Analytics', url: '#/analytics', icon: 'chart-bar', permission: 'analytics', countUrl: '/api/analytics/active-count'},
579
- {text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'}
589
+ {text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'},
590
+ {text: 'SEO', url: '#/seo', icon: 'search', permission: 'seo', countUrl: '/api/seo/count'}
580
591
  ];
581
592
  for (const item of coreToolItems) {
582
593
  if (isToolEnabled(item.permission)) registerSidebarItem({item: {source: 'Built in', ...item}});
@@ -226,6 +226,17 @@ export const REGISTRY = [
226
226
  {key: 'delete', label: 'Reset', description: 'Reset all counters'}
227
227
  ]
228
228
  },
229
+ {
230
+ key: 'seo',
231
+ label: 'SEO',
232
+ description: 'How the site looks to search engines: the audit, page titles and descriptions, redirects, the sitemap and robots.txt.',
233
+ icon: 'search',
234
+ group: 'Tools',
235
+ actions: [
236
+ {key: 'read', label: 'View', description: 'See the audit, redirects and sitemap; run the audit'},
237
+ {key: 'manage', label: 'Manage', description: 'Change page titles and descriptions, redirects and settings'}
238
+ ]
239
+ },
229
240
  {
230
241
  key: 'notes',
231
242
  label: 'Notes',
@@ -165,7 +165,7 @@ export function isCorePlugin(name) { return CORE_PLUGINS.has(name); }
165
165
  * same data - and, like supersession, nothing is written down: a lapsed
166
166
  * licence means the plugin does not load and the core Tool is simply back.
167
167
  */
168
- export const BUILT_IN_TOOLS = new Set(['notes', 'todo', 'analytics', 'contacts']);
168
+ export const BUILT_IN_TOOLS = new Set(['notes', 'todo', 'analytics', 'contacts', 'seo']);
169
169
 
170
170
  /** Built-in Tool → {plugin, route}, for the plugins that loaded this boot. */
171
171
  const _toolTakeovers = {};
@@ -196,6 +196,16 @@ export function toolTakeoversOf(manifest, taken = {}) {
196
196
  return out;
197
197
  }
198
198
 
199
+ /**
200
+ * Plugins that became part of the CMS. A site updated from an engine that
201
+ * shipped one still has its folder; it is never loaded again (it would run
202
+ * beside the built-in copy - two redirect guards, two sets of tags) and its
203
+ * data is carried over by the built-in feature.
204
+ */
205
+ export const RETIRED_PLUGINS = Object.freeze({
206
+ seo: 'Built in since 0.90.0 (server/services/seo) - its data moves to content/seo and config/seo.json.'
207
+ });
208
+
199
209
  /**
200
210
  * Scan the plugins/ directory and return all valid manifests.
201
211
  * Validates mandatory fields and required files (plugin.js, config.js).
@@ -212,7 +222,7 @@ export async function discoverPlugins() {
212
222
  }
213
223
 
214
224
  const plugins = [];
215
- for (const entry of entries.filter(e => e.isDirectory() && !e.name.startsWith('_'))) {
225
+ for (const entry of entries.filter(e => e.isDirectory() && !e.name.startsWith('_') && !RETIRED_PLUGINS[e.name])) {
216
226
  const dir = entry.name;
217
227
  const pluginDir = path.join(PLUGINS_DIR, dir);
218
228
  const manifestPath = path.join(pluginDir, 'plugin.json');
@@ -2,12 +2,12 @@
2
2
  * SEO - the audit: every URL in the sitemap (and every published page hidden
3
3
  * from search engines), fetched INSIDE the process with fastify.inject - no
4
4
  * network, no certificate, the same HTML a visitor gets - and judged by
5
- * admin/lib/analyse.js.
5
+ * admin/js/lib/seo-analyse.js.
6
6
  *
7
7
  * The requests carry `user-agent: DommaSEO/1 (audit)` and `x-domma-seo: audit`
8
8
  * so analytics and SEO Pro's 404 log can leave them out.
9
9
  */
10
- import {analyse, scanHtml} from '../admin/lib/analyse.js';
10
+ import {analyse, scanHtml} from '../../../admin/js/lib/seo-analyse.js';
11
11
 
12
12
  export const AUDIT_UA = 'DommaSEO/1 (audit)';
13
13
  const CONCURRENCY = 3;
@@ -0,0 +1,345 @@
1
+ /**
2
+ * SEO - built in since 0.90.0 (it was the free `seo` plugin in 0.89.0).
3
+ *
4
+ * - Redirects: a global onRequest guard (GET/HEAD, never /api or /admin);
5
+ * rules in content/seo/redirects.json (admin/js/lib/seo-redirects.js).
6
+ * A renamed page gets one (content:pageRenamed), older rules re-aimed.
7
+ * - What search engines are given: the 'seo:meta', 'sitemap:entries' and
8
+ * 'robots:lines' transforms (hooks.js section 8) - a URL's overrides,
9
+ * Organization / BlogPosting JSON-LD, sitemap exclusions, robots rules.
10
+ * - The audit (./audit.js): every sitemap URL fetched in-process and judged.
11
+ * - The registry (./registry.js) SEO Pro plugs into.
12
+ *
13
+ * A switchable built-in Tool (services/tools.js): when it is off, none of the
14
+ * above runs and /api/seo answers 404. The switch restarts the site, so the
15
+ * boot-time state is the state.
16
+ *
17
+ * `seoEngine()` is the one instance; routes/api/seo.js is its API.
18
+ *
19
+ * @module services/seo
20
+ */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import fp from 'fastify-plugin';
24
+ import {createStore} from './store.js';
25
+ import {auditAll, auditOne, AUDIT_UA, fetchLocal, hostOf} from './audit.js';
26
+ import {seoRegistry} from './registry.js';
27
+ import {analyse, scanHtml, siteIssues} from '../../../admin/js/lib/seo-analyse.js';
28
+ import {chains, checkRule, matchRedirect, MAX_RULES, normalisePath, onMoved} from '../../../admin/js/lib/seo-redirects.js';
29
+ import {applySeo, robotsLines} from '../../../admin/js/lib/seo-schema.js';
30
+ import {checkSettings, excluded, mergeDefaults} from '../../../admin/js/lib/seo-settings.js';
31
+ import {getConfig, saveConfig} from '../../config.js';
32
+ import {hooks as coreHooks, getSitemapSources, registerTransform} from '../hooks.js';
33
+ import {listPages} from '../content.js';
34
+ import {invalidateTags} from '../cache/index.js';
35
+
36
+ const SKIP = /^\/(api|admin)(\/|$)/;
37
+
38
+ let engine = null;
39
+
40
+ /** The SEO engine, once `seoPlugin` has registered it (null before). */
41
+ export const seoEngine = () => engine;
42
+
43
+ /** Settings: config/seo.json over the defaults. */
44
+ function readSettings() {
45
+ try { return mergeDefaults(getConfig('seo')); } catch { return mergeDefaults({}); }
46
+ }
47
+
48
+ /**
49
+ * The SEO plugin's data, once, when SEO became built in (0.89.0 -> 0.90.0):
50
+ * its settings from config/plugins.json, redirects (with their counters) and
51
+ * overrides from plugins/seo/data. Never over anything already here.
52
+ */
53
+ export function migrateFromPlugin({root = process.cwd(), log = console} = {}) {
54
+ const moved = [];
55
+ const cfgFile = path.join(root, 'config', 'seo.json');
56
+ const pluginData = path.join(root, 'plugins', 'seo', 'data');
57
+ const dir = path.join(root, 'content', 'seo');
58
+ try {
59
+ if (!fs.existsSync(cfgFile)) {
60
+ const plugins = JSON.parse(fs.readFileSync(path.join(root, 'config', 'plugins.json'), 'utf8'));
61
+ const s = plugins?.seo?.settings;
62
+ if (s && typeof s === 'object' && Object.keys(s).length) {
63
+ fs.writeFileSync(cfgFile, JSON.stringify(mergeDefaults(s), null, 2) + '\n');
64
+ moved.push('settings');
65
+ }
66
+ }
67
+ } catch { /* no plugins.json, or no SEO settings in it */ }
68
+ const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
69
+ const old = readJson(path.join(pluginData, 'redirects.json'));
70
+ if (Array.isArray(old?.rules) && old.rules.length && !fs.existsSync(path.join(dir, 'redirects.json'))) {
71
+ fs.mkdirSync(dir, {recursive: true});
72
+ const rules = old.rules.map(({hits: _h, lastHit: _l, ...r}) => r);
73
+ const hits = Object.fromEntries(old.rules.filter(r => r.hits).map(r => [r.id, {hits: r.hits, lastHit: r.lastHit || null}]));
74
+ fs.writeFileSync(path.join(dir, 'redirects.json'), JSON.stringify({rules}, null, 2) + '\n');
75
+ if (Object.keys(hits).length) fs.writeFileSync(path.join(dir, 'hits.json'), JSON.stringify(hits) + '\n');
76
+ moved.push(`${rules.length} redirect(s)`);
77
+ }
78
+ const over = readJson(path.join(pluginData, 'overrides.json'));
79
+ if (over && typeof over === 'object' && Object.keys(over).length && !fs.existsSync(path.join(dir, 'overrides.json'))) {
80
+ fs.mkdirSync(dir, {recursive: true});
81
+ fs.writeFileSync(path.join(dir, 'overrides.json'), JSON.stringify(over, null, 2) + '\n');
82
+ moved.push(`${Object.keys(over).length} override(s)`);
83
+ }
84
+ if (moved.length) log.info?.(`[seo] carried over from the SEO plugin: ${moved.join(', ')}.`);
85
+ return moved;
86
+ }
87
+
88
+ async function seo(fastify, {enabled = true, getBaseUrl = null, rolesService = null} = {}) {
89
+ const log = fastify.log;
90
+ const registry = seoRegistry();
91
+ if (!enabled) {
92
+ log.info('[seo] SEO is switched off on this site.');
93
+ return;
94
+ }
95
+ try { migrateFromPlugin({log}); } catch (err) { log.warn(`[seo] the SEO plugin's data was not carried over: ${err.message}`); }
96
+ // Admins may use it, once - removed by hand, it stays removed (roles.js grantPermissionOnce).
97
+ rolesService?.grantPermissionOnce?.('admin', 'seo', 'core')?.catch?.(() => {});
98
+
99
+ let cfg = readSettings();
100
+ const store = createStore();
101
+ const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
102
+ const originOf = (request) => {
103
+ try { return String(getBaseUrl?.(request) || site().baseUrl || '').replace(/\/+$/, ''); } catch { return ''; }
104
+ };
105
+ const sitemapChanged = () => { invalidateTags(['sitemap']).catch(() => {}); };
106
+
107
+ // ---- 1. Redirects, ahead of everything else ----
108
+ fastify.addHook('onRequest', async (request, reply) => {
109
+ try {
110
+ if (request.method !== 'GET' && request.method !== 'HEAD') return;
111
+ const url = String(request.url || '');
112
+ if (SKIP.test(url)) return;
113
+ const rules = store.rules();
114
+ if (!rules.length) return;
115
+ const hit = matchRedirect(rules, url);
116
+ if (!hit) return;
117
+ if (request.headers['x-domma-seo'] !== 'audit') store.countHit(hit.rule.id);
118
+ if (hit.code === 410) {
119
+ reply.code(410).header('content-type', 'text/plain; charset=utf-8').send('Gone');
120
+ return reply;
121
+ }
122
+ reply.code(hit.code).header('location', hit.location)
123
+ .header('cache-control', hit.code === 301 || hit.code === 308 ? 'public, max-age=3600' : 'no-store').send();
124
+ return reply;
125
+ } catch (err) {
126
+ log.warn(`[seo] redirect skipped: ${err.message}`);
127
+ }
128
+ });
129
+ const saveHits = setInterval(() => store.saveHits(), 60_000);
130
+ saveHits.unref?.();
131
+
132
+ const onRenamed = ({from, to} = {}) => {
133
+ if (!cfg.redirects.onRename || !from || !to) return;
134
+ try {
135
+ store.setRules(onMoved(store.rulesWithHits(), from, to, {id: newId()}));
136
+ log.info(`[seo] ${from} moved to ${to}: redirect added.`);
137
+ } catch (err) {
138
+ log.warn(`[seo] no redirect for the move of ${from}: ${err.message}`);
139
+ }
140
+ };
141
+ coreHooks.on('content:pageRenamed', onRenamed);
142
+
143
+ // ---- 2. What search engines are given ----
144
+ registerTransform('seo:meta', (meta, ctx) => {
145
+ try {
146
+ const origin = String(ctx?.baseUrl || '').replace(/\/+$/, '');
147
+ return applySeo(meta, {page: ctx?.page, kind: ctx?.kind, origin, siteTitle: ctx?.site?.title || '', settings: cfg,
148
+ override: store.override(normalisePath(ctx?.page?.urlPath || '/') || '/')});
149
+ } catch (err) {
150
+ log.warn(`[seo] page tags left as they were: ${err.message}`);
151
+ return meta;
152
+ }
153
+ });
154
+ registerTransform('sitemap:entries', (entries) => {
155
+ const rules = store.rules();
156
+ const over = store.overrides();
157
+ return entries.filter(e => !excluded(e.urlPath, cfg.sitemap.exclude)
158
+ && cfg.sitemap.sources[e.source] !== false
159
+ && over[normalisePath(e.urlPath)]?.noindex !== true
160
+ && !(rules.length && matchRedirect(rules, e.urlPath)));
161
+ });
162
+ registerTransform('robots:lines', (lines, ctx) => robotsLines(lines, cfg.robots, ctx?.sitemapUrl));
163
+
164
+ // ---- 3. The audit ----
165
+ const sitemap = await import('../sitemap.js');
166
+ let running = null;
167
+ let progress = {done: 0, total: 0};
168
+ const isPublic = (p) => p.status === 'published' && (p.visibility || 'public') === 'public' && p.urlPath !== '/404';
169
+
170
+ /** What to audit: the sitemap, plus published pages kept out of it (so they show as hidden). */
171
+ async function auditList(origin) {
172
+ const list = [];
173
+ const seen = new Set();
174
+ for (const e of await sitemap.listSitemapEntries(origin, {log})) {
175
+ seen.add(e.urlPath);
176
+ list.push({urlPath: e.urlPath, source: e.source, title: e.title});
177
+ }
178
+ for (const p of await listPages()) {
179
+ if (seen.has(p.urlPath) || !isPublic(p)) continue;
180
+ list.push({urlPath: p.urlPath, source: 'pages', title: p.title, noindex: p.seo?.noindex === true, unlisted: true});
181
+ }
182
+ return list;
183
+ }
184
+ async function focusMap() {
185
+ const map = {};
186
+ for (const p of await listPages()) if (p.seo?.focus) map[p.urlPath] = p.seo.focus;
187
+ for (const [p, o] of Object.entries(store.overrides())) if (o.focus) map[p] = o.focus;
188
+ return map;
189
+ }
190
+ async function runAudit(origin) {
191
+ if (running) return running;
192
+ running = (async () => {
193
+ const started = Date.now();
194
+ try {
195
+ const o = origin || store.audit().origin || String(site().baseUrl || '').replace(/\/+$/, '');
196
+ const [list, focus] = await Promise.all([auditList(o), focusMap()]);
197
+ progress = {done: 0, total: list.length};
198
+ const results = await auditAll(fastify, list, {origin: o, focusOf: (p) => focus[p] || '', limits: cfg.audit,
199
+ onProgress: (done, total) => { progress = {done, total}; }});
200
+ store.setAudit({at: new Date().toISOString(), ms: Date.now() - started, origin: o, results});
201
+ registry.emit('audit', store.audit());
202
+ return store.audit();
203
+ } catch (err) {
204
+ log.warn(`[seo] audit failed: ${err.message}`);
205
+ return store.audit();
206
+ } finally {
207
+ running = null;
208
+ }
209
+ })();
210
+ return running;
211
+ }
212
+ function summary(a = store.audit()) {
213
+ const rows = a.results || [];
214
+ const judged = rows.filter(r => typeof r.score === 'number' && !r.noindex);
215
+ const issues = siteIssues(rows);
216
+ return {
217
+ at: a.at, ms: a.ms || 0, origin: a.origin || '', running: Boolean(running), progress,
218
+ pages: rows.length,
219
+ score: judged.length ? Math.round(judged.reduce((n, r) => n + r.score, 0) / judged.length) : null,
220
+ failing: rows.filter(r => r.error || r.failing > 0).length,
221
+ broken: rows.filter(r => r.error).length,
222
+ hidden: rows.filter(r => r.noindex).length,
223
+ duplicates: issues.duplicateTitles.length + issues.duplicateDescriptions.length,
224
+ issues
225
+ };
226
+ }
227
+ /** Audit one URL again (after its fields changed), in place. */
228
+ async function reaudit(urlPath, {origin, focus}) {
229
+ const a = store.audit();
230
+ const at = (a.results || []).findIndex(r => r.urlPath === urlPath);
231
+ if (at < 0) return;
232
+ const row = await auditOne(fastify, a.results[at], {origin: a.origin || origin, focus, limits: cfg.audit});
233
+ const results = [...a.results];
234
+ results[at] = row;
235
+ store.setAudit({...a, results});
236
+ }
237
+
238
+ // ---- 4. Redirect rules ----
239
+ function addRule(input) {
240
+ const rules = store.rules();
241
+ if (rules.length >= MAX_RULES) return {errors: {from: `There are ${MAX_RULES} redirects already.`}};
242
+ const {errors, rule} = checkRule(input, rules);
243
+ if (Object.keys(errors).length) return {errors};
244
+ const full = {id: newId(), ...rule, at: new Date().toISOString()};
245
+ store.setRules([...rules, full]);
246
+ sitemapChanged();
247
+ return {rule: {...full, hits: 0, lastHit: null}};
248
+ }
249
+ function updateRule(id, input) {
250
+ const rules = store.rules();
251
+ const old = rules.find(r => r.id === id);
252
+ if (!old) return {missing: true};
253
+ const {errors, rule} = checkRule({...old, ...input, auto: false}, rules.filter(r => r !== old));
254
+ if (Object.keys(errors).length) return {errors};
255
+ const next = {...old, ...rule};
256
+ delete next.auto;
257
+ store.setRules(rules.map(r => (r === old ? next : r)));
258
+ sitemapChanged();
259
+ return {rule: next};
260
+ }
261
+ function removeRules(ids) {
262
+ const drop = new Set(ids);
263
+ store.setRules(store.rules().filter(r => !drop.has(r.id)));
264
+ sitemapChanged();
265
+ }
266
+
267
+ /** The sitemap entries before SEO's filter (pages + every source), to show what it leaves out. */
268
+ async function rawEntries(origin) {
269
+ const out = (await listPages()).filter(p => isPublic(p) && !p.seo?.noindex).map(p => ({urlPath: p.urlPath, title: p.title, source: 'pages'}));
270
+ const seen = new Set(out.map(p => p.urlPath));
271
+ for (const s of getSitemapSources()) {
272
+ try {
273
+ for (const e of (await s.list({baseUrl: origin})) || []) {
274
+ if (!e?.urlPath || seen.has(e.urlPath)) continue;
275
+ seen.add(e.urlPath);
276
+ out.push({urlPath: e.urlPath, title: e.title || '', source: s.id});
277
+ }
278
+ } catch { /* shown as nothing from that source */ }
279
+ }
280
+ return out;
281
+ }
282
+
283
+ function saveSettings(input) {
284
+ const {errors, settings} = checkSettings(input, cfg);
285
+ if (Object.keys(errors).length) return {errors};
286
+ saveConfig('seo', settings);
287
+ cfg = settings;
288
+ sitemapChanged();
289
+ schedule();
290
+ return {settings};
291
+ }
292
+
293
+ // ---- 5. The registry SEO Pro plugs into ----
294
+ Object.assign(registry.services, {
295
+ version: 1,
296
+ builtIn: true,
297
+ settings: () => cfg,
298
+ listEntries: (origin) => sitemap.listSitemapEntries(origin, {log}),
299
+ auditList,
300
+ fetchPage: (urlPath, origin) => fetchLocal(fastify, urlPath, hostOf(origin)),
301
+ scanHtml, analyse,
302
+ audit: {latest: () => store.audit(), summary: () => summary(), run: (origin) => runAudit(origin)},
303
+ redirects: {list: () => store.rulesWithHits(), match: (p) => matchRedirect(store.rules(), p), add: (input) => addRule(input)},
304
+ auditUserAgent: AUDIT_UA
305
+ });
306
+
307
+ // ---- 6. On a schedule ----
308
+ let timer = null;
309
+ function schedule() {
310
+ if (timer) clearInterval(timer);
311
+ timer = null;
312
+ if (!cfg.audit.everyHours) return;
313
+ timer = setInterval(() => { runAudit().catch(() => {}); }, cfg.audit.everyHours * 3_600_000);
314
+ timer.unref?.();
315
+ }
316
+ schedule();
317
+ // The first audit a few minutes after start, if the last is older than the interval.
318
+ const first = setTimeout(() => {
319
+ const at = Date.parse(store.audit().at || 0) || 0;
320
+ if (cfg.audit.everyHours && Date.now() - at > cfg.audit.everyHours * 3_600_000) runAudit().catch(() => {});
321
+ }, 3 * 60_000);
322
+ first.unref?.();
323
+
324
+ fastify.addHook('onClose', (_i, done) => {
325
+ clearInterval(timer); clearInterval(saveHits); clearTimeout(first);
326
+ coreHooks.off?.('content:pageRenamed', onRenamed);
327
+ store.close();
328
+ done();
329
+ });
330
+
331
+ engine = {
332
+ store, registry, originOf, summary, runAudit, reaudit, auditList, rawEntries,
333
+ addRule, updateRule, removeRules, saveSettings, sitemapChanged,
334
+ settings: () => cfg, chains, matchRedirect, normalisePath, scanHtml, analyse,
335
+ fetchLocal: (urlPath, origin) => fetchLocal(fastify, urlPath, hostOf(origin)),
336
+ isRunning: () => Boolean(running), maxRules: MAX_RULES
337
+ };
338
+ }
339
+
340
+ function newId() {
341
+ return `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
342
+ }
343
+
344
+ /** Global (fastify-plugin): the redirect guard must see every request. */
345
+ export const seoPlugin = fp(seo, {name: 'core-seo'});