This is page 15 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/compiler/src/lib/lcp/api/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createGroq } from "@ai-sdk/groq";
2 | import { createGoogleGenerativeAI } from "@ai-sdk/google";
3 | import { createOpenRouter } from "@openrouter/ai-sdk-provider";
4 | import { createOllama } from "ollama-ai-provider";
5 | import { createMistral } from "@ai-sdk/mistral";
6 | import { generateText } from "ai";
7 | import { LingoDotDevEngine } from "@lingo.dev/_sdk";
8 | import { DictionarySchema } from "../schema";
9 | import _ from "lodash";
10 | import { getLocaleModel } from "../../../utils/locales";
11 | import getSystemPrompt from "./prompt";
12 | import { obj2xml, xml2obj } from "./xml2obj";
13 | import shots from "./shots";
14 | import {
15 | getGroqKey,
16 | getGroqKeyFromEnv,
17 | getGoogleKey,
18 | getGoogleKeyFromEnv,
19 | getOpenRouterKey,
20 | getOpenRouterKeyFromEnv,
21 | getMistralKey,
22 | getMistralKeyFromEnv,
23 | getLingoDotDevKeyFromEnv,
24 | getLingoDotDevKey,
25 | } from "../../../utils/llm-api-key";
26 | import dedent from "dedent";
27 | import { isRunningInCIOrDocker } from "../../../utils/env";
28 | import { LanguageModel } from "ai";
29 | import { providerDetails } from "./provider-details";
30 |
31 | export class LCPAPI {
32 | static async translate(
33 | models: "lingo.dev" | Record<string, string>,
34 | sourceDictionary: DictionarySchema,
35 | sourceLocale: string,
36 | targetLocale: string,
37 | prompt?: string | null,
38 | ): Promise<DictionarySchema> {
39 | const timeLabel = `LCPAPI.translate: ${targetLocale}`;
40 | console.time(timeLabel);
41 | const chunks = this._chunkDictionary(sourceDictionary);
42 | const translatedChunks = [];
43 | for (const chunk of chunks) {
44 | const translatedChunk = await this._translateChunk(
45 | models,
46 | chunk,
47 | sourceLocale,
48 | targetLocale,
49 | prompt,
50 | );
51 | translatedChunks.push(translatedChunk);
52 | }
53 | const result = this._mergeDictionaries(translatedChunks);
54 | console.timeEnd(timeLabel);
55 | return result;
56 | }
57 |
58 | private static _chunkDictionary(
59 | dictionary: DictionarySchema,
60 | ): DictionarySchema[] {
61 | const MAX_ENTRIES_PER_CHUNK = 100;
62 | const { files, ...rest } = dictionary;
63 | const chunks: DictionarySchema[] = [];
64 |
65 | let currentChunk: DictionarySchema = {
66 | ...rest,
67 | files: {},
68 | };
69 | let currentEntryCount = 0;
70 |
71 | Object.entries(files).forEach(([fileName, file]) => {
72 | const entries = file.entries;
73 | const entryPairs = Object.entries(entries);
74 |
75 | let currentIndex = 0;
76 | while (currentIndex < entryPairs.length) {
77 | const remainingSpace = MAX_ENTRIES_PER_CHUNK - currentEntryCount;
78 | const entriesToAdd = entryPairs.slice(
79 | currentIndex,
80 | currentIndex + remainingSpace,
81 | );
82 |
83 | if (entriesToAdd.length > 0) {
84 | currentChunk.files[fileName] = currentChunk.files[fileName] || {
85 | entries: {},
86 | };
87 | currentChunk.files[fileName].entries = {
88 | ...currentChunk.files[fileName].entries,
89 | ...Object.fromEntries(entriesToAdd),
90 | };
91 | currentEntryCount += entriesToAdd.length;
92 | }
93 |
94 | currentIndex += entriesToAdd.length;
95 |
96 | if (
97 | currentEntryCount >= MAX_ENTRIES_PER_CHUNK ||
98 | (currentIndex < entryPairs.length &&
99 | currentEntryCount + (entryPairs.length - currentIndex) >
100 | MAX_ENTRIES_PER_CHUNK)
101 | ) {
102 | chunks.push(currentChunk);
103 | currentChunk = { ...rest, files: {} };
104 | currentEntryCount = 0;
105 | }
106 | }
107 | });
108 |
109 | if (currentEntryCount > 0) {
110 | chunks.push(currentChunk);
111 | }
112 |
113 | return chunks;
114 | }
115 |
116 | private static _mergeDictionaries(dictionaries: DictionarySchema[]) {
117 | const fileNames = _.uniq(
118 | _.flatMap(dictionaries, (dict) => Object.keys(dict.files)),
119 | );
120 | const files = _(fileNames)
121 | .map((fileName) => {
122 | const entries = dictionaries.reduce((entries, dict) => {
123 | const file = dict.files[fileName];
124 | if (file) {
125 | entries = _.merge(entries, file.entries);
126 | }
127 | return entries;
128 | }, {});
129 | return [fileName, { entries }];
130 | })
131 | .fromPairs()
132 | .value();
133 | const dictionary = {
134 | version: dictionaries[0].version,
135 | locale: dictionaries[0].locale,
136 | files,
137 | };
138 | return dictionary;
139 | }
140 |
141 | private static _createLingoDotDevEngine() {
142 | // Specific check for CI/CD or Docker missing GROQ key
143 | if (isRunningInCIOrDocker()) {
144 | const apiKeyFromEnv = getLingoDotDevKeyFromEnv();
145 | if (!apiKeyFromEnv) {
146 | this._failMissingLLMKeyCi("lingo.dev");
147 | }
148 | }
149 | const apiKey = getLingoDotDevKey();
150 | if (!apiKey) {
151 | throw new Error(
152 | "⚠️ Lingo.dev API key not found. Please set LINGODOTDEV_API_KEY environment variable or configure it user-wide.",
153 | );
154 | }
155 | console.log(`Creating Lingo.dev client`);
156 | return new LingoDotDevEngine({
157 | apiKey,
158 | });
159 | }
160 |
161 | private static async _translateChunk(
162 | models: "lingo.dev" | Record<string, string>,
163 | sourceDictionary: DictionarySchema,
164 | sourceLocale: string,
165 | targetLocale: string,
166 | prompt?: string | null,
167 | ): Promise<DictionarySchema> {
168 | if (models === "lingo.dev") {
169 | try {
170 | const lingoDotDevEngine = this._createLingoDotDevEngine();
171 |
172 | console.log(
173 | `✨ Using Lingo.dev Engine to localize from "${sourceLocale}" to "${targetLocale}"`,
174 | );
175 |
176 | const result = await lingoDotDevEngine.localizeObject(
177 | sourceDictionary,
178 | {
179 | sourceLocale: sourceLocale,
180 | targetLocale: targetLocale,
181 | },
182 | );
183 |
184 | return result as DictionarySchema;
185 | } catch (error) {
186 | this._failLLMFailureLocal(
187 | "lingo.dev",
188 | targetLocale,
189 | error instanceof Error ? error.message : "Unknown error",
190 | );
191 | // This throw is unreachable because the failure method exits,
192 | // but it helps satisfy the TypeScript compiler.
193 | throw error;
194 | }
195 | } else {
196 | const { provider, model } = getLocaleModel(
197 | models,
198 | sourceLocale,
199 | targetLocale,
200 | );
201 |
202 | if (!provider || !model) {
203 | throw new Error(
204 | dedent`
205 | 🚫 Lingo.dev Localization Engine Not Configured!
206 |
207 | The "models" parameter is missing or incomplete in your Lingo.dev configuration.
208 |
209 | 👉 To fix this, set the "models" parameter to either:
210 | • "lingo.dev" (for the default engine)
211 | • a map of locale-to-model, e.g. { "models": { "en:es": "openai:gpt-3.5-turbo" } }
212 |
213 | Example:
214 | {
215 | // ...
216 | "models": "lingo.dev"
217 | }
218 |
219 | For more details, see: https://lingo.dev/compiler
220 | To get help, join our Discord: https://lingo.dev/go/discord
221 | `,
222 | );
223 | }
224 |
225 | try {
226 | const aiModel = this._createAiModel(provider, model, targetLocale);
227 |
228 | console.log(
229 | `ℹ️ Using raw LLM API ("${provider}":"${model}") to translate from "${sourceLocale}" to "${targetLocale}"`,
230 | );
231 |
232 | const response = await generateText({
233 | model: aiModel,
234 | messages: [
235 | {
236 | role: "system",
237 | content: getSystemPrompt({
238 | sourceLocale,
239 | targetLocale,
240 | prompt: prompt ?? undefined,
241 | }),
242 | },
243 | ...shots.flatMap((shotsTuple) => [
244 | {
245 | role: "user" as const,
246 | content: obj2xml(shotsTuple[0]),
247 | },
248 | {
249 | role: "assistant" as const,
250 | content: obj2xml(shotsTuple[1]),
251 | },
252 | ]),
253 | {
254 | role: "user",
255 | content: obj2xml(sourceDictionary),
256 | },
257 | ],
258 | });
259 |
260 | console.log("Response text received for", targetLocale);
261 | let responseText = response.text;
262 | // Extract XML content
263 | responseText = responseText.substring(
264 | responseText.indexOf("<"),
265 | responseText.lastIndexOf(">") + 1,
266 | );
267 |
268 | return xml2obj(responseText);
269 | } catch (error) {
270 | this._failLLMFailureLocal(
271 | provider,
272 | targetLocale,
273 | error instanceof Error ? error.message : "Unknown error",
274 | );
275 | // This throw is unreachable because the failure method exits,
276 | // but it helps satisfy the TypeScript compiler.
277 | throw error;
278 | }
279 | }
280 | }
281 |
282 | /**
283 | * Instantiates an AI model based on provider and model ID.
284 | * Includes CI/CD API key checks.
285 | * @param providerId The ID of the AI provider (e.g., "groq", "google").
286 | * @param modelId The ID of the specific model (e.g., "llama3-8b-8192", "gemini-2.0-flash").
287 | * @param targetLocale The target locale being translated to (for logging/error messages).
288 | * @returns An instantiated AI LanguageModel.
289 | * @throws Error if the provider is not supported or API key is missing in CI/CD.
290 | */
291 | private static _createAiModel(
292 | providerId: string,
293 | modelId: string,
294 | targetLocale: string,
295 | ): LanguageModel {
296 | switch (providerId) {
297 | case "groq": {
298 | // Specific check for CI/CD or Docker missing GROQ key
299 | if (isRunningInCIOrDocker()) {
300 | const groqFromEnv = getGroqKeyFromEnv();
301 | if (!groqFromEnv) {
302 | this._failMissingLLMKeyCi(providerId);
303 | }
304 | }
305 | const groqKey = getGroqKey();
306 | if (!groqKey) {
307 | throw new Error(
308 | "⚠️ GROQ API key not found. Please set GROQ_API_KEY environment variable or configure it user-wide.",
309 | );
310 | }
311 | console.log(
312 | `Creating Groq client for ${targetLocale} using model ${modelId}`,
313 | );
314 | return createGroq({ apiKey: groqKey })(modelId);
315 | }
316 |
317 | case "google": {
318 | // Specific check for CI/CD or Docker missing Google key
319 | if (isRunningInCIOrDocker()) {
320 | const googleFromEnv = getGoogleKeyFromEnv();
321 | if (!googleFromEnv) {
322 | this._failMissingLLMKeyCi(providerId);
323 | }
324 | }
325 | const googleKey = getGoogleKey();
326 | if (!googleKey) {
327 | throw new Error(
328 | "⚠️ Google API key not found. Please set GOOGLE_API_KEY environment variable or configure it user-wide.",
329 | );
330 | }
331 | console.log(
332 | `Creating Google Generative AI client for ${targetLocale} using model ${modelId}`,
333 | );
334 | return createGoogleGenerativeAI({ apiKey: googleKey })(modelId);
335 | }
336 | case "openrouter": {
337 | // Specific check for CI/CD or Docker missing OpenRouter key
338 | if (isRunningInCIOrDocker()) {
339 | const openRouterFromEnv = getOpenRouterKeyFromEnv();
340 | if (!openRouterFromEnv) {
341 | this._failMissingLLMKeyCi(providerId);
342 | }
343 | }
344 | const openRouterKey = getOpenRouterKey();
345 | if (!openRouterKey) {
346 | throw new Error(
347 | "⚠️ OpenRouter API key not found. Please set OPENROUTER_API_KEY environment variable or configure it user-wide.",
348 | );
349 | }
350 | console.log(
351 | `Creating OpenRouter client for ${targetLocale} using model ${modelId}`,
352 | );
353 | return createOpenRouter({
354 | apiKey: openRouterKey,
355 | })(modelId);
356 | }
357 |
358 | case "ollama": {
359 | // No API key check needed for Ollama
360 | console.log(
361 | `Creating Ollama client for ${targetLocale} using model ${modelId} at default Ollama address`,
362 | );
363 | return createOllama()(modelId);
364 | }
365 |
366 | case "mistral": {
367 | // Specific check for CI/CD or Docker missing Mistral key
368 | if (isRunningInCIOrDocker()) {
369 | const mistralFromEnv = getMistralKeyFromEnv();
370 | if (!mistralFromEnv) {
371 | this._failMissingLLMKeyCi(providerId);
372 | }
373 | }
374 | const mistralKey = getMistralKey();
375 | if (!mistralKey) {
376 | throw new Error(
377 | "⚠️ Mistral API key not found. Please set MISTRAL_API_KEY environment variable or configure it user-wide.",
378 | );
379 | }
380 | console.log(
381 | `Creating Mistral client for ${targetLocale} using model ${modelId}`,
382 | );
383 | return createMistral({ apiKey: mistralKey })(modelId);
384 | }
385 |
386 | default: {
387 | throw new Error(
388 | `⚠️ Provider "${providerId}" for locale "${targetLocale}" is not supported. Only "groq", "google", "openrouter", "ollama", and "mistral" providers are supported at the moment.`,
389 | );
390 | }
391 | }
392 | }
393 |
394 | /**
395 | * Show an actionable error message and exit the process when the compiler
396 | * is running in CI/CD without a required LLM API key.
397 | * The message explains why this situation is unusual and how to fix it.
398 | * @param providerId The ID of the LLM provider whose key is missing.
399 | */
400 | private static _failMissingLLMKeyCi(providerId: string): never {
401 | let details = providerDetails[providerId];
402 | if (!details) {
403 | // Fallback for unsupported provider in failure message logic
404 | throw new Error(
405 | `Internal Error: Missing details for provider "${providerId}" when reporting missing key in CI/CD. You might be using an unsupported provider.`,
406 | );
407 | }
408 |
409 | const errorMessage = dedent`
410 | 💡 You're using Lingo.dev Localization Compiler, and it detected unlocalized components in your app.
411 |
412 | The compiler needs a ${details.name} API key to translate missing strings, but ${details.apiKeyEnvVar} is not set in the environment.
413 |
414 | This is unexpected: typically you run a full build locally, commit the generated translation files, and push them to CI/CD.
415 |
416 | However, If you want CI/CD to translate the new strings, provide the key with:
417 | • Session-wide: export ${details.apiKeyEnvVar}=<your-api-key>
418 | • Project-wide / CI: add ${details.apiKeyEnvVar}=<your-api-key> to your pipeline environment variables
419 |
420 | ⭐️ Also:
421 | 1. If you don't yet have a ${details.name} API key, get one for free at ${details.getKeyLink}
422 | 2. If you want to use a different LLM, update your configuration. Refer to documentation for help: https://lingo.dev/compiler
423 | 3. If the model you want to use isn't supported yet, raise an issue in our open-source repo: https://lingo.dev/go/gh
424 | `;
425 | console.log(errorMessage);
426 | throw new Error(`Missing ${details.name} API key in CI/CD environment.`);
427 | }
428 |
429 | /**
430 | * Show an actionable error message and exit the process when an LLM API call
431 | * fails during local compilation.
432 | * @param providerId The ID of the LLM provider that failed.
433 | * @param targetLocale The target locale being translated to.
434 | * @param errorMessage The error message received from the API.
435 | */
436 | private static _failLLMFailureLocal(
437 | providerId: string,
438 | targetLocale: string,
439 | errorMessage: string,
440 | ): never {
441 | const details = providerDetails[providerId];
442 | if (!details) {
443 | // Fallback
444 | throw new Error(
445 | `Internal Error: Missing details for provider "${providerId}" when reporting local failure. Original Error: ${errorMessage}`,
446 | );
447 | }
448 |
449 | const isInvalidApiKey = errorMessage.match("Invalid API Key"); // TODO: This may change per-provider, so might update this later
450 |
451 | if (isInvalidApiKey) {
452 | const message = dedent`
453 | ⚠️ Lingo.dev Compiler requires a valid ${details.name} API key to translate your application.
454 |
455 | It looks like you set ${details.name} API key but it is not valid. Please check your API key and try again.
456 |
457 | Error details from ${details.name} API: ${errorMessage}
458 |
459 | 👉 You can set the API key in one of the following ways:
460 | 1. User-wide: Run npx lingo.dev@latest config set ${details.apiKeyConfigKey} <your-api-key>
461 | 2. Project-wide: Add ${details.apiKeyEnvVar}=<your-api-key> to .env file in every project that uses Lingo.dev Localization Compiler
462 | 3 Session-wide: Run export ${details.apiKeyEnvVar}=<your-api-key> in your terminal before running the compiler to set the API key for the current session
463 |
464 | ⭐️ Also:
465 | 1. If you don't yet have a ${details.name} API key, get one for free at ${details.getKeyLink}
466 | 2. If you want to use a different LLM, raise an issue in our open-source repo: https://lingo.dev/go/gh
467 | 3. If you have questions, feature requests, or would like to contribute, join our Discord: https://lingo.dev/go/discord
468 | `;
469 | console.log(message);
470 | throw new Error(`Invalid ${details.name} API key.`);
471 | } else {
472 | const message = dedent`
473 | ⚠️ Lingo.dev Compiler tried to translate your application to "${targetLocale}" locale via ${
474 | details.name
475 | } but it failed.
476 |
477 | Error details from ${details.name} API: ${errorMessage}
478 |
479 | This error comes from the ${
480 | details.name
481 | } API, please check their documentation for more details: ${
482 | details.docsLink
483 | }
484 |
485 | ⭐️ Also:
486 | 1. Did you set ${
487 | details.apiKeyEnvVar
488 | ? `${details.apiKeyEnvVar}`
489 | : "the provider API key"
490 | } environment variable correctly ${
491 | !details.apiKeyEnvVar ? "(if required)" : ""
492 | }?
493 | 2. Did you reach any limits of your ${details.name} account?
494 | 3. If you have questions, feature requests, or would like to contribute, join our Discord: https://lingo.dev/go/discord
495 | `;
496 | console.log(message);
497 | throw new Error(
498 | `Translation failed for locale "${targetLocale}" using ${details.name}: ${errorMessage}`,
499 | );
500 | }
501 | }
502 | }
503 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/ignored-keys-buckets.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, vi } from "vitest";
2 | import fs from "fs/promises";
3 | import dedent from "dedent";
4 | import createBucketLoader from "./index";
5 |
6 | describe("ignored keys support across buckets", () => {
7 | beforeEach(() => {
8 | vi.clearAllMocks();
9 | vi.resetModules();
10 | setupFileMocks();
11 | });
12 |
13 | it("android: should omit ignored keys on pull", async () => {
14 | const input = `
15 | <resources>
16 | <string name="button.title">Submit</string>
17 | <string name="button.description">Description</string>
18 | </resources>
19 | `.trim();
20 | mockFileOperations(input);
21 |
22 | const loader = createBucketLoader(
23 | "android",
24 | "values-[locale]/strings.xml",
25 | { defaultLocale: "en" },
26 | [],
27 | [],
28 | ["button.description"],
29 | );
30 | loader.setDefaultLocale("en");
31 | const data = await loader.pull("en");
32 | expect(data).toEqual({ "button.title": "Submit" });
33 | });
34 |
35 | it("csv: should omit ignored keys on pull", async () => {
36 | const input = `id,en\nbutton.title,Submit\nbutton.description,Description`;
37 | mockFileOperations(input);
38 |
39 | const loader = createBucketLoader(
40 | "csv",
41 | "i18n.csv",
42 | { defaultLocale: "en" },
43 | [],
44 | [],
45 | ["button.description"],
46 | );
47 | loader.setDefaultLocale("en");
48 | const data = await loader.pull("en");
49 | expect(data).toEqual({ "button.title": "Submit" });
50 | });
51 |
52 | it("html: should omit ignored keys (by prefix) on pull", async () => {
53 | const input = dedent`
54 | <html>
55 | <head>
56 | <title>My Page</title>
57 | <meta name="description" content="Page description" />
58 | </head>
59 | <body>
60 | <h1>Hello</h1>
61 | <p>Paragraph</p>
62 | </body>
63 | </html>
64 | `;
65 | mockFileOperations(input);
66 |
67 | const loader = createBucketLoader(
68 | "html",
69 | "i18n/[locale].html",
70 | { defaultLocale: "en" },
71 | [],
72 | [],
73 | ["head"],
74 | );
75 | loader.setDefaultLocale("en");
76 | const data = await loader.pull("en");
77 | expect(Object.keys(data).some((k) => k.startsWith("head"))).toBe(false);
78 | });
79 |
80 | it("ejs: should omit ignored keys on pull", async () => {
81 | const input = `<h1>Welcome</h1><p>Hello <%= name %></p>`;
82 | mockFileOperations(input);
83 |
84 | const loader = createBucketLoader(
85 | "ejs",
86 | "templates/[locale].ejs",
87 | { defaultLocale: "en" },
88 | [],
89 | [],
90 | ["text_*"],
91 | );
92 | loader.setDefaultLocale("en");
93 | const data = await loader.pull("en");
94 | expect(data).toEqual({});
95 | });
96 |
97 | it("json: should omit ignored keys on pull", async () => {
98 | const input = JSON.stringify({ title: "Submit", description: "Desc" });
99 | mockFileOperations(input);
100 |
101 | const loader = createBucketLoader(
102 | "json",
103 | "i18n/[locale].json",
104 | { defaultLocale: "en" },
105 | [],
106 | [],
107 | ["description"],
108 | );
109 | loader.setDefaultLocale("en");
110 | const data = await loader.pull("en");
111 | expect(data).toEqual({ title: "Submit" });
112 | });
113 |
114 | it("json5: should omit ignored keys on pull", async () => {
115 | const input = `{
116 | // comment
117 | title: "Submit",
118 | description: "Desc"
119 | }`;
120 | mockFileOperations(input);
121 |
122 | const loader = createBucketLoader(
123 | "json5",
124 | "i18n/[locale].json5",
125 | { defaultLocale: "en" },
126 | [],
127 | [],
128 | ["description"],
129 | );
130 | loader.setDefaultLocale("en");
131 | const data = await loader.pull("en");
132 | expect(data).toEqual({ title: "Submit" });
133 | });
134 |
135 | it("jsonc: should omit ignored keys on pull", async () => {
136 | const input = `{
137 | // comment
138 | "title": "Submit",
139 | "description": "Desc"
140 | }`;
141 | mockFileOperations(input);
142 |
143 | const loader = createBucketLoader(
144 | "jsonc",
145 | "i18n/[locale].jsonc",
146 | { defaultLocale: "en" },
147 | [],
148 | [],
149 | ["description"],
150 | );
151 | loader.setDefaultLocale("en");
152 | const data = await loader.pull("en");
153 | expect(data).toEqual({ title: "Submit" });
154 | });
155 |
156 | it("markdown: should omit ignored keys (frontmatter) on pull", async () => {
157 | const input = dedent`
158 | ---
159 | title: Test Markdown
160 | date: 2023-05-25
161 | ---
162 |
163 | # Heading 1
164 |
165 | Content.
166 | `;
167 | mockFileOperations(input);
168 |
169 | const loader = createBucketLoader(
170 | "markdown",
171 | "i18n/[locale].md",
172 | { defaultLocale: "en" },
173 | [],
174 | [],
175 | ["fm-attr-title"],
176 | );
177 | loader.setDefaultLocale("en");
178 | const data = await loader.pull("en");
179 | expect(Object.keys(data)).not.toContain("fm-attr-title");
180 | });
181 |
182 | it("markdoc: should omit ignored keys by semantic prefix on pull", async () => {
183 | const input = dedent`
184 | ---
185 | title: My Page
186 | ---
187 |
188 | # Heading 1
189 |
190 | Hello world
191 | `;
192 | mockFileOperations(input);
193 |
194 | const loader = createBucketLoader(
195 | "markdoc",
196 | "docs/[locale].md",
197 | { defaultLocale: "en" },
198 | [],
199 | [],
200 | ["heading"],
201 | );
202 | loader.setDefaultLocale("en");
203 | const data = await loader.pull("en");
204 | expect(Object.keys(data).some((k) => k.startsWith("heading"))).toBe(false);
205 | });
206 |
207 | it("mdx: should omit ignored section keys on pull", async () => {
208 | const input = dedent`
209 | ---
210 | title: Hello
211 | ---
212 |
213 | # Title
214 |
215 | Paragraph
216 | `;
217 | mockFileOperations(input);
218 |
219 | const loader = createBucketLoader(
220 | "mdx",
221 | "i18n/[locale].mdx",
222 | { defaultLocale: "en", formatter: undefined },
223 | [],
224 | [],
225 | ["md-section-0"],
226 | );
227 | loader.setDefaultLocale("en");
228 | const data = await loader.pull("en");
229 | expect(Object.keys(data)).not.toContain("md-section-0");
230 | });
231 |
232 | it("po: should omit ignored keys on pull", async () => {
233 | const input = dedent`
234 | #: hello.py:1
235 | msgid "Hello"
236 | msgstr ""
237 | `;
238 | mockFileOperations(input);
239 |
240 | const loader = createBucketLoader(
241 | "po",
242 | "i18n/[locale].po",
243 | { defaultLocale: "en" },
244 | [],
245 | [],
246 | ["Hello"],
247 | );
248 | loader.setDefaultLocale("en");
249 | const data = await loader.pull("en");
250 | expect(data).toEqual({});
251 | });
252 |
253 | it("properties: should omit ignored keys on pull", async () => {
254 | const input = dedent`
255 | welcome.message=Welcome
256 | error.message=Error
257 | `;
258 | mockFileOperations(input);
259 |
260 | const loader = createBucketLoader(
261 | "properties",
262 | "i18n/[locale].properties",
263 | { defaultLocale: "en" },
264 | [],
265 | [],
266 | ["error.message"],
267 | );
268 | loader.setDefaultLocale("en");
269 | const data = await loader.pull("en");
270 | expect(data).toEqual({ "welcome.message": "Welcome" });
271 | });
272 |
273 | it("xcode-strings: should omit ignored keys on pull", async () => {
274 | const input = `"hello" = "Hello!";\n"bye" = "Bye!";`;
275 | mockFileOperations(input);
276 |
277 | const loader = createBucketLoader(
278 | "xcode-strings",
279 | "i18n/[locale].strings",
280 | { defaultLocale: "en" },
281 | [],
282 | [],
283 | ["bye"],
284 | );
285 | loader.setDefaultLocale("en");
286 | const data = await loader.pull("en");
287 | expect(data).toEqual({ hello: "Hello!" });
288 | });
289 |
290 | it("xcode-stringsdict: should omit ignored keys on pull", async () => {
291 | const input = dedent`
292 | <?xml version="1.0" encoding="UTF-8"?>
293 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
294 | <plist version="1.0">
295 | <dict>
296 | <key>greeting</key>
297 | <string>Hello!</string>
298 | <key>items_count</key>
299 | <dict>
300 | <key>NSStringLocalizedFormatKey</key>
301 | <string>%#@items@</string>
302 | <key>items</key>
303 | <dict>
304 | <key>NSStringFormatSpecTypeKey</key>
305 | <string>NSStringPluralRuleType</string>
306 | <key>NSStringFormatValueTypeKey</key>
307 | <string>d</string>
308 | <key>one</key>
309 | <string>%d item</string>
310 | <key>other</key>
311 | <string>%d items</string>
312 | </dict>
313 | </dict>
314 | </dict>
315 | </plist>
316 | `;
317 | mockFileOperations(input);
318 |
319 | const loader = createBucketLoader(
320 | "xcode-stringsdict",
321 | "i18n/[locale].stringsdict",
322 | { defaultLocale: "en" },
323 | [],
324 | [],
325 | ["items_count"],
326 | );
327 | loader.setDefaultLocale("en");
328 | const data = await loader.pull("en");
329 | expect(Object.keys(data)).toContain("greeting");
330 | expect(Object.keys(data).some((k) => k.startsWith("items_count"))).toBe(
331 | false,
332 | );
333 | });
334 |
335 | it("xcode-xcstrings: should omit ignored keys on pull", async () => {
336 | const input = dedent`
337 | {
338 | "sourceLanguage": "en",
339 | "strings": {
340 | "greeting": {
341 | "extractionState": "manual",
342 | "localizations": {
343 | "en": { "stringUnit": { "state": "translated", "value": "Hello!" } }
344 | }
345 | },
346 | "message": {
347 | "extractionState": "manual",
348 | "localizations": {
349 | "en": { "stringUnit": { "state": "translated", "value": "Welcome" } }
350 | }
351 | }
352 | }
353 | }
354 | `;
355 | mockFileOperations(input);
356 |
357 | const loader = createBucketLoader(
358 | "xcode-xcstrings",
359 | "i18n/[locale].xcstrings",
360 | { defaultLocale: "en" },
361 | [],
362 | [],
363 | ["message"],
364 | );
365 | loader.setDefaultLocale("en");
366 | const data = await loader.pull("en");
367 | expect(data).toEqual({ greeting: "Hello!" });
368 | });
369 |
370 | it("xcode-xcstrings-v2: should omit ignored string keys on pull", async () => {
371 | const input = dedent`
372 | {
373 | "sourceLanguage": "en",
374 | "strings": {
375 | "hello": {
376 | "extractionState": "manual",
377 | "localizations": {
378 | "en": { "stringUnit": { "state": "translated", "value": "Hello" } }
379 | }
380 | },
381 | "world": {
382 | "extractionState": "manual",
383 | "localizations": {
384 | "en": { "stringUnit": { "state": "translated", "value": "World" } }
385 | }
386 | }
387 | }
388 | }
389 | `;
390 | mockFileOperations(input);
391 |
392 | const loader = createBucketLoader(
393 | "xcode-xcstrings-v2",
394 | "i18n/[locale].xcstrings",
395 | { defaultLocale: "en" },
396 | [],
397 | [],
398 | ["world"],
399 | );
400 | loader.setDefaultLocale("en");
401 | const data = await loader.pull("en");
402 | expect(Object.keys(data)).toContain("hello");
403 | expect(Object.keys(data)).not.toContain("world");
404 | });
405 |
406 | it("yaml: should omit ignored keys on pull", async () => {
407 | const input = dedent`
408 | title: Submit
409 | description: Desc
410 | `;
411 | mockFileOperations(input);
412 |
413 | const loader = createBucketLoader(
414 | "yaml",
415 | "i18n/[locale].yml",
416 | { defaultLocale: "en" },
417 | [],
418 | [],
419 | ["description"],
420 | );
421 | loader.setDefaultLocale("en");
422 | const data = await loader.pull("en");
423 | expect(data).toEqual({ title: "Submit" });
424 | });
425 |
426 | it("yaml-root-key: should omit ignored keys on pull", async () => {
427 | const input = dedent`
428 | en:
429 | title: Submit
430 | description: Desc
431 | `;
432 | mockFileOperations(input);
433 |
434 | const loader = createBucketLoader(
435 | "yaml-root-key",
436 | "i18n/[locale].yml",
437 | { defaultLocale: "en" },
438 | [],
439 | [],
440 | ["description"],
441 | );
442 | loader.setDefaultLocale("en");
443 | const data = await loader.pull("en");
444 | expect(data).toEqual({ title: "Submit" });
445 | });
446 |
447 | it("flutter: should omit ignored keys on pull", async () => {
448 | const input = JSON.stringify(
449 | {
450 | "@@locale": "en",
451 | greeting: "Hello, {name}!",
452 | "@greeting": { description: "d" },
453 | farewell: "Goodbye!",
454 | },
455 | null,
456 | 2,
457 | );
458 | mockFileOperations(input);
459 |
460 | const loader = createBucketLoader(
461 | "flutter",
462 | "lib/l10n/app_[locale].arb",
463 | { defaultLocale: "en" },
464 | [],
465 | [],
466 | ["farewell"],
467 | );
468 | loader.setDefaultLocale("en");
469 | const data = await loader.pull("en");
470 | expect(Object.keys(data)).toContain("greeting");
471 | expect(Object.keys(data)).not.toContain("farewell");
472 | });
473 |
474 | it("xliff: should omit ignored keys on pull", async () => {
475 | const input = dedent`
476 | <?xml version="1.0" encoding="utf-8"?>
477 | <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
478 | <file original="" source-language="en" datatype="plaintext">
479 | <body>
480 | <trans-unit id="greeting" resname="greeting"><source>Hello</source></trans-unit>
481 | <trans-unit id="farewell" resname="farewell"><source>Goodbye</source></trans-unit>
482 | </body>
483 | </file>
484 | </xliff>
485 | `;
486 | mockFileOperations(input);
487 |
488 | const loader = createBucketLoader(
489 | "xliff",
490 | "i18n/[locale].xliff",
491 | { defaultLocale: "en" },
492 | [],
493 | [],
494 | ["farewell"],
495 | );
496 | loader.setDefaultLocale("en");
497 | const data = await loader.pull("en");
498 | expect(Object.keys(data)).toContain("greeting");
499 | expect(Object.keys(data)).not.toContain("farewell");
500 | });
501 |
502 | it("xml: should omit ignored keys on pull", async () => {
503 | const input = `<root><title>Hello</title><description>Desc</description></root>`;
504 | mockFileOperations(input);
505 |
506 | const loader = createBucketLoader(
507 | "xml",
508 | "i18n/[locale].xml",
509 | { defaultLocale: "en" },
510 | [],
511 | [],
512 | ["root/description"],
513 | );
514 | loader.setDefaultLocale("en");
515 | const data = await loader.pull("en");
516 | expect(Object.keys(data)).toContain("root/title");
517 | expect(Object.keys(data)).not.toContain("root/description");
518 | });
519 |
520 | it("srt: should omit ignored keys on pull", async () => {
521 | const input = dedent`
522 | 1
523 | 00:00:01,000 --> 00:00:04,000
524 | Hello
525 |
526 | 2
527 | 00:00:05,000 --> 00:00:06,000
528 | World
529 | `;
530 | mockFileOperations(input);
531 |
532 | const loader = createBucketLoader(
533 | "srt",
534 | "i18n/[locale].srt",
535 | { defaultLocale: "en" },
536 | [],
537 | [],
538 | ["1#*"],
539 | );
540 | loader.setDefaultLocale("en");
541 | const data = await loader.pull("en");
542 | // Expect only entry 2 remains
543 | const keys = Object.keys(data);
544 | expect(keys.length).toBe(1);
545 | expect(keys[0].startsWith("2#")).toBe(true);
546 | });
547 |
548 | it("vtt: should omit ignored keys on pull", async () => {
549 | const input = dedent`
550 | WEBVTT
551 |
552 | 00:00:00.000 --> 00:00:02.000
553 | First
554 |
555 | 00:00:02.000 --> 00:00:04.000
556 | Second
557 | `;
558 | mockFileOperations(input);
559 |
560 | const loader = createBucketLoader(
561 | "vtt",
562 | "i18n/[locale].vtt",
563 | { defaultLocale: "en" },
564 | [],
565 | [],
566 | ["0#*"],
567 | );
568 | loader.setDefaultLocale("en");
569 | const data = await loader.pull("en");
570 | // One cue should be filtered
571 | expect(Object.keys(data).length).toBe(1);
572 | });
573 |
574 | it("php: should omit ignored keys on pull", async () => {
575 | const input = dedent`
576 | <?php
577 | return [
578 | 'title' => 'Submit',
579 | 'description' => 'Desc',
580 | ];
581 | `;
582 | mockFileOperations(input);
583 |
584 | const loader = createBucketLoader(
585 | "php",
586 | "i18n/[locale].php",
587 | { defaultLocale: "en" },
588 | [],
589 | [],
590 | ["description"],
591 | );
592 | loader.setDefaultLocale("en");
593 | const data = await loader.pull("en");
594 | expect(data).toEqual({ title: "Submit" });
595 | });
596 |
597 | it("vue-json: should omit ignored keys on pull", async () => {
598 | const input = dedent`
599 | <template></template>
600 | <i18n>
601 | {"en": {"title": "Hello", "description": "Desc"}}
602 | </i18n>
603 | <script setup></script>
604 | `;
605 | mockFileOperations(input);
606 |
607 | const loader = createBucketLoader(
608 | "vue-json",
609 | "i18n/App.vue",
610 | { defaultLocale: "en" },
611 | [],
612 | [],
613 | ["description"],
614 | );
615 | loader.setDefaultLocale("en");
616 | const data = await loader.pull("en");
617 | expect(data).toEqual({ title: "Hello" });
618 | });
619 |
620 | it("typescript: should omit ignored keys on pull", async () => {
621 | const input = dedent`
622 | export default {
623 | title: "Submit",
624 | description: "Desc"
625 | };
626 | `;
627 | mockFileOperations(input);
628 |
629 | const loader = createBucketLoader(
630 | "typescript",
631 | "i18n/[locale].ts",
632 | { defaultLocale: "en" },
633 | [],
634 | [],
635 | ["description"],
636 | );
637 | loader.setDefaultLocale("en");
638 | const data = await loader.pull("en");
639 | expect(data).toEqual({ title: "Submit" });
640 | });
641 |
642 | it("txt: should omit ignored keys on pull", async () => {
643 | const input = dedent`
644 | First line
645 | Second line
646 | `;
647 | mockFileOperations(input);
648 |
649 | const loader = createBucketLoader(
650 | "txt",
651 | "fastlane/metadata/[locale]/description.txt",
652 | { defaultLocale: "en" },
653 | [],
654 | [],
655 | ["1"],
656 | );
657 | loader.setDefaultLocale("en");
658 | const data = await loader.pull("en");
659 | expect(Object.keys(data)).toEqual(["2"]);
660 | });
661 |
662 | it("json-dictionary: should omit ignored keys on pull (wildcard)", async () => {
663 | const input = JSON.stringify(
664 | {
665 | title: { en: "Title" },
666 | pages: [
667 | {
668 | elements: [
669 | { title: { en: "E1" }, description: { en: "D1" } },
670 | { title: { en: "E2" }, description: { en: "D2" } },
671 | ],
672 | },
673 | ],
674 | },
675 | null,
676 | 2,
677 | );
678 | mockFileOperations(input);
679 |
680 | const loader = createBucketLoader(
681 | "json-dictionary",
682 | "i18n/[locale].json",
683 | { defaultLocale: "en" },
684 | [],
685 | [],
686 | ["pages/*/elements/*/description"],
687 | );
688 | loader.setDefaultLocale("en");
689 | const data = await loader.pull("en");
690 | const keys = Object.keys(data);
691 | expect(keys).toContain("title");
692 | expect(keys).toContain("pages/0/elements/0/title");
693 | expect(keys.find((k) => k.includes("/description"))).toBeUndefined();
694 | });
695 | });
696 |
697 | function setupFileMocks() {
698 | vi.mock("fs/promises", () => ({
699 | default: {
700 | readFile: vi.fn(),
701 | writeFile: vi.fn(),
702 | mkdir: vi.fn(),
703 | access: vi.fn(),
704 | },
705 | }));
706 |
707 | vi.mock("path", () => ({
708 | default: {
709 | resolve: vi.fn((path) => path),
710 | dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/")),
711 | },
712 | }));
713 | }
714 |
715 | function mockFileOperations(input: string) {
716 | (fs.access as any).mockImplementation(() => Promise.resolve());
717 | (fs.readFile as any).mockImplementation(() => Promise.resolve(input));
718 | (fs.writeFile as any).mockImplementation(() => Promise.resolve());
719 | }
720 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/xcode-xcstrings.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import createXcodeXcstringsLoader, { _removeLocale } from "./xcode-xcstrings";
3 |
4 | describe("loaders/xcode-xcstrings", () => {
5 | const defaultLocale = "en";
6 | const mockInput = {
7 | sourceLanguage: "en",
8 | strings: {
9 | "app.title": {
10 | localizations: {
11 | en: {
12 | stringUnit: {
13 | state: "translated",
14 | value: "My App",
15 | },
16 | },
17 | es: {
18 | stringUnit: {
19 | state: "translated",
20 | value: "Mi App",
21 | },
22 | },
23 | },
24 | },
25 | "items.count": {
26 | localizations: {
27 | en: {
28 | variations: {
29 | plural: {
30 | one: {
31 | stringUnit: {
32 | state: "translated",
33 | value: "1 item",
34 | },
35 | },
36 | other: {
37 | stringUnit: {
38 | state: "translated",
39 | value: "%d items",
40 | },
41 | },
42 | },
43 | },
44 | },
45 | es: {
46 | variations: {
47 | plural: {
48 | one: {
49 | stringUnit: {
50 | state: "translated",
51 | value: "1 artículo",
52 | },
53 | },
54 | other: {
55 | stringUnit: {
56 | state: "translated",
57 | value: "%d artículos",
58 | },
59 | },
60 | },
61 | },
62 | },
63 | },
64 | },
65 | "key.no-translate": {
66 | shouldTranslate: false,
67 | localizations: {
68 | en: {
69 | stringUnit: {
70 | state: "translated",
71 | value: "Do not translate",
72 | },
73 | },
74 | },
75 | },
76 | "key.source-only": {
77 | localizations: {},
78 | },
79 | "key.missing-localization": {
80 | localizations: {
81 | es: {
82 | stringUnit: {
83 | state: "translated",
84 | value: "solo español",
85 | },
86 | },
87 | },
88 | },
89 | },
90 | version: "1.0",
91 | };
92 |
93 | describe("pull", () => {
94 | it("should pull simple string translations for a given locale", async () => {
95 | const loader = createXcodeXcstringsLoader(defaultLocale);
96 | loader.setDefaultLocale(defaultLocale);
97 | await loader.pull(defaultLocale, mockInput);
98 | const result = await loader.pull("es", mockInput);
99 | expect(result).toEqual({
100 | "app.title": "Mi App",
101 | "items.count": {
102 | one: "1 artículo",
103 | other: "%d artículos",
104 | },
105 | "key.missing-localization": "solo español",
106 | });
107 | });
108 |
109 | it("should pull plural translations for a given locale", async () => {
110 | const loader = createXcodeXcstringsLoader(defaultLocale);
111 | loader.setDefaultLocale(defaultLocale);
112 | const result = await loader.pull("en", mockInput);
113 | expect(result["items.count"]).toEqual({
114 | one: "1 item",
115 | other: "%d items",
116 | });
117 | });
118 |
119 | it("should use the key as value for the source language if no translation is available", async () => {
120 | const loader = createXcodeXcstringsLoader(defaultLocale);
121 | loader.setDefaultLocale(defaultLocale);
122 | const result = await loader.pull("en", mockInput);
123 | expect(result["key.source-only"]).toBe("key.source-only");
124 | expect(result["key.missing-localization"]).toBe(
125 | "key.missing-localization",
126 | );
127 | });
128 |
129 | it("should not use key as value if not source language", async () => {
130 | const loader = createXcodeXcstringsLoader(defaultLocale);
131 | loader.setDefaultLocale(defaultLocale);
132 | await loader.pull(defaultLocale, mockInput);
133 | const result = await loader.pull("es", mockInput);
134 | expect(result["key.source-only"]).toBeUndefined();
135 | });
136 |
137 | it("should skip keys marked with shouldTranslate: false", async () => {
138 | const loader = createXcodeXcstringsLoader(defaultLocale);
139 | loader.setDefaultLocale(defaultLocale);
140 | const result = await loader.pull("en", mockInput);
141 | expect(result["key.no-translate"]).toBeUndefined();
142 | });
143 |
144 | it("should return an empty object for a locale with no translations", async () => {
145 | const loader = createXcodeXcstringsLoader(defaultLocale);
146 | loader.setDefaultLocale(defaultLocale);
147 | await loader.pull(defaultLocale, mockInput);
148 | const result = await loader.pull("fr", mockInput);
149 | expect(result).toEqual({});
150 | });
151 | });
152 |
153 | describe("push", () => {
154 | it("should push simple string translations", async () => {
155 | const loader = createXcodeXcstringsLoader(defaultLocale);
156 | loader.setDefaultLocale(defaultLocale);
157 | await loader.pull(defaultLocale, mockInput);
158 | const payload = {
159 | "app.title": "Mon App",
160 | };
161 | const result = await loader.push("fr", payload);
162 | expect(result).not.toBeNull();
163 | expect(result!.version).toBe("1.0");
164 | expect(result!.strings["app.title"].localizations.fr).toEqual({
165 | stringUnit: {
166 | state: "translated",
167 | value: "Mon App",
168 | },
169 | });
170 | });
171 |
172 | it("should push plural translations in plain object format", async () => {
173 | const loader = createXcodeXcstringsLoader(defaultLocale);
174 | loader.setDefaultLocale(defaultLocale);
175 | await loader.pull(defaultLocale, mockInput);
176 | const payload = {
177 | "items.count": {
178 | one: "1 article",
179 | other: "%d articles",
180 | },
181 | };
182 | const result = await loader.push("fr", payload);
183 | expect(result).not.toBeNull();
184 | expect(result!.strings["items.count"].localizations.fr).toEqual({
185 | variations: {
186 | plural: {
187 | one: {
188 | stringUnit: {
189 | state: "translated",
190 | value: "1 article",
191 | },
192 | },
193 | other: {
194 | stringUnit: {
195 | state: "translated",
196 | value: "%d articles",
197 | },
198 | },
199 | },
200 | },
201 | });
202 | });
203 |
204 | it("should merge translations into existing input", async () => {
205 | const loader = createXcodeXcstringsLoader(defaultLocale);
206 | loader.setDefaultLocale(defaultLocale);
207 | await loader.pull(defaultLocale, mockInput);
208 | const payload = {
209 | "app.title": "Mi App (actualizado)",
210 | };
211 | const result = await loader.push("es", payload);
212 | expect(result).not.toBeNull();
213 | // check new value
214 | expect(
215 | result!.strings["app.title"].localizations.es.stringUnit.value,
216 | ).toBe("Mi App (actualizado)");
217 | // check existing value is untouched
218 | expect(
219 | result!.strings["app.title"].localizations.en.stringUnit.value,
220 | ).toBe("My App");
221 | });
222 |
223 | it("should preserve the shouldTranslate: false flag", async () => {
224 | const loader = createXcodeXcstringsLoader(defaultLocale);
225 | loader.setDefaultLocale(defaultLocale);
226 | await loader.pull(defaultLocale, mockInput);
227 | const payload = {
228 | "key.no-translate": "Ne pas traduire",
229 | };
230 | const result = await loader.push("fr", payload);
231 | expect(result).not.toBeNull();
232 | expect(result!.strings["key.no-translate"].shouldTranslate).toBe(false);
233 | expect(
234 | result!.strings["key.no-translate"].localizations.fr.stringUnit.value,
235 | ).toBe("Ne pas traduire");
236 | });
237 |
238 | it("should handle pushing to a null or undefined originalInput", async () => {
239 | const loader = createXcodeXcstringsLoader(defaultLocale);
240 | loader.setDefaultLocale(defaultLocale);
241 | await loader.pull(defaultLocale, { strings: {} });
242 | const payload = {
243 | greeting: "Hello",
244 | };
245 | const result = await loader.push("en", payload);
246 | expect(result).toEqual({
247 | strings: {
248 | greeting: {
249 | localizations: {
250 | en: {
251 | stringUnit: {
252 | state: "translated",
253 | value: "Hello",
254 | },
255 | },
256 | },
257 | },
258 | },
259 | });
260 | });
261 |
262 | it("should skip null and undefined values in payload", async () => {
263 | const loader = createXcodeXcstringsLoader(defaultLocale);
264 | loader.setDefaultLocale(defaultLocale);
265 | await loader.pull(defaultLocale, mockInput);
266 | const payload = {
267 | "app.title": "new title",
268 | "key.null": null,
269 | "key.undefined": undefined,
270 | };
271 | const result = await loader.push("en", payload);
272 | expect(result).not.toBeNull();
273 | expect(Object.keys(result!.strings)).not.toContain("key.null");
274 | expect(Object.keys(result!.strings)).not.toContain("key.undefined");
275 | expect(
276 | result!.strings["app.title"].localizations.en.stringUnit.value,
277 | ).toBe("new title");
278 | });
279 |
280 | it("should remove the pushed locale from original input", async () => {
281 | const loader = createXcodeXcstringsLoader(defaultLocale);
282 | loader.setDefaultLocale(defaultLocale);
283 | await loader.pull(defaultLocale, mockInput);
284 | const payload = {
285 | "app.title": "new title",
286 | };
287 | const result = await loader.push("en", payload);
288 | expect(result).not.toBeNull();
289 | expect(result!.strings["app.title"].localizations.en.stringUnit).toEqual({
290 | state: "translated",
291 | value: "new title",
292 | });
293 | expect(result!.strings["items.count"].localizations.en).toBeUndefined();
294 | expect(
295 | result!.strings["key.no-translate"].localizations.en,
296 | ).toBeUndefined();
297 | expect(
298 | result!.strings["key.source-only"].localizations.en,
299 | ).toBeUndefined();
300 | expect(
301 | result!.strings["key.missing-localization"].localizations.en,
302 | ).toBeUndefined();
303 | });
304 | });
305 |
306 | describe("_removeLocale", () => {
307 | it("should remove the locale from the input", () => {
308 | const input = {
309 | sourceLanguage: "en",
310 | strings: {
311 | key1: {
312 | localizations: {
313 | en: { stringUnit: { state: "translated", value: "Hello" } },
314 | es: { stringUnit: { state: "translated", value: "Hola" } },
315 | },
316 | },
317 | key2: {
318 | localizations: {
319 | en: { stringUnit: { state: "translated", value: "World" } },
320 | fr: { stringUnit: { state: "translated", value: "Monde" } },
321 | },
322 | },
323 | key3: {
324 | localizations: {
325 | en: {
326 | variations: {
327 | plural: {
328 | one: {
329 | stringUnit: { state: "translated", value: "1 item" },
330 | },
331 | },
332 | },
333 | },
334 | fr: {
335 | variations: {
336 | plural: {
337 | one: {
338 | stringUnit: { state: "translated", value: "1 article" },
339 | },
340 | },
341 | },
342 | },
343 | },
344 | },
345 | },
346 | };
347 | const result = _removeLocale(input, "en");
348 | expect(result).toEqual({
349 | sourceLanguage: "en",
350 | strings: {
351 | key1: {
352 | localizations: {
353 | es: { stringUnit: { state: "translated", value: "Hola" } },
354 | },
355 | },
356 | key2: {
357 | localizations: {
358 | fr: { stringUnit: { state: "translated", value: "Monde" } },
359 | },
360 | },
361 | key3: {
362 | localizations: {
363 | fr: {
364 | variations: {
365 | plural: {
366 | one: {
367 | stringUnit: { state: "translated", value: "1 article" },
368 | },
369 | },
370 | },
371 | },
372 | },
373 | },
374 | },
375 | });
376 | });
377 |
378 | it("should do nothing if the locale does not exist", () => {
379 | const input = {
380 | sourceLanguage: "en",
381 | strings: {
382 | key1: {
383 | localizations: {
384 | en: { stringUnit: { state: "translated", value: "Hello" } },
385 | es: { stringUnit: { state: "translated", value: "Hola" } },
386 | },
387 | },
388 | },
389 | };
390 | const result = _removeLocale(input, "fr");
391 | expect(result).toEqual({
392 | sourceLanguage: "en",
393 | strings: {
394 | key1: {
395 | localizations: {
396 | en: { stringUnit: { state: "translated", value: "Hello" } },
397 | es: { stringUnit: { state: "translated", value: "Hola" } },
398 | },
399 | },
400 | },
401 | });
402 | });
403 |
404 | it("should handle empty strings object", () => {
405 | const input = {
406 | sourceLanguage: "en",
407 | strings: {},
408 | };
409 | const result = _removeLocale(input, "en");
410 | expect(result).toEqual({
411 | sourceLanguage: "en",
412 | strings: {},
413 | });
414 | });
415 |
416 | it("should handle keys with no localizations", () => {
417 | const input = {
418 | sourceLanguage: "en",
419 | strings: {
420 | key1: {
421 | localizations: {},
422 | },
423 | },
424 | };
425 | const result = _removeLocale(input, "en");
426 | expect(result).toEqual({
427 | sourceLanguage: "en",
428 | strings: {
429 | key1: {
430 | localizations: {},
431 | },
432 | },
433 | });
434 | });
435 | });
436 |
437 | describe("pullHints", () => {
438 | it("should extract comments from xcstrings format", async () => {
439 | const inputWithComments = {
440 | sourceLanguage: "en",
441 | strings: {
442 | welcome_message: {
443 | comment: "Greeting shown on the main screen",
444 | extractionState: "manual",
445 | localizations: {
446 | en: {
447 | stringUnit: {
448 | state: "translated",
449 | value: "Welcome!",
450 | },
451 | },
452 | },
453 | },
454 | user_count: {
455 | comment: "Number of active users",
456 | extractionState: "manual",
457 | localizations: {
458 | en: {
459 | variations: {
460 | plural: {
461 | one: {
462 | stringUnit: {
463 | state: "translated",
464 | value: "1 user",
465 | },
466 | },
467 | other: {
468 | stringUnit: {
469 | state: "translated",
470 | value: "%d users",
471 | },
472 | },
473 | },
474 | },
475 | },
476 | },
477 | },
478 | no_comment_key: {
479 | extractionState: "manual",
480 | localizations: {
481 | en: {
482 | stringUnit: {
483 | state: "translated",
484 | value: "No comment",
485 | },
486 | },
487 | },
488 | },
489 | },
490 | };
491 |
492 | const loader = createXcodeXcstringsLoader(defaultLocale);
493 | loader.setDefaultLocale(defaultLocale);
494 | await loader.pull(defaultLocale, inputWithComments);
495 |
496 | const hints = await loader.pullHints(inputWithComments);
497 |
498 | expect(hints).toEqual({
499 | welcome_message: { hint: "Greeting shown on the main screen" },
500 | user_count: { hint: "Number of active users" },
501 | "user_count/one": { hint: "Number of active users" },
502 | "user_count/other": { hint: "Number of active users" },
503 | });
504 | });
505 |
506 | it("should handle empty input", async () => {
507 | const loader = createXcodeXcstringsLoader(defaultLocale);
508 | loader.setDefaultLocale(defaultLocale);
509 |
510 | const hints1 = await loader.pullHints({});
511 | expect(hints1).toEqual({});
512 |
513 | const hints2 = await loader.pullHints(null as any);
514 | expect(hints2).toEqual({});
515 |
516 | const hints3 = await loader.pullHints(undefined as any);
517 | expect(hints3).toEqual({});
518 | });
519 |
520 | it("should handle xcstrings without comments", async () => {
521 | const loader = createXcodeXcstringsLoader(defaultLocale);
522 | loader.setDefaultLocale(defaultLocale);
523 | await loader.pull(defaultLocale, mockInput);
524 |
525 | const hints = await loader.pullHints(mockInput);
526 | expect(hints).toEqual({});
527 | });
528 |
529 | it("should handle strings with only some having comments", async () => {
530 | const inputWithMixedComments = {
531 | sourceLanguage: "en",
532 | strings: {
533 | with_comment: {
534 | comment: "This has a comment",
535 | localizations: {
536 | en: {
537 | stringUnit: {
538 | state: "translated",
539 | value: "Value with comment",
540 | },
541 | },
542 | },
543 | },
544 | without_comment: {
545 | localizations: {
546 | en: {
547 | stringUnit: {
548 | state: "translated",
549 | value: "Value without comment",
550 | },
551 | },
552 | },
553 | },
554 | },
555 | };
556 |
557 | const loader = createXcodeXcstringsLoader(defaultLocale);
558 | loader.setDefaultLocale(defaultLocale);
559 | await loader.pull(defaultLocale, inputWithMixedComments);
560 |
561 | const hints = await loader.pullHints(inputWithMixedComments);
562 |
563 | expect(hints).toEqual({
564 | with_comment: { hint: "This has a comment" },
565 | });
566 | });
567 |
568 | it("should handle multiple locales with same comment", async () => {
569 | const inputWithMultipleLocales = {
570 | sourceLanguage: "en",
571 | strings: {
572 | multi_locale: {
573 | comment: "Available in multiple languages",
574 | localizations: {
575 | en: {
576 | stringUnit: {
577 | state: "translated",
578 | value: "English",
579 | },
580 | },
581 | es: {
582 | stringUnit: {
583 | state: "translated",
584 | value: "Español",
585 | },
586 | },
587 | fr: {
588 | variations: {
589 | plural: {
590 | one: {
591 | stringUnit: {
592 | state: "translated",
593 | value: "1 français",
594 | },
595 | },
596 | other: {
597 | stringUnit: {
598 | state: "translated",
599 | value: "%d français",
600 | },
601 | },
602 | },
603 | },
604 | },
605 | },
606 | },
607 | },
608 | };
609 |
610 | const loader = createXcodeXcstringsLoader(defaultLocale);
611 | loader.setDefaultLocale(defaultLocale);
612 | await loader.pull(defaultLocale, inputWithMultipleLocales);
613 |
614 | const hints = await loader.pullHints(inputWithMultipleLocales);
615 |
616 | expect(hints).toEqual({
617 | multi_locale: { hint: "Available in multiple languages" },
618 | "multi_locale/one": { hint: "Available in multiple languages" },
619 | "multi_locale/other": { hint: "Available in multiple languages" },
620 | });
621 | });
622 | });
623 | });
624 |
```
--------------------------------------------------------------------------------
/demo/next-app/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # next-app
2 |
3 | ## 0.2.81
4 |
5 | ### Patch Changes
6 |
7 | - Updated dependencies [[`68fb3ea`](https://github.com/lingodotdev/lingo.dev/commit/68fb3ea64fc0191ecee66403432e0c8efabab2b9)]:
8 | - [email protected]
9 |
10 | ## 0.2.80
11 |
12 | ### Patch Changes
13 |
14 | - Updated dependencies [[`e70385b`](https://github.com/lingodotdev/lingo.dev/commit/e70385bd1ac676bf5bd31b212d8510e6b7ebf793)]:
15 | - [email protected]
16 |
17 | ## 0.2.79
18 |
19 | ### Patch Changes
20 |
21 | - Updated dependencies [[`f7215c1`](https://github.com/lingodotdev/lingo.dev/commit/f7215c1e435378aac8fc953765335cd478cbf507)]:
22 | - [email protected]
23 |
24 | ## 0.2.78
25 |
26 | ### Patch Changes
27 |
28 | - Updated dependencies [[`898bd36`](https://github.com/lingodotdev/lingo.dev/commit/898bd36cc2e444641560d2ad2b28065a57072183)]:
29 | - [email protected]
30 |
31 | ## 0.2.77
32 |
33 | ### Patch Changes
34 |
35 | - Updated dependencies [[`060680c`](https://github.com/lingodotdev/lingo.dev/commit/060680cd13c05dd77dd9d5447c064d948bd21cb0), [`f102356`](https://github.com/lingodotdev/lingo.dev/commit/f102356e1ea12c800399ac11f074c42708c304b1), [`a956e53`](https://github.com/lingodotdev/lingo.dev/commit/a956e537d0d45565c3243dd0c5ba4eec8bed69c6), [`3fd38c2`](https://github.com/lingodotdev/lingo.dev/commit/3fd38c2d38e4b22dcd824c865fe31abbc56bc862)]:
36 | - [email protected]
37 |
38 | ## 0.2.76
39 |
40 | ### Patch Changes
41 |
42 | - Updated dependencies [[`03671f7`](https://github.com/lingodotdev/lingo.dev/commit/03671f7cb252d6bee3debce2f4a4eb989dc0050b)]:
43 | - [email protected]
44 |
45 | ## 0.2.75
46 |
47 | ### Patch Changes
48 |
49 | - Updated dependencies [[`4f5ffe6`](https://github.com/lingodotdev/lingo.dev/commit/4f5ffe62189949bb26a6c7825cb72c217aefa32f)]:
50 | - [email protected]
51 |
52 | ## 0.2.74
53 |
54 | ### Patch Changes
55 |
56 | - Updated dependencies [[`be8de32`](https://github.com/lingodotdev/lingo.dev/commit/be8de3280bb5dc5f409fc7680c0e5ff6a53e2fe5)]:
57 | - [email protected]
58 |
59 | ## 0.2.73
60 |
61 | ### Patch Changes
62 |
63 | - Updated dependencies [[`79c4c00`](https://github.com/lingodotdev/lingo.dev/commit/79c4c00108b9c102cf53e1c090b286070a43e3d5)]:
64 | - [email protected]
65 |
66 | ## 0.2.72
67 |
68 | ### Patch Changes
69 |
70 | - Updated dependencies [[`b45347c`](https://github.com/lingodotdev/lingo.dev/commit/b45347c38572ee371b2bc494261b7e3e90c4aed1)]:
71 | - [email protected]
72 |
73 | ## 0.2.71
74 |
75 | ### Patch Changes
76 |
77 | - Updated dependencies [[`74d8efe`](https://github.com/lingodotdev/lingo.dev/commit/74d8efef8d4789f9baa5b7837e053c2571df0308)]:
78 | - [email protected]
79 |
80 | ## 0.2.70
81 |
82 | ### Patch Changes
83 |
84 | - Updated dependencies [[`3d3c3d7`](https://github.com/lingodotdev/lingo.dev/commit/3d3c3d783a61443da50a5d182391db33a0d29c84)]:
85 | - [email protected]
86 |
87 | ## 0.2.69
88 |
89 | ### Patch Changes
90 |
91 | - Updated dependencies [[`38139c8`](https://github.com/lingodotdev/lingo.dev/commit/38139c81a85001739cece60873c0c6ad711327a4)]:
92 | - [email protected]
93 |
94 | ## 0.2.68
95 |
96 | ### Patch Changes
97 |
98 | - Updated dependencies [[`3413dad`](https://github.com/lingodotdev/lingo.dev/commit/3413dad22af688a6d26649c4f25e18304b3caee6)]:
99 | - [email protected]
100 |
101 | ## 0.2.67
102 |
103 | ### Patch Changes
104 |
105 | - Updated dependencies [[`26d2ec1`](https://github.com/lingodotdev/lingo.dev/commit/26d2ec155c5868a5bdce1027cd76a5a2d4f8f2b1)]:
106 | - [email protected]
107 |
108 | ## 0.2.66
109 |
110 | ### Patch Changes
111 |
112 | - Updated dependencies [[`82f5e7c`](https://github.com/lingodotdev/lingo.dev/commit/82f5e7cdde9a2a15b4c2a7fcb8c67ed64eab596b), [`e858174`](https://github.com/lingodotdev/lingo.dev/commit/e858174fd5165e0ea3e3f25fa1fc3edb292bc58f)]:
113 | - [email protected]
114 |
115 | ## 0.2.65
116 |
117 | ### Patch Changes
118 |
119 | - Updated dependencies [[`f3d4987`](https://github.com/lingodotdev/lingo.dev/commit/f3d4987ddc393c28d488f030c087f3e99a667975), [`a933b81`](https://github.com/lingodotdev/lingo.dev/commit/a933b8102763e0481f088c847da53e0eee3f0617)]:
120 | - [email protected]
121 |
122 | ## 0.2.64
123 |
124 | ### Patch Changes
125 |
126 | - Updated dependencies []:
127 | - [email protected]
128 |
129 | ## 0.2.63
130 |
131 | ### Patch Changes
132 |
133 | - Updated dependencies [[`dd0663f`](https://github.com/lingodotdev/lingo.dev/commit/dd0663fdcdd0ff4fd5748386758a8c20f9e52a4b)]:
134 | - [email protected]
135 |
136 | ## 0.2.62
137 |
138 | ### Patch Changes
139 |
140 | - Updated dependencies [[`762396b`](https://github.com/lingodotdev/lingo.dev/commit/762396bb37110dbe3e4e000edb27892b318aa3ef)]:
141 | - [email protected]
142 |
143 | ## 0.2.61
144 |
145 | ### Patch Changes
146 |
147 | - Updated dependencies [[`468a59b`](https://github.com/lingodotdev/lingo.dev/commit/468a59b89736c72253b1f32abbf30a950e5434ec)]:
148 | - [email protected]
149 |
150 | ## 0.2.60
151 |
152 | ### Patch Changes
153 |
154 | - Updated dependencies [[`bbc71b9`](https://github.com/lingodotdev/lingo.dev/commit/bbc71b9948ccc289c9669d8b0c276c9596f6a5e7)]:
155 | - [email protected]
156 |
157 | ## 0.2.59
158 |
159 | ### Patch Changes
160 |
161 | - Updated dependencies [[`0e6d605`](https://github.com/lingodotdev/lingo.dev/commit/0e6d605a9ad6835bef26c40895760c652a69b7a2)]:
162 | - [email protected]
163 |
164 | ## 0.2.58
165 |
166 | ### Patch Changes
167 |
168 | - Updated dependencies [[`03138da`](https://github.com/lingodotdev/lingo.dev/commit/03138dac37e869e2e99702ffd3c76532f1c58aa6), [`9557fe5`](https://github.com/lingodotdev/lingo.dev/commit/9557fe572d3e4a1a4d8c1e35417fe3b7531c3d52)]:
169 | - [email protected]
170 |
171 | ## 0.2.57
172 |
173 | ### Patch Changes
174 |
175 | - Updated dependencies [[`64225d0`](https://github.com/lingodotdev/lingo.dev/commit/64225d073999d599ba86f65fee8e08e3e5f2800b)]:
176 | - [email protected]
177 |
178 | ## 0.2.56
179 |
180 | ### Patch Changes
181 |
182 | - Updated dependencies []:
183 | - [email protected]
184 |
185 | ## 0.2.55
186 |
187 | ### Patch Changes
188 |
189 | - Updated dependencies [[`88b7e31`](https://github.com/lingodotdev/lingo.dev/commit/88b7e3132c77d0a1e823de4ee6ef5a96a3098b97)]:
190 | - [email protected]
191 |
192 | ## 0.2.54
193 |
194 | ### Patch Changes
195 |
196 | - Updated dependencies [[`d9294c0`](https://github.com/lingodotdev/lingo.dev/commit/d9294c0bbb993454ad3654f77dd48d82211e0465)]:
197 | - [email protected]
198 |
199 | ## 0.2.53
200 |
201 | ### Patch Changes
202 |
203 | - Updated dependencies [[`100b141`](https://github.com/lingodotdev/lingo.dev/commit/100b141d2143e33b603830475ba55089dc421e3d)]:
204 | - [email protected]
205 |
206 | ## 0.2.52
207 |
208 | ### Patch Changes
209 |
210 | - Updated dependencies [[`8741a20`](https://github.com/lingodotdev/lingo.dev/commit/8741a20dcaa3983131a1919f875dd2c264cb29fb)]:
211 | - [email protected]
212 |
213 | ## 0.2.51
214 |
215 | ### Patch Changes
216 |
217 | - Updated dependencies [[`bd3f69d`](https://github.com/lingodotdev/lingo.dev/commit/bd3f69dde76814146f775bc87241fa2fad012ab0)]:
218 | - [email protected]
219 |
220 | ## 0.2.50
221 |
222 | ### Patch Changes
223 |
224 | - Updated dependencies [[`6c174c3`](https://github.com/lingodotdev/lingo.dev/commit/6c174c38f3cf28c2af24ead18503658c3c641026)]:
225 | - [email protected]
226 |
227 | ## 0.2.49
228 |
229 | ### Patch Changes
230 |
231 | - Updated dependencies [[`3a642f3`](https://github.com/lingodotdev/lingo.dev/commit/3a642f33c04378706a8382aa0fde36e747fd6af5)]:
232 | - [email protected]
233 |
234 | ## 0.2.48
235 |
236 | ### Patch Changes
237 |
238 | - Updated dependencies [[`bc7b08e`](https://github.com/lingodotdev/lingo.dev/commit/bc7b08ef1245d1af0c68813cb18193d4f14bc7e0)]:
239 | - [email protected]
240 |
241 | ## 0.2.47
242 |
243 | ### Patch Changes
244 |
245 | - Updated dependencies [[`b6071e4`](https://github.com/lingodotdev/lingo.dev/commit/b6071e4f19dd1823f4f2ce54ba5495538a94d4fd)]:
246 | - [email protected]
247 |
248 | ## 0.2.46
249 |
250 | ### Patch Changes
251 |
252 | - Updated dependencies [[`e898c1e`](https://github.com/lingodotdev/lingo.dev/commit/e898c1eeb34e4dd3e74df26465802b520018acf9)]:
253 | - [email protected]
254 |
255 | ## 0.2.45
256 |
257 | ### Patch Changes
258 |
259 | - Updated dependencies [[`410825c`](https://github.com/lingodotdev/lingo.dev/commit/410825c8bf0029d8ee458514d6f203a7397c8f22)]:
260 | - [email protected]
261 |
262 | ## 0.2.44
263 |
264 | ### Patch Changes
265 |
266 | - Updated dependencies [[`555384d`](https://github.com/lingodotdev/lingo.dev/commit/555384dacf79167e1bb8b9e6871e153fea763471)]:
267 | - [email protected]
268 |
269 | ## 0.2.43
270 |
271 | ### Patch Changes
272 |
273 | - Updated dependencies [[`c0486ca`](https://github.com/lingodotdev/lingo.dev/commit/c0486ca9b0451ea75d070e199f502507ba418e5e)]:
274 | - [email protected]
275 |
276 | ## 0.2.42
277 |
278 | ### Patch Changes
279 |
280 | - Updated dependencies [[`99aae2d`](https://github.com/lingodotdev/lingo.dev/commit/99aae2d09a26060c810913f740893a4a5874d9d4)]:
281 | - [email protected]
282 |
283 | ## 0.2.41
284 |
285 | ### Patch Changes
286 |
287 | - Updated dependencies []:
288 | - [email protected]
289 |
290 | ## 0.2.40
291 |
292 | ### Patch Changes
293 |
294 | - Updated dependencies [[`6af91a0`](https://github.com/lingodotdev/lingo.dev/commit/6af91a083d16f85051fb49a4034789abe784017e)]:
295 | - [email protected]
296 |
297 | ## 0.2.39
298 |
299 | ### Patch Changes
300 |
301 | - Updated dependencies [[`1ff847b`](https://github.com/lingodotdev/lingo.dev/commit/1ff847b9273a3082178553e70c22524f5831ad36), [`55e9e68`](https://github.com/lingodotdev/lingo.dev/commit/55e9e687a3d0efa84b808818a848a276b1a42015), [`b9e2551`](https://github.com/lingodotdev/lingo.dev/commit/b9e2551f349e33542212f941b3407e8517b5fb27)]:
302 | - [email protected]
303 |
304 | ## 0.2.38
305 |
306 | ### Patch Changes
307 |
308 | - Updated dependencies []:
309 | - [email protected]
310 |
311 | ## 0.2.37
312 |
313 | ### Patch Changes
314 |
315 | - Updated dependencies []:
316 | - [email protected]
317 |
318 | ## 0.2.36
319 |
320 | ### Patch Changes
321 |
322 | - Updated dependencies [[`20a3737`](https://github.com/lingodotdev/lingo.dev/commit/20a3737ddb50b2a97699e57e03ea353b8912b78f)]:
323 | - [email protected]
324 |
325 | ## 0.2.35
326 |
327 | ### Patch Changes
328 |
329 | - Updated dependencies [[`afbb978`](https://github.com/lingodotdev/lingo.dev/commit/afbb978fec83d574f2c43b7d68457e435fca9b57)]:
330 | - [email protected]
331 |
332 | ## 0.2.34
333 |
334 | ### Patch Changes
335 |
336 | - Updated dependencies [[`1f1e33f`](https://github.com/lingodotdev/lingo.dev/commit/1f1e33fe4d0767c2f026214a505a2aa9f3785996), [`9266fd0`](https://github.com/lingodotdev/lingo.dev/commit/9266fd0bcddf4b07ca51d2609af92a9473106f9d)]:
337 | - [email protected]
338 |
339 | ## 0.2.33
340 |
341 | ### Patch Changes
342 |
343 | - Updated dependencies [[`6baa1a7`](https://github.com/lingodotdev/lingo.dev/commit/6baa1a7e88dbfac3783d1d49695595077fd8d209)]:
344 | - [email protected]
345 |
346 | ## 0.2.32
347 |
348 | ### Patch Changes
349 |
350 | - Updated dependencies [[`925997d`](https://github.com/lingodotdev/lingo.dev/commit/925997d75a1edbb4211a3be8db2b186cb139327e)]:
351 | - [email protected]
352 |
353 | ## 0.2.31
354 |
355 | ### Patch Changes
356 |
357 | - Updated dependencies [[`cb2aa0f`](https://github.com/lingodotdev/lingo.dev/commit/cb2aa0f505d6b7dbc435b526e8a6f62265d1f453)]:
358 | - [email protected]
359 |
360 | ## 0.2.30
361 |
362 | ### Patch Changes
363 |
364 | - Updated dependencies [[`bfcb424`](https://github.com/lingodotdev/lingo.dev/commit/bfcb424eb4479d0d3b767e062d30f02c5bcaeb14)]:
365 | - [email protected]
366 |
367 | ## 0.2.29
368 |
369 | ### Patch Changes
370 |
371 | - Updated dependencies [[`2b297ba`](https://github.com/lingodotdev/lingo.dev/commit/2b297babe76f9799c5154d9421fecd1ebbe1bb72)]:
372 | - [email protected]
373 |
374 | ## 0.2.28
375 |
376 | ### Patch Changes
377 |
378 | - Updated dependencies [[`30faa6d`](https://github.com/lingodotdev/lingo.dev/commit/30faa6d10e851a38ced86ae403b3a1fd48440bca)]:
379 | - [email protected]
380 |
381 | ## 0.2.27
382 |
383 | ### Patch Changes
384 |
385 | - Updated dependencies []:
386 | - [email protected]
387 |
388 | ## 0.2.26
389 |
390 | ### Patch Changes
391 |
392 | - Updated dependencies [[`4e9e368`](https://github.com/lingodotdev/lingo.dev/commit/4e9e36830ee4277ef9d65eee9ee92380a95a622c)]:
393 | - [email protected]
394 |
395 | ## 0.2.25
396 |
397 | ### Patch Changes
398 |
399 | - Updated dependencies [[`65701e5`](https://github.com/lingodotdev/lingo.dev/commit/65701e5b9694e811587ef600227251a1ff1384a0), [`4e55355`](https://github.com/lingodotdev/lingo.dev/commit/4e5535535029743b7a0edc4fdab3d4ee71374035)]:
400 | - [email protected]
401 |
402 | ## 0.2.24
403 |
404 | ### Patch Changes
405 |
406 | - Updated dependencies [[`f644123`](https://github.com/lingodotdev/lingo.dev/commit/f644123ddf6a6254790d08af50141e4dd78c3677)]:
407 | - [email protected]
408 |
409 | ## 0.2.23
410 |
411 | ### Patch Changes
412 |
413 | - Updated dependencies [[`29cf6a7`](https://github.com/lingodotdev/lingo.dev/commit/29cf6a7359707e0e341c11942d1ce6dedf7e66e5)]:
414 | - [email protected]
415 |
416 | ## 0.2.22
417 |
418 | ### Patch Changes
419 |
420 | - Updated dependencies [[`b249484`](https://github.com/lingodotdev/lingo.dev/commit/b249484d6f0060e29cd5b50b3d8ce68b857ccad5)]:
421 | - [email protected]
422 |
423 | ## 0.2.21
424 |
425 | ### Patch Changes
426 |
427 | - Updated dependencies [[`f7debef`](https://github.com/lingodotdev/lingo.dev/commit/f7debef9f004e670bb1f6a45ae17067a72a6e53f)]:
428 | - [email protected]
429 |
430 | ## 0.2.20
431 |
432 | ### Patch Changes
433 |
434 | - Updated dependencies [[`da6f0c8`](https://github.com/lingodotdev/lingo.dev/commit/da6f0c85e69687615df943323d261078742ba3f2)]:
435 | - [email protected]
436 |
437 | ## 0.2.19
438 |
439 | ### Patch Changes
440 |
441 | - Updated dependencies [[`8b306bc`](https://github.com/lingodotdev/lingo.dev/commit/8b306bcd0a3231ffd8bde283414b6d069b7a5b99), [`013fca0`](https://github.com/lingodotdev/lingo.dev/commit/013fca0f4252103ee3009fe3cdcfce2a87c80058)]:
442 | - [email protected]
443 |
444 | ## 0.2.18
445 |
446 | ### Patch Changes
447 |
448 | - Updated dependencies [[`84fd214`](https://github.com/lingodotdev/lingo.dev/commit/84fd214a21766e7683c5d645fcb8c4c0162eb0b6), [`0fc6385`](https://github.com/lingodotdev/lingo.dev/commit/0fc63856c6f49ac68a220b6e2f1c4f060e7ce78e), [`cac5429`](https://github.com/lingodotdev/lingo.dev/commit/cac54296d512d436dc3861441d5d1a3f1076792b)]:
449 | - [email protected]
450 |
451 | ## 0.2.17
452 |
453 | ### Patch Changes
454 |
455 | - Updated dependencies [[`ce0e5cd`](https://github.com/lingodotdev/lingo.dev/commit/ce0e5cd6d1ec17f5c593d394ceb63a28666df924)]:
456 | - [email protected]
457 |
458 | ## 0.2.16
459 |
460 | ### Patch Changes
461 |
462 | - Updated dependencies [[`ce8c75c`](https://github.com/lingodotdev/lingo.dev/commit/ce8c75c7fc1a2124d3e18444bc356c4dfce26434)]:
463 | - [email protected]
464 |
465 | ## 0.2.15
466 |
467 | ### Patch Changes
468 |
469 | - Updated dependencies []:
470 | - [email protected]
471 |
472 | ## 0.2.14
473 |
474 | ### Patch Changes
475 |
476 | - Updated dependencies [[`d80285a`](https://github.com/lingodotdev/lingo.dev/commit/d80285a9b12bd85425564cb00e558812fd0aee40)]:
477 | - [email protected]
478 |
479 | ## 0.2.13
480 |
481 | ### Patch Changes
482 |
483 | - Updated dependencies [[`81eff21`](https://github.com/lingodotdev/lingo.dev/commit/81eff2104a4401b1c1b6cdf4dcc7ca75b7411ba4)]:
484 | - [email protected]
485 |
486 | ## 0.2.12
487 |
488 | ### Patch Changes
489 |
490 | - Updated dependencies [[`b39b04a`](https://github.com/lingodotdev/lingo.dev/commit/b39b04ad83d3c8001008c3cefe309d8e762b2adc), [`4e5983d`](https://github.com/lingodotdev/lingo.dev/commit/4e5983d7e59ebf9eb529c4b7c1c87689432ac873)]:
491 | - [email protected]
492 |
493 | ## 0.2.11
494 |
495 | ### Patch Changes
496 |
497 | - Updated dependencies [[`1a3cbc1`](https://github.com/lingodotdev/lingo.dev/commit/1a3cbc1751c64e5617e91812506b3c061475f16a)]:
498 | - [email protected]
499 |
500 | ## 0.2.10
501 |
502 | ### Patch Changes
503 |
504 | - Updated dependencies []:
505 | - [email protected]
506 |
507 | ## 0.2.9
508 |
509 | ### Patch Changes
510 |
511 | - Updated dependencies []:
512 | - [email protected]
513 |
514 | ## 0.2.8
515 |
516 | ### Patch Changes
517 |
518 | - [`8e97256`](https://github.com/lingodotdev/lingo.dev/commit/8e97256ca4e78dd09a967539ca9dec359bd558ef) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix dictionary merging
519 |
520 | - Updated dependencies []:
521 | - [email protected]
522 |
523 | ## 0.2.7
524 |
525 | ### Patch Changes
526 |
527 | - [#925](https://github.com/lingodotdev/lingo.dev/pull/925) [`215af19`](https://github.com/lingodotdev/lingo.dev/commit/215af1944667cce66e9c5966f4fb627186687b74) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - improved compiler concurrency, caching, added lingo.dev engine to the compiler, and updated demo apps
528 |
529 | - Updated dependencies [[`1b9b113`](https://github.com/lingodotdev/lingo.dev/commit/1b9b11301978e8caa2555832d027ff93216aa6e1), [`0329a9c`](https://github.com/lingodotdev/lingo.dev/commit/0329a9cdb5e5a63fcecab4efcd7cce22f155a0e9)]:
530 | - [email protected]
531 |
532 | ## 0.2.6
533 |
534 | ### Patch Changes
535 |
536 | - Updated dependencies [[`3b6574f`](https://github.com/lingodotdev/lingo.dev/commit/3b6574f0499f3f4d3c48f66ba2b828d2c1c0ceb0), [`6b4b9e6`](https://github.com/lingodotdev/lingo.dev/commit/6b4b9e6cc9a0cb5da8a4df9e9ebda474bf2a18ed), [`6b4b9e6`](https://github.com/lingodotdev/lingo.dev/commit/6b4b9e6cc9a0cb5da8a4df9e9ebda474bf2a18ed)]:
537 | - [email protected]
538 |
539 | ## 0.2.5
540 |
541 | ### Patch Changes
542 |
543 | - Updated dependencies []:
544 | - [email protected]
545 |
546 | ## 0.2.4
547 |
548 | ### Patch Changes
549 |
550 | - Updated dependencies [[`2dd8170`](https://github.com/lingodotdev/lingo.dev/commit/2dd8170ff0101268f2253c9248409d184da5f75c)]:
551 | - [email protected]
552 |
553 | ## 0.2.3
554 |
555 | ### Patch Changes
556 |
557 | - Updated dependencies []:
558 | - [email protected]
559 |
560 | ## 0.2.2
561 |
562 | ### Patch Changes
563 |
564 | - Updated dependencies [[`cc232eb`](https://github.com/lingodotdev/lingo.dev/commit/cc232eb72d0e54b3571bbb70e88cdad24ba6372a)]:
565 | - [email protected]
566 |
567 | ## 0.2.1
568 |
569 | ### Patch Changes
570 |
571 | - Updated dependencies [[`fead8e0`](https://github.com/lingodotdev/lingo.dev/commit/fead8e08dc2b2869a093cb25a04f6e0aa78cf6b7)]:
572 | - [email protected]
573 |
574 | ## 0.2.0
575 |
576 | ### Minor Changes
577 |
578 | - [#897](https://github.com/lingodotdev/lingo.dev/pull/897) [`a5da697`](https://github.com/lingodotdev/lingo.dev/commit/a5da697f7efd46de31d17b202d06eb5f655ed9b9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for other providers in the compiler and implement Google AI as a provider.
579 |
580 | ### Patch Changes
581 |
582 | - Updated dependencies [[`a5da697`](https://github.com/lingodotdev/lingo.dev/commit/a5da697f7efd46de31d17b202d06eb5f655ed9b9), [`10a0139`](https://github.com/lingodotdev/lingo.dev/commit/10a0139edc9ffbc1c52ac2226f6b0f345cc19878)]:
583 | - [email protected]
584 |
585 | ## 0.1.23
586 |
587 | ### Patch Changes
588 |
589 | - Updated dependencies [[`3bd4045`](https://github.com/lingodotdev/lingo.dev/commit/3bd40450cbb5c8aabce61d7f1f3ab9c7293323d9)]:
590 | - [email protected]
591 |
592 | ## 0.1.22
593 |
594 | ### Patch Changes
595 |
596 | - Updated dependencies [[`f140f82`](https://github.com/lingodotdev/lingo.dev/commit/f140f820d00b15f99214a7eece1a9c7f0d098e90)]:
597 | - [email protected]
598 |
599 | ## 0.1.21
600 |
601 | ### Patch Changes
602 |
603 | - Updated dependencies [[`145fb74`](https://github.com/lingodotdev/lingo.dev/commit/145fb74c09b42c8810f351be5a641b1366881ae1), [`0c45acc`](https://github.com/lingodotdev/lingo.dev/commit/0c45accfc45e63f597758c47033bc58d2f6059b5)]:
604 | - [email protected]
605 |
606 | ## 0.1.20
607 |
608 | ### Patch Changes
609 |
610 | - Updated dependencies [[`511a2ec`](https://github.com/lingodotdev/lingo.dev/commit/511a2ecd68a9c5e2800035d5c6a6b5b31b2dc80f)]:
611 | - [email protected]
612 |
613 | ## 0.1.19
614 |
615 | ### Patch Changes
616 |
617 | - Updated dependencies [[`7191444`](https://github.com/lingodotdev/lingo.dev/commit/7191444f67864ea5b5a91a9be759b2445bf186d3)]:
618 | - [email protected]
619 |
620 | ## 0.1.18
621 |
622 | ### Patch Changes
623 |
624 | - Updated dependencies []:
625 | - [email protected]
626 |
627 | ## 0.1.17
628 |
629 | ### Patch Changes
630 |
631 | - Updated dependencies [[`af011b1`](https://github.com/lingodotdev/lingo.dev/commit/af011b18fe96f15287609278f4d4d2b343b6c2cc)]:
632 | - [email protected]
633 |
634 | ## 0.1.16
635 |
636 | ### Patch Changes
637 |
638 | - Updated dependencies []:
639 | - [email protected]
640 |
641 | ## 0.1.15
642 |
643 | ### Patch Changes
644 |
645 | - Updated dependencies [[`3750c9c`](https://github.com/lingodotdev/lingo.dev/commit/3750c9ca25a78280b04e4a2b2e6641dd21f9f3b0)]:
646 | - [email protected]
647 |
648 | ## 0.1.14
649 |
650 | ### Patch Changes
651 |
652 | - Updated dependencies []:
653 | - [email protected]
654 |
655 | ## 0.1.13
656 |
657 | ### Patch Changes
658 |
659 | - Updated dependencies []:
660 | - [email protected]
661 |
662 | ## 0.1.12
663 |
664 | ### Patch Changes
665 |
666 | - Updated dependencies []:
667 | - [email protected]
668 |
669 | ## 0.1.11
670 |
671 | ### Patch Changes
672 |
673 | - Updated dependencies []:
674 | - [email protected]
675 |
676 | ## 0.1.10
677 |
678 | ### Patch Changes
679 |
680 | - Updated dependencies []:
681 | - [email protected]
682 |
683 | ## 0.1.9
684 |
685 | ### Patch Changes
686 |
687 | - Updated dependencies [[`cb7d5e2`](https://github.com/lingodotdev/lingo.dev/commit/cb7d5e213282c00af658159472183a763f84ca3d)]:
688 | - [email protected]
689 |
690 | ## 0.1.8
691 |
692 | ### Patch Changes
693 |
694 | - Updated dependencies [[`5d27455`](https://github.com/lingodotdev/lingo.dev/commit/5d2745545044cbaddb099f7920c96fe198879ba3)]:
695 | - [email protected]
696 |
697 | ## 0.1.7
698 |
699 | ### Patch Changes
700 |
701 | - Updated dependencies [[`b67a331`](https://github.com/lingodotdev/lingo.dev/commit/b67a33141253fa755b5531e52cd690bf5824d4b6)]:
702 | - [email protected]
703 |
704 | ## 0.1.6
705 |
706 | ### Patch Changes
707 |
708 | - Updated dependencies []:
709 | - [email protected]
710 |
711 | ## 0.1.5
712 |
713 | ### Patch Changes
714 |
715 | - Updated dependencies [[`f42cff8`](https://github.com/lingodotdev/lingo.dev/commit/f42cff8355b1ff7bba1445bd04d11ee4672903c2)]:
716 | - [email protected]
717 |
718 | ## 0.1.4
719 |
720 | ### Patch Changes
721 |
722 | - Updated dependencies [[`920e3f5`](https://github.com/lingodotdev/lingo.dev/commit/920e3f5c3ca1fd51b0919db13a4787cfd616de54)]:
723 | - [email protected]
724 |
725 | ## 0.1.3
726 |
727 | ### Patch Changes
728 |
729 | - Updated dependencies [[`cdb59dd`](https://github.com/lingodotdev/lingo.dev/commit/cdb59dddcd14da1ba3181a33c4c119af877cb4f3)]:
730 | - [email protected]
731 |
732 | ## 0.1.2
733 |
734 | ### Patch Changes
735 |
736 | - Updated dependencies [[`caef325`](https://github.com/lingodotdev/lingo.dev/commit/caef3253bc99fa7bf7a0b40e5604c3590dcb4958)]:
737 | - [email protected]
738 |
739 | ## 0.1.1
740 |
741 | ### Patch Changes
742 |
743 | - Updated dependencies [[`e980e84`](https://github.com/lingodotdev/lingo.dev/commit/e980e84178439ad70417d38b425acf9148cfc4b6)]:
744 | - [email protected]
745 |
```
--------------------------------------------------------------------------------
/packages/sdk/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import Z from "zod";
2 | import { LocaleCode, localeCodeSchema } from "@lingo.dev/_spec";
3 | import { createId } from "@paralleldrive/cuid2";
4 |
5 | const engineParamsSchema = Z.object({
6 | apiKey: Z.string(),
7 | apiUrl: Z.string().url().default("https://engine.lingo.dev"),
8 | batchSize: Z.number().int().gt(0).lte(250).default(25),
9 | idealBatchItemSize: Z.number().int().gt(0).lte(2500).default(250),
10 | }).passthrough();
11 |
12 | const payloadSchema = Z.record(Z.string(), Z.any());
13 | const referenceSchema = Z.record(localeCodeSchema, payloadSchema);
14 | const hintsSchema = Z.record(Z.string(), Z.array(Z.string()));
15 |
16 | const localizationParamsSchema = Z.object({
17 | sourceLocale: Z.union([localeCodeSchema, Z.null()]),
18 | targetLocale: localeCodeSchema,
19 | fast: Z.boolean().optional(),
20 | reference: referenceSchema.optional(),
21 | hints: hintsSchema.optional(),
22 | });
23 |
24 | /**
25 | * LingoDotDevEngine class for interacting with the LingoDotDev API
26 | * A powerful localization engine that supports various content types including
27 | * plain text, objects, chat sequences, and HTML documents.
28 | */
29 | export class LingoDotDevEngine {
30 | protected config: Z.infer<typeof engineParamsSchema>;
31 |
32 | /**
33 | * Create a new LingoDotDevEngine instance
34 | * @param config - Configuration options for the Engine
35 | */
36 | constructor(config: Partial<Z.infer<typeof engineParamsSchema>>) {
37 | this.config = engineParamsSchema.parse(config);
38 | }
39 |
40 | /**
41 | * Localize content using the Lingo.dev API
42 | * @param payload - The content to be localized
43 | * @param params - Localization parameters including source/target locales and fast mode option
44 | * @param progressCallback - Optional callback function to report progress (0-100)
45 | * @param signal - Optional AbortSignal to cancel the operation
46 | * @returns Localized content
47 | * @internal
48 | */
49 | async _localizeRaw(
50 | payload: Z.infer<typeof payloadSchema>,
51 | params: Z.infer<typeof localizationParamsSchema>,
52 | progressCallback?: (
53 | progress: number,
54 | sourceChunk: Record<string, string>,
55 | processedChunk: Record<string, string>,
56 | ) => void,
57 | signal?: AbortSignal,
58 | ): Promise<Record<string, string>> {
59 | const finalPayload = payloadSchema.parse(payload);
60 | const finalParams = localizationParamsSchema.parse(params);
61 |
62 | const chunkedPayload = this.extractPayloadChunks(finalPayload);
63 | const processedPayloadChunks: Record<string, string>[] = [];
64 |
65 | const workflowId = createId();
66 | for (let i = 0; i < chunkedPayload.length; i++) {
67 | const chunk = chunkedPayload[i];
68 | const percentageCompleted = Math.round(
69 | ((i + 1) / chunkedPayload.length) * 100,
70 | );
71 |
72 | const processedPayloadChunk = await this.localizeChunk(
73 | finalParams.sourceLocale,
74 | finalParams.targetLocale,
75 | { data: chunk, reference: params.reference, hints: params.hints },
76 | workflowId,
77 | params.fast || false,
78 | signal,
79 | );
80 |
81 | if (progressCallback) {
82 | progressCallback(percentageCompleted, chunk, processedPayloadChunk);
83 | }
84 |
85 | processedPayloadChunks.push(processedPayloadChunk);
86 | }
87 |
88 | return Object.assign({}, ...processedPayloadChunks);
89 | }
90 |
91 | /**
92 | * Localize a single chunk of content
93 | * @param sourceLocale - Source locale
94 | * @param targetLocale - Target locale
95 | * @param payload - Payload containing the chunk to be localized
96 | * @param workflowId - Workflow ID for tracking
97 | * @param fast - Whether to use fast mode
98 | * @param signal - Optional AbortSignal to cancel the operation
99 | * @returns Localized chunk
100 | */
101 | private async localizeChunk(
102 | sourceLocale: string | null,
103 | targetLocale: string,
104 | payload: {
105 | data: Z.infer<typeof payloadSchema>;
106 | reference?: Z.infer<typeof referenceSchema>;
107 | hints?: Z.infer<typeof hintsSchema>;
108 | },
109 | workflowId: string,
110 | fast: boolean,
111 | signal?: AbortSignal,
112 | ): Promise<Record<string, string>> {
113 | const res = await fetch(`${this.config.apiUrl}/i18n`, {
114 | method: "POST",
115 | headers: {
116 | "Content-Type": "application/json; charset=utf-8",
117 | Authorization: `Bearer ${this.config.apiKey}`,
118 | },
119 | body: JSON.stringify(
120 | {
121 | params: { workflowId, fast },
122 | locale: {
123 | source: sourceLocale,
124 | target: targetLocale,
125 | },
126 | data: payload.data,
127 | reference: payload.reference,
128 | hints: payload.hints,
129 | },
130 | null,
131 | 2,
132 | ),
133 | signal,
134 | });
135 |
136 | if (!res.ok) {
137 | if (res.status >= 500 && res.status < 600) {
138 | const errorText = await res.text();
139 | throw new Error(
140 | `Server error (${res.status}): ${res.statusText}. ${errorText}. This may be due to temporary service issues.`,
141 | );
142 | } else if (res.status === 400) {
143 | throw new Error(`Invalid request: ${res.statusText}`);
144 | } else {
145 | const errorText = await res.text();
146 | throw new Error(errorText);
147 | }
148 | }
149 |
150 | const jsonResponse = await res.json();
151 |
152 | // when streaming the error is returned in the response body
153 | if (!jsonResponse.data && jsonResponse.error) {
154 | throw new Error(jsonResponse.error);
155 | }
156 |
157 | return jsonResponse.data || {};
158 | }
159 |
160 | /**
161 | * Extract payload chunks based on the ideal chunk size
162 | * @param payload - The payload to be chunked
163 | * @returns An array of payload chunks
164 | */
165 | private extractPayloadChunks(
166 | payload: Record<string, string>,
167 | ): Record<string, string>[] {
168 | const result: Record<string, string>[] = [];
169 | let currentChunk: Record<string, string> = {};
170 | let currentChunkItemCount = 0;
171 |
172 | const payloadEntries = Object.entries(payload);
173 | for (let i = 0; i < payloadEntries.length; i++) {
174 | const [key, value] = payloadEntries[i];
175 | currentChunk[key] = value;
176 | currentChunkItemCount++;
177 |
178 | const currentChunkSize = this.countWordsInRecord(currentChunk);
179 | if (
180 | currentChunkSize > this.config.idealBatchItemSize ||
181 | currentChunkItemCount >= this.config.batchSize ||
182 | i === payloadEntries.length - 1
183 | ) {
184 | result.push(currentChunk);
185 | currentChunk = {};
186 | currentChunkItemCount = 0;
187 | }
188 | }
189 |
190 | return result;
191 | }
192 |
193 | /**
194 | * Count words in a record or array
195 | * @param payload - The payload to count words in
196 | * @returns The total number of words
197 | */
198 | private countWordsInRecord(
199 | payload: any | Record<string, any> | Array<any>,
200 | ): number {
201 | if (Array.isArray(payload)) {
202 | return payload.reduce(
203 | (acc, item) => acc + this.countWordsInRecord(item),
204 | 0,
205 | );
206 | } else if (typeof payload === "object" && payload !== null) {
207 | return Object.values(payload).reduce(
208 | (acc: number, item) => acc + this.countWordsInRecord(item),
209 | 0,
210 | );
211 | } else if (typeof payload === "string") {
212 | return payload.trim().split(/\s+/).filter(Boolean).length;
213 | } else {
214 | return 0;
215 | }
216 | }
217 |
218 | /**
219 | * Localize a typical JavaScript object
220 | * @param obj - The object to be localized (strings will be extracted and translated)
221 | * @param params - Localization parameters:
222 | * - sourceLocale: The source language code (e.g., 'en')
223 | * - targetLocale: The target language code (e.g., 'es')
224 | * - fast: Optional boolean to enable fast mode (faster but potentially lower quality)
225 | * @param progressCallback - Optional callback function to report progress (0-100)
226 | * @param signal - Optional AbortSignal to cancel the operation
227 | * @returns A new object with the same structure but localized string values
228 | */
229 | async localizeObject(
230 | obj: Record<string, any>,
231 | params: Z.infer<typeof localizationParamsSchema>,
232 | progressCallback?: (
233 | progress: number,
234 | sourceChunk: Record<string, string>,
235 | processedChunk: Record<string, string>,
236 | ) => void,
237 | signal?: AbortSignal,
238 | ): Promise<Record<string, any>> {
239 | return this._localizeRaw(obj, params, progressCallback, signal);
240 | }
241 |
242 | /**
243 | * Localize a single text string
244 | * @param text - The text string to be localized
245 | * @param params - Localization parameters:
246 | * - sourceLocale: The source language code (e.g., 'en')
247 | * - targetLocale: The target language code (e.g., 'es')
248 | * - fast: Optional boolean to enable fast mode (faster for bigger batches)
249 | * @param progressCallback - Optional callback function to report progress (0-100)
250 | * @param signal - Optional AbortSignal to cancel the operation
251 | * @returns The localized text string
252 | */
253 | async localizeText(
254 | text: string,
255 | params: Z.infer<typeof localizationParamsSchema>,
256 | progressCallback?: (progress: number) => void,
257 | signal?: AbortSignal,
258 | ): Promise<string> {
259 | const response = await this._localizeRaw(
260 | { text },
261 | params,
262 | progressCallback,
263 | signal,
264 | );
265 | return response.text || "";
266 | }
267 |
268 | /**
269 | * Localize a text string to multiple target locales
270 | * @param text - The text string to be localized
271 | * @param params - Localization parameters:
272 | * - sourceLocale: The source language code (e.g., 'en')
273 | * - targetLocales: An array of target language codes (e.g., ['es', 'fr'])
274 | * - fast: Optional boolean to enable fast mode (for bigger batches)
275 | * @param signal - Optional AbortSignal to cancel the operation
276 | * @returns An array of localized text strings
277 | */
278 | async batchLocalizeText(
279 | text: string,
280 | params: {
281 | sourceLocale: LocaleCode;
282 | targetLocales: LocaleCode[];
283 | fast?: boolean;
284 | },
285 | signal?: AbortSignal,
286 | ) {
287 | const responses = await Promise.all(
288 | params.targetLocales.map((targetLocale) =>
289 | this.localizeText(
290 | text,
291 | {
292 | sourceLocale: params.sourceLocale,
293 | targetLocale,
294 | fast: params.fast,
295 | },
296 | undefined,
297 | signal,
298 | ),
299 | ),
300 | );
301 |
302 | return responses;
303 | }
304 |
305 | /**
306 | * Localize an array of strings
307 | * @param strings - An array of strings to be localized
308 | * @param params - Localization parameters:
309 | * - sourceLocale: The source language code (e.g., 'en')
310 | * - targetLocale: The target language code (e.g., 'es')
311 | * - fast: Optional boolean to enable fast mode (faster for bigger batches)
312 | * @returns An array of localized strings in the same order
313 | */
314 | async localizeStringArray(
315 | strings: string[],
316 | params: Z.infer<typeof localizationParamsSchema>,
317 | ): Promise<string[]> {
318 | const mapped = strings.reduce(
319 | (acc, str, i) => {
320 | acc[`item_${i}`] = str;
321 | return acc;
322 | },
323 | {} as Record<string, string>,
324 | );
325 |
326 | const result = await this.localizeObject(mapped, params);
327 | return Object.values(result);
328 | }
329 |
330 | /**
331 | * Localize a chat sequence while preserving speaker names
332 | * @param chat - Array of chat messages, each with 'name' and 'text' properties
333 | * @param params - Localization parameters:
334 | * - sourceLocale: The source language code (e.g., 'en')
335 | * - targetLocale: The target language code (e.g., 'es')
336 | * - fast: Optional boolean to enable fast mode (faster but potentially lower quality)
337 | * @param progressCallback - Optional callback function to report progress (0-100)
338 | * @param signal - Optional AbortSignal to cancel the operation
339 | * @returns Array of localized chat messages with preserved structure
340 | */
341 | async localizeChat(
342 | chat: Array<{ name: string; text: string }>,
343 | params: Z.infer<typeof localizationParamsSchema>,
344 | progressCallback?: (progress: number) => void,
345 | signal?: AbortSignal,
346 | ): Promise<Array<{ name: string; text: string }>> {
347 | const localized = await this._localizeRaw(
348 | { chat },
349 | params,
350 | progressCallback,
351 | signal,
352 | );
353 |
354 | return Object.entries(localized).map(([key, value]) => ({
355 | name: chat[parseInt(key.split("_")[1])].name,
356 | text: value,
357 | }));
358 | }
359 |
360 | /**
361 | * Localize an HTML document while preserving structure and formatting
362 | * Handles both text content and localizable attributes (alt, title, placeholder, meta content)
363 | * @param html - The HTML document string to be localized
364 | * @param params - Localization parameters:
365 | * - sourceLocale: The source language code (e.g., 'en')
366 | * - targetLocale: The target language code (e.g., 'es')
367 | * - fast: Optional boolean to enable fast mode (faster but potentially lower quality)
368 | * @param progressCallback - Optional callback function to report progress (0-100)
369 | * @param signal - Optional AbortSignal to cancel the operation
370 | * @returns The localized HTML document as a string, with updated lang attribute
371 | */
372 | async localizeHtml(
373 | html: string,
374 | params: Z.infer<typeof localizationParamsSchema>,
375 | progressCallback?: (progress: number) => void,
376 | signal?: AbortSignal,
377 | ): Promise<string> {
378 | const jsdomPackage = await import("jsdom");
379 | const { JSDOM } = jsdomPackage;
380 | const dom = new JSDOM(html);
381 | const document = dom.window.document;
382 |
383 | const LOCALIZABLE_ATTRIBUTES: Record<string, string[]> = {
384 | meta: ["content"],
385 | img: ["alt"],
386 | input: ["placeholder"],
387 | a: ["title"],
388 | };
389 | const UNLOCALIZABLE_TAGS = ["script", "style"];
390 |
391 | const extractedContent: Record<string, string> = {};
392 |
393 | const getPath = (node: Node, attribute?: string): string => {
394 | const indices: number[] = [];
395 | let current = node as ChildNode;
396 | let rootParent = "";
397 |
398 | while (current) {
399 | const parent = current.parentElement as Element;
400 | if (!parent) break;
401 |
402 | if (parent === document.documentElement) {
403 | rootParent = current.nodeName.toLowerCase();
404 | break;
405 | }
406 |
407 | const siblings = Array.from(parent.childNodes).filter(
408 | (n) =>
409 | n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()),
410 | );
411 | const index = siblings.indexOf(current);
412 | if (index !== -1) {
413 | indices.unshift(index);
414 | }
415 | current = parent;
416 | }
417 |
418 | const basePath = rootParent
419 | ? `${rootParent}/${indices.join("/")}`
420 | : indices.join("/");
421 | return attribute ? `${basePath}#${attribute}` : basePath;
422 | };
423 |
424 | const processNode = (node: Node) => {
425 | let parent = node.parentElement;
426 | while (parent) {
427 | if (UNLOCALIZABLE_TAGS.includes(parent.tagName.toLowerCase())) {
428 | return;
429 | }
430 | parent = parent.parentElement;
431 | }
432 |
433 | if (node.nodeType === 3) {
434 | const text = node.textContent?.trim() || "";
435 | if (text) {
436 | extractedContent[getPath(node)] = text;
437 | }
438 | } else if (node.nodeType === 1) {
439 | const element = node as Element;
440 | const tagName = element.tagName.toLowerCase();
441 |
442 | const attributes = LOCALIZABLE_ATTRIBUTES[tagName] || [];
443 | attributes.forEach((attr) => {
444 | const value = element.getAttribute(attr);
445 | if (value) {
446 | extractedContent[getPath(element, attr)] = value;
447 | }
448 | });
449 |
450 | Array.from(element.childNodes)
451 | .filter(
452 | (n) =>
453 | n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()),
454 | )
455 | .forEach(processNode);
456 | }
457 | };
458 |
459 | Array.from(document.head.childNodes)
460 | .filter(
461 | (n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()),
462 | )
463 | .forEach(processNode);
464 | Array.from(document.body.childNodes)
465 | .filter(
466 | (n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()),
467 | )
468 | .forEach(processNode);
469 |
470 | const localizedContent = await this._localizeRaw(
471 | extractedContent,
472 | params,
473 | progressCallback,
474 | signal,
475 | );
476 |
477 | // Update the DOM with localized content
478 | document.documentElement.setAttribute("lang", params.targetLocale);
479 |
480 | Object.entries(localizedContent).forEach(([path, value]) => {
481 | const [nodePath, attribute] = path.split("#");
482 | const [rootTag, ...indices] = nodePath.split("/");
483 |
484 | let parent: Element = rootTag === "head" ? document.head : document.body;
485 | let current: Node | null = parent;
486 |
487 | for (const index of indices) {
488 | const siblings = Array.from(parent.childNodes).filter(
489 | (n) =>
490 | n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()),
491 | );
492 | current = siblings[parseInt(index)] || null;
493 | if (current?.nodeType === 1) {
494 | parent = current as Element;
495 | }
496 | }
497 |
498 | if (current) {
499 | if (attribute) {
500 | (current as Element).setAttribute(attribute, value);
501 | } else {
502 | current.textContent = value;
503 | }
504 | }
505 | });
506 |
507 | return dom.serialize();
508 | }
509 |
510 | /**
511 | * Detect the language of a given text
512 | * @param text - The text to analyze
513 | * @param signal - Optional AbortSignal to cancel the operation
514 | * @returns Promise resolving to a locale code (e.g., 'en', 'es', 'fr')
515 | */
516 | async recognizeLocale(
517 | text: string,
518 | signal?: AbortSignal,
519 | ): Promise<LocaleCode> {
520 | const response = await fetch(`${this.config.apiUrl}/recognize`, {
521 | method: "POST",
522 | headers: {
523 | "Content-Type": "application/json; charset=utf-8",
524 | Authorization: `Bearer ${this.config.apiKey}`,
525 | },
526 | body: JSON.stringify({ text }),
527 | signal,
528 | });
529 |
530 | if (!response.ok) {
531 | if (response.status >= 500 && response.status < 600) {
532 | throw new Error(
533 | `Server error (${response.status}): ${response.statusText}. This may be due to temporary service issues.`,
534 | );
535 | }
536 | throw new Error(`Error recognizing locale: ${response.statusText}`);
537 | }
538 |
539 | const jsonResponse = await response.json();
540 | return jsonResponse.locale;
541 | }
542 |
543 | async whoami(
544 | signal?: AbortSignal,
545 | ): Promise<{ email: string; id: string } | null> {
546 | try {
547 | const res = await fetch(`${this.config.apiUrl}/whoami`, {
548 | method: "POST",
549 | headers: {
550 | Authorization: `Bearer ${this.config.apiKey}`,
551 | ContentType: "application/json",
552 | },
553 | signal,
554 | });
555 |
556 | if (res.ok) {
557 | const payload = await res.json();
558 | if (!payload?.email) {
559 | return null;
560 | }
561 |
562 | return {
563 | email: payload.email,
564 | id: payload.id,
565 | };
566 | }
567 |
568 | if (res.status >= 500 && res.status < 600) {
569 | throw new Error(
570 | `Server error (${res.status}): ${res.statusText}. This may be due to temporary service issues.`,
571 | );
572 | }
573 |
574 | return null;
575 | } catch (error) {
576 | if (error instanceof Error && error.message.includes("Server error")) {
577 | throw error;
578 | }
579 | return null;
580 | }
581 | }
582 | }
583 |
584 | /**
585 | * @deprecated Use LingoDotDevEngine instead. This class is maintained for backwards compatibility.
586 | */
587 | export class ReplexicaEngine extends LingoDotDevEngine {
588 | private static hasWarnedDeprecation = false;
589 |
590 | constructor(config: Partial<Z.infer<typeof engineParamsSchema>>) {
591 | super(config);
592 | if (!ReplexicaEngine.hasWarnedDeprecation) {
593 | console.warn(
594 | "ReplexicaEngine is deprecated and will be removed in a future release. " +
595 | "Please use LingoDotDevEngine instead. " +
596 | "See https://lingo.dev/cli for more information.",
597 | );
598 | ReplexicaEngine.hasWarnedDeprecation = true;
599 | }
600 | }
601 | }
602 |
603 | /**
604 | * @deprecated Use LingoDotDevEngine instead. This class is maintained for backwards compatibility.
605 | */
606 | export class LingoEngine extends LingoDotDevEngine {
607 | private static hasWarnedDeprecation = false;
608 |
609 | constructor(config: Partial<Z.infer<typeof engineParamsSchema>>) {
610 | super(config);
611 | if (!LingoEngine.hasWarnedDeprecation) {
612 | console.warn(
613 | "LingoEngine is deprecated and will be removed in a future release. " +
614 | "Please use LingoDotDevEngine instead. " +
615 | "See https://lingo.dev/cli for more information.",
616 | );
617 | LingoEngine.hasWarnedDeprecation = true;
618 | }
619 | }
620 | }
621 |
```