@ultimat3/seo 1.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/LICENSE +21 -0
- package/README.md +149 -0
- package/package.json +35 -0
- package/src/budgets.ts +127 -0
- package/src/errors.ts +177 -0
- package/src/image-driver.ts +91 -0
- package/src/images.ts +235 -0
- package/src/index.ts +121 -0
- package/src/ld.ts +325 -0
- package/src/meta.ts +269 -0
- package/src/robots.ts +79 -0
- package/src/routes.ts +62 -0
- package/src/rss.ts +178 -0
- package/src/sitemap.ts +179 -0
- package/src/validate.ts +146 -0
- package/src/xml.ts +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 developerz.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# @ultimat3/seo 🔍
|
|
2
|
+
|
|
3
|
+
SEO is **enforced, not documented**. These are build errors, not lint warnings.
|
|
4
|
+
|
|
5
|
+
## What fails the build
|
|
6
|
+
|
|
7
|
+
| Code | Trigger | Why it is fatal |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `X_SEO_META_MISSING` | a `site/` route with no `meta.title` or `meta.description` | a page with no description gets a snippet Google invents; that is the one string you cannot fix after launch |
|
|
10
|
+
| `X_SEO_DUPLICATE_META` | two routes share a title or description | duplicate meta makes the pages compete with each other, and the wrong one wins |
|
|
11
|
+
| `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
|
+
| `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
|
+
| `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
|
+
| `X_SITEMAP_TOO_LARGE` | the sitemap index exceeds 50,000 files | past the protocol limit the whole sitemap is discarded |
|
|
16
|
+
|
|
17
|
+
Every error names the exact route **file** and the exact edit:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
X_SEO_META_MISSING: a site/ route is missing required metadata
|
|
21
|
+
cause: apps/web/site/about/page.tsx (route "/about") has no meta.description
|
|
22
|
+
fix: add description to defineRoute({ meta }) in apps/web/site/about/page.tsx
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Modules
|
|
26
|
+
|
|
27
|
+
| File | Owns |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `meta.ts` | the metadata model, `renderMeta()` → head tags: title template, canonical, robots, `og:*`, `twitter:*`, hreflang + `x-default`, `theme-color` per colour scheme |
|
|
30
|
+
| `validate.ts` | the build gate — `validateMeta()` (`--json`-shaped) and `assertMeta()` |
|
|
31
|
+
| `ld.ts` | typed JSON-LD builders; required fields are required in the **input type** |
|
|
32
|
+
| `sitemap.ts` | `buildSitemap()` from the route table + each route's `prerender()`, per-locale alternates, automatic index splitting past 50k |
|
|
33
|
+
| `robots.ts` | `buildRobots()`, environment-aware and fail-closed |
|
|
34
|
+
| `rss.ts` | `buildFeed()` → RSS 2.0 + Atom + JSON Feed from one item list |
|
|
35
|
+
| `images.ts` | `srcset` widths, AVIF → WebP → original, inlined intrinsic dimensions, and `parseImageQuery()` — reads a minted URL back into a transform request |
|
|
36
|
+
| `image-driver.ts` | `ImageTransformDriver` + `builtinImageDriver()`: the variant bytes and the blur placeholder |
|
|
37
|
+
| `budgets.ts` | `checkBudgets()` / `assertBudgets()`, the CI gate |
|
|
38
|
+
|
|
39
|
+
## Type-level enforcement
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
ld.Article({ headline: 'Ship it', author: { name: 'Ada' } });
|
|
43
|
+
// ^ error: Property 'datePublished' is missing — required by schema.org
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A missing `datePublished` is a compile error, not a Search Console warning three
|
|
47
|
+
weeks later.
|
|
48
|
+
|
|
49
|
+
## robots.txt is fail-closed
|
|
50
|
+
|
|
51
|
+
Only the literal string `production` in `ULTIMATE_ENV` / `NODE_ENV` opts a deploy
|
|
52
|
+
into indexing. A typo, an unset variable, or a branch deploy all resolve to
|
|
53
|
+
`preview`, and preview emits:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
User-agent: *
|
|
57
|
+
Disallow: /
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
No sitemap line either — advertising one invites the crawl that was just refused.
|
|
61
|
+
|
|
62
|
+
## Image transforms
|
|
63
|
+
|
|
64
|
+
`builtinImageDriver({ read })` is the one built-in `ImageTransformDriver`, and it runs
|
|
65
|
+
`@ultimat3/core`'s pipeline — zero dependencies, no `sharp`, no native binary.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const images = builtinImageDriver({ read: (src) => Bun.file(`public${src}`).bytes() });
|
|
69
|
+
|
|
70
|
+
// a 1200x630 source
|
|
71
|
+
await images.transform({ src: '/img/hero.png', width: 640 });
|
|
72
|
+
// → { bytes, contentType: 'image/jpeg', width: 640, height: 336 }
|
|
73
|
+
await images.blurPlaceholder('/img/hero.png');
|
|
74
|
+
// → 'data:image/png;base64,…'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| | |
|
|
78
|
+
|---|---|
|
|
79
|
+
| decodes | `png`, `jpeg` |
|
|
80
|
+
| encodes | `png`, `jpeg` |
|
|
81
|
+
| probes only | `webp`, `avif`, `gif`, `svg` — intrinsic size, never a transform |
|
|
82
|
+
|
|
83
|
+
- **`read` is required.** `src` is a string and only the app knows whether it is a path, a
|
|
84
|
+
storage key or a URL. Guessing would mean a filesystem read off a URL-shaped string.
|
|
85
|
+
- **No `format` → the pixels decide:** PNG when the raster has alpha, JPEG otherwise. A logo
|
|
86
|
+
never grows a black background because nobody passed a format.
|
|
87
|
+
- **`format: 'avif'` or `'webp'` → `X_IMAGE_UNSUPPORTED`,** with the fix line. Those `<source>`
|
|
88
|
+
entries need a CDN driver; the framework will not pretend to encode what it cannot.
|
|
89
|
+
- **`width`/`height` are probed off the output bytes,** never echoed from the request. A width
|
|
90
|
+
above the intrinsic one clamps to the source and reports the source's size, so the box the
|
|
91
|
+
browser reserves is the box the bytes fill.
|
|
92
|
+
- `blurPlaceholder()` returns a 16px-wide PNG `data:` URI, ready for `ImageInput.blurDataUrl`.
|
|
93
|
+
|
|
94
|
+
### Reading the URL back
|
|
95
|
+
|
|
96
|
+
`images.ts` mints `?w=&f=` query strings; `parseImageQuery()` is the only place that reads one
|
|
97
|
+
back, so a server route never hand-rolls its own parsing of what `responsiveImage()` wrote.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const query = parseImageQuery(new URL(req.url).searchParams);
|
|
101
|
+
// null: none of w/f/q was present — a plain asset read, not a transform.
|
|
102
|
+
if (query !== null) {
|
|
103
|
+
await images.transform({
|
|
104
|
+
src,
|
|
105
|
+
// `?f=webp` alone still needs a width, and the source's own is the only one that resizes
|
|
106
|
+
// nothing the caller did not ask to resize.
|
|
107
|
+
width: query.width ?? intrinsicWidth,
|
|
108
|
+
// Spread, not `format: query.format`: `TransformRequest` declares both keys optional and
|
|
109
|
+
// `exactOptionalPropertyTypes` refuses an explicit `undefined`. Forward all three or `?q=75`
|
|
110
|
+
// parses and is then silently dropped.
|
|
111
|
+
...(query.format === undefined ? {} : { format: query.format }),
|
|
112
|
+
...(query.quality === undefined ? {} : { quality: query.quality }),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- **`null`** means no transform was asked for. A present-but-unusable `w` or `q` — empty, `0`,
|
|
118
|
+
negative, fractional, longer than an exact integer, or `q` over 100 — throws
|
|
119
|
+
`X_IMAGE_QUERY_INVALID` instead: serving the untransformed original against a `?w=320` URL is
|
|
120
|
+
the layout shift this contract exists to prevent.
|
|
121
|
+
- **`f` is not checked against real format names here.** `?f=potato` parses fine; `transform()`
|
|
122
|
+
is what refuses an unencodable format, with `X_IMAGE_UNSUPPORTED`.
|
|
123
|
+
- **`IMAGE_QUERY_KEYS`** (`{ width: 'w', format: 'f', quality: 'q' }`) is the one spelling of the
|
|
124
|
+
three keys — `defaultUrlFor` and `parseImageQuery` both read it, so the two can never drift.
|
|
125
|
+
|
|
126
|
+
## Usage
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
export const config = defineRoute({
|
|
130
|
+
render: 'isr',
|
|
131
|
+
prerender: () => db.posts.slugs(),
|
|
132
|
+
meta: ({ post }) => ({
|
|
133
|
+
title: post.title,
|
|
134
|
+
titleTemplate: '%s — Ultimate',
|
|
135
|
+
description: post.excerpt,
|
|
136
|
+
og: { type: 'article', image: post.cover, publishedTime: post.publishedAt },
|
|
137
|
+
alternates: post.locales.map((l) => ({ hreflang: l, href: `/${l}/blog/${post.slug}` })),
|
|
138
|
+
ld: [ld.Article({ headline: post.title, datePublished: post.publishedAt, author: post.author })],
|
|
139
|
+
}),
|
|
140
|
+
budget: { js: '0kb', lcp: 2000 },
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Commands
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
bun test # meta, validation, JSON-LD, sitemap, robots, feeds, images, budgets
|
|
148
|
+
bun run typecheck
|
|
149
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ultimat3/seo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Enforced SEO: typed meta, JSON-LD, sitemap, robots, feeds, responsive images, perf budgets",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
10
|
+
"directory": "packages/seo"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
30
|
+
"test": "bun test"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@ultimat3/core": "1.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/budgets.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// @ultimat3/seo error codes. SEO in Ultimate is enforced, not documented: these
|
|
2
|
+
// are build errors, so every one names the exact route file and the exact fix.
|
|
3
|
+
|
|
4
|
+
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
5
|
+
// errors.ts <-> images.ts: images.ts throws imageQueryInvalid() and this file spells its fix
|
|
6
|
+
// using images.ts's IMAGE_QUERY_KEYS. Safe like core's errors.ts <-> error-codes.ts cycle:
|
|
7
|
+
// nothing at this module's top level reads the import, only the factory body below does, and by
|
|
8
|
+
// the time that runs both modules have finished loading.
|
|
9
|
+
import { IMAGE_QUERY_KEYS } from './images';
|
|
10
|
+
|
|
11
|
+
export const SEO_ERROR_CODES = {
|
|
12
|
+
metaMissing: 'X_SEO_META_MISSING',
|
|
13
|
+
duplicateMeta: 'X_SEO_DUPLICATE_META',
|
|
14
|
+
metaTooLong: 'X_SEO_META_TOO_LONG',
|
|
15
|
+
canonicalMismatch: 'X_SEO_CANONICAL_MISMATCH',
|
|
16
|
+
ldInvalid: 'X_LD_INVALID',
|
|
17
|
+
budgetExceeded: 'X_SEO_BUDGET_EXCEEDED',
|
|
18
|
+
sitemapTooLarge: 'X_SITEMAP_TOO_LARGE',
|
|
19
|
+
imageQueryInvalid: 'X_IMAGE_QUERY_INVALID',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export type SeoErrorCode = (typeof SEO_ERROR_CODES)[keyof typeof SEO_ERROR_CODES];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 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. `X_SEO_BUDGET_EXCEEDED` is
|
|
27
|
+
* namespaced for exactly that reason: `X_BUDGET_EXCEEDED` is `@ultimat3/render`'s, seo is tier 1
|
|
28
|
+
* and cannot import render, so sharing the code left the meaning up to import order.
|
|
29
|
+
* `X_NOT_IMPLEMENTED` and `X_IMAGE_UNSUPPORTED` are core's; `SeoError` throws them, untitled here.
|
|
30
|
+
*/
|
|
31
|
+
registerErrorCodes({
|
|
32
|
+
X_SEO_META_MISSING: { title: 'a site/ route is missing required metadata' },
|
|
33
|
+
X_SEO_DUPLICATE_META: { title: 'two routes share a title or description' },
|
|
34
|
+
X_SEO_META_TOO_LONG: { title: 'title or description exceeds what search results render' },
|
|
35
|
+
X_SEO_CANONICAL_MISMATCH: { title: 'canonical URL does not match the route path' },
|
|
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
|
+
X_SITEMAP_TOO_LARGE: { title: 'sitemap exceeds the 50,000-entry protocol limit' },
|
|
39
|
+
X_IMAGE_QUERY_INVALID: { title: 'an image transform query parameter is present but unusable' },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export interface SeoErrorInit {
|
|
43
|
+
readonly code: SeoErrorCode | 'X_NOT_IMPLEMENTED';
|
|
44
|
+
readonly cause: string;
|
|
45
|
+
readonly fix: string;
|
|
46
|
+
readonly meta?: Readonly<Record<string, unknown>>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class SeoError extends UltimateError {
|
|
50
|
+
override readonly name: string = 'SeoError';
|
|
51
|
+
|
|
52
|
+
constructor(init: SeoErrorInit) {
|
|
53
|
+
super({
|
|
54
|
+
code: init.code,
|
|
55
|
+
cause: init.cause,
|
|
56
|
+
fix: init.fix,
|
|
57
|
+
docs: `https://ultimate.dev/errors/${init.code}`,
|
|
58
|
+
meta: init.meta,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A `site/` route without a title or description. Names the file, not the URL. */
|
|
64
|
+
export function metaMissing(file: string, path: string, field: string): SeoError {
|
|
65
|
+
return new SeoError({
|
|
66
|
+
code: SEO_ERROR_CODES.metaMissing,
|
|
67
|
+
cause: `${file} (route "${path}") has no meta.${field}`,
|
|
68
|
+
fix: `add ${field} to defineRoute({ meta }) in ${file}`,
|
|
69
|
+
meta: { file, path, field },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function duplicateMeta(field: string, value: string, files: readonly string[]): SeoError {
|
|
74
|
+
return new SeoError({
|
|
75
|
+
code: SEO_ERROR_CODES.duplicateMeta,
|
|
76
|
+
cause: `${files.length} routes share the same ${field} ${JSON.stringify(value)}: ${files.join(', ')}`,
|
|
77
|
+
fix: `give each route a unique ${field} in its defineRoute({ meta })`,
|
|
78
|
+
meta: { field, value, files },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function metaTooLong(file: string, field: string, length: number, max: number): SeoError {
|
|
83
|
+
return new SeoError({
|
|
84
|
+
code: SEO_ERROR_CODES.metaTooLong,
|
|
85
|
+
cause: `${file} has a ${length}-character ${field}; search results truncate past ${max}`,
|
|
86
|
+
fix: `shorten meta.${field} in ${file} to <= ${max} characters`,
|
|
87
|
+
meta: { file, field, length, max },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function canonicalMismatch(file: string, canonical: string, expected: string): SeoError {
|
|
92
|
+
return new SeoError({
|
|
93
|
+
code: SEO_ERROR_CODES.canonicalMismatch,
|
|
94
|
+
cause: `${file} declares canonical ${canonical} but the route resolves to ${expected}`,
|
|
95
|
+
fix: `set meta.canonical to ${expected} in ${file}, or delete it and let the route supply it`,
|
|
96
|
+
meta: { file, canonical, expected },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function ldInvalid(type: string, field: string, hint: string): SeoError {
|
|
101
|
+
return new SeoError({
|
|
102
|
+
code: SEO_ERROR_CODES.ldInvalid,
|
|
103
|
+
cause: `ld.${type}() received an empty or missing "${field}" (${hint})`,
|
|
104
|
+
fix: `provide a non-empty ${field} to ld.${type}(); it is required by schema.org`,
|
|
105
|
+
meta: { type, field },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
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
|
+
export function sitemapTooLarge(count: number, max: number): SeoError {
|
|
126
|
+
return new SeoError({
|
|
127
|
+
code: SEO_ERROR_CODES.sitemapTooLarge,
|
|
128
|
+
cause: `sitemap index would hold ${count} sitemaps; the protocol limit is ${max}`,
|
|
129
|
+
fix: 'exclude non-indexable routes with noindex, or shard the site across hostnames',
|
|
130
|
+
meta: { count, max },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `parseImageQuery`'s only refusal: a `?w=`/`?q=`/`?f=` value present but not usable — serving
|
|
136
|
+
* the untransformed original against a URL that asked for a size would be the layout shift this
|
|
137
|
+
* contract exists to prevent, so an unusable value throws instead of falling back silently. A
|
|
138
|
+
* format string naming no *real* encoder is a different failure (`image-driver.ts`'s
|
|
139
|
+
* `X_IMAGE_UNSUPPORTED`); this code never covers it.
|
|
140
|
+
*
|
|
141
|
+
* The `fix` is written as an inline ternary, not a helper call, so the `errors` gate step can
|
|
142
|
+
* still read each branch as a literal — a `fix` computed behind a function call has nothing for
|
|
143
|
+
* a static scan to check, and the gate would silently wave the whole thing through.
|
|
144
|
+
*/
|
|
145
|
+
export function imageQueryInvalid(param: string, value: string, reason: string): SeoError {
|
|
146
|
+
return new SeoError({
|
|
147
|
+
code: SEO_ERROR_CODES.imageQueryInvalid,
|
|
148
|
+
cause: `?${param}=${value} is not usable: ${reason}`,
|
|
149
|
+
fix:
|
|
150
|
+
param === IMAGE_QUERY_KEYS.quality
|
|
151
|
+
? `request ?${IMAGE_QUERY_KEYS.quality}=75 — a whole number from 1 to 100`
|
|
152
|
+
: param === IMAGE_QUERY_KEYS.format
|
|
153
|
+
? `request ?${IMAGE_QUERY_KEYS.format}=webp — a non-empty format name`
|
|
154
|
+
: `request ?${IMAGE_QUERY_KEYS.width}=640 with a positive integer width`,
|
|
155
|
+
meta: { param, value, reason },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The vocabulary a **user-supplied** `ImageTransformDriver` uses to report a capability it
|
|
161
|
+
* does not implement — a CDN driver with no blur endpoint, say. `builtinImageDriver` needs it
|
|
162
|
+
* for nothing; it implements both entry points. Exported so a partial driver fails with a code
|
|
163
|
+
* and a fix instead of returning an unoptimised original and calling that a transform.
|
|
164
|
+
*
|
|
165
|
+
* `at` is the driver's own module path — pass `import.meta.path`. It is required because
|
|
166
|
+
* `driver` is a display name, and a fix an agent cannot open is not a fix.
|
|
167
|
+
*/
|
|
168
|
+
export function notImplementedDriver(driver: string, capability: string, at: string): SeoError {
|
|
169
|
+
return new SeoError({
|
|
170
|
+
code: 'X_NOT_IMPLEMENTED',
|
|
171
|
+
cause: `the ${driver} image driver does not implement ${capability} yet`,
|
|
172
|
+
fix:
|
|
173
|
+
`implement ${capability} in ${at}, or swap the driver for builtinImageDriver({ read }) ` +
|
|
174
|
+
'— png and jpeg, no dependencies — then run: x verify --json',
|
|
175
|
+
meta: { driver, capability, at },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The bytes half of the image contract: `images.ts` decides what the markup promises, this
|
|
2
|
+
// file produces the variant that promise refers to. Split because they fail differently — a
|
|
3
|
+
// wrong `srcset` is a pure-function bug, a wrong variant is a decode — and because a project
|
|
4
|
+
// swapping in a CDN replaces only this half.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
blurDataUrl,
|
|
8
|
+
IMAGE_FORMATS,
|
|
9
|
+
IMAGE_MIME_TYPES,
|
|
10
|
+
type ImageFormat,
|
|
11
|
+
imageUnsupported,
|
|
12
|
+
probeImage,
|
|
13
|
+
transformImageBytes,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
|
+
|
|
16
|
+
export interface TransformRequest {
|
|
17
|
+
src: string;
|
|
18
|
+
width: number;
|
|
19
|
+
format?: string;
|
|
20
|
+
quality?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TransformedImage {
|
|
24
|
+
bytes: Uint8Array;
|
|
25
|
+
contentType: string;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Swappable so a project can route transforms through a CDN instead. */
|
|
31
|
+
export interface ImageTransformDriver {
|
|
32
|
+
readonly name: string;
|
|
33
|
+
transform(request: TransformRequest): Promise<TransformedImage>;
|
|
34
|
+
/** Tiny base64 data URI (typically a 16px-wide blur) for the placeholder. */
|
|
35
|
+
blurPlaceholder(src: string): Promise<string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BuiltinImageDriverOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Resolves a `src` to its bytes. Required: seo must not guess whether a src is a path, a
|
|
41
|
+
* storage key or a URL — reading the filesystem off a URL-shaped string is exactly the
|
|
42
|
+
* ambient default this package forbids everywhere else.
|
|
43
|
+
*/
|
|
44
|
+
readonly read: (src: string) => Promise<Uint8Array>;
|
|
45
|
+
/** Default JPEG quality for lossy output. */
|
|
46
|
+
readonly quality?: number | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isImageFormat = (value: string): value is ImageFormat =>
|
|
50
|
+
(IMAGE_FORMATS as readonly string[]).includes(value);
|
|
51
|
+
|
|
52
|
+
/** A string naming no format at all fails with the same code an unencodable format does. */
|
|
53
|
+
function requestedFormat(format: string | undefined): ImageFormat | undefined {
|
|
54
|
+
if (format === undefined || isImageFormat(format)) return format;
|
|
55
|
+
throw imageUnsupported(
|
|
56
|
+
`"${format}" names no image format`,
|
|
57
|
+
`request one of ${IMAGE_FORMATS.join(', ')}, or omit format to keep the source's`,
|
|
58
|
+
{ format },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The zero-dependency pipeline in `@ultimat3/core`: PNG and JPEG in, PNG and JPEG out.
|
|
64
|
+
* `<picture>` still offers AVIF and WebP, but nothing here synthesises them — asking for one
|
|
65
|
+
* raises core's `X_IMAGE_UNSUPPORTED`, and those variants belong on a CDN driver instead.
|
|
66
|
+
*/
|
|
67
|
+
export function builtinImageDriver(options: BuiltinImageDriverOptions): ImageTransformDriver {
|
|
68
|
+
return {
|
|
69
|
+
name: 'builtin',
|
|
70
|
+
async transform(request: TransformRequest): Promise<TransformedImage> {
|
|
71
|
+
const bytes = transformImageBytes(await options.read(request.src), {
|
|
72
|
+
width: request.width,
|
|
73
|
+
// Omitted means core picks by the pixels: PNG when the raster has alpha.
|
|
74
|
+
format: requestedFormat(request.format),
|
|
75
|
+
quality: request.quality ?? options.quality,
|
|
76
|
+
});
|
|
77
|
+
// Read back off the output, never assumed: a width clamped to the source has to report
|
|
78
|
+
// the source's size, or the box the browser reserved is the wrong one.
|
|
79
|
+
const info = probeImage(bytes);
|
|
80
|
+
return {
|
|
81
|
+
bytes,
|
|
82
|
+
contentType: IMAGE_MIME_TYPES[info.format],
|
|
83
|
+
width: info.width,
|
|
84
|
+
height: info.height,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
async blurPlaceholder(src: string): Promise<string> {
|
|
88
|
+
return blurDataUrl(await options.read(src));
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|