🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@i18n-micro/core

Package Overview
Dependencies
Maintainers
1
Versions
13
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@i18n-micro/core - npm Package Compare versions

Comparing version
1.3.0
to
1.3.1
+5
dist/index.d.cts
import { BaseI18n, BaseI18nOptions } from './base';
import { FormatService } from './format-service';
import { defaultPlural, interpolate, isNoPrefixStrategy, isPrefixAndDefaultStrategy, isPrefixExceptDefaultStrategy, isPrefixStrategy, withPrefixStrategy } from './helpers';
import { TranslationStorage, useTranslationHelper } from './translation';
export { useTranslationHelper, interpolate, withPrefixStrategy, isNoPrefixStrategy, isPrefixStrategy, isPrefixExceptDefaultStrategy, isPrefixAndDefaultStrategy, defaultPlural, FormatService, BaseI18n, type TranslationStorage, type BaseI18nOptions, };
+45
-9
{
"name": "@i18n-micro/core",
"version": "1.3.0",
"description": "",
"repository": "s00d/nuxt-i18n-micro",
"version": "1.3.1",
"description": "Core utilities for translations, formatting, and locale routing in Nuxt I18n Micro.",
"repository": {
"type": "git",
"url": "git+https://github.com/s00d/nuxt-i18n-micro.git",
"directory": "packages/core"
},
"license": "MIT",
"type": "module",
"sideEffects": false,
"author": {

@@ -13,24 +18,55 @@ "name": "s00d",

},
"homepage": "https://github.com/s00d/nuxt-i18n-micro",
"homepage": "https://github.com/s00d/nuxt-i18n-micro/tree/main/packages/core#readme",
"bugs": {
"url": "https://github.com/s00d/nuxt-i18n-micro/issues"
},
"engines": {
"node": ">=18"
},
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
},
"default": "./dist/index.mjs"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"keywords": [],
"keywords": [
"nuxt",
"i18n",
"translations",
"formatting",
"locale"
],
"dependencies": {
"@i18n-micro/types": "1.2.0"
"@i18n-micro/types": "1.2.2"
},
"devDependencies": {
"publint": "^0.3.17",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4"
"vite-plugin-dts": "^4.5.4",
"vitest": "^3.2.4"
},
"scripts": {
"build": "vite build",
"test": "jest"
"check:package": "publint",
"test": "jest",
"test:dist": "vitest run --config vitest.dist.config.ts"
}
}
-1
import '@types/jest'
module.exports = {
roots: ['<rootDir>/tests'],
testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
}
import type { CleanTranslation, Getter, MissingHandler, Params, PluralFunc, TranslationKey, Translations } from '@i18n-micro/types'
import { FormatService } from './format-service'
import { defaultPlural, interpolate } from './helpers'
import { type TranslationStorage, useTranslationHelper } from './translation'
export interface BaseI18nOptions {
storage?: TranslationStorage
plural?: PluralFunc
missingWarn?: boolean
missingHandler?: (locale: string, key: string, routeName: string) => void
getCustomMissingHandler?: () => MissingHandler | null
}
/**
* Abstract base class for i18n adapters
*
* Contains all common translation logic (t, ts, tc, tn, td, tdr, has).
* Adapters must implement abstract methods to provide current state (locale, fallbackLocale, route).
*/
export abstract class BaseI18n {
// Public fields (made public to allow type export in Nuxt plugins)
public helper: ReturnType<typeof useTranslationHelper>
public formatter = new FormatService()
public pluralFunc: PluralFunc
public missingWarn: boolean
public missingHandler?: (locale: string, key: string, routeName: string) => void
public getCustomMissingHandler?: () => MissingHandler | null
constructor(options: BaseI18nOptions = {}) {
this.helper = useTranslationHelper(options.storage)
this.formatter = new FormatService()
this.pluralFunc = options.plural || defaultPlural
this.missingWarn = options.missingWarn ?? true
this.missingHandler = options.missingHandler
this.getCustomMissingHandler = options.getCustomMissingHandler
}
// --- Abstract methods (must be implemented by subclasses) ---
/**
* Get current locale
*/
public abstract getLocale(): string
/**
* Get fallback locale
*/
public abstract getFallbackLocale(): string
/**
* Get current route name
*/
public abstract getRoute(): string
// --- Public methods (implemented in base class) ---
/**
* Get translation for a key
* Based on logic from src/runtime/plugins/01.plugin.ts
*/
public t(key: TranslationKey, params?: Params, defaultValue?: string | null, routeName?: string): CleanTranslation {
if (!key) return ''
// Use abstract getters to get current state
const locale = this.getLocale()
const route = routeName || this.getRoute()
// 1. Try to find translation in current locale
let value = this.helper.getTranslation<string>(locale, route, key)
// 2. Fallback to fallbackLocale if not found and different
if (!value) {
const fallbackLocale = this.getFallbackLocale()
if (locale !== fallbackLocale) {
value = this.helper.getTranslation<string>(fallbackLocale, route, key)
}
}
// 3. Handle missing
if (!value) {
// Call custom handler if set (Nuxt runtime), otherwise use instance handler
const customHandler = this.getCustomMissingHandler?.()
if (customHandler) {
customHandler(locale, key, route)
} else if (this.missingHandler) {
this.missingHandler(locale, key as string, route)
} else if (this.missingWarn) {
const isDev = process.env.NODE_ENV !== 'production'
const isClient = typeof window !== 'undefined'
if (isDev && isClient) {
console.warn(`Not found '${key}' key in '${locale}' locale messages for route '${route}'.`)
}
}
value = defaultValue === undefined ? key : defaultValue || key
}
// 4. Interpolate
return typeof value === 'string' && params ? interpolate(value, params) : (value as CleanTranslation)
}
/**
* Get translation as string
*/
public ts(key: TranslationKey, params?: Params, defaultValue?: string, routeName?: string): string {
const value = this.t(key, params, defaultValue, routeName)
return value?.toString() ?? defaultValue ?? key
}
/**
* Plural translation
*/
public tc(key: TranslationKey, count: number | Params, defaultValue?: string): string {
const { count: countValue, ...params } = typeof count === 'number' ? { count } : count
if (countValue === undefined) {
return defaultValue ?? key
}
// Getter passed to plural function
const getter: Getter = (k: TranslationKey, p?: Params, dv?: string) => {
return this.t(k, p, dv)
}
const result = this.pluralFunc(key, Number.parseInt(countValue.toString(), 10), params, this.getLocale(), getter)
return result ?? defaultValue ?? key
}
/**
* Format number
*/
public tn(value: number, options?: Intl.NumberFormatOptions): string {
return this.formatter.formatNumber(value, this.getLocale(), options)
}
/**
* Format date
*/
public td(value: Date | number | string, options?: Intl.DateTimeFormatOptions): string {
return this.formatter.formatDate(value, this.getLocale(), options)
}
/**
* Format relative time
*/
public tdr(value: Date | number | string, options?: Intl.RelativeTimeFormatOptions): string {
return this.formatter.formatRelativeTime(value, this.getLocale(), options)
}
/**
* Check if translation exists
* Based on logic from src/runtime/plugins/01.plugin.ts
*/
public has(key: TranslationKey, routeName?: string): boolean {
const route = routeName || this.getRoute()
const locale = this.getLocale()
// Check only through getTranslation (as in plugin)
return !!this.helper.getTranslation(locale, route, key)
}
/**
* Clear cache
*/
public clearCache(): void {
this.helper.clearCache()
}
// --- Public methods (for subclasses to use) ---
/**
* Core translation loading logic (without reactivity)
* Subclasses can override addTranslations/addRouteTranslations to add reactivity
*/
public loadTranslationsCore(locale: string, translations: Translations, merge: boolean, routeName = 'index'): void {
if (merge) {
this.helper.mergeTranslation(locale, routeName, translations, true)
} else {
this.helper.setTranslations(locale, translations, routeName)
}
}
/**
* Core route translation loading logic (without reactivity)
* Subclasses can override addRouteTranslations to add reactivity
*/
public loadRouteTranslationsCore(locale: string, routeName: string, translations: Translations, merge: boolean): void {
if (merge) {
this.helper.mergeTranslation(locale, routeName, translations, true)
} else {
this.helper.loadPageTranslations(locale, routeName, translations)
}
}
}
export class FormatService {
formatNumber(value: number, locale: string, options?: Intl.NumberFormatOptions): string {
return new Intl.NumberFormat(locale, options).format(value)
}
formatDate(value: Date | number | string, locale: string, options?: Intl.DateTimeFormatOptions): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return 'Invalid Date'
}
return new Intl.DateTimeFormat(locale, options).format(date)
}
formatRelativeTime(value: Date | number | string, locale: string, options?: Intl.RelativeTimeFormatOptions): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
// Return "0 seconds ago" for invalid dates
return new Intl.RelativeTimeFormat(locale, options).format(0, 'second')
}
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
const units: { unit: Intl.RelativeTimeFormatUnit; seconds: number }[] = [
{ unit: 'year', seconds: 31536000 },
{ unit: 'month', seconds: 2592000 },
{ unit: 'day', seconds: 86400 },
{ unit: 'hour', seconds: 3600 },
{ unit: 'minute', seconds: 60 },
{ unit: 'second', seconds: 1 },
]
for (const { unit, seconds } of units) {
const diff = Math.floor(diffInSeconds / seconds)
if (diff >= 1) {
return new Intl.RelativeTimeFormat(locale, options).format(-diff, unit)
}
}
return new Intl.RelativeTimeFormat(locale, options).format(0, 'second')
}
}
import type { Getter, Params, PluralFunc, Strategies, TranslationKey } from '@i18n-micro/types'
const RE_TOKEN = /\{(\w+)\}/g
export function interpolate(template: string, params: Params): string {
if (!params) return template
return template.replace(RE_TOKEN, (_, key) => {
const value = params[key]
return value !== undefined ? String(value) : `{${key}}`
})
}
export function withPrefixStrategy(strategy: Strategies) {
return strategy === 'prefix' || strategy === 'prefix_and_default'
}
export function isNoPrefixStrategy(strategy: Strategies) {
return strategy === 'no_prefix'
}
export function isPrefixStrategy(strategy: Strategies) {
return strategy === 'prefix'
}
export function isPrefixExceptDefaultStrategy(strategy: Strategies) {
return strategy === 'prefix_except_default'
}
export function isPrefixAndDefaultStrategy(strategy: Strategies) {
return strategy === 'prefix_and_default'
}
/**
* Default pluralization function
* Splits translation by '|' and selects form based on count
* @param key - Translation key
* @param count - Count for pluralization
* @param params - Parameters for translation
* @param _locale - Current locale (unused in default implementation)
* @param getTranslation - Function to get translation value
* @returns Selected plural form or null if not found
*/
export const defaultPlural: PluralFunc = (key: TranslationKey, count: number, params: Params, _locale: string, getTranslation: Getter) => {
const translation = getTranslation(key, params)
if (!translation) {
return null
}
const forms = translation.toString().split('|')
if (forms.length === 0) return null
const selectedForm = count < forms.length ? forms[count] : forms[forms.length - 1]
if (!selectedForm) return null
return selectedForm.trim().replace('{count}', count.toString())
}
import { BaseI18n, type BaseI18nOptions } from './base'
import { FormatService } from './format-service'
import {
defaultPlural,
interpolate,
isNoPrefixStrategy,
isPrefixAndDefaultStrategy,
isPrefixExceptDefaultStrategy,
isPrefixStrategy,
withPrefixStrategy,
} from './helpers'
import { type TranslationStorage, useTranslationHelper } from './translation'
export {
useTranslationHelper,
interpolate,
withPrefixStrategy,
isNoPrefixStrategy,
isPrefixStrategy,
isPrefixExceptDefaultStrategy,
isPrefixAndDefaultStrategy,
defaultPlural,
FormatService,
BaseI18n,
type TranslationStorage,
type BaseI18nOptions,
}
import type { Translations } from '@i18n-micro/types'
/**
* Bare Metal: Simple translation storage without Ref, useState, devalue.
* Map key: `${locale}:${routeName}` (page-specific).
*/
export interface TranslationStorage {
translations: Map<string, Translations>
}
function findValue<T = unknown>(data: Translations | null | undefined, key: string): T | null {
if (!data || typeof key !== 'string') return null
if (key in data) {
const value = data[key]
if (typeof value === 'object' && value !== null) {
return value as T
}
return value as T
}
const parts = key.split('.')
let value: unknown = data
for (const part of parts) {
if (value && typeof value === 'object' && part in (value as object)) {
value = (value as Translations)[part]
} else {
return null
}
}
return (value as T) ?? null
}
export function useTranslationHelper(storage?: TranslationStorage) {
const translations = storage?.translations ?? new Map<string, Translations>()
return {
hasCache(locale: string, page: string) {
const p = page || 'index'
return translations.has(`${locale}:${p}`)
},
getCache(locale: string, routeName: string) {
const rn = routeName || 'index'
return translations.get(`${locale}:${rn}`)
},
setCache(_locale: string, _routeName: string, _cache: Map<string, unknown>) {
// No-op for bare metal
},
hasTranslation(locale: string, key: string): boolean {
for (const [k, v] of translations) {
if (k.startsWith(`${locale}:`) && findValue(v, key) !== null) {
return true
}
}
return false
},
hasPageTranslation(locale: string, routeName: string): boolean {
const rn = routeName || 'index'
return translations.has(`${locale}:${rn}`)
},
getTranslation<T = unknown>(locale: string, routeName: string, key: string): T | null {
const rn = routeName || 'index'
return findValue<T>(translations.get(`${locale}:${rn}`), key)
},
loadTranslations(locale: string, data: Translations, routeName = 'index'): void {
const rn = routeName || 'index'
const key = `${locale}:${rn}`
const existing = translations.get(key) ?? {}
translations.set(key, { ...existing, ...data })
},
setTranslations(locale: string, data: Translations, routeName = 'index'): void {
const rn = routeName || 'index'
translations.set(`${locale}:${rn}`, data)
},
loadPageTranslations(locale: string, routeName: string, data: Translations): void {
const rn = routeName || 'index'
const key = `${locale}:${rn}`
const existing = translations.get(key)
if (!existing || Object.keys(existing).length === 0) {
translations.set(key, data)
} else {
translations.set(key, { ...existing, ...data })
}
},
mergeTranslation(locale: string, routeName: string, newTranslations: Translations, _force = false): void {
const rn = routeName || 'index'
const key = `${locale}:${rn}`
const existing = translations.get(key) ?? {}
translations.set(key, { ...existing, ...newTranslations })
},
clearCache(): void {
translations.clear()
},
}
}
import type { PluralFunc, Translations } from '@i18n-micro/types'
import { BaseI18n, type BaseI18nOptions } from '../src/base'
// Test implementation of BaseI18n
class TestI18n extends BaseI18n {
private _locale: string
private _fallbackLocale: string
private _route: string
constructor(locale: string, fallbackLocale: string, route: string, options?: BaseI18nOptions) {
super(options)
this._locale = locale
this._fallbackLocale = fallbackLocale
this._route = route
}
public getLocale(): string {
return this._locale
}
public getFallbackLocale(): string {
return this._fallbackLocale
}
public getRoute(): string {
return this._route
}
public setLocale(locale: string): void {
this._locale = locale
}
public setRoute(route: string): void {
this._route = route
}
}
describe('BaseI18n', () => {
describe('Constructor', () => {
test('should initialize with default options', () => {
const i18n = new TestI18n('en', 'en', 'index')
expect(i18n.getLocale()).toBe('en')
expect(i18n.getFallbackLocale()).toBe('en')
expect(i18n.getRoute()).toBe('index')
})
test('should initialize with custom storage', () => {
const storage = { translations: new Map<string, Translations>() }
const i18n = new TestI18n('en', 'en', 'index', { storage })
expect(i18n).toBeDefined()
})
test('should initialize with custom plural function', () => {
const customPlural: PluralFunc = () => 'custom'
const i18n = new TestI18n('en', 'en', 'index', { plural: customPlural })
expect(i18n).toBeDefined()
})
test('should initialize with missingWarn option', () => {
const i18n = new TestI18n('en', 'en', 'index', { missingWarn: false })
expect(i18n).toBeDefined()
})
test('should initialize with missingHandler', () => {
const handler = jest.fn()
const i18n = new TestI18n('en', 'en', 'index', { missingHandler: handler })
expect(i18n).toBeDefined()
})
})
describe('t() method', () => {
test('should return empty string for empty key', () => {
const i18n = new TestI18n('en', 'en', 'index')
expect(i18n.t('')).toBe('')
})
test('should return translation for existing key', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = { greeting: 'Hello' }
i18n['helper'].loadTranslations('en', translations)
expect(i18n.t('greeting')).toBe('Hello')
})
test('should interpolate params in translation', async () => {
const storage = { translations: new Map<string, Translations>() }
const i18n = new TestI18n('en', 'en', 'index', { storage })
const translations: Translations = { greeting: 'Hello, {name}!' }
await i18n['helper'].loadTranslations('en', translations)
expect(i18n.t('greeting', { name: 'John' })).toBe('Hello, John!')
})
test('should use defaultValue when translation is missing', () => {
const i18n = new TestI18n('en', 'en', 'index')
expect(i18n.t('missing.key', undefined, 'Default value')).toBe('Default value')
})
test('should return key when translation is missing and no defaultValue', () => {
const i18n = new TestI18n('en', 'en', 'index')
expect(i18n.t('missing.key')).toBe('missing.key')
})
test('should fallback to fallbackLocale when translation is missing', async () => {
const storage = { translations: new Map<string, Translations>() }
const i18n = new TestI18n('en', 'fr', 'index', { storage })
const translations: Translations = { greeting: 'Bonjour' }
await i18n['helper'].loadTranslations('fr', translations)
expect(i18n.t('greeting')).toBe('Bonjour')
})
test('should use route-specific translation when routeName is provided', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const routeTranslations: Translations = { title: 'Route Title' }
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
expect(i18n.t('title', undefined, undefined, 'about')).toBe('Route Title')
})
test('should call missingHandler when translation is missing', () => {
const handler = jest.fn()
const i18n = new TestI18n('en', 'en', 'index', { missingHandler: handler })
i18n.t('missing.key')
expect(handler).toHaveBeenCalledWith('en', 'missing.key', 'index')
})
test('should call customMissingHandler when set (Nuxt runtime)', () => {
const customHandler = jest.fn()
const i18n = new TestI18n('en', 'en', 'index', {
getCustomMissingHandler: () => customHandler,
})
i18n.t('missing.key')
expect(customHandler).toHaveBeenCalledWith('en', 'missing.key', 'index')
})
test('should not warn when missingWarn is false', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
const i18n = new TestI18n('en', 'en', 'index', { missingWarn: false })
i18n.t('missing.key')
expect(consoleSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
})
describe('ts() method', () => {
test('should return translation as string', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = { greeting: 'Hello' }
i18n['helper'].loadTranslations('en', translations)
expect(i18n.ts('greeting')).toBe('Hello')
})
test('should return defaultValue when translation is missing', () => {
const i18n = new TestI18n('en', 'en', 'index')
expect(i18n.ts('missing.key', undefined, 'Default')).toBe('Default')
})
test('should return key when translation is missing and no defaultValue', () => {
const i18n = new TestI18n('en', 'en', 'index')
expect(i18n.ts('missing.key')).toBe('missing.key')
})
test('should convert non-string values to string', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = { count: 42 }
i18n['helper'].loadTranslations('en', translations)
expect(i18n.ts('count')).toBe('42')
})
})
describe('tc() method', () => {
test('should return defaultValue when count is undefined', () => {
const i18n = new TestI18n('en', 'en', 'index')
expect(i18n.tc('apples', { other: 'params' }, 'No count')).toBe('No count')
})
test('should use plural function with count', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = { apples: 'apple|apples' }
await i18n['helper'].loadTranslations('en', translations)
// defaultPlural selects form by index: forms[count] or last form if count >= forms.length
// For 'apple|apples': forms[0]='apple', forms[1]='apples'
expect(i18n.tc('apples', 0)).toBe('apple')
expect(i18n.tc('apples', 1)).toBe('apples') // forms[1]
expect(i18n.tc('apples', 5)).toBe('apples') // last form
})
test('should handle count as number', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = { apples: 'apple|apples' }
i18n['helper'].loadTranslations('en', translations)
expect(i18n.tc('apples', 2)).toBe('apples')
})
test('should handle count as Params object', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = { apples: 'apple|apples' }
i18n['helper'].loadTranslations('en', translations)
expect(i18n.tc('apples', { count: 2, name: 'John' })).toBe('apples')
})
test('should return defaultValue when plural function returns null', () => {
const i18n = new TestI18n('en', 'en', 'index')
// When translation is missing, t() returns key, which is passed to pluralFunc
// pluralFunc tries to process 'missing.key' as translation, but since it doesn't contain '|',
// it returns the key itself. So tc returns the key, not defaultValue.
// To test defaultValue, we need a case where pluralFunc actually returns null.
// This happens when translation exists but is empty or invalid.
expect(i18n.tc('missing.key', 1, 'Default')).toBe('missing.key')
})
})
describe('tn() method', () => {
test('should format number with default locale', () => {
const i18n = new TestI18n('en', 'en', 'index')
const result = i18n.tn(1234.56)
expect(result).toMatch(/1[,.]234[.,]56/)
})
test('should format number with custom options', () => {
const i18n = new TestI18n('en', 'en', 'index')
const result = i18n.tn(1234.56, { style: 'currency', currency: 'USD' })
expect(result).toContain('1,234.56')
})
test('should use current locale for formatting', () => {
const i18n = new TestI18n('ru', 'en', 'index')
const result = i18n.tn(1234.56)
// Russian locale uses different number formatting
expect(result).toBeDefined()
})
})
describe('td() method', () => {
test('should format date with default locale', () => {
const i18n = new TestI18n('en', 'en', 'index')
const date = new Date('2024-01-15')
const result = i18n.td(date)
expect(result).toBeDefined()
expect(result).not.toBe('Invalid Date')
})
test('should format date with custom options', () => {
const i18n = new TestI18n('en', 'en', 'index')
const date = new Date('2024-01-15')
const result = i18n.td(date, { year: 'numeric', month: 'long', day: 'numeric' })
expect(result).toContain('2024')
expect(result).toContain('January')
})
test('should handle date as number (timestamp)', () => {
const i18n = new TestI18n('en', 'en', 'index')
const timestamp = new Date('2024-01-15').getTime()
const result = i18n.td(timestamp)
expect(result).toBeDefined()
expect(result).not.toBe('Invalid Date')
})
test('should handle date as string', () => {
const i18n = new TestI18n('en', 'en', 'index')
const result = i18n.td('2024-01-15')
expect(result).toBeDefined()
expect(result).not.toBe('Invalid Date')
})
})
describe('tdr() method', () => {
test('should format relative time', () => {
const i18n = new TestI18n('en', 'en', 'index')
const yesterday = new Date(Date.now() - 86400000)
const result = i18n.tdr(yesterday)
expect(result).toBeDefined()
expect(result).toMatch(/day|ago/i)
})
test('should format relative time with custom options', () => {
const i18n = new TestI18n('en', 'en', 'index')
const yesterday = new Date(Date.now() - 86400000)
const result = i18n.tdr(yesterday, { numeric: 'always' })
expect(result).toBeDefined()
})
test('should handle invalid date gracefully', () => {
const i18n = new TestI18n('en', 'en', 'index')
const invalidDate = new Date('invalid')
const result = i18n.tdr(invalidDate)
expect(result).toBeDefined()
})
})
describe('has() method', () => {
test('should return true when translation exists', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = { greeting: 'Hello' }
i18n['helper'].loadTranslations('en', translations)
expect(i18n.has('greeting')).toBe(true)
})
test('should return false when translation does not exist', () => {
const i18n = new TestI18n('en', 'en', 'index')
expect(i18n.has('missing.key')).toBe(false)
})
test('should check route-specific translation when routeName is provided', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const routeTranslations: Translations = { title: 'Route Title' }
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
expect(i18n.has('title', 'about')).toBe(true)
expect(i18n.has('title', 'index')).toBe(false)
})
test('should use current route when routeName is not provided', async () => {
const i18n = new TestI18n('en', 'en', 'about')
const routeTranslations: Translations = { title: 'Route Title' }
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
expect(i18n.has('title')).toBe(true)
})
})
describe('clearCache() method', () => {
test('should clear all translations from cache', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = { greeting: 'Hello' }
i18n['helper'].loadTranslations('en', translations)
expect(i18n.has('greeting')).toBe(true)
i18n.clearCache()
expect(i18n.has('greeting')).toBe(false)
})
})
describe('loadTranslationsCore() method', () => {
test('should load translations when merge is false', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = { greeting: 'Hello' }
i18n['loadTranslationsCore']('en', translations, false)
// Wait for async operation to complete
await new Promise((resolve) => setTimeout(resolve, 0))
expect(i18n.has('greeting')).toBe(true)
})
test('should merge translations when merge is true', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const initial: Translations = { greeting: 'Hello' }
const additional: Translations = { farewell: 'Goodbye' }
await i18n['helper'].loadTranslations('en', initial)
i18n['loadTranslationsCore']('en', additional, true)
// Wait for async operation to complete
await new Promise((resolve) => setTimeout(resolve, 0))
expect(i18n.has('greeting')).toBe(true)
expect(i18n.has('farewell')).toBe(true)
})
})
describe('loadRouteTranslationsCore() method', () => {
test('should load route translations when merge is false', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = { title: 'Page Title' }
i18n['loadRouteTranslationsCore']('en', 'about', translations, false)
// Wait for async operation to complete
await new Promise((resolve) => setTimeout(resolve, 0))
expect(i18n.has('title', 'about')).toBe(true)
})
test('should merge route translations when merge is true', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const initial: Translations = { title: 'Page Title' }
const additional: Translations = { description: 'Page Description' }
await i18n['helper'].loadPageTranslations('en', 'about', initial)
i18n['loadRouteTranslationsCore']('en', 'about', additional, true)
// Wait for async operation to complete
await new Promise((resolve) => setTimeout(resolve, 0))
expect(i18n.has('title', 'about')).toBe(true)
expect(i18n.has('description', 'about')).toBe(true)
})
})
describe('Edge cases', () => {
test('should handle nested translation keys', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = {
nested: {
deep: {
key: 'Nested value',
},
},
}
i18n['helper'].loadTranslations('en', translations)
expect(i18n.t('nested.deep.key')).toBe('Nested value')
})
test('should handle multiple params in interpolation', async () => {
const i18n = new TestI18n('en', 'en', 'index')
const translations: Translations = {
message: 'Hello, {name}! You are {age} years old.',
}
i18n['helper'].loadTranslations('en', translations)
expect(i18n.t('message', { name: 'John', age: 30 })).toBe('Hello, John! You are 30 years old.')
})
test('should handle null defaultValue', () => {
const i18n = new TestI18n('en', 'en', 'index')
expect(i18n.t('missing.key', undefined, null)).toBe('missing.key')
})
test('should handle empty string defaultValue', () => {
const i18n = new TestI18n('en', 'en', 'index')
// Empty string is falsy, so it will fall back to key (as per defaultValue || key logic)
expect(i18n.t('missing.key', undefined, '')).toBe('missing.key')
})
test('should handle route change', async () => {
const storage = { translations: new Map<string, Translations>() }
const i18n = new TestI18n('en', 'en', 'index', { storage })
const rootTranslations: Translations = { greeting: 'Hello' }
// Packages (vue, node, etc.) merge root into pages automatically.
// Core does not — so we simulate pre-merged data here.
const routeTranslations: Translations = { greeting: 'Hello', title: 'About Page' }
await i18n['helper'].loadTranslations('en', rootTranslations)
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
// Verify translations are loaded
expect(i18n.has('greeting')).toBe(true)
expect(i18n.has('title', 'about')).toBe(true)
// Check route-specific translation with explicit routeName
const titleValue = i18n.t('title', undefined, undefined, 'about')
expect(titleValue).toBe('About Page')
// Change route and check
i18n.setRoute('about')
expect(i18n.t('title')).toBe('About Page')
expect(i18n.t('greeting')).toBe('Hello')
})
})
})
import { interpolate, useTranslationHelper } from '../src'
describe('Translation Helper', () => {
const translations = {
en: {
greeting: 'Hello, {name}!',
nested: {
message: 'This is a nested message.',
},
},
fr: {
greeting: 'Bonjour, {name}!',
},
}
test('interpolate function replaces placeholders correctly', () => {
const result = interpolate('Hello, {name}!', { name: 'John' })
expect(result).toBe('Hello, John!')
})
test('interpolate function handles missing placeholders gracefully', () => {
const result = interpolate('Hello, {name}!', { age: 30 })
expect(result).toBe('Hello, {name}!')
})
test('getTranslation fetches correct translation', () => {
const helper = useTranslationHelper()
helper.loadTranslations('en', translations.en)
const translation = helper.getTranslation('en', 'index', 'greeting')
expect(translation).toBe('Hello, {name}!')
})
test('getTranslation supports nested keys', () => {
const helper = useTranslationHelper()
helper.loadTranslations('en', translations.en)
const translation = helper.getTranslation('en', 'index', 'nested.message')
expect(translation).toBe('This is a nested message.')
})
test('getTranslation falls back when translation is missing', () => {
const helper = useTranslationHelper()
helper.loadTranslations('en', translations.en)
const translation = helper.getTranslation('en', 'index', 'nonexistent.key')
expect(translation).toBeNull()
})
test('loadPageTranslations correctly caches translations', async () => {
const helper = useTranslationHelper()
await helper.loadPageTranslations('fr', 'home', translations.fr)
expect(helper.hasPageTranslation('fr', 'home')).toBe(true)
expect(helper.getTranslation('fr', 'home', 'greeting')).toBe('Bonjour, {name}!')
})
test('mergeTranslation updates route translations', () => {
const helper = useTranslationHelper()
helper.loadPageTranslations('en', 'home', translations.en)
helper.mergeTranslation('en', 'home', { newKey: 'New value' })
expect(helper.getTranslation('en', 'home', 'newKey')).toBe('New value')
})
test('mergeTranslation with index routeName updates index translations', () => {
const helper = useTranslationHelper()
helper.loadTranslations('en', translations.en)
helper.mergeTranslation('en', 'index', { newKey: 'New value' })
expect(helper.getTranslation('en', 'index', 'newKey')).toBe('New value')
})
test('deepClone creates a deep copy of objects', () => {
const original = { nested: { key: 'value' } }
const cloned = JSON.parse(JSON.stringify(original))
expect(cloned).toEqual(original)
expect(cloned).not.toBe(original)
})
})
import { FormatService } from '../src'
describe('FormatService', () => {
let formatService: FormatService
beforeEach(() => {
formatService = new FormatService()
})
describe('formatNumber', () => {
test('should format number with default options', () => {
const result = formatService.formatNumber(123456.789, 'en-US')
expect(result).toBe('123,456.789')
})
test('should format number with custom options', () => {
const result = formatService.formatNumber(123456.789, 'de-DE', {
style: 'currency',
currency: 'EUR',
})
expect(result).toBe('123.456,79 €')
})
test('should handle invalid locale by falling back to default formatting', () => {
const result = formatService.formatNumber(123456.789, 'invalid-locale')
expect(result).toBe('123,456.789') // Fallback to default formatting
})
})
describe('formatDate', () => {
test('should format date with default options', () => {
const date = new Date('2023-10-05T12:34:56Z')
const result = formatService.formatDate(date, 'en-US')
expect(result).toBe('10/5/2023')
})
test('should format date with custom options', () => {
const date = new Date('2023-10-05T12:34:56Z')
const result = formatService.formatDate(date, 'de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
expect(result).toBe('5. Oktober 2023')
})
test('should handle invalid date by returning "Invalid Date"', () => {
const result = formatService.formatDate('invalid-date', 'en-US')
expect(result).toBe('Invalid Date')
})
})
describe('formatRelativeTime', () => {
test('should format relative time for seconds', () => {
const now = new Date()
const past = new Date(now.getTime() - 30 * 1000) // 30 seconds ago
const result = formatService.formatRelativeTime(past, 'en-US')
expect(result).toBe('30 seconds ago')
})
test('should format relative time for minutes', () => {
const now = new Date()
const past = new Date(now.getTime() - 5 * 60 * 1000) // 5 minutes ago
const result = formatService.formatRelativeTime(past, 'en-US')
expect(result).toBe('5 minutes ago')
})
test('should format relative time for hours', () => {
const now = new Date()
const past = new Date(now.getTime() - 2 * 60 * 60 * 1000) // 2 hours ago
const result = formatService.formatRelativeTime(past, 'en-US')
expect(result).toBe('2 hours ago')
})
test('should format relative time for days', () => {
const now = new Date()
const past = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000) // 3 days ago
const result = formatService.formatRelativeTime(past, 'en-US')
expect(result).toBe('3 days ago')
})
test('should format relative time for months', () => {
const now = new Date()
const past = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000) // ~2 months ago
const result = formatService.formatRelativeTime(past, 'en-US')
expect(result).toBe('2 months ago')
})
test('should format relative time for years', () => {
const now = new Date()
const past = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000) // 1 year ago
const result = formatService.formatRelativeTime(past, 'en-US')
expect(result).toBe('1 year ago')
})
test('should handle invalid date by returning "0 seconds ago"', () => {
const result = formatService.formatRelativeTime('invalid-date', 'en-US')
expect(result).toBe('in 0 seconds')
})
})
})
import {
interpolate,
isNoPrefixStrategy,
isPrefixAndDefaultStrategy,
isPrefixExceptDefaultStrategy,
isPrefixStrategy,
withPrefixStrategy,
} from '../src/helpers'
describe('Helpers', () => {
describe('interpolate', () => {
test('should replace placeholders with params', () => {
const template = 'Hello, {name}! Your age is {age}.'
const params = { name: 'John', age: 30 }
const result = interpolate(template, params)
expect(result).toBe('Hello, John! Your age is 30.')
})
test('should handle missing params by leaving placeholders', () => {
const template = 'Hello, {name}! Your age is {age}.'
const params = { name: 'John' } // age is missing
const result = interpolate(template, params)
expect(result).toBe('Hello, John! Your age is {age}.')
})
test('should handle empty params', () => {
const template = 'Hello, {name}!'
const params = {}
const result = interpolate(template, params)
expect(result).toBe('Hello, {name}!')
})
test('should handle empty template', () => {
const template = ''
const params = { name: 'John' }
const result = interpolate(template, params)
expect(result).toBe('')
})
})
describe('withPrefixStrategy', () => {
test('should return true for "prefix" strategy', () => {
expect(withPrefixStrategy('prefix')).toBe(true)
})
test('should return true for "prefix_and_default" strategy', () => {
expect(withPrefixStrategy('prefix_and_default')).toBe(true)
})
test('should return false for other strategies', () => {
expect(withPrefixStrategy('no_prefix')).toBe(false)
expect(withPrefixStrategy('prefix_except_default')).toBe(false)
})
})
describe('isNoPrefixStrategy', () => {
test('should return true for "no_prefix" strategy', () => {
expect(isNoPrefixStrategy('no_prefix')).toBe(true)
})
test('should return false for other strategies', () => {
expect(isNoPrefixStrategy('prefix')).toBe(false)
expect(isNoPrefixStrategy('prefix_and_default')).toBe(false)
expect(isNoPrefixStrategy('prefix_except_default')).toBe(false)
})
})
describe('isPrefixStrategy', () => {
test('should return true for "prefix" strategy', () => {
expect(isPrefixStrategy('prefix')).toBe(true)
})
test('should return false for other strategies', () => {
expect(isPrefixStrategy('no_prefix')).toBe(false)
expect(isPrefixStrategy('prefix_and_default')).toBe(false)
expect(isPrefixStrategy('prefix_except_default')).toBe(false)
})
})
describe('isPrefixExceptDefaultStrategy', () => {
test('should return true for "prefix_except_default" strategy', () => {
expect(isPrefixExceptDefaultStrategy('prefix_except_default')).toBe(true)
})
test('should return false for other strategies', () => {
expect(isPrefixExceptDefaultStrategy('no_prefix')).toBe(false)
expect(isPrefixExceptDefaultStrategy('prefix')).toBe(false)
expect(isPrefixExceptDefaultStrategy('prefix_and_default')).toBe(false)
})
})
describe('isPrefixAndDefaultStrategy', () => {
test('should return true for "prefix_and_default" strategy', () => {
expect(isPrefixAndDefaultStrategy('prefix_and_default')).toBe(true)
})
test('should return false for other strategies', () => {
expect(isPrefixAndDefaultStrategy('no_prefix')).toBe(false)
expect(isPrefixAndDefaultStrategy('prefix')).toBe(false)
expect(isPrefixAndDefaultStrategy('prefix_except_default')).toBe(false)
})
})
})
{
"compilerOptions": {
"target": "ES2018",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"declaration": true,
"declarationDir": "./dist",
"sourceMap": true,
"rootDir": "./src",
"baseUrl": "./",
"paths": {
"*": ["node_modules/*", "src/types/*"]
},
"types": ["jest", "node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
// @ts-nocheck
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: '@i18n-micro/core',
formats: ['cjs', 'es'],
fileName: (format) => `index.${format === 'cjs' ? 'cjs' : 'mjs'}`,
},
rollupOptions: {
external: [],
output: {
exports: 'named',
},
},
},
plugins: [dts()],
})