├── .eslintrc ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── __tests__ ├── __mocks__ │ └── lang │ │ ├── de.json │ │ ├── en.json │ │ ├── en │ │ ├── auth.php │ │ ├── custom.php │ │ ├── random.php │ │ └── validation.php │ │ ├── fr │ │ ├── auth.php │ │ └── random.php │ │ ├── it │ │ └── auth.php │ │ ├── nl.json │ │ ├── php_en.json │ │ ├── php_fr.json │ │ ├── php_it.json │ │ └── uk.json ├── jest-setup.ts ├── plugin │ ├── helper.test.ts │ ├── locale.test.ts │ └── parser.test.ts ├── provider.test.tsx └── utils │ ├── pluralization.test.ts │ ├── recognizer.test.ts │ ├── replacer.test.ts │ └── resolver.test.ts ├── babel.config.json ├── jest.config.json ├── package-lock.json ├── package.json ├── src ├── @types │ └── php-array-reader.d.ts ├── context.ts ├── contrib │ └── get-plural-index.ts ├── hook.ts ├── index.ts ├── interfaces │ ├── context.ts │ ├── default-options.ts │ ├── i18n-provider-props.ts │ ├── locale-file.ts │ ├── options-provider.ts │ ├── options.ts │ └── replacements.ts ├── plugin │ ├── helper.ts │ ├── key-type.ts │ ├── locale.ts │ └── parser.ts ├── provider.ts ├── utils │ ├── pluralization.ts │ ├── recognizer.ts │ ├── replacer.ts │ └── resolver.ts └── vite.ts ├── tsconfig.client.json ├── tsconfig.commonjs.json ├── tsconfig.json ├── tsconfig.plugin.json └── vite.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "jest": true 5 | }, 6 | "extends": [ 7 | "airbnb-typescript", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:jest/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "plugins": ["import", "react", "@typescript-eslint", "jest"], 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true 16 | }, 17 | "ecmaVersion": 2018, 18 | "sourceType": "module", 19 | "project": "tsconfig.json" 20 | }, 21 | "rules": { 22 | "@typescript-eslint/no-unused-vars": "off", 23 | "@typescript-eslint/no-use-before-define": "off", 24 | "@typescript-eslint/no-shadow": "off", 25 | "consistent-return": "off", 26 | "import/no-extraneous-dependencies": "off", 27 | "import/prefer-default-export": "off", 28 | "no-nested-ternary": "off", 29 | "no-param-reassign": "off", 30 | "no-promise-executor-return": "off", 31 | "prefer-destructuring": "off" 32 | } 33 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [ 16.x, 18.x] 17 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'npm' 26 | - run: npm install 27 | - run: npm test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.fleet 2 | /.idea 3 | /.vscode 4 | /node_modules 5 | /coverage 6 | /dist 7 | npm-debug.log* 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | __tests__ 4 | /.fleet 5 | /.idea 6 | /.vscode -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120, 4 | "trailingComma": "none" 5 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Eugene Meles xmelesx@gmail.com 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Laravel React i18n 3 |

4 | 5 |

6 | GitHub Workflow Status (master) 7 | License 8 | Version 9 | Total Downloads 10 |

11 | 12 |

13 | laravel-react-i18n is a React plugin that allows to connect your Laravel Framework translation 14 | files with React. It uses the same logic used on Laravel Localization. 15 |

16 | 17 | ## Demo 18 | [laravel-react-i18n-playground](https://github.com/EugeneMeles/laravel-react-i18n-playground) 19 | 20 | ## Requirements 21 | - Laravel >= 9 22 | - NodeJS >= 16 23 | - Vite 24 | 25 | ## Installation 26 | 27 | With [npm](https://www.npmjs.com): 28 | ```sh 29 | npm i laravel-react-i18n 30 | ``` 31 | 32 | or with [yarn](https://yarnpkg.com): 33 | ```sh 34 | yarn add laravel-react-i18n 35 | ``` 36 | 37 | ## Setup 38 | 39 | 40 | #### CSR (Client Side Rendering) 41 | 42 | `app.tsx:` 43 | 44 | ```tsx 45 | import './bootstrap'; 46 | import '../css/app.css'; 47 | 48 | import { createRoot } from 'react-dom/client'; 49 | import { createInertiaApp } from '@inertiajs/react'; 50 | import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; 51 | import { LaravelReactI18nProvider } from 'laravel-react-i18n'; 52 | 53 | const appName = window.document.getElementsByTagName('title')[0]?.innerText || 'Laravel'; 54 | 55 | createInertiaApp({ 56 | title: (title) => `${title} - ${appName}`, 57 | resolve: (name) => resolvePageComponent(`./Pages/${name}.tsx`, import.meta.glob('./Pages/**/*.tsx')), 58 | setup({ el, App, props }) { 59 | const root = createRoot(el); 60 | 61 | root.render( 62 | 67 | 68 | 69 | ); 70 | }, 71 | progress: { 72 | color: '#4B5563', 73 | }, 74 | }); 75 | 76 | ``` 77 | 78 | #### SSR (Server Side Rendering) 79 | 80 | `ssr.tsx:` 81 | 82 | ```tsx 83 | import ReactDOMServer from 'react-dom/server'; 84 | import { createInertiaApp } from '@inertiajs/react'; 85 | import createServer from '@inertiajs/react/server'; 86 | import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; 87 | import route from '../../vendor/tightenco/ziggy/dist/index.m'; 88 | import { LaravelReactI18nProvider } from 'laravel-react-i18n'; 89 | 90 | const appName = 'Laravel'; 91 | 92 | createServer((page) => 93 | createInertiaApp({ 94 | page, 95 | render: ReactDOMServer.renderToString, 96 | title: (title) => `${title} - ${appName}`, 97 | resolve: (name) => resolvePageComponent(`./Pages/${name}.tsx`, import.meta.glob('./Pages/**/*.tsx')), 98 | setup: ({ App, props }) => { 99 | global.route = (name, params, absolute) => 100 | route(name, params, absolute, { 101 | // @ts-expect-error 102 | ...page.props.ziggy, 103 | // @ts-expect-error 104 | location: new URL(page.props.ziggy.location), 105 | }); 106 | 107 | return ( 108 | 113 | 114 | 115 | ); 116 | }, 117 | }) 118 | ); 119 | 120 | ``` 121 | 122 | #### PHP Translations Available on React 123 | 124 | In order to load `php` translations, you can use this `Vite` plugin. 125 | 126 | `vite.config.js:` 127 | 128 | ```js 129 | import i18n from 'laravel-react-i18n/vite'; // <-- add this 130 | 131 | export default defineConfig({ 132 | plugins: [ 133 | laravel([ 134 | 'resources/css/app.css', 135 | 'resources/js/app.js' 136 | ]), 137 | react(), 138 | i18n(), // <-- add this 139 | ], 140 | }); 141 | ``` 142 | 143 | > During the `npm run dev` execution time, the plugin will create some files like this `php_{lang}.json` on your lang folder. 144 | > And to avoid that to be commited to your code base, I suggest to your `.gitignore` this like: 145 | 146 | ``` 147 | lang/php_*.json 148 | ``` 149 | 150 | ## Usage 151 | 152 | 153 | ### Provider Options 154 | 155 | - `locale` *(optional)*: If not provided it will try to find from the `` tag or set `en`. 156 | - `fallbackLocale` *(optional)*: If the `locale` was not provided or is invalid, it will try reach for this `fallbackLocale` instead, default it will try to find from the `` tag or set `en`. 157 | - `files` *(required)*: The way to reach your language files. 158 | 159 | ```js 160 | 164 | ... 165 | ``` 166 | 167 | ### Hook Response: 168 | ```tsx 169 | ... 170 | import { useLaravelReactI18n } from 'laravel-react-i18n'; 171 | 172 | export default function Component() { 173 | const { t, tChoice, currentLocale, setLocale, getLocales, isLocale, loading } = useLaravelReactI18n(); 174 | ... 175 | } 176 | 177 | ``` 178 | 179 | ### `t(key: string, replacements?: {[key: string]: string | number})` 180 | 181 | The `t()` method can translate a given message. 182 | 183 | `lang/pt.json:` 184 | ```json 185 | { 186 | "Welcome!": "Bem-vindo!", 187 | "Welcome, :name!": "Bem-vindo, :name!" 188 | } 189 | ``` 190 | 191 | `welcome.tsx:` 192 | ```tsx 193 | ... 194 | const { t } = useLaravelReactI18n(); 195 | 196 | t('Welcome!'); // Bem-vindo! 197 | t('Welcome, :name!', { name: 'Francisco' }); // Bem-vindo Francisco! 198 | t('Welcome, :NAME!', { name: 'Francisco' }); // Bem-vindo FRANCISCO! 199 | t('Some untranslated'); // Some untranslated 200 | ... 201 | ``` 202 | 203 | ### `tChoice(key: string, count: number, replacements?: {[key: string]: string | number})` 204 | 205 | The `tChoice()` method can translate a given message based on a count, 206 | there is also available an `trans_choice` alias, and a mixin called `$tChoice()`. 207 | 208 | `lang/pt.json:` 209 | ```json 210 | { 211 | "There is one apple|There are many apples": "Existe uma maça|Existe muitas maças", 212 | "{0} There are none|[1,19] There are some|[20,*] There are many": "Não tem|Tem algumas|Tem muitas", 213 | "{1} :count minute ago|[2,*] :count minutes ago": "{1} há :count minuto|[2,*] há :count minutos", 214 | } 215 | ``` 216 | 217 | `choice.tsx:` 218 | ```tsx 219 | ... 220 | const { tChoice } = useLaravelReactI18n() 221 | 222 | tChoice('There is one apple|There are many apples', 1); // Existe uma maça 223 | tChoice('{0} There are none|[1,19] There are some|[20,*] There are many', 19); // Tem algumas 224 | tChoice('{1} :count minute ago|[2,*] :count minutes ago', 10); // Há 10 minutos. 225 | ... 226 | ``` 227 | 228 | ### `currentLocale()` 229 | 230 | The `currentLocale()` returns the locale that is currently being used. 231 | 232 | ```tsx 233 | const { currentLocale } = useLaravelReactI18n() 234 | 235 | currentLocale(); // en 236 | ``` 237 | 238 | ### `setLocale(locale: string)` 239 | 240 | The `setLocale()` can be used to change the locale during the runtime. 241 | 242 | ```tsx 243 | const { currentLocale, setLocale } = useLaravelReactI18n(); 244 | 245 | function handler() { 246 | setLocale('it') 247 | } 248 | 249 | return ( 250 |
251 |

