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