@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 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.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": "3.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
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,
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
- 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, '');
119
127
  if (brand !== '' && title.includes(brand)) return title;
120
- return template.replace('%s', title);
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
- /** 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;