@ultimat3/seo 10.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.
Files changed (3) hide show
  1. package/CLAUDE.md +10 -0
  2. package/package.json +2 -2
  3. package/src/xml.ts +24 -3
package/CLAUDE.md CHANGED
@@ -96,6 +96,16 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
96
96
  whole contract exists to prevent. `parseImageQuery` never validates `f` against real format
97
97
  names; that refusal stays `image-driver.ts`'s `X_IMAGE_UNSUPPORTED`, so one bad URL never
98
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: `` 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.
99
109
  - **A feed date never throws and never lies.** `feed-dates.ts` is the only place a feed timestamp
100
110
  is parsed or formatted; `buildFeed` resolves every date once, so no builder ever sees a string it
101
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": "10.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": "10.0.0"
34
+ "@ultimat3/core": "11.0.0"
35
35
  }
36
36
  }
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 {