domma-cms 0.89.1 → 0.90.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/{plugins/seo/admin → admin/js}/templates/seo.html +1 -2
- package/admin/js/views/index.js +1 -1
- 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/seo.js +201 -0
- package/server/server.js +12 -1
- 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/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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO - the API (mounted at /api/seo behind toolGate('seo'); the `seo`
|
|
3
|
+
* permission: read / manage). The engine is services/seo/index.js.
|
|
4
|
+
*
|
|
5
|
+
* GET /overview · GET /count · GET /audit · POST /audit · GET|PUT /item
|
|
6
|
+
* GET|POST /redirects · PUT|DELETE /redirects/:id · POST /redirects/import
|
|
7
|
+
* GET /redirects/test · GET /sitemap · GET|PUT /settings · GET /extensions
|
|
8
|
+
*
|
|
9
|
+
* Was /api/plugins/seo while SEO was a plugin (0.89.0).
|
|
10
|
+
*/
|
|
11
|
+
import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
|
|
12
|
+
import {getConfig} from '../../config.js';
|
|
13
|
+
import {getPage, updatePage} from '../../services/content.js';
|
|
14
|
+
import {getSitemapSources} from '../../services/hooks.js';
|
|
15
|
+
import {seoEngine} from '../../services/seo/index.js';
|
|
16
|
+
import {excluded} from '../../../admin/js/lib/seo-settings.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
20
|
+
* @param {object} [opts] - authenticate / requirePermission overrides (tests)
|
|
21
|
+
*/
|
|
22
|
+
export async function seoRoutes(fastify, opts = {}) {
|
|
23
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
24
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
25
|
+
const may = (action) => ({preHandler: [authenticate, requirePermission('seo', action)]});
|
|
26
|
+
const seo = () => seoEngine();
|
|
27
|
+
const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
|
|
28
|
+
const off = (reply) => reply.code(503).send({error: 'SEO is not running on this site.'});
|
|
29
|
+
|
|
30
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
31
|
+
if (!seo()) return off(reply);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
fastify.get('/overview', may('read'), async (request) => {
|
|
35
|
+
const s = seo().summary();
|
|
36
|
+
const rules = seo().store.rules();
|
|
37
|
+
return {...s, redirects: rules.length, chains: seo().chains(rules).length, hooks: true,
|
|
38
|
+
origin: s.origin || seo().originOf(request), siteSeo: site().seo || {}};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
fastify.get('/count', may('read'), async () => {
|
|
42
|
+
const s = seo().summary();
|
|
43
|
+
if (!s.at) return {count: 0, label: 'Not checked yet'};
|
|
44
|
+
const bad = (seo().store.audit().results || []).filter(r => r.error || r.failing > 0);
|
|
45
|
+
return {
|
|
46
|
+
count: bad.length,
|
|
47
|
+
label: bad.length ? `${bad.length} ${bad.length === 1 ? 'page has' : 'pages have'} SEO problems` : 'No SEO problems found',
|
|
48
|
+
...(s.broken && {tone: 'danger'}),
|
|
49
|
+
details: {title: 'SEO', rows: [{label: 'Score', value: s.score === null ? '—' : `${s.score}%`}, {label: 'Pages', value: String(s.pages)}],
|
|
50
|
+
items: bad.slice(0, 5).map(r => ({text: r.urlPath, meta: r.error ? 'broken' : `${r.failing} failing`})),
|
|
51
|
+
empty: 'Every page passes.'}
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
fastify.get('/audit', may('read'), async () => {
|
|
56
|
+
const a = seo().store.audit();
|
|
57
|
+
return {...seo().summary(a), results: (a.results || []).map(r => ({...r, checks: undefined,
|
|
58
|
+
problems: (r.checks || []).filter(c => c.status === 'fail' || c.status === 'warn').map(c => c.title)}))};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
fastify.post('/audit', may('read'), async (request) => {
|
|
62
|
+
const p = seo().runAudit(seo().originOf(request));
|
|
63
|
+
if (request.body?.wait === true) await p;
|
|
64
|
+
return seo().summary();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
fastify.get('/item', may('read'), async (request, reply) => {
|
|
68
|
+
const e = seo();
|
|
69
|
+
const urlPath = e.normalisePath(request.query?.path || '') || '/';
|
|
70
|
+
const origin = e.originOf(request);
|
|
71
|
+
const page = await getPage(urlPath).catch(() => null);
|
|
72
|
+
const override = e.store.override(urlPath) || {};
|
|
73
|
+
const fields = page
|
|
74
|
+
? {title: page.seo?.title || '', description: page.seo?.description || '', image: page.seo?.image || '', noindex: page.seo?.noindex === true, focus: page.seo?.focus || ''}
|
|
75
|
+
: {title: override.title || '', description: override.description || '', image: override.image || '', noindex: override.noindex === true, focus: override.focus || ''};
|
|
76
|
+
const fetched = await e.fetchLocal(urlPath, origin).catch(err => ({status: 0, error: err.message}));
|
|
77
|
+
if (!page && fetched.status === 404) return reply.code(404).send({error: `Nothing is served at ${urlPath}.`});
|
|
78
|
+
// Only a page that is really served is judged - a draft answers with the 404 page.
|
|
79
|
+
const facts = fetched.status === 200 && fetched.html ? e.scanHtml(fetched.html) : null;
|
|
80
|
+
const limits = e.settings().audit;
|
|
81
|
+
const judged = facts ? e.analyse(facts, {urlPath, focus: fields.focus, limits, origin}) : null;
|
|
82
|
+
const s = site();
|
|
83
|
+
return {
|
|
84
|
+
urlPath, origin, kind: page ? 'page' : 'url',
|
|
85
|
+
page: page ? {title: page.title, status: page.status, description: page.description || ''} : null,
|
|
86
|
+
fields,
|
|
87
|
+
live: facts ? {title: facts.title, description: facts.description || '', image: facts.og.image, h1: facts.headings.find(h => h.level === 1)?.text || '', words: facts.words, canonical: facts.canonical} : null,
|
|
88
|
+
status: fetched.status || 0,
|
|
89
|
+
defaults: {separator: s.seo?.titleSeparator || ' | ', siteTitle: s.seo?.defaultTitle || s.title || '', description: s.seo?.defaultDescription || '', image: s.seo?.defaultImage || ''},
|
|
90
|
+
analysis: judged,
|
|
91
|
+
limits,
|
|
92
|
+
redirect: e.matchRedirect(e.store.rules(), urlPath)?.rule || null
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
fastify.put('/item', may('manage'), async (request, reply) => {
|
|
97
|
+
const e = seo();
|
|
98
|
+
const b = request.body ?? {};
|
|
99
|
+
const urlPath = e.normalisePath(b.path || '') || '/';
|
|
100
|
+
const clip = (v, n) => String(v ?? '').trim().slice(0, n);
|
|
101
|
+
const fields = {title: clip(b.title, 200), description: clip(b.description, 400), image: clip(b.image, 500), noindex: b.noindex === true, focus: clip(b.focus, 100)};
|
|
102
|
+
if (fields.image && !/^(https?:\/\/|\/)/i.test(fields.image)) return reply.code(400).send({error: 'The image: a path like /media/photo.jpg, or a full web address.'});
|
|
103
|
+
const page = await getPage(urlPath).catch(() => null);
|
|
104
|
+
if (page) {
|
|
105
|
+
// Empty means "use the default": sent as null, updatePage's merge removes the key.
|
|
106
|
+
const seoFields = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, v === '' || v === false ? null : v]));
|
|
107
|
+
await updatePage(urlPath, {seo: seoFields}, undefined, {author: request.user?.username || request.user?.email});
|
|
108
|
+
} else {
|
|
109
|
+
e.store.setOverride(urlPath, fields);
|
|
110
|
+
}
|
|
111
|
+
e.sitemapChanged();
|
|
112
|
+
await e.reaudit(urlPath, {origin: e.originOf(request), focus: fields.focus}).catch(() => {});
|
|
113
|
+
return {ok: true, kind: page ? 'page' : 'url'};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Redirects
|
|
117
|
+
fastify.get('/redirects', may('read'), async () => {
|
|
118
|
+
const rules = seo().store.rulesWithHits();
|
|
119
|
+
return {rules, chains: seo().chains(rules).map(c => ({id: c.rule.id, via: c.via.id})), max: seo().maxRules, onRename: seo().settings().redirects.onRename};
|
|
120
|
+
});
|
|
121
|
+
fastify.post('/redirects', may('manage'), async (request, reply) => {
|
|
122
|
+
const r = seo().addRule(request.body ?? {});
|
|
123
|
+
if (r.errors) return reply.code(400).send({error: Object.values(r.errors).join(' '), errors: r.errors});
|
|
124
|
+
return r;
|
|
125
|
+
});
|
|
126
|
+
fastify.put('/redirects/:id', may('manage'), async (request, reply) => {
|
|
127
|
+
const r = seo().updateRule(request.params.id, request.body ?? {});
|
|
128
|
+
if (r.missing) return reply.code(404).send({error: 'No such redirect.'});
|
|
129
|
+
if (r.errors) return reply.code(400).send({error: Object.values(r.errors).join(' '), errors: r.errors});
|
|
130
|
+
return r;
|
|
131
|
+
});
|
|
132
|
+
fastify.delete('/redirects/:id', may('manage'), async (request) => {
|
|
133
|
+
seo().removeRules(String(request.params.id).split(','));
|
|
134
|
+
return {ok: true};
|
|
135
|
+
});
|
|
136
|
+
fastify.post('/redirects/import', may('manage'), async (request) => {
|
|
137
|
+
const text = String(request.body?.csv ?? '').slice(0, 1_000_000);
|
|
138
|
+
let added = 0;
|
|
139
|
+
const skipped = [];
|
|
140
|
+
for (const [i, line] of text.split(/\r?\n/).entries()) {
|
|
141
|
+
const cells = line.split(/[,\t]/).map(c => c.trim().replace(/^"|"$/g, ''));
|
|
142
|
+
if (!cells[0] || cells[0].startsWith('#') || /^(from|source|old)/i.test(cells[0])) continue;
|
|
143
|
+
const r = seo().addRule({from: cells[0], to: cells[1], code: cells[2] || 301, note: cells[3] || 'Imported'});
|
|
144
|
+
if (r.errors) skipped.push({line: i + 1, from: cells[0], why: Object.values(r.errors).join(' ')});
|
|
145
|
+
else added++;
|
|
146
|
+
if (added + skipped.length >= 2000) break;
|
|
147
|
+
}
|
|
148
|
+
return {added, skipped};
|
|
149
|
+
});
|
|
150
|
+
fastify.get('/redirects/test', may('read'), async (request) => {
|
|
151
|
+
const hops = [];
|
|
152
|
+
let at = String(request.query?.path || '');
|
|
153
|
+
const rules = seo().store.rules();
|
|
154
|
+
for (let i = 0; i < 10 && at; i++) {
|
|
155
|
+
const hit = seo().matchRedirect(rules, at);
|
|
156
|
+
if (!hit) break;
|
|
157
|
+
hops.push({from: at, to: hit.location, code: hit.code, id: hit.rule.id});
|
|
158
|
+
if (hit.code === 410 || /^https?:/i.test(hit.location)) break;
|
|
159
|
+
at = hit.location;
|
|
160
|
+
}
|
|
161
|
+
return {hops};
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Sitemap and robots
|
|
165
|
+
fastify.get('/sitemap', may('read'), async (request) => {
|
|
166
|
+
const e = seo();
|
|
167
|
+
const cfg = e.settings();
|
|
168
|
+
const origin = e.originOf(request);
|
|
169
|
+
const {listSitemapEntries, buildRobotsTxt} = await import('../../services/sitemap.js');
|
|
170
|
+
const entries = await listSitemapEntries(origin, {log: fastify.log});
|
|
171
|
+
const raw = await e.rawEntries(origin);
|
|
172
|
+
const kept = new Set(entries.map(x => x.urlPath));
|
|
173
|
+
const sources = {};
|
|
174
|
+
for (const x of raw) {
|
|
175
|
+
sources[x.source] ??= {id: x.source, count: 0, listed: 0, on: cfg.sitemap.sources[x.source] !== false};
|
|
176
|
+
sources[x.source].count++;
|
|
177
|
+
if (kept.has(x.urlPath)) sources[x.source].listed++;
|
|
178
|
+
}
|
|
179
|
+
const rules = e.store.rules();
|
|
180
|
+
const over = e.store.overrides();
|
|
181
|
+
const left = raw.filter(x => !kept.has(x.urlPath)).map(x => ({...x,
|
|
182
|
+
why: cfg.sitemap.sources[x.source] === false ? 'Its source is switched off'
|
|
183
|
+
: excluded(x.urlPath, cfg.sitemap.exclude) ? 'Excluded in the settings'
|
|
184
|
+
: over[x.urlPath]?.noindex ? 'Hidden from search engines'
|
|
185
|
+
: e.matchRedirect(rules, x.urlPath) ? 'Redirected' : 'Left out'}));
|
|
186
|
+
return {hooks: true, origin, entries, left, sources: Object.values(sources),
|
|
187
|
+
labels: {pages: 'Pages', ...Object.fromEntries(getSitemapSources().map(s => [s.id, s.label]))},
|
|
188
|
+
robots: buildRobotsTxt(origin)};
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
fastify.get('/settings', may('read'), async () => ({settings: seo().settings(), hooks: true}));
|
|
192
|
+
fastify.put('/settings', may('manage'), async (request, reply) => {
|
|
193
|
+
const r = seo().saveSettings(request.body ?? {});
|
|
194
|
+
if (r.errors) return reply.code(400).send({error: Object.values(r.errors).join(' '), errors: r.errors});
|
|
195
|
+
return {settings: r.settings, hooks: true};
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
fastify.get('/extensions', may('read'), async () => ({
|
|
199
|
+
tabs: seo().registry.list().flatMap(x => x.tabs.map(t => ({...t, owner: x.owner, ownerLabel: x.label})))
|
|
200
|
+
}));
|
|
201
|
+
}
|
package/server/server.js
CHANGED
|
@@ -491,6 +491,7 @@ const { notesRoutes } = await import('./routes/api/notes.js');
|
|
|
491
491
|
const { todoRoutes } = await import('./routes/api/todo.js');
|
|
492
492
|
const { analyticsRoutes } = await import('./routes/api/analytics.js');
|
|
493
493
|
const { contactsRoutes } = await import('./routes/api/contacts.js');
|
|
494
|
+
const { seoRoutes } = await import('./routes/api/seo.js');
|
|
494
495
|
const { relatedRoutes } = await import('./routes/api/related.js');
|
|
495
496
|
const { pluginMarketplaceRoutes } = await import('./routes/api/plugin-marketplace.js');
|
|
496
497
|
const { collectionsRoutes } = await import('./routes/api/collections.js');
|
|
@@ -548,6 +549,15 @@ await app.register(toolGate('notes', notesRoutes), {prefix: '/api/notes'});
|
|
|
548
549
|
await app.register(toolGate('todo', todoRoutes), {prefix: '/api/todo'});
|
|
549
550
|
await app.register(toolGate('analytics', analyticsRoutes), {prefix: '/api/analytics'});
|
|
550
551
|
await app.register(toolGate('contacts', contactsRoutes), {prefix: '/api/contacts'});
|
|
552
|
+
// SEO (0.90.0; the `seo` plugin in 0.89.0): a global plugin - its redirect guard
|
|
553
|
+
// sees every request - plus its API. Off, the engine does nothing at all.
|
|
554
|
+
{
|
|
555
|
+
const {seoPlugin} = await import('./services/seo/index.js');
|
|
556
|
+
const {getBaseUrl} = await import('./routes/public.js');
|
|
557
|
+
const rolesService = await import('./services/roles.js');
|
|
558
|
+
await app.register(seoPlugin, {enabled: isToolEnabled('seo'), getBaseUrl, rolesService});
|
|
559
|
+
}
|
|
560
|
+
await app.register(toolGate('seo', seoRoutes), {prefix: '/api/seo'});
|
|
551
561
|
await app.register(toolsRoutes, {prefix: '/api'});
|
|
552
562
|
// What plugins show beside a contact or a note (Calendar's Events) - 0.79.
|
|
553
563
|
await app.register(relatedRoutes, {prefix: '/api/related'});
|
|
@@ -576,7 +586,8 @@ const coreToolItems = [
|
|
|
576
586
|
{text: 'Notes', url: '#/notes', icon: 'edit-3', permission: 'notes', countUrl: '/api/notes/count'},
|
|
577
587
|
{text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'},
|
|
578
588
|
{text: 'Analytics', url: '#/analytics', icon: 'chart-bar', permission: 'analytics', countUrl: '/api/analytics/active-count'},
|
|
579
|
-
{text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'}
|
|
589
|
+
{text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'},
|
|
590
|
+
{text: 'SEO', url: '#/seo', icon: 'search', permission: 'seo', countUrl: '/api/seo/count'}
|
|
580
591
|
];
|
|
581
592
|
for (const item of coreToolItems) {
|
|
582
593
|
if (isToolEnabled(item.permission)) registerSidebarItem({item: {source: 'Built in', ...item}});
|
|
@@ -226,6 +226,17 @@ export const REGISTRY = [
|
|
|
226
226
|
{key: 'delete', label: 'Reset', description: 'Reset all counters'}
|
|
227
227
|
]
|
|
228
228
|
},
|
|
229
|
+
{
|
|
230
|
+
key: 'seo',
|
|
231
|
+
label: 'SEO',
|
|
232
|
+
description: 'How the site looks to search engines: the audit, page titles and descriptions, redirects, the sitemap and robots.txt.',
|
|
233
|
+
icon: 'search',
|
|
234
|
+
group: 'Tools',
|
|
235
|
+
actions: [
|
|
236
|
+
{key: 'read', label: 'View', description: 'See the audit, redirects and sitemap; run the audit'},
|
|
237
|
+
{key: 'manage', label: 'Manage', description: 'Change page titles and descriptions, redirects and settings'}
|
|
238
|
+
]
|
|
239
|
+
},
|
|
229
240
|
{
|
|
230
241
|
key: 'notes',
|
|
231
242
|
label: 'Notes',
|
|
@@ -165,7 +165,7 @@ export function isCorePlugin(name) { return CORE_PLUGINS.has(name); }
|
|
|
165
165
|
* same data - and, like supersession, nothing is written down: a lapsed
|
|
166
166
|
* licence means the plugin does not load and the core Tool is simply back.
|
|
167
167
|
*/
|
|
168
|
-
export const BUILT_IN_TOOLS = new Set(['notes', 'todo', 'analytics', 'contacts']);
|
|
168
|
+
export const BUILT_IN_TOOLS = new Set(['notes', 'todo', 'analytics', 'contacts', 'seo']);
|
|
169
169
|
|
|
170
170
|
/** Built-in Tool → {plugin, route}, for the plugins that loaded this boot. */
|
|
171
171
|
const _toolTakeovers = {};
|
|
@@ -196,6 +196,16 @@ export function toolTakeoversOf(manifest, taken = {}) {
|
|
|
196
196
|
return out;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Plugins that became part of the CMS. A site updated from an engine that
|
|
201
|
+
* shipped one still has its folder; it is never loaded again (it would run
|
|
202
|
+
* beside the built-in copy - two redirect guards, two sets of tags) and its
|
|
203
|
+
* data is carried over by the built-in feature.
|
|
204
|
+
*/
|
|
205
|
+
export const RETIRED_PLUGINS = Object.freeze({
|
|
206
|
+
seo: 'Built in since 0.90.0 (server/services/seo) - its data moves to content/seo and config/seo.json.'
|
|
207
|
+
});
|
|
208
|
+
|
|
199
209
|
/**
|
|
200
210
|
* Scan the plugins/ directory and return all valid manifests.
|
|
201
211
|
* Validates mandatory fields and required files (plugin.js, config.js).
|
|
@@ -212,7 +222,7 @@ export async function discoverPlugins() {
|
|
|
212
222
|
}
|
|
213
223
|
|
|
214
224
|
const plugins = [];
|
|
215
|
-
for (const entry of entries.filter(e => e.isDirectory() && !e.name.startsWith('_'))) {
|
|
225
|
+
for (const entry of entries.filter(e => e.isDirectory() && !e.name.startsWith('_') && !RETIRED_PLUGINS[e.name])) {
|
|
216
226
|
const dir = entry.name;
|
|
217
227
|
const pluginDir = path.join(PLUGINS_DIR, dir);
|
|
218
228
|
const manifestPath = path.join(pluginDir, 'plugin.json');
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* SEO - the audit: every URL in the sitemap (and every published page hidden
|
|
3
3
|
* from search engines), fetched INSIDE the process with fastify.inject - no
|
|
4
4
|
* network, no certificate, the same HTML a visitor gets - and judged by
|
|
5
|
-
* admin/lib/analyse.js.
|
|
5
|
+
* admin/js/lib/seo-analyse.js.
|
|
6
6
|
*
|
|
7
7
|
* The requests carry `user-agent: DommaSEO/1 (audit)` and `x-domma-seo: audit`
|
|
8
8
|
* so analytics and SEO Pro's 404 log can leave them out.
|
|
9
9
|
*/
|
|
10
|
-
import {analyse, scanHtml} from '
|
|
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'});
|