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