domma-cms 0.88.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.88.1",
3
+ "version": "0.89.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -165,6 +165,12 @@ with `?display=` and swaps the cards, since each display is different block mark
165
165
 
166
166
  ## Gotchas
167
167
 
168
+ - **In the sitemap (1.9.0, core 0.89+).** `registerSitemapSource('blog')` lists the index and every public post at the
169
+ live base path (a post with `seo.noindex` is left out); a non-GET that succeeds on /posts, /settings or /samples calls
170
+ `sitemapChanged()`. Post pages pass `baseUrl` (canonical/og:url - they had none before), `noindex` and
171
+ `item: {type: 'blog:post', id}` to renderBlogPage, so the SEO plugin knows what it is tagging. PUT /posts/:id MERGES
172
+ `seo` (the editor sends title + description only; the SEO Tool may have set image/noindex/focus).
173
+
168
174
  - **Public handlers must RETURN the reply.** `reply.type(...).send(html)` without `return` in an
169
175
  async handler loses the body under @fastify/compress - every browser got a 200 with nothing in it,
170
176
  while curl (no Accept-Encoding) saw the page. `tests/public.test.js` drives gzip and brotli.
@@ -291,6 +291,23 @@ export default async function blogPlugin(fastify, options) {
291
291
  registerEmbedShortcodes(options.hooks.registerShortcode, () => liveBase(settings));
292
292
  }
293
293
 
294
+ // The blog in /sitemap.xml (core 0.89+; feature-detected): the index and
295
+ // every public post, at the base path as it is saved now. A post marked
296
+ // "hide from search engines" (seo.noindex) is left out.
297
+ if (typeof options.hooks?.registerSitemapSource === 'function') {
298
+ options.hooks.registerSitemapSource({id: 'blog', label: 'Blog posts', list: async () => {
299
+ const base = liveBase(settings);
300
+ const posts = (await loadAll('blog-posts')).filter(p => isVisible(p) && p.seo?.noindex !== true);
301
+ return [{urlPath: base, title: 'Blog', changefreq: 'daily'},
302
+ ...posts.map(p => ({urlPath: `${base}/${encodeURIComponent(p.slug)}`, title: p.title || '',
303
+ lastmod: p.updatedAt || p.publishedAt || null, ...(safeImageUrl(p.featuredImage) && {image: safeImageUrl(p.featuredImage)})}))];
304
+ }});
305
+ // A saved post, publish or setting changes the list: the sitemap is rebuilt on its next request.
306
+ fastify.addHook('onResponse', async (request, reply) => {
307
+ if (request.method !== 'GET' && reply.statusCode < 400 && /\/(posts|settings|samples)\b/.test(request.url)) options.hooks.sitemapChanged?.();
308
+ });
309
+ }
310
+
294
311
  // The layouts are blocks; put the shipped ones where the Blocks tool can
295
312
  // edit them. Once each, never over a site's own copy (lib/render.js).
