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.
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/app.js +2 -2
- package/admin/js/lib/page-editor-arrange.js +1 -1
- package/admin/js/lib/page-editor-reference.js +153 -0
- package/admin/js/lib/seo-analyse.js +1 -0
- package/admin/js/lib/seo-redirects.js +1 -0
- package/admin/js/lib/seo-schema.js +2 -0
- package/admin/js/lib/seo-settings.js +2 -0
- package/admin/js/templates/page-editor.html +119 -80
- package/{plugins/seo/admin → admin/js}/templates/seo.html +1 -2
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/page-editor.js +62 -90
- package/admin/js/views/seo.js +144 -0
- package/package.json +2 -2
- package/plugins/free-tier.lock.json +0 -19
- package/server/routes/api/pages.js +10 -13
- package/server/routes/api/seo.js +201 -0
- package/server/server.js +12 -1
- package/server/services/menus.js +43 -0
- package/server/services/permissionRegistry.js +11 -0
- package/server/services/plugins.js +12 -2
- package/{plugins/seo/server → server/services/seo}/audit.js +2 -2
- package/server/services/seo/index.js +345 -0
- package/{plugins/seo/server → server/services/seo}/registry.js +5 -3
- package/server/services/seo/store.js +110 -0
- package/server/services/siteGitignore.js +4 -1
- package/server/services/tools.js +2 -1
- package/admin/js/templates/navigation.html +0 -226
- package/admin/js/views/navigation.js +0 -23
- package/plugins/seo/CLAUDE.md +0 -48
- package/plugins/seo/admin/lib/analyse.js +0 -286
- package/plugins/seo/admin/lib/redirects.js +0 -163
- package/plugins/seo/admin/lib/schema.js +0 -113
- package/plugins/seo/admin/lib/settings.js +0 -141
- package/plugins/seo/admin/views/seo.js +0 -912
- package/plugins/seo/config.js +0 -7
- package/plugins/seo/plugin.js +0 -477
- package/plugins/seo/plugin.json +0 -64
- package/plugins/seo/server/store.js +0 -76
- 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 '
|
|
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
|
|
3
|
-
* replacing it (the Shopping Cart's pattern
|
|
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
|
|
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*',
|
package/server/services/tools.js
CHANGED
|
@@ -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));
|