@ultimat3/seo 9.0.0 → 11.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
@@ -82,6 +82,11 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
82
82
  whether that string is a path, a storage key or a URL is the app's fact, not seo's — never add
83
83
  a filesystem fallback. Pixels come from `@ultimat3/core`'s pipeline; seo owns no second scaler,
84
84
  and the driver reports the size it probed off the output, never the size that was requested.
85
+ - **A table keyed on a caller string is read with `Object.hasOwn`, never a bare index.**
86
+ `MIME_TYPES[format]` in `images.ts` walked the prototype, so `formats: ['constructor']` rendered
87
+ `<source type="function Object() { [native code] }">` — into the one attribute a browser reads to
88
+ decide whether to fetch the candidate at all. `mimeTypeFor` is the own-key read; the fallback is
89
+ still `image/<format>`.
85
90
  - **One spelling of the transform query keys.** `IMAGE_QUERY_KEYS` in `images.ts` is the only
86
91
  place `w`/`f`/`q` are spelled; `defaultUrlFor` writes them and `parseImageQuery` is the only
87
92
  reader — never hand-roll either half against a literal. A present-but-unusable `w` or `q`
@@ -91,6 +96,16 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
91
96
  whole contract exists to prevent. `parseImageQuery` never validates `f` against real format
92
97
  names; that refusal stays `image-driver.ts`'s `X_IMAGE_UNSUPPORTED`, so one bad URL never
93
98
  carries two codes.
99
+ - **`xml.ts` drops what XML 1.0 cannot represent, `As of 2026-08-23`.** The C0 controls other than
100
+ tab, LF and CR, plus U+FFFE and U+FFFF, are removed in `escapeXml`, `escapeAttribute` and
101
+ `cdata` — so `xmlElement`, `attributes`, every sitemap and every feed inherit it. Dropped and not
102
+ escaped, because there is no escape: `&#1;` is illegal in XML 1.0 for exactly the reason the raw
103
+ byte is. It has to happen in the escaper rather than at each call site, because ONE such byte in
104
+ ONE `FeedItem` title makes the whole document not well-formed and a reader answers with "invalid
105
+ XML" instead of the 49 items it could have parsed — and a scraped title, a paste out of a word
106
+ processor and a `\x00` a `text` column stored without complaint each produce one. Never widen the
107
+ class to the surrogate range: an astral character is a legal PAIR, and half of one is worse than
108
+ the byte the rule exists for.
94
109
  - **A feed date never throws and never lies.** `feed-dates.ts` is the only place a feed timestamp
95
110
  is parsed or formatted; `buildFeed` resolves every date once, so no builder ever sees a string it
96
111
  has to parse. An item date that will not parse is **absent** — the element is omitted (Atom's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/seo",
3
- "version": "9.0.0",
3
+ "version": "11.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": "9.0.0"
34
+ "@ultimat3/core": "11.0.0"
35
35
  }
36
36
  }
package/src/errors.ts CHANGED
@@ -55,7 +55,6 @@ export class SeoError extends UltimateError {
55
55
  code: init.code,
56
56
  cause: init.cause,
57
57
  fix: init.fix,
58
- docs: `https://ultimate.dev/errors/${init.code}`,
59
58
  meta: init.meta,
60
59
  });
61
60
  }
package/src/images.ts CHANGED
@@ -186,6 +186,11 @@ export function srcsetFor(
186
186
  return widths.map((width) => `${urlFor(input.src, width, format)} ${width}w`).join(', ');
187
187
  }
188
188
 
189
+ /** The declared MIME type, else the conventional `image/<format>`. Own keys only. */
190
+ function mimeTypeFor(format: string): string {
191
+ return Object.hasOwn(MIME_TYPES, format) ? (MIME_TYPES[format] as string) : `image/${format}`;
192
+ }
193
+
189
194
  export function responsiveImage(
190
195
  input: ImageInput,
191
196
  options: ResponsiveImageOptions = {},
@@ -196,7 +201,10 @@ export function responsiveImage(
196
201
  const formats = options.formats ?? FORMAT_ORDER;
197
202
 
198
203
  const sources: ImageSourceSet[] = formats.map((format) => ({
199
- type: MIME_TYPES[format] ?? `image/${format}`,
204
+ // `Object.hasOwn`, never a bare index: `MIME_TYPES` is a `{}`-prototyped literal, so an
205
+ // unlisted format spelled `constructor` or `toString` rendered a function into the one
206
+ // attribute a browser reads to decide whether to fetch the candidate at all.
207
+ type: mimeTypeFor(format),
200
208
  srcset: srcsetFor(input, widths, format, urlFor),
201
209
  sizes,
202
210
  }));
package/src/xml.ts CHANGED
@@ -9,21 +9,42 @@ const XML_ESCAPES: Readonly<Record<string, string>> = {
9
9
  "'": '&apos;',
10
10
  };
11
11
 
12
+ /**
13
+ * The characters XML 1.0 excludes from `Char`: the C0 controls other than tab, LF and CR, and the
14
+ * two non-characters at the end of the BMP.
15
+ *
16
+ * They are dropped rather than escaped because XML 1.0 offers no way to write one — `&#1;` is
17
+ * illegal for exactly the same reason the raw byte is, so an emitter's only total move is to omit
18
+ * it. And it has to happen HERE, in the escaper every element, attribute and CDATA section goes
19
+ * through: one such byte in one `FeedItem` title makes the whole document not well-formed, and a
20
+ * reader answers that with "invalid XML" rather than with the 49 items it could have parsed. A
21
+ * scraped title, a paste out of a word processor and a `\x00` a `text` column stored without
22
+ * complaint all produce one.
23
+ */
24
+ // The same exemption `@ultimat3/core`'s `error-render.ts` and `@ultimat3/schema`'s `errors.ts`
25
+ // take, for the same reason.
26
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: naming them is the point.
27
+ const ILLEGAL_XML = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g;
28
+
29
+ /** Never a surrogate range: an astral character is a legal PAIR, and half of one is worse. */
30
+ const legal = (value: string): string => value.replace(ILLEGAL_XML, '');
31
+
12
32
  export function escapeXml(value: string): string {
13
- return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char] ?? char);
33
+ return legal(value).replace(/[&<>"']/g, (char) => XML_ESCAPES[char] ?? char);
14
34
  }
15
35
 
16
36
  /** Attribute values only ever need these three; apostrophes stay readable. */
17
37
  export function escapeAttribute(value: string): string {
18
- return value.replace(/[&<>"]/g, (char) => XML_ESCAPES[char] ?? char);
38
+ return legal(value).replace(/[&<>"]/g, (char) => XML_ESCAPES[char] ?? char);
19
39
  }
20
40
 
21
41
  export function xmlElement(name: string, text: string): string {
22
42
  return `<${name}>${escapeXml(text)}</${name}>`;
23
43
  }
24
44
 
45
+ /** CDATA suspends MARKUP, never the character rule — `legal` applies here exactly as above. */
25
46
  export function cdata(value: string): string {
26
- return `<![CDATA[${value.replaceAll(']]>', ']]]]><![CDATA[>')}]]>`;
47
+ return `<![CDATA[${legal(value).replaceAll(']]>', ']]]]><![CDATA[>')}]]>`;
27
48
  }
28
49
 
29
50
  export function attributes(attrs: Readonly<Record<string, string>>): string {