This is page 14 of 20. Use http://codebase.md/lingodotdev/lingo.dev?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .changeset
│ ├── config.json
│ └── README.md
├── .claude
│ ├── agents
│ │ └── code-architect-reviewer.md
│ └── commands
│ ├── analyze-bucket-type.md
│ └── create-bucket-docs.md
├── .editorconfig
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── docker.yml
│ ├── lingodotdev.yml
│ ├── pr-check.yml
│ ├── pr-lint.yml
│ └── release.yml
├── .gitignore
├── .husky
│ └── commit-msg
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .vscode
│ ├── extensions.json
│ ├── launch.json
│ └── settings.json
├── action.yml
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── commitlint.config.js
├── composer.json
├── content
│ ├── banner.compiler.png
│ ├── banner.dark.png
│ └── banner.launch.png
├── CONTRIBUTING.md
├── DEBUGGING.md
├── demo
│ ├── adonisjs
│ │ ├── .editorconfig
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── ace.js
│ │ ├── adonisrc.ts
│ │ ├── app
│ │ │ ├── exceptions
│ │ │ │ └── handler.ts
│ │ │ └── middleware
│ │ │ └── container_bindings_middleware.ts
│ │ ├── bin
│ │ │ ├── console.ts
│ │ │ ├── server.ts
│ │ │ └── test.ts
│ │ ├── CHANGELOG.md
│ │ ├── config
│ │ │ ├── app.ts
│ │ │ ├── bodyparser.ts
│ │ │ ├── cors.ts
│ │ │ ├── hash.ts
│ │ │ ├── inertia.ts
│ │ │ ├── logger.ts
│ │ │ ├── session.ts
│ │ │ ├── shield.ts
│ │ │ ├── static.ts
│ │ │ └── vite.ts
│ │ ├── eslint.config.js
│ │ ├── inertia
│ │ │ ├── app
│ │ │ │ ├── app.tsx
│ │ │ │ └── ssr.tsx
│ │ │ ├── css
│ │ │ │ └── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── pages
│ │ │ │ ├── errors
│ │ │ │ │ ├── not_found.tsx
│ │ │ │ │ └── server_error.tsx
│ │ │ │ └── home.tsx
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── resources
│ │ │ └── views
│ │ │ └── inertia_layout.edge
│ │ ├── start
│ │ │ ├── env.ts
│ │ │ ├── kernel.ts
│ │ │ └── routes.ts
│ │ ├── tests
│ │ │ └── bootstrap.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── next-app
│ │ ├── .gitignore
│ │ ├── CHANGELOG.md
│ │ ├── eslint.config.mjs
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── public
│ │ │ ├── file.svg
│ │ │ ├── globe.svg
│ │ │ ├── next.svg
│ │ │ ├── vercel.svg
│ │ │ └── window.svg
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── client-component.tsx
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── lingo-dot-dev.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── test
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── hero-actions.tsx
│ │ │ │ ├── hero-subtitle.tsx
│ │ │ │ ├── hero-title.tsx
│ │ │ │ └── index.ts
│ │ │ └── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ └── tsconfig.json
│ ├── react-router-app
│ │ ├── .dockerignore
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── root.tsx
│ │ │ ├── routes
│ │ │ │ ├── home.tsx
│ │ │ │ └── test.tsx
│ │ │ ├── routes.ts
│ │ │ └── welcome
│ │ │ ├── lingo-dot-dev.tsx
│ │ │ ├── logo-dark.svg
│ │ │ ├── logo-light.svg
│ │ │ └── welcome.tsx
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ ├── public
│ │ │ └── favicon.ico
│ │ ├── react-router.config.ts
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── vite-project
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── README.md
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ └── test.tsx
│ │ ├── index.css
│ │ ├── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ ├── lingo-dot-dev.tsx
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── Dockerfile
├── i18n.json
├── i18n.lock
├── integrations
│ └── directus
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── docker-compose.yml
│ ├── Dockerfile
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── api.ts
│ │ ├── app.ts
│ │ └── index.spec.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── ISSUE_TEMPLATE.md
├── legacy
│ ├── cli
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ └── readme.md
│ └── sdk
│ ├── CHANGELOG.md
│ ├── index.d.ts
│ ├── index.js
│ ├── package.json
│ └── README.md
├── LICENSE.md
├── mcp.md
├── package.json
├── packages
│ ├── cli
│ │ ├── assets
│ │ │ ├── failure.mp3
│ │ │ └── success.mp3
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── demo
│ │ │ ├── android
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── csv
│ │ │ │ ├── example.csv
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── demo.spec.ts
│ │ │ ├── ejs
│ │ │ │ ├── en
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── es
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── flutter
│ │ │ │ ├── en
│ │ │ │ │ └── example.arb
│ │ │ │ ├── es
│ │ │ │ │ └── example.arb
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── html
│ │ │ │ ├── en
│ │ │ │ │ └── example.html
│ │ │ │ ├── es
│ │ │ │ │ └── example.html
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json
│ │ │ │ ├── en
│ │ │ │ │ └── example.json
│ │ │ │ ├── es
│ │ │ │ │ └── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json-dictionary
│ │ │ │ ├── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json5
│ │ │ │ ├── en
│ │ │ │ │ └── example.json5
│ │ │ │ ├── es
│ │ │ │ │ └── example.json5
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── jsonc
│ │ │ │ ├── en
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── es
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── i18n.json
│ │ │ │ ├── i18n.lock
│ │ │ │ └── ru
│ │ │ │ └── example.jsonc
│ │ │ ├── markdoc
│ │ │ │ ├── en
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── es
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── markdown
│ │ │ │ ├── en
│ │ │ │ │ └── example.md
│ │ │ │ ├── es
│ │ │ │ │ └── example.md
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── mdx
│ │ │ │ ├── en
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── es
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── php
│ │ │ │ ├── en
│ │ │ │ │ └── example.php
│ │ │ │ ├── es
│ │ │ │ │ └── example.php
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── po
│ │ │ │ ├── en
│ │ │ │ │ └── example.po
│ │ │ │ ├── es
│ │ │ │ │ └── example.po
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── properties
│ │ │ │ ├── en
│ │ │ │ │ └── example.properties
│ │ │ │ ├── es
│ │ │ │ │ └── example.properties
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── run_i18n.sh
│ │ │ ├── srt
│ │ │ │ ├── en
│ │ │ │ │ └── example.srt
│ │ │ │ ├── es
│ │ │ │ │ └── example.srt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── txt
│ │ │ │ ├── en
│ │ │ │ │ └── example.txt
│ │ │ │ ├── es
│ │ │ │ │ └── example.txt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── typescript
│ │ │ │ ├── en
│ │ │ │ │ └── example.ts
│ │ │ │ ├── es
│ │ │ │ │ └── example.ts
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vtt
│ │ │ │ ├── en
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── es
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vue-json
│ │ │ │ ├── example.vue
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-strings
│ │ │ │ ├── en
│ │ │ │ │ └── example.strings
│ │ │ │ ├── es
│ │ │ │ │ └── example.strings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-stringsdict
│ │ │ │ ├── en
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── es
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings-v2
│ │ │ │ ├── complex-example.xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xliff
│ │ │ │ ├── en
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ └── example-v2.xliff
│ │ │ │ ├── es
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ ├── example-v2.xliff
│ │ │ │ │ └── example.xliff
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xml
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── yaml
│ │ │ │ ├── en
│ │ │ │ │ └── example.yml
│ │ │ │ ├── es
│ │ │ │ │ └── example.yml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ └── yaml-root-key
│ │ │ ├── en
│ │ │ │ └── example.yml
│ │ │ ├── es
│ │ │ │ └── example.yml
│ │ │ ├── i18n.json
│ │ │ └── i18n.lock
│ │ ├── i18n.json
│ │ ├── i18n.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── cli
│ │ │ │ ├── cmd
│ │ │ │ │ ├── auth.ts
│ │ │ │ │ ├── ci
│ │ │ │ │ │ ├── flows
│ │ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ │ ├── in-branch.ts
│ │ │ │ │ │ │ └── pull-request.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── platforms
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── bitbucket.ts
│ │ │ │ │ │ ├── github.ts
│ │ │ │ │ │ ├── gitlab.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── cleanup.ts
│ │ │ │ │ ├── config
│ │ │ │ │ │ ├── get.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── set.ts
│ │ │ │ │ │ └── unset.ts
│ │ │ │ │ ├── i18n.ts
│ │ │ │ │ ├── init.ts
│ │ │ │ │ ├── lockfile.ts
│ │ │ │ │ ├── login.ts
│ │ │ │ │ ├── logout.ts
│ │ │ │ │ ├── may-the-fourth.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── purge.ts
│ │ │ │ │ ├── run
│ │ │ │ │ │ ├── _const.ts
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── execute.spec.ts
│ │ │ │ │ │ ├── execute.ts
│ │ │ │ │ │ ├── frozen.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── plan.ts
│ │ │ │ │ │ ├── setup.ts
│ │ │ │ │ │ └── watch.ts
│ │ │ │ │ ├── show
│ │ │ │ │ │ ├── _shared-key-command.ts
│ │ │ │ │ │ ├── config.ts
│ │ │ │ │ │ ├── files.ts
│ │ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── locale.ts
│ │ │ │ │ │ └── locked-keys.ts
│ │ │ │ │ └── status.ts
│ │ │ │ ├── constants.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loaders
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── _utils.ts
│ │ │ │ │ ├── android.spec.ts
│ │ │ │ │ ├── android.ts
│ │ │ │ │ ├── csv.spec.ts
│ │ │ │ │ ├── csv.ts
│ │ │ │ │ ├── dato
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── api.ts
│ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ ├── filter.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── ejs.spec.ts
│ │ │ │ │ ├── ejs.ts
│ │ │ │ │ ├── ensure-key-order.spec.ts
│ │ │ │ │ ├── ensure-key-order.ts
│ │ │ │ │ ├── flat.spec.ts
│ │ │ │ │ ├── flat.ts
│ │ │ │ │ ├── flutter.spec.ts
│ │ │ │ │ ├── flutter.ts
│ │ │ │ │ ├── formatters
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── biome.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── prettier.ts
│ │ │ │ │ ├── html.ts
│ │ │ │ │ ├── icu-safety.spec.ts
│ │ │ │ │ ├── ignored-keys-buckets.spec.ts
│ │ │ │ │ ├── ignored-keys.spec.ts
│ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── inject-locale.spec.ts
│ │ │ │ │ ├── inject-locale.ts
│ │ │ │ │ ├── json-dictionary.spec.ts
│ │ │ │ │ ├── json-dictionary.ts
│ │ │ │ │ ├── json-sorting.test.ts
│ │ │ │ │ ├── json-sorting.ts
│ │ │ │ │ ├── json.ts
│ │ │ │ │ ├── json5.spec.ts
│ │ │ │ │ ├── json5.ts
│ │ │ │ │ ├── jsonc.spec.ts
│ │ │ │ │ ├── jsonc.ts
│ │ │ │ │ ├── locked-keys.spec.ts
│ │ │ │ │ ├── locked-keys.ts
│ │ │ │ │ ├── locked-patterns.spec.ts
│ │ │ │ │ ├── locked-patterns.ts
│ │ │ │ │ ├── markdoc.spec.ts
│ │ │ │ │ ├── markdoc.ts
│ │ │ │ │ ├── markdown.ts
│ │ │ │ │ ├── mdx.spec.ts
│ │ │ │ │ ├── mdx.ts
│ │ │ │ │ ├── mdx2
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── code-placeholder.spec.ts
│ │ │ │ │ │ ├── code-placeholder.ts
│ │ │ │ │ │ ├── frontmatter-split.spec.ts
│ │ │ │ │ │ ├── frontmatter-split.ts
│ │ │ │ │ │ ├── localizable-document.spec.ts
│ │ │ │ │ │ ├── localizable-document.ts
│ │ │ │ │ │ ├── section-split.spec.ts
│ │ │ │ │ │ ├── section-split.ts
│ │ │ │ │ │ └── sections-split-2.ts
│ │ │ │ │ ├── passthrough.ts
│ │ │ │ │ ├── php.ts
│ │ │ │ │ ├── plutil-json-loader.ts
│ │ │ │ │ ├── po
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── properties.ts
│ │ │ │ │ ├── root-key.ts
│ │ │ │ │ ├── srt.ts
│ │ │ │ │ ├── sync.ts
│ │ │ │ │ ├── text-file.ts
│ │ │ │ │ ├── txt.ts
│ │ │ │ │ ├── typescript
│ │ │ │ │ │ ├── cjs-interop.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── unlocalizable.spec.ts
│ │ │ │ │ ├── unlocalizable.ts
│ │ │ │ │ ├── variable
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── vtt.ts
│ │ │ │ │ ├── vue-json.ts
│ │ │ │ │ ├── xcode-strings
│ │ │ │ │ │ ├── escape.ts
│ │ │ │ │ │ ├── parser.ts
│ │ │ │ │ │ ├── tokenizer.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── xcode-strings.spec.ts
│ │ │ │ │ ├── xcode-strings.ts
│ │ │ │ │ ├── xcode-stringsdict.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.ts
│ │ │ │ │ ├── xcode-xcstrings-lock-compatibility.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-v2-loader.ts
│ │ │ │ │ ├── xcode-xcstrings.spec.ts
│ │ │ │ │ ├── xcode-xcstrings.ts
│ │ │ │ │ ├── xliff.spec.ts
│ │ │ │ │ ├── xliff.ts
│ │ │ │ │ ├── xml.ts
│ │ │ │ │ └── yaml.ts
│ │ │ │ ├── localizer
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── explicit.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingodotdev.ts
│ │ │ │ ├── processor
│ │ │ │ │ ├── _base.ts
│ │ │ │ │ ├── basic.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingo.ts
│ │ │ │ └── utils
│ │ │ │ ├── auth.ts
│ │ │ │ ├── buckets.spec.ts
│ │ │ │ ├── buckets.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── cloudflare-status.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── delta.spec.ts
│ │ │ │ ├── delta.ts
│ │ │ │ ├── ensure-patterns.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── exec.spec.ts
│ │ │ │ ├── exec.ts
│ │ │ │ ├── exit-gracefully.spec.ts
│ │ │ │ ├── exit-gracefully.ts
│ │ │ │ ├── exp-backoff.ts
│ │ │ │ ├── find-locale-paths.spec.ts
│ │ │ │ ├── find-locale-paths.ts
│ │ │ │ ├── fs.ts
│ │ │ │ ├── init-ci-cd.ts
│ │ │ │ ├── key-matching.spec.ts
│ │ │ │ ├── key-matching.ts
│ │ │ │ ├── lockfile.ts
│ │ │ │ ├── md5.ts
│ │ │ │ ├── observability.ts
│ │ │ │ ├── plutil-formatter.spec.ts
│ │ │ │ ├── plutil-formatter.ts
│ │ │ │ ├── settings.ts
│ │ │ │ ├── ui.ts
│ │ │ │ └── update-gitignore.ts
│ │ │ ├── compiler
│ │ │ │ └── index.ts
│ │ │ ├── locale-codes
│ │ │ │ └── index.ts
│ │ │ ├── react
│ │ │ │ ├── client.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── react-router.ts
│ │ │ │ └── rsc.ts
│ │ │ ├── sdk
│ │ │ │ └── index.ts
│ │ │ └── spec
│ │ │ └── index.ts
│ │ ├── tests
│ │ │ └── mock-storage.ts
│ │ ├── troubleshooting.md
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ ├── tsup.config.ts
│ │ ├── types
│ │ │ ├── vtt.d.ts
│ │ │ └── xliff.d.ts
│ │ ├── vitest.config.ts
│ │ └── WATCH_MODE.md
│ ├── compiler
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── _base.ts
│ │ │ ├── _const.ts
│ │ │ ├── _loader-utils.spec.ts
│ │ │ ├── _loader-utils.ts
│ │ │ ├── _utils.spec.ts
│ │ │ ├── _utils.ts
│ │ │ ├── client-dictionary-loader.ts
│ │ │ ├── i18n-directive.spec.ts
│ │ │ ├── i18n-directive.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── jsx-attribute-flag.spec.ts
│ │ │ ├── jsx-attribute-flag.ts
│ │ │ ├── jsx-attribute-scope-inject.spec.ts
│ │ │ ├── jsx-attribute-scope-inject.ts
│ │ │ ├── jsx-attribute-scopes-export.spec.ts
│ │ │ ├── jsx-attribute-scopes-export.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-fragment.spec.ts
│ │ │ ├── jsx-fragment.ts
│ │ │ ├── jsx-html-lang.spec.ts
│ │ │ ├── jsx-html-lang.ts
│ │ │ ├── jsx-provider.spec.ts
│ │ │ ├── jsx-provider.ts
│ │ │ ├── jsx-remove-attributes.spec.ts
│ │ │ ├── jsx-remove-attributes.ts
│ │ │ ├── jsx-root-flag.spec.ts
│ │ │ ├── jsx-root-flag.ts
│ │ │ ├── jsx-scope-flag.spec.ts
│ │ │ ├── jsx-scope-flag.ts
│ │ │ ├── jsx-scope-inject.spec.ts
│ │ │ ├── jsx-scope-inject.ts
│ │ │ ├── jsx-scopes-export.spec.ts
│ │ │ ├── jsx-scopes-export.ts
│ │ │ ├── lib
│ │ │ │ └── lcp
│ │ │ │ ├── api
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prompt.spec.ts
│ │ │ │ │ ├── prompt.ts
│ │ │ │ │ ├── provider-details.spec.ts
│ │ │ │ │ ├── provider-details.ts
│ │ │ │ │ ├── shots.ts
│ │ │ │ │ ├── xml2obj.spec.ts
│ │ │ │ │ └── xml2obj.ts
│ │ │ │ ├── api.spec.ts
│ │ │ │ ├── cache.spec.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── server.spec.ts
│ │ │ │ └── server.ts
│ │ │ ├── lingo-turbopack-loader.ts
│ │ │ ├── react-router-dictionary-loader.ts
│ │ │ ├── rsc-dictionary-loader.ts
│ │ │ └── utils
│ │ │ ├── ast-key.spec.ts
│ │ │ ├── ast-key.ts
│ │ │ ├── create-locale-import-map.spec.ts
│ │ │ ├── create-locale-import-map.ts
│ │ │ ├── env.spec.ts
│ │ │ ├── env.ts
│ │ │ ├── hash.spec.ts
│ │ │ ├── hash.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── invokations.spec.ts
│ │ │ ├── invokations.ts
│ │ │ ├── jsx-attribute-scope.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-content-whitespace.spec.ts
│ │ │ ├── jsx-content.spec.ts
│ │ │ ├── jsx-content.ts
│ │ │ ├── jsx-element.spec.ts
│ │ │ ├── jsx-element.ts
│ │ │ ├── jsx-expressions.test.ts
│ │ │ ├── jsx-expressions.ts
│ │ │ ├── jsx-functions.spec.ts
│ │ │ ├── jsx-functions.ts
│ │ │ ├── jsx-scope.spec.ts
│ │ │ ├── jsx-scope.ts
│ │ │ ├── jsx-variables.spec.ts
│ │ │ ├── jsx-variables.ts
│ │ │ ├── llm-api-key.ts
│ │ │ ├── llm-api-keys.spec.ts
│ │ │ ├── locales.spec.ts
│ │ │ ├── locales.ts
│ │ │ ├── module-params.spec.ts
│ │ │ ├── module-params.ts
│ │ │ ├── observability.spec.ts
│ │ │ ├── observability.ts
│ │ │ ├── rc.spec.ts
│ │ │ └── rc.ts
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ └── vitest.config.ts
│ ├── locales
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── names
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── integration.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── parser.spec.ts
│ │ │ ├── parser.ts
│ │ │ ├── types.ts
│ │ │ ├── validation.spec.ts
│ │ │ └── validation.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── react
│ │ ├── build.config.ts
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── context.spec.tsx
│ │ │ │ ├── context.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── locale-switcher.spec.tsx
│ │ │ │ ├── locale-switcher.tsx
│ │ │ │ ├── locale.spec.ts
│ │ │ │ ├── locale.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── core
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── const.ts
│ │ │ │ ├── get-dictionary.spec.ts
│ │ │ │ ├── get-dictionary.ts
│ │ │ │ └── index.ts
│ │ │ ├── react-router
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── rsc
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ └── test
│ │ │ └── setup.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── sdk
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── abort-controller.specs.ts
│ │ │ ├── index.spec.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ └── tsup.config.ts
│ └── spec
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── config.spec.ts
│ │ ├── config.ts
│ │ ├── formats.ts
│ │ ├── index.spec.ts
│ │ ├── index.ts
│ │ ├── json-schema.ts
│ │ ├── locales.spec.ts
│ │ └── locales.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── readme
│ ├── ar.md
│ ├── bn.md
│ ├── de.md
│ ├── en.md
│ ├── es.md
│ ├── fa.md
│ ├── fr.md
│ ├── he.md
│ ├── hi.md
│ ├── it.md
│ ├── ja.md
│ ├── ko.md
│ ├── pl.md
│ ├── pt-BR.md
│ ├── ru.md
│ ├── tr.md
│ ├── uk-UA.md
│ └── zh-Hans.md
├── readme.md
├── scripts
│ ├── docs
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── generate-cli-docs.ts
│ │ │ ├── generate-config-docs.ts
│ │ │ ├── json-schema
│ │ │ │ ├── markdown-renderer.test.ts
│ │ │ │ ├── markdown-renderer.ts
│ │ │ │ ├── parser.test.ts
│ │ │ │ ├── parser.ts
│ │ │ │ └── types.ts
│ │ │ ├── utils.test.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ └── packagist-publish.php
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/scripts/docs/src/generate-cli-docs.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import type { Argument, Command, Option } from "commander";
4 | import { existsSync } from "fs";
5 | import { mkdir, writeFile } from "fs/promises";
6 | import type {
7 | Content,
8 | Heading,
9 | List,
10 | ListItem,
11 | Paragraph,
12 | PhrasingContent,
13 | Root,
14 | } from "mdast";
15 | import { dirname, join, resolve } from "path";
16 | import remarkStringify from "remark-stringify";
17 | import { unified } from "unified";
18 | import { pathToFileURL } from "url";
19 | import { createOrUpdateGitHubComment, getRepoRoot } from "./utils";
20 | import { format as prettierFormat, resolveConfig } from "prettier";
21 |
22 | type CommandWithInternals = Command & {
23 | _hidden?: boolean;
24 | _helpCommand?: Command;
25 | };
26 |
27 | const FRONTMATTER_DELIMITER = "---";
28 |
29 | async function getProgram(repoRoot: string): Promise<Command> {
30 | const filePath = resolve(
31 | repoRoot,
32 | "packages",
33 | "cli",
34 | "src",
35 | "cli",
36 | "index.ts",
37 | );
38 |
39 | if (!existsSync(filePath)) {
40 | throw new Error(`CLI source file not found at ${filePath}`);
41 | }
42 |
43 | const cliModule = (await import(pathToFileURL(filePath).href)) as {
44 | default: Command;
45 | };
46 |
47 | return cliModule.default;
48 | }
49 |
50 | function slugifyCommandName(name: string): string {
51 | const slug = name
52 | .trim()
53 | .toLowerCase()
54 | .replace(/[^a-z0-9]+/g, "-")
55 | .replace(/^-+|-+$/g, "");
56 |
57 | return slug.length > 0 ? slug : "command";
58 | }
59 |
60 | function formatYamlValue(value: string): string {
61 | const escaped = value.replace(/"/g, '\\"');
62 | return `"${escaped}"`;
63 | }
64 |
65 | function createHeading(
66 | depth: number,
67 | content: string | PhrasingContent[],
68 | ): Heading {
69 | const children = Array.isArray(content)
70 | ? content
71 | : [{ type: "text", value: content }];
72 |
73 | return {
74 | type: "heading",
75 | depth: Math.min(Math.max(depth, 1), 6),
76 | children,
77 | };
78 | }
79 |
80 | function createInlineCode(value: string): PhrasingContent {
81 | return { type: "inlineCode", value };
82 | }
83 |
84 | function createParagraph(text: string): Paragraph {
85 | return {
86 | type: "paragraph",
87 | children: createTextNodes(text),
88 | };
89 | }
90 |
91 | function createTextNodes(text: string): PhrasingContent[] {
92 | if (!text) {
93 | return [];
94 | }
95 |
96 | const nodes: PhrasingContent[] = [];
97 | const parts = text.split(/(`[^`]*`)/g);
98 |
99 | parts.forEach((part) => {
100 | if (!part) {
101 | return;
102 | }
103 |
104 | if (part.startsWith("`") && part.endsWith("`")) {
105 | nodes.push(createInlineCode(part.slice(1, -1)));
106 | } else {
107 | nodes.push(...createBracketAwareTextNodes(part));
108 | }
109 | });
110 |
111 | return nodes;
112 | }
113 |
114 | function createBracketAwareTextNodes(text: string): PhrasingContent[] {
115 | const nodes: PhrasingContent[] = [];
116 | const bracketPattern = /\[[^\]]+\]/g;
117 | let lastIndex = 0;
118 |
119 | for (const match of text.matchAll(bracketPattern)) {
120 | const [value] = match;
121 | const start = match.index ?? 0;
122 |
123 | if (start > lastIndex) {
124 | nodes.push({ type: "text", value: text.slice(lastIndex, start) });
125 | }
126 |
127 | nodes.push(createInlineCode(value));
128 | lastIndex = start + value.length;
129 | }
130 |
131 | if (lastIndex < text.length) {
132 | nodes.push({ type: "text", value: text.slice(lastIndex) });
133 | }
134 |
135 | if (nodes.length === 0) {
136 | nodes.push({ type: "text", value: text });
137 | }
138 |
139 | return nodes;
140 | }
141 |
142 | function createList(items: ListItem[]): List {
143 | return {
144 | type: "list",
145 | ordered: false,
146 | spread: false,
147 | children: items,
148 | };
149 | }
150 |
151 | function createListItem(children: PhrasingContent[]): ListItem {
152 | return {
153 | type: "listItem",
154 | spread: false,
155 | children: [
156 | {
157 | type: "paragraph",
158 | children,
159 | },
160 | ],
161 | };
162 | }
163 |
164 | function formatArgumentLabel(arg: Argument): string {
165 | const name = arg.name();
166 | const suffix = arg.variadic ? "..." : "";
167 | return arg.required ? `<${name}${suffix}>` : `[${name}${suffix}]`;
168 | }
169 |
170 | function formatValue(value: unknown): string {
171 | if (value === undefined) {
172 | return "";
173 | }
174 |
175 | if (value === null) {
176 | return "null";
177 | }
178 |
179 | if (typeof value === "string") {
180 | return value;
181 | }
182 |
183 | if (typeof value === "number" || typeof value === "bigint") {
184 | return value.toString();
185 | }
186 |
187 | if (typeof value === "boolean") {
188 | return value ? "true" : "false";
189 | }
190 |
191 | if (Array.isArray(value)) {
192 | if (value.length === 0) {
193 | return "[]";
194 | }
195 | return value.map((item) => formatValue(item)).join(", ");
196 | }
197 |
198 | return JSON.stringify(value);
199 | }
200 |
201 | function getCommandPath(
202 | rootName: string,
203 | ancestors: string[],
204 | command: Command,
205 | ): string {
206 | return [rootName, ...ancestors, command.name()].filter(Boolean).join(" ");
207 | }
208 |
209 | function isHiddenCommand(command: Command): boolean {
210 | return Boolean((command as CommandWithInternals)._hidden);
211 | }
212 |
213 | function isHelpCommand(parent: Command, command: Command): boolean {
214 | const helpCmd = (parent as CommandWithInternals)._helpCommand;
215 | return helpCmd === command;
216 | }
217 |
218 | function partitionOptions(options: Option[]): {
219 | flags: Option[];
220 | valueOptions: Option[];
221 | } {
222 | const flags: Option[] = [];
223 | const valueOptions: Option[] = [];
224 |
225 | options.forEach((option) => {
226 | if (option.hidden) {
227 | return;
228 | }
229 |
230 | if (option.required || option.optional) {
231 | valueOptions.push(option);
232 | } else {
233 | flags.push(option);
234 | }
235 | });
236 |
237 | return { flags, valueOptions };
238 | }
239 |
240 | function buildUsage(command: Command): string {
241 | return command.createHelp().commandUsage(command).trim();
242 | }
243 |
244 | function formatOptionSignature(option: Option): string {
245 | return option.flags.replace(/\s+/g, " ").trim();
246 | }
247 |
248 | function extractOptionPlaceholder(option: Option): string {
249 | const match = option.flags.match(/(<[^>]+>|\[[^\]]+\])/);
250 | return match ? match[0] : "";
251 | }
252 |
253 | function buildOptionUsage(commandPath: string, option: Option): string {
254 | const preferred =
255 | option.long || option.short || formatOptionSignature(option);
256 | const placeholder = extractOptionPlaceholder(option);
257 | const usage = [commandPath, preferred, placeholder]
258 | .filter(Boolean)
259 | .join(" ")
260 | .replace(/\s+/g, " ")
261 | .trim();
262 |
263 | return usage;
264 | }
265 |
266 | function buildOptionDetails(option: Option): string[] {
267 | const details: string[] = [];
268 |
269 | if (option.mandatory) {
270 | details.push("Must be specified.");
271 | }
272 |
273 | if (option.required) {
274 | details.push("Requires a value.");
275 | } else if (option.optional) {
276 | details.push("Accepts an optional value.");
277 | }
278 |
279 | if (option.defaultValueDescription) {
280 | details.push(`Default: ${option.defaultValueDescription}.`);
281 | } else if (option.defaultValue !== undefined) {
282 | details.push(`Default: ${formatValue(option.defaultValue)}.`);
283 | }
284 |
285 | if (option.argChoices && option.argChoices.length > 0) {
286 | details.push(`Allowed values: ${option.argChoices.join(", ")}.`);
287 | }
288 |
289 | if (option.envVar) {
290 | details.push(`Environment variable: ${option.envVar}.`);
291 | }
292 |
293 | if (option.presetArg !== undefined) {
294 | details.push(`Preset value: ${formatValue(option.presetArg)}.`);
295 | }
296 |
297 | return details;
298 | }
299 |
300 | type BuildOptionEntriesArgs = {
301 | options: Option[];
302 | commandPath: string;
303 | depth: number;
304 | };
305 |
306 | function buildOptionEntries({
307 | options,
308 | commandPath,
309 | depth,
310 | }: BuildOptionEntriesArgs): Content[] {
311 | const nodes: Content[] = [];
312 | const headingDepth = Math.min(depth + 1, 6);
313 |
314 | options.forEach((option) => {
315 | const signature = formatOptionSignature(option);
316 | nodes.push(createHeading(headingDepth, [createInlineCode(signature)]));
317 |
318 | nodes.push({
319 | type: "code",
320 | lang: "bash",
321 | value: buildOptionUsage(commandPath, option),
322 | });
323 |
324 | if (option.description) {
325 | nodes.push(createParagraph(option.description));
326 | }
327 |
328 | const details = buildOptionDetails(option);
329 | if (details.length > 0) {
330 | nodes.push(createParagraph(details.join(" ")));
331 | }
332 | });
333 |
334 | return nodes;
335 | }
336 |
337 | function buildArgumentListItems(args: readonly Argument[]): ListItem[] {
338 | return args.map((arg) => {
339 | const children: PhrasingContent[] = [
340 | createInlineCode(formatArgumentLabel(arg)),
341 | ];
342 |
343 | if (arg.description) {
344 | children.push({ type: "text", value: ` — ${arg.description}` });
345 | }
346 |
347 | const details: string[] = [];
348 |
349 | if (arg.defaultValueDescription) {
350 | details.push(`default: ${arg.defaultValueDescription}`);
351 | } else if (arg.defaultValue !== undefined) {
352 | details.push(`default: ${formatValue(arg.defaultValue)}`);
353 | }
354 |
355 | if (arg.argChoices && arg.argChoices.length > 0) {
356 | details.push(`choices: ${arg.argChoices.join(", ")}`);
357 | }
358 |
359 | if (!arg.required) {
360 | details.push("optional");
361 | }
362 |
363 | if (details.length > 0) {
364 | children.push({
365 | type: "text",
366 | value: ` (${details.join("; ")})`,
367 | });
368 | }
369 |
370 | return createListItem(children);
371 | });
372 | }
373 |
374 | type BuildCommandSectionOptions = {
375 | command: Command;
376 | rootName: string;
377 | ancestors: string[];
378 | depth: number;
379 | useRootIntro: boolean;
380 | };
381 |
382 | function buildCommandSection({
383 | command,
384 | rootName,
385 | ancestors,
386 | depth,
387 | useRootIntro,
388 | }: BuildCommandSectionOptions): Content[] {
389 | const nodes: Content[] = [];
390 | const commandPath = getCommandPath(rootName, ancestors, command);
391 | const isRootCommand = ancestors.length === 0;
392 | const shouldUseIntro = isRootCommand && useRootIntro;
393 | const headingContent = shouldUseIntro
394 | ? "Introduction"
395 | : [createInlineCode(commandPath)];
396 |
397 | nodes.push(createHeading(depth, headingContent));
398 |
399 | const description = command.description();
400 | if (description) {
401 | nodes.push(createParagraph(description));
402 | }
403 |
404 | const usage = buildUsage(command);
405 | if (usage) {
406 | const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6);
407 | nodes.push(createHeading(sectionDepth, "Usage"));
408 | nodes.push({
409 | type: "paragraph",
410 | children: [createInlineCode(usage)],
411 | });
412 | }
413 |
414 | const aliases = command.aliases();
415 | if (aliases.length > 0) {
416 | const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6);
417 | nodes.push(createHeading(sectionDepth, "Aliases"));
418 | nodes.push(
419 | createList(
420 | aliases.map((alias) => createListItem([createInlineCode(alias)])),
421 | ),
422 | );
423 | }
424 |
425 | const args = command.registeredArguments ?? [];
426 | if (args.length > 0) {
427 | const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6);
428 | nodes.push(createHeading(sectionDepth, "Arguments"));
429 | nodes.push(createList(buildArgumentListItems(args)));
430 | }
431 |
432 | const visibleOptions = command.options.filter((option) => !option.hidden);
433 | if (visibleOptions.length > 0) {
434 | const { flags, valueOptions } = partitionOptions(visibleOptions);
435 | const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6);
436 |
437 | if (valueOptions.length > 0) {
438 | nodes.push(createHeading(sectionDepth, "Options"));
439 | nodes.push(
440 | ...buildOptionEntries({
441 | options: valueOptions,
442 | commandPath,
443 | depth: sectionDepth,
444 | }),
445 | );
446 | }
447 |
448 | if (flags.length > 0) {
449 | nodes.push(createHeading(sectionDepth, "Flags"));
450 | nodes.push(
451 | ...buildOptionEntries({
452 | options: flags,
453 | commandPath,
454 | depth: sectionDepth,
455 | }),
456 | );
457 | }
458 | }
459 |
460 | const subcommands = command.commands.filter(
461 | (sub) =>
462 | !isHiddenCommand(sub) &&
463 | !isHelpCommand(command, sub) &&
464 | sub.parent === command,
465 | );
466 |
467 | if (subcommands.length > 0) {
468 | const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6);
469 | nodes.push(createHeading(sectionDepth, "Subcommands"));
470 |
471 | subcommands.forEach((sub) => {
472 | nodes.push(
473 | ...buildCommandSection({
474 | command: sub,
475 | rootName,
476 | ancestors: [...ancestors, command.name()],
477 | depth: Math.min(sectionDepth + 1, 6),
478 | useRootIntro,
479 | }),
480 | );
481 | });
482 | }
483 |
484 | return nodes;
485 | }
486 |
487 | function toMarkdown(root: Root): string {
488 | return unified().use(remarkStringify).stringify(root).trimEnd();
489 | }
490 |
491 | async function formatWithPrettier(
492 | content: string,
493 | filePath: string,
494 | ): Promise<string> {
495 | const config = await resolveConfig(filePath);
496 | return prettierFormat(content, {
497 | ...(config ?? {}),
498 | filepath: filePath,
499 | });
500 | }
501 |
502 | type CommandDoc = {
503 | fileName: string;
504 | markdown: string;
505 | mdx: string;
506 | commandPath: string;
507 | };
508 |
509 | type BuildCommandDocOptions = {
510 | useRootIntro?: boolean;
511 | };
512 |
513 | function buildCommandDoc(
514 | command: Command,
515 | rootName: string,
516 | options?: BuildCommandDocOptions,
517 | ): CommandDoc {
518 | const useRootIntro = options?.useRootIntro ?? true;
519 | const commandPath = getCommandPath(rootName, [], command);
520 | const title = commandPath;
521 | const subtitle = `CLI reference docs for ${command.name()} command`;
522 | const root: Root = {
523 | type: "root",
524 | children: buildCommandSection({
525 | command,
526 | rootName,
527 | ancestors: [],
528 | depth: 2,
529 | useRootIntro,
530 | }),
531 | };
532 |
533 | const markdown = toMarkdown(root);
534 | const frontmatter = [
535 | FRONTMATTER_DELIMITER,
536 | `title: ${formatYamlValue(title)}`,
537 | `subtitle: ${formatYamlValue(subtitle)}`,
538 | FRONTMATTER_DELIMITER,
539 | "",
540 | ].join("\n");
541 |
542 | const mdx = `${frontmatter}${markdown}\n`;
543 | const fileName = `${slugifyCommandName(command.name())}.mdx`;
544 |
545 | return { fileName, markdown, mdx, commandPath };
546 | }
547 |
548 | function buildIndexDoc(commands: Command[], rootName: string): CommandDoc {
549 | const root: Root = {
550 | type: "root",
551 | children: [
552 | createHeading(2, "Introduction"),
553 | createParagraph(
554 | `This page aggregates CLI reference docs for ${rootName} commands.`,
555 | ),
556 | ],
557 | };
558 |
559 | commands.forEach((command) => {
560 | root.children.push(
561 | ...buildCommandSection({
562 | command,
563 | rootName,
564 | ancestors: [],
565 | depth: 2,
566 | useRootIntro: false,
567 | }),
568 | );
569 | });
570 |
571 | const markdown = toMarkdown(root);
572 | const frontmatter = [
573 | FRONTMATTER_DELIMITER,
574 | `title: ${formatYamlValue(`${rootName} CLI reference`)}`,
575 | "seo:",
576 | " noindex: true",
577 | FRONTMATTER_DELIMITER,
578 | "",
579 | ].join("\n");
580 |
581 | const mdx = `${frontmatter}${markdown}\n`;
582 |
583 | return {
584 | fileName: "index.mdx",
585 | markdown,
586 | mdx,
587 | commandPath: `${rootName} (index)`,
588 | };
589 | }
590 |
591 | async function main(): Promise<void> {
592 | const repoRoot = getRepoRoot();
593 | const cli = await getProgram(repoRoot);
594 |
595 | const outputArg = process.argv[2];
596 |
597 | if (!outputArg) {
598 | throw new Error(
599 | "Output directory is required. Usage: generate-cli-docs <output-directory>",
600 | );
601 | }
602 |
603 | const outputDir = resolve(process.cwd(), outputArg);
604 | await mkdir(outputDir, { recursive: true });
605 |
606 | const topLevelCommands = cli.commands.filter(
607 | (command) => command.parent === cli && !isHiddenCommand(command),
608 | );
609 |
610 | if (topLevelCommands.length === 0) {
611 | console.warn("No top-level commands found. Nothing to document.");
612 | return;
613 | }
614 |
615 | const docs = topLevelCommands.map((command) =>
616 | buildCommandDoc(command, cli.name()),
617 | );
618 | const indexDoc = buildIndexDoc(topLevelCommands, cli.name());
619 |
620 | for (const doc of [...docs, indexDoc]) {
621 | const filePath = join(outputDir, doc.fileName);
622 | await mkdir(dirname(filePath), { recursive: true });
623 | const formatted = await formatWithPrettier(doc.mdx, filePath);
624 | await writeFile(filePath, formatted, "utf8");
625 | console.log(`✅ Saved ${doc.commandPath} docs to ${filePath}`);
626 | }
627 |
628 | if (process.env.GITHUB_ACTIONS) {
629 | const commentMarker = "<!-- generate-cli-docs -->";
630 | const combinedMarkdown = docs
631 | .map((doc) => doc.markdown)
632 | .join("\n\n---\n\n");
633 |
634 | const commentBody = [
635 | commentMarker,
636 | "",
637 | "Your PR updates Lingo.dev CLI behavior. Please review the regenerated reference docs below.",
638 | "",
639 | combinedMarkdown,
640 | ].join("\n");
641 |
642 | await createOrUpdateGitHubComment({
643 | commentMarker,
644 | body: commentBody,
645 | });
646 | }
647 | }
648 |
649 | main().catch((err) => {
650 | console.error(err);
651 | process.exit(1);
652 | });
653 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/xcode-xcstrings-icu.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * ICU MessageFormat conversion utilities for xcstrings pluralization
3 | *
4 | * This module handles converting between xcstrings plural format and ICU MessageFormat,
5 | * preserving format specifier precision and supporting multiple variables.
6 | */
7 |
8 | /**
9 | * Type guard marker to distinguish ICU objects from user data
10 | * Using a symbol ensures no collision with user data
11 | */
12 | const ICU_TYPE_MARKER = Symbol.for("@lingo.dev/icu-plural-object");
13 |
14 | export interface PluralWithMetadata {
15 | icu: string;
16 | _meta?: {
17 | variables: {
18 | [varName: string]: {
19 | format: string;
20 | role: "plural" | "other";
21 | };
22 | };
23 | };
24 | // Type marker for robust detection
25 | [ICU_TYPE_MARKER]?: true;
26 | }
27 |
28 | /**
29 | * CLDR plural categories as defined by Unicode
30 | * https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html
31 | */
32 | const CLDR_PLURAL_CATEGORIES = new Set([
33 | "zero",
34 | "one",
35 | "two",
36 | "few",
37 | "many",
38 | "other",
39 | ]);
40 |
41 | /**
42 | * Type guard to check if a value is a valid ICU object with metadata
43 | * This is more robust than simple key checking
44 | */
45 | export function isICUPluralObject(value: any): value is PluralWithMetadata {
46 | if (!value || typeof value !== "object" || Array.isArray(value)) {
47 | return false;
48 | }
49 |
50 | // Check for type marker (most reliable)
51 | if (ICU_TYPE_MARKER in value) {
52 | return true;
53 | }
54 |
55 | // Fallback: validate structure thoroughly
56 | if (!("icu" in value) || typeof value.icu !== "string") {
57 | return false;
58 | }
59 |
60 | // Must match ICU plural format pattern
61 | const icuPluralPattern = /^\{[\w]+,\s*plural,\s*.+\}$/;
62 | if (!icuPluralPattern.test(value.icu)) {
63 | return false;
64 | }
65 |
66 | // If _meta exists, validate its structure
67 | if (value._meta !== undefined) {
68 | if (
69 | typeof value._meta !== "object" ||
70 | !value._meta.variables ||
71 | typeof value._meta.variables !== "object"
72 | ) {
73 | return false;
74 | }
75 |
76 | // Validate each variable entry
77 | for (const [varName, varMeta] of Object.entries(value._meta.variables)) {
78 | if (
79 | !varMeta ||
80 | typeof varMeta !== "object" ||
81 | typeof (varMeta as any).format !== "string" ||
82 | ((varMeta as any).role !== "plural" &&
83 | (varMeta as any).role !== "other")
84 | ) {
85 | return false;
86 | }
87 | }
88 | }
89 |
90 | return true;
91 | }
92 |
93 | /**
94 | * Type guard to check if an object is a valid plural forms object
95 | * Ensures ALL keys are CLDR categories to avoid false positives
96 | */
97 | export function isPluralFormsObject(
98 | value: any,
99 | ): value is Record<string, string> {
100 | if (!value || typeof value !== "object" || Array.isArray(value)) {
101 | return false;
102 | }
103 |
104 | const keys = Object.keys(value);
105 |
106 | // Must have at least one key
107 | if (keys.length === 0) {
108 | return false;
109 | }
110 |
111 | // Check if ALL keys are CLDR plural categories
112 | const allKeysAreCldr = keys.every((key) => CLDR_PLURAL_CATEGORIES.has(key));
113 |
114 | if (!allKeysAreCldr) {
115 | return false;
116 | }
117 |
118 | // Check if all values are strings
119 | const allValuesAreStrings = keys.every(
120 | (key) => typeof value[key] === "string",
121 | );
122 |
123 | if (!allValuesAreStrings) {
124 | return false;
125 | }
126 |
127 | // Must have at least "other" form (required in all locales)
128 | if (!("other" in value)) {
129 | return false;
130 | }
131 |
132 | return true;
133 | }
134 |
135 | /**
136 | * Get required CLDR plural categories for a locale
137 | *
138 | * @throws {Error} If locale is invalid and cannot be resolved
139 | */
140 | function getRequiredPluralCategories(locale: string): string[] {
141 | try {
142 | const pluralRules = new Intl.PluralRules(locale);
143 | const categories = pluralRules.resolvedOptions().pluralCategories;
144 |
145 | if (!categories || categories.length === 0) {
146 | throw new Error(`No plural categories found for locale: ${locale}`);
147 | }
148 |
149 | return categories;
150 | } catch (error) {
151 | // Log warning but use safe fallback
152 | console.warn(
153 | `[xcode-xcstrings-icu] Failed to resolve plural categories for locale "${locale}". ` +
154 | `Using fallback ["one", "other"]. Error: ${error instanceof Error ? error.message : String(error)}`,
155 | );
156 | return ["one", "other"];
157 | }
158 | }
159 |
160 | /**
161 | * Map CLDR category names to their numeric values for exact match conversion
162 | */
163 | const CLDR_CATEGORY_TO_NUMBER: Record<string, number> = {
164 | zero: 0,
165 | one: 1,
166 | two: 2,
167 | };
168 |
169 | /**
170 | * Map numeric values back to CLDR category names
171 | */
172 | const NUMBER_TO_CLDR_CATEGORY: Record<number, string> = {
173 | 0: "zero",
174 | 1: "one",
175 | 2: "two",
176 | };
177 |
178 | /**
179 | * Convert xcstrings plural forms to ICU MessageFormat with metadata
180 | *
181 | * @param pluralForms - Record of plural forms (e.g., { one: "1 item", other: "%d items" })
182 | * @param sourceLocale - Source language locale (e.g., "en", "ru") to determine required vs optional forms
183 | * @returns ICU string with metadata for format preservation
184 | *
185 | * @example
186 | * xcstringsToPluralWithMeta({ one: "1 mile", other: "%.1f miles" }, "en")
187 | * // Returns:
188 | * // {
189 | * // icu: "{count, plural, one {1 mile} other {# miles}}",
190 | * // _meta: { variables: { count: { format: "%.1f", role: "plural" } } }
191 | * // }
192 | *
193 | * @example
194 | * xcstringsToPluralWithMeta({ zero: "No items", one: "1 item", other: "%d items" }, "en")
195 | * // Returns:
196 | * // {
197 | * // icu: "{count, plural, =0 {No items} one {1 item} other {# items}}",
198 | * // _meta: { variables: { count: { format: "%d", role: "plural" } } }
199 | * // }
200 | */
201 | export function xcstringsToPluralWithMeta(
202 | pluralForms: Record<string, string>,
203 | sourceLocale: string = "en",
204 | ): PluralWithMetadata {
205 | if (!pluralForms || Object.keys(pluralForms).length === 0) {
206 | throw new Error("pluralForms cannot be empty");
207 | }
208 |
209 | // Get required CLDR categories for this locale
210 | const requiredCategories = getRequiredPluralCategories(sourceLocale);
211 |
212 | const variables: Record<
213 | string,
214 | { format: string; role: "plural" | "other" }
215 | > = {};
216 |
217 | // Regex to match format specifiers:
218 | // %[position$][flags][width][.precision][length]specifier
219 | // Examples: %d, %lld, %.2f, %@, %1$@, %2$lld
220 | const formatRegex =
221 | /(%(?:(\d+)\$)?(?:[+-])?(?:\d+)?(?:\.(\d+))?([lhqLzjt]*)([diuoxXfFeEgGaAcspn@]))/g;
222 |
223 | // Analyze ALL forms to find the one with most variables (typically "other")
224 | let maxMatches: RegExpMatchArray[] = [];
225 | let maxMatchText = "";
226 | for (const [form, text] of Object.entries(pluralForms)) {
227 | // Skip if text is not a string
228 | if (typeof text !== "string") {
229 | console.warn(
230 | `Warning: Plural form "${form}" has non-string value:`,
231 | text,
232 | );
233 | continue;
234 | }
235 | const matches = [...text.matchAll(formatRegex)];
236 | if (matches.length > maxMatches.length) {
237 | maxMatches = matches;
238 | maxMatchText = text;
239 | }
240 | }
241 |
242 | let lastNumericIndex = -1;
243 |
244 | // Find which variable is the plural one (heuristic: last numeric format)
245 | maxMatches.forEach((match, idx) => {
246 | const specifier = match[5];
247 | // Numeric specifiers that could be plural counts
248 | if (/[diuoxXfFeE]/.test(specifier)) {
249 | lastNumericIndex = idx;
250 | }
251 | });
252 |
253 | // Build variable metadata
254 | let nonPluralCounter = 0;
255 | maxMatches.forEach((match, idx) => {
256 | const fullFormat = match[1]; // e.g., "%.2f", "%lld", "%@"
257 | const position = match[2]; // e.g., "1" from "%1$@"
258 | const precision = match[3]; // e.g., "2" from "%.2f"
259 | const lengthMod = match[4]; // e.g., "ll" from "%lld"
260 | const specifier = match[5]; // e.g., "f", "d", "@"
261 |
262 | const isPluralVar = idx === lastNumericIndex;
263 | const varName = isPluralVar ? "count" : `var${nonPluralCounter++}`;
264 |
265 | variables[varName] = {
266 | format: fullFormat,
267 | role: isPluralVar ? "plural" : "other",
268 | };
269 | });
270 |
271 | // Build ICU string for each plural form
272 | const variableKeys = Object.keys(variables);
273 | const icuForms = Object.entries(pluralForms)
274 | .filter(([form, text]) => {
275 | // Skip non-string values
276 | if (typeof text !== "string") {
277 | return false;
278 | }
279 | return true;
280 | })
281 | .map(([form, text]) => {
282 | let processed = text as string;
283 | let vIdx = 0;
284 |
285 | // Replace format specifiers with ICU equivalents
286 | processed = processed.replace(formatRegex, () => {
287 | if (vIdx >= variableKeys.length) {
288 | // Shouldn't happen, but fallback
289 | vIdx++;
290 | return "#";
291 | }
292 |
293 | const varName = variableKeys[vIdx];
294 | const varMeta = variables[varName];
295 | vIdx++;
296 |
297 | if (varMeta.role === "plural") {
298 | // Plural variable uses # in ICU
299 | return "#";
300 | } else {
301 | // Non-plural variables use {varName}
302 | return `{${varName}}`;
303 | }
304 | });
305 |
306 | // Determine if this form is required or optional
307 | const isRequired = requiredCategories.includes(form);
308 | const formKey =
309 | !isRequired && form in CLDR_CATEGORY_TO_NUMBER
310 | ? `=${CLDR_CATEGORY_TO_NUMBER[form]}` // Convert optional forms to exact matches
311 | : form; // Keep required forms as CLDR keywords
312 |
313 | return `${formKey} {${processed}}`;
314 | })
315 | .join(" ");
316 |
317 | // Find plural variable name
318 | const pluralVarName =
319 | Object.keys(variables).find((name) => variables[name].role === "plural") ||
320 | "count";
321 |
322 | const icu = `{${pluralVarName}, plural, ${icuForms}}`;
323 |
324 | const result: PluralWithMetadata = {
325 | icu,
326 | _meta: Object.keys(variables).length > 0 ? { variables } : undefined,
327 | [ICU_TYPE_MARKER]: true, // Add type marker for robust detection
328 | };
329 |
330 | return result;
331 | }
332 |
333 | /**
334 | * Convert ICU MessageFormat with metadata back to xcstrings plural forms
335 | *
336 | * Uses metadata to restore original format specifiers with full precision.
337 | *
338 | * @param data - ICU string with metadata
339 | * @returns Record of plural forms suitable for xcstrings
340 | *
341 | * @example
342 | * pluralWithMetaToXcstrings({
343 | * icu: "{count, plural, one {# километр} other {# километров}}",
344 | * _meta: { variables: { count: { format: "%.1f", role: "plural" } } }
345 | * })
346 | * // Returns: { one: "%.1f километр", other: "%.1f километров" }
347 | */
348 | export function pluralWithMetaToXcstrings(
349 | data: PluralWithMetadata,
350 | ): Record<string, string> {
351 | if (!data.icu) {
352 | throw new Error("ICU string is required");
353 | }
354 |
355 | // Parse ICU MessageFormat string
356 | const ast = parseICU(data.icu);
357 |
358 | if (!ast || ast.length === 0) {
359 | throw new Error("Invalid ICU format");
360 | }
361 |
362 | // Find the plural node
363 | const pluralNode = ast.find((node) => node.type === "plural");
364 |
365 | if (!pluralNode) {
366 | throw new Error("No plural found in ICU format");
367 | }
368 |
369 | const forms: Record<string, string> = {};
370 |
371 | // Convert each plural form back to xcstrings format
372 | for (const [form, option] of Object.entries(pluralNode.options)) {
373 | let text = "";
374 |
375 | const optionValue = (option as any).value;
376 | for (const element of optionValue) {
377 | if (element.type === "literal") {
378 | // Plain text
379 | text += element.value;
380 | } else if (element.type === "pound") {
381 | // # → look up plural variable format in metadata
382 | const pluralVar = Object.entries(data._meta?.variables || {}).find(
383 | ([_, meta]) => meta.role === "plural",
384 | );
385 |
386 | text += pluralVar?.[1].format || "%lld";
387 | } else if (element.type === "argument") {
388 | // {varName} → look up variable format by name
389 | const varName = element.value;
390 | const varMeta = data._meta?.variables?.[varName];
391 |
392 | text += varMeta?.format || "%@";
393 | }
394 | }
395 |
396 | // Convert exact matches (=0, =1) back to CLDR category names
397 | let xcstringsFormName = form;
398 | if (form.startsWith("=")) {
399 | const numValue = parseInt(form.substring(1), 10);
400 | xcstringsFormName = NUMBER_TO_CLDR_CATEGORY[numValue] || form;
401 | }
402 |
403 | forms[xcstringsFormName] = text;
404 | }
405 |
406 | return forms;
407 | }
408 |
409 | /**
410 | * Simple ICU MessageFormat parser
411 | *
412 | * This is a lightweight parser for our specific use case.
413 | * For production, consider using @formatjs/icu-messageformat-parser
414 | */
415 | function parseICU(icu: string): any[] {
416 | // Remove outer braces and split by "plural,"
417 | const match = icu.match(/\{(\w+),\s*plural,\s*(.+)\}$/);
418 |
419 | if (!match) {
420 | throw new Error("Invalid ICU plural format");
421 | }
422 |
423 | const varName = match[1];
424 | const formsText = match[2];
425 |
426 | // Parse plural forms manually to handle nested braces
427 | const options: Record<string, any> = {};
428 |
429 | let i = 0;
430 | while (i < formsText.length) {
431 | // Skip whitespace
432 | while (i < formsText.length && /\s/.test(formsText[i])) {
433 | i++;
434 | }
435 |
436 | if (i >= formsText.length) break;
437 |
438 | // Read form name (e.g., "one", "other", "few", "=0", "=1")
439 | let formName = "";
440 |
441 | // Check for exact match syntax (=0, =1, etc.)
442 | if (formsText[i] === "=") {
443 | formName += formsText[i];
444 | i++;
445 | // Read the number
446 | while (i < formsText.length && /\d/.test(formsText[i])) {
447 | formName += formsText[i];
448 | i++;
449 | }
450 | } else {
451 | // Read word form name
452 | while (i < formsText.length && /\w/.test(formsText[i])) {
453 | formName += formsText[i];
454 | i++;
455 | }
456 | }
457 |
458 | if (!formName) break;
459 |
460 | // Skip whitespace and find opening brace
461 | while (i < formsText.length && /\s/.test(formsText[i])) {
462 | i++;
463 | }
464 |
465 | if (i >= formsText.length || formsText[i] !== "{") {
466 | throw new Error(`Expected '{' after form name '${formName}'`);
467 | }
468 |
469 | // Find matching closing brace
470 | i++; // skip opening brace
471 | let braceCount = 1;
472 | let formText = "";
473 |
474 | while (i < formsText.length && braceCount > 0) {
475 | if (formsText[i] === "{") {
476 | braceCount++;
477 | formText += formsText[i];
478 | } else if (formsText[i] === "}") {
479 | braceCount--;
480 | if (braceCount > 0) {
481 | formText += formsText[i];
482 | }
483 | } else {
484 | formText += formsText[i];
485 | }
486 | i++;
487 | }
488 |
489 | if (braceCount !== 0) {
490 | // Provide detailed error with context
491 | const preview = formsText.substring(
492 | Math.max(0, i - 50),
493 | Math.min(formsText.length, i + 50),
494 | );
495 | throw new Error(
496 | `Unclosed brace for form '${formName}' in ICU MessageFormat.\n` +
497 | `Expected ${braceCount} more closing brace(s).\n` +
498 | `Context: ...${preview}...\n` +
499 | `Full ICU: {${varName}, plural, ${formsText}}`,
500 | );
501 | }
502 |
503 | // Parse the form text to extract elements
504 | const elements = parseFormText(formText);
505 |
506 | options[formName] = {
507 | value: elements,
508 | };
509 | }
510 |
511 | return [
512 | {
513 | type: "plural",
514 | value: varName,
515 | options,
516 | },
517 | ];
518 | }
519 |
520 | /**
521 | * Parse form text into elements (literals, pounds, arguments)
522 | */
523 | function parseFormText(text: string): any[] {
524 | const elements: any[] = [];
525 | let currentText = "";
526 | let i = 0;
527 |
528 | while (i < text.length) {
529 | if (text[i] === "#") {
530 | // Add accumulated text as literal
531 | if (currentText) {
532 | elements.push({ type: "literal", value: currentText });
533 | currentText = "";
534 | }
535 | // Add pound element
536 | elements.push({ type: "pound" });
537 | i++;
538 | } else if (text[i] === "{") {
539 | // Variable reference - need to handle nested braces
540 | // Add accumulated text as literal
541 | if (currentText) {
542 | elements.push({ type: "literal", value: currentText });
543 | currentText = "";
544 | }
545 |
546 | // Find matching closing brace (handle nesting)
547 | let braceCount = 1;
548 | let j = i + 1;
549 | while (j < text.length && braceCount > 0) {
550 | if (text[j] === "{") {
551 | braceCount++;
552 | } else if (text[j] === "}") {
553 | braceCount--;
554 | }
555 | j++;
556 | }
557 |
558 | if (braceCount !== 0) {
559 | throw new Error("Unclosed variable reference");
560 | }
561 |
562 | // j is now positioned after the closing brace
563 | const varName = text.slice(i + 1, j - 1);
564 | elements.push({ type: "argument", value: varName });
565 |
566 | i = j;
567 | } else {
568 | currentText += text[i];
569 | i++;
570 | }
571 | }
572 |
573 | // Add remaining text
574 | if (currentText) {
575 | elements.push({ type: "literal", value: currentText });
576 | }
577 |
578 | return elements;
579 | }
580 |
```
--------------------------------------------------------------------------------
/packages/compiler/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # @lingo.dev/\_compiler
2 |
3 | ## 0.7.15
4 |
5 | ### Patch Changes
6 |
7 | - [#1231](https://github.com/lingodotdev/lingo.dev/pull/1231) [`44a928b`](https://github.com/lingodotdev/lingo.dev/commit/44a928b473802cd07bec64f94a273ee1b845a0d0) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Compiler now throws errors instead of abruptly exiting the process, allowing parent applications to handle errors gracefully
8 |
9 | ## 0.7.14
10 |
11 | ### Patch Changes
12 |
13 | - Updated dependencies [[`b45347c`](https://github.com/lingodotdev/lingo.dev/commit/b45347c38572ee371b2bc494261b7e3e90c4aed1)]:
14 | - @lingo.dev/[email protected]
15 | - @lingo.dev/[email protected]
16 |
17 | ## 0.7.13
18 |
19 | ### Patch Changes
20 |
21 | - [#1222](https://github.com/lingodotdev/lingo.dev/pull/1222) [`38139c8`](https://github.com/lingodotdev/lingo.dev/commit/38139c81a85001739cece60873c0c6ad711327a4) Thanks [@vrcprl](https://github.com/vrcprl)! - fix regex replacement
22 |
23 | ## 0.7.12
24 |
25 | ### Patch Changes
26 |
27 | - Updated dependencies [[`82f5e7c`](https://github.com/lingodotdev/lingo.dev/commit/82f5e7cdde9a2a15b4c2a7fcb8c67ed64eab596b), [`e858174`](https://github.com/lingodotdev/lingo.dev/commit/e858174fd5165e0ea3e3f25fa1fc3edb292bc58f)]:
28 | - @lingo.dev/[email protected]
29 | - @lingo.dev/[email protected]
30 |
31 | ## 0.7.11
32 |
33 | ### Patch Changes
34 |
35 | - Updated dependencies [[`1fa218c`](https://github.com/lingodotdev/lingo.dev/commit/1fa218c13bf90df6d175fb18264f59c1a10b967c)]:
36 | - @lingo.dev/[email protected]
37 | - @lingo.dev/[email protected]
38 |
39 | ## 0.7.10
40 |
41 | ### Patch Changes
42 |
43 | - Updated dependencies [[`bbc71b9`](https://github.com/lingodotdev/lingo.dev/commit/bbc71b9948ccc289c9669d8b0c276c9596f6a5e7)]:
44 | - @lingo.dev/[email protected]
45 | - @lingo.dev/[email protected]
46 |
47 | ## 0.7.9
48 |
49 | ### Patch Changes
50 |
51 | - Updated dependencies [[`6579d70`](https://github.com/lingodotdev/lingo.dev/commit/6579d70bc670c2fdc06c09842d931b07e134151c)]:
52 | - @lingo.dev/[email protected]
53 | - @lingo.dev/[email protected]
54 |
55 | ## 0.7.8
56 |
57 | ### Patch Changes
58 |
59 | - Updated dependencies [[`a35032e`](https://github.com/lingodotdev/lingo.dev/commit/a35032e7e7a188d1f5e774576352068124526e24)]:
60 | - @lingo.dev/[email protected]
61 | - @lingo.dev/[email protected]
62 |
63 | ## 0.7.7
64 |
65 | ### Patch Changes
66 |
67 | - [#1130](https://github.com/lingodotdev/lingo.dev/pull/1130) [`bc7b08e`](https://github.com/lingodotdev/lingo.dev/commit/bc7b08ef1245d1af0c68813cb18193d4f14bc7e0) Thanks [@mathio](https://github.com/mathio)! - dictionary path calculation
68 |
69 | ## 0.7.6
70 |
71 | ### Patch Changes
72 |
73 | - [#1121](https://github.com/lingodotdev/lingo.dev/pull/1121) [`b6071e4`](https://github.com/lingodotdev/lingo.dev/commit/b6071e4f19dd1823f4f2ce54ba5495538a94d4fd) Thanks [@mathio](https://github.com/mathio)! - compiler: prevent duplicate props
74 |
75 | ## 0.7.5
76 |
77 | ### Patch Changes
78 |
79 | - [#1118](https://github.com/lingodotdev/lingo.dev/pull/1118) [`410825c`](https://github.com/lingodotdev/lingo.dev/commit/410825c8bf0029d8ee458514d6f203a7397c8f22) Thanks [@mathio](https://github.com/mathio)! - support Turbopack in Next.js v14 by Compiler
80 |
81 | - [#1116](https://github.com/lingodotdev/lingo.dev/pull/1116) [`bc419ae`](https://github.com/lingodotdev/lingo.dev/commit/bc419aeeb4211d80d3c0ddd65deeab62ad68fea8) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - fix: move vitest from dependencies to devDependencies
82 |
83 | ## 0.7.4
84 |
85 | ### Patch Changes
86 |
87 | - [#1072](https://github.com/lingodotdev/lingo.dev/pull/1072) [`3cb1ebe`](https://github.com/lingodotdev/lingo.dev/commit/3cb1ebec5441882678ab30a7d1b532bc2fc397b6) Thanks [@The-Best-Codes](https://github.com/The-Best-Codes)! - Fixed compiler handling of namespace imports (import \* as React from "react") and default imports.
88 |
89 | ## 0.7.3
90 |
91 | ### Patch Changes
92 |
93 | - Updated dependencies [[`6af91a0`](https://github.com/lingodotdev/lingo.dev/commit/6af91a083d16f85051fb49a4034789abe784017e), [`6af91a0`](https://github.com/lingodotdev/lingo.dev/commit/6af91a083d16f85051fb49a4034789abe784017e)]:
94 | - @lingo.dev/[email protected]
95 | - @lingo.dev/[email protected]
96 |
97 | ## 0.7.2
98 |
99 | ### Patch Changes
100 |
101 | - Updated dependencies [[`85dfc10`](https://github.com/lingodotdev/lingo.dev/commit/85dfc10961b116e31b2bb478f42013756ca49974)]:
102 | - @lingo.dev/[email protected]
103 |
104 | ## 0.7.1
105 |
106 | ### Patch Changes
107 |
108 | - [#1040](https://github.com/lingodotdev/lingo.dev/pull/1040) [`f897a7d`](https://github.com/lingodotdev/lingo.dev/commit/f897a7d0a3f7a236fb64f19bce9a8d00626d09ca) Thanks [@The-Best-Codes](https://github.com/The-Best-Codes)! - Fixed the compiler to handle type-only react imports.
109 |
110 | ## 0.7.0
111 |
112 | ### Minor Changes
113 |
114 | - [#997](https://github.com/lingodotdev/lingo.dev/pull/997) [`bd9538a`](https://github.com/lingodotdev/lingo.dev/commit/bd9538ac6eba0ffc91ffc1fef5db6366c13e9e06) Thanks [@VAIBHAVSING](https://github.com/VAIBHAVSING)! - ### Whitespace Normalization Fix
115 |
116 | - Improved `normalizeJsxWhitespace` logic to preserve leading spaces inside JSX elements while removing unnecessary formatting whitespace and extra lines.
117 | - Ensured explicit whitespace (e.g., `{" "}`) is handled correctly without introducing double spaces.
118 | - Added targeted tests (`jsx-content-whitespace.spec.ts`) to verify whitespace handling.
119 | - Cleaned up unnecessary debug/test files created during development.
120 |
121 | ## 0.6.3
122 |
123 | ### Patch Changes
124 |
125 | - Updated dependencies [[`afbb978`](https://github.com/lingodotdev/lingo.dev/commit/afbb978fec83d574f2c43b7d68457e435fca9b57)]:
126 | - @lingo.dev/[email protected]
127 | - @lingo.dev/[email protected]
128 |
129 | ## 0.6.2
130 |
131 | ### Patch Changes
132 |
133 | - [#1023](https://github.com/lingodotdev/lingo.dev/pull/1023) [`9266fd0`](https://github.com/lingodotdev/lingo.dev/commit/9266fd0bcddf4b07ca51d2609af92a9473106f9d) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Update Zod dependency to version 3.25.76
134 |
135 | - Updated dependencies [[`9266fd0`](https://github.com/lingodotdev/lingo.dev/commit/9266fd0bcddf4b07ca51d2609af92a9473106f9d)]:
136 | - @lingo.dev/[email protected]
137 | - @lingo.dev/[email protected]
138 |
139 | ## 0.6.1
140 |
141 | ### Patch Changes
142 |
143 | - [#1021](https://github.com/lingodotdev/lingo.dev/pull/1021) [`6baa1a7`](https://github.com/lingodotdev/lingo.dev/commit/6baa1a7e88dbfac3783d1d49695595077fd8d209) Thanks [@mathio](https://github.com/mathio)! - add lingo.dev provider details
144 |
145 | ## 0.6.0
146 |
147 | ### Minor Changes
148 |
149 | - [#1010](https://github.com/lingodotdev/lingo.dev/pull/1010) [`864c305`](https://github.com/lingodotdev/lingo.dev/commit/864c30586510e6b69739c20fa42efdf45d8881ed) Thanks [@davidturnbull](https://github.com/davidturnbull)! - improve type safety of compiler params
150 |
151 | ### Patch Changes
152 |
153 | - Updated dependencies [[`cb2aa0f`](https://github.com/lingodotdev/lingo.dev/commit/cb2aa0f505d6b7dbc435b526e8a6f62265d1f453)]:
154 | - @lingo.dev/[email protected]
155 |
156 | ## 0.5.5
157 |
158 | ### Patch Changes
159 |
160 | - [#1011](https://github.com/lingodotdev/lingo.dev/pull/1011) [`bfcb424`](https://github.com/lingodotdev/lingo.dev/commit/bfcb424eb4479d0d3b767e062d30f02c5bcaeb14) Thanks [@mathio](https://github.com/mathio)! - replace elements with dot in name
161 |
162 | ## 0.5.4
163 |
164 | ### Patch Changes
165 |
166 | - [#1002](https://github.com/lingodotdev/lingo.dev/pull/1002) [`2b297ba`](https://github.com/lingodotdev/lingo.dev/commit/2b297babe76f9799c5154d9421fecd1ebbe1bb72) Thanks [@mathio](https://github.com/mathio)! - support custom prompts in compiler
167 |
168 | ## 0.5.3
169 |
170 | ### Patch Changes
171 |
172 | - Updated dependencies []:
173 | - @lingo.dev/[email protected]
174 |
175 | ## 0.5.2
176 |
177 | ### Patch Changes
178 |
179 | - Updated dependencies []:
180 | - @lingo.dev/[email protected]
181 |
182 | ## 0.5.1
183 |
184 | ### Patch Changes
185 |
186 | - [#972](https://github.com/lingodotdev/lingo.dev/pull/972) [`b249484`](https://github.com/lingodotdev/lingo.dev/commit/b249484d6f0060e29cd5b50b3d8ce68b857ccad5) Thanks [@mathio](https://github.com/mathio)! - support components with dot in name
187 |
188 | ## 0.5.0
189 |
190 | ### Minor Changes
191 |
192 | - [#958](https://github.com/lingodotdev/lingo.dev/pull/958) [`84fd214`](https://github.com/lingodotdev/lingo.dev/commit/84fd214a21766e7683c5d645fcb8c4c0162eb0b6) Thanks [@chrissiwaffler](https://github.com/chrissiwaffler)! - feat: add Mistral AI as a supported LLM provider
193 |
194 | - Added Mistral AI provider support across the entire lingo.dev ecosystem
195 | - Users can now use Mistral models for localization by setting MISTRAL_API_KEY
196 | - Supports all Mistral models available through the @ai-sdk/mistral package
197 | - Configuration via environment variable or user-wide config: `npx lingo.dev@latest config set llm.mistralApiKey <key>`
198 |
199 | ### Patch Changes
200 |
201 | - Updated dependencies []:
202 | - @lingo.dev/[email protected]
203 |
204 | ## 0.4.1
205 |
206 | ### Patch Changes
207 |
208 | - Updated dependencies []:
209 | - @lingo.dev/[email protected]
210 |
211 | ## 0.4.0
212 |
213 | ### Minor Changes
214 |
215 | - [#932](https://github.com/lingodotdev/lingo.dev/pull/932) [`1bba8ee`](https://github.com/lingodotdev/lingo.dev/commit/1bba8eed6272ae166ceb9b92963404bfe90a4aaa) Thanks [@The-Best-Codes](https://github.com/The-Best-Codes)! - Add support for Next.js Turbopack with the Lingo.dev compiler.
216 |
217 | ## 0.3.5
218 |
219 | ### Patch Changes
220 |
221 | - [#947](https://github.com/lingodotdev/lingo.dev/pull/947) [`d80285a`](https://github.com/lingodotdev/lingo.dev/commit/d80285a9b12bd85425564cb00e558812fd0aee40) Thanks [@mathio](https://github.com/mathio)! - remove local variable cache
222 |
223 | ## 0.3.4
224 |
225 | ### Patch Changes
226 |
227 | - [#937](https://github.com/lingodotdev/lingo.dev/pull/937) [`4e5983d`](https://github.com/lingodotdev/lingo.dev/commit/4e5983d7e59ebf9eb529c4b7c1c87689432ac873) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Update documentation URLs from docs.lingo.dev to lingo.dev/cli and lingo.dev/compiler
228 |
229 | - Updated dependencies [[`4e5983d`](https://github.com/lingodotdev/lingo.dev/commit/4e5983d7e59ebf9eb529c4b7c1c87689432ac873)]:
230 | - @lingo.dev/[email protected]
231 |
232 | ## 0.3.3
233 |
234 | ### Patch Changes
235 |
236 | - [`76cbd9b`](https://github.com/lingodotdev/lingo.dev/commit/76cbd9b2f2e1217421ad1f671bed5b3d64b43333) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - dictionary merging
237 |
238 | ## 0.3.2
239 |
240 | ### Patch Changes
241 |
242 | - [`01f253d`](https://github.com/lingodotdev/lingo.dev/commit/01f253dd9759b518f400dff03ab51b460b9b8997) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix dictionary merging
243 |
244 | ## 0.3.1
245 |
246 | ### Patch Changes
247 |
248 | - [`8e97256`](https://github.com/lingodotdev/lingo.dev/commit/8e97256ca4e78dd09a967539ca9dec359bd558ef) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix dictionary merging
249 |
250 | ## 0.3.0
251 |
252 | ### Minor Changes
253 |
254 | - [#913](https://github.com/lingodotdev/lingo.dev/pull/913) [`1b9b113`](https://github.com/lingodotdev/lingo.dev/commit/1b9b11301978e8caa2555832d027ff93216aa6e1) Thanks [@The-Best-Codes](https://github.com/The-Best-Codes)! - Add support for Ollama as a CLI and Compiler provider.
255 |
256 | - [#922](https://github.com/lingodotdev/lingo.dev/pull/922) [`0329a9c`](https://github.com/lingodotdev/lingo.dev/commit/0329a9cdb5e5a63fcecab4efcd7cce22f155a0e9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add openrouter ais support for compiler
257 |
258 | ### Patch Changes
259 |
260 | - [#925](https://github.com/lingodotdev/lingo.dev/pull/925) [`215af19`](https://github.com/lingodotdev/lingo.dev/commit/215af1944667cce66e9c5966f4fb627186687b74) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - improved compiler concurrency, caching, added lingo.dev engine to the compiler, and updated demo apps
261 |
262 | - Updated dependencies []:
263 | - @lingo.dev/[email protected]
264 |
265 | ## 0.2.4
266 |
267 | ### Patch Changes
268 |
269 | - [#919](https://github.com/lingodotdev/lingo.dev/pull/919) [`3b6574f`](https://github.com/lingodotdev/lingo.dev/commit/3b6574f0499f3f4d3c48f66ba2b828d2c1c0ceb0) Thanks [@mathio](https://github.com/mathio)! - update package import names
270 |
271 | ## 0.2.3
272 |
273 | ### Patch Changes
274 |
275 | - [#911](https://github.com/lingodotdev/lingo.dev/pull/911) [`d7e74c6`](https://github.com/lingodotdev/lingo.dev/commit/d7e74c6cc724da8ae759ba8d8fdb1a64867d505c) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix hyphens in locale names
276 |
277 | ## 0.2.2
278 |
279 | ### Patch Changes
280 |
281 | - [#905](https://github.com/lingodotdev/lingo.dev/pull/905) [`1a235a1`](https://github.com/lingodotdev/lingo.dev/commit/1a235a17455fb2631f7426283aa8431209999758) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - remove @/ path mapping in compiler
282 |
283 | ## 0.2.1
284 |
285 | ### Patch Changes
286 |
287 | - [#900](https://github.com/lingodotdev/lingo.dev/pull/900) [`fead8e0`](https://github.com/lingodotdev/lingo.dev/commit/fead8e08dc2b2869a093cb25a04f6e0aa78cf6b7) Thanks [@mathio](https://github.com/mathio)! - load API key from env var and env files
288 |
289 | ## 0.2.0
290 |
291 | ### Minor Changes
292 |
293 | - [#897](https://github.com/lingodotdev/lingo.dev/pull/897) [`a5da697`](https://github.com/lingodotdev/lingo.dev/commit/a5da697f7efd46de31d17b202d06eb5f655ed9b9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for other providers in the compiler and implement Google AI as a provider.
294 |
295 | ## 0.1.13
296 |
297 | ### Patch Changes
298 |
299 | - [#890](https://github.com/lingodotdev/lingo.dev/pull/890) [`145fb74`](https://github.com/lingodotdev/lingo.dev/commit/145fb74c09b42c8810f351be5a641b1366881ae1) Thanks [@mathio](https://github.com/mathio)! - do not parse LingoProvider component
300 |
301 | - [#889](https://github.com/lingodotdev/lingo.dev/pull/889) [`0c45acc`](https://github.com/lingodotdev/lingo.dev/commit/0c45accfc45e63f597758c47033bc58d2f6059b5) Thanks [@mathio](https://github.com/mathio)! - update Groq API error handling
302 |
303 | ## 0.1.12
304 |
305 | ### Patch Changes
306 |
307 | - [#887](https://github.com/lingodotdev/lingo.dev/pull/887) [`511a2ec`](https://github.com/lingodotdev/lingo.dev/commit/511a2ecd68a9c5e2800035d5c6a6b5b31b2dc80f) Thanks [@mathio](https://github.com/mathio)! - handle when lingo dir is deleted
308 |
309 | ## 0.1.11
310 |
311 | ### Patch Changes
312 |
313 | - [#883](https://github.com/lingodotdev/lingo.dev/pull/883) [`7191444`](https://github.com/lingodotdev/lingo.dev/commit/7191444f67864ea5b5a91a9be759b2445bf186d3) Thanks [@mathio](https://github.com/mathio)! - client-side loading state
314 |
315 | ## 0.1.10
316 |
317 | ### Patch Changes
318 |
319 | - [#876](https://github.com/lingodotdev/lingo.dev/pull/876) [`152e96a`](https://github.com/lingodotdev/lingo.dev/commit/152e96a46b98dd25d558ff0e7e20b18b954d375a) Thanks [@vrcprl](https://github.com/vrcprl)! - fix for triggering reload on Windows
320 |
321 | ## 0.1.9
322 |
323 | ### Patch Changes
324 |
325 | - [#866](https://github.com/lingodotdev/lingo.dev/pull/866) [`77461a7`](https://github.com/lingodotdev/lingo.dev/commit/77461a7872eec3ea188b3ca6c6f7ce1fd13fdfbb) Thanks [@vrcprl](https://github.com/vrcprl)! - normalize paths in dictionaries
326 |
327 | ## 0.1.8
328 |
329 | ### Patch Changes
330 |
331 | - [#861](https://github.com/lingodotdev/lingo.dev/pull/861) [`1bccb7e`](https://github.com/lingodotdev/lingo.dev/commit/1bccb7ed51ac1f13ea79e618bbee551d5529efdc) Thanks [@vrcprl](https://github.com/vrcprl)! - support filePath on Windows
332 |
333 | ## 0.1.7
334 |
335 | ### Patch Changes
336 |
337 | - [`5b68641`](https://github.com/lingodotdev/lingo.dev/commit/5b686414f363f8ee4b79fd4e804a434db5cfcb36) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - feat: unshift the plugins
338 |
339 | ## 0.1.6
340 |
341 | ### Patch Changes
342 |
343 | - [`7a5898b`](https://github.com/lingodotdev/lingo.dev/commit/7a5898b12dcd0015a5e57236bf65172cedb8a6ee) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - merge dictionaries
344 |
345 | ## 0.1.5
346 |
347 | ### Patch Changes
348 |
349 | - [`7013b53`](https://github.com/lingodotdev/lingo.dev/commit/7013b5300d6c2c26f39da62b5ad2c7cf11158c74) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - value.trim() issue
350 |
351 | ## 0.1.4
352 |
353 | ### Patch Changes
354 |
355 | - [#853](https://github.com/lingodotdev/lingo.dev/pull/853) [`cb7d5e2`](https://github.com/lingodotdev/lingo.dev/commit/cb7d5e213282c00af658159472183a763f84ca3d) Thanks [@vrcprl](https://github.com/vrcprl)! - Fix groq api key retrieval from .env
356 |
357 | ## 0.1.3
358 |
359 | ### Patch Changes
360 |
361 | - [`f42cff8`](https://github.com/lingodotdev/lingo.dev/commit/f42cff8355b1ff7bba1445bd04d11ee4672903c2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - flat reexports
362 |
363 | ## 0.1.2
364 |
365 | ### Patch Changes
366 |
367 | - [`920e3f5`](https://github.com/lingodotdev/lingo.dev/commit/920e3f5c3ca1fd51b0919db13a4787cfd616de54) Thanks [@mathio](https://github.com/mathio)! - remove cloneDeep for optimization
368 |
369 | ## 0.1.1
370 |
371 | ### Patch Changes
372 |
373 | - [`caef325`](https://github.com/lingodotdev/lingo.dev/commit/caef3253bc99fa7bf7a0b40e5604c3590dcb4958) Thanks [@mathio](https://github.com/mathio)! - release fix
374 |
375 | ## 0.1.0
376 |
377 | ### Minor Changes
378 |
379 | - [`e980e84`](https://github.com/lingodotdev/lingo.dev/commit/e980e84178439ad70417d38b425acf9148cfc4b6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added the compiler
380 |
```
--------------------------------------------------------------------------------
/packages/react/src/core/component.spec.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import { render } from "@testing-library/react";
3 | import { LingoComponent } from "./component";
4 |
5 | describe("LingoComponent", () => {
6 | const dictionary = {
7 | files: {
8 | messages: {
9 | entries: {
10 | greeting: "Hello {user.profile.name} you have {count} messages",
11 | welcome:
12 | "Welcome <element:a>incredible <element:span>fantastic <element:em>wonderful <element:strong>amazing</element:strong></element:em></element:span> user</element:a> <element:Icons.Rocket></element:Icons.Rocket>",
13 | complex:
14 | "<element:a>Hello {user.profile.name}, welcome to <element:span>wonderful <element:strong><element:em>{placeholder}</element:em> nested</element:strong></element:span> world</element:a> of the <element:u>universe number {count}</element:u>",
15 | },
16 | },
17 | },
18 | };
19 |
20 | it("replaces variables in text", () => {
21 | const { container } = render(
22 | <LingoComponent
23 | $dictionary={dictionary}
24 | $as="div"
25 | $fileKey="messages"
26 | $entryKey="greeting"
27 | $variables={{ "user.profile.name": "John", count: 69 }}
28 | />,
29 | );
30 | expect(container.textContent).toBe("Hello John you have 69 messages");
31 | });
32 |
33 | it("replaces variables with JSX", () => {
34 | const { container } = render(
35 | <LingoComponent
36 | $dictionary={dictionary}
37 | $as="div"
38 | $fileKey="messages"
39 | $entryKey="greeting"
40 | $variables={{
41 | "user.profile.name": <strong>John</strong>,
42 | count: <em>69</em>,
43 | }}
44 | />,
45 | );
46 | expect(container.innerHTML).toBe(
47 | "<div>Hello <strong>John</strong> you have <em>69</em> messages</div>",
48 | );
49 | });
50 |
51 | it("replaces element placeholders", () => {
52 | const Icons = {
53 | Rocket: () => <span>🚀</span>,
54 | };
55 |
56 | const { container } = render(
57 | <LingoComponent
58 | $dictionary={dictionary}
59 | $as="div"
60 | $fileKey="messages"
61 | $entryKey="welcome"
62 | $elements={[
63 | ({ children }: any) => <a href="#">{children}</a>,
64 | ({ children }: any) => <span>{children}</span>,
65 | ({ children }: any) => <em>{children}</em>,
66 | ({ children }: any) => <strong className="red">{children}</strong>,
67 | ({ children }: any) => <Icons.Rocket />,
68 | ]}
69 | />,
70 | );
71 | expect(container.innerHTML).toBe(
72 | '<div>Welcome <a href="#">incredible <span>fantastic <em>wonderful <strong class="red">amazing</strong></em></span> user</a> <span>🚀</span></div>',
73 | );
74 | });
75 |
76 | it("handles both variables and elements", () => {
77 | const { container } = render(
78 | <LingoComponent
79 | $dictionary={dictionary}
80 | $as="div"
81 | $fileKey="messages"
82 | $entryKey="complex"
83 | $variables={{
84 | "user.profile.name": "John",
85 | count: 42,
86 | placeholder: "very",
87 | }}
88 | $elements={[
89 | ({ children }: any) => <a>{children}</a>,
90 | ({ children }: any) => <span>{children}</span>,
91 | ({ children }: any) => <strong>{children}</strong>,
92 | ({ children }: any) => <em>{children}</em>,
93 | ({ children }: any) => <u>{children}</u>,
94 | ]}
95 | />,
96 | );
97 | expect(container.innerHTML).toBe(
98 | "<div><a>Hello John, welcome to <span>wonderful <strong><em>very</em> nested</strong></span> world</a> of the <u>universe number 42</u></div>",
99 | );
100 | });
101 |
102 | it("falls back to entryKey if value not found", () => {
103 | const { container } = render(
104 | <LingoComponent
105 | $dictionary={dictionary}
106 | $as="div"
107 | $fileKey="messages"
108 | $entryKey="nonexistent"
109 | $variables={{}}
110 | $elements={[]}
111 | />,
112 | );
113 | expect(container.textContent).toBe("nonexistent");
114 | });
115 |
116 | describe("function replacement", () => {
117 | const getName = () => "John";
118 | const getCount = () => 42;
119 | const formatName = () => "John Doe";
120 | const getUnread = () => 3;
121 | const fnDictionary = {
122 | files: {
123 | messages: {
124 | entries: {
125 | simple:
126 | "Hello <function:getName/>, you have <function:getCount/> items",
127 | chained: "Hello <function:user.details.profile.name/>",
128 | mixed:
129 | "Welcome <function:formatName/>, you have {count} items and <function:getUnread/> unread",
130 | nested:
131 | "<element:strong>User <function:getName/></element:strong> has <element:em><function:getCount/></element:em>",
132 | },
133 | },
134 | },
135 | };
136 |
137 | it("replaces function calls in text", () => {
138 | const { container } = render(
139 | <LingoComponent
140 | $dictionary={fnDictionary}
141 | $as="div"
142 | $fileKey="messages"
143 | $entryKey="simple"
144 | $functions={{
145 | getName: [getName()],
146 | getCount: [getCount()],
147 | }}
148 | />,
149 | );
150 | expect(container.textContent).toBe("Hello John, you have 42 items");
151 | });
152 |
153 | it("handles mixed variables and functions", () => {
154 | const { container } = render(
155 | <LingoComponent
156 | $dictionary={fnDictionary}
157 | $as="div"
158 | $fileKey="messages"
159 | $entryKey="mixed"
160 | $variables={{
161 | count: 5,
162 | }}
163 | $functions={{
164 | formatName: [formatName()],
165 | getUnread: [getUnread()],
166 | }}
167 | />,
168 | );
169 | expect(container.textContent).toBe(
170 | "Welcome John Doe, you have 5 items and 3 unread",
171 | );
172 | });
173 |
174 | it("handles functions with nested elements", () => {
175 | const { container } = render(
176 | <LingoComponent
177 | $dictionary={fnDictionary}
178 | $as="div"
179 | $fileKey="messages"
180 | $entryKey="nested"
181 | $functions={{
182 | getName: [getName()],
183 | getCount: [getCount()],
184 | }}
185 | $elements={[
186 | ({ children }: any) => <strong>{children}</strong>,
187 | ({ children }: any) => <em>{children}</em>,
188 | ]}
189 | />,
190 | );
191 | expect(container.innerHTML).toBe(
192 | "<div><strong>User John</strong> has <em>42</em></div>",
193 | );
194 | });
195 |
196 | it("handles function with chained names", () => {
197 | const { container } = render(
198 | <LingoComponent
199 | $dictionary={fnDictionary}
200 | $as="div"
201 | $fileKey="messages"
202 | $entryKey="chained"
203 | $functions={{
204 | "user.details.profile.name": [getName()],
205 | }}
206 | />,
207 | );
208 | expect(container.textContent).toBe("Hello John");
209 | });
210 |
211 | it("preserves function placeholder if function not provided", () => {
212 | const { container } = render(
213 | <LingoComponent
214 | $dictionary={fnDictionary}
215 | $as="div"
216 | $fileKey="messages"
217 | $entryKey="simple"
218 | $functions={{
219 | getName: [getName()],
220 | // fn1:getCount not provided
221 | }}
222 | />,
223 | );
224 | expect(container.textContent).toBe(
225 | "Hello John, you have <function:getCount/> items",
226 | );
227 | });
228 |
229 | it("replaces function calls with JSX", () => {
230 | const { container } = render(
231 | <LingoComponent
232 | $dictionary={fnDictionary}
233 | $as="div"
234 | $fileKey="messages"
235 | $entryKey="simple"
236 | $functions={{
237 | getName: [<strong>John</strong>],
238 | getCount: [<em>42</em>],
239 | }}
240 | />,
241 | );
242 | expect(container.innerHTML).toBe(
243 | "<div>Hello <strong>John</strong>, you have <em>42</em> items</div>",
244 | );
245 | });
246 | });
247 |
248 | describe("expression replacement", () => {
249 | const exprDictionary = {
250 | files: {
251 | messages: {
252 | entries: {
253 | simple: "Result: <expression/>",
254 | multiple: "First: <expression/>, Second: <expression/>",
255 | mixed:
256 | "Count: <expression/>, User: {user.name}, Items: <expression/>",
257 | nested:
258 | "<element:strong>Value: <expression/></element:strong> and <element:em>Total: <expression/></element:em>",
259 | },
260 | },
261 | },
262 | };
263 |
264 | it("replaces simple expressions", () => {
265 | const { container } = render(
266 | <LingoComponent
267 | $dictionary={exprDictionary}
268 | $as="div"
269 | $fileKey="messages"
270 | $entryKey="simple"
271 | $expressions={[42]}
272 | />,
273 | );
274 | expect(container.textContent).toBe("Result: 42");
275 | });
276 |
277 | it("handles multiple expressions", () => {
278 | const { container } = render(
279 | <LingoComponent
280 | $dictionary={exprDictionary}
281 | $as="div"
282 | $fileKey="messages"
283 | $entryKey="multiple"
284 | $expressions={[42 * 2, "hello".toUpperCase()]}
285 | />,
286 | );
287 | expect(container.textContent).toBe("First: 84, Second: HELLO");
288 | });
289 |
290 | it("handles mixed variables and expressions", () => {
291 | const { container } = render(
292 | <LingoComponent
293 | $dictionary={exprDictionary}
294 | $as="div"
295 | $fileKey="messages"
296 | $entryKey="mixed"
297 | $variables={{
298 | "user.name": "John",
299 | }}
300 | $expressions={[42 + 1, [1, 2, 3].length]}
301 | />,
302 | );
303 | expect(container.textContent).toBe("Count: 43, User: John, Items: 3");
304 | });
305 |
306 | it("handles expressions with nested elements", () => {
307 | const { container } = render(
308 | <LingoComponent
309 | $dictionary={exprDictionary}
310 | $as="div"
311 | $fileKey="messages"
312 | $entryKey="nested"
313 | $expressions={[42 * 2, [1, 2, 3].reduce((a, b) => a + b, 0)]}
314 | $elements={[
315 | ({ children }: any) => <strong>{children}</strong>,
316 | ({ children }: any) => <em>{children}</em>,
317 | ]}
318 | />,
319 | );
320 | expect(container.innerHTML).toBe(
321 | "<div><strong>Value: 84</strong> and <em>Total: 6</em></div>",
322 | );
323 | });
324 |
325 | it("preserves expression placeholder if not provided", () => {
326 | const { container } = render(
327 | <LingoComponent
328 | $dictionary={exprDictionary}
329 | $as="div"
330 | $fileKey="messages"
331 | $entryKey="multiple"
332 | $expressions={[
333 | 42,
334 | // second expression not provided
335 | ]}
336 | />,
337 | );
338 | expect(container.textContent).toBe("First: 42, Second: <expression/>");
339 | });
340 |
341 | it("replaces expressions with JSX", () => {
342 | const { container } = render(
343 | <LingoComponent
344 | $dictionary={exprDictionary}
345 | $as="div"
346 | $fileKey="messages"
347 | $entryKey="multiple"
348 | $expressions={[<strong>foo</strong>, <code>bar</code>]}
349 | />,
350 | );
351 | expect(container.innerHTML).toBe(
352 | "<div>First: <strong>foo</strong>, Second: <code>bar</code></div>",
353 | );
354 | });
355 | });
356 |
357 | describe("array mutation prevention (shift() bug fix)", () => {
358 | const mutationDictionary = {
359 | files: {
360 | test: {
361 | entries: {
362 | elements:
363 | "First <element:0>text</element:0> and <element:1>more</element:1>",
364 | functions: "Call <function:fn1/> then <function:fn2/>",
365 | expressions: "Value <expression/> and <expression/>",
366 | mixed:
367 | "Element <element:0>content</element:0> with <function:fn1/> and <expression/>",
368 | },
369 | },
370 | },
371 | };
372 |
373 | it("does not mutate elements array during processing", () => {
374 | const elements = [
375 | ({ children }: any) => <span>{children}</span>,
376 | ({ children }: any) => <strong>{children}</strong>,
377 | ];
378 | const originalElements = [...elements];
379 |
380 | render(
381 | <LingoComponent
382 | $dictionary={mutationDictionary}
383 | $as="div"
384 | $fileKey="test"
385 | $entryKey="elements"
386 | $elements={elements}
387 | />,
388 | );
389 |
390 | expect(elements).toEqual(originalElements);
391 | expect(elements.length).toBe(2);
392 | });
393 |
394 | it("does not mutate functions arrays during processing", () => {
395 | const functions = {
396 | fn1: ["result1", "result2"],
397 | fn2: ["result3", "result4"],
398 | };
399 | const originalFunctions = {
400 | fn1: [...functions.fn1],
401 | fn2: [...functions.fn2],
402 | };
403 |
404 | render(
405 | <LingoComponent
406 | $dictionary={mutationDictionary}
407 | $as="div"
408 | $fileKey="test"
409 | $entryKey="functions"
410 | $functions={functions}
411 | />,
412 | );
413 |
414 | expect(functions.fn1).toEqual(originalFunctions.fn1);
415 | expect(functions.fn2).toEqual(originalFunctions.fn2);
416 | expect(functions.fn1.length).toBe(2);
417 | expect(functions.fn2.length).toBe(2);
418 | });
419 |
420 | it("does not mutate expressions array during processing", () => {
421 | const expressions = ["value1", "value2"];
422 | const originalExpressions = [...expressions];
423 |
424 | render(
425 | <LingoComponent
426 | $dictionary={mutationDictionary}
427 | $as="div"
428 | $fileKey="test"
429 | $entryKey="expressions"
430 | $expressions={expressions}
431 | />,
432 | );
433 |
434 | expect(expressions).toEqual(originalExpressions);
435 | expect(expressions.length).toBe(2);
436 | });
437 |
438 | it("produces consistent output across multiple renders", () => {
439 | const elements = [
440 | ({ children }: any) => <span>{children}</span>,
441 | ({ children }: any) => <strong>{children}</strong>,
442 | ];
443 | const functions = { fn1: ["result1"] };
444 | const expressions = ["value1"];
445 |
446 | const { container: container1 } = render(
447 | <LingoComponent
448 | $dictionary={mutationDictionary}
449 | $as="div"
450 | $fileKey="test"
451 | $entryKey="mixed"
452 | $elements={elements}
453 | $functions={functions}
454 | $expressions={expressions}
455 | />,
456 | );
457 |
458 | const { container: container2 } = render(
459 | <LingoComponent
460 | $dictionary={mutationDictionary}
461 | $as="div"
462 | $fileKey="test"
463 | $entryKey="mixed"
464 | $elements={elements}
465 | $functions={functions}
466 | $expressions={expressions}
467 | />,
468 | );
469 |
470 | expect(container1.innerHTML).toBe(container2.innerHTML);
471 | });
472 |
473 | it("handles shared arrays across multiple component instances", () => {
474 | const sharedElements = [
475 | ({ children }: any) => <span>{children}</span>,
476 | ({ children }: any) => <strong>{children}</strong>,
477 | ];
478 |
479 | const { container: container1 } = render(
480 | <div>
481 | <LingoComponent
482 | $dictionary={mutationDictionary}
483 | $as="div"
484 | $fileKey="test"
485 | $entryKey="elements"
486 | $elements={sharedElements}
487 | />
488 | </div>,
489 | );
490 |
491 | const { container: container2 } = render(
492 | <div>
493 | <LingoComponent
494 | $dictionary={mutationDictionary}
495 | $as="div"
496 | $fileKey="test"
497 | $entryKey="elements"
498 | $elements={sharedElements}
499 | />
500 | </div>,
501 | );
502 |
503 | expect(container1.innerHTML).toBe(container2.innerHTML);
504 | expect(sharedElements.length).toBe(2);
505 | });
506 |
507 | it("extracts inner content when elements array is exhausted", () => {
508 | const { container } = render(
509 | <LingoComponent
510 | $dictionary={mutationDictionary}
511 | $as="div"
512 | $fileKey="test"
513 | $entryKey="elements"
514 | $elements={[({ children }: any) => <span>{children}</span>]}
515 | />,
516 | );
517 |
518 | expect(container.textContent).toBe("First text and more");
519 | expect(container.innerHTML).toBe(
520 | "<div>First <span>text</span> and more</div>",
521 | );
522 | });
523 |
524 | it("handles completely empty elements array gracefully", () => {
525 | const { container } = render(
526 | <LingoComponent
527 | $dictionary={mutationDictionary}
528 | $as="div"
529 | $fileKey="test"
530 | $entryKey="elements"
531 | $elements={[]}
532 | />,
533 | );
534 |
535 | expect(container.textContent).toBe("First text and more");
536 | expect(container.innerHTML).toBe("<div>First text and more</div>");
537 | });
538 |
539 | it("maintains function index tracking per function name", () => {
540 | const multiCallDictionary = {
541 | files: {
542 | test: {
543 | entries: {
544 | multiCall:
545 | "First <function:fn1/>, second <function:fn1/>, third <function:fn2/>",
546 | },
547 | },
548 | },
549 | };
550 |
551 | const { container } = render(
552 | <LingoComponent
553 | $dictionary={multiCallDictionary}
554 | $as="div"
555 | $fileKey="test"
556 | $entryKey="multiCall"
557 | $functions={{
558 | fn1: ["A", "B"],
559 | fn2: ["C"],
560 | }}
561 | />,
562 | );
563 |
564 | expect(container.textContent).toBe("First A, second B, third C");
565 | });
566 | });
567 | });
568 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/xliff.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ILoader } from "./_types";
2 | import { createLoader } from "./_utils";
3 | import { JSDOM } from "jsdom";
4 |
5 | /**
6 | * Creates a comprehensive XLIFF loader supporting versions 1.2 and 2.0
7 | * with deterministic key generation and structure preservation
8 | */
9 | export default function createXliffLoader(): ILoader<
10 | string,
11 | Record<string, string>
12 | > {
13 | return createLoader({
14 | async pull(locale, input, _ctx, originalLocale) {
15 | const trimmedInput = (input ?? "").trim();
16 |
17 | if (!trimmedInput) {
18 | return createEmptyResult(originalLocale, locale);
19 | }
20 |
21 | try {
22 | const dom = new JSDOM(trimmedInput, { contentType: "text/xml" });
23 | const document = dom.window.document;
24 |
25 | // Check for parsing errors
26 | const parserError = document.querySelector("parsererror");
27 | if (parserError) {
28 | throw new Error(`XML parsing failed: ${parserError.textContent}`);
29 | }
30 |
31 | const xliffElement = document.documentElement;
32 | if (!xliffElement || xliffElement.tagName !== "xliff") {
33 | throw new Error("Invalid XLIFF: missing root <xliff> element");
34 | }
35 |
36 | const version = xliffElement.getAttribute("version") || "1.2";
37 | const isV2 = version === "2.0";
38 |
39 | if (isV2) {
40 | return pullV2(xliffElement, locale, originalLocale);
41 | } else {
42 | return pullV1(xliffElement, locale, originalLocale);
43 | }
44 | } catch (error: any) {
45 | throw new Error(`Failed to parse XLIFF file: ${error.message}`);
46 | }
47 | },
48 |
49 | async push(locale, translations, originalInput, originalLocale, pullInput) {
50 | if (!originalInput) {
51 | // Create new file from scratch
52 | return pushNewFile(locale, translations, originalLocale);
53 | }
54 |
55 | try {
56 | const dom = new JSDOM(originalInput, { contentType: "text/xml" });
57 | const document = dom.window.document;
58 | const xliffElement = document.documentElement;
59 | const version = xliffElement.getAttribute("version") || "1.2";
60 | const isV2 = version === "2.0";
61 |
62 | if (isV2) {
63 | return pushV2(
64 | dom,
65 | xliffElement,
66 | locale,
67 | translations,
68 | originalLocale,
69 | originalInput,
70 | );
71 | } else {
72 | return pushV1(
73 | dom,
74 | xliffElement,
75 | locale,
76 | translations,
77 | originalLocale,
78 | originalInput,
79 | );
80 | }
81 | } catch (error: any) {
82 | throw new Error(`Failed to update XLIFF file: ${error.message}`);
83 | }
84 | },
85 | });
86 | }
87 |
88 | /* -------------------------------------------------------------------------- */
89 | /* Version 1.2 Support */
90 | /* -------------------------------------------------------------------------- */
91 |
92 | function pullV1(
93 | xliffElement: Element,
94 | locale: string,
95 | originalLocale: string,
96 | ): Record<string, string> {
97 | const result: Record<string, string> = {};
98 | const fileElement = xliffElement.querySelector("file");
99 |
100 | if (!fileElement) {
101 | return result;
102 | }
103 |
104 | const sourceLanguage =
105 | fileElement.getAttribute("source-language") || originalLocale;
106 | const isSourceLocale = sourceLanguage === locale;
107 | const bodyElement = fileElement.querySelector("body");
108 |
109 | if (!bodyElement) {
110 | return result;
111 | }
112 |
113 | const transUnits = bodyElement.querySelectorAll("trans-unit");
114 | const seenKeys = new Set<string>();
115 |
116 | transUnits.forEach((unit) => {
117 | let key = getTransUnitKey(unit as Element);
118 | if (!key) return;
119 |
120 | // Handle duplicates deterministically
121 | if (seenKeys.has(key)) {
122 | const id = (unit as Element).getAttribute("id")?.trim();
123 | if (id) {
124 | key = `${key}#${id}`;
125 | } else {
126 | let counter = 1;
127 | let newKey = `${key}__${counter}`;
128 | while (seenKeys.has(newKey)) {
129 | counter++;
130 | newKey = `${key}__${counter}`;
131 | }
132 | key = newKey;
133 | }
134 | }
135 | seenKeys.add(key);
136 |
137 | const elementName = isSourceLocale ? "source" : "target";
138 | const textElement = (unit as Element).querySelector(elementName);
139 |
140 | if (textElement) {
141 | result[key] = extractTextContent(textElement);
142 | } else if (isSourceLocale) {
143 | result[key] = key; // fallback for source
144 | } else {
145 | result[key] = ""; // empty for missing target
146 | }
147 | });
148 |
149 | return result;
150 | }
151 |
152 | function pushV1(
153 | dom: JSDOM,
154 | xliffElement: Element,
155 | locale: string,
156 | translations: Record<string, string>,
157 | originalLocale: string,
158 | originalInput?: string,
159 | ): string {
160 | const document = dom.window.document;
161 | const fileElement = xliffElement.querySelector("file");
162 |
163 | if (!fileElement) {
164 | throw new Error("Invalid XLIFF 1.2: missing <file> element");
165 | }
166 |
167 | // Update language attributes
168 | const sourceLanguage =
169 | fileElement.getAttribute("source-language") || originalLocale;
170 | const isSourceLocale = sourceLanguage === locale;
171 |
172 | if (!isSourceLocale) {
173 | fileElement.setAttribute("target-language", locale);
174 | }
175 |
176 | let bodyElement = fileElement.querySelector("body");
177 | if (!bodyElement) {
178 | bodyElement = document.createElement("body");
179 | fileElement.appendChild(bodyElement);
180 | }
181 |
182 | // Build current index
183 | const existingUnits = new Map<string, Element>();
184 | const seenKeys = new Set<string>();
185 |
186 | bodyElement.querySelectorAll("trans-unit").forEach((unit) => {
187 | let key = getTransUnitKey(unit as Element);
188 | if (!key) return;
189 |
190 | if (seenKeys.has(key)) {
191 | const id = (unit as Element).getAttribute("id")?.trim();
192 | if (id) {
193 | key = `${key}#${id}`;
194 | } else {
195 | let counter = 1;
196 | let newKey = `${key}__${counter}`;
197 | while (seenKeys.has(newKey)) {
198 | counter++;
199 | newKey = `${key}__${counter}`;
200 | }
201 | key = newKey;
202 | }
203 | }
204 | seenKeys.add(key);
205 | existingUnits.set(key, unit as Element);
206 | });
207 |
208 | // Update/create translation units
209 | Object.entries(translations).forEach(([key, value]) => {
210 | let unit = existingUnits.get(key);
211 |
212 | if (!unit) {
213 | unit = document.createElement("trans-unit");
214 | unit.setAttribute("id", key);
215 | unit.setAttribute("resname", key);
216 | unit.setAttribute("restype", "string");
217 | unit.setAttribute("datatype", "plaintext");
218 |
219 | const sourceElement = document.createElement("source");
220 | setTextContent(sourceElement, isSourceLocale ? value : key);
221 | unit.appendChild(sourceElement);
222 |
223 | if (!isSourceLocale) {
224 | const targetElement = document.createElement("target");
225 | targetElement.setAttribute("state", value ? "translated" : "new");
226 | setTextContent(targetElement, value);
227 | unit.appendChild(targetElement);
228 | }
229 |
230 | bodyElement.appendChild(unit);
231 | existingUnits.set(key, unit);
232 | } else {
233 | updateTransUnitV1(unit, key, value, isSourceLocale);
234 | }
235 | });
236 |
237 | // Remove orphaned units
238 | const translationKeys = new Set(Object.keys(translations));
239 | existingUnits.forEach((unit, key) => {
240 | if (!translationKeys.has(key)) {
241 | unit.parentNode?.removeChild(unit);
242 | }
243 | });
244 |
245 | return serializeWithDeclaration(
246 | dom,
247 | extractXmlDeclaration(originalInput || ""),
248 | );
249 | }
250 |
251 | function updateTransUnitV1(
252 | unit: Element,
253 | key: string,
254 | value: string,
255 | isSourceLocale: boolean,
256 | ): void {
257 | const document = unit.ownerDocument!;
258 |
259 | if (isSourceLocale) {
260 | let sourceElement = unit.querySelector("source");
261 | if (!sourceElement) {
262 | sourceElement = document.createElement("source");
263 | unit.appendChild(sourceElement);
264 | }
265 | setTextContent(sourceElement, value);
266 | } else {
267 | let targetElement = unit.querySelector("target");
268 | if (!targetElement) {
269 | targetElement = document.createElement("target");
270 | unit.appendChild(targetElement);
271 | }
272 |
273 | setTextContent(targetElement, value);
274 | targetElement.setAttribute("state", value.trim() ? "translated" : "new");
275 | }
276 | }
277 |
278 | /* -------------------------------------------------------------------------- */
279 | /* Version 2.0 Support */
280 | /* -------------------------------------------------------------------------- */
281 |
282 | function pullV2(
283 | xliffElement: Element,
284 | locale: string,
285 | originalLocale: string,
286 | ): Record<string, string> {
287 | const result: Record<string, string> = {};
288 |
289 | // Add source language metadata
290 | const srcLang = xliffElement.getAttribute("srcLang") || originalLocale;
291 | result.sourceLanguage = srcLang;
292 |
293 | const fileElements = xliffElement.querySelectorAll("file");
294 |
295 | fileElements.forEach((fileElement) => {
296 | const fileId = fileElement.getAttribute("id");
297 | if (!fileId) return;
298 |
299 | traverseUnitsV2(fileElement, fileId, "", result);
300 | });
301 |
302 | return result;
303 | }
304 |
305 | function traverseUnitsV2(
306 | container: Element,
307 | fileId: string,
308 | currentPath: string,
309 | result: Record<string, string>,
310 | ): void {
311 | Array.from(container.children).forEach((child) => {
312 | const tagName = child.tagName;
313 |
314 | if (tagName === "unit") {
315 | const unitId = child.getAttribute("id")?.trim();
316 | if (!unitId) return;
317 |
318 | const key = `resources/${fileId}/${currentPath}${unitId}/source`;
319 | const segment = child.querySelector("segment");
320 | const source = segment?.querySelector("source");
321 |
322 | if (source) {
323 | result[key] = extractTextContent(source);
324 | } else {
325 | result[key] = unitId; // fallback
326 | }
327 | } else if (tagName === "group") {
328 | const groupId = child.getAttribute("id")?.trim();
329 | const newPath = groupId
330 | ? `${currentPath}${groupId}/groupUnits/`
331 | : currentPath;
332 | traverseUnitsV2(child, fileId, newPath, result);
333 | }
334 | });
335 | }
336 |
337 | function pushV2(
338 | dom: JSDOM,
339 | xliffElement: Element,
340 | locale: string,
341 | translations: Record<string, string>,
342 | originalLocale: string,
343 | originalInput?: string,
344 | ): string {
345 | const document = dom.window.document;
346 |
347 | // Handle sourceLanguage metadata
348 | if (translations.sourceLanguage) {
349 | xliffElement.setAttribute("srcLang", translations.sourceLanguage);
350 | delete translations.sourceLanguage; // Don't process as regular translation
351 | }
352 |
353 | // Build index of existing units
354 | const existingUnits = new Map<string, Element>();
355 | const fileElements = xliffElement.querySelectorAll("file");
356 |
357 | fileElements.forEach((fileElement) => {
358 | const fileId = fileElement.getAttribute("id");
359 | if (!fileId) return;
360 |
361 | indexUnitsV2(fileElement, fileId, "", existingUnits);
362 | });
363 |
364 | // Update existing units
365 | Object.entries(translations).forEach(([key, value]) => {
366 | const unit = existingUnits.get(key);
367 | if (unit) {
368 | updateUnitV2(unit, value);
369 | } else {
370 | // For new units, we'd need to create the structure
371 | // This is complex in V2 due to the hierarchical nature
372 | console.warn(`Cannot create new unit for key: ${key} in XLIFF 2.0`);
373 | }
374 | });
375 |
376 | return serializeWithDeclaration(
377 | dom,
378 | extractXmlDeclaration(originalInput || ""),
379 | );
380 | }
381 |
382 | function indexUnitsV2(
383 | container: Element,
384 | fileId: string,
385 | currentPath: string,
386 | index: Map<string, Element>,
387 | ): void {
388 | Array.from(container.children).forEach((child) => {
389 | const tagName = child.tagName;
390 |
391 | if (tagName === "unit") {
392 | const unitId = child.getAttribute("id")?.trim();
393 | if (!unitId) return;
394 |
395 | const key = `resources/${fileId}/${currentPath}${unitId}/source`;
396 | index.set(key, child);
397 | } else if (tagName === "group") {
398 | const groupId = child.getAttribute("id")?.trim();
399 | const newPath = groupId
400 | ? `${currentPath}${groupId}/groupUnits/`
401 | : currentPath;
402 | indexUnitsV2(child, fileId, newPath, index);
403 | }
404 | });
405 | }
406 |
407 | function updateUnitV2(unit: Element, value: string): void {
408 | const document = unit.ownerDocument!;
409 |
410 | let segment = unit.querySelector("segment");
411 | if (!segment) {
412 | segment = document.createElement("segment");
413 | unit.appendChild(segment);
414 | }
415 |
416 | let source = segment.querySelector("source");
417 | if (!source) {
418 | source = document.createElement("source");
419 | segment.appendChild(source);
420 | }
421 |
422 | setTextContent(source, value);
423 | }
424 |
425 | /* -------------------------------------------------------------------------- */
426 | /* Utilities */
427 | /* -------------------------------------------------------------------------- */
428 |
429 | function getTransUnitKey(transUnit: Element): string {
430 | const resname = transUnit.getAttribute("resname")?.trim();
431 | if (resname) return resname;
432 |
433 | const id = transUnit.getAttribute("id")?.trim();
434 | if (id) return id;
435 |
436 | const sourceElement = transUnit.querySelector("source");
437 | if (sourceElement) {
438 | const sourceText = extractTextContent(sourceElement).trim();
439 | if (sourceText) return sourceText;
440 | }
441 |
442 | return "";
443 | }
444 |
445 | function extractTextContent(element: Element): string {
446 | // Handle CDATA sections
447 | const cdataNode = Array.from(element.childNodes).find(
448 | (node) => node.nodeType === element.CDATA_SECTION_NODE,
449 | );
450 |
451 | if (cdataNode) {
452 | return cdataNode.nodeValue || "";
453 | }
454 |
455 | return element.textContent || "";
456 | }
457 |
458 | function setTextContent(element: Element, content: string): void {
459 | const document = element.ownerDocument!;
460 |
461 | // Clear existing content
462 | while (element.firstChild) {
463 | element.removeChild(element.firstChild);
464 | }
465 |
466 | // Use CDATA if content contains XML-sensitive characters
467 | if (/[<>&"']/.test(content)) {
468 | const cdataSection = document.createCDATASection(content);
469 | element.appendChild(cdataSection);
470 | } else {
471 | element.textContent = content;
472 | }
473 | }
474 |
475 | function extractXmlDeclaration(xmlContent: string): string {
476 | const match = xmlContent.match(/^<\?xml[^>]*\?>/);
477 | return match ? match[0] : "";
478 | }
479 |
480 | function serializeWithDeclaration(dom: JSDOM, declaration: string): string {
481 | let serialized = dom.serialize();
482 |
483 | // Add proper indentation for readability
484 | serialized = formatXml(serialized);
485 |
486 | if (declaration) {
487 | serialized = `${declaration}\n${serialized}`;
488 | }
489 |
490 | return serialized;
491 | }
492 |
493 | function formatXml(xml: string): string {
494 | // Parse and reformat XML with proper indentation using JSDOM
495 | const dom = new JSDOM(xml, { contentType: "text/xml" });
496 | const doc = dom.window.document;
497 |
498 | function formatElement(element: Element, depth: number = 0): string {
499 | const indent = " ".repeat(depth);
500 | const tagName = element.tagName;
501 | const attributes = Array.from(element.attributes)
502 | .map((attr) => `${attr.name}="${attr.value}"`)
503 | .join(" ");
504 |
505 | const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`;
506 |
507 | // Check for CDATA sections first
508 | const cdataNode = Array.from(element.childNodes).find(
509 | (node) => node.nodeType === element.CDATA_SECTION_NODE,
510 | );
511 |
512 | if (cdataNode) {
513 | return `${indent}${openTag}<![CDATA[${cdataNode.nodeValue}]]></${tagName}>`;
514 | }
515 |
516 | // Check if element has only text content
517 | const textContent = element.textContent?.trim() || "";
518 | const hasOnlyText =
519 | element.childNodes.length === 1 && element.childNodes[0].nodeType === 3;
520 |
521 | if (hasOnlyText && textContent) {
522 | return `${indent}${openTag}${textContent}</${tagName}>`;
523 | }
524 |
525 | // Element has child elements
526 | const children = Array.from(element.children);
527 | if (children.length === 0) {
528 | return `${indent}${openTag}</${tagName}>`;
529 | }
530 |
531 | let result = `${indent}${openTag}\n`;
532 | for (const child of children) {
533 | result += formatElement(child, depth + 1) + "\n";
534 | }
535 | result += `${indent}</${tagName}>`;
536 |
537 | return result;
538 | }
539 |
540 | return formatElement(doc.documentElement);
541 | }
542 |
543 | function createEmptyResult(
544 | originalLocale: string,
545 | locale: string,
546 | ): Record<string, string> {
547 | return {};
548 | }
549 |
550 | function pushNewFile(
551 | locale: string,
552 | translations: Record<string, string>,
553 | originalLocale: string,
554 | ): string {
555 | const skeleton = `<?xml version="1.0" encoding="utf-8"?>
556 | <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
557 | <file original="" source-language="${originalLocale}" target-language="${locale}" datatype="plaintext">
558 | <header></header>
559 | <body></body>
560 | </file>
561 | </xliff>`;
562 |
563 | const dom = new JSDOM(skeleton, { contentType: "text/xml" });
564 | const document = dom.window.document;
565 | const bodyElement = document.querySelector("body")!;
566 |
567 | Object.entries(translations).forEach(([key, value]) => {
568 | const unit = document.createElement("trans-unit");
569 | unit.setAttribute("id", key);
570 | unit.setAttribute("resname", key);
571 | unit.setAttribute("restype", "string");
572 | unit.setAttribute("datatype", "plaintext");
573 |
574 | const sourceElement = document.createElement("source");
575 | setTextContent(sourceElement, key);
576 | unit.appendChild(sourceElement);
577 |
578 | const targetElement = document.createElement("target");
579 | targetElement.setAttribute("state", value ? "translated" : "new");
580 | setTextContent(targetElement, value);
581 | unit.appendChild(targetElement);
582 |
583 | bodyElement.appendChild(unit);
584 | });
585 |
586 | return serializeWithDeclaration(
587 | dom,
588 | '<?xml version="1.0" encoding="utf-8"?>',
589 | );
590 | }
591 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import Z from "zod";
2 | import jsdom from "jsdom";
3 | import { bucketTypeSchema } from "@lingo.dev/_spec";
4 | import { composeLoaders } from "./_utils";
5 | import createJsonLoader from "./json";
6 | import createJson5Loader from "./json5";
7 | import createJsoncLoader from "./jsonc";
8 | import createFlatLoader from "./flat";
9 | import createTextFileLoader from "./text-file";
10 | import createYamlLoader from "./yaml";
11 | import createRootKeyLoader from "./root-key";
12 | import createFlutterLoader from "./flutter";
13 | import { ILoader } from "./_types";
14 | import createAndroidLoader from "./android";
15 | import createCsvLoader from "./csv";
16 | import createHtmlLoader from "./html";
17 | import createMarkdownLoader from "./markdown";
18 | import createMarkdocLoader from "./markdoc";
19 | import createPropertiesLoader from "./properties";
20 | import createXcodeStringsLoader from "./xcode-strings";
21 | import createXcodeStringsdictLoader from "./xcode-stringsdict";
22 | import createXcodeXcstringsLoader from "./xcode-xcstrings";
23 | import createXcodeXcstringsV2Loader from "./xcode-xcstrings-v2-loader";
24 | import { isICUPluralObject } from "./xcode-xcstrings-icu";
25 | import createUnlocalizableLoader from "./unlocalizable";
26 | import { createFormatterLoader, FormatterType } from "./formatters";
27 | import createPoLoader from "./po";
28 | import createXliffLoader from "./xliff";
29 | import createXmlLoader from "./xml";
30 | import createSrtLoader from "./srt";
31 | import createDatoLoader from "./dato";
32 | import createVttLoader from "./vtt";
33 | import createVariableLoader from "./variable";
34 | import createSyncLoader from "./sync";
35 | import createPlutilJsonTextLoader from "./plutil-json-loader";
36 | import createPhpLoader from "./php";
37 | import createVueJsonLoader from "./vue-json";
38 | import createTypescriptLoader from "./typescript";
39 | import createInjectLocaleLoader from "./inject-locale";
40 | import createLockedKeysLoader from "./locked-keys";
41 | import createMdxFrontmatterSplitLoader from "./mdx2/frontmatter-split";
42 | import createMdxCodePlaceholderLoader from "./mdx2/code-placeholder";
43 | import createLocalizableMdxDocumentLoader from "./mdx2/localizable-document";
44 | import createMdxSectionsSplit2Loader from "./mdx2/sections-split-2";
45 | import createLockedPatternsLoader from "./locked-patterns";
46 | import createIgnoredKeysLoader from "./ignored-keys";
47 | import createEjsLoader from "./ejs";
48 | import createEnsureKeyOrderLoader from "./ensure-key-order";
49 | import createTxtLoader from "./txt";
50 | import createJsonKeysLoader from "./json-dictionary";
51 |
52 | type BucketLoaderOptions = {
53 | returnUnlocalizedKeys?: boolean;
54 | defaultLocale: string;
55 | injectLocale?: string[];
56 | targetLocale?: string;
57 | formatter?: FormatterType;
58 | };
59 |
60 | export default function createBucketLoader(
61 | bucketType: Z.infer<typeof bucketTypeSchema>,
62 | bucketPathPattern: string,
63 | options: BucketLoaderOptions,
64 | lockedKeys?: string[],
65 | lockedPatterns?: string[],
66 | ignoredKeys?: string[],
67 | ): ILoader<void, Record<string, any>> {
68 | switch (bucketType) {
69 | default:
70 | throw new Error(`Unsupported bucket type: ${bucketType}`);
71 | case "android":
72 | return composeLoaders(
73 | createTextFileLoader(bucketPathPattern),
74 | createLockedPatternsLoader(lockedPatterns),
75 | createAndroidLoader(),
76 | createEnsureKeyOrderLoader(),
77 | createFlatLoader(),
78 | createLockedKeysLoader(lockedKeys || []),
79 | createIgnoredKeysLoader(ignoredKeys || []),
80 | createSyncLoader(),
81 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
82 | );
83 | case "csv":
84 | return composeLoaders(
85 | createTextFileLoader(bucketPathPattern),
86 | createLockedPatternsLoader(lockedPatterns),
87 | createCsvLoader(),
88 | createEnsureKeyOrderLoader(),
89 | createFlatLoader(),
90 | createLockedKeysLoader(lockedKeys || []),
91 | createIgnoredKeysLoader(ignoredKeys || []),
92 | createSyncLoader(),
93 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
94 | );
95 | case "html":
96 | return composeLoaders(
97 | createTextFileLoader(bucketPathPattern),
98 | createFormatterLoader(options.formatter, "html", bucketPathPattern),
99 | createLockedPatternsLoader(lockedPatterns),
100 | createHtmlLoader(),
101 | createLockedKeysLoader(lockedKeys || []),
102 | createIgnoredKeysLoader(ignoredKeys || []),
103 | createSyncLoader(),
104 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
105 | );
106 | case "ejs":
107 | return composeLoaders(
108 | createTextFileLoader(bucketPathPattern),
109 | createLockedPatternsLoader(lockedPatterns),
110 | createEjsLoader(),
111 | createLockedKeysLoader(lockedKeys || []),
112 | createIgnoredKeysLoader(ignoredKeys || []),
113 | createSyncLoader(),
114 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
115 | );
116 | case "json":
117 | return composeLoaders(
118 | createTextFileLoader(bucketPathPattern),
119 | createFormatterLoader(options.formatter, "json", bucketPathPattern),
120 | createLockedPatternsLoader(lockedPatterns),
121 | createJsonLoader(),
122 | createEnsureKeyOrderLoader(),
123 | createFlatLoader(),
124 | createInjectLocaleLoader(options.injectLocale),
125 | createLockedKeysLoader(lockedKeys || []),
126 | createIgnoredKeysLoader(ignoredKeys || []),
127 | createSyncLoader(),
128 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
129 | );
130 | case "json5":
131 | return composeLoaders(
132 | createTextFileLoader(bucketPathPattern),
133 | createLockedPatternsLoader(lockedPatterns),
134 | createJson5Loader(),
135 | createEnsureKeyOrderLoader(),
136 | createFlatLoader(),
137 | createInjectLocaleLoader(options.injectLocale),
138 | createLockedKeysLoader(lockedKeys || []),
139 | createIgnoredKeysLoader(ignoredKeys || []),
140 | createSyncLoader(),
141 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
142 | );
143 | case "jsonc":
144 | return composeLoaders(
145 | createTextFileLoader(bucketPathPattern),
146 | createLockedPatternsLoader(lockedPatterns),
147 | createJsoncLoader(),
148 | createEnsureKeyOrderLoader(),
149 | createFlatLoader(),
150 | createInjectLocaleLoader(options.injectLocale),
151 | createLockedKeysLoader(lockedKeys || []),
152 | createIgnoredKeysLoader(ignoredKeys || []),
153 | createSyncLoader(),
154 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
155 | );
156 | case "markdown":
157 | return composeLoaders(
158 | createTextFileLoader(bucketPathPattern),
159 | createFormatterLoader(options.formatter, "markdown", bucketPathPattern),
160 | createLockedPatternsLoader(lockedPatterns),
161 | createMarkdownLoader(),
162 | createLockedKeysLoader(lockedKeys || []),
163 | createIgnoredKeysLoader(ignoredKeys || []),
164 | createSyncLoader(),
165 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
166 | );
167 | case "markdoc":
168 | return composeLoaders(
169 | createTextFileLoader(bucketPathPattern),
170 | createLockedPatternsLoader(lockedPatterns),
171 | createMarkdocLoader(),
172 | createFlatLoader(),
173 | createEnsureKeyOrderLoader(),
174 | createLockedKeysLoader(lockedKeys || []),
175 | createIgnoredKeysLoader(ignoredKeys || []),
176 | createSyncLoader(),
177 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
178 | );
179 | case "mdx":
180 | return composeLoaders(
181 | createTextFileLoader(bucketPathPattern),
182 | createFormatterLoader(options.formatter, "mdx", bucketPathPattern),
183 | createMdxCodePlaceholderLoader(),
184 | createLockedPatternsLoader(lockedPatterns),
185 | createMdxFrontmatterSplitLoader(),
186 | createMdxSectionsSplit2Loader(),
187 | createLocalizableMdxDocumentLoader(),
188 | createFlatLoader(),
189 | createEnsureKeyOrderLoader(),
190 | createLockedKeysLoader(lockedKeys || []),
191 | createIgnoredKeysLoader(ignoredKeys || []),
192 | createSyncLoader(),
193 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
194 | );
195 | case "po":
196 | return composeLoaders(
197 | createTextFileLoader(bucketPathPattern),
198 | createLockedPatternsLoader(lockedPatterns),
199 | createPoLoader(),
200 | createFlatLoader(),
201 | createEnsureKeyOrderLoader(),
202 | createLockedKeysLoader(lockedKeys || []),
203 | createIgnoredKeysLoader(ignoredKeys || []),
204 | createSyncLoader(),
205 | createVariableLoader({ type: "python" }),
206 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
207 | );
208 | case "properties":
209 | return composeLoaders(
210 | createTextFileLoader(bucketPathPattern),
211 | createLockedPatternsLoader(lockedPatterns),
212 | createPropertiesLoader(),
213 | createLockedKeysLoader(lockedKeys || []),
214 | createIgnoredKeysLoader(ignoredKeys || []),
215 | createSyncLoader(),
216 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
217 | );
218 | case "xcode-strings":
219 | return composeLoaders(
220 | createTextFileLoader(bucketPathPattern),
221 | createLockedPatternsLoader(lockedPatterns),
222 | createXcodeStringsLoader(),
223 | createLockedKeysLoader(lockedKeys || []),
224 | createIgnoredKeysLoader(ignoredKeys || []),
225 | createSyncLoader(),
226 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
227 | );
228 | case "xcode-stringsdict":
229 | return composeLoaders(
230 | createTextFileLoader(bucketPathPattern),
231 | createLockedPatternsLoader(lockedPatterns),
232 | createXcodeStringsdictLoader(),
233 | createFlatLoader(),
234 | createEnsureKeyOrderLoader(),
235 | createLockedKeysLoader(lockedKeys || []),
236 | createIgnoredKeysLoader(ignoredKeys || []),
237 | createSyncLoader(),
238 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
239 | );
240 | case "xcode-xcstrings":
241 | return composeLoaders(
242 | createTextFileLoader(bucketPathPattern),
243 | createPlutilJsonTextLoader(),
244 | createLockedPatternsLoader(lockedPatterns),
245 | createJsonLoader(),
246 | createXcodeXcstringsLoader(options.defaultLocale),
247 | createFlatLoader(),
248 | createEnsureKeyOrderLoader(),
249 | createLockedKeysLoader(lockedKeys || []),
250 | createIgnoredKeysLoader(ignoredKeys || []),
251 | createSyncLoader(),
252 | createVariableLoader({ type: "ieee" }),
253 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
254 | );
255 | case "xcode-xcstrings-v2":
256 | return composeLoaders(
257 | createTextFileLoader(bucketPathPattern),
258 | createPlutilJsonTextLoader(),
259 | createLockedPatternsLoader(lockedPatterns),
260 | createJsonLoader(),
261 | createXcodeXcstringsLoader(options.defaultLocale),
262 | createXcodeXcstringsV2Loader(options.defaultLocale),
263 | createFlatLoader({ shouldPreserveObject: isICUPluralObject }),
264 | createEnsureKeyOrderLoader(),
265 | createLockedKeysLoader(lockedKeys || []),
266 | createIgnoredKeysLoader(ignoredKeys || []),
267 | createSyncLoader(),
268 | createVariableLoader({ type: "ieee" }),
269 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
270 | );
271 | case "yaml":
272 | return composeLoaders(
273 | createTextFileLoader(bucketPathPattern),
274 | createFormatterLoader(options.formatter, "yaml", bucketPathPattern),
275 | createLockedPatternsLoader(lockedPatterns),
276 | createYamlLoader(),
277 | createFlatLoader(),
278 | createEnsureKeyOrderLoader(),
279 | createLockedKeysLoader(lockedKeys || []),
280 | createIgnoredKeysLoader(ignoredKeys || []),
281 | createSyncLoader(),
282 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
283 | );
284 | case "yaml-root-key":
285 | return composeLoaders(
286 | createTextFileLoader(bucketPathPattern),
287 | createFormatterLoader(options.formatter, "yaml", bucketPathPattern),
288 | createLockedPatternsLoader(lockedPatterns),
289 | createYamlLoader(),
290 | createRootKeyLoader(true),
291 | createFlatLoader(),
292 | createEnsureKeyOrderLoader(),
293 | createLockedKeysLoader(lockedKeys || []),
294 | createIgnoredKeysLoader(ignoredKeys || []),
295 | createSyncLoader(),
296 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
297 | );
298 | case "flutter":
299 | return composeLoaders(
300 | createTextFileLoader(bucketPathPattern),
301 | createFormatterLoader(options.formatter, "json", bucketPathPattern),
302 | createLockedPatternsLoader(lockedPatterns),
303 | createJsonLoader(),
304 | createEnsureKeyOrderLoader(),
305 | createFlutterLoader(),
306 | createFlatLoader(),
307 | createLockedKeysLoader(lockedKeys || []),
308 | createIgnoredKeysLoader(ignoredKeys || []),
309 | createSyncLoader(),
310 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
311 | );
312 | case "xliff":
313 | return composeLoaders(
314 | createTextFileLoader(bucketPathPattern),
315 | createLockedPatternsLoader(lockedPatterns),
316 | createXliffLoader(),
317 | createFlatLoader(),
318 | createEnsureKeyOrderLoader(),
319 | createLockedKeysLoader(lockedKeys || []),
320 | createIgnoredKeysLoader(ignoredKeys || []),
321 | createSyncLoader(),
322 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
323 | );
324 | case "xml":
325 | return composeLoaders(
326 | createTextFileLoader(bucketPathPattern),
327 | createLockedPatternsLoader(lockedPatterns),
328 | createXmlLoader(),
329 | createFlatLoader(),
330 | createEnsureKeyOrderLoader(),
331 | createLockedKeysLoader(lockedKeys || []),
332 | createIgnoredKeysLoader(ignoredKeys || []),
333 | createSyncLoader(),
334 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
335 | );
336 | case "srt":
337 | return composeLoaders(
338 | createTextFileLoader(bucketPathPattern),
339 | createLockedPatternsLoader(lockedPatterns),
340 | createSrtLoader(),
341 | createLockedKeysLoader(lockedKeys || []),
342 | createIgnoredKeysLoader(ignoredKeys || []),
343 | createSyncLoader(),
344 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
345 | );
346 | case "dato":
347 | return composeLoaders(
348 | createDatoLoader(bucketPathPattern),
349 | createSyncLoader(),
350 | createFlatLoader(),
351 | createEnsureKeyOrderLoader(),
352 | createLockedKeysLoader(lockedKeys || []),
353 | createIgnoredKeysLoader(ignoredKeys || []),
354 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
355 | );
356 | case "vtt":
357 | return composeLoaders(
358 | createTextFileLoader(bucketPathPattern),
359 | createLockedPatternsLoader(lockedPatterns),
360 | createVttLoader(),
361 | createLockedKeysLoader(lockedKeys || []),
362 | createIgnoredKeysLoader(ignoredKeys || []),
363 | createSyncLoader(),
364 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
365 | );
366 | case "php":
367 | return composeLoaders(
368 | createTextFileLoader(bucketPathPattern),
369 | createLockedPatternsLoader(lockedPatterns),
370 | createPhpLoader(),
371 | createSyncLoader(),
372 | createFlatLoader(),
373 | createEnsureKeyOrderLoader(),
374 | createLockedKeysLoader(lockedKeys || []),
375 | createIgnoredKeysLoader(ignoredKeys || []),
376 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
377 | );
378 | case "vue-json":
379 | return composeLoaders(
380 | createTextFileLoader(bucketPathPattern),
381 | createLockedPatternsLoader(lockedPatterns),
382 | createVueJsonLoader(),
383 | createSyncLoader(),
384 | createFlatLoader(),
385 | createEnsureKeyOrderLoader(),
386 | createLockedKeysLoader(lockedKeys || []),
387 | createIgnoredKeysLoader(ignoredKeys || []),
388 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
389 | );
390 | case "typescript":
391 | return composeLoaders(
392 | createTextFileLoader(bucketPathPattern),
393 | createFormatterLoader(
394 | options.formatter,
395 | "typescript",
396 | bucketPathPattern,
397 | ),
398 | createLockedPatternsLoader(lockedPatterns),
399 | createTypescriptLoader(),
400 | createFlatLoader(),
401 | createEnsureKeyOrderLoader(),
402 | createSyncLoader(),
403 | createLockedKeysLoader(lockedKeys || []),
404 | createIgnoredKeysLoader(ignoredKeys || []),
405 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
406 | );
407 | case "txt":
408 | return composeLoaders(
409 | createTextFileLoader(bucketPathPattern),
410 | createLockedPatternsLoader(lockedPatterns),
411 | createTxtLoader(),
412 | createLockedKeysLoader(lockedKeys || []),
413 | createIgnoredKeysLoader(ignoredKeys || []),
414 | createSyncLoader(),
415 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
416 | );
417 | case "json-dictionary":
418 | return composeLoaders(
419 | createTextFileLoader(bucketPathPattern),
420 | createFormatterLoader(options.formatter, "json", bucketPathPattern),
421 | createLockedPatternsLoader(lockedPatterns),
422 | createJsonLoader(),
423 | createJsonKeysLoader(),
424 | createEnsureKeyOrderLoader(),
425 | createFlatLoader(),
426 | createInjectLocaleLoader(options.injectLocale),
427 | createLockedKeysLoader(lockedKeys || []),
428 | createIgnoredKeysLoader(ignoredKeys || []),
429 | createSyncLoader(),
430 | createUnlocalizableLoader(options.returnUnlocalizedKeys),
431 | );
432 | }
433 | }
434 |
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/markdoc.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import createMarkdocLoader from "./markdoc";
3 |
4 | describe("markdoc loader", () => {
5 | describe("block-level tag", () => {
6 | it("should extract text content from block-level tag", async () => {
7 | const loader = createMarkdocLoader();
8 | loader.setDefaultLocale("en");
9 |
10 | const input = `{% foo %}
11 | This is content inside of a block-level tag
12 | {% /foo %}`;
13 |
14 | const output = await loader.pull("en", input);
15 |
16 | // Should extract the text content with semantic keys
17 | const contents = Object.values(output);
18 |
19 | expect(contents).toContain("This is content inside of a block-level tag");
20 | });
21 |
22 | it("should preserve tag structure on push", async () => {
23 | const loader = createMarkdocLoader();
24 | loader.setDefaultLocale("en");
25 |
26 | const input = `{% foo %}
27 | This is content inside of a block-level tag
28 | {% /foo %}`;
29 |
30 | const pulled = await loader.pull("en", input);
31 | const pushed = await loader.push("en", pulled);
32 |
33 | expect(pushed.trim()).toBe(input.trim());
34 | });
35 |
36 | it("should apply translations on push", async () => {
37 | const loader = createMarkdocLoader();
38 | loader.setDefaultLocale("en");
39 |
40 | const input = `{% example %}
41 | This paragraph is nested within a Markdoc tag.
42 | {% /example %}`;
43 |
44 | const pulled = await loader.pull("en", input);
45 |
46 | // Modify the content using semantic keys
47 | const translated = { ...pulled };
48 | const contentKey = Object.keys(translated).find(
49 | (k) =>
50 | translated[k] === "This paragraph is nested within a Markdoc tag.",
51 | );
52 | if (contentKey) {
53 | translated[contentKey] =
54 | "Este párrafo está anidado dentro de una etiqueta Markdoc.";
55 | }
56 |
57 | const pushed = await loader.push("es", translated);
58 |
59 | expect(pushed).toContain(
60 | "Este párrafo está anidado dentro de una etiqueta Markdoc.",
61 | );
62 | expect(pushed).toContain("{% example %}");
63 | expect(pushed).toContain("{% /example %}");
64 | });
65 | });
66 |
67 | describe("self-closing tag", () => {
68 | it("should handle self-closing tag with no content", async () => {
69 | const loader = createMarkdocLoader();
70 | loader.setDefaultLocale("en");
71 |
72 | const input = `{% example /%}`;
73 |
74 | const output = await loader.pull("en", input);
75 |
76 | // Should have the tag but no text content
77 | expect(output).toBeDefined();
78 | });
79 |
80 | it("should preserve self-closing tag on push", async () => {
81 | const loader = createMarkdocLoader();
82 | loader.setDefaultLocale("en");
83 |
84 | const input = `{% example /%}`;
85 |
86 | const pulled = await loader.pull("en", input);
87 | const pushed = await loader.push("en", pulled);
88 |
89 | expect(pushed.trim()).toBe(input.trim());
90 | });
91 | });
92 |
93 | describe("inline tag", () => {
94 | it("should extract text from inline tag", async () => {
95 | const loader = createMarkdocLoader();
96 | loader.setDefaultLocale("en");
97 |
98 | const input = `This is a paragraph {% foo %}that contains a tag{% /foo %}`;
99 |
100 | const output = await loader.pull("en", input);
101 |
102 | // Should extract both text segments
103 | const contents = Object.values(output);
104 |
105 | expect(contents).toContain("This is a paragraph ");
106 | expect(contents).toContain("that contains a tag");
107 | });
108 |
109 | it("should preserve inline tag structure on push", async () => {
110 | const loader = createMarkdocLoader();
111 | loader.setDefaultLocale("en");
112 |
113 | const input = `This is a paragraph {% foo %}that contains a tag{% /foo %}`;
114 |
115 | const pulled = await loader.pull("en", input);
116 | const pushed = await loader.push("en", pulled);
117 |
118 | expect(pushed.trim()).toBe(input.trim());
119 | });
120 |
121 | it("should apply translations to inline tag content", async () => {
122 | const loader = createMarkdocLoader();
123 | loader.setDefaultLocale("en");
124 |
125 | const input = `This is a paragraph {% foo %}that contains a tag{% /foo %}`;
126 |
127 | const pulled = await loader.pull("en", input);
128 |
129 | // Translate both text segments
130 | const translated = { ...pulled };
131 | Object.keys(translated).forEach((key) => {
132 | if (translated[key] === "This is a paragraph ") {
133 | translated[key] = "Este es un párrafo ";
134 | } else if (translated[key] === "that contains a tag") {
135 | translated[key] = "que contiene una etiqueta";
136 | }
137 | });
138 |
139 | const pushed = await loader.push("es", translated);
140 |
141 | expect(pushed).toContain("Este es un párrafo");
142 | expect(pushed).toContain("que contiene una etiqueta");
143 | expect(pushed).toContain("{% foo %}");
144 | expect(pushed).toContain("{% /foo %}");
145 | });
146 | });
147 |
148 | describe("inline tag only content", () => {
149 | it("should handle inline tag as sole paragraph content", async () => {
150 | const loader = createMarkdocLoader();
151 | loader.setDefaultLocale("en");
152 |
153 | const input = `{% foo %}This is content inside of an inline tag{% /foo %}`;
154 |
155 | const output = await loader.pull("en", input);
156 |
157 | const contents = Object.values(output);
158 |
159 | expect(contents).toContain("This is content inside of an inline tag");
160 | });
161 |
162 | it("should preserve inline-only tag structure on push", async () => {
163 | const loader = createMarkdocLoader();
164 | loader.setDefaultLocale("en");
165 |
166 | const input = `{% foo %}This is content inside of an inline tag{% /foo %}`;
167 |
168 | const pulled = await loader.pull("en", input);
169 | const pushed = await loader.push("en", pulled);
170 |
171 | expect(pushed.trim()).toBe(input.trim());
172 | });
173 | });
174 |
175 | describe("mixed content", () => {
176 | it("should handle document with multiple tags and text", async () => {
177 | const loader = createMarkdocLoader();
178 | loader.setDefaultLocale("en");
179 |
180 | const input = `# Heading
181 |
182 | This is a paragraph.
183 |
184 | {% note %}
185 | Important information here.
186 | {% /note %}
187 |
188 | Another paragraph with {% inline %}inline content{% /inline %}.
189 |
190 | {% self-closing /%}`;
191 |
192 | const output = await loader.pull("en", input);
193 | const pushed = await loader.push("en", output);
194 |
195 | // Verify structure is preserved
196 | expect(pushed).toContain("# Heading");
197 | expect(pushed).toContain("{% note %}");
198 | expect(pushed).toContain("{% /note %}");
199 | expect(pushed).toContain("{% inline %}");
200 | expect(pushed).toContain("{% /inline %}");
201 | expect(pushed).toContain("{% self-closing /%}");
202 | });
203 | });
204 |
205 | describe("nested tags", () => {
206 | it("should handle nested tags", async () => {
207 | const loader = createMarkdocLoader();
208 | loader.setDefaultLocale("en");
209 |
210 | const input = `{% outer %}
211 | Outer content
212 | {% inner %}
213 | Inner content
214 | {% /inner %}
215 | More outer content
216 | {% /outer %}`;
217 |
218 | const output = await loader.pull("en", input);
219 | const pushed = await loader.push("en", output);
220 |
221 | expect(pushed).toContain("{% outer %}");
222 | expect(pushed).toContain("{% inner %}");
223 | expect(pushed).toContain("{% /inner %}");
224 | expect(pushed).toContain("{% /outer %}");
225 | });
226 | });
227 |
228 | describe("interpolation", () => {
229 | it("should preserve variable interpolation", async () => {
230 | const loader = createMarkdocLoader();
231 | loader.setDefaultLocale("en");
232 |
233 | const input = `Hello {% $username %}`;
234 |
235 | const output = await loader.pull("en", input);
236 | const pushed = await loader.push("en", output);
237 |
238 | expect(pushed.trim()).toBe(input.trim());
239 | });
240 |
241 | it("should preserve function interpolation", async () => {
242 | const loader = createMarkdocLoader();
243 | loader.setDefaultLocale("en");
244 |
245 | const input = `Result: {% calculateValue() %}`;
246 |
247 | const output = await loader.pull("en", input);
248 | const pushed = await loader.push("en", output);
249 |
250 | expect(pushed.trim()).toBe(input.trim());
251 | });
252 |
253 | it("should preserve interpolation in middle of text", async () => {
254 | const loader = createMarkdocLoader();
255 | loader.setDefaultLocale("en");
256 |
257 | const input = `This is {% $var %} some text.`;
258 |
259 | const output = await loader.pull("en", input);
260 | const pushed = await loader.push("en", output);
261 |
262 | expect(pushed.trim()).toBe(input.trim());
263 | });
264 |
265 | it("should translate text around interpolation", async () => {
266 | const loader = createMarkdocLoader();
267 | loader.setDefaultLocale("en");
268 |
269 | const input = `Hello {% $username %}, welcome!`;
270 |
271 | const output = await loader.pull("en", input);
272 |
273 | // Should extract text segments but not interpolation
274 | const textContents = Object.values(output).filter(
275 | (v) => typeof v === "string",
276 | );
277 |
278 | expect(textContents).toContain("Hello ");
279 | expect(textContents).toContain(", welcome!");
280 |
281 | // Translate the text segments
282 | const translated = { ...output };
283 | Object.keys(translated).forEach((key) => {
284 | if (translated[key] === "Hello ") {
285 | translated[key] = "Hola ";
286 | } else if (translated[key] === ", welcome!") {
287 | translated[key] = ", ¡bienvenido!";
288 | }
289 | });
290 |
291 | const pushed = await loader.push("es", translated);
292 |
293 | expect(pushed).toContain("Hola");
294 | expect(pushed).toContain("¡bienvenido!");
295 | expect(pushed).toContain("{% $username %}");
296 | });
297 |
298 | it("should handle interpolation in tags", async () => {
299 | const loader = createMarkdocLoader();
300 | loader.setDefaultLocale("en");
301 |
302 | const input = `{% callout %}
303 | The value is {% $value %} today.
304 | {% /callout %}`;
305 |
306 | const output = await loader.pull("en", input);
307 | const pushed = await loader.push("en", output);
308 |
309 | expect(pushed).toContain("{% callout %}");
310 | expect(pushed).toContain("{% $value %}");
311 | expect(pushed).toContain("{% /callout %}");
312 | });
313 | });
314 |
315 | describe("annotations", () => {
316 | it("should preserve annotations with shorthand class attribute", async () => {
317 | const loader = createMarkdocLoader();
318 | loader.setDefaultLocale("en");
319 |
320 | const input = `# Heading {% .example %}`;
321 |
322 | const output = await loader.pull("en", input);
323 | const pushed = await loader.push("en", output);
324 |
325 | expect(pushed).toContain("# Heading");
326 | expect(pushed).toContain("{% .example %}");
327 | });
328 |
329 | it("should preserve annotations with shorthand id attribute", async () => {
330 | const loader = createMarkdocLoader();
331 | loader.setDefaultLocale("en");
332 |
333 | const input = `# Heading {% #main-title %}`;
334 |
335 | const output = await loader.pull("en", input);
336 | const pushed = await loader.push("en", output);
337 |
338 | expect(pushed).toContain("# Heading");
339 | expect(pushed).toContain("{% #main-title %}");
340 | });
341 |
342 | it("should preserve annotations with multiple shorthand attributes", async () => {
343 | const loader = createMarkdocLoader();
344 | loader.setDefaultLocale("en");
345 |
346 | const input = `# Heading {% #foo .bar .baz %}`;
347 |
348 | const output = await loader.pull("en", input);
349 | const pushed = await loader.push("en", output);
350 |
351 | expect(pushed).toContain("# Heading");
352 | expect(pushed).toContain("{% #foo .bar .baz %}");
353 | });
354 |
355 | it("should translate heading text with annotations", async () => {
356 | const loader = createMarkdocLoader();
357 | loader.setDefaultLocale("en");
358 |
359 | const input = `# Welcome {% .hero-title %}`;
360 |
361 | const output = await loader.pull("en", input);
362 |
363 | // Find and translate the heading text (note: has trailing space)
364 | const translated = { ...output };
365 | Object.keys(translated).forEach((key) => {
366 | if (translated[key] === "Welcome ") {
367 | translated[key] = "Bienvenido ";
368 | }
369 | });
370 |
371 | const pushed = await loader.push("es", translated);
372 |
373 | expect(pushed).toContain("Bienvenido");
374 | expect(pushed).toContain("{% .hero-title %}");
375 | });
376 | });
377 |
378 | describe("tag attributes", () => {
379 | it("should preserve tags with full attributes", async () => {
380 | const loader = createMarkdocLoader();
381 | loader.setDefaultLocale("en");
382 |
383 | const input = `{% callout type="note" %}
384 | This is important information.
385 | {% /callout %}`;
386 |
387 | const output = await loader.pull("en", input);
388 | const pushed = await loader.push("en", output);
389 |
390 | expect(pushed).toContain('{% callout type="note" %}');
391 | expect(pushed).toContain("{% /callout %}");
392 | });
393 |
394 | it("should preserve tags with multiple attributes", async () => {
395 | const loader = createMarkdocLoader();
396 | loader.setDefaultLocale("en");
397 |
398 | const input = `{% image src="logo.png" alt="Company Logo" width="200" /%}`;
399 |
400 | const output = await loader.pull("en", input);
401 | const pushed = await loader.push("en", output);
402 |
403 | expect(pushed.trim()).toBe(input.trim());
404 | });
405 |
406 | it("should preserve tags with array attributes", async () => {
407 | const loader = createMarkdocLoader();
408 | loader.setDefaultLocale("en");
409 |
410 | const input = `{% chart data=[1, 2, 3] /%}`;
411 |
412 | const output = await loader.pull("en", input);
413 | const pushed = await loader.push("en", output);
414 |
415 | expect(pushed.trim()).toBe(input.trim());
416 | });
417 |
418 | it("should translate content in tags with attributes", async () => {
419 | const loader = createMarkdocLoader();
420 | loader.setDefaultLocale("en");
421 |
422 | const input = `{% callout type="warning" %}
423 | Please read carefully.
424 | {% /callout %}`;
425 |
426 | const output = await loader.pull("en", input);
427 |
428 | // Translate the content
429 | const translated = { ...output };
430 | Object.keys(translated).forEach((key) => {
431 | if (translated[key] === "Please read carefully.") {
432 | translated[key] = "Por favor lea con atención.";
433 | }
434 | });
435 |
436 | const pushed = await loader.push("es", translated);
437 |
438 | expect(pushed).toContain("Por favor lea con atención.");
439 | expect(pushed).toContain('{% callout type="warning" %}');
440 | expect(pushed).toContain("{% /callout %}");
441 | });
442 | });
443 |
444 | describe("primary attributes", () => {
445 | it("should preserve tags with primary attribute", async () => {
446 | const loader = createMarkdocLoader();
447 | loader.setDefaultLocale("en");
448 |
449 | const input = `{% if $showContent %}
450 | Content is visible.
451 | {% /if %}`;
452 |
453 | const output = await loader.pull("en", input);
454 | const pushed = await loader.push("en", output);
455 |
456 | expect(pushed).toContain("{% if $showContent %}");
457 | expect(pushed).toContain("{% /if %}");
458 | });
459 |
460 | it("should translate content in tags with primary attribute", async () => {
461 | const loader = createMarkdocLoader();
462 | loader.setDefaultLocale("en");
463 |
464 | const input = `{% if $showContent %}
465 | Content is visible.
466 | {% /if %}`;
467 |
468 | const output = await loader.pull("en", input);
469 |
470 | // Translate the content
471 | const translated = { ...output };
472 | Object.keys(translated).forEach((key) => {
473 | if (translated[key] === "Content is visible.") {
474 | translated[key] = "El contenido es visible.";
475 | }
476 | });
477 |
478 | const pushed = await loader.push("es", translated);
479 |
480 | expect(pushed).toContain("El contenido es visible.");
481 | expect(pushed).toContain("{% if $showContent %}");
482 | });
483 | });
484 |
485 | describe("frontmatter", () => {
486 | it("should extract frontmatter attributes", async () => {
487 | const loader = createMarkdocLoader();
488 | loader.setDefaultLocale("en");
489 |
490 | const input = `---
491 | title: My Document
492 | description: A sample document
493 | author: John Doe
494 | ---
495 |
496 | # Heading
497 |
498 | Content here.`;
499 |
500 | const output = await loader.pull("en", input);
501 |
502 | expect(output["fm-attr-title"]).toBe("My Document");
503 | expect(output["fm-attr-description"]).toBe("A sample document");
504 | expect(output["fm-attr-author"]).toBe("John Doe");
505 | });
506 |
507 | it("should preserve frontmatter on push", async () => {
508 | const loader = createMarkdocLoader();
509 | loader.setDefaultLocale("en");
510 |
511 | const input = `---
512 | title: My Document
513 | description: A sample document
514 | ---
515 |
516 | # Heading
517 |
518 | Content here.`;
519 |
520 | const pulled = await loader.pull("en", input);
521 | const pushed = await loader.push("en", pulled);
522 |
523 | expect(pushed).toContain("title: My Document");
524 | expect(pushed).toContain("description: A sample document");
525 | expect(pushed).toContain("# Heading");
526 | expect(pushed).toContain("Content here.");
527 | });
528 |
529 | it("should translate frontmatter attributes", async () => {
530 | const loader = createMarkdocLoader();
531 | loader.setDefaultLocale("en");
532 |
533 | const input = `---
534 | title: Welcome
535 | description: This is a guide
536 | ---
537 |
538 | # Content
539 |
540 | Some text.`;
541 |
542 | const pulled = await loader.pull("en", input);
543 |
544 | // Translate frontmatter
545 | const translated = { ...pulled };
546 | translated["fm-attr-title"] = "Bienvenido";
547 | translated["fm-attr-description"] = "Esta es una guía";
548 |
549 | const pushed = await loader.push("es", translated);
550 |
551 | expect(pushed).toContain("title: Bienvenido");
552 | expect(pushed).toContain("description: Esta es una guía");
553 | });
554 |
555 | it("should handle documents without frontmatter", async () => {
556 | const loader = createMarkdocLoader();
557 | loader.setDefaultLocale("en");
558 |
559 | const input = `# Heading
560 |
561 | Content without frontmatter.`;
562 |
563 | const output = await loader.pull("en", input);
564 | const pushed = await loader.push("en", output);
565 |
566 | expect(pushed).not.toContain("---");
567 | expect(pushed).toContain("# Heading");
568 | expect(pushed).toContain("Content without frontmatter.");
569 | });
570 | });
571 | });
572 |
```