domma-cms 0.88.0 → 0.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/login-arrange.js +1 -1
- package/admin/js/templates/login.html +74 -52
- package/admin/js/views/login.js +1 -11
- package/package.json +1 -1
- package/plugins/blog/CLAUDE.md +6 -0
- package/plugins/blog/plugin.js +23 -0
- package/plugins/blog/plugin.json +2 -2
- package/plugins/blog/plugin.public.js +15 -2
- package/plugins/free-tier.lock.json +34 -14
- package/plugins/security/CLAUDE.md +7 -0
- package/plugins/security/admin/lib/resets.js +82 -0
- package/plugins/security/admin/lib/settings.js +1 -1
- package/plugins/security/admin/templates/security.html +3 -0
- package/plugins/security/admin/views/security.js +23 -10
- package/plugins/security/plugin.js +39 -1
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/security.test.js +34 -0
- package/plugins/seo/CLAUDE.md +48 -0
- package/plugins/seo/admin/lib/analyse.js +286 -0
- package/plugins/seo/admin/lib/redirects.js +163 -0
- package/plugins/seo/admin/lib/schema.js +113 -0
- package/plugins/seo/admin/lib/settings.js +141 -0
- package/plugins/seo/admin/templates/seo.html +224 -0
- package/plugins/seo/admin/views/seo.js +912 -0
- package/plugins/seo/config.js +7 -0
- package/plugins/seo/plugin.js +477 -0
- package/plugins/seo/plugin.json +64 -0
- package/plugins/seo/server/audit.js +106 -0
- package/plugins/seo/server/registry.js +57 -0
- package/plugins/seo/server/store.js +76 -0
- package/plugins/seo/tests/seo.test.js +192 -0
- package/server/routes/api/forms.js +3 -0
- package/server/routes/public.js +4 -3
- package/server/services/content.js +11 -0
- package/server/services/hooks.js +45 -1
- package/server/services/plugins.js +6 -0
- package/server/services/renderer.js +136 -55
- package/server/services/siteGitignore.js +114 -3
- package/server/services/sitemap.js +89 -18
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO - reading a rendered page and judging it. Pure: no DOM, no network, so
|
|
3
|
+
* the server's audit, the admin screen and the tests share it.
|
|
4
|
+
*
|
|
5
|
+
* scanHtml(html) -> facts (title, description, headings, images, links, words...)
|
|
6
|
+
* analyse(facts, opts) -> {score, checks: [{id, group, status, title, detail, fix}]}
|
|
7
|
+
* siteIssues(results) -> duplicates across pages (titles, descriptions)
|
|
8
|
+
* snippet({title, ...}) -> what a search result would show, cut where Google cuts
|
|
9
|
+
*
|
|
10
|
+
* A status is 'pass' | 'warn' | 'fail' | 'info'. The score counts a pass as
|
|
11
|
+
* 1, a warn as 0.5, a fail as 0, and leaves 'info' out - as Security does.
|
|
12
|
+
*
|
|
13
|
+
* @module seo/admin/lib/analyse
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const LIMITS = {titleMin: 30, titleMax: 60, descMin: 70, descMax: 160, minWords: 300};
|
|
17
|
+
|
|
18
|
+
const ENTITIES = {amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', hellip: '…',
|
|
19
|
+
lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”', pound: '£', euro: '€', copy: '©'};
|
|
20
|
+
|
|
21
|
+
/** Undo HTML entities in text. */
|
|
22
|
+
export function decode(s) {
|
|
23
|
+
return String(s ?? '').replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
24
|
+
if (e[0] === '#') {
|
|
25
|
+
const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
26
|
+
return Number.isFinite(n) && n > 0 && n < 0x110000 ? String.fromCodePoint(n) : m;
|
|
27
|
+
}
|
|
28
|
+
return ENTITIES[e.toLowerCase()] ?? m;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const squash = (s) => decode(String(s ?? '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
|
|
33
|
+
|
|
34
|
+
/** The attributes of one start tag: `<meta name="x" content='y' async>` -> {name, content, async: ''}. */
|
|
35
|
+
export function attrs(tag) {
|
|
36
|
+
const out = {};
|
|
37
|
+
const body = String(tag).replace(/^<\s*[a-z0-9-]+/i, '').replace(/\/?>$/, '');
|
|
38
|
+
const re = /([^\s"'=<>`/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
39
|
+
let m;
|
|
40
|
+
while ((m = re.exec(body))) out[m[1].toLowerCase()] = decode(m[2] ?? m[3] ?? m[4] ?? '');
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The facts of one page, from its HTML. Scripts, styles, templates and
|
|
46
|
+
* comments are removed first, so nothing inside them counts.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} html
|
|
49
|
+
* @returns {object}
|
|
50
|
+
*/
|
|
51
|
+
export function scanHtml(html) {
|
|
52
|
+
const src = String(html ?? '');
|
|
53
|
+
const headEnd = src.search(/<\/head\s*>/i);
|
|
54
|
+
const head = headEnd >= 0 ? src.slice(0, headEnd) : src;
|
|
55
|
+
|
|
56
|
+
const jsonLdTypes = [];
|
|
57
|
+
for (const m of src.matchAll(/<script\b[^>]*type\s*=\s*["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
58
|
+
try {
|
|
59
|
+
const j = JSON.parse(m[1]);
|
|
60
|
+
for (const x of [j, ...(Array.isArray(j) ? j : []), ...(Array.isArray(j?.['@graph']) ? j['@graph'] : [])]) {
|
|
61
|
+
const t = x?.['@type'];
|
|
62
|
+
if (t) jsonLdTypes.push(...(Array.isArray(t) ? t : [t]).map(String));
|
|
63
|
+
}
|
|
64
|
+
} catch { jsonLdTypes.push('(invalid)'); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const clean = src
|
|
68
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
69
|
+
.replace(/<(script|style|template|noscript|svg)\b[\s\S]*?<\/\1\s*>/gi, ' ');
|
|
70
|
+
|
|
71
|
+
const metas = [...head.matchAll(/<meta\b[^>]*>/gi)].map(m => attrs(m[0]));
|
|
72
|
+
const meta = (key, val) => metas.find(a => (a[key] || '').toLowerCase() === val)?.content;
|
|
73
|
+
const links = [...head.matchAll(/<link\b[^>]*>/gi)].map(m => attrs(m[0]));
|
|
74
|
+
|
|
75
|
+
const bodyStart = clean.search(/<body\b/i);
|
|
76
|
+
const body = bodyStart >= 0 ? clean.slice(bodyStart) : clean;
|
|
77
|
+
|
|
78
|
+
const headings = [...body.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi)].map(m => ({level: Number(m[1]), text: squash(m[2])}));
|
|
79
|
+
const images = [...body.matchAll(/<img\b[^>]*>/gi)].map(m => {
|
|
80
|
+
const a = attrs(m[0]);
|
|
81
|
+
return {src: a.src || a['data-src'] || '', alt: 'alt' in a ? a.alt : null, role: a.role || ''};
|
|
82
|
+
});
|
|
83
|
+
const anchors = [...body.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a\s*>/gi)].map(m => {
|
|
84
|
+
const a = attrs(`<a ${m[1]}>`);
|
|
85
|
+
return {href: a.href || '', text: squash(m[2]), rel: a.rel || '', title: a.title || ''};
|
|
86
|
+
});
|
|
87
|
+
const text = squash(body);
|
|
88
|
+
const firstPara = squash(body.match(/<p\b[^>]*>([\s\S]*?)<\/p\s*>/i)?.[1] || '');
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
title: squash(head.match(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i)?.[1] || ''),
|
|
92
|
+
description: meta('name', 'description') ?? null,
|
|
93
|
+
robots: meta('name', 'robots') || '',
|
|
94
|
+
viewport: Boolean(meta('name', 'viewport')),
|
|
95
|
+
canonical: links.find(l => (l.rel || '').toLowerCase() === 'canonical')?.href || '',
|
|
96
|
+
lang: attrs(src.match(/<html\b[^>]*>/i)?.[0] || '<html>').lang || '',
|
|
97
|
+
og: {
|
|
98
|
+
title: meta('property', 'og:title') || '',
|
|
99
|
+
description: meta('property', 'og:description') || '',
|
|
100
|
+
image: meta('property', 'og:image') || '',
|
|
101
|
+
type: meta('property', 'og:type') || ''
|
|
102
|
+
},
|
|
103
|
+
twitter: {card: meta('name', 'twitter:card') || '', image: meta('name', 'twitter:image') || ''},
|
|
104
|
+
headings,
|
|
105
|
+
images,
|
|
106
|
+
links: anchors,
|
|
107
|
+
words: text ? text.split(/\s+/).filter(w => /[\p{L}\p{N}]/u.test(w)).length : 0,
|
|
108
|
+
text: text.slice(0, 20000),
|
|
109
|
+
firstPara: firstPara.slice(0, 1000),
|
|
110
|
+
jsonLdTypes: [...new Set(jsonLdTypes)]
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
115
|
+
const has = (hay, needle) => Boolean(needle) && String(hay || '').toLowerCase().includes(needle);
|
|
116
|
+
const slugWords = (s) => String(s || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Judge one page.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} f - scanHtml() facts
|
|
122
|
+
* @param {object} [opts]
|
|
123
|
+
* @param {string} [opts.urlPath]
|
|
124
|
+
* @param {string} [opts.focus] - the focus keyphrase, if one is set
|
|
125
|
+
* @param {object} [opts.limits] - LIMITS overrides
|
|
126
|
+
* @param {string} [opts.origin] - the site's own origin, to tell internal links
|
|
127
|
+
* @returns {{score: number, failing: number, checks: object[]}}
|
|
128
|
+
*/
|
|
129
|
+
export function analyse(f, {urlPath = '/', focus = '', limits = {}, origin = ''} = {}) {
|
|
130
|
+
const L = {...LIMITS, ...limits};
|
|
131
|
+
const checks = [];
|
|
132
|
+
const add = (id, group, status, title, detail, fix = '') => checks.push({id, group, status, title, detail, fix});
|
|
133
|
+
const noindex = /noindex/i.test(f.robots);
|
|
134
|
+
|
|
135
|
+
// --- Search result ---
|
|
136
|
+
const tl = f.title.length;
|
|
137
|
+
if (!tl) add('title', 'Search result', 'fail', 'Title', 'The page has no title.', 'Give it one: it is the blue link people click in the results.');
|
|
138
|
+
else if (tl < L.titleMin) add('title', 'Search result', 'warn', 'Title', `${tl} characters - short. Search engines may write their own.`, `Aim for ${L.titleMin}-${L.titleMax} characters that say what the page is about.`);
|
|
139
|
+
else if (tl > L.titleMax) add('title', 'Search result', 'warn', 'Title', `${tl} characters - it will be cut off after about ${L.titleMax}.`, 'Put the words that matter first, or shorten it.');
|
|
140
|
+
else add('title', 'Search result', 'pass', 'Title', `${tl} characters.`);
|
|
141
|
+
|
|
142
|
+
const d = f.description;
|
|
143
|
+
const dl = (d || '').length;
|
|
144
|
+
if (!dl) add('description', 'Search result', 'fail', 'Description', 'No description: search engines pick any sentence from the page.', 'Write one or two sentences that make someone want to click.');
|
|
145
|
+
else if (dl < L.descMin) add('description', 'Search result', 'warn', 'Description', `${dl} characters - short.`, `Aim for ${L.descMin}-${L.descMax} characters.`);
|
|
146
|
+
else if (dl > L.descMax) add('description', 'Search result', 'warn', 'Description', `${dl} characters - it will be cut off after about ${L.descMax}.`, 'Shorten it, keeping the point in the first half.');
|
|
147
|
+
else add('description', 'Search result', 'pass', 'Description', `${dl} characters.`);
|
|
148
|
+
|
|
149
|
+
const slug = urlPath.split('/').filter(Boolean).pop() || '';
|
|
150
|
+
if (urlPath !== '/' && (slug.length > 60 || /[A-Z_ %]/.test(slug) || /\d{5,}/.test(slug))) {
|
|
151
|
+
add('url', 'Search result', 'warn', 'Address', `"${slug}" is long or hard to read.`, 'Short, lower-case words joined by hyphens read best - rename the page (a redirect is added for you).');
|
|
152
|
+
} else {
|
|
153
|
+
add('url', 'Search result', 'pass', 'Address', urlPath === '/' ? 'The home page.' : `"${slug}" reads well.`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Content ---
|
|
157
|
+
const h1s = f.headings.filter(h => h.level === 1);
|
|
158
|
+
if (!h1s.length) add('h1', 'Content', 'fail', 'Main heading', 'There is no H1 heading.', 'Start the page with one main heading (# in Markdown) saying what it is about.');
|
|
159
|
+
else if (h1s.length > 1) add('h1', 'Content', 'warn', 'Main heading', `${h1s.length} H1 headings: "${h1s.slice(0, 3).map(h => h.text).join('", "')}".`, 'Keep one H1; make the others H2.');
|
|
160
|
+
else if (!h1s[0].text) add('h1', 'Content', 'warn', 'Main heading', 'The H1 heading is empty.', 'Give it words.');
|
|
161
|
+
else add('h1', 'Content', 'pass', 'Main heading', `"${h1s[0].text.slice(0, 80)}".`);
|
|
162
|
+
|
|
163
|
+
const skips = [];
|
|
164
|
+
for (let i = 1; i < f.headings.length; i++) {
|
|
165
|
+
if (f.headings[i].level > f.headings[i - 1].level + 1) skips.push(`H${f.headings[i - 1].level} → H${f.headings[i].level}`);
|
|
166
|
+
}
|
|
167
|
+
if (skips.length) add('headings', 'Content', 'warn', 'Heading order', `Levels are skipped: ${[...new Set(skips)].slice(0, 3).join(', ')}.`, 'Go down one level at a time (H2, then H3) - screen readers and search engines read them as an outline.');
|
|
168
|
+
else if (f.headings.length > 1) add('headings', 'Content', 'pass', 'Heading order', `${plural(f.headings.length, 'heading')}, in order.`);
|
|
169
|
+
|
|
170
|
+
if (f.words < Math.round(L.minWords / 3)) add('words', 'Content', 'warn', 'Length', `${plural(f.words, 'word')}. Very little for a search engine to go on.`, `Pages that rank usually have ${L.minWords}+ words - unless the page is a form or a contact page.`);
|
|
171
|
+
else if (f.words < L.minWords) add('words', 'Content', 'info', 'Length', `${plural(f.words, 'word')}.`, `Pages that rank usually have ${L.minWords}+ words.`);
|
|
172
|
+
else add('words', 'Content', 'pass', 'Length', `${f.words.toLocaleString('en-GB')} words.`);
|
|
173
|
+
|
|
174
|
+
const content = f.images.filter(i => i.role !== 'presentation');
|
|
175
|
+
const noAlt = content.filter(i => i.alt === null);
|
|
176
|
+
if (noAlt.length) add('alt', 'Content', 'fail', 'Image descriptions', `${plural(noAlt.length, 'image')} of ${content.length} ${noAlt.length === 1 ? 'has' : 'have'} no alt text.`, 'Describe each image in a few words (empty alt="" only for decoration). It is how search and screen readers see them.');
|
|
177
|
+
else if (content.length) add('alt', 'Content', 'pass', 'Image descriptions', `All ${plural(content.length, 'image')} described.`);
|
|
178
|
+
|
|
179
|
+
const internal = f.links.filter(l => isInternal(l.href, origin));
|
|
180
|
+
if (!internal.length && f.links.length < 50) add('links', 'Content', 'warn', 'Links to your other pages', 'None in the page itself.', 'Link to related pages: it helps visitors, and search engines find pages through links.');
|
|
181
|
+
else add('links', 'Content', 'pass', 'Links to your other pages', `${plural(internal.length, 'link')}.`);
|
|
182
|
+
|
|
183
|
+
// --- Focus keyphrase ---
|
|
184
|
+
const k = String(focus || '').trim().toLowerCase();
|
|
185
|
+
if (k) {
|
|
186
|
+
const where = [
|
|
187
|
+
['title', has(f.title, k)],
|
|
188
|
+
['description', has(d, k)],
|
|
189
|
+
['main heading', h1s.some(h => has(h.text, k))],
|
|
190
|
+
['address', slugWords(urlPath).includes(slugWords(k))],
|
|
191
|
+
['first paragraph', has(f.firstPara, k)]
|
|
192
|
+
];
|
|
193
|
+
const missing = where.filter(([, ok]) => !ok).map(([w]) => w);
|
|
194
|
+
const count = countPhrase(f.text, k);
|
|
195
|
+
const density = f.words ? (count * k.split(/\s+/).length * 100) / f.words : 0;
|
|
196
|
+
if (missing.length >= 3) add('focus', 'Keyphrase', 'fail', `"${focus}"`, `Missing from the ${missing.join(', ')}.`, 'Use the phrase people search for where they look first: the title, the heading and the opening.');
|
|
197
|
+
else if (missing.length) add('focus', 'Keyphrase', 'warn', `"${focus}"`, `Missing from the ${missing.join(', ')}.`, 'Work it in naturally - never at the cost of reading well.');
|
|
198
|
+
else add('focus', 'Keyphrase', 'pass', `"${focus}"`, 'In the title, description, heading, address and opening.');
|
|
199
|
+
if (f.words < 100) { /* too short for a share of the words to mean anything */ }
|
|
200
|
+
else if (density > 3) add('density', 'Keyphrase', 'warn', 'Repetition', `The phrase is ${density.toFixed(1)}% of the words (${plural(count, 'time')}).`, 'Over about 3% reads as stuffing - use other words for the same thing.');
|
|
201
|
+
else if (count) add('density', 'Keyphrase', 'pass', 'Repetition', `Used ${plural(count, 'time')} (${density.toFixed(1)}%).`);
|
|
202
|
+
} else {
|
|
203
|
+
add('focus', 'Keyphrase', 'info', 'Focus keyphrase', 'None set.', 'Say which search this page should be found for, and the checks will look for it.');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Sharing and technical ---
|
|
207
|
+
if (noindex) add('index', 'Technical', 'info', 'Search engines', 'Hidden from search engines (noindex).', 'On purpose for thank-you pages and the like; otherwise switch it off.');
|
|
208
|
+
else add('index', 'Technical', 'pass', 'Search engines', 'May be indexed.');
|
|
209
|
+
if (!f.canonical) add('canonical', 'Technical', 'warn', 'Canonical address', 'No canonical link.', 'Set the site address in Settings so every page names its one true address.');
|
|
210
|
+
else if (!f.canonical.replace(/\/$/, '').endsWith(urlPath === '/' ? '' : urlPath)) add('canonical', 'Technical', 'info', 'Canonical address', `Points elsewhere: ${f.canonical}.`, 'Right when this page is a copy of that one; otherwise it hides this page.');
|
|
211
|
+
else add('canonical', 'Technical', 'pass', 'Canonical address', f.canonical);
|
|
212
|
+
if (!f.og.image) add('share-image', 'Sharing', 'warn', 'Share image', 'No image when the page is shared.', 'Choose an image (1200 × 630 is ideal), or set a default one for the site.');
|
|
213
|
+
else add('share-image', 'Sharing', 'pass', 'Share image', f.og.image);
|
|
214
|
+
if (f.jsonLdTypes.includes('(invalid)')) add('schema', 'Technical', 'fail', 'Structured data', 'A JSON-LD block is not valid JSON.', 'Fix or remove it - search engines ignore the lot.');
|
|
215
|
+
else if (f.jsonLdTypes.length) add('schema', 'Technical', 'pass', 'Structured data', f.jsonLdTypes.join(', '));
|
|
216
|
+
else add('schema', 'Technical', 'info', 'Structured data', 'None.', 'Structured data lets search engines show rich results.');
|
|
217
|
+
if (!f.lang) add('lang', 'Technical', 'warn', 'Language', 'The page does not say what language it is in.', 'The site template sets <html lang="…">.');
|
|
218
|
+
|
|
219
|
+
return {...score(checks), checks};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isInternal(href, origin) {
|
|
223
|
+
const h = String(href || '').trim();
|
|
224
|
+
if (!h || h.startsWith('#') || /^(mailto|tel|javascript|data):/i.test(h)) return false;
|
|
225
|
+
if (h.startsWith('/') && !h.startsWith('//')) return true;
|
|
226
|
+
if (!origin) return false;
|
|
227
|
+
try { return new URL(h).origin === new URL(origin).origin; } catch { return false; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function countPhrase(text, phrase) {
|
|
231
|
+
if (!phrase) return 0;
|
|
232
|
+
const esc = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
|
|
233
|
+
return (String(text).match(new RegExp(`(^|[^\\p{L}\\p{N}])${esc}(?=$|[^\\p{L}\\p{N}])`, 'giu')) || []).length;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Score and failing count of a list of checks. */
|
|
237
|
+
export function score(checks) {
|
|
238
|
+
const counted = checks.filter(c => c.status !== 'info');
|
|
239
|
+
const got = counted.reduce((n, c) => n + (c.status === 'pass' ? 1 : c.status === 'warn' ? 0.5 : 0), 0);
|
|
240
|
+
return {
|
|
241
|
+
score: counted.length ? Math.round((got / counted.length) * 100) : 100,
|
|
242
|
+
failing: checks.filter(c => c.status === 'fail').length,
|
|
243
|
+
warnings: checks.filter(c => c.status === 'warn').length
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Problems only visible across pages: the same title or description on more
|
|
249
|
+
* than one. `results` are audit rows `{urlPath, title, description, noindex}`.
|
|
250
|
+
*
|
|
251
|
+
* @returns {{duplicateTitles: Array<{text, paths}>, duplicateDescriptions: Array<{text, paths}>}}
|
|
252
|
+
*/
|
|
253
|
+
export function siteIssues(results) {
|
|
254
|
+
const dupes = (key) => {
|
|
255
|
+
const by = new Map();
|
|
256
|
+
for (const r of results) {
|
|
257
|
+
const v = String(r[key] || '').trim().toLowerCase();
|
|
258
|
+
if (!v || r.noindex || r.error) continue;
|
|
259
|
+
if (!by.has(v)) by.set(v, {text: r[key], paths: []});
|
|
260
|
+
by.get(v).paths.push(r.urlPath);
|
|
261
|
+
}
|
|
262
|
+
return [...by.values()].filter(x => x.paths.length > 1).sort((a, b) => b.paths.length - a.paths.length);
|
|
263
|
+
};
|
|
264
|
+
return {duplicateTitles: dupes('title'), duplicateDescriptions: dupes('description')};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A search result as Google would show it, cut where it cuts (roughly: it
|
|
269
|
+
* measures pixels; characters are close enough to show the problem).
|
|
270
|
+
*/
|
|
271
|
+
export function snippet({title = '', description = '', url = '', limits = {}} = {}) {
|
|
272
|
+
const L = {...LIMITS, ...limits};
|
|
273
|
+
const cut = (s, n) => (s.length > n ? `${s.slice(0, n - 1).replace(/\s+\S*$/, '')} …` : s);
|
|
274
|
+
let crumbs = url;
|
|
275
|
+
try {
|
|
276
|
+
const u = new URL(url);
|
|
277
|
+
crumbs = [u.hostname, ...u.pathname.split('/').filter(Boolean)].join(' › ');
|
|
278
|
+
} catch { /* a path */ }
|
|
279
|
+
return {
|
|
280
|
+
title: cut(String(title), L.titleMax),
|
|
281
|
+
description: cut(String(description), L.descMax),
|
|
282
|
+
crumbs,
|
|
283
|
+
titleCut: String(title).length > L.titleMax,
|
|
284
|
+
descriptionCut: String(description).length > L.descMax
|
|
285
|
+
};
|
|
286
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
}
|