@ultimat3/seo 21.0.0 → 22.1.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/README.md +13 -4
- package/package.json +3 -3
- package/src/feed-dates.ts +15 -1
- package/src/ld.ts +7 -2
- package/src/meta.ts +3 -1
package/README.md
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
SEO is **enforced, not documented**: every rule below is an `assert*` that throws a coded
|
|
4
4
|
`SeoError` with the route file and the exact edit, never a lint warning.
|
|
5
5
|
|
|
6
|
-
`
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
`x verify`'s **`seo`** step (`packages/cli/src/verify-checks.ts`) runs `validateMeta` over every
|
|
7
|
+
`site/` route, so missing, duplicate and over-long meta fail the gate `As of 2026-09-23`. Canonical
|
|
8
|
+
checks are skipped there — an app declares no base URL — and JSON-LD, sitemaps and robots are
|
|
9
|
+
enforced where they are built: each builder throws.
|
|
10
10
|
|
|
11
11
|
## What fails the build
|
|
12
12
|
|
|
@@ -31,6 +31,15 @@ X_SEO_META_MISSING: a site/ route is missing required metadata
|
|
|
31
31
|
fix: add description to defineRoute({ meta }) in apps/web/site/about/page.tsx
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
### Error classes
|
|
35
|
+
|
|
36
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
37
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
38
|
+
|
|
39
|
+
| Class | Code | Declared in |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `SeoError` | any `SeoErrorCode` — `SEO_ERROR_CODES` | `src/errors.ts` |
|
|
42
|
+
|
|
34
43
|
## Modules
|
|
35
44
|
|
|
36
45
|
| File | Owns |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/seo",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.1.0",
|
|
4
4
|
"description": "Enforced SEO: typed meta, JSON-LD, sitemap, robots, feeds, responsive images, perf budgets",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "22.1.0",
|
|
35
|
+
"@ultimat3/schema": "22.1.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/feed-dates.ts
CHANGED
|
@@ -4,10 +4,24 @@
|
|
|
4
4
|
// `toISOString()` as a `RangeError` that takes the whole route down over one bad row.
|
|
5
5
|
|
|
6
6
|
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import { logger } from '@ultimat3/core';
|
|
8
|
+
import { isIsoDateTime } from '@ultimat3/schema';
|
|
7
9
|
|
|
8
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Milliseconds for a timestamp, or `undefined` when the string names no instant. An ISO date, or
|
|
12
|
+
* an ISO date-time carrying `Z` or an offset, is the whole accepted set — `@ultimat3/schema`'s
|
|
13
|
+
* `isIsoDateTime`, the rule `t.date` and `fromIso` answer with. `Date.parse` alone read
|
|
14
|
+
* `2026-03-14T09:00:00` through the PROCESS's zone, so a feed's `pubDate` moved with `TZ`; that
|
|
15
|
+
* form is treated as absent and logged, the screen `@ultimat3/flags` gives an expiry.
|
|
16
|
+
*/
|
|
9
17
|
export function epochOf(iso: string | undefined): number | undefined {
|
|
10
18
|
if (iso === undefined) return undefined;
|
|
19
|
+
if (!isIsoDateTime(iso)) {
|
|
20
|
+
// Offsetless is the one refusal worth a line: it is a real date the author meant, and it
|
|
21
|
+
// used to render — adding a `Z` is exactly what makes it valid.
|
|
22
|
+
if (isIsoDateTime(`${iso}Z`)) logger.warn('seo.feed.date_offsetless', { value: iso });
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
11
25
|
const ms = Date.parse(iso);
|
|
12
26
|
return Number.isNaN(ms) ? undefined : ms;
|
|
13
27
|
}
|
package/src/ld.ts
CHANGED
|
@@ -9,8 +9,13 @@ export type JsonLd = Readonly<Record<string, unknown>>;
|
|
|
9
9
|
|
|
10
10
|
export const LD_CONTEXT = 'https://schema.org';
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* `typeof` first: the value is read from a CMS, whose missing field is `null`, and `.trim()` on it
|
|
14
|
+
* was a bare `TypeError` in place of the coded refusal. The input TYPE says `string`; a CMS row
|
|
15
|
+
* does not read the type.
|
|
16
|
+
*/
|
|
17
|
+
function required(type: string, field: string, value: unknown, hint: string): string {
|
|
18
|
+
if (typeof value !== 'string' || value.trim() === '') throw ldInvalid(type, field, hint);
|
|
14
19
|
return value;
|
|
15
20
|
}
|
|
16
21
|
|
package/src/meta.ts
CHANGED
|
@@ -125,7 +125,9 @@ export function applyTitleTemplate(title: string, template?: string): string {
|
|
|
125
125
|
if (!template.includes(TITLE_SLOT)) return title;
|
|
126
126
|
const brand = template.replace(TITLE_SLOT, '').replace(TEMPLATE_SEPARATORS, '');
|
|
127
127
|
if (brand !== '' && title.includes(brand)) return title;
|
|
128
|
-
|
|
128
|
+
// A FUNCTION as the replacement, never the string: `replace` expands `$$`, `$&` and `$'` in a
|
|
129
|
+
// replacement literal, and a title is app data — "Save $$ on shoes" rendered one dollar.
|
|
130
|
+
return template.replace(TITLE_SLOT, () => title);
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
export function robotsContent(directives: RobotsDirectives): string {
|