This is page 6 of 20. Use http://codebase.md/lingodotdev/lingo.dev?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .changeset
│ ├── config.json
│ └── README.md
├── .claude
│ ├── agents
│ │ └── code-architect-reviewer.md
│ └── commands
│ ├── analyze-bucket-type.md
│ └── create-bucket-docs.md
├── .editorconfig
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── docker.yml
│ ├── lingodotdev.yml
│ ├── pr-check.yml
│ ├── pr-lint.yml
│ └── release.yml
├── .gitignore
├── .husky
│ └── commit-msg
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .vscode
│ ├── extensions.json
│ ├── launch.json
│ └── settings.json
├── action.yml
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── commitlint.config.js
├── composer.json
├── content
│ ├── banner.compiler.png
│ ├── banner.dark.png
│ └── banner.launch.png
├── CONTRIBUTING.md
├── DEBUGGING.md
├── demo
│ ├── adonisjs
│ │ ├── .editorconfig
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── ace.js
│ │ ├── adonisrc.ts
│ │ ├── app
│ │ │ ├── exceptions
│ │ │ │ └── handler.ts
│ │ │ └── middleware
│ │ │ └── container_bindings_middleware.ts
│ │ ├── bin
│ │ │ ├── console.ts
│ │ │ ├── server.ts
│ │ │ └── test.ts
│ │ ├── CHANGELOG.md
│ │ ├── config
│ │ │ ├── app.ts
│ │ │ ├── bodyparser.ts
│ │ │ ├── cors.ts
│ │ │ ├── hash.ts
│ │ │ ├── inertia.ts
│ │ │ ├── logger.ts
│ │ │ ├── session.ts
│ │ │ ├── shield.ts
│ │ │ ├── static.ts
│ │ │ └── vite.ts
│ │ ├── eslint.config.js
│ │ ├── inertia
│ │ │ ├── app
│ │ │ │ ├── app.tsx
│ │ │ │ └── ssr.tsx
│ │ │ ├── css
│ │ │ │ └── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── pages
│ │ │ │ ├── errors
│ │ │ │ │ ├── not_found.tsx
│ │ │ │ │ └── server_error.tsx
│ │ │ │ └── home.tsx
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── resources
│ │ │ └── views
│ │ │ └── inertia_layout.edge
│ │ ├── start
│ │ │ ├── env.ts
│ │ │ ├── kernel.ts
│ │ │ └── routes.ts
│ │ ├── tests
│ │ │ └── bootstrap.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── next-app
│ │ ├── .gitignore
│ │ ├── CHANGELOG.md
│ │ ├── eslint.config.mjs
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── public
│ │ │ ├── file.svg
│ │ │ ├── globe.svg
│ │ │ ├── next.svg
│ │ │ ├── vercel.svg
│ │ │ └── window.svg
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── client-component.tsx
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── lingo-dot-dev.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── test
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── hero-actions.tsx
│ │ │ │ ├── hero-subtitle.tsx
│ │ │ │ ├── hero-title.tsx
│ │ │ │ └── index.ts
│ │ │ └── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ └── tsconfig.json
│ ├── react-router-app
│ │ ├── .dockerignore
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── root.tsx
│ │ │ ├── routes
│ │ │ │ ├── home.tsx
│ │ │ │ └── test.tsx
│ │ │ ├── routes.ts
│ │ │ └── welcome
│ │ │ ├── lingo-dot-dev.tsx
│ │ │ ├── logo-dark.svg
│ │ │ ├── logo-light.svg
│ │ │ └── welcome.tsx
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ ├── public
│ │ │ └── favicon.ico
│ │ ├── react-router.config.ts
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── vite-project
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── README.md
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ └── test.tsx
│ │ ├── index.css
│ │ ├── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ ├── lingo-dot-dev.tsx
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── Dockerfile
├── i18n.json
├── i18n.lock
├── integrations
│ └── directus
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── docker-compose.yml
│ ├── Dockerfile
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── api.ts
│ │ ├── app.ts
│ │ └── index.spec.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── ISSUE_TEMPLATE.md
├── legacy
│ ├── cli
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ └── readme.md
│ └── sdk
│ ├── CHANGELOG.md
│ ├── index.d.ts
│ ├── index.js
│ ├── package.json
│ └── README.md
├── LICENSE.md
├── mcp.md
├── package.json
├── packages
│ ├── cli
│ │ ├── assets
│ │ │ ├── failure.mp3
│ │ │ └── success.mp3
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── demo
│ │ │ ├── android
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── csv
│ │ │ │ ├── example.csv
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── demo.spec.ts
│ │ │ ├── ejs
│ │ │ │ ├── en
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── es
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── flutter
│ │ │ │ ├── en
│ │ │ │ │ └── example.arb
│ │ │ │ ├── es
│ │ │ │ │ └── example.arb
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── html
│ │ │ │ ├── en
│ │ │ │ │ └── example.html
│ │ │ │ ├── es
│ │ │ │ │ └── example.html
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json
│ │ │ │ ├── en
│ │ │ │ │ └── example.json
│ │ │ │ ├── es
│ │ │ │ │ └── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json-dictionary
│ │ │ │ ├── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json5
│ │ │ │ ├── en
│ │ │ │ │ └── example.json5
│ │ │ │ ├── es
│ │ │ │ │ └── example.json5
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── jsonc
│ │ │ │ ├── en
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── es
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── i18n.json
│ │ │ │ ├── i18n.lock
│ │ │ │ └── ru
│ │ │ │ └── example.jsonc
│ │ │ ├── markdoc
│ │ │ │ ├── en
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── es
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── markdown
│ │ │ │ ├── en
│ │ │ │ │ └── example.md
│ │ │ │ ├── es
│ │ │ │ │ └── example.md
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── mdx
│ │ │ │ ├── en
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── es
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── php
│ │ │ │ ├── en
│ │ │ │ │ └── example.php
│ │ │ │ ├── es
│ │ │ │ │ └── example.php
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── po
│ │ │ │ ├── en
│ │ │ │ │ └── example.po
│ │ │ │ ├── es
│ │ │ │ │ └── example.po
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── properties
│ │ │ │ ├── en
│ │ │ │ │ └── example.properties
│ │ │ │ ├── es
│ │ │ │ │ └── example.properties
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── run_i18n.sh
│ │ │ ├── srt
│ │ │ │ ├── en
│ │ │ │ │ └── example.srt
│ │ │ │ ├── es
│ │ │ │ │ └── example.srt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── txt
│ │ │ │ ├── en
│ │ │ │ │ └── example.txt
│ │ │ │ ├── es
│ │ │ │ │ └── example.txt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── typescript
│ │ │ │ ├── en
│ │ │ │ │ └── example.ts
│ │ │ │ ├── es
│ │ │ │ │ └── example.ts
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vtt
│ │ │ │ ├── en
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── es
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vue-json
│ │ │ │ ├── example.vue
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-strings
│ │ │ │ ├── en
│ │ │ │ │ └── example.strings
│ │ │ │ ├── es
│ │ │ │ │ └── example.strings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-stringsdict
│ │ │ │ ├── en
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── es
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings-v2
│ │ │ │ ├── complex-example.xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xliff
│ │ │ │ ├── en
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ └── example-v2.xliff
│ │ │ │ ├── es
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ ├── example-v2.xliff
│ │ │ │ │ └── example.xliff
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xml
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── yaml
│ │ │ │ ├── en
│ │ │ │ │ └── example.yml
│ │ │ │ ├── es
│ │ │ │ │ └── example.yml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ └── yaml-root-key
│ │ │ ├── en
│ │ │ │ └── example.yml
│ │ │ ├── es
│ │ │ │ └── example.yml
│ │ │ ├── i18n.json
│ │ │ └── i18n.lock
│ │ ├── i18n.json
│ │ ├── i18n.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── cli
│ │ │ │ ├── cmd
│ │ │ │ │ ├── auth.ts
│ │ │ │ │ ├── ci
│ │ │ │ │ │ ├── flows
│ │ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ │ ├── in-branch.ts
│ │ │ │ │ │ │ └── pull-request.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── platforms
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── bitbucket.ts
│ │ │ │ │ │ ├── github.ts
│ │ │ │ │ │ ├── gitlab.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── cleanup.ts
│ │ │ │ │ ├── config
│ │ │ │ │ │ ├── get.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── set.ts
│ │ │ │ │ │ └── unset.ts
│ │ │ │ │ ├── i18n.ts
│ │ │ │ │ ├── init.ts
│ │ │ │ │ ├── lockfile.ts
│ │ │ │ │ ├── login.ts
│ │ │ │ │ ├── logout.ts
│ │ │ │ │ ├── may-the-fourth.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── purge.ts
│ │ │ │ │ ├── run
│ │ │ │ │ │ ├── _const.ts
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── execute.spec.ts
│ │ │ │ │ │ ├── execute.ts
│ │ │ │ │ │ ├── frozen.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── plan.ts
│ │ │ │ │ │ ├── setup.ts
│ │ │ │ │ │ └── watch.ts
│ │ │ │ │ ├── show
│ │ │ │ │ │ ├── _shared-key-command.ts
│ │ │ │ │ │ ├── config.ts
│ │ │ │ │ │ ├── files.ts
│ │ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── locale.ts
│ │ │ │ │ │ └── locked-keys.ts
│ │ │ │ │ └── status.ts
│ │ │ │ ├── constants.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loaders
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── _utils.ts
│ │ │ │ │ ├── android.spec.ts
│ │ │ │ │ ├── android.ts
│ │ │ │ │ ├── csv.spec.ts
│ │ │ │ │ ├── csv.ts
│ │ │ │ │ ├── dato
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── api.ts
│ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ ├── filter.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── ejs.spec.ts
│ │ │ │ │ ├── ejs.ts
│ │ │ │ │ ├── ensure-key-order.spec.ts
│ │ │ │ │ ├── ensure-key-order.ts
│ │ │ │ │ ├── flat.spec.ts
│ │ │ │ │ ├── flat.ts
│ │ │ │ │ ├── flutter.spec.ts
│ │ │ │ │ ├── flutter.ts
│ │ │ │ │ ├── formatters
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── biome.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── prettier.ts
│ │ │ │ │ ├── html.ts
│ │ │ │ │ ├── icu-safety.spec.ts
│ │ │ │ │ ├── ignored-keys-buckets.spec.ts
│ │ │ │ │ ├── ignored-keys.spec.ts
│ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── inject-locale.spec.ts
│ │ │ │ │ ├── inject-locale.ts
│ │ │ │ │ ├── json-dictionary.spec.ts
│ │ │ │ │ ├── json-dictionary.ts
│ │ │ │ │ ├── json-sorting.test.ts
│ │ │ │ │ ├── json-sorting.ts
│ │ │ │ │ ├── json.ts
│ │ │ │ │ ├── json5.spec.ts
│ │ │ │ │ ├── json5.ts
│ │ │ │ │ ├── jsonc.spec.ts
│ │ │ │ │ ├── jsonc.ts
│ │ │ │ │ ├── locked-keys.spec.ts
│ │ │ │ │ ├── locked-keys.ts
│ │ │ │ │ ├── locked-patterns.spec.ts
│ │ │ │ │ ├── locked-patterns.ts
│ │ │ │ │ ├── markdoc.spec.ts
│ │ │ │ │ ├── markdoc.ts
│ │ │ │ │ ├── markdown.ts
│ │ │ │ │ ├── mdx.spec.ts
│ │ │ │ │ ├── mdx.ts
│ │ │ │ │ ├── mdx2
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── code-placeholder.spec.ts
│ │ │ │ │ │ ├── code-placeholder.ts
│ │ │ │ │ │ ├── frontmatter-split.spec.ts
│ │ │ │ │ │ ├── frontmatter-split.ts
│ │ │ │ │ │ ├── localizable-document.spec.ts
│ │ │ │ │ │ ├── localizable-document.ts
│ │ │ │ │ │ ├── section-split.spec.ts
│ │ │ │ │ │ ├── section-split.ts
│ │ │ │ │ │ └── sections-split-2.ts
│ │ │ │ │ ├── passthrough.ts
│ │ │ │ │ ├── php.ts
│ │ │ │ │ ├── plutil-json-loader.ts
│ │ │ │ │ ├── po
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── properties.ts
│ │ │ │ │ ├── root-key.ts
│ │ │ │ │ ├── srt.ts
│ │ │ │ │ ├── sync.ts
│ │ │ │ │ ├── text-file.ts
│ │ │ │ │ ├── txt.ts
│ │ │ │ │ ├── typescript
│ │ │ │ │ │ ├── cjs-interop.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── unlocalizable.spec.ts
│ │ │ │ │ ├── unlocalizable.ts
│ │ │ │ │ ├── variable
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── vtt.ts
│ │ │ │ │ ├── vue-json.ts
│ │ │ │ │ ├── xcode-strings
│ │ │ │ │ │ ├── escape.ts
│ │ │ │ │ │ ├── parser.ts
│ │ │ │ │ │ ├── tokenizer.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── xcode-strings.spec.ts
│ │ │ │ │ ├── xcode-strings.ts
│ │ │ │ │ ├── xcode-stringsdict.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.ts
│ │ │ │ │ ├── xcode-xcstrings-lock-compatibility.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-v2-loader.ts
│ │ │ │ │ ├── xcode-xcstrings.spec.ts
│ │ │ │ │ ├── xcode-xcstrings.ts
│ │ │ │ │ ├── xliff.spec.ts
│ │ │ │ │ ├── xliff.ts
│ │ │ │ │ ├── xml.ts
│ │ │ │ │ └── yaml.ts
│ │ │ │ ├── localizer
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── explicit.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingodotdev.ts
│ │ │ │ ├── processor
│ │ │ │ │ ├── _base.ts
│ │ │ │ │ ├── basic.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingo.ts
│ │ │ │ └── utils
│ │ │ │ ├── auth.ts
│ │ │ │ ├── buckets.spec.ts
│ │ │ │ ├── buckets.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── cloudflare-status.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── delta.spec.ts
│ │ │ │ ├── delta.ts
│ │ │ │ ├── ensure-patterns.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── exec.spec.ts
│ │ │ │ ├── exec.ts
│ │ │ │ ├── exit-gracefully.spec.ts
│ │ │ │ ├── exit-gracefully.ts
│ │ │ │ ├── exp-backoff.ts
│ │ │ │ ├── find-locale-paths.spec.ts
│ │ │ │ ├── find-locale-paths.ts
│ │ │ │ ├── fs.ts
│ │ │ │ ├── init-ci-cd.ts
│ │ │ │ ├── key-matching.spec.ts
│ │ │ │ ├── key-matching.ts
│ │ │ │ ├── lockfile.ts
│ │ │ │ ├── md5.ts
│ │ │ │ ├── observability.ts
│ │ │ │ ├── plutil-formatter.spec.ts
│ │ │ │ ├── plutil-formatter.ts
│ │ │ │ ├── settings.ts
│ │ │ │ ├── ui.ts
│ │ │ │ └── update-gitignore.ts
│ │ │ ├── compiler
│ │ │ │ └── index.ts
│ │ │ ├── locale-codes
│ │ │ │ └── index.ts
│ │ │ ├── react
│ │ │ │ ├── client.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── react-router.ts
│ │ │ │ └── rsc.ts
│ │ │ ├── sdk
│ │ │ │ └── index.ts
│ │ │ └── spec
│ │ │ └── index.ts
│ │ ├── tests
│ │ │ └── mock-storage.ts
│ │ ├── troubleshooting.md
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ ├── tsup.config.ts
│ │ ├── types
│ │ │ ├── vtt.d.ts
│ │ │ └── xliff.d.ts
│ │ ├── vitest.config.ts
│ │ └── WATCH_MODE.md
│ ├── compiler
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── _base.ts
│ │ │ ├── _const.ts
│ │ │ ├── _loader-utils.spec.ts
│ │ │ ├── _loader-utils.ts
│ │ │ ├── _utils.spec.ts
│ │ │ ├── _utils.ts
│ │ │ ├── client-dictionary-loader.ts
│ │ │ ├── i18n-directive.spec.ts
│ │ │ ├── i18n-directive.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── jsx-attribute-flag.spec.ts
│ │ │ ├── jsx-attribute-flag.ts
│ │ │ ├── jsx-attribute-scope-inject.spec.ts
│ │ │ ├── jsx-attribute-scope-inject.ts
│ │ │ ├── jsx-attribute-scopes-export.spec.ts
│ │ │ ├── jsx-attribute-scopes-export.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-fragment.spec.ts
│ │ │ ├── jsx-fragment.ts
│ │ │ ├── jsx-html-lang.spec.ts
│ │ │ ├── jsx-html-lang.ts
│ │ │ ├── jsx-provider.spec.ts
│ │ │ ├── jsx-provider.ts
│ │ │ ├── jsx-remove-attributes.spec.ts
│ │ │ ├── jsx-remove-attributes.ts
│ │ │ ├── jsx-root-flag.spec.ts
│ │ │ ├── jsx-root-flag.ts
│ │ │ ├── jsx-scope-flag.spec.ts
│ │ │ ├── jsx-scope-flag.ts
│ │ │ ├── jsx-scope-inject.spec.ts
│ │ │ ├── jsx-scope-inject.ts
│ │ │ ├── jsx-scopes-export.spec.ts
│ │ │ ├── jsx-scopes-export.ts
│ │ │ ├── lib
│ │ │ │ └── lcp
│ │ │ │ ├── api
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prompt.spec.ts
│ │ │ │ │ ├── prompt.ts
│ │ │ │ │ ├── provider-details.spec.ts
│ │ │ │ │ ├── provider-details.ts
│ │ │ │ │ ├── shots.ts
│ │ │ │ │ ├── xml2obj.spec.ts
│ │ │ │ │ └── xml2obj.ts
│ │ │ │ ├── api.spec.ts
│ │ │ │ ├── cache.spec.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── server.spec.ts
│ │ │ │ └── server.ts
│ │ │ ├── lingo-turbopack-loader.ts
│ │ │ ├── react-router-dictionary-loader.ts
│ │ │ ├── rsc-dictionary-loader.ts
│ │ │ └── utils
│ │ │ ├── ast-key.spec.ts
│ │ │ ├── ast-key.ts
│ │ │ ├── create-locale-import-map.spec.ts
│ │ │ ├── create-locale-import-map.ts
│ │ │ ├── env.spec.ts
│ │ │ ├── env.ts
│ │ │ ├── hash.spec.ts
│ │ │ ├── hash.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── invokations.spec.ts
│ │ │ ├── invokations.ts
│ │ │ ├── jsx-attribute-scope.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-content-whitespace.spec.ts
│ │ │ ├── jsx-content.spec.ts
│ │ │ ├── jsx-content.ts
│ │ │ ├── jsx-element.spec.ts
│ │ │ ├── jsx-element.ts
│ │ │ ├── jsx-expressions.test.ts
│ │ │ ├── jsx-expressions.ts
│ │ │ ├── jsx-functions.spec.ts
│ │ │ ├── jsx-functions.ts
│ │ │ ├── jsx-scope.spec.ts
│ │ │ ├── jsx-scope.ts
│ │ │ ├── jsx-variables.spec.ts
│ │ │ ├── jsx-variables.ts
│ │ │ ├── llm-api-key.ts
│ │ │ ├── llm-api-keys.spec.ts
│ │ │ ├── locales.spec.ts
│ │ │ ├── locales.ts
│ │ │ ├── module-params.spec.ts
│ │ │ ├── module-params.ts
│ │ │ ├── observability.spec.ts
│ │ │ ├── observability.ts
│ │ │ ├── rc.spec.ts
│ │ │ └── rc.ts
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ └── vitest.config.ts
│ ├── locales
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── names
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── integration.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── parser.spec.ts
│ │ │ ├── parser.ts
│ │ │ ├── types.ts
│ │ │ ├── validation.spec.ts
│ │ │ └── validation.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── react
│ │ ├── build.config.ts
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── context.spec.tsx
│ │ │ │ ├── context.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── locale-switcher.spec.tsx
│ │ │ │ ├── locale-switcher.tsx
│ │ │ │ ├── locale.spec.ts
│ │ │ │ ├── locale.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── core
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── const.ts
│ │ │ │ ├── get-dictionary.spec.ts
│ │ │ │ ├── get-dictionary.ts
│ │ │ │ └── index.ts
│ │ │ ├── react-router
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── rsc
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ └── test
│ │ │ └── setup.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── sdk
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── abort-controller.specs.ts
│ │ │ ├── index.spec.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ └── tsup.config.ts
│ └── spec
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── config.spec.ts
│ │ ├── config.ts
│ │ ├── formats.ts
│ │ ├── index.spec.ts
│ │ ├── index.ts
│ │ ├── json-schema.ts
│ │ ├── locales.spec.ts
│ │ └── locales.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── readme
│ ├── ar.md
│ ├── bn.md
│ ├── de.md
│ ├── en.md
│ ├── es.md
│ ├── fa.md
│ ├── fr.md
│ ├── he.md
│ ├── hi.md
│ ├── it.md
│ ├── ja.md
│ ├── ko.md
│ ├── pl.md
│ ├── pt-BR.md
│ ├── ru.md
│ ├── tr.md
│ ├── uk-UA.md
│ └── zh-Hans.md
├── readme.md
├── scripts
│ ├── docs
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── generate-cli-docs.ts
│ │ │ ├── generate-config-docs.ts
│ │ │ ├── json-schema
│ │ │ │ ├── markdown-renderer.test.ts
│ │ │ │ ├── markdown-renderer.ts
│ │ │ │ ├── parser.test.ts
│ │ │ │ ├── parser.ts
│ │ │ │ └── types.ts
│ │ │ ├── utils.test.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ └── packagist-publish.php
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/packages/compiler/src/utils/jsx-scope.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import { parse } from "@babel/parser";
3 | import traverse, { NodePath } from "@babel/traverse";
4 | import * as t from "@babel/types";
5 | import {
6 | collectJsxScopes,
7 | getJsxScopes,
8 | hasJsxScopeAttribute,
9 | getJsxScopeAttribute,
10 | } from "./jsx-scope";
11 |
12 | function parseJSX(code: string): t.File {
13 | return parse(code, {
14 | sourceType: "module",
15 | plugins: ["jsx", "typescript"],
16 | });
17 | }
18 |
19 | function getJSXElementPaths(ast: t.File): NodePath<t.JSXElement>[] {
20 | const paths: NodePath<t.JSXElement>[] = [];
21 | traverse(ast, {
22 | JSXElement(path) {
23 | paths.push(path);
24 | },
25 | });
26 | return paths;
27 | }
28 |
29 | describe("jsx-scope utils", () => {
30 | describe("collectJsxScopes", () => {
31 | it("collects elements with data-jsx-scope attribute", () => {
32 | const ast = parseJSX(`
33 | <div>
34 | <span data-jsx-scope="foo">A</span>
35 | <b>B</b>
36 | <section data-jsx-scope="bar">C</section>
37 | </div>
38 | `);
39 | const scopes = collectJsxScopes(ast);
40 | expect(scopes).toHaveLength(2);
41 | expect(getJsxScopeAttribute(scopes[0])).toBe("foo");
42 | expect(getJsxScopeAttribute(scopes[1])).toBe("bar");
43 | });
44 | it("returns empty if no elements have data-jsx-scope", () => {
45 | const ast = parseJSX(`<div><span>A</span></div>`);
46 | const scopes = collectJsxScopes(ast);
47 | expect(scopes).toHaveLength(0);
48 | });
49 | });
50 |
51 | describe("getJsxScopes", () => {
52 | it("finds elements with non-empty JSXText children and no non-empty siblings", () => {
53 | const ast = parseJSX(`
54 | <div>
55 | <span>Text</span>
56 | <b></b>
57 | <section> </section>
58 | <div>
59 | <span>Text</span> and <b>Bold</b>
60 | </div>
61 | <p>
62 | <span>Text</span> here
63 | </p>
64 | </div>
65 | `);
66 | const scopes = getJsxScopes(ast);
67 | const scopeNames = scopes.map(
68 | (scope) => (scope.node.openingElement.name as t.JSXIdentifier).name,
69 | );
70 | expect(scopes).toHaveLength(3);
71 | expect(scopeNames).toEqual(["span", "div", "p"]);
72 | });
73 | it("skips LingoProvider component", () => {
74 | const ast = parseJSX(`
75 | <div>
76 | <LingoProvider>ShouldSkip</LingoProvider>
77 | <span>Text</span>
78 | </div>
79 | `);
80 | const scopes = getJsxScopes(ast);
81 | expect(scopes).toHaveLength(1);
82 | expect((scopes[0].node.openingElement.name as t.JSXIdentifier).name).toBe(
83 | "span",
84 | );
85 | });
86 | });
87 |
88 | describe("hasJsxScopeAttribute", () => {
89 | it("returns true if data-jsx-scope attribute exists", () => {
90 | const ast = parseJSX(`<div data-jsx-scope="foo">A</div>`);
91 | const [path] = getJSXElementPaths(ast);
92 | expect(hasJsxScopeAttribute(path)).toBe(true);
93 | });
94 | it("returns false if data-jsx-scope attribute does not exist", () => {
95 | const ast = parseJSX(`<div>A</div>`);
96 | const [path] = getJSXElementPaths(ast);
97 | expect(hasJsxScopeAttribute(path)).toBe(false);
98 | });
99 | });
100 |
101 | describe("getJsxScopeAttribute", () => {
102 | it("returns the value of data-jsx-scope attribute", () => {
103 | const ast = parseJSX(`<div data-jsx-scope="bar">B</div>`);
104 | const [path] = getJSXElementPaths(ast);
105 | expect(getJsxScopeAttribute(path)).toBe("bar");
106 | });
107 | it("returns undefined if data-jsx-scope attribute does not exist", () => {
108 | const ast = parseJSX(`<div>B</div>`);
109 | const [path] = getJSXElementPaths(ast);
110 | expect(getJsxScopeAttribute(path)).toBeUndefined();
111 | });
112 | });
113 | });
114 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/jsx-scope-flag.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import jsxScopeFlagMutation from "./jsx-scope-flag";
3 | import { createPayload, createOutput, defaultParams } from "./_base";
4 |
5 | // Helper function to run mutation and get result
6 | function runMutation(code: string) {
7 | const input = createPayload({ code, params: defaultParams, fileKey: "test" });
8 | const mutated = jsxScopeFlagMutation(input);
9 | if (!mutated) throw new Error("Mutation returned null");
10 | return createOutput(mutated).code;
11 | }
12 |
13 | describe("jsxScopeFlagMutation", () => {
14 | it("should add data-jsx-scope flag to element containing text without text siblings", () => {
15 | const input = `
16 | function Component() {
17 | return <div>
18 | <span>Hello World</span>
19 | </div>;
20 | }
21 | `.trim();
22 |
23 | const expected = `
24 | function Component() {
25 | return <div>
26 | <span data-jsx-scope="0/body/0/argument/1">Hello World</span>
27 | </div>;
28 | }
29 | `.trim();
30 | const result = runMutation(input);
31 | expect(result).toBe(expected);
32 | });
33 |
34 | it("should not add flag when element has text siblings", () => {
35 | const input = `
36 | function Component() {
37 | return <div>
38 | Some text
39 | <span>Hello World</span>
40 | More text
41 | </div>;
42 | }
43 | `.trim();
44 |
45 | const expected = `
46 | function Component() {
47 | return <div data-jsx-scope="0/body/0/argument">
48 | Some text
49 | <span>Hello World</span>
50 | More text
51 | </div>;
52 | }
53 | `.trim();
54 | const result = runMutation(input);
55 | expect(result).toBe(expected);
56 | });
57 |
58 | it("should handle multiple nested scopes correctly", () => {
59 | const input = `
60 | function Component() {
61 | return <div>
62 | <section>
63 | <p>First text</p>
64 | </section>
65 | <section>
66 | Text here
67 | <div>More text</div>
68 | </section>
69 | </div>;
70 | }
71 | `.trim();
72 |
73 | const expected = `
74 | function Component() {
75 | return <div>
76 | <section>
77 | <p data-jsx-scope="0/body/0/argument/1/1">First text</p>
78 | </section>
79 | <section data-jsx-scope="0/body/0/argument/3">
80 | Text here
81 | <div>More text</div>
82 | </section>
83 | </div>;
84 | }
85 | `.trim();
86 | const result = runMutation(input);
87 | expect(result).toBe(expected);
88 | });
89 |
90 | it("should not add flag to elements without text content", () => {
91 | const input = `
92 | function Component() {
93 | return <div>
94 | <span></span>
95 | <p>{variable}</p>
96 | </div>;
97 | }
98 | `.trim();
99 |
100 | const expected = `
101 | function Component() {
102 | return <div>
103 | <span></span>
104 | <p>{variable}</p>
105 | </div>;
106 | }
107 | `.trim();
108 | const result = runMutation(input);
109 | expect(result).toBe(expected);
110 | });
111 |
112 | it("should handle whitespace-only text nodes correctly", () => {
113 | const input = `
114 | function Component() {
115 | return <div>
116 | <span>
117 |
118 | Hello
119 |
120 | </span>
121 | </div>;
122 | }
123 | `.trim();
124 |
125 | const expected = `
126 | function Component() {
127 | return <div>
128 | <span data-jsx-scope="0/body/0/argument/1">
129 |
130 | Hello
131 |
132 | </span>
133 | </div>;
134 | }
135 | `.trim();
136 | const result = runMutation(input);
137 | expect(result).toBe(expected);
138 | });
139 |
140 | it("should handle JSX in props", () => {
141 | const input = `
142 | function Component() {
143 | return <MyComponent label={<label>Hello</label>}>
144 | <p>Foobar</p>
145 | </MyComponent>;
146 | }
147 | `.trim();
148 |
149 | const expected = `
150 | function Component() {
151 | return <MyComponent label={<label data-jsx-scope="0/body/0/argument/openingElement/0/value/expression">Hello</label>}>
152 | <p data-jsx-scope="0/body/0/argument/1">Foobar</p>
153 | </MyComponent>;
154 | }
155 | `.trim();
156 | const result = runMutation(input);
157 | expect(result).toBe(expected);
158 | });
159 | });
160 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/_loader-utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | import _ from "lodash";
2 | import path from "path";
3 | import { composeMutations, createOutput, createPayload } from "./_base";
4 | import { LCP_DICTIONARY_FILE_NAME } from "./_const";
5 | import { clientDictionaryLoaderMutation } from "./client-dictionary-loader";
6 | import i18nDirectiveMutation from "./i18n-directive";
7 | import jsxAttributeFlagMutation from "./jsx-attribute-flag";
8 | import { lingoJsxAttributeScopeInjectMutation } from "./jsx-attribute-scope-inject";
9 | import { jsxAttributeScopesExportMutation } from "./jsx-attribute-scopes-export";
10 | import { jsxFragmentMutation } from "./jsx-fragment";
11 | import { jsxHtmlLangMutation } from "./jsx-html-lang";
12 | import jsxProviderMutation from "./jsx-provider";
13 | import { jsxRemoveAttributesMutation } from "./jsx-remove-attributes";
14 | import jsxRootFlagMutation from "./jsx-root-flag";
15 | import jsxScopeFlagMutation from "./jsx-scope-flag";
16 | import { lingoJsxScopeInjectMutation } from "./jsx-scope-inject";
17 | import { jsxScopesExportMutation } from "./jsx-scopes-export";
18 | import { LCP } from "./lib/lcp";
19 | import { LCPServer } from "./lib/lcp/server";
20 | import { reactRouterDictionaryLoaderMutation } from "./react-router-dictionary-loader";
21 | import { rscDictionaryLoaderMutation } from "./rsc-dictionary-loader";
22 | import { parseParametrizedModuleId } from "./utils/module-params";
23 |
24 | /**
25 | * Loads a dictionary for a specific locale
26 | */
27 | export async function loadDictionary(options: {
28 | resourcePath: string;
29 | resourceQuery?: string;
30 | params: any;
31 | sourceRoot: string;
32 | lingoDir: string;
33 | isDev: boolean;
34 | }) {
35 | const {
36 | resourcePath,
37 | resourceQuery = "",
38 | params,
39 | sourceRoot,
40 | lingoDir,
41 | isDev,
42 | } = options;
43 | const fullResourcePath = `${resourcePath}${resourceQuery}`;
44 |
45 | if (!resourcePath.match(LCP_DICTIONARY_FILE_NAME)) {
46 | return null; // Not a dictionary file
47 | }
48 |
49 | const moduleInfo = parseParametrizedModuleId(fullResourcePath);
50 | const locale = moduleInfo.params.locale;
51 |
52 | if (!locale) {
53 | return null; // No locale specified
54 | }
55 |
56 | const lcpParams = {
57 | sourceRoot,
58 | lingoDir,
59 | isDev,
60 | };
61 |
62 | await LCP.ready(lcpParams);
63 | const lcp = LCP.getInstance(lcpParams);
64 |
65 | const dictionaries = await LCPServer.loadDictionaries({
66 | ...params,
67 | lcp: lcp.data,
68 | });
69 |
70 | const dictionary = dictionaries[locale];
71 | if (!dictionary) {
72 | throw new Error(
73 | `Lingo.dev: Dictionary for locale "${locale}" could not be generated.`,
74 | );
75 | }
76 |
77 | return dictionary;
78 | }
79 |
80 | /**
81 | * Transforms component code
82 | */
83 | export function transformComponent(options: {
84 | code: string;
85 | params: any;
86 | resourcePath: string;
87 | sourceRoot: string;
88 | }) {
89 | const { code, params, resourcePath, sourceRoot } = options;
90 |
91 | return _.chain({
92 | code,
93 | params,
94 | relativeFilePath: path
95 | .relative(path.resolve(process.cwd(), sourceRoot), resourcePath)
96 | .split(path.sep)
97 | .join("/"), // Always normalize for consistent dictionaries
98 | })
99 | .thru(createPayload)
100 | .thru(
101 | composeMutations(
102 | i18nDirectiveMutation,
103 | jsxFragmentMutation,
104 | jsxAttributeFlagMutation,
105 | jsxProviderMutation,
106 | jsxHtmlLangMutation,
107 | jsxRootFlagMutation,
108 | jsxScopeFlagMutation,
109 | jsxAttributeScopesExportMutation,
110 | jsxScopesExportMutation,
111 | lingoJsxAttributeScopeInjectMutation,
112 | lingoJsxScopeInjectMutation,
113 | rscDictionaryLoaderMutation,
114 | reactRouterDictionaryLoaderMutation,
115 | jsxRemoveAttributesMutation,
116 | clientDictionaryLoaderMutation,
117 | ),
118 | )
119 | .thru(createOutput)
120 | .value();
121 | }
122 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/utils/delta.ts:
--------------------------------------------------------------------------------
```typescript
1 | import _ from "lodash";
2 | import z from "zod";
3 | import { md5 } from "./md5";
4 | import { tryReadFile, writeFile, checkIfFileExists } from "../utils/fs";
5 | import * as path from "path";
6 | import YAML from "yaml";
7 |
8 | const LockSchema = z.object({
9 | version: z.literal(1).default(1),
10 | checksums: z
11 | .record(
12 | z.string(), // localizable files' keys
13 | // checksums hashmap
14 | z
15 | .record(
16 | // key
17 | z.string(),
18 | // checksum of the key's value in the source locale
19 | z.string(),
20 | )
21 | .default({}),
22 | )
23 | .default({}),
24 | });
25 | export type LockData = z.infer<typeof LockSchema>;
26 |
27 | export type Delta = {
28 | added: string[];
29 | removed: string[];
30 | updated: string[];
31 | renamed: [string, string][];
32 | hasChanges: boolean;
33 | };
34 |
35 | export function createDeltaProcessor(fileKey: string) {
36 | const lockfilePath = path.join(process.cwd(), "i18n.lock");
37 | return {
38 | async checkIfLockExists() {
39 | return checkIfFileExists(lockfilePath);
40 | },
41 | async calculateDelta(params: {
42 | sourceData: Record<string, any>;
43 | targetData: Record<string, any>;
44 | checksums: Record<string, string>;
45 | }): Promise<Delta> {
46 | let added = _.difference(
47 | Object.keys(params.sourceData),
48 | Object.keys(params.targetData),
49 | );
50 | let removed = _.difference(
51 | Object.keys(params.targetData),
52 | Object.keys(params.sourceData),
53 | );
54 | const updated = Object.keys(params.sourceData).filter(
55 | (key) =>
56 | md5(params.sourceData[key]) !== params.checksums[key] &&
57 | params.checksums[key],
58 | );
59 |
60 | const renamed: [string, string][] = [];
61 | for (const addedKey of added) {
62 | const addedHash = md5(params.sourceData[addedKey]);
63 | for (const removedKey of removed) {
64 | if (params.checksums[removedKey] === addedHash) {
65 | renamed.push([removedKey, addedKey]);
66 | break;
67 | }
68 | }
69 | }
70 | added = added.filter(
71 | (key) => !renamed.some(([oldKey, newKey]) => newKey === key),
72 | );
73 | removed = removed.filter(
74 | (key) => !renamed.some(([oldKey, newKey]) => oldKey === key),
75 | );
76 |
77 | const hasChanges = [
78 | added.length > 0,
79 | removed.length > 0,
80 | updated.length > 0,
81 | renamed.length > 0,
82 | ].some((v) => v);
83 |
84 | return {
85 | added,
86 | removed,
87 | updated,
88 | renamed,
89 | hasChanges,
90 | };
91 | },
92 | async loadLock() {
93 | const lockfileContent = tryReadFile(lockfilePath, null);
94 | const lockfileYaml = lockfileContent ? YAML.parse(lockfileContent) : null;
95 | const lockfileData: z.infer<typeof LockSchema> = lockfileYaml
96 | ? LockSchema.parse(lockfileYaml)
97 | : {
98 | version: 1,
99 | checksums: {},
100 | };
101 | return lockfileData;
102 | },
103 | async saveLock(lockData: LockData) {
104 | const lockfileYaml = YAML.stringify(lockData);
105 | writeFile(lockfilePath, lockfileYaml);
106 | },
107 | async loadChecksums() {
108 | const id = md5(fileKey);
109 | const lockfileData = await this.loadLock();
110 | return lockfileData.checksums[id] || {};
111 | },
112 | async saveChecksums(checksums: Record<string, string>) {
113 | const id = md5(fileKey);
114 | const lockfileData = await this.loadLock();
115 | lockfileData.checksums[id] = checksums;
116 | await this.saveLock(lockfileData);
117 | },
118 | async createChecksums(sourceData: Record<string, any>) {
119 | const checksums = _.mapValues(sourceData, (value) => md5(value));
120 | return checksums;
121 | },
122 | };
123 | }
124 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/utils/init-ci-cd.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { checkbox, confirm } from "@inquirer/prompts";
2 | import fs from "fs";
3 | import { Ora } from "ora";
4 | import path from "path";
5 |
6 | type Platform = "github" | "bitbucket" | "gitlab";
7 |
8 | const platforms: Platform[] = ["github", "bitbucket", "gitlab"];
9 |
10 | export default async function initCICD(spinner: Ora) {
11 | const initializers = getPlatformInitializers(spinner);
12 |
13 | const init = await confirm({
14 | message: "Would you like to use Lingo.dev in your CI/CD?",
15 | });
16 |
17 | if (!init) {
18 | spinner.warn(
19 | "CI/CD not initialized. To set it up later, see docs: https://lingo.dev/ci",
20 | );
21 | return;
22 | }
23 |
24 | const selectedPlatforms: Platform[] = await checkbox({
25 | message: "Please select CI/CD platform(s) you want to use:",
26 | choices: platforms.map((platform) => ({
27 | name: initializers[platform].name,
28 | value: platform,
29 | checked: initializers[platform].isEnabled(),
30 | })),
31 | });
32 |
33 | for (const platform of selectedPlatforms) {
34 | await initializers[platform].init();
35 | }
36 | }
37 |
38 | function getPlatformInitializers(spinner: Ora) {
39 | return {
40 | github: makeGithubInitializer(spinner),
41 | bitbucket: makeBitbucketInitializer(spinner),
42 | gitlab: makeGitlabInitializer(spinner),
43 | };
44 | }
45 |
46 | type PlatformConfig = {
47 | name: string;
48 | checkPath: string;
49 | ciConfigPath: string;
50 | ciConfigContent: string;
51 | };
52 |
53 | function makePlatformInitializer(config: PlatformConfig, spinner: Ora) {
54 | return {
55 | name: config.name,
56 | isEnabled: () => {
57 | const filePath = path.join(process.cwd(), config.checkPath);
58 | return fs.existsSync(filePath);
59 | },
60 | init: async () => {
61 | const filePath = path.join(process.cwd(), config.ciConfigPath);
62 | const dirPath = path.dirname(filePath);
63 | if (!fs.existsSync(dirPath)) {
64 | fs.mkdirSync(dirPath, { recursive: true });
65 | }
66 | let canWrite = true;
67 | if (fs.existsSync(filePath)) {
68 | canWrite = await confirm({
69 | message: `File ${filePath} already exists. Do you want to overwrite it?`,
70 | default: false,
71 | });
72 | }
73 | if (canWrite) {
74 | fs.writeFileSync(filePath, config.ciConfigContent);
75 | spinner.succeed(`CI/CD initialized for ${config.name}`);
76 | } else {
77 | spinner.warn(`CI/CD not initialized for ${config.name}`);
78 | }
79 | },
80 | };
81 | }
82 |
83 | function makeGithubInitializer(spinner: Ora) {
84 | return makePlatformInitializer(
85 | {
86 | name: "GitHub Action",
87 | checkPath: ".github",
88 | ciConfigPath: ".github/workflows/i18n.yml",
89 | ciConfigContent: `name: Lingo.dev i18n
90 |
91 | on:
92 | push:
93 | branches:
94 | - main
95 |
96 | permissions:
97 | contents: write
98 | pull-requests: write
99 |
100 | jobs:
101 | i18n:
102 | name: Run i18n
103 | runs-on: ubuntu-latest
104 | steps:
105 | - uses: actions/checkout@v4
106 | - uses: lingodotdev/lingo.dev@main
107 | with:
108 | api-key: \${{ secrets.LINGODOTDEV_API_KEY }}
109 | `,
110 | },
111 | spinner,
112 | );
113 | }
114 |
115 | function makeBitbucketInitializer(spinner: Ora) {
116 | return makePlatformInitializer(
117 | {
118 | name: "Bitbucket Pipeline",
119 | checkPath: "bitbucket-pipelines.yml",
120 | ciConfigPath: "bitbucket-pipelines.yml",
121 | ciConfigContent: `pipelines:
122 | branches:
123 | main:
124 | - step:
125 | name: Run i18n
126 | script:
127 | - pipe: lingodotdev/lingo.dev:main`,
128 | },
129 | spinner,
130 | );
131 | }
132 |
133 | function makeGitlabInitializer(spinner: Ora) {
134 | return makePlatformInitializer(
135 | {
136 | name: "Gitlab CI",
137 | checkPath: ".gitlab-ci.yml",
138 | ciConfigPath: ".gitlab-ci.yml",
139 | ciConfigContent: `lingodotdev:
140 | image: lingodotdev/ci-action:latest
141 | script:
142 | - echo "Done"
143 | `,
144 | },
145 | spinner,
146 | );
147 | }
148 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/xliff.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import dedent from "dedent";
3 | import createXliffLoader from "./xliff";
4 |
5 | function normalize(xml: string) {
6 | return xml.trim().replace(/\r?\n/g, "\n");
7 | }
8 |
9 | describe("XLIFF loader", () => {
10 | it("round-trips a simple file without changes", async () => {
11 | const input = dedent`<?xml version="1.0" encoding="utf-8"?>
12 | <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
13 | <file original="demo" source-language="en" target-language="en" datatype="plaintext">
14 | <body>
15 | <trans-unit id="hello" resname="hello">
16 | <source>Hello</source>
17 | <target state="translated">Hello</target>
18 | </trans-unit>
19 | </body>
20 | </file>
21 | </xliff>`;
22 |
23 | const loader = createXliffLoader();
24 | loader.setDefaultLocale("en");
25 |
26 | const data = await loader.pull("en", input);
27 | expect(data).toEqual({ hello: "Hello" });
28 |
29 | // push back identical payload
30 | const output = await loader.push("en", data);
31 | expect(normalize(output)).toBe(normalize(input));
32 | });
33 |
34 | it("handles duplicate resnames deterministically", async () => {
35 | const input = dedent`<?xml version="1.0" encoding="utf-8"?>
36 | <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
37 | <file original="dup" source-language="en" target-language="en" datatype="plaintext">
38 | <body>
39 | <trans-unit id="a" resname="dup_key"><source>A</source><target>A</target></trans-unit>
40 | <trans-unit id="b" resname="dup_key"><source>B</source><target>B</target></trans-unit>
41 | </body>
42 | </file>
43 | </xliff>`;
44 |
45 | const loader = createXliffLoader();
46 | loader.setDefaultLocale("en");
47 | const pulled = await loader.pull("en", input);
48 | expect(pulled).toEqual({
49 | dup_key: "A",
50 | "dup_key#b": "B",
51 | });
52 |
53 | // translate and push
54 | const esPayload = {
55 | dup_key: "AA",
56 | "dup_key#b": "BB",
57 | } as const;
58 |
59 | const esXml = await loader.push("es", esPayload);
60 |
61 | // Pull from Spanish to verify the values were set correctly
62 | const loaderEs = createXliffLoader();
63 | loaderEs.setDefaultLocale("en");
64 | await loaderEs.pull("en", input); // pull original first
65 | const pullEs = await loaderEs.pull("es", esXml);
66 |
67 | // Should get the translated values, not the original
68 | expect(pullEs).toEqual({
69 | dup_key: "AA",
70 | "dup_key#b": "BB",
71 | });
72 | });
73 |
74 | it("wraps XML-sensitive target in CDATA", async () => {
75 | const input = dedent`<?xml version="1.0" encoding="utf-8"?>
76 | <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
77 | <file original="cdata" source-language="en" target-language="en" datatype="plaintext">
78 | <body>
79 | <trans-unit id="expr" resname="expr"><source>5 < 7</source><target>5 < 7</target></trans-unit>
80 | </body>
81 | </file>
82 | </xliff>`;
83 |
84 | const loader = createXliffLoader();
85 | loader.setDefaultLocale("en");
86 | await loader.pull("en", input);
87 |
88 | const out = await loader.push("es", { expr: "5 < 7 & 8 > 3" });
89 |
90 | expect(out.includes("<![CDATA[5 < 7 & 8 > 3]]>")).toBe(true);
91 | });
92 |
93 | it("creates skeleton for missing locale", async () => {
94 | const loader = createXliffLoader();
95 | loader.setDefaultLocale("en");
96 |
97 | // pulling default locale from scratch (empty)
98 | await loader.pull("en", "");
99 |
100 | const payload = { key1: "Valor" };
101 | const esXml = await loader.push("es", payload);
102 |
103 | // Ensure skeleton contains our translated value
104 | expect(esXml.includes("Valor")).toBe(true);
105 | expect(esXml.includes('target-language="es"'));
106 | });
107 | });
108 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/json-dictionary.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { forEach } from "lodash";
2 | import createJsonKeysLoader from "./json-dictionary";
3 | import { describe, it, expect } from "vitest";
4 |
5 | describe("json-dictionary loader", () => {
6 | const input = {
7 | title: {
8 | en: "I am a title",
9 | },
10 | logoPosition: "right",
11 | pages: [
12 | {
13 | name: "Welcome to my world",
14 | elements: [
15 | {
16 | title: {
17 | en: "I am an element title",
18 | },
19 | description: {
20 | en: "I am an element description",
21 | },
22 | },
23 | ],
24 | },
25 | ],
26 | };
27 |
28 | it("should return nested object of only translatable keys on pull", async () => {
29 | const loader = createJsonKeysLoader();
30 | loader.setDefaultLocale("en");
31 | const pulled = await loader.pull("en", input);
32 | expect(pulled).toEqual({
33 | title: "I am a title",
34 | pages: [
35 | {
36 | elements: [
37 | {
38 | title: "I am an element title",
39 | description: "I am an element description",
40 | },
41 | ],
42 | },
43 | ],
44 | });
45 | });
46 |
47 | it("should add target locale keys only where source locale keys exist on push", async () => {
48 | const loader = createJsonKeysLoader();
49 | loader.setDefaultLocale("en");
50 | const pulled = await loader.pull("en", input);
51 | const output = await loader.push("es", {
52 | title: "Yo soy un titulo",
53 | logoPosition: "right",
54 | pages: [
55 | {
56 | name: "Welcome to my world",
57 | elements: [
58 | {
59 | title: "Yo soy un elemento de titulo",
60 | description: "Yo soy una descripcion de elemento",
61 | },
62 | ],
63 | },
64 | ],
65 | });
66 | expect(output).toEqual({
67 | title: { en: "I am a title", es: "Yo soy un titulo" },
68 | logoPosition: "right",
69 | pages: [
70 | {
71 | name: "Welcome to my world",
72 | elements: [
73 | {
74 | title: {
75 | en: "I am an element title",
76 | es: "Yo soy un elemento de titulo",
77 | },
78 | description: {
79 | en: "I am an element description",
80 | es: "Yo soy una descripcion de elemento",
81 | },
82 | },
83 | ],
84 | },
85 | ],
86 | });
87 | });
88 |
89 | it("should correctly order locale keys on push", async () => {
90 | const loader = createJsonKeysLoader();
91 | loader.setDefaultLocale("en");
92 | const pulled = await loader.pull("en", {
93 | data: {
94 | en: "foo1",
95 | es: "foo2",
96 | de: "foo3",
97 | },
98 | });
99 | const output = await loader.push("fr", { data: "foo4" });
100 | expect(Object.keys(output.data)).toEqual(["en", "de", "es", "fr"]);
101 | });
102 |
103 | it("should not add target locale keys to non-object values", async () => {
104 | const loader = createJsonKeysLoader();
105 | loader.setDefaultLocale("en");
106 | const data = { foo: 123, bar: true, baz: null };
107 | const pulled = await loader.pull("en", data);
108 | expect(pulled).toEqual({});
109 | const output = await loader.push("es", pulled);
110 | expect(output).toEqual({
111 | foo: 123,
112 | bar: true,
113 | baz: null,
114 | });
115 | });
116 |
117 | it("should handle locale keys on top level", async () => {
118 | const loader = createJsonKeysLoader();
119 | loader.setDefaultLocale("en");
120 | const pulled = await loader.pull("en", {
121 | en: "foo1",
122 | es: "foo2",
123 | other: "bar",
124 | });
125 | expect(pulled).toEqual({ "--content--": "foo1" });
126 | const output = await loader.push("fr", { "--content--": "foo3" });
127 | expect(output).toEqual({
128 | en: "foo1",
129 | es: "foo2",
130 | fr: "foo3",
131 | other: "bar",
132 | });
133 | });
134 | });
135 |
```
--------------------------------------------------------------------------------
/.claude/agents/code-architect-reviewer.md:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | name: code-architect-reviewer
3 | description: Use this agent when you need expert code review focusing on architectural quality, clean code principles, and best practices. Examples: <example>Context: User has just written a new service class and wants architectural feedback. user: 'I just implemented a user authentication service. Can you review it?' assistant: 'I'll use the code-architect-reviewer agent to provide comprehensive architectural review of your authentication service.' <commentary>Since the user is requesting code review with architectural focus, use the code-architect-reviewer agent to analyze the code structure, design patterns, and clean code adherence.</commentary></example> <example>Context: User has refactored a complex module and wants validation. user: 'I refactored the payment processing module to improve maintainability' assistant: 'Let me use the code-architect-reviewer agent to evaluate your refactoring and ensure it follows clean architecture principles.' <commentary>The user has made architectural changes and needs expert validation, so use the code-architect-reviewer agent to assess the improvements.</commentary></example>
4 | tools: Task, Bash, Glob, Grep, LS, ExitPlanMode, Read, Edit, MultiEdit, Write, NotebookRead, NotebookEdit, WebFetch, TodoWrite, WebSearch
5 | ---
6 |
7 | You are an Expert Software Architect and Code Reviewer with deep expertise in clean code principles, software design patterns, and architectural best practices. Your mission is to provide thorough, actionable code reviews that elevate code quality and maintainability.
8 |
9 | When reviewing code, you will:
10 |
11 | **Architectural Analysis:**
12 |
13 | - Evaluate overall code structure and organization
14 | - Assess adherence to SOLID principles and design patterns
15 | - Identify architectural smells and suggest improvements
16 | - Review separation of concerns and modularity
17 | - Analyze dependency management and coupling
18 |
19 | **Clean Code Assessment:**
20 |
21 | - Review naming conventions for clarity and expressiveness
22 | - Evaluate function and class sizes (single responsibility)
23 | - Check for code duplication and suggest DRY improvements
24 | - Assess readability and self-documenting code practices
25 | - Review error handling and edge case coverage
26 |
27 | **Best Practices Validation:**
28 |
29 | - Verify adherence to language-specific conventions
30 | - Check for proper use of abstractions and interfaces
31 | - Evaluate testing strategy and testability
32 | - Review performance considerations and potential bottlenecks
33 | - Assess security implications and vulnerabilities
34 |
35 | **Review Process:**
36 |
37 | 1. First, understand the code's purpose and context
38 | 2. Analyze the overall architecture and design decisions
39 | 3. Examine implementation details for clean code violations
40 | 4. Identify specific improvement opportunities
41 | 5. Prioritize feedback by impact (critical, important, nice-to-have)
42 | 6. Provide concrete, actionable recommendations with examples
43 |
44 | **Feedback Format:**
45 |
46 | - Start with positive observations about good practices
47 | - Organize feedback by category (Architecture, Clean Code, Performance, etc.)
48 | - For each issue, explain the problem, why it matters, and how to fix it
49 | - Provide code examples for suggested improvements when helpful
50 | - End with a summary of key action items
51 |
52 | **Quality Standards:**
53 |
54 | - Be thorough but focus on the most impactful improvements
55 | - Explain the reasoning behind each recommendation
56 | - Consider maintainability, scalability, and team collaboration
57 | - Balance perfectionism with pragmatism
58 | - Encourage best practices while respecting project constraints
59 |
60 | You are not just identifying problems—you are mentoring developers toward architectural excellence and clean code mastery.
61 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/utils/find-locale-paths.ts:
--------------------------------------------------------------------------------
```typescript
1 | import fs from "fs";
2 | import path from "path";
3 | import { glob } from "glob";
4 | import _ from "lodash";
5 | import { LocaleCode, resolveLocaleCode } from "@lingo.dev/_spec";
6 |
7 | export default function findLocaleFiles(bucket: string) {
8 | switch (bucket) {
9 | case "json":
10 | return findLocaleFilesWithExtension(".json");
11 | case "yaml":
12 | return findLocaleFilesWithExtension(".yml");
13 | case "flutter":
14 | return findLocaleFilesWithExtension(".arb");
15 | case "android":
16 | return findLocaleFilesWithExtension(".xml");
17 | case "markdown":
18 | return findLocaleFilesWithExtension(".md");
19 | case "php":
20 | return findLocaleFilesWithExtension(".php");
21 | case "po":
22 | return findLocaleFilesWithExtension(".po");
23 | case "xcode-xcstrings":
24 | return findLocaleFilesForFilename("Localizable.xcstrings");
25 | case "xcode-strings":
26 | return findLocaleFilesForFilename("Localizable.strings");
27 | case "xcode-stringsdict":
28 | return findLocaleFilesForFilename("Localizable.stringsdict");
29 | default:
30 | return null;
31 | }
32 | }
33 |
34 | function findLocaleFilesWithExtension(ext: string) {
35 | const files = glob.sync(`**/*${ext}`, {
36 | ignore: ["node_modules/**", "package*.json", "i18n.json", "lingo.json"],
37 | });
38 |
39 | const localeFilePattern = new RegExp(`\/([a-z]{2}(-[A-Z]{2})?)${ext}$`);
40 | const localeDirectoryPattern = new RegExp(
41 | `\/([a-z]{2}(-[A-Z]{2})?)\/[^\/]+${ext}$`,
42 | );
43 | const potentialLocaleFiles = files.filter(
44 | (file: string) =>
45 | localeFilePattern.test(file) || localeDirectoryPattern.test(file),
46 | );
47 |
48 | const potantialLocaleFilesAndPatterns = potentialLocaleFiles
49 | .map((file: string) => {
50 | const matchPotentialLocales = Array.from(
51 | file.matchAll(
52 | new RegExp(`\/([a-z]{2}(-[A-Z]{2})?|[^\/]+)(?=\/|${ext})`, "g"),
53 | ),
54 | );
55 | const potantialLocales = matchPotentialLocales.map((match) => match[1]);
56 | return { file, potantialLocales };
57 | })
58 | .map(({ file, potantialLocales }) => {
59 | for (const locale of potantialLocales) {
60 | try {
61 | resolveLocaleCode(locale as LocaleCode);
62 | return { locale, file };
63 | } catch (e) {}
64 | }
65 | return { file, locale: null };
66 | })
67 | .filter(({ locale }) => locale !== null);
68 |
69 | const localeFilesAndPatterns = potantialLocaleFilesAndPatterns.map(
70 | ({ file, locale }) => {
71 | const pattern = file
72 | .replaceAll(new RegExp(`/${locale}${ext}`, "g"), `/[locale]${ext}`)
73 | .replaceAll(new RegExp(`/${locale}/`, "g"), `/[locale]/`)
74 | .replaceAll(new RegExp(`/${locale}/`, "g"), `/[locale]/`); // for when there are 2 locales one after another
75 | return { pattern, file };
76 | },
77 | );
78 |
79 | const grouppedFilesAndPatterns = _.groupBy(localeFilesAndPatterns, "pattern");
80 | const patterns = Object.keys(grouppedFilesAndPatterns);
81 | const defaultPatterns = [`i18n/[locale]${ext}`];
82 |
83 | if (patterns.length > 0) {
84 | return { patterns, defaultPatterns };
85 | }
86 |
87 | return { patterns: [], defaultPatterns };
88 | }
89 |
90 | function findLocaleFilesForFilename(fileName: string) {
91 | const pattern = fileName;
92 | const localeFiles = glob.sync(`**/${fileName}`, {
93 | ignore: ["node_modules/**", "package*.json", "i18n.json", "lingo.json"],
94 | });
95 |
96 | const localeFilesAndPatterns = localeFiles.map((file: string) => ({
97 | file,
98 | pattern: path.join(path.dirname(file), pattern),
99 | }));
100 | const grouppedFilesAndPatterns = _.groupBy(localeFilesAndPatterns, "pattern");
101 | const patterns = Object.keys(grouppedFilesAndPatterns);
102 | const defaultPatterns = [fileName];
103 |
104 | if (patterns.length > 0) {
105 | return { patterns, defaultPatterns };
106 | }
107 |
108 | return { patterns: [], defaultPatterns };
109 | }
110 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/mdx.ts:
--------------------------------------------------------------------------------
```typescript
1 | import _ from "lodash";
2 | import { unified } from "unified";
3 | import remarkParse from "remark-parse";
4 | import remarkFrontmatter from "remark-frontmatter";
5 | import remarkGfm from "remark-gfm";
6 | import remarkStringify from "remark-stringify";
7 | import remarkMdxFrontmatter from "remark-mdx-frontmatter";
8 | import { VFile } from "vfile";
9 | import { Root, RootContent, RootContentMap } from "mdast";
10 | import { ILoader } from "./_types";
11 | import { createLoader } from "./_utils";
12 |
13 | const parser = unified()
14 | .use(remarkParse)
15 | .use(remarkFrontmatter, ["yaml"])
16 | .use(remarkGfm);
17 | const serializer = unified()
18 | .use(remarkStringify)
19 | .use(remarkFrontmatter, ["yaml"])
20 | .use(remarkGfm);
21 |
22 | export function createMdxFormatLoader(): ILoader<string, Record<string, any>> {
23 | const skippedTypes: (keyof RootContentMap | "root")[] = [
24 | "code",
25 | "inlineCode",
26 | ];
27 | return createLoader({
28 | async pull(locale, input) {
29 | const file = new VFile(input);
30 | const ast = parser.parse(file);
31 |
32 | const result = _.cloneDeep(ast);
33 |
34 | traverseMdast(result, (node) => {
35 | if (skippedTypes.includes(node.type)) {
36 | if ("value" in node) {
37 | node.value = "";
38 | }
39 | }
40 | });
41 |
42 | return result;
43 | },
44 |
45 | async push(
46 | locale,
47 | data,
48 | originalInput,
49 | originalLocale,
50 | pullInput,
51 | pullOutput,
52 | ) {
53 | const file = new VFile(originalInput);
54 | const ast = parser.parse(file);
55 |
56 | const result = _.cloneDeep(ast);
57 |
58 | traverseMdast(result, (node, indexPath) => {
59 | if ("value" in node) {
60 | const incomingValue = findNodeByIndexPath(data, indexPath);
61 | if (
62 | incomingValue &&
63 | "value" in incomingValue &&
64 | !_.isEmpty(incomingValue.value)
65 | ) {
66 | node.value = incomingValue.value;
67 | }
68 | }
69 | });
70 |
71 | return String(serializer.stringify(result));
72 | },
73 | });
74 | }
75 |
76 | export function createDoubleSerializationLoader(): ILoader<string, string> {
77 | return createLoader({
78 | async pull(locale, input) {
79 | return input;
80 | },
81 |
82 | async push(locale, data) {
83 | const file = new VFile(data);
84 | const ast = parser.parse(file);
85 |
86 | const finalContent = String(serializer.stringify(ast));
87 | return finalContent;
88 | },
89 | });
90 | }
91 |
92 | export function createMdxStructureLoader(): ILoader<
93 | Record<string, any>,
94 | Record<string, string>
95 | > {
96 | return createLoader({
97 | async pull(locale, input) {
98 | const result = _.pickBy(input, (value, key) => _isValueKey(key));
99 | return result;
100 | },
101 | async push(locale, data, originalInput) {
102 | const nonValueKeys = _.pickBy(
103 | originalInput,
104 | (value, key) => !_isValueKey(key),
105 | );
106 | const result = _.merge({}, nonValueKeys, data);
107 |
108 | return result;
109 | },
110 | });
111 | }
112 |
113 | function _isValueKey(key: string) {
114 | return key.endsWith("/value");
115 | }
116 |
117 | function traverseMdast(
118 | ast: Root | RootContent,
119 | visitor: (node: Root | RootContent, path: number[]) => void,
120 | indexPath: number[] = [],
121 | ) {
122 | visitor(ast, indexPath);
123 |
124 | if ("children" in ast && Array.isArray(ast.children)) {
125 | for (let i = 0; i < ast.children.length; i++) {
126 | traverseMdast(ast.children[i], visitor, [...indexPath, i]);
127 | }
128 | }
129 | }
130 |
131 | function findNodeByIndexPath(
132 | ast: Root | RootContent,
133 | indexPath: number[],
134 | ): Root | RootContent | null {
135 | let result: Root | RootContent | null = null;
136 |
137 | const stringifiedIndexPath = indexPath.join(".");
138 | traverseMdast(ast, (node, path) => {
139 | if (result) {
140 | return;
141 | }
142 |
143 | const currentStringifiedPath = path.join(".");
144 | if (currentStringifiedPath === stringifiedIndexPath) {
145 | result = node;
146 | }
147 | });
148 |
149 | return result;
150 | }
151 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/utils/jsx-element.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as t from "@babel/types";
2 | import traverse, { NodePath } from "@babel/traverse";
3 | import { parse } from "@babel/parser";
4 | import { getJsxElementName, getNestedJsxElements } from "./jsx-element";
5 | import { describe, it, expect } from "vitest";
6 | import generate from "@babel/generator";
7 |
8 | function parseJSX(code: string): t.File {
9 | return parse(code, {
10 | sourceType: "module",
11 | plugins: ["jsx", "typescript"],
12 | });
13 | }
14 |
15 | function getJSXElementPath(code: string): NodePath<t.JSXElement> {
16 | const ast = parseJSX(code);
17 | let elementPath: NodePath<t.JSXElement> | null = null;
18 |
19 | traverse(ast, {
20 | JSXElement(path) {
21 | elementPath = path;
22 | path.stop();
23 | },
24 | });
25 |
26 | if (!elementPath) {
27 | throw new Error("No JSX element found in the code");
28 | }
29 |
30 | return elementPath;
31 | }
32 |
33 | describe("JSX Element Utils", () => {
34 | describe("getJsxElementName", () => {
35 | it("should return element name for simple elements", () => {
36 | const path = getJSXElementPath("<div>Hello</div>");
37 | expect(getJsxElementName(path)).toBe("div");
38 | });
39 |
40 | it("should return element name for custom elements", () => {
41 | const path = getJSXElementPath("<MyComponent>Hello</MyComponent>");
42 | expect(getJsxElementName(path)).toBe("MyComponent");
43 | });
44 |
45 | it("should return element name for elements with dots", () => {
46 | const path = getJSXElementPath("<My.Component>Hello</My.Component>");
47 | expect(getJsxElementName(path)).toBe("My.Component");
48 | });
49 |
50 | it("should return element name for elements with multiple dot", () => {
51 | const path = getJSXElementPath(
52 | "<My.Very.Custom.React.Component>Hello</My.Very.Custom.React.Component>",
53 | );
54 | expect(getJsxElementName(path)).toBe("My.Very.Custom.React.Component");
55 | });
56 | });
57 |
58 | describe("getNestedJsxElements", () => {
59 | it("should transform single nested element into a function", () => {
60 | const path = getJSXElementPath("<div>Hello <b>world</b></div>");
61 | const result = getNestedJsxElements(path);
62 |
63 | expect(result.elements).toHaveLength(1);
64 | const generatedCode = generate(result.elements[0]).code;
65 | expect(generatedCode).toBe(`({
66 | children
67 | }) => <b>{children}</b>`);
68 | });
69 |
70 | it("should handle multiple nested elements", () => {
71 | const path = getJSXElementPath(
72 | "<div><strong>Hello</strong> and <em>welcome</em> to <code>my app</code></div>",
73 | );
74 | const result = getNestedJsxElements(path);
75 |
76 | expect(result.elements).toHaveLength(3);
77 | const generatedCodes = result.elements.map((fn) => generate(fn).code);
78 | expect(generatedCodes).toEqual([
79 | `({
80 | children
81 | }) => <strong>{children}</strong>`,
82 | `({
83 | children
84 | }) => <em>{children}</em>`,
85 | `({
86 | children
87 | }) => <code>{children}</code>`,
88 | ]);
89 | });
90 |
91 | it("should handle deeply nested elements", () => {
92 | const path = getJSXElementPath(
93 | "<div><a>Hello <strong>wonderful <i><b>very</b>nested</i></strong> world</a> of the <u>universe</u></div>",
94 | );
95 | const result = getNestedJsxElements(path);
96 |
97 | // expect(result).toHaveLength(4);
98 | const generatedCodes = result.elements.map((fn) => generate(fn).code);
99 | expect(generatedCodes).toEqual([
100 | `({
101 | children
102 | }) => <a>{children}</a>`,
103 | `({
104 | children
105 | }) => <strong>{children}</strong>`,
106 | `({
107 | children
108 | }) => <i>{children}</i>`,
109 | `({
110 | children
111 | }) => <b>{children}</b>`,
112 | `({
113 | children
114 | }) => <u>{children}</u>`,
115 | ]);
116 | });
117 |
118 | it("should return empty array for elements with no nested JSX", () => {
119 | const path = getJSXElementPath("<div>Hello world</div>");
120 | const result = getNestedJsxElements(path);
121 | expect(result.elements).toHaveLength(0);
122 | });
123 | });
124 | });
125 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/mdx.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import {
3 | createMdxFormatLoader,
4 | createDoubleSerializationLoader,
5 | createMdxStructureLoader,
6 | } from "./mdx";
7 |
8 | // Helper to traverse mdast tree
9 | function traverse(node: any, visitor: (n: any) => void) {
10 | visitor(node);
11 | if (node && Array.isArray(node.children)) {
12 | node.children.forEach((child: any) => traverse(child, visitor));
13 | }
14 | }
15 |
16 | describe("mdx loader", () => {
17 | const mdxSample = `\n# Heading\n\nHere is some code:\n\n\u0060\u0060\u0060js\nconsole.log("hello");\n\u0060\u0060\u0060\n\nSome inline \u0060world\u0060 and more text.\n`;
18 |
19 | describe("createMdxFormatLoader", () => {
20 | it("should strip values of code and inlineCode nodes on pull", async () => {
21 | const loader = createMdxFormatLoader();
22 | loader.setDefaultLocale("en");
23 |
24 | const ast = await loader.pull("en", mdxSample);
25 |
26 | // Assert that every code or inlineCode node now has an empty value
27 | traverse(ast, (node) => {
28 | if (node?.type === "code" || node?.type === "inlineCode") {
29 | expect(node.value).toBe("");
30 | }
31 | });
32 | });
33 |
34 | it("should preserve original code & inlineCode content on push when incoming value is empty", async () => {
35 | const loader = createMdxFormatLoader();
36 | loader.setDefaultLocale("en");
37 |
38 | const pulledAst = await loader.pull("en", mdxSample);
39 | const output = await loader.push("es", pulledAst);
40 |
41 | // The serialized output must still contain the original code and inline code content
42 | expect(output).toContain('console.log("hello");');
43 | expect(output).toMatch(/`world`/);
44 | });
45 | });
46 |
47 | describe("createDoubleSerializationLoader", () => {
48 | it("should return the same content on pull", async () => {
49 | const loader = createDoubleSerializationLoader();
50 | loader.setDefaultLocale("en");
51 | const input = "# Hello";
52 | const output = await loader.pull("en", input);
53 | expect(output).toBe(input);
54 | });
55 |
56 | it("should reformat markdown on push", async () => {
57 | const loader = createDoubleSerializationLoader();
58 | loader.setDefaultLocale("en");
59 | const input = "# Hello ";
60 | const expectedOutput = "# Hello\n";
61 | await loader.pull("en", input);
62 | const output = await loader.push("en", input);
63 | expect(output).toBe(expectedOutput);
64 | });
65 | });
66 |
67 | describe("createMdxStructureLoader", () => {
68 | it("should extract values from keys ending with /value on pull", async () => {
69 | const loader = createMdxStructureLoader();
70 | loader.setDefaultLocale("en");
71 | const input = {
72 | "title/value": "Hello",
73 | "title/type": "string",
74 | "content/value": "Some content",
75 | unrelated: "field",
76 | };
77 | const output = await loader.pull("en", input);
78 | expect(output).toEqual({
79 | "title/value": "Hello",
80 | "content/value": "Some content",
81 | });
82 | });
83 |
84 | it("should merge translated data with non-value keys on push, should not include untranslated keys from originalInput", async () => {
85 | const loader = createMdxStructureLoader();
86 | loader.setDefaultLocale("en");
87 | const originalInput = {
88 | "title/value": "Hello",
89 | "title/type": "string",
90 | "content/value": "Some content",
91 | "untranslated/value": "untranslated",
92 | unrelated: "field",
93 | };
94 | await loader.pull("en", originalInput);
95 | const translatedData = {
96 | "title/value": "Hola",
97 | "content/value": "Algun contenido",
98 | };
99 | const output = await loader.push("es", translatedData);
100 | expect(output).toEqual({
101 | "title/value": "Hola",
102 | "title/type": "string",
103 | "content/value": "Algun contenido",
104 | unrelated: "field",
105 | });
106 | });
107 | });
108 | });
109 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/processor/basic.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { generateText, LanguageModelV1 } from "ai";
2 | import { LocalizerInput, LocalizerProgressFn } from "./_base";
3 | import _ from "lodash";
4 |
5 | type ModelSettings = {
6 | temperature?: number;
7 | };
8 |
9 | export function createBasicTranslator(
10 | model: LanguageModelV1,
11 | systemPrompt: string,
12 | settings: ModelSettings = {},
13 | ) {
14 | return async (input: LocalizerInput, onProgress: LocalizerProgressFn) => {
15 | const chunks = extractPayloadChunks(input.processableData);
16 |
17 | const subResults: Record<string, any>[] = [];
18 | for (let i = 0; i < chunks.length; i++) {
19 | const chunk = chunks[i];
20 | const result = await doJob({
21 | ...input,
22 | processableData: chunk,
23 | });
24 | subResults.push(result);
25 | onProgress((i / chunks.length) * 100, chunk, result);
26 | }
27 |
28 | const result = _.merge({}, ...subResults);
29 |
30 | return result;
31 | };
32 |
33 | async function doJob(input: LocalizerInput) {
34 | if (!Object.keys(input.processableData).length) {
35 | return input.processableData;
36 | }
37 |
38 | const response = await generateText({
39 | model,
40 | ...settings,
41 | messages: [
42 | {
43 | role: "system",
44 | content: JSON.stringify({
45 | role: "system",
46 | content: systemPrompt
47 | .replaceAll("{source}", input.sourceLocale)
48 | .replaceAll("{target}", input.targetLocale),
49 | }),
50 | },
51 | {
52 | role: "user",
53 | content: JSON.stringify({
54 | sourceLocale: "en",
55 | targetLocale: "es",
56 | data: {
57 | message: "Hello, world!",
58 | },
59 | }),
60 | },
61 | {
62 | role: "assistant",
63 | content: JSON.stringify({
64 | sourceLocale: "en",
65 | targetLocale: "es",
66 | data: {
67 | message: "Hola, mundo!",
68 | },
69 | }),
70 | },
71 | {
72 | role: "user",
73 | content: JSON.stringify({
74 | sourceLocale: input.sourceLocale,
75 | targetLocale: input.targetLocale,
76 | data: input.processableData,
77 | }),
78 | },
79 | ],
80 | });
81 |
82 | const result = JSON.parse(response.text);
83 |
84 | return result?.data || {};
85 | }
86 | }
87 |
88 | /**
89 | * Extract payload chunks based on the ideal chunk size
90 | * @param payload - The payload to be chunked
91 | * @returns An array of payload chunks
92 | */
93 | function extractPayloadChunks(
94 | payload: Record<string, string>,
95 | ): Record<string, string>[] {
96 | const idealBatchItemSize = 250;
97 | const batchSize = 25;
98 | const result: Record<string, string>[] = [];
99 | let currentChunk: Record<string, string> = {};
100 | let currentChunkItemCount = 0;
101 |
102 | const payloadEntries = Object.entries(payload);
103 | for (let i = 0; i < payloadEntries.length; i++) {
104 | const [key, value] = payloadEntries[i];
105 | currentChunk[key] = value;
106 | currentChunkItemCount++;
107 |
108 | const currentChunkSize = countWordsInRecord(currentChunk);
109 | if (
110 | currentChunkSize > idealBatchItemSize ||
111 | currentChunkItemCount >= batchSize ||
112 | i === payloadEntries.length - 1
113 | ) {
114 | result.push(currentChunk);
115 | currentChunk = {};
116 | currentChunkItemCount = 0;
117 | }
118 | }
119 |
120 | return result;
121 | }
122 |
123 | /**
124 | * Count words in a record or array
125 | * @param payload - The payload to count words in
126 | * @returns The total number of words
127 | */
128 | function countWordsInRecord(
129 | payload: any | Record<string, any> | Array<any>,
130 | ): number {
131 | if (Array.isArray(payload)) {
132 | return payload.reduce((acc, item) => acc + countWordsInRecord(item), 0);
133 | } else if (typeof payload === "object" && payload !== null) {
134 | return Object.values(payload).reduce(
135 | (acc: number, item) => acc + countWordsInRecord(item),
136 | 0,
137 | );
138 | } else if (typeof payload === "string") {
139 | return payload.trim().split(/\s+/).filter(Boolean).length;
140 | } else {
141 | return 0;
142 | }
143 | }
144 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/lib/lcp/index.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2 | import * as fs from "fs";
3 | import * as path from "path";
4 | import dedent from "dedent";
5 |
6 | vi.mock("fs", async (importOriginal) => {
7 | const mod = await importOriginal<typeof import("fs")>();
8 | return {
9 | ...mod,
10 | existsSync: vi.fn(() => false),
11 | mkdirSync: vi.fn(),
12 | writeFileSync: vi.fn(),
13 | readFileSync: vi.fn(() => "{}"),
14 | statSync: vi.fn(() => ({ mtimeMs: Date.now() - 10_000 }) as any),
15 | rmdirSync: vi.fn(),
16 | utimesSync: vi.fn(),
17 | } as any;
18 | });
19 |
20 | // import after mocks
21 | import { LCP } from "./index";
22 |
23 | describe("LCP", () => {
24 | beforeEach(() => {
25 | (fs.existsSync as any).mockReset().mockReturnValue(false);
26 | (fs.mkdirSync as any).mockReset();
27 | (fs.writeFileSync as any).mockReset();
28 | (fs.readFileSync as any).mockReset().mockReturnValue("{}");
29 | (fs.statSync as any)
30 | .mockReset()
31 | .mockReturnValue({ mtimeMs: Date.now() - 10_000 });
32 | (fs.rmdirSync as any).mockReset();
33 | (fs.utimesSync as any).mockReset();
34 | });
35 |
36 | describe("ensureFile", () => {
37 | it("creates meta.json and throws an error", () => {
38 | (fs.existsSync as any).mockReturnValueOnce(false);
39 | expect(() => {
40 | LCP.ensureFile({ sourceRoot: "src", lingoDir: "lingo" });
41 | }).toThrow(/Lingo.dev Compiler detected missing meta.json file/);
42 | expect(fs.mkdirSync).toHaveBeenCalled();
43 | expect(fs.writeFileSync).toHaveBeenCalled();
44 | });
45 |
46 | it("does not create meta.json if it already exists", () => {
47 | (fs.existsSync as any).mockReturnValue(true);
48 | LCP.ensureFile({ sourceRoot: "src", lingoDir: "lingo" });
49 | expect(fs.mkdirSync).not.toHaveBeenCalled();
50 | expect(fs.writeFileSync).not.toHaveBeenCalled();
51 | });
52 | });
53 |
54 | describe("getInstance", () => {
55 | it("returns parsed schema when file exists", () => {
56 | (fs.existsSync as any).mockReturnValue(true);
57 | (fs.readFileSync as any).mockReturnValue('{"version":42, "files": {}}');
58 | const lcp = LCP.getInstance({ sourceRoot: "src", lingoDir: "lingo" });
59 | expect(lcp.data.version).toBe(42);
60 | });
61 |
62 | it("returns new instance when file does not exist", () => {
63 | (fs.existsSync as any).mockReturnValue(false);
64 | const lcp = LCP.getInstance({ sourceRoot: "src", lingoDir: "lingo" });
65 | expect(lcp.data.version).toBe(0.1);
66 | });
67 | });
68 |
69 | describe("ready", () => {
70 | it("resolves immediately when meta.json is older than threshold", async () => {
71 | (fs.existsSync as any).mockReturnValue(true);
72 | (fs.statSync as any).mockReturnValue({ mtimeMs: Date.now() - 10_000 });
73 | await LCP.ready({ sourceRoot: "src", lingoDir: "lingo", isDev: false });
74 | expect(fs.statSync).toHaveBeenCalled();
75 | });
76 | });
77 |
78 | describe("setScope* chain", () => {
79 | it("modifies internal data and save writes only on change", () => {
80 | const lcp = LCP.getInstance({ sourceRoot: "src", lingoDir: "lingo" });
81 | (fs.existsSync as any).mockReturnValue(false);
82 | lcp
83 | .resetScope("file.tsx", "scope-1")
84 | .setScopeType("file.tsx", "scope-1", "element")
85 | .setScopeContext("file.tsx", "scope-1", "ctx")
86 | .setScopeHash("file.tsx", "scope-1", "hash")
87 | .setScopeSkip("file.tsx", "scope-1", false)
88 | .setScopeOverrides("file.tsx", "scope-1", { es: "x" })
89 | .setScopeContent("file.tsx", "scope-1", "Hello");
90 |
91 | // first save writes
92 | lcp.save();
93 | expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
94 |
95 | // mimic that file exists and content matches -> no write
96 | (fs.existsSync as any).mockReturnValue(true);
97 | (fs.readFileSync as any).mockReturnValueOnce(
98 | (fs.writeFileSync as any).mock.calls[0][1],
99 | );
100 | lcp.save();
101 | expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
102 | });
103 | });
104 | });
105 |
```
--------------------------------------------------------------------------------
/packages/cli/demo/android/en/example.xml:
--------------------------------------------------------------------------------
```
1 | <?xml version="1.0" encoding="utf-8"?>
2 | <resources>
3 | <!-- Basic translatable strings -->
4 | <string name="app_name">MyApp</string>
5 | <string name="welcome_message">Hello, world!</string>
6 | <string name="button_text">Get Started</string>
7 |
8 | <!-- Non-translatable configuration strings (will not appear in target locales) -->
9 | <string name="api_endpoint" translatable="false">https://api.example.com</string>
10 | <string name="debug_key" translatable="false">DEBUG_MODE_ENABLED</string>
11 |
12 | <!-- Translatable string arrays -->
13 | <string-array name="color_names">
14 | <item>Red</item>
15 | <item>Green</item>
16 | <item>Blue!</item>
17 | </string-array>
18 |
19 | <!-- Non-translatable server URLs (will not appear in target locales) -->
20 | <string-array name="server_urls" translatable="false">
21 | <item>https://prod.example.com</item>
22 | <item>https://staging.example.com</item>
23 | <item>https://dev.example.com</item>
24 | </string-array>
25 |
26 | <!-- Translatable plurals -->
27 | <plurals name="notification_count">
28 | <item quantity="one">%d new message</item>
29 | <item quantity="other">%d new messages</item>
30 | </plurals>
31 |
32 | <!-- Non-translatable plurals (will not appear in target locales) -->
33 | <plurals name="cache_size" translatable="false">
34 | <item quantity="one">%d byte</item>
35 | <item quantity="other">%d bytes</item>
36 | </plurals>
37 |
38 | <!-- Translatable booleans -->
39 | <bool name="show_tutorial">true</bool>
40 | <bool name="enable_animations">false</bool>
41 |
42 | <!-- Non-translatable debug flags (will not appear in target locales) -->
43 | <bool name="is_debug_build" translatable="false">false</bool>
44 | <bool name="enable_logging" translatable="false">true</bool>
45 |
46 | <!-- Translatable integers -->
47 | <integer name="max_retry_attempts">3</integer>
48 | <integer name="default_timeout">30</integer>
49 |
50 | <!-- Non-translatable version numbers (will not appear in target locales) -->
51 | <integer name="build_version" translatable="false">43</integer>
52 | <integer name="api_version" translatable="false">1</integer>
53 |
54 | <!-- Special character handling -->
55 | <string name="html_snippet"><b>Bold</b></string>
56 |
57 | <string name="apostrophe_example">Don\'t forget!</string>
58 |
59 | <string name="cdata_example"><![CDATA[Users can only see your comment after they’ve signed up. <u>Learn more.</u>]]></string>
60 |
61 | <!-- Array with whitespace preservation -->
62 | <string-array name="mixed_items">
63 | <item> Item with spaces </item>
64 | <item> </item>
65 | </string-array>
66 |
67 | <!-- Non-translatable test resources (will not appear in target locales) -->
68 | <string-array name="non_localised_array" translatable="false">
69 | <item>Ignored</item>
70 | </string-array>
71 |
72 | <plurals name="non_localised_plural" translatable="false">
73 | <item quantity="one">Ignored</item>
74 | <item quantity="other">Ignored</item>
75 | </plurals>
76 |
77 | <!-- Colors - will not be translated (copied as-is to target locales) -->
78 | <color name="primary_color">#FF6200EE</color>
79 | <color name="secondary_color">#FF03DAC5</color>
80 |
81 | <!-- Dimensions - will not be translated (copied as-is to target locales) -->
82 | <dimen name="text_size">16sp</dimen>
83 | <dimen name="margin">8dp</dimen>
84 |
85 | <!-- Integer arrays - will not be translated (copied as-is to target locales) -->
86 | <integer-array name="numbers">
87 | <item>1</item>
88 | <item>2</item>
89 | <item>3</item>
90 | </integer-array>
91 |
92 | <!-- Typed arrays - will not be translated (copied as-is to target locales) -->
93 | <array name="icons">
94 | <item>@drawable/icon1</item>
95 | <item>@drawable/icon2</item>
96 | </array>
97 |
98 | <!-- Resource IDs - will not be translated (copied as-is to target locales) -->
99 | <item type="id" name="button_ok" />
100 |
101 | </resources>
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/cmd/ci/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Command } from "interactive-commander";
2 | import createOra from "ora";
3 | import { getSettings } from "../../utils/settings";
4 | import { createAuthenticator } from "../../utils/auth";
5 | import { IIntegrationFlow } from "./flows/_base";
6 | import { PullRequestFlow } from "./flows/pull-request";
7 | import { InBranchFlow } from "./flows/in-branch";
8 | import { getPlatformKit } from "./platforms";
9 |
10 | interface CIOptions {
11 | parallel?: boolean;
12 | apiKey?: string;
13 | debug?: boolean;
14 | pullRequest?: boolean;
15 | commitMessage?: string;
16 | pullRequestTitle?: string;
17 | workingDirectory?: string;
18 | processOwnCommits?: boolean;
19 | }
20 |
21 | export default new Command()
22 | .command("ci")
23 | .description("Run localization pipeline in CI/CD environment")
24 | .helpOption("-h, --help", "Show help")
25 | .option(
26 | "--parallel [boolean]",
27 | "Process translations concurrently for faster execution. Defaults to false",
28 | parseBooleanArg,
29 | )
30 | .option(
31 | "--api-key <key>",
32 | "Override API key from settings or environment variables",
33 | )
34 | .option(
35 | "--pull-request [boolean]",
36 | "Create or update translations on a dedicated branch and manage pull requests automatically. When false, commits directly to current branch. Defaults to false",
37 | parseBooleanArg,
38 | )
39 | .option(
40 | "--commit-message <message>",
41 | "Commit message for localization changes. Defaults to 'feat: update translations via @lingodotdev'",
42 | )
43 | .option(
44 | "--pull-request-title <title>",
45 | "Title for the pull request when using --pull-request mode. Defaults to 'feat: update translations via @lingodotdev'",
46 | )
47 | .option(
48 | "--working-directory <dir>",
49 | "Directory to run localization from (useful for monorepos where localization files are in a subdirectory)",
50 | )
51 | .option(
52 | "--process-own-commits [boolean]",
53 | "Allow processing commits made by this CI user (bypasses infinite loop prevention)",
54 | parseBooleanArg,
55 | )
56 | .action(async (options: CIOptions) => {
57 | const settings = getSettings(options.apiKey);
58 |
59 | console.log(options);
60 |
61 | if (!settings.auth.apiKey) {
62 | console.error("No API key provided");
63 | return;
64 | }
65 |
66 | const authenticator = createAuthenticator({
67 | apiUrl: settings.auth.apiUrl,
68 | apiKey: settings.auth.apiKey,
69 | });
70 | const auth = await authenticator.whoami();
71 |
72 | if (!auth) {
73 | console.error("Not authenticated");
74 | return;
75 | }
76 |
77 | const env = {
78 | LINGODOTDEV_API_KEY: settings.auth.apiKey,
79 | LINGODOTDEV_PULL_REQUEST: options.pullRequest?.toString() || "false",
80 | ...(options.commitMessage && {
81 | LINGODOTDEV_COMMIT_MESSAGE: options.commitMessage,
82 | }),
83 | ...(options.pullRequestTitle && {
84 | LINGODOTDEV_PULL_REQUEST_TITLE: options.pullRequestTitle,
85 | }),
86 | ...(options.workingDirectory && {
87 | LINGODOTDEV_WORKING_DIRECTORY: options.workingDirectory,
88 | }),
89 | ...(options.processOwnCommits && {
90 | LINGODOTDEV_PROCESS_OWN_COMMITS: options.processOwnCommits.toString(),
91 | }),
92 | };
93 |
94 | process.env = { ...process.env, ...env };
95 |
96 | const ora = createOra();
97 | const platformKit = getPlatformKit();
98 | const { isPullRequestMode } = platformKit.config;
99 |
100 | ora.info(`Pull request mode: ${isPullRequestMode ? "on" : "off"}`);
101 |
102 | const flow: IIntegrationFlow = isPullRequestMode
103 | ? new PullRequestFlow(ora, platformKit)
104 | : new InBranchFlow(ora, platformKit);
105 |
106 | const canRun = await flow.preRun?.();
107 | if (canRun === false) {
108 | return;
109 | }
110 |
111 | const hasChanges = await flow.run({
112 | parallel: options.parallel,
113 | });
114 | if (!hasChanges) {
115 | return;
116 | }
117 |
118 | await flow.postRun?.();
119 | });
120 |
121 | function parseBooleanArg(val: string | boolean | undefined): boolean {
122 | if (val === true) return true;
123 | if (typeof val === "string") {
124 | return val.toLowerCase() === "true";
125 | }
126 | return false;
127 | }
128 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/utils/jsx-attribute.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as t from "@babel/types";
2 | import { NodePath } from "@babel/traverse";
3 | import _ from "lodash";
4 |
5 | /**
6 | * Gets a map of all JSX attributes from a JSX element
7 | *
8 | * @param nodePath The JSX element node path
9 | * @returns A record mapping attribute names to their values
10 | */
11 | export function getJsxAttributesMap(
12 | nodePath: NodePath<t.JSXElement>,
13 | ): Record<string, any> {
14 | const attributes = nodePath.node.openingElement.attributes;
15 |
16 | return _.reduce(
17 | attributes,
18 | (result, attr) => {
19 | if (attr.type !== "JSXAttribute" || attr.name.type !== "JSXIdentifier") {
20 | return result;
21 | }
22 |
23 | const name = attr.name.name;
24 | const value = extractAttributeValue(attr);
25 |
26 | return { ...result, [name]: value };
27 | },
28 | {} as Record<string, any>,
29 | );
30 | }
31 |
32 | /**
33 | * Gets the value of a JSX attribute from a JSX element
34 | *
35 | * @param nodePath The JSX element node path
36 | * @param attributeName The name of the attribute to get
37 | * @returns The attribute value or undefined if not found
38 | */
39 | export function getJsxAttributeValue(
40 | nodePath: NodePath<t.JSXElement>,
41 | attributeName: string,
42 | ) {
43 | const attributes = nodePath.node.openingElement.attributes;
44 | const attribute = _.find(
45 | attributes,
46 | (attr): attr is t.JSXAttribute =>
47 | attr.type === "JSXAttribute" &&
48 | attr.name.type === "JSXIdentifier" &&
49 | attr.name.name === attributeName,
50 | );
51 |
52 | if (!attribute) {
53 | return undefined;
54 | }
55 |
56 | return extractAttributeValue(attribute);
57 | }
58 |
59 | /**
60 | * Sets the value of a JSX attribute on a JSX element
61 | *
62 | * @param nodePath The JSX element node path
63 | * @param attributeName The name of the attribute to set
64 | * @param value The value to set (string, number, boolean, expression, or null for boolean attributes)
65 | */
66 | export function setJsxAttributeValue(
67 | nodePath: NodePath<t.JSXElement>,
68 | attributeName: string,
69 | value: any,
70 | ) {
71 | const attributes = nodePath.node.openingElement.attributes;
72 | const attributeIndex = _.findIndex(
73 | attributes,
74 | (attr) =>
75 | attr.type === "JSXAttribute" &&
76 | attr.name.type === "JSXIdentifier" &&
77 | attr.name.name === attributeName,
78 | );
79 |
80 | const jsxValue = createAttributeValue(value);
81 | const jsxAttribute = t.jsxAttribute(t.jsxIdentifier(attributeName), jsxValue);
82 |
83 | if (attributeIndex >= 0) {
84 | attributes[attributeIndex] = jsxAttribute;
85 | } else {
86 | attributes.push(jsxAttribute);
87 | }
88 | }
89 |
90 | /**
91 | * Extracts the value from a JSX attribute
92 | */
93 | function extractAttributeValue(attribute: t.JSXAttribute) {
94 | if (!attribute.value) {
95 | return true; // Boolean attribute
96 | }
97 |
98 | if (attribute.value.type === "StringLiteral") {
99 | return attribute.value.value;
100 | }
101 |
102 | if (attribute.value.type === "JSXExpressionContainer") {
103 | const expression = attribute.value.expression;
104 |
105 | if (expression.type === "BooleanLiteral") {
106 | return expression.value;
107 | }
108 |
109 | if (expression.type === "NumericLiteral") {
110 | return expression.value;
111 | }
112 |
113 | if (expression.type === "StringLiteral") {
114 | return expression.value;
115 | }
116 | }
117 | // We could return the raw expression for other types
118 | return null;
119 | }
120 |
121 | /**
122 | * Creates an appropriate JSX attribute value based on the input value
123 | */
124 | function createAttributeValue(
125 | value: any,
126 | ): t.StringLiteral | t.JSXExpressionContainer | null {
127 | if (value === null || value === undefined) {
128 | return null;
129 | }
130 |
131 | if (typeof value === "string") {
132 | return t.stringLiteral(value);
133 | }
134 |
135 | if (typeof value === "boolean") {
136 | return t.jsxExpressionContainer(t.booleanLiteral(value));
137 | }
138 |
139 | if (typeof value === "number") {
140 | return t.jsxExpressionContainer(t.numericLiteral(value));
141 | }
142 |
143 | if (t.isExpression(value)) {
144 | return t.jsxExpressionContainer(value);
145 | }
146 |
147 | // For complex objects/arrays, convert to expression
148 | return t.jsxExpressionContainer(t.stringLiteral(JSON.stringify(value)));
149 | }
150 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/jsx-scope-inject.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createCodeMutation } from "./_base";
2 | import {
3 | getJsxAttributeValue,
4 | getModuleExecutionMode,
5 | getOrCreateImport,
6 | } from "./utils";
7 | import * as t from "@babel/types";
8 | import _ from "lodash";
9 | import { ModuleId } from "./_const";
10 | import { getJsxElementName, getNestedJsxElements } from "./utils/jsx-element";
11 | import { getJsxVariables } from "./utils/jsx-variables";
12 | import { getJsxFunctions } from "./utils/jsx-functions";
13 | import { getJsxExpressions } from "./utils/jsx-expressions";
14 | import { collectJsxScopes, getJsxScopeAttribute } from "./utils/jsx-scope";
15 | import { setJsxAttributeValue } from "./utils/jsx-attribute";
16 |
17 | export const lingoJsxScopeInjectMutation = createCodeMutation((payload) => {
18 | const mode = getModuleExecutionMode(payload.ast, payload.params.rsc);
19 | const jsxScopes = collectJsxScopes(payload.ast);
20 |
21 | for (const jsxScope of jsxScopes) {
22 | const skip = getJsxAttributeValue(jsxScope, "data-lingo-skip");
23 | if (skip) {
24 | continue;
25 | }
26 | // Import LingoComponent based on the module execution mode
27 | const packagePath =
28 | mode === "client" ? ModuleId.ReactClient : ModuleId.ReactRSC;
29 | const lingoComponentImport = getOrCreateImport(payload.ast, {
30 | moduleName: packagePath,
31 | exportedName: "LingoComponent",
32 | });
33 |
34 | // Get the original JSX element name
35 | const originalJsxElementName = getJsxElementName(jsxScope);
36 | if (!originalJsxElementName) {
37 | continue;
38 | }
39 |
40 | // Create new JSXElement with original attributes
41 | const newNode = t.jsxElement(
42 | t.jsxOpeningElement(
43 | t.jsxIdentifier(lingoComponentImport.importedName),
44 | jsxScope.node.openingElement.attributes.slice(), // original attributes
45 | true, // selfClosing
46 | ),
47 | null, // no closing element
48 | [], // no children
49 | true, // selfClosing
50 | );
51 |
52 | // Create a NodePath wrapper for the new node to use setJsxAttributeValue
53 | const newNodePath = {
54 | node: newNode,
55 | } as any;
56 |
57 | // Add $as prop
58 | const as = /^[A-Z]/.test(originalJsxElementName)
59 | ? t.identifier(originalJsxElementName)
60 | : originalJsxElementName;
61 | setJsxAttributeValue(newNodePath, "$as", as);
62 |
63 | // Add $fileKey prop
64 | setJsxAttributeValue(newNodePath, "$fileKey", payload.relativeFilePath);
65 |
66 | // Add $entryKey prop
67 | setJsxAttributeValue(
68 | newNodePath,
69 | "$entryKey",
70 | getJsxScopeAttribute(jsxScope)!,
71 | );
72 |
73 | // Extract $variables from original JSX scope before lingo component was inserted
74 | const $variables = getJsxVariables(jsxScope);
75 | if ($variables.properties.length > 0) {
76 | setJsxAttributeValue(newNodePath, "$variables", $variables);
77 | }
78 |
79 | // Extract nested JSX elements
80 | const $elements = getNestedJsxElements(jsxScope);
81 | if ($elements.elements.length > 0) {
82 | setJsxAttributeValue(newNodePath, "$elements", $elements);
83 | }
84 |
85 | // Extract nested functions
86 | const $functions = getJsxFunctions(jsxScope);
87 | if ($functions.properties.length > 0) {
88 | setJsxAttributeValue(newNodePath, "$functions", $functions);
89 | }
90 |
91 | // Extract expressions
92 | const $expressions = getJsxExpressions(jsxScope);
93 | if ($expressions.elements.length > 0) {
94 | setJsxAttributeValue(newNodePath, "$expressions", $expressions);
95 | }
96 |
97 | if (mode === "server") {
98 | // Add $loadDictionary prop
99 | const loadDictionaryImport = getOrCreateImport(payload.ast, {
100 | exportedName: "loadDictionary",
101 | moduleName: ModuleId.ReactRSC,
102 | });
103 | setJsxAttributeValue(
104 | newNodePath,
105 | "$loadDictionary",
106 | t.arrowFunctionExpression(
107 | [t.identifier("locale")],
108 | t.callExpression(t.identifier(loadDictionaryImport.importedName), [
109 | t.identifier("locale"),
110 | ]),
111 | ),
112 | );
113 | }
114 |
115 | jsxScope.replaceWith(newNode);
116 | }
117 |
118 | return payload;
119 | });
120 |
```
--------------------------------------------------------------------------------
/packages/react/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # @lingo.dev/\_react
2 |
3 | ## 0.5.0
4 |
5 | ### Minor Changes
6 |
7 | - [#1134](https://github.com/lingodotdev/lingo.dev/pull/1134) [`3a642f3`](https://github.com/lingodotdev/lingo.dev/commit/3a642f33c04378706a8382aa0fde36e747fd6af5) Thanks [@mathio](https://github.com/mathio)! - useLingoLocale, setLingoLocale
8 |
9 | ## 0.4.3
10 |
11 | ### Patch Changes
12 |
13 | - [#1119](https://github.com/lingodotdev/lingo.dev/pull/1119) [`e898c1e`](https://github.com/lingodotdev/lingo.dev/commit/e898c1eeb34e4dd3e74df26465802b520018acf9) Thanks [@mathio](https://github.com/mathio)! - compiler fallback to source locale
14 |
15 | ## 0.4.2
16 |
17 | ### Patch Changes
18 |
19 | - [#1054](https://github.com/lingodotdev/lingo.dev/pull/1054) [`2d67369`](https://github.com/lingodotdev/lingo.dev/commit/2d673697b9cf4d91de2f48444581f8b3fd894cd6) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Fix loadLocaleFromCookies to return default locale instead of null when no cookie is found
20 |
21 | ## 0.4.1
22 |
23 | ### Patch Changes
24 |
25 | - [#1011](https://github.com/lingodotdev/lingo.dev/pull/1011) [`bfcb424`](https://github.com/lingodotdev/lingo.dev/commit/bfcb424eb4479d0d3b767e062d30f02c5bcaeb14) Thanks [@mathio](https://github.com/mathio)! - replace elements with dot in name
26 |
27 | ## 0.4.0
28 |
29 | ### Minor Changes
30 |
31 | - [`95c23cc`](https://github.com/lingodotdev/lingo.dev/commit/95c23ccbafd335939832dbdd0f995ebcb23082fd) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add className support to language switcher component
32 |
33 | ## 0.3.0
34 |
35 | ### Minor Changes
36 |
37 | - [#897](https://github.com/lingodotdev/lingo.dev/pull/897) [`a5da697`](https://github.com/lingodotdev/lingo.dev/commit/a5da697f7efd46de31d17b202d06eb5f655ed9b9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for other providers in the compiler and implement Google AI as a provider.
38 |
39 | ## 0.2.4
40 |
41 | ### Patch Changes
42 |
43 | - [#887](https://github.com/lingodotdev/lingo.dev/pull/887) [`511a2ec`](https://github.com/lingodotdev/lingo.dev/commit/511a2ecd68a9c5e2800035d5c6a6b5b31b2dc80f) Thanks [@mathio](https://github.com/mathio)! - handle when lingo dir is deleted
44 |
45 | ## 0.2.3
46 |
47 | ### Patch Changes
48 |
49 | - [#883](https://github.com/lingodotdev/lingo.dev/pull/883) [`7191444`](https://github.com/lingodotdev/lingo.dev/commit/7191444f67864ea5b5a91a9be759b2445bf186d3) Thanks [@mathio](https://github.com/mathio)! - client-side loading state
50 |
51 | ## 0.2.2
52 |
53 | ### Patch Changes
54 |
55 | - [#867](https://github.com/lingodotdev/lingo.dev/pull/867) [`a7bf553`](https://github.com/lingodotdev/lingo.dev/commit/a7bf5538b5b72e41f90371f6211378aac7d5f800) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Fix template substitution destructive shift() bug that caused rendering failures when translations have different element counts between locales
56 |
57 | - [#868](https://github.com/lingodotdev/lingo.dev/pull/868) [`562e667`](https://github.com/lingodotdev/lingo.dev/commit/562e667471abb51d7dd193217eefb8e8b3f8a686) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - show dictionary error
58 |
59 | ## 0.2.1
60 |
61 | ### Patch Changes
62 |
63 | - [`1f9db11`](https://github.com/lingodotdev/lingo.dev/commit/1f9db11a53d8c75ce0e83517b73d43544d0f0fd2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add console log to lingoproviderwrapper
64 |
65 | ## 0.2.0
66 |
67 | ### Minor Changes
68 |
69 | - [#838](https://github.com/lingodotdev/lingo.dev/pull/838) [`e75e615`](https://github.com/lingodotdev/lingo.dev/commit/e75e615ab17e279deb5a505dbda682fdfc7ead62) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - switch from tsup to unbuild
70 |
71 | ## 0.1.1
72 |
73 | ### Patch Changes
74 |
75 | - [`caef325`](https://github.com/lingodotdev/lingo.dev/commit/caef3253bc99fa7bf7a0b40e5604c3590dcb4958) Thanks [@mathio](https://github.com/mathio)! - release fix
76 |
77 | ## 0.1.0
78 |
79 | ### Minor Changes
80 |
81 | - [`e980e84`](https://github.com/lingodotdev/lingo.dev/commit/e980e84178439ad70417d38b425acf9148cfc4b6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added the compiler
82 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/lib/lcp/api/xml2obj.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { XMLParser, XMLBuilder } from "fast-xml-parser";
2 | import _ from "lodash";
3 |
4 | // Generic tag names used in XML output
5 | const TAG_OBJECT = "object";
6 | const TAG_ARRAY = "array";
7 | const TAG_VALUE = "value";
8 |
9 | /**
10 | * Converts a JavaScript value to a generic XML node structure understood by fast-xml-parser.
11 | */
12 | function _toGenericNode(value: any, key?: string): Record<string, any> {
13 | if (_.isArray(value)) {
14 | const children = _.map(value, (item) => _toGenericNode(item));
15 | return {
16 | [TAG_ARRAY]: {
17 | ...(key ? { key } : {}),
18 | ..._groupChildren(children),
19 | },
20 | };
21 | }
22 |
23 | if (_.isPlainObject(value)) {
24 | const children = _.map(Object.entries(value), ([k, v]) =>
25 | _toGenericNode(v, k),
26 | );
27 | return {
28 | [TAG_OBJECT]: {
29 | ...(key ? { key } : {}),
30 | ..._groupChildren(children),
31 | },
32 | };
33 | }
34 |
35 | return {
36 | [TAG_VALUE]: {
37 | ...(key ? { key } : {}),
38 | "#text": value ?? "",
39 | },
40 | };
41 | }
42 |
43 | /**
44 | * Groups a list of nodes by their tag name so that fast-xml-parser outputs arrays even for single elements.
45 | */
46 | function _groupChildren(nodes: Record<string, any>[]): Record<string, any> {
47 | return _(nodes)
48 | .groupBy((node) => Object.keys(node)[0])
49 | .mapValues((arr) => _.map(arr, (n) => n[Object.keys(n)[0]]))
50 | .value();
51 | }
52 |
53 | /**
54 | * Recursively converts a generic XML node back to a JavaScript value.
55 | */
56 | function _fromGenericNode(tag: string, data: any): any {
57 | if (tag === TAG_VALUE) {
58 | // <value>123</value> without attributes is parsed as a primitive (number | string)
59 | // whereas <value key="id">123</value> is parsed as an object with a "#text" field.
60 | // Support both shapes.
61 | if (_.isPlainObject(data)) {
62 | return _.get(data, "#text", "");
63 | }
64 | return data ?? "";
65 | }
66 |
67 | if (tag === TAG_ARRAY) {
68 | const result: any[] = [];
69 | _.forEach([TAG_VALUE, TAG_OBJECT, TAG_ARRAY], (childTag) => {
70 | const childNodes = _.castArray(_.get(data, childTag, []));
71 | _.forEach(childNodes, (child) => {
72 | result.push(_fromGenericNode(childTag, child));
73 | });
74 | });
75 | return result;
76 | }
77 |
78 | // TAG_OBJECT
79 | const obj: Record<string, any> = {};
80 | _.forEach([TAG_VALUE, TAG_OBJECT, TAG_ARRAY], (childTag) => {
81 | const childNodes = _.castArray(_.get(data, childTag, []));
82 | _.forEach(childNodes, (child) => {
83 | const key = _.get(child, "key", "");
84 | obj[key] = _fromGenericNode(childTag, child);
85 | });
86 | });
87 | return obj;
88 | }
89 |
90 | export function obj2xml<T>(obj: T): string {
91 | const rootNode = _toGenericNode(obj)[TAG_OBJECT];
92 | const builder = new XMLBuilder({
93 | ignoreAttributes: false,
94 | attributeNamePrefix: "",
95 | format: true,
96 | suppressEmptyNode: true,
97 | });
98 | return builder.build({ [TAG_OBJECT]: rootNode });
99 | }
100 |
101 | export function xml2obj<T = any>(xml: string): T {
102 | const parser = new XMLParser({
103 | ignoreAttributes: false,
104 | attributeNamePrefix: "",
105 | parseTagValue: true,
106 | parseAttributeValue: false,
107 | processEntities: true,
108 | isArray: (name) => [TAG_VALUE, TAG_ARRAY, TAG_OBJECT].includes(name),
109 | });
110 | const parsed = parser.parse(xml);
111 |
112 | // The parser keeps the XML declaration (<?xml version="1.0"?>) under the
113 | // pseudo-tag "?xml". Skip it so that we always start the conversion at the
114 | // first real node (i.e. <object>, <array> or <value>).
115 | const withoutDeclaration = _.omit(parsed, "?xml");
116 |
117 | const rootTag = Object.keys(withoutDeclaration)[0];
118 |
119 | // fast-xml-parser treats every <object>, <array> and <value> element as an array
120 | // because we configured the `isArray` option above. This means even the root
121 | // element comes wrapped in an array. Unwrap it so that the recursive
122 | // conversion logic receives the actual node object instead of an array –
123 | // otherwise no children will be found and we would return an empty result.
124 | const rootNode = _.castArray(withoutDeclaration[rootTag])[0];
125 |
126 | return _fromGenericNode(rootTag, rootNode);
127 | }
128 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/utils/ui.ts:
--------------------------------------------------------------------------------
```typescript
1 | import chalk from "chalk";
2 | import figlet from "figlet";
3 | import { vice } from "gradient-string";
4 | import readline from "readline";
5 | import { colors } from "../constants";
6 |
7 | export async function renderClear() {
8 | console.log("\x1Bc");
9 | }
10 |
11 | export async function renderSpacer() {
12 | console.log(" ");
13 | }
14 |
15 | export async function renderBanner() {
16 | console.log(
17 | vice(
18 | figlet.textSync("LINGO.DEV", {
19 | font: "ANSI Shadow",
20 | horizontalLayout: "default",
21 | verticalLayout: "default",
22 | }),
23 | ),
24 | );
25 | }
26 |
27 | export async function renderHero() {
28 | console.log(
29 | `⚡️ ${chalk.hex(colors.green)(
30 | "Lingo.dev",
31 | )} - open-source, AI-powered i18n CLI for web & mobile localization.`,
32 | );
33 | console.log("");
34 |
35 | const label1 = "📚 Docs:";
36 | const label2 = "⭐ Star the repo:";
37 | const label3 = "🎮 Join Discord:";
38 | const maxLabelWidth = 17; // Approximate visual width accounting for emoji
39 |
40 | console.log(
41 | `${chalk.hex(colors.blue)(label1.padEnd(maxLabelWidth + 1))} ${chalk.hex(
42 | colors.blue,
43 | )("https://lingo.dev/go/docs")}`,
44 | ); // Docs emoji seems narrower
45 | console.log(
46 | `${chalk.hex(colors.blue)(label2.padEnd(maxLabelWidth))} ${chalk.hex(
47 | colors.blue,
48 | )("https://lingo.dev/go/gh")}`,
49 | );
50 | console.log(
51 | `${chalk.hex(colors.blue)(label3.padEnd(maxLabelWidth + 1))} ${chalk.hex(
52 | colors.blue,
53 | )("https://lingo.dev/go/discord")}`,
54 | );
55 | }
56 |
57 | export async function waitForUserPrompt(message: string): Promise<void> {
58 | const rl = readline.createInterface({
59 | input: process.stdin,
60 | output: process.stdout,
61 | });
62 |
63 | return new Promise((resolve) => {
64 | rl.question(chalk.dim(`[${message}]\n`), () => {
65 | rl.close();
66 | resolve();
67 | });
68 | });
69 | }
70 |
71 | export async function pauseIfDebug(debug: boolean) {
72 | if (debug) {
73 | await waitForUserPrompt("Press Enter to continue...");
74 | }
75 | }
76 |
77 | export async function renderSummary(results: Map<any, any>) {
78 | console.log(chalk.hex(colors.green)("[Done]"));
79 |
80 | const skippedResults = Array.from(results.values()).filter(
81 | (r) => r.status === "skipped",
82 | );
83 | const succeededResults = Array.from(results.values()).filter(
84 | (r) => r.status === "success",
85 | );
86 | const failedResults = Array.from(results.values()).filter(
87 | (r) => r.status === "error",
88 | );
89 |
90 | console.log(
91 | `• ${chalk.hex(colors.yellow)(skippedResults.length)} from cache`,
92 | );
93 | console.log(
94 | `• ${chalk.hex(colors.yellow)(succeededResults.length)} processed`,
95 | );
96 | console.log(`• ${chalk.hex(colors.yellow)(failedResults.length)} failed`);
97 |
98 | // Show processed files
99 | if (succeededResults.length > 0) {
100 | console.log(chalk.hex(colors.green)("\n[Processed Files]"));
101 | for (const result of succeededResults) {
102 | const displayPath =
103 | result.pathPattern?.replace("[locale]", result.targetLocale) ||
104 | "unknown";
105 | console.log(
106 | ` ✓ ${chalk.dim(displayPath)} ${chalk.hex(colors.yellow)(`(${result.sourceLocale} → ${result.targetLocale})`)}`,
107 | );
108 | }
109 | }
110 |
111 | // Show cached files
112 | if (skippedResults.length > 0) {
113 | console.log(chalk.hex(colors.blue)("\n[Cached Files]"));
114 | for (const result of skippedResults) {
115 | const displayPath =
116 | result.pathPattern?.replace("[locale]", result.targetLocale) ||
117 | "unknown";
118 | console.log(
119 | ` ⚡ ${chalk.dim(displayPath)} ${chalk.hex(colors.yellow)(`(${result.sourceLocale} → ${result.targetLocale})`)}`,
120 | );
121 | }
122 | }
123 |
124 | // Show failed files
125 | if (failedResults.length > 0) {
126 | console.log(chalk.hex(colors.orange)("\n[Failed Files]"));
127 | for (const result of failedResults) {
128 | const displayPath =
129 | result.pathPattern?.replace("[locale]", result.targetLocale) ||
130 | "unknown";
131 | console.log(
132 | ` ❌ ${chalk.dim(displayPath)} ${chalk.hex(colors.yellow)(`(${result.sourceLocale} → ${result.targetLocale})`)}`,
133 | );
134 | console.log(
135 | ` ${chalk.hex(colors.white)(String(result.error?.message || "Unknown error"))}`,
136 | );
137 | }
138 | }
139 | }
140 |
```
--------------------------------------------------------------------------------
/scripts/docs/src/generate-config-docs.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { LATEST_CONFIG_DEFINITION } from "@lingo.dev/_spec/src/config";
4 | import type { Root } from "mdast";
5 | import { mkdirSync, writeFileSync } from "node:fs";
6 | import { dirname, resolve } from "node:path";
7 | import remarkStringify from "remark-stringify";
8 | import { unified } from "unified";
9 | import { zodToJsonSchema } from "zod-to-json-schema";
10 | import { renderMarkdown } from "./json-schema/markdown-renderer";
11 | import { parseSchema } from "./json-schema/parser";
12 | import type { JSONSchemaObject } from "./json-schema/types";
13 | import { createOrUpdateGitHubComment, formatMarkdown } from "./utils";
14 |
15 | const ROOT_PROPERTY_ORDER = ["$schema", "version", "locale", "buckets"];
16 |
17 | function generateMarkdown(schema: unknown): string {
18 | if (!schema || typeof schema !== "object") {
19 | throw new Error("Invalid schema provided");
20 | }
21 |
22 | // Ensure the `version` property reflects the latest schema version in docs
23 | const schemaObj = schema as JSONSchemaObject;
24 | const rootRef = schemaObj.$ref as string | undefined;
25 | const rootName: string = rootRef
26 | ? (rootRef.split("/").pop() ?? "I18nConfig")
27 | : "I18nConfig";
28 |
29 | let rootSchema: unknown;
30 | if (
31 | rootRef &&
32 | schemaObj.definitions &&
33 | typeof schemaObj.definitions === "object"
34 | ) {
35 | const definitions = schemaObj.definitions as Record<string, unknown>;
36 | rootSchema = definitions[rootName];
37 | } else {
38 | rootSchema = schema;
39 | }
40 |
41 | if (rootSchema && typeof rootSchema === "object") {
42 | const rootSchemaObj = rootSchema as JSONSchemaObject;
43 | if (
44 | rootSchemaObj.properties &&
45 | typeof rootSchemaObj.properties === "object"
46 | ) {
47 | const properties = rootSchemaObj.properties as Record<string, unknown>;
48 | if (properties.version && typeof properties.version === "object") {
49 | (properties.version as Record<string, unknown>).default =
50 | LATEST_CONFIG_DEFINITION.defaultValue.version;
51 | }
52 | }
53 | }
54 |
55 | const properties = parseSchema(schema, { customOrder: ROOT_PROPERTY_ORDER });
56 | return renderMarkdown(properties);
57 | }
58 |
59 | async function main() {
60 | const commentMarker = "<!-- generate-config-docs -->";
61 | const isGitHubAction = Boolean(process.env.GITHUB_ACTIONS);
62 |
63 | const outputArg = process.argv[2];
64 |
65 | const schema = zodToJsonSchema(LATEST_CONFIG_DEFINITION.schema, {
66 | name: "I18nConfig",
67 | markdownDescription: true,
68 | });
69 |
70 | console.log("🔄 Generating i18n.json reference docs...");
71 | const markdown = generateMarkdown(schema);
72 | const formattedMarkdown = await formatMarkdown(markdown);
73 |
74 | if (isGitHubAction) {
75 | const mdast: Root = {
76 | type: "root",
77 | children: [
78 | { type: "html", value: commentMarker },
79 | {
80 | type: "paragraph",
81 | children: [
82 | {
83 | type: "text",
84 | value:
85 | "Your PR affects the Lingo.dev i18n.json configuration schema and may affect the auto-generated reference documentation. Please review the output below to ensure that the changes are correct.",
86 | },
87 | ],
88 | },
89 | { type: "html", value: "<details>" },
90 | {
91 | type: "html",
92 | value: "<summary>i18n.json reference docs</summary>",
93 | },
94 | { type: "code", lang: "markdown", value: formattedMarkdown },
95 | { type: "html", value: "</details>" },
96 | ],
97 | };
98 | const body = unified()
99 | .use([[remarkStringify, { fence: "~" }]])
100 | .stringify(mdast)
101 | .toString();
102 | await createOrUpdateGitHubComment({
103 | commentMarker,
104 | body,
105 | });
106 | return;
107 | }
108 |
109 | if (!outputArg) {
110 | throw new Error(
111 | "Output file path is required. Usage: generate-config-docs <output-path>",
112 | );
113 | }
114 |
115 | const outputFilePath = resolve(process.cwd(), outputArg);
116 | console.log(`💾 Saving to ${outputFilePath}...`);
117 | mkdirSync(dirname(outputFilePath), { recursive: true });
118 | writeFileSync(outputFilePath, formattedMarkdown);
119 | console.log(`✅ Saved to ${outputFilePath}`);
120 | }
121 |
122 | main().catch((err) => {
123 | console.error(err);
124 | process.exit(1);
125 | });
126 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/cmd/ci/platforms/bitbucket.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { execSync } from "child_process";
2 | import bbLib from "bitbucket";
3 | import Z from "zod";
4 | import { PlatformKit } from "./_base";
5 |
6 | const { Bitbucket } = bbLib;
7 |
8 | interface BitbucketConfig {
9 | baseBranchName: string;
10 | repositoryOwner: string;
11 | repositoryName: string;
12 | bbToken?: string;
13 | }
14 |
15 | export class BitbucketPlatformKit extends PlatformKit<BitbucketConfig> {
16 | private _bb?: ReturnType<typeof Bitbucket>;
17 |
18 | private get bb() {
19 | if (!this._bb) {
20 | this._bb = new Bitbucket({
21 | auth: { token: this.platformConfig.bbToken || "" },
22 | });
23 | }
24 | return this._bb;
25 | }
26 |
27 | async branchExists({ branch }: { branch: string }) {
28 | return await this.bb.repositories
29 | .getBranch({
30 | workspace: this.platformConfig.repositoryOwner,
31 | repo_slug: this.platformConfig.repositoryName,
32 | name: branch,
33 | })
34 | .then((r) => r.data)
35 | .then((v) => !!v)
36 | .catch((r) => (r.status === 404 ? false : Promise.reject(r)));
37 | }
38 |
39 | async getOpenPullRequestNumber({ branch }: { branch: string }) {
40 | return await this.bb.repositories
41 | .listPullRequests({
42 | workspace: this.platformConfig.repositoryOwner,
43 | repo_slug: this.platformConfig.repositoryName,
44 | state: "OPEN",
45 | })
46 | .then(({ data: { values } }) => {
47 | // TODO: we might need to handle pagination in future
48 | // bitbucket API does not support filtering pull requests
49 | // https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-get
50 | return values?.find(
51 | ({ source, destination }) =>
52 | source?.branch?.name === branch &&
53 | destination?.branch?.name === this.platformConfig.baseBranchName,
54 | );
55 | })
56 | .then((pr) => pr?.id);
57 | }
58 |
59 | async closePullRequest({ pullRequestNumber }: { pullRequestNumber: number }) {
60 | await this.bb.repositories.declinePullRequest({
61 | workspace: this.platformConfig.repositoryOwner,
62 | repo_slug: this.platformConfig.repositoryName,
63 | pull_request_id: pullRequestNumber,
64 | });
65 | }
66 |
67 | async createPullRequest({
68 | title,
69 | body,
70 | head,
71 | }: {
72 | title: string;
73 | body?: string;
74 | head: string;
75 | }) {
76 | return await this.bb.repositories
77 | .createPullRequest({
78 | workspace: this.platformConfig.repositoryOwner,
79 | repo_slug: this.platformConfig.repositoryName,
80 | _body: {
81 | title,
82 | description: body,
83 | source: { branch: { name: head } },
84 | destination: { branch: { name: this.platformConfig.baseBranchName } },
85 | } as any,
86 | })
87 | .then(({ data }) => data.id ?? 0);
88 | }
89 |
90 | async commentOnPullRequest({
91 | pullRequestNumber,
92 | body,
93 | }: {
94 | pullRequestNumber: number;
95 | body: string;
96 | }) {
97 | await this.bb.repositories.createPullRequestComment({
98 | workspace: this.platformConfig.repositoryOwner,
99 | repo_slug: this.platformConfig.repositoryName,
100 | pull_request_id: pullRequestNumber,
101 | _body: {
102 | content: {
103 | raw: body,
104 | },
105 | } as any,
106 | });
107 | }
108 |
109 | async gitConfig() {
110 | execSync("git config --unset http.${BITBUCKET_GIT_HTTP_ORIGIN}.proxy", {
111 | stdio: "inherit",
112 | });
113 | execSync(
114 | "git config http.${BITBUCKET_GIT_HTTP_ORIGIN}.proxy http://host.docker.internal:29418/",
115 | {
116 | stdio: "inherit",
117 | },
118 | );
119 | }
120 |
121 | get platformConfig() {
122 | const env = Z.object({
123 | BITBUCKET_BRANCH: Z.string(),
124 | BITBUCKET_REPO_FULL_NAME: Z.string(),
125 | BB_TOKEN: Z.string().optional(),
126 | }).parse(process.env);
127 |
128 | const [repositoryOwner, repositoryName] =
129 | env.BITBUCKET_REPO_FULL_NAME.split("/");
130 |
131 | return {
132 | baseBranchName: env.BITBUCKET_BRANCH,
133 | repositoryOwner,
134 | repositoryName,
135 | bbToken: env.BB_TOKEN,
136 | };
137 | }
138 |
139 | buildPullRequestUrl(pullRequestNumber: number) {
140 | const { repositoryOwner, repositoryName } = this.platformConfig;
141 | return `https://bitbucket.org/${repositoryOwner}/${repositoryName}/pull-requests/${pullRequestNumber}`;
142 | }
143 | }
144 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/cmd/ci/flows/in-branch.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { execSync } from "child_process";
2 | import path from "path";
3 | import {
4 | gitConfig,
5 | IntegrationFlow,
6 | escapeShellArg,
7 | IIntegrationFlowOptions,
8 | } from "./_base";
9 | import i18nCmd from "../../i18n";
10 | import runCmd from "../../run";
11 |
12 | export class InBranchFlow extends IntegrationFlow {
13 | async preRun() {
14 | this.ora.start("Configuring git");
15 | const canContinue = this.configureGit();
16 | this.ora.succeed("Git configured");
17 |
18 | return canContinue;
19 | }
20 |
21 | async run(options: IIntegrationFlowOptions) {
22 | this.ora.start("Running Lingo.dev");
23 | await this.runLingoDotDev(options.parallel);
24 | this.ora.succeed("Done running Lingo.dev");
25 |
26 | execSync(`rm -f i18n.cache`, { stdio: "inherit" }); // do not commit cache file if it exists
27 |
28 | this.ora.start("Checking for changes");
29 | const hasChanges = this.checkCommitableChanges();
30 | this.ora.succeed(hasChanges ? "Changes detected" : "No changes detected");
31 |
32 | if (hasChanges) {
33 | this.ora.start("Committing changes");
34 | execSync(`git add .`, { stdio: "inherit" });
35 | execSync(`git status --porcelain`, { stdio: "inherit" });
36 | execSync(
37 | `git commit -m ${escapeShellArg(
38 | this.platformKit.config.commitMessage,
39 | )} --no-verify`,
40 | {
41 | stdio: "inherit",
42 | },
43 | );
44 | this.ora.succeed("Changes committed");
45 |
46 | this.ora.start("Pushing changes to remote");
47 | const currentBranch =
48 | this.i18nBranchName ?? this.platformKit.platformConfig.baseBranchName;
49 | execSync(
50 | `git push origin ${currentBranch} ${options.force ? "--force" : ""}`,
51 | {
52 | stdio: "inherit",
53 | },
54 | );
55 | this.ora.succeed("Changes pushed to remote");
56 | }
57 |
58 | return hasChanges;
59 | }
60 |
61 | protected checkCommitableChanges() {
62 | return (
63 | execSync('git status --porcelain || echo "has_changes"', {
64 | encoding: "utf8",
65 | }).length > 0
66 | );
67 | }
68 |
69 | private async runLingoDotDev(isParallel?: boolean) {
70 | try {
71 | if (!isParallel) {
72 | await i18nCmd
73 | .exitOverride()
74 | .parseAsync(["--api-key", this.platformKit.config.replexicaApiKey], {
75 | from: "user",
76 | });
77 | } else {
78 | await runCmd
79 | .exitOverride()
80 | .parseAsync(["--api-key", this.platformKit.config.replexicaApiKey], {
81 | from: "user",
82 | });
83 | }
84 | } catch (err: any) {
85 | if (err.code === "commander.helpDisplayed") return;
86 | throw err;
87 | }
88 | }
89 |
90 | private configureGit() {
91 | const { processOwnCommits } = this.platformKit.config;
92 | const { baseBranchName } = this.platformKit.platformConfig;
93 |
94 | this.ora.info(`Current working directory:`);
95 | execSync(`pwd`, { stdio: "inherit" });
96 | execSync(`ls -la`, { stdio: "inherit" });
97 |
98 | execSync(`git config --global safe.directory ${process.cwd()}`);
99 |
100 | execSync(`git config user.name "${gitConfig.userName}"`);
101 | execSync(`git config user.email "${gitConfig.userEmail}"`);
102 |
103 | // perform platform-specific configuration before fetching or pushing to the remote
104 | this.platformKit?.gitConfig();
105 |
106 | execSync(`git fetch origin ${baseBranchName}`, { stdio: "inherit" });
107 | execSync(`git checkout ${baseBranchName} --`, { stdio: "inherit" });
108 |
109 | if (!processOwnCommits) {
110 | const currentAuthor = `${gitConfig.userName} <${gitConfig.userEmail}>`;
111 | const authorOfLastCommit = execSync(
112 | `git log -1 --pretty=format:'%an <%ae>'`,
113 | ).toString();
114 | if (authorOfLastCommit === currentAuthor) {
115 | this.ora.warn(
116 | `The last commit was already made by ${currentAuthor}, so this run will be skipped, as running again would have no effect. See docs: https://lingo.dev/ci`,
117 | );
118 | return false;
119 | }
120 | }
121 |
122 | const workingDir = path.resolve(
123 | process.cwd(),
124 | this.platformKit.config.workingDir,
125 | );
126 | if (workingDir !== process.cwd()) {
127 | this.ora.info(
128 | `Changing to working directory: ${this.platformKit.config.workingDir}`,
129 | );
130 | process.chdir(workingDir);
131 | }
132 |
133 | return true;
134 | }
135 | }
136 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/processor/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { I18nConfig } from "@lingo.dev/_spec";
2 | import chalk from "chalk";
3 | import dedent from "dedent";
4 | import { LocalizerFn } from "./_base";
5 | import { createLingoLocalizer } from "./lingo";
6 | import { createBasicTranslator } from "./basic";
7 | import { createOpenAI } from "@ai-sdk/openai";
8 | import { colors } from "../constants";
9 | import { createAnthropic } from "@ai-sdk/anthropic";
10 | import { createGoogleGenerativeAI } from "@ai-sdk/google";
11 | import { createOpenRouter } from "@openrouter/ai-sdk-provider";
12 | import { createMistral } from "@ai-sdk/mistral";
13 | import { createOllama } from "ollama-ai-provider";
14 |
15 | export default function createProcessor(
16 | provider: I18nConfig["provider"],
17 | params: { apiKey?: string; apiUrl: string },
18 | ): LocalizerFn {
19 | if (!provider) {
20 | const result = createLingoLocalizer(params);
21 | return result;
22 | } else {
23 | const model = getPureModelProvider(provider);
24 | const settings = provider.settings || {};
25 | const result = createBasicTranslator(model, provider.prompt, settings);
26 | return result;
27 | }
28 | }
29 |
30 | function getPureModelProvider(provider: I18nConfig["provider"]) {
31 | const createMissingKeyErrorMessage = (
32 | providerId: string,
33 | envVar?: string,
34 | ) => dedent`
35 | You're trying to use raw ${chalk.dim(providerId)} API for translation. ${
36 | envVar
37 | ? `However, ${chalk.dim(envVar)} environment variable is not set.`
38 | : "However, that provider is unavailable."
39 | }
40 |
41 | To fix this issue:
42 | 1. ${
43 | envVar
44 | ? `Set ${chalk.dim(envVar)} in your environment variables`
45 | : "Set the environment variable for your provider (if required)"
46 | }, or
47 | 2. Remove the ${chalk.italic(
48 | "provider",
49 | )} node from your i18n.json configuration to switch to ${chalk.hex(
50 | colors.green,
51 | )("Lingo.dev")}
52 |
53 | ${chalk.hex(colors.blue)("Docs: https://lingo.dev/go/docs")}
54 | `;
55 |
56 | const createUnsupportedProviderErrorMessage = (providerId?: string) =>
57 | dedent`
58 | You're trying to use unsupported provider: ${chalk.dim(providerId)}.
59 |
60 | To fix this issue:
61 | 1. Switch to one of the supported providers, or
62 | 2. Remove the ${chalk.italic(
63 | "provider",
64 | )} node from your i18n.json configuration to switch to ${chalk.hex(
65 | colors.green,
66 | )("Lingo.dev")}
67 |
68 | ${chalk.hex(colors.blue)("Docs: https://lingo.dev/go/docs")}
69 | `;
70 |
71 | switch (provider?.id) {
72 | case "openai": {
73 | if (!process.env.OPENAI_API_KEY) {
74 | throw new Error(
75 | createMissingKeyErrorMessage("OpenAI", "OPENAI_API_KEY"),
76 | );
77 | }
78 | return createOpenAI({
79 | apiKey: process.env.OPENAI_API_KEY,
80 | baseURL: provider.baseUrl,
81 | })(provider.model);
82 | }
83 | case "anthropic": {
84 | if (!process.env.ANTHROPIC_API_KEY) {
85 | throw new Error(
86 | createMissingKeyErrorMessage("Anthropic", "ANTHROPIC_API_KEY"),
87 | );
88 | }
89 | return createAnthropic({
90 | apiKey: process.env.ANTHROPIC_API_KEY,
91 | })(provider.model);
92 | }
93 | case "google": {
94 | if (!process.env.GOOGLE_API_KEY) {
95 | throw new Error(
96 | createMissingKeyErrorMessage("Google", "GOOGLE_API_KEY"),
97 | );
98 | }
99 | return createGoogleGenerativeAI({
100 | apiKey: process.env.GOOGLE_API_KEY,
101 | })(provider.model);
102 | }
103 | case "openrouter": {
104 | if (!process.env.OPENROUTER_API_KEY) {
105 | throw new Error(
106 | createMissingKeyErrorMessage("OpenRouter", "OPENROUTER_API_KEY"),
107 | );
108 | }
109 | return createOpenRouter({
110 | apiKey: process.env.OPENROUTER_API_KEY,
111 | baseURL: provider.baseUrl,
112 | })(provider.model);
113 | }
114 | case "ollama": {
115 | // No API key check needed for Ollama
116 | return createOllama()(provider.model);
117 | }
118 | case "mistral": {
119 | if (!process.env.MISTRAL_API_KEY) {
120 | throw new Error(
121 | createMissingKeyErrorMessage("Mistral", "MISTRAL_API_KEY"),
122 | );
123 | }
124 | return createMistral({
125 | apiKey: process.env.MISTRAL_API_KEY,
126 | baseURL: provider.baseUrl,
127 | })(provider.model);
128 | }
129 | default: {
130 | throw new Error(createUnsupportedProviderErrorMessage(provider?.id));
131 | }
132 | }
133 | }
134 |
```
--------------------------------------------------------------------------------
/packages/compiler/src/utils/index.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import { createPayload, createOutput } from "../_base";
3 | import * as t from "@babel/types";
4 | import {
5 | getJsxRoots,
6 | isGoodJsxText,
7 | getOrCreateImport,
8 | hasI18nDirective,
9 | hasClientDirective,
10 | hasServerDirective,
11 | getModuleExecutionMode,
12 | } from "./index";
13 |
14 | function parse(code: string) {
15 | return createPayload({ code, params: {} as any, relativeFilePath: "x.tsx" })
16 | .ast as unknown as t.Node;
17 | }
18 |
19 | describe("getOrCreateImport", () => {
20 | it("inserts import when missing and reuses existing import when present", () => {
21 | const ast = parse(`export const X = 1;`);
22 | const res1 = getOrCreateImport(ast, {
23 | exportedName: "Fragment",
24 | moduleName: ["react"],
25 | });
26 | expect(res1.importedName).toBe("Fragment");
27 | const code1 = createOutput({
28 | code: "",
29 | ast,
30 | params: {} as any,
31 | relativeFilePath: "x.tsx",
32 | }).code;
33 | expect(code1).toMatch(/import\s*\{\s*Fragment\s*\}\s*from\s*["']react["']/);
34 |
35 | // Call again should reuse the same import and not duplicate
36 | const res2 = getOrCreateImport(ast, {
37 | exportedName: "Fragment",
38 | moduleName: ["react"],
39 | });
40 | expect(res2.importedName).toBe("Fragment");
41 | const code2 = createOutput({
42 | code: "",
43 | ast,
44 | params: {} as any,
45 | relativeFilePath: "x.tsx",
46 | }).code;
47 | const matches =
48 | code2.match(/import\s*\{\s*Fragment\s*\}\s*from\s*["']react["']/g) || [];
49 | expect(matches.length).toBe(1);
50 | });
51 | });
52 |
53 | describe("getJsxRoots", () => {
54 | it("returns only top-level JSX roots", () => {
55 | const ast = parse(`const X = () => (<div><span>Hello</span></div>);`);
56 | const roots = getJsxRoots(ast);
57 | expect(roots.length).toBe(1);
58 | });
59 | });
60 |
61 | describe("isGoodJsxText", () => {
62 | it("detects non-empty JSXText", () => {
63 | const payload = createPayload({
64 | code: `const X = () => (<div> Hello </div>);`,
65 | params: {} as any,
66 | relativeFilePath: "x.tsx",
67 | });
68 | let textPath: any;
69 | // locate JSXText
70 | require("@babel/traverse").default(payload.ast, {
71 | JSXText(p: any) {
72 | if (!textPath) textPath = p;
73 | },
74 | });
75 | expect(isGoodJsxText(textPath)).toBe(true);
76 | });
77 | });
78 |
79 | describe("hasI18nDirective", () => {
80 | it("returns true when file has use i18n directive", () => {
81 | const ast = parse(`"use i18n"; export const X = 1;`);
82 | expect(hasI18nDirective(ast)).toBe(true);
83 | });
84 |
85 | it("returns false when file does not have use i18n directive", () => {
86 | const ast = parse(`export const X = 1;`);
87 | expect(hasI18nDirective(ast)).toBe(false);
88 | });
89 | });
90 |
91 | describe("hasClientDirective", () => {
92 | it("returns true when file has use client directive", () => {
93 | const ast = parse(`"use client"; export const X = 1;`);
94 | expect(hasClientDirective(ast)).toBe(true);
95 | });
96 |
97 | it("returns false when file does not have use client directive", () => {
98 | expect(hasClientDirective(parse(`const X = 1;`))).toBe(false);
99 | expect(hasClientDirective(parse(`"use server"; const X=1;`))).toBe(false);
100 | });
101 | });
102 |
103 | describe("hasServerDirective", () => {
104 | it("returns true when file has use server directive", () => {
105 | const ast = parse(`"use server"; export const X = 1;`);
106 | expect(hasServerDirective(ast)).toBe(true);
107 | });
108 |
109 | it("returns false when file does not have use server directive", () => {
110 | expect(hasServerDirective(parse(`const X = 1;`))).toBe(false);
111 | expect(hasServerDirective(parse(`"use client"; const X=1;`))).toBe(false);
112 | });
113 | });
114 |
115 | describe("getModuleExecutionMode", () => {
116 | it("returns server by default when RSC enabled and no client directive", () => {
117 | const ast = parse(`export const X = 1;`);
118 | expect(getModuleExecutionMode(ast, true)).toBe("server");
119 | });
120 |
121 | it("returns client when use client directive present", () => {
122 | const ast = parse(`"use client"; export const X = 1;`);
123 | expect(getModuleExecutionMode(ast, true)).toBe("client");
124 | });
125 |
126 | it("returns client when RSC disabled", () => {
127 | const ast = parse(`export const X = 1;`);
128 | expect(getModuleExecutionMode(ast, false)).toBe("client");
129 | });
130 | });
131 |
```
--------------------------------------------------------------------------------
/demo/vite-project/src/assets/react.svg:
--------------------------------------------------------------------------------
```
1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/json5.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect, it } from "vitest";
2 | import createJson5Loader from "./json5";
3 |
4 | describe("json5 loader", () => {
5 | it("pull should parse valid JSON5 format", async () => {
6 | const loader = createJson5Loader();
7 | loader.setDefaultLocale("en");
8 | const json5Input = `{
9 | // Comments are allowed in JSON5
10 | hello: "Hello",
11 | 'single-quotes': 'work too',
12 | unquoted: 'keys work',
13 | trailing: 'comma is ok',
14 | }`;
15 |
16 | const result = await loader.pull("en", json5Input);
17 | expect(result).toEqual({
18 | hello: "Hello",
19 | "single-quotes": "work too",
20 | unquoted: "keys work",
21 | trailing: "comma is ok",
22 | });
23 | });
24 |
25 | it("pull should parse regular JSON as fallback", async () => {
26 | const loader = createJson5Loader();
27 | loader.setDefaultLocale("en");
28 | const jsonInput = '{"hello": "Hello", "world": "World"}';
29 |
30 | const result = await loader.pull("en", jsonInput);
31 | expect(result).toEqual({
32 | hello: "Hello",
33 | world: "World",
34 | });
35 | });
36 |
37 | it("pull should handle empty input", async () => {
38 | const loader = createJson5Loader();
39 | loader.setDefaultLocale("en");
40 | const result = await loader.pull("en", "");
41 | expect(result).toEqual({});
42 | });
43 |
44 | it("pull should handle null/undefined input", async () => {
45 | const loader = createJson5Loader();
46 | loader.setDefaultLocale("en");
47 | const result = await loader.pull("en", null as any);
48 | expect(result).toEqual({});
49 | });
50 |
51 | it("pull should handle JSON5 with multiline strings", async () => {
52 | const loader = createJson5Loader();
53 | loader.setDefaultLocale("en");
54 | const json5Input = `{
55 | multiline: "This is a \\
56 | long string that \\
57 | spans multiple lines"
58 | }`;
59 |
60 | const result = await loader.pull("en", json5Input);
61 | expect(result).toEqual({
62 | multiline: "This is a long string that spans multiple lines",
63 | });
64 | });
65 |
66 | it("pull should handle JSON5 with hexadecimal numbers", async () => {
67 | const loader = createJson5Loader();
68 | loader.setDefaultLocale("en");
69 | const json5Input = `{
70 | hex: 0xdecaf,
71 | positive: +123,
72 | negative: -456
73 | }`;
74 |
75 | const result = await loader.pull("en", json5Input);
76 | expect(result).toEqual({
77 | hex: 0xdecaf,
78 | positive: 123,
79 | negative: -456,
80 | });
81 | });
82 |
83 | it("pull should throw error for invalid JSON5", async () => {
84 | const loader = createJson5Loader();
85 | loader.setDefaultLocale("en");
86 | const invalidInput = `{
87 | hello: "Hello"
88 | world: "World" // missing comma
89 | invalid: syntax
90 | }`;
91 |
92 | await expect(loader.pull("en", invalidInput)).rejects.toThrow();
93 | });
94 |
95 | it("push should serialize data to JSON5 format", async () => {
96 | const loader = createJson5Loader();
97 | loader.setDefaultLocale("en");
98 | // Need to call pull first to initialize the loader state
99 | await loader.pull("en", "{}");
100 |
101 | const data = {
102 | hello: "Hello",
103 | world: "World",
104 | nested: {
105 | key: "value",
106 | },
107 | };
108 |
109 | const result = await loader.push("en", data);
110 | const expectedOutput = `{
111 | hello: 'Hello',
112 | world: 'World',
113 | nested: {
114 | key: 'value',
115 | },
116 | }`;
117 |
118 | expect(result).toBe(expectedOutput);
119 | });
120 |
121 | it("push should handle empty object", async () => {
122 | const loader = createJson5Loader();
123 | loader.setDefaultLocale("en");
124 | // Need to call pull first to initialize the loader state
125 | await loader.pull("en", "{}");
126 |
127 | const result = await loader.push("en", {});
128 | expect(result).toBe("{}");
129 | });
130 |
131 | it("push should handle complex nested data", async () => {
132 | const loader = createJson5Loader();
133 | loader.setDefaultLocale("en");
134 | // Need to call pull first to initialize the loader state
135 | await loader.pull("en", "{}");
136 |
137 | const data = {
138 | strings: ["hello", "world"],
139 | numbers: [1, 2, 3],
140 | nested: {
141 | deep: {
142 | key: "value",
143 | },
144 | },
145 | };
146 |
147 | const result = await loader.push("en", data);
148 |
149 | // Parse the result back to verify it's valid JSON5
150 | const JSON5 = await import("json5");
151 | const parsed = JSON5.default.parse(result);
152 | expect(parsed).toEqual(data);
153 | });
154 | });
155 |
```