@i18n-micro/core 1.0.27 → 1.1.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.
@@ -5,16 +5,18 @@ interface NavigateToInterface {
5
5
  redirectCode?: number;
6
6
  external?: boolean;
7
7
  }
8
+ export type GetDefaultLocale = () => string | null | undefined;
8
9
  export declare class RouteService {
9
10
  private i18nConfig;
10
11
  private router;
11
12
  private hashLocaleDefault;
12
13
  private noPrefixDefault;
13
14
  private navigateTo;
14
- private setCookie;
15
- private cookieLocaleDefault;
16
- private cookieLocaleName;
17
- constructor(i18nConfig: ModuleOptionsExtend, router: Router, hashLocaleDefault: string | null | undefined, noPrefixDefault: string | null | undefined, navigateTo: (to: RouteLocationRaw | undefined | null, options?: NavigateToInterface) => Promise<void | NavigationFailure | false> | false | void | RouteLocationRaw, setCookie: (name: string, value: string) => void, cookieLocaleDefault?: string | null | undefined, cookieLocaleName?: string | null | undefined);
15
+ /** Optional getter for current locale (used by hash/noPrefix when state changes). Plugin provides () => localeState.value */
16
+ private getDefaultLocale?;
17
+ constructor(i18nConfig: ModuleOptionsExtend, router: Router, hashLocaleDefault: string | null | undefined, noPrefixDefault: string | null | undefined, navigateTo: (to: RouteLocationRaw | undefined | null, options?: NavigateToInterface) => Promise<void | NavigationFailure | false> | false | void | RouteLocationRaw,
18
+ /** Optional getter for current locale (used by hash/noPrefix when state changes). Plugin provides () => localeState.value */
19
+ getDefaultLocale?: GetDefaultLocale | undefined);
18
20
  /**
19
21
  * Extracts locale from URL path by checking the first path segment
20
22
  * @param path - URL path (e.g., '/ru/sdfsdf' or '/en/about')
@@ -31,7 +33,6 @@ export declare class RouteService {
31
33
  private handlePrefixStrategy;
32
34
  private createLocalizedRoute;
33
35
  getLocalizedRoute(to: RouteLocationResolvedGeneric | RouteLocationAsPathGeneric | RouteLocationNamedRaw | string, route: RouteLocationNormalizedLoaded, locale?: string): RouteLocationResolved;
34
- updateCookies(toLocale: string): void;
35
36
  getCurrentRoute(): RouteLocationNormalizedLoaded;
36
37
  private resolveRouteWithStrategy;
37
38
  switchLocaleLogic(toLocale: string, i18nRouteParams: I18nRouteParams, to?: RouteLocationNamedRaw | RouteLocationResolvedGeneric | string): string | false | void | import('vue-router').RouteLocationAsRelativeGeneric | RouteLocationAsPathGeneric | Promise<false | void | NavigationFailure>;
@@ -1,24 +1,23 @@
1
1
  import { Translations } from '@i18n-micro/types';
2
- export interface RefLike<T> {
3
- value: T;
2
+ /**
3
+ * Bare Metal: Простое хранилище переводов без Ref, useState, devalue.
4
+ * Ключ Map: locale (general) или locale:routeName (page-specific).
5
+ */
6
+ export interface TranslationStorage {
7
+ translations: Map<string, Translations>;
4
8
  }
5
- export interface TranslationCache {
6
- generalLocaleCache: RefLike<Record<string, Translations>> | Record<string, Translations>;
7
- routeLocaleCache: RefLike<Record<string, Translations>> | Record<string, Translations>;
8
- dynamicTranslationsCaches: RefLike<Record<string, Translations>[]> | Record<string, Translations>[];
9
- serverTranslationCache: RefLike<Record<string, Map<string, Translations | unknown>>> | Record<string, Map<string, Translations | unknown>>;
10
- }
11
- export declare function useTranslationHelper(caches?: TranslationCache): {
9
+ export declare function useTranslationHelper(storage?: TranslationStorage): {
12
10
  hasCache(locale: string, page: string): boolean;
13
- getCache(locale: string, routeName: string): Map<string, unknown> | undefined;
14
- setCache(locale: string, routeName: string, cache: Map<string, Translations | unknown>): void;
15
- mergeTranslation(locale: string, routeName: string, newTranslations: Translations, force?: boolean): void;
16
- mergeGlobalTranslation(locale: string, newTranslations: Translations, force?: boolean): void;
11
+ getCache(locale: string, routeName: string): Translations | undefined;
12
+ setCache(_locale: string, _routeName: string, _cache: Map<string, unknown>): void;
13
+ hasTranslation(locale: string, key: string): boolean;
17
14
  hasGeneralTranslation(locale: string): boolean;
18
15
  hasPageTranslation(locale: string, routeName: string): boolean;
19
- hasTranslation: (locale: string, key: import('@i18n-micro/types').TranslationKey) => boolean;
20
- getTranslation: <T = unknown>(locale: string, routeName: string, key: import('@i18n-micro/types').TranslationKey) => T | null;
21
- loadPageTranslations(locale: string, routeName: string, translations: Translations): Promise<void>;
22
- loadTranslations(locale: string, translations: Translations): Promise<void>;
16
+ getTranslation<T = unknown>(locale: string, routeName: string, key: string): T | null;
17
+ loadTranslations(locale: string, data: Translations): void;
18
+ setTranslations(locale: string, data: Translations): void;
19
+ loadPageTranslations(locale: string, routeName: string, data: Translations): void;
20
+ mergeTranslation(locale: string, routeName: string, newTranslations: Translations, _force?: boolean): void;
21
+ mergeGlobalTranslation(locale: string, newTranslations: Translations, _force?: boolean): void;
23
22
  clearCache(): void;
24
23
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i18n-micro/core",
3
- "version": "1.0.27",
3
+ "version": "1.1.0",
4
4
  "description": "",
5
5
  "repository": "s00d/nuxt-i18n-micro",
6
6
  "license": "MIT",
@@ -22,11 +22,11 @@
22
22
  },
23
23
  "keywords": [],
24
24
  "dependencies": {
25
- "@i18n-micro/types": "1.0.15"
25
+ "@i18n-micro/types": "1.1.0"
26
26
  },
27
27
  "devDependencies": {
28
- "vite": "^5.0.0",
29
- "vite-plugin-dts": "^4.3.0"
28
+ "vite": "^7.3.1",
29
+ "vite-plugin-dts": "^4.5.4"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "vite build",
package/src/base.ts ADDED
@@ -0,0 +1,250 @@
1
+ import { useTranslationHelper, type TranslationStorage } from './translation'
2
+ import { FormatService } from './format-service'
3
+ import { interpolate, defaultPlural } from './helpers'
4
+ import type {
5
+ Translations,
6
+ Params,
7
+ PluralFunc,
8
+ Getter,
9
+ CleanTranslation,
10
+ TranslationKey,
11
+ MissingHandler,
12
+ } from '@i18n-micro/types'
13
+
14
+ export interface BaseI18nOptions {
15
+ storage?: TranslationStorage
16
+ plural?: PluralFunc
17
+ missingWarn?: boolean
18
+ missingHandler?: (locale: string, key: string, routeName: string) => void
19
+ // Optional hooks for Nuxt runtime specific features
20
+ getPreviousPageInfo?: () => { locale: string, routeName: string } | null
21
+ getCustomMissingHandler?: () => MissingHandler | null
22
+ enablePreviousPageFallback?: boolean
23
+ }
24
+
25
+ /**
26
+ * Abstract base class for i18n adapters
27
+ *
28
+ * Contains all common translation logic (t, ts, tc, tn, td, tdr, has).
29
+ * Adapters must implement abstract methods to provide current state (locale, fallbackLocale, route).
30
+ */
31
+ export abstract class BaseI18n {
32
+ // Public fields (made public to allow type export in Nuxt plugins)
33
+ public helper: ReturnType<typeof useTranslationHelper>
34
+ public formatter = new FormatService()
35
+ public pluralFunc: PluralFunc
36
+ public missingWarn: boolean
37
+ public missingHandler?: (locale: string, key: string, routeName: string) => void
38
+ // Optional hooks for Nuxt runtime specific features
39
+ public getPreviousPageInfo?: () => { locale: string, routeName: string } | null
40
+ public getCustomMissingHandler?: () => MissingHandler | null
41
+ public enablePreviousPageFallback: boolean
42
+
43
+ constructor(options: BaseI18nOptions = {}) {
44
+ this.helper = useTranslationHelper(options.storage)
45
+ this.formatter = new FormatService()
46
+ this.pluralFunc = options.plural || defaultPlural
47
+ this.missingWarn = options.missingWarn ?? true
48
+ this.missingHandler = options.missingHandler
49
+ this.getPreviousPageInfo = options.getPreviousPageInfo
50
+ this.getCustomMissingHandler = options.getCustomMissingHandler
51
+ this.enablePreviousPageFallback = options.enablePreviousPageFallback ?? false
52
+ }
53
+
54
+ // --- Abstract methods (must be implemented by subclasses) ---
55
+
56
+ /**
57
+ * Get current locale
58
+ */
59
+ public abstract getLocale(): string
60
+
61
+ /**
62
+ * Get fallback locale
63
+ */
64
+ public abstract getFallbackLocale(): string
65
+
66
+ /**
67
+ * Get current route name
68
+ */
69
+ public abstract getRoute(): string
70
+
71
+ // --- Public methods (implemented in base class) ---
72
+
73
+ /**
74
+ * Get translation for a key
75
+ * Based on logic from src/runtime/plugins/01.plugin.ts
76
+ */
77
+ public t(
78
+ key: TranslationKey,
79
+ params?: Params,
80
+ defaultValue?: string | null,
81
+ routeName?: string,
82
+ ): CleanTranslation {
83
+ if (!key) return ''
84
+
85
+ // Use abstract getters to get current state
86
+ const locale = this.getLocale()
87
+ const route = routeName || this.getRoute()
88
+
89
+ // 1. Try to find translation in current locale
90
+ // Note: In Nuxt runtime, server already merges global translations, so we don't need explicit fallback
91
+ let value = this.helper.getTranslation<string>(locale, route, key)
92
+
93
+ // 2. If translation not found and there are saved previous translations, use them (only if enabled)
94
+ if (!value && this.enablePreviousPageFallback && this.getPreviousPageInfo) {
95
+ const prev = this.getPreviousPageInfo()
96
+ if (prev) {
97
+ const prevValue = this.helper.getTranslation<string>(prev.locale, prev.routeName, key)
98
+ if (prevValue) {
99
+ value = prevValue
100
+ if (process.env.NODE_ENV !== 'production') {
101
+ console.log(`Using fallback translation from previous route: ${prev.routeName} -> ${key}`)
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ // 3. Fallback to fallbackLocale if not found and different (for non-Nuxt adapters)
108
+ if (!value) {
109
+ const fallbackLocale = this.getFallbackLocale()
110
+ if (locale !== fallbackLocale) {
111
+ value = this.helper.getTranslation<string>(fallbackLocale, route, key)
112
+ }
113
+ }
114
+
115
+ // 4. Handle missing
116
+ if (!value) {
117
+ // Call custom handler if set (Nuxt runtime), otherwise use instance handler
118
+ const customHandler = this.getCustomMissingHandler?.()
119
+ if (customHandler) {
120
+ customHandler(locale, key, route)
121
+ }
122
+ else if (this.missingHandler) {
123
+ this.missingHandler(locale, key as string, route)
124
+ }
125
+ else if (this.missingWarn) {
126
+ const isDev = process.env.NODE_ENV !== 'production'
127
+ const isClient = typeof window !== 'undefined'
128
+ if (isDev && isClient) {
129
+ console.warn(`Not found '${key}' key in '${locale}' locale messages for route '${route}'.`)
130
+ }
131
+ }
132
+ value = defaultValue === undefined ? key : (defaultValue || key)
133
+ }
134
+
135
+ // 5. Interpolate
136
+ return typeof value === 'string' && params ? interpolate(value, params) : value as CleanTranslation
137
+ }
138
+
139
+ /**
140
+ * Get translation as string
141
+ */
142
+ public ts(
143
+ key: TranslationKey,
144
+ params?: Params,
145
+ defaultValue?: string,
146
+ routeName?: string,
147
+ ): string {
148
+ const value = this.t(key, params, defaultValue, routeName)
149
+ return value?.toString() ?? defaultValue ?? key
150
+ }
151
+
152
+ /**
153
+ * Plural translation
154
+ */
155
+ public tc(key: TranslationKey, count: number | Params, defaultValue?: string): string {
156
+ const { count: countValue, ...params } = typeof count === 'number' ? { count } : count
157
+
158
+ if (countValue === undefined) {
159
+ return defaultValue ?? key
160
+ }
161
+
162
+ // Getter passed to plural function
163
+ const getter: Getter = (k: TranslationKey, p?: Params, dv?: string) => {
164
+ return this.t(k, p, dv)
165
+ }
166
+
167
+ const result = this.pluralFunc(
168
+ key,
169
+ Number.parseInt(countValue.toString()),
170
+ params,
171
+ this.getLocale(),
172
+ getter,
173
+ )
174
+
175
+ return result ?? defaultValue ?? key
176
+ }
177
+
178
+ /**
179
+ * Format number
180
+ */
181
+ public tn(value: number, options?: Intl.NumberFormatOptions): string {
182
+ return this.formatter.formatNumber(value, this.getLocale(), options)
183
+ }
184
+
185
+ /**
186
+ * Format date
187
+ */
188
+ public td(value: Date | number | string, options?: Intl.DateTimeFormatOptions): string {
189
+ return this.formatter.formatDate(value, this.getLocale(), options)
190
+ }
191
+
192
+ /**
193
+ * Format relative time
194
+ */
195
+ public tdr(value: Date | number | string, options?: Intl.RelativeTimeFormatOptions): string {
196
+ return this.formatter.formatRelativeTime(value, this.getLocale(), options)
197
+ }
198
+
199
+ /**
200
+ * Check if translation exists
201
+ * Based on logic from src/runtime/plugins/01.plugin.ts
202
+ */
203
+ public has(key: TranslationKey, routeName?: string): boolean {
204
+ const route = routeName || this.getRoute()
205
+ const locale = this.getLocale()
206
+
207
+ // Check only through getTranslation (as in plugin)
208
+ return !!this.helper.getTranslation(locale, route, key)
209
+ }
210
+
211
+ /**
212
+ * Clear cache
213
+ */
214
+ public clearCache(): void {
215
+ this.helper.clearCache()
216
+ }
217
+
218
+ // --- Public methods (for subclasses to use) ---
219
+
220
+ /**
221
+ * Core translation loading logic (without reactivity)
222
+ * Subclasses can override addTranslations/addRouteTranslations to add reactivity
223
+ */
224
+ public loadTranslationsCore(locale: string, translations: Translations, merge: boolean): void {
225
+ if (merge) {
226
+ this.helper.mergeGlobalTranslation(locale, translations, true)
227
+ }
228
+ else {
229
+ this.helper.setTranslations(locale, translations)
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Core route translation loading logic (without reactivity)
235
+ * Subclasses can override addRouteTranslations to add reactivity
236
+ */
237
+ public loadRouteTranslationsCore(
238
+ locale: string,
239
+ routeName: string,
240
+ translations: Translations,
241
+ merge: boolean,
242
+ ): void {
243
+ if (merge) {
244
+ this.helper.mergeTranslation(locale, routeName, translations, true)
245
+ }
246
+ else {
247
+ this.helper.loadPageTranslations(locale, routeName, translations)
248
+ }
249
+ }
250
+ }
package/src/helpers.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import type { Params, Strategies, PluralFunc, Getter, TranslationKey } from '@i18n-micro/types'
2
2
 
3
- export function interpolate(template: string, params: Params): string {
4
- let result = template
3
+ const RE_TOKEN = /\{(\w+)\}/g
5
4
 
6
- for (const key in params) {
7
- result = result.split(`{${key}}`).join(String(params[key]))
8
- }
5
+ export function interpolate(template: string, params: Params): string {
6
+ if (!params) return template
9
7
 
10
- return result
8
+ return template.replace(RE_TOKEN, (_, key) => {
9
+ const value = params[key]
10
+ return value !== undefined ? String(value) : `{${key}}`
11
+ })
11
12
  }
12
13
 
13
14
  export function withPrefixStrategy(strategy: Strategies) {
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { useTranslationHelper, type TranslationCache } from './translation'
1
+ import { useTranslationHelper, type TranslationStorage } from './translation'
2
2
  import { RouteService } from './route-service'
3
3
  import { FormatService } from './format-service'
4
4
  import { interpolate, withPrefixStrategy, isNoPrefixStrategy, isPrefixStrategy, isPrefixExceptDefaultStrategy, isPrefixAndDefaultStrategy, defaultPlural } from './helpers'
5
+ import { BaseI18n, type BaseI18nOptions } from './base'
5
6
 
6
7
  export {
7
8
  useTranslationHelper,
@@ -14,5 +15,7 @@ export {
14
15
  defaultPlural,
15
16
  RouteService,
16
17
  FormatService,
17
- type TranslationCache,
18
+ BaseI18n,
19
+ type TranslationStorage,
20
+ type BaseI18nOptions,
18
21
  }
@@ -13,7 +13,7 @@ import type {
13
13
  Router,
14
14
  } from 'vue-router'
15
15
  import type { I18nRouteParams, Locale, ModuleOptionsExtend } from '@i18n-micro/types'
16
- import { isNoPrefixStrategy, withPrefixStrategy } from './helpers'
16
+ import { isNoPrefixStrategy, withPrefixStrategy, isPrefixAndDefaultStrategy, isPrefixExceptDefaultStrategy } from './helpers'
17
17
 
18
18
  interface NavigateToInterface {
19
19
  replace?: boolean
@@ -21,6 +21,8 @@ interface NavigateToInterface {
21
21
  external?: boolean
22
22
  }
23
23
 
24
+ export type GetDefaultLocale = () => string | null | undefined
25
+
24
26
  export class RouteService {
25
27
  constructor(
26
28
  private i18nConfig: ModuleOptionsExtend,
@@ -28,9 +30,8 @@ export class RouteService {
28
30
  private hashLocaleDefault: string | null | undefined,
29
31
  private noPrefixDefault: string | null | undefined,
30
32
  private navigateTo: (to: RouteLocationRaw | undefined | null, options?: NavigateToInterface) => Promise<void | NavigationFailure | false> | false | void | RouteLocationRaw,
31
- private setCookie: (name: string, value: string) => void,
32
- private cookieLocaleDefault: string | null | undefined = null,
33
- private cookieLocaleName: string | null | undefined = null,
33
+ /** Optional getter for current locale (used by hash/noPrefix when state changes). Plugin provides () => localeState.value */
34
+ private getDefaultLocale?: GetDefaultLocale,
34
35
  ) {}
35
36
 
36
37
  /**
@@ -75,14 +76,25 @@ export class RouteService {
75
76
  getCurrentLocale(route?: RouteLocationNormalizedLoaded | RouteLocationResolvedGeneric): string {
76
77
  route = route ?? this.router.currentRoute.value
77
78
 
78
- // 1. Check hashMode
79
- if (this.i18nConfig.hashMode && this.hashLocaleDefault) {
80
- return this.hashLocaleDefault
79
+ // 1. Check hashMode (getter overrides initial value when state changes)
80
+ if (this.i18nConfig.hashMode) {
81
+ const fromGetter = this.getDefaultLocale?.()
82
+ if (fromGetter) return fromGetter
83
+ if (this.hashLocaleDefault) return this.hashLocaleDefault
81
84
  }
82
85
 
83
86
  // 2. Check noPrefix strategy
84
- if (isNoPrefixStrategy(this.i18nConfig.strategy!) && this.noPrefixDefault) {
85
- return this.noPrefixDefault
87
+ if (isNoPrefixStrategy(this.i18nConfig.strategy!)) {
88
+ const fromGetter = this.getDefaultLocale?.()
89
+ if (fromGetter) return fromGetter
90
+ if (this.noPrefixDefault) return this.noPrefixDefault
91
+ }
92
+
93
+ const path = route.path || route.fullPath || ''
94
+ // 2b. prefix_and_default at /: useState/cookie can override — / is valid for any locale
95
+ if (isPrefixAndDefaultStrategy(this.i18nConfig.strategy!) && (path === '/' || path === '')) {
96
+ const fromGetter = this.getDefaultLocale?.()
97
+ if (fromGetter) return fromGetter
86
98
  }
87
99
 
88
100
  // 3. Check route.params.locale (for existing routes)
@@ -91,18 +103,21 @@ export class RouteService {
91
103
  }
92
104
 
93
105
  // 4. Extract locale from URL path (for non-existent routes)
94
- const path = route.path || route.fullPath || ''
95
106
  const localeFromPath = this.extractLocaleFromPath(path)
96
107
  if (localeFromPath) {
97
108
  return localeFromPath
98
109
  }
99
110
 
100
- // 5. Check cookie (if provided)
101
- if (this.cookieLocaleDefault) {
102
- return this.cookieLocaleDefault
111
+ // 5. For prefix_except_default: URL without locale prefix = defaultLocale
112
+ if (isPrefixExceptDefaultStrategy(this.i18nConfig.strategy!)) {
113
+ return (this.i18nConfig.defaultLocale || 'en').toString()
103
114
  }
104
115
 
105
- // 6. Return defaultLocale as fallback
116
+ // 6. Check getter (for no_prefix and other strategies)
117
+ const fromGetter = this.getDefaultLocale?.()
118
+ if (fromGetter) return fromGetter
119
+
120
+ // 7. Return defaultLocale as fallback
106
121
  return (this.i18nConfig.defaultLocale || 'en').toString()
107
122
  }
108
123
 
@@ -373,24 +388,6 @@ export class RouteService {
373
388
  return this.createLocalizedRoute(processedTo, route, currentLocale)
374
389
  }
375
390
 
376
- updateCookies(toLocale: string): void {
377
- if (this.i18nConfig.hashMode) {
378
- this.setCookie('hash-locale', toLocale)
379
- // useCookie('hash-locale').value = toLocale
380
- this.hashLocaleDefault = toLocale
381
- }
382
- if (isNoPrefixStrategy(this.i18nConfig.strategy!)) {
383
- this.setCookie('no-prefix-locale', toLocale)
384
- // useCookie('no-prefix-locale').value = toLocale
385
- this.noPrefixDefault = toLocale
386
- }
387
- // Update cookie for regular strategy (prefix or prefix_except_default)
388
- if (!this.i18nConfig.hashMode && !isNoPrefixStrategy(this.i18nConfig.strategy!) && this.cookieLocaleName) {
389
- this.setCookie(this.cookieLocaleName, toLocale)
390
- this.cookieLocaleDefault = toLocale
391
- }
392
- }
393
-
394
391
  getCurrentRoute(): RouteLocationNormalizedLoaded {
395
392
  return this.router.currentRoute.value
396
393
  }
@@ -422,7 +419,6 @@ export class RouteService {
422
419
  current = to ?? this.getCurrentRoute() as RouteLocationResolved
423
420
  }
424
421
 
425
- this.updateCookies(toLocale)
426
422
  const switchedRoute = this.switchLocaleRoute(fromLocale, toLocale, current, i18nRouteParams)
427
423
 
428
424
  if (typeof switchedRoute === 'string' && switchedRoute.startsWith('http')) {