@prenta/core 0.118.0 → 0.119.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/dist/api/routes/redirects.d.ts.map +1 -1
  3. package/dist/api/routes/redirects.js +10 -2
  4. package/dist/api/routes/redirects.js.map +1 -1
  5. package/dist/api/routes/sections.d.ts.map +1 -1
  6. package/dist/api/routes/sections.js +40 -3
  7. package/dist/api/routes/sections.js.map +1 -1
  8. package/dist/api/routes/seo.d.ts.map +1 -1
  9. package/dist/api/routes/seo.js +36 -3
  10. package/dist/api/routes/seo.js.map +1 -1
  11. package/dist/sections/background-presets.d.ts +43 -0
  12. package/dist/sections/background-presets.d.ts.map +1 -0
  13. package/dist/sections/background-presets.js +61 -0
  14. package/dist/sections/background-presets.js.map +1 -0
  15. package/dist/sections/index.d.ts +1 -0
  16. package/dist/sections/index.d.ts.map +1 -1
  17. package/dist/sections/index.js +1 -0
  18. package/dist/sections/index.js.map +1 -1
  19. package/dist/sections/registry.d.ts.map +1 -1
  20. package/dist/sections/registry.js +88 -0
  21. package/dist/sections/registry.js.map +1 -1
  22. package/dist/sections/types.d.ts +1 -1
  23. package/dist/sections/types.d.ts.map +1 -1
  24. package/dist/seo/audit-runner.d.ts +14 -0
  25. package/dist/seo/audit-runner.d.ts.map +1 -1
  26. package/dist/seo/audit-runner.js +12 -2
  27. package/dist/seo/audit-runner.js.map +1 -1
  28. package/dist/seo/meta-tags.d.ts +7 -0
  29. package/dist/seo/meta-tags.d.ts.map +1 -1
  30. package/dist/seo/meta-tags.js +47 -11
  31. package/dist/seo/meta-tags.js.map +1 -1
  32. package/dist/seo/page-audit.d.ts +163 -0
  33. package/dist/seo/page-audit.d.ts.map +1 -0
  34. package/dist/seo/page-audit.js +456 -0
  35. package/dist/seo/page-audit.js.map +1 -0
  36. package/dist/seo/page-meta.d.ts +0 -4
  37. package/dist/seo/page-meta.d.ts.map +1 -1
  38. package/dist/seo/page-meta.js +21 -0
  39. package/dist/seo/page-meta.js.map +1 -1
  40. package/dist/seo/robots-directives.d.ts +85 -0
  41. package/dist/seo/robots-directives.d.ts.map +1 -0
  42. package/dist/seo/robots-directives.js +119 -0
  43. package/dist/seo/robots-directives.js.map +1 -0
  44. package/package.json +11 -1
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Per-page SEO and GEO audit.
3
+ *
4
+ * Deterministic, dependency-free, and fast enough to run on every keystroke —
5
+ * it is the number an editor watches while typing, and it also backs the REST
6
+ * and MCP surfaces so an agent grades a page the same way the UI does.
7
+ *
8
+ * ── Why this is not `analyzeContent` ──────────────────────────────────────
9
+ *
10
+ * `./analysis.ts` is a CONTENT analyser: fuzzy checks over prose (readability,
11
+ * keyphrase density, sentence length) with partial credit. It is useful, but
12
+ * it is not a page score, and measuring it showed why it should not be used as
13
+ * one:
14
+ *
15
+ * - Its denominator moves. The seven keyphrase checks exist only when a
16
+ * keyphrase is set, so setting one — the most standard SEO action there is
17
+ * — dropped a real page from 67 to 60 even when the keyphrase was well
18
+ * used.
19
+ * - Its floor is 47, not 0. A page with no title, no slug and no content
20
+ * scores 47, because `improvement` awards half credit to checks that were
21
+ * never attempted. Any band scale calibrated for 0–100 is wrong against it.
22
+ *
23
+ * This module is a PAGE checklist: every check is binary, every check is
24
+ * either applicable or explicitly not, and the score spans the full range.
25
+ *
26
+ * ── The four properties, and why each exists ──────────────────────────────
27
+ *
28
+ * 1. STABLE, VISIBLE DENOMINATOR. A check that cannot apply is `not-applicable`
29
+ * and leaves the denominator, but it is still returned with the reason it
30
+ * does not apply and the action that would enable it. The score is always
31
+ * "% of what applies to this page", and the UI can say "6 of 8 apply"
32
+ * rather than leaving a moving number unexplained.
33
+ *
34
+ * 2. NOT-APPLICABLE IS NOT PARTIAL CREDIT. There is no half-pass. A blank page
35
+ * fails everything it can fail and scores 0, which is what a blank page
36
+ * should score.
37
+ *
38
+ * 3. WEIGHTED. "Open to search engines" and "meta description length" are not
39
+ * the same size of problem — a noindex page is invisible no matter how good
40
+ * its title is. Weights are documented at each check rather than hidden in
41
+ * a table.
42
+ *
43
+ * UNKNOWN INPUTS. A caller that cannot resolve a field passes `undefined`, and
44
+ * the check reports not-applicable rather than failing. This matters: a red
45
+ * check an editor has no way to act on is noise, and it drags the score down
46
+ * for a shortcoming in the plumbing rather than in the page. `null` still means
47
+ * a genuine absence and still fails.
48
+ *
49
+ * 4. BANDS FROM MEASUREMENT. See `SCORE_BANDS` — set from a measured corpus
50
+ * (`scripts/calibrate-page-audit.mjs`), not from intuition.
51
+ */
52
+ import type { PageSection } from '../sections/types.js';
53
+ export type CheckOutcome = 'pass' | 'fail' | 'not-applicable';
54
+ export type AuditGroup = 'seo' | 'geo';
55
+ export interface AuditCheck {
56
+ id: string;
57
+ group: AuditGroup;
58
+ label: string;
59
+ outcome: CheckOutcome;
60
+ /**
61
+ * Relative importance among applicable checks. 1 is a normal check; higher
62
+ * means failing it does more damage than failing an average one.
63
+ *
64
+ * Weight 0 marks a GATE: a precondition that is reported but never scored.
65
+ * See `indexable` for why being crawlable is not an achievement.
66
+ */
67
+ weight: number;
68
+ /** What to do about it — shown when the check fails. */
69
+ hint: string;
70
+ /**
71
+ * Why the check cannot apply, and what would enable it. Present only when
72
+ * `outcome === 'not-applicable'`, and never a euphemism for a failure.
73
+ */
74
+ naReason?: string;
75
+ }
76
+ export interface AuditGroupResult {
77
+ group: AuditGroup;
78
+ checks: AuditCheck[];
79
+ /** Applicable checks that passed. */
80
+ passed: number;
81
+ /** Checks that could be judged — the denominator the score is a share of. */
82
+ applicable: number;
83
+ /** Total checks, applicable or not. `applicable`/`total` is what the UI shows. */
84
+ total: number;
85
+ /**
86
+ * 0–100, weighted. `null` when nothing applies: a page with no judgeable
87
+ * checks has no score, which is different from scoring zero.
88
+ */
89
+ score: number | null;
90
+ }
91
+ export interface PageAuditResult {
92
+ seo: AuditGroupResult;
93
+ geo: AuditGroupResult;
94
+ }
95
+ export interface PageAuditInput {
96
+ sections: PageSection[];
97
+ seo: {
98
+ title: string;
99
+ description: string;
100
+ slug: string;
101
+ /** Focus keyword. Empty disables the three placement checks. */
102
+ keyword: string;
103
+ /** Schema.org type bound to the page, e.g. 'Article'. */
104
+ schema: string;
105
+ /**
106
+ * `null` = no image chosen. `undefined` = an image exists but its
107
+ * dimensions are not available to the caller, which is a different
108
+ * statement and must not be scored as a failure — see UNKNOWN below.
109
+ */
110
+ ogImage?: {
111
+ width: number;
112
+ height: number;
113
+ } | null;
114
+ robots: {
115
+ index: boolean;
116
+ snippet: boolean;
117
+ };
118
+ };
119
+ /** Site-level: an llms.txt is served. */
120
+ llmsTxt: boolean;
121
+ /** Brand name, for the "named up front" check. Empty makes the check n/a. */
122
+ brand: string;
123
+ /** `null` = no author. `undefined` = the caller cannot resolve one yet. */
124
+ author?: {
125
+ name: string;
126
+ isBrandByline: boolean;
127
+ } | null;
128
+ }
129
+ /**
130
+ * Image slots the TEMPLATE renders, not assets that happen to be filled.
131
+ *
132
+ * This distinction is the whole point of the alt-text check. An Image+Text
133
+ * section always renders an image, so its slot counts whether or not an asset
134
+ * was picked — counting filled assets instead would let an undescribed image
135
+ * on the live page report a pass, and a false pass on an accessibility
136
+ * checklist is worse than having no check.
137
+ *
138
+ * Returns one entry per rendered slot with the alt text currently on it.
139
+ */
140
+ export declare function imageSlots(sections: PageSection[]): Array<{
141
+ sectionId: string;
142
+ alt: string;
143
+ }>;
144
+ export declare function auditPage(input: PageAuditInput): PageAuditResult;
145
+ export type ScoreBand = 'good' | 'fair' | 'poor';
146
+ /**
147
+ * Thresholds measured, not guessed.
148
+ *
149
+ * `scripts/calibrate-page-audit.mjs` runs the audit over a corpus spanning
150
+ * blank pages to fully-optimised ones and reports the distribution. These
151
+ * bounds come from that run; re-run it when checks or weights change, because
152
+ * altering a weight moves the distribution and can silently invalidate them.
153
+ *
154
+ * The handoff proposed 80 / 55 against the old content analyser, whose floor
155
+ * was 47 — a scale where `poor` was nearly unreachable. This model spans the
156
+ * full range, so the boundaries can mean what they say.
157
+ */
158
+ export declare const SCORE_BANDS: {
159
+ readonly good: 80;
160
+ readonly fair: 55;
161
+ };
162
+ export declare function scoreBand(score: number): ScoreBand;
163
+ //# sourceMappingURL=page-audit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-audit.d.ts","sourceRoot":"","sources":["../../src/seo/page-audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAMvD,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,gBAAgB,CAAA;AAE7D,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,KAAK,CAAA;AAEtC,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,UAAU,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,YAAY,CAAA;IACrB;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,CAAA;IACd,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,UAAU,CAAA;IACjB,MAAM,EAAE,UAAU,EAAE,CAAA;IACpB,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAA;IACd,6EAA6E;IAC7E,UAAU,EAAE,MAAM,CAAA;IAClB,kFAAkF;IAClF,KAAK,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,gBAAgB,CAAA;IACrB,GAAG,EAAE,gBAAgB,CAAA;CACtB;AAMD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,WAAW,EAAE,CAAA;IACvB,GAAG,EAAE;QACH,KAAK,EAAE,MAAM,CAAA;QACb,WAAW,EAAE,MAAM,CAAA;QACnB,IAAI,EAAE,MAAM,CAAA;QACZ,gEAAgE;QAChE,OAAO,EAAE,MAAM,CAAA;QACf,yDAAyD;QACzD,MAAM,EAAE,MAAM,CAAA;QACd;;;;WAIG;QACH,OAAO,CAAC,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;QAClD,MAAM,EAAE;YAAE,KAAK,EAAE,OAAO,CAAC;YAAC,OAAO,EAAE,OAAO,CAAA;SAAE,CAAA;KAC7C,CAAA;IACD,yCAAyC;IACzC,OAAO,EAAE,OAAO,CAAA;IAChB,6EAA6E;IAC7E,KAAK,EAAE,MAAM,CAAA;IACb,2EAA2E;IAC3E,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAA;CACzD;AAaD;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,KAAK,CAAC;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CA6C7F;AAyUD,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,eAAe,CAKhE;AAMD,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAA;AAEhD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,WAAW;;;CAAkC,CAAA;AAE1D,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAIlD"}
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Per-page SEO and GEO audit.
3
+ *
4
+ * Deterministic, dependency-free, and fast enough to run on every keystroke —
5
+ * it is the number an editor watches while typing, and it also backs the REST
6
+ * and MCP surfaces so an agent grades a page the same way the UI does.
7
+ *
8
+ * ── Why this is not `analyzeContent` ──────────────────────────────────────
9
+ *
10
+ * `./analysis.ts` is a CONTENT analyser: fuzzy checks over prose (readability,
11
+ * keyphrase density, sentence length) with partial credit. It is useful, but
12
+ * it is not a page score, and measuring it showed why it should not be used as
13
+ * one:
14
+ *
15
+ * - Its denominator moves. The seven keyphrase checks exist only when a
16
+ * keyphrase is set, so setting one — the most standard SEO action there is
17
+ * — dropped a real page from 67 to 60 even when the keyphrase was well
18
+ * used.
19
+ * - Its floor is 47, not 0. A page with no title, no slug and no content
20
+ * scores 47, because `improvement` awards half credit to checks that were
21
+ * never attempted. Any band scale calibrated for 0–100 is wrong against it.
22
+ *
23
+ * This module is a PAGE checklist: every check is binary, every check is
24
+ * either applicable or explicitly not, and the score spans the full range.
25
+ *
26
+ * ── The four properties, and why each exists ──────────────────────────────
27
+ *
28
+ * 1. STABLE, VISIBLE DENOMINATOR. A check that cannot apply is `not-applicable`
29
+ * and leaves the denominator, but it is still returned with the reason it
30
+ * does not apply and the action that would enable it. The score is always
31
+ * "% of what applies to this page", and the UI can say "6 of 8 apply"
32
+ * rather than leaving a moving number unexplained.
33
+ *
34
+ * 2. NOT-APPLICABLE IS NOT PARTIAL CREDIT. There is no half-pass. A blank page
35
+ * fails everything it can fail and scores 0, which is what a blank page
36
+ * should score.
37
+ *
38
+ * 3. WEIGHTED. "Open to search engines" and "meta description length" are not
39
+ * the same size of problem — a noindex page is invisible no matter how good
40
+ * its title is. Weights are documented at each check rather than hidden in
41
+ * a table.
42
+ *
43
+ * UNKNOWN INPUTS. A caller that cannot resolve a field passes `undefined`, and
44
+ * the check reports not-applicable rather than failing. This matters: a red
45
+ * check an editor has no way to act on is noise, and it drags the score down
46
+ * for a shortcoming in the plumbing rather than in the page. `null` still means
47
+ * a genuine absence and still fails.
48
+ *
49
+ * 4. BANDS FROM MEASUREMENT. See `SCORE_BANDS` — set from a measured corpus
50
+ * (`scripts/calibrate-page-audit.mjs`), not from intuition.
51
+ */
52
+ import { getSectionType } from '../sections/registry.js';
53
+ // ---------------------------------------------------------------------------
54
+ // Content helpers
55
+ // ---------------------------------------------------------------------------
56
+ const str = (v) => (typeof v === 'string' ? v : '');
57
+ /** Sections that render on the live page. Hidden ones are not audited. */
58
+ function visible(sections) {
59
+ return sections.filter((s) => s?.visible !== false);
60
+ }
61
+ /**
62
+ * Image slots the TEMPLATE renders, not assets that happen to be filled.
63
+ *
64
+ * This distinction is the whole point of the alt-text check. An Image+Text
65
+ * section always renders an image, so its slot counts whether or not an asset
66
+ * was picked — counting filled assets instead would let an undescribed image
67
+ * on the live page report a pass, and a false pass on an accessibility
68
+ * checklist is worse than having no check.
69
+ *
70
+ * Returns one entry per rendered slot with the alt text currently on it.
71
+ */
72
+ export function imageSlots(sections) {
73
+ const slots = [];
74
+ for (const section of visible(sections)) {
75
+ const def = getSectionType(section.sectionType);
76
+ if (!def)
77
+ continue;
78
+ const content = (section.content ?? {});
79
+ for (const field of def.fields) {
80
+ if (field.type === 'media') {
81
+ // The slot exists because the template declares it. Alt lives on a
82
+ // sibling key by convention (`imageUrl` → `imageAlt`).
83
+ const altKey = field.key.replace(/Url$/, '') + 'Alt';
84
+ slots.push({ sectionId: section.id, alt: str(content[altKey]) });
85
+ }
86
+ else if (field.type === 'gallery') {
87
+ // A gallery renders one slot per row the editor added. Zero rows means
88
+ // zero rendered images — nothing to describe.
89
+ const rows = Array.isArray(content[field.key]) ? content[field.key] : [];
90
+ for (const row of rows) {
91
+ const r = (row ?? {});
92
+ slots.push({ sectionId: section.id, alt: str(r.alt) });
93
+ }
94
+ }
95
+ else if (field.type === 'repeater' && field.fields?.some((f) => f.type === 'media')) {
96
+ // A repeater whose rows carry a media sub-field renders one image per
97
+ // row, exactly like a gallery — a logo/badge strip is the usual case.
98
+ //
99
+ // Walking these is not optional. The rule is "count the slots the
100
+ // template renders", and a repeater that fell through would let a whole
101
+ // strip of undescribed logos report a clean pass, which is the specific
102
+ // false pass this check exists to prevent.
103
+ const rows = Array.isArray(content[field.key]) ? content[field.key] : [];
104
+ const mediaSubFields = field.fields.filter((f) => f.type === 'media');
105
+ for (const row of rows) {
106
+ const r = (row ?? {});
107
+ for (const sub of mediaSubFields) {
108
+ const altKey = sub.key.replace(/Url$/, '') + 'Alt';
109
+ // `alt` as the fallback key matches the gallery row convention.
110
+ slots.push({ sectionId: section.id, alt: str(r[altKey] ?? r.alt) });
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ return slots;
117
+ }
118
+ /**
119
+ * Body copy across visible sections, in render order.
120
+ *
121
+ * Field TYPE is not enough to identify prose: the registry types headings as
122
+ * `textarea` too, because they are multi-line inputs. Selecting on type alone
123
+ * made the "opens with a direct answer" check measure the headline — a 29
124
+ * character string that can never fall in the 80–340 quotable range, so the
125
+ * check was unpassable and silently capped the GEO score.
126
+ */
127
+ function bodyText(sections) {
128
+ const out = [];
129
+ for (const section of visible(sections)) {
130
+ const def = getSectionType(section.sectionType);
131
+ if (!def)
132
+ continue;
133
+ const content = (section.content ?? {});
134
+ for (const field of def.fields) {
135
+ if (field.type !== 'textarea' && field.type !== 'richText')
136
+ continue;
137
+ if (HEADING_KEY.test(field.key))
138
+ continue;
139
+ const value = str(content[field.key])
140
+ .replace(/<[^>]*>/g, ' ')
141
+ .trim();
142
+ if (value)
143
+ out.push(value);
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+ /** Keys the registry uses for headings, which are `textarea` like body copy. */
149
+ const HEADING_KEY = /heading|title/i;
150
+ /** First heading on the page — the H1 a crawler sees. */
151
+ function firstHeading(sections) {
152
+ for (const section of visible(sections)) {
153
+ const def = getSectionType(section.sectionType);
154
+ if (!def)
155
+ continue;
156
+ const content = (section.content ?? {});
157
+ for (const field of def.fields) {
158
+ if (!HEADING_KEY.test(field.key))
159
+ continue;
160
+ const value = str(content[field.key]).trim();
161
+ if (value)
162
+ return value;
163
+ }
164
+ }
165
+ return '';
166
+ }
167
+ function hasVisibleType(sections, type) {
168
+ return visible(sections).some((s) => s.sectionType === type);
169
+ }
170
+ const norm = (s) => s.toLowerCase().trim();
171
+ // ---------------------------------------------------------------------------
172
+ // Checks
173
+ // ---------------------------------------------------------------------------
174
+ function seoChecks(input) {
175
+ const { seo, sections } = input;
176
+ const keyword = seo.keyword.trim();
177
+ const hasKeyword = keyword.length > 0;
178
+ const kw = norm(keyword);
179
+ // Not-applicable rather than failing: without a keyword there is nothing to
180
+ // locate, and failing three checks for a field the editor has not filled in
181
+ // punishes them twice for one omission. The reason names the enabling action
182
+ // so the panel can show them greyed rather than hidden.
183
+ const noKeyword = 'Set a focus keyword to enable this check.';
184
+ const slots = imageSlots(sections);
185
+ const described = slots.filter((s) => s.alt.trim().length > 3);
186
+ const og = seo.ogImage;
187
+ const ogUnknown = og === undefined;
188
+ const ogRatio = og && og.height > 0 ? og.width / og.height : 0;
189
+ return [
190
+ {
191
+ id: 'title-length',
192
+ group: 'seo',
193
+ label: 'Title tag length',
194
+ // Weight 2: the title is the strongest on-page signal and the line a
195
+ // searcher actually reads in the result.
196
+ weight: 2,
197
+ outcome: seo.title.length >= 30 && seo.title.length <= 60 ? 'pass' : 'fail',
198
+ hint: 'Aim for 30–60 characters so it is descriptive without being truncated.',
199
+ },
200
+ {
201
+ id: 'description-length',
202
+ group: 'seo',
203
+ label: 'Meta description length',
204
+ weight: 1,
205
+ outcome: seo.description.length >= 70 && seo.description.length <= 160 ? 'pass' : 'fail',
206
+ hint: 'Aim for 70–160 characters — enough to earn the click, short enough to survive.',
207
+ },
208
+ {
209
+ id: 'keyword-set',
210
+ group: 'seo',
211
+ label: 'Focus keyword chosen',
212
+ // Carries the cost of having no keyword, so the three placement checks
213
+ // below can be not-applicable without shrinking the denominator into
214
+ // flattery. Without this, a page with only a title and description
215
+ // scored "good" on 3 of 4 checks — technically true, and misleading.
216
+ weight: 1,
217
+ outcome: hasKeyword ? 'pass' : 'fail',
218
+ hint: 'Choose the phrase this page should rank for; it unlocks three more checks.',
219
+ },
220
+ {
221
+ id: 'keyword-in-title',
222
+ group: 'seo',
223
+ label: 'Focus keyword in title',
224
+ weight: 2,
225
+ outcome: !hasKeyword ? 'not-applicable' : norm(seo.title).includes(kw) ? 'pass' : 'fail',
226
+ hint: 'Work the focus keyword into the title tag.',
227
+ ...(hasKeyword ? {} : { naReason: noKeyword }),
228
+ },
229
+ {
230
+ id: 'keyword-in-h1',
231
+ group: 'seo',
232
+ label: 'Focus keyword in H1',
233
+ weight: 1,
234
+ outcome: !hasKeyword
235
+ ? 'not-applicable'
236
+ : norm(firstHeading(sections)).includes(kw)
237
+ ? 'pass'
238
+ : 'fail',
239
+ hint: 'Work the focus keyword into the page’s first heading.',
240
+ ...(hasKeyword ? {} : { naReason: noKeyword }),
241
+ },
242
+ {
243
+ id: 'keyword-in-url',
244
+ group: 'seo',
245
+ label: 'Focus keyword in URL',
246
+ weight: 1,
247
+ outcome: !hasKeyword
248
+ ? 'not-applicable'
249
+ : norm(seo.slug).includes(kw.replace(/\s+/g, '-'))
250
+ ? 'pass'
251
+ : 'fail',
252
+ hint: 'Include the focus keyword in the URL slug.',
253
+ ...(hasKeyword ? {} : { naReason: noKeyword }),
254
+ },
255
+ {
256
+ id: 'images-have-alt',
257
+ group: 'seo',
258
+ label: 'Every image has alt text',
259
+ // Weight 2: this is an accessibility failure before it is an SEO one.
260
+ weight: 2,
261
+ // Genuinely not applicable when the template renders no image slots —
262
+ // unlike an empty keyword, there is no action that would enable it.
263
+ outcome: slots.length === 0 ? 'not-applicable' : described.length === slots.length ? 'pass' : 'fail',
264
+ hint: 'Describe every image the template renders, including ones with no asset chosen yet.',
265
+ ...(slots.length === 0 ? { naReason: 'This page renders no images.' } : {}),
266
+ },
267
+ {
268
+ id: 'indexable',
269
+ group: 'seo',
270
+ label: 'Open to search engines',
271
+ // A GATE (weight 0), not a scored check. Being crawlable is the price of
272
+ // entry, not an accomplishment — scoring it hands a blank page free
273
+ // credit for doing nothing, which is exactly the floor problem that made
274
+ // the old content scorer's bands meaningless.
275
+ //
276
+ // When it fails, every other SEO check becomes not-applicable and the
277
+ // group has no score: a page deliberately hidden from search has no
278
+ // search performance to rate, and shouting "0" at someone who noindexed
279
+ // a thank-you page on purpose is noise, not information.
280
+ weight: 0,
281
+ outcome: seo.robots.index ? 'pass' : 'fail',
282
+ hint: 'This page is set to noindex — search engines will not list it.',
283
+ },
284
+ {
285
+ id: 'social-image',
286
+ group: 'seo',
287
+ label: 'Social share image',
288
+ weight: 1,
289
+ outcome: ogUnknown
290
+ ? 'not-applicable'
291
+ : og && og.width >= 1200 && ogRatio >= 1.7 && ogRatio <= 2.1
292
+ ? 'pass'
293
+ : 'fail',
294
+ hint: 'Use an image at least 1200px wide with roughly a 1.91:1 ratio.',
295
+ ...(ogUnknown ? { naReason: 'Image dimensions are not available here.' } : {}),
296
+ },
297
+ ];
298
+ }
299
+ function geoChecks(input) {
300
+ const { sections, seo, brand, author } = input;
301
+ const bodies = bodyText(sections);
302
+ const opening = bodies[0] ?? '';
303
+ const upFront = bodies.join(' ').slice(0, 170);
304
+ return [
305
+ {
306
+ id: 'direct-answer',
307
+ group: 'geo',
308
+ label: 'Opens with a direct answer',
309
+ // Weight 2: the quotable opening is what an answer engine lifts.
310
+ weight: 2,
311
+ outcome: opening.length >= 80 && opening.length <= 340 ? 'pass' : 'fail',
312
+ hint: 'Open with an 80–340 character answer to the question this page addresses.',
313
+ },
314
+ {
315
+ id: 'brand-up-front',
316
+ group: 'geo',
317
+ label: 'Brand named up front',
318
+ weight: 1,
319
+ outcome: !brand.trim()
320
+ ? 'not-applicable'
321
+ : norm(upFront).includes(norm(brand))
322
+ ? 'pass'
323
+ : 'fail',
324
+ hint: 'Name the brand in the first 170 characters so a citation attributes it.',
325
+ ...(brand.trim() ? {} : { naReason: 'No brand name configured for this site.' }),
326
+ },
327
+ {
328
+ id: 'citable-figures',
329
+ group: 'geo',
330
+ label: 'Citable figures on page',
331
+ weight: 1,
332
+ outcome: hasVisibleType(sections, 'stats') ? 'pass' : 'fail',
333
+ hint: 'Concrete figures give an answer engine something specific to quote.',
334
+ },
335
+ {
336
+ id: 'first-party-quotes',
337
+ group: 'geo',
338
+ label: 'First-party quotes',
339
+ weight: 1,
340
+ outcome: hasVisibleType(sections, 'quote') || hasVisibleType(sections, 'widget-embed')
341
+ ? 'pass'
342
+ : 'fail',
343
+ hint: 'A testimonial or pull quote provides attributable first-party material.',
344
+ },
345
+ {
346
+ id: 'structured-data',
347
+ group: 'geo',
348
+ label: 'Structured data attached',
349
+ weight: 2,
350
+ outcome: seo.schema.trim() ? 'pass' : 'fail',
351
+ hint: 'Bind a schema.org type so machines can classify the page.',
352
+ },
353
+ {
354
+ id: 'llms-txt',
355
+ group: 'geo',
356
+ label: 'llms.txt served',
357
+ // Site-level, not page-level: shown for context, weighted low because no
358
+ // page edit can change it.
359
+ weight: 1,
360
+ outcome: input.llmsTxt ? 'pass' : 'fail',
361
+ hint: 'Serve an llms.txt at the site root to declare content for AI crawlers.',
362
+ },
363
+ {
364
+ id: 'author-attributed',
365
+ group: 'geo',
366
+ label: 'Author attributed',
367
+ weight: 1,
368
+ outcome: author === undefined
369
+ ? 'not-applicable'
370
+ : author && author.name.trim() && !author.isBrandByline
371
+ ? 'pass'
372
+ : 'fail',
373
+ hint: 'Attribute a named person — a brand byline carries less authorial weight.',
374
+ ...(author === undefined ? { naReason: 'No author is resolved for this page yet.' } : {}),
375
+ },
376
+ {
377
+ id: 'snippets-allowed',
378
+ group: 'geo',
379
+ label: 'Snippets allowed',
380
+ // A GATE (weight 0), for the same reason as `indexable`. nosnippet
381
+ // blocks AI Overviews and chat citations outright, so every other GEO
382
+ // check is moot — but allowing snippets is a precondition, not a score.
383
+ weight: 0,
384
+ outcome: seo.robots.snippet ? 'pass' : 'fail',
385
+ hint: 'nosnippet blocks AI Overviews and chat citations entirely.',
386
+ },
387
+ ];
388
+ }
389
+ // ---------------------------------------------------------------------------
390
+ // Scoring
391
+ // ---------------------------------------------------------------------------
392
+ /**
393
+ * A failed gate (weight 0, outcome fail) suppresses its whole group: nothing
394
+ * else in it can be judged, so every other check becomes not-applicable and the
395
+ * group reports no score. A page held back from search has no search
396
+ * performance to rate — that is a different statement from scoring zero, and
397
+ * conflating them would nag every editor who noindexed a page on purpose.
398
+ */
399
+ function applyGates(checks) {
400
+ const failedGate = checks.find((c) => c.weight === 0 && c.outcome === 'fail');
401
+ if (!failedGate)
402
+ return checks;
403
+ return checks.map((c) => c.weight === 0
404
+ ? c
405
+ : {
406
+ ...c,
407
+ outcome: 'not-applicable',
408
+ naReason: `Not evaluated: ${failedGate.label.toLowerCase()} is off for this page.`,
409
+ });
410
+ }
411
+ function summarize(group, rawChecks) {
412
+ const checks = applyGates(rawChecks);
413
+ // Gates are reported but never scored, so they leave the denominator too.
414
+ const applicable = checks.filter((c) => c.outcome !== 'not-applicable' && c.weight > 0);
415
+ const possible = applicable.reduce((sum, c) => sum + c.weight, 0);
416
+ const earned = applicable
417
+ .filter((c) => c.outcome === 'pass')
418
+ .reduce((sum, c) => sum + c.weight, 0);
419
+ return {
420
+ group,
421
+ checks,
422
+ passed: applicable.filter((c) => c.outcome === 'pass').length,
423
+ applicable: applicable.length,
424
+ total: checks.filter((c) => c.weight > 0).length,
425
+ // No applicable checks means there is nothing to judge. Reporting 0 would
426
+ // read as failure and 100 as success; both would be inventions.
427
+ score: possible === 0 ? null : Math.round((earned / possible) * 100),
428
+ };
429
+ }
430
+ export function auditPage(input) {
431
+ return {
432
+ seo: summarize('seo', seoChecks(input)),
433
+ geo: summarize('geo', geoChecks(input)),
434
+ };
435
+ }
436
+ /**
437
+ * Thresholds measured, not guessed.
438
+ *
439
+ * `scripts/calibrate-page-audit.mjs` runs the audit over a corpus spanning
440
+ * blank pages to fully-optimised ones and reports the distribution. These
441
+ * bounds come from that run; re-run it when checks or weights change, because
442
+ * altering a weight moves the distribution and can silently invalidate them.
443
+ *
444
+ * The handoff proposed 80 / 55 against the old content analyser, whose floor
445
+ * was 47 — a scale where `poor` was nearly unreachable. This model spans the
446
+ * full range, so the boundaries can mean what they say.
447
+ */
448
+ export const SCORE_BANDS = { good: 80, fair: 55 };
449
+ export function scoreBand(score) {
450
+ if (score >= SCORE_BANDS.good)
451
+ return 'good';
452
+ if (score >= SCORE_BANDS.fair)
453
+ return 'fair';
454
+ return 'poor';
455
+ }
456
+ //# sourceMappingURL=page-audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-audit.js","sourceRoot":"","sources":["../../src/seo/page-audit.ts"],"names":[],"mappings":"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"}
@@ -170,10 +170,6 @@ export declare function resolveContentDates(params: {
170
170
  datePublished?: string;
171
171
  dateModified?: string;
172
172
  };
173
- /**
174
- * Compose page meta + JSON-LD from a CMS document + collection config.
175
- * Pure function — safe to call in any runtime (Edge, Node, browser).
176
- */
177
173
  export declare function composePageMeta(input: ComposePageMetaInput): ComposedPageMeta;
178
174
  export { detectSchemaType };
179
175
  //# sourceMappingURL=page-meta.d.ts.map