@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 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
- `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.
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": "21.0.0",
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": "21.0.0",
35
- "@ultimat3/schema": "21.0.0"
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
- /** Milliseconds for a timestamp, or `undefined` when the string is not a date at all. */
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
- function required(type: string, field: string, value: string, hint: string): string {
13
- if (value.trim() === '') throw ldInvalid(type, field, hint);
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
- return template.replace(TITLE_SLOT, title);
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 {