diff --git a/.github/workflows/validate-merge-request.yml b/.github/workflows/validate-merge-request.yml new file mode 100644 index 0000000..44e0d27 --- /dev/null +++ b/.github/workflows/validate-merge-request.yml @@ -0,0 +1,79 @@ +name: Validate @agape/locale + +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-locale (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/locale" ]; 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/locale + cp -r . ../AgapeToolkit/libs/locale + + - 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/locale + working-directory: ../AgapeToolkit + run: npx nx lint locale + + - name: Run tests for @agape/locale + working-directory: ../AgapeToolkit + run: npx nx test locale --no-watch --ci diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..2c93473 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,2 @@ +npx nx lint locale +npx nx test locale --no-watch --ci diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c178f51 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +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. \ No newline at end of file diff --git a/README.md b/README.md index 6a919f7..88accdd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,14 @@ -# agape-locale +# @agape/locale + Locale settings + +--- + +## πŸ“š Documentation + +See the full API documentation at [agape.dev/api](https://agape.dev/api). + + +## πŸ“¦ Agape Toolkit + +This package is part of the [Agape Toolkit](https://github.com/AgapeToolkit/AgapeToolkit) - a comprehensive collection of TypeScript utilities and libraries for modern web development. diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..45969bf --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +export default { + displayName: 'agape-locale', + 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/locale', +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..876e398 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "@agape/locale", + "version": "0.1.0", + "description": "Locale management utilities with global state", + "main": "./cjs/index.js", + "module": "./es2020/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", + "locale", + "i18n", + "internationalization" + ], + "es2020": "./es2020/index.js", + "exports": { + "./package.json": { + "default": "./package.json" + }, + ".": { + "es2020": "./es2020/index.js", + "node": "./cjs/index.js", + "default": "./es2020/index.js", + "require": "./cjs/index.js", + "import": "./es2020/index.js" + } + } +} diff --git a/project.json b/project.json new file mode 100644 index 0000000..fd264c0 --- /dev/null +++ b/project.json @@ -0,0 +1,47 @@ +{ + "name": "locale", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/locale/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/locale", + "main": "libs/locale/src/index.ts", + "tsConfig": "libs/agape/tsconfig.lib.json", + "assets": ["libs/agape/*.md"] + } + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "node tools/scripts/publish.mjs locale {args.ver} {args.tag}" + }, + "dependsOn": ["build"] + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/locale/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/locale/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "tags": [] +} diff --git a/src/index.spec.ts b/src/index.spec.ts new file mode 100644 index 0000000..a11f1b7 --- /dev/null +++ b/src/index.spec.ts @@ -0,0 +1,117 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { getLocale, setLocale } from './index'; + +const LOCALE_KEY = Symbol.for("@agape/locale"); + +beforeEach(() => { + delete (globalThis as any)[LOCALE_KEY]; +}); + +describe('@agape/locale', () => { + describe('getLocale', () => { + it('should return system locale when no locale has been set', () => { + const systemLocale = Intl.DateTimeFormat().resolvedOptions().locale; + const result = getLocale(); + + expect(result).toBe(systemLocale); + expect(typeof result).toBe('string'); + }); + + it('should return the previously set locale', () => { + setLocale('fr-FR'); + const result = getLocale(); + + expect(result).toBe('fr-FR'); + }); + + it('should return system locale after resetting with null', () => { + setLocale('de-DE'); + setLocale(null); + + const systemLocale = Intl.DateTimeFormat().resolvedOptions().locale; + const result = getLocale(); + + expect(result).toBe(systemLocale); + }); + + it('should return system locale after resetting with undefined', () => { + setLocale('ja-JP'); + setLocale(undefined); + + const systemLocale = Intl.DateTimeFormat().resolvedOptions().locale; + const result = getLocale(); + + expect(result).toBe(systemLocale); + }); + }); + + describe('setLocale', () => { + it('should set a valid locale', () => { + setLocale('es-ES'); + expect(getLocale()).toBe('es-ES'); + }); + + it('should throw error for invalid locale', () => { + expect(() => setLocale('')).toThrow('Invalid locale: "". Must be a valid BCP 47 locale string.'); + expect(() => setLocale('123')).toThrow('Invalid locale: "123". Must be a valid BCP 47 locale string.'); + expect(() => setLocale('en-')).toThrow('Invalid locale: "en-". Must be a valid BCP 47 locale string.'); + }); + + it('should reset to system locale when passed null', () => { + setLocale('fr-FR'); + setLocale(null); + + const systemLocale = Intl.DateTimeFormat().resolvedOptions().locale; + expect(getLocale()).toBe(systemLocale); + }); + + it('should reset to system locale when passed undefined', () => { + setLocale('de-DE'); + setLocale(undefined); + + const systemLocale = Intl.DateTimeFormat().resolvedOptions().locale; + expect(getLocale()).toBe(systemLocale); + }); + + it('should accept various valid locale formats', () => { + const validLocales = ['en-US', 'fr-FR', 'de-DE', 'ja-JP', 'zh-CN', 'en', 'es-419']; + + validLocales.forEach(locale => { + setLocale(locale); + expect(getLocale()).toBe(locale); + }); + }); + }); + + describe('integration', () => { + it('should maintain state across multiple calls', () => { + // Start with system locale + const systemLocale = getLocale(); + expect(typeof systemLocale).toBe('string'); + + // Set a custom locale + setLocale('it-IT'); + expect(getLocale()).toBe('it-IT'); + + // Set another locale + setLocale('pt-BR'); + expect(getLocale()).toBe('pt-BR'); + + // Reset to system + setLocale(null); + expect(getLocale()).toBe(systemLocale); + }); + + it('should handle rapid successive calls', () => { + setLocale('en-US'); + expect(getLocale()).toBe('en-US'); + + setLocale('fr-FR'); + expect(getLocale()).toBe('fr-FR'); + + setLocale('de-DE'); + expect(getLocale()).toBe('de-DE'); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7faff70 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,57 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const g = globalThis as any; +const LOCALE_KEY = Symbol.for("@agape/locale"); + +/** + * Validates if a locale string is valid according to what Intl.DateTimeFormat supports. + * + * @param locale - The locale string to validate + * @returns True if the locale is valid, false otherwise + */ +function isValidLocale(locale: string): boolean { + if (!locale || typeof locale !== 'string' || locale.trim() === '') { + return false; + } + + try { + // Simply test if Intl.DateTimeFormat can use this locale + new Intl.DateTimeFormat(locale); + return true; + } catch { + return false; + } +} + +/** + * Get the current locale. + * + * - Returns the locale previously set with {@link setLocale} or the system locale + * if no locale has been set. + */ +export function getLocale(): string { + + if (g[LOCALE_KEY]) return g[LOCALE_KEY]; + + g[LOCALE_KEY] = Intl.DateTimeFormat().resolvedOptions().locale; + return g[LOCALE_KEY]; +} + +/** + * Set the current locale. + * + * @param locale - The BCP 47 locale string to use (e.g. "en-US", "fr-FR"), or null/undefined to reset to system locale. + * @throws Error if the locale string is invalid + */ +export function setLocale(locale: string | null | undefined): void { + if (locale === null || locale === undefined) { + // Reset to system locale + g[LOCALE_KEY] = Intl.DateTimeFormat().resolvedOptions().locale; + return; + } + + if (!isValidLocale(locale)) { + throw new Error(`Invalid locale: "${locale}". Must be a valid BCP 47 locale string.`); + } + + g[LOCALE_KEY] = locale; +} 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.es2020.json b/tsconfig.es2020.json new file mode 100644 index 0000000..ba6782f --- /dev/null +++ b/tsconfig.es2020.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "target": "es2020", + "module": "es2020", + "moduleResolution":"node", + "sourceMap" : true, + }, + } \ No newline at end of file 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" + ] +}