@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 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": "2.0.0",
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": "2.0.0"
34
+ "@ultimat3/core": "4.0.0"
35
35
  }
36
36
  }
package/src/errors.ts CHANGED
@@ -1,5 +1,7 @@
1
- // @ultimat3/seo error codes. SEO in Ultimate is enforced, not documented: these
2
- // are build errors, so every one names the exact route file and the exact fix.
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
- src: urlFor(input.src, widths[widths.length - 1] ?? input.width, undefined),
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
- /** Search results truncate past this; validate.ts enforces it. */
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
- const brand = template.replace('%s', '').replace(TEMPLATE_SEPARATORS, '');
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('%s', title);
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
- /** Fails the build on the first issue. `x verify` calls this. */
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;