@ultimat3/seo 2.0.0 → 4.0.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/CLAUDE.md +17 -0
- package/package.json +2 -2
- package/src/errors.ts +18 -2
- package/src/images.ts +9 -5
- package/src/index.ts +0 -2
- package/src/meta.ts +15 -4
- package/src/sitemap.ts +19 -1
- package/src/validate.ts +16 -2
package/CLAUDE.md
CHANGED
|
@@ -28,6 +28,17 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
|
|
|
28
28
|
over the route manifest and the build's own stats, throwing `@ultimat3/render`'s
|
|
29
29
|
`X_BUDGET_EXCEEDED`. seo is tier 1 and cannot see a build's bytes, so it was never the package
|
|
30
30
|
that could answer. `errors.test.ts` pins the code set, so re-adding one is a failing test.
|
|
31
|
+
- **A length bound with no enforcer does not ship.** `DESCRIPTION_MIN_LENGTH` (50) sat in
|
|
32
|
+
`meta.ts` under the comment "validate.ts enforces it", was re-exported from `index.ts`, and no
|
|
33
|
+
validator anywhere read it — a 10-character description passed the gate the constant claimed to
|
|
34
|
+
fail. Deleted `As of 2026-08`, comment included; `validateMeta` enforces maxima only. Adding a
|
|
35
|
+
minimum back means adding the check AND a new `X_SEO_*` code in the same change.
|
|
36
|
+
`meta.test.ts` pins the exported `*_LENGTH` set, so a bound with no enforcer is a failing test.
|
|
37
|
+
- **The `<img src>` fallback is the LARGEST usable width, chosen with `Math.max`.**
|
|
38
|
+
`usableWidths` preserves the CALLER's order, so `widths[widths.length - 1]` was the largest only
|
|
39
|
+
because `DEFAULT_WIDTHS` happens to ascend — `widths: [1200, 640]` handed every browser without
|
|
40
|
+
`srcset` support the 640 variant of a 1200-wide image. Never re-derive it from position, and
|
|
41
|
+
never sort inside `usableWidths`: the `srcset` order is the caller's to choose.
|
|
31
42
|
- **Errors name the file, not the URL.** `RouteRecord.file` is in every cause and every fix; an agent must be able to open the source without guessing.
|
|
32
43
|
- **Fail closed, and core reads the key.** `isIndexable()` is `environment === 'production'` and
|
|
33
44
|
nothing else — `staging`, a laptop, a typo and an unset variable all disallow. `ULTIMATE_ENV` has
|
|
@@ -38,6 +49,12 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
|
|
|
38
49
|
Never invert that default and never re-read the key here.
|
|
39
50
|
- **Required schema.org fields are required in the input type.** Runtime `required()` only catches empty strings from a CMS; the type is the primary gate.
|
|
40
51
|
- **No ambient defaults for meta.** A missing description is an error, never a fallback string.
|
|
52
|
+
- **`applyTitleTemplate` is TOTAL; `validateMeta` is where a broken template is refused.**
|
|
53
|
+
`'Ultimate'.replace('%s', title)` on a template with no slot is a no-op that returned the BRAND,
|
|
54
|
+
so every route's `<title>` became the brand and the page's own title was discarded — visible only
|
|
55
|
+
as a duplicate-title issue, weeks later. The renderer runs per request, so it falls back to the
|
|
56
|
+
title rather than throwing; the refusal is `titleTemplateSlotMissing`, `X_SEO_META_MISSING`,
|
|
57
|
+
naming the file. Same split as every other check here: the renderer degrades, the gate refuses.
|
|
41
58
|
- **Head tags are CONSTRUCTED here and serialised nowhere here.** `renderMeta` returns data;
|
|
42
59
|
`HeadTag.text` is raw, and `@ultimat3/render`'s `renderHead` picks the escape from the element
|
|
43
60
|
(raw text for code, the total `\uXXXX` JSON rule for a `type` ending in `json`). `meta.ts` had a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/seo",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Enforced SEO: typed meta, JSON-LD, sitemap, robots, feeds, responsive images, perf budgets",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "4.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/errors.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// @ultimat3/seo error codes.
|
|
2
|
-
//
|
|
1
|
+
// @ultimat3/seo error codes. Every one names the exact route file and the exact edit, because an
|
|
2
|
+
// SEO rule that cannot say which route broke it is a rule nobody acts on. What they are NOT, `As
|
|
3
|
+
// of 2026-08`, is a build gate: no step of `x verify` calls the asserts that throw them (see
|
|
4
|
+
// `README.md`), so they fail the app that calls one itself and nothing else.
|
|
3
5
|
|
|
4
6
|
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
5
7
|
// errors.ts <-> images.ts: images.ts throws imageQueryInvalid() and this file spells its fix
|
|
@@ -69,6 +71,20 @@ export function metaMissing(file: string, path: string, field: string): SeoError
|
|
|
69
71
|
});
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A `titleTemplate` that cannot place the title. Reported under `X_SEO_META_MISSING` because that
|
|
76
|
+
* is what it produces — every page's `<title>` is the brand and the route's own title is gone —
|
|
77
|
+
* and the reader lands on the page about missing metadata, which is where the answer is.
|
|
78
|
+
*/
|
|
79
|
+
export function titleTemplateSlotMissing(file: string, path: string): SeoError {
|
|
80
|
+
return new SeoError({
|
|
81
|
+
code: SEO_ERROR_CODES.metaMissing,
|
|
82
|
+
cause: `${file} (route "${path}") declares a titleTemplate with no %s slot, so the page title is discarded and every route renders the brand alone`,
|
|
83
|
+
fix: `put the slot in the template — meta.titleTemplate: '%s — Ultimate' in ${file} — or delete titleTemplate and let meta.title stand alone`,
|
|
84
|
+
meta: { file, path, field: 'titleTemplate' },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
export function duplicateMeta(field: string, value: string, files: readonly string[]): SeoError {
|
|
73
89
|
return new SeoError({
|
|
74
90
|
code: SEO_ERROR_CODES.duplicateMeta,
|
package/src/images.ts
CHANGED
|
@@ -69,10 +69,6 @@ export interface ResponsiveImageOptions {
|
|
|
69
69
|
urlFor?: (src: string, width: number, format?: string) => string;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
export function extensionOf(src: string): string {
|
|
73
|
-
return (src.split('?')[0]?.split('.').pop() ?? '').toLowerCase();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
72
|
/**
|
|
77
73
|
* The one spelling of the transform query keys. `defaultUrlFor` writes them and
|
|
78
74
|
* `parseImageQuery` reads them back — a literal `'w'` in one place and a literal `'w'` in the
|
|
@@ -176,6 +172,11 @@ export function usableWidths(intrinsic: number, widths: readonly number[]): read
|
|
|
176
172
|
return usable.includes(intrinsic) ? usable : [...usable, intrinsic];
|
|
177
173
|
}
|
|
178
174
|
|
|
175
|
+
/** The widest candidate, or `undefined` for an empty list — never `Math.max()`'s `-Infinity`. */
|
|
176
|
+
function largestOf(widths: readonly number[]): number | undefined {
|
|
177
|
+
return widths.length === 0 ? undefined : Math.max(...widths);
|
|
178
|
+
}
|
|
179
|
+
|
|
179
180
|
export function srcsetFor(
|
|
180
181
|
input: ImageInput,
|
|
181
182
|
widths: readonly number[],
|
|
@@ -212,7 +213,10 @@ export function responsiveImage(
|
|
|
212
213
|
return {
|
|
213
214
|
sources,
|
|
214
215
|
img: {
|
|
215
|
-
|
|
216
|
+
// `Math.max`, not the last element: `usableWidths` preserves the CALLER's order, so the
|
|
217
|
+
// tail was the largest only because `DEFAULT_WIDTHS` happens to ascend. `widths: [1200, 640]`
|
|
218
|
+
// handed every no-`srcset` browser the 640 variant of a 1200-wide image.
|
|
219
|
+
src: urlFor(input.src, largestOf(widths) ?? input.width, undefined),
|
|
216
220
|
srcset: srcsetFor(input, widths, undefined, urlFor),
|
|
217
221
|
sizes,
|
|
218
222
|
alt: input.alt,
|
package/src/index.ts
CHANGED
|
@@ -30,7 +30,6 @@ export type {
|
|
|
30
30
|
} from './images';
|
|
31
31
|
export {
|
|
32
32
|
DEFAULT_WIDTHS,
|
|
33
|
-
extensionOf,
|
|
34
33
|
FORMAT_ORDER,
|
|
35
34
|
IMAGE_QUERY_KEYS,
|
|
36
35
|
inlineBlur,
|
|
@@ -84,7 +83,6 @@ export type {
|
|
|
84
83
|
export {
|
|
85
84
|
applyTitleTemplate,
|
|
86
85
|
DESCRIPTION_MAX_LENGTH,
|
|
87
|
-
DESCRIPTION_MIN_LENGTH,
|
|
88
86
|
hreflangSet,
|
|
89
87
|
renderMeta,
|
|
90
88
|
robotsContent,
|
package/src/meta.ts
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { absoluteUrl } from './xml';
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Search results truncate past these; `validate.ts` enforces both, and only these two exist for
|
|
9
|
+
* that reason — a `DESCRIPTION_MIN_LENGTH` shipped here with no validator reading it, so the
|
|
10
|
+
* comment promised a gate that never ran. A bound with no enforcer does not ship.
|
|
11
|
+
*/
|
|
8
12
|
export const TITLE_MAX_LENGTH = 60;
|
|
9
|
-
export const DESCRIPTION_MIN_LENGTH = 50;
|
|
10
13
|
export const DESCRIPTION_MAX_LENGTH = 160;
|
|
11
14
|
|
|
12
15
|
export interface RobotsDirectives {
|
|
@@ -105,6 +108,9 @@ export interface RenderMetaOptions {
|
|
|
105
108
|
*/
|
|
106
109
|
const TEMPLATE_SEPARATORS = /^[\s\-–—|·:>/]+|[\s\-–—|·:>/]+$/g;
|
|
107
110
|
|
|
111
|
+
/** Where the page's own title goes. Case-sensitive: `%S` names no slot, and neither does absence. */
|
|
112
|
+
export const TITLE_SLOT = '%s';
|
|
113
|
+
|
|
108
114
|
/**
|
|
109
115
|
* The containment was `template.includes(title)` — inverted, so it only ever answered true when
|
|
110
116
|
* the title EQUALLED the brand. `applyTitleTemplate('About Ultimate', '%s — Ultimate')` produced
|
|
@@ -112,9 +118,14 @@ const TEMPLATE_SEPARATORS = /^[\s\-–—|·:>/]+|[\s\-–—|·:>/]+$/g;
|
|
|
112
118
|
*/
|
|
113
119
|
export function applyTitleTemplate(title: string, template?: string): string {
|
|
114
120
|
if (template === undefined || template === '') return title;
|
|
115
|
-
|
|
121
|
+
// No slot, nothing to apply: `'Ultimate'.replace('%s', title)` is a no-op that returned the
|
|
122
|
+
// BRAND and threw the page's own title away, on every route in the app. Total on purpose —
|
|
123
|
+
// `renderMeta` runs per request, so the refusal belongs in `validate.ts`'s build gate, where it
|
|
124
|
+
// can name the file.
|
|
125
|
+
if (!template.includes(TITLE_SLOT)) return title;
|
|
126
|
+
const brand = template.replace(TITLE_SLOT, '').replace(TEMPLATE_SEPARATORS, '');
|
|
116
127
|
if (brand !== '' && title.includes(brand)) return title;
|
|
117
|
-
return template.replace(
|
|
128
|
+
return template.replace(TITLE_SLOT, title);
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
export function robotsContent(directives: RobotsDirectives): string {
|
package/src/sitemap.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// their `prerender()` enumerates, so the sitemap can never drift from what the
|
|
3
3
|
// build actually produced. Splits into an index past the 50,000-URL protocol cap.
|
|
4
4
|
|
|
5
|
+
import { assert } from '@ultimat3/core';
|
|
5
6
|
import { sitemapTooLarge } from './errors';
|
|
6
7
|
import { type ChangeFreq, expandRoute, indexableRoutes, type RouteRecord } from './routes';
|
|
7
8
|
import { absoluteUrl, attributes, escapeXml } from './xml';
|
|
@@ -134,6 +135,15 @@ function renderIndex(files: readonly SitemapFile[], options: BuildSitemapOptions
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
export function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
138
|
+
// The loop advances by `size`, so a non-positive one never moves the cursor: `maxUrls: 0` in a
|
|
139
|
+
// route config turned a build into an infinite loop allocating empty slices until the box ran
|
|
140
|
+
// out of memory. A fractional size is refused for a quieter reason — `slice` truncates it, so
|
|
141
|
+
// the groups silently stop being the size that was asked for.
|
|
142
|
+
assert(
|
|
143
|
+
Number.isSafeInteger(size) && size > 0,
|
|
144
|
+
`a chunk size must be a positive integer, got ${String(size)}: a non-positive step never advances and the loop cannot end`,
|
|
145
|
+
'pass a positive integer — buildSitemap(routes, { baseUrl, maxUrls: 50000 }), the sitemaps.org bound SITEMAP_MAX_URLS already carries',
|
|
146
|
+
);
|
|
137
147
|
const out: T[][] = [];
|
|
138
148
|
for (let index = 0; index < items.length; index += size) {
|
|
139
149
|
out.push(items.slice(index, index + size));
|
|
@@ -145,8 +155,16 @@ export async function buildSitemap(
|
|
|
145
155
|
routes: readonly RouteRecord[],
|
|
146
156
|
options: BuildSitemapOptions,
|
|
147
157
|
): Promise<SitemapResult> {
|
|
148
|
-
const urls = await sitemapUrls(routes, options);
|
|
149
158
|
const maxUrls = options.maxUrls ?? SITEMAP_MAX_URLS;
|
|
159
|
+
// Refused here and not only in `chunk`, so the answer does not depend on how many URLs the site
|
|
160
|
+
// happens to have today: `maxUrls: 2.5` is a typo whether or not this build has enough routes to
|
|
161
|
+
// reach the split, exactly as a metric refuses `maxSeries: 1.5` at declaration.
|
|
162
|
+
assert(
|
|
163
|
+
Number.isSafeInteger(maxUrls) && maxUrls > 0,
|
|
164
|
+
`buildSitemap({ maxUrls }) must be a positive integer, got ${String(maxUrls)}`,
|
|
165
|
+
'pass a positive integer — buildSitemap(routes, { baseUrl, maxUrls: 50000 }) — or omit it and take SITEMAP_MAX_URLS, the sitemaps.org bound',
|
|
166
|
+
);
|
|
167
|
+
const urls = await sitemapUrls(routes, options);
|
|
150
168
|
|
|
151
169
|
if (urls.length <= maxUrls) {
|
|
152
170
|
return {
|
package/src/validate.ts
CHANGED
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
metaTooLong,
|
|
11
11
|
SeoError,
|
|
12
12
|
type SeoErrorCode,
|
|
13
|
+
titleTemplateSlotMissing,
|
|
13
14
|
} from './errors';
|
|
14
|
-
import { applyTitleTemplate, DESCRIPTION_MAX_LENGTH, TITLE_MAX_LENGTH } from './meta';
|
|
15
|
+
import { applyTitleTemplate, DESCRIPTION_MAX_LENGTH, TITLE_MAX_LENGTH, TITLE_SLOT } from './meta';
|
|
15
16
|
import { indexableRoutes, isDynamic, type RouteRecord } from './routes';
|
|
16
17
|
import { absoluteUrl } from './xml';
|
|
17
18
|
|
|
@@ -61,6 +62,14 @@ export function validateMeta(
|
|
|
61
62
|
if (meta.title === undefined || meta.title.trim() === '') {
|
|
62
63
|
issues.push(issueOf(metaMissing(route.file, route.path, 'title'), route.path, route.file));
|
|
63
64
|
} else {
|
|
65
|
+
// An empty template is "no template" and applies nothing; a non-empty one that cannot place
|
|
66
|
+
// the title silently discards it, which is the one the renderer cannot report.
|
|
67
|
+
const template = meta.titleTemplate ?? '';
|
|
68
|
+
if (template !== '' && !template.includes(TITLE_SLOT)) {
|
|
69
|
+
issues.push(
|
|
70
|
+
issueOf(titleTemplateSlotMissing(route.file, route.path), route.path, route.file),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
64
73
|
const rendered = applyTitleTemplate(meta.title, meta.titleTemplate);
|
|
65
74
|
if (rendered.length > titleMax) {
|
|
66
75
|
issues.push(
|
|
@@ -133,7 +142,12 @@ function duplicates(index: Map<string, string[]>, field: string): MetaIssue[] {
|
|
|
133
142
|
return out;
|
|
134
143
|
}
|
|
135
144
|
|
|
136
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Throws on the first issue. `x verify` does NOT call this — `As of 2026-08` nothing outside this
|
|
147
|
+
* package imports it, and wiring it is a `HostCheck` on an existing step in
|
|
148
|
+
* `packages/cli/src/cmd-verify.ts`. Today it fails an app that calls it itself, which is what
|
|
149
|
+
* `README.md` says and what this line claimed the opposite of.
|
|
150
|
+
*/
|
|
137
151
|
export function assertMeta(report: MetaValidationReport): void {
|
|
138
152
|
const first = report.issues[0];
|
|
139
153
|
if (first === undefined) return;
|