296
313
  try {
@@ -487,6 +504,12 @@ export default async function blogPlugin(fastify, options) {
487
504
  }
488
505
 
489
506
  const merged = { ...entry.data, ...postFields(request.body ?? {}, user) };
507
+ // `seo` is merged too: the editor sends title and description only, and
508
+ // must not wipe what the SEO Tool set (image, noindex, focus keyword...).
509
+ if (merged.seo && typeof merged.seo === 'object' && entry.data.seo && typeof entry.data.seo === 'object') {
510
+ merged.seo = {...entry.data.seo, ...merged.seo};
511
+ for (const [k, v] of Object.entries(merged.seo)) if (v === null) delete merged.seo[k];
512
+ }
490
513
 
491
514
  const updated = await updateEntry(POSTS_SLUG, id, merged);
492
515
  return reply.send(toPost(updated));
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.8.0",
4
+ "version": "1.9.0",
5
5
  "tier": "free",
6
6
  "description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-09-26",
8
+ "date": "2026-09-27",
9
9
  "icon": "file-text",
10
10
  "admin": {
11
11
  "css": [
@@ -15,6 +15,7 @@
15
15
  import path from 'path';
16
16
  import {fileURLToPath} from 'url';
17
17
  import {renderBlogPage} from '../../server/services/renderer.js';
18
+ import {getConfig} from '../../server/config.js';
18
19
  import {byNewest, escapeHtml, matchBlogPath, pickDisplay, safeImageUrl} from './lib/layouts.js';
19
20
  import {catalogue, renderCards} from './lib/render.js';
20
21
  import {authorNamesFor, isVisible, liveBase, liveSettings, loadEntries, renderPostPage, resolveAuthorName} from './lib/page.js';
@@ -49,6 +50,15 @@ function buildPagination(page, totalPages, basePath, query = '') {
49
50
  return `<nav class="blog-pagination" aria-label="Pages">${parts.join('')}</nav>`;
50
51
  }
51
52
 
53
+ /**
54
+ * The site's own origin for canonical and og:url - as core's getBaseUrl():
55
+ * the Settings address when there is one, else the request's.
56
+ */
57
+ function originOf(request) {
58
+ const set = String(getConfig('site')?.baseUrl || '').trim().replace(/\/+$/, '');
59
+ return set || `${request.protocol}://${request.host || request.headers.host || request.hostname}`;
60
+ }
61
+
52
62
  /** JSON inside a <script> element, safe against `</script>` in any value. */
53
63
  function scriptJson(value) {
54
64
  return JSON.stringify(value).replace(/</g, '\\u003c');
@@ -121,7 +131,7 @@ ${items || ''}
121
131
  pagination: interactive ? '' : buildPagination(safePage, totalPages, base, requested),
122
132
  assetV: ASSET_V
123
133
  },
124
- {title, urlPath: request.url.split('?')[0]}
134
+ {title, urlPath: request.url.split('?')[0], baseUrl: originOf(request)}
125
135
  );
126
136
  return reply.type('text/html').send(html);
127
137
  }
@@ -215,7 +225,10 @@ ${items}
215
225
  ogImage: safeImageUrl(post.featuredImage),
216
226
  ogType: 'article',
217
227
  usedComponents: page.usedComponents,
218
- urlPath: `${base}/${slug}`
228
+ urlPath: `${base}/${slug}`,
229
+ baseUrl: originOf(request),
230
+ noindex: post.seo?.noindex === true,
231
+ item: {type: 'blog:post', id: post.id}
219
232
  }
220
233
  );
