This is page 20 of 20. Use http://codebase.md/lingodotdev/lingo.dev?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .changeset
│ ├── config.json
│ └── README.md
├── .claude
│ ├── agents
│ │ └── code-architect-reviewer.md
│ └── commands
│ ├── analyze-bucket-type.md
│ └── create-bucket-docs.md
├── .editorconfig
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── docker.yml
│ ├── lingodotdev.yml
│ ├── pr-check.yml
│ ├── pr-lint.yml
│ └── release.yml
├── .gitignore
├── .husky
│ └── commit-msg
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .vscode
│ ├── extensions.json
│ ├── launch.json
│ └── settings.json
├── action.yml
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── commitlint.config.js
├── composer.json
├── content
│ ├── banner.compiler.png
│ ├── banner.dark.png
│ └── banner.launch.png
├── CONTRIBUTING.md
├── DEBUGGING.md
├── demo
│ ├── adonisjs
│ │ ├── .editorconfig
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── ace.js
│ │ ├── adonisrc.ts
│ │ ├── app
│ │ │ ├── exceptions
│ │ │ │ └── handler.ts
│ │ │ └── middleware
│ │ │ └── container_bindings_middleware.ts
│ │ ├── bin
│ │ │ ├── console.ts
│ │ │ ├── server.ts
│ │ │ └── test.ts
│ │ ├── CHANGELOG.md
│ │ ├── config
│ │ │ ├── app.ts
│ │ │ ├── bodyparser.ts
│ │ │ ├── cors.ts
│ │ │ ├── hash.ts
│ │ │ ├── inertia.ts
│ │ │ ├── logger.ts
│ │ │ ├── session.ts
│ │ │ ├── shield.ts
│ │ │ ├── static.ts
│ │ │ └── vite.ts
│ │ ├── eslint.config.js
│ │ ├── inertia
│ │ │ ├── app
│ │ │ │ ├── app.tsx
│ │ │ │ └── ssr.tsx
│ │ │ ├── css
│ │ │ │ └── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── pages
│ │ │ │ ├── errors
│ │ │ │ │ ├── not_found.tsx
│ │ │ │ │ └── server_error.tsx
│ │ │ │ └── home.tsx
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── resources
│ │ │ └── views
│ │ │ └── inertia_layout.edge
│ │ ├── start
│ │ │ ├── env.ts
│ │ │ ├── kernel.ts
│ │ │ └── routes.ts
│ │ ├── tests
│ │ │ └── bootstrap.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── next-app
│ │ ├── .gitignore
│ │ ├── CHANGELOG.md
│ │ ├── eslint.config.mjs
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── public
│ │ │ ├── file.svg
│ │ │ ├── globe.svg
│ │ │ ├── next.svg
│ │ │ ├── vercel.svg
│ │ │ └── window.svg
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── client-component.tsx
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── lingo-dot-dev.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── test
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── hero-actions.tsx
│ │ │ │ ├── hero-subtitle.tsx
│ │ │ │ ├── hero-title.tsx
│ │ │ │ └── index.ts
│ │ │ └── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ └── tsconfig.json
│ ├── react-router-app
│ │ ├── .dockerignore
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── root.tsx
│ │ │ ├── routes
│ │ │ │ ├── home.tsx
│ │ │ │ └── test.tsx
│ │ │ ├── routes.ts
│ │ │ └── welcome
│ │ │ ├── lingo-dot-dev.tsx
│ │ │ ├── logo-dark.svg
│ │ │ ├── logo-light.svg
│ │ │ └── welcome.tsx
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ ├── public
│ │ │ └── favicon.ico
│ │ ├── react-router.config.ts
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── vite-project
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── README.md
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ └── test.tsx
│ │ ├── index.css
│ │ ├── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ ├── lingo-dot-dev.tsx
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── Dockerfile
├── i18n.json
├── i18n.lock
├── integrations
│ └── directus
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── docker-compose.yml
│ ├── Dockerfile
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── api.ts
│ │ ├── app.ts
│ │ └── index.spec.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── ISSUE_TEMPLATE.md
├── legacy
│ ├── cli
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ └── readme.md
│ └── sdk
│ ├── CHANGELOG.md
│ ├── index.d.ts
│ ├── index.js
│ ├── package.json
│ └── README.md
├── LICENSE.md
├── mcp.md
├── package.json
├── packages
│ ├── cli
│ │ ├── assets
│ │ │ ├── failure.mp3
│ │ │ └── success.mp3
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── demo
│ │ │ ├── android
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── csv
│ │ │ │ ├── example.csv
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── demo.spec.ts
│ │ │ ├── ejs
│ │ │ │ ├── en
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── es
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── flutter
│ │ │ │ ├── en
│ │ │ │ │ └── example.arb
│ │ │ │ ├── es
│ │ │ │ │ └── example.arb
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── html
│ │ │ │ ├── en
│ │ │ │ │ └── example.html
│ │ │ │ ├── es
│ │ │ │ │ └── example.html
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json
│ │ │ │ ├── en
│ │ │ │ │ └── example.json
│ │ │ │ ├── es
│ │ │ │ │ └── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json-dictionary
│ │ │ │ ├── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json5
│ │ │ │ ├── en
│ │ │ │ │ └── example.json5
│ │ │ │ ├── es
│ │ │ │ │ └── example.json5
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── jsonc
│ │ │ │ ├── en
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── es
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── i18n.json
│ │ │ │ ├── i18n.lock
│ │ │ │ └── ru
│ │ │ │ └── example.jsonc
│ │ │ ├── markdoc
│ │ │ │ ├── en
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── es
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── markdown
│ │ │ │ ├── en
│ │ │ │ │ └── example.md
│ │ │ │ ├── es
│ │ │ │ │ └── example.md
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── mdx
│ │ │ │ ├── en
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── es
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── php
│ │ │ │ ├── en
│ │ │ │ │ └── example.php
│ │ │ │ ├── es
│ │ │ │ │ └── example.php
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── po
│ │ │ │ ├── en
│ │ │ │ │ └── example.po
│ │ │ │ ├── es
│ │ │ │ │ └── example.po
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── properties
│ │ │ │ ├── en
│ │ │ │ │ └── example.properties
│ │ │ │ ├── es
│ │ │ │ │ └── example.properties
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── run_i18n.sh
│ │ │ ├── srt
│ │ │ │ ├── en
│ │ │ │ │ └── example.srt
│ │ │ │ ├── es
│ │ │ │ │ └── example.srt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── txt
│ │ │ │ ├── en
│ │ │ │ │ └── example.txt
│ │ │ │ ├── es
│ │ │ │ │ └── example.txt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── typescript
│ │ │ │ ├── en
│ │ │ │ │ └── example.ts
│ │ │ │ ├── es
│ │ │ │ │ └── example.ts
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vtt
│ │ │ │ ├── en
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── es
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vue-json
│ │ │ │ ├── example.vue
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-strings
│ │ │ │ ├── en
│ │ │ │ │ └── example.strings
│ │ │ │ ├── es
│ │ │ │ │ └── example.strings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-stringsdict
│ │ │ │ ├── en
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── es
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings-v2
│ │ │ │ ├── complex-example.xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xliff
│ │ │ │ ├── en
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ └── example-v2.xliff
│ │ │ │ ├── es
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ ├── example-v2.xliff
│ │ │ │ │ └── example.xliff
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xml
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── yaml
│ │ │ │ ├── en
│ │ │ │ │ └── example.yml
│ │ │ │ ├── es
│ │ │ │ │ └── example.yml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ └── yaml-root-key
│ │ │ ├── en
│ │ │ │ └── example.yml
│ │ │ ├── es
│ │ │ │ └── example.yml
│ │ │ ├── i18n.json
│ │ │ └── i18n.lock
│ │ ├── i18n.json
│ │ ├── i18n.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── cli
│ │ │ │ ├── cmd
│ │ │ │ │ ├── auth.ts
│ │ │ │ │ ├── ci
│ │ │ │ │ │ ├── flows
│ │ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ │ ├── in-branch.ts
│ │ │ │ │ │ │ └── pull-request.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── platforms
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── bitbucket.ts
│ │ │ │ │ │ ├── github.ts
│ │ │ │ │ │ ├── gitlab.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── cleanup.ts
│ │ │ │ │ ├── config
│ │ │ │ │ │ ├── get.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── set.ts
│ │ │ │ │ │ └── unset.ts
│ │ │ │ │ ├── i18n.ts
│ │ │ │ │ ├── init.ts
│ │ │ │ │ ├── lockfile.ts
│ │ │ │ │ ├── login.ts
│ │ │ │ │ ├── logout.ts
│ │ │ │ │ ├── may-the-fourth.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── purge.ts
│ │ │ │ │ ├── run
│ │ │ │ │ │ ├── _const.ts
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── execute.spec.ts
│ │ │ │ │ │ ├── execute.ts
│ │ │ │ │ │ ├── frozen.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── plan.ts
│ │ │ │ │ │ ├── setup.ts
│ │ │ │ │ │ └── watch.ts
│ │ │ │ │ ├── show
│ │ │ │ │ │ ├── _shared-key-command.ts
│ │ │ │ │ │ ├── config.ts
│ │ │ │ │ │ ├── files.ts
│ │ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── locale.ts
│ │ │ │ │ │ └── locked-keys.ts
│ │ │ │ │ └── status.ts
│ │ │ │ ├── constants.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loaders
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── _utils.ts
│ │ │ │ │ ├── android.spec.ts
│ │ │ │ │ ├── android.ts
│ │ │ │ │ ├── csv.spec.ts
│ │ │ │ │ ├── csv.ts
│ │ │ │ │ ├── dato
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── api.ts
│ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ ├── filter.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── ejs.spec.ts
│ │ │ │ │ ├── ejs.ts
│ │ │ │ │ ├── ensure-key-order.spec.ts
│ │ │ │ │ ├── ensure-key-order.ts
│ │ │ │ │ ├── flat.spec.ts
│ │ │ │ │ ├── flat.ts
│ │ │ │ │ ├── flutter.spec.ts
│ │ │ │ │ ├── flutter.ts
│ │ │ │ │ ├── formatters
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── biome.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── prettier.ts
│ │ │ │ │ ├── html.ts
│ │ │ │ │ ├── icu-safety.spec.ts
│ │ │ │ │ ├── ignored-keys-buckets.spec.ts
│ │ │ │ │ ├── ignored-keys.spec.ts
│ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── inject-locale.spec.ts
│ │ │ │ │ ├── inject-locale.ts
│ │ │ │ │ ├── json-dictionary.spec.ts
│ │ │ │ │ ├── json-dictionary.ts
│ │ │ │ │ ├── json-sorting.test.ts
│ │ │ │ │ ├── json-sorting.ts
│ │ │ │ │ ├── json.ts
│ │ │ │ │ ├── json5.spec.ts
│ │ │ │ │ ├── json5.ts
│ │ │ │ │ ├── jsonc.spec.ts
│ │ │ │ │ ├── jsonc.ts
│ │ │ │ │ ├── locked-keys.spec.ts
│ │ │ │ │ ├── locked-keys.ts
│ │ │ │ │ ├── locked-patterns.spec.ts
│ │ │ │ │ ├── locked-patterns.ts
│ │ │ │ │ ├── markdoc.spec.ts
│ │ │ │ │ ├── markdoc.ts
│ │ │ │ │ ├── markdown.ts
│ │ │ │ │ ├── mdx.spec.ts
│ │ │ │ │ ├── mdx.ts
│ │ │ │ │ ├── mdx2
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── code-placeholder.spec.ts
│ │ │ │ │ │ ├── code-placeholder.ts
│ │ │ │ │ │ ├── frontmatter-split.spec.ts
│ │ │ │ │ │ ├── frontmatter-split.ts
│ │ │ │ │ │ ├── localizable-document.spec.ts
│ │ │ │ │ │ ├── localizable-document.ts
│ │ │ │ │ │ ├── section-split.spec.ts
│ │ │ │ │ │ ├── section-split.ts
│ │ │ │ │ │ └── sections-split-2.ts
│ │ │ │ │ ├── passthrough.ts
│ │ │ │ │ ├── php.ts
│ │ │ │ │ ├── plutil-json-loader.ts
│ │ │ │ │ ├── po
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── properties.ts
│ │ │ │ │ ├── root-key.ts
│ │ │ │ │ ├── srt.ts
│ │ │ │ │ ├── sync.ts
│ │ │ │ │ ├── text-file.ts
│ │ │ │ │ ├── txt.ts
│ │ │ │ │ ├── typescript
│ │ │ │ │ │ ├── cjs-interop.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── unlocalizable.spec.ts
│ │ │ │ │ ├── unlocalizable.ts
│ │ │ │ │ ├── variable
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── vtt.ts
│ │ │ │ │ ├── vue-json.ts
│ │ │ │ │ ├── xcode-strings
│ │ │ │ │ │ ├── escape.ts
│ │ │ │ │ │ ├── parser.ts
│ │ │ │ │ │ ├── tokenizer.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── xcode-strings.spec.ts
│ │ │ │ │ ├── xcode-strings.ts
│ │ │ │ │ ├── xcode-stringsdict.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.ts
│ │ │ │ │ ├── xcode-xcstrings-lock-compatibility.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-v2-loader.ts
│ │ │ │ │ ├── xcode-xcstrings.spec.ts
│ │ │ │ │ ├── xcode-xcstrings.ts
│ │ │ │ │ ├── xliff.spec.ts
│ │ │ │ │ ├── xliff.ts
│ │ │ │ │ ├── xml.ts
│ │ │ │ │ └── yaml.ts
│ │ │ │ ├── localizer
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── explicit.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingodotdev.ts
│ │ │ │ ├── processor
│ │ │ │ │ ├── _base.ts
│ │ │ │ │ ├── basic.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingo.ts
│ │ │ │ └── utils
│ │ │ │ ├── auth.ts
│ │ │ │ ├── buckets.spec.ts
│ │ │ │ ├── buckets.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── cloudflare-status.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── delta.spec.ts
│ │ │ │ ├── delta.ts
│ │ │ │ ├── ensure-patterns.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── exec.spec.ts
│ │ │ │ ├── exec.ts
│ │ │ │ ├── exit-gracefully.spec.ts
│ │ │ │ ├── exit-gracefully.ts
│ │ │ │ ├── exp-backoff.ts
│ │ │ │ ├── find-locale-paths.spec.ts
│ │ │ │ ├── find-locale-paths.ts
│ │ │ │ ├── fs.ts
│ │ │ │ ├── init-ci-cd.ts
│ │ │ │ ├── key-matching.spec.ts
│ │ │ │ ├── key-matching.ts
│ │ │ │ ├── lockfile.ts
│ │ │ │ ├── md5.ts
│ │ │ │ ├── observability.ts
│ │ │ │ ├── plutil-formatter.spec.ts
│ │ │ │ ├── plutil-formatter.ts
│ │ │ │ ├── settings.ts
│ │ │ │ ├── ui.ts
│ │ │ │ └── update-gitignore.ts
│ │ │ ├── compiler
│ │ │ │ └── index.ts
│ │ │ ├── locale-codes
│ │ │ │ └── index.ts
│ │ │ ├── react
│ │ │ │ ├── client.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── react-router.ts
│ │ │ │ └── rsc.ts
│ │ │ ├── sdk
│ │ │ │ └── index.ts
│ │ │ └── spec
│ │ │ └── index.ts
│ │ ├── tests
│ │ │ └── mock-storage.ts
│ │ ├── troubleshooting.md
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ ├── tsup.config.ts
│ │ ├── types
│ │ │ ├── vtt.d.ts
│ │ │ └── xliff.d.ts
│ │ ├── vitest.config.ts
│ │ └── WATCH_MODE.md
│ ├── compiler
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── _base.ts
│ │ │ ├── _const.ts
│ │ │ ├── _loader-utils.spec.ts
│ │ │ ├── _loader-utils.ts
│ │ │ ├── _utils.spec.ts
│ │ │ ├── _utils.ts
│ │ │ ├── client-dictionary-loader.ts
│ │ │ ├── i18n-directive.spec.ts
│ │ │ ├── i18n-directive.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── jsx-attribute-flag.spec.ts
│ │ │ ├── jsx-attribute-flag.ts
│ │ │ ├── jsx-attribute-scope-inject.spec.ts
│ │ │ ├── jsx-attribute-scope-inject.ts
│ │ │ ├── jsx-attribute-scopes-export.spec.ts
│ │ │ ├── jsx-attribute-scopes-export.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-fragment.spec.ts
│ │ │ ├── jsx-fragment.ts
│ │ │ ├── jsx-html-lang.spec.ts
│ │ │ ├── jsx-html-lang.ts
│ │ │ ├── jsx-provider.spec.ts
│ │ │ ├── jsx-provider.ts
│ │ │ ├── jsx-remove-attributes.spec.ts
│ │ │ ├── jsx-remove-attributes.ts
│ │ │ ├── jsx-root-flag.spec.ts
│ │ │ ├── jsx-root-flag.ts
│ │ │ ├── jsx-scope-flag.spec.ts
│ │ │ ├── jsx-scope-flag.ts
│ │ │ ├── jsx-scope-inject.spec.ts
│ │ │ ├── jsx-scope-inject.ts
│ │ │ ├── jsx-scopes-export.spec.ts
│ │ │ ├── jsx-scopes-export.ts
│ │ │ ├── lib
│ │ │ │ └── lcp
│ │ │ │ ├── api
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prompt.spec.ts
│ │ │ │ │ ├── prompt.ts
│ │ │ │ │ ├── provider-details.spec.ts
│ │ │ │ │ ├── provider-details.ts
│ │ │ │ │ ├── shots.ts
│ │ │ │ │ ├── xml2obj.spec.ts
│ │ │ │ │ └── xml2obj.ts
│ │ │ │ ├── api.spec.ts
│ │ │ │ ├── cache.spec.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── server.spec.ts
│ │ │ │ └── server.ts
│ │ │ ├── lingo-turbopack-loader.ts
│ │ │ ├── react-router-dictionary-loader.ts
│ │ │ ├── rsc-dictionary-loader.ts
│ │ │ └── utils
│ │ │ ├── ast-key.spec.ts
│ │ │ ├── ast-key.ts
│ │ │ ├── create-locale-import-map.spec.ts
│ │ │ ├── create-locale-import-map.ts
│ │ │ ├── env.spec.ts
│ │ │ ├── env.ts
│ │ │ ├── hash.spec.ts
│ │ │ ├── hash.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── invokations.spec.ts
│ │ │ ├── invokations.ts
│ │ │ ├── jsx-attribute-scope.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-content-whitespace.spec.ts
│ │ │ ├── jsx-content.spec.ts
│ │ │ ├── jsx-content.ts
│ │ │ ├── jsx-element.spec.ts
│ │ │ ├── jsx-element.ts
│ │ │ ├── jsx-expressions.test.ts
│ │ │ ├── jsx-expressions.ts
│ │ │ ├── jsx-functions.spec.ts
│ │ │ ├── jsx-functions.ts
│ │ │ ├── jsx-scope.spec.ts
│ │ │ ├── jsx-scope.ts
│ │ │ ├── jsx-variables.spec.ts
│ │ │ ├── jsx-variables.ts
│ │ │ ├── llm-api-key.ts
│ │ │ ├── llm-api-keys.spec.ts
│ │ │ ├── locales.spec.ts
│ │ │ ├── locales.ts
│ │ │ ├── module-params.spec.ts
│ │ │ ├── module-params.ts
│ │ │ ├── observability.spec.ts
│ │ │ ├── observability.ts
│ │ │ ├── rc.spec.ts
│ │ │ └── rc.ts
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ └── vitest.config.ts
│ ├── locales
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── names
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── integration.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── parser.spec.ts
│ │ │ ├── parser.ts
│ │ │ ├── types.ts
│ │ │ ├── validation.spec.ts
│ │ │ └── validation.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── react
│ │ ├── build.config.ts
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── context.spec.tsx
│ │ │ │ ├── context.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── locale-switcher.spec.tsx
│ │ │ │ ├── locale-switcher.tsx
│ │ │ │ ├── locale.spec.ts
│ │ │ │ ├── locale.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── core
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── const.ts
│ │ │ │ ├── get-dictionary.spec.ts
│ │ │ │ ├── get-dictionary.ts
│ │ │ │ └── index.ts
│ │ │ ├── react-router
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── rsc
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ └── test
│ │ │ └── setup.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── sdk
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── abort-controller.specs.ts
│ │ │ ├── index.spec.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ └── tsup.config.ts
│ └── spec
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── config.spec.ts
│ │ ├── config.ts
│ │ ├── formats.ts
│ │ ├── index.spec.ts
│ │ ├── index.ts
│ │ ├── json-schema.ts
│ │ ├── locales.spec.ts
│ │ └── locales.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── readme
│ ├── ar.md
│ ├── bn.md
│ ├── de.md
│ ├── en.md
│ ├── es.md
│ ├── fa.md
│ ├── fr.md
│ ├── he.md
│ ├── hi.md
│ ├── it.md
│ ├── ja.md
│ ├── ko.md
│ ├── pl.md
│ ├── pt-BR.md
│ ├── ru.md
│ ├── tr.md
│ ├── uk-UA.md
│ └── zh-Hans.md
├── readme.md
├── scripts
│ ├── docs
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── generate-cli-docs.ts
│ │ │ ├── generate-config-docs.ts
│ │ │ ├── json-schema
│ │ │ │ ├── markdown-renderer.test.ts
│ │ │ │ ├── markdown-renderer.ts
│ │ │ │ ├── parser.test.ts
│ │ │ │ ├── parser.ts
│ │ │ │ └── types.ts
│ │ │ ├── utils.test.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ └── packagist-publish.php
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/index.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from "vitest";
2 | import dedent from "dedent";
3 | import _ from "lodash";
4 | import fs from "fs/promises";
5 | import createBucketLoader from "./index";
6 | import createTextFileLoader from "./text-file";
7 |
8 | describe("bucket loaders", () => {
9 | beforeEach(() => {
10 | vi.clearAllMocks();
11 | vi.resetModules();
12 | });
13 |
14 | describe("android bucket loader", () => {
15 | it("should load android data", async () => {
16 | setupFileMocks();
17 |
18 | const input = `
19 | <resources>
20 | <string name="button.title">Submit</string>
21 | </resources>
22 | `.trim();
23 | const expectedOutput = { "button.title": "Submit" };
24 |
25 | mockFileOperations(input);
26 |
27 | const androidLoader = createBucketLoader(
28 | "android",
29 | "values-[locale]/strings.xml",
30 | {
31 | defaultLocale: "en",
32 | },
33 | );
34 | androidLoader.setDefaultLocale("en");
35 | const data = await androidLoader.pull("en");
36 |
37 | expect(data).toEqual(expectedOutput);
38 | });
39 |
40 | it("should skip non-translatable strings", async () => {
41 | setupFileMocks();
42 |
43 | const input = `
44 | <resources>
45 | <string name="app_name" translatable="false">MyApp</string>
46 | <string name="button.title">Submit</string>
47 | <string name="version" translatable="false">1.0.0</string>
48 | </resources>
49 | `.trim();
50 | const expectedOutput = { "button.title": "Submit" };
51 |
52 | mockFileOperations(input);
53 |
54 | const androidLoader = createBucketLoader(
55 | "android",
56 | "values-[locale]/strings.xml",
57 | {
58 | defaultLocale: "en",
59 | },
60 | );
61 | androidLoader.setDefaultLocale("en");
62 | const data = await androidLoader.pull("en");
63 |
64 | expect(data).toEqual(expectedOutput);
65 | });
66 |
67 | it("should save android data", async () => {
68 | setupFileMocks();
69 |
70 | // Use proper Android Studio format: XML declaration + 4-space indentation
71 | const input = `<?xml version="1.0" encoding="utf-8"?>
72 | <resources>
73 | <string name="button.title">Submit</string>
74 | </resources>`;
75 | const payload = { "button.title": "Enviar" };
76 | // Output preserves XML declaration and uses 4-space indentation (Android standard)
77 | const expectedOutput = `<?xml version="1.0" encoding="utf-8"?>
78 | <resources>
79 | <string name="button.title">Enviar</string>
80 | </resources>`;
81 |
82 | mockFileOperations(input);
83 |
84 | const androidLoader = createBucketLoader(
85 | "android",
86 | "values-[locale]/strings.xml",
87 | {
88 | defaultLocale: "en",
89 | },
90 | );
91 | androidLoader.setDefaultLocale("en");
92 | await androidLoader.pull("en");
93 |
94 | await androidLoader.push("es", payload);
95 |
96 | expect(fs.writeFile).toHaveBeenCalledWith(
97 | "values-es/strings.xml",
98 | expectedOutput,
99 | {
100 | encoding: "utf-8",
101 | flag: "w",
102 | },
103 | );
104 | });
105 |
106 | it("should respect locked keys (pull)", async () => {
107 | setupFileMocks();
108 |
109 | const input = `<?xml version="1.0" encoding="utf-8"?>
110 | <resources>
111 | <string name="locked_key">Original</string>
112 | <string name="unlocked_key">Hello</string>
113 | </resources>`;
114 |
115 | mockFileOperations(input);
116 |
117 | const androidLoader = createBucketLoader(
118 | "android",
119 | "values-[locale]/strings.xml",
120 | { defaultLocale: "en" },
121 | ["locked_key"],
122 | );
123 | androidLoader.setDefaultLocale("en");
124 | const data = await androidLoader.pull("en");
125 |
126 | expect(data).toEqual({ unlocked_key: "Hello" });
127 | });
128 | });
129 |
130 | describe("csv bucket loader", () => {
131 | it("should load csv data ('KEY' as key, from automatic fallback", async () => {
132 | setupFileMocks();
133 |
134 | const input = ` ,KEY,en\n,button.title,Submit`;
135 | const expectedOutput = { "button.title": "Submit" };
136 |
137 | mockFileOperations(input);
138 |
139 | const csvLoader = createBucketLoader("csv", "i18n.csv", {
140 | defaultLocale: "en",
141 | });
142 | csvLoader.setDefaultLocale("en");
143 | const data = await csvLoader.pull("en");
144 |
145 | expect(data).toEqual(expectedOutput);
146 | });
147 |
148 | it("should load csv data ('id' as key, first cell)", async () => {
149 | setupFileMocks();
150 |
151 | const input = `id,en\nbutton.title,Submit`;
152 | const expectedOutput = { "button.title": "Submit" };
153 |
154 | mockFileOperations(input);
155 |
156 | const csvLoader = createBucketLoader("csv", "i18n.csv", {
157 | defaultLocale: "en",
158 | });
159 | csvLoader.setDefaultLocale("en");
160 | const data = await csvLoader.pull("en");
161 |
162 | expect(data).toEqual(expectedOutput);
163 | });
164 |
165 | it("should save csv data", async () => {
166 | setupFileMocks();
167 |
168 | const input = `id,en,es\nbutton.title,Submit,`;
169 | const payload = { "button.title": "Enviar" };
170 | const expectedOutput = `id,en,es\nbutton.title,Submit,Enviar`;
171 |
172 | mockFileOperations(input);
173 |
174 | const csvLoader = createBucketLoader("csv", "i18n.csv", {
175 | defaultLocale: "en",
176 | });
177 | csvLoader.setDefaultLocale("en");
178 | await csvLoader.pull("en");
179 |
180 | await csvLoader.push("es", payload);
181 |
182 | expect(fs.writeFile).toHaveBeenCalledWith("i18n.csv", expectedOutput, {
183 | encoding: "utf-8",
184 | flag: "w",
185 | });
186 | });
187 |
188 | it("should respect locked keys (pull)", async () => {
189 | setupFileMocks();
190 |
191 | const input = `id,en\nlocked_key,Original\nunlocked_key,Hello`;
192 |
193 | mockFileOperations(input);
194 |
195 | const csvLoader = createBucketLoader(
196 | "csv",
197 | "i18n.csv",
198 | {
199 | defaultLocale: "en",
200 | },
201 | ["locked_key"],
202 | );
203 | csvLoader.setDefaultLocale("en");
204 | const data = await csvLoader.pull("en");
205 |
206 | expect(data).toEqual({ unlocked_key: "Hello" });
207 | });
208 | });
209 |
210 | describe("flutter bucket loader", () => {
211 | it("should load flutter data", async () => {
212 | setupFileMocks();
213 |
214 | const input = `{
215 | "@@locale": "en",
216 | "greeting": "Hello, {name}!",
217 | "@greeting": {
218 | "description": "A greeting with a name placeholder",
219 | "placeholders": {
220 | "name": {
221 | "type": "String",
222 | "example": "John"
223 | }
224 | }
225 | }
226 | }`;
227 | const expectedOutput = { greeting: "Hello, {name}!" };
228 |
229 | mockFileOperations(input);
230 |
231 | const flutterLoader = createBucketLoader(
232 | "flutter",
233 | "lib/l10n/app_[locale].arb",
234 | {
235 | defaultLocale: "en",
236 | },
237 | );
238 | flutterLoader.setDefaultLocale("en");
239 | const data = await flutterLoader.pull("en");
240 |
241 | expect(data).toEqual(expectedOutput);
242 | });
243 |
244 | it("should save flutter data", async () => {
245 | setupFileMocks();
246 |
247 | const input = `{
248 | "@@locale": "en",
249 | "greeting": "Hello, {name}!",
250 | "@greeting": {
251 | "description": "A greeting with a name placeholder",
252 | "placeholders": {
253 | "name": {
254 | "type": "String",
255 | "example": "John"
256 | }
257 | }
258 | }
259 | }`;
260 | const payload = { greeting: "¡Hola, {name}!" };
261 | const expectedOutput = JSON.stringify(
262 | {
263 | "@@locale": "es",
264 | greeting: "¡Hola, {name}!",
265 | "@greeting": {
266 | description: "A greeting with a name placeholder",
267 | placeholders: {
268 | name: {
269 | type: "String",
270 | example: "John",
271 | },
272 | },
273 | },
274 | },
275 | null,
276 | 2,
277 | );
278 |
279 | mockFileOperations(input);
280 |
281 | const flutterLoader = createBucketLoader(
282 | "flutter",
283 | "lib/l10n/app_[locale].arb",
284 | {
285 | defaultLocale: "en",
286 | },
287 | );
288 | flutterLoader.setDefaultLocale("en");
289 | await flutterLoader.pull("en");
290 |
291 | await flutterLoader.push("es", payload);
292 |
293 | expect(fs.writeFile).toHaveBeenCalledWith(
294 | "lib/l10n/app_es.arb",
295 | expectedOutput,
296 | {
297 | encoding: "utf-8",
298 | flag: "w",
299 | },
300 | );
301 | });
302 |
303 | it("should respect locked keys (pull)", async () => {
304 | setupFileMocks();
305 |
306 | const input = `{
307 | "@@locale": "en",
308 | "locked_key": "Original",
309 | "unlocked_key": "Hello"
310 | }`;
311 |
312 | mockFileOperations(input);
313 |
314 | const flutterLoader = createBucketLoader(
315 | "flutter",
316 | "lib/l10n/app_[locale].arb",
317 | { defaultLocale: "en" },
318 | ["locked_key"],
319 | );
320 | flutterLoader.setDefaultLocale("en");
321 | const data = await flutterLoader.pull("en");
322 |
323 | expect(data).toEqual({ unlocked_key: "Hello" });
324 | });
325 | });
326 |
327 | describe("html bucket loader", () => {
328 | it("should load html data", async () => {
329 | setupFileMocks();
330 |
331 | const input = `
332 | <html>
333 | <head>
334 | <title>My Page</title>
335 | <meta name="description" content="Page description" />
336 | </head>
337 | <body>
338 | some simple text without an html tag
339 | <h1>Hello, world!</h1>
340 | <p>
341 | This is a paragraph with a
342 | <a href="https://example.com">link</a>
343 | and
344 | <b>
345 | bold and <i>italic text</i>
346 | </b>
347 | .
348 | </p>
349 | </body>
350 | </html>
351 | `.trim();
352 | const expectedOutput = {
353 | "head/0/0": "My Page",
354 | "head/1#content": "Page description",
355 | "body/0": "some simple text without an html tag",
356 | "body/1/0": "Hello, world!",
357 | "body/2/0": "This is a paragraph with a",
358 | "body/2/1/0": "link",
359 | "body/2/2": "and",
360 | "body/2/3/0": "bold and",
361 | "body/2/3/1/0": "italic text",
362 | "body/2/4": ".",
363 | };
364 |
365 | mockFileOperations(input);
366 |
367 | const htmlLoader = createBucketLoader("html", "i18n/[locale].html", {
368 | defaultLocale: "en",
369 | });
370 | htmlLoader.setDefaultLocale("en");
371 | const data = await htmlLoader.pull("en");
372 |
373 | expect(data).toEqual(expectedOutput);
374 | });
375 |
376 | it("should save html data", async () => {
377 | const input = dedent`
378 | <html>
379 | <head>
380 | <title>My Page</title>
381 | <meta name="description" content="Page description" />
382 | </head>
383 | <body>
384 | some simple text without an html tag
385 | <h1>Hello, world!</h1>
386 | <p>
387 | This is a paragraph with a <a href="https://example.com">link</a> and <b>bold and <i>italic text</i></b>
388 | </p>
389 | </body>
390 | </html>
391 | `.trim();
392 | const payload = {
393 | "head/0/0": "Mi Página",
394 | "head/1#content": "Descripción de la página",
395 | "body/0": "texto simple sin etiqueta html",
396 | "body/1/0": "¡Hola, mundo!",
397 | "body/2/0": "Este es un párrafo con un ",
398 | "body/2/1/0": "enlace",
399 | "body/2/2": " y ",
400 | "body/2/3/0": "texto en negrita y ",
401 | "body/2/3/1/0": "texto en cursiva",
402 | };
403 | const expectedOutput = `<html lang="es">
404 | <head>
405 | <title>Mi Página</title>
406 | <meta name="description" content="Descripción de la página" />
407 | </head>
408 | <body>
409 | texto simple sin etiqueta html
410 | <h1>¡Hola, mundo!</h1>
411 | <p>
412 | Este es un párrafo con un
413 | <a href="https://example.com">enlace</a>
414 | y
415 | <b>
416 | texto en negrita y
417 | <i>texto en cursiva</i>
418 | </b>
419 | </p>
420 | </body>
421 | </html>
422 | `.trim();
423 |
424 | mockFileOperations(input);
425 |
426 | const htmlLoader = createBucketLoader("html", "i18n/[locale].html", {
427 | defaultLocale: "en",
428 | });
429 | htmlLoader.setDefaultLocale("en");
430 | await htmlLoader.pull("en");
431 |
432 | await htmlLoader.push("es", payload);
433 |
434 | expect(fs.writeFile).toHaveBeenCalledWith(
435 | "i18n/es.html",
436 | expectedOutput,
437 | { encoding: "utf-8", flag: "w" },
438 | );
439 | });
440 |
441 | it("should respect locked keys (pull)", async () => {
442 | setupFileMocks();
443 |
444 | const input = `
445 | <html>
446 | <head>
447 | <title>Locked Title</title>
448 | </head>
449 | <body>
450 | <h1>Hello</h1>
451 | </body>
452 | </html>`;
453 |
454 | mockFileOperations(input);
455 |
456 | const htmlLoader = createBucketLoader(
457 | "html",
458 | "i18n/[locale].html",
459 | { defaultLocale: "en" },
460 | ["head/0/0"],
461 | );
462 | htmlLoader.setDefaultLocale("en");
463 | const data = await htmlLoader.pull("en");
464 |
465 | // Title is locked, only body text should remain
466 | expect(Object.values(data)).toContain("Hello");
467 | expect(Object.keys(data)).not.toContain("head/0/0");
468 | });
469 | });
470 |
471 | describe("jsonc bucket loader", () => {
472 | it("should load jsonc data with comments", async () => {
473 | setupFileMocks();
474 |
475 | const input = `{
476 | // This is a comment for title
477 | "title": "Submit",
478 | /* This is a block comment for description */
479 | "description": "Button description",
480 | "nested": {
481 | // Nested comment
482 | "key": "value"
483 | }
484 | }`;
485 | const expectedOutput = {
486 | title: "Submit",
487 | description: "Button description",
488 | "nested/key": "value",
489 | };
490 |
491 | mockFileOperations(input);
492 |
493 | const jsoncLoader = createBucketLoader("jsonc", "i18n/[locale].jsonc", {
494 | defaultLocale: "en",
495 | });
496 | jsoncLoader.setDefaultLocale("en");
497 | const data = await jsoncLoader.pull("en");
498 |
499 | expect(data).toEqual(expectedOutput);
500 | });
501 |
502 | it("should save jsonc data", async () => {
503 | setupFileMocks();
504 |
505 | const input = `{
506 | // This is a comment
507 | "title": "Submit"
508 | }`;
509 | const payload = { title: "Enviar" };
510 | const expectedOutput = JSON.stringify(payload, null, 2);
511 |
512 | mockFileOperations(input);
513 |
514 | const jsoncLoader = createBucketLoader("jsonc", "i18n/[locale].jsonc", {
515 | defaultLocale: "en",
516 | });
517 | jsoncLoader.setDefaultLocale("en");
518 | await jsoncLoader.pull("en");
519 |
520 | await jsoncLoader.push("es", payload);
521 |
522 | expect(fs.writeFile).toHaveBeenCalledWith(
523 | "i18n/es.jsonc",
524 | expectedOutput,
525 | { encoding: "utf-8", flag: "w" },
526 | );
527 | });
528 |
529 | it("should extract hints from jsonc comments", async () => {
530 | setupFileMocks();
531 |
532 | const input = `{
533 | "key1": "value1", // This is a comment for key1
534 | "key2": "value2" /* This is a comment for key2 */,
535 | // This is a comment for key3
536 | "key3": "value3",
537 | /* This is a block comment for key4 */
538 | "key4": "value4",
539 | /*
540 | This is a comment for key5
541 | */
542 | "key5": "value5",
543 | // This is a comment for key6
544 | "key6": {
545 | // This is a comment for key7
546 | "key7": "value7"
547 | }
548 | }`;
549 |
550 | mockFileOperations(input);
551 |
552 | const jsoncLoader = createBucketLoader("jsonc", "i18n/[locale].jsonc", {
553 | defaultLocale: "en",
554 | });
555 | jsoncLoader.setDefaultLocale("en");
556 | await jsoncLoader.pull("en");
557 |
558 | const hints = await jsoncLoader.pullHints();
559 |
560 | expect(hints).toEqual({
561 | key1: ["This is a comment for key1"],
562 | key2: ["This is a comment for key2"],
563 | key3: ["This is a comment for key3"],
564 | key4: ["This is a block comment for key4"],
565 | key5: ["This is a comment for key5"],
566 | "key6/key7": [
567 | "This is a comment for key6",
568 | "This is a comment for key7",
569 | ],
570 | });
571 | });
572 |
573 | it("should handle jsonc with trailing commas", async () => {
574 | setupFileMocks();
575 |
576 | const input = `{
577 | "hello": "Hello",
578 | "world": "World",
579 | "array": [
580 | "item1",
581 | "item2",
582 | ],
583 | }`;
584 | const expectedOutput = {
585 | hello: "Hello",
586 | world: "World",
587 | "array/0": "item1",
588 | "array/1": "item2",
589 | };
590 |
591 | mockFileOperations(input);
592 |
593 | const jsoncLoader = createBucketLoader("jsonc", "i18n/[locale].jsonc", {
594 | defaultLocale: "en",
595 | });
596 | jsoncLoader.setDefaultLocale("en");
597 | const data = await jsoncLoader.pull("en");
598 |
599 | expect(data).toEqual(expectedOutput);
600 | });
601 |
602 | it("should handle invalid jsonc gracefully", async () => {
603 | setupFileMocks();
604 |
605 | const input = `{
606 | "hello": "Hello"
607 | "world": "World" // missing comma
608 | invalid: syntax
609 | }`;
610 |
611 | mockFileOperations(input);
612 |
613 | const jsoncLoader = createBucketLoader("jsonc", "i18n/[locale].jsonc", {
614 | defaultLocale: "en",
615 | });
616 | jsoncLoader.setDefaultLocale("en");
617 |
618 | await expect(jsoncLoader.pull("en")).rejects.toThrow(
619 | "Failed to parse JSONC",
620 | );
621 | });
622 |
623 | it("should respect locked keys (pull)", async () => {
624 | setupFileMocks();
625 |
626 | const input = `{
627 | "locked_key": "Original",
628 | "unlocked_key": "Hello"
629 | }`;
630 |
631 | mockFileOperations(input);
632 |
633 | const jsoncLoader = createBucketLoader(
634 | "jsonc",
635 | "i18n/[locale].jsonc",
636 | {
637 | defaultLocale: "en",
638 | },
639 | ["locked_key"],
640 | );
641 | jsoncLoader.setDefaultLocale("en");
642 | const data = await jsoncLoader.pull("en");
643 |
644 | expect(data).toEqual({ unlocked_key: "Hello" });
645 | });
646 | });
647 |
648 | describe("json bucket loader", () => {
649 | it("should load json data", async () => {
650 | setupFileMocks();
651 |
652 | const input = { "button.title": "Submit" };
653 | mockFileOperations(JSON.stringify(input));
654 |
655 | const jsonLoader = createBucketLoader("json", "i18n/[locale].json", {
656 | defaultLocale: "en",
657 | });
658 | jsonLoader.setDefaultLocale("en");
659 | const data = await jsonLoader.pull("en");
660 |
661 | expect(data).toEqual(input);
662 | });
663 |
664 | it("should save json data", async () => {
665 | setupFileMocks();
666 |
667 | const input = { "button.title": "Submit" };
668 | const payload = { "button.title": "Enviar" };
669 | const expectedOutput = JSON.stringify(payload, null, 2);
670 |
671 | mockFileOperations(JSON.stringify(input));
672 |
673 | const jsonLoader = createBucketLoader("json", "i18n/[locale].json", {
674 | defaultLocale: "en",
675 | });
676 | jsonLoader.setDefaultLocale("en");
677 | await jsonLoader.pull("en");
678 |
679 | await jsonLoader.push("es", payload);
680 |
681 | expect(fs.writeFile).toHaveBeenCalledWith(
682 | "i18n/es.json",
683 | expectedOutput,
684 | { encoding: "utf-8", flag: "w" },
685 | );
686 | });
687 |
688 | it("should save json data with numeric keys", async () => {
689 | setupFileMocks();
690 |
691 | const input = { messages: { "1": "foo", "2": "bar", "3": "bar" } };
692 | const payload = {
693 | "messages/1": "foo",
694 | "messages/2": "bar",
695 | "messages/3": "bar",
696 | };
697 | const expectedOutput = JSON.stringify(input, null, 2);
698 |
699 | mockFileOperations(JSON.stringify(input));
700 |
701 | const jsonLoader = createBucketLoader("json", "i18n/[locale].json", {
702 | defaultLocale: "en",
703 | });
704 | jsonLoader.setDefaultLocale("en");
705 | await jsonLoader.pull("en");
706 |
707 | await jsonLoader.push("es", payload);
708 |
709 | expect(fs.writeFile).toHaveBeenCalledWith(
710 | "i18n/es.json",
711 | expectedOutput,
712 | { encoding: "utf-8", flag: "w" },
713 | );
714 | });
715 |
716 | it("should save json data with array", async () => {
717 | setupFileMocks();
718 |
719 | const input = { messages: ["foo", "bar"] };
720 | const payload = { "messages/0": "foo", "messages/1": "bar" };
721 | const expectedOutput = dedent`
722 | {
723 | "messages": ["foo", "bar"]
724 | }
725 | `.trim();
726 |
727 | mockFileOperations(JSON.stringify(input));
728 |
729 | const jsonLoader = createBucketLoader("json", "i18n/[locale].json", {
730 | defaultLocale: "en",
731 | });
732 | jsonLoader.setDefaultLocale("en");
733 | await jsonLoader.pull("en");
734 |
735 | await jsonLoader.push("es", payload);
736 |
737 | expect(fs.writeFile).toHaveBeenCalledWith(
738 | "i18n/es.json",
739 | expectedOutput,
740 | { encoding: "utf-8", flag: "w" },
741 | );
742 | });
743 |
744 | it("should return keys in correct order, should not use key values from original input for missing keys", async () => {
745 | setupFileMocks();
746 |
747 | const input = {
748 | "button.title": "Submit",
749 | "button.subtitle": "Submit subtitle",
750 | "button.description": "Submit description",
751 | };
752 | const payload = {
753 | "button.subtitle": "Subtítulo de envío",
754 | "button.title": "Enviar",
755 | };
756 | const expectedOutput = JSON.stringify(
757 | {
758 | "button.title": "Enviar",
759 | "button.subtitle": "Subtítulo de envío",
760 | },
761 | null,
762 | 2,
763 | );
764 |
765 | mockFileOperations(JSON.stringify(input));
766 |
767 | const jsonLoader = createBucketLoader("json", "i18n/[locale].json", {
768 | defaultLocale: "en",
769 | });
770 | jsonLoader.setDefaultLocale("en");
771 | await jsonLoader.pull("en");
772 |
773 | await jsonLoader.push("es", payload);
774 |
775 | expect(fs.writeFile).toHaveBeenCalledWith(
776 | "i18n/es.json",
777 | expectedOutput,
778 | { encoding: "utf-8", flag: "w" },
779 | );
780 | });
781 |
782 | it("should load and save json data for paths with multiple locales", async () => {
783 | setupFileMocks();
784 |
785 | const input = { "button.title": "Submit" };
786 | const payload = { "button.title": "Enviar" };
787 | const expectedOutput = JSON.stringify(payload, null, 2);
788 |
789 | mockFileOperations(JSON.stringify(input));
790 |
791 | const jsonLoader = createBucketLoader(
792 | "json",
793 | "i18n/[locale]/[locale].json",
794 | {
795 | defaultLocale: "en",
796 | },
797 | );
798 | jsonLoader.setDefaultLocale("en");
799 | const data = await jsonLoader.pull("en");
800 |
801 | await jsonLoader.push("es", payload);
802 |
803 | expect(data).toEqual(input);
804 | expect(fs.access).toHaveBeenCalledWith("i18n/en/en.json");
805 | expect(fs.writeFile).toHaveBeenCalledWith(
806 | "i18n/es/es.json",
807 | expectedOutput,
808 | { encoding: "utf-8", flag: "w" },
809 | );
810 | });
811 |
812 | it("should remove injected locales from json data", async () => {
813 | setupFileMocks();
814 |
815 | const input = {
816 | "button.title": "Submit",
817 | settings: { locale: "en" },
818 | "not-a-locale": "bar",
819 | };
820 | mockFileOperations(JSON.stringify(input));
821 |
822 | const jsonLoader = createBucketLoader("json", "i18n/[locale].json", {
823 | defaultLocale: "en",
824 | injectLocale: ["settings/locale", "not-a-locale"],
825 | });
826 | jsonLoader.setDefaultLocale("en");
827 | const data = await jsonLoader.pull("en");
828 |
829 | expect(data).toEqual({ "button.title": "Submit", "not-a-locale": "bar" });
830 | });
831 |
832 | it("should inject locales into json data", async () => {
833 | setupFileMocks();
834 |
835 | const input = {
836 | "button.title": "Submit",
837 | "not-a-locale": "bar",
838 | settings: { locale: "en" },
839 | };
840 | const payload = { "button.title": "Enviar", "not-a-locale": "bar" };
841 | const expectedOutput = JSON.stringify(
842 | { ...payload, settings: { locale: "es" } },
843 | null,
844 | 2,
845 | );
846 |
847 | mockFileOperations(JSON.stringify(input));
848 |
849 | const jsonLoader = createBucketLoader("json", "i18n/[locale].json", {
850 | defaultLocale: "en",
851 | injectLocale: ["settings/locale", "not-a-locale"],
852 | });
853 | jsonLoader.setDefaultLocale("en");
854 | await jsonLoader.pull("en");
855 |
856 | await jsonLoader.push("es", payload);
857 |
858 | expect(fs.writeFile).toHaveBeenCalledWith(
859 | "i18n/es.json",
860 | expectedOutput,
861 | { encoding: "utf-8", flag: "w" },
862 | );
863 | });
864 | });
865 |
866 | describe("locked keys functionality", () => {
867 | it("should respect locked keys for JSON format", async () => {
868 | setupFileMocks();
869 |
870 | const input = {
871 | "button.title": "Submit",
872 | "button.description": "Submit description",
873 | "locked.key": "Should not change",
874 | nested: {
875 | locked: "This is locked",
876 | unlocked: "This can change",
877 | },
878 | };
879 | const payload = {
880 | "button.title": "Enviar",
881 | "button.description": "Descripción de envío",
882 | "locked.key": "This should not be applied",
883 | "nested/locked": "This should not be applied either",
884 | "nested/unlocked": "Este puede cambiar",
885 | };
886 |
887 | mockFileOperations(JSON.stringify(input));
888 |
889 | const jsonLoader = createBucketLoader(
890 | "json",
891 | "i18n/[locale].json",
892 | { defaultLocale: "en" },
893 | ["locked.key", "nested/locked"],
894 | );
895 |
896 | jsonLoader.setDefaultLocale("en");
897 | await jsonLoader.pull("en");
898 |
899 | await jsonLoader.push("es", payload);
900 |
901 | expect(fs.writeFile).toHaveBeenCalled();
902 | const writeFileCall = (fs.writeFile as any).mock.calls[0];
903 | const writtenContent = JSON.parse(writeFileCall[1]);
904 |
905 | // Check that locked keys retain their original values
906 | expect(writtenContent["locked.key"]).toBe("Should not change");
907 | expect(writtenContent.nested.locked).toBe("This is locked");
908 |
909 | // Check that unlocked keys are updated
910 | expect(writtenContent["button.title"]).toBe("Enviar");
911 | expect(writtenContent["button.description"]).toBe("Descripción de envío");
912 | expect(writtenContent.nested.unlocked).toBe("Este puede cambiar");
913 | });
914 |
915 | it("should handle deeply nested locked keys", async () => {
916 | setupFileMocks();
917 |
918 | const input = {
919 | level1: {
920 | level2: {
921 | level3: {
922 | locked: "This is locked deep",
923 | unlocked: "This can change",
924 | },
925 | },
926 | },
927 | };
928 | const payload = {
929 | "level1/level2/level3/locked": "This should not be applied",
930 | "level1/level2/level3/unlocked": "This should change",
931 | };
932 |
933 | mockFileOperations(JSON.stringify(input));
934 |
935 | const jsonLoader = createBucketLoader(
936 | "json",
937 | "i18n/[locale].json",
938 | { defaultLocale: "en" },
939 | ["level1/level2/level3/locked"],
940 | );
941 |
942 | jsonLoader.setDefaultLocale("en");
943 | await jsonLoader.pull("en");
944 |
945 | await jsonLoader.push("es", payload);
946 |
947 | expect(fs.writeFile).toHaveBeenCalled();
948 | const writeFileCall = (fs.writeFile as any).mock.calls[0];
949 | const writtenContent = JSON.parse(writeFileCall[1]);
950 |
951 | // Check that deeply nested locked key retains its original value
952 | expect(writtenContent.level1.level2.level3.locked).toBe(
953 | "This is locked deep",
954 | );
955 |
956 | // Check that unlocked key is updated
957 | expect(writtenContent.level1.level2.level3.unlocked).toBe(
958 | "This should change",
959 | );
960 | });
961 |
962 | it("should lock keys that are arrays", async () => {
963 | setupFileMocks();
964 |
965 | const input = {
966 | messages: ["first", "second", "third"],
967 | unlocked: ["can", "be", "changed"],
968 | };
969 | const payload = {
970 | "messages/0": "should not change",
971 | "messages/1": "should not change either",
972 | "messages/2": "should definitely not change",
973 | "unlocked/0": "should",
974 | "unlocked/1": "definitely",
975 | "unlocked/2": "change",
976 | };
977 |
978 | mockFileOperations(JSON.stringify(input));
979 |
980 | const jsonLoader = createBucketLoader(
981 | "json",
982 | "i18n/[locale].json",
983 | { defaultLocale: "en" },
984 | ["messages/0", "messages/1", "messages/2"],
985 | );
986 |
987 | jsonLoader.setDefaultLocale("en");
988 | await jsonLoader.pull("en");
989 |
990 | await jsonLoader.push("es", payload);
991 |
992 | expect(fs.writeFile).toHaveBeenCalled();
993 | const writeFileCall = (fs.writeFile as any).mock.calls[0];
994 | const writtenContent = JSON.parse(writeFileCall[1]);
995 |
996 | // Check that locked array elements retain their original values
997 | expect(writtenContent.messages[0]).toBe("first");
998 | expect(writtenContent.messages[1]).toBe("second");
999 | expect(writtenContent.messages[2]).toBe("third");
1000 |
1001 | // Check that unlocked array elements are updated
1002 | expect(writtenContent.unlocked[0]).toBe("should");
1003 | expect(writtenContent.unlocked[1]).toBe("definitely");
1004 | expect(writtenContent.unlocked[2]).toBe("change");
1005 | });
1006 | });
1007 |
1008 | describe("ignored keys functionality", () => {
1009 | it("should omit ignored keys for JSON format", async () => {
1010 | setupFileMocks();
1011 |
1012 | const input = {
1013 | "button.title": "Submit",
1014 | "button.description": "Submit description",
1015 | "ignored.key": "Should be ignored",
1016 | nested: {
1017 | ignored: "This is ignored",
1018 | kept: "This is kept",
1019 | },
1020 | };
1021 | const payload = {
1022 | "button.title": "Enviar",
1023 | "button.description": "Descripción de envío",
1024 | "nested/kept": "Esto se mantiene",
1025 | };
1026 |
1027 | mockFileOperations(JSON.stringify(input));
1028 |
1029 | const jsonLoader = createBucketLoader(
1030 | "json",
1031 | "i18n/[locale].json",
1032 | { defaultLocale: "en" },
1033 | undefined, // lockedKeys
1034 | undefined, // lockedPatterns
1035 | ["ignored.key", "nested/ignored"], // ignoredKeys
1036 | );
1037 |
1038 | jsonLoader.setDefaultLocale("en");
1039 | const pulledData = await jsonLoader.pull("en");
1040 |
1041 | // Verify ignored keys are not in pulled data
1042 | expect(pulledData).toEqual({
1043 | "button.title": "Submit",
1044 | "button.description": "Submit description",
1045 | "nested/kept": "This is kept",
1046 | });
1047 |
1048 | await jsonLoader.push("es", payload);
1049 |
1050 | expect(fs.writeFile).toHaveBeenCalled();
1051 | const writeFileCall = (fs.writeFile as any).mock.calls[0];
1052 | const writtenContent = JSON.parse(writeFileCall[1]);
1053 |
1054 | // Check that ignored keys are completely removed from output
1055 | expect(writtenContent["ignored.key"]).toBeUndefined();
1056 | expect(writtenContent.nested?.ignored).toBeUndefined();
1057 |
1058 | // Check that non-ignored keys are updated
1059 | expect(writtenContent["button.title"]).toBe("Enviar");
1060 | expect(writtenContent["button.description"]).toBe("Descripción de envío");
1061 | expect(writtenContent.nested.kept).toBe("Esto se mantiene");
1062 | });
1063 |
1064 | it("should handle wildcard patterns in ignored keys", async () => {
1065 | setupFileMocks();
1066 |
1067 | const input = {
1068 | "button.title": "Submit",
1069 | wildcard_a: "Value A",
1070 | wildcard_b: "Value B",
1071 | other: "Other value",
1072 | };
1073 | const payload = {
1074 | "button.title": "Enviar",
1075 | other: "Otro valor",
1076 | };
1077 |
1078 | mockFileOperations(JSON.stringify(input));
1079 |
1080 | const jsonLoader = createBucketLoader(
1081 | "json",
1082 | "i18n/[locale].json",
1083 | { defaultLocale: "en" },
1084 | undefined, // lockedKeys
1085 | undefined, // lockedPatterns
1086 | ["wildcard_*"], // ignoredKeys with wildcard
1087 | );
1088 |
1089 | jsonLoader.setDefaultLocale("en");
1090 | const pulledData = await jsonLoader.pull("en");
1091 |
1092 | // Verify wildcard ignored keys are not in pulled data
1093 | expect(pulledData).toEqual({
1094 | "button.title": "Submit",
1095 | other: "Other value",
1096 | });
1097 |
1098 | await jsonLoader.push("es", payload);
1099 |
1100 | expect(fs.writeFile).toHaveBeenCalled();
1101 | const writeFileCall = (fs.writeFile as any).mock.calls[0];
1102 | const writtenContent = JSON.parse(writeFileCall[1]);
1103 |
1104 | // Check that wildcard ignored keys are completely removed from output
1105 | expect(writtenContent["wildcard_a"]).toBeUndefined();
1106 | expect(writtenContent["wildcard_b"]).toBeUndefined();
1107 |
1108 | // Check that non-ignored keys are updated
1109 | expect(writtenContent["button.title"]).toBe("Enviar");
1110 | expect(writtenContent.other).toBe("Otro valor");
1111 | });
1112 | });
1113 |
1114 | describe("mdx bucket loader", () => {
1115 | it("should skip locked keys", async () => {
1116 | setupFileMocks();
1117 |
1118 | const input = dedent`
1119 | ---
1120 | title: Test Mdx
1121 | category: test
1122 | ---
1123 |
1124 | # Heading 1
1125 | `;
1126 | const expectedPayload = {
1127 | "meta/title": "Test Mdx",
1128 | "content/0": "\n# Heading 1",
1129 | };
1130 |
1131 | mockFileOperations(input);
1132 |
1133 | const mdxLoader = createBucketLoader(
1134 | "mdx",
1135 | "i18n/[locale].mdx",
1136 | { defaultLocale: "en" },
1137 | ["meta/category"],
1138 | );
1139 |
1140 | mdxLoader.setDefaultLocale("en");
1141 | const data = await mdxLoader.pull("en");
1142 |
1143 | expect(data).toEqual(expectedPayload);
1144 | });
1145 | });
1146 |
1147 | describe("markdown bucket loader", () => {
1148 | it("should load markdown data", async () => {
1149 | setupFileMocks();
1150 |
1151 | const input = `---
1152 | title: Test Markdown
1153 | date: 2023-05-25
1154 | ---
1155 |
1156 | # Heading 1
1157 |
1158 | This is a paragraph.
1159 |
1160 | ## Heading 2
1161 |
1162 | Another paragraph with **bold** and *italic* text.`;
1163 | const expectedOutput = {
1164 | "fm-attr-title": "Test Markdown",
1165 | "md-section-0": "# Heading 1",
1166 | "md-section-1": "This is a paragraph.",
1167 | "md-section-2": "## Heading 2",
1168 | "md-section-3": "Another paragraph with **bold** and _italic_ text.",
1169 | };
1170 |
1171 | mockFileOperations(input);
1172 |
1173 | const markdownLoader = createBucketLoader(
1174 | "markdown",
1175 | "i18n/[locale].md",
1176 | {
1177 | defaultLocale: "en",
1178 | },
1179 | );
1180 | markdownLoader.setDefaultLocale("en");
1181 | const data = await markdownLoader.pull("en");
1182 |
1183 | expect(data).toEqual(expectedOutput);
1184 | });
1185 |
1186 | it("should save markdown data", async () => {
1187 | setupFileMocks();
1188 |
1189 | const input = `---
1190 | title: Test Markdown
1191 | date: 2023-05-25
1192 | ---
1193 |
1194 | # Heading 1
1195 |
1196 | This is a paragraph.
1197 |
1198 | ## Heading 2
1199 |
1200 | Another paragraph with **bold** and *italic* text.`;
1201 | const payload = {
1202 | "fm-attr-title": "Prueba Markdown",
1203 | "fm-attr-date": "2023-05-25",
1204 | "md-section-0": "# Encabezado 1",
1205 | "md-section-1": "Esto es un párrafo.",
1206 | "md-section-2": "## Encabezado 2",
1207 | "md-section-3": "Otro párrafo con texto en **negrita** y en _cursiva_.",
1208 | };
1209 | const expectedOutput = `---
1210 | title: Prueba Markdown
1211 | date: 2023-05-25
1212 | ---
1213 |
1214 | # Encabezado 1
1215 |
1216 | Esto es un párrafo.
1217 |
1218 | ## Encabezado 2
1219 |
1220 | Otro párrafo con texto en **negrita** y en _cursiva_.
1221 | `.trim();
1222 |
1223 | mockFileOperations(input);
1224 |
1225 | const markdownLoader = createBucketLoader(
1226 | "markdown",
1227 | "i18n/[locale].md",
1228 | {
1229 | defaultLocale: "en",
1230 | },
1231 | );
1232 | markdownLoader.setDefaultLocale("en");
1233 | await markdownLoader.pull("en");
1234 |
1235 | await markdownLoader.push("es", payload);
1236 |
1237 | expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.md", expectedOutput, {
1238 | encoding: "utf-8",
1239 | flag: "w",
1240 | });
1241 | });
1242 |
1243 | it("should respect locked keys (pull)", async () => {
1244 | setupFileMocks();
1245 |
1246 | const input = dedent`---
1247 | title: Locked Title
1248 | ---
1249 |
1250 | Content here.`;
1251 |
1252 | mockFileOperations(input);
1253 |
1254 | const markdownLoader = createBucketLoader(
1255 | "markdown",
1256 | "i18n/[locale].md",
1257 | { defaultLocale: "en" },
1258 | ["fm-attr-title"],
1259 | );
1260 | markdownLoader.setDefaultLocale("en");
1261 | const data = await markdownLoader.pull("en");
1262 |
1263 | // frontmatter title removed
1264 | expect(Object.keys(data)).not.toContain("fm-attr-title");
1265 | });
1266 | });
1267 |
1268 | describe("properties bucket loader", () => {
1269 | it("should load properties data", async () => {
1270 | setupFileMocks();
1271 |
1272 | const input = `
1273 | # General messages
1274 | welcome.message=Welcome to our application!
1275 | error.message=An error has occurred. Please try again later.
1276 |
1277 | # User-related messages
1278 | user.login=Please enter your username and password.
1279 | user.username=Username
1280 | user.password=Password
1281 | `.trim();
1282 | const expectedOutput = {
1283 | "welcome.message": "Welcome to our application!",
1284 | "error.message": "An error has occurred. Please try again later.",
1285 | "user.login": "Please enter your username and password.",
1286 | "user.username": "Username",
1287 | "user.password": "Password",
1288 | };
1289 |
1290 | mockFileOperations(input);
1291 |
1292 | const propertiesLoader = createBucketLoader(
1293 | "properties",
1294 | "i18n/[locale].properties",
1295 | {
1296 | defaultLocale: "en",
1297 | },
1298 | );
1299 | propertiesLoader.setDefaultLocale("en");
1300 | const data = await propertiesLoader.pull("en");
1301 |
1302 | expect(data).toEqual(expectedOutput);
1303 | });
1304 |
1305 | it("should save properties data", async () => {
1306 | setupFileMocks();
1307 |
1308 | const input = `
1309 | # General messages
1310 | welcome.message=Welcome to our application!
1311 | error.message=An error has occurred. Please try again later.
1312 |
1313 | # User-related messages
1314 | user.login=Please enter your username and password.
1315 | user.username=Username
1316 | user.password=Password
1317 | `.trim();
1318 | const payload = {
1319 | "welcome.message": "Bienvenido a nuestra aplicación!",
1320 | "error.message":
1321 | "Se ha producido un error. Por favor, inténtelo de nuevo más tarde.",
1322 | "user.login":
1323 | "Por favor, introduzca su nombre de usuario y contraseña.",
1324 | "user.username": "Nombre de usuario",
1325 | "user.password": "Contraseña",
1326 | };
1327 | const expectedOutput = `
1328 | welcome.message=Bienvenido a nuestra aplicación!
1329 | error.message=Se ha producido un error. Por favor, inténtelo de nuevo más tarde.
1330 | user.login=Por favor, introduzca su nombre de usuario y contraseña.
1331 | user.username=Nombre de usuario
1332 | user.password=Contraseña
1333 | `.trim();
1334 |
1335 | mockFileOperations(input);
1336 |
1337 | const propertiesLoader = createBucketLoader(
1338 | "properties",
1339 | "i18n/[locale].properties",
1340 | {
1341 | defaultLocale: "en",
1342 | },
1343 | );
1344 | propertiesLoader.setDefaultLocale("en");
1345 | await propertiesLoader.pull("en");
1346 |
1347 | await propertiesLoader.push("es", payload);
1348 |
1349 | expect(fs.writeFile).toHaveBeenCalledWith(
1350 | "i18n/es.properties",
1351 | expectedOutput,
1352 | { encoding: "utf-8", flag: "w" },
1353 | );
1354 | });
1355 |
1356 | it("should respect locked keys (pull)", async () => {
1357 | setupFileMocks();
1358 |
1359 | const input = `locked=Original\nunlocked=Hello`;
1360 |
1361 | mockFileOperations(input);
1362 |
1363 | const propertiesLoader = createBucketLoader(
1364 | "properties",
1365 | "i18n/[locale].properties",
1366 | { defaultLocale: "en" },
1367 | ["locked"],
1368 | );
1369 | propertiesLoader.setDefaultLocale("en");
1370 | const data = await propertiesLoader.pull("en");
1371 |
1372 | expect(data).toEqual({ unlocked: "Hello" });
1373 | });
1374 | });
1375 |
1376 | describe("xcode-strings bucket loader", () => {
1377 | it("should load xcode-strings", async () => {
1378 | setupFileMocks();
1379 |
1380 | const input = `
1381 | "key1" = "value1";
1382 | "key2" = "value2";
1383 | "key3" = "Line 1\\nLine 2\\"quoted\\"";
1384 | `.trim();
1385 | const expectedOutput = {
1386 | key1: "value1",
1387 | key2: "value2",
1388 | key3: 'Line 1\nLine 2"quoted"',
1389 | };
1390 |
1391 | mockFileOperations(input);
1392 |
1393 | const xcodeStringsLoader = createBucketLoader(
1394 | "xcode-strings",
1395 | "i18n/[locale].strings",
1396 | {
1397 | defaultLocale: "en",
1398 | },
1399 | );
1400 | xcodeStringsLoader.setDefaultLocale("en");
1401 | const data = await xcodeStringsLoader.pull("en");
1402 |
1403 | expect(data).toEqual(expectedOutput);
1404 | });
1405 |
1406 | it("should save xcode-strings", async () => {
1407 | setupFileMocks();
1408 |
1409 | const input = `
1410 | "hello" = "Hello!";
1411 | `.trim();
1412 | const payload = { hello: "¡Hola!" };
1413 | const expectedOutput = `"hello" = "¡Hola!";`;
1414 |
1415 | mockFileOperations(input);
1416 |
1417 | const xcodeStringsLoader = createBucketLoader(
1418 | "xcode-strings",
1419 | "i18n/[locale].strings",
1420 | {
1421 | defaultLocale: "en",
1422 | },
1423 | );
1424 | xcodeStringsLoader.setDefaultLocale("en");
1425 | await xcodeStringsLoader.pull("en");
1426 |
1427 | await xcodeStringsLoader.push("es", payload);
1428 |
1429 | expect(fs.writeFile).toHaveBeenCalledWith(
1430 | "i18n/es.strings",
1431 | expectedOutput,
1432 | { encoding: "utf-8", flag: "w" },
1433 | );
1434 | });
1435 |
1436 | it("should respect locked keys (pull)", async () => {
1437 | setupFileMocks();
1438 |
1439 | const input = `
1440 | "locked" = "Original";
1441 | "hello" = "Hello!";
1442 | `.trim();
1443 |
1444 | mockFileOperations(input);
1445 |
1446 | const xcodeStringsLoader = createBucketLoader(
1447 | "xcode-strings",
1448 | "i18n/[locale].strings",
1449 | { defaultLocale: "en" },
1450 | ["locked"],
1451 | );
1452 | xcodeStringsLoader.setDefaultLocale("en");
1453 | const data = await xcodeStringsLoader.pull("en");
1454 |
1455 | expect(data).toEqual({ hello: "Hello!" });
1456 | });
1457 | });
1458 |
1459 | describe("xcode-stringsdict bucket loader", () => {
1460 | it("should load xcode-stringsdict", async () => {
1461 | setupFileMocks();
1462 |
1463 | const input = `
1464 | <?xml version="1.0" encoding="UTF-8"?>
1465 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1466 | <plist version="1.0">
1467 | <dict>
1468 | <key>greeting</key>
1469 | <string>Hello!</string>
1470 | <key>items_count</key>
1471 | <dict>
1472 | <key>NSStringLocalizedFormatKey</key>
1473 | <string>%#@items@</string>
1474 | <key>items</key>
1475 | <dict>
1476 | <key>NSStringFormatSpecTypeKey</key>
1477 | <string>NSStringPluralRuleType</string>
1478 | <key>NSStringFormatValueTypeKey</key>
1479 | <string>d</string>
1480 | <key>one</key>
1481 | <string>%d item</string>
1482 | <key>other</key>
1483 | <string>%d items</string>
1484 | </dict>
1485 | </dict>
1486 | </dict>
1487 | </plist>
1488 | `.trim();
1489 | const expectedOutput = {
1490 | greeting: "Hello!",
1491 | "items_count/NSStringLocalizedFormatKey": "%#@items@",
1492 | "items_count/items/NSStringFormatSpecTypeKey": "NSStringPluralRuleType",
1493 | "items_count/items/NSStringFormatValueTypeKey": "d",
1494 | "items_count/items/one": "%d item",
1495 | "items_count/items/other": "%d items",
1496 | };
1497 |
1498 | mockFileOperations(input);
1499 |
1500 | const xcodeStringsdictLoader = createBucketLoader(
1501 | "xcode-stringsdict",
1502 | "i18n/[locale].stringsdict",
1503 | {
1504 | defaultLocale: "en",
1505 | },
1506 | );
1507 | xcodeStringsdictLoader.setDefaultLocale("en");
1508 | const data = await xcodeStringsdictLoader.pull("en");
1509 |
1510 | expect(data).toEqual(expectedOutput);
1511 | });
1512 |
1513 | it("should save xcode-stringsdict", async () => {
1514 | setupFileMocks();
1515 |
1516 | const input = `
1517 | <?xml version="1.0" encoding="UTF-8"?>
1518 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1519 | <plist version="1.0">
1520 | <dict>
1521 | <key>greeting</key>
1522 | <string>Hello!</string>
1523 | </dict>
1524 | </plist>
1525 | `.trim();
1526 | const payload = { greeting: "¡Hola!" };
1527 | const expectedOutput = `<?xml version="1.0" encoding="UTF-8"?>
1528 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1529 | <plist version="1.0">
1530 | <dict>
1531 | <key>greeting</key>
1532 | <string>¡Hola!</string>
1533 | </dict>
1534 | </plist>
1535 | `.trim();
1536 |
1537 | mockFileOperations(input);
1538 |
1539 | const xcodeStringsdictLoader = createBucketLoader(
1540 | "xcode-stringsdict",
1541 | "[locale].lproj/Localizable.stringsdict",
1542 | {
1543 | defaultLocale: "en",
1544 | },
1545 | );
1546 | xcodeStringsdictLoader.setDefaultLocale("en");
1547 | await xcodeStringsdictLoader.pull("en");
1548 |
1549 | await xcodeStringsdictLoader.push("es", payload);
1550 |
1551 | expect(fs.writeFile).toHaveBeenCalledWith(
1552 | "es.lproj/Localizable.stringsdict",
1553 | expectedOutput,
1554 | {
1555 | encoding: "utf-8",
1556 | flag: "w",
1557 | },
1558 | );
1559 | });
1560 |
1561 | it("should respect locked keys (pull)", async () => {
1562 | setupFileMocks();
1563 |
1564 | const input = `
1565 | <?xml version="1.0" encoding="UTF-8"?>
1566 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1567 | <plist version="1.0">
1568 | <dict>
1569 | <key>locked</key>
1570 | <string>Original</string>
1571 | <key>hello</key>
1572 | <string>Hello!</string>
1573 | </dict>
1574 | </plist>
1575 | `.trim();
1576 |
1577 | mockFileOperations(input);
1578 |
1579 | const xcodeStringsdictLoader = createBucketLoader(
1580 | "xcode-stringsdict",
1581 | "i18n/[locale].stringsdict",
1582 | { defaultLocale: "en" },
1583 | ["locked"],
1584 | );
1585 | xcodeStringsdictLoader.setDefaultLocale("en");
1586 | const data = await xcodeStringsdictLoader.pull("en");
1587 |
1588 | expect(data).toEqual({ hello: "Hello!" });
1589 | });
1590 | });
1591 |
1592 | describe("xcode-xcstrings bucket loader", () => {
1593 | it("should load xcode-xcstrings", async () => {
1594 | setupFileMocks();
1595 |
1596 | const input = JSON.stringify({
1597 | sourceLanguage: "en",
1598 | strings: {
1599 | greeting: {
1600 | extractionState: "manual",
1601 | localizations: {
1602 | en: {
1603 | stringUnit: {
1604 | state: "translated",
1605 | value: "Hello!",
1606 | },
1607 | },
1608 | },
1609 | },
1610 | message: {
1611 | extractionState: "manual",
1612 | localizations: {
1613 | en: {
1614 | stringUnit: {
1615 | state: "translated",
1616 | value: "Welcome to our app",
1617 | },
1618 | },
1619 | },
1620 | },
1621 | items_count: {
1622 | extractionState: "manual",
1623 | localizations: {
1624 | en: {
1625 | variations: {
1626 | plural: {
1627 | zero: {
1628 | stringUnit: {
1629 | state: "translated",
1630 | value: "No items",
1631 | },
1632 | },
1633 | one: {
1634 | stringUnit: {
1635 | state: "translated",
1636 | value: "%d item",
1637 | },
1638 | },
1639 | other: {
1640 | stringUnit: {
1641 | state: "translated",
1642 | value: "%d items",
1643 | },
1644 | },
1645 | },
1646 | },
1647 | },
1648 | },
1649 | },
1650 | },
1651 | });
1652 |
1653 | const expectedOutput = {
1654 | greeting: "Hello!",
1655 | message: "Welcome to our app",
1656 | "items_count/zero": "No items",
1657 | "items_count/one": "{variable:0} item",
1658 | "items_count/other": "{variable:0} items",
1659 | };
1660 |
1661 | mockFileOperations(input);
1662 |
1663 | const xcodeXcstringsLoader = createBucketLoader(
1664 | "xcode-xcstrings",
1665 | "i18n/[locale].xcstrings",
1666 | {
1667 | defaultLocale: "en",
1668 | },
1669 | );
1670 | xcodeXcstringsLoader.setDefaultLocale("en");
1671 | const data = await xcodeXcstringsLoader.pull("en");
1672 |
1673 | expect(data).toEqual(expectedOutput);
1674 | });
1675 |
1676 | it("should load keys without default locale entries and use the key as value", async () => {
1677 | setupFileMocks();
1678 |
1679 | const input = JSON.stringify({
1680 | sourceLanguage: "en",
1681 | strings: {
1682 | greeting: {
1683 | extractionState: "manual",
1684 | localizations: {
1685 | en: {
1686 | stringUnit: {
1687 | state: "translated",
1688 | value: "Hello!",
1689 | },
1690 | },
1691 | },
1692 | },
1693 | " and ": {
1694 | extractionState: "manual",
1695 | localizations: {
1696 | en: {
1697 | stringUnit: {
1698 | state: "translated",
1699 | value: " and ",
1700 | },
1701 | },
1702 | },
1703 | },
1704 | key_with_no_default: {
1705 | extractionState: "manual",
1706 | localizations: {
1707 | fr: {
1708 | stringUnit: {
1709 | state: "translated",
1710 | value: "Valeur traduite",
1711 | },
1712 | },
1713 | },
1714 | },
1715 | },
1716 | });
1717 |
1718 | const expectedOutput = {
1719 | greeting: "Hello!",
1720 | "%20and%20": " and ",
1721 | key_with_no_default: "key_with_no_default",
1722 | };
1723 |
1724 | mockFileOperations(input);
1725 |
1726 | const xcodeXcstringsLoader = createBucketLoader(
1727 | "xcode-xcstrings",
1728 | "i18n/[locale].xcstrings",
1729 | {
1730 | defaultLocale: "en",
1731 | },
1732 | );
1733 | xcodeXcstringsLoader.setDefaultLocale("en");
1734 | const data = await xcodeXcstringsLoader.pull("en");
1735 |
1736 | expect(data).toEqual(expectedOutput);
1737 | });
1738 |
1739 | it("should save xcode-xcstrings", async () => {
1740 | setupFileMocks();
1741 |
1742 | const originalInput = {
1743 | sourceLanguage: "en",
1744 | strings: {
1745 | greeting: {
1746 | extractionState: "manual",
1747 | localizations: {
1748 | en: {
1749 | stringUnit: {
1750 | state: "translated",
1751 | value: "Hello!",
1752 | },
1753 | },
1754 | },
1755 | },
1756 | },
1757 | };
1758 |
1759 | mockFileOperations(JSON.stringify(originalInput));
1760 |
1761 | const payload = {
1762 | greeting: "Bonjour!",
1763 | message: "Bienvenue dans notre application",
1764 | "items_count/zero": "Aucun élément",
1765 | "items_count/one": "%d élément",
1766 | "items_count/other": "%d éléments",
1767 | };
1768 |
1769 | const xcodeXcstringsLoader = createBucketLoader(
1770 | "xcode-xcstrings",
1771 | "i18n/[locale].xcstrings",
1772 | {
1773 | defaultLocale: "en",
1774 | },
1775 | );
1776 | xcodeXcstringsLoader.setDefaultLocale("en");
1777 | await xcodeXcstringsLoader.pull("en");
1778 | await xcodeXcstringsLoader.push("fr", payload);
1779 |
1780 | expect(fs.writeFile).toHaveBeenCalled();
1781 | const writeFileCall = (fs.writeFile as any).mock.calls[0];
1782 | const writtenContent = JSON.parse(writeFileCall[1]);
1783 |
1784 | expect(writtenContent.strings.greeting.localizations.fr).toBeDefined();
1785 | expect(
1786 | writtenContent.strings.greeting.localizations.fr.stringUnit.value,
1787 | ).toBe("Bonjour!");
1788 |
1789 | if (writtenContent.strings.message) {
1790 | expect(
1791 | writtenContent.strings.message.localizations.fr.stringUnit.value,
1792 | ).toBe("Bienvenue dans notre application");
1793 | }
1794 |
1795 | if (writtenContent.strings.items_count) {
1796 | expect(
1797 | writtenContent.strings.items_count.localizations.fr.variations.plural
1798 | .zero.stringUnit.value,
1799 | ).toBe("Aucun élément");
1800 | expect(
1801 | writtenContent.strings.items_count.localizations.fr.variations.plural
1802 | .one.stringUnit.value,
1803 | ).toBe("%d élément");
1804 | expect(
1805 | writtenContent.strings.items_count.localizations.fr.variations.plural
1806 | .other.stringUnit.value,
1807 | ).toBe("%d éléments");
1808 | }
1809 | });
1810 |
1811 | it("should maintain ASCII ordering with empty strings, whitespace, and numbers", async () => {
1812 | setupFileMocks();
1813 |
1814 | const input = `{
1815 | "sourceLanguage": "en",
1816 | "strings": {
1817 | "": {
1818 | "extractionState": "manual",
1819 | "localizations": {
1820 | "en": {
1821 | "stringUnit": {
1822 | "state": "translated",
1823 | "value": "Empty key"
1824 | }
1825 | }
1826 | }
1827 | },
1828 | " ": {
1829 | "extractionState": "manual",
1830 | "localizations": {
1831 | "en": {
1832 | "stringUnit": {
1833 | "state": "translated",
1834 | "value": "Space key"
1835 | }
1836 | }
1837 | }
1838 | },
1839 | "25": {
1840 | "extractionState": "manual",
1841 | "localizations": {
1842 | "en": {
1843 | "stringUnit": {
1844 | "state": "translated",
1845 | "value": "Numeric key"
1846 | }
1847 | }
1848 | }
1849 | },
1850 | "apple": {
1851 | "extractionState": "manual",
1852 | "localizations": {
1853 | "en": {
1854 | "stringUnit": {
1855 | "state": "translated",
1856 | "value": "Apple"
1857 | }
1858 | }
1859 | }
1860 | }
1861 | }`;
1862 |
1863 | mockFileOperations(input);
1864 |
1865 | const xcodeXcstringsLoader = createBucketLoader(
1866 | "xcode-xcstrings",
1867 | "i18n/[locale].xcstrings",
1868 | {
1869 | defaultLocale: "en",
1870 | },
1871 | );
1872 | xcodeXcstringsLoader.setDefaultLocale("en");
1873 | const data = await xcodeXcstringsLoader.pull("en");
1874 |
1875 | Object.keys(data).forEach((key) => {
1876 | if (key === "") {
1877 | expect(data[key]).toBe("Empty key");
1878 | } else if (key.includes("%20") || key === " ") {
1879 | expect(data[key]).toBe("Space key");
1880 | } else if (key === "25") {
1881 | expect(data[key]).toBe("Numeric key");
1882 | } else if (key === "apple") {
1883 | expect(data[key]).toBe("Apple");
1884 | }
1885 | });
1886 |
1887 | const payload: Record<string, string> = {};
1888 |
1889 | Object.keys(data).forEach((key) => {
1890 | if (key === "") {
1891 | payload[key] = "Vide";
1892 | } else if (key.includes("%20") || key === " ") {
1893 | payload[key] = "Espace";
1894 | } else if (key === "25") {
1895 | payload[key] = "Numérique";
1896 | } else if (key === "apple") {
1897 | payload[key] = "Pomme";
1898 | }
1899 | });
1900 |
1901 | await xcodeXcstringsLoader.pull("en");
1902 | await xcodeXcstringsLoader.push("fr", payload);
1903 |
1904 | expect(fs.writeFile).toHaveBeenCalled();
1905 | const writeFileCall = (fs.writeFile as any).mock.calls[0];
1906 | const writtenContent = JSON.parse(writeFileCall[1]);
1907 |
1908 | if (writtenContent.strings[""]) {
1909 | expect(
1910 | writtenContent.strings[""].localizations.fr.stringUnit.value,
1911 | ).toBe("Vide");
1912 | }
1913 |
1914 | const hasSpaceKey = Object.keys(writtenContent.strings).some(
1915 | (key) => key === " " || key === "%20" || key.includes("%20"),
1916 | );
1917 | if (hasSpaceKey) {
1918 | const spaceKey = Object.keys(writtenContent.strings).find(
1919 | (key) => key === " " || key === "%20" || key.includes("%20"),
1920 | );
1921 | if (spaceKey) {
1922 | expect(
1923 | writtenContent.strings[spaceKey].localizations.fr.stringUnit.value,
1924 | ).toBe("Espace");
1925 | }
1926 | }
1927 |
1928 | if (writtenContent.strings["25"]) {
1929 | expect(
1930 | writtenContent.strings["25"].localizations.fr.stringUnit.value,
1931 | ).toBe("Numérique");
1932 | }
1933 |
1934 | if (writtenContent.strings["apple"]) {
1935 | expect(
1936 | writtenContent.strings["apple"].localizations.fr.stringUnit.value,
1937 | ).toBe("Pomme");
1938 | }
1939 |
1940 | const stringKeys = Object.keys(writtenContent.strings);
1941 |
1942 | expect(stringKeys.includes("25")).toBe(true);
1943 | expect(stringKeys.includes("")).toBe(true);
1944 | expect(stringKeys.includes(" ") || stringKeys.includes("%20")).toBe(true);
1945 | expect(stringKeys.includes("apple")).toBe(true);
1946 |
1947 | expect(stringKeys.indexOf("25")).toBeLessThan(stringKeys.indexOf(""));
1948 |
1949 | const spaceIdx =
1950 | stringKeys.indexOf(" ") === -1
1951 | ? stringKeys.indexOf("%20")
1952 | : stringKeys.indexOf(" ");
1953 | if (spaceIdx !== -1) {
1954 | expect(stringKeys.indexOf("")).toBeLessThan(spaceIdx);
1955 | }
1956 |
1957 | if (spaceIdx !== -1) {
1958 | expect(spaceIdx).toBeLessThan(stringKeys.indexOf("apple"));
1959 | }
1960 | });
1961 |
1962 | it("should respect shouldTranslate: false flag", async () => {
1963 | setupFileMocks();
1964 |
1965 | const input = `{
1966 | "sourceLanguage": "en",
1967 | "strings": {
1968 | "do_not_translate": {
1969 | "shouldTranslate": false,
1970 | "localizations": {
1971 | "en": {
1972 | "stringUnit": {
1973 | "state": "translated",
1974 | "value": "This should not be translated"
1975 | }
1976 | }
1977 | }
1978 | },
1979 | "normal_key": {
1980 | "extractionState": "manual",
1981 | "localizations": {
1982 | "en": {
1983 | "stringUnit": {
1984 | "state": "translated",
1985 | "value": "This should be translated"
1986 | }
1987 | }
1988 | }
1989 | }
1990 | }
1991 | }`;
1992 |
1993 | mockFileOperations(input);
1994 |
1995 | const xcodeXcstringsLoader = createBucketLoader(
1996 | "xcode-xcstrings",
1997 | "i18n/[locale].xcstrings",
1998 | {
1999 | defaultLocale: "en",
2000 | },
2001 | );
2002 | xcodeXcstringsLoader.setDefaultLocale("en");
2003 |
2004 | const data = await xcodeXcstringsLoader.pull("en");
2005 |
2006 | expect(data).toHaveProperty("normal_key", "This should be translated");
2007 | expect(data).not.toHaveProperty("do_not_translate");
2008 |
2009 | const payload = {
2010 | normal_key: "Ceci devrait être traduit",
2011 | };
2012 |
2013 | await xcodeXcstringsLoader.push("fr", payload);
2014 |
2015 | expect(fs.writeFile).toHaveBeenCalled();
2016 | const writeFileCall = (fs.writeFile as any).mock.calls[0];
2017 | const writtenContent = JSON.parse(writeFileCall[1]);
2018 |
2019 | expect(
2020 | writtenContent.strings.normal_key.localizations.fr.stringUnit.value,
2021 | ).toBe("Ceci devrait être traduit");
2022 |
2023 | expect(writtenContent.strings.do_not_translate).toHaveProperty(
2024 | "shouldTranslate",
2025 | false,
2026 | );
2027 |
2028 | expect(
2029 | writtenContent.strings.do_not_translate.localizations,
2030 | ).not.toHaveProperty("fr");
2031 |
2032 | await xcodeXcstringsLoader.push("fr", {});
2033 |
2034 | const secondWriteFileCall = (fs.writeFile as any).mock.calls[1];
2035 | const secondWrittenContent = JSON.parse(secondWriteFileCall[1]);
2036 |
2037 | expect(secondWrittenContent.strings.do_not_translate).toHaveProperty(
2038 | "shouldTranslate",
2039 | false,
2040 | );
2041 | });
2042 |
2043 | it("should extract and restore variables during pull/push", async () => {
2044 | setupFileMocks();
2045 |
2046 | const input = JSON.stringify({
2047 | sourceLanguage: "en",
2048 | strings: {
2049 | message: {
2050 | extractionState: "manual",
2051 | localizations: {
2052 | en: {
2053 | stringUnit: {
2054 | state: "translated",
2055 | value: "Value: %d items",
2056 | },
2057 | },
2058 | },
2059 | },
2060 | },
2061 | });
2062 |
2063 | mockFileOperations(input);
2064 |
2065 | const xcLoader = createBucketLoader(
2066 | "xcode-xcstrings",
2067 | "i18n/[locale].xcstrings",
2068 | {
2069 | defaultLocale: "en",
2070 | },
2071 | );
2072 | xcLoader.setDefaultLocale("en");
2073 |
2074 | const data = await xcLoader.pull("en");
2075 |
2076 | expect(data).toEqual({ message: "Value: {variable:0} items" });
2077 |
2078 | const payload = {
2079 | message: "Valeur: {variable:0} éléments",
2080 | };
2081 |
2082 | await xcLoader.push("fr", payload);
2083 |
2084 | expect(fs.writeFile).toHaveBeenCalled();
2085 | const writeFileCall = (fs.writeFile as any).mock.calls[0];
2086 | const writtenContent = JSON.parse(writeFileCall[1]);
2087 |
2088 | expect(
2089 | writtenContent.strings.message.localizations.fr.stringUnit.value,
2090 | ).toBe("Valeur: %d éléments");
2091 | });
2092 |
2093 | it("should extract hints from xcstrings comments", async () => {
2094 | setupFileMocks();
2095 |
2096 | const input = JSON.stringify({
2097 | sourceLanguage: "en",
2098 | strings: {
2099 | welcome_message: {
2100 | comment: "Greeting displayed on the main screen",
2101 | extractionState: "manual",
2102 | localizations: {
2103 | en: {
2104 | stringUnit: {
2105 | state: "translated",
2106 | value: "Welcome!",
2107 | },
2108 | },
2109 | },
2110 | },
2111 | user_count: {
2112 | comment: "Number of active users - supports pluralization",
2113 | extractionState: "manual",
2114 | localizations: {
2115 | en: {
2116 | variations: {
2117 | plural: {
2118 | one: {
2119 | stringUnit: {
2120 | state: "translated",
2121 | value: "1 user",
2122 | },
2123 | },
2124 | other: {
2125 | stringUnit: {
2126 | state: "translated",
2127 | value: "%d users",
2128 | },
2129 | },
2130 | },
2131 | },
2132 | },
2133 | },
2134 | },
2135 | no_comment: {
2136 | extractionState: "manual",
2137 | localizations: {
2138 | en: {
2139 | stringUnit: {
2140 | state: "translated",
2141 | value: "No comment here",
2142 | },
2143 | },
2144 | },
2145 | },
2146 | },
2147 | });
2148 |
2149 | mockFileOperations(input);
2150 |
2151 | const xcodeXcstringsLoader = createBucketLoader(
2152 | "xcode-xcstrings",
2153 | "i18n/[locale].xcstrings",
2154 | {
2155 | defaultLocale: "en",
2156 | },
2157 | );
2158 | xcodeXcstringsLoader.setDefaultLocale("en");
2159 | await xcodeXcstringsLoader.pull("en");
2160 |
2161 | const hints = await xcodeXcstringsLoader.pullHints();
2162 |
2163 | // Note: The output is flattened because xcode-xcstrings bucket loader goes through the flat loader
2164 | expect(hints).toEqual({
2165 | welcome_message: ["Greeting displayed on the main screen"],
2166 | user_count: ["Number of active users - supports pluralization"],
2167 | "user_count/one": ["Number of active users - supports pluralization"],
2168 | "user_count/other": ["Number of active users - supports pluralization"],
2169 | });
2170 | });
2171 |
2172 | it("should handle xcstrings without comments in full-stack loader", async () => {
2173 | setupFileMocks();
2174 |
2175 | const input = JSON.stringify({
2176 | sourceLanguage: "en",
2177 | strings: {
2178 | simple_key: {
2179 | extractionState: "manual",
2180 | localizations: {
2181 | en: {
2182 | stringUnit: {
2183 | state: "translated",
2184 | value: "Simple value",
2185 | },
2186 | },
2187 | },
2188 | },
2189 | },
2190 | });
2191 |
2192 | mockFileOperations(input);
2193 |
2194 | const xcodeXcstringsLoader = createBucketLoader(
2195 | "xcode-xcstrings",
2196 | "i18n/[locale].xcstrings",
2197 | {
2198 | defaultLocale: "en",
2199 | },
2200 | );
2201 | xcodeXcstringsLoader.setDefaultLocale("en");
2202 | await xcodeXcstringsLoader.pull("en");
2203 |
2204 | const hints = await xcodeXcstringsLoader.pullHints();
2205 |
2206 | expect(hints).toEqual({});
2207 | });
2208 |
2209 | it("should properly filter lockedKeys from data during pull operations", async () => {
2210 | setupFileMocks();
2211 |
2212 | const input = JSON.stringify({
2213 | sourceLanguage: "en",
2214 | strings: {
2215 | welcome_message: {
2216 | comment: "Welcome message - should be locked",
2217 | extractionState: "manual",
2218 | localizations: {
2219 | en: {
2220 | stringUnit: {
2221 | state: "translated",
2222 | value: "Hello, world!",
2223 | },
2224 | },
2225 | es: {
2226 | stringUnit: {
2227 | state: "translated",
2228 | value: "¡Hola, mundo!",
2229 | },
2230 | },
2231 | },
2232 | },
2233 | user_count: {
2234 | comment: "Number of users - should be translatable",
2235 | extractionState: "manual",
2236 | localizations: {
2237 | en: {
2238 | variations: {
2239 | plural: {
2240 | one: {
2241 | stringUnit: {
2242 | state: "translated",
2243 | value: "1 user",
2244 | },
2245 | },
2246 | other: {
2247 | stringUnit: {
2248 | state: "translated",
2249 | value: "%d users",
2250 | },
2251 | },
2252 | },
2253 | },
2254 | },
2255 | es: {
2256 | variations: {
2257 | plural: {
2258 | one: {
2259 | stringUnit: {
2260 | state: "translated",
2261 | value: "1 usuario",
2262 | },
2263 | },
2264 | other: {
2265 | stringUnit: {
2266 | state: "translated",
2267 | value: "%d usuarios",
2268 | },
2269 | },
2270 | },
2271 | },
2272 | },
2273 | },
2274 | },
2275 | api_key: {
2276 | comment: "API key - should be locked with wildcard pattern",
2277 | extractionState: "manual",
2278 | localizations: {
2279 | en: {
2280 | stringUnit: {
2281 | state: "translated",
2282 | value: "sk-1234567890abcdef",
2283 | },
2284 | },
2285 | },
2286 | },
2287 | },
2288 | });
2289 |
2290 | mockFileOperations(input);
2291 |
2292 | // Test with lockedKeys including both specific keys and wildcard patterns
2293 | const xcodeXcstringsLoaderWithLockedKeys = createBucketLoader(
2294 | "xcode-xcstrings",
2295 | "i18n/[locale].xcstrings",
2296 | {
2297 | defaultLocale: "en",
2298 | },
2299 | ["welcome_message", "api*"], // lockedKeys parameter
2300 | );
2301 | xcodeXcstringsLoaderWithLockedKeys.setDefaultLocale("en");
2302 |
2303 | // First pull the default locale to initialize the loader
2304 | await xcodeXcstringsLoaderWithLockedKeys.pull("en");
2305 |
2306 | // Pull data for translation - should filter out locked keys
2307 | const dataForTranslation =
2308 | await xcodeXcstringsLoaderWithLockedKeys.pull("es");
2309 |
2310 | // Locked keys should be filtered out
2311 | expect(dataForTranslation).not.toHaveProperty("welcome_message");
2312 | expect(dataForTranslation).not.toHaveProperty("api_key");
2313 |
2314 | // Non-locked keys should remain
2315 | expect(dataForTranslation).toHaveProperty("user_count/one");
2316 | expect(dataForTranslation).toHaveProperty("user_count/other");
2317 | expect(dataForTranslation["user_count/one"]).toBe("1 usuario");
2318 | expect(dataForTranslation["user_count/other"]).toBe(
2319 | "{variable:0} usuarios",
2320 | );
2321 |
2322 | // Test that push operations preserve locked keys from original
2323 |
2324 | const translationPayload = {
2325 | "user_count/one": "1 usuario nuevo",
2326 | "user_count/other": "{variable:0} usuarios nuevos",
2327 | // Attempt to overwrite locked keys - should be ignored
2328 | welcome_message: "This should be ignored",
2329 | api_key: "This should also be ignored",
2330 | };
2331 |
2332 | await xcodeXcstringsLoaderWithLockedKeys.push("es", translationPayload);
2333 |
2334 | expect(fs.writeFile).toHaveBeenCalled();
2335 | const writeFileCall = (fs.writeFile as any).mock.calls[0];
2336 | const writtenContent = JSON.parse(writeFileCall[1]);
2337 |
2338 | // Locked keys should preserve their original values from the input
2339 | // Since welcome_message was locked, the Spanish translation should not be overwritten
2340 | // But it might be replaced with the English value due to how the xcstrings loader works
2341 | // The important thing is that it wasn't sent for translation
2342 | expect(
2343 | writtenContent.strings.welcome_message.localizations.es,
2344 | ).toBeDefined();
2345 | expect(
2346 | writtenContent.strings.welcome_message.localizations.es.stringUnit
2347 | .value,
2348 | ).toMatch(/Hello, world!|¡Hola, mundo!/);
2349 | expect(
2350 | writtenContent.strings.api_key.localizations.en.stringUnit.value,
2351 | ).toBe("sk-1234567890abcdef");
2352 | // The api_key is locked, so it should preserve the original value even if we tried to overwrite it
2353 | if (writtenContent.strings.api_key.localizations.es) {
2354 | expect(
2355 | writtenContent.strings.api_key.localizations.es.stringUnit.value,
2356 | ).toBe("sk-1234567890abcdef");
2357 | }
2358 |
2359 | // Non-locked keys should have new translations
2360 | expect(
2361 | writtenContent.strings.user_count.localizations.es.variations.plural.one
2362 | .stringUnit.value,
2363 | ).toBe("1 usuario nuevo");
2364 | expect(
2365 | writtenContent.strings.user_count.localizations.es.variations.plural
2366 | .other.stringUnit.value,
2367 | ).toBe("%d usuarios nuevos");
2368 | });
2369 | });
2370 |
2371 | describe("yaml bucket loader", () => {
2372 | it("should load yaml", async () => {
2373 | setupFileMocks();
2374 |
2375 | const input = `
2376 | greeting: Hello!
2377 | `.trim();
2378 | const expectedOutput = { greeting: "Hello!" };
2379 |
2380 | mockFileOperations(input);
2381 |
2382 | const yamlLoader = createBucketLoader("yaml", "i18n/[locale].yaml", {
2383 | defaultLocale: "en",
2384 | });
2385 | yamlLoader.setDefaultLocale("en");
2386 | const data = await yamlLoader.pull("en");
2387 |
2388 | expect(data).toEqual(expectedOutput);
2389 | });
2390 |
2391 | it("should respect locked keys (pull)", async () => {
2392 | setupFileMocks();
2393 |
2394 | const input = `locked: Original\nhello: Hello!`;
2395 |
2396 | mockFileOperations(input);
2397 |
2398 | const yamlLoader = createBucketLoader(
2399 | "yaml",
2400 | "i18n/[locale].yaml",
2401 | {
2402 | defaultLocale: "en",
2403 | },
2404 | ["locked"],
2405 | );
2406 | yamlLoader.setDefaultLocale("en");
2407 | const data = await yamlLoader.pull("en");
2408 |
2409 | expect(data).toEqual({ hello: "Hello!" });
2410 | });
2411 |
2412 | it("should save yaml", async () => {
2413 | setupFileMocks();
2414 |
2415 | const input = `
2416 | greeting: Hello!
2417 | `.trim();
2418 | const payload = { greeting: "¡Hola!" };
2419 | const expectedOutput = `greeting: ¡Hola!`;
2420 |
2421 | mockFileOperations(input);
2422 |
2423 | const yamlLoader = createBucketLoader("yaml", "i18n/[locale].yaml", {
2424 | defaultLocale: "en",
2425 | });
2426 | yamlLoader.setDefaultLocale("en");
2427 | await yamlLoader.pull("en");
2428 |
2429 | await yamlLoader.push("es", payload);
2430 |
2431 | expect(fs.writeFile).toHaveBeenCalledWith(
2432 | "i18n/es.yaml",
2433 | expectedOutput,
2434 | { encoding: "utf-8", flag: "w" },
2435 | );
2436 | });
2437 |
2438 | describe("yaml with quoted keys and values", async () => {
2439 | it.each([
2440 | ["double quoted values", `greeting: "Hello!"`, `greeting: "¡Hola!"`],
2441 | ["double quoted keys", `"greeting": Hello!`, `"greeting": ¡Hola!`],
2442 | [
2443 | "double quoted keys and values",
2444 | `"greeting": "Hello!"`,
2445 | `"greeting": "¡Hola!"`,
2446 | ],
2447 | ])(
2448 | "should return correct value for %s",
2449 | async (_, input, expectedOutput) => {
2450 | const payload = { greeting: "¡Hola!" };
2451 |
2452 | mockFileOperations(input);
2453 |
2454 | const yamlLoader = createBucketLoader("yaml", "i18n/[locale].yaml", {
2455 | defaultLocale: "en",
2456 | });
2457 | yamlLoader.setDefaultLocale("en");
2458 | await yamlLoader.pull("en");
2459 |
2460 | await yamlLoader.push("es", payload);
2461 |
2462 | expect(fs.writeFile).toHaveBeenCalledWith(
2463 | "i18n/es.yaml",
2464 | expectedOutput,
2465 | { encoding: "utf-8", flag: "w" },
2466 | );
2467 | },
2468 | );
2469 | });
2470 | });
2471 |
2472 | describe("yaml-root-key bucket loader", () => {
2473 | it("should load yaml-root-key", async () => {
2474 | setupFileMocks();
2475 |
2476 | const input = `
2477 | en:
2478 | greeting: Hello!
2479 | `.trim();
2480 | const expectedOutput = { greeting: "Hello!" };
2481 |
2482 | mockFileOperations(input);
2483 |
2484 | const yamlRootKeyLoader = createBucketLoader(
2485 | "yaml-root-key",
2486 | "i18n/[locale].yaml",
2487 | {
2488 | defaultLocale: "en",
2489 | },
2490 | );
2491 | yamlRootKeyLoader.setDefaultLocale("en");
2492 | const data = await yamlRootKeyLoader.pull("en");
2493 |
2494 | expect(data).toEqual(expectedOutput);
2495 | });
2496 |
2497 | it("should save yaml-root-key", async () => {
2498 | setupFileMocks();
2499 |
2500 | const input = `
2501 | en:
2502 | greeting: Hello!
2503 | `.trim();
2504 | const payload = { greeting: "¡Hola!" };
2505 | const expectedOutput = `es:\n greeting: ¡Hola!`;
2506 |
2507 | mockFileOperations(input);
2508 |
2509 | const yamlRootKeyLoader = createBucketLoader(
2510 | "yaml-root-key",
2511 | "i18n/[locale].yaml",
2512 | {
2513 | defaultLocale: "en",
2514 | },
2515 | );
2516 | yamlRootKeyLoader.setDefaultLocale("en");
2517 | await yamlRootKeyLoader.pull("en");
2518 |
2519 | await yamlRootKeyLoader.push("es", payload);
2520 |
2521 | expect(fs.writeFile).toHaveBeenCalledWith(
2522 | "i18n/es.yaml",
2523 | expectedOutput,
2524 | { encoding: "utf-8", flag: "w" },
2525 | );
2526 | });
2527 | });
2528 |
2529 | describe("vtt bucket loader", () => {
2530 | it("should load complex vtt data", async () => {
2531 | setupFileMocks();
2532 |
2533 | const input = `
2534 | WEBVTT
2535 |
2536 | 00:00:00.000 --> 00:00:01.000
2537 | Hello world!
2538 |
2539 | 00:00:30.000 --> 00:00:31.000 align:start line:0%
2540 | This is a subtitle
2541 |
2542 | 00:01:00.000 --> 00:01:01.000
2543 | Foo
2544 |
2545 | 00:01:50.000 --> 00:01:51.000
2546 | Bar
2547 | `.trim();
2548 |
2549 | const expectedOutput = {
2550 | "0#0-1#": "Hello world!",
2551 | "1#30-31#": "This is a subtitle",
2552 | "2#60-61#": "Foo",
2553 | "3#110-111#": "Bar",
2554 | };
2555 |
2556 | mockFileOperations(input);
2557 |
2558 | const vttLoader = createBucketLoader("vtt", "i18n/[locale].vtt", {
2559 | defaultLocale: "en",
2560 | });
2561 | vttLoader.setDefaultLocale("en");
2562 | const data = await vttLoader.pull("en");
2563 |
2564 | expect(data).toEqual(expectedOutput);
2565 | });
2566 |
2567 | it("should save complex vtt data", async () => {
2568 | setupFileMocks();
2569 | const input = `
2570 | WEBVTT
2571 |
2572 | 00:00:00.000 --> 00:00:01.000
2573 | Hello world!
2574 |
2575 | 00:00:30.000 --> 00:00:31.000 align:start line:0%
2576 | This is a subtitle
2577 |
2578 | 00:01:00.000 --> 00:01:01.000
2579 | Foo
2580 |
2581 | 00:01:50.000 --> 00:01:51.000
2582 | Bar
2583 | `.trim();
2584 |
2585 | const payload = {
2586 | "0#0-1#": "¡Hola mundo!",
2587 | "1#30-31#": "Este es un subtítulo",
2588 | "2#60-61#": "Foo",
2589 | "3#110-111#": "Bar",
2590 | };
2591 |
2592 | const expectedOutput = `
2593 | WEBVTT
2594 |
2595 | 00:00:00.000 --> 00:00:01.000
2596 | ¡Hola mundo!
2597 |
2598 | 00:00:30.000 --> 00:00:31.000
2599 | Este es un subtítulo
2600 |
2601 | 00:01:00.000 --> 00:01:01.000
2602 | Foo
2603 |
2604 | 00:01:50.000 --> 00:01:51.000
2605 | Bar`.trim();
2606 |
2607 | mockFileOperations(input);
2608 |
2609 | const vttLoader = createBucketLoader("vtt", "i18n/[locale].vtt", {
2610 | defaultLocale: "en",
2611 | });
2612 | vttLoader.setDefaultLocale("en");
2613 | await vttLoader.pull("en");
2614 |
2615 | await vttLoader.push("es", payload);
2616 |
2617 | expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.vtt", expectedOutput, {
2618 | encoding: "utf-8",
2619 | flag: "w",
2620 | });
2621 | });
2622 |
2623 | it("should respect locked keys (pull)", async () => {
2624 | setupFileMocks();
2625 | const input = `
2626 | WEBVTT
2627 |
2628 | 00:00:00.000 --> 00:00:01.000
2629 | Hello world!
2630 |
2631 | 00:00:30.000 --> 00:00:31.000
2632 | Another cue
2633 | `.trim();
2634 |
2635 | mockFileOperations(input);
2636 |
2637 | const vttLoader = createBucketLoader(
2638 | "vtt",
2639 | "i18n/[locale].vtt",
2640 | {
2641 | defaultLocale: "en",
2642 | },
2643 | ["0#*"],
2644 | );
2645 | vttLoader.setDefaultLocale("en");
2646 | const data = await vttLoader.pull("en");
2647 |
2648 | // First cue (index 0) locked, so only second remains
2649 | expect(Object.values(data)).toContain("Another cue");
2650 | expect(Object.values(data)).not.toContain("Hello world!");
2651 | });
2652 | });
2653 |
2654 | describe("XML bucket loader", () => {
2655 | it("should load XML data", async () => {
2656 | setupFileMocks();
2657 |
2658 | const input = `<root>
2659 | <title>Test XML</title>
2660 | <date>2023-05-25</date>
2661 | <content>
2662 | <section>Introduction</section>
2663 | <section>
2664 | <text>
2665 | Detailed text.
2666 | </text>
2667 | </section>
2668 | </content>
2669 | </root>`;
2670 |
2671 | const expectedOutput = {
2672 | "root/title": "Test XML",
2673 | "root/content/section/0": "Introduction",
2674 | "root/content/section/1/text": "Detailed text.",
2675 | };
2676 |
2677 | mockFileOperations(input);
2678 |
2679 | const xmlLoader = createBucketLoader("xml", "i18n/[locale].xml", {
2680 | defaultLocale: "en",
2681 | });
2682 | xmlLoader.setDefaultLocale("en");
2683 | const data = await xmlLoader.pull("en");
2684 |
2685 | expect(data).toEqual(expectedOutput);
2686 | });
2687 |
2688 | it("should save XML data", async () => {
2689 | setupFileMocks();
2690 |
2691 | const input = `<root>
2692 | <title>Test XML</title>
2693 | <date>2023-05-25</date>
2694 | <content>
2695 | <section>Introduction</section>
2696 | <section>
2697 | <text>
2698 | Detailed text.
2699 | </text>
2700 | </section>
2701 | </content>
2702 | </root>`;
2703 |
2704 | const payload = {
2705 | "root/title": "Prueba XML",
2706 | "root/date": "2023-05-25",
2707 | "root/content/section/0": "Introducción",
2708 | "root/content/section/1/text": "Detalles texto.",
2709 | };
2710 |
2711 | let expectedOutput = `
2712 | <root>
2713 | <title>Prueba XML</title>
2714 | <date>2023-05-25</date>
2715 | <content>
2716 | <section>Introducción</section>
2717 | <section>
2718 | <text>Detalles texto.</text>
2719 | </section>
2720 | </content>
2721 | </root>`
2722 | .replace(/\s+/g, " ")
2723 | .replace(/>\s+</g, "><")
2724 | .trim();
2725 | mockFileOperations(input);
2726 | const xmlLoader = createBucketLoader("xml", "i18n/[locale].xml", {
2727 | defaultLocale: "en",
2728 | });
2729 | xmlLoader.setDefaultLocale("en");
2730 | await xmlLoader.pull("en");
2731 |
2732 | await xmlLoader.push("es", payload);
2733 |
2734 | expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.xml", expectedOutput, {
2735 | encoding: "utf-8",
2736 | flag: "w",
2737 | });
2738 | });
2739 |
2740 | it("should respect locked keys (pull)", async () => {
2741 | setupFileMocks();
2742 | const input = `<root><locked>Original</locked><hello>Hello!</hello></root>`;
2743 | mockFileOperations(input);
2744 |
2745 | const xmlLoader = createBucketLoader(
2746 | "xml",
2747 | "i18n/[locale].xml",
2748 | {
2749 | defaultLocale: "en",
2750 | },
2751 | ["root/locked"],
2752 | );
2753 | xmlLoader.setDefaultLocale("en");
2754 | const data = await xmlLoader.pull("en");
2755 |
2756 | expect(data).toEqual({ "root/hello": "Hello!" });
2757 | });
2758 | });
2759 |
2760 | describe("srt bucket loader", () => {
2761 | it("should load srt", async () => {
2762 | setupFileMocks();
2763 |
2764 | const input = `
2765 | 1
2766 | 00:00:00,000 --> 00:00:01,000
2767 | Hello!
2768 |
2769 | 2
2770 | 00:00:01,000 --> 00:00:02,000
2771 | World!
2772 | `.trim();
2773 | const expectedOutput = {
2774 | "1#00:00:00,000-00:00:01,000": "Hello!",
2775 | "2#00:00:01,000-00:00:02,000": "World!",
2776 | };
2777 |
2778 | mockFileOperations(input);
2779 |
2780 | const srtLoader = createBucketLoader("srt", "i18n/[locale].srt", {
2781 | defaultLocale: "en",
2782 | });
2783 | srtLoader.setDefaultLocale("en");
2784 | const data = await srtLoader.pull("en");
2785 |
2786 | expect(data).toEqual(expectedOutput);
2787 | });
2788 |
2789 | it("should save srt", async () => {
2790 | setupFileMocks();
2791 |
2792 | const input = `
2793 | 1
2794 | 00:00:00,000 --> 00:00:01,000
2795 | Hello!
2796 |
2797 | 2
2798 | 00:00:01,000 --> 00:00:02,000
2799 | World!
2800 | `.trim();
2801 |
2802 | const payload = {
2803 | "1#00:00:00,000-00:00:01,000": "¡Hola!",
2804 | "2#00:00:01,000-00:00:02,000": "Mundo!",
2805 | };
2806 |
2807 | const expectedOutput = `1
2808 | 00:00:00,000 --> 00:00:01,000
2809 | ¡Hola!
2810 |
2811 | 2
2812 | 00:00:01,000 --> 00:00:02,000
2813 | Mundo!`;
2814 |
2815 | mockFileOperations(input);
2816 |
2817 | const srtLoader = createBucketLoader("srt", "i18n/[locale].srt", {
2818 | defaultLocale: "en",
2819 | });
2820 | srtLoader.setDefaultLocale("en");
2821 | await srtLoader.pull("en");
2822 |
2823 | await srtLoader.push("es", payload);
2824 |
2825 | expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.srt", expectedOutput, {
2826 | encoding: "utf-8",
2827 | flag: "w",
2828 | });
2829 | });
2830 |
2831 | it("should respect locked keys (pull)", async () => {
2832 | setupFileMocks();
2833 | const input = `
2834 | 1
2835 | 00:00:00,000 --> 00:00:01,000
2836 | Hello!
2837 |
2838 | 2
2839 | 00:00:01,000 --> 00:00:02,000
2840 | World!
2841 | `.trim();
2842 |
2843 | mockFileOperations(input);
2844 |
2845 | const srtLoader = createBucketLoader(
2846 | "srt",
2847 | "i18n/[locale].srt",
2848 | {
2849 | defaultLocale: "en",
2850 | },
2851 | ["1#00:00:00,000-00:00:01,000"],
2852 | );
2853 | srtLoader.setDefaultLocale("en");
2854 | const data = await srtLoader.pull("en");
2855 |
2856 | expect(data).toEqual({ "2#00:00:01,000-00:00:02,000": "World!" });
2857 | });
2858 | });
2859 |
2860 | describe("xliff bucket loader", () => {
2861 | it("should load xliff data", async () => {
2862 | setupFileMocks();
2863 |
2864 | const input = `
2865 | <xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en-US">
2866 | <file id="namespace1">
2867 | <unit id="key1">
2868 | <segment>
2869 | <source>Hello</source>
2870 | </segment>
2871 | </unit>
2872 | <unit id="key2">
2873 | <segment>
2874 | <source>An application to manipulate and process XLIFF documents</source>
2875 | </segment>
2876 | </unit>
2877 | <unit id="key.nested">
2878 | <segment>
2879 | <source>XLIFF Data Manager</source>
2880 | </segment>
2881 | </unit>
2882 | <group id="group">
2883 | <unit id="groupUnit">
2884 | <segment>
2885 | <source>Group</source>
2886 | </segment>
2887 | </unit>
2888 | </group>
2889 | </file>
2890 | </xliff>
2891 | `.trim();
2892 |
2893 | // Keys must be encoded (e.g. / replaced with %2F)
2894 | const expectedOutput = {
2895 | "resources%2Fnamespace1%2Fgroup%2FgroupUnits%2FgroupUnit%2Fsource":
2896 | "Group",
2897 | "resources%2Fnamespace1%2Fkey.nested%2Fsource": "XLIFF Data Manager",
2898 | "resources%2Fnamespace1%2Fkey1%2Fsource": "Hello",
2899 | "resources%2Fnamespace1%2Fkey2%2Fsource":
2900 | "An application to manipulate and process XLIFF documents",
2901 | sourceLanguage: "en-US",
2902 | };
2903 |
2904 | mockFileOperations(input);
2905 |
2906 | const xliffLoader = createBucketLoader("xliff", "i18n/[locale].xliff", {
2907 | defaultLocale: "en",
2908 | });
2909 | xliffLoader.setDefaultLocale("en");
2910 | const data = await xliffLoader.pull("en");
2911 |
2912 | expect(data).toEqual(expectedOutput);
2913 | });
2914 |
2915 | it("should save xliff data", async () => {
2916 | setupFileMocks();
2917 |
2918 | const input = `
2919 | <xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en-US">
2920 | <file id="namespace1">
2921 | <unit id="key1">
2922 | <segment>
2923 | <source>Hello</source>
2924 | </segment>
2925 | </unit>
2926 | <unit id="key2">
2927 | <segment>
2928 | <source>An application to manipulate and process XLIFF documents</source>
2929 | </segment>
2930 | </unit>
2931 | <unit id="key.nested">
2932 | <segment>
2933 | <source>XLIFF Data Manager</source>
2934 | </segment>
2935 | </unit>
2936 | <group id="group">
2937 | <unit id="groupUnit">
2938 | <segment>
2939 | <source>Group</source>
2940 | </segment>
2941 | </unit>
2942 | </group>
2943 | </file>
2944 | </xliff>
2945 | `.trim();
2946 | // Keys must be encoded (e.g. / replaced with %2F)
2947 | const payload = {
2948 | "resources%2Fnamespace1%2Fgroup%2FgroupUnits%2FgroupUnit%2Fsource":
2949 | "Grupo",
2950 | "resources%2Fnamespace1%2Fkey.nested%2Fsource":
2951 | "Administrador de Datos XLIFF",
2952 | "resources%2Fnamespace1%2Fkey1%2Fsource": "Hola",
2953 | "resources%2Fnamespace1%2Fkey2%2Fsource":
2954 | "Una aplicación para manipular y procesar documentos XLIFF",
2955 | sourceLanguage: "es-ES",
2956 | };
2957 |
2958 | const expectedOutput = `
2959 | <xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="es-ES">
2960 | <file id="namespace1">
2961 | <unit id="key1">
2962 | <segment>
2963 | <source>Hola</source>
2964 | </segment>
2965 | </unit>
2966 | <unit id="key2">
2967 | <segment>
2968 | <source>Una aplicación para manipular y procesar documentos XLIFF</source>
2969 | </segment>
2970 | </unit>
2971 | <unit id="key.nested">
2972 | <segment>
2973 | <source>Administrador de Datos XLIFF</source>
2974 | </segment>
2975 | </unit>
2976 | <group id="group">
2977 | <unit id="groupUnit">
2978 | <segment>
2979 | <source>Grupo</source>
2980 | </segment>
2981 | </unit>
2982 | </group>
2983 | </file>
2984 | </xliff>`.trim();
2985 |
2986 | mockFileOperations(input);
2987 |
2988 | const xliffLoader = createBucketLoader("xliff", "i18n/[locale].xlf", {
2989 | defaultLocale: "en",
2990 | });
2991 | xliffLoader.setDefaultLocale("en");
2992 | await xliffLoader.pull("en");
2993 |
2994 | await xliffLoader.push("es", payload);
2995 |
2996 | expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.xlf", expectedOutput, {
2997 | encoding: "utf-8",
2998 | flag: "w",
2999 | });
3000 | });
3001 |
3002 | it("should respect locked keys (pull)", async () => {
3003 | setupFileMocks();
3004 |
3005 | const input = `
3006 | <xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en-US">
3007 | <file id="namespace1">
3008 | <unit id="locked">
3009 | <segment>
3010 | <source>Original</source>
3011 | </segment>
3012 | </unit>
3013 | <unit id="key1">
3014 | <segment>
3015 | <source>Hello</source>
3016 | </segment>
3017 | </unit>
3018 | </file>
3019 | </xliff>
3020 | `.trim();
3021 |
3022 | mockFileOperations(input);
3023 |
3024 | const xliffLoader = createBucketLoader(
3025 | "xliff",
3026 | "i18n/[locale].xliff",
3027 | {
3028 | defaultLocale: "en",
3029 | },
3030 | ["resources%2Fnamespace1%2Flocked%2Fsource"],
3031 | );
3032 | xliffLoader.setDefaultLocale("en");
3033 | const data = await xliffLoader.pull("en");
3034 |
3035 | expect(data).toEqual({
3036 | "resources%2Fnamespace1%2Fkey1%2Fsource": "Hello",
3037 | sourceLanguage: "en-US",
3038 | });
3039 | });
3040 | });
3041 |
3042 | describe("text-file", () => {
3043 | describe("when there is no target locale file", () => {
3044 | it("should preserve trailing new line based on the source locale", async () => {
3045 | setupFileMocks();
3046 |
3047 | const input = "Hello\n";
3048 | const expectedOutput = "Hola\n";
3049 |
3050 | mockFileOperationsForPaths({
3051 | "i18n/en.txt": input,
3052 | "i18n/es.txt": "",
3053 | });
3054 |
3055 | const textFileLoader = createTextFileLoader("i18n/[locale].txt");
3056 | textFileLoader.setDefaultLocale("en");
3057 | await textFileLoader.pull("en");
3058 |
3059 | await textFileLoader.push("es", "Hola");
3060 |
3061 | expect(fs.writeFile).toHaveBeenCalledWith(
3062 | "i18n/es.txt",
3063 | expectedOutput,
3064 | { encoding: "utf-8", flag: "w" },
3065 | );
3066 | });
3067 |
3068 | it("should not add trailing new line based on the source locale", async () => {
3069 | setupFileMocks();
3070 |
3071 | const input = "Hello";
3072 | const expectedOutput = "Hola";
3073 |
3074 | mockFileOperationsForPaths({
3075 | "i18n/en.txt": input,
3076 | "i18n/es.txt": "",
3077 | });
3078 |
3079 | const textFileLoader = createTextFileLoader("i18n/[locale].txt");
3080 | textFileLoader.setDefaultLocale("en");
3081 | await textFileLoader.pull("en");
3082 |
3083 | await textFileLoader.push("es", "Hola");
3084 |
3085 | expect(fs.writeFile).toHaveBeenCalledWith(
3086 | "i18n/es.txt",
3087 | expectedOutput,
3088 | { encoding: "utf-8", flag: "w" },
3089 | );
3090 | });
3091 | });
3092 |
3093 | describe("when there is a target locale file", () => {
3094 | it("should preserve trailing new lines based on the target locale", async () => {
3095 | setupFileMocks();
3096 |
3097 | const input = "Hello";
3098 | const targetInput = "Hola\n";
3099 | const expectedOutput = "Hola (translated)\n";
3100 |
3101 | mockFileOperationsForPaths({
3102 | "i18n/en.txt": input,
3103 | "i18n/es.txt": targetInput,
3104 | });
3105 |
3106 | const textFileLoader = createTextFileLoader("i18n/[locale].txt");
3107 | textFileLoader.setDefaultLocale("en");
3108 | await textFileLoader.pull("en");
3109 |
3110 | await textFileLoader.push("es", "Hola (translated)");
3111 |
3112 | expect(fs.writeFile).toHaveBeenCalledWith(
3113 | "i18n/es.txt",
3114 | expectedOutput,
3115 | { encoding: "utf-8", flag: "w" },
3116 | );
3117 | });
3118 |
3119 | it("should not add trailing new line based on the target locale", async () => {
3120 | setupFileMocks();
3121 |
3122 | const input = "Hello\n";
3123 | const targetInput = "Hola";
3124 | const expectedOutput = "Hola (translated)";
3125 |
3126 | mockFileOperationsForPaths({
3127 | "i18n/en.txt": input,
3128 | "i18n/es.txt": targetInput,
3129 | });
3130 |
3131 | const textFileLoader = createTextFileLoader("i18n/[locale].txt");
3132 | textFileLoader.setDefaultLocale("en");
3133 | await textFileLoader.pull("en");
3134 |
3135 | await textFileLoader.push("es", "Hola (translated)");
3136 |
3137 | expect(fs.writeFile).toHaveBeenCalledWith(
3138 | "i18n/es.txt",
3139 | expectedOutput,
3140 | { encoding: "utf-8", flag: "w" },
3141 | );
3142 | });
3143 | });
3144 | });
3145 |
3146 | describe("php bucket loader", () => {
3147 | it("should load php array", async () => {
3148 | setupFileMocks();
3149 |
3150 | const input = `<?php return ['button.title' => 'Submit'];`;
3151 | const expectedOutput = { "button.title": "Submit" };
3152 |
3153 | mockFileOperations(input);
3154 |
3155 | const phpLoader = createBucketLoader("php", "i18n/[locale].php", {
3156 | defaultLocale: "en",
3157 | });
3158 | phpLoader.setDefaultLocale("en");
3159 | const data = await phpLoader.pull("en");
3160 |
3161 | expect(data).toEqual(expectedOutput);
3162 | });
3163 |
3164 | it("should save php array", async () => {
3165 | setupFileMocks();
3166 |
3167 | const input = `<?php
3168 | // this is locale
3169 |
3170 | return array(
3171 | 'button.title' => 'Submit',
3172 | 'button.description' => ['Hello', 'Goodbye'],
3173 | 'button.index' => 1,
3174 | 'button.class' => null,
3175 | );`;
3176 | const expectedOutput = `<?php
3177 | // this is locale
3178 |
3179 | return array(
3180 | 'button.title' => 'Enviar',
3181 | 'button.description' => array(
3182 | 'Hola',
3183 | 'Adiós'
3184 | ),
3185 | 'button.index' => 1,
3186 | 'button.class' => null
3187 | );`;
3188 |
3189 | mockFileOperations(input);
3190 |
3191 | const phpLoader = createBucketLoader("php", "i18n/[locale].php", {
3192 | defaultLocale: "en",
3193 | });
3194 | phpLoader.setDefaultLocale("en");
3195 | await phpLoader.pull("en");
3196 |
3197 | await phpLoader.push("es", {
3198 | "button.title": "Enviar",
3199 | "button.description/0": "Hola",
3200 | "button.description/1": "Adiós",
3201 | });
3202 |
3203 | expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.php", expectedOutput, {
3204 | encoding: "utf-8",
3205 | flag: "w",
3206 | });
3207 | });
3208 |
3209 | it("should respect locked keys (pull)", async () => {
3210 | setupFileMocks();
3211 |
3212 | const input = `<?php\n\nreturn [\n 'locked' => 'Original',\n 'hello' => 'Hello'\n];`;
3213 |
3214 | mockFileOperations(input);
3215 |
3216 | const phpLoader = createBucketLoader(
3217 | "php",
3218 | "i18n/[locale].php",
3219 | {
3220 | defaultLocale: "en",
3221 | },
3222 | ["locked"],
3223 | );
3224 | phpLoader.setDefaultLocale("en");
3225 | const data = await phpLoader.pull("en");
3226 |
3227 | expect(data).toEqual({ hello: "Hello" });
3228 | });
3229 | });
3230 |
3231 | describe("po bucket loader", () => {
3232 | it("should load po file", async () => {
3233 | setupFileMocks();
3234 |
3235 | const input = `msgid "Hello"\nmsgstr "Hello"`;
3236 | const expectedOutput = { "Hello/singular": "Hello" };
3237 |
3238 | mockFileOperations(input);
3239 |
3240 | const poLoader = createBucketLoader("po", "i18n/[locale].po", {
3241 | defaultLocale: "en",
3242 | });
3243 | poLoader.setDefaultLocale("en");
3244 | const data = await poLoader.pull("en");
3245 |
3246 | expect(data).toEqual(expectedOutput);
3247 | });
3248 |
3249 | it("should save po file", async () => {
3250 | setupFileMocks();
3251 |
3252 | const input = `msgid "Hello"\nmsgstr "Hello"`;
3253 | const expectedOutput = `msgid "Hello"\nmsgstr "Hola"`;
3254 |
3255 | mockFileOperations(input);
3256 |
3257 | const poLoader = createBucketLoader("po", "i18n/[locale].po", {
3258 | defaultLocale: "en",
3259 | });
3260 | poLoader.setDefaultLocale("en");
3261 | await poLoader.pull("en");
3262 |
3263 | await poLoader.push("es", {
3264 | "Hello/singular": "Hola",
3265 | });
3266 |
3267 | expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.po", expectedOutput, {
3268 | encoding: "utf-8",
3269 | flag: "w",
3270 | });
3271 | });
3272 |
3273 | it("should extract and restore variables", async () => {
3274 | setupFileMocks();
3275 |
3276 | const input = `msgid "You have %(count)d items"\nmsgstr "You have %(count)d items"\n\n#~ msgid "I am obsolete"\n#~ msgstr "I am obsolete"`;
3277 |
3278 | const expectedPullOutput = {
3279 | "You%20have%20%25(count)d%20items/singular":
3280 | "You have {variable:0} items",
3281 | };
3282 |
3283 | mockFileOperations(input);
3284 |
3285 | const poLoader = createBucketLoader("po", "i18n/[locale].po", {
3286 | defaultLocale: "en",
3287 | });
3288 | poLoader.setDefaultLocale("en");
3289 |
3290 | const data = await poLoader.pull("en");
3291 |
3292 | expect(data).toEqual(expectedPullOutput);
3293 |
3294 | const payload = {
3295 | "You%20have%20%25(count)d%20items/singular":
3296 | "Sie haben {variable:0} Elemente",
3297 | };
3298 |
3299 | await poLoader.push("de", payload);
3300 |
3301 | expect(fs.writeFile).toHaveBeenCalledWith(
3302 | "i18n/de.po",
3303 | `msgid "You have %(count)d items"\nmsgstr "Sie haben %(count)d Elemente"`,
3304 | { encoding: "utf-8", flag: "w" },
3305 | );
3306 | });
3307 |
3308 | it("should respect locked keys (pull)", async () => {
3309 | setupFileMocks();
3310 | const input = `# Comment\n\nmsgid "greeting"\nmsgstr "Hello"\n\nmsgid "farewell"\nmsgstr "Bye"`;
3311 | const payload = input; // same for mocking
3312 |
3313 | mockFileOperations(payload);
3314 |
3315 | const poLoader = createBucketLoader(
3316 | "po",
3317 | "i18n/[locale].po",
3318 | {
3319 | defaultLocale: "en",
3320 | },
3321 | ["greeting/singular"],
3322 | );
3323 | poLoader.setDefaultLocale("en");
3324 | const data = await poLoader.pull("en");
3325 |
3326 | // Only farewell remains (po loader returns structured values, flattened to keys)
3327 | expect(Object.keys(data)).toContain("farewell/singular");
3328 | expect(Object.keys(data)).not.toContain("greeting/singular");
3329 | });
3330 | });
3331 |
3332 | describe("vue-json bucket loader", () => {
3333 | const template = `<template>
3334 | <div id="app">
3335 | <label for="locale">locale</label>
3336 | <select v-model="locale">
3337 | <option>en</option>
3338 | <option>ja</option>
3339 | </select>
3340 | <p>message: {{ $t('hello') }}</p>
3341 | </div>
3342 | </template>`;
3343 | const script = `<script>
3344 | export default {
3345 | name: 'app',
3346 | data () {
3347 | this.$i18n.locale = 'en';
3348 | return { locale: 'en' }
3349 | },
3350 | watch: {
3351 | locale (val) {
3352 | this.$i18n.locale = val
3353 | }
3354 | }
3355 | }
3356 | </script>`;
3357 |
3358 | it("should load vue-json file", async () => {
3359 | setupFileMocks();
3360 |
3361 | const input = `${template}
3362 |
3363 | <i18n>
3364 | {
3365 | "en": {
3366 | "hello": "hello world!"
3367 | }
3368 | }
3369 | </i18n>
3370 |
3371 | ${script}`;
3372 | const expectedOutput = { hello: "hello world!" };
3373 |
3374 | mockFileOperations(input);
3375 |
3376 | const vueLoader = createBucketLoader("vue-json", "i18n/[locale].vue", {
3377 | defaultLocale: "en",
3378 | });
3379 | vueLoader.setDefaultLocale("en");
3380 | const data = await vueLoader.pull("en");
3381 |
3382 | expect(data).toEqual(expectedOutput);
3383 | });
3384 |
3385 | it("should save vue-json file", async () => {
3386 | setupFileMocks();
3387 |
3388 | const input = `${template}
3389 |
3390 | <i18n>
3391 | {
3392 | "en": {
3393 | "hello": "hello world!"
3394 | }
3395 | }
3396 | </i18n>
3397 |
3398 | ${script}`;
3399 | const expectedOutput = `${template}
3400 |
3401 | <i18n>
3402 | {
3403 | "en": {
3404 | "hello": "hello world!"
3405 | },
3406 | "es": {
3407 | "hello": "hola mundo!"
3408 | }
3409 | }
3410 | </i18n>
3411 |
3412 | ${script}`;
3413 |
3414 | mockFileOperations(input);
3415 |
3416 | const vueLoader = createBucketLoader("vue-json", "i18n/App.vue", {
3417 | defaultLocale: "en",
3418 | });
3419 | vueLoader.setDefaultLocale("en");
3420 | await vueLoader.pull("en");
3421 |
3422 | await vueLoader.push("es", {
3423 | hello: "hola mundo!",
3424 | });
3425 |
3426 | expect(fs.writeFile).toHaveBeenCalledWith(
3427 | "i18n/App.vue",
3428 | expectedOutput,
3429 | { encoding: "utf-8", flag: "w" },
3430 | );
3431 | });
3432 |
3433 | it("should ignore vue file without i18n tag", async () => {
3434 | setupFileMocks();
3435 |
3436 | const input = `${template}
3437 |
3438 | ${script}`;
3439 | const expectedOutput = `${template}
3440 |
3441 | ${script}`;
3442 |
3443 | mockFileOperations(input);
3444 |
3445 | const vueLoader = createBucketLoader("vue-json", "i18n/App.vue", {
3446 | defaultLocale: "en",
3447 | });
3448 | vueLoader.setDefaultLocale("en");
3449 | await vueLoader.pull("en");
3450 |
3451 | await vueLoader.push("es", {
3452 | hello: "hola mundo!",
3453 | });
3454 |
3455 | expect(fs.writeFile).toHaveBeenCalledWith(
3456 | "i18n/App.vue",
3457 | expectedOutput,
3458 | { encoding: "utf-8", flag: "w" },
3459 | );
3460 | });
3461 |
3462 | it("should respect locked keys (pull)", async () => {
3463 | setupFileMocks();
3464 |
3465 | const input = `${template}
3466 |
3467 | <i18n>
3468 | {
3469 | "en": {
3470 | "locked": "Original",
3471 | "hello": "Hello!"
3472 | }
3473 | }
3474 | </i18n>
3475 |
3476 | ${script}`;
3477 |
3478 | mockFileOperations(input);
3479 |
3480 | const vueLoader = createBucketLoader(
3481 | "vue-json",
3482 | "i18n/[locale].vue",
3483 | {
3484 | defaultLocale: "en",
3485 | },
3486 | ["locked"],
3487 | );
3488 | vueLoader.setDefaultLocale("en");
3489 | const data = await vueLoader.pull("en");
3490 |
3491 | expect(data).toEqual({ hello: "Hello!" });
3492 | });
3493 | });
3494 | describe("ejs bucket loader", () => {
3495 | it("should load ejs data", async () => {
3496 | setupFileMocks();
3497 |
3498 | const input = `<!DOCTYPE html>
3499 | <html>
3500 | <head>
3501 | <title>Welcome Page</title>
3502 | </head>
3503 | <body>
3504 | <h1>Hello <%= user.name %>!</h1>
3505 | <% if (user.isLoggedIn) { %>
3506 | <p>Welcome back to our application.</p>
3507 | <p>You have <%= notifications.length %> new notifications.</p>
3508 | <% } else { %>
3509 | <p>Please log in to continue.</p>
3510 | <% } %>
3511 | <ul>
3512 | <% items.forEach(function(item, index) { %>
3513 | <li>Item <%= index + 1 %>: <%= item.title %></li>
3514 | <% }); %>
3515 | </ul>
3516 | <footer>© 2024 My Company. All rights reserved.</footer>
3517 | </body>
3518 | </html>`;
3519 |
3520 | const expectedOutput = {
3521 | text_0: "Welcome Page",
3522 | text_1: "Hello",
3523 | text_2: "!",
3524 | text_3: "Welcome back to our application.",
3525 | text_4: "You have",
3526 | text_5: "new notifications.",
3527 | text_6: "Please log in to continue.",
3528 | text_7: "Item",
3529 | text_8: ":",
3530 | text_9: "© 2024 My Company. All rights reserved.",
3531 | };
3532 |
3533 | mockFileOperations(input);
3534 |
3535 | const ejsLoader = createBucketLoader("ejs", "templates/[locale].ejs", {
3536 | defaultLocale: "en",
3537 | });
3538 | ejsLoader.setDefaultLocale("en");
3539 | const data = await ejsLoader.pull("en");
3540 |
3541 | expect(data).toEqual(expectedOutput);
3542 | });
3543 |
3544 | it("should save ejs data", async () => {
3545 | setupFileMocks();
3546 |
3547 | const input = `<!DOCTYPE html>
3548 | <html>
3549 | <head>
3550 | <title>Welcome Page</title>
3551 | </head>
3552 | <body>
3553 | <h1>Hello <%= user.name %>!</h1>
3554 | <p>Welcome to our application.</p>
3555 | <footer>© 2024 My Company. All rights reserved.</footer>
3556 | </body>
3557 | </html>`;
3558 |
3559 | const payload = {
3560 | text_0: "Página de Bienvenida",
3561 | text_1: "Hola",
3562 | text_2: "!",
3563 | text_3: "Bienvenido a nuestra aplicación.",
3564 | text_4: "© 2024 Mi Empresa. Todos los derechos reservados.",
3565 | };
3566 |
3567 | const expectedOutput = `<!DOCTYPE html>
3568 | <html>
3569 | <head>
3570 | <title>Página de Bienvenida</title>
3571 | </head>
3572 | <body>
3573 | <h1>Hola <%= user.name %>!</h1>
3574 | <p>Bienvenido a nuestra aplicación.</p>
3575 | <footer>© 2024 Mi Empresa. Todos los derechos reservados.</footer>
3576 | </body>
3577 | </html>`;
3578 |
3579 | mockFileOperations(input);
3580 |
3581 | const ejsLoader = createBucketLoader("ejs", "templates/[locale].ejs", {
3582 | defaultLocale: "en",
3583 | });
3584 | ejsLoader.setDefaultLocale("en");
3585 | await ejsLoader.pull("en");
3586 |
3587 | await ejsLoader.push("es", payload);
3588 |
3589 | expect(fs.writeFile).toHaveBeenCalledWith(
3590 | "templates/es.ejs",
3591 | expectedOutput,
3592 | { encoding: "utf-8", flag: "w" },
3593 | );
3594 | });
3595 |
3596 | it("should respect locked keys (pull)", async () => {
3597 | setupFileMocks();
3598 |
3599 | const input = `<!DOCTYPE html>
3600 | <html>
3601 | <head>
3602 | <title>Welcome Page</title>
3603 | </head>
3604 | <body>
3605 | <h1>Hello <%= user.name %>!</h1>
3606 | <p>Welcome to our application.</p>
3607 | </body>
3608 | </html>`;
3609 |
3610 | mockFileOperations(input);
3611 |
3612 | const ejsLoader = createBucketLoader(
3613 | "ejs",
3614 | "templates/[locale].ejs",
3615 | {
3616 | defaultLocale: "en",
3617 | },
3618 | ["text_0"],
3619 | );
3620 | ejsLoader.setDefaultLocale("en");
3621 | const data = await ejsLoader.pull("en");
3622 |
3623 | // text_0 (title) is locked; remaining translatables present
3624 | expect(Object.keys(data)).not.toContain("text_0");
3625 | expect(Object.keys(data)).toContain("text_1");
3626 | });
3627 | });
3628 |
3629 | describe("txt bucket loader", () => {
3630 | it("should load txt", async () => {
3631 | setupFileMocks();
3632 |
3633 | const input = `Welcome to our application!
3634 | This is a sample text file for fastlane metadata.
3635 | It contains app description that needs to be translated.`;
3636 |
3637 | const expectedOutput = {
3638 | "1": "Welcome to our application!",
3639 | "2": "This is a sample text file for fastlane metadata.",
3640 | "3": "It contains app description that needs to be translated.",
3641 | };
3642 |
3643 | mockFileOperations(input);
3644 |
3645 | const txtLoader = createBucketLoader(
3646 | "txt",
3647 | "fastlane/metadata/[locale]/description.txt",
3648 | {
3649 | defaultLocale: "en",
3650 | },
3651 | );
3652 | txtLoader.setDefaultLocale("en");
3653 | const data = await txtLoader.pull("en");
3654 |
3655 | expect(data).toEqual(expectedOutput);
3656 | });
3657 |
3658 | it("should save txt", async () => {
3659 | setupFileMocks();
3660 |
3661 | const input = `Welcome to our application!
3662 | This is a sample text file for fastlane metadata.
3663 | It contains app description that needs to be translated.`;
3664 |
3665 | const payload = {
3666 | "1": "¡Bienvenido a nuestra aplicación!",
3667 | "2": "Este es un archivo de texto de muestra para metadatos de fastlane.",
3668 | "3": "Contiene la descripción de la aplicación que necesita ser traducida.",
3669 | };
3670 |
3671 | const expectedOutput = `¡Bienvenido a nuestra aplicación!
3672 | Este es un archivo de texto de muestra para metadatos de fastlane.
3673 | Contiene la descripción de la aplicación que necesita ser traducida.`;
3674 |
3675 | mockFileOperations(input);
3676 |
3677 | const txtLoader = createBucketLoader(
3678 | "txt",
3679 | "fastlane/metadata/[locale]/description.txt",
3680 | {
3681 | defaultLocale: "en",
3682 | },
3683 | );
3684 | txtLoader.setDefaultLocale("en");
3685 | await txtLoader.pull("en");
3686 |
3687 | await txtLoader.push("es", payload);
3688 |
3689 | expect(fs.writeFile).toHaveBeenCalledWith(
3690 | "fastlane/metadata/es/description.txt",
3691 | expectedOutput,
3692 | { encoding: "utf-8", flag: "w" },
3693 | );
3694 | });
3695 |
3696 | it("should handle empty txt files", async () => {
3697 | setupFileMocks();
3698 |
3699 | const input = "";
3700 | const expectedOutput = {};
3701 |
3702 | mockFileOperations(input);
3703 |
3704 | const txtLoader = createBucketLoader(
3705 | "txt",
3706 | "fastlane/metadata/[locale]/description.txt",
3707 | {
3708 | defaultLocale: "en",
3709 | },
3710 | );
3711 | txtLoader.setDefaultLocale("en");
3712 | const data = await txtLoader.pull("en");
3713 |
3714 | expect(data).toEqual(expectedOutput);
3715 | });
3716 |
3717 | it("should filter out empty lines during pull", async () => {
3718 | setupFileMocks();
3719 |
3720 | const input = `Line 1
3721 |
3722 | Line 3`;
3723 | const expectedOutput = {
3724 | "1": "Line 1",
3725 | "3": "Line 3",
3726 | };
3727 |
3728 | mockFileOperations(input);
3729 |
3730 | const txtLoader = createBucketLoader(
3731 | "txt",
3732 | "fastlane/metadata/[locale]/description.txt",
3733 | {
3734 | defaultLocale: "en",
3735 | },
3736 | );
3737 | txtLoader.setDefaultLocale("en");
3738 | const data = await txtLoader.pull("en");
3739 |
3740 | expect(data).toEqual(expectedOutput);
3741 | });
3742 |
3743 | it("should reconstruct file with empty lines restored", async () => {
3744 | setupFileMocks();
3745 |
3746 | const input = `Line 1
3747 |
3748 | Line 3`;
3749 |
3750 | const payload = {
3751 | "1": "Línea 1",
3752 | "3": "Línea 3",
3753 | };
3754 |
3755 | const expectedOutput = `Línea 1
3756 |
3757 | Línea 3`;
3758 |
3759 | mockFileOperations(input);
3760 |
3761 | const txtLoader = createBucketLoader(
3762 | "txt",
3763 | "fastlane/metadata/[locale]/description.txt",
3764 | {
3765 | defaultLocale: "en",
3766 | },
3767 | );
3768 | txtLoader.setDefaultLocale("en");
3769 | await txtLoader.pull("en");
3770 |
3771 | await txtLoader.push("es", payload);
3772 |
3773 | expect(fs.writeFile).toHaveBeenCalledWith(
3774 | "fastlane/metadata/es/description.txt",
3775 | expectedOutput,
3776 | { encoding: "utf-8", flag: "w" },
3777 | );
3778 | });
3779 |
3780 | it("should respect locked keys (pull)", async () => {
3781 | setupFileMocks();
3782 |
3783 | const input = `Secret\nHello`;
3784 | mockFileOperations(input);
3785 |
3786 | const txtLoader = createBucketLoader(
3787 | "txt",
3788 | "fastlane/metadata/[locale]/description.txt",
3789 | { defaultLocale: "en" },
3790 | ["1"],
3791 | );
3792 | txtLoader.setDefaultLocale("en");
3793 | const data = await txtLoader.pull("en");
3794 |
3795 | expect(data).toEqual({ 2: "Hello" } as any);
3796 | });
3797 | });
3798 |
3799 | describe("json-dictionary bucket loader", () => {
3800 | it("should add target locale keys only where source locale keys exist", async () => {
3801 | setupFileMocks();
3802 | const input = {
3803 | title: { en: "I am a title" },
3804 | logoPosition: "right",
3805 | pages: [
3806 | {
3807 | name: "Welcome to my world",
3808 | elements: [
3809 | {
3810 | title: { en: "I am an element title" },
3811 | description: { en: "I am an element description" },
3812 | },
3813 | ],
3814 | },
3815 | ],
3816 | };
3817 | mockFileOperations(JSON.stringify(input));
3818 | const loader = createBucketLoader(
3819 | "json-dictionary",
3820 | "i18n/[locale].json",
3821 | {
3822 | defaultLocale: "en",
3823 | },
3824 | );
3825 | loader.setDefaultLocale("en");
3826 | await loader.pull("en");
3827 | await loader.push("es", {
3828 | title: "Yo soy un titulo",
3829 | "pages/0/elements/0/title": "Yo soy un elemento de titulo",
3830 | "pages/0/elements/0/description": "Yo soy una descripcion de elemento",
3831 | });
3832 | const expectedOutput = `{
3833 | "title": {
3834 | "en": "I am a title",
3835 | "es": "Yo soy un titulo"
3836 | },
3837 | "logoPosition": "right",
3838 | "pages": [
3839 | {
3840 | "name": "Welcome to my world",
3841 | "elements": [
3842 | {
3843 | "title": {
3844 | "en": "I am an element title",
3845 | "es": "Yo soy un elemento de titulo"
3846 | },
3847 | "description": {
3848 | "en": "I am an element description",
3849 | "es": "Yo soy una descripcion de elemento"
3850 | }
3851 | }
3852 | ]
3853 | }
3854 | ]
3855 | }`;
3856 | expect(fs.writeFile).toHaveBeenCalledWith(
3857 | "i18n/es.json",
3858 | expectedOutput,
3859 | { encoding: "utf-8", flag: "w" },
3860 | );
3861 | });
3862 |
3863 | it("should respect locked keys (pull)", async () => {
3864 | setupFileMocks();
3865 | const input = {
3866 | title: { en: "I am a title" },
3867 | subtitle: { en: "Sub" },
3868 | };
3869 | mockFileOperations(JSON.stringify(input));
3870 | const loader = createBucketLoader(
3871 | "json-dictionary",
3872 | "i18n/[locale].json",
3873 | { defaultLocale: "en" },
3874 | ["title"],
3875 | );
3876 | loader.setDefaultLocale("en");
3877 | const data = await loader.pull("en");
3878 | expect(data).toEqual({ subtitle: "Sub" });
3879 | });
3880 | });
3881 |
3882 | describe("yaml-root-key bucket loader", () => {
3883 | it("should respect locked keys (pull)", async () => {
3884 | setupFileMocks();
3885 | const input = `en:\n locked: Original\n hello: Hello!`;
3886 | mockFileOperations(input);
3887 | const loader = createBucketLoader(
3888 | "yaml-root-key",
3889 | "i18n/[locale].yml",
3890 | { defaultLocale: "en" },
3891 | ["locked"],
3892 | );
3893 | loader.setDefaultLocale("en");
3894 | const data = await loader.pull("en");
3895 | expect(data).toEqual({ hello: "Hello!" });
3896 | });
3897 | });
3898 |
3899 | describe("xcode-xcstrings-v2 bucket loader", () => {
3900 | it("should respect locked keys (pull)", async () => {
3901 | setupFileMocks();
3902 | const input = JSON.stringify({
3903 | sourceLanguage: "en",
3904 | strings: {
3905 | locked: {
3906 | extractionState: "manual",
3907 | localizations: {
3908 | en: { stringUnit: { state: "translated", value: "Original" } },
3909 | },
3910 | },
3911 | hello: {
3912 | extractionState: "manual",
3913 | localizations: {
3914 | en: { stringUnit: { state: "translated", value: "Hello" } },
3915 | },
3916 | },
3917 | },
3918 | });
3919 | mockFileOperations(input);
3920 |
3921 | const loader = createBucketLoader(
3922 | "xcode-xcstrings-v2",
3923 | "i18n/[locale].xcstrings",
3924 | { defaultLocale: "en" },
3925 | ["locked"],
3926 | );
3927 | loader.setDefaultLocale("en");
3928 | const data = await loader.pull("en");
3929 | expect(data).toEqual({ hello: "Hello" });
3930 | });
3931 | });
3932 |
3933 | describe("typescript bucket loader", () => {
3934 | it("should respect locked keys (pull)", async () => {
3935 | setupFileMocks();
3936 | const input = `export default { locked: "Original", hello: "Hello" };`;
3937 | mockFileOperations(input);
3938 | const loader = createBucketLoader(
3939 | "typescript",
3940 | "i18n/[locale].ts",
3941 | { defaultLocale: "en" },
3942 | ["locked"],
3943 | );
3944 | loader.setDefaultLocale("en");
3945 | const data = await loader.pull("en");
3946 | expect(data).toEqual({ hello: "Hello" });
3947 | });
3948 | });
3949 | });
3950 |
3951 | function setupFileMocks() {
3952 | vi.mock("fs/promises", () => ({
3953 | default: {
3954 | readFile: vi.fn(),
3955 | writeFile: vi.fn(),
3956 | mkdir: vi.fn(),
3957 | access: vi.fn(),
3958 | },
3959 | }));
3960 |
3961 | vi.mock("path", () => ({
3962 | default: {
3963 | resolve: vi.fn((path) => path),
3964 | dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/")),
3965 | },
3966 | }));
3967 | }
3968 |
3969 | function mockFileOperations(input: string) {
3970 | (fs.access as any).mockImplementation(() => Promise.resolve());
3971 | (fs.readFile as any).mockImplementation(() => Promise.resolve(input));
3972 | (fs.writeFile as any).mockImplementation(() => Promise.resolve());
3973 | }
3974 |
3975 | function mockFileOperationsForPaths(input: Record<string, string>) {
3976 | (fs.access as any).mockImplementation((path) =>
3977 | input.hasOwnProperty(path)
3978 | ? Promise.resolve()
3979 | : Promise.reject(`fs.access: ${path} not mocked`),
3980 | );
3981 | (fs.readFile as any).mockImplementation((path) =>
3982 | input.hasOwnProperty(path)
3983 | ? Promise.resolve(input[path])
3984 | : Promise.reject(`fs.readFile: ${path} not mocked`),
3985 | );
3986 | (fs.writeFile as any).mockImplementation((path) =>
3987 | input.hasOwnProperty(path)
3988 | ? Promise.resolve()
3989 | : Promise.reject(`fs:writeFile: ${path} not mocked`),
3990 | );
3991 | }
3992 |
```