@lupinum/ginko-i18n 0.4.0 → 0.5.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/CHANGELOG.md +16 -0
- package/README.md +17 -3
- package/dist/cli/index.mjs +1 -1
- package/dist/core/index.d.mts +31 -26
- package/dist/core/index.mjs +7 -6
- package/dist/module.json +1 -1
- package/dist/module.mjs +1 -1
- package/dist/runtime/composables.js +4 -2
- package/dist/runtime/plugin.js +29 -5
- package/dist/runtime/server/middleware.js +6 -3
- package/dist/runtime/types.d.ts +5 -0
- package/dist/shared/{ginko-i18n.C1vsnUyB.mjs → ginko-i18n.0DBFKY5Y.mjs} +18 -1
- package/dist/shared/{ginko-i18n.Fn02YzGM.d.mts → ginko-i18n.BW09FRJO.d.mts} +2 -0
- package/dist/shared/{ginko-i18n.C3yTBMSg.d.mts → ginko-i18n.DAtdFZNJ.d.mts} +14 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @lupinum/ginko-i18n
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#21](https://github.com/lupinum-dev/ginko/pull/21) [`80f93a4`](https://github.com/lupinum-dev/ginko/commit/80f93a4f6bd171e1146b929913254039becdd991) Thanks [@Mat4m0](https://github.com/Mat4m0)! - Add the `dateLanguage` option to `createFormatters` and `createTranslator`: dates in another tag (`en-GB`) while numbers and words stay in `language`.
|
|
8
|
+
|
|
9
|
+
- [#21](https://github.com/lupinum-dev/ginko/pull/21) [`80f93a4`](https://github.com/lupinum-dev/ginko/commit/80f93a4f6bd171e1146b929913254039becdd991) Thanks [@Mat4m0](https://github.com/Mat4m0)! - Fix `//blog` on Nuxt 4: a doubled leading slash now answers with a 301 to `/blog` there too, instead of showing the home page.
|
|
10
|
+
|
|
11
|
+
- [#21](https://github.com/lupinum-dev/ginko/pull/21) [`80f93a4`](https://github.com/lupinum-dev/ginko/commit/80f93a4f6bd171e1146b929913254039becdd991) Thanks [@Mat4m0](https://github.com/Mat4m0)! - Fix error pages: they get no canonical or hreflang and are noindex.
|
|
12
|
+
|
|
13
|
+
- [#21](https://github.com/lupinum-dev/ginko/pull/21) [`80f93a4`](https://github.com/lupinum-dev/ginko/commit/80f93a4f6bd171e1146b929913254039becdd991) Thanks [@Mat4m0](https://github.com/Mat4m0)! - Fix page addresses in another case: `/Blog` now answers with a 301 to `/blog`, and the canonical link uses that spelling.
|
|
14
|
+
|
|
15
|
+
- [#21](https://github.com/lupinum-dev/ginko/pull/21) [`80f93a4`](https://github.com/lupinum-dev/ginko/commit/80f93a4f6bd171e1146b929913254039becdd991) Thanks [@Mat4m0](https://github.com/Mat4m0)! - Fix doubled and trailing slashes: `//blog` and `/blog/` now answer with a 301 to `/blog`, so every page has one URL.
|
|
16
|
+
|
|
17
|
+
- [#21](https://github.com/lupinum-dev/ginko/pull/21) [`80f93a4`](https://github.com/lupinum-dev/ginko/commit/80f93a4f6bd171e1146b929913254039becdd991) Thanks [@Mat4m0](https://github.com/Mat4m0)! - Change dates in user-locale zones to follow the region of the browser's first language when it is the same language and the locale names no region (`en` reads 25/10/2026 in an en-GB browser); numbers and words stay in the locale's language.
|
|
18
|
+
|
|
3
19
|
## 0.4.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -210,7 +210,15 @@ export default defineNuxtPlugin(() => {
|
|
|
210
210
|
Every page gets `<html lang dir>`, a canonical link, hreflang links (with `x-default` for the default
|
|
211
211
|
locale) and `og:locale`. Pages that are not translated into a locale get no hreflang for it. Set
|
|
212
212
|
`baseUrl` so the links are absolute; without it the module uses `runtimeConfig.public.siteUrl`, and
|
|
213
|
-
without both the links are relative (the build warns). Turn the tags off with `seo: false`.
|
|
213
|
+
without both the links are relative (the build warns). Turn the tags off with `seo: false`. Error
|
|
214
|
+
pages (404, 503) are not pages of the site: they get `lang`, `dir` and `robots: noindex`, but no
|
|
215
|
+
canonical, hreflang or `og:url`.
|
|
216
|
+
|
|
217
|
+
Every page has one URL. The server answers other spellings of it with a 301 to that URL: repeated
|
|
218
|
+
slashes (`//blog`), a trailing slash (`/blog/`) and a locale code in another case (`/EN/about`). A
|
|
219
|
+
page path in another case (`/Blog`) redirects to the spelling of the page file or route; a slug keeps
|
|
220
|
+
the case the visitor typed, and the page decides whether it exists. URLs therefore never end in `/`, except the
|
|
221
|
+
root. The canonical link and `og:url` use that one URL.
|
|
214
222
|
|
|
215
223
|
With `@nuxtjs/sitemap` installed, the module turns its `autoI18n` off and gives it explicit alternates,
|
|
216
224
|
because `@nuxtjs/sitemap` pairs locales by stripping the prefix, which is wrong for translated slugs.
|
|
@@ -286,6 +294,10 @@ hydration mismatch. Set the cookie from the user's account setting when they sig
|
|
|
286
294
|
(`ginko_locale=en; Path=/; Max-Age=31536000; SameSite=Lax`), so the first render already has the
|
|
287
295
|
right language.
|
|
288
296
|
|
|
297
|
+
Dates in a zone follow the region of the browser's first language when it is the same language and the
|
|
298
|
+
locale names no region: `en` reads 25/10/2026 in an `en-GB` browser and 10/25/2026 in an `en-US` one.
|
|
299
|
+
Numbers and words stay in the locale's language, and a locale with a region (`de-AT`) keeps it.
|
|
300
|
+
|
|
289
301
|
`switchLocale(code)` changes the language:
|
|
290
302
|
|
|
291
303
|
```vue
|
|
@@ -461,18 +473,20 @@ Framework-free and parser-free. See [Outside Nuxt](#outside-nuxt).
|
|
|
461
473
|
|
|
462
474
|
| Export | Meaning |
|
|
463
475
|
| --- | --- |
|
|
464
|
-
| `createTranslator({ messages, fallback?, language, timeZone?, params?, onMissing? })` | A `Translator` (`t`, `parts`, `has`) for one locale. Lookup: `messages`, then `fallback`, then the key itself. |
|
|
476
|
+
| `createTranslator({ messages, fallback?, language, timeZone?, dateLanguage?, params?, onMissing? })` | A `Translator` (`t`, `parts`, `has`) for one locale. Lookup: `messages`, then `fallback`, then the key itself. `dateLanguage` writes the dates in messages in another tag (`en-GB`). |
|
|
465
477
|
| `defineCatalog<K>(trees, { defaultLocale, params? })` | A `Catalog`: `t(locale, key, params?, options?)`, `translator(locale)`, `locales`, `defaultLocale`, `isLocale(value)`. An unknown locale gets the default; a full tag (`en-US`) finds its language. |
|
|
466
478
|
| `KeysOf<typeof catalog>` | The dotted keys of a source catalog type: `'mail.invite.subject' \| …`. |
|
|
467
|
-
| `createFormatters(language, { timeZone? })` | The `format` object of `useI18n()`. |
|
|
479
|
+
| `createFormatters(language, { timeZone?, dateLanguage? })` | The `format` object of `useI18n()`. `dateLanguage` writes `date` and `range` in another tag (`en-GB`); numbers and words stay in `language`. |
|
|
468
480
|
| `resolveConfig({ locales, defaultLocale?, strategy?, exclude?, userLocale? })` | The resolved locale config for the functions below. |
|
|
469
481
|
| `localizePath(path, code, config)` | Adds the prefix: `/about` → `/en/about`. |
|
|
470
482
|
| `stripLocale(path, config)` | Removes it: `/en/about` → `/about`. |
|
|
471
483
|
| `localeFromPath(path, config)` | The code a path belongs to, or `undefined` (`/de/x` under `prefix_except_default`). |
|
|
472
484
|
| `negotiateLocale(header, config)` | The best code for an `Accept-Language` header, or `undefined`. |
|
|
485
|
+
| `regionalLanguage(language, browser)` | The tag that formats dates in a user-locale zone: the browser's regional variant of `language` (`en` + `en-GB` → `en-GB`), else `language`. |
|
|
473
486
|
| `isExcluded(path, config)` | Whether the path never gets a prefix (`exclude`, `/api/**`, Nuxt internals). |
|
|
474
487
|
| `inUserZone(path, config)` | Whether the path is in a user-locale zone (`userLocale`). |
|
|
475
488
|
| `canonicalCase(path, config)` | The path with its prefix in the code's own case (`/EN/about` → `/en/about`), or `undefined` when it is already right. |
|
|
489
|
+
| `cleanPath(path, config)` | The one form of a path: no repeated or trailing slashes, the prefix in the code's own case (`//EN/about/` → `/en/about`), or `undefined` when it is already right. The server redirects other forms here with a 301. |
|
|
476
490
|
| `alternatesOf(paths, config)` | The hreflang links of a page from its path per code: `{ hreflang, path }[]`, with `x-default` for the default locale. The head tags and the sitemap use it. |
|
|
477
491
|
| `ogLocale(language)` | The `og:locale` form of a tag: `de-AT` → `de_AT`. |
|
|
478
492
|
| `partsToString(parts)` | The plain text of a `parts()` result. |
|
package/dist/cli/index.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { createHash } from 'node:crypto';
|
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { parse, TYPE } from '@formatjs/icu-messageformat-parser';
|
|
8
8
|
import { k as keyProblem } from '../shared/ginko-i18n.BPkxIZl5.mjs';
|
|
9
|
-
import { L as LOCALE_CODE } from '../shared/ginko-i18n.
|
|
9
|
+
import { L as LOCALE_CODE } from '../shared/ginko-i18n.0DBFKY5Y.mjs';
|
|
10
10
|
import { M as MANIFEST_VERSION } from '../shared/ginko-i18n.BloO2EKS.mjs';
|
|
11
11
|
|
|
12
12
|
const ORIGINS = ["library", "layer", "app"];
|
package/dist/core/index.d.mts
CHANGED
|
@@ -1,34 +1,16 @@
|
|
|
1
|
-
import { P as Params, T as TranslateOptions, a as Translator, M as MessageTree } from '../shared/ginko-i18n.
|
|
2
|
-
export { b as Part, c as createTranslator, p as partsToString } from '../shared/ginko-i18n.
|
|
3
|
-
export { a as LocaleConfig, b as LocaleInfo, L as LocaleInput, S as Strategy, c as alternatesOf, d as canonicalCase, i as inUserZone,
|
|
4
|
-
|
|
5
|
-
/** Dotted keys of a raw catalog type: `KeysOf<typeof de>` → `'errors.noAccess' | 'mail.invite.subject'`. */
|
|
6
|
-
type KeysOf<T, P extends string = ''> = {
|
|
7
|
-
[K in keyof T & string]: T[K] extends string ? `${P}${K}` : KeysOf<T[K], `${P}${K}.`>;
|
|
8
|
-
}[keyof T & string];
|
|
9
|
-
interface Catalog<K extends string = string> {
|
|
10
|
-
/** Locale codes that have a tree, in the order given. */
|
|
11
|
-
locales: string[];
|
|
12
|
-
defaultLocale: string;
|
|
13
|
-
isLocale: (value: unknown) => value is string;
|
|
14
|
-
/** The text in `locale`; an unknown or missing locale gets the default locale. */
|
|
15
|
-
t: (locale: string | undefined, key: K, params?: Params, options?: TranslateOptions) => string;
|
|
16
|
-
/** The full translator of one locale (`parts`, `has`), with the same fallback. */
|
|
17
|
-
translator: (locale: string | undefined) => Translator;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Pass the key type explicitly, so the trees can come precompiled (no parser at runtime):
|
|
21
|
-
* `defineCatalog<KeysOf<typeof de>>(compiled, { defaultLocale: 'de' })`.
|
|
22
|
-
*/
|
|
23
|
-
declare function defineCatalog<K extends string = string>(trees: Record<string, MessageTree>, options: {
|
|
24
|
-
defaultLocale: string;
|
|
25
|
-
params?: Params;
|
|
26
|
-
}): Catalog<K>;
|
|
1
|
+
import { P as Params, T as TranslateOptions, a as Translator, M as MessageTree } from '../shared/ginko-i18n.BW09FRJO.mjs';
|
|
2
|
+
export { b as Part, c as createTranslator, p as partsToString } from '../shared/ginko-i18n.BW09FRJO.mjs';
|
|
3
|
+
export { a as LocaleConfig, b as LocaleInfo, L as LocaleInput, S as Strategy, c as alternatesOf, d as canonicalCase, e as cleanPath, i as inUserZone, f as isExcluded, l as localeFromPath, g as localizePath, n as negotiateLocale, o as ogLocale, r as regionalLanguage, h as resolveConfig, s as stripLocale } from '../shared/ginko-i18n.DAtdFZNJ.mjs';
|
|
27
4
|
|
|
28
5
|
type DateStyle = 'short' | 'medium' | 'long' | 'full';
|
|
29
6
|
interface FormatterOptions {
|
|
30
7
|
/** IANA time zone (`Europe/Vienna`) for timestamps. Calendar dates always use UTC. Default `UTC`. */
|
|
31
8
|
timeZone?: string;
|
|
9
|
+
/**
|
|
10
|
+
* The tag for dates (`date`, `range`) when it differs from `language`: a regional variant (`en-GB` orders
|
|
11
|
+
* 25/10/2026) while numbers and words stay in `language`. Default `language`.
|
|
12
|
+
*/
|
|
13
|
+
dateLanguage?: string;
|
|
32
14
|
}
|
|
33
15
|
type DateInput = Date | number | string;
|
|
34
16
|
declare function createFormatters(language: string, options?: FormatterOptions): {
|
|
@@ -50,5 +32,28 @@ declare function createFormatters(language: string, options?: FormatterOptions):
|
|
|
50
32
|
};
|
|
51
33
|
type Formatters = ReturnType<typeof createFormatters>;
|
|
52
34
|
|
|
35
|
+
/** Dotted keys of a raw catalog type: `KeysOf<typeof de>` → `'errors.noAccess' | 'mail.invite.subject'`. */
|
|
36
|
+
type KeysOf<T, P extends string = ''> = {
|
|
37
|
+
[K in keyof T & string]: T[K] extends string ? `${P}${K}` : KeysOf<T[K], `${P}${K}.`>;
|
|
38
|
+
}[keyof T & string];
|
|
39
|
+
interface Catalog<K extends string = string> {
|
|
40
|
+
/** Locale codes that have a tree, in the order given. */
|
|
41
|
+
locales: string[];
|
|
42
|
+
defaultLocale: string;
|
|
43
|
+
isLocale: (value: unknown) => value is string;
|
|
44
|
+
/** The text in `locale`; an unknown or missing locale gets the default locale. */
|
|
45
|
+
t: (locale: string | undefined, key: K, params?: Params, options?: TranslateOptions) => string;
|
|
46
|
+
/** The full translator of one locale (`parts`, `has`), with the same fallback. */
|
|
47
|
+
translator: (locale: string | undefined) => Translator;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Pass the key type explicitly, so the trees can come precompiled (no parser at runtime):
|
|
51
|
+
* `defineCatalog<KeysOf<typeof de>>(compiled, { defaultLocale: 'de' })`.
|
|
52
|
+
*/
|
|
53
|
+
declare function defineCatalog<K extends string = string>(trees: Record<string, MessageTree>, options: {
|
|
54
|
+
defaultLocale: string;
|
|
55
|
+
params?: Params;
|
|
56
|
+
}): Catalog<K>;
|
|
57
|
+
|
|
53
58
|
export { MessageTree, Params, TranslateOptions, Translator, createFormatters, defineCatalog };
|
|
54
59
|
export type { Catalog, DateStyle, FormatterOptions, Formatters, KeysOf };
|
package/dist/core/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { b as alternatesOf, c as canonicalCase, i as inUserZone, a as isExcluded,
|
|
1
|
+
export { b as alternatesOf, c as canonicalCase, d as cleanPath, i as inUserZone, a as isExcluded, e as localeFromPath, l as localizePath, n as negotiateLocale, o as ogLocale, f as regionalLanguage, r as resolveConfig, s as stripLocale } from '../shared/ginko-i18n.0DBFKY5Y.mjs';
|
|
2
2
|
|
|
3
3
|
const cache = /* @__PURE__ */ new Map();
|
|
4
4
|
function cached(key, make) {
|
|
@@ -32,17 +32,18 @@ const RELATIVE_UNITS = [
|
|
|
32
32
|
];
|
|
33
33
|
function createFormatters(language, options = {}) {
|
|
34
34
|
const zone = options.timeZone ?? "UTC";
|
|
35
|
+
const dates = options.dateLanguage ?? language;
|
|
35
36
|
const zoneFor = (value) => isCalendarDate(value) ? "UTC" : zone;
|
|
36
37
|
return {
|
|
37
38
|
/** `8. Oktober 2026`. A style or Intl options; a calendar date (`2026-10-08`) never shifts a day. */
|
|
38
|
-
date: (value, options2 = "long") => spaces(dateFormat(
|
|
39
|
+
date: (value, options2 = "long") => spaces(dateFormat(dates, zoneFor(value), options2).format(toDate(value))),
|
|
39
40
|
/**
|
|
40
41
|
* `20.–21. Juni 2026` / `20–21 June 2026`; one day stays one date. Same options as `date`.
|
|
41
42
|
* If either end is a calendar date, the whole range uses UTC so that date keeps its day.
|
|
42
43
|
*/
|
|
43
44
|
range: (from, to, options2 = "long") => {
|
|
44
45
|
const [start, end] = [toDate(from), toDate(to)];
|
|
45
|
-
const format = dateFormat(
|
|
46
|
+
const format = dateFormat(dates, isCalendarDate(from) || isCalendarDate(to) ? "UTC" : zone, options2);
|
|
46
47
|
if (start.getTime() === end.getTime()) return spaces(format.format(start));
|
|
47
48
|
return spaces(format.formatRange(start, end)).replace(/(\d\.?)\s?[–-]\s?(\d)/, "$1\u2013$2");
|
|
48
49
|
},
|
|
@@ -83,9 +84,9 @@ function formatDate(format, value, options) {
|
|
|
83
84
|
function show(value) {
|
|
84
85
|
return value == null ? "" : String(value);
|
|
85
86
|
}
|
|
86
|
-
function formatParts(message, params, language,
|
|
87
|
+
function formatParts(message, params, language, options = {}) {
|
|
87
88
|
if (typeof message === "string") return [message];
|
|
88
|
-
const format = createFormatters(language,
|
|
89
|
+
const format = createFormatters(language, options);
|
|
89
90
|
const out = [];
|
|
90
91
|
const walk = (nodes, pound, into) => {
|
|
91
92
|
for (const node of nodes) {
|
|
@@ -157,7 +158,7 @@ function createTranslator(options) {
|
|
|
157
158
|
options.onMissing?.(key);
|
|
158
159
|
return [key];
|
|
159
160
|
}
|
|
160
|
-
return formatParts(message, { ...options.params, ...params }, options.language, call?.timeZone ?? options.timeZone);
|
|
161
|
+
return formatParts(message, { ...options.params, ...params }, options.language, { timeZone: call?.timeZone ?? options.timeZone, dateLanguage: options.dateLanguage });
|
|
161
162
|
};
|
|
162
163
|
return {
|
|
163
164
|
parts,
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { defineNuxtModule, hasNuxtModule, createResolver, addTemplate, addTypeTemplate, addNitroPlugin, addPlugin, addImports, addComponent, addServerHandler, addServerImports, updateTemplates } from '@nuxt/kit';
|
|
4
4
|
import { M as MANIFEST_VERSION } from './shared/ginko-i18n.BloO2EKS.mjs';
|
|
5
5
|
import { m as mergeTrees, a as compileTree, s as sourceProblem } from './shared/ginko-i18n.BPkxIZl5.mjs';
|
|
6
|
-
import { r as resolveConfig, i as inUserZone, l as localizePath, a as isExcluded } from './shared/ginko-i18n.
|
|
6
|
+
import { r as resolveConfig, i as inUserZone, l as localizePath, a as isExcluded } from './shared/ginko-i18n.0DBFKY5Y.mjs';
|
|
7
7
|
import '@formatjs/icu-messageformat-parser';
|
|
8
8
|
|
|
9
9
|
const caches = (rule) => !!(rule.isr || rule.swr || rule.cache);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { computed, onMounted, ref } from "vue";
|
|
2
2
|
import { navigateTo, useNuxtApp, useRoute, useRouter } from "#imports";
|
|
3
3
|
import { config } from "#ginko-i18n/config.mjs";
|
|
4
|
-
import { createFormatters, createTranslator, inUserZone, localeFromPath, localizePath, negotiateLocale, stripLocale } from "@lupinum/ginko-i18n/core";
|
|
4
|
+
import { createFormatters, createTranslator, inUserZone, localeFromPath, localizePath, negotiateLocale, regionalLanguage, stripLocale } from "@lupinum/ginko-i18n/core";
|
|
5
5
|
import { typedTranslator } from "./types.js";
|
|
6
6
|
const c = config;
|
|
7
7
|
export function currentLocale(path, cfg = c, userLocale) {
|
|
@@ -40,9 +40,11 @@ export function useI18n(namespace) {
|
|
|
40
40
|
const zone = computed(() => inUserZone(route.path, c));
|
|
41
41
|
const locale = computed(() => state.fixedLocale ?? currentLocale(route.path, c, state.userLocale.value));
|
|
42
42
|
const current = computed(() => c.locales.find((l) => l.code === locale.value));
|
|
43
|
+
const dateLanguage = computed(() => zone.value ? regionalLanguage(current.value.language, state.browserLanguage.value) : current.value.language);
|
|
43
44
|
const translator = computed(() => createTranslator({
|
|
44
45
|
messages: state.messages[locale.value],
|
|
45
46
|
language: current.value.language,
|
|
47
|
+
dateLanguage: dateLanguage.value,
|
|
46
48
|
params: c.params,
|
|
47
49
|
timeZone: c.timeZone,
|
|
48
50
|
onMissing: import.meta.dev ? (key) => console.warn(`[ginko-i18n] Missing message "${key}" (${locale.value})`) : void 0
|
|
@@ -72,7 +74,7 @@ export function useI18n(namespace) {
|
|
|
72
74
|
}
|
|
73
75
|
await navigateTo(alternates.value.find((a) => a.code === code).path);
|
|
74
76
|
}
|
|
75
|
-
const formatters = () => createFormatters(current.value.language, { timeZone: c.timeZone });
|
|
77
|
+
const formatters = () => createFormatters(current.value.language, { timeZone: c.timeZone, dateLanguage: dateLanguage.value });
|
|
76
78
|
const format = {
|
|
77
79
|
date: (...args) => formatters().date(...args),
|
|
78
80
|
range: (...args) => formatters().range(...args),
|
package/dist/runtime/plugin.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { shallowReactive } from "vue";
|
|
2
|
-
import { defineNuxtPlugin, isPrerendered, loadPayload, useHead, useRequestEvent, useRoute, useRouter, useState } from "#imports";
|
|
2
|
+
import { addRouteMiddleware, defineNuxtPlugin, isPrerendered, loadPayload, navigateTo, useError, useHead, useRequestEvent, useRequestHeader, useRoute, useRouter, useState } from "#imports";
|
|
3
3
|
import { config } from "#ginko-i18n/config.mjs";
|
|
4
4
|
import { loaders } from "#ginko-i18n/loaders.mjs";
|
|
5
|
-
import { alternatesOf, inUserZone, localeFromPath, ogLocale } from "@lupinum/ginko-i18n/core";
|
|
5
|
+
import { alternatesOf, cleanPath, inUserZone, localeFromPath, ogLocale } from "@lupinum/ginko-i18n/core";
|
|
6
6
|
import { alternatesKey, createLocalePath, currentLocale, useI18n, writeLocaleCookie } from "./composables.js";
|
|
7
7
|
export default defineNuxtPlugin({
|
|
8
8
|
name: "ginko-i18n",
|
|
@@ -24,9 +24,31 @@ export default defineNuxtPlugin({
|
|
|
24
24
|
const fromEvent = import.meta.server ? useRequestEvent()?.context.locale : void 0;
|
|
25
25
|
return typeof fromEvent === "string" && c.locales.some((l) => l.code === fromEvent) ? fromEvent : c.defaultLocale;
|
|
26
26
|
});
|
|
27
|
+
const browserLanguage = useState("ginko-i18n:browser-language", () => {
|
|
28
|
+
if (import.meta.client) return navigator.language;
|
|
29
|
+
return useRequestHeader("accept-language")?.split(",")[0]?.split(";")[0]?.trim() || void 0;
|
|
30
|
+
});
|
|
27
31
|
let inlined = false;
|
|
28
32
|
const resolvers = [];
|
|
29
33
|
const router = useRouter();
|
|
34
|
+
const decoded = (path) => {
|
|
35
|
+
try {
|
|
36
|
+
return decodeURI(path);
|
|
37
|
+
} catch {
|
|
38
|
+
return path;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const canonicalPath = (to) => {
|
|
42
|
+
const own = to.name && to.matched.length ? router.resolve({ name: to.name, params: to.params }).path : to.path;
|
|
43
|
+
const path = decoded(own) === decoded(to.path) ? to.path : own;
|
|
44
|
+
return cleanPath(path, c) ?? path;
|
|
45
|
+
};
|
|
46
|
+
const same = (path) => decoded(path).replace(/(.)\/$/, "$1").toLowerCase();
|
|
47
|
+
addRouteMiddleware("ginko-i18n:canonical", (to) => {
|
|
48
|
+
const canonical = canonicalPath(to);
|
|
49
|
+
if (canonical !== to.path && same(canonical) === same(to.path))
|
|
50
|
+
return navigateTo({ path: canonical, query: to.query, hash: to.hash }, { redirectCode: 301, replace: true });
|
|
51
|
+
}, { global: true });
|
|
30
52
|
router.beforeResolve(async (to) => {
|
|
31
53
|
const zone = inUserZone(to.path, c);
|
|
32
54
|
const code = zone ? userLocale.value : localeFromPath(to.path, c) ?? c.defaultLocale;
|
|
@@ -78,26 +100,28 @@ export default defineNuxtPlugin({
|
|
|
78
100
|
fixedLocale = fromProp ?? currentLocale(new URL(url, "http://x").pathname, c, userLocale.value);
|
|
79
101
|
}
|
|
80
102
|
if (fixedLocale) await load(fixedLocale);
|
|
81
|
-
nuxtApp.provide("ginkoI18n", { messages, load, fixedLocale, resolvers, userLocale });
|
|
103
|
+
nuxtApp.provide("ginkoI18n", { messages, load, fixedLocale, resolvers, userLocale, browserLanguage });
|
|
82
104
|
if (c.seo) {
|
|
83
105
|
nuxtApp.hook("app:created", () => {
|
|
84
106
|
const route = useRoute();
|
|
85
107
|
const i18n = useI18n();
|
|
108
|
+
const error = useError();
|
|
86
109
|
useHead(() => {
|
|
87
110
|
const code = currentLocale(route.path, c, userLocale.value);
|
|
88
111
|
const locale = c.locales.find((l) => l.code === code);
|
|
112
|
+
if (error.value) return { htmlAttrs: { lang: locale.language, dir: locale.dir }, meta: [{ name: "robots", content: "noindex" }] };
|
|
89
113
|
if (inUserZone(route.path, c)) return { htmlAttrs: { lang: locale.language, dir: locale.dir } };
|
|
90
114
|
const translated = i18n.alternates.value.filter((a) => a.translated);
|
|
91
115
|
const url = (path) => `${c.baseUrl}${c.base}${path}`;
|
|
92
116
|
return {
|
|
93
117
|
htmlAttrs: { lang: locale.language, dir: locale.dir },
|
|
94
118
|
link: [
|
|
95
|
-
{ rel: "canonical", href: url(route
|
|
119
|
+
{ rel: "canonical", href: url(canonicalPath(route)) },
|
|
96
120
|
...alternatesOf(Object.fromEntries(translated.map((a) => [a.code, a.path])), c).map((a) => ({ rel: "alternate", hreflang: a.hreflang, href: url(a.path) }))
|
|
97
121
|
],
|
|
98
122
|
meta: [
|
|
99
123
|
// Share links point at the canonical address, like search engines.
|
|
100
|
-
{ property: "og:url", content: url(route
|
|
124
|
+
{ property: "og:url", content: url(canonicalPath(route)) },
|
|
101
125
|
{ property: "og:locale", content: ogLocale(locale.language) },
|
|
102
126
|
...translated.filter((a) => !a.current).map((a) => ({ property: "og:locale:alternate", content: ogLocale(a.language) }))
|
|
103
127
|
]
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { defineEventHandler, getRequestHeader, getRequestURL, sendRedirect } from "nuxt/server";
|
|
2
2
|
import { config } from "#ginko-i18n/config.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { cleanPath, inUserZone, isExcluded, localizePath } from "@lupinum/ginko-i18n/core";
|
|
4
4
|
import { preferredLocale, requestLocale } from "./locale.js";
|
|
5
5
|
const c = config;
|
|
6
6
|
export default defineEventHandler((event) => {
|
|
7
7
|
const url = getRequestURL(event);
|
|
8
8
|
if (c.base && url.pathname !== c.base && !url.pathname.startsWith(`${c.base}/`)) return;
|
|
9
9
|
const path = url.pathname.slice(c.base.length) || "/";
|
|
10
|
+
const requested = ("path" in event && typeof event.path === "string" ? event.path : url.pathname).split("?")[0] ?? "";
|
|
11
|
+
if (!isExcluded(path, c)) {
|
|
12
|
+
const clean = cleanPath(/^[/\\]{2}/.test(requested) && !c.base ? requested : path, c);
|
|
13
|
+
if (clean) return sendRedirect(event, `${c.base}${clean}${url.search}`, 301);
|
|
14
|
+
}
|
|
10
15
|
if (inUserZone(path, c)) {
|
|
11
16
|
event.res.headers.append("vary", "accept-language, cookie");
|
|
12
17
|
event.context.locale = requestLocale(event, path);
|
|
13
18
|
return;
|
|
14
19
|
}
|
|
15
|
-
const canonical = canonicalCase(path, c);
|
|
16
|
-
if (canonical) return sendRedirect(event, `${c.base}${canonical}${url.search}`, 301);
|
|
17
20
|
if (path === "/" && c.strategy === "prefix" && !getRequestHeader(event, "x-nitro-prerender")) {
|
|
18
21
|
event.res.headers.append("vary", "accept-language, cookie");
|
|
19
22
|
return sendRedirect(event, c.base + localizePath("/", preferredLocale(event) ?? c.defaultLocale, c) + url.search, 302);
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -84,6 +84,11 @@ export interface I18nState {
|
|
|
84
84
|
* cookie or Accept-Language, read from the payload on the client, changed only by `switchLocale`.
|
|
85
85
|
*/
|
|
86
86
|
userLocale: Ref<string>;
|
|
87
|
+
/**
|
|
88
|
+
* The person's first browser language (`useState('ginko-i18n:browser-language')`): Accept-Language on the server,
|
|
89
|
+
* from the payload on the client. User-locale zones format dates in its region (`regionalLanguage`).
|
|
90
|
+
*/
|
|
91
|
+
browserLanguage: Ref<string | undefined>;
|
|
87
92
|
/** Set while rendering an island: its page's locale, since islands have no route of their own. */
|
|
88
93
|
fixedLocale?: string;
|
|
89
94
|
/**
|
|
@@ -18,6 +18,17 @@ function nativeName(language) {
|
|
|
18
18
|
return language;
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
function regionalLanguage(language, browser) {
|
|
22
|
+
if (!browser) return language;
|
|
23
|
+
try {
|
|
24
|
+
const own = new Intl.Locale(language);
|
|
25
|
+
const theirs = new Intl.Locale(browser);
|
|
26
|
+
const same = theirs.language === own.language && (!own.script || own.script === theirs.script);
|
|
27
|
+
return !own.region && theirs.region && same ? theirs.baseName : language;
|
|
28
|
+
} catch {
|
|
29
|
+
return language;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
21
32
|
function ogLocale(language) {
|
|
22
33
|
const tag = new Intl.Locale(language);
|
|
23
34
|
return tag.region ? `${tag.language}_${tag.region}` : tag.language;
|
|
@@ -84,6 +95,12 @@ function canonicalCase(path, config) {
|
|
|
84
95
|
if (!locale || locale.code === segment) return void 0;
|
|
85
96
|
return `/${locale.code}${path.slice(segment.length + 1)}`;
|
|
86
97
|
}
|
|
98
|
+
function cleanPath(path, config) {
|
|
99
|
+
let clean = path.replace(/[\\/]{2,}/g, "/");
|
|
100
|
+
if (clean.length > 1 && clean.endsWith("/")) clean = clean.slice(0, -1);
|
|
101
|
+
clean = canonicalCase(clean, config) ?? clean;
|
|
102
|
+
return clean === path ? void 0 : clean;
|
|
103
|
+
}
|
|
87
104
|
function localeFromPath(path, config) {
|
|
88
105
|
if (isExcluded(path, config) || inUserZone(path, config)) return void 0;
|
|
89
106
|
const segment = firstSegment(path);
|
|
@@ -127,4 +144,4 @@ function negotiateLocale(header, config) {
|
|
|
127
144
|
return void 0;
|
|
128
145
|
}
|
|
129
146
|
|
|
130
|
-
export { LOCALE_CODE as L, isExcluded as a, alternatesOf as b, canonicalCase as c,
|
|
147
|
+
export { LOCALE_CODE as L, isExcluded as a, alternatesOf as b, canonicalCase as c, cleanPath as d, localeFromPath as e, regionalLanguage as f, inUserZone as i, localizePath as l, negotiateLocale as n, ogLocale as o, resolveConfig as r, stripLocale as s };
|
|
@@ -31,6 +31,8 @@ declare function createTranslator(options: {
|
|
|
31
31
|
language: string;
|
|
32
32
|
/** Zone for dates that are not calendar dates (`YYYY-MM-DD` is always UTC). Default `UTC`. */
|
|
33
33
|
timeZone?: string;
|
|
34
|
+
/** The tag for dates in messages when it differs from `language` (see `FormatterOptions.dateLanguage`). */
|
|
35
|
+
dateLanguage?: string;
|
|
34
36
|
params?: Params;
|
|
35
37
|
onMissing?: (key: string) => void;
|
|
36
38
|
}): Translator;
|
|
@@ -22,6 +22,12 @@ interface LocaleConfig {
|
|
|
22
22
|
/** Path patterns whose locale comes from the user (cookie, then Accept-Language), not from the URL. */
|
|
23
23
|
userLocale: string[];
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* The tag that formats dates for `language` in a user-locale zone, where `browser` is the person's first
|
|
27
|
+
* browser language (Accept-Language, `navigator.language`): its regional variant when it is the same language and
|
|
28
|
+
* `language` names no region itself (`en` read in an `en-GB` browser formats as `en-GB`: 25/10/2026), else `language`.
|
|
29
|
+
*/
|
|
30
|
+
declare function regionalLanguage(language: string, browser: string | undefined): string;
|
|
25
31
|
/** `og:locale` wants `language_TERRITORY`: `de-AT` → `de_AT`, `zh-Hant-TW` → `zh_TW`, `zh-Hant` → `zh`. */
|
|
26
32
|
declare function ogLocale(language: string): string;
|
|
27
33
|
declare function resolveConfig(input: {
|
|
@@ -53,6 +59,13 @@ declare function inUserZone(path: string, config: LocaleConfig): boolean;
|
|
|
53
59
|
* The router matches case-insensitively, so a wrong-case URL would otherwise render in the default locale.
|
|
54
60
|
*/
|
|
55
61
|
declare function canonicalCase(path: string, config: LocaleConfig): string | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* The one form of a path: no repeated slashes, no trailing slash (except `/`), the locale code in its
|
|
64
|
+
* own case. `undefined` when the path already has it. Browsers read `\` as `/` in http(s) URLs, so a
|
|
65
|
+
* run of mixed `/` and `\` collapses to one `/`: `//evil.com` and `/\evil.com/` become `/evil.com`,
|
|
66
|
+
* and a redirect to the result never leaves the site.
|
|
67
|
+
*/
|
|
68
|
+
declare function cleanPath(path: string, config: LocaleConfig): string | undefined;
|
|
56
69
|
/** The locale a path belongs to, or `undefined` when the path has no locale (`prefix` root, excluded paths, user-locale zones). */
|
|
57
70
|
declare function localeFromPath(path: string, config: LocaleConfig): string | undefined;
|
|
58
71
|
declare function stripLocale(path: string, config: LocaleConfig): string;
|
|
@@ -64,5 +77,5 @@ declare function localizePath(path: string, locale: string, config: LocaleConfig
|
|
|
64
77
|
*/
|
|
65
78
|
declare function negotiateLocale(header: string | null | undefined, config: LocaleConfig): string | undefined;
|
|
66
79
|
|
|
67
|
-
export { alternatesOf as c, canonicalCase as d,
|
|
80
|
+
export { alternatesOf as c, canonicalCase as d, cleanPath as e, isExcluded as f, localizePath as g, resolveConfig as h, inUserZone as i, localeFromPath as l, negotiateLocale as n, ogLocale as o, regionalLanguage as r, stripLocale as s };
|
|
68
81
|
export type { LocaleInput as L, Strategy as S, LocaleConfig as a, LocaleInfo as b };
|