@ultimat3/seo 3.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 +6 -0
- package/package.json +2 -2
- package/src/errors.ts +18 -2
- package/src/images.ts +0 -4
- package/src/index.ts +0 -1
- package/src/meta.ts +10 -2
- package/src/sitemap.ts +19 -1
- package/src/validate.ts +16 -2
package/CLAUDE.md
CHANGED
|
@@ -49,6 +49,12 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
|
|
|
49
49
|
Never invert that default and never re-read the key here.
|
|
50
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.
|
|
51
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.
|
|
52
58
|
- **Head tags are CONSTRUCTED here and serialised nowhere here.** `renderMeta` returns data;
|
|
53
59
|
`HeadTag.text` is raw, and `@ultimat3/render`'s `renderHead` picks the escape from the element
|
|
54
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
|
package/src/index.ts
CHANGED
package/src/meta.ts
CHANGED
|
@@ -108,6 +108,9 @@ export interface RenderMetaOptions {
|
|
|
108
108
|
*/
|
|
109
109
|
const TEMPLATE_SEPARATORS = /^[\s\-–—|·:>/]+|[\s\-–—|·:>/]+$/g;
|
|
110
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
|
+
|
|
111
114
|
/**
|
|
112
115
|
* The containment was `template.includes(title)` — inverted, so it only ever answered true when
|
|
113
116
|
* the title EQUALLED the brand. `applyTitleTemplate('About Ultimate', '%s — Ultimate')` produced
|
|
@@ -115,9 +118,14 @@ const TEMPLATE_SEPARATORS = /^[\s\-–—|·:>/]+|[\s\-–—|·:>/]+$/g;
|
|
|
115
118
|
*/
|
|
116
119
|
export function applyTitleTemplate(title: string, template?: string): string {
|
|
117
120
|
if (template === undefined || template === '') return title;
|
|
118
|
-
|
|
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, '');
|
|
119
127
|
if (brand !== '' && title.includes(brand)) return title;
|
|
120
|
-
return template.replace(
|
|
128
|
+
return template.replace(TITLE_SLOT, title);
|
|
121
129
|
}
|
|
122
130
|
|
|
123
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;
|