This is page 18 of 20. Use http://codebase.md/lingodotdev/lingo.dev?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .changeset
│ ├── config.json
│ └── README.md
├── .claude
│ ├── agents
│ │ └── code-architect-reviewer.md
│ └── commands
│ ├── analyze-bucket-type.md
│ └── create-bucket-docs.md
├── .editorconfig
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── docker.yml
│ ├── lingodotdev.yml
│ ├── pr-check.yml
│ ├── pr-lint.yml
│ └── release.yml
├── .gitignore
├── .husky
│ └── commit-msg
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .vscode
│ ├── extensions.json
│ ├── launch.json
│ └── settings.json
├── action.yml
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── commitlint.config.js
├── composer.json
├── content
│ ├── banner.compiler.png
│ ├── banner.dark.png
│ └── banner.launch.png
├── CONTRIBUTING.md
├── DEBUGGING.md
├── demo
│ ├── adonisjs
│ │ ├── .editorconfig
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── ace.js
│ │ ├── adonisrc.ts
│ │ ├── app
│ │ │ ├── exceptions
│ │ │ │ └── handler.ts
│ │ │ └── middleware
│ │ │ └── container_bindings_middleware.ts
│ │ ├── bin
│ │ │ ├── console.ts
│ │ │ ├── server.ts
│ │ │ └── test.ts
│ │ ├── CHANGELOG.md
│ │ ├── config
│ │ │ ├── app.ts
│ │ │ ├── bodyparser.ts
│ │ │ ├── cors.ts
│ │ │ ├── hash.ts
│ │ │ ├── inertia.ts
│ │ │ ├── logger.ts
│ │ │ ├── session.ts
│ │ │ ├── shield.ts
│ │ │ ├── static.ts
│ │ │ └── vite.ts
│ │ ├── eslint.config.js
│ │ ├── inertia
│ │ │ ├── app
│ │ │ │ ├── app.tsx
│ │ │ │ └── ssr.tsx
│ │ │ ├── css
│ │ │ │ └── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── pages
│ │ │ │ ├── errors
│ │ │ │ │ ├── not_found.tsx
│ │ │ │ │ └── server_error.tsx
│ │ │ │ └── home.tsx
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── resources
│ │ │ └── views
│ │ │ └── inertia_layout.edge
│ │ ├── start
│ │ │ ├── env.ts
│ │ │ ├── kernel.ts
│ │ │ └── routes.ts
│ │ ├── tests
│ │ │ └── bootstrap.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── next-app
│ │ ├── .gitignore
│ │ ├── CHANGELOG.md
│ │ ├── eslint.config.mjs
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── public
│ │ │ ├── file.svg
│ │ │ ├── globe.svg
│ │ │ ├── next.svg
│ │ │ ├── vercel.svg
│ │ │ └── window.svg
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── client-component.tsx
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── lingo-dot-dev.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── test
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── hero-actions.tsx
│ │ │ │ ├── hero-subtitle.tsx
│ │ │ │ ├── hero-title.tsx
│ │ │ │ └── index.ts
│ │ │ └── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ └── tsconfig.json
│ ├── react-router-app
│ │ ├── .dockerignore
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── app.css
│ │ │ ├── lingo
│ │ │ │ ├── dictionary.js
│ │ │ │ └── meta.json
│ │ │ ├── root.tsx
│ │ │ ├── routes
│ │ │ │ ├── home.tsx
│ │ │ │ └── test.tsx
│ │ │ ├── routes.ts
│ │ │ └── welcome
│ │ │ ├── lingo-dot-dev.tsx
│ │ │ ├── logo-dark.svg
│ │ │ ├── logo-light.svg
│ │ │ └── welcome.tsx
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ ├── public
│ │ │ └── favicon.ico
│ │ ├── react-router.config.ts
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── vite-project
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── README.md
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ └── test.tsx
│ │ ├── index.css
│ │ ├── lingo
│ │ │ ├── dictionary.js
│ │ │ └── meta.json
│ │ ├── lingo-dot-dev.tsx
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── Dockerfile
├── i18n.json
├── i18n.lock
├── integrations
│ └── directus
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── docker-compose.yml
│ ├── Dockerfile
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── api.ts
│ │ ├── app.ts
│ │ └── index.spec.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── ISSUE_TEMPLATE.md
├── legacy
│ ├── cli
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ └── readme.md
│ └── sdk
│ ├── CHANGELOG.md
│ ├── index.d.ts
│ ├── index.js
│ ├── package.json
│ └── README.md
├── LICENSE.md
├── mcp.md
├── package.json
├── packages
│ ├── cli
│ │ ├── assets
│ │ │ ├── failure.mp3
│ │ │ └── success.mp3
│ │ ├── bin
│ │ │ └── cli.mjs
│ │ ├── CHANGELOG.md
│ │ ├── demo
│ │ │ ├── android
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── csv
│ │ │ │ ├── example.csv
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── demo.spec.ts
│ │ │ ├── ejs
│ │ │ │ ├── en
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── es
│ │ │ │ │ └── example.ejs
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── flutter
│ │ │ │ ├── en
│ │ │ │ │ └── example.arb
│ │ │ │ ├── es
│ │ │ │ │ └── example.arb
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── html
│ │ │ │ ├── en
│ │ │ │ │ └── example.html
│ │ │ │ ├── es
│ │ │ │ │ └── example.html
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json
│ │ │ │ ├── en
│ │ │ │ │ └── example.json
│ │ │ │ ├── es
│ │ │ │ │ └── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json-dictionary
│ │ │ │ ├── example.json
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── json5
│ │ │ │ ├── en
│ │ │ │ │ └── example.json5
│ │ │ │ ├── es
│ │ │ │ │ └── example.json5
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── jsonc
│ │ │ │ ├── en
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── es
│ │ │ │ │ └── example.jsonc
│ │ │ │ ├── i18n.json
│ │ │ │ ├── i18n.lock
│ │ │ │ └── ru
│ │ │ │ └── example.jsonc
│ │ │ ├── markdoc
│ │ │ │ ├── en
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── es
│ │ │ │ │ └── example.markdoc
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── markdown
│ │ │ │ ├── en
│ │ │ │ │ └── example.md
│ │ │ │ ├── es
│ │ │ │ │ └── example.md
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── mdx
│ │ │ │ ├── en
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── es
│ │ │ │ │ └── example.mdx
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── php
│ │ │ │ ├── en
│ │ │ │ │ └── example.php
│ │ │ │ ├── es
│ │ │ │ │ └── example.php
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── po
│ │ │ │ ├── en
│ │ │ │ │ └── example.po
│ │ │ │ ├── es
│ │ │ │ │ └── example.po
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── properties
│ │ │ │ ├── en
│ │ │ │ │ └── example.properties
│ │ │ │ ├── es
│ │ │ │ │ └── example.properties
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── run_i18n.sh
│ │ │ ├── srt
│ │ │ │ ├── en
│ │ │ │ │ └── example.srt
│ │ │ │ ├── es
│ │ │ │ │ └── example.srt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── txt
│ │ │ │ ├── en
│ │ │ │ │ └── example.txt
│ │ │ │ ├── es
│ │ │ │ │ └── example.txt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── typescript
│ │ │ │ ├── en
│ │ │ │ │ └── example.ts
│ │ │ │ ├── es
│ │ │ │ │ └── example.ts
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vtt
│ │ │ │ ├── en
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── es
│ │ │ │ │ └── example.vtt
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── vue-json
│ │ │ │ ├── example.vue
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-strings
│ │ │ │ ├── en
│ │ │ │ │ └── example.strings
│ │ │ │ ├── es
│ │ │ │ │ └── example.strings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-stringsdict
│ │ │ │ ├── en
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── es
│ │ │ │ │ └── example.stringsdict
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xcode-xcstrings-v2
│ │ │ │ ├── complex-example.xcstrings
│ │ │ │ ├── example.xcstrings
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xliff
│ │ │ │ ├── en
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ └── example-v2.xliff
│ │ │ │ ├── es
│ │ │ │ │ ├── example-v1.2.xliff
│ │ │ │ │ ├── example-v2.xliff
│ │ │ │ │ └── example.xliff
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── xml
│ │ │ │ ├── en
│ │ │ │ │ └── example.xml
│ │ │ │ ├── es
│ │ │ │ │ └── example.xml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ ├── yaml
│ │ │ │ ├── en
│ │ │ │ │ └── example.yml
│ │ │ │ ├── es
│ │ │ │ │ └── example.yml
│ │ │ │ ├── i18n.json
│ │ │ │ └── i18n.lock
│ │ │ └── yaml-root-key
│ │ │ ├── en
│ │ │ │ └── example.yml
│ │ │ ├── es
│ │ │ │ └── example.yml
│ │ │ ├── i18n.json
│ │ │ └── i18n.lock
│ │ ├── i18n.json
│ │ ├── i18n.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── cli
│ │ │ │ ├── cmd
│ │ │ │ │ ├── auth.ts
│ │ │ │ │ ├── ci
│ │ │ │ │ │ ├── flows
│ │ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ │ ├── in-branch.ts
│ │ │ │ │ │ │ └── pull-request.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── platforms
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── bitbucket.ts
│ │ │ │ │ │ ├── github.ts
│ │ │ │ │ │ ├── gitlab.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── cleanup.ts
│ │ │ │ │ ├── config
│ │ │ │ │ │ ├── get.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── set.ts
│ │ │ │ │ │ └── unset.ts
│ │ │ │ │ ├── i18n.ts
│ │ │ │ │ ├── init.ts
│ │ │ │ │ ├── lockfile.ts
│ │ │ │ │ ├── login.ts
│ │ │ │ │ ├── logout.ts
│ │ │ │ │ ├── may-the-fourth.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── purge.ts
│ │ │ │ │ ├── run
│ │ │ │ │ │ ├── _const.ts
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── execute.spec.ts
│ │ │ │ │ │ ├── execute.ts
│ │ │ │ │ │ ├── frozen.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── plan.ts
│ │ │ │ │ │ ├── setup.ts
│ │ │ │ │ │ └── watch.ts
│ │ │ │ │ ├── show
│ │ │ │ │ │ ├── _shared-key-command.ts
│ │ │ │ │ │ ├── config.ts
│ │ │ │ │ │ ├── files.ts
│ │ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── locale.ts
│ │ │ │ │ │ └── locked-keys.ts
│ │ │ │ │ └── status.ts
│ │ │ │ ├── constants.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loaders
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── _utils.ts
│ │ │ │ │ ├── android.spec.ts
│ │ │ │ │ ├── android.ts
│ │ │ │ │ ├── csv.spec.ts
│ │ │ │ │ ├── csv.ts
│ │ │ │ │ ├── dato
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── api.ts
│ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ ├── filter.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── ejs.spec.ts
│ │ │ │ │ ├── ejs.ts
│ │ │ │ │ ├── ensure-key-order.spec.ts
│ │ │ │ │ ├── ensure-key-order.ts
│ │ │ │ │ ├── flat.spec.ts
│ │ │ │ │ ├── flat.ts
│ │ │ │ │ ├── flutter.spec.ts
│ │ │ │ │ ├── flutter.ts
│ │ │ │ │ ├── formatters
│ │ │ │ │ │ ├── _base.ts
│ │ │ │ │ │ ├── biome.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── prettier.ts
│ │ │ │ │ ├── html.ts
│ │ │ │ │ ├── icu-safety.spec.ts
│ │ │ │ │ ├── ignored-keys-buckets.spec.ts
│ │ │ │ │ ├── ignored-keys.spec.ts
│ │ │ │ │ ├── ignored-keys.ts
│ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── inject-locale.spec.ts
│ │ │ │ │ ├── inject-locale.ts
│ │ │ │ │ ├── json-dictionary.spec.ts
│ │ │ │ │ ├── json-dictionary.ts
│ │ │ │ │ ├── json-sorting.test.ts
│ │ │ │ │ ├── json-sorting.ts
│ │ │ │ │ ├── json.ts
│ │ │ │ │ ├── json5.spec.ts
│ │ │ │ │ ├── json5.ts
│ │ │ │ │ ├── jsonc.spec.ts
│ │ │ │ │ ├── jsonc.ts
│ │ │ │ │ ├── locked-keys.spec.ts
│ │ │ │ │ ├── locked-keys.ts
│ │ │ │ │ ├── locked-patterns.spec.ts
│ │ │ │ │ ├── locked-patterns.ts
│ │ │ │ │ ├── markdoc.spec.ts
│ │ │ │ │ ├── markdoc.ts
│ │ │ │ │ ├── markdown.ts
│ │ │ │ │ ├── mdx.spec.ts
│ │ │ │ │ ├── mdx.ts
│ │ │ │ │ ├── mdx2
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── _utils.ts
│ │ │ │ │ │ ├── code-placeholder.spec.ts
│ │ │ │ │ │ ├── code-placeholder.ts
│ │ │ │ │ │ ├── frontmatter-split.spec.ts
│ │ │ │ │ │ ├── frontmatter-split.ts
│ │ │ │ │ │ ├── localizable-document.spec.ts
│ │ │ │ │ │ ├── localizable-document.ts
│ │ │ │ │ │ ├── section-split.spec.ts
│ │ │ │ │ │ ├── section-split.ts
│ │ │ │ │ │ └── sections-split-2.ts
│ │ │ │ │ ├── passthrough.ts
│ │ │ │ │ ├── php.ts
│ │ │ │ │ ├── plutil-json-loader.ts
│ │ │ │ │ ├── po
│ │ │ │ │ │ ├── _types.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── properties.ts
│ │ │ │ │ ├── root-key.ts
│ │ │ │ │ ├── srt.ts
│ │ │ │ │ ├── sync.ts
│ │ │ │ │ ├── text-file.ts
│ │ │ │ │ ├── txt.ts
│ │ │ │ │ ├── typescript
│ │ │ │ │ │ ├── cjs-interop.ts
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── unlocalizable.spec.ts
│ │ │ │ │ ├── unlocalizable.ts
│ │ │ │ │ ├── variable
│ │ │ │ │ │ ├── index.spec.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── vtt.ts
│ │ │ │ │ ├── vue-json.ts
│ │ │ │ │ ├── xcode-strings
│ │ │ │ │ │ ├── escape.ts
│ │ │ │ │ │ ├── parser.ts
│ │ │ │ │ │ ├── tokenizer.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── xcode-strings.spec.ts
│ │ │ │ │ ├── xcode-strings.ts
│ │ │ │ │ ├── xcode-stringsdict.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-icu.ts
│ │ │ │ │ ├── xcode-xcstrings-lock-compatibility.spec.ts
│ │ │ │ │ ├── xcode-xcstrings-v2-loader.ts
│ │ │ │ │ ├── xcode-xcstrings.spec.ts
│ │ │ │ │ ├── xcode-xcstrings.ts
│ │ │ │ │ ├── xliff.spec.ts
│ │ │ │ │ ├── xliff.ts
│ │ │ │ │ ├── xml.ts
│ │ │ │ │ └── yaml.ts
│ │ │ │ ├── localizer
│ │ │ │ │ ├── _types.ts
│ │ │ │ │ ├── explicit.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingodotdev.ts
│ │ │ │ ├── processor
│ │ │ │ │ ├── _base.ts
│ │ │ │ │ ├── basic.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lingo.ts
│ │ │ │ └── utils
│ │ │ │ ├── auth.ts
│ │ │ │ ├── buckets.spec.ts
│ │ │ │ ├── buckets.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── cloudflare-status.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── delta.spec.ts
│ │ │ │ ├── delta.ts
│ │ │ │ ├── ensure-patterns.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── exec.spec.ts
│ │ │ │ ├── exec.ts
│ │ │ │ ├── exit-gracefully.spec.ts
│ │ │ │ ├── exit-gracefully.ts
│ │ │ │ ├── exp-backoff.ts
│ │ │ │ ├── find-locale-paths.spec.ts
│ │ │ │ ├── find-locale-paths.ts
│ │ │ │ ├── fs.ts
│ │ │ │ ├── init-ci-cd.ts
│ │ │ │ ├── key-matching.spec.ts
│ │ │ │ ├── key-matching.ts
│ │ │ │ ├── lockfile.ts
│ │ │ │ ├── md5.ts
│ │ │ │ ├── observability.ts
│ │ │ │ ├── plutil-formatter.spec.ts
│ │ │ │ ├── plutil-formatter.ts
│ │ │ │ ├── settings.ts
│ │ │ │ ├── ui.ts
│ │ │ │ └── update-gitignore.ts
│ │ │ ├── compiler
│ │ │ │ └── index.ts
│ │ │ ├── locale-codes
│ │ │ │ └── index.ts
│ │ │ ├── react
│ │ │ │ ├── client.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── react-router.ts
│ │ │ │ └── rsc.ts
│ │ │ ├── sdk
│ │ │ │ └── index.ts
│ │ │ └── spec
│ │ │ └── index.ts
│ │ ├── tests
│ │ │ └── mock-storage.ts
│ │ ├── troubleshooting.md
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ ├── tsup.config.ts
│ │ ├── types
│ │ │ ├── vtt.d.ts
│ │ │ └── xliff.d.ts
│ │ ├── vitest.config.ts
│ │ └── WATCH_MODE.md
│ ├── compiler
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── _base.ts
│ │ │ ├── _const.ts
│ │ │ ├── _loader-utils.spec.ts
│ │ │ ├── _loader-utils.ts
│ │ │ ├── _utils.spec.ts
│ │ │ ├── _utils.ts
│ │ │ ├── client-dictionary-loader.ts
│ │ │ ├── i18n-directive.spec.ts
│ │ │ ├── i18n-directive.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── jsx-attribute-flag.spec.ts
│ │ │ ├── jsx-attribute-flag.ts
│ │ │ ├── jsx-attribute-scope-inject.spec.ts
│ │ │ ├── jsx-attribute-scope-inject.ts
│ │ │ ├── jsx-attribute-scopes-export.spec.ts
│ │ │ ├── jsx-attribute-scopes-export.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-fragment.spec.ts
│ │ │ ├── jsx-fragment.ts
│ │ │ ├── jsx-html-lang.spec.ts
│ │ │ ├── jsx-html-lang.ts
│ │ │ ├── jsx-provider.spec.ts
│ │ │ ├── jsx-provider.ts
│ │ │ ├── jsx-remove-attributes.spec.ts
│ │ │ ├── jsx-remove-attributes.ts
│ │ │ ├── jsx-root-flag.spec.ts
│ │ │ ├── jsx-root-flag.ts
│ │ │ ├── jsx-scope-flag.spec.ts
│ │ │ ├── jsx-scope-flag.ts
│ │ │ ├── jsx-scope-inject.spec.ts
│ │ │ ├── jsx-scope-inject.ts
│ │ │ ├── jsx-scopes-export.spec.ts
│ │ │ ├── jsx-scopes-export.ts
│ │ │ ├── lib
│ │ │ │ └── lcp
│ │ │ │ ├── api
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prompt.spec.ts
│ │ │ │ │ ├── prompt.ts
│ │ │ │ │ ├── provider-details.spec.ts
│ │ │ │ │ ├── provider-details.ts
│ │ │ │ │ ├── shots.ts
│ │ │ │ │ ├── xml2obj.spec.ts
│ │ │ │ │ └── xml2obj.ts
│ │ │ │ ├── api.spec.ts
│ │ │ │ ├── cache.spec.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── server.spec.ts
│ │ │ │ └── server.ts
│ │ │ ├── lingo-turbopack-loader.ts
│ │ │ ├── react-router-dictionary-loader.ts
│ │ │ ├── rsc-dictionary-loader.ts
│ │ │ └── utils
│ │ │ ├── ast-key.spec.ts
│ │ │ ├── ast-key.ts
│ │ │ ├── create-locale-import-map.spec.ts
│ │ │ ├── create-locale-import-map.ts
│ │ │ ├── env.spec.ts
│ │ │ ├── env.ts
│ │ │ ├── hash.spec.ts
│ │ │ ├── hash.ts
│ │ │ ├── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── invokations.spec.ts
│ │ │ ├── invokations.ts
│ │ │ ├── jsx-attribute-scope.ts
│ │ │ ├── jsx-attribute.spec.ts
│ │ │ ├── jsx-attribute.ts
│ │ │ ├── jsx-content-whitespace.spec.ts
│ │ │ ├── jsx-content.spec.ts
│ │ │ ├── jsx-content.ts
│ │ │ ├── jsx-element.spec.ts
│ │ │ ├── jsx-element.ts
│ │ │ ├── jsx-expressions.test.ts
│ │ │ ├── jsx-expressions.ts
│ │ │ ├── jsx-functions.spec.ts
│ │ │ ├── jsx-functions.ts
│ │ │ ├── jsx-scope.spec.ts
│ │ │ ├── jsx-scope.ts
│ │ │ ├── jsx-variables.spec.ts
│ │ │ ├── jsx-variables.ts
│ │ │ ├── llm-api-key.ts
│ │ │ ├── llm-api-keys.spec.ts
│ │ │ ├── locales.spec.ts
│ │ │ ├── locales.ts
│ │ │ ├── module-params.spec.ts
│ │ │ ├── module-params.ts
│ │ │ ├── observability.spec.ts
│ │ │ ├── observability.ts
│ │ │ ├── rc.spec.ts
│ │ │ └── rc.ts
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ └── vitest.config.ts
│ ├── locales
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── names
│ │ │ │ ├── index.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── integration.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── parser.spec.ts
│ │ │ ├── parser.ts
│ │ │ ├── types.ts
│ │ │ ├── validation.spec.ts
│ │ │ └── validation.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── react
│ │ ├── build.config.ts
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── context.spec.tsx
│ │ │ │ ├── context.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── locale-switcher.spec.tsx
│ │ │ │ ├── locale-switcher.tsx
│ │ │ │ ├── locale.spec.ts
│ │ │ │ ├── locale.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── core
│ │ │ │ ├── attribute-component.spec.tsx
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── const.ts
│ │ │ │ ├── get-dictionary.spec.ts
│ │ │ │ ├── get-dictionary.ts
│ │ │ │ └── index.ts
│ │ │ ├── react-router
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ └── loader.ts
│ │ │ ├── rsc
│ │ │ │ ├── attribute-component.tsx
│ │ │ │ ├── component.lingo-component.spec.tsx
│ │ │ │ ├── component.spec.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.spec.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── provider.spec.tsx
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── utils.spec.ts
│ │ │ │ └── utils.ts
│ │ │ └── test
│ │ │ └── setup.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── sdk
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── abort-controller.specs.ts
│ │ │ ├── index.spec.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ └── tsup.config.ts
│ └── spec
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── config.spec.ts
│ │ ├── config.ts
│ │ ├── formats.ts
│ │ ├── index.spec.ts
│ │ ├── index.ts
│ │ ├── json-schema.ts
│ │ ├── locales.spec.ts
│ │ └── locales.ts
│ ├── tsconfig.json
│ ├── tsconfig.test.json
│ └── tsup.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── readme
│ ├── ar.md
│ ├── bn.md
│ ├── de.md
│ ├── en.md
│ ├── es.md
│ ├── fa.md
│ ├── fr.md
│ ├── he.md
│ ├── hi.md
│ ├── it.md
│ ├── ja.md
│ ├── ko.md
│ ├── pl.md
│ ├── pt-BR.md
│ ├── ru.md
│ ├── tr.md
│ ├── uk-UA.md
│ └── zh-Hans.md
├── readme.md
├── scripts
│ ├── docs
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── generate-cli-docs.ts
│ │ │ ├── generate-config-docs.ts
│ │ │ ├── json-schema
│ │ │ │ ├── markdown-renderer.test.ts
│ │ │ │ ├── markdown-renderer.ts
│ │ │ │ ├── parser.test.ts
│ │ │ │ ├── parser.ts
│ │ │ │ └── types.ts
│ │ │ ├── utils.test.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ └── packagist-publish.php
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/packages/cli/src/cli/loaders/android.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createRequire } from "node:module";
2 | import { parseStringPromise, type XmlDeclarationAttributes } from "xml2js";
3 | import { ILoader } from "./_types";
4 | import { CLIError } from "../utils/errors";
5 | import { createLoader } from "./_utils";
6 |
7 | interface SaxParser {
8 | onopentag: (node: {
9 | name: string;
10 | attributes: Record<string, string>;
11 | }) => void;
12 | onclosetag: (name: string) => void;
13 | ontext: (text: string) => void;
14 | oncdata: (cdata: string) => void;
15 | write(data: string): SaxParser;
16 | close(): SaxParser;
17 | }
18 |
19 | interface SaxModule {
20 | parser(
21 | strict: boolean,
22 | options?: { trim?: boolean; normalize?: boolean; lowercase?: boolean },
23 | ): SaxParser;
24 | }
25 |
26 | const require = createRequire(import.meta.url);
27 | const sax: SaxModule = require("sax") as SaxModule;
28 |
29 | const defaultAndroidResourcesXml = `<?xml version="1.0" encoding="utf-8"?>
30 | <resources>
31 | </resources>`;
32 |
33 | type AndroidResourceType =
34 | | "string"
35 | | "string-array"
36 | | "plurals"
37 | | "bool"
38 | | "integer";
39 |
40 | type PrimitiveValue = boolean | number | string;
41 |
42 | type ContentSegment =
43 | | { kind: "text"; value: string }
44 | | { kind: "cdata"; value: string };
45 |
46 | interface TextualMeta {
47 | segments: ContentSegment[];
48 | hasCdata: boolean;
49 | }
50 |
51 | interface ArrayItemMeta extends TextualMeta {
52 | quantity?: string;
53 | }
54 |
55 | interface StringResourceNode {
56 | type: "string";
57 | name: string;
58 | translatable: boolean;
59 | node: any;
60 | meta: TextualMeta;
61 | }
62 |
63 | interface StringArrayItemNode {
64 | node: any;
65 | meta: TextualMeta;
66 | }
67 |
68 | interface StringArrayResourceNode {
69 | type: "string-array";
70 | name: string;
71 | translatable: boolean;
72 | node: any;
73 | items: StringArrayItemNode[];
74 | }
75 |
76 | interface PluralsItemNode {
77 | node: any;
78 | quantity: string;
79 | meta: TextualMeta;
80 | }
81 |
82 | interface PluralsResourceNode {
83 | type: "plurals";
84 | name: string;
85 | translatable: boolean;
86 | node: any;
87 | items: PluralsItemNode[];
88 | }
89 |
90 | interface BoolResourceNode {
91 | type: "bool";
92 | name: string;
93 | translatable: boolean;
94 | node: any;
95 | meta: TextualMeta;
96 | }
97 |
98 | interface IntegerResourceNode {
99 | type: "integer";
100 | name: string;
101 | translatable: boolean;
102 | node: any;
103 | meta: TextualMeta;
104 | }
105 |
106 | type AndroidResourceNode =
107 | | StringResourceNode
108 | | StringArrayResourceNode
109 | | PluralsResourceNode
110 | | BoolResourceNode
111 | | IntegerResourceNode;
112 |
113 | interface AndroidDocument {
114 | resources: any;
115 | resourceNodes: AndroidResourceNode[];
116 | }
117 |
118 | interface XmlDeclarationOptions {
119 | xmldec?: XmlDeclarationAttributes;
120 | headless: boolean;
121 | }
122 |
123 | export default function createAndroidLoader(): ILoader<
124 | string,
125 | Record<string, any>
126 | > {
127 | return createLoader({
128 | async pull(locale, input) {
129 | try {
130 | if (!input) {
131 | return {};
132 | }
133 |
134 | const document = await parseAndroidDocument(input);
135 | return buildPullResult(document);
136 | } catch (error) {
137 | console.error("Error parsing Android resource file:", error);
138 | throw new CLIError({
139 | message: "Failed to parse Android resource file",
140 | docUrl: "androidResouceError",
141 | });
142 | }
143 | },
144 | async push(
145 | locale,
146 | payload,
147 | originalInput,
148 | originalLocale,
149 | pullInput,
150 | pullOutput,
151 | ) {
152 | try {
153 | const selectedBase = selectBaseXml(
154 | locale,
155 | originalLocale,
156 | pullInput,
157 | originalInput,
158 | );
159 |
160 | const existingDocument = await parseAndroidDocument(selectedBase);
161 | const sourceDocument = await parseAndroidDocument(originalInput);
162 | const translatedDocument = buildTranslatedDocument(
163 | payload,
164 | existingDocument,
165 | sourceDocument,
166 | );
167 |
168 | const referenceXml =
169 | selectedBase || originalInput || defaultAndroidResourcesXml;
170 | const declaration = resolveXmlDeclaration(referenceXml);
171 |
172 | return buildAndroidXml(translatedDocument, declaration);
173 | } catch (error) {
174 | console.error("Error generating Android resource file:", error);
175 | throw new CLIError({
176 | message: "Failed to generate Android resource file",
177 | docUrl: "androidResouceError",
178 | });
179 | }
180 | },
181 | });
182 | }
183 |
184 | function resolveXmlDeclaration(xml: string | null): XmlDeclarationOptions {
185 | if (!xml) {
186 | const xmldec: XmlDeclarationAttributes = {
187 | version: "1.0",
188 | encoding: "utf-8",
189 | };
190 | return {
191 | xmldec,
192 | headless: false,
193 | };
194 | }
195 |
196 | const match = xml.match(
197 | /<\?xml\s+version="([^"]+)"(?:\s+encoding="([^"]+)")?\s*\?>/,
198 | );
199 | if (match) {
200 | const version = match[1] && match[1].trim().length > 0 ? match[1] : "1.0";
201 | const encoding =
202 | match[2] && match[2].trim().length > 0 ? match[2] : undefined;
203 | const xmldec: XmlDeclarationAttributes = encoding
204 | ? { version, encoding }
205 | : { version };
206 | return {
207 | xmldec,
208 | headless: false,
209 | };
210 | }
211 |
212 | return { headless: true };
213 | }
214 |
215 | async function parseAndroidDocument(
216 | input?: string | null,
217 | ): Promise<AndroidDocument> {
218 | const xmlToParse =
219 | input && input.trim().length > 0 ? input : defaultAndroidResourcesXml;
220 |
221 | const parsed = await parseStringPromise(xmlToParse, {
222 | explicitArray: true,
223 | explicitChildren: true,
224 | preserveChildrenOrder: true,
225 | charsAsChildren: true,
226 | includeWhiteChars: true,
227 | mergeAttrs: false,
228 | normalize: false,
229 | normalizeTags: false,
230 | trim: false,
231 | attrkey: "$",
232 | charkey: "_",
233 | childkey: "$$",
234 | });
235 |
236 | if (!parsed || !parsed.resources) {
237 | return {
238 | resources: { $$: [] },
239 | resourceNodes: [],
240 | };
241 | }
242 |
243 | const resourcesNode = parsed.resources;
244 | resourcesNode["#name"] = resourcesNode["#name"] ?? "resources";
245 | resourcesNode.$$ = resourcesNode.$$ ?? [];
246 |
247 | const metadata = extractResourceMetadata(xmlToParse);
248 |
249 | const resourceNodes: AndroidResourceNode[] = [];
250 | let metaIndex = 0;
251 |
252 | for (const child of resourcesNode.$$ as any[]) {
253 | const elementName = child?.["#name"];
254 | if (!isResourceElementName(elementName)) {
255 | continue;
256 | }
257 |
258 | const meta = metadata[metaIndex++];
259 | if (!meta || meta.type !== elementName) {
260 | continue;
261 | }
262 |
263 | const name = child?.$?.name ?? meta.name;
264 | if (!name) {
265 | continue;
266 | }
267 |
268 | const translatable =
269 | (child?.$?.translatable ?? "").toLowerCase() !== "false";
270 |
271 | switch (meta.type) {
272 | case "string": {
273 | resourceNodes.push({
274 | type: "string",
275 | name,
276 | translatable,
277 | node: child,
278 | meta: cloneTextMeta(meta.meta),
279 | });
280 | break;
281 | }
282 | case "string-array": {
283 | const itemNodes = (child?.item ?? []) as any[];
284 | const items: StringArrayItemNode[] = [];
285 | const templateItems = meta.items;
286 |
287 | for (
288 | let i = 0;
289 | i < Math.max(itemNodes.length, templateItems.length);
290 | i++
291 | ) {
292 | const nodeItem = itemNodes[i];
293 | const templateItem =
294 | templateItems[i] ?? templateItems[templateItems.length - 1];
295 | if (!nodeItem) {
296 | continue;
297 | }
298 | items.push({
299 | node: nodeItem,
300 | meta: cloneTextMeta(templateItem.meta),
301 | });
302 | }
303 |
304 | resourceNodes.push({
305 | type: "string-array",
306 | name,
307 | translatable,
308 | node: child,
309 | items,
310 | });
311 | break;
312 | }
313 | case "plurals": {
314 | const itemNodes = (child?.item ?? []) as any[];
315 | const templateItems = meta.items;
316 | const items: PluralsItemNode[] = [];
317 |
318 | for (const templateItem of templateItems) {
319 | const quantity = templateItem.quantity;
320 | if (!quantity) {
321 | continue;
322 | }
323 | const nodeItem = itemNodes.find(
324 | (item: any) => item?.$?.quantity === quantity,
325 | );
326 | if (!nodeItem) {
327 | continue;
328 | }
329 | items.push({
330 | node: nodeItem,
331 | quantity,
332 | meta: cloneTextMeta(templateItem.meta),
333 | });
334 | }
335 |
336 | resourceNodes.push({
337 | type: "plurals",
338 | name,
339 | translatable,
340 | node: child,
341 | items,
342 | });
343 | break;
344 | }
345 | case "bool": {
346 | resourceNodes.push({
347 | type: "bool",
348 | name,
349 | translatable,
350 | node: child,
351 | meta: cloneTextMeta(meta.meta),
352 | });
353 | break;
354 | }
355 | case "integer": {
356 | resourceNodes.push({
357 | type: "integer",
358 | name,
359 | translatable,
360 | node: child,
361 | meta: cloneTextMeta(meta.meta),
362 | });
363 | break;
364 | }
365 | }
366 | }
367 |
368 | return { resources: resourcesNode, resourceNodes };
369 | }
370 |
371 | function buildPullResult(document: AndroidDocument): Record<string, any> {
372 | const result: Record<string, any> = {};
373 |
374 | for (const resource of document.resourceNodes) {
375 | if (!isTranslatable(resource)) {
376 | continue;
377 | }
378 |
379 | switch (resource.type) {
380 | case "string": {
381 | result[resource.name] = decodeAndroidText(
382 | segmentsToString(resource.meta.segments),
383 | );
384 | break;
385 | }
386 | case "string-array": {
387 | result[resource.name] = resource.items.map((item) =>
388 | decodeAndroidText(segmentsToString(item.meta.segments)),
389 | );
390 | break;
391 | }
392 | case "plurals": {
393 | const pluralMap: Record<string, string> = {};
394 | for (const item of resource.items) {
395 | pluralMap[item.quantity] = decodeAndroidText(
396 | segmentsToString(item.meta.segments),
397 | );
398 | }
399 | result[resource.name] = pluralMap;
400 | break;
401 | }
402 | case "bool": {
403 | const value = segmentsToString(resource.meta.segments).trim();
404 | result[resource.name] = value === "true";
405 | break;
406 | }
407 | case "integer": {
408 | const value = parseInt(
409 | segmentsToString(resource.meta.segments).trim(),
410 | 10,
411 | );
412 | result[resource.name] = Number.isNaN(value) ? 0 : value;
413 | break;
414 | }
415 | }
416 | }
417 |
418 | return result;
419 | }
420 |
421 | function isTranslatable(resource: AndroidResourceNode): boolean {
422 | return resource.translatable;
423 | }
424 |
425 | function buildTranslatedDocument(
426 | payload: Record<string, any>,
427 | existingDocument: AndroidDocument,
428 | sourceDocument: AndroidDocument,
429 | ): AndroidDocument {
430 | const templateDocument = sourceDocument;
431 | const finalDocument = cloneDocumentStructure(templateDocument);
432 |
433 | const templateMap = createResourceMap(templateDocument);
434 | const existingMap = createResourceMap(existingDocument);
435 | const payloadEntries = payload ?? {};
436 | const finalMap = createResourceMap(finalDocument);
437 |
438 | for (const resource of finalDocument.resourceNodes) {
439 | if (!resource.translatable) {
440 | continue;
441 | }
442 |
443 | const templateResource = templateMap.get(resource.name);
444 | let translationValue: any;
445 |
446 | if (
447 | Object.prototype.hasOwnProperty.call(payloadEntries, resource.name) &&
448 | payloadEntries[resource.name] !== undefined &&
449 | payloadEntries[resource.name] !== null
450 | ) {
451 | translationValue = payloadEntries[resource.name];
452 | } else if (existingMap.has(resource.name)) {
453 | translationValue = extractValueFromResource(
454 | existingMap.get(resource.name)!,
455 | );
456 | } else {
457 | translationValue = extractValueFromResource(templateResource ?? resource);
458 | }
459 |
460 | updateResourceNode(resource, translationValue, templateResource);
461 | }
462 |
463 | for (const resource of existingDocument.resourceNodes) {
464 | if (finalMap.has(resource.name)) {
465 | continue;
466 | }
467 | if (!isTranslatable(resource)) {
468 | continue;
469 | }
470 | const cloned = cloneResourceNode(resource);
471 | appendResourceNode(finalDocument, cloned);
472 | finalMap.set(cloned.name, cloned);
473 | }
474 |
475 | for (const [name, value] of Object.entries(payloadEntries)) {
476 | if (finalMap.has(name)) {
477 | continue;
478 | }
479 | try {
480 | const inferred = createResourceNodeFromValue(name, value);
481 | appendResourceNode(finalDocument, inferred);
482 | finalMap.set(name, inferred);
483 | } catch (error) {
484 | if (error instanceof CLIError) {
485 | throw error;
486 | }
487 | }
488 | }
489 |
490 | return finalDocument;
491 | }
492 |
493 | function buildAndroidXml(
494 | document: AndroidDocument,
495 | declaration: XmlDeclarationOptions,
496 | ): string {
497 | const xmlBody = serializeElement(document.resources);
498 |
499 | if (declaration.headless) {
500 | return xmlBody;
501 | }
502 |
503 | if (declaration.xmldec) {
504 | const { version, encoding } = declaration.xmldec;
505 | const encodingPart = encoding ? ` encoding="${encoding}"` : "";
506 | return `<?xml version="${version}"${encodingPart}?>\n${xmlBody}`;
507 | }
508 |
509 | return `<?xml version="1.0" encoding="utf-8"?>\n${xmlBody}`;
510 | }
511 |
512 | function selectBaseXml(
513 | locale: string,
514 | originalLocale: string,
515 | pullInput: string | null,
516 | originalInput: string | null,
517 | ): string | null {
518 | if (locale === originalLocale) {
519 | return pullInput ?? originalInput;
520 | }
521 | return pullInput ?? originalInput;
522 | }
523 |
524 | function updateResourceNode(
525 | target: AndroidResourceNode,
526 | rawValue: any,
527 | template: AndroidResourceNode | undefined,
528 | ): void {
529 | switch (target.type) {
530 | case "string": {
531 | const value = asString(rawValue, target.name);
532 | const templateMeta =
533 | template && template.type === "string" ? template.meta : target.meta;
534 | const useCdata = templateMeta.hasCdata;
535 | setTextualNodeContent(target.node, value, useCdata);
536 | target.meta = makeTextMeta([
537 | { kind: useCdata ? "cdata" : "text", value },
538 | ]);
539 | break;
540 | }
541 | case "string-array": {
542 | const values = asStringArray(rawValue, target.name);
543 | const templateItems =
544 | template && template.type === "string-array"
545 | ? template.items
546 | : target.items;
547 | const maxLength = Math.max(target.items.length, templateItems.length);
548 | for (let index = 0; index < maxLength; index++) {
549 | const targetItem = target.items[index];
550 | const templateItem =
551 | templateItems[index] ??
552 | templateItems[templateItems.length - 1] ??
553 | target.items[index];
554 | if (!targetItem || !templateItem) {
555 | continue;
556 | }
557 | const translation =
558 | index < values.length
559 | ? values[index]
560 | : segmentsToString(templateItem.meta.segments);
561 | const useCdata = templateItem.meta.hasCdata;
562 | setTextualNodeContent(targetItem.node, translation, useCdata);
563 | targetItem.meta = makeTextMeta([
564 | { kind: useCdata ? "cdata" : "text", value: translation },
565 | ]);
566 | }
567 | break;
568 | }
569 | case "plurals": {
570 | const pluralValues = asPluralMap(rawValue, target.name);
571 | const templateItems =
572 | template && template.type === "plurals" ? template.items : target.items;
573 | const templateMap = new Map(
574 | templateItems.map((item) => [item.quantity, item]),
575 | );
576 | for (const item of target.items) {
577 | const templateItem =
578 | templateMap.get(item.quantity) ?? templateMap.values().next().value;
579 | const fallback = templateItem
580 | ? segmentsToString(templateItem.meta.segments)
581 | : segmentsToString(item.meta.segments);
582 | const translation =
583 | typeof pluralValues[item.quantity] === "string"
584 | ? pluralValues[item.quantity]
585 | : fallback;
586 | const useCdata = templateItem
587 | ? templateItem.meta.hasCdata
588 | : item.meta.hasCdata;
589 | setTextualNodeContent(item.node, translation, useCdata);
590 | item.meta = makeTextMeta([
591 | { kind: useCdata ? "cdata" : "text", value: translation },
592 | ]);
593 | }
594 | break;
595 | }
596 | case "bool": {
597 | const boolValue = asBoolean(rawValue, target.name);
598 | const strValue = boolValue ? "true" : "false";
599 | setTextualNodeContent(target.node, strValue, false);
600 | target.meta = makeTextMeta([{ kind: "text", value: strValue }]);
601 | break;
602 | }
603 | case "integer": {
604 | const intValue = asInteger(rawValue, target.name);
605 | const strValue = intValue.toString();
606 | setTextualNodeContent(target.node, strValue, false);
607 | target.meta = makeTextMeta([{ kind: "text", value: strValue }]);
608 | break;
609 | }
610 | }
611 | }
612 |
613 | function appendResourceNode(
614 | document: AndroidDocument,
615 | resourceNode: AndroidResourceNode,
616 | ): void {
617 | document.resources.$$ = document.resources.$$ ?? [];
618 | const children = document.resources.$$ as any[];
619 |
620 | if (
621 | children.length === 0 ||
622 | (children[children.length - 1]["#name"] !== "__text__" &&
623 | children[children.length - 1]["#name"] !== "__comment__")
624 | ) {
625 | children.push({ "#name": "__text__", _: "\n " });
626 | }
627 |
628 | children.push(resourceNode.node);
629 | children.push({ "#name": "__text__", _: "\n" });
630 | document.resourceNodes.push(resourceNode);
631 | }
632 |
633 | function setTextualNodeContent(
634 | node: any,
635 | value: string,
636 | useCdata: boolean,
637 | ): void {
638 | // CDATA needs apostrophe escaping but not XML entity escaping
639 | const escapedValue = useCdata
640 | ? escapeApostrophesOnly(value)
641 | : escapeAndroidString(value);
642 | node._ = escapedValue;
643 |
644 | node.$$ = node.$$ ?? [];
645 | let textNode = node.$$.find(
646 | (child: any) =>
647 | child["#name"] === "__text__" || child["#name"] === "__cdata",
648 | );
649 |
650 | if (!textNode) {
651 | textNode = {};
652 | node.$$.push(textNode);
653 | }
654 |
655 | textNode["#name"] = useCdata ? "__cdata" : "__text__";
656 | textNode._ = escapedValue;
657 | }
658 |
659 | function buildResourceNameMap(
660 | document: AndroidDocument,
661 | ): Map<string, AndroidResourceNode> {
662 | const map = new Map<string, AndroidResourceNode>();
663 | for (const node of document.resourceNodes) {
664 | if (!map.has(node.name)) {
665 | map.set(node.name, node);
666 | }
667 | }
668 | return map;
669 | }
670 |
671 | function createResourceMap(
672 | document: AndroidDocument,
673 | ): Map<string, AndroidResourceNode> {
674 | return buildResourceNameMap(document);
675 | }
676 |
677 | function cloneResourceNode(resource: AndroidResourceNode): AndroidResourceNode {
678 | switch (resource.type) {
679 | case "string": {
680 | const nodeClone = deepClone(resource.node);
681 | return {
682 | type: "string",
683 | name: resource.name,
684 | translatable: resource.translatable,
685 | node: nodeClone,
686 | meta: cloneTextMeta(resource.meta),
687 | };
688 | }
689 | case "string-array": {
690 | const nodeClone = deepClone(resource.node);
691 | const itemNodes = (nodeClone.item ?? []) as any[];
692 | const items: StringArrayItemNode[] = itemNodes.map((itemNode, index) => {
693 | const templateMeta =
694 | resource.items[index]?.meta ??
695 | resource.items[resource.items.length - 1]?.meta ??
696 | makeTextMeta([]);
697 | return {
698 | node: itemNode,
699 | meta: cloneTextMeta(templateMeta),
700 | };
701 | });
702 | return {
703 | type: "string-array",
704 | name: resource.name,
705 | translatable: resource.translatable,
706 | node: nodeClone,
707 | items,
708 | };
709 | }
710 | case "plurals": {
711 | const nodeClone = deepClone(resource.node);
712 | const itemNodes = (nodeClone.item ?? []) as any[];
713 | const items: PluralsItemNode[] = [];
714 | for (const templateItem of resource.items) {
715 | const cloneNode = itemNodes.find(
716 | (item: any) => item?.$?.quantity === templateItem.quantity,
717 | );
718 | if (!cloneNode) {
719 | continue;
720 | }
721 | items.push({
722 | node: cloneNode,
723 | quantity: templateItem.quantity,
724 | meta: cloneTextMeta(templateItem.meta),
725 | });
726 | }
727 | return {
728 | type: "plurals",
729 | name: resource.name,
730 | translatable: resource.translatable,
731 | node: nodeClone,
732 | items,
733 | };
734 | }
735 | case "bool": {
736 | const nodeClone = deepClone(resource.node);
737 | return {
738 | type: "bool",
739 | name: resource.name,
740 | translatable: resource.translatable,
741 | node: nodeClone,
742 | meta: cloneTextMeta(resource.meta),
743 | };
744 | }
745 | case "integer": {
746 | const nodeClone = deepClone(resource.node);
747 | return {
748 | type: "integer",
749 | name: resource.name,
750 | translatable: resource.translatable,
751 | node: nodeClone,
752 | meta: cloneTextMeta(resource.meta),
753 | };
754 | }
755 | }
756 | }
757 |
758 | function cloneTextMeta(meta: TextualMeta): TextualMeta {
759 | return {
760 | hasCdata: meta.hasCdata,
761 | segments: meta.segments.map((segment) => ({ ...segment })),
762 | };
763 | }
764 |
765 | function asString(value: any, name: string): string {
766 | if (typeof value === "string") {
767 | return value;
768 | }
769 | throw new CLIError({
770 | message: `Expected string value for resource "${name}"`,
771 | docUrl: "androidResouceError",
772 | });
773 | }
774 |
775 | function asStringArray(value: any, name: string): string[] {
776 | if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
777 | return value;
778 | }
779 | throw new CLIError({
780 | message: `Expected array of strings for resource "${name}"`,
781 | docUrl: "androidResouceError",
782 | });
783 | }
784 |
785 | function asPluralMap(value: any, name: string): Record<string, string> {
786 | if (value && typeof value === "object" && !Array.isArray(value)) {
787 | const result: Record<string, string> = {};
788 | for (const [quantity, pluralValue] of Object.entries(value)) {
789 | if (typeof pluralValue !== "string") {
790 | throw new CLIError({
791 | message: `Expected plural item "${quantity}" of "${name}" to be a string`,
792 | docUrl: "androidResouceError",
793 | });
794 | }
795 | result[quantity] = pluralValue;
796 | }
797 | return result;
798 | }
799 | throw new CLIError({
800 | message: `Expected object value for plurals resource "${name}"`,
801 | docUrl: "androidResouceError",
802 | });
803 | }
804 |
805 | function asBoolean(value: any, name: string): boolean {
806 | if (typeof value === "boolean") {
807 | return value;
808 | }
809 | if (typeof value === "string") {
810 | if (value === "true" || value === "false") {
811 | return value === "true";
812 | }
813 | }
814 | throw new CLIError({
815 | message: `Expected boolean value for resource "${name}"`,
816 | docUrl: "androidResouceError",
817 | });
818 | }
819 |
820 | function asInteger(value: any, name: string): number {
821 | if (typeof value === "number" && Number.isInteger(value)) {
822 | return value;
823 | }
824 | throw new CLIError({
825 | message: `Expected number value for resource "${name}"`,
826 | docUrl: "androidResouceError",
827 | });
828 | }
829 |
830 | function escapeAndroidString(value: string): string {
831 | return value
832 | .replace(/&/g, "&")
833 | .replace(/</g, "<")
834 | .replace(/>/g, ">")
835 | .replace(/(?<!\\)'/g, "\\'");
836 | }
837 |
838 | function escapeApostrophesOnly(value: string): string {
839 | // Even inside CDATA, apostrophes must be escaped for Android AAPT
840 | return value.replace(/(?<!\\)'/g, "\\'");
841 | }
842 |
843 | function segmentsToString(segments: ContentSegment[]): string {
844 | return segments.map((segment) => segment.value).join("");
845 | }
846 |
847 | function makeTextMeta(segments: ContentSegment[]): TextualMeta {
848 | return {
849 | segments,
850 | hasCdata: segments.some((segment) => segment.kind === "cdata"),
851 | };
852 | }
853 |
854 | function createResourceNodeFromValue(
855 | name: string,
856 | value: any,
857 | ): AndroidResourceNode {
858 | const inferredType = inferTypeFromValue(value);
859 |
860 | switch (inferredType) {
861 | case "string": {
862 | const stringValue = asString(value, name);
863 | const escaped = escapeAndroidString(stringValue);
864 | const node = {
865 | "#name": "string",
866 | $: { name },
867 | _: escaped,
868 | $$: [{ "#name": "__text__", _: escaped }],
869 | };
870 | return {
871 | type: "string",
872 | name,
873 | translatable: true,
874 | node,
875 | meta: makeTextMeta([{ kind: "text", value: stringValue }]),
876 | };
877 | }
878 | case "string-array": {
879 | const items = asStringArray(value, name);
880 | const node = {
881 | "#name": "string-array",
882 | $: { name },
883 | $$: [] as any[],
884 | item: [] as any[],
885 | };
886 | const itemNodes: StringArrayItemNode[] = [];
887 | for (const itemValue of items) {
888 | const escaped = escapeAndroidString(itemValue);
889 | const itemNode = {
890 | "#name": "item",
891 | _: escaped,
892 | $$: [{ "#name": "__text__", _: escaped }],
893 | };
894 | node.$$!.push(itemNode);
895 | node.item!.push(itemNode);
896 | itemNodes.push({
897 | node: itemNode,
898 | meta: makeTextMeta([{ kind: "text", value: itemValue }]),
899 | });
900 | }
901 | return {
902 | type: "string-array",
903 | name,
904 | translatable: true,
905 | node,
906 | items: itemNodes,
907 | };
908 | }
909 | case "plurals": {
910 | const pluralMap = asPluralMap(value, name);
911 | const node = {
912 | "#name": "plurals",
913 | $: { name },
914 | $$: [] as any[],
915 | item: [] as any[],
916 | };
917 | const items: PluralsItemNode[] = [];
918 | for (const [quantity, pluralValue] of Object.entries(pluralMap)) {
919 | const escaped = escapeAndroidString(pluralValue);
920 | const itemNode = {
921 | "#name": "item",
922 | $: { quantity },
923 | _: escaped,
924 | $$: [{ "#name": "__text__", _: escaped }],
925 | };
926 | node.$$!.push(itemNode);
927 | node.item!.push(itemNode);
928 | items.push({
929 | node: itemNode,
930 | quantity,
931 | meta: makeTextMeta([{ kind: "text", value: pluralValue }]),
932 | });
933 | }
934 | return {
935 | type: "plurals",
936 | name,
937 | translatable: true,
938 | node,
939 | items,
940 | };
941 | }
942 | case "bool": {
943 | const boolValue = asBoolean(value, name);
944 | const textValue = boolValue ? "true" : "false";
945 | const node = {
946 | "#name": "bool",
947 | $: { name },
948 | _: textValue,
949 | $$: [{ "#name": "__text__", _: textValue }],
950 | };
951 | return {
952 | type: "bool",
953 | name,
954 | translatable: true,
955 | node,
956 | meta: makeTextMeta([{ kind: "text", value: textValue }]),
957 | };
958 | }
959 | case "integer": {
960 | const intValue = asInteger(value, name);
961 | const textValue = intValue.toString();
962 | const node = {
963 | "#name": "integer",
964 | $: { name },
965 | _: textValue,
966 | $$: [{ "#name": "__text__", _: textValue }],
967 | };
968 | return {
969 | type: "integer",
970 | name,
971 | translatable: true,
972 | node,
973 | meta: makeTextMeta([{ kind: "text", value: textValue }]),
974 | };
975 | }
976 | }
977 | }
978 |
979 | function cloneDocumentStructure(document: AndroidDocument): AndroidDocument {
980 | // Filter first - only keep translatable resources
981 | const translatableResources = document.resourceNodes.filter(isTranslatable);
982 |
983 | const resourcesClone = deepClone(document.resources);
984 | const lookup = buildResourceLookup(resourcesClone);
985 | const resourceNodes: AndroidResourceNode[] = [];
986 |
987 | for (const resource of translatableResources) {
988 | const cloned = cloneResourceNodeFromLookup(resource, lookup);
989 | resourceNodes.push(cloned);
990 | }
991 |
992 | // Clean up XML structure - only keep translatable resource nodes
993 | if (resourcesClone.$$ && Array.isArray(resourcesClone.$$)) {
994 | const includedKeys = new Set(
995 | resourceNodes.map((r) => resourceLookupKey(r.type, r.name)),
996 | );
997 |
998 | // Filter out non-translatable resources
999 | let filtered = resourcesClone.$$.filter((child: any) => {
1000 | const elementName = child?.["#name"];
1001 | const name = child?.$?.name;
1002 | if (!isResourceElementName(elementName) || !name) {
1003 | return true; // Keep whitespace, comments, etc.
1004 | }
1005 | return includedKeys.has(resourceLookupKey(elementName, name));
1006 | });
1007 |
1008 | // Remove consecutive whitespace nodes (fixes extra blank lines)
1009 | const cleaned: any[] = [];
1010 | let lastWasWhitespace = false;
1011 |
1012 | for (const child of filtered) {
1013 | const isWhitespace =
1014 | child?.["#name"] === "__text__" && (!child._ || child._.trim() === "");
1015 |
1016 | if (isWhitespace) {
1017 | if (!lastWasWhitespace) {
1018 | cleaned.push(child);
1019 | lastWasWhitespace = true;
1020 | }
1021 | // Skip consecutive whitespace
1022 | } else {
1023 | cleaned.push(child);
1024 | lastWasWhitespace = false;
1025 | }
1026 | }
1027 |
1028 | resourcesClone.$$ = cleaned;
1029 | }
1030 |
1031 | return {
1032 | resources: resourcesClone,
1033 | resourceNodes,
1034 | };
1035 | }
1036 |
1037 | function buildResourceLookup(resources: any): Map<string, any[]> {
1038 | const lookup = new Map<string, any[]>();
1039 | const children = Array.isArray(resources.$$) ? resources.$$ : [];
1040 | for (const child of children) {
1041 | const type = child?.["#name"];
1042 | const name = child?.$?.name;
1043 | if (!type || !name || !isResourceElementName(type)) {
1044 | continue;
1045 | }
1046 | const key = resourceLookupKey(type, name);
1047 | if (!lookup.has(key)) {
1048 | lookup.set(key, []);
1049 | }
1050 | lookup.get(key)!.push(child);
1051 | }
1052 | return lookup;
1053 | }
1054 |
1055 | function cloneResourceNodeFromLookup(
1056 | resource: AndroidResourceNode,
1057 | lookup: Map<string, any[]>,
1058 | ): AndroidResourceNode {
1059 | const node = takeResourceNode(lookup, resource.type, resource.name);
1060 | if (!node) {
1061 | return cloneResourceNode(resource);
1062 | }
1063 |
1064 | switch (resource.type) {
1065 | case "string": {
1066 | return {
1067 | type: "string",
1068 | name: resource.name,
1069 | translatable: resource.translatable,
1070 | node,
1071 | meta: cloneTextMeta(resource.meta),
1072 | };
1073 | }
1074 | case "string-array": {
1075 | const childItems = (Array.isArray(node.$$) ? node.$$ : []).filter(
1076 | (child: any) => child?.["#name"] === "item",
1077 | );
1078 | node.item = childItems;
1079 | if (childItems.length < resource.items.length) {
1080 | return cloneResourceNode(resource);
1081 | }
1082 | const items: StringArrayItemNode[] = resource.items.map((item, index) => {
1083 | const nodeItem = childItems[index];
1084 | if (!nodeItem) {
1085 | return {
1086 | node: deepClone(item.node),
1087 | meta: cloneTextMeta(item.meta),
1088 | };
1089 | }
1090 | return {
1091 | node: nodeItem,
1092 | meta: cloneTextMeta(item.meta),
1093 | };
1094 | });
1095 | return {
1096 | type: "string-array",
1097 | name: resource.name,
1098 | translatable: resource.translatable,
1099 | node,
1100 | items,
1101 | };
1102 | }
1103 | case "plurals": {
1104 | const childItems = (Array.isArray(node.$$) ? node.$$ : []).filter(
1105 | (child: any) => child?.["#name"] === "item",
1106 | );
1107 | node.item = childItems;
1108 | const itemMap = new Map<string, any>();
1109 | for (const item of childItems) {
1110 | if (item?.$?.quantity) {
1111 | itemMap.set(item.$.quantity, item);
1112 | }
1113 | }
1114 | const items: PluralsItemNode[] = [];
1115 | for (const templateItem of resource.items) {
1116 | const nodeItem = itemMap.get(templateItem.quantity);
1117 | if (!nodeItem) {
1118 | return cloneResourceNode(resource);
1119 | }
1120 | items.push({
1121 | node: nodeItem,
1122 | quantity: templateItem.quantity,
1123 | meta: cloneTextMeta(templateItem.meta),
1124 | });
1125 | }
1126 | return {
1127 | type: "plurals",
1128 | name: resource.name,
1129 | translatable: resource.translatable,
1130 | node,
1131 | items,
1132 | };
1133 | }
1134 | case "bool": {
1135 | return {
1136 | type: "bool",
1137 | name: resource.name,
1138 | translatable: resource.translatable,
1139 | node,
1140 | meta: cloneTextMeta(resource.meta),
1141 | };
1142 | }
1143 | case "integer": {
1144 | return {
1145 | type: "integer",
1146 | name: resource.name,
1147 | translatable: resource.translatable,
1148 | node,
1149 | meta: cloneTextMeta(resource.meta),
1150 | };
1151 | }
1152 | }
1153 | }
1154 |
1155 | function takeResourceNode(
1156 | lookup: Map<string, any[]>,
1157 | type: AndroidResourceType,
1158 | name: string,
1159 | ): any | undefined {
1160 | const key = resourceLookupKey(type, name);
1161 | const list = lookup.get(key);
1162 | if (!list || list.length === 0) {
1163 | return undefined;
1164 | }
1165 | return list.shift();
1166 | }
1167 |
1168 | function resourceLookupKey(type: string, name: string): string {
1169 | return `${type}:${name}`;
1170 | }
1171 |
1172 | function extractValueFromResource(resource: AndroidResourceNode): any {
1173 | switch (resource.type) {
1174 | case "string":
1175 | return decodeAndroidText(segmentsToString(resource.meta.segments));
1176 | case "string-array":
1177 | return resource.items.map((item) =>
1178 | decodeAndroidText(segmentsToString(item.meta.segments)),
1179 | );
1180 | case "plurals": {
1181 | const result: Record<string, string> = {};
1182 | for (const item of resource.items) {
1183 | result[item.quantity] = decodeAndroidText(
1184 | segmentsToString(item.meta.segments),
1185 | );
1186 | }
1187 | return result;
1188 | }
1189 | case "bool": {
1190 | const value = segmentsToString(resource.meta.segments).trim();
1191 | return value === "true";
1192 | }
1193 | case "integer": {
1194 | const value = parseInt(
1195 | segmentsToString(resource.meta.segments).trim(),
1196 | 10,
1197 | );
1198 | return Number.isNaN(value) ? 0 : value;
1199 | }
1200 | }
1201 | }
1202 |
1203 | function inferTypeFromValue(value: any): AndroidResourceType {
1204 | if (typeof value === "string") {
1205 | return "string";
1206 | }
1207 | if (Array.isArray(value)) {
1208 | return "string-array";
1209 | }
1210 | if (value && typeof value === "object") {
1211 | return "plurals";
1212 | }
1213 | if (typeof value === "boolean") {
1214 | return "bool";
1215 | }
1216 | if (typeof value === "number" && Number.isInteger(value)) {
1217 | return "integer";
1218 | }
1219 | throw new CLIError({
1220 | message: "Unable to infer Android resource type from payload",
1221 | docUrl: "androidResouceError",
1222 | });
1223 | }
1224 |
1225 | function extractResourceMetadata(xml: string) {
1226 | interface StackEntry {
1227 | name: string;
1228 | rawName: string;
1229 | attributes: Record<string, string>;
1230 | segments: ContentSegment[];
1231 | items: Array<{ quantity?: string; meta: TextualMeta }>;
1232 | }
1233 |
1234 | interface StringMeta {
1235 | type: "string";
1236 | name: string;
1237 | translatable: boolean;
1238 | meta: TextualMeta;
1239 | }
1240 |
1241 | interface StringArrayMeta {
1242 | type: "string-array";
1243 | name: string;
1244 | translatable: boolean;
1245 | items: Array<{ meta: TextualMeta }>;
1246 | }
1247 |
1248 | interface PluralsMeta {
1249 | type: "plurals";
1250 | name: string;
1251 | translatable: boolean;
1252 | items: Array<{ quantity: string; meta: TextualMeta }>;
1253 | }
1254 |
1255 | interface BoolMeta {
1256 | type: "bool";
1257 | name: string;
1258 | translatable: boolean;
1259 | meta: TextualMeta;
1260 | }
1261 |
1262 | interface IntegerMeta {
1263 | type: "integer";
1264 | name: string;
1265 | translatable: boolean;
1266 | meta: TextualMeta;
1267 | }
1268 |
1269 | type ResourceMeta =
1270 | | StringMeta
1271 | | StringArrayMeta
1272 | | PluralsMeta
1273 | | BoolMeta
1274 | | IntegerMeta;
1275 |
1276 | const parser = sax.parser(true, {
1277 | trim: false,
1278 | normalize: false,
1279 | lowercase: false,
1280 | });
1281 |
1282 | const stack: StackEntry[] = [];
1283 | const result: ResourceMeta[] = [];
1284 |
1285 | parser.onopentag = (node) => {
1286 | const lowerName = node.name.toLowerCase();
1287 | const attributes: Record<string, string> = {};
1288 | for (const [key, value] of Object.entries(node.attributes ?? {})) {
1289 | attributes[key.toLowerCase()] = String(value);
1290 | }
1291 | stack.push({
1292 | name: lowerName,
1293 | rawName: node.name,
1294 | attributes,
1295 | segments: [],
1296 | items: [],
1297 | });
1298 |
1299 | if (
1300 | lowerName !== "resources" &&
1301 | lowerName !== "item" &&
1302 | !isResourceElementName(lowerName)
1303 | ) {
1304 | const attrString = Object.entries(node.attributes ?? {})
1305 | .map(
1306 | ([key, value]) => ` ${key}="${escapeAttributeValue(String(value))}"`,
1307 | )
1308 | .join("");
1309 | appendSegmentToNearestResource(stack, {
1310 | kind: "text",
1311 | value: `<${node.name}${attrString}>`,
1312 | });
1313 | }
1314 | };
1315 |
1316 | parser.ontext = (text) => {
1317 | if (!text) {
1318 | return;
1319 | }
1320 | appendSegmentToNearestResource(stack, { kind: "text", value: text });
1321 | };
1322 |
1323 | parser.oncdata = (cdata) => {
1324 | appendSegmentToNearestResource(stack, { kind: "cdata", value: cdata });
1325 | };
1326 |
1327 | parser.onclosetag = () => {
1328 | const entry = stack.pop();
1329 | if (!entry) {
1330 | return;
1331 | }
1332 |
1333 | const parent = stack[stack.length - 1];
1334 |
1335 | if (entry.name === "item" && parent) {
1336 | const meta = makeTextMeta(entry.segments);
1337 | parent.items.push({
1338 | quantity: entry.attributes.quantity,
1339 | meta,
1340 | });
1341 | return;
1342 | }
1343 |
1344 | if (
1345 | entry.name !== "resources" &&
1346 | entry.name !== "item" &&
1347 | !isResourceElementName(entry.name)
1348 | ) {
1349 | appendSegmentToNearestResource(stack, {
1350 | kind: "text",
1351 | value: `</${entry.rawName}>`,
1352 | });
1353 | return;
1354 | }
1355 |
1356 | if (!isResourceElementName(entry.name)) {
1357 | return;
1358 | }
1359 |
1360 | const name = entry.attributes.name;
1361 | if (!name) {
1362 | return;
1363 | }
1364 |
1365 | const translatable =
1366 | (entry.attributes.translatable ?? "").toLowerCase() !== "false";
1367 |
1368 | switch (entry.name) {
1369 | case "string": {
1370 | result.push({
1371 | type: "string",
1372 | name,
1373 | translatable,
1374 | meta: makeTextMeta(entry.segments),
1375 | });
1376 | break;
1377 | }
1378 | case "string-array": {
1379 | result.push({
1380 | type: "string-array",
1381 | name,
1382 | translatable,
1383 | items: entry.items.map((item) => ({
1384 | meta: cloneTextMeta(item.meta),
1385 | })),
1386 | });
1387 | break;
1388 | }
1389 | case "plurals": {
1390 | const items: Array<{ quantity: string; meta: TextualMeta }> = [];
1391 | for (const item of entry.items) {
1392 | if (!item.quantity) {
1393 | continue;
1394 | }
1395 | items.push({
1396 | quantity: item.quantity,
1397 | meta: cloneTextMeta(item.meta),
1398 | });
1399 | }
1400 | result.push({
1401 | type: "plurals",
1402 | name,
1403 | translatable,
1404 | items,
1405 | });
1406 | break;
1407 | }
1408 | case "bool": {
1409 | result.push({
1410 | type: "bool",
1411 | name,
1412 | translatable,
1413 | meta: makeTextMeta(entry.segments),
1414 | });
1415 | break;
1416 | }
1417 | case "integer": {
1418 | result.push({
1419 | type: "integer",
1420 | name,
1421 | translatable,
1422 | meta: makeTextMeta(entry.segments),
1423 | });
1424 | break;
1425 | }
1426 | }
1427 | };
1428 |
1429 | parser.write(xml).close();
1430 |
1431 | return result;
1432 | }
1433 |
1434 | function appendSegmentToNearestResource(
1435 | stack: Array<{
1436 | name: string;
1437 | segments: ContentSegment[];
1438 | attributes: Record<string, string>;
1439 | }>,
1440 | segment: ContentSegment,
1441 | ) {
1442 | for (let index = stack.length - 1; index >= 0; index--) {
1443 | const entry = stack[index];
1444 | if (
1445 | entry.name === "string" ||
1446 | entry.name === "item" ||
1447 | entry.name === "bool" ||
1448 | entry.name === "integer"
1449 | ) {
1450 | entry.segments.push(segment);
1451 | return;
1452 | }
1453 | }
1454 | }
1455 |
1456 | function isResourceElementName(
1457 | value: string | undefined,
1458 | ): value is AndroidResourceType {
1459 | return (
1460 | value === "string" ||
1461 | value === "string-array" ||
1462 | value === "plurals" ||
1463 | value === "bool" ||
1464 | value === "integer"
1465 | );
1466 | }
1467 |
1468 | function deepClone<T>(value: T): T {
1469 | return value === undefined ? value : JSON.parse(JSON.stringify(value));
1470 | }
1471 |
1472 | function serializeElement(node: any): string {
1473 | if (!node) {
1474 | return "";
1475 | }
1476 |
1477 | const name = node["#name"] ?? "resources";
1478 |
1479 | if (name === "__text__") {
1480 | return node._ ?? "";
1481 | }
1482 |
1483 | if (name === "__cdata") {
1484 | return `<![CDATA[${node._ ?? ""}]]>`;
1485 | }
1486 |
1487 | if (name === "__comment__") {
1488 | return `<!--${node._ ?? ""}-->`;
1489 | }
1490 |
1491 | const attributes = node.$ ?? {};
1492 | const attrString = Object.entries(attributes)
1493 | .map(([key, value]) => ` ${key}="${escapeAttributeValue(String(value))}"`)
1494 | .join("");
1495 |
1496 | const children = Array.isArray(node.$$) ? node.$$ : [];
1497 |
1498 | if (children.length === 0) {
1499 | const textContent = node._ ?? "";
1500 | return `<${name}${attrString}>${textContent}</${name}>`;
1501 | }
1502 |
1503 | const childContent = children.map(serializeElement).join("");
1504 | return `<${name}${attrString}>${childContent}</${name}>`;
1505 | }
1506 |
1507 | function escapeAttributeValue(value: string): string {
1508 | return value
1509 | .replace(/&/g, "&")
1510 | .replace(/"/g, """)
1511 | .replace(/</g, "<")
1512 | .replace(/>/g, ">")
1513 | .replace(/'/g, "'");
1514 | }
1515 |
1516 | function decodeAndroidText(value: string): string {
1517 | return value.replace(/\\'/g, "'");
1518 | }
1519 |
```