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,9 +1,10 @@
1
1
  /**
2
- * SEO's door for extensions - how SEO Pro adds to the free SEO without
3
- * replacing it (the Shopping Cart's pattern, lib/extensions.js there).
2
+ * SEO's door for extensions - how SEO Pro adds to the built-in SEO without
3
+ * replacing it (the Shopping Cart's pattern). The same registry the SEO plugin
4
+ * had before SEO was built in (0.90.0), so SEO Pro works with either.
4
5
  *
5
6
  * It lives on `globalThis` under a registered symbol, so an extension reaches
6
- * it WITHOUT importing a file from this plugin. An extension registers from its
7
+ * it WITHOUT importing a core file. An extension registers from its
7
8
  * `onReady` hook, when every plugin has loaded:
8
9
  *
9
10
  * const seo = globalThis[Symbol.for('domma.seo')];
@@ -16,6 +17,7 @@
16
17
  * and reads through `seo.services` (listEntries, auditList, fetchPage,
17
18
  * scanHtml, analyse, audit {latest, summary, run}, redirects {list, match, add}).
18
19
  * A lapsed licence means the extension never loads and SEO carries on alone.
20
+ * A tab's `module` is a `/plugins/<slug>/...` URL exporting mount(root, ctx).
19
21
  */
20
22
  export const KEY = Symbol.for('domma.seo');
21
23
  export const VERSION = 1;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * SEO - its data. Held in memory (the redirect guard and the 'seo:meta'
3
+ * transform run on every request and must not touch the disk) and written back
4
+ * a moment after each change; a file changed on disk (a restore, a pull of the
5
+ * site's repo) is read again within a few seconds.
6
+ *
7
+ * In content/seo/ - authored, so tracked by the site's repo:
8
+ * redirects.json {rules: [{id, from, to, code, note, auto, at}]} see admin/js/lib/seo-redirects.js
9
+ * overrides.json {"/path": {title, description, image, noindex, focus}}
10
+ * Runtime state, kept out of the repo (services/siteGitignore.js):
11
+ * hits.json {ruleId: {hits, lastHit}} - kept apart so a visit never dirties redirects.json
12
+ * audit.json {at, origin, results: [...]}
13
+ *
14
+ * @module services/seo/store
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ /** Where SEO keeps its files - resolved per call, as analyticsDir() is. */
20
+ export const seoDir = () => path.resolve('content', 'seo');
21
+
22
+ function jsonFile(file, empty, {pretty = true} = {}) {
23
+ let value = read();
24
+ let mtime = stat();
25
+ let timer = null;
26
+ let checked = Date.now();
27
+
28
+ function stat() { try { return fs.statSync(file).mtimeMs; } catch { return 0; } }
29
+ function read() {
30
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return structuredClone(empty); }
31
+ }
32
+ function flush() {
33
+ timer = null;
34
+ try {
35
+ fs.mkdirSync(path.dirname(file), {recursive: true});
36
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(value, null, pretty ? 2 : 0) + '\n');
37
+ fs.renameSync(`${file}.tmp`, file);
38
+ mtime = stat();
39
+ } catch { /* tried again with the next change */ }
40
+ }
41
+ return {
42
+ get() {
43
+ const now = Date.now();
44
+ if (!timer && now - checked > 3000) {
45
+ checked = now;
46
+ const m = stat();
47
+ if (m && m !== mtime) { value = read(); mtime = m; }
48
+ }
49
+ return value;
50
+ },
51
+ set(next, {soon = 500} = {}) {
52
+ value = next;
53
+ if (timer) clearTimeout(timer);
54
+ timer = setTimeout(flush, soon);
55
+ timer.unref?.();
56
+ return value;
57
+ },
58
+ flush() { if (timer) { clearTimeout(timer); flush(); } }
59
+ };
60
+ }
61
+
62
+ /**
63
+ * @param {string} [dir] - defaults to content/seo
64
+ */
65
+ export function createStore(dir = seoDir()) {
66
+ const redirects = jsonFile(path.join(dir, 'redirects.json'), {rules: []});
67
+ const hits = jsonFile(path.join(dir, 'hits.json'), {}, {pretty: false});
68
+ const overrides = jsonFile(path.join(dir, 'overrides.json'), {});
69
+ const audit = jsonFile(path.join(dir, 'audit.json'), {at: null, origin: '', results: []}, {pretty: false});
70
+ const rulesNow = () => (Array.isArray(redirects.get()?.rules) ? redirects.get().rules : []);
71
+ let hitsDirty = false;
72
+ return {
73
+ /** The rules as stored (no counters) - what matching uses. */
74
+ rules: rulesNow,
75
+ /** The rules with their counters, for the screen and SEO Pro. */
76
+ rulesWithHits() {
77
+ const h = hits.get() || {};
78
+ return rulesNow().map(r => ({...r, hits: h[r.id]?.hits || 0, lastHit: h[r.id]?.lastHit || null}));
79
+ },
80
+ setRules(rules) {
81
+ const clean = rules.map(({hits: _h, lastHit: _l, ...r}) => r);
82
+ redirects.set({rules: clean});
83
+ const ids = new Set(clean.map(r => r.id));
84
+ const h = hits.get() || {};
85
+ if (Object.keys(h).some(id => !ids.has(id))) hits.set(Object.fromEntries(Object.entries(h).filter(([id]) => ids.has(id))));
86
+ },
87
+ /** One use of a rule - in memory; saved by saveHits() once a minute. */
88
+ countHit(id) {
89
+ const h = hits.get() || {};
90
+ const e = h[id] ||= {hits: 0, lastHit: null};
91
+ e.hits++;
92
+ e.lastHit = new Date().toISOString();
93
+ hitsDirty = true;
94
+ },
95
+ saveHits() { if (hitsDirty) { hitsDirty = false; hits.set(hits.get(), {soon: 0}); } },
96
+ overrides: () => overrides.get() || {},
97
+ override: (urlPath) => overrides.get()?.[urlPath] || null,
98
+ setOverride(urlPath, fields) {
99
+ const all = {...overrides.get()};
100
+ const clean = Object.fromEntries(Object.entries(fields || {}).filter(([, v]) => v !== '' && v !== null && v !== undefined && v !== false));
101
+ if (Object.keys(clean).length) all[urlPath] = clean;
102
+ else delete all[urlPath];
103
+ overrides.set(all);
104
+ return all[urlPath] || null;
105
+ },
106
+ audit: () => audit.get(),
107
+ setAudit: (a) => audit.set(a, {soon: 100}),
108
+ close() { this.saveHits(); redirects.flush(); hits.flush(); overrides.flush(); audit.flush(); }
109
+ };
110
+ }
@@ -49,7 +49,7 @@ const SITE_ROOTS = ['content', 'config', 'plugins'];
49
49
  */
