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
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
/** SEO - the pure parts: redirects, reading and judging a page, settings, structured data, robots. */
|
|
2
|
-
import {test} from 'node:test';
|
|
3
|
-
import assert from 'node:assert/strict';
|
|
4
|
-
import {chains, checkRule, matchRedirect, normalisePath, onMoved} from '../admin/lib/redirects.js';
|
|
5
|
-
import {analyse, attrs, decode, scanHtml, siteIssues, snippet} from '../admin/lib/analyse.js';
|
|
6
|
-
import {checkSettings, DEFAULT_SETTINGS, excluded, mergeDefaults} from '../admin/lib/settings.js';
|
|
7
|
-
import {applySeo, organisationLd, robotsLines} from '../admin/lib/schema.js';
|
|
8
|
-
|
|
9
|
-
const rule = (from, to, code = 301, id = from) => ({id, from, to, code});
|
|
10
|
-
|
|
11
|
-
test('paths: trailing slashes, case, doubled slashes and queries do not matter', () => {
|
|
12
|
-
assert.equal(normalisePath('/About/'), '/about');
|
|
13
|
-
assert.equal(normalisePath('about'), '/about');
|
|
14
|
-
assert.equal(normalisePath('//a///b/?x=1#y'), '/a/b');
|
|
15
|
-
assert.equal(normalisePath('/'), '/');
|
|
16
|
-
assert.equal(normalisePath(''), '');
|
|
17
|
-
assert.equal(normalisePath('/caf%C3%A9'), '/café');
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test('redirects: exact before prefix, the longest prefix wins, the query travels', () => {
|
|
21
|
-
const rules = [rule('/old', '/new'), rule('/news/*', '/blog/*'), rule('/news/2019/*', '/archive', 302), rule('/gone', '', 410)];
|
|
22
|
-
assert.deepEqual(matchRedirect(rules, '/OLD/?utm=x').location, '/new?utm=x');
|
|
23
|
-
assert.equal(matchRedirect(rules, '/news/hello').location, '/blog/hello');
|
|
24
|
-
assert.equal(matchRedirect(rules, '/news').location, '/blog', 'the prefix itself goes to the target root');
|
|
25
|
-
assert.equal(matchRedirect(rules, '/news/2019/x').location, '/archive');
|
|
26
|
-
assert.equal(matchRedirect(rules, '/news/2019/x').code, 302);
|
|
27
|
-
assert.equal(matchRedirect(rules, '/gone').code, 410);
|
|
28
|
-
assert.equal(matchRedirect(rules, '/newsletter'), null, 'a prefix matches whole segments');
|
|
29
|
-
assert.equal(matchRedirect([rule('/a', '/b?ref=1')], '/a?x=2').location, '/b?ref=1', 'a target with its own query keeps it');
|
|
30
|
-
assert.equal(matchRedirect([rule('/a', 'https://ex.com/b')], '/a').location, 'https://ex.com/b');
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test('redirect rules are checked: self, loops, duplicates, the admin, bad targets', () => {
|
|
34
|
-
const ok = checkRule({from: '/Old-Page/', to: '/new-page'});
|
|
35
|
-
assert.deepEqual(ok.errors, {});
|
|
36
|
-
assert.equal(ok.rule.from, '/old-page');
|
|
37
|
-
assert.equal(checkRule({from: '/a', to: '/a'}).errors.to, 'It would redirect to itself.');
|
|
38
|
-
assert.match(checkRule({from: '/b', to: '/a'}, [rule('/a', '/b')]).errors.to, /loop/);
|
|
39
|
-
assert.match(checkRule({from: '/c', to: '/a'}, [rule('/a', '/b'), rule('/b', '/c')]).errors.to, /loop/, 'a longer loop too');
|
|
40
|
-
assert.match(checkRule({from: '/a', to: '/x'}, [rule('/a', '/b')]).errors.from, /already/);
|
|
41
|
-
assert.ok(checkRule({from: '/admin/x', to: '/y'}).errors.from);
|
|
42
|
-
assert.ok(checkRule({from: '/*', to: '/y'}).errors.from);
|
|
43
|
-
assert.ok(checkRule({from: '/a', to: 'javascript:alert(1)'}).errors.to);
|
|
44
|
-
assert.ok(checkRule({from: '/a', to: '//evil.example'}).errors.to);
|
|
45
|
-
assert.ok(checkRule({from: '/a', to: '/b/*'}).errors.to, 'a * needs a prefix rule');
|
|
46
|
-
assert.deepEqual(checkRule({from: '/a/*', to: '/b/*'}).errors, {});
|
|
47
|
-
assert.deepEqual(checkRule({from: '/a', code: 410}).errors, {}, 'gone needs no target');
|
|
48
|
-
assert.ok(checkRule({from: '/a', to: '/b', code: 303}).errors.code);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('a moved page: a redirect is added, older ones re-aimed, none from the new address', () => {
|
|
52
|
-
let rules = [rule('/older', '/old', 301, 'r1'), rule('/new', '/elsewhere', 301, 'r2')];
|
|
53
|
-
rules = onMoved(rules, '/old', '/new', {id: 'r3', now: 'T'});
|
|
54
|
-
assert.deepEqual(rules.map(r => [r.from, r.to]), [['/older', '/new'], ['/old', '/new']]);
|
|
55
|
-
assert.equal(rules.at(-1).auto, true);
|
|
56
|
-
assert.deepEqual(chains(rules), [], 'no chains left');
|
|
57
|
-
assert.deepEqual(chains([rule('/a', '/b', 301, 'a'), rule('/b', '/c', 301, 'b')]).map(c => [c.rule.id, c.via.id]), [['a', 'b']]);
|
|
58
|
-
assert.equal(onMoved(rules, '/x', '/x'), rules, 'no move, no change');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
const PAGE = `<!doctype html><html lang="en"><head>
|
|
62
|
-
<title>Garden tools & seeds | Shop</title>
|
|
63
|
-
<meta name="description" content="Everything for the garden: garden tools, seeds and advice from people who grow things for a living.">
|
|
64
|
-
<link rel="canonical" href="https://ex.com/garden-tools">
|
|
65
|
-
<meta property="og:image" content="https://ex.com/a.jpg">
|
|
66
|
-
<script type="application/ld+json">{"@type":"WebPage"}</script>
|
|
67
|
-
<script type="application/ld+json">{"@graph":[{"@type":"BreadcrumbList"}]}</script>
|
|
68
|
-
</head><body>
|
|
69
|
-
<!-- <h1>not this</h1> -->
|
|
70
|
-
<script>document.write('<h1>nor this</h1>')</script>
|
|
71
|
-
<h1>Garden tools</h1><p>Our garden tools are made to last. Choose from forks, spades and trowels.</p>
|
|
72
|
-
<h2>Forks</h2><h4>Skipped a level</h4>
|
|
73
|
-
<img src="/a.jpg" alt="A fork"><img src="/b.jpg"><img src="/c.jpg" alt=""><img src="/d.jpg" role="presentation">
|
|
74
|
-
<a href="/spades">Spades</a> <a href="https://other.example/">Other</a> <a href="#top">Top</a>
|
|
75
|
-
</body></html>`;
|
|
76
|
-
|
|
77
|
-
test('scanHtml: what a crawler sees, ignoring scripts and comments', () => {
|
|
78
|
-
const f = scanHtml(PAGE);
|
|
79
|
-
assert.equal(f.title, 'Garden tools & seeds | Shop');
|
|
80
|
-
assert.match(f.description, /^Everything for the garden/);
|
|
81
|
-
assert.equal(f.canonical, 'https://ex.com/garden-tools');
|
|
82
|
-
assert.equal(f.lang, 'en');
|
|
83
|
-
assert.deepEqual(f.headings.map(h => `${h.level}:${h.text}`), ['1:Garden tools', '2:Forks', '4:Skipped a level']);
|
|
84
|
-
assert.deepEqual(f.images.map(i => i.alt), ['A fork', null, '', null]);
|
|
85
|
-
assert.equal(f.links.length, 3);
|
|
86
|
-
assert.deepEqual(f.jsonLdTypes, ['WebPage', 'BreadcrumbList']);
|
|
87
|
-
assert.ok(f.words > 10);
|
|
88
|
-
assert.match(f.firstPara, /made to last/);
|
|
89
|
-
assert.deepEqual(attrs('<meta name=x content=\'a "b"\' async>'), {name: 'x', content: 'a "b"', async: ''});
|
|
90
|
-
assert.equal(decode('<a> 'x' £ '), '<a> \'x\' £ ');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('analyse: the checks, a focus keyphrase, and the score', () => {
|
|
94
|
-
const f = scanHtml(PAGE);
|
|
95
|
-
const r = analyse(f, {urlPath: '/garden-tools', focus: 'garden tools', origin: 'https://ex.com'});
|
|
96
|
-
const by = Object.fromEntries(r.checks.map(c => [c.id, c.status]));
|
|
97
|
-
assert.equal(by.title, 'warn', '27 characters is short');
|
|
98
|
-
assert.equal(by.h1, 'pass');
|
|
99
|
-
assert.equal(by.headings, 'warn', 'H2 -> H4 is a skip');
|
|
100
|
-
assert.equal(by.alt, 'fail', 'one image has no alt; decoration and alt="" are fine');
|
|
101
|
-
assert.equal(by.links, 'pass');
|
|
102
|
-
assert.equal(by.focus, 'pass');
|
|
103
|
-
assert.equal(by.canonical, 'pass');
|
|
104
|
-
assert.equal(by.schema, 'pass');
|
|
105
|
-
assert.equal(by.words, 'warn', 'a few dozen words');
|
|
106
|
-
assert.ok(r.score > 0 && r.score < 100);
|
|
107
|
-
assert.equal(r.failing, 1);
|
|
108
|
-
|
|
109
|
-
const bare = analyse(scanHtml('<html><head></head><body><p>hi</p></body></html>'), {urlPath: '/x'});
|
|
110
|
-
const b = Object.fromEntries(bare.checks.map(c => [c.id, c.status]));
|
|
111
|
-
assert.equal(b.title, 'fail');
|
|
112
|
-
assert.equal(b.description, 'fail');
|
|
113
|
-
assert.equal(b.h1, 'fail');
|
|
114
|
-
assert.equal(b.lang, 'warn');
|
|
115
|
-
assert.equal(b.focus, 'info');
|
|
116
|
-
|
|
117
|
-
const missing = analyse(f, {urlPath: '/garden-tools', focus: 'lawn mower'});
|
|
118
|
-
assert.equal(missing.checks.find(c => c.id === 'focus').status, 'fail');
|
|
119
|
-
assert.equal(analyse(scanHtml('<html><head><meta name="robots" content="noindex"></head></html>')).checks.find(c => c.id === 'index').status, 'info');
|
|
120
|
-
assert.equal(analyse(scanHtml('<script type="application/ld+json">{bad</script>')).checks.find(c => c.id === 'schema').status, 'fail');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test('siteIssues: duplicate titles and descriptions, hidden pages left out', () => {
|
|
124
|
-
const r = siteIssues([
|
|
125
|
-
{urlPath: '/a', title: 'Home', description: 'Same'},
|
|
126
|
-
{urlPath: '/b', title: 'home', description: 'Same'},
|
|
127
|
-
{urlPath: '/c', title: 'Home', noindex: true},
|
|
128
|
-
{urlPath: '/d', title: 'Other', description: ''}
|
|
129
|
-
]);
|
|
130
|
-
assert.deepEqual(r.duplicateTitles.map(d => d.paths), [['/a', '/b']]);
|
|
131
|
-
assert.deepEqual(r.duplicateDescriptions.map(d => d.paths), [['/a', '/b']]);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('snippet: cut where search results cut, crumbs from the address', () => {
|
|
135
|
-
const s = snippet({title: 'x'.repeat(40) + ' ' + 'y'.repeat(30), description: 'short', url: 'https://ex.com/a/b'});
|
|
136
|
-
assert.equal(s.titleCut, true);
|
|
137
|
-
assert.ok(s.title.endsWith('…'));
|
|
138
|
-
assert.equal(s.crumbs, 'ex.com › a › b');
|
|
139
|
-
assert.equal(s.descriptionCut, false);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test('settings: checked, merged, and a missing organisation name refused', () => {
|
|
143
|
-
const {errors, settings} = checkSettings({audit: {titleMax: 20}, sitemap: {exclude: 'thanks\n/private/'}, organisation: {enabled: true}});
|
|
144
|
-
assert.ok(errors['audit.titleMax']);
|
|
145
|
-
assert.ok(errors['sitemap.exclude']);
|
|
146
|
-
assert.ok(errors['organisation.name']);
|
|
147
|
-
assert.equal(settings.audit.titleMax, DEFAULT_SETTINGS.audit.titleMax);
|
|
148
|
-
const ok = checkSettings({sitemap: {exclude: '/private/\n/thanks', sources: {blog: false}}, robots: {extra: 'Disallow: /x/\n# note'}, organisation: {sameAs: 'https://x.com/a'}});
|
|
149
|
-
assert.deepEqual(ok.errors, {});
|
|
150
|
-
assert.deepEqual(ok.settings.sitemap.exclude, ['/private/', '/thanks']);
|
|
151
|
-
assert.equal(ok.settings.sitemap.sources.blog, false);
|
|
152
|
-
assert.ok(checkSettings({robots: {extra: 'nonsense line'}}).errors['robots.extra']);
|
|
153
|
-
assert.deepEqual(mergeDefaults({audit: {everyHours: 6}}).audit.titleMax, DEFAULT_SETTINGS.audit.titleMax);
|
|
154
|
-
assert.equal(excluded('/private/x', ['/private/']), true);
|
|
155
|
-
assert.equal(excluded('/private', ['/private/']), true);
|
|
156
|
-
assert.equal(excluded('/privateer', ['/private/']), false);
|
|
157
|
-
assert.equal(excluded('/thanks', ['/thanks']), true);
|
|
158
|
-
assert.equal(excluded('/thanks/more', ['/thanks']), false);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test('structured data: the organisation on the home page, BlogPosting on posts, overrides applied', () => {
|
|
162
|
-
const settings = mergeDefaults({organisation: {enabled: true, type: 'LocalBusiness', name: 'Acme', logo: '/logo.png', address: {locality: 'Leeds'}}});
|
|
163
|
-
assert.equal(organisationLd({enabled: false}), null);
|
|
164
|
-
const ld = organisationLd(settings.organisation, {origin: 'https://ex.com'});
|
|
165
|
-
assert.equal(ld.logo, 'https://ex.com/logo.png');
|
|
166
|
-
assert.equal(ld.address.addressLocality, 'Leeds');
|
|
167
|
-
|
|
168
|
-
const base = {title: 'T', description: 'D', image: '', ogType: 'website', noindex: false, canonical: 'https://ex.com/', og: {}, twitter: {}, jsonLd: [{'@type': 'WebPage'}], extra: []};
|
|
169
|
-
const home = applySeo(base, {page: {urlPath: '/'}, kind: 'page', origin: 'https://ex.com', settings});
|
|
170
|
-
assert.deepEqual(home.jsonLd.map(x => x['@type']), ['WebPage', 'LocalBusiness']);
|
|
171
|
-
assert.deepEqual(base.jsonLd.length, 1, 'core\'s model is not changed in place');
|
|
172
|
-
|
|
173
|
-
const post = applySeo({...base, canonical: 'https://ex.com/blog/p'}, {page: {urlPath: '/blog/p', item: {type: 'blog:post'}}, kind: 'plugin', origin: 'https://ex.com', settings,
|
|
174
|
-
override: {title: 'Better', description: 'Better D', image: '/p.jpg', noindex: true}});
|
|
175
|
-
assert.equal(post.title, 'Better');
|
|
176
|
-
assert.equal(post.image, 'https://ex.com/p.jpg');
|
|
177
|
-
assert.equal(post.noindex, true);
|
|
178
|
-
assert.equal(post.ogType, 'article');
|
|
179
|
-
const bp = post.jsonLd.find(x => x['@type'] === 'BlogPosting');
|
|
180
|
-
assert.equal(bp.headline, 'Better');
|
|
181
|
-
assert.equal(bp.publisher.name, 'Acme');
|
|
182
|
-
assert.equal(post.jsonLd[0].description, 'Better D');
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test('robots: extra rules join the * group; their own groups go before Sitemap; block all', () => {
|
|
186
|
-
const core = ['User-agent: *', 'Allow: /', 'Disallow: /admin/', 'Disallow: /api/', '', 'Sitemap: https://ex.com/sitemap.xml'];
|
|
187
|
-
assert.deepEqual(robotsLines(core, {extra: 'Disallow: /tmp/'}), ['User-agent: *', 'Allow: /', 'Disallow: /admin/', 'Disallow: /api/', 'Disallow: /tmp/', '', 'Sitemap: https://ex.com/sitemap.xml']);
|
|
188
|
-
const g = robotsLines(core, {extra: 'User-agent: GPTBot\nDisallow: /'});
|
|
189
|
-
assert.deepEqual(g.slice(5), ['User-agent: GPTBot', 'Disallow: /', '', 'Sitemap: https://ex.com/sitemap.xml']);
|
|
190
|
-
assert.deepEqual(robotsLines(core, {blockAll: true}).slice(1), ['User-agent: *', 'Disallow: /']);
|
|
191
|
-
assert.equal(robotsLines(core, {}), core);
|
|
192
|
-
});
|