@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 +3 -3
- package/src/index.ts +3 -0
- package/src/locale-tags.ts +37 -0
- package/src/meta.ts +47 -2
- package/src/robots.ts +13 -1
- package/src/sitemap.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/seo",
|
|
3
|
-
"version": "22.
|
|
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.
|
|
35
|
-
"@ultimat3/schema": "22.
|
|
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',
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|