@lupinum/ginko-i18n 0.4.0 → 0.6.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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # @lupinum/ginko-i18n
2
2
 
3
+ ## 0.6.0
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.5.0
8
+
9
+ ### Patch Changes
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)! - Add the `dateLanguage` option to `createFormatters` and `createTranslator`: dates in another tag (`en-GB`) while numbers and words stay in `language`.
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 `//blog` on Nuxt 4: a doubled leading slash now answers with a 301 to `/blog` there too, instead of showing the home page.
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 error pages: they get no canonical or hreflang and are noindex.
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)! - Fix page addresses in another case: `/Blog` now answers with a 301 to `/blog`, and the canonical link uses that spelling.
18
+
19
+ - [#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.
20
+
21
+ - [#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.
22
+
3
23
  ## 0.4.0
4
24
 
5
25
  ### 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. |
@@ -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.C1vsnUyB.mjs';
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"];
@@ -1,34 +1,16 @@
1
- import { P as Params, T as TranslateOptions, a as Translator, M as MessageTree } from '../shared/ginko-i18n.Fn02YzGM.mjs';
2
- export { b as Part, c as createTranslator, p as partsToString } from '../shared/ginko-i18n.Fn02YzGM.mjs';
3
- export { a as LocaleConfig, b as LocaleInfo, L as LocaleInput, S as Strategy, c as alternatesOf, d as canonicalCase, i as inUserZone, e as isExcluded, l as localeFromPath, f as localizePath, n as negotiateLocale, o as ogLocale, r as resolveConfig, s as stripLocale } from '../shared/ginko-i18n.C3yTBMSg.mjs';
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 };
@@ -1,4 +1,4 @@
1
- export { b as alternatesOf, c as canonicalCase, i as inUserZone, a as isExcluded, d as localeFromPath, l as localizePath, n as negotiateLocale, o as ogLocale, r as resolveConfig, s as stripLocale } from '../shared/ginko-i18n.C1vsnUyB.mjs';
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(language, zoneFor(value), options2).format(toDate(value))),
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(language, isCalendarDate(from) || isCalendarDate(to) ? "UTC" : zone, options2);
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, timeZone = "UTC") {
87
+ function formatParts(message, params, language, options = {}) {
87
88
  if (typeof message === "string") return [message];
88
- const format = createFormatters(language, { timeZone });
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
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^4.6.0 || ^5.0.0-0"
6
6
  },
7
- "version": "0.4.0",
7
+ "version": "0.6.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
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.C1vsnUyB.mjs';
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),
@@ -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.path) },
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.path) },
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 { canonicalCase, inUserZone, localizePath } from "@lupinum/ginko-i18n/core";
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);
@@ -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, localeFromPath as d, inUserZone as i, localizePath as l, negotiateLocale as n, ogLocale as o, resolveConfig as r, stripLocale as s };
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, isExcluded as e, localizePath as f, inUserZone as i, localeFromPath as l, negotiateLocale as n, ogLocale as o, resolveConfig as r, stripLocale as s };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lupinum/ginko-i18n",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Nuxt i18n with the URL as the only locale source.",
6
6
  "license": "MIT",