221
234
  return reply.type('text/html').send(html);
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@6301533",
3
+ "source": "dcms-marketplace@466a485",
4
4
  "plugins": {
5
5
  "blog": {
6
- "version": "1.8.0",
6
+ "version": "1.9.0",
7
7
  "files": {
8
- "CLAUDE.md": "026438d4d93f5a637be16ac22d5131e8248e93d2df1bd9b30d5f1cfe357ba2cc",
8
+ "CLAUDE.md": "5f3260acc22f202ce3dd1a262a41d4a98016a71e05da8f95732dcd73b6e08e33",
9
9
  "admin/css/index.css": "6142a6b756e1218157842528830b1e84f592f0b84b7d5ce6449db421b6f4b039",
10
10
  "admin/templates/blog.html": "a6054c1febe764bb1edf5c9e466d44a9080d9c83ba5486437b7a8244acf5ba54",
11
11
  "admin/templates/categories.html": "ab7d6fc2fedd48876c585bc216a1532b3bdd78c946dee976c1171bfe4664feb6",
@@ -48,9 +48,9 @@
48
48
  "lib/page.js": "464face32871185368de096ed9a6aa635523bf319af6459308cfceb77d66f82c",
49
49
  "lib/render.js": "0e97c6a950bc962c8d8ea4b2f21d06cb73fbcdc738d8942d6935d01309a29b1d",
50
50
  "lib/samples.js": "e8cdb77ce22e43937707f6854d3a5cb722437dc584e22ac7b24480bef686cf85",
51
- "plugin.js": "db93fd831f6ff6762c6768d47e658830b6284b6f4a9efd2bbffaab5966ba0361",
52
- "plugin.json": "d6a254e9ec114cbc469e8e2b5a9ca33376c3f88e49c54d42ea589ac883e3dac3",
53
- "plugin.public.js": "8efc1ff3a2ead68d36fbd545f8ce590f600b293896ba6f4947418b1efcdf2947",
51
+ "plugin.js": "a6e4442400594116c43aab1db706f0b9c2953e85699aa458709752c6c27fd781",
52
+ "plugin.json": "234c8067a4bf9f214667b5c99e05933c3321f0b8b35815a76e8ac8eacbbda857",
53
+ "plugin.public.js": "ab23fadd5655fab8eea0e636a3e1f8ced78432d6da929059a4a147f0805ff3a4",
54
54
  "public/blog.css": "fd733e6b624190da1e70dc1b6613e2376efd7f7570a28fab311d81006d03d960",
55
55
  "public/blog.js": "d01c72a9834dcc7c5241923371f2b164ac30d20ac9f1a5c5dc5b71fa3cb09f08",
56
56
  "public/samples/aurora.svg": "feb29e2b46465913fc29de9eda221b00b1541f1fa3ea7120f0c1d1cd11a9a4f6",
@@ -106,6 +106,25 @@
106
106
  "tests/security.test.js": "f0cb70dcb28bc8b3de8d3b39064616296382c5b0f97bfd615ee8e24fe3b031bc"
107
107
  }
108
108
  },
109
+ "seo": {
110
+ "version": "1.0.0",
111
+ "files": {
112
+ "CLAUDE.md": "c4b326afeacded36d076f96ec3554b6cd875087ea5c84b29dffb144059292ff7",
113
+ "admin/lib/analyse.js": "368b9e20bc1eac957870272ee6909b5f7c09ecf0a2fc2c344fb5cd85473f18cd",
114
+ "admin/lib/redirects.js": "a506ca40b712c3d8087d6698397b900fe5005ae6094f80dbc3f3091d5d9b6819",
115
+ "admin/lib/schema.js": "4313e0ed528562ee8b85c18479663c8216101f892848b44426baefc8d7c1c7e8",
116
+ "admin/lib/settings.js": "94e76c2960376f0cc0570128a7226af177f5de7ee115357e37baba45b1e05e19",
117
+ "admin/templates/seo.html": "e8684d98bd0649c001d7395b75b2bc3c74234f947d3dbda508ff4ecb91e20d1c",
118
+ "admin/views/seo.js": "b37774a5df642bdbdb6f7fb89b12dbc6d45d06d2b70bd2de7faf4c3098c26189",
119
+ "config.js": "d90f4cfb10b2f44b528de37522d31fd01351a6ebbab95556b5dfa2820d69e1a8",
120
+ "plugin.js": "3dd88bec75535349e6a748bae90fc49e54e2781c4765b9c8ff02898795ef476d",
121
+ "plugin.json": "6fe2fb5d50d37cc9749cf84db7ed59c5557a52c1b38f63c8e7bc1744065cd7d6",
122
+ "server/audit.js": "f51fcf9403ad40fde9e9ba5f31e58ed95fc2a5c8514c921146f23ee1ca2e0bd3",
123
+ "server/registry.js": "9336614bff3ab8b4fe9d50de074a9633307ab41c46fa62113ca82379cfad90a6",
124
+ "server/store.js": "3584ec3efd8a2d0ba8fd5be7e56d97cca76b5b2358a7dda99e379c42be67253c",
125
+ "tests/seo.test.js": "bdc52f2a9f8f02aaa0cb907f76f1acf8ff887034172e18d9ae108d3675b68f1a"
126
+ }
127
+ },
109
128
  "shopping-cart": {
110
129
  "version": "1.0.0",
111
130
  "files": {
@@ -0,0 +1,48 @@
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.
@@ -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
+ }