50
50
  export const RETIRED_PLUGINS = [
51
51
  'analytics', 'contacts', 'notes', 'todo', 'demo-viewer', 'theme-switcher', 'site-search',
52
- 'seo-pro', 'shopping-cart', 'form-builder'
52
+ 'seo', 'seo-pro', 'shopping-cart', 'form-builder'
53
53
  ];
54
54
 
55
55
  /**
@@ -309,6 +309,9 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
309
309
  '/content/sessions/',
310
310
  '/content/versions/',
311
311
  '/content/analytics/',
312
+ // SEO's runtime state; its redirects and overrides beside them are authored, and tracked.
313
+ '/content/seo/audit.json',
314
+ '/content/seo/hits.json',
312
315
  '/content/instances_log.json',
313
316
  '/content/preview-links.json',
314
317
  '/config/connections.json*',
@@ -46,7 +46,8 @@ export const CORE_TOOLS = Object.freeze([
46
46
  {name: 'contacts', displayName: 'Contacts', icon: 'users', description: 'An address book for each user, with groups.'},
47
47
  {name: 'notes', displayName: 'Notes', icon: 'edit-3', description: 'Private notes for each user.'},
48
48
  {name: 'todo', displayName: 'Todo', icon: 'check-square', description: 'To-do lists with due dates and reminders.'},
49
- {name: 'analytics', displayName: 'Analytics', icon: 'chart-bar', description: 'Page views and visitors, counted on this server.'}
49
+ {name: 'analytics', displayName: 'Analytics', icon: 'chart-bar', description: 'Page views and visitors, counted on this server.'},
50
+ {name: 'seo', displayName: 'SEO', icon: 'search', description: 'How the site looks to search engines: an audit of every page, redirects, the sitemap and robots.txt.'}
50
51
  ]);
51
52
 
52
53
  const CORE_NAMES = new Set(CORE_TOOLS.map(t => t.name));
@@ -1,48 +0,0 @@
1
- # SEO (free) - AI Assistant Guide
2
-
3
- How the site looks to search engines, and what to fix. Free tier (`make sync-free-tier` ships it with the engine).
4
- SEO Pro (paid) is an ADD-ON (`requires: ["seo"]`, no supersedes) that plugs in through `server/registry.js`.
5
- Social (paid, planned) will be its own plugin, sold with SEO Pro.
6
-
7
- Needs core 0.89 for: `hooks.registerSitemapSource` / `sitemapChanged`, and the `seo:meta`, `sitemap:entries`,
8
- `robots:lines` transforms (core `renderer.js` buildSeoMeta/renderSeoTags, `sitemap.js`). On an older engine the plugin
9
- logs it, the screen says so, and the audit, editor and redirects still work (`hasCore` in plugin.js).
10
-
11
- ## What it does
12
- - **Audit** (`server/audit.js`): every sitemap URL (`listSitemapEntries`) plus published pages kept out of it, fetched
13
- IN-PROCESS with `fastify.inject` (UA `DommaSEO/1 (audit)`, header `x-domma-seo: audit` - analytics / SEO Pro skip
14
- those), judged by `admin/lib/analyse.js` (pure: `scanHtml` regex scanner - no DOM, `analyse`, `siteIssues`, `snippet`).
15
- Every `audit.everyHours` (first run 3 min after start if stale), or POST /audit. Kept in `data/audit.json`.
16
- - **Editor per URL** (GET/PUT /item): a core page is edited IN PLACE (`updatePage(path, {seo})`, empty -> null so core's
17
- merge removes the key); anything else (a blog post) gets an override in `data/overrides.json`, applied by the
18
- `seo:meta` transform (`admin/lib/schema.js applySeo`). Fields: title, description, image, noindex, focus.
19
- - **Redirects** (`admin/lib/redirects.js`, pure): exact and "/prefix/*" rules, 301/302/307/308/410, query kept, no
20
- loops (checkRule follows the chain), `onMoved` on core's `content:pageRenamed` re-aims older rules (no chains) and
21
- drops a rule FROM the new address. Guard = global `onRequest` (fastify-plugin), GET/HEAD, never /api or /admin.
22
- Hits counted in memory, saved each minute.
23
- - **Sitemap/robots**: `sitemap:entries` drops excluded paths, switched-off sources, overridden noindex and redirected
24
- addresses; `robots:lines` adds rules (plain rules into the `*` group; `User-agent:` blocks as their own groups) or
25
- blocks everything. JSON-LD: Organization/LocalBusiness/Person on `/`, BlogPosting when `page.item.type === 'blog:post'`.
26
-
27
- ## Data (plugins/seo/data - kept by updates, never bundled)
28
- `redirects.json`, `overrides.json`, `audit.json` - in memory (the guard and transform run on every request), debounced
29
- writes, re-read within ~3 s when changed on disk (`server/store.js`).
30
-
31
- ## Permission
32
- `seo` (read / manage), granted to admin. Routes: /overview /count /audit (GET, POST) /item (GET, PUT) /redirects
33
- (GET, POST, PUT/:id, DELETE/:id - ids comma-separated) /redirects/import /redirects/test /sitemap /settings /extensions.
34
-
35
- ## Extensions (SEO Pro)
36
- `globalThis[Symbol.for('domma.seo')]` (`server/registry.js`): `register(owner, {label, tabs})`, `on('audit', fn)`,
37
- `services` {settings, listEntries, auditList, fetchPage, scanHtml, analyse, audit {latest, summary, run},
38
- redirects {list, match, add}, auditUserAgent}. Tabs: `{key, label, icon, module, help}`; `module` is a
39
- `/plugins/<slug>/...` URL exporting `mount(root, ctx)` -> dispose; ctx = {kit, api, scope, here, openItem, reload, E, M, I}.
40
-
41
- ## Admin
42
- `admin/views/seo.js` + `admin/templates/seo.html` (prefix `sox-`): Pages (the audit + "Across the site"), Redirects,
43
- Sitemap, extension tabs; page editor slideover (search result + share card previews, meters, checks); settings
44
- slideover. Rows by index, text through data-bind-text only.
45
-
46
- ## Tests
47
- `tests/seo.test.js` (pure). Headless: throwaway CMS from a clean domma-cms worktree with plugins/seo + blog copied in;
48
- puppeteer-core + ~/.cache/ms-playwright chromium_headless_shell for screenshots.
@@ -1,286 +0,0 @@
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
- }