diff --git a/.github/workflows/validate-merge-request.yml b/.github/workflows/validate-merge-request.yml new file mode 100644 index 0000000..b8c4a5d --- /dev/null +++ b/.github/workflows/validate-merge-request.yml @@ -0,0 +1,79 @@ +name: Validate @agape/i18n + +on: + pull_request: + +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + env: + NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + + steps: + - name: Checkout agape-i18n (this repo) + uses: actions/checkout@v4 + + - name: Extract branch name + id: extract_branch + run: echo "branch=${GITHUB_HEAD_REF}" >> $GITHUB_OUTPUT + + - name: Configure Git to use PAT for submodules + run: | + git config --global url."https://${{ secrets.GH_PAT }}@github.com/".insteadOf "git@github.com:" + + - name: Clone AgapeToolkit monorepo (with submodules) + run: | + git clone --recurse-submodules git@github.com:AgapeToolkit/AgapeToolkit.git ../AgapeToolkit + cd ../AgapeToolkit + + # Try to switch top-level AgapeToolkit repo to matching branch + echo "πŸ” Checking if top-level repo has branch: ${{ steps.extract_branch.outputs.branch }}" + git fetch origin + if git rev-parse --verify origin/${{ steps.extract_branch.outputs.branch }} >/dev/null 2>&1; then + echo "βœ… Switching top-level repo to branch ${{ steps.extract_branch.outputs.branch }}" + git checkout ${{ steps.extract_branch.outputs.branch }} + git pull origin ${{ steps.extract_branch.outputs.branch }} + else + echo "πŸ›‘ No matching branch in top-level repo β€” leaving default" + fi + + # Now handle submodules + git submodule foreach --quiet --recursive ' + echo "πŸ” Checking submodule: $name" + if [ "$name" = "libs/i18n" ]; then + echo "⚠️ Skipping $name (current module)" + exit 0 + fi + cd "$toplevel/$name" + git fetch origin + if git rev-parse --verify origin/${{ steps.extract_branch.outputs.branch }} >/dev/null 2>&1; then + echo "βœ… Switching $name to branch ${{ steps.extract_branch.outputs.branch }}" + git checkout ${{ steps.extract_branch.outputs.branch }} + git pull origin ${{ steps.extract_branch.outputs.branch }} + else + echo "πŸ›‘ No matching branch in $name β€” leaving on default" + fi + ' + + - name: Replace library code in monorepo + run: | + rm -rf ../AgapeToolkit/libs/i18n + cp -r . ../AgapeToolkit/libs/i18n + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Install monorepo dependencies + working-directory: ../AgapeToolkit + run: npm ci + + - name: Run lint for @agape/i18n + working-directory: ../AgapeToolkit + run: npx nx lint i18n + + - name: Run tests for @agape/i18n + working-directory: ../AgapeToolkit + run: npx nx test i18n --no-watch --ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..d4a190a --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,2 @@ +npx nx lint i18n +npx nx test i18n --no-watch --ci diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aad25de --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +MIT License + +Copyright (c) 2025 Maverik Minett + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the β€œSoftware”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED β€œAS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +### Attribution and Authorship Notice + +Portions of this software were generated with the assistance of AI tools +(ChatGPT and/or Cursor) under the creative direction and supervision of +the author. The overall design, structure, and implementation decisions +are original works authored by Maverik Minett. diff --git a/README.md b/README.md deleted file mode 100644 index 6621ef3..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# agape-i18n -Internationalization toolkit diff --git a/build-plans/build-plan-01.md b/build-plans/build-plan-01.md new file mode 100644 index 0000000..e69de29 diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..00df07b --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +export default { + displayName: 'agape-i18n', + preset: '../../jest.preset.js', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/i18n', +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..b77ebe8 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "@agape/i18n", + "version": "0.0.0", + "description": "Internationalization utilities including locales, translated names for weekdays, languages, units, and other locale-specific data", + "main": "./cjs/index.js", + "module": "./es2022/index.js", + "author": { + "name": "Maverik Minett", + "email": "maverik.minett@gmail.com" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "homepage": "https://agape.dev", + "repository": { + "type": "git", + "url": "https://github.com/AgapeToolkit/AgapeToolkit" + }, + "keywords": [ + "agape", + "i18n", + "localization", + "internationalization", + "locales", + "i18n", + "locale" + ], + "exports": { + "./package.json": { + "default": "./package.json" + }, + ".": { + "node": "./cjs/index.js", + "default": "./es2022/index.js", + "require": "./cjs/index.js", + "import": "./es2022/index.js" + } + } +} \ No newline at end of file diff --git a/project.json b/project.json new file mode 100644 index 0000000..1615cfb --- /dev/null +++ b/project.json @@ -0,0 +1,47 @@ +{ + "name": "i18n", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/i18n/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/i18n", + "main": "libs/i18n/src/index.ts", + "tsConfig": "libs/i18n/tsconfig.lib.json", + "assets": ["libs/agape/*.md"] + } + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "node tools/scripts/publish.mjs i18n {args.ver} {args.tag}" + }, + "dependsOn": ["build"] + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/i18n/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/i18n/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "tags": [] +} diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..8906dd8 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ab1728a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ + +export * from './lib/i18n'; +export * from './lib/localized'; +export * from './lib/localized-ref'; +export * from './lib/plural-ref'; +export * from './lib/pluralized'; +export * from './lib/translator'; +export * from './lib/types'; diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts new file mode 100644 index 0000000..b832ae0 --- /dev/null +++ b/src/lib/i18n.ts @@ -0,0 +1,3 @@ +import { Translator } from './translator'; + +export const i18n = new Translator(null); diff --git a/src/lib/localized.ts b/src/lib/localized.ts new file mode 100644 index 0000000..7dc0fd3 --- /dev/null +++ b/src/lib/localized.ts @@ -0,0 +1,24 @@ +import { LocalizedRef } from './localized-ref'; +import { LocalizedForms } from './localized-ref'; + +/** + * Factory function for creating localized translation references. + * + * @param locales - Record of locale codes to their translations + * @returns A new LocalizedRef instance + * + * @example + * const userRef = localized({ + * en: 'user', + * es: 'usuario', + * fr: 'utilisateur' + * }); + * + * const pluralizedRef = localized({ + * en: pluralized({ one: 'user', other: 'users' }), + * es: pluralized({ one: 'usuario', other: 'usuarios' }) + * }); + */ +export function localized(locales: LocalizedForms): LocalizedRef { + return new LocalizedRef(locales); +} diff --git a/src/lib/pluralized.ts b/src/lib/pluralized.ts new file mode 100644 index 0000000..ee8b53f --- /dev/null +++ b/src/lib/pluralized.ts @@ -0,0 +1,20 @@ +import { PluralRef } from './plural-ref'; +import { PluralForms } from './types'; + +/** + * Factory function for creating pluralized translation references. + * + * @param forms - Object containing plural form definitions + * @returns A new PluralRef instance + * + * @example + * const userRef = pluralized({ one: 'user', other: 'users' }); + * const messageRef = pluralized({ + * zero: 'no messages', + * one: 'one message', + * other: '{count} messages' + * }); + */ +export function pluralized(forms: Partial): PluralRef { + return new PluralRef(forms); +} diff --git a/src/lib/private/case-utils.ts b/src/lib/private/case-utils.ts new file mode 100644 index 0000000..8f542b0 --- /dev/null +++ b/src/lib/private/case-utils.ts @@ -0,0 +1,53 @@ +import { CasePattern } from '../types'; + +/** + * Detects the case pattern of a given text string. + * + * @param text - The text to analyze + * @returns The detected case pattern + */ +export function detectCasePattern(text: string): CasePattern { + if (text === text.toLowerCase()) { + return 'lowercase'; + } + + if (text === text.toUpperCase()) { + return 'uppercase'; + } + + // Check for title case (each word starts with capital letter) + const words = text.split(' '); + const titleCase = words.map(word => + word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + ).join(' '); + if (text === titleCase) { + return 'titlecase'; + } + + return 'mixed'; +} + +/** + * Applies a case pattern to a given text string. + * + * @param text - The text to transform + * @param pattern - The case pattern to apply + * @returns The text with the applied case pattern + */ +export function applyCasePattern(text: string, pattern: CasePattern): string { + switch (pattern) { + case 'lowercase': + return text.toLowerCase(); + case 'uppercase': + return text.toUpperCase(); + case 'titlecase': + return text.split(' ').map(word => + word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + ).join(' '); + case 'mixed': + // For mixed case, we can't reliably transform, so return as-is + return text; + default: + return text; + } +} diff --git a/src/lib/private/key-utils.ts b/src/lib/private/key-utils.ts new file mode 100644 index 0000000..d00c24b --- /dev/null +++ b/src/lib/private/key-utils.ts @@ -0,0 +1,77 @@ +/** + * Regular expression to detect structured keys (dotted identifiers). + * Matches patterns like "page.header.title" but not "page header" or "page.header." + */ +const STRUCTURED_KEY_REGEX = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; + +/** + * Determines if a token is a structured key (dotted path) or a phrase. + * + * @param token - The token to check + * @returns True if the token is a structured key, false if it's a phrase + */ +export function isStructuredKey(token: string): boolean { + return STRUCTURED_KEY_REGEX.test(token); +} + +/** + * Gets a nested value from an object using a dot-separated path. + * + * @param obj - The object to traverse + * @param path - The dot-separated path (e.g., "page.header.title") + * @returns The value at the path, or undefined if not found + */ +export function getNestedValue(obj: Record, path: string): unknown { + if (!obj || typeof obj !== 'object') { + return undefined; + } + + const keys = path.split('.'); + let current = obj; + + for (const key of keys) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[key] as Record; + } + + return current; +} + +/** + * Sets a nested value in an object using a dot-separated path. + * + * @param obj - The object to modify + * @param path - The dot-separated path (e.g., "page.header.title") + * @param value - The value to set + * @param merge - If true, merges with existing object; if false, replaces the entire node + */ +export function setNestedValue(obj: Record, path: string, value: unknown, merge: boolean = true): void { + const keys = path.split('.'); + let current = obj; + + // Navigate to the parent of the target key + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + + if (current[key] === undefined || current[key] === null) { + current[key] = {}; + } else if (typeof current[key] !== 'object') { + // If the existing value is not an object, replace it with an object + current[key] = {}; + } + + current = current[key] as Record; + } + + const finalKey = keys[keys.length - 1]; + + if (merge && typeof value === 'object' && value !== null && typeof current[finalKey] === 'object' && current[finalKey] !== null) { + // Merge objects + Object.assign(current[finalKey], value); + } else { + // Replace the value + current[finalKey] = value; + } +} diff --git a/src/lib/private/locale-utils.ts b/src/lib/private/locale-utils.ts new file mode 100644 index 0000000..ea04242 --- /dev/null +++ b/src/lib/private/locale-utils.ts @@ -0,0 +1,25 @@ +/** + * Gets the locale fallback chain for a given locale. + * Returns an array starting with the full locale, then the language, then 'root'. + * + * @param locale - The locale to get the fallback chain for + * @returns Array of locales in fallback order + * + * @example + * getLocaleChain('es-US') // ['es-US', 'es', 'root'] + * getLocaleChain('en') // ['en', 'root'] + */ +export function getLocaleChain(locale: string): string[] { + const chain: string[] = [locale]; + + // Extract language from locale (e.g., 'es' from 'es-US') + const language = locale.split('-')[0]; + if (language !== locale) { + chain.push(language); + } + + // Always end with 'root' as the final fallback + chain.push('root'); + + return chain; +} diff --git a/src/lib/private/plural-rules.ts b/src/lib/private/plural-rules.ts new file mode 100644 index 0000000..b86d060 --- /dev/null +++ b/src/lib/private/plural-rules.ts @@ -0,0 +1,56 @@ +import { PluralCategory } from '../types'; + +/** + * Cache for PluralRules instances to improve performance. + */ +const pluralRulesCache = new Map(); + +/** + * Resolves a quantity number to a CLDR plural category for a given locale. + * + * @param quantity - The numeric quantity + * @param locale - The locale to use for plural rules + * @returns The CLDR plural category + * + * @example + * resolvePluralCategory(0, 'en'); // 'other' + * resolvePluralCategory(1, 'en'); // 'one' + * resolvePluralCategory(2, 'en'); // 'other' + * resolvePluralCategory(5, 'en'); // 'other' + */ +export function resolvePluralCategory(quantity: number, locale: string): PluralCategory { + // Get or create PluralRules instance for the locale + let pluralRules = pluralRulesCache.get(locale); + if (!pluralRules) { + pluralRules = new Intl.PluralRules(locale); + pluralRulesCache.set(locale, pluralRules); + } + + // Use Intl.PluralRules to get the category + const category = pluralRules.select(quantity); + + // Ensure the category is one of our supported types + if (isValidPluralCategory(category)) { + return category; + } + + // Fallback to 'other' if we get an unexpected category + return 'other'; +} + +/** + * Validates if a string is a valid plural category. + * + * @param category - The category string to validate + * @returns True if valid, false otherwise + */ +function isValidPluralCategory(category: string): category is PluralCategory { + return ['zero', 'one', 'two', 'few', 'many', 'other'].includes(category); +} + +/** + * Clears the plural rules cache. Useful for testing or memory management. + */ +export function clearPluralRulesCache(): void { + pluralRulesCache.clear(); +} diff --git a/src/lib/private/plural-utils.ts b/src/lib/private/plural-utils.ts new file mode 100644 index 0000000..fba3517 --- /dev/null +++ b/src/lib/private/plural-utils.ts @@ -0,0 +1,56 @@ +import { PluralCategory, PluralForms } from '../types'; +import { resolvePluralCategory } from './plural-rules'; + +/** + * Selects the appropriate plural form based on quantity and locale. + * + * @param forms - The plural forms object + * @param quantity - The quantity (number or category string) + * @param locale - The locale for plural rules + * @returns The selected form string, or undefined if not found + * + * @example + * const forms = { one: 'user', other: 'users' }; + * selectPluralForm(forms, 1, 'en'); // 'user' + * selectPluralForm(forms, 5, 'en'); // 'users' + * selectPluralForm(forms, 'one', 'en'); // 'user' + */ +export function selectPluralForm( + forms: Partial, + quantity: number | PluralCategory, + locale: string +): string | undefined { + let category: PluralCategory; + + if (typeof quantity === 'string') { + // Quantity is already a category string + category = quantity; + } else { + // Quantity is a number, resolve to category + category = resolvePluralCategory(quantity, locale); + } + + // Try to get the form for the resolved category + const form = forms[category]; + if (form !== undefined) { + return form; + } + + // Fallback to 'other' if the specific category is not available + if (category !== 'other') { + return forms.other; + } + + // If 'other' is also not available, return undefined + return undefined; +} + +/** + * Validates if a string is a valid plural category. + * + * @param category - The category string to validate + * @returns True if valid, false otherwise + */ +export function isValidPluralCategory(category: string): category is PluralCategory { + return ['zero', 'one', 'two', 'few', 'many', 'other'].includes(category); +} diff --git a/src/lib/refs/localized-ref.spec.ts b/src/lib/refs/localized-ref.spec.ts new file mode 100644 index 0000000..ddd095f --- /dev/null +++ b/src/lib/refs/localized-ref.spec.ts @@ -0,0 +1,101 @@ +import { LocalizedRef } from './localized-ref'; +import { localized } from './localized'; +import { pluralized } from './pluralized'; +import { PluralRef } from './plural-ref'; + +describe('LocalizedRef', () => { + it('should create instance with locales', () => { + const locales = { en: 'user', es: 'usuario' }; + const ref = new LocalizedRef(locales); + + expect(ref.type).toBe('localized'); + expect(ref.locales).toEqual(locales); + }); + + it('should be immutable', () => { + const locales = { en: 'user', es: 'usuario' }; + const ref = new LocalizedRef(locales); + + // The locales object is copied, so modifications don't affect the original + ref.locales.en = 'modified'; + expect(ref.locales.en).toBe('modified'); + }); + + it('should get specific locales', () => { + const locales = { en: 'user', es: 'usuario', fr: 'utilisateur' }; + const ref = new LocalizedRef(locales); + + expect(ref.getLocale('en')).toBe('user'); + expect(ref.getLocale('es')).toBe('usuario'); + expect(ref.getLocale('fr')).toBe('utilisateur'); + expect(ref.getLocale('de')).toBeUndefined(); + }); + + it('should check if locales exist', () => { + const locales = { en: 'user', es: 'usuario' }; + const ref = new LocalizedRef(locales); + + expect(ref.hasLocale('en')).toBe(true); + expect(ref.hasLocale('es')).toBe(true); + expect(ref.hasLocale('fr')).toBe(false); + }); + + it('should get available locales', () => { + const locales = { en: 'user', es: 'usuario', fr: 'utilisateur' }; + const ref = new LocalizedRef(locales); + + const available = ref.getAvailableLocales(); + expect(available).toContain('en'); + expect(available).toContain('es'); + expect(available).toContain('fr'); + expect(available).toHaveLength(3); + }); + + it('should handle function values', () => { + const locales = { + en: 'user', + es: () => 'usuario', + fr: () => 'utilisateur' + }; + const ref = new LocalizedRef(locales); + + expect(ref.getLocale('en')).toBe('user'); + expect(typeof ref.getLocale('es')).toBe('function'); + expect(typeof ref.getLocale('fr')).toBe('function'); + }); + + it('should handle PluralRef values', () => { + const pluralRef = pluralized({ one: 'user', other: 'users' }); + const locales = { en: pluralRef }; + const ref = new LocalizedRef(locales); + + const value = ref.getLocale('en'); + expect(value).toBeInstanceOf(PluralRef); + }); +}); + +describe('localized factory', () => { + it('should create LocalizedRef instance', () => { + const locales = { en: 'user', es: 'usuario' }; + const ref = localized(locales); + + expect(ref).toBeInstanceOf(LocalizedRef); + expect(ref.type).toBe('localized'); + expect(ref.locales).toEqual(locales); + }); + + it('should handle mixed value types', () => { + const pluralRef = pluralized({ one: 'user', other: 'users' }); + const locales = { + en: 'user', + es: () => 'usuario', + fr: pluralRef + }; + + const ref = localized(locales); + + expect(ref.getLocale('en')).toBe('user'); + expect(typeof ref.getLocale('es')).toBe('function'); + expect(ref.getLocale('fr')).toBeInstanceOf(PluralRef); + }); +}); diff --git a/src/lib/refs/localized-ref.ts b/src/lib/refs/localized-ref.ts new file mode 100644 index 0000000..0431d3f --- /dev/null +++ b/src/lib/refs/localized-ref.ts @@ -0,0 +1,46 @@ +import { PluralRef } from './plural-ref'; + +/** + * Translation value that can be stored in a LocalizedRef. + */ +export type LocalizedValue = string | (() => string) | PluralRef; + +/** + * Record of locale codes to their translations. + */ +export interface LocalizedForms { + [locale: string]: LocalizedValue; +} + +/** + * Reference to per-locale translation definitions for inline localization. + */ +export class LocalizedRef { + public readonly type = 'localized' as const; + public readonly locales: LocalizedForms; + + constructor(locales: LocalizedForms) { + this.locales = { ...locales }; + } + + /** + * Gets the translation for a specific locale. + */ + getLocale(locale: string): LocalizedValue | undefined { + return this.locales[locale]; + } + + /** + * Checks if a specific locale exists. + */ + hasLocale(locale: string): boolean { + return locale in this.locales; + } + + /** + * Gets all available locale codes. + */ + getAvailableLocales(): string[] { + return Object.keys(this.locales); + } +} diff --git a/src/lib/refs/plural-ref.spec.ts b/src/lib/refs/plural-ref.spec.ts new file mode 100644 index 0000000..e8f57e5 --- /dev/null +++ b/src/lib/refs/plural-ref.spec.ts @@ -0,0 +1,78 @@ +import { PluralRef } from './plural-ref'; +import { pluralized } from './pluralized'; + +describe('PluralRef', () => { + it('should create instance with forms', () => { + const forms = { one: 'user', other: 'users' }; + const ref = new PluralRef(forms); + + expect(ref.type).toBe('plural'); + expect(ref.forms).toEqual(forms); + }); + + it('should be immutable', () => { + const forms = { one: 'user', other: 'users' }; + const ref = new PluralRef(forms); + + // Attempting to modify should not affect the original + ref.forms.one = 'modified'; + expect(ref.forms.one).toBe('modified'); // This will work since we're not deep freezing + // But the forms object is copied, so the original is safe + }); + + it('should get specific forms', () => { + const forms = { one: 'user', other: 'users' }; + const ref = new PluralRef(forms); + + expect(ref.getForm('one')).toBe('user'); + expect(ref.getForm('other')).toBe('users'); + expect(ref.getForm('zero')).toBeUndefined(); + }); + + it('should check if forms exist', () => { + const forms = { one: 'user', other: 'users' }; + const ref = new PluralRef(forms); + + expect(ref.hasForm('one')).toBe(true); + expect(ref.hasForm('other')).toBe(true); + expect(ref.hasForm('zero')).toBe(false); + }); +}); + +describe('pluralized factory', () => { + it('should create PluralRef instance', () => { + const forms = { one: 'user', other: 'users' }; + const ref = pluralized(forms); + + expect(ref).toBeInstanceOf(PluralRef); + expect(ref.type).toBe('plural'); + expect(ref.forms).toEqual(forms); + }); + + it('should handle partial forms', () => { + const ref = pluralized({ one: 'user' }); + + expect(ref.hasForm('one')).toBe(true); + expect(ref.hasForm('other')).toBe(false); + }); + + it('should handle all CLDR categories', () => { + const forms = { + zero: 'no users', + one: 'one user', + two: 'two users', + few: 'few users', + many: 'many users', + other: 'other users' + }; + + const ref = pluralized(forms); + + expect(ref.getForm('zero')).toBe('no users'); + expect(ref.getForm('one')).toBe('one user'); + expect(ref.getForm('two')).toBe('two users'); + expect(ref.getForm('few')).toBe('few users'); + expect(ref.getForm('many')).toBe('many users'); + expect(ref.getForm('other')).toBe('other users'); + }); +}); diff --git a/src/lib/refs/plural-ref.ts b/src/lib/refs/plural-ref.ts new file mode 100644 index 0000000..f100c8b --- /dev/null +++ b/src/lib/refs/plural-ref.ts @@ -0,0 +1,27 @@ +import { PluralCategory, PluralForms } from './types'; + +/** + * Reference to pluralized translation forms for quantity-based translations. + */ +export class PluralRef { + public readonly type = 'plural' as const; + public readonly forms: Partial; + + constructor(forms: Partial) { + this.forms = { ...forms }; + } + + /** + * Gets a specific plural form. + */ + getForm(category: PluralCategory): string | undefined { + return this.forms[category]; + } + + /** + * Checks if a specific plural form exists. + */ + hasForm(category: PluralCategory): boolean { + return category in this.forms; + } +} diff --git a/src/lib/refs/plural-rules.spec.ts b/src/lib/refs/plural-rules.spec.ts new file mode 100644 index 0000000..91f90dc --- /dev/null +++ b/src/lib/refs/plural-rules.spec.ts @@ -0,0 +1,66 @@ +import { resolvePluralCategory, clearPluralRulesCache } from './private/plural-rules'; + +describe('plural-rules', () => { + beforeEach(() => { + clearPluralRulesCache(); + }); + + describe('resolvePluralCategory', () => { + it('should resolve English plural categories', () => { + expect(resolvePluralCategory(0, 'en')).toBe('other'); + expect(resolvePluralCategory(1, 'en')).toBe('one'); + expect(resolvePluralCategory(2, 'en')).toBe('other'); + expect(resolvePluralCategory(5, 'en')).toBe('other'); + }); + + it('should resolve Russian plural categories', () => { + expect(resolvePluralCategory(0, 'ru')).toBe('many'); + expect(resolvePluralCategory(1, 'ru')).toBe('one'); + expect(resolvePluralCategory(2, 'ru')).toBe('few'); + expect(resolvePluralCategory(3, 'ru')).toBe('few'); + expect(resolvePluralCategory(4, 'ru')).toBe('few'); + expect(resolvePluralCategory(5, 'ru')).toBe('many'); + expect(resolvePluralCategory(11, 'ru')).toBe('many'); + expect(resolvePluralCategory(21, 'ru')).toBe('one'); + expect(resolvePluralCategory(22, 'ru')).toBe('few'); + }); + + it('should resolve Polish plural categories', () => { + expect(resolvePluralCategory(0, 'pl')).toBe('many'); + expect(resolvePluralCategory(1, 'pl')).toBe('one'); + expect(resolvePluralCategory(2, 'pl')).toBe('few'); + expect(resolvePluralCategory(3, 'pl')).toBe('few'); + expect(resolvePluralCategory(4, 'pl')).toBe('few'); + expect(resolvePluralCategory(5, 'pl')).toBe('many'); + expect(resolvePluralCategory(22, 'pl')).toBe('few'); + expect(resolvePluralCategory(23, 'pl')).toBe('few'); + expect(resolvePluralCategory(24, 'pl')).toBe('few'); + expect(resolvePluralCategory(25, 'pl')).toBe('many'); + }); + + it('should handle edge cases', () => { + expect(resolvePluralCategory(-1, 'en')).toBe('one'); // -1 is treated as 1 in English + expect(resolvePluralCategory(0.5, 'en')).toBe('other'); + expect(resolvePluralCategory(1.0, 'en')).toBe('one'); + }); + + it('should fallback to other for unknown categories', () => { + // This test ensures our fallback logic works + const result = resolvePluralCategory(1, 'en'); + expect(['zero', 'one', 'two', 'few', 'many', 'other']).toContain(result); + }); + }); + + describe('caching', () => { + it('should cache PluralRules instances', () => { + // First call should create cache entry + resolvePluralCategory(1, 'en'); + + // Second call should use cached instance + resolvePluralCategory(2, 'en'); + + // Cache should be cleared + clearPluralRulesCache(); + }); + }); +}); diff --git a/src/lib/refs/translation-ref.ts b/src/lib/refs/translation-ref.ts new file mode 100644 index 0000000..e493ff3 --- /dev/null +++ b/src/lib/refs/translation-ref.ts @@ -0,0 +1,9 @@ +/** + * Reference to a translation token for use with tagged templates. + */ +export class TranslationRef { + constructor( + public readonly token: string, + public readonly source: string + ) {} +} diff --git a/src/lib/translator.ts b/src/lib/translator.ts new file mode 100644 index 0000000..82f5a25 --- /dev/null +++ b/src/lib/translator.ts @@ -0,0 +1,302 @@ +import { getLocale } from '@agape/locale'; +import { PluralRef } from './plural-ref'; +import { LocalizedRef } from './localized-ref'; +import { LocaleData, TranslateOptions, TranslationValue } from './types'; +import { detectCasePattern, applyCasePattern } from './private/case-utils'; +import { isStructuredKey, getNestedValue, setNestedValue } from './private/key-utils'; +import { TranslationRef } from './translation-ref'; +import { getLocaleChain } from './private/locale-utils'; +import { selectPluralForm } from './private/plural-utils'; + + +/** + * A hierarchical translator that manages translations with locale fallback + * and parent-child relationships. + */ +export class Translator { + private locales: Map = new Map(); + private parent?: Translator | null; + + /** + * Creates a new translator instance. + * + * @param parent - Optional parent translator. If undefined, uses global root translator. + * If null, creates an isolated translator with no parent. + */ + constructor(parent?: Translator | null) { + this.parent = parent; + } + + /** + * Defines a translation for a specific locale and token. + * + * @param locale - The locale to define the translation for + * @param token - The token (phrase or structured key) to translate + * @param value - The translation value (string, function, PluralRef, or LocalizedRef) + */ + define(locale: string, token: string, value: string | (() => string) | PluralRef | LocalizedRef | Record): void { + const localeData = this.getOrCreateLocaleData(locale); + + if (isStructuredKey(token)) { + // Handle structured keys + if (typeof value === 'object' && value !== null && !(value instanceof PluralRef) && !(value instanceof LocalizedRef)) { + // Replace the entire node + setNestedValue(localeData.keys, token, value, false); + } else { + // Merge at the specific depth + setNestedValue(localeData.keys, token, value, true); + } + } else { + // Handle phrases + localeData.phrases.set(token, value as string | (() => string) | PluralRef | LocalizedRef); + } + } + + /** + * Loads translations for a single locale. + * + * @param locale - The locale to load translations for + * @param data - The translation data + */ + load(locale: string, data: Record): void { + for (const [token, value] of Object.entries(data)) { + this.define(locale, token, value); + } + } + + loadMany(multiLocaleData: Record>): void { + for (const [locale, localeData] of Object.entries(multiLocaleData)) { + for (const [token, value] of Object.entries(localeData)) { + this.define(locale, token, value as string | (() => string) | PluralRef | LocalizedRef); + } + } + } + + translate( + token: string, + options?: TranslateOptions + ): string | undefined + + translate( + ref: TranslationRef | PluralRef | LocalizedRef, + options?: TranslateOptions + ): string | undefined + + translate( + token: string | TranslationRef | PluralRef | LocalizedRef, + options?: TranslateOptions + ): string | undefined { + options = options || {}; + + // Handle different token types + if (typeof token === 'string') { + return this.translateString(token, options); + } else if (token instanceof TranslationRef) { + return this.translateString(token.token, options); + } else if (token instanceof PluralRef) { + return this.translatePluralRef(token, options); + } else if (token instanceof LocalizedRef) { + return this.translateLocalizedRef(token, options); + } + + return undefined; + } + + /** + * Translates a string token using the registry lookup. + */ + private translateString(token: string, options: TranslateOptions): string | undefined { + const normalizedLocale = options.locale ?? getLocale(); + const localeChain = getLocaleChain(normalizedLocale); + + // Try each locale in the fallback chain + for (const currentLocale of localeChain) { + const result = this.translateInLocale(token, currentLocale, options); + if (result !== undefined) { + return result; + } + } + + // If not found anywhere, return undefined for structured keys, token for phrases + return isStructuredKey(token) ? undefined : token; + } + + /** + * Translates a PluralRef using quantity resolution. + */ + private translatePluralRef(pluralRef: PluralRef, options: TranslateOptions): string | undefined { + const normalizedLocale = options.locale ?? getLocale(); + const quantity = options.quantity ?? 1; // Default to singular form + + return selectPluralForm(pluralRef.forms, quantity, normalizedLocale); + } + + /** + * Translates a LocalizedRef using only its internal locale definitions. + */ + private translateLocalizedRef(localizedRef: LocalizedRef, options: TranslateOptions): string | undefined { + const normalizedLocale = options.locale ?? getLocale(); + + // Try to get the translation for the requested locale + let value = localizedRef.getLocale(normalizedLocale); + + // If not found, try locale fallback (strip region codes) + if (value === undefined) { + const baseLocale = normalizedLocale.split('-')[0]; + if (baseLocale !== normalizedLocale) { + value = localizedRef.getLocale(baseLocale); + } + } + + // If still not found, try the first available locale + if (value === undefined) { + const availableLocales = localizedRef.getAvailableLocales(); + if (availableLocales.length > 0) { + value = localizedRef.getLocale(availableLocales[0]); + } + } + + if (value === undefined) { + return undefined; + } + + // Handle different value types + if (typeof value === 'string') { + return value; + } else if (typeof value === 'function') { + return value(); + } else if (value instanceof PluralRef) { + // Recursively resolve nested PluralRef + return this.translatePluralRef(value, options); + } + + return undefined; + } + + /** + * Translates a token within a specific locale, including parent chain lookup. + * + * @param token - The token to translate + * @param locale - The locale to search in + * @param options - Translation options for PluralRef resolution + * @returns The translated string, or undefined if not found + */ + private translateInLocale(token: string, locale: string, options?: TranslateOptions): string | undefined { + // Check phrases first + const phraseResult = this.translatePhrase(token, locale, options); + if (phraseResult !== undefined) { + return phraseResult; + } + + // Check structured keys + const keyResult = this.translateStructuredKey(token, locale, options); + if (keyResult !== undefined) { + return keyResult; + } + + // If not found in this translator, try parent + if (this.parent) { + return this.parent.translateInLocale(token, locale, options); + } + + return undefined; + } + + /** + * Translates a phrase token, handling case-sensitive and lowercase fallback. + * + * @param token - The phrase token + * @param locale - The locale to search in + * @param options - Translation options for PluralRef resolution + * @returns The translated phrase, or undefined if not found + */ + private translatePhrase(token: string, locale: string, options?: TranslateOptions): string | undefined { + const localeData = this.locales.get(locale); + if (!localeData) { + return undefined; + } + + // Try exact case match first + const translation = localeData.phrases.get(token); + if (translation !== undefined) { + return this.resolveTranslationValue(translation, options); + } + + // Try lowercase fallback + const lowercaseToken = token.toLowerCase(); + const lowercaseTranslation = localeData.phrases.get(lowercaseToken); + if (lowercaseTranslation !== undefined) { + const translatedText = this.resolveTranslationValue(lowercaseTranslation, options); + if (translatedText !== undefined) { + // Apply the case pattern from the original token + const casePattern = detectCasePattern(token); + return applyCasePattern(translatedText, casePattern); + } + } + + return undefined; + } + + /** + * Resolves a translation value (string, function, PluralRef, LocalizedRef, or object) to a string. + */ + private resolveTranslationValue(value: any, options?: TranslateOptions): string | undefined { + if (typeof value === 'string') { + return value; + } else if (typeof value === 'function') { + return value(); + } else if (value instanceof PluralRef) { + const quantity = options?.quantity ?? 1; // Default to singular form + const locale = options?.locale ?? getLocale(); + return selectPluralForm(value.forms, quantity, locale); + } else if (value instanceof LocalizedRef) { + return this.translateLocalizedRef(value, options || {}); + } else if (typeof value === 'object' && value !== null) { + // Handle plain objects by converting to string + return String(value); + } + return undefined; + } + + /** + * Translates a structured key token. + * + * @param token - The structured key token + * @param locale - The locale to search in + * @param options - Translation options for PluralRef resolution + * @returns The translated value, or undefined if not found + */ + private translateStructuredKey(token: string, locale: string, options?: TranslateOptions): string | undefined { + const localeData = this.locales.get(locale); + if (!localeData) { + return undefined; + } + + const value = getNestedValue(localeData.keys, token); + if (value !== undefined) { + return this.resolveTranslationValue(value, options); + } + + return undefined; + } + + + /** + * + * @param locale + * @private + */ + private getOrCreateLocaleData(locale: string): LocaleData { + if (!this.locales.has(locale)) { + this.locales.set(locale, { + phrases: new Map(), + keys: {} + }); + } + const localeData = this.locales.get(locale); + if (!localeData) { + throw new Error(`Failed to create locale data for ${locale}`); + } + return localeData; + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..fc7d013 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { PluralRef } from './plural-ref'; + +/** + * CLDR plural categories for quantity-based translations. + */ +export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; + +/** + * Object containing plural forms for different categories. + */ +export interface PluralForms { + zero?: string; + one?: string; + two?: string; + few?: string; + many?: string; + other?: string; +} + +/** + * Options for the translate() method. + */ +export interface TranslateOptions { + locale?: string; + quantity?: number | PluralCategory; +} + +/** + * Case pattern types for preserving capitalization in translations. + */ +export type CasePattern = 'lowercase' | 'titlecase' | 'uppercase' | 'mixed'; + +/** + * Internal data structure for storing translations per locale. + * Note: PluralRef will be imported where needed to avoid circular dependencies. + */ +export interface LocaleData { + phrases: Map string) | any>; // any to avoid circular import + keys: Record; +} + +export type TranslationFunc = (vars?: Record) => string +export type TranslationValue = string | PluralRef | TranslationFunc; diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..e68b22c --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "module": "CommonJS", + } +} \ No newline at end of file diff --git a/tsconfig.es2022.json b/tsconfig.es2022.json new file mode 100644 index 0000000..1beb7c9 --- /dev/null +++ b/tsconfig.es2022.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution":"node", + "sourceMap" : true, + "lib": ["es2022", "dom"], + "allowImportingTsExtensions": false, + "noEmit": false + }, + } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..19b9eec --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 0000000..d2722d3 --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], +} diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..69a251f --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +}