@astronox/core 0.1.0 → 0.2.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 -1
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +2 -1
- package/src/components/navigation/SubNavigation/SubNavigation.astro +2 -1
- package/src/display/index.ts +1 -0
- package/src/display/responsiveMedia.ts +38 -0
- package/src/i18n/LocalePreferenceBootstrap.astro +40 -0
- package/src/i18n/catalog.ts +203 -0
- package/src/i18n/config.ts +112 -0
- package/src/i18n/coreMessages.ts +37 -0
- package/src/i18n/formatters.ts +34 -0
- package/src/i18n/index.ts +6 -0
- package/src/i18n/preference.ts +58 -0
- package/src/index.ts +2 -0
- package/src/routing/index.ts +1 -0
- package/src/routing/localized.ts +155 -0
- package/src/seo/index.ts +1 -0
- package/src/seo/localizedMeta.ts +58 -0
package/README.md
CHANGED
|
@@ -44,6 +44,13 @@ Use the remaining entry points for layouts, patterns, primitives, display
|
|
|
44
44
|
contracts, configuration, theme, client behavior, data validation, and SEO.
|
|
45
45
|
Deep imports into the physical `src` tree are not part of the public API.
|
|
46
46
|
|
|
47
|
+
Locale catalogs, localized routing, preference persistence, localized metadata,
|
|
48
|
+
and responsive media presentation are available through the `i18n`, `routing`,
|
|
49
|
+
`seo`, and `display` entry points. Consuming sites provide their own locale
|
|
50
|
+
definitions, translations, route names, product metadata, and assets. Generic
|
|
51
|
+
component labels can extend `defaultCoreMessages` through
|
|
52
|
+
`resolveCoreMessages()` while retaining the English fallback.
|
|
53
|
+
|
|
47
54
|
## Local Development
|
|
48
55
|
|
|
49
56
|
`astronox-core` is the only source of truth. A consuming project may link this
|
|
@@ -52,10 +59,12 @@ must always reference a released package version.
|
|
|
52
59
|
|
|
53
60
|
The expected workflow is:
|
|
54
61
|
|
|
55
|
-
1.
|
|
56
|
-
2.
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
1. Keep an exact published version in the consumer manifest and lockfile.
|
|
63
|
+
2. Link the sibling package locally without touching either file with
|
|
64
|
+
`npm install --no-save --package-lock=false ../astronox-core`.
|
|
65
|
+
3. Implement and validate the generic change through the linked consumer.
|
|
66
|
+
4. Publish a prerelease or stable package version.
|
|
67
|
+
5. Update the exact consumer dependency before deployment.
|
|
59
68
|
|
|
60
69
|
Do not edit installed package files or copy `src` folders between projects.
|
|
61
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astronox/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -35,9 +35,11 @@
|
|
|
35
35
|
"./config": "./src/config/index.ts",
|
|
36
36
|
"./data": "./src/data/index.ts",
|
|
37
37
|
"./display": "./src/display/index.ts",
|
|
38
|
+
"./i18n": "./src/i18n/index.ts",
|
|
38
39
|
"./layouts": "./src/layouts/index.ts",
|
|
39
40
|
"./patterns": "./src/patterns/index.ts",
|
|
40
41
|
"./primitives": "./src/primitives/index.ts",
|
|
42
|
+
"./routing": "./src/routing/index.ts",
|
|
41
43
|
"./seo": "./src/seo/index.ts",
|
|
42
44
|
"./theme": "./src/theme/index.ts",
|
|
43
45
|
"./styles.css": "./src/styles/style.css",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { HTMLAttributes } from 'astro/types';
|
|
3
3
|
import type { BreadcrumbItem } from '../../../display/contracts';
|
|
4
4
|
import type { Tone } from '../../../theme/contracts';
|
|
5
|
+
import { defaultCoreMessages } from '../../../i18n/coreMessages';
|
|
5
6
|
|
|
6
7
|
type Props = HTMLAttributes<'nav'> & {
|
|
7
8
|
items: readonly BreadcrumbItem[];
|
|
@@ -13,7 +14,7 @@ const {
|
|
|
13
14
|
class: className,
|
|
14
15
|
'data-tone': dataTone,
|
|
15
16
|
items,
|
|
16
|
-
label =
|
|
17
|
+
label = defaultCoreMessages.navigation.breadcrumbs,
|
|
17
18
|
tone,
|
|
18
19
|
...attrs
|
|
19
20
|
} = Astro.props;
|
|
@@ -3,6 +3,7 @@ import type { HTMLAttributes } from 'astro/types';
|
|
|
3
3
|
import type { Tone } from '../../../theme/contracts';
|
|
4
4
|
import type { SubNavigationItem } from '../navigationTypes';
|
|
5
5
|
import Media from '../../../primitives/media/Media/Media.astro';
|
|
6
|
+
import { defaultCoreMessages } from '../../../i18n/coreMessages';
|
|
6
7
|
|
|
7
8
|
type Props = HTMLAttributes<'nav'> & {
|
|
8
9
|
items: readonly SubNavigationItem[];
|
|
@@ -14,7 +15,7 @@ const {
|
|
|
14
15
|
class: className,
|
|
15
16
|
'data-tone': dataTone,
|
|
16
17
|
items,
|
|
17
|
-
label =
|
|
18
|
+
label = defaultCoreMessages.navigation.section,
|
|
18
19
|
tone,
|
|
19
20
|
...attrs
|
|
20
21
|
} = Astro.props;
|
package/src/display/index.ts
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DisplayMedia } from './contracts';
|
|
2
|
+
|
|
3
|
+
export type MediaRendition = Pick<
|
|
4
|
+
DisplayMedia,
|
|
5
|
+
'contentBounds' | 'height' | 'src' | 'width'
|
|
6
|
+
>;
|
|
7
|
+
|
|
8
|
+
export type ResponsiveMediaInput = {
|
|
9
|
+
alt: string;
|
|
10
|
+
loading?: DisplayMedia['loading'];
|
|
11
|
+
renditions: readonly MediaRendition[];
|
|
12
|
+
sizes: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function presentResponsiveMedia({
|
|
16
|
+
alt,
|
|
17
|
+
loading = 'lazy',
|
|
18
|
+
renditions,
|
|
19
|
+
sizes,
|
|
20
|
+
}: ResponsiveMediaInput): DisplayMedia {
|
|
21
|
+
const sortedRenditions = renditions.toSorted(
|
|
22
|
+
(left, right) => left.width - right.width,
|
|
23
|
+
);
|
|
24
|
+
const smallest = sortedRenditions[0];
|
|
25
|
+
if (!smallest) throw new Error(`Missing media rendition: ${alt}`);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
...smallest,
|
|
29
|
+
alt,
|
|
30
|
+
fit: 'contain',
|
|
31
|
+
loading,
|
|
32
|
+
sizes,
|
|
33
|
+
sources: sortedRenditions.map((rendition) => ({
|
|
34
|
+
src: rendition.src,
|
|
35
|
+
width: rendition.width,
|
|
36
|
+
})),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { LocaleDefinition } from './config';
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
defaultLocale: string;
|
|
6
|
+
locales: readonly LocaleDefinition[];
|
|
7
|
+
storageKey: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const { defaultLocale, locales, storageKey } = Astro.props;
|
|
11
|
+
const localeEntries = locales
|
|
12
|
+
.filter(({ id }) => id !== defaultLocale)
|
|
13
|
+
.map(({ id, urlCode }) => [id, urlCode]);
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<script
|
|
17
|
+
is:inline
|
|
18
|
+
define:vars={{
|
|
19
|
+
localeEntries,
|
|
20
|
+
storageKey,
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
(() => {
|
|
24
|
+
if (window.location.pathname !== '/') return;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const preferredLocale = localStorage.getItem(storageKey);
|
|
28
|
+
const preferredUrlCode = localeEntries.find(
|
|
29
|
+
([locale]) => locale === preferredLocale,
|
|
30
|
+
)?.[1];
|
|
31
|
+
if (!preferredUrlCode) return;
|
|
32
|
+
|
|
33
|
+
window.location.replace(
|
|
34
|
+
`/${preferredUrlCode}${window.location.search}${window.location.hash}`,
|
|
35
|
+
);
|
|
36
|
+
} catch {
|
|
37
|
+
// The canonical default-locale page remains usable without storage.
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
</script>
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
export type MessageTree = {
|
|
2
|
+
readonly [key: string]: string | MessageTree;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export type MessageShape<T> = {
|
|
6
|
+
readonly [Key in keyof T]: T[Key] extends string
|
|
7
|
+
? string
|
|
8
|
+
: T[Key] extends MessageTree
|
|
9
|
+
? MessageShape<T[Key]>
|
|
10
|
+
: never;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type VersionedMessageShape<
|
|
14
|
+
T extends { readonly schemaVersion: number },
|
|
15
|
+
> = {
|
|
16
|
+
readonly schemaVersion: T['schemaVersion'];
|
|
17
|
+
} & MessageShape<Omit<T, 'schemaVersion'>>;
|
|
18
|
+
|
|
19
|
+
export function defineLocalizedCatalog<
|
|
20
|
+
const TLocale extends string,
|
|
21
|
+
const TFallbackLocale extends TLocale,
|
|
22
|
+
const TFallbackCatalog extends MessageTree,
|
|
23
|
+
>({
|
|
24
|
+
catalogs,
|
|
25
|
+
fallbackLocale,
|
|
26
|
+
locales,
|
|
27
|
+
}: {
|
|
28
|
+
catalogs: Readonly<Record<TLocale, unknown>> &
|
|
29
|
+
Readonly<Record<TFallbackLocale, TFallbackCatalog>>;
|
|
30
|
+
fallbackLocale: TFallbackLocale;
|
|
31
|
+
locales: readonly TLocale[];
|
|
32
|
+
}): Readonly<Record<TLocale, MessageShape<TFallbackCatalog>>> {
|
|
33
|
+
const fallback = catalogs[fallbackLocale];
|
|
34
|
+
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
locales.map((locale) => [
|
|
37
|
+
locale,
|
|
38
|
+
resolveLocalizedCatalog(fallback, catalogs[locale], locale),
|
|
39
|
+
]),
|
|
40
|
+
) as Readonly<Record<TLocale, MessageShape<TFallbackCatalog>>>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveLocalizedCatalog<const TFallback extends MessageTree>(
|
|
44
|
+
fallback: TFallback,
|
|
45
|
+
translated: unknown,
|
|
46
|
+
locale: string,
|
|
47
|
+
): MessageShape<TFallback> {
|
|
48
|
+
return resolveMessageNode(
|
|
49
|
+
fallback,
|
|
50
|
+
translated,
|
|
51
|
+
locale,
|
|
52
|
+
'',
|
|
53
|
+
) as MessageShape<TFallback>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveVersionedLocaleCatalog<
|
|
57
|
+
const TFallback extends { readonly schemaVersion: number },
|
|
58
|
+
>(
|
|
59
|
+
fallback: TFallback,
|
|
60
|
+
translated: unknown,
|
|
61
|
+
locale: string,
|
|
62
|
+
): VersionedMessageShape<TFallback> {
|
|
63
|
+
if (!isMessageRecord(translated)) {
|
|
64
|
+
throw new Error(`Invalid ${locale} locale catalog: expected object`);
|
|
65
|
+
}
|
|
66
|
+
if (translated.schemaVersion !== fallback.schemaVersion) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Invalid ${locale} locale schema version: ` +
|
|
69
|
+
`expected ${fallback.schemaVersion}, received ${String(translated.schemaVersion)}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
schemaVersion: fallback.schemaVersion,
|
|
75
|
+
...resolveLocalizedCatalog(
|
|
76
|
+
stripSchemaVersion(fallback) as MessageTree,
|
|
77
|
+
stripSchemaVersion(translated),
|
|
78
|
+
locale,
|
|
79
|
+
),
|
|
80
|
+
} as VersionedMessageShape<TFallback>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatMessage(
|
|
84
|
+
template: string,
|
|
85
|
+
values: Readonly<Record<string, string | number>>,
|
|
86
|
+
): string {
|
|
87
|
+
const rendered = template.replace(
|
|
88
|
+
/\{([a-zA-Z][a-zA-Z0-9]*)\}/g,
|
|
89
|
+
(_, key: string) => {
|
|
90
|
+
const value = values[key];
|
|
91
|
+
if (value === undefined) {
|
|
92
|
+
throw new Error(`Missing localized message value: ${key}`);
|
|
93
|
+
}
|
|
94
|
+
return String(value);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (/\{[^{}]+\}/.test(rendered)) {
|
|
99
|
+
throw new Error(`Unresolved localized message: ${rendered}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return rendered;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveMessageNode(
|
|
106
|
+
fallback: string | MessageTree,
|
|
107
|
+
translated: unknown,
|
|
108
|
+
locale: string,
|
|
109
|
+
path: string,
|
|
110
|
+
): string | MessageTree {
|
|
111
|
+
if (typeof fallback === 'string') {
|
|
112
|
+
assertMessage(fallback, 'fallback', path);
|
|
113
|
+
if (translated === undefined) return fallback;
|
|
114
|
+
if (typeof translated !== 'string') {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Invalid ${locale} localized value at ${path}: expected string`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
assertMessage(translated, locale, path);
|
|
120
|
+
assertPlaceholders(fallback, translated, locale, path);
|
|
121
|
+
return translated;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (translated !== undefined && !isMessageRecord(translated)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Invalid ${locale} localized value at ${path || '<root>'}: expected object`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const translatedRecord = translated ?? {};
|
|
131
|
+
const extraKeys = Object.keys(translatedRecord).filter(
|
|
132
|
+
(key) => !(key in fallback),
|
|
133
|
+
);
|
|
134
|
+
if (extraKeys.length > 0) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Invalid ${locale} localized keys at ${path || '<root>'}: ` +
|
|
137
|
+
`[${extraKeys.join(', ')}]`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return Object.fromEntries(
|
|
142
|
+
Object.entries(fallback).map(([key, fallbackValue]) => {
|
|
143
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
144
|
+
return [
|
|
145
|
+
key,
|
|
146
|
+
resolveMessageNode(
|
|
147
|
+
fallbackValue,
|
|
148
|
+
translatedRecord[key],
|
|
149
|
+
locale,
|
|
150
|
+
childPath,
|
|
151
|
+
),
|
|
152
|
+
];
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isMessageRecord(value: unknown): value is Record<string, unknown> {
|
|
158
|
+
return (
|
|
159
|
+
typeof value === 'object' &&
|
|
160
|
+
value !== null &&
|
|
161
|
+
!Array.isArray(value) &&
|
|
162
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function stripSchemaVersion(
|
|
167
|
+
value: Readonly<Record<string, unknown>>,
|
|
168
|
+
): Record<string, unknown> {
|
|
169
|
+
return Object.fromEntries(
|
|
170
|
+
Object.entries(value).filter(([key]) => key !== 'schemaVersion'),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function assertMessage(value: string, locale: string, path: string): void {
|
|
175
|
+
if (value.trim().length === 0) {
|
|
176
|
+
throw new Error(`Empty ${locale} localized value at ${path}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function assertPlaceholders(
|
|
181
|
+
fallback: string,
|
|
182
|
+
translated: string,
|
|
183
|
+
locale: string,
|
|
184
|
+
path: string,
|
|
185
|
+
): void {
|
|
186
|
+
const expected = getPlaceholders(fallback);
|
|
187
|
+
const actual = getPlaceholders(translated);
|
|
188
|
+
if (
|
|
189
|
+
expected.length !== actual.length ||
|
|
190
|
+
expected.some((placeholder, index) => placeholder !== actual[index])
|
|
191
|
+
) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Invalid ${locale} placeholders for ${path}: ` +
|
|
194
|
+
`expected [${expected.join(', ')}], received [${actual.join(', ')}]`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getPlaceholders(message: string): readonly string[] {
|
|
200
|
+
return [...message.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)]
|
|
201
|
+
.map((match) => match[1]!)
|
|
202
|
+
.toSorted();
|
|
203
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export type TextDirection = 'ltr' | 'rtl';
|
|
2
|
+
|
|
3
|
+
export type LocaleDefinition = {
|
|
4
|
+
id: string;
|
|
5
|
+
urlCode: string;
|
|
6
|
+
languageTag: string;
|
|
7
|
+
nativeLabel: string;
|
|
8
|
+
intlLocale: string;
|
|
9
|
+
openGraphLocale: string;
|
|
10
|
+
direction: TextDirection;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type LocaleConfig<
|
|
14
|
+
TDefinition extends LocaleDefinition = LocaleDefinition,
|
|
15
|
+
> = {
|
|
16
|
+
definitions: readonly TDefinition[];
|
|
17
|
+
locales: readonly TDefinition['id'][];
|
|
18
|
+
localizedLocales: readonly TDefinition['id'][];
|
|
19
|
+
defaultLocale: TDefinition['id'];
|
|
20
|
+
prefixDefaultLocale: boolean;
|
|
21
|
+
isLocale(value: string): value is TDefinition['id'];
|
|
22
|
+
getDefinition(locale: TDefinition['id']): TDefinition;
|
|
23
|
+
getLocaleFromUrlCode(value: string): TDefinition['id'] | undefined;
|
|
24
|
+
getLocaleFromLanguageTag(value: string): TDefinition['id'] | undefined;
|
|
25
|
+
requireLocalizedLocale(value: string): TDefinition['id'];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function createLocaleConfig<
|
|
29
|
+
const TDefinitions extends readonly [
|
|
30
|
+
LocaleDefinition,
|
|
31
|
+
...LocaleDefinition[],
|
|
32
|
+
],
|
|
33
|
+
const TDefaultLocale extends TDefinitions[number]['id'],
|
|
34
|
+
>({
|
|
35
|
+
defaultLocale,
|
|
36
|
+
locales,
|
|
37
|
+
prefixDefaultLocale = false,
|
|
38
|
+
}: {
|
|
39
|
+
defaultLocale: TDefaultLocale;
|
|
40
|
+
locales: TDefinitions;
|
|
41
|
+
prefixDefaultLocale?: boolean;
|
|
42
|
+
}): LocaleConfig<TDefinitions[number]> & {
|
|
43
|
+
defaultLocale: TDefaultLocale;
|
|
44
|
+
} {
|
|
45
|
+
type Definition = TDefinitions[number];
|
|
46
|
+
type Locale = Definition['id'];
|
|
47
|
+
|
|
48
|
+
const localeById = new Map<Locale, Definition>();
|
|
49
|
+
const localeByUrlCode = new Map<string, Definition>();
|
|
50
|
+
const localeByLanguageTag = new Map<string, Definition>();
|
|
51
|
+
|
|
52
|
+
for (const locale of locales) {
|
|
53
|
+
if (localeById.has(locale.id)) {
|
|
54
|
+
throw new Error(`Duplicate locale: ${locale.id}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const normalizedUrlCode = locale.urlCode.toLowerCase();
|
|
58
|
+
if (localeByUrlCode.has(normalizedUrlCode)) {
|
|
59
|
+
throw new Error(`Duplicate locale URL code: ${locale.urlCode}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const normalizedLanguageTag = locale.languageTag.toLowerCase();
|
|
63
|
+
if (localeByLanguageTag.has(normalizedLanguageTag)) {
|
|
64
|
+
throw new Error(`Duplicate locale language tag: ${locale.languageTag}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
localeById.set(locale.id, locale);
|
|
68
|
+
localeByUrlCode.set(normalizedUrlCode, locale);
|
|
69
|
+
localeByLanguageTag.set(normalizedLanguageTag, locale);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!localeById.has(defaultLocale)) {
|
|
73
|
+
throw new Error(`Unknown default locale: ${defaultLocale}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const localeIds = locales.map(({ id }) => id);
|
|
77
|
+
const localizedLocales = localeIds.filter(
|
|
78
|
+
(locale) => prefixDefaultLocale || locale !== defaultLocale,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
definitions: locales,
|
|
83
|
+
locales: localeIds,
|
|
84
|
+
localizedLocales,
|
|
85
|
+
defaultLocale,
|
|
86
|
+
prefixDefaultLocale,
|
|
87
|
+
isLocale(value: string): value is Locale {
|
|
88
|
+
return localeById.has(value as Locale);
|
|
89
|
+
},
|
|
90
|
+
getDefinition(locale: Locale): Definition {
|
|
91
|
+
const definition = localeById.get(locale);
|
|
92
|
+
if (!definition) throw new Error(`Unsupported locale: ${locale}`);
|
|
93
|
+
return definition;
|
|
94
|
+
},
|
|
95
|
+
getLocaleFromUrlCode(value: string): Locale | undefined {
|
|
96
|
+
return localeByUrlCode.get(value.toLowerCase())?.id;
|
|
97
|
+
},
|
|
98
|
+
getLocaleFromLanguageTag(value: string): Locale | undefined {
|
|
99
|
+
return localeByLanguageTag.get(value.toLowerCase())?.id;
|
|
100
|
+
},
|
|
101
|
+
requireLocalizedLocale(value: string): Locale {
|
|
102
|
+
const locale = localeByUrlCode.get(value.toLowerCase())?.id;
|
|
103
|
+
if (
|
|
104
|
+
!locale ||
|
|
105
|
+
(!prefixDefaultLocale && locale === defaultLocale)
|
|
106
|
+
) {
|
|
107
|
+
throw new Error(`Unsupported localized route locale: ${value}`);
|
|
108
|
+
}
|
|
109
|
+
return locale;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveLocalizedCatalog,
|
|
3
|
+
type MessageShape,
|
|
4
|
+
} from './catalog';
|
|
5
|
+
|
|
6
|
+
export const defaultCoreMessages = {
|
|
7
|
+
actions: {
|
|
8
|
+
close: 'Close',
|
|
9
|
+
copy: 'Copy',
|
|
10
|
+
enterFullscreen: 'Enter full screen',
|
|
11
|
+
exitFullscreen: 'Exit full screen',
|
|
12
|
+
next: 'Next',
|
|
13
|
+
open: 'Open',
|
|
14
|
+
previous: 'Previous',
|
|
15
|
+
search: 'Search',
|
|
16
|
+
},
|
|
17
|
+
feedback: {
|
|
18
|
+
noResults: 'No results.',
|
|
19
|
+
},
|
|
20
|
+
navigation: {
|
|
21
|
+
breadcrumbs: 'Breadcrumbs',
|
|
22
|
+
section: 'Section navigation',
|
|
23
|
+
},
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export type CoreMessages = MessageShape<typeof defaultCoreMessages>;
|
|
27
|
+
|
|
28
|
+
export function resolveCoreMessages(
|
|
29
|
+
translated: unknown = undefined,
|
|
30
|
+
locale = 'en',
|
|
31
|
+
): CoreMessages {
|
|
32
|
+
return resolveLocalizedCatalog(
|
|
33
|
+
defaultCoreMessages,
|
|
34
|
+
translated,
|
|
35
|
+
locale,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type LocaleFormatters = ReturnType<typeof createLocaleFormatters>;
|
|
2
|
+
|
|
3
|
+
export function createLocaleFormatters(intlLocale: string) {
|
|
4
|
+
return {
|
|
5
|
+
locale: intlLocale,
|
|
6
|
+
collator: new Intl.Collator(intlLocale, { sensitivity: 'base' }),
|
|
7
|
+
integer: new Intl.NumberFormat(intlLocale, {
|
|
8
|
+
maximumFractionDigits: 0,
|
|
9
|
+
}),
|
|
10
|
+
decimal: new Intl.NumberFormat(intlLocale, {
|
|
11
|
+
maximumFractionDigits: 2,
|
|
12
|
+
}),
|
|
13
|
+
fixedDecimal: new Intl.NumberFormat(intlLocale, {
|
|
14
|
+
minimumFractionDigits: 2,
|
|
15
|
+
maximumFractionDigits: 2,
|
|
16
|
+
}),
|
|
17
|
+
dateLong: new Intl.DateTimeFormat(intlLocale, {
|
|
18
|
+
day: 'numeric',
|
|
19
|
+
month: 'short',
|
|
20
|
+
year: 'numeric',
|
|
21
|
+
timeZone: 'UTC',
|
|
22
|
+
}),
|
|
23
|
+
dateTimeLong: new Intl.DateTimeFormat(intlLocale, {
|
|
24
|
+
dateStyle: 'long',
|
|
25
|
+
timeStyle: 'short',
|
|
26
|
+
timeZone: 'UTC',
|
|
27
|
+
}),
|
|
28
|
+
list: new Intl.ListFormat(intlLocale, {
|
|
29
|
+
style: 'long',
|
|
30
|
+
type: 'conjunction',
|
|
31
|
+
}),
|
|
32
|
+
plural: new Intl.PluralRules(intlLocale),
|
|
33
|
+
} as const;
|
|
34
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type LocalePreferenceControllerOptions<TLocale extends string> = {
|
|
2
|
+
storageKey: string;
|
|
3
|
+
resolveLocale(languageTag: string): TLocale | undefined;
|
|
4
|
+
resolvePath(pathname: string, locale: TLocale): string;
|
|
5
|
+
initializedAttribute?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function createLocalePreferenceController<TLocale extends string>({
|
|
9
|
+
initializedAttribute = 'data-language-preference-initialized',
|
|
10
|
+
resolveLocale,
|
|
11
|
+
resolvePath,
|
|
12
|
+
storageKey,
|
|
13
|
+
}: LocalePreferenceControllerOptions<TLocale>) {
|
|
14
|
+
return function initLocalePreference(
|
|
15
|
+
root: Document = document,
|
|
16
|
+
): () => void {
|
|
17
|
+
const element = root.documentElement;
|
|
18
|
+
if (element.hasAttribute(initializedAttribute)) return () => undefined;
|
|
19
|
+
element.setAttribute(initializedAttribute, '');
|
|
20
|
+
|
|
21
|
+
const handleClick = (event: MouseEvent): void => {
|
|
22
|
+
const target = event.target;
|
|
23
|
+
if (!(target instanceof Element)) return;
|
|
24
|
+
|
|
25
|
+
const link = target.closest<HTMLAnchorElement>('a[hreflang]');
|
|
26
|
+
const languageTag = link?.getAttribute('hreflang');
|
|
27
|
+
const locale = languageTag
|
|
28
|
+
? resolveLocale(languageTag)
|
|
29
|
+
: undefined;
|
|
30
|
+
if (!locale) return;
|
|
31
|
+
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
try {
|
|
34
|
+
localStorage.setItem(storageKey, locale);
|
|
35
|
+
} catch {
|
|
36
|
+
// Navigation remains available when storage is unavailable.
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const destination = new URL(
|
|
40
|
+
resolvePath(window.location.pathname, locale),
|
|
41
|
+
window.location.origin,
|
|
42
|
+
);
|
|
43
|
+
destination.search = window.location.search;
|
|
44
|
+
destination.hash = window.location.hash;
|
|
45
|
+
|
|
46
|
+
if (destination.href !== window.location.href) {
|
|
47
|
+
window.location.assign(destination);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
root.addEventListener('click', handleClick);
|
|
52
|
+
|
|
53
|
+
return () => {
|
|
54
|
+
root.removeEventListener('click', handleClick);
|
|
55
|
+
element.removeAttribute(initializedAttribute);
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './localized';
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { LocaleConfig, LocaleDefinition } from '../i18n/config';
|
|
2
|
+
import { normalizePathname } from '../seo/url';
|
|
3
|
+
|
|
4
|
+
export type LocalizedRoutePolicy = 'localized' | 'technical';
|
|
5
|
+
|
|
6
|
+
export type LocalizedRouter<TLocale extends string> = {
|
|
7
|
+
localizePath(path: string, locale: TLocale): string;
|
|
8
|
+
stripLocalePrefix(path: string): { locale: TLocale; path: string };
|
|
9
|
+
resolveLocale(path: string): TLocale;
|
|
10
|
+
getEquivalentPath(path: string, locale: TLocale): string;
|
|
11
|
+
getRoutePolicy(path: string): LocalizedRoutePolicy;
|
|
12
|
+
normalizePath(path: string): string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function createLocalizedRouter<
|
|
16
|
+
TDefinition extends LocaleDefinition,
|
|
17
|
+
>({
|
|
18
|
+
localeConfig,
|
|
19
|
+
localizedRoots = ['/'],
|
|
20
|
+
technicalRoots = [],
|
|
21
|
+
}: {
|
|
22
|
+
localeConfig: LocaleConfig<TDefinition>;
|
|
23
|
+
localizedRoots?: readonly string[];
|
|
24
|
+
technicalRoots?: readonly string[];
|
|
25
|
+
}): LocalizedRouter<TDefinition['id']> {
|
|
26
|
+
type Locale = TDefinition['id'];
|
|
27
|
+
|
|
28
|
+
const normalizedLocalizedRoots = localizedRoots.map(normalizePathname);
|
|
29
|
+
const normalizedTechnicalRoots = technicalRoots.map(normalizePathname);
|
|
30
|
+
|
|
31
|
+
const localizePath = (path: string, locale: Locale): string => {
|
|
32
|
+
const { pathname, suffix } = splitPathReference(path);
|
|
33
|
+
const normalizedPath = normalizePathname(pathname);
|
|
34
|
+
if (
|
|
35
|
+
!localeConfig.prefixDefaultLocale &&
|
|
36
|
+
locale === localeConfig.defaultLocale
|
|
37
|
+
) {
|
|
38
|
+
return `${normalizedPath}${suffix}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const prefix = `/${localeConfig.getDefinition(locale).urlCode}`;
|
|
42
|
+
const localizedPath = normalizedPath === '/'
|
|
43
|
+
? prefix
|
|
44
|
+
: normalizePathname(`${prefix}${normalizedPath}`);
|
|
45
|
+
return `${localizedPath}${suffix}`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const stripLocalePrefix = (
|
|
49
|
+
path: string,
|
|
50
|
+
): { locale: Locale; path: string } => {
|
|
51
|
+
const { pathname, suffix } = splitPathReference(path);
|
|
52
|
+
const normalizedPath = normalizePathname(pathname);
|
|
53
|
+
const [firstSegment = ''] = normalizedPath.slice(1).split('/');
|
|
54
|
+
const locale = localeConfig.getLocaleFromUrlCode(firstSegment);
|
|
55
|
+
|
|
56
|
+
if (!locale) {
|
|
57
|
+
return {
|
|
58
|
+
locale: localeConfig.defaultLocale,
|
|
59
|
+
path: `${normalizedPath}${suffix}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const prefix = `/${firstSegment}`;
|
|
64
|
+
const unprefixedPath = normalizedPath.slice(prefix.length);
|
|
65
|
+
return {
|
|
66
|
+
locale,
|
|
67
|
+
path: `${normalizePathname(unprefixedPath || '/')}${suffix}`,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const matchesRoot = (path: string, root: string): boolean =>
|
|
72
|
+
path === root || path.startsWith(`${root}/`);
|
|
73
|
+
|
|
74
|
+
const getRoutePolicy = (path: string): LocalizedRoutePolicy => {
|
|
75
|
+
const { path: unprefixedPath } = stripLocalePrefix(path);
|
|
76
|
+
const normalizedPath = normalizePathname(unprefixedPath);
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
normalizedTechnicalRoots.some((root) =>
|
|
80
|
+
matchesRoot(normalizedPath, root),
|
|
81
|
+
)
|
|
82
|
+
) {
|
|
83
|
+
return 'technical';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return normalizedLocalizedRoots.some(
|
|
87
|
+
(root) => root === '/' || matchesRoot(normalizedPath, root),
|
|
88
|
+
)
|
|
89
|
+
? 'localized'
|
|
90
|
+
: 'technical';
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
localizePath,
|
|
95
|
+
stripLocalePrefix,
|
|
96
|
+
resolveLocale(path: string): Locale {
|
|
97
|
+
return stripLocalePrefix(path).locale;
|
|
98
|
+
},
|
|
99
|
+
getEquivalentPath(path: string, locale: Locale): string {
|
|
100
|
+
const { path: unprefixedPath } = stripLocalePrefix(path);
|
|
101
|
+
return getRoutePolicy(unprefixedPath) === 'localized'
|
|
102
|
+
? localizePath(unprefixedPath, locale)
|
|
103
|
+
: unprefixedPath;
|
|
104
|
+
},
|
|
105
|
+
getRoutePolicy,
|
|
106
|
+
normalizePath(path: string): string {
|
|
107
|
+
const { pathname, suffix } = splitPathReference(path);
|
|
108
|
+
return `${normalizePathname(pathname)}${suffix}`;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type LocalizedRouteParams = {
|
|
114
|
+
locale: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export function createLocalizedRoutePaths<
|
|
118
|
+
TDefinition extends LocaleDefinition,
|
|
119
|
+
>(localeConfig: LocaleConfig<TDefinition>): readonly {
|
|
120
|
+
params: LocalizedRouteParams;
|
|
121
|
+
props: { locale: TDefinition['id'] };
|
|
122
|
+
}[] {
|
|
123
|
+
return localeConfig.localizedLocales.map((locale) => ({
|
|
124
|
+
params: {
|
|
125
|
+
locale: localeConfig.getDefinition(locale).urlCode,
|
|
126
|
+
},
|
|
127
|
+
props: { locale },
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function splitPathReference(value: string): {
|
|
132
|
+
pathname: string;
|
|
133
|
+
suffix: string;
|
|
134
|
+
} {
|
|
135
|
+
const cleanValue = value.trim();
|
|
136
|
+
|
|
137
|
+
if (/^https?:\/\//i.test(cleanValue)) {
|
|
138
|
+
const url = new URL(cleanValue);
|
|
139
|
+
return {
|
|
140
|
+
pathname: url.pathname,
|
|
141
|
+
suffix: `${url.search}${url.hash}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const searchIndex = cleanValue.indexOf('?');
|
|
146
|
+
const hashIndex = cleanValue.indexOf('#');
|
|
147
|
+
const suffixIndex = [searchIndex, hashIndex]
|
|
148
|
+
.filter((index) => index >= 0)
|
|
149
|
+
.reduce((lowest, index) => Math.min(lowest, index), cleanValue.length);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
pathname: cleanValue.slice(0, suffixIndex),
|
|
153
|
+
suffix: cleanValue.slice(suffixIndex),
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/seo/index.ts
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { PageMeta } from '../config/pageMeta';
|
|
2
|
+
import type { LocaleConfig, LocaleDefinition } from '../i18n/config';
|
|
3
|
+
import type { LocalizedRouter } from '../routing/localized';
|
|
4
|
+
import { normalizePathname } from './url';
|
|
5
|
+
|
|
6
|
+
export type LocalizedMetaInput = Omit<
|
|
7
|
+
PageMeta,
|
|
8
|
+
| 'alternates'
|
|
9
|
+
| 'canonical'
|
|
10
|
+
| 'lang'
|
|
11
|
+
| 'openGraphLocale'
|
|
12
|
+
| 'openGraphLocaleAlternates'
|
|
13
|
+
> & {
|
|
14
|
+
canonical: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function createLocalizedPageMeta<
|
|
18
|
+
TDefinition extends LocaleDefinition,
|
|
19
|
+
>({
|
|
20
|
+
input,
|
|
21
|
+
locale,
|
|
22
|
+
localeConfig,
|
|
23
|
+
router,
|
|
24
|
+
socialImageAlt,
|
|
25
|
+
}: {
|
|
26
|
+
input: LocalizedMetaInput;
|
|
27
|
+
locale: TDefinition['id'];
|
|
28
|
+
localeConfig: LocaleConfig<TDefinition>;
|
|
29
|
+
router: LocalizedRouter<TDefinition['id']>;
|
|
30
|
+
socialImageAlt?: string;
|
|
31
|
+
}): PageMeta {
|
|
32
|
+
const definition = localeConfig.getDefinition(locale);
|
|
33
|
+
const { path } = router.stripLocalePrefix(
|
|
34
|
+
normalizePathname(input.canonical),
|
|
35
|
+
);
|
|
36
|
+
const alternates = localeConfig.definitions.map((alternateLocale) => ({
|
|
37
|
+
lang: alternateLocale.languageTag,
|
|
38
|
+
href: router.localizePath(path, alternateLocale.id),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
...input,
|
|
43
|
+
canonical: router.localizePath(path, locale),
|
|
44
|
+
lang: definition.languageTag,
|
|
45
|
+
socialImageAlt: input.socialImageAlt ?? socialImageAlt,
|
|
46
|
+
openGraphLocale: definition.openGraphLocale,
|
|
47
|
+
openGraphLocaleAlternates: localeConfig.definitions
|
|
48
|
+
.filter((alternateLocale) => alternateLocale.id !== locale)
|
|
49
|
+
.map((alternateLocale) => alternateLocale.openGraphLocale),
|
|
50
|
+
alternates: [
|
|
51
|
+
...alternates,
|
|
52
|
+
{
|
|
53
|
+
lang: 'x-default',
|
|
54
|
+
href: router.localizePath(path, localeConfig.defaultLocale),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
}
|