This is page 10 of 16. Use http://codebase.md/lingodotdev/lingo.dev?lines=false&page={x} to view the full context.
# Directory Structure
```
├── .changeset
│ ├── config.json
│ └── README.md
├── .claude
│ ├── agents
│ │ └── code-architect-reviewer.md
│ └── commands
│ ├── analyze-bucket-type.md
│ └── create-bucket-docs.md
├── .editorconfig
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── docker.yml
│ ├── lingodotdev.yml
│ ├── pr-check.yml
│ ├── pr-lint.yml
│ └── release.yml
├── .gitignore
├── .husky
│ └── commit-msg
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .vscode
│ ├── extensions.json
│ ├── launch.json
│ └── settings.json
├── action.yml
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── commitlint.config.js
├── composer.json
├── content
│ ├── banner.compiler.png
│ ├── banner.dark.png
│ └── banner.launch.png
├── CONTRIBUTING.md
├── DEBUGGING.md
├── demo
│ ├── adonisjs
│ │ ├── .editorconfig
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── ace.js
│ │ ├── adonisrc.ts
│ │ ├── app
│ │ │ ├── exceptions
│ │ │ │ └── handler.ts
│ │ │ └── middleware
│ │ │ └── container_bindings_middleware.ts
│ │ ├── bin
│ │ │ ├── console.ts
│ │ │ ├── server.ts
│ │ │ └── test.ts
│ │ ├── CHANGELOG.md
│ │ ├── config
│ │ │ ├── app.ts
│ │ │ ├── bodyparser.ts
│ │ │ ├── cors.ts
│ │ │ ├── hash.ts
│ │ │ ├── inertia.ts
│ │ │ ├── logger.ts
│ │ │ ├── session.ts
│ │ │ ├── shield.ts
│ │ │ ├── static.ts
│ │ │ └── vite.ts
│ │ ├── eslint.config.js
│ │ ├── inertia
│ │ │ ├── app
│ │ │ │ ├── app.tsx
│ │ │ │ └── ssr.tsx
│ │ │ ├── css
│ │ │ │ └── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── pages
│ │ │ │ ├── errors
│ │ │ │ │ ├── not_found.tsx
│ │ │ │ │ └── server_error.tsx
│ │ │ │ └── home.tsx
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── resources
│ │ │ └── views
│ │ │ └── inertia_layout.edge
│ │ ├── start
│ │ │ ├── env.ts
│ │ │ ├── kernel.ts
│ │ │ └── routes.ts
│ │ ├── tests
│ │ │ └── bootstrap.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── next-app
│ │ ├── .gitignore
│ │ ├── CHANGELOG.md
│ │ ├── eslint.config.mjs
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── public
│ │ │ ├── file.svg
│ │ │ ├── globe.svg
│ │ │ ├── next.svg
│ │ │ ├── vercel.svg
│ │ │ └── window.svg
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── client-component.tsx
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── lingo-dot-dev.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── test
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── hero-actions.tsx
│ │ │ │ ├── hero-subtitle.tsx
│ │ │ │ ├── hero-title.tsx
│ │ │ │ └── index.ts
│ │ │ └── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ └── tsconfig.json
│ ├── react-router-app
│ │ ├── .dockerignore
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── root.tsx
│ │ │ ├── routes
│ │ │ │ ├── home.tsx
│ │ │ │ └── test.tsx
│ │ │ ├── routes.ts
│ │ │ └── welcome
│ │ │ ├── lingo-dot-dev.tsx
│ │ │ ├── logo-dark.svg
│ │ │ ├── logo-light.svg
│ │ │ └── welcome.tsx
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ ├── public
│ │ │ └── favicon.ico
│ │ ├── react-router.config.ts
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── vite-project
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── README.md
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ └── test.tsx
│ │ ├── index.css
│ │ ├── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ ├── lingo-dot-dev.tsx
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── Dockerfile
├── i18n.json
├── i18n.lock
├── integrations
│ └── directus
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── docker-compose.yml
│ ├── Dockerfile
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── api.ts
│ │ ├── app.ts
│ │ └── index.spec.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── ISSUE_TEMPLATE.md
├── legacy
│ ├── cli
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ └── readme.md
│ └── sdk
│ ├── CHANGELOG.md
│ ├── index.d.ts
│ ├── index.js
│ ├── package.json
│ └── README.md
├── LICENSE.md
├── mcp.md
├── package.json
├── packages
│ ├── cli
│ │ ├── assets
│ │ │ ├── failure.mp3
│ │ │ └── success.mp3
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── demo
│ │ │ ├── android
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── csv
│ │ │ │ ├── example.csv
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── demo.spec.ts
│ │ │ ├── ejs
│ │ │ │ ├── en
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── es
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── flutter
│ │ │ │ ├── en
│ │ │ │ │ └── example.arb
│ │ │ │ ├── es
│ │ │ │ │ └── example.arb
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── html
│ │ │ │ ├── en
│ │ │ │ │ └── example.html
│ │ │ │ ├── es
│ │ │ │ │ └── example.html
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json
│ │ │ │ ├── en
│ │ │ │ │ └── example.json
│ │ │ │ ├── es
│ │ │ │ │ └── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json-dictionary
│ │ │ │ ├── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json5
│ │ │ │ ├── en
│ │ │ │ │ └── example.json5
│ │ │ │ ├── es
│ │ │ │ │ └── example.json5
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── jsonc
│ │ │ │ ├── en
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── es
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── i18n.json
│ │ │ │ ├── i18n.lock
│ │ │ │ └── ru
│ │ │ │ └── example.jsonc
│ │ │ ├── markdoc
│ │ │ │ ├── en
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── es
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── markdown
│ │ │ │ ├── en
│ │ │ │ │ └── example.md
│ │ │ │ ├── es
│ │ │ │ │ └── example.md
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── mdx
│ │ │ │ ├── en
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── es
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── php
│ │ │ │ ├── en
│ │ │ │ │ └── example.php
│ │ │ │ ├── es
│ │ │ │ │ └── example.php
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── po
│ │ │ │ ├── en
│ │ │ │ │ └── example.po
│ │ │ │ ├── es
│ │ │ │ │ └── example.po
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── properties
│ │ │ │ ├── en
│ │ │ │ │ └── example.properties
│ │ │ │ ├── es
│ │ │ │ │ └── example.properties
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── run_i18n.sh
│ │ │ ├── srt
│ │ │ │ ├── en
│ │ │ │ │ └── example.srt
│ │ │ │ ├── es
│ │ │ │ │ └── example.srt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── txt
│ │ │ │ ├── en
│ │ │ │ │ └── example.txt
│ │ │ │ ├── es
│ │ │ │ │ └── example.txt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── typescript
│ │ │ │ ├── en
│ │ │ │ │ └── example.ts
│ │ │ │ ├── es
│ │ │ │ │ └── example.ts
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vtt
│ │ │ │ ├── en
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── es
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vue-json
│ │ │ │ ├── example.vue
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-strings
│ │ │ │ ├── en
│ │ │ │ │ └── example.strings
│ │ │ │ ├── es
│ │ │ │ │ └── example.strings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-stringsdict
│ │ │ │ ├── en
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── es
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings-v2
│ │ │ │ ├── complex-example.xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xliff
│ │ │ │ ├── en
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ └── example-v2.xliff
│ │ │ │ ├── es
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ ├── example-v2.xliff
│ │ │ │ │ └── example.xliff
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xml
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── yaml
│ │ │ │ ├── en
│ │ │ │ │ └── example.yml
│ │ │ │ ├── es
│ │ │ │ │ └── example.yml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ └── yaml-root-key
│ │ │ ├── en
│ │ │ │ └── example.yml
│ │ │ ├── es
│ │ │ │ └── example.yml
│ │ │ ├── i18n.json
│ │ │ └── i18n.lock
│ │ ├── i18n.json
│ │ ├── i18n.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── cli
│ │ │ │ ├── cmd
│ │ │ │ │ ├── auth.ts
│ │ │ │ │ ├── ci
│ │ │ │ │ │ ├── flows
│ │ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ │ ├── in-branch.ts
│ │ │ │ │ │ │ └── pull-request.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── platforms
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── bitbucket.ts
│ │ │ │ │ │ ├── github.ts
│ │ │ │ │ │ ├── gitlab.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── cleanup.ts
│ │ │ │ │ ├── config
│ │ │ │ │ │ ├── get.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── set.ts
│ │ │ │ │ │ └── unset.ts
│ │ │ │ │ ├── i18n.ts
│ │ │ │ │ ├── init.ts
│ │ │ │ │ ├── lockfile.ts
│ │ │ │ │ ├── login.ts
│ │ │ │ │ ├── logout.ts
│ │ │ │ │ ├── may-the-fourth.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── purge.ts
│ │ │ │ │ ├── run
│ │ │ │ │ │ ├── _const.ts
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── execute.spec.ts
│ │ │ │ │ │ ├── execute.ts
│ │ │ │ │ │ ├── frozen.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── plan.ts
│ │ │ │ │ │ ├── setup.ts
│ │ │ │ │ │ └── watch.ts
│ │ │ │ │ ├── show
│ │ │ │ │ │ ├── _shared-key-command.ts
│ │ │ │ │ │ ├── config.ts
│ │ │ │ │ │ ├── files.ts
│ │ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── locale.ts
│ │ │ │ │ │ └── locked-keys.ts
│ │ │ │ │ └── status.ts
│ │ │ │ ├── constants.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loaders
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── _utils.ts
│ │ │ │ │ ├── android.spec.ts
│ │ │ │ │ ├── android.ts
│ │ │ │ │ ├── csv.spec.ts
│ │ │ │ │ ├── csv.ts
│ │ │ │ │ ├── dato
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── api.ts
│ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ ├── filter.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── ejs.spec.ts
│ │ │ │ │ ├── ejs.ts
│ │ │ │ │ ├── ensure-key-order.spec.ts
│ │ │ │ │ ├── ensure-key-order.ts
│ │ │ │ │ ├── flat.spec.ts
│ │ │ │ │ ├── flat.ts
│ │ │ │ │ ├── flutter.spec.ts
│ │ │ │ │ ├── flutter.ts
│ │ │ │ │ ├── formatters
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── biome.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── prettier.ts
│ │ │ │ │ ├── html.ts
│ │ │ │ │ ├── icu-safety.spec.ts
│ │ │ │ │ ├── ignored-keys-buckets.spec.ts
│ │ │ │ │ ├── ignored-keys.spec.ts
│ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── inject-locale.spec.ts
│ │ │ │ │ ├── inject-locale.ts
│ │ │ │ │ ├── json-dictionary.spec.ts
│ │ │ │ │ ├── json-dictionary.ts
│ │ │ │ │ ├── json-sorting.test.ts
│ │ │ │ │ ├── json-sorting.ts
│ │ │ │ │ ├── json.ts
│ │ │ │ │ ├── json5.spec.ts
│ │ │ │ │ ├── json5.ts
│ │ │ │ │ ├── jsonc.spec.ts
│ │ │ │ │ ├── jsonc.ts
│ │ │ │ │ ├── locked-keys.spec.ts
│ │ │ │ │ ├── locked-keys.ts
│ │ │ │ │ ├── locked-patterns.spec.ts
│ │ │ │ │ ├── locked-patterns.ts
│ │ │ │ │ ├── markdoc.spec.ts
│ │ │ │ │ ├── markdoc.ts
│ │ │ │ │ ├── markdown.ts
│ │ │ │ │ ├── mdx.spec.ts
│ │ │ │ │ ├── mdx.ts
│ │ │ │ │ ├── mdx2
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── code-placeholder.spec.ts
│ │ │ │ │ │ ├── code-placeholder.ts
│ │ │ │ │ │ ├── frontmatter-split.spec.ts
│ │ │ │ │ │ ├── frontmatter-split.ts
│ │ │ │ │ │ ├── localizable-document.spec.ts
│ │ │ │ │ │ ├── localizable-document.ts
│ │ │ │ │ │ ├── section-split.spec.ts
│ │ │ │ │ │ ├── section-split.ts
│ │ │ │ │ │ └── sections-split-2.ts
│ │ │ │ │ ├── passthrough.ts
│ │ │ │ │ ├── php.ts
│ │ │ │ │ ├── plutil-json-loader.ts
│ │ │ │ │ ├── po
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── properties.ts
│ │ │ │ │ ├── root-key.ts
│ │ │ │ │ ├── srt.ts
│ │ │ │ │ ├── sync.ts
│ │ │ │ │ ├── text-file.ts
│ │ │ │ │ ├── txt.ts
│ │ │ │ │ ├── typescript
│ │ │ │ │ │ ├── cjs-interop.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── unlocalizable.spec.ts
│ │ │ │ │ ├── unlocalizable.ts
│ │ │ │ │ ├── variable
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── vtt.ts
│ │ │ │ │ ├── vue-json.ts
│ │ │ │ │ ├── xcode-strings
│ │ │ │ │ │ ├── escape.ts
│ │ │ │ │ │ ├── parser.ts
│ │ │ │ │ │ ├── tokenizer.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── xcode-strings.spec.ts
│ │ │ │ │ ├── xcode-strings.ts
│ │ │ │ │ ├── xcode-stringsdict.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.ts
│ │ │ │ │ ├── xcode-xcstrings-lock-compatibility.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-v2-loader.ts
│ │ │ │ │ ├── xcode-xcstrings.spec.ts
│ │ │ │ │ ├── xcode-xcstrings.ts
│ │ │ │ │ ├── xliff.spec.ts
│ │ │ │ │ ├── xliff.ts
│ │ │ │ │ ├── xml.ts
│ │ │ │ │ └── yaml.ts
│ │ │ │ ├── localizer
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── explicit.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingodotdev.ts
│ │ │ │ ├── processor
│ │ │ │ │ ├── _base.ts
│ │ │ │ │ ├── basic.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingo.ts
│ │ │ │ └── utils
│ │ │ │ ├── auth.ts
│ │ │ │ ├── buckets.spec.ts
│ │ │ │ ├── buckets.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── cloudflare-status.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── delta.spec.ts
│ │ │ │ ├── delta.ts
│ │ │ │ ├── ensure-patterns.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── exec.spec.ts
│ │ │ │ ├── exec.ts
│ │ │ │ ├── exit-gracefully.spec.ts
│ │ │ │ ├── exit-gracefully.ts
│ │ │ │ ├── exp-backoff.ts
│ │ │ │ ├── find-locale-paths.spec.ts
│ │ │ │ ├── find-locale-paths.ts
│ │ │ │ ├── fs.ts
│ │ │ │ ├── init-ci-cd.ts
│ │ │ │ ├── key-matching.spec.ts
│ │ │ │ ├── key-matching.ts
│ │ │ │ ├── lockfile.ts
│ │ │ │ ├── md5.ts
│ │ │ │ ├── observability.ts
│ │ │ │ ├── plutil-formatter.spec.ts
│ │ │ │ ├── plutil-formatter.ts
│ │ │ │ ├── settings.ts
│ │ │ │ ├── ui.ts
│ │ │ │ └── update-gitignore.ts
│ │ │ ├── compiler
│ │ │ │ └── index.ts
│ │ │ ├── locale-codes
│ │ │ │ └── index.ts
│ │ │ ├── react
│ │ │ │ ├── client.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── react-router.ts
│ │ │ │ └── rsc.ts
│ │ │ ├── sdk
│ │ │ │ └── index.ts
│ │ │ └── spec
│ │ │ └── index.ts
│ │ ├── tests
│ │ │ └── mock-storage.ts
│ │ ├── troubleshooting.md
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ ├── tsup.config.ts
│ │ ├── types
│ │ │ ├── vtt.d.ts
│ │ │ └── xliff.d.ts
│ │ ├── vitest.config.ts
│ │ └── WATCH_MODE.md
│ ├── compiler
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── _base.ts
│ │ │ ├── _const.ts
│ │ │ ├── _loader-utils.spec.ts
│ │ │ ├── _loader-utils.ts
│ │ │ ├── _utils.spec.ts
│ │ │ ├── _utils.ts
│ │ │ ├── client-dictionary-loader.ts
│ │ │ ├── i18n-directive.spec.ts
│ │ │ ├── i18n-directive.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── jsx-attribute-flag.spec.ts
│ │ │ ├── jsx-attribute-flag.ts
│ │ │ ├── jsx-attribute-scope-inject.spec.ts
│ │ │ ├── jsx-attribute-scope-inject.ts
│ │ │ ├── jsx-attribute-scopes-export.spec.ts
│ │ │ ├── jsx-attribute-scopes-export.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-fragment.spec.ts
│ │ │ ├── jsx-fragment.ts
│ │ │ ├── jsx-html-lang.spec.ts
│ │ │ ├── jsx-html-lang.ts
│ │ │ ├── jsx-provider.spec.ts
│ │ │ ├── jsx-provider.ts
│ │ │ ├── jsx-remove-attributes.spec.ts
│ │ │ ├── jsx-remove-attributes.ts
│ │ │ ├── jsx-root-flag.spec.ts
│ │ │ ├── jsx-root-flag.ts
│ │ │ ├── jsx-scope-flag.spec.ts
│ │ │ ├── jsx-scope-flag.ts
│ │ │ ├── jsx-scope-inject.spec.ts
│ │ │ ├── jsx-scope-inject.ts
│ │ │ ├── jsx-scopes-export.spec.ts
│ │ │ ├── jsx-scopes-export.ts
│ │ │ ├── lib
│ │ │ │ └── lcp
│ │ │ │ ├── api
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prompt.spec.ts
│ │ │ │ │ ├── prompt.ts
│ │ │ │ │ ├── provider-details.spec.ts
│ │ │ │ │ ├── provider-details.ts
│ │ │ │ │ ├── shots.ts
│ │ │ │ │ ├── xml2obj.spec.ts
│ │ │ │ │ └── xml2obj.ts
│ │ │ │ ├── api.spec.ts
│ │ │ │ ├── cache.spec.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── server.spec.ts
│ │ │ │ └── server.ts
│ │ │ ├── lingo-turbopack-loader.ts
│ │ │ ├── react-router-dictionary-loader.ts
│ │ │ ├── rsc-dictionary-loader.ts
│ │ │ └── utils
│ │ │ ├── ast-key.spec.ts
│ │ │ ├── ast-key.ts
│ │ │ ├── create-locale-import-map.spec.ts
│ │ │ ├── create-locale-import-map.ts
│ │ │ ├── env.spec.ts
│ │ │ ├── env.ts
│ │ │ ├── hash.spec.ts
│ │ │ ├── hash.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── invokations.spec.ts
│ │ │ ├── invokations.ts
│ │ │ ├── jsx-attribute-scope.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-content-whitespace.spec.ts
│ │ │ ├── jsx-content.spec.ts
│ │ │ ├── jsx-content.ts
│ │ │ ├── jsx-element.spec.ts
│ │ │ ├── jsx-element.ts
│ │ │ ├── jsx-expressions.test.ts
│ │ │ ├── jsx-expressions.ts
│ │ │ ├── jsx-functions.spec.ts
│ │ │ ├── jsx-functions.ts
│ │ │ ├── jsx-scope.spec.ts
│ │ │ ├── jsx-scope.ts
│ │ │ ├── jsx-variables.spec.ts
│ │ │ ├── jsx-variables.ts
│ │ │ ├── llm-api-key.ts
│ │ │ ├── llm-api-keys.spec.ts
│ │ │ ├── locales.spec.ts
│ │ │ ├── locales.ts
│ │ │ ├── module-params.spec.ts
│ │ │ ├── module-params.ts
│ │ │ ├── observability.spec.ts
│ │ │ ├── observability.ts
│ │ │ ├── rc.spec.ts
│ │ │ └── rc.ts
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ └── vitest.config.ts
│ ├── locales
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── names
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── integration.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── parser.spec.ts
│ │ │ ├── parser.ts
│ │ │ ├── types.ts
│ │ │ ├── validation.spec.ts
│ │ │ └── validation.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── react
│ │ ├── build.config.ts
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── context.spec.tsx
│ │ │ │ ├── context.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── locale-switcher.spec.tsx
│ │ │ │ ├── locale-switcher.tsx
│ │ │ │ ├── locale.spec.ts
│ │ │ │ ├── locale.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── core
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── const.ts
│ │ │ │ ├── get-dictionary.spec.ts
│ │ │ │ ├── get-dictionary.ts
│ │ │ │ └── index.ts
│ │ │ ├── react-router
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── rsc
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ └── test
│ │ │ └── setup.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── sdk
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── abort-controller.specs.ts
│ │ │ ├── index.spec.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ └── tsup.config.ts
│ └── spec
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── config.spec.ts
│ │ ├── config.ts
│ │ ├── formats.ts
│ │ ├── index.spec.ts
│ │ ├── index.ts
│ │ ├── json-schema.ts
│ │ ├── locales.spec.ts
│ │ └── locales.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── readme
│ ├── ar.md
│ ├── bn.md
│ ├── de.md
│ ├── en.md
│ ├── es.md
│ ├── fa.md
│ ├── fr.md
│ ├── he.md
│ ├── hi.md
│ ├── it.md
│ ├── ja.md
│ ├── ko.md
│ ├── pl.md
│ ├── pt-BR.md
│ ├── ru.md
│ ├── tr.md
│ ├── uk-UA.md
│ └── zh-Hans.md
├── readme.md
├── scripts
│ ├── docs
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── generate-cli-docs.ts
│ │ │ ├── generate-config-docs.ts
│ │ │ ├── json-schema
│ │ │ │ ├── markdown-renderer.test.ts
│ │ │ │ ├── markdown-renderer.ts
│ │ │ │ ├── parser.test.ts
│ │ │ │ ├── parser.ts
│ │ │ │ └── types.ts
│ │ │ ├── utils.test.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ └── packagist-publish.php
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/scripts/docs/src/json-schema/parser.ts:
--------------------------------------------------------------------------------
```typescript
import type {
JSONSchemaObject,
PropertyInfo,
SchemaParsingOptions,
} from "./types";
export function resolveRef(ref: string, root: unknown): unknown {
if (!ref.startsWith("#/")) return undefined;
const pathSegments = ref
.slice(2) // remove "#/"
.split("/")
.map((seg) => decodeURIComponent(seg));
let current = root;
for (const segment of pathSegments) {
if (current && typeof current === "object" && segment in current) {
current = (current as Record<string, unknown>)[segment];
} else {
return undefined;
}
}
return current;
}
export function sortPropertyKeys(
keys: string[],
requiredKeys: string[] = [],
customOrder: string[] = [],
): string[] {
const keySet = new Set(keys);
const requiredSet = new Set(requiredKeys);
// Start with custom ordered keys that exist in the properties
const orderedKeys: string[] = [];
for (const key of customOrder) {
if (keySet.has(key)) {
orderedKeys.push(key);
keySet.delete(key);
}
}
// Handle remaining keys - separate into required and optional
const remainingKeys = Array.from(keySet);
const remainingRequired: string[] = [];
const remainingOptional: string[] = [];
for (const key of remainingKeys) {
if (requiredSet.has(key)) {
remainingRequired.push(key);
} else {
remainingOptional.push(key);
}
}
// Sort alphabetically within each group
remainingRequired.sort((a, b) => a.localeCompare(b));
remainingOptional.sort((a, b) => a.localeCompare(b));
return [...orderedKeys, ...remainingRequired, ...remainingOptional];
}
export function inferType(schema: unknown, root: unknown): string {
if (!schema || typeof schema !== "object") return "unknown";
const schemaObj = schema as JSONSchemaObject;
// Handle $ref at the root level
if (schemaObj.$ref) {
return inferTypeFromRef(schemaObj.$ref, root);
}
// Handle type property
if (schemaObj.type) {
return inferTypeFromType(schemaObj, root);
}
// Handle union types (anyOf) at the top level
if (Array.isArray(schemaObj.anyOf)) {
return inferTypeFromAnyOf(schemaObj.anyOf, root);
}
return "unknown";
}
function inferTypeFromRef(ref: string, root: unknown): string {
const resolved = resolveRef(ref, root);
if (resolved) {
return inferType(resolved, root);
}
return String(ref).split("/").pop() || "unknown";
}
function inferTypeFromType(schemaObj: JSONSchemaObject, root: unknown): string {
// Handle array of types
if (Array.isArray(schemaObj.type)) {
return schemaObj.type.join(" | ");
}
if (schemaObj.type === "array") {
return inferTypeFromArray(schemaObj, root);
}
return String(schemaObj.type);
}
function inferTypeFromArray(
schemaObj: JSONSchemaObject,
root: unknown,
): string {
const items = schemaObj.items;
if (!items || typeof items !== "object") {
return "array";
}
const itemsObj = items as JSONSchemaObject;
// Array with $ref items
if (itemsObj.$ref) {
return `array of ${inferTypeFromRef(itemsObj.$ref, root)}`;
}
// Array with anyOf union types
if (Array.isArray(itemsObj.anyOf)) {
const types = itemsObj.anyOf.map((item) => inferType(item, root));
return `array of ${types.join(" | ")}`;
}
// Array with direct type(s)
if (itemsObj.type) {
if (Array.isArray(itemsObj.type)) {
return `array of ${itemsObj.type.join(" | ")}`;
}
return `array of ${itemsObj.type}`;
}
// Array of object or unknown
return `array of ${inferType(items, root)}`;
}
function inferTypeFromAnyOf(anyOfArr: unknown[], root: unknown): string {
const types = anyOfArr.map((item) => inferType(item, root));
return types.join(" | ");
}
function extractAllowedValues(schema: JSONSchemaObject): unknown[] | undefined {
if (!Array.isArray(schema.enum)) return undefined;
return Array.from(new Set(schema.enum)).sort((a, b) =>
String(a).localeCompare(String(b)),
);
}
function extractAllowedKeys(schema: JSONSchemaObject): string[] | undefined {
if (
!schema.propertyNames ||
typeof schema.propertyNames !== "object" ||
!Array.isArray(schema.propertyNames.enum)
) {
return undefined;
}
const allowedKeys = schema.propertyNames.enum as string[];
if (allowedKeys.length === 0) return undefined;
return Array.from(new Set(allowedKeys)).sort((a, b) => a.localeCompare(b));
}
export function parseProperty(
name: string,
schema: unknown,
required: boolean,
options: SchemaParsingOptions = {},
): PropertyInfo[] {
if (!schema || typeof schema !== "object") return [];
const { parentPath = "", rootSchema = schema } = options;
const schemaObj = schema as JSONSchemaObject;
const fullPath = parentPath ? `${parentPath}.${name}` : name;
const description = schemaObj.markdownDescription ?? schemaObj.description;
const property: PropertyInfo = {
name,
fullPath,
type: inferType(schema, rootSchema),
required,
description,
defaultValue: schemaObj.default,
allowedValues: extractAllowedValues(schemaObj),
allowedKeys: extractAllowedKeys(schemaObj),
};
const result: PropertyInfo[] = [property];
// Add children for nested properties
const children = parseNestedProperties(schema, fullPath, rootSchema);
if (children.length > 0) {
property.children = children;
}
return result;
}
function parseNestedProperties(
schema: unknown,
fullPath: string,
rootSchema: unknown,
): PropertyInfo[] {
if (!schema || typeof schema !== "object") return [];
const schemaObj = schema as JSONSchemaObject;
const children: PropertyInfo[] = [];
// Recurse into nested properties for objects
if (schemaObj.type === "object") {
if (schemaObj.properties && typeof schemaObj.properties === "object") {
const properties = schemaObj.properties;
const nestedRequired = Array.isArray(schemaObj.required)
? schemaObj.required
: [];
const sortedKeys = sortPropertyKeys(
Object.keys(properties),
nestedRequired,
);
for (const key of sortedKeys) {
children.push(
...parseProperty(key, properties[key], nestedRequired.includes(key), {
parentPath: fullPath,
rootSchema,
}),
);
}
}
// Handle schemas that use `additionalProperties`
if (
schemaObj.additionalProperties &&
typeof schemaObj.additionalProperties === "object"
) {
children.push(
...parseProperty("*", schemaObj.additionalProperties, false, {
parentPath: fullPath,
rootSchema,
}),
);
}
}
// Recurse into items for arrays of objects
if (schemaObj.type === "array" && schemaObj.items) {
const items = schemaObj.items as JSONSchemaObject;
const itemSchema = items.$ref
? resolveRef(items.$ref, rootSchema) || items
: items;
// Handle union types in array items (anyOf)
if (Array.isArray(items.anyOf)) {
items.anyOf.forEach((unionItem) => {
let resolvedItem = unionItem;
if (unionItem && typeof unionItem === "object") {
const unionItemObj = unionItem as JSONSchemaObject;
if (unionItemObj.$ref) {
resolvedItem =
resolveRef(unionItemObj.$ref, rootSchema) || unionItem;
}
}
if (
resolvedItem &&
typeof resolvedItem === "object" &&
((resolvedItem as JSONSchemaObject).type === "object" ||
(resolvedItem as JSONSchemaObject).properties)
) {
const resolvedItemObj = resolvedItem as JSONSchemaObject;
const nestedRequired = Array.isArray(resolvedItemObj.required)
? resolvedItemObj.required
: [];
const properties = resolvedItemObj.properties || {};
const sortedKeys = sortPropertyKeys(
Object.keys(properties),
nestedRequired,
);
for (const key of sortedKeys) {
children.push(
...parseProperty(
key,
properties[key],
nestedRequired.includes(key),
{
parentPath: `${fullPath}.*`,
rootSchema,
},
),
);
}
}
});
} else if (
itemSchema &&
typeof itemSchema === "object" &&
((itemSchema as JSONSchemaObject).type === "object" ||
(itemSchema as JSONSchemaObject).properties)
) {
// Handle regular object items (non-union)
const itemSchemaObj = itemSchema as JSONSchemaObject;
const nestedRequired = Array.isArray(itemSchemaObj.required)
? itemSchemaObj.required
: [];
const properties = itemSchemaObj.properties || {};
const sortedKeys = sortPropertyKeys(
Object.keys(properties),
nestedRequired,
);
for (const key of sortedKeys) {
children.push(
...parseProperty(key, properties[key], nestedRequired.includes(key), {
parentPath: `${fullPath}.*`,
rootSchema,
}),
);
}
// Handle additionalProperties inside array items if present
if (
itemSchemaObj.additionalProperties &&
typeof itemSchemaObj.additionalProperties === "object"
) {
children.push(
...parseProperty("*", itemSchemaObj.additionalProperties, false, {
parentPath: `${fullPath}.*`,
rootSchema,
}),
);
}
}
}
return children;
}
export function parseSchema(
schema: unknown,
options: SchemaParsingOptions = {},
): PropertyInfo[] {
if (!schema || typeof schema !== "object") {
return [];
}
const schemaObj = schema as JSONSchemaObject;
const { customOrder = [] } = options;
const rootRef = schemaObj.$ref as string | undefined;
const rootName: string = rootRef
? (rootRef.split("/").pop() ?? "I18nConfig")
: "I18nConfig";
let rootSchema: unknown;
if (
rootRef &&
schemaObj.definitions &&
typeof schemaObj.definitions === "object"
) {
const definitions = schemaObj.definitions as Record<string, unknown>;
rootSchema = definitions[rootName];
} else {
rootSchema = schema;
}
if (!rootSchema || typeof rootSchema !== "object") {
console.log(`Could not find root schema: ${rootName}`);
return [];
}
const rootSchemaObj = rootSchema as JSONSchemaObject;
const required = Array.isArray(rootSchemaObj.required)
? rootSchemaObj.required
: [];
if (
!rootSchemaObj.properties ||
typeof rootSchemaObj.properties !== "object"
) {
return [];
}
const properties = rootSchemaObj.properties;
const sortedKeys = sortPropertyKeys(
Object.keys(properties),
required,
customOrder,
);
const result: PropertyInfo[] = [];
for (const key of sortedKeys) {
result.push(
...parseProperty(key, properties[key], required.includes(key), {
rootSchema: schema,
}),
);
}
return result;
}
```
--------------------------------------------------------------------------------
/scripts/docs/src/json-schema/markdown-renderer.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, expect, it } from "vitest";
import type { RootContent } from "mdast";
import {
makeHeadingNode,
makeDescriptionNode,
makeTypeBulletNode,
makeRequiredBulletNode,
makeDefaultBulletNode,
makeEnumBulletNode,
makeAllowedKeysBulletNode,
makeBullets,
renderPropertyToMarkdown,
renderPropertiesToMarkdown,
renderMarkdown,
} from "./markdown-renderer";
import type { PropertyInfo } from "./types";
describe("makeHeadingNode", () => {
it("should create heading with correct depth for top-level property", () => {
const node = makeHeadingNode("version");
expect(node).toEqual({
type: "heading",
depth: 2,
children: [{ type: "inlineCode", value: "version" }],
});
});
it("should create deeper heading for nested property", () => {
const node = makeHeadingNode("config.debug.level");
expect(node).toEqual({
type: "heading",
depth: 4,
children: [{ type: "inlineCode", value: "config.debug.level" }],
});
});
it("should cap heading depth at 6", () => {
const node = makeHeadingNode("a.b.c.d.e.f.g.h");
expect(node).toEqual({
type: "heading",
depth: 6,
children: [{ type: "inlineCode", value: "a.b.c.d.e.f.g.h" }],
});
});
});
describe("makeDescriptionNode", () => {
it("should create paragraph node for description", () => {
const node = makeDescriptionNode("This is a description");
expect(node).toEqual({
type: "paragraph",
children: [{ type: "text", value: "This is a description" }],
});
});
it("should return null for empty description", () => {
expect(makeDescriptionNode("")).toBeNull();
expect(makeDescriptionNode(undefined)).toBeNull();
});
});
describe("makeTypeBulletNode", () => {
it("should create list item with type information", () => {
const node = makeTypeBulletNode("string");
expect(node).toEqual({
type: "listItem",
children: [
{
type: "paragraph",
children: [
{ type: "text", value: "Type: " },
{ type: "inlineCode", value: "string" },
],
},
],
});
});
});
describe("makeRequiredBulletNode", () => {
it("should create list item for required property", () => {
const node = makeRequiredBulletNode(true);
expect(node).toEqual({
type: "listItem",
children: [
{
type: "paragraph",
children: [
{ type: "text", value: "Required: " },
{ type: "inlineCode", value: "yes" },
],
},
],
});
});
it("should create list item for optional property", () => {
const node = makeRequiredBulletNode(false);
expect(node).toEqual({
type: "listItem",
children: [
{
type: "paragraph",
children: [
{ type: "text", value: "Required: " },
{ type: "inlineCode", value: "no" },
],
},
],
});
});
});
describe("makeDefaultBulletNode", () => {
it("should create list item for default value", () => {
const node = makeDefaultBulletNode("default value");
expect(node).toEqual({
type: "listItem",
children: [
{
type: "paragraph",
children: [
{ type: "text", value: "Default: " },
{ type: "inlineCode", value: '"default value"' },
],
},
],
});
});
it("should handle numeric default", () => {
const node = makeDefaultBulletNode(42);
expect(node).toBeDefined();
if (
node &&
"children" in node &&
node.children[0] &&
"children" in node.children[0]
) {
expect(node.children[0].children[1]).toEqual({
type: "inlineCode",
value: "42",
});
}
});
it("should return null for undefined default", () => {
expect(makeDefaultBulletNode(undefined)).toBeNull();
});
});
describe("makeEnumBulletNode", () => {
it("should create list item with enum values", () => {
const node = makeEnumBulletNode(["red", "green", "blue"]);
expect(node).toEqual({
type: "listItem",
children: [
{
type: "paragraph",
children: [{ type: "text", value: "Allowed values:" }],
},
{
type: "list",
ordered: false,
spread: false,
children: [
{
type: "listItem",
children: [
{
type: "paragraph",
children: [{ type: "inlineCode", value: "red" }],
},
],
},
{
type: "listItem",
children: [
{
type: "paragraph",
children: [{ type: "inlineCode", value: "green" }],
},
],
},
{
type: "listItem",
children: [
{
type: "paragraph",
children: [{ type: "inlineCode", value: "blue" }],
},
],
},
],
},
],
});
});
it("should return null for empty array", () => {
expect(makeEnumBulletNode([])).toBeNull();
expect(makeEnumBulletNode(undefined)).toBeNull();
});
});
describe("makeAllowedKeysBulletNode", () => {
it("should create list item with allowed keys", () => {
const node = makeAllowedKeysBulletNode(["key1", "key2"]);
expect(node).toEqual({
type: "listItem",
children: [
{
type: "paragraph",
children: [{ type: "text", value: "Allowed keys:" }],
},
{
type: "list",
ordered: false,
spread: false,
children: [
{
type: "listItem",
children: [
{
type: "paragraph",
children: [{ type: "inlineCode", value: "key1" }],
},
],
},
{
type: "listItem",
children: [
{
type: "paragraph",
children: [{ type: "inlineCode", value: "key2" }],
},
],
},
],
},
],
});
});
it("should return null for empty/undefined array", () => {
expect(makeAllowedKeysBulletNode([])).toBeNull();
expect(makeAllowedKeysBulletNode(undefined)).toBeNull();
});
});
describe("makeBullets", () => {
it("should create all relevant bullets for a property", () => {
const property: PropertyInfo = {
name: "test",
fullPath: "test",
type: "string",
required: true,
defaultValue: "default",
allowedValues: ["a", "b"],
allowedKeys: ["key1"],
};
const bullets = makeBullets(property);
expect(bullets).toHaveLength(5); // type, required, default, enum, allowedKeys
});
it("should only create necessary bullets", () => {
const property: PropertyInfo = {
name: "test",
fullPath: "test",
type: "string",
required: false,
};
const bullets = makeBullets(property);
expect(bullets).toHaveLength(2); // only type and required
});
});
describe("renderPropertyToMarkdown", () => {
it("should render simple property", () => {
const property: PropertyInfo = {
name: "version",
fullPath: "version",
type: "string",
required: true,
description: "The version number",
};
const nodes = renderPropertyToMarkdown(property);
expect(nodes).toHaveLength(3); // heading, description, bullets list
expect(nodes[0].type).toBe("heading");
expect(nodes[1].type).toBe("paragraph");
expect(nodes[2].type).toBe("list");
});
it("should render property without description", () => {
const property: PropertyInfo = {
name: "test",
fullPath: "test",
type: "string",
required: false,
};
const nodes = renderPropertyToMarkdown(property);
expect(nodes).toHaveLength(2); // heading, bullets list (no description)
});
it("should render property with children", () => {
const property: PropertyInfo = {
name: "config",
fullPath: "config",
type: "object",
required: true,
children: [
{
name: "debug",
fullPath: "config.debug",
type: "boolean",
required: false,
},
],
};
const nodes = renderPropertyToMarkdown(property);
expect(nodes.length).toBeGreaterThan(2); // includes child nodes
// Find child heading node
const childHeading = nodes.find(
(node: RootContent) =>
node.type === "heading" &&
node.type === "heading" &&
"children" in node &&
node.children[0] &&
"value" in node.children[0] &&
node.children[0].value === "config.debug",
);
expect(childHeading).toBeDefined();
});
});
describe("renderPropertiesToMarkdown", () => {
it("should render complete document with header", () => {
const properties: PropertyInfo[] = [
{
name: "version",
fullPath: "version",
type: "string",
required: true,
},
];
const nodes = renderPropertiesToMarkdown(properties);
expect(nodes[0]).toEqual({
type: "paragraph",
children: [
{
type: "text",
value:
"This page describes the complete list of properties that are available within the ",
},
{ type: "inlineCode", value: "i18n.json" },
{
type: "text",
value: " configuration file. This file is used by ",
},
{
type: "strong",
children: [{ type: "text", value: "Lingo.dev CLI" }],
},
{
type: "text",
value: " to configure the behavior of the translation pipeline.",
},
],
});
expect(nodes[1].type).toBe("heading"); // version heading
});
it("should add spacing between top-level properties", () => {
const properties: PropertyInfo[] = [
{
name: "prop1",
fullPath: "prop1",
type: "string",
required: true,
},
{
name: "prop2",
fullPath: "prop2",
type: "string",
required: false,
},
];
const nodes = renderPropertiesToMarkdown(properties);
// Should have spacing paragraphs between properties
const spacingNodes = nodes.filter(
(node: RootContent) =>
node.type === "paragraph" &&
"children" in node &&
node.children[0] &&
"value" in node.children[0] &&
node.children[0].value === "",
);
expect(spacingNodes).toHaveLength(2); // One after each property
});
});
describe("renderMarkdown", () => {
it("should generate valid markdown string", () => {
const properties: PropertyInfo[] = [
{
name: "version",
fullPath: "version",
type: "string",
required: true,
description: "The version",
},
];
const markdown = renderMarkdown(properties);
expect(typeof markdown).toBe("string");
expect(markdown).toContain("---\ntitle: i18n.json properties\n---");
expect(markdown).toContain(
"This page describes the complete list of properties",
);
expect(markdown).toContain("## `version`");
expect(markdown).toContain("The version");
expect(markdown).toContain("* Type: `string`");
expect(markdown).toContain("* Required: `yes`");
});
it("should handle empty properties array", () => {
const markdown = renderMarkdown([]);
expect(markdown).toContain("---\ntitle: i18n.json properties\n---");
expect(markdown).toContain("This page describes the complete list");
});
});
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/xcode-xcstrings-icu.spec.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from "vitest";
import {
xcstringsToPluralWithMeta,
pluralWithMetaToXcstrings,
type PluralWithMetadata,
} from "./xcode-xcstrings-icu";
describe("loaders/xcode-xcstrings-icu", () => {
describe("xcstringsToPluralWithMeta", () => {
it("should convert simple plural forms to ICU", () => {
const input = {
one: "1 item",
other: "%d items",
};
const result = xcstringsToPluralWithMeta(input, "en");
expect(result.icu).toBe("{count, plural, one {1 item} other {# items}}");
expect(result._meta).toEqual({
variables: {
count: {
format: "%d",
role: "plural",
},
},
});
});
it("should convert optional zero form to exact match =0 for English", () => {
const input = {
zero: "No items",
one: "1 item",
other: "%d items",
};
const result = xcstringsToPluralWithMeta(input, "en");
// English required forms: one, other
// "zero" is optional, so it becomes "=0"
expect(result.icu).toBe(
"{count, plural, =0 {No items} one {1 item} other {# items}}",
);
expect(result._meta?.variables.count.format).toBe("%d");
});
it("should convert optional zero form to exact match =0 for Russian", () => {
const input = {
zero: "Нет элементов",
one: "1 элемент",
few: "%d элемента",
many: "%d элементов",
other: "%d элемента",
};
const result = xcstringsToPluralWithMeta(input, "ru");
// Russian required forms: one, few, many, other
// "zero" is optional, so it becomes "=0"
expect(result.icu).toBe(
"{count, plural, =0 {Нет элементов} one {1 элемент} few {# элемента} many {# элементов} other {# элемента}}",
);
expect(result._meta?.variables.count.format).toBe("%d");
});
it("should preserve float format specifiers", () => {
const input = {
one: "%.1f mile",
other: "%.1f miles",
};
const result = xcstringsToPluralWithMeta(input, "en");
expect(result.icu).toBe("{count, plural, one {# mile} other {# miles}}");
expect(result._meta).toEqual({
variables: {
count: {
format: "%.1f",
role: "plural",
},
},
});
});
it("should preserve %lld format specifier", () => {
const input = {
one: "1 photo",
other: "%lld photos",
};
const result = xcstringsToPluralWithMeta(input, "en");
expect(result.icu).toBe(
"{count, plural, one {1 photo} other {# photos}}",
);
expect(result._meta).toEqual({
variables: {
count: {
format: "%lld",
role: "plural",
},
},
});
});
it("should handle multiple variables", () => {
const input = {
one: "%@ uploaded 1 photo",
other: "%@ uploaded %d photos",
};
const result = xcstringsToPluralWithMeta(input, "en");
expect(result.icu).toBe(
"{count, plural, one {{var0} uploaded 1 photo} other {{var0} uploaded # photos}}",
);
expect(result._meta).toEqual({
variables: {
var0: {
format: "%@",
role: "other",
},
count: {
format: "%d",
role: "plural",
},
},
});
});
it("should handle three variables", () => {
const input = {
one: "%@ uploaded 1 photo to %@",
other: "%@ uploaded %d photos to %@",
};
const result = xcstringsToPluralWithMeta(input, "en");
// Note: This is a known limitation - when forms have different numbers of placeholders,
// the conversion may not be perfect. The "one" form has 2 placeholders but we map 3 variables.
// In practice, this edge case is rare as plural forms usually have consistent placeholder counts.
expect(result.icu).toContain("{var0} uploaded");
expect(result._meta?.variables).toEqual({
var0: { format: "%@", role: "other" },
count: { format: "%d", role: "plural" },
var1: { format: "%@", role: "other" },
});
});
it("should handle %.2f precision", () => {
const input = {
one: "%.2f kilometer",
other: "%.2f kilometers",
};
const result = xcstringsToPluralWithMeta(input, "en");
expect(result.icu).toBe(
"{count, plural, one {# kilometer} other {# kilometers}}",
);
expect(result._meta?.variables.count.format).toBe("%.2f");
});
it("should throw error for empty input", () => {
expect(() => xcstringsToPluralWithMeta({}, "en")).toThrow(
"pluralForms cannot be empty",
);
});
});
describe("pluralWithMetaToXcstrings", () => {
it("should convert ICU back to xcstrings format", () => {
const input: PluralWithMetadata = {
icu: "{count, plural, one {1 item} other {# items}}",
_meta: {
variables: {
count: {
format: "%d",
role: "plural",
},
},
},
};
const result = pluralWithMetaToXcstrings(input);
expect(result).toEqual({
one: "1 item",
other: "%d items",
});
});
it("should restore float format specifiers", () => {
const input: PluralWithMetadata = {
icu: "{count, plural, one {# mile} other {# miles}}",
_meta: {
variables: {
count: {
format: "%.1f",
role: "plural",
},
},
},
};
const result = pluralWithMetaToXcstrings(input);
expect(result).toEqual({
one: "%.1f mile",
other: "%.1f miles",
});
});
it("should restore %lld format", () => {
const input: PluralWithMetadata = {
icu: "{count, plural, one {1 photo} other {# photos}}",
_meta: {
variables: {
count: {
format: "%lld",
role: "plural",
},
},
},
};
const result = pluralWithMetaToXcstrings(input);
expect(result).toEqual({
one: "1 photo",
other: "%lld photos",
});
});
it("should handle multiple variables", () => {
const input: PluralWithMetadata = {
icu: "{count, plural, one {{userName} uploaded 1 photo} other {{userName} uploaded # photos}}",
_meta: {
variables: {
userName: { format: "%@", role: "other" },
count: { format: "%d", role: "plural" },
},
},
};
const result = pluralWithMetaToXcstrings(input);
expect(result).toEqual({
one: "%@ uploaded 1 photo",
other: "%@ uploaded %d photos",
});
});
it("should convert exact match =0 back to zero form", () => {
const input: PluralWithMetadata = {
icu: "{count, plural, =0 {No items} one {1 item} other {# items}}",
_meta: {
variables: {
count: { format: "%d", role: "plural" },
},
},
};
const result = pluralWithMetaToXcstrings(input);
expect(result).toEqual({
zero: "No items",
one: "1 item",
other: "%d items",
});
});
it("should use default format when metadata is missing", () => {
const input: PluralWithMetadata = {
icu: "{count, plural, one {1 item} other {# items}}",
};
const result = pluralWithMetaToXcstrings(input);
expect(result).toEqual({
one: "1 item",
other: "%lld items",
});
});
it("should throw error for invalid ICU format", () => {
const input: PluralWithMetadata = {
icu: "not valid ICU",
};
expect(() => pluralWithMetaToXcstrings(input)).toThrow();
});
});
describe("round-trip conversion", () => {
it("should preserve format through round-trip", () => {
const original = {
one: "1 item",
other: "%d items",
};
const icu = xcstringsToPluralWithMeta(original, "en");
const restored = pluralWithMetaToXcstrings(icu);
expect(restored).toEqual(original);
});
it("should preserve float precision through round-trip", () => {
const original = {
one: "%.2f mile",
other: "%.2f miles",
};
const icu = xcstringsToPluralWithMeta(original, "en");
const restored = pluralWithMetaToXcstrings(icu);
expect(restored).toEqual(original);
});
it("should preserve multiple variables through round-trip", () => {
const original = {
one: "%@ uploaded 1 photo",
other: "%@ uploaded %d photos",
};
const icu = xcstringsToPluralWithMeta(original, "en");
const restored = pluralWithMetaToXcstrings(icu);
expect(restored).toEqual(original);
});
it("should preserve zero form through round-trip", () => {
const original = {
zero: "No items",
one: "1 item",
other: "%lld items",
};
const icu = xcstringsToPluralWithMeta(original, "en");
const restored = pluralWithMetaToXcstrings(icu);
expect(restored).toEqual(original);
});
});
describe("translation simulation", () => {
it("should handle English to Russian translation", () => {
// Source (English)
const englishForms = {
one: "1 item",
other: "%d items",
};
const englishICU = xcstringsToPluralWithMeta(englishForms, "en");
// Simulate backend translation (English → Russian)
// Backend expands 2 forms to 4 forms
const russianICU: PluralWithMetadata = {
icu: "{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}",
_meta: englishICU._meta, // Metadata preserved
};
const russianForms = pluralWithMetaToXcstrings(russianICU);
expect(russianForms).toEqual({
one: "%d элемент",
few: "%d элемента",
many: "%d элементов",
other: "%d элемента",
});
});
it("should handle Chinese to Arabic translation", () => {
// Source (Chinese - no plurals)
const chineseForms = {
other: "%d 个项目",
};
const chineseICU = xcstringsToPluralWithMeta(chineseForms, "zh");
// Simulate backend translation (Chinese → Arabic)
// Backend expands 1 form to 6 forms
const arabicICU: PluralWithMetadata = {
icu: "{count, plural, zero {لا توجد مشاريع} one {مشروع واحد} two {مشروعان} few {# مشاريع} many {# مشروعًا} other {# مشروع}}",
_meta: chineseICU._meta,
};
const arabicForms = pluralWithMetaToXcstrings(arabicICU);
expect(arabicForms).toEqual({
zero: "لا توجد مشاريع",
one: "مشروع واحد",
two: "مشروعان",
few: "%d مشاريع",
many: "%d مشروعًا",
other: "%d مشروع",
});
});
it("should handle variable reordering in translation", () => {
// Source (English)
const englishForms = {
one: "%@ uploaded 1 photo",
other: "%@ uploaded %d photos",
};
const englishICU = xcstringsToPluralWithMeta(englishForms, "en");
// Simulate backend translation with variable reordering
const russianICU: PluralWithMetadata = {
icu: "{count, plural, one {{var0} загрузил 1 фото} few {{var0} загрузил # фото} many {{var0} загрузил # фотографий} other {{var0} загрузил # фотографии}}",
_meta: englishICU._meta, // Metadata preserved
};
const russianForms = pluralWithMetaToXcstrings(russianICU);
expect(russianForms).toEqual({
one: "%@ загрузил 1 фото",
few: "%@ загрузил %d фото",
many: "%@ загрузил %d фотографий",
other: "%@ загрузил %d фотографии",
});
});
});
});
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/cmd/run/execute.ts:
--------------------------------------------------------------------------------
```typescript
import chalk from "chalk";
import { Listr, ListrTask } from "listr2";
import pLimit, { LimitFunction } from "p-limit";
import _ from "lodash";
import { minimatch } from "minimatch";
import { colors } from "../../constants";
import { CmdRunContext, CmdRunTask, CmdRunTaskResult } from "./_types";
import { commonTaskRendererOptions } from "./_const";
import createBucketLoader from "../../loaders";
import { createDeltaProcessor, Delta } from "../../utils/delta";
const MAX_WORKER_COUNT = 10;
export default async function execute(input: CmdRunContext) {
const effectiveConcurrency = Math.min(
input.flags.concurrency,
input.tasks.length,
MAX_WORKER_COUNT,
);
console.log(chalk.hex(colors.orange)(`[Localization]`));
return new Listr<CmdRunContext>(
[
{
title: "Initializing localization engine",
task: async (ctx, task) => {
task.title = `Localization engine ${chalk.hex(colors.green)(
"ready",
)} (${ctx.localizer!.id})`;
},
},
{
title: `Processing localization tasks ${chalk.dim(
`(tasks: ${input.tasks.length}, concurrency: ${effectiveConcurrency})`,
)}`,
task: async (ctx, task) => {
if (input.tasks.length < 1) {
task.title = `Skipping, nothing to localize.`;
task.skip();
return;
}
// Preload checksums for all unique bucket path patterns before starting any workers
const initialChecksumsMap = new Map<string, Record<string, string>>();
const uniqueBucketPatterns = _.uniq(
ctx.tasks.map((t) => t.bucketPathPattern),
);
for (const bucketPathPattern of uniqueBucketPatterns) {
const deltaProcessor = createDeltaProcessor(bucketPathPattern);
const checksums = await deltaProcessor.loadChecksums();
initialChecksumsMap.set(bucketPathPattern, checksums);
}
const i18nLimiter = pLimit(effectiveConcurrency);
const ioLimiter = pLimit(1);
const perFileIoLimiters = new Map<string, LimitFunction>();
const getFileIoLimiter = (
bucketPathPattern: string,
): LimitFunction => {
const lockKey = bucketPathPattern;
if (!perFileIoLimiters.has(lockKey)) {
perFileIoLimiters.set(lockKey, pLimit(1));
}
return perFileIoLimiters.get(lockKey)!;
};
const workersCount = effectiveConcurrency;
const workerTasks: ListrTask[] = [];
for (let i = 0; i < workersCount; i++) {
const assignedTasks = ctx.tasks.filter(
(_, idx) => idx % workersCount === i,
);
workerTasks.push(
createWorkerTask({
ctx,
assignedTasks,
ioLimiter,
i18nLimiter,
initialChecksumsMap,
getFileIoLimiter,
onDone() {
task.title = createExecutionProgressMessage(ctx);
},
}),
);
}
return task.newListr(workerTasks, {
concurrent: true,
exitOnError: false,
rendererOptions: {
...commonTaskRendererOptions,
collapseSubtasks: true,
},
});
},
},
],
{
exitOnError: false,
rendererOptions: commonTaskRendererOptions,
},
).run(input);
}
function createWorkerStatusMessage(args: {
assignedTask: CmdRunTask;
percentage: number;
}) {
const displayPath = args.assignedTask.bucketPathPattern.replace(
"[locale]",
args.assignedTask.targetLocale,
);
return `[${chalk.hex(colors.yellow)(
`${args.percentage}%`,
)}] Processing: ${chalk.dim(displayPath)} (${chalk.hex(colors.yellow)(
args.assignedTask.sourceLocale,
)} -> ${chalk.hex(colors.yellow)(args.assignedTask.targetLocale)})`;
}
function createExecutionProgressMessage(ctx: CmdRunContext) {
const succeededTasksCount = countTasks(
ctx,
(_t, result) => result.status === "success",
);
const failedTasksCount = countTasks(
ctx,
(_t, result) => result.status === "error",
);
const skippedTasksCount = countTasks(
ctx,
(_t, result) => result.status === "skipped",
);
return `Processed ${chalk.green(succeededTasksCount)}/${
ctx.tasks.length
}, Failed ${chalk.red(failedTasksCount)}, Skipped ${chalk.dim(
skippedTasksCount,
)}`;
}
function createLoaderForTask(assignedTask: CmdRunTask) {
const bucketLoader = createBucketLoader(
assignedTask.bucketType,
assignedTask.bucketPathPattern,
{
defaultLocale: assignedTask.sourceLocale,
injectLocale: assignedTask.injectLocale,
formatter: assignedTask.formatter,
},
assignedTask.lockedKeys,
assignedTask.lockedPatterns,
assignedTask.ignoredKeys,
);
bucketLoader.setDefaultLocale(assignedTask.sourceLocale);
return bucketLoader;
}
function createWorkerTask(args: {
ctx: CmdRunContext;
assignedTasks: CmdRunTask[];
ioLimiter: LimitFunction;
i18nLimiter: LimitFunction;
onDone: () => void;
initialChecksumsMap: Map<string, Record<string, string>>;
getFileIoLimiter: (bucketPathPattern: string) => LimitFunction;
}): ListrTask {
return {
title: "Initializing...",
task: async (_subCtx: any, subTask: any) => {
for (const assignedTask of args.assignedTasks) {
subTask.title = createWorkerStatusMessage({
assignedTask,
percentage: 0,
});
const bucketLoader = createLoaderForTask(assignedTask);
const deltaProcessor = createDeltaProcessor(
assignedTask.bucketPathPattern,
);
// Get initial checksums from the preloaded map
const initialChecksums =
args.initialChecksumsMap.get(assignedTask.bucketPathPattern) || {};
const taskResult = await args.i18nLimiter(async () => {
try {
// Pull operations must be serialized per-file for single-file formats
// where multiple locales share the same file (e.g., xcode-xcstrings)
const fileIoLimiter = args.getFileIoLimiter(
assignedTask.bucketPathPattern,
);
const sourceData = await fileIoLimiter(async () =>
bucketLoader.pull(assignedTask.sourceLocale),
);
const hints = await fileIoLimiter(async () =>
bucketLoader.pullHints(),
);
const targetData = await fileIoLimiter(async () =>
bucketLoader.pull(assignedTask.targetLocale),
);
const delta = await deltaProcessor.calculateDelta({
sourceData,
targetData,
checksums: initialChecksums,
});
const processableData = _.chain(sourceData)
.entries()
.filter(
([key, value]) =>
delta.added.includes(key) ||
delta.updated.includes(key) ||
!!args.ctx.flags.force,
)
.filter(
([key]) =>
!assignedTask.onlyKeys.length ||
assignedTask.onlyKeys?.some((pattern) =>
minimatch(key, pattern),
),
)
.fromPairs()
.value();
if (!Object.keys(processableData).length) {
await fileIoLimiter(async () => {
// re-push in case some of the unlocalizable / meta data changed
await bucketLoader.push(assignedTask.targetLocale, targetData);
});
return {
status: "skipped",
pathPattern: assignedTask.bucketPathPattern,
sourceLocale: assignedTask.sourceLocale,
targetLocale: assignedTask.targetLocale,
} satisfies CmdRunTaskResult;
}
const relevantHints = _.pick(hints, Object.keys(processableData));
const processedTargetData = await args.ctx.localizer!.localize(
{
sourceLocale: assignedTask.sourceLocale,
targetLocale: assignedTask.targetLocale,
sourceData,
targetData,
processableData,
hints: relevantHints,
},
async (progress, _sourceChunk, processedChunk) => {
// write translated chunks as they are received from LLM
await fileIoLimiter(async () => {
// pull the latest source data before pushing for buckets that store all locales in a single file
await bucketLoader.pull(assignedTask.sourceLocale);
// pull the latest target data to include all already processed chunks
const latestTargetData = await bucketLoader.pull(
assignedTask.targetLocale,
);
// add the new chunk to target data
const _partialData = _.merge(
{},
latestTargetData,
processedChunk,
);
// process renamed keys
const finalChunkTargetData = processRenamedKeys(
delta,
_partialData,
);
// push final chunk to the target locale
await bucketLoader.push(
assignedTask.targetLocale,
finalChunkTargetData,
);
});
subTask.title = createWorkerStatusMessage({
assignedTask,
percentage: progress,
});
},
);
const finalTargetData = _.merge(
{},
sourceData,
targetData,
processedTargetData,
);
const finalRenamedTargetData = processRenamedKeys(
delta,
finalTargetData,
);
await fileIoLimiter(async () => {
// not all localizers have progress callback (eg. explicit localizer),
// the final target data might not be pushed yet - push now to ensure it's up to date
await bucketLoader.pull(assignedTask.sourceLocale);
await bucketLoader.push(
assignedTask.targetLocale,
finalRenamedTargetData,
);
const checksums =
await deltaProcessor.createChecksums(sourceData);
if (!args.ctx.flags.targetLocale?.length) {
await deltaProcessor.saveChecksums(checksums);
}
});
return {
status: "success",
pathPattern: assignedTask.bucketPathPattern,
sourceLocale: assignedTask.sourceLocale,
targetLocale: assignedTask.targetLocale,
} satisfies CmdRunTaskResult;
} catch (error) {
return {
status: "error",
error: error as Error,
pathPattern: assignedTask.bucketPathPattern,
sourceLocale: assignedTask.sourceLocale,
targetLocale: assignedTask.targetLocale,
} satisfies CmdRunTaskResult;
}
});
args.ctx.results.set(assignedTask, taskResult);
}
subTask.title = "Done";
},
};
}
function countTasks(
ctx: CmdRunContext,
predicate: (task: CmdRunTask, result: CmdRunTaskResult) => boolean,
) {
return Array.from(ctx.results.entries()).filter(([task, result]) =>
predicate(task, result),
).length;
}
function processRenamedKeys(delta: Delta, targetData: Record<string, string>) {
return _.chain(targetData)
.entries()
.map(([key, value]) => {
const renaming = delta.renamed.find(([oldKey]) => oldKey === key);
if (!renaming) {
return [key, value];
}
return [renaming[1], value];
})
.fromPairs()
.value();
}
```
--------------------------------------------------------------------------------
/packages/spec/src/config.ts:
--------------------------------------------------------------------------------
```typescript
import Z from "zod";
import { localeCodeSchema } from "./locales";
import { bucketTypeSchema } from "./formats";
// common
export const localeSchema = Z.object({
source: localeCodeSchema.describe(
"Primary source locale code of your content (e.g. 'en', 'en-US', 'pt_BR', or 'pt-rBR'). Must be one of the supported locale codes – either a short ISO-639 language code or a full locale identifier using '-', '_' or Android '-r' notation.",
),
targets: Z.array(localeCodeSchema).describe(
"List of target locale codes to translate to.",
),
}).describe("Locale configuration block.");
// factories
type ConfigDefinition<
T extends Z.ZodRawShape,
_P extends Z.ZodRawShape = any,
> = {
schema: Z.ZodObject<T>;
defaultValue: Z.infer<Z.ZodObject<T>>;
parse: (rawConfig: unknown) => Z.infer<Z.ZodObject<T>>;
};
const createConfigDefinition = <
T extends Z.ZodRawShape,
_P extends Z.ZodRawShape = any,
>(
definition: ConfigDefinition<T, _P>,
) => definition;
type ConfigDefinitionExtensionParams<
T extends Z.ZodRawShape,
P extends Z.ZodRawShape,
> = {
createSchema: (baseSchema: Z.ZodObject<P>) => Z.ZodObject<T>;
createDefaultValue: (
baseDefaultValue: Z.infer<Z.ZodObject<P>>,
) => Z.infer<Z.ZodObject<T>>;
createUpgrader: (
config: Z.infer<Z.ZodObject<P>>,
schema: Z.ZodObject<T>,
defaultValue: Z.infer<Z.ZodObject<T>>,
) => Z.infer<Z.ZodObject<T>>;
};
const extendConfigDefinition = <
T extends Z.ZodRawShape,
P extends Z.ZodRawShape,
>(
definition: ConfigDefinition<P, any>,
params: ConfigDefinitionExtensionParams<T, P>,
) => {
const schema = params.createSchema(definition.schema);
const defaultValue = params.createDefaultValue(definition.defaultValue);
const upgrader = (config: Z.infer<Z.ZodObject<P>>) =>
params.createUpgrader(config, schema, defaultValue);
return createConfigDefinition({
schema,
defaultValue,
parse: (rawConfig) => {
const safeResult = schema.safeParse(rawConfig);
if (safeResult.success) {
return safeResult.data;
}
const localeErrors = safeResult.error.errors
.filter((issue) => issue.message.includes("Invalid locale code"))
.map((issue) => {
let unsupportedLocale = "";
const path = issue.path;
const config = rawConfig as { locale?: { [key: string]: any } };
if (config.locale) {
unsupportedLocale = path.reduce<any>((acc, key) => {
if (acc && typeof acc === "object" && key in acc) {
return acc[key];
}
return acc;
}, config.locale);
}
return `Unsupported locale: ${unsupportedLocale}`;
});
if (localeErrors.length > 0) {
throw new Error(`\n${localeErrors.join("\n")}`);
}
const baseConfig = definition.parse(rawConfig);
const result = upgrader(baseConfig);
return result;
},
});
};
// any -> v0
const configV0Schema = Z.object({
version: Z.union([Z.number(), Z.string()])
.default(0)
.describe("The version number of the schema."),
});
export const configV0Definition = createConfigDefinition({
schema: configV0Schema,
defaultValue: { version: 0 },
parse: (rawConfig) => {
return configV0Schema.parse(rawConfig);
},
});
// v0 -> v1
export const configV1Definition = extendConfigDefinition(configV0Definition, {
createSchema: (baseSchema) =>
baseSchema.extend({
locale: localeSchema,
buckets: Z.record(Z.string(), bucketTypeSchema)
.default({})
.describe(
"Mapping of source file paths (glob patterns) to bucket types.",
)
.optional(),
}),
createDefaultValue: () => ({
version: 1,
locale: {
source: "en" as const,
targets: ["es" as const],
},
buckets: {},
}),
createUpgrader: () => ({
version: 1,
locale: {
source: "en" as const,
targets: ["es" as const],
},
buckets: {},
}),
});
// v1 -> v1.1
export const configV1_1Definition = extendConfigDefinition(configV1Definition, {
createSchema: (baseSchema) =>
baseSchema.extend({
buckets: Z.record(
bucketTypeSchema,
Z.object({
include: Z.array(Z.string())
.default([])
.describe(
"File paths or glob patterns to include for this bucket.",
),
exclude: Z.array(Z.string())
.default([])
.optional()
.describe(
"File paths or glob patterns to exclude from this bucket.",
),
}),
).default({}),
}),
createDefaultValue: (baseDefaultValue) => ({
...baseDefaultValue,
version: 1.1,
buckets: {},
}),
createUpgrader: (oldConfig, schema) => {
const upgradedConfig: Z.infer<typeof schema> = {
...oldConfig,
version: 1.1,
buckets: {},
};
// Transform buckets from v1 to v1.1 format
if (oldConfig.buckets) {
for (const [bucketPath, bucketType] of Object.entries(
oldConfig.buckets,
)) {
if (!upgradedConfig.buckets[bucketType]) {
upgradedConfig.buckets[bucketType] = {
include: [],
};
}
upgradedConfig.buckets[bucketType]?.include.push(bucketPath);
}
}
return upgradedConfig;
},
});
// v1.1 -> v1.2
// Changes: Add "extraSource" optional field to the locale node of the config
export const configV1_2Definition = extendConfigDefinition(
configV1_1Definition,
{
createSchema: (baseSchema) =>
baseSchema.extend({
locale: localeSchema.extend({
extraSource: localeCodeSchema
.optional()
.describe(
"Optional extra source locale code used as fallback during translation.",
),
}),
}),
createDefaultValue: (baseDefaultValue) => ({
...baseDefaultValue,
version: 1.2,
}),
createUpgrader: (oldConfig) => ({
...oldConfig,
version: 1.2,
}),
},
);
// v1.2 -> v1.3
// Changes: Support both string paths and {path, delimiter} objects in bucket include/exclude arrays
export const bucketItemSchema = Z.object({
path: Z.string().describe("Path pattern containing a [locale] placeholder."),
delimiter: Z.union([Z.literal("-"), Z.literal("_"), Z.literal(null)])
.optional()
.describe(
"Delimiter that replaces the [locale] placeholder in the path (default: no delimiter).",
),
}).describe(
"Bucket path item. Either a string path or an object specifying path and delimiter.",
);
export type BucketItem = Z.infer<typeof bucketItemSchema>;
// Define a base bucket value schema that can be reused and extended
export const bucketValueSchemaV1_3 = Z.object({
include: Z.array(Z.union([Z.string(), bucketItemSchema]))
.default([])
.describe("Glob patterns or bucket items to include for this bucket."),
exclude: Z.array(Z.union([Z.string(), bucketItemSchema]))
.default([])
.optional()
.describe("Glob patterns or bucket items to exclude from this bucket."),
injectLocale: Z.array(Z.string())
.optional()
.describe(
"Keys within files where the current locale should be injected or removed.",
),
}).describe("Configuration options for a translation bucket.");
export const configV1_3Definition = extendConfigDefinition(
configV1_2Definition,
{
createSchema: (baseSchema) =>
baseSchema.extend({
buckets: Z.record(bucketTypeSchema, bucketValueSchemaV1_3).default({}),
}),
createDefaultValue: (baseDefaultValue) => ({
...baseDefaultValue,
version: 1.3,
}),
createUpgrader: (oldConfig) => ({
...oldConfig,
version: 1.3,
}),
},
);
const configSchema = "https://lingo.dev/schema/i18n.json";
// v1.3 -> v1.4
// Changes: Add $schema to the config
export const configV1_4Definition = extendConfigDefinition(
configV1_3Definition,
{
createSchema: (baseSchema) =>
baseSchema.extend({
$schema: Z.string().default(configSchema),
}),
createDefaultValue: (baseDefaultValue) => ({
...baseDefaultValue,
version: 1.4,
$schema: configSchema,
}),
createUpgrader: (oldConfig) => ({
...oldConfig,
version: 1.4,
$schema: configSchema,
}),
},
);
// v1.4 -> v1.5
// Changes: add "provider" field to the config
const providerSchema = Z.object({
id: Z.enum([
"openai",
"anthropic",
"google",
"ollama",
"openrouter",
"mistral",
]).describe("Identifier of the translation provider service."),
model: Z.string().describe("Model name to use for translations."),
prompt: Z.string().describe(
"Prompt template used when requesting translations.",
),
baseUrl: Z.string()
.optional()
.describe("Custom base URL for the provider API (optional)."),
}).describe("Configuration for the machine-translation provider.");
export const configV1_5Definition = extendConfigDefinition(
configV1_4Definition,
{
createSchema: (baseSchema) =>
baseSchema.extend({
provider: providerSchema.optional(),
}),
createDefaultValue: (baseDefaultValue) => ({
...baseDefaultValue,
version: 1.5,
}),
createUpgrader: (oldConfig) => ({
...oldConfig,
version: 1.5,
}),
},
);
// v1.5 -> v1.6
// Changes: Add "lockedKeys" string array to bucket config
export const bucketValueSchemaV1_6 = bucketValueSchemaV1_3.extend({
lockedKeys: Z.array(Z.string())
.default([])
.optional()
.describe(
"Keys that must remain unchanged and should never be overwritten by translations.",
),
});
export const configV1_6Definition = extendConfigDefinition(
configV1_5Definition,
{
createSchema: (baseSchema) =>
baseSchema.extend({
buckets: Z.record(bucketTypeSchema, bucketValueSchemaV1_6).default({}),
}),
createDefaultValue: (baseDefaultValue) => ({
...baseDefaultValue,
version: 1.6,
}),
createUpgrader: (oldConfig) => ({
...oldConfig,
version: 1.6,
}),
},
);
// Changes: Add "lockedPatterns" string array of regex patterns to bucket config
export const bucketValueSchemaV1_7 = bucketValueSchemaV1_6.extend({
lockedPatterns: Z.array(Z.string())
.default([])
.optional()
.describe(
"Regular expression patterns whose matched content should remain locked during translation.",
),
});
export const configV1_7Definition = extendConfigDefinition(
configV1_6Definition,
{
createSchema: (baseSchema) =>
baseSchema.extend({
buckets: Z.record(bucketTypeSchema, bucketValueSchemaV1_7).default({}),
}),
createDefaultValue: (baseDefaultValue) => ({
...baseDefaultValue,
version: 1.7,
}),
createUpgrader: (oldConfig) => ({
...oldConfig,
version: 1.7,
}),
},
);
// v1.7 -> v1.8
// Changes: Add "ignoredKeys" string array to bucket config
export const bucketValueSchemaV1_8 = bucketValueSchemaV1_7.extend({
ignoredKeys: Z.array(Z.string())
.default([])
.optional()
.describe(
"Keys that should be completely ignored by translation processes.",
),
});
export const configV1_8Definition = extendConfigDefinition(
configV1_7Definition,
{
createSchema: (baseSchema) =>
baseSchema.extend({
buckets: Z.record(bucketTypeSchema, bucketValueSchemaV1_8).default({}),
}),
createDefaultValue: (baseDefaultValue) => ({
...baseDefaultValue,
version: 1.8,
}),
createUpgrader: (oldConfig) => ({
...oldConfig,
version: 1.8,
}),
},
);
// v1.8 -> v1.9
// Changes: Add "formatter" field to top-level config
export const configV1_9Definition = extendConfigDefinition(
configV1_8Definition,
{
createSchema: (baseSchema) =>
baseSchema.extend({
formatter: Z.enum(["prettier", "biome"])
.optional()
.describe(
"Code formatter to use for all buckets. Defaults to 'prettier' if not specified and a prettier config is found.",
),
}),
createDefaultValue: (baseDefaultValue) => ({
...baseDefaultValue,
version: 1.9,
}),
createUpgrader: (oldConfig) => ({
...oldConfig,
version: 1.9,
}),
},
);
// v1.9 -> v1.10
// Changes: Add "settings" field to provider config for model-specific parameters
const modelSettingsSchema = Z.object({
temperature: Z.number()
.min(0)
.max(2)
.optional()
.describe(
"Controls randomness in model outputs (0=deterministic, 2=very random). Some models like GPT-5 require temperature=1.",
),
})
.optional()
.describe("Model-specific settings for translation requests.");
const providerSchemaV1_10 = Z.object({
id: Z.enum([
"openai",
"anthropic",
"google",
"ollama",
"openrouter",
"mistral",
]).describe("Identifier of the translation provider service."),
model: Z.string().describe("Model name to use for translations."),
prompt: Z.string().describe(
"Prompt template used when requesting translations.",
),
baseUrl: Z.string()
.optional()
.describe("Custom base URL for the provider API (optional)."),
settings: modelSettingsSchema,
}).describe("Configuration for the machine-translation provider.");
export const configV1_10Definition = extendConfigDefinition(
configV1_9Definition,
{
createSchema: (baseSchema) =>
baseSchema.extend({
provider: providerSchemaV1_10.optional(),
}),
createDefaultValue: (baseDefaultValue) => ({
...baseDefaultValue,
version: "1.10",
}),
createUpgrader: (oldConfig) => ({
...oldConfig,
version: "1.10",
}),
},
);
// exports
export const LATEST_CONFIG_DEFINITION = configV1_10Definition;
export type I18nConfig = Z.infer<(typeof LATEST_CONFIG_DEFINITION)["schema"]>;
export function parseI18nConfig(rawConfig: unknown) {
try {
const result = LATEST_CONFIG_DEFINITION.parse(rawConfig);
return result;
} catch (error: any) {
throw new Error(`Failed to parse config: ${error.message}`);
}
}
export const defaultConfig = LATEST_CONFIG_DEFINITION.defaultValue;
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/mdx2/section-split.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Optimized version of the section joining algorithm
*
* This implementation focuses on performance and maintainability:
* 1. Uses a lookup table for faster section type determination
* 2. Uses a matrix for faster spacing determination
* 3. Reduces string concatenations by using an array and joining at the end
* 4. Adds detailed comments for better maintainability
*/
import { unified } from "unified";
import _ from "lodash";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkMdx from "remark-mdx";
import { VFile } from "vfile";
import { Root, RootContent } from "mdast";
import { PlaceholderedMdx, SectionedMdx } from "./_types";
import { traverseMdast } from "./_utils";
import { createLoader } from "../_utils";
import { ILoader } from "../_types";
/**
* MDX Section Splitter
*
* This module splits MDX content into logical sections, with special handling for JSX/HTML tags.
*
* Key features:
* - Splits content at headings (h1-h6)
* - Treats JSX/HTML opening tags as separate sections
* - Treats JSX/HTML closing tags as separate sections
* - Treats self-closing JSX/HTML tags as separate sections
* - Handles nested components properly
* - Preserves content between tags as separate sections
* - Intelligently joins sections with appropriate spacing
*/
// Create a parser instance for GitHub-flavoured Markdown and MDX JSX
const parser = unified().use(remarkParse).use(remarkGfm).use(remarkMdx);
// Interface for section boundaries
interface Boundary {
/** 0-based offset into content where the boundary begins */
start: number;
/** 0-based offset into content where the boundary ends */
end: number;
/** Whether the boundary node itself should be isolated as its own section */
isolateSelf: boolean;
}
// Section types for intelligent joining
enum SectionType {
HEADING = 0,
JSX_OPENING_TAG = 1,
JSX_CLOSING_TAG = 2,
JSX_SELF_CLOSING_TAG = 3,
CONTENT = 4,
UNKNOWN = 5,
}
// Spacing matrix for fast lookup
// [prevType][currentType] = spacing
const SPACING_MATRIX = [
// HEADING as previous type
["\n\n", "\n\n", "\n\n", "\n\n", "\n\n", "\n\n"],
// JSX_OPENING_TAG as previous type
["\n\n", "\n", "\n", "\n", "\n", "\n\n"],
// JSX_CLOSING_TAG as previous type
["\n\n", "\n", "\n", "\n", "\n\n", "\n\n"],
// JSX_SELF_CLOSING_TAG as previous type
["\n\n", "\n", "\n", "\n", "\n", "\n\n"],
// CONTENT as previous type
["\n\n", "\n\n", "\n", "\n\n", "\n\n", "\n\n"],
// UNKNOWN as previous type
["\n\n", "\n\n", "\n\n", "\n\n", "\n\n", "\n\n"],
];
/**
* Creates a loader that splits MDX content into logical sections.
*
* A new section starts at:
* • Any heading (level 1-6)
* • Any JSX/HTML opening tag (<Component> or <div> etc.)
* • Any JSX/HTML closing tag (</Component> or </div> etc.)
* • Any self-closing JSX/HTML tag (<Component /> or <br /> etc.)
*/
export default function createMdxSectionSplitLoader(): ILoader<
PlaceholderedMdx,
SectionedMdx
> {
return createLoader({
async pull(_locale, input) {
// Extract input or use defaults
const {
frontmatter = {},
content = "",
codePlaceholders = {},
} = input ||
({
frontmatter: {},
content: "",
codePlaceholders: {},
} as PlaceholderedMdx);
// Skip processing for empty content
if (!content.trim()) {
return {
frontmatter,
sections: {},
};
}
// Parse the content to get the AST
const file = new VFile(content);
const ast = parser.parse(file) as Root;
// Process the AST to find section boundaries
const boundaries = findSectionBoundaries(ast, content);
// Build sections from boundaries
const sections = createSectionsFromBoundaries(boundaries, content);
return {
frontmatter,
sections,
};
},
async push(_locale, data, originalInput, _originalLocale) {
// Get sections as array
const sectionsArray = Object.values(data.sections);
// If no sections, return empty content
if (sectionsArray.length === 0) {
return {
frontmatter: data.frontmatter,
content: "",
codePlaceholders: originalInput?.codePlaceholders ?? {},
};
}
// Optimize by pre-allocating result array and determining section types once
const resultParts: string[] = new Array(sectionsArray.length * 2 - 1);
const sectionTypes: SectionType[] = new Array(sectionsArray.length);
// Determine section types for all sections
for (let i = 0; i < sectionsArray.length; i++) {
sectionTypes[i] = determineJsxSectionType(sectionsArray[i]);
}
// Add first section without spacing
resultParts[0] = sectionsArray[0];
// Add remaining sections with appropriate spacing
for (let i = 1, j = 1; i < sectionsArray.length; i++, j += 2) {
const prevType = sectionTypes[i - 1];
const currentType = sectionTypes[i];
// Get spacing from matrix for better performance
resultParts[j] = SPACING_MATRIX[prevType][currentType];
resultParts[j + 1] = sectionsArray[i];
}
// Join all parts into final content
const content = resultParts.join("");
return {
frontmatter: data.frontmatter,
content,
codePlaceholders: originalInput?.codePlaceholders ?? {},
};
},
});
}
/**
* Determines the type of a section based on its content.
* Optimized with regex caching and early returns.
*/
function determineJsxSectionType(section: string): SectionType {
section = section.trim();
// Early returns for common cases
if (!section) return SectionType.UNKNOWN;
const firstChar = section.charAt(0);
const lastChar = section.charAt(section.length - 1);
// Check for headings (starts with #)
if (firstChar === "#") {
// Ensure it's a proper heading with space after #
if (/^#{1,6}\s/.test(section)) {
return SectionType.HEADING;
}
}
// Check for JSX/HTML tags (starts with <)
if (firstChar === "<") {
// Self-closing tag (ends with />)
if (section.endsWith("/>")) {
return SectionType.JSX_SELF_CLOSING_TAG;
}
// Closing tag (starts with </)
if (section.startsWith("</")) {
return SectionType.JSX_CLOSING_TAG;
}
// Opening tag (ends with >)
if (lastChar === ">") {
return SectionType.JSX_OPENING_TAG;
}
}
// Default to content
return SectionType.CONTENT;
}
/**
* Determines if a node is a JSX or HTML element.
*/
function isJsxOrHtml(node: Root | RootContent): boolean {
return (
node.type === "mdxJsxFlowElement" ||
node.type === "mdxJsxTextElement" ||
node.type === "html"
);
}
/**
* Finds the end position of an opening tag in a text string.
* Optimized to handle nested angle brackets correctly.
*/
function findOpeningTagEnd(text: string): number {
let depth = 0;
let inQuotes = false;
let quoteChar = "";
for (let i = 0; i < text.length; i++) {
const char = text[i];
// Handle quotes (to avoid counting angle brackets inside attribute values)
if ((char === '"' || char === "'") && (i === 0 || text[i - 1] !== "\\")) {
if (!inQuotes) {
inQuotes = true;
quoteChar = char;
} else if (char === quoteChar) {
inQuotes = false;
}
}
// Only count angle brackets when not in quotes
if (!inQuotes) {
if (char === "<") depth++;
if (char === ">") {
depth--;
if (depth === 0) return i + 1;
}
}
}
return -1;
}
/**
* Finds the start position of a closing tag in a text string.
* Optimized to handle nested components correctly.
*/
function findClosingTagStart(text: string): number {
// Extract the tag name from the opening tag to match the correct closing tag
const openTagMatch = /<([^\s/>]+)/.exec(text);
if (!openTagMatch) return -1;
const tagName = openTagMatch[1];
const closingTagRegex = new RegExp(`</${tagName}\\s*>`, "g");
// Find the last occurrence of the closing tag
let lastMatch = null;
let match;
while ((match = closingTagRegex.exec(text)) !== null) {
lastMatch = match;
}
return lastMatch ? lastMatch.index : -1;
}
/**
* Processes a JSX/HTML node to extract opening and closing tags as separate boundaries.
*/
function processJsxNode(
node: RootContent,
content: string,
boundaries: Boundary[],
): void {
// Skip nodes without valid position information
if (
!node.position ||
typeof node.position.start.offset !== "number" ||
typeof node.position.end.offset !== "number"
) {
return;
}
const nodeStart = node.position.start.offset;
const nodeEnd = node.position.end.offset;
const nodeContent = content.slice(nodeStart, nodeEnd);
// Handle HTML nodes using regex
if (node.type === "html") {
extractHtmlTags(nodeStart, nodeContent, boundaries);
return;
}
// Handle MDX JSX elements
if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
const isSelfClosing = (node as any).selfClosing === true;
if (isSelfClosing) {
// Self-closing tag - treat as a single section
boundaries.push({
start: nodeStart,
end: nodeEnd,
isolateSelf: true,
});
} else {
extractJsxTags(node, nodeContent, boundaries);
// Process children recursively to handle nested components
if ((node as any).children) {
for (const child of (node as any).children) {
if (isJsxOrHtml(child)) {
processJsxNode(child, content, boundaries);
}
}
}
}
}
}
/**
* Extracts HTML tags using regex and adds them as boundaries.
* Optimized with a more precise regex pattern.
*/
function extractHtmlTags(
nodeStart: number,
nodeContent: string,
boundaries: Boundary[],
): void {
// More precise regex for HTML tags that handles attributes better
const tagRegex =
/<\/?[a-zA-Z][a-zA-Z0-9:._-]*(?:\s+[a-zA-Z:_][a-zA-Z0-9:._-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^'">\s]+))?)*\s*\/?>/g;
let match;
while ((match = tagRegex.exec(nodeContent)) !== null) {
const tagStart = nodeStart + match.index;
const tagEnd = tagStart + match[0].length;
boundaries.push({
start: tagStart,
end: tagEnd,
isolateSelf: true,
});
}
}
/**
* Extracts opening and closing JSX tags and adds them as boundaries.
*/
function extractJsxTags(
node: RootContent,
nodeContent: string,
boundaries: Boundary[],
): void {
const nodeStart = node.position!.start.offset;
const nodeEnd = node.position!.end.offset;
if (!nodeStart || !nodeEnd) {
return;
}
// Find the opening tag
const openingTagEnd = findOpeningTagEnd(nodeContent);
if (openingTagEnd > 0) {
boundaries.push({
start: nodeStart,
end: nodeStart + openingTagEnd,
isolateSelf: true,
});
}
// Find the closing tag
const closingTagStart = findClosingTagStart(nodeContent);
if (closingTagStart > 0 && closingTagStart < nodeContent.length) {
boundaries.push({
start: nodeStart + closingTagStart,
end: nodeEnd,
isolateSelf: true,
});
}
}
/**
* Finds all section boundaries in the AST.
*/
function findSectionBoundaries(ast: Root, content: string): Boundary[] {
const boundaries: Boundary[] = [];
// Use a Map to cache node positions for faster lookups
const nodePositions = new Map<RootContent, { start: number; end: number }>();
// Pre-process nodes to cache their positions
traverseMdast(ast, (node: any) => {
if (
node.position &&
typeof node.position.start.offset === "number" &&
typeof node.position.end.offset === "number"
) {
nodePositions.set(node, {
start: node.position.start.offset,
end: node.position.end.offset,
});
}
});
for (const child of ast.children) {
const position = nodePositions.get(child);
if (!position) continue;
if (child.type === "heading") {
// Heading marks the beginning of a new section including itself
boundaries.push({
start: position.start,
end: position.end,
isolateSelf: false,
});
} else if (isJsxOrHtml(child)) {
// Process JSX/HTML nodes to extract tags as separate sections
processJsxNode(child, content, boundaries);
}
}
// Sort boundaries by start position
return boundaries.sort((a, b) => a.start - b.start);
}
/**
* Creates sections from the identified boundaries.
* Optimized to reduce unnecessary string operations.
*/
function createSectionsFromBoundaries(
boundaries: Boundary[],
content: string,
): Record<string, string> {
const sections: Record<string, string> = {};
// Early return for empty content or no boundaries
if (!content.trim() || boundaries.length === 0) {
const trimmed = content.trim();
if (trimmed) {
sections["0"] = trimmed;
}
return sections;
}
let idx = 0;
let lastEnd = 0;
// Pre-allocate array with estimated capacity
const sectionsArray: string[] = [];
// Process each boundary and the content between boundaries
for (let i = 0; i < boundaries.length; i++) {
const { start, end, isolateSelf } = boundaries[i];
// Capture content before this boundary if any
if (start > lastEnd) {
const segment = content.slice(lastEnd, start).trim();
if (segment) {
sectionsArray.push(segment);
}
}
if (isolateSelf) {
// Extract the boundary itself as a section
const segment = content.slice(start, end).trim();
if (segment) {
sectionsArray.push(segment);
}
lastEnd = end;
} else {
// For non-isolated boundaries (like headings), include them with following content
const nextStart =
i + 1 < boundaries.length ? boundaries[i + 1].start : content.length;
const segment = content.slice(start, nextStart).trim();
if (segment) {
sectionsArray.push(segment);
}
lastEnd = nextStart;
}
}
// Capture any content after the last boundary
if (lastEnd < content.length) {
const segment = content.slice(lastEnd).trim();
if (segment) {
sectionsArray.push(segment);
}
}
// Convert array to object with sequential keys
sectionsArray.forEach((section, index) => {
sections[index.toString()] = section;
});
return sections;
}
```
--------------------------------------------------------------------------------
/packages/compiler/src/jsx-scope-inject.spec.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from "vitest";
import { lingoJsxScopeInjectMutation } from "./jsx-scope-inject";
import { createPayload, createOutput, defaultParams } from "./_base";
import * as parser from "@babel/parser";
import generate from "@babel/generator";
// Helper function to run mutation and get result
function runMutation(code: string, rsc = false) {
const params = { ...defaultParams, rsc };
const input = createPayload({ code, params, relativeFilePath: "test" });
const mutated = lingoJsxScopeInjectMutation(input);
if (!mutated) throw new Error("Mutation returned null");
return createOutput(mutated).code;
}
// Helper function to normalize code for comparison
function normalizeCode(code: string) {
const ast = parser.parse(code, {
sourceType: "module",
plugins: ["jsx", "typescript"],
});
return generate(ast).code;
}
describe("lingoJsxScopeInjectMutation", () => {
describe("skip", () => {
it("should skip if data-lingo-skip is truthy", () => {
const input = `
function Component() {
return <div data-jsx-scope data-lingo-skip>
<p>Hello world!</p>
</div>;
}
`;
const expected = `
function Component() {
return <div data-jsx-scope data-lingo-skip>
<p>Hello world!</p>
</div>;
}
`;
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
});
describe("transform", () => {
it("should transform elements with data-jsx-scope into LingoComponent", () => {
const input = `
function Component() {
return <div>
<p data-jsx-scope="0/my/custom/path/1" className="text-foreground">Hello world!</p>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component() {
return <div>
<LingoComponent data-jsx-scope="0/my/custom/path/1" className="text-foreground" $as="p" $fileKey="test" $entryKey="0/my/custom/path/1" />
</div>;
}
`.trim();
const result = runMutation(input);
// We normalize both the expected and result to handle formatting differences
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
it("should transform JSX elements differently for server components", () => {
const input = `
function Component() {
return <div>
<p data-jsx-scope="0/body/0/argument/1" className="text-foreground">Hello world!</p>
</div>;
}
`.trim();
const expected = `
import { LingoComponent, loadDictionary } from "lingo.dev/react/rsc";
function Component() {
return <div>
<LingoComponent data-jsx-scope="0/body/0/argument/1" className="text-foreground" $as="p" $fileKey="test" $entryKey="0/body/0/argument/1" $loadDictionary={locale => loadDictionary(locale)} />
</div>;
}
`.trim();
const result = runMutation(input, true);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
it("should skip transformation if no JSX scopes are present", () => {
const input = `
function Component() {
return <div>
<p className="text-foreground">Hello world!</p>
</div>;
}
`.trim();
// Input should match output exactly
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(input));
});
it("should preserve JSX expression attributes", () => {
const input = `
function Component({ dynamicClass }) {
return <div>
<p data-jsx-scope="0/body/0/argument/1" className={dynamicClass}>Hello world!</p>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component({
dynamicClass
}) {
return <div>
<LingoComponent data-jsx-scope="0/body/0/argument/1" className={dynamicClass} $as="p" $fileKey="test" $entryKey="0/body/0/argument/1" />
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
it("should handle boolean attributes correctly", () => {
const input = `
function Component() {
return <div>
<button data-jsx-scope="0/body/0/argument/1" disabled>Click me</button>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component() {
return <div>
<LingoComponent data-jsx-scope="0/body/0/argument/1" disabled $as="button" $fileKey="test" $entryKey="0/body/0/argument/1" />
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
});
describe("variables", () => {
it("should handle JSX variables in elements with data-jsx-scope", () => {
const input = `
function Component({ count, category }) {
return <div>
<p data-jsx-scope="0/body/0/argument/1" className="text-foreground">You have {count} items in {category}.</p>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component({ count, category }) {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
className="text-foreground"
$as="p"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$variables={{ "count": count, "category": category }}
/>
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
it("should handle JSX variables for server components", () => {
const input = `
function Component({ count, category }) {
return <div>
<p data-jsx-scope="0/body/0/argument/1" className="text-foreground">You have {count} items in {category}.</p>
</div>;
}
`.trim();
const expected = `
import { LingoComponent, loadDictionary } from "lingo.dev/react/rsc";
function Component({ count, category }) {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
className="text-foreground"
$as="p"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$variables={{ "count": count, "category": category }}
$loadDictionary={locale => loadDictionary(locale)}
/>
</div>;
}
`.trim();
const result = runMutation(input, true);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
it("should handle nested JSX elements with variables", () => {
const input = `
function Component({ count, user }) {
return <div>
<div data-jsx-scope="0/body/0/argument/1">
Welcome {user.name}, you have {count} notifications.
</div>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component({ count, user }) {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
$as="div"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$variables={{ "user.name": user.name, "count": count }}
/>
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
});
describe("elements", () => {
it("should handle nested JSX elements", () => {
const input = `
function Component() {
return <div>
<div data-jsx-scope="0/body/0/argument/1">
<p>Hello</p>
<span>World</span>
</div>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component() {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
$as="div"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$elements={[
({
children
}) => <p>{children}</p>,
({
children
}) => <span>{children}</span>
]}
/>
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
it("should handle deeply nested JSX elements", () => {
const input = `
function Component() {
return <div>
<div data-jsx-scope="0/body/0/argument/1">
<p>
<span>
<strong>Deeply</strong>
</span>
nested
</p>
</div>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component() {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
$as="div"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$elements={[
({
children
}) => <p>{children}</p>,
({
children
}) => <span>{children}</span>,
({
children
}) => <strong>{children}</strong>
]}
/>
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
it("should handle nested elements with variables", () => {
const input = `
function Component({ name }) {
return <div>
<div data-jsx-scope="0/body/0/argument/1">
<p>Hello {name}</p>
<span>Welcome back!</span>
</div>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component({ name }) {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
$as="div"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$variables={{ "name": name }}
$elements={[
({
children
}) => <p>{children}</p>,
({
children
}) => <span>{children}</span>
]}
/>
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
});
describe("functions", () => {
it("should handle simple function calls", () => {
const input = `
function Component() {
return <div>
<p data-jsx-scope="0/body/0/argument/1">Hello {getName(user)}, you have {getCount()} items</p>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component() {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
$as="p"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$functions={{
"getName": [getName(user)],
"getCount": [getCount()]
}}
/>
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
it("should handle function calls with variables and nested elements", () => {
const input = `
function Component({ user }) {
return <div>
<div data-jsx-scope="0/body/0/argument/1">
<p>{formatName(getName(user))}</p> has <em>{count}</em>
<span>Last seen: {formatDate(user.lastSeen)}</span>
</div>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component({ user }) {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
$as="div"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$variables={{ "count": count }}
$elements={[
({ children }) => <p>{children}</p>,
({ children }) => <em>{children}</em>,
({ children }) => <span>{children}</span>
]}
$functions={{
"formatName": [formatName(getName(user))],
"formatDate": [formatDate(user.lastSeen)]
}}
/>
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
});
describe("expressions", () => {
it("should extract simple expressions", () => {
const input = `
function Component() {
return <div>
<p data-jsx-scope="0/body/0/argument/1">Result: {count + 1}</p>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component() {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
$as="p"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$expressions={[
count + 1
]}
/>
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
it("should extract multiple expressions", () => {
const input = `
function Component() {
return <div>
<p data-jsx-scope="0/body/0/argument/1">First: {count * 2}, Second: {value > 0}</p>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component() {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
$as="p"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$expressions={[
count * 2,
value > 0
]}
/>
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
it("should handle mixed variables, functions and expressions", () => {
const input = `
function Component() {
return <div>
<p data-jsx-scope="0/body/0/argument/1">
{count + 1} items by {user.name}, processed by {getName()}}
</p>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component() {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
$as="p"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$variables={{
"user.name": user.name
}}
$functions={{
"getName": [getName()],
}}
$expressions={[
count + 1
]}
/>
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
it("should handle expressions in nested elements", () => {
const input = `
function Component() {
return <div>
<div data-jsx-scope="0/body/0/argument/1">
<p>Count: {items.length + offset}</p>
<span>Active: {items.filter(i => i.active).length > 0}</span>
</div>
</div>;
}
`.trim();
const expected = `
import { LingoComponent } from "lingo.dev/react/client";
function Component() {
return <div>
<LingoComponent
data-jsx-scope="0/body/0/argument/1"
$as="div"
$fileKey="test"
$entryKey="0/body/0/argument/1"
$elements={[
({ children }) => <p>{children}</p>,
({ children }) => <span>{children}</span>
]}
$expressions={[
items.length + offset,
items.filter(i => i.active).length > 0
]}
/>
</div>;
}
`.trim();
const result = runMutation(input);
expect(normalizeCode(result)).toBe(normalizeCode(expected));
});
});
});
```
--------------------------------------------------------------------------------
/packages/compiler/src/index.ts:
--------------------------------------------------------------------------------
```typescript
import { createUnplugin } from "unplugin";
import type { NextConfig } from "next";
import packageJson from "../package.json";
import _ from "lodash";
import dedent from "dedent";
import { defaultParams } from "./_base";
import { LCP_DICTIONARY_FILE_NAME } from "./_const";
import { LCPCache } from "./lib/lcp/cache";
import { getInvalidLocales } from "./utils/locales";
import {
getGroqKeyFromEnv,
getGroqKeyFromRc,
getGoogleKeyFromEnv,
getGoogleKeyFromRc,
getMistralKeyFromEnv,
getMistralKeyFromRc,
getLingoDotDevKeyFromEnv,
getLingoDotDevKeyFromRc,
} from "./utils/llm-api-key";
import { isRunningInCIOrDocker } from "./utils/env";
import { providerDetails } from "./lib/lcp/api/provider-details";
import { loadDictionary, transformComponent } from "./_loader-utils";
import trackEvent from "./utils/observability";
const keyCheckers: Record<
string,
{
checkEnv: () => string | undefined;
checkRc: () => string | undefined;
}
> = {
groq: {
checkEnv: getGroqKeyFromEnv,
checkRc: getGroqKeyFromRc,
},
google: {
checkEnv: getGoogleKeyFromEnv,
checkRc: getGoogleKeyFromRc,
},
mistral: {
checkEnv: getMistralKeyFromEnv,
checkRc: getMistralKeyFromRc,
},
"lingo.dev": {
checkEnv: getLingoDotDevKeyFromEnv,
checkRc: getLingoDotDevKeyFromRc,
},
};
const alreadySentBuildEvent = { value: false };
function sendBuildEvent(framework: string, config: any, isDev: boolean) {
if (alreadySentBuildEvent.value) return;
alreadySentBuildEvent.value = true;
trackEvent("compiler.build.start", {
framework,
configuration: config,
isDevMode: isDev,
});
}
const unplugin = createUnplugin<Partial<typeof defaultParams> | undefined>(
(_params, _meta) => {
console.log("ℹ️ Starting Lingo.dev compiler...");
const params = _.defaults(_params, defaultParams);
// Validate if not in CI or Docker
if (!isRunningInCIOrDocker()) {
if (params.models === "lingo.dev") {
validateLLMKeyDetails(["lingo.dev"]);
} else {
const configuredProviders = getConfiguredProviders(params.models);
validateLLMKeyDetails(configuredProviders);
const invalidLocales = getInvalidLocales(
params.models,
params.sourceLocale,
params.targetLocales,
);
if (invalidLocales.length > 0) {
throw new Error(dedent`
⚠️ Lingo.dev Localization Compiler requires LLM model setup for the following locales: ${invalidLocales.join(
", ",
)}.
⭐️ Next steps:
1. Refer to documentation for help: https://lingo.dev/compiler
2. If you want to use a different LLM, raise an issue in our open-source repo: https://lingo.dev/go/gh
3. If you have questions, feature requests, or would like to contribute, join our Discord: https://lingo.dev/go/discord
`);
}
}
}
LCPCache.ensureDictionaryFile({
sourceRoot: params.sourceRoot,
lingoDir: params.lingoDir,
});
const isDev: boolean =
"dev" in _meta ? !!_meta.dev : process.env.NODE_ENV !== "production";
sendBuildEvent("unplugin", params, isDev);
return {
name: packageJson.name,
loadInclude: (id) => !!id.match(LCP_DICTIONARY_FILE_NAME),
async load(id) {
const dictionary = await loadDictionary({
resourcePath: id,
resourceQuery: "",
params: {
...params,
models: params.models,
sourceLocale: params.sourceLocale,
targetLocales: params.targetLocales,
},
sourceRoot: params.sourceRoot,
lingoDir: params.lingoDir,
isDev,
});
if (!dictionary) {
return null;
}
return {
code: `export default ${JSON.stringify(dictionary, null, 2)}`,
};
},
transformInclude: (id) => id.endsWith(".tsx") || id.endsWith(".jsx"),
enforce: "pre",
transform(code, id) {
try {
const result = transformComponent({
code,
params,
resourcePath: id,
sourceRoot: params.sourceRoot,
});
return result;
} catch (error) {
console.error("⚠️ Lingo.dev compiler failed to localize your app");
console.error("⚠️ Details:", error);
return code;
}
},
};
},
);
export default {
/**
* Initializes Lingo.dev Compiler for Next.js (App Router).
*
* @param compilerParams - The compiler parameters.
*
* @returns The Next.js configuration.
*
* @example Configuration for Next.js's default template
* ```ts
* import lingoCompiler from "lingo.dev/compiler";
* import type { NextConfig } from "next";
*
* const nextConfig: NextConfig = {
* /* config options here *\/
* };
*
* export default lingoCompiler.next({
* sourceRoot: "app",
* models: "lingo.dev",
* })(nextConfig);
* ```
*/
next:
(
compilerParams?: Partial<typeof defaultParams> & {
turbopack?: {
enabled?: boolean | "auto";
useLegacyTurbo?: boolean;
};
},
) =>
(nextConfig: any = {}): NextConfig => {
const mergedParams = _.merge(
{},
defaultParams,
{
rsc: true,
turbopack: {
enabled: "auto",
useLegacyTurbo: false,
},
},
compilerParams,
);
const isDev = process.env.NODE_ENV !== "production";
sendBuildEvent("Next.js", mergedParams, isDev);
let turbopackEnabled: boolean;
if (mergedParams.turbopack?.enabled === "auto") {
turbopackEnabled =
process.env.TURBOPACK === "1" || process.env.TURBOPACK === "true";
} else {
turbopackEnabled = mergedParams.turbopack?.enabled === true;
}
const supportLegacyTurbo: boolean =
mergedParams.turbopack?.useLegacyTurbo === true;
const hasWebpackConfig = typeof nextConfig.webpack === "function";
const hasTurbopackConfig = typeof nextConfig.turbopack === "function";
if (hasWebpackConfig && turbopackEnabled) {
console.warn(
"⚠️ Turbopack is enabled in the Lingo.dev compiler, but you have webpack config. Lingo.dev will still apply turbopack configuration.",
);
}
if (hasTurbopackConfig && !turbopackEnabled) {
console.warn(
"⚠️ Turbopack is disabled in the Lingo.dev compiler, but you have turbopack config. Lingo.dev will not apply turbopack configuration.",
);
}
// Webpack
const originalWebpack = nextConfig.webpack;
nextConfig.webpack = (config: any, options: any) => {
if (!turbopackEnabled) {
console.log("Applying Lingo.dev webpack configuration...");
config.plugins.unshift(unplugin.webpack(mergedParams));
}
if (typeof originalWebpack === "function") {
return originalWebpack(config, options);
}
return config;
};
// Turbopack
if (turbopackEnabled) {
console.log("Applying Lingo.dev Turbopack configuration...");
// Check if the legacy turbo flag is set
let turbopackConfigPath = (nextConfig.turbopack ??= {});
if (supportLegacyTurbo) {
turbopackConfigPath = (nextConfig.experimental ??= {}).turbo ??= {};
}
turbopackConfigPath.rules ??= {};
const rules = turbopackConfigPath.rules;
// Regex for all relevant files for Lingo.dev
const lingoGlob = `**/*.{ts,tsx,js,jsx}`;
// The .cjs extension is required for Next.js v14
const lingoLoaderPath = require.resolve("./lingo-turbopack-loader.cjs");
rules[lingoGlob] = {
loaders: [
{
loader: lingoLoaderPath,
options: mergedParams,
},
],
};
}
return nextConfig;
},
/**
* Initializes Lingo.dev Compiler for Vite.
*
* @param compilerParams - The compiler parameters.
*
* @returns The Vite configuration.
*
* @example Configuration for Vite's "react-ts" template
* ```ts
* import { defineConfig, type UserConfig } from "vite";
* import react from "@vitejs/plugin-react";
* import lingoCompiler from "lingo.dev/compiler";
*
* // https://vite.dev/config/
* const viteConfig: UserConfig = {
* plugins: [react()],
* };
*
* export default defineConfig(() =>
* lingoCompiler.vite({
* models: "lingo.dev",
* })(viteConfig)
* );
* ```
*
* @example Configuration for React Router's default template
* ```ts
* import { reactRouter } from "@react-router/dev/vite";
* import tailwindcss from "@tailwindcss/vite";
* import lingoCompiler from "lingo.dev/compiler";
* import { defineConfig, type UserConfig } from "vite";
* import tsconfigPaths from "vite-tsconfig-paths";
*
* const viteConfig: UserConfig = {
* plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
* };
*
* export default defineConfig(() =>
* lingoCompiler.vite({
* sourceRoot: "app",
* models: "lingo.dev",
* })(viteConfig)
* );
* ```
*/
vite: (compilerParams?: Partial<typeof defaultParams>) => (config: any) => {
const mergedParams = _.merge(
{},
defaultParams,
{ rsc: false },
compilerParams,
);
const isDev = process.env.NODE_ENV !== "production";
const isReactRouter = config.plugins
?.flat()
?.some((plugin: any) => plugin.name === "react-router");
const framework = isReactRouter ? "React Router" : "Vite";
sendBuildEvent(framework, mergedParams, isDev);
config.plugins.unshift(unplugin.vite(mergedParams));
return config;
},
};
/**
* Extract a list of supported LLM provider IDs from the locale→model mapping.
* @param models Mapping from locale to "<providerId>:<modelName>" strings.
*/
function getConfiguredProviders(models: Record<string, string>): string[] {
return _.chain(Object.values(models))
.map((modelString) => modelString.split(":")[0]) // Extract provider ID
.filter(Boolean) // Remove empty strings
.uniq() // Get unique providers
.filter(
(providerId) =>
providerDetails.hasOwnProperty(providerId) &&
keyCheckers.hasOwnProperty(providerId),
) // Only check for known and implemented providers
.value();
}
/**
* Print helpful information about where the LLM API keys for configured providers
* were discovered. The compiler looks for the key first in the environment
* (incl. .env files) and then in the user-wide configuration. Environment always wins.
* @param configuredProviders List of provider IDs detected in the configuration.
*/
function validateLLMKeyDetails(configuredProviders: string[]): void {
if (configuredProviders.length === 0) {
// No LLM providers configured that we can validate keys for.
return;
}
const keyStatuses: Record<
string,
{
foundInEnv: boolean;
foundInRc: boolean;
details: (typeof providerDetails)[string];
}
> = {};
const missingProviders: string[] = [];
const foundProviders: string[] = [];
for (const providerId of configuredProviders) {
const details = providerDetails[providerId];
const checkers = keyCheckers[providerId];
if (!details || !checkers) continue; // Should not happen due to filter above
const foundInEnv = !!checkers.checkEnv();
const foundInRc = !!checkers.checkRc();
keyStatuses[providerId] = { foundInEnv, foundInRc, details };
if (!foundInEnv && !foundInRc) {
missingProviders.push(providerId);
} else {
foundProviders.push(providerId);
}
}
if (missingProviders.length > 0) {
console.log(dedent`
\n
💡 Lingo.dev Localization Compiler is configured to use the following LLM provider(s): ${configuredProviders.join(
", ",
)}.
The compiler requires API keys for these providers to work, but the following keys are missing:
`);
for (const providerId of missingProviders) {
const status = keyStatuses[providerId];
if (!status) continue;
console.log(dedent`
⚠️ ${status.details.name} API key is missing. Set ${
status.details.apiKeyEnvVar
} environment variable.
👉 You can set the API key in one of the following ways:
1. User-wide: Run npx lingo.dev@latest config set ${
status.details.apiKeyConfigKey || "<config-key-not-available>"
} <your-api-key>
2. Project-wide: Add ${
status.details.apiKeyEnvVar
}=<your-api-key> to .env file in every project that uses Lingo.dev Localization Compiler
3. Session-wide: Run export ${
status.details.apiKeyEnvVar
}=<your-api-key> in your terminal before running the compiler to set the API key for the current session
⭐️ If you don't yet have a ${
status.details.name
} API key, get one for free at ${status.details.getKeyLink}
`);
}
const errorMessage = dedent`
\n
⭐️ Also:
1. If you want to use a different LLM, update your configuration. Refer to documentation for help: https://lingo.dev/compiler
2. If the model/provider you want to use isn't supported yet, raise an issue in our open-source repo: https://lingo.dev/go/gh
3. If you have questions, feature requests, or would like to contribute, join our Discord: https://lingo.dev/go/discord
`;
console.log(errorMessage);
throw new Error("Missing required LLM API keys. See details above.");
} else if (foundProviders.length > 0) {
console.log(dedent`
\n
🔑 LLM API keys detected for configured providers: ${foundProviders.join(
", ",
)}.
`);
for (const providerId of foundProviders) {
const status = keyStatuses[providerId];
if (!status) continue;
let sourceMessage = "";
if (status.foundInEnv && status.foundInRc) {
sourceMessage = `from both environment variables (${status.details.apiKeyEnvVar}) and your user-wide configuration. The key from the environment will be used because it has higher priority.`;
} else if (status.foundInEnv) {
sourceMessage = `from environment variables (${status.details.apiKeyEnvVar}).`;
} else if (status.foundInRc) {
sourceMessage = `from your user-wide configuration${
status.details.apiKeyConfigKey
? ` (${status.details.apiKeyConfigKey})`
: ""
}.`;
}
console.log(dedent`
• ${status.details.name} API key loaded ${sourceMessage}
`);
}
console.log("✨");
}
}
```
--------------------------------------------------------------------------------
/packages/compiler/src/lib/lcp/server.spec.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { LCPServer } from "./server";
import { LCPSchema } from "./schema";
import { LCPCache } from "./cache";
import { LCPAPI } from "./api";
describe("LCPServer", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.mock("fs");
vi.mock("path");
});
describe("loadDictionaries", () => {
it("should load dictionaries for all target locales", async () => {
const lcp: LCPSchema = {
version: 0.1,
files: {},
};
const loadDictionaryForLocaleSpy = vi.spyOn(
LCPServer,
"loadDictionaryForLocale",
);
const dictionaries = await LCPServer.loadDictionaries({
models: {
"*:*": "groq:mistral-saba-24b",
},
lcp,
sourceLocale: "en",
targetLocales: ["fr", "es", "de"],
sourceRoot: "src",
lingoDir: "lingo",
});
expect(loadDictionaryForLocaleSpy).toHaveBeenCalledTimes(4);
expect(dictionaries).toEqual({
fr: {
version: 0.1,
locale: "fr",
files: {},
},
es: {
version: 0.1,
locale: "es",
files: {},
},
de: {
version: 0.1,
locale: "de",
files: {},
},
en: {
version: 0.1,
locale: "en",
files: {},
},
});
});
});
describe("loadDictionaryForLocale", () => {
it("should correctly extract the source dictionary when source and target locales are the same", async () => {
// Mock LCPAPI.translate() to ensure it's not called
const translateSpy = vi.spyOn(LCPAPI, "translate");
const lcp: LCPSchema = {
version: 0.1,
files: {
"app/test.tsx": {
scopes: {
key1: {
content: "Hello World",
hash: "abcd1234",
},
key2: {
content: "Button Text",
hash: "efgh5678",
},
},
},
},
};
const result = await LCPServer.loadDictionaryForLocale({
lcp,
sourceLocale: "en",
targetLocale: "en", // Same locale
sourceRoot: "src",
lingoDir: "lingo",
});
// Verify the structure
expect(result).toEqual({
version: 0.1,
locale: "en",
files: {
"app/test.tsx": {
entries: {
key1: "Hello World",
key2: "Button Text",
},
},
},
});
// Ensure LCPAPI.translate() wasn't called since source == target
expect(translateSpy).not.toHaveBeenCalled();
});
it("should return empty dictionary when source dictionary is empty", async () => {
// Mock LCPAPI.translate() to ensure it's not called
const translateSpy = vi.spyOn(LCPAPI, "translate");
const lcp: LCPSchema = {
version: 0.1,
files: {},
};
const result = await LCPServer.loadDictionaryForLocale({
lcp,
sourceLocale: "en",
targetLocale: "es",
sourceRoot: "src",
lingoDir: "lingo",
});
// Verify the structure
expect(result).toEqual({
version: 0.1,
locale: "es",
files: {},
});
// Ensure LCPAPI.translate() wasn't called since source == target
expect(translateSpy).not.toHaveBeenCalled();
});
it("should handle overrides in source content", async () => {
// Mock LCPAPI.translate() to ensure it's not called
vi.spyOn(LCPAPI, "translate").mockImplementation(() =>
Promise.resolve({
version: 0.1,
locale: "fr",
files: {
"app/test.tsx": {
entries: {
key1: "Bonjour le monde",
key2: "Texte du bouton",
},
},
},
}),
);
const lcp: LCPSchema = {
version: 0.1,
files: {
"app/test.tsx": {
scopes: {
key1: {
content: "Hello World",
hash: "abcd1234",
},
key2: {
content: "Button Text",
hash: "efgh5678",
},
key3: {
content: "Original",
hash: "1234abcd",
overrides: {
fr: "Remplacé", // French override for 'key3'
},
},
},
},
},
};
const result = await LCPServer.loadDictionaryForLocale({
lcp,
sourceLocale: "en",
targetLocale: "fr",
sourceRoot: "src",
lingoDir: "lingo",
});
// Check that the overrides were applied
expect(result.files["app/test.tsx"].entries).toEqual({
key1: "Bonjour le monde",
key2: "Texte du bouton",
key3: "Remplacé",
});
expect(result.locale).toBe("fr");
});
it("should create empty dictionary when no files are provided", async () => {
const lcp: LCPSchema = {
version: 0.1,
};
const result = await LCPServer.loadDictionaryForLocale({
lcp,
sourceLocale: "en",
targetLocale: "en",
sourceRoot: "src",
lingoDir: "lingo",
});
expect(result).toEqual({
version: 0.1,
locale: "en",
files: {},
});
});
it("should read dictionary from cache only, not call LCPAPI.translate()", async () => {
vi.spyOn(LCPCache, "readLocaleDictionary").mockReturnValue({
version: 0.1,
locale: "en",
files: {
"app/test.tsx": {
entries: {
key1: "Hello World",
key2: "Button Text",
key3: "New text",
},
},
},
});
const translateSpy = vi
.spyOn(LCPAPI, "translate")
.mockImplementation(() => {
throw new Error("Should not translate anything");
});
const lcp: LCPSchema = {
version: 0.1,
files: {
"app/test.tsx": {
scopes: {
key1: {
content: "Hello World",
},
},
},
},
};
await LCPServer.loadDictionaryForLocale({
lcp,
sourceLocale: "en",
targetLocale: "fr",
sourceRoot: "src",
lingoDir: "lingo",
});
expect(translateSpy).not.toHaveBeenCalled();
expect(LCPCache.readLocaleDictionary).toHaveBeenCalledWith("fr", {
lcp,
sourceLocale: "en",
lingoDir: "lingo",
sourceRoot: "src",
});
});
it("should write dictionary to cache", async () => {
vi.spyOn(LCPCache, "writeLocaleDictionary");
vi.spyOn(LCPAPI, "translate").mockReturnValue({
version: 0.1,
locale: "fr",
files: {
"app/test.tsx": {
entries: {
key1: "Bonjour le monde",
key2: "Texte du bouton",
},
},
},
});
const lcp: LCPSchema = {
version: 0.1,
files: {
"app/test.tsx": {
scopes: {
key1: {
content: "Hello World",
hash: "abcd1234",
},
key2: {
content: "Button Text",
hash: "efgh5678",
},
},
},
},
};
await LCPServer.loadDictionaryForLocale({
lcp,
sourceLocale: "en",
targetLocale: "fr",
sourceRoot: "src",
lingoDir: "lingo",
});
expect(LCPCache.writeLocaleDictionary).toHaveBeenCalledWith(
{
files: {
"app/test.tsx": {
entries: {
key1: "Bonjour le monde",
key2: "Texte du bouton",
},
},
},
locale: "fr",
version: 0.1,
},
{
lcp,
sourceLocale: "en",
lingoDir: "lingo",
sourceRoot: "src",
},
);
});
it("should reuse cached keys with matching hash, call LCPAPI.translate() for keys with different hash, fallback to source locale, cache new translations", async () => {
vi.spyOn(LCPCache, "readLocaleDictionary").mockReturnValue({
version: 0.1,
locale: "fr",
files: {
"app/test.tsx": {
entries: {
key1: "Bonjour le monde",
},
},
},
});
const writeCacheSpy = vi.spyOn(LCPCache, "writeLocaleDictionary");
const translateSpy = vi.spyOn(LCPAPI, "translate").mockResolvedValue({
version: 0.1,
locale: "fr",
files: {
"app/test.tsx": {
entries: {
key2: "Nouveau texte du bouton",
key3: "", // LLM might return empty string
},
},
},
});
const lcp: LCPSchema = {
version: 0.1,
files: {
"app/test.tsx": {
scopes: {
key1: {
content: "Hello World",
hash: "abcd1234",
},
key2: {
content: "Button Text",
hash: "new_hash",
},
key3: {
content: "New text",
hash: "ijkl4321",
},
},
},
},
};
const models = {
"*:*": "groq:mistral-saba-24b",
};
const result = await LCPServer.loadDictionaryForLocale({
models,
lcp,
sourceLocale: "en",
targetLocale: "fr",
sourceRoot: "src",
lingoDir: "lingo",
});
// Verify that only changed content was sent for translation
expect(translateSpy).toHaveBeenCalledWith(
models,
{
version: 0.1,
locale: "en",
files: {
"app/test.tsx": {
entries: {
key2: "Button Text",
key3: "New text",
},
},
},
},
"en",
"fr",
undefined,
);
// Verify final result combines cached and newly translated content
expect(result).toEqual({
version: 0.1,
locale: "fr",
files: {
"app/test.tsx": {
entries: {
key1: "Bonjour le monde",
key2: "Nouveau texte du bouton",
key3: "New text", // LLM returned empty string, but result contains fallback to source locale string
},
},
},
});
// when LLM returns empty string, we cache empty string (the result contains fallback to source locale string)
result.files["app/test.tsx"].entries.key3 = "";
// Verify cache is updated with new translations
expect(writeCacheSpy).toHaveBeenCalledWith(result, {
lcp,
sourceLocale: "en",
lingoDir: "lingo",
sourceRoot: "src",
});
});
});
describe("_getDictionaryDiff", () => {
it("should return diff between source and target dictionaries", () => {
const sourceDictionary = {
version: 0.1,
locale: "en",
files: {
"app/test.tsx": {
entries: {
key1: "Hello World",
key2: "Button Text",
key3: "New Text",
key4: "More text",
},
},
},
};
const targetDictionary = {
version: 0.1,
locale: "es",
files: {
"app/test.tsx": {
entries: {
key1: "Hola mundo",
key2: "El texto del botón",
key3: "", // empty string is valid value
},
},
},
};
const diff = (LCPServer as any)._getDictionaryDiff(
sourceDictionary,
targetDictionary,
);
expect(diff).toEqual({
version: 0.1,
locale: "en",
files: {
"app/test.tsx": {
entries: {
key4: "More text",
},
},
},
});
});
});
describe("_mergeDictionaries", () => {
it("should merge dictionaries", () => {
const sourceDictionary = {
version: 0.1,
locale: "es",
files: {
"app/test.tsx": {
entries: {
key2: "",
key3: "Nuevo texto",
},
},
"app/test3.tsx": {
entries: {
key1: "Como estas?",
key2: "Yo soy bien",
},
},
},
};
const targetDictionary = {
version: 0.1,
locale: "es",
files: {
"app/test.tsx": {
entries: {
key1: "Hola mundo",
key2: "Hola",
},
},
"app/test2.tsx": {
entries: {
key1: "Yo soy un programador",
key2: "",
},
},
},
};
const merge = (LCPServer as any)._mergeDictionaries(
sourceDictionary,
targetDictionary,
);
expect(merge).toEqual({
version: 0.1,
locale: "es",
files: {
"app/test.tsx": {
entries: {
key1: "Hola mundo",
key2: "",
key3: "Nuevo texto",
},
},
"app/test2.tsx": {
entries: {
key1: "Yo soy un programador",
key2: "",
},
},
"app/test3.tsx": {
entries: {
key1: "Como estas?",
key2: "Yo soy bien",
},
},
},
});
});
it("should remove empty entries when merging dictionaries", () => {
const sourceDictionary = {
version: 0.1,
locale: "es",
files: {
"app/test.tsx": {
entries: {
key1: "",
key2: "El texto del botón",
},
},
"app/test2.tsx": {
entries: {
key1: "Yo soy un programador",
key2: "",
},
},
},
};
const targetDictionary = {
version: 0.1,
locale: "es",
files: {
"app/test.tsx": {
entries: {
key1: "Hello world",
key2: "Button Text",
},
},
"app/test2.tsx": {
entries: {
key1: "I am a programmer",
key2: "You are a gardener",
},
},
},
};
const merge = (LCPServer as any)._mergeDictionaries(
sourceDictionary,
targetDictionary,
true,
);
expect(merge).toEqual({
version: 0.1,
locale: "es",
files: {
"app/test.tsx": {
entries: {
key1: "Hello world",
key2: "El texto del botón",
},
},
"app/test2.tsx": {
entries: {
key1: "Yo soy un programador",
key2: "You are a gardener",
},
},
},
});
});
});
});
```
--------------------------------------------------------------------------------
/scripts/docs/src/generate-cli-docs.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import type { Argument, Command, Option } from "commander";
import { existsSync } from "fs";
import { mkdir, writeFile } from "fs/promises";
import type {
Content,
Heading,
List,
ListItem,
Paragraph,
PhrasingContent,
Root,
} from "mdast";
import { dirname, join, resolve } from "path";
import remarkStringify from "remark-stringify";
import { unified } from "unified";
import { pathToFileURL } from "url";
import { createOrUpdateGitHubComment, getRepoRoot } from "./utils";
import { format as prettierFormat, resolveConfig } from "prettier";
type CommandWithInternals = Command & {
_hidden?: boolean;
_helpCommand?: Command;
};
const FRONTMATTER_DELIMITER = "---";
async function getProgram(repoRoot: string): Promise<Command> {
const filePath = resolve(
repoRoot,
"packages",
"cli",
"src",
"cli",
"index.ts",
);
if (!existsSync(filePath)) {
throw new Error(`CLI source file not found at ${filePath}`);
}
const cliModule = (await import(pathToFileURL(filePath).href)) as {
default: Command;
};
return cliModule.default;
}
function slugifyCommandName(name: string): string {
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug.length > 0 ? slug : "command";
}
function formatYamlValue(value: string): string {
const escaped = value.replace(/"/g, '\\"');
return `"${escaped}"`;
}
function createHeading(
depth: number,
content: string | PhrasingContent[],
): Heading {
const children = Array.isArray(content)
? content
: [{ type: "text", value: content }];
return {
type: "heading",
depth: Math.min(Math.max(depth, 1), 6),
children,
};
}
function createInlineCode(value: string): PhrasingContent {
return { type: "inlineCode", value };
}
function createParagraph(text: string): Paragraph {
return {
type: "paragraph",
children: createTextNodes(text),
};
}
function createTextNodes(text: string): PhrasingContent[] {
if (!text) {
return [];
}
const nodes: PhrasingContent[] = [];
const parts = text.split(/(`[^`]*`)/g);
parts.forEach((part) => {
if (!part) {
return;
}
if (part.startsWith("`") && part.endsWith("`")) {
nodes.push(createInlineCode(part.slice(1, -1)));
} else {
nodes.push(...createBracketAwareTextNodes(part));
}
});
return nodes;
}
function createBracketAwareTextNodes(text: string): PhrasingContent[] {
const nodes: PhrasingContent[] = [];
const bracketPattern = /\[[^\]]+\]/g;
let lastIndex = 0;
for (const match of text.matchAll(bracketPattern)) {
const [value] = match;
const start = match.index ?? 0;
if (start > lastIndex) {
nodes.push({ type: "text", value: text.slice(lastIndex, start) });
}
nodes.push(createInlineCode(value));
lastIndex = start + value.length;
}
if (lastIndex < text.length) {
nodes.push({ type: "text", value: text.slice(lastIndex) });
}
if (nodes.length === 0) {
nodes.push({ type: "text", value: text });
}
return nodes;
}
function createList(items: ListItem[]): List {
return {
type: "list",
ordered: false,
spread: false,
children: items,
};
}
function createListItem(children: PhrasingContent[]): ListItem {
return {
type: "listItem",
spread: false,
children: [
{
type: "paragraph",
children,
},
],
};
}
function formatArgumentLabel(arg: Argument): string {
const name = arg.name();
const suffix = arg.variadic ? "..." : "";
return arg.required ? `<${name}${suffix}>` : `[${name}${suffix}]`;
}
function formatValue(value: unknown): string {
if (value === undefined) {
return "";
}
if (value === null) {
return "null";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "bigint") {
return value.toString();
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
if (Array.isArray(value)) {
if (value.length === 0) {
return "[]";
}
return value.map((item) => formatValue(item)).join(", ");
}
return JSON.stringify(value);
}
function getCommandPath(
rootName: string,
ancestors: string[],
command: Command,
): string {
return [rootName, ...ancestors, command.name()].filter(Boolean).join(" ");
}
function isHiddenCommand(command: Command): boolean {
return Boolean((command as CommandWithInternals)._hidden);
}
function isHelpCommand(parent: Command, command: Command): boolean {
const helpCmd = (parent as CommandWithInternals)._helpCommand;
return helpCmd === command;
}
function partitionOptions(options: Option[]): {
flags: Option[];
valueOptions: Option[];
} {
const flags: Option[] = [];
const valueOptions: Option[] = [];
options.forEach((option) => {
if (option.hidden) {
return;
}
if (option.required || option.optional) {
valueOptions.push(option);
} else {
flags.push(option);
}
});
return { flags, valueOptions };
}
function buildUsage(command: Command): string {
return command.createHelp().commandUsage(command).trim();
}
function formatOptionSignature(option: Option): string {
return option.flags.replace(/\s+/g, " ").trim();
}
function extractOptionPlaceholder(option: Option): string {
const match = option.flags.match(/(<[^>]+>|\[[^\]]+\])/);
return match ? match[0] : "";
}
function buildOptionUsage(commandPath: string, option: Option): string {
const preferred =
option.long || option.short || formatOptionSignature(option);
const placeholder = extractOptionPlaceholder(option);
const usage = [commandPath, preferred, placeholder]
.filter(Boolean)
.join(" ")
.replace(/\s+/g, " ")
.trim();
return usage;
}
function buildOptionDetails(option: Option): string[] {
const details: string[] = [];
if (option.mandatory) {
details.push("Must be specified.");
}
if (option.required) {
details.push("Requires a value.");
} else if (option.optional) {
details.push("Accepts an optional value.");
}
if (option.defaultValueDescription) {
details.push(`Default: ${option.defaultValueDescription}.`);
} else if (option.defaultValue !== undefined) {
details.push(`Default: ${formatValue(option.defaultValue)}.`);
}
if (option.argChoices && option.argChoices.length > 0) {
details.push(`Allowed values: ${option.argChoices.join(", ")}.`);
}
if (option.envVar) {
details.push(`Environment variable: ${option.envVar}.`);
}
if (option.presetArg !== undefined) {
details.push(`Preset value: ${formatValue(option.presetArg)}.`);
}
return details;
}
type BuildOptionEntriesArgs = {
options: Option[];
commandPath: string;
depth: number;
};
function buildOptionEntries({
options,
commandPath,
depth,
}: BuildOptionEntriesArgs): Content[] {
const nodes: Content[] = [];
const headingDepth = Math.min(depth + 1, 6);
options.forEach((option) => {
const signature = formatOptionSignature(option);
nodes.push(createHeading(headingDepth, [createInlineCode(signature)]));
nodes.push({
type: "code",
lang: "bash",
value: buildOptionUsage(commandPath, option),
});
if (option.description) {
nodes.push(createParagraph(option.description));
}
const details = buildOptionDetails(option);
if (details.length > 0) {
nodes.push(createParagraph(details.join(" ")));
}
});
return nodes;
}
function buildArgumentListItems(args: readonly Argument[]): ListItem[] {
return args.map((arg) => {
const children: PhrasingContent[] = [
createInlineCode(formatArgumentLabel(arg)),
];
if (arg.description) {
children.push({ type: "text", value: ` — ${arg.description}` });
}
const details: string[] = [];
if (arg.defaultValueDescription) {
details.push(`default: ${arg.defaultValueDescription}`);
} else if (arg.defaultValue !== undefined) {
details.push(`default: ${formatValue(arg.defaultValue)}`);
}
if (arg.argChoices && arg.argChoices.length > 0) {
details.push(`choices: ${arg.argChoices.join(", ")}`);
}
if (!arg.required) {
details.push("optional");
}
if (details.length > 0) {
children.push({
type: "text",
value: ` (${details.join("; ")})`,
});
}
return createListItem(children);
});
}
type BuildCommandSectionOptions = {
command: Command;
rootName: string;
ancestors: string[];
depth: number;
useRootIntro: boolean;
};
function buildCommandSection({
command,
rootName,
ancestors,
depth,
useRootIntro,
}: BuildCommandSectionOptions): Content[] {
const nodes: Content[] = [];
const commandPath = getCommandPath(rootName, ancestors, command);
const isRootCommand = ancestors.length === 0;
const shouldUseIntro = isRootCommand && useRootIntro;
const headingContent = shouldUseIntro
? "Introduction"
: [createInlineCode(commandPath)];
nodes.push(createHeading(depth, headingContent));
const description = command.description();
if (description) {
nodes.push(createParagraph(description));
}
const usage = buildUsage(command);
if (usage) {
const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6);
nodes.push(createHeading(sectionDepth, "Usage"));
nodes.push({
type: "paragraph",
children: [createInlineCode(usage)],
});
}
const aliases = command.aliases();
if (aliases.length > 0) {
const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6);
nodes.push(createHeading(sectionDepth, "Aliases"));
nodes.push(
createList(
aliases.map((alias) => createListItem([createInlineCode(alias)])),
),
);
}
const args = command.registeredArguments ?? [];
if (args.length > 0) {
const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6);
nodes.push(createHeading(sectionDepth, "Arguments"));
nodes.push(createList(buildArgumentListItems(args)));
}
const visibleOptions = command.options.filter((option) => !option.hidden);
if (visibleOptions.length > 0) {
const { flags, valueOptions } = partitionOptions(visibleOptions);
const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6);
if (valueOptions.length > 0) {
nodes.push(createHeading(sectionDepth, "Options"));
nodes.push(
...buildOptionEntries({
options: valueOptions,
commandPath,
depth: sectionDepth,
}),
);
}
if (flags.length > 0) {
nodes.push(createHeading(sectionDepth, "Flags"));
nodes.push(
...buildOptionEntries({
options: flags,
commandPath,
depth: sectionDepth,
}),
);
}
}
const subcommands = command.commands.filter(
(sub) =>
!isHiddenCommand(sub) &&
!isHelpCommand(command, sub) &&
sub.parent === command,
);
if (subcommands.length > 0) {
const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6);
nodes.push(createHeading(sectionDepth, "Subcommands"));
subcommands.forEach((sub) => {
nodes.push(
...buildCommandSection({
command: sub,
rootName,
ancestors: [...ancestors, command.name()],
depth: Math.min(sectionDepth + 1, 6),
useRootIntro,
}),
);
});
}
return nodes;
}
function toMarkdown(root: Root): string {
return unified().use(remarkStringify).stringify(root).trimEnd();
}
async function formatWithPrettier(
content: string,
filePath: string,
): Promise<string> {
const config = await resolveConfig(filePath);
return prettierFormat(content, {
...(config ?? {}),
filepath: filePath,
});
}
type CommandDoc = {
fileName: string;
markdown: string;
mdx: string;
commandPath: string;
};
type BuildCommandDocOptions = {
useRootIntro?: boolean;
};
function buildCommandDoc(
command: Command,
rootName: string,
options?: BuildCommandDocOptions,
): CommandDoc {
const useRootIntro = options?.useRootIntro ?? true;
const commandPath = getCommandPath(rootName, [], command);
const title = commandPath;
const subtitle = `CLI reference docs for ${command.name()} command`;
const root: Root = {
type: "root",
children: buildCommandSection({
command,
rootName,
ancestors: [],
depth: 2,
useRootIntro,
}),
};
const markdown = toMarkdown(root);
const frontmatter = [
FRONTMATTER_DELIMITER,
`title: ${formatYamlValue(title)}`,
`subtitle: ${formatYamlValue(subtitle)}`,
FRONTMATTER_DELIMITER,
"",
].join("\n");
const mdx = `${frontmatter}${markdown}\n`;
const fileName = `${slugifyCommandName(command.name())}.mdx`;
return { fileName, markdown, mdx, commandPath };
}
function buildIndexDoc(commands: Command[], rootName: string): CommandDoc {
const root: Root = {
type: "root",
children: [
createHeading(2, "Introduction"),
createParagraph(
`This page aggregates CLI reference docs for ${rootName} commands.`,
),
],
};
commands.forEach((command) => {
root.children.push(
...buildCommandSection({
command,
rootName,
ancestors: [],
depth: 2,
useRootIntro: false,
}),
);
});
const markdown = toMarkdown(root);
const frontmatter = [
FRONTMATTER_DELIMITER,
`title: ${formatYamlValue(`${rootName} CLI reference`)}`,
"seo:",
" noindex: true",
FRONTMATTER_DELIMITER,
"",
].join("\n");
const mdx = `${frontmatter}${markdown}\n`;
return {
fileName: "index.mdx",
markdown,
mdx,
commandPath: `${rootName} (index)`,
};
}
async function main(): Promise<void> {
const repoRoot = getRepoRoot();
const cli = await getProgram(repoRoot);
const outputArg = process.argv[2];
if (!outputArg) {
throw new Error(
"Output directory is required. Usage: generate-cli-docs <output-directory>",
);
}
const outputDir = resolve(process.cwd(), outputArg);
await mkdir(outputDir, { recursive: true });
const topLevelCommands = cli.commands.filter(
(command) => command.parent === cli && !isHiddenCommand(command),
);
if (topLevelCommands.length === 0) {
console.warn("No top-level commands found. Nothing to document.");
return;
}
const docs = topLevelCommands.map((command) =>
buildCommandDoc(command, cli.name()),
);
const indexDoc = buildIndexDoc(topLevelCommands, cli.name());
for (const doc of [...docs, indexDoc]) {
const filePath = join(outputDir, doc.fileName);
await mkdir(dirname(filePath), { recursive: true });
const formatted = await formatWithPrettier(doc.mdx, filePath);
await writeFile(filePath, formatted, "utf8");
console.log(`✅ Saved ${doc.commandPath} docs to ${filePath}`);
}
if (process.env.GITHUB_ACTIONS) {
const commentMarker = "<!-- generate-cli-docs -->";
const combinedMarkdown = docs
.map((doc) => doc.markdown)
.join("\n\n---\n\n");
const commentBody = [
commentMarker,
"",
"Your PR updates Lingo.dev CLI behavior. Please review the regenerated reference docs below.",
"",
combinedMarkdown,
].join("\n");
await createOrUpdateGitHubComment({
commentMarker,
body: commentBody,
});
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/xcode-xcstrings-icu.ts:
--------------------------------------------------------------------------------
```typescript
/**
* ICU MessageFormat conversion utilities for xcstrings pluralization
*
* This module handles converting between xcstrings plural format and ICU MessageFormat,
* preserving format specifier precision and supporting multiple variables.
*/
/**
* Type guard marker to distinguish ICU objects from user data
* Using a symbol ensures no collision with user data
*/
const ICU_TYPE_MARKER = Symbol.for("@lingo.dev/icu-plural-object");
export interface PluralWithMetadata {
icu: string;
_meta?: {
variables: {
[varName: string]: {
format: string;
role: "plural" | "other";
};
};
};
// Type marker for robust detection
[ICU_TYPE_MARKER]?: true;
}
/**
* CLDR plural categories as defined by Unicode
* https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html
*/
const CLDR_PLURAL_CATEGORIES = new Set([
"zero",
"one",
"two",
"few",
"many",
"other",
]);
/**
* Type guard to check if a value is a valid ICU object with metadata
* This is more robust than simple key checking
*/
export function isICUPluralObject(value: any): value is PluralWithMetadata {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
// Check for type marker (most reliable)
if (ICU_TYPE_MARKER in value) {
return true;
}
// Fallback: validate structure thoroughly
if (!("icu" in value) || typeof value.icu !== "string") {
return false;
}
// Must match ICU plural format pattern
const icuPluralPattern = /^\{[\w]+,\s*plural,\s*.+\}$/;
if (!icuPluralPattern.test(value.icu)) {
return false;
}
// If _meta exists, validate its structure
if (value._meta !== undefined) {
if (
typeof value._meta !== "object" ||
!value._meta.variables ||
typeof value._meta.variables !== "object"
) {
return false;
}
// Validate each variable entry
for (const [varName, varMeta] of Object.entries(value._meta.variables)) {
if (
!varMeta ||
typeof varMeta !== "object" ||
typeof (varMeta as any).format !== "string" ||
((varMeta as any).role !== "plural" &&
(varMeta as any).role !== "other")
) {
return false;
}
}
}
return true;
}
/**
* Type guard to check if an object is a valid plural forms object
* Ensures ALL keys are CLDR categories to avoid false positives
*/
export function isPluralFormsObject(
value: any,
): value is Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const keys = Object.keys(value);
// Must have at least one key
if (keys.length === 0) {
return false;
}
// Check if ALL keys are CLDR plural categories
const allKeysAreCldr = keys.every((key) => CLDR_PLURAL_CATEGORIES.has(key));
if (!allKeysAreCldr) {
return false;
}
// Check if all values are strings
const allValuesAreStrings = keys.every(
(key) => typeof value[key] === "string",
);
if (!allValuesAreStrings) {
return false;
}
// Must have at least "other" form (required in all locales)
if (!("other" in value)) {
return false;
}
return true;
}
/**
* Get required CLDR plural categories for a locale
*
* @throws {Error} If locale is invalid and cannot be resolved
*/
function getRequiredPluralCategories(locale: string): string[] {
try {
const pluralRules = new Intl.PluralRules(locale);
const categories = pluralRules.resolvedOptions().pluralCategories;
if (!categories || categories.length === 0) {
throw new Error(`No plural categories found for locale: ${locale}`);
}
return categories;
} catch (error) {
// Log warning but use safe fallback
console.warn(
`[xcode-xcstrings-icu] Failed to resolve plural categories for locale "${locale}". ` +
`Using fallback ["one", "other"]. Error: ${error instanceof Error ? error.message : String(error)}`,
);
return ["one", "other"];
}
}
/**
* Map CLDR category names to their numeric values for exact match conversion
*/
const CLDR_CATEGORY_TO_NUMBER: Record<string, number> = {
zero: 0,
one: 1,
two: 2,
};
/**
* Map numeric values back to CLDR category names
*/
const NUMBER_TO_CLDR_CATEGORY: Record<number, string> = {
0: "zero",
1: "one",
2: "two",
};
/**
* Convert xcstrings plural forms to ICU MessageFormat with metadata
*
* @param pluralForms - Record of plural forms (e.g., { one: "1 item", other: "%d items" })
* @param sourceLocale - Source language locale (e.g., "en", "ru") to determine required vs optional forms
* @returns ICU string with metadata for format preservation
*
* @example
* xcstringsToPluralWithMeta({ one: "1 mile", other: "%.1f miles" }, "en")
* // Returns:
* // {
* // icu: "{count, plural, one {1 mile} other {# miles}}",
* // _meta: { variables: { count: { format: "%.1f", role: "plural" } } }
* // }
*
* @example
* xcstringsToPluralWithMeta({ zero: "No items", one: "1 item", other: "%d items" }, "en")
* // Returns:
* // {
* // icu: "{count, plural, =0 {No items} one {1 item} other {# items}}",
* // _meta: { variables: { count: { format: "%d", role: "plural" } } }
* // }
*/
export function xcstringsToPluralWithMeta(
pluralForms: Record<string, string>,
sourceLocale: string = "en",
): PluralWithMetadata {
if (!pluralForms || Object.keys(pluralForms).length === 0) {
throw new Error("pluralForms cannot be empty");
}
// Get required CLDR categories for this locale
const requiredCategories = getRequiredPluralCategories(sourceLocale);
const variables: Record<
string,
{ format: string; role: "plural" | "other" }
> = {};
// Regex to match format specifiers:
// %[position$][flags][width][.precision][length]specifier
// Examples: %d, %lld, %.2f, %@, %1$@, %2$lld
const formatRegex =
/(%(?:(\d+)\$)?(?:[+-])?(?:\d+)?(?:\.(\d+))?([lhqLzjt]*)([diuoxXfFeEgGaAcspn@]))/g;
// Analyze ALL forms to find the one with most variables (typically "other")
let maxMatches: RegExpMatchArray[] = [];
let maxMatchText = "";
for (const [form, text] of Object.entries(pluralForms)) {
// Skip if text is not a string
if (typeof text !== "string") {
console.warn(
`Warning: Plural form "${form}" has non-string value:`,
text,
);
continue;
}
const matches = [...text.matchAll(formatRegex)];
if (matches.length > maxMatches.length) {
maxMatches = matches;
maxMatchText = text;
}
}
let lastNumericIndex = -1;
// Find which variable is the plural one (heuristic: last numeric format)
maxMatches.forEach((match, idx) => {
const specifier = match[5];
// Numeric specifiers that could be plural counts
if (/[diuoxXfFeE]/.test(specifier)) {
lastNumericIndex = idx;
}
});
// Build variable metadata
let nonPluralCounter = 0;
maxMatches.forEach((match, idx) => {
const fullFormat = match[1]; // e.g., "%.2f", "%lld", "%@"
const position = match[2]; // e.g., "1" from "%1$@"
const precision = match[3]; // e.g., "2" from "%.2f"
const lengthMod = match[4]; // e.g., "ll" from "%lld"
const specifier = match[5]; // e.g., "f", "d", "@"
const isPluralVar = idx === lastNumericIndex;
const varName = isPluralVar ? "count" : `var${nonPluralCounter++}`;
variables[varName] = {
format: fullFormat,
role: isPluralVar ? "plural" : "other",
};
});
// Build ICU string for each plural form
const variableKeys = Object.keys(variables);
const icuForms = Object.entries(pluralForms)
.filter(([form, text]) => {
// Skip non-string values
if (typeof text !== "string") {
return false;
}
return true;
})
.map(([form, text]) => {
let processed = text as string;
let vIdx = 0;
// Replace format specifiers with ICU equivalents
processed = processed.replace(formatRegex, () => {
if (vIdx >= variableKeys.length) {
// Shouldn't happen, but fallback
vIdx++;
return "#";
}
const varName = variableKeys[vIdx];
const varMeta = variables[varName];
vIdx++;
if (varMeta.role === "plural") {
// Plural variable uses # in ICU
return "#";
} else {
// Non-plural variables use {varName}
return `{${varName}}`;
}
});
// Determine if this form is required or optional
const isRequired = requiredCategories.includes(form);
const formKey =
!isRequired && form in CLDR_CATEGORY_TO_NUMBER
? `=${CLDR_CATEGORY_TO_NUMBER[form]}` // Convert optional forms to exact matches
: form; // Keep required forms as CLDR keywords
return `${formKey} {${processed}}`;
})
.join(" ");
// Find plural variable name
const pluralVarName =
Object.keys(variables).find((name) => variables[name].role === "plural") ||
"count";
const icu = `{${pluralVarName}, plural, ${icuForms}}`;
const result: PluralWithMetadata = {
icu,
_meta: Object.keys(variables).length > 0 ? { variables } : undefined,
[ICU_TYPE_MARKER]: true, // Add type marker for robust detection
};
return result;
}
/**
* Convert ICU MessageFormat with metadata back to xcstrings plural forms
*
* Uses metadata to restore original format specifiers with full precision.
*
* @param data - ICU string with metadata
* @returns Record of plural forms suitable for xcstrings
*
* @example
* pluralWithMetaToXcstrings({
* icu: "{count, plural, one {# километр} other {# километров}}",
* _meta: { variables: { count: { format: "%.1f", role: "plural" } } }
* })
* // Returns: { one: "%.1f километр", other: "%.1f километров" }
*/
export function pluralWithMetaToXcstrings(
data: PluralWithMetadata,
): Record<string, string> {
if (!data.icu) {
throw new Error("ICU string is required");
}
// Parse ICU MessageFormat string
const ast = parseICU(data.icu);
if (!ast || ast.length === 0) {
throw new Error("Invalid ICU format");
}
// Find the plural node
const pluralNode = ast.find((node) => node.type === "plural");
if (!pluralNode) {
throw new Error("No plural found in ICU format");
}
const forms: Record<string, string> = {};
// Convert each plural form back to xcstrings format
for (const [form, option] of Object.entries(pluralNode.options)) {
let text = "";
const optionValue = (option as any).value;
for (const element of optionValue) {
if (element.type === "literal") {
// Plain text
text += element.value;
} else if (element.type === "pound") {
// # → look up plural variable format in metadata
const pluralVar = Object.entries(data._meta?.variables || {}).find(
([_, meta]) => meta.role === "plural",
);
text += pluralVar?.[1].format || "%lld";
} else if (element.type === "argument") {
// {varName} → look up variable format by name
const varName = element.value;
const varMeta = data._meta?.variables?.[varName];
text += varMeta?.format || "%@";
}
}
// Convert exact matches (=0, =1) back to CLDR category names
let xcstringsFormName = form;
if (form.startsWith("=")) {
const numValue = parseInt(form.substring(1), 10);
xcstringsFormName = NUMBER_TO_CLDR_CATEGORY[numValue] || form;
}
forms[xcstringsFormName] = text;
}
return forms;
}
/**
* Simple ICU MessageFormat parser
*
* This is a lightweight parser for our specific use case.
* For production, consider using @formatjs/icu-messageformat-parser
*/
function parseICU(icu: string): any[] {
// Remove outer braces and split by "plural,"
const match = icu.match(/\{(\w+),\s*plural,\s*(.+)\}$/);
if (!match) {
throw new Error("Invalid ICU plural format");
}
const varName = match[1];
const formsText = match[2];
// Parse plural forms manually to handle nested braces
const options: Record<string, any> = {};
let i = 0;
while (i < formsText.length) {
// Skip whitespace
while (i < formsText.length && /\s/.test(formsText[i])) {
i++;
}
if (i >= formsText.length) break;
// Read form name (e.g., "one", "other", "few", "=0", "=1")
let formName = "";
// Check for exact match syntax (=0, =1, etc.)
if (formsText[i] === "=") {
formName += formsText[i];
i++;
// Read the number
while (i < formsText.length && /\d/.test(formsText[i])) {
formName += formsText[i];
i++;
}
} else {
// Read word form name
while (i < formsText.length && /\w/.test(formsText[i])) {
formName += formsText[i];
i++;
}
}
if (!formName) break;
// Skip whitespace and find opening brace
while (i < formsText.length && /\s/.test(formsText[i])) {
i++;
}
if (i >= formsText.length || formsText[i] !== "{") {
throw new Error(`Expected '{' after form name '${formName}'`);
}
// Find matching closing brace
i++; // skip opening brace
let braceCount = 1;
let formText = "";
while (i < formsText.length && braceCount > 0) {
if (formsText[i] === "{") {
braceCount++;
formText += formsText[i];
} else if (formsText[i] === "}") {
braceCount--;
if (braceCount > 0) {
formText += formsText[i];
}
} else {
formText += formsText[i];
}
i++;
}
if (braceCount !== 0) {
// Provide detailed error with context
const preview = formsText.substring(
Math.max(0, i - 50),
Math.min(formsText.length, i + 50),
);
throw new Error(
`Unclosed brace for form '${formName}' in ICU MessageFormat.\n` +
`Expected ${braceCount} more closing brace(s).\n` +
`Context: ...${preview}...\n` +
`Full ICU: {${varName}, plural, ${formsText}}`,
);
}
// Parse the form text to extract elements
const elements = parseFormText(formText);
options[formName] = {
value: elements,
};
}
return [
{
type: "plural",
value: varName,
options,
},
];
}
/**
* Parse form text into elements (literals, pounds, arguments)
*/
function parseFormText(text: string): any[] {
const elements: any[] = [];
let currentText = "";
let i = 0;
while (i < text.length) {
if (text[i] === "#") {
// Add accumulated text as literal
if (currentText) {
elements.push({ type: "literal", value: currentText });
currentText = "";
}
// Add pound element
elements.push({ type: "pound" });
i++;
} else if (text[i] === "{") {
// Variable reference - need to handle nested braces
// Add accumulated text as literal
if (currentText) {
elements.push({ type: "literal", value: currentText });
currentText = "";
}
// Find matching closing brace (handle nesting)
let braceCount = 1;
let j = i + 1;
while (j < text.length && braceCount > 0) {
if (text[j] === "{") {
braceCount++;
} else if (text[j] === "}") {
braceCount--;
}
j++;
}
if (braceCount !== 0) {
throw new Error("Unclosed variable reference");
}
// j is now positioned after the closing brace
const varName = text.slice(i + 1, j - 1);
elements.push({ type: "argument", value: varName });
i = j;
} else {
currentText += text[i];
i++;
}
}
// Add remaining text
if (currentText) {
elements.push({ type: "literal", value: currentText });
}
return elements;
}
```