@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 +15 -0
- package/package.json +2 -2
- package/src/errors.ts +0 -1
- package/src/images.ts +9 -1
- package/src/xml.ts +24 -3
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: `` 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": "
|
|
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": "
|
|
34
|
+
"@ultimat3/core": "11.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/errors.ts
CHANGED
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
|
-
|
|
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
|
"'": ''',
|
|
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 — `` 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 {
|