This is page 9 of 16. Use http://codebase.md/lingodotdev/lingo.dev?page={x} to view the full context.
# Directory Structure
```
├── .changeset
│ ├── config.json
│ └── README.md
├── .claude
│ ├── agents
│ │ └── code-architect-reviewer.md
│ └── commands
│ ├── analyze-bucket-type.md
│ └── create-bucket-docs.md
├── .editorconfig
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── docker.yml
│ ├── lingodotdev.yml
│ ├── pr-check.yml
│ ├── pr-lint.yml
│ └── release.yml
├── .gitignore
├── .husky
│ └── commit-msg
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .vscode
│ ├── extensions.json
│ ├── launch.json
│ └── settings.json
├── action.yml
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── commitlint.config.js
├── composer.json
├── content
│ ├── banner.compiler.png
│ ├── banner.dark.png
│ └── banner.launch.png
├── CONTRIBUTING.md
├── DEBUGGING.md
├── demo
│ ├── adonisjs
│ │ ├── .editorconfig
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── ace.js
│ │ ├── adonisrc.ts
│ │ ├── app
│ │ │ ├── exceptions
│ │ │ │ └── handler.ts
│ │ │ └── middleware
│ │ │ └── container_bindings_middleware.ts
│ │ ├── bin
│ │ │ ├── console.ts
│ │ │ ├── server.ts
│ │ │ └── test.ts
│ │ ├── CHANGELOG.md
│ │ ├── config
│ │ │ ├── app.ts
│ │ │ ├── bodyparser.ts
│ │ │ ├── cors.ts
│ │ │ ├── hash.ts
│ │ │ ├── inertia.ts
│ │ │ ├── logger.ts
│ │ │ ├── session.ts
│ │ │ ├── shield.ts
│ │ │ ├── static.ts
│ │ │ └── vite.ts
│ │ ├── eslint.config.js
│ │ ├── inertia
│ │ │ ├── app
│ │ │ │ ├── app.tsx
│ │ │ │ └── ssr.tsx
│ │ │ ├── css
│ │ │ │ └── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── pages
│ │ │ │ ├── errors
│ │ │ │ │ ├── not_found.tsx
│ │ │ │ │ └── server_error.tsx
│ │ │ │ └── home.tsx
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── resources
│ │ │ └── views
│ │ │ └── inertia_layout.edge
│ │ ├── start
│ │ │ ├── env.ts
│ │ │ ├── kernel.ts
│ │ │ └── routes.ts
│ │ ├── tests
│ │ │ └── bootstrap.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── next-app
│ │ ├── .gitignore
│ │ ├── CHANGELOG.md
│ │ ├── eslint.config.mjs
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── public
│ │ │ ├── file.svg
│ │ │ ├── globe.svg
│ │ │ ├── next.svg
│ │ │ ├── vercel.svg
│ │ │ └── window.svg
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── client-component.tsx
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── lingo-dot-dev.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── test
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── hero-actions.tsx
│ │ │ │ ├── hero-subtitle.tsx
│ │ │ │ ├── hero-title.tsx
│ │ │ │ └── index.ts
│ │ │ └── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ └── tsconfig.json
│ ├── react-router-app
│ │ ├── .dockerignore
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── root.tsx
│ │ │ ├── routes
│ │ │ │ ├── home.tsx
│ │ │ │ └── test.tsx
│ │ │ ├── routes.ts
│ │ │ └── welcome
│ │ │ ├── lingo-dot-dev.tsx
│ │ │ ├── logo-dark.svg
│ │ │ ├── logo-light.svg
│ │ │ └── welcome.tsx
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ ├── public
│ │ │ └── favicon.ico
│ │ ├── react-router.config.ts
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── vite-project
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── README.md
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ └── test.tsx
│ │ ├── index.css
│ │ ├── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ ├── lingo-dot-dev.tsx
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── Dockerfile
├── i18n.json
├── i18n.lock
├── integrations
│ └── directus
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── docker-compose.yml
│ ├── Dockerfile
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── api.ts
│ │ ├── app.ts
│ │ └── index.spec.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── ISSUE_TEMPLATE.md
├── legacy
│ ├── cli
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ └── readme.md
│ └── sdk
│ ├── CHANGELOG.md
│ ├── index.d.ts
│ ├── index.js
│ ├── package.json
│ └── README.md
├── LICENSE.md
├── mcp.md
├── package.json
├── packages
│ ├── cli
│ │ ├── assets
│ │ │ ├── failure.mp3
│ │ │ └── success.mp3
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── demo
│ │ │ ├── android
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── csv
│ │ │ │ ├── example.csv
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── demo.spec.ts
│ │ │ ├── ejs
│ │ │ │ ├── en
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── es
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── flutter
│ │ │ │ ├── en
│ │ │ │ │ └── example.arb
│ │ │ │ ├── es
│ │ │ │ │ └── example.arb
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── html
│ │ │ │ ├── en
│ │ │ │ │ └── example.html
│ │ │ │ ├── es
│ │ │ │ │ └── example.html
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json
│ │ │ │ ├── en
│ │ │ │ │ └── example.json
│ │ │ │ ├── es
│ │ │ │ │ └── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json-dictionary
│ │ │ │ ├── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json5
│ │ │ │ ├── en
│ │ │ │ │ └── example.json5
│ │ │ │ ├── es
│ │ │ │ │ └── example.json5
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── jsonc
│ │ │ │ ├── en
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── es
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── i18n.json
│ │ │ │ ├── i18n.lock
│ │ │ │ └── ru
│ │ │ │ └── example.jsonc
│ │ │ ├── markdoc
│ │ │ │ ├── en
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── es
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── markdown
│ │ │ │ ├── en
│ │ │ │ │ └── example.md
│ │ │ │ ├── es
│ │ │ │ │ └── example.md
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── mdx
│ │ │ │ ├── en
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── es
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── php
│ │ │ │ ├── en
│ │ │ │ │ └── example.php
│ │ │ │ ├── es
│ │ │ │ │ └── example.php
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── po
│ │ │ │ ├── en
│ │ │ │ │ └── example.po
│ │ │ │ ├── es
│ │ │ │ │ └── example.po
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── properties
│ │ │ │ ├── en
│ │ │ │ │ └── example.properties
│ │ │ │ ├── es
│ │ │ │ │ └── example.properties
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── run_i18n.sh
│ │ │ ├── srt
│ │ │ │ ├── en
│ │ │ │ │ └── example.srt
│ │ │ │ ├── es
│ │ │ │ │ └── example.srt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── txt
│ │ │ │ ├── en
│ │ │ │ │ └── example.txt
│ │ │ │ ├── es
│ │ │ │ │ └── example.txt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── typescript
│ │ │ │ ├── en
│ │ │ │ │ └── example.ts
│ │ │ │ ├── es
│ │ │ │ │ └── example.ts
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vtt
│ │ │ │ ├── en
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── es
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vue-json
│ │ │ │ ├── example.vue
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-strings
│ │ │ │ ├── en
│ │ │ │ │ └── example.strings
│ │ │ │ ├── es
│ │ │ │ │ └── example.strings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-stringsdict
│ │ │ │ ├── en
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── es
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings-v2
│ │ │ │ ├── complex-example.xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xliff
│ │ │ │ ├── en
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ └── example-v2.xliff
│ │ │ │ ├── es
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ ├── example-v2.xliff
│ │ │ │ │ └── example.xliff
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xml
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── yaml
│ │ │ │ ├── en
│ │ │ │ │ └── example.yml
│ │ │ │ ├── es
│ │ │ │ │ └── example.yml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ └── yaml-root-key
│ │ │ ├── en
│ │ │ │ └── example.yml
│ │ │ ├── es
│ │ │ │ └── example.yml
│ │ │ ├── i18n.json
│ │ │ └── i18n.lock
│ │ ├── i18n.json
│ │ ├── i18n.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── cli
│ │ │ │ ├── cmd
│ │ │ │ │ ├── auth.ts
│ │ │ │ │ ├── ci
│ │ │ │ │ │ ├── flows
│ │ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ │ ├── in-branch.ts
│ │ │ │ │ │ │ └── pull-request.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── platforms
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── bitbucket.ts
│ │ │ │ │ │ ├── github.ts
│ │ │ │ │ │ ├── gitlab.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── cleanup.ts
│ │ │ │ │ ├── config
│ │ │ │ │ │ ├── get.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── set.ts
│ │ │ │ │ │ └── unset.ts
│ │ │ │ │ ├── i18n.ts
│ │ │ │ │ ├── init.ts
│ │ │ │ │ ├── lockfile.ts
│ │ │ │ │ ├── login.ts
│ │ │ │ │ ├── logout.ts
│ │ │ │ │ ├── may-the-fourth.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── purge.ts
│ │ │ │ │ ├── run
│ │ │ │ │ │ ├── _const.ts
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── execute.spec.ts
│ │ │ │ │ │ ├── execute.ts
│ │ │ │ │ │ ├── frozen.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── plan.ts
│ │ │ │ │ │ ├── setup.ts
│ │ │ │ │ │ └── watch.ts
│ │ │ │ │ ├── show
│ │ │ │ │ │ ├── _shared-key-command.ts
│ │ │ │ │ │ ├── config.ts
│ │ │ │ │ │ ├── files.ts
│ │ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── locale.ts
│ │ │ │ │ │ └── locked-keys.ts
│ │ │ │ │ └── status.ts
│ │ │ │ ├── constants.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loaders
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── _utils.ts
│ │ │ │ │ ├── android.spec.ts
│ │ │ │ │ ├── android.ts
│ │ │ │ │ ├── csv.spec.ts
│ │ │ │ │ ├── csv.ts
│ │ │ │ │ ├── dato
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── api.ts
│ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ ├── filter.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── ejs.spec.ts
│ │ │ │ │ ├── ejs.ts
│ │ │ │ │ ├── ensure-key-order.spec.ts
│ │ │ │ │ ├── ensure-key-order.ts
│ │ │ │ │ ├── flat.spec.ts
│ │ │ │ │ ├── flat.ts
│ │ │ │ │ ├── flutter.spec.ts
│ │ │ │ │ ├── flutter.ts
│ │ │ │ │ ├── formatters
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── biome.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── prettier.ts
│ │ │ │ │ ├── html.ts
│ │ │ │ │ ├── icu-safety.spec.ts
│ │ │ │ │ ├── ignored-keys-buckets.spec.ts
│ │ │ │ │ ├── ignored-keys.spec.ts
│ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── inject-locale.spec.ts
│ │ │ │ │ ├── inject-locale.ts
│ │ │ │ │ ├── json-dictionary.spec.ts
│ │ │ │ │ ├── json-dictionary.ts
│ │ │ │ │ ├── json-sorting.test.ts
│ │ │ │ │ ├── json-sorting.ts
│ │ │ │ │ ├── json.ts
│ │ │ │ │ ├── json5.spec.ts
│ │ │ │ │ ├── json5.ts
│ │ │ │ │ ├── jsonc.spec.ts
│ │ │ │ │ ├── jsonc.ts
│ │ │ │ │ ├── locked-keys.spec.ts
│ │ │ │ │ ├── locked-keys.ts
│ │ │ │ │ ├── locked-patterns.spec.ts
│ │ │ │ │ ├── locked-patterns.ts
│ │ │ │ │ ├── markdoc.spec.ts
│ │ │ │ │ ├── markdoc.ts
│ │ │ │ │ ├── markdown.ts
│ │ │ │ │ ├── mdx.spec.ts
│ │ │ │ │ ├── mdx.ts
│ │ │ │ │ ├── mdx2
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── code-placeholder.spec.ts
│ │ │ │ │ │ ├── code-placeholder.ts
│ │ │ │ │ │ ├── frontmatter-split.spec.ts
│ │ │ │ │ │ ├── frontmatter-split.ts
│ │ │ │ │ │ ├── localizable-document.spec.ts
│ │ │ │ │ │ ├── localizable-document.ts
│ │ │ │ │ │ ├── section-split.spec.ts
│ │ │ │ │ │ ├── section-split.ts
│ │ │ │ │ │ └── sections-split-2.ts
│ │ │ │ │ ├── passthrough.ts
│ │ │ │ │ ├── php.ts
│ │ │ │ │ ├── plutil-json-loader.ts
│ │ │ │ │ ├── po
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── properties.ts
│ │ │ │ │ ├── root-key.ts
│ │ │ │ │ ├── srt.ts
│ │ │ │ │ ├── sync.ts
│ │ │ │ │ ├── text-file.ts
│ │ │ │ │ ├── txt.ts
│ │ │ │ │ ├── typescript
│ │ │ │ │ │ ├── cjs-interop.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── unlocalizable.spec.ts
│ │ │ │ │ ├── unlocalizable.ts
│ │ │ │ │ ├── variable
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── vtt.ts
│ │ │ │ │ ├── vue-json.ts
│ │ │ │ │ ├── xcode-strings
│ │ │ │ │ │ ├── escape.ts
│ │ │ │ │ │ ├── parser.ts
│ │ │ │ │ │ ├── tokenizer.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── xcode-strings.spec.ts
│ │ │ │ │ ├── xcode-strings.ts
│ │ │ │ │ ├── xcode-stringsdict.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.ts
│ │ │ │ │ ├── xcode-xcstrings-lock-compatibility.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-v2-loader.ts
│ │ │ │ │ ├── xcode-xcstrings.spec.ts
│ │ │ │ │ ├── xcode-xcstrings.ts
│ │ │ │ │ ├── xliff.spec.ts
│ │ │ │ │ ├── xliff.ts
│ │ │ │ │ ├── xml.ts
│ │ │ │ │ └── yaml.ts
│ │ │ │ ├── localizer
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── explicit.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingodotdev.ts
│ │ │ │ ├── processor
│ │ │ │ │ ├── _base.ts
│ │ │ │ │ ├── basic.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingo.ts
│ │ │ │ └── utils
│ │ │ │ ├── auth.ts
│ │ │ │ ├── buckets.spec.ts
│ │ │ │ ├── buckets.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── cloudflare-status.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── delta.spec.ts
│ │ │ │ ├── delta.ts
│ │ │ │ ├── ensure-patterns.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── exec.spec.ts
│ │ │ │ ├── exec.ts
│ │ │ │ ├── exit-gracefully.spec.ts
│ │ │ │ ├── exit-gracefully.ts
│ │ │ │ ├── exp-backoff.ts
│ │ │ │ ├── find-locale-paths.spec.ts
│ │ │ │ ├── find-locale-paths.ts
│ │ │ │ ├── fs.ts
│ │ │ │ ├── init-ci-cd.ts
│ │ │ │ ├── key-matching.spec.ts
│ │ │ │ ├── key-matching.ts
│ │ │ │ ├── lockfile.ts
│ │ │ │ ├── md5.ts
│ │ │ │ ├── observability.ts
│ │ │ │ ├── plutil-formatter.spec.ts
│ │ │ │ ├── plutil-formatter.ts
│ │ │ │ ├── settings.ts
│ │ │ │ ├── ui.ts
│ │ │ │ └── update-gitignore.ts
│ │ │ ├── compiler
│ │ │ │ └── index.ts
│ │ │ ├── locale-codes
│ │ │ │ └── index.ts
│ │ │ ├── react
│ │ │ │ ├── client.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── react-router.ts
│ │ │ │ └── rsc.ts
│ │ │ ├── sdk
│ │ │ │ └── index.ts
│ │ │ └── spec
│ │ │ └── index.ts
│ │ ├── tests
│ │ │ └── mock-storage.ts
│ │ ├── troubleshooting.md
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ ├── tsup.config.ts
│ │ ├── types
│ │ │ ├── vtt.d.ts
│ │ │ └── xliff.d.ts
│ │ ├── vitest.config.ts
│ │ └── WATCH_MODE.md
│ ├── compiler
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── _base.ts
│ │ │ ├── _const.ts
│ │ │ ├── _loader-utils.spec.ts
│ │ │ ├── _loader-utils.ts
│ │ │ ├── _utils.spec.ts
│ │ │ ├── _utils.ts
│ │ │ ├── client-dictionary-loader.ts
│ │ │ ├── i18n-directive.spec.ts
│ │ │ ├── i18n-directive.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── jsx-attribute-flag.spec.ts
│ │ │ ├── jsx-attribute-flag.ts
│ │ │ ├── jsx-attribute-scope-inject.spec.ts
│ │ │ ├── jsx-attribute-scope-inject.ts
│ │ │ ├── jsx-attribute-scopes-export.spec.ts
│ │ │ ├── jsx-attribute-scopes-export.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-fragment.spec.ts
│ │ │ ├── jsx-fragment.ts
│ │ │ ├── jsx-html-lang.spec.ts
│ │ │ ├── jsx-html-lang.ts
│ │ │ ├── jsx-provider.spec.ts
│ │ │ ├── jsx-provider.ts
│ │ │ ├── jsx-remove-attributes.spec.ts
│ │ │ ├── jsx-remove-attributes.ts
│ │ │ ├── jsx-root-flag.spec.ts
│ │ │ ├── jsx-root-flag.ts
│ │ │ ├── jsx-scope-flag.spec.ts
│ │ │ ├── jsx-scope-flag.ts
│ │ │ ├── jsx-scope-inject.spec.ts
│ │ │ ├── jsx-scope-inject.ts
│ │ │ ├── jsx-scopes-export.spec.ts
│ │ │ ├── jsx-scopes-export.ts
│ │ │ ├── lib
│ │ │ │ └── lcp
│ │ │ │ ├── api
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prompt.spec.ts
│ │ │ │ │ ├── prompt.ts
│ │ │ │ │ ├── provider-details.spec.ts
│ │ │ │ │ ├── provider-details.ts
│ │ │ │ │ ├── shots.ts
│ │ │ │ │ ├── xml2obj.spec.ts
│ │ │ │ │ └── xml2obj.ts
│ │ │ │ ├── api.spec.ts
│ │ │ │ ├── cache.spec.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── server.spec.ts
│ │ │ │ └── server.ts
│ │ │ ├── lingo-turbopack-loader.ts
│ │ │ ├── react-router-dictionary-loader.ts
│ │ │ ├── rsc-dictionary-loader.ts
│ │ │ └── utils
│ │ │ ├── ast-key.spec.ts
│ │ │ ├── ast-key.ts
│ │ │ ├── create-locale-import-map.spec.ts
│ │ │ ├── create-locale-import-map.ts
│ │ │ ├── env.spec.ts
│ │ │ ├── env.ts
│ │ │ ├── hash.spec.ts
│ │ │ ├── hash.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── invokations.spec.ts
│ │ │ ├── invokations.ts
│ │ │ ├── jsx-attribute-scope.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-content-whitespace.spec.ts
│ │ │ ├── jsx-content.spec.ts
│ │ │ ├── jsx-content.ts
│ │ │ ├── jsx-element.spec.ts
│ │ │ ├── jsx-element.ts
│ │ │ ├── jsx-expressions.test.ts
│ │ │ ├── jsx-expressions.ts
│ │ │ ├── jsx-functions.spec.ts
│ │ │ ├── jsx-functions.ts
│ │ │ ├── jsx-scope.spec.ts
│ │ │ ├── jsx-scope.ts
│ │ │ ├── jsx-variables.spec.ts
│ │ │ ├── jsx-variables.ts
│ │ │ ├── llm-api-key.ts
│ │ │ ├── llm-api-keys.spec.ts
│ │ │ ├── locales.spec.ts
│ │ │ ├── locales.ts
│ │ │ ├── module-params.spec.ts
│ │ │ ├── module-params.ts
│ │ │ ├── observability.spec.ts
│ │ │ ├── observability.ts
│ │ │ ├── rc.spec.ts
│ │ │ └── rc.ts
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ └── vitest.config.ts
│ ├── locales
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── names
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── integration.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── parser.spec.ts
│ │ │ ├── parser.ts
│ │ │ ├── types.ts
│ │ │ ├── validation.spec.ts
│ │ │ └── validation.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── react
│ │ ├── build.config.ts
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── context.spec.tsx
│ │ │ │ ├── context.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── locale-switcher.spec.tsx
│ │ │ │ ├── locale-switcher.tsx
│ │ │ │ ├── locale.spec.ts
│ │ │ │ ├── locale.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── core
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── const.ts
│ │ │ │ ├── get-dictionary.spec.ts
│ │ │ │ ├── get-dictionary.ts
│ │ │ │ └── index.ts
│ │ │ ├── react-router
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── rsc
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ └── test
│ │ │ └── setup.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── sdk
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── abort-controller.specs.ts
│ │ │ ├── index.spec.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ └── tsup.config.ts
│ └── spec
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── config.spec.ts
│ │ ├── config.ts
│ │ ├── formats.ts
│ │ ├── index.spec.ts
│ │ ├── index.ts
│ │ ├── json-schema.ts
│ │ ├── locales.spec.ts
│ │ └── locales.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── readme
│ ├── ar.md
│ ├── bn.md
│ ├── de.md
│ ├── en.md
│ ├── es.md
│ ├── fa.md
│ ├── fr.md
│ ├── he.md
│ ├── hi.md
│ ├── it.md
│ ├── ja.md
│ ├── ko.md
│ ├── pl.md
│ ├── pt-BR.md
│ ├── ru.md
│ ├── tr.md
│ ├── uk-UA.md
│ └── zh-Hans.md
├── readme.md
├── scripts
│ ├── docs
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── generate-cli-docs.ts
│ │ │ ├── generate-config-docs.ts
│ │ │ ├── json-schema
│ │ │ │ ├── markdown-renderer.test.ts
│ │ │ │ ├── markdown-renderer.ts
│ │ │ │ ├── parser.test.ts
│ │ │ │ ├── parser.ts
│ │ │ │ └── types.ts
│ │ │ ├── utils.test.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ └── packagist-publish.php
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/packages/spec/src/locales.ts:
--------------------------------------------------------------------------------
```typescript
import Z from "zod";
const localeMap = {
// Urdu (Pakistan)
ur: ["ur-PK"],
// Vietnamese (Vietnam)
vi: ["vi-VN"],
// Turkish (Turkey)
tr: ["tr-TR"],
// Tamil (India)
ta: [
"ta-IN", // India
"ta-SG", // Singapore
],
// Serbian
sr: [
"sr-RS", // Serbian (Latin)
"sr-Latn-RS", // Serbian (Latin)
"sr-Cyrl-RS", // Serbian (Cyrillic)
],
// Hungarian (Hungary)
hu: ["hu-HU"],
// Hebrew (Israel)
he: ["he-IL"],
// Estonian (Estonia)
et: ["et-EE"],
// Greek
el: [
"el-GR", // Greece
"el-CY", // Cyprus
],
// Danish (Denmark)
da: ["da-DK"],
// Azerbaijani (Azerbaijan)
az: ["az-AZ"],
// Thai (Thailand)
th: ["th-TH"],
// Swedish (Sweden)
sv: ["sv-SE"],
// English
en: [
"en-US", // United States
"en-GB", // United Kingdom
"en-AU", // Australia
"en-CA", // Canada
"en-SG", // Singapore
"en-IE", // Ireland
],
// Spanish
es: [
"es-ES", // Spain
"es-419", // Latin America
"es-MX", // Mexico
"es-AR", // Argentina
],
// French
fr: [
"fr-FR", // France
"fr-CA", // Canada
"fr-BE", // Belgium
"fr-LU", // Luxembourg
],
// Catalan (Spain)
ca: ["ca-ES"],
// Japanese (Japan)
ja: ["ja-JP"],
// Kazakh (Kazakhstan)
kk: ["kk-KZ"],
// German
de: [
"de-DE", // Germany
"de-AT", // Austria
"de-CH", // Switzerland
],
// Portuguese
pt: [
"pt-PT", // Portugal
"pt-BR", // Brazil
],
// Italian
it: [
"it-IT", // Italy
"it-CH", // Switzerland
],
// Russian
ru: [
"ru-RU", // Russia
"ru-BY", // Belarus
],
// Ukrainian (Ukraine)
uk: ["uk-UA"],
// Belarusian (Belarus)
be: ["be-BY"],
// Hindi (India)
hi: ["hi-IN"],
// Chinese
zh: [
"zh-CN", // Simplified Chinese (China)
"zh-TW", // Traditional Chinese (Taiwan)
"zh-HK", // Traditional Chinese (Hong Kong)
"zh-SG", // Simplified Chinese (Singapore)
"zh-Hans", // Simplified Chinese
"zh-Hant", // Traditional Chinese
"zh-Hant-HK", // Traditional Chinese (Hong Kong)
"zh-Hant-TW", // Traditional Chinese (Taiwan)
"zh-Hant-CN", // Traditional Chinese (China)
"zh-Hans-HK", // Simplified Chinese (Hong Kong)
"zh-Hans-TW", // Simplified Chinese (China)
"zh-Hans-CN", // Simplified Chinese (China)
],
// Korean (South Korea)
ko: ["ko-KR"],
// Arabic
ar: [
"ar-EG", // Egypt
"ar-SA", // Saudi Arabia
"ar-AE", // United Arab Emirates
"ar-MA", // Morocco
],
// Bulgarian (Bulgaria)
bg: ["bg-BG"],
// Czech (Czech Republic)
cs: ["cs-CZ"],
// Welsh (Wales)
cy: ["cy-GB"],
// Dutch
nl: [
"nl-NL", // Netherlands
"nl-BE", // Belgium
],
// Polish (Poland)
pl: ["pl-PL"],
// Indonesian (Indonesia)
id: ["id-ID"],
is: ["is-IS"],
// Malay (Malaysia)
ms: ["ms-MY"],
// Finnish (Finland)
fi: ["fi-FI"],
// Basque (Spain)
eu: ["eu-ES"],
// Croatian (Croatia)
hr: ["hr-HR"],
// Hebrew (Israel) - alternative code
iw: ["iw-IL"],
// Khmer (Cambodia)
km: ["km-KH"],
// Latvian (Latvia)
lv: ["lv-LV"],
// Lithuanian (Lithuania)
lt: ["lt-LT"],
// Norwegian
no: [
"no-NO", // Norway (legacy)
"nb-NO", // Norwegian Bokmål
"nn-NO", // Norwegian Nynorsk
],
// Romanian (Romania)
ro: ["ro-RO"],
// Slovak (Slovakia)
sk: ["sk-SK"],
// Swahili
sw: [
"sw-TZ", // Tanzania
"sw-KE", // Kenya
"sw-UG", // Uganda
"sw-CD", // Democratic Republic of Congo
"sw-RW", // Rwanda
],
// Persian (Iran)
fa: ["fa-IR"],
// Filipino (Philippines)
fil: ["fil-PH"],
// Punjabi
pa: [
"pa-IN", // India
"pa-PK", // Pakistan
],
// Bengali
bn: [
"bn-BD", // Bangladesh
"bn-IN", // India
],
// Irish (Ireland)
ga: ["ga-IE"],
// Galician (Spain)
gl: ["gl-ES"],
// Maltese (Malta)
mt: ["mt-MT"],
// Slovenian (Slovenia)
sl: ["sl-SI"],
// Albanian (Albania)
sq: ["sq-AL"],
// Bavarian (Germany)
bar: ["bar-DE"],
// Neapolitan (Italy)
nap: ["nap-IT"],
// Afrikaans (South Africa)
af: ["af-ZA"],
// Uzbek (Latin)
uz: ["uz-Latn"],
// Somali (Somalia)
so: ["so-SO"],
// Tigrinya (Ethiopia)
ti: ["ti-ET"],
// Standard Moroccan Tamazight (Morocco)
zgh: ["zgh-MA"],
// Tagalog (Philippines)
tl: ["tl-PH"],
// Telugu (India)
te: ["te-IN"],
// Kinyarwanda (Rwanda)
rw: ["rw-RW"],
// Georgian (Georgia)
ka: ["ka-GE"],
// Malayalam (India)
ml: ["ml-IN"],
// Armenian (Armenia)
hy: ["hy-AM"],
// Macedonian (Macedonia)
mk: ["mk-MK"],
} as const;
export type LocaleCodeShort = keyof typeof localeMap;
export type LocaleCodeFull = (typeof localeMap)[LocaleCodeShort][number];
export type LocaleCode = LocaleCodeShort | LocaleCodeFull;
export type LocaleDelimiter = "-" | "_" | null;
export const localeCodesShort = Object.keys(localeMap) as LocaleCodeShort[];
export const localeCodesFull = Object.values(
localeMap,
).flat() as LocaleCodeFull[];
export const localeCodesFullUnderscore = localeCodesFull.map((value) =>
value.replace("-", "_"),
);
export const localeCodesFullExplicitRegion = localeCodesFull.map((value) => {
const chunks = value.split("-");
const result = [chunks[0], "-r", chunks.slice(1).join("-")].join("");
return result;
});
export const localeCodes = [
...localeCodesShort,
...localeCodesFull,
...localeCodesFullUnderscore,
...localeCodesFullExplicitRegion,
] as LocaleCode[];
export const localeCodeSchema = Z.string().refine(
(value) => localeCodes.includes(value as any),
{
message: "Invalid locale code",
},
);
/**
* Resolves a locale code to its full locale representation.
*
* If the provided locale code is already a full locale code, it returns as is.
* If the provided locale code is a short locale code, it returns the first corresponding full locale.
* If the locale code is not found, it throws an error.
*
* @param {localeCodes} value - The locale code to resolve (either short or full)
* @return {LocaleCodeFull} The resolved full locale code
* @throws {Error} If the provided locale code is invalid.
*/
export const resolveLocaleCode = (value: string): LocaleCodeFull => {
const existingFullLocaleCode = Object.values(localeMap)
.flat()
.includes(value as any);
if (existingFullLocaleCode) {
return value as LocaleCodeFull;
}
const existingShortLocaleCode = Object.keys(localeMap).includes(value);
if (existingShortLocaleCode) {
const correspondingFullLocales = localeMap[value as LocaleCodeShort];
const fallbackFullLocale = correspondingFullLocales[0];
return fallbackFullLocale;
}
throw new Error(`Invalid locale code: ${value}`);
};
/**
* Determines the delimiter used in a locale code
*
* @param {string} locale - the locale string (e.g.,"en_US","en-GB")
* @return { string | null} - The delimiter ("_" or "-") if found, otherwise `null`.
*/
export const getLocaleCodeDelimiter = (locale: string): LocaleDelimiter => {
if (locale.includes("_")) {
return "_";
} else if (locale.includes("-")) {
return "-";
} else {
return null;
}
};
/**
* Replaces the delimiter in a locale string with the specified delimiter.
*
* @param {string}locale - The locale string (e.g.,"en_US", "en-GB").
* @param {"-" | "_" | null} [delimiter] - The new delimiter to replace the existing one.
* @returns {string} The locale string with the replaced delimiter, or the original locale if no delimiter is provided.
*/
export const resolveOverriddenLocale = (
locale: string,
delimiter?: LocaleDelimiter,
): string => {
if (!delimiter) {
return locale;
}
const currentDelimiter = getLocaleCodeDelimiter(locale);
if (!currentDelimiter) {
return locale;
}
return locale.replace(currentDelimiter, delimiter);
};
/**
* Normalizes a locale string by replacing underscores with hyphens
* and removing the "r" in certain regional codes (e.g., "fr-rCA" → "fr-CA")
*
* @param {string} locale - The locale string (e.g.,"en_US", "en-GB").
* @return {string} The normalized locale string.
*/
export function normalizeLocale(locale: string): string {
return locale.replaceAll("_", "-").replace(/([a-z]{2,3}-)r/, "$1");
}
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/typescript/index.spec.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, expect, it } from "vitest";
import createTypescriptLoader from "./index";
import dedent from "dedent";
describe("typescript loader", () => {
it("should extract string literals from default export object", async () => {
const input = `
export default {
greeting: "Hello, world!",
farewell: "Goodbye!",
number: 42,
boolean: true
};
`;
const loader = createTypescriptLoader().setDefaultLocale("en");
const result = await loader.pull("en", input);
expect(result).toEqual({
greeting: "Hello, world!",
farewell: "Goodbye!",
});
});
it("should extract string literals from exported variable", async () => {
const input = `
const messages = {
welcome: "Welcome to our app",
error: "Something went wrong",
count: 5
};
export default messages;
`;
const loader = createTypescriptLoader().setDefaultLocale("en");
const result = await loader.pull("en", input);
expect(result).toEqual({
welcome: "Welcome to our app",
error: "Something went wrong",
});
});
it("should handle empty or invalid input", async () => {
const loader = createTypescriptLoader().setDefaultLocale("en");
let result = await loader.pull("en", "");
expect(result).toEqual({});
result = await loader.pull("en", "const x = 5;");
expect(result).toEqual({});
});
it("should update string literals in default export object", async () => {
const input = `
export default {
greeting: "Hello, world!",
farewell: "Goodbye!",
number: 42
};
`;
const loader = createTypescriptLoader().setDefaultLocale("en");
await loader.pull("en", input);
const data = {
greeting: "Hola, mundo!",
farewell: "Adiós!",
};
const result = await loader.push("es", data);
expect(result).toBe(dedent`
export default {
greeting: "Hola, mundo!",
farewell: "Adiós!",
number: 42
};
`);
});
it("should extract string literals from nested objects", async () => {
const input = `
export default {
messages: {
welcome: "Welcome to our app",
error: "Something went wrong",
count: 5
},
settings: {
theme: {
name: "Dark Mode",
colors: {
primary: "blue",
secondary: "gray"
}
}
}
};
`;
const loader = createTypescriptLoader().setDefaultLocale("en");
const result = await loader.pull("en", input);
expect(result).toEqual({
messages: {
welcome: "Welcome to our app",
error: "Something went wrong",
},
settings: {
theme: {
name: "Dark Mode",
colors: {
primary: "blue",
secondary: "gray",
},
},
},
});
});
it("should extract string literals from arrays", async () => {
const input = `
export default {
greetings: ["Hello", "Hi", "Hey"],
categories: [
{ name: "Electronics", description: "Electronic devices" },
{ name: "Books", description: "Reading materials" }
]
};
`;
const loader = createTypescriptLoader().setDefaultLocale("en");
const result = await loader.pull("en", input);
expect(result).toEqual({
greetings: ["Hello", "Hi", "Hey"],
categories: [
{ name: "Electronics", description: "Electronic devices" },
{ name: "Books", description: "Reading materials" },
],
});
});
it("should update string literals in nested objects", async () => {
const input = dedent`
export default {
messages: {
welcome: "Welcome to our app",
error: "Something went wrong"
},
settings: {
theme: {
name: "Dark Mode",
colors: {
primary: "blue"
}
}
}
};
`;
const loader = createTypescriptLoader().setDefaultLocale("en");
let data = await loader.pull("en", input);
data.settings.theme.colors.primary = "red";
const result = await loader.push("es", data);
expect(result).toBe(dedent`
export default {
messages: {
welcome: "Welcome to our app",
error: "Something went wrong"
},
settings: {
theme: {
name: "Dark Mode",
colors: {
primary: "red"
}
}
}
};
`);
});
it("should update string literals in arrays", async () => {
const input = `
export default {
greetings: ["Hello", "Hi", "Hey"],
};
`;
const loader = createTypescriptLoader().setDefaultLocale("en");
let data = await loader.pull("en", input);
data.greetings[0] = "Hola";
data.greetings[1] = "Hola";
data.greetings[2] = "Oye";
const result = await loader.push("es", data);
expect(result).toBe(dedent`
export default {
greetings: ["Hola", "Hola", "Oye"]
};
`);
});
it("should handle mixed nested structures", async () => {
const input = `
export default {
app: {
name: "My App",
version: "1.0.0",
features: ["Login", "Dashboard", "Settings"],
pages: [
{
title: "Home",
sections: [
{ heading: "Welcome", content: "Welcome to our app" },
{ heading: "Features", content: "Check out our features" }
]
},
{
title: "About",
sections: [
{ heading: "Our Story", content: "We started in 2020" }
]
}
]
}
};
`;
const loader = createTypescriptLoader().setDefaultLocale("en");
const result = await loader.pull("en", input);
expect(result).toEqual({
app: {
name: "My App",
version: "1.0.0",
features: ["Login", "Dashboard", "Settings"],
pages: [
{
title: "Home",
sections: [
{ heading: "Welcome", content: "Welcome to our app" },
{ heading: "Features", content: "Check out our features" },
],
},
{
title: "About",
sections: [{ heading: "Our Story", content: "We started in 2020" }],
},
],
},
});
});
it("should extract string literals when default export has 'as const'", async () => {
const input = `
export default {
greeting: "Hello, world!",
farewell: "Goodbye!"
} as const;
`;
const loader = createTypescriptLoader().setDefaultLocale("en");
const result = await loader.pull("en", input);
expect(result).toEqual({
greeting: "Hello, world!",
farewell: "Goodbye!",
});
});
it("should extract and update string literals including multiline template literals, URLs, and numeric keys", async () => {
const input = dedent`
export default {
multilineContent: \`Multiline test
Super content
Includes also "test"\`,
testUrl: 'https://someurl.com',
6: '6. Class',
9: '9. Class',
};
`;
const loader = createTypescriptLoader().setDefaultLocale("en");
// Pull phase – ensure the loader extracts all expected strings
const pulled = await loader.pull("en", input);
expect(pulled).toEqual({
multilineContent: dedent`
Multiline test
Super content
Includes also "test"`,
testUrl: "https://someurl.com",
6: "6. Class",
9: "9. Class",
});
// Push phase – modify some values and ensure they are written back
const updatedData = {
...pulled,
multilineContent: dedent`
Prueba multilínea
Contenido superior
Incluye también "prueba"`,
testUrl: "https://algunaurl.com",
6: "6. Clase",
9: "9. Clase",
} as any;
const result = await loader.push("es", updatedData);
expect(result).toBe(
`
export default {
multilineContent: \`Prueba multilínea
Contenido superior
Incluye también "prueba"\`,
testUrl: "https://algunaurl.com",
6: "6. Clase",
9: "9. Clase"
};
`.trim(),
);
});
// TODO
});
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/po/index.spec.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from "vitest";
import createPoLoader, { PoLoaderParams } from "./index";
describe("createPoDataLoader", () => {
it("pull the correct data", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgid "Hello world"
msgstr ""
`.trim();
const data = await loader.pull("en", input);
expect(data).toEqual({
"Hello world": {
singular: "Hello world",
plural: null,
},
});
});
it("pull entries with context", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgctxt "role of the user in the workspace"
msgid "Role"
msgstr ""
`.trim();
const data = await loader.pull("en", input);
expect(data).toEqual({
Role: {
singular: "Role",
plural: null,
},
});
});
it("push entries with context preserving the original context value", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgctxt "role of the user in the workspace"
msgid "Role"
msgstr ""
#: hello.py:2
msgctxt "role of the user in the workspace"
msgid "Admin"
msgstr ""
`.trim();
const update = {
Admin: {
singular: "[upd] Admin",
plural: null,
},
};
const updatedInput = `
#: hello.py:1
msgctxt "role of the user in the workspace"
msgid "Role"
msgstr ""
#: hello.py:2
msgctxt "role of the user in the workspace"
msgid "Admin"
msgstr "[upd] Admin"
`.trim();
await loader.pull("en", input);
const result = await loader.push("en-upd", update);
expect(result).toEqual(updatedInput);
});
it("avoid pulling metadata", async () => {
const loader = createLoader();
const input = `
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-22 13:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <[email protected]>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: hello.py:1
msgid "Hello world"
msgstr ""
`.trim();
const data = await loader.pull("en", input);
expect(data).toEqual({
"Hello world": {
singular: "Hello world",
plural: null,
},
});
});
it("update data when pushed", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgid "Hello world"
msgstr ""
`.trim();
const updatedData = {
"Hello world": {
singular: "Hello world!",
plural: null,
},
};
const updatedInput = `
#: hello.py:1
msgid "Hello world"
msgstr "Hello world!"
`.trim();
await loader.pull("en", input);
const result = await loader.push("en", updatedData);
expect(result).toEqual(updatedInput);
});
it("avoid pushing default metadata if it's missing", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgid "Hello world"
msgstr ""
`.trim();
const updatedInput = `
#: hello.py:1
msgid "Hello world"
msgstr ""
`.trim();
await loader.pull("en", input);
const result = await loader.push("en", {});
expect(result).toEqual(updatedInput);
});
it("split long lines when told to do so", async () => {
const loader = createLoader({ multiline: true });
const input = `
#: hello.py:1
msgid ""
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod "
"tempor incididunt ut labore et dolore magna aliqua."
msgstr ""
`.trim();
await loader.pull("en", input);
const result = await loader.push("en", {});
expect(result).toEqual(input);
});
it("dont't split long lines by default", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgid ""
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod "
"tempor incididunt ut labore et dolore magna aliqua."
msgstr ""
`.trim();
const updatedInput = `
#: hello.py:1
msgid "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
msgstr ""
`.trim();
await loader.pull("en", input);
const result = await loader.push("en", {});
expect(result).toEqual(updatedInput);
});
it("pull entries with context", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgctxt "role of the user in the workspace"
msgid "Role"
msgstr ""
`.trim();
const data = await loader.pull("en", input);
expect(data).toEqual({
Role: {
singular: "Role",
plural: null,
},
});
});
it("push entries with context preserving the original context value", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgctxt "role of the user in the workspace"
msgid "Role"
msgstr ""
`.trim();
const payload = {
Role: {
singular: "[upd] Role",
plural: null,
},
};
const updatedInput = `
#: hello.py:1
msgctxt "role of the user in the workspace"
msgid "Role"
msgstr "[upd] Role"
`.trim();
await loader.pull("en", input);
const result = await loader.push("en-upd", payload);
expect(result).toEqual(updatedInput);
});
it("fallbacks to msgid when single msgstr value is empty", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgid "File"
msgstr ""
`.trim();
const data = await loader.pull("en", input);
expect(data).toEqual({
File: {
singular: "File",
plural: null,
},
});
});
it("fallbacks to msgid when msgstr values are empty", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgid "File"
msgstr[0] ""
msgstr[1] ""
`.trim();
const data = await loader.pull("en", input);
expect(data).toEqual({
File: {
singular: "File",
plural: "File",
},
});
});
it("does not fallback to msgid for non-source locale when single msgstr value is empty", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgid "File"
msgstr ""
`.trim();
// First, pull default locale to satisfy loader invariants
await loader.pull("en", input);
// Pull a different locale with the same content
const data = await loader.pull("fr", input);
expect(data).toEqual({
File: {
singular: null,
plural: null,
},
});
});
it("does not fallback to msgid for non-source locale when msgstr values are empty", async () => {
const loader = createLoader();
const input = `
#: hello.py:1
msgid "File"
msgstr[0] ""
msgstr[1] ""
`.trim();
// Pull default locale first
await loader.pull("en", input);
// Pull a different locale
const data = await loader.pull("fr", input);
expect(data).toEqual({
File: {
singular: null,
plural: null,
},
});
});
it("should preserve order of comments (file and line number, translator notes)", async () => {
const loader = createLoader();
const input = `
# My animal
#, animal
#. This is an animal
#: hello.py:1
# I like animals
#| foobar
msgid "Zebra"
msgstr ""
#. This is a bird
#: hello.py:2
msgid "Parrot"
msgstr ""
#. Food
msgid "Apple"
msgstr ""
`.trim();
const data = await loader.pull("en", input);
const updatedData = {
Zebra: { singular: "[upd] Zebra", plural: null },
Parrot: { singular: "[upd] Parrot", plural: null },
Apple: { singular: "[upd] Apple", plural: null },
};
const expectedOutput = `
# My animal
#, animal
#. This is an animal
#: hello.py:1
# I like animals
#| foobar
msgid "Zebra"
msgstr "[upd] Zebra"
#. This is a bird
#: hello.py:2
msgid "Parrot"
msgstr "[upd] Parrot"
#. Food
msgid "Apple"
msgstr "[upd] Apple"
`.trim();
const result = await loader.push("en", updatedData);
expect(result).toEqual(expectedOutput);
});
});
function createLoader(params: PoLoaderParams = { multiline: false }) {
return createPoLoader(params).setDefaultLocale("en");
}
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/flat.spec.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, expect, it } from "vitest";
import { flatten } from "flat";
import createFlatLoader, {
buildDenormalizedKeysMap,
denormalizeObjectKeys,
mapDenormalizedKeys,
normalizeObjectKeys,
OBJECT_NUMERIC_KEY_PREFIX,
} from "./flat";
describe("flat loader", () => {
describe("createFlatLoader", () => {
it("loads numeric object and array and preserves state", async () => {
const loader = createFlatLoader();
loader.setDefaultLocale("en");
await loader.pull("en", {
messages: { "1": "foo", "2": "bar" },
years: ["January 13, 2025", "February 14, 2025"],
});
await loader.pull("es", {}); // run again to ensure state is preserved
const output = await loader.push("en", {
"messages/1": "foo",
"messages/2": "bar",
"years/0": "January 13, 2025",
"years/1": "February 14, 2025",
});
expect(output).toEqual({
messages: { "1": "foo", "2": "bar" },
years: ["January 13, 2025", "February 14, 2025"],
});
});
it("handles date objects correctly", async () => {
const loader = createFlatLoader();
loader.setDefaultLocale("en");
const date = new Date("2023-01-01T00:00:00Z");
await loader.pull("en", {
publishedAt: date,
metadata: { createdAt: date },
});
const output = await loader.push("en", {
publishedAt: date.toISOString(),
"metadata/createdAt": date.toISOString(),
});
expect(output).toEqual({
publishedAt: date.toISOString(),
metadata: { createdAt: date.toISOString() },
});
});
});
describe("helper functions", () => {
const inputObj = {
messages: {
"1": "a",
"2": "b",
},
};
const inputArray = {
messages: ["a", "b", "c"],
};
describe("denormalizeObjectKeys", () => {
it("should denormalize object keys", () => {
const output = denormalizeObjectKeys(inputObj);
expect(output).toEqual({
messages: {
[`${OBJECT_NUMERIC_KEY_PREFIX}1`]: "a",
[`${OBJECT_NUMERIC_KEY_PREFIX}2`]: "b",
},
});
});
it("should preserve array", () => {
const output = denormalizeObjectKeys(inputArray);
expect(output).toEqual({
messages: ["a", "b", "c"],
});
});
it("should preserve date objects", () => {
const date = new Date();
const input = { createdAt: date };
const output = denormalizeObjectKeys(input);
expect(output).toEqual({ createdAt: date });
});
});
describe("buildDenormalizedKeysMap", () => {
it("should build normalized keys map", () => {
const denormalized: Record<string, string> = flatten(
denormalizeObjectKeys(inputObj),
{ delimiter: "/" },
);
const output = buildDenormalizedKeysMap(denormalized);
expect(output).toEqual({
"messages/1": `messages/${OBJECT_NUMERIC_KEY_PREFIX}1`,
"messages/2": `messages/${OBJECT_NUMERIC_KEY_PREFIX}2`,
});
});
it("should build keys map array", () => {
const denormalized: Record<string, string> = flatten(
denormalizeObjectKeys(inputArray),
{ delimiter: "/" },
);
const output = buildDenormalizedKeysMap(denormalized);
expect(output).toEqual({
"messages/0": "messages/0",
"messages/1": "messages/1",
"messages/2": "messages/2",
});
});
});
describe("normalizeObjectKeys", () => {
it("should normalize denormalized object keys", () => {
const output = normalizeObjectKeys(denormalizeObjectKeys(inputObj));
expect(output).toEqual(inputObj);
});
it("should process array keys", () => {
const output = normalizeObjectKeys(denormalizeObjectKeys(inputArray));
expect(output).toEqual(inputArray);
});
it("should preserve date objects", () => {
const date = new Date();
const input = { createdAt: date };
const output = normalizeObjectKeys(input);
expect(output).toEqual({ createdAt: date });
});
});
describe("mapDeormalizedKeys", () => {
it("should map normalized keys", () => {
const denormalized: Record<string, string> = flatten(
denormalizeObjectKeys(inputObj),
{ delimiter: "/" },
);
const keyMap = buildDenormalizedKeysMap(denormalized);
const flattened: Record<string, string> = flatten(inputObj, {
delimiter: "/",
});
const mapped = mapDenormalizedKeys(flattened, keyMap);
expect(mapped).toEqual(denormalized);
});
it("should map array", () => {
const denormalized: Record<string, string> = flatten(
denormalizeObjectKeys(inputArray),
{ delimiter: "/" },
);
const keyMap = buildDenormalizedKeysMap(denormalized);
const flattened: Record<string, string> = flatten(inputArray, {
delimiter: "/",
});
const mapped = mapDenormalizedKeys(flattened, keyMap);
expect(mapped).toEqual(denormalized);
});
});
});
describe("pullHints", () => {
it("should flatten comments from nested structure", async () => {
const loader = createFlatLoader();
loader.setDefaultLocale("en");
const input = {
key1: { hint: "This is a comment for key1" },
key2: { hint: "This is a comment for key2" },
key3: { hint: "This is a comment for key3" },
key4: { hint: "This is a block comment for key4" },
key5: { hint: "This is a comment for key5" },
key6: {
hint: "This is a comment for key6",
key7: { hint: "This is a comment for key7" },
},
};
const comments = await loader.pullHints(input);
expect(comments).toEqual({
key1: ["This is a comment for key1"],
key2: ["This is a comment for key2"],
key3: ["This is a comment for key3"],
key4: ["This is a block comment for key4"],
key5: ["This is a comment for key5"],
"key6/key7": [
"This is a comment for key6",
"This is a comment for key7",
],
});
});
it("should handle empty input", async () => {
const loader = createFlatLoader();
loader.setDefaultLocale("en");
const comments = await loader.pullHints({});
expect(comments).toEqual({});
});
it("should handle null/undefined input", async () => {
const loader = createFlatLoader();
loader.setDefaultLocale("en");
const comments1 = await loader.pullHints(null as any);
expect(comments1).toEqual({});
const comments2 = await loader.pullHints(undefined as any);
expect(comments2).toEqual({});
});
it("should handle deeply nested structure", async () => {
const loader = createFlatLoader();
loader.setDefaultLocale("en");
const input = {
level1: {
hint: "Level 1 hint",
level2: {
hint: "Level 2 hint",
level3: {
hint: "Level 3 hint",
},
},
},
};
const comments = await loader.pullHints(input);
expect(comments).toEqual({
"level1/level2/level3": [
"Level 1 hint",
"Level 2 hint",
"Level 3 hint",
],
});
});
it("should handle objects without hints", async () => {
const loader = createFlatLoader();
loader.setDefaultLocale("en");
const input = {
key1: { hint: "Has hint" },
key2: {
key3: { hint: "Nested hint" },
},
};
const comments = await loader.pullHints(input);
expect(comments).toEqual({
key1: ["Has hint"],
"key2/key3": ["Nested hint"],
});
});
it("should handle mixed structures", async () => {
const loader = createFlatLoader();
loader.setDefaultLocale("en");
const input = {
simple: { hint: "Simple hint" },
parent: {
hint: "Parent hint",
child1: { hint: "Child 1 hint" },
child2: {
grandchild: { hint: "Grandchild hint" },
},
},
};
const comments = await loader.pullHints(input);
expect(comments).toEqual({
simple: ["Simple hint"],
"parent/child1": ["Parent hint", "Child 1 hint"],
"parent/child2/grandchild": ["Parent hint", "Grandchild hint"],
});
});
});
});
```
--------------------------------------------------------------------------------
/demo/react-router-app/app/lingo/dictionary.js:
--------------------------------------------------------------------------------
```javascript
export default {
version: 0.1,
files: {
"root.tsx": {
entries: {
"9/declaration/body/1/argument/1/1/3-content": {
content: {
de: "width=device-width, initial-scale=1",
en: "width=device-width, initial-scale=1",
es: "width=device-width, initial-scale=1",
fr: "width=device-width, initial-scale=1",
},
hash: "d94b318cb327f61f1aea44a6cb1fdcad",
},
},
},
"routes/test.tsx": {
entries: {
"3/declaration/body/0/argument/1/1": {
content: {
de: "Zurück nach Hause",
en: "Go back home",
es: "Volver a inicio",
fr: "Retourner à l'accueil",
},
hash: "a0ac69aec348674378faaf92ce476f64",
},
"3/declaration/body/0/argument/1/3": {
content: {
de: "Dies ist eine Testseite",
en: "This is a test page",
es: "Esta es una página de prueba",
fr: "Ceci est une page de test",
},
hash: "51eb13586d30537dfa934742439cc7ee",
},
"3/declaration/body/0/argument/1/5": {
content: {
de: "Willkommen auf der nicht-interaktiven Testseite.",
en: "Welcome to non-interactive testing page.",
es: "Bienvenido a la página de prueba no interactiva.",
fr: "Bienvenue sur la page de test non interactive.",
},
hash: "792a8d0c1ca71a88ab7d887075e69b1d",
},
"3/declaration/body/0/argument/1/7": {
content: {
de: "Bitte versuchen Sie nicht, mit dieser Seite zu interagieren, um Ihre eigene Sicherheit zu gewährleisten.",
en: "Please do not try to interact with this page for your own safety.",
es: "Por favor, no intentes interactuar con esta página por tu propia seguridad.",
fr: "Veuillez ne pas essayer d'interagir avec cette page pour votre propre sécurité.",
},
hash: "31ab29a98c0bb54378cb5a2390d07e57",
},
},
},
"welcome/welcome.tsx": {
entries: {
"3/declaration/body/0/argument/1/1/1/1-alt": {
content: {
de: "React Router",
en: "React Router",
es: "Enrutador de React",
fr: "React Router",
},
hash: "68ae50c1603f87d51e788a96b419f2ee",
},
"3/declaration/body/0/argument/1/1/1/3-alt": {
content: {
de: "React Router",
en: "React Router",
es: "Enrutador de React",
fr: "React Router",
},
hash: "68ae50c1603f87d51e788a96b419f2ee",
},
"3/declaration/body/0/argument/1/1/3/1-alt": {
content: {
de: "React Router",
en: "React Router",
es: "Enrutador de React",
fr: "React Router",
},
hash: "68ae50c1603f87d51e788a96b419f2ee",
},
"3/declaration/body/0/argument/1/1/3/3-alt": {
content: {
de: "React Router",
en: "React Router",
es: "Enrutador de React",
fr: "React Router",
},
hash: "68ae50c1603f87d51e788a96b419f2ee",
},
"3/declaration/body/0/argument/1/3": {
content: {
de: "Test",
en: "Test",
es: "Prueba",
fr: "Test",
},
hash: "4938894bf1608cee94696ec86f5d059a",
},
"3/declaration/body/0/argument/1/5/1/1": {
content: {
de: "Was kommt als nächstes?",
en: "What's next?",
es: "¿Qué sigue?",
fr: "Qu'en est-il ensuite ?",
},
hash: "e0d9d29b9e761346e506557eb7b7e798",
},
"4/declaration/body/0/argument/1/1": {
content: {
de: "<element:LingoDotDev></element:LingoDotDev> 💚<element:div><element:img></element:img><element:img></element:img></element:div>",
en: "<element:LingoDotDev></element:LingoDotDev> 💚<element:div><element:img></element:img><element:img></element:img></element:div>",
es: "<element:LingoDotDev></element:LingoDotDev> 💚<element:div><element:img></element:img><element:img></element:img></element:div>",
fr: "<element:LingoDotDev></element:LingoDotDev> 💚<element:div><element:img></element:img><element:img></element:img></element:div>",
},
hash: "201cf15cf0830aaaf478e49a9665d096",
},
"4/declaration/body/0/argument/1/1/3": {
content: {
de: "💚",
en: "💚",
es: "💚",
fr: "💚",
},
hash: "0ecc986bbbb51a93878f2d11bb45c04a",
},
"4/declaration/body/0/argument/1/1/3/1-alt": {
content: {
de: "React Router",
en: "React Router",
es: "Enrutador de React",
fr: "React Router",
},
hash: "68ae50c1603f87d51e788a96b419f2ee",
},
"4/declaration/body/0/argument/1/1/3/3-alt": {
content: {
de: "React Router",
en: "React Router",
es: "Enrutador de React",
fr: "React Router",
},
hash: "68ae50c1603f87d51e788a96b419f2ee",
},
"4/declaration/body/0/argument/1/1/5/1-alt": {
content: {
de: "React Router",
en: "React Router",
es: "Enrutador de React",
fr: "React Router",
},
hash: "68ae50c1603f87d51e788a96b419f2ee",
},
"4/declaration/body/0/argument/1/1/5/1/1-alt": {
content: {
de: "React Router",
en: "React Router",
es: "Enrutador de React",
fr: "React Router",
},
hash: "68ae50c1603f87d51e788a96b419f2ee",
},
"4/declaration/body/0/argument/1/1/5/1/3-alt": {
content: {
de: "React Router",
en: "React Router",
es: "Enrutador de React",
fr: "React Router",
},
hash: "68ae50c1603f87d51e788a96b419f2ee",
},
"4/declaration/body/0/argument/1/1/5/3-alt": {
content: {
de: "React Router",
en: "React Router",
es: "Enrutador de React",
fr: "React Router",
},
hash: "68ae50c1603f87d51e788a96b419f2ee",
},
"4/declaration/body/0/argument/1/1/5/5-alt": {
content: {
de: "React Router",
en: "React Router",
es: "Enrutador de React",
fr: "React Router",
},
hash: "68ae50c1603f87d51e788a96b419f2ee",
},
"4/declaration/body/0/argument/1/3": {
content: {
de: "Testseite öffnen",
en: "Open test page",
es: "Abrir página de prueba",
fr: "Ouvrir la page de test",
},
hash: "4e5098c50297642cf07ce303398bad59",
},
"4/declaration/body/0/argument/1/5": {
content: {
de: "Willkommen zu Ihrer neuen React Router Anwendung! Dieses Starter-Template enthält alles, was Sie benötigen, um mit React Router und Lingo.dev für die Internationalisierung zu beginnen.",
en: "Welcome to your new React Router application! This starter template includes everything you need to get started with React Router and Lingo.dev for internationalization.",
es: "¡Bienvenido a tu nueva aplicación de React Router! Esta plantilla inicial incluye todo lo que necesitas para empezar con React Router y Lingo.dev para la internacionalización.",
fr: "Bienvenue dans votre nouvelle application React Router ! Ce modèle de départ inclut tout ce dont vous avez besoin pour commencer avec React Router et Lingo.dev pour l'internationalisation.",
},
hash: "a90f2300128bce36346e0debd0b6092b",
},
"4/declaration/body/0/argument/1/5/1/1": {
content: {
de: "Was kommt als nächstes?",
en: "What's next?",
es: "¿Qué sigue?",
fr: "Qu'en est-il ensuite ?",
},
hash: "e0d9d29b9e761346e506557eb7b7e798",
},
"4/declaration/body/0/argument/1/7/1/1": {
content: {
de: "Was kommt als nächstes?",
en: "What's next?",
es: "¿Qué sigue?",
fr: "Qu'en est-il ensuite ?",
},
hash: "e0d9d29b9e761346e506557eb7b7e798",
},
},
},
},
};
```
--------------------------------------------------------------------------------
/packages/compiler/src/lib/lcp/server.ts:
--------------------------------------------------------------------------------
```typescript
import {
DictionaryFile,
DictionarySchema,
LCPSchema,
LCPScope,
} from "./schema";
import _ from "lodash";
import { LCPCache } from "./cache";
import { LCPAPI } from "./api";
type LCPServerBaseParams = {
lcp: LCPSchema;
sourceLocale: string;
sourceRoot: string;
lingoDir: string;
models: "lingo.dev" | Record<string, string>;
prompt?: string | null;
};
export type LCPServerParams = LCPServerBaseParams & {
targetLocales: string[];
};
export type LCPServerParamsForLocale = LCPServerBaseParams & {
targetLocale: string;
};
export class LCPServer {
private static inFlightPromise: Promise<
Record<string, DictionarySchema>
> | null = null;
static async loadDictionaries(
params: LCPServerParams,
): Promise<Record<string, DictionarySchema>> {
// If a load is already in progress, await it
if (this.inFlightPromise) {
return this.inFlightPromise;
}
// Otherwise start a new load restricted by the limiter
this.inFlightPromise = (async () => {
try {
const targetLocales = _.uniq([
...params.targetLocales,
params.sourceLocale,
]);
const dictionaries = await Promise.all(
targetLocales.map((targetLocale) =>
this.loadDictionaryForLocale({ ...params, targetLocale }),
),
);
const result = _.fromPairs(
targetLocales.map((targetLocale, index) => [
targetLocale,
dictionaries[index],
]),
);
return result;
} finally {
// Clear inFlightPromise regardless of success/failure
this.inFlightPromise = null;
}
})();
return this.inFlightPromise;
}
static async loadDictionaryForLocale(
params: LCPServerParamsForLocale,
): Promise<DictionarySchema> {
const sourceDictionary = this._extractSourceDictionary(
params.lcp,
params.sourceLocale,
params.targetLocale,
);
const cacheParams = {
lcp: params.lcp,
sourceLocale: params.sourceLocale,
lingoDir: params.lingoDir,
sourceRoot: params.sourceRoot,
};
if (this._countDictionaryEntries(sourceDictionary) === 0) {
console.log(
"Source dictionary is empty, returning empty dictionary for target locale",
);
return { ...sourceDictionary, locale: params.targetLocale };
}
const cache = LCPCache.readLocaleDictionary(
params.targetLocale,
cacheParams,
);
const uncachedSourceDictionary = this._getDictionaryDiff(
sourceDictionary,
cache,
);
let targetDictionary: DictionarySchema;
let newTranslations: DictionarySchema | undefined;
if (this._countDictionaryEntries(uncachedSourceDictionary) === 0) {
targetDictionary = cache;
} else if (params.targetLocale === params.sourceLocale) {
console.log(
"ℹ️ Lingo.dev returns source dictionary - source and target locales are the same",
);
// cache source dictionary for convenience when editing the dictionary.js file
await LCPCache.writeLocaleDictionary(sourceDictionary, cacheParams);
return sourceDictionary;
} else {
newTranslations = await LCPAPI.translate(
params.models,
uncachedSourceDictionary,
params.sourceLocale,
params.targetLocale,
params.prompt,
);
// we merge new translations with cache, so that we can cache empty strings
targetDictionary = this._mergeDictionaries(newTranslations, cache);
// ensure the locale metadata reflects the target locale
targetDictionary = {
...targetDictionary,
locale: params.targetLocale,
};
await LCPCache.writeLocaleDictionary(targetDictionary, cacheParams);
}
const targetDictionaryWithFallback = this._mergeDictionaries(
targetDictionary,
sourceDictionary,
true,
);
const result = this._addOverridesToDictionary(
targetDictionaryWithFallback,
params.lcp,
params.targetLocale,
);
if (newTranslations) {
console.log(
`ℹ️ Lingo.dev dictionary for ${params.targetLocale}:\n- %d entries\n- %d cached\n- %d uncached\n- %d translated\n- %d overrides`,
this._countDictionaryEntries(result),
this._countDictionaryEntries(cache),
this._countDictionaryEntries(uncachedSourceDictionary),
newTranslations ? this._countDictionaryEntries(newTranslations) : 0,
this._countDictionaryEntries(result) -
this._countDictionaryEntries(targetDictionary),
);
}
// console.log("Generated object", JSON.stringify(result, null, 2));
return result;
}
private static _extractSourceDictionary(
lcp: LCPSchema,
sourceLocale: string,
targetLocale: string,
): DictionarySchema {
const dictionary: DictionarySchema = {
version: 0.1,
locale: sourceLocale,
files: {},
};
for (const [fileKey, fileData] of Object.entries(lcp.files || {})) {
for (const [scopeKey, scopeData] of Object.entries(
fileData.scopes || {},
)) {
if (scopeData.skip) {
continue;
}
if (this._getScopeLocaleOverride(scopeData, targetLocale)) {
continue;
}
_.set(
dictionary,
[
"files" satisfies keyof DictionarySchema,
fileKey,
"entries" satisfies keyof DictionaryFile,
scopeKey,
],
scopeData.content,
);
}
}
return dictionary;
}
private static _addOverridesToDictionary(
dictionary: DictionarySchema,
lcp: LCPSchema,
targetLocale: string,
) {
for (const [fileKey, fileData] of Object.entries(lcp.files || {})) {
for (const [scopeKey, scopeData] of Object.entries(
fileData.scopes || {},
)) {
const override = this._getScopeLocaleOverride(scopeData, targetLocale);
if (!override) {
continue;
}
_.set(
dictionary,
[
"files" satisfies keyof DictionarySchema,
fileKey,
"entries" satisfies keyof DictionaryFile,
scopeKey,
],
override,
);
}
}
return dictionary;
}
private static _getScopeLocaleOverride(scopeData: LCPScope, locale: string) {
return _.get(scopeData.overrides, locale) ?? null;
}
private static _getDictionaryDiff(
sourceDictionary: DictionarySchema,
targetDictionary: DictionarySchema,
) {
if (this._countDictionaryEntries(targetDictionary) === 0) {
return sourceDictionary;
}
const files = _(sourceDictionary.files)
.mapValues((file, fileName) => ({
...file,
entries: _(file.entries)
.mapValues((entry, entryName) => {
const targetEntry = _.get(targetDictionary.files, [
fileName,
"entries",
entryName,
]);
if (targetEntry !== undefined) {
return undefined;
}
return entry;
})
.pickBy((value) => value !== undefined)
.value(),
}))
.pickBy((value) => Object.keys(value.entries).length > 0)
.value();
const dictionary = {
version: sourceDictionary.version,
locale: sourceDictionary.locale,
files,
};
return dictionary;
}
private static _mergeDictionaries(
sourceDictionary: DictionarySchema,
targetDictionary: DictionarySchema,
removeEmptyEntries = false,
) {
const fileNames = _.uniq([
...Object.keys(sourceDictionary.files),
...Object.keys(targetDictionary.files),
]);
const files = _(fileNames)
.map((fileName) => {
const sourceFile = _.get(sourceDictionary.files, fileName);
const targetFile = _.get(targetDictionary.files, fileName);
const entries = removeEmptyEntries
? _.pickBy(
sourceFile?.entries || {},
(value) => String(value || "")?.trim?.()?.length > 0,
)
: sourceFile?.entries || {};
return [
fileName,
{
...targetFile,
entries: _.merge({}, targetFile?.entries || {}, entries),
},
];
})
.fromPairs()
.value();
const dictionary = {
version: sourceDictionary.version,
locale: sourceDictionary.locale,
files,
};
return dictionary;
}
private static _countDictionaryEntries(dict: DictionarySchema) {
return Object.values(dict.files).reduce(
(sum, file) => sum + Object.keys(file.entries).length,
0,
);
}
}
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/dato/_utils.ts:
--------------------------------------------------------------------------------
```typescript
import _ from "lodash";
import { buildClient, SimpleSchemaTypes } from "@datocms/cma-client-node";
import { DastDocument, DatoBlock, DatoSimpleValue, DatoValue } from "./_base";
import { DastDocumentNode } from "./_base";
type DatoClientParams = {
apiKey: string;
projectId: string;
};
export type DatoClient = ReturnType<typeof createDatoClient>;
export default function createDatoClient(params: DatoClientParams) {
if (!params.apiKey) {
throw new Error(
"Missing required environment variable: DATO_API_TOKEN. Please set this variable and try again.",
);
}
const dato = buildClient({
apiToken: params.apiKey,
extraHeaders: {
"X-Exclude-Invalid": "true",
},
});
return {
findProject: async (): Promise<SimpleSchemaTypes.Site> => {
const project = await dato.site.find();
return project;
},
updateField: async (
fieldId: string,
payload: SimpleSchemaTypes.FieldUpdateSchema,
): Promise<void> => {
try {
await dato.fields.update(fieldId, payload);
} catch (_error: any) {
throw new Error(
[
`Failed to update field in DatoCMS.`,
`Field ID: ${fieldId}`,
`Payload: ${JSON.stringify(payload, null, 2)}`,
`Error: ${JSON.stringify(_error, null, 2)}`,
].join("\n\n"),
);
}
},
findField: async (fieldId: string): Promise<SimpleSchemaTypes.Field> => {
try {
const field = await dato.fields.find(fieldId);
if (!field) {
throw new Error(`Field ${fieldId} not found`);
}
return field;
} catch (_error: any) {
throw new Error(
[
`Failed to find field in DatoCMS.`,
`Field ID: ${fieldId}`,
`Error: ${JSON.stringify(_error, null, 2)}`,
].join("\n\n"),
);
}
},
findModels: async (): Promise<SimpleSchemaTypes.ItemType[]> => {
try {
const models = await dato.itemTypes.list();
const modelsWithoutBlocks = models.filter(
(model) => !model.modular_block,
);
return modelsWithoutBlocks;
} catch (_error: any) {
throw new Error(
[
`Failed to find models in DatoCMS.`,
`Error: ${JSON.stringify(_error, null, 2)}`,
].join("\n\n"),
);
}
},
findModel: async (modelId: string): Promise<SimpleSchemaTypes.ItemType> => {
try {
const model = await dato.itemTypes.find(modelId);
if (!model) {
throw new Error(`Model ${modelId} not found`);
}
return model;
} catch (_error: any) {
throw new Error(
[
`Failed to find model in DatoCMS.`,
`Model ID: ${modelId}`,
`Error: ${JSON.stringify(_error, null, 2)}`,
].join("\n\n"),
);
}
},
findRecords: async (
records: string[],
limit: number = 100,
): Promise<SimpleSchemaTypes.Item[]> => {
return dato.items
.list({
nested: true,
version: "current",
limit,
filter: {
projectId: params.projectId,
only_valid: "true",
ids: !records.length ? undefined : records.join(","),
},
})
.catch((error: any) =>
Promise.reject(error?.response?.body?.data?.[0] || error),
);
},
findRecordsForModel: async (
modelId: string,
records?: string[],
): Promise<SimpleSchemaTypes.Item[]> => {
try {
const result = await dato.items
.list({
nested: true,
version: "current",
filter: {
type: modelId,
only_valid: "true",
ids: !records?.length ? undefined : records.join(","),
},
})
.catch((error: any) =>
Promise.reject(error?.response?.body?.data?.[0] || error),
);
return result;
} catch (_error: any) {
throw new Error(
[
`Failed to find records for model in DatoCMS.`,
`Model ID: ${modelId}`,
`Error: ${JSON.stringify(_error, null, 2)}`,
].join("\n\n"),
);
}
},
updateRecord: async (id: string, payload: any): Promise<void> => {
try {
await dato.items
.update(id, payload)
.catch((error: any) =>
Promise.reject(error?.response?.body?.data?.[0] || error),
);
} catch (_error: any) {
if (_error?.attributes?.details?.message) {
throw new Error(
[
`${_error.attributes.details.message}`,
`Payload: ${JSON.stringify(payload, null, 2)}`,
`Error: ${JSON.stringify(_error, null, 2)}`,
].join("\n\n"),
);
}
throw new Error(
[
`Failed to update record in DatoCMS.`,
`Record ID: ${id}`,
`Payload: ${JSON.stringify(payload, null, 2)}`,
`Error: ${JSON.stringify(_error, null, 2)}`,
].join("\n\n"),
);
}
},
enableFieldLocalization: async (args: {
modelId: string;
fieldId: string;
}): Promise<void> => {
try {
await dato.fields
.update(`${args.modelId}::${args.fieldId}`, { localized: true })
.catch((error: any) =>
Promise.reject(error?.response?.body?.data?.[0] || error),
);
} catch (_error: any) {
if (_error?.attributes?.code === "NOT_FOUND") {
throw new Error(
[
`Field "${args.fieldId}" not found in model "${args.modelId}".`,
`Error: ${JSON.stringify(_error, null, 2)}`,
].join("\n\n"),
);
}
if (_error?.attributes?.details?.message) {
throw new Error(
[
`${_error.attributes.details.message}`,
`Error: ${JSON.stringify(_error, null, 2)}`,
].join("\n\n"),
);
}
throw new Error(
[
`Failed to enable field localization in DatoCMS.`,
`Field ID: ${args.fieldId}`,
`Model ID: ${args.modelId}`,
`Error: ${JSON.stringify(_error, null, 2)}`,
].join("\n\n"),
);
}
},
};
}
type TraverseDatoCallbackMap = {
onValue?: (
path: string[],
value: DatoSimpleValue,
setValue: (value: DatoSimpleValue) => void,
) => void;
onBlock?: (path: string[], value: DatoBlock) => void;
};
export function traverseDatoPayload(
payload: Record<string, DatoValue>,
callbackMap: TraverseDatoCallbackMap,
path: string[] = [],
) {
for (const fieldName of Object.keys(payload)) {
const fieldValue = payload[fieldName];
traverseDatoValue(payload, fieldValue, callbackMap, [...path, fieldName]);
}
}
export function traverseDatoValue(
parent: Record<string, DatoValue>,
value: DatoValue,
callbackMap: TraverseDatoCallbackMap,
path: string[] = [],
) {
if (_.isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverseDatoValue(parent, value[i], callbackMap, [...path, i.toString()]);
}
} else if (_.isObject(value)) {
if ("schema" in value && value.schema === "dast") {
traverseDastDocument(value, callbackMap, [...path]);
} else if ("type" in value && value.type === "item") {
traverseDatoBlock(value, callbackMap, [...path]);
} else {
throw new Error(
[
"Unsupported dato object value type:",
JSON.stringify(value, null, 2),
].join("\n\n"),
);
}
} else {
callbackMap.onValue?.(path, value, (value) => {
_.set(parent, path[path.length - 1], value);
});
}
}
export function traverseDastDocument(
dast: DastDocument,
callbackMap: TraverseDatoCallbackMap,
path: string[] = [],
) {
traverseDastNode(dast.document, callbackMap, [...path, "document"]);
}
export function traverseDatoBlock(
block: DatoBlock,
callbackMap: TraverseDatoCallbackMap,
path: string[] = [],
) {
callbackMap.onBlock?.(path, block);
traverseDatoPayload(block.attributes, callbackMap, [...path, "attributes"]);
}
export function traverseDastNode(
node: DastDocumentNode,
callbackMap: TraverseDatoCallbackMap,
path: string[] = [],
) {
if (node.value) {
callbackMap.onValue?.(path, node.value, (value) => {
_.set(node, "value", value);
});
}
if (node.children?.length) {
for (let i = 0; i < node.children.length; i++) {
traverseDastNode(node.children[i], callbackMap, [...path, i.toString()]);
}
}
}
```
--------------------------------------------------------------------------------
/integrations/directus/src/api.ts:
--------------------------------------------------------------------------------
```typescript
import { defineOperationApi } from "@directus/extensions-sdk";
interface Options {
item_id: string;
collection: string;
translation_table: string;
language_table: string;
replexica_api_key: string;
source_language?: string;
target_languages: string[];
}
interface Context {
services: {
ItemsService: any;
};
getSchema: () => Promise<any>;
}
interface TranslationResult {
success: boolean;
language: string;
operation?: "updated" | "created";
data?: any;
error?: string;
}
interface TranslationSummary {
successful: number;
failed: number;
updated: number;
created: number;
details: TranslationResult[];
}
export default defineOperationApi<Options>({
id: "replexica-integration-directus",
handler: async (
{
item_id,
collection,
translation_table,
language_table,
replexica_api_key,
source_language = "en-US",
target_languages,
},
context: Context,
) => {
if (!replexica_api_key) {
throw new Error("Replexica API Key not defined");
}
try {
const { ReplexicaEngine } = await import("@replexica/sdk");
const replexica = new ReplexicaEngine({ apiKey: replexica_api_key });
const { ItemsService } = context.services;
const schema = await context.getSchema();
// Initialize services
const languagesService = new ItemsService(language_table, { schema });
const translationsService = new ItemsService(translation_table, {
schema,
});
// Get the primary key field for the collection
const collection_pk = schema.collections[collection].primary;
// Get collection fields and their types
const collectionFields = schema.collections[translation_table].fields;
// Get all existing translations for this item
const existingTranslations = await translationsService.readByQuery({
fields: ["*"],
filter: {
[`${collection}_${collection_pk}`]: { _eq: item_id },
},
});
const sourceTranslation = existingTranslations.find(
(t: { languages_code: string }) => t.languages_code === source_language,
);
if (!sourceTranslation) {
throw new Error("No source translation found");
}
// Get target languages
const targetLanguages = await languagesService.readByQuery({
fields: ["code", "name"],
filter:
target_languages && target_languages.length > 0
? { code: { _in: target_languages } }
: { code: { _neq: source_language } },
});
if (!targetLanguages.length) {
throw new Error(
target_languages
? `Target language ${target_languages} not found in language table`
: "No target languages found in table",
);
}
// Prepare translation template
const translationTemplate = {
...sourceTranslation,
id: undefined,
languages_code: undefined,
date_created: undefined,
date_updated: undefined,
user_created: undefined,
user_updated: undefined,
};
// Process translations
const results: TranslationResult[] = await Promise.all(
targetLanguages.map(
async (language: { code: string; name: string }) => {
try {
let translatedData: Record<string, any> = {};
let objectToTranslate: Record<string, any> = {};
let textFields: Array<{ fieldName: string; fieldValue: string }> =
[];
// Separate fields into text and non-text
for (const [fieldName, fieldValue] of Object.entries(
translationTemplate,
)) {
// Skip if field is null or undefined
if (fieldValue == null) {
translatedData[fieldName] = fieldValue;
continue;
}
// Skip system fields and non-translatable fields
const fieldSchema = collectionFields[fieldName];
if (!fieldSchema || fieldSchema.system) {
translatedData[fieldName] = fieldValue;
continue;
}
if (fieldSchema.type === "text") {
textFields.push({
fieldName,
fieldValue: fieldValue as string,
});
} else {
objectToTranslate[fieldName] = fieldValue;
}
}
// Translate non-text fields in one batch
if (Object.keys(objectToTranslate).length > 0) {
const translatedObject = await replexica.localizeObject(
objectToTranslate,
{
sourceLocale: source_language,
targetLocale: language.code,
},
);
translatedData = { ...translatedData, ...translatedObject };
}
// Translate text fields individually
for (const { fieldName, fieldValue } of textFields) {
try {
if (isHtml(fieldValue)) {
translatedData[fieldName] = await replexica.localizeHtml(
fieldValue,
{
sourceLocale: source_language,
targetLocale: language.code,
},
);
} else {
translatedData[fieldName] = await replexica.localizeText(
fieldValue,
{
sourceLocale: source_language,
targetLocale: language.code,
},
);
}
} catch (fieldError) {
console.error(
`Error translating field ${fieldName}:`,
fieldError,
);
translatedData[fieldName] = fieldValue; // Keep original value on error
}
}
// Find existing translation for this language
const existingTranslation = existingTranslations.find(
(t: { languages_code: string }) =>
t.languages_code === language.code,
);
let result;
if (existingTranslation) {
result = await translationsService.updateOne(
existingTranslation.id,
{
...translatedData,
languages_code: language.code,
},
);
} else {
result = await translationsService.createOne({
...translatedData,
languages_code: language.code,
[`${collection}_${collection_pk}`]: item_id,
});
}
return {
success: true,
language: language.code,
operation: existingTranslation ? "updated" : "created",
data: result,
};
} catch (error) {
return {
success: false,
language: language.code,
error: error instanceof Error ? error.message : "Unknown error",
};
}
},
),
);
const requestedLanguages = new Set(target_languages || []);
const missingLanguages =
target_languages?.filter(
(code) =>
!targetLanguages.find(
(lang: { code: string }) => lang.code === code,
),
) || [];
const missingResults: TranslationResult[] = missingLanguages.map(
(code) => ({
success: false,
language: code,
error: `Language ${code} not found in language table`,
}),
);
const allResults = [...results, ...missingResults];
const summary: TranslationSummary = {
successful: allResults.filter((r) => r.success).length,
failed: allResults.filter((r) => !r.success).length,
updated: allResults.filter((r) => r.operation === "updated").length,
created: allResults.filter((r) => r.operation === "created").length,
details: allResults,
};
return summary;
} catch (error) {
throw new Error(
`Translation process failed: ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
}
},
});
// Helper functions
function isHtml(text: string): boolean {
const htmlRegex = /<[a-z][\s\S]*>/i;
return htmlRegex.test(text);
}
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/dato/extract.ts:
--------------------------------------------------------------------------------
```typescript
import _ from "lodash";
import { ILoader } from "../_types";
import { createLoader } from "../_utils";
import { DatoFilterLoaderOutput } from "./filter";
import fs from "fs";
import Z from "zod";
export type DatoExtractLoaderOutput = {
[modelId: string]: {
[recordId: string]: {
[fieldName: string]: string | Record<string, object>;
};
};
};
export default function createDatoExtractLoader(): ILoader<
DatoFilterLoaderOutput,
DatoExtractLoaderOutput
> {
return createLoader({
async pull(locale, input) {
const result: DatoExtractLoaderOutput = {};
for (const [modelId, modelInfo] of _.entries(input)) {
for (const [recordId, record] of _.entries(modelInfo)) {
for (const [fieldName, fieldValue] of _.entries(record)) {
const parsedValue = createParsedDatoValue(fieldValue);
if (parsedValue) {
_.set(result, [modelId, `_${recordId}`, fieldName], parsedValue);
}
}
}
}
return result;
},
async push(locale, data, originalInput) {
const result = _.cloneDeep(originalInput || {});
for (const [modelId, modelInfo] of _.entries(data)) {
for (const [virtualRecordId, record] of _.entries(modelInfo)) {
for (const [fieldName, fieldValue] of _.entries(record)) {
const [, recordId] = virtualRecordId.split("_");
const originalFieldValue = _.get(originalInput, [
modelId,
recordId,
fieldName,
]);
const rawValue = createRawDatoValue(
fieldValue,
originalFieldValue,
true,
);
_.set(
result,
[modelId, recordId, fieldName],
rawValue || originalFieldValue,
);
}
}
}
return result;
},
});
}
export type DatoValueRaw = any;
export type DatoValueParsed = any;
export function detectDatoFieldType(rawDatoValue: DatoValueRaw): string | null {
if (
_.has(rawDatoValue, "document") &&
_.get(rawDatoValue, "schema") === "dast"
) {
return "structured_text";
} else if (
_.has(rawDatoValue, "no_index") ||
_.has(rawDatoValue, "twitter_card")
) {
return "seo";
} else if (_.get(rawDatoValue, "type") === "item") {
return "single_block";
} else if (
_.isArray(rawDatoValue) &&
_.every(rawDatoValue, (item) => _.get(item, "type") === "item")
) {
return "rich_text";
} else if (_isFile(rawDatoValue)) {
return "file";
} else if (
_.isArray(rawDatoValue) &&
_.every(rawDatoValue, (item) => _isFile(item))
) {
return "gallery";
} else if (_isJson(rawDatoValue)) {
return "json";
} else if (_.isString(rawDatoValue)) {
return "string";
} else if (_isVideo(rawDatoValue)) {
return "video";
} else if (
_.isArray(rawDatoValue) &&
_.every(rawDatoValue, (item) => _.isString(item))
) {
return "ref_list";
} else {
return null;
}
}
export function createParsedDatoValue(
rawDatoValue: DatoValueRaw,
): DatoValueParsed {
const fieldType = detectDatoFieldType(rawDatoValue);
switch (fieldType) {
default:
return rawDatoValue;
case "structured_text":
return serializeStructuredText(rawDatoValue);
case "seo":
return serializeSeo(rawDatoValue);
case "single_block":
return serializeBlock(rawDatoValue);
case "rich_text":
return serializeBlockList(rawDatoValue);
case "json":
return JSON.parse(rawDatoValue);
case "video":
return serializeVideo(rawDatoValue);
case "file":
return serializeFile(rawDatoValue);
case "gallery":
return serializeGallery(rawDatoValue);
case "ref_list":
return null;
}
}
export function createRawDatoValue(
parsedDatoValue: DatoValueParsed,
originalRawDatoValue: any,
isClean = false,
): DatoValueRaw {
const fieldType = detectDatoFieldType(originalRawDatoValue);
switch (fieldType) {
default:
return parsedDatoValue;
case "structured_text":
return deserializeStructuredText(parsedDatoValue, originalRawDatoValue);
case "seo":
return deserializeSeo(parsedDatoValue, originalRawDatoValue);
case "single_block":
return deserializeBlock(parsedDatoValue, originalRawDatoValue, isClean);
case "rich_text":
return deserializeBlockList(
parsedDatoValue,
originalRawDatoValue,
isClean,
);
case "json":
return JSON.stringify(parsedDatoValue, null, 2);
case "video":
return deserializeVideo(parsedDatoValue, originalRawDatoValue);
case "file":
return deserializeFile(parsedDatoValue, originalRawDatoValue);
case "gallery":
return deserializeGallery(parsedDatoValue, originalRawDatoValue);
case "ref_list":
return originalRawDatoValue;
}
}
function serializeStructuredText(rawStructuredText: any) {
return serializeStructuredTextNode(rawStructuredText);
// Encapsulates helper function args
function serializeStructuredTextNode(
node: any,
path: string[] = [],
acc: Record<string, any> = {},
) {
if ("document" in node) {
return serializeStructuredTextNode(
node.document,
[...path, "document"],
acc,
);
}
if (!_.isNil(node.value)) {
acc[[...path, "value"].join(".")] = node.value;
} else if (_.get(node, "type") === "block") {
acc[[...path, "item"].join(".")] = serializeBlock(node.item);
}
if (node.children) {
for (let i = 0; i < node.children.length; i++) {
serializeStructuredTextNode(
node.children[i],
[...path, i.toString()],
acc,
);
}
}
return acc;
}
}
function serializeSeo(rawSeo: any) {
return _.chain(rawSeo).pick(["title", "description"]).value();
}
function serializeBlock(rawBlock: any) {
if (_.get(rawBlock, "type") === "item" && _.has(rawBlock, "id")) {
return serializeBlock(rawBlock.attributes);
}
const result: Record<string, any> = {};
for (const [attributeName, attributeValue] of _.entries(rawBlock)) {
result[attributeName] = createParsedDatoValue(attributeValue);
}
return result;
}
function serializeBlockList(rawBlockList: any) {
return _.chain(rawBlockList)
.map((block) => serializeBlock(block))
.value();
}
function serializeVideo(rawVideo: any) {
return _.chain(rawVideo).pick(["title"]).value();
}
function serializeFile(rawFile: any) {
return _.chain(rawFile).pick(["alt", "title"]).value();
}
function serializeGallery(rawGallery: any) {
return _.chain(rawGallery)
.map((item) => serializeFile(item))
.value();
}
function deserializeFile(parsedFile: any, originalRawFile: any) {
return _.chain(parsedFile).defaults(originalRawFile).value();
}
function deserializeGallery(parsedGallery: any, originalRawGallery: any) {
return _.chain(parsedGallery)
.map((item, i) => deserializeFile(item, originalRawGallery[i]))
.value();
}
function deserializeVideo(parsedVideo: any, originalRawVideo: any) {
return _.chain(parsedVideo).defaults(originalRawVideo).value();
}
function deserializeBlock(payload: any, rawNode: any, isClean = false) {
const result = _.cloneDeep(rawNode);
for (const [attributeName, attributeValue] of _.entries(rawNode.attributes)) {
const rawValue = createRawDatoValue(
payload[attributeName],
attributeValue,
isClean,
);
_.set(result, ["attributes", attributeName], rawValue);
}
if (isClean) {
delete result["id"];
}
return result;
}
function deserializeSeo(parsedSeo: any, originalRawSeo: any) {
return _.chain(parsedSeo)
.pick(["title", "description"])
.defaults(originalRawSeo)
.value();
}
function deserializeBlockList(
parsedBlockList: any,
originalRawBlockList: any,
isClean = false,
) {
return _.chain(parsedBlockList)
.map((block, i) =>
deserializeBlock(block, originalRawBlockList[i], isClean),
)
.value();
}
function deserializeStructuredText(
parsedStructuredText: Record<string, string>,
originalRawStructuredText: any,
) {
const result = _.cloneDeep(originalRawStructuredText);
for (const [path, value] of _.entries(parsedStructuredText)) {
const realPath = _.chain(path.split("."))
.flatMap((s) => (!_.isNaN(_.toNumber(s)) ? ["children", s] : s))
.value();
const deserializedValue = createRawDatoValue(
value,
_.get(originalRawStructuredText, realPath),
true,
);
_.set(result, realPath, deserializedValue);
}
return result;
}
function _isJson(rawDatoValue: DatoValueRaw): boolean {
try {
return (
_.isString(rawDatoValue) &&
rawDatoValue.startsWith("{") &&
rawDatoValue.endsWith("}") &&
!!JSON.parse(rawDatoValue)
);
} catch (e) {
return false;
}
}
function _isFile(rawDatoValue: DatoValueRaw): boolean {
return (
_.isObject(rawDatoValue) &&
["alt", "title", "custom_data", "focal_point", "upload_id"].every((key) =>
_.has(rawDatoValue, key),
)
);
}
function _isVideo(rawDatoValue: DatoValueRaw): boolean {
return (
_.isObject(rawDatoValue) &&
[
"url",
"title",
"width",
"height",
"provider",
"provider_uid",
"thumbnail_url",
].every((key) => _.has(rawDatoValue, key))
);
}
```
--------------------------------------------------------------------------------
/packages/locales/src/names/index.spec.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getCountryName, getLanguageName, getScriptName } from "./index";
// Mock the loader functions
vi.mock("./loader", () => ({
loadTerritoryNames: vi.fn(),
loadLanguageNames: vi.fn(),
loadScriptNames: vi.fn(),
}));
import {
loadTerritoryNames,
loadLanguageNames,
loadScriptNames,
} from "./loader";
const mockLoadTerritoryNames = loadTerritoryNames as ReturnType<typeof vi.fn>;
const mockLoadLanguageNames = loadLanguageNames as ReturnType<typeof vi.fn>;
const mockLoadScriptNames = loadScriptNames as ReturnType<typeof vi.fn>;
describe("getCountryName", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should get country name in English by default", async () => {
mockLoadTerritoryNames.mockResolvedValue({
US: "United States",
CN: "China",
DE: "Germany",
});
const result = await getCountryName("US");
expect(result).toBe("United States");
expect(mockLoadTerritoryNames).toHaveBeenCalledWith("en");
});
it("should get country name in Spanish", async () => {
mockLoadTerritoryNames.mockResolvedValue({
US: "Estados Unidos",
CN: "China",
DE: "Alemania",
});
const result = await getCountryName("US", "es");
expect(result).toBe("Estados Unidos");
expect(mockLoadTerritoryNames).toHaveBeenCalledWith("es");
});
it("should normalize country code to uppercase", async () => {
mockLoadTerritoryNames.mockResolvedValue({
US: "United States",
CN: "China",
});
const result = await getCountryName("us");
expect(result).toBe("United States");
expect(mockLoadTerritoryNames).toHaveBeenCalledWith("en");
});
it("should throw error for empty country code", async () => {
await expect(getCountryName("")).rejects.toThrow(
"Country code is required",
);
expect(mockLoadTerritoryNames).not.toHaveBeenCalled();
});
it("should throw error for null country code", async () => {
await expect(getCountryName(null as any)).rejects.toThrow(
"Country code is required",
);
expect(mockLoadTerritoryNames).not.toHaveBeenCalled();
});
it("should throw error for undefined country code", async () => {
await expect(getCountryName(undefined as any)).rejects.toThrow(
"Country code is required",
);
expect(mockLoadTerritoryNames).not.toHaveBeenCalled();
});
it("should throw error for unknown country code", async () => {
mockLoadTerritoryNames.mockResolvedValue({
US: "United States",
CN: "China",
});
await expect(getCountryName("XX")).rejects.toThrow(
'Country code "XX" not found',
);
});
it("should handle loader errors", async () => {
mockLoadTerritoryNames.mockRejectedValue(new Error("Failed to load data"));
await expect(getCountryName("US")).rejects.toThrow("Failed to load data");
});
});
describe("getLanguageName", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should get language name in English by default", async () => {
mockLoadLanguageNames.mockResolvedValue({
en: "English",
es: "Spanish",
zh: "Chinese",
});
const result = await getLanguageName("en");
expect(result).toBe("English");
expect(mockLoadLanguageNames).toHaveBeenCalledWith("en");
});
it("should get language name in Spanish", async () => {
mockLoadLanguageNames.mockResolvedValue({
en: "inglés",
es: "español",
zh: "chino",
});
const result = await getLanguageName("en", "es");
expect(result).toBe("inglés");
expect(mockLoadLanguageNames).toHaveBeenCalledWith("es");
});
it("should normalize language code to lowercase", async () => {
mockLoadLanguageNames.mockResolvedValue({
en: "English",
es: "Spanish",
});
const result = await getLanguageName("EN");
expect(result).toBe("English");
expect(mockLoadLanguageNames).toHaveBeenCalledWith("en");
});
it("should throw error for empty language code", async () => {
await expect(getLanguageName("")).rejects.toThrow(
"Language code is required",
);
expect(mockLoadLanguageNames).not.toHaveBeenCalled();
});
it("should throw error for null language code", async () => {
await expect(getLanguageName(null as any)).rejects.toThrow(
"Language code is required",
);
expect(mockLoadLanguageNames).not.toHaveBeenCalled();
});
it("should throw error for undefined language code", async () => {
await expect(getLanguageName(undefined as any)).rejects.toThrow(
"Language code is required",
);
expect(mockLoadLanguageNames).not.toHaveBeenCalled();
});
it("should throw error for unknown language code", async () => {
mockLoadLanguageNames.mockResolvedValue({
en: "English",
es: "Spanish",
});
await expect(getLanguageName("xx")).rejects.toThrow(
'Language code "xx" not found',
);
});
it("should handle loader errors", async () => {
mockLoadLanguageNames.mockRejectedValue(new Error("Failed to load data"));
await expect(getLanguageName("en")).rejects.toThrow("Failed to load data");
});
});
describe("getScriptName", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should get script name in English by default", async () => {
mockLoadScriptNames.mockResolvedValue({
Latn: "Latin",
Cyrl: "Cyrillic",
Hans: "Simplified",
Hant: "Traditional",
});
const result = await getScriptName("Latn");
expect(result).toBe("Latin");
expect(mockLoadScriptNames).toHaveBeenCalledWith("en");
});
it("should get script name in Spanish", async () => {
mockLoadScriptNames.mockResolvedValue({
Latn: "latino",
Cyrl: "cirílico",
Hans: "simplificado",
Hant: "tradicional",
});
const result = await getScriptName("Hans", "es");
expect(result).toBe("simplificado");
expect(mockLoadScriptNames).toHaveBeenCalledWith("es");
});
it("should preserve script code case", async () => {
mockLoadScriptNames.mockResolvedValue({
Latn: "Latin",
CYRL: "Cyrillic", // Note: some script codes might be uppercase
hans: "Simplified", // Note: some might be lowercase
});
const result1 = await getScriptName("Latn");
const result2 = await getScriptName("CYRL");
const result3 = await getScriptName("hans");
expect(result1).toBe("Latin");
expect(result2).toBe("Cyrillic");
expect(result3).toBe("Simplified");
});
it("should throw error for empty script code", async () => {
await expect(getScriptName("")).rejects.toThrow("Script code is required");
expect(mockLoadScriptNames).not.toHaveBeenCalled();
});
it("should throw error for null script code", async () => {
await expect(getScriptName(null as any)).rejects.toThrow(
"Script code is required",
);
expect(mockLoadScriptNames).not.toHaveBeenCalled();
});
it("should throw error for undefined script code", async () => {
await expect(getScriptName(undefined as any)).rejects.toThrow(
"Script code is required",
);
expect(mockLoadScriptNames).not.toHaveBeenCalled();
});
it("should throw error for unknown script code", async () => {
mockLoadScriptNames.mockResolvedValue({
Latn: "Latin",
Cyrl: "Cyrillic",
});
await expect(getScriptName("Xxxx")).rejects.toThrow(
'Script code "Xxxx" not found',
);
});
it("should handle loader errors", async () => {
mockLoadScriptNames.mockRejectedValue(new Error("Failed to load data"));
await expect(getScriptName("Latn")).rejects.toThrow("Failed to load data");
});
});
describe("Integration scenarios", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should handle multiple languages for the same code", async () => {
// Mock different responses for different languages
mockLoadTerritoryNames
.mockResolvedValueOnce({ US: "United States" }) // en
.mockResolvedValueOnce({ US: "Estados Unidos" }) // es
.mockResolvedValueOnce({ US: "États-Unis" }); // fr
const result1 = await getCountryName("US", "en");
const result2 = await getCountryName("US", "es");
const result3 = await getCountryName("US", "fr");
expect(result1).toBe("United States");
expect(result2).toBe("Estados Unidos");
expect(result3).toBe("États-Unis");
expect(mockLoadTerritoryNames).toHaveBeenCalledTimes(3);
expect(mockLoadTerritoryNames).toHaveBeenNthCalledWith(1, "en");
expect(mockLoadTerritoryNames).toHaveBeenNthCalledWith(2, "es");
expect(mockLoadTerritoryNames).toHaveBeenNthCalledWith(3, "fr");
});
it("should handle Chinese language names", async () => {
mockLoadLanguageNames.mockResolvedValue({
en: "英语",
es: "西班牙语",
fr: "法语",
});
const result1 = await getLanguageName("en", "zh");
const result2 = await getLanguageName("es", "zh");
const result3 = await getLanguageName("fr", "zh");
expect(result1).toBe("英语");
expect(result2).toBe("西班牙语");
expect(result3).toBe("法语");
});
it("should handle script names with variants", async () => {
mockLoadScriptNames.mockResolvedValue({
Hans: "Simplified Han",
Hant: "Traditional Han",
Latn: "Latin",
Cyrl: "Cyrillic",
});
const result1 = await getScriptName("Hans");
const result2 = await getScriptName("Hant");
const result3 = await getScriptName("Latn");
expect(result1).toBe("Simplified Han");
expect(result2).toBe("Traditional Han");
expect(result3).toBe("Latin");
});
});
```
--------------------------------------------------------------------------------
/packages/locales/src/validation.ts:
--------------------------------------------------------------------------------
```typescript
import { LOCALE_REGEX } from "./constants";
/**
* Validation functions for locale codes and components
*/
// ISO 639-1 language codes (most common)
const VALID_LANGUAGE_CODES = new Set([
"aa",
"ab",
"ae",
"af",
"ak",
"am",
"an",
"ar",
"as",
"av",
"ay",
"az",
"ba",
"be",
"bg",
"bh",
"bi",
"bm",
"bn",
"bo",
"br",
"bs",
"ca",
"ce",
"ch",
"co",
"cr",
"cs",
"cu",
"cv",
"cy",
"da",
"de",
"dv",
"dz",
"ee",
"el",
"en",
"eo",
"es",
"et",
"eu",
"fa",
"ff",
"fi",
"fj",
"fo",
"fr",
"fy",
"ga",
"gd",
"gl",
"gn",
"gu",
"gv",
"ha",
"he",
"hi",
"ho",
"hr",
"ht",
"hu",
"hy",
"hz",
"ia",
"id",
"ie",
"ig",
"ii",
"ik",
"io",
"is",
"it",
"iu",
"ja",
"jv",
"ka",
"kg",
"ki",
"kj",
"kk",
"kl",
"km",
"kn",
"ko",
"kr",
"ks",
"ku",
"kv",
"kw",
"ky",
"la",
"lb",
"lg",
"li",
"ln",
"lo",
"lt",
"lu",
"lv",
"mg",
"mh",
"mi",
"mk",
"ml",
"mn",
"mr",
"ms",
"mt",
"my",
"na",
"nb",
"nd",
"ne",
"ng",
"nl",
"nn",
"no",
"nr",
"nv",
"ny",
"oc",
"oj",
"om",
"or",
"os",
"pa",
"pi",
"pl",
"ps",
"pt",
"qu",
"rm",
"rn",
"ro",
"ru",
"rw",
"sa",
"sc",
"sd",
"se",
"sg",
"si",
"sk",
"sl",
"sm",
"sn",
"so",
"sq",
"sr",
"ss",
"st",
"su",
"sv",
"sw",
"ta",
"te",
"tg",
"th",
"ti",
"tk",
"tl",
"tn",
"to",
"tr",
"ts",
"tt",
"tw",
"ty",
"ug",
"uk",
"ur",
"uz",
"ve",
"vi",
"vo",
"wa",
"wo",
"xh",
"yi",
"yo",
"za",
"zh",
"zu",
]);
// ISO 15924 script codes (most common)
const VALID_SCRIPT_CODES = new Set([
"Adlm",
"Afak",
"Aghb",
"Ahom",
"Arab",
"Aran",
"Armi",
"Armn",
"Avst",
"Bali",
"Bamu",
"Bass",
"Batk",
"Beng",
"Bhks",
"Blis",
"Bopo",
"Brah",
"Brai",
"Bugi",
"Buhd",
"Cakm",
"Cans",
"Cari",
"Cham",
"Cher",
"Chrs",
"Cirt",
"Copt",
"Cpmn",
"Cprt",
"Cyrl",
"Cyrs",
"Deva",
"Diak",
"Dogr",
"Dsrt",
"Dupl",
"Egyd",
"Egyh",
"Egyp",
"Elba",
"Elym",
"Ethi",
"Gara",
"Gong",
"Gonm",
"Goth",
"Gran",
"Grek",
"Gujr",
"Guru",
"Hanb",
"Hang",
"Hani",
"Hano",
"Hans",
"Hant",
"Hatr",
"Hebr",
"Hira",
"Hluw",
"Hmng",
"Hmnp",
"Hrkt",
"Hung",
"Inds",
"Ital",
"Jamo",
"Java",
"Jpan",
"Jurc",
"Kali",
"Kana",
"Khar",
"Khmr",
"Khoj",
"Kits",
"Knda",
"Kore",
"Kpel",
"Kthi",
"Lana",
"Laoo",
"Latf",
"Latg",
"Latn",
"Leke",
"Lepc",
"Limb",
"Lina",
"Linb",
"Lisu",
"Loma",
"Lyci",
"Lydi",
"Mahj",
"Maka",
"Mand",
"Mani",
"Marc",
"Maya",
"Medf",
"Mend",
"Merc",
"Mero",
"Mlym",
"Modi",
"Mong",
"Moon",
"Mroo",
"Mtei",
"Mult",
"Mymr",
"Nand",
"Narb",
"Nbat",
"Newa",
"Nkgb",
"Nkoo",
"Nshu",
"Ogam",
"Olck",
"Orkh",
"Orya",
"Osge",
"Osma",
"Ougr",
"Palm",
"Pauc",
"Perm",
"Phag",
"Phli",
"Phlp",
"Phlv",
"Phnx",
"Plrd",
"Prti",
"Qaaa",
"Qabx",
"Rjng",
"Rohg",
"Roro",
"Runr",
"Samr",
"Sara",
"Sarb",
"Saur",
"Sgnw",
"Shaw",
"Shrd",
"Shui",
"Sidd",
"Sind",
"Sinh",
"Sogd",
"Sogo",
"Sora",
"Soyo",
"Sund",
"Sylo",
"Syrc",
"Syre",
"Syrj",
"Syrn",
"Tagb",
"Takr",
"Tale",
"Talu",
"Taml",
"Tang",
"Tavt",
"Telu",
"Teng",
"Tfng",
"Tglg",
"Thaa",
"Thai",
"Tibt",
"Tirh",
"Ugar",
"Vaii",
"Visp",
"Wara",
"Wcho",
"Wole",
"Xpeo",
"Xsux",
"Yezi",
"Yiii",
"Zanb",
"Zinh",
"Zmth",
"Zsye",
"Zsym",
"Zxxx",
"Zyyy",
"Zzzz",
]);
// ISO 3166-1 alpha-2 country codes (most common)
const VALID_REGION_CODES = new Set([
"AD",
"AE",
"AF",
"AG",
"AI",
"AL",
"AM",
"AO",
"AQ",
"AR",
"AS",
"AT",
"AU",
"AW",
"AX",
"AZ",
"BA",
"BB",
"BD",
"BE",
"BF",
"BG",
"BH",
"BI",
"BJ",
"BL",
"BM",
"BN",
"BO",
"BQ",
"BR",
"BS",
"BT",
"BV",
"BW",
"BY",
"BZ",
"CA",
"CC",
"CD",
"CF",
"CG",
"CH",
"CI",
"CK",
"CL",
"CM",
"CN",
"CO",
"CR",
"CU",
"CV",
"CW",
"CX",
"CY",
"CZ",
"DE",
"DJ",
"DK",
"DM",
"DO",
"DZ",
"EC",
"EE",
"EG",
"EH",
"ER",
"ES",
"ET",
"FI",
"FJ",
"FK",
"FM",
"FO",
"FR",
"GA",
"GB",
"GD",
"GE",
"GF",
"GG",
"GH",
"GI",
"GL",
"GM",
"GN",
"GP",
"GQ",
"GR",
"GS",
"GT",
"GU",
"GW",
"GY",
"HK",
"HM",
"HN",
"HR",
"HT",
"HU",
"ID",
"IE",
"IL",
"IM",
"IN",
"IO",
"IQ",
"IR",
"IS",
"IT",
"JE",
"JM",
"JO",
"JP",
"KE",
"KG",
"KH",
"KI",
"KM",
"KN",
"KP",
"KR",
"KW",
"KY",
"KZ",
"LA",
"LB",
"LC",
"LI",
"LK",
"LR",
"LS",
"LT",
"LU",
"LV",
"LY",
"MA",
"MC",
"MD",
"ME",
"MF",
"MG",
"MH",
"MK",
"ML",
"MM",
"MN",
"MO",
"MP",
"MQ",
"MR",
"MS",
"MT",
"MU",
"MV",
"MW",
"MX",
"MY",
"MZ",
"NA",
"NC",
"NE",
"NF",
"NG",
"NI",
"NL",
"NO",
"NP",
"NR",
"NU",
"NZ",
"OM",
"PA",
"PE",
"PF",
"PG",
"PH",
"PK",
"PL",
"PM",
"PN",
"PR",
"PS",
"PT",
"PW",
"PY",
"QA",
"RE",
"RO",
"RS",
"RU",
"RW",
"SA",
"SB",
"SC",
"SD",
"SE",
"SG",
"SH",
"SI",
"SJ",
"SK",
"SL",
"SM",
"SN",
"SO",
"SR",
"SS",
"ST",
"SV",
"SX",
"SY",
"SZ",
"TC",
"TD",
"TF",
"TG",
"TH",
"TJ",
"TK",
"TL",
"TM",
"TN",
"TO",
"TR",
"TT",
"TV",
"TW",
"TZ",
"UA",
"UG",
"UM",
"US",
"UY",
"UZ",
"VA",
"VC",
"VE",
"VG",
"VI",
"VN",
"VU",
"WF",
"WS",
"YE",
"YT",
"ZA",
"ZM",
"ZW",
]);
// UN M.49 numeric region codes (most common)
const VALID_NUMERIC_REGION_CODES = new Set([
"001",
"002",
"003",
"005",
"009",
"010",
"011",
"013",
"014",
"015",
"017",
"018",
"019",
"021",
"029",
"030",
"034",
"035",
"039",
"053",
"054",
"057",
"061",
"142",
"143",
"145",
"150",
"151",
"154",
"155",
"202",
"419",
"AC",
"BL",
"BQ",
"BV",
"CP",
"CW",
"DG",
"EA",
"EU",
"EZ",
"FK",
"FO",
"GF",
"GG",
"GI",
"GL",
"GP",
"GS",
"GU",
"HM",
"IC",
"IM",
"IO",
"JE",
"KY",
"MF",
"MH",
"MO",
"MP",
"MQ",
"MS",
"NC",
"NF",
"PF",
"PM",
"PN",
"PR",
"PS",
"RE",
"SH",
"SJ",
"SX",
"TC",
"TF",
"TK",
"TL",
"UM",
"VA",
"VC",
"VG",
"VI",
"WF",
"YT",
]);
/**
* Checks if a locale string is properly formatted and uses real codes
*
* @param locale - The locale string to validate
* @returns true if the locale is valid, false otherwise
*
* @example
* ```typescript
* isValidLocale("en-US"); // true
* isValidLocale("en_US"); // true
* isValidLocale("zh-Hans-CN"); // true
* isValidLocale("invalid"); // false
* isValidLocale("en-FAKE"); // false
* isValidLocale("xyz-US"); // false
* ```
*/
export function isValidLocale(locale: string): boolean {
if (typeof locale !== "string" || !locale.trim()) {
return false;
}
try {
const match = locale.match(LOCALE_REGEX);
if (!match) {
return false;
}
const [, language, script, region] = match;
// Validate language code
if (!isValidLanguageCode(language)) {
return false;
}
// Validate script code if present
if (script && !isValidScriptCode(script)) {
return false;
}
// Validate region code if present
if (region && !isValidRegionCode(region)) {
return false;
}
return true;
} catch {
return false;
}
}
/**
* Checks if a language code is valid
*
* @param code - The language code to validate
* @returns true if the language code is valid, false otherwise
*
* @example
* ```typescript
* isValidLanguageCode("en"); // true
* isValidLanguageCode("zh"); // true
* isValidLanguageCode("es"); // true
* isValidLanguageCode("xyz"); // false
* isValidLanguageCode("fake"); // false
* ```
*/
export function isValidLanguageCode(code: string): boolean {
if (typeof code !== "string" || !code.trim()) {
return false;
}
return VALID_LANGUAGE_CODES.has(code.toLowerCase());
}
/**
* Checks if a script code is valid
*
* @param code - The script code to validate
* @returns true if the script code is valid, false otherwise
*
* @example
* ```typescript
* isValidScriptCode("Hans"); // true (Simplified Chinese)
* isValidScriptCode("Hant"); // true (Traditional Chinese)
* isValidScriptCode("Latn"); // true (Latin alphabet)
* isValidScriptCode("Cyrl"); // true (Cyrillic)
* isValidScriptCode("Fake"); // false
* ```
*/
export function isValidScriptCode(code: string): boolean {
if (typeof code !== "string" || !code.trim()) {
return false;
}
return VALID_SCRIPT_CODES.has(code);
}
/**
* Checks if a region/country code is valid
*
* @param code - The region code to validate
* @returns true if the region code is valid, false otherwise
*
* @example
* ```typescript
* isValidRegionCode("US"); // true
* isValidRegionCode("CN"); // true
* isValidRegionCode("GB"); // true
* isValidRegionCode("ZZ"); // false
* isValidRegionCode("FAKE"); // false
* ```
*/
export function isValidRegionCode(code: string): boolean {
if (typeof code !== "string" || !code.trim()) {
return false;
}
const upperCode = code.toUpperCase();
return (
VALID_REGION_CODES.has(upperCode) ||
VALID_NUMERIC_REGION_CODES.has(upperCode)
);
}
```
--------------------------------------------------------------------------------
/scripts/docs/src/json-schema/parser.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, expect, it } from "vitest";
import {
parseProperty,
parseSchema,
resolveRef,
sortPropertyKeys,
inferType,
} from "./parser";
import type { JSONSchemaObject, PropertyInfo } from "./types";
describe("resolveRef", () => {
it("should resolve simple reference", () => {
const root = {
definitions: {
User: { type: "object", properties: { name: { type: "string" } } },
},
};
const result = resolveRef("#/definitions/User", root);
expect(result).toEqual({
type: "object",
properties: { name: { type: "string" } },
});
});
it("should return undefined for invalid reference", () => {
const root = { definitions: {} };
const result = resolveRef("#/definitions/NonExistent", root);
expect(result).toBeUndefined();
});
it("should handle deep nested references", () => {
const root = {
a: { b: { c: { value: "found" } } },
};
const result = resolveRef("#/a/b/c", root);
expect(result).toEqual({ value: "found" });
});
it("should return undefined for non-hash references", () => {
const root = {};
const result = resolveRef("invalid", root);
expect(result).toBeUndefined();
});
});
describe("sortPropertyKeys", () => {
it("should sort with custom order first", () => {
const keys = ["gamma", "alpha", "beta"];
const customOrder = ["beta", "alpha"];
const result = sortPropertyKeys(keys, [], customOrder);
expect(result).toEqual(["beta", "alpha", "gamma"]);
});
it("should prioritize required properties", () => {
const keys = ["optional1", "required1", "optional2", "required2"];
const required = ["required1", "required2"];
const result = sortPropertyKeys(keys, required);
expect(result).toEqual([
"required1",
"required2",
"optional1",
"optional2",
]);
});
it("should combine custom order with required sorting", () => {
const keys = ["d", "c", "b", "a"];
const required = ["c", "a"];
const customOrder = ["b"];
const result = sortPropertyKeys(keys, required, customOrder);
expect(result).toEqual(["b", "a", "c", "d"]);
});
it("should handle empty arrays", () => {
const result = sortPropertyKeys([]);
expect(result).toEqual([]);
});
});
describe("inferType", () => {
const root = {};
it("should handle primitive types", () => {
expect(inferType({ type: "string" }, root)).toBe("string");
expect(inferType({ type: "number" }, root)).toBe("number");
expect(inferType({ type: "boolean" }, root)).toBe("boolean");
});
it("should handle array types", () => {
expect(inferType({ type: "array" }, root)).toBe("array");
expect(inferType({ type: "array", items: { type: "string" } }, root)).toBe(
"array of string",
);
});
it("should handle union types", () => {
const schema = {
type: ["string", "number"],
};
expect(inferType(schema, root)).toBe("string | number");
});
it("should handle anyOf unions", () => {
const schema = {
anyOf: [{ type: "string" }, { type: "number" }],
};
expect(inferType(schema, root)).toBe("string | number");
});
it("should handle $ref types", () => {
const rootWithRef = {
definitions: {
User: { type: "object" },
},
};
const schema = { $ref: "#/definitions/User" };
expect(inferType(schema, rootWithRef)).toBe("object");
});
it("should handle complex array items with unions", () => {
const schema = {
type: "array",
items: {
anyOf: [{ type: "string" }, { type: "number" }],
},
};
expect(inferType(schema, root)).toBe("array of string | number");
});
it("should return unknown for invalid schemas", () => {
expect(inferType(null, root)).toBe("unknown");
expect(inferType({}, root)).toBe("unknown");
expect(inferType({ invalid: true }, root)).toBe("unknown");
});
});
describe("parseProperty", () => {
it("should parse simple property", () => {
const schema = {
type: "string",
description: "A string property",
default: "default value",
};
const result = parseProperty("name", schema, true);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
name: "name",
fullPath: "name",
type: "string",
required: true,
description: "A string property",
defaultValue: "default value",
allowedValues: undefined,
allowedKeys: undefined,
});
});
it("should parse property with enum values", () => {
const schema = {
type: "string",
enum: ["red", "green", "blue"],
};
const result = parseProperty("color", schema, false);
expect(result[0].allowedValues).toEqual(["blue", "green", "red"]);
});
it("should parse property with allowed keys", () => {
const schema = {
type: "object",
propertyNames: {
enum: ["key1", "key2", "key3"],
},
};
const result = parseProperty("config", schema, false);
expect(result[0].allowedKeys).toEqual(["key1", "key2", "key3"]);
});
it("should handle parent path correctly", () => {
const schema = { type: "string" };
const result = parseProperty("child", schema, false, {
parentPath: "parent",
});
expect(result[0].fullPath).toBe("parent.child");
});
it("should parse nested object properties", () => {
const schema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number", description: "Person's age" },
},
required: ["name"],
};
const result = parseProperty("person", schema, true);
expect(result).toHaveLength(1);
expect(result[0].children).toHaveLength(2);
expect(result[0].children?.[0]).toEqual({
name: "name",
fullPath: "person.name",
type: "string",
required: true,
description: undefined,
defaultValue: undefined,
allowedValues: undefined,
allowedKeys: undefined,
});
expect(result[0].children?.[1]).toEqual({
name: "age",
fullPath: "person.age",
type: "number",
required: false,
description: "Person's age",
defaultValue: undefined,
allowedValues: undefined,
allowedKeys: undefined,
});
});
it("should parse array with object items", () => {
const schema = {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
value: { type: "number" },
},
required: ["id"],
},
};
const result = parseProperty("items", schema, false);
expect(result[0].children).toHaveLength(2);
expect(result[0].children?.[0].fullPath).toBe("items.*.id");
expect(result[0].children?.[0].required).toBe(true);
expect(result[0].children?.[1].fullPath).toBe("items.*.value");
expect(result[0].children?.[1].required).toBe(false);
});
it("should handle additionalProperties", () => {
const schema = {
type: "object",
additionalProperties: {
type: "string",
description: "Dynamic property",
},
};
const result = parseProperty("config", schema, false);
expect(result[0].children).toHaveLength(1);
expect(result[0].children?.[0].name).toBe("*");
expect(result[0].children?.[0].fullPath).toBe("config.*");
expect(result[0].children?.[0].type).toBe("string");
});
it("should handle markdownDescription over description", () => {
const schema = {
type: "string",
description: "Plain description",
markdownDescription: "**Markdown** description",
};
const result = parseProperty("field", schema, false);
expect(result[0].description).toBe("**Markdown** description");
});
it("should return empty array for invalid schema", () => {
const result = parseProperty("invalid", null, false);
expect(result).toEqual([]);
});
});
describe("parseSchema", () => {
it("should parse complete schema", () => {
const schema = {
type: "object",
properties: {
version: { type: "string", default: "1.0" },
config: {
type: "object",
properties: {
debug: { type: "boolean" },
},
},
},
required: ["version"],
};
const result = parseSchema(schema);
expect(result).toHaveLength(2);
expect(result[0].name).toBe("version");
expect(result[0].required).toBe(true);
expect(result[1].name).toBe("config");
expect(result[1].required).toBe(false);
});
it("should handle schema with $ref root", () => {
const schema = {
$ref: "#/definitions/Config",
definitions: {
Config: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
},
},
};
const result = parseSchema(schema);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("name");
expect(result[0].required).toBe(true);
});
it("should apply custom ordering", () => {
const schema = {
type: "object",
properties: {
gamma: { type: "string" },
alpha: { type: "string" },
beta: { type: "string" },
},
};
const result = parseSchema(schema, { customOrder: ["beta", "alpha"] });
expect(result.map((p: PropertyInfo) => p.name)).toEqual([
"beta",
"alpha",
"gamma",
]);
});
it("should return empty array for invalid schema", () => {
expect(parseSchema(null)).toEqual([]);
expect(parseSchema({})).toEqual([]);
expect(parseSchema({ type: "string" })).toEqual([]);
});
it("should handle missing definitions gracefully", () => {
const schema = {
$ref: "#/definitions/NonExistent",
definitions: {},
};
const result = parseSchema(schema);
expect(result).toEqual([]);
});
});
```
--------------------------------------------------------------------------------
/packages/compiler/src/utils/jsx-content.spec.ts:
--------------------------------------------------------------------------------
```typescript
import * as t from "@babel/types";
import traverse, { NodePath } from "@babel/traverse";
import { parse } from "@babel/parser";
import { extractJsxContent } from "./jsx-content";
import { describe, it, expect } from "vitest";
describe("JSX Content Utils", () => {
function parseJSX(code: string): t.File {
return parse(code, {
sourceType: "module",
plugins: ["jsx", "typescript"],
});
}
function getJSXElementPath(code: string): NodePath<t.JSXElement> {
const ast = parseJSX(code);
let elementPath: NodePath<t.JSXElement> | null = null;
traverse(ast, {
JSXElement(path) {
elementPath = path;
path.stop();
},
});
if (!elementPath) {
throw new Error("No JSX element found in the code");
}
return elementPath;
}
describe("extractJsxContent", () => {
describe("plain", () => {
it("should extract plain text content from JSX element", () => {
const path = getJSXElementPath("<div>Hello world</div>");
const content = extractJsxContent(path);
expect(content).toBe("Hello world");
});
it("should return empty string for elements with no content", () => {
const path = getJSXElementPath("<div></div>");
const content = extractJsxContent(path);
expect(content).toBe("");
});
});
describe("whitespaces", () => {
it("should handle multiple whitespaces", () => {
const path = getJSXElementPath("<div> Hello world </div>");
const content = extractJsxContent(path);
expect(content).toBe("Hello world");
});
it("should handle multi-line content with whitespaces", () => {
const path = getJSXElementPath("<div>\n Hello\n crazy world!</div>");
const content = extractJsxContent(path);
expect(content).toBe("Hello crazy world!");
});
it("should handle whitespaces between elements", () => {
const path = getJSXElementPath(
"<div>\n Hello <strong>crazy</strong> world! <Icons.Rocket /></div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"Hello <element:strong>crazy</element:strong> world! <element:Icons.Rocket></element:Icons.Rocket>",
);
});
it("should handle explicit whitespaces", () => {
const path = getJSXElementPath(
'<div>\n Hello{" "}<strong>crazy {" "}world</strong></div>',
);
const content = extractJsxContent(path);
expect(content).toBe(
"Hello <element:strong>crazy world</element:strong>",
);
});
it("should handle new lines between elements and explicit whitespaces", () => {
const path = getJSXElementPath(
'<div>\n Hello \n <strong>crazy</strong>\n <em>world</em>{" "}\n<u>forever</u></div>',
);
const content = extractJsxContent(path);
expect(content).toBe(
"Hello<element:strong>crazy</element:strong><element:em>world</element:em> <element:u>forever</element:u>",
);
});
});
describe("variables", () => {
it("should extract content with simple identifiers like {count}", () => {
const path = getJSXElementPath("<div>Items: {count}</div>");
const content = extractJsxContent(path);
expect(content).toBe("Items: {count}");
});
it("should handle multiple expressions", () => {
const path = getJSXElementPath(
"<div>{count} items in {category}</div>",
);
const content = extractJsxContent(path);
expect(content).toBe("{count} items in {category}");
});
it("should handle nested elements", () => {
const path = getJSXElementPath(
"<div>Total: <strong>{count}</strong> items</div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"Total: <element:strong>{count}</element:strong> items",
);
});
it("should handle object variables", () => {
const path = getJSXElementPath(
"<div>User: <strong>{user.profile.name}</strong> has {user.private.details.items.count} items</div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"User: <element:strong>{user.profile.name}</element:strong> has {user.private.details.items.count} items",
);
});
it("should handle dynamic variables", () => {
const path = getJSXElementPath(
"<div>User <strong>{data[currentUserType][currentUserIndex].name}</strong> has {items.counts[type]} items of type <em>{typeNames[type]}</em></div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"User <element:strong>{data[currentUserType][currentUserIndex].name}</element:strong> has {items.counts[type]} items of type <element:em>{typeNames[type]}</element:em>",
);
});
});
describe("nested elements", () => {
it("should handle multiple nested elements with correct indices", () => {
const path = getJSXElementPath(
"<div><strong>Hello</strong> and <em>welcome</em> to <code>my app</code></div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"<element:strong>Hello</element:strong> and <element:em>welcome</element:em> to <element:code>my app</element:code>",
);
});
it("should handle deeply nested elements", () => {
const path = getJSXElementPath(
"<div><a>Hello <strong>wonderful <i><b>very</b>nested</i></strong> world</a> of the <u>universe</u></div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"<element:a>Hello <element:strong>wonderful <element:i><element:b>very</element:b>nested</element:i></element:strong> world</element:a> of the <element:u>universe</element:u>",
);
});
});
describe("function calls", () => {
it("should extract function calls with placeholders", () => {
const path = getJSXElementPath(
"<div>Hello {getName(user)} you have {getCount()} items</div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"Hello <function:getName/> you have <function:getCount/> items",
);
});
it("should handle mixed function calls and variables", () => {
const path = getJSXElementPath(
"<div>{user.name} called {getFunction()} and {getData(user.id)}</div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"{user.name} called <function:getFunction/> and <function:getData/>",
);
});
it("should handle nested elements with function calls and variables", () => {
const path = getJSXElementPath(
'<div><strong>{formatName(getName(user))}</strong> has <a href="#"><em>{getCount()}</em> unread messages</a> and <em>{count} in total</em></div>',
);
const content = extractJsxContent(path);
expect(content).toBe(
"<element:strong><function:formatName/></element:strong> has <element:a><element:em><function:getCount/></element:em> unread messages</element:a> and <element:em>{count} in total</element:em>",
);
});
it("should handle functions with chained names", () => {
const path = getJSXElementPath(
"<div>{getCount()} items: {user.details.products.items.map((item) => item.value).filter(value => value > 0)}</div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"<function:getCount/> items: <function:user.details.products.items.map/>",
);
});
it("should handle multiple usages of the same function", () => {
const path = getJSXElementPath(
"<div>{getCount(foo)} is more than {getCount(bar)}</div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"<function:getCount/> is more than <function:getCount/>",
);
});
it("should handle function calls on classes with 'new' keyword", () => {
const path = getJSXElementPath(
"<div>© {new Date().getFullYear()} vitest</div>",
);
const content = extractJsxContent(path);
expect(content).toBe("© <function:Date.getFullYear/> vitest");
});
});
describe("expressions", () => {
it("should handle mixed content with expressions and text", () => {
const path = getJSXElementPath(
"<div>You have {count} new messages and {count * 2} total items.</div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"You have {count} new messages and <expression/> total items.",
);
});
it("should handle complex expressions", () => {
const path = getJSXElementPath(
"<div>{isAdmin ? 'Admin' : 'User'} - {items.filter(i => i.active).length > 0}</div>",
);
const content = extractJsxContent(path);
expect(content).toBe("<expression/> - <expression/>");
});
it("should handle mixed variables, functions and expressions", () => {
const path = getJSXElementPath(
"<div>{count + 1} by {user.name}, processed by {getName()} {length > 0}</div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"<expression/> by {user.name}, processed by <function:getName/> <expression/>",
);
});
it("should handle expressions in nested elements", () => {
const path = getJSXElementPath(
"<div><p>Count: {items.length + offset}</p><span>Active: {items.filter(i => i.active).length > 0}</span></div>",
);
const content = extractJsxContent(path);
expect(content).toBe(
"<element:p>Count: <expression/></element:p><element:span>Active: <expression/></element:span>",
);
});
});
});
});
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/typescript/index.ts:
--------------------------------------------------------------------------------
```typescript
import { parse } from "@babel/parser";
import _ from "lodash";
import babelTraverseModule from "@babel/traverse";
import type { NodePath } from "@babel/traverse";
import * as t from "@babel/types";
import babelGenerateModule from "@babel/generator";
import { ILoader } from "../_types";
import { createLoader } from "../_utils";
import { resolveCjsExport } from "./cjs-interop";
const traverse = resolveCjsExport(babelTraverseModule, "@babel/traverse");
const generate = resolveCjsExport(babelGenerateModule, "@babel/generator");
export default function createTypescriptLoader(): ILoader<
string,
Record<string, any>
> {
return createLoader({
pull: async (locale, input) => {
if (!input) {
return {};
}
const ast = parseTypeScript(input);
const extractedStrings = extractStringsFromDefaultExport(ast);
return extractedStrings;
},
push: async (
locale,
data,
originalInput,
defaultLocale,
pullInput,
pullOutput,
) => {
const ast = parseTypeScript(originalInput || "");
const finalData = _.merge({}, pullOutput, data);
updateStringsInDefaultExport(ast, finalData);
const { code } = generate(ast, {
jsescOption: {
minimal: true,
},
});
return code;
},
});
}
/**
* Parse TypeScript code into an AST
*/
function parseTypeScript(input: string) {
return parse(input, {
sourceType: "module",
plugins: ["typescript"],
});
}
/**
* Extract the localizable (string literal) content from the default export
* and return it as a nested object that mirrors the original structure.
*/
function extractStringsFromDefaultExport(ast: t.File): Record<string, any> {
let extracted: Record<string, any> = {};
traverse(ast, {
ExportDefaultDeclaration(path: NodePath<t.ExportDefaultDeclaration>) {
const { declaration } = path.node;
const decl = unwrapTSAsExpression(declaration);
if (t.isObjectExpression(decl)) {
extracted = objectExpressionToObject(decl);
} else if (t.isArrayExpression(decl)) {
extracted = arrayExpressionToArray(decl) as unknown as Record<
string,
any
>;
} else if (t.isIdentifier(decl)) {
// Handle: const foo = {...}; export default foo;
const binding = path.scope.bindings[decl.name];
if (
binding &&
t.isVariableDeclarator(binding.path.node) &&
binding.path.node.init
) {
const initRaw = binding.path.node.init;
const init = initRaw ? unwrapTSAsExpression(initRaw) : initRaw;
if (t.isObjectExpression(init)) {
extracted = objectExpressionToObject(init);
} else if (t.isArrayExpression(init)) {
extracted = arrayExpressionToArray(init) as unknown as Record<
string,
any
>;
}
}
}
},
});
return extracted;
}
/**
* Helper: unwraps nested TSAsExpression nodes (e.g. `obj as const`)
* to get to the underlying expression/node we care about.
*/
function unwrapTSAsExpression<T extends t.Node>(node: T): t.Node {
let current: t.Node = node;
// TSAsExpression is produced for `expr as const` assertions.
// We want to get to the underlying expression so that the rest of the
// loader logic can work unchanged.
// There could theoretically be multiple nested `as const` assertions, so we
// unwrap in a loop.
// eslint-disable-next-line no-constant-condition
while (t.isTSAsExpression(current)) {
current = current.expression;
}
return current;
}
/**
* Recursively converts an `ObjectExpression` into a plain JavaScript object that
* only contains the string-literal values we care about. Non-string primitives
* (numbers, booleans, etc.) are ignored.
*/
function objectExpressionToObject(
objectExpression: t.ObjectExpression,
): Record<string, any> {
const obj: Record<string, any> = {};
objectExpression.properties.forEach((prop) => {
if (!t.isObjectProperty(prop)) return;
const key = getPropertyKey(prop);
if (t.isStringLiteral(prop.value)) {
obj[key] = prop.value.value;
} else if (
t.isTemplateLiteral(prop.value) &&
prop.value.expressions.length === 0
) {
// Handle template literals without expressions as plain strings
obj[key] = prop.value.quasis[0].value.cooked ?? "";
} else if (t.isObjectExpression(prop.value)) {
const nested = objectExpressionToObject(prop.value);
if (Object.keys(nested).length > 0) {
obj[key] = nested;
}
} else if (t.isArrayExpression(prop.value)) {
const arr = arrayExpressionToArray(prop.value);
if (arr.length > 0) {
obj[key] = arr;
}
}
});
return obj;
}
/**
* Recursively converts an `ArrayExpression` into a JavaScript array that
* contains string literals and nested objects/arrays when relevant.
*/
function arrayExpressionToArray(arrayExpression: t.ArrayExpression): any[] {
const arr: any[] = [];
arrayExpression.elements.forEach((element) => {
if (!element) return; // holes in the array
if (t.isStringLiteral(element)) {
arr.push(element.value);
} else if (
t.isTemplateLiteral(element) &&
element.expressions.length === 0
) {
arr.push(element.quasis[0].value.cooked ?? "");
} else if (t.isObjectExpression(element)) {
const nestedObj = objectExpressionToObject(element);
arr.push(nestedObj);
} else if (t.isArrayExpression(element)) {
arr.push(arrayExpressionToArray(element));
}
});
return arr;
}
// ------------------ updating helpers (nested data) ------------------------
function updateStringsInDefaultExport(
ast: t.File,
data: Record<string, any>,
): boolean {
let modified = false;
traverse(ast, {
ExportDefaultDeclaration(path: NodePath<t.ExportDefaultDeclaration>) {
const { declaration } = path.node;
const decl = unwrapTSAsExpression(declaration);
if (t.isObjectExpression(decl)) {
modified = updateStringsInObjectExpression(decl, data) || modified;
} else if (t.isArrayExpression(decl)) {
if (Array.isArray(data)) {
modified = updateStringsInArrayExpression(decl, data) || modified;
}
} else if (t.isIdentifier(decl)) {
modified = updateStringsInExportedIdentifier(path, data) || modified;
}
},
});
return modified;
}
function updateStringsInObjectExpression(
objectExpression: t.ObjectExpression,
data: Record<string, any>,
): boolean {
let modified = false;
objectExpression.properties.forEach((prop) => {
if (!t.isObjectProperty(prop)) return;
const key = getPropertyKey(prop);
const incomingVal = data?.[key];
if (incomingVal === undefined) {
// nothing to update for this key
return;
}
if (t.isStringLiteral(prop.value) && typeof incomingVal === "string") {
if (prop.value.value !== incomingVal) {
prop.value.value = incomingVal;
modified = true;
}
} else if (
t.isTemplateLiteral(prop.value) &&
prop.value.expressions.length === 0 &&
typeof incomingVal === "string"
) {
const currentVal = prop.value.quasis[0].value.cooked ?? "";
if (currentVal !== incomingVal) {
// Replace the existing template literal with an updated one
prop.value.quasis[0].value.raw = incomingVal;
prop.value.quasis[0].value.cooked = incomingVal;
modified = true;
}
} else if (
t.isObjectExpression(prop.value) &&
typeof incomingVal === "object" &&
!Array.isArray(incomingVal)
) {
const subModified = updateStringsInObjectExpression(
prop.value,
incomingVal,
);
modified = subModified || modified;
} else if (t.isArrayExpression(prop.value) && Array.isArray(incomingVal)) {
const subModified = updateStringsInArrayExpression(
prop.value,
incomingVal,
);
modified = subModified || modified;
}
});
return modified;
}
function updateStringsInArrayExpression(
arrayExpression: t.ArrayExpression,
incoming: any[],
): boolean {
let modified = false;
arrayExpression.elements.forEach((element, index) => {
if (!element) return;
const incomingVal = incoming?.[index];
if (incomingVal === undefined) return;
if (t.isStringLiteral(element) && typeof incomingVal === "string") {
if (element.value !== incomingVal) {
element.value = incomingVal;
modified = true;
}
} else if (
t.isTemplateLiteral(element) &&
element.expressions.length === 0 &&
typeof incomingVal === "string"
) {
const currentVal = element.quasis[0].value.cooked ?? "";
if (currentVal !== incomingVal) {
element.quasis[0].value.raw = incomingVal;
element.quasis[0].value.cooked = incomingVal;
modified = true;
}
} else if (
t.isObjectExpression(element) &&
typeof incomingVal === "object" &&
!Array.isArray(incomingVal)
) {
const subModified = updateStringsInObjectExpression(element, incomingVal);
modified = subModified || modified;
} else if (t.isArrayExpression(element) && Array.isArray(incomingVal)) {
const subModified = updateStringsInArrayExpression(element, incomingVal);
modified = subModified || modified;
}
});
return modified;
}
function updateStringsInExportedIdentifier(
path: NodePath<t.ExportDefaultDeclaration>,
data: Record<string, any>,
): boolean {
const exportName = (path.node.declaration as t.Identifier).name;
const binding = path.scope.bindings[exportName];
if (!binding || !binding.path.node) return false;
if (t.isVariableDeclarator(binding.path.node) && binding.path.node.init) {
const initRaw = binding.path.node.init;
const init = initRaw ? unwrapTSAsExpression(initRaw) : initRaw;
if (t.isObjectExpression(init)) {
return updateStringsInObjectExpression(init, data);
} else if (t.isArrayExpression(init)) {
return updateStringsInArrayExpression(init, data as any[]);
}
}
return false;
}
/**
* Get the string key from an object property
*/
function getPropertyKey(prop: t.ObjectProperty): string {
if (t.isIdentifier(prop.key)) {
return prop.key.name;
} else if (t.isStringLiteral(prop.key)) {
return prop.key.value;
} else if (t.isNumericLiteral(prop.key)) {
return String(prop.key.value);
}
return String(prop.key);
}
```
--------------------------------------------------------------------------------
/packages/compiler/src/lib/lcp/cache.spec.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { resolve } from "path";
import { LCPCache, LCPCacheParams } from "./cache";
import * as fs from "fs";
import * as prettier from "prettier";
import { LCPSchema } from "./schema";
import { LCP_DICTIONARY_FILE_NAME } from "../../_const";
vi.mock("fs");
vi.mock("prettier");
// cached JSON is stored in JS file, we need to add export default to make it valid JS file
function toCachedString(cache: any) {
return `export default ${JSON.stringify(cache, null, 2)};`;
}
describe("LCPCache", () => {
const lcp: LCPSchema = {
version: 0.1,
files: {
"test.ts": {
scopes: {
key1: {
hash: "123",
},
newKey: {
hash: "111",
},
},
},
"old.ts": {
scopes: {
oldKey: {
hash: "456",
},
},
},
"new.ts": {
scopes: {
brandNew: {
hash: "222",
},
},
},
},
};
const params: LCPCacheParams = {
sourceRoot: ".",
lingoDir: ".lingo",
lcp,
};
const cachePath = resolve(
process.cwd(),
params.sourceRoot,
params.lingoDir,
LCP_DICTIONARY_FILE_NAME,
);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prettier.format).mockImplementation(
async (value: string) => value,
);
});
describe("readLocaleDictionary", () => {
it("returns empty dictionary when no cache exists", () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const dictionary = LCPCache.readLocaleDictionary("en", params);
expect(dictionary).toEqual({
version: 0.1,
locale: "en",
files: {},
});
});
it("returns empty dictionary when cache exists but has no entries for requested locale", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
toCachedString({
version: 0.1,
files: {
"test.ts": {
entries: {
key1: {
content: {
fr: "Bonjour",
},
},
},
},
},
}),
);
const dictionary = LCPCache.readLocaleDictionary("en", params);
expect(dictionary).toEqual({
version: 0.1,
locale: "en",
files: {},
});
});
it("returns dictionary entries with matching hashfor requested locale when cache exists", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
toCachedString({
version: 0.1,
files: {
"test.ts": {
entries: {
key1: {
content: {
en: "Hello",
fr: "Bonjour",
},
hash: "123",
},
newKey: {
content: {
en: "New",
fr: "Nouveau",
},
hash: "888",
},
},
},
"somewhere-else.ts": {
entries: {
somethingElse: {
content: {
en: "Something else",
fr: "Autre chose",
},
hash: "222",
},
},
},
},
}),
);
const dictionary = LCPCache.readLocaleDictionary("en", params);
expect(dictionary).toEqual({
version: 0.1,
locale: "en",
files: {
"new.ts": {
entries: {
brandNew: "Something else", // found in somewhere-else.ts under different key via matching hash
},
},
"test.ts": {
entries: {
key1: "Hello", // found in test.ts under the same key via matching hash
},
},
},
});
});
});
describe("writeLocaleDictionary", () => {
it("creates new cache when no cache exists", async () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.writeFileSync);
const dictionary = {
version: 0.1,
locale: "en",
files: {
"test.ts": {
entries: {
key1: "Hello",
},
},
},
};
await LCPCache.writeLocaleDictionary(dictionary, params);
expect(fs.writeFileSync).toHaveBeenCalledWith(
cachePath,
toCachedString({
version: 0.1,
files: {
"test.ts": {
entries: {
key1: {
content: {
en: "Hello",
},
hash: "123",
},
},
},
},
}),
);
});
it("adds new locale to existing cache", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
toCachedString({
version: 0.1,
files: {
"test.ts": {
entries: {
key1: {
content: {
en: "Hello",
},
hash: "123",
},
},
},
},
}),
);
vi.mocked(fs.writeFileSync);
const dictionary = {
version: 0.1,
locale: "fr",
files: {
"test.ts": {
entries: {
key1: "Bonjour",
},
},
},
};
await LCPCache.writeLocaleDictionary(dictionary, params);
expect(fs.writeFileSync).toHaveBeenCalledWith(
cachePath,
toCachedString({
version: 0.1,
files: {
"test.ts": {
entries: {
key1: {
content: {
en: "Hello",
fr: "Bonjour",
},
hash: "123",
},
},
},
},
}),
);
});
it("overrides existing locale entries in cache", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
toCachedString({
version: 0.1,
files: {
"test.ts": {
entries: {
key1: {
content: {
en: "Hello",
fr: "Bonjour",
},
hash: "123",
},
},
},
},
}),
);
vi.mocked(fs.writeFileSync);
const dictionary = {
version: 0.1,
locale: "en",
files: {
"test.ts": {
entries: {
key1: "Hi",
},
},
},
};
await LCPCache.writeLocaleDictionary(dictionary, params);
expect(fs.writeFileSync).toHaveBeenCalledWith(
cachePath,
toCachedString({
version: 0.1,
files: {
"test.ts": {
entries: {
key1: {
content: {
en: "Hi",
fr: "Bonjour",
},
hash: "123",
},
},
},
},
}),
);
});
it("handles different files and entries between cache and dictionary", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
toCachedString({
version: 0.1,
files: {
"old.ts": {
entries: {
oldKey: {
content: {
en: "Old",
fr: "Vieux",
},
hash: "456",
},
},
},
"test.ts": {
entries: {
key1: {
content: {
en: "Hello",
fr: "Bonjour",
},
hash: "123",
},
newKey: {
content: {
en: "New",
fr: "Nouveau",
},
hash: "111",
},
},
},
},
}),
);
vi.mocked(fs.writeFileSync);
const dictionary = {
version: 0.1,
locale: "en",
files: {
"test.ts": {
entries: {
key1: "Hi",
newKey: "Newer",
},
},
"new.ts": {
entries: {
brandNew: "Brand New",
},
},
},
};
await LCPCache.writeLocaleDictionary(dictionary, params);
expect(fs.writeFileSync).toHaveBeenCalledWith(
cachePath,
toCachedString({
version: 0.1,
files: {
"new.ts": {
entries: {
brandNew: {
content: {
en: "Brand New",
},
hash: "222",
},
},
},
"test.ts": {
entries: {
key1: {
content: {
en: "Hi",
fr: "Bonjour",
},
hash: "123",
},
newKey: {
content: {
en: "Newer",
fr: "Nouveau",
},
hash: "111",
},
},
},
},
}),
);
});
it("formats the cache with prettier", async () => {
vi.mocked(prettier.resolveConfig).mockResolvedValue({});
vi.mocked(prettier.format).mockResolvedValue("formatted");
const dictionary = {
version: 0.1,
locale: "en",
files: {
"test.ts": {
entries: {
key1: "Hi",
},
},
},
};
await LCPCache.writeLocaleDictionary(dictionary, params);
expect(prettier.resolveConfig).toHaveBeenCalledTimes(1);
expect(prettier.format).toHaveBeenCalledTimes(1);
expect(fs.writeFileSync).toHaveBeenCalledWith(cachePath, "formatted");
});
});
});
```
--------------------------------------------------------------------------------
/packages/cli/src/cli/cmd/run/execute.spec.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import pLimit from "p-limit";
/**
* Tests for the per-file I/O locking mechanism in execute.ts
*
* This tests the critical race condition fix where multiple concurrent tasks
* writing to the same file (e.g., xcode-xcstrings with multiple locales)
* could cause "Cannot convert undefined or null to object" errors.
*/
describe("execute.ts - Per-file I/O locking", () => {
describe("getFileIoLimiter", () => {
it("should create separate limiters for different files", () => {
const perFileIoLimiters = new Map();
const getFileIoLimiter = (bucketPathPattern: string) => {
const lockKey = bucketPathPattern;
if (!perFileIoLimiters.has(lockKey)) {
perFileIoLimiters.set(lockKey, pLimit(1));
}
return perFileIoLimiters.get(lockKey)!;
};
const limiter1 = getFileIoLimiter("example.xcstrings");
const limiter2 = getFileIoLimiter("messages.json");
const limiter3 = getFileIoLimiter("example.xcstrings");
// Same file should return same limiter instance
expect(limiter1).toBe(limiter3);
// Different files should have different limiters
expect(limiter1).not.toBe(limiter2);
});
it("should use pattern as-is without manipulation", () => {
const perFileIoLimiters = new Map();
const getFileIoLimiter = (bucketPathPattern: string) => {
const lockKey = bucketPathPattern;
if (!perFileIoLimiters.has(lockKey)) {
perFileIoLimiters.set(lockKey, pLimit(1));
}
return perFileIoLimiters.get(lockKey)!;
};
// Test various pattern formats
const patterns = [
"example.xcstrings", // Single-file, no locale
"src/[locale]/messages.json", // Multi-file with [locale]
"locales/[locale].json", // Multi-file with [locale]
"[locale]-config.json", // Multi-file starting with [locale]
"locale-data.json", // Contains word "locale" but not placeholder
];
const limiters = patterns.map((p) => getFileIoLimiter(p));
// All should be unique (no patterns accidentally grouped)
const uniqueLimiters = new Set(limiters);
expect(uniqueLimiters.size).toBe(patterns.length);
});
});
describe("Per-file serialization", () => {
it("should serialize I/O operations for the same file", async () => {
const perFileIoLimiters = new Map();
const getFileIoLimiter = (bucketPathPattern: string) => {
const lockKey = bucketPathPattern;
if (!perFileIoLimiters.has(lockKey)) {
perFileIoLimiters.set(lockKey, pLimit(1));
}
return perFileIoLimiters.get(lockKey)!;
};
const operations: { id: number; start: number; end: number }[] = [];
// Simulate 3 concurrent tasks writing to the same file
const tasks = [
{ id: 1, file: "example.xcstrings" },
{ id: 2, file: "example.xcstrings" },
{ id: 3, file: "example.xcstrings" },
];
await Promise.all(
tasks.map(async (task) => {
const limiter = getFileIoLimiter(task.file);
await limiter(async () => {
const start = Date.now();
await new Promise((resolve) => setTimeout(resolve, 50));
const end = Date.now();
operations.push({ id: task.id, start, end });
});
}),
);
// Verify operations were serialized (no overlap)
operations.sort((a, b) => a.start - b.start);
for (let i = 0; i < operations.length - 1; i++) {
const current = operations[i];
const next = operations[i + 1];
// Next operation should start after current ends (serialized)
expect(next.start).toBeGreaterThanOrEqual(current.end);
}
});
it("should allow concurrent I/O operations for different files", async () => {
const perFileIoLimiters = new Map();
const getFileIoLimiter = (bucketPathPattern: string) => {
const lockKey = bucketPathPattern;
if (!perFileIoLimiters.has(lockKey)) {
perFileIoLimiters.set(lockKey, pLimit(1));
}
return perFileIoLimiters.get(lockKey)!;
};
const operations: {
id: number;
file: string;
start: number;
end: number;
}[] = [];
// Simulate concurrent tasks writing to different files
const tasks = [
{ id: 1, file: "example.xcstrings" },
{ id: 2, file: "messages.json" },
{ id: 3, file: "strings.xml" },
];
await Promise.all(
tasks.map(async (task) => {
const limiter = getFileIoLimiter(task.file);
await limiter(async () => {
const start = Date.now();
await new Promise((resolve) => setTimeout(resolve, 50));
const end = Date.now();
operations.push({ id: task.id, file: task.file, start, end });
});
}),
);
// Verify that at least some operations overlapped (ran concurrently)
operations.sort((a, b) => a.start - b.start);
let hasOverlap = false;
for (let i = 0; i < operations.length - 1; i++) {
const current = operations[i];
const next = operations[i + 1];
// If next starts before current ends, they overlapped
if (next.start < current.end) {
hasOverlap = true;
break;
}
}
expect(hasOverlap).toBe(true);
});
});
describe("Race condition prevention", () => {
it("should prevent concurrent read/write race conditions", async () => {
const perFileIoLimiters = new Map();
const getFileIoLimiter = (bucketPathPattern: string) => {
const lockKey = bucketPathPattern;
if (!perFileIoLimiters.has(lockKey)) {
perFileIoLimiters.set(lockKey, pLimit(1));
}
return perFileIoLimiters.get(lockKey)!;
};
// Simulate a shared file state
let fileContent: Record<string, string> = {};
const operations: string[] = [];
// Multiple tasks reading and writing to the same file
const tasks = Array.from({ length: 5 }, (_, i) => ({
id: i + 1,
file: "example.xcstrings",
}));
await Promise.all(
tasks.map(async (task) => {
const limiter = getFileIoLimiter(task.file);
await limiter(async () => {
// Read
operations.push(
`Task ${task.id}: Read ${JSON.stringify(fileContent)}`,
);
const currentContent = { ...fileContent };
// Simulate processing
await new Promise((resolve) => setTimeout(resolve, 10));
// Write
currentContent[`key${task.id}`] = `value${task.id}`;
fileContent = currentContent;
operations.push(
`Task ${task.id}: Write ${JSON.stringify(fileContent)}`,
);
});
}),
);
// Verify all keys were written (no lost updates)
expect(Object.keys(fileContent).length).toBe(5); // 5 new keys
expect(fileContent).toHaveProperty("key1");
expect(fileContent).toHaveProperty("key2");
expect(fileContent).toHaveProperty("key3");
expect(fileContent).toHaveProperty("key4");
expect(fileContent).toHaveProperty("key5");
});
});
describe("Hints handling", () => {
it("should not block hints reading unnecessarily", async () => {
const perFileIoLimiters = new Map();
const getFileIoLimiter = (bucketPathPattern: string) => {
const lockKey = bucketPathPattern;
if (!perFileIoLimiters.has(lockKey)) {
perFileIoLimiters.set(lockKey, pLimit(1));
}
return perFileIoLimiters.get(lockKey)!;
};
const fileIoLimiter = getFileIoLimiter("example.xcstrings");
// Simulate the actual execution order
const sourceData = await fileIoLimiter(async () => {
// Simulate file read
await new Promise((resolve) => setTimeout(resolve, 10));
return { key1: "value1" };
});
const hints = await fileIoLimiter(async () => {
// Hints don't read file, just process in-memory data
return { key1: { hint: "hint1" } };
});
const targetData = await fileIoLimiter(async () => {
// Simulate file read
await new Promise((resolve) => setTimeout(resolve, 10));
return { key1: "translated1" };
});
// All should complete successfully
expect(sourceData).toEqual({ key1: "value1" });
expect(hints).toEqual({ key1: { hint: "hint1" } });
expect(targetData).toEqual({ key1: "translated1" });
});
});
describe("Edge cases", () => {
it("should handle empty pattern gracefully", () => {
const perFileIoLimiters = new Map();
const getFileIoLimiter = (bucketPathPattern: string) => {
const lockKey = bucketPathPattern;
if (!perFileIoLimiters.has(lockKey)) {
perFileIoLimiters.set(lockKey, pLimit(1));
}
return perFileIoLimiters.get(lockKey)!;
};
const limiter1 = getFileIoLimiter("");
const limiter2 = getFileIoLimiter("");
expect(limiter1).toBe(limiter2);
});
it("should handle patterns with special characters", () => {
const perFileIoLimiters = new Map();
const getFileIoLimiter = (bucketPathPattern: string) => {
const lockKey = bucketPathPattern;
if (!perFileIoLimiters.has(lockKey)) {
perFileIoLimiters.set(lockKey, pLimit(1));
}
return perFileIoLimiters.get(lockKey)!;
};
const patterns = [
"file with spaces.json",
"path/with/nested/dirs.json",
"файл-с-unicode.json",
"file-with-[brackets].json",
"file.with.dots.in.name.json",
];
patterns.forEach((pattern) => {
expect(() => getFileIoLimiter(pattern)).not.toThrow();
});
});
it("should maintain separate limiters across many files", () => {
const perFileIoLimiters = new Map();
const getFileIoLimiter = (bucketPathPattern: string) => {
const lockKey = bucketPathPattern;
if (!perFileIoLimiters.has(lockKey)) {
perFileIoLimiters.set(lockKey, pLimit(1));
}
return perFileIoLimiters.get(lockKey)!;
};
// Create limiters for 100 different files
const limiters = Array.from({ length: 100 }, (_, i) =>
getFileIoLimiter(`file${i}.json`),
);
// All should be unique
const uniqueLimiters = new Set(limiters);
expect(uniqueLimiters.size).toBe(100);
// Map should contain 100 entries
expect(perFileIoLimiters.size).toBe(100);
});
});
});
```