@ultimat3/seo 1.2.0 → 2.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 ADDED
@@ -0,0 +1,98 @@
1
+ # @ultimat3/seo — agent notes
2
+
3
+ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothing above tier 1, no external deps.
4
+
5
+ ## Boundary
6
+
7
+ | Owns | Does not own |
8
+ |---|---|
9
+ | metadata model, JSON-LD, sitemap/robots/feeds, image contract | rendering, routing, the route table itself (it consumes `RouteRecord`), **performance budgets** |
10
+
11
+ ## Hard rules
12
+
13
+ - **Enforced, not documented — and `As of 2026-08` the gate half of this package is not yet
14
+ enforcing anything.** Every check here has an `assert*` that throws a coded `SeoError` beside a
15
+ `--json`-shaped report; a check that only returns a boolean does not exist. That is a rule about
16
+ the SHAPE of a check, and it must not be read as a claim that CI fails on one. What is actually
17
+ reached from outside this package is `renderMeta` (through `@ultimat3/render`'s `seoRenderers`),
18
+ `ld.*` (an app's own `meta`), `buildFeed`, and the image contract (`parseImageQuery` /
19
+ `builtinImageDriver` / `responsiveImage`, through `@ultimat3/cli`'s `dev-assets.ts`).
20
+ `validateMeta`/`assertMeta`, `buildSitemap`, `buildRobots`, `isIndexable` and
21
+ `indexableRoutes`/`expandRoute` have **no caller anywhere** — no step of `x verify` runs them.
22
+ Wiring them is a `HostCheck` on an existing step in `packages/cli/src/cmd-verify.ts`; until that
23
+ lands, do not restate the CI claim here, in `README.md` or in the wiki.
24
+ - **Performance budgets are not this package's, `As of 2026-08`.** `checkBudgets`/`assertBudgets`/
25
+ `parseBytes`/`DEFAULT_BUDGET`/`BUDGET_UNITS`, `RouteBudget`, `RouteRecord.budget` and
26
+ `X_SEO_BUDGET_EXCEEDED` are deleted: the whole surface was callerless, and `parseBytes` reported a
27
+ malformed size string *as a budget violation*. The live gate is `@ultimat3/cli`'s `checkBudgets`
28
+ over the route manifest and the build's own stats, throwing `@ultimat3/render`'s
29
+ `X_BUDGET_EXCEEDED`. seo is tier 1 and cannot see a build's bytes, so it was never the package
30
+ that could answer. `errors.test.ts` pins the code set, so re-adding one is a failing test.
31
+ - **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
+ - **Fail closed, and core reads the key.** `isIndexable()` is `environment === 'production'` and
33
+ nothing else — `staging`, a laptop, a typo and an unset variable all disallow. `ULTIMATE_ENV` has
34
+ exactly one reader and it is `@ultimat3/core`'s (`Environment`, `tryResolveEnvironment`); this
35
+ package owns only what an *unnameable* environment means, which is core's `DEFAULT_ENVIRONMENT`
36
+ and never a throw — nothing in a web container's boot path resolves the environment
37
+ unconditionally, so a `robots.txt` render is routinely the first reader and it must answer.
38
+ Never invert that default and never re-read the key here.
39
+ - **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
+ - **No ambient defaults for meta.** A missing description is an error, never a fallback string.
41
+ - **Head tags are CONSTRUCTED here and serialised nowhere here.** `renderMeta` returns data;
42
+ `HeadTag.text` is raw, and `@ultimat3/render`'s `renderHead` picks the escape from the element
43
+ (raw text for code, the total `\uXXXX` JSON rule for a `type` ending in `json`). `meta.ts` had a
44
+ `renderHeadTags` that emitted HTML with a weaker escape — `</` neutralised, `<!--<script>` not —
45
+ and **nothing called it**; deleted `As of 2026-08`. Never add it back, and never escape `text`
46
+ at construction: it would double-escape at tier 4, and the two rules would drift apart. seo
47
+ cannot import render's escapers either — seo is tier 1, render is tier 4. `xml.ts` stays the
48
+ package's one escaper for the XML and attribute surfaces it really does emit (sitemap, feeds,
49
+ robots, `<picture>`), whose rules are the opposite of a raw-text element's.
50
+ - **One producer of the JSON-LD tags, and it is `renderMeta`.** `ld.*` builds nodes; `renderMeta`
51
+ emits `meta.ld` as one `<script type="application/ld+json">` **per node**. `ld.ts` also carried a
52
+ `renderLd(nodes)` that collapsed the same nodes into ONE script with a `@graph` — a second
53
+ serialisation of one input, exported, callerless, and the reason `@ultimat3/render`'s
54
+ `head-seo.ts` had to write down that it binds no `renderLd` half on purpose. Deleted
55
+ `As of 2026-08`. Never add it back: an app that found it on the public surface and called it from
56
+ `meta` emitted its graph twice, which is exactly the duplicate that comment was defending against.
57
+ - **`builtinImageDriver({ read })` takes its reader.** `TransformRequest.src` is a string, and
58
+ whether that string is a path, a storage key or a URL is the app's fact, not seo's — never add
59
+ a filesystem fallback. Pixels come from `@ultimat3/core`'s pipeline; seo owns no second scaler,
60
+ and the driver reports the size it probed off the output, never the size that was requested.
61
+ - **One spelling of the transform query keys.** `IMAGE_QUERY_KEYS` in `images.ts` is the only
62
+ place `w`/`f`/`q` are spelled; `defaultUrlFor` writes them and `parseImageQuery` is the only
63
+ reader — never hand-roll either half against a literal. A present-but-unusable `w` or `q`
64
+ (`?w=0`, `?q=150`, empty, negative, fractional, or so many digits that `parseInt` returns
65
+ `Infinity`) throws `X_IMAGE_QUERY_INVALID` instead of
66
+ falling back to the untransformed original — that silent fallback is the layout shift this
67
+ whole contract exists to prevent. `parseImageQuery` never validates `f` against real format
68
+ names; that refusal stays `image-driver.ts`'s `X_IMAGE_UNSUPPORTED`, so one bad URL never
69
+ carries two codes.
70
+ - **A feed date never throws and never lies.** `feed-dates.ts` is the only place a feed timestamp
71
+ is parsed or formatted; `buildFeed` resolves every date once, so no builder ever sees a string it
72
+ has to parse. An item date that will not parse is **absent** — the element is omitted (Atom's
73
+ required `<updated>` falls back to the feed's) — never `Invalid Date`, never today standing in
74
+ for it, and never a `RangeError` out of a route a reader is polling. Scan a feed with a loop, not
75
+ `Math.max(...times)`: a spread is one argument per item and the engine's limit is the caller's
76
+ stack depth, not the feed's size. "Now" arrives through `BuildFeedOptions.clock`; nothing in
77
+ `rss.ts` reads a clock of its own.
78
+ - Only `site/` routes are SEO-checked — `app/` is behind auth and crawlers never authenticate.
79
+
80
+ ## Files
81
+
82
+ | Path | Responsibility |
83
+ |---|---|
84
+ | `routes.ts` | the `RouteRecord` shape + `indexableRoutes` / `expandRoute` |
85
+ | `meta.ts` | model + `renderMeta()`; the only place head tags are constructed, and no place they are serialised |
86
+ | `validate.ts` | the gate; `MetaIssue` is the serialisable projection of a `SeoError` |
87
+ | `xml.ts` | all escaping. Never hand-roll an escape in another module |
88
+ | `rss.ts` | `buildFeed()` — the three feed formats; owns markup, never a date |
89
+ | `feed-dates.ts` | all timestamp parsing and formatting for feeds. Never `Date.parse` in another module |
90
+ | `images.ts` | what the markup promises: `srcset` widths, `<picture>` order, inlined dimensions — plus `IMAGE_QUERY_KEYS` and `parseImageQuery`, the contract that reads a minted URL back. Decodes nothing |
91
+ | `image-driver.ts` | the bytes behind that promise: `ImageTransformDriver` + `builtinImageDriver({ read })` over core's pipeline — png/jpeg only |
92
+
93
+ ## Commands
94
+
95
+ ```
96
+ bun test packages/seo
97
+ bun run --filter @ultimat3/seo typecheck
98
+ ```
package/README.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # @ultimat3/seo 🔍
2
2
 
3
- SEO is **enforced, not documented**. These are build errors, not lint warnings.
3
+ SEO is **enforced, not documented**: every rule below is an `assert*` that throws a coded
4
+ `SeoError` with the route file and the exact edit, never a lint warning.
5
+
6
+ `As of 2026-08` the half that *calls* those asserts is missing — nothing outside this package
7
+ imports `assertMeta`, `buildSitemap` or `buildRobots`, so no step of `x verify` runs them yet.
8
+ Wiring them is a `HostCheck` on an existing step in `packages/cli/src/cmd-verify.ts`. The shapes
9
+ below are what will fail the build; today they fail an app that calls them itself.
4
10
 
5
11
  ## What fails the build
6
12
 
@@ -11,9 +17,12 @@ SEO is **enforced, not documented**. These are build errors, not lint warnings.
11
17
  | `X_SEO_META_TOO_LONG` | title > 60 chars, description > 160 | the tail is truncated in results — the words are paid for and never read |
12
18
  | `X_SEO_CANONICAL_MISMATCH` | `meta.canonical` does not resolve to the route's own URL | a wrong canonical de-indexes the page in favour of another |
13
19
  | `X_LD_INVALID` | a JSON-LD node missing a required schema.org field | invalid structured data drops the rich result silently |
14
- | `X_SEO_BUDGET_EXCEEDED` | a route over its `js` / `css` / `lcp` / `cls` / `inp` budget | performance is a ranking factor and regressions are invisible without a gate |
15
20
  | `X_SITEMAP_TOO_LARGE` | the sitemap index exceeds 50,000 files | past the protocol limit the whole sitemap is discarded |
16
21
 
22
+ Performance budgets are **not** here: `x verify`'s `budgets` step is `@ultimat3/cli`'s
23
+ `checkBudgets`, over the route manifest and the build's own stats, and it throws
24
+ `@ultimat3/render`'s `X_BUDGET_EXCEEDED`. This package is tier 1 and cannot see a build's bytes.
25
+
17
26
  Every error names the exact route **file** and the exact edit:
18
27
 
19
28
  ```
@@ -32,9 +41,9 @@ X_SEO_META_MISSING: a site/ route is missing required metadata
32
41
  | `sitemap.ts` | `buildSitemap()` from the route table + each route's `prerender()`, per-locale alternates, automatic index splitting past 50k |
33
42
  | `robots.ts` | `buildRobots()`, environment-aware and fail-closed |
34
43
  | `rss.ts` | `buildFeed()` → RSS 2.0 + Atom + JSON Feed from one item list |
44
+ | `feed-dates.ts` | the one place a feed timestamp is parsed or formatted — an item date that will not parse is *absent*, never `Invalid Date` and never a crash |
35
45
  | `images.ts` | `srcset` widths, AVIF → WebP → original, inlined intrinsic dimensions, and `parseImageQuery()` — reads a minted URL back into a transform request |
36
46
  | `image-driver.ts` | `ImageTransformDriver` + `builtinImageDriver()`: the variant bytes and the blur placeholder |
37
- | `budgets.ts` | `checkBudgets()` / `assertBudgets()`, the CI gate |
38
47
 
39
48
  ## Type-level enforcement
40
49
 
@@ -49,8 +58,9 @@ weeks later.
49
58
  ## robots.txt is fail-closed
50
59
 
51
60
  Only the literal string `production` in `ULTIMATE_ENV` / `NODE_ENV` opts a deploy
52
- into indexing. A typo, an unset variable, or a branch deploy all resolve to
53
- `preview`, and preview emits:
61
+ into indexing. The environment is `@ultimat3/core`'s `Environment` and its reader
62
+ is core's this package owns no second one. A branch deploy (`staging`), a
63
+ laptop, a typo and an unset variable are all "not production", and each emits:
54
64
 
55
65
  ```
56
66
  User-agent: *
@@ -144,6 +154,6 @@ export const config = defineRoute({
144
154
  ## Commands
145
155
 
146
156
  ```
147
- bun test # meta, validation, JSON-LD, sitemap, robots, feeds, images, budgets
157
+ bun test # meta, validation, JSON-LD, sitemap, robots, feeds, images, error codes
148
158
  bun run typecheck
149
159
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/seo",
3
- "version": "1.2.0",
3
+ "version": "2.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",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,6 +31,6 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/core": "1.2.0"
34
+ "@ultimat3/core": "2.0.0"
34
35
  }
35
36
  }
package/src/errors.ts CHANGED
@@ -14,7 +14,6 @@ export const SEO_ERROR_CODES = {
14
14
  metaTooLong: 'X_SEO_META_TOO_LONG',
15
15
  canonicalMismatch: 'X_SEO_CANONICAL_MISMATCH',
16
16
  ldInvalid: 'X_LD_INVALID',
17
- budgetExceeded: 'X_SEO_BUDGET_EXCEEDED',
18
17
  sitemapTooLarge: 'X_SITEMAP_TOO_LARGE',
19
18
  imageQueryInvalid: 'X_IMAGE_QUERY_INVALID',
20
19
  } as const;
@@ -23,9 +22,10 @@ export type SeoErrorCode = (typeof SEO_ERROR_CODES)[keyof typeof SEO_ERROR_CODES
23
22
 
24
23
  /**
25
24
  * Every code here is seo's own, so the registration is unconditional and atomic — a collision must
26
- * surface as X_ERROR_CODE_DUPLICATE, never as a first-writer-wins title. `X_SEO_BUDGET_EXCEEDED` is
27
- * namespaced for exactly that reason: `X_BUDGET_EXCEEDED` is `@ultimat3/render`'s, seo is tier 1
28
- * and cannot import render, so sharing the code left the meaning up to import order.
25
+ * surface as X_ERROR_CODE_DUPLICATE, never as a first-writer-wins title. A performance budget is
26
+ * deliberately not among them: `@ultimat3/render` owns `X_BUDGET_EXCEEDED` and `@ultimat3/cli`'s
27
+ * `checkBudgets` is the gate that throws it, so seo naming the same condition was a second code
28
+ * for one fault whose only thrower was its own test. `errors.test.ts` pins the set.
29
29
  * `X_NOT_IMPLEMENTED` and `X_IMAGE_UNSUPPORTED` are core's; `SeoError` throws them, untitled here.
30
30
  */
31
31
  registerErrorCodes({
@@ -34,7 +34,6 @@ registerErrorCodes({
34
34
  X_SEO_META_TOO_LONG: { title: 'title or description exceeds what search results render' },
35
35
  X_SEO_CANONICAL_MISMATCH: { title: 'canonical URL does not match the route path' },
36
36
  X_LD_INVALID: { title: 'JSON-LD node is missing a required schema.org field' },
37
- X_SEO_BUDGET_EXCEEDED: { title: 'route exceeded its performance budget' },
38
37
  X_SITEMAP_TOO_LARGE: { title: 'sitemap exceeds the 50,000-entry protocol limit' },
39
38
  X_IMAGE_QUERY_INVALID: { title: 'an image transform query parameter is present but unusable' },
40
39
  });
@@ -106,22 +105,6 @@ export function ldInvalid(type: string, field: string, hint: string): SeoError {
106
105
  });
107
106
  }
108
107
 
109
- export function budgetExceeded(
110
- route: string,
111
- file: string,
112
- metric: string,
113
- limit: number,
114
- actual: number,
115
- unit: string,
116
- ): SeoError {
117
- return new SeoError({
118
- code: SEO_ERROR_CODES.budgetExceeded,
119
- cause: `${route} (${file}) ${metric} is ${actual}${unit}, budget is ${limit}${unit}`,
120
- fix: `x analyze ${route} --json # then trim, or raise budget.${metric} in ${file}`,
121
- meta: { route, file, metric, limit, actual, unit },
122
- });
123
- }
124
-
125
108
  export function sitemapTooLarge(count: number, max: number): SeoError {
126
109
  return new SeoError({
127
110
  code: SEO_ERROR_CODES.sitemapTooLarge,
@@ -0,0 +1,49 @@
1
+ // The one place a feed timestamp is parsed or formatted. Item dates are app data — a CMS column,
2
+ // a front-matter line — so a string that is not a date at all is a live possibility, and a feed is
3
+ // a page a reader is polling, not a build step: `Date.parse` answers `NaN`, and `NaN` reaches
4
+ // `toISOString()` as a `RangeError` that takes the whole route down over one bad row.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+
8
+ /** Milliseconds for a timestamp, or `undefined` when the string is not a date at all. */
9
+ export function epochOf(iso: string | undefined): number | undefined {
10
+ if (iso === undefined) return undefined;
11
+ const ms = Date.parse(iso);
12
+ return Number.isNaN(ms) ? undefined : ms;
13
+ }
14
+
15
+ /**
16
+ * The newest instant in a feed, or `undefined` when nothing in it carries a usable one.
17
+ *
18
+ * A loop, never `Math.max(...times)`: a spread passes one argument per item, so the call overflows
19
+ * the engine's stack in proportion to how well the blog did — and it overflows sooner the deeper
20
+ * the request stack already is, which makes the size that breaks it a property of the caller
21
+ * rather than of the feed.
22
+ */
23
+ export function newestEpoch(times: Iterable<number | undefined>): number | undefined {
24
+ let max: number | undefined;
25
+ for (const ms of times) {
26
+ if (ms !== undefined && (max === undefined || ms > max)) max = ms;
27
+ }
28
+ return max;
29
+ }
30
+
31
+ /**
32
+ * The clock's instant, for a feed whose items carry no usable timestamp of their own. A `Clock`
33
+ * handing back an invalid `Date` is the caller's bug, and the epoch is a wrong-but-renderable
34
+ * answer — throwing here would be the crash this module exists to prevent, one seam over.
35
+ */
36
+ export function nowEpoch(clock: Clock): number {
37
+ const ms = clock.now().getTime();
38
+ return Number.isFinite(ms) ? ms : 0;
39
+ }
40
+
41
+ /** RFC 3339, for Atom and JSON Feed. Takes an instant, so it can never be handed a `NaN`. */
42
+ export function isoOf(ms: number): string {
43
+ return new Date(ms).toISOString();
44
+ }
45
+
46
+ /** RFC 822, RSS 2.0's only date format. Same guarantee. */
47
+ export function rfc822Of(ms: number): string {
48
+ return new Date(ms).toUTCString();
49
+ }
package/src/images.ts CHANGED
@@ -118,6 +118,26 @@ function parseQuality(raw: string): number {
118
118
  return quality;
119
119
  }
120
120
 
121
+ /**
122
+ * A safe integer is not a servable width. `?w=99999999` clears every gate above and then asks the
123
+ * encoder for a row buffer nothing can allocate — and on a surface that CACHES its output, each
124
+ * distinct width mints a new stored object, so an unbounded `w` is unbounded writes as well as one
125
+ * bad allocation. 8192 is past every display this can legitimately serve (`DEFAULT_SRCSET_WIDTHS`
126
+ * tops out two orders below it).
127
+ *
128
+ * Hard, not configurable: the shape `packages/mcp/src/query-limits.ts:48-56` uses — a caller may
129
+ * narrow a request by asking for less, never widen it by asking for more.
130
+ */
131
+ const MAX_IMAGE_WIDTH = 8192;
132
+
133
+ function parseWidth(raw: string): number {
134
+ const width = parsePositiveInt(IMAGE_QUERY_KEYS.width, raw);
135
+ if (width > MAX_IMAGE_WIDTH) {
136
+ throw imageQueryInvalid(IMAGE_QUERY_KEYS.width, raw, `must be ${MAX_IMAGE_WIDTH} or less`);
137
+ }
138
+ return width;
139
+ }
140
+
121
141
  /**
122
142
  * Naming no *real* format is deliberately not refused here: `image-driver.ts`'s
123
143
  * `requestedFormat` already owns "is this an encodable format", and throwing in two places
@@ -143,7 +163,7 @@ export function parseImageQuery(params: URLSearchParams): ImageQuery | null {
143
163
  if (rawWidth === null && rawFormat === null && rawQuality === null) return null;
144
164
 
145
165
  return {
146
- ...(rawWidth === null ? {} : { width: parsePositiveInt(IMAGE_QUERY_KEYS.width, rawWidth) }),
166
+ ...(rawWidth === null ? {} : { width: parseWidth(rawWidth) }),
147
167
  ...(rawFormat === null ? {} : { format: parseFormat(rawFormat) }),
148
168
  ...(rawQuality === null ? {} : { quality: parseQuality(rawQuality) }),
149
169
  };
package/src/index.ts CHANGED
@@ -1,10 +1,7 @@
1
1
  // The public surface of @ultimat3/seo. Explicit named exports only.
2
2
 
3
- export type { BudgetMeasurement, BudgetMetric, BudgetReport, BudgetViolation } from './budgets';
4
- export { assertBudgets, BUDGET_UNITS, checkBudgets, DEFAULT_BUDGET, parseBytes } from './budgets';
5
3
  export type { SeoErrorCode, SeoErrorInit } from './errors';
6
4
  export {
7
- budgetExceeded,
8
5
  canonicalMismatch,
9
6
  duplicateMeta,
10
7
  imageQueryInvalid,
@@ -70,7 +67,6 @@ export {
70
67
  Organization,
71
68
  Person,
72
69
  Product,
73
- renderLd,
74
70
  SoftwareApplication,
75
71
  WebSite,
76
72
  } from './ld';
@@ -90,16 +86,15 @@ export {
90
86
  DESCRIPTION_MAX_LENGTH,
91
87
  DESCRIPTION_MIN_LENGTH,
92
88
  hreflangSet,
93
- renderHeadTags,
94
89
  renderMeta,
95
90
  robotsContent,
96
91
  TITLE_MAX_LENGTH,
97
92
  } from './meta';
98
- export type { RobotsConfig, RobotsGroup, SeoEnvironment } from './robots';
99
- export { buildRobots, isIndexable, resolveEnvironment } from './robots';
100
- export type { ChangeFreq, RenderMode, RouteBudget, RouteRecord, Surface } from './routes';
93
+ export type { RobotsConfig, RobotsGroup } from './robots';
94
+ export { buildRobots, isIndexable } from './robots';
95
+ export type { ChangeFreq, RenderMode, RouteRecord, Surface } from './routes';
101
96
  export { expandRoute, indexableRoutes, isDynamic } from './routes';
102
- export type { Feed, FeedAuthor, FeedChannel, FeedItem } from './rss';
97
+ export type { BuildFeedOptions, Feed, FeedAuthor, FeedChannel, FeedItem } from './rss';
103
98
  export { buildFeed } from './rss';
104
99
  export type {
105
100
  BuildSitemapOptions,
package/src/ld.ts CHANGED
@@ -4,7 +4,6 @@
4
4
  // type system cannot (a value read from a CMS).
5
5
 
6
6
  import { ldInvalid } from './errors';
7
- import type { HeadTag } from './meta';
8
7
 
9
8
  export type JsonLd = Readonly<Record<string, unknown>>;
10
9
 
@@ -310,16 +309,3 @@ export const ld = {
310
309
  SoftwareApplication,
311
310
  WebSite,
312
311
  } as const;
313
-
314
- /** One `<script type="application/ld+json">` holding a @graph of every node. */
315
- export function renderLd(nodes: readonly JsonLd[]): HeadTag {
316
- const payload =
317
- nodes.length === 1
318
- ? nodes[0]
319
- : { '@context': LD_CONTEXT, '@graph': nodes.map(({ '@context': _drop, ...rest }) => rest) };
320
- return {
321
- tag: 'script',
322
- attrs: { type: 'application/ld+json' },
323
- text: JSON.stringify(payload),
324
- };
325
- }
package/src/meta.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // for a global default: a route that does not declare a description does not get
3
3
  // one, it fails the build (see validate.ts).
4
4
 
5
- import { absoluteUrl, attributes, escapeXml } from './xml';
5
+ import { absoluteUrl } from './xml';
6
6
 
7
7
  /** Search results truncate past this; validate.ts enforces it. */
8
8
  export const TITLE_MAX_LENGTH = 60;
@@ -83,7 +83,12 @@ export interface RouteMeta {
83
83
  export interface HeadTag {
84
84
  readonly tag: 'title' | 'meta' | 'link' | 'script';
85
85
  readonly attrs: Readonly<Record<string, string>>;
86
- /** Text content, already safe to embed for `title`; escaped on render. */
86
+ /**
87
+ * Text content, raw. Escaping is the RENDERER's — `@ultimat3/render`'s `renderHead` picks the
88
+ * rule from the element and, for a `type` ending in `json`, from the fact that the body is data.
89
+ * Escaping here would double-escape there, and a second escaper is how one of them ends up
90
+ * missing a character.
91
+ */
87
92
  readonly text?: string;
88
93
  }
89
94
 
@@ -94,9 +99,22 @@ export interface RenderMetaOptions {
94
99
  path?: string;
95
100
  }
96
101
 
102
+ /**
103
+ * Separators a title template puts between the slot and the brand. Stripped so the BRAND is what
104
+ * the containment test reads: `'%s — Ultimate'` means the brand is `Ultimate`, not `— Ultimate`.
105
+ */
106
+ const TEMPLATE_SEPARATORS = /^[\s\-–—|·:>/]+|[\s\-–—|·:>/]+$/g;
107
+
108
+ /**
109
+ * The containment was `template.includes(title)` — inverted, so it only ever answered true when
110
+ * the title EQUALLED the brand. `applyTitleTemplate('About Ultimate', '%s — Ultimate')` produced
111
+ * `'About Ultimate — Ultimate'`, which `validate.ts` then measured against `TITLE_MAX_LENGTH`.
112
+ */
97
113
  export function applyTitleTemplate(title: string, template?: string): string {
98
114
  if (template === undefined || template === '') return title;
99
- return template.includes(title) ? title : template.replace('%s', title);
115
+ const brand = template.replace('%s', '').replace(TEMPLATE_SEPARATORS, '');
116
+ if (brand !== '' && title.includes(brand)) return title;
117
+ return template.replace('%s', title);
100
118
  }
101
119
 
102
120
  export function robotsContent(directives: RobotsDirectives): string {
@@ -254,16 +272,10 @@ export function renderMeta(meta: RouteMeta, options: RenderMetaOptions = {}): re
254
272
  return tags;
255
273
  }
256
274
 
257
- /** Serialise head tags to HTML. `<script>` content is JSON, escaped for `</`. */
258
- export function renderHeadTags(tags: readonly HeadTag[]): string {
259
- return tags
260
- .map((tag) => {
261
- if (tag.tag === 'title') return `<title>${escapeXml(tag.text ?? '')}</title>`;
262
- if (tag.tag === 'script') {
263
- const safe = (tag.text ?? '').replaceAll('</', '<\\/');
264
- return `<script${attributes(tag.attrs)}>${safe}</script>`;
265
- }
266
- return `<${tag.tag}${attributes(tag.attrs)}>`;
267
- })
268
- .join('\n');
269
- }
275
+ // `renderHeadTags` lived here and was deleted `As of 2026-08`. It serialised these tags to HTML,
276
+ // nothing called it, and its escaping was WEAKER than the path every document actually takes
277
+ // (`@ultimat3/render`'s `renderHead`): it neutralised `</` and not `<!--<script>`, and it applied
278
+ // that code-shaped rule to a JSON body where the total `\uXXXX` rule is available and cannot be
279
+ // got wrong. Exported and vulnerable is a trap — it looks authoritative because it lives in the
280
+ // package named `seo`. This module CONSTRUCTS head tags; serialising them is tier 4's job, and
281
+ // there is now one way to do it. seo cannot borrow render's escapers either: render is tier 4.
package/src/robots.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  // robots.txt generation. Environment-aware, and the default is the safe one:
2
2
  // anything that is not explicitly production emits `Disallow: /`, because a
3
- // preview deploy that gets indexed outranks and cannibalises the real site.
3
+ // branch deploy that gets indexed outranks and cannibalises the real site.
4
4
 
5
+ import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from '@ultimat3/core';
5
6
  import { absoluteUrl } from './xml';
6
7
 
7
- export type SeoEnvironment = 'production' | 'preview' | 'development' | 'test';
8
-
9
8
  export interface RobotsGroup {
10
9
  /** One or more user agents this group applies to. */
11
10
  userAgent: string | readonly string[];
@@ -16,8 +15,8 @@ export interface RobotsGroup {
16
15
 
17
16
  export interface RobotsConfig {
18
17
  baseUrl: string;
19
- /** Omitted means "resolve from the environment", which defaults to preview. */
20
- environment?: SeoEnvironment | undefined;
18
+ /** Omitted means "resolve from the environment"; anything but `production` disallows all. */
19
+ environment?: Environment | undefined;
21
20
  groups?: readonly RobotsGroup[];
22
21
  /** Sitemap paths or absolute URLs. Only emitted in production. */
23
22
  sitemaps?: readonly string[];
@@ -26,21 +25,22 @@ export interface RobotsConfig {
26
25
  }
27
26
 
28
27
  /**
29
- * Fail-closed: only the exact string `production` opts a deploy into indexing.
30
- * A typo, an unset variable, or a branch deploy all resolve to `preview`.
28
+ * Fail-closed: only the exact string `production` opts a deploy into indexing. A branch deploy
29
+ * (`staging`), a laptop, a typo and an unset variable are all "not production" and all disallow.
31
30
  */
32
- export function resolveEnvironment(
33
- env: Readonly<Record<string, string | undefined>> = process.env,
34
- ): SeoEnvironment {
35
- const raw = env['ULTIMATE_ENV'] ?? env['NODE_ENV'];
36
- if (raw === 'production') return 'production';
37
- if (raw === 'test') return 'test';
38
- if (raw === 'development') return 'development';
39
- return 'preview';
31
+ export function isIndexable(environment: Environment): boolean {
32
+ return environment === 'production';
40
33
  }
41
34
 
42
- export function isIndexable(environment: SeoEnvironment): boolean {
43
- return environment === 'production';
35
+ /**
36
+ * `@ultimat3/core` owns the read of `ULTIMATE_ENV`; this module owns only what an unreadable one
37
+ * means for a crawler. A typo in that key throws there, and a `robots.txt` render can be the first
38
+ * thing in a web process to ask — nothing in the container's boot path resolves the environment
39
+ * unconditionally — so an unnameable deploy resolves to core's own default here. The body was
40
+ * already going to be `Disallow: /`; a 500 would only cost the operator the reason.
41
+ */
42
+ function ambientEnvironment(): Environment {
43
+ return tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT;
44
44
  }
45
45
 
46
46
  function agents(userAgent: string | readonly string[]): readonly string[] {
@@ -48,7 +48,7 @@ function agents(userAgent: string | readonly string[]): readonly string[] {
48
48
  }
49
49
 
50
50
  export function buildRobots(config: RobotsConfig): string {
51
- const environment = config.environment ?? resolveEnvironment();
51
+ const environment = config.environment ?? ambientEnvironment();
52
52
  const lines: string[] = [`# environment: ${environment}`];
53
53
 
54
54
  if (!isIndexable(environment)) {
package/src/routes.ts CHANGED
@@ -11,18 +11,6 @@ export type Surface = 'site' | 'app' | 'api';
11
11
 
12
12
  export type ChangeFreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
13
13
 
14
- export interface RouteBudget {
15
- /** Byte budgets accept `'40kb'` or a raw number of bytes. */
16
- js?: string | number;
17
- css?: string | number;
18
- /** Milliseconds. */
19
- lcp?: number;
20
- /** Unitless layout-shift score. */
21
- cls?: number;
22
- /** Milliseconds. */
23
- inp?: number;
24
- }
25
-
26
14
  export interface RouteRecord {
27
15
  /** URL pattern, e.g. `/blog/:slug`. */
28
16
  path: string;
@@ -33,7 +21,6 @@ export interface RouteRecord {
33
21
  meta?: RouteMeta;
34
22
  /** Concrete paths for a dynamic route, from `defineRoute({ prerender })`. */
35
23
  prerender?: () => readonly string[] | Promise<readonly string[]>;
36
- budget?: RouteBudget;
37
24
  /** Keep out of the sitemap and emit `noindex`. */
38
25
  noindex?: boolean;
39
26
  lastmod?: string;
package/src/rss.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  // one item list, because a site that ships RSS but not JSON Feed has simply
3
3
  // picked a winner for its readers.
4
4
 
5
+ import { type Clock, systemClock } from '@ultimat3/core';
6
+ import { epochOf, isoOf, newestEpoch, nowEpoch, rfc822Of } from './feed-dates';
5
7
  import { absoluteUrl, cdata, escapeXml, xmlElement } from './xml';
6
8
 
7
9
  export interface FeedAuthor {
@@ -48,26 +50,50 @@ export interface Feed {
48
50
  readonly json: string;
49
51
  }
50
52
 
51
- function newest(items: readonly FeedItem[]): string {
52
- const times = items.map((item) => Date.parse(item.updated ?? item.published));
53
- const max = times.length === 0 ? Date.now() : Math.max(...times);
54
- return new Date(max).toISOString();
53
+ export interface BuildFeedOptions {
54
+ /**
55
+ * Where "now" comes from when no item carries a usable timestamp. Defaults to `systemClock`;
56
+ * pass `frozenClock(at)` and the three formats are byte-for-byte reproducible.
57
+ */
58
+ clock?: Clock;
55
59
  }
56
60
 
57
- function rfc822(iso: string): string {
58
- return new Date(iso).toUTCString();
61
+ /**
62
+ * An item with its timestamps already resolved, so no builder below ever parses a date. A date the
63
+ * app supplied that will not parse is `undefined` here, and every format omits the element rather
64
+ * than emitting `Invalid Date` — a feed reader is entitled to reject a document over one, and the
65
+ * other twenty entries are fine.
66
+ */
67
+ interface DatedItem {
68
+ readonly item: FeedItem;
69
+ readonly published: number | undefined;
70
+ readonly updated: number | undefined;
59
71
  }
60
72
 
61
- function buildRss(channel: FeedChannel, items: readonly FeedItem[], updated: string): string {
73
+ function dated(item: FeedItem): DatedItem {
74
+ return { item, published: epochOf(item.published), updated: epochOf(item.updated) };
75
+ }
76
+
77
+ /**
78
+ * Newest first. An item with no usable `published` sorts last, rather than making the comparator
79
+ * answer `NaN` — which hands the order of the whole feed to the engine's sort implementation.
80
+ */
81
+ function byNewest(a: DatedItem, b: DatedItem): number {
82
+ if (a.published === undefined) return b.published === undefined ? 0 : 1;
83
+ if (b.published === undefined) return -1;
84
+ return b.published - a.published;
85
+ }
86
+
87
+ function buildRss(channel: FeedChannel, items: readonly DatedItem[], updated: number): string {
62
88
  const self = absoluteUrl(channel.siteUrl, channel.feedUrl);
63
89
  const entries = items
64
- .map((item) => {
90
+ .map(({ item, published }) => {
65
91
  const parts = [
66
92
  xmlElement('title', item.title),
67
93
  xmlElement('link', item.url),
68
94
  ` <guid isPermaLink="false">${escapeXml(item.id)}</guid>`,
69
- xmlElement('pubDate', rfc822(item.published)),
70
95
  ];
96
+ if (published !== undefined) parts.push(xmlElement('pubDate', rfc822Of(published)));
71
97
  if (item.summary !== undefined) parts.push(xmlElement('description', item.summary));
72
98
  if (item.contentHtml !== undefined) {
73
99
  parts.push(` <content:encoded>${cdata(item.contentHtml)}</content:encoded>`);
@@ -85,7 +111,7 @@ function buildRss(channel: FeedChannel, items: readonly FeedItem[], updated: str
85
111
  ` ${xmlElement('link', channel.siteUrl)}`,
86
112
  ` ${xmlElement('description', channel.description)}`,
87
113
  ` ${xmlElement('language', channel.language)}`,
88
- ` ${xmlElement('lastBuildDate', rfc822(updated))}`,
114
+ ` ${xmlElement('lastBuildDate', rfc822Of(updated))}`,
89
115
  ` <atom:link href="${escapeXml(self)}" rel="self" type="application/rss+xml"/>`,
90
116
  channel.copyright === undefined ? '' : ` ${xmlElement('copyright', channel.copyright)}`,
91
117
  entries,
@@ -97,17 +123,21 @@ function buildRss(channel: FeedChannel, items: readonly FeedItem[], updated: str
97
123
  .join('\n');
98
124
  }
99
125
 
100
- function buildAtom(channel: FeedChannel, items: readonly FeedItem[], updated: string): string {
126
+ function buildAtom(channel: FeedChannel, items: readonly DatedItem[], updated: number): string {
101
127
  const self = absoluteUrl(channel.siteUrl, channel.feedUrl);
102
128
  const entries = items
103
- .map((item) => {
129
+ .map(({ item, published, updated: itemUpdated }) => {
104
130
  const parts = [
105
131
  ` ${xmlElement('title', item.title)}`,
106
132
  ` <link href="${escapeXml(item.url)}"/>`,
107
133
  ` ${xmlElement('id', item.id)}`,
108
- ` ${xmlElement('updated', item.updated ?? item.published)}`,
109
- ` ${xmlElement('published', item.published)}`,
134
+ // Atom requires <updated> on every entry, so an entry with no usable date of its own
135
+ // carries the feed's — the one instant in the document that is always real.
136
+ ` ${xmlElement('updated', isoOf(itemUpdated ?? published ?? updated))}`,
110
137
  ];
138
+ if (published !== undefined) {
139
+ parts.push(` ${xmlElement('published', isoOf(published))}`);
140
+ }
111
141
  if (item.summary !== undefined) parts.push(` ${xmlElement('summary', item.summary)}`);
112
142
  if (item.contentHtml !== undefined) {
113
143
  parts.push(` <content type="html">${escapeXml(item.contentHtml)}</content>`);
@@ -128,7 +158,7 @@ function buildAtom(channel: FeedChannel, items: readonly FeedItem[], updated: st
128
158
  ` ${xmlElement('title', channel.title)}`,
129
159
  ` ${xmlElement('subtitle', channel.description)}`,
130
160
  ` ${xmlElement('id', channel.siteUrl)}`,
131
- ` ${xmlElement('updated', updated)}`,
161
+ ` ${xmlElement('updated', isoOf(updated))}`,
132
162
  ` <link href="${escapeXml(channel.siteUrl)}"/>`,
133
163
  ` <link href="${escapeXml(self)}" rel="self" type="application/atom+xml"/>`,
134
164
  entries,
@@ -137,7 +167,7 @@ function buildAtom(channel: FeedChannel, items: readonly FeedItem[], updated: st
137
167
  ].join('\n');
138
168
  }
139
169
 
140
- function buildJsonFeed(channel: FeedChannel, items: readonly FeedItem[]): string {
170
+ function buildJsonFeed(channel: FeedChannel, items: readonly DatedItem[]): string {
141
171
  return `${JSON.stringify(
142
172
  {
143
173
  version: 'https://jsonfeed.org/version/1.1',
@@ -148,15 +178,15 @@ function buildJsonFeed(channel: FeedChannel, items: readonly FeedItem[]): string
148
178
  language: channel.language,
149
179
  icon: channel.icon,
150
180
  authors: channel.author === undefined ? undefined : [channel.author],
151
- items: items.map((item) => ({
181
+ items: items.map(({ item, published, updated }) => ({
152
182
  id: item.id,
153
183
  url: item.url,
154
184
  title: item.title,
155
185
  summary: item.summary,
156
186
  content_html: item.contentHtml,
157
187
  image: item.image,
158
- date_published: item.published,
159
- date_modified: item.updated,
188
+ date_published: published === undefined ? undefined : isoOf(published),
189
+ date_modified: updated === undefined ? undefined : isoOf(updated),
160
190
  tags: item.tags,
161
191
  authors: item.author === undefined ? undefined : [item.author],
162
192
  })),
@@ -166,10 +196,20 @@ function buildJsonFeed(channel: FeedChannel, items: readonly FeedItem[]): string
166
196
  )}\n`;
167
197
  }
168
198
 
169
- /** RSS 2.0, Atom, and JSON Feed 1.1 from one item list. */
170
- export function buildFeed(channel: FeedChannel, items: readonly FeedItem[]): Feed {
171
- const ordered = [...items].sort((a, b) => Date.parse(b.published) - Date.parse(a.published));
172
- const updated = channel.updated ?? newest(ordered);
199
+ /**
200
+ * RSS 2.0, Atom, and JSON Feed 1.1 from one item list. Never throws over a date: an unparseable
201
+ * one is treated as absent, and the feed still renders for the items that have a real one.
202
+ */
203
+ export function buildFeed(
204
+ channel: FeedChannel,
205
+ items: readonly FeedItem[],
206
+ options: BuildFeedOptions = {},
207
+ ): Feed {
208
+ const ordered = items.map(dated).sort(byNewest);
209
+ const updated =
210
+ epochOf(channel.updated) ??
211
+ newestEpoch(ordered.map((entry) => entry.updated ?? entry.published)) ??
212
+ nowEpoch(options.clock ?? systemClock);
173
213
  return {
174
214
  rss: buildRss(channel, ordered, updated),
175
215
  atom: buildAtom(channel, ordered, updated),
package/src/xml.ts CHANGED
@@ -32,8 +32,17 @@ export function attributes(attrs: Readonly<Record<string, string>>): string {
32
32
  .join('');
33
33
  }
34
34
 
35
- /** Join a base URL and a path without producing `//` or dropping a segment. */
35
+ /**
36
+ * Join a base URL and a path without producing `//` or dropping a segment.
37
+ *
38
+ * A trailing slash the PATH declares is kept: `/blog/` and `/blog` are different resources, and
39
+ * this builds every `<loc>` (`sitemap.ts`) and every canonical (`meta.ts`), so stripping it made a
40
+ * trailing-slash site publish URLs that redirect. Only the bare-root join — `''` or `'/'` — has
41
+ * nothing to keep, and it collapses to the base.
42
+ */
36
43
  export function absoluteUrl(baseUrl: string, path: string): string {
37
44
  if (/^https?:\/\//.test(path)) return path;
38
- return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`.replace(/\/$/, '') || baseUrl;
45
+ const base = baseUrl.replace(/\/+$/, '');
46
+ const rest = path.replace(/^\/+/, '');
47
+ return rest === '' ? base : `${base}/${rest}`;
39
48
  }
package/src/budgets.ts DELETED
@@ -1,127 +0,0 @@
1
- // Per-route performance budgets. `checkBudgets()` returns a `--json`-shaped
2
- // report; `x verify` fails CI on it. A budget that only warns is a budget that
3
- // gets ignored, so the assert path throws X_SEO_BUDGET_EXCEEDED naming the route file.
4
-
5
- import { budgetExceeded, SEO_ERROR_CODES, SeoError } from './errors';
6
- import type { RouteBudget, RouteRecord } from './routes';
7
-
8
- /** Core Web Vitals "good" thresholds, as of 2026-07. */
9
- export const DEFAULT_BUDGET: Required<Pick<RouteBudget, 'lcp' | 'cls' | 'inp'>> = {
10
- lcp: 2500,
11
- cls: 0.1,
12
- inp: 200,
13
- };
14
-
15
- export type BudgetMetric = 'js' | 'css' | 'lcp' | 'cls' | 'inp';
16
-
17
- export const BUDGET_UNITS: Readonly<Record<BudgetMetric, string>> = {
18
- js: 'b',
19
- css: 'b',
20
- lcp: 'ms',
21
- cls: '',
22
- inp: 'ms',
23
- };
24
-
25
- /** What a build or a lab run measured for one route. */
26
- export interface BudgetMeasurement {
27
- route: string;
28
- js?: number;
29
- css?: number;
30
- lcp?: number;
31
- cls?: number;
32
- inp?: number;
33
- }
34
-
35
- export interface BudgetViolation {
36
- readonly route: string;
37
- readonly file: string;
38
- readonly metric: BudgetMetric;
39
- readonly limit: number;
40
- readonly actual: number;
41
- readonly overBy: number;
42
- readonly unit: string;
43
- }
44
-
45
- export interface BudgetReport {
46
- readonly ok: boolean;
47
- readonly checked: number;
48
- readonly violations: readonly BudgetViolation[];
49
- }
50
-
51
- const BYTE_UNITS: Readonly<Record<string, number>> = {
52
- b: 1,
53
- kb: 1024,
54
- mb: 1024 * 1024,
55
- };
56
-
57
- /** `'40kb'` -> 40960. A raw number is already bytes. */
58
- export function parseBytes(value: string | number): number {
59
- if (typeof value === 'number') return value;
60
- const match = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|mb)?\s*$/i.exec(value);
61
- if (match === null) {
62
- throw new SeoError({
63
- code: SEO_ERROR_CODES.budgetExceeded,
64
- cause: `budget size ${JSON.stringify(value)} is not a byte count or a size string`,
65
- fix: "write the budget as a number of bytes or a string like '40kb'",
66
- meta: { value },
67
- });
68
- }
69
- const scale = BYTE_UNITS[(match[2] ?? 'b').toLowerCase()] ?? 1;
70
- return Math.round(Number(match[1]) * scale);
71
- }
72
-
73
- function limitOf(budget: RouteBudget, metric: BudgetMetric): number | undefined {
74
- if (metric === 'js' || metric === 'css') {
75
- const raw = budget[metric];
76
- return raw === undefined ? undefined : parseBytes(raw);
77
- }
78
- return budget[metric] ?? DEFAULT_BUDGET[metric];
79
- }
80
-
81
- export function checkBudgets(
82
- routes: readonly RouteRecord[],
83
- measurements: readonly BudgetMeasurement[],
84
- ): BudgetReport {
85
- const byRoute = new Map(measurements.map((measurement) => [measurement.route, measurement]));
86
- const violations: BudgetViolation[] = [];
87
- let checked = 0;
88
-
89
- for (const route of routes) {
90
- const measured = byRoute.get(route.path);
91
- if (measured === undefined) continue;
92
- checked += 1;
93
- const budget = route.budget ?? {};
94
-
95
- for (const metric of ['js', 'css', 'lcp', 'cls', 'inp'] as const) {
96
- const actual = measured[metric];
97
- const limit = limitOf(budget, metric);
98
- if (actual === undefined || limit === undefined) continue;
99
- if (actual <= limit) continue;
100
- violations.push({
101
- route: route.path,
102
- file: route.file,
103
- metric,
104
- limit,
105
- actual,
106
- overBy: Number((actual - limit).toFixed(4)),
107
- unit: BUDGET_UNITS[metric],
108
- });
109
- }
110
- }
111
-
112
- return { ok: violations.length === 0, checked, violations };
113
- }
114
-
115
- /** Fails the build on the first violation. `x verify` calls this. */
116
- export function assertBudgets(report: BudgetReport): void {
117
- const first = report.violations[0];
118
- if (first === undefined) return;
119
- throw budgetExceeded(
120
- first.route,
121
- first.file,
122
- first.metric,
123
- first.limit,
124
- first.actual,
125
- first.unit,
126
- );
127
- }