├── .tool-versions ├── docs ├── .vitepress │ ├── language │ │ ├── LINGUAS │ │ ├── messages.pot │ │ ├── translations.json │ │ ├── zh_CN.po │ │ ├── en_GB.po │ │ └── fr_FR.po │ ├── theme │ │ ├── index.ts │ │ └── demo │ │ │ ├── DemoBox.vue │ │ │ ├── LanguageSelect.vue │ │ │ ├── DemoPluralization.vue │ │ │ └── DemoParams.vue │ ├── gettext.d.ts │ ├── tsconfig.json │ ├── i18n.ts │ └── config.ts ├── setup.md ├── index.md ├── README.md ├── configuration.md ├── demo.md ├── extraction.md └── functions.md ├── .vscode └── settings.json ├── .prettierrc ├── tests ├── rawModules.d.ts ├── json │ ├── plugin.config.ts │ ├── directive.arabic.ts │ ├── component.ts │ ├── vueComponent.txt │ ├── directive.ts │ ├── translate.ts │ └── unicodeTestPage.txt ├── utils.ts ├── plurals.spec.ts ├── extract.test.ts ├── arabic.spec.ts ├── tokenizer.spec.ts ├── config.test.ts ├── interpolate.spec.ts ├── parser.spec.ts ├── component.spec.ts └── translate.spec.ts ├── .prettierignore ├── vitest.config.ts ├── .github ├── release.yml └── workflows │ ├── pages-deploy.yml │ ├── pull-request.yml │ └── release.yml ├── .gitignore ├── scripts ├── tsconfig.json ├── utils.ts ├── extract.ts ├── gettext_compile.ts ├── config.ts ├── compile.ts └── gettext_extract.ts ├── tsup.config.ts ├── tsconfig.json ├── gettext.config.js ├── eslint.config.js ├── LICENSE ├── src ├── utilities.ts ├── interpolate.ts ├── index.ts ├── extract │ ├── tokenizer.ts │ └── parser.ts ├── typeDefs.ts ├── plurals.ts └── translate.ts ├── README.md └── package.json /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 22.18.0 2 | -------------------------------------------------------------------------------- /docs/.vitepress/language/LINGUAS: -------------------------------------------------------------------------------- 1 | en_GB fr_FR zh_CN -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "arrowParens": "always", 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /tests/rawModules.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*?raw" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /tests/json/plugin.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | en_US: { 3 | Foo: "Foo en_US", 4 | }, 5 | fr_FR: { 6 | Foo: "Foo fr_FR", 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | docs/.vitepress/cache 5 | docs/.vitepress/language/translations.json 6 | distDocs 7 | .vscode 8 | .eslintrc.json 9 | package.json 10 | package-lock.json -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import vue from "@vitejs/plugin-vue"; 3 | 4 | export default defineConfig({ 5 | plugins: [vue()], 6 | test: { 7 | globals: true, 8 | environment: "happy-dom", 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: BREAKING CHANGES 💣 4 | labels: 5 | - breaking-change 6 | - title: Enhancements ✨ 7 | labels: 8 | - enhancement 9 | - title: Fixes 10 | labels: 11 | - bug 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.tmp/ 2 | /node_modules/ 3 | dist 4 | distDocs 5 | npm-debug.log 6 | manifest.json 7 | *.DS_Store 8 | *~ 9 | .*.swp 10 | .*.pid 11 | *.mo 12 | ./coverage 13 | .yarn 14 | yarn.lock 15 | 16 | .tm_properties 17 | .idea/ 18 | 19 | docs/.vitepress/cache 20 | .npmrc 21 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import DefaultTheme from "vitepress/theme"; 2 | import { Theme } from "vitepress"; 3 | import { gettext } from "../i18n"; 4 | 5 | export default { 6 | ...DefaultTheme, 7 | enhanceApp({ app }) { 8 | app.use(gettext); 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2018", 5 | "module": "NodeNext", 6 | "moduleResolution": "nodenext", 7 | "esModuleInterop": true, 8 | "lib": ["ES5", "ES2021"] 9 | }, 10 | "include": ["**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tests/json/directive.arabic.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | ar: { 3 | Orange: "البرتقالي", 4 | "%{ count } day": [ 5 | "%{ count }أقل من يوم", 6 | "%{ count }يوم واحد", 7 | "%{ count }يومان", 8 | "%{ count } أيام", 9 | "%{ count } يومًا", 10 | "%{ count } يوم", 11 | ], 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /docs/.vitepress/gettext.d.ts: -------------------------------------------------------------------------------- 1 | import "vue"; 2 | 3 | declare module "vue" { 4 | interface ComponentCustomProperties { 5 | __: ComponentCustomProperties["$gettext"]; 6 | _x: ComponentCustomProperties["$pgettext"]; 7 | _n: ComponentCustomProperties["$ngettext"]; 8 | _xn: ComponentCustomProperties["$npgettext"]; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/.vitepress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "moduleResolution": "Bundler", 5 | "resolveJsonModule": true, 6 | "esModuleInterop": true, 7 | "baseUrl": "./", 8 | "skipLibCheck": true, 9 | "lib": ["ES5", "ES2021"] 10 | }, 11 | "include": ["./**/*.vue", "./**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "node:child_process"; 2 | 3 | export function execShellCommand(cmd: string) { 4 | return new Promise((resolve) => { 5 | exec(cmd, { env: process.env }, (error, stdout, stderr) => { 6 | if (error) { 7 | console.warn(error); 8 | } 9 | resolve(stdout ? stdout : stderr); 10 | }); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { createGettext } from "../src"; 2 | import type { GetTextOptions } from "../src/typeDefs"; 3 | import { mount } from "@vue/test-utils"; 4 | 5 | export const mountWithPlugin = (pluginOptions: Partial) => (componentOptions: any) => { 6 | const gettext = createGettext(pluginOptions); 7 | return mount(componentOptions, { 8 | global: { 9 | plugins: [gettext], 10 | }, 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig([ 4 | { 5 | entry: ["src/index.ts"], 6 | clean: true, 7 | dts: true, 8 | format: ["esm"], 9 | }, 10 | { 11 | entry: ["scripts/gettext_extract.ts"], 12 | clean: true, 13 | external: ["typescript"], 14 | format: ["esm"], 15 | }, 16 | { 17 | entry: ["scripts/gettext_compile.ts"], 18 | clean: true, 19 | format: ["esm"], 20 | }, 21 | ]); 22 | -------------------------------------------------------------------------------- /.github/workflows/pages-deploy.yml: -------------------------------------------------------------------------------- 1 | name: pages 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | build-and-deploy: 8 | runs-on: ubuntu-24.04 9 | steps: 10 | - name: checkout 11 | uses: actions/checkout@v5 12 | 13 | - name: build 14 | run: | 15 | npm ci 16 | npm run docs:build 17 | 18 | - name: deploy 19 | uses: JamesIves/github-pages-deploy-action@v4 20 | with: 21 | branch: pages 22 | folder: docs/.vitepress/dist 23 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/demo/DemoBox.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "esnext", 5 | "sourceMap": false, 6 | "strict": true, 7 | "strictNullChecks": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "moduleResolution": "Bundler", 11 | "skipLibCheck": true, 12 | "lib": ["DOM", "ES5", "ES2021"], 13 | "rootDir": ".", 14 | "types": ["vitest/globals"], 15 | "allowJs": true 16 | }, 17 | "exclude": ["node_modules", "**/node_modules/*"], 18 | "include": ["src/**/*.ts", "tests/**/*.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /gettext.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** @type {import('./src/index').Config} */ 3 | const config = { 4 | input: { 5 | path: "./docs", 6 | include: [".vitepress/theme/**/*.vue", "demo.md"], 7 | parserOptions: { 8 | mapping: { 9 | simple: ["__"], 10 | plural: ["_n"], 11 | ctx: ["_x"], 12 | ctxPlural: ["_xn"], 13 | }, 14 | }, 15 | }, 16 | output: { 17 | path: "./docs/.vitepress/language", 18 | locales: ["en_GB", "fr_FR", "zh_CN"], 19 | splitJson: false, 20 | locations: true, 21 | }, 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /docs/.vitepress/i18n.ts: -------------------------------------------------------------------------------- 1 | import translations from "./language/translations.json"; 2 | import { createGettext } from "../../src"; 3 | 4 | const gettext = createGettext({ 5 | availableLanguages: { 6 | en_GB: "British English", 7 | fr_FR: "Français", 8 | zh_CN: "简体中文", 9 | }, 10 | defaultLanguage: "en_GB", 11 | translations: translations, 12 | setGlobalProperties: true, 13 | globalProperties: { 14 | // custom global properties name 15 | gettext: ["$gettext", "__"], 16 | ngettext: ["$ngettext", "_n"], 17 | pgettext: ["$pgettext", "_x"], 18 | npgettext: ["$npgettext", "_xn"], 19 | }, 20 | }); 21 | 22 | export { gettext }; 23 | -------------------------------------------------------------------------------- /tests/json/component.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | en_US: { 3 | Answer: { 4 | Noun: "Answer (noun)", 5 | Verb: "Answer (verb)", 6 | }, 7 | "Hello %{ name }": "Hello %{ name }", 8 | Pending: "Pending", 9 | "%{ count } car": ["1 car", "%{ count } cars"], 10 | "A lot of lines": "A lot of lines", 11 | }, 12 | fr_FR: { 13 | Answer: { 14 | Noun: "Réponse (nom)", 15 | Verb: "Réponse (verbe)", 16 | }, 17 | "Hello %{ name }": "Bonjour %{ name }", 18 | Pending: "En cours", 19 | "%{ count } car": ["1 véhicule", "%{ count } véhicules"], 20 | "A lot of lines": "Plein de lignes", 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginVue from "eslint-plugin-vue"; 3 | import eslintConfigPrettier from "eslint-config-prettier/flat"; 4 | 5 | export default [ 6 | { 7 | ignores: ["**/node_modules", "**/dist", "**/distDocs", "**/coverage", "docs/.vitepress/cache", "**/.vscode"], 8 | }, 9 | ...pluginVue.configs["flat/recommended"], 10 | eslintConfigPrettier, 11 | { 12 | languageOptions: { 13 | globals: { 14 | ...globals.node, 15 | }, 16 | ecmaVersion: 2020, 17 | sourceType: "module", 18 | parserOptions: { 19 | parser: "@typescript-eslint/parser", 20 | }, 21 | }, 22 | rules: {}, 23 | }, 24 | ]; 25 | -------------------------------------------------------------------------------- /docs/.vitepress/language/messages.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | 5 | #: docs/.vitepress/theme/demo/DemoParams.vue:4 6 | #: docs/demo.md:81 7 | msgid "%{name} is a good friend. My favorite number is %{favNum}." 8 | msgstr "" 9 | 10 | #: docs/.vitepress/theme/demo/DemoPluralization.vue:4 11 | #: docs/demo.md:100 12 | msgid "I have %{count} book." 13 | msgid_plural "I have %{count} books." 14 | msgstr[0] "" 15 | msgstr[1] "" 16 | 17 | #: docs/.vitepress/theme/demo/LanguageSelect.vue:4 18 | msgid "Select your language:" 19 | msgstr "" 20 | 21 | #: docs/demo.md:17 22 | #: docs/demo.md:63 23 | #: docs/demo.md:69 24 | #: docs/demo.md:122 25 | msgid "I like cats." 26 | msgstr "" 27 | -------------------------------------------------------------------------------- /docs/.vitepress/language/translations.json: -------------------------------------------------------------------------------- 1 | {"en_GB":{"%{name} is a good friend. My favorite number is %{favNum}.":"%{name} is a good friend. My favorite number is %{favNum}.","I have %{count} book.":["I have %{count} book.","I have %{count} books."],"Select your language:":"Select your language:","I like cats.":"I like cats."},"fr_FR":{"%{name} is a good friend. My favorite number is %{favNum}.":"%{name} est une bonne amie. Mon numéro préféré est le %{favNum}.","I have %{count} book.":["J'ai %{count} livre.","J'ai %{count} livres."],"Select your language:":"Choisissez votre langue:","I like cats.":"J'aime les chats."},"zh_CN":{"%{name} is a good friend. My favorite number is %{favNum}.":"%{name} 是一个好朋友。我最喜欢的数字是 %{favNum}。","I have %{count} book.":["我有 %{count} 本书。","我有 %{count} 本书。"],"Select your language:":"选择你的语言","I like cats.":"我喜欢猫。"}} -------------------------------------------------------------------------------- /docs/.vitepress/theme/demo/LanguageSelect.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 24 | 25 | 37 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: pull request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | types: [opened, edited, reopened, synchronize] 8 | 9 | jobs: 10 | test: 11 | name: "test" 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: check out repo 16 | uses: actions/checkout@v3 17 | - name: setup node 18 | id: setup-node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 20 22 | - name: install system dependencies 23 | run: | 24 | sudo apt-get update 25 | sudo apt-get install -y gettext 26 | - name: install npm dependencies 27 | run: npm ci 28 | - name: run tests 29 | run: npm run test 30 | - name: build package 31 | run: npm run build 32 | - name: build docs 33 | run: npm run docs:build 34 | -------------------------------------------------------------------------------- /tests/json/vueComponent.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.vitepress/language/zh_CN.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: vue 3-gettext\n" 4 | "Last-Translator: Automatically generated\n" 5 | "Language-Team: none\n" 6 | "Language: zh_CN\n" 7 | "MIME-Version: 1.0\n" 8 | "Content-Type: text/plain; charset=UTF-8\n" 9 | "Content-Transfer-Encoding: 8bit\n" 10 | 11 | #: docs/.vitepress/theme/demo/DemoParams.vue:4 docs/demo.md:81 12 | msgid "%{name} is a good friend. My favorite number is %{favNum}." 13 | msgstr "%{name} 是一个好朋友。我最喜欢的数字是 %{favNum}。" 14 | 15 | #: docs/.vitepress/theme/demo/DemoPluralization.vue:4 docs/demo.md:100 16 | msgid "I have %{count} book." 17 | msgid_plural "I have %{count} books." 18 | msgstr[0] "我有 %{count} 本书。" 19 | msgstr[1] "我有 %{count} 本书。" 20 | 21 | #: docs/.vitepress/theme/demo/LanguageSelect.vue:4 22 | msgid "Select your language:" 23 | msgstr "选择你的语言" 24 | 25 | #: docs/demo.md:17 docs/demo.md:63 docs/demo.md:69 docs/demo.md:122 26 | msgid "I like cats." 27 | msgstr "我喜欢猫。" 28 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | 3 | export default defineConfig({ 4 | title: "vue3-gettext", 5 | description: "Translate Vue applications with gettext", 6 | base: "/vue3-gettext/", 7 | themeConfig: { 8 | sidebar: [ 9 | { text: "Overview", link: "/" }, 10 | { 11 | text: "Getting started", 12 | items: [ 13 | { link: "/demo.md", text: "Demo" }, 14 | { link: "/demo.md", text: "Quick start guide" }, 15 | ], 16 | }, 17 | { 18 | text: "Setup", 19 | items: [ 20 | { link: "/setup.md", text: "Installation" }, 21 | { link: "/extraction.md", text: "Message extraction" }, 22 | { link: "/configuration.md", text: "Configuration" }, 23 | ], 24 | }, 25 | { 26 | text: "Usage", 27 | items: [{ link: "/functions.md", text: "Functions" }], 28 | }, 29 | ], 30 | 31 | socialLinks: [{ icon: "github", link: "https://github.com/jshmrtn/vue3-gettext/" }], 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/demo/DemoPluralization.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 31 | 32 | 45 | -------------------------------------------------------------------------------- /docs/.vitepress/language/en_GB.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: vue 3-gettext\n" 4 | "Last-Translator: Automatically generated\n" 5 | "Language-Team: none\n" 6 | "Language: en_GB\n" 7 | "MIME-Version: 1.0\n" 8 | "Content-Type: text/plain; charset=UTF-8\n" 9 | "Content-Transfer-Encoding: 8bit\n" 10 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 11 | 12 | #: docs/.vitepress/theme/demo/DemoParams.vue:4 docs/demo.md:81 13 | msgid "%{name} is a good friend. My favorite number is %{favNum}." 14 | msgstr "%{name} is a good friend. My favorite number is %{favNum}." 15 | 16 | #: docs/.vitepress/theme/demo/DemoPluralization.vue:4 docs/demo.md:100 17 | msgid "I have %{count} book." 18 | msgid_plural "I have %{count} books." 19 | msgstr[0] "I have %{count} book." 20 | msgstr[1] "I have %{count} books." 21 | 22 | #: docs/.vitepress/theme/demo/LanguageSelect.vue:4 23 | msgid "Select your language:" 24 | msgstr "Select your language:" 25 | 26 | #: docs/demo.md:17 docs/demo.md:63 docs/demo.md:69 docs/demo.md:122 27 | msgid "I like cats." 28 | msgstr "I like cats." 29 | -------------------------------------------------------------------------------- /docs/.vitepress/language/fr_FR.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: vue 3-gettext\n" 4 | "Last-Translator: Automatically generated\n" 5 | "Language-Team: none\n" 6 | "Language: fr_FR\n" 7 | "MIME-Version: 1.0\n" 8 | "Content-Type: text/plain; charset=UTF-8\n" 9 | "Content-Transfer-Encoding: 8bit\n" 10 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 11 | 12 | #: docs/.vitepress/theme/demo/DemoParams.vue:4 docs/demo.md:81 13 | msgid "%{name} is a good friend. My favorite number is %{favNum}." 14 | msgstr "%{name} est une bonne amie. Mon numéro préféré est le %{favNum}." 15 | 16 | #: docs/.vitepress/theme/demo/DemoPluralization.vue:4 docs/demo.md:100 17 | msgid "I have %{count} book." 18 | msgid_plural "I have %{count} books." 19 | msgstr[0] "J'ai %{count} livre." 20 | msgstr[1] "J'ai %{count} livres." 21 | 22 | #: docs/.vitepress/theme/demo/LanguageSelect.vue:4 23 | msgid "Select your language:" 24 | msgstr "Choisissez votre langue:" 25 | 26 | #: docs/demo.md:17 docs/demo.md:63 docs/demo.md:69 docs/demo.md:122 27 | msgid "I like cats." 28 | msgstr "J'aime les chats." 29 | -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | ## Prerequisites 4 | 5 | Vue 3 Gettext provides scripts to automatically extract translation messages into gettext [PO files](https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html) and, after translation, merge those into a JSON file that can be used in your application. You must install the GNU gettext utilities for those scripts to work: 6 | 7 | **Ubuntu/Linux:** 8 | 9 | ```sh 10 | sudo apt-get update 11 | sudo apt-get install gettext 12 | ``` 13 | 14 | **macOS:** 15 | 16 | ```sh 17 | brew install gettext 18 | brew link --force gettext 19 | ``` 20 | 21 | **Windows:** 22 | 23 | On Windows, you have multiple options. You can run the scripts and install gettext under WSL2 like you would with regular Ubuntu (recommended) or install gettext via mingw64 or cygwin. You may also find precompiled binaries [here](https://mlocati.github.io/articles/gettext-iconv-windows.html). 24 | 25 | ## Installation 26 | 27 | Install Vue 3 Gettext using `npm` (or the package manager of your choice): 28 | 29 | ```sh 30 | npm i vue3-gettext 31 | ``` 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 JOSHMARTIN GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utilities.ts: -------------------------------------------------------------------------------- 1 | import { inject } from "vue"; 2 | import { GetTextOptions, GetTextSymbol, Language, LanguageData, Translations } from "./typeDefs.js"; 3 | 4 | export function normalizeMsgId(key: string) { 5 | return key.replaceAll(/\r?\n/g, "\n"); 6 | } 7 | 8 | export function normalizeTranslations(translations: GetTextOptions["translations"]) { 9 | const newTranslations: Translations = {}; 10 | Object.keys(translations).forEach((lang) => { 11 | const langData = translations[lang]; 12 | const newLangData: LanguageData = {}; 13 | Object.keys(langData).forEach((key) => { 14 | newLangData[normalizeMsgId(key)] = langData[key]; 15 | }); 16 | newTranslations[lang] = newLangData; 17 | }); 18 | return newTranslations; 19 | } 20 | 21 | export const useGettext = (): Language => { 22 | const gettext = inject(GetTextSymbol, null) as Language | null; 23 | if (!gettext) { 24 | throw new Error("Failed to inject gettext. Make sure vue3-gettext is set up properly."); 25 | } 26 | return gettext; 27 | }; 28 | 29 | export function assertIsDefined(value: T): asserts value is NonNullable { 30 | if (value === undefined || value === null) { 31 | throw new Error(`${value} is not defined`); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | test: 9 | name: "test" 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: check out repo 14 | uses: actions/checkout@v3 15 | 16 | - name: prepare release information 17 | id: release 18 | uses: manovotny/github-releases-for-automated-package-publishing-action@v2.0.1 19 | 20 | - name: setup node 21 | id: setup-node 22 | uses: actions/setup-node@v3 23 | with: 24 | always-auth: true 25 | node-version: 20 26 | registry-url: "https://registry.npmjs.org" 27 | - name: install dependencies 28 | run: npm ci 29 | - name: run tests 30 | run: npm run test 31 | - name: build package 32 | run: npm run build 33 | 34 | - name: publish version 35 | if: steps.release.outputs.tag == '' 36 | run: npm publish 37 | env: 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | - name: publish tagged version 40 | if: steps.release.outputs.tag != '' 41 | run: npm publish --tag ${{ steps.release.outputs.tag }} 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | -------------------------------------------------------------------------------- /tests/json/directive.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | en_US: { 3 | Answer: { 4 | Noun: "Answer (noun)", 5 | Verb: "Answer (verb)", 6 | }, 7 | "Hello %{ name }": "Hello %{ name }", 8 | "Hello %{ openingTag }%{ name }%{ closingTag }": "Hello %{ openingTag }%{ name }%{ closingTag }", 9 | Pending: "Pending", 10 | "%{ count } car": ["1 car", "%{ count } cars"], 11 | "%{ count } %{ brand } car": [ 12 | "1 %{ brand } car", 13 | "%{ count } %{ brand } cars", 14 | ], 15 | }, 16 | fr_FR: { 17 | Answer: { 18 | Noun: "Réponse (nom)", 19 | Verb: "Réponse (verbe)", 20 | }, 21 | "Hello %{ openingTag }%{ name }%{ closingTag }": "Bonjour %{ openingTag }%{ name }%{ closingTag }", 22 | "Hello %{ name }": "Bonjour %{ name }", 23 | Pending: "En cours", 24 | "%{ count } car": ["1 véhicule", "%{ count } véhicules"], 25 | "%{ count } %{ brand } car": [ 26 | "1 %{ brand } véhicule", 27 | "%{ count } %{ brand } véhicules", 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # vue3-gettext 🌍 2 | 3 | Translate [Vue](http://vuejs.org) applications with [gettext](https://en.wikipedia.org/wiki/Gettext). 4 | 5 | ## Features 6 | 7 | - Simple, ergonomic API 8 | - Very **little overhead** for **translation-ready apps** 9 | - In gettext, translation keys (message ids) don't have to be manually defined 10 | - CLI tools to **automatically extract messages** from code 11 | - Easy pluralization 12 | 13 | ## Basic workflow 14 | 15 | ### 1. Use translation functions in your code: 16 | 17 | ```ts 18 | $gettext("I'm %{age} years old!", { age: 32 }); 19 | ``` 20 | 21 | ### 2. Extract messages 22 | 23 | Run `vue-gettext-extract` to extract messages into a `.pot` file and `.po` files for each of your configured languages: 24 | 25 | ```bash 26 | $ vue-gettext-extract 27 | 28 | # creates: 29 | language 30 | ├── LINGUAS 31 | ├── en_US.po 32 | ├── fr_FR.po 33 | ├── it_IT.po 34 | ├── zh_CN.po 35 | └── messages.pot 36 | ``` 37 | 38 | ### 3. Translate and compile messages 39 | 40 | Edit the `msgstr`s in the `.po` files with a text editor or specialized tools like [POEdit](https://poedit.net/): 41 | 42 | ```po 43 | # de_DE.po 44 | msgid "I'm %{age} years old!" 45 | msgstr "Ich bin %{age} Jahre alt!" 46 | ``` 47 | 48 | ```bash 49 | $ vue-gettext-compile 50 | 51 | # creates: 52 | language 53 | └── translations.json 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/demo/DemoParams.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 26 | 27 | 58 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | heroText: Vue 3 Gettext 4 | tagline: Translate your Vue 3 applications with Gettext 5 | actions: 6 | - text: Demo 7 | link: /demo.html 8 | type: primary 9 | - text: Setup 10 | link: /setup.html 11 | type: secondary 12 | - text: Docs 13 | link: /setup.html 14 | type: secondary 15 | footer: MIT Licensed | Copyright © 2020-present JOSHMARTIN GmbH 16 | --- 17 | 18 | # Quick Start 19 | 20 | ```sh 21 | npm i vue3-gettext 22 | ``` 23 | 24 | Set up gettext in your `main.ts`/`main.js`: 25 | 26 | ```javascript {main.ts/main.js} 27 | import { createGettext } from "vue3-gettext"; 28 | import { createApp } from "vue"; 29 | import translations from "./src/language/translations.json"; 30 | 31 | const app = createApp(App); 32 | app.use(createGettext({ translations })); 33 | ``` 34 | 35 | Use gettext functions in your application: 36 | 37 | ```jsx 38 | {{ $gettext("Translate me") }} 39 | ``` 40 | 41 | Add scripts to your `package.json`: 42 | 43 | ```json { package.json } 44 | "scripts": { 45 | ... 46 | "gettext:extract": "vue-gettext-extract", 47 | "gettext:compile": "vue-gettext-compile", 48 | } 49 | ``` 50 | 51 | `npm run gettext:extract` extracts translation keys from your code and creates `.po` files to translate. 52 | 53 | `npm run gettext:compile` compiles the translated messages from the `.po` files to a `.json` to be used in your application. 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Vue 3 Gettext 💬 4 | 5 |

6 |
7 | 8 | Translate [Vue 3](http://vuejs.org) applications with [gettext](https://en.wikipedia.org/wiki/Gettext). 9 | 10 |
11 |

12 | Getting started | Demo | Documentation | 中文 13 |

14 |
15 | 16 | ## Basic usage 17 | 18 | In templates: 19 | 20 | ```jsx 21 | 22 | {{ $gettext("I'm %{age} years old!", { age: 32 }) }} 23 | 24 | ``` 25 | 26 | In code: 27 | 28 | ```ts 29 | const { $gettext } = useGettext(); 30 | 31 | console.log($gettext("Hello World!")); 32 | ``` 33 | 34 | ## Features 35 | 36 | - simple, ergonomic API 37 | - reactive translations in Vue templates and TypeScript/JavaScript code 38 | - CLI to automatically extract messages from code files 39 | - support for pluralization and message contexts 40 | 41 | ## Contribute 42 | 43 | Please make sure your code is properly formatted (the project contains a `prettier` config) and all the tests run successfully (`npm run test`) when opening a pull request. 44 | 45 | Please specify clearly what you changed and why. 46 | 47 | ## Credits 48 | 49 | This plugin relies heavily on the work of the original [`vue-gettext`](https://github.com/Polyconseil/vue-gettext/). 50 | 51 | ## License 52 | 53 | [MIT](http://opensource.org/licenses/MIT) 54 | -------------------------------------------------------------------------------- /tests/plurals.spec.ts: -------------------------------------------------------------------------------- 1 | import plurals from "../src/plurals"; 2 | 3 | describe("Translate plurals tests", () => { 4 | it("plural form of singular english is 0", () => { 5 | expect(plurals.getTranslationIndex("en", 1)).toEqual(0); 6 | }); 7 | 8 | it("plural form of plural english is 1", () => { 9 | expect(plurals.getTranslationIndex("en", 2)).toEqual(1); 10 | }); 11 | 12 | it("plural form of Infinity in english is 1", () => { 13 | expect(plurals.getTranslationIndex("en", Infinity)).toEqual(1); 14 | }); 15 | 16 | it("plural form of zero in english is 1", () => { 17 | expect(plurals.getTranslationIndex("en", 0)).toEqual(1); 18 | }); 19 | 20 | it("plural form of singular dutch is 0", () => { 21 | expect(plurals.getTranslationIndex("nl", 1)).toEqual(0); 22 | }); 23 | 24 | it("plural form of plural dutch is 1", () => { 25 | expect(plurals.getTranslationIndex("nl", 2)).toEqual(1); 26 | }); 27 | 28 | it("plural form of zero in dutch is 1", () => { 29 | expect(plurals.getTranslationIndex("nl", 0)).toEqual(1); 30 | }); 31 | 32 | it("plural form of singular french is 0", () => { 33 | expect(plurals.getTranslationIndex("fr", 1)).toEqual(0); 34 | }); 35 | 36 | it("plural form of plural french is 1", () => { 37 | expect(plurals.getTranslationIndex("fr", 2)).toEqual(1); 38 | }); 39 | 40 | it("plural form of zero in french is 0", () => { 41 | expect(plurals.getTranslationIndex("fr", 0)).toEqual(0); 42 | }); 43 | 44 | it("plural form of 27 in arabic is 4", () => { 45 | expect(plurals.getTranslationIndex("ar", 27)).toEqual(4); 46 | }); 47 | 48 | it("plural form of 23 in kashubian is 1", () => { 49 | expect(plurals.getTranslationIndex("csb", 23)).toEqual(1); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/json/translate.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | en_US: { 3 | Answer: { 4 | Noun: "Answer (noun)", 5 | Verb: "Answer (verb)", 6 | }, 7 | "Hello %{ name }": "Hello %{ name }", 8 | May: "May", 9 | Pending: "Pending", 10 | "%{ carCount } car": ["1 car", "%{ carCount } cars"], 11 | "%{ carCount } car (noun)": { 12 | Noun: ["%{ carCount } car (noun)", "%{ carCount } cars (noun)"], 13 | }, 14 | "%{ carCount } car (verb)": { 15 | Verb: ["%{ carCount } car (verb)", "%{ carCount } cars (verb)"], 16 | }, 17 | "%{ carCount } car (multiple contexts)": { 18 | "": ["1 car", "%{ carCount } cars"], 19 | Context: ["1 car with context", "%{ carCount } cars with context"], 20 | }, 21 | Object: { 22 | "": "Object", 23 | Context: "Object with context", 24 | }, 25 | 26 | "%{ orangeCount } orange": ["", "%{ orangeCount } oranges"], 27 | "%{ appleCount } apple": ["1 apple", ""], 28 | }, 29 | fr: { 30 | Answer: { 31 | Noun: "Réponse (nom)", 32 | Verb: "Réponse (verbe)", 33 | }, 34 | "Hello %{ name }": "Bonjour %{ name }", 35 | May: "Pourrait", 36 | Pending: "En cours", 37 | "%{ carCount } car": ["1 véhicule", "%{ carCount } véhicules"], 38 | "%{ carCount } car (noun)": { 39 | Noun: ["%{ carCount } véhicule (nom)", "%{ carCount } véhicules (nom)"], 40 | }, 41 | "%{ carCount } car (verb)": { 42 | Verb: ["%{ carCount } véhicule (verbe)", "%{ carCount } véhicules (verbe)"], 43 | }, 44 | "%{ carCount } car (multiple contexts)": { 45 | "": ["1 véhicule", "%{ carCount } véhicules"], 46 | Context: ["1 véhicule avec contexte", "%{ carCount } véhicules avec contexte"], 47 | }, 48 | Object: { 49 | "": "Objet", 50 | Context: "Objet avec contexte", 51 | }, 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Once you have extracted your messages and compiled a `.json` file, you are ready to set up the gettext plugin in your `main.ts`/`main.js`: 4 | 5 | ```ts 6 | import { createGettext } from "vue3-gettext"; 7 | import translations from "./language/translations.json"; 8 | 9 | const gettext = createGettext({ 10 | availableLanguages: { 11 | en: "English", 12 | de: "Deutsch", 13 | }, 14 | defaultLanguage: "en", 15 | translations: translations, 16 | }); 17 | 18 | const app = createApp(App); 19 | app.use(gettext); 20 | ``` 21 | 22 | All the available options can be found in the `GetTextOptions` type, these are the default values: 23 | 24 | ```ts 25 | { 26 | availableLanguages: { en: "English" }, 27 | defaultLanguage: "en", 28 | mutedLanguages: [], 29 | silent: false, 30 | translations: {}, 31 | setGlobalProperties: true, 32 | globalProperties: { // custom global properties name 33 | language: ['$language'], // the plugin instance 34 | gettext: ['$gettext'], // ['$gettext', '__'] 35 | pgettext: ['$pgettext'], // ['$pgettext', '_n'] 36 | ngettext: ['$ngettext'], // ['$ngettext','_x'] 37 | npgettext: ['$npgettext'], // ['$npgettext', '_nx'] 38 | interpolate: ['$gettextInterpolate'],// deprecated 39 | }, 40 | } 41 | ``` 42 | 43 | ## Gotchas 44 | 45 | ### Using gettext functions outside of components 46 | 47 | If you need to have plain typescript/javascript files that must access gettext, you may simple move and export gettext from a separate file: 48 | 49 | `gettext.ts` 50 | 51 | ```ts 52 | export default createGettext({ 53 | ... 54 | }); 55 | ``` 56 | 57 | Then import and use the functions: 58 | 59 | ```ts 60 | import gettext from "./gettext"; 61 | 62 | const { $gettext } = gettext; 63 | 64 | const myTest = $gettext("My translation message"); 65 | ``` 66 | -------------------------------------------------------------------------------- /scripts/extract.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import fs from "node:fs"; 3 | import { MsgInfo, makePO, parseSrc } from "../src/extract/parser.js"; 4 | import { GettextConfigOptions } from "../src/typeDefs.js"; 5 | 6 | import PO from "pofile"; 7 | 8 | export async function extractAndCreatePOT(filePaths: string[], potPath: string, config: GettextConfigOptions) { 9 | const fileMsgMap: { path: string; msgs: MsgInfo[] }[] = []; 10 | 11 | await Promise.all( 12 | filePaths.map(async (fp) => { 13 | const buffer: string = await new Promise((res, rej) => 14 | fs.readFile(fp, "utf-8", (err, data) => { 15 | if (err) { 16 | rej(err); 17 | } 18 | res(data); 19 | }), 20 | ); 21 | 22 | const msgs = parseSrc(buffer, { 23 | mapping: config.input?.parserOptions?.mapping, 24 | overrideDefaults: config.input?.parserOptions?.overrideDefaultKeywords, 25 | }); 26 | 27 | fileMsgMap.push({ path: fp, msgs }); 28 | }), 29 | ); 30 | 31 | const pot = new PO(); 32 | pot.headers["Content-Type"] = "text/plain; charset=UTF-8"; 33 | 34 | // sorting makes order more deterministic and prevents unnecessary source diffs 35 | fileMsgMap.sort((a, b) => a.path.localeCompare(b.path)); 36 | fileMsgMap.forEach((m) => { 37 | const po = makePO(m.path, m.msgs); 38 | for (const i of po.items) { 39 | const prevItem = pot.items.find( 40 | (pi) => pi.msgid === i.msgid && pi.msgid_plural === i.msgid_plural && pi.msgctxt === i.msgctxt, 41 | ); 42 | 43 | if (prevItem) { 44 | prevItem.references.push(...i.references); 45 | } else { 46 | pot.items.push(i); 47 | } 48 | } 49 | }); 50 | 51 | try { 52 | fs.writeFileSync(potPath, pot.toString()); 53 | } catch (err) { 54 | console.error(err); 55 | } 56 | 57 | console.info(`${chalk.green("Extraction successful")}, ${chalk.blueBright(potPath)} created.`); 58 | } 59 | -------------------------------------------------------------------------------- /scripts/gettext_compile.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import chalk from "chalk"; 4 | import commandLineArgs, { OptionDefinition } from "command-line-args"; 5 | import fsPromises from "node:fs/promises"; 6 | import path from "node:path"; 7 | import { compilePoFiles } from "./compile.js"; 8 | import { loadConfig } from "./config.js"; 9 | 10 | const optionDefinitions: OptionDefinition[] = [{ name: "config", alias: "c", type: String }]; 11 | let options; 12 | try { 13 | options = commandLineArgs(optionDefinitions) as { 14 | config?: string; 15 | }; 16 | } catch (e) { 17 | console.error(e); 18 | process.exit(1); 19 | } 20 | 21 | (async () => { 22 | const config = await loadConfig(options); 23 | console.info(`Language directory: ${chalk.blueBright(config.output.path)}`); 24 | console.info(`Locales: ${chalk.blueBright(config.output.locales)}`); 25 | console.info(); 26 | const localesPaths = config.output.locales.map((loc) => 27 | config.output.flat ? path.join(config.output.path, `${loc}.po`) : path.join(config.output.path, `${loc}/app.po`), 28 | ); 29 | 30 | await fsPromises.mkdir(config.output.path, { recursive: true }); 31 | const jsonRes = await compilePoFiles(localesPaths); 32 | console.info(`${chalk.green("Compiled json")}: ${chalk.grey(JSON.stringify(jsonRes))}`); 33 | console.info(); 34 | if (config.output.splitJson) { 35 | await Promise.all( 36 | config.output.locales.map(async (locale) => { 37 | const outputPath = path.join(config.output.jsonPath, `${locale}.json`); 38 | await fsPromises.writeFile( 39 | outputPath, 40 | JSON.stringify({ 41 | [locale]: jsonRes[locale], 42 | }), 43 | ); 44 | console.info(`${chalk.green("Created")}: ${chalk.blueBright(outputPath)}`); 45 | }), 46 | ); 47 | } else { 48 | const outputPath = config.output.jsonPath; 49 | await fsPromises.writeFile(outputPath, JSON.stringify(jsonRes)); 50 | console.info(`${chalk.green("Created")}: ${chalk.blueBright(outputPath)}`); 51 | } 52 | })(); 53 | -------------------------------------------------------------------------------- /scripts/config.ts: -------------------------------------------------------------------------------- 1 | import { lilconfig } from "lilconfig"; 2 | import path from "node:path"; 3 | import { GettextConfig, GettextConfigOptions } from "../src/typeDefs.js"; 4 | 5 | export const loadConfig = async (cliArgs?: { config?: string }): Promise => { 6 | const configSearcher = lilconfig("gettext", { 7 | searchPlaces: ["gettext.config.js", "gettext.config.cjs", "gettext.config.mjs", "package.json"], 8 | }); 9 | 10 | let configRes; 11 | if (cliArgs?.config) { 12 | configRes = await configSearcher.load(cliArgs.config); 13 | if (!configRes) { 14 | throw new Error(`Config not found: ${cliArgs.config}`); 15 | } 16 | } else { 17 | configRes = await configSearcher.search(); 18 | } 19 | 20 | const config: GettextConfigOptions = configRes?.config ?? {}; 21 | 22 | const languagePath = config.output?.path || "./src/language"; 23 | const joinPath = (inputPath: string) => path.join(languagePath, inputPath); 24 | const joinPathIfRelative = (inputPath?: string) => { 25 | if (!inputPath) { 26 | return undefined; 27 | } 28 | return path.isAbsolute(inputPath) ? inputPath : path.join(languagePath, inputPath); 29 | }; 30 | return { 31 | input: { 32 | path: config.input?.path || "./src", 33 | include: config.input?.include || ["**/*.js", "**/*.ts", "**/*.vue"], 34 | exclude: config.input?.exclude || [], 35 | parserOptions: config.input?.parserOptions, 36 | }, 37 | output: { 38 | path: languagePath, 39 | potPath: joinPathIfRelative(config.output?.potPath) || joinPath("./messages.pot"), 40 | jsonPath: 41 | joinPathIfRelative(config.output?.jsonPath) || 42 | (config.output?.splitJson ? joinPath("./") : joinPath("./translations.json")), 43 | locales: config.output?.locales || ["en"], 44 | flat: config.output?.flat === undefined ? true : config.output.flat, 45 | linguas: config.output?.linguas === undefined ? true : config.output.linguas, 46 | splitJson: config.output?.splitJson === undefined ? false : config.output.splitJson, 47 | fuzzyMatching: config.output?.fuzzyMatching === undefined ? true : config.output.fuzzyMatching, 48 | locations: config.output?.locations === undefined ? true : config.output.locations, 49 | }, 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /scripts/compile.ts: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/Polyconseil/easygettext/blob/master/src/compile.js 2 | 3 | import Pofile from "pofile"; 4 | import fsPromises from "fs/promises"; 5 | import { LanguageData, MessageContext, Translations } from "../src/typeDefs.js"; 6 | 7 | /** 8 | * Returns a sanitized po data dictionary where: 9 | * - no fuzzy or obsolete strings are returned 10 | * - no empty translations are returned 11 | * 12 | * @param poItems items from the PO catalog 13 | * @returns jsonData: sanitized PO data 14 | */ 15 | export const sanitizePoData = (poItems: InstanceType[]) => { 16 | const messages: LanguageData = {}; 17 | 18 | for (let item of poItems) { 19 | const ctx = item.msgctxt || ""; 20 | if (item.msgstr[0] && item.msgstr[0].length > 0 && !item.flags.fuzzy && !(item as any).obsolete) { 21 | if (!messages[item.msgid]) { 22 | messages[item.msgid] = {}; 23 | } 24 | // Add an array for plural, a single string for singular. 25 | (messages[item.msgid] as MessageContext)[ctx] = item.msgstr.length === 1 ? item.msgstr[0] : item.msgstr; 26 | } 27 | } 28 | 29 | // Strip context from messages that have no context. 30 | for (let key in messages) { 31 | if (Object.keys(messages[key]).length === 1 && (messages[key] as MessageContext)[""]) { 32 | messages[key] = (messages[key] as MessageContext)[""]; 33 | } 34 | } 35 | 36 | return messages; 37 | }; 38 | 39 | export const po2json = (poContent: string) => { 40 | const catalog = Pofile.parse(poContent); 41 | if (!catalog.headers.Language) { 42 | throw new Error("No Language headers found!"); 43 | } 44 | return { 45 | headers: catalog.headers, 46 | messages: sanitizePoData(catalog.items), 47 | }; 48 | }; 49 | 50 | export const compilePoFiles = async (localesPaths: string[]) => { 51 | const translations: Translations = {}; 52 | 53 | await Promise.all( 54 | localesPaths.map(async (lp) => { 55 | const fileContent = await fsPromises.readFile(lp, { encoding: "utf-8" }); 56 | const data = po2json(fileContent); 57 | const lang = data.headers.Language; 58 | if (lang && !translations[lang]) { 59 | translations[lang] = data.messages; 60 | } else { 61 | Object.assign(translations[data.headers.Language!], data.messages); 62 | } 63 | }), 64 | ); 65 | 66 | return translations; 67 | }; 68 | -------------------------------------------------------------------------------- /tests/extract.test.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "fs/promises"; 2 | import { join } from "path"; 3 | import { tmpdir } from "os"; 4 | import { cwd } from "process"; 5 | import { execSync } from "child_process"; 6 | import { describe, it, expect } from "vitest"; 7 | 8 | describe("Extractor Script Tests", () => { 9 | type WithTempDirTest = (tmpDir: string) => Promise; 10 | 11 | // This helper function is copied directly from config.test.ts 12 | const withTempDir = async (testFunc: WithTempDirTest) => { 13 | let tmpDir; 14 | try { 15 | tmpDir = await mkdtemp(join(tmpdir(), "vue3-gettext-extract-")); 16 | await testFunc(tmpDir); 17 | } finally { 18 | if (tmpDir) { 19 | await rm(tmpDir, { recursive: true, force: true }); 20 | } 21 | } 22 | }; 23 | 24 | it("should correctly extract a message from a $gettext call with a trailing comma", async () => { 25 | await withTempDir(async (tmpDir) => { 26 | // 1. Setup the project structure inside the temp directory 27 | for (const d of ["src", "scripts", "node_modules"]) { 28 | await symlink(join(cwd(), d), join(tmpDir, d)); 29 | } 30 | await writeFile(join(tmpDir, "package.json"), JSON.stringify({ name: "test", type: "commonjs" })); 31 | 32 | await writeFile( 33 | join(tmpDir, "gettext.config.js"), 34 | `module.exports = { input: { path: './srctest' }, output: { path: './srctest/lang' } };`, 35 | ); 36 | 37 | await mkdir(join(tmpDir, "srctest", "lang"), { recursive: true }); 38 | await writeFile( 39 | join(tmpDir, "srctest", "MultiLineLiteralWithComma.js"), 40 | ` 41 | const myText = $gettext( 42 | \`This is a multiline template literal, 43 | That previously caused a crash in extraction.\`, 44 | ); 45 | `, 46 | ); 47 | 48 | execSync(`sh -c 'cd ${tmpDir}; tsx ./scripts/gettext_extract.ts'`); 49 | 50 | // Verify that the output .pot file is correct. 51 | const potContent = (await readFile(join(tmpDir, "srctest", "lang", "messages.pot"))).toString(); 52 | console.debug(potContent); 53 | expect(potContent).toContain( 54 | 'msgid ""\n' + 55 | '"This is a multiline template literal,\\n"\n' + 56 | '" That previously caused a crash in extraction."\n', 57 | ); 58 | expect(potContent).toContain("#: srctest/MultiLineLiteralWithComma.js:2"); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/arabic.spec.ts: -------------------------------------------------------------------------------- 1 | import translations from "./json/directive.arabic"; 2 | import { mountWithPlugin } from "./utils"; 3 | 4 | const mount = mountWithPlugin({ 5 | availableLanguages: { 6 | en_US: "American English", 7 | ar: "Arabic", 8 | }, 9 | defaultLanguage: "ar", 10 | translations: translations, 11 | setGlobalProperties: true, 12 | }); 13 | 14 | describe("translate arabic directive tests", () => { 15 | it("translates singular", () => { 16 | const vm = mount({ 17 | template: `

{{ $gettext("Orange") }}

`, 18 | data() { 19 | return { count: 1 }; 20 | }, 21 | }).vm; 22 | expect(vm.$el.innerHTML).toEqual("البرتقالي"); 23 | }); 24 | 25 | it("translates plural form 0", async () => { 26 | const count = 0; 27 | const vm = mount({ 28 | template: '

{{ $ngettext("%{ count } day", "%{ count } days", count, { count }) }}

', 29 | data() { 30 | return { count }; 31 | }, 32 | }).vm; 33 | await vm.$nextTick(); 34 | expect(vm.$el.innerHTML).toEqual(`${count}أقل من يوم`); 35 | }); 36 | 37 | it("translates plural form 1", () => { 38 | const count = 1; 39 | const vm = mount({ 40 | template: '

{{ $ngettext("%{ count } day", "%{ count } days", count, { count }) }}

', 41 | data() { 42 | return { count }; 43 | }, 44 | }).vm; 45 | expect(vm.$el.innerHTML).toEqual(`${count}يوم واحد`); 46 | }); 47 | 48 | it("translates plural form 2", () => { 49 | const count = 2; 50 | const vm = mount({ 51 | template: '

{{ $ngettext("%{ count } day", "%{ count } days", count, { count }) }}

', 52 | data() { 53 | return { count }; 54 | }, 55 | }).vm; 56 | expect(vm.$el.innerHTML).toEqual(`${count}يومان`); 57 | }); 58 | 59 | it("translates plural form 3", () => { 60 | const count = 9; 61 | const vm = mount({ 62 | template: '

{{ $ngettext("%{ count } day", "%{ count } days", count, { count }) }}

', 63 | data() { 64 | return { count }; 65 | }, 66 | }).vm; 67 | expect(vm.$el.innerHTML).toEqual(`${count} أيام`); 68 | }); 69 | 70 | it("translates plural form 4", () => { 71 | const count = 11; 72 | const vm = mount({ 73 | template: '

{{ $ngettext("%{ count } day", "%{ count } days", count, { count }) }}

', 74 | data() { 75 | return { count }; 76 | }, 77 | }).vm; 78 | expect(vm.$el.innerHTML).toEqual(`${count} يومًا`); 79 | }); 80 | 81 | it("translates plural form 5", async () => { 82 | const count = 3000; 83 | const vm = mount({ 84 | template: '

{{ $ngettext("%{ count } day", "%{ count } days", count, { count }) }}

', 85 | data() { 86 | return { count }; 87 | }, 88 | }).vm; 89 | expect(vm.$el.innerHTML).toEqual(`${count} يوم`); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /tests/tokenizer.spec.ts: -------------------------------------------------------------------------------- 1 | import { getKeywords } from "../src/extract/parser"; 2 | import { Token, tokenize, TokenKind } from "../src/extract/tokenizer"; 3 | 4 | const keywords = getKeywords(); 5 | 6 | describe("tokenizer", () => { 7 | it("basic function calls", () => { 8 | expect(tokenize(keywords, `("test")`)).toEqual([ 9 | { kind: TokenKind.ParenLeft, idx: 0 }, 10 | { kind: TokenKind.String, value: "test", idx: 1 }, 11 | ]); 12 | 13 | expect(tokenize(keywords, `("test", 'test', \`test\`)`)).toEqual([ 14 | { kind: TokenKind.ParenLeft, idx: 0 }, 15 | { kind: TokenKind.String, value: "test", idx: 1 }, 16 | { kind: TokenKind.Comma, idx: 7 }, 17 | { kind: TokenKind.String, value: "test", idx: 9 }, 18 | { kind: TokenKind.Comma, idx: 15 }, 19 | { kind: TokenKind.String, value: "test", idx: 17 }, 20 | ]); 21 | }); 22 | 23 | it("deals with escaped delimiters", () => { 24 | expect(tokenize(keywords, `("te\\"s\\\\t", 'te\\'st', \`te\\\`st\`)`)).toEqual([ 25 | { kind: TokenKind.ParenLeft, idx: 0 }, 26 | { kind: TokenKind.String, value: `te"s\\t`, idx: 1 }, 27 | { kind: TokenKind.Comma, idx: 11 }, 28 | { kind: TokenKind.String, value: `te'st`, idx: 13 }, 29 | { kind: TokenKind.Comma, idx: 21 }, 30 | { kind: TokenKind.String, value: "te`st", idx: 23 }, 31 | ]); 32 | }); 33 | 34 | it("read vue file", () => { 35 | const src = ` 36 | 47 | 52 | `; 53 | expect(tokenize(keywords, src)).toEqual([ 54 | { kind: TokenKind.Unrecognized, idx: 41, value: `\n\n