This is page 11 of 20. Use http://codebase.md/lingodotdev/lingo.dev?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .changeset
│ ├── config.json
│ └── README.md
├── .claude
│ ├── agents
│ │ └── code-architect-reviewer.md
│ └── commands
│ ├── analyze-bucket-type.md
│ └── create-bucket-docs.md
├── .editorconfig
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── docker.yml
│ ├── lingodotdev.yml
│ ├── pr-check.yml
│ ├── pr-lint.yml
│ └── release.yml
├── .gitignore
├── .husky
│ └── commit-msg
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .vscode
│ ├── extensions.json
│ ├── launch.json
│ └── settings.json
├── action.yml
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── commitlint.config.js
├── composer.json
├── content
│ ├── banner.compiler.png
│ ├── banner.dark.png
│ └── banner.launch.png
├── CONTRIBUTING.md
├── DEBUGGING.md
├── demo
│ ├── adonisjs
│ │ ├── .editorconfig
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── ace.js
│ │ ├── adonisrc.ts
│ │ ├── app
│ │ │ ├── exceptions
│ │ │ │ └── handler.ts
│ │ │ └── middleware
│ │ │ └── container_bindings_middleware.ts
│ │ ├── bin
│ │ │ ├── console.ts
│ │ │ ├── server.ts
│ │ │ └── test.ts
│ │ ├── CHANGELOG.md
│ │ ├── config
│ │ │ ├── app.ts
│ │ │ ├── bodyparser.ts
│ │ │ ├── cors.ts
│ │ │ ├── hash.ts
│ │ │ ├── inertia.ts
│ │ │ ├── logger.ts
│ │ │ ├── session.ts
│ │ │ ├── shield.ts
│ │ │ ├── static.ts
│ │ │ └── vite.ts
│ │ ├── eslint.config.js
│ │ ├── inertia
│ │ │ ├── app
│ │ │ │ ├── app.tsx
│ │ │ │ └── ssr.tsx
│ │ │ ├── css
│ │ │ │ └── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── pages
│ │ │ │ ├── errors
│ │ │ │ │ ├── not_found.tsx
│ │ │ │ │ └── server_error.tsx
│ │ │ │ └── home.tsx
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── resources
│ │ │ └── views
│ │ │ └── inertia_layout.edge
│ │ ├── start
│ │ │ ├── env.ts
│ │ │ ├── kernel.ts
│ │ │ └── routes.ts
│ │ ├── tests
│ │ │ └── bootstrap.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── next-app
│ │ ├── .gitignore
│ │ ├── CHANGELOG.md
│ │ ├── eslint.config.mjs
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── public
│ │ │ ├── file.svg
│ │ │ ├── globe.svg
│ │ │ ├── next.svg
│ │ │ ├── vercel.svg
│ │ │ └── window.svg
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── client-component.tsx
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── lingo-dot-dev.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── test
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── hero-actions.tsx
│ │ │ │ ├── hero-subtitle.tsx
│ │ │ │ ├── hero-title.tsx
│ │ │ │ └── index.ts
│ │ │ └── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ └── tsconfig.json
│ ├── react-router-app
│ │ ├── .dockerignore
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── root.tsx
│ │ │ ├── routes
│ │ │ │ ├── home.tsx
│ │ │ │ └── test.tsx
│ │ │ ├── routes.ts
│ │ │ └── welcome
│ │ │ ├── lingo-dot-dev.tsx
│ │ │ ├── logo-dark.svg
│ │ │ ├── logo-light.svg
│ │ │ └── welcome.tsx
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ ├── public
│ │ │ └── favicon.ico
│ │ ├── react-router.config.ts
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── vite-project
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── README.md
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ └── test.tsx
│ │ ├── index.css
│ │ ├── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ ├── lingo-dot-dev.tsx
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── Dockerfile
├── i18n.json
├── i18n.lock
├── integrations
│ └── directus
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── docker-compose.yml
│ ├── Dockerfile
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── api.ts
│ │ ├── app.ts
│ │ └── index.spec.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── ISSUE_TEMPLATE.md
├── legacy
│ ├── cli
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ └── readme.md
│ └── sdk
│ ├── CHANGELOG.md
│ ├── index.d.ts
│ ├── index.js
│ ├── package.json
│ └── README.md
├── LICENSE.md
├── mcp.md
├── package.json
├── packages
│ ├── cli
│ │ ├── assets
│ │ │ ├── failure.mp3
│ │ │ └── success.mp3
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── demo
│ │ │ ├── android
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── csv
│ │ │ │ ├── example.csv
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── demo.spec.ts
│ │ │ ├── ejs
│ │ │ │ ├── en
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── es
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── flutter
│ │ │ │ ├── en
│ │ │ │ │ └── example.arb
│ │ │ │ ├── es
│ │ │ │ │ └── example.arb
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── html
│ │ │ │ ├── en
│ │ │ │ │ └── example.html
│ │ │ │ ├── es
│ │ │ │ │ └── example.html
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json
│ │ │ │ ├── en
│ │ │ │ │ └── example.json
│ │ │ │ ├── es
│ │ │ │ │ └── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json-dictionary
│ │ │ │ ├── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json5
│ │ │ │ ├── en
│ │ │ │ │ └── example.json5
│ │ │ │ ├── es
│ │ │ │ │ └── example.json5
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── jsonc
│ │ │ │ ├── en
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── es
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── i18n.json
│ │ │ │ ├── i18n.lock
│ │ │ │ └── ru
│ │ │ │ └── example.jsonc
│ │ │ ├── markdoc
│ │ │ │ ├── en
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── es
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── markdown
│ │ │ │ ├── en
│ │ │ │ │ └── example.md
│ │ │ │ ├── es
│ │ │ │ │ └── example.md
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── mdx
│ │ │ │ ├── en
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── es
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── php
│ │ │ │ ├── en
│ │ │ │ │ └── example.php
│ │ │ │ ├── es
│ │ │ │ │ └── example.php
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── po
│ │ │ │ ├── en
│ │ │ │ │ └── example.po
│ │ │ │ ├── es
│ │ │ │ │ └── example.po
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── properties
│ │ │ │ ├── en
│ │ │ │ │ └── example.properties
│ │ │ │ ├── es
│ │ │ │ │ └── example.properties
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── run_i18n.sh
│ │ │ ├── srt
│ │ │ │ ├── en
│ │ │ │ │ └── example.srt
│ │ │ │ ├── es
│ │ │ │ │ └── example.srt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── txt
│ │ │ │ ├── en
│ │ │ │ │ └── example.txt
│ │ │ │ ├── es
│ │ │ │ │ └── example.txt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── typescript
│ │ │ │ ├── en
│ │ │ │ │ └── example.ts
│ │ │ │ ├── es
│ │ │ │ │ └── example.ts
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vtt
│ │ │ │ ├── en
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── es
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vue-json
│ │ │ │ ├── example.vue
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-strings
│ │ │ │ ├── en
│ │ │ │ │ └── example.strings
│ │ │ │ ├── es
│ │ │ │ │ └── example.strings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-stringsdict
│ │ │ │ ├── en
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── es
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings-v2
│ │ │ │ ├── complex-example.xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xliff
│ │ │ │ ├── en
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ └── example-v2.xliff
│ │ │ │ ├── es
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ ├── example-v2.xliff
│ │ │ │ │ └── example.xliff
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xml
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── yaml
│ │ │ │ ├── en
│ │ │ │ │ └── example.yml
│ │ │ │ ├── es
│ │ │ │ │ └── example.yml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ └── yaml-root-key
│ │ │ ├── en
│ │ │ │ └── example.yml
│ │ │ ├── es
│ │ │ │ └── example.yml
│ │ │ ├── i18n.json
│ │ │ └── i18n.lock
│ │ ├── i18n.json
│ │ ├── i18n.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── cli
│ │ │ │ ├── cmd
│ │ │ │ │ ├── auth.ts
│ │ │ │ │ ├── ci
│ │ │ │ │ │ ├── flows
│ │ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ │ ├── in-branch.ts
│ │ │ │ │ │ │ └── pull-request.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── platforms
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── bitbucket.ts
│ │ │ │ │ │ ├── github.ts
│ │ │ │ │ │ ├── gitlab.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── cleanup.ts
│ │ │ │ │ ├── config
│ │ │ │ │ │ ├── get.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── set.ts
│ │ │ │ │ │ └── unset.ts
│ │ │ │ │ ├── i18n.ts
│ │ │ │ │ ├── init.ts
│ │ │ │ │ ├── lockfile.ts
│ │ │ │ │ ├── login.ts
│ │ │ │ │ ├── logout.ts
│ │ │ │ │ ├── may-the-fourth.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── purge.ts
│ │ │ │ │ ├── run
│ │ │ │ │ │ ├── _const.ts
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── execute.spec.ts
│ │ │ │ │ │ ├── execute.ts
│ │ │ │ │ │ ├── frozen.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── plan.ts
│ │ │ │ │ │ ├── setup.ts
│ │ │ │ │ │ └── watch.ts
│ │ │ │ │ ├── show
│ │ │ │ │ │ ├── _shared-key-command.ts
│ │ │ │ │ │ ├── config.ts
│ │ │ │ │ │ ├── files.ts
│ │ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── locale.ts
│ │ │ │ │ │ └── locked-keys.ts
│ │ │ │ │ └── status.ts
│ │ │ │ ├── constants.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loaders
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── _utils.ts
│ │ │ │ │ ├── android.spec.ts
│ │ │ │ │ ├── android.ts
│ │ │ │ │ ├── csv.spec.ts
│ │ │ │ │ ├── csv.ts
│ │ │ │ │ ├── dato
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── api.ts
│ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ ├── filter.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── ejs.spec.ts
│ │ │ │ │ ├── ejs.ts
│ │ │ │ │ ├── ensure-key-order.spec.ts
│ │ │ │ │ ├── ensure-key-order.ts
│ │ │ │ │ ├── flat.spec.ts
│ │ │ │ │ ├── flat.ts
│ │ │ │ │ ├── flutter.spec.ts
│ │ │ │ │ ├── flutter.ts
│ │ │ │ │ ├── formatters
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── biome.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── prettier.ts
│ │ │ │ │ ├── html.ts
│ │ │ │ │ ├── icu-safety.spec.ts
│ │ │ │ │ ├── ignored-keys-buckets.spec.ts
│ │ │ │ │ ├── ignored-keys.spec.ts
│ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── inject-locale.spec.ts
│ │ │ │ │ ├── inject-locale.ts
│ │ │ │ │ ├── json-dictionary.spec.ts
│ │ │ │ │ ├── json-dictionary.ts
│ │ │ │ │ ├── json-sorting.test.ts
│ │ │ │ │ ├── json-sorting.ts
│ │ │ │ │ ├── json.ts
│ │ │ │ │ ├── json5.spec.ts
│ │ │ │ │ ├── json5.ts
│ │ │ │ │ ├── jsonc.spec.ts
│ │ │ │ │ ├── jsonc.ts
│ │ │ │ │ ├── locked-keys.spec.ts
│ │ │ │ │ ├── locked-keys.ts
│ │ │ │ │ ├── locked-patterns.spec.ts
│ │ │ │ │ ├── locked-patterns.ts
│ │ │ │ │ ├── markdoc.spec.ts
│ │ │ │ │ ├── markdoc.ts
│ │ │ │ │ ├── markdown.ts
│ │ │ │ │ ├── mdx.spec.ts
│ │ │ │ │ ├── mdx.ts
│ │ │ │ │ ├── mdx2
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── code-placeholder.spec.ts
│ │ │ │ │ │ ├── code-placeholder.ts
│ │ │ │ │ │ ├── frontmatter-split.spec.ts
│ │ │ │ │ │ ├── frontmatter-split.ts
│ │ │ │ │ │ ├── localizable-document.spec.ts
│ │ │ │ │ │ ├── localizable-document.ts
│ │ │ │ │ │ ├── section-split.spec.ts
│ │ │ │ │ │ ├── section-split.ts
│ │ │ │ │ │ └── sections-split-2.ts
│ │ │ │ │ ├── passthrough.ts
│ │ │ │ │ ├── php.ts
│ │ │ │ │ ├── plutil-json-loader.ts
│ │ │ │ │ ├── po
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── properties.ts
│ │ │ │ │ ├── root-key.ts
│ │ │ │ │ ├── srt.ts
│ │ │ │ │ ├── sync.ts
│ │ │ │ │ ├── text-file.ts
│ │ │ │ │ ├── txt.ts
│ │ │ │ │ ├── typescript
│ │ │ │ │ │ ├── cjs-interop.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── unlocalizable.spec.ts
│ │ │ │ │ ├── unlocalizable.ts
│ │ │ │ │ ├── variable
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── vtt.ts
│ │ │ │ │ ├── vue-json.ts
│ │ │ │ │ ├── xcode-strings
│ │ │ │ │ │ ├── escape.ts
│ │ │ │ │ │ ├── parser.ts
│ │ │ │ │ │ ├── tokenizer.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── xcode-strings.spec.ts
│ │ │ │ │ ├── xcode-strings.ts
│ │ │ │ │ ├── xcode-stringsdict.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.ts
│ │ │ │ │ ├── xcode-xcstrings-lock-compatibility.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-v2-loader.ts
│ │ │ │ │ ├── xcode-xcstrings.spec.ts
│ │ │ │ │ ├── xcode-xcstrings.ts
│ │ │ │ │ ├── xliff.spec.ts
│ │ │ │ │ ├── xliff.ts
│ │ │ │ │ ├── xml.ts
│ │ │ │ │ └── yaml.ts
│ │ │ │ ├── localizer
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── explicit.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingodotdev.ts
│ │ │ │ ├── processor
│ │ │ │ │ ├── _base.ts
│ │ │ │ │ ├── basic.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingo.ts
│ │ │ │ └── utils
│ │ │ │ ├── auth.ts
│ │ │ │ ├── buckets.spec.ts
│ │ │ │ ├── buckets.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── cloudflare-status.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── delta.spec.ts
│ │ │ │ ├── delta.ts
│ │ │ │ ├── ensure-patterns.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── exec.spec.ts
│ │ │ │ ├── exec.ts
│ │ │ │ ├── exit-gracefully.spec.ts
│ │ │ │ ├── exit-gracefully.ts
│ │ │ │ ├── exp-backoff.ts
│ │ │ │ ├── find-locale-paths.spec.ts
│ │ │ │ ├── find-locale-paths.ts
│ │ │ │ ├── fs.ts
│ │ │ │ ├── init-ci-cd.ts
│ │ │ │ ├── key-matching.spec.ts
│ │ │ │ ├── key-matching.ts
│ │ │ │ ├── lockfile.ts
│ │ │ │ ├── md5.ts
│ │ │ │ ├── observability.ts
│ │ │ │ ├── plutil-formatter.spec.ts
│ │ │ │ ├── plutil-formatter.ts
│ │ │ │ ├── settings.ts
│ │ │ │ ├── ui.ts
│ │ │ │ └── update-gitignore.ts
│ │ │ ├── compiler
│ │ │ │ └── index.ts
│ │ │ ├── locale-codes
│ │ │ │ └── index.ts
│ │ │ ├── react
│ │ │ │ ├── client.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── react-router.ts
│ │ │ │ └── rsc.ts
│ │ │ ├── sdk
│ │ │ │ └── index.ts
│ │ │ └── spec
│ │ │ └── index.ts
│ │ ├── tests
│ │ │ └── mock-storage.ts
│ │ ├── troubleshooting.md
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ ├── tsup.config.ts
│ │ ├── types
│ │ │ ├── vtt.d.ts
│ │ │ └── xliff.d.ts
│ │ ├── vitest.config.ts
│ │ └── WATCH_MODE.md
│ ├── compiler
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── _base.ts
│ │ │ ├── _const.ts
│ │ │ ├── _loader-utils.spec.ts
│ │ │ ├── _loader-utils.ts
│ │ │ ├── _utils.spec.ts
│ │ │ ├── _utils.ts
│ │ │ ├── client-dictionary-loader.ts
│ │ │ ├── i18n-directive.spec.ts
│ │ │ ├── i18n-directive.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── jsx-attribute-flag.spec.ts
│ │ │ ├── jsx-attribute-flag.ts
│ │ │ ├── jsx-attribute-scope-inject.spec.ts
│ │ │ ├── jsx-attribute-scope-inject.ts
│ │ │ ├── jsx-attribute-scopes-export.spec.ts
│ │ │ ├── jsx-attribute-scopes-export.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-fragment.spec.ts
│ │ │ ├── jsx-fragment.ts
│ │ │ ├── jsx-html-lang.spec.ts
│ │ │ ├── jsx-html-lang.ts
│ │ │ ├── jsx-provider.spec.ts
│ │ │ ├── jsx-provider.ts
│ │ │ ├── jsx-remove-attributes.spec.ts
│ │ │ ├── jsx-remove-attributes.ts
│ │ │ ├── jsx-root-flag.spec.ts
│ │ │ ├── jsx-root-flag.ts
│ │ │ ├── jsx-scope-flag.spec.ts
│ │ │ ├── jsx-scope-flag.ts
│ │ │ ├── jsx-scope-inject.spec.ts
│ │ │ ├── jsx-scope-inject.ts
│ │ │ ├── jsx-scopes-export.spec.ts
│ │ │ ├── jsx-scopes-export.ts
│ │ │ ├── lib
│ │ │ │ └── lcp
│ │ │ │ ├── api
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prompt.spec.ts
│ │ │ │ │ ├── prompt.ts
│ │ │ │ │ ├── provider-details.spec.ts
│ │ │ │ │ ├── provider-details.ts
│ │ │ │ │ ├── shots.ts
│ │ │ │ │ ├── xml2obj.spec.ts
│ │ │ │ │ └── xml2obj.ts
│ │ │ │ ├── api.spec.ts
│ │ │ │ ├── cache.spec.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── server.spec.ts
│ │ │ │ └── server.ts
│ │ │ ├── lingo-turbopack-loader.ts
│ │ │ ├── react-router-dictionary-loader.ts
│ │ │ ├── rsc-dictionary-loader.ts
│ │ │ └── utils
│ │ │ ├── ast-key.spec.ts
│ │ │ ├── ast-key.ts
│ │ │ ├── create-locale-import-map.spec.ts
│ │ │ ├── create-locale-import-map.ts
│ │ │ ├── env.spec.ts
│ │ │ ├── env.ts
│ │ │ ├── hash.spec.ts
│ │ │ ├── hash.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── invokations.spec.ts
│ │ │ ├── invokations.ts
│ │ │ ├── jsx-attribute-scope.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-content-whitespace.spec.ts
│ │ │ ├── jsx-content.spec.ts
│ │ │ ├── jsx-content.ts
│ │ │ ├── jsx-element.spec.ts
│ │ │ ├── jsx-element.ts
│ │ │ ├── jsx-expressions.test.ts
│ │ │ ├── jsx-expressions.ts
│ │ │ ├── jsx-functions.spec.ts
│ │ │ ├── jsx-functions.ts
│ │ │ ├── jsx-scope.spec.ts
│ │ │ ├── jsx-scope.ts
│ │ │ ├── jsx-variables.spec.ts
│ │ │ ├── jsx-variables.ts
│ │ │ ├── llm-api-key.ts
│ │ │ ├── llm-api-keys.spec.ts
│ │ │ ├── locales.spec.ts
│ │ │ ├── locales.ts
│ │ │ ├── module-params.spec.ts
│ │ │ ├── module-params.ts
│ │ │ ├── observability.spec.ts
│ │ │ ├── observability.ts
│ │ │ ├── rc.spec.ts
│ │ │ └── rc.ts
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ └── vitest.config.ts
│ ├── locales
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── names
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── integration.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── parser.spec.ts
│ │ │ ├── parser.ts
│ │ │ ├── types.ts
│ │ │ ├── validation.spec.ts
│ │ │ └── validation.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── react
│ │ ├── build.config.ts
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── context.spec.tsx
│ │ │ │ ├── context.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── locale-switcher.spec.tsx
│ │ │ │ ├── locale-switcher.tsx
│ │ │ │ ├── locale.spec.ts
│ │ │ │ ├── locale.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── core
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── const.ts
│ │ │ │ ├── get-dictionary.spec.ts
│ │ │ │ ├── get-dictionary.ts
│ │ │ │ └── index.ts
│ │ │ ├── react-router
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── rsc
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ └── test
│ │ │ └── setup.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── sdk
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── abort-controller.specs.ts
│ │ │ ├── index.spec.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ └── tsup.config.ts
│ └── spec
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── config.spec.ts
│ │ ├── config.ts
│ │ ├── formats.ts
│ │ ├── index.spec.ts
│ │ ├── index.ts
│ │ ├── json-schema.ts
│ │ ├── locales.spec.ts
│ │ └── locales.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── readme
│ ├── ar.md
│ ├── bn.md
│ ├── de.md
│ ├── en.md
│ ├── es.md
│ ├── fa.md
│ ├── fr.md
│ ├── he.md
│ ├── hi.md
│ ├── it.md
│ ├── ja.md
│ ├── ko.md
│ ├── pl.md
│ ├── pt-BR.md
│ ├── ru.md
│ ├── tr.md
│ ├── uk-UA.md
│ └── zh-Hans.md
├── readme.md
├── scripts
│ ├── docs
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── generate-cli-docs.ts
│ │ │ ├── generate-config-docs.ts
│ │ │ ├── json-schema
│ │ │ │ ├── markdown-renderer.test.ts
│ │ │ │ ├── markdown-renderer.ts
│ │ │ │ ├── parser.test.ts
│ │ │ │ ├── parser.ts
│ │ │ │ └── types.ts
│ │ │ ├── utils.test.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ └── packagist-publish.php
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/packages/cli/src/cli/utils/delta.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2 | import { createDeltaProcessor } from "./delta";
3 | import * as path from "path";
4 | import { tryReadFile, writeFile, checkIfFileExists } from "../utils/fs";
5 | import YAML from "yaml";
6 |
7 | // Setup mocks before importing the module
8 | vi.mock("object-hash", () => ({
9 | MD5: vi.fn().mockImplementation((value) => `mocked-hash-${value}`),
10 | }));
11 |
12 | // Mock dependencies
13 | vi.mock("path", () => ({
14 | join: vi.fn(() => "/mocked/path/i18n.lock"),
15 | }));
16 |
17 | vi.mock("../utils/fs", () => ({
18 | tryReadFile: vi.fn(),
19 | writeFile: vi.fn(),
20 | checkIfFileExists: vi.fn(),
21 | }));
22 |
23 | // Import MD5 after mocking
24 | import { MD5 } from "object-hash";
25 |
26 | describe("createDeltaProcessor", () => {
27 | const mockFileKey = "test-file-key";
28 | let mockProcessor;
29 |
30 | beforeEach(() => {
31 | vi.clearAllMocks();
32 | // Reset the mock implementation for MD5
33 | (MD5 as any).mockImplementation((value) => `mocked-hash-${value}`);
34 | // Create a new processor instance for each test
35 | mockProcessor = createDeltaProcessor(mockFileKey);
36 | });
37 |
38 | describe("checkIfLockExists", () => {
39 | it("should call checkIfFileExists with the correct path", async () => {
40 | (checkIfFileExists as any).mockResolvedValue(true);
41 |
42 | const result = await mockProcessor.checkIfLockExists();
43 |
44 | expect(path.join).toHaveBeenCalledWith(process.cwd(), "i18n.lock");
45 | expect(checkIfFileExists).toHaveBeenCalledWith("/mocked/path/i18n.lock");
46 | expect(result).toBe(true);
47 | });
48 | });
49 |
50 | describe("calculateDelta", () => {
51 | it("should correctly identify added keys", async () => {
52 | const sourceData = { key1: "value1", key2: "value2" };
53 | const targetData = { key1: "value1" };
54 | const checksums = { key1: "checksum1" };
55 |
56 | const result = await mockProcessor.calculateDelta({
57 | sourceData,
58 | targetData,
59 | checksums,
60 | });
61 |
62 | expect(result.added).toEqual(["key2"]);
63 | expect(result.hasChanges).toBe(true);
64 | });
65 |
66 | it("should correctly identify removed keys", async () => {
67 | const sourceData = { key1: "value1" };
68 | const targetData = { key1: "value1", key2: "value2" };
69 | const checksums = { key1: "checksum1", key2: "checksum2" };
70 |
71 | const result = await mockProcessor.calculateDelta({
72 | sourceData,
73 | targetData,
74 | checksums,
75 | });
76 |
77 | expect(result.removed).toEqual(["key2"]);
78 | expect(result.hasChanges).toBe(true);
79 | });
80 |
81 | it("should correctly identify updated keys", async () => {
82 | const sourceData = { key1: "new-value1" };
83 | const targetData = { key1: "value1" };
84 | const checksums = { key1: "old-checksum" }; // Different from MD5(new-value1)
85 |
86 | const result = await mockProcessor.calculateDelta({
87 | sourceData,
88 | targetData,
89 | checksums,
90 | });
91 |
92 | expect(result.updated).toContain("key1");
93 | expect(result.hasChanges).toBe(true);
94 | });
95 |
96 | it("should correctly identify renamed keys", async () => {
97 | // Mock to simulate a renamed key (same hash but different key name)
98 | (MD5 as any).mockImplementation((value) =>
99 | value === "value1" ? "same-hash" : "other-hash",
100 | );
101 |
102 | const sourceData = { newKey: "value1" };
103 | const targetData = { oldKey: "something" };
104 | const checksums = { oldKey: "same-hash" };
105 |
106 | const result = await mockProcessor.calculateDelta({
107 | sourceData,
108 | targetData,
109 | checksums,
110 | });
111 |
112 | expect(result.renamed).toEqual([["oldKey", "newKey"]]);
113 | expect(result.added).toEqual([]);
114 | expect(result.removed).toEqual([]);
115 | expect(result.hasChanges).toBe(true);
116 | });
117 |
118 | it("should return hasChanges=false when there are no changes", async () => {
119 | const sourceData = { key1: "value1" };
120 | const targetData = { key1: "value1" };
121 |
122 | // Mock to simulate matching checksums
123 | (MD5 as any).mockImplementation((value) => "matching-hash");
124 | const checksums = { key1: "matching-hash" };
125 |
126 | const result = await mockProcessor.calculateDelta({
127 | sourceData,
128 | targetData,
129 | checksums,
130 | });
131 |
132 | expect(result.added).toEqual([]);
133 | expect(result.removed).toEqual([]);
134 | expect(result.updated).toEqual([]);
135 | expect(result.renamed).toEqual([]);
136 | expect(result.hasChanges).toBe(false);
137 | });
138 | });
139 |
140 | describe("loadLock", () => {
141 | it("should return default lock data when no file exists", async () => {
142 | (tryReadFile as any).mockReturnValue(null);
143 |
144 | const result = await mockProcessor.loadLock();
145 |
146 | expect(result).toEqual({
147 | version: 1,
148 | checksums: {},
149 | });
150 | });
151 |
152 | it("should parse and return lock file data when it exists", async () => {
153 | const mockYaml = "version: 1\nchecksums:\n fileId:\n key1: checksum1";
154 | (tryReadFile as any).mockReturnValue(mockYaml);
155 |
156 | const result = await mockProcessor.loadLock();
157 |
158 | expect(result).toEqual({
159 | version: 1,
160 | checksums: {
161 | fileId: {
162 | key1: "checksum1",
163 | },
164 | },
165 | });
166 | });
167 | });
168 |
169 | describe("saveLock", () => {
170 | it("should stringify and save lock data", async () => {
171 | const lockData = {
172 | version: 1 as const,
173 | checksums: {
174 | fileId: {
175 | key1: "checksum1",
176 | },
177 | },
178 | };
179 |
180 | await mockProcessor.saveLock(lockData);
181 |
182 | expect(writeFile).toHaveBeenCalledWith(
183 | "/mocked/path/i18n.lock",
184 | expect.any(String),
185 | );
186 |
187 | // Verify the YAML conversion is correct
188 | const yamlArg = (writeFile as any).mock.calls[0][1];
189 | const parsedBack = YAML.parse(yamlArg);
190 | expect(parsedBack).toEqual(lockData);
191 | });
192 | });
193 |
194 | describe("loadChecksums and saveChecksums", () => {
195 | it("should load checksums for the specific file key", async () => {
196 | // Reset MD5 implementation for fileKey hash
197 | (MD5 as any).mockImplementation((value) => "mocked-hash");
198 |
199 | // Mock the loadLock to return specific data
200 | const mockLockData = {
201 | version: 1 as const,
202 | checksums: {
203 | "mocked-hash": {
204 | key1: "checksum1",
205 | },
206 | },
207 | };
208 |
209 | vi.spyOn(mockProcessor, "loadLock").mockResolvedValue(mockLockData);
210 |
211 | const result = await mockProcessor.loadChecksums();
212 |
213 | expect(result).toEqual({
214 | key1: "checksum1",
215 | });
216 | });
217 |
218 | it("should save checksums for the specific file key", async () => {
219 | const checksums = { key1: "checksum1" };
220 |
221 | // Reset MD5 implementation for fileKey hash
222 | (MD5 as any).mockImplementation((value) => "mocked-hash");
223 |
224 | // Mock loadLock and saveLock
225 | const mockLockData = {
226 | version: 1 as const,
227 | checksums: {},
228 | };
229 | vi.spyOn(mockProcessor, "loadLock").mockResolvedValue(mockLockData);
230 | const saveLockSpy = vi
231 | .spyOn(mockProcessor, "saveLock")
232 | .mockResolvedValue(void 0);
233 |
234 | await mockProcessor.saveChecksums(checksums);
235 |
236 | expect(saveLockSpy).toHaveBeenCalledWith({
237 | version: 1,
238 | checksums: {
239 | "mocked-hash": checksums,
240 | },
241 | });
242 | });
243 | });
244 |
245 | describe("createChecksums", () => {
246 | it("should create checksums from source data", async () => {
247 | const sourceData = {
248 | key1: "value1",
249 | key2: "value2",
250 | };
251 |
252 | // Setup counter for mock
253 | let counter = 0;
254 | (MD5 as any).mockImplementation((value) => `mock-hash-${++counter}`);
255 |
256 | const result = await mockProcessor.createChecksums(sourceData);
257 |
258 | expect(result).toEqual({
259 | key1: "mock-hash-1",
260 | key2: "mock-hash-2",
261 | });
262 | });
263 | });
264 | });
265 |
```
--------------------------------------------------------------------------------
/legacy/sdk/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # @replexica/sdk
2 |
3 | ## 0.7.11
4 |
5 | ### Patch Changes
6 |
7 | - [`5dee9ee`](https://github.com/lingodotdev/lingo.dev/commit/5dee9ee743fbef489fbe342597a768ebd59e5f67) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add proxies to legacy packages
8 |
9 | - [`63eb57b`](https://github.com/lingodotdev/lingo.dev/commit/63eb57b8f4cc37605be196085fafbbfdab71cce5) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add deprecation message to legacy package jsons
10 |
11 | - [`bbf7760`](https://github.com/lingodotdev/lingo.dev/commit/bbf7760580f1631805d68612053ebcd4601bb02b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add deprecation warning to the legacy package proxies
12 |
13 | - Updated dependencies [[`b4c7f1e`](https://github.com/lingodotdev/lingo.dev/commit/b4c7f1e86334d229bee62219c26f30d0b523926d)]:
14 | - [email protected]
15 |
16 | ## 0.7.10
17 |
18 | ### Patch Changes
19 |
20 | - Updated dependencies [[`003344f`](https://github.com/lingodotdev/lingo.dev/commit/003344ffcca98a391a298707f18476971c4d4c2b)]:
21 | - @replexica/[email protected]
22 |
23 | ## 0.7.9
24 |
25 | ### Patch Changes
26 |
27 | - Updated dependencies [[`a2ada16`](https://github.com/lingodotdev/lingo.dev/commit/a2ada16ecf4cd559d3486f0e4756d58808194f7e)]:
28 | - @replexica/[email protected]
29 |
30 | ## 0.7.8
31 |
32 | ### Patch Changes
33 |
34 | - Updated dependencies [[`e6521b8`](https://github.com/lingodotdev/lingo.dev/commit/e6521b86637c254c011aba89a3558802c04ab3ca)]:
35 | - @replexica/[email protected]
36 |
37 | ## 0.7.7
38 |
39 | ### Patch Changes
40 |
41 | - Updated dependencies [[`cff3c4e`](https://github.com/lingodotdev/lingo.dev/commit/cff3c4eb1a40f82a9c4c095e49cfd9fce053b048)]:
42 | - @replexica/[email protected]
43 |
44 | ## 0.7.6
45 |
46 | ### Patch Changes
47 |
48 | - Updated dependencies [[`58d7b35`](https://github.com/lingodotdev/lingo.dev/commit/58d7b3567e51cc3ef0fad0288c13451381b95a98)]:
49 | - @replexica/[email protected]
50 |
51 | ## 0.7.5
52 |
53 | ### Patch Changes
54 |
55 | - Updated dependencies [[`9cf5299`](https://github.com/lingodotdev/lingo.dev/commit/9cf5299f7efbef70fd83f95177eac49b4d8f8007), [`3ab5de6`](https://github.com/lingodotdev/lingo.dev/commit/3ab5de66d8a913297b46095c2e73823124cc8c5b)]:
56 | - @replexica/[email protected]
57 |
58 | ## 0.7.4
59 |
60 | ### Patch Changes
61 |
62 | - Updated dependencies [[`1556977`](https://github.com/lingodotdev/lingo.dev/commit/1556977332a6f949100283bfa8c9a9ff5e74b156)]:
63 | - @replexica/[email protected]
64 |
65 | ## 0.7.3
66 |
67 | ### Patch Changes
68 |
69 | - [`cbef8f3`](https://github.com/lingodotdev/lingo.dev/commit/cbef8f3cafdc955d61053ce885d98e425acb668d) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - moved jsdom import into the html handler function
70 |
71 | ## 0.7.2
72 |
73 | ### Patch Changes
74 |
75 | - Updated dependencies [[`5cb3c93`](https://github.com/lingodotdev/lingo.dev/commit/5cb3c930fff6e30cff5cc2266b794f75a0db646d)]:
76 | - @replexica/[email protected]
77 |
78 | ## 0.7.1
79 |
80 | ### Patch Changes
81 |
82 | - [`db819a4`](https://github.com/lingodotdev/lingo.dev/commit/db819a42412ceb67fedbe729b7d018952686d60b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - reduce default batch size to avoid hitting rate limits
83 |
84 | - [`2c5cbcf`](https://github.com/lingodotdev/lingo.dev/commit/2c5cbcfbf6feb28440255cdea0818c8cefa61d91) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - filter out non extistent keys
85 |
86 | ## 0.7.0
87 |
88 | ### Minor Changes
89 |
90 | - [`c42dc2d`](https://github.com/lingodotdev/lingo.dev/commit/c42dc2d5b4efe95e804b5a7e7f6d354cf8622dc7) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add `batchLocalizeText` to sdk
91 |
92 | ## 0.6.0
93 |
94 | ### Minor Changes
95 |
96 | - [`a71a88e`](https://github.com/lingodotdev/lingo.dev/commit/a71a88e5c8bd6601b0838c381433a87763142801) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fast mode
97 |
98 | ### Patch Changes
99 |
100 | - [`f0a77ad`](https://github.com/lingodotdev/lingo.dev/commit/f0a77ad774a01c30e7e9bc5a0253638176332fd2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - updated default batch size limits in the SDK
101 |
102 | ## 0.5.0
103 |
104 | ### Minor Changes
105 |
106 | - [`ebf44cb`](https://github.com/lingodotdev/lingo.dev/commit/ebf44cbb462516abfe660c295c04627796c5a3a7) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - implement recognize locale
107 |
108 | - [`42d0a5a`](https://github.com/lingodotdev/lingo.dev/commit/42d0a5a7a53e296192a31e8f1d67c126793ea280) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added .localizeHtml implementation to SDK
109 |
110 | ### Patch Changes
111 |
112 | - Updated dependencies [[`a6b22a3`](https://github.com/lingodotdev/lingo.dev/commit/a6b22a3237f574455d8119f914d82b0b247b4151)]:
113 | - @replexica/[email protected]
114 |
115 | ## 0.4.3
116 |
117 | ### Patch Changes
118 |
119 | - Updated dependencies [[`091ee35`](https://github.com/lingodotdev/lingo.dev/commit/091ee353081795bf8f61c7d41483bc309c7b62ef)]:
120 | - @replexica/[email protected]
121 |
122 | ## 0.4.2
123 |
124 | ### Patch Changes
125 |
126 | - Updated dependencies [[`5e282d7`](https://github.com/lingodotdev/lingo.dev/commit/5e282d7ffa5ca9494aa7046a090bb7c327085a86)]:
127 | - @replexica/[email protected]
128 |
129 | ## 0.4.1
130 |
131 | ### Patch Changes
132 |
133 | - Updated dependencies [[`0071cd6`](https://github.com/lingodotdev/lingo.dev/commit/0071cd66b1c868ad3898fc368390a628c5a67767)]:
134 | - @replexica/[email protected]
135 |
136 | ## 0.4.0
137 |
138 | ### Minor Changes
139 |
140 | - [#264](https://github.com/lingodotdev/lingo.dev/pull/264) [`cdef5b7`](https://github.com/lingodotdev/lingo.dev/commit/cdef5b7bfbee4670c6de62cf4b4f3e0315748e25) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added format specific methods to `@replexica/sdk`
141 |
142 | ## 0.3.4
143 |
144 | ### Patch Changes
145 |
146 | - Updated dependencies [[`2859938`](https://github.com/lingodotdev/lingo.dev/commit/28599388a91bf80cea3813bb4b8999bb4df302c9)]:
147 | - @replexica/[email protected]
148 |
149 | ## 0.3.3
150 |
151 | ### Patch Changes
152 |
153 | - Updated dependencies [[`ca9e20e`](https://github.com/lingodotdev/lingo.dev/commit/ca9e20eef9047e20d39ccf9dff74d2f6069d4676), [`2aedf3b`](https://github.com/lingodotdev/lingo.dev/commit/2aedf3bec2d9dffc7b43fc10dea0cab5742d44af), [`626082a`](https://github.com/lingodotdev/lingo.dev/commit/626082a64b88fb3b589acd950afeafe417ce5ddc)]:
154 | - @replexica/[email protected]
155 |
156 | ## 0.3.2
157 |
158 | ### Patch Changes
159 |
160 | - Updated dependencies [[`1601f70`](https://github.com/lingodotdev/lingo.dev/commit/1601f708bdf0ff1786d3bf9b19265ac5b567f740)]:
161 | - @replexica/[email protected]
162 |
163 | ## 0.3.1
164 |
165 | ### Patch Changes
166 |
167 | - Updated dependencies [[`bc5a28c`](https://github.com/lingodotdev/lingo.dev/commit/bc5a28c3c98b619872924b5f913229ac01387524)]:
168 | - @replexica/[email protected]
169 |
170 | ## 0.3.0
171 |
172 | ### Minor Changes
173 |
174 | - [#165](https://github.com/lingodotdev/lingo.dev/pull/165) [`5c2ca37`](https://github.com/lingodotdev/lingo.dev/commit/5c2ca37114663eaeb529a027e33949ef3839549b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Update locale code resolution logic
175 |
176 | ### Patch Changes
177 |
178 | - Updated dependencies [[`5c2ca37`](https://github.com/lingodotdev/lingo.dev/commit/5c2ca37114663eaeb529a027e33949ef3839549b)]:
179 | - @replexica/[email protected]
180 |
181 | ## 0.2.1
182 |
183 | ### Patch Changes
184 |
185 | - Updated dependencies [[`6870fc7`](https://github.com/lingodotdev/lingo.dev/commit/6870fc758dae9d1adb641576befbd8cda61cd5ea)]:
186 | - @replexica/[email protected]
187 |
188 | ## 0.2.0
189 |
190 | ### Minor Changes
191 |
192 | - [`d6e6d5c`](https://github.com/lingodotdev/lingo.dev/commit/d6e6d5c24b266de3769e95545f74632e7d75c697) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for multisource localization to the CLI
193 |
194 | ### Patch Changes
195 |
196 | - Updated dependencies [[`d6e6d5c`](https://github.com/lingodotdev/lingo.dev/commit/d6e6d5c24b266de3769e95545f74632e7d75c697)]:
197 | - @replexica/[email protected]
198 |
199 | ## 0.1.1
200 |
201 | ### Patch Changes
202 |
203 | - Updated dependencies [[`73c9250`](https://github.com/lingodotdev/lingo.dev/commit/73c925084989ccea120cae1617ec87776c88e83e)]:
204 | - @replexica/[email protected]
205 |
206 | ## 0.1.0
207 |
208 | ### Minor Changes
209 |
210 | - [#142](https://github.com/lingodotdev/lingo.dev/pull/142) [`d9b0e51`](https://github.com/lingodotdev/lingo.dev/commit/d9b0e512196329cc781a4d33346f8ca0f3a81e7e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Extract API calling into SDK package
211 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/jsonc.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { parse, ParseError } from "jsonc-parser";
2 | import { ILoader } from "./_types";
3 | import { createLoader } from "./_utils";
4 |
5 | interface CommentInfo {
6 | hint?: string;
7 | [key: string]: any;
8 | }
9 |
10 | function extractCommentsFromJsonc(jsoncString: string): Record<string, any> {
11 | const lines = jsoncString.split("\n");
12 | const comments: Record<string, any> = {};
13 |
14 | // Parse to validate structure
15 | const errors: ParseError[] = [];
16 | const result = parse(jsoncString, errors, {
17 | allowTrailingComma: true,
18 | disallowComments: false,
19 | allowEmptyContent: true,
20 | });
21 |
22 | if (errors.length > 0) {
23 | return {};
24 | }
25 |
26 | // Track nesting context
27 | const contextStack: Array<{ key: string; isArray: boolean }> = [];
28 |
29 | for (let i = 0; i < lines.length; i++) {
30 | const line = lines[i];
31 | const trimmedLine = line.trim();
32 |
33 | if (!trimmedLine) continue;
34 |
35 | // Handle different comment types
36 | const commentData = extractCommentFromLine(line, lines, i);
37 | if (commentData.hint) {
38 | let keyInfo;
39 |
40 | if (commentData.isInline) {
41 | // For inline comments, extract key from the same line
42 | const keyMatch = line.match(/^\s*["']?([^"':,\s]+)["']?\s*:/);
43 | if (keyMatch) {
44 | const key = keyMatch[1];
45 | const path = contextStack.map((ctx) => ctx.key).filter(Boolean);
46 | keyInfo = { key, path };
47 | }
48 | } else {
49 | // For standalone comments, find the next key
50 | keyInfo = findAssociatedKey(lines, commentData.lineIndex, contextStack);
51 | }
52 |
53 | if (keyInfo && keyInfo.key) {
54 | setCommentAtPath(comments, keyInfo.path, keyInfo.key, commentData.hint);
55 | }
56 |
57 | // Skip processed lines for multi-line comments
58 | i = commentData.endIndex;
59 | continue;
60 | }
61 |
62 | // Update context for object/array nesting
63 | updateContext(contextStack, line, result);
64 | }
65 |
66 | return comments;
67 | }
68 |
69 | function extractCommentFromLine(
70 | line: string,
71 | lines: string[],
72 | lineIndex: number,
73 | ): {
74 | hint: string | null;
75 | lineIndex: number;
76 | endIndex: number;
77 | isInline: boolean;
78 | } {
79 | const trimmed = line.trim();
80 |
81 | // Single-line comment (standalone)
82 | if (trimmed.startsWith("//")) {
83 | const hint = trimmed.replace(/^\/\/\s*/, "").trim();
84 | return { hint, lineIndex, endIndex: lineIndex, isInline: false };
85 | }
86 |
87 | // Block comment (standalone or multi-line)
88 | if (trimmed.startsWith("/*")) {
89 | const blockResult = extractBlockComment(lines, lineIndex);
90 | return { ...blockResult, isInline: false };
91 | }
92 |
93 | // Inline comments (after JSON content)
94 | // Handle single-line inline comments
95 | const singleInlineMatch = line.match(/^(.+?)\s*\/\/\s*(.+)$/);
96 | if (singleInlineMatch && singleInlineMatch[1].includes(":")) {
97 | const hint = singleInlineMatch[2].trim();
98 | return { hint, lineIndex, endIndex: lineIndex, isInline: true };
99 | }
100 |
101 | // Handle block inline comments
102 | const blockInlineMatch = line.match(/^(.+?)\s*\/\*\s*(.*?)\s*\*\/.*$/);
103 | if (blockInlineMatch && blockInlineMatch[1].includes(":")) {
104 | const hint = blockInlineMatch[2].trim();
105 | return { hint, lineIndex, endIndex: lineIndex, isInline: true };
106 | }
107 |
108 | return { hint: null, lineIndex, endIndex: lineIndex, isInline: false };
109 | }
110 |
111 | function extractBlockComment(
112 | lines: string[],
113 | startIndex: number,
114 | ): { hint: string | null; lineIndex: number; endIndex: number } {
115 | const startLine = lines[startIndex];
116 |
117 | // Single-line block comment
118 | const singleMatch = startLine.match(/\/\*\s*(.*?)\s*\*\//);
119 | if (singleMatch) {
120 | return {
121 | hint: singleMatch[1].trim(),
122 | lineIndex: startIndex,
123 | endIndex: startIndex,
124 | };
125 | }
126 |
127 | // Multi-line block comment
128 | const commentParts: string[] = [];
129 | let endIndex = startIndex;
130 |
131 | // Extract content from first line
132 | const firstContent = startLine.replace(/.*?\/\*\s*/, "").trim();
133 | if (firstContent && !firstContent.includes("*/")) {
134 | commentParts.push(firstContent);
135 | }
136 |
137 | // Process subsequent lines
138 | for (let i = startIndex + 1; i < lines.length; i++) {
139 | const line = lines[i];
140 | endIndex = i;
141 |
142 | if (line.includes("*/")) {
143 | const lastContent = line
144 | .replace(/\*\/.*$/, "")
145 | .replace(/^\s*\*?\s*/, "")
146 | .trim();
147 | if (lastContent) {
148 | commentParts.push(lastContent);
149 | }
150 | break;
151 | } else {
152 | const content = line.replace(/^\s*\*?\s*/, "").trim();
153 | if (content) {
154 | commentParts.push(content);
155 | }
156 | }
157 | }
158 |
159 | return {
160 | hint: commentParts.join(" ").trim() || null,
161 | lineIndex: startIndex,
162 | endIndex,
163 | };
164 | }
165 |
166 | function findAssociatedKey(
167 | lines: string[],
168 | commentLineIndex: number,
169 | contextStack: Array<{ key: string; isArray: boolean }>,
170 | ): { key: string | null; path: string[] } {
171 | // Look for the next key after the comment
172 | for (let i = commentLineIndex + 1; i < lines.length; i++) {
173 | const line = lines[i].trim();
174 |
175 | if (
176 | !line ||
177 | line.startsWith("//") ||
178 | line.startsWith("/*") ||
179 | line === "{" ||
180 | line === "}"
181 | ) {
182 | continue;
183 | }
184 |
185 | // Extract key from line
186 | const keyMatch = line.match(/^\s*["']?([^"':,\s]+)["']?\s*:/);
187 | if (keyMatch) {
188 | const key = keyMatch[1];
189 | const path = contextStack.map((ctx) => ctx.key).filter(Boolean);
190 | return { key, path };
191 | }
192 | }
193 |
194 | return { key: null, path: [] };
195 | }
196 |
197 | function updateContext(
198 | contextStack: Array<{ key: string; isArray: boolean }>,
199 | line: string,
200 | parsedJson: any,
201 | ): void {
202 | // This is a simplified context tracking - in a full implementation,
203 | // you'd want more sophisticated AST-based tracking
204 | const openBraces = (line.match(/\{/g) || []).length;
205 | const closeBraces = (line.match(/\}/g) || []).length;
206 |
207 | if (openBraces > closeBraces) {
208 | // Extract the key that's opening this object
209 | const keyMatch = line.match(/^\s*["']?([^"':,\s]+)["']?\s*:\s*\{/);
210 | if (keyMatch) {
211 | contextStack.push({ key: keyMatch[1], isArray: false });
212 | }
213 | } else if (closeBraces > openBraces) {
214 | // Pop context when closing braces
215 | for (let i = 0; i < closeBraces - openBraces; i++) {
216 | contextStack.pop();
217 | }
218 | }
219 | }
220 |
221 | function setCommentAtPath(
222 | comments: Record<string, any>,
223 | path: string[],
224 | key: string,
225 | hint: string,
226 | ): void {
227 | let current = comments;
228 |
229 | // Navigate to the correct nested location
230 | for (const pathKey of path) {
231 | if (!current[pathKey]) {
232 | current[pathKey] = {};
233 | }
234 | current = current[pathKey];
235 | }
236 |
237 | // Set the hint for the key
238 | if (!current[key]) {
239 | current[key] = {};
240 | }
241 |
242 | if (typeof current[key] === "object" && current[key] !== null) {
243 | current[key].hint = hint;
244 | } else {
245 | current[key] = { hint };
246 | }
247 | }
248 |
249 | export default function createJsoncLoader(): ILoader<
250 | string,
251 | Record<string, any>
252 | > {
253 | return createLoader({
254 | pull: async (locale, input) => {
255 | const jsoncString = input || "{}";
256 | const errors: ParseError[] = [];
257 | const result = parse(jsoncString, errors, {
258 | allowTrailingComma: true,
259 | disallowComments: false,
260 | allowEmptyContent: true,
261 | });
262 |
263 | if (errors.length > 0) {
264 | throw new Error(`Failed to parse JSONC: ${errors[0].error}`);
265 | }
266 |
267 | return result || {};
268 | },
269 | push: async (locale, data) => {
270 | // JSONC parser's stringify preserves formatting but doesn't add comments
271 | // We'll use standard JSON.stringify with pretty formatting for output
272 | const serializedData = JSON.stringify(data, null, 2);
273 | return serializedData;
274 | },
275 | pullHints: async (input) => {
276 | if (!input || typeof input !== "string") {
277 | return {};
278 | }
279 |
280 | try {
281 | return extractCommentsFromJsonc(input);
282 | } catch (error) {
283 | console.warn("Failed to extract comments from JSONC:", error);
284 | return {};
285 | }
286 | },
287 | });
288 | }
289 |
```
--------------------------------------------------------------------------------
/packages/locales/src/names/integration.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from "vitest";
2 | import { getCountryName, getLanguageName, getScriptName } from "./index";
3 |
4 | // Mock the loader functions to return predictable data
5 | vi.mock("./loader", () => ({
6 | loadTerritoryNames: vi.fn(),
7 | loadLanguageNames: vi.fn(),
8 | loadScriptNames: vi.fn(),
9 | }));
10 |
11 | import {
12 | loadTerritoryNames,
13 | loadLanguageNames,
14 | loadScriptNames,
15 | } from "./loader";
16 |
17 | const mockLoadTerritoryNames = loadTerritoryNames as ReturnType<typeof vi.fn>;
18 | const mockLoadLanguageNames = loadLanguageNames as ReturnType<typeof vi.fn>;
19 | const mockLoadScriptNames = loadScriptNames as ReturnType<typeof vi.fn>;
20 |
21 | describe("Integration Tests", () => {
22 | beforeEach(() => {
23 | vi.clearAllMocks();
24 | });
25 |
26 | describe("getCountryName", () => {
27 | it("should get country names in different languages", async () => {
28 | // Mock data for different languages
29 | mockLoadTerritoryNames
30 | .mockResolvedValueOnce({ US: "United States", CN: "China" }) // en
31 | .mockResolvedValueOnce({ US: "Estados Unidos", CN: "China" }) // es
32 | .mockResolvedValueOnce({ US: "États-Unis", CN: "Chine" }); // fr
33 |
34 | const result1 = await getCountryName("US", "en");
35 | const result2 = await getCountryName("US", "es");
36 | const result3 = await getCountryName("US", "fr");
37 |
38 | expect(result1).toBe("United States");
39 | expect(result2).toBe("Estados Unidos");
40 | expect(result3).toBe("États-Unis");
41 |
42 | expect(mockLoadTerritoryNames).toHaveBeenCalledTimes(3);
43 | expect(mockLoadTerritoryNames).toHaveBeenNthCalledWith(1, "en");
44 | expect(mockLoadTerritoryNames).toHaveBeenNthCalledWith(2, "es");
45 | expect(mockLoadTerritoryNames).toHaveBeenNthCalledWith(3, "fr");
46 | });
47 |
48 | it("should normalize country codes to uppercase", async () => {
49 | mockLoadTerritoryNames.mockResolvedValue({ US: "United States" });
50 |
51 | const result = await getCountryName("us");
52 | expect(result).toBe("United States");
53 | });
54 |
55 | it("should handle loader errors gracefully", async () => {
56 | mockLoadTerritoryNames.mockRejectedValue(new Error("Network error"));
57 |
58 | await expect(getCountryName("US")).rejects.toThrow("Network error");
59 | });
60 | });
61 |
62 | describe("getLanguageName", () => {
63 | it("should get language names in different languages", async () => {
64 | mockLoadLanguageNames
65 | .mockResolvedValueOnce({ en: "English", es: "Spanish" }) // en
66 | .mockResolvedValueOnce({ en: "inglés", es: "español" }) // es
67 | .mockResolvedValueOnce({ en: "anglais", es: "espagnol" }); // fr
68 |
69 | const result1 = await getLanguageName("en", "en");
70 | const result2 = await getLanguageName("en", "es");
71 | const result3 = await getLanguageName("en", "fr");
72 |
73 | expect(result1).toBe("English");
74 | expect(result2).toBe("inglés");
75 | expect(result3).toBe("anglais");
76 | });
77 |
78 | it("should normalize language codes to lowercase", async () => {
79 | mockLoadLanguageNames.mockResolvedValue({ en: "English" });
80 |
81 | const result = await getLanguageName("EN");
82 | expect(result).toBe("English");
83 | });
84 | });
85 |
86 | describe("getScriptName", () => {
87 | it("should get script names in different languages", async () => {
88 | mockLoadScriptNames
89 | .mockResolvedValueOnce({
90 | Hans: "Simplified Han",
91 | Hant: "Traditional Han",
92 | }) // en
93 | .mockResolvedValueOnce({
94 | Hans: "han simplificado",
95 | Hant: "han tradicional",
96 | }) // es
97 | .mockResolvedValueOnce({
98 | Hans: "han simplifié",
99 | Hant: "han traditionnel",
100 | }); // fr
101 |
102 | const result1 = await getScriptName("Hans", "en");
103 | const result2 = await getScriptName("Hans", "es");
104 | const result3 = await getScriptName("Hans", "fr");
105 |
106 | expect(result1).toBe("Simplified Han");
107 | expect(result2).toBe("han simplificado");
108 | expect(result3).toBe("han simplifié");
109 | });
110 |
111 | it("should preserve script code case", async () => {
112 | mockLoadScriptNames.mockResolvedValue({
113 | Latn: "Latin",
114 | CYRL: "Cyrillic",
115 | hans: "Simplified Han",
116 | });
117 |
118 | const result1 = await getScriptName("Latn");
119 | const result2 = await getScriptName("CYRL");
120 | const result3 = await getScriptName("hans");
121 |
122 | expect(result1).toBe("Latin");
123 | expect(result2).toBe("Cyrillic");
124 | expect(result3).toBe("Simplified Han");
125 | });
126 | });
127 |
128 | describe("Error handling", () => {
129 | it("should throw for empty inputs", async () => {
130 | await expect(getCountryName("")).rejects.toThrow(
131 | "Country code is required",
132 | );
133 | await expect(getLanguageName("")).rejects.toThrow(
134 | "Language code is required",
135 | );
136 | await expect(getScriptName("")).rejects.toThrow(
137 | "Script code is required",
138 | );
139 | });
140 |
141 | it("should throw for null/undefined inputs", async () => {
142 | await expect(getCountryName(null as any)).rejects.toThrow(
143 | "Country code is required",
144 | );
145 | await expect(getLanguageName(undefined as any)).rejects.toThrow(
146 | "Language code is required",
147 | );
148 | await expect(getScriptName(null as any)).rejects.toThrow(
149 | "Script code is required",
150 | );
151 | });
152 |
153 | it("should throw for unknown codes", async () => {
154 | mockLoadTerritoryNames.mockResolvedValue({ US: "United States" });
155 | mockLoadLanguageNames.mockResolvedValue({ en: "English" });
156 | mockLoadScriptNames.mockResolvedValue({ Latn: "Latin" });
157 |
158 | await expect(getCountryName("XX")).rejects.toThrow(
159 | 'Country code "XX" not found',
160 | );
161 | await expect(getLanguageName("xx")).rejects.toThrow(
162 | 'Language code "xx" not found',
163 | );
164 | await expect(getScriptName("Xxxx")).rejects.toThrow(
165 | 'Script code "Xxxx" not found',
166 | );
167 | });
168 | });
169 |
170 | describe("Real-world scenarios", () => {
171 | it("should handle Chinese locale names", async () => {
172 | mockLoadLanguageNames.mockResolvedValue({
173 | en: "英语",
174 | es: "西班牙语",
175 | fr: "法语",
176 | de: "德语",
177 | });
178 |
179 | const result1 = await getLanguageName("en", "zh");
180 | const result2 = await getLanguageName("es", "zh");
181 | const result3 = await getLanguageName("fr", "zh");
182 | const result4 = await getLanguageName("de", "zh");
183 |
184 | expect(result1).toBe("英语");
185 | expect(result2).toBe("西班牙语");
186 | expect(result3).toBe("法语");
187 | expect(result4).toBe("德语");
188 | });
189 |
190 | it("should handle Arabic locale names", async () => {
191 | mockLoadTerritoryNames.mockResolvedValue({
192 | US: "الولايات المتحدة",
193 | GB: "المملكة المتحدة",
194 | FR: "فرنسا",
195 | });
196 |
197 | const result1 = await getCountryName("US", "ar");
198 | const result2 = await getCountryName("GB", "ar");
199 | const result3 = await getCountryName("FR", "ar");
200 |
201 | expect(result1).toBe("الولايات المتحدة");
202 | expect(result2).toBe("المملكة المتحدة");
203 | expect(result3).toBe("فرنسا");
204 | });
205 |
206 | it("should handle script variants", async () => {
207 | mockLoadScriptNames.mockResolvedValue({
208 | Hans: "Simplified Han",
209 | Hant: "Traditional Han",
210 | Latn: "Latin",
211 | Cyrl: "Cyrillic",
212 | Arab: "Arabic",
213 | Deva: "Devanagari",
214 | });
215 |
216 | const result1 = await getScriptName("Hans");
217 | const result2 = await getScriptName("Hant");
218 | const result3 = await getScriptName("Latn");
219 | const result4 = await getScriptName("Cyrl");
220 | const result5 = await getScriptName("Arab");
221 | const result6 = await getScriptName("Deva");
222 |
223 | expect(result1).toBe("Simplified Han");
224 | expect(result2).toBe("Traditional Han");
225 | expect(result3).toBe("Latin");
226 | expect(result4).toBe("Cyrillic");
227 | expect(result5).toBe("Arabic");
228 | expect(result6).toBe("Devanagari");
229 | });
230 | });
231 | });
232 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/cmd/init.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { InteractiveCommand, InteractiveOption } from "interactive-commander";
2 | import Ora from "ora";
3 | import { getConfig, saveConfig } from "../utils/config";
4 | import {
5 | defaultConfig,
6 | LocaleCode,
7 | resolveLocaleCode,
8 | bucketTypes,
9 | } from "@lingo.dev/_spec";
10 | import fs from "fs";
11 | import path from "path";
12 | import _ from "lodash";
13 | import { checkbox, confirm, input } from "@inquirer/prompts";
14 | import { login } from "./login";
15 | import { getSettings, saveSettings } from "../utils/settings";
16 | import { createAuthenticator } from "../utils/auth";
17 | import findLocaleFiles from "../utils/find-locale-paths";
18 | import { ensurePatterns } from "../utils/ensure-patterns";
19 | import updateGitignore from "../utils/update-gitignore";
20 | import initCICD from "../utils/init-ci-cd";
21 | import open from "open";
22 |
23 | const openUrl = (path: string) => {
24 | const settings = getSettings(undefined);
25 | open(`${settings.auth.webUrl}${path}`, { wait: false });
26 | };
27 |
28 | const throwHelpError = (option: string, value: string) => {
29 | if (value === "help") {
30 | openUrl("/go/call");
31 | }
32 | throw new Error(
33 | `Invalid ${option}: ${value}\n\nDo you need support for ${value} ${option}? Type "help" and we will.`,
34 | );
35 | };
36 |
37 | export default new InteractiveCommand()
38 | .command("init")
39 | .description("Create i18n.json configuration file for a new project")
40 | .helpOption("-h, --help", "Show help")
41 | .addOption(
42 | new InteractiveOption(
43 | "-f --force",
44 | "Overwrite existing Lingo.dev configuration instead of aborting initialization (destructive operation)",
45 | )
46 | .prompt(undefined)
47 | .default(false),
48 | )
49 | .addOption(
50 | new InteractiveOption(
51 | "-s --source <locale>",
52 | "Primary language of your application that content will be translated from. Defaults to 'en'",
53 | )
54 | .argParser((value) => {
55 | try {
56 | resolveLocaleCode(value as LocaleCode);
57 | } catch (e) {
58 | throwHelpError("locale", value);
59 | }
60 | return value;
61 | })
62 | .default("en"),
63 | )
64 | .addOption(
65 | new InteractiveOption(
66 | "-t --targets <locale...>",
67 | "Target languages to translate to. Accepts locale codes like 'es', 'fr', 'de-AT' separated by commas or spaces. Defaults to 'es'",
68 | )
69 | .argParser((value) => {
70 | const values = (
71 | value.includes(",") ? value.split(",") : value.split(" ")
72 | ) as LocaleCode[];
73 | values.forEach((value) => {
74 | try {
75 | resolveLocaleCode(value);
76 | } catch (e) {
77 | throwHelpError("locale", value);
78 | }
79 | });
80 | return values;
81 | })
82 | .default("es"),
83 | )
84 | .addOption(
85 | new InteractiveOption(
86 | "-b, --bucket <type>",
87 | "File format for your translation files. Must match a supported type such as json, yaml, or android",
88 | )
89 | .argParser((value) => {
90 | if (!bucketTypes.includes(value as (typeof bucketTypes)[number])) {
91 | throwHelpError("bucket format", value);
92 | }
93 | return value;
94 | })
95 | .default("json"),
96 | )
97 | .addOption(
98 | new InteractiveOption(
99 | "-p, --paths [path...]",
100 | "File paths containing translations when using --no-interactive mode. Specify paths with [locale] placeholder, separated by commas or spaces",
101 | )
102 | .argParser((value) => {
103 | if (!value || value.length === 0) return [];
104 | const values = value.includes(",")
105 | ? value.split(",")
106 | : value.split(" ");
107 |
108 | for (const p of values) {
109 | try {
110 | const dirPath = path.dirname(p);
111 | const stats = fs.statSync(dirPath);
112 | if (!stats.isDirectory()) {
113 | throw new Error(`${dirPath} is not a directory`);
114 | }
115 | } catch (err) {
116 | throw new Error(`Invalid path: ${p}`);
117 | }
118 | }
119 |
120 | return values;
121 | })
122 | .prompt(undefined) // make non-interactive
123 | .default([]),
124 | )
125 | .action(async (options) => {
126 | const settings = getSettings(undefined);
127 | const isInteractive = options.interactive;
128 |
129 | const spinner = Ora().start("Initializing Lingo.dev project");
130 |
131 | let existingConfig = await getConfig(false);
132 | if (existingConfig && !options.force) {
133 | spinner.fail("Lingo.dev project already initialized");
134 | return process.exit(1);
135 | }
136 |
137 | const newConfig = _.cloneDeep(defaultConfig);
138 |
139 | newConfig.locale.source = options.source;
140 | newConfig.locale.targets = options.targets;
141 |
142 | if (!isInteractive) {
143 | newConfig.buckets = {
144 | [options.bucket]: {
145 | include: options.paths || [],
146 | },
147 | };
148 | } else {
149 | let selectedPatterns: string[] = [];
150 | const localeFiles = findLocaleFiles(options.bucket);
151 |
152 | if (!localeFiles) {
153 | spinner.warn(
154 | `Bucket type "${options.bucket}" does not supported automatic initialization. Add paths to "i18n.json" manually.`,
155 | );
156 | newConfig.buckets = {
157 | [options.bucket]: {
158 | include: options.paths || [],
159 | },
160 | };
161 | } else {
162 | const { patterns, defaultPatterns } = localeFiles;
163 |
164 | if (patterns.length > 0) {
165 | spinner.succeed("Found existing locale files:");
166 |
167 | selectedPatterns = await checkbox({
168 | message: "Select the paths to use",
169 | choices: patterns.map((value) => ({
170 | value,
171 | })),
172 | });
173 | } else {
174 | spinner.succeed("No existing locale files found.");
175 | }
176 |
177 | if (selectedPatterns.length === 0) {
178 | const useDefault = await confirm({
179 | message: `Use (and create) default path ${defaultPatterns.join(
180 | ", ",
181 | )}?`,
182 | });
183 | if (useDefault) {
184 | ensurePatterns(defaultPatterns, options.source);
185 | selectedPatterns = defaultPatterns;
186 | }
187 | }
188 |
189 | if (selectedPatterns.length === 0) {
190 | const customPaths = await input({
191 | message: "Enter paths to use",
192 | });
193 | selectedPatterns = customPaths.includes(",")
194 | ? customPaths.split(",")
195 | : customPaths.split(" ");
196 | }
197 |
198 | newConfig.buckets = {
199 | [options.bucket]: {
200 | include: selectedPatterns || [],
201 | },
202 | };
203 | }
204 | }
205 |
206 | await saveConfig(newConfig);
207 |
208 | spinner.succeed("Lingo.dev project initialized");
209 |
210 | if (isInteractive) {
211 | await initCICD(spinner);
212 |
213 | const openDocs = await confirm({
214 | message: "Would you like to see our docs?",
215 | });
216 | if (openDocs) {
217 | openUrl("/go/docs");
218 | }
219 | }
220 |
221 | const authenticator = createAuthenticator({
222 | apiKey: settings.auth.apiKey,
223 | apiUrl: settings.auth.apiUrl,
224 | });
225 | const auth = await authenticator.whoami();
226 | if (!auth) {
227 | if (isInteractive) {
228 | const doAuth = await confirm({
229 | message: "It looks like you are not logged into the CLI. Login now?",
230 | });
231 | if (doAuth) {
232 | const apiKey = await login(settings.auth.webUrl);
233 | settings.auth.apiKey = apiKey;
234 | await saveSettings(settings);
235 |
236 | const newAuthenticator = createAuthenticator({
237 | apiKey: settings.auth.apiKey,
238 | apiUrl: settings.auth.apiUrl,
239 | });
240 | const auth = await newAuthenticator.whoami();
241 | if (auth) {
242 | Ora().succeed(`Authenticated as ${auth?.email}`);
243 | } else {
244 | Ora().fail("Authentication failed.");
245 | }
246 | }
247 | } else {
248 | Ora().warn(
249 | "You are not logged in. Run `npx lingo.dev@latest login` to login.",
250 | );
251 | }
252 | } else {
253 | Ora().succeed(`Authenticated as ${auth.email}`);
254 | }
255 |
256 | updateGitignore();
257 |
258 | if (!isInteractive) {
259 | Ora().info("Please see https://lingo.dev/cli");
260 | }
261 | });
262 |
```
--------------------------------------------------------------------------------
/.claude/commands/create-bucket-docs.md:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | argument-hint: <analysis-output>
3 | description: Create documentation for a Lingo.dev bucket type using analysis output
4 | ---
5 |
6 | Using the bucket analysis output provided at the end of this prompt, create documentation for the specified bucket type in Lingo.dev CLI.
7 |
8 | ## Template Structure
9 |
10 | ````markdown
11 | ---
12 | title: "[BUCKET_TYPE in title case]"
13 | subtitle: "Translate [BUCKET_TYPE] files with Lingo.dev CLI"
14 | ---
15 |
16 | ## Introduction
17 |
18 | [BUCKET_TYPE in title case] files are [BRIEF DESCRIPTION OF THE FILE FORMAT, ITS PURPOSE AND PRIMARY USE CASE]. [ONE SENTENCE ABOUT STRUCTURE OR KEY CHARACTERISTICS].
19 |
20 | **Lingo.dev CLI** uses LLMs to translate your [BUCKET_TYPE] files across multiple locales. This guide shows you how to set up and run translations for [BUCKET_TYPE] files.
21 |
22 | ## Quickstart
23 |
24 | ### Step 1: Install Lingo.dev CLI
25 |
26 | ```bash
27 | # Install globally
28 | npm install -g lingo.dev@latest
29 |
30 | # Or run directly with npx
31 | npx lingo.dev@latest --version
32 | ```
33 |
34 | ### Step 2: Authenticate
35 |
36 | Log in to your Lingo.dev account:
37 |
38 | ```bash
39 | npx lingo.dev@latest login
40 | ```
41 |
42 | This opens your browser for authentication. Your API key is stored locally for future use.
43 |
44 | ### Step 3: Initialize Project
45 |
46 | Create your base configuration:
47 |
48 | ```bash
49 | npx lingo.dev@latest init
50 | ```
51 |
52 | This generates an `i18n.json` file with default settings.
53 |
54 | ### Step 4: Configure [BUCKET_TYPE] Bucket
55 |
56 | Update your `i18n.json` to add [BUCKET_TYPE] support:
57 |
58 | ```json
59 | {
60 | "$schema": "https://lingo.dev/schema/i18n.json",
61 | "version": "1.10",
62 | "locale": {
63 | "source": "en",
64 | "targets": ["es"]
65 | },
66 | "buckets": {
67 | "[BUCKET_TYPE]": {
68 | "include": ["[PATH_PATTERN]"]
69 | }
70 | }
71 | }
72 | ```
73 |
74 | [IF separate-files: **Note**: Keep `[locale]` as-is in the config — it's replaced with actual locale codes at runtime.]
75 | [IF in-place: DO NOT include any note about [locale]]
76 |
77 | ### Step 5: Create File Structure
78 |
79 | [FOR separate-files:]
80 | Organize your [BUCKET_TYPE] files by locale:
81 |
82 | ```
83 | [directory]/
84 | ├── en/
85 | │ └── [filename] # Source file
86 | └── es/ # Target directory (empty initially)
87 | ```
88 |
89 | Place your source [BUCKET_TYPE] files in the `en/` directory. The `es/` directory can be empty — translated files will be created there automatically.
90 |
91 | [FOR in-place:]
92 | Place your [BUCKET_TYPE] file in your project:
93 |
94 | ```
95 | [directory]/
96 | └── [filename] # Contains all locales
97 | ```
98 |
99 | This single file will contain translations for all configured locales.
100 |
101 | ### Step 6: Run Translation
102 |
103 | Execute the translation command:
104 |
105 | ```bash
106 | npx lingo.dev@latest i18n
107 | ```
108 |
109 | The CLI will:
110 |
111 | - Read [BUCKET_TYPE] files from your source locale
112 | - Translate content to target locales using LLMs
113 | - [FOR separate-files: Create new files in target directories (e.g., `es/[filename]`)]
114 | - [FOR in-place: Update the file with translations for all configured locales]
115 |
116 | [FOR separate-files: **Note**: Unlike some bucket types that modify files in place, the [BUCKET_TYPE] bucket creates separate files for each locale. Your source files remain unchanged.]
117 | [FOR in-place: **Note**: The [BUCKET_TYPE] bucket modifies the source file directly, adding translations for all target locales to the same file.]
118 |
119 | ### Step 7: Verify Results
120 |
121 | Check the translation status:
122 |
123 | ```bash
124 | npx lingo.dev@latest status
125 | ```
126 |
127 | [FOR separate-files: Review generated files in your target locale directory (`es/`).]
128 | [FOR in-place: Review the updated [filename] file which now contains all locales.]
129 |
130 | ## [Feature Sections - ONLY include supported features]
131 |
132 | [IF Locked Keys = YES:]
133 |
134 | ## Locked Content
135 |
136 | The [BUCKET_TYPE] bucket supports locking specific keys to prevent translation:
137 |
138 | ```json
139 | "[BUCKET_TYPE]": {
140 | "include": ["[PATH_PATTERN]"],
141 | "lockedKeys": ["key1", "key2", "nested/key3"]
142 | }
143 | ```
144 |
145 | This feature is available for [BUCKET_TYPE] and other structured format buckets where specific keys need to remain untranslated.
146 |
147 | [IF Ignored Keys = YES:]
148 |
149 | ## Ignored Keys
150 |
151 | The [BUCKET_TYPE] bucket supports ignoring keys entirely during processing:
152 |
153 | ```json
154 | "[BUCKET_TYPE]": {
155 | "include": ["[PATH_PATTERN]"],
156 | "ignoredKeys": ["debug", "internal/*"]
157 | }
158 | ```
159 |
160 | Unlike locked keys which preserve content, ignored keys are completely skipped during the translation process.
161 |
162 | [IF Inject Locale = YES:]
163 |
164 | ## Inject Locale
165 |
166 | The [BUCKET_TYPE] bucket supports automatically injecting locale codes into specific keys:
167 |
168 | ```json
169 | "[BUCKET_TYPE]": {
170 | "include": ["[PATH_PATTERN]"],
171 | "injectLocale": ["settings/language", "config/locale"]
172 | }
173 | ```
174 |
175 | These keys will automatically have their values replaced with the current locale code during translation.
176 |
177 | [IF Translator Notes = YES:]
178 |
179 | ## Translator Notes
180 |
181 | The [BUCKET_TYPE] bucket supports providing context hints to improve translation quality. [Describe how translator notes/hints work for this specific bucket type]
182 |
183 | ```[format]
184 | [Show example of how to add translator notes in this format]
185 | ```
186 |
187 | ## Example
188 |
189 | **Configuration** (`i18n.json`):
190 |
191 | ```json
192 | {
193 | "$schema": "https://lingo.dev/schema/i18n.json",
194 | "version": "1.10",
195 | "locale": {
196 | "source": "en",
197 | "targets": ["es"]
198 | },
199 | "buckets": {
200 | "[BUCKET_TYPE]": {
201 | "include": ["[REALISTIC_PATH]"]
202 | }
203 | }
204 | }
205 | ```
206 |
207 | [FOR separate-files:]
208 | **Input** (`[path]/en/[filename]`):
209 |
210 | ```[format]
211 | [Source content in appropriate format]
212 | ```
213 |
214 | **Output** (`[path]/es/[filename]`):
215 |
216 | ```[format]
217 | [Translated content in appropriate format]
218 | ```
219 |
220 | [FOR in-place:]
221 | **Before translation** (`[path]/[filename]`):
222 |
223 | ```[format]
224 | [Source content showing only English]
225 | ```
226 |
227 | **After translation** (`[path]/[filename]`):
228 |
229 | ```[format]
230 | [Same file now containing both English and Spanish]
231 | ```
232 | ````
233 |
234 | ## Critical Adaptation Rules
235 |
236 | ### For Separate-Files Buckets
237 |
238 | 1. **Always use `[locale]` placeholder** in paths
239 | 2. Step 5: Show source (`en/`) and target (`es/`) directories
240 | 3. Step 6: Explain "creates new files"
241 | 4. Include the [locale] note in Step 4
242 | 5. Example: Show input as `path/en/file.ext` and output as `path/es/file.ext`
243 |
244 | ### For In-Place Buckets
245 |
246 | 1. **Never use `[locale]` placeholder** anywhere in the document
247 | 2. **Never include the [locale] note** in Step 4
248 | 3. Step 5: Show single file path
249 | 4. Step 6: Explain "modifies the file directly"
250 | 5. Example: Use "Before translation" and "After translation" labels
251 | 6. Example: Show the same file path for both states
252 |
253 | ### Feature Sections
254 |
255 | - Only include sections for features marked YES
256 | - Locked Keys: Content is preserved unchanged
257 | - Ignored Keys: Keys are skipped entirely during processing
258 | - Inject Locale: Keys automatically get the locale code as their value
259 | - Translator Notes: Format varies significantly by bucket type
260 |
261 | ### Path Conventions
262 |
263 | Choose realistic paths for the bucket type:
264 |
265 | - iOS: `ios/Resources/`, `[AppName]/`
266 | - Android: `app/src/main/res/values-[locale]/`
267 | - Web: `locales/`, `i18n/`, `translations/`
268 | - Flutter: `lib/l10n/`
269 | - Java: `src/main/resources/`
270 |
271 | ### Writing Rules
272 |
273 | - Match the concise, direct tone of the template
274 | - No marketing language or unnecessary adjectives
275 | - Don't document what specifically gets translated
276 | - Don't include generic features (exclude patterns, multiple directories)
277 | - Focus only on bucket-specific behavior
278 | - Use only `en` → `es` in all examples
279 | - Keep examples minimal but representative
280 |
281 | ## Instructions
282 |
283 | 1. Parse the bucket analysis output provided in the arguments to determine:
284 |
285 | - Bucket type name
286 | - File organization (separate-files if uses [locale] placeholder, in-place if not)
287 | - Supported features (lockedKeys, ignoredKeys, injectLocale, hints/notes)
288 | - Typical file extension and paths
289 |
290 | 2. Based on the analysis, fill in the template with appropriate:
291 |
292 | - Description of the file format
293 | - Realistic path patterns
294 | - Only the features that are actually supported
295 | - Appropriate examples for the format
296 |
297 | 3. Generate the complete Markdown documentation following the specifications exactly.
298 |
299 | ---
300 |
301 | ## Bucket Analysis Output
302 |
303 | $ARGUMENTS
304 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/cmd/purge.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Command } from "interactive-commander";
2 | import _ from "lodash";
3 | import Ora from "ora";
4 | import { getConfig } from "../utils/config";
5 | import { getBuckets } from "../utils/buckets";
6 | import { resolveOverriddenLocale } from "@lingo.dev/_spec";
7 | import createBucketLoader from "../loaders";
8 | import { minimatch } from "minimatch";
9 | import { confirm } from "@inquirer/prompts";
10 |
11 | interface PurgeOptions {
12 | bucket?: string[];
13 | file?: string[];
14 | key?: string;
15 | locale?: string[];
16 | yesReally?: boolean;
17 | }
18 |
19 | export default new Command()
20 | .command("purge")
21 | .description(
22 | "WARNING: Permanently delete translation entries from bucket path patterns defined in i18n.json. This is a destructive operation that cannot be undone. Without any filters, ALL managed keys will be removed from EVERY target locale.",
23 | )
24 | .helpOption("-h, --help", "Show help")
25 | .option(
26 | "--bucket <bucket>",
27 | "Limit the purge to specific bucket types defined under `buckets` in i18n.json. Repeat the flag to include multiple bucket types. Defaults to all buckets",
28 | (val: string, prev: string[]) => (prev ? [...prev, val] : [val]),
29 | )
30 | .option(
31 | "--file [files...]",
32 | "Filter which file paths to purge by matching against path patterns. Only paths containing any of these values will be processed. Examples: --file messages.json --file admin/",
33 | )
34 | .option(
35 | "--key <key>",
36 | "Filter which keys to delete using prefix matching on dot-separated key paths. Example: 'auth.login' matches all keys starting with auth.login. Omit this option to delete ALL keys. Keys marked as locked or ignored in i18n.json are automatically skipped",
37 | (val: string) => encodeURIComponent(val),
38 | )
39 | .option(
40 | "--locale <locale>",
41 | "Limit purging to specific target locale codes from i18n.json. Repeat the flag to include multiple locales. Defaults to all configured target locales. Warning: Including the source locale will delete content from it as well.",
42 | (val: string, prev: string[]) => (prev ? [...prev, val] : [val]),
43 | )
44 | .option(
45 | "--yes-really",
46 | "Bypass safety confirmations for destructive operations. Use with extreme caution - this will delete translation keys without asking for confirmation. Intended for automated scripts and CI environments only.",
47 | )
48 | .action(async function (options: PurgeOptions) {
49 | const ora = Ora();
50 | try {
51 | ora.start("Loading configuration...");
52 | const i18nConfig = getConfig();
53 | if (!i18nConfig) {
54 | throw new Error("i18n.json not found. Please run `lingo.dev init`.");
55 | }
56 | ora.succeed("Configuration loaded");
57 |
58 | let buckets = getBuckets(i18nConfig);
59 | if (options.bucket && options.bucket.length) {
60 | buckets = buckets.filter((bucket) =>
61 | options.bucket!.includes(bucket.type),
62 | );
63 | }
64 | if (options.file && options.file.length) {
65 | buckets = buckets
66 | .map((bucket) => {
67 | const paths = bucket.paths.filter((bucketPath) =>
68 | options.file?.some((f) => bucketPath.pathPattern.includes(f)),
69 | );
70 | return { ...bucket, paths };
71 | })
72 | .filter((bucket) => bucket.paths.length > 0);
73 | if (buckets.length === 0) {
74 | ora.fail("All files were filtered out by --file option.");
75 | process.exit(1);
76 | }
77 | }
78 | const sourceLocale = i18nConfig.locale.source;
79 | const targetLocales =
80 | options.locale && options.locale.length
81 | ? options.locale
82 | : i18nConfig.locale.targets;
83 | let removedAny = false;
84 | for (const bucket of buckets) {
85 | console.log();
86 | ora.info(`Processing bucket: ${bucket.type}`);
87 | for (const bucketPath of bucket.paths) {
88 | for (const _targetLocale of targetLocales) {
89 | const targetLocale = resolveOverriddenLocale(
90 | _targetLocale,
91 | bucketPath.delimiter,
92 | );
93 | const bucketOra = Ora({ indent: 2 }).start(
94 | `Processing path: ${bucketPath.pathPattern} [${targetLocale}]`,
95 | );
96 | try {
97 | const bucketLoader = createBucketLoader(
98 | bucket.type,
99 | bucketPath.pathPattern,
100 | {
101 | defaultLocale: sourceLocale,
102 | injectLocale: bucket.injectLocale,
103 | formatter: i18nConfig!.formatter,
104 | },
105 | bucket.lockedKeys,
106 | bucket.lockedPatterns,
107 | bucket.ignoredKeys,
108 | );
109 | await bucketLoader.init();
110 | bucketLoader.setDefaultLocale(sourceLocale);
111 | await bucketLoader.pull(sourceLocale);
112 | let targetData = await bucketLoader.pull(targetLocale);
113 | if (!targetData || Object.keys(targetData).length === 0) {
114 | bucketOra.info(
115 | `No translations found for ${bucketPath.pathPattern} [${targetLocale}]`,
116 | );
117 | continue;
118 | }
119 | let newData = { ...targetData };
120 | let keysToRemove: string[] = [];
121 | if (options.key) {
122 | // minimatch for key patterns
123 | keysToRemove = Object.keys(newData).filter((k) =>
124 | minimatch(k, options.key!),
125 | );
126 | } else {
127 | // No key specified: remove all keys
128 | keysToRemove = Object.keys(newData);
129 | }
130 | if (keysToRemove.length > 0) {
131 | // Show what will be deleted
132 | if (options.key) {
133 | bucketOra.info(
134 | `About to delete ${keysToRemove.length} key(s) matching '${options.key}' from ${bucketPath.pathPattern} [${targetLocale}]:\n ${keysToRemove.slice(0, 10).join(", ")}${keysToRemove.length > 10 ? ", ..." : ""}`,
135 | );
136 | } else {
137 | bucketOra.info(
138 | `About to delete all (${keysToRemove.length}) keys from ${bucketPath.pathPattern} [${targetLocale}]`,
139 | );
140 | }
141 |
142 | if (!options.yesReally) {
143 | bucketOra.warn(
144 | "This is a destructive operation. If you are sure, type 'y' to continue. (Use --yes-really to skip this check.)",
145 | );
146 | const confirmed = await confirm({
147 | message: `Delete these keys from ${bucketPath.pathPattern} [${targetLocale}]?`,
148 | default: false,
149 | });
150 | if (!confirmed) {
151 | bucketOra.info("Skipped by user.");
152 | continue;
153 | }
154 | }
155 | for (const key of keysToRemove) {
156 | delete newData[key];
157 | }
158 | removedAny = true;
159 | await bucketLoader.push(targetLocale, newData);
160 | if (options.key) {
161 | bucketOra.succeed(
162 | `Removed ${keysToRemove.length} key(s) matching '${options.key}' from ${bucketPath.pathPattern} [${targetLocale}]`,
163 | );
164 | } else {
165 | bucketOra.succeed(
166 | `Removed all keys (${keysToRemove.length}) from ${bucketPath.pathPattern} [${targetLocale}]`,
167 | );
168 | }
169 | } else if (options.key) {
170 | bucketOra.info(
171 | `No keys matching '${options.key}' found in ${bucketPath.pathPattern} [${targetLocale}]`,
172 | );
173 | } else {
174 | bucketOra.info("No keys to remove.");
175 | }
176 | } catch (error) {
177 | const err = error as Error;
178 | bucketOra.fail(`Failed: ${err.message}`);
179 | }
180 | }
181 | }
182 | }
183 | if (!removedAny) {
184 | ora.info("No keys were removed.");
185 | } else {
186 | ora.succeed("Purge completed.");
187 | }
188 | } catch (error) {
189 | const err = error as Error;
190 | ora.fail(err.message);
191 | process.exit(1);
192 | }
193 | });
194 |
```
--------------------------------------------------------------------------------
/packages/spec/src/locales.ts:
--------------------------------------------------------------------------------
```typescript
1 | import Z from "zod";
2 |
3 | const localeMap = {
4 | // Urdu (Pakistan)
5 | ur: ["ur-PK"],
6 | // Vietnamese (Vietnam)
7 | vi: ["vi-VN"],
8 | // Turkish (Turkey)
9 | tr: ["tr-TR"],
10 | // Tamil (India)
11 | ta: [
12 | "ta-IN", // India
13 | "ta-SG", // Singapore
14 | ],
15 | // Serbian
16 | sr: [
17 | "sr-RS", // Serbian (Latin)
18 | "sr-Latn-RS", // Serbian (Latin)
19 | "sr-Cyrl-RS", // Serbian (Cyrillic)
20 | ],
21 | // Hungarian (Hungary)
22 | hu: ["hu-HU"],
23 | // Hebrew (Israel)
24 | he: ["he-IL"],
25 | // Estonian (Estonia)
26 | et: ["et-EE"],
27 | // Greek
28 | el: [
29 | "el-GR", // Greece
30 | "el-CY", // Cyprus
31 | ],
32 | // Danish (Denmark)
33 | da: ["da-DK"],
34 | // Azerbaijani (Azerbaijan)
35 | az: ["az-AZ"],
36 | // Thai (Thailand)
37 | th: ["th-TH"],
38 | // Swedish (Sweden)
39 | sv: ["sv-SE"],
40 | // English
41 | en: [
42 | "en-US", // United States
43 | "en-GB", // United Kingdom
44 | "en-AU", // Australia
45 | "en-CA", // Canada
46 | "en-SG", // Singapore
47 | "en-IE", // Ireland
48 | ],
49 | // Spanish
50 | es: [
51 | "es-ES", // Spain
52 | "es-419", // Latin America
53 | "es-MX", // Mexico
54 | "es-AR", // Argentina
55 | ],
56 | // French
57 | fr: [
58 | "fr-FR", // France
59 | "fr-CA", // Canada
60 | "fr-BE", // Belgium
61 | "fr-LU", // Luxembourg
62 | ],
63 | // Catalan (Spain)
64 | ca: ["ca-ES"],
65 | // Japanese (Japan)
66 | ja: ["ja-JP"],
67 | // Kazakh (Kazakhstan)
68 | kk: ["kk-KZ"],
69 | // German
70 | de: [
71 | "de-DE", // Germany
72 | "de-AT", // Austria
73 | "de-CH", // Switzerland
74 | ],
75 | // Portuguese
76 | pt: [
77 | "pt-PT", // Portugal
78 | "pt-BR", // Brazil
79 | ],
80 | // Italian
81 | it: [
82 | "it-IT", // Italy
83 | "it-CH", // Switzerland
84 | ],
85 | // Russian
86 | ru: [
87 | "ru-RU", // Russia
88 | "ru-BY", // Belarus
89 | ],
90 | // Ukrainian (Ukraine)
91 | uk: ["uk-UA"],
92 | // Belarusian (Belarus)
93 | be: ["be-BY"],
94 | // Hindi (India)
95 | hi: ["hi-IN"],
96 | // Chinese
97 | zh: [
98 | "zh-CN", // Simplified Chinese (China)
99 | "zh-TW", // Traditional Chinese (Taiwan)
100 | "zh-HK", // Traditional Chinese (Hong Kong)
101 | "zh-SG", // Simplified Chinese (Singapore)
102 | "zh-Hans", // Simplified Chinese
103 | "zh-Hant", // Traditional Chinese
104 | "zh-Hant-HK", // Traditional Chinese (Hong Kong)
105 | "zh-Hant-TW", // Traditional Chinese (Taiwan)
106 | "zh-Hant-CN", // Traditional Chinese (China)
107 | "zh-Hans-HK", // Simplified Chinese (Hong Kong)
108 | "zh-Hans-TW", // Simplified Chinese (China)
109 | "zh-Hans-CN", // Simplified Chinese (China)
110 | ],
111 | // Korean (South Korea)
112 | ko: ["ko-KR"],
113 | // Arabic
114 | ar: [
115 | "ar-EG", // Egypt
116 | "ar-SA", // Saudi Arabia
117 | "ar-AE", // United Arab Emirates
118 | "ar-MA", // Morocco
119 | ],
120 | // Bulgarian (Bulgaria)
121 | bg: ["bg-BG"],
122 | // Czech (Czech Republic)
123 | cs: ["cs-CZ"],
124 | // Welsh (Wales)
125 | cy: ["cy-GB"],
126 | // Dutch
127 | nl: [
128 | "nl-NL", // Netherlands
129 | "nl-BE", // Belgium
130 | ],
131 | // Polish (Poland)
132 | pl: ["pl-PL"],
133 | // Indonesian (Indonesia)
134 | id: ["id-ID"],
135 | is: ["is-IS"],
136 | // Malay (Malaysia)
137 | ms: ["ms-MY"],
138 | // Finnish (Finland)
139 | fi: ["fi-FI"],
140 | // Basque (Spain)
141 | eu: ["eu-ES"],
142 | // Croatian (Croatia)
143 | hr: ["hr-HR"],
144 | // Hebrew (Israel) - alternative code
145 | iw: ["iw-IL"],
146 | // Khmer (Cambodia)
147 | km: ["km-KH"],
148 | // Latvian (Latvia)
149 | lv: ["lv-LV"],
150 | // Lithuanian (Lithuania)
151 | lt: ["lt-LT"],
152 | // Norwegian
153 | no: [
154 | "no-NO", // Norway (legacy)
155 | "nb-NO", // Norwegian Bokmål
156 | "nn-NO", // Norwegian Nynorsk
157 | ],
158 | // Romanian (Romania)
159 | ro: ["ro-RO"],
160 | // Slovak (Slovakia)
161 | sk: ["sk-SK"],
162 | // Swahili
163 | sw: [
164 | "sw-TZ", // Tanzania
165 | "sw-KE", // Kenya
166 | "sw-UG", // Uganda
167 | "sw-CD", // Democratic Republic of Congo
168 | "sw-RW", // Rwanda
169 | ],
170 | // Persian (Iran)
171 | fa: ["fa-IR"],
172 | // Filipino (Philippines)
173 | fil: ["fil-PH"],
174 | // Punjabi
175 | pa: [
176 | "pa-IN", // India
177 | "pa-PK", // Pakistan
178 | ],
179 | // Bengali
180 | bn: [
181 | "bn-BD", // Bangladesh
182 | "bn-IN", // India
183 | ],
184 | // Irish (Ireland)
185 | ga: ["ga-IE"],
186 | // Galician (Spain)
187 | gl: ["gl-ES"],
188 | // Maltese (Malta)
189 | mt: ["mt-MT"],
190 | // Slovenian (Slovenia)
191 | sl: ["sl-SI"],
192 | // Albanian (Albania)
193 | sq: ["sq-AL"],
194 | // Bavarian (Germany)
195 | bar: ["bar-DE"],
196 | // Neapolitan (Italy)
197 | nap: ["nap-IT"],
198 | // Afrikaans (South Africa)
199 | af: ["af-ZA"],
200 | // Uzbek (Latin)
201 | uz: ["uz-Latn"],
202 | // Somali (Somalia)
203 | so: ["so-SO"],
204 | // Tigrinya (Ethiopia)
205 | ti: ["ti-ET"],
206 | // Standard Moroccan Tamazight (Morocco)
207 | zgh: ["zgh-MA"],
208 | // Tagalog (Philippines)
209 | tl: ["tl-PH"],
210 | // Telugu (India)
211 | te: ["te-IN"],
212 | // Kinyarwanda (Rwanda)
213 | rw: ["rw-RW"],
214 | // Georgian (Georgia)
215 | ka: ["ka-GE"],
216 | // Malayalam (India)
217 | ml: ["ml-IN"],
218 | // Armenian (Armenia)
219 | hy: ["hy-AM"],
220 | // Macedonian (Macedonia)
221 | mk: ["mk-MK"],
222 | } as const;
223 |
224 | export type LocaleCodeShort = keyof typeof localeMap;
225 | export type LocaleCodeFull = (typeof localeMap)[LocaleCodeShort][number];
226 | export type LocaleCode = LocaleCodeShort | LocaleCodeFull;
227 | export type LocaleDelimiter = "-" | "_" | null;
228 |
229 | export const localeCodesShort = Object.keys(localeMap) as LocaleCodeShort[];
230 | export const localeCodesFull = Object.values(
231 | localeMap,
232 | ).flat() as LocaleCodeFull[];
233 | export const localeCodesFullUnderscore = localeCodesFull.map((value) =>
234 | value.replace("-", "_"),
235 | );
236 | export const localeCodesFullExplicitRegion = localeCodesFull.map((value) => {
237 | const chunks = value.split("-");
238 | const result = [chunks[0], "-r", chunks.slice(1).join("-")].join("");
239 | return result;
240 | });
241 | export const localeCodes = [
242 | ...localeCodesShort,
243 | ...localeCodesFull,
244 | ...localeCodesFullUnderscore,
245 | ...localeCodesFullExplicitRegion,
246 | ] as LocaleCode[];
247 |
248 | export const localeCodeSchema = Z.string().refine(
249 | (value) => localeCodes.includes(value as any),
250 | {
251 | message: "Invalid locale code",
252 | },
253 | );
254 |
255 | /**
256 | * Resolves a locale code to its full locale representation.
257 | *
258 | * If the provided locale code is already a full locale code, it returns as is.
259 | * If the provided locale code is a short locale code, it returns the first corresponding full locale.
260 | * If the locale code is not found, it throws an error.
261 | *
262 | * @param {localeCodes} value - The locale code to resolve (either short or full)
263 | * @return {LocaleCodeFull} The resolved full locale code
264 | * @throws {Error} If the provided locale code is invalid.
265 | */
266 | export const resolveLocaleCode = (value: string): LocaleCodeFull => {
267 | const existingFullLocaleCode = Object.values(localeMap)
268 | .flat()
269 | .includes(value as any);
270 | if (existingFullLocaleCode) {
271 | return value as LocaleCodeFull;
272 | }
273 |
274 | const existingShortLocaleCode = Object.keys(localeMap).includes(value);
275 | if (existingShortLocaleCode) {
276 | const correspondingFullLocales = localeMap[value as LocaleCodeShort];
277 | const fallbackFullLocale = correspondingFullLocales[0];
278 | return fallbackFullLocale;
279 | }
280 |
281 | throw new Error(`Invalid locale code: ${value}`);
282 | };
283 |
284 | /**
285 | * Determines the delimiter used in a locale code
286 | *
287 | * @param {string} locale - the locale string (e.g.,"en_US","en-GB")
288 | * @return { string | null} - The delimiter ("_" or "-") if found, otherwise `null`.
289 | */
290 |
291 | export const getLocaleCodeDelimiter = (locale: string): LocaleDelimiter => {
292 | if (locale.includes("_")) {
293 | return "_";
294 | } else if (locale.includes("-")) {
295 | return "-";
296 | } else {
297 | return null;
298 | }
299 | };
300 |
301 | /**
302 | * Replaces the delimiter in a locale string with the specified delimiter.
303 | *
304 | * @param {string}locale - The locale string (e.g.,"en_US", "en-GB").
305 | * @param {"-" | "_" | null} [delimiter] - The new delimiter to replace the existing one.
306 | * @returns {string} The locale string with the replaced delimiter, or the original locale if no delimiter is provided.
307 | */
308 |
309 | export const resolveOverriddenLocale = (
310 | locale: string,
311 | delimiter?: LocaleDelimiter,
312 | ): string => {
313 | if (!delimiter) {
314 | return locale;
315 | }
316 |
317 | const currentDelimiter = getLocaleCodeDelimiter(locale);
318 | if (!currentDelimiter) {
319 | return locale;
320 | }
321 |
322 | return locale.replace(currentDelimiter, delimiter);
323 | };
324 |
325 | /**
326 | * Normalizes a locale string by replacing underscores with hyphens
327 | * and removing the "r" in certain regional codes (e.g., "fr-rCA" → "fr-CA")
328 | *
329 | * @param {string} locale - The locale string (e.g.,"en_US", "en-GB").
330 | * @return {string} The normalized locale string.
331 | */
332 |
333 | export function normalizeLocale(locale: string): string {
334 | return locale.replaceAll("_", "-").replace(/([a-z]{2,3}-)r/, "$1");
335 | }
336 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/typescript/index.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect, it } from "vitest";
2 | import createTypescriptLoader from "./index";
3 | import dedent from "dedent";
4 |
5 | describe("typescript loader", () => {
6 | it("should extract string literals from default export object", async () => {
7 | const input = `
8 | export default {
9 | greeting: "Hello, world!",
10 | farewell: "Goodbye!",
11 | number: 42,
12 | boolean: true
13 | };
14 | `;
15 |
16 | const loader = createTypescriptLoader().setDefaultLocale("en");
17 | const result = await loader.pull("en", input);
18 |
19 | expect(result).toEqual({
20 | greeting: "Hello, world!",
21 | farewell: "Goodbye!",
22 | });
23 | });
24 |
25 | it("should extract string literals from exported variable", async () => {
26 | const input = `
27 | const messages = {
28 | welcome: "Welcome to our app",
29 | error: "Something went wrong",
30 | count: 5
31 | };
32 | export default messages;
33 | `;
34 |
35 | const loader = createTypescriptLoader().setDefaultLocale("en");
36 | const result = await loader.pull("en", input);
37 |
38 | expect(result).toEqual({
39 | welcome: "Welcome to our app",
40 | error: "Something went wrong",
41 | });
42 | });
43 |
44 | it("should handle empty or invalid input", async () => {
45 | const loader = createTypescriptLoader().setDefaultLocale("en");
46 |
47 | let result = await loader.pull("en", "");
48 | expect(result).toEqual({});
49 |
50 | result = await loader.pull("en", "const x = 5;");
51 | expect(result).toEqual({});
52 | });
53 |
54 | it("should update string literals in default export object", async () => {
55 | const input = `
56 | export default {
57 | greeting: "Hello, world!",
58 | farewell: "Goodbye!",
59 | number: 42
60 | };
61 | `;
62 |
63 | const loader = createTypescriptLoader().setDefaultLocale("en");
64 |
65 | await loader.pull("en", input);
66 |
67 | const data = {
68 | greeting: "Hola, mundo!",
69 | farewell: "Adiós!",
70 | };
71 |
72 | const result = await loader.push("es", data);
73 |
74 | expect(result).toBe(dedent`
75 | export default {
76 | greeting: "Hola, mundo!",
77 | farewell: "Adiós!",
78 | number: 42
79 | };
80 | `);
81 | });
82 |
83 | it("should extract string literals from nested objects", async () => {
84 | const input = `
85 | export default {
86 | messages: {
87 | welcome: "Welcome to our app",
88 | error: "Something went wrong",
89 | count: 5
90 | },
91 | settings: {
92 | theme: {
93 | name: "Dark Mode",
94 | colors: {
95 | primary: "blue",
96 | secondary: "gray"
97 | }
98 | }
99 | }
100 | };
101 | `;
102 |
103 | const loader = createTypescriptLoader().setDefaultLocale("en");
104 | const result = await loader.pull("en", input);
105 |
106 | expect(result).toEqual({
107 | messages: {
108 | welcome: "Welcome to our app",
109 | error: "Something went wrong",
110 | },
111 | settings: {
112 | theme: {
113 | name: "Dark Mode",
114 | colors: {
115 | primary: "blue",
116 | secondary: "gray",
117 | },
118 | },
119 | },
120 | });
121 | });
122 |
123 | it("should extract string literals from arrays", async () => {
124 | const input = `
125 | export default {
126 | greetings: ["Hello", "Hi", "Hey"],
127 | categories: [
128 | { name: "Electronics", description: "Electronic devices" },
129 | { name: "Books", description: "Reading materials" }
130 | ]
131 | };
132 | `;
133 |
134 | const loader = createTypescriptLoader().setDefaultLocale("en");
135 | const result = await loader.pull("en", input);
136 |
137 | expect(result).toEqual({
138 | greetings: ["Hello", "Hi", "Hey"],
139 | categories: [
140 | { name: "Electronics", description: "Electronic devices" },
141 | { name: "Books", description: "Reading materials" },
142 | ],
143 | });
144 | });
145 |
146 | it("should update string literals in nested objects", async () => {
147 | const input = dedent`
148 | export default {
149 | messages: {
150 | welcome: "Welcome to our app",
151 | error: "Something went wrong"
152 | },
153 | settings: {
154 | theme: {
155 | name: "Dark Mode",
156 | colors: {
157 | primary: "blue"
158 | }
159 | }
160 | }
161 | };
162 | `;
163 |
164 | const loader = createTypescriptLoader().setDefaultLocale("en");
165 |
166 | let data = await loader.pull("en", input);
167 |
168 | data.settings.theme.colors.primary = "red";
169 |
170 | const result = await loader.push("es", data);
171 |
172 | expect(result).toBe(dedent`
173 | export default {
174 | messages: {
175 | welcome: "Welcome to our app",
176 | error: "Something went wrong"
177 | },
178 | settings: {
179 | theme: {
180 | name: "Dark Mode",
181 | colors: {
182 | primary: "red"
183 | }
184 | }
185 | }
186 | };
187 | `);
188 | });
189 |
190 | it("should update string literals in arrays", async () => {
191 | const input = `
192 | export default {
193 | greetings: ["Hello", "Hi", "Hey"],
194 | };
195 | `;
196 |
197 | const loader = createTypescriptLoader().setDefaultLocale("en");
198 |
199 | let data = await loader.pull("en", input);
200 |
201 | data.greetings[0] = "Hola";
202 | data.greetings[1] = "Hola";
203 | data.greetings[2] = "Oye";
204 |
205 | const result = await loader.push("es", data);
206 |
207 | expect(result).toBe(dedent`
208 | export default {
209 | greetings: ["Hola", "Hola", "Oye"]
210 | };
211 | `);
212 | });
213 |
214 | it("should handle mixed nested structures", async () => {
215 | const input = `
216 | export default {
217 | app: {
218 | name: "My App",
219 | version: "1.0.0",
220 | features: ["Login", "Dashboard", "Settings"],
221 | pages: [
222 | {
223 | title: "Home",
224 | sections: [
225 | { heading: "Welcome", content: "Welcome to our app" },
226 | { heading: "Features", content: "Check out our features" }
227 | ]
228 | },
229 | {
230 | title: "About",
231 | sections: [
232 | { heading: "Our Story", content: "We started in 2020" }
233 | ]
234 | }
235 | ]
236 | }
237 | };
238 | `;
239 |
240 | const loader = createTypescriptLoader().setDefaultLocale("en");
241 | const result = await loader.pull("en", input);
242 |
243 | expect(result).toEqual({
244 | app: {
245 | name: "My App",
246 | version: "1.0.0",
247 | features: ["Login", "Dashboard", "Settings"],
248 | pages: [
249 | {
250 | title: "Home",
251 | sections: [
252 | { heading: "Welcome", content: "Welcome to our app" },
253 | { heading: "Features", content: "Check out our features" },
254 | ],
255 | },
256 | {
257 | title: "About",
258 | sections: [{ heading: "Our Story", content: "We started in 2020" }],
259 | },
260 | ],
261 | },
262 | });
263 | });
264 |
265 | it("should extract string literals when default export has 'as const'", async () => {
266 | const input = `
267 | export default {
268 | greeting: "Hello, world!",
269 | farewell: "Goodbye!"
270 | } as const;
271 | `;
272 |
273 | const loader = createTypescriptLoader().setDefaultLocale("en");
274 | const result = await loader.pull("en", input);
275 |
276 | expect(result).toEqual({
277 | greeting: "Hello, world!",
278 | farewell: "Goodbye!",
279 | });
280 | });
281 |
282 | it("should extract and update string literals including multiline template literals, URLs, and numeric keys", async () => {
283 | const input = dedent`
284 | export default {
285 | multilineContent: \`Multiline test
286 |
287 | Super content
288 |
289 | Includes also "test"\`,
290 | testUrl: 'https://someurl.com',
291 | 6: '6. Class',
292 | 9: '9. Class',
293 | };
294 | `;
295 |
296 | const loader = createTypescriptLoader().setDefaultLocale("en");
297 |
298 | // Pull phase – ensure the loader extracts all expected strings
299 | const pulled = await loader.pull("en", input);
300 |
301 | expect(pulled).toEqual({
302 | multilineContent: dedent`
303 | Multiline test
304 |
305 | Super content
306 |
307 | Includes also "test"`,
308 | testUrl: "https://someurl.com",
309 | 6: "6. Class",
310 | 9: "9. Class",
311 | });
312 |
313 | // Push phase – modify some values and ensure they are written back
314 | const updatedData = {
315 | ...pulled,
316 | multilineContent: dedent`
317 | Prueba multilínea
318 |
319 | Contenido superior
320 |
321 | Incluye también "prueba"`,
322 | testUrl: "https://algunaurl.com",
323 | 6: "6. Clase",
324 | 9: "9. Clase",
325 | } as any;
326 |
327 | const result = await loader.push("es", updatedData);
328 |
329 | expect(result).toBe(
330 | `
331 | export default {
332 | multilineContent: \`Prueba multilínea
333 |
334 | Contenido superior
335 |
336 | Incluye también "prueba"\`,
337 | testUrl: "https://algunaurl.com",
338 | 6: "6. Clase",
339 | 9: "9. Clase"
340 | };
341 | `.trim(),
342 | );
343 | });
344 |
345 | // TODO
346 | });
347 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/po/index.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import createPoLoader, { PoLoaderParams } from "./index";
3 |
4 | describe("createPoDataLoader", () => {
5 | it("pull the correct data", async () => {
6 | const loader = createLoader();
7 | const input = `
8 | #: hello.py:1
9 | msgid "Hello world"
10 | msgstr ""
11 | `.trim();
12 |
13 | const data = await loader.pull("en", input);
14 | expect(data).toEqual({
15 | "Hello world": {
16 | singular: "Hello world",
17 | plural: null,
18 | },
19 | });
20 | });
21 |
22 | it("pull entries with context", async () => {
23 | const loader = createLoader();
24 | const input = `
25 | #: hello.py:1
26 | msgctxt "role of the user in the workspace"
27 | msgid "Role"
28 | msgstr ""
29 | `.trim();
30 |
31 | const data = await loader.pull("en", input);
32 | expect(data).toEqual({
33 | Role: {
34 | singular: "Role",
35 | plural: null,
36 | },
37 | });
38 | });
39 |
40 | it("push entries with context preserving the original context value", async () => {
41 | const loader = createLoader();
42 | const input = `
43 | #: hello.py:1
44 | msgctxt "role of the user in the workspace"
45 | msgid "Role"
46 | msgstr ""
47 |
48 | #: hello.py:2
49 | msgctxt "role of the user in the workspace"
50 | msgid "Admin"
51 | msgstr ""
52 | `.trim();
53 |
54 | const update = {
55 | Admin: {
56 | singular: "[upd] Admin",
57 | plural: null,
58 | },
59 | };
60 |
61 | const updatedInput = `
62 | #: hello.py:1
63 | msgctxt "role of the user in the workspace"
64 | msgid "Role"
65 | msgstr ""
66 |
67 | #: hello.py:2
68 | msgctxt "role of the user in the workspace"
69 | msgid "Admin"
70 | msgstr "[upd] Admin"
71 | `.trim();
72 |
73 | await loader.pull("en", input);
74 | const result = await loader.push("en-upd", update);
75 | expect(result).toEqual(updatedInput);
76 | });
77 |
78 | it("avoid pulling metadata", async () => {
79 | const loader = createLoader();
80 | const input = `
81 | # SOME DESCRIPTIVE TITLE.
82 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
83 | # This file is distributed under the same license as the PACKAGE package.
84 | # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
85 | #
86 | #, fuzzy
87 | msgid ""
88 | msgstr ""
89 | "Project-Id-Version: PACKAGE VERSION\n"
90 | "Report-Msgid-Bugs-To: \n"
91 | "POT-Creation-Date: 2025-01-22 13:15+0000\n"
92 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
93 | "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
94 | "Language-Team: LANGUAGE <[email protected]>\n"
95 | "Language: \n"
96 | "MIME-Version: 1.0\n"
97 | "Content-Type: text/plain; charset=UTF-8\n"
98 | "Content-Transfer-Encoding: 8bit\n"
99 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
100 |
101 | #: hello.py:1
102 | msgid "Hello world"
103 | msgstr ""
104 | `.trim();
105 |
106 | const data = await loader.pull("en", input);
107 | expect(data).toEqual({
108 | "Hello world": {
109 | singular: "Hello world",
110 | plural: null,
111 | },
112 | });
113 | });
114 |
115 | it("update data when pushed", async () => {
116 | const loader = createLoader();
117 | const input = `
118 | #: hello.py:1
119 | msgid "Hello world"
120 | msgstr ""
121 | `.trim();
122 | const updatedData = {
123 | "Hello world": {
124 | singular: "Hello world!",
125 | plural: null,
126 | },
127 | };
128 | const updatedInput = `
129 | #: hello.py:1
130 | msgid "Hello world"
131 | msgstr "Hello world!"
132 | `.trim();
133 |
134 | await loader.pull("en", input);
135 | const result = await loader.push("en", updatedData);
136 |
137 | expect(result).toEqual(updatedInput);
138 | });
139 |
140 | it("avoid pushing default metadata if it's missing", async () => {
141 | const loader = createLoader();
142 | const input = `
143 | #: hello.py:1
144 | msgid "Hello world"
145 | msgstr ""
146 | `.trim();
147 | const updatedInput = `
148 | #: hello.py:1
149 | msgid "Hello world"
150 | msgstr ""
151 | `.trim();
152 |
153 | await loader.pull("en", input);
154 | const result = await loader.push("en", {});
155 | expect(result).toEqual(updatedInput);
156 | });
157 |
158 | it("split long lines when told to do so", async () => {
159 | const loader = createLoader({ multiline: true });
160 | const input = `
161 | #: hello.py:1
162 | msgid ""
163 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod "
164 | "tempor incididunt ut labore et dolore magna aliqua."
165 | msgstr ""
166 | `.trim();
167 |
168 | await loader.pull("en", input);
169 | const result = await loader.push("en", {});
170 | expect(result).toEqual(input);
171 | });
172 |
173 | it("dont't split long lines by default", async () => {
174 | const loader = createLoader();
175 | const input = `
176 | #: hello.py:1
177 | msgid ""
178 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod "
179 | "tempor incididunt ut labore et dolore magna aliqua."
180 | msgstr ""
181 | `.trim();
182 |
183 | const updatedInput = `
184 | #: hello.py:1
185 | msgid "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
186 | msgstr ""
187 | `.trim();
188 |
189 | await loader.pull("en", input);
190 | const result = await loader.push("en", {});
191 | expect(result).toEqual(updatedInput);
192 | });
193 |
194 | it("pull entries with context", async () => {
195 | const loader = createLoader();
196 | const input = `
197 | #: hello.py:1
198 | msgctxt "role of the user in the workspace"
199 | msgid "Role"
200 | msgstr ""
201 | `.trim();
202 |
203 | const data = await loader.pull("en", input);
204 | expect(data).toEqual({
205 | Role: {
206 | singular: "Role",
207 | plural: null,
208 | },
209 | });
210 | });
211 |
212 | it("push entries with context preserving the original context value", async () => {
213 | const loader = createLoader();
214 | const input = `
215 | #: hello.py:1
216 | msgctxt "role of the user in the workspace"
217 | msgid "Role"
218 | msgstr ""
219 | `.trim();
220 | const payload = {
221 | Role: {
222 | singular: "[upd] Role",
223 | plural: null,
224 | },
225 | };
226 | const updatedInput = `
227 | #: hello.py:1
228 | msgctxt "role of the user in the workspace"
229 | msgid "Role"
230 | msgstr "[upd] Role"
231 | `.trim();
232 |
233 | await loader.pull("en", input);
234 | const result = await loader.push("en-upd", payload);
235 | expect(result).toEqual(updatedInput);
236 | });
237 |
238 | it("fallbacks to msgid when single msgstr value is empty", async () => {
239 | const loader = createLoader();
240 | const input = `
241 | #: hello.py:1
242 | msgid "File"
243 | msgstr ""
244 | `.trim();
245 |
246 | const data = await loader.pull("en", input);
247 | expect(data).toEqual({
248 | File: {
249 | singular: "File",
250 | plural: null,
251 | },
252 | });
253 | });
254 |
255 | it("fallbacks to msgid when msgstr values are empty", async () => {
256 | const loader = createLoader();
257 | const input = `
258 | #: hello.py:1
259 | msgid "File"
260 | msgstr[0] ""
261 | msgstr[1] ""
262 | `.trim();
263 |
264 | const data = await loader.pull("en", input);
265 | expect(data).toEqual({
266 | File: {
267 | singular: "File",
268 | plural: "File",
269 | },
270 | });
271 | });
272 |
273 | it("does not fallback to msgid for non-source locale when single msgstr value is empty", async () => {
274 | const loader = createLoader();
275 | const input = `
276 | #: hello.py:1
277 | msgid "File"
278 | msgstr ""
279 | `.trim();
280 |
281 | // First, pull default locale to satisfy loader invariants
282 | await loader.pull("en", input);
283 |
284 | // Pull a different locale with the same content
285 | const data = await loader.pull("fr", input);
286 |
287 | expect(data).toEqual({
288 | File: {
289 | singular: null,
290 | plural: null,
291 | },
292 | });
293 | });
294 |
295 | it("does not fallback to msgid for non-source locale when msgstr values are empty", async () => {
296 | const loader = createLoader();
297 | const input = `
298 | #: hello.py:1
299 | msgid "File"
300 | msgstr[0] ""
301 | msgstr[1] ""
302 | `.trim();
303 |
304 | // Pull default locale first
305 | await loader.pull("en", input);
306 |
307 | // Pull a different locale
308 | const data = await loader.pull("fr", input);
309 |
310 | expect(data).toEqual({
311 | File: {
312 | singular: null,
313 | plural: null,
314 | },
315 | });
316 | });
317 |
318 | it("should preserve order of comments (file and line number, translator notes)", async () => {
319 | const loader = createLoader();
320 | const input = `
321 | # My animal
322 | #, animal
323 | #. This is an animal
324 | #: hello.py:1
325 | # I like animals
326 | #| foobar
327 | msgid "Zebra"
328 | msgstr ""
329 |
330 | #. This is a bird
331 | #: hello.py:2
332 | msgid "Parrot"
333 | msgstr ""
334 |
335 | #. Food
336 | msgid "Apple"
337 | msgstr ""
338 | `.trim();
339 |
340 | const data = await loader.pull("en", input);
341 |
342 | const updatedData = {
343 | Zebra: { singular: "[upd] Zebra", plural: null },
344 | Parrot: { singular: "[upd] Parrot", plural: null },
345 | Apple: { singular: "[upd] Apple", plural: null },
346 | };
347 | const expectedOutput = `
348 | # My animal
349 | #, animal
350 | #. This is an animal
351 | #: hello.py:1
352 | # I like animals
353 | #| foobar
354 | msgid "Zebra"
355 | msgstr "[upd] Zebra"
356 |
357 | #. This is a bird
358 | #: hello.py:2
359 | msgid "Parrot"
360 | msgstr "[upd] Parrot"
361 |
362 | #. Food
363 | msgid "Apple"
364 | msgstr "[upd] Apple"
365 | `.trim();
366 |
367 | const result = await loader.push("en", updatedData);
368 | expect(result).toEqual(expectedOutput);
369 | });
370 | });
371 |
372 | function createLoader(params: PoLoaderParams = { multiline: false }) {
373 | return createPoLoader(params).setDefaultLocale("en");
374 | }
375 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/flat.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect, it } from "vitest";
2 | import { flatten } from "flat";
3 | import createFlatLoader, {
4 | buildDenormalizedKeysMap,
5 | denormalizeObjectKeys,
6 | mapDenormalizedKeys,
7 | normalizeObjectKeys,
8 | OBJECT_NUMERIC_KEY_PREFIX,
9 | } from "./flat";
10 |
11 | describe("flat loader", () => {
12 | describe("createFlatLoader", () => {
13 | it("loads numeric object and array and preserves state", async () => {
14 | const loader = createFlatLoader();
15 | loader.setDefaultLocale("en");
16 | await loader.pull("en", {
17 | messages: { "1": "foo", "2": "bar" },
18 | years: ["January 13, 2025", "February 14, 2025"],
19 | });
20 | await loader.pull("es", {}); // run again to ensure state is preserved
21 | const output = await loader.push("en", {
22 | "messages/1": "foo",
23 | "messages/2": "bar",
24 | "years/0": "January 13, 2025",
25 | "years/1": "February 14, 2025",
26 | });
27 | expect(output).toEqual({
28 | messages: { "1": "foo", "2": "bar" },
29 | years: ["January 13, 2025", "February 14, 2025"],
30 | });
31 | });
32 |
33 | it("handles date objects correctly", async () => {
34 | const loader = createFlatLoader();
35 | loader.setDefaultLocale("en");
36 | const date = new Date("2023-01-01T00:00:00Z");
37 | await loader.pull("en", {
38 | publishedAt: date,
39 | metadata: { createdAt: date },
40 | });
41 | const output = await loader.push("en", {
42 | publishedAt: date.toISOString(),
43 | "metadata/createdAt": date.toISOString(),
44 | });
45 | expect(output).toEqual({
46 | publishedAt: date.toISOString(),
47 | metadata: { createdAt: date.toISOString() },
48 | });
49 | });
50 | });
51 |
52 | describe("helper functions", () => {
53 | const inputObj = {
54 | messages: {
55 | "1": "a",
56 | "2": "b",
57 | },
58 | };
59 | const inputArray = {
60 | messages: ["a", "b", "c"],
61 | };
62 |
63 | describe("denormalizeObjectKeys", () => {
64 | it("should denormalize object keys", () => {
65 | const output = denormalizeObjectKeys(inputObj);
66 | expect(output).toEqual({
67 | messages: {
68 | [`${OBJECT_NUMERIC_KEY_PREFIX}1`]: "a",
69 | [`${OBJECT_NUMERIC_KEY_PREFIX}2`]: "b",
70 | },
71 | });
72 | });
73 |
74 | it("should preserve array", () => {
75 | const output = denormalizeObjectKeys(inputArray);
76 | expect(output).toEqual({
77 | messages: ["a", "b", "c"],
78 | });
79 | });
80 |
81 | it("should preserve date objects", () => {
82 | const date = new Date();
83 | const input = { createdAt: date };
84 | const output = denormalizeObjectKeys(input);
85 | expect(output).toEqual({ createdAt: date });
86 | });
87 | });
88 |
89 | describe("buildDenormalizedKeysMap", () => {
90 | it("should build normalized keys map", () => {
91 | const denormalized: Record<string, string> = flatten(
92 | denormalizeObjectKeys(inputObj),
93 | { delimiter: "/" },
94 | );
95 | const output = buildDenormalizedKeysMap(denormalized);
96 | expect(output).toEqual({
97 | "messages/1": `messages/${OBJECT_NUMERIC_KEY_PREFIX}1`,
98 | "messages/2": `messages/${OBJECT_NUMERIC_KEY_PREFIX}2`,
99 | });
100 | });
101 |
102 | it("should build keys map array", () => {
103 | const denormalized: Record<string, string> = flatten(
104 | denormalizeObjectKeys(inputArray),
105 | { delimiter: "/" },
106 | );
107 | const output = buildDenormalizedKeysMap(denormalized);
108 | expect(output).toEqual({
109 | "messages/0": "messages/0",
110 | "messages/1": "messages/1",
111 | "messages/2": "messages/2",
112 | });
113 | });
114 | });
115 |
116 | describe("normalizeObjectKeys", () => {
117 | it("should normalize denormalized object keys", () => {
118 | const output = normalizeObjectKeys(denormalizeObjectKeys(inputObj));
119 | expect(output).toEqual(inputObj);
120 | });
121 |
122 | it("should process array keys", () => {
123 | const output = normalizeObjectKeys(denormalizeObjectKeys(inputArray));
124 | expect(output).toEqual(inputArray);
125 | });
126 |
127 | it("should preserve date objects", () => {
128 | const date = new Date();
129 | const input = { createdAt: date };
130 | const output = normalizeObjectKeys(input);
131 | expect(output).toEqual({ createdAt: date });
132 | });
133 | });
134 |
135 | describe("mapDeormalizedKeys", () => {
136 | it("should map normalized keys", () => {
137 | const denormalized: Record<string, string> = flatten(
138 | denormalizeObjectKeys(inputObj),
139 | { delimiter: "/" },
140 | );
141 | const keyMap = buildDenormalizedKeysMap(denormalized);
142 | const flattened: Record<string, string> = flatten(inputObj, {
143 | delimiter: "/",
144 | });
145 | const mapped = mapDenormalizedKeys(flattened, keyMap);
146 | expect(mapped).toEqual(denormalized);
147 | });
148 |
149 | it("should map array", () => {
150 | const denormalized: Record<string, string> = flatten(
151 | denormalizeObjectKeys(inputArray),
152 | { delimiter: "/" },
153 | );
154 | const keyMap = buildDenormalizedKeysMap(denormalized);
155 | const flattened: Record<string, string> = flatten(inputArray, {
156 | delimiter: "/",
157 | });
158 | const mapped = mapDenormalizedKeys(flattened, keyMap);
159 | expect(mapped).toEqual(denormalized);
160 | });
161 | });
162 | });
163 |
164 | describe("pullHints", () => {
165 | it("should flatten comments from nested structure", async () => {
166 | const loader = createFlatLoader();
167 | loader.setDefaultLocale("en");
168 |
169 | const input = {
170 | key1: { hint: "This is a comment for key1" },
171 | key2: { hint: "This is a comment for key2" },
172 | key3: { hint: "This is a comment for key3" },
173 | key4: { hint: "This is a block comment for key4" },
174 | key5: { hint: "This is a comment for key5" },
175 | key6: {
176 | hint: "This is a comment for key6",
177 | key7: { hint: "This is a comment for key7" },
178 | },
179 | };
180 |
181 | const comments = await loader.pullHints(input);
182 |
183 | expect(comments).toEqual({
184 | key1: ["This is a comment for key1"],
185 | key2: ["This is a comment for key2"],
186 | key3: ["This is a comment for key3"],
187 | key4: ["This is a block comment for key4"],
188 | key5: ["This is a comment for key5"],
189 | "key6/key7": [
190 | "This is a comment for key6",
191 | "This is a comment for key7",
192 | ],
193 | });
194 | });
195 |
196 | it("should handle empty input", async () => {
197 | const loader = createFlatLoader();
198 | loader.setDefaultLocale("en");
199 |
200 | const comments = await loader.pullHints({});
201 | expect(comments).toEqual({});
202 | });
203 |
204 | it("should handle null/undefined input", async () => {
205 | const loader = createFlatLoader();
206 | loader.setDefaultLocale("en");
207 |
208 | const comments1 = await loader.pullHints(null as any);
209 | expect(comments1).toEqual({});
210 |
211 | const comments2 = await loader.pullHints(undefined as any);
212 | expect(comments2).toEqual({});
213 | });
214 |
215 | it("should handle deeply nested structure", async () => {
216 | const loader = createFlatLoader();
217 | loader.setDefaultLocale("en");
218 |
219 | const input = {
220 | level1: {
221 | hint: "Level 1 hint",
222 | level2: {
223 | hint: "Level 2 hint",
224 | level3: {
225 | hint: "Level 3 hint",
226 | },
227 | },
228 | },
229 | };
230 |
231 | const comments = await loader.pullHints(input);
232 |
233 | expect(comments).toEqual({
234 | "level1/level2/level3": [
235 | "Level 1 hint",
236 | "Level 2 hint",
237 | "Level 3 hint",
238 | ],
239 | });
240 | });
241 |
242 | it("should handle objects without hints", async () => {
243 | const loader = createFlatLoader();
244 | loader.setDefaultLocale("en");
245 |
246 | const input = {
247 | key1: { hint: "Has hint" },
248 | key2: {
249 | key3: { hint: "Nested hint" },
250 | },
251 | };
252 |
253 | const comments = await loader.pullHints(input);
254 |
255 | expect(comments).toEqual({
256 | key1: ["Has hint"],
257 | "key2/key3": ["Nested hint"],
258 | });
259 | });
260 |
261 | it("should handle mixed structures", async () => {
262 | const loader = createFlatLoader();
263 | loader.setDefaultLocale("en");
264 |
265 | const input = {
266 | simple: { hint: "Simple hint" },
267 | parent: {
268 | hint: "Parent hint",
269 | child1: { hint: "Child 1 hint" },
270 | child2: {
271 | grandchild: { hint: "Grandchild hint" },
272 | },
273 | },
274 | };
275 |
276 | const comments = await loader.pullHints(input);
277 |
278 | expect(comments).toEqual({
279 | simple: ["Simple hint"],
280 | "parent/child1": ["Parent hint", "Child 1 hint"],
281 | "parent/child2/grandchild": ["Parent hint", "Grandchild hint"],
282 | });
283 | });
284 | });
285 | });
286 |
```
--------------------------------------------------------------------------------
/demo/react-router-app/app/lingo/dictionary.js:
--------------------------------------------------------------------------------
```javascript
1 | export default {
2 | version: 0.1,
3 | files: {
4 | "root.tsx": {
5 | entries: {
6 | "9/declaration/body/1/argument/1/1/3-content": {
7 | content: {
8 | de: "width=device-width, initial-scale=1",
9 | en: "width=device-width, initial-scale=1",
10 | es: "width=device-width, initial-scale=1",
11 | fr: "width=device-width, initial-scale=1",
12 | },
13 | hash: "d94b318cb327f61f1aea44a6cb1fdcad",
14 | },
15 | },
16 | },
17 | "routes/test.tsx": {
18 | entries: {
19 | "3/declaration/body/0/argument/1/1": {
20 | content: {
21 | de: "Zurück nach Hause",
22 | en: "Go back home",
23 | es: "Volver a inicio",
24 | fr: "Retourner à l'accueil",
25 | },
26 | hash: "a0ac69aec348674378faaf92ce476f64",
27 | },
28 | "3/declaration/body/0/argument/1/3": {
29 | content: {
30 | de: "Dies ist eine Testseite",
31 | en: "This is a test page",
32 | es: "Esta es una página de prueba",
33 | fr: "Ceci est une page de test",
34 | },
35 | hash: "51eb13586d30537dfa934742439cc7ee",
36 | },
37 | "3/declaration/body/0/argument/1/5": {
38 | content: {
39 | de: "Willkommen auf der nicht-interaktiven Testseite.",
40 | en: "Welcome to non-interactive testing page.",
41 | es: "Bienvenido a la página de prueba no interactiva.",
42 | fr: "Bienvenue sur la page de test non interactive.",
43 | },
44 | hash: "792a8d0c1ca71a88ab7d887075e69b1d",
45 | },
46 | "3/declaration/body/0/argument/1/7": {
47 | content: {
48 | de: "Bitte versuchen Sie nicht, mit dieser Seite zu interagieren, um Ihre eigene Sicherheit zu gewährleisten.",
49 | en: "Please do not try to interact with this page for your own safety.",
50 | es: "Por favor, no intentes interactuar con esta página por tu propia seguridad.",
51 | fr: "Veuillez ne pas essayer d'interagir avec cette page pour votre propre sécurité.",
52 | },
53 | hash: "31ab29a98c0bb54378cb5a2390d07e57",
54 | },
55 | },
56 | },
57 | "welcome/welcome.tsx": {
58 | entries: {
59 | "3/declaration/body/0/argument/1/1/1/1-alt": {
60 | content: {
61 | de: "React Router",
62 | en: "React Router",
63 | es: "Enrutador de React",
64 | fr: "React Router",
65 | },
66 | hash: "68ae50c1603f87d51e788a96b419f2ee",
67 | },
68 | "3/declaration/body/0/argument/1/1/1/3-alt": {
69 | content: {
70 | de: "React Router",
71 | en: "React Router",
72 | es: "Enrutador de React",
73 | fr: "React Router",
74 | },
75 | hash: "68ae50c1603f87d51e788a96b419f2ee",
76 | },
77 | "3/declaration/body/0/argument/1/1/3/1-alt": {
78 | content: {
79 | de: "React Router",
80 | en: "React Router",
81 | es: "Enrutador de React",
82 | fr: "React Router",
83 | },
84 | hash: "68ae50c1603f87d51e788a96b419f2ee",
85 | },
86 | "3/declaration/body/0/argument/1/1/3/3-alt": {
87 | content: {
88 | de: "React Router",
89 | en: "React Router",
90 | es: "Enrutador de React",
91 | fr: "React Router",
92 | },
93 | hash: "68ae50c1603f87d51e788a96b419f2ee",
94 | },
95 | "3/declaration/body/0/argument/1/3": {
96 | content: {
97 | de: "Test",
98 | en: "Test",
99 | es: "Prueba",
100 | fr: "Test",
101 | },
102 | hash: "4938894bf1608cee94696ec86f5d059a",
103 | },
104 | "3/declaration/body/0/argument/1/5/1/1": {
105 | content: {
106 | de: "Was kommt als nächstes?",
107 | en: "What's next?",
108 | es: "¿Qué sigue?",
109 | fr: "Qu'en est-il ensuite ?",
110 | },
111 | hash: "e0d9d29b9e761346e506557eb7b7e798",
112 | },
113 | "4/declaration/body/0/argument/1/1": {
114 | content: {
115 | de: "<element:LingoDotDev></element:LingoDotDev> 💚<element:div><element:img></element:img><element:img></element:img></element:div>",
116 | en: "<element:LingoDotDev></element:LingoDotDev> 💚<element:div><element:img></element:img><element:img></element:img></element:div>",
117 | es: "<element:LingoDotDev></element:LingoDotDev> 💚<element:div><element:img></element:img><element:img></element:img></element:div>",
118 | fr: "<element:LingoDotDev></element:LingoDotDev> 💚<element:div><element:img></element:img><element:img></element:img></element:div>",
119 | },
120 | hash: "201cf15cf0830aaaf478e49a9665d096",
121 | },
122 | "4/declaration/body/0/argument/1/1/3": {
123 | content: {
124 | de: "💚",
125 | en: "💚",
126 | es: "💚",
127 | fr: "💚",
128 | },
129 | hash: "0ecc986bbbb51a93878f2d11bb45c04a",
130 | },
131 | "4/declaration/body/0/argument/1/1/3/1-alt": {
132 | content: {
133 | de: "React Router",
134 | en: "React Router",
135 | es: "Enrutador de React",
136 | fr: "React Router",
137 | },
138 | hash: "68ae50c1603f87d51e788a96b419f2ee",
139 | },
140 | "4/declaration/body/0/argument/1/1/3/3-alt": {
141 | content: {
142 | de: "React Router",
143 | en: "React Router",
144 | es: "Enrutador de React",
145 | fr: "React Router",
146 | },
147 | hash: "68ae50c1603f87d51e788a96b419f2ee",
148 | },
149 | "4/declaration/body/0/argument/1/1/5/1-alt": {
150 | content: {
151 | de: "React Router",
152 | en: "React Router",
153 | es: "Enrutador de React",
154 | fr: "React Router",
155 | },
156 | hash: "68ae50c1603f87d51e788a96b419f2ee",
157 | },
158 | "4/declaration/body/0/argument/1/1/5/1/1-alt": {
159 | content: {
160 | de: "React Router",
161 | en: "React Router",
162 | es: "Enrutador de React",
163 | fr: "React Router",
164 | },
165 | hash: "68ae50c1603f87d51e788a96b419f2ee",
166 | },
167 | "4/declaration/body/0/argument/1/1/5/1/3-alt": {
168 | content: {
169 | de: "React Router",
170 | en: "React Router",
171 | es: "Enrutador de React",
172 | fr: "React Router",
173 | },
174 | hash: "68ae50c1603f87d51e788a96b419f2ee",
175 | },
176 | "4/declaration/body/0/argument/1/1/5/3-alt": {
177 | content: {
178 | de: "React Router",
179 | en: "React Router",
180 | es: "Enrutador de React",
181 | fr: "React Router",
182 | },
183 | hash: "68ae50c1603f87d51e788a96b419f2ee",
184 | },
185 | "4/declaration/body/0/argument/1/1/5/5-alt": {
186 | content: {
187 | de: "React Router",
188 | en: "React Router",
189 | es: "Enrutador de React",
190 | fr: "React Router",
191 | },
192 | hash: "68ae50c1603f87d51e788a96b419f2ee",
193 | },
194 | "4/declaration/body/0/argument/1/3": {
195 | content: {
196 | de: "Testseite öffnen",
197 | en: "Open test page",
198 | es: "Abrir página de prueba",
199 | fr: "Ouvrir la page de test",
200 | },
201 | hash: "4e5098c50297642cf07ce303398bad59",
202 | },
203 | "4/declaration/body/0/argument/1/5": {
204 | content: {
205 | de: "Willkommen zu Ihrer neuen React Router Anwendung! Dieses Starter-Template enthält alles, was Sie benötigen, um mit React Router und Lingo.dev für die Internationalisierung zu beginnen.",
206 | en: "Welcome to your new React Router application! This starter template includes everything you need to get started with React Router and Lingo.dev for internationalization.",
207 | es: "¡Bienvenido a tu nueva aplicación de React Router! Esta plantilla inicial incluye todo lo que necesitas para empezar con React Router y Lingo.dev para la internacionalización.",
208 | fr: "Bienvenue dans votre nouvelle application React Router ! Ce modèle de départ inclut tout ce dont vous avez besoin pour commencer avec React Router et Lingo.dev pour l'internationalisation.",
209 | },
210 | hash: "a90f2300128bce36346e0debd0b6092b",
211 | },
212 | "4/declaration/body/0/argument/1/5/1/1": {
213 | content: {
214 | de: "Was kommt als nächstes?",
215 | en: "What's next?",
216 | es: "¿Qué sigue?",
217 | fr: "Qu'en est-il ensuite ?",
218 | },
219 | hash: "e0d9d29b9e761346e506557eb7b7e798",
220 | },
221 | "4/declaration/body/0/argument/1/7/1/1": {
222 | content: {
223 | de: "Was kommt als nächstes?",
224 | en: "What's next?",
225 | es: "¿Qué sigue?",
226 | fr: "Qu'en est-il ensuite ?",
227 | },
228 | hash: "e0d9d29b9e761346e506557eb7b7e798",
229 | },
230 | },
231 | },
232 | },
233 | };
234 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/lib/lcp/server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | DictionaryFile,
3 | DictionarySchema,
4 | LCPSchema,
5 | LCPScope,
6 | } from "./schema";
7 | import _ from "lodash";
8 | import { LCPCache } from "./cache";
9 | import { LCPAPI } from "./api";
10 |
11 | type LCPServerBaseParams = {
12 | lcp: LCPSchema;
13 | sourceLocale: string;
14 | sourceRoot: string;
15 | lingoDir: string;
16 | models: "lingo.dev" | Record<string, string>;
17 | prompt?: string | null;
18 | };
19 |
20 | export type LCPServerParams = LCPServerBaseParams & {
21 | targetLocales: string[];
22 | };
23 |
24 | export type LCPServerParamsForLocale = LCPServerBaseParams & {
25 | targetLocale: string;
26 | };
27 |
28 | export class LCPServer {
29 | private static inFlightPromise: Promise<
30 | Record<string, DictionarySchema>
31 | > | null = null;
32 |
33 | static async loadDictionaries(
34 | params: LCPServerParams,
35 | ): Promise<Record<string, DictionarySchema>> {
36 | // If a load is already in progress, await it
37 | if (this.inFlightPromise) {
38 | return this.inFlightPromise;
39 | }
40 |
41 | // Otherwise start a new load restricted by the limiter
42 | this.inFlightPromise = (async () => {
43 | try {
44 | const targetLocales = _.uniq([
45 | ...params.targetLocales,
46 | params.sourceLocale,
47 | ]);
48 |
49 | const dictionaries = await Promise.all(
50 | targetLocales.map((targetLocale) =>
51 | this.loadDictionaryForLocale({ ...params, targetLocale }),
52 | ),
53 | );
54 |
55 | const result = _.fromPairs(
56 | targetLocales.map((targetLocale, index) => [
57 | targetLocale,
58 | dictionaries[index],
59 | ]),
60 | );
61 |
62 | return result;
63 | } finally {
64 | // Clear inFlightPromise regardless of success/failure
65 | this.inFlightPromise = null;
66 | }
67 | })();
68 |
69 | return this.inFlightPromise;
70 | }
71 |
72 | static async loadDictionaryForLocale(
73 | params: LCPServerParamsForLocale,
74 | ): Promise<DictionarySchema> {
75 | const sourceDictionary = this._extractSourceDictionary(
76 | params.lcp,
77 | params.sourceLocale,
78 | params.targetLocale,
79 | );
80 |
81 | const cacheParams = {
82 | lcp: params.lcp,
83 | sourceLocale: params.sourceLocale,
84 | lingoDir: params.lingoDir,
85 | sourceRoot: params.sourceRoot,
86 | };
87 |
88 | if (this._countDictionaryEntries(sourceDictionary) === 0) {
89 | console.log(
90 | "Source dictionary is empty, returning empty dictionary for target locale",
91 | );
92 | return { ...sourceDictionary, locale: params.targetLocale };
93 | }
94 |
95 | const cache = LCPCache.readLocaleDictionary(
96 | params.targetLocale,
97 | cacheParams,
98 | );
99 |
100 | const uncachedSourceDictionary = this._getDictionaryDiff(
101 | sourceDictionary,
102 | cache,
103 | );
104 | let targetDictionary: DictionarySchema;
105 | let newTranslations: DictionarySchema | undefined;
106 | if (this._countDictionaryEntries(uncachedSourceDictionary) === 0) {
107 | targetDictionary = cache;
108 | } else if (params.targetLocale === params.sourceLocale) {
109 | console.log(
110 | "ℹ️ Lingo.dev returns source dictionary - source and target locales are the same",
111 | );
112 | // cache source dictionary for convenience when editing the dictionary.js file
113 | await LCPCache.writeLocaleDictionary(sourceDictionary, cacheParams);
114 | return sourceDictionary;
115 | } else {
116 | newTranslations = await LCPAPI.translate(
117 | params.models,
118 | uncachedSourceDictionary,
119 | params.sourceLocale,
120 | params.targetLocale,
121 | params.prompt,
122 | );
123 |
124 | // we merge new translations with cache, so that we can cache empty strings
125 | targetDictionary = this._mergeDictionaries(newTranslations, cache);
126 | // ensure the locale metadata reflects the target locale
127 | targetDictionary = {
128 | ...targetDictionary,
129 | locale: params.targetLocale,
130 | };
131 | await LCPCache.writeLocaleDictionary(targetDictionary, cacheParams);
132 | }
133 |
134 | const targetDictionaryWithFallback = this._mergeDictionaries(
135 | targetDictionary,
136 | sourceDictionary,
137 | true,
138 | );
139 |
140 | const result = this._addOverridesToDictionary(
141 | targetDictionaryWithFallback,
142 | params.lcp,
143 | params.targetLocale,
144 | );
145 |
146 | if (newTranslations) {
147 | console.log(
148 | `ℹ️ Lingo.dev dictionary for ${params.targetLocale}:\n- %d entries\n- %d cached\n- %d uncached\n- %d translated\n- %d overrides`,
149 | this._countDictionaryEntries(result),
150 | this._countDictionaryEntries(cache),
151 | this._countDictionaryEntries(uncachedSourceDictionary),
152 | newTranslations ? this._countDictionaryEntries(newTranslations) : 0,
153 | this._countDictionaryEntries(result) -
154 | this._countDictionaryEntries(targetDictionary),
155 | );
156 | }
157 |
158 | // console.log("Generated object", JSON.stringify(result, null, 2));
159 | return result;
160 | }
161 |
162 | private static _extractSourceDictionary(
163 | lcp: LCPSchema,
164 | sourceLocale: string,
165 | targetLocale: string,
166 | ): DictionarySchema {
167 | const dictionary: DictionarySchema = {
168 | version: 0.1,
169 | locale: sourceLocale,
170 | files: {},
171 | };
172 |
173 | for (const [fileKey, fileData] of Object.entries(lcp.files || {})) {
174 | for (const [scopeKey, scopeData] of Object.entries(
175 | fileData.scopes || {},
176 | )) {
177 | if (scopeData.skip) {
178 | continue;
179 | }
180 | if (this._getScopeLocaleOverride(scopeData, targetLocale)) {
181 | continue;
182 | }
183 |
184 | _.set(
185 | dictionary,
186 | [
187 | "files" satisfies keyof DictionarySchema,
188 | fileKey,
189 | "entries" satisfies keyof DictionaryFile,
190 | scopeKey,
191 | ],
192 | scopeData.content,
193 | );
194 | }
195 | }
196 |
197 | return dictionary;
198 | }
199 |
200 | private static _addOverridesToDictionary(
201 | dictionary: DictionarySchema,
202 | lcp: LCPSchema,
203 | targetLocale: string,
204 | ) {
205 | for (const [fileKey, fileData] of Object.entries(lcp.files || {})) {
206 | for (const [scopeKey, scopeData] of Object.entries(
207 | fileData.scopes || {},
208 | )) {
209 | const override = this._getScopeLocaleOverride(scopeData, targetLocale);
210 | if (!override) {
211 | continue;
212 | }
213 | _.set(
214 | dictionary,
215 | [
216 | "files" satisfies keyof DictionarySchema,
217 | fileKey,
218 | "entries" satisfies keyof DictionaryFile,
219 | scopeKey,
220 | ],
221 | override,
222 | );
223 | }
224 | }
225 | return dictionary;
226 | }
227 |
228 | private static _getScopeLocaleOverride(scopeData: LCPScope, locale: string) {
229 | return _.get(scopeData.overrides, locale) ?? null;
230 | }
231 |
232 | private static _getDictionaryDiff(
233 | sourceDictionary: DictionarySchema,
234 | targetDictionary: DictionarySchema,
235 | ) {
236 | if (this._countDictionaryEntries(targetDictionary) === 0) {
237 | return sourceDictionary;
238 | }
239 |
240 | const files = _(sourceDictionary.files)
241 | .mapValues((file, fileName) => ({
242 | ...file,
243 | entries: _(file.entries)
244 | .mapValues((entry, entryName) => {
245 | const targetEntry = _.get(targetDictionary.files, [
246 | fileName,
247 | "entries",
248 | entryName,
249 | ]);
250 | if (targetEntry !== undefined) {
251 | return undefined;
252 | }
253 | return entry;
254 | })
255 | .pickBy((value) => value !== undefined)
256 | .value(),
257 | }))
258 | .pickBy((value) => Object.keys(value.entries).length > 0)
259 | .value();
260 | const dictionary = {
261 | version: sourceDictionary.version,
262 | locale: sourceDictionary.locale,
263 | files,
264 | };
265 | return dictionary;
266 | }
267 |
268 | private static _mergeDictionaries(
269 | sourceDictionary: DictionarySchema,
270 | targetDictionary: DictionarySchema,
271 | removeEmptyEntries = false,
272 | ) {
273 | const fileNames = _.uniq([
274 | ...Object.keys(sourceDictionary.files),
275 | ...Object.keys(targetDictionary.files),
276 | ]);
277 | const files = _(fileNames)
278 | .map((fileName) => {
279 | const sourceFile = _.get(sourceDictionary.files, fileName);
280 | const targetFile = _.get(targetDictionary.files, fileName);
281 | const entries = removeEmptyEntries
282 | ? _.pickBy(
283 | sourceFile?.entries || {},
284 | (value) => String(value || "")?.trim?.()?.length > 0,
285 | )
286 | : sourceFile?.entries || {};
287 | return [
288 | fileName,
289 | {
290 | ...targetFile,
291 | entries: _.merge({}, targetFile?.entries || {}, entries),
292 | },
293 | ];
294 | })
295 | .fromPairs()
296 | .value();
297 | const dictionary = {
298 | version: sourceDictionary.version,
299 | locale: sourceDictionary.locale,
300 | files,
301 | };
302 | return dictionary;
303 | }
304 |
305 | private static _countDictionaryEntries(dict: DictionarySchema) {
306 | return Object.values(dict.files).reduce(
307 | (sum, file) => sum + Object.keys(file.entries).length,
308 | 0,
309 | );
310 | }
311 | }
312 |
```