@ultimat3/seo 1.1.0 → 2.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 +98 -0
- package/README.md +16 -6
- package/package.json +3 -2
- package/src/errors.ts +4 -21
- package/src/feed-dates.ts +49 -0
- package/src/images.ts +21 -1
- package/src/index.ts +4 -9
- package/src/ld.ts +0 -14
- package/src/meta.ts +28 -16
- package/src/robots.ts +18 -18
- package/src/routes.ts +0 -13
- package/src/rss.ts +63 -23
- package/src/xml.ts +11 -2
- package/src/budgets.ts +0 -127
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# @ultimat3/seo — agent notes
|
|
2
|
+
|
|
3
|
+
Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothing above tier 1, no external deps.
|
|
4
|
+
|
|
5
|
+
## Boundary
|
|
6
|
+
|
|
7
|
+
| Owns | Does not own |
|
|
8
|
+
|---|---|
|
|
9
|
+
| metadata model, JSON-LD, sitemap/robots/feeds, image contract | rendering, routing, the route table itself (it consumes `RouteRecord`), **performance budgets** |
|
|
10
|
+
|
|
11
|
+
## Hard rules
|
|
12
|
+
|
|
13
|
+
- **Enforced, not documented — and `As of 2026-08` the gate half of this package is not yet
|
|
14
|
+
enforcing anything.** Every check here has an `assert*` that throws a coded `SeoError` beside a
|
|
15
|
+
`--json`-shaped report; a check that only returns a boolean does not exist. That is a rule about
|
|
16
|
+
the SHAPE of a check, and it must not be read as a claim that CI fails on one. What is actually
|
|
17
|
+
reached from outside this package is `renderMeta` (through `@ultimat3/render`'s `seoRenderers`),
|
|
18
|
+
`ld.*` (an app's own `meta`), `buildFeed`, and the image contract (`parseImageQuery` /
|
|
19
|
+
`builtinImageDriver` / `responsiveImage`, through `@ultimat3/cli`'s `dev-assets.ts`).
|
|
20
|
+
`validateMeta`/`assertMeta`, `buildSitemap`, `buildRobots`, `isIndexable` and
|
|
21
|
+
`indexableRoutes`/`expandRoute` have **no caller anywhere** — no step of `x verify` runs them.
|
|
22
|
+
Wiring them is a `HostCheck` on an existing step in `packages/cli/src/cmd-verify.ts`; until that
|
|
23
|
+
lands, do not restate the CI claim here, in `README.md` or in the wiki.
|
|
24
|
+
- **Performance budgets are not this package's, `As of 2026-08`.** `checkBudgets`/`assertBudgets`/
|
|
25
|
+
`parseBytes`/`DEFAULT_BUDGET`/`BUDGET_UNITS`, `RouteBudget`, `RouteRecord.budget` and
|
|
26
|
+
`X_SEO_BUDGET_EXCEEDED` are deleted: the whole surface was callerless, and `parseBytes` reported a
|
|
27
|
+
malformed size string *as a budget violation*. The live gate is `@ultimat3/cli`'s `checkBudgets`
|
|
28
|
+
over the route manifest and the build's own stats, throwing `@ultimat3/render`'s
|
|
29
|
+
`X_BUDGET_EXCEEDED`. seo is tier 1 and cannot see a build's bytes, so it was never the package
|
|
30
|
+
that could answer. `errors.test.ts` pins the code set, so re-adding one is a failing test.
|
|
31
|
+
- **Errors name the file, not the URL.** `RouteRecord.file` is in every cause and every fix; an agent must be able to open the source without guessing.
|
|
32
|
+
- **Fail closed, and core reads the key.** `isIndexable()` is `environment === 'production'` and
|
|
33
|
+
nothing else — `staging`, a laptop, a typo and an unset variable all disallow. `ULTIMATE_ENV` has
|
|
34
|
+
exactly one reader and it is `@ultimat3/core`'s (`Environment`, `tryResolveEnvironment`); this
|
|
35
|
+
package owns only what an *unnameable* environment means, which is core's `DEFAULT_ENVIRONMENT`
|
|
36
|
+
and never a throw — nothing in a web container's boot path resolves the environment
|
|
37
|
+
unconditionally, so a `robots.txt` render is routinely the first reader and it must answer.
|
|
38
|
+
Never invert that default and never re-read the key here.
|
|
39
|
+
- **Required schema.org fields are required in the input type.** Runtime `required()` only catches empty strings from a CMS; the type is the primary gate.
|
|
40
|
+
- **No ambient defaults for meta.** A missing description is an error, never a fallback string.
|
|
41
|
+
- **Head tags are CONSTRUCTED here and serialised nowhere here.** `renderMeta` returns data;
|
|
42
|
+
`HeadTag.text` is raw, and `@ultimat3/render`'s `renderHead` picks the escape from the element
|
|
43
|
+
(raw text for code, the total `\uXXXX` JSON rule for a `type` ending in `json`). `meta.ts` had a
|
|
44
|
+
`renderHeadTags` that emitted HTML with a weaker escape — `</` neutralised, `<!--<script>` not —
|
|
45
|
+
and **nothing called it**; deleted `As of 2026-08`. Never add it back, and never escape `text`
|
|
46
|
+
at construction: it would double-escape at tier 4, and the two rules would drift apart. seo
|
|
47
|
+
cannot import render's escapers either — seo is tier 1, render is tier 4. `xml.ts` stays the
|
|
48
|
+
package's one escaper for the XML and attribute surfaces it really does emit (sitemap, feeds,
|
|
49
|
+
robots, `<picture>`), whose rules are the opposite of a raw-text element's.
|
|
50
|
+
- **One producer of the JSON-LD tags, and it is `renderMeta`.** `ld.*` builds nodes; `renderMeta`
|
|
51
|
+
emits `meta.ld` as one `<script type="application/ld+json">` **per node**. `ld.ts` also carried a
|
|
52
|
+
`renderLd(nodes)` that collapsed the same nodes into ONE script with a `@graph` — a second
|
|
53
|
+
serialisation of one input, exported, callerless, and the reason `@ultimat3/render`'s
|
|
54
|
+
`head-seo.ts` had to write down that it binds no `renderLd` half on purpose. Deleted
|
|
55
|
+
`As of 2026-08`. Never add it back: an app that found it on the public surface and called it from
|
|
56
|
+
`meta` emitted its graph twice, which is exactly the duplicate that comment was defending against.
|
|
57
|
+
- **`builtinImageDriver({ read })` takes its reader.** `TransformRequest.src` is a string, and
|
|
58
|
+
whether that string is a path, a storage key or a URL is the app's fact, not seo's — never add
|
|
59
|
+
a filesystem fallback. Pixels come from `@ultimat3/core`'s pipeline; seo owns no second scaler,
|
|
60
|
+
and the driver reports the size it probed off the output, never the size that was requested.
|
|
61
|
+
- **One spelling of the transform query keys.** `IMAGE_QUERY_KEYS` in `images.ts` is the only
|
|
62
|
+
place `w`/`f`/`q` are spelled; `defaultUrlFor` writes them and `parseImageQuery` is the only
|
|
63
|
+
reader — never hand-roll either half against a literal. A present-but-unusable `w` or `q`
|
|
64
|
+
(`?w=0`, `?q=150`, empty, negative, fractional, or so many digits that `parseInt` returns
|
|
65
|
+
`Infinity`) throws `X_IMAGE_QUERY_INVALID` instead of
|
|
66
|
+
falling back to the untransformed original — that silent fallback is the layout shift this
|
|
67
|
+
whole contract exists to prevent. `parseImageQuery` never validates `f` against real format
|
|
68
|
+
names; that refusal stays `image-driver.ts`'s `X_IMAGE_UNSUPPORTED`, so one bad URL never
|
|
69
|
+
carries two codes.
|
|
70
|
+
- **A feed date never throws and never lies.** `feed-dates.ts` is the only place a feed timestamp
|
|
71
|
+
is parsed or formatted; `buildFeed` resolves every date once, so no builder ever sees a string it
|
|
72
|
+
has to parse. An item date that will not parse is **absent** — the element is omitted (Atom's
|
|
73
|
+
required `<updated>` falls back to the feed's) — never `Invalid Date`, never today standing in
|
|
74
|
+
for it, and never a `RangeError` out of a route a reader is polling. Scan a feed with a loop, not
|
|
75
|
+
`Math.max(...times)`: a spread is one argument per item and the engine's limit is the caller's
|
|
76
|
+
stack depth, not the feed's size. "Now" arrives through `BuildFeedOptions.clock`; nothing in
|
|
77
|
+
`rss.ts` reads a clock of its own.
|
|
78
|
+
- Only `site/` routes are SEO-checked — `app/` is behind auth and crawlers never authenticate.
|
|
79
|
+
|
|
80
|
+
## Files
|
|
81
|
+
|
|
82
|
+
| Path | Responsibility |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `routes.ts` | the `RouteRecord` shape + `indexableRoutes` / `expandRoute` |
|
|
85
|
+
| `meta.ts` | model + `renderMeta()`; the only place head tags are constructed, and no place they are serialised |
|
|
86
|
+
| `validate.ts` | the gate; `MetaIssue` is the serialisable projection of a `SeoError` |
|
|
87
|
+
| `xml.ts` | all escaping. Never hand-roll an escape in another module |
|
|
88
|
+
| `rss.ts` | `buildFeed()` — the three feed formats; owns markup, never a date |
|
|
89
|
+
| `feed-dates.ts` | all timestamp parsing and formatting for feeds. Never `Date.parse` in another module |
|
|
90
|
+
| `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 |
|
|
91
|
+
| `image-driver.ts` | the bytes behind that promise: `ImageTransformDriver` + `builtinImageDriver({ read })` over core's pipeline — png/jpeg only |
|
|
92
|
+
|
|
93
|
+
## Commands
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
bun test packages/seo
|
|
97
|
+
bun run --filter @ultimat3/seo typecheck
|
|
98
|
+
```
|
package/README.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# @ultimat3/seo 🔍
|
|
2
2
|
|
|
3
|
-
SEO is **enforced, not documented
|
|
3
|
+
SEO is **enforced, not documented**: every rule below is an `assert*` that throws a coded
|
|
4
|
+
`SeoError` with the route file and the exact edit, never a lint warning.
|
|
5
|
+
|
|
6
|
+
`As of 2026-08` the half that *calls* those asserts is missing — nothing outside this package
|
|
7
|
+
imports `assertMeta`, `buildSitemap` or `buildRobots`, so no step of `x verify` runs them yet.
|
|
8
|
+
Wiring them is a `HostCheck` on an existing step in `packages/cli/src/cmd-verify.ts`. The shapes
|
|
9
|
+
below are what will fail the build; today they fail an app that calls them itself.
|
|
4
10
|
|
|
5
11
|
## What fails the build
|
|
6
12
|
|
|
@@ -11,9 +17,12 @@ SEO is **enforced, not documented**. These are build errors, not lint warnings.
|
|
|
11
17
|
| `X_SEO_META_TOO_LONG` | title > 60 chars, description > 160 | the tail is truncated in results — the words are paid for and never read |
|
|
12
18
|
| `X_SEO_CANONICAL_MISMATCH` | `meta.canonical` does not resolve to the route's own URL | a wrong canonical de-indexes the page in favour of another |
|
|
13
19
|
| `X_LD_INVALID` | a JSON-LD node missing a required schema.org field | invalid structured data drops the rich result silently |
|
|
14
|
-
| `X_SEO_BUDGET_EXCEEDED` | a route over its `js` / `css` / `lcp` / `cls` / `inp` budget | performance is a ranking factor and regressions are invisible without a gate |
|
|
15
20
|
| `X_SITEMAP_TOO_LARGE` | the sitemap index exceeds 50,000 files | past the protocol limit the whole sitemap is discarded |
|
|
16
21
|
|
|
22
|
+
Performance budgets are **not** here: `x verify`'s `budgets` step is `@ultimat3/cli`'s
|
|
23
|
+
`checkBudgets`, over the route manifest and the build's own stats, and it throws
|
|
24
|
+
`@ultimat3/render`'s `X_BUDGET_EXCEEDED`. This package is tier 1 and cannot see a build's bytes.
|
|
25
|
+
|
|
17
26
|
Every error names the exact route **file** and the exact edit:
|
|
18
27
|
|
|
19
28
|
```
|
|
@@ -32,9 +41,9 @@ X_SEO_META_MISSING: a site/ route is missing required metadata
|
|
|
32
41
|
| `sitemap.ts` | `buildSitemap()` from the route table + each route's `prerender()`, per-locale alternates, automatic index splitting past 50k |
|
|
33
42
|
| `robots.ts` | `buildRobots()`, environment-aware and fail-closed |
|
|
34
43
|
| `rss.ts` | `buildFeed()` → RSS 2.0 + Atom + JSON Feed from one item list |
|
|
44
|
+
| `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 |
|
|
35
45
|
| `images.ts` | `srcset` widths, AVIF → WebP → original, inlined intrinsic dimensions, and `parseImageQuery()` — reads a minted URL back into a transform request |
|
|
36
46
|
| `image-driver.ts` | `ImageTransformDriver` + `builtinImageDriver()`: the variant bytes and the blur placeholder |
|
|
37
|
-
| `budgets.ts` | `checkBudgets()` / `assertBudgets()`, the CI gate |
|
|
38
47
|
|
|
39
48
|
## Type-level enforcement
|
|
40
49
|
|
|
@@ -49,8 +58,9 @@ weeks later.
|
|
|
49
58
|
## robots.txt is fail-closed
|
|
50
59
|
|
|
51
60
|
Only the literal string `production` in `ULTIMATE_ENV` / `NODE_ENV` opts a deploy
|
|
52
|
-
into indexing.
|
|
53
|
-
|
|
61
|
+
into indexing. The environment is `@ultimat3/core`'s `Environment` and its reader
|
|
62
|
+
is core's — this package owns no second one. A branch deploy (`staging`), a
|
|
63
|
+
laptop, a typo and an unset variable are all "not production", and each emits:
|
|
54
64
|
|
|
55
65
|
```
|
|
56
66
|
User-agent: *
|
|
@@ -144,6 +154,6 @@ export const config = defineRoute({
|
|
|
144
154
|
## Commands
|
|
145
155
|
|
|
146
156
|
```
|
|
147
|
-
bun test # meta, validation, JSON-LD, sitemap, robots, feeds, images,
|
|
157
|
+
bun test # meta, validation, JSON-LD, sitemap, robots, feeds, images, error codes
|
|
148
158
|
bun run typecheck
|
|
149
159
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/seo",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.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",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,6 +31,6 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "2.0.0"
|
|
34
35
|
}
|
|
35
36
|
}
|
package/src/errors.ts
CHANGED
|
@@ -14,7 +14,6 @@ export const SEO_ERROR_CODES = {
|
|
|
14
14
|
metaTooLong: 'X_SEO_META_TOO_LONG',
|
|
15
15
|
canonicalMismatch: 'X_SEO_CANONICAL_MISMATCH',
|
|
16
16
|
ldInvalid: 'X_LD_INVALID',
|
|
17
|
-
budgetExceeded: 'X_SEO_BUDGET_EXCEEDED',
|
|
18
17
|
sitemapTooLarge: 'X_SITEMAP_TOO_LARGE',
|
|
19
18
|
imageQueryInvalid: 'X_IMAGE_QUERY_INVALID',
|
|
20
19
|
} as const;
|
|
@@ -23,9 +22,10 @@ export type SeoErrorCode = (typeof SEO_ERROR_CODES)[keyof typeof SEO_ERROR_CODES
|
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
24
|
* Every code here is seo's own, so the registration is unconditional and atomic — a collision must
|
|
26
|
-
* surface as X_ERROR_CODE_DUPLICATE, never as a first-writer-wins title.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* surface as X_ERROR_CODE_DUPLICATE, never as a first-writer-wins title. A performance budget is
|
|
26
|
+
* deliberately not among them: `@ultimat3/render` owns `X_BUDGET_EXCEEDED` and `@ultimat3/cli`'s
|
|
27
|
+
* `checkBudgets` is the gate that throws it, so seo naming the same condition was a second code
|
|
28
|
+
* for one fault whose only thrower was its own test. `errors.test.ts` pins the set.
|
|
29
29
|
* `X_NOT_IMPLEMENTED` and `X_IMAGE_UNSUPPORTED` are core's; `SeoError` throws them, untitled here.
|
|
30
30
|
*/
|
|
31
31
|
registerErrorCodes({
|
|
@@ -34,7 +34,6 @@ registerErrorCodes({
|
|
|
34
34
|
X_SEO_META_TOO_LONG: { title: 'title or description exceeds what search results render' },
|
|
35
35
|
X_SEO_CANONICAL_MISMATCH: { title: 'canonical URL does not match the route path' },
|
|
36
36
|
X_LD_INVALID: { title: 'JSON-LD node is missing a required schema.org field' },
|
|
37
|
-
X_SEO_BUDGET_EXCEEDED: { title: 'route exceeded its performance budget' },
|
|
38
37
|
X_SITEMAP_TOO_LARGE: { title: 'sitemap exceeds the 50,000-entry protocol limit' },
|
|
39
38
|
X_IMAGE_QUERY_INVALID: { title: 'an image transform query parameter is present but unusable' },
|
|
40
39
|
});
|
|
@@ -106,22 +105,6 @@ export function ldInvalid(type: string, field: string, hint: string): SeoError {
|
|
|
106
105
|
});
|
|
107
106
|
}
|
|
108
107
|
|
|
109
|
-
export function budgetExceeded(
|
|
110
|
-
route: string,
|
|
111
|
-
file: string,
|
|
112
|
-
metric: string,
|
|
113
|
-
limit: number,
|
|
114
|
-
actual: number,
|
|
115
|
-
unit: string,
|
|
116
|
-
): SeoError {
|
|
117
|
-
return new SeoError({
|
|
118
|
-
code: SEO_ERROR_CODES.budgetExceeded,
|
|
119
|
-
cause: `${route} (${file}) ${metric} is ${actual}${unit}, budget is ${limit}${unit}`,
|
|
120
|
-
fix: `x analyze ${route} --json # then trim, or raise budget.${metric} in ${file}`,
|
|
121
|
-
meta: { route, file, metric, limit, actual, unit },
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
|
|
125
108
|
export function sitemapTooLarge(count: number, max: number): SeoError {
|
|
126
109
|
return new SeoError({
|
|
127
110
|
code: SEO_ERROR_CODES.sitemapTooLarge,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The one place a feed timestamp is parsed or formatted. Item dates are app data — a CMS column,
|
|
2
|
+
// a front-matter line — so a string that is not a date at all is a live possibility, and a feed is
|
|
3
|
+
// a page a reader is polling, not a build step: `Date.parse` answers `NaN`, and `NaN` reaches
|
|
4
|
+
// `toISOString()` as a `RangeError` that takes the whole route down over one bad row.
|
|
5
|
+
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/** Milliseconds for a timestamp, or `undefined` when the string is not a date at all. */
|
|
9
|
+
export function epochOf(iso: string | undefined): number | undefined {
|
|
10
|
+
if (iso === undefined) return undefined;
|
|
11
|
+
const ms = Date.parse(iso);
|
|
12
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The newest instant in a feed, or `undefined` when nothing in it carries a usable one.
|
|
17
|
+
*
|
|
18
|
+
* A loop, never `Math.max(...times)`: a spread passes one argument per item, so the call overflows
|
|
19
|
+
* the engine's stack in proportion to how well the blog did — and it overflows sooner the deeper
|
|
20
|
+
* the request stack already is, which makes the size that breaks it a property of the caller
|
|
21
|
+
* rather than of the feed.
|
|
22
|
+
*/
|
|
23
|
+
export function newestEpoch(times: Iterable<number | undefined>): number | undefined {
|
|
24
|
+
let max: number | undefined;
|
|
25
|
+
for (const ms of times) {
|
|
26
|
+
if (ms !== undefined && (max === undefined || ms > max)) max = ms;
|
|
27
|
+
}
|
|
28
|
+
return max;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The clock's instant, for a feed whose items carry no usable timestamp of their own. A `Clock`
|
|
33
|
+
* handing back an invalid `Date` is the caller's bug, and the epoch is a wrong-but-renderable
|
|
34
|
+
* answer — throwing here would be the crash this module exists to prevent, one seam over.
|
|
35
|
+
*/
|
|
36
|
+
export function nowEpoch(clock: Clock): number {
|
|
37
|
+
const ms = clock.now().getTime();
|
|
38
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** RFC 3339, for Atom and JSON Feed. Takes an instant, so it can never be handed a `NaN`. */
|
|
42
|
+
export function isoOf(ms: number): string {
|
|
43
|
+
return new Date(ms).toISOString();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** RFC 822, RSS 2.0's only date format. Same guarantee. */
|
|
47
|
+
export function rfc822Of(ms: number): string {
|
|
48
|
+
return new Date(ms).toUTCString();
|
|
49
|
+
}
|
package/src/images.ts
CHANGED
|
@@ -118,6 +118,26 @@ function parseQuality(raw: string): number {
|
|
|
118
118
|
return quality;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* A safe integer is not a servable width. `?w=99999999` clears every gate above and then asks the
|
|
123
|
+
* encoder for a row buffer nothing can allocate — and on a surface that CACHES its output, each
|
|
124
|
+
* distinct width mints a new stored object, so an unbounded `w` is unbounded writes as well as one
|
|
125
|
+
* bad allocation. 8192 is past every display this can legitimately serve (`DEFAULT_SRCSET_WIDTHS`
|
|
126
|
+
* tops out two orders below it).
|
|
127
|
+
*
|
|
128
|
+
* Hard, not configurable: the shape `packages/mcp/src/query-limits.ts:48-56` uses — a caller may
|
|
129
|
+
* narrow a request by asking for less, never widen it by asking for more.
|
|
130
|
+
*/
|
|
131
|
+
const MAX_IMAGE_WIDTH = 8192;
|
|
132
|
+
|
|
133
|
+
function parseWidth(raw: string): number {
|
|
134
|
+
const width = parsePositiveInt(IMAGE_QUERY_KEYS.width, raw);
|
|
135
|
+
if (width > MAX_IMAGE_WIDTH) {
|
|
136
|
+
throw imageQueryInvalid(IMAGE_QUERY_KEYS.width, raw, `must be ${MAX_IMAGE_WIDTH} or less`);
|
|
137
|
+
}
|
|
138
|
+
return width;
|
|
139
|
+
}
|
|
140
|
+
|
|
121
141
|
/**
|
|
122
142
|
* Naming no *real* format is deliberately not refused here: `image-driver.ts`'s
|
|
123
143
|
* `requestedFormat` already owns "is this an encodable format", and throwing in two places
|
|
@@ -143,7 +163,7 @@ export function parseImageQuery(params: URLSearchParams): ImageQuery | null {
|
|
|
143
163
|
if (rawWidth === null && rawFormat === null && rawQuality === null) return null;
|
|
144
164
|
|
|
145
165
|
return {
|
|
146
|
-
...(rawWidth === null ? {} : { width:
|
|
166
|
+
...(rawWidth === null ? {} : { width: parseWidth(rawWidth) }),
|
|
147
167
|
...(rawFormat === null ? {} : { format: parseFormat(rawFormat) }),
|
|
148
168
|
...(rawQuality === null ? {} : { quality: parseQuality(rawQuality) }),
|
|
149
169
|
};
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
// The public surface of @ultimat3/seo. Explicit named exports only.
|
|
2
2
|
|
|
3
|
-
export type { BudgetMeasurement, BudgetMetric, BudgetReport, BudgetViolation } from './budgets';
|
|
4
|
-
export { assertBudgets, BUDGET_UNITS, checkBudgets, DEFAULT_BUDGET, parseBytes } from './budgets';
|
|
5
3
|
export type { SeoErrorCode, SeoErrorInit } from './errors';
|
|
6
4
|
export {
|
|
7
|
-
budgetExceeded,
|
|
8
5
|
canonicalMismatch,
|
|
9
6
|
duplicateMeta,
|
|
10
7
|
imageQueryInvalid,
|
|
@@ -70,7 +67,6 @@ export {
|
|
|
70
67
|
Organization,
|
|
71
68
|
Person,
|
|
72
69
|
Product,
|
|
73
|
-
renderLd,
|
|
74
70
|
SoftwareApplication,
|
|
75
71
|
WebSite,
|
|
76
72
|
} from './ld';
|
|
@@ -90,16 +86,15 @@ export {
|
|
|
90
86
|
DESCRIPTION_MAX_LENGTH,
|
|
91
87
|
DESCRIPTION_MIN_LENGTH,
|
|
92
88
|
hreflangSet,
|
|
93
|
-
renderHeadTags,
|
|
94
89
|
renderMeta,
|
|
95
90
|
robotsContent,
|
|
96
91
|
TITLE_MAX_LENGTH,
|
|
97
92
|
} from './meta';
|
|
98
|
-
export type { RobotsConfig, RobotsGroup
|
|
99
|
-
export { buildRobots, isIndexable
|
|
100
|
-
export type { ChangeFreq, RenderMode,
|
|
93
|
+
export type { RobotsConfig, RobotsGroup } from './robots';
|
|
94
|
+
export { buildRobots, isIndexable } from './robots';
|
|
95
|
+
export type { ChangeFreq, RenderMode, RouteRecord, Surface } from './routes';
|
|
101
96
|
export { expandRoute, indexableRoutes, isDynamic } from './routes';
|
|
102
|
-
export type { Feed, FeedAuthor, FeedChannel, FeedItem } from './rss';
|
|
97
|
+
export type { BuildFeedOptions, Feed, FeedAuthor, FeedChannel, FeedItem } from './rss';
|
|
103
98
|
export { buildFeed } from './rss';
|
|
104
99
|
export type {
|
|
105
100
|
BuildSitemapOptions,
|
package/src/ld.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// type system cannot (a value read from a CMS).
|
|
5
5
|
|
|
6
6
|
import { ldInvalid } from './errors';
|
|
7
|
-
import type { HeadTag } from './meta';
|
|
8
7
|
|
|
9
8
|
export type JsonLd = Readonly<Record<string, unknown>>;
|
|
10
9
|
|
|
@@ -310,16 +309,3 @@ export const ld = {
|
|
|
310
309
|
SoftwareApplication,
|
|
311
310
|
WebSite,
|
|
312
311
|
} as const;
|
|
313
|
-
|
|
314
|
-
/** One `<script type="application/ld+json">` holding a @graph of every node. */
|
|
315
|
-
export function renderLd(nodes: readonly JsonLd[]): HeadTag {
|
|
316
|
-
const payload =
|
|
317
|
-
nodes.length === 1
|
|
318
|
-
? nodes[0]
|
|
319
|
-
: { '@context': LD_CONTEXT, '@graph': nodes.map(({ '@context': _drop, ...rest }) => rest) };
|
|
320
|
-
return {
|
|
321
|
-
tag: 'script',
|
|
322
|
-
attrs: { type: 'application/ld+json' },
|
|
323
|
-
text: JSON.stringify(payload),
|
|
324
|
-
};
|
|
325
|
-
}
|
package/src/meta.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// for a global default: a route that does not declare a description does not get
|
|
3
3
|
// one, it fails the build (see validate.ts).
|
|
4
4
|
|
|
5
|
-
import { absoluteUrl
|
|
5
|
+
import { absoluteUrl } from './xml';
|
|
6
6
|
|
|
7
7
|
/** Search results truncate past this; validate.ts enforces it. */
|
|
8
8
|
export const TITLE_MAX_LENGTH = 60;
|
|
@@ -83,7 +83,12 @@ export interface RouteMeta {
|
|
|
83
83
|
export interface HeadTag {
|
|
84
84
|
readonly tag: 'title' | 'meta' | 'link' | 'script';
|
|
85
85
|
readonly attrs: Readonly<Record<string, string>>;
|
|
86
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Text content, raw. Escaping is the RENDERER's — `@ultimat3/render`'s `renderHead` picks the
|
|
88
|
+
* rule from the element and, for a `type` ending in `json`, from the fact that the body is data.
|
|
89
|
+
* Escaping here would double-escape there, and a second escaper is how one of them ends up
|
|
90
|
+
* missing a character.
|
|
91
|
+
*/
|
|
87
92
|
readonly text?: string;
|
|
88
93
|
}
|
|
89
94
|
|
|
@@ -94,9 +99,22 @@ export interface RenderMetaOptions {
|
|
|
94
99
|
path?: string;
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Separators a title template puts between the slot and the brand. Stripped so the BRAND is what
|
|
104
|
+
* the containment test reads: `'%s — Ultimate'` means the brand is `Ultimate`, not `— Ultimate`.
|
|
105
|
+
*/
|
|
106
|
+
const TEMPLATE_SEPARATORS = /^[\s\-–—|·:>/]+|[\s\-–—|·:>/]+$/g;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The containment was `template.includes(title)` — inverted, so it only ever answered true when
|
|
110
|
+
* the title EQUALLED the brand. `applyTitleTemplate('About Ultimate', '%s — Ultimate')` produced
|
|
111
|
+
* `'About Ultimate — Ultimate'`, which `validate.ts` then measured against `TITLE_MAX_LENGTH`.
|
|
112
|
+
*/
|
|
97
113
|
export function applyTitleTemplate(title: string, template?: string): string {
|
|
98
114
|
if (template === undefined || template === '') return title;
|
|
99
|
-
|
|
115
|
+
const brand = template.replace('%s', '').replace(TEMPLATE_SEPARATORS, '');
|
|
116
|
+
if (brand !== '' && title.includes(brand)) return title;
|
|
117
|
+
return template.replace('%s', title);
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
export function robotsContent(directives: RobotsDirectives): string {
|
|
@@ -254,16 +272,10 @@ export function renderMeta(meta: RouteMeta, options: RenderMetaOptions = {}): re
|
|
|
254
272
|
return tags;
|
|
255
273
|
}
|
|
256
274
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
return `<script${attributes(tag.attrs)}>${safe}</script>`;
|
|
265
|
-
}
|
|
266
|
-
return `<${tag.tag}${attributes(tag.attrs)}>`;
|
|
267
|
-
})
|
|
268
|
-
.join('\n');
|
|
269
|
-
}
|
|
275
|
+
// `renderHeadTags` lived here and was deleted `As of 2026-08`. It serialised these tags to HTML,
|
|
276
|
+
// nothing called it, and its escaping was WEAKER than the path every document actually takes
|
|
277
|
+
// (`@ultimat3/render`'s `renderHead`): it neutralised `</` and not `<!--<script>`, and it applied
|
|
278
|
+
// that code-shaped rule to a JSON body where the total `\uXXXX` rule is available and cannot be
|
|
279
|
+
// got wrong. Exported and vulnerable is a trap — it looks authoritative because it lives in the
|
|
280
|
+
// package named `seo`. This module CONSTRUCTS head tags; serialising them is tier 4's job, and
|
|
281
|
+
// there is now one way to do it. seo cannot borrow render's escapers either: render is tier 4.
|
package/src/robots.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// robots.txt generation. Environment-aware, and the default is the safe one:
|
|
2
2
|
// anything that is not explicitly production emits `Disallow: /`, because a
|
|
3
|
-
//
|
|
3
|
+
// branch deploy that gets indexed outranks and cannibalises the real site.
|
|
4
4
|
|
|
5
|
+
import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from '@ultimat3/core';
|
|
5
6
|
import { absoluteUrl } from './xml';
|
|
6
7
|
|
|
7
|
-
export type SeoEnvironment = 'production' | 'preview' | 'development' | 'test';
|
|
8
|
-
|
|
9
8
|
export interface RobotsGroup {
|
|
10
9
|
/** One or more user agents this group applies to. */
|
|
11
10
|
userAgent: string | readonly string[];
|
|
@@ -16,8 +15,8 @@ export interface RobotsGroup {
|
|
|
16
15
|
|
|
17
16
|
export interface RobotsConfig {
|
|
18
17
|
baseUrl: string;
|
|
19
|
-
/** Omitted means "resolve from the environment"
|
|
20
|
-
environment?:
|
|
18
|
+
/** Omitted means "resolve from the environment"; anything but `production` disallows all. */
|
|
19
|
+
environment?: Environment | undefined;
|
|
21
20
|
groups?: readonly RobotsGroup[];
|
|
22
21
|
/** Sitemap paths or absolute URLs. Only emitted in production. */
|
|
23
22
|
sitemaps?: readonly string[];
|
|
@@ -26,21 +25,22 @@ export interface RobotsConfig {
|
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
|
-
* Fail-closed: only the exact string `production` opts a deploy into indexing.
|
|
30
|
-
*
|
|
28
|
+
* Fail-closed: only the exact string `production` opts a deploy into indexing. A branch deploy
|
|
29
|
+
* (`staging`), a laptop, a typo and an unset variable are all "not production" and all disallow.
|
|
31
30
|
*/
|
|
32
|
-
export function
|
|
33
|
-
|
|
34
|
-
): SeoEnvironment {
|
|
35
|
-
const raw = env['ULTIMATE_ENV'] ?? env['NODE_ENV'];
|
|
36
|
-
if (raw === 'production') return 'production';
|
|
37
|
-
if (raw === 'test') return 'test';
|
|
38
|
-
if (raw === 'development') return 'development';
|
|
39
|
-
return 'preview';
|
|
31
|
+
export function isIndexable(environment: Environment): boolean {
|
|
32
|
+
return environment === 'production';
|
|
40
33
|
}
|
|
41
34
|
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
/**
|
|
36
|
+
* `@ultimat3/core` owns the read of `ULTIMATE_ENV`; this module owns only what an unreadable one
|
|
37
|
+
* means for a crawler. A typo in that key throws there, and a `robots.txt` render can be the first
|
|
38
|
+
* thing in a web process to ask — nothing in the container's boot path resolves the environment
|
|
39
|
+
* unconditionally — so an unnameable deploy resolves to core's own default here. The body was
|
|
40
|
+
* already going to be `Disallow: /`; a 500 would only cost the operator the reason.
|
|
41
|
+
*/
|
|
42
|
+
function ambientEnvironment(): Environment {
|
|
43
|
+
return tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function agents(userAgent: string | readonly string[]): readonly string[] {
|
|
@@ -48,7 +48,7 @@ function agents(userAgent: string | readonly string[]): readonly string[] {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export function buildRobots(config: RobotsConfig): string {
|
|
51
|
-
const environment = config.environment ??
|
|
51
|
+
const environment = config.environment ?? ambientEnvironment();
|
|
52
52
|
const lines: string[] = [`# environment: ${environment}`];
|
|
53
53
|
|
|
54
54
|
if (!isIndexable(environment)) {
|
package/src/routes.ts
CHANGED
|
@@ -11,18 +11,6 @@ export type Surface = 'site' | 'app' | 'api';
|
|
|
11
11
|
|
|
12
12
|
export type ChangeFreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
13
13
|
|
|
14
|
-
export interface RouteBudget {
|
|
15
|
-
/** Byte budgets accept `'40kb'` or a raw number of bytes. */
|
|
16
|
-
js?: string | number;
|
|
17
|
-
css?: string | number;
|
|
18
|
-
/** Milliseconds. */
|
|
19
|
-
lcp?: number;
|
|
20
|
-
/** Unitless layout-shift score. */
|
|
21
|
-
cls?: number;
|
|
22
|
-
/** Milliseconds. */
|
|
23
|
-
inp?: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
14
|
export interface RouteRecord {
|
|
27
15
|
/** URL pattern, e.g. `/blog/:slug`. */
|
|
28
16
|
path: string;
|
|
@@ -33,7 +21,6 @@ export interface RouteRecord {
|
|
|
33
21
|
meta?: RouteMeta;
|
|
34
22
|
/** Concrete paths for a dynamic route, from `defineRoute({ prerender })`. */
|
|
35
23
|
prerender?: () => readonly string[] | Promise<readonly string[]>;
|
|
36
|
-
budget?: RouteBudget;
|
|
37
24
|
/** Keep out of the sitemap and emit `noindex`. */
|
|
38
25
|
noindex?: boolean;
|
|
39
26
|
lastmod?: string;
|
package/src/rss.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// one item list, because a site that ships RSS but not JSON Feed has simply
|
|
3
3
|
// picked a winner for its readers.
|
|
4
4
|
|
|
5
|
+
import { type Clock, systemClock } from '@ultimat3/core';
|
|
6
|
+
import { epochOf, isoOf, newestEpoch, nowEpoch, rfc822Of } from './feed-dates';
|
|
5
7
|
import { absoluteUrl, cdata, escapeXml, xmlElement } from './xml';
|
|
6
8
|
|
|
7
9
|
export interface FeedAuthor {
|
|
@@ -48,26 +50,50 @@ export interface Feed {
|
|
|
48
50
|
readonly json: string;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
export interface BuildFeedOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Where "now" comes from when no item carries a usable timestamp. Defaults to `systemClock`;
|
|
56
|
+
* pass `frozenClock(at)` and the three formats are byte-for-byte reproducible.
|
|
57
|
+
*/
|
|
58
|
+
clock?: Clock;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
/**
|
|
62
|
+
* An item with its timestamps already resolved, so no builder below ever parses a date. A date the
|
|
63
|
+
* app supplied that will not parse is `undefined` here, and every format omits the element rather
|
|
64
|
+
* than emitting `Invalid Date` — a feed reader is entitled to reject a document over one, and the
|
|
65
|
+
* other twenty entries are fine.
|
|
66
|
+
*/
|
|
67
|
+
interface DatedItem {
|
|
68
|
+
readonly item: FeedItem;
|
|
69
|
+
readonly published: number | undefined;
|
|
70
|
+
readonly updated: number | undefined;
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
function
|
|
73
|
+
function dated(item: FeedItem): DatedItem {
|
|
74
|
+
return { item, published: epochOf(item.published), updated: epochOf(item.updated) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Newest first. An item with no usable `published` sorts last, rather than making the comparator
|
|
79
|
+
* answer `NaN` — which hands the order of the whole feed to the engine's sort implementation.
|
|
80
|
+
*/
|
|
81
|
+
function byNewest(a: DatedItem, b: DatedItem): number {
|
|
82
|
+
if (a.published === undefined) return b.published === undefined ? 0 : 1;
|
|
83
|
+
if (b.published === undefined) return -1;
|
|
84
|
+
return b.published - a.published;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildRss(channel: FeedChannel, items: readonly DatedItem[], updated: number): string {
|
|
62
88
|
const self = absoluteUrl(channel.siteUrl, channel.feedUrl);
|
|
63
89
|
const entries = items
|
|
64
|
-
.map((item) => {
|
|
90
|
+
.map(({ item, published }) => {
|
|
65
91
|
const parts = [
|
|
66
92
|
xmlElement('title', item.title),
|
|
67
93
|
xmlElement('link', item.url),
|
|
68
94
|
` <guid isPermaLink="false">${escapeXml(item.id)}</guid>`,
|
|
69
|
-
xmlElement('pubDate', rfc822(item.published)),
|
|
70
95
|
];
|
|
96
|
+
if (published !== undefined) parts.push(xmlElement('pubDate', rfc822Of(published)));
|
|
71
97
|
if (item.summary !== undefined) parts.push(xmlElement('description', item.summary));
|
|
72
98
|
if (item.contentHtml !== undefined) {
|
|
73
99
|
parts.push(` <content:encoded>${cdata(item.contentHtml)}</content:encoded>`);
|
|
@@ -85,7 +111,7 @@ function buildRss(channel: FeedChannel, items: readonly FeedItem[], updated: str
|
|
|
85
111
|
` ${xmlElement('link', channel.siteUrl)}`,
|
|
86
112
|
` ${xmlElement('description', channel.description)}`,
|
|
87
113
|
` ${xmlElement('language', channel.language)}`,
|
|
88
|
-
` ${xmlElement('lastBuildDate',
|
|
114
|
+
` ${xmlElement('lastBuildDate', rfc822Of(updated))}`,
|
|
89
115
|
` <atom:link href="${escapeXml(self)}" rel="self" type="application/rss+xml"/>`,
|
|
90
116
|
channel.copyright === undefined ? '' : ` ${xmlElement('copyright', channel.copyright)}`,
|
|
91
117
|
entries,
|
|
@@ -97,17 +123,21 @@ function buildRss(channel: FeedChannel, items: readonly FeedItem[], updated: str
|
|
|
97
123
|
.join('\n');
|
|
98
124
|
}
|
|
99
125
|
|
|
100
|
-
function buildAtom(channel: FeedChannel, items: readonly
|
|
126
|
+
function buildAtom(channel: FeedChannel, items: readonly DatedItem[], updated: number): string {
|
|
101
127
|
const self = absoluteUrl(channel.siteUrl, channel.feedUrl);
|
|
102
128
|
const entries = items
|
|
103
|
-
.map((item) => {
|
|
129
|
+
.map(({ item, published, updated: itemUpdated }) => {
|
|
104
130
|
const parts = [
|
|
105
131
|
` ${xmlElement('title', item.title)}`,
|
|
106
132
|
` <link href="${escapeXml(item.url)}"/>`,
|
|
107
133
|
` ${xmlElement('id', item.id)}`,
|
|
108
|
-
|
|
109
|
-
|
|
134
|
+
// Atom requires <updated> on every entry, so an entry with no usable date of its own
|
|
135
|
+
// carries the feed's — the one instant in the document that is always real.
|
|
136
|
+
` ${xmlElement('updated', isoOf(itemUpdated ?? published ?? updated))}`,
|
|
110
137
|
];
|
|
138
|
+
if (published !== undefined) {
|
|
139
|
+
parts.push(` ${xmlElement('published', isoOf(published))}`);
|
|
140
|
+
}
|
|
111
141
|
if (item.summary !== undefined) parts.push(` ${xmlElement('summary', item.summary)}`);
|
|
112
142
|
if (item.contentHtml !== undefined) {
|
|
113
143
|
parts.push(` <content type="html">${escapeXml(item.contentHtml)}</content>`);
|
|
@@ -128,7 +158,7 @@ function buildAtom(channel: FeedChannel, items: readonly FeedItem[], updated: st
|
|
|
128
158
|
` ${xmlElement('title', channel.title)}`,
|
|
129
159
|
` ${xmlElement('subtitle', channel.description)}`,
|
|
130
160
|
` ${xmlElement('id', channel.siteUrl)}`,
|
|
131
|
-
` ${xmlElement('updated', updated)}`,
|
|
161
|
+
` ${xmlElement('updated', isoOf(updated))}`,
|
|
132
162
|
` <link href="${escapeXml(channel.siteUrl)}"/>`,
|
|
133
163
|
` <link href="${escapeXml(self)}" rel="self" type="application/atom+xml"/>`,
|
|
134
164
|
entries,
|
|
@@ -137,7 +167,7 @@ function buildAtom(channel: FeedChannel, items: readonly FeedItem[], updated: st
|
|
|
137
167
|
].join('\n');
|
|
138
168
|
}
|
|
139
169
|
|
|
140
|
-
function buildJsonFeed(channel: FeedChannel, items: readonly
|
|
170
|
+
function buildJsonFeed(channel: FeedChannel, items: readonly DatedItem[]): string {
|
|
141
171
|
return `${JSON.stringify(
|
|
142
172
|
{
|
|
143
173
|
version: 'https://jsonfeed.org/version/1.1',
|
|
@@ -148,15 +178,15 @@ function buildJsonFeed(channel: FeedChannel, items: readonly FeedItem[]): string
|
|
|
148
178
|
language: channel.language,
|
|
149
179
|
icon: channel.icon,
|
|
150
180
|
authors: channel.author === undefined ? undefined : [channel.author],
|
|
151
|
-
items: items.map((item) => ({
|
|
181
|
+
items: items.map(({ item, published, updated }) => ({
|
|
152
182
|
id: item.id,
|
|
153
183
|
url: item.url,
|
|
154
184
|
title: item.title,
|
|
155
185
|
summary: item.summary,
|
|
156
186
|
content_html: item.contentHtml,
|
|
157
187
|
image: item.image,
|
|
158
|
-
date_published:
|
|
159
|
-
date_modified:
|
|
188
|
+
date_published: published === undefined ? undefined : isoOf(published),
|
|
189
|
+
date_modified: updated === undefined ? undefined : isoOf(updated),
|
|
160
190
|
tags: item.tags,
|
|
161
191
|
authors: item.author === undefined ? undefined : [item.author],
|
|
162
192
|
})),
|
|
@@ -166,10 +196,20 @@ function buildJsonFeed(channel: FeedChannel, items: readonly FeedItem[]): string
|
|
|
166
196
|
)}\n`;
|
|
167
197
|
}
|
|
168
198
|
|
|
169
|
-
/**
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
199
|
+
/**
|
|
200
|
+
* RSS 2.0, Atom, and JSON Feed 1.1 from one item list. Never throws over a date: an unparseable
|
|
201
|
+
* one is treated as absent, and the feed still renders for the items that have a real one.
|
|
202
|
+
*/
|
|
203
|
+
export function buildFeed(
|
|
204
|
+
channel: FeedChannel,
|
|
205
|
+
items: readonly FeedItem[],
|
|
206
|
+
options: BuildFeedOptions = {},
|
|
207
|
+
): Feed {
|
|
208
|
+
const ordered = items.map(dated).sort(byNewest);
|
|
209
|
+
const updated =
|
|
210
|
+
epochOf(channel.updated) ??
|
|
211
|
+
newestEpoch(ordered.map((entry) => entry.updated ?? entry.published)) ??
|
|
212
|
+
nowEpoch(options.clock ?? systemClock);
|
|
173
213
|
return {
|
|
174
214
|
rss: buildRss(channel, ordered, updated),
|
|
175
215
|
atom: buildAtom(channel, ordered, updated),
|
package/src/xml.ts
CHANGED
|
@@ -32,8 +32,17 @@ export function attributes(attrs: Readonly<Record<string, string>>): string {
|
|
|
32
32
|
.join('');
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Join a base URL and a path without producing `//` or dropping a segment.
|
|
37
|
+
*
|
|
38
|
+
* A trailing slash the PATH declares is kept: `/blog/` and `/blog` are different resources, and
|
|
39
|
+
* this builds every `<loc>` (`sitemap.ts`) and every canonical (`meta.ts`), so stripping it made a
|
|
40
|
+
* trailing-slash site publish URLs that redirect. Only the bare-root join — `''` or `'/'` — has
|
|
41
|
+
* nothing to keep, and it collapses to the base.
|
|
42
|
+
*/
|
|
36
43
|
export function absoluteUrl(baseUrl: string, path: string): string {
|
|
37
44
|
if (/^https?:\/\//.test(path)) return path;
|
|
38
|
-
|
|
45
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
46
|
+
const rest = path.replace(/^\/+/, '');
|
|
47
|
+
return rest === '' ? base : `${base}/${rest}`;
|
|
39
48
|
}
|
package/src/budgets.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
// Per-route performance budgets. `checkBudgets()` returns a `--json`-shaped
|
|
2
|
-
// report; `x verify` fails CI on it. A budget that only warns is a budget that
|
|
3
|
-
// gets ignored, so the assert path throws X_SEO_BUDGET_EXCEEDED naming the route file.
|
|
4
|
-
|
|
5
|
-
import { budgetExceeded, SEO_ERROR_CODES, SeoError } from './errors';
|
|
6
|
-
import type { RouteBudget, RouteRecord } from './routes';
|
|
7
|
-
|
|
8
|
-
/** Core Web Vitals "good" thresholds, as of 2026-07. */
|
|
9
|
-
export const DEFAULT_BUDGET: Required<Pick<RouteBudget, 'lcp' | 'cls' | 'inp'>> = {
|
|
10
|
-
lcp: 2500,
|
|
11
|
-
cls: 0.1,
|
|
12
|
-
inp: 200,
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export type BudgetMetric = 'js' | 'css' | 'lcp' | 'cls' | 'inp';
|
|
16
|
-
|
|
17
|
-
export const BUDGET_UNITS: Readonly<Record<BudgetMetric, string>> = {
|
|
18
|
-
js: 'b',
|
|
19
|
-
css: 'b',
|
|
20
|
-
lcp: 'ms',
|
|
21
|
-
cls: '',
|
|
22
|
-
inp: 'ms',
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
/** What a build or a lab run measured for one route. */
|
|
26
|
-
export interface BudgetMeasurement {
|
|
27
|
-
route: string;
|
|
28
|
-
js?: number;
|
|
29
|
-
css?: number;
|
|
30
|
-
lcp?: number;
|
|
31
|
-
cls?: number;
|
|
32
|
-
inp?: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface BudgetViolation {
|
|
36
|
-
readonly route: string;
|
|
37
|
-
readonly file: string;
|
|
38
|
-
readonly metric: BudgetMetric;
|
|
39
|
-
readonly limit: number;
|
|
40
|
-
readonly actual: number;
|
|
41
|
-
readonly overBy: number;
|
|
42
|
-
readonly unit: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface BudgetReport {
|
|
46
|
-
readonly ok: boolean;
|
|
47
|
-
readonly checked: number;
|
|
48
|
-
readonly violations: readonly BudgetViolation[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const BYTE_UNITS: Readonly<Record<string, number>> = {
|
|
52
|
-
b: 1,
|
|
53
|
-
kb: 1024,
|
|
54
|
-
mb: 1024 * 1024,
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/** `'40kb'` -> 40960. A raw number is already bytes. */
|
|
58
|
-
export function parseBytes(value: string | number): number {
|
|
59
|
-
if (typeof value === 'number') return value;
|
|
60
|
-
const match = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|mb)?\s*$/i.exec(value);
|
|
61
|
-
if (match === null) {
|
|
62
|
-
throw new SeoError({
|
|
63
|
-
code: SEO_ERROR_CODES.budgetExceeded,
|
|
64
|
-
cause: `budget size ${JSON.stringify(value)} is not a byte count or a size string`,
|
|
65
|
-
fix: "write the budget as a number of bytes or a string like '40kb'",
|
|
66
|
-
meta: { value },
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
const scale = BYTE_UNITS[(match[2] ?? 'b').toLowerCase()] ?? 1;
|
|
70
|
-
return Math.round(Number(match[1]) * scale);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function limitOf(budget: RouteBudget, metric: BudgetMetric): number | undefined {
|
|
74
|
-
if (metric === 'js' || metric === 'css') {
|
|
75
|
-
const raw = budget[metric];
|
|
76
|
-
return raw === undefined ? undefined : parseBytes(raw);
|
|
77
|
-
}
|
|
78
|
-
return budget[metric] ?? DEFAULT_BUDGET[metric];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function checkBudgets(
|
|
82
|
-
routes: readonly RouteRecord[],
|
|
83
|
-
measurements: readonly BudgetMeasurement[],
|
|
84
|
-
): BudgetReport {
|
|
85
|
-
const byRoute = new Map(measurements.map((measurement) => [measurement.route, measurement]));
|
|
86
|
-
const violations: BudgetViolation[] = [];
|
|
87
|
-
let checked = 0;
|
|
88
|
-
|
|
89
|
-
for (const route of routes) {
|
|
90
|
-
const measured = byRoute.get(route.path);
|
|
91
|
-
if (measured === undefined) continue;
|
|
92
|
-
checked += 1;
|
|
93
|
-
const budget = route.budget ?? {};
|
|
94
|
-
|
|
95
|
-
for (const metric of ['js', 'css', 'lcp', 'cls', 'inp'] as const) {
|
|
96
|
-
const actual = measured[metric];
|
|
97
|
-
const limit = limitOf(budget, metric);
|
|
98
|
-
if (actual === undefined || limit === undefined) continue;
|
|
99
|
-
if (actual <= limit) continue;
|
|
100
|
-
violations.push({
|
|
101
|
-
route: route.path,
|
|
102
|
-
file: route.file,
|
|
103
|
-
metric,
|
|
104
|
-
limit,
|
|
105
|
-
actual,
|
|
106
|
-
overBy: Number((actual - limit).toFixed(4)),
|
|
107
|
-
unit: BUDGET_UNITS[metric],
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return { ok: violations.length === 0, checked, violations };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Fails the build on the first violation. `x verify` calls this. */
|
|
116
|
-
export function assertBudgets(report: BudgetReport): void {
|
|
117
|
-
const first = report.violations[0];
|
|
118
|
-
if (first === undefined) return;
|
|
119
|
-
throw budgetExceeded(
|
|
120
|
-
first.route,
|
|
121
|
-
first.file,
|
|
122
|
-
first.metric,
|
|
123
|
-
first.limit,
|
|
124
|
-
first.actual,
|
|
125
|
-
first.unit,
|
|
126
|
-
);
|
|
127
|
-
}
|