@ultimat3/seo 22.2.2 → 22.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/seo",
3
- "version": "22.2.2",
3
+ "version": "22.3.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": "22.2.2",
35
- "@ultimat3/schema": "22.2.2"
34
+ "@ultimat3/core": "22.3.0",
35
+ "@ultimat3/schema": "22.3.0"
36
36
  }
37
37
  }
package/src/index.ts CHANGED
@@ -70,6 +70,8 @@ export {
70
70
  SoftwareApplication,
71
71
  WebSite,
72
72
  } from './ld';
73
+ export type { LocalizedPage, MetaLocalization } from './locale-tags';
74
+ export { hreflangTag, ogLocaleTag } from './locale-tags';
73
75
  export type {
74
76
  AlternateLocale,
75
77
  HeadTag,
@@ -85,6 +87,7 @@ export {
85
87
  applyTitleTemplate,
86
88
  DESCRIPTION_MAX_LENGTH,
87
89
  hreflangSet,
90
+ localizedAlternates,
88
91
  renderMeta,
89
92
  robotsContent,
90
93
  TITLE_MAX_LENGTH,
@@ -0,0 +1,37 @@
1
+ // A locale as the two vocabularies a crawler reads spell it. An app registers `es-co` (lowercase is
2
+ // the catalog and URL form); `hreflang` wants BCP 47 with its region uppercased, `es-CO`, and Open
3
+ // Graph wants the underscore form, `es_CO`. One conversion, so the sitemap and the head agree.
4
+
5
+ /**
6
+ * `es-co` → `es-CO`, `zh-hant-tw` → `zh-Hant-TW`, `en` → `en`. `Intl`'s own canonical form, which
7
+ * is BCP 47's; a tag `Intl` refuses is returned as written rather than thrown — this runs per
8
+ * render, and the config screen (`configureLocales`) already refused anything unparseable.
9
+ */
10
+ export function hreflangTag(locale: string): string {
11
+ try {
12
+ return Intl.getCanonicalLocales(locale)[0] ?? locale;
13
+ } catch {
14
+ return locale;
15
+ }
16
+ }
17
+
18
+ /** `es-co` → `es_CO`: Open Graph's `language_TERRITORY`. */
19
+ export function ogLocaleTag(locale: string): string {
20
+ return hreflangTag(locale).replace(/-/g, '_');
21
+ }
22
+
23
+ /** A page in one locale: the locale and the page's own path in it. */
24
+ export interface LocalizedPage {
25
+ readonly locale: string;
26
+ readonly path: string;
27
+ }
28
+
29
+ /** What `renderMeta` needs to emit a page's hreflang cluster and its `og:locale` pair. */
30
+ export interface MetaLocalization {
31
+ /** The locale this document renders in. */
32
+ readonly locale: string;
33
+ /** The locale whose page `x-default` names. */
34
+ readonly defaultLocale: string;
35
+ /** This page in every locale it exists in, the current one included. */
36
+ readonly alternates: readonly LocalizedPage[];
37
+ }
package/src/meta.ts CHANGED
@@ -2,6 +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 { hreflangTag, type MetaLocalization, ogLocaleTag } from './locale-tags';
5
6
  import { absoluteUrl } from './xml';
6
7
 
7
8
  /**
@@ -100,6 +101,33 @@ export interface RenderMetaOptions {
100
101
  baseUrl?: string;
101
102
  /** Resolved path, used when `canonical` is omitted. */
102
103
  path?: string;
104
+ /**
105
+ * The document's locale and the page in every routed locale. Given, and the route declares no
106
+ * `alternates` of its own, the full hreflang cluster is emitted — every locale plus `x-default` —
107
+ * with `og:locale` and one `og:locale:alternate` per other locale. A cluster of one is none.
108
+ */
109
+ localization?: MetaLocalization;
110
+ }
111
+
112
+ /**
113
+ * The hreflang set a route did not have to write: one entry per locale, BCP 47 region form, and
114
+ * `x-default` naming the default locale's page. Empty for a single-locale app — an hreflang cluster
115
+ * of one tells a crawler nothing and costs every document a tag.
116
+ */
117
+ export function localizedAlternates(
118
+ localization: MetaLocalization | undefined,
119
+ ): readonly AlternateLocale[] {
120
+ if (localization === undefined || localization.alternates.length < 2) return [];
121
+ const cluster = localization.alternates.map((page) => ({
122
+ hreflang: hreflangTag(page.locale),
123
+ href: page.path,
124
+ }));
125
+ const fallback = localization.alternates.find(
126
+ (page) => page.locale === localization.defaultLocale,
127
+ );
128
+ return fallback === undefined
129
+ ? cluster
130
+ : [...cluster, { hreflang: 'x-default', href: fallback.path }];
103
131
  }
104
132
 
105
133
  /**
@@ -190,17 +218,31 @@ export function renderMeta(meta: RouteMeta, options: RenderMetaOptions = {}): re
190
218
 
191
219
  // --- Open Graph ------------------------------------------------------------
192
220
  const og = meta.og ?? {};
221
+ const localization = options.localization;
222
+ const ogLocale =
223
+ og.locale ?? (localization === undefined ? undefined : ogLocaleTag(localization.locale));
193
224
  const ogEntries: Array<[string, string | undefined]> = [
194
225
  ['og:type', og.type ?? 'website'],
195
226
  ['og:title', og.title ?? meta.title],
196
227
  ['og:description', og.description ?? meta.description],
197
228
  ['og:url', og.url === undefined ? canonicalHref : abs(og.url)],
198
229
  ['og:site_name', og.siteName],
199
- ['og:locale', og.locale],
230
+ ['og:locale', ogLocale],
200
231
  ];
201
232
  for (const [property, content] of ogEntries) {
202
233
  if (content !== undefined) tags.push({ tag: 'meta', attrs: { property, content } });
203
234
  }
235
+ // Every OTHER locale the page exists in — a repeatable property, which `@ultimat3/render`'s head
236
+ // dedupe keys by content as well (`head-seo.ts`), so N alternates do not collapse to the last.
237
+ if (localization !== undefined && localization.alternates.length > 1) {
238
+ for (const page of localization.alternates) {
239
+ if (page.locale === localization.locale) continue;
240
+ tags.push({
241
+ tag: 'meta',
242
+ attrs: { property: 'og:locale:alternate', content: ogLocaleTag(page.locale) },
243
+ });
244
+ }
245
+ }
204
246
  if (og.image !== undefined) {
205
247
  const image = typeof og.image === 'string' ? { url: og.image } : og.image;
206
248
  tags.push({ tag: 'meta', attrs: { property: 'og:image', content: abs(image.url) } });
@@ -251,7 +293,10 @@ export function renderMeta(meta: RouteMeta, options: RenderMetaOptions = {}): re
251
293
  }
252
294
 
253
295
  // --- hreflang --------------------------------------------------------------
254
- for (const alternate of hreflangSet(meta.alternates ?? [], meta.xDefault ?? canonicalHref)) {
296
+ // A route's own `alternates` win whole; otherwise the cluster comes from the routed locales.
297
+ const declared = meta.alternates ?? [];
298
+ const alternates = declared.length > 0 ? declared : localizedAlternates(localization);
299
+ for (const alternate of hreflangSet(alternates, meta.xDefault ?? canonicalHref)) {
255
300
  tags.push({
256
301
  tag: 'link',
257
302
  attrs: { rel: 'alternate', hreflang: alternate.hreflang, href: abs(alternate.href) },
package/src/robots.ts CHANGED
@@ -18,6 +18,12 @@ export interface RobotsConfig {
18
18
  /** Omitted means "resolve from the environment"; anything but `production` disallows all. */
19
19
  environment?: Environment | undefined;
20
20
  groups?: readonly RobotsGroup[];
21
+ /**
22
+ * Paths every crawler is kept out of — `app.config.ts`'s `seo.robots.disallow`. Added to the
23
+ * default `User-agent: *` group, or to each declared group that names `*`. Production only: a
24
+ * non-production `robots.txt` already disallows everything.
25
+ */
26
+ disallow?: readonly string[];
21
27
  /** Sitemap paths or absolute URLs. Only emitted in production. */
22
28
  sitemaps?: readonly string[];
23
29
  /** Extra lines appended verbatim, e.g. a `Host:` directive. */
@@ -57,10 +63,16 @@ export function buildRobots(config: RobotsConfig): string {
57
63
  return `${lines.join('\n')}\n`;
58
64
  }
59
65
 
60
- const groups: readonly RobotsGroup[] =
66
+ const declared: readonly RobotsGroup[] =
61
67
  config.groups === undefined || config.groups.length === 0
62
68
  ? [{ userAgent: '*', allow: ['/'] }]
63
69
  : config.groups;
70
+ const extra = config.disallow ?? [];
71
+ const groups = declared.map((group) =>
72
+ extra.length === 0 || !agents(group.userAgent).includes('*')
73
+ ? group
74
+ : { ...group, disallow: [...(group.disallow ?? []), ...extra] },
75
+ );
64
76
 
65
77
  for (const group of groups) {
66
78
  for (const agent of agents(group.userAgent)) lines.push(`User-agent: ${agent}`);
package/src/sitemap.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { assert } from '@ultimat3/core';
6
6
  import { sitemapTooLarge } from './errors';
7
+ import { hreflangTag } from './locale-tags';
7
8
  import { type ChangeFreq, expandRoute, indexableRoutes, type RouteRecord } from './routes';
8
9
  import { absoluteUrl, attributes, escapeXml } from './xml';
9
10
 
@@ -95,7 +96,9 @@ export async function sitemapUrls(
95
96
  path: localize(path, locale, options),
96
97
  }));
97
98
  const alternates: SitemapAlternate[] = localised.map((entry) => ({
98
- hreflang: entry.locale,
99
+ // BCP 47 region form (`es-CO`), the spelling `renderMeta` puts in the head: one cluster,
100
+ // one vocabulary, whether a crawler reads the page or the sitemap.
101
+ hreflang: hreflangTag(entry.locale),
99
102
  href: absoluteUrl(options.baseUrl, entry.path),
100
103
  }));
101
104
  // `x-default` only when it names a URL THIS sitemap lists. It was the bare `path` for every