@ultimat3/seo 6.0.0 → 8.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 CHANGED
@@ -39,6 +39,13 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
39
39
  because `DEFAULT_WIDTHS` happens to ascend — `widths: [1200, 640]` handed every browser without
40
40
  `srcset` support the 640 variant of a 1200-wide image. Never re-derive it from position, and
41
41
  never sort inside `usableWidths`: the `srcset` order is the caller's to choose.
42
+ - **`x-default` names a URL the sitemap CONTAINS, or it is not emitted.** `buildSitemap` pushed
43
+ the unprefixed `path` for every route with `locales` set — and with no `defaultLocale`,
44
+ `localize` prefixes every locale, so the whole hreflang cluster pointed at a URL the sitemap
45
+ never lists. A dangling `x-default` is the shape a search engine drops the entire cluster for,
46
+ which costs the alternates that WERE right. `defaultLocaleUrl` answers `undefined` unless the
47
+ default locale's own URL is among the ones this route emits — the same explicit-fallback shape
48
+ `meta.ts`'s `hreflangSet` takes. Never re-derive it from `path`.
42
49
  - **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.
43
50
  - **Fail closed, and core reads the key.** `isIndexable()` is `environment === 'production'` and
44
51
  nothing else — `staging`, a laptop, a typo and an unset variable all disallow. `ULTIMATE_ENV` has
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/seo",
3
- "version": "6.0.0",
3
+ "version": "8.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",
@@ -31,6 +31,6 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "6.0.0"
34
+ "@ultimat3/core": "8.0.0"
35
35
  }
36
36
  }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // The public surface of @ultimat3/seo. Explicit named exports only.
2
2
 
3
+ export type { RenderMode } from '@ultimat3/core';
3
4
  export type { SeoErrorCode, SeoErrorInit } from './errors';
