├── .npmignore ├── .gitignore ├── index.js ├── index.cjs.bak ├── .github ├── FUNDING.yml └── workflows │ └── node.js.yml ├── svelte-intl-precompile-0.12.3-beta.0.tgz ├── svelte-intl-precompile-0.12.3-beta.1.tgz ├── svelte-intl-precompile-0.12.3-beta.2.tgz ├── vitest.config.ts ├── index.d.ts ├── .vscode └── launch.json ├── sveltekit-plugin.d.ts ├── LICENSE ├── package.json ├── tests └── sveltekit-plugin.test.ts ├── CHANGELOG.md ├── logos ├── svelte-intl-precompile-double-line.svg └── svelte-intl-precompile-single-line.svg ├── sveltekit-plugin.js ├── sveltekit-plugin.cjs └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | /logos -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export * from 'precompile-intl-runtime'; -------------------------------------------------------------------------------- /index.cjs.bak: -------------------------------------------------------------------------------- 1 | module.exports = require('precompile-intl-runtime') 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [cibernox] 4 | -------------------------------------------------------------------------------- /svelte-intl-precompile-0.12.3-beta.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cibernox/svelte-intl-precompile/HEAD/svelte-intl-precompile-0.12.3-beta.0.tgz -------------------------------------------------------------------------------- /svelte-intl-precompile-0.12.3-beta.1.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cibernox/svelte-intl-precompile/HEAD/svelte-intl-precompile-0.12.3-beta.1.tgz -------------------------------------------------------------------------------- /svelte-intl-precompile-0.12.3-beta.2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cibernox/svelte-intl-precompile/HEAD/svelte-intl-precompile-0.12.3-beta.2.tgz -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | // ... 6 | }, 7 | }) -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'svelte-intl-precompile' { 2 | export * from 'precompile-intl-runtime'; 3 | } 4 | 5 | declare module '$locales' { 6 | /** Registers all locales found in `localesRoot`. */ 7 | export const registerAll: () => void 8 | 9 | /** A list of all locales that will be registered by {@link registerAll()}. */ 10 | export const availableLocales: string[] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "type": "pwa-node", 7 | "request": "launch", 8 | "name": "Debug Current Test File", 9 | "autoAttachChildProcesses": true, 10 | "skipFiles": ["/**", "**/node_modules/**"], 11 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", 12 | "args": ["run", "${relativeFile}"], 13 | "smartStep": true, 14 | "console": "integratedTerminal" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /sveltekit-plugin.d.ts: -------------------------------------------------------------------------------- 1 | type Transformer = ( 2 | content: string, 3 | options: { filename: string, basename: string, extname: string } 4 | ) => string | PromiseLike 5 | 6 | interface Options { 7 | prefix?: string 8 | exclude?: RegExp | ((filename: string) => boolean) 9 | transformers?: Record 10 | } 11 | 12 | type VitePlugin = any 13 | 14 | interface SvelteIntlPrecompile { 15 | (localesRoot: string, prefix?: string): VitePlugin 16 | (localesRoot: string, options?: Options): VitePlugin 17 | 18 | transformCode: typeof transformCode 19 | } 20 | 21 | export function transformCode(code: string, options?: Record): string 22 | 23 | declare const svelteIntlPrecompile: SvelteIntlPrecompile 24 | 25 | export default svelteIntlPrecompile 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2021, 2022, Miguel Camba et al. 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build --if-present 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-intl-precompile", 3 | "version": "0.12.3", 4 | "description": "I18n library for Svelte.js that analyzes your keys at build time for max performance and minimal footprint", 5 | "type": "module", 6 | "module": "index.js", 7 | "exports": { 8 | ".": { 9 | "default": "./index.js" 10 | }, 11 | "./sveltekit-plugin": { 12 | "default": "./sveltekit-plugin.js" 13 | }, 14 | "./sveltekit-plugin.js": "./sveltekit-plugin.js", 15 | "./sveltekit-plugin.cjs": "./sveltekit-plugin.cjs", 16 | "./package.json": "./package.json" 17 | }, 18 | "sideEffects": false, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/cibernox/svelte-intl-precompile" 22 | }, 23 | "scripts": { 24 | "build": "ascjs --no-default ./sveltekit-plugin.js ./sveltekit-plugin.cjs", 25 | "prepublish": "npm run build", 26 | "test": "vitest", 27 | "coverage": "vitest run --coverage" 28 | }, 29 | "keywords": [ 30 | "svelte", 31 | "intl", 32 | "i18n", 33 | "precompile" 34 | ], 35 | "author": "Miguel Camba", 36 | "license": "ISC", 37 | "dependencies": { 38 | "babel-plugin-precompile-intl": "^0.5.2", 39 | "js-yaml": "^4.1.0", 40 | "json5": "^2.2.3", 41 | "path-starts-with": "^2.0.0", 42 | "precompile-intl-runtime": "^0.8.5", 43 | "strip-bom": "^5.0.0" 44 | }, 45 | "devDependencies": { 46 | "ascjs": "^5.0.1", 47 | "vitest": "^0.28.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/sveltekit-plugin.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 2 | import svelteIntlPrecompile from '../sveltekit-plugin'; 3 | 4 | const enJsonTranslations = singleLineString` 5 | { 6 | "simple": "Simple string", 7 | "interpolated": "String with one {value} interpolated" 8 | }`; 9 | const esJsonTranslations = singleLineString` 10 | { 11 | "simple": "Cadena simple", 12 | "interpolated": "Cadena con un {value} interpolado" 13 | }`; 14 | const glYamlTranslations = singleLineString` 15 | simple: "Cadea simple" 16 | interpolated: "Cadea con un {value} interpolado"`; 17 | const translationFiles = { 18 | 'fakeroot/locales/en.json': enJsonTranslations, 19 | 'fakeroot/locales/es.json': esJsonTranslations, 20 | 'fakeroot/locales/gl.yaml': glYamlTranslations, 21 | } 22 | 23 | beforeEach(() => { 24 | vi.mock('path', () => ({ 25 | resolve(...paths) { 26 | return ['fakeroot', ...paths].join('/'); 27 | }, 28 | extname(filename) { 29 | const ext = filename.split('.')[1]; 30 | return ext && ('.' + ext); 31 | }, 32 | basename(filename) { 33 | return filename.split('.')[0]; 34 | }, 35 | })); 36 | 37 | vi.mock('fs/promises', () => ({ 38 | readdir() { 39 | return Promise.resolve().then(() => ['en-US.json', 'en.json', 'es.json']) 40 | }, 41 | readFile(filename) { 42 | const content = translationFiles[filename]; 43 | if (content) return content; 44 | let error = new Error('File not found'); 45 | (error as any).code = 'ENOENT'; 46 | throw error; 47 | } 48 | })); 49 | }); 50 | 51 | afterEach(() => { 52 | vi.clearAllMocks() 53 | }) 54 | 55 | describe('imports', () => { 56 | it('`$locales` returns a module that is aware of all the available locales', async () => { 57 | const plugin = svelteIntlPrecompile('locales'); 58 | const content = await plugin.load('$locales'); 59 | expect(content).toBe(singleLineString` 60 | import { register } from 'svelte-intl-precompile' 61 | export function registerAll() { 62 | register("en-US", () => import("$locales/en-US")) 63 | register("en", () => import("$locales/en")) 64 | register("es", () => import("$locales/es")) 65 | } 66 | export const availableLocales = ["en","es","en-US"]`) 67 | }); 68 | 69 | it('`$locales/en` returns the translations for that language', async () => { 70 | const plugin = svelteIntlPrecompile('locales'); 71 | const content = await plugin.load('$locales/en'); 72 | expect(content).toBe(singleLineString` 73 | import { __interpolate } from "svelte-intl-precompile"; 74 | export default { 75 | "simple": "Simple string", 76 | "interpolated": value => \`String with one \${__interpolate(value)} interpolated\` 77 | };` 78 | ); 79 | 80 | }); 81 | 82 | it('`$locales/es` returns the translations for that language', async () => { 83 | const plugin = svelteIntlPrecompile('locales'); 84 | const content = await plugin.load('$locales/es'); 85 | expect(content).toBe(singleLineString` 86 | import { __interpolate } from "svelte-intl-precompile"; 87 | export default { 88 | "simple": "Cadena simple", 89 | "interpolated": value => \`Cadena con un \${__interpolate(value)} interpolado\` 90 | };` 91 | ); 92 | }); 93 | 94 | it('supports yaml files', async () => { 95 | const plugin = svelteIntlPrecompile('locales'); 96 | const content = await plugin.load('$locales/gl'); 97 | expect(content).toBe(singleLineString` 98 | import { __interpolate } from "svelte-intl-precompile"; 99 | export default { 100 | "simple": "Cadea simple", 101 | "interpolated": value => \`Cadea con un \${__interpolate(value)} interpolado\` 102 | };` 103 | ); 104 | }); 105 | }); 106 | 107 | function singleLineString([str]: TemplateStringsArray) { 108 | let lines: string[] = str.split('\n'); 109 | if (lines[0] === '') { 110 | lines = lines.splice(1); 111 | } 112 | let firstLineSpaces = lines[0].search(/\S|$/); 113 | return lines.map(l => l.substring(firstLineSpaces)).join('\n'); 114 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.12.0 2 | - Now the `$date`, `$time` and `$number` stores automatically update when the current locale changes. 3 | ## 0.11.0 4 | - Update precompile-intl-runtime, which contains improvements in the algorithm that chooses the best locale from the locale HTTP header of a request. 5 | ## 0.11.0-beta.2 6 | - Add a funcionality to exclude certain files in the locales folder from being processed. That `exclude` property 7 | can be either a regular expression or a function that takes the file name and returns a boolean. This features 8 | replaces the reverted feature from beta.1 9 | - Revert change that made that only languages matching `xx` or `xx-XX` are processed. Turns out that detecting 10 | what is a valid language name is more complex than that. 11 | ## 0.11.0-beta.1 12 | - Ensure that only the files in the locales folder that match a language name (`xx` or `xx-XX`) are processed. 13 | ## 0.10.1 14 | - Ignore any file in the locales folder which name (excluding the extension) does not match ISO locale names. E.g. `en` or `en-US` (Although not technically correct, `en-us` is also accepted) 15 | ## 0.10.0 16 | - Support template literals when translations are defined in javascript files. 17 | ## 0.10.0-beta.1 18 | - Adds support for using `scale` in number skeletons. It is compiled at build time to a simple math operation like `number / 100`. 19 | ## 0.10.0-beta.0 20 | - Support number skeletons (beta) 21 | ## 0.9.2 22 | - Make 0.9.1 actually work. 23 | ## 0.9.1 24 | - [BUGFIX] Ensure the formatters work when the app is compiled with the immutable flag on and the locale changes 25 | ## 0.8.0 26 | - [FEATURE] Support all i18n-ally file formats for locales – json5 (`.json5` and `.json`) and yaml (`.yaml` and `.yml`) 27 | - [FEATURE] Support locales to be defined within `.js`, `.ts`, and `.mjs` files 28 | - [FEATURE] Allow configuring new transformers based on file extension 29 | - [FEATURE] Support loading of locales without an extension (`$locales/en` instead of `$locales/en.js`) 30 | - [FEATURE] Sort `availableLocales` export of `$locales` module that more specific locales come first `en-US` comes before `en` 31 | - [FEATURE] Add `filename` to babel transform for better debugging 32 | - [FEATURE] Add typescript definitions for `svelte-intl-precompile/sveltekit-plugin` 33 | - [FEATURE] Add `exports` field to `package.json` with legacy `./sveltekit-plugin.js` and `./sveltekit-plugin.cjs` exports 34 | - [BUGFIX] Invalidate `$locales` module in HMR mode if a file within the locales directory has changed 35 | - [BUGFIX] Normalize paths when determining if a file is within the locales directory 36 | - [BUGFIX] Add `index.cjs` to re-export `precompile-intl-runtime` for cjs compat loading 37 | - [BUILD] Autogenerate `./sveltekit-plugin.cjs` from `./sveltekit-plugin.js` during publishing 38 | ## 0.7.0 39 | - [FEATURE] expose the `prefix` (default: `$locales`) as a module that allows to register (`registerAll()`) and access (`availableLocales`) all available translations. 40 | ## 0.6.2 41 | - [BUGFIX] Update babel pluging to fix a bug with translations that only contain a single date/time helper and no other text. 42 | - ## 0.6.1 43 | - [BUGFIX] Fix bug with exact plurals having two or more digits. E.g `=12 {a docen cats}`. 44 | ## 0.6.0 45 | - Updates the babel plugin which in turn now depends on the new `@formatjs/icu-message-parser` package, which replaces the now 46 | unmaintained `intl-message-parser`. It is supposed to be compatible although I've identified one corner case where some previously 47 | supported feature (nested plurals) are now not supported anymore. Opened https://github.com/formatjs/formatjs/issues/3250 to fix this. 48 | ## 0.5.0 49 | - Updates the babel pluging that precompiles ICU strings. Should be backwards compatible, but it's risky enough to grant a minor version bump. 50 | ## 0.4.2 51 | - Export internal `transformCode(str)` function to create interactive playground. 52 | ## 0.4.0 53 | - [FEATURE] Supports defining translations in plain json files. This makes much easier to integrate this library with i18n flows like Lokalise. In reality this 54 | has always been the goal and having to define translations in JS/TS files was just a workaround while figuring out how it works. 55 | Now if your translations are defined in `/locales/en.json` you can import it from `$locales/en.js` and svelte will know what to do. 56 | ## 0.3.4 57 | - Reexport type definitions so typescript detects types properly. 58 | ## 0.3.2 59 | - Bump `babel-plugin-precompile-intl` to fix bug when ICU string start with an expression but end with something else. 60 | ## 0.3.1 61 | - Update `precompile-intl-runtime` to 0.4.2 which allows for the `getLocaleFromNavigator` function to receive an optional parameter that is the value to return when SSR'ing. 62 | 63 | ## 0.3.0 64 | 65 | - Add two versions of the SvelteKit plugin: One in ES6 module syntax ending in `.js` and one in commonJS ending in `.cjs`. 66 | -------------------------------------------------------------------------------- /logos/svelte-intl-precompile-double-line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /logos/svelte-intl-precompile-single-line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /sveltekit-plugin.js: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs/promises'; 3 | 4 | import * as babel from '@babel/core'; 5 | import buildICUPlugin from 'babel-plugin-precompile-intl'; 6 | import pathStartsWith from 'path-starts-with' 7 | 8 | const intlPrecompiler = buildICUPlugin('svelte-intl-precompile'); 9 | 10 | export function transformCode(code, options) { 11 | return babel.transform(code, { ...options, plugins: [intlPrecompiler] }).code; 12 | } 13 | 14 | const transformScript = (content) => content 15 | 16 | const transformJSON = async (content) => { 17 | const { default: JSON5 } = await import('json5'); 18 | return `export default ${JSON.stringify(JSON5.parse(content))}`; 19 | } 20 | 21 | const transformYaml = async (content, { filename }) => { 22 | const { load } = await import('js-yaml'); 23 | return `export default ${JSON.stringify(load(content, { filename }))}`; 24 | } 25 | 26 | const stdTransformers = { 27 | // order matters as it defines which extensions are tried first 28 | // when looking for a matching file for a locale 29 | '.js': transformScript, 30 | '.ts': transformScript, 31 | '.mjs': transformScript, 32 | 33 | // use json5 loader for json files 34 | '.json': transformJSON, 35 | '.json5': transformJSON, 36 | 37 | '.yaml': transformYaml, 38 | '.yml': transformYaml, 39 | }; 40 | 41 | function svelteIntlPrecompile(localesRoot, prefixOrOptions) { 42 | const { 43 | prefix = '$locales', 44 | transformers: customTransformers, 45 | exclude, 46 | } = typeof prefixOrOptions === 'string' 47 | ? { prefix: prefixOrOptions } 48 | : { ...prefixOrOptions }; 49 | 50 | const resolvedPath = path.resolve(localesRoot); 51 | 52 | const transformers = { ...stdTransformers, ...customTransformers } 53 | 54 | async function loadPrefixModule() { 55 | const code = [ 56 | `import { register } from 'svelte-intl-precompile'`, 57 | `export function registerAll() {` 58 | // register('en', () => import('$locales/en')) 59 | ]; 60 | 61 | const availableLocales = []; 62 | const filesInLocalesFolder = await fs.readdir(localesRoot) 63 | const excludeFn = typeof exclude === 'function' ? exclude : (exclude instanceof RegExp ? (s) => exclude.test(s) : () => false); 64 | const languageFiles = filesInLocalesFolder.filter(name => !excludeFn(name)); 65 | 66 | // add register calls for each found locale 67 | for (const file of languageFiles) { 68 | const extname = path.extname(file); 69 | 70 | if (transformers[extname]) { 71 | const locale = path.basename(file, extname); 72 | 73 | // ensure we register each locale only once 74 | // there shouldn't be more than one file each locale 75 | // our load(id) method only ever loads the first found 76 | // file for a locale and it makes no sense to register 77 | // more than one file per locale 78 | if (!availableLocales.includes(locale)) { 79 | availableLocales.push(locale); 80 | 81 | code.push( 82 | ` register(${JSON.stringify(locale)}, () => import(${ 83 | JSON.stringify(`${prefix}/${locale}`) 84 | }))`, 85 | ) 86 | } 87 | } 88 | } 89 | 90 | // Sort locales that more specific locales come first 91 | // 'en-US' comes before 'en' 92 | availableLocales.sort((a, b) => { 93 | const order = a.split('-').length - b.split('-').length 94 | 95 | if (order) return order 96 | 97 | return a.localeCompare(b, 'en') 98 | }) 99 | 100 | code.push( 101 | `}`, 102 | `export const availableLocales = ${JSON.stringify(availableLocales)}`, 103 | ); 104 | 105 | return code.join('\n'); 106 | } 107 | 108 | async function tranformLocale( 109 | content, 110 | { 111 | filename, 112 | extname = path.extname(filename), 113 | basename = path.basename(filename, extname), 114 | transform = transformers[extname] || transformScript 115 | } 116 | ) { 117 | const code = await transform(content, { filename, basename, extname }); 118 | 119 | return transformCode(code, { filename }); 120 | } 121 | 122 | async function findLocale(basename) { 123 | const filebase = path.resolve(localesRoot, basename); 124 | 125 | // lazy loaded here because strip-bom is an es module 126 | // and this file could be a cjs module 127 | // by using dynamic import we can load es modules 128 | const { default: stripBom } = await import('strip-bom'); 129 | 130 | for await (const [extname, transform] of Object.entries(transformers)) { 131 | const filename = filebase + extname; 132 | 133 | try { 134 | const content = await fs.readFile(filename, { encoding: 'utf-8' }); 135 | 136 | return tranformLocale(stripBom(content), { filename, basename, extname, transform }); 137 | } catch (error) { 138 | // incase the file did not exist try next transformer 139 | // otherwise propagate the error 140 | if (error.code !== 'ENOENT') { 141 | throw error; 142 | } 143 | } 144 | } 145 | } 146 | 147 | return { 148 | name: 'svelte-intl-precompile', // required, will show up in warnings and errors 149 | enforce: 'pre', 150 | configureServer(server) { 151 | const { ws, watcher, moduleGraph } = server; 152 | // listen to vite files watcher 153 | watcher.on('change', (file) => { 154 | file = path.relative('', file); 155 | // check if file changed is a locale 156 | if (pathStartsWith(file, localesRoot)) { 157 | // invalidate $locales/ modules 158 | const name = `${prefix}/${path.basename(file, path.extname(file))}`; 159 | 160 | // Alltough we are normalizing the module names 161 | // $locales/en.js -> $locales/en 162 | // we check all configured extensions just to be sure 163 | // '.js', '.ts', '.json', ..., and '' ($locales/en) 164 | for (const extname of [...Object.keys(transformers), '']) { 165 | // check if locale file is in vite cache 166 | const localeModule = moduleGraph.getModuleById(`${name}${extname}`); 167 | 168 | if (localeModule) { 169 | moduleGraph.invalidateModule(localeModule); 170 | } 171 | } 172 | 173 | // invalidate $locales module 174 | const prefixModule = moduleGraph.getModuleById(prefix) 175 | if (prefixModule) { 176 | moduleGraph.invalidateModule(prefixModule); 177 | } 178 | 179 | // trigger hmr 180 | ws.send({ type: 'full-reload', path: '*' }); 181 | } 182 | }) 183 | }, 184 | resolveId(id) { 185 | if (id === prefix || id.startsWith(`${prefix}/`)) { 186 | const extname = path.extname(id); 187 | 188 | // "normalize" module id to have no extension 189 | // $locales/en.js -> $locales/en 190 | // we do this as the extension is ignored 191 | // when loading a locale 192 | // we always try to find a locale file by its basename 193 | // and adding an extension from transformers 194 | // additionally this prevents loading the same module/locale 195 | // several times with different extensions 196 | const normalized = extname 197 | ? id.slice(0, -extname.length) 198 | : id 199 | 200 | return normalized; 201 | } 202 | }, 203 | load(id) { 204 | // allow to auto register locales by calling registerAll from $locales module 205 | // import { registerAll, availableLocales } from '$locales' 206 | if (id === prefix) { 207 | return loadPrefixModule(); 208 | } 209 | 210 | // import en from '$locales/en' 211 | // import en from '$locales/en.js' 212 | if (id.startsWith(`${prefix}/`)) { 213 | const extname = path.extname(id); 214 | 215 | // $locales/en -> en 216 | // $locales/en.js -> en 217 | // $locales/en.ts -> en 218 | const locale = extname 219 | ? id.slice(`${prefix}/`.length, -extname.length) 220 | : id.slice(`${prefix}/`.length) 221 | 222 | return findLocale(locale); 223 | } 224 | }, 225 | transform(content, id) { 226 | // import locale from '../locales/en.js' 227 | if (pathStartsWith(id, resolvedPath)) { 228 | return tranformLocale(content, { filename: id }); 229 | } 230 | } 231 | } 232 | } 233 | 234 | svelteIntlPrecompile.transformCode = transformCode; 235 | 236 | export default svelteIntlPrecompile; 237 | -------------------------------------------------------------------------------- /sveltekit-plugin.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'); 3 | const fs = require('fs/promises'); 4 | 5 | const babel = require('@babel/core'); 6 | const buildICUPlugin = (m => /* c8 ignore start */ m.__esModule ? m.default : m /* c8 ignore stop */)(require('babel-plugin-precompile-intl')); 7 | const pathStartsWith = (m => /* c8 ignore start */ m.__esModule ? m.default : m /* c8 ignore stop */)(require('path-starts-with')) 8 | 9 | const intlPrecompiler = buildICUPlugin('svelte-intl-precompile'); 10 | 11 | function transformCode(code, options) { 12 | return babel.transform(code, { ...options, plugins: [intlPrecompiler] }).code; 13 | } 14 | exports.transformCode = transformCode 15 | 16 | const transformScript = (content) => content 17 | 18 | const transformJSON = async (content) => { 19 | const { default: JSON5 } = await import('json5'); 20 | return `export default ${JSON.stringify(JSON5.parse(content))}`; 21 | } 22 | 23 | const transformYaml = async (content, { filename }) => { 24 | const { load } = await import('js-yaml'); 25 | return `export default ${JSON.stringify(load(content, { filename }))}`; 26 | } 27 | 28 | const stdTransformers = { 29 | // order matters as it defines which extensions are tried first 30 | // when looking for a matching file for a locale 31 | '.js': transformScript, 32 | '.ts': transformScript, 33 | '.mjs': transformScript, 34 | 35 | // use json5 loader for json files 36 | '.json': transformJSON, 37 | '.json5': transformJSON, 38 | 39 | '.yaml': transformYaml, 40 | '.yml': transformYaml, 41 | }; 42 | 43 | function svelteIntlPrecompile(localesRoot, prefixOrOptions) { 44 | const { 45 | prefix = '$locales', 46 | transformers: customTransformers, 47 | exclude, 48 | } = typeof prefixOrOptions === 'string' 49 | ? { prefix: prefixOrOptions } 50 | : { ...prefixOrOptions }; 51 | 52 | const resolvedPath = path.resolve(localesRoot); 53 | 54 | const transformers = { ...stdTransformers, ...customTransformers } 55 | 56 | async function loadPrefixModule() { 57 | const code = [ 58 | `import { register } from 'svelte-intl-precompile'`, 59 | `export function registerAll() {` 60 | // register('en', () => import('$locales/en')) 61 | ]; 62 | 63 | const availableLocales = []; 64 | const filesInLocalesFolder = await fs.readdir(localesRoot) 65 | const excludeFn = typeof exclude === 'function' ? exclude : (exclude instanceof RegExp ? (s) => exclude.test(s) : () => false); 66 | const languageFiles = filesInLocalesFolder.filter(name => !excludeFn(name)); 67 | 68 | // add register calls for each found locale 69 | for (const file of languageFiles) { 70 | const extname = path.extname(file); 71 | 72 | if (transformers[extname]) { 73 | const locale = path.basename(file, extname); 74 | 75 | // ensure we register each locale only once 76 | // there shouldn't be more than one file each locale 77 | // our load(id) method only ever loads the first found 78 | // file for a locale and it makes no sense to register 79 | // more than one file per locale 80 | if (!availableLocales.includes(locale)) { 81 | availableLocales.push(locale); 82 | 83 | code.push( 84 | ` register(${JSON.stringify(locale)}, () => import(${ 85 | JSON.stringify(`${prefix}/${locale}`) 86 | }))`, 87 | ) 88 | } 89 | } 90 | } 91 | 92 | // Sort locales that more specific locales come first 93 | // 'en-US' comes before 'en' 94 | availableLocales.sort((a, b) => { 95 | const order = a.split('-').length - b.split('-').length 96 | 97 | if (order) return order 98 | 99 | return a.localeCompare(b, 'en') 100 | }) 101 | 102 | code.push( 103 | `}`, 104 | `export const availableLocales = ${JSON.stringify(availableLocales)}`, 105 | ); 106 | 107 | return code.join('\n'); 108 | } 109 | 110 | async function tranformLocale( 111 | content, 112 | { 113 | filename, 114 | extname = path.extname(filename), 115 | basename = path.basename(filename, extname), 116 | transform = transformers[extname] || transformScript 117 | } 118 | ) { 119 | const code = await transform(content, { filename, basename, extname }); 120 | 121 | return transformCode(code, { filename }); 122 | } 123 | 124 | async function findLocale(basename) { 125 | const filebase = path.resolve(localesRoot, basename); 126 | 127 | // lazy loaded here because strip-bom is an es module 128 | // and this file could be a cjs module 129 | // by using dynamic import we can load es modules 130 | const { default: stripBom } = await import('strip-bom'); 131 | 132 | for await (const [extname, transform] of Object.entries(transformers)) { 133 | const filename = filebase + extname; 134 | 135 | try { 136 | const content = await fs.readFile(filename, { encoding: 'utf-8' }); 137 | 138 | return tranformLocale(stripBom(content), { filename, basename, extname, transform }); 139 | } catch (error) { 140 | // incase the file did not exist try next transformer 141 | // otherwise propagate the error 142 | if (error.code !== 'ENOENT') { 143 | throw error; 144 | } 145 | } 146 | } 147 | } 148 | 149 | return { 150 | name: 'svelte-intl-precompile', // required, will show up in warnings and errors 151 | enforce: 'pre', 152 | configureServer(server) { 153 | const { ws, watcher, moduleGraph } = server; 154 | // listen to vite files watcher 155 | watcher.on('change', (file) => { 156 | file = path.relative('', file); 157 | // check if file changed is a locale 158 | if (pathStartsWith(file, localesRoot)) { 159 | // invalidate $locales/ modules 160 | const name = `${prefix}/${path.basename(file, path.extname(file))}`; 161 | 162 | // Alltough we are normalizing the module names 163 | // $locales/en.js -> $locales/en 164 | // we check all configured extensions just to be sure 165 | // '.js', '.ts', '.json', ..., and '' ($locales/en) 166 | for (const extname of [...Object.keys(transformers), '']) { 167 | // check if locale file is in vite cache 168 | const localeModule = moduleGraph.getModuleById(`${name}${extname}`); 169 | 170 | if (localeModule) { 171 | moduleGraph.invalidateModule(localeModule); 172 | } 173 | } 174 | 175 | // invalidate $locales module 176 | const prefixModule = moduleGraph.getModuleById(prefix) 177 | if (prefixModule) { 178 | moduleGraph.invalidateModule(prefixModule); 179 | } 180 | 181 | // trigger hmr 182 | ws.send({ type: 'full-reload', path: '*' }); 183 | } 184 | }) 185 | }, 186 | resolveId(id) { 187 | if (id === prefix || id.startsWith(`${prefix}/`)) { 188 | const extname = path.extname(id); 189 | 190 | // "normalize" module id to have no extension 191 | // $locales/en.js -> $locales/en 192 | // we do this as the extension is ignored 193 | // when loading a locale 194 | // we always try to find a locale file by its basename 195 | // and adding an extension from transformers 196 | // additionally this prevents loading the same module/locale 197 | // several times with different extensions 198 | const normalized = extname 199 | ? id.slice(0, -extname.length) 200 | : id 201 | 202 | return normalized; 203 | } 204 | }, 205 | load(id) { 206 | // allow to auto register locales by calling registerAll from $locales module 207 | // import { registerAll, availableLocales } from '$locales' 208 | if (id === prefix) { 209 | return loadPrefixModule(); 210 | } 211 | 212 | // import en from '$locales/en' 213 | // import en from '$locales/en.js' 214 | if (id.startsWith(`${prefix}/`)) { 215 | const extname = path.extname(id); 216 | 217 | // $locales/en -> en 218 | // $locales/en.js -> en 219 | // $locales/en.ts -> en 220 | const locale = extname 221 | ? id.slice(`${prefix}/`.length, -extname.length) 222 | : id.slice(`${prefix}/`.length) 223 | 224 | return findLocale(locale); 225 | } 226 | }, 227 | transform(content, id) { 228 | // import locale from '../locales/en.js' 229 | if (pathStartsWith(id, resolvedPath)) { 230 | return tranformLocale(content, { filename: id }); 231 | } 232 | } 233 | } 234 | } 235 | 236 | svelteIntlPrecompile.transformCode = transformCode; 237 | 238 | module.exports = svelteIntlPrecompile; 239 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Svelte Intl Precompile](https://raw.githubusercontent.com/cibernox/svelte-intl-precompile/main/logos/svelte-intl-precompile-double-line.svg) 2 | 3 | ## Svelte-intl-precompile 4 | 5 | This i18n library for Svelte.js has an API identical (or at least very similar) to https://github.com/kaisermann/svelte-i18n but has 6 | a different approach to processing translations. 7 | 8 | Instead of doing all the work in the client, much like Svelte.js acts as a compiler for your app, this library acts as a compiler 9 | for your translations. 10 | 11 | ## Check the documentation page, it's better than this Readme 12 | 13 | Go to [https://svelte-intl-precompile.com](https://svelte-intl-precompile.com) 14 | 15 | Still, there you have the rest of the Readme. 16 | 17 | ### Why would I want to use it? How does it work? 18 | This approach is different than the taken by libraries like intl-messageformat or format-message, which do all the work in the browser. The approach taken by those libraries is more flexible as you can just load json files with translations in plain text and that's it, but it also means the library needs to ship a parser for the ICU message syntax, and it always has to have ship code for all the features that the ICU syntax supports, even features you might not use, making those libraries several times bigger. 19 | 20 | This process spares the browser of the burden of doing the same process in the user's devices, resulting in smaller and faster apps. 21 | 22 | For instance if an app has the following set of translations: 23 | ```json 24 | { 25 | "plain": "Some text without interpolations", 26 | "interpolated": "A text where I interpolate {count} times", 27 | "time": "Now is {now, time}", 28 | "number": "My favorite number is {n, number}", 29 | "pluralized": "I have {count, plural,=0 {no cats} =1 {one cat} other {{count} cats}}", 30 | "pluralized-with-hash": "I have {count, plural, zero {no cats} one {just # cat} other {# cats}}", 31 | "selected": "{gender, select, male {He is a good boy} female {She is a good girl} other {They are good fellas}}", 32 | "numberSkeleton": "Your account balance is {n, number, ::currency/CAD sign-always}", 33 | "installProgress": "{progress, number, ::percent scale/100 .##} completed" 34 | } 35 | ``` 36 | 37 | The babel plugin will analyze and understand the strings in the ICU message syntax and transform them into something like: 38 | ```js 39 | import { __interpolate, __number, __plural, __select, __time } from "precompile-intl-runtime"; 40 | export default { 41 | plain: "Some text without interpolations", 42 | interpolated: count => `A text where I interpolate ${__interpolate(count)} times`, 43 | time: now => `Now is ${__time(now)}`, 44 | number: n => `My favorite number is ${__number(n)}`, 45 | pluralized: count => `I have ${__plural(count, { 0: "no cats", 1: "one cat", h: `${__interpolate(count)} cats`})}`, 46 | "pluralized-with-hash": count => `I have ${__plural(count, { z: "no cats", o: `just ${count} cat`, h: `${count} cats`})}`, 47 | selected: gender => __select(gender, { male: "He is a good boy", female: "She is a good girl", other: "They are good fellas"}), 48 | numberSkeleton: n => `Your account balance is ${__number(n, { style: 'currency', currency: 'CAD', signDisplay: 'always' })}`, 49 | installProgress: progress => `${__number(progress / 100, { style: 'percent', maximumFractionDigits: 2 })} completed` 50 | } 51 | ``` 52 | 53 | Now the translations are either strings or functions that take some arguments and generate strings using some utility helpers. Those utility helpers are very small and use the native Intl API available in all modern browsers and in node. Also, unused helpers are tree-shaken by rollup. 54 | 55 | When the above code is minified it will results in an output that compact that often is shorter than the original ICU string: 56 | 57 | ``` 58 | "pluralized-with-hash": "I have {count, plural, zero {no cats} one {just # cat} other {# cats}}", 59 | -------------------------------------------------------------------------------------------------- 60 | "pluralized-with-hash":t=>`I have ${jt(t,{z:"no cats",o:`just ${t} cat`,h:`${t} cats`})}` 61 | ``` 62 | 63 | The combination of a very small and treeshakeable runtime with moving the parsing into the build step results in an extremely small footprint and 64 | extremely fast performance. 65 | 66 | **How small, you may ask?** 67 | Usually adds less than 2kb to your final build size after compression and minification, when compared with nearly 15kb that alternatives with 68 | a runtime ICU-message parser like `svelte-i18n` add. 69 | 70 | **How fast, you may also ask?** 71 | When rendering a key that has also been rendered before around 25% faster. For initial rendering or rendering a keys that haven't been rendered 72 | before, around 400% faster. 73 | 74 | ### Setup 75 | First of all, you can find a working sveltekit app configured to use `svelte-intl-precompile` in https://github.com/cibernox/sample-app-svelte-intl-precompile. 76 | If you struggle with any of the following steps you can always use that app to compare it with yours: 77 | 78 | 1. Install `svelte-intl-precompile` as a runtime dependency. 79 | 80 | 2. Create a folder to put your translations. I like to use a `/messages` or `/locales` folder on the root. On that folder, create `en.json`, `es.json` (you can also create JS files exporting objects with the translations) and as many files as languages you want. On each file, export an object with your translations: 81 | ```json 82 | { 83 | "recent.aria": "Find recently viewed tides", 84 | "menu": "Menu", 85 | "foot": "{count} {count, plural, =1 {foot} other {feet}}", 86 | } 87 | ``` 88 | 89 | 3. In your `svelte.config.js` import the function exported by `svelte-intl-precompile/sveltekit-plugin` and invoke with the folder where you've placed 90 | your translation files it to your list of Vite plugins: 91 | ```js 92 | import precompileIntl from "svelte-intl-precompile/sveltekit-plugin"; 93 | 94 | /** @type {import('@sveltejs/kit').Config} */ 95 | module.exports = { 96 | kit: { 97 | target: '#svelte', 98 | vite: { 99 | plugins: [ 100 | // if your translations are defined in /locales/[lang].js 101 | precompileIntl('locales') 102 | // precompileIntl('locales', '$myprefix') // also you can change import path prefix for json files ($locales by default) 103 | ] 104 | } 105 | } 106 | }; 107 | ``` 108 | 109 | If you are using CommonJS, you can instead use `const precompileIntl = require("svelte-intl-precompile/sveltekit-plugin");`. 110 | 111 | From this step onward the library almost identical to use and configure to the popular `svelte-i18n`. It has the same features and only the import path is different. You can check the docs of `svelte-i18n` for examples and details in the configuration options. 112 | 113 | 4. Now you need some initialization code to register your locales and configure your preferences. You can import your languages statically (which will add them to your bundle) or register loaders that will load the translations lazily. The best place to put this configuration is inside a ` 130 | 131 | 134 | 135 | 136 | ``` 137 | 138 | 5. Now on your `.svelte` files you start translating using the `t` store exported from `svelte-intl-precompile`: 139 | ```html 140 | 143 |
144 | 145 |
146 | ``` 147 | 148 | 149 | ## Note for automatic browser locale detection when server side rendering 150 | 151 | If you want to automatically detect your user's locale from the browser using `getLocaleFromNavigator()` but you are 152 | server side rendering your app (which sveltekit does by default), you need to take some extra steps for the 153 | locale used when SSR matches the locale when hydrating the app which would cause texts to change. 154 | 155 | You can pass to `getLocaleFromNavigator` an optional argument which is the locale to use when SSR'ing your app. 156 | How you get that value depends on how you run your app, but for instance using sveltekit you can extract it from the 157 | `accept-language` HTTP header of the request, using [Hooks](https://kit.svelte.dev/docs#hooks) 158 | 159 | You can use `getSession` to extract the preferred locale from the request headers and store it in the session object, 160 | which is made available to the client: 161 | ```js 162 | // src/hooks.js 163 | export function getSession(request) { 164 | let acceptedLanguage = request.headers["accept-language"] && request.headers["accept-language"].split(',')[0];` 165 | return { acceptedLanguage }; 166 | } 167 | ``` 168 | 169 | Then you can use the `session` store to pass it to the `init` function: 170 | ```html 171 | 172 | 187 | ``` 188 | 189 | If you have a lot of languages or want to register all available languages, you can use the `registerAll` function: 190 | 191 | ```html 192 | 193 | 209 | ``` 210 | --------------------------------------------------------------------------------