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
@@ -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'});
@@ -1,9 +1,10 @@
1
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).
2
+ * SEO's door for extensions - how SEO Pro adds to the built-in SEO without
3
+ * replacing it (the Shopping Cart's pattern). The same registry the SEO plugin
4
+ * had before SEO was built in (0.90.0), so SEO Pro works with either.
4
5
  *
5
6
  * 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
+ * it WITHOUT importing a core file. An extension registers from its
7
8
  * `onReady` hook, when every plugin has loaded:
8
9
  *
9
10
  * const seo = globalThis[Symbol.for('domma.seo')];
@@ -16,6 +17,7 @@
16
17
  * and reads through `seo.services` (listEntries, auditList, fetchPage,
17
18
  * scanHtml, analyse, audit {latest, summary, run}, redirects {list, match, add}).
18
19
  * A lapsed licence means the extension never loads and SEO carries on alone.
20
+ * A tab's `module` is a `/plugins/<slug>/...` URL exporting mount(root, ctx).
19
21
  */
20
22
  export const KEY = Symbol.for('domma.seo');
21
23
  export const VERSION = 1;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * SEO - its data. Held in memory (the redirect guard and the 'seo:meta'
3
+ * transform run on every request and must not touch the disk) and written back
4
+ * a moment after each change; a file changed on disk (a restore, a pull of the
5
+ * site's repo) is read again within a few seconds.
6
+ *
7
+ * In content/seo/ - authored, so tracked by the site's repo:
8
+ * redirects.json {rules: [{id, from, to, code, note, auto, at}]} see admin/js/lib/seo-redirects.js
9
+ * overrides.json {"/path": {title, description, image, noindex, focus}}
10
+ * Runtime state, kept out of the repo (services/siteGitignore.js):
11
+ * hits.json {ruleId: {hits, lastHit}} - kept apart so a visit never dirties redirects.json
12
+ * audit.json {at, origin, results: [...]}
13
+ *
14
+ * @module services/seo/store
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ /** Where SEO keeps its files - resolved per call, as analyticsDir() is. */
20
+ export const seoDir = () => path.resolve('content', 'seo');
21
+
22
+ function jsonFile(file, empty, {pretty = true} = {}) {
23
+ let value = read();
24
+ let mtime = stat();
25
+ let timer = null;
26
+ let checked = Date.now();
27
+
28
+ function stat() { try { return fs.statSync(file).mtimeMs; } catch { return 0; } }
29
+ function read() {
30
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return structuredClone(empty); }
31
+ }
32
+ function flush() {
33
+ timer = null;
34
+ try {
35
+ fs.mkdirSync(path.dirname(file), {recursive: true});
36
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(value, null, pretty ? 2 : 0) + '\n');
37
+ fs.renameSync(`${file}.tmp`, file);
38
+ mtime = stat();
39
+ } catch { /* tried again with the next change */ }
40
+ }
41
+ return {
42
+ get() {
43
+ const now = Date.now();
44
+ if (!timer && now - checked > 3000) {
45
+ checked = now;
46
+ const m = stat();
47
+ if (m && m !== mtime) { value = read(); mtime = m; }
48
+ }
49
+ return value;
50
+ },
51
+ set(next, {soon = 500} = {}) {
52
+ value = next;
53
+ if (timer) clearTimeout(timer);
54
+ timer = setTimeout(flush, soon);
55
+ timer.unref?.();
56
+ return value;
57
+ },
58
+ flush() { if (timer) { clearTimeout(timer); flush(); } }
59
+ };
60
+ }
61
+
62
+ /**
63
+ * @param {string} [dir] - defaults to content/seo
64
+ */
65
+ export function createStore(dir = seoDir()) {
66
+ const redirects = jsonFile(path.join(dir, 'redirects.json'), {rules: []});
67
+ const hits = jsonFile(path.join(dir, 'hits.json'), {}, {pretty: false});
68
+ const overrides = jsonFile(path.join(dir, 'overrides.json'), {});
69
+ const audit = jsonFile(path.join(dir, 'audit.json'), {at: null, origin: '', results: []}, {pretty: false});
70
+ const rulesNow = () => (Array.isArray(redirects.get()?.rules) ? redirects.get().rules : []);
71
+ let hitsDirty = false;
72
+ return {
73
+ /** The rules as stored (no counters) - what matching uses. */
74
+ rules: rulesNow,
75
+ /** The rules with their counters, for the screen and SEO Pro. */
76
+ rulesWithHits() {
77
+ const h = hits.get() || {};
78
+ return rulesNow().map(r => ({...r, hits: h[r.id]?.hits || 0, lastHit: h[r.id]?.lastHit || null}));
79
+ },
80
+ setRules(rules) {
81
+ const clean = rules.map(({hits: _h, lastHit: _l, ...r}) => r);
82
+ redirects.set({rules: clean});
83
+ const ids = new Set(clean.map(r => r.id));
84
+ const h = hits.get() || {};
85
+ if (Object.keys(h).some(id => !ids.has(id))) hits.set(Object.fromEntries(Object.entries(h).filter(([id]) => ids.has(id))));
86
+ },
87
+ /** One use of a rule - in memory; saved by saveHits() once a minute. */
88
+ countHit(id) {
89
+ const h = hits.get() || {};
90
+ const e = h[id] ||= {hits: 0, lastHit: null};
91
+ e.hits++;
92
+ e.lastHit = new Date().toISOString();
93
+ hitsDirty = true;
94
+ },
95
+ saveHits() { if (hitsDirty) { hitsDirty = false; hits.set(hits.get(), {soon: 0}); } },
96
+ overrides: () => overrides.get() || {},
97
+ override: (urlPath) => overrides.get()?.[urlPath] || null,
98
+ setOverride(urlPath, fields) {
99
+ const all = {...overrides.get()};
100
+ const clean = Object.fromEntries(Object.entries(fields || {}).filter(([, v]) => v !== '' && v !== null && v !== undefined && v !== false));
101
+ if (Object.keys(clean).length) all[urlPath] = clean;
102
+ else delete all[urlPath];
103
+ overrides.set(all);
104
+ return all[urlPath] || null;
105
+ },
106
+ audit: () => audit.get(),
107
+ setAudit: (a) => audit.set(a, {soon: 100}),
108
+ close() { this.saveHits(); redirects.flush(); hits.flush(); overrides.flush(); audit.flush(); }
109
+ };
110
+ }
@@ -49,7 +49,7 @@ const SITE_ROOTS = ['content', 'config', 'plugins'];
49
49
  */
50
50
  export const RETIRED_PLUGINS = [
51
51
  'analytics', 'contacts', 'notes', 'todo', 'demo-viewer', 'theme-switcher', 'site-search',
52
- 'seo-pro', 'shopping-cart', 'form-builder'
52
+ 'seo', 'seo-pro', 'shopping-cart', 'form-builder'
53
53
  ];
54
54
 
55
55
  /**
@@ -309,6 +309,9 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
309
309
  '/content/sessions/',
310
310
  '/content/versions/',
311
311
  '/content/analytics/',
312
+ // SEO's runtime state; its redirects and overrides beside them are authored, and tracked.
313
+ '/content/seo/audit.json',
314
+ '/content/seo/hits.json',
312
315
  '/content/instances_log.json',
313
316
  '/content/preview-links.json',
314
317
  '/config/connections.json*',
@@ -46,7 +46,8 @@ export const CORE_TOOLS = Object.freeze([
46
46
  {name: 'contacts', displayName: 'Contacts', icon: 'users', description: 'An address book for each user, with groups.'},
47
47
  {name: 'notes', displayName: 'Notes', icon: 'edit-3', description: 'Private notes for each user.'},
48
48
  {name: 'todo', displayName: 'Todo', icon: 'check-square', description: 'To-do lists with due dates and reminders.'},
49
- {name: 'analytics', displayName: 'Analytics', icon: 'chart-bar', description: 'Page views and visitors, counted on this server.'}
49
+ {name: 'analytics', displayName: 'Analytics', icon: 'chart-bar', description: 'Page views and visitors, counted on this server.'},
50
+ {name: 'seo', displayName: 'SEO', icon: 'search', description: 'How the site looks to search engines: an audit of every page, redirects, the sitemap and robots.txt.'}
50
51
  ]);
51
52
 
52
53
  const CORE_NAMES = new Set(CORE_TOOLS.map(t => t.name));