Current locale: `{currentLocale()}`

252 | 253 |
254 | ) 255 | ``` 256 | 257 | ### `getLocales()` 258 | 259 | The `getLocales()` return string array with all locales available in folder `/lang/*`. 260 | 261 | ```text 262 | /lang/.. 263 | de.json 264 | en.json 265 | nl.json 266 | uk.json 267 | ``` 268 | 269 | `myLocales.tsx:` 270 | ```tsx 271 | const { getLocales } = useLaravelReactI18n(); 272 | 273 | getLocales(); // ['de', 'en', 'nl', 'uk'] 274 | ``` 275 | 276 | ### `isLocale(locale: string)` 277 | 278 | The `isLocale()` method checks the locale is available in folder `/lang/*`. 279 | 280 | ```tsx 281 | const { isLocale } = useLaravelReactI18n(); 282 | 283 | isLocale('uk'); // true 284 | isLocale('fr'); // false 285 | ``` 286 | 287 | ### `loading` 288 | 289 | The `loading` show current loading state, only on client side where you change the locale. 290 | 291 | ```tsx 292 | const { loading, currentLocale, setLocale } = useLaravelReactI18n(); 293 | 294 | function handler() { 295 | setLocale('it') 296 | } 297 | 298 | if (loading) return

Loading...

; 299 | 300 | return ( 301 |
302 |

Current locale: `{currentLocale()}`

303 | 304 |
305 | ) 306 | ``` 307 | -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "30 Days": "30 Tage", 3 | ":amount Total": ":amount Gesamt", 4 | "Actions": "Aktionen" 5 | } -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "30 Days": "30 Days", 3 | ":amount Total": ":Amount Total", 4 | "Accept Invitation": "Accept Invitation" 5 | } 6 | -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/en/auth.php: -------------------------------------------------------------------------------- 1 | 'These credentials do not match our records.', 17 | 'password' => 'The provided password is incorrect.', 18 | 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 19 | 20 | ]; 21 | -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/en/custom.php: -------------------------------------------------------------------------------- 1 | 'Welcome', 5 | 'apple_count' => ':count apple|:count apples', 6 | 'min_ago' => ':count minute ago|:count minutes ago', 7 | 'min_left_for_reason' => ':count minute left for :reason|:count minutes left for :reason', 8 | 'sub_level1' => [ 9 | 'text' => 'I\'m sub level 1', 10 | 'sub_level2' => [ 11 | 'text' => 'I\'m sub level 2', 12 | 'sub_level3' => [ 13 | 'text' => 'I\'m sub level 3', 14 | 'sub_level4' => [ 15 | 'text' => 'I\'m sub level 4' 16 | ] 17 | ] 18 | ] 19 | ], 20 | ]; 21 | -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/en/random.php: -------------------------------------------------------------------------------- 1 | 'The :attribute field must be accepted.', 17 | 'between' => [ 18 | 'array' => 'The :attribute field must have between :min and :max items.', 19 | ], 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Custom Validation Language Lines 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Here you may specify custom validation messages for attributes using the 27 | | convention "attribute.rule" to name the lines. This makes it quick to 28 | | specify a specific custom language line for a given attribute rule. 29 | | 30 | */ 31 | 32 | 'custom' => [ 33 | 'attribute-name' => [ 34 | 'rule-name' => 'custom-message', 35 | ], 36 | ], 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Custom Validation Attributes 41 | |-------------------------------------------------------------------------- 42 | | 43 | | The following language lines are used to swap our attribute placeholder 44 | | with something more reader friendly such as "E-Mail Address" instead 45 | | of "email". This simply helps us make our message more expressive. 46 | | 47 | */ 48 | 49 | 'attributes' => [], 50 | 51 | ]; 52 | -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/fr/auth.php: -------------------------------------------------------------------------------- 1 | 'Ces identifiants ne correspondent pas à nos enregistrements.', 16 | 'password' => 'Le mot de passe fourni est incorrect.', 17 | 'throttle' => 'Tentatives de connexion trop nombreuses. Veuillez essayer de nouveau dans :seconds secondes.', 18 | ]; 19 | -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/fr/random.php: -------------------------------------------------------------------------------- 1 | 'Credenziali non valide.', 16 | 'password' => 'La password non è valida.', 17 | 'throttle' => 'Troppi tentativi di accesso. Riprova tra :seconds secondi.', 18 | ]; 19 | -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "30 Days": "30 dagen", 3 | ":amount Total": ":amount Totaal", 4 | "Actions": "Acties" 5 | } -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/php_en.json: -------------------------------------------------------------------------------- 1 | {"auth.failed":"These credentials do not match our records.","auth.password":"The provided password is incorrect.","auth.throttle":"Too many login attempts. Please try again in :seconds seconds.","custom.Welcome":"Welcome","custom.apple_count":":count apple|:count apples","custom.min_ago":":count minute ago|:count minutes ago","custom.min_left_for_reason":":count minute left for :reason|:count minutes left for :reason","custom.sub_level1.text":"I'm sub level 1","custom.sub_level1.sub_level2.text":"I'm sub level 2","custom.sub_level1.sub_level2.sub_level3.text":"I'm sub level 3","custom.sub_level1.sub_level2.sub_level3.sub_level4.text":"I'm sub level 4","validation.accepted":"The :attribute field must be accepted.","validation.between.array":"The :attribute field must have between :min and :max items.","validation.custom.attribute-name.rule-name":"custom-message","validation.attributes":[]} -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/php_fr.json: -------------------------------------------------------------------------------- 1 | {"auth.failed":"Ces identifiants ne correspondent pas à nos enregistrements.","auth.password":"Le mot de passe fourni est incorrect.","auth.throttle":"Tentatives de connexion trop nombreuses. Veuillez essayer de nouveau dans :seconds secondes."} -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/php_it.json: -------------------------------------------------------------------------------- 1 | {"auth.failed":"Credenziali non valide.","auth.password":"La password non è valida.","auth.throttle":"Troppi tentativi di accesso. Riprova tra :seconds secondi."} -------------------------------------------------------------------------------- /__tests__/__mocks__/lang/uk.json: -------------------------------------------------------------------------------- 1 | { 2 | "30 Days": "30 днів", 3 | ":amount Total": "Всього :amount", 4 | "Action": "Дія" 5 | } -------------------------------------------------------------------------------- /__tests__/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import fs from 'fs'; 3 | import { resolve } from 'path'; 4 | import locale from '../src/plugin/locale'; 5 | import parser from '../src/plugin/parser'; 6 | 7 | const clientFile: any = {}; 8 | const serverFile: any = {}; 9 | 10 | let files: { path: string; basename: string }[] = []; 11 | 12 | const mock = (dirName: string) => resolve(`./__tests__/__mocks__/${dirName}`); 13 | 14 | beforeAll(() => { 15 | const dirName = mock('lang'); 16 | const phpLocales = locale.getPhpLocale(dirName); 17 | 18 | if (phpLocales.length > 0) { 19 | files = parser(dirName); 20 | } 21 | 22 | fs.readdirSync(dirName) 23 | .filter((basename) => !fs.statSync(dirName + '/' + basename).isDirectory() && basename.endsWith('.json')) 24 | .map((file) => { 25 | const path = dirName + '/' + file; 26 | clientFile[path] = import(path); 27 | serverFile[path] = require(path); 28 | }); 29 | }); 30 | 31 | export { clientFile, serverFile, mock }; 32 | -------------------------------------------------------------------------------- /__tests__/plugin/helper.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { dirnameSanitize } from '../../src/plugin/helper'; 3 | 4 | const { sep } = path; 5 | 6 | describe('helper', () => { 7 | it.each([ 8 | ['/laravel/lang/', `${sep}laravel${sep}lang${sep}`], 9 | ['/laravel/lang', `${sep}laravel${sep}lang${sep}`], 10 | ['/laravel/lang//', `${sep}laravel${sep}lang${sep}`], 11 | ['/laravel/lang/sub', `${sep}laravel${sep}lang${sep}sub${sep}`], 12 | 13 | ['\\laravel\\lang\\', `${sep}laravel${sep}lang${sep}`], 14 | ['\\laravel\\lang', `${sep}laravel${sep}lang${sep}`], 15 | ['\\laravel\\lang\\\\', `${sep}laravel${sep}lang${sep}`], 16 | ['\\laravel\\lang\\sub', `${sep}laravel${sep}lang${sep}sub${sep}`] 17 | ])('dirnameSanitize', (rawDirname, expected) => { 18 | expect(dirnameSanitize(rawDirname)).toEqual(expected); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /__tests__/plugin/locale.test.ts: -------------------------------------------------------------------------------- 1 | import { mock } from '../jest-setup'; 2 | 3 | import locale from '../../src/plugin/locale'; 4 | 5 | const jsonLocales = ['de', 'en', 'nl', 'uk']; 6 | const phpLocales = ['en', 'fr', 'it']; 7 | 8 | describe('locale', () => { 9 | it.each([ 10 | [mock('lang'), jsonLocales], 11 | [mock('langError'), []] 12 | ])('getJsonLocale', (dirname, expected) => { 13 | expect(locale.getJsonLocale(dirname)).toMatchObject(expected); 14 | }); 15 | 16 | it.each([ 17 | [mock('lang'), phpLocales], 18 | [mock('langError'), []] 19 | ])('getJsonLocale', (dirname, expected) => { 20 | expect(locale.getPhpLocale(dirname)).toMatchObject(expected); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /__tests__/plugin/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { mock } from '../jest-setup'; 2 | import parser from '../../src/plugin/parser'; 3 | 4 | const result = [ 5 | { 6 | basename: 'php_en.json', 7 | path: `${mock('lang')}/php_en.json` 8 | }, 9 | { 10 | basename: 'php_fr.json', 11 | path: `${mock('lang')}/php_fr.json` 12 | }, 13 | { 14 | basename: 'php_it.json', 15 | path: `${mock('lang')}/php_it.json` 16 | } 17 | ]; 18 | 19 | describe('parser', () => { 20 | it.each([ 21 | [mock('lang'), result], 22 | [mock('langError'), []] 23 | ])('parse php-file', (dirname, expected) => { 24 | expect(parser(dirname)).toMatchObject(expected); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /__tests__/provider.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReplacementsInterface from '../src/interfaces/replacements'; 3 | 4 | const locales = ['de', 'en', 'fr', 'it', 'nl', 'uk']; 5 | 6 | const tHookCases: [string, undefined | ReplacementsInterface, string | []][] = [ 7 | ['130 Days', undefined, '130 Days'], 8 | [':amount Total', { amount: 'ten' }, 'Ten Total'], 9 | [':amount Total', { amount: 12 }, '12 Total'], 10 | ['Accept Invitation', undefined, 'Accept Invitation'], 11 | 12 | ['validation.accepted', { attribute: 'your' }, 'The your field must be accepted.'], 13 | ['validation.accepted', undefined, 'The :attribute field must be accepted.'], 14 | [ 15 | 'validation.between.array', 16 | { attribute: '"my custom"', min: 1, max: 10 }, 17 | 'The "my custom" field must have between 1 and 10 items.' 18 | ], 19 | ['validation.between.array', undefined, 'The :attribute field must have between :min and :max items.'], 20 | ['validation.custom.attribute-name.rule-name', undefined, 'custom-message'], 21 | ['validation.attributes', undefined, []], 22 | 23 | ['custom.Welcome', undefined, 'Welcome'], 24 | ['custom.sub_level1.text', undefined, "I'm sub level 1"], 25 | ['custom.sub_level1.sub_level2.text', undefined, "I'm sub level 2"], 26 | ['custom.sub_level1.sub_level2.sub_level3.text', undefined, "I'm sub level 3"], 27 | ['custom.sub_level1.sub_level2.sub_level3.sub_level4.text', undefined, "I'm sub level 4"] 28 | ]; 29 | 30 | const tChoiceHookCases: [string, number, undefined | ReplacementsInterface, string][] = [ 31 | ['custom.apple_count', 1, undefined, '1 apple'], 32 | ['custom.apple_count', 10, undefined, '10 apples'], 33 | ['custom.min_ago', 1, undefined, '1 minute ago'], 34 | ['custom.min_ago', 4, undefined, '4 minutes ago'], 35 | ['custom.min_left_for_reason', 1, undefined, '1 minute left for :reason'], 36 | ['custom.min_left_for_reason', 1, { reason: 'auto logout' }, '1 minute left for auto logout'], 37 | ['custom.min_left_for_reason', 15, { reason: 'auto logout' }, '15 minutes left for auto logout'] 38 | ]; 39 | 40 | test.todo('provider testing'); 41 | -------------------------------------------------------------------------------- /__tests__/utils/pluralization.test.ts: -------------------------------------------------------------------------------- 1 | import pluralization from '../../src/utils/pluralization'; 2 | 3 | it.each([ 4 | ['first', 'first', 1], 5 | ['first', 'first', 10], 6 | ['first', 'first|second', 1], 7 | ['second', 'first|second', 10], 8 | ['second', 'first|second', 0], 9 | 10 | ['first', '{0} first|{1}second', 0], 11 | ['first', '{1}first|{2}second', 1], 12 | ['second', '{1}first|{2}second', 2], 13 | ['first', '{2}first|{1}second', 2], 14 | ['second', '{9}first|{10}second', 0], 15 | ['first', '{9}first|{10}second', 1], 16 | ['', '{0}|{1}second', 0], 17 | ['', '{0}first|{1}', 1], 18 | ['first', '{1.3}first|{2.3}second', 1.3], 19 | ['second', '{1.3}first|{2.3}second', 2.3], 20 | ['first line', '{1}first line|{2}second', 1], 21 | ['first \nline', '{1}first \nline|{2}second', 1], 22 | 23 | ['first', '{0} first|[1,9]second', 0], 24 | ['second', '{0}first|[1,9]second', 1], 25 | ['second', '{0}first|[1,9]second', 10], 26 | ['first', '{0}first|[2,9]second', 1], 27 | ['second', '[4,*]first|[1,3]second', 1], 28 | ['first', '[4,*]first|[1,3]second', 100], 29 | ['second', '[1,5]first|[6,10]second', 7], 30 | ['first', '[*,4]first|[5,*]second', 1], 31 | ['second', '[5,*]first|[*,4]second', 1], 32 | ['second', '[5,*]first|[*,4]second', 0], 33 | 34 | ['first', '{0}first|[1,3]second|[4,*]third', 0], 35 | ['second', '{0}first|[1,3]second|[4,*]third', 1], 36 | ['second', '{0}first|[1,3]second|[4,*]third', 2], 37 | ['second', '{0}first|[1,3]second|[4,*]third', 3], 38 | ['third', '{0}first|[1,3]second|[4,*]third', 4], 39 | ['third', '{0}first|[1,3]second|[4,*]third', 100], 40 | 41 | ['second', 'first|second|third', 0], 42 | ['first', 'first|second|third', 1], 43 | ['second', 'first|second|third', 9], 44 | 45 | ['first', '{0} first | { 1 } second', 0], 46 | ['first', '[4,*]first | [1,3]second', 100] 47 | ])('translates pluralization', (expected, message, number) => { 48 | expect(pluralization(message, number, 'en')).toBe(expected); 49 | }); 50 | 51 | it.each([ 52 | ['en-US', 0, 'second'], 53 | ['en_US', 1, 'first'], 54 | ['en', 2, 'second'], 55 | ['fr', 0, 'first'], 56 | ['fr', 1, 'first'], 57 | ['fr', 2, 'second'], 58 | ['be', 0, 'third'], 59 | ['be', 1, 'first'], 60 | ['be', 3, 'second'], 61 | ['sk', 0, 'third'], 62 | ['sk', 1, 'first'], 63 | ['sk', 2, 'second'], 64 | ['ga', 0, 'third'], 65 | ['ga', 1, 'first'], 66 | ['ga', 2, 'second'], 67 | ['lt', 0, 'third'], 68 | ['lt', 1, 'first'], 69 | ['lt', 2, 'second'], 70 | ['sl', 0, 'fourth'], 71 | ['sl', 1, 'first'], 72 | ['sl', 2, 'second'], 73 | ['sl', 4, 'third'], 74 | ['mk', 0, 'second'], 75 | ['mk', 1, 'first'], 76 | ['mt', 0, 'second'], 77 | ['mt', 1, 'first'], 78 | ['mt', 11, 'third'], 79 | ['mt', 21, 'fourth'], 80 | ['lv', 0, 'first'], 81 | ['lv', 1, 'second'], 82 | ['lv', 2, 'third'], 83 | ['pl', 0, 'third'], 84 | ['pl', 1, 'first'], 85 | ['pl', 2, 'second'], 86 | ['cy', 0, 'fourth'], 87 | ['cy', 1, 'first'], 88 | ['cy', 2, 'second'], 89 | ['cy', 8, 'third'], 90 | ['ro', 0, 'second'], 91 | ['ro', 1, 'first'], 92 | ['ro', 21, 'third'], 93 | ['ar', 0, 'first'], 94 | ['ar', 1, 'second'], 95 | ['ar', 2, 'third'], 96 | ['ar', 3, 'fourth'], 97 | ['ar', 12, 'fifth'], 98 | ['ar', 99.1, 'sixth'], 99 | ['az', 0, 'first'], 100 | ['az', 1, 'first'], 101 | ['random', 0, 'first'] 102 | ])('translates each locale with the correct plural', (locale, number, correctMessage) => { 103 | const message = 'first|second|third|fourth|fifth|sixth'; 104 | 105 | expect(pluralization(message, number, locale)).toBe(correctMessage); 106 | }); 107 | -------------------------------------------------------------------------------- /__tests__/utils/recognizer.test.ts: -------------------------------------------------------------------------------- 1 | describe('recognizer', () => { 2 | test.todo('---'); 3 | }); 4 | -------------------------------------------------------------------------------- /__tests__/utils/replacer.test.ts: -------------------------------------------------------------------------------- 1 | import replacer from '../../src/utils/replacer'; 2 | 3 | it.each([ 4 | ['some text', ':replace', 'some text'], 5 | ['some text', ':replace', 'some text'], 6 | [' some text ', ' :replace ', 'some text'], 7 | [' some text ', ':replace', ' some text '], 8 | ['\nsome text', '\n:replace', 'some text'], 9 | ['some\ntext', ':replace', 'some\ntext'], 10 | 11 | ['"some text"', '":replace"', 'some text'], 12 | ['
some text
', '
:replace
', 'some text'], 13 | 14 | ['some text', ':replace', 'some text'], 15 | ['Some text', ':Replace', 'some text'], 16 | ['SOME TEXT', ':REPLACE', 'some text'], 17 | 18 | ['Lorem Ipsum some text', 'Lorem Ipsum :replace', 'some text'], 19 | ['some text Lorem Ipsum', ':replace Lorem Ipsum', 'some text'], 20 | ['Lorem Ipsum some text Lorem Ipsum', 'Lorem Ipsum :replace Lorem Ipsum', 'some text'], 21 | 22 | ['some text some text', ':replace :replace', 'some text'], 23 | [':anyReplace', ':anyReplace', 'some text'] 24 | ])('replacer', (expected, message, replace) => { 25 | expect(replacer(message, { replace })).toBe(expected); 26 | }); 27 | 28 | test('Sentence replace', () => { 29 | const expected = 30 | 'It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.'; 31 | 32 | const message = 33 | 'It has survived not only :count centuries, but also the leap into :epochType typesetting, remaining :remaining unchanged.'; 34 | 35 | const replacements = { count: 'five', epochType: 'electronic', remaining: 'essentially' }; 36 | 37 | expect(replacer(message, replacements)).toBe(expected); 38 | }); 39 | -------------------------------------------------------------------------------- /__tests__/utils/resolver.test.ts: -------------------------------------------------------------------------------- 1 | import { clientFile, serverFile } from '../jest-setup'; 2 | import resolver from '../../src/utils/resolver'; 3 | 4 | const results = { 5 | en: { 6 | json: { 7 | '30 Days': '30 Days', 8 | ':amount Total': ':Amount Total', 9 | 'Accept Invitation': 'Accept Invitation' 10 | }, 11 | php: { 12 | 'auth.failed': 'These credentials do not match our records.', 13 | 'auth.password': 'The provided password is incorrect.', 14 | 'auth.throttle': 'Too many login attempts. Please try again in :seconds seconds.', 15 | 'custom.Welcome': 'Welcome', 16 | 'custom.apple_count': ':count apple|:count apples', 17 | 'custom.min_ago': ':count minute ago|:count minutes ago', 18 | 'custom.sub_level1.text': "I'm sub level 1", 19 | 'custom.sub_level1.sub_level2.text': "I'm sub level 2", 20 | 'custom.sub_level1.sub_level2.sub_level3.text': "I'm sub level 3", 21 | 'custom.sub_level1.sub_level2.sub_level3.sub_level4.text': "I'm sub level 4", 22 | 'validation.accepted': 'The :attribute field must be accepted.', 23 | 'validation.between.array': 'The :attribute field must have between :min and :max items.', 24 | 'validation.custom.attribute-name.rule-name': 'custom-message', 25 | 'validation.attributes': [] 26 | } 27 | }, 28 | uk: { 29 | json: { 30 | '30 Days': '30 днів', 31 | ':amount Total': 'Всього :amount', 32 | Action: 'Дія' 33 | }, 34 | php: {} 35 | }, 36 | it: { 37 | json: {}, 38 | php: { 39 | 'auth.failed': 'Credenziali non valide.', 40 | 'auth.password': 'La password non è valida.', 41 | 'auth.throttle': 'Troppi tentativi di accesso. Riprova tra :seconds secondi.' 42 | } 43 | } 44 | }; 45 | 46 | describe('resolver', () => { 47 | // let clientFile: any; 48 | // let serverFile: any; 49 | // 50 | // beforeAll(() => { 51 | // clientFile = import.meta.glob('../__mocks__/lang/*.json'); 52 | // serverFile = import.meta.globEager('../__mocks__/lang/*.json'); 53 | // }); 54 | 55 | it.each([ 56 | ['en', results.en.json, results.en.php], 57 | ['uk', results.uk.json, results.uk.php], 58 | ['it', results.it.json, results.it.php], 59 | ['ro', {}, {}], 60 | ['error', {}, {}] 61 | ])('client', async (locale, resultJson, resultPhp) => { 62 | const promises = resolver(clientFile, locale); 63 | const [responseJson, responsePhp] = await Promise.all(promises); 64 | 65 | expect(responseJson.default).toMatchObject(resultJson); 66 | expect(responsePhp.default).toMatchObject(resultPhp); 67 | }); 68 | 69 | it.each([ 70 | ['en', results.en.json, results.en.php], 71 | ['uk', results.uk.json, results.uk.php], 72 | ['it', results.it.json, results.it.php], 73 | ['ro', {}, {}], 74 | ['error', {}, {}] 75 | ])('client', async (locale, resultJson, resultPhp) => { 76 | const responses = resolver(serverFile, locale); 77 | const [responseJson, responsePhp] = responses; 78 | 79 | expect(responseJson.default).toMatchObject(resultJson); 80 | expect(responsePhp.default).toMatchObject(resultPhp); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"] 3 | } -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testEnvironment": "jsdom", 3 | "setupFilesAfterEnv": ["/__tests__/jest-setup.ts"], 4 | "testPathIgnorePatterns": ["jest-setup.ts", "__mocks__"] 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-react-i18n", 3 | "version": "2.0.5", 4 | "author": { 5 | "name": "Eugene Meles", 6 | "email": "xmelesx@gmail.com" 7 | }, 8 | "keywords": [ 9 | "vite-plugin", 10 | "vite-plugin-laravel", 11 | "laravel", 12 | "react", 13 | "i18n", 14 | "inertiajs" 15 | ], 16 | "engines": { 17 | "npm": ">=9.0.0", 18 | "node": ">=16.0.0" 19 | }, 20 | "repository": "https://github.com/EugeneMeles/laravel-react-i18n", 21 | "license": "MIT", 22 | "description": "Allows to connect your `Laravel` Framework localization files with `React`.", 23 | "main": "dist/cjs/index.js", 24 | "module": "dist/index.js", 25 | "types": "dist/index.d.ts", 26 | "scripts": { 27 | "test": "jest", 28 | "prettier": "prettier -c \"src/**/*.(ts|tsx)\" --write", 29 | "build": "npm run build:client && npm run build:commonjs && npm run build:plugin", 30 | "build:client": "tsc -p tsconfig.client.json", 31 | "build:commonjs": "tsc -p tsconfig.commonjs.json", 32 | "build:plugin": "tsc -p tsconfig.plugin.json", 33 | "prepare": "npm run build", 34 | "postpublish": "PACKAGE_VERSION=$(cat package.json | grep \\\"version\\\" | head -1 | awk -F: '{ print $2 }' | sed 's/[\",]//g' | tr -d '[[:space:]]') && git tag v$PACKAGE_VERSION && git push --tags" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.21.8", 38 | "@babel/preset-env": "^7.21.5", 39 | "@babel/preset-react": "^7.18.6", 40 | "@babel/preset-typescript": "^7.21.5", 41 | "@testing-library/jest-dom": "^5.16.5", 42 | "@testing-library/react": "^14.0.0", 43 | "@types/jest": "^29.5.1", 44 | "@types/node": "^18.16.8", 45 | "@types/react": "^18.2.6", 46 | "@types/react-dom": "^18.2.4", 47 | "@typescript-eslint/eslint-plugin": "^5.59.5", 48 | "@typescript-eslint/parser": "^5.59.5", 49 | "eslint": "^8.40.0", 50 | "eslint-config-airbnb-typescript": "^17.0.0", 51 | "eslint-config-prettier": "^8.8.0", 52 | "eslint-plugin-import": "^2.27.5", 53 | "eslint-plugin-jest": "^27.2.1", 54 | "eslint-plugin-prettier": "^4.2.1", 55 | "eslint-plugin-react": "^7.32.2", 56 | "jest": "^29.5.0", 57 | "jest-environment-jsdom": "^29.5.0", 58 | "prettier": "^2.8.8", 59 | "typescript": "^5.0.4", 60 | "vite": "^4.3.5" 61 | }, 62 | "dependencies": { 63 | "php-array-reader": "^2.1.2", 64 | "react": "^18.2.0" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/@types/php-array-reader.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Declaration patch for module `php-array-reader` 3 | * 4 | * (fromString) 5 | */ 6 | declare module 'php-array-reader' { 7 | export const fromString: (phpString: string) => object; 8 | } 9 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import ContextInterface from './interfaces/context'; 4 | 5 | export const Context = createContext({ 6 | t: (key) => '', 7 | tChoice: (key) => '', 8 | currentLocale: () => '', 9 | getLocales: () => [''], 10 | isLocale: (locale) => true, 11 | loading: true, 12 | setLocale: (locale) => {} 13 | }); 14 | -------------------------------------------------------------------------------- /src/contrib/get-plural-index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | /** 4 | * Get the index to use for pluralization. 5 | * The plural rules are derived from code of the Zend Framework. 6 | * 7 | * @category Zend 8 | * @package Zend_Locale 9 | * @public https://github.com/zendframework/zf1/blob/master/library/Zend/Translate/Plural.php 10 | * @copyright 2005-2015 Zend Technologies USA Inc. http://www.zend.com 11 | * @license http://framework.zend.com/license New BSD License 12 | * 13 | * @param {String} locale 14 | * @param {Number} number 15 | * @return {Number} 16 | */ 17 | export function getPluralIndex(locale: string, number: number) { 18 | locale = locale.replace('-', '_'); 19 | 20 | if (locale === 'pt_BR') { 21 | // temporary set a locale for brazilian 22 | locale = 'xbr'; 23 | } 24 | 25 | if (locale.length > 3) { 26 | locale = locale.substring(0, locale.lastIndexOf('_')); 27 | } 28 | 29 | switch (locale) { 30 | case 'az': 31 | case 'bo': 32 | case 'dz': 33 | case 'id': 34 | case 'ja': 35 | case 'jv': 36 | case 'ka': 37 | case 'km': 38 | case 'kn': 39 | case 'ko': 40 | case 'ms': 41 | case 'th': 42 | case 'tr': 43 | case 'vi': 44 | case 'zh': 45 | return 0; 46 | case 'af': 47 | case 'bn': 48 | case 'bg': 49 | case 'ca': 50 | case 'da': 51 | case 'de': 52 | case 'el': 53 | case 'en': 54 | case 'eo': 55 | case 'es': 56 | case 'et': 57 | case 'eu': 58 | case 'fa': 59 | case 'fi': 60 | case 'fo': 61 | case 'fur': 62 | case 'fy': 63 | case 'gl': 64 | case 'gu': 65 | case 'ha': 66 | case 'he': 67 | case 'hu': 68 | case 'is': 69 | case 'it': 70 | case 'ku': 71 | case 'lb': 72 | case 'ml': 73 | case 'mn': 74 | case 'mr': 75 | case 'nah': 76 | case 'nb': 77 | case 'ne': 78 | case 'nl': 79 | case 'nn': 80 | case 'no': 81 | case 'om': 82 | case 'or': 83 | case 'pa': 84 | case 'pap': 85 | case 'ps': 86 | case 'pt': 87 | case 'so': 88 | case 'sq': 89 | case 'sv': 90 | case 'sw': 91 | case 'ta': 92 | case 'te': 93 | case 'tk': 94 | case 'ur': 95 | case 'zu': 96 | return number === 1 ? 0 : 1; 97 | case 'am': 98 | case 'bh': 99 | case 'fil': 100 | case 'fr': 101 | case 'gun': 102 | case 'hi': 103 | case 'ln': 104 | case 'mg': 105 | case 'nso': 106 | case 'xbr': 107 | case 'ti': 108 | case 'wa': 109 | return number === 0 || number === 1 ? 0 : 1; 110 | case 'be': 111 | case 'bs': 112 | case 'hr': 113 | case 'ru': 114 | case 'sr': 115 | case 'uk': 116 | return number % 10 === 1 && number % 100 !== 11 117 | ? 0 118 | : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) 119 | ? 1 120 | : 2; 121 | case 'cs': 122 | case 'sk': 123 | return number === 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2; 124 | case 'ga': 125 | return number === 1 ? 0 : number === 2 ? 1 : 2; 126 | case 'lt': 127 | return number % 10 === 1 && number % 100 !== 11 128 | ? 0 129 | : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) 130 | ? 1 131 | : 2; 132 | case 'sl': 133 | return number % 100 === 1 ? 0 : number % 100 === 2 ? 1 : number % 100 === 3 || number % 100 === 4 ? 2 : 3; 134 | case 'mk': 135 | return number % 10 === 1 ? 0 : 1; 136 | case 'mt': 137 | return number === 1 138 | ? 0 139 | : number === 0 || (number % 100 > 1 && number % 100 < 11) 140 | ? 1 141 | : number % 100 > 10 && number % 100 < 20 142 | ? 2 143 | : 3; 144 | case 'lv': 145 | return number === 0 ? 0 : number % 10 === 1 && number % 100 !== 11 ? 1 : 2; 146 | case 'pl': 147 | return number === 1 148 | ? 0 149 | : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) 150 | ? 1 151 | : 2; 152 | case 'cy': 153 | return number === 1 ? 0 : number === 2 ? 1 : number === 8 || number === 11 ? 2 : 3; 154 | case 'ro': 155 | return number === 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2; 156 | case 'ar': 157 | return number === 0 158 | ? 0 159 | : number === 1 160 | ? 1 161 | : number === 2 162 | ? 2 163 | : number >= 3 && number <= 10 164 | ? 3 165 | : number >= 11 && number <= 99 166 | ? 4 167 | : 5; 168 | default: 169 | return 0; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/hook.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { Context } from './context'; 4 | 5 | import ContextInterface from './interfaces/context'; 6 | 7 | export default function useLaravelReactI18n() { 8 | return useContext>(Context); 9 | } 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import LaravelReactI18nProvider from './provider'; 2 | import useLaravelReactI18n from './hook'; 3 | 4 | export { LaravelReactI18nProvider, useLaravelReactI18n }; 5 | -------------------------------------------------------------------------------- /src/interfaces/context.ts: -------------------------------------------------------------------------------- 1 | import ReplacementsInterface from './replacements'; 2 | 3 | /** 4 | * 5 | */ 6 | export default interface ContextInterface { 7 | currentLocale: () => string; 8 | getLocales: () => string[]; 9 | isLocale: (locale: string) => boolean; 10 | loading: boolean; 11 | setLocale: (locale: string) => void; 12 | t: (key: T, replacements?: ReplacementsInterface) => string; 13 | tChoice: (key: T, number: number, replacements?: ReplacementsInterface) => string; 14 | } 15 | -------------------------------------------------------------------------------- /src/interfaces/default-options.ts: -------------------------------------------------------------------------------- 1 | import LocaleFileInterface from './locale-file'; 2 | 3 | /** 4 | * The Interface that is responsible for the default options. 5 | */ 6 | export default interface DefaultOptionsInterface { 7 | fallbackLocale: string; 8 | locale: string; 9 | prevLocale: string; 10 | files: Record | Record Promise>; 11 | } 12 | -------------------------------------------------------------------------------- /src/interfaces/i18n-provider-props.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import OptionsInterface from './options'; 4 | 5 | /** 6 | * 7 | */ 8 | export default interface I18nProviderProps extends OptionsInterface { 9 | children: ReactNode; 10 | ssr?: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /src/interfaces/locale-file.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | export default interface LocaleFileInterface { 5 | default: { 6 | [key: string]: string; 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/options-provider.ts: -------------------------------------------------------------------------------- 1 | import OptionsInterface from './options'; 2 | 3 | /** 4 | * The Interface that is responsible for the OptionsProvider provided. 5 | */ 6 | export default interface OptionsProviderInterface extends OptionsInterface { 7 | prevLocale?: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/options.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The Interface that is responsible for the Options provided. 3 | */ 4 | export default interface OptionsInterface { 5 | fallbackLocale?: string; 6 | locale?: string; 7 | files: Record | Record Promise>; 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/replacements.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | export default interface ReplacementsInterface { 5 | [key: string]: string | number; 6 | } 7 | -------------------------------------------------------------------------------- /src/plugin/helper.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | /** 4 | * 5 | * @param rawDirname 6 | */ 7 | export function dirnameSanitize(rawDirname: string): string { 8 | return rawDirname.replace(/[\\/]+/g, path.sep).replace(/[\\/]+$/, '') + path.sep; 9 | } 10 | -------------------------------------------------------------------------------- /src/plugin/key-type.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | /** 5 | * 6 | * @param dirname 7 | * @param basename 8 | */ 9 | export function convertToKeyType(dirname: string, basename: string): string { 10 | const result = fs.readFileSync(`${dirname + path.sep + basename}.json`, 'utf8'); 11 | const obj = Object.entries(JSON.parse(result)); 12 | 13 | let str = ''; 14 | obj.forEach(([key], index) => { 15 | // Escaping a key 16 | const escKey = key.replace(/[!@#$%^&*()+=\-[\]\\';,/{}|":<>?~_]/g, '\\$&'); 17 | 18 | str = obj.length === 1 || obj.length - 1 === index ? `${str}'${escKey}'` : `${str}'${escKey}'|`; 19 | }); 20 | 21 | return str; 22 | } 23 | 24 | /** 25 | * 26 | * @param keys 27 | * @param dirname 28 | */ 29 | export function saveKeyTypeToFile(keys: string, dirname = 'resources/js') { 30 | const sanitizeDirname = dirname.replace(/[\\/]$/, '') + path.sep; 31 | const data = `export type I18nKeyType = ${keys};`.replace(/[\r\n]+/g, ''); 32 | 33 | fs.writeFileSync(`${sanitizeDirname}LaravelReactI18n.types.ts`, data); 34 | } 35 | -------------------------------------------------------------------------------- /src/plugin/locale.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { dirnameSanitize } from './helper'; 5 | 6 | export default { 7 | /** 8 | * 9 | * @param dirname 10 | */ 11 | getJsonLocale: (dirname: string): string[] => { 12 | const sanitizedDirname = dirnameSanitize(dirname); 13 | 14 | if (!fs.existsSync(sanitizedDirname)) { 15 | return []; 16 | } 17 | 18 | return fs 19 | .readdirSync(sanitizedDirname) 20 | .filter((basename) => { 21 | const fullPath = path.join(sanitizedDirname, basename); 22 | return fs.statSync(fullPath).isFile() && !basename.startsWith('php_'); 23 | }) 24 | .map((basename) => basename.replace(/\.json$/, '')) 25 | .sort(); 26 | }, 27 | 28 | /** 29 | * 30 | * @param dirname 31 | */ 32 | getPhpLocale: (dirname: string): string[] => { 33 | const sanitizedDirname = dirnameSanitize(dirname); 34 | 35 | if (!fs.existsSync(sanitizedDirname)) { 36 | return []; 37 | } 38 | 39 | return fs 40 | .readdirSync(sanitizedDirname) 41 | .filter((folder) => { 42 | const fullPath = path.join(sanitizedDirname, folder); 43 | return fs.statSync(fullPath).isDirectory(); 44 | }) 45 | .filter((folder) => { 46 | const phpFiles = fs.readdirSync(path.join(sanitizedDirname, folder)); 47 | return phpFiles.some((basename) => /\.php$/.test(basename)); 48 | }) 49 | .sort(); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/plugin/parser.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { fromString } from 'php-array-reader'; 4 | 5 | import { dirnameSanitize } from './helper'; 6 | 7 | /** 8 | * 9 | * Parse array of PHP file. 10 | * 11 | * @param dirname 12 | */ 13 | export default function parser(dirname: string): { path: string; basename: string }[] { 14 | const sanitizedDirname = dirnameSanitize(dirname); 15 | 16 | if (!fs.existsSync(sanitizedDirname)) { 17 | return []; 18 | } 19 | 20 | return fs 21 | .readdirSync(sanitizedDirname) 22 | .filter((locale) => fs.statSync(path.join(sanitizedDirname, locale)).isDirectory()) 23 | .sort() 24 | .map((locale) => { 25 | const translations = convertToDottedKey(getObjectTranslation(path.join(sanitizedDirname, locale))); 26 | return { locale, trans: translations }; 27 | }) 28 | .filter(({ trans }) => Object.keys(trans).length > 0) 29 | .map(({ locale, trans }) => { 30 | const basename = `php_${locale}.json`; 31 | fs.writeFileSync(path.join(sanitizedDirname, basename), JSON.stringify(trans)); 32 | 33 | return { basename, path: path.join(sanitizedDirname, basename) }; 34 | }); 35 | } 36 | 37 | /** 38 | * 39 | * @param source 40 | * @param target 41 | * @param keys 42 | */ 43 | function convertToDottedKey( 44 | source: Record, 45 | target: Record = {}, 46 | keys: string[] = [] 47 | ): Record { 48 | Object.entries(source).forEach(([key, value]) => { 49 | const newPrefix = [...keys, key]; 50 | 51 | if (typeof value === 'object' && value !== null && !Array.isArray(value)) { 52 | convertToDottedKey(value as Record, target, newPrefix); 53 | } else { 54 | target[newPrefix.join('.')] = value as string; 55 | } 56 | }); 57 | 58 | return target; 59 | } 60 | 61 | /** 62 | * 63 | * @param dirname 64 | */ 65 | function getObjectTranslation(dirname: string): Record { 66 | const translations: Record = {}; 67 | 68 | fs.readdirSync(dirname).forEach((basename) => { 69 | const absoluteFile = path.join(dirname, basename); 70 | const key = basename.replace(/\.\w+$/, ''); 71 | 72 | if (fs.statSync(absoluteFile).isDirectory()) { 73 | translations[key] = getObjectTranslation(absoluteFile); 74 | } else { 75 | const fileContent = fs.readFileSync(absoluteFile, 'utf-8'); 76 | translations[key] = fromString(fileContent); 77 | } 78 | }); 79 | 80 | return translations; 81 | } 82 | -------------------------------------------------------------------------------- /src/provider.ts: -------------------------------------------------------------------------------- 1 | import { createElement, useEffect, useState } from 'react'; 2 | 3 | import { Context } from './context'; 4 | import type DefaultOptionsInterface from './interfaces/default-options'; 5 | import type I18nProviderProps from './interfaces/i18n-provider-props'; 6 | import type ReplacementsInterface from './interfaces/replacements'; 7 | import pluralization from './utils/pluralization'; 8 | import recognizer from './utils/recognizer'; 9 | import replacer from './utils/replacer'; 10 | import resolver from './utils/resolver'; 11 | 12 | /** 13 | * 14 | */ 15 | const isServer = typeof window === 'undefined'; 16 | 17 | /** 18 | * Map object for translations. 19 | */ 20 | const translation = new Map(); 21 | 22 | /** 23 | * Get document lang meta from HTML. 24 | */ 25 | const documentLang = 26 | typeof document !== 'undefined' ? document?.documentElement?.lang?.replace('-', '_') || 'en' : 'en'; 27 | 28 | /** 29 | * The default options. 30 | */ 31 | const defaultOptions: DefaultOptionsInterface = { 32 | locale: documentLang, 33 | fallbackLocale: documentLang, 34 | prevLocale: documentLang, 35 | files: {} 36 | }; 37 | 38 | /** 39 | * Laravel React I18n Provider: 40 | */ 41 | export default function LaravelReactI18nProvider({ children, ssr, ...currentOptions }: I18nProviderProps) { 42 | const [isFirstRender, setIsFirstRender] = useState(true); 43 | const [loading, setLoading] = useState(!isServer); 44 | const [options, setOptions] = useState({ 45 | ...defaultOptions, 46 | ...currentOptions 47 | }); 48 | const { getLocales, isLocale } = recognizer(options.files); 49 | 50 | // Determine if files are eagerly loaded. 51 | const filesAreEagerlyLoaded = Object.values(options.files).every( 52 | (value) => typeof value === 'object' && value !== null 53 | ); 54 | 55 | const { locale, fallbackLocale } = options; 56 | 57 | if (!translation.get(locale)) { 58 | if (filesAreEagerlyLoaded) { 59 | fetchLocaleSync(locale); 60 | } else if (isServer) { 61 | fetchLocaleServer(locale); 62 | } 63 | } 64 | 65 | if (locale !== fallbackLocale && !translation.get(fallbackLocale)) { 66 | if (filesAreEagerlyLoaded) { 67 | fetchLocaleSync(fallbackLocale); 68 | } else if (isServer) { 69 | fetchLocaleServer(fallbackLocale); 70 | } 71 | } 72 | 73 | useEffect(() => { 74 | if (!filesAreEagerlyLoaded) { 75 | if (!translation.get(locale)) fetchLocaleClient(locale); 76 | if (locale !== fallbackLocale && !translation.get(fallbackLocale)) fetchLocaleClient(fallbackLocale); 77 | } 78 | }, [options.locale]); 79 | 80 | function fetchLocaleSync(locale: string): void { 81 | const responses = resolver(options.files, locale); 82 | 83 | for (const response of responses) { 84 | translation.set(locale, { 85 | ...(translation.get(locale) || {}), 86 | ...response.default 87 | }); 88 | } 89 | } 90 | 91 | /** 92 | * Initialise translations for server. 93 | */ 94 | if (isServer) { 95 | const { locale, fallbackLocale } = options; 96 | 97 | if (!translation.get(locale)) fetchLocaleServer(locale); 98 | if (locale !== fallbackLocale && !translation.get(fallbackLocale)) fetchLocaleServer(fallbackLocale); 99 | } 100 | 101 | /** 102 | * Fetching locale for client side. 103 | */ 104 | function fetchLocaleClient(locale: string): void { 105 | const promises = resolver(options.files, locale); 106 | 107 | setLoading(true); 108 | Promise.all(promises) 109 | .then((responses) => { 110 | for (const response of responses) { 111 | translation.set(locale, { 112 | ...(translation.get(locale) || {}), 113 | ...response.default 114 | }); 115 | } 116 | }) 117 | .then(() => { 118 | if (isFirstRender) setIsFirstRender(false); 119 | setLoading(false); 120 | }); 121 | } 122 | 123 | /** 124 | * Fetching locale for server side. 125 | */ 126 | function fetchLocaleServer(locale: string): void { 127 | const responses = resolver(options.files, locale); 128 | 129 | for (const response of responses) { 130 | translation.set(locale, { 131 | ...(translation.get(locale) || {}), 132 | ...response.default 133 | }); 134 | } 135 | } 136 | 137 | /** 138 | * Get the translation for the given key. 139 | */ 140 | function t(key: string, replacements: ReplacementsInterface = {}): string { 141 | const { locale, fallbackLocale, prevLocale } = options; 142 | 143 | let message = translation.get(fallbackLocale)?.[key] ? translation.get(fallbackLocale)[key] : key; 144 | 145 | if (isLocale(locale)) { 146 | if (translation.get(locale)?.[key]) { 147 | message = translation.get(locale)[key]; 148 | } else if (translation.get(prevLocale)?.[key]) { 149 | message = translation.get(prevLocale)[key]; 150 | } else if (translation.get(fallbackLocale)?.[key]) { 151 | message = translation.get(fallbackLocale)[key]; 152 | } 153 | } 154 | 155 | return replacer(message, replacements); 156 | } 157 | 158 | /** 159 | * Translates the given message based on a count. 160 | */ 161 | function tChoice(key: string, number: number, replacements: ReplacementsInterface = {}): string { 162 | const message = t(key, replacements); 163 | const locale = isLocale(options.locale) ? options.locale : options.fallbackLocale; 164 | 165 | return replacer(pluralization(message, number, locale), { 166 | ...replacements, 167 | count: number.toString() 168 | }); 169 | } 170 | 171 | /** 172 | * Set locale. 173 | */ 174 | function setLocale(locale: string) { 175 | if (!isServer) { 176 | // When setting the HTML lang attribute, hyphen must be use instead of underscore. 177 | document.documentElement.setAttribute('lang', locale.replace('_', '-')); 178 | } 179 | 180 | setOptions((prevState) => ({ 181 | ...options, 182 | locale, 183 | prevLocale: prevState.locale 184 | })); 185 | } 186 | 187 | /** 188 | * Current locale. 189 | */ 190 | function currentLocale(): string { 191 | return options.locale || options.fallbackLocale; 192 | } 193 | 194 | return createElement( 195 | Context.Provider, 196 | { 197 | value: { 198 | t, 199 | tChoice, 200 | loading, 201 | isLocale, 202 | getLocales, 203 | currentLocale, 204 | setLocale 205 | } 206 | }, 207 | children 208 | ); 209 | } 210 | -------------------------------------------------------------------------------- /src/utils/pluralization.ts: -------------------------------------------------------------------------------- 1 | import { getPluralIndex } from '../contrib/get-plural-index'; 2 | 3 | /** 4 | * Select a proper translation string based on the given number. 5 | * 6 | * @param message 7 | * @param number 8 | * @param locale 9 | */ 10 | export default function pluralization(message: string, number: number, locale: string): string { 11 | let segments = message.split('|'); 12 | const extracted = extract(segments, number); 13 | 14 | if (extracted !== null) { 15 | return extracted.trim(); 16 | } 17 | 18 | segments = stripConditions(segments); 19 | const pluralIndex = getPluralIndex(locale, number); 20 | 21 | return segments.length === 1 || !segments[pluralIndex] ? segments[0] : segments[pluralIndex]; 22 | } 23 | 24 | /** 25 | * Extract a translation string using inline conditions. 26 | * 27 | * @param segments 28 | * @param number 29 | */ 30 | function extract(segments: string[], number: number): string | null { 31 | let result: string | null = null; 32 | 33 | segments.forEach((segment) => { 34 | if (result !== null) return; 35 | result = extractFromString(segment, number); 36 | }); 37 | 38 | return result; 39 | } 40 | 41 | /** 42 | * Get the translation string if the condition matches. 43 | * 44 | * @param part 45 | * @param number 46 | */ 47 | function extractFromString(part: string, number: number): string | null { 48 | const matches = part.match(/^[{[]([^,{}\[\]]*),?([^{}\[\]]*)[}\]]([\s\S]*)/); 49 | 50 | if (!matches) return null; 51 | 52 | const [, from, to, value] = matches; 53 | 54 | if ((from === '*' || number >= parseFloat(from)) && (to === '*' || number <= parseFloat(to))) { 55 | return value; 56 | } 57 | 58 | return from && parseFloat(from) === number ? value : null; 59 | } 60 | 61 | /** 62 | * Strip the inline conditions from each segment, just leaving the text. 63 | * 64 | * @param segments 65 | */ 66 | function stripConditions(segments: string[]): string[] { 67 | return segments.map((part) => part.replace(/^[{[]([^[\]{}]*)[}\]]/, '')); 68 | } 69 | -------------------------------------------------------------------------------- /src/utils/recognizer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param files 4 | */ 5 | export default function recognizer(files: Record | Record Promise>) { 6 | const jsonLocales = new Set(); 7 | const phpLocales = new Set(); 8 | const jsonFileLocales: Record = {}; 9 | const phpFileLocales: Record = {}; 10 | 11 | Object.keys(files).forEach((file) => { 12 | const match = file.match(/.*\/(php_)?(.*)\.json$/); 13 | if (match) { 14 | const [, isPhp, locale] = match; 15 | 16 | if (isPhp) { 17 | phpLocales.add(locale); 18 | phpFileLocales[locale] = file; 19 | } else { 20 | jsonLocales.add(locale); 21 | jsonFileLocales[locale] = file; 22 | } 23 | } 24 | }); 25 | 26 | const locales = Array.from(new Set([...jsonLocales, ...phpLocales])).sort(); 27 | 28 | return { 29 | isLocale: (locale: string): boolean => locales.includes(locale), 30 | getLocales: () => locales, 31 | isJsonLocale: (locale: string): boolean => jsonLocales.has(locale), 32 | getJsonLocales: () => Array.from(jsonLocales).sort(), 33 | isPhpLocale: (locale: string): boolean => phpLocales.has(locale), 34 | getPhpLocales: () => Array.from(phpLocales).sort(), 35 | getJsonFile: (locale: string): string => jsonFileLocales[locale], 36 | getPhpFile: (locale: string): string => phpFileLocales[locale] 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/replacer.ts: -------------------------------------------------------------------------------- 1 | import ReplacementsInterface from '../interfaces/replacements'; 2 | 3 | /** 4 | * Make the place-holder replacements on a line. 5 | * 6 | * @param message 7 | * @param replacements 8 | */ 9 | export default function replacer(message: string, replacements?: ReplacementsInterface): string { 10 | if (!replacements) return message; 11 | 12 | const patterns = Object.entries(replacements).flatMap(([key, value]) => [ 13 | { pattern: new RegExp(`:${key}`, 'g'), replacement: value.toString() }, 14 | { pattern: new RegExp(`:${key.toUpperCase()}`, 'g'), replacement: value.toString().toUpperCase() }, 15 | { pattern: new RegExp(`:${capitalize(key)}`, 'g'), replacement: capitalize(value.toString()) } 16 | ]); 17 | 18 | return patterns.reduce((result, { pattern, replacement }) => result.replace(pattern, replacement), message); 19 | } 20 | 21 | /** 22 | * Capitalizing string. 23 | * 24 | * @param str 25 | */ 26 | function capitalize(str: string): string { 27 | return str ? str[0].toUpperCase() + str.slice(1) : ''; 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/resolver.ts: -------------------------------------------------------------------------------- 1 | import recognizer from '../utils/recognizer'; 2 | 3 | import LocaleFileInterface from '../interfaces/locale-file'; 4 | 5 | /** 6 | * Resolver the language file. 7 | * 8 | * @param files 9 | * @param locale 10 | */ 11 | export default function resolver( 12 | files: Record | Record Promise>, 13 | locale: string 14 | ): LocaleFileInterface[] { 15 | const { isJsonLocale, isPhpLocale, getJsonFile, getPhpFile } = recognizer(files); 16 | 17 | const jsonLocale = isJsonLocale(locale) ? files[getJsonFile(locale)] : undefined; 18 | const phpLocale = isPhpLocale(locale) ? files[getPhpFile(locale)] : undefined; 19 | 20 | const getType = (obj: string) => Object.prototype.toString.call(obj); 21 | 22 | if ( 23 | ['[object Promise]', '[object Module]'].includes(getType(jsonLocale)) || 24 | ['[object Promise]', '[object Module]'].includes(getType(phpLocale)) 25 | ) { 26 | return [jsonLocale ? jsonLocale : { default: {} }, phpLocale ? phpLocale : { default: {} }]; 27 | } 28 | 29 | if (getType(jsonLocale) === '[object Object]' || getType(phpLocale) === '[object Object]') { 30 | return [{ default: jsonLocale || {} }, { default: phpLocale || {} }]; 31 | } 32 | 33 | if (getType(jsonLocale) === '[object Function]' || getType(phpLocale) === '[object Function]') { 34 | return [jsonLocale ? jsonLocale() : { default: {} }, phpLocale ? phpLocale() : { default: {} }]; 35 | } 36 | 37 | return [{ default: {} }, { default: {} }]; 38 | } 39 | -------------------------------------------------------------------------------- /src/vite.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import { createLogger } from 'vite'; 4 | 5 | import { convertToKeyType, saveKeyTypeToFile } from './plugin/key-type'; 6 | import parser from './plugin/parser'; 7 | import locale from './plugin/locale'; 8 | 9 | interface ConfigInterface { 10 | langDirname?: string; 11 | typeDestinationPath?: string; 12 | typeTranslationKeys?: boolean; 13 | } 14 | 15 | /** 16 | * 17 | */ 18 | export default function i18n(config?: ConfigInterface) { 19 | const langDirname = config?.langDirname ? config.langDirname : 'lang'; 20 | 21 | const logger = createLogger('info', { prefix: '[laravel-react-i18n]' }); 22 | 23 | let isPhpLocale = false; 24 | let files: { path: string; basename: string }[] = []; 25 | let exitHandlersBound = false; 26 | let jsonLocales: string[] = []; 27 | let phpLocales: string[] = []; 28 | 29 | function clean() { 30 | files.forEach((file) => fs.existsSync(file.path) && fs.unlinkSync(file.path)); 31 | files = []; 32 | } 33 | 34 | function pushKeys(keys: string[], locales: string[]) { 35 | if ( 36 | typeof process.env.VITE_LARAVEL_REACT_I18N_LOCALE !== 'undefined' && 37 | locales.includes(process.env.VITE_LARAVEL_REACT_I18N_LOCALE) 38 | ) { 39 | const fileName = isPhpLocale 40 | ? `php_${process.env.VITE_LARAVEL_REACT_I18N_LOCALE}` 41 | : process.env.VITE_LARAVEL_REACT_I18N_LOCALE; 42 | keys.push(convertToKeyType(langDirname, fileName)); 43 | } 44 | 45 | if ( 46 | typeof process.env.VITE_LARAVEL_REACT_I18N_FALLBACK_LOCALE !== 'undefined' && 47 | locales.includes(process.env.VITE_LARAVEL_REACT_I18N_FALLBACK_LOCALE) && 48 | process.env.VITE_LARAVEL_REACT_I18N_LOCALE !== process.env.VITE_LARAVEL_REACT_I18N_FALLBACK_LOCALE 49 | ) { 50 | const fileName = isPhpLocale 51 | ? `php_${process.env.VITE_LARAVEL_REACT_I18N_FALLBACK_LOCALE}` 52 | : process.env.VITE_LARAVEL_REACT_I18N_FALLBACK_LOCALE; 53 | keys.push(convertToKeyType(langDirname, fileName)); 54 | } 55 | } 56 | 57 | return { 58 | name: 'i18n', 59 | enforce: 'post', 60 | config() { 61 | const keys: string[] = []; 62 | 63 | // Check language directory is exists. 64 | if (!fs.existsSync(langDirname)) { 65 | const msg = [ 66 | 'Language directory is not exist, maybe you did not publish the language files with `php artisan lang:publish`.', 67 | 'For more information please visit: https://laravel.com/docs/10.x/localization#publishing-the-language-files' 68 | ]; 69 | 70 | msg.map((str) => logger.error(str, { timestamp: true })); 71 | return; 72 | } 73 | 74 | // JSON-file locales. 75 | jsonLocales = locale.getJsonLocale(langDirname); 76 | 77 | if (config?.typeTranslationKeys) { 78 | pushKeys(keys, jsonLocales); 79 | } 80 | 81 | // PHP-file locales. 82 | phpLocales = locale.getPhpLocale(langDirname); 83 | 84 | if (phpLocales.length > 0) { 85 | files = parser(langDirname); 86 | isPhpLocale = true; 87 | 88 | if (config?.typeTranslationKeys) { 89 | pushKeys(keys, phpLocales); 90 | } 91 | } else { 92 | const msg = [ 93 | 'Language directory not contain php translations files.', 94 | 'For more information please visit: https://laravel.com/docs/10.x/localization#introduction' 95 | ]; 96 | 97 | msg.map((str) => logger.info(str, { timestamp: true })); 98 | } 99 | 100 | if (config?.typeTranslationKeys) { 101 | saveKeyTypeToFile(keys.join('|'), config?.typeDestinationPath); 102 | } 103 | }, 104 | buildEnd: clean, 105 | handleHotUpdate(ctx: any) { 106 | const keys: string[] = []; 107 | 108 | if (config?.typeTranslationKeys) { 109 | pushKeys(keys, jsonLocales); 110 | } 111 | 112 | if (isPhpLocale) { 113 | if (/lang\/.*\.php$/.test(ctx.file)) { 114 | files = parser(langDirname); 115 | } 116 | 117 | if (config?.typeTranslationKeys) { 118 | pushKeys(keys, phpLocales); 119 | } 120 | } 121 | 122 | if (config?.typeTranslationKeys) { 123 | saveKeyTypeToFile(keys.join('|'), config?.typeDestinationPath); 124 | } 125 | }, 126 | configureServer() { 127 | if (exitHandlersBound) return; 128 | 129 | process.on('exit', clean); 130 | process.on('SIGINT', process.exit); 131 | process.on('SIGTERM', process.exit); 132 | process.on('SIGHUP', process.exit); 133 | 134 | exitHandlersBound = true; 135 | } 136 | }; 137 | } 138 | -------------------------------------------------------------------------------- /tsconfig.client.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "exclude": [ 7 | "node_modules", 8 | "src/plugin/*.ts", 9 | "src/vite.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.ts" 13 | ] 14 | } -------------------------------------------------------------------------------- /tsconfig.commonjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.client.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "dist/cjs" 6 | } 7 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "isolatedModules": true, 9 | "jsx": "react-jsx", 10 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 11 | "module": "ESNext", 12 | "moduleResolution": "Node", 13 | "noFallthroughCasesInSwitch": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "strict": true, 17 | "target": "ES2015", 18 | "types": ["node", "jest", "vite/client"], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "dist" 6 | }, 7 | "include": [ 8 | "src/@types/*.d.ts", 9 | "src/plugin/*.ts", 10 | "src/vite.ts" 11 | ] 12 | } -------------------------------------------------------------------------------- /vite.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var i18n = require('./dist/vite'); 3 | module.exports = typeof i18n['default'] !== undefined ? i18n['default'] : i18n; --------------------------------------------------------------------------------