@ultimat3/seo 23.0.0 → 25.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
@@ -16,7 +16,7 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
16
16
  the SHAPE of a check, and it must not be read as a claim that CI fails on one. What is actually
17
17
  reached from outside this package is `renderMeta` (through `@ultimat3/render`'s `seoRenderers`),
18
18
  `ld.*` (an app's own `meta`), `buildFeed`, and the image contract (`parseImageQuery` /
19
- `builtinImageDriver` / `responsiveImage`, through `@ultimat3/cli`'s `dev-assets.ts`).
19
+ `builtinImageDriver` / `responsiveImage`, through `@ultimat3/cli`'s `runtime-assets.ts`).
20
20
  `validateMeta`/`assertMeta`, `buildSitemap`, `buildRobots`, `isIndexable` and
21
21
  `indexableRoutes`/`expandRoute` have **no caller anywhere** — no step of `x verify` runs them.
22
22
  Wiring them is a `HostCheck` on an existing step in `packages/cli/src/cmd-verify.ts`; until that
@@ -78,6 +78,23 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
78
78
  `head-seo.ts` had to write down that it binds no `renderLd` half on purpose. Deleted
79
79
  `As of 2026-08`. Never add it back: an app that found it on the public surface and called it from
80
80
  `meta` emitted its graph twice, which is exactly the duplicate that comment was defending against.
81
+ - **`responsiveImage` never mints what its own reader or the default driver refuses.**
82
+ `usableWidths` caps at `min(intrinsic, MAX_IMAGE_WIDTH)` and keeps whole positive widths only
83
+ (`?w=10000` was `X_IMAGE_QUERY_INVALID`). `DEFAULT_FORMATS` is `FORMAT_ORDER` filtered through
84
+ core's `canEncode` — `webp` — because a browser does not fall back from a `<source>` that
85
+ errors; AVIF is opt-in (`formats: FORMAT_ORDER`) for an app whose driver encodes it.
86
+ `image-driver.test.ts` runs every default format through `builtinImageDriver`.
87
+ - **`seo.robots.disallow` goes into EVERY group**, and a `*` group is appended when none is
88
+ declared: a crawler obeys only the group naming it. De-duplicated per group.
89
+ - **The brand test in `applyTitleTemplate` is a WORD match** (`containsWord`, Unicode letters and
90
+ digits), never `includes`: `Ultimately fast` still gets its brand.
91
+ - **`ogLocaleTag` is `Intl.Locale` language + region** — `zh_TW`, never `zh_Hant_TW`.
92
+ - **Feed authors**: Atom takes `<author>` (name, email, uri), `<rights>`, `<icon>`. An RSS item
93
+ author is `<author>email (name)</author>` or, with no email, `<dc:creator>`. An item `image` is
94
+ Atom `<link rel="enclosure">` and RSS `<media:content>` — never RSS `<enclosure>`, which
95
+ requires a byte length. `xmlns:dc`/`xmlns:media` are declared only when used.
96
+ - **`usableWidths` refuses an intrinsic width that is not a size** — core's `finiteCount`
97
+ (`X_INVARIANT`) on the floored value, so `NaN`, `Infinity`, `0` and negatives never reach a URL.
81
98
  - **`builtinImageDriver({ read })` takes its reader.** `TransformRequest.src` is a string, and
82
99
  whether that string is a path, a storage key or a URL is the app's fact, not seo's — never add
83
100
  a filesystem fallback. Pixels come from `@ultimat3/core`'s pipeline; seo owns no second scaler,
@@ -97,8 +114,8 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
97
114
  names; that refusal stays `image-driver.ts`'s `X_IMAGE_UNSUPPORTED`, so one bad URL never
98
115
  carries two codes.
99
116
  - **`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
117
+ tab, LF and CR, plus U+FFFE and U+FFFF, are removed in `escapeXml` (core's `escapeHtml` table
118
+ after the strip — `'` is `&#39;`, valid XML and HTML alike) and `cdata` — so `xmlElement`, `attributes`, every sitemap and every feed inherit it. Dropped and not
102
119
  escaped, because there is no escape: `&#1;` is illegal in XML 1.0 for exactly the reason the raw
103
120
  byte is. It has to happen in the escaper rather than at each call site, because ONE such byte in
104
121
  ONE `FeedItem` title makes the whole document not well-formed and a reader answers with "invalid
@@ -132,7 +149,7 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
132
149
  | `rss.ts` | `buildFeed()` — the three feed formats; owns markup, never a date |
133
150
  | `feed-dates.ts` | all timestamp parsing and formatting for feeds. Never `Date.parse` in another module |
134
151
  | `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 |
135
- | `image-driver.ts` | the bytes behind that promise: `ImageTransformDriver` + `builtinImageDriver({ read })` over core's pipeline — png/jpeg only |
152
+ | `image-driver.ts` | the bytes behind that promise: `ImageTransformDriver` + `builtinImageDriver({ read })` over core's pipeline — encodes png/jpeg/webp |
136
153
 
137
154
  ## Commands
138
155
 
package/README.md CHANGED
@@ -47,11 +47,11 @@ a job boundary the class is gone and the `code` is what survives — match on th
47
47
  | `meta.ts` | the metadata model, `renderMeta()` → head tags: title template, canonical, robots, `og:*`, `twitter:*`, hreflang + `x-default`, `theme-color` per colour scheme. **`robots: { index: false }` withdraws every `og:*`, `article:*` and `twitter:*` tag** — declared or derived; there is no `social` switch |
48
48
  | `validate.ts` | the build gate — `validateMeta()` (`--json`-shaped) and `assertMeta()` |
49
49
  | `ld.ts` | typed JSON-LD builders; required fields are required in the **input type** |
50
- | `sitemap.ts` | `buildSitemap()` from the route table + each route's `prerender()`, per-locale alternates, automatic index splitting past 50k |
51
- | `robots.ts` | `buildRobots()`, environment-aware and fail-closed |
52
- | `rss.ts` | `buildFeed()` → RSS 2.0 + Atom + JSON Feed from one item list |
50
+ | `sitemap.ts` | `buildSitemap()` from the route table + each route's `prerender()`, per-locale alternates, automatic index splitting past 50k — the index stays `/sitemap.xml`, the parts are `/sitemaps/<n>.xml` (`SITEMAP_PARTS_DIR`) |
51
+ | `robots.ts` | `buildRobots()`, environment-aware and fail-closed; `disallow` reaches **every** group, and a `User-agent: *` group is emitted for it when none is declared |
52
+ | `rss.ts` | `buildFeed()` → RSS 2.0 + Atom + JSON Feed from one item list. Channel `author`/`copyright`/`icon` → Atom `<author>`/`<rights>`/`<icon>`; an item `author` → Atom `<author>`, RSS `<author>` when it has an email, `<dc:creator>` when it does not; an item `image` → Atom `<link rel="enclosure">`, RSS `<media:content medium="image">`. Extra RSS namespaces are declared only when used |
53
53
  | `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 |
54
- | `images.ts` | `srcset` widths, AVIF → WebP → original, inlined intrinsic dimensions, and `parseImageQuery()` — reads a minted URL back into a transform request |
54
+ | `images.ts` | `srcset` widths (never past the intrinsic width or `MAX_IMAGE_WIDTH`, 8192), modern formats before the original — `DEFAULT_FORMATS` is what the built-in driver encodes (`webp`); pass `formats: FORMAT_ORDER` for AVIF behind a CDN driver — inlined intrinsic dimensions, and `parseImageQuery()` — reads a minted URL back into a transform request |
55
55
  | `image-driver.ts` | `ImageTransformDriver` + `builtinImageDriver()`: the variant bytes and the blur placeholder |
56
56
 
57
57
  ## Type-level enforcement
@@ -88,27 +88,32 @@ const images = builtinImageDriver({ read: (src) => Bun.file(`public${src}`).byte
88
88
 
89
89
  // a 1200x630 source
90
90
  await images.transform({ src: '/img/hero.png', width: 640 });
91
- // → { bytes, contentType: 'image/jpeg', width: 640, height: 336 }
91
+ // → { bytes, contentType: 'image/png', width: 640, height: 336 }
92
92
  await images.blurPlaceholder('/img/hero.png');
93
93
  // → 'data:image/png;base64,…'
94
94
  ```
95
95
 
96
96
  | | |
97
97
  |---|---|
98
- | decodes | `png`, `jpeg` |
99
- | encodes | `png`, `jpeg` |
100
- | probes only | `webp`, `avif`, `gif`, `svg` — intrinsic size, never a transform |
98
+ | decodes | `png`, `jpeg`, `webp`, `gif` |
99
+ | encodes | `png`, `jpeg`, `webp` |
100
+ | probes only | `avif`, `svg` — intrinsic size, never a transform |
101
+
102
+ The two lists are `@ultimat3/core`'s `DECODABLE_FORMATS` and `ENCODABLE_FORMATS`
103
+ (`packages/core/src/image/pipeline.ts`), `As of 2026-10`.
101
104
 
102
105
  - **`read` is required.** `src` is a string and only the app knows whether it is a path, a
103
106
  storage key or a URL. Guessing would mean a filesystem read off a URL-shaped string.
104
- - **No `format` → the pixels decide:** PNG when the raster has alpha, JPEG otherwise. A logo
105
- never grows a black background because nobody passed a format.
106
- - **`format: 'avif'` or `'webp'` → `X_IMAGE_UNSUPPORTED`,** with the fix line. Those `<source>`
107
- entries need a CDN driver; the framework will not pretend to encode what it cannot.
107
+ - **No `format` → the source's format is kept** when the pipeline can write it, PNG otherwise. A
108
+ logo never grows a black background because nobody passed a format.
109
+ - **`format: 'avif'` → `X_IMAGE_UNSUPPORTED`,** with the fix line. That `<source>` needs a CDN
110
+ driver, so `responsiveImage()` does not offer it unless asked (`formats: FORMAT_ORDER`): a
111
+ browser picks the first `<source>` it supports and does not fall back from an error.
108
112
  - **`width`/`height` are probed off the output bytes,** never echoed from the request. A width
109
113
  above the intrinsic one clamps to the source and reports the source's size, so the box the
110
114
  browser reserves is the box the bytes fill.
111
- - `blurPlaceholder()` returns a 16px-wide PNG `data:` URI, ready for `ImageInput.blurDataUrl`.
115
+ - `blurPlaceholder()` returns a PNG `data:` URI at most 32px on its long edge, ready for
116
+ `ImageInput.blurDataUrl`.
112
117
 
113
118
  ### Reading the URL back
114
119
 
@@ -156,7 +161,7 @@ export const config = defineRoute({
156
161
  alternates: post.locales.map((l) => ({ hreflang: l, href: `/${l}/blog/${post.slug}` })),
157
162
  ld: [ld.Article({ headline: post.title, datePublished: post.publishedAt, author: post.author })],
158
163
  }),
159
- budget: { js: '0kb', lcp: 2000 },
164
+ budget: { js: '0kb' },
160
165
  });
161
166
  ```
162
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/seo",
3
- "version": "23.0.0",
3
+ "version": "25.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",
@@ -25,14 +25,14 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.4.0"
28
+ "bun": ">=1.4.2"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "23.0.0",
36
- "@ultimat3/schema": "23.0.0"
35
+ "@ultimat3/core": "25.0.0",
36
+ "@ultimat3/schema": "25.0.0"
37
37
  }
38
38
  }
package/src/errors.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // of 2026-08`, is a build gate: no step of `x verify` calls the asserts that throw them (see
4
4
  // `README.md`), so they fail the app that calls one itself and nothing else.
5
5
 
6
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
6
+ import { NotImplementedError, registerErrorCodes, UltimateError } from '@ultimat3/core';
7
7
  // errors.ts <-> images.ts: images.ts throws imageQueryInvalid() and this file spells its fix
8
8
  // using images.ts's IMAGE_QUERY_KEYS. Safe like core's errors.ts <-> error-codes.ts cycle:
9
9
  // nothing at this module's top level reads the import, only the factory body below does, and by
@@ -29,7 +29,9 @@ export type SeoErrorCode = (typeof SEO_ERROR_CODES)[keyof typeof SEO_ERROR_CODES
29
29
  * deliberately not among them: `@ultimat3/render` owns `X_BUDGET_EXCEEDED` and `@ultimat3/cli`'s
30
30
  * `checkBudgets` is the gate that throws it, so seo naming the same condition was a second code
31
31
  * for one fault whose only thrower was its own test. `errors.test.ts` pins the set.
32
- * `X_NOT_IMPLEMENTED` and `X_IMAGE_UNSUPPORTED` are core's; `SeoError` throws them, untitled here.
32
+ * `X_NOT_IMPLEMENTED` is core's, and so is its one constructor: `notImplementedDriver` below builds
33
+ * core's `NotImplementedError`, never a `SeoError` carrying the code. `X_IMAGE_UNSUPPORTED` is
34
+ * core's codec's, untitled here.
33
35
  */
34
36
  registerErrorCodes({
35
37
  X_SEO_META_MISSING: { title: 'a site/ route is missing required metadata' },
@@ -43,7 +45,7 @@ registerErrorCodes({
43
45
  });
44
46
 
45
47
  export interface SeoErrorInit {
46
- readonly code: SeoErrorCode | 'X_NOT_IMPLEMENTED';
48
+ readonly code: SeoErrorCode;
47
49
  readonly cause: string;
48
50
  readonly fix: string;
49
51
  readonly meta?: Readonly<Record<string, unknown>>;
@@ -180,9 +182,12 @@ export function imageQueryInvalid(param: string, value: string, reason: string):
180
182
  * `at` is the driver's own module path — pass `import.meta.path`. It is required because
181
183
  * `driver` is a display name, and a fix an agent cannot open is not a fix.
182
184
  */
183
- export function notImplementedDriver(driver: string, capability: string, at: string): SeoError {
184
- return new SeoError({
185
- code: 'X_NOT_IMPLEMENTED',
185
+ export function notImplementedDriver(
186
+ driver: string,
187
+ capability: string,
188
+ at: string,
189
+ ): NotImplementedError {
190
+ return new NotImplementedError({
186
191
  cause: `the ${driver} image driver does not implement ${capability} yet`,
187
192
  fix:
188
193
  `implement ${capability} in ${at}, or swap the driver for builtinImageDriver({ read }) ` +
@@ -61,8 +61,8 @@ function requestedFormat(format: string | undefined): ImageFormat | undefined {
61
61
 
62
62
  /**
63
63
  * The zero-dependency pipeline in `@ultimat3/core`: PNG, JPEG, WebP and GIF in, PNG, JPEG and
64
- * WebP out. `<picture>` still offers AVIF, and nothing here synthesises it — asking for one
65
- * raises core's `X_IMAGE_UNSUPPORTED`, and that variant belongs on a CDN driver instead.
64
+ * WebP out. Nothing here synthesises AVIF — asking for one raises core's `X_IMAGE_UNSUPPORTED` —
65
+ * which is why `images.ts`'s `DEFAULT_FORMATS` does not offer it; that variant is a CDN driver's.
66
66
  */
67
67
  export function builtinImageDriver(options: BuiltinImageDriverOptions): ImageTransformDriver {
68
68
  return {
package/src/images.ts CHANGED
@@ -3,17 +3,31 @@
3
3
  // so the browser reserves the box before the bytes arrive, keeping CLS at 0. Producing those
4
4
  // bytes is `image-driver.ts`; nothing here decodes a pixel.
5
5
 
6
+ import { canEncode, finiteCount } from '@ultimat3/core';
6
7
  import { imageQueryInvalid } from './errors';
7
- import { attributes, escapeAttribute } from './xml';
8
+ import { attributes, escapeXml } from './xml';
8
9
 
9
10
  /** Ordered widest-first is wrong for `srcset`; browsers want ascending. */
10
11
  export const DEFAULT_WIDTHS: readonly number[] = [320, 480, 640, 768, 1024, 1280, 1536, 1920];
11
12
 
12
- /** Most-preferred first. The original format is always appended last. */
13
+ /** Every modern format the markup can offer, most-preferred first. The original comes last. */
13
14
  export const FORMAT_ORDER = ['avif', 'webp'] as const;
14
15
 
15
16
  export type ModernFormat = (typeof FORMAT_ORDER)[number];
16
17
 
18
+ /**
19
+ * What `responsiveImage` offers when the caller names no `formats`: the part of `FORMAT_ORDER`
20
+ * the built-in pipeline can ENCODE, read off core's own capability list so the two cannot drift.
21
+ *
22
+ * A `<source>` is a promise the browser does not hedge: it picks the first type it supports and
23
+ * never falls back from an error response. Offering AVIF by default made that pick a URL the
24
+ * default driver answers with `X_IMAGE_UNSUPPORTED`. An app whose driver does encode AVIF — a
25
+ * CDN — says so: `responsiveImage(input, { formats: FORMAT_ORDER })`.
26
+ */
27
+ export const DEFAULT_FORMATS: readonly ModernFormat[] = FORMAT_ORDER.filter((format) =>
28
+ canEncode(format),
29
+ );
30
+
17
31
  export const MIME_TYPES: Readonly<Record<string, string>> = {
18
32
  avif: 'image/avif',
19
33
  webp: 'image/webp',
@@ -124,7 +138,7 @@ function parseQuality(raw: string): number {
124
138
  * Hard, not configurable: the shape `packages/mcp/src/query-limits.ts:48-56` uses — a caller may
125
139
  * narrow a request by asking for less, never widen it by asking for more.
126
140
  */
127
- const MAX_IMAGE_WIDTH = 8192;
141
+ export const MAX_IMAGE_WIDTH = 8192;
128
142
 
129
143
  function parseWidth(raw: string): number {
130
144
  const width = parsePositiveInt(IMAGE_QUERY_KEYS.width, raw);
@@ -165,11 +179,22 @@ export function parseImageQuery(params: URLSearchParams): ImageQuery | null {
165
179
  };
166
180
  }
167
181
 
168
- /** Never upscale: drop candidate widths above the intrinsic width. */
182
+ /**
183
+ * Never upscale, and never mint what `parseImageQuery` refuses: candidates are whole pixel counts
184
+ * from 1 to the intrinsic width or `MAX_IMAGE_WIDTH`, whichever is smaller, and that cap closes
185
+ * the list. A 10,000-wide source used to put `?w=10000` in its own `srcset` — the widest
186
+ * candidate, the one a large display picks, answered `X_IMAGE_QUERY_INVALID`.
187
+ *
188
+ * The intrinsic width is floored (an SVG may measure a fraction) and then has to BE a size: `NaN`
189
+ * fails every comparison below, so it came back as the only candidate and went out as `?w=NaN`.
190
+ */
169
191
  export function usableWidths(intrinsic: number, widths: readonly number[]): readonly number[] {
170
- const usable = widths.filter((width) => width <= intrinsic);
171
- if (usable.length === 0) return [intrinsic];
172
- return usable.includes(intrinsic) ? usable : [...usable, intrinsic];
192
+ const cap = Math.min(
193
+ finiteCount('usableWidths', 'intrinsic', Math.floor(intrinsic), 1),
194
+ MAX_IMAGE_WIDTH,
195
+ );
196
+ const usable = widths.filter((width) => Number.isInteger(width) && width >= 1 && width <= cap);
197
+ return usable.includes(cap) ? usable : [...usable, cap];
173
198
  }
174
199
 
175
200
  /** The widest candidate, or `undefined` for an empty list — never `Math.max()`'s `-Infinity`. */
@@ -198,7 +223,7 @@ export function responsiveImage(
198
223
  const urlFor = options.urlFor ?? defaultUrlFor;
199
224
  const widths = usableWidths(input.width, options.widths ?? DEFAULT_WIDTHS);
200
225
  const sizes = input.sizes ?? '100vw';
201
- const formats = options.formats ?? FORMAT_ORDER;
226
+ const formats = options.formats ?? DEFAULT_FORMATS;
202
227
 
203
228
  const sources: ImageSourceSet[] = formats.map((format) => ({
204
229
  // `Object.hasOwn`, never a bare index: `MIME_TYPES` is a `{}`-prototyped literal, so an
@@ -238,7 +263,7 @@ export function responsiveImage(
238
263
  };
239
264
  }
240
265
 
241
- /** `<picture>` with AVIF, then WebP, then the original. */
266
+ /** `<picture>`: each offered modern format in order, then the original as the `<img>`. */
242
267
  export function renderPicture(image: ResponsiveImage): string {
243
268
  const sources = image.sources
244
269
  .map(
@@ -263,5 +288,5 @@ export function renderPicture(image: ResponsiveImage): string {
263
288
 
264
289
  /** Escapes a data URI for inline `style`, for callers assembling their own tags. */
265
290
  export function inlineBlur(dataUrl: string): string {
266
- return escapeAttribute(`background-image:url(${dataUrl});background-size:cover`);
291
+ return escapeXml(`background-image:url(${dataUrl});background-size:cover`);
267
292
  }
package/src/index.ts CHANGED
@@ -31,10 +31,12 @@ export type {
31
31
  ResponsiveImageOptions,
32
32
  } from './images';
33
33
  export {
34
+ DEFAULT_FORMATS,
34
35
  DEFAULT_WIDTHS,
35
36
  FORMAT_ORDER,
36
37
  IMAGE_QUERY_KEYS,
37
38
  inlineBlur,
39
+ MAX_IMAGE_WIDTH,
38
40
  MIME_TYPES,
39
41
  parseImageQuery,
40
42
  renderPicture,
@@ -113,9 +115,9 @@ export {
113
115
  chunk,
114
116
  SITEMAP_INDEX_MAX_FILES,
115
117
  SITEMAP_MAX_URLS,
118
+ SITEMAP_PARTS_DIR,
116
119
  sitemapUrls,
117
120
  } from './sitemap';
118
121
  export type { MetaIssue, MetaValidationReport, ValidateMetaOptions } from './validate';
119
122
  export { assertMeta, validateMeta } from './validate';
120
-
121
- export { absoluteUrl, escapeAttribute, escapeXml } from './xml';
123
+ export { absoluteUrl, escapeXml } from './xml';
@@ -15,9 +15,18 @@ export function hreflangTag(locale: string): string {
15
15
  }
16
16
  }
17
17
 
18
- /** `es-co` → `es_CO`: Open Graph's `language_TERRITORY`. */
18
+ /**
19
+ * `es-co` → `es_CO`, `zh-hant-tw` → `zh_TW`: Open Graph's `language_TERRITORY`, which has no slot
20
+ * for a script, a variant or an extension. Replacing hyphens in the BCP 47 tag produced
21
+ * `zh_Hant_TW`, a value no consumer recognises. Total, for `hreflangTag`'s reason.
22
+ */
19
23
  export function ogLocaleTag(locale: string): string {
20
- return hreflangTag(locale).replace(/-/g, '_');
24
+ try {
25
+ const { language, region } = new Intl.Locale(locale);
26
+ return region === undefined ? language : `${language}_${region}`;
27
+ } catch {
28
+ return locale.replace(/-/g, '_');
29
+ }
21
30
  }
22
31
 
23
32
  /** A page in one locale: the locale and the page's own path in it. */
package/src/meta.ts CHANGED
@@ -143,6 +143,28 @@ export function localizedAlternates(
143
143
  */
144
144
  const TEMPLATE_SEPARATORS = /^[\s\-–—|·:>/]+|[\s\-–—|·:>/]+$/g;
145
145
 
146
+ const WORD_CHARACTER = /[\p{L}\p{N}]/u;
147
+
148
+ const isWordCharacter = (char: string | undefined): boolean =>
149
+ char !== undefined && WORD_CHARACTER.test(char);
150
+
151
+ /**
152
+ * `word` standing alone in `text`: `\b`, but for every script. `'Ultimately fast'.includes(
153
+ * 'Ultimate')` is true, so that page shipped with no brand at all. A side of the word that is not
154
+ * itself a letter or digit (`C++`) needs no boundary, exactly as `\b` would not find one there.
155
+ */
156
+ function containsWord(text: string, word: string): boolean {
157
+ const [first, last] = [[...word][0], [...word].at(-1)];
158
+ for (let at = text.indexOf(word); at !== -1; at = text.indexOf(word, at + 1)) {
159
+ const before = [...text.slice(0, at)].at(-1);
160
+ const after = [...text.slice(at + word.length)][0];
161
+ const opens = !isWordCharacter(first) || !isWordCharacter(before);
162
+ const closes = !isWordCharacter(last) || !isWordCharacter(after);
163
+ if (opens && closes) return true;
164
+ }
165
+ return false;
166
+ }
167
+
146
168
  /** Where the page's own title goes. Case-sensitive: `%S` names no slot, and neither does absence. */
147
169
  export const TITLE_SLOT = '%s';
148
170
 
@@ -159,7 +181,7 @@ export function applyTitleTemplate(title: string, template?: string): string {
159
181
  // can name the file.
160
182
  if (!template.includes(TITLE_SLOT)) return title;
161
183
  const brand = template.replace(TITLE_SLOT, '').replace(TEMPLATE_SEPARATORS, '');
162
- if (brand !== '' && title.includes(brand)) return title;
184
+ if (brand !== '' && containsWord(title, brand)) return title;
163
185
  // A FUNCTION as the replacement, never the string: `replace` expands `$$`, `$&` and `$'` in a
164
186
  // replacement literal, and a title is app data — "Save $$ on shoes" rendered one dollar.
165
187
  return template.replace(TITLE_SLOT, () => title);
package/src/robots.ts CHANGED
@@ -19,9 +19,9 @@ export interface RobotsConfig {
19
19
  environment?: Environment | undefined;
20
20
  groups?: readonly RobotsGroup[];
21
21
  /**
22
- * Paths every crawler is kept out of — `app.config.ts`'s `seo.robots.disallow`. Added to the
23
- * default `User-agent: *` group, or to each declared group that names `*`. Production only: a
24
- * non-production `robots.txt` already disallows everything.
22
+ * Paths every crawler is kept out of — `app.config.ts`'s `seo.robots.disallow`. Added to EVERY
23
+ * group, and a `User-agent: *` group is emitted to carry it when none is declared. Production
24
+ * only: a non-production `robots.txt` already disallows everything.
25
25
  */
26
26
  disallow?: readonly string[];
27
27
  /** Sitemap paths or absolute URLs. Only emitted in production. */
@@ -67,12 +67,19 @@ export function buildRobots(config: RobotsConfig): string {
67
67
  config.groups === undefined || config.groups.length === 0
68
68
  ? [{ userAgent: '*', allow: ['/'] }]
69
69
  : config.groups;
70
+ // A crawler obeys the ONE group that names it most specifically and never reads `*` beside it,
71
+ // so "every crawler" means every group. Appending to `*` alone left the list out of exactly
72
+ // the groups an app had written for a named crawler — and out of the file when no group named `*`.
70
73
  const extra = config.disallow ?? [];
71
- const groups = declared.map((group) =>
72
- extra.length === 0 || !agents(group.userAgent).includes('*')
73
- ? group
74
- : { ...group, disallow: [...(group.disallow ?? []), ...extra] },
75
- );
74
+ const everywhere = declared.map((group) => ({
75
+ ...group,
76
+ disallow: [...new Set([...(group.disallow ?? []), ...extra])],
77
+ }));
78
+ const namesEveryone = declared.some((group) => agents(group.userAgent).includes('*'));
79
+ const groups: readonly RobotsGroup[] =
80
+ extra.length === 0 || namesEveryone
81
+ ? everywhere
82
+ : [...everywhere, { userAgent: '*', disallow: extra }];
76
83
 
77
84
  for (const group of groups) {
78
85
  for (const agent of agents(group.userAgent)) lines.push(`User-agent: ${agent}`);
package/src/rss.ts CHANGED
@@ -84,8 +84,33 @@ function byNewest(a: DatedItem, b: DatedItem): number {
84
84
  return b.published - a.published;
85
85
  }
86
86
 
87
+ /** Atom's person construct (RFC 4287 §3.2): a name, and whichever of email and uri is known. */
88
+ function atomAuthor(author: FeedAuthor): string {
89
+ const email = author.email === undefined ? '' : xmlElement('email', author.email);
90
+ const uri = author.url === undefined ? '' : xmlElement('uri', author.url);
91
+ return `<author>${xmlElement('name', author.name)}${email}${uri}</author>`;
92
+ }
93
+
94
+ /** RSS 2.0's `<author>` IS an e-mail address; a name with none is Dublin Core's `dc:creator`. */
95
+ const needsCreator = (author: FeedAuthor | undefined): author is FeedAuthor =>
96
+ author !== undefined && author.email === undefined;
97
+
98
+ function rssAuthor(author: FeedAuthor): string {
99
+ return author.email === undefined
100
+ ? xmlElement('dc:creator', author.name)
101
+ : xmlElement('author', `${author.email} (${author.name})`);
102
+ }
103
+
87
104
  function buildRss(channel: FeedChannel, items: readonly DatedItem[], updated: number): string {
88
105
  const self = absoluteUrl(channel.siteUrl, channel.feedUrl);
106
+ // Declared only when used: a namespace on every feed would change bytes no reader asked for.
107
+ const dublinCore = items.some(({ item }) => needsCreator(item.author))
108
+ ? ' xmlns:dc="http://purl.org/dc/elements/1.1/"'
109
+ : '';
110
+ // Media RSS, not `<enclosure>`: RSS 2.0 requires a byte `length` there and nothing here has one.
111
+ const media = items.some(({ item }) => item.image !== undefined)
112
+ ? ' xmlns:media="http://search.yahoo.com/mrss/"'
113
+ : '';
89
114
  const entries = items
90
115
  .map(({ item, published }) => {
91
116
  const parts = [
@@ -94,18 +119,23 @@ function buildRss(channel: FeedChannel, items: readonly DatedItem[], updated: nu
94
119
  ` <guid isPermaLink="false">${escapeXml(item.id)}</guid>`,
95
120
  ];
96
121
  if (published !== undefined) parts.push(xmlElement('pubDate', rfc822Of(published)));
122
+ if (item.author !== undefined) parts.push(rssAuthor(item.author));
97
123
  if (item.summary !== undefined) parts.push(xmlElement('description', item.summary));
98
124
  if (item.contentHtml !== undefined) {
99
125
  parts.push(` <content:encoded>${cdata(item.contentHtml)}</content:encoded>`);
100
126
  }
101
127
  for (const tag of item.tags ?? []) parts.push(xmlElement('category', tag));
128
+ if (item.image !== undefined) {
129
+ const url = escapeXml(absoluteUrl(channel.siteUrl, item.image));
130
+ parts.push(` <media:content url="${url}" medium="image"/>`);
131
+ }
102
132
  return ` <item>\n${parts.map((line) => (line.startsWith(' ') ? line : ` ${line}`)).join('\n')}\n </item>`;
103
133
  })
104
134
  .join('\n');
105
135
 
106
136
  return [
107
137
  '<?xml version="1.0" encoding="UTF-8"?>',
108
- '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">',
138
+ `<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"${dublinCore}${media}>`,
109
139
  ' <channel>',
110
140
  ` ${xmlElement('title', channel.title)}`,
111
141
  ` ${xmlElement('link', channel.siteUrl)}`,
@@ -142,29 +172,43 @@ function buildAtom(channel: FeedChannel, items: readonly DatedItem[], updated: n
142
172
  if (item.contentHtml !== undefined) {
143
173
  parts.push(` <content type="html">${escapeXml(item.contentHtml)}</content>`);
144
174
  }
145
- if (item.author !== undefined) {
146
- parts.push(` <author>${xmlElement('name', item.author.name)}</author>`);
147
- }
175
+ if (item.author !== undefined) parts.push(` ${atomAuthor(item.author)}`);
148
176
  for (const tag of item.tags ?? []) {
149
177
  parts.push(` <category term="${escapeXml(tag)}"/>`);
150
178
  }
179
+ if (item.image !== undefined) {
180
+ const href = escapeXml(absoluteUrl(channel.siteUrl, item.image));
181
+ parts.push(` <link rel="enclosure" href="${href}"/>`);
182
+ }
151
183
  return ` <entry>\n${parts.join('\n')}\n </entry>`;
152
184
  })
153
185
  .join('\n');
154
186
 
155
- return [
156
- '<?xml version="1.0" encoding="UTF-8"?>',
157
- `<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="${escapeXml(channel.language)}">`,
158
- ` ${xmlElement('title', channel.title)}`,
159
- ` ${xmlElement('subtitle', channel.description)}`,
160
- ` ${xmlElement('id', channel.siteUrl)}`,
161
- ` ${xmlElement('updated', isoOf(updated))}`,
162
- ` <link href="${escapeXml(channel.siteUrl)}"/>`,
163
- ` <link href="${escapeXml(self)}" rel="self" type="application/atom+xml"/>`,
164
- entries,
165
- '</feed>',
166
- '',
167
- ].join('\n');
187
+ return (
188
+ [
189
+ '<?xml version="1.0" encoding="UTF-8"?>',
190
+ `<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="${escapeXml(channel.language)}">`,
191
+ ` ${xmlElement('title', channel.title)}`,
192
+ ` ${xmlElement('subtitle', channel.description)}`,
193
+ ` ${xmlElement('id', channel.siteUrl)}`,
194
+ ` ${xmlElement('updated', isoOf(updated))}`,
195
+ ` <link href="${escapeXml(channel.siteUrl)}"/>`,
196
+ ` <link href="${escapeXml(self)}" rel="self" type="application/atom+xml"/>`,
197
+ // RFC 4287 §4.1.1: a feed carries an author unless every entry does. The channel's was read
198
+ // by the JSON builder alone, so an Atom feed with authorless entries was not a valid one.
199
+ channel.author === undefined ? '' : ` ${atomAuthor(channel.author)}`,
200
+ channel.copyright === undefined ? '' : ` ${xmlElement('rights', channel.copyright)}`,
201
+ channel.icon === undefined
202
+ ? ''
203
+ : ` ${xmlElement('icon', absoluteUrl(channel.siteUrl, channel.icon))}`,
204
+ entries,
205
+ '</feed>',
206
+ '',
207
+ ]
208
+ // The trailing '' is the document's final newline; only an absent optional line is dropped.
209
+ .filter((line, index, lines) => line !== '' || index === lines.length - 1)
210
+ .join('\n')
211
+ );
168
212
  }
169
213
 
170
214
  function buildJsonFeed(channel: FeedChannel, items: readonly DatedItem[]): string {
package/src/sitemap.ts CHANGED
@@ -12,6 +12,13 @@ import { absoluteUrl, attributes, escapeXml } from './xml';
12
12
  export const SITEMAP_MAX_URLS = 50_000;
13
13
  export const SITEMAP_INDEX_MAX_FILES = 50_000;
14
14
 
15
+ /**
16
+ * Where the parts of a split sitemap live: `/sitemaps/1.xml`, `/sitemaps/2.xml`, … — one directory,
17
+ * so a running server answers every part from ONE route (`/sitemaps/:file`). `/sitemap-N.xml` needed
18
+ * a parameter inside a segment, which no router here has, and every part 404'd from the web role.
19
+ */
20
+ export const SITEMAP_PARTS_DIR = '/sitemaps';
21
+
15
22
  export interface SitemapAlternate {
16
23
  hreflang: string;
17
24
  href: string;
@@ -26,7 +33,7 @@ export interface SitemapUrl {
26
33
  }
27
34
 
28
35
  export interface SitemapFile {
29
- /** Path relative to the site root, e.g. `/sitemap-1.xml`. */
36
+ /** Path relative to the site root: `/sitemap.xml`, or a part, `/sitemaps/1.xml`. */
30
37
  path: string;
31
38
  xml: string;
32
39
  urlCount: number;
@@ -214,7 +221,7 @@ export async function buildSitemap(
214
221
  }
215
222
 
216
223
  const files: SitemapFile[] = groups.map((group, position) => ({
217
- path: `/sitemap-${position + 1}.xml`,
224
+ path: `${SITEMAP_PARTS_DIR}/${position + 1}.xml`,
218
225
  xml: renderUrlSet(group),
219
226
  urlCount: group.length,
220
227
  }));
package/src/xml.ts CHANGED
@@ -1,13 +1,8 @@
1
- // XML/HTML escaping and tag emission. One implementation, because a sitemap, a
2
- // feed, and a <head> tag all fail the same way on an unescaped ampersand.
1
+ // XML/HTML escaping and tag emission. The character table is `@ultimat3/core`'s `escapeHtml`; this
2
+ // file adds only what XML needs on top of it — the characters XML cannot carry at all — because a
3
+ // sitemap, a feed and a <head> tag all fail the same way on an unescaped ampersand.
3
4
 
4
- const XML_ESCAPES: Readonly<Record<string, string>> = {
5
- '&': '&amp;',
6
- '<': '&lt;',
7
- '>': '&gt;',
8
- '"': '&quot;',
9
- "'": '&apos;',
10
- };
5
+ import { escapeHtml } from '@ultimat3/core';
11
6
 
12
7
  /**
13
8
  * The characters XML 1.0 excludes from `Char`: the C0 controls other than tab, LF and CR, and the
@@ -29,13 +24,13 @@ const ILLEGAL_XML = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g;
29
24
  /** Never a surrogate range: an astral character is a legal PAIR, and half of one is worse. */
30
25
  const legal = (value: string): string => value.replace(ILLEGAL_XML, '');
31
26
 
27
+ /**
28
+ * Element text and attribute values alike: the XML-illegal characters dropped, then core's one
29
+ * character table. `&#39;` is a numeric reference, so the same output is well-formed XML and valid
30
+ * HTML — which is why the `<head>` tags and `<picture>` markup this package writes use it too.
31
+ */
32
32
  export function escapeXml(value: string): string {
33
- return legal(value).replace(/[&<>"']/g, (char) => XML_ESCAPES[char] ?? char);
34
- }
35
-
36
- /** Attribute values only ever need these three; apostrophes stay readable. */
37
- export function escapeAttribute(value: string): string {
38
- return legal(value).replace(/[&<>"]/g, (char) => XML_ESCAPES[char] ?? char);
33
+ return escapeHtml(legal(value));
39
34
  }
40
35
 
41
36
  export function xmlElement(name: string, text: string): string {
@@ -49,7 +44,7 @@ export function cdata(value: string): string {
49
44
 
50
45
  export function attributes(attrs: Readonly<Record<string, string>>): string {
51
46
  return Object.entries(attrs)
52
- .map(([key, value]) => ` ${key}="${escapeAttribute(value)}"`)
47
+ .map(([key, value]) => ` ${key}="${escapeXml(value)}"`)
53
48
  .join('');
54
49
  }
55
50