@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 +21 -4
- package/README.md +19 -14
- package/package.json +4 -4
- package/src/errors.ts +11 -6
- package/src/image-driver.ts +2 -2
- package/src/images.ts +35 -10
- package/src/index.ts +4 -2
- package/src/locale-tags.ts +11 -2
- package/src/meta.ts +23 -1
- package/src/robots.ts +15 -8
- package/src/rss.ts +61 -17
- package/src/sitemap.ts +9 -2
- package/src/xml.ts +11 -16
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 `
|
|
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
|
|
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 `'`, 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: `` 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
|
|
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,
|
|
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/
|
|
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 | `
|
|
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
|
|
105
|
-
never grows a black background because nobody passed a format.
|
|
106
|
-
- **`format: 'avif'`
|
|
107
|
-
|
|
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
|
|
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'
|
|
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": "
|
|
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.
|
|
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": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
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`
|
|
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
|
|
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(
|
|
184
|
-
|
|
185
|
-
|
|
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 }) ` +
|
package/src/image-driver.ts
CHANGED
|
@@ -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.
|
|
65
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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 ??
|
|
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
|
|
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
|
|
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';
|
package/src/locale-tags.ts
CHANGED
|
@@ -15,9 +15,18 @@ export function hreflangTag(locale: string): string {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
23
|
-
*
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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:
|
|
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.
|
|
2
|
-
//
|
|
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
|
-
|
|
5
|
-
'&': '&',
|
|
6
|
-
'<': '<',
|
|
7
|
-
'>': '>',
|
|
8
|
-
'"': '"',
|
|
9
|
-
"'": ''',
|
|
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. `'` 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)
|
|
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}="${
|
|
47
|
+
.map(([key, value]) => ` ${key}="${escapeXml(value)}"`)
|
|
53
48
|
.join('');
|
|
54
49
|
}
|
|
55
50
|
|