This is page 13 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
--------------------------------------------------------------------------------
/scripts/docs/src/json-schema/markdown-renderer.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect, it } from "vitest";
2 | import type { RootContent } from "mdast";
3 | import {
4 | makeHeadingNode,
5 | makeDescriptionNode,
6 | makeTypeBulletNode,
7 | makeRequiredBulletNode,
8 | makeDefaultBulletNode,
9 | makeEnumBulletNode,
10 | makeAllowedKeysBulletNode,
11 | makeBullets,
12 | renderPropertyToMarkdown,
13 | renderPropertiesToMarkdown,
14 | renderMarkdown,
15 | } from "./markdown-renderer";
16 | import type { PropertyInfo } from "./types";
17 |
18 | describe("makeHeadingNode", () => {
19 | it("should create heading with correct depth for top-level property", () => {
20 | const node = makeHeadingNode("version");
21 | expect(node).toEqual({
22 | type: "heading",
23 | depth: 2,
24 | children: [{ type: "inlineCode", value: "version" }],
25 | });
26 | });
27 |
28 | it("should create deeper heading for nested property", () => {
29 | const node = makeHeadingNode("config.debug.level");
30 | expect(node).toEqual({
31 | type: "heading",
32 | depth: 4,
33 | children: [{ type: "inlineCode", value: "config.debug.level" }],
34 | });
35 | });
36 |
37 | it("should cap heading depth at 6", () => {
38 | const node = makeHeadingNode("a.b.c.d.e.f.g.h");
39 | expect(node).toEqual({
40 | type: "heading",
41 | depth: 6,
42 | children: [{ type: "inlineCode", value: "a.b.c.d.e.f.g.h" }],
43 | });
44 | });
45 | });
46 |
47 | describe("makeDescriptionNode", () => {
48 | it("should create paragraph node for description", () => {
49 | const node = makeDescriptionNode("This is a description");
50 | expect(node).toEqual({
51 | type: "paragraph",
52 | children: [{ type: "text", value: "This is a description" }],
53 | });
54 | });
55 |
56 | it("should return null for empty description", () => {
57 | expect(makeDescriptionNode("")).toBeNull();
58 | expect(makeDescriptionNode(undefined)).toBeNull();
59 | });
60 | });
61 |
62 | describe("makeTypeBulletNode", () => {
63 | it("should create list item with type information", () => {
64 | const node = makeTypeBulletNode("string");
65 | expect(node).toEqual({
66 | type: "listItem",
67 | children: [
68 | {
69 | type: "paragraph",
70 | children: [
71 | { type: "text", value: "Type: " },
72 | { type: "inlineCode", value: "string" },
73 | ],
74 | },
75 | ],
76 | });
77 | });
78 | });
79 |
80 | describe("makeRequiredBulletNode", () => {
81 | it("should create list item for required property", () => {
82 | const node = makeRequiredBulletNode(true);
83 | expect(node).toEqual({
84 | type: "listItem",
85 | children: [
86 | {
87 | type: "paragraph",
88 | children: [
89 | { type: "text", value: "Required: " },
90 | { type: "inlineCode", value: "yes" },
91 | ],
92 | },
93 | ],
94 | });
95 | });
96 |
97 | it("should create list item for optional property", () => {
98 | const node = makeRequiredBulletNode(false);
99 | expect(node).toEqual({
100 | type: "listItem",
101 | children: [
102 | {
103 | type: "paragraph",
104 | children: [
105 | { type: "text", value: "Required: " },
106 | { type: "inlineCode", value: "no" },
107 | ],
108 | },
109 | ],
110 | });
111 | });
112 | });
113 |
114 | describe("makeDefaultBulletNode", () => {
115 | it("should create list item for default value", () => {
116 | const node = makeDefaultBulletNode("default value");
117 | expect(node).toEqual({
118 | type: "listItem",
119 | children: [
120 | {
121 | type: "paragraph",
122 | children: [
123 | { type: "text", value: "Default: " },
124 | { type: "inlineCode", value: '"default value"' },
125 | ],
126 | },
127 | ],
128 | });
129 | });
130 |
131 | it("should handle numeric default", () => {
132 | const node = makeDefaultBulletNode(42);
133 | expect(node).toBeDefined();
134 | if (
135 | node &&
136 | "children" in node &&
137 | node.children[0] &&
138 | "children" in node.children[0]
139 | ) {
140 | expect(node.children[0].children[1]).toEqual({
141 | type: "inlineCode",
142 | value: "42",
143 | });
144 | }
145 | });
146 |
147 | it("should return null for undefined default", () => {
148 | expect(makeDefaultBulletNode(undefined)).toBeNull();
149 | });
150 | });
151 |
152 | describe("makeEnumBulletNode", () => {
153 | it("should create list item with enum values", () => {
154 | const node = makeEnumBulletNode(["red", "green", "blue"]);
155 | expect(node).toEqual({
156 | type: "listItem",
157 | children: [
158 | {
159 | type: "paragraph",
160 | children: [{ type: "text", value: "Allowed values:" }],
161 | },
162 | {
163 | type: "list",
164 | ordered: false,
165 | spread: false,
166 | children: [
167 | {
168 | type: "listItem",
169 | children: [
170 | {
171 | type: "paragraph",
172 | children: [{ type: "inlineCode", value: "red" }],
173 | },
174 | ],
175 | },
176 | {
177 | type: "listItem",
178 | children: [
179 | {
180 | type: "paragraph",
181 | children: [{ type: "inlineCode", value: "green" }],
182 | },
183 | ],
184 | },
185 | {
186 | type: "listItem",
187 | children: [
188 | {
189 | type: "paragraph",
190 | children: [{ type: "inlineCode", value: "blue" }],
191 | },
192 | ],
193 | },
194 | ],
195 | },
196 | ],
197 | });
198 | });
199 |
200 | it("should return null for empty array", () => {
201 | expect(makeEnumBulletNode([])).toBeNull();
202 | expect(makeEnumBulletNode(undefined)).toBeNull();
203 | });
204 | });
205 |
206 | describe("makeAllowedKeysBulletNode", () => {
207 | it("should create list item with allowed keys", () => {
208 | const node = makeAllowedKeysBulletNode(["key1", "key2"]);
209 | expect(node).toEqual({
210 | type: "listItem",
211 | children: [
212 | {
213 | type: "paragraph",
214 | children: [{ type: "text", value: "Allowed keys:" }],
215 | },
216 | {
217 | type: "list",
218 | ordered: false,
219 | spread: false,
220 | children: [
221 | {
222 | type: "listItem",
223 | children: [
224 | {
225 | type: "paragraph",
226 | children: [{ type: "inlineCode", value: "key1" }],
227 | },
228 | ],
229 | },
230 | {
231 | type: "listItem",
232 | children: [
233 | {
234 | type: "paragraph",
235 | children: [{ type: "inlineCode", value: "key2" }],
236 | },
237 | ],
238 | },
239 | ],
240 | },
241 | ],
242 | });
243 | });
244 |
245 | it("should return null for empty/undefined array", () => {
246 | expect(makeAllowedKeysBulletNode([])).toBeNull();
247 | expect(makeAllowedKeysBulletNode(undefined)).toBeNull();
248 | });
249 | });
250 |
251 | describe("makeBullets", () => {
252 | it("should create all relevant bullets for a property", () => {
253 | const property: PropertyInfo = {
254 | name: "test",
255 | fullPath: "test",
256 | type: "string",
257 | required: true,
258 | defaultValue: "default",
259 | allowedValues: ["a", "b"],
260 | allowedKeys: ["key1"],
261 | };
262 |
263 | const bullets = makeBullets(property);
264 | expect(bullets).toHaveLength(5); // type, required, default, enum, allowedKeys
265 | });
266 |
267 | it("should only create necessary bullets", () => {
268 | const property: PropertyInfo = {
269 | name: "test",
270 | fullPath: "test",
271 | type: "string",
272 | required: false,
273 | };
274 |
275 | const bullets = makeBullets(property);
276 | expect(bullets).toHaveLength(2); // only type and required
277 | });
278 | });
279 |
280 | describe("renderPropertyToMarkdown", () => {
281 | it("should render simple property", () => {
282 | const property: PropertyInfo = {
283 | name: "version",
284 | fullPath: "version",
285 | type: "string",
286 | required: true,
287 | description: "The version number",
288 | };
289 |
290 | const nodes = renderPropertyToMarkdown(property);
291 | expect(nodes).toHaveLength(3); // heading, description, bullets list
292 | expect(nodes[0].type).toBe("heading");
293 | expect(nodes[1].type).toBe("paragraph");
294 | expect(nodes[2].type).toBe("list");
295 | });
296 |
297 | it("should render property without description", () => {
298 | const property: PropertyInfo = {
299 | name: "test",
300 | fullPath: "test",
301 | type: "string",
302 | required: false,
303 | };
304 |
305 | const nodes = renderPropertyToMarkdown(property);
306 | expect(nodes).toHaveLength(2); // heading, bullets list (no description)
307 | });
308 |
309 | it("should render property with children", () => {
310 | const property: PropertyInfo = {
311 | name: "config",
312 | fullPath: "config",
313 | type: "object",
314 | required: true,
315 | children: [
316 | {
317 | name: "debug",
318 | fullPath: "config.debug",
319 | type: "boolean",
320 | required: false,
321 | },
322 | ],
323 | };
324 |
325 | const nodes = renderPropertyToMarkdown(property);
326 | expect(nodes.length).toBeGreaterThan(2); // includes child nodes
327 |
328 | // Find child heading node
329 | const childHeading = nodes.find(
330 | (node: RootContent) =>
331 | node.type === "heading" &&
332 | node.type === "heading" &&
333 | "children" in node &&
334 | node.children[0] &&
335 | "value" in node.children[0] &&
336 | node.children[0].value === "config.debug",
337 | );
338 | expect(childHeading).toBeDefined();
339 | });
340 | });
341 |
342 | describe("renderPropertiesToMarkdown", () => {
343 | it("should render complete document with header", () => {
344 | const properties: PropertyInfo[] = [
345 | {
346 | name: "version",
347 | fullPath: "version",
348 | type: "string",
349 | required: true,
350 | },
351 | ];
352 |
353 | const nodes = renderPropertiesToMarkdown(properties);
354 | expect(nodes[0]).toEqual({
355 | type: "paragraph",
356 | children: [
357 | {
358 | type: "text",
359 | value:
360 | "This page describes the complete list of properties that are available within the ",
361 | },
362 | { type: "inlineCode", value: "i18n.json" },
363 | {
364 | type: "text",
365 | value: " configuration file. This file is used by ",
366 | },
367 | {
368 | type: "strong",
369 | children: [{ type: "text", value: "Lingo.dev CLI" }],
370 | },
371 | {
372 | type: "text",
373 | value: " to configure the behavior of the translation pipeline.",
374 | },
375 | ],
376 | });
377 | expect(nodes[1].type).toBe("heading"); // version heading
378 | });
379 |
380 | it("should add spacing between top-level properties", () => {
381 | const properties: PropertyInfo[] = [
382 | {
383 | name: "prop1",
384 | fullPath: "prop1",
385 | type: "string",
386 | required: true,
387 | },
388 | {
389 | name: "prop2",
390 | fullPath: "prop2",
391 | type: "string",
392 | required: false,
393 | },
394 | ];
395 |
396 | const nodes = renderPropertiesToMarkdown(properties);
397 | // Should have spacing paragraphs between properties
398 | const spacingNodes = nodes.filter(
399 | (node: RootContent) =>
400 | node.type === "paragraph" &&
401 | "children" in node &&
402 | node.children[0] &&
403 | "value" in node.children[0] &&
404 | node.children[0].value === "",
405 | );
406 | expect(spacingNodes).toHaveLength(2); // One after each property
407 | });
408 | });
409 |
410 | describe("renderMarkdown", () => {
411 | it("should generate valid markdown string", () => {
412 | const properties: PropertyInfo[] = [
413 | {
414 | name: "version",
415 | fullPath: "version",
416 | type: "string",
417 | required: true,
418 | description: "The version",
419 | },
420 | ];
421 |
422 | const markdown = renderMarkdown(properties);
423 | expect(typeof markdown).toBe("string");
424 | expect(markdown).toContain("---\ntitle: i18n.json properties\n---");
425 | expect(markdown).toContain(
426 | "This page describes the complete list of properties",
427 | );
428 | expect(markdown).toContain("## `version`");
429 | expect(markdown).toContain("The version");
430 | expect(markdown).toContain("* Type: `string`");
431 | expect(markdown).toContain("* Required: `yes`");
432 | });
433 |
434 | it("should handle empty properties array", () => {
435 | const markdown = renderMarkdown([]);
436 | expect(markdown).toContain("---\ntitle: i18n.json properties\n---");
437 | expect(markdown).toContain("This page describes the complete list");
438 | });
439 | });
440 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/xcode-xcstrings-icu.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import {
3 | xcstringsToPluralWithMeta,
4 | pluralWithMetaToXcstrings,
5 | type PluralWithMetadata,
6 | } from "./xcode-xcstrings-icu";
7 |
8 | describe("loaders/xcode-xcstrings-icu", () => {
9 | describe("xcstringsToPluralWithMeta", () => {
10 | it("should convert simple plural forms to ICU", () => {
11 | const input = {
12 | one: "1 item",
13 | other: "%d items",
14 | };
15 |
16 | const result = xcstringsToPluralWithMeta(input, "en");
17 |
18 | expect(result.icu).toBe("{count, plural, one {1 item} other {# items}}");
19 | expect(result._meta).toEqual({
20 | variables: {
21 | count: {
22 | format: "%d",
23 | role: "plural",
24 | },
25 | },
26 | });
27 | });
28 |
29 | it("should convert optional zero form to exact match =0 for English", () => {
30 | const input = {
31 | zero: "No items",
32 | one: "1 item",
33 | other: "%d items",
34 | };
35 |
36 | const result = xcstringsToPluralWithMeta(input, "en");
37 |
38 | // English required forms: one, other
39 | // "zero" is optional, so it becomes "=0"
40 | expect(result.icu).toBe(
41 | "{count, plural, =0 {No items} one {1 item} other {# items}}",
42 | );
43 | expect(result._meta?.variables.count.format).toBe("%d");
44 | });
45 |
46 | it("should convert optional zero form to exact match =0 for Russian", () => {
47 | const input = {
48 | zero: "Нет элементов",
49 | one: "1 элемент",
50 | few: "%d элемента",
51 | many: "%d элементов",
52 | other: "%d элемента",
53 | };
54 |
55 | const result = xcstringsToPluralWithMeta(input, "ru");
56 |
57 | // Russian required forms: one, few, many, other
58 | // "zero" is optional, so it becomes "=0"
59 | expect(result.icu).toBe(
60 | "{count, plural, =0 {Нет элементов} one {1 элемент} few {# элемента} many {# элементов} other {# элемента}}",
61 | );
62 | expect(result._meta?.variables.count.format).toBe("%d");
63 | });
64 |
65 | it("should preserve float format specifiers", () => {
66 | const input = {
67 | one: "%.1f mile",
68 | other: "%.1f miles",
69 | };
70 |
71 | const result = xcstringsToPluralWithMeta(input, "en");
72 |
73 | expect(result.icu).toBe("{count, plural, one {# mile} other {# miles}}");
74 | expect(result._meta).toEqual({
75 | variables: {
76 | count: {
77 | format: "%.1f",
78 | role: "plural",
79 | },
80 | },
81 | });
82 | });
83 |
84 | it("should preserve %lld format specifier", () => {
85 | const input = {
86 | one: "1 photo",
87 | other: "%lld photos",
88 | };
89 |
90 | const result = xcstringsToPluralWithMeta(input, "en");
91 |
92 | expect(result.icu).toBe(
93 | "{count, plural, one {1 photo} other {# photos}}",
94 | );
95 | expect(result._meta).toEqual({
96 | variables: {
97 | count: {
98 | format: "%lld",
99 | role: "plural",
100 | },
101 | },
102 | });
103 | });
104 |
105 | it("should handle multiple variables", () => {
106 | const input = {
107 | one: "%@ uploaded 1 photo",
108 | other: "%@ uploaded %d photos",
109 | };
110 |
111 | const result = xcstringsToPluralWithMeta(input, "en");
112 |
113 | expect(result.icu).toBe(
114 | "{count, plural, one {{var0} uploaded 1 photo} other {{var0} uploaded # photos}}",
115 | );
116 | expect(result._meta).toEqual({
117 | variables: {
118 | var0: {
119 | format: "%@",
120 | role: "other",
121 | },
122 | count: {
123 | format: "%d",
124 | role: "plural",
125 | },
126 | },
127 | });
128 | });
129 |
130 | it("should handle three variables", () => {
131 | const input = {
132 | one: "%@ uploaded 1 photo to %@",
133 | other: "%@ uploaded %d photos to %@",
134 | };
135 |
136 | const result = xcstringsToPluralWithMeta(input, "en");
137 |
138 | // Note: This is a known limitation - when forms have different numbers of placeholders,
139 | // the conversion may not be perfect. The "one" form has 2 placeholders but we map 3 variables.
140 | // In practice, this edge case is rare as plural forms usually have consistent placeholder counts.
141 | expect(result.icu).toContain("{var0} uploaded");
142 | expect(result._meta?.variables).toEqual({
143 | var0: { format: "%@", role: "other" },
144 | count: { format: "%d", role: "plural" },
145 | var1: { format: "%@", role: "other" },
146 | });
147 | });
148 |
149 | it("should handle %.2f precision", () => {
150 | const input = {
151 | one: "%.2f kilometer",
152 | other: "%.2f kilometers",
153 | };
154 |
155 | const result = xcstringsToPluralWithMeta(input, "en");
156 |
157 | expect(result.icu).toBe(
158 | "{count, plural, one {# kilometer} other {# kilometers}}",
159 | );
160 | expect(result._meta?.variables.count.format).toBe("%.2f");
161 | });
162 |
163 | it("should throw error for empty input", () => {
164 | expect(() => xcstringsToPluralWithMeta({}, "en")).toThrow(
165 | "pluralForms cannot be empty",
166 | );
167 | });
168 | });
169 |
170 | describe("pluralWithMetaToXcstrings", () => {
171 | it("should convert ICU back to xcstrings format", () => {
172 | const input: PluralWithMetadata = {
173 | icu: "{count, plural, one {1 item} other {# items}}",
174 | _meta: {
175 | variables: {
176 | count: {
177 | format: "%d",
178 | role: "plural",
179 | },
180 | },
181 | },
182 | };
183 |
184 | const result = pluralWithMetaToXcstrings(input);
185 |
186 | expect(result).toEqual({
187 | one: "1 item",
188 | other: "%d items",
189 | });
190 | });
191 |
192 | it("should restore float format specifiers", () => {
193 | const input: PluralWithMetadata = {
194 | icu: "{count, plural, one {# mile} other {# miles}}",
195 | _meta: {
196 | variables: {
197 | count: {
198 | format: "%.1f",
199 | role: "plural",
200 | },
201 | },
202 | },
203 | };
204 |
205 | const result = pluralWithMetaToXcstrings(input);
206 |
207 | expect(result).toEqual({
208 | one: "%.1f mile",
209 | other: "%.1f miles",
210 | });
211 | });
212 |
213 | it("should restore %lld format", () => {
214 | const input: PluralWithMetadata = {
215 | icu: "{count, plural, one {1 photo} other {# photos}}",
216 | _meta: {
217 | variables: {
218 | count: {
219 | format: "%lld",
220 | role: "plural",
221 | },
222 | },
223 | },
224 | };
225 |
226 | const result = pluralWithMetaToXcstrings(input);
227 |
228 | expect(result).toEqual({
229 | one: "1 photo",
230 | other: "%lld photos",
231 | });
232 | });
233 |
234 | it("should handle multiple variables", () => {
235 | const input: PluralWithMetadata = {
236 | icu: "{count, plural, one {{userName} uploaded 1 photo} other {{userName} uploaded # photos}}",
237 | _meta: {
238 | variables: {
239 | userName: { format: "%@", role: "other" },
240 | count: { format: "%d", role: "plural" },
241 | },
242 | },
243 | };
244 |
245 | const result = pluralWithMetaToXcstrings(input);
246 |
247 | expect(result).toEqual({
248 | one: "%@ uploaded 1 photo",
249 | other: "%@ uploaded %d photos",
250 | });
251 | });
252 |
253 | it("should convert exact match =0 back to zero form", () => {
254 | const input: PluralWithMetadata = {
255 | icu: "{count, plural, =0 {No items} one {1 item} other {# items}}",
256 | _meta: {
257 | variables: {
258 | count: { format: "%d", role: "plural" },
259 | },
260 | },
261 | };
262 |
263 | const result = pluralWithMetaToXcstrings(input);
264 |
265 | expect(result).toEqual({
266 | zero: "No items",
267 | one: "1 item",
268 | other: "%d items",
269 | });
270 | });
271 |
272 | it("should use default format when metadata is missing", () => {
273 | const input: PluralWithMetadata = {
274 | icu: "{count, plural, one {1 item} other {# items}}",
275 | };
276 |
277 | const result = pluralWithMetaToXcstrings(input);
278 |
279 | expect(result).toEqual({
280 | one: "1 item",
281 | other: "%lld items",
282 | });
283 | });
284 |
285 | it("should throw error for invalid ICU format", () => {
286 | const input: PluralWithMetadata = {
287 | icu: "not valid ICU",
288 | };
289 |
290 | expect(() => pluralWithMetaToXcstrings(input)).toThrow();
291 | });
292 | });
293 |
294 | describe("round-trip conversion", () => {
295 | it("should preserve format through round-trip", () => {
296 | const original = {
297 | one: "1 item",
298 | other: "%d items",
299 | };
300 |
301 | const icu = xcstringsToPluralWithMeta(original, "en");
302 | const restored = pluralWithMetaToXcstrings(icu);
303 |
304 | expect(restored).toEqual(original);
305 | });
306 |
307 | it("should preserve float precision through round-trip", () => {
308 | const original = {
309 | one: "%.2f mile",
310 | other: "%.2f miles",
311 | };
312 |
313 | const icu = xcstringsToPluralWithMeta(original, "en");
314 | const restored = pluralWithMetaToXcstrings(icu);
315 |
316 | expect(restored).toEqual(original);
317 | });
318 |
319 | it("should preserve multiple variables through round-trip", () => {
320 | const original = {
321 | one: "%@ uploaded 1 photo",
322 | other: "%@ uploaded %d photos",
323 | };
324 |
325 | const icu = xcstringsToPluralWithMeta(original, "en");
326 | const restored = pluralWithMetaToXcstrings(icu);
327 |
328 | expect(restored).toEqual(original);
329 | });
330 |
331 | it("should preserve zero form through round-trip", () => {
332 | const original = {
333 | zero: "No items",
334 | one: "1 item",
335 | other: "%lld items",
336 | };
337 |
338 | const icu = xcstringsToPluralWithMeta(original, "en");
339 | const restored = pluralWithMetaToXcstrings(icu);
340 |
341 | expect(restored).toEqual(original);
342 | });
343 | });
344 |
345 | describe("translation simulation", () => {
346 | it("should handle English to Russian translation", () => {
347 | // Source (English)
348 | const englishForms = {
349 | one: "1 item",
350 | other: "%d items",
351 | };
352 |
353 | const englishICU = xcstringsToPluralWithMeta(englishForms, "en");
354 |
355 | // Simulate backend translation (English → Russian)
356 | // Backend expands 2 forms to 4 forms
357 | const russianICU: PluralWithMetadata = {
358 | icu: "{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}",
359 | _meta: englishICU._meta, // Metadata preserved
360 | };
361 |
362 | const russianForms = pluralWithMetaToXcstrings(russianICU);
363 |
364 | expect(russianForms).toEqual({
365 | one: "%d элемент",
366 | few: "%d элемента",
367 | many: "%d элементов",
368 | other: "%d элемента",
369 | });
370 | });
371 |
372 | it("should handle Chinese to Arabic translation", () => {
373 | // Source (Chinese - no plurals)
374 | const chineseForms = {
375 | other: "%d 个项目",
376 | };
377 |
378 | const chineseICU = xcstringsToPluralWithMeta(chineseForms, "zh");
379 |
380 | // Simulate backend translation (Chinese → Arabic)
381 | // Backend expands 1 form to 6 forms
382 | const arabicICU: PluralWithMetadata = {
383 | icu: "{count, plural, zero {لا توجد مشاريع} one {مشروع واحد} two {مشروعان} few {# مشاريع} many {# مشروعًا} other {# مشروع}}",
384 | _meta: chineseICU._meta,
385 | };
386 |
387 | const arabicForms = pluralWithMetaToXcstrings(arabicICU);
388 |
389 | expect(arabicForms).toEqual({
390 | zero: "لا توجد مشاريع",
391 | one: "مشروع واحد",
392 | two: "مشروعان",
393 | few: "%d مشاريع",
394 | many: "%d مشروعًا",
395 | other: "%d مشروع",
396 | });
397 | });
398 |
399 | it("should handle variable reordering in translation", () => {
400 | // Source (English)
401 | const englishForms = {
402 | one: "%@ uploaded 1 photo",
403 | other: "%@ uploaded %d photos",
404 | };
405 |
406 | const englishICU = xcstringsToPluralWithMeta(englishForms, "en");
407 |
408 | // Simulate backend translation with variable reordering
409 | const russianICU: PluralWithMetadata = {
410 | icu: "{count, plural, one {{var0} загрузил 1 фото} few {{var0} загрузил # фото} many {{var0} загрузил # фотографий} other {{var0} загрузил # фотографии}}",
411 | _meta: englishICU._meta, // Metadata preserved
412 | };
413 |
414 | const russianForms = pluralWithMetaToXcstrings(russianICU);
415 |
416 | expect(russianForms).toEqual({
417 | one: "%@ загрузил 1 фото",
418 | few: "%@ загрузил %d фото",
419 | many: "%@ загрузил %d фотографий",
420 | other: "%@ загрузил %d фотографии",
421 | });
422 | });
423 | });
424 | });
425 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/cmd/run/execute.ts:
--------------------------------------------------------------------------------
```typescript
1 | import chalk from "chalk";
2 | import { Listr, ListrTask } from "listr2";
3 | import pLimit, { LimitFunction } from "p-limit";
4 | import _ from "lodash";
5 | import { minimatch } from "minimatch";
6 |
7 | import { colors } from "../../constants";
8 | import { CmdRunContext, CmdRunTask, CmdRunTaskResult } from "./_types";
9 | import { commonTaskRendererOptions } from "./_const";
10 | import createBucketLoader from "../../loaders";
11 | import { createDeltaProcessor, Delta } from "../../utils/delta";
12 |
13 | const MAX_WORKER_COUNT = 10;
14 |
15 | export default async function execute(input: CmdRunContext) {
16 | const effectiveConcurrency = Math.min(
17 | input.flags.concurrency,
18 | input.tasks.length,
19 | MAX_WORKER_COUNT,
20 | );
21 | console.log(chalk.hex(colors.orange)(`[Localization]`));
22 |
23 | return new Listr<CmdRunContext>(
24 | [
25 | {
26 | title: "Initializing localization engine",
27 | task: async (ctx, task) => {
28 | task.title = `Localization engine ${chalk.hex(colors.green)(
29 | "ready",
30 | )} (${ctx.localizer!.id})`;
31 | },
32 | },
33 | {
34 | title: `Processing localization tasks ${chalk.dim(
35 | `(tasks: ${input.tasks.length}, concurrency: ${effectiveConcurrency})`,
36 | )}`,
37 | task: async (ctx, task) => {
38 | if (input.tasks.length < 1) {
39 | task.title = `Skipping, nothing to localize.`;
40 | task.skip();
41 | return;
42 | }
43 |
44 | // Preload checksums for all unique bucket path patterns before starting any workers
45 | const initialChecksumsMap = new Map<string, Record<string, string>>();
46 | const uniqueBucketPatterns = _.uniq(
47 | ctx.tasks.map((t) => t.bucketPathPattern),
48 | );
49 | for (const bucketPathPattern of uniqueBucketPatterns) {
50 | const deltaProcessor = createDeltaProcessor(bucketPathPattern);
51 | const checksums = await deltaProcessor.loadChecksums();
52 | initialChecksumsMap.set(bucketPathPattern, checksums);
53 | }
54 |
55 | const i18nLimiter = pLimit(effectiveConcurrency);
56 | const ioLimiter = pLimit(1);
57 |
58 | const perFileIoLimiters = new Map<string, LimitFunction>();
59 | const getFileIoLimiter = (
60 | bucketPathPattern: string,
61 | ): LimitFunction => {
62 | const lockKey = bucketPathPattern;
63 |
64 | if (!perFileIoLimiters.has(lockKey)) {
65 | perFileIoLimiters.set(lockKey, pLimit(1));
66 | }
67 | return perFileIoLimiters.get(lockKey)!;
68 | };
69 |
70 | const workersCount = effectiveConcurrency;
71 |
72 | const workerTasks: ListrTask[] = [];
73 | for (let i = 0; i < workersCount; i++) {
74 | const assignedTasks = ctx.tasks.filter(
75 | (_, idx) => idx % workersCount === i,
76 | );
77 | workerTasks.push(
78 | createWorkerTask({
79 | ctx,
80 | assignedTasks,
81 | ioLimiter,
82 | i18nLimiter,
83 | initialChecksumsMap,
84 | getFileIoLimiter,
85 | onDone() {
86 | task.title = createExecutionProgressMessage(ctx);
87 | },
88 | }),
89 | );
90 | }
91 |
92 | return task.newListr(workerTasks, {
93 | concurrent: true,
94 | exitOnError: false,
95 | rendererOptions: {
96 | ...commonTaskRendererOptions,
97 | collapseSubtasks: true,
98 | },
99 | });
100 | },
101 | },
102 | ],
103 | {
104 | exitOnError: false,
105 | rendererOptions: commonTaskRendererOptions,
106 | },
107 | ).run(input);
108 | }
109 |
110 | function createWorkerStatusMessage(args: {
111 | assignedTask: CmdRunTask;
112 | percentage: number;
113 | }) {
114 | const displayPath = args.assignedTask.bucketPathPattern.replace(
115 | "[locale]",
116 | args.assignedTask.targetLocale,
117 | );
118 | return `[${chalk.hex(colors.yellow)(
119 | `${args.percentage}%`,
120 | )}] Processing: ${chalk.dim(displayPath)} (${chalk.hex(colors.yellow)(
121 | args.assignedTask.sourceLocale,
122 | )} -> ${chalk.hex(colors.yellow)(args.assignedTask.targetLocale)})`;
123 | }
124 |
125 | function createExecutionProgressMessage(ctx: CmdRunContext) {
126 | const succeededTasksCount = countTasks(
127 | ctx,
128 | (_t, result) => result.status === "success",
129 | );
130 | const failedTasksCount = countTasks(
131 | ctx,
132 | (_t, result) => result.status === "error",
133 | );
134 | const skippedTasksCount = countTasks(
135 | ctx,
136 | (_t, result) => result.status === "skipped",
137 | );
138 |
139 | return `Processed ${chalk.green(succeededTasksCount)}/${
140 | ctx.tasks.length
141 | }, Failed ${chalk.red(failedTasksCount)}, Skipped ${chalk.dim(
142 | skippedTasksCount,
143 | )}`;
144 | }
145 |
146 | function createLoaderForTask(assignedTask: CmdRunTask) {
147 | const bucketLoader = createBucketLoader(
148 | assignedTask.bucketType,
149 | assignedTask.bucketPathPattern,
150 | {
151 | defaultLocale: assignedTask.sourceLocale,
152 | injectLocale: assignedTask.injectLocale,
153 | formatter: assignedTask.formatter,
154 | },
155 | assignedTask.lockedKeys,
156 | assignedTask.lockedPatterns,
157 | assignedTask.ignoredKeys,
158 | );
159 | bucketLoader.setDefaultLocale(assignedTask.sourceLocale);
160 |
161 | return bucketLoader;
162 | }
163 |
164 | function createWorkerTask(args: {
165 | ctx: CmdRunContext;
166 | assignedTasks: CmdRunTask[];
167 | ioLimiter: LimitFunction;
168 | i18nLimiter: LimitFunction;
169 | onDone: () => void;
170 | initialChecksumsMap: Map<string, Record<string, string>>;
171 | getFileIoLimiter: (bucketPathPattern: string) => LimitFunction;
172 | }): ListrTask {
173 | return {
174 | title: "Initializing...",
175 | task: async (_subCtx: any, subTask: any) => {
176 | for (const assignedTask of args.assignedTasks) {
177 | subTask.title = createWorkerStatusMessage({
178 | assignedTask,
179 | percentage: 0,
180 | });
181 | const bucketLoader = createLoaderForTask(assignedTask);
182 | const deltaProcessor = createDeltaProcessor(
183 | assignedTask.bucketPathPattern,
184 | );
185 |
186 | // Get initial checksums from the preloaded map
187 | const initialChecksums =
188 | args.initialChecksumsMap.get(assignedTask.bucketPathPattern) || {};
189 |
190 | const taskResult = await args.i18nLimiter(async () => {
191 | try {
192 | // Pull operations must be serialized per-file for single-file formats
193 | // where multiple locales share the same file (e.g., xcode-xcstrings)
194 | const fileIoLimiter = args.getFileIoLimiter(
195 | assignedTask.bucketPathPattern,
196 | );
197 | const sourceData = await fileIoLimiter(async () =>
198 | bucketLoader.pull(assignedTask.sourceLocale),
199 | );
200 | const hints = await fileIoLimiter(async () =>
201 | bucketLoader.pullHints(),
202 | );
203 | const targetData = await fileIoLimiter(async () =>
204 | bucketLoader.pull(assignedTask.targetLocale),
205 | );
206 | const delta = await deltaProcessor.calculateDelta({
207 | sourceData,
208 | targetData,
209 | checksums: initialChecksums,
210 | });
211 |
212 | const processableData = _.chain(sourceData)
213 | .entries()
214 | .filter(
215 | ([key, value]) =>
216 | delta.added.includes(key) ||
217 | delta.updated.includes(key) ||
218 | !!args.ctx.flags.force,
219 | )
220 | .filter(
221 | ([key]) =>
222 | !assignedTask.onlyKeys.length ||
223 | assignedTask.onlyKeys?.some((pattern) =>
224 | minimatch(key, pattern),
225 | ),
226 | )
227 | .fromPairs()
228 | .value();
229 |
230 | if (!Object.keys(processableData).length) {
231 | await fileIoLimiter(async () => {
232 | // re-push in case some of the unlocalizable / meta data changed
233 | await bucketLoader.push(assignedTask.targetLocale, targetData);
234 | });
235 | return {
236 | status: "skipped",
237 | pathPattern: assignedTask.bucketPathPattern,
238 | sourceLocale: assignedTask.sourceLocale,
239 | targetLocale: assignedTask.targetLocale,
240 | } satisfies CmdRunTaskResult;
241 | }
242 |
243 | const relevantHints = _.pick(hints, Object.keys(processableData));
244 | const processedTargetData = await args.ctx.localizer!.localize(
245 | {
246 | sourceLocale: assignedTask.sourceLocale,
247 | targetLocale: assignedTask.targetLocale,
248 | sourceData,
249 | targetData,
250 | processableData,
251 | hints: relevantHints,
252 | },
253 | async (progress, _sourceChunk, processedChunk) => {
254 | // write translated chunks as they are received from LLM
255 | await fileIoLimiter(async () => {
256 | // pull the latest source data before pushing for buckets that store all locales in a single file
257 | await bucketLoader.pull(assignedTask.sourceLocale);
258 | // pull the latest target data to include all already processed chunks
259 | const latestTargetData = await bucketLoader.pull(
260 | assignedTask.targetLocale,
261 | );
262 |
263 | // add the new chunk to target data
264 | const _partialData = _.merge(
265 | {},
266 | latestTargetData,
267 | processedChunk,
268 | );
269 | // process renamed keys
270 | const finalChunkTargetData = processRenamedKeys(
271 | delta,
272 | _partialData,
273 | );
274 | // push final chunk to the target locale
275 | await bucketLoader.push(
276 | assignedTask.targetLocale,
277 | finalChunkTargetData,
278 | );
279 | });
280 |
281 | subTask.title = createWorkerStatusMessage({
282 | assignedTask,
283 | percentage: progress,
284 | });
285 | },
286 | );
287 |
288 | const finalTargetData = _.merge(
289 | {},
290 | sourceData,
291 | targetData,
292 | processedTargetData,
293 | );
294 | const finalRenamedTargetData = processRenamedKeys(
295 | delta,
296 | finalTargetData,
297 | );
298 |
299 | await fileIoLimiter(async () => {
300 | // not all localizers have progress callback (eg. explicit localizer),
301 | // the final target data might not be pushed yet - push now to ensure it's up to date
302 | await bucketLoader.pull(assignedTask.sourceLocale);
303 | await bucketLoader.push(
304 | assignedTask.targetLocale,
305 | finalRenamedTargetData,
306 | );
307 |
308 | const checksums =
309 | await deltaProcessor.createChecksums(sourceData);
310 | if (!args.ctx.flags.targetLocale?.length) {
311 | await deltaProcessor.saveChecksums(checksums);
312 | }
313 | });
314 |
315 | return {
316 | status: "success",
317 | pathPattern: assignedTask.bucketPathPattern,
318 | sourceLocale: assignedTask.sourceLocale,
319 | targetLocale: assignedTask.targetLocale,
320 | } satisfies CmdRunTaskResult;
321 | } catch (error) {
322 | return {
323 | status: "error",
324 | error: error as Error,
325 | pathPattern: assignedTask.bucketPathPattern,
326 | sourceLocale: assignedTask.sourceLocale,
327 | targetLocale: assignedTask.targetLocale,
328 | } satisfies CmdRunTaskResult;
329 | }
330 | });
331 |
332 | args.ctx.results.set(assignedTask, taskResult);
333 | }
334 |
335 | subTask.title = "Done";
336 | },
337 | };
338 | }
339 |
340 | function countTasks(
341 | ctx: CmdRunContext,
342 | predicate: (task: CmdRunTask, result: CmdRunTaskResult) => boolean,
343 | ) {
344 | return Array.from(ctx.results.entries()).filter(([task, result]) =>
345 | predicate(task, result),
346 | ).length;
347 | }
348 |
349 | function processRenamedKeys(delta: Delta, targetData: Record<string, string>) {
350 | return _.chain(targetData)
351 | .entries()
352 | .map(([key, value]) => {
353 | const renaming = delta.renamed.find(([oldKey]) => oldKey === key);
354 | if (!renaming) {
355 | return [key, value];
356 | }
357 | return [renaming[1], value];
358 | })
359 | .fromPairs()
360 | .value();
361 | }
362 |
```
--------------------------------------------------------------------------------
/packages/spec/src/config.ts:
--------------------------------------------------------------------------------
```typescript
1 | import Z from "zod";
2 | import { localeCodeSchema } from "./locales";
3 | import { bucketTypeSchema } from "./formats";
4 |
5 | // common
6 | export const localeSchema = Z.object({
7 | source: localeCodeSchema.describe(
8 | "Primary source locale code of your content (e.g. 'en', 'en-US', 'pt_BR', or 'pt-rBR'). Must be one of the supported locale codes – either a short ISO-639 language code or a full locale identifier using '-', '_' or Android '-r' notation.",
9 | ),
10 | targets: Z.array(localeCodeSchema).describe(
11 | "List of target locale codes to translate to.",
12 | ),
13 | }).describe("Locale configuration block.");
14 |
15 | // factories
16 | type ConfigDefinition<
17 | T extends Z.ZodRawShape,
18 | _P extends Z.ZodRawShape = any,
19 | > = {
20 | schema: Z.ZodObject<T>;
21 | defaultValue: Z.infer<Z.ZodObject<T>>;
22 | parse: (rawConfig: unknown) => Z.infer<Z.ZodObject<T>>;
23 | };
24 | const createConfigDefinition = <
25 | T extends Z.ZodRawShape,
26 | _P extends Z.ZodRawShape = any,
27 | >(
28 | definition: ConfigDefinition<T, _P>,
29 | ) => definition;
30 |
31 | type ConfigDefinitionExtensionParams<
32 | T extends Z.ZodRawShape,
33 | P extends Z.ZodRawShape,
34 | > = {
35 | createSchema: (baseSchema: Z.ZodObject<P>) => Z.ZodObject<T>;
36 | createDefaultValue: (
37 | baseDefaultValue: Z.infer<Z.ZodObject<P>>,
38 | ) => Z.infer<Z.ZodObject<T>>;
39 | createUpgrader: (
40 | config: Z.infer<Z.ZodObject<P>>,
41 | schema: Z.ZodObject<T>,
42 | defaultValue: Z.infer<Z.ZodObject<T>>,
43 | ) => Z.infer<Z.ZodObject<T>>;
44 | };
45 | const extendConfigDefinition = <
46 | T extends Z.ZodRawShape,
47 | P extends Z.ZodRawShape,
48 | >(
49 | definition: ConfigDefinition<P, any>,
50 | params: ConfigDefinitionExtensionParams<T, P>,
51 | ) => {
52 | const schema = params.createSchema(definition.schema);
53 | const defaultValue = params.createDefaultValue(definition.defaultValue);
54 | const upgrader = (config: Z.infer<Z.ZodObject<P>>) =>
55 | params.createUpgrader(config, schema, defaultValue);
56 |
57 | return createConfigDefinition({
58 | schema,
59 | defaultValue,
60 | parse: (rawConfig) => {
61 | const safeResult = schema.safeParse(rawConfig);
62 | if (safeResult.success) {
63 | return safeResult.data;
64 | }
65 |
66 | const localeErrors = safeResult.error.errors
67 | .filter((issue) => issue.message.includes("Invalid locale code"))
68 | .map((issue) => {
69 | let unsupportedLocale = "";
70 | const path = issue.path;
71 |
72 | const config = rawConfig as { locale?: { [key: string]: any } };
73 |
74 | if (config.locale) {
75 | unsupportedLocale = path.reduce<any>((acc, key) => {
76 | if (acc && typeof acc === "object" && key in acc) {
77 | return acc[key];
78 | }
79 | return acc;
80 | }, config.locale);
81 | }
82 |
83 | return `Unsupported locale: ${unsupportedLocale}`;
84 | });
85 |
86 | if (localeErrors.length > 0) {
87 | throw new Error(`\n${localeErrors.join("\n")}`);
88 | }
89 |
90 | const baseConfig = definition.parse(rawConfig);
91 | const result = upgrader(baseConfig);
92 | return result;
93 | },
94 | });
95 | };
96 |
97 | // any -> v0
98 | const configV0Schema = Z.object({
99 | version: Z.union([Z.number(), Z.string()])
100 | .default(0)
101 | .describe("The version number of the schema."),
102 | });
103 | export const configV0Definition = createConfigDefinition({
104 | schema: configV0Schema,
105 | defaultValue: { version: 0 },
106 | parse: (rawConfig) => {
107 | return configV0Schema.parse(rawConfig);
108 | },
109 | });
110 |
111 | // v0 -> v1
112 | export const configV1Definition = extendConfigDefinition(configV0Definition, {
113 | createSchema: (baseSchema) =>
114 | baseSchema.extend({
115 | locale: localeSchema,
116 | buckets: Z.record(Z.string(), bucketTypeSchema)
117 | .default({})
118 | .describe(
119 | "Mapping of source file paths (glob patterns) to bucket types.",
120 | )
121 | .optional(),
122 | }),
123 | createDefaultValue: () => ({
124 | version: 1,
125 | locale: {
126 | source: "en" as const,
127 | targets: ["es" as const],
128 | },
129 | buckets: {},
130 | }),
131 | createUpgrader: () => ({
132 | version: 1,
133 | locale: {
134 | source: "en" as const,
135 | targets: ["es" as const],
136 | },
137 | buckets: {},
138 | }),
139 | });
140 |
141 | // v1 -> v1.1
142 | export const configV1_1Definition = extendConfigDefinition(configV1Definition, {
143 | createSchema: (baseSchema) =>
144 | baseSchema.extend({
145 | buckets: Z.record(
146 | bucketTypeSchema,
147 | Z.object({
148 | include: Z.array(Z.string())
149 | .default([])
150 | .describe(
151 | "File paths or glob patterns to include for this bucket.",
152 | ),
153 | exclude: Z.array(Z.string())
154 | .default([])
155 | .optional()
156 | .describe(
157 | "File paths or glob patterns to exclude from this bucket.",
158 | ),
159 | }),
160 | ).default({}),
161 | }),
162 | createDefaultValue: (baseDefaultValue) => ({
163 | ...baseDefaultValue,
164 | version: 1.1,
165 | buckets: {},
166 | }),
167 | createUpgrader: (oldConfig, schema) => {
168 | const upgradedConfig: Z.infer<typeof schema> = {
169 | ...oldConfig,
170 | version: 1.1,
171 | buckets: {},
172 | };
173 |
174 | // Transform buckets from v1 to v1.1 format
175 | if (oldConfig.buckets) {
176 | for (const [bucketPath, bucketType] of Object.entries(
177 | oldConfig.buckets,
178 | )) {
179 | if (!upgradedConfig.buckets[bucketType]) {
180 | upgradedConfig.buckets[bucketType] = {
181 | include: [],
182 | };
183 | }
184 | upgradedConfig.buckets[bucketType]?.include.push(bucketPath);
185 | }
186 | }
187 |
188 | return upgradedConfig;
189 | },
190 | });
191 |
192 | // v1.1 -> v1.2
193 | // Changes: Add "extraSource" optional field to the locale node of the config
194 | export const configV1_2Definition = extendConfigDefinition(
195 | configV1_1Definition,
196 | {
197 | createSchema: (baseSchema) =>
198 | baseSchema.extend({
199 | locale: localeSchema.extend({
200 | extraSource: localeCodeSchema
201 | .optional()
202 | .describe(
203 | "Optional extra source locale code used as fallback during translation.",
204 | ),
205 | }),
206 | }),
207 | createDefaultValue: (baseDefaultValue) => ({
208 | ...baseDefaultValue,
209 | version: 1.2,
210 | }),
211 | createUpgrader: (oldConfig) => ({
212 | ...oldConfig,
213 | version: 1.2,
214 | }),
215 | },
216 | );
217 |
218 | // v1.2 -> v1.3
219 | // Changes: Support both string paths and {path, delimiter} objects in bucket include/exclude arrays
220 | export const bucketItemSchema = Z.object({
221 | path: Z.string().describe("Path pattern containing a [locale] placeholder."),
222 | delimiter: Z.union([Z.literal("-"), Z.literal("_"), Z.literal(null)])
223 | .optional()
224 | .describe(
225 | "Delimiter that replaces the [locale] placeholder in the path (default: no delimiter).",
226 | ),
227 | }).describe(
228 | "Bucket path item. Either a string path or an object specifying path and delimiter.",
229 | );
230 | export type BucketItem = Z.infer<typeof bucketItemSchema>;
231 |
232 | // Define a base bucket value schema that can be reused and extended
233 | export const bucketValueSchemaV1_3 = Z.object({
234 | include: Z.array(Z.union([Z.string(), bucketItemSchema]))
235 | .default([])
236 | .describe("Glob patterns or bucket items to include for this bucket."),
237 | exclude: Z.array(Z.union([Z.string(), bucketItemSchema]))
238 | .default([])
239 | .optional()
240 | .describe("Glob patterns or bucket items to exclude from this bucket."),
241 | injectLocale: Z.array(Z.string())
242 | .optional()
243 | .describe(
244 | "Keys within files where the current locale should be injected or removed.",
245 | ),
246 | }).describe("Configuration options for a translation bucket.");
247 |
248 | export const configV1_3Definition = extendConfigDefinition(
249 | configV1_2Definition,
250 | {
251 | createSchema: (baseSchema) =>
252 | baseSchema.extend({
253 | buckets: Z.record(bucketTypeSchema, bucketValueSchemaV1_3).default({}),
254 | }),
255 | createDefaultValue: (baseDefaultValue) => ({
256 | ...baseDefaultValue,
257 | version: 1.3,
258 | }),
259 | createUpgrader: (oldConfig) => ({
260 | ...oldConfig,
261 | version: 1.3,
262 | }),
263 | },
264 | );
265 |
266 | const configSchema = "https://lingo.dev/schema/i18n.json";
267 |
268 | // v1.3 -> v1.4
269 | // Changes: Add $schema to the config
270 | export const configV1_4Definition = extendConfigDefinition(
271 | configV1_3Definition,
272 | {
273 | createSchema: (baseSchema) =>
274 | baseSchema.extend({
275 | $schema: Z.string().default(configSchema),
276 | }),
277 | createDefaultValue: (baseDefaultValue) => ({
278 | ...baseDefaultValue,
279 | version: 1.4,
280 | $schema: configSchema,
281 | }),
282 | createUpgrader: (oldConfig) => ({
283 | ...oldConfig,
284 | version: 1.4,
285 | $schema: configSchema,
286 | }),
287 | },
288 | );
289 |
290 | // v1.4 -> v1.5
291 | // Changes: add "provider" field to the config
292 | const providerSchema = Z.object({
293 | id: Z.enum([
294 | "openai",
295 | "anthropic",
296 | "google",
297 | "ollama",
298 | "openrouter",
299 | "mistral",
300 | ]).describe("Identifier of the translation provider service."),
301 | model: Z.string().describe("Model name to use for translations."),
302 | prompt: Z.string().describe(
303 | "Prompt template used when requesting translations.",
304 | ),
305 | baseUrl: Z.string()
306 | .optional()
307 | .describe("Custom base URL for the provider API (optional)."),
308 | }).describe("Configuration for the machine-translation provider.");
309 | export const configV1_5Definition = extendConfigDefinition(
310 | configV1_4Definition,
311 | {
312 | createSchema: (baseSchema) =>
313 | baseSchema.extend({
314 | provider: providerSchema.optional(),
315 | }),
316 | createDefaultValue: (baseDefaultValue) => ({
317 | ...baseDefaultValue,
318 | version: 1.5,
319 | }),
320 | createUpgrader: (oldConfig) => ({
321 | ...oldConfig,
322 | version: 1.5,
323 | }),
324 | },
325 | );
326 |
327 | // v1.5 -> v1.6
328 | // Changes: Add "lockedKeys" string array to bucket config
329 | export const bucketValueSchemaV1_6 = bucketValueSchemaV1_3.extend({
330 | lockedKeys: Z.array(Z.string())
331 | .default([])
332 | .optional()
333 | .describe(
334 | "Keys that must remain unchanged and should never be overwritten by translations.",
335 | ),
336 | });
337 |
338 | export const configV1_6Definition = extendConfigDefinition(
339 | configV1_5Definition,
340 | {
341 | createSchema: (baseSchema) =>
342 | baseSchema.extend({
343 | buckets: Z.record(bucketTypeSchema, bucketValueSchemaV1_6).default({}),
344 | }),
345 | createDefaultValue: (baseDefaultValue) => ({
346 | ...baseDefaultValue,
347 | version: 1.6,
348 | }),
349 | createUpgrader: (oldConfig) => ({
350 | ...oldConfig,
351 | version: 1.6,
352 | }),
353 | },
354 | );
355 |
356 | // Changes: Add "lockedPatterns" string array of regex patterns to bucket config
357 | export const bucketValueSchemaV1_7 = bucketValueSchemaV1_6.extend({
358 | lockedPatterns: Z.array(Z.string())
359 | .default([])
360 | .optional()
361 | .describe(
362 | "Regular expression patterns whose matched content should remain locked during translation.",
363 | ),
364 | });
365 |
366 | export const configV1_7Definition = extendConfigDefinition(
367 | configV1_6Definition,
368 | {
369 | createSchema: (baseSchema) =>
370 | baseSchema.extend({
371 | buckets: Z.record(bucketTypeSchema, bucketValueSchemaV1_7).default({}),
372 | }),
373 | createDefaultValue: (baseDefaultValue) => ({
374 | ...baseDefaultValue,
375 | version: 1.7,
376 | }),
377 | createUpgrader: (oldConfig) => ({
378 | ...oldConfig,
379 | version: 1.7,
380 | }),
381 | },
382 | );
383 |
384 | // v1.7 -> v1.8
385 | // Changes: Add "ignoredKeys" string array to bucket config
386 | export const bucketValueSchemaV1_8 = bucketValueSchemaV1_7.extend({
387 | ignoredKeys: Z.array(Z.string())
388 | .default([])
389 | .optional()
390 | .describe(
391 | "Keys that should be completely ignored by translation processes.",
392 | ),
393 | });
394 |
395 | export const configV1_8Definition = extendConfigDefinition(
396 | configV1_7Definition,
397 | {
398 | createSchema: (baseSchema) =>
399 | baseSchema.extend({
400 | buckets: Z.record(bucketTypeSchema, bucketValueSchemaV1_8).default({}),
401 | }),
402 | createDefaultValue: (baseDefaultValue) => ({
403 | ...baseDefaultValue,
404 | version: 1.8,
405 | }),
406 | createUpgrader: (oldConfig) => ({
407 | ...oldConfig,
408 | version: 1.8,
409 | }),
410 | },
411 | );
412 |
413 | // v1.8 -> v1.9
414 | // Changes: Add "formatter" field to top-level config
415 | export const configV1_9Definition = extendConfigDefinition(
416 | configV1_8Definition,
417 | {
418 | createSchema: (baseSchema) =>
419 | baseSchema.extend({
420 | formatter: Z.enum(["prettier", "biome"])
421 | .optional()
422 | .describe(
423 | "Code formatter to use for all buckets. Defaults to 'prettier' if not specified and a prettier config is found.",
424 | ),
425 | }),
426 | createDefaultValue: (baseDefaultValue) => ({
427 | ...baseDefaultValue,
428 | version: 1.9,
429 | }),
430 | createUpgrader: (oldConfig) => ({
431 | ...oldConfig,
432 | version: 1.9,
433 | }),
434 | },
435 | );
436 |
437 | // v1.9 -> v1.10
438 | // Changes: Add "settings" field to provider config for model-specific parameters
439 | const modelSettingsSchema = Z.object({
440 | temperature: Z.number()
441 | .min(0)
442 | .max(2)
443 | .optional()
444 | .describe(
445 | "Controls randomness in model outputs (0=deterministic, 2=very random). Some models like GPT-5 require temperature=1.",
446 | ),
447 | })
448 | .optional()
449 | .describe("Model-specific settings for translation requests.");
450 |
451 | const providerSchemaV1_10 = Z.object({
452 | id: Z.enum([
453 | "openai",
454 | "anthropic",
455 | "google",
456 | "ollama",
457 | "openrouter",
458 | "mistral",
459 | ]).describe("Identifier of the translation provider service."),
460 | model: Z.string().describe("Model name to use for translations."),
461 | prompt: Z.string().describe(
462 | "Prompt template used when requesting translations.",
463 | ),
464 | baseUrl: Z.string()
465 | .optional()
466 | .describe("Custom base URL for the provider API (optional)."),
467 | settings: modelSettingsSchema,
468 | }).describe("Configuration for the machine-translation provider.");
469 |
470 | export const configV1_10Definition = extendConfigDefinition(
471 | configV1_9Definition,
472 | {
473 | createSchema: (baseSchema) =>
474 | baseSchema.extend({
475 | provider: providerSchemaV1_10.optional(),
476 | }),
477 | createDefaultValue: (baseDefaultValue) => ({
478 | ...baseDefaultValue,
479 | version: "1.10",
480 | }),
481 | createUpgrader: (oldConfig) => ({
482 | ...oldConfig,
483 | version: "1.10",
484 | }),
485 | },
486 | );
487 |
488 | // exports
489 | export const LATEST_CONFIG_DEFINITION = configV1_10Definition;
490 |
491 | export type I18nConfig = Z.infer<(typeof LATEST_CONFIG_DEFINITION)["schema"]>;
492 |
493 | export function parseI18nConfig(rawConfig: unknown) {
494 | try {
495 | const result = LATEST_CONFIG_DEFINITION.parse(rawConfig);
496 | return result;
497 | } catch (error: any) {
498 | throw new Error(`Failed to parse config: ${error.message}`);
499 | }
500 | }
501 |
502 | export const defaultConfig = LATEST_CONFIG_DEFINITION.defaultValue;
503 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/mdx2/section-split.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Optimized version of the section joining algorithm
3 | *
4 | * This implementation focuses on performance and maintainability:
5 | * 1. Uses a lookup table for faster section type determination
6 | * 2. Uses a matrix for faster spacing determination
7 | * 3. Reduces string concatenations by using an array and joining at the end
8 | * 4. Adds detailed comments for better maintainability
9 | */
10 |
11 | import { unified } from "unified";
12 | import _ from "lodash";
13 | import remarkParse from "remark-parse";
14 | import remarkGfm from "remark-gfm";
15 | import remarkMdx from "remark-mdx";
16 | import { VFile } from "vfile";
17 | import { Root, RootContent } from "mdast";
18 | import { PlaceholderedMdx, SectionedMdx } from "./_types";
19 | import { traverseMdast } from "./_utils";
20 | import { createLoader } from "../_utils";
21 | import { ILoader } from "../_types";
22 |
23 | /**
24 | * MDX Section Splitter
25 | *
26 | * This module splits MDX content into logical sections, with special handling for JSX/HTML tags.
27 | *
28 | * Key features:
29 | * - Splits content at headings (h1-h6)
30 | * - Treats JSX/HTML opening tags as separate sections
31 | * - Treats JSX/HTML closing tags as separate sections
32 | * - Treats self-closing JSX/HTML tags as separate sections
33 | * - Handles nested components properly
34 | * - Preserves content between tags as separate sections
35 | * - Intelligently joins sections with appropriate spacing
36 | */
37 |
38 | // Create a parser instance for GitHub-flavoured Markdown and MDX JSX
39 | const parser = unified().use(remarkParse).use(remarkGfm).use(remarkMdx);
40 |
41 | // Interface for section boundaries
42 | interface Boundary {
43 | /** 0-based offset into content where the boundary begins */
44 | start: number;
45 | /** 0-based offset into content where the boundary ends */
46 | end: number;
47 | /** Whether the boundary node itself should be isolated as its own section */
48 | isolateSelf: boolean;
49 | }
50 |
51 | // Section types for intelligent joining
52 | enum SectionType {
53 | HEADING = 0,
54 | JSX_OPENING_TAG = 1,
55 | JSX_CLOSING_TAG = 2,
56 | JSX_SELF_CLOSING_TAG = 3,
57 | CONTENT = 4,
58 | UNKNOWN = 5,
59 | }
60 |
61 | // Spacing matrix for fast lookup
62 | // [prevType][currentType] = spacing
63 | const SPACING_MATRIX = [
64 | // HEADING as previous type
65 | ["\n\n", "\n\n", "\n\n", "\n\n", "\n\n", "\n\n"],
66 | // JSX_OPENING_TAG as previous type
67 | ["\n\n", "\n", "\n", "\n", "\n", "\n\n"],
68 | // JSX_CLOSING_TAG as previous type
69 | ["\n\n", "\n", "\n", "\n", "\n\n", "\n\n"],
70 | // JSX_SELF_CLOSING_TAG as previous type
71 | ["\n\n", "\n", "\n", "\n", "\n", "\n\n"],
72 | // CONTENT as previous type
73 | ["\n\n", "\n\n", "\n", "\n\n", "\n\n", "\n\n"],
74 | // UNKNOWN as previous type
75 | ["\n\n", "\n\n", "\n\n", "\n\n", "\n\n", "\n\n"],
76 | ];
77 |
78 | /**
79 | * Creates a loader that splits MDX content into logical sections.
80 | *
81 | * A new section starts at:
82 | * • Any heading (level 1-6)
83 | * • Any JSX/HTML opening tag (<Component> or <div> etc.)
84 | * • Any JSX/HTML closing tag (</Component> or </div> etc.)
85 | * • Any self-closing JSX/HTML tag (<Component /> or <br /> etc.)
86 | */
87 | export default function createMdxSectionSplitLoader(): ILoader<
88 | PlaceholderedMdx,
89 | SectionedMdx
90 | > {
91 | return createLoader({
92 | async pull(_locale, input) {
93 | // Extract input or use defaults
94 | const {
95 | frontmatter = {},
96 | content = "",
97 | codePlaceholders = {},
98 | } = input ||
99 | ({
100 | frontmatter: {},
101 | content: "",
102 | codePlaceholders: {},
103 | } as PlaceholderedMdx);
104 |
105 | // Skip processing for empty content
106 | if (!content.trim()) {
107 | return {
108 | frontmatter,
109 | sections: {},
110 | };
111 | }
112 |
113 | // Parse the content to get the AST
114 | const file = new VFile(content);
115 | const ast = parser.parse(file) as Root;
116 |
117 | // Process the AST to find section boundaries
118 | const boundaries = findSectionBoundaries(ast, content);
119 |
120 | // Build sections from boundaries
121 | const sections = createSectionsFromBoundaries(boundaries, content);
122 |
123 | return {
124 | frontmatter,
125 | sections,
126 | };
127 | },
128 |
129 | async push(_locale, data, originalInput, _originalLocale) {
130 | // Get sections as array
131 | const sectionsArray = Object.values(data.sections);
132 |
133 | // If no sections, return empty content
134 | if (sectionsArray.length === 0) {
135 | return {
136 | frontmatter: data.frontmatter,
137 | content: "",
138 | codePlaceholders: originalInput?.codePlaceholders ?? {},
139 | };
140 | }
141 |
142 | // Optimize by pre-allocating result array and determining section types once
143 | const resultParts: string[] = new Array(sectionsArray.length * 2 - 1);
144 | const sectionTypes: SectionType[] = new Array(sectionsArray.length);
145 |
146 | // Determine section types for all sections
147 | for (let i = 0; i < sectionsArray.length; i++) {
148 | sectionTypes[i] = determineJsxSectionType(sectionsArray[i]);
149 | }
150 |
151 | // Add first section without spacing
152 | resultParts[0] = sectionsArray[0];
153 |
154 | // Add remaining sections with appropriate spacing
155 | for (let i = 1, j = 1; i < sectionsArray.length; i++, j += 2) {
156 | const prevType = sectionTypes[i - 1];
157 | const currentType = sectionTypes[i];
158 |
159 | // Get spacing from matrix for better performance
160 | resultParts[j] = SPACING_MATRIX[prevType][currentType];
161 | resultParts[j + 1] = sectionsArray[i];
162 | }
163 |
164 | // Join all parts into final content
165 | const content = resultParts.join("");
166 |
167 | return {
168 | frontmatter: data.frontmatter,
169 | content,
170 | codePlaceholders: originalInput?.codePlaceholders ?? {},
171 | };
172 | },
173 | });
174 | }
175 |
176 | /**
177 | * Determines the type of a section based on its content.
178 | * Optimized with regex caching and early returns.
179 | */
180 | function determineJsxSectionType(section: string): SectionType {
181 | section = section.trim();
182 |
183 | // Early returns for common cases
184 | if (!section) return SectionType.UNKNOWN;
185 |
186 | const firstChar = section.charAt(0);
187 | const lastChar = section.charAt(section.length - 1);
188 |
189 | // Check for headings (starts with #)
190 | if (firstChar === "#") {
191 | // Ensure it's a proper heading with space after #
192 | if (/^#{1,6}\s/.test(section)) {
193 | return SectionType.HEADING;
194 | }
195 | }
196 |
197 | // Check for JSX/HTML tags (starts with <)
198 | if (firstChar === "<") {
199 | // Self-closing tag (ends with />)
200 | if (section.endsWith("/>")) {
201 | return SectionType.JSX_SELF_CLOSING_TAG;
202 | }
203 |
204 | // Closing tag (starts with </)
205 | if (section.startsWith("</")) {
206 | return SectionType.JSX_CLOSING_TAG;
207 | }
208 |
209 | // Opening tag (ends with >)
210 | if (lastChar === ">") {
211 | return SectionType.JSX_OPENING_TAG;
212 | }
213 | }
214 |
215 | // Default to content
216 | return SectionType.CONTENT;
217 | }
218 |
219 | /**
220 | * Determines if a node is a JSX or HTML element.
221 | */
222 | function isJsxOrHtml(node: Root | RootContent): boolean {
223 | return (
224 | node.type === "mdxJsxFlowElement" ||
225 | node.type === "mdxJsxTextElement" ||
226 | node.type === "html"
227 | );
228 | }
229 |
230 | /**
231 | * Finds the end position of an opening tag in a text string.
232 | * Optimized to handle nested angle brackets correctly.
233 | */
234 | function findOpeningTagEnd(text: string): number {
235 | let depth = 0;
236 | let inQuotes = false;
237 | let quoteChar = "";
238 |
239 | for (let i = 0; i < text.length; i++) {
240 | const char = text[i];
241 |
242 | // Handle quotes (to avoid counting angle brackets inside attribute values)
243 | if ((char === '"' || char === "'") && (i === 0 || text[i - 1] !== "\\")) {
244 | if (!inQuotes) {
245 | inQuotes = true;
246 | quoteChar = char;
247 | } else if (char === quoteChar) {
248 | inQuotes = false;
249 | }
250 | }
251 |
252 | // Only count angle brackets when not in quotes
253 | if (!inQuotes) {
254 | if (char === "<") depth++;
255 | if (char === ">") {
256 | depth--;
257 | if (depth === 0) return i + 1;
258 | }
259 | }
260 | }
261 | return -1;
262 | }
263 |
264 | /**
265 | * Finds the start position of a closing tag in a text string.
266 | * Optimized to handle nested components correctly.
267 | */
268 | function findClosingTagStart(text: string): number {
269 | // Extract the tag name from the opening tag to match the correct closing tag
270 | const openTagMatch = /<([^\s/>]+)/.exec(text);
271 | if (!openTagMatch) return -1;
272 |
273 | const tagName = openTagMatch[1];
274 | const closingTagRegex = new RegExp(`</${tagName}\\s*>`, "g");
275 |
276 | // Find the last occurrence of the closing tag
277 | let lastMatch = null;
278 | let match;
279 |
280 | while ((match = closingTagRegex.exec(text)) !== null) {
281 | lastMatch = match;
282 | }
283 |
284 | return lastMatch ? lastMatch.index : -1;
285 | }
286 |
287 | /**
288 | * Processes a JSX/HTML node to extract opening and closing tags as separate boundaries.
289 | */
290 | function processJsxNode(
291 | node: RootContent,
292 | content: string,
293 | boundaries: Boundary[],
294 | ): void {
295 | // Skip nodes without valid position information
296 | if (
297 | !node.position ||
298 | typeof node.position.start.offset !== "number" ||
299 | typeof node.position.end.offset !== "number"
300 | ) {
301 | return;
302 | }
303 |
304 | const nodeStart = node.position.start.offset;
305 | const nodeEnd = node.position.end.offset;
306 | const nodeContent = content.slice(nodeStart, nodeEnd);
307 |
308 | // Handle HTML nodes using regex
309 | if (node.type === "html") {
310 | extractHtmlTags(nodeStart, nodeContent, boundaries);
311 | return;
312 | }
313 |
314 | // Handle MDX JSX elements
315 | if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
316 | const isSelfClosing = (node as any).selfClosing === true;
317 |
318 | if (isSelfClosing) {
319 | // Self-closing tag - treat as a single section
320 | boundaries.push({
321 | start: nodeStart,
322 | end: nodeEnd,
323 | isolateSelf: true,
324 | });
325 | } else {
326 | extractJsxTags(node, nodeContent, boundaries);
327 |
328 | // Process children recursively to handle nested components
329 | if ((node as any).children) {
330 | for (const child of (node as any).children) {
331 | if (isJsxOrHtml(child)) {
332 | processJsxNode(child, content, boundaries);
333 | }
334 | }
335 | }
336 | }
337 | }
338 | }
339 |
340 | /**
341 | * Extracts HTML tags using regex and adds them as boundaries.
342 | * Optimized with a more precise regex pattern.
343 | */
344 | function extractHtmlTags(
345 | nodeStart: number,
346 | nodeContent: string,
347 | boundaries: Boundary[],
348 | ): void {
349 | // More precise regex for HTML tags that handles attributes better
350 | const tagRegex =
351 | /<\/?[a-zA-Z][a-zA-Z0-9:._-]*(?:\s+[a-zA-Z:_][a-zA-Z0-9:._-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^'">\s]+))?)*\s*\/?>/g;
352 | let match;
353 |
354 | while ((match = tagRegex.exec(nodeContent)) !== null) {
355 | const tagStart = nodeStart + match.index;
356 | const tagEnd = tagStart + match[0].length;
357 |
358 | boundaries.push({
359 | start: tagStart,
360 | end: tagEnd,
361 | isolateSelf: true,
362 | });
363 | }
364 | }
365 |
366 | /**
367 | * Extracts opening and closing JSX tags and adds them as boundaries.
368 | */
369 | function extractJsxTags(
370 | node: RootContent,
371 | nodeContent: string,
372 | boundaries: Boundary[],
373 | ): void {
374 | const nodeStart = node.position!.start.offset;
375 | const nodeEnd = node.position!.end.offset;
376 |
377 | if (!nodeStart || !nodeEnd) {
378 | return;
379 | }
380 |
381 | // Find the opening tag
382 | const openingTagEnd = findOpeningTagEnd(nodeContent);
383 | if (openingTagEnd > 0) {
384 | boundaries.push({
385 | start: nodeStart,
386 | end: nodeStart + openingTagEnd,
387 | isolateSelf: true,
388 | });
389 | }
390 |
391 | // Find the closing tag
392 | const closingTagStart = findClosingTagStart(nodeContent);
393 | if (closingTagStart > 0 && closingTagStart < nodeContent.length) {
394 | boundaries.push({
395 | start: nodeStart + closingTagStart,
396 | end: nodeEnd,
397 | isolateSelf: true,
398 | });
399 | }
400 | }
401 |
402 | /**
403 | * Finds all section boundaries in the AST.
404 | */
405 | function findSectionBoundaries(ast: Root, content: string): Boundary[] {
406 | const boundaries: Boundary[] = [];
407 |
408 | // Use a Map to cache node positions for faster lookups
409 | const nodePositions = new Map<RootContent, { start: number; end: number }>();
410 |
411 | // Pre-process nodes to cache their positions
412 | traverseMdast(ast, (node: any) => {
413 | if (
414 | node.position &&
415 | typeof node.position.start.offset === "number" &&
416 | typeof node.position.end.offset === "number"
417 | ) {
418 | nodePositions.set(node, {
419 | start: node.position.start.offset,
420 | end: node.position.end.offset,
421 | });
422 | }
423 | });
424 |
425 | for (const child of ast.children) {
426 | const position = nodePositions.get(child);
427 | if (!position) continue;
428 |
429 | if (child.type === "heading") {
430 | // Heading marks the beginning of a new section including itself
431 | boundaries.push({
432 | start: position.start,
433 | end: position.end,
434 | isolateSelf: false,
435 | });
436 | } else if (isJsxOrHtml(child)) {
437 | // Process JSX/HTML nodes to extract tags as separate sections
438 | processJsxNode(child, content, boundaries);
439 | }
440 | }
441 |
442 | // Sort boundaries by start position
443 | return boundaries.sort((a, b) => a.start - b.start);
444 | }
445 |
446 | /**
447 | * Creates sections from the identified boundaries.
448 | * Optimized to reduce unnecessary string operations.
449 | */
450 | function createSectionsFromBoundaries(
451 | boundaries: Boundary[],
452 | content: string,
453 | ): Record<string, string> {
454 | const sections: Record<string, string> = {};
455 |
456 | // Early return for empty content or no boundaries
457 | if (!content.trim() || boundaries.length === 0) {
458 | const trimmed = content.trim();
459 | if (trimmed) {
460 | sections["0"] = trimmed;
461 | }
462 | return sections;
463 | }
464 |
465 | let idx = 0;
466 | let lastEnd = 0;
467 |
468 | // Pre-allocate array with estimated capacity
469 | const sectionsArray: string[] = [];
470 |
471 | // Process each boundary and the content between boundaries
472 | for (let i = 0; i < boundaries.length; i++) {
473 | const { start, end, isolateSelf } = boundaries[i];
474 |
475 | // Capture content before this boundary if any
476 | if (start > lastEnd) {
477 | const segment = content.slice(lastEnd, start).trim();
478 | if (segment) {
479 | sectionsArray.push(segment);
480 | }
481 | }
482 |
483 | if (isolateSelf) {
484 | // Extract the boundary itself as a section
485 | const segment = content.slice(start, end).trim();
486 | if (segment) {
487 | sectionsArray.push(segment);
488 | }
489 | lastEnd = end;
490 | } else {
491 | // For non-isolated boundaries (like headings), include them with following content
492 | const nextStart =
493 | i + 1 < boundaries.length ? boundaries[i + 1].start : content.length;
494 | const segment = content.slice(start, nextStart).trim();
495 | if (segment) {
496 | sectionsArray.push(segment);
497 | }
498 | lastEnd = nextStart;
499 | }
500 | }
501 |
502 | // Capture any content after the last boundary
503 | if (lastEnd < content.length) {
504 | const segment = content.slice(lastEnd).trim();
505 | if (segment) {
506 | sectionsArray.push(segment);
507 | }
508 | }
509 |
510 | // Convert array to object with sequential keys
511 | sectionsArray.forEach((section, index) => {
512 | sections[index.toString()] = section;
513 | });
514 |
515 | return sections;
516 | }
517 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/jsx-scope-inject.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import { lingoJsxScopeInjectMutation } from "./jsx-scope-inject";
3 | import { createPayload, createOutput, defaultParams } from "./_base";
4 | import * as parser from "@babel/parser";
5 | import generate from "@babel/generator";
6 |
7 | // Helper function to run mutation and get result
8 | function runMutation(code: string, rsc = false) {
9 | const params = { ...defaultParams, rsc };
10 | const input = createPayload({ code, params, relativeFilePath: "test" });
11 | const mutated = lingoJsxScopeInjectMutation(input);
12 | if (!mutated) throw new Error("Mutation returned null");
13 | return createOutput(mutated).code;
14 | }
15 |
16 | // Helper function to normalize code for comparison
17 | function normalizeCode(code: string) {
18 | const ast = parser.parse(code, {
19 | sourceType: "module",
20 | plugins: ["jsx", "typescript"],
21 | });
22 | return generate(ast).code;
23 | }
24 |
25 | describe("lingoJsxScopeInjectMutation", () => {
26 | describe("skip", () => {
27 | it("should skip if data-lingo-skip is truthy", () => {
28 | const input = `
29 | function Component() {
30 | return <div data-jsx-scope data-lingo-skip>
31 | <p>Hello world!</p>
32 | </div>;
33 | }
34 | `;
35 |
36 | const expected = `
37 | function Component() {
38 | return <div data-jsx-scope data-lingo-skip>
39 | <p>Hello world!</p>
40 | </div>;
41 | }
42 | `;
43 |
44 | const result = runMutation(input);
45 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
46 | });
47 | });
48 |
49 | describe("transform", () => {
50 | it("should transform elements with data-jsx-scope into LingoComponent", () => {
51 | const input = `
52 | function Component() {
53 | return <div>
54 | <p data-jsx-scope="0/my/custom/path/1" className="text-foreground">Hello world!</p>
55 | </div>;
56 | }
57 | `.trim();
58 |
59 | const expected = `
60 | import { LingoComponent } from "lingo.dev/react/client";
61 | function Component() {
62 | return <div>
63 | <LingoComponent data-jsx-scope="0/my/custom/path/1" className="text-foreground" $as="p" $fileKey="test" $entryKey="0/my/custom/path/1" />
64 | </div>;
65 | }
66 | `.trim();
67 |
68 | const result = runMutation(input);
69 |
70 | // We normalize both the expected and result to handle formatting differences
71 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
72 | });
73 |
74 | it("should transform JSX elements differently for server components", () => {
75 | const input = `
76 | function Component() {
77 | return <div>
78 | <p data-jsx-scope="0/body/0/argument/1" className="text-foreground">Hello world!</p>
79 | </div>;
80 | }
81 | `.trim();
82 |
83 | const expected = `
84 | import { LingoComponent, loadDictionary } from "lingo.dev/react/rsc";
85 | function Component() {
86 | return <div>
87 | <LingoComponent data-jsx-scope="0/body/0/argument/1" className="text-foreground" $as="p" $fileKey="test" $entryKey="0/body/0/argument/1" $loadDictionary={locale => loadDictionary(locale)} />
88 | </div>;
89 | }
90 | `.trim();
91 |
92 | const result = runMutation(input, true);
93 |
94 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
95 | });
96 |
97 | it("should skip transformation if no JSX scopes are present", () => {
98 | const input = `
99 | function Component() {
100 | return <div>
101 | <p className="text-foreground">Hello world!</p>
102 | </div>;
103 | }
104 | `.trim();
105 |
106 | // Input should match output exactly
107 | const result = runMutation(input);
108 | expect(normalizeCode(result)).toBe(normalizeCode(input));
109 | });
110 |
111 | it("should preserve JSX expression attributes", () => {
112 | const input = `
113 | function Component({ dynamicClass }) {
114 | return <div>
115 | <p data-jsx-scope="0/body/0/argument/1" className={dynamicClass}>Hello world!</p>
116 | </div>;
117 | }
118 | `.trim();
119 |
120 | const expected = `
121 | import { LingoComponent } from "lingo.dev/react/client";
122 | function Component({
123 | dynamicClass
124 | }) {
125 | return <div>
126 | <LingoComponent data-jsx-scope="0/body/0/argument/1" className={dynamicClass} $as="p" $fileKey="test" $entryKey="0/body/0/argument/1" />
127 | </div>;
128 | }
129 | `.trim();
130 |
131 | const result = runMutation(input);
132 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
133 | });
134 |
135 | it("should handle boolean attributes correctly", () => {
136 | const input = `
137 | function Component() {
138 | return <div>
139 | <button data-jsx-scope="0/body/0/argument/1" disabled>Click me</button>
140 | </div>;
141 | }
142 | `.trim();
143 |
144 | const expected = `
145 | import { LingoComponent } from "lingo.dev/react/client";
146 | function Component() {
147 | return <div>
148 | <LingoComponent data-jsx-scope="0/body/0/argument/1" disabled $as="button" $fileKey="test" $entryKey="0/body/0/argument/1" />
149 | </div>;
150 | }
151 | `.trim();
152 |
153 | const result = runMutation(input);
154 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
155 | });
156 | });
157 |
158 | describe("variables", () => {
159 | it("should handle JSX variables in elements with data-jsx-scope", () => {
160 | const input = `
161 | function Component({ count, category }) {
162 | return <div>
163 | <p data-jsx-scope="0/body/0/argument/1" className="text-foreground">You have {count} items in {category}.</p>
164 | </div>;
165 | }
166 | `.trim();
167 |
168 | const expected = `
169 | import { LingoComponent } from "lingo.dev/react/client";
170 | function Component({ count, category }) {
171 | return <div>
172 | <LingoComponent
173 | data-jsx-scope="0/body/0/argument/1"
174 | className="text-foreground"
175 | $as="p"
176 | $fileKey="test"
177 | $entryKey="0/body/0/argument/1"
178 | $variables={{ "count": count, "category": category }}
179 | />
180 | </div>;
181 | }
182 | `.trim();
183 |
184 | const result = runMutation(input);
185 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
186 | });
187 |
188 | it("should handle JSX variables for server components", () => {
189 | const input = `
190 | function Component({ count, category }) {
191 | return <div>
192 | <p data-jsx-scope="0/body/0/argument/1" className="text-foreground">You have {count} items in {category}.</p>
193 | </div>;
194 | }
195 | `.trim();
196 |
197 | const expected = `
198 | import { LingoComponent, loadDictionary } from "lingo.dev/react/rsc";
199 | function Component({ count, category }) {
200 | return <div>
201 | <LingoComponent
202 | data-jsx-scope="0/body/0/argument/1"
203 | className="text-foreground"
204 | $as="p"
205 | $fileKey="test"
206 | $entryKey="0/body/0/argument/1"
207 | $variables={{ "count": count, "category": category }}
208 | $loadDictionary={locale => loadDictionary(locale)}
209 | />
210 | </div>;
211 | }
212 | `.trim();
213 |
214 | const result = runMutation(input, true);
215 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
216 | });
217 |
218 | it("should handle nested JSX elements with variables", () => {
219 | const input = `
220 | function Component({ count, user }) {
221 | return <div>
222 | <div data-jsx-scope="0/body/0/argument/1">
223 | Welcome {user.name}, you have {count} notifications.
224 | </div>
225 | </div>;
226 | }
227 | `.trim();
228 |
229 | const expected = `
230 | import { LingoComponent } from "lingo.dev/react/client";
231 | function Component({ count, user }) {
232 | return <div>
233 | <LingoComponent
234 | data-jsx-scope="0/body/0/argument/1"
235 | $as="div"
236 | $fileKey="test"
237 | $entryKey="0/body/0/argument/1"
238 | $variables={{ "user.name": user.name, "count": count }}
239 | />
240 | </div>;
241 | }
242 | `.trim();
243 |
244 | const result = runMutation(input);
245 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
246 | });
247 | });
248 |
249 | describe("elements", () => {
250 | it("should handle nested JSX elements", () => {
251 | const input = `
252 | function Component() {
253 | return <div>
254 | <div data-jsx-scope="0/body/0/argument/1">
255 | <p>Hello</p>
256 | <span>World</span>
257 | </div>
258 | </div>;
259 | }
260 | `.trim();
261 |
262 | const expected = `
263 | import { LingoComponent } from "lingo.dev/react/client";
264 | function Component() {
265 | return <div>
266 | <LingoComponent
267 | data-jsx-scope="0/body/0/argument/1"
268 | $as="div"
269 | $fileKey="test"
270 | $entryKey="0/body/0/argument/1"
271 | $elements={[
272 | ({
273 | children
274 | }) => <p>{children}</p>,
275 | ({
276 | children
277 | }) => <span>{children}</span>
278 | ]}
279 | />
280 | </div>;
281 | }
282 | `.trim();
283 |
284 | const result = runMutation(input);
285 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
286 | });
287 |
288 | it("should handle deeply nested JSX elements", () => {
289 | const input = `
290 | function Component() {
291 | return <div>
292 | <div data-jsx-scope="0/body/0/argument/1">
293 | <p>
294 | <span>
295 | <strong>Deeply</strong>
296 | </span>
297 | nested
298 | </p>
299 | </div>
300 | </div>;
301 | }
302 | `.trim();
303 |
304 | const expected = `
305 | import { LingoComponent } from "lingo.dev/react/client";
306 | function Component() {
307 | return <div>
308 | <LingoComponent
309 | data-jsx-scope="0/body/0/argument/1"
310 | $as="div"
311 | $fileKey="test"
312 | $entryKey="0/body/0/argument/1"
313 | $elements={[
314 | ({
315 | children
316 | }) => <p>{children}</p>,
317 | ({
318 | children
319 | }) => <span>{children}</span>,
320 | ({
321 | children
322 | }) => <strong>{children}</strong>
323 | ]}
324 | />
325 | </div>;
326 | }
327 | `.trim();
328 |
329 | const result = runMutation(input);
330 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
331 | });
332 |
333 | it("should handle nested elements with variables", () => {
334 | const input = `
335 | function Component({ name }) {
336 | return <div>
337 | <div data-jsx-scope="0/body/0/argument/1">
338 | <p>Hello {name}</p>
339 | <span>Welcome back!</span>
340 | </div>
341 | </div>;
342 | }
343 | `.trim();
344 |
345 | const expected = `
346 | import { LingoComponent } from "lingo.dev/react/client";
347 | function Component({ name }) {
348 | return <div>
349 | <LingoComponent
350 | data-jsx-scope="0/body/0/argument/1"
351 | $as="div"
352 | $fileKey="test"
353 | $entryKey="0/body/0/argument/1"
354 | $variables={{ "name": name }}
355 | $elements={[
356 | ({
357 | children
358 | }) => <p>{children}</p>,
359 | ({
360 | children
361 | }) => <span>{children}</span>
362 | ]}
363 | />
364 | </div>;
365 | }
366 | `.trim();
367 |
368 | const result = runMutation(input);
369 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
370 | });
371 | });
372 |
373 | describe("functions", () => {
374 | it("should handle simple function calls", () => {
375 | const input = `
376 | function Component() {
377 | return <div>
378 | <p data-jsx-scope="0/body/0/argument/1">Hello {getName(user)}, you have {getCount()} items</p>
379 | </div>;
380 | }
381 | `.trim();
382 |
383 | const expected = `
384 | import { LingoComponent } from "lingo.dev/react/client";
385 | function Component() {
386 | return <div>
387 | <LingoComponent
388 | data-jsx-scope="0/body/0/argument/1"
389 | $as="p"
390 | $fileKey="test"
391 | $entryKey="0/body/0/argument/1"
392 | $functions={{
393 | "getName": [getName(user)],
394 | "getCount": [getCount()]
395 | }}
396 | />
397 | </div>;
398 | }
399 | `.trim();
400 |
401 | const result = runMutation(input);
402 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
403 | });
404 |
405 | it("should handle function calls with variables and nested elements", () => {
406 | const input = `
407 | function Component({ user }) {
408 | return <div>
409 | <div data-jsx-scope="0/body/0/argument/1">
410 | <p>{formatName(getName(user))}</p> has <em>{count}</em>
411 | <span>Last seen: {formatDate(user.lastSeen)}</span>
412 | </div>
413 | </div>;
414 | }
415 | `.trim();
416 |
417 | const expected = `
418 | import { LingoComponent } from "lingo.dev/react/client";
419 | function Component({ user }) {
420 | return <div>
421 | <LingoComponent
422 | data-jsx-scope="0/body/0/argument/1"
423 | $as="div"
424 | $fileKey="test"
425 | $entryKey="0/body/0/argument/1"
426 | $variables={{ "count": count }}
427 | $elements={[
428 | ({ children }) => <p>{children}</p>,
429 | ({ children }) => <em>{children}</em>,
430 | ({ children }) => <span>{children}</span>
431 | ]}
432 | $functions={{
433 | "formatName": [formatName(getName(user))],
434 | "formatDate": [formatDate(user.lastSeen)]
435 | }}
436 | />
437 | </div>;
438 | }
439 | `.trim();
440 |
441 | const result = runMutation(input);
442 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
443 | });
444 | });
445 |
446 | describe("expressions", () => {
447 | it("should extract simple expressions", () => {
448 | const input = `
449 | function Component() {
450 | return <div>
451 | <p data-jsx-scope="0/body/0/argument/1">Result: {count + 1}</p>
452 | </div>;
453 | }
454 | `.trim();
455 |
456 | const expected = `
457 | import { LingoComponent } from "lingo.dev/react/client";
458 | function Component() {
459 | return <div>
460 | <LingoComponent
461 | data-jsx-scope="0/body/0/argument/1"
462 | $as="p"
463 | $fileKey="test"
464 | $entryKey="0/body/0/argument/1"
465 | $expressions={[
466 | count + 1
467 | ]}
468 | />
469 | </div>;
470 | }
471 | `.trim();
472 |
473 | const result = runMutation(input);
474 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
475 | });
476 |
477 | it("should extract multiple expressions", () => {
478 | const input = `
479 | function Component() {
480 | return <div>
481 | <p data-jsx-scope="0/body/0/argument/1">First: {count * 2}, Second: {value > 0}</p>
482 | </div>;
483 | }
484 | `.trim();
485 |
486 | const expected = `
487 | import { LingoComponent } from "lingo.dev/react/client";
488 | function Component() {
489 | return <div>
490 | <LingoComponent
491 | data-jsx-scope="0/body/0/argument/1"
492 | $as="p"
493 | $fileKey="test"
494 | $entryKey="0/body/0/argument/1"
495 | $expressions={[
496 | count * 2,
497 | value > 0
498 | ]}
499 | />
500 | </div>;
501 | }
502 | `.trim();
503 |
504 | const result = runMutation(input);
505 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
506 | });
507 |
508 | it("should handle mixed variables, functions and expressions", () => {
509 | const input = `
510 | function Component() {
511 | return <div>
512 | <p data-jsx-scope="0/body/0/argument/1">
513 | {count + 1} items by {user.name}, processed by {getName()}}
514 | </p>
515 | </div>;
516 | }
517 | `.trim();
518 |
519 | const expected = `
520 | import { LingoComponent } from "lingo.dev/react/client";
521 | function Component() {
522 | return <div>
523 | <LingoComponent
524 | data-jsx-scope="0/body/0/argument/1"
525 | $as="p"
526 | $fileKey="test"
527 | $entryKey="0/body/0/argument/1"
528 | $variables={{
529 | "user.name": user.name
530 | }}
531 | $functions={{
532 | "getName": [getName()],
533 | }}
534 | $expressions={[
535 | count + 1
536 | ]}
537 | />
538 | </div>;
539 | }
540 | `.trim();
541 |
542 | const result = runMutation(input);
543 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
544 | });
545 |
546 | it("should handle expressions in nested elements", () => {
547 | const input = `
548 | function Component() {
549 | return <div>
550 | <div data-jsx-scope="0/body/0/argument/1">
551 | <p>Count: {items.length + offset}</p>
552 | <span>Active: {items.filter(i => i.active).length > 0}</span>
553 | </div>
554 | </div>;
555 | }
556 | `.trim();
557 |
558 | const expected = `
559 | import { LingoComponent } from "lingo.dev/react/client";
560 | function Component() {
561 | return <div>
562 | <LingoComponent
563 | data-jsx-scope="0/body/0/argument/1"
564 | $as="div"
565 | $fileKey="test"
566 | $entryKey="0/body/0/argument/1"
567 | $elements={[
568 | ({ children }) => <p>{children}</p>,
569 | ({ children }) => <span>{children}</span>
570 | ]}
571 | $expressions={[
572 | items.length + offset,
573 | items.filter(i => i.active).length > 0
574 | ]}
575 | />
576 | </div>;
577 | }
578 | `.trim();
579 |
580 | const result = runMutation(input);
581 | expect(normalizeCode(result)).toBe(normalizeCode(expected));
582 | });
583 | });
584 | });
585 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createUnplugin } from "unplugin";
2 | import type { NextConfig } from "next";
3 | import packageJson from "../package.json";
4 | import _ from "lodash";
5 | import dedent from "dedent";
6 | import { defaultParams } from "./_base";
7 | import { LCP_DICTIONARY_FILE_NAME } from "./_const";
8 | import { LCPCache } from "./lib/lcp/cache";
9 | import { getInvalidLocales } from "./utils/locales";
10 | import {
11 | getGroqKeyFromEnv,
12 | getGroqKeyFromRc,
13 | getGoogleKeyFromEnv,
14 | getGoogleKeyFromRc,
15 | getMistralKeyFromEnv,
16 | getMistralKeyFromRc,
17 | getLingoDotDevKeyFromEnv,
18 | getLingoDotDevKeyFromRc,
19 | } from "./utils/llm-api-key";
20 | import { isRunningInCIOrDocker } from "./utils/env";
21 | import { providerDetails } from "./lib/lcp/api/provider-details";
22 | import { loadDictionary, transformComponent } from "./_loader-utils";
23 | import trackEvent from "./utils/observability";
24 |
25 | const keyCheckers: Record<
26 | string,
27 | {
28 | checkEnv: () => string | undefined;
29 | checkRc: () => string | undefined;
30 | }
31 | > = {
32 | groq: {
33 | checkEnv: getGroqKeyFromEnv,
34 | checkRc: getGroqKeyFromRc,
35 | },
36 | google: {
37 | checkEnv: getGoogleKeyFromEnv,
38 | checkRc: getGoogleKeyFromRc,
39 | },
40 | mistral: {
41 | checkEnv: getMistralKeyFromEnv,
42 | checkRc: getMistralKeyFromRc,
43 | },
44 | "lingo.dev": {
45 | checkEnv: getLingoDotDevKeyFromEnv,
46 | checkRc: getLingoDotDevKeyFromRc,
47 | },
48 | };
49 |
50 | const alreadySentBuildEvent = { value: false };
51 |
52 | function sendBuildEvent(framework: string, config: any, isDev: boolean) {
53 | if (alreadySentBuildEvent.value) return;
54 | alreadySentBuildEvent.value = true;
55 | trackEvent("compiler.build.start", {
56 | framework,
57 | configuration: config,
58 | isDevMode: isDev,
59 | });
60 | }
61 |
62 | const unplugin = createUnplugin<Partial<typeof defaultParams> | undefined>(
63 | (_params, _meta) => {
64 | console.log("ℹ️ Starting Lingo.dev compiler...");
65 |
66 | const params = _.defaults(_params, defaultParams);
67 |
68 | // Validate if not in CI or Docker
69 | if (!isRunningInCIOrDocker()) {
70 | if (params.models === "lingo.dev") {
71 | validateLLMKeyDetails(["lingo.dev"]);
72 | } else {
73 | const configuredProviders = getConfiguredProviders(params.models);
74 | validateLLMKeyDetails(configuredProviders);
75 |
76 | const invalidLocales = getInvalidLocales(
77 | params.models,
78 | params.sourceLocale,
79 | params.targetLocales,
80 | );
81 | if (invalidLocales.length > 0) {
82 | throw new Error(dedent`
83 | ⚠️ Lingo.dev Localization Compiler requires LLM model setup for the following locales: ${invalidLocales.join(
84 | ", ",
85 | )}.
86 |
87 | ⭐️ Next steps:
88 | 1. Refer to documentation for help: https://lingo.dev/compiler
89 | 2. If you want to use a different LLM, raise an issue in our open-source repo: https://lingo.dev/go/gh
90 | 3. If you have questions, feature requests, or would like to contribute, join our Discord: https://lingo.dev/go/discord
91 | `);
92 | }
93 | }
94 | }
95 |
96 | LCPCache.ensureDictionaryFile({
97 | sourceRoot: params.sourceRoot,
98 | lingoDir: params.lingoDir,
99 | });
100 |
101 | const isDev: boolean =
102 | "dev" in _meta ? !!_meta.dev : process.env.NODE_ENV !== "production";
103 | sendBuildEvent("unplugin", params, isDev);
104 |
105 | return {
106 | name: packageJson.name,
107 | loadInclude: (id) => !!id.match(LCP_DICTIONARY_FILE_NAME),
108 | async load(id) {
109 | const dictionary = await loadDictionary({
110 | resourcePath: id,
111 | resourceQuery: "",
112 | params: {
113 | ...params,
114 | models: params.models,
115 | sourceLocale: params.sourceLocale,
116 | targetLocales: params.targetLocales,
117 | },
118 | sourceRoot: params.sourceRoot,
119 | lingoDir: params.lingoDir,
120 | isDev,
121 | });
122 |
123 | if (!dictionary) {
124 | return null;
125 | }
126 |
127 | return {
128 | code: `export default ${JSON.stringify(dictionary, null, 2)}`,
129 | };
130 | },
131 | transformInclude: (id) => id.endsWith(".tsx") || id.endsWith(".jsx"),
132 | enforce: "pre",
133 | transform(code, id) {
134 | try {
135 | const result = transformComponent({
136 | code,
137 | params,
138 | resourcePath: id,
139 | sourceRoot: params.sourceRoot,
140 | });
141 |
142 | return result;
143 | } catch (error) {
144 | console.error("⚠️ Lingo.dev compiler failed to localize your app");
145 | console.error("⚠️ Details:", error);
146 |
147 | return code;
148 | }
149 | },
150 | };
151 | },
152 | );
153 |
154 | export default {
155 | /**
156 | * Initializes Lingo.dev Compiler for Next.js (App Router).
157 | *
158 | * @param compilerParams - The compiler parameters.
159 | *
160 | * @returns The Next.js configuration.
161 | *
162 | * @example Configuration for Next.js's default template
163 | * ```ts
164 | * import lingoCompiler from "lingo.dev/compiler";
165 | * import type { NextConfig } from "next";
166 | *
167 | * const nextConfig: NextConfig = {
168 | * /* config options here *\/
169 | * };
170 | *
171 | * export default lingoCompiler.next({
172 | * sourceRoot: "app",
173 | * models: "lingo.dev",
174 | * })(nextConfig);
175 | * ```
176 | */
177 | next:
178 | (
179 | compilerParams?: Partial<typeof defaultParams> & {
180 | turbopack?: {
181 | enabled?: boolean | "auto";
182 | useLegacyTurbo?: boolean;
183 | };
184 | },
185 | ) =>
186 | (nextConfig: any = {}): NextConfig => {
187 | const mergedParams = _.merge(
188 | {},
189 | defaultParams,
190 | {
191 | rsc: true,
192 | turbopack: {
193 | enabled: "auto",
194 | useLegacyTurbo: false,
195 | },
196 | },
197 | compilerParams,
198 | );
199 |
200 | const isDev = process.env.NODE_ENV !== "production";
201 | sendBuildEvent("Next.js", mergedParams, isDev);
202 |
203 | let turbopackEnabled: boolean;
204 | if (mergedParams.turbopack?.enabled === "auto") {
205 | turbopackEnabled =
206 | process.env.TURBOPACK === "1" || process.env.TURBOPACK === "true";
207 | } else {
208 | turbopackEnabled = mergedParams.turbopack?.enabled === true;
209 | }
210 |
211 | const supportLegacyTurbo: boolean =
212 | mergedParams.turbopack?.useLegacyTurbo === true;
213 |
214 | const hasWebpackConfig = typeof nextConfig.webpack === "function";
215 | const hasTurbopackConfig = typeof nextConfig.turbopack === "function";
216 | if (hasWebpackConfig && turbopackEnabled) {
217 | console.warn(
218 | "⚠️ Turbopack is enabled in the Lingo.dev compiler, but you have webpack config. Lingo.dev will still apply turbopack configuration.",
219 | );
220 | }
221 | if (hasTurbopackConfig && !turbopackEnabled) {
222 | console.warn(
223 | "⚠️ Turbopack is disabled in the Lingo.dev compiler, but you have turbopack config. Lingo.dev will not apply turbopack configuration.",
224 | );
225 | }
226 |
227 | // Webpack
228 | const originalWebpack = nextConfig.webpack;
229 | nextConfig.webpack = (config: any, options: any) => {
230 | if (!turbopackEnabled) {
231 | console.log("Applying Lingo.dev webpack configuration...");
232 | config.plugins.unshift(unplugin.webpack(mergedParams));
233 | }
234 |
235 | if (typeof originalWebpack === "function") {
236 | return originalWebpack(config, options);
237 | }
238 | return config;
239 | };
240 |
241 | // Turbopack
242 | if (turbopackEnabled) {
243 | console.log("Applying Lingo.dev Turbopack configuration...");
244 |
245 | // Check if the legacy turbo flag is set
246 | let turbopackConfigPath = (nextConfig.turbopack ??= {});
247 | if (supportLegacyTurbo) {
248 | turbopackConfigPath = (nextConfig.experimental ??= {}).turbo ??= {};
249 | }
250 |
251 | turbopackConfigPath.rules ??= {};
252 | const rules = turbopackConfigPath.rules;
253 |
254 | // Regex for all relevant files for Lingo.dev
255 | const lingoGlob = `**/*.{ts,tsx,js,jsx}`;
256 |
257 | // The .cjs extension is required for Next.js v14
258 | const lingoLoaderPath = require.resolve("./lingo-turbopack-loader.cjs");
259 |
260 | rules[lingoGlob] = {
261 | loaders: [
262 | {
263 | loader: lingoLoaderPath,
264 | options: mergedParams,
265 | },
266 | ],
267 | };
268 | }
269 |
270 | return nextConfig;
271 | },
272 | /**
273 | * Initializes Lingo.dev Compiler for Vite.
274 | *
275 | * @param compilerParams - The compiler parameters.
276 | *
277 | * @returns The Vite configuration.
278 | *
279 | * @example Configuration for Vite's "react-ts" template
280 | * ```ts
281 | * import { defineConfig, type UserConfig } from "vite";
282 | * import react from "@vitejs/plugin-react";
283 | * import lingoCompiler from "lingo.dev/compiler";
284 | *
285 | * // https://vite.dev/config/
286 | * const viteConfig: UserConfig = {
287 | * plugins: [react()],
288 | * };
289 | *
290 | * export default defineConfig(() =>
291 | * lingoCompiler.vite({
292 | * models: "lingo.dev",
293 | * })(viteConfig)
294 | * );
295 | * ```
296 | *
297 | * @example Configuration for React Router's default template
298 | * ```ts
299 | * import { reactRouter } from "@react-router/dev/vite";
300 | * import tailwindcss from "@tailwindcss/vite";
301 | * import lingoCompiler from "lingo.dev/compiler";
302 | * import { defineConfig, type UserConfig } from "vite";
303 | * import tsconfigPaths from "vite-tsconfig-paths";
304 | *
305 | * const viteConfig: UserConfig = {
306 | * plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
307 | * };
308 | *
309 | * export default defineConfig(() =>
310 | * lingoCompiler.vite({
311 | * sourceRoot: "app",
312 | * models: "lingo.dev",
313 | * })(viteConfig)
314 | * );
315 | * ```
316 | */
317 | vite: (compilerParams?: Partial<typeof defaultParams>) => (config: any) => {
318 | const mergedParams = _.merge(
319 | {},
320 | defaultParams,
321 | { rsc: false },
322 | compilerParams,
323 | );
324 |
325 | const isDev = process.env.NODE_ENV !== "production";
326 | const isReactRouter = config.plugins
327 | ?.flat()
328 | ?.some((plugin: any) => plugin.name === "react-router");
329 | const framework = isReactRouter ? "React Router" : "Vite";
330 | sendBuildEvent(framework, mergedParams, isDev);
331 | config.plugins.unshift(unplugin.vite(mergedParams));
332 | return config;
333 | },
334 | };
335 |
336 | /**
337 | * Extract a list of supported LLM provider IDs from the locale→model mapping.
338 | * @param models Mapping from locale to "<providerId>:<modelName>" strings.
339 | */
340 | function getConfiguredProviders(models: Record<string, string>): string[] {
341 | return _.chain(Object.values(models))
342 | .map((modelString) => modelString.split(":")[0]) // Extract provider ID
343 | .filter(Boolean) // Remove empty strings
344 | .uniq() // Get unique providers
345 | .filter(
346 | (providerId) =>
347 | providerDetails.hasOwnProperty(providerId) &&
348 | keyCheckers.hasOwnProperty(providerId),
349 | ) // Only check for known and implemented providers
350 | .value();
351 | }
352 |
353 | /**
354 | * Print helpful information about where the LLM API keys for configured providers
355 | * were discovered. The compiler looks for the key first in the environment
356 | * (incl. .env files) and then in the user-wide configuration. Environment always wins.
357 | * @param configuredProviders List of provider IDs detected in the configuration.
358 | */
359 | function validateLLMKeyDetails(configuredProviders: string[]): void {
360 | if (configuredProviders.length === 0) {
361 | // No LLM providers configured that we can validate keys for.
362 | return;
363 | }
364 |
365 | const keyStatuses: Record<
366 | string,
367 | {
368 | foundInEnv: boolean;
369 | foundInRc: boolean;
370 | details: (typeof providerDetails)[string];
371 | }
372 | > = {};
373 | const missingProviders: string[] = [];
374 | const foundProviders: string[] = [];
375 |
376 | for (const providerId of configuredProviders) {
377 | const details = providerDetails[providerId];
378 | const checkers = keyCheckers[providerId];
379 | if (!details || !checkers) continue; // Should not happen due to filter above
380 |
381 | const foundInEnv = !!checkers.checkEnv();
382 | const foundInRc = !!checkers.checkRc();
383 |
384 | keyStatuses[providerId] = { foundInEnv, foundInRc, details };
385 |
386 | if (!foundInEnv && !foundInRc) {
387 | missingProviders.push(providerId);
388 | } else {
389 | foundProviders.push(providerId);
390 | }
391 | }
392 |
393 | if (missingProviders.length > 0) {
394 | console.log(dedent`
395 | \n
396 | 💡 Lingo.dev Localization Compiler is configured to use the following LLM provider(s): ${configuredProviders.join(
397 | ", ",
398 | )}.
399 |
400 | The compiler requires API keys for these providers to work, but the following keys are missing:
401 | `);
402 |
403 | for (const providerId of missingProviders) {
404 | const status = keyStatuses[providerId];
405 | if (!status) continue;
406 | console.log(dedent`
407 | ⚠️ ${status.details.name} API key is missing. Set ${
408 | status.details.apiKeyEnvVar
409 | } environment variable.
410 |
411 | 👉 You can set the API key in one of the following ways:
412 | 1. User-wide: Run npx lingo.dev@latest config set ${
413 | status.details.apiKeyConfigKey || "<config-key-not-available>"
414 | } <your-api-key>
415 | 2. Project-wide: Add ${
416 | status.details.apiKeyEnvVar
417 | }=<your-api-key> to .env file in every project that uses Lingo.dev Localization Compiler
418 | 3. Session-wide: Run export ${
419 | status.details.apiKeyEnvVar
420 | }=<your-api-key> in your terminal before running the compiler to set the API key for the current session
421 |
422 | ⭐️ If you don't yet have a ${
423 | status.details.name
424 | } API key, get one for free at ${status.details.getKeyLink}
425 | `);
426 | }
427 |
428 | const errorMessage = dedent`
429 | \n
430 | ⭐️ Also:
431 | 1. If you want to use a different LLM, update your configuration. Refer to documentation for help: https://lingo.dev/compiler
432 | 2. If the model/provider you want to use isn't supported yet, raise an issue in our open-source repo: https://lingo.dev/go/gh
433 | 3. If you have questions, feature requests, or would like to contribute, join our Discord: https://lingo.dev/go/discord
434 | `;
435 | console.log(errorMessage);
436 | throw new Error("Missing required LLM API keys. See details above.");
437 | } else if (foundProviders.length > 0) {
438 | console.log(dedent`
439 | \n
440 | 🔑 LLM API keys detected for configured providers: ${foundProviders.join(
441 | ", ",
442 | )}.
443 | `);
444 | for (const providerId of foundProviders) {
445 | const status = keyStatuses[providerId];
446 | if (!status) continue;
447 | let sourceMessage = "";
448 | if (status.foundInEnv && status.foundInRc) {
449 | sourceMessage = `from both environment variables (${status.details.apiKeyEnvVar}) and your user-wide configuration. The key from the environment will be used because it has higher priority.`;
450 | } else if (status.foundInEnv) {
451 | sourceMessage = `from environment variables (${status.details.apiKeyEnvVar}).`;
452 | } else if (status.foundInRc) {
453 | sourceMessage = `from your user-wide configuration${
454 | status.details.apiKeyConfigKey
455 | ? ` (${status.details.apiKeyConfigKey})`
456 | : ""
457 | }.`;
458 | }
459 | console.log(dedent`
460 | • ${status.details.name} API key loaded ${sourceMessage}
461 | `);
462 | }
463 | console.log("✨");
464 | }
465 | }
466 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/lib/lcp/server.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from "vitest";
2 | import { LCPServer } from "./server";
3 | import { LCPSchema } from "./schema";
4 | import { LCPCache } from "./cache";
5 | import { LCPAPI } from "./api";
6 |
7 | describe("LCPServer", () => {
8 | beforeEach(() => {
9 | vi.restoreAllMocks();
10 | vi.mock("fs");
11 | vi.mock("path");
12 | });
13 |
14 | describe("loadDictionaries", () => {
15 | it("should load dictionaries for all target locales", async () => {
16 | const lcp: LCPSchema = {
17 | version: 0.1,
18 | files: {},
19 | };
20 | const loadDictionaryForLocaleSpy = vi.spyOn(
21 | LCPServer,
22 | "loadDictionaryForLocale",
23 | );
24 | const dictionaries = await LCPServer.loadDictionaries({
25 | models: {
26 | "*:*": "groq:mistral-saba-24b",
27 | },
28 | lcp,
29 | sourceLocale: "en",
30 | targetLocales: ["fr", "es", "de"],
31 | sourceRoot: "src",
32 | lingoDir: "lingo",
33 | });
34 |
35 | expect(loadDictionaryForLocaleSpy).toHaveBeenCalledTimes(4);
36 | expect(dictionaries).toEqual({
37 | fr: {
38 | version: 0.1,
39 | locale: "fr",
40 | files: {},
41 | },
42 | es: {
43 | version: 0.1,
44 | locale: "es",
45 | files: {},
46 | },
47 | de: {
48 | version: 0.1,
49 | locale: "de",
50 | files: {},
51 | },
52 | en: {
53 | version: 0.1,
54 | locale: "en",
55 | files: {},
56 | },
57 | });
58 | });
59 | });
60 |
61 | describe("loadDictionaryForLocale", () => {
62 | it("should correctly extract the source dictionary when source and target locales are the same", async () => {
63 | // Mock LCPAPI.translate() to ensure it's not called
64 | const translateSpy = vi.spyOn(LCPAPI, "translate");
65 |
66 | const lcp: LCPSchema = {
67 | version: 0.1,
68 | files: {
69 | "app/test.tsx": {
70 | scopes: {
71 | key1: {
72 | content: "Hello World",
73 | hash: "abcd1234",
74 | },
75 | key2: {
76 | content: "Button Text",
77 | hash: "efgh5678",
78 | },
79 | },
80 | },
81 | },
82 | };
83 |
84 | const result = await LCPServer.loadDictionaryForLocale({
85 | lcp,
86 | sourceLocale: "en",
87 | targetLocale: "en", // Same locale
88 | sourceRoot: "src",
89 | lingoDir: "lingo",
90 | });
91 |
92 | // Verify the structure
93 | expect(result).toEqual({
94 | version: 0.1,
95 | locale: "en",
96 | files: {
97 | "app/test.tsx": {
98 | entries: {
99 | key1: "Hello World",
100 | key2: "Button Text",
101 | },
102 | },
103 | },
104 | });
105 |
106 | // Ensure LCPAPI.translate() wasn't called since source == target
107 | expect(translateSpy).not.toHaveBeenCalled();
108 | });
109 |
110 | it("should return empty dictionary when source dictionary is empty", async () => {
111 | // Mock LCPAPI.translate() to ensure it's not called
112 | const translateSpy = vi.spyOn(LCPAPI, "translate");
113 |
114 | const lcp: LCPSchema = {
115 | version: 0.1,
116 | files: {},
117 | };
118 |
119 | const result = await LCPServer.loadDictionaryForLocale({
120 | lcp,
121 | sourceLocale: "en",
122 | targetLocale: "es",
123 | sourceRoot: "src",
124 | lingoDir: "lingo",
125 | });
126 |
127 | // Verify the structure
128 | expect(result).toEqual({
129 | version: 0.1,
130 | locale: "es",
131 | files: {},
132 | });
133 |
134 | // Ensure LCPAPI.translate() wasn't called since source == target
135 | expect(translateSpy).not.toHaveBeenCalled();
136 | });
137 |
138 | it("should handle overrides in source content", async () => {
139 | // Mock LCPAPI.translate() to ensure it's not called
140 | vi.spyOn(LCPAPI, "translate").mockImplementation(() =>
141 | Promise.resolve({
142 | version: 0.1,
143 | locale: "fr",
144 | files: {
145 | "app/test.tsx": {
146 | entries: {
147 | key1: "Bonjour le monde",
148 | key2: "Texte du bouton",
149 | },
150 | },
151 | },
152 | }),
153 | );
154 |
155 | const lcp: LCPSchema = {
156 | version: 0.1,
157 | files: {
158 | "app/test.tsx": {
159 | scopes: {
160 | key1: {
161 | content: "Hello World",
162 | hash: "abcd1234",
163 | },
164 | key2: {
165 | content: "Button Text",
166 | hash: "efgh5678",
167 | },
168 | key3: {
169 | content: "Original",
170 | hash: "1234abcd",
171 | overrides: {
172 | fr: "Remplacé", // French override for 'key3'
173 | },
174 | },
175 | },
176 | },
177 | },
178 | };
179 |
180 | const result = await LCPServer.loadDictionaryForLocale({
181 | lcp,
182 | sourceLocale: "en",
183 | targetLocale: "fr",
184 | sourceRoot: "src",
185 | lingoDir: "lingo",
186 | });
187 |
188 | // Check that the overrides were applied
189 | expect(result.files["app/test.tsx"].entries).toEqual({
190 | key1: "Bonjour le monde",
191 | key2: "Texte du bouton",
192 | key3: "Remplacé",
193 | });
194 | expect(result.locale).toBe("fr");
195 | });
196 |
197 | it("should create empty dictionary when no files are provided", async () => {
198 | const lcp: LCPSchema = {
199 | version: 0.1,
200 | };
201 |
202 | const result = await LCPServer.loadDictionaryForLocale({
203 | lcp,
204 | sourceLocale: "en",
205 | targetLocale: "en",
206 | sourceRoot: "src",
207 | lingoDir: "lingo",
208 | });
209 |
210 | expect(result).toEqual({
211 | version: 0.1,
212 | locale: "en",
213 | files: {},
214 | });
215 | });
216 |
217 | it("should read dictionary from cache only, not call LCPAPI.translate()", async () => {
218 | vi.spyOn(LCPCache, "readLocaleDictionary").mockReturnValue({
219 | version: 0.1,
220 | locale: "en",
221 | files: {
222 | "app/test.tsx": {
223 | entries: {
224 | key1: "Hello World",
225 | key2: "Button Text",
226 | key3: "New text",
227 | },
228 | },
229 | },
230 | });
231 | const translateSpy = vi
232 | .spyOn(LCPAPI, "translate")
233 | .mockImplementation(() => {
234 | throw new Error("Should not translate anything");
235 | });
236 |
237 | const lcp: LCPSchema = {
238 | version: 0.1,
239 | files: {
240 | "app/test.tsx": {
241 | scopes: {
242 | key1: {
243 | content: "Hello World",
244 | },
245 | },
246 | },
247 | },
248 | };
249 |
250 | await LCPServer.loadDictionaryForLocale({
251 | lcp,
252 | sourceLocale: "en",
253 | targetLocale: "fr",
254 | sourceRoot: "src",
255 | lingoDir: "lingo",
256 | });
257 |
258 | expect(translateSpy).not.toHaveBeenCalled();
259 | expect(LCPCache.readLocaleDictionary).toHaveBeenCalledWith("fr", {
260 | lcp,
261 | sourceLocale: "en",
262 | lingoDir: "lingo",
263 | sourceRoot: "src",
264 | });
265 | });
266 |
267 | it("should write dictionary to cache", async () => {
268 | vi.spyOn(LCPCache, "writeLocaleDictionary");
269 | vi.spyOn(LCPAPI, "translate").mockReturnValue({
270 | version: 0.1,
271 | locale: "fr",
272 | files: {
273 | "app/test.tsx": {
274 | entries: {
275 | key1: "Bonjour le monde",
276 | key2: "Texte du bouton",
277 | },
278 | },
279 | },
280 | });
281 |
282 | const lcp: LCPSchema = {
283 | version: 0.1,
284 | files: {
285 | "app/test.tsx": {
286 | scopes: {
287 | key1: {
288 | content: "Hello World",
289 | hash: "abcd1234",
290 | },
291 | key2: {
292 | content: "Button Text",
293 | hash: "efgh5678",
294 | },
295 | },
296 | },
297 | },
298 | };
299 |
300 | await LCPServer.loadDictionaryForLocale({
301 | lcp,
302 | sourceLocale: "en",
303 | targetLocale: "fr",
304 | sourceRoot: "src",
305 | lingoDir: "lingo",
306 | });
307 |
308 | expect(LCPCache.writeLocaleDictionary).toHaveBeenCalledWith(
309 | {
310 | files: {
311 | "app/test.tsx": {
312 | entries: {
313 | key1: "Bonjour le monde",
314 | key2: "Texte du bouton",
315 | },
316 | },
317 | },
318 | locale: "fr",
319 | version: 0.1,
320 | },
321 | {
322 | lcp,
323 | sourceLocale: "en",
324 | lingoDir: "lingo",
325 | sourceRoot: "src",
326 | },
327 | );
328 | });
329 |
330 | it("should reuse cached keys with matching hash, call LCPAPI.translate() for keys with different hash, fallback to source locale, cache new translations", async () => {
331 | vi.spyOn(LCPCache, "readLocaleDictionary").mockReturnValue({
332 | version: 0.1,
333 | locale: "fr",
334 | files: {
335 | "app/test.tsx": {
336 | entries: {
337 | key1: "Bonjour le monde",
338 | },
339 | },
340 | },
341 | });
342 | const writeCacheSpy = vi.spyOn(LCPCache, "writeLocaleDictionary");
343 | const translateSpy = vi.spyOn(LCPAPI, "translate").mockResolvedValue({
344 | version: 0.1,
345 | locale: "fr",
346 | files: {
347 | "app/test.tsx": {
348 | entries: {
349 | key2: "Nouveau texte du bouton",
350 | key3: "", // LLM might return empty string
351 | },
352 | },
353 | },
354 | });
355 |
356 | const lcp: LCPSchema = {
357 | version: 0.1,
358 | files: {
359 | "app/test.tsx": {
360 | scopes: {
361 | key1: {
362 | content: "Hello World",
363 | hash: "abcd1234",
364 | },
365 | key2: {
366 | content: "Button Text",
367 | hash: "new_hash",
368 | },
369 | key3: {
370 | content: "New text",
371 | hash: "ijkl4321",
372 | },
373 | },
374 | },
375 | },
376 | };
377 |
378 | const models = {
379 | "*:*": "groq:mistral-saba-24b",
380 | };
381 |
382 | const result = await LCPServer.loadDictionaryForLocale({
383 | models,
384 | lcp,
385 | sourceLocale: "en",
386 | targetLocale: "fr",
387 | sourceRoot: "src",
388 | lingoDir: "lingo",
389 | });
390 |
391 | // Verify that only changed content was sent for translation
392 | expect(translateSpy).toHaveBeenCalledWith(
393 | models,
394 | {
395 | version: 0.1,
396 | locale: "en",
397 | files: {
398 | "app/test.tsx": {
399 | entries: {
400 | key2: "Button Text",
401 | key3: "New text",
402 | },
403 | },
404 | },
405 | },
406 | "en",
407 | "fr",
408 | undefined,
409 | );
410 |
411 | // Verify final result combines cached and newly translated content
412 | expect(result).toEqual({
413 | version: 0.1,
414 | locale: "fr",
415 | files: {
416 | "app/test.tsx": {
417 | entries: {
418 | key1: "Bonjour le monde",
419 | key2: "Nouveau texte du bouton",
420 | key3: "New text", // LLM returned empty string, but result contains fallback to source locale string
421 | },
422 | },
423 | },
424 | });
425 |
426 | // when LLM returns empty string, we cache empty string (the result contains fallback to source locale string)
427 | result.files["app/test.tsx"].entries.key3 = "";
428 |
429 | // Verify cache is updated with new translations
430 | expect(writeCacheSpy).toHaveBeenCalledWith(result, {
431 | lcp,
432 | sourceLocale: "en",
433 | lingoDir: "lingo",
434 | sourceRoot: "src",
435 | });
436 | });
437 | });
438 |
439 | describe("_getDictionaryDiff", () => {
440 | it("should return diff between source and target dictionaries", () => {
441 | const sourceDictionary = {
442 | version: 0.1,
443 | locale: "en",
444 | files: {
445 | "app/test.tsx": {
446 | entries: {
447 | key1: "Hello World",
448 | key2: "Button Text",
449 | key3: "New Text",
450 | key4: "More text",
451 | },
452 | },
453 | },
454 | };
455 |
456 | const targetDictionary = {
457 | version: 0.1,
458 | locale: "es",
459 | files: {
460 | "app/test.tsx": {
461 | entries: {
462 | key1: "Hola mundo",
463 | key2: "El texto del botón",
464 | key3: "", // empty string is valid value
465 | },
466 | },
467 | },
468 | };
469 |
470 | const diff = (LCPServer as any)._getDictionaryDiff(
471 | sourceDictionary,
472 | targetDictionary,
473 | );
474 |
475 | expect(diff).toEqual({
476 | version: 0.1,
477 | locale: "en",
478 | files: {
479 | "app/test.tsx": {
480 | entries: {
481 | key4: "More text",
482 | },
483 | },
484 | },
485 | });
486 | });
487 | });
488 |
489 | describe("_mergeDictionaries", () => {
490 | it("should merge dictionaries", () => {
491 | const sourceDictionary = {
492 | version: 0.1,
493 | locale: "es",
494 | files: {
495 | "app/test.tsx": {
496 | entries: {
497 | key2: "",
498 | key3: "Nuevo texto",
499 | },
500 | },
501 | "app/test3.tsx": {
502 | entries: {
503 | key1: "Como estas?",
504 | key2: "Yo soy bien",
505 | },
506 | },
507 | },
508 | };
509 |
510 | const targetDictionary = {
511 | version: 0.1,
512 | locale: "es",
513 | files: {
514 | "app/test.tsx": {
515 | entries: {
516 | key1: "Hola mundo",
517 | key2: "Hola",
518 | },
519 | },
520 | "app/test2.tsx": {
521 | entries: {
522 | key1: "Yo soy un programador",
523 | key2: "",
524 | },
525 | },
526 | },
527 | };
528 |
529 | const merge = (LCPServer as any)._mergeDictionaries(
530 | sourceDictionary,
531 | targetDictionary,
532 | );
533 |
534 | expect(merge).toEqual({
535 | version: 0.1,
536 | locale: "es",
537 | files: {
538 | "app/test.tsx": {
539 | entries: {
540 | key1: "Hola mundo",
541 | key2: "",
542 | key3: "Nuevo texto",
543 | },
544 | },
545 | "app/test2.tsx": {
546 | entries: {
547 | key1: "Yo soy un programador",
548 | key2: "",
549 | },
550 | },
551 | "app/test3.tsx": {
552 | entries: {
553 | key1: "Como estas?",
554 | key2: "Yo soy bien",
555 | },
556 | },
557 | },
558 | });
559 | });
560 |
561 | it("should remove empty entries when merging dictionaries", () => {
562 | const sourceDictionary = {
563 | version: 0.1,
564 | locale: "es",
565 | files: {
566 | "app/test.tsx": {
567 | entries: {
568 | key1: "",
569 | key2: "El texto del botón",
570 | },
571 | },
572 | "app/test2.tsx": {
573 | entries: {
574 | key1: "Yo soy un programador",
575 | key2: "",
576 | },
577 | },
578 | },
579 | };
580 |
581 | const targetDictionary = {
582 | version: 0.1,
583 | locale: "es",
584 | files: {
585 | "app/test.tsx": {
586 | entries: {
587 | key1: "Hello world",
588 | key2: "Button Text",
589 | },
590 | },
591 | "app/test2.tsx": {
592 | entries: {
593 | key1: "I am a programmer",
594 | key2: "You are a gardener",
595 | },
596 | },
597 | },
598 | };
599 |
600 | const merge = (LCPServer as any)._mergeDictionaries(
601 | sourceDictionary,
602 | targetDictionary,
603 | true,
604 | );
605 |
606 | expect(merge).toEqual({
607 | version: 0.1,
608 | locale: "es",
609 | files: {
610 | "app/test.tsx": {
611 | entries: {
612 | key1: "Hello world",
613 | key2: "El texto del botón",
614 | },
615 | },
616 | "app/test2.tsx": {
617 | entries: {
618 | key1: "Yo soy un programador",
619 | key2: "You are a gardener",
620 | },
621 | },
622 | },
623 | });
624 | });
625 | });
626 | });
627 |
```