4
5
  export {
5
6
  canonicalMismatch,
@@ -90,7 +91,7 @@ export {
90
91
  } from './meta';
91
92
  export type { RobotsConfig, RobotsGroup } from './robots';
92
93
  export { buildRobots, isIndexable } from './robots';
93
- export type { ChangeFreq, RenderMode, RouteRecord, Surface } from './routes';
94
+ export type { ChangeFreq, RouteRecord, Surface } from './routes';
94
95
  export { expandRoute, indexableRoutes, isDynamic } from './routes';
95
96
  export type { BuildFeedOptions, Feed, FeedAuthor, FeedChannel, FeedItem } from './rss';
96
97
  export { buildFeed } from './rss';
package/src/routes.ts CHANGED
@@ -2,10 +2,9 @@
2
2
  // `x.manifest.json`; every checker here reports against `file`, so an agent can
3
3
  // open the exact source rather than guess which route a URL came from.
4
4
 
5
+ import type { RenderMode } from '@ultimat3/core';
5
6
  import type { RouteMeta } from './meta';
6
7
 
7
- export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream';
8
-
9
8
  /** `site/` is the only surface SEO applies to; `app/` is behind auth. */
10
9
  export type Surface = 'site' | 'app' | 'api';
11
10
 
package/src/sitemap.ts CHANGED
@@ -44,7 +44,10 @@ export interface BuildSitemapOptions {
44
44
  locales?: readonly string[];
45
45
  /** Defaults to `/{locale}{path}`. */
46
46
  localizePath?: (path: string, locale: string) => string;
47
- /** The locale whose URLs are unprefixed and become `x-default`. */
47
+ /**
48
+ * The locale whose URLs are unprefixed and become `x-default`. **Omitted, no `x-default` is
49
+ * emitted at all** — there is no unprefixed URL in the sitemap for it to name.
50
+ */
48
51
  defaultLocale?: string;
49
52
  maxUrls?: number;
50
53
  lastmod?: string;
@@ -56,6 +59,24 @@ function localize(path: string, locale: string, options: BuildSitemapOptions): s
56
59
  return fn(path, locale);
57
60
  }
58
61
 
62
+ /**
63
+ * The path `x-default` may point at: the default locale's own, and only when this route emits it.
64
+ * `undefined` when no `defaultLocale` was declared, or when it names a locale outside `locales` —
65
+ * in both cases the URL it would carry is one the sitemap does not contain.
66
+ *
67
+ * Matched on the LOCALE, never on the path. Recomputing the default's URL and asking whether any
68
+ * entry carries it made a `localizePath` that maps two locales onto one URL answer yes for a
69
+ * `defaultLocale` outside `locales`: the href existed, but it belonged to another locale, so
70
+ * `x-default` spoke for a locale this cluster does not carry. The entry that emits it is the only
71
+ * thing that can prove it exists, so read the answer off that entry.
72
+ */
73
+ function defaultLocaleUrl(
74
+ localised: readonly { readonly locale: string; readonly path: string }[],
75
+ options: BuildSitemapOptions,
76
+ ): string | undefined {
77
+ return localised.find((entry) => entry.locale === options.defaultLocale)?.path;
78
+ }
79
+
59
80
  /** Every concrete URL the route table produces, with per-locale alternates. */
60
81
  export async function sitemapUrls(
61
82
  routes: readonly RouteRecord[],
@@ -66,23 +87,33 @@ export async function sitemapUrls(
66
87
 
67
88
  for (const route of indexableRoutes(routes)) {
68
89
  for (const path of await expandRoute(route)) {
69
- const alternates: SitemapAlternate[] = locales.map((locale) => ({
70
- hreflang: locale,
71
- href: absoluteUrl(options.baseUrl, localize(path, locale, options)),
90
+ // Localised once, then read three times — the alternates, the `x-default` candidate and the
91
+ // `<loc>`s below are three questions with one answer, and they drifted apart when each
92
+ // computed its own.
93
+ const localised = locales.map((locale) => ({
94
+ locale,
95
+ path: localize(path, locale, options),
72
96
  }));
73
- if (alternates.length > 0) {
74
- alternates.push({
75
- hreflang: 'x-default',
76
- href: absoluteUrl(options.baseUrl, path),
77
- });
97
+ const alternates: SitemapAlternate[] = localised.map((entry) => ({
98
+ hreflang: entry.locale,
99
+ href: absoluteUrl(options.baseUrl, entry.path),
100
+ }));
101
+ // `x-default` only when it names a URL THIS sitemap lists. It was the bare `path` for every
102
+ // route: with `locales` set and no `defaultLocale`, every `<loc>` is prefixed and the
103
+ // unprefixed path is one the sitemap never mentions — an hreflang cluster pointing at a URL
104
+ // outside itself, which is the shape a search engine drops the whole cluster for. Same rule
105
+ // as `meta.ts`'s `hreflangSet`, which takes the fallback href explicitly rather than
106
+ // assuming one exists.
107
+ const fallback = defaultLocaleUrl(localised, options);
108
+ if (alternates.length > 0 && fallback !== undefined) {
109
+ alternates.push({ hreflang: 'x-default', href: absoluteUrl(options.baseUrl, fallback) });
78
110
  }
79
111
 
80
112
  const lastmod = route.lastmod ?? options.lastmod;
81
- const emitFor =
82
- locales.length === 0 ? [path] : locales.map((l) => localize(path, l, options));
83
- for (const localised of emitFor) {
113
+ const emitFor = locales.length === 0 ? [path] : localised.map((entry) => entry.path);
114
+ for (const emitted of emitFor) {
84
115
  urls.push({
85
- loc: absoluteUrl(options.baseUrl, localised),
116
+ loc: absoluteUrl(options.baseUrl, emitted),
86
117
  ...(lastmod === undefined ? {} : { lastmod }),
87
118
  ...(route.changefreq === undefined ? {} : { changefreq: route.changefreq }),
88
119
  ...(route.priority === undefined ? {} : { priority: route.priority }),