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.
Files changed (33) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/seo-analyse.js +1 -0
  5. package/admin/js/lib/seo-redirects.js +1 -0
  6. package/admin/js/lib/seo-schema.js +2 -0
  7. package/admin/js/lib/seo-settings.js +2 -0
  8. package/{plugins/seo/admin → admin/js}/templates/seo.html +1 -2
  9. package/admin/js/views/index.js +1 -1
  10. package/admin/js/views/seo.js +144 -0
  11. package/package.json +2 -2
  12. package/plugins/free-tier.lock.json +0 -19
  13. package/server/routes/api/seo.js +201 -0
  14. package/server/server.js +12 -1
  15. package/server/services/permissionRegistry.js +11 -0
  16. package/server/services/plugins.js +12 -2
  17. package/{plugins/seo/server → server/services/seo}/audit.js +2 -2
  18. package/server/services/seo/index.js +345 -0
  19. package/{plugins/seo/server → server/services/seo}/registry.js +5 -3
  20. package/server/services/seo/store.js +110 -0
  21. package/server/services/siteGitignore.js +4 -1
  22. package/server/services/tools.js +2 -1
  23. package/plugins/seo/CLAUDE.md +0 -48
  24. package/plugins/seo/admin/lib/analyse.js +0 -286
  25. package/plugins/seo/admin/lib/redirects.js +0 -163
  26. package/plugins/seo/admin/lib/schema.js +0 -113
  27. package/plugins/seo/admin/lib/settings.js +0 -141
  28. package/plugins/seo/admin/views/seo.js +0 -912
  29. package/plugins/seo/config.js +0 -7
  30. package/plugins/seo/plugin.js +0 -477
  31. package/plugins/seo/plugin.json +0 -64
  32. package/plugins/seo/server/store.js +0 -76
  33. package/plugins/seo/tests/seo.test.js +0 -192
@@ -1,163 +0,0 @@
1
- /**
2
- * SEO - redirects, pure. Used by the server (matching every request) and the
3
- * admin screen (checking a rule before it is sent).
4
- *
5
- * A rule: {id, from, to, code: 301|302|307|308|410, hits, lastHit, note, auto, at}
6
- * - `from` is a path: "/old-page", or a prefix ending in "/*": "/news/*".
7
- * - `to` is a path or a full http(s) URL; a "*" in it takes what the prefix
8
- * matched. 410 ("gone") has no `to`.
9
- * Paths compare without a trailing slash and without case, and the query string
10
- * travels with the visitor unless `to` has one of its own.
11
- *
12
- * @module seo/admin/lib/redirects
13
- */
14
-
15
- export const CODES = [301, 302, 307, 308, 410];
16
- export const MAX_RULES = 5000;
17
-
18
- /** "/About/" -> "/about"; "" -> ""; keeps "/" as "/". */
19
- export function normalisePath(p) {
20
- let s = String(p ?? '').trim();
21
- if (!s) return '';
22
- try { s = decodeURI(s); } catch { /* keep as typed */ }
23
- s = s.split('#')[0].split('?')[0];
24
- if (!s.startsWith('/')) s = `/${s}`;
25
- s = s.replace(/\/{2,}/g, '/');
26
- if (s.length > 1) s = s.replace(/\/+$/, '');
27
- return s.toLowerCase();
28
- }
29
-
30
- const isWildcard = (from) => String(from).endsWith('/*');
31
- const isAbsolute = (to) => /^https?:\/\//i.test(String(to));
32
-
33
- /**
34
- * Check one rule. `others` are the rules already there (minus this one, when
35
- * editing), for duplicates and loops.
36
- *
37
- * @returns {{errors: Record<string, string>, rule: object}}
38
- */
39
- export function checkRule(input = {}, others = []) {
40
- const errors = {};
41
- const code = Number(input.code ?? 301);
42
- const fromRaw = String(input.from ?? '').trim();
43
- const wildcard = isWildcard(fromRaw);
44
- const from = wildcard ? `${normalisePath(fromRaw.slice(0, -2)) === '/' ? '' : normalisePath(fromRaw.slice(0, -2))}/*` : normalisePath(fromRaw);
45
- let to = String(input.to ?? '').trim();
46
-
47
- if (!CODES.includes(code)) errors.code = 'Choose 301, 302, 307, 308 or 410.';
48
- if (!fromRaw) errors.from = 'Say which address to redirect.';
49
- else if (/^https?:\/\//i.test(fromRaw)) errors.from = 'The address to redirect is a path on this site, like /old-page.';
50
- else if (/^\/(admin|api)(\/|$)/.test(from)) errors.from = 'The admin and the API cannot be redirected.';
51
- else if (from === '/*') errors.from = 'That would redirect the whole site.';
52
-
53
- if (code === 410) {
54
- to = '';
55
- } else if (!to) {
56
- errors.to = 'Say where it goes.';
57
- } else if (isAbsolute(to)) {
58
- try { new URL(to.replace('*', 'x')); } catch { errors.to = 'Not a web address.'; }
59
- } else if (/^[a-z][a-z0-9+.-]*:/i.test(to) || to.startsWith('//')) {
60
- errors.to = 'A path on this site (/new-page) or an http(s) address.';
61
- } else {
62
- const [p, q] = to.split('?');
63
- to = normalisePath(p) + (q ? `?${q}` : '');
64
- if (to.includes('*') && !wildcard) errors.to = 'A "*" in the target needs a "/*" rule to fill it.';
65
- }
66
-
67
- if (!errors.from && !errors.to && code !== 410 && !isAbsolute(to)) {
68
- const target = normalisePath(to.split('?')[0]);
69
- if (!wildcard && target === from) errors.to = 'It would redirect to itself.';
70
- else if (!wildcard && loopsBack(from, target, others)) errors.to = 'That makes a loop with another redirect.';
71
- }
72
- if (!errors.from && others.some(r => r.from === from)) errors.from = `There is already a redirect from ${from}.`;
73
-
74
- return {
75
- errors,
76
- rule: {
77
- from,
78
- to,
79
- code,
80
- note: String(input.note ?? '').trim().slice(0, 200),
81
- ...(input.auto === true && {auto: true})
82
- }
83
- };
84
- }
85
-
86
- /** Would following `target` through `rules` come back to `from`? */
87
- function loopsBack(from, target, rules) {
88
- const seen = new Set([from]);
89
- let at = target;
90
- for (let i = 0; i < 20; i++) {
91
- if (seen.has(at)) return true;
92
- seen.add(at);
93
- const next = matchRedirect(rules, at);
94
- if (!next || !next.location || isAbsolute(next.location)) return false;
95
- at = normalisePath(next.location.split('?')[0]);
96
- }
97
- return true;
98
- }
99
-
100
- /**
101
- * The rule for a request path, exact rules before prefixes (the longest
102
- * prefix wins). Returns null when none applies.
103
- *
104
- * @param {object[]} rules
105
- * @param {string} rawPath - the request path, query included or not
106
- * @returns {{rule: object, location: string, code: number}|null}
107
- */
108
- export function matchRedirect(rules, rawPath) {
109
- const [pathPart, query] = String(rawPath ?? '').split('?');
110
- const p = normalisePath(pathPart);
111
- if (!p) return null;
112
- let hit = null;
113
- let rest = '';
114
- for (const r of rules) {
115
- if (r.from === p) { hit = r; rest = ''; break; }
116
- }
117
- if (!hit) {
118
- let best = -1;
119
- for (const r of rules) {
120
- if (!isWildcard(r.from)) continue;
121
- const prefix = r.from.slice(0, -2);
122
- if ((p === prefix || p.startsWith(`${prefix}/`)) && prefix.length > best) {
123
- best = prefix.length;
124
- hit = r;
125
- rest = p.slice(prefix.length + 1);
126
- }
127
- }
128
- }
129
- if (!hit) return null;
130
- if (hit.code === 410) return {rule: hit, location: '', code: 410};
131
- let location = hit.to.includes('*') ? hit.to.replace('*', rest) : hit.to;
132
- if (!isAbsolute(location)) location = location.replace(/\/{2,}/g, '/').replace(/(.)\/$/, '$1');
133
- if (query && !location.includes('?')) location += `?${query}`;
134
- return {rule: hit, location, code: hit.code};
135
- }
136
-
137
- /**
138
- * A page moved from `from` to `to`: the redirect to add, and every existing
139
- * rule that pointed at `from` re-aimed at `to` (no chains). A rule FROM the
140
- * new address is removed - the page lives there now. Pure: returns a new list.
141
- */
142
- export function onMoved(rules, from, to, {now = new Date().toISOString(), id} = {}) {
143
- const f = normalisePath(from);
144
- const t = normalisePath(to);
145
- if (!f || !t || f === t) return rules;
146
- const out = rules
147
- .filter(r => r.from !== t)
148
- .map(r => (normalisePath(String(r.to).split('?')[0]) === f && !isAbsolute(r.to) ? {...r, to: t} : r));
149
- const existing = out.find(r => r.from === f);
150
- if (existing) return out.map(r => (r === existing ? {...r, to: t, code: 301} : r));
151
- return [...out, {id: id || `r${Date.now().toString(36)}`, from: f, to: t, code: 301, note: 'The page moved', auto: true, hits: 0, lastHit: null, at: now}];
152
- }
153
-
154
- /** Redirects that lead to another redirect (A -> B -> C): [{rule, via}]. */
155
- export function chains(rules) {
156
- const out = [];
157
- for (const r of rules) {
158
- if (r.code === 410 || isAbsolute(r.to) || isWildcard(r.from)) continue;
159
- const next = matchRedirect(rules, r.to);
160
- if (next && next.rule !== r) out.push({rule: r, via: next.rule});
161
- }
162
- return out;
163
- }
@@ -1,113 +0,0 @@
1
- /**
2
- * SEO - structured data (JSON-LD) and the 'seo:meta' changes this plugin
3
- * makes, pure: the server's transform calls applySeo() on every render.
4
- *
5
- * @module seo/admin/lib/schema
6
- */
7
-
8
- const abs = (u, origin) => {
9
- const s = String(u || '').trim();
10
- if (!s || /^https?:\/\//i.test(s) || !origin) return s;
11
- return `${origin}${s.startsWith('/') ? '' : '/'}${s}`;
12
- };
13
-
14
- /** Organization / LocalBusiness / Person for the home page, or null when off or unnamed. */
15
- export function organisationLd(org, {origin = '', siteTitle = ''} = {}) {
16
- if (!org?.enabled) return null;
17
- const name = org.name || siteTitle;
18
- if (!name) return null;
19
- const ld = {'@context': 'https://schema.org', '@type': org.type || 'Organization', name};
20
- const url = org.url || origin;
21
- if (url) ld.url = abs(url, origin);
22
- if (org.logo) ld[org.type === 'Person' ? 'image' : 'logo'] = abs(org.logo, origin);
23
- if (org.email) ld.email = org.email;
24
- if (org.phone) ld.telephone = org.phone;
25
- if (org.sameAs?.length) ld.sameAs = org.sameAs;
26
- const a = org.address || {};
27
- if (org.type !== 'Person' && (a.street || a.locality || a.postcode)) {
28
- ld.address = {'@type': 'PostalAddress',
29
- ...(a.street && {streetAddress: a.street}), ...(a.locality && {addressLocality: a.locality}),
30
- ...(a.region && {addressRegion: a.region}), ...(a.postcode && {postalCode: a.postcode}),
31
- ...(a.country && {addressCountry: a.country})};
32
- }
33
- return ld;
34
- }
35
-
36
- /** BlogPosting for a blog post (the render says `item.type === 'blog:post'`). */
37
- export function articleLd(meta, {origin = '', publisher = ''} = {}) {
38
- const ld = {'@context': 'https://schema.org', '@type': 'BlogPosting', headline: String(meta.title || '').slice(0, 110)};
39
- if (meta.description) ld.description = meta.description;
40
- if (meta.image) ld.image = abs(meta.image, origin);
41
- if (meta.canonical) {
42
- ld.url = meta.canonical;
43
- ld.mainEntityOfPage = meta.canonical;
44
- }
45
- if (publisher) ld.publisher = {'@type': 'Organization', name: publisher};
46
- return ld;
47
- }
48
-
49
- /**
50
- * The 'seo:meta' transform's work, on a copy of core's model.
51
- *
52
- * @param {object} meta - core's model (renderer.js buildSeoMeta)
53
- * @param {object} ctx
54
- * @param {object} ctx.page - {urlPath, item?}
55
- * @param {'page'|'plugin'} ctx.kind
56
- * @param {string} ctx.origin - the request's origin, no trailing slash
57
- * @param {string} ctx.siteTitle
58
- * @param {object} ctx.settings - SEO settings (mergeDefaults)
59
- * @param {object|null} ctx.override - this URL's overrides {title, description, image, noindex}
60
- * @returns {object}
61
- */
62
- export function applySeo(meta, {page, kind, origin = '', siteTitle = '', settings, override = null}) {
63
- const out = {...meta, jsonLd: [...(meta.jsonLd || [])], extra: [...(meta.extra || [])]};
64
-
65
- // A URL's own overrides - for plugin pages (a blog post, a listing) whose
66
- // fields this plugin cannot edit in place. Core pages are edited in place.
67
- if (override) {
68
- if (override.title) out.title = override.title;
69
- if (override.description) out.description = override.description;
70
- if (override.image) out.image = abs(override.image, origin);
71
- if (override.noindex === true) out.noindex = true;
72
- const webPage = out.jsonLd.find(x => x?.['@type'] === 'WebPage');
73
- if (webPage) {
74
- const i = out.jsonLd.indexOf(webPage);
75
- out.jsonLd[i] = {...webPage, ...(override.description && {description: override.description}), ...(out.image && {image: out.image})};
76
- }
77
- }
78
-
79
- const urlPath = page?.urlPath || '/';
80
- if (urlPath === '/') {
81
- const org = organisationLd(settings?.organisation, {origin, siteTitle});
82
- if (org) out.jsonLd.push(org);
83
- }
84
- if (settings?.articles !== false && kind === 'plugin' && page?.item?.type === 'blog:post') {
85
- out.ogType = 'article';
86
- out.jsonLd.push(articleLd(out, {origin, publisher: settings?.organisation?.enabled ? settings.organisation.name : siteTitle}));
87
- }
88
- return out;
89
- }
90
-
91
- /**
92
- * robots.txt lines, the settings applied: everything blocked (a site not yet
93
- * live), or extra lines added before the Sitemap line.
94
- */
95
- export function robotsLines(lines, {blockAll = false, extra = ''} = {}, sitemapUrl = '/sitemap.xml') {
96
- if (blockAll) return ['# This site asks search engines to stay away (SEO settings).', 'User-agent: *', 'Disallow: /'];
97
- const add = String(extra || '').split('\n').map(l => l.trimEnd()).filter((l, i, a) => l || (i > 0 && a[i - 1]));
98
- if (!add.length) return lines;
99
- let out;
100
- if (add.some(l => /^user-agent\s*:/i.test(l))) {
101
- // Groups of their own: after core's group, before the Sitemap line.
102
- const at = lines.findIndex(l => /^sitemap:/i.test(l));
103
- out = at >= 0 ? [...lines.slice(0, at), ...add, '', ...lines.slice(at)] : [...lines, '', ...add];
104
- } else {
105
- // Plain rules belong to the "User-agent: *" group: in it, before the blank line that ends it.
106
- const ua = lines.findIndex(l => /^user-agent\s*:\s*\*/i.test(l));
107
- const end = ua >= 0 ? lines.findIndex((l, i) => i > ua && !l.trim()) : -1;
108
- const at = end >= 0 ? end : (ua >= 0 ? lines.length : -1);
109
- out = at >= 0 ? [...lines.slice(0, at), ...add.filter(Boolean), ...lines.slice(at)] : ['User-agent: *', ...add.filter(Boolean), '', ...lines];
110
- }
111
- if (!out.some(l => /^sitemap:/i.test(l))) out.push('', `Sitemap: ${sitemapUrl}`);
112
- return out;
113
- }
@@ -1,141 +0,0 @@
1
- /**
2
- * SEO - settings (config/plugins.json `seo.settings`), checked the same way by
3
- * the server and the settings slideover. Pure.
4
- *
5
- * The site-wide defaults (title separator, default description and image, the
6
- * X handle) stay where core keeps them - Settings › SEO in site.json - so a
7
- * site without this plugin still has them.
8
- */
9
- import {LIMITS} from './analyse.js';
10
-
11
- export const ORG_TYPES = ['Organization', 'LocalBusiness', 'Person'];
12
-
13
- export const DEFAULT_SETTINGS = {
14
- organisation: {
15
- enabled: false, type: 'Organization', name: '', logo: '', url: '', email: '', phone: '', sameAs: [],
16
- address: {street: '', locality: '', region: '', postcode: '', country: ''}
17
- },
18
- articles: true,
19
- sitemap: {exclude: [], sources: {}},
20
- robots: {blockAll: false, extra: ''},
21
- redirects: {onRename: true},
22
- audit: {everyHours: 24, ...LIMITS}
23
- };
24
-
25
- const str = (v, max = 300) => String(v ?? '').trim().slice(0, max);
26
- const bool = (v, d) => (v === undefined ? d : v === true || v === 'true');
27
- const lines = (v) => (Array.isArray(v) ? v : String(v ?? '').split(/[\n,]+/)).map(s => String(s).trim()).filter(Boolean);
28
- const int = (v, min, max) => {
29
- const n = Number(v);
30
- return Number.isInteger(n) && n >= min && n <= max ? n : null;
31
- };
32
- const httpUrl = (s) => { try { return ['http:', 'https:'].includes(new URL(s).protocol); } catch { return false; } };
33
-
34
- /**
35
- * The stored settings merged over the defaults, every value checked.
36
- *
37
- * @param {object} input - a full or partial settings object
38
- * @param {object} [base] - what is stored now
39
- * @returns {{errors: Record<string, string>, settings: object}}
40
- */
41
- export function checkSettings(input = {}, base = DEFAULT_SETTINGS) {
42
- const errors = {};
43
- const out = JSON.parse(JSON.stringify(mergeDefaults(base)));
44
-
45
- const o = input.organisation;
46
- if (o && typeof o === 'object') {
47
- const org = out.organisation;
48
- if (o.enabled !== undefined) org.enabled = bool(o.enabled, false);
49
- if (o.type !== undefined) {
50
- if (ORG_TYPES.includes(o.type)) org.type = o.type;
51
- else errors['organisation.type'] = 'Organisation, local business or person.';
52
- }
53
- for (const k of ['name', 'email', 'phone']) if (o[k] !== undefined) org[k] = str(o[k], 200);
54
- for (const k of ['logo', 'url']) {
55
- if (o[k] === undefined) continue;
56
- const v = str(o[k], 500);
57
- if (v && !httpUrl(v) && !v.startsWith('/')) errors[`organisation.${k}`] = `${k === 'logo' ? 'The logo' : 'The address'}: a full web address, or a path like /media/logo.png.`;
58
- else org[k] = v;
59
- }
60
- if (o.sameAs !== undefined) {
61
- const list = lines(o.sameAs);
62
- const bad = list.filter(u => !httpUrl(u));
63
- if (bad.length) errors['organisation.sameAs'] = `Not web addresses: ${bad.slice(0, 2).join(', ')}`;
64
- else org.sameAs = [...new Set(list)].slice(0, 20);
65
- }
66
- if (o.address && typeof o.address === 'object') {
67
- for (const k of Object.keys(DEFAULT_SETTINGS.organisation.address)) if (o.address[k] !== undefined) org.address[k] = str(o.address[k], 200);
68
- }
69
- if (org.enabled && !org.name) errors['organisation.name'] = 'Give the name search engines should show.';
70
- }
71
-
72
- if (input.articles !== undefined) out.articles = bool(input.articles, true);
73
-
74
- if (input.sitemap && typeof input.sitemap === 'object') {
75
- if (input.sitemap.exclude !== undefined) {
76
- const list = lines(input.sitemap.exclude);
77
- const bad = list.filter(p => !p.startsWith('/'));
78
- if (bad.length) errors['sitemap.exclude'] = `Each starts with "/": ${bad.slice(0, 3).join(', ')}`;
79
- else out.sitemap.exclude = [...new Set(list)].slice(0, 200);
80
- }
81
- if (input.sitemap.sources && typeof input.sitemap.sources === 'object') {
82
- for (const [id, on] of Object.entries(input.sitemap.sources)) {
83
- if (/^[a-z0-9][a-z0-9:-]*$/.test(id)) out.sitemap.sources[id] = bool(on, true);
84
- }
85
- }
86
- }
87
-
88
- if (input.robots && typeof input.robots === 'object') {
89
- if (input.robots.blockAll !== undefined) out.robots.blockAll = bool(input.robots.blockAll, false);
90
- if (input.robots.extra !== undefined) {
91
- const text = String(input.robots.extra ?? '').replace(/\r/g, '').slice(0, 5000);
92
- const bad = text.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#') && !/^[A-Za-z-]+\s*:/.test(l));
93
- if (bad.length) errors['robots.extra'] = `Each line is "Field: value" (or a # comment): ${bad[0].slice(0, 40)}`;
94
- else out.robots.extra = text.trim();
95
- }
96
- }
97
-
98
- if (input.redirects && input.redirects.onRename !== undefined) out.redirects.onRename = bool(input.redirects.onRename, true);
99
-
100
- const a = input.audit;
101
- if (a && typeof a === 'object') {
102
- const num = (key, min, max, label) => {
103
- if (a[key] === undefined) return;
104
- const n = int(a[key], min, max);
105
- if (n === null) errors[`audit.${key}`] = `${label}: a whole number, ${min} to ${max}.`;
106
- else out.audit[key] = n;
107
- };
108
- num('everyHours', 0, 168, 'Check every (hours)');
109
- num('titleMin', 10, 70, 'Shortest title');
110
- num('titleMax', 30, 100, 'Longest title');
111
- num('descMin', 30, 160, 'Shortest description');
112
- num('descMax', 80, 320, 'Longest description');
113
- num('minWords', 0, 5000, 'Words a page should have');
114
- if (out.audit.titleMax < out.audit.titleMin) errors['audit.titleMax'] = 'The longest title cannot be shorter than the shortest.';
115
- if (out.audit.descMax < out.audit.descMin) errors['audit.descMax'] = 'The longest description cannot be shorter than the shortest.';
116
- }
117
- return {errors, settings: out};
118
- }
119
-
120
- /** Stored settings (possibly old or partial) with every default filled in. */
121
- export function mergeDefaults(s = {}) {
122
- const d = DEFAULT_SETTINGS;
123
- return {
124
- organisation: {...d.organisation, ...(s.organisation || {}), address: {...d.organisation.address, ...(s.organisation?.address || {})},
125
- sameAs: Array.isArray(s.organisation?.sameAs) ? s.organisation.sameAs : []},
126
- articles: s.articles ?? d.articles,
127
- sitemap: {exclude: Array.isArray(s.sitemap?.exclude) ? s.sitemap.exclude : [], sources: {...(s.sitemap?.sources || {})}},
128
- robots: {...d.robots, ...(s.robots || {})},
129
- redirects: {...d.redirects, ...(s.redirects || {})},
130
- audit: {...d.audit, ...(s.audit || {})}
131
- };
132
- }
133
-
134
- /** Is a path excluded from the sitemap by the settings? A trailing / or /* covers everything under it. */
135
- export function excluded(urlPath, exclude = []) {
136
- const p = String(urlPath || '').toLowerCase();
137
- return exclude.some((raw) => {
138
- const e = String(raw).toLowerCase().replace(/\*$/, '');
139
- return e.endsWith('/') ? p === e.slice(0, -1) || p.startsWith(e) : p === e;
140
- });
141
- }