diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 000000000..e5b6d8d6a --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 000000000..500af1667 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": ["@changesets/changelog-github", { "repo": "lingodotdev/lingo.dev" }], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch" +} diff --git a/.changeset/pink-lemons-buy.md b/.changeset/pink-lemons-buy.md new file mode 100644 index 000000000..d297779d7 --- /dev/null +++ b/.changeset/pink-lemons-buy.md @@ -0,0 +1,6 @@ +--- +"@lingo.dev/_compiler": minor +"lingo.dev": minor +--- + +Upgrade Compiler and CLI to AI SDK v5. diff --git a/.claude/agents/code-architect-reviewer.md b/.claude/agents/code-architect-reviewer.md new file mode 100644 index 000000000..b832d97d7 --- /dev/null +++ b/.claude/agents/code-architect-reviewer.md @@ -0,0 +1,60 @@ +--- +name: code-architect-reviewer +description: Use this agent when you need expert code review focusing on architectural quality, clean code principles, and best practices. Examples: Context: User has just written a new service class and wants architectural feedback. user: 'I just implemented a user authentication service. Can you review it?' assistant: 'I'll use the code-architect-reviewer agent to provide comprehensive architectural review of your authentication service.' Since the user is requesting code review with architectural focus, use the code-architect-reviewer agent to analyze the code structure, design patterns, and clean code adherence. Context: User has refactored a complex module and wants validation. user: 'I refactored the payment processing module to improve maintainability' assistant: 'Let me use the code-architect-reviewer agent to evaluate your refactoring and ensure it follows clean architecture principles.' The user has made architectural changes and needs expert validation, so use the code-architect-reviewer agent to assess the improvements. +tools: Task, Bash, Glob, Grep, LS, ExitPlanMode, Read, Edit, MultiEdit, Write, NotebookRead, NotebookEdit, WebFetch, TodoWrite, WebSearch +--- + +You are an Expert Software Architect and Code Reviewer with deep expertise in clean code principles, software design patterns, and architectural best practices. Your mission is to provide thorough, actionable code reviews that elevate code quality and maintainability. + +When reviewing code, you will: + +**Architectural Analysis:** + +- Evaluate overall code structure and organization +- Assess adherence to SOLID principles and design patterns +- Identify architectural smells and suggest improvements +- Review separation of concerns and modularity +- Analyze dependency management and coupling + +**Clean Code Assessment:** + +- Review naming conventions for clarity and expressiveness +- Evaluate function and class sizes (single responsibility) +- Check for code duplication and suggest DRY improvements +- Assess readability and self-documenting code practices +- Review error handling and edge case coverage + +**Best Practices Validation:** + +- Verify adherence to language-specific conventions +- Check for proper use of abstractions and interfaces +- Evaluate testing strategy and testability +- Review performance considerations and potential bottlenecks +- Assess security implications and vulnerabilities + +**Review Process:** + +1. First, understand the code's purpose and context +2. Analyze the overall architecture and design decisions +3. Examine implementation details for clean code violations +4. Identify specific improvement opportunities +5. Prioritize feedback by impact (critical, important, nice-to-have) +6. Provide concrete, actionable recommendations with examples + +**Feedback Format:** + +- Start with positive observations about good practices +- Organize feedback by category (Architecture, Clean Code, Performance, etc.) +- For each issue, explain the problem, why it matters, and how to fix it +- Provide code examples for suggested improvements when helpful +- End with a summary of key action items + +**Quality Standards:** + +- Be thorough but focus on the most impactful improvements +- Explain the reasoning behind each recommendation +- Consider maintainability, scalability, and team collaboration +- Balance perfectionism with pragmatism +- Encourage best practices while respecting project constraints + +You are not just identifying problems—you are mentoring developers toward architectural excellence and clean code mastery. diff --git a/.claude/commands/analyze-bucket-type.md b/.claude/commands/analyze-bucket-type.md new file mode 100644 index 000000000..4de6e49d9 --- /dev/null +++ b/.claude/commands/analyze-bucket-type.md @@ -0,0 +1,74 @@ +--- +argument-hint: +description: Analyze a bucket type implementation to identify all behaviors and configurations +--- + +Given the bucket type ID "$ARGUMENTS" (e.g., "json", "mdx", "typescript"), analyze the implementation code to identify ALL bucket-specific behaviors, configurations, and characteristics. + +## Instructions + +1. **Locate where this bucket type is processed** in the codebase by searching for the bucket type string. Start with the main loader composition/pipeline code. + +2. **Trace the complete execution pipeline** for this bucket: + + - List every function/loader in the processing chain, in order + - For each function/loader, read its implementation to understand: + - Input parameters it receives + - Transformations it performs on the data + - Output format it produces + - Any side effects or file operations + +3. **Identify configuration parameters** by: + + - Finding which variables are passed into the loaders (e.g., lockedKeys, ignoredKeys) + - Tracing these variables back to their source (configuration parsing) + - Determining if they're bucket-specific or universal + +4. **Analyze file I/O behavior**: + + - How are file paths constructed? + - Does the path pattern contain locale placeholders that would create separate files? + - What file operations are performed (read, write, create, delete)? + - Are files overwritten or are new files created? + - **IMPORTANT**: Note that "overwrites existing files completely" and "[locale] placeholder support" are mutually exclusive in practice: + - If a bucket type stores all locales in a single file (like CSV with columns per locale), it overwrites that single file and does NOT support `[locale]` placeholders + - If a bucket type creates separate files per locale using `[locale]` placeholders, each locale file is overwritten individually + - Clarify which pattern the bucket type follows + +5. **Examine data transformation logic**: + + - How is the file content parsed? + - What internal data structures are used? + - How is the data serialized back to file format? + - Are there any format-preserving mechanisms? + +6. **Identify special behaviors** by examining: + + - Conditional logic specific to this bucket + - Error handling unique to this format + - Any validation or normalization steps + - Interactions between multiple loaders in the pipeline + +7. **Determine constraints and capabilities**: + + - What data types/structures are supported? + - Are there any size or complexity limitations? + - What happens with edge cases (empty files, malformed content)? + +## Required Depth + +- Read the ACTUAL implementation of each loader/function +- Follow all function calls to understand the complete flow +- Don't make assumptions - verify behavior in the code +- Consider the order of operations in the pipeline + +## Output Format + +List all findings categorized as: + +- Configuration parameters (with their types and defaults) +- Processing pipeline (ordered list of transformations) +- File handling behavior +- Data transformation characteristics +- Special capabilities or limitations +- Edge case handling diff --git a/.claude/commands/create-bucket-docs.md b/.claude/commands/create-bucket-docs.md new file mode 100644 index 000000000..2d62aec71 --- /dev/null +++ b/.claude/commands/create-bucket-docs.md @@ -0,0 +1,303 @@ +--- +argument-hint: +description: Create documentation for a Lingo.dev bucket type using analysis output +--- + +Using the bucket analysis output provided at the end of this prompt, create documentation for the specified bucket type in Lingo.dev CLI. + +## Template Structure + +````markdown +--- +title: "[BUCKET_TYPE in title case]" +subtitle: "Translate [BUCKET_TYPE] files with Lingo.dev CLI" +--- + +## Introduction + +[BUCKET_TYPE in title case] files are [BRIEF DESCRIPTION OF THE FILE FORMAT, ITS PURPOSE AND PRIMARY USE CASE]. [ONE SENTENCE ABOUT STRUCTURE OR KEY CHARACTERISTICS]. + +**Lingo.dev CLI** uses LLMs to translate your [BUCKET_TYPE] files across multiple locales. This guide shows you how to set up and run translations for [BUCKET_TYPE] files. + +## Quickstart + +### Step 1: Install Lingo.dev CLI + +```bash +# Install globally +npm install -g lingo.dev@latest + +# Or run directly with npx +npx lingo.dev@latest --version +``` + +### Step 2: Authenticate + +Log in to your Lingo.dev account: + +```bash +npx lingo.dev@latest login +``` + +This opens your browser for authentication. Your API key is stored locally for future use. + +### Step 3: Initialize Project + +Create your base configuration: + +```bash +npx lingo.dev@latest init +``` + +This generates an `i18n.json` file with default settings. + +### Step 4: Configure [BUCKET_TYPE] Bucket + +Update your `i18n.json` to add [BUCKET_TYPE] support: + +```json +{ + "$schema": "https://lingo.dev/schema/i18n.json", + "version": "1.10", + "locale": { + "source": "en", + "targets": ["es"] + }, + "buckets": { + "[BUCKET_TYPE]": { + "include": ["[PATH_PATTERN]"] + } + } +} +``` + +[IF separate-files: **Note**: Keep `[locale]` as-is in the config — it's replaced with actual locale codes at runtime.] +[IF in-place: DO NOT include any note about [locale]] + +### Step 5: Create File Structure + +[FOR separate-files:] +Organize your [BUCKET_TYPE] files by locale: + +``` +[directory]/ +├── en/ +│ └── [filename] # Source file +└── es/ # Target directory (empty initially) +``` + +Place your source [BUCKET_TYPE] files in the `en/` directory. The `es/` directory can be empty — translated files will be created there automatically. + +[FOR in-place:] +Place your [BUCKET_TYPE] file in your project: + +``` +[directory]/ +└── [filename] # Contains all locales +``` + +This single file will contain translations for all configured locales. + +### Step 6: Run Translation + +Execute the translation command: + +```bash +npx lingo.dev@latest i18n +``` + +The CLI will: + +- Read [BUCKET_TYPE] files from your source locale +- Translate content to target locales using LLMs +- [FOR separate-files: Create new files in target directories (e.g., `es/[filename]`)] +- [FOR in-place: Update the file with translations for all configured locales] + +[FOR separate-files: **Note**: Unlike some bucket types that modify files in place, the [BUCKET_TYPE] bucket creates separate files for each locale. Your source files remain unchanged.] +[FOR in-place: **Note**: The [BUCKET_TYPE] bucket modifies the source file directly, adding translations for all target locales to the same file.] + +### Step 7: Verify Results + +Check the translation status: + +```bash +npx lingo.dev@latest status +``` + +[FOR separate-files: Review generated files in your target locale directory (`es/`).] +[FOR in-place: Review the updated [filename] file which now contains all locales.] + +## [Feature Sections - ONLY include supported features] + +[IF Locked Keys = YES:] + +## Locked Content + +The [BUCKET_TYPE] bucket supports locking specific keys to prevent translation: + +```json +"[BUCKET_TYPE]": { + "include": ["[PATH_PATTERN]"], + "lockedKeys": ["key1", "key2", "nested/key3"] +} +``` + +This feature is available for [BUCKET_TYPE] and other structured format buckets where specific keys need to remain untranslated. + +[IF Ignored Keys = YES:] + +## Ignored Keys + +The [BUCKET_TYPE] bucket supports ignoring keys entirely during processing: + +```json +"[BUCKET_TYPE]": { + "include": ["[PATH_PATTERN]"], + "ignoredKeys": ["debug", "internal/*"] +} +``` + +Unlike locked keys which preserve content, ignored keys are completely skipped during the translation process. + +[IF Inject Locale = YES:] + +## Inject Locale + +The [BUCKET_TYPE] bucket supports automatically injecting locale codes into specific keys: + +```json +"[BUCKET_TYPE]": { + "include": ["[PATH_PATTERN]"], + "injectLocale": ["settings/language", "config/locale"] +} +``` + +These keys will automatically have their values replaced with the current locale code during translation. + +[IF Translator Notes = YES:] + +## Translator Notes + +The [BUCKET_TYPE] bucket supports providing context hints to improve translation quality. [Describe how translator notes/hints work for this specific bucket type] + +```[format] +[Show example of how to add translator notes in this format] +``` + +## Example + +**Configuration** (`i18n.json`): + +```json +{ + "$schema": "https://lingo.dev/schema/i18n.json", + "version": "1.10", + "locale": { + "source": "en", + "targets": ["es"] + }, + "buckets": { + "[BUCKET_TYPE]": { + "include": ["[REALISTIC_PATH]"] + } + } +} +``` + +[FOR separate-files:] +**Input** (`[path]/en/[filename]`): + +```[format] +[Source content in appropriate format] +``` + +**Output** (`[path]/es/[filename]`): + +```[format] +[Translated content in appropriate format] +``` + +[FOR in-place:] +**Before translation** (`[path]/[filename]`): + +```[format] +[Source content showing only English] +``` + +**After translation** (`[path]/[filename]`): + +```[format] +[Same file now containing both English and Spanish] +``` +```` + +## Critical Adaptation Rules + +### For Separate-Files Buckets + +1. **Always use `[locale]` placeholder** in paths +2. Step 5: Show source (`en/`) and target (`es/`) directories +3. Step 6: Explain "creates new files" +4. Include the [locale] note in Step 4 +5. Example: Show input as `path/en/file.ext` and output as `path/es/file.ext` + +### For In-Place Buckets + +1. **Never use `[locale]` placeholder** anywhere in the document +2. **Never include the [locale] note** in Step 4 +3. Step 5: Show single file path +4. Step 6: Explain "modifies the file directly" +5. Example: Use "Before translation" and "After translation" labels +6. Example: Show the same file path for both states + +### Feature Sections + +- Only include sections for features marked YES +- Locked Keys: Content is preserved unchanged +- Ignored Keys: Keys are skipped entirely during processing +- Inject Locale: Keys automatically get the locale code as their value +- Translator Notes: Format varies significantly by bucket type + +### Path Conventions + +Choose realistic paths for the bucket type: + +- iOS: `ios/Resources/`, `[AppName]/` +- Android: `app/src/main/res/values-[locale]/` +- Web: `locales/`, `i18n/`, `translations/` +- Flutter: `lib/l10n/` +- Java: `src/main/resources/` + +### Writing Rules + +- Match the concise, direct tone of the template +- No marketing language or unnecessary adjectives +- Don't document what specifically gets translated +- Don't include generic features (exclude patterns, multiple directories) +- Focus only on bucket-specific behavior +- Use only `en` → `es` in all examples +- Keep examples minimal but representative + +## Instructions + +1. Parse the bucket analysis output provided in the arguments to determine: + + - Bucket type name + - File organization (separate-files if uses [locale] placeholder, in-place if not) + - Supported features (lockedKeys, ignoredKeys, injectLocale, hints/notes) + - Typical file extension and paths + +2. Based on the analysis, fill in the template with appropriate: + + - Description of the file format + - Realistic path patterns + - Only the features that are actually supported + - Appropriate examples for the format + +3. Generate the complete Markdown documentation following the specifications exactly. + +--- + +## Bucket Analysis Output + +$ARGUMENTS diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..7a55a5090 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..b8bc2078c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,57 @@ +name: 🐛 Bug Report +description: Report a bug with detailed reproduction steps +body: + - type: markdown + attributes: + value: | + **Reproducibility is critical.** Provide exact steps so maintainers can reproduce instantly. + + - type: textarea + id: description + attributes: + label: What's happening? + placeholder: Describe the bug in 1-2 sentences + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Exact Reproduction Steps + description: Copy-pasteable commands only + placeholder: | + 1. Run command + 2. Change config/file + 3. Run another command + 4. See error + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected + placeholder: What should happen + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual + placeholder: What actually happens + validations: + required: true + + - type: textarea + id: visuals + attributes: + label: Screenshots/Videos + description: "**REQUIRED** - Visual proof of the issue" + placeholder: Drag screenshot/screencast here + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 000000000..28099a791 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,46 @@ +name: 📚 Documentation Issue +description: Report docs problems with screenshots +body: + - type: dropdown + id: type + attributes: + label: Issue Type + options: + - Broken link + - Typo/grammar + - Missing docs + - Outdated content + - Unclear explanation + validations: + required: true + + - type: input + id: url + attributes: + label: URL + placeholder: "https://lingo.dev/..." + validations: + required: true + + - type: textarea + id: issue + attributes: + label: Issue + placeholder: What's wrong? + validations: + required: true + + - type: textarea + id: fix + attributes: + label: Fix + placeholder: How to fix it? + + - type: textarea + id: screenshot + attributes: + label: Screenshot + description: "**REQUIRED** - Show the issue" + placeholder: Drag screenshot here + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..c4c1389e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,33 @@ +name: ✨ Feature Request +description: Suggest a feature with clear use case +body: + - type: textarea + id: problem + attributes: + label: Problem + placeholder: What can't you do today? + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Solution + placeholder: How should it work? + validations: + required: true + + - type: textarea + id: visuals + attributes: + label: Visuals + description: "**REQUIRED** - Show mockup/example" + placeholder: Drag screenshot/diagram here + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Workarounds + placeholder: What have you tried? diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..af97a4d95 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 + +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..6392e987c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,31 @@ +## Summary + +[One sentence: what this PR does] + +## Changes + +- [Key change 1] +- [Key change 2] + +## Testing + +**Business logic tests added:** + +- [ ] [Describe test 1 - what behavior it validates] +- [ ] [Describe test 2 - what edge case it covers] +- [ ] All tests pass locally + +## Visuals + +**Required for UI/UX changes:** + +- [ ] Before/after screenshots attached +- [ ] Video demo for interactions (< 30s) + +## Checklist + +- [ ] Changeset added (if version bump needed) +- [ ] Tests cover business logic (not just happy path) +- [ ] No breaking changes (or documented below) + +Closes #[issue-number] diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..0761c5129 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,33 @@ +name: Build Docker Image + +on: + workflow_dispatch: + push: + paths: + - Dockerfile + branches: + - main + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Docker image and push + uses: docker/build-push-action@v6 + with: + push: true + platforms: linux/amd64 + context: ./ + file: ./Dockerfile + tags: ${{ secrets.DOCKERHUB_USERNAME }}/ci-action:latest diff --git a/.github/workflows/lingodotdev.yml b/.github/workflows/lingodotdev.yml new file mode 100644 index 000000000..7f2d6bdbc --- /dev/null +++ b/.github/workflows/lingodotdev.yml @@ -0,0 +1,65 @@ +name: "Lingo.dev" + +on: + workflow_dispatch: + inputs: + version: + description: "Lingo.dev CLI version" + default: "latest" + required: false + pull-request: + description: "Create a pull request with the changes" + type: boolean + default: false + required: false + commit-message: + description: "Commit message" + default: "feat: update translations via @LingoDotDev" + required: false + pull-request-title: + description: "Pull request title" + default: "feat: update translations via @LingoDotDev" + required: false + working-directory: + description: "Working directory" + default: "." + required: false + process-own-commits: + description: "Process commits made by this action" + type: boolean + default: false + required: false + parallel: + description: "Run in parallel mode" + type: boolean + default: false + required: false + +jobs: + lingodotdev: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Lingo.dev + uses: ./ + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} + version: ${{ inputs.version }} + pull-request: ${{ inputs['pull-request'] }} + commit-message: ${{ inputs['commit-message'] }} + pull-request-title: ${{ inputs['pull-request-title'] }} + working-directory: ${{ inputs['working-directory'] }} + process-own-commits: ${{ inputs['process-own-commits'] }} + parallel: ${{ inputs.parallel }} + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/pr-assignment-check.yml b/.github/workflows/pr-assignment-check.yml new file mode 100644 index 000000000..b8fbdcfd7 --- /dev/null +++ b/.github/workflows/pr-assignment-check.yml @@ -0,0 +1,43 @@ +name: PR Assignment Check + +on: + pull_request_target: + types: [opened, reopened, edited] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to check" + required: true + type: number + +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.CLAUDE_CODE_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + show_full_output: true + prompt: | + EXEMPT USERS: @maxprilutskiy, @vrcprl, @davidturnbull, @monicabe, @sumitsaurabh927, @AleksandrSl, @cherkanovart, @AndreyHirsa, @ohmoses (skip all checks if PR author is one of these) + + Close this PR if: + 1. It doesn't reference any issue (no "Closes #123", "Fixes #456", etc.) + 2. OR it references an issue where the PR author is NOT assigned + + When closing, leave a brief, friendly comment (2-3 sentences, no emojis) explaining: + - They need to reference an assigned issue or get assigned first + - They should find unassigned issues to work on + + Otherwise, do nothing. + claude_args: | + --allowedTools "Bash,Read,Grep,Glob" diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 000000000..03edca0c6 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,117 @@ +name: Check PR + +on: + workflow_dispatch: + pull_request: + types: + - opened + - edited + - synchronize + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{github.event.pull_request.head.sha}} + fetch-depth: 0 + + - name: Check for [skip i18n] + run: | + COMMIT_MESSAGE=$(git log -1 --pretty=%B) + if echo "$COMMIT_MESSAGE" | grep -iq '\[skip i18n\]'; then + echo "Skipping i18n checks due to [skip i18n] in commit message." + exit 0 + fi + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install pnpm + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + version: 9.12.3 + run_install: false + + - name: Configure pnpm cache + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install deps + run: pnpm install + + - name: Setup + run: | + pnpm turbo telemetry disable + + - name: Configure Turbo cache + uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Build + run: pnpm turbo build --force + + - name: Test + run: pnpm turbo test --force + + - name: Require changeset to be present in PR + if: github.event.pull_request.user.login != 'dependabot[bot]' + run: pnpm changeset status --since origin/main + + compiler-e2e: + needs: check + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{github.event.pull_request.head.sha}} + fetch-depth: 0 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.12.3 + + - name: Configure pnpm cache + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Install Playwright Browsers + run: pnpm exec playwright install chromium --with-deps + working-directory: packages/new-compiler + + - name: Configure Turbo cache + uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Run E2E tests + run: pnpm turbo run test:e2e --filter=./packages/new-compiler diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml new file mode 100644 index 000000000..5aff8c9a3 --- /dev/null +++ b/.github/workflows/pr-lint.yml @@ -0,0 +1,20 @@ +name: Lint PR + +on: + pull_request: + types: + - opened + - edited + - synchronize + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-merge-conflict-check.yml b/.github/workflows/pr-merge-conflict-check.yml new file mode 100644 index 000000000..ddbbb9c6b --- /dev/null +++ b/.github/workflows/pr-merge-conflict-check.yml @@ -0,0 +1,59 @@ +name: Check for Merge Conflicts + +on: + pull_request: + types: [opened, synchronize, reopened] + # Also run when the base branch is updated + push: + branches: + - main + +jobs: + check-merge-conflict: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Check for merge conflicts + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + // Get the PR details to check mergeable state + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + + console.log(`PR #${pr.number} mergeable state: ${pullRequest.mergeable_state}`); + console.log(`PR #${pr.number} mergeable: ${pullRequest.mergeable}`); + + // The mergeable field can be: true, false, or null (if GitHub is still calculating) + if (pullRequest.mergeable === null) { + console.log('GitHub is still calculating mergeable status. Waiting...'); + // Wait a bit and try again + await new Promise(resolve => setTimeout(resolve, 5000)); + + const { data: updatedPR } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + + console.log(`Updated mergeable state: ${updatedPR.mergeable_state}`); + console.log(`Updated mergeable: ${updatedPR.mergeable}`); + + if (updatedPR.mergeable === false) { + core.setFailed(`❌ PR #${pr.number} has merge conflicts that must be resolved.`); + } else if (updatedPR.mergeable === null) { + core.warning('⚠️ Could not determine merge conflict status. GitHub may still be calculating.'); + } else { + console.log(`✅ PR #${pr.number} has no merge conflicts.`); + } + } else if (pullRequest.mergeable === false) { + core.setFailed(`❌ PR #${pr.number} has merge conflicts that must be resolved.`); + } else { + console.log(`✅ PR #${pr.number} has no merge conflicts.`); + } diff --git a/.github/workflows/pr-stale-check.yml b/.github/workflows/pr-stale-check.yml new file mode 100644 index 000000000..5032b2d84 --- /dev/null +++ b/.github/workflows/pr-stale-check.yml @@ -0,0 +1,50 @@ +name: PR Stale Check + +on: + schedule: + - cron: "0 0 * * 1" # Every Monday at midnight UTC + workflow_dispatch: + +jobs: + check-stale: + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.CLAUDE_CODE_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + show_full_output: true + prompt: | + EXEMPT USERS: @maxprilutskiy, @vrcprl, @davidturnbull, @monicabe, @sumitsaurabh927, @AleksandrSl + + Check all open pull requests and for each PR: + + 1. Analyze the conversation to determine: + - Are there unaddressed comments/requests from maintainers? + - Has the PR author responded or made updates recently? + - Is the ball in the author's court or are they waiting on maintainers? + - Has a stale reminder been posted already (look for "still working on this" or similar language in bot comments)? + + 2. Decision logic: + - If author is waiting on maintainers: SKIP + - If author responded recently (within 7 days): SKIP + - If PR author is in exempt users list: SKIP + - If there are unaddressed maintainer comments AND: + - No stale reminder posted yet: POST REMINDER + - Stale reminder was posted >7 days ago with no response: CLOSE PR + + 3. Reminder message template (friendly, concise, no emojis): + "Hey @author! Just checking in - are you still working on this PR? We noticed there are some comments that may need addressing. If you need more time, no problem! Just let us know. If we don't hear back within a week, we'll close this to keep the repo tidy, but you can always reopen when ready." + + 4. Close message template (friendly, concise, no emojis): + "Closing this PR as stale to keep the repo clean. Feel free to reopen or create a new PR once you're ready to continue. Thanks for your contribution!" + claude_args: | + --allowedTools "Bash,Read,Grep,Glob" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..550f5d9b6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,108 @@ +name: Release + +on: + workflow_dispatch: + inputs: + skip_lingo: + description: "Skip Lingo.dev step" + type: "boolean" + default: false + push: + branches: + - main + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + id-token: write # Required for npm Trusted Publishing with OIDC + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Debug Permissions + run: | + ls -la + ls -la integrations/ + ls -la integrations/directus/ + + - name: Check for [skip i18n] + run: | + COMMIT_MESSAGE=$(git log -1 --pretty=%B) + if echo "$COMMIT_MESSAGE" | grep -iq '\[skip i18n\]'; then + echo "Skipping i18n checks due to [skip i18n] in commit message." + exit 0 + fi + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Upgrade npm for OIDC support + run: npm install -g npm@11.5.1 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + version: 9.12.3 + run_install: false + + - name: Configure pnpm cache + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install deps + run: pnpm install + + - name: Lingo.dev + if: ${{ !inputs.skip_lingo }} + uses: ./ + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} + pull-request: true + parallel: true + env: + GH_TOKEN: ${{ github.token }} + + - name: Setup + run: | + pnpm turbo telemetry disable + + - name: Configure Turbo cache + uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Build + run: pnpm turbo build --force + + - name: Test + run: pnpm turbo test --force + + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GHA_BOT_GPG }} + git_user_signingkey: true + git_commit_gpgsign: true + + - name: Create Release Pull Request or Publish to npm + id: changesets + uses: changesets/action@v1 + with: + title: "chore: bump package versions" + version: pnpm changeset version + publish: pnpm changeset publish + commit: "chore: bump package version" + setupGitUser: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_CONFIG_PROVENANCE: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..ce8901476 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +node_modules +.pnp +.pnp.js + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage +.idea + +# Turbo +.turbo + +# Vercel +.vercel + +# Build Outputs +.next/ +out/ +build +dist + + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem +i18n.cache diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 000000000..cfe751017 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm commitlint --edit $1 diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/cmp/.prettierrc b/.prettierrc similarity index 100% rename from cmp/.prettierrc rename to .prettierrc diff --git a/.syncpackrc.json b/.syncpackrc.json new file mode 100644 index 000000000..a53ef93f6 --- /dev/null +++ b/.syncpackrc.json @@ -0,0 +1,9 @@ +{ + "semverGroups": [ + { + "dependencies": ["**"], + "dependencyTypes": ["prod", "dev"], + "range": "" + } + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..75b820aec --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode", "bradlc.vscode-tailwindcss"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..f47a02045 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to CLI", + "processId": "${command:PickProcess}", + "request": "attach", + "skipFiles": ["/**"], + "type": "node", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/packages/cli/build/**/*.mjs"], + "resolveSourceMapLocations": [ + "${workspaceFolder}/packages/cli/build/**/*.mjs", + "!**/node_modules/**" + ], + "remoteRoot": "${workspaceFolder}/packages/cli", + "localRoot": "${workspaceFolder}/packages/cli", + "port": 9229 + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..77d278358 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll": "always" + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..cf51f307f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,19 @@ +# Extremely important instructions for Claude + +## FYI + +- We're in a pnpm + turbo monorepo + +## Tools + +- Must use `pnpm` as package manager + +## Testing + +- When you add tests - make sure they pass + +## Changesets + +- Every PR must include a changeset +- Create changeset: `pnpm new` from repo root +- For changes that don't need a release (e.g., README updates): `pnpm new:empty` \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..7717605e8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +support@lingo.dev. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..f5feb3256 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing to Lingo.dev + +Thank you for contributing! We maintain high standards for code quality and design. + +**IMPORTANT: Every requirement below is critical. If any requirement is not met, your issue or PR will be automatically rejected by the bots.** + +## Before You Start + +1. **Find or create an issue** - Search [existing issues](https://github.com/lingodotdev/lingo.dev/issues) first +2. **Wait to be assigned** - Comment on the issue and wait for assignment before starting work. Assignment priority: + - First: Issue creator + - Second: First volunteer commenter + - **Submitting a PR without assignment will result in automatic rejection** +3. **Discuss approach** - Align on implementation details before coding + +## Pull Request Requirements + +### Must Have + +- **Linked issue** - Reference the issue in your PR (e.g., "Closes #123") +- **Valid title** - Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `chore:`, etc. +- **Tests** - Unit tests for main code paths +- **Changeset** - Run `pnpm new` from repo root +- **Passing checks** - All CI checks must pass + +### Standards + +- **Surgical PRs** - One clear objective per PR +- **Clean code** - Elegant, well-reasoned implementation +- **Meaningful changes** - No low-effort, cosmetic, or trivial edits made only to gain contributions +- **No duplicate work** - Check if someone else already opened a PR + +## Local Development + +### Prerequisites + +- Node.js 18+ +- pnpm (`npm install -g pnpm`) +- AI API key (Groq, Google, or Mistral) - [setup guide](https://lingo.dev/en/cli/quick-start#step-2-authentication) + +### Setup + +```bash +git clone https://github.com/lingodotdev/lingo.dev +cd lingo.dev +pnpm install +pnpm turbo build +``` + +### Run Locally + +```bash +# Terminal 1 - watch CLI changes +cd packages/cli +pnpm run dev + +# Terminal 2 - test CLI +cd packages/cli +pnpm lingo.dev --help +``` + +### Run Checks + +```bash +pnpm install --frozen-lockfile +pnpm turbo build --force +pnpm turbo test --force +pnpm changeset status --since origin/main +``` + +## Review Process + +- Automated code review by AI bots provides suggestions +- Human reviewers make final decisions +- **Address maintainer comments promptly** - PRs with unaddressed comments will be closed to keep the repo clean. Feel free to recreate once issues are resolved. + +Questions? Join our [Discord](https://lingo.dev/go/discord)! diff --git a/DEBUGGING.md b/DEBUGGING.md new file mode 100644 index 000000000..f0ea203d7 --- /dev/null +++ b/DEBUGGING.md @@ -0,0 +1,91 @@ +# Debugging the Lingo.dev Compiler + +Lingo.dev Compiler is in active development. We use it ourselves and strive to provide the best developer experience for all supported frameworks. However, every project is unique and may present its own challenges. + +This guide will help you debug the Compiler locally in your project. + +--- + +## Getting Started: Local Setup + +### 1. Clone, Install, and Build + +```bash +git clone git@github.com:lingodotdev/lingo.dev.git +cd lingo.dev +pnpm install +pnpm build +``` + +Lingo.dev uses [pnpm](https://pnpm.io/) as its package manager. + +### 2. Link the CLI Package + +In the `lingo.dev/packages/cli` directory, link the CLI package using your project's package manager: + +```bash +npm link +# or +yarn link +# or +pnpm link +``` + +Use the package manager that matches your project (npm, yarn, or pnpm). + +### 3. Watch for Changes + +To enable live-reloading for development, run the following in the root of the `lingo.dev` repo: + +```bash +pnpm --filter "./packages/{compiler,react}" dev +``` + +--- + +## Using Your Local Build in Your Project + +### 1. Install Lingo.dev + +If you haven't already, add Lingo.dev to your project: + +```bash +npm install lingo.dev +``` + +For full setup and configuration, see the [Lingo.dev Compiler docs](https://lingo.dev/compiler). + +### 2. Link Your Local Library + +```bash +npm link lingo.dev +``` + +### 3. Build Your Project + +For local development and testing with the Lingo.dev Compiler: + +```bash +npm run dev +``` + +The exact command may vary depending on your project setup and package manager. + +--- + +## Debugging Tips + +You can now use your debugger or classic `console.log` statements in the Compiler and React packages to inspect what happens during your project build. + +- The Compiler entry point is at `packages/compiler/src/index.ts`. +- The `load` method: + - Loads and generates `lingo/dictionary.js` using `LCPServer.loadDictionaries`. +- The `transform` method: + - Applies mutations to process source code (see methods in `composeMutations`). + - Generates `lingo/meta.json` based on the translatable content in your app. + - Injects Compiler-related components (`LingoComponent`, `LingoAttributeComponent`). + - Replaces the `loadDictionary` method with `loadDictionary_internal`. + +--- + +For more details, check out the [Lingo.dev Compiler documentation](https://lingo.dev/compiler) or [join our Discord](https://lingo.dev/go/discord) for help! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..9dc77c540 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20.12.2-alpine + +# Run the Node.js / TypeScript application +ENTRYPOINT ["sh", "-c", "npx lingo.dev@latest ci \ + --api-key \"$LINGODOTDEV_API_KEY\" \ + --pull-request \"$LINGODOTDEV_PULL_REQUEST\" \ + --commit-message \"$LINGODOTDEV_COMMIT_MESSAGE\" \ + --pull-request-title \"$LINGODOTDEV_PULL_REQUEST_TITLE\" \ + --working-directory \"$LINGODOTDEV_WORKING_DIRECTORY\" \ + --process-own-commits \"$LINGODOTDEV_PROCESS_OWN_COMMITS\""] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..f48091af3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2024 Lingo.dev + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/action.yml b/action.yml new file mode 100644 index 000000000..f64e9b83e --- /dev/null +++ b/action.yml @@ -0,0 +1,64 @@ +name: "Lingo.Dev AI Localization" +description: Automated AI localization for dev teams. +author: Lingo.dev + +branding: + icon: "aperture" + color: "black" + +runs: + using: "composite" + steps: + - name: Run + run: | + npx lingo.dev@${{ inputs.version }} ci \ + --api-key "${{ inputs.api-key }}" \ + --pull-request "${{ inputs.pull-request }}" \ + --commit-message "${{ inputs.commit-message }}" \ + --pull-request-title "${{ inputs.pull-request-title }}" \ + --commit-author-name "${{ inputs.commit-author-name }}" \ + --commit-author-email "${{ inputs.commit-author-email }}" \ + --working-directory "${{ inputs.working-directory }}" \ + --process-own-commits "${{ inputs.process-own-commits }}" \ + --parallel ${{ inputs.parallel }} + shell: bash +inputs: + version: + description: "Lingo.dev CLI version" + default: "latest" + required: false + api-key: + description: "Lingo.dev Platform API Key" + required: true + pull-request: + description: "Create a pull request with the changes" + default: false + required: false + commit-message: + description: "Commit message" + default: "feat: update translations via @LingoDotDev" + required: false + pull-request-title: + description: "Pull request title" + default: "feat: update translations via @LingoDotDev" + required: false + commit-author-name: + description: "Git commit author name" + default: "Lingo.dev" + required: false + commit-author-email: + description: "Git commit author email" + default: "support@lingo.dev" + required: false + working-directory: + description: "Working directory" + default: "." + required: false + process-own-commits: + description: "Process commits made by this action" + default: false + required: false + parallel: + description: "Run in parallel mode" + default: false + required: false diff --git a/cmp/.editorconfig b/cmp/.editorconfig deleted file mode 100644 index 048d90337..000000000 --- a/cmp/.editorconfig +++ /dev/null @@ -1,16 +0,0 @@ -# EditorConfig is awesome: https://EditorConfig.org - -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.{js,ts,json,yml,yaml}] -indent_style = space -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false diff --git a/cmp/.github/workflows/ci.yml b/cmp/.github/workflows/ci.yml deleted file mode 100644 index a52214b54..000000000 --- a/cmp/.github/workflows/ci.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5.0.1 - # pnpm version is taken from package.json - - name: Install pnpm - uses: pnpm/action-setup@v4 - - name: Use Node.js 20 - uses: actions/setup-node@v6.0.0 - with: - node-version: 20 - cache: "pnpm" - - name: Install dependencies - run: pnpm install - - name: Lint - run: pnpm lint:check - - name: Check formatting - run: pnpm format:check - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5.0.1 - # pnpm version is taken from package.json - - name: Install pnpm - uses: pnpm/action-setup@v4 - - name: Use Node.js 20 - uses: actions/setup-node@v6.0.0 - with: - node-version: 20 - cache: "pnpm" - - name: Install dependencies - run: pnpm install - - name: Build - run: pnpm build - - name: Test - run: pnpm test - - playwright-e2e: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5.0.1 - # pnpm version is taken from package.json - - name: Install pnpm - uses: pnpm/action-setup@v4 - - name: Use Node.js 20 - uses: actions/setup-node@v6.0.0 - with: - node-version: 20 - cache: "pnpm" - - name: Install dependencies - run: pnpm install - - name: Install Playwright Browsers - working-directory: ./compiler - run: pnpm exec playwright install --with-deps - - name: Prepare tests - working-directory: ./compiler - run: pnpm run test:prepare - - name: Run tests - working-directory: ./compiler - run: pnpm run test:e2e - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 diff --git a/cmp/.gitignore b/cmp/.gitignore deleted file mode 100644 index 64364889c..000000000 --- a/cmp/.gitignore +++ /dev/null @@ -1,40 +0,0 @@ -# Dependencies -node_modules/ -.pnpm-store/ - -# Build outputs -dist/ -build/ -*.tsbuildinfo - -# Turbo -.turbo/ - -# Environment variables -.env -.env*.local - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* -pnpm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Testing -coverage/ -.nyc_output/ - -# Misc -*.local diff --git a/cmp/.husky/pre-commit b/cmp/.husky/pre-commit deleted file mode 100644 index 286365038..000000000 --- a/cmp/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -./node_modules/.bin/nano-staged diff --git a/cmp/.npmrc b/cmp/.npmrc deleted file mode 100644 index 286234946..000000000 --- a/cmp/.npmrc +++ /dev/null @@ -1,6 +0,0 @@ -# Use pnpm for package management -shamefully-hoist=false -strict-peer-dependencies=false - -# Lockfile settings -lockfile=true diff --git a/cmp/.prettierignore b/cmp/.prettierignore deleted file mode 100644 index 5305242a1..000000000 --- a/cmp/.prettierignore +++ /dev/null @@ -1,10 +0,0 @@ -pnpm-lock.yaml -.changeset/ -packages/cli/demo/ -build/ -dist/ -.react-router/ -.turbo/ -.next/ -CLAUDE.md -**/routeTree.gen.ts diff --git a/cmp/README.md b/cmp/README.md deleted file mode 100644 index d45c16ef7..000000000 --- a/cmp/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# Lingo.dev compiler - -Lingo.dev compiler with automatic translation support for React applications. - -This package provides plugins for multiple bundlers (Vite, Webpack) and Next.js that -automatically transforms React components to inject translation calls. - -## Features - -- **Automatic JSX text transformation** - Automatically detects and transforms translatable text in JSX -- **Opt-in or automatic** - Configure whether to require `'use i18n'` directive or transform all files -- **Multi-bundler support** - Works with Vite, Webpack and Next.js (both Webpack and Turbopack builds) -- **Translation server** - On-demand translation generation during development -- **AI-powered translations** - Support for multiple LLM providers and Lingo.dev Engine - -## Getting started - -Install the package - `pnpm install @lingo.dev/compiler` - -### Vite - -- Configure the plugin in your vite config. - - ```ts - import { defineConfig } from "vite"; - import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; - - export default defineConfig({ - plugins: [ - lingoCompilerPlugin({ - sourceRoot: "src", - sourceLocale: "en", - targetLocales: ["es", "de", "fr"], - models: "lingo.dev", - dev: { - usePseudotranslator: true, - }, - }), // ...other plugins - ], - }); - ``` - -- Wrap your app with `LingoProvider`. It should be used as early as possible in your app. - e.g. in vite example with tanstack-router `LingoProvider` should be above `RouterProvider`, otherwise code-splitting breaks contexts. - ```tsx - // Imports and other tanstack router setup - if (rootElement && !rootElement.innerHTML) { - const root = ReactDOM.createRoot(rootElement); - root.render( - - - - - , - ); - } - ``` - -See `demo/vite-react-spa` for the working example - -### Next.js - -- Use `withLingo` function to wrap your existing next config. You will have to make your config async. - - ```ts - import type { NextConfig } from "next"; - import { withLingo } from "@lingo.dev/compiler/next"; - - const nextConfig: NextConfig = {}; - - export default async function (): Promise { - return await withLingo(nextConfig, { - sourceRoot: "./app", - sourceLocale: "en", - targetLocales: ["es", "de", "ru"], - models: "lingo.dev", - dev: { - usePseudotranslator: true, - }, - buildMode: "cache-only", - }); - } - ``` - -- Wrap your app with `LingoProvider`. It should be used as early as possible in your app, root `Layout` is a good place. - ```tsx - export default function RootLayout({ - children, - }: Readonly<{ children: React.ReactNode }>) { - return ( - - - {children} - - - ); - } - ``` - -## Development - -`pnpm install` from project root -`pnpm turbo dev --filter=@lingo.dev/compile` to compile and watch for compiler changes - -Choose the demo you want to work with and run it from the corresponding folder. -`tsdown` in compiler is configured to cleanup the output folder before compilation, which works fine with next, but vite -seems to be dead every time and has to be restarted. diff --git a/cmp/compiler/README.md b/cmp/compiler/README.md deleted file mode 100644 index 47459dc53..000000000 --- a/cmp/compiler/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# @lingo.dev/compiler - -See the main [README](../README.md) for general information. - -## Structure - -The compiler is organized into several key modules: - -### Core Directories - -#### `src/plugin/` - Build-time transformation - -- **`transform/`** - Babel AST transformation logic for JSX text extraction -- **`unplugin.ts`** - Universal plugin implementation (Vite, Webpack) -- **`next.ts`** - Next.js-specific plugin with Turbopack and Webpack support -- **`build-translator.ts`** - Batch translation generation at build time -- **`virtual-modules-code-generator.ts`** - Generates code for virtual modules, dev config and locale resolvers for client and server - -#### `src/metadata/` - Translation metadata management - -- **`manager.ts`** - CRUD operations for `.lingo/metadata.json` -- Thread-safe metadata file operations with file locking -- Manages translation entries with hash-based identifiers - -#### `src/translators/` - Translation provider abstraction - -- **`lcp/`** - Lingo.dev Engine integration -- **`pseudotranslator/`** - Development-mode fake translator -- **`pluralization/`** - Automatic ICU MessageFormat detection -- **`translator-factory.ts`** - Provider selection and initialization - -#### `src/translation-server/` - Development server - -- **`translation-server.ts`** - HTTP server for on-demand translations -- **`cli.ts`** - Standalone CLI for translation generation -- WebSocket support for real-time dev widget updates -- Port management (60000-60099 range) - -#### `src/react/` - Runtime translation hooks - -- **`client/`** - Client-side Context-based hooks -- **`server/`** - Server component cache-based hooks (isomorphic) -- **`server-only/`** - Async server-only API (`getServerTranslations`) -- **`shared/`** - Shared utilities (RichText rendering, Context) -- **`next/`** - Next.js-specific middleware and locale switcher - -#### `src/utils/` - Shared utilities - -- **`hash.ts`** - Stable hash generation for translation keys -- **`config-factory.ts`** - Configuration defaults and merging -- **`logger.ts`** - Structured logging utilities -- **`path-helpers.ts`** - File path resolution - -#### `src/widget/` - Development widget - -- In-browser translation editor overlay for development mode - -### Support Directories - -#### `tests/` - End-to-end testing - -- **`e2e/`** - Playwright tests for full build workflows -- **`fixtures/`** - Test applications (Vite, Next.js) -- **`helpers/`** - Test utilities and assertions - -#### `benchmarks/` - Performance benchmarks - -- Translation speed benchmarks -- Metadata I/O performance tests - -#### `old-docs/` - Legacy documentation - -- Historical design documents and notes - -### Entry Points - -- **`src/index.ts`** - Main package exports (plugins, types) -- **`src/types.ts`** - Core TypeScript types - -## Contributing - -This is a beta package under active development. Feedback and contributions are welcome! - -## License - -ISC diff --git a/cmp/compiler/src/translators/lingo/index.ts b/cmp/compiler/src/translators/lingo/index.ts deleted file mode 100644 index 3a5165cac..000000000 --- a/cmp/compiler/src/translators/lingo/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Lingo Translation Engine - * - * Real AI-powered translation using various LLM providers - */ - -export { Service } from "./service"; -export type { LingoTranslatorConfig } from "./service"; diff --git a/cmp/compiler/src/translators/lingo/provider-details.ts b/cmp/compiler/src/translators/lingo/provider-details.ts deleted file mode 100644 index d7005db23..000000000 --- a/cmp/compiler/src/translators/lingo/provider-details.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Provider details for error messages and documentation links - */ - -export interface ProviderDetails { - name: string; // Display name (e.g., "Groq", "Google") - apiKeyEnvVar?: string; // Environment variable name (e.g., "GROQ_API_KEY") - apiKeyConfigKey?: string; // Config key if applicable (e.g., "llm.groqApiKey") - getKeyLink: string; // Link to get API key - docsLink: string; // Link to API docs for troubleshooting -} - -export const providerDetails: Record = { - groq: { - name: "Groq", - apiKeyEnvVar: "GROQ_API_KEY", - apiKeyConfigKey: "llm.groqApiKey", - getKeyLink: "https://groq.com", - docsLink: "https://console.groq.com/docs/errors", - }, - google: { - name: "Google", - apiKeyEnvVar: "GOOGLE_API_KEY", - apiKeyConfigKey: "llm.googleApiKey", - getKeyLink: "https://ai.google.dev/", - docsLink: "https://ai.google.dev/gemini-api/docs/troubleshooting", - }, - openrouter: { - name: "OpenRouter", - apiKeyEnvVar: "OPENROUTER_API_KEY", - apiKeyConfigKey: "llm.openrouterApiKey", - getKeyLink: "https://openrouter.ai", - docsLink: "https://openrouter.ai/docs", - }, - ollama: { - name: "Ollama", - apiKeyEnvVar: undefined, - apiKeyConfigKey: undefined, - getKeyLink: "https://ollama.com/download", - docsLink: "https://github.com/ollama/ollama/tree/main/docs", - }, - mistral: { - name: "Mistral", - apiKeyEnvVar: "MISTRAL_API_KEY", - apiKeyConfigKey: "llm.mistralApiKey", - getKeyLink: "https://console.mistral.ai", - docsLink: "https://docs.mistral.ai", - }, - "lingo.dev": { - name: "Lingo.dev", - apiKeyEnvVar: "LINGODOTDEV_API_KEY", - apiKeyConfigKey: "auth.apiKey", - getKeyLink: "https://lingo.dev", - docsLink: "https://lingo.dev/docs", - }, -}; - -/** - * Get provider details by ID - */ -export function getProviderDetails(providerId: string): ProviderDetails | null { - return providerDetails[providerId] || null; -} - -/** - * Get all providers that require API keys - */ -export function getProvidersRequiringKeys(): string[] { - return Object.keys(providerDetails).filter( - (id) => providerDetails[id].apiKeyEnvVar !== undefined, - ); -} - -/** - * Format a helpful error message when API key is missing - */ -export function formatMissingApiKeyError(providerId: string): string { - const details = providerDetails[providerId]; - - if (!details) { - return `Unknown provider: ${providerId}`; - } - - if (!details.apiKeyEnvVar) { - // Provider doesn't need API key (like Ollama) - return `Provider ${details.name} doesn't require an API key. Check connection at ${details.getKeyLink}`; - } - - return [ - `⚠️ ${details.name} API key not found.`, - ``, - `To use ${details.name} for translations, you need to set ${details.apiKeyEnvVar}.`, - ``, - `👉 Set the API key:`, - ` 1. Add to .env file: ${details.apiKeyEnvVar}=`, - ` 2. Or export in terminal: export ${details.apiKeyEnvVar}=`, - ``, - `⭐️ Get your API key:`, - ` ${details.getKeyLink}`, - ``, - `📚 Documentation:`, - ` ${details.docsLink}`, - ].join("\n"); -} - -/** - * Format a helpful error message when API call fails - */ -export function formatApiCallError( - providerId: string, - targetLocale: string, - errorMessage: string, -): string { - const details = providerDetails[providerId]; - - if (!details) { - return `Translation failed with unknown provider "${providerId}": ${errorMessage}`; - } - - // Check for common error patterns - const isInvalidApiKey = - errorMessage.toLowerCase().includes("invalid api key") || - errorMessage.toLowerCase().includes("unauthorized") || - errorMessage.toLowerCase().includes("authentication failed"); - - if (isInvalidApiKey) { - return [ - `⚠️ ${details.name} API key is invalid.`, - ``, - `Error details: ${errorMessage}`, - ``, - `👉 Please check your API key:`, - details.apiKeyEnvVar - ? ` Environment variable: ${details.apiKeyEnvVar}` - : "", - ``, - `⭐️ Get a new API key:`, - ` ${details.getKeyLink}`, - ``, - `📚 Troubleshooting:`, - ` ${details.docsLink}`, - ] - .filter(Boolean) - .join("\n"); - } - - // Generic API error - return [ - `⚠️ Translation to "${targetLocale}" failed via ${details.name}.`, - ``, - `Error details: ${errorMessage}`, - ``, - `📚 Check ${details.name} documentation for more details:`, - ` ${details.docsLink}`, - ``, - `💡 Tips:`, - ` - Check your API quota/credits`, - ` - Verify the model is available for your account`, - ` - Check ${details.name} status page for outages`, - ].join("\n"); -} - -/** - * Format error message when API keys are missing for configured providers - * @param missingProviders List of providers that are missing API keys - * @param allProviders Optional: list of all configured providers for context - */ -export function formatNoApiKeysError( - missingProviders: string[], - allProviders?: string[], -): string { - const lines: string[] = []; - - if (missingProviders.length === 0) { - // No missing providers (shouldn't happen, but handle it) - return "Translation API keys validated successfully."; - } - - // Header - if (allProviders && allProviders.length > missingProviders.length) { - lines.push( - `⚠️ Missing API keys for ${missingProviders.length} of ${allProviders.length} configured providers.`, - ); - } else { - lines.push(`⚠️ Missing API keys for configured translation providers.`); - } - - lines.push(``); - - // List missing providers with their environment variables and links - lines.push(`Missing API keys for:`); - for (const providerId of missingProviders) { - const details = providerDetails[providerId]; - if (details) { - if (details.apiKeyEnvVar) { - lines.push( - ` • ${details.name}: ${details.apiKeyEnvVar} → ${details.getKeyLink}`, - ); - } else { - lines.push(` • ${details.name}: ${details.getKeyLink}`); - } - } else { - lines.push(` • ${providerId}: (unknown provider)`); - } - } - - lines.push( - ``, - `👉 Set the required API keys:`, - ` 1. Add to .env file (recommended)`, - ` 2. Or export in terminal: export API_KEY_NAME=`, - ``, - `💡 In development mode, the app will auto-fallback to pseudotranslator.`, - ` In production, all configured providers must have valid API keys.`, - ); - - return lines.join("\n"); -} diff --git a/cmp/compiler/src/translators/translator-factory.ts b/cmp/compiler/src/translators/translator-factory.ts deleted file mode 100644 index b401a2d3e..000000000 --- a/cmp/compiler/src/translators/translator-factory.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Factory for creating translators based on configuration - */ - -import type { Translator } from "./api"; -import { PseudoTranslator } from "./pseudotranslator"; -import { Service } from "./lingo"; -import { Logger } from "../utils/logger"; -import type { LocaleCode } from "lingo.dev/spec"; -import type { LingoEnvironment } from "../types"; - -interface TranslatorFactoryConfig { - sourceLocale: LocaleCode; - models: "lingo.dev" | Record; - prompt?: string; - environment: LingoEnvironment; - dev?: { - usePseudotranslator?: boolean; - }; -} - -/** - * Create a translator instance based on configuration - * - * Development mode behavior: - * - If translator is "pseudo" or dev.usePseudotranslator is true, use pseudotranslator - * - If API keys are missing, auto-fallback to pseudotranslator (with warning) - * - Otherwise, create real translator - * - * Production mode behavior: - * - Always require real translator with valid API keys - * - Throw error if API keys are missing - * - * Note: Translators are stateless and don't handle caching. - * Caching is handled by TranslationService layer. - * - * API key validation is now done in the LingoTranslator constructor - * which validates and fetches all keys once at initialization. - */ -export function createTranslator( - config: TranslatorFactoryConfig, - logger: Logger, -): Translator { - const isDev = config.environment === "development"; - - // 1. Explicit dev override takes precedence - if (isDev && config.dev?.usePseudotranslator) { - logger.info("📝 Using pseudotranslator (dev.usePseudotranslator enabled)"); - return new PseudoTranslator({ delayMedian: 100 }, logger); - } - - // 2. Try to create real translator - // LingoTranslator constructor will validate and fetch API keys - // If validation fails, it will throw an error with helpful message - try { - const models = config.models; - - logger.info( - `Creating Lingo translator with models: ${JSON.stringify(models)}`, - ); - - return new Service( - { - models, - sourceLocale: config.sourceLocale, - prompt: config.prompt, - }, - logger, - ); - } catch (error) { - // 3. Auto-fallback in dev mode if creation fails - if (isDev) { - // Use console.error to ensure visibility in all contexts (loader, server, etc.) - const errorMsg = error instanceof Error ? error.message : String(error); - logger.error(`\n❌ [Lingo] Translation setup error: ${errorMsg}\n`); - logger.warn( - `⚠️ [Lingo] Auto-fallback to pseudotranslator in development mode.\n` + - ` Set the required API keys for real translations.\n`, - ); - - return new PseudoTranslator({ delayMedian: 100 }, logger); - } - - // 4. Fail in production - throw error; - } -} diff --git a/cmp/demo/vite-react-spa/package.json b/cmp/demo/vite-react-spa/package.json deleted file mode 100644 index e3dba52fb..000000000 --- a/cmp/demo/vite-react-spa/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "vite-react-spa", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --port 3000", - "build": "vite build && tsc", - "serve": "vite preview" - }, - "dependencies": { - "@lingo.dev/compiler": "workspace:*", - "@tailwindcss/vite": "^4.0.6", - "@tanstack/react-devtools": "^0.7.0", - "@tanstack/react-router": "^1.132.0", - "@tanstack/react-router-devtools": "^1.132.0", - "@tanstack/router-plugin": "^1.132.0", - "lucide-react": "^0.545.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "tailwindcss": "^4.0.6" - }, - "devDependencies": { - "@tanstack/devtools-vite": "^0.3.11", - "@testing-library/dom": "^10.4.0", - "@testing-library/react": "^16.2.0", - "@types/node": "^22.10.2", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^5.0.4", - "jsdom": "^27.0.0", - "typescript": "^5.7.2", - "vite": "^7.3.0", - "web-vitals": "^5.1.0" - } -} diff --git a/cmp/package.json b/cmp/package.json deleted file mode 100644 index 9f5e9b341..000000000 --- a/cmp/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "compiler-monorepo", - "packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b", - "version": "1.0.0", - "private": true, - "scripts": { - "prepare": "husky", - "dev": "turbo run dev", - "build": "turbo run build", - "test": "turbo run test", - "format": "prettier . --write", - "format:check": "prettier . --check", - "lint": "oxlint --fix", - "lint:check": "oxlint --type-aware" - }, - "engines": { - "node": ">=20" - }, - "devDependencies": { - "husky": "^9.1.7", - "nano-staged": "^0.9.0", - "oxlint": "^1.29.0", - "oxlint-tsgolint": "^0.8.0", - "prettier": "^3.6.2", - "turbo": "^2.6.1" - }, - "nano-staged": { - "*": [ - "prettier --write --ignore-unknown", - "oxlint" - ] - } -} diff --git a/cmp/pnpm-workspace.yaml b/cmp/pnpm-workspace.yaml deleted file mode 100644 index 24fe20ed4..000000000 --- a/cmp/pnpm-workspace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - - "demo/*" - - "compiler" - - "localizer" diff --git a/cmp/turbo.json b/cmp/turbo.json deleted file mode 100644 index 148149d84..000000000 --- a/cmp/turbo.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://turbo.build/schema.json", - "tasks": { - "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**"] - }, - "dev": { - "cache": false, - "persistent": true - }, - "test": { - "dependsOn": ["build"] - } - } -} diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 000000000..fa584fb6d --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1 @@ +export default { extends: ["@commitlint/config-conventional"] }; diff --git a/community/README.md b/community/README.md new file mode 100644 index 000000000..45a12de4d --- /dev/null +++ b/community/README.md @@ -0,0 +1,42 @@ +# Lingo.dev Community Contributions + +Welcome to the official community space of Lingo.dev! + +This directory is a dedicated space for community-driven projects, demo applications, and integrations that showcase what can be built with Lingo.dev. + +**Note:** The code in this directory is for examples and educational purposes. It exists separately from the core Lingo.dev product source code. If you want to contribute to the core product (like the CLI, compiler etc), you can go through our or open new one. + +## 📂 What belongs here? + +We encourage you to submit projects that help others learn and build. Ideal contributions include: + +* **Demo Apps:** Full-stack or frontend applications (e.g., React, Next.js) demonstrating specific Lingo.dev features. +* **Integrations:** Scripts or plugins that connect Lingo.dev with other tools in your stack. +* **Starters/Boilerplates:** Minimal setups to help new users get up and running quickly. +* **Tutorial Code:** Source code companion files for blogs or video tutorials. + +## 🚀 How to Contribute + +We love seeing what you build! To contribute to this directory: + +1. **Fork & Branch:** Fork the repository and create a new branch for your contribution. +2. **Create a Directory:** Inside `community/`, create a new folder for your project. Please use `kebab-case` for naming (e.g., `community/react-todo-demo`). +3. **Add Documentation:** Every contribution **must** have its own `README.md` inside its folder explaining: + * What the project does. + * Prerequisites (e.g., Node version, API keys). + * How to run it locally. +4. **Submit a PR:** Open a Pull Request targeting the `main` branch. Please tag your PR with `community-submission` so our team can spot it easily. +5. Don't forget to add a changeset in your PRs as mentioned in our [contributor's guide](https://github.com/lingodotdev/lingo.dev/blob/main/CONTRIBUTING.md). + +## 💬 Join the Discussion + +If you have an idea for a contribution but aren't sure where to start, or if you want to show off your work: + +* **Discord:** Join our [Discord server](https://lingo.dev/go/discord) to interact with our fellow community members. +* **Issues:** You can also open a GitHub Issue with the tag `community-submission-idea` to discuss potential contributions. + +## ⚖️ License & Disclaimer + +Code in the `community/` directory is contributed by the community. While the Lingo.dev team reviews submissions, these projects are maintained by their respective authors. + +By contributing, you agree that your code will be licensed under the same terms as the repository. diff --git a/composer.json b/composer.json new file mode 100644 index 000000000..38fd47f96 --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "name": "lingodotdev/lingo.dev", + "description": "Lingo.dev Monorepo", + "type": "project", + "license": "MIT", + "repositories": [ + { + "type": "path", + "url": "php/sdk", + "options": { + "symlink": false + } + } + ], + "require": { + "lingodotdev/sdk": "*" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/content/banner.compiler.png b/content/banner.compiler.png new file mode 100644 index 000000000..9aba9f346 Binary files /dev/null and b/content/banner.compiler.png differ diff --git a/content/banner.dark.png b/content/banner.dark.png new file mode 100644 index 000000000..ebd35fa21 Binary files /dev/null and b/content/banner.dark.png differ diff --git a/content/banner.launch.png b/content/banner.launch.png new file mode 100644 index 000000000..21950b3c1 Binary files /dev/null and b/content/banner.launch.png differ diff --git a/cmp/demo/next16/.gitignore b/demo/new-compiler-next16/.gitignore similarity index 100% rename from cmp/demo/next16/.gitignore rename to demo/new-compiler-next16/.gitignore diff --git a/demo/new-compiler-next16/CHANGELOG.md b/demo/new-compiler-next16/CHANGELOG.md new file mode 100644 index 000000000..ba5000280 --- /dev/null +++ b/demo/new-compiler-next16/CHANGELOG.md @@ -0,0 +1,54 @@ +# @compiler/demo-next + +## 0.1.7 + +### Patch Changes + +- Updated dependencies []: + - @lingo.dev/compiler@0.1.8 + +## 0.1.6 + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +- Updated dependencies [[`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59)]: + - @lingo.dev/compiler@0.1.7 + +## 0.1.5 + +### Patch Changes + +- Updated dependencies []: + - @lingo.dev/compiler@0.1.6 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies []: + - @lingo.dev/compiler@0.1.5 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [[`68b8496`](https://github.com/lingodotdev/lingo.dev/commit/68b849602a88b9f9aa3097f37ce2f0ccf97c1ad5)]: + - @lingo.dev/compiler@0.1.4 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [[`c77c8c8`](https://github.com/lingodotdev/lingo.dev/commit/c77c8c8b8e1db859839b184882d56a0ef7da1ab0)]: + - @lingo.dev/compiler@0.1.3 + +## 0.1.1 + +### Patch Changes + +- [#1710](https://github.com/lingodotdev/lingo.dev/pull/1710) [`020424f`](https://github.com/lingodotdev/lingo.dev/commit/020424f2601c535e88c66aeeece5a15fb9b66b70) Thanks [@vrcprl](https://github.com/vrcprl)! - Add support for JSONC comments in arrays + +- Updated dependencies [[`b2d335b`](https://github.com/lingodotdev/lingo.dev/commit/b2d335b37af3e300a402d75f0eb2a0112f81e7ee)]: + - @lingo.dev/compiler@0.1.2 diff --git a/cmp/demo/next16/README.md b/demo/new-compiler-next16/README.md similarity index 100% rename from cmp/demo/next16/README.md rename to demo/new-compiler-next16/README.md diff --git a/cmp/demo/next16/app/.lingo/cache/de.json b/demo/new-compiler-next16/app/.lingo/cache/de.json similarity index 100% rename from cmp/demo/next16/app/.lingo/cache/de.json rename to demo/new-compiler-next16/app/.lingo/cache/de.json diff --git a/cmp/demo/next16/app/.lingo/cache/en.json b/demo/new-compiler-next16/app/.lingo/cache/en.json similarity index 100% rename from cmp/demo/next16/app/.lingo/cache/en.json rename to demo/new-compiler-next16/app/.lingo/cache/en.json diff --git a/cmp/demo/next16/app/.lingo/cache/es.json b/demo/new-compiler-next16/app/.lingo/cache/es.json similarity index 100% rename from cmp/demo/next16/app/.lingo/cache/es.json rename to demo/new-compiler-next16/app/.lingo/cache/es.json diff --git a/cmp/demo/next16/app/.lingo/cache/ru.json b/demo/new-compiler-next16/app/.lingo/cache/ru.json similarity index 100% rename from cmp/demo/next16/app/.lingo/cache/ru.json rename to demo/new-compiler-next16/app/.lingo/cache/ru.json diff --git a/cmp/demo/next16/app/favicon.ico b/demo/new-compiler-next16/app/favicon.ico similarity index 100% rename from cmp/demo/next16/app/favicon.ico rename to demo/new-compiler-next16/app/favicon.ico diff --git a/cmp/demo/next16/app/globals.css b/demo/new-compiler-next16/app/globals.css similarity index 100% rename from cmp/demo/next16/app/globals.css rename to demo/new-compiler-next16/app/globals.css diff --git a/cmp/demo/next16/app/layout.tsx b/demo/new-compiler-next16/app/layout.tsx similarity index 100% rename from cmp/demo/next16/app/layout.tsx rename to demo/new-compiler-next16/app/layout.tsx diff --git a/cmp/demo/next16/app/page.tsx b/demo/new-compiler-next16/app/page.tsx similarity index 100% rename from cmp/demo/next16/app/page.tsx rename to demo/new-compiler-next16/app/page.tsx diff --git a/cmp/demo/next16/app/test/page.tsx b/demo/new-compiler-next16/app/test/page.tsx similarity index 100% rename from cmp/demo/next16/app/test/page.tsx rename to demo/new-compiler-next16/app/test/page.tsx diff --git a/cmp/demo/next16/components/ClientChildWrapper.tsx b/demo/new-compiler-next16/components/ClientChildWrapper.tsx similarity index 100% rename from cmp/demo/next16/components/ClientChildWrapper.tsx rename to demo/new-compiler-next16/components/ClientChildWrapper.tsx diff --git a/cmp/demo/next16/components/Counter.tsx b/demo/new-compiler-next16/components/Counter.tsx similarity index 100% rename from cmp/demo/next16/components/Counter.tsx rename to demo/new-compiler-next16/components/Counter.tsx diff --git a/cmp/demo/next16/components/CounterClientChild.tsx b/demo/new-compiler-next16/components/CounterClientChild.tsx similarity index 100% rename from cmp/demo/next16/components/CounterClientChild.tsx rename to demo/new-compiler-next16/components/CounterClientChild.tsx diff --git a/cmp/demo/next16/components/ServerChild.tsx b/demo/new-compiler-next16/components/ServerChild.tsx similarity index 100% rename from cmp/demo/next16/components/ServerChild.tsx rename to demo/new-compiler-next16/components/ServerChild.tsx diff --git a/cmp/demo/next16/eslint.config.mjs b/demo/new-compiler-next16/eslint.config.mjs similarity index 100% rename from cmp/demo/next16/eslint.config.mjs rename to demo/new-compiler-next16/eslint.config.mjs diff --git a/cmp/demo/next16/next.config.ts b/demo/new-compiler-next16/next.config.ts similarity index 100% rename from cmp/demo/next16/next.config.ts rename to demo/new-compiler-next16/next.config.ts diff --git a/cmp/demo/next16/package.json b/demo/new-compiler-next16/package.json similarity index 58% rename from cmp/demo/next16/package.json rename to demo/new-compiler-next16/package.json index 4b81cadb9..e849c2907 100644 --- a/cmp/demo/next16/package.json +++ b/demo/new-compiler-next16/package.json @@ -1,6 +1,6 @@ { "name": "@compiler/demo-next", - "version": "0.1.0", + "version": "0.1.7", "private": true, "scripts": { "dev": "next dev", @@ -10,19 +10,19 @@ }, "dependencies": { "@lingo.dev/compiler": "workspace:*", - "next": "^16.0.4", + "next": "16.0.4", "react": "19.2.0", "react-dom": "19.2.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "eslint": "^9", + "@tailwindcss/postcss": "4", + "@types/node": "20", + "@types/react": "19", + "@types/react-dom": "19", + "eslint": "9", "eslint-config-next": "16.0.3", - "tailwindcss": "^4", - "tsx": "^4.20.6", - "typescript": "^5" + "tailwindcss": "4", + "tsx": "4.20.6", + "typescript": "5" } } diff --git a/cmp/demo/next16/postcss.config.mjs b/demo/new-compiler-next16/postcss.config.mjs similarity index 100% rename from cmp/demo/next16/postcss.config.mjs rename to demo/new-compiler-next16/postcss.config.mjs diff --git a/cmp/demo/next16/public/file.svg b/demo/new-compiler-next16/public/file.svg similarity index 100% rename from cmp/demo/next16/public/file.svg rename to demo/new-compiler-next16/public/file.svg diff --git a/cmp/demo/next16/public/globe.svg b/demo/new-compiler-next16/public/globe.svg similarity index 100% rename from cmp/demo/next16/public/globe.svg rename to demo/new-compiler-next16/public/globe.svg diff --git a/cmp/demo/next16/public/next.svg b/demo/new-compiler-next16/public/next.svg similarity index 100% rename from cmp/demo/next16/public/next.svg rename to demo/new-compiler-next16/public/next.svg diff --git a/cmp/demo/next16/public/vercel.svg b/demo/new-compiler-next16/public/vercel.svg similarity index 100% rename from cmp/demo/next16/public/vercel.svg rename to demo/new-compiler-next16/public/vercel.svg diff --git a/cmp/demo/next16/public/window.svg b/demo/new-compiler-next16/public/window.svg similarity index 100% rename from cmp/demo/next16/public/window.svg rename to demo/new-compiler-next16/public/window.svg diff --git a/cmp/demo/next16/tsconfig.json b/demo/new-compiler-next16/tsconfig.json similarity index 100% rename from cmp/demo/next16/tsconfig.json rename to demo/new-compiler-next16/tsconfig.json diff --git a/cmp/demo/vite-react-spa/.cta.json b/demo/new-compiler-vite-react-spa/.cta.json similarity index 100% rename from cmp/demo/vite-react-spa/.cta.json rename to demo/new-compiler-vite-react-spa/.cta.json diff --git a/cmp/demo/vite-react-spa/.gitignore b/demo/new-compiler-vite-react-spa/.gitignore similarity index 100% rename from cmp/demo/vite-react-spa/.gitignore rename to demo/new-compiler-vite-react-spa/.gitignore diff --git a/cmp/demo/vite-react-spa/README.md b/demo/new-compiler-vite-react-spa/README.md similarity index 100% rename from cmp/demo/vite-react-spa/README.md rename to demo/new-compiler-vite-react-spa/README.md diff --git a/cmp/demo/vite-react-spa/analyze-bundle.sh b/demo/new-compiler-vite-react-spa/analyze-bundle.sh similarity index 100% rename from cmp/demo/vite-react-spa/analyze-bundle.sh rename to demo/new-compiler-vite-react-spa/analyze-bundle.sh diff --git a/cmp/demo/vite-react-spa/index.html b/demo/new-compiler-vite-react-spa/index.html similarity index 100% rename from cmp/demo/vite-react-spa/index.html rename to demo/new-compiler-vite-react-spa/index.html diff --git a/cmp/demo/vite-react-spa/package-lock.json b/demo/new-compiler-vite-react-spa/package-lock.json similarity index 100% rename from cmp/demo/vite-react-spa/package-lock.json rename to demo/new-compiler-vite-react-spa/package-lock.json diff --git a/demo/new-compiler-vite-react-spa/package.json b/demo/new-compiler-vite-react-spa/package.json new file mode 100644 index 000000000..121607447 --- /dev/null +++ b/demo/new-compiler-vite-react-spa/package.json @@ -0,0 +1,35 @@ +{ + "name": "vite-react-spa", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "build": "vite build && tsc", + "serve": "vite preview" + }, + "dependencies": { + "@lingo.dev/compiler": "workspace:*", + "@tailwindcss/vite": "4.1.18", + "@tanstack/react-devtools": "0.7.0", + "@tanstack/react-router": "1.132.0", + "@tanstack/react-router-devtools": "1.132.0", + "@tanstack/router-plugin": "1.132.0", + "lucide-react": "0.545.0", + "react": "19.2.0", + "react-dom": "19.2.0", + "tailwindcss": "4.1.18" + }, + "devDependencies": { + "@tanstack/devtools-vite": "0.3.11", + "@testing-library/dom": "10.4.0", + "@testing-library/react": "16.2.0", + "@types/node": "22.10.2", + "@types/react": "19.2.0", + "@types/react-dom": "19.2.0", + "@vitejs/plugin-react": "5.0.4", + "jsdom": "27.0.0", + "typescript": "5.7.2", + "vite": "7.3.0", + "web-vitals": "5.1.0" + } +} \ No newline at end of file diff --git a/cmp/demo/vite-react-spa/public/favicon.ico b/demo/new-compiler-vite-react-spa/public/favicon.ico similarity index 100% rename from cmp/demo/vite-react-spa/public/favicon.ico rename to demo/new-compiler-vite-react-spa/public/favicon.ico diff --git a/cmp/demo/vite-react-spa/public/logo192.png b/demo/new-compiler-vite-react-spa/public/logo192.png similarity index 100% rename from cmp/demo/vite-react-spa/public/logo192.png rename to demo/new-compiler-vite-react-spa/public/logo192.png diff --git a/cmp/demo/vite-react-spa/public/logo512.png b/demo/new-compiler-vite-react-spa/public/logo512.png similarity index 100% rename from cmp/demo/vite-react-spa/public/logo512.png rename to demo/new-compiler-vite-react-spa/public/logo512.png diff --git a/cmp/demo/vite-react-spa/public/manifest.json b/demo/new-compiler-vite-react-spa/public/manifest.json similarity index 100% rename from cmp/demo/vite-react-spa/public/manifest.json rename to demo/new-compiler-vite-react-spa/public/manifest.json diff --git a/cmp/demo/vite-react-spa/public/robots.txt b/demo/new-compiler-vite-react-spa/public/robots.txt similarity index 100% rename from cmp/demo/vite-react-spa/public/robots.txt rename to demo/new-compiler-vite-react-spa/public/robots.txt diff --git a/cmp/demo/vite-react-spa/public/tanstack-circle-logo.png b/demo/new-compiler-vite-react-spa/public/tanstack-circle-logo.png similarity index 100% rename from cmp/demo/vite-react-spa/public/tanstack-circle-logo.png rename to demo/new-compiler-vite-react-spa/public/tanstack-circle-logo.png diff --git a/cmp/demo/vite-react-spa/public/tanstack-word-logo-white.svg b/demo/new-compiler-vite-react-spa/public/tanstack-word-logo-white.svg similarity index 100% rename from cmp/demo/vite-react-spa/public/tanstack-word-logo-white.svg rename to demo/new-compiler-vite-react-spa/public/tanstack-word-logo-white.svg diff --git a/cmp/demo/vite-react-spa/public/translations/de.json b/demo/new-compiler-vite-react-spa/public/translations/de.json similarity index 98% rename from cmp/demo/vite-react-spa/public/translations/de.json rename to demo/new-compiler-vite-react-spa/public/translations/de.json index a5f8fa7a8..37358010e 100644 --- a/cmp/demo/vite-react-spa/public/translations/de.json +++ b/demo/new-compiler-vite-react-spa/public/translations/de.json @@ -2,6 +2,15 @@ "version": 0.1, "locale": "de", "entries": { + "daa4d8839395": "{counter} mal geklickt", + "52ed9ee761d8": "Hallo Welt", + "f11fc78c3ac0": "Gemischter Inhalt Fragment", + "556f5956dca7": "Willkommen zur Lingo.dev Compiler Demo", + "02704ec4e52a": "Es extrahiert automatisch Text aus Ihrem JSX und übersetzt ihn in andere Sprachen.", + "de6bfb30be49": "Text, der als eingefügt wird, wird nicht übersetzt: {text}", + "5c15bd35e916": "Um es zu übersetzen, müssen Sie es in '<'>{translatableText} '<'/> einschließen", + "93b50fe805b7": "Text außerhalb der Komponente wird nicht übersetzt: {externalText}", + "d756b03ffbf5": "Inhalte, die Text und andere Tags enthalten, werden als eine Einheit übersetzt: {translatableMixedContextFragment}", "8492c53cfbaf": "Über Lingo.dev", "8aa4fe3f0590": "Dies ist eine Demo-Anwendung, die den Lingo.dev-Compiler für automatische Übersetzungen in React-Anwendungen präsentiert.", "af76f667703b": "Hauptfunktionen", @@ -12,15 +21,6 @@ "aca12d550fe2": "Unterstützung für Server- und Client-Komponenten", "44a3311c3a4a": "Wie es funktioniert", "0add30e37450": "Der Compiler analysiert Ihre React-Komponenten zur Build-Zeit und extrahiert automatisch alle übersetzbaren Strings. Anschließend generiert er Übersetzungen mit Ihrem konfigurierten Übersetzungsanbieter.", - "07d84d34dd3a": "Fügen Sie einfach die Direktive \"use i18n\" am Anfang Ihrer Komponentendateien hinzu, und der Compiler erledigt den Rest!", - "daa4d8839395": "{counter} mal geklickt", - "52ed9ee761d8": "Hallo Welt", - "f11fc78c3ac0": "Gemischter Inhalt Fragment", - "556f5956dca7": "Willkommen zur Lingo.dev Compiler Demo", - "02704ec4e52a": "Es extrahiert automatisch Text aus Ihrem JSX und übersetzt ihn in andere Sprachen.", - "de6bfb30be49": "Text, der als eingefügt wird, wird nicht übersetzt: {text}", - "5c15bd35e916": "Um es zu übersetzen, müssen Sie es in '<'>{translatableText} '<'/> einschließen", - "93b50fe805b7": "Text außerhalb der Komponente wird nicht übersetzt: {externalText}", - "d756b03ffbf5": "Inhalte, die Text und andere Tags enthalten, werden als eine Einheit übersetzt: {translatableMixedContextFragment}" + "07d84d34dd3a": "Fügen Sie einfach die Direktive \"use i18n\" am Anfang Ihrer Komponentendateien hinzu, und der Compiler erledigt den Rest!" } -} +} \ No newline at end of file diff --git a/cmp/demo/vite-react-spa/public/translations/en.json b/demo/new-compiler-vite-react-spa/public/translations/en.json similarity index 98% rename from cmp/demo/vite-react-spa/public/translations/en.json rename to demo/new-compiler-vite-react-spa/public/translations/en.json index a4a78986f..f7ede8432 100644 --- a/cmp/demo/vite-react-spa/public/translations/en.json +++ b/demo/new-compiler-vite-react-spa/public/translations/en.json @@ -2,6 +2,15 @@ "version": 0.1, "locale": "en", "entries": { + "daa4d8839395": "Clicked {counter} times", + "52ed9ee761d8": "Hello World", + "f11fc78c3ac0": "Mixed content fragment", + "556f5956dca7": "Welcome to Lingo.dev compiler demo", + "02704ec4e52a": "It automatically extract text from your JSX and translate it to other languages.", + "de6bfb30be49": "Text inserted as an is not translated: {text}", + "5c15bd35e916": "To translate it you have to wrap it into the '<'>{translatableText} '<'/>", + "93b50fe805b7": "Text external to the component is not translated: {externalText}", + "d756b03ffbf5": "Content that has text and other tags inside will br translated as a single entity: {translatableMixedContextFragment}", "8492c53cfbaf": "About Lingo.dev", "8aa4fe3f0590": "This is a demo application showcasing the Lingo.dev compiler for automatic translations in React applications.", "af76f667703b": "Key Features", @@ -12,15 +21,6 @@ "aca12d550fe2": "Server and client component support", "44a3311c3a4a": "How It Works", "0add30e37450": "The compiler analyzes your React components at build time and automatically extracts all translatable strings. It then generates translations using your configured translation provider.", - "07d84d34dd3a": "Simply add the \"use i18n\" directive at the top of your component files, and the compiler handles the rest!", - "daa4d8839395": "Clicked {counter} times", - "52ed9ee761d8": "Hello World", - "f11fc78c3ac0": "Mixed content fragment", - "556f5956dca7": "Welcome to Lingo.dev compiler demo", - "02704ec4e52a": "It automatically extract text from your JSX and translate it to other languages.", - "de6bfb30be49": "Text inserted as an is not translated: {text}", - "5c15bd35e916": "To translate it you have to wrap it into the '<'>{translatableText} '<'/>", - "93b50fe805b7": "Text external to the component is not translated: {externalText}", - "d756b03ffbf5": "Content that has text and other tags inside will br translated as a single entity: {translatableMixedContextFragment}" + "07d84d34dd3a": "Simply add the \"use i18n\" directive at the top of your component files, and the compiler handles the rest!" } -} +} \ No newline at end of file diff --git a/cmp/demo/vite-react-spa/public/translations/es.json b/demo/new-compiler-vite-react-spa/public/translations/es.json similarity index 97% rename from cmp/demo/vite-react-spa/public/translations/es.json rename to demo/new-compiler-vite-react-spa/public/translations/es.json index b2861d029..750cefb27 100644 --- a/cmp/demo/vite-react-spa/public/translations/es.json +++ b/demo/new-compiler-vite-react-spa/public/translations/es.json @@ -2,6 +2,15 @@ "version": 0.1, "locale": "es", "entries": { + "daa4d8839395": "Clicado {counter} veces", + "52ed9ee761d8": "Hola Mundo", + "f11fc78c3ac0": "Contenido mixto fragmento", + "556f5956dca7": "Bienvenido a la demo de Lingo.dev compiler", + "02704ec4e52a": "Extrae automáticamente texto de tu JSX y lo traduce a otros idiomas.", + "de6bfb30be49": "El texto insertado como no se traduce: {text}", + "5c15bd35e916": "Para traducirlo tienes que envolverlo en el '<'>{translatableText} '<'/>", + "93b50fe805b7": "El texto externo al componente no se traduce: {externalText}", + "d756b03ffbf5": "El contenido que tiene texto y otras etiquetas dentro se traducirá como una sola entidad: {translatableMixedContextFragment}", "8492c53cfbaf": "Acerca de Lingo.dev", "8aa4fe3f0590": "Esta es una aplicación de demostración que muestra el compilador Lingo.dev para traducciones automáticas en aplicaciones React.", "af76f667703b": "Características principales", @@ -12,15 +21,6 @@ "aca12d550fe2": "Soporte para componentes de servidor y cliente", "44a3311c3a4a": "Cómo funciona", "0add30e37450": "El compilador analiza tus componentes de React en tiempo de compilación y extrae automáticamente todas las cadenas traducibles. Luego genera traducciones utilizando tu proveedor de traducción configurado.", - "07d84d34dd3a": "¡Simplemente agrega la directiva \"use i18n\" en la parte superior de tus archivos de componentes, y el compilador se encarga del resto!", - "daa4d8839395": "Clicado {counter} veces", - "52ed9ee761d8": "Hola Mundo", - "f11fc78c3ac0": "Contenido mixto fragmento", - "556f5956dca7": "Bienvenido a la demo de Lingo.dev compiler", - "02704ec4e52a": "Extrae automáticamente texto de tu JSX y lo traduce a otros idiomas.", - "de6bfb30be49": "El texto insertado como no se traduce: {text}", - "5c15bd35e916": "Para traducirlo tienes que envolverlo en el '<'>{translatableText} '<'/>", - "93b50fe805b7": "El texto externo al componente no se traduce: {externalText}", - "d756b03ffbf5": "El contenido que tiene texto y otras etiquetas dentro se traducirá como una sola entidad: {translatableMixedContextFragment}" + "07d84d34dd3a": "¡Simplemente agrega la directiva \"use i18n\" en la parte superior de tus archivos de componentes, y el compilador se encarga del resto!" } -} +} \ No newline at end of file diff --git a/cmp/demo/vite-react-spa/public/translations/fr.json b/demo/new-compiler-vite-react-spa/public/translations/fr.json similarity index 97% rename from cmp/demo/vite-react-spa/public/translations/fr.json rename to demo/new-compiler-vite-react-spa/public/translations/fr.json index a25cc6766..1d4224d18 100644 --- a/cmp/demo/vite-react-spa/public/translations/fr.json +++ b/demo/new-compiler-vite-react-spa/public/translations/fr.json @@ -2,6 +2,15 @@ "version": 0.1, "locale": "fr", "entries": { + "daa4d8839395": "Cliqué {counter} fois", + "52ed9ee761d8": "Bonjour le monde", + "f11fc78c3ac0": "Contenu mixte fragment", + "556f5956dca7": "Bienvenue dans la démo du compilateur Lingo.dev", + "02704ec4e52a": "Il extrait automatiquement le texte de votre JSX et le traduit dans d'autres langues.", + "de6bfb30be49": "Le texte inséré comme un n'est pas traduit : {text}", + "5c15bd35e916": "Pour le traduire, vous devez l'envelopper dans le '<'>{translatableText} '<'/>", + "93b50fe805b7": "Le texte externe au composant n'est pas traduit : {externalText}", + "d756b03ffbf5": "Le contenu qui contient du texte et d'autres balises sera traduit comme une seule entité : {translatableMixedContextFragment}", "8492c53cfbaf": "À propos de Lingo.dev", "8aa4fe3f0590": "Ceci est une application de démonstration présentant le compilateur Lingo.dev pour les traductions automatiques dans les applications React.", "af76f667703b": "Fonctionnalités clés", @@ -12,15 +21,6 @@ "aca12d550fe2": "Prise en charge des composants serveur et client", "44a3311c3a4a": "Comment ça fonctionne", "0add30e37450": "Le compilateur analyse vos composants React au moment de la compilation et extrait automatiquement toutes les chaînes traduisibles. Il génère ensuite des traductions en utilisant votre fournisseur de traduction configuré.", - "07d84d34dd3a": "Ajoutez simplement la directive \"use i18n\" en haut de vos fichiers de composants, et le compilateur s'occupe du reste !", - "daa4d8839395": "Cliqué {counter} fois", - "52ed9ee761d8": "Bonjour le monde", - "f11fc78c3ac0": "Contenu mixte fragment", - "556f5956dca7": "Bienvenue dans la démo du compilateur Lingo.dev", - "02704ec4e52a": "Il extrait automatiquement le texte de votre JSX et le traduit dans d'autres langues.", - "de6bfb30be49": "Le texte inséré comme un n'est pas traduit : {text}", - "5c15bd35e916": "Pour le traduire, vous devez l'envelopper dans le '<'>{translatableText} '<'/>", - "93b50fe805b7": "Le texte externe au composant n'est pas traduit : {externalText}", - "d756b03ffbf5": "Le contenu qui contient du texte et d'autres balises sera traduit comme une seule entité : {translatableMixedContextFragment}" + "07d84d34dd3a": "Ajoutez simplement la directive \"use i18n\" en haut de vos fichiers de composants, et le compilateur s'occupe du reste !" } -} +} \ No newline at end of file diff --git a/cmp/demo/vite-react-spa/src/.lingo/cache/de.json b/demo/new-compiler-vite-react-spa/src/.lingo/cache/de.json similarity index 100% rename from cmp/demo/vite-react-spa/src/.lingo/cache/de.json rename to demo/new-compiler-vite-react-spa/src/.lingo/cache/de.json diff --git a/cmp/demo/vite-react-spa/src/.lingo/cache/en.json b/demo/new-compiler-vite-react-spa/src/.lingo/cache/en.json similarity index 100% rename from cmp/demo/vite-react-spa/src/.lingo/cache/en.json rename to demo/new-compiler-vite-react-spa/src/.lingo/cache/en.json diff --git a/cmp/demo/vite-react-spa/src/.lingo/cache/es.json b/demo/new-compiler-vite-react-spa/src/.lingo/cache/es.json similarity index 100% rename from cmp/demo/vite-react-spa/src/.lingo/cache/es.json rename to demo/new-compiler-vite-react-spa/src/.lingo/cache/es.json diff --git a/cmp/demo/vite-react-spa/src/.lingo/cache/fr.json b/demo/new-compiler-vite-react-spa/src/.lingo/cache/fr.json similarity index 100% rename from cmp/demo/vite-react-spa/src/.lingo/cache/fr.json rename to demo/new-compiler-vite-react-spa/src/.lingo/cache/fr.json diff --git a/cmp/demo/vite-react-spa/src/components/Header.tsx b/demo/new-compiler-vite-react-spa/src/components/Header.tsx similarity index 100% rename from cmp/demo/vite-react-spa/src/components/Header.tsx rename to demo/new-compiler-vite-react-spa/src/components/Header.tsx diff --git a/cmp/demo/vite-react-spa/src/logo.svg b/demo/new-compiler-vite-react-spa/src/logo.svg similarity index 100% rename from cmp/demo/vite-react-spa/src/logo.svg rename to demo/new-compiler-vite-react-spa/src/logo.svg diff --git a/cmp/demo/vite-react-spa/src/main.tsx b/demo/new-compiler-vite-react-spa/src/main.tsx similarity index 100% rename from cmp/demo/vite-react-spa/src/main.tsx rename to demo/new-compiler-vite-react-spa/src/main.tsx diff --git a/cmp/demo/vite-react-spa/src/reportWebVitals.ts b/demo/new-compiler-vite-react-spa/src/reportWebVitals.ts similarity index 100% rename from cmp/demo/vite-react-spa/src/reportWebVitals.ts rename to demo/new-compiler-vite-react-spa/src/reportWebVitals.ts diff --git a/cmp/demo/vite-react-spa/src/routeTree.gen.ts b/demo/new-compiler-vite-react-spa/src/routeTree.gen.ts similarity index 100% rename from cmp/demo/vite-react-spa/src/routeTree.gen.ts rename to demo/new-compiler-vite-react-spa/src/routeTree.gen.ts diff --git a/cmp/demo/vite-react-spa/src/routes/__root.tsx b/demo/new-compiler-vite-react-spa/src/routes/__root.tsx similarity index 100% rename from cmp/demo/vite-react-spa/src/routes/__root.tsx rename to demo/new-compiler-vite-react-spa/src/routes/__root.tsx diff --git a/cmp/demo/vite-react-spa/src/routes/about.tsx b/demo/new-compiler-vite-react-spa/src/routes/about.tsx similarity index 100% rename from cmp/demo/vite-react-spa/src/routes/about.tsx rename to demo/new-compiler-vite-react-spa/src/routes/about.tsx diff --git a/cmp/demo/vite-react-spa/src/routes/index.tsx b/demo/new-compiler-vite-react-spa/src/routes/index.tsx similarity index 100% rename from cmp/demo/vite-react-spa/src/routes/index.tsx rename to demo/new-compiler-vite-react-spa/src/routes/index.tsx diff --git a/cmp/demo/vite-react-spa/src/styles.css b/demo/new-compiler-vite-react-spa/src/styles.css similarity index 100% rename from cmp/demo/vite-react-spa/src/styles.css rename to demo/new-compiler-vite-react-spa/src/styles.css diff --git a/cmp/demo/vite-react-spa/tsconfig.json b/demo/new-compiler-vite-react-spa/tsconfig.json similarity index 100% rename from cmp/demo/vite-react-spa/tsconfig.json rename to demo/new-compiler-vite-react-spa/tsconfig.json diff --git a/cmp/demo/vite-react-spa/vite.config.ts b/demo/new-compiler-vite-react-spa/vite.config.ts similarity index 100% rename from cmp/demo/vite-react-spa/vite.config.ts rename to demo/new-compiler-vite-react-spa/vite.config.ts diff --git a/docs/svelte-integration-guide.md b/docs/svelte-integration-guide.md new file mode 100644 index 000000000..1780ebe6a --- /dev/null +++ b/docs/svelte-integration-guide.md @@ -0,0 +1,283 @@ + +--- +title: Svelte AI Translation with Lingo.dev +--- + +## What is Svelte? + +[Svelte](https://svelte.dev/) is a modern framework for building web applications. +Unlike other frameworks, Svelte compiles your code to highly optimized JavaScript at build time, so your app runs faster in the browser. + +--- + +## What is Lingo.dev? + +[Lingo.dev](https://lingo.dev/) is an AI-powered translation platform. +It allows you to automatically translate content in your project using a **CLI** for static translations. + +--- + +## About this guide + +This guide explains how to set up **Lingo.dev CLI** in a Svelte project. +You will learn how to: + +- Install Svelte or SvelteKit +- Create static content +- Configure and run Lingo.dev CLI +- Use translated content in your Svelte components + +--- + +## Step 1. Install Svelte (If you have an existing Svelte project, please skip to **Step 2**) + +### Using SvelteKit + +1. Open a terminal of your choice. +2. Create a new project: + +```bash + npx sv create +``` +- C__Windows_system32_cmd exe  03-11-2025 13_13_47 + +3. Follow the prompts to choose options. + +- C__Windows_system32_cmd exe  03-11-2025 13_14_22 + +4. Navigate into the project directory: + +```bash +cd +``` + +5. Start the development server: + +```bash +npm run dev -- --open +``` + +6. This should open localhost with the built-in Svelte page. + +### Using Vite + Svelte (Svelte compiler only) + +1. Open a terminal of your choice. +2. Create a new project: + +```bash +npm create vite@latest -- --template svelte +``` + +3. Navigate into the project directory: + +```bash +cd +``` + +4. Install dependencies: + +```bash +npm install +``` + +5. Start the development server: + +```bash +npm run dev -- --open +``` + +--- + +## Step 2. Create a Lingo.dev account + +1. Go to [lingo.dev](https://lingo.dev/). +2. Click **Get Started**. +3. Sign in using your preferred authorization method. +4. On the dashboard, click **Get API Key**. +5. Copy the key using the **Copy** button. + +> The API key starts with `api_` followed by a 24-character alphanumeric string. + +--- + +## Step 3. Install and Configure Lingo.dev CLI + +### 1. Initialize Lingo.dev in the project + +```bash +npx lingo.dev@latest init +``` + +### 2. Log in to Lingo.dev + +```bash +npx lingo.dev@latest login +``` + +3. ⚠ On Windows, `npx` may inject a shell script that cannot run without WSL or Git Bash. + + In that case, install globally: + + - C__Windows_system32_cmd exe  03-11-2025 13_48_58 + + ```bash + npm i -g lingo.dev + lingo.dev login + +### 3. Create a directory for localizable content + +```bash +mkdir -p src/lib/i18n +``` + +### 4. Create an English content file + +```bash +touch src/lib/i18n/en.json +``` + +### 5. Populate it with your content, for example: + +```json +{ + "home": { + "title": "Welcome", + "subtitle": "This text is translated by Lingo.dev" + }, + "cta": "Get started" +} +``` + +### 6. Create a root configuration file for the CLI + +```bash +touch i18n.json +``` + +Add the following content: + +```json +{ + "$schema": "https://lingo.dev/schema/i18n.json", + "version": "1.10", + "locale": { + "source": "en", + "targets": ["es"] + }, + "buckets": { + "json": { + "include": ["src/lib/i18n/[locale].json"] + } + } +} +``` + +### 7. Run the CLI to generate target language content + +```bash +npx lingo.dev@latest run +# or if globally installed +lingo.dev run +``` +- MINGW64__c_Users_SOHAM_Desktop_web development_my-lingo-app 03-11-2025 13_57_40 + +--- + +## Step 4. Create an i18n Consumer + +Add the following Svelte store setup to manage translations: + +```ts +// src/lib/i18n.ts +import { writable } from "svelte/store"; +import en from "$lib/i18n/en.json"; +import es from "$lib/i18n/es.json"; + +const translations = { en, es }; + +type Locale = keyof typeof translations; + +// Narrow the browser language to a supported locale or fallback to 'en' +const detectedLang = (navigator.language.split("-")[0] || "en") as string; +const browserLang: Locale = (Object.keys(translations) as Locale[]).includes( + detectedLang as Locale +) + ? (detectedLang as Locale) + : "en"; + +// Svelte stores +export const locale = writable(browserLang); +export const t = writable(translations[browserLang]); + +// Function to change the locale dynamically +export function setLocale(newLocale: Locale) { + if (translations[newLocale]) { + locale.set(newLocale); + t.set(translations[newLocale]); + } +} +``` + +--- + +## Step 5. Use Translations in a Svelte Component + +```ts + + +

{translation.home.title}

+

{translation.home.subtitle}

+ + + + +``` + +--- + +## Step 6. Run and Test + +Start your dev server again: + +```bash +npm run dev +``` + +Visit your app in the browser and click the toggle button — +you should see your text switch between **English** and **Spanish**, powered by **Lingo.dev CLI** translations. + +--- + +## Docs + +* [Lingo.dev CLI Documentation](https://lingo.dev/cli) +* [Svelte Documentation](https://svelte.dev/docs) diff --git a/docs/vue-integration-guide.md b/docs/vue-integration-guide.md new file mode 100644 index 000000000..c74d09b4d --- /dev/null +++ b/docs/vue-integration-guide.md @@ -0,0 +1,337 @@ +--- +title: "Vue.js" +subtitle: "AI translation for Vue.js with Lingo.dev CLI" +--- + +## What is Vue.js? + +[Vue.js](https://vuejs.org/) is a progressive JavaScript framework for building user interfaces. It is designed to be incrementally adoptable and can easily scale between a library and a full-featured framework. + +## What is Lingo.dev CLI? + +Lingo.dev is an AI-powered translation platform. The Lingo.dev CLI reads source files, sends translatable content to large language models, and writes translated files back to your project. + +## About this guide + +This guide explains how to set up Lingo.dev CLI in a Vue.js application. You'll learn how to scaffold a project with Vue.js, configure a translation pipeline using vue-i18n, and implement language switching in your application. + +## Step 1. Set up a Vue.js project + +1. Install Vue CLI globally: + + ```bash + npm install -g @vue/cli + ``` + +2. Create a new Vue.js project: + + ```bash + vue create i18n-example + ``` + + When prompted for a preset, choose **"Manually select features"** and select: + + - Babel + - Router + - Linter / Formatter + + Then configure: + + - **Vue version:** 3.x + - **Router history mode:** Yes + - **Linter:** ESLint with error prevention only (or your preference) + +3. Navigate into the project directory: + + ```bash + cd i18n-example + ``` + +4. Install vue-i18n for internationalization: + + ```bash + npm install vue-i18n@9 + ``` + +## Step 2. Create source content + +1. Create a directory for storing localizable content: + + ```bash + mkdir -p src/locales + ``` + +2. Create a file that contains some localizable content (e.g., `src/locales/en.json`): + + ```json + { + "welcome": "Welcome to Your Vue.js App", + "description": "This text is translated by Lingo.dev", + "greeting": "Hello, {name}!", + "toggle": "Switch Language", + "counter": "You clicked {count} times" + } + ``` + +## Step 3. Configure the CLI + +In the root of the project, create an `i18n.json` file: + +```json +{ + "$schema": "https://lingo.dev/schema/i18n.json", + "version": "1.10", + "locale": { + "source": "en", + "targets": ["es", "fr", "de", "ja"] + }, + "buckets": { + "json": { + "include": ["src/locales/[locale].json"] + } + } +} +``` + +This file defines: + +- the files that Lingo.dev CLI should translate +- the languages to translate between + +In this case, the configuration translates JSON files from English to Spanish, French, German, and Japanese. + +It's important to note that: + +- `[locale]` is a placeholder that's replaced at runtime. It ensures that content is read from one location (e.g., `src/locales/en.json`) and written to a different location (e.g., `src/locales/es.json`). +- Lingo.dev CLI supports various file formats including JSON, MDX, and more. + +To learn more, see [i18n.json configuration](/cli/fundamentals/i18n-json-config). + +## Step 4. Translate the content + +1. [Sign up for a Lingo.dev account](/app). + +2. Log in to Lingo.dev via the CLI: + + ```bash + npx lingo.dev@latest login + ``` + +3. Run the translation pipeline: + + ```bash + npx lingo.dev@latest run + ``` + + The CLI will create translation files (e.g., `src/locales/es.json`, `src/locales/fr.json`, etc.) for storing the translated content and an `i18n.lock` file for keeping track of what has been translated (to prevent unnecessary retranslations). + +## Step 5. Set up vue-i18n in your application + +1. Create an i18n configuration file (`src/i18n.js`): + + ```javascript + import { createI18n } from "vue-i18n"; + + // It only imports 5 specific locales + import en from "./locales/en.json"; + import es from "./locales/es.json"; + import fr from "./locales/fr.json"; + import de from "./locales/de.json"; + import ja from "./locales/ja.json"; + + const messages = { + en, + es, + fr, + de, + ja, + }; + + // Create i18n instance + export default createI18n({ + legacy: false, //you must set this to `false` to use Composition API + locale: "en", // set locale + fallbackLocale: "en", // set fallback locale + messages, // set locale messages + }); + ``` + +2. Update your main.js file to use i18n: + + ```javascript + import { createApp } from "vue"; + import App from "./App.vue"; + import router from "./router"; + import i18n from "./i18n"; + + createApp(App).use(router).use(i18n).mount("#app"); + ``` + +## Step 6. Implement language switching in your Vue components + +1. Create a language switcher component (`src/components/LanguageSwitcher.vue`): + + ```vue + + + + + + ``` + +2. Update your `App.vue` to use translations: + + ```vue + + + + + + ``` + +3. Create a Counter component to demonstrate dynamic content (`src/components/Counter.vue`): + + ```vue + + + + + + ``` + +## Step 7. Test the application + +1. Start the development server: + + ```bash + npm run serve + ``` + +2. Navigate to the following URL: + + - http://localhost:8080 + +3. Verify that the language switching works: + + - The application should display content in English by default + - Selecting a different language from the dropdown should update all translated text + - The counter should continue to function in all languages diff --git a/i18n.json b/i18n.json new file mode 100644 index 000000000..1e199d5b4 --- /dev/null +++ b/i18n.json @@ -0,0 +1,42 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "ar", + "as-IN", + "bho", + "bn", + "de", + "es", + "fa", + "fr", + "gu-IN", + "he", + "hi", + "it", + "ja", + "ko", + "ml-IN", + "mr-IN", + "or-IN", + "pa-IN", + "pl", + "pt-BR", + "ru", + "si-LK", + "ta-IN", + "te-IN", + "tr", + "uk-UA", + "ur", + "zh-Hans" + ] + }, + "buckets": { + "mdx": { + "include": ["readme/[locale].md"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} diff --git a/i18n.lock b/i18n.lock new file mode 100644 index 000000000..df6f335b1 --- /dev/null +++ b/i18n.lock @@ -0,0 +1,53 @@ +version: 1 +checksums: + 29ba5363b2b0f1cc53dd4a667d52f86e: + content/0: 33695a94ee7240884ee6333632e7f380 + content/1: f2ab7f6b867f4a4b4e6c746e4ee3fc61 + content/2: 2acf1e217477dafe17423bcbdd123711 + content/3: b06ccc8f45a1b77c3f8aade9f12ad28b + content/4: fdb5d914fd53a8502b906bd89fe6f6d1 + content/5: 51adf33450cab2ef392e93147386647c + content/6: e8f224099ddfc434b281b592575f8606 + content/7: 463b13b8d939ec534c0b9d3245deeb96 + content/8: 694bfbbfabb3f70982aebfe8fa249e58 + content/9: a39f42026429684441827821e2e8d891 + content/10: a9c2e4ade137ba3d8978b9b7988929cf + content/11: db8d8472c95426545bb1f80956e2eeed + content/12: ec6c7831617bf0358043e4683a6fd618 + content/13: 0d25961f9ce4056854c9d5ad9ccbc1c8 + content/14: 51adf33450cab2ef392e93147386647c + content/15: 60bb4098369f54ba8a72bac60076b218 + content/16: 90b1210596ab126053e172530986aebf + content/17: 2ddb213e261e9002051c06858bee809b + content/18: 51adf33450cab2ef392e93147386647c + content/19: 48669d7fc8bb5f765e87ffd5386d61a1 + content/20: 21041825a02a64079cd88fb8ca132fe0 + content/21: c60c699a88f37ef6b6e1cca240c14dd7 + content/22: 68a3343deee29d5198c6b13c7a42d3da + content/23: c7782476858462e82e4aaeea744ba5be + content/24: 51adf33450cab2ef392e93147386647c + content/25: 0395d8b4658fbae7c47c28f560fe5ec5 + content/26: 3362f6e2c4992a99a372cf1bb5c97dfe + content/27: d541ec61d004d8bfaf981405c8eb50a9 + content/28: 5770157db19da195d00b4155abda32c2 + content/29: 07361c5ebf2a4301e9aa944727ceee49 + content/30: 51adf33450cab2ef392e93147386647c + content/31: e7b9bbce52747d42f1f937afa30dd8f2 + content/32: 3e790a0a44a7eba77ab4c3d3d1f4097f + content/33: 0ad1a51ba5c894a605013221cc227d59 + content/34: 570ef7c205f6cb50d90fd69efa0b90ee + content/35: 19c4c0ed72392d7d53fd1d0a75f7fcf1 + content/36: 51adf33450cab2ef392e93147386647c + content/37: d63dd71fe00a42b6a7e94f1897a907c7 + content/38: eb9e72b970c23be10290eecb06101724 + content/39: 97a4f55ecfa2cf1560c92001c13bfb08 + content/40: 50807ce1a32e29e02d3fe982cf18b1fd + content/41: 15c0fd631a07d8c7fbd3acb98d10d97a + content/42: c7315dc914f85a8e7be1553e0458fdda + content/43: a15715eea8694ee737338c021ea79787 + content/44: 3194966101f0d8ea12b02a4dcaa34d0e + content/45: f8c2f53155acc08b4ced42864d396221 + content/46: 412c30ecdf83b1ea2c01325570df0682 + content/47: 0d3adbe3787bc7f85b0fcf53c4e388fd + content/48: ac8d9035a00029b097de622b4c6b9d4b + content/49: 48b804cf2a1ab66e4bb3e009600d0ff4 diff --git a/integrations/directus/.gitignore b/integrations/directus/.gitignore new file mode 100644 index 000000000..2ee3677b6 --- /dev/null +++ b/integrations/directus/.gitignore @@ -0,0 +1 @@ +.directus diff --git a/integrations/directus/CHANGELOG.md b/integrations/directus/CHANGELOG.md new file mode 100644 index 000000000..5c2de786b --- /dev/null +++ b/integrations/directus/CHANGELOG.md @@ -0,0 +1,86 @@ +# @replexica/integration-directus + +## 0.1.11 + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +- Updated dependencies [[`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59)]: + - @replexica/sdk@0.7.13 + +## 0.1.10 + +### Patch Changes + +- [#937](https://github.com/lingodotdev/lingo.dev/pull/937) [`4e5983d`](https://github.com/lingodotdev/lingo.dev/commit/4e5983d7e59ebf9eb529c4b7c1c87689432ac873) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Update documentation URLs from docs.lingo.dev to lingo.dev/cli and lingo.dev/compiler + +## 0.1.9 + +### Patch Changes + +- [`fc3cb88`](https://github.com/lingodotdev/lingo.dev/commit/fc3cb8839cbbb574b69087625dd5f97cf37d5d35) Thanks [@vrcprl](https://github.com/vrcprl)! - Updated README file with target languages changes + +## 0.1.8 + +### Patch Changes + +- [`2571fcd`](https://github.com/lingodotdev/lingo.dev/commit/2571fcdce6e969d9df96526188c9f3f89dd80677) Thanks [@vrcprl](https://github.com/vrcprl)! - Added multimple target languages option + +## 0.1.7 + +### Patch Changes + +- [`bd7c0a6`](https://github.com/lingodotdev/lingo.dev/commit/bd7c0a62ddfc5144690f6f572667a27d572e521a) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - update `@replexica/sdk` version + +## 0.1.6 + +### Patch Changes + +- [`e808190`](https://github.com/lingodotdev/lingo.dev/commit/e80819059b89f4a3f69980bab0979432cb7823bf) Thanks [@vrcprl](https://github.com/vrcprl)! - Fixed screenshot + +- Updated dependencies []: + - @replexica/sdk@0.7.7 + +## 0.1.5 + +### Patch Changes + +- [`ca7a085`](https://github.com/lingodotdev/lingo.dev/commit/ca7a085033ff31780001db1e6d58d818b60beded) Thanks [@vrcprl](https://github.com/vrcprl)! - Add README + +## 0.1.4 + +### Patch Changes + +- [`998a4a6`](https://github.com/lingodotdev/lingo.dev/commit/998a4a6267ff8542279a8f6d812d5579e3a78a42) Thanks [@vrcprl](https://github.com/vrcprl)! - Update primary key selection + +## 0.1.3 + +### Patch Changes + +- [`75253b6`](https://github.com/lingodotdev/lingo.dev/commit/75253b66833b000bf80d6880e92e3c60f5bcd068) Thanks [@vrcprl](https://github.com/vrcprl)! - Update replexica sdk version + +## 0.1.2 + +### Patch Changes + +- Updated dependencies []: + - @replexica/sdk@0.7.5 + +## 0.1.1 + +### Patch Changes + +- [`22490ab`](https://github.com/lingodotdev/lingo.dev/commit/22490ab94f22d8e5769b23dc58d811fc8483f714) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add Directus integration + +## 0.1.0 + +### Minor Changes + +- [`03b4506`](https://github.com/lingodotdev/lingo.dev/commit/03b45063f435715967825f70daf3f5bbdb05b9a0) Thanks [@vrcprl](https://github.com/vrcprl)! - Lingo.dev integration for Directus + +## 0.0.1 + +### Patch Changes + +- [#341](https://github.com/lingodotdev/lingo.dev/pull/341) [`1df47d6`](https://github.com/lingodotdev/lingo.dev/commit/1df47d6095f907e1d756524f5e2cc2e043fdb093) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - empty directus integration package diff --git a/integrations/directus/Dockerfile b/integrations/directus/Dockerfile new file mode 100644 index 000000000..b1330058d --- /dev/null +++ b/integrations/directus/Dockerfile @@ -0,0 +1,10 @@ +FROM directus/directus:11 + +USER root + +RUN npm install -g pnpm@latest + +USER node + +# Install the integration +RUN pnpm install @replexica/integration-directus diff --git a/integrations/directus/README.md b/integrations/directus/README.md new file mode 100644 index 000000000..5b434e97b --- /dev/null +++ b/integrations/directus/README.md @@ -0,0 +1,164 @@ +# Lingo.dev Integration for Directus + +This is the official Lingo.dev integration for [Directus](https://directus.io), a headless CMS, enabling automated AI-powered localization within your Directus workflow. + +## Overview + +This integration adds a Lingo.dev operation to Directus CMS that allows you to automatically translate content across 80+ languages using Lingo.dev's AI localization engine. + +## Configuration + +1. Install Lingo.dev Extension in your Directus project +2. Create a new Flow in Directus with Lingo.dev Extension +3. Run the Flow to localize content + +## 1. Set up Lingo.dev Extension + +This section is based on the [Directus documentation for installing extensions via the npm registry](https://docs.directus.io/extensions/installing-extensions.html#installing-via-the-npm-registry). + +### Modify `docker-compose.yml` + +Open the `docker-compose.yml` file of your project and replace the `image` option with a `build` section: + +- remove the `image` option: + +```yaml +image: directus/directus:11.x.y +``` + +- add the `build` section: + +```yaml +build: + context: ./ +``` + +This allows you to build a customized Docker Image with the added extensions. + +### Create a `Dockerfile` + +At the root of your project, create a `Dockerfile` if one doesn't already exist and add the following: + +```Dockerfile +FROM directus/directus:11.x.y + +USER root +RUN corepack enable +USER node + +RUN pnpm install @replexica/integration-directus +``` + +### Build the Docker Image + +Build your Docker image: + +```bash +docker compose build +``` + +### Start the Docker Container + +Start your Docker container: + +```bash +docker compose up +``` + +On startup, Directus will automatically load any extension installed in the previous steps. + +## 2. Create a New Flow + +1. Navigate to the Flows section in Directus CMS. +2. Create a new Flow + +![Create Flow](https://nlugbbdqxnqwhydszieg.supabase.co/storage/v1/object/public/replexica-integration-directus/create-flow.png) + +3. Select a Manual trigger, check collections to apply to, and Save. + +![Select Trigger](https://nlugbbdqxnqwhydszieg.supabase.co/storage/v1/object/public/replexica-integration-directus/create-new-flow-trigger.png) + +4. Add Confirmation dialog with Target Languages and Save. + +![Add Confirmation Dialog](https://nlugbbdqxnqwhydszieg.supabase.co/storage/v1/object/public/replexica-integration-directus/confirmation-dialog.png) + +5. Click '+' to add a new operation and select Lingo.dev Integration for Directus. + +![Add Operation](https://nlugbbdqxnqwhydszieg.supabase.co/storage/v1/object/public/replexica-integration-directus/replexica-operation.png) + +6. Configure the operation with the required parameters. + +![Configure Operation](https://nlugbbdqxnqwhydszieg.supabase.co/storage/v1/object/public/replexica-integration-directus/replexica-operation-settings.png) + +7. Save the Flow. + +## 3. Run the Flow + +Go to Content and run the Flow on the collection to localize your content. + +![Run Flow](https://nlugbbdqxnqwhydszieg.supabase.co/storage/v1/object/public/replexica-integration-directus/run-flow.png) + +## Results + +The Flow will automatically translate the content in the selected collection. + +![Flow Results](https://nlugbbdqxnqwhydszieg.supabase.co/storage/v1/object/public/replexica-integration-directus/flow-results.png) + +## Lingo.dev Extension Inputs + +The integration provides a Directus operation that accepts the following parameters: + +- `item_id`: The ID of the item to translate +- `collection`: The collection containing the content +- `translation_table`: The table storing translations +- `language_table`: The table containing supported languages +- `replexica_api_key`: Your Lingo.dev API key +- `source_language`: Source language code (defaults to 'en-US') +- `target_languages`: Array of target language codes (example: ['fr-FR', 'de-DE']) + +## Development + +To run the integration locally: + +```bash +# Clone the repo +git clone https://github.com/lingodotdev/lingo.dev + +# Install dependencies +cd integrations/directus +pnpm install + +# Run dev server +pnpm dev + +# Build +pnpm build + +# Run tests +pnpm test +``` + +The integration can be tested using the included Docker setup: + +```bash +docker-compose up +``` + +This will start Directus at [http://localhost:8055](http://localhost:8055). + +## License + +[Apache-2.0](./LICENSE) + +## More Information + +- [Lingo.dev Documentation](https://lingo.dev) +- [Directus Extensions Guide](https://docs.directus.io/extensions/operations) +- [GitHub Repository](https://github.com/lingodotdev/lingo.dev) + +## Support + +For questions and support: + +- [Lingo.dev Discord](https://lingo.dev/go/discord) +- Email: diff --git a/integrations/directus/docker-compose.yml b/integrations/directus/docker-compose.yml new file mode 100644 index 000000000..8881f2b07 --- /dev/null +++ b/integrations/directus/docker-compose.yml @@ -0,0 +1,18 @@ +version: "3" +services: + directus: + build: + context: ./ + ports: + - 8055:8055 + volumes: + - ./.directus/database:/directus/database + - ./.directus/uploads:/directus/uploads + - ./:/directus/extensions/lingo.dev-directus-extension + environment: + SECRET: "replace-with-secure-random-value" + ADMIN_EMAIL: "admin@example.com" + ADMIN_PASSWORD: "d1r3ctu5" + DB_CLIENT: "sqlite3" + DB_FILENAME: "/directus/database/data.db" + WEBSOCKETS_ENABLED: "true" diff --git a/integrations/directus/package.json b/integrations/directus/package.json new file mode 100644 index 000000000..70b822014 --- /dev/null +++ b/integrations/directus/package.json @@ -0,0 +1,39 @@ +{ + "name": "@replexica/integration-directus", + "version": "0.1.10", + "description": "Lingo.dev integration for Directus", + "private": false, + "sideEffects": false, + "directus:extension": { + "type": "operation", + "path": { + "app": "build/app.mjs", + "api": "build/api.mjs" + }, + "source": { + "app": "src/app.ts", + "api": "src/api.ts" + }, + "host": "^10.10.0" + }, + "files": [ + "build", + "readme.md", + "changelog.md" + ], + "scripts": { + "dev": "tsup --watch", + "build": "tsc --noEmit && tsup", + "test": "vitest run" + }, + "license": "Apache-2.0", + "dependencies": { + "@replexica/sdk": "0.7.12" + }, + "devDependencies": { + "@directus/extensions-sdk": "17.0.3", + "tsup": "8.5.1", + "typescript": "5.9.3", + "vitest": "4.0.13" + } +} \ No newline at end of file diff --git a/integrations/directus/src/api.ts b/integrations/directus/src/api.ts new file mode 100644 index 000000000..aaada74bc --- /dev/null +++ b/integrations/directus/src/api.ts @@ -0,0 +1,276 @@ +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; +} + +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({ + 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 = {}; + let objectToTranslate: Record = {}; + 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); +} diff --git a/integrations/directus/src/app.ts b/integrations/directus/src/app.ts new file mode 100644 index 000000000..0947bc0b8 --- /dev/null +++ b/integrations/directus/src/app.ts @@ -0,0 +1,96 @@ +import { defineOperationApp } from "@directus/extensions-sdk"; + +export default defineOperationApp({ + id: "replexica-integration-directus", + name: "Replexica Integration for Directus", + icon: "translate", + description: + "Use Replexica Localization Engine to make content multilingual.", + overview: ({ collection }) => [ + { + label: "$t:collection", + text: collection, + }, + ], + options: [ + { + field: "item_id", + name: "Item ID", + type: "string", + meta: { + interface: "input", + width: "half", + }, + }, + { + field: "collection", + name: "$t:collection", + type: "string", + meta: { + interface: "system-collection", + options: { + includeSystem: true, + includeSingleton: false, + }, + width: "half", + }, + }, + { + field: "source_language", + name: "Source Language", + type: "string", + meta: { + interface: "input", + width: "half", + }, + }, + { + field: "target_languages", + name: "Target Languages", + type: "string", + meta: { + interface: "input", + width: "half", + }, + }, + { + field: "translation_table", + name: "Translation Table", + type: "string", + meta: { + interface: "system-collection", + options: { + includeSystem: true, + includeSingleton: false, + }, + width: "half", + }, + }, + { + field: "language_table", + name: "Languages Table", + type: "string", + meta: { + interface: "system-collection", + options: { + includeSystem: true, + includeSingleton: false, + }, + width: "half", + }, + }, + { + field: "replexica_api_key", + name: "Replexica API Key", + type: "string", + meta: { + interface: "input-hash", + width: "half", + options: { + masked: true, + placeholder: "Enter your Replexica API key", + }, + }, + }, + ], +}); diff --git a/integrations/directus/src/index.spec.ts b/integrations/directus/src/index.spec.ts new file mode 100644 index 000000000..1b6af7788 --- /dev/null +++ b/integrations/directus/src/index.spec.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from "vitest"; + +describe("Test suite", () => { + it("should pass", () => { + expect(1).toBe(1); + }); +}); diff --git a/integrations/directus/tsconfig.json b/integrations/directus/tsconfig.json new file mode 100644 index 000000000..c35153d4a --- /dev/null +++ b/integrations/directus/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "rootDir": "src", + "outDir": "build" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"] +} diff --git a/integrations/directus/tsconfig.test.json b/integrations/directus/tsconfig.test.json new file mode 100644 index 000000000..a64e65041 --- /dev/null +++ b/integrations/directus/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "src/**/*.tsx"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/integrations/directus/tsup.config.ts b/integrations/directus/tsup.config.ts new file mode 100644 index 000000000..392702f8e --- /dev/null +++ b/integrations/directus/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + clean: true, + target: "esnext", + entry: ["src/api.ts", "src/app.ts"], + outDir: "build", + format: ["cjs", "esm"], + cjsInterop: true, + splitting: true, + external: ["@replexica/sdk"], + outExtension: (ctx) => ({ + js: ctx.format === "cjs" ? ".cjs" : ".mjs", + }), +}); diff --git a/legacy/cli/CHANGELOG.md b/legacy/cli/CHANGELOG.md new file mode 100644 index 000000000..8f0afde37 --- /dev/null +++ b/legacy/cli/CHANGELOG.md @@ -0,0 +1,893 @@ +# replexica + +## 0.71.2 + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +- Updated dependencies [[`348b2de`](https://github.com/lingodotdev/lingo.dev/commit/348b2de39412101bacb5ed541b0db23f0ca6213d), [`04c3679`](https://github.com/lingodotdev/lingo.dev/commit/04c3679c69231012f167da1640dc17ac57743d6b), [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59), [`797f913`](https://github.com/lingodotdev/lingo.dev/commit/797f9132b5cf05fe457968b691bca10db1fc37bb)]: + - lingo.dev@0.120.0 + +## 0.71.1 + +### Patch Changes + +- Updated dependencies [[`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144)]: + - lingo.dev@0.117.0 + +## 0.71.0 + +### Minor Changes + +- [#428](https://github.com/lingodotdev/lingo.dev/pull/428) [`5dd7b65`](https://github.com/lingodotdev/lingo.dev/commit/5dd7b6529ce174d8759e80644c3233927b1ecce4) Thanks [@mathio](https://github.com/mathio)! - map old env vars + +### Patch Changes + +- Updated dependencies [[`cd836e4`](https://github.com/lingodotdev/lingo.dev/commit/cd836e45cf810f495e2c6e1449824dc84794d571), [`5dd7b65`](https://github.com/lingodotdev/lingo.dev/commit/5dd7b6529ce174d8759e80644c3233927b1ecce4)]: + - lingo.dev@0.71.0 + +## 0.70.1 + +### Patch Changes + +- [`5dee9ee`](https://github.com/lingodotdev/lingo.dev/commit/5dee9ee743fbef489fbe342597a768ebd59e5f67) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add proxies to legacy packages + +- [`63eb57b`](https://github.com/lingodotdev/lingo.dev/commit/63eb57b8f4cc37605be196085fafbbfdab71cce5) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add deprecation message to legacy package jsons + +- [`bbf7760`](https://github.com/lingodotdev/lingo.dev/commit/bbf7760580f1631805d68612053ebcd4601bb02b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add deprecation warning to the legacy package proxies + +- Updated dependencies [[`b4c7f1e`](https://github.com/lingodotdev/lingo.dev/commit/b4c7f1e86334d229bee62219c26f30d0b523926d)]: + - lingo.dev@0.70.4 + +## 0.70.0 + +### Minor Changes + +- [`003344f`](https://github.com/lingodotdev/lingo.dev/commit/003344ffcca98a391a298707f18476971c4d4c2b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add locale delimiter override + +### Patch Changes + +- Updated dependencies [[`003344f`](https://github.com/lingodotdev/lingo.dev/commit/003344ffcca98a391a298707f18476971c4d4c2b)]: + - @replexica/cli@0.70.0 + - @replexica/react@0.3.19 + +## 0.69.0 + +### Minor Changes + +- [#411](https://github.com/lingodotdev/lingo.dev/pull/411) [`1b0647d`](https://github.com/lingodotdev/lingo.dev/commit/1b0647d91080f4947ba6227c397fb6232d0d1907) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add structure sync loader to cli + +### Patch Changes + +- Updated dependencies [[`1b0647d`](https://github.com/lingodotdev/lingo.dev/commit/1b0647d91080f4947ba6227c397fb6232d0d1907)]: + - @replexica/cli@0.69.0 + +## 0.68.0 + +### Minor Changes + +- [#408](https://github.com/lingodotdev/lingo.dev/pull/408) [`36fd4af`](https://github.com/lingodotdev/lingo.dev/commit/36fd4af376caf1540dc0a594fd65742c81706aa0) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - disable .po folding + +### Patch Changes + +- Updated dependencies [[`36fd4af`](https://github.com/lingodotdev/lingo.dev/commit/36fd4af376caf1540dc0a594fd65742c81706aa0)]: + - @replexica/cli@0.68.0 + +## 0.67.0 + +### Minor Changes + +- [#405](https://github.com/lingodotdev/lingo.dev/pull/405) [`446cf9c`](https://github.com/lingodotdev/lingo.dev/commit/446cf9c5c933f71a43fd5d80487b1608023cba8e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - improved .po loader + +- [#404](https://github.com/lingodotdev/lingo.dev/pull/404) [`3edef26`](https://github.com/lingodotdev/lingo.dev/commit/3edef26ef3a4e2d27394c5eeb2bc94b164e037ac) Thanks [@mathio](https://github.com/mathio)! - interactive init comman + +### Patch Changes + +- Updated dependencies [[`446cf9c`](https://github.com/lingodotdev/lingo.dev/commit/446cf9c5c933f71a43fd5d80487b1608023cba8e), [`3edef26`](https://github.com/lingodotdev/lingo.dev/commit/3edef26ef3a4e2d27394c5eeb2bc94b164e037ac)]: + - @replexica/cli@0.67.0 + +## 0.66.2 + +### Patch Changes + +- [#399](https://github.com/lingodotdev/lingo.dev/pull/399) [`01ca7bb`](https://github.com/lingodotdev/lingo.dev/commit/01ca7bb9d28d0de903caf44ec6ede2e2bbbb3ba2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - feat(cli): enhance .po loader to support plural translations and improve loader composition + +- Updated dependencies [[`01ca7bb`](https://github.com/lingodotdev/lingo.dev/commit/01ca7bb9d28d0de903caf44ec6ede2e2bbbb3ba2)]: + - @replexica/cli@0.66.2 + +## 0.66.1 + +### Patch Changes + +- [`aef36b5`](https://github.com/lingodotdev/lingo.dev/commit/aef36b53163efa523f7632786e0f293890f05b23) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - improve .po handling + +- Updated dependencies [[`aef36b5`](https://github.com/lingodotdev/lingo.dev/commit/aef36b53163efa523f7632786e0f293890f05b23)]: + - @replexica/cli@0.66.1 + +## 0.66.0 + +### Minor Changes + +- [`e885fcf`](https://github.com/lingodotdev/lingo.dev/commit/e885fcf8731d9f2a250cf44a534c5556a057ca51) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - single quotes escaping + +### Patch Changes + +- Updated dependencies [[`e885fcf`](https://github.com/lingodotdev/lingo.dev/commit/e885fcf8731d9f2a250cf44a534c5556a057ca51)]: + - @replexica/cli@0.66.0 + +## 0.65.1 + +### Patch Changes + +- [#390](https://github.com/lingodotdev/lingo.dev/pull/390) [`a2ada16`](https://github.com/lingodotdev/lingo.dev/commit/a2ada16ecf4cd559d3486f0e4756d58808194f7e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add ieee variables support + +- Updated dependencies [[`a2ada16`](https://github.com/lingodotdev/lingo.dev/commit/a2ada16ecf4cd559d3486f0e4756d58808194f7e)]: + - @replexica/cli@0.65.1 + - @replexica/react@0.3.18 + +## 0.65.0 + +### Minor Changes + +- [`bd577f2`](https://github.com/lingodotdev/lingo.dev/commit/bd577f22da52e7e889bb4b419cb5dab9865512f1) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - remove unlocalizable from dato + +### Patch Changes + +- Updated dependencies [[`bd577f2`](https://github.com/lingodotdev/lingo.dev/commit/bd577f22da52e7e889bb4b419cb5dab9865512f1)]: + - @replexica/cli@0.65.0 + +## 0.64.0 + +### Minor Changes + +- [#387](https://github.com/lingodotdev/lingo.dev/pull/387) [`8db4527`](https://github.com/lingodotdev/lingo.dev/commit/8db4527d9c3501d97f8bb7b414dd61e8a3ee80f6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add support for blocks / array of blocks / nested blocks + +### Patch Changes + +- Updated dependencies [[`8db4527`](https://github.com/lingodotdev/lingo.dev/commit/8db4527d9c3501d97f8bb7b414dd61e8a3ee80f6)]: + - @replexica/cli@0.64.0 + +## 0.63.1 + +### Patch Changes + +- [#382](https://github.com/lingodotdev/lingo.dev/pull/382) [`3320c8c`](https://github.com/lingodotdev/lingo.dev/commit/3320c8c6f9df9671e1002b63a00bf877270a6064) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix lockfile resetting when --key flag is applied + +- Updated dependencies [[`3320c8c`](https://github.com/lingodotdev/lingo.dev/commit/3320c8c6f9df9671e1002b63a00bf877270a6064)]: + - @replexica/cli@0.63.1 + +## 0.63.0 + +### Minor Changes + +- [`db2e800`](https://github.com/lingodotdev/lingo.dev/commit/db2e80013e44b478331b6a97008b3e67bae82a1f) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add --key flag for selective updates + +### Patch Changes + +- Updated dependencies [[`db2e800`](https://github.com/lingodotdev/lingo.dev/commit/db2e80013e44b478331b6a97008b3e67bae82a1f)]: + - @replexica/cli@0.63.0 + +## 0.62.0 + +### Minor Changes + +- [`302afdf`](https://github.com/lingodotdev/lingo.dev/commit/302afdfd3047b781bd9688921eab3dc84173aa20) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - handle C specifiers in localizable content + +### Patch Changes + +- Updated dependencies [[`302afdf`](https://github.com/lingodotdev/lingo.dev/commit/302afdfd3047b781bd9688921eab3dc84173aa20)]: + - @replexica/cli@0.62.0 + +## 0.61.0 + +### Minor Changes + +- [`9d38df2`](https://github.com/lingodotdev/lingo.dev/commit/9d38df2fdbe23fdcbb1b7e2e207de650e714e433) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fixed same-file locale rewrites + +### Patch Changes + +- Updated dependencies [[`9d38df2`](https://github.com/lingodotdev/lingo.dev/commit/9d38df2fdbe23fdcbb1b7e2e207de650e714e433)]: + - @replexica/cli@0.61.0 + +## 0.60.1 + +### Patch Changes + +- [#372](https://github.com/lingodotdev/lingo.dev/pull/372) [`b9a8350`](https://github.com/lingodotdev/lingo.dev/commit/b9a83502803f4a62fc9a62b4348f853f2baff20d) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix single-file results overwriting + +- [#371](https://github.com/lingodotdev/lingo.dev/pull/371) [`e6521b8`](https://github.com/lingodotdev/lingo.dev/commit/e6521b86637c254c011aba89a3558802c04ab3ca) Thanks [@mathio](https://github.com/mathio)! - support underscore in locale code + +- Updated dependencies [[`b9a8350`](https://github.com/lingodotdev/lingo.dev/commit/b9a83502803f4a62fc9a62b4348f853f2baff20d), [`e6521b8`](https://github.com/lingodotdev/lingo.dev/commit/e6521b86637c254c011aba89a3558802c04ab3ca)]: + - @replexica/cli@0.60.1 + - @replexica/react@0.3.17 + +## 0.60.0 + +### Minor Changes + +- [#356](https://github.com/lingodotdev/lingo.dev/pull/356) [`cff3c4e`](https://github.com/lingodotdev/lingo.dev/commit/cff3c4eb1a40f82a9c4c095e49cfd9fce053b048) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add dato support + +### Patch Changes + +- Updated dependencies [[`cff3c4e`](https://github.com/lingodotdev/lingo.dev/commit/cff3c4eb1a40f82a9c4c095e49cfd9fce053b048)]: + - @replexica/cli@0.60.0 + - @replexica/compiler@0.5.13 + - @replexica/react@0.3.16 + +## 0.59.1 + +### Patch Changes + +- Updated dependencies []: + - @replexica/cli@0.59.1 + - @replexica/compiler@0.5.12 + - @replexica/react@0.3.15 + +## 0.59.0 + +### Minor Changes + +- [`63daf00`](https://github.com/lingodotdev/lingo.dev/commit/63daf00e80004775f12c9e1d426cdd2bbf10f5a4) Thanks [@vrcprl](https://github.com/vrcprl)! - noop + +### Patch Changes + +- [`6eb5282`](https://github.com/lingodotdev/lingo.dev/commit/6eb5282063515db93fc76ff3137422862720fa0d) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - noop + +- Updated dependencies [[`63daf00`](https://github.com/lingodotdev/lingo.dev/commit/63daf00e80004775f12c9e1d426cdd2bbf10f5a4), [`3ab5de6`](https://github.com/lingodotdev/lingo.dev/commit/3ab5de66d8a913297b46095c2e73823124cc8c5b), [`3ab5de6`](https://github.com/lingodotdev/lingo.dev/commit/3ab5de66d8a913297b46095c2e73823124cc8c5b), [`6eb5282`](https://github.com/lingodotdev/lingo.dev/commit/6eb5282063515db93fc76ff3137422862720fa0d)]: + - @replexica/cli@0.59.0 + - @replexica/compiler@0.5.11 + - @replexica/react@0.3.14 + +## 0.58.2 + +### Patch Changes + +- Updated dependencies []: + - @replexica/cli@0.58.2 + - @replexica/compiler@0.5.10 + - @replexica/react@0.3.13 + +## 0.58.1 + +### Patch Changes + +- Updated dependencies []: + - @replexica/cli@0.58.1 + - @replexica/compiler@0.5.9 + +## 0.58.0 + +### Minor Changes + +- [`ff0d2d7`](https://github.com/lingodotdev/lingo.dev/commit/ff0d2d7fb12806a7264a72c03e48a8dda3526c23) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add retry with exponential backoff to the cli + +### Patch Changes + +- [`7ff7f8f`](https://github.com/lingodotdev/lingo.dev/commit/7ff7f8fca7318e4dba929194972d20ccf3487e9d) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - display number of entries in localization completion message + +- Updated dependencies [[`7ff7f8f`](https://github.com/lingodotdev/lingo.dev/commit/7ff7f8fca7318e4dba929194972d20ccf3487e9d), [`ff0d2d7`](https://github.com/lingodotdev/lingo.dev/commit/ff0d2d7fb12806a7264a72c03e48a8dda3526c23)]: + - @replexica/cli@0.58.0 + +## 0.57.1 + +### Patch Changes + +- Updated dependencies []: + - @replexica/cli@0.57.1 + - @replexica/compiler@0.5.8 + - @replexica/react@0.3.12 + +## 0.57.0 + +### Minor Changes + +- [`8e2cee4`](https://github.com/lingodotdev/lingo.dev/commit/8e2cee4b282c39fef1e00fa429e03e1c1e489cc5) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add `cleanup` command + +### Patch Changes + +- [`2c5cbcf`](https://github.com/lingodotdev/lingo.dev/commit/2c5cbcfbf6feb28440255cdea0818c8cefa61d91) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - filter out non extistent keys + +- [`ca10072`](https://github.com/lingodotdev/lingo.dev/commit/ca10072f636d8bd1105ed0f6cc84cf0af5a12402) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - improve progress logging in cli + +- Updated dependencies [[`2c5cbcf`](https://github.com/lingodotdev/lingo.dev/commit/2c5cbcfbf6feb28440255cdea0818c8cefa61d91), [`8e2cee4`](https://github.com/lingodotdev/lingo.dev/commit/8e2cee4b282c39fef1e00fa429e03e1c1e489cc5), [`ca10072`](https://github.com/lingodotdev/lingo.dev/commit/ca10072f636d8bd1105ed0f6cc84cf0af5a12402)]: + - @replexica/cli@0.57.0 + - @replexica/compiler@0.5.7 + +## 0.56.3 + +### Patch Changes + +- [`b8ad864`](https://github.com/lingodotdev/lingo.dev/commit/b8ad8643347088635eeeb568f1818d71d5226269) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - feat(cli): disable safe mode at localizable chunk level + +- Updated dependencies [[`b8ad864`](https://github.com/lingodotdev/lingo.dev/commit/b8ad8643347088635eeeb568f1818d71d5226269)]: + - @replexica/cli@0.56.3 + +## 0.56.2 + +### Patch Changes + +- Updated dependencies []: + - @replexica/cli@0.56.2 + - @replexica/compiler@0.5.6 + +## 0.56.1 + +### Patch Changes + +- Updated dependencies []: + - @replexica/cli@0.56.1 + - @replexica/compiler@0.5.5 + +## 0.56.0 + +### Minor Changes + +- [#298](https://github.com/lingodotdev/lingo.dev/pull/298) [`c03437d`](https://github.com/lingodotdev/lingo.dev/commit/c03437dc9cfd8183e40f74926b4ba7f0874ebf81) Thanks [@partik03](https://github.com/partik03)! - implemented xml loader + +- [`42d0a5a`](https://github.com/lingodotdev/lingo.dev/commit/42d0a5a7a53e296192a31e8f1d67c126793ea280) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added .localizeHtml implementation to SDK + +### Patch Changes + +- Updated dependencies [[`c03437d`](https://github.com/lingodotdev/lingo.dev/commit/c03437dc9cfd8183e40f74926b4ba7f0874ebf81), [`a6b22a3`](https://github.com/lingodotdev/lingo.dev/commit/a6b22a3237f574455d8119f914d82b0b247b4151), [`42d0a5a`](https://github.com/lingodotdev/lingo.dev/commit/42d0a5a7a53e296192a31e8f1d67c126793ea280)]: + - @replexica/cli@0.56.0 + - @replexica/compiler@0.5.4 + - @replexica/react@0.3.11 + +## 0.55.0 + +### Minor Changes + +- [`57e395a`](https://github.com/lingodotdev/lingo.dev/commit/57e395aae8ab100ba470bc7d1104ddfa178249e7) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add `--source` and `--target` flags to show files cmd + +### Patch Changes + +- Updated dependencies [[`57e395a`](https://github.com/lingodotdev/lingo.dev/commit/57e395aae8ab100ba470bc7d1104ddfa178249e7)]: + - @replexica/cli@0.55.0 + +## 0.54.0 + +### Minor Changes + +- [#301](https://github.com/lingodotdev/lingo.dev/pull/301) [`44b4cca`](https://github.com/lingodotdev/lingo.dev/commit/44b4cca2718bd72d55a938bac458d32a4536508a) Thanks [@partik03](https://github.com/partik03)! - --frozen flag + +- [`4fc27da`](https://github.com/lingodotdev/lingo.dev/commit/4fc27daae5810f6167726a28d76a874fd8421a5b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - replexica show files now shows both source and target paths + +### Patch Changes + +- Updated dependencies [[`44b4cca`](https://github.com/lingodotdev/lingo.dev/commit/44b4cca2718bd72d55a938bac458d32a4536508a), [`4fc27da`](https://github.com/lingodotdev/lingo.dev/commit/4fc27daae5810f6167726a28d76a874fd8421a5b)]: + - @replexica/cli@0.54.0 + +## 0.53.1 + +### Patch Changes + +- [`44b5c5c`](https://github.com/lingodotdev/lingo.dev/commit/44b5c5c498ca8df3bb814764f40057576c28c941) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - downgrade glob to @10, to allow node 18 + +- Updated dependencies [[`44b5c5c`](https://github.com/lingodotdev/lingo.dev/commit/44b5c5c498ca8df3bb814764f40057576c28c941)]: + - @replexica/cli@0.53.1 + +## 0.53.0 + +### Minor Changes + +- [`072e23e`](https://github.com/lingodotdev/lingo.dev/commit/072e23e58fca0da20bfd01f6a0ae600e6fb760a8) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - hide process summary label when there's zero elements to show + +### Patch Changes + +- Updated dependencies [[`072e23e`](https://github.com/lingodotdev/lingo.dev/commit/072e23e58fca0da20bfd01f6a0ae600e6fb760a8)]: + - @replexica/cli@0.53.0 + +## 0.51.2 + +### Patch Changes + +- Updated dependencies [[`6bc309c`](https://github.com/lingodotdev/lingo.dev/commit/6bc309c56a8e6a468510109182fd75f8f4e61b8f)]: + - @replexica/cli@0.52.0 + +## 0.51.1 + +### Patch Changes + +- [`e511b50`](https://github.com/lingodotdev/lingo.dev/commit/e511b5080dba58728e8650c7bf34d810cccdcf4e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added node@18 support + +- Updated dependencies [[`e511b50`](https://github.com/lingodotdev/lingo.dev/commit/e511b5080dba58728e8650c7bf34d810cccdcf4e)]: + - @replexica/cli@0.51.1 + +## 0.51.0 + +### Minor Changes + +- [#275](https://github.com/lingodotdev/lingo.dev/pull/275) [`091ee35`](https://github.com/lingodotdev/lingo.dev/commit/091ee353081795bf8f61c7d41483bc309c7b62ef) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add support for `.po` format + +### Patch Changes + +- Updated dependencies [[`091ee35`](https://github.com/lingodotdev/lingo.dev/commit/091ee353081795bf8f61c7d41483bc309c7b62ef)]: + - @replexica/cli@0.51.0 + - @replexica/compiler@0.5.3 + - @replexica/react@0.3.10 + +## 0.50.0 + +### Minor Changes + +- [#268](https://github.com/lingodotdev/lingo.dev/pull/268) [`5e282d7`](https://github.com/lingodotdev/lingo.dev/commit/5e282d7ffa5ca9494aa7046a090bb7c327085a86) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - composable loaders + +### Patch Changes + +- Updated dependencies [[`5e282d7`](https://github.com/lingodotdev/lingo.dev/commit/5e282d7ffa5ca9494aa7046a090bb7c327085a86)]: + - @replexica/cli@0.50.0 + - @replexica/compiler@0.5.2 + - @replexica/react@0.3.9 + +## 0.49.1 + +### Patch Changes + +- [`64cd6f3`](https://github.com/lingodotdev/lingo.dev/commit/64cd6f3765bb4524e9f78f93ff283e833a6f26a2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fixed path patter relativity + +- Updated dependencies [[`64cd6f3`](https://github.com/lingodotdev/lingo.dev/commit/64cd6f3765bb4524e9f78f93ff283e833a6f26a2)]: + - @replexica/cli@0.49.1 + +## 0.49.0 + +### Minor Changes + +- [`0071cd6`](https://github.com/lingodotdev/lingo.dev/commit/0071cd66b1c868ad3898fc368390a628c5a67767) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add csv format support + +### Patch Changes + +- [`1cc0796`](https://github.com/lingodotdev/lingo.dev/commit/1cc07961d221e397ad5dd2917bed76cb4f2b1f04) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add path.resolve to text loaders + +- Updated dependencies [[`0071cd6`](https://github.com/lingodotdev/lingo.dev/commit/0071cd66b1c868ad3898fc368390a628c5a67767), [`1cc0796`](https://github.com/lingodotdev/lingo.dev/commit/1cc07961d221e397ad5dd2917bed76cb4f2b1f04)]: + - @replexica/cli@0.49.0 + - @replexica/compiler@0.5.1 + - @replexica/react@0.3.8 + +## 0.48.0 + +### Minor Changes + +- [#264](https://github.com/lingodotdev/lingo.dev/pull/264) [`cdef5b7`](https://github.com/lingodotdev/lingo.dev/commit/cdef5b7bfbee4670c6de62cf4b4f3e0315748e25) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added format specific methods to `@replexica/sdk` + +### Patch Changes + +- [#261](https://github.com/lingodotdev/lingo.dev/pull/261) [`62c464d`](https://github.com/lingodotdev/lingo.dev/commit/62c464d5602909f8f6370dfa5009131a4d6719d0) Thanks [@Nithishvb](https://github.com/Nithishvb)! - This pr introduces a custom error handling base class for the CLI + +- Updated dependencies [[`cdef5b7`](https://github.com/lingodotdev/lingo.dev/commit/cdef5b7bfbee4670c6de62cf4b4f3e0315748e25), [`62c464d`](https://github.com/lingodotdev/lingo.dev/commit/62c464d5602909f8f6370dfa5009131a4d6719d0)]: + - @replexica/compiler@0.5.0 + - @replexica/cli@0.48.0 + +## 0.47.1 + +### Patch Changes + +- [`2859938`](https://github.com/lingodotdev/lingo.dev/commit/28599388a91bf80cea3813bb4b8999bb4df302c9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add missing locales + +- Updated dependencies []: + - @replexica/cli@0.47.1 + - @replexica/compiler@0.4.7 + - @replexica/react@0.3.7 + +## 0.47.0 + +### Minor Changes + +- [`4dfc8d8`](https://github.com/lingodotdev/lingo.dev/commit/4dfc8d8b301a690875401af5d107a88f1716182a) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added support for android format + +- [`ca9e20e`](https://github.com/lingodotdev/lingo.dev/commit/ca9e20eef9047e20d39ccf9dff74d2f6069d4676) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - .strings support + +- [`2aedf3b`](https://github.com/lingodotdev/lingo.dev/commit/2aedf3bec2d9dffc7b43fc10dea0cab5742d44af) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added support for .stringsdict + +- [#245](https://github.com/lingodotdev/lingo.dev/pull/245) [`3fc9da7`](https://github.com/lingodotdev/lingo.dev/commit/3fc9da7e3d2ec58e7f278c79a53eae6d3dfa5896) Thanks [@Nithishvb](https://github.com/Nithishvb)! - prevented overwritting of i18n.json with a default template for unsupported locales + +- [`626082a`](https://github.com/lingodotdev/lingo.dev/commit/626082a64b88fb3b589acd950afeafe417ce5ddc) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added Flutter .arb support + +### Patch Changes + +- [`2b5e3ae`](https://github.com/lingodotdev/lingo.dev/commit/2b5e3aea3f0745955266f6edf2ce34830242e503) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fixed yaml-root-key loader + +- [`747847a`](https://github.com/lingodotdev/lingo.dev/commit/747847a86720d4c36f15daeb41d13d0aff129ca9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fixed .xcstrings plurals + +- Updated dependencies [[`2b5e3ae`](https://github.com/lingodotdev/lingo.dev/commit/2b5e3aea3f0745955266f6edf2ce34830242e503), [`4dfc8d8`](https://github.com/lingodotdev/lingo.dev/commit/4dfc8d8b301a690875401af5d107a88f1716182a), [`ca9e20e`](https://github.com/lingodotdev/lingo.dev/commit/ca9e20eef9047e20d39ccf9dff74d2f6069d4676), [`2aedf3b`](https://github.com/lingodotdev/lingo.dev/commit/2aedf3bec2d9dffc7b43fc10dea0cab5742d44af), [`747847a`](https://github.com/lingodotdev/lingo.dev/commit/747847a86720d4c36f15daeb41d13d0aff129ca9), [`3fc9da7`](https://github.com/lingodotdev/lingo.dev/commit/3fc9da7e3d2ec58e7f278c79a53eae6d3dfa5896), [`626082a`](https://github.com/lingodotdev/lingo.dev/commit/626082a64b88fb3b589acd950afeafe417ce5ddc)]: + - @replexica/cli@0.47.0 + - @replexica/compiler@0.4.6 + - @replexica/react@0.3.6 + +## 0.46.0 + +### Minor Changes + +- [`8887ece`](https://github.com/lingodotdev/lingo.dev/commit/8887ece066eccb8da31d42b30a76b005de2219a8) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add node 18 compatibility + +### Patch Changes + +- Updated dependencies [[`8887ece`](https://github.com/lingodotdev/lingo.dev/commit/8887ece066eccb8da31d42b30a76b005de2219a8)]: + - @replexica/cli@0.46.0 + +## 0.45.0 + +### Minor Changes + +- [`ad78fb2`](https://github.com/lingodotdev/lingo.dev/commit/ad78fb231d4044d09280127ad8d7c7f7141afe1b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - remove waitlist + +### Patch Changes + +- Updated dependencies [[`ad78fb2`](https://github.com/lingodotdev/lingo.dev/commit/ad78fb231d4044d09280127ad8d7c7f7141afe1b)]: + - @replexica/cli@0.45.0 + +## 0.44.3 + +### Patch Changes + +- [`1e4cbd9`](https://github.com/lingodotdev/lingo.dev/commit/1e4cbd9670ea330c6938efdda3a965ac1e3e8376) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add support for symlinks in i18n.json + +- Updated dependencies [[`1e4cbd9`](https://github.com/lingodotdev/lingo.dev/commit/1e4cbd9670ea330c6938efdda3a965ac1e3e8376)]: + - @replexica/cli@0.44.3 + +## 0.44.2 + +### Patch Changes + +- [#224](https://github.com/lingodotdev/lingo.dev/pull/224) [`2d019f1`](https://github.com/lingodotdev/lingo.dev/commit/2d019f153bd8cc928c2065c9e0260e9de0a6885c) Thanks [@Absterrg0](https://github.com/Absterrg0)! - Added 2 new github issue forms + +- [#228](https://github.com/lingodotdev/lingo.dev/pull/228) [`38fab73`](https://github.com/lingodotdev/lingo.dev/commit/38fab73377278124dfc85a847326fdc957261c6e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - avoid stringifying frontmatter dates + +- Updated dependencies [[`38fab73`](https://github.com/lingodotdev/lingo.dev/commit/38fab73377278124dfc85a847326fdc957261c6e)]: + - @replexica/cli@0.44.2 + +## 0.44.1 + +### Patch Changes + +- [`4760f61`](https://github.com/lingodotdev/lingo.dev/commit/4760f617ef5cca7bed742e4fac28044721d33fc1) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - update cli messages + +- Updated dependencies [[`4760f61`](https://github.com/lingodotdev/lingo.dev/commit/4760f617ef5cca7bed742e4fac28044721d33fc1)]: + - @replexica/cli@0.44.1 + +## 0.44.0 + +### Minor Changes + +- [#220](https://github.com/lingodotdev/lingo.dev/pull/220) [`1b11f8e`](https://github.com/lingodotdev/lingo.dev/commit/1b11f8e710d140045be0c4385bad6348f21f4e5c) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add `replexica show files` command + +### Patch Changes + +- Updated dependencies [[`1b11f8e`](https://github.com/lingodotdev/lingo.dev/commit/1b11f8e710d140045be0c4385bad6348f21f4e5c)]: + - @replexica/cli@0.44.0 + +## 0.43.0 + +### Minor Changes + +- [`fe09f8b`](https://github.com/lingodotdev/lingo.dev/commit/fe09f8b68b1583ba9be83722beceb1596970809f) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add --api-key to the i18n cmd + +### Patch Changes + +- Updated dependencies [[`fe09f8b`](https://github.com/lingodotdev/lingo.dev/commit/fe09f8b68b1583ba9be83722beceb1596970809f)]: + - @replexica/cli@0.43.0 + +## 0.42.0 + +### Minor Changes + +- [`7c67fc5`](https://github.com/lingodotdev/lingo.dev/commit/7c67fc5d87d66abbf0a174417b938810a112cc1a) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - migrate to the new markdown parser + +### Patch Changes + +- Updated dependencies [[`7c67fc5`](https://github.com/lingodotdev/lingo.dev/commit/7c67fc5d87d66abbf0a174417b938810a112cc1a)]: + - @replexica/cli@0.42.0 + +## 0.41.3 + +### Patch Changes + +- [#204](https://github.com/lingodotdev/lingo.dev/pull/204) [`99a4d0a`](https://github.com/lingodotdev/lingo.dev/commit/99a4d0a926d6b6ec0821b47e34f337ca5bb05fca) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add additional exception throws + +- Updated dependencies [[`99a4d0a`](https://github.com/lingodotdev/lingo.dev/commit/99a4d0a926d6b6ec0821b47e34f337ca5bb05fca)]: + - @replexica/cli@0.41.3 + +## 0.41.2 + +### Patch Changes + +- [`962ec5e`](https://github.com/lingodotdev/lingo.dev/commit/962ec5e619632d020ff60fb562d3ad7bc8900443) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - avoid rewriting i18n.json when there's no changes + +- Updated dependencies [[`962ec5e`](https://github.com/lingodotdev/lingo.dev/commit/962ec5e619632d020ff60fb562d3ad7bc8900443)]: + - @replexica/cli@0.41.2 + +## 0.41.1 + +### Patch Changes + +- [`6fdc5d5`](https://github.com/lingodotdev/lingo.dev/commit/6fdc5d535a077bb0656d37c5edf3423dd32e6412) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add json repair to json file loader + +- Updated dependencies [[`6fdc5d5`](https://github.com/lingodotdev/lingo.dev/commit/6fdc5d535a077bb0656d37c5edf3423dd32e6412)]: + - @replexica/cli@0.41.1 + +## 0.41.0 + +### Minor Changes + +- [#181](https://github.com/lingodotdev/lingo.dev/pull/181) [`1601f70`](https://github.com/lingodotdev/lingo.dev/commit/1601f708bdf0ff1786d3bf9b19265ac5b567f740) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Added support for .properties file + +### Patch Changes + +- Updated dependencies [[`1601f70`](https://github.com/lingodotdev/lingo.dev/commit/1601f708bdf0ff1786d3bf9b19265ac5b567f740)]: + - @replexica/cli@0.41.0 + - @replexica/compiler@0.4.5 + - @replexica/react@0.3.5 + +## 0.40.1 + +### Patch Changes + +- [`bc5a28c`](https://github.com/lingodotdev/lingo.dev/commit/bc5a28c3c98b619872924b5f913229ac01387524) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Fix spec imports + +- Updated dependencies [[`bc5a28c`](https://github.com/lingodotdev/lingo.dev/commit/bc5a28c3c98b619872924b5f913229ac01387524)]: + - @replexica/cli@0.40.1 + - @replexica/compiler@0.4.4 + - @replexica/react@0.3.4 + +## 0.40.0 + +### Minor Changes + +- [#165](https://github.com/lingodotdev/lingo.dev/pull/165) [`5c2ca37`](https://github.com/lingodotdev/lingo.dev/commit/5c2ca37114663eaeb529a027e33949ef3839549b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Update locale code resolution logic + +- [#166](https://github.com/lingodotdev/lingo.dev/pull/166) [`78c4ce4`](https://github.com/lingodotdev/lingo.dev/commit/78c4ce479149d3eeb2f67f9283de54eecf3c35ab) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add lockfile autogeneration + +### Patch Changes + +- Updated dependencies [[`5c2ca37`](https://github.com/lingodotdev/lingo.dev/commit/5c2ca37114663eaeb529a027e33949ef3839549b), [`78c4ce4`](https://github.com/lingodotdev/lingo.dev/commit/78c4ce479149d3eeb2f67f9283de54eecf3c35ab)]: + - @replexica/cli@0.40.0 + - @replexica/compiler@0.4.3 + - @replexica/react@0.3.3 + +## 0.39.1 + +### Patch Changes + +- [#162](https://github.com/lingodotdev/lingo.dev/pull/162) [`c990101`](https://github.com/lingodotdev/lingo.dev/commit/c990101185aa17b036fa5a21db679fc7781bf551) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add replexica lockfile command for explicit lockfile generation + +- Updated dependencies [[`c990101`](https://github.com/lingodotdev/lingo.dev/commit/c990101185aa17b036fa5a21db679fc7781bf551)]: + - @replexica/cli@0.39.1 + +## 0.39.0 + +### Minor Changes + +- [`6870fc7`](https://github.com/lingodotdev/lingo.dev/commit/6870fc758dae9d1adb641576befbd8cda61cd5ea) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Fix version number bumping in 1.2 config autoupgrade + +### Patch Changes + +- Updated dependencies [[`6870fc7`](https://github.com/lingodotdev/lingo.dev/commit/6870fc758dae9d1adb641576befbd8cda61cd5ea)]: + - @replexica/cli@0.39.0 + - @replexica/compiler@0.4.2 + - @replexica/react@0.3.2 + +## 0.38.0 + +### Minor Changes + +- [`d6e6d5c`](https://github.com/lingodotdev/lingo.dev/commit/d6e6d5c24b266de3769e95545f74632e7d75c697) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for multisource localization to the CLI + +### Patch Changes + +- Updated dependencies [[`d6e6d5c`](https://github.com/lingodotdev/lingo.dev/commit/d6e6d5c24b266de3769e95545f74632e7d75c697)]: + - @replexica/cli@0.38.0 + - @replexica/compiler@0.4.1 + - @replexica/react@0.3.1 + +## 0.37.0 + +### Minor Changes + +- [#158](https://github.com/lingodotdev/lingo.dev/pull/158) [`73c9250`](https://github.com/lingodotdev/lingo.dev/commit/73c925084989ccea120cae1617ec87776c88e83e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Configuration spec v1.1: Improved bucket config structure, to support exclusion patterns + +### Patch Changes + +- Updated dependencies [[`73c9250`](https://github.com/lingodotdev/lingo.dev/commit/73c925084989ccea120cae1617ec87776c88e83e)]: + - @replexica/compiler@0.4.0 + - @replexica/react@0.3.0 + - @replexica/cli@0.37.0 + +## 0.36.2 + +### Patch Changes + +- [#156](https://github.com/lingodotdev/lingo.dev/pull/156) [`f59380f`](https://github.com/lingodotdev/lingo.dev/commit/f59380f85c98fae4dfb938f842bdf39fe795ddcd) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Preserve order of keys in JSONs + +- Updated dependencies [[`f59380f`](https://github.com/lingodotdev/lingo.dev/commit/f59380f85c98fae4dfb938f842bdf39fe795ddcd)]: + - @replexica/cli@0.36.2 + +## 0.36.1 + +### Patch Changes + +- [`5ad1879`](https://github.com/lingodotdev/lingo.dev/commit/5ad18797f22bc06fe38769120c27bd7c4642fe2d) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add ascii art + +- Updated dependencies [[`5ad1879`](https://github.com/lingodotdev/lingo.dev/commit/5ad18797f22bc06fe38769120c27bd7c4642fe2d)]: + - @replexica/cli@0.36.1 + +## 0.36.0 + +### Minor Changes + +- [#148](https://github.com/lingodotdev/lingo.dev/pull/148) [`fca3bd9`](https://github.com/lingodotdev/lingo.dev/commit/fca3bd984e5bef20a4a9921d7562980a3401f131) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add basic glob pattern support for multi-file buckets + +### Patch Changes + +- Updated dependencies [[`fca3bd9`](https://github.com/lingodotdev/lingo.dev/commit/fca3bd984e5bef20a4a9921d7562980a3401f131)]: + - @replexica/cli@0.36.0 + +## 0.35.0 + +### Minor Changes + +- [`d293f05`](https://github.com/lingodotdev/lingo.dev/commit/d293f059e1bd9131d6d41ceffc713efa8d6fa598) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - New feature: remove unused keys, whenever a key gets deleted in the source file (thanks @quentin-decre!) + +### Patch Changes + +- Updated dependencies [[`d293f05`](https://github.com/lingodotdev/lingo.dev/commit/d293f059e1bd9131d6d41ceffc713efa8d6fa598)]: + - @replexica/cli@0.35.0 + +## 0.34.0 + +### Minor Changes + +- [#142](https://github.com/lingodotdev/lingo.dev/pull/142) [`d9b0e51`](https://github.com/lingodotdev/lingo.dev/commit/d9b0e512196329cc781a4d33346f8ca0f3a81e7e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Extract API calling into SDK package + +### Patch Changes + +- Updated dependencies [[`d9b0e51`](https://github.com/lingodotdev/lingo.dev/commit/d9b0e512196329cc781a4d33346f8ca0f3a81e7e)]: + - @replexica/cli@0.34.0 + +## 0.33.0 + +### Minor Changes + +- [#138](https://github.com/lingodotdev/lingo.dev/pull/138) [`8948266`](https://github.com/lingodotdev/lingo.dev/commit/8948266b0f026da9f656c916bedcedc72e5aedba) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Added JSON flat/unflat for more granular control over lockfile caching and performance + +### Patch Changes + +- Updated dependencies [[`8948266`](https://github.com/lingodotdev/lingo.dev/commit/8948266b0f026da9f656c916bedcedc72e5aedba)]: + - @replexica/cli@0.33.0 + +## 0.32.0 + +### Minor Changes + +- [`dab6f68`](https://github.com/lingodotdev/lingo.dev/commit/dab6f68b4e564f4f1a757431b5a590f87e30aeca) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add frontmatter support + +### Patch Changes + +- Updated dependencies [[`dab6f68`](https://github.com/lingodotdev/lingo.dev/commit/dab6f68b4e564f4f1a757431b5a590f87e30aeca)]: + - @replexica/cli@0.32.0 + +## 0.31.1 + +### Patch Changes + +- [`387b6b7`](https://github.com/lingodotdev/lingo.dev/commit/387b6b74c1718503f50f18991b0337ee87cb53f8) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Fixed extra newline added to markdown results + +- Updated dependencies [[`387b6b7`](https://github.com/lingodotdev/lingo.dev/commit/387b6b74c1718503f50f18991b0337ee87cb53f8)]: + - @replexica/cli@0.31.1 + +## 0.31.0 + +### Minor Changes + +- [`8c8e7dd`](https://github.com/lingodotdev/lingo.dev/commit/8c8e7dd4d35669d484240d643427612ecdaf73eb) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Added new locales + +### Patch Changes + +- Updated dependencies [[`8c8e7dd`](https://github.com/lingodotdev/lingo.dev/commit/8c8e7dd4d35669d484240d643427612ecdaf73eb)]: + - @replexica/cli@0.31.0 + - @replexica/compiler@0.3.9 + - @replexica/react@0.2.5 + +## 0.30.0 + +### Minor Changes + +- [`bd2029d`](https://github.com/lingodotdev/lingo.dev/commit/bd2029d5c1241f7355ea08621dbeb7e04b7f5b5c) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Updated markdown processor algo + +### Patch Changes + +- Updated dependencies [[`bd2029d`](https://github.com/lingodotdev/lingo.dev/commit/bd2029d5c1241f7355ea08621dbeb7e04b7f5b5c)]: + - @replexica/cli@0.30.0 + +## 0.29.0 + +### Minor Changes + +- [`7d83cfc`](https://github.com/lingodotdev/lingo.dev/commit/7d83cfc79921346a47ccef43accee454ba80c83c) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Added retry mechanism to i18n engine calls + +### Patch Changes + +- Updated dependencies [[`7d83cfc`](https://github.com/lingodotdev/lingo.dev/commit/7d83cfc79921346a47ccef43accee454ba80c83c)]: + - @replexica/cli@0.29.0 + +## 0.24.0 + +### Minor Changes + +- [`37167d6`](https://github.com/lingodotdev/lingo.dev/commit/37167d6d29d747b0dd35e26e5b6f0978f0e156d9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Added -v, --version flag to print out CLI version + +### Patch Changes + +- Updated dependencies [[`37167d6`](https://github.com/lingodotdev/lingo.dev/commit/37167d6d29d747b0dd35e26e5b6f0978f0e156d9)]: + - @replexica/cli@0.28.0 + +## 0.23.7 + +### Patch Changes + +- Updated dependencies [[`c0be1a2`](https://github.com/lingodotdev/lingo.dev/commit/c0be1a29e3069ef2c8bdc4e4f52d2fb17abdb1f5), [`a083a55`](https://github.com/lingodotdev/lingo.dev/commit/a083a551cbe755c87a78ad14673f5dbac6d86832)]: + - @replexica/cli@0.27.0 + - @replexica/compiler@0.3.8 + - @replexica/react@0.2.4 + +## 0.23.6 + +### Patch Changes + +- [`eee21e1`](https://github.com/lingodotdev/lingo.dev/commit/eee21e1913e86f18938f1d6fd0dffaf6c17fb33c) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Improved markdown performance, added support for VERY large markdown content files. + +- Updated dependencies [[`eee21e1`](https://github.com/lingodotdev/lingo.dev/commit/eee21e1913e86f18938f1d6fd0dffaf6c17fb33c)]: + - @replexica/cli@0.26.1 + +## 0.23.5 + +### Patch Changes + +- Updated dependencies [[`ca1dd58`](https://github.com/lingodotdev/lingo.dev/commit/ca1dd58008e31c8aa88ab14362f6506d6efb970a)]: + - @replexica/cli@0.26.0 + +## 0.23.4 + +### Patch Changes + +- Updated dependencies [[`3c7a30c`](https://github.com/lingodotdev/lingo.dev/commit/3c7a30c6be91fb27c00681c998452d7bf1beca0e)]: + - @replexica/cli@0.25.0 + +## 0.23.3 + +### Patch Changes + +- Updated dependencies [[`fbce978`](https://github.com/lingodotdev/lingo.dev/commit/fbce97846eabf00fb1c903b82e7d556480de5d23), [`10252ce`](https://github.com/lingodotdev/lingo.dev/commit/10252ceaa2685cc23f4dbeb6ac985cc2148853e2)]: + - @replexica/cli@0.24.0 + - @replexica/compiler@0.3.7 + - @replexica/react@0.2.3 + +## 0.23.2 + +### Patch Changes + +- Updated dependencies [[`27bb7fd`](https://github.com/lingodotdev/lingo.dev/commit/27bb7fd7e644e37c59e2cce9b453122097f6362c)]: + - @replexica/cli@0.23.2 + +## 0.23.1 + +### Patch Changes + +- [`088de18`](https://github.com/lingodotdev/lingo.dev/commit/088de18a53f45fa8df5833fe81ed96a2ed231299) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Fix @replexica/config reference + +- Updated dependencies [[`088de18`](https://github.com/lingodotdev/lingo.dev/commit/088de18a53f45fa8df5833fe81ed96a2ed231299)]: + - @replexica/compiler@0.3.6 + - @replexica/react@0.2.2 + - @replexica/cli@0.23.1 + +## 0.23.0 + +### Minor Changes + +- [#99](https://github.com/lingodotdev/lingo.dev/pull/99) [`4e94058`](https://github.com/lingodotdev/lingo.dev/commit/4e940582ea8ebe5a058b76fb33420729f7bfdcef) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Added support for i18n lockfiles to improve AI localization performance. + +### Patch Changes + +- Updated dependencies [[`4e94058`](https://github.com/lingodotdev/lingo.dev/commit/4e940582ea8ebe5a058b76fb33420729f7bfdcef)]: + - @replexica/cli@0.23.0 + - @replexica/compiler@0.3.5 + - @replexica/react@0.2.1 diff --git a/legacy/cli/bin/cli.mjs b/legacy/cli/bin/cli.mjs new file mode 100755 index 000000000..c7a2b151d --- /dev/null +++ b/legacy/cli/bin/cli.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +import CLI from "lingo.dev/cli"; + +const envVarConfigWarning = process.env.LINGODOTDEV_API_KEY + ? "\nThis version is not compatible with LINGODOTDEV_API_KEY env variable." + : ""; + +console.warn( + "\x1b[33m%s\x1b[0m", + ` +⚠️ WARNING: NEW PACKAGE AVAILABLE ⚠️ +================================================================================ +This CLI version is deprecated.${envVarConfigWarning} +Please use lingo.dev instead: + +npx lingo.dev@latest + +Visit https://lingo.dev for more information. +================================================================================ +`, +); + +process.env.LINGODOTDEV_API_KEY = process.env.REPLEXICA_API_KEY; +process.env.LINGODOTDEV_PULL_REQUEST = process.env.REPLEXICA_PULL_REQUEST; +process.env.LINGODOTDEV_PULL_REQUEST_TITLE = + process.env.REPLEXICA_PULL_REQUEST_TITLE; +process.env.LINGODOTDEV_COMMIT_MESSAGE = process.env.REPLEXICA_COMMIT_MESSAGE; +process.env.LINGODOTDEV_WORKING_DIRECTORY = + process.env.REPLEXICA_WORKING_DIRECTORY; + +await CLI.parseAsync(process.argv); diff --git a/legacy/cli/package.json b/legacy/cli/package.json new file mode 100644 index 000000000..64542aa1a --- /dev/null +++ b/legacy/cli/package.json @@ -0,0 +1,28 @@ +{ + "name": "replexica", + "version": "0.71.1", + "description": "[DEPRECATED] Replexica CLI (now Lingo.dev) - Please use lingo.dev instead", + "private": false, + "type": "module", + "sideEffects": false, + "types": "build/index.d.ts", + "module": "build/index.mjs", + "main": "build/index.cjs", + "bin": { + "replexica": "./bin/cli.mjs" + }, + "files": [ + "bin", + "build" + ], + "scripts": { + "replexica": "./bin/cli.mjs" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "lingo.dev": "*" + }, + "deprecated": "Replexica is now Lingo.dev! Please use our new CLI package by running: npx lingo.dev@latest. Visit https://lingo.dev for the latest features and documentation." +} diff --git a/legacy/cli/readme.md b/legacy/cli/readme.md new file mode 100644 index 000000000..50fec4113 --- /dev/null +++ b/legacy/cli/readme.md @@ -0,0 +1 @@ +Replexica is now [Lingo.dev](https://npmjs.com/package/lingo.dev) diff --git a/legacy/sdk/CHANGELOG.md b/legacy/sdk/CHANGELOG.md new file mode 100644 index 000000000..cec170f8c --- /dev/null +++ b/legacy/sdk/CHANGELOG.md @@ -0,0 +1,226 @@ +# @replexica/sdk + +## 0.7.13 + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +- Updated dependencies [[`348b2de`](https://github.com/lingodotdev/lingo.dev/commit/348b2de39412101bacb5ed541b0db23f0ca6213d), [`04c3679`](https://github.com/lingodotdev/lingo.dev/commit/04c3679c69231012f167da1640dc17ac57743d6b), [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59), [`797f913`](https://github.com/lingodotdev/lingo.dev/commit/797f9132b5cf05fe457968b691bca10db1fc37bb)]: + - lingo.dev@0.120.0 + +## 0.7.12 + +### Patch Changes + +- Updated dependencies [[`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144)]: + - lingo.dev@0.117.0 + +## 0.7.11 + +### Patch Changes + +- [`5dee9ee`](https://github.com/lingodotdev/lingo.dev/commit/5dee9ee743fbef489fbe342597a768ebd59e5f67) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add proxies to legacy packages + +- [`63eb57b`](https://github.com/lingodotdev/lingo.dev/commit/63eb57b8f4cc37605be196085fafbbfdab71cce5) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add deprecation message to legacy package jsons + +- [`bbf7760`](https://github.com/lingodotdev/lingo.dev/commit/bbf7760580f1631805d68612053ebcd4601bb02b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add deprecation warning to the legacy package proxies + +- Updated dependencies [[`b4c7f1e`](https://github.com/lingodotdev/lingo.dev/commit/b4c7f1e86334d229bee62219c26f30d0b523926d)]: + - lingo.dev@0.70.4 + +## 0.7.10 + +### Patch Changes + +- Updated dependencies [[`003344f`](https://github.com/lingodotdev/lingo.dev/commit/003344ffcca98a391a298707f18476971c4d4c2b)]: + - @replexica/spec@0.24.0 + +## 0.7.9 + +### Patch Changes + +- Updated dependencies [[`a2ada16`](https://github.com/lingodotdev/lingo.dev/commit/a2ada16ecf4cd559d3486f0e4756d58808194f7e)]: + - @replexica/spec@0.23.0 + +## 0.7.8 + +### Patch Changes + +- Updated dependencies [[`e6521b8`](https://github.com/lingodotdev/lingo.dev/commit/e6521b86637c254c011aba89a3558802c04ab3ca)]: + - @replexica/spec@0.22.1 + +## 0.7.7 + +### Patch Changes + +- Updated dependencies [[`cff3c4e`](https://github.com/lingodotdev/lingo.dev/commit/cff3c4eb1a40f82a9c4c095e49cfd9fce053b048)]: + - @replexica/spec@0.22.0 + +## 0.7.6 + +### Patch Changes + +- Updated dependencies [[`58d7b35`](https://github.com/lingodotdev/lingo.dev/commit/58d7b3567e51cc3ef0fad0288c13451381b95a98)]: + - @replexica/spec@0.21.1 + +## 0.7.5 + +### Patch Changes + +- Updated dependencies [[`9cf5299`](https://github.com/lingodotdev/lingo.dev/commit/9cf5299f7efbef70fd83f95177eac49b4d8f8007), [`3ab5de6`](https://github.com/lingodotdev/lingo.dev/commit/3ab5de66d8a913297b46095c2e73823124cc8c5b)]: + - @replexica/spec@0.21.0 + +## 0.7.4 + +### Patch Changes + +- Updated dependencies [[`1556977`](https://github.com/lingodotdev/lingo.dev/commit/1556977332a6f949100283bfa8c9a9ff5e74b156)]: + - @replexica/spec@0.20.0 + +## 0.7.3 + +### Patch Changes + +- [`cbef8f3`](https://github.com/lingodotdev/lingo.dev/commit/cbef8f3cafdc955d61053ce885d98e425acb668d) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - moved jsdom import into the html handler function + +## 0.7.2 + +### Patch Changes + +- Updated dependencies [[`5cb3c93`](https://github.com/lingodotdev/lingo.dev/commit/5cb3c930fff6e30cff5cc2266b794f75a0db646d)]: + - @replexica/spec@0.19.0 + +## 0.7.1 + +### Patch Changes + +- [`db819a4`](https://github.com/lingodotdev/lingo.dev/commit/db819a42412ceb67fedbe729b7d018952686d60b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - reduce default batch size to avoid hitting rate limits + +- [`2c5cbcf`](https://github.com/lingodotdev/lingo.dev/commit/2c5cbcfbf6feb28440255cdea0818c8cefa61d91) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - filter out non extistent keys + +## 0.7.0 + +### Minor Changes + +- [`c42dc2d`](https://github.com/lingodotdev/lingo.dev/commit/c42dc2d5b4efe95e804b5a7e7f6d354cf8622dc7) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add `batchLocalizeText` to sdk + +## 0.6.0 + +### Minor Changes + +- [`a71a88e`](https://github.com/lingodotdev/lingo.dev/commit/a71a88e5c8bd6601b0838c381433a87763142801) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fast mode + +### Patch Changes + +- [`f0a77ad`](https://github.com/lingodotdev/lingo.dev/commit/f0a77ad774a01c30e7e9bc5a0253638176332fd2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - updated default batch size limits in the SDK + +## 0.5.0 + +### Minor Changes + +- [`ebf44cb`](https://github.com/lingodotdev/lingo.dev/commit/ebf44cbb462516abfe660c295c04627796c5a3a7) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - implement recognize locale + +- [`42d0a5a`](https://github.com/lingodotdev/lingo.dev/commit/42d0a5a7a53e296192a31e8f1d67c126793ea280) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added .localizeHtml implementation to SDK + +### Patch Changes + +- Updated dependencies [[`a6b22a3`](https://github.com/lingodotdev/lingo.dev/commit/a6b22a3237f574455d8119f914d82b0b247b4151)]: + - @replexica/spec@0.18.0 + +## 0.4.3 + +### Patch Changes + +- Updated dependencies [[`091ee35`](https://github.com/lingodotdev/lingo.dev/commit/091ee353081795bf8f61c7d41483bc309c7b62ef)]: + - @replexica/spec@0.17.0 + +## 0.4.2 + +### Patch Changes + +- Updated dependencies [[`5e282d7`](https://github.com/lingodotdev/lingo.dev/commit/5e282d7ffa5ca9494aa7046a090bb7c327085a86)]: + - @replexica/spec@0.16.0 + +## 0.4.1 + +### Patch Changes + +- Updated dependencies [[`0071cd6`](https://github.com/lingodotdev/lingo.dev/commit/0071cd66b1c868ad3898fc368390a628c5a67767)]: + - @replexica/spec@0.15.0 + +## 0.4.0 + +### Minor Changes + +- [#264](https://github.com/lingodotdev/lingo.dev/pull/264) [`cdef5b7`](https://github.com/lingodotdev/lingo.dev/commit/cdef5b7bfbee4670c6de62cf4b4f3e0315748e25) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added format specific methods to `@replexica/sdk` + +## 0.3.4 + +### Patch Changes + +- Updated dependencies [[`2859938`](https://github.com/lingodotdev/lingo.dev/commit/28599388a91bf80cea3813bb4b8999bb4df302c9)]: + - @replexica/spec@0.14.1 + +## 0.3.3 + +### Patch Changes + +- Updated dependencies [[`ca9e20e`](https://github.com/lingodotdev/lingo.dev/commit/ca9e20eef9047e20d39ccf9dff74d2f6069d4676), [`2aedf3b`](https://github.com/lingodotdev/lingo.dev/commit/2aedf3bec2d9dffc7b43fc10dea0cab5742d44af), [`626082a`](https://github.com/lingodotdev/lingo.dev/commit/626082a64b88fb3b589acd950afeafe417ce5ddc)]: + - @replexica/spec@0.14.0 + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [[`1601f70`](https://github.com/lingodotdev/lingo.dev/commit/1601f708bdf0ff1786d3bf9b19265ac5b567f740)]: + - @replexica/spec@0.13.0 + +## 0.3.1 + +### Patch Changes + +- Updated dependencies [[`bc5a28c`](https://github.com/lingodotdev/lingo.dev/commit/bc5a28c3c98b619872924b5f913229ac01387524)]: + - @replexica/spec@0.12.1 + +## 0.3.0 + +### Minor Changes + +- [#165](https://github.com/lingodotdev/lingo.dev/pull/165) [`5c2ca37`](https://github.com/lingodotdev/lingo.dev/commit/5c2ca37114663eaeb529a027e33949ef3839549b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Update locale code resolution logic + +### Patch Changes + +- Updated dependencies [[`5c2ca37`](https://github.com/lingodotdev/lingo.dev/commit/5c2ca37114663eaeb529a027e33949ef3839549b)]: + - @replexica/spec@0.12.0 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [[`6870fc7`](https://github.com/lingodotdev/lingo.dev/commit/6870fc758dae9d1adb641576befbd8cda61cd5ea)]: + - @replexica/spec@0.11.0 + +## 0.2.0 + +### Minor Changes + +- [`d6e6d5c`](https://github.com/lingodotdev/lingo.dev/commit/d6e6d5c24b266de3769e95545f74632e7d75c697) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for multisource localization to the CLI + +### Patch Changes + +- Updated dependencies [[`d6e6d5c`](https://github.com/lingodotdev/lingo.dev/commit/d6e6d5c24b266de3769e95545f74632e7d75c697)]: + - @replexica/spec@0.10.0 + +## 0.1.1 + +### Patch Changes + +- Updated dependencies [[`73c9250`](https://github.com/lingodotdev/lingo.dev/commit/73c925084989ccea120cae1617ec87776c88e83e)]: + - @replexica/spec@0.9.0 + +## 0.1.0 + +### Minor Changes + +- [#142](https://github.com/lingodotdev/lingo.dev/pull/142) [`d9b0e51`](https://github.com/lingodotdev/lingo.dev/commit/d9b0e512196329cc781a4d33346f8ca0f3a81e7e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Extract API calling into SDK package diff --git a/legacy/sdk/README.md b/legacy/sdk/README.md new file mode 100644 index 000000000..50fec4113 --- /dev/null +++ b/legacy/sdk/README.md @@ -0,0 +1 @@ +Replexica is now [Lingo.dev](https://npmjs.com/package/lingo.dev) diff --git a/legacy/sdk/index.d.ts b/legacy/sdk/index.d.ts new file mode 100644 index 000000000..5cea8c734 --- /dev/null +++ b/legacy/sdk/index.d.ts @@ -0,0 +1 @@ +export * from "lingo.dev/sdk"; diff --git a/legacy/sdk/index.js b/legacy/sdk/index.js new file mode 100644 index 000000000..5e5a7d369 --- /dev/null +++ b/legacy/sdk/index.js @@ -0,0 +1,16 @@ +console.warn( + "\x1b[33m%s\x1b[0m", + ` +⚠️ WARNING: NEW PACKAGE AVAILABLE ⚠️ +======================================= +This SDK version is deprecated. +Please use lingo.dev instead: + +npm install lingo.dev + +Visit https://lingo.dev for more information. +======================================= +`, +); + +export * from "lingo.dev/sdk"; diff --git a/legacy/sdk/package.json b/legacy/sdk/package.json new file mode 100644 index 000000000..c96c521e6 --- /dev/null +++ b/legacy/sdk/package.json @@ -0,0 +1,22 @@ +{ + "name": "@replexica/sdk", + "version": "0.7.12", + "description": "[DEPRECATED] Replexica SDK (now Lingo.dev) - Please use lingo.dev instead", + "private": false, + "type": "module", + "sideEffects": false, + "types": "index.d.ts", + "module": "index.js", + "main": "index.js", + "files": [ + "index.js", + "index.d.ts" + ], + "keywords": [], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "lingo.dev": "*" + }, + "deprecated": "Replexica is now Lingo.dev! Please use our new SDK package by running: npm install lingo.dev. Visit https://lingo.dev for the latest features and documentation." +} diff --git a/mcp.md b/mcp.md new file mode 100644 index 000000000..eb8c3501c --- /dev/null +++ b/mcp.md @@ -0,0 +1,61 @@ +# Model Context Protocol + +The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is a standard for connecting Large Language Models (LLMs) to external services. This guide will walk you through how to connect AI tools to Lingo.dev using MCP. + +Some of the AI tools that support MCP are: + +- [Cursor](https://www.cursor.com/) +- [Claude desktop](https://claude.ai/download) +- [Cline for VS Code](https://github.com/cline/cline) + +Connecting these tools to Lingo.dev will allow you to translate apps, websites, and other data using the best LLM models directly in your AI tool. + +## Setup + +Add this command to your AI tool: + +```bash +npx -y lingo.dev mcp +``` + +You can find your API key in [Lingo.dev app](https://lingo.dev/app/), in your project settings. + +This will allow the tool to use `translate` tool provided by Lingo.dev. The setup depends on your AI tool and might be different for each tool. Here is setup for some of the tools we use in our team: + +### Cursor + +1. Open Cursor and go to Cursor Settings. +2. Open MCP tab +3. Click `+ Add new MCP server` +4. Enter the following details: + - Name: Lingo.dev + - Type: command + - Command: `npx -y lingo.dev mcp ` (use your project API key) +5. You will see green status indicator and "translate" tool available in the list + +### Claude desktop + +1. Open Claude desktop and go to Settings. +2. Open Developer tab +3. Click `Edit Config` to see configuration file in file explorer. +4. Open the file in text editor +5. Add the following configuration (use your project API key): + +```json +{ + "mcpServers": { + "supabase": { + "command": "npx", + "args": ["-y", "lingo.dev", "mcp", ""] + } + } +} +``` + +6. Save the configuration file +7. Restart Claude desktop. +8. In the chat input, you will see a hammer icon with your MCP server details. + +## Usage + +You are now able to access Lingo.dev via MCP. You can ask AI tool translate any content via our service. diff --git a/package.json b/package.json new file mode 100644 index 000000000..c3e53439b --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "@lingo.dev", + "version": "1.0.0", + "type": "module", + "scripts": { + "prepare": "husky", + "build": "turbo build", + "typecheck": "turbo typecheck", + "test": "turbo test", + "new": "changeset", + "new:empty": "changeset --empty", + "prebuild": "syncpack lint-semver-ranges --filter '.*' --types prod,dev" + }, + "devDependencies": { + "@babel/generator": "7.28.5", + "@babel/parser": "7.28.5", + "@babel/traverse": "7.28.5", + "@babel/types": "7.28.5", + "@commitlint/cli": "19.8.1", + "@commitlint/config-conventional": "19.8.1", + "@types/babel__traverse": "7.28.0", + "commitlint": "19.8.1", + "husky": "9.1.7", + "syncpack": "13.0.4", + "turbo": "2.6.1" + }, + "dependencies": { + "@changesets/changelog-github": "0.5.1", + "@changesets/cli": "2.29.7", + "minimatch": "10.1.1", + "node-machine-id": "1.1.12" + }, + "packageManager": "pnpm@9.12.3" +} \ No newline at end of file diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md new file mode 100644 index 000000000..c03508cd8 --- /dev/null +++ b/packages/cli/CHANGELOG.md @@ -0,0 +1,2467 @@ +# lingo.dev + +## 0.121.1 + +### Patch Changes + +- [#1752](https://github.com/lingodotdev/lingo.dev/pull/1752) [`b563670`](https://github.com/lingodotdev/lingo.dev/commit/b563670ecdb663bffced547d0600954df8bfbaa4) Thanks [@vrcprl](https://github.com/vrcprl)! - Add deprecation warning to 'i18n' command, directing users to use 'run' instead + +## 0.121.0 + +### Minor Changes + +- [#1270](https://github.com/lingodotdev/lingo.dev/pull/1270) [`606fd5b`](https://github.com/lingodotdev/lingo.dev/commit/606fd5b10d9d15a42a65d1cb763f59210d3c8842) Thanks [@pahimauchil](https://github.com/pahimauchil)! - Added Malayalam translation for README and updated i18n.json to include "ml" in targets. + +## 0.120.0 + +### Minor Changes + +- [#1738](https://github.com/lingodotdev/lingo.dev/pull/1738) [`348b2de`](https://github.com/lingodotdev/lingo.dev/commit/348b2de39412101bacb5ed541b0db23f0ca6213d) Thanks [@cherkanovart](https://github.com/cherkanovart)! - Remove hardcoded concurrency limit + +- [#1742](https://github.com/lingodotdev/lingo.dev/pull/1742) [`04c3679`](https://github.com/lingodotdev/lingo.dev/commit/04c3679c69231012f167da1640dc17ac57743d6b) Thanks [@cherkanovart](https://github.com/cherkanovart)! - Add csv-per-locale bucket and improve ignoredKeys support for CSV + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +- [#1748](https://github.com/lingodotdev/lingo.dev/pull/1748) [`797f913`](https://github.com/lingodotdev/lingo.dev/commit/797f9132b5cf05fe457968b691bca10db1fc37bb) Thanks [@jarne](https://github.com/jarne)! - Fix API key check condition that breaks the Ollama provider + +- Updated dependencies [[`04c3679`](https://github.com/lingodotdev/lingo.dev/commit/04c3679c69231012f167da1640dc17ac57743d6b), [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59)]: + - @lingo.dev/_spec@0.46.0 + - @lingo.dev/_compiler@0.8.12 + - @lingo.dev/_locales@0.3.3 + - @lingo.dev/_react@0.7.6 + - @lingo.dev/_sdk@0.13.7 + +## 0.119.0 + +### Minor Changes + +- [#1409](https://github.com/lingodotdev/lingo.dev/pull/1409) [`978b817`](https://github.com/lingodotdev/lingo.dev/commit/978b81793dff52abb348b1b0977cb233232721d0) Thanks [@SK8-infi](https://github.com/SK8-infi)! - feat: add init cursor command for .cursorrules setup + +## 0.118.0 + +### Minor Changes + +- [`18ef68f`](https://github.com/lingodotdev/lingo.dev/commit/18ef68f8d51f0d3208cfe1f1d2167e2e1580fdcc) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - vNext localizer + +### Patch Changes + +- [#1629](https://github.com/lingodotdev/lingo.dev/pull/1629) [`d76b729`](https://github.com/lingodotdev/lingo.dev/commit/d76b729ba692f1ec258355ebed5b47d7137b001d) Thanks [@ashutoshdebug](https://github.com/ashutoshdebug)! - Add a pseudo-localization mode (--pseudo) to the CLI, including character mapping, recursive object handling, localizer implementation and tests + +- Updated dependencies [[`18ef68f`](https://github.com/lingodotdev/lingo.dev/commit/18ef68f8d51f0d3208cfe1f1d2167e2e1580fdcc)]: + - @lingo.dev/_spec@0.45.0 + - @lingo.dev/_compiler@0.8.11 + - @lingo.dev/_sdk@0.13.6 + +## 0.117.26 + +### Patch Changes + +- [#1730](https://github.com/lingodotdev/lingo.dev/pull/1730) [`ea02a43`](https://github.com/lingodotdev/lingo.dev/commit/ea02a43b6c4eaeddf61fa62c7564a4231b67ef82) Thanks [@vrcprl](https://github.com/vrcprl)! - Upd code placeholders to prevent gray-matter engine error + +## 0.117.25 + +### Patch Changes + +- [#1726](https://github.com/lingodotdev/lingo.dev/pull/1726) [`68b8496`](https://github.com/lingodotdev/lingo.dev/commit/68b849602a88b9f9aa3097f37ce2f0ccf97c1ad5) Thanks [@vrcprl](https://github.com/vrcprl)! - Observability improvement + +- Updated dependencies [[`68b8496`](https://github.com/lingodotdev/lingo.dev/commit/68b849602a88b9f9aa3097f37ce2f0ccf97c1ad5)]: + - @lingo.dev/_compiler@0.8.10 + +## 0.117.24 + +### Patch Changes + +- [#1724](https://github.com/lingodotdev/lingo.dev/pull/1724) [`c617611`](https://github.com/lingodotdev/lingo.dev/commit/c61761181c5f8145ec2e54f34d33ad04a90968e3) Thanks [@vrcprl](https://github.com/vrcprl)! - fix observability for status command + +## 0.117.23 + +### Patch Changes + +- Updated dependencies [[`40dc1bb`](https://github.com/lingodotdev/lingo.dev/commit/40dc1bbd03633d7046da5580858f728dffdcbf81)]: + - @lingo.dev/_locales@0.3.2 + - @lingo.dev/_spec@0.44.5 + - @lingo.dev/_compiler@0.8.9 + - @lingo.dev/_sdk@0.13.5 + +## 0.117.22 + +### Patch Changes + +- [#1710](https://github.com/lingodotdev/lingo.dev/pull/1710) [`020424f`](https://github.com/lingodotdev/lingo.dev/commit/020424f2601c535e88c66aeeece5a15fb9b66b70) Thanks [@vrcprl](https://github.com/vrcprl)! - Add support for JSONC comments in arrays + +## 0.117.21 + +### Patch Changes + +- [#1683](https://github.com/lingodotdev/lingo.dev/pull/1683) [`d2d44a1`](https://github.com/lingodotdev/lingo.dev/commit/d2d44a180b20102bf176dbd46866afab72380b74) Thanks [@ceolinwill](https://github.com/ceolinwill)! - fix racing condition where concurrent processing could use data from the wrong locale + +## 0.117.20 + +### Patch Changes + +- [#1681](https://github.com/lingodotdev/lingo.dev/pull/1681) [`595215f`](https://github.com/lingodotdev/lingo.dev/commit/595215f0060fb365faf0b988e39a561649359517) Thanks [@vrcprl](https://github.com/vrcprl)! - improve observability for i18n + +## 0.117.19 + +### Patch Changes + +- [#1678](https://github.com/lingodotdev/lingo.dev/pull/1678) [`bb14deb`](https://github.com/lingodotdev/lingo.dev/commit/bb14debf734bf87a2ea64946f8e7235c01b05578) Thanks [@vrcprl](https://github.com/vrcprl)! - Fix inconsistent event tracking in CLI to ensure start/success/error events are always paired correctly for accurate health metrics + +## 0.117.18 + +### Patch Changes + +- Updated dependencies [[`3b24647`](https://github.com/lingodotdev/lingo.dev/commit/3b246473f6f4773f00ea13211bc2be59a98e0b7c)]: + - @lingo.dev/_compiler@0.8.8 + - @lingo.dev/_react@0.7.5 + +## 0.117.17 + +### Patch Changes + +- [#1672](https://github.com/lingodotdev/lingo.dev/pull/1672) [`29949db`](https://github.com/lingodotdev/lingo.dev/commit/29949db24ff9c8938233ebb42e8189690c3c7813) Thanks [@vrcprl](https://github.com/vrcprl)! - Improve observability + +- Updated dependencies [[`29949db`](https://github.com/lingodotdev/lingo.dev/commit/29949db24ff9c8938233ebb42e8189690c3c7813)]: + - @lingo.dev/_compiler@0.8.7 + +## 0.117.16 + +### Patch Changes + +- [#1662](https://github.com/lingodotdev/lingo.dev/pull/1662) [`a60aa1e`](https://github.com/lingodotdev/lingo.dev/commit/a60aa1ec01149a4ef418b9025ae50891264f9123) Thanks [@ceolinwill](https://github.com/ceolinwill)! - fix language header for PO files + +## 0.117.15 + +### Patch Changes + +- [`d7ccd60`](https://github.com/lingodotdev/lingo.dev/commit/d7ccd6000cd980333e7ac4b63da4e2ba624c3de4) Thanks [@vrcprl](https://github.com/vrcprl)! - chore: update React to 19.2.3 to fix CVE-2025-55184 (DoS) and CVE-2025-55183 (source code exposure) + +- Updated dependencies [[`d7ccd60`](https://github.com/lingodotdev/lingo.dev/commit/d7ccd6000cd980333e7ac4b63da4e2ba624c3de4)]: + - @lingo.dev/_react@0.7.4 + +## 0.117.14 + +### Patch Changes + +- [#1664](https://github.com/lingodotdev/lingo.dev/pull/1664) [`7367bee`](https://github.com/lingodotdev/lingo.dev/commit/7367bee3318a14647bf9bd0105270b2492fcec31) Thanks [@vrcprl](https://github.com/vrcprl)! - supp[ort keys with whitespaces + +## 0.117.13 + +### Patch Changes + +- [#1667](https://github.com/lingodotdev/lingo.dev/pull/1667) [`1a857bd`](https://github.com/lingodotdev/lingo.dev/commit/1a857bdf76d50afb3024a2437da5fd60e6721bb9) Thanks [@vrcprl](https://github.com/vrcprl)! - Upd NPM workflows + +- Updated dependencies [[`1a857bd`](https://github.com/lingodotdev/lingo.dev/commit/1a857bdf76d50afb3024a2437da5fd60e6721bb9)]: + - @lingo.dev/_compiler@0.8.6 + - @lingo.dev/_locales@0.3.1 + - @lingo.dev/_react@0.7.3 + - @lingo.dev/_spec@0.44.4 + - @lingo.dev/_sdk@0.13.4 + +## 0.117.12 + +### Patch Changes + +- [#1665](https://github.com/lingodotdev/lingo.dev/pull/1665) [`b898777`](https://github.com/lingodotdev/lingo.dev/commit/b89877729555025e0380451fa495573c2a114a6b) Thanks [@vrcprl](https://github.com/vrcprl)! - Upd react version + +- Updated dependencies [[`b898777`](https://github.com/lingodotdev/lingo.dev/commit/b89877729555025e0380451fa495573c2a114a6b)]: + - @lingo.dev/_react@0.7.2 + +## 0.117.11 + +### Patch Changes + +- Updated dependencies [[`1b2980d`](https://github.com/lingodotdev/lingo.dev/commit/1b2980d9215eca4f2db101af530680d6eb3be8eb)]: + - @lingo.dev/_compiler@0.8.5 + - @lingo.dev/_react@0.7.1 + +## 0.117.10 + +### Patch Changes + +- [#1658](https://github.com/lingodotdev/lingo.dev/pull/1658) [`77cf56e`](https://github.com/lingodotdev/lingo.dev/commit/77cf56e57725c680d071c6f5bc310e77c8ead463) Thanks [@vrcprl](https://github.com/vrcprl)! - fix mjml format issue + +## 0.117.9 + +### Patch Changes + +- [#1655](https://github.com/lingodotdev/lingo.dev/pull/1655) [`738bf08`](https://github.com/lingodotdev/lingo.dev/commit/738bf08edfe226392ec4534e05864101bc66c39c) Thanks [@vrcprl](https://github.com/vrcprl)! - add AIL bucket + +- Updated dependencies [[`738bf08`](https://github.com/lingodotdev/lingo.dev/commit/738bf08edfe226392ec4534e05864101bc66c39c)]: + - @lingo.dev/_spec@0.44.3 + - @lingo.dev/_compiler@0.8.4 + - @lingo.dev/_sdk@0.13.3 + +## 0.117.8 + +### Patch Changes + +- [#1653](https://github.com/lingodotdev/lingo.dev/pull/1653) [`f6352b6`](https://github.com/lingodotdev/lingo.dev/commit/f6352b6222e425d5d184c1591a90b1d13a7effbc) Thanks [@vrcprl](https://github.com/vrcprl)! - add Twig bucket + +- Updated dependencies [[`f6352b6`](https://github.com/lingodotdev/lingo.dev/commit/f6352b6222e425d5d184c1591a90b1d13a7effbc)]: + - @lingo.dev/_spec@0.44.2 + - @lingo.dev/_compiler@0.8.3 + - @lingo.dev/_sdk@0.13.2 + +## 0.117.7 + +### Patch Changes + +- [#1628](https://github.com/lingodotdev/lingo.dev/pull/1628) [`ad646a4`](https://github.com/lingodotdev/lingo.dev/commit/ad646a4f44dc2f0771eb3aa2783872b4d0e55f57) Thanks [@vrcprl](https://github.com/vrcprl)! - Add MJML bucket support + +- Updated dependencies [[`ad646a4`](https://github.com/lingodotdev/lingo.dev/commit/ad646a4f44dc2f0771eb3aa2783872b4d0e55f57)]: + - @lingo.dev/_spec@0.44.1 + - @lingo.dev/_compiler@0.8.2 + - @lingo.dev/_sdk@0.13.1 + +## 0.117.6 + +### Patch Changes + +- [#1647](https://github.com/lingodotdev/lingo.dev/pull/1647) [`a9e1af5`](https://github.com/lingodotdev/lingo.dev/commit/a9e1af5a57b9711ac1ef98b40b5f7abff4b0c31a) Thanks [@vrcprl](https://github.com/vrcprl)! - prevent HTML tag duplication in Android bucket + +## 0.117.5 + +### Patch Changes + +- [#1639](https://github.com/lingodotdev/lingo.dev/pull/1639) [`a881f81`](https://github.com/lingodotdev/lingo.dev/commit/a881f8115059168dabb4cbe07a1d28ca33d36ece) Thanks [@vrcprl](https://github.com/vrcprl)! - rewrite HTML loader with block-based translation + +## 0.117.4 + +### Patch Changes + +- [#1644](https://github.com/lingodotdev/lingo.dev/pull/1644) [`2881712`](https://github.com/lingodotdev/lingo.dev/commit/2881712a1964dfa36eedfe70a00ae438f400647b) Thanks [@vrcprl](https://github.com/vrcprl)! - preserve list formatting in YAML files + +## 0.117.3 + +### Patch Changes + +- [#1642](https://github.com/lingodotdev/lingo.dev/pull/1642) [`9f429c6`](https://github.com/lingodotdev/lingo.dev/commit/9f429c6c8a64f8f829ac7bc1fc293697c5d93b9f) Thanks [@vrcprl](https://github.com/vrcprl)! - Preserve formatting in YAML files + +## 0.117.2 + +### Patch Changes + +- [#1640](https://github.com/lingodotdev/lingo.dev/pull/1640) [`80bcbe4`](https://github.com/lingodotdev/lingo.dev/commit/80bcbe4a65e0728e5795bb5b4f2b6e3d7e3aa206) Thanks [@vrcprl](https://github.com/vrcprl)! - preserve formatting for yaml format + +## 0.117.1 + +### Patch Changes + +- Updated dependencies [[`ec2f00a`](https://github.com/lingodotdev/lingo.dev/commit/ec2f00a0a1127ff4c5333ce4c6d8d691f89c4b17)]: + - @lingo.dev/_compiler@0.8.1 + +## 0.117.0 + +### Minor Changes + +- [#1634](https://github.com/lingodotdev/lingo.dev/pull/1634) [`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Pin all dependencies to exact versions to prevent supply chain attacks. Dependencies no longer use caret (^) or tilde (~) ranges, ensuring full control over version updates and requiring explicit review of all dependency changes. + +### Patch Changes + +- Updated dependencies [[`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144)]: + - @lingo.dev/_compiler@0.8.0 + - @lingo.dev/_locales@0.3.0 + - @lingo.dev/_react@0.7.0 + - @lingo.dev/_sdk@0.13.0 + - @lingo.dev/_spec@0.44.0 + +## 0.116.5 + +### Patch Changes + +- [#1626](https://github.com/lingodotdev/lingo.dev/pull/1626) [`9c338a8`](https://github.com/lingodotdev/lingo.dev/commit/9c338a8c5fab77c386d74700a6055c73d06daafd) Thanks [@vrcprl](https://github.com/vrcprl)! - preserve YAML literal block scalars without backslash escaping + +## 0.116.4 + +### Patch Changes + +- [#1622](https://github.com/lingodotdev/lingo.dev/pull/1622) [`3dd04bd`](https://github.com/lingodotdev/lingo.dev/commit/3dd04bd937828c16862b2b1459576931028bb01a) Thanks [@vrcprl](https://github.com/vrcprl)! - Fix ICU input + +## 0.116.3 + +### Patch Changes + +- [#1620](https://github.com/lingodotdev/lingo.dev/pull/1620) [`dd09791`](https://github.com/lingodotdev/lingo.dev/commit/dd09791948351046e083b077805db9039ee2faf1) Thanks [@vrcprl](https://github.com/vrcprl)! - add substitutions support to xcode-xcstrings-v2 + +## 0.116.2 + +### Patch Changes + +- [#1617](https://github.com/lingodotdev/lingo.dev/pull/1617) [`b0ac42a`](https://github.com/lingodotdev/lingo.dev/commit/b0ac42a896b46d0670a5ad9817304b32125aef85) Thanks [@vrcprl](https://github.com/vrcprl)! - support for stringSet to xcode-xcstrings and v2 + +## 0.116.1 + +### Patch Changes + +- Updated dependencies [[`0f6ffbf`](https://github.com/lingodotdev/lingo.dev/commit/0f6ffbf7dafafbead768eb9e52787cb6013aa1c3)]: + - @lingo.dev/_locales@0.2.0 + - @lingo.dev/_spec@0.43.1 + - @lingo.dev/_compiler@0.7.18 + - @lingo.dev/_sdk@0.12.9 + +## 0.116.0 + +### Minor Changes + +- [#1519](https://github.com/lingodotdev/lingo.dev/pull/1519) [`5d808bd`](https://github.com/lingodotdev/lingo.dev/commit/5d808bd33eb3a0b5c685e3a3a6cb079ba86eb6e2) Thanks [@Dishantydv7](https://github.com/Dishantydv7)! - fix(status): prevent NaN% when totalWordsToTranslate is 0 + +### Patch Changes + +- [`c0aa906`](https://github.com/lingodotdev/lingo.dev/commit/c0aa906880d26c5d01748e0d72b9f61ec989606d) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Fix prettier formatter loaders after prettier config removal + + Restores prettier as a runtime dependency for the CLI package and restores the `.prettierrc` config file needed by the formatter loaders. This fixes failing tests for HTML, JSON, and Markdown bucket loaders that depend on prettier for formatting translation files. + +## 0.115.0 + +### Minor Changes + +- [#1591](https://github.com/lingodotdev/lingo.dev/pull/1591) [`6267878`](https://github.com/lingodotdev/lingo.dev/commit/6267878d1be28337d77749e39ab3547b6a19b3ed) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Minor release + +## 0.114.7 + +### Patch Changes + +- Updated dependencies [[`ac38e8e`](https://github.com/lingodotdev/lingo.dev/commit/ac38e8e8dea0d8c4cd3c8b00e6394bfbd8074611), [`4d2359a`](https://github.com/lingodotdev/lingo.dev/commit/4d2359a3d7164f825bf5ddf62b5d13a4690cb4a2)]: + - @lingo.dev/_spec@0.43.0 + - @lingo.dev/_react@0.6.0 + - @lingo.dev/_compiler@0.7.17 + - @lingo.dev/_sdk@0.12.8 + +## 0.114.6 + +### Patch Changes + +- Updated dependencies [[`d72c67c`](https://github.com/lingodotdev/lingo.dev/commit/d72c67c78a4d8f01077db8098b5d973ec98a4c1e)]: + - @lingo.dev/_spec@0.42.0 + - @lingo.dev/_compiler@0.7.16 + - @lingo.dev/_sdk@0.12.7 + +## 0.114.5 + +### Patch Changes + +- [#1565](https://github.com/lingodotdev/lingo.dev/pull/1565) [`0a0d0c9`](https://github.com/lingodotdev/lingo.dev/commit/0a0d0c9ea3c7111ed0b54cdafba1bae76eeb8663) Thanks [@vrcprl](https://github.com/vrcprl)! - configurable author commit + +## 0.114.4 + +### Patch Changes + +- [#1544](https://github.com/lingodotdev/lingo.dev/pull/1544) [`68fb3ea`](https://github.com/lingodotdev/lingo.dev/commit/68fb3ea64fc0191ecee66403432e0c8efabab2b9) Thanks [@vrcprl](https://github.com/vrcprl)! - fix key encoding + +## 0.114.3 + +### Patch Changes + +- [#1542](https://github.com/lingodotdev/lingo.dev/pull/1542) [`e70385b`](https://github.com/lingodotdev/lingo.dev/commit/e70385bd1ac676bf5bd31b212d8510e6b7ebf793) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - chore: add changeset + +## 0.114.2 + +### Patch Changes + +- [#1535](https://github.com/lingodotdev/lingo.dev/pull/1535) [`f7215c1`](https://github.com/lingodotdev/lingo.dev/commit/f7215c1e435378aac8fc953765335cd478cbf507) Thanks [@vrcprl](https://github.com/vrcprl)! - prevent race condition in single-file format concurrent I/O + +## 0.114.1 + +### Patch Changes + +- [#1532](https://github.com/lingodotdev/lingo.dev/pull/1532) [`898bd36`](https://github.com/lingodotdev/lingo.dev/commit/898bd36cc2e444641560d2ad2b28065a57072183) Thanks [@vrcprl](https://github.com/vrcprl)! - fix CDATA and translatable=false strings in Android bucket + +## 0.114.0 + +### Minor Changes + +- [#1241](https://github.com/lingodotdev/lingo.dev/pull/1241) [`060680c`](https://github.com/lingodotdev/lingo.dev/commit/060680cd13c05dd77dd9d5447c064d948bd21cb0) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Enable locked patterns for all buckets + +- [#1240](https://github.com/lingodotdev/lingo.dev/pull/1240) [`a956e53`](https://github.com/lingodotdev/lingo.dev/commit/a956e537d0d45565c3243dd0c5ba4eec8bed69c6) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Enable ignored keys for all buckets + +- [#1239](https://github.com/lingodotdev/lingo.dev/pull/1239) [`3fd38c2`](https://github.com/lingodotdev/lingo.dev/commit/3fd38c2d38e4b22dcd824c865fe31abbc56bc862) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Enable locked keys for all buckets + +### Patch Changes + +- [#1331](https://github.com/lingodotdev/lingo.dev/pull/1331) [`f102356`](https://github.com/lingodotdev/lingo.dev/commit/f102356e1ea12c800399ac11f074c42708c304b1) Thanks [@vrcprl](https://github.com/vrcprl)! - fix xcode-xcstrings-v2 flattening + +## 0.113.8 + +### Patch Changes + +- [#1245](https://github.com/lingodotdev/lingo.dev/pull/1245) [`03671f7`](https://github.com/lingodotdev/lingo.dev/commit/03671f7cb252d6bee3debce2f4a4eb989dc0050b) Thanks [@vrcprl](https://github.com/vrcprl)! - update xcode-strings example + +## 0.113.7 + +### Patch Changes + +- [#1243](https://github.com/lingodotdev/lingo.dev/pull/1243) [`4f5ffe6`](https://github.com/lingodotdev/lingo.dev/commit/4f5ffe62189949bb26a6c7825cb72c217aefa32f) Thanks [@vrcprl](https://github.com/vrcprl)! - Improve xcode-strings loader + +## 0.113.6 + +### Patch Changes + +- [#1238](https://github.com/lingodotdev/lingo.dev/pull/1238) [`be8de32`](https://github.com/lingodotdev/lingo.dev/commit/be8de3280bb5dc5f409fc7680c0e5ff6a53e2fe5) Thanks [@vrcprl](https://github.com/vrcprl)! - enchance Android bucket loader + +- Updated dependencies [[`44a928b`](https://github.com/lingodotdev/lingo.dev/commit/44a928b473802cd07bec64f94a273ee1b845a0d0)]: + - @lingo.dev/_compiler@0.7.15 + +## 0.113.5 + +### Patch Changes + +- [#1233](https://github.com/lingodotdev/lingo.dev/pull/1233) [`79c4c00`](https://github.com/lingodotdev/lingo.dev/commit/79c4c00108b9c102cf53e1c090b286070a43e3d5) Thanks [@vrcprl](https://github.com/vrcprl)! - i18n xcode-scstring-v2 log fix + +## 0.113.4 + +### Patch Changes + +- [#1230](https://github.com/lingodotdev/lingo.dev/pull/1230) [`b45347c`](https://github.com/lingodotdev/lingo.dev/commit/b45347c38572ee371b2bc494261b7e3e90c4aed1) Thanks [@vrcprl](https://github.com/vrcprl)! - add an xcode-xcstrings-v2 bucket type that supports cldr pluralization rules + +- Updated dependencies [[`b45347c`](https://github.com/lingodotdev/lingo.dev/commit/b45347c38572ee371b2bc494261b7e3e90c4aed1)]: + - @lingo.dev/_spec@0.41.1 + - @lingo.dev/_sdk@0.12.6 + - @lingo.dev/_compiler@0.7.14 + +## 0.113.3 + +### Patch Changes + +- [#1227](https://github.com/lingodotdev/lingo.dev/pull/1227) [`74d8efe`](https://github.com/lingodotdev/lingo.dev/commit/74d8efef8d4789f9baa5b7837e053c2571df0308) Thanks [@vrcprl](https://github.com/vrcprl)! - Add ignoredKeys support + +## 0.113.2 + +### Patch Changes + +- [#1224](https://github.com/lingodotdev/lingo.dev/pull/1224) [`3d3c3d7`](https://github.com/lingodotdev/lingo.dev/commit/3d3c3d783a61443da50a5d182391db33a0d29c84) Thanks [@vrcprl](https://github.com/vrcprl)! - fix code replacement in mdx + +## 0.113.1 + +### Patch Changes + +- [#1222](https://github.com/lingodotdev/lingo.dev/pull/1222) [`38139c8`](https://github.com/lingodotdev/lingo.dev/commit/38139c81a85001739cece60873c0c6ad711327a4) Thanks [@vrcprl](https://github.com/vrcprl)! - fix regex replacement + +- Updated dependencies [[`38139c8`](https://github.com/lingodotdev/lingo.dev/commit/38139c81a85001739cece60873c0c6ad711327a4)]: + - @lingo.dev/_compiler@0.7.13 + +## 0.113.0 + +### Minor Changes + +- [#1211](https://github.com/lingodotdev/lingo.dev/pull/1211) [`3413dad`](https://github.com/lingodotdev/lingo.dev/commit/3413dad22af688a6d26649c4f25e18304b3caee6) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Add `--frozen` mode to validate translations without writing changes. + +## 0.112.1 + +### Patch Changes + +- [#1218](https://github.com/lingodotdev/lingo.dev/pull/1218) [`26d2ec1`](https://github.com/lingodotdev/lingo.dev/commit/26d2ec155c5868a5bdce1027cd76a5a2d4f8f2b1) Thanks [@vrcprl](https://github.com/vrcprl)! - add 'show ignored-keys' and 'show locked-keys' commands + +## 0.112.0 + +### Minor Changes + +- [#1186](https://github.com/lingodotdev/lingo.dev/pull/1186) [`82f5e7c`](https://github.com/lingodotdev/lingo.dev/commit/82f5e7cdde9a2a15b4c2a7fcb8c67ed64eab596b) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Add Markdoc support + +### Patch Changes + +- [#1215](https://github.com/lingodotdev/lingo.dev/pull/1215) [`e858174`](https://github.com/lingodotdev/lingo.dev/commit/e858174fd5165e0ea3e3f25fa1fc3edb292bc58f) Thanks [@vrcprl](https://github.com/vrcprl)! - add provider settings + +- Updated dependencies [[`82f5e7c`](https://github.com/lingodotdev/lingo.dev/commit/82f5e7cdde9a2a15b4c2a7fcb8c67ed64eab596b), [`e858174`](https://github.com/lingodotdev/lingo.dev/commit/e858174fd5165e0ea3e3f25fa1fc3edb292bc58f)]: + - @lingo.dev/_spec@0.41.0 + - @lingo.dev/_compiler@0.7.12 + - @lingo.dev/_sdk@0.12.5 + +## 0.111.16 + +### Patch Changes + +- [#1185](https://github.com/lingodotdev/lingo.dev/pull/1185) [`f3d4987`](https://github.com/lingodotdev/lingo.dev/commit/f3d4987ddc393c28d488f030c087f3e99a667975) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - updated product hunt badges + +- [#1208](https://github.com/lingodotdev/lingo.dev/pull/1208) [`a933b81`](https://github.com/lingodotdev/lingo.dev/commit/a933b8102763e0481f088c847da53e0eee3f0617) Thanks [@vrcprl](https://github.com/vrcprl)! - Fix run retranslation with run command + +## 0.111.15 + +### Patch Changes + +- Updated dependencies [[`1fa218c`](https://github.com/lingodotdev/lingo.dev/commit/1fa218c13bf90df6d175fb18264f59c1a10b967c)]: + - @lingo.dev/_spec@0.40.4 + - @lingo.dev/_compiler@0.7.11 + - @lingo.dev/_sdk@0.12.4 + +## 0.111.14 + +### Patch Changes + +- [#1200](https://github.com/lingodotdev/lingo.dev/pull/1200) [`dd0663f`](https://github.com/lingodotdev/lingo.dev/commit/dd0663fdcdd0ff4fd5748386758a8c20f9e52a4b) Thanks [@vrcprl](https://github.com/vrcprl)! - fix Biome JS API v3 bug + +## 0.111.13 + +### Patch Changes + +- [#1197](https://github.com/lingodotdev/lingo.dev/pull/1197) [`762396b`](https://github.com/lingodotdev/lingo.dev/commit/762396bb37110dbe3e4e000edb27892b318aa3ef) Thanks [@vrcprl](https://github.com/vrcprl)! - biome error logging + +## 0.111.12 + +### Patch Changes + +- [#1195](https://github.com/lingodotdev/lingo.dev/pull/1195) [`468a59b`](https://github.com/lingodotdev/lingo.dev/commit/468a59b89736c72253b1f32abbf30a950e5434ec) Thanks [@vrcprl](https://github.com/vrcprl)! - Fix Biome formatting + +## 0.111.11 + +### Patch Changes + +- [#1192](https://github.com/lingodotdev/lingo.dev/pull/1192) [`bbc71b9`](https://github.com/lingodotdev/lingo.dev/commit/bbc71b9948ccc289c9669d8b0c276c9596f6a5e7) Thanks [@vrcprl](https://github.com/vrcprl)! - Add biome support + +- Updated dependencies [[`bbc71b9`](https://github.com/lingodotdev/lingo.dev/commit/bbc71b9948ccc289c9669d8b0c276c9596f6a5e7)]: + - @lingo.dev/_spec@0.40.3 + - @lingo.dev/_compiler@0.7.10 + - @lingo.dev/_sdk@0.12.3 + +## 0.111.10 + +### Patch Changes + +- [#1189](https://github.com/lingodotdev/lingo.dev/pull/1189) [`0e6d605`](https://github.com/lingodotdev/lingo.dev/commit/0e6d605a9ad6835bef26c40895760c652a69b7a2) Thanks [@vrcprl](https://github.com/vrcprl)! - upd stars + +## 0.111.9 + +### Patch Changes + +- [#1177](https://github.com/lingodotdev/lingo.dev/pull/1177) [`03138da`](https://github.com/lingodotdev/lingo.dev/commit/03138dac37e869e2e99702ffd3c76532f1c58aa6) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Improve CLI command descriptions + +- [#1183](https://github.com/lingodotdev/lingo.dev/pull/1183) [`9557fe5`](https://github.com/lingodotdev/lingo.dev/commit/9557fe572d3e4a1a4d8c1e35417fe3b7531c3d52) Thanks [@vrcprl](https://github.com/vrcprl)! - fix lockedKeys in xcstrings + +## 0.111.8 + +### Patch Changes + +- [#1174](https://github.com/lingodotdev/lingo.dev/pull/1174) [`64225d0`](https://github.com/lingodotdev/lingo.dev/commit/64225d073999d599ba86f65fee8e08e3e5f2800b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - locale-codes reexport + +## 0.111.7 + +### Patch Changes + +- Updated dependencies [[`6579d70`](https://github.com/lingodotdev/lingo.dev/commit/6579d70bc670c2fdc06c09842d931b07e134151c)]: + - @lingo.dev/_spec@0.40.2 + - @lingo.dev/_compiler@0.7.9 + - @lingo.dev/_sdk@0.12.2 + +## 0.111.6 + +### Patch Changes + +- [#1164](https://github.com/lingodotdev/lingo.dev/pull/1164) [`88b7e31`](https://github.com/lingodotdev/lingo.dev/commit/88b7e3132c77d0a1e823de4ee6ef5a96a3098b97) Thanks [@vrcprl](https://github.com/vrcprl)! - upd demo + +## 0.111.5 + +### Patch Changes + +- [#1166](https://github.com/lingodotdev/lingo.dev/pull/1166) [`d9294c0`](https://github.com/lingodotdev/lingo.dev/commit/d9294c0bbb993454ad3654f77dd48d82211e0465) Thanks [@vrcprl](https://github.com/vrcprl)! - enhance cli errors debugging + +## 0.111.4 + +### Patch Changes + +- [#1157](https://github.com/lingodotdev/lingo.dev/pull/1157) [`100b141`](https://github.com/lingodotdev/lingo.dev/commit/100b141d2143e33b603830475ba55089dc421e3d) Thanks [@ankur0904](https://github.com/ankur0904)! - add --sound flag for task completion + +## 0.111.3 + +### Patch Changes + +- [`8741a20`](https://github.com/lingodotdev/lingo.dev/commit/8741a20dcaa3983131a1919f875dd2c264cb29fb) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix observability tracking + +## 0.111.2 + +### Patch Changes + +- [#1149](https://github.com/lingodotdev/lingo.dev/pull/1149) [`bd3f69d`](https://github.com/lingodotdev/lingo.dev/commit/bd3f69dde76814146f775bc87241fa2fad012ab0) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Fix CI command hanging due to process.exit calls + - Remove PostHog shutdown() call that was causing process to hang + - Replace process.exit() with proper exception throwing in i18n and run commands + - Upgrade posthog-node from 5.5.1 to 5.8.1 for better stability + - This fixes the CI command integration where process.exit() was terminating the parent process instead of returning control + +## 0.111.1 + +### Patch Changes + +- [#1144](https://github.com/lingodotdev/lingo.dev/pull/1144) [`6c174c3`](https://github.com/lingodotdev/lingo.dev/commit/6c174c38f3cf28c2af24ead18503658c3c641026) Thanks [@mathio](https://github.com/mathio)! - exit cli gracefully + +## 0.111.0 + +### Minor Changes + +- [#1134](https://github.com/lingodotdev/lingo.dev/pull/1134) [`3a642f3`](https://github.com/lingodotdev/lingo.dev/commit/3a642f33c04378706a8382aa0fde36e747fd6af5) Thanks [@mathio](https://github.com/mathio)! - useLingoLocale, setLingoLocale + +### Patch Changes + +- Updated dependencies [[`a35032e`](https://github.com/lingodotdev/lingo.dev/commit/a35032e7e7a188d1f5e774576352068124526e24), [`3a642f3`](https://github.com/lingodotdev/lingo.dev/commit/3a642f33c04378706a8382aa0fde36e747fd6af5)]: + - @lingo.dev/_spec@0.40.1 + - @lingo.dev/_react@0.5.0 + - @lingo.dev/_compiler@0.7.8 + - @lingo.dev/_sdk@0.12.1 + +## 0.110.5 + +### Patch Changes + +- [#1130](https://github.com/lingodotdev/lingo.dev/pull/1130) [`bc7b08e`](https://github.com/lingodotdev/lingo.dev/commit/bc7b08ef1245d1af0c68813cb18193d4f14bc7e0) Thanks [@mathio](https://github.com/mathio)! - dictionary path calculation + +- Updated dependencies [[`bc7b08e`](https://github.com/lingodotdev/lingo.dev/commit/bc7b08ef1245d1af0c68813cb18193d4f14bc7e0)]: + - @lingo.dev/_compiler@0.7.7 + +## 0.110.4 + +### Patch Changes + +- [#1121](https://github.com/lingodotdev/lingo.dev/pull/1121) [`b6071e4`](https://github.com/lingodotdev/lingo.dev/commit/b6071e4f19dd1823f4f2ce54ba5495538a94d4fd) Thanks [@mathio](https://github.com/mathio)! - compiler: prevent duplicate props + +- Updated dependencies [[`b6071e4`](https://github.com/lingodotdev/lingo.dev/commit/b6071e4f19dd1823f4f2ce54ba5495538a94d4fd)]: + - @lingo.dev/_compiler@0.7.6 + +## 0.110.3 + +### Patch Changes + +- [#1119](https://github.com/lingodotdev/lingo.dev/pull/1119) [`e898c1e`](https://github.com/lingodotdev/lingo.dev/commit/e898c1eeb34e4dd3e74df26465802b520018acf9) Thanks [@mathio](https://github.com/mathio)! - compiler fallback to source locale + +- Updated dependencies [[`e898c1e`](https://github.com/lingodotdev/lingo.dev/commit/e898c1eeb34e4dd3e74df26465802b520018acf9)]: + - @lingo.dev/_react@0.4.3 + +## 0.110.2 + +### Patch Changes + +- [#1118](https://github.com/lingodotdev/lingo.dev/pull/1118) [`410825c`](https://github.com/lingodotdev/lingo.dev/commit/410825c8bf0029d8ee458514d6f203a7397c8f22) Thanks [@mathio](https://github.com/mathio)! - support Turbopack in Next.js v14 by Compiler + +- Updated dependencies [[`410825c`](https://github.com/lingodotdev/lingo.dev/commit/410825c8bf0029d8ee458514d6f203a7397c8f22), [`bc419ae`](https://github.com/lingodotdev/lingo.dev/commit/bc419aeeb4211d80d3c0ddd65deeab62ad68fea8)]: + - @lingo.dev/_compiler@0.7.5 + +## 0.110.1 + +### Patch Changes + +- [`555384d`](https://github.com/lingodotdev/lingo.dev/commit/555384dacf79167e1bb8b9e6871e153fea763471) Thanks [@mathio](https://github.com/mathio)! - revert + +## 0.110.0 + +### Minor Changes + +- [#1065](https://github.com/lingodotdev/lingo.dev/pull/1065) [`c0486ca`](https://github.com/lingodotdev/lingo.dev/commit/c0486ca9b0451ea75d070e199f502507ba418e5e) Thanks [@VAIBHAVSING](https://github.com/VAIBHAVSING)! - Add support for `ignoredKeys` in TypeScript loader + + The TypeScript loader now fully supports the `ignoredKeys` option, allowing you to exclude specific keys (including nested keys) from localization when using both `export default` and `export const` patterns. This works seamlessly with the `run` method and the CLI, and is compatible with flattened key structures. All related tests now pass. + +## 0.109.2 + +### Patch Changes + +- [#1108](https://github.com/lingodotdev/lingo.dev/pull/1108) [`99aae2d`](https://github.com/lingodotdev/lingo.dev/commit/99aae2d09a26060c810913f740893a4a5874d9d4) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Update deprecated 'lingo.dev auth --login' command references to 'lingo.dev login' in CLI error messages + +## 0.109.1 + +### Patch Changes + +- Updated dependencies [[`3cb1ebe`](https://github.com/lingodotdev/lingo.dev/commit/3cb1ebec5441882678ab30a7d1b532bc2fc397b6)]: + - @lingo.dev/_compiler@0.7.4 + +## 0.109.0 + +### Minor Changes + +- [#1066](https://github.com/lingodotdev/lingo.dev/pull/1066) [`6af91a0`](https://github.com/lingodotdev/lingo.dev/commit/6af91a083d16f85051fb49a4034789abe784017e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add hints support for xcode and jsonc buckets + +### Patch Changes + +- Updated dependencies [[`6af91a0`](https://github.com/lingodotdev/lingo.dev/commit/6af91a083d16f85051fb49a4034789abe784017e), [`6af91a0`](https://github.com/lingodotdev/lingo.dev/commit/6af91a083d16f85051fb49a4034789abe784017e)]: + - @lingo.dev/_spec@0.40.0 + - @lingo.dev/_sdk@0.12.0 + - @lingo.dev/_compiler@0.7.3 + +## 0.108.0 + +### Minor Changes + +- [#1061](https://github.com/lingodotdev/lingo.dev/pull/1061) [`55e9e68`](https://github.com/lingodotdev/lingo.dev/commit/55e9e687a3d0efa84b808818a848a276b1a42015) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add Discord link to CLI help text + +### Patch Changes + +- [#1062](https://github.com/lingodotdev/lingo.dev/pull/1062) [`1ff847b`](https://github.com/lingodotdev/lingo.dev/commit/1ff847b9273a3082178553e70c22524f5831ad36) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fixed missing placeholder replacement logic in mdx + +- [#1028](https://github.com/lingodotdev/lingo.dev/pull/1028) [`b9e2551`](https://github.com/lingodotdev/lingo.dev/commit/b9e2551f349e33542212f941b3407e8517b5fb27) Thanks [@pushkar1713](https://github.com/pushkar1713)! - Make run cmd in CLI print a list of collected errors + +- Updated dependencies [[`85dfc10`](https://github.com/lingodotdev/lingo.dev/commit/85dfc10961b116e31b2bb478f42013756ca49974), [`2d67369`](https://github.com/lingodotdev/lingo.dev/commit/2d673697b9cf4d91de2f48444581f8b3fd894cd6)]: + - @lingo.dev/_sdk@0.11.0 + - @lingo.dev/_react@0.4.2 + - @lingo.dev/_compiler@0.7.2 + +## 0.107.6 + +### Patch Changes + +- Updated dependencies [[`f897a7d`](https://github.com/lingodotdev/lingo.dev/commit/f897a7d0a3f7a236fb64f19bce9a8d00626d09ca)]: + - @lingo.dev/_compiler@0.7.1 + +## 0.107.5 + +### Patch Changes + +- Updated dependencies [[`bd9538a`](https://github.com/lingodotdev/lingo.dev/commit/bd9538ac6eba0ffc91ffc1fef5db6366c13e9e06)]: + - @lingo.dev/_compiler@0.7.0 + +## 0.107.4 + +### Patch Changes + +- [#1038](https://github.com/lingodotdev/lingo.dev/pull/1038) [`20a3737`](https://github.com/lingodotdev/lingo.dev/commit/20a3737ddb50b2a97699e57e03ea353b8912b78f) Thanks [@mathio](https://github.com/mathio)! - json-dictionary with locales on top level + +## 0.107.3 + +### Patch Changes + +- [#1031](https://github.com/lingodotdev/lingo.dev/pull/1031) [`afbb978`](https://github.com/lingodotdev/lingo.dev/commit/afbb978fec83d574f2c43b7d68457e435fca9b57) Thanks [@mathio](https://github.com/mathio)! - add json-dictionary loader support + +- Updated dependencies [[`afbb978`](https://github.com/lingodotdev/lingo.dev/commit/afbb978fec83d574f2c43b7d68457e435fca9b57)]: + - @lingo.dev/_spec@0.39.3 + - @lingo.dev/_compiler@0.6.3 + - @lingo.dev/_sdk@0.10.2 + +## 0.107.2 + +### Patch Changes + +- [#1029](https://github.com/lingodotdev/lingo.dev/pull/1029) [`1f1e33f`](https://github.com/lingodotdev/lingo.dev/commit/1f1e33fe4d0767c2f026214a505a2aa9f3785996) Thanks [@mathio](https://github.com/mathio)! - allow wildcards when matching lockedKeys, ignoredKeys, injectLocale + +- [#1023](https://github.com/lingodotdev/lingo.dev/pull/1023) [`9266fd0`](https://github.com/lingodotdev/lingo.dev/commit/9266fd0bcddf4b07ca51d2609af92a9473106f9d) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Update Zod dependency to version 3.25.76 + +- Updated dependencies [[`9266fd0`](https://github.com/lingodotdev/lingo.dev/commit/9266fd0bcddf4b07ca51d2609af92a9473106f9d)]: + - @lingo.dev/_compiler@0.6.2 + - @lingo.dev/_spec@0.39.2 + - @lingo.dev/_sdk@0.10.1 + +## 0.107.1 + +### Patch Changes + +- [#1021](https://github.com/lingodotdev/lingo.dev/pull/1021) [`6baa1a7`](https://github.com/lingodotdev/lingo.dev/commit/6baa1a7e88dbfac3783d1d49695595077fd8d209) Thanks [@mathio](https://github.com/mathio)! - add lingo.dev provider details + +- Updated dependencies [[`6baa1a7`](https://github.com/lingodotdev/lingo.dev/commit/6baa1a7e88dbfac3783d1d49695595077fd8d209)]: + - @lingo.dev/_compiler@0.6.1 + +## 0.107.0 + +### Minor Changes + +- [#1019](https://github.com/lingodotdev/lingo.dev/pull/1019) [`925997d`](https://github.com/lingodotdev/lingo.dev/commit/925997d75a1edbb4211a3be8db2b186cb139327e) Thanks [@mathio](https://github.com/mathio)! - injectLocale uses forward slash now + +## 0.106.0 + +### Minor Changes + +- [#998](https://github.com/lingodotdev/lingo.dev/pull/998) [`cb2aa0f`](https://github.com/lingodotdev/lingo.dev/commit/cb2aa0f505d6b7dbc435b526e8a6f62265d1f453) Thanks [@VAIBHAVSING](https://github.com/VAIBHAVSING)! - Added support for AbortController to all public SDK methods, enabling consumers to cancel long-running operations using the standard AbortController API. Refactored internal methods to propagate AbortSignal and check for abortion between batch chunks. Updated fetch calls to use AbortSignal for network request cancellation. + +### Patch Changes + +- Updated dependencies [[`864c305`](https://github.com/lingodotdev/lingo.dev/commit/864c30586510e6b69739c20fa42efdf45d8881ed), [`cb2aa0f`](https://github.com/lingodotdev/lingo.dev/commit/cb2aa0f505d6b7dbc435b526e8a6f62265d1f453)]: + - @lingo.dev/_compiler@0.6.0 + - @lingo.dev/_sdk@0.10.0 + +## 0.105.4 + +### Patch Changes + +- [#1011](https://github.com/lingodotdev/lingo.dev/pull/1011) [`bfcb424`](https://github.com/lingodotdev/lingo.dev/commit/bfcb424eb4479d0d3b767e062d30f02c5bcaeb14) Thanks [@mathio](https://github.com/mathio)! - replace elements with dot in name + +- Updated dependencies [[`bfcb424`](https://github.com/lingodotdev/lingo.dev/commit/bfcb424eb4479d0d3b767e062d30f02c5bcaeb14)]: + - @lingo.dev/_compiler@0.5.5 + - @lingo.dev/_react@0.4.1 + +## 0.105.3 + +### Patch Changes + +- [#1002](https://github.com/lingodotdev/lingo.dev/pull/1002) [`2b297ba`](https://github.com/lingodotdev/lingo.dev/commit/2b297babe76f9799c5154d9421fecd1ebbe1bb72) Thanks [@mathio](https://github.com/mathio)! - support custom prompts in compiler + +- Updated dependencies [[`2b297ba`](https://github.com/lingodotdev/lingo.dev/commit/2b297babe76f9799c5154d9421fecd1ebbe1bb72)]: + - @lingo.dev/_compiler@0.5.4 + +## 0.105.2 + +### Patch Changes + +- [#1000](https://github.com/lingodotdev/lingo.dev/pull/1000) [`30faa6d`](https://github.com/lingodotdev/lingo.dev/commit/30faa6d10e851a38ced86ae403b3a1fd48440bca) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - xliff 1.2 implementation + +## 0.105.1 + +### Patch Changes + +- Updated dependencies [[`acd5356`](https://github.com/lingodotdev/lingo.dev/commit/acd5356b68d2261576240c173fea790864c3c31d)]: + - @lingo.dev/_spec@0.39.1 + - @lingo.dev/_sdk@0.9.6 + - @lingo.dev/_compiler@0.5.3 + +## 0.105.0 + +### Minor Changes + +- [#992](https://github.com/lingodotdev/lingo.dev/pull/992) [`4e9e368`](https://github.com/lingodotdev/lingo.dev/commit/4e9e36830ee4277ef9d65eee9ee92380a95a622c) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - feat: skip lockfile updates when --target-locale or --locale flags are provided explicitly + +## 0.104.0 + +### Minor Changes + +- [#986](https://github.com/lingodotdev/lingo.dev/pull/986) [`65701e5`](https://github.com/lingodotdev/lingo.dev/commit/65701e5b9694e811587ef600227251a1ff1384a0) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add glob pattern matching support to --file argument using minimatch library + +### Patch Changes + +- [#988](https://github.com/lingodotdev/lingo.dev/pull/988) [`4e55355`](https://github.com/lingodotdev/lingo.dev/commit/4e5535535029743b7a0edc4fdab3d4ee71374035) Thanks [@mathio](https://github.com/mathio)! - o11y + +## 0.103.0 + +### Minor Changes + +- [#981](https://github.com/lingodotdev/lingo.dev/pull/981) [`f644123`](https://github.com/lingodotdev/lingo.dev/commit/f644123ddf6a6254790d08af50141e4dd78c3677) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add support for plain TXT files to enable translation of fastlane App Store metadata and other plain text content + +### Patch Changes + +- Updated dependencies [[`f644123`](https://github.com/lingodotdev/lingo.dev/commit/f644123ddf6a6254790d08af50141e4dd78c3677)]: + - @lingo.dev/_spec@0.39.0 + - @lingo.dev/_sdk@0.9.5 + - @lingo.dev/_compiler@0.5.2 + +## 0.102.4 + +### Patch Changes + +- [#982](https://github.com/lingodotdev/lingo.dev/pull/982) [`29cf6a7`](https://github.com/lingodotdev/lingo.dev/commit/29cf6a7359707e0e341c11942d1ce6dedf7e66e5) Thanks [@mathio](https://github.com/mathio)! - fix xcstrings version + +## 0.102.3 + +### Patch Changes + +- [#972](https://github.com/lingodotdev/lingo.dev/pull/972) [`b249484`](https://github.com/lingodotdev/lingo.dev/commit/b249484d6f0060e29cd5b50b3d8ce68b857ccad5) Thanks [@mathio](https://github.com/mathio)! - support components with dot in name + +- Updated dependencies [[`b249484`](https://github.com/lingodotdev/lingo.dev/commit/b249484d6f0060e29cd5b50b3d8ce68b857ccad5)]: + - @lingo.dev/_compiler@0.5.1 + +## 0.102.2 + +### Patch Changes + +- [#946](https://github.com/lingodotdev/lingo.dev/pull/946) [`f7debef`](https://github.com/lingodotdev/lingo.dev/commit/f7debef9f004e670bb1f6a45ae17067a72a6e53f) Thanks [@scm1400](https://github.com/scm1400)! - normalize paths and improve compatibility on windows + +## 0.102.1 + +### Patch Changes + +- [#969](https://github.com/lingodotdev/lingo.dev/pull/969) [`da6f0c8`](https://github.com/lingodotdev/lingo.dev/commit/da6f0c85e69687615df943323d261078742ba3f2) Thanks [@mathio](https://github.com/mathio)! - run: always push to target locales + +## 0.102.0 + +### Minor Changes + +- [#966](https://github.com/lingodotdev/lingo.dev/pull/966) [`8b306bc`](https://github.com/lingodotdev/lingo.dev/commit/8b306bcd0a3231ffd8bde283414b6d069b7a5b99) Thanks [@VAIBHAVSING](https://github.com/VAIBHAVSING)! - Add watch mode to CLI for automatic retranslation on file changes + + This release introduces a new watch mode feature that automatically triggers retranslation when changes are detected in source files: + - **New `--watch` flag**: Enables file watching mode that monitors source files for changes + - **New `--debounce` flag**: Configurable debounce delay (default: 5 seconds) to prevent excessive retranslations + - **Intelligent file pattern detection**: Automatically determines which files to watch based on i18n.json bucket configurations + - **Graceful error handling**: Robust error recovery and process management + - **Background operation**: Non-blocking watch mode with proper cleanup on exit (Ctrl+C) + + **Usage:** + + ```bash + # Enable watch mode with default 5-second debounce + lingo.dev run --watch + + # Enable watch mode with custom debounce timing + lingo.dev run --watch --debounce 7000 + + # Combine with other flags + lingo.dev run --watch --target-locale es --bucket json + ``` + + **Technical Implementation:** + - Uses `chokidar` for robust cross-platform file watching + - Integrates seamlessly with existing CLI pipeline (setup → plan → execute) + - Maintains full compatibility with all existing CLI options and workflows + - Includes comprehensive documentation in `WATCH_MODE.md` + + This feature significantly improves developer experience by eliminating the need to manually retrigger translations during development. + +### Patch Changes + +- [#968](https://github.com/lingodotdev/lingo.dev/pull/968) [`013fca0`](https://github.com/lingodotdev/lingo.dev/commit/013fca0f4252103ee3009fe3cdcfce2a87c80058) Thanks [@mathio](https://github.com/mathio)! - reorder falsy keys + +## 0.101.0 + +### Minor Changes + +- [#958](https://github.com/lingodotdev/lingo.dev/pull/958) [`84fd214`](https://github.com/lingodotdev/lingo.dev/commit/84fd214a21766e7683c5d645fcb8c4c0162eb0b6) Thanks [@chrissiwaffler](https://github.com/chrissiwaffler)! - feat: add Mistral AI as a supported LLM provider + - Added Mistral AI provider support across the entire lingo.dev ecosystem + - Users can now use Mistral models for localization by setting MISTRAL_API_KEY + - Supports all Mistral models available through the @ai-sdk/mistral package + - Configuration via environment variable or user-wide config: `npx lingo.dev@latest config set llm.mistralApiKey ` + +### Patch Changes + +- [#962](https://github.com/lingodotdev/lingo.dev/pull/962) [`0fc6385`](https://github.com/lingodotdev/lingo.dev/commit/0fc63856c6f49ac68a220b6e2f1c4f060e7ce78e) Thanks [@mathio](https://github.com/mathio)! - format with prettier, add prettier check for PRs + +- [#955](https://github.com/lingodotdev/lingo.dev/pull/955) [`cac5429`](https://github.com/lingodotdev/lingo.dev/commit/cac54296d512d436dc3861441d5d1a3f1076792b) Thanks [@mathio](https://github.com/mathio)! - progressive push as chunks are processed + +- Updated dependencies [[`84fd214`](https://github.com/lingodotdev/lingo.dev/commit/84fd214a21766e7683c5d645fcb8c4c0162eb0b6)]: + - @lingo.dev/_compiler@0.5.0 + - @lingo.dev/_spec@0.38.0 + - @lingo.dev/_sdk@0.9.4 + +## 0.100.1 + +### Patch Changes + +- [#960](https://github.com/lingodotdev/lingo.dev/pull/960) [`ce0e5cd`](https://github.com/lingodotdev/lingo.dev/commit/ce0e5cd6d1ec17f5c593d394ceb63a28666df924) Thanks [@mathio](https://github.com/mathio)! - fix compiler dictionary + +## 0.100.0 + +### Minor Changes + +- [#956](https://github.com/lingodotdev/lingo.dev/pull/956) [`ce8c75c`](https://github.com/lingodotdev/lingo.dev/commit/ce8c75c7fc1a2124d3e18444bc356c4dfce26434) Thanks [@VAIBHAVSING](https://github.com/VAIBHAVSING)! - feat: add EJS (Embedded JavaScript) templating engine support + - Added EJS loader to support parsing and translating EJS template files + - EJS loader extracts translatable text while preserving EJS tags and expressions + - Updated spec package to include "ejs" in supported bucket types + - Added comprehensive test suite covering various EJS scenarios including conditionals, loops, includes, and mixed content + - Automatically installed EJS dependency (@types/ejs) for TypeScript support + +### Patch Changes + +- Updated dependencies [[`ce8c75c`](https://github.com/lingodotdev/lingo.dev/commit/ce8c75c7fc1a2124d3e18444bc356c4dfce26434)]: + - @lingo.dev/_spec@0.37.0 + - @lingo.dev/_sdk@0.9.3 + - @lingo.dev/_compiler@0.4.1 + +## 0.99.8 + +### Patch Changes + +- Updated dependencies [[`1bba8ee`](https://github.com/lingodotdev/lingo.dev/commit/1bba8eed6272ae166ceb9b92963404bfe90a4aaa)]: + - @lingo.dev/_compiler@0.4.0 + +## 0.99.7 + +### Patch Changes + +- [#947](https://github.com/lingodotdev/lingo.dev/pull/947) [`d80285a`](https://github.com/lingodotdev/lingo.dev/commit/d80285a9b12bd85425564cb00e558812fd0aee40) Thanks [@mathio](https://github.com/mathio)! - remove local variable cache + +- Updated dependencies [[`d80285a`](https://github.com/lingodotdev/lingo.dev/commit/d80285a9b12bd85425564cb00e558812fd0aee40)]: + - @lingo.dev/_compiler@0.3.5 + +## 0.99.6 + +### Patch Changes + +- [`81eff21`](https://github.com/lingodotdev/lingo.dev/commit/81eff2104a4401b1c1b6cdf4dcc7ca75b7411ba4) Thanks [@mathio](https://github.com/mathio)! - fix + +## 0.99.5 + +### Patch Changes + +- [#948](https://github.com/lingodotdev/lingo.dev/pull/948) [`b39b04a`](https://github.com/lingodotdev/lingo.dev/commit/b39b04ad83d3c8001008c3cefe309d8e762b2adc) Thanks [@mathio](https://github.com/mathio)! - match --keys via minimatch in run + +- [#937](https://github.com/lingodotdev/lingo.dev/pull/937) [`4e5983d`](https://github.com/lingodotdev/lingo.dev/commit/4e5983d7e59ebf9eb529c4b7c1c87689432ac873) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Update documentation URLs from docs.lingo.dev to lingo.dev/cli and lingo.dev/compiler + +- Updated dependencies [[`4e5983d`](https://github.com/lingodotdev/lingo.dev/commit/4e5983d7e59ebf9eb529c4b7c1c87689432ac873)]: + - @lingo.dev/_compiler@0.3.4 + - @lingo.dev/_sdk@0.9.2 + +## 0.99.4 + +### Patch Changes + +- [#933](https://github.com/lingodotdev/lingo.dev/pull/933) [`1a3cbc1`](https://github.com/lingodotdev/lingo.dev/commit/1a3cbc1751c64e5617e91812506b3c061475f16a) Thanks [@caffeinated10xprogrammer](https://github.com/caffeinated10xprogrammer)! - fix a bug in cli status command when delimiter is used + +## 0.99.3 + +### Patch Changes + +- Updated dependencies [[`76cbd9b`](https://github.com/lingodotdev/lingo.dev/commit/76cbd9b2f2e1217421ad1f671bed5b3d64b43333)]: + - @lingo.dev/_compiler@0.3.3 + +## 0.99.2 + +### Patch Changes + +- Updated dependencies [[`01f253d`](https://github.com/lingodotdev/lingo.dev/commit/01f253dd9759b518f400dff03ab51b460b9b8997)]: + - @lingo.dev/_compiler@0.3.2 + +## 0.99.1 + +### Patch Changes + +- Updated dependencies [[`8e97256`](https://github.com/lingodotdev/lingo.dev/commit/8e97256ca4e78dd09a967539ca9dec359bd558ef)]: + - @lingo.dev/_compiler@0.3.1 + +## 0.99.0 + +### Minor Changes + +- [#913](https://github.com/lingodotdev/lingo.dev/pull/913) [`1b9b113`](https://github.com/lingodotdev/lingo.dev/commit/1b9b11301978e8caa2555832d027ff93216aa6e1) Thanks [@The-Best-Codes](https://github.com/The-Best-Codes)! - Add support for Ollama as a CLI and Compiler provider. + +### Patch Changes + +- [#922](https://github.com/lingodotdev/lingo.dev/pull/922) [`0329a9c`](https://github.com/lingodotdev/lingo.dev/commit/0329a9cdb5e5a63fcecab4efcd7cce22f155a0e9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add openrouter ais support for compiler + +- Updated dependencies [[`215af19`](https://github.com/lingodotdev/lingo.dev/commit/215af1944667cce66e9c5966f4fb627186687b74), [`1b9b113`](https://github.com/lingodotdev/lingo.dev/commit/1b9b11301978e8caa2555832d027ff93216aa6e1), [`95c23cc`](https://github.com/lingodotdev/lingo.dev/commit/95c23ccbafd335939832dbdd0f995ebcb23082fd), [`0329a9c`](https://github.com/lingodotdev/lingo.dev/commit/0329a9cdb5e5a63fcecab4efcd7cce22f155a0e9)]: + - @lingo.dev/_compiler@0.3.0 + - @lingo.dev/_spec@0.36.0 + - @lingo.dev/_react@0.4.0 + - @lingo.dev/_sdk@0.9.1 + +## 0.98.0 + +### Minor Changes + +- [#915](https://github.com/lingodotdev/lingo.dev/pull/915) [`6b4b9e6`](https://github.com/lingodotdev/lingo.dev/commit/6b4b9e6cc9a0cb5da8a4df9e9ebda474bf2a18ed) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - feat: enhance 5xx error handling with Cloudflare status integration + +- [#915](https://github.com/lingodotdev/lingo.dev/pull/915) [`6b4b9e6`](https://github.com/lingodotdev/lingo.dev/commit/6b4b9e6cc9a0cb5da8a4df9e9ebda474bf2a18ed) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - feat: enhance 5xx error handling with Cloudflare status integration + +### Patch Changes + +- [#919](https://github.com/lingodotdev/lingo.dev/pull/919) [`3b6574f`](https://github.com/lingodotdev/lingo.dev/commit/3b6574f0499f3f4d3c48f66ba2b828d2c1c0ceb0) Thanks [@mathio](https://github.com/mathio)! - update package import names + +- Updated dependencies [[`3b6574f`](https://github.com/lingodotdev/lingo.dev/commit/3b6574f0499f3f4d3c48f66ba2b828d2c1c0ceb0), [`6b4b9e6`](https://github.com/lingodotdev/lingo.dev/commit/6b4b9e6cc9a0cb5da8a4df9e9ebda474bf2a18ed), [`6b4b9e6`](https://github.com/lingodotdev/lingo.dev/commit/6b4b9e6cc9a0cb5da8a4df9e9ebda474bf2a18ed)]: + - @lingo.dev/_compiler@0.2.4 + - @lingo.dev/_sdk@0.9.0 + +## 0.97.5 + +### Patch Changes + +- Updated dependencies [[`d7e74c6`](https://github.com/lingodotdev/lingo.dev/commit/d7e74c6cc724da8ae759ba8d8fdb1a64867d505c)]: + - @lingo.dev/_compiler@0.2.3 + +## 0.97.4 + +### Patch Changes + +- [`2dd8170`](https://github.com/lingodotdev/lingo.dev/commit/2dd8170ff0101268f2253c9248409d184da5f75c) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - relative paths + new locales + +## 0.97.3 + +### Patch Changes + +- Updated dependencies [[`1a235a1`](https://github.com/lingodotdev/lingo.dev/commit/1a235a17455fb2631f7426283aa8431209999758)]: + - @lingo.dev/_compiler@0.2.2 + +## 0.97.2 + +### Patch Changes + +- [#903](https://github.com/lingodotdev/lingo.dev/pull/903) [`cc232eb`](https://github.com/lingodotdev/lingo.dev/commit/cc232eb72d0e54b3571bbb70e88cdad24ba6372a) Thanks [@mathio](https://github.com/mathio)! - vtt parsing + +## 0.97.1 + +### Patch Changes + +- [#900](https://github.com/lingodotdev/lingo.dev/pull/900) [`fead8e0`](https://github.com/lingodotdev/lingo.dev/commit/fead8e08dc2b2869a093cb25a04f6e0aa78cf6b7) Thanks [@mathio](https://github.com/mathio)! - load API key from env var and env files + +- Updated dependencies [[`fead8e0`](https://github.com/lingodotdev/lingo.dev/commit/fead8e08dc2b2869a093cb25a04f6e0aa78cf6b7)]: + - @lingo.dev/_compiler@0.2.1 + +## 0.97.0 + +### Minor Changes + +- [#897](https://github.com/lingodotdev/lingo.dev/pull/897) [`a5da697`](https://github.com/lingodotdev/lingo.dev/commit/a5da697f7efd46de31d17b202d06eb5f655ed9b9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for other providers in the compiler and implement Google AI as a provider. + +### Patch Changes + +- [#899](https://github.com/lingodotdev/lingo.dev/pull/899) [`10a0139`](https://github.com/lingodotdev/lingo.dev/commit/10a0139edc9ffbc1c52ac2226f6b0f345cc19878) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add support for --key to `lingo.dev run` + +- Updated dependencies [[`a5da697`](https://github.com/lingodotdev/lingo.dev/commit/a5da697f7efd46de31d17b202d06eb5f655ed9b9)]: + - @lingo.dev/_compiler@0.2.0 + - @lingo.dev/_react@0.3.0 + - @lingo.dev/_spec@0.35.0 + - @lingo.dev/_sdk@0.8.1 + +## 0.96.0 + +### Minor Changes + +- [#895](https://github.com/lingodotdev/lingo.dev/pull/895) [`3bd4045`](https://github.com/lingodotdev/lingo.dev/commit/3bd40450cbb5c8aabce61d7f1f3ab9c7293323d9) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add PostHog tracking to run command + +## 0.95.0 + +### Minor Changes + +- [#874](https://github.com/lingodotdev/lingo.dev/pull/874) [`f140f82`](https://github.com/lingodotdev/lingo.dev/commit/f140f820d00b15f99214a7eece1a9c7f0d098e90) Thanks [@NamesMT](https://github.com/NamesMT)! - Reduce duplicated parsing and more dynamic key column naming support + +## 0.94.6 + +### Patch Changes + +- [#890](https://github.com/lingodotdev/lingo.dev/pull/890) [`145fb74`](https://github.com/lingodotdev/lingo.dev/commit/145fb74c09b42c8810f351be5a641b1366881ae1) Thanks [@mathio](https://github.com/mathio)! - do not parse LingoProvider component + +- [#889](https://github.com/lingodotdev/lingo.dev/pull/889) [`0c45acc`](https://github.com/lingodotdev/lingo.dev/commit/0c45accfc45e63f597758c47033bc58d2f6059b5) Thanks [@mathio](https://github.com/mathio)! - update Groq API error handling + +- Updated dependencies [[`145fb74`](https://github.com/lingodotdev/lingo.dev/commit/145fb74c09b42c8810f351be5a641b1366881ae1), [`0c45acc`](https://github.com/lingodotdev/lingo.dev/commit/0c45accfc45e63f597758c47033bc58d2f6059b5)]: + - @lingo.dev/_compiler@0.1.13 + +## 0.94.5 + +### Patch Changes + +- [#887](https://github.com/lingodotdev/lingo.dev/pull/887) [`511a2ec`](https://github.com/lingodotdev/lingo.dev/commit/511a2ecd68a9c5e2800035d5c6a6b5b31b2dc80f) Thanks [@mathio](https://github.com/mathio)! - handle when lingo dir is deleted + +- Updated dependencies [[`511a2ec`](https://github.com/lingodotdev/lingo.dev/commit/511a2ecd68a9c5e2800035d5c6a6b5b31b2dc80f)]: + - @lingo.dev/_compiler@0.1.12 + - @lingo.dev/_react@0.2.4 + +## 0.94.4 + +### Patch Changes + +- [#883](https://github.com/lingodotdev/lingo.dev/pull/883) [`7191444`](https://github.com/lingodotdev/lingo.dev/commit/7191444f67864ea5b5a91a9be759b2445bf186d3) Thanks [@mathio](https://github.com/mathio)! - client-side loading state + +- Updated dependencies [[`7191444`](https://github.com/lingodotdev/lingo.dev/commit/7191444f67864ea5b5a91a9be759b2445bf186d3)]: + - @lingo.dev/_react@0.2.3 + - @lingo.dev/_compiler@0.1.11 + +## 0.94.3 + +### Patch Changes + +- Updated dependencies [[`152e96a`](https://github.com/lingodotdev/lingo.dev/commit/152e96a46b98dd25d558ff0e7e20b18b954d375a)]: + - @lingo.dev/_compiler@0.1.10 + +## 0.94.2 + +### Patch Changes + +- [#872](https://github.com/lingodotdev/lingo.dev/pull/872) [`af011b1`](https://github.com/lingodotdev/lingo.dev/commit/af011b18fe96f15287609278f4d4d2b343b6c2cc) Thanks [@NamesMT](https://github.com/NamesMT)! - Allows user to type even less and benefit from lingo.dev <3 + +## 0.94.1 + +### Patch Changes + +- Updated dependencies [[`a7bf553`](https://github.com/lingodotdev/lingo.dev/commit/a7bf5538b5b72e41f90371f6211378aac7d5f800), [`562e667`](https://github.com/lingodotdev/lingo.dev/commit/562e667471abb51d7dd193217eefb8e8b3f8a686)]: + - @lingo.dev/_react@0.2.2 + +## 0.94.0 + +### Minor Changes + +- [#864](https://github.com/lingodotdev/lingo.dev/pull/864) [`3750c9c`](https://github.com/lingodotdev/lingo.dev/commit/3750c9ca25a78280b04e4a2b2e6641dd21f9f3b0) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - feat(cli): add `login` and `logout` commands to replace `auth --login` and `auth --logout` + +### Patch Changes + +- Updated dependencies [[`77461a7`](https://github.com/lingodotdev/lingo.dev/commit/77461a7872eec3ea188b3ca6c6f7ce1fd13fdfbb)]: + - @lingo.dev/_compiler@0.1.9 + +## 0.93.13 + +### Patch Changes + +- Updated dependencies [[`1bccb7e`](https://github.com/lingodotdev/lingo.dev/commit/1bccb7ed51ac1f13ea79e618bbee551d5529efdc)]: + - @lingo.dev/_compiler@0.1.8 + +## 0.93.12 + +### Patch Changes + +- Updated dependencies [[`5b68641`](https://github.com/lingodotdev/lingo.dev/commit/5b686414f363f8ee4b79fd4e804a434db5cfcb36)]: + - @lingo.dev/_compiler@0.1.7 + +## 0.93.11 + +### Patch Changes + +- Updated dependencies [[`1f9db11`](https://github.com/lingodotdev/lingo.dev/commit/1f9db11a53d8c75ce0e83517b73d43544d0f0fd2)]: + - @lingo.dev/_react@0.2.1 + +## 0.93.10 + +### Patch Changes + +- Updated dependencies [[`7a5898b`](https://github.com/lingodotdev/lingo.dev/commit/7a5898b12dcd0015a5e57236bf65172cedb8a6ee)]: + - @lingo.dev/_compiler@0.1.6 + +## 0.93.9 + +### Patch Changes + +- Updated dependencies [[`7013b53`](https://github.com/lingodotdev/lingo.dev/commit/7013b5300d6c2c26f39da62b5ad2c7cf11158c74)]: + - @lingo.dev/_compiler@0.1.5 + +## 0.93.8 + +### Patch Changes + +- [#853](https://github.com/lingodotdev/lingo.dev/pull/853) [`cb7d5e2`](https://github.com/lingodotdev/lingo.dev/commit/cb7d5e213282c00af658159472183a763f84ca3d) Thanks [@vrcprl](https://github.com/vrcprl)! - Fix groq api key retrieval from .env + +- Updated dependencies [[`cb7d5e2`](https://github.com/lingodotdev/lingo.dev/commit/cb7d5e213282c00af658159472183a763f84ca3d)]: + - @lingo.dev/_compiler@0.1.4 + +## 0.93.7 + +### Patch Changes + +- [`5d27455`](https://github.com/lingodotdev/lingo.dev/commit/5d2745545044cbaddb099f7920c96fe198879ba3) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add boolean parsing + +## 0.93.6 + +### Patch Changes + +- [#843](https://github.com/lingodotdev/lingo.dev/pull/843) [`b67a331`](https://github.com/lingodotdev/lingo.dev/commit/b67a33141253fa755b5531e52cd690bf5824d4b6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - explicit source / targets in run cmd, parallel mode for ci/cd + +## 0.93.5 + +### Patch Changes + +- Updated dependencies [[`e75e615`](https://github.com/lingodotdev/lingo.dev/commit/e75e615ab17e279deb5a505dbda682fdfc7ead62)]: + - @lingo.dev/_react@0.2.0 + +## 0.93.4 + +### Patch Changes + +- [`f42cff8`](https://github.com/lingodotdev/lingo.dev/commit/f42cff8355b1ff7bba1445bd04d11ee4672903c2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - flat reexports + +- Updated dependencies [[`f42cff8`](https://github.com/lingodotdev/lingo.dev/commit/f42cff8355b1ff7bba1445bd04d11ee4672903c2)]: + - @lingo.dev/_compiler@0.1.3 + +## 0.93.3 + +### Patch Changes + +- [`920e3f5`](https://github.com/lingodotdev/lingo.dev/commit/920e3f5c3ca1fd51b0919db13a4787cfd616de54) Thanks [@mathio](https://github.com/mathio)! - remove cloneDeep for optimization + +- Updated dependencies [[`920e3f5`](https://github.com/lingodotdev/lingo.dev/commit/920e3f5c3ca1fd51b0919db13a4787cfd616de54)]: + - @lingo.dev/_compiler@0.1.2 + +## 0.93.2 + +### Patch Changes + +- [`cdb59dd`](https://github.com/lingodotdev/lingo.dev/commit/cdb59dddcd14da1ba3181a33c4c119af877cb4f3) Thanks [@mathio](https://github.com/mathio)! - update deps + +## 0.93.1 + +### Patch Changes + +- [`caef325`](https://github.com/lingodotdev/lingo.dev/commit/caef3253bc99fa7bf7a0b40e5604c3590dcb4958) Thanks [@mathio](https://github.com/mathio)! - release fix + +- Updated dependencies [[`caef325`](https://github.com/lingodotdev/lingo.dev/commit/caef3253bc99fa7bf7a0b40e5604c3590dcb4958)]: + - @lingo.dev/_compiler@0.1.1 + - @lingo.dev/_react@0.1.1 + +## 0.93.0 + +### Minor Changes + +- [`e980e84`](https://github.com/lingodotdev/lingo.dev/commit/e980e84178439ad70417d38b425acf9148cfc4b6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added the compiler + +### Patch Changes + +- Updated dependencies [[`e980e84`](https://github.com/lingodotdev/lingo.dev/commit/e980e84178439ad70417d38b425acf9148cfc4b6)]: + - @lingo.dev/_compiler@0.1.0 + - @lingo.dev/_react@0.1.0 + - @lingo.dev/_sdk@0.8.0 + - @lingo.dev/_spec@0.34.0 + +## 0.92.19 + +### Patch Changes + +- [#828](https://github.com/lingodotdev/lingo.dev/pull/828) [`4e9734c`](https://github.com/lingodotdev/lingo.dev/commit/4e9734ce32749caa95703d2b96ba8af6cc83ef94) Thanks [@mathio](https://github.com/mathio)! - preserve order of comments in po files + +## 0.92.18 + +### Patch Changes + +- [#824](https://github.com/lingodotdev/lingo.dev/pull/824) [`69d9568`](https://github.com/lingodotdev/lingo.dev/commit/69d9568e85f4f56de3b300b4dd5973bd9c410b99) Thanks [@mathio](https://github.com/mathio)! - skip obsolete entries in po files + +## 0.92.17 + +### Patch Changes + +- [#816](https://github.com/lingodotdev/lingo.dev/pull/816) [`28a19e6`](https://github.com/lingodotdev/lingo.dev/commit/28a19e686bc13788b10fd7d9fa6769a34f86d523) Thanks [@vrcprl](https://github.com/vrcprl)! - add config get/set/unset commands + +## 0.92.16 + +### Patch Changes + +- [#806](https://github.com/lingodotdev/lingo.dev/pull/806) [`a146328`](https://github.com/lingodotdev/lingo.dev/commit/a1463289697a83ce704cff793c8840db6fa47619) Thanks [@vrcprl](https://github.com/vrcprl)! - fix variables order in po / xcode-xcstrings + +- Updated dependencies [[`0272fbf`](https://github.com/lingodotdev/lingo.dev/commit/0272fbf8847240ed9453130237d5843b918f869f)]: + - @lingo.dev/_spec@0.33.3 + - @lingo.dev/_sdk@0.7.43 + +## 0.92.15 + +### Patch Changes + +- [#798](https://github.com/lingodotdev/lingo.dev/pull/798) [`48e4f00`](https://github.com/lingodotdev/lingo.dev/commit/48e4f0052b85f0d3575e83390ef82647036c1aec) Thanks [@pushkar1713](https://github.com/pushkar1713)! - added jsonrepair and trimming in explicit.ts so there should be no error now incase LLM's provide a malformed response. + +- [#803](https://github.com/lingodotdev/lingo.dev/pull/803) [`f5657a9`](https://github.com/lingodotdev/lingo.dev/commit/f5657a9fae0924c7f34165bcfaa609d8740557d7) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fixed results counting logic in the summary step + +## 0.92.14 + +### Patch Changes + +- [#801](https://github.com/lingodotdev/lingo.dev/pull/801) [`3764be3`](https://github.com/lingodotdev/lingo.dev/commit/3764be3b23ee8af474ebc4751e137102af315e5e) Thanks [@vrcprl](https://github.com/vrcprl)! - add chunking to cli with byok model + +## 0.92.13 + +### Patch Changes + +- [#799](https://github.com/lingodotdev/lingo.dev/pull/799) [`6edc1d6`](https://github.com/lingodotdev/lingo.dev/commit/6edc1d6a10f8917a1f9e5a7c43a24acb4ab50116) Thanks [@vrcprl](https://github.com/vrcprl)! - fix numeric keys during key renaming step + +## 0.92.12 + +### Patch Changes + +- [#792](https://github.com/lingodotdev/lingo.dev/pull/792) [`2bce8de`](https://github.com/lingodotdev/lingo.dev/commit/2bce8deabd06b413b8f284ca102fd0669aa8aaf3) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix variables ordering mismatch + +## 0.92.11 + +### Patch Changes + +- [#787](https://github.com/lingodotdev/lingo.dev/pull/787) [`3c27920`](https://github.com/lingodotdev/lingo.dev/commit/3c27920843b37adc71f08a49aa6c0d482decea86) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix byok params + +## 0.92.10 + +### Patch Changes + +- [#785](https://github.com/lingodotdev/lingo.dev/pull/785) [`af1315a`](https://github.com/lingodotdev/lingo.dev/commit/af1315a3fd1f3247dd56f7a8d5d7101debd43a98) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - race condition in lingo.dev run + +## 0.92.9 + +### Patch Changes + +- [#782](https://github.com/lingodotdev/lingo.dev/pull/782) [`d913c20`](https://github.com/lingodotdev/lingo.dev/commit/d913c20fdf0086741c8b50fd4ddfb38eae304a24) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - parallel processing + +- Updated dependencies [[`d913c20`](https://github.com/lingodotdev/lingo.dev/commit/d913c20fdf0086741c8b50fd4ddfb38eae304a24)]: + - @lingo.dev/_spec@0.33.2 + - @lingo.dev/_sdk@0.7.42 + +## 0.92.8 + +### Patch Changes + +- [#778](https://github.com/lingodotdev/lingo.dev/pull/778) [`3f2aba9`](https://github.com/lingodotdev/lingo.dev/commit/3f2aba9c1d5834faf89a26194f1f3d9f9b878d40) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add ignoredKeys + +- Updated dependencies [[`3f2aba9`](https://github.com/lingodotdev/lingo.dev/commit/3f2aba9c1d5834faf89a26194f1f3d9f9b878d40)]: + - @lingo.dev/_spec@0.33.1 + - @lingo.dev/_sdk@0.7.41 + +## 0.92.7 + +### Patch Changes + +- [#772](https://github.com/lingodotdev/lingo.dev/pull/772) [`f859352`](https://github.com/lingodotdev/lingo.dev/commit/f859352a8d573bb0cff7a79790e5bb94ee8d16a3) Thanks [@vrcprl](https://github.com/vrcprl)! - fix error tracking + +## 0.92.6 + +### Patch Changes + +- [#775](https://github.com/lingodotdev/lingo.dev/pull/775) [`f2e416a`](https://github.com/lingodotdev/lingo.dev/commit/f2e416a02456f7e35dfa81822d319911202e6b43) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - template strings support for ts loader + +## 0.92.5 + +### Patch Changes + +- [#770](https://github.com/lingodotdev/lingo.dev/pull/770) [`d25eb8c`](https://github.com/lingodotdev/lingo.dev/commit/d25eb8cb6be5e3b24a4940651776f23bdc84ed56) Thanks [@vrcprl](https://github.com/vrcprl)! - upd package + +## 0.92.4 + +### Patch Changes + +- [#766](https://github.com/lingodotdev/lingo.dev/pull/766) [`bfc2b7e`](https://github.com/lingodotdev/lingo.dev/commit/bfc2b7e395ddfe01a31dfa193e94726c1d682826) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Skip lingo.dev authentication when in "Bring Your Own Key" mode + +- [#768](https://github.com/lingodotdev/lingo.dev/pull/768) [`fcdf04e`](https://github.com/lingodotdev/lingo.dev/commit/fcdf04eb111c06ad24bcb1a22e66db442b6a2bc7) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix nested typescript support + +## 0.92.3 + +### Patch Changes + +- [#764](https://github.com/lingodotdev/lingo.dev/pull/764) [`a45a11a`](https://github.com/lingodotdev/lingo.dev/commit/a45a11a7323baa6a5f119b9b0913da7a910a324b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix babel imports in ts loader + +## 0.92.2 + +### Patch Changes + +- [`8539842`](https://github.com/lingodotdev/lingo.dev/commit/8539842f878a29572c6ed8b6d077e51a247b28d0) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix missing deps + +## 0.92.1 + +### Patch Changes + +- [`2977542`](https://github.com/lingodotdev/lingo.dev/commit/29775423c728c7b0146c0c62f167806d85f2d5c6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix babel traverse import + +## 0.92.0 + +### Minor Changes + +- [#759](https://github.com/lingodotdev/lingo.dev/pull/759) [`9aa7004`](https://github.com/lingodotdev/lingo.dev/commit/9aa700491446865dc131b80419f681132b888652) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Enhance TypeScript loader to support nested fields and arrays + +### Patch Changes + +- Updated dependencies [[`9aa7004`](https://github.com/lingodotdev/lingo.dev/commit/9aa700491446865dc131b80419f681132b888652)]: + - @lingo.dev/_spec@0.33.0 + - @lingo.dev/_sdk@0.7.40 + +## 0.91.0 + +### Minor Changes + +- [#757](https://github.com/lingodotdev/lingo.dev/pull/757) [`5170449`](https://github.com/lingodotdev/lingo.dev/commit/517044905dfc682d6a5fa95b0605b8715e2b72c7) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add TypeScript loader for .ts files that extracts string literals from default exports + +### Patch Changes + +- Updated dependencies [[`5170449`](https://github.com/lingodotdev/lingo.dev/commit/517044905dfc682d6a5fa95b0605b8715e2b72c7)]: + - @lingo.dev/_spec@0.32.0 + - @lingo.dev/_sdk@0.7.39 + +## 0.90.4 + +### Patch Changes + +- [#755](https://github.com/lingodotdev/lingo.dev/pull/755) [`3ad5974`](https://github.com/lingodotdev/lingo.dev/commit/3ad597416b2b39daf53abce2a3d6d255e07b4a2e) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Fix extra escaping issue in Android loader for Dutch strings + +## 0.90.3 + +### Patch Changes + +- [`fa87f8b`](https://github.com/lingodotdev/lingo.dev/commit/fa87f8b959305b080c964634cb94d81fea1c8caf) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix android single quotes + add tests + +- [`3e6c0d6`](https://github.com/lingodotdev/lingo.dev/commit/3e6c0d68b440604100936130bea5e47098418040) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix boolean flags in ci cmd + +## 0.90.2 + +### Patch Changes + +- [`8443a9e`](https://github.com/lingodotdev/lingo.dev/commit/8443a9e83bbbb33f20eff1b3bf5f107e5bd18b7d) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - easter egg ;) + +## 0.90.1 + +### Patch Changes + +- [#739](https://github.com/lingodotdev/lingo.dev/pull/739) [`bee8861`](https://github.com/lingodotdev/lingo.dev/commit/bee8861f4725344f8157f264d3c5a80870ec9ba2) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add hidden "may-the-fourth" command for Star Wars Day easter egg + +## 0.90.0 + +### Minor Changes + +- [#708](https://github.com/lingodotdev/lingo.dev/pull/708) [`ab585d5`](https://github.com/lingodotdev/lingo.dev/commit/ab585d5331c668f88c95cf192e3877368213257e) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add comprehensive Android loader implementation with support for various edge cases including HTML markup, CDATA sections, format strings, and special character escaping. + +### Patch Changes + +- [`2b9b6c6`](https://github.com/lingodotdev/lingo.dev/commit/2b9b6c63d6594690119c534540cc9d305da2cdd5) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - show config in ci cmd + +## 0.89.6 + +### Patch Changes + +- [#717](https://github.com/lingodotdev/lingo.dev/pull/717) [`437d5a1`](https://github.com/lingodotdev/lingo.dev/commit/437d5a1c07f702d0f7a37ae916f27ec9055a9d01) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - fix: prevent truncation of commit message and PR title by properly escaping special characters in shell commands + +## 0.89.5 + +### Patch Changes + +- [`e93f405`](https://github.com/lingodotdev/lingo.dev/commit/e93f405a06e026bc6a4f71f534af615970cefdda) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add --no-verify to ci commits + +## 0.89.4 + +### Patch Changes + +- [`90c8334`](https://github.com/lingodotdev/lingo.dev/commit/90c83344087a712f238c69b756f86dbab0a3c1e9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add missing --api-key option to ci + +## 0.89.3 + +### Patch Changes + +- [`4b080b9`](https://github.com/lingodotdev/lingo.dev/commit/4b080b9e89ec858d4638ef73a96599721e7e90ce) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - upd deps + +## 0.89.2 + +### Patch Changes + +- [`14edaed`](https://github.com/lingodotdev/lingo.dev/commit/14edaed1b3a4d3020e3358aaecc6aae2825a1886) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix o11y + +## 0.89.1 + +### Patch Changes + +- [`5ce634d`](https://github.com/lingodotdev/lingo.dev/commit/5ce634d3d54701868365d384de90bef51f432fa5) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - reuse i18n inside ci cmd + +## 0.89.0 + +### Minor Changes + +- [`c3171c4`](https://github.com/lingodotdev/lingo.dev/commit/c3171c412bb2efab38f311ebb1c740b3cd225c32) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add `ci` cmd + +### Patch Changes + +- [#709](https://github.com/lingodotdev/lingo.dev/pull/709) [`3fa1c35`](https://github.com/lingodotdev/lingo.dev/commit/3fa1c35b52a521fe2cfd0155ffc8cae6961a4066) Thanks [@vrcprl](https://github.com/vrcprl)! - Fix error tracking in PostHog by properly serializing error objects + +## 0.88.0 + +### Minor Changes + +- [#700](https://github.com/lingodotdev/lingo.dev/pull/700) [`c5ccf81`](https://github.com/lingodotdev/lingo.dev/commit/c5ccf81e9c2bd27bae332306da2a41e41bbeb87d) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add support for locked patterns in MDX loader + + This change adds support for preserving specific patterns in MDX files during translation, including: + - !params syntax for parameter documentation + - !! parameter_name headings + - !type declarations + - !required flags + - !values lists + + The implementation adds a new config version 1.7 with a "lockedPatterns" field that accepts an array of regex patterns to be preserved during translation. + +### Patch Changes + +- [#704](https://github.com/lingodotdev/lingo.dev/pull/704) [`f78bd68`](https://github.com/lingodotdev/lingo.dev/commit/f78bd6862b85d10c3f26542f55614dbc301ac90a) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Fix image regex in MDX2 loader to handle URLs with parentheses + +- [#696](https://github.com/lingodotdev/lingo.dev/pull/696) [`b8c73cb`](https://github.com/lingodotdev/lingo.dev/commit/b8c73cb947f8c445e3515f8c23b3b607e5ea38c2) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Fix PostHog import in CLI to support both ESM and CommonJS environments + +- Updated dependencies [[`c5ccf81`](https://github.com/lingodotdev/lingo.dev/commit/c5ccf81e9c2bd27bae332306da2a41e41bbeb87d)]: + - @lingo.dev/_spec@0.31.0 + - @lingo.dev/_sdk@0.7.38 + +## 0.87.15 + +### Patch Changes + +- [#685](https://github.com/lingodotdev/lingo.dev/pull/685) [`15b448f`](https://github.com/lingodotdev/lingo.dev/commit/15b448ff79bb58f021619fcb460837b353007609) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Fix npm package readme by making packages/cli/README.md the source of truth and creating symlinks from root readme.md and readme/en.md + +## 0.87.14 + +### Patch Changes + +- [#680](https://github.com/lingodotdev/lingo.dev/pull/680) [`b1c397b`](https://github.com/lingodotdev/lingo.dev/commit/b1c397bcd117b2ba2eea5edd713f9e3b0d4d71d5) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - split images into sections + +- [#680](https://github.com/lingodotdev/lingo.dev/pull/680) [`b1c397b`](https://github.com/lingodotdev/lingo.dev/commit/b1c397bcd117b2ba2eea5edd713f9e3b0d4d71d5) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - dates in mdx + +## 0.87.13 + +### Patch Changes + +- [#678](https://github.com/lingodotdev/lingo.dev/pull/678) + [`0e3916c`](https://github.com/lingodotdev/lingo.dev/commit/0e3916c4817cd0bc77f426aa66c97df61c6617bf) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - dates in mdx + +## 0.87.12 + +### Patch Changes + +- [#675](https://github.com/lingodotdev/lingo.dev/pull/675) + [`99d9901`](https://github.com/lingodotdev/lingo.dev/commit/99d99013dfcfd92ad35baf94801ecee22041ae42) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - avoid msg id + fallbacks in .po files + +- [#677](https://github.com/lingodotdev/lingo.dev/pull/677) + [`bb2be5f`](https://github.com/lingodotdev/lingo.dev/commit/bb2be5faba6a9e26000293a55185376eff4ebc22) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - reorder prettier + +## 0.87.11 + +### Patch Changes + +- [`c0d801e`](https://github.com/lingodotdev/lingo.dev/commit/c0d801e6b7efa2ec5115f27e5b8726704a5e5f99) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - retain custom + inlinde code values + +## 0.87.10 + +### Patch Changes + +- [`1b17491`](https://github.com/lingodotdev/lingo.dev/commit/1b17491bdea7705858c13f84e2188cd37ad7b212) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - inline code + placeholder format + +## 0.87.9 + +### Patch Changes + +- [`ab9f883`](https://github.com/lingodotdev/lingo.dev/commit/ab9f883079a111f62dde522ebe23171db9b7949e) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - inline code + placeholders + +## 0.87.8 + +### Patch Changes + +- [`f4cf34e`](https://github.com/lingodotdev/lingo.dev/commit/f4cf34eaa63150331dded008a1d819e8b3b960dc) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add handling for + identical unspaces fences replacement + +- [`0d4142a`](https://github.com/lingodotdev/lingo.dev/commit/0d4142a2a7c92f6a04cfe30d64f967a6b8d8744d) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - quoted fences + +## 0.87.7 + +### Patch Changes + +- [`c4f6b0b`](https://github.com/lingodotdev/lingo.dev/commit/c4f6b0bdbd913195f0c2133584d3e77b55467c7d) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - trim fences' + newlines when in quote mode + +## 0.87.6 + +### Patch Changes + +- [`71bd89a`](https://github.com/lingodotdev/lingo.dev/commit/71bd89a3a074ecfb3cc1df4ec06529b9b04d2cfb) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - multiline fences + in quotes and jsx + +## 0.87.5 + +### Patch Changes + +- [`dfefe32`](https://github.com/lingodotdev/lingo.dev/commit/dfefe3228683347beb9976e6e61632c65d68140c) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fences after jsx + block + +## 0.87.4 + +### Patch Changes + +- [`12afc85`](https://github.com/lingodotdev/lingo.dev/commit/12afc85dc1a9abdfd11eb9fa41fb574863cde176) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - support for + quoted and broken code blocks + +## 0.87.3 + +### Patch Changes + +- [`dcb119c`](https://github.com/lingodotdev/lingo.dev/commit/dcb119c0ec3cc22f5954a09607f89de5a9978732) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - extra slash n for + code fences + +## 0.87.2 + +### Patch Changes + +- [`e4c9e4f`](https://github.com/lingodotdev/lingo.dev/commit/e4c9e4f1264348ed842e341b6009b10ac5ae84ab) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add safety fence + decoration with newlines + +## 0.87.1 + +### Patch Changes + +- [`a241343`](https://github.com/lingodotdev/lingo.dev/commit/a241343caf7ee326d4fcb6fc0d00b5f07350668b) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - mdx improvements + +- [`c0ee61c`](https://github.com/lingodotdev/lingo.dev/commit/c0ee61cb482253f3c0c1a2701b1124e445a6c253) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - mdx placeholder + replacement + +## 0.87.0 + +### Minor Changes + +- [#659](https://github.com/lingodotdev/lingo.dev/pull/659) + [`4c1e20e`](https://github.com/lingodotdev/lingo.dev/commit/4c1e20e01af79ebc1fba6a3bdb8989494ee71b8c) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - feat: mdx + +## 0.86.0 + +### Minor Changes + +- [#656](https://github.com/lingodotdev/lingo.dev/pull/656) + [`915a0f5`](https://github.com/lingodotdev/lingo.dev/commit/915a0f5d8b74996f2b26dd01ac9c431c85a95d85) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - code formatting + support for advanced mdx + +## 0.85.7 + +### Patch Changes + +- [`a502caf`](https://github.com/lingodotdev/lingo.dev/commit/a502caf8680f02e769c819badd08ddb8b731d261) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - metadata + transfers for .po + +## 0.85.6 + +### Patch Changes + +- [#651](https://github.com/lingodotdev/lingo.dev/pull/651) + [`a6133f4`](https://github.com/lingodotdev/lingo.dev/commit/a6133f4074cce1ffd9e42b73efb5213e1fe6f76a) + Thanks [@mathio](https://github.com/mathio)! - --file option + +## 0.85.5 + +### Patch Changes + +- [#649](https://github.com/lingodotdev/lingo.dev/pull/649) + [`409018d`](https://github.com/lingodotdev/lingo.dev/commit/409018de74614a1fd99363c6749b0e4be9e1a278) + Thanks [@mathio](https://github.com/mathio)! - refactor dependencies + +- Updated dependencies + [[`409018d`](https://github.com/lingodotdev/lingo.dev/commit/409018de74614a1fd99363c6749b0e4be9e1a278)]: + - @lingo.dev/_spec@0.30.3 + - @lingo.dev/_sdk@0.7.37 + +## 0.85.4 + +### Patch Changes + +- [#647](https://github.com/lingodotdev/lingo.dev/pull/647) + [`235b6d9`](https://github.com/lingodotdev/lingo.dev/commit/235b6d914c5f542ee5f1a8a88085cfd9dea5409e) + Thanks [@mathio](https://github.com/mathio)! - update vitest + +- Updated dependencies + [[`235b6d9`](https://github.com/lingodotdev/lingo.dev/commit/235b6d914c5f542ee5f1a8a88085cfd9dea5409e)]: + - @lingo.dev/_spec@0.30.2 + - @lingo.dev/_sdk@0.7.36 + +## 0.85.3 + +### Patch Changes + +- [#645](https://github.com/lingodotdev/lingo.dev/pull/645) + [`d824b10`](https://github.com/lingodotdev/lingo.dev/commit/d824b106631f45fc428cf01f733aab4842b4fa81) + Thanks [@mathio](https://github.com/mathio)! - update dependencies + +- Updated dependencies + [[`d824b10`](https://github.com/lingodotdev/lingo.dev/commit/d824b106631f45fc428cf01f733aab4842b4fa81)]: + - @lingo.dev/_spec@0.30.1 + - @lingo.dev/_sdk@0.7.35 + +## 0.85.2 + +### Patch Changes + +- [#643](https://github.com/lingodotdev/lingo.dev/pull/643) + [`94ed627`](https://github.com/lingodotdev/lingo.dev/commit/94ed6277f08ba60b43ada1825708538860a932dd) + Thanks [@mathio](https://github.com/mathio)! - update dependencies + +## 0.85.1 + +### Patch Changes + +- [`8da8bd0`](https://github.com/lingodotdev/lingo.dev/commit/8da8bd0039729876fdedc43d991908ccbc9a3a85) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - upd usage table + +## 0.85.0 + +### Minor Changes + +- [`486f2d2`](https://github.com/lingodotdev/lingo.dev/commit/486f2d2fec021b0e9403277da5fec7ca510047c3) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add status + command + +## 0.84.0 + +### Minor Changes + +- [#631](https://github.com/lingodotdev/lingo.dev/pull/631) + [`82efe61`](https://github.com/lingodotdev/lingo.dev/commit/82efe6176db12cc7c5bbeb84f38bc3261f9eec4f) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - double formatting + for mdx + +- [#631](https://github.com/lingodotdev/lingo.dev/pull/631) + [`82efe61`](https://github.com/lingodotdev/lingo.dev/commit/82efe6176db12cc7c5bbeb84f38bc3261f9eec4f) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - advanced mdx + support (shout out to @ZYJLiu!) + +### Patch Changes + +- Updated dependencies + [[`82efe61`](https://github.com/lingodotdev/lingo.dev/commit/82efe6176db12cc7c5bbeb84f38bc3261f9eec4f), + [`82efe61`](https://github.com/lingodotdev/lingo.dev/commit/82efe6176db12cc7c5bbeb84f38bc3261f9eec4f)]: + - @lingo.dev/_spec@0.30.0 + - @lingo.dev/_sdk@0.7.34 + +## 0.83.0 + +### Minor Changes + +- [#629](https://github.com/lingodotdev/lingo.dev/pull/629) + [`58f3959`](https://github.com/lingodotdev/lingo.dev/commit/58f39599b3b765ad807e725b4089a5e9b11a01b2) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - advanced mdx + support (shout out to @ZYJLiu!) + +### Patch Changes + +- Updated dependencies + [[`58f3959`](https://github.com/lingodotdev/lingo.dev/commit/58f39599b3b765ad807e725b4089a5e9b11a01b2)]: + - @lingo.dev/_spec@0.29.0 + - @lingo.dev/_sdk@0.7.33 + +## 0.82.1 + +### Patch Changes + +- [`fd2fd5c`](https://github.com/lingodotdev/lingo.dev/commit/fd2fd5cec4ebf8467b4fd8df9dc2892a3f0249f0) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add startsWith + for locked keys + +## 0.82.0 + +### Minor Changes + +- [#627](https://github.com/lingodotdev/lingo.dev/pull/627) + [`fe922a4`](https://github.com/lingodotdev/lingo.dev/commit/fe922a469c2d5dac23a909a4fb67a6efd56d80d6) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add support for + json/yaml key locking + +### Patch Changes + +- [`e8ea955`](https://github.com/lingodotdev/lingo.dev/commit/e8ea95551c8a3b16afe078554ebcb1d79ce817cf) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - gracefully exit + on o11y errors + +- Updated dependencies + [[`fe922a4`](https://github.com/lingodotdev/lingo.dev/commit/fe922a469c2d5dac23a909a4fb67a6efd56d80d6)]: + - @lingo.dev/_spec@0.28.0 + - @lingo.dev/_sdk@0.7.32 + +## 0.81.0 + +### Minor Changes + +- [`ddc2b7b`](https://github.com/lingodotdev/lingo.dev/commit/ddc2b7b3513d6118245bd01fc10c1b8563b52910) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add key rename + tracking + +## 0.80.1 + +### Patch Changes + +- [`fb450cb`](https://github.com/lingodotdev/lingo.dev/commit/fb450cb2e90dd67ec008691a03237bdeecce5807) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - upd banner + message + +## 0.80.0 + +### Minor Changes + +- [#614](https://github.com/lingodotdev/lingo.dev/pull/614) + [`2495afd`](https://github.com/lingodotdev/lingo.dev/commit/2495afd69e23700f96e19e5bbf74e393b29c2033) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add basic + translators + +### Patch Changes + +- [#616](https://github.com/lingodotdev/lingo.dev/pull/616) + [`516a79c`](https://github.com/lingodotdev/lingo.dev/commit/516a79c75501c5960ae944379f38591806ca43e2) + Thanks [@mathio](https://github.com/mathio)! - po files --frozen flag + +- Updated dependencies + [[`2495afd`](https://github.com/lingodotdev/lingo.dev/commit/2495afd69e23700f96e19e5bbf74e393b29c2033), + [`516a79c`](https://github.com/lingodotdev/lingo.dev/commit/516a79c75501c5960ae944379f38591806ca43e2), + [`2cc6114`](https://github.com/lingodotdev/lingo.dev/commit/2cc61140fccc69ab73d40c7802a2d0e018889475)]: + - @lingo.dev/_spec@0.27.0 + - @lingo.dev/_sdk@0.7.31 + +## 0.79.5 + +### Patch Changes + +- [#612](https://github.com/lingodotdev/lingo.dev/pull/612) + [`e541a12`](https://github.com/lingodotdev/lingo.dev/commit/e541a12436eeb4947caa077143c0b6e00b07e9b0) + Thanks [@mathio](https://github.com/mathio)! - inject locale + +## 0.79.4 + +### Patch Changes + +- [#610](https://github.com/lingodotdev/lingo.dev/pull/610) + [`ad05fbf`](https://github.com/lingodotdev/lingo.dev/commit/ad05fbf11b737c17a7cf2861be23ab2bf1189b52) + Thanks [@mathio](https://github.com/mathio)! - handle prettier plugins deps + +## 0.79.3 + +### Patch Changes + +- [#606](https://github.com/lingodotdev/lingo.dev/pull/606) + [`997a447`](https://github.com/lingodotdev/lingo.dev/commit/997a447c079a0554df17c6a7b25415058b017331) + Thanks [@mathio](https://github.com/mathio)! - support bun package manager + +## 0.79.2 + +### Patch Changes + +- [#601](https://github.com/lingodotdev/lingo.dev/pull/601) + [`27964ba`](https://github.com/lingodotdev/lingo.dev/commit/27964bacaf3772c573230a8967c9fc112c81d054) + Thanks [@mathio](https://github.com/mathio)! - lingo.dev ci PR flow update + +- [#605](https://github.com/lingodotdev/lingo.dev/pull/605) + [`1dbbfd2`](https://github.com/lingodotdev/lingo.dev/commit/1dbbfd2ed9f5a7e0479dc83f700fb68ee5347a18) + Thanks [@mathio](https://github.com/mathio)! - inject locale + +- Updated dependencies + [[`1dbbfd2`](https://github.com/lingodotdev/lingo.dev/commit/1dbbfd2ed9f5a7e0479dc83f700fb68ee5347a18)]: + - @lingo.dev/_spec@0.26.6 + - @lingo.dev/_sdk@0.7.30 + +## 0.79.1 + +### Patch Changes + +- [#602](https://github.com/lingodotdev/lingo.dev/pull/602) + [`6d6eded`](https://github.com/lingodotdev/lingo.dev/commit/6d6ededbbd9310b9b4ae331e520da2a1e2722e79) + Thanks [@mathio](https://github.com/mathio)! - add i18n command --file option + +## 0.79.0 + +### Minor Changes + +- [#599](https://github.com/lingodotdev/lingo.dev/pull/599) + [`81b2447`](https://github.com/lingodotdev/lingo.dev/commit/81b244746acd54f3c69e40353e8a0b8f71a5e73c) + Thanks [@mathio](https://github.com/mathio)! - add "ci" command + +## 0.78.17 + +### Patch Changes + +- [#596](https://github.com/lingodotdev/lingo.dev/pull/596) + [`61b487e`](https://github.com/lingodotdev/lingo.dev/commit/61b487e1e059328a32c3cdf673255d9d2cd480d9) + Thanks [@vrcprl](https://github.com/vrcprl)! - add new locale + +- Updated dependencies + [[`61b487e`](https://github.com/lingodotdev/lingo.dev/commit/61b487e1e059328a32c3cdf673255d9d2cd480d9)]: + - @lingo.dev/_spec@0.26.5 + - @lingo.dev/_sdk@0.7.29 + +## 0.78.16 + +### Patch Changes + +- [#595](https://github.com/lingodotdev/lingo.dev/pull/595) + [`ca73e26`](https://github.com/lingodotdev/lingo.dev/commit/ca73e269edd31a237aeebf49244798f7222b3c72) + Thanks [@mathio](https://github.com/mathio)! - gitignore logic + +- Updated dependencies + [[`743d93e`](https://github.com/lingodotdev/lingo.dev/commit/743d93e554841bbd96d23682d8aec63cb4eb3ec8)]: + - @lingo.dev/_spec@0.26.4 + - @lingo.dev/_sdk@0.7.28 + +## 0.78.15 + +### Patch Changes + +- [#574](https://github.com/lingodotdev/lingo.dev/pull/574) + [`dde7fbe`](https://github.com/lingodotdev/lingo.dev/commit/dde7fbe57fc9b1d3ce28e192b778921099354dad) + Thanks [@mathio](https://github.com/mathio)! - handle errors from i18n when + streaming + +- Updated dependencies + [[`dde7fbe`](https://github.com/lingodotdev/lingo.dev/commit/dde7fbe57fc9b1d3ce28e192b778921099354dad)]: + - @lingo.dev/_sdk@0.7.27 + +## 0.78.14 + +### Patch Changes + +- [#553](https://github.com/lingodotdev/lingo.dev/pull/553) + [`95023f2`](https://github.com/lingodotdev/lingo.dev/commit/95023f2c8da3958e8582628a22bf40674f8d2317) + Thanks [@vrcprl](https://github.com/vrcprl)! - Add new locales + +- Updated dependencies + [[`95023f2`](https://github.com/lingodotdev/lingo.dev/commit/95023f2c8da3958e8582628a22bf40674f8d2317)]: + - @lingo.dev/_spec@0.26.3 + - @lingo.dev/_sdk@0.7.26 + +## 0.78.13 + +### Patch Changes + +- [#551](https://github.com/lingodotdev/lingo.dev/pull/551) + [`30a56b6`](https://github.com/lingodotdev/lingo.dev/commit/30a56b65d3b2a0cfb32a57bfebeba0f4c014a400) + Thanks [@mathio](https://github.com/mathio)! - support prettier plugins + +## 0.78.12 + +### Patch Changes + +- [#548](https://github.com/lingodotdev/lingo.dev/pull/548) + [`d8b9f57`](https://github.com/lingodotdev/lingo.dev/commit/d8b9f57b2231262b0940a83af8cc16101209c029) + Thanks [@mathio](https://github.com/mathio)! - warn if env vars override + values from "auth --login" + +- [#550](https://github.com/lingodotdev/lingo.dev/pull/550) + [`8eea2e4`](https://github.com/lingodotdev/lingo.dev/commit/8eea2e4ac148adbecbda9794885ed5486a549037) + Thanks [@mathio](https://github.com/mathio)! - info message + +## 0.78.11 + +### Patch Changes + +- [#546](https://github.com/lingodotdev/lingo.dev/pull/546) + [`9089b08`](https://github.com/lingodotdev/lingo.dev/commit/9089b085b968ff3195866e377ecf3016aa06f959) + Thanks [@mathio](https://github.com/mathio)! - add helper method to spec + +- Updated dependencies + [[`9089b08`](https://github.com/lingodotdev/lingo.dev/commit/9089b085b968ff3195866e377ecf3016aa06f959)]: + - @lingo.dev/_spec@0.26.2 + - @lingo.dev/_sdk@0.7.25 + +## 0.78.10 + +### Patch Changes + +- Updated dependencies + [[`0b48be1`](https://github.com/lingodotdev/lingo.dev/commit/0b48be197e88dac581cc4f257789a04b43acf932)]: + - @lingo.dev/_spec@0.26.1 + - @lingo.dev/_sdk@0.7.24 + +## 0.78.9 + +### Patch Changes + +- [#543](https://github.com/lingodotdev/lingo.dev/pull/543) + [`c6bd0ba`](https://github.com/lingodotdev/lingo.dev/commit/c6bd0ba188b3358bff7193d396be528da02aa026) + Thanks [@mathio](https://github.com/mathio)! - run prettier in context of the + target file + +## 0.78.8 + +### Patch Changes + +- [#540](https://github.com/lingodotdev/lingo.dev/pull/540) + [`f82762a`](https://github.com/lingodotdev/lingo.dev/commit/f82762a958a795327b911c91f71d1cf550d37ad3) + Thanks [@mathio](https://github.com/mathio)! - possible fix for loading + markdown files + +## 0.78.7 + +### Patch Changes + +- Updated dependencies + [[`7597b99`](https://github.com/lingodotdev/lingo.dev/commit/7597b99c4869f63a42e6de3c4ed25424498d15ae)]: + - @lingo.dev/_sdk@0.7.23 + +## 0.78.6 + +### Patch Changes + +- [#532](https://github.com/lingodotdev/lingo.dev/pull/532) + [`c3b6a04`](https://github.com/lingodotdev/lingo.dev/commit/c3b6a04c8fa3a2898b0f4b68d42e15f45184b5c4) + Thanks [@mathio](https://github.com/mathio)! - save yaml with keys and values + in quotes + +## 0.78.5 + +### Patch Changes + +- Updated dependencies + [[`bafa755`](https://github.com/lingodotdev/lingo.dev/commit/bafa755d9681e93741462eb7bcf9b85073d20fd7)]: + - @lingo.dev/_spec@0.26.0 + - @lingo.dev/_sdk@0.7.22 + +## 0.78.4 + +### Patch Changes + +- [#529](https://github.com/lingodotdev/lingo.dev/pull/529) + [`d75efba`](https://github.com/lingodotdev/lingo.dev/commit/d75efbaf243ff5fe256142dcda8f2b48f806a7fd) + Thanks [@mathio](https://github.com/mathio)! - fix --frozen flag + +- [#527](https://github.com/lingodotdev/lingo.dev/pull/527) + [`a404e2b`](https://github.com/lingodotdev/lingo.dev/commit/a404e2bf123e6a018945e5b6f9bfcfce9235ae77) + Thanks [@mathio](https://github.com/mathio)! - update unlocalizable keys even + with no translation changes + +## 0.78.3 + +### Patch Changes + +- [#524](https://github.com/lingodotdev/lingo.dev/pull/524) + [`befa237`](https://github.com/lingodotdev/lingo.dev/commit/befa23704f9b010923292da89e232152c0423aed) + Thanks [@mathio](https://github.com/mathio)! - fix vue json files + +## 0.78.2 + +### Patch Changes + +- [#518](https://github.com/lingodotdev/lingo.dev/pull/518) + [`444a731`](https://github.com/lingodotdev/lingo.dev/commit/444a7319a1351e22e5666504169023b4c8a29d5f) + Thanks [@mathio](https://github.com/mathio)! - support JSON messages in + block of .vue files + +- Updated dependencies + [[`444a731`](https://github.com/lingodotdev/lingo.dev/commit/444a7319a1351e22e5666504169023b4c8a29d5f)]: + - @lingo.dev/_spec@0.25.3 + - @lingo.dev/_sdk@0.7.21 + +## 0.78.1 + +### Patch Changes + +- [#521](https://github.com/lingodotdev/lingo.dev/pull/521) + [`3cf6753`](https://github.com/lingodotdev/lingo.dev/commit/3cf675320f7534183e2921e0afb3dd7e50beac92) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - empty nodes in + localizable files + +## 0.78.0 + +### Minor Changes + +- [#519](https://github.com/lingodotdev/lingo.dev/pull/519) + [`64b9461`](https://github.com/lingodotdev/lingo.dev/commit/64b946163c5a588405abbe53ac1b0a45cc859d7f) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - treat keys w/ + empty nodes as values in `Localizable.xcstrings` skip keys w/ + `shouldTranslate: false` in `Localizable.xcstrings` + +## 0.77.7 + +### Patch Changes + +- [#515](https://github.com/lingodotdev/lingo.dev/pull/515) + [`fd99a6c`](https://github.com/lingodotdev/lingo.dev/commit/fd99a6ca18ee21774ba5c2b7ce72d1712e374675) + Thanks [@mathio](https://github.com/mathio)! - add typesVersions for support + of older `moduleResolution` + +- Updated dependencies + [[`fd99a6c`](https://github.com/lingodotdev/lingo.dev/commit/fd99a6ca18ee21774ba5c2b7ce72d1712e374675)]: + - @lingo.dev/_sdk@0.7.20 + +## 0.77.6 + +### Patch Changes + +- [#507](https://github.com/lingodotdev/lingo.dev/pull/507) + [`f0c7f6e`](https://github.com/lingodotdev/lingo.dev/commit/f0c7f6e7fa669e4f049d0b1175eb54bae20ec330) + Thanks [@mathio](https://github.com/mathio)! - fix handling numbers in + unlocalizable loader + +## 0.77.5 + +### Patch Changes + +- [#505](https://github.com/lingodotdev/lingo.dev/pull/505) + [`1fc204b`](https://github.com/lingodotdev/lingo.dev/commit/1fc204bdc90ae59a9cda7cd13b0fbf61b7fc0749) + Thanks [@mathio](https://github.com/mathio)! - init github/bitbucket/gitlab + action + +## 0.77.4 + +### Patch Changes + +- [#503](https://github.com/lingodotdev/lingo.dev/pull/503) + [`7f73148`](https://github.com/lingodotdev/lingo.dev/commit/7f73148e6acb67920ce1deaec8d16384115b1071) + Thanks [@github-actions](https://github.com/apps/github-actions)! - release + +- [#500](https://github.com/lingodotdev/lingo.dev/pull/500) + [`3a526b8`](https://github.com/lingodotdev/lingo.dev/commit/3a526b86ef55b501cab167d072791b7487a20c9c) + Thanks [@mathio](https://github.com/mathio)! - fix bucket path with \* + filenames + +## 0.77.3 + +### Patch Changes + +- [#498](https://github.com/lingodotdev/lingo.dev/pull/498) + [`ec2902e`](https://github.com/lingodotdev/lingo.dev/commit/ec2902e5dc31fd79cc3b6fbf478ed1f3c4df0345) + Thanks [@mathio](https://github.com/mathio)! - build json schema for config + +- Updated dependencies + [[`ec2902e`](https://github.com/lingodotdev/lingo.dev/commit/ec2902e5dc31fd79cc3b6fbf478ed1f3c4df0345)]: + - @lingo.dev/_spec@0.25.2 + - @lingo.dev/_sdk@0.7.19 + +## 0.77.2 + +### Patch Changes + +- [#496](https://github.com/lingodotdev/lingo.dev/pull/496) + [`beb0541`](https://github.com/lingodotdev/lingo.dev/commit/beb05411ee459461e05801a763b1fa28d288e04e) + Thanks [@mathio](https://github.com/mathio)! - po files + +- Updated dependencies + [[`beb0541`](https://github.com/lingodotdev/lingo.dev/commit/beb05411ee459461e05801a763b1fa28d288e04e)]: + - @lingo.dev/_spec@0.25.1 + - @lingo.dev/_sdk@0.7.18 + +## 0.77.1 + +### Patch Changes + +- [#493](https://github.com/lingodotdev/lingo.dev/pull/493) + [`81527a4`](https://github.com/lingodotdev/lingo.dev/commit/81527a457ad8ef7fe735232caacdf2cc575e5b20) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix payload + references + +- Updated dependencies + [[`81527a4`](https://github.com/lingodotdev/lingo.dev/commit/81527a457ad8ef7fe735232caacdf2cc575e5b20)]: + - @lingo.dev/_sdk@0.7.17 + +## 0.77.0 + +### Minor Changes + +- [#487](https://github.com/lingodotdev/lingo.dev/pull/487) + [`81c6ddf`](https://github.com/lingodotdev/lingo.dev/commit/81c6ddfb1e7b4f0e530a46a873303a7996d7264a) + Thanks [@mathio](https://github.com/mathio)! - add mcp command + +## 0.76.0 + +### Minor Changes + +- [#490](https://github.com/lingodotdev/lingo.dev/pull/490) + [`c3881c3`](https://github.com/lingodotdev/lingo.dev/commit/c3881c3d763c35d0e4fcad88e5f6918939c6b2a4) + Thanks [@mathio](https://github.com/mathio)! - support multiple [locale] + placeholders in bucket path + +## 0.75.1 + +### Patch Changes + +- [#488](https://github.com/lingodotdev/lingo.dev/pull/488) + [`07241f5`](https://github.com/lingodotdev/lingo.dev/commit/07241f50aa7fe80d1f318106f50d8629b66628f6) + Thanks [@mathio](https://github.com/mathio)! - init command fix + +## 0.75.0 + +### Minor Changes + +- [#485](https://github.com/lingodotdev/lingo.dev/pull/485) + [`a096300`](https://github.com/lingodotdev/lingo.dev/commit/a0963008ea2a8bbc910b0eaeb20f4e3b3cd641a7) + Thanks [@mathio](https://github.com/mathio)! - add support for php buckets + +### Patch Changes + +- Updated dependencies + [[`a096300`](https://github.com/lingodotdev/lingo.dev/commit/a0963008ea2a8bbc910b0eaeb20f4e3b3cd641a7)]: + - @lingo.dev/_spec@0.25.0 + - @lingo.dev/_sdk@0.7.16 + +## 0.74.17 + +### Patch Changes + +- [#483](https://github.com/lingodotdev/lingo.dev/pull/483) + [`d2963fc`](https://github.com/lingodotdev/lingo.dev/commit/d2963fc45a30d3e6972440e8ea7da8e425417cb6) + Thanks [@mathio](https://github.com/mathio)! - fix partial cache restore + +## 0.74.16 + +### Patch Changes + +- [#477](https://github.com/lingodotdev/lingo.dev/pull/477) + [`3d21698`](https://github.com/lingodotdev/lingo.dev/commit/3d21698e3783325ab7bb25aac6d5a8687774cf78) + Thanks [@mathio](https://github.com/mathio)! - detect paths for existing + locale files for each bucket during init + +## 0.74.15 + +### Patch Changes + +- [#473](https://github.com/lingodotdev/lingo.dev/pull/473) + [`3a99763`](https://github.com/lingodotdev/lingo.dev/commit/3a99763087512ba82955303d6f0567e813f4fa05) + Thanks [@vrcprl](https://github.com/vrcprl)! - add new locales + +- Updated dependencies + [[`3a99763`](https://github.com/lingodotdev/lingo.dev/commit/3a99763087512ba82955303d6f0567e813f4fa05)]: + - @lingo.dev/_spec@0.24.4 + - @lingo.dev/_sdk@0.7.15 + +## 0.74.14 + +### Patch Changes + +- [`0dbd288`](https://github.com/lingodotdev/lingo.dev/commit/0dbd288f292db922fa7fbaed239d26897bbe8a8e) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - refactor csv + loader + +## 0.74.13 + +### Patch Changes + +- [#469](https://github.com/lingodotdev/lingo.dev/pull/469) + [`ba51b9b`](https://github.com/lingodotdev/lingo.dev/commit/ba51b9b6d6ecc36e7282d471cef7bf968ccc270e) + Thanks [@mathio](https://github.com/mathio)! - transform object with numeric + keys + +## 0.74.12 + +### Patch Changes + +- [`c5a785a`](https://github.com/lingodotdev/lingo.dev/commit/c5a785aae6a3d786c25e7082e5e3a5309d3327e2) + Thanks [@mathio](https://github.com/mathio)! - revert + +## 0.74.11 + +### Patch Changes + +- [#466](https://github.com/lingodotdev/lingo.dev/pull/466) + [`9ef4da1`](https://github.com/lingodotdev/lingo.dev/commit/9ef4da115043d89218eaf46142bf69dd126448f6) + Thanks [@mathio](https://github.com/mathio)! - transform object with numeric + keys + +## 0.74.10 + +### Patch Changes + +- [#465](https://github.com/lingodotdev/lingo.dev/pull/465) + [`e033656`](https://github.com/lingodotdev/lingo.dev/commit/e0336566758defbd6cf1f7ad3a210d9f94d0c8de) + Thanks [@mathio](https://github.com/mathio)! - fix init cmd + +- [#463](https://github.com/lingodotdev/lingo.dev/pull/463) + [`f249d8f`](https://github.com/lingodotdev/lingo.dev/commit/f249d8f69d04f0ce40fd94e500e7b829b7ba1ed4) + Thanks [@vrcprl](https://github.com/vrcprl)! - set utf-8 encoding explicitly + +- Updated dependencies + [[`f249d8f`](https://github.com/lingodotdev/lingo.dev/commit/f249d8f69d04f0ce40fd94e500e7b829b7ba1ed4)]: + - @lingo.dev/_sdk@0.7.14 + +## 0.74.9 + +### Patch Changes + +- [#461](https://github.com/lingodotdev/lingo.dev/pull/461) + [`d31f9fc`](https://github.com/lingodotdev/lingo.dev/commit/d31f9fcc7fe1a729f093c68a0573f2a8ec077f0e) + Thanks [@mathio](https://github.com/mathio)! - ordering of keys in xcstrings + file to match that of Xcode + +## 0.74.8 + +### Patch Changes + +- [#457](https://github.com/lingodotdev/lingo.dev/pull/457) + [`8ffff97`](https://github.com/lingodotdev/lingo.dev/commit/8ffff9757e28e4beef071866835a491080d7cba5) + Thanks [@mathio](https://github.com/mathio)! - fix trailing new lines + +## 0.74.7 + +### Patch Changes + +- [#455](https://github.com/lingodotdev/lingo.dev/pull/455) + [`96babb9`](https://github.com/lingodotdev/lingo.dev/commit/96babb956b0aa7b83627aefd596051ed1849e7ca) + Thanks [@github-actions](https://github.com/apps/github-actions)! - i18n + progress fix + +## 0.74.6 + +### Patch Changes + +- [#454](https://github.com/lingodotdev/lingo.dev/pull/454) + [`9856274`](https://github.com/lingodotdev/lingo.dev/commit/98562747f3cce0d7109b24f3e5b867f003ebdfbb) + Thanks [@mathio](https://github.com/mathio)! - fix i18n progress indicator + +## 0.74.5 + +### Patch Changes + +- [`e950be4`](https://github.com/lingodotdev/lingo.dev/commit/e950be4ff406ba4328a53a9c9ff8dd094787b105) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - remove newline + loader from dato + +## 0.74.4 + +### Patch Changes + +- [`74bd1c9`](https://github.com/lingodotdev/lingo.dev/commit/74bd1c9c19f364c65b70e91b2f4ff63949c40adf) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - package exports + +## 0.74.3 + +### Patch Changes + +- [`c0bc85d`](https://github.com/lingodotdev/lingo.dev/commit/c0bc85d5870f9150eeecf6e806cbb4a4494b7bf0) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - move \_ packages + from devdeps to deps + +## 0.74.2 + +### Patch Changes + +- [`dc8bfc7`](https://github.com/lingodotdev/lingo.dev/commit/dc8bfc7ddc38ade768b8aa11c56669db7eb446e6) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - publish deps + +## 0.74.1 + +### Patch Changes + +- [`6281dbd`](https://github.com/lingodotdev/lingo.dev/commit/6281dbd96bd5cfe54f194a6a1d055c8255a250de) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix sdk/spec + exported types + +## 0.74.0 + +### Minor Changes + +- [`ee9e666`](https://github.com/lingodotdev/lingo.dev/commit/ee9e666df2a3d11f2e89af37ea47a2d714a5173b) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add belarusian + +## 0.73.1 + +### Patch Changes + +- [#437](https://github.com/lingodotdev/lingo.dev/pull/437) + [`f643c28`](https://github.com/lingodotdev/lingo.dev/commit/f643c2810662e4ced0aa5e57f6e574d0294dab49) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - order xcstrings + ascii-way + +## 0.73.0 + +### Minor Changes + +- [#435](https://github.com/lingodotdev/lingo.dev/pull/435) + [`754de44`](https://github.com/lingodotdev/lingo.dev/commit/754de44bd94119c88e3fb27d0713b8e1b20c4264) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - preserve + newlines, whitespaces while formatting + +- [#435](https://github.com/lingodotdev/lingo.dev/pull/435) + [`754de44`](https://github.com/lingodotdev/lingo.dev/commit/754de44bd94119c88e3fb27d0713b8e1b20c4264) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add json sorting + +## 0.72.0 + +### Minor Changes + +- [#433](https://github.com/lingodotdev/lingo.dev/pull/433) + [`e895746`](https://github.com/lingodotdev/lingo.dev/commit/e895746dff9c6a146e0fa61f681b9a5d60b7d124) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - preserve + newlines, whitespaces while formatting + +## 0.71.0 + +### Minor Changes + +- [#432](https://github.com/lingodotdev/lingo.dev/pull/432) + [`cd836e4`](https://github.com/lingodotdev/lingo.dev/commit/cd836e45cf810f495e2c6e1449824dc84794d571) + Thanks [@mathio](https://github.com/mathio)! - cache processed data chunks, + recover from cache + +- [#428](https://github.com/lingodotdev/lingo.dev/pull/428) + [`5dd7b65`](https://github.com/lingodotdev/lingo.dev/commit/5dd7b6529ce174d8759e80644c3233927b1ecce4) + Thanks [@mathio](https://github.com/mathio)! - map old env vars + +## 0.70.4 + +### Patch Changes + +- [`b4c7f1e`](https://github.com/lingodotdev/lingo.dev/commit/b4c7f1e86334d229bee62219c26f30d0b523926d) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - rename cli + references + +## 0.70.2 + +### Patch Changes + +- [`5dda52b`](https://github.com/lingodotdev/lingo.dev/commit/5dda52bec6788ffa171a976b1739209b193c5a4c) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - optimize + lingo.dev (dev) dependencies + +## 0.70.3 + +### Patch Changes + +- [`9917328`](https://github.com/lingodotdev/lingo.dev/commit/9917328f1293cc44caadde74d7b3c0e3e39e8691) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix export issue + +## 0.70.1 + +### Patch Changes + +- [#419](https://github.com/lingodotdev/lingo.dev/pull/419) + [`a45feb1`](https://github.com/lingodotdev/lingo.dev/commit/a45feb1d747f8fa32c42c1726953a04c174e754a) + Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Replexica is now + Lingo.dev! 🎉 + +- Updated dependencies + [[`a45feb1`](https://github.com/lingodotdev/lingo.dev/commit/a45feb1d747f8fa32c42c1726953a04c174e754a)]: + - @lingo.dev/_spec@0.24.1 + - @lingo.dev/cli@0.70.1 + - @lingo.dev/_sdk@0.7.11 diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 000000000..5c0387ef0 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,186 @@ +

+ + Lingo.dev + +

+ +

+ ⚡ Lingo.dev - open-source, AI-powered i18n toolkit for instant localization with LLMs. +

+ +
+ +

+ Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

+ +

+ + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

+ +--- + +## Meet the Compiler 🆕 + +**Lingo.dev Compiler** is a free, open-source compiler middleware, designed to make any React app multilingual at build time without requiring any changes to the existing React components. + +Install once: + +```bash +npm install @lingo.dev/compiler +``` + +Enable in your build config: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +Run `next build` and watch Spanish and French bundles pop out ✨ + +[Read the docs →](https://lingo.dev/compiler) for the full guide, and [Join our Discord](https://lingo.dev/go/discord) to get help with your setup. + +--- + +### What's inside this repo? + +| Tool | TL;DR | Docs | +| ------------ | ------------------------------------------------------------------------------ | --------------------------------------- | +| **Compiler** | Build-time React localization | [/compiler](https://lingo.dev/compiler) | +| **CLI** | One-command localization for web and mobile apps, JSON, YAML, markdown, + more | [/cli](https://lingo.dev/cli) | +| **CI/CD** | Auto-commit translations on every push + create pull requests if needed | [/ci](https://lingo.dev/ci) | +| **SDK** | Realtime translation for user-generated content | [/sdk](https://lingo.dev/sdk) | + +Below are the quick hits for each 👇 + +--- + +### ⚡️ Lingo.dev CLI + +Translate code & content straight from your terminal. + +```bash +npx lingo.dev@latest run +``` + +It fingerprints every string, caches results, and only re-translates what changed. + +[Follow the docs →](https://lingo.dev/cli) to learn how to set it up. + +--- + +### 🔄 Lingo.dev CI/CD + +Ship perfect translations automatically. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +Keeps your repo green and your product multilingual without the manual steps. + +[Read the docs →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +Instant per-request translation for dynamic content. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +Perfect for chat, user comments, and other real-time flows. + +[Read the docs →](https://lingo.dev/sdk) + +--- + +## 🤝 Community + +We're community-driven and love contributions! + +- Got an idea? [Open an issue](https://github.com/lingodotdev/lingo.dev/issues) +- Want to fix something? [Send a PR](https://github.com/lingodotdev/lingo.dev/pulls) +- Need help? [Join our Discord](https://lingo.dev/go/discord) + +## ⭐ Star History + +If you like what we're doing, give us a ⭐ and help us reach 6,000 stars! 🌟 + +[![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date)](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 Readme in other languages + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +Don't see your language? Add it to [`i18n.json`](./i18n.json) and open a PR! + +**Locale format:** Use [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) codes: `language[-Script][-REGION]` +- Language: ISO 639-1/2/3 lowercase (`en`, `zh`, `bho`) +- Script: ISO 15924 title case (`Hans`, `Hant`, `Latn`) +- Region: ISO 3166-1 alpha-2 uppercase (`US`, `CN`, `IN`) +- Examples: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/packages/cli/WATCH_MODE.md b/packages/cli/WATCH_MODE.md new file mode 100644 index 000000000..06c23a6cc --- /dev/null +++ b/packages/cli/WATCH_MODE.md @@ -0,0 +1,192 @@ +# Watch Mode Implementation + +This document describes the implementation of the watch mode feature for the Lingo.dev CLI. + +## Overview + +The watch mode (`--watch` flag) automatically monitors source files for changes and triggers retranslation when modifications are detected. This eliminates the need for manual retranslation after each edit and keeps target language files in sync with source file changes. + +## Usage + +```bash +# Start watch mode +lingo.dev run --watch + +# Watch with custom debounce timing (7 seconds) +lingo.dev run --watch --debounce 7000 + +# Watch with faster debounce for development (2 seconds) +lingo.dev run --watch --debounce 2000 + +# Watch with additional filters +lingo.dev run --watch --locale es --bucket json +lingo.dev run --watch --file "src/locales/*.json" --debounce 1000 +``` + +## Features + +### 1. Automatic File Monitoring + +- Watches all source locale files based on your `i18n.json` configuration +- Monitors file changes, additions, and deletions +- Uses stable file watching to avoid false triggers + +### 2. Debounced Processing + +- Implements configurable debounce mechanism to avoid excessive retranslations +- Default: 5 seconds, customizable with `--debounce` flag +- Groups rapid changes into single translation batches +- Prevents resource waste from frequent file saves + +### 3. Intelligent Pattern Detection + +- Automatically determines which files to watch based on bucket patterns +- Replaces `[locale]` placeholders with source locale +- Respects filtering options (`--bucket`, `--file`, etc.) + +### 4. Real-time Feedback + +- Shows which files are being watched on startup +- Displays file change notifications +- Provides translation progress updates +- Shows completion status for each batch + +### 5. Graceful Error Handling + +- Continues watching even if individual translations fail +- Reports errors without stopping the watch process +- Maintains watch state across translation cycles + +## Implementation Details + +### File Structure + +- `src/cli/cmd/run/watch.ts` - Main watch implementation +- `src/cli/cmd/run/_types.ts` - Updated to include watch flag +- `src/cli/cmd/run/index.ts` - Integration with main run command + +### Key Components + +#### Watch State Management + +```typescript +interface WatchState { + isRunning: boolean; + pendingChanges: Set; + debounceTimer?: NodeJS.Timeout; +} +``` + +#### File Pattern Resolution + +The watch mode automatically determines which files to monitor by: + +1. Getting buckets from `i18n.json` +2. Applying user filters (`--bucket`, `--file`) +3. Replacing `[locale]` with source locale +4. Creating file patterns for chokidar + +#### Debounce Logic + +- Uses configurable debounce timer (default: 5000ms) +- Resets timer on each file change +- Only triggers translation when timer expires +- Prevents overlapping translation runs +- Customizable via `--debounce ` flag + +### Dependencies + +- `chokidar` - Robust file watching library +- Existing Lingo.dev pipeline (setup, plan, execute) + +## Example Workflow + +1. **Start Watch Mode** + + ```bash + lingo.dev run --watch + ``` + +2. **Initial Setup** + + - Performs normal translation setup + - Runs initial planning and execution + - Shows summary of completed translations + - Starts file watching + +3. **File Change Detection** + + ``` + 📝 File changed: locales/en.json + ⏳ Debouncing... (5000ms) + ``` + +4. **Automatic Retranslation** + + ``` + 🔄 Triggering retranslation... + Changed files: locales/en.json + + [Planning] Found 2 translation task(s) + [Localization] Processing tasks... + ✅ Retranslation completed + 👀 Continuing to watch for changes... + ``` + +## Error Handling + +The watch mode is designed to be resilient: + +- **Translation Errors**: Reports errors but continues watching +- **File System Errors**: Logs watch errors but maintains process +- **Invalid Files**: Skips problematic files and continues +- **Interrupt Handling**: Gracefully shuts down on Ctrl+C + +## Performance Considerations + +- **Efficient Pattern Matching**: Only watches relevant source files +- **Debounced Processing**: Prevents excessive API calls +- **Memory Management**: Clears completed change sets +- **Process Isolation**: Each translation runs in isolated context + +## Testing + +Use the provided demo setup script: + +```bash +./demo-watch-setup.sh +cd /tmp/lingo-watch-demo +lingo.dev run --watch +``` + +Then in another terminal: + +```bash +# Add a new translation key +echo '{"hello": "Hello", "world": "World", "welcome": "Welcome to Lingo.dev", "goodbye": "Goodbye"}' > locales/en.json + +# Watch as translations are automatically updated +``` + +## Integration with Existing Features + +The watch mode works seamlessly with all existing run command options: + +- `--locale` - Watch only affects specified locales +- `--bucket` - Watch only monitors specified bucket types +- `--file` - Watch only monitors matching file patterns +- `--key` - Post-change filtering applies to specific keys +- `--force` - Forces full retranslation on each change +- `--api-key` - Uses specified API key for all operations +- `--concurrency` - Controls translation parallelism +- `--debounce` - Configures debounce delay in milliseconds (default: 5000ms) + +## Future Enhancements + +Potential improvements for future versions: + +1. **Watch Exclusions**: Ignore specific files or patterns +2. **Selective Translation**: Only translate changed keys +3. **Change Summaries**: Show detailed change reports +4. **Multi-project Support**: Watch multiple i18n configurations +5. **Advanced Debounce Modes**: Per-file or per-bucket debouncing diff --git a/packages/cli/agents.md b/packages/cli/agents.md new file mode 100644 index 000000000..a70fbcf8a --- /dev/null +++ b/packages/cli/agents.md @@ -0,0 +1,12 @@ +# Lingo.dev CLI Localization Guidelines + +The following rules and guidelines should be followed to ensure effective AI-powered localization using the Lingo.dev CLI in projects using Lingo.dev: + +1. **Structure content for translation**: Organize user-facing strings in supported formats (e.g., JSON locale files, ARB, PO, Markdown) to allow the Lingo.dev CLI to efficiently extract and translate content. Avoid hardcoding text directly in code where possible; use structured keys or files that the CLI can process. +2. **Locale files**: Store source content and translations in locale-specific files or directories (e.g., `en.json`, `app/i18n/fr.json`). The Lingo.dev CLI will automatically generate and keep target locale files in sync by translating only new or changed content. +3. **Source language as fallback**: Use a primary source language (usually English) as the base. The CLI treats your project files as the source of truth and provides fallbacks naturally through your runtime i18n setup for any missing translations. +4. **Pluralization and formatting**: Use i18n formats and libraries that support pluralization, placeholders, and locale-specific date/number formatting (e.g., ICU MessageFormat). The CLI preserves these structures during AI translation for accurate results. +5. **Avoid concatenation**: Do not concatenate translatable strings. Provide complete messages with interpolation placeholders so the AI translator can produce natural, contextually accurate results. +6. **Contextual translations**: Add context or translator notes in your configuration (via buckets or comments in supported formats) for ambiguous terms. This helps the AI generate higher-quality translations. +7. **Testing and review**: Run the Lingo.dev CLI regularly (e.g., in CI/CD) to generate translations, then test your application in multiple locales. Review AI-generated translations for accuracy and tone, using features like key locking to preserve approved versions. +_For more details, refer to the Lingo.dev CLI documentation at lingo.dev or join the community Discord._ diff --git a/packages/cli/assets/failure.mp3 b/packages/cli/assets/failure.mp3 new file mode 100644 index 000000000..61a4cd0e6 Binary files /dev/null and b/packages/cli/assets/failure.mp3 differ diff --git a/packages/cli/assets/success.mp3 b/packages/cli/assets/success.mp3 new file mode 100644 index 000000000..2fef238a5 Binary files /dev/null and b/packages/cli/assets/success.mp3 differ diff --git a/packages/cli/bin/cli.mjs b/packages/cli/bin/cli.mjs new file mode 100755 index 000000000..8b77978fc --- /dev/null +++ b/packages/cli/bin/cli.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import CLI from "../build/cli.mjs"; + +await CLI.parseAsync(process.argv); diff --git a/packages/cli/demo/ail/example.ail b/packages/cli/demo/ail/example.ail new file mode 100644 index 000000000..25fd3e7d8 --- /dev/null +++ b/packages/cli/demo/ail/example.ail @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/cli/demo/ail/i18n.json b/packages/cli/demo/ail/i18n.json new file mode 100644 index 000000000..dd8d10f32 --- /dev/null +++ b/packages/cli/demo/ail/i18n.json @@ -0,0 +1,13 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": ["es"] + }, + "buckets": { + "ail": { + "include": ["./*.ail"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} diff --git a/packages/cli/demo/ail/i18n.lock b/packages/cli/demo/ail/i18n.lock new file mode 100644 index 000000000..e69de29bb diff --git a/packages/cli/demo/android/en/example.xml b/packages/cli/demo/android/en/example.xml new file mode 100644 index 000000000..658d1bc3b --- /dev/null +++ b/packages/cli/demo/android/en/example.xml @@ -0,0 +1,105 @@ + + + + MyApp + Hello, world! + Get Started + + + https://api.example.com + DEBUG_MODE_ENABLED + + + + Red + Green + Blue! + + + + + https://prod.example.com + https://staging.example.com + https://dev.example.com + + + + + %d new message + %d new messages + + + + + %d byte + %d bytes + + + + true + false + + + false + true + + + 3 + 30 + + + 43 + 1 + + + <b>Bold</b> + + Don\'t forget! + + Learn more.]]> + + + + Item with spaces + + + + + + Ignored + + + + Ignored + Ignored + + + + #FF6200EE + #FF03DAC5 + + + 16sp + 8dp + + + + 1 + 2 + 3 + + + + + @drawable/icon1 + @drawable/icon2 + + + Terms of Use + + + + + View your options + + \ No newline at end of file diff --git a/packages/cli/demo/android/es/example.xml b/packages/cli/demo/android/es/example.xml new file mode 100644 index 000000000..e9d244baa --- /dev/null +++ b/packages/cli/demo/android/es/example.xml @@ -0,0 +1,58 @@ + + + MyApp + ¡Hola, mundo! + Comenzar + + + Rojo + Verde + ¡Azul! + + + + %d mensaje nuevo + %d mensajes nuevos + + + true + false + + 3 + 30 + + <b>Negrita</b> + + ¡No lo olvides! + + Más información.]]> + + + Elemento con espacios + + + + #FF6200EE + #FF03DAC5 + + 16sp + 8dp + + + 1 + 2 + 3 + + + + @drawable/icon1 + @drawable/icon2 + + + <u>Condiciones de uso</u> + + + + <u>Ver tus opciones</u> + + \ No newline at end of file diff --git a/packages/cli/demo/android/i18n.json b/packages/cli/demo/android/i18n.json new file mode 100644 index 000000000..f06c983d0 --- /dev/null +++ b/packages/cli/demo/android/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "android": { + "include": [ + "./[locale]/example.xml" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/android/i18n.lock b/packages/cli/demo/android/i18n.lock new file mode 100644 index 000000000..be77a3005 --- /dev/null +++ b/packages/cli/demo/android/i18n.lock @@ -0,0 +1,17 @@ +version: 1 +checksums: + ec06a6ebae97ffd5f7afc99d9a8f051b: + app_name: 7dc70110429d46e3685f385bd2cc941c + welcome_message: 0468579ef2fbc83c9d520c2f2f1c5059 + button_text: 1d5f030c4ec9c869e647ae060518b948 + color_names/0: bace0083b78cdb188523bc4abc7b55c6 + color_names/1: 482ff383a4258357ba404f283682471d + color_names/2: a3f56a5c28ea75888356c2280aadc0e7 + notification_count/one: fe0aceb70f334c52a87937c36898a1d0 + notification_count/other: 13acfd95b16962ebe1f67dcd343513e1 + html_snippet: f060191b1af70b3848106a4df91f43cd + apostrophe_example: 997099339b144b06266f8da411de8d93 + cdata_example: b88d55f92c4a90f64016e497051e997d + mixed_items/0: 31c5d470a2fe8e1ae88e964fc673aee3 + mixed_items/1: 9823a57cbe6e6e84c1d025ce24a1eec4 + terms_of_use_raw: e3048f75742e66473369a83c10ea95c3 diff --git a/packages/cli/demo/csv-per-locale/en/example.csv b/packages/cli/demo/csv-per-locale/en/example.csv new file mode 100644 index 000000000..981ab2fc1 --- /dev/null +++ b/packages/cli/demo/csv-per-locale/en/example.csv @@ -0,0 +1,6 @@ +id,name,description,created,enabled,sort +1,Welcome,Welcome to our application,2024-01-01,true,1 +2,Save,Save your changes,2024-01-01,true,2 +3,Error,An error occurred,2024-01-01,true,3 +4,Success,Operation completed successfully,2024-01-01,true,4 +5,Loading,Please wait while we load your data,2024-01-01,true,5 diff --git a/packages/cli/demo/csv-per-locale/es/example.csv b/packages/cli/demo/csv-per-locale/es/example.csv new file mode 100644 index 000000000..6ff0c2805 --- /dev/null +++ b/packages/cli/demo/csv-per-locale/es/example.csv @@ -0,0 +1,6 @@ +id,name,description,created,enabled,sort +1,Bienvenida,Bienvenido a nuestra aplicación,2024-01-01,true,1 +2,Guardar,Guarda tus cambios,2024-01-01,true,2 +3,Error,Ha ocurrido un error,2024-01-01,true,3 +4,Éxito,Operación completada con éxito,2024-01-01,true,4 +5,Cargando,Por favor espera mientras cargamos tus datos,2024-01-01,true,5 diff --git a/packages/cli/demo/csv-per-locale/i18n.json b/packages/cli/demo/csv-per-locale/i18n.json new file mode 100644 index 000000000..72c629610 --- /dev/null +++ b/packages/cli/demo/csv-per-locale/i18n.json @@ -0,0 +1,15 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": ["es"] + }, + "buckets": { + "csv-per-locale": { + "include": ["./[locale]/example.csv"], + "lockedKeys": ["locked_key_1"], + "ignoredKeys": ["ignored_key_1"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} diff --git a/packages/cli/demo/csv-per-locale/i18n.lock b/packages/cli/demo/csv-per-locale/i18n.lock new file mode 100644 index 000000000..d3587db5f --- /dev/null +++ b/packages/cli/demo/csv-per-locale/i18n.lock @@ -0,0 +1,51 @@ +version: 1 +checksums: + e8b273672f895de0944f0a2317670d7c: + 0/NAME: 82fd7edcad911fd528a6e1d65905e468 + 0/PRODUCT: 97cddc0ea1a642dbbc77e2c58ef96c54 + 0/BONUS: 3c196cabd552b6b6941f8813562cdec6 + 1/NAME: 3e7241ad69e00f71f6447b69006fdd6b + 1/PRODUCT: 52dceb6e133876eedefc91758f4dafa8 + 1/BONUS: a61657f3137c31deb9498810287d7ed4 + 2/NAME: 229dc77b9845261d704d992c7b00153a + 2/PRODUCT: 45b9756cad31c05eba897f19a4550ecb + 2/BONUS: 02ac38cc1479911157054402e82959c1 + 3/NAME: 40f55d4ddad53f1c31c7244813bc68e7 + 3/PRODUCT: 93017112c1dc2f074d3be1abb02d2507 + 3/BONUS: 604e6eca8e6c781586fab7cf0b97f0b7 + 4/NAME: ba44c67983adfdd5bf03ec5168f3abc9 + 4/PRODUCT: 898e5908c9726f8056673258dbe9b1af + 4/BONUS: 719f3dbab3cb569a93518d4c2ff1b633 + 5/NAME: 61d99435646f27866c505e7d1f40d171 + 5/PRODUCT: e9c4963b1da635d2365e0111a7a7fc2f + 5/BONUS: 35db06a282738c6c6a9417094d39c80e + 6/NAME: 032412ef2ec37e8be14137292049e970 + 6/PRODUCT: 221ea6f7cf3778ae8b9f079588d7fb7a + 6/BONUS: b557d0a6619c27e558b8581ce7d3108a + 7/NAME: 6a554c4b466acc8b76d043452e3a710f + 7/PRODUCT: e6b44d2244ead5e4ae5a6a3755d103f8 + 7/BONUS: 0a91164a59598e2650e4e48eaa6bd4bf + 8/NAME: 275a03d2e25a65f126991ada1daa870d + 8/PRODUCT: 97cddc0ea1a642dbbc77e2c58ef96c54 + 8/BONUS: 3c196cabd552b6b6941f8813562cdec6 + 9/NAME: 221b270fe5a60e5a160d5502f9b0c139 + 9/PRODUCT: 52dceb6e133876eedefc91758f4dafa8 + 9/BONUS: a61657f3137c31deb9498810287d7ed4 + 10/NAME: 7517a402f3581ea2c04a992b8468c008 + 10/PRODUCT: 45b9756cad31c05eba897f19a4550ecb + 10/BONUS: 02ac38cc1479911157054402e82959c1 + 11/NAME: a649692aa9ba9468f98ed718bd4d0729 + 11/PRODUCT: 93017112c1dc2f074d3be1abb02d2507 + 11/BONUS: 604e6eca8e6c781586fab7cf0b97f0b7 + 12/NAME: 10e10057922963a0ef53b526cb2baf90 + 12/PRODUCT: 898e5908c9726f8056673258dbe9b1af + 12/BONUS: 719f3dbab3cb569a93518d4c2ff1b633 + 13/NAME: 2b0365cf946aeb2678cb2a10f7b662ae + 13/PRODUCT: e9c4963b1da635d2365e0111a7a7fc2f + 13/BONUS: 35db06a282738c6c6a9417094d39c80e + 14/NAME: bbe0e79469d425ce26a44bd8c1f9783d + 14/PRODUCT: 221ea6f7cf3778ae8b9f079588d7fb7a + 14/BONUS: b557d0a6619c27e558b8581ce7d3108a + 15/NAME: e08118de3644266e79c63d1fe03bdd34 + 15/PRODUCT: e6b44d2244ead5e4ae5a6a3755d103f8 + 15/BONUS: 0a91164a59598e2650e4e48eaa6bd4bf diff --git a/packages/cli/demo/csv/example.csv b/packages/cli/demo/csv/example.csv new file mode 100644 index 000000000..05a286599 --- /dev/null +++ b/packages/cli/demo/csv/example.csv @@ -0,0 +1,8 @@ +KEY,en,es +welcome_message,Welcome to our application,Bienvenido a nuestra aplicación +button_save,Save,Guardar +error_invalid_email,Please enter a valid email address,Introduce una dirección de correo electrónico válida +product_name,Premium Widget,Premium Widget +empty_row_key,, +whitespace_only, , +new_feature,This is a new feature,Esta es una nueva funcionalidad \ No newline at end of file diff --git a/packages/cli/demo/csv/i18n.json b/packages/cli/demo/csv/i18n.json new file mode 100644 index 000000000..fc07221f9 --- /dev/null +++ b/packages/cli/demo/csv/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "csv": { + "include": [ + "./example.csv" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/csv/i18n.lock b/packages/cli/demo/csv/i18n.lock new file mode 100644 index 000000000..6d4b832d5 --- /dev/null +++ b/packages/cli/demo/csv/i18n.lock @@ -0,0 +1,14 @@ +version: 1 +checksums: + e8b273672f895de0944f0a2317670d7c: + welcome_message: 1308168cca4fa5d8d7a0cf24e55e93fc + button_save: f7a2929f33bc420195e59ac5a8bcd454 + error_invalid_email: 8de4bc8832b11b380bc4cbcedc16e48b + product_name: d3d99b147cc363dc6db8a48e8a13d4c1 + new_feature: 7cd986af1fe5e89abe7ecffba5413110 + d0f33bd41270762260010c4723a564f5: + welcome_message: 1308168cca4fa5d8d7a0cf24e55e93fc + button_save: f7a2929f33bc420195e59ac5a8bcd454 + error_invalid_email: 8de4bc8832b11b380bc4cbcedc16e48b + product_name: d3d99b147cc363dc6db8a48e8a13d4c1 + new_feature: 7cd986af1fe5e89abe7ecffba5413110 diff --git a/packages/cli/demo/demo.spec.ts b/packages/cli/demo/demo.spec.ts new file mode 100644 index 000000000..56f3390b3 --- /dev/null +++ b/packages/cli/demo/demo.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import fs from "fs"; +import path from "path"; +import { bucketTypes, parseI18nConfig } from "@lingo.dev/_spec"; + +type BucketType = (typeof bucketTypes)[number]; + +const SKIP_BUCKET_TYPES: BucketType[] = ["compiler", "dato"]; + +const TESTABLE_BUCKET_TYPES: BucketType[] = bucketTypes.filter( + (type) => !SKIP_BUCKET_TYPES.includes(type), +); + +describe("packages/cli/demo", () => { + console.warn( + `Bucket types are defined but not tested: ${SKIP_BUCKET_TYPES.join(", ")}`, + ); + + it("should include a demo for each bucket type", () => { + const demoRoot = path.resolve(__dirname); + const missingBuckets: string[] = []; + + for (const bucketType of new Set(TESTABLE_BUCKET_TYPES)) { + const bucketPath = path.join(demoRoot, bucketType); + const exists = fs.existsSync(bucketPath); + if (!exists) { + missingBuckets.push(bucketType); + } + } + + expect(missingBuckets).toEqual([]); + }); + + it("should have an i18n.json file in each bucket demo", () => { + const demoRoot = path.resolve(__dirname); + const missingFiles: string[] = []; + + for (const bucketType of new Set(TESTABLE_BUCKET_TYPES)) { + const bucketPath = path.join(demoRoot, bucketType); + const i18nJsonPath = path.join(bucketPath, "i18n.json"); + if (!fs.existsSync(i18nJsonPath)) { + missingFiles.push(bucketType); + } + } + + expect(missingFiles).toEqual([]); + }); + + it("should have valid i18n.json config in each bucket demo", () => { + const demoRoot = path.resolve(__dirname); + const invalidConfigs: Array<{ bucketType: string; error: string }> = []; + + for (const bucketType of new Set(TESTABLE_BUCKET_TYPES)) { + const bucketPath = path.join(demoRoot, bucketType); + const i18nJsonPath = path.join(bucketPath, "i18n.json"); + try { + const configContent = fs.readFileSync(i18nJsonPath, "utf-8"); + const rawConfig = JSON.parse(configContent); + parseI18nConfig(rawConfig); + } catch (error) { + invalidConfigs.push({ + bucketType, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + expect(invalidConfigs).toEqual([]); + }); + + it("should have an i18n.lock file in each bucket demo", () => { + const demoRoot = path.resolve(__dirname); + const missingFiles: string[] = []; + + for (const bucketType of new Set(TESTABLE_BUCKET_TYPES)) { + const bucketPath = path.join(demoRoot, bucketType); + const i18nLockPath = path.join(bucketPath, "i18n.lock"); + if (!fs.existsSync(i18nLockPath)) { + missingFiles.push(bucketType); + } + } + + expect(missingFiles).toEqual([]); + }); +}); diff --git a/packages/cli/demo/ejs/en/example.ejs b/packages/cli/demo/ejs/en/example.ejs new file mode 100644 index 000000000..a111cfbdc --- /dev/null +++ b/packages/cli/demo/ejs/en/example.ejs @@ -0,0 +1,56 @@ + + + + <%= pageTitle %> + + + + <% if (user) { %> +

Welcome back!

+ +

Hello, <%= user.name %>! You have <%= messageCount %> new messages.

+ +
+ Notice: Account expires in 30 days. +
+ + User avatar + + <% } else { %> +

Please log in

+ +
+ + + + + + + + +
+ <% } %> + +
    + <% items.forEach(function(item) { %> +
  • Product: <%= item.name %> - Price: $<%= item.price %>
  • + <% }); %> +
+ + + +
+ +
+ Copyright © <%= new Date().getFullYear() %> MyApp. All rights reserved. +
+ + + + \ No newline at end of file diff --git a/packages/cli/demo/ejs/es/example.ejs b/packages/cli/demo/ejs/es/example.ejs new file mode 100644 index 000000000..79bfc709c --- /dev/null +++ b/packages/cli/demo/ejs/es/example.ejs @@ -0,0 +1,56 @@ + + + + <%= pageTitle %> + + + + <% if (user) { %> +

¡Bienvenido de nuevo!

+ +

Hola, <%= user.name %>! Tienes <%= messageCount %> mensajes nuevos.

+ +
+ Aviso: La cuenta expira en 30 días. +
+ + User avatar + + <% } else { %> +

Por favor, inicia sesión

+ +
+ + + + + + + + +
+ <% } %> + +
    + <% items.forEach(function(item) { %> +
  • Producto: <%= item.name %> - Precio: $<%= item.price %>
  • + <% }); %> +
+ + + +
+ +
+ Copyright © <%= new Date().getFullYear() %> MyApp. Todos los derechos reservados. +
+ + + + \ No newline at end of file diff --git a/packages/cli/demo/ejs/i18n.json b/packages/cli/demo/ejs/i18n.json new file mode 100644 index 000000000..acddfeea7 --- /dev/null +++ b/packages/cli/demo/ejs/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "ejs": { + "include": [ + "./[locale]/example.ejs" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/ejs/i18n.lock b/packages/cli/demo/ejs/i18n.lock new file mode 100644 index 000000000..0d77da931 --- /dev/null +++ b/packages/cli/demo/ejs/i18n.lock @@ -0,0 +1,20 @@ +version: 1 +checksums: + 21b99a2aea148b309f95ec2c966d326c: + text_0: e4d2da607604b3fda41eef5e0dd35faa + text_1: 69eb28c44f7168b1df0455ad2a62588c + text_2: bff335b01588a8db802bd193c725ec11 + text_3: 0744639a7ac440afe0d792ea79c54512 + text_4: b4cc462fb3a00d2f60deefe548c10a33 + text_5: d0fd310aef9cf3c5827f1db4b0c098a1 + text_6: 85bb1f6fb66b5ab65a9c61469183236e + text_7: bdbc827b3d224e03394dfd56304500f2 + text_8: 5e8497af456decf6cf716c0a23f1dbc2 + text_9: d572e25ed81420669e65c03925da1001 + text_10: 2cf6537fb69cdd2eb030e55bf4223b93 + text_11: ec7b8f314fe9bc6591006707484ede61 + text_12: c2460fb2a7887fdf2d68db2b553a4338 + text_13: 3abe623951250bd24a9d7799415761ab + text_14: 988be328b82702586f2cd541858710fe + text_15: b2328773b0ef0699fd5791055c5cf9e2 + text_16: 92acabd12cd9b63c825294c54fcbc806 diff --git a/packages/cli/demo/flutter/en/example.arb b/packages/cli/demo/flutter/en/example.arb new file mode 100644 index 000000000..b6fcbebe1 --- /dev/null +++ b/packages/cli/demo/flutter/en/example.arb @@ -0,0 +1,54 @@ +{ + "@@locale": "en", + "@@last_modified": "2024-01-15T10:30:00Z", + "@@version": "1.0.0", + "welcome_message": "Welcome to our Flutter app", + "@welcome_message": { + "description": "The main welcome message shown on the home screen", + "context": "greeting" + }, + "login_button": "Log In", + "@login_button": { + "description": "Text for the login button" + }, + "signup_button": "Sign Up", + "error_network": "Network connection failed", + "@error_network": { + "description": "Error message displayed when network request fails" + }, + "success_message": "Operation completed successfully", + "user_profile_title": "User Profile", + "settings_screen_title": "Settings", + "cancel_action": "Cancel", + "confirm_action": "Confirm", + "greeting_with_name": "Hello {name}!", + "@greeting_with_name": { + "description": "Greeting message with user's name", + "placeholders": { + "name": { + "type": "String", + "description": "The user's display name" + } + } + }, + "item_count": "{count, plural, =0 {No items} one {# item} other {# items}}", + "@item_count": { + "description": "Message showing the number of items with proper pluralization", + "placeholders": { + "count": { + "type": "int", + "description": "The number of items" + } + } + }, + "user_status": "{status, select, online {User is online} offline {User is offline} away {User is away} other {Unknown status}}", + "@user_status": { + "description": "Message showing user's current status", + "placeholders": { + "status": { + "type": "String", + "description": "The user's current status" + } + } + } +} \ No newline at end of file diff --git a/packages/cli/demo/flutter/es/example.arb b/packages/cli/demo/flutter/es/example.arb new file mode 100644 index 000000000..1310e2774 --- /dev/null +++ b/packages/cli/demo/flutter/es/example.arb @@ -0,0 +1,54 @@ +{ + "@@locale": "es", + "@@last_modified": "2024-01-15T10:30:00Z", + "@@version": "1.0.0", + "welcome_message": "Bienvenido a nuestra app de Flutter", + "@welcome_message": { + "description": "The main welcome message shown on the home screen", + "context": "greeting" + }, + "login_button": "Iniciar sesión", + "@login_button": { + "description": "Text for the login button" + }, + "signup_button": "Registrarse", + "error_network": "Error de conexión de red", + "@error_network": { + "description": "Error message displayed when network request fails" + }, + "success_message": "Operación completada con éxito", + "user_profile_title": "Perfil de usuario", + "settings_screen_title": "Configuración", + "cancel_action": "Cancelar", + "confirm_action": "Confirmar", + "greeting_with_name": "¡Hola {name}!", + "@greeting_with_name": { + "description": "Greeting message with user's name", + "placeholders": { + "name": { + "type": "String", + "description": "The user's display name" + } + } + }, + "item_count": "{count, plural, =0 {Sin elementos} one {# elemento} other {# elementos}}", + "@item_count": { + "description": "Message showing the number of items with proper pluralization", + "placeholders": { + "count": { + "type": "int", + "description": "The number of items" + } + } + }, + "user_status": "{status, select, online {El usuario está en línea} offline {El usuario está desconectado} away {El usuario está ausente} other {Estado desconocido}}", + "@user_status": { + "description": "Message showing user's current status", + "placeholders": { + "status": { + "type": "String", + "description": "The user's current status" + } + } + } +} \ No newline at end of file diff --git a/packages/cli/demo/flutter/i18n.json b/packages/cli/demo/flutter/i18n.json new file mode 100644 index 000000000..a4550da12 --- /dev/null +++ b/packages/cli/demo/flutter/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "flutter": { + "include": [ + "./[locale]/example.arb" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/flutter/i18n.lock b/packages/cli/demo/flutter/i18n.lock new file mode 100644 index 000000000..93e41363a --- /dev/null +++ b/packages/cli/demo/flutter/i18n.lock @@ -0,0 +1,15 @@ +version: 1 +checksums: + 88559ecadc7cff52625bd3a47e51dd78: + welcome_message: 9c56d00796c3c7facba6a25275de5b7b + login_button: 0029e5a35676c0051e761fcd046ef9ee + signup_button: 0dd2ae69be4618c1f9e615774a4509ca + error_network: e0becd5fc88e85a97c6987b96c80fb11 + success_message: e6dcd73052dc41dbe05d86af9887d2c4 + user_profile_title: bee775ff7216747b2111e93cefa57ddc + settings_screen_title: 8df6777277469c1fd88cc18dde2f1cc3 + cancel_action: 2e2a849c2223911717de8caa2c71bade + confirm_action: 90930b51154032f119fa75c1bd422d8b + greeting_with_name: 218521f06746e82ba44d68c8a5bb210c + item_count: d8d083b56bc155cf0997ea6ae989b83f + user_status: 903b5a6edde5368dd9089accdc1b1f9d diff --git a/packages/cli/demo/html/en/advanced-example.html b/packages/cli/demo/html/en/advanced-example.html new file mode 100644 index 000000000..c8915aa1e --- /dev/null +++ b/packages/cli/demo/html/en/advanced-example.html @@ -0,0 +1,989 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Maximal HTML5 Showcase + + + + + + + + + + + Skip to main content + +
+
+
+

Maximal HTML5 Showcase

+

+ This file tries to cover a huge swath of modern + HTML elements, structures, and attributes. It can’t literally + include “every possible” attribute, but it’s a dense reference. +

+

+ HTML5 + ARIA + Forms + Media + SVG + MathML +

+
+ +
+ + +
+
+
+ +
+
+ +
+

Text, Semantics & Inline Elements

+ +
+
+

Article header

+

+ + + +

+
+ +

+ Paragraph with emphasis, strong, + italic, bold, underline, + strikethrough, small text, + highlight, + HTML, + definition, + inline quote, and a + citation. +

+ +

+ Subscripts H2O, superscripts x2, and an + inline span: + English text. Here’s + isolated text and reversed text. +

+ +

+ Code-ish stuff: const x = 1;, keyboard: + Ctrl+C, sample output: OK, and + variable n. +

+ +
// Preformatted code block
+function hello(name){
+  return `Hello, ${name}`;
+}
+ +
+

+ Block quote with nested a short quote and attribution. +

+
Someone
+
+ +
+ +

+ Links with attributes: + + external link , download-ish link, + mailto, tel. +

+ +
    +
  • Unordered list item
  • +
  • Another item with anchor
  • +
+ +
    +
  1. Ordered item with custom value
  2. +
  3. Next ordered item
  4. +
+ + +
  • +
  • +
    + +
    +
    Term A
    +
    Definition for term A.
    +
    Term B
    +
    Definition for term B.
    +
    + +
    + Placeholder image +
    Figure + figcaption.
    +
    + +
    + Expandable details with summary +

    Details content. Supports more anchors.

    +
    + +

    + Ruby annotation example: + Textannotation +

    + +
    +

    + Article footer with + keywords, tags, etc. +

    +
    +
    +
    + + +
    +

    Layout & Structural Elements

    + +
    +
    +

    Section inside grid

    +

    Using section for thematic grouping.

    +
    + +
    +

    Article inside grid

    +

    Using article for self-contained content.

    +
    + +
    +

    Generic div + ARIA region

    +

    Sometimes a div is fine with proper roles.

    +
    +
    + +

    + Inline container: span • block + container: + +

    + + +
    + + +
    +

    Media Elements

    + +

    Audio

    + + +

    Video

    + + +

    Picture / Responsive Images

    + + + Responsive fallback + + +

    Iframe / Embed / Object

    + + + + + + Fallback text for object. + + +

    Canvas

    + + Canvas fallback text. + + +
    + + +
    +

    Inline SVG

    + + + + + + + + + + + + + + + + + + + + + + SVG Elements + + +
    + + +
    +

    MathML (if supported)

    + + + a2 + + + b2 + = + c2 + + +
    + + +
    +

    Forms (a lot of inputs)

    + +
    +
    + Basics + + + + + + + + + + + + + + + + + + + Search help text. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Radio group
    + + +
    + + + + + 5 + + + + +
    + +
    + Advanced widgets + + + + 65% + + + + 45% + + + + + + + + + + + + + +
    +
    +
    + + +
    +

    Tables

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Complex table with colgroup, thead, tbody, tfoot +
    NameRoleStatusNotes
    AdaEngineerActive
    LinusMaintainerAwayOld note New note
    Footer row
    +
    + + +
    +

    Interactive, Embedded, and Misc Elements

    + +

    Dialog

    + + +

    Demo dialog

    +

    Native dialog element with showModal().

    +
    + +
    +
    + +

    Popover

    + +
    + I am a popover. + +
    + +

    Disclosure / Summary

    +
    + Open details +

    + Some details text. Includes Esc to close dialog, etc. +

    +
    + +

    Word break opportunities

    +

    WordBreakOpportunities.

    + +

    Slots (Web Components-ready)

    +
    + Fallback slot content +
    + +

    Autocomplete list (ul[role=listbox])

    +
      +
    • Suggestion 1
    • +
    • Suggestion 2
    • +
    + +

    Keyboard navigation target

    +
    + Focusable div button +
    + +

    Address / Contact

    +
    + 123 Demo St.
    + Example City, EX 00000
    + contact@example.com +
    + +

    Annotation / Edits

    +

    + This is deleted and + inserted. +

    + +

    Text isolation example

    +

    Mixing text: isolated segment + normal English.

    + +

    Noscript

    + +
    +
    + + +
    + +
    +

    + Footer with small print. + © Demo. +

    + +

    + Back to top +

    +
    + + +
    + + diff --git a/packages/cli/demo/html/en/example.html b/packages/cli/demo/html/en/example.html new file mode 100644 index 000000000..a23a50d1d --- /dev/null +++ b/packages/cli/demo/html/en/example.html @@ -0,0 +1,82 @@ + + + + MyApp - Hello World + + + + + + + + + + + + +

    Welcome to MyApp

    + +

    + Hello, world! This is a simple demo with + bold text and + italic text. +

    + +
    +
    + Nested content here +
    +
    + + Example image + + Demo image + + + + + + Learn more + + + Go to page + + +
    + Content area +
    + + + + + + \ No newline at end of file diff --git a/packages/cli/demo/html/es/advanced-example.html b/packages/cli/demo/html/es/advanced-example.html new file mode 100644 index 000000000..b827ab47e --- /dev/null +++ b/packages/cli/demo/html/es/advanced-example.html @@ -0,0 +1,1101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Demostración maximalista de HTML5 + + + + + + + + + + + Ir al contenido principal + +
    +
    +
    +

    Demostración maximalista de HTML5

    +

    + Este archivo intenta cubrir una + amplia gama + de elementos, estructuras y atributos HTML modernos. No puede + incluir literalmente “todos los posibles” atributos, pero es una + referencia densa. +

    +

    + HTML5 + ARIA + Formularios + Medios + SVG + MathML +

    +
    + +
    + + +
    +
    +
    + +
    +
    + +
    +

    Texto, semántica y elementos en línea

    + +
    +
    +

    Encabezado del artículo

    +

    + + + +

    +
    + +

    + Párrafo con + énfasis + , + negrita fuerte + , + cursiva + , + negrita + , + subrayado + , + tachado + , + texto pequeño + , + resaltado + , + HTML + , + definición + , + cita en línea + , y una + citación + . +

    + +

    + Subíndices H + 2 + O, superíndices x + 2 + , y un span en línea: + texto en inglés + . Aquí hay + texto aislado + y + texto invertido + . +

    + +

    + Cosas relacionadas con código: + const x = 1; + , teclado: + Ctrl + + + C + , salida de muestra: + OK + , y variable + n + . +

    + +
    // Bloque de código preformateado
    +function hello(name){
    +  return `Hello, ${name}`;
    +}
    + +
    +

    + Cita en bloque con + una cita corta + anidada y atribución. +

    +
    + — + Alguien +
    +
    + +
    + +

    + Enlaces con atributos: + + enlace externo + + , + enlace de descarga + , + + mailto + + , + tel + . +

    + +
      +
    • Elemento de lista no ordenada
    • +
    • + Otro elemento con + ancla +
    • +
    + +
      +
    1. Elemento ordenado con valor personalizado
    2. +
    3. Siguiente elemento ordenado
    4. +
    + + +
  • +
  • +
    + +
    +
    Término A
    +
    Definición del término A.
    +
    Término B
    +
    Definición del término B.
    +
    + +
    + Imagen de marcador de posición +
    Figura + pie de figura.
    +
    + +
    + Detalles expandibles con resumen +

    + Contenido de detalles. Admite + más enlaces + . +

    +
    + +

    + Ejemplo de anotación Ruby: + + Texto + anotación + +

    + +
    +

    + Pie de artículo con + palabras clave, etiquetas, etc. +

    +
    +
    +
    + + +
    +

    Elementos de diseño y estructura

    + +
    +
    +

    Sección dentro de la cuadrícula

    +

    + Usando + section + para agrupación temática. +

    +
    + +
    +

    Artículo dentro de la cuadrícula

    +

    + Usando + article + para contenido independiente. +

    +
    + +
    +

    Div genérico + región ARIA

    +

    A veces un div está bien con los roles apropiados.

    +
    +
    + +

    + Contenedor en línea: + span + • contenedor de bloque: + +

    + + +
    + + +
    +

    Elementos multimedia

    + +

    Audio

    + + +

    Vídeo

    + + +

    Picture / imágenes adaptables

    + + + Alternativa adaptable + + +

    Iframe / Embed / Object

    + + + + + + Texto alternativo para objeto. + + +

    Canvas

    + + Texto alternativo de canvas. + + +
    + + +
    +

    SVG en línea

    + + + + + + + + + + + + + + + + + + + + + + Elementos SVG + + +
    + + +
    +

    MathML (si es compatible)

    + + + + a + 2 + + + + + b + 2 + + = + + c + 2 + + + +
    + + +
    +

    Formularios (muchos campos de entrada)

    + +
    +
    + Básicos + + + + + + + + + + + + + + + + + + + Texto de ayuda de búsqueda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Grupo de botones de opción +
    + + +
    + + + + + 5 + + + + +
    + +
    + Widgets avanzados + + + + 65% + + + + 45% + + + + + + + + + + + + + +
    +
    +
    + + +
    +

    Tablas

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Tabla compleja con colgroup, thead, tbody, tfoot
    NombreRolEstadoNotas
    AdaIngenieraActiva
    LinusMantenedorAusente + Nota antigua + Nota nueva +
    Fila de pie de página
    +
    + + +
    +

    + Elementos interactivos, incrustados y varios +

    + +

    Diálogo

    + + +

    Diálogo de demostración

    +

    + Elemento de diálogo nativo con + showModal() + . +

    +
    + +
    +
    + +

    Popover

    + +
    + Soy un popover. + +
    + +

    Divulgación / Resumen

    +
    + Abrir detalles +

    + Texto de detalles. Incluye + Esc + para cerrar el diálogo, etc. +

    +
    + +

    Oportunidades de salto de palabra

    +

    + + Oportunidades + + de + + salto + + de + + palabra. +

    + +

    Slots (preparado para Web Components)

    +
    + Contenido de slot alternativo +
    + +

    Lista de autocompletado (ul[role=listbox])

    +
      +
    • Sugerencia 1
    • +
    • Sugerencia 2
    • +
    + +

    Objetivo de navegación por teclado

    +
    + Botón div enfocable +
    + +

    Dirección / Contacto

    +
    + Calle Demo 123 +
    + Ciudad Ejemplo, EX 00000 +
    + contact@example.com +
    + +

    Anotación / Ediciones

    +

    + Esto está + eliminado + y + insertado + . +

    + +

    Ejemplo de aislamiento de texto

    +

    + Mezcla de texto: + segmento aislado + + inglés normal. +

    + +

    Noscript

    + +
    +
    + + +
    + +
    +

    + Pie de página con letra pequeña. + + © + + Demo. + +

    + +

    Volver arriba

    +
    + + +
    + + diff --git a/packages/cli/demo/html/es/example.html b/packages/cli/demo/html/es/example.html new file mode 100644 index 000000000..cc62ba6d5 --- /dev/null +++ b/packages/cli/demo/html/es/example.html @@ -0,0 +1,76 @@ + + + + MyApp - Hola mundo + + + + + + + + + + + + +

    Bienvenido a MyApp

    + +

    + ¡Hola, mundo! Esta es una demostración simple con + texto en negrita + y + texto en cursiva + . +

    + +
    +
    Contenido anidado aquí
    +
    + + Imagen de ejemplo + + Imagen de demostración + + + + + + Más información + + + Ir a la página + + +
    Área de contenido
    + + + + + + \ No newline at end of file diff --git a/packages/cli/demo/html/i18n.json b/packages/cli/demo/html/i18n.json new file mode 100644 index 000000000..7f8c70f99 --- /dev/null +++ b/packages/cli/demo/html/i18n.json @@ -0,0 +1,13 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": ["es"] + }, + "buckets": { + "html": { + "include": ["./[locale]/example.html", "./[locale]/advanced-example.html"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} diff --git a/packages/cli/demo/html/i18n.lock b/packages/cli/demo/html/i18n.lock new file mode 100644 index 000000000..36b570bed --- /dev/null +++ b/packages/cli/demo/html/i18n.lock @@ -0,0 +1,202 @@ +version: 1 +checksums: + ab95e8c959a889717f02a05af5c5b1e6: + head/0: 7d39787547365ee4194f29f3f54e5c05 + head/1#content: 49f8864eb0e53903f04532bf33e1e4fa + head/2#content: c2a1da93efb7e744d100df705e5fcbfd + head/3#content: d94b318cb327f61f1aea44a6cb1fdcad + body/0: d1c3a9f35e377554a4ccaa467ca26614 + body/1: 886bab34500cc953ed339a540fa15a01 + body/2/0: f1e6240e8bf6f6264d122322892f6f1b + body/3#alt: 68f95fca639f8bf72a4796b6734b02d5 + body/4#alt: cb7d920c3bbcade1c8e0307093f58573 + body/4#title: b065d13c12906a65ae9a80ad58f57804 + body/5#placeholder: a05ce3b4578f55e41bd2ad4964f966b4 + body/6#placeholder: a4554ed67c02872e302b0042724f859d + body/7#title: c903c6985a40ce02d65c90229de35a4e + body/7: e598091d132f890c37a6d4ed94f6d794 + body/8#title: d656021ba5f485fa1a82f8aac6ecc5de + body/8: 1c6856488bd34ad87fcacce2d8e66a0b + body/9: 862964e6cd73cdffdcac622406c6bac9 + ac0dd4d7f7007c5165a365b4f11e51e6: + head/1#content: 87c1cace5dae97b8d8e7e33935d38648 + head/2#content: b5e67d04e51169a06100e8073a2af399 + head/3#content: 6dd9850e2dd00fa90543d76492fac178 + head/4#content: 927eae1ba94d9613db75205352278538 + head/5#content: ca6d9e7477f55ac948c2d0e2392b19d3 + head/6#content: 3e695f9f6c1ac8162d0ad4b3c37e9923 + head/7#content: e0f5363c63a67f2001d65d98de31bec2 + head/8#content: 673b19ac81d26b874750872da85b69af + head/9#content: f5f171e3b792ae88ad4316b892ab4feb + head/10#content: f0cf9abfcc52ca0cb2d99bae3ee26afa + head/11#content: c5ba2f94feaae5f23020d53f1b87e2e7 + head/12#content: 300ff9c2ab7ad93ec799bd96f13268a1 + head/13#content: 63daa51677c557c1382d40be1c7d1f08 + head/14#content: 53d07ce1ee9351ace5220d1694f6c548 + head/15#content: 11ca112e48517b3addf6a156603308b7 + head/16#content: 3c7b20f91f8fa888969cc818b3925749 + head/19#content: d387d7423ccda42d2f7b9f6e0a75ee76 + head/20#content: 63daa51677c557c1382d40be1c7d1f08 + head/21#content: f14c6f672f70a6d464312134e3b13fcc + head/31#title: 76814904f99f5c708993a63933218b53 + head/32#title: 66b06156bbfc5878ac6593f289087de7 + head/36#title: 49dd6c21604b5e8d4153ff1aff2177e1 + head/39: 63daa51677c557c1382d40be1c7d1f08 + body/0: 4a7d27cf91a0270eb8843cf630013650 + body/1/0/0/0: 63daa51677c557c1382d40be1c7d1f08 + body/1/0/0/1: 62aeeef7dac63c8d6fe616a3cac9dcf6 + body/1/0/0/2: 179023c1a0b2826185d6e57ecde1f9f5 + body/1/0/1/0/0/0: f7760908c3fd8011002970228076f952 + body/1/0/1/0/0/1: bd7c665158280b3ee401a1f5e3eb3982 + body/1/0/1/0/0/2: ace03c3afa054f3b3273844f10e26fdf + body/1/0/1/0/0/3: b51febcc48512258f34262b89c43f3d6 + body/1/0/1/0/0/4: 7f2852d8b918329969657dc332d3f187 + body/1/0/1/1: dd03ee6b83901fc440f90236483dafc5 + body/2/0/0/0: 5002bf1bc4d510aa4b3c32e6942c2534 + body/2/0/0/1/0/0: 443a272585670118a9d2eb690b04068a + body/2/0/0/1/0/1: 005ed361f9983cec5c7fcfaa950aa47e + body/2/0/0/1/1: f1085711ee61a2e9f3828092aa1fd78d + body/2/0/0/1/2: 48cf08e6817a9b025211ba452dbf69ef + body/2/0/0/1/3: b6be1118d8495fe2ae3743a0b88ba697 + body/2/0/0/1/4: c9e53f98f2838da2bf1fb68f74a5bca9 + body/2/0/0/1/5/0: 7bc30455d723b29e3330af0e06f97d00 + body/2/0/0/1/5/1: 3e8eb8d10f92a2781cc1bd5022d6e4a8 + body/2/0/0/1/7: 5dccad892e0e190636fa2bdc389c4c8b + body/2/0/0/1/8/0: 7c633df96da956f320e3ee5923deee7a + body/2/0/0/1/8/1: 284f7f0a4daf83467dd94ffaf26b75eb + body/2/0/0/1/9/0: d16c67c5a6cd69f37c6d2ec1d41bba67 + body/2/0/0/1/9/1: a04b153d6606549db374683f17de8d74 + body/2/0/0/1/10/0: ada6d7eab6bcebbf0bc4423595414385 + body/2/0/0/1/10/1: 5b75280dafa40a7da2baa8bc701bca49 + body/2/0/0/1/11/0: b200e55c8532fbad3dd1373a188e7fc1 + body/2/0/0/1/11/1: 8665a3a1768b8e41732211ba9bfaa125 + body/2/0/0/1/11/2: 2a78f643c64664457b73ad92ac20baed + body/2/0/0/1/11/3: a5af6aa8e725eb77a06b1e9414d146b5 + body/2/0/0/1/12/0#alt: 74efadaed0d05c3a5905f7e4f950645c + body/2/0/0/1/12/1: 1b876723babaccb49d137f8ea6fee7a2 + body/2/0/0/1/13/0: 39daa202d315989cd33f42148d15c37d + body/2/0/0/1/13/1: 7ce3b9df4a17edfa91c9a184c9acb1e3 + body/2/0/0/1/14: bfa06f332273214d53c9d5098326e80c + body/2/0/0/1/15/0: ca92010bbf04f01955b23a9831e64195 + body/2/0/1/0: 1a5f7e07b06eea3b94956b037ee0fca9 + body/2/0/1/1/0/0: cd9a263db2efa2b80c14a5a6767e5158 + body/2/0/1/1/0/1: 788ed526bc172610d71c19a77a157962 + body/2/0/1/1/1/0: 8d000d3f6b2fc6bc4e6fd14136acddf8 + body/2/0/1/1/1/1: 2d57000ccd0ab4d4d7e4e3089dccbe65 + body/2/0/1/1/2/0: bf0f878a2030ec1e1c405073a78f7ca9 + body/2/0/1/1/2/1: 1682cf1d95f5defb44e67d37cd17a642 + body/2/0/1/2: baf7300216bbed89ca80cb784f7ef2a7 + body/2/0/1/3/0: 0923275764252dfefc577961f16d33c7 + body/2/0/2/0: 7e80747687b90e33d43e1ecfb8c2b624 + body/2/0/2/1: 2be95a0576c5f2dc8c4359afa6cac340 + body/2/0/2/2: 22c7c4d2bca99ae95cf2a41515c74c4d + body/2/0/2/3: 8050c90e4289b105a0780f0fdda6ff66 + body/2/0/2/4: c1a5269f77b04ec5f2918e45c0db66df + body/2/0/2/5: 32f3449aa6a3bd8b5af54691b794532f + body/2/0/2/6/1#alt: 80a3917af41089617410b43e39573a8a + body/2/0/2/7: 05cb2a340027ff4ccd398cfaebfc1469 + body/2/0/2/10: 71002a0cda82d0a2d2df8960e8fefb7b + body/2/0/2/11: 05eb2940365d3e06a2b1d05aba7f13e5 + body/2/0/2/12: 9c98ee8c22dede522efea5629790e428 + body/2/0/3/0: 077294fc58d2b680c87d31926ff38646 + body/2/0/3/1: 434f13dd5ab41ae16a1771cbe3a19ec9 + body/2/0/4/0: 4b8762309a29ceb0e6abe037f47b4cfe + body/2/0/4/1: 134d0dd47418ae278431d8feb077a01e + body/2/0/5/0: 2ef494d69144fcb05bcb6db6c79e55ca + body/2/0/5/1/0/0: 1f5b53b9904ec41d49c1e726e3d56b40 + body/2/0/5/1/0/1: 4ddccc1974775ed7357f9beaf9361cec + body/2/0/5/1/0/2#placeholder: f01e599cced8b7d7105329947b5096de + body/2/0/5/1/0/3: 223a61cf906ab9c40d22612c588dff48 + body/2/0/5/1/0/5: e7f34943a0c2fb849db1839ff6ef5cb5 + body/2/0/5/1/0/6#placeholder: 6b0c96a86624c61fd6a35719b9a32704 + body/2/0/5/1/0/7: ca97457614226960d41dd18c3c29c86b + body/2/0/5/1/0/8#placeholder: 4bedfc5b02cbff3d37abc3f359f91e20 + body/2/0/5/1/0/9: e59677c4302af38259384c09c5d26025 + body/2/0/5/1/0/10#placeholder: 565d39c67ccaab5c4aff14175de34e3c + body/2/0/5/1/0/11: 49dd6c21604b5e8d4153ff1aff2177e1 + body/2/0/5/1/0/13: 7d6b3b3a3630f607188ee3b2c8687f03 + body/2/0/5/1/0/14: 2789f8391f63e7200a5521078aab017d + body/2/0/5/1/0/16: 1fad969ecf3de1c21df046b93053c422 + body/2/0/5/1/0/18: 9d53d1d120e8b8954bcae9a322573748 + body/2/0/5/1/0/20: 56f41c5d30a76295bb087b20b7bee4c3 + body/2/0/5/1/0/22: 863b74b1cdcbf3b04ea79e679b37e29e + body/2/0/5/1/0/24: ae7bef950efc406ff0980affabc1a64c + body/2/0/5/1/0/26: 436fdd694160827dd6ea4644cdd0a8f8 + body/2/0/5/1/0/28: b504a03d52e8001bfdc5cb6205364f42 + body/2/0/5/1/0/30: 1255b55897f2be1443d3bb8c30cd9795 + body/2/0/5/1/0/32: 76aec20f1efb4b47bcbd3a7c7a2b4e66 + body/2/0/5/1/0/33#placeholder: 2b62a3c0a68a4accee7908e235624592 + body/2/0/5/1/0/34: 5ac04c47a98deb85906bc02e0de91ab0 + body/2/0/5/1/0/35: 74c61b6d3ef5b5d239fa9f768a685156 + body/2/0/5/1/0/36: dd968cccbc2145b56bba3c9524f3f2d3 + body/2/0/5/1/0/37#placeholder: e99cba5fc891cc38d237399cbc479338 + body/2/0/5/1/0/39: 1ec22abec772e8f5edc8e598d56d3843 + body/2/0/5/1/0/40: 0ff9caa359ca0fd34c0e2cb0c5d01174 + body/2/0/5/1/0/41: b77f79143d1c352333a47ea419a69fbe + body/2/0/5/1/0/45: 7c91ef5f747eea9f77a9c4f23e19fb2e + body/2/0/5/1/0/46: c19c53a05e21c7f3b395247809a14b51 + body/2/0/5/1/0/47: 0889a3dfd914a7ef638611796b17bf72 + body/2/0/5/1/1/0: da7ca00f480ee314c2896801ef429ea4 + body/2/0/5/1/1/1: 4f2e381ebbefcacd811c0e21771163c9 + body/2/0/5/1/1/3: dd0200d5849ebb7d64c15098ae91d229 + body/2/0/5/1/1/5: d9837510f236ac243e08cbe9c3127da8 + body/2/0/5/1/1/8: 2ac0c6681366b8e4046c6960a14e1504 + body/2/0/5/1/1/10: c2bdcc937b357934ac9662d78d852af9 + body/2/0/5/1/1/12: 4a00dd4d2e73d834ee18d21f5f3650c5 + body/2/0/5/1/1/13: 8f1d81036e38b5e5753cb6546385b924 + body/2/0/6/0: 6653280e32b1c41add771bafa8000591 + body/2/0/6/1/0: d713fc8f199710574212799de1a5fb73 + body/2/0/6/1/2/0/0: 9368b5a047572b6051f334af5aa76819 + body/2/0/6/1/2/0/1: 53743bbb6ca938f5b893552e839d067f + body/2/0/6/1/2/0/2: 4e1fcce15854d824919b4a582c697c90 + body/2/0/6/1/2/0/3: 93c7ef9bc767f51fbe360afcf6b1fecb + body/2/0/6/1/3/0/0: 8d0d788100b42b3ea4e2c45076d1f148 + body/2/0/6/1/3/0/1: b4997d4e5d7c31b1fd8cfbfbba46e013 + body/2/0/6/1/3/0/2: c88138191fb4c14df9ae9f0534aca2ce + body/2/0/6/1/3/1/0: 2e43f51640039df53b343775f0cbdb61 + body/2/0/6/1/3/1/1: 7cfc2a0845ce7ae0ca09301af5ea8a22 + body/2/0/6/1/3/1/2: 82541f089723e16a17d85257a99092b6 + body/2/0/6/1/3/1/3: e84647e929178e65607ee7c1a897b0ab + body/2/0/6/1/4/0/0: 6480370194a4e970842b1a2ef18242a1 + body/2/0/7/0: a11facce8a11ec0809318094e4c931b1 + body/2/0/7/1: c64bd9eaec3f9bcbaf3e7cde56f4aaa2 + body/2/0/7/2: 6e32275e2e75b92a2c28d574d5279c41 + body/2/0/7/3/0: 204a0fc63eb7687c7be7ef8f0a7f42e3 + body/2/0/7/3/1: 3ff961ca447b1200b77ce0e968b31b6f + body/2/0/7/3/2: 0aff1b07d3a76664629413bcc5365fd2 + body/2/0/7/4: 58978e420f4fe37dfefeea3007d05982 + body/2/0/7/5: 38604c9ea19fdd3097e6915d907c0902 + body/2/0/7/6: 64a9c53d57d2f520e05a0b9a491e80a1 + body/2/0/7/7: 037e3f80ad829da941505d199c3660c6 + body/2/0/7/8/0: 3f9078eef0892916d54547dea2337bfc + body/2/0/7/8/1: c44ec16967268516fd51c1ac7c4281c0 + body/2/0/7/9: 14243c62624a4da5553219d5b8a7f284 + body/2/0/7/10: 67ca49294d4f0b92b9b499c1b625dde0 + body/2/0/7/11: 403e3d14286ae86f196e1e96babd8b4d + body/2/0/7/12: 1a04cb30e2858160715e8167f74daa51 + body/2/0/7/13: 331efa9c094bc8ab9b9eec272fcf056c + body/2/0/7/14/0: 787c096af6c141c0efdd867487fc0811 + body/2/0/7/14/1: 82ad9a00332179807639f947a7751900 + body/2/0/7/15: 42db774092f308bc767a759b10a1da90 + body/2/0/7/16: c9d3b9f6b347f0a666ea2338e194dd92 + body/2/0/7/17: ba053a2cc321c9c7a059fea95b7f9bbd + body/2/0/7/18: d68fcfba0918369df6d7a7b8b21ee22b + body/2/0/7/19: f1f64bb5cc22eed6d922323eaf53a7a5 + body/2/0/7/20: 2dab542206ca1c9b37525f6227cda092 + body/2/0/7/21: b63348201e09a0bd3f3136ca4ecf7c8c + body/2/0/7/22: 992468e15234bb249d8dc9b890808ad2 + body/2/0/7/23: c879f1245e558be55db381e4d4cda3a8 + body/2/0/7/24: bca225b0f8c02210afd4df4ed922361b + body/2/1/0: c991b6a9fd74c24e18b224146036bbee + body/2/1/1/0: 49dd6c21604b5e8d4153ff1aff2177e1 + body/2/1/1/1: ea75c9ebe0551fa039857cff8f9805c4 + body/2/1/2/0: 6030e344bc8108f192eec8d7e1bd5ef9 + body/2/1/2/1/0/0: 252cd97ccaa452fbf52c8ece86cc6357 + body/2/1/2/1/0/1: 9dc6fc5838c4781ae16511b428edc580 + body/2/1/2/1/0/2: 0fcd8169cfcb7a986e5f5bc8e0304179 + body/2/1/2/1/0/3: c2c11312a3f6c6f1ca8285a68777b3c5 + body/2/1/3/0: 129891f5ec1aba3e82dc4e9b577cdba8 + body/2/1/4/0: b504a03d52e8001bfdc5cb6205364f42 + body/2/1/4/1: 7eac03268a7a1f9c7c838f8d848240e3 + body/3/0: 5d4ada1ab4dda0067307ed27db717f55 + body/3/1: 327405ed3e27fd2c1781b18f71fb5219 diff --git a/packages/cli/demo/json-dictionary/example.json b/packages/cli/demo/json-dictionary/example.json new file mode 100644 index 000000000..11a06784e --- /dev/null +++ b/packages/cli/demo/json-dictionary/example.json @@ -0,0 +1,44 @@ +{ + "navigation": { + "en": "Home", + "es": "Inicio" + }, + "buttons": { + "submit": { + "en": "Submit", + "es": "Enviar" + }, + "cancel": { + "en": "Cancel", + "es": "Cancelar" + } + }, + "messages": { + "welcome": { + "en": "Welcome to our application", + "es": "Bienvenido a nuestra aplicación" + }, + "error": { + "en": "An error occurred", + "es": "Se produjo un error" + } + }, + "forms": { + "login": { + "title": { + "en": "Login", + "es": "Iniciar sesión" + }, + "fields": { + "username": { + "en": "Username", + "es": "Nombre de usuario" + }, + "password": { + "en": "Password", + "es": "Contraseña" + } + } + } + } +} diff --git a/packages/cli/demo/json-dictionary/i18n.json b/packages/cli/demo/json-dictionary/i18n.json new file mode 100644 index 000000000..b781a275c --- /dev/null +++ b/packages/cli/demo/json-dictionary/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "json-dictionary": { + "include": [ + "./example.json" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} diff --git a/packages/cli/demo/json-dictionary/i18n.lock b/packages/cli/demo/json-dictionary/i18n.lock new file mode 100644 index 000000000..cacb01d5d --- /dev/null +++ b/packages/cli/demo/json-dictionary/i18n.lock @@ -0,0 +1,20 @@ +version: 1 +checksums: + 455da9346f4e772000927cd2ff5bb898: + navigation: 104a3db3b671c04e167eafbe21e57881 + buttons/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e + buttons/cancel: 2e2a849c2223911717de8caa2c71bade + messages/welcome: 1308168cca4fa5d8d7a0cf24e55e93fc + messages/error: 53a2b5f5e7d83c737c8e02fe18fb4bdb + forms/login/title: f4f219abeb5a465ecb1c7efaf50246de + forms/login/fields/username: 2ee65bc2dd2f12cf2672f95b2a054bf8 + forms/login/fields/password: 223a61cf906ab9c40d22612c588dff48 + a8f80a1a4e0e0aa2750e514a8a6abacf: + navigation: 104a3db3b671c04e167eafbe21e57881 + buttons/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e + buttons/cancel: 2e2a849c2223911717de8caa2c71bade + messages/welcome: 1308168cca4fa5d8d7a0cf24e55e93fc + messages/error: 53a2b5f5e7d83c737c8e02fe18fb4bdb + forms/login/title: f4f219abeb5a465ecb1c7efaf50246de + forms/login/fields/username: 2ee65bc2dd2f12cf2672f95b2a054bf8 + forms/login/fields/password: 223a61cf906ab9c40d22612c588dff48 diff --git a/packages/cli/demo/json/en/example.json b/packages/cli/demo/json/en/example.json new file mode 100644 index 000000000..1d3897957 --- /dev/null +++ b/packages/cli/demo/json/en/example.json @@ -0,0 +1,37 @@ +{ + "title": "Hello, world!", + "description": "A simple demo app", + "version": "1.0.0", + "support_email": "support@example.com", + "homepage": "https://lingo.dev", + "deprecated": null, + "empty": "", + "emoji": "🚀", + "author": { + "name": "John Doe" + }, + "contributors": [ + { "name": "Alice" }, + { "name": "Bob" } + ], + "messages": [ + "Welcome to MyApp", + "Hello, world!" + ], + "config": { + "theme": { + "primary": "Blue theme" + } + }, + "mixed_array": [ + "Mixed content here", + 42, + true, + { + "nested_message": "Nested text" + } + ], + "locked_key_1": "This value is locked and should not be changed", + "ignored_key_1": "This value is ignored and should not appear in target locales" + +} \ No newline at end of file diff --git a/packages/cli/demo/json/es/example.json b/packages/cli/demo/json/es/example.json new file mode 100644 index 000000000..57d82fcd0 --- /dev/null +++ b/packages/cli/demo/json/es/example.json @@ -0,0 +1,36 @@ +{ + "title": "¡Hola, mundo!", + "description": "Una aplicación de demostración simple", + "version": "1.0.0", + "support_email": "support@example.com", + "homepage": "https://lingo.dev", + "deprecated": null, + "empty": "", + "emoji": "🚀", + "author": { + "name": "John Doe" + }, + "contributors": [ + { + "name": "Alice" + }, + { + "name": "Bob" + } + ], + "messages": ["Bienvenido a MyApp", "¡Hola, mundo!"], + "config": { + "theme": { + "primary": "Tema azul" + } + }, + "mixed_array": [ + "Contenido mixto aquí", + 42, + true, + { + "nested_message": "Texto anidado" + } + ], + "locked_key_1": "This value is locked and should not be changed" +} \ No newline at end of file diff --git a/packages/cli/demo/json/i18n.json b/packages/cli/demo/json/i18n.json new file mode 100644 index 000000000..16d5900be --- /dev/null +++ b/packages/cli/demo/json/i18n.json @@ -0,0 +1,18 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "json": { + "include": [ + "./[locale]/example.json" + ], + "lockedKeys": ["locked_key_1"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/json/i18n.lock b/packages/cli/demo/json/i18n.lock new file mode 100644 index 000000000..c394f1891 --- /dev/null +++ b/packages/cli/demo/json/i18n.lock @@ -0,0 +1,16 @@ +version: 1 +checksums: + 455da9346f4e772000927cd2ff5bb898: + title: 0468579ef2fbc83c9d520c2f2f1c5059 + description: 49f8864eb0e53903f04532bf33e1e4fa + version: 54a9e730e88fb16291b852274d433923 + support_email: 10627fcc465897af0f5e1bba042685f9 + emoji: b328c432cee108a87a92f05258b6a651 + author/name: febee8e9ab40b2fe5106d72675228d00 + contributors/0/name: e80d4063a32adaad7b0a82b0bcc10551 + contributors/1/name: b2bca2fa3c890618e56d07473f26ead3 + messages/0: d1c3a9f35e377554a4ccaa467ca26614 + messages/1: 0468579ef2fbc83c9d520c2f2f1c5059 + config/theme/primary: 7535a3779d6934ea8ecf18f5cb5b93fd + mixed_array/0: 001b5b003d96c133534f5907abffdf77 + mixed_array/3/nested_message: 5f0782dfc5993e99890c0475bc295a30 diff --git a/packages/cli/demo/json5/en/example.json5 b/packages/cli/demo/json5/en/example.json5 new file mode 100644 index 000000000..5f21bd18e --- /dev/null +++ b/packages/cli/demo/json5/en/example.json5 @@ -0,0 +1,47 @@ +{ + // JSON5 allows comments! + title: "Hello, world!", + description: "A simple demo app with JSON5 features", + version: "1.0.0", + support_email: "support@example.com", + homepage: "https://lingo.dev", + deprecated: null, + empty: "", + emoji: "🚀", + + // Unquoted keys are allowed + author: { + name: "John Doe" + }, + + contributors: [ + { name: "Alice" }, + { name: "Bob" } + ], + + messages: [ + "Welcome to MyApp", + "Hello, world!" + ], + + config: { + theme: { + primary: "Blue theme" + } + }, + + mixed_array: [ + "Mixed content here", + 42, + true, + { + nested_message: "Nested text" + } + ], + + // Hexadecimal numbers work in JSON5 + hex_value: 0xDEADBEEF, + + // Trailing commas are allowed + locked_key_1: "This value is locked and should not be changed", +} \ No newline at end of file diff --git a/packages/cli/demo/json5/es/example.json5 b/packages/cli/demo/json5/es/example.json5 new file mode 100644 index 000000000..1f77c5ef6 --- /dev/null +++ b/packages/cli/demo/json5/es/example.json5 @@ -0,0 +1,40 @@ +{ + title: '¡Hola, mundo!', + description: 'Una aplicación de demostración simple con características JSON5', + version: '1.0.0', + support_email: 'support@example.com', + homepage: 'https://lingo.dev', + deprecated: null, + empty: '', + emoji: '🚀', + author: { + name: 'John Doe', + }, + contributors: [ + { + name: 'Alice', + }, + { + name: 'Bob', + }, + ], + messages: [ + 'Bienvenido a MyApp', + '¡Hola, mundo!', + ], + config: { + theme: { + primary: 'Tema azul', + }, + }, + mixed_array: [ + 'Contenido mixto aquí', + 42, + true, + { + nested_message: 'Texto anidado', + }, + ], + hex_value: 3735928559, + locked_key_1: 'This value is locked and should not be changed', +} \ No newline at end of file diff --git a/packages/cli/demo/json5/i18n.json b/packages/cli/demo/json5/i18n.json new file mode 100644 index 000000000..e941b3971 --- /dev/null +++ b/packages/cli/demo/json5/i18n.json @@ -0,0 +1,18 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "json5": { + "include": [ + "./[locale]/example.json5" + ], + "lockedKeys": ["locked_key_1"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/json5/i18n.lock b/packages/cli/demo/json5/i18n.lock new file mode 100644 index 000000000..2e4528177 --- /dev/null +++ b/packages/cli/demo/json5/i18n.lock @@ -0,0 +1,17 @@ +version: 1 +checksums: + 455da9346f4e772000927cd2ff5bb898: + title: 0468579ef2fbc83c9d520c2f2f1c5059 + description: 6f4922f45568161a8cdf4ad2299f6d23 + version: 54a9e730e88fb16291b852274d433923 + support_email: 10627fcc465897af0f5e1bba042685f9 + emoji: b328c432cee108a87a92f05258b6a651 + author/name: febee8e9ab40b2fe5106d72675228d00 + contributors/0/name: e80d4063a32adaad7b0a82b0bcc10551 + contributors/1/name: b2bca2fa3c890618e56d07473f26ead3 + messages/0: d1c3a9f35e377554a4ccaa467ca26614 + messages/1: 0468579ef2fbc83c9d520c2f2f1c5059 + config/theme/primary: 7535a3779d6934ea8ecf18f5cb5b93fd + mixed_array/0: 001b5b003d96c133534f5907abffdf77 + mixed_array/3/nested_message: 5f0782dfc5993e99890c0475bc295a30 + hex_value: a1b2c3d4e5f6789012345678abcdef01 \ No newline at end of file diff --git a/packages/cli/demo/jsonc/en/example.jsonc b/packages/cli/demo/jsonc/en/example.jsonc new file mode 100644 index 000000000..d3b8eb626 --- /dev/null +++ b/packages/cli/demo/jsonc/en/example.jsonc @@ -0,0 +1,21 @@ +{ + "key1": "Hello, world!", // This is a comment for key1 + "key2": "A simple demo app with JSONC features" /* This is a comment for key2 */, + // This is a comment for key3 + "key3": "1.0.0", + /* This is a block comment for key4 */ + "key4": "support@example.com", + /* + This is a comment for key5 + */ + "key5": "🚀", + // This is a comment for key6 + "key6": { + // This is a comment for key7 + "key7": "Nested value", + }, + // This key is locked and should not be changed + "locked_key_1": "This value is locked and should not be changed", + // This key is ignored and should be removed from target locales + "ignored_key_1": "This value is ignored and should not appear in target locales", +} diff --git a/packages/cli/demo/jsonc/es/example.jsonc b/packages/cli/demo/jsonc/es/example.jsonc new file mode 100644 index 000000000..e25004874 --- /dev/null +++ b/packages/cli/demo/jsonc/es/example.jsonc @@ -0,0 +1,11 @@ +{ + "key1": "¡Hola, mundo!", + "key2": "Una aplicación de demostración simple con funciones JSONC", + "key3": "1.0.0", + "key4": "support@example.com", + "key5": "🚀", + "key6": { + "key7": "Valor anidado" + }, + "locked_key_1": "This value is locked and should not be changed" +} \ No newline at end of file diff --git a/packages/cli/demo/jsonc/i18n.json b/packages/cli/demo/jsonc/i18n.json new file mode 100644 index 000000000..4ec504484 --- /dev/null +++ b/packages/cli/demo/jsonc/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "jsonc": { + "include": [ + "./[locale]/example.jsonc" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} diff --git a/packages/cli/demo/jsonc/i18n.lock b/packages/cli/demo/jsonc/i18n.lock new file mode 100644 index 000000000..bccbc1ac7 --- /dev/null +++ b/packages/cli/demo/jsonc/i18n.lock @@ -0,0 +1,16 @@ +version: 1 +checksums: + 455da9346f4e772000927cd2ff5bb898: + title: 0468579ef2fbc83c9d520c2f2f1c5059 + description: 6f4922f45568161a8cdf4ad2299f6d23 + version: 54a9e730e88fb16291b852274d433923 + support_email: 10627fcc465897af0f5e1bba042685f9 + emoji: b328c432cee108a87a92f05258b6a651 + author/name: febee8e9ab40b2fe5106d72675228d00 + contributors/0/name: e80d4063a32adaad7b0a82b0bcc10551 + contributors/1/name: b2bca2fa3c890618e56d07473f26ead3 + messages/0: d1c3a9f35e377554a4ccaa467ca26614 + messages/1: 0468579ef2fbc83c9d520c2f2f1c5059 + config/theme/primary: 7535a3779d6934ea8ecf18f5cb5b93fd + mixed_array/0: 001b5b003d96c133534f5907abffdf77 + mixed_array/3/nested_message: 5f0782dfc5993e99890c0475bc295a30 \ No newline at end of file diff --git a/packages/cli/demo/jsonc/ru/example.jsonc b/packages/cli/demo/jsonc/ru/example.jsonc new file mode 100644 index 000000000..ef7c21394 --- /dev/null +++ b/packages/cli/demo/jsonc/ru/example.jsonc @@ -0,0 +1,11 @@ +{ + "key1": "Привет, мир!", + "key2": "Простое демонстрационное приложение с функциями JSONC", + "key3": "1.0.0", + "key4": "support@example.com", + "key5": "🚀", + "key6": { + "key7": "Вложенное значение" + }, + "locked_key_1": "This value is locked and should not be changed" +} \ No newline at end of file diff --git a/packages/cli/demo/markdoc/en/example.markdoc b/packages/cli/demo/markdoc/en/example.markdoc new file mode 100644 index 000000000..7ce519936 --- /dev/null +++ b/packages/cli/demo/markdoc/en/example.markdoc @@ -0,0 +1,142 @@ +--- +title: Introduction to Markdoc +description: A comprehensive demonstration of Markdoc features and syntax +author: Documentation Team +version: 1.0 +--- + +# Introduction to Markdoc + +Welcome to this **Markdoc demonstration** document. This file showcases various Markdoc features and syntax. + +{% callout type="note" %} +This is a sample document demonstrating Markdoc capabilities. +{% /callout %} + +## Basic Formatting + +Markdoc supports standard Markdown features: + +1. Ordered lists +2. Unordered lists +3. Text formatting like *italics* and **bold** + +### Emphasis and Inline Code + +You can emphasize text with various styles: + +- Regular text +- *Italic text* +- **Bold text** +- `inline code` + +{% callout type="warning" %} +Remember to test your documents before publishing. +{% /callout %} + +## Custom Tags {% #tags .section-highlight %} + +Markdoc extends Markdown with custom tags: + +{% table %} +* Feature +* Description +--- +* Tags +* Custom components using {% icon name="tag" /%} syntax +--- +* Variables +* Dynamic content with {% $variableName %} +--- +* Attributes +* IDs and classes for styling +{% /table %} + +### Code Examples + +Here's a sample code block: + +```javascript +function greet(name) { + return `Hello, ${name}!`; +} + +console.log(greet('World')); +``` + +{% if $showExample %} +This conditional content only appears when the variable is set. +{% /if %} + +### Configuration {% .code-section %} + +Example configuration file: + +```json +{ + "version": 1.0, + "settings": { + "enabled": true, + "options": ["option1", "option2"] + } +} +``` + +## Document Structure + +Good documentation follows clear organization: + +{% list %} +1. **Clear headings**: Use hierarchical structure +2. **Descriptive titles**: Make sections easy to find +3. **Examples**: Include practical demonstrations +4. **Consistency**: Maintain uniform formatting +{% /list %} + +## Advanced Syntax + +{% tabs %} +{% tab title="Variables" %} +Markdoc supports variables for dynamic content using the syntax {% $variableName %}. +{% /tab %} + +{% tab title="Conditionals" %} +Use conditional tags to show or hide content based on variables. +{% /tab %} + +{% tab title="Attributes" %} +Add IDs and classes to elements for styling and anchoring. +{% /tab %} +{% /tabs %} + +## Common Patterns + +{% accordion %} +{% item title="How do I use variables?" %} +Variables are inserted using the {% $variableName %} syntax and can be defined in your configuration. +{% /item %} + +{% item title="What are custom tags?" %} +Custom tags extend Markdoc with component functionality using {% tagName /%} or {% tagName %}...{% /tagName %} syntax. +{% /item %} + +{% item title="Can I nest tags?" %} +Yes, tags can be nested within other tags to create complex layouts. +{% /item %} +{% /accordion %} + +## Sample Data + +{% stats %} +- **{% $itemCount %}** items in collection +- **{% $pageCount %}** pages of documentation +- **{% $updateTime %}** last updated +{% /stats %} + +--- + +*This is a demonstration document* showing various {% link href="/docs" %}Markdoc features{% /link %} in action. + +**Need help?** Refer to the official documentation for detailed information. + +{% badge color="blue" %}Example{% /badge %} This document demonstrates typical Markdoc usage patterns. \ No newline at end of file diff --git a/packages/cli/demo/markdoc/es/example.markdoc b/packages/cli/demo/markdoc/es/example.markdoc new file mode 100644 index 000000000..9c33f115d --- /dev/null +++ b/packages/cli/demo/markdoc/es/example.markdoc @@ -0,0 +1,141 @@ +--- +title: Introducción a Markdoc +description: Una demostración completa de las funcionalidades y sintaxis de Markdoc +author: Equipo de documentación +--- + +# Introducción a Markdoc + +Bienvenido a este documento de **demostración de Markdoc**. Este archivo muestra varias características y sintaxis de Markdoc. + +{% callout type="note" %} +Este es un documento de ejemplo que demuestra las capacidades de Markdoc. +{% /callout %} + +## Formato básico + +Markdoc admite características estándar de Markdown: + +1. Listas ordenadas +1. Listas desordenadas +1. Formato de texto como *cursiva* y **negrita** + +### Énfasis y código en línea + +Puedes enfatizar el texto con varios estilos: + +- Texto normal +- *Texto en cursiva* +- **Texto en negrita** +- `inline code` + +{% callout type="warning" %} +Recuerda probar tus documentos antes de publicarlos. +{% /callout %} + +## Etiquetas personalizadas{% #tags .section-highlight %} + +Markdoc extiende Markdown con etiquetas personalizadas: + +{% table %} +- Característica +- Descripción +--- +- Etiquetas +- Componentes personalizados usando {% icon name="tag" /%} sintaxis +--- +- Variables +- Contenido dinámico con {% $variableName %} +--- +- Atributos +- IDs y clases para estilos +{% /table %} + +### Ejemplos de código + +Aquí hay un bloque de código de ejemplo: + +```javascript +function greet(name) { + return `Hello, ${name}!`; +} + +console.log(greet('World')); +``` + +{% if $showExample %} +Este contenido condicional solo aparece cuando la variable está configurada. +{% /if %} + +### Configuración {% .code-section %} + +Archivo de configuración de ejemplo: + +```json +{ + "version": 1.0, + "settings": { + "enabled": true, + "options": ["option1", "option2"] + } +} +``` + +## Estructura del documento + +Una buena documentación sigue una organización clara: + +{% list %} +1. **Encabezados claros**: usa estructura jerárquica +1. **Títulos descriptivos**: haz que las secciones sean fáciles de encontrar +1. **Ejemplos**: incluye demostraciones prácticas +1. **Consistencia**: mantén un formato uniforme +{% /list %} + +## Sintaxis avanzada + +{% tabs %} +{% tab title="Variables" %} +Markdoc admite variables para contenido dinámico usando la sintaxis {% $variableName %}. +{% /tab %} + +{% tab title="Conditionals" %} +Usa etiquetas condicionales para mostrar u ocultar contenido según las variables. +{% /tab %} + +{% tab title="Attributes" %} +Añade IDs y clases a los elementos para estilos y anclaje. +{% /tab %} +{% /tabs %} + +## Patrones comunes + +{% accordion %} +{% item title="How do I use variables?" %} +Las variables se insertan usando la sintaxis {% $variableName %} y pueden definirse en tu configuración. +{% /item %} + +{% item title="What are custom tags?" %} +Las etiquetas personalizadas extienden Markdoc con funcionalidad de componentes usando la sintaxis {% tagName /%} o {% tagName %}...{% /tagName %}. +{% /item %} + +{% item title="Can I nest tags?" %} +Sí, las etiquetas pueden anidarse dentro de otras etiquetas para crear diseños complejos. +{% /item %} +{% /accordion %} + +## Datos de ejemplo + +{% stats %} +- **{% $itemCount %}** elementos en la colección +- **{% $pageCount %}** páginas de documentación +- **{% $updateTime %}** última actualización +{% /stats %} + +--- + +*Este es un documento de demostración* que muestra varias {% link href="/docs" %}funcionalidades de Markdoc{% /link %} en acción. + +**¿Necesitas ayuda?** Consulta la documentación oficial para información detallada. + +{% badge color="blue" %}Ejemplo{% /badge %} Este documento demuestra patrones de uso típicos de Markdoc. \ No newline at end of file diff --git a/packages/cli/demo/markdoc/i18n.json b/packages/cli/demo/markdoc/i18n.json new file mode 100644 index 000000000..034d28748 --- /dev/null +++ b/packages/cli/demo/markdoc/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "markdoc": { + "include": [ + "./[locale]/example.markdoc" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/markdoc/i18n.lock b/packages/cli/demo/markdoc/i18n.lock new file mode 100644 index 000000000..c780d250b --- /dev/null +++ b/packages/cli/demo/markdoc/i18n.lock @@ -0,0 +1,78 @@ +version: 1 +checksums: + 829674e11a9003549b66978e9b9c259a: + heading-0: e19079d431e6b75a7d74e9c35639ea5a + paragraph-0: 6757b797fc6d8c5ec5628f2619274e28 + paragraph-1: cc37b30c1aca11f64dc096264761368e + paragraph-2: 50e2b92b9e711ae7758f8edcc1767102 + paragraph-3: 7bc573b304cf3a3414dd60f85eea4e36 + heading-1: 80779abd23399c676227609057093235 + paragraph-4: dfd87f4dde3d3e12fa6781966432465f + item-0: ad9b7460be42c194e4771cf5281d5fd0 + item-1: 7d67473952061a571de034013964c5b6 + item-2: 91cffca4db2b901a3a0310ca2ab17ed9 + item-3: 56b97b82cc030c511847d1850af088a0 + item-4: 05dc0a811ee5b04941c826293471367d + item-5: b1701133f7e62467e354a5fc526ea951 + heading-2: ff6e4ea57280ef5a42ebbe2cf9e3273b + paragraph-5: 3e11ad608af106c15eed82d0e5d07712 + item-6: 2b1ab56131399aab1842fc3a81dd170e + item-7: 8aedf70e351d5e5bb41ad66ad5ad18d8 + item-8: 1d0eccad0413a69da0d45c481aa754e9 + paragraph-6: e2a208aa62fd61821848519fa2abc211 + heading-3: 2761eb3f80a7da53d2cd899f33641e98 + paragraph-7: f341db1f3167bd074937f4399c3a1158 + th-0: 58f5f3f37862b6312a2f20ec1a1fd0e8 + th-1: e17686a22ffad04cc7bb70524ed4478b + td-0: 97813c8ae67d69575fd04e35a88aed0c + td-1: 117451569b718867c43b26d9ee3c4e8f + td-2: e801c1eaca53c3aa702b747ed750fdd1 + td-3: ffd3eec5497af36d7b4e4185bad1313a + td-4: 03d20ebc4966301ceba02199a24e02dc + td-5: 86d0ae6fea0fbb119722ed3841f8385a + td-6: 6d26991f040628f6002efa192bebb9c2 + heading-4: c8b45e4d54115ec279a2a6bde4b8a725 + paragraph-8: 933c68ed0598328263d1146641d0ab2c + fence-0: ffdb698812040ead47e2039dfa22d9d7 + paragraph-9: fe471cd364c38cc79a7638b4a5ffb528 + heading-5: 4cbc75f1ae6830a190eb70628c4e4b54 + paragraph-10: bc6097c6af86133a3972bdb7a5343dd2 + fence-1: 31a1aab989ce9935c98e672182bffbbe + heading-6: fe53e9de958685ab7c70d0a973c0a146 + paragraph-11: 69f77475eb830f1aa357f45db29f809e + item-9: 90f2650aad06503472c46ce2612b9bc8 + item-10: a392737d0507463d40d1a8ff7502607c + item-11: 7ed6e23ffae34636a417daf72b3a5b6b + item-12: 0f93e7285aafb5c9929b29973f047026 + item-13: 9dacb7c56bc894e4bcad39f41265e3a0 + item-14: 496e1d7de5d7ff53f1af813a2ba46d7f + item-15: 1bd4ec30282cfc0bdf7d5f3559990a1c + item-16: 46c9eec1392a8d9f33170569e342bf7b + heading-7: aa8d69ad456402762aeb915a67cfa698 + paragraph-12: 275eb44389d498dab93e38ed2889e5d9 + paragraph-13: f05f450fffcb17520c441ab9789f40ce + paragraph-14: 915fe5ce4ded772d7844df222ebd9d3e + paragraph-15: 3fae115ccac303b7cd908b49b8509217 + heading-8: 2fdf7c243436eba4bd1fe5ebd605ab96 + paragraph-16: fc0f77d45ad1e1764d2793706eb8a049 + paragraph-17: 6b4340d30988a714d34f0df9b3e18889 + paragraph-18: a59d117938ed65f303209da2b23ad35a + paragraph-19: 28e8b27fb60b305a9caac04d8a92a038 + tagName-0: 0421db688c4d19bb542014733d553a43 + paragraph-20: 83951ea4c30a9b5057ff046e3a0bfc07 + paragraph-21: 8d2152d9e84fff4b3cd98d1fb305be8f + heading-9: 6fe6489310962f6bc8ad13279106568b + item-17: 0539fa4e65545d8a334abf6e0aee57ab + item-18: a41ccc6f28eec377fb19a95b0b6db7a8 + item-19: 45aa24cef8a5d9b037d898917a862563 + paragraph-22: 59bf7b09d603b846c2cdd63cd878f20a + paragraph-23: 9e30a94d9122095ac52a52ed2a864a26 + paragraph-24: 3b28910e425d79f9fd23ada6d6f33bff + paragraph-25: 05d9c0fe6c099b1e20ebdb2320a28257 + paragraph-26: 04e7322f2d3ffb2d73ff2f64b71637c8 + paragraph-27: 65ef9814c2d07fd3d54d9f7bee1bba6c + badge-0: c2d5d8760d96802e1b9a7bac290b1cfc + paragraph-28: df1f854bcdb047d98a68cd39704a8981 + fm-attr-title: e19079d431e6b75a7d74e9c35639ea5a + fm-attr-description: 82574f93a40b35a16a4c9fc5c2ab58cf + fm-attr-author: a51ec27845d1fc7cf13c810f0e2d42ab diff --git a/packages/cli/demo/markdown/en/example.md b/packages/cli/demo/markdown/en/example.md new file mode 100644 index 000000000..47882fda4 --- /dev/null +++ b/packages/cli/demo/markdown/en/example.md @@ -0,0 +1,31 @@ +--- +title: "Product Launch Guide" +description: "Everything you need to know about our latest product features" +author: "Product Team" +date: 2024-01-15 +tags: ["apples", "bananas", "pears"] +--- + +# Welcome to Our New Dashboard + +Discover powerful new features designed to streamline your workflow and boost productivity. + +## Getting Started + +Follow these simple steps to set up your account and begin using our platform effectively. + +--- + +Our advanced analytics help you make data-driven decisions with confidence. + +![Dashboard overview screenshot](image.jpg) + +The intuitive interface makes it easy to navigate between different features and tools. + +[View documentation](https://example.com) + +Need help getting started? Our support team is available 24/7 to assist you. + +*** + +Join thousands of satisfied customers who have transformed their business with our platform. \ No newline at end of file diff --git a/packages/cli/demo/markdown/es/example.md b/packages/cli/demo/markdown/es/example.md new file mode 100644 index 000000000..10394430d --- /dev/null +++ b/packages/cli/demo/markdown/es/example.md @@ -0,0 +1,34 @@ +--- +title: Guía de lanzamiento de producto +description: Todo lo que necesitas saber sobre las últimas funciones de nuestro producto +author: Equipo de producto +date: 2024-01-15 +tags: + - manzanas + - plátanos + - peras +--- + +# Bienvenido a nuestro nuevo panel + +Descubre potentes funciones nuevas diseñadas para optimizar tu flujo de trabajo y aumentar la productividad. + +## Primeros pasos + +Sigue estos sencillos pasos para configurar tu cuenta y comenzar a usar nuestra plataforma de manera efectiva. + +--- + +Nuestros análisis avanzados te ayudan a tomar decisiones basadas en datos con confianza. + +![Captura de pantalla de la vista general del panel](image.jpg) + +La interfaz intuitiva facilita la navegación entre diferentes funciones y herramientas. + +[Ver documentación](https://example.com) + +¿Necesitas ayuda para comenzar? Nuestro equipo de soporte está disponible 24/7 para asistirte. + +--- + +Únete a miles de clientes satisfechos que han transformado su negocio con nuestra plataforma. \ No newline at end of file diff --git a/packages/cli/demo/markdown/i18n.json b/packages/cli/demo/markdown/i18n.json new file mode 100644 index 000000000..258fadb39 --- /dev/null +++ b/packages/cli/demo/markdown/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "markdown": { + "include": [ + "./[locale]/example.md" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/markdown/i18n.lock b/packages/cli/demo/markdown/i18n.lock new file mode 100644 index 000000000..8350d51d3 --- /dev/null +++ b/packages/cli/demo/markdown/i18n.lock @@ -0,0 +1,19 @@ +version: 1 +checksums: + eed9ef23a605b747d38b8916cee1d500: + md-section-0: d53f61b8c8922fb62d9df5678d9b44a8 + md-section-1: 98aec271471bedce0e12b530c7060827 + md-section-2: 9e5a786192608844493dfbb6e4100886 + md-section-3: 1a5299c38bb20c1b8af0e64e33d7b2b0 + md-section-4: 51adf33450cab2ef392e93147386647c + md-section-5: e56cc804e3e06b5f5fb2484e88c18adc + md-section-6: 0ea86a3338305070c865e8fe138da890 + md-section-7: bbabf7f391569a72099001e3d81eb251 + md-section-8: 36cbfd93f42528edce4faac2ac3c2c12 + md-section-9: a1c50054ab23d70be8d453789b214580 + md-section-10: 51adf33450cab2ef392e93147386647c + md-section-11: cb596c9608828f7b87a0ab8fa37beb07 + fm-attr-title: f3469c4e3d3377c39a705c844930b3a5 + fm-attr-description: 2e988d98001e44997a3f5fa3fb487ca6 + fm-attr-author: ec8c8711fce61265a4fe296ce2ba3b6f + fm-attr-tags: 313ac6f17ee08e4f4a6a2ca95e5ae024 diff --git a/packages/cli/demo/mdx/en/example.mdx b/packages/cli/demo/mdx/en/example.mdx new file mode 100644 index 000000000..bbc99d7c0 --- /dev/null +++ b/packages/cli/demo/mdx/en/example.mdx @@ -0,0 +1,63 @@ +--- +title: "Restaurant Review: Bella Vista" +description: "Our dining experience at the new Italian restaurant downtown" +author: "not-localized-author" +published: "2024-03-15" +rating: 4.5 +locked_key_1: "This value should remain unchanged in all locales" +ignored_key_1: "This field should not appear in target locales" +--- + +# Dinner at Bella Vista + +We finally tried the new Italian restaurant that opened last month on Main Street. Here's our honest review. + +## The Atmosphere + +The restaurant has a warm, inviting atmosphere with: + +- **Dim lighting** that creates an intimate setting +- *Soft jazz music* playing in the background +- Fresh flowers on every table + +### Making Reservations + +[Book your table online](https://example.com) or call during business hours. + +> Tip: Weekend reservations fill up quickly, so book ahead! + +## Menu Highlights + +```javascript +// Restaurant website code - not localized +function displayMenu(category) { + const items = "This code stays in original language"; + return renderMenuItems(items); +} +``` + +```css +/* Styling for menu display - not localized */ +.menu-item { + color: "This CSS remains unchanged"; +} +``` + +## Our Order + +We started with the antipasto platter and house salad. + +The pasta was cooked perfectly - exactly `al_dente` as it should be. + +## Service Quality + +The waitstaff was attentive but not overwhelming. + +Our server checked on us regularly, maintaining `service.quality = "excellent"` throughout the evening. + +## Final Verdict + +| Course | Rating | +|--------|--------| +| Appetizers | `stars(4)` | +| Main dishes | `stars(5)` | diff --git a/packages/cli/demo/mdx/es/example.mdx b/packages/cli/demo/mdx/es/example.mdx new file mode 100644 index 000000000..c6e04d973 --- /dev/null +++ b/packages/cli/demo/mdx/es/example.mdx @@ -0,0 +1,62 @@ +--- +title: "Reseña de restaurante: Bella Vista" +description: Nuestra experiencia gastronómica en el nuevo restaurante italiano del centro +author: not-localized-author +published: 2024-03-15 +rating: 4.5 +locked_key_1: This value should remain unchanged in all locales +--- + +# Cena en Bella Vista + +Finalmente probamos el nuevo restaurante italiano que abrió el mes pasado en Main Street. Esta es nuestra reseña honesta. + +## El ambiente + +El restaurante tiene un ambiente cálido y acogedor con: + +- **Iluminación tenue** que crea un ambiente íntimo +- _Música de jazz suave_ sonando de fondo +- Flores frescas en cada mesa + +### Hacer reservas + +[Reserva tu mesa en línea](https://example.com) o llama durante el horario de atención. + +> Consejo: las reservas de fin de semana se llenan rápidamente, ¡así que reserva con anticipación! + +## Destacados del menú + +```javascript +// Restaurant website code - not localized +function displayMenu(category) { + const items = "This code stays in original language"; + return renderMenuItems(items); +} +``` + +```css +/* Styling for menu display - not localized */ +.menu-item { + color: "This CSS remains unchanged"; +} +``` + +## Nuestro pedido + +Comenzamos con la tabla de antipasto y ensalada de la casa. + +La pasta estaba cocinada a la perfección, exactamente `al_dente` como debe ser. + +## Calidad del servicio + +El personal de servicio fue atento pero no abrumador. + +Nuestro camarero nos atendió regularmente, manteniendo `service.quality = "excellent"` durante toda la velada. + +## Veredicto final + +| Plato | Calificación | +| ------------------ | ------------ | +| Aperitivos | `stars(4)` | +| Platos principales | `stars(5)` | diff --git a/packages/cli/demo/mdx/i18n.json b/packages/cli/demo/mdx/i18n.json new file mode 100644 index 000000000..334f6201a --- /dev/null +++ b/packages/cli/demo/mdx/i18n.json @@ -0,0 +1,19 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "mdx": { + "include": [ + "./[locale]/example.mdx" + ], + "lockedKeys": ["locked_key_1"], + "lockedPatterns": ["pattern_1"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/mdx/i18n.lock b/packages/cli/demo/mdx/i18n.lock new file mode 100644 index 000000000..086df65bc --- /dev/null +++ b/packages/cli/demo/mdx/i18n.lock @@ -0,0 +1,28 @@ +version: 1 +checksums: + 0d5b5aa6d2b9937d47fd63868ef9e9f6: + meta/title: a4bdd0dee24f8318f3300dcae130a353 + meta/description: 609213841f122e494f62262618ee4761 + meta/author: f3f7164b5963b4da6cd31a2ec0251630 + content/0: 8a8520492d23503da5691602e60bd22a + content/1: 1fc859854cda505b2a94a04c8b09ab43 + content/2: 8add667f2a1d5d791a64b50bde54fa59 + content/3: e6e34c4c92eda512ec209266abe8e074 + content/4: 07f1896ad050b9606d7674f70d847818 + content/5: bd4d40a4f0cc92ac8a880c8d9ce8b43d + content/6: 3036a07a887121ea080427d84fc80912 + content/7: f555318416c5c5388c1d961ef02f5955 + content/8: 90e02688ab103de60e42c70ece7efc4d + content/9: 8c5be3cd002a3a194c991821e0182e08 + content/10: 3495801a7461ac5ea8d78369873a5409 + content/11: d444739ce3d48afb7976067c67149a9e + content/12: 5f02c0a3b6385f80bdd08cf7e2d8c04d + content/13: 0a15fd446b87d907f58c303aece0882b + content/14: 778ed0aa1f81768280a23afe559c55f7 + content/15: fa244af2d8e558d6c3644ff8c1a64562 + content/16: 14f593e7cf3b3df84a21e17db318912e + content/17: 5f42d26a42aa29be063019eea27ad07c + content/18: 48bb7e89e72d68d6de12f5cdac64fc18 + content/19: 1639b9ef57bf363e04293e27d1c13952 + content/20: bb1c8d22064f7af4879c69d444e6e769 + content/21: 52f9d6beaa85591f77811e1162d756c4 diff --git a/packages/cli/demo/mjml/en/example.mjml b/packages/cli/demo/mjml/en/example.mjml new file mode 100644 index 000000000..208530485 --- /dev/null +++ b/packages/cli/demo/mjml/en/example.mjml @@ -0,0 +1,89 @@ + + + + Welcome to Our Service + Get started with your new account today + + + + + + + + + + + + + + + Welcome to Our Platform! + + + Thank you for signing up. We're excited to have you on board. + + + To get started, please verify your email address by clicking the + button below. + + + + + + + + Verify Email Address! + + + + + + + + If you didn't create an account, you can safely ignore this email. + + + Need help? Contact our support team. + + + + + + + + + + + + + © 2024 Example Company. All rights reserved. + + + + + diff --git a/packages/cli/demo/mjml/es/example.mjml b/packages/cli/demo/mjml/es/example.mjml new file mode 100644 index 000000000..7632e6e0d --- /dev/null +++ b/packages/cli/demo/mjml/es/example.mjml @@ -0,0 +1,89 @@ + + + Bienvenido a nuestro servicio + Comienza con tu nueva cuenta hoy + + + + + + + + + + + + + + + ¡Bienvenido a nuestra plataforma! + + + Gracias por registrarte. Estamos encantados de tenerte con nosotros. + + + Para comenzar, verifica tu dirección de correo electrónico haciendo + clic en el botón de abajo. + + + + + + + + ¡Verificar dirección de correo electrónico! + + + + + + + + Si no creaste una cuenta, puedes ignorar este correo electrónico de + forma segura. + + + ¿Necesitas ayuda? Contacta con nuestro equipo de soporte. + + + + + + + + + + + + + © 2024 Example Company. Todos los derechos reservados. + + + + + diff --git a/packages/cli/demo/mjml/i18n.json b/packages/cli/demo/mjml/i18n.json new file mode 100644 index 000000000..eb0be68a7 --- /dev/null +++ b/packages/cli/demo/mjml/i18n.json @@ -0,0 +1,13 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": ["es"] + }, + "buckets": { + "mjml": { + "include": ["./[locale]/example.mjml"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} diff --git a/packages/cli/demo/mjml/i18n.lock b/packages/cli/demo/mjml/i18n.lock new file mode 100644 index 000000000..70b9c9e5c --- /dev/null +++ b/packages/cli/demo/mjml/i18n.lock @@ -0,0 +1,21 @@ +version: 1 +checksums: + c1acde0589961652d4caf8a39d080857: + mjml/mj-head/0/mj-title/0: c514a686b50f7158b2dd08ea65d3bc8a + mjml/mj-head/0/mj-preview/0: 4ce14f6062c814cbdcdf8b0a3cb094d3 + mjml/mj-body/0/mj-section/0/mj-column/0/mj-image/0#alt: 82d5c0d5994508210ee02d684819f4b8 + mjml/mj-body/0/mj-section/1/mj-column/0/mj-text/0: b320b02942617a70dcbd1beac61da11a + mjml/mj-body/0/mj-section/1/mj-column/0/mj-text/1: 028311348a5aeefea365fdf422a3fb21 + mjml/mj-body/0/mj-section/1/mj-column/0/mj-text/2: 0dfdc9b80ee70fcc2b28d0e81e03fabc + mjml/mj-body/0/mj-section/2/mj-column/0/mj-button/0#title: 5c96f738bd6153ee07b72094cdfd2b98 + mjml/mj-body/0/mj-section/2/mj-column/0/mj-button/0#aria-label: 42dcab68d931f9145d9b6d76740a5c66 + mjml/mj-body/0/mj-section/2/mj-column/0/mj-button/0: dc8001d5c58294d22fe0b0e6118dbfb7 + mjml/mj-body/0/mj-section/3/mj-column/0/mj-text/0: a18f14ab69467cbdbe467df6255cfda7 + mjml/mj-body/0/mj-section/3/mj-column/0/mj-text/1: e83236e98aad1937bc99a47cff159caa + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/0#title: 180bd8aa700f6cedf65e0a2079503cea + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/0#alt: ac8afe226a7424849c247e6a9d566f64 + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/1#title: ea4c2a7a9a60cbb0f8f9632222a46abe + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/1#alt: ba3d4aed69a50759b53a0b7c319a3ad9 + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/2#title: 754efa5f98f51c510ff268e217877d8b + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/2#alt: c9555810826c30d571ffae869a236494 + mjml/mj-body/0/mj-section/4/mj-column/0/mj-text/0: 9ac6c625c7af33d70634846c8c9d11b0 diff --git a/packages/cli/demo/php/en/example.php b/packages/cli/demo/php/en/example.php new file mode 100644 index 000000000..3bfb4e4a3 --- /dev/null +++ b/packages/cli/demo/php/en/example.php @@ -0,0 +1,33 @@ + 'Welcome!', + 'error_text' => 'Something went wrong', + + 'navigation' => [ + 'home' => 'Home', + 'about' => 'About', + 'contact' => 'Contact', + ], + + 'forms' => [ + 'login' => [ + 'username_label' => 'Username', + 'password_label' => 'Password', + 'submit_button' => 'Sign In', + ], + ], + + 'mixed_content' => [ + 'title' => 'Settings', + 'count' => 42, + 'enabled' => true, + 'nothing_here' => null, + 'description' => 'App settings and preferences', + ], +]; diff --git a/packages/cli/demo/php/es/example.php b/packages/cli/demo/php/es/example.php new file mode 100644 index 000000000..1263ee971 --- /dev/null +++ b/packages/cli/demo/php/es/example.php @@ -0,0 +1,38 @@ + '¡Hola, mundo!', + '1' => 'Bienvenido a MyApp', + '2' => 'Es "simple\\" con una barra invertida \\ y salto de línea\nTodo el texto aquí', + '3' => [ + 'welcome_message' => '¡Bienvenido!' + ], + '4' => [ + 'error_text' => 'Algo salió mal' + ], + '5' => [ + 'navigation' => [ + 'home' => 'Inicio', + 'about' => 'Acerca de', + 'contact' => 'Contacto' + ] + ], + '6' => [ + 'forms' => [ + 'login' => [ + 'username_label' => 'Nombre de usuario', + 'password_label' => 'Contraseña', + 'submit_button' => 'Iniciar sesión' + ] + ] + ], + '7' => [ + 'mixed_content' => [ + 'title' => 'Configuración', + 'count' => 42, + 'enabled' => true, + 'nothing_here' => null, + 'description' => 'Configuración y preferencias de la aplicación' + ] + ] +]; \ No newline at end of file diff --git a/packages/cli/demo/php/i18n.json b/packages/cli/demo/php/i18n.json new file mode 100644 index 000000000..d86e08e86 --- /dev/null +++ b/packages/cli/demo/php/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "php": { + "include": [ + "./[locale]/example.php" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/php/i18n.lock b/packages/cli/demo/php/i18n.lock new file mode 100644 index 000000000..d7e35e1b4 --- /dev/null +++ b/packages/cli/demo/php/i18n.lock @@ -0,0 +1,16 @@ +version: 1 +checksums: + 0ee8d6907f60fa2cc288cc0503ecbba1: + "0": 0468579ef2fbc83c9d520c2f2f1c5059 + "1": d1c3a9f35e377554a4ccaa467ca26614 + "2": 769caedbdc5246bb9fee615739534bbd + 3/welcome_message: 8778dc41547a2778d0f9482da989fc00 + 4/error_text: a3cd2f01c073f1f5ff436d4b132d39cf + 5/navigation/home: 104a3db3b671c04e167eafbe21e57881 + 5/navigation/about: 944521eeeed2511833d2299931273c71 + 5/navigation/contact: 9afa39bc47019ee6dec6c74b6273967c + 6/forms/login/username_label: 2ee65bc2dd2f12cf2672f95b2a054bf8 + 6/forms/login/password_label: 223a61cf906ab9c40d22612c588dff48 + 6/forms/login/submit_button: ec7b8f314fe9bc6591006707484ede61 + 7/mixed_content/title: 8df6777277469c1fd88cc18dde2f1cc3 + 7/mixed_content/description: 063afcd2ea84a82a1acc8f5c9fd8e42f diff --git a/packages/cli/demo/po/en/example.po b/packages/cli/demo/po/en/example.po new file mode 100644 index 000000000..0bf2b92d6 --- /dev/null +++ b/packages/cli/demo/po/en/example.po @@ -0,0 +1,61 @@ +msgid "" +msgstr "" +"Project-Id-Version: Example Project 1.0\n" +"Report-Msgid-Bugs-To: support@example.com\n" +"POT-Creation-Date: 2024-01-01 12:00+0000\n" +"PO-Revision-Date: 2024-01-01 12:00+0000\n" +"Last-Translator: Example Translator \n" +"Language-Team: English \n" +"Language: en\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" + +msgid "Welcome" +msgstr "Welcome" + +msgctxt "navigation" +msgid "Home" +msgstr "Home" + +msgid "You have %d message" +msgid_plural "You have %d messages" +msgstr[0] "You have %d message" +msgstr[1] "You have %d messages" + +msgid "Save" +msgstr "Save" + +msgid "Cancel" +msgstr "Cancel" + +msgid "Delete" +msgstr "Delete" + +msgid "Name" +msgstr "Name" + +msgid "Email Address" +msgstr "Email Address" + +msgid "Message" +msgstr "Message" + +msgid "Loading..." +msgstr "Loading..." + +msgid "Success! Changes saved." +msgstr "Success! Changes saved." + +msgid "Error: Request failed." +msgstr "Error: Request failed." + +msgid "Add to Cart" +msgstr "Add to Cart" + +msgid "Out of Stock" +msgstr "Out of Stock" + +msgid "Price: $%s" +msgstr "Price: $%s" \ No newline at end of file diff --git a/packages/cli/demo/po/es/example.po b/packages/cli/demo/po/es/example.po new file mode 100644 index 000000000..503df4e0c --- /dev/null +++ b/packages/cli/demo/po/es/example.po @@ -0,0 +1,61 @@ +msgid "" +msgstr "" +"Project-Id-Version: Example Project 1.0\n" +"Report-Msgid-Bugs-To: support@example.com\n" +"POT-Creation-Date: 2024-01-01 12:00+0000\n" +"PO-Revision-Date: 2024-01-01 12:00+0000\n" +"Last-Translator: Example Translator \n" +"Language-Team: English \n" +"Language: en\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" + +msgid "Welcome" +msgstr "Bienvenido" + +msgctxt "navigation" +msgid "Home" +msgstr "Inicio" + +msgid "You have %d message" +msgid_plural "You have %d messages" +msgstr[0] "Tienes %d mensaje" +msgstr[1] "Tienes %d mensajes" + +msgid "Save" +msgstr "Guardar" + +msgid "Cancel" +msgstr "Cancelar" + +msgid "Delete" +msgstr "Eliminar" + +msgid "Name" +msgstr "Nombre" + +msgid "Email Address" +msgstr "Dirección de correo electrónico" + +msgid "Message" +msgstr "Mensaje" + +msgid "Loading..." +msgstr "Cargando..." + +msgid "Success! Changes saved." +msgstr "¡Éxito! Cambios guardados." + +msgid "Error: Request failed." +msgstr "Error: la solicitud falló." + +msgid "Add to Cart" +msgstr "Añadir al carrito" + +msgid "Out of Stock" +msgstr "Agotado" + +msgid "Price: $%s" +msgstr "Precio: $%s" \ No newline at end of file diff --git a/packages/cli/demo/po/i18n.json b/packages/cli/demo/po/i18n.json new file mode 100644 index 000000000..6bfe7630d --- /dev/null +++ b/packages/cli/demo/po/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "po": { + "include": [ + "./[locale]/example.po" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/po/i18n.lock b/packages/cli/demo/po/i18n.lock new file mode 100644 index 000000000..b1214f641 --- /dev/null +++ b/packages/cli/demo/po/i18n.lock @@ -0,0 +1,19 @@ +version: 1 +checksums: + cdba37b4ade3da08cf12eccfde49d625: + Welcome/singular: 3180ad6b8de344b781637750259e0f53 + Home/singular: 104a3db3b671c04e167eafbe21e57881 + You%20have%20%25d%20message/singular: 1691abfe2c5d017cda86e298d34f3524 + You%20have%20%25d%20message/plural: 2d37831bf51cc2cf75e812c0e61c6861 + Save/singular: f7a2929f33bc420195e59ac5a8bcd454 + Cancel/singular: 2e2a849c2223911717de8caa2c71bade + Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9 + Name/singular: 9368b5a047572b6051f334af5aa76819 + Email%20Address/singular: 0ee22bbbe989a0c61a18023407d12dc2 + Message/singular: f2f72126bd244cfc534eab395e054362 + Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66 + Success!%20Changes%20saved./singular: 906371aaeec474803e22ae959605dad8 + Error%3A%20Request%20failed./singular: cdeaab2374e34c0e396cdb2596a9824e + Add%20to%20Cart/singular: c93a29ccf502ff71bf08924dcdea9179 + Out%20of%20Stock/singular: 6673fc95c2cee3c713e0d60c8184e289 + Price%3A%20%24%25s/singular: a860f7b395e4a9d916a48717f9f8837a diff --git a/packages/cli/demo/properties/en/example.properties b/packages/cli/demo/properties/en/example.properties new file mode 100644 index 000000000..d54dcbe5e --- /dev/null +++ b/packages/cli/demo/properties/en/example.properties @@ -0,0 +1,14 @@ +app.title=MyApp +app.description=A simple demo application + +user.greeting=Hello, world! +user.farewell=Thanks for using MyApp + +error.message=Something went wrong +error.notFound=Page not found + +database.host=localhost +database.port=5432 + +notification.success=Changes saved! +notification.warning=Please check your input \ No newline at end of file diff --git a/packages/cli/demo/properties/es/example.properties b/packages/cli/demo/properties/es/example.properties new file mode 100644 index 000000000..4443633bf --- /dev/null +++ b/packages/cli/demo/properties/es/example.properties @@ -0,0 +1,10 @@ +app.title=MyApp +app.description=Una aplicación de demostración simple +user.greeting=¡Hola, mundo! +user.farewell=Gracias por usar MyApp +error.message=Algo salió mal +error.notFound=Página no encontrada +database.host=localhost +database.port=5432 +notification.success=¡Cambios guardados! +notification.warning=Por favor, revisa tu entrada \ No newline at end of file diff --git a/packages/cli/demo/properties/i18n.json b/packages/cli/demo/properties/i18n.json new file mode 100644 index 000000000..1c51604de --- /dev/null +++ b/packages/cli/demo/properties/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "properties": { + "include": [ + "./[locale]/example.properties" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/properties/i18n.lock b/packages/cli/demo/properties/i18n.lock new file mode 100644 index 000000000..3dabed444 --- /dev/null +++ b/packages/cli/demo/properties/i18n.lock @@ -0,0 +1,12 @@ +version: 1 +checksums: + 2250baa513dddb2c691cb62115d5e257: + app.title: 7dc70110429d46e3685f385bd2cc941c + app.description: e13baa1e885129d9328e216ff534761b + user.greeting: 0468579ef2fbc83c9d520c2f2f1c5059 + user.farewell: 118794a2b84f7bfb4b4ce602ed463b0f + error.message: a3cd2f01c073f1f5ff436d4b132d39cf + error.notFound: 97612e6230bc7a1ebd99380bf561b732 + database.host: da86e4fc0c04d82c87006dc71cea7e97 + notification.success: 3b7a8b0aa23977592d4270ea136a390c + notification.warning: c38895f731311cefacee9e8d7d10fc49 diff --git a/packages/cli/demo/run_i18n.sh b/packages/cli/demo/run_i18n.sh new file mode 100755 index 000000000..b39da1582 --- /dev/null +++ b/packages/cli/demo/run_i18n.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm install + +pnpm --filter lingo.dev run build + +root_dir=$(git rev-parse --show-toplevel) +cli="$root_dir/packages/cli/bin/cli.mjs" +demo_root="$root_dir/packages/cli/demo" + +for demo in "$demo_root"/*/; do + printf '\n%s\n' "${demo%/}" + ( + cd "$demo" + node "$cli" i18n "$@" + ) + done diff --git a/packages/cli/demo/srt/en/example.srt b/packages/cli/demo/srt/en/example.srt new file mode 100644 index 000000000..541c14ebb --- /dev/null +++ b/packages/cli/demo/srt/en/example.srt @@ -0,0 +1,20 @@ +1 +00:00:01,000 --> 00:00:03,500 +Welcome to our product demo + +2 +00:00:04,000 --> 00:00:06,500 +In this video, we'll show you +how to get started quickly + +3 +00:00:07,000 --> 00:00:09,500 +First, navigate to your dashboard 📊 + +4 +00:00:10,000 --> 00:00:12,500 +This process takes about 5 minutes + +5 +00:00:13,000 --> 00:00:15,500 +Click the Create New button to begin \ No newline at end of file diff --git a/packages/cli/demo/srt/es/example.srt b/packages/cli/demo/srt/es/example.srt new file mode 100644 index 000000000..748c048f8 --- /dev/null +++ b/packages/cli/demo/srt/es/example.srt @@ -0,0 +1,20 @@ +1 +00:00:01,000 --> 00:00:03,500 +Bienvenido a la demostración de nuestro producto + +2 +00:00:04,000 --> 00:00:06,500 +En este vídeo, te mostraremos +cómo empezar rápidamente + +3 +00:00:07,000 --> 00:00:09,500 +Primero, navega a tu panel de control 📊 + +4 +00:00:10,000 --> 00:00:12,500 +Este proceso tarda unos 5 minutos + +5 +00:00:13,000 --> 00:00:15,500 +Haz clic en el botón Crear nuevo para comenzar \ No newline at end of file diff --git a/packages/cli/demo/srt/i18n.json b/packages/cli/demo/srt/i18n.json new file mode 100644 index 000000000..096bf7631 --- /dev/null +++ b/packages/cli/demo/srt/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "srt": { + "include": [ + "./[locale]/example.srt" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/srt/i18n.lock b/packages/cli/demo/srt/i18n.lock new file mode 100644 index 000000000..c529f05d6 --- /dev/null +++ b/packages/cli/demo/srt/i18n.lock @@ -0,0 +1,8 @@ +version: 1 +checksums: + 424142ee2f7c4f944722042f761b30eb: + 1#00:00:01,000-00:00:03,500: 5a2215cdfd6d9e9162efbdee57b89c27 + 2#00:00:04,000-00:00:06,500: ecb7d6cb214b6db6d02e6e98cdfea178 + 3#00:00:07,000-00:00:09,500: 3eee55196aea6ac13fb19eae7e7ffaf6 + 4#00:00:10,000-00:00:12,500: a6cc802efe3431c7a986ac5d42d62ce1 + 5#00:00:13,000-00:00:15,500: f73ef0a42ea51efb4e1e5fd2276ef243 diff --git a/packages/cli/demo/twig/en/example.html.twig b/packages/cli/demo/twig/en/example.html.twig new file mode 100644 index 000000000..f07d7da7c --- /dev/null +++ b/packages/cli/demo/twig/en/example.html.twig @@ -0,0 +1,70 @@ +{% set user = app.user %} + + + + + Welcome + + +
    + +
    + +
    +
    +

    Welcome to Our Platform

    +

    Hello {{ user.name }}, we're glad to have you here!

    +

    Start exploring our features and discover what makes us unique.

    +
    + + {% if user.isPremium %} +
    +

    Premium Benefits

    +
      +
    • Unlimited access to all features
    • +
    • Priority customer support
    • +
    • Advanced analytics and reporting
    • +
    +
    + {% endif %} + +
    +

    Getting Started

    +

    Follow these simple steps to begin your journey:

    +
      +
    1. Complete your profile
    2. +
    3. Explore the dashboard
    4. +
    5. Invite your team members
    6. +
    + +
    + + + + + + + +
    +
    + + {# This section is for internal notes and won't be displayed #} + {% if app.debug %} +
    +

    Debug Information

    +

    User ID: {{ user.id }}

    +

    Last login: {{ user.lastLogin|date('Y-m-d H:i:s') }}

    +
    + {% endif %} +
    + +
    +

    Need help? Contact Support

    +

    © {{ "now"|date('Y') }} Our Company. All rights reserved.

    +
    + + diff --git a/packages/cli/demo/twig/es/example.html.twig b/packages/cli/demo/twig/es/example.html.twig new file mode 100644 index 000000000..005b7dfcb --- /dev/null +++ b/packages/cli/demo/twig/es/example.html.twig @@ -0,0 +1,66 @@ +{% set user = app.user %} + + + + + Bienvenido + + +
    + +
    + +
    +
    +

    Bienvenido a nuestra plataforma

    +

    Hola {{ user.name }}, nos alegra tenerte aquí!

    +

    Comienza a explorar nuestras funciones y descubre lo que nos hace únicos.

    +
    + + {% if user.isPremium %} +
    +

    Beneficios premium

    +
      +
    • Acceso ilimitado a todas las funciones
    • +
    • Soporte al cliente prioritario
    • +
    • Análisis e informes avanzados
    • +
    +
    + {% endif %} + +
    +

    Primeros pasos

    +

    Sigue estos sencillos pasos para comenzar tu viaje:

    +
      +
    1. Completa tu perfil
    2. +
    3. Explora el panel de control
    4. +
    5. Invita a los miembros de tu equipo
    6. +
    + +
    + + + + + +
    +
    + + {# This section is for internal notes and won't be displayed #} + {% if app.debug %} +
    +

    Información de depuración

    +

    ID de usuario: {{ user.id }}

    +

    Último inicio de sesión: {{ user.lastLogin|date('Y-m-d H:i:s') }}

    +
    + {% endif %} +
    + +
    +

    ¿Necesitas ayuda? Contacta con soporte

    +

    © {{ "now"|date('Y') }} Nuestra empresa. Todos los derechos reservados.

    +
    + + diff --git a/packages/cli/demo/twig/i18n.json b/packages/cli/demo/twig/i18n.json new file mode 100644 index 000000000..529e70f8a --- /dev/null +++ b/packages/cli/demo/twig/i18n.json @@ -0,0 +1,13 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": ["es", "ru"] + }, + "buckets": { + "twig": { + "include": ["./[locale]/*.twig"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} diff --git a/packages/cli/demo/twig/i18n.lock b/packages/cli/demo/twig/i18n.lock new file mode 100644 index 000000000..bd263ad31 --- /dev/null +++ b/packages/cli/demo/twig/i18n.lock @@ -0,0 +1,24 @@ +version: 1 +checksums: + 2d3d028d905803e471ca9f97a4969d5e: + head/0#content: 1308168cca4fa5d8d7a0cf24e55e93fc + head/1: 3180ad6b8de344b781637750259e0f53 + body/0/0: 9de5fe40cbf5f851a6d2270f01fe0739 + body/1/0/0: c59070fe496d5e4bd0066295b63a9056 + body/1/0/1: 12d74865332bf1988d51e84ba67aae09 + body/1/0/2: 58f0e438e665c77eedc440c5a8529b1a + body/1/1/0: 119e3aa396d12a5a1aa7058e0983f9b9 + body/1/1/1/0: 60f9a22f4200bb4620a6ff7a1797ec30 + body/1/1/1/1: 03846a81f16f5e4a11acfd9445ad497d + body/1/1/1/2: 15aae9d70ff1fb682f7d86baca81dcc0 + body/1/2/0: fbd403146395526d68ac68d142a50e21 + body/1/2/1: da8dc7fe06175d8b805f7f565bfe2788 + body/1/2/2/0: 061e1acc1b9ebad9de09fd5626e813c7 + body/1/2/2/1: 67f022a3f9e278d065a063b5e29dd932 + body/1/2/2/2: 7e23f048179f6661050edaa796528fe0 + body/1/2/3: 635f7e9a4afc00de34f975914afbb8b8 + body/1/3/0: 7a7892379e31868abba9865d20be2b72 + body/1/3/1: 8740df822561d74d51bb30e4b39d6193 + body/1/3/2: 0429f12258fabbde3abaca3dd9986178 + body/2/0: d32e57e4a5a65f3bee8b63dcb2bfa8e7 + body/2/1: 7e10a8ab9cc4e6d603b3cdc48849688f diff --git a/packages/cli/demo/txt/en/example.txt b/packages/cli/demo/txt/en/example.txt new file mode 100644 index 000000000..c48f44e91 --- /dev/null +++ b/packages/cli/demo/txt/en/example.txt @@ -0,0 +1,6 @@ +This is the first line of text content +This is the second line with different content +This line contains special characters: !@#$%^&*() + +This is line five after an empty line above +Thank you for choosing our service \ No newline at end of file diff --git a/packages/cli/demo/txt/es/example.txt b/packages/cli/demo/txt/es/example.txt new file mode 100644 index 000000000..1f761a42c --- /dev/null +++ b/packages/cli/demo/txt/es/example.txt @@ -0,0 +1,6 @@ +Esta es la primera línea de contenido de texto +Esta es la segunda línea con contenido diferente +Esta línea contiene caracteres especiales: !@#$%^&*() + +Esta es la línea cinco después de una línea vacía arriba +Gracias por elegir nuestro servicio \ No newline at end of file diff --git a/packages/cli/demo/txt/i18n.json b/packages/cli/demo/txt/i18n.json new file mode 100644 index 000000000..59955fd1a --- /dev/null +++ b/packages/cli/demo/txt/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "txt": { + "include": [ + "./[locale]/example.txt" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/txt/i18n.lock b/packages/cli/demo/txt/i18n.lock new file mode 100644 index 000000000..cc305c144 --- /dev/null +++ b/packages/cli/demo/txt/i18n.lock @@ -0,0 +1,8 @@ +version: 1 +checksums: + 93e6c54f553af59fa33d7469c80a30e9: + "1": 5c0212aca9c84332df0190d13e929623 + "2": d39d54116929959bf76f43655e7bebc9 + "3": 960c83d6eeed679ee9fb1b2be2f9934b + "5": 78569dd2f0e7cd872659850ef2f9c19a + "6": 5c5a850ec695512b6182630c563eeed9 diff --git a/packages/cli/demo/typescript/en/example.ts b/packages/cli/demo/typescript/en/example.ts new file mode 100644 index 000000000..d1f678c7c --- /dev/null +++ b/packages/cli/demo/typescript/en/example.ts @@ -0,0 +1,21 @@ +const messages = { + navigation: { + home: "Home", + about: "About Us", + contact: "Contact Us", + services: "Our Services" + }, + + forms: { + title: "Contact Form", + nameLabel: "Your Name", + emailLabel: "Email Address", + messageLabel: "Your Message", + submitButton: "Send Message", + successMessage: "Thank you for your message!", + locked_key_1: "This value is locked and should not be changed", + ignored_key_1: "This value is ignored and should not be processed" + }, +}; + +export default messages; diff --git a/packages/cli/demo/typescript/es/example.ts b/packages/cli/demo/typescript/es/example.ts new file mode 100644 index 000000000..d95753679 --- /dev/null +++ b/packages/cli/demo/typescript/es/example.ts @@ -0,0 +1,19 @@ +const messages = { + navigation: { + home: "Inicio", + about: "Acerca de nosotros", + contact: "Contacto", + services: "Nuestros servicios", + }, + forms: { + title: "Formulario de contacto", + nameLabel: "Tu nombre", + emailLabel: "Dirección de correo electrónico", + messageLabel: "Tu mensaje", + submitButton: "Enviar mensaje", + successMessage: "¡Gracias por tu mensaje!", + locked_key_1: "This value is locked and should not be changed", + ignored_key_1: "This value is ignored and should not be processed", + }, +}; +export default messages; \ No newline at end of file diff --git a/packages/cli/demo/typescript/i18n.json b/packages/cli/demo/typescript/i18n.json new file mode 100644 index 000000000..004973a08 --- /dev/null +++ b/packages/cli/demo/typescript/i18n.json @@ -0,0 +1,19 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "typescript": { + "include": [ + "./[locale]/example.ts" + ], + "lockedKeys": ["locked_key_1"], + "ignoredKeys": ["ignored_key_1"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/typescript/i18n.lock b/packages/cli/demo/typescript/i18n.lock new file mode 100644 index 000000000..b6ff21e12 --- /dev/null +++ b/packages/cli/demo/typescript/i18n.lock @@ -0,0 +1,15 @@ +version: 1 +checksums: + 0cdc4f3ba34edc7f3ba4d996158306ad: + navigation/home: 104a3db3b671c04e167eafbe21e57881 + navigation/about: 8f89131a66d4659be07cd5af2c7ea898 + navigation/contact: 2a75337dc9603915c4ec1d1905afb7b9 + navigation/services: 999f32026e64978cb3ec794a496b0bb8 + forms/title: ac85dea7c7f0bf1cd7d48cc1b4da3acc + forms/nameLabel: 03c6ae7996d5841f743cd406b4eff72d + forms/emailLabel: 0ee22bbbe989a0c61a18023407d12dc2 + forms/messageLabel: 1e460d0909502d0e94b9be562643af0d + forms/submitButton: 487177489aafc9c0243c57ef3850a2d9 + forms/successMessage: a0a7aa980dffa31d4d194af718a917b3 + forms/locked_key_1: 73fc105de1aefc3f0a97d187a80cf0a4 + forms/ignored_key_1: d88a7aa6c5661ca901ee0f4cb51e3a0d diff --git a/packages/cli/demo/vtt/en/example.vtt b/packages/cli/demo/vtt/en/example.vtt new file mode 100644 index 000000000..377df6841 --- /dev/null +++ b/packages/cli/demo/vtt/en/example.vtt @@ -0,0 +1,24 @@ +WEBVTT + +NOTE +Product demonstration video with captions + +1 +00:00:01.000 --> 00:00:03.500 +Welcome to our software tutorial + +subtitle-2 +00:00:04.000 --> 00:00:07.200 +Let's explore the main features + +3 +00:00:08.500 --> 00:00:12.000 align:middle line:90% +Here's how to create your first project + +00:00:13.000 --> 00:00:16.500 +Click the New Project button +to get started + +final-cue +00:00:17.000 --> 00:00:20.000 position:25% align:start +That completes our basic tutorial \ No newline at end of file diff --git a/packages/cli/demo/vtt/es/example.vtt b/packages/cli/demo/vtt/es/example.vtt new file mode 100644 index 000000000..fcfe37c46 --- /dev/null +++ b/packages/cli/demo/vtt/es/example.vtt @@ -0,0 +1,21 @@ +WEBVTT + +1 +00:00:01.000 --> 00:00:03.500 +Bienvenido a nuestro tutorial de software + +subtitle-2 +00:00:04.000 --> 00:00:07.200 +Exploremos las funciones principales + +3 +00:00:08.500 --> 00:00:12.000 +Así es como crear tu primer proyecto + +00:00:13.000 --> 00:00:16.500 +Haz clic en el botón Nuevo proyecto +para comenzar + +final-cue +00:00:17.000 --> 00:00:20.000 +Esto completa nuestro tutorial básico \ No newline at end of file diff --git a/packages/cli/demo/vtt/i18n.json b/packages/cli/demo/vtt/i18n.json new file mode 100644 index 000000000..9fd278adc --- /dev/null +++ b/packages/cli/demo/vtt/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "vtt": { + "include": [ + "./[locale]/example.vtt" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/vtt/i18n.lock b/packages/cli/demo/vtt/i18n.lock new file mode 100644 index 000000000..bc6940d85 --- /dev/null +++ b/packages/cli/demo/vtt/i18n.lock @@ -0,0 +1,8 @@ +version: 1 +checksums: + ab6437a18c50af6612bb75499361d64a: + 0#1-3.5#1: 5df3c06b74cfc8558e85ff75a30a9162 + 1#4-7.2#subtitle-2: 0de65f1d2616b6959aa79ac5beb6e84c + 2#8.5-12#3: 3351244c032529a099f1191477d9e488 + 3#13-16.5#: b9341abc965d5178a96d9bc4e8e2c59a + 4#17-20#final-cue: 0b67e089cd3f39b8520d7a2be9f34362 diff --git a/packages/cli/demo/vue-json/example.vue b/packages/cli/demo/vue-json/example.vue new file mode 100644 index 000000000..ca1f52c43 --- /dev/null +++ b/packages/cli/demo/vue-json/example.vue @@ -0,0 +1,68 @@ + + + + + + + +{ + "en": { + "welcome": "Hello, world!", + "description": "A simple demo app", + "button": { + "submit": "Submit", + "cancel": "Cancel" + }, + "messages": [ + "Hello from MyApp", + "Welcome message" + ], + "metadata": { + "active": true, + "maxItems": 10, + "note": null + } + }, + "es": { + "welcome": "¡Hola, mundo!", + "description": "Una aplicación de demostración simple", + "button": { + "submit": "Enviar", + "cancel": "Cancelar" + }, + "messages": [ + "Hola desde MyApp", + "Mensaje de bienvenida" + ], + "metadata": { + "active": true, + "maxItems": 10, + "note": null + } + } +} + \ No newline at end of file diff --git a/packages/cli/demo/vue-json/i18n.json b/packages/cli/demo/vue-json/i18n.json new file mode 100644 index 000000000..1880ad13f --- /dev/null +++ b/packages/cli/demo/vue-json/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "vue-json": { + "include": [ + "./example.vue" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/vue-json/i18n.lock b/packages/cli/demo/vue-json/i18n.lock new file mode 100644 index 000000000..e81625b00 --- /dev/null +++ b/packages/cli/demo/vue-json/i18n.lock @@ -0,0 +1,16 @@ +version: 1 +checksums: + 7142a39dd2be0c1e12089c922ab4fdb5: + welcome: 1308168cca4fa5d8d7a0cf24e55e93fc + description: 36349ca88e416a6f2d6ac8742f0a963c + button/submit: dabdff794b5804b8f22ecab13f37fb7d + button/cancel: efdc4af6863f1e503a7f9961be721eed + messages/0: f77d23a0a4b7f8fc7f8fd458921b90dd + messages/1: e841c4139402ded42079401299e4fa1e + 0378a0d09447bf0944e842f7e54d3ec2: + welcome: 0468579ef2fbc83c9d520c2f2f1c5059 + description: 49f8864eb0e53903f04532bf33e1e4fa + button/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e + button/cancel: 2e2a849c2223911717de8caa2c71bade + messages/0: 97a8db12c3955a85c4f50e3951c91a40 + messages/1: 986a434e3895c8ee0b267df95cc40051 diff --git a/packages/cli/demo/xcode-strings/en/example.strings b/packages/cli/demo/xcode-strings/en/example.strings new file mode 100644 index 000000000..01ded4562 --- /dev/null +++ b/packages/cli/demo/xcode-strings/en/example.strings @@ -0,0 +1,70 @@ +/* Basic examples */ +"welcome_message" = "Hello, world!"; +"login_button" = "Log In"; +"error_message" = "Something went wrong"; + +"user_profile_title" = "User Profile"; + +/* Escaped characters */ +"quote_example" = "She said \"Hello world!\""; +"newline_example" = "First line\nSecond line"; +"backslash_example" = "Path: C:\\Users\\Documents"; +"mixed_escapes" = "Quote: \" Newline: \\n Backslash: \\\\"; +"tab_example" = "Column1\tColumn2\tColumn3"; + +/* Multi-line string with literal newlines */ +"multiline_literal" = "This is line 1 +This is line 2 +This is line 3"; + +/* Multi-line with mixed content */ +"multiline_mixed" = "Start here + Indented line +End here"; + +/* Multi-line with quotes inside */ +"multiline_with_quotes" = "He said \"Hello\" +Then she said \"Goodbye\" +The end"; + +/* Single-line comments */ +// This is a comment that should be ignored +"after_comment" = "Value after comment"; + +/* Multi-line comment block */ +/* + * This entire block + * should be ignored + * by the parser + */ +"after_multiline_comment" = "Value after multiline comment"; + +/* Empty value */ +"empty_string" = ""; + +/* Very long value */ +"long_value" = "This is a very long string that contains a lot of text to test how the parser handles longer content without any special characters or escapes just plain text going on and on"; + +/* Unicode and special characters */ +"unicode_example" = "Hello 世界 🌍"; +"emoji" = "👋 Hello! 🎉"; +"accents" = "Café, naïve, résumé"; + +/* Edge case: Value with only spaces */ +"spaces_only" = " "; + +/* Edge case: Multiple quotes */ +"many_quotes" = "\"\"\"Multiple quotes\"\"\""; + +/* Edge case: URL */ +"url_example" = "https://example.com/path?key=value&other=123"; + +/* Malformed entries (should be skipped gracefully) */ +This is not a valid key-value pair +"incomplete_pair" = += "missing_key"; +"missing_semicolon" = "This line has no semicolon" + +/* Valid entries after malformed ones */ +"settings_title" = "Settings"; +"save_button" = "Save"; \ No newline at end of file diff --git a/packages/cli/demo/xcode-strings/es/example.strings b/packages/cli/demo/xcode-strings/es/example.strings new file mode 100644 index 000000000..aa8042184 --- /dev/null +++ b/packages/cli/demo/xcode-strings/es/example.strings @@ -0,0 +1,25 @@ +"welcome_message" = "¡Hola, mundo!"; +"login_button" = "Iniciar sesión"; +"error_message" = "Algo salió mal"; +"user_profile_title" = "Perfil de usuario"; +"quote_example" = "Ella dijo \"Hola mundo\""; +"newline_example" = "Primera línea\nSegunda línea"; +"backslash_example" = "Ruta: C:\\Users\\Documents"; +"mixed_escapes" = "Comilla: \" Salto de línea: \\n Barra invertida: \\\\"; +"tab_example" = "Columna1\tColumna2\tColumna3"; +"multiline_literal" = "Esta es la línea 1\nEsta es la línea 2\nEsta es la línea 3"; +"multiline_mixed" = "Comienza aquí\n Línea con sangría\nTermina aquí"; +"multiline_with_quotes" = "Él dijo \"Hola\"\nLuego ella dijo \"Adiós\"\nEl fin"; +"after_comment" = "Valor después del comentario"; +"after_multiline_comment" = "Valor después del comentario multilínea"; +"empty_string" = ""; +"long_value" = "Esta es una cadena muy larga que contiene mucho texto para probar cómo el analizador maneja contenido más extenso sin caracteres especiales ni escapes, solo texto plano que continúa y continúa"; +"unicode_example" = "Hola 世界 🌍"; +"emoji" = "👋 ¡Hola! 🎉"; +"accents" = "Café, ingenuo, currículum"; +"spaces_only" = " "; +"many_quotes" = "\"\"\"Múltiples comillas\"\"\""; +"url_example" = "https://example.com/path?key=value&other=123"; +"missing_semicolon" = "Esta línea no tiene punto y coma"; +"settings_title" = "Configuración"; +"save_button" = "Guardar"; \ No newline at end of file diff --git a/packages/cli/demo/xcode-strings/i18n.json b/packages/cli/demo/xcode-strings/i18n.json new file mode 100644 index 000000000..08bebd5e8 --- /dev/null +++ b/packages/cli/demo/xcode-strings/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "xcode-strings": { + "include": [ + "./[locale]/example.strings" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/xcode-strings/i18n.lock b/packages/cli/demo/xcode-strings/i18n.lock new file mode 100644 index 000000000..c6199d52d --- /dev/null +++ b/packages/cli/demo/xcode-strings/i18n.lock @@ -0,0 +1,26 @@ +version: 1 +checksums: + c0001027bc43a90fe59344ea57847e7a: + welcome_message: 0468579ef2fbc83c9d520c2f2f1c5059 + login_button: 0029e5a35676c0051e761fcd046ef9ee + error_message: a3cd2f01c073f1f5ff436d4b132d39cf + user_profile_title: bee775ff7216747b2111e93cefa57ddc + quote_example: c519c83fe2629c0e9a6e7a14f64b6317 + newline_example: ae9313a2231a16f17e2367a4e5b322ee + backslash_example: acf69a7273edf9f932f66027f699bbbe + mixed_escapes: 9285b600baf307f7c060e20dc5778fad + tab_example: 1451b8323511459dac68316a2594bb82 + multiline_literal: a4c5d1c388a06e29d96833e4d2f14a26 + multiline_mixed: f5d741606567d78281bc455074eb8f6c + multiline_with_quotes: c82ec05ec488644808917b9c958da8cc + after_comment: b7c19db10622cb67d4dd28270e85a428 + after_multiline_comment: 759d0ffce80451996a5a45b33a0870cc + long_value: a54e8485e571c671e35865ba72cbcaf5 + unicode_example: 2de42b1aef6d20b314928b9c2554759d + emoji: 1b387c2b5ce6c2cd608081ebcb5e6a94 + accents: 8c054e17f9b960d9317ca110a6fedf8c + spaces_only: 8af60e2ee58a2e1e42071066e9c225da + many_quotes: e2ff57b8058ab2c03c5b07cf901a7a48 + missing_semicolon: b2b5f0c3f552a348188de51bd4fcf511 + settings_title: 8df6777277469c1fd88cc18dde2f1cc3 + save_button: f7a2929f33bc420195e59ac5a8bcd454 diff --git a/packages/cli/demo/xcode-stringsdict/en/example.stringsdict b/packages/cli/demo/xcode-stringsdict/en/example.stringsdict new file mode 100644 index 000000000..764c410c8 --- /dev/null +++ b/packages/cli/demo/xcode-stringsdict/en/example.stringsdict @@ -0,0 +1,43 @@ + + + + + welcome_message + + NSStringLocalizedFormatKey + Hello %#@user_count@! + user_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + No users + one + 1 user + other + %d users + + + + notification_count + + NSStringLocalizedFormatKey + %#@count@ notifications + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + no + one + 1 + other + %d + + + + \ No newline at end of file diff --git a/packages/cli/demo/xcode-stringsdict/es/example.stringsdict b/packages/cli/demo/xcode-stringsdict/es/example.stringsdict new file mode 100644 index 000000000..a09ec673e --- /dev/null +++ b/packages/cli/demo/xcode-stringsdict/es/example.stringsdict @@ -0,0 +1,42 @@ + + + + + welcome_message + + NSStringLocalizedFormatKey + ¡Hola %#@user_count@! + user_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + Ningún usuario + one + 1 usuario + other + %d usuarios + + + notification_count + + NSStringLocalizedFormatKey + %#@count@ notificaciones + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + ninguna + one + 1 + other + %d + + + + \ No newline at end of file diff --git a/packages/cli/demo/xcode-stringsdict/i18n.json b/packages/cli/demo/xcode-stringsdict/i18n.json new file mode 100644 index 000000000..f325ebb36 --- /dev/null +++ b/packages/cli/demo/xcode-stringsdict/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "xcode-stringsdict": { + "include": [ + "./[locale]/example.stringsdict" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/xcode-stringsdict/i18n.lock b/packages/cli/demo/xcode-stringsdict/i18n.lock new file mode 100644 index 000000000..f73277a72 --- /dev/null +++ b/packages/cli/demo/xcode-stringsdict/i18n.lock @@ -0,0 +1,14 @@ +version: 1 +checksums: + a96f640d0f744d993247e36ea1b33052: + welcome_message/NSStringLocalizedFormatKey: f142738692c027d95dce521e7cb29c82 + welcome_message/user_count/NSStringFormatSpecTypeKey: 8cc03ef30ad5b2d8e27f3612627d932e + welcome_message/user_count/NSStringFormatValueTypeKey: fe9efa39a6fd9f10358f43f00e0ab82b + welcome_message/user_count/zero: 3d4643c483a49c2f61e17aaa8620e71e + welcome_message/user_count/one: 3b547431ab12f0fba84307e6a81109d8 + welcome_message/user_count/other: cb01ae522c991a2ad651b4049339c48a + notification_count/NSStringLocalizedFormatKey: e01fd796051132b678d7574a11e9a944 + notification_count/count/NSStringFormatSpecTypeKey: 8cc03ef30ad5b2d8e27f3612627d932e + notification_count/count/NSStringFormatValueTypeKey: fe9efa39a6fd9f10358f43f00e0ab82b + notification_count/count/zero: ac0137deebf6e2b972c6c714dd8658ee + notification_count/count/other: 9b350a78e1c499b9ab69eb330162c8ee diff --git a/packages/cli/demo/xcode-xcstrings-v2/complex-example.xcstrings b/packages/cli/demo/xcode-xcstrings-v2/complex-example.xcstrings new file mode 100644 index 000000000..e558f8aea --- /dev/null +++ b/packages/cli/demo/xcode-xcstrings-v2/complex-example.xcstrings @@ -0,0 +1,958 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "plural_comments_thread" : { + "comment" : "Comment thread depth", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "View 1 comment" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "View all %d comments" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start a conversation" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ver 1 comentario" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ver todos los %d comentarios" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar una conversación" + } + } + } + } + } + } + }, + "plural_complex_sentence" : { + "comment" : "Complex sentence with multiple clauses", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ uploaded 1 file today" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ uploaded %d files today" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ hasn't uploaded any files yet" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ha subido 1 archivo hoy" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ha subido %d archivos hoy" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ no ha subido ningún archivo todavía" + } + } + } + } + } + } + }, + "plural_download_speed" : { + "comment" : "Download speed with rate", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloading 1 file at %.1f MB/s" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloading %d files at %.1f MB/s" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descargando 1 archivo a %.1f MB/s" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descargando %d archivos a %.1f MB/s" + } + } + } + } + } + } + }, + "plural_likes_with_names" : { + "comment" : "Social media likes with first liker name", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ likes this" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ and %d others like this" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No likes yet" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "A %@ le gusta esto" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "A %@ y %d más les gusta esto" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aún no hay me gusta" + } + } + } + } + } + } + }, + "plural_mixed_types" : { + "comment" : "Mix of different variable types: string, int, float", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ purchased 1 item for $%.2f" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ purchased %d items for $%.2f" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ compró 1 artículo por $%.2f" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ compró %d artículos por $%.2f" + } + } + } + } + } + } + }, + "plural_participants" : { + "comment" : "Event participants with specific names", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is participating" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ and %d others are participating" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No participants" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ está participando" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ y %d más están participando" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay participantes" + } + } + } + } + } + } + }, + "plural_positional_args" : { + "comment" : "Using positional arguments (e.g., %1$@, %2$d)", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ has 1 follower in %2$@" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ has %2$d followers in %3$@" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ tiene 1 seguidor en %2$@" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ tiene %2$d seguidores en %3$@" + } + } + } + } + } + } + }, + "plural_storage_usage" : { + "comment" : "Storage usage with precision", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.2f GB used" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.2f GB used of %.2f GB" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.2f GB usado" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.2f GB usados de %.2f GB" + } + } + } + } + } + } + }, + "plural_time_remaining" : { + "comment" : "Time remaining with minutes", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 minute remaining" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d minutes remaining" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 minuto restante" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d minutos restantes" + } + } + } + } + } + } + }, + "plural_unread_notifications" : { + "comment" : "Unread notifications with user mention", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 new notification from %@" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d new notifications" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No new notifications" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 nueva notificación de %@" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d nuevas notificaciones" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay nuevas notificaciones" + } + } + } + } + } + } + }, + "plural_with_float" : { + "comment" : "Plural with floating point precision", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1f mile" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1f miles" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1f milla" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1f millas" + } + } + } + } + } + } + }, + "plural_with_high_precision" : { + "comment" : "Plural with high precision float", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.2f kilometer" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.2f kilometers" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.2f kilómetro" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.2f kilómetros" + } + } + } + } + } + } + }, + "plural_with_long_long" : { + "comment" : "Plural with long long integer format", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 byte" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld bytes" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No bytes" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 byte" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld bytes" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin bytes" + } + } + } + } + } + } + }, + "plural_with_one_variable" : { + "comment" : "Plural with one non-plural variable (username)", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ has 1 photo" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ has %d photos" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ has no photos" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ tiene 1 foto" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ tiene %d fotos" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ no tiene fotos" + } + } + } + } + } + } + }, + "plural_with_percentage" : { + "comment" : "Progress with percentage", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 file uploaded (%.0f%% complete)" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files uploaded (%.0f%% complete)" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No files uploaded" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 archivo subido (%.0f%% completado)" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d archivos subidos (%.0f%% completado)" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay archivos subidos" + } + } + } + } + } + } + }, + "plural_with_two_variables" : { + "comment" : "Plural with two non-plural variables (username and album name)", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ uploaded 1 photo to %@" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ uploaded %d photos to %@" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ uploaded no photos to %@" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ subió 1 foto a %@" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ subió %d fotos a %@" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ no subió fotos a %@" + } + } + } + } + } + } + }, + "plural_with_units" : { + "comment" : "File size with appropriate units", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 file (%.1f MB)" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files (%.1f MB total)" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empty folder" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 archivo (%.1f MB)" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d archivos (%.1f MB en total)" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpeta vacía" + } + } + } + } + } + } + }, + "plural_with_zero" : { + "comment" : "Plural with optional zero form for better UX", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 message" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d messages" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No messages" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "=0" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin mensajes" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 mensaje" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d mensajes" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay mensajes" + } + } + } + } + } + } + }, + "simple_plural" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 item" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d items" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 artículo" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d artículos" + } + } + } + } + } + } + }, + "simple_string" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome to the app" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bienvenido a la aplicación" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/packages/cli/demo/xcode-xcstrings-v2/example.xcstrings b/packages/cli/demo/xcode-xcstrings-v2/example.xcstrings new file mode 100644 index 000000000..647dd0515 --- /dev/null +++ b/packages/cli/demo/xcode-xcstrings-v2/example.xcstrings @@ -0,0 +1,168 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%d items left" : { + "comment" : "Number of items remaining - key has space and special char", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d items remaining" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d items remaining" + } + } + } + }, + "api_key" : { + "comment" : "API key used for authentication - should not be translated", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sk-1234567890abcdef" + } + } + }, + "shouldTranslate" : false + }, + "item_count" : { + "comment" : "Number of items displayed in the list - supports pluralization", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 item" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d items" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No items" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 artículo" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d artículos" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin artículos" + } + } + } + } + } + } + }, + "notification_message" : { + "comment" : "Notification message with plural substitution - demonstrates substitutions feature", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There is limited availability for %#@count_items@ in this cart." + }, + "substitutions" : { + "count_items" : { + "formatSpecifier" : "d", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%arg item..." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%arg items..." + } + } + } + } + } + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hay disponibilidad limitada para %#@count_items@ en este carrito." + }, + "substitutions" : { + "count_items" : { + "formatSpecifier" : "d", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%arg artículo..." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%arg artículos..." + } + } + } + } + } + } + } + } + }, + "welcome message" : { + "comment" : "Welcome message shown on the app's home screen - key has space", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello, world!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Hola, mundo!" + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/cli/demo/xcode-xcstrings-v2/i18n.json b/packages/cli/demo/xcode-xcstrings-v2/i18n.json new file mode 100644 index 000000000..8040729be --- /dev/null +++ b/packages/cli/demo/xcode-xcstrings-v2/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "xcode-xcstrings-v2": { + "include": [ + "./example.xcstrings" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/xcode-xcstrings-v2/i18n.lock b/packages/cli/demo/xcode-xcstrings-v2/i18n.lock new file mode 100644 index 000000000..4aba99ef1 --- /dev/null +++ b/packages/cli/demo/xcode-xcstrings-v2/i18n.lock @@ -0,0 +1,2 @@ +version: 1 +checksums: {} diff --git a/packages/cli/demo/xcode-xcstrings/example.xcstrings b/packages/cli/demo/xcode-xcstrings/example.xcstrings new file mode 100644 index 000000000..65d7b26e9 --- /dev/null +++ b/packages/cli/demo/xcode-xcstrings/example.xcstrings @@ -0,0 +1,66 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "api_key" : { + "comment" : "API key used for authentication - should not be translated", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sk-1234567890abcdef" + } + } + }, + "shouldTranslate" : false + }, + "item_count" : { + "comment" : "Number of items displayed in the list - supports pluralization", + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 item" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d items" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No items" + } + } + } + } + } + } + }, + "welcome_message" : { + "comment" : "Welcome message shown on the app's home screen", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello, world!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Hola, mundo!" + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/cli/demo/xcode-xcstrings/i18n.json b/packages/cli/demo/xcode-xcstrings/i18n.json new file mode 100644 index 000000000..031e99e0f --- /dev/null +++ b/packages/cli/demo/xcode-xcstrings/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "xcode-xcstrings": { + "include": [ + "./example.xcstrings" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/xcode-xcstrings/i18n.lock b/packages/cli/demo/xcode-xcstrings/i18n.lock new file mode 100644 index 000000000..a50f94e94 --- /dev/null +++ b/packages/cli/demo/xcode-xcstrings/i18n.lock @@ -0,0 +1,12 @@ +version: 1 +checksums: + d3330df55a54ce785f378e7388282a96: + welcome_message: 57da71b2c89eaddcbca81b3c3766deed + item_count/zero: 955c31a42e9ff663a49e69e944c8c537 + item_count/one: 154c8eed472b867c8d5af66d162abd2c + item_count/other: ecb3f18dc6a5e7b3e1d9afe7ef62cf07 + 614e9d20c639fed18ee30b9f9637c83d: + item_count/one: d25ee41f72a2ea94ebc6debc85b9e573 + item_count/other: ecb3f18dc6a5e7b3e1d9afe7ef62cf07 + item_count/zero: 955c31a42e9ff663a49e69e944c8c537 + welcome_message: 0468579ef2fbc83c9d520c2f2f1c5059 diff --git a/packages/cli/demo/xliff/en/example-v1.2.xliff b/packages/cli/demo/xliff/en/example-v1.2.xliff new file mode 100644 index 000000000..0455a8d73 --- /dev/null +++ b/packages/cli/demo/xliff/en/example-v1.2.xliff @@ -0,0 +1,33 @@ + + + + + +
    + + This header content is not localized +
    + + + + Hello, world! + + + + ]]> + + + + Continue + + + + Save Changes + + + + Processing your request... + + +
    +
    \ No newline at end of file diff --git a/packages/cli/demo/xliff/en/example-v2.xliff b/packages/cli/demo/xliff/en/example-v2.xliff new file mode 100644 index 000000000..9c5cdf77f --- /dev/null +++ b/packages/cli/demo/xliff/en/example-v2.xliff @@ -0,0 +1,42 @@ + + + + + + + This header content is not localized + + + + + Hello, world! + + + + + + ]]> + + + + + + Continue + + + + + + Save Changes + + + + + + Processing your request... + + + + diff --git a/packages/cli/demo/xliff/es/example-v1.2.xliff b/packages/cli/demo/xliff/es/example-v1.2.xliff new file mode 100644 index 000000000..9e4d9d9e4 --- /dev/null +++ b/packages/cli/demo/xliff/es/example-v1.2.xliff @@ -0,0 +1,31 @@ + + + +
    + + This header content is not localized +
    + + + Hello, world! + ¡Hola, mundo! + + + ]]> + válido]]> + + + Continue + Continuar + + + Save Changes + Guardar cambios + + + Processing your request... + Procesando tu solicitud... + + +
    +
    \ No newline at end of file diff --git a/packages/cli/demo/xliff/es/example-v2.xliff b/packages/cli/demo/xliff/es/example-v2.xliff new file mode 100644 index 000000000..4869b2326 --- /dev/null +++ b/packages/cli/demo/xliff/es/example-v2.xliff @@ -0,0 +1,33 @@ + + + + + This header content is not localized + + + + ¡Hola, mundo! + + + + + válido]]> + + + + + Continuar + + + + + Guardar cambios + + + + + Procesando tu solicitud... + + + + diff --git a/packages/cli/demo/xliff/es/example.xliff b/packages/cli/demo/xliff/es/example.xliff new file mode 100644 index 000000000..d90a19873 --- /dev/null +++ b/packages/cli/demo/xliff/es/example.xliff @@ -0,0 +1,23 @@ + + + +
    + + This header content is not localized +
    + + + Welcome to our application + Bienvenido a nuestra aplicación + + + address]]> + válida]]> + + + Click here to continue + Haz clic aquí para continuar + + +
    +
    \ No newline at end of file diff --git a/packages/cli/demo/xliff/i18n.json b/packages/cli/demo/xliff/i18n.json new file mode 100644 index 000000000..e274f2a07 --- /dev/null +++ b/packages/cli/demo/xliff/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "xliff": { + "include": [ + "./[locale]/example.xliff" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/xliff/i18n.lock b/packages/cli/demo/xliff/i18n.lock new file mode 100644 index 000000000..ad4f1fab4 --- /dev/null +++ b/packages/cli/demo/xliff/i18n.lock @@ -0,0 +1,6 @@ +version: 1 +checksums: + 33cb76eb379e7352a70c6dace0ea2c20: + welcome_message: 1308168cca4fa5d8d7a0cf24e55e93fc + error.validation: b538f828ae3306cbe35e3bd4e0103870 + button_text: ff2b3010cdfa176f8dceeb1898c428c3 diff --git a/packages/cli/demo/xml/en/example.xml b/packages/cli/demo/xml/en/example.xml new file mode 100644 index 000000000..10bf6abf6 --- /dev/null +++ b/packages/cli/demo/xml/en/example.xml @@ -0,0 +1,31 @@ + + + Hello, world! + + + Simple demo app +
    Basic example content
    +
    + + Example photo + + Learn more + + + +
    +
    + + Nested content + +
    +
    + + + + + Hello there! + Welcome to the app + Thanks, MyApp Team + +
    \ No newline at end of file diff --git a/packages/cli/demo/xml/es/example.xml b/packages/cli/demo/xml/es/example.xml new file mode 100644 index 000000000..2c006ac36 --- /dev/null +++ b/packages/cli/demo/xml/es/example.xml @@ -0,0 +1 @@ +¡Hola, mundo!Aplicación de demostración simple
    Contenido de ejemplo básico
    Foto de ejemploMás información
    Contenido anidado
    ¡Hola!Bienvenido a la aplicaciónGracias, equipo de MyApp
    \ No newline at end of file diff --git a/packages/cli/demo/xml/i18n.json b/packages/cli/demo/xml/i18n.json new file mode 100644 index 000000000..227524d3e --- /dev/null +++ b/packages/cli/demo/xml/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "xml": { + "include": [ + "./[locale]/example.xml" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/xml/i18n.lock b/packages/cli/demo/xml/i18n.lock new file mode 100644 index 000000000..1a0caef29 --- /dev/null +++ b/packages/cli/demo/xml/i18n.lock @@ -0,0 +1,22 @@ +version: 1 +checksums: + ec06a6ebae97ffd5f7afc99d9a8f051b: + root/title: 0468579ef2fbc83c9d520c2f2f1c5059 + root/description/summary: f2c85bf6eeebeea33609e04598201bb6 + root/description/details: 2ee85b8f2f0f1bc008d9cf1f916cb09c + root/image/%24/src: 3ce26f0a5486adf10e1b7eee1b866a70 + root/image/%24/alt: 94058fbed56fffaef2e9bbea59ba4a54 + root/image/%24/title: 60487c71b570d9dedca6fddd4a75d16a + root/link/_: e598091d132f890c37a6d4ed94f6d794 + root/link/%24/href: 285d79d2783cf0769ab9e767362c1499 + root/link/%24/label: 26ce69aad587f70d47e7606436bf1d6d + root/button/_: 7c91ef5f747eea9f77a9c4f23e19fb2e + root/button/%24/type: fa8748b22d5bac98fdcd57e3d6594cf3 + root/button/%24/value: 7c91ef5f747eea9f77a9c4f23e19fb2e + root/button/%24/placeholder: 7b5d59cee6952db66043a4b289b51884 + root/section/article/paragraph/sentence: 28ca53c71a2e3e3de79c892a9b193b1a + root/meta/%24/name: d097029e873a4b19132e2603bd2c9fe4 + root/meta/%24/content: 0811ae3ab84aa87205383c3d8ac42bf3 + root/message/greeting: 85559fc839c5181b7958654e62c987d5 + root/message/body: ed0d4b1cde20d045f9f8c5007c784b0b + root/message/signature: 181c8c304980949e101865098f548705 diff --git a/packages/cli/demo/yaml-root-key/en/example.yml b/packages/cli/demo/yaml-root-key/en/example.yml new file mode 100644 index 000000000..08f0687ea --- /dev/null +++ b/packages/cli/demo/yaml-root-key/en/example.yml @@ -0,0 +1,55 @@ +en: + navigation: + home: "Home" + about: "About us" + contact: "Contact" + services: "Services" + forms: + title: "Contact form" + name_label: "Your name" + email_label: "Email address" + "message_label": Message + submit_button: Submit message + success_message: "Thank you for your message!" + inflections: + number_of_items: 100 + gender: + f: "Feminine" + m: "Masculine" + n: "Neuter" + female: :@f + male: :@m + neuter: :@n + F: :@f + M: :@m + N: :@n + default: :m + date: + abbr_day_names: + - "Mon" + - "Tue" + - "Wed" + - "Thu" + - "Fri" + - "Sat" + - "Sun" + abbr_month_names: + - null + - "Jan" + - "Feb" + - "Mar" + - "Apr" + - "May" + - "Jun" + - "Jul" + - "Aug" + - "Sep" + - "Oct" + - "Nov" + - "Dec" + formats: + default: "MM/dd/yyyy" + long: "MMMM d, yyyy" + short: "MM/dd/yy" + time: "h:mm a" + time_with_seconds: "h:mm:ss a" diff --git a/packages/cli/demo/yaml-root-key/es/example.yml b/packages/cli/demo/yaml-root-key/es/example.yml new file mode 100644 index 000000000..b6fa9ee90 --- /dev/null +++ b/packages/cli/demo/yaml-root-key/es/example.yml @@ -0,0 +1,55 @@ +es: + navigation: + home: "Inicio" + about: "Acerca de nosotros" + contact: "Contacto" + services: "Servicios" + forms: + title: "Formulario de contacto" + name_label: "Tu nombre" + email_label: "Dirección de correo electrónico" + "message_label": Mensaje + submit_button: Enviar mensaje + success_message: "¡Gracias por tu mensaje!" + inflections: + number_of_items: 100 + gender: + f: "Femenino" + m: "Masculino" + n: "Neutro" + female: :@f + male: :@m + neuter: :@n + F: :@f + M: :@m + N: :@n + default: :m + date: + abbr_day_names: + - "Lun" + - "Mar" + - "Mié" + - "Jue" + - "Vie" + - "sáb" + - "dom" + abbr_month_names: + - null + - "ene" + - "feb" + - "mar" + - "abr" + - "may" + - "jun" + - "jul" + - "ago" + - "sep" + - "oct" + - "nov" + - "dic" + formats: + default: "dd/MM/yyyy" + long: "d 'de' MMMM 'de' yyyy" + short: "dd/MM/yy" + time: "H:mm" + time_with_seconds: "H:mm:ss" diff --git a/packages/cli/demo/yaml-root-key/i18n.json b/packages/cli/demo/yaml-root-key/i18n.json new file mode 100644 index 000000000..eeffaab1b --- /dev/null +++ b/packages/cli/demo/yaml-root-key/i18n.json @@ -0,0 +1,17 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "yaml-root-key": { + "include": [ + "./[locale]/example.yml" + ] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} diff --git a/packages/cli/demo/yaml-root-key/i18n.lock b/packages/cli/demo/yaml-root-key/i18n.lock new file mode 100644 index 000000000..197d3dd5c --- /dev/null +++ b/packages/cli/demo/yaml-root-key/i18n.lock @@ -0,0 +1,14 @@ +version: 1 +checksums: + 1b0d7c9f07dcc31a978bc337763270ea: {} + 87b1c33c3f85415e0906ece6cfed17c5: + navigation/home: 104a3db3b671c04e167eafbe21e57881 + navigation/about: 8f89131a66d4659be07cd5af2c7ea898 + navigation/contact: 9afa39bc47019ee6dec6c74b6273967c + navigation/services: 8ea10b45b9abab2a3bfc3c07e1c9cdc6 + forms/title: ac85dea7c7f0bf1cd7d48cc1b4da3acc + forms/name_label: 03c6ae7996d5841f743cd406b4eff72d + forms/email_label: 0ee22bbbe989a0c61a18023407d12dc2 + forms/message_label: f2f72126bd244cfc534eab395e054362 + forms/submit_button: 487177489aafc9c0243c57ef3850a2d9 + forms/success_message: a0a7aa980dffa31d4d194af718a917b3 diff --git a/packages/cli/demo/yaml/en/example.yml b/packages/cli/demo/yaml/en/example.yml new file mode 100644 index 000000000..1b9764ef3 --- /dev/null +++ b/packages/cli/demo/yaml/en/example.yml @@ -0,0 +1,25 @@ +title: "MyApp" +description: Hello, world! +welcome_message: "Welcome to MyApp" +user_profile: + display_name: "John Doe" + bio: Software developer +navigation_items: + - "Home" + - "About" + - "Contact" +product: + name: "MyWidget" + tagline: The best widget ever + features: + - "Easy to use" + - "Fast and reliable" +settings: + max_users: 100 + enabled: true + timeout: 30.5 +complex_structure: + level_one: + level_two: + message: "Deep nested text" +locked_key_1: "This value is locked and should not be changed" \ No newline at end of file diff --git a/packages/cli/demo/yaml/es/example.yml b/packages/cli/demo/yaml/es/example.yml new file mode 100644 index 000000000..055bfa9b0 --- /dev/null +++ b/packages/cli/demo/yaml/es/example.yml @@ -0,0 +1,25 @@ +title: "MyApp" +description: ¡Hola, mundo! +welcome_message: "Bienvenido a MyApp" +user_profile: + display_name: "John Doe" + bio: Desarrollador de software +navigation_items: + - "Inicio" + - "Acerca de" + - "Contacto" +product: + name: "MyWidget" + tagline: El mejor widget de todos + features: + - "Fácil de usar" + - "Rápido y confiable" +settings: + max_users: 100 + enabled: true + timeout: 30.5 +complex_structure: + level_one: + level_two: + message: "Texto profundamente anidado" +locked_key_1: "This value is locked and should not be changed" \ No newline at end of file diff --git a/packages/cli/demo/yaml/i18n.json b/packages/cli/demo/yaml/i18n.json new file mode 100644 index 000000000..e85056cc1 --- /dev/null +++ b/packages/cli/demo/yaml/i18n.json @@ -0,0 +1,18 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": [ + "es" + ] + }, + "buckets": { + "yaml": { + "include": [ + "./[locale]/example.yml" + ], + "lockedKeys": ["locked_key_1"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} \ No newline at end of file diff --git a/packages/cli/demo/yaml/i18n.lock b/packages/cli/demo/yaml/i18n.lock new file mode 100644 index 000000000..574aa9a27 --- /dev/null +++ b/packages/cli/demo/yaml/i18n.lock @@ -0,0 +1,16 @@ +version: 1 +checksums: + 1b0d7c9f07dcc31a978bc337763270ea: + title: 7dc70110429d46e3685f385bd2cc941c + description: 0468579ef2fbc83c9d520c2f2f1c5059 + welcome_message: d1c3a9f35e377554a4ccaa467ca26614 + user_profile/display_name: febee8e9ab40b2fe5106d72675228d00 + user_profile/bio: 155ddcb7c93493ac72a37074eea0a653 + navigation_items/0: 104a3db3b671c04e167eafbe21e57881 + navigation_items/1: 944521eeeed2511833d2299931273c71 + navigation_items/2: 9afa39bc47019ee6dec6c74b6273967c + product/name: ed21de171d538a49db999c47875f75a5 + product/tagline: b7ac41680e82d75ae7f5774f7ceef1b4 + product/features/0: c916ba887951a02793ff851853fd964f + product/features/1: 1c60a04d6890c6ec910a7f2e6ec0ae7b + complex_structure/level_one/level_two/message: b53034560bf657106e5aaea9160e357e diff --git a/packages/cli/i18n.json b/packages/cli/i18n.json new file mode 100644 index 000000000..2ac197d8b --- /dev/null +++ b/packages/cli/i18n.json @@ -0,0 +1,122 @@ +{ + "version": "1.10", + "locale": { + "source": "en", + "targets": ["es"] + }, + "buckets": { + "ail": { + "include": ["demo/ail/*.ail"] + }, + "xliff": { + "include": ["demo/xliff/[locale]/*.xliff"] + }, + "android": { + "include": ["demo/android/[locale]/*.xml"] + }, + "csv": { + "include": ["demo/csv/example.csv"] + }, + "csv-per-locale": { + "include": ["demo/csv-per-locale/[locale]/example.csv"], + "lockedKeys": ["locked_key_1"], + "ignoredKeys": ["ignored_key_1"] + }, + "ejs": { + "include": ["demo/ejs/[locale]/*.ejs"] + }, + "flutter": { + "include": ["demo/flutter/[locale]/*.arb"] + }, + "html": { + "include": ["demo/html/[locale]/*.html"] + }, + "json": { + "include": ["demo/json/[locale]/example.json"], + "lockedKeys": ["locked_key_1"], + "ignoredKeys": ["ignored_key_1"] + }, + "jsonc": { + "include": ["demo/jsonc/[locale]/example.jsonc"], + "lockedKeys": ["locked_key_1"], + "ignoredKeys": ["ignored_key_1"] + }, + "json5": { + "include": ["demo/json5/[locale]/example.json5"] + }, + "json-dictionary": { + "include": ["demo/json-dictionary/example.json"] + }, + "markdoc": { + "include": ["demo/markdoc/[locale]/*.markdoc"] + }, + "markdown": { + "include": ["demo/markdown/[locale]/*.md"], + "exclude": ["demo/markdown/[locale]/ignored.md"] + }, + "mdx": { + "include": ["demo/mdx/[locale]/*.mdx"], + "lockedKeys": ["meta/locked_key_1"], + "ignoredKeys": ["meta/ignored_key_1"] + }, + "mjml": { + "include": ["demo/mjml/[locale]/*.mjml"] + }, + "po": { + "include": ["demo/po/[locale]/*.po"] + }, + "properties": { + "include": ["demo/properties/[locale]/*.properties"] + }, + "srt": { + "include": ["demo/srt/[locale]/*.srt"] + }, + "txt": { + "include": ["demo/txt/[locale]/*.txt"] + }, + "typescript": { + "include": ["demo/typescript/[locale]/*.ts"], + "lockedKeys": ["forms/locked_key_1"], + "ignoredKeys": ["forms/ignored_key_1"] + }, + "twig": { + "include": ["demo/twig/[locale]/*.twig"] + }, + "vtt": { + "include": ["demo/vtt/[locale]/*.vtt"] + }, + "xcode-strings": { + "include": ["demo/xcode-strings/[locale]/*.strings"] + }, + "xcode-stringsdict": { + "include": ["demo/xcode-stringsdict/[locale]/*.stringsdict"] + }, + "xcode-xcstrings": { + "include": ["demo/xcode-xcstrings/*.xcstrings"], + "lockedKeys": ["api_key"], + "ignoredKeys": ["item_count"] + }, + "xcode-xcstrings-v2": { + "include": ["demo/xcode-xcstrings-v2/example.xcstrings"], + "lockedKeys": ["%d items left"] + }, + "xml": { + "include": ["demo/xml/[locale]/*.xml"] + }, + "yaml": { + "include": ["demo/yaml/[locale]/*.yml"], + "lockedKeys": ["locked_key_1"], + "ignoredKeys": ["ignored_key_1"] + }, + "yaml-root-key": { + "include": ["demo/yaml-root-key/[locale]/*.yml"] + }, + "php": { + "include": ["demo/php/[locale]/*.php"] + }, + "vue-json": { + "include": ["demo/vue-json/*.vue"] + } + }, + "$schema": "https://lingo.dev/schema/i18n.json" +} diff --git a/packages/cli/i18n.lock b/packages/cli/i18n.lock new file mode 100644 index 000000000..9a15786ed --- /dev/null +++ b/packages/cli/i18n.lock @@ -0,0 +1,882 @@ +version: 1 +checksums: + 8d96b892e6b2722deed5b45d9a3f5654: + mjml/mj-head/0/mj-title/0: c514a686b50f7158b2dd08ea65d3bc8a + mjml/mj-head/0/mj-preview/0: 4ce14f6062c814cbdcdf8b0a3cb094d3 + mjml/mj-body/0/mj-section/0/mj-column/0/mj-image/0#alt: 82d5c0d5994508210ee02d684819f4b8 + mjml/mj-body/0/mj-section/1/mj-column/0/mj-text/0: b320b02942617a70dcbd1beac61da11a + mjml/mj-body/0/mj-section/1/mj-column/0/mj-text/1: 028311348a5aeefea365fdf422a3fb21 + mjml/mj-body/0/mj-section/1/mj-column/0/mj-text/2: 0dfdc9b80ee70fcc2b28d0e81e03fabc + mjml/mj-body/0/mj-section/2/mj-column/0/mj-button/0#title: 5c96f738bd6153ee07b72094cdfd2b98 + mjml/mj-body/0/mj-section/2/mj-column/0/mj-button/0#aria-label: 42dcab68d931f9145d9b6d76740a5c66 + mjml/mj-body/0/mj-section/2/mj-column/0/mj-button/0: dc8001d5c58294d22fe0b0e6118dbfb7 + mjml/mj-body/0/mj-section/3/mj-column/0/mj-text/0: a18f14ab69467cbdbe467df6255cfda7 + mjml/mj-body/0/mj-section/3/mj-column/0/mj-text/1: e83236e98aad1937bc99a47cff159caa + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/0#title: 180bd8aa700f6cedf65e0a2079503cea + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/0#alt: ac8afe226a7424849c247e6a9d566f64 + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/1#title: ea4c2a7a9a60cbb0f8f9632222a46abe + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/1#alt: ba3d4aed69a50759b53a0b7c319a3ad9 + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/2#title: 754efa5f98f51c510ff268e217877d8b + mjml/mj-body/0/mj-section/4/mj-column/0/mj-social/0/mj-social-element/2#alt: c9555810826c30d571ffae869a236494 + mjml/mj-body/0/mj-section/4/mj-column/0/mj-text/0: 9ac6c625c7af33d70634846c8c9d11b0 + 8a29de2ca48b6f70dc45a19eab5c4fce: + sourceLanguage: cdbbce7452d5b27f1b4ad1eec67ea0fb + resources%2Fmessages.properties%2Fwelcome_message%2Fsource: 0468579ef2fbc83c9d520c2f2f1c5059 + resources%2Fmessages.properties%2Ferror_message%2Fsource: 09e95617fe980e06fe01dcb814145529 + resources%2Fmessages.properties%2Fbutton_text%2Fsource: 3cfba90b4600131e82fc4260c568d044 + resources%2Fmessages.properties%2Fsave_button%2Fsource: d895276cde226e9225eca1e74aa799f4 + resources%2Fmessages.properties%2Fstatus_message%2Fsource: c5febb38cd2357e1ae0ac7824c52b11d + cba6bb8409869d70442daa54e273a816: + welcome_message: 0468579ef2fbc83c9d520c2f2f1c5059 + error.validation: 09e95617fe980e06fe01dcb814145529 + button_text: 3cfba90b4600131e82fc4260c568d044 + save_button: d895276cde226e9225eca1e74aa799f4 + status_message: c5febb38cd2357e1ae0ac7824c52b11d + ff69fd831da0e77f25d19bc16ecd1955: + app_name: 7dc70110429d46e3685f385bd2cc941c + welcome_message: 0468579ef2fbc83c9d520c2f2f1c5059 + button_text: 1d5f030c4ec9c869e647ae060518b948 + color_names/0: bace0083b78cdb188523bc4abc7b55c6 + color_names/1: 482ff383a4258357ba404f283682471d + color_names/2: a3f56a5c28ea75888356c2280aadc0e7 + notification_count/one: fe0aceb70f334c52a87937c36898a1d0 + notification_count/other: 13acfd95b16962ebe1f67dcd343513e1 + html_snippet: f060191b1af70b3848106a4df91f43cd + apostrophe_example: 997099339b144b06266f8da411de8d93 + cdata_example: b88d55f92c4a90f64016e497051e997d + mixed_items/0: 31c5d470a2fe8e1ae88e964fc673aee3 + mixed_items/1: 9823a57cbe6e6e84c1d025ce24a1eec4 + terms_of_use_raw: e3048f75742e66473369a83c10ea95c3 + view_your_options: 416ed59ca3254f9da0d565c7c75f9033 + 26c0d1830ada5fc2893012ae36df612d: + welcome_message: 1308168cca4fa5d8d7a0cf24e55e93fc + button_save: f7a2929f33bc420195e59ac5a8bcd454 + error_invalid_email: 8de4bc8832b11b380bc4cbcedc16e48b + product_name: d3d99b147cc363dc6db8a48e8a13d4c1 + new_feature: 7cd986af1fe5e89abe7ecffba5413110 + 0f98b7647666155847e3e788a431b083: + text_0: e4d2da607604b3fda41eef5e0dd35faa + text_1: 69eb28c44f7168b1df0455ad2a62588c + text_2: bff335b01588a8db802bd193c725ec11 + text_3: 0744639a7ac440afe0d792ea79c54512 + text_4: b4cc462fb3a00d2f60deefe548c10a33 + text_5: d0fd310aef9cf3c5827f1db4b0c098a1 + text_6: 85bb1f6fb66b5ab65a9c61469183236e + text_7: bdbc827b3d224e03394dfd56304500f2 + text_8: 5e8497af456decf6cf716c0a23f1dbc2 + text_9: d572e25ed81420669e65c03925da1001 + text_10: 2cf6537fb69cdd2eb030e55bf4223b93 + text_11: ec7b8f314fe9bc6591006707484ede61 + text_12: c2460fb2a7887fdf2d68db2b553a4338 + text_13: 3abe623951250bd24a9d7799415761ab + text_14: 988be328b82702586f2cd541858710fe + text_15: b2328773b0ef0699fd5791055c5cf9e2 + text_16: 92acabd12cd9b63c825294c54fcbc806 + 92012b8cd020f7be41f85bab88f5d251: + welcome_message: 9c56d00796c3c7facba6a25275de5b7b + login_button: 0029e5a35676c0051e761fcd046ef9ee + signup_button: 0dd2ae69be4618c1f9e615774a4509ca + error_network: e0becd5fc88e85a97c6987b96c80fb11 + success_message: e6dcd73052dc41dbe05d86af9887d2c4 + user_profile_title: bee775ff7216747b2111e93cefa57ddc + settings_screen_title: 8df6777277469c1fd88cc18dde2f1cc3 + cancel_action: 2e2a849c2223911717de8caa2c71bade + confirm_action: 90930b51154032f119fa75c1bd422d8b + greeting_with_name: 218521f06746e82ba44d68c8a5bb210c + item_count: d8d083b56bc155cf0997ea6ae989b83f + user_status: 903b5a6edde5368dd9089accdc1b1f9d + 9549584f354a859ae39756a4d4e546a6: + head/0: 7d39787547365ee4194f29f3f54e5c05 + head/1#content: 49f8864eb0e53903f04532bf33e1e4fa + head/2#content: c2a1da93efb7e744d100df705e5fcbfd + head/3#content: d94b318cb327f61f1aea44a6cb1fdcad + body/0: d1c3a9f35e377554a4ccaa467ca26614 + body/1: 886bab34500cc953ed339a540fa15a01 + body/2/0: f1e6240e8bf6f6264d122322892f6f1b + body/3#alt: 68f95fca639f8bf72a4796b6734b02d5 + body/4#alt: cb7d920c3bbcade1c8e0307093f58573 + body/4#title: b065d13c12906a65ae9a80ad58f57804 + body/5#placeholder: a05ce3b4578f55e41bd2ad4964f966b4 + body/6#placeholder: a4554ed67c02872e302b0042724f859d + body/7#title: c903c6985a40ce02d65c90229de35a4e + body/7: e598091d132f890c37a6d4ed94f6d794 + body/8#title: d656021ba5f485fa1a82f8aac6ecc5de + body/8: 1c6856488bd34ad87fcacce2d8e66a0b + body/9: 862964e6cd73cdffdcac622406c6bac9 + 61124498fe2038e02d0c86102e68efb6: + head/1#content: 87c1cace5dae97b8d8e7e33935d38648 + head/2#content: b5e67d04e51169a06100e8073a2af399 + head/3#content: 6dd9850e2dd00fa90543d76492fac178 + head/4#content: 927eae1ba94d9613db75205352278538 + head/5#content: ca6d9e7477f55ac948c2d0e2392b19d3 + head/6#content: 3e695f9f6c1ac8162d0ad4b3c37e9923 + head/7#content: e0f5363c63a67f2001d65d98de31bec2 + head/8#content: 673b19ac81d26b874750872da85b69af + head/9#content: f5f171e3b792ae88ad4316b892ab4feb + head/10#content: f0cf9abfcc52ca0cb2d99bae3ee26afa + head/11#content: c5ba2f94feaae5f23020d53f1b87e2e7 + head/12#content: 300ff9c2ab7ad93ec799bd96f13268a1 + head/13#content: 63daa51677c557c1382d40be1c7d1f08 + head/14#content: 53d07ce1ee9351ace5220d1694f6c548 + head/15#content: 11ca112e48517b3addf6a156603308b7 + head/16#content: 3c7b20f91f8fa888969cc818b3925749 + head/19#content: d387d7423ccda42d2f7b9f6e0a75ee76 + head/20#content: 63daa51677c557c1382d40be1c7d1f08 + head/21#content: f14c6f672f70a6d464312134e3b13fcc + head/31#title: 76814904f99f5c708993a63933218b53 + head/32#title: 66b06156bbfc5878ac6593f289087de7 + head/36#title: 49dd6c21604b5e8d4153ff1aff2177e1 + head/39: 63daa51677c557c1382d40be1c7d1f08 + body/0: 4a7d27cf91a0270eb8843cf630013650 + body/1/0/0/0: 63daa51677c557c1382d40be1c7d1f08 + body/1/0/0/1: 62aeeef7dac63c8d6fe616a3cac9dcf6 + body/1/0/0/2: 179023c1a0b2826185d6e57ecde1f9f5 + body/1/0/1/0/0/0: f7760908c3fd8011002970228076f952 + body/1/0/1/0/0/1: bd7c665158280b3ee401a1f5e3eb3982 + body/1/0/1/0/0/2: ace03c3afa054f3b3273844f10e26fdf + body/1/0/1/0/0/3: b51febcc48512258f34262b89c43f3d6 + body/1/0/1/0/0/4: 7f2852d8b918329969657dc332d3f187 + body/1/0/1/1: dd03ee6b83901fc440f90236483dafc5 + body/2/0/0/0: 5002bf1bc4d510aa4b3c32e6942c2534 + body/2/0/0/1/0/0: 443a272585670118a9d2eb690b04068a + body/2/0/0/1/0/1: 005ed361f9983cec5c7fcfaa950aa47e + body/2/0/0/1/1: f1085711ee61a2e9f3828092aa1fd78d + body/2/0/0/1/2: 48cf08e6817a9b025211ba452dbf69ef + body/2/0/0/1/3: b6be1118d8495fe2ae3743a0b88ba697 + body/2/0/0/1/4: c9e53f98f2838da2bf1fb68f74a5bca9 + body/2/0/0/1/5/0: 7bc30455d723b29e3330af0e06f97d00 + body/2/0/0/1/5/1: 3e8eb8d10f92a2781cc1bd5022d6e4a8 + body/2/0/0/1/7: 5dccad892e0e190636fa2bdc389c4c8b + body/2/0/0/1/8/0: 7c633df96da956f320e3ee5923deee7a + body/2/0/0/1/8/1: 284f7f0a4daf83467dd94ffaf26b75eb + body/2/0/0/1/9/0: d16c67c5a6cd69f37c6d2ec1d41bba67 + body/2/0/0/1/9/1: a04b153d6606549db374683f17de8d74 + body/2/0/0/1/10/0: ada6d7eab6bcebbf0bc4423595414385 + body/2/0/0/1/10/1: 5b75280dafa40a7da2baa8bc701bca49 + body/2/0/0/1/11/0: b200e55c8532fbad3dd1373a188e7fc1 + body/2/0/0/1/11/1: 8665a3a1768b8e41732211ba9bfaa125 + body/2/0/0/1/11/2: 2a78f643c64664457b73ad92ac20baed + body/2/0/0/1/11/3: a5af6aa8e725eb77a06b1e9414d146b5 + body/2/0/0/1/12/0#alt: 74efadaed0d05c3a5905f7e4f950645c + body/2/0/0/1/12/1: 1b876723babaccb49d137f8ea6fee7a2 + body/2/0/0/1/13/0: 39daa202d315989cd33f42148d15c37d + body/2/0/0/1/13/1: 7ce3b9df4a17edfa91c9a184c9acb1e3 + body/2/0/0/1/14: bfa06f332273214d53c9d5098326e80c + body/2/0/0/1/15/0: ca92010bbf04f01955b23a9831e64195 + body/2/0/1/0: 1a5f7e07b06eea3b94956b037ee0fca9 + body/2/0/1/1/0/0: cd9a263db2efa2b80c14a5a6767e5158 + body/2/0/1/1/0/1: 788ed526bc172610d71c19a77a157962 + body/2/0/1/1/1/0: 8d000d3f6b2fc6bc4e6fd14136acddf8 + body/2/0/1/1/1/1: 2d57000ccd0ab4d4d7e4e3089dccbe65 + body/2/0/1/1/2/0: bf0f878a2030ec1e1c405073a78f7ca9 + body/2/0/1/1/2/1: 1682cf1d95f5defb44e67d37cd17a642 + body/2/0/1/2: baf7300216bbed89ca80cb784f7ef2a7 + body/2/0/1/3/0: 0923275764252dfefc577961f16d33c7 + body/2/0/2/0: 7e80747687b90e33d43e1ecfb8c2b624 + body/2/0/2/1: 2be95a0576c5f2dc8c4359afa6cac340 + body/2/0/2/2: 22c7c4d2bca99ae95cf2a41515c74c4d + body/2/0/2/3: 8050c90e4289b105a0780f0fdda6ff66 + body/2/0/2/4: c1a5269f77b04ec5f2918e45c0db66df + body/2/0/2/5: 32f3449aa6a3bd8b5af54691b794532f + body/2/0/2/6/1#alt: 80a3917af41089617410b43e39573a8a + body/2/0/2/7: 05cb2a340027ff4ccd398cfaebfc1469 + body/2/0/2/10: 71002a0cda82d0a2d2df8960e8fefb7b + body/2/0/2/11: 05eb2940365d3e06a2b1d05aba7f13e5 + body/2/0/2/12: 9c98ee8c22dede522efea5629790e428 + body/2/0/3/0: 077294fc58d2b680c87d31926ff38646 + body/2/0/3/1: 434f13dd5ab41ae16a1771cbe3a19ec9 + body/2/0/4/0: 4b8762309a29ceb0e6abe037f47b4cfe + body/2/0/4/1: 134d0dd47418ae278431d8feb077a01e + body/2/0/5/0: 2ef494d69144fcb05bcb6db6c79e55ca + body/2/0/5/1/0/0: 1f5b53b9904ec41d49c1e726e3d56b40 + body/2/0/5/1/0/1: 4ddccc1974775ed7357f9beaf9361cec + body/2/0/5/1/0/2#placeholder: f01e599cced8b7d7105329947b5096de + body/2/0/5/1/0/3: 223a61cf906ab9c40d22612c588dff48 + body/2/0/5/1/0/5: e7f34943a0c2fb849db1839ff6ef5cb5 + body/2/0/5/1/0/6#placeholder: 6b0c96a86624c61fd6a35719b9a32704 + body/2/0/5/1/0/7: ca97457614226960d41dd18c3c29c86b + body/2/0/5/1/0/8#placeholder: 4bedfc5b02cbff3d37abc3f359f91e20 + body/2/0/5/1/0/9: e59677c4302af38259384c09c5d26025 + body/2/0/5/1/0/10#placeholder: 565d39c67ccaab5c4aff14175de34e3c + body/2/0/5/1/0/11: 49dd6c21604b5e8d4153ff1aff2177e1 + body/2/0/5/1/0/13: 7d6b3b3a3630f607188ee3b2c8687f03 + body/2/0/5/1/0/14: 2789f8391f63e7200a5521078aab017d + body/2/0/5/1/0/16: 1fad969ecf3de1c21df046b93053c422 + body/2/0/5/1/0/18: 9d53d1d120e8b8954bcae9a322573748 + body/2/0/5/1/0/20: 56f41c5d30a76295bb087b20b7bee4c3 + body/2/0/5/1/0/22: 863b74b1cdcbf3b04ea79e679b37e29e + body/2/0/5/1/0/24: ae7bef950efc406ff0980affabc1a64c + body/2/0/5/1/0/26: 436fdd694160827dd6ea4644cdd0a8f8 + body/2/0/5/1/0/28: b504a03d52e8001bfdc5cb6205364f42 + body/2/0/5/1/0/30: 1255b55897f2be1443d3bb8c30cd9795 + body/2/0/5/1/0/32: 76aec20f1efb4b47bcbd3a7c7a2b4e66 + body/2/0/5/1/0/33#placeholder: 2b62a3c0a68a4accee7908e235624592 + body/2/0/5/1/0/34: 5ac04c47a98deb85906bc02e0de91ab0 + body/2/0/5/1/0/35: 74c61b6d3ef5b5d239fa9f768a685156 + body/2/0/5/1/0/36: dd968cccbc2145b56bba3c9524f3f2d3 + body/2/0/5/1/0/37#placeholder: e99cba5fc891cc38d237399cbc479338 + body/2/0/5/1/0/39: 1ec22abec772e8f5edc8e598d56d3843 + body/2/0/5/1/0/40: 0ff9caa359ca0fd34c0e2cb0c5d01174 + body/2/0/5/1/0/41: b77f79143d1c352333a47ea419a69fbe + body/2/0/5/1/0/45: 7c91ef5f747eea9f77a9c4f23e19fb2e + body/2/0/5/1/0/46: c19c53a05e21c7f3b395247809a14b51 + body/2/0/5/1/0/47: 0889a3dfd914a7ef638611796b17bf72 + body/2/0/5/1/1/0: da7ca00f480ee314c2896801ef429ea4 + body/2/0/5/1/1/1: 4f2e381ebbefcacd811c0e21771163c9 + body/2/0/5/1/1/3: dd0200d5849ebb7d64c15098ae91d229 + body/2/0/5/1/1/5: d9837510f236ac243e08cbe9c3127da8 + body/2/0/5/1/1/8: 2ac0c6681366b8e4046c6960a14e1504 + body/2/0/5/1/1/10: c2bdcc937b357934ac9662d78d852af9 + body/2/0/5/1/1/12: 4a00dd4d2e73d834ee18d21f5f3650c5 + body/2/0/5/1/1/13: 8f1d81036e38b5e5753cb6546385b924 + body/2/0/6/0: 6653280e32b1c41add771bafa8000591 + body/2/0/6/1/0: d713fc8f199710574212799de1a5fb73 + body/2/0/6/1/2/0/0: 9368b5a047572b6051f334af5aa76819 + body/2/0/6/1/2/0/1: 53743bbb6ca938f5b893552e839d067f + body/2/0/6/1/2/0/2: 4e1fcce15854d824919b4a582c697c90 + body/2/0/6/1/2/0/3: 93c7ef9bc767f51fbe360afcf6b1fecb + body/2/0/6/1/3/0/0: 8d0d788100b42b3ea4e2c45076d1f148 + body/2/0/6/1/3/0/1: b4997d4e5d7c31b1fd8cfbfbba46e013 + body/2/0/6/1/3/0/2: c88138191fb4c14df9ae9f0534aca2ce + body/2/0/6/1/3/1/0: 2e43f51640039df53b343775f0cbdb61 + body/2/0/6/1/3/1/1: 7cfc2a0845ce7ae0ca09301af5ea8a22 + body/2/0/6/1/3/1/2: 82541f089723e16a17d85257a99092b6 + body/2/0/6/1/3/1/3: e84647e929178e65607ee7c1a897b0ab + body/2/0/6/1/4/0/0: 6480370194a4e970842b1a2ef18242a1 + body/2/0/7/0: a11facce8a11ec0809318094e4c931b1 + body/2/0/7/1: c64bd9eaec3f9bcbaf3e7cde56f4aaa2 + body/2/0/7/2: 6e32275e2e75b92a2c28d574d5279c41 + body/2/0/7/3/0: 204a0fc63eb7687c7be7ef8f0a7f42e3 + body/2/0/7/3/1: 3ff961ca447b1200b77ce0e968b31b6f + body/2/0/7/3/2: 0aff1b07d3a76664629413bcc5365fd2 + body/2/0/7/4: 58978e420f4fe37dfefeea3007d05982 + body/2/0/7/5: 38604c9ea19fdd3097e6915d907c0902 + body/2/0/7/6: 64a9c53d57d2f520e05a0b9a491e80a1 + body/2/0/7/7: 037e3f80ad829da941505d199c3660c6 + body/2/0/7/8/0: 3f9078eef0892916d54547dea2337bfc + body/2/0/7/8/1: c44ec16967268516fd51c1ac7c4281c0 + body/2/0/7/9: 14243c62624a4da5553219d5b8a7f284 + body/2/0/7/10: 67ca49294d4f0b92b9b499c1b625dde0 + body/2/0/7/11: 403e3d14286ae86f196e1e96babd8b4d + body/2/0/7/12: 1a04cb30e2858160715e8167f74daa51 + body/2/0/7/13: 331efa9c094bc8ab9b9eec272fcf056c + body/2/0/7/14/0: 787c096af6c141c0efdd867487fc0811 + body/2/0/7/14/1: 82ad9a00332179807639f947a7751900 + body/2/0/7/15: 42db774092f308bc767a759b10a1da90 + body/2/0/7/16: c9d3b9f6b347f0a666ea2338e194dd92 + body/2/0/7/17: ba053a2cc321c9c7a059fea95b7f9bbd + body/2/0/7/18: d68fcfba0918369df6d7a7b8b21ee22b + body/2/0/7/19: f1f64bb5cc22eed6d922323eaf53a7a5 + body/2/0/7/20: 2dab542206ca1c9b37525f6227cda092 + body/2/0/7/21: b63348201e09a0bd3f3136ca4ecf7c8c + body/2/0/7/22: 992468e15234bb249d8dc9b890808ad2 + body/2/0/7/23: c879f1245e558be55db381e4d4cda3a8 + body/2/0/7/24: bca225b0f8c02210afd4df4ed922361b + body/2/1/0: c991b6a9fd74c24e18b224146036bbee + body/2/1/1/0: 49dd6c21604b5e8d4153ff1aff2177e1 + body/2/1/1/1: ea75c9ebe0551fa039857cff8f9805c4 + body/2/1/2/0: 6030e344bc8108f192eec8d7e1bd5ef9 + body/2/1/2/1/0/0: 252cd97ccaa452fbf52c8ece86cc6357 + body/2/1/2/1/0/1: 9dc6fc5838c4781ae16511b428edc580 + body/2/1/2/1/0/2: 0fcd8169cfcb7a986e5f5bc8e0304179 + body/2/1/2/1/0/3: c2c11312a3f6c6f1ca8285a68777b3c5 + body/2/1/3/0: 129891f5ec1aba3e82dc4e9b577cdba8 + body/2/1/4/0: b504a03d52e8001bfdc5cb6205364f42 + body/2/1/4/1: 7eac03268a7a1f9c7c838f8d848240e3 + body/3/0: 5d4ada1ab4dda0067307ed27db717f55 + body/3/1: 327405ed3e27fd2c1781b18f71fb5219 + df547e152136431bbc29e26ae0eeabb4: + title: 0468579ef2fbc83c9d520c2f2f1c5059 + description: 49f8864eb0e53903f04532bf33e1e4fa + version: 54a9e730e88fb16291b852274d433923 + support_email: 10627fcc465897af0f5e1bba042685f9 + emoji: b328c432cee108a87a92f05258b6a651 + author/name: febee8e9ab40b2fe5106d72675228d00 + contributors/0/name: e80d4063a32adaad7b0a82b0bcc10551 + contributors/1/name: b2bca2fa3c890618e56d07473f26ead3 + messages/0: d1c3a9f35e377554a4ccaa467ca26614 + messages/1: 0468579ef2fbc83c9d520c2f2f1c5059 + config/theme/primary: 7535a3779d6934ea8ecf18f5cb5b93fd + mixed_array/0: 001b5b003d96c133534f5907abffdf77 + mixed_array/3/nested_message: 5f0782dfc5993e99890c0475bc295a30 + 2be4c282da9773e3b7b07aaee059e5a1: + key1: 0468579ef2fbc83c9d520c2f2f1c5059 + key2: b48a4cb78277d72905362f8d2dfa1e88 + key3: 54a9e730e88fb16291b852274d433923 + key4: 10627fcc465897af0f5e1bba042685f9 + key5: b328c432cee108a87a92f05258b6a651 + key6/key7: 9da96ad1d5c544070ac5e268de77fb48 + a661dac7c4e5e7b85a84760d221260ae: + title: 0468579ef2fbc83c9d520c2f2f1c5059 + description: fded006c54f10b76105c14ce3048c154 + version: 54a9e730e88fb16291b852274d433923 + support_email: 10627fcc465897af0f5e1bba042685f9 + emoji: b328c432cee108a87a92f05258b6a651 + author/name: febee8e9ab40b2fe5106d72675228d00 + contributors/0/name: e80d4063a32adaad7b0a82b0bcc10551 + contributors/1/name: b2bca2fa3c890618e56d07473f26ead3 + messages/0: d1c3a9f35e377554a4ccaa467ca26614 + messages/1: 0468579ef2fbc83c9d520c2f2f1c5059 + config/theme/primary: 7535a3779d6934ea8ecf18f5cb5b93fd + mixed_array/0: 001b5b003d96c133534f5907abffdf77 + mixed_array/3/nested_message: 5f0782dfc5993e99890c0475bc295a30 + locked_key_1: 73fc105de1aefc3f0a97d187a80cf0a4 + 18ac35cad2b3022d734493a5fce7cddc: + navigation: 104a3db3b671c04e167eafbe21e57881 + buttons/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e + buttons/cancel: 2e2a849c2223911717de8caa2c71bade + messages/welcome: 1308168cca4fa5d8d7a0cf24e55e93fc + messages/error: 53a2b5f5e7d83c737c8e02fe18fb4bdb + forms/login/title: f4f219abeb5a465ecb1c7efaf50246de + forms/login/fields/username: 2ee65bc2dd2f12cf2672f95b2a054bf8 + forms/login/fields/password: 223a61cf906ab9c40d22612c588dff48 + 3b3843fdc86f8dbbb17471b5db8a26d9: + heading-0: e19079d431e6b75a7d74e9c35639ea5a + paragraph-0: 6757b797fc6d8c5ec5628f2619274e28 + paragraph-1: cc37b30c1aca11f64dc096264761368e + paragraph-2: 50e2b92b9e711ae7758f8edcc1767102 + paragraph-3: 7bc573b304cf3a3414dd60f85eea4e36 + heading-1: 80779abd23399c676227609057093235 + paragraph-4: dfd87f4dde3d3e12fa6781966432465f + item-0: ad9b7460be42c194e4771cf5281d5fd0 + item-1: 7d67473952061a571de034013964c5b6 + item-2: 91cffca4db2b901a3a0310ca2ab17ed9 + item-3: 56b97b82cc030c511847d1850af088a0 + item-4: 05dc0a811ee5b04941c826293471367d + item-5: b1701133f7e62467e354a5fc526ea951 + heading-2: ff6e4ea57280ef5a42ebbe2cf9e3273b + paragraph-5: 3e11ad608af106c15eed82d0e5d07712 + item-6: 2b1ab56131399aab1842fc3a81dd170e + item-7: 8aedf70e351d5e5bb41ad66ad5ad18d8 + item-8: 1d0eccad0413a69da0d45c481aa754e9 + paragraph-6: e2a208aa62fd61821848519fa2abc211 + heading-3: 2761eb3f80a7da53d2cd899f33641e98 + paragraph-7: f341db1f3167bd074937f4399c3a1158 + th-0: 58f5f3f37862b6312a2f20ec1a1fd0e8 + th-1: e17686a22ffad04cc7bb70524ed4478b + td-0: 97813c8ae67d69575fd04e35a88aed0c + td-1: 117451569b718867c43b26d9ee3c4e8f + td-2: e801c1eaca53c3aa702b747ed750fdd1 + td-3: ffd3eec5497af36d7b4e4185bad1313a + td-4: 03d20ebc4966301ceba02199a24e02dc + td-5: 86d0ae6fea0fbb119722ed3841f8385a + td-6: 6d26991f040628f6002efa192bebb9c2 + heading-4: c8b45e4d54115ec279a2a6bde4b8a725 + paragraph-8: 933c68ed0598328263d1146641d0ab2c + fence-0: ffdb698812040ead47e2039dfa22d9d7 + paragraph-9: fe471cd364c38cc79a7638b4a5ffb528 + heading-5: 4cbc75f1ae6830a190eb70628c4e4b54 + paragraph-10: bc6097c6af86133a3972bdb7a5343dd2 + fence-1: 31a1aab989ce9935c98e672182bffbbe + heading-6: fe53e9de958685ab7c70d0a973c0a146 + paragraph-11: 69f77475eb830f1aa357f45db29f809e + item-9: 90f2650aad06503472c46ce2612b9bc8 + item-10: a392737d0507463d40d1a8ff7502607c + item-11: 7ed6e23ffae34636a417daf72b3a5b6b + item-12: 0f93e7285aafb5c9929b29973f047026 + item-13: 9dacb7c56bc894e4bcad39f41265e3a0 + item-14: 496e1d7de5d7ff53f1af813a2ba46d7f + item-15: 1bd4ec30282cfc0bdf7d5f3559990a1c + item-16: 46c9eec1392a8d9f33170569e342bf7b + heading-7: aa8d69ad456402762aeb915a67cfa698 + paragraph-12: 275eb44389d498dab93e38ed2889e5d9 + paragraph-13: f05f450fffcb17520c441ab9789f40ce + paragraph-14: 915fe5ce4ded772d7844df222ebd9d3e + paragraph-15: 3fae115ccac303b7cd908b49b8509217 + heading-8: 2fdf7c243436eba4bd1fe5ebd605ab96 + paragraph-16: fc0f77d45ad1e1764d2793706eb8a049 + paragraph-17: 6b4340d30988a714d34f0df9b3e18889 + paragraph-18: a59d117938ed65f303209da2b23ad35a + paragraph-19: 28e8b27fb60b305a9caac04d8a92a038 + tagName-0: 0421db688c4d19bb542014733d553a43 + paragraph-20: 83951ea4c30a9b5057ff046e3a0bfc07 + paragraph-21: 8d2152d9e84fff4b3cd98d1fb305be8f + heading-9: 6fe6489310962f6bc8ad13279106568b + item-17: 0539fa4e65545d8a334abf6e0aee57ab + item-18: a41ccc6f28eec377fb19a95b0b6db7a8 + item-19: 45aa24cef8a5d9b037d898917a862563 + paragraph-22: 59bf7b09d603b846c2cdd63cd878f20a + paragraph-23: 9e30a94d9122095ac52a52ed2a864a26 + paragraph-24: 3b28910e425d79f9fd23ada6d6f33bff + paragraph-25: 05d9c0fe6c099b1e20ebdb2320a28257 + paragraph-26: 04e7322f2d3ffb2d73ff2f64b71637c8 + paragraph-27: 65ef9814c2d07fd3d54d9f7bee1bba6c + badge-0: c2d5d8760d96802e1b9a7bac290b1cfc + paragraph-28: df1f854bcdb047d98a68cd39704a8981 + fm-attr-title: e19079d431e6b75a7d74e9c35639ea5a + fm-attr-description: 82574f93a40b35a16a4c9fc5c2ab58cf + fm-attr-author: a51ec27845d1fc7cf13c810f0e2d42ab + 1d941f3fcb422e74ccb8adb0f899afad: + md-section-0: d53f61b8c8922fb62d9df5678d9b44a8 + md-section-1: 98aec271471bedce0e12b530c7060827 + md-section-2: 9e5a786192608844493dfbb6e4100886 + md-section-3: 1a5299c38bb20c1b8af0e64e33d7b2b0 + md-section-4: 51adf33450cab2ef392e93147386647c + md-section-5: e56cc804e3e06b5f5fb2484e88c18adc + md-section-6: 0ea86a3338305070c865e8fe138da890 + md-section-7: bbabf7f391569a72099001e3d81eb251 + md-section-8: 36cbfd93f42528edce4faac2ac3c2c12 + md-section-9: a1c50054ab23d70be8d453789b214580 + md-section-10: 51adf33450cab2ef392e93147386647c + md-section-11: cb596c9608828f7b87a0ab8fa37beb07 + fm-attr-title: f3469c4e3d3377c39a705c844930b3a5 + fm-attr-description: 2e988d98001e44997a3f5fa3fb487ca6 + fm-attr-author: ec8c8711fce61265a4fe296ce2ba3b6f + fm-attr-tags: 313ac6f17ee08e4f4a6a2ca95e5ae024 + 43e407a3798eb45c648586dfaabcc0bf: + meta/title: a4bdd0dee24f8318f3300dcae130a353 + meta/description: 609213841f122e494f62262618ee4761 + meta/author: f3f7164b5963b4da6cd31a2ec0251630 + content/0: 8a8520492d23503da5691602e60bd22a + content/1: 1fc859854cda505b2a94a04c8b09ab43 + content/2: 8add667f2a1d5d791a64b50bde54fa59 + content/3: e6e34c4c92eda512ec209266abe8e074 + content/4: 07f1896ad050b9606d7674f70d847818 + content/5: bd4d40a4f0cc92ac8a880c8d9ce8b43d + content/6: 3036a07a887121ea080427d84fc80912 + content/7: f555318416c5c5388c1d961ef02f5955 + content/8: 90e02688ab103de60e42c70ece7efc4d + content/9: ab043b7322efaf6b148b2364a97ef451 + content/10: 67c569240f087ebe0573fba9aa5a07c0 + content/11: d444739ce3d48afb7976067c67149a9e + content/12: 5f02c0a3b6385f80bdd08cf7e2d8c04d + content/13: 41d7c108bed548ac1720437ca7becb1f + content/14: 778ed0aa1f81768280a23afe559c55f7 + content/15: fa244af2d8e558d6c3644ff8c1a64562 + content/16: b35880987a21d2ecb0859ca8238f779c + content/17: 5f42d26a42aa29be063019eea27ad07c + content/18: 877806d99e3388dd2c27c1be818d350d + 85a42d442df1a15336ea9c1ccb451a18: + Welcome/singular: 3180ad6b8de344b781637750259e0f53 + Home/singular: 104a3db3b671c04e167eafbe21e57881 + You%20have%20%25d%20message/singular: 1691abfe2c5d017cda86e298d34f3524 + You%20have%20%25d%20message/plural: 2d37831bf51cc2cf75e812c0e61c6861 + Save/singular: f7a2929f33bc420195e59ac5a8bcd454 + Cancel/singular: 2e2a849c2223911717de8caa2c71bade + Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9 + Name/singular: 9368b5a047572b6051f334af5aa76819 + Email%20Address/singular: 0ee22bbbe989a0c61a18023407d12dc2 + Message/singular: f2f72126bd244cfc534eab395e054362 + Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66 + Success!%20Changes%20saved./singular: 906371aaeec474803e22ae959605dad8 + Error%3A%20Request%20failed./singular: cdeaab2374e34c0e396cdb2596a9824e + Add%20to%20Cart/singular: c93a29ccf502ff71bf08924dcdea9179 + Out%20of%20Stock/singular: 6673fc95c2cee3c713e0d60c8184e289 + Price%3A%20%24%25s/singular: a860f7b395e4a9d916a48717f9f8837a + 60e3fb38a8f4172248a877262283854c: + app.title: 7dc70110429d46e3685f385bd2cc941c + app.description: e13baa1e885129d9328e216ff534761b + user.greeting: 0468579ef2fbc83c9d520c2f2f1c5059 + user.farewell: 118794a2b84f7bfb4b4ce602ed463b0f + error.message: a3cd2f01c073f1f5ff436d4b132d39cf + error.notFound: 97612e6230bc7a1ebd99380bf561b732 + database.host: da86e4fc0c04d82c87006dc71cea7e97 + notification.success: 3b7a8b0aa23977592d4270ea136a390c + notification.warning: c38895f731311cefacee9e8d7d10fc49 + 6da152a30ab05dbd1785c179224a09c9: + 1#00:00:01,000-00:00:03,500: 5a2215cdfd6d9e9162efbdee57b89c27 + 2#00:00:04,000-00:00:06,500: ecb7d6cb214b6db6d02e6e98cdfea178 + 3#00:00:07,000-00:00:09,500: 3eee55196aea6ac13fb19eae7e7ffaf6 + 4#00:00:10,000-00:00:12,500: a6cc802efe3431c7a986ac5d42d62ce1 + 5#00:00:13,000-00:00:15,500: f73ef0a42ea51efb4e1e5fd2276ef243 + 7a5e07095171dd2f59dbcb8d4109574c: + "1": 5c0212aca9c84332df0190d13e929623 + "2": d39d54116929959bf76f43655e7bebc9 + "3": 960c83d6eeed679ee9fb1b2be2f9934b + "5": 78569dd2f0e7cd872659850ef2f9c19a + "6": 5c5a850ec695512b6182630c563eeed9 + fb1f33e873b2ca499a48c636071081ae: + navigation/home: 104a3db3b671c04e167eafbe21e57881 + navigation/about: 8f89131a66d4659be07cd5af2c7ea898 + navigation/contact: 2a75337dc9603915c4ec1d1905afb7b9 + navigation/services: 999f32026e64978cb3ec794a496b0bb8 + forms/title: ac85dea7c7f0bf1cd7d48cc1b4da3acc + forms/nameLabel: 03c6ae7996d5841f743cd406b4eff72d + forms/emailLabel: 0ee22bbbe989a0c61a18023407d12dc2 + forms/messageLabel: 1e460d0909502d0e94b9be562643af0d + forms/submitButton: 487177489aafc9c0243c57ef3850a2d9 + forms/successMessage: a0a7aa980dffa31d4d194af718a917b3 + d67e3f776169ba16208faf8320e4318f: + 0#1-3.5#1: 5df3c06b74cfc8558e85ff75a30a9162 + 1#4-7.2#subtitle-2: 0de65f1d2616b6959aa79ac5beb6e84c + 2#8.5-12#3: 3351244c032529a099f1191477d9e488 + 3#13-16.5#: b9341abc965d5178a96d9bc4e8e2c59a + 4#17-20#final-cue: 0b67e089cd3f39b8520d7a2be9f34362 + ae4fae82257615e5c22b34de033c7eeb: + welcome_message: 0468579ef2fbc83c9d520c2f2f1c5059 + login_button: 0029e5a35676c0051e761fcd046ef9ee + error_message: a3cd2f01c073f1f5ff436d4b132d39cf + user_profile_title: bee775ff7216747b2111e93cefa57ddc + quote_example: c519c83fe2629c0e9a6e7a14f64b6317 + newline_example: ae9313a2231a16f17e2367a4e5b322ee + backslash_example: acf69a7273edf9f932f66027f699bbbe + mixed_escapes: 9285b600baf307f7c060e20dc5778fad + tab_example: 1451b8323511459dac68316a2594bb82 + multiline_literal: a4c5d1c388a06e29d96833e4d2f14a26 + multiline_mixed: f5d741606567d78281bc455074eb8f6c + multiline_with_quotes: c82ec05ec488644808917b9c958da8cc + after_comment: b7c19db10622cb67d4dd28270e85a428 + after_multiline_comment: 759d0ffce80451996a5a45b33a0870cc + long_value: a54e8485e571c671e35865ba72cbcaf5 + unicode_example: 2de42b1aef6d20b314928b9c2554759d + emoji: 1b387c2b5ce6c2cd608081ebcb5e6a94 + accents: 8c054e17f9b960d9317ca110a6fedf8c + spaces_only: 8af60e2ee58a2e1e42071066e9c225da + many_quotes: e2ff57b8058ab2c03c5b07cf901a7a48 + missing_semicolon: b2b5f0c3f552a348188de51bd4fcf511 + settings_title: 8df6777277469c1fd88cc18dde2f1cc3 + save_button: f7a2929f33bc420195e59ac5a8bcd454 + 32707dfb19e6dfad9a1af32087b6f9f3: + welcome_message/NSStringLocalizedFormatKey: f142738692c027d95dce521e7cb29c82 + welcome_message/user_count/NSStringFormatSpecTypeKey: 8cc03ef30ad5b2d8e27f3612627d932e + welcome_message/user_count/NSStringFormatValueTypeKey: fe9efa39a6fd9f10358f43f00e0ab82b + welcome_message/user_count/zero: 3d4643c483a49c2f61e17aaa8620e71e + welcome_message/user_count/one: 3b547431ab12f0fba84307e6a81109d8 + welcome_message/user_count/other: cb01ae522c991a2ad651b4049339c48a + notification_count/NSStringLocalizedFormatKey: e01fd796051132b678d7574a11e9a944 + notification_count/count/NSStringFormatSpecTypeKey: 8cc03ef30ad5b2d8e27f3612627d932e + notification_count/count/NSStringFormatValueTypeKey: fe9efa39a6fd9f10358f43f00e0ab82b + notification_count/count/zero: ac0137deebf6e2b972c6c714dd8658ee + notification_count/count/other: 9b350a78e1c499b9ab69eb330162c8ee + 11b3ea8486d8e09d2bf06db1811e0490: + welcome_message: 0468579ef2fbc83c9d520c2f2f1c5059 + 1254631a73b754e11a1b9ca8f7362025: + item_count/variations/plural: 021a2f0c489893d720d30fe4277ccdb5 + notification_message/stringUnit: d14316154e233634917e317452c5f42c + notification_message/substitutions/count_items/variations/plural: 3db7a8a0078bc1a87ccdb095af3164a5 + welcome%20message/stringUnit: 0468579ef2fbc83c9d520c2f2f1c5059 + ee8bbe4c9de58af9c4d5e1f4373f4db1: + plural_comments_thread/variations/plural: 97a71bd34c84a4a5743d658937695acb + plural_complex_sentence/variations/plural: 2a1d1e020670dc56bbe319866dbbfca9 + plural_download_speed/variations/plural: a25c6b896b06a81f76a9191dfa683d3c + plural_likes_with_names/variations/plural: 705a39beb4051cf029164df7abb2f6c5 + plural_mixed_types/variations/plural: a03ccdfcc3f45d988184cacfd80aea67 + plural_participants/variations/plural: 750a9c917be84adc814b814faed3121b + plural_positional_args/variations/plural: f66423f9ca6b02f4d83586cc814646cb + plural_storage_usage/variations/plural: b40ad5e569a937206e120b1aa027fc10 + plural_time_remaining/variations/plural: 6486a72fb367e941f1ed953a1184f824 + plural_unread_notifications/variations/plural: 9065c55c8f0f4cef2ae8a15d30916cbc + plural_with_float/variations/plural: 0d8ec56f120924931e39048ad7ffccc2 + plural_with_high_precision/variations/plural: ada7f82e4097be81131932cc9b2ea1a9 + plural_with_long_long/variations/plural: cd071f3cb70733a5a28a0105166dff99 + plural_with_one_variable/variations/plural: 6b55e11ccfb8b87cc6fb537d04963788 + plural_with_percentage/variations/plural: e93584fd51ed05cae53ebe2781ea1ae0 + plural_with_two_variables/variations/plural: f1c5ad7dfbbb0a9995eae3111a0c5b8a + plural_with_units/variations/plural: c9d1e24dbab0890d89001aac962946c9 + plural_with_zero/variations/plural: 42a48519f0960f5531673eb5cc16259b + simple_plural/variations/plural: dcaae1387d28339af1fff9e8cfe4ebb9 + simple_string/stringUnit: ed0d4b1cde20d045f9f8c5007c784b0b + b23a0b1bf493252238c751dd2d6cf17c: + root/title: 0468579ef2fbc83c9d520c2f2f1c5059 + root/description/summary: f2c85bf6eeebeea33609e04598201bb6 + root/description/details: 2ee85b8f2f0f1bc008d9cf1f916cb09c + root/image/%24/src: 3ce26f0a5486adf10e1b7eee1b866a70 + root/image/%24/alt: 94058fbed56fffaef2e9bbea59ba4a54 + root/image/%24/title: 60487c71b570d9dedca6fddd4a75d16a + root/link/_: e598091d132f890c37a6d4ed94f6d794 + root/link/%24/href: 285d79d2783cf0769ab9e767362c1499 + root/link/%24/label: 26ce69aad587f70d47e7606436bf1d6d + root/button/_: 7c91ef5f747eea9f77a9c4f23e19fb2e + root/button/%24/type: fa8748b22d5bac98fdcd57e3d6594cf3 + root/button/%24/value: 7c91ef5f747eea9f77a9c4f23e19fb2e + root/button/%24/placeholder: 7b5d59cee6952db66043a4b289b51884 + root/section/article/paragraph/sentence: 28ca53c71a2e3e3de79c892a9b193b1a + root/meta/%24/name: d097029e873a4b19132e2603bd2c9fe4 + root/meta/%24/content: 0811ae3ab84aa87205383c3d8ac42bf3 + root/message/greeting: 85559fc839c5181b7958654e62c987d5 + root/message/body: ed0d4b1cde20d045f9f8c5007c784b0b + root/message/signature: 181c8c304980949e101865098f548705 + 48bbac9fb7941a5da53508f37bfade60: + title: 7dc70110429d46e3685f385bd2cc941c + description: 0468579ef2fbc83c9d520c2f2f1c5059 + welcome_message: d1c3a9f35e377554a4ccaa467ca26614 + user_profile/display_name: febee8e9ab40b2fe5106d72675228d00 + user_profile/bio: 155ddcb7c93493ac72a37074eea0a653 + navigation_items/0: 104a3db3b671c04e167eafbe21e57881 + navigation_items/1: 944521eeeed2511833d2299931273c71 + navigation_items/2: 9afa39bc47019ee6dec6c74b6273967c + product/name: ed21de171d538a49db999c47875f75a5 + product/tagline: b7ac41680e82d75ae7f5774f7ceef1b4 + product/features/0: c916ba887951a02793ff851853fd964f + product/features/1: 1c60a04d6890c6ec910a7f2e6ec0ae7b + complex_structure/level_one/level_two/message: b53034560bf657106e5aaea9160e357e + e6d8e00051ea40ca138a9549ed52e1c6: + navigation/home: 104a3db3b671c04e167eafbe21e57881 + navigation/about: 7ed93e7bbfca42a405d61ea3c2791aae + navigation/contact: 9afa39bc47019ee6dec6c74b6273967c + navigation/services: 8ea10b45b9abab2a3bfc3c07e1c9cdc6 + navigation/forms/title: cd1568dd5f8241c9429dc634de250ef4 + navigation/forms/name_label: b00c01deec0af9a441331a5134210de1 + navigation/forms/email_label: 3ba3f099b1b9be6c35ad797da660cb9f + navigation/forms/message_label: f2f72126bd244cfc534eab395e054362 + navigation/forms/submit_button: da352018f0db23d97405e3e44ccfe50d + navigation/forms/success_message: a0a7aa980dffa31d4d194af718a917b3 + navigation/inflections/gender/f: 1cdef9a43e68074eae7dce0248f7e5a9 + navigation/inflections/gender/m: 91f7f601c08b37b397f14f952416623f + navigation/inflections/gender/n: cab8f0be0df82bac41435dee4d2eb1df + navigation/inflections/gender/female: f4adbe8df79a872d3c16329a7e7a361a + navigation/inflections/gender/male: 9ebdcb660f503bb2618ae7ae086617e2 + navigation/inflections/gender/neuter: 603743850a2510aaa6a5eb9dbfbe7416 + navigation/inflections/gender/F: f4adbe8df79a872d3c16329a7e7a361a + navigation/inflections/gender/M: 9ebdcb660f503bb2618ae7ae086617e2 + navigation/inflections/gender/N: 603743850a2510aaa6a5eb9dbfbe7416 + navigation/inflections/gender/default: 453a466f60641d9934bbee33dc4cd2b6 + date/abbr_day_names/0: b29e2b72f74643194654961775178fec + date/abbr_day_names/1: b750502bab403473852a20cea73dcf2c + date/abbr_day_names/2: 4786ce2a2427ad9183ef1a6f7385fb24 + date/abbr_day_names/3: ce25e3f9bfc6cfdc6017f791045da079 + date/abbr_day_names/4: 4029492def3bd66fa5a9d1c693743b62 + date/abbr_day_names/5: 0f0718f17b758ea9d5167c120c230be6 + date/abbr_day_names/6: 3b32ac0f13383ecdf580b4db09773fda + date/abbr_month_names/1: 268e5f1e700c23c50b88e8c6aa754c88 + date/abbr_month_names/2: c111dae80531454da886782893e71541 + date/abbr_month_names/3: 3055ece906ba97d9b5050b4385d873a9 + date/abbr_month_names/4: 7a76ba706f71adc981bc050190ccc63f + date/abbr_month_names/5: 320223c5afaaf28560a3a84d3527d51c + date/abbr_month_names/6: 47f51f7cecc9bd30eef853748a40f2e7 + date/abbr_month_names/7: 5088467e6b771d6f02e1d4ea3550dd96 + date/abbr_month_names/8: 160006d60703204ab06369352f5f2520 + date/abbr_month_names/9: 91cac5f11e31c9907ea26a79fe5df889 + date/abbr_month_names/10: 384ae5bd38358c3f8db4560d59405c70 + date/abbr_month_names/11: a87c80252a5b03a82a0d16c510b5ed12 + date/abbr_month_names/12: 2a7ffd15bea3a6fdb664dff36b0e8043 + date/formats/default: df39f2ed8e14212ef5664e0603460e76 + date/formats/long: 60dc8afda933eee168c7eb73bda1a296 + date/formats/short: 05eb7bb8ee06c9de1435ba4cd1d81dcf + date/formats/time: 1407af7bd151a6fa95e2d51497454cc2 + date/formats/time_with_seconds: f02992d40da922a1382859c45cb0231e + d5ff4a01e7a8f148b46bb86afc0e9ace: + "0": 0468579ef2fbc83c9d520c2f2f1c5059 + "1": d1c3a9f35e377554a4ccaa467ca26614 + "2": 769caedbdc5246bb9fee615739534bbd + 3/welcome_message: 8778dc41547a2778d0f9482da989fc00 + 4/error_text: a3cd2f01c073f1f5ff436d4b132d39cf + 5/navigation/home: 104a3db3b671c04e167eafbe21e57881 + 5/navigation/about: 944521eeeed2511833d2299931273c71 + 5/navigation/contact: 9afa39bc47019ee6dec6c74b6273967c + 6/forms/login/username_label: 2ee65bc2dd2f12cf2672f95b2a054bf8 + 6/forms/login/password_label: 223a61cf906ab9c40d22612c588dff48 + 6/forms/login/submit_button: ec7b8f314fe9bc6591006707484ede61 + 7/mixed_content/title: 8df6777277469c1fd88cc18dde2f1cc3 + 7/mixed_content/description: 063afcd2ea84a82a1acc8f5c9fd8e42f + f96a3f7181c8ce89f928d418873259aa: + welcome: 0468579ef2fbc83c9d520c2f2f1c5059 + description: 49f8864eb0e53903f04532bf33e1e4fa + button/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e + button/cancel: 2e2a849c2223911717de8caa2c71bade + messages/0: 97a8db12c3955a85c4f50e3951c91a40 + messages/1: 986a434e3895c8ee0b267df95cc40051 + 21f72852c50ba239e04a475d93f91691: + head/0#content: 1308168cca4fa5d8d7a0cf24e55e93fc + head/1: 3180ad6b8de344b781637750259e0f53 + body/0/0: 9de5fe40cbf5f851a6d2270f01fe0739 + body/1/0/0: c59070fe496d5e4bd0066295b63a9056 + body/1/0/1: 12d74865332bf1988d51e84ba67aae09 + body/1/0/2: 58f0e438e665c77eedc440c5a8529b1a + body/1/1/0: 119e3aa396d12a5a1aa7058e0983f9b9 + body/1/1/1/0: 60f9a22f4200bb4620a6ff7a1797ec30 + body/1/1/1/1: 03846a81f16f5e4a11acfd9445ad497d + body/1/1/1/2: 15aae9d70ff1fb682f7d86baca81dcc0 + body/1/2/0: fbd403146395526d68ac68d142a50e21 + body/1/2/1: da8dc7fe06175d8b805f7f565bfe2788 + body/1/2/2/0: 061e1acc1b9ebad9de09fd5626e813c7 + body/1/2/2/1: 67f022a3f9e278d065a063b5e29dd932 + body/1/2/2/2: 7e23f048179f6661050edaa796528fe0 + body/1/2/3: 635f7e9a4afc00de34f975914afbb8b8 + body/1/3/0: 7a7892379e31868abba9865d20be2b72 + body/1/3/1: 8740df822561d74d51bb30e4b39d6193 + body/1/3/2: 0429f12258fabbde3abaca3dd9986178 + body/2/0: d32e57e4a5a65f3bee8b63dcb2bfa8e7 + body/2/1: 7e10a8ab9cc4e6d603b3cdc48849688f + 4f37032b5b02b6755e131de5f41d6105: + Control.Text.WelcomeDlg%23Title: 93af4b47a8c0a84ce9fb82d2ee2bca13 + Control.Text.WelcomeDlg%23Description: 9cbc6500217ae2a7b3ffd74a3c723493 + Control.Text.LicenseAgreementDlg%23Title: 3fe6d3757d7129ede78b41dd06042bf2 + Control.Text.LicenseAgreementDlg%23Description: dcb0c3ee6751632a4fdf2c96953f99e1 + Control.Text.LicenseAgreementDlg%23AcceptCheckbox: 768d9d11db014facbc1b65af8e39e260 + Control.Text.FolderDlg%23Title: 0076b1dd48fd382d64661a81b9961042 + Control.Text.FolderDlg%23Description: bc1455ec06d5f670d5084586cf2ba9de + Control.Text.FolderDlg%23ChangeButton: d4c59a35bb9636f6c8ea1e8bc5c6d624 + Control.Text.ProgressDlg%23Title: a3040b342470f8c7b057d5db6f8b558b + Control.Text.ProgressDlg%23Description: 277d8219f832a99164df480f4304716b + Control.Text.ProgressDlg%23StatusLabel: 6eddeb13a4e7ccb25e21ec3f665dbcdf + Control.Text.CompleteDlg%23Title: 7adc37e2e844b5a4594f9440724b2bb5 + Control.Text.CompleteDlg%23Description: 8c45e01cbb06e94401df2e6a944023bd + Control.Text.CompleteDlg%23LaunchCheckbox: a4ff2781965a961d37c74514339cc0e2 + Control.Text.MaintenanceDlg%23Title: df4569e2e3e6e525954c94833e3ad2c5 + Control.Text.MaintenanceDlg%23ModifyButton: 108a14dd240bb930dd1b2c7615099615 + Control.Text.MaintenanceDlg%23RepairButton: 9368af6bdea003a293f24c8a6dc38b51 + Control.Text.MaintenanceDlg%23RemoveButton: dba2fe5fe9f83f8078c687f28cba4b52 + Property.ProductName: c215a8d46c95f00089c5ea0d17eef742 + Property.ProductVersion: 54a9e730e88fb16291b852274d433923 + Property.Manufacturer: 31ec2d348b96a835b044ab1ee28c317a + Property.ARPCOMMENTS: a595ddbc7e0bab708e0b2516287fdf92 + Property.ARPCONTACT: a7ad445ceffe0a5172a059a9d6c985a5 + Control.Text.ErrorDlg%23Title: 18d4f5c54da93fa36d82b65d528c2bee + Control.Text.ErrorDlg%23Description: 8a1c0b60c4a355277b5477982834ada5 + Control.Text.CancelDlg%23Title: 805177777f2474248fd32002327571a0 + Control.Text.CancelDlg%23Description: 48b33c48c57096b86971cc78d5a5cefd + Control.Button.Next: 89ddbcf710eba274963494f312bdc8a9 + Control.Button.Back: f541015a827e37cb3b1234e56bc2aa3c + Control.Button.Cancel: 2e2a849c2223911717de8caa2c71bade + Control.Button.Finish: ffa7a10f71182b48fefed7135bee24fa + Control.Button.Yes: ec580fd11a45779b039466f1e35eed2a + Control.Button.No: 8c708225830b06df2d1141c536f2a0d6 + 9f951a98fcd778865b0fb7c5ce1b3df2: + Control.Text.WelcomeDlg%23Title: 93af4b47a8c0a84ce9fb82d2ee2bca13 + Control.Text.WelcomeDlg%23Description: 9cbc6500217ae2a7b3ffd74a3c723493 + Control.Text.LicenseAgreementDlg%23Title: 3fe6d3757d7129ede78b41dd06042bf2 + Control.Text.LicenseAgreementDlg%23Description: dcb0c3ee6751632a4fdf2c96953f99e1 + Control.Text.LicenseAgreementDlg%23AcceptCheckbox: 768d9d11db014facbc1b65af8e39e260 + Control.Text.FolderDlg%23Title: 0076b1dd48fd382d64661a81b9961042 + Control.Text.FolderDlg%23Description: bc1455ec06d5f670d5084586cf2ba9de + Control.Text.FolderDlg%23ChangeButton: d4c59a35bb9636f6c8ea1e8bc5c6d624 + Control.Text.ProgressDlg%23Title: a3040b342470f8c7b057d5db6f8b558b + Control.Text.ProgressDlg%23Description: 277d8219f832a99164df480f4304716b + Control.Text.ProgressDlg%23StatusLabel: 6eddeb13a4e7ccb25e21ec3f665dbcdf + Control.Text.CompleteDlg%23Title: 7adc37e2e844b5a4594f9440724b2bb5 + Control.Text.CompleteDlg%23Description: 8c45e01cbb06e94401df2e6a944023bd + Control.Text.CompleteDlg%23LaunchCheckbox: a4ff2781965a961d37c74514339cc0e2 + Control.Text.MaintenanceDlg%23Title: df4569e2e3e6e525954c94833e3ad2c5 + Control.Text.MaintenanceDlg%23ModifyButton: 108a14dd240bb930dd1b2c7615099615 + Control.Text.MaintenanceDlg%23RepairButton: 9368af6bdea003a293f24c8a6dc38b51 + Control.Text.MaintenanceDlg%23RemoveButton: dba2fe5fe9f83f8078c687f28cba4b52 + Property.ProductName: c215a8d46c95f00089c5ea0d17eef742 + Property.ProductVersion: 54a9e730e88fb16291b852274d433923 + Property.Manufacturer: 31ec2d348b96a835b044ab1ee28c317a + Property.ARPCOMMENTS: a595ddbc7e0bab708e0b2516287fdf92 + Property.ARPCONTACT: a7ad445ceffe0a5172a059a9d6c985a5 + Control.Text.ErrorDlg%23Title: 18d4f5c54da93fa36d82b65d528c2bee + Control.Text.ErrorDlg%23Description: 8a1c0b60c4a355277b5477982834ada5 + Control.Text.CancelDlg%23Title: 805177777f2474248fd32002327571a0 + Control.Text.CancelDlg%23Description: 48b33c48c57096b86971cc78d5a5cefd + Control.Button.Next: 45e9f984df59a475157f2c1b26655ac4 + Control.Button.Back: f541015a827e37cb3b1234e56bc2aa3c + Control.Button.Cancel: 2e2a849c2223911717de8caa2c71bade + Control.Button.Finish: ffa7a10f71182b48fefed7135bee24fa + Control.Button.Yes: ec580fd11a45779b039466f1e35eed2a + Control.Button.No: 8c708225830b06df2d1141c536f2a0d6 + 260f1c30bddb38efcbf007f3adf662fa: + "%25lld%20unit_days/variations/plural": 6cebb166a1f103ab1d5cb94e777a654a + hello%20world/stringUnit: a2aa5a2c93a8a789a3c7f089817d4c17 + 8595fe363b79f6a8524b7b3f788f84c9: + Control.Text.WelcomeDlg%23Title: 93af4b47a8c0a84ce9fb82d2ee2bca13 + Control.Text.WelcomeDlg%23Description: 9cbc6500217ae2a7b3ffd74a3c723493 + Control.Text.LicenseAgreementDlg%23Title: 3fe6d3757d7129ede78b41dd06042bf2 + Control.Text.LicenseAgreementDlg%23Description: dcb0c3ee6751632a4fdf2c96953f99e1 + Control.Text.LicenseAgreementDlg%23AcceptCheckbox: 768d9d11db014facbc1b65af8e39e260 + Control.Text.FolderDlg%23Title: 0076b1dd48fd382d64661a81b9961042 + Control.Text.FolderDlg%23Description: bc1455ec06d5f670d5084586cf2ba9de + Control.Text.FolderDlg%23ChangeButton: d4c59a35bb9636f6c8ea1e8bc5c6d624 + Control.Text.ProgressDlg%23Title: a3040b342470f8c7b057d5db6f8b558b + Control.Text.ProgressDlg%23Description: 277d8219f832a99164df480f4304716b + Control.Text.ProgressDlg%23StatusLabel: 6eddeb13a4e7ccb25e21ec3f665dbcdf + Control.Text.CompleteDlg%23Title: 7adc37e2e844b5a4594f9440724b2bb5 + Control.Text.CompleteDlg%23Description: 8c45e01cbb06e94401df2e6a944023bd + Control.Text.CompleteDlg%23LaunchCheckbox: a4ff2781965a961d37c74514339cc0e2 + Control.Text.MaintenanceDlg%23Title: df4569e2e3e6e525954c94833e3ad2c5 + Control.Text.MaintenanceDlg%23ModifyButton: 108a14dd240bb930dd1b2c7615099615 + Control.Text.MaintenanceDlg%23RepairButton: 9368af6bdea003a293f24c8a6dc38b51 + Control.Text.MaintenanceDlg%23RemoveButton: dba2fe5fe9f83f8078c687f28cba4b52 + Property.ProductName: c215a8d46c95f00089c5ea0d17eef742 + Property.ProductVersion: 54a9e730e88fb16291b852274d433923 + Property.Manufacturer: 31ec2d348b96a835b044ab1ee28c317a + Property.ARPCOMMENTS: a595ddbc7e0bab708e0b2516287fdf92 + Property.ARPCONTACT: a7ad445ceffe0a5172a059a9d6c985a5 + Control.Text.ErrorDlg%23Title: 18d4f5c54da93fa36d82b65d528c2bee + Control.Text.ErrorDlg%23Description: 8a1c0b60c4a355277b5477982834ada5 + Control.Text.CancelDlg%23Title: 805177777f2474248fd32002327571a0 + Control.Text.CancelDlg%23Description: 48b33c48c57096b86971cc78d5a5cefd + Control.Button.Next: 45e9f984df59a475157f2c1b26655ac4 + Control.Button.Back: f541015a827e37cb3b1234e56bc2aa3c + Control.Button.Cancel: 2e2a849c2223911717de8caa2c71bade + Control.Button.Finish: ffa7a10f71182b48fefed7135bee24fa + Control.Button.Yes: ec580fd11a45779b039466f1e35eed2a + Control.Button.No: 8c708225830b06df2d1141c536f2a0d6 + 868daf6de703449b418b99d1b57173ae: + head/0#content: 1308168cca4fa5d8d7a0cf24e55e93fc + head/1: 3180ad6b8de344b781637750259e0f53 + body/0/0: 9de5fe40cbf5f851a6d2270f01fe0739 + body/1/0/0: c59070fe496d5e4bd0066295b63a9056 + body/1/0/1: 12d74865332bf1988d51e84ba67aae09 + body/1/0/2: 58f0e438e665c77eedc440c5a8529b1a + body/1/1/0: 119e3aa396d12a5a1aa7058e0983f9b9 + body/1/1/1/0: 60f9a22f4200bb4620a6ff7a1797ec30 + body/1/1/1/1: 03846a81f16f5e4a11acfd9445ad497d + body/1/1/1/2: 15aae9d70ff1fb682f7d86baca81dcc0 + body/1/2/0: fbd403146395526d68ac68d142a50e21 + body/1/2/1: da8dc7fe06175d8b805f7f565bfe2788 + body/1/2/2/0: 061e1acc1b9ebad9de09fd5626e813c7 + body/1/2/2/1: 67f022a3f9e278d065a063b5e29dd932 + body/1/2/2/2: 7e23f048179f6661050edaa796528fe0 + body/1/2/3: 635f7e9a4afc00de34f975914afbb8b8 + body/1/3/0: 7a7892379e31868abba9865d20be2b72 + body/1/3/1: 8740df822561d74d51bb30e4b39d6193 + body/1/3/2: 0429f12258fabbde3abaca3dd9986178 + body/2/0: d32e57e4a5a65f3bee8b63dcb2bfa8e7 + body/2/1: 7e10a8ab9cc4e6d603b3cdc48849688f + 0b4cc73cf7debceac50ede9a7c731c9a: + 0/NAME: 82fd7edcad911fd528a6e1d65905e468 + 0/PRODUCT: 97cddc0ea1a642dbbc77e2c58ef96c54 + 0/BONUS: 3c196cabd552b6b6941f8813562cdec6 + 0/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 1/NAME: 3e7241ad69e00f71f6447b69006fdd6b + 1/PRODUCT: 52dceb6e133876eedefc91758f4dafa8 + 1/BONUS: a61657f3137c31deb9498810287d7ed4 + 1/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 2/NAME: 229dc77b9845261d704d992c7b00153a + 2/PRODUCT: 45b9756cad31c05eba897f19a4550ecb + 2/BONUS: 02ac38cc1479911157054402e82959c1 + 2/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 3/NAME: 40f55d4ddad53f1c31c7244813bc68e7 + 3/PRODUCT: 93017112c1dc2f074d3be1abb02d2507 + 3/BONUS: 604e6eca8e6c781586fab7cf0b97f0b7 + 3/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 4/NAME: ba44c67983adfdd5bf03ec5168f3abc9 + 4/PRODUCT: 898e5908c9726f8056673258dbe9b1af + 4/BONUS: 719f3dbab3cb569a93518d4c2ff1b633 + 4/BONUS__2: 9762a40457f1695e8679def406f76d37 + 5/NAME: 61d99435646f27866c505e7d1f40d171 + 5/PRODUCT: e9c4963b1da635d2365e0111a7a7fc2f + 5/BONUS: 35db06a282738c6c6a9417094d39c80e + 5/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 6/NAME: 032412ef2ec37e8be14137292049e970 + 6/PRODUCT: 221ea6f7cf3778ae8b9f079588d7fb7a + 6/BONUS: b557d0a6619c27e558b8581ce7d3108a + 6/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 7/NAME: 6a554c4b466acc8b76d043452e3a710f + 7/PRODUCT: e6b44d2244ead5e4ae5a6a3755d103f8 + 7/BONUS: 0a91164a59598e2650e4e48eaa6bd4bf + 7/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 8/NAME: 275a03d2e25a65f126991ada1daa870d + 8/PRODUCT: 97cddc0ea1a642dbbc77e2c58ef96c54 + 8/BONUS: 3c196cabd552b6b6941f8813562cdec6 + 8/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 9/NAME: 221b270fe5a60e5a160d5502f9b0c139 + 9/PRODUCT: 52dceb6e133876eedefc91758f4dafa8 + 9/BONUS: a61657f3137c31deb9498810287d7ed4 + 9/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 10/NAME: 7517a402f3581ea2c04a992b8468c008 + 10/PRODUCT: 45b9756cad31c05eba897f19a4550ecb + 10/BONUS: 02ac38cc1479911157054402e82959c1 + 10/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 11/NAME: a649692aa9ba9468f98ed718bd4d0729 + 11/PRODUCT: 93017112c1dc2f074d3be1abb02d2507 + 11/BONUS: 604e6eca8e6c781586fab7cf0b97f0b7 + 11/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 12/NAME: 10e10057922963a0ef53b526cb2baf90 + 12/PRODUCT: 898e5908c9726f8056673258dbe9b1af + 12/BONUS: 719f3dbab3cb569a93518d4c2ff1b633 + 12/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 13/NAME: 2b0365cf946aeb2678cb2a10f7b662ae + 13/PRODUCT: e9c4963b1da635d2365e0111a7a7fc2f + 13/BONUS: 35db06a282738c6c6a9417094d39c80e + 13/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 14/NAME: bbe0e79469d425ce26a44bd8c1f9783d + 14/PRODUCT: 221ea6f7cf3778ae8b9f079588d7fb7a + 14/BONUS: b557d0a6619c27e558b8581ce7d3108a + 14/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 + 15/NAME: e08118de3644266e79c63d1fe03bdd34 + 15/PRODUCT: e6b44d2244ead5e4ae5a6a3755d103f8 + 15/BONUS: 0a91164a59598e2650e4e48eaa6bd4bf + 15/BONUS__2: 34cd669195d2ca8c293aa2191417a1d4 diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 000000000..7b56ed304 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,261 @@ +{ + "name": "lingo.dev", + "version": "0.121.1", + "description": "Lingo.dev CLI", + "private": false, + "repository": { + "type": "git", + "url": "https://github.com/lingodotdev/lingo.dev.git", + "directory": "packages/cli" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "type": "module", + "sideEffects": false, + "exports": { + "./cli": { + "types": "./build/cli.d.ts", + "import": "./build/cli.mjs", + "require": "./build/cli.cjs" + }, + "./sdk": { + "types": "./build/sdk.d.ts", + "import": "./build/sdk.mjs", + "require": "./build/sdk.cjs" + }, + "./spec": { + "types": "./build/spec.d.ts", + "import": "./build/spec.mjs", + "require": "./build/spec.cjs" + }, + "./compiler": { + "types": "./build/compiler.d.ts", + "import": "./build/compiler.mjs", + "require": "./build/compiler.cjs" + }, + "./react": { + "types": "./build/react.d.ts", + "import": "./build/react.mjs", + "require": "./build/react.cjs" + }, + "./react-client": { + "types": "./build/react/client.d.ts", + "import": "./build/react/client.mjs", + "require": "./build/react/client.cjs" + }, + "./react/client": { + "types": "./build/react/client.d.ts", + "import": "./build/react/client.mjs", + "require": "./build/react/client.cjs" + }, + "./react-rsc": { + "types": "./build/react/rsc.d.ts", + "import": "./build/react/rsc.mjs", + "require": "./build/react/rsc.cjs" + }, + "./react/rsc": { + "types": "./build/react/rsc.d.ts", + "import": "./build/react/rsc.mjs", + "require": "./build/react/rsc.cjs" + }, + "./react-router": { + "types": "./build/react/react-router.d.ts", + "import": "./build/react/react-router.mjs", + "require": "./build/react/react-router.cjs" + }, + "./react/react-router": { + "types": "./build/react/react-router.d.ts", + "import": "./build/react/react-router.mjs", + "require": "./build/react/react-router.cjs" + }, + "./locale-codes": { + "types": "./build/locale-codes.d.ts", + "import": "./build/locale-codes.mjs", + "require": "./build/locale-codes.cjs" + } + }, + "typesVersions": { + "*": { + "sdk": [ + "./build/sdk.d.ts" + ], + "cli": [ + "./build/cli.d.ts" + ], + "spec": [ + "./build/spec.d.ts" + ], + "compiler": [ + "./build/compiler.d.ts" + ], + "react": [ + "./build/react.d.ts" + ], + "react/client": [ + "./build/react/client.d.ts" + ], + "react/rsc": [ + "./build/react/rsc.d.ts" + ], + "react/react-router": [ + "./build/react/react-router.d.ts" + ], + "locale-codes": [ + "./build/locale-codes.d.ts" + ] + } + }, + "bin": { + "lingo.dev": "./bin/cli.mjs" + }, + "files": [ + "bin", + "build", + "assets", + "agents.md" + ], + "scripts": { + "lingo.dev": "node --inspect=9229 ./bin/cli.mjs", + "dev": "tsup --watch", + "build": "pnpm typecheck && tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf build" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/anthropic": "3.0.9", + "@ai-sdk/google": "3.0.6", + "@ai-sdk/mistral": "3.0.5", + "@ai-sdk/openai": "3.0.7", + "@babel/generator": "7.28.5", + "@babel/parser": "7.28.5", + "@babel/traverse": "7.28.5", + "@babel/types": "7.28.5", + "@biomejs/js-api": "3.0.0", + "@biomejs/wasm-nodejs": "2.3.7", + "@datocms/cma-client-node": "4.0.1", + "@gitbeaker/rest": "39.34.3", + "@inkjs/ui": "2.0.0", + "@inquirer/prompts": "7.8.0", + "@lingo.dev/_compiler": "workspace:*", + "@lingo.dev/_locales": "workspace:*", + "@lingo.dev/_react": "workspace:*", + "@lingo.dev/_sdk": "workspace:*", + "@lingo.dev/_spec": "workspace:*", + "@markdoc/markdoc": "0.5.4", + "@modelcontextprotocol/sdk": "1.22.0", + "@openrouter/ai-sdk-provider": "6.0.0-alpha.1", + "@paralleldrive/cuid2": "2.2.2", + "@types/ejs": "3.1.5", + "ai": "6.0.25", + "bitbucket": "2.12.0", + "chalk": "5.6.2", + "chokidar": "4.0.3", + "cli-progress": "3.12.0", + "cli-table3": "0.6.5", + "cors": "2.8.5", + "csv-parse": "5.6.0", + "csv-stringify": "6.6.0", + "date-fns": "4.1.0", + "dedent": "1.7.0", + "diff": "7.0.0", + "dom-serializer": "2.0.0", + "domhandler": "5.0.3", + "domutils": "3.2.2", + "dotenv": "16.4.7", + "ejs": "3.1.10", + "express": "5.1.0", + "external-editor": "3.1.0", + "figlet": "1.9.4", + "flat": "6.0.1", + "gettext-parser": "8.0.0", + "glob": "11.1.0", + "gradient-string": "3.0.0", + "gray-matter": "4.0.3", + "htmlparser2": "10.0.0", + "ini": "5.0.0", + "ink": "4.2.0", + "ink-progress-bar": "3.0.0", + "ink-spinner": "5.0.0", + "inquirer": "12.6.0", + "interactive-commander": "0.5.194", + "is-url": "1.2.4", + "jsdom": "25.0.1", + "json5": "2.2.3", + "jsonc-parser": "3.3.1", + "jsonrepair": "3.13.1", + "listr2": "8.3.2", + "lodash": "4.17.21", + "marked": "15.0.6", + "mdast-util-from-markdown": "2.0.2", + "mdast-util-gfm": "3.1.0", + "micromark-extension-gfm": "3.0.0", + "node-machine-id": "1.1.12", + "node-webvtt": "1.9.4", + "object-hash": "3.0.0", + "octokit": "4.0.2", + "ollama-ai-provider-v2": "2.0.0", + "open": "10.2.0", + "ora": "8.1.1", + "p-limit": "6.2.0", + "php-array-reader": "2.1.2", + "plist": "3.1.0", + "posthog-node": "5.14.0", + "prettier": "3.6.2", + "react": "19.2.3", + "rehype-stringify": "10.0.1", + "remark-disable-tokenizers": "1.1.1", + "remark-frontmatter": "5.0.0", + "remark-gfm": "4.0.1", + "remark-mdx": "3.1.1", + "remark-mdx-frontmatter": "5.2.0", + "remark-parse": "11.0.0", + "remark-rehype": "11.1.2", + "remark-stringify": "11.0.0", + "sax": "1.4.3", + "srt-parser-2": "1.2.3", + "unified": "11.0.5", + "unist-util-visit": "5.0.0", + "vfile": "6.0.3", + "xliff": "6.2.2", + "xml2js": "0.6.2", + "xpath": "0.0.34", + "yaml": "2.8.1", + "zod": "4.1.12" + }, + "devDependencies": { + "@types/babel__generator": "7.27.0", + "@types/chokidar": "2.1.7", + "@types/cli-progress": "3.11.6", + "@types/cors": "2.8.19", + "@types/diff": "7.0.0", + "@types/express": "5.0.5", + "@types/figlet": "1.7.0", + "@types/gettext-parser": "4.0.4", + "@types/glob": "8.1.0", + "@types/ini": "4.1.1", + "@types/is-url": "1.2.32", + "@types/jsdom": "21.1.7", + "@types/lodash": "4.17.21", + "@types/mdast": "4.0.4", + "@types/node": "22.10.2", + "@types/node-gettext": "3.0.6", + "@types/object-hash": "3.0.6", + "@types/plist": "3.0.5", + "@types/react": "19.2.7", + "@types/xml2js": "0.4.14", + "tsup": "8.5.1", + "typescript": "5.9.3", + "vitest": "3.1.2" + }, + "engines": { + "node": ">=18" + }, + "packageManager": "pnpm@9.12.3" +} diff --git a/packages/cli/src/cli/cmd/auth.ts b/packages/cli/src/cli/cmd/auth.ts new file mode 100644 index 000000000..caeae089a --- /dev/null +++ b/packages/cli/src/cli/cmd/auth.ts @@ -0,0 +1,53 @@ +import { Command } from "interactive-commander"; +import Ora from "ora"; +import { getSettings, saveSettings } from "../utils/settings"; +import { createAuthenticator } from "../utils/auth"; + +export default new Command() + .command("auth") + .description("Show current authentication status and user email") + .helpOption("-h, --help", "Show help") + // Deprecated options, safe to remove after September 2025 + .option( + "--login", + "DEPRECATED: Shows deprecation warning and exits. Use `lingo.dev login` instead", + ) + .option( + "--logout", + "DEPRECATED: Shows deprecation warning and exits. Use `lingo.dev logout` instead", + ) + .action(async (options) => { + try { + // Handle deprecated login option + if (options.login) { + Ora().warn( + "⚠️ DEPRECATED: '--login' is deprecated. Please use 'lingo.dev login' instead.", + ); + process.exit(1); + } + + // Handle deprecated logout option + if (options.logout) { + Ora().warn( + "⚠️ DEPRECATED: '--logout' is deprecated. Please use 'lingo.dev logout' instead.", + ); + process.exit(1); + } + + // Default behavior: show authentication status + const settings = await getSettings(undefined); + const authenticator = createAuthenticator({ + apiUrl: settings.auth.apiUrl, + apiKey: settings.auth.apiKey!, + }); + const auth = await authenticator.whoami(); + if (!auth) { + Ora().warn("Not authenticated"); + } else { + Ora().succeed(`Authenticated as ${auth.email}`); + } + } catch (error: any) { + Ora().fail(error.message); + process.exit(1); + } + }); diff --git a/packages/cli/src/cli/cmd/ci/flows/_base.ts b/packages/cli/src/cli/cmd/ci/flows/_base.ts new file mode 100644 index 000000000..f221f3166 --- /dev/null +++ b/packages/cli/src/cli/cmd/ci/flows/_base.ts @@ -0,0 +1,35 @@ +import { Ora } from "ora"; +import { PlatformKit } from "../platforms/_base"; + +export type IIntegrationFlowOptions = { + parallel?: boolean; + force?: boolean; +}; + +export interface IIntegrationFlow { + preRun?(): Promise; + run(options: IIntegrationFlowOptions): Promise; + postRun?(): Promise; +} + +export abstract class IntegrationFlow implements IIntegrationFlow { + protected i18nBranchName?: string; + + constructor( + protected ora: Ora, + protected platformKit: PlatformKit, + ) {} + + abstract run(options: IIntegrationFlowOptions): Promise; +} + +export function getGitConfig(platformKit: PlatformKit) { + return { + userName: platformKit.config.commitAuthorName, + userEmail: platformKit.config.commitAuthorEmail, + }; +} + +export function escapeShellArg(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/packages/cli/src/cli/cmd/ci/flows/in-branch.ts b/packages/cli/src/cli/cmd/ci/flows/in-branch.ts new file mode 100644 index 000000000..a704b9501 --- /dev/null +++ b/packages/cli/src/cli/cmd/ci/flows/in-branch.ts @@ -0,0 +1,136 @@ +import { execSync } from "child_process"; +import path from "path"; +import { + getGitConfig, + IntegrationFlow, + escapeShellArg, + IIntegrationFlowOptions, +} from "./_base"; +import i18nCmd from "../../i18n"; +import runCmd from "../../run"; + +export class InBranchFlow extends IntegrationFlow { + async preRun() { + this.ora.start("Configuring git"); + const canContinue = this.configureGit(); + this.ora.succeed("Git configured"); + + return canContinue; + } + + async run(options: IIntegrationFlowOptions) { + this.ora.start("Running Lingo.dev"); + await this.runLingoDotDev(options.parallel); + this.ora.succeed("Done running Lingo.dev"); + + execSync(`rm -f i18n.cache`, { stdio: "inherit" }); // do not commit cache file if it exists + + this.ora.start("Checking for changes"); + const hasChanges = this.checkCommitableChanges(); + this.ora.succeed(hasChanges ? "Changes detected" : "No changes detected"); + + if (hasChanges) { + this.ora.start("Committing changes"); + execSync(`git add .`, { stdio: "inherit" }); + execSync(`git status --porcelain`, { stdio: "inherit" }); + execSync( + `git commit -m ${escapeShellArg( + this.platformKit.config.commitMessage, + )} --no-verify`, + { + stdio: "inherit", + }, + ); + this.ora.succeed("Changes committed"); + + this.ora.start("Pushing changes to remote"); + const currentBranch = + this.i18nBranchName ?? this.platformKit.platformConfig.baseBranchName; + execSync( + `git push origin ${currentBranch} ${options.force ? "--force" : ""}`, + { + stdio: "inherit", + }, + ); + this.ora.succeed("Changes pushed to remote"); + } + + return hasChanges; + } + + protected checkCommitableChanges() { + return ( + execSync('git status --porcelain || echo "has_changes"', { + encoding: "utf8", + }).length > 0 + ); + } + + private async runLingoDotDev(isParallel?: boolean) { + try { + if (!isParallel) { + await i18nCmd + .exitOverride() + .parseAsync(["--api-key", this.platformKit.config.replexicaApiKey], { + from: "user", + }); + } else { + await runCmd + .exitOverride() + .parseAsync(["--api-key", this.platformKit.config.replexicaApiKey], { + from: "user", + }); + } + } catch (err: any) { + if (err.code === "commander.helpDisplayed") return; + throw err; + } + } + + private configureGit() { + const { processOwnCommits } = this.platformKit.config; + const { baseBranchName } = this.platformKit.platformConfig; + const gitConfig = getGitConfig(this.platformKit); + + this.ora.info(`Current working directory:`); + execSync(`pwd`, { stdio: "inherit" }); + execSync(`ls -la`, { stdio: "inherit" }); + + execSync(`git config --global safe.directory ${process.cwd()}`); + + execSync(`git config user.name "${gitConfig.userName}"`); + execSync(`git config user.email "${gitConfig.userEmail}"`); + + // perform platform-specific configuration before fetching or pushing to the remote + this.platformKit?.gitConfig(); + + execSync(`git fetch origin ${baseBranchName}`, { stdio: "inherit" }); + execSync(`git checkout ${baseBranchName} --`, { stdio: "inherit" }); + + if (!processOwnCommits) { + const currentAuthor = `${gitConfig.userName} <${gitConfig.userEmail}>`; + const authorOfLastCommit = execSync( + `git log -1 --pretty=format:'%an <%ae>'`, + ).toString(); + if (authorOfLastCommit === currentAuthor) { + this.ora.warn( + `The last commit was already made by ${currentAuthor}, so this run will be skipped, as running again would have no effect. See docs: https://lingo.dev/ci`, + ); + return false; + } + } + + const workingDir = path.resolve( + process.cwd(), + this.platformKit.config.workingDir, + ); + if (workingDir !== process.cwd()) { + this.ora.info( + `Changing to working directory: ${this.platformKit.config.workingDir}`, + ); + process.chdir(workingDir); + } + + return true; + } +} diff --git a/packages/cli/src/cli/cmd/ci/flows/pull-request.ts b/packages/cli/src/cli/cmd/ci/flows/pull-request.ts new file mode 100644 index 000000000..bdffd70f9 --- /dev/null +++ b/packages/cli/src/cli/cmd/ci/flows/pull-request.ts @@ -0,0 +1,231 @@ +import { execSync } from "child_process"; +import { InBranchFlow } from "./in-branch"; +import { IIntegrationFlowOptions } from "./_base"; + +export class PullRequestFlow extends InBranchFlow { + async preRun() { + const canContinue = await super.preRun?.(); + if (!canContinue) { + return false; + } + + this.ora.start("Calculating automated branch name"); + this.i18nBranchName = this.calculatePrBranchName(); + this.ora.succeed( + `Automated branch name calculated: ${this.i18nBranchName}`, + ); + + this.ora.start("Checking if branch exists"); + const branchExists = await this.checkBranchExistance(this.i18nBranchName); + this.ora.succeed(branchExists ? "Branch exists" : "Branch does not exist"); + + if (branchExists) { + this.ora.start(`Checking out branch ${this.i18nBranchName}`); + this.checkoutI18nBranch(this.i18nBranchName); + this.ora.succeed(`Checked out branch ${this.i18nBranchName}`); + + this.ora.start( + `Syncing with ${this.platformKit.platformConfig.baseBranchName}`, + ); + this.syncI18nBranch(); + this.ora.succeed(`Checked out and synced branch ${this.i18nBranchName}`); + } else { + this.ora.start(`Creating branch ${this.i18nBranchName}`); + this.createI18nBranch(this.i18nBranchName); + this.ora.succeed(`Created branch ${this.i18nBranchName}`); + } + + return true; + } + + override async run(options: IIntegrationFlowOptions) { + return super.run({ + force: true, + ...options, + }); + } + + async postRun() { + if (!this.i18nBranchName) { + throw new Error( + "i18nBranchName is not set. Did you forget to call preRun?", + ); + } + + this.ora.start("Checking if PR already exists"); + const pullRequestNumber = await this.ensureFreshPr(this.i18nBranchName); + // await this.createLabelIfNotExists(pullRequestNumber, 'lingo.dev/i18n', false); + this.ora.succeed( + `Pull request ready: ${this.platformKit.buildPullRequestUrl( + pullRequestNumber, + )}`, + ); + } + + private calculatePrBranchName(): string { + return `lingo.dev/${this.platformKit.platformConfig.baseBranchName}`; + } + + private async checkBranchExistance(prBranchName: string) { + return this.platformKit.branchExists({ + branch: prBranchName, + }); + } + + private async ensureFreshPr(i18nBranchName: string) { + // Check if PR exists + this.ora.start( + `Checking for existing PR with head ${i18nBranchName} and base ${this.platformKit.platformConfig.baseBranchName}`, + ); + let prNumber = await this.platformKit.getOpenPullRequestNumber({ + branch: i18nBranchName, + }); + + if (prNumber) { + this.ora.succeed(`Existing PR found: #${prNumber}`); + } else { + // Create new PR + this.ora.start(`Creating new PR`); + prNumber = await this.platformKit.createPullRequest({ + head: i18nBranchName, + title: this.platformKit.config.pullRequestTitle, + body: this.getPrBodyContent(), + }); + this.ora.succeed(`Created new PR: #${prNumber}`); + } + + return prNumber; + } + + private checkoutI18nBranch(i18nBranchName: string) { + execSync(`git fetch origin ${i18nBranchName}`, { stdio: "inherit" }); + execSync(`git checkout -b ${i18nBranchName}`, { + stdio: "inherit", + }); + } + + private createI18nBranch(i18nBranchName: string) { + try { + execSync( + `git fetch origin ${this.platformKit.platformConfig.baseBranchName}`, + { stdio: "inherit" }, + ); + execSync( + `git checkout -b ${i18nBranchName} origin/${this.platformKit.platformConfig.baseBranchName}`, + { + stdio: "inherit", + }, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred"; + this.ora.fail(`Failed to create branch: ${errorMessage}`); + this.ora.info(` + Troubleshooting tips: + 1. Make sure you have permission to create branches + 2. Check if the branch already exists locally (try 'git branch -a') + 3. Verify connectivity to remote repository + `); + throw new Error(`Branch creation failed: ${errorMessage}`); + } + } + + private syncI18nBranch() { + if (!this.i18nBranchName) { + throw new Error("i18nBranchName is not set"); + } + + this.ora.start( + `Fetching latest changes from ${this.platformKit.platformConfig.baseBranchName}`, + ); + execSync( + `git fetch origin ${this.platformKit.platformConfig.baseBranchName}`, + { stdio: "inherit" }, + ); + this.ora.succeed( + `Fetched latest changes from ${this.platformKit.platformConfig.baseBranchName}`, + ); + + try { + this.ora.start("Attempting to rebase branch"); + execSync( + `git rebase origin/${this.platformKit.platformConfig.baseBranchName}`, + { stdio: "inherit" }, + ); + this.ora.succeed("Successfully rebased branch"); + } catch (error) { + this.ora.warn("Rebase failed, falling back to alternative sync method"); + + this.ora.start("Aborting failed rebase"); + execSync("git rebase --abort", { stdio: "inherit" }); + this.ora.succeed("Aborted failed rebase"); + + this.ora.start( + `Resetting to ${this.platformKit.platformConfig.baseBranchName}`, + ); + execSync( + `git reset --hard origin/${this.platformKit.platformConfig.baseBranchName}`, + { stdio: "inherit" }, + ); + this.ora.succeed( + `Reset to ${this.platformKit.platformConfig.baseBranchName}`, + ); + + this.ora.start("Restoring target files"); + const targetFiles = ["i18n.lock"]; + const targetFileNames = execSync( + `npx lingo.dev@latest show files --target ${this.platformKit.platformConfig.baseBranchName}`, + { encoding: "utf8" }, + ) + .split("\n") + .filter(Boolean); + targetFiles.push(...targetFileNames); + execSync(`git fetch origin ${this.i18nBranchName}`, { stdio: "inherit" }); + for (const file of targetFiles) { + try { + // bring all files to the i18n branch's state + execSync(`git checkout FETCH_HEAD -- ${file}`, { stdio: "inherit" }); + } catch (error) { + // If file doesn't exist in FETCH_HEAD, that's okay - just skip it + this.ora.warn(`Skipping non-existent file: ${file}`); + continue; + } + } + this.ora.succeed("Restored target files"); + } + + this.ora.start("Checking for changes to commit"); + const hasChanges = this.checkCommitableChanges(); + if (hasChanges) { + execSync("git add .", { stdio: "inherit" }); + execSync( + `git commit -m "chore: sync with ${this.platformKit.platformConfig.baseBranchName}" --no-verify`, + { + stdio: "inherit", + }, + ); + this.ora.succeed("Committed additional changes"); + } else { + this.ora.succeed("No changes to commit"); + } + } + + private getPrBodyContent(): string { + return ` +Hey team, + +[**Lingo.dev**](https://lingo.dev) here with fresh translations! + +### In this update + +- Added missing translations +- Performed brand voice, context and glossary checks +- Enhanced translations using Lingo.dev Localization Engine + +### Next Steps + +- [ ] Review the changes +- [ ] Merge when ready + `.trim(); + } +} diff --git a/packages/cli/src/cli/cmd/ci/index.ts b/packages/cli/src/cli/cmd/ci/index.ts new file mode 100644 index 000000000..900f0ce0b --- /dev/null +++ b/packages/cli/src/cli/cmd/ci/index.ts @@ -0,0 +1,143 @@ +import { Command } from "interactive-commander"; +import createOra from "ora"; +import { getSettings } from "../../utils/settings"; +import { createAuthenticator } from "../../utils/auth"; +import { IIntegrationFlow } from "./flows/_base"; +import { PullRequestFlow } from "./flows/pull-request"; +import { InBranchFlow } from "./flows/in-branch"; +import { getPlatformKit } from "./platforms"; + +interface CIOptions { + parallel?: boolean; + apiKey?: string; + debug?: boolean; + pullRequest?: boolean; + commitMessage?: string; + pullRequestTitle?: string; + commitAuthorName?: string; + commitAuthorEmail?: string; + workingDirectory?: string; + processOwnCommits?: boolean; +} + +export default new Command() + .command("ci") + .description("Run localization pipeline in CI/CD environment") + .helpOption("-h, --help", "Show help") + .option( + "--parallel [boolean]", + "Process translations concurrently for faster execution. Defaults to false", + parseBooleanArg, + ) + .option( + "--api-key ", + "Override API key from settings or environment variables", + ) + .option( + "--pull-request [boolean]", + "Create or update translations on a dedicated branch and manage pull requests automatically. When false, commits directly to current branch. Defaults to false", + parseBooleanArg, + ) + .option( + "--commit-message ", + "Commit message for localization changes. Defaults to 'feat: update translations via @lingodotdev'", + ) + .option( + "--pull-request-title ", + "Title for the pull request when using --pull-request mode. Defaults to 'feat: update translations via @lingodotdev'", + ) + .option( + "--commit-author-name <name>", + "Git commit author name. Defaults to 'Lingo.dev'", + ) + .option( + "--commit-author-email <email>", + "Git commit author email. Defaults to 'support@lingo.dev'", + ) + .option( + "--working-directory <dir>", + "Directory to run localization from (useful for monorepos where localization files are in a subdirectory)", + ) + .option( + "--process-own-commits [boolean]", + "Allow processing commits made by this CI user (bypasses infinite loop prevention)", + parseBooleanArg, + ) + .action(async (options: CIOptions) => { + const settings = getSettings(options.apiKey); + + console.log(options); + + if (!settings.auth.apiKey) { + console.error("No API key provided"); + return; + } + + const authenticator = createAuthenticator({ + apiUrl: settings.auth.apiUrl, + apiKey: settings.auth.apiKey, + }); + const auth = await authenticator.whoami(); + + if (!auth) { + console.error("Not authenticated"); + return; + } + + const env = { + LINGODOTDEV_API_KEY: settings.auth.apiKey, + LINGODOTDEV_PULL_REQUEST: options.pullRequest?.toString() || "false", + ...(options.commitMessage && { + LINGODOTDEV_COMMIT_MESSAGE: options.commitMessage, + }), + ...(options.pullRequestTitle && { + LINGODOTDEV_PULL_REQUEST_TITLE: options.pullRequestTitle, + }), + ...(options.commitAuthorName && { + LINGODOTDEV_COMMIT_AUTHOR_NAME: options.commitAuthorName, + }), + ...(options.commitAuthorEmail && { + LINGODOTDEV_COMMIT_AUTHOR_EMAIL: options.commitAuthorEmail, + }), + ...(options.workingDirectory && { + LINGODOTDEV_WORKING_DIRECTORY: options.workingDirectory, + }), + ...(options.processOwnCommits && { + LINGODOTDEV_PROCESS_OWN_COMMITS: options.processOwnCommits.toString(), + }), + }; + + process.env = { ...process.env, ...env }; + + const ora = createOra(); + const platformKit = getPlatformKit(); + const { isPullRequestMode } = platformKit.config; + + ora.info(`Pull request mode: ${isPullRequestMode ? "on" : "off"}`); + + const flow: IIntegrationFlow = isPullRequestMode + ? new PullRequestFlow(ora, platformKit) + : new InBranchFlow(ora, platformKit); + + const canRun = await flow.preRun?.(); + if (canRun === false) { + return; + } + + const hasChanges = await flow.run({ + parallel: options.parallel, + }); + if (!hasChanges) { + return; + } + + await flow.postRun?.(); + }); + +function parseBooleanArg(val: string | boolean | undefined): boolean { + if (val === true) return true; + if (typeof val === "string") { + return val.toLowerCase() === "true"; + } + return false; +} diff --git a/packages/cli/src/cli/cmd/ci/platforms/_base.ts b/packages/cli/src/cli/cmd/ci/platforms/_base.ts new file mode 100644 index 000000000..eaf3a111f --- /dev/null +++ b/packages/cli/src/cli/cmd/ci/platforms/_base.ts @@ -0,0 +1,85 @@ +import { execSync } from "child_process"; +import Z from "zod"; + +const defaultMessage = "feat: update translations via @lingodotdev"; + +interface BasePlatformConfig { + baseBranchName: string; + repositoryOwner: string; + repositoryName: string; +} + +export abstract class PlatformKit< + PlatformConfig extends BasePlatformConfig = BasePlatformConfig, +> { + abstract branchExists(props: { branch: string }): Promise<boolean>; + + abstract getOpenPullRequestNumber(props: { + branch: string; + }): Promise<number | undefined>; + + abstract closePullRequest(props: { + pullRequestNumber: number; + }): Promise<void>; + + abstract createPullRequest(props: { + head: string; + title: string; + body?: string; + }): Promise<number>; + + abstract commentOnPullRequest(props: { + pullRequestNumber: number; + body: string; + }): Promise<void>; + + abstract get platformConfig(): PlatformConfig; + + abstract buildPullRequestUrl(pullRequestNumber: number): string; + + gitConfig(token?: string, repoUrl?: string) { + if (token && repoUrl) { + execSync(`git remote set-url origin ${repoUrl}`, { + stdio: "inherit", + }); + } + } + + get config() { + const env = Z.object({ + LINGODOTDEV_API_KEY: Z.string(), + LINGODOTDEV_PULL_REQUEST: Z.preprocess( + (val) => val === "true" || val === true, + Z.boolean(), + ), + LINGODOTDEV_COMMIT_MESSAGE: Z.string().optional(), + LINGODOTDEV_PULL_REQUEST_TITLE: Z.string().optional(), + LINGODOTDEV_COMMIT_AUTHOR_NAME: Z.string().optional(), + LINGODOTDEV_COMMIT_AUTHOR_EMAIL: Z.string().optional(), + LINGODOTDEV_WORKING_DIRECTORY: Z.string().optional(), + LINGODOTDEV_PROCESS_OWN_COMMITS: Z.preprocess( + (val) => val === "true" || val === true, + Z.boolean(), + ).optional(), + }).parse(process.env); + + return { + replexicaApiKey: env.LINGODOTDEV_API_KEY, + isPullRequestMode: env.LINGODOTDEV_PULL_REQUEST, + commitMessage: env.LINGODOTDEV_COMMIT_MESSAGE || defaultMessage, + pullRequestTitle: env.LINGODOTDEV_PULL_REQUEST_TITLE || defaultMessage, + commitAuthorName: env.LINGODOTDEV_COMMIT_AUTHOR_NAME || "Lingo.dev", + commitAuthorEmail: + env.LINGODOTDEV_COMMIT_AUTHOR_EMAIL || "support@lingo.dev", + workingDir: env.LINGODOTDEV_WORKING_DIRECTORY || ".", + processOwnCommits: env.LINGODOTDEV_PROCESS_OWN_COMMITS || false, + }; + } +} + +export interface IConfig { + replexicaApiKey: string; + isPullRequestMode: boolean; + commitMessage: string; + pullRequestTitle: string; +} diff --git a/packages/cli/src/cli/cmd/ci/platforms/bitbucket.ts b/packages/cli/src/cli/cmd/ci/platforms/bitbucket.ts new file mode 100644 index 000000000..f738349a1 --- /dev/null +++ b/packages/cli/src/cli/cmd/ci/platforms/bitbucket.ts @@ -0,0 +1,143 @@ +import { execSync } from "child_process"; +import bbLib from "bitbucket"; +import Z from "zod"; +import { PlatformKit } from "./_base"; + +const { Bitbucket } = bbLib; + +interface BitbucketConfig { + baseBranchName: string; + repositoryOwner: string; + repositoryName: string; + bbToken?: string; +} + +export class BitbucketPlatformKit extends PlatformKit<BitbucketConfig> { + private _bb?: ReturnType<typeof Bitbucket>; + + private get bb() { + if (!this._bb) { + this._bb = new Bitbucket({ + auth: { token: this.platformConfig.bbToken || "" }, + }); + } + return this._bb; + } + + async branchExists({ branch }: { branch: string }) { + return await this.bb.repositories + .getBranch({ + workspace: this.platformConfig.repositoryOwner, + repo_slug: this.platformConfig.repositoryName, + name: branch, + }) + .then((r) => r.data) + .then((v) => !!v) + .catch((r) => (r.status === 404 ? false : Promise.reject(r))); + } + + async getOpenPullRequestNumber({ branch }: { branch: string }) { + return await this.bb.repositories + .listPullRequests({ + workspace: this.platformConfig.repositoryOwner, + repo_slug: this.platformConfig.repositoryName, + state: "OPEN", + }) + .then(({ data: { values } }) => { + // TODO: we might need to handle pagination in future + // bitbucket API does not support filtering pull requests + // https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-get + return values?.find( + ({ source, destination }) => + source?.branch?.name === branch && + destination?.branch?.name === this.platformConfig.baseBranchName, + ); + }) + .then((pr) => pr?.id); + } + + async closePullRequest({ pullRequestNumber }: { pullRequestNumber: number }) { + await this.bb.repositories.declinePullRequest({ + workspace: this.platformConfig.repositoryOwner, + repo_slug: this.platformConfig.repositoryName, + pull_request_id: pullRequestNumber, + }); + } + + async createPullRequest({ + title, + body, + head, + }: { + title: string; + body?: string; + head: string; + }) { + return await this.bb.repositories + .createPullRequest({ + workspace: this.platformConfig.repositoryOwner, + repo_slug: this.platformConfig.repositoryName, + _body: { + title, + description: body, + source: { branch: { name: head } }, + destination: { branch: { name: this.platformConfig.baseBranchName } }, + } as any, + }) + .then(({ data }) => data.id ?? 0); + } + + async commentOnPullRequest({ + pullRequestNumber, + body, + }: { + pullRequestNumber: number; + body: string; + }) { + await this.bb.repositories.createPullRequestComment({ + workspace: this.platformConfig.repositoryOwner, + repo_slug: this.platformConfig.repositoryName, + pull_request_id: pullRequestNumber, + _body: { + content: { + raw: body, + }, + } as any, + }); + } + + async gitConfig() { + execSync("git config --unset http.${BITBUCKET_GIT_HTTP_ORIGIN}.proxy", { + stdio: "inherit", + }); + execSync( + "git config http.${BITBUCKET_GIT_HTTP_ORIGIN}.proxy http://host.docker.internal:29418/", + { + stdio: "inherit", + }, + ); + } + + get platformConfig() { + const env = Z.object({ + BITBUCKET_BRANCH: Z.string(), + BITBUCKET_REPO_FULL_NAME: Z.string(), + BB_TOKEN: Z.string().optional(), + }).parse(process.env); + + const [repositoryOwner, repositoryName] = + env.BITBUCKET_REPO_FULL_NAME.split("/"); + + return { + baseBranchName: env.BITBUCKET_BRANCH, + repositoryOwner, + repositoryName, + bbToken: env.BB_TOKEN, + }; + } + + buildPullRequestUrl(pullRequestNumber: number) { + const { repositoryOwner, repositoryName } = this.platformConfig; + return `https://bitbucket.org/${repositoryOwner}/${repositoryName}/pull-requests/${pullRequestNumber}`; + } +} diff --git a/packages/cli/src/cli/cmd/ci/platforms/github.ts b/packages/cli/src/cli/cmd/ci/platforms/github.ts new file mode 100644 index 000000000..95db988cc --- /dev/null +++ b/packages/cli/src/cli/cmd/ci/platforms/github.ts @@ -0,0 +1,125 @@ +import { Octokit } from "octokit"; +import { PlatformKit } from "./_base"; +import Z from "zod"; + +export class GitHubPlatformKit extends PlatformKit { + private _octokit?: Octokit; + + private get octokit(): Octokit { + if (!this._octokit) { + this._octokit = new Octokit({ auth: this.platformConfig.ghToken }); + } + return this._octokit; + } + + async branchExists({ branch }: { branch: string }) { + return await this.octokit.rest.repos + .getBranch({ + branch, + owner: this.platformConfig.repositoryOwner, + repo: this.platformConfig.repositoryName, + }) + .then((r) => r.data) + .then((v) => !!v) + .catch((r) => (r.status === 404 ? false : Promise.reject(r))); + } + + async getOpenPullRequestNumber({ branch }: { branch: string }) { + return await this.octokit.rest.pulls + .list({ + head: `${this.platformConfig.repositoryOwner}:${branch}`, + owner: this.platformConfig.repositoryOwner, + repo: this.platformConfig.repositoryName, + base: this.platformConfig.baseBranchName, + state: "open", + }) + .then(({ data }) => data[0]) + .then((pr) => pr?.number); + } + + async closePullRequest({ pullRequestNumber }: { pullRequestNumber: number }) { + await this.octokit.rest.pulls.update({ + pull_number: pullRequestNumber, + owner: this.platformConfig.repositoryOwner, + repo: this.platformConfig.repositoryName, + state: "closed", + }); + } + + async createPullRequest({ + head, + title, + body, + }: { + head: string; + title: string; + body?: string; + }) { + return await this.octokit.rest.pulls + .create({ + head, + title, + body, + owner: this.platformConfig.repositoryOwner, + repo: this.platformConfig.repositoryName, + base: this.platformConfig.baseBranchName, + }) + .then(({ data }) => data.number); + } + + async commentOnPullRequest({ + pullRequestNumber, + body, + }: { + pullRequestNumber: number; + body: string; + }) { + await this.octokit.rest.issues.createComment({ + issue_number: pullRequestNumber, + body, + owner: this.platformConfig.repositoryOwner, + repo: this.platformConfig.repositoryName, + }); + } + + async gitConfig() { + const { ghToken, repositoryOwner, repositoryName } = this.platformConfig; + const { processOwnCommits } = this.config; + + if (ghToken && processOwnCommits) { + console.log( + "Using provided GH_TOKEN. This will trigger your CI/CD pipeline to run again.", + ); + + const url = `https://${ghToken}@github.com/${repositoryOwner}/${repositoryName}.git`; + + super.gitConfig(ghToken, url); + } + } + + get platformConfig() { + const env = Z.object({ + GITHUB_REPOSITORY: Z.string(), + GITHUB_REPOSITORY_OWNER: Z.string(), + GITHUB_REF_NAME: Z.string(), + GITHUB_HEAD_REF: Z.string(), + GH_TOKEN: Z.string().optional(), + }).parse(process.env); + + const baseBranchName = !env.GITHUB_REF_NAME.endsWith("/merge") + ? env.GITHUB_REF_NAME + : env.GITHUB_HEAD_REF; + + return { + ghToken: env.GH_TOKEN, + baseBranchName, + repositoryOwner: env.GITHUB_REPOSITORY_OWNER, + repositoryName: env.GITHUB_REPOSITORY.split("/")[1], + }; + } + + buildPullRequestUrl(pullRequestNumber: number) { + const { repositoryOwner, repositoryName } = this.platformConfig; + return `https://github.com/${repositoryOwner}/${repositoryName}/pull/${pullRequestNumber}`; + } +} diff --git a/packages/cli/src/cli/cmd/ci/platforms/gitlab.ts b/packages/cli/src/cli/cmd/ci/platforms/gitlab.ts new file mode 100644 index 000000000..a188abe3d --- /dev/null +++ b/packages/cli/src/cli/cmd/ci/platforms/gitlab.ts @@ -0,0 +1,136 @@ +import { Gitlab } from "@gitbeaker/rest"; +import Z from "zod"; +import { PlatformKit } from "./_base"; + +const gl = new Gitlab({ token: "" }); + +export class GitlabPlatformKit extends PlatformKit { + private _gitlab?: InstanceType<typeof Gitlab>; + + constructor() { + super(); + + // change directory to current repository before executing replexica + process.chdir(this.platformConfig.projectDir); + } + + private get gitlab(): InstanceType<typeof Gitlab> { + if (!this._gitlab) { + this._gitlab = new Gitlab({ + token: this.platformConfig.glToken || "", + }); + } + return this._gitlab; + } + + get platformConfig() { + const env = Z.object({ + GL_TOKEN: Z.string().optional(), + CI_COMMIT_BRANCH: Z.string(), + CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: Z.string().optional(), + CI_PROJECT_NAMESPACE: Z.string(), + CI_PROJECT_NAME: Z.string(), + CI_PROJECT_ID: Z.string(), + CI_PROJECT_DIR: Z.string(), + CI_REPOSITORY_URL: Z.string(), + }).parse(process.env); + + const config = { + glToken: env.GL_TOKEN, + baseBranchName: + env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ?? env.CI_COMMIT_BRANCH, + repositoryOwner: env.CI_PROJECT_NAMESPACE, + repositoryName: env.CI_PROJECT_NAME, + gitlabProjectId: env.CI_PROJECT_ID, + projectDir: env.CI_PROJECT_DIR, + reporitoryUrl: env.CI_REPOSITORY_URL, + }; + + return config; + } + + async branchExists({ branch }: { branch: string }): Promise<boolean> { + try { + await this.gitlab.Branches.show( + this.platformConfig.gitlabProjectId, + branch, + ); + return true; + } catch { + return false; + } + } + + async getOpenPullRequestNumber({ + branch, + }: { + branch: string; + }): Promise<number | undefined> { + const mergeRequests = await this.gitlab.MergeRequests.all({ + projectId: this.platformConfig.gitlabProjectId, + sourceBranch: branch, + state: "opened", + }); + return mergeRequests[0]?.iid; + } + + async closePullRequest({ + pullRequestNumber, + }: { + pullRequestNumber: number; + }): Promise<void> { + await this.gitlab.MergeRequests.edit( + this.platformConfig.gitlabProjectId, + pullRequestNumber, + { + stateEvent: "close", + }, + ); + } + + async createPullRequest({ + head, + title, + body, + }: { + head: string; + title: string; + body?: string; + }): Promise<number> { + const mr = await this.gitlab.MergeRequests.create( + this.platformConfig.gitlabProjectId, + head, + this.platformConfig.baseBranchName, + title, + { + description: body, + }, + ); + return mr.iid; + } + + async commentOnPullRequest({ + pullRequestNumber, + body, + }: { + pullRequestNumber: number; + body: string; + }): Promise<void> { + await this.gitlab.MergeRequestNotes.create( + this.platformConfig.gitlabProjectId, + pullRequestNumber, + body, + ); + } + + gitConfig(): Promise<void> | void { + const glToken = this.platformConfig.glToken; + const url = `https://oauth2:${glToken}@gitlab.com/${this.platformConfig.repositoryOwner}/${this.platformConfig.repositoryName}.git`; + + super.gitConfig(glToken, url); + } + + buildPullRequestUrl(pullRequestNumber: number): string { + return `https://gitlab.com/${this.platformConfig.repositoryOwner}/${this.platformConfig.repositoryName}/-/merge_requests/${pullRequestNumber}`; + } +} diff --git a/packages/cli/src/cli/cmd/ci/platforms/index.ts b/packages/cli/src/cli/cmd/ci/platforms/index.ts new file mode 100644 index 000000000..faa5da1b1 --- /dev/null +++ b/packages/cli/src/cli/cmd/ci/platforms/index.ts @@ -0,0 +1,19 @@ +import { BitbucketPlatformKit } from "./bitbucket"; +import { GitHubPlatformKit } from "./github"; +import { GitlabPlatformKit } from "./gitlab"; + +export const getPlatformKit = () => { + if (process.env.BITBUCKET_PIPELINE_UUID) { + return new BitbucketPlatformKit(); + } + + if (process.env.GITHUB_ACTION) { + return new GitHubPlatformKit(); + } + + if (process.env.GITLAB_CI) { + return new GitlabPlatformKit(); + } + + throw new Error("This platform is not supported"); +}; diff --git a/packages/cli/src/cli/cmd/cleanup.ts b/packages/cli/src/cli/cmd/cleanup.ts new file mode 100644 index 000000000..8f78d0f3b --- /dev/null +++ b/packages/cli/src/cli/cmd/cleanup.ts @@ -0,0 +1,168 @@ +import { I18nConfig, resolveOverriddenLocale } from "@lingo.dev/_spec"; +import { Command } from "interactive-commander"; +import _ from "lodash"; +import { getConfig } from "../utils/config"; +import { CLIError } from "../utils/errors"; +import Ora from "ora"; +import createBucketLoader from "../loaders"; +import { getBuckets } from "../utils/buckets"; + +export default new Command() + .command("cleanup") + .description( + "Remove translation keys from target locales that no longer exist in the source locale", + ) + .helpOption("-h, --help", "Show help") + .option( + "--locale <locale>", + "Limit cleanup to a specific target locale from i18n.json. Defaults to all configured target locales", + ) + .option( + "--bucket <bucket>", + "Limit cleanup to a specific bucket type defined under `buckets` in i18n.json", + ) + .option( + "--dry-run", + "Preview which keys would be deleted without making any changes", + ) + .option( + "--verbose", + "Print detailed output showing the specific keys to be removed for each locale", + ) + .action(async function (options) { + const ora = Ora(); + const results: any = []; + + try { + ora.start("Loading configuration..."); + const i18nConfig = getConfig(); + validateConfig(i18nConfig); + ora.succeed("Configuration loaded"); + + let buckets = getBuckets(i18nConfig!); + if (options.bucket) { + buckets = buckets.filter( + (bucket: any) => bucket.type === options.bucket, + ); + } + + const targetLocales = options.locale + ? [options.locale] + : i18nConfig!.locale.targets; + + // Process each bucket + for (const bucket of buckets) { + console.log(); + ora.info(`Processing bucket: ${bucket.type}`); + + for (const bucketConfig of bucket.paths) { + const sourceLocale = resolveOverriddenLocale( + i18nConfig!.locale.source, + bucketConfig.delimiter, + ); + const bucketOra = Ora({ indent: 2 }).info( + `Processing path: ${bucketConfig.pathPattern}`, + ); + const bucketLoader = createBucketLoader( + bucket.type, + bucketConfig.pathPattern, + { + defaultLocale: sourceLocale, + formatter: i18nConfig!.formatter, + }, + bucket.lockedKeys, + bucket.lockedPatterns, + bucket.ignoredKeys, + ); + bucketLoader.setDefaultLocale(sourceLocale); + + // Load source data + const sourceData = await bucketLoader.pull(sourceLocale); + const sourceKeys = Object.keys(sourceData); + + for (const _targetLocale of targetLocales) { + const targetLocale = resolveOverriddenLocale( + _targetLocale, + bucketConfig.delimiter, + ); + try { + const targetData = await bucketLoader.pull(targetLocale); + const targetKeys = Object.keys(targetData); + const keysToRemove = _.difference(targetKeys, sourceKeys); + + if (keysToRemove.length === 0) { + bucketOra.succeed(`[${targetLocale}] No keys to remove`); + continue; + } + + if (options.verbose) { + bucketOra.info( + `[${targetLocale}] Keys to remove: ${JSON.stringify( + keysToRemove, + null, + 2, + )}`, + ); + } + + if (!options.dryRun) { + const cleanedData = _.pick(targetData, sourceKeys); + await bucketLoader.push(targetLocale, cleanedData); + bucketOra.succeed( + `[${targetLocale}] Removed ${keysToRemove.length} keys`, + ); + } else { + bucketOra.succeed( + `[${targetLocale}] Would remove ${keysToRemove.length} keys (dry run)`, + ); + } + } catch (error: any) { + bucketOra.fail( + `[${targetLocale}] Failed to cleanup: ${error.message}`, + ); + results.push({ + step: `Cleanup ${bucket.type}/${bucketConfig} for ${targetLocale}`, + status: "Failed", + error: error.message, + }); + } + } + } + } + + console.log(); + ora.succeed("Cleanup completed!"); + } catch (error: any) { + ora.fail(error.message); + process.exit(1); + } finally { + displaySummary(results); + } + }); + +function validateConfig(i18nConfig: I18nConfig | null) { + if (!i18nConfig) { + throw new CLIError({ + message: + "i18n.json not found. Please run `lingo.dev init` to initialize the project.", + docUrl: "i18nNotFound", + }); + } + if (!i18nConfig.buckets || !Object.keys(i18nConfig.buckets).length) { + throw new CLIError({ + message: + "No buckets found in i18n.json. Please add at least one bucket containing i18n content.", + docUrl: "bucketNotFound", + }); + } +} + +function displaySummary(results: any[]) { + if (results.length === 0) return; + + console.log("\nProcess Summary:"); + results.forEach((result) => { + console.log(`${result.step}: ${result.status}`); + if (result.error) console.log(` - Error: ${result.error}`); + }); +} diff --git a/packages/cli/src/cli/cmd/config/get.ts b/packages/cli/src/cli/cmd/config/get.ts new file mode 100644 index 000000000..e6430820c --- /dev/null +++ b/packages/cli/src/cli/cmd/config/get.ts @@ -0,0 +1,43 @@ +import { Command } from "interactive-commander"; +import chalk from "chalk"; +import _ from "lodash"; +import { SETTINGS_KEYS, loadSystemSettings } from "../../utils/settings"; +import dedent from "dedent"; + +export default new Command() + .name("get") + .description("Display the value of a CLI setting from ~/.lingodotdevrc") + .addHelpText("afterAll", `\nAvailable keys:\n ${SETTINGS_KEYS.join("\n ")}`) + .argument( + "<key>", + "Configuration key to read (choose from the available keys listed below)", + ) + .helpOption("-h, --help", "Show help") + .action(async (key: string) => { + // Validate that the provided key is one of the recognised configuration keys. + if (!SETTINGS_KEYS.includes(key)) { + console.error( + dedent` + ${chalk.red("✖")} Unknown configuration key: ${chalk.bold(key)} + Run ${chalk.dim("lingo.dev config get --help")} to see available keys. + `, + ); + process.exitCode = 1; + return; + } + + const settings = loadSystemSettings(); + const value = _.get(settings, key); + + if (!value) { + // Key is valid but not set in the configuration file. + console.log(`${chalk.cyan("ℹ")} ${chalk.bold(key)} is not set.`); + return; + } + + if (typeof value === "object") { + console.log(JSON.stringify(value, null, 2)); + } else { + console.log(value); + } + }); diff --git a/packages/cli/src/cli/cmd/config/index.ts b/packages/cli/src/cli/cmd/config/index.ts new file mode 100644 index 000000000..c055b3e41 --- /dev/null +++ b/packages/cli/src/cli/cmd/config/index.ts @@ -0,0 +1,15 @@ +import { Command } from "interactive-commander"; + +import setCmd from "./set"; +import unsetCmd from "./unset"; +import getCmd from "./get"; + +export default new Command() + .command("config") + .description( + "Manage CLI settings (authentication, API keys) stored in ~/.lingodotdevrc", + ) + .helpOption("-h, --help", "Show help") + .addCommand(setCmd) + .addCommand(unsetCmd) + .addCommand(getCmd); diff --git a/packages/cli/src/cli/cmd/config/set.ts b/packages/cli/src/cli/cmd/config/set.ts new file mode 100644 index 000000000..6ca879d2f --- /dev/null +++ b/packages/cli/src/cli/cmd/config/set.ts @@ -0,0 +1,50 @@ +import { Command } from "interactive-commander"; +import chalk from "chalk"; +import dedent from "dedent"; +import _ from "lodash"; +import { + SETTINGS_KEYS, + loadSystemSettings, + saveSettings, +} from "../../utils/settings"; + +export default new Command() + .name("set") + .description("Set or update a CLI setting in ~/.lingodotdevrc") + .addHelpText("afterAll", `\nAvailable keys:\n ${SETTINGS_KEYS.join("\n ")}`) + .argument( + "<key>", + "Configuration key to set (dot notation, e.g., auth.apiKey)", + ) + .argument("<value>", "The configuration value to set") + .helpOption("-h, --help", "Show help") + .action(async (key: string, value: string) => { + if (!SETTINGS_KEYS.includes(key)) { + console.error( + dedent` + ${chalk.red("✖")} Unknown configuration key: ${chalk.bold(key)} + Run ${chalk.dim("lingo.dev config set --help")} to see available keys. + `, + ); + process.exitCode = 1; + return; + } + + const current = loadSystemSettings(); + const updated: any = _.cloneDeep(current); + _.set(updated, key, value); + + try { + saveSettings(updated as any); + console.log(`${chalk.green("✔")} Set ${chalk.bold(key)}`); + } catch (err) { + console.error( + chalk.red( + `✖ Failed to save configuration: ${chalk.dim( + err instanceof Error ? err.message : String(err), + )}`, + ), + ); + process.exitCode = 1; + } + }); diff --git a/packages/cli/src/cli/cmd/config/unset.ts b/packages/cli/src/cli/cmd/config/unset.ts new file mode 100644 index 000000000..14e4377e7 --- /dev/null +++ b/packages/cli/src/cli/cmd/config/unset.ts @@ -0,0 +1,61 @@ +import { Command } from "interactive-commander"; +import chalk from "chalk"; +import dedent from "dedent"; +import _ from "lodash"; +import { + SETTINGS_KEYS, + loadSystemSettings, + saveSettings, +} from "../../utils/settings"; + +export default new Command() + .name("unset") + .description("Remove a CLI setting from ~/.lingodotdevrc") + .addHelpText("afterAll", `\nAvailable keys:\n ${SETTINGS_KEYS.join("\n ")}`) + .argument( + "<key>", + "Configuration key to remove (must match one of the available keys listed below)", + ) + .helpOption("-h, --help", "Show help") + .action(async (key: string) => { + // Validate key first (defensive; choices() should already restrict but keep for safety). + if (!SETTINGS_KEYS.includes(key)) { + console.error( + dedent` + ${chalk.red("✖")} Unknown configuration key: ${chalk.bold(key)} + Run ${chalk.dim( + "lingo.dev config unset --help", + )} to see available keys. + `, + ); + process.exitCode = 1; + return; + } + + // Load existing settings. + const settings = loadSystemSettings(); + const currentValue = _.get(settings, key); + + if (!_.trim(String(currentValue || ""))) { + console.log(`${chalk.cyan("ℹ")} ${chalk.bold(key)} is not set.`); + return; + } else { + const updated: any = _.cloneDeep(settings); + _.unset(updated, key); + try { + saveSettings(updated as any); + console.log( + `${chalk.green("✔")} Removed configuration key ${chalk.bold(key)}`, + ); + } catch (err) { + console.error( + chalk.red( + `✖ Failed to save configuration: ${chalk.dim( + err instanceof Error ? err.message : String(err), + )}`, + ), + ); + process.exitCode = 1; + } + } + }); diff --git a/packages/cli/src/cli/cmd/i18n.ts b/packages/cli/src/cli/cmd/i18n.ts new file mode 100644 index 000000000..b7c2b8fc9 --- /dev/null +++ b/packages/cli/src/cli/cmd/i18n.ts @@ -0,0 +1,858 @@ +import { + bucketTypeSchema, + I18nConfig, + localeCodeSchema, + resolveOverriddenLocale, +} from "@lingo.dev/_spec"; +import { Command } from "interactive-commander"; +import Z from "zod"; +import _ from "lodash"; +import * as path from "path"; +import { getConfigOrThrow } from "../utils/config"; +import { getSettings } from "../utils/settings"; +import { + ConfigError, + AuthenticationError, + ValidationError, + LocalizationError, + BucketProcessingError, + getCLIErrorType, + isLocalizationError, + isBucketProcessingError, + ErrorDetail, + aggregateErrorAnalytics, + createPreviousErrorContext, +} from "../utils/errors"; +import Ora from "ora"; +import createBucketLoader from "../loaders"; +import { createAuthenticator } from "../utils/auth"; +import { getBuckets } from "../utils/buckets"; +import chalk from "chalk"; +import { createTwoFilesPatch } from "diff"; +import inquirer from "inquirer"; +import externalEditor from "external-editor"; +import updateGitignore from "../utils/update-gitignore"; +import createProcessor from "../processor"; +import { withExponentialBackoff } from "../utils/exp-backoff"; +import trackEvent from "../utils/observability"; +import { createDeltaProcessor } from "../utils/delta"; + +export default new Command() + .command("i18n") + .description( + "DEPRECATED: Run localization pipeline (prefer `run` command instead)", + ) + .helpOption("-h, --help", "Show help") + .option( + "--locale <locale>", + "Limit processing to the listed target locale codes from i18n.json. Repeat the flag to include multiple locales. Defaults to all configured target locales", + (val: string, prev: string[]) => (prev ? [...prev, val] : [val]), + ) + .option( + "--bucket <bucket>", + "Limit processing to specific bucket types defined in i18n.json (e.g., json, yaml, android). Repeat the flag to include multiple bucket types. Defaults to all buckets", + (val: string, prev: string[]) => (prev ? [...prev, val] : [val]), + ) + .option( + "--key <key>", + "Limit processing to a single translation key by exact match. Filters all buckets and locales to process only this key, useful for testing or debugging specific translations. Example: auth.login.title", + (val: string) => encodeURIComponent(val), + ) + .option( + "--file [files...]", + "Filter processing to only buckets whose file paths contain these substrings. Example: 'components' to process only files in components directories", + ) + .option( + "--frozen", + "Validate translations are up-to-date without making changes - fails if source files, target files, or lockfile are out of sync. Ideal for CI/CD to ensure translation consistency before deployment", + ) + .option( + "--force", + "Force re-translation of all keys, bypassing change detection. Useful when you want to regenerate translations with updated AI models or translation settings", + ) + .option( + "--verbose", + "Print the translation data being processed as formatted JSON for each bucket and locale", + ) + .option( + "--interactive", + "Review and edit AI-generated translations interactively before applying changes to files", + ) + .option( + "--api-key <api-key>", + "Override API key from settings or environment variables", + ) + .option( + "--debug", + "Pause before processing localization so you can attach a debugger", + ) + .option( + "--strict", + "Stop immediately on first error instead of continuing to process remaining buckets and locales (fail-fast mode)", + ) + .action(async function (options) { + updateGitignore(); + + const ora = Ora(); + + // Show deprecation warning + console.log(); + ora.warn( + chalk.yellow( + " DEPRECATED: 'i18n' is deprecated. Please use 'run' instead. Docs: https://lingo.dev/cli/commands/run", + ), + ); + console.log(); + + let flags: ReturnType<typeof parseFlags>; + + try { + flags = parseFlags(options); + } catch (parseError: any) { + // Handle flag validation errors (like invalid locale codes) + await trackEvent(null, "cmd.i18n.error", { + errorType: "validation_error", + errorName: parseError.name || "ValidationError", + errorMessage: parseError.message || "Invalid command line options", + errorStack: parseError.stack, + fatal: true, + errorCount: 1, + stage: "flag_validation", + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + throw parseError; + } + + if (flags.debug) { + // wait for user input, use inquirer + const { debug } = await inquirer.prompt([ + { + type: "confirm", + name: "debug", + message: "Debug mode. Wait for user input before continuing.", + }, + ]); + } + + let hasErrors = false; + let email: string | null = null; + const errorDetails: ErrorDetail[] = []; + try { + ora.start("Loading configuration..."); + const i18nConfig = getConfigOrThrow(); + const settings = getSettings(flags.apiKey); + ora.succeed("Configuration loaded"); + + ora.start("Validating localization configuration..."); + validateParams(i18nConfig, flags); + ora.succeed("Localization configuration is valid"); + + ora.start("Connecting to Lingo.dev Localization Engine..."); + const isByokMode = !!i18nConfig?.provider; + + if (isByokMode) { + email = null; + ora.succeed("Using external provider (BYOK mode)"); + } else { + const auth = await validateAuth(settings); + email = auth.email; + ora.succeed(`Authenticated as ${auth.email}`); + } + + await trackEvent(email, "cmd.i18n.start", { + i18nConfig, + flags, + }); + + let buckets = getBuckets(i18nConfig!); + if (flags.bucket?.length) { + buckets = buckets.filter((bucket: any) => + flags.bucket!.includes(bucket.type), + ); + } + ora.succeed("Buckets retrieved"); + + if (flags.file?.length) { + buckets = buckets + .map((bucket: any) => { + const paths = bucket.paths.filter((path: any) => + flags.file!.find((file) => path.pathPattern?.includes(file)), + ); + return { ...bucket, paths }; + }) + .filter((bucket: any) => bucket.paths.length > 0); + if (buckets.length === 0) { + ora.fail( + "No buckets found. All buckets were filtered out by --file option.", + ); + throw new Error( + "No buckets found. All buckets were filtered out by --file option.", + ); + } else { + ora.info(`\x1b[36mProcessing only filtered buckets:\x1b[0m`); + buckets.map((bucket: any) => { + ora.info(` ${bucket.type}:`); + bucket.paths.forEach((path: any) => { + ora.info(` - ${path.pathPattern}`); + }); + }); + } + } + + const targetLocales = flags.locale?.length + ? flags.locale + : i18nConfig!.locale.targets; + + // Ensure the lockfile exists + ora.start("Setting up localization cache..."); + const checkLockfileProcessor = createDeltaProcessor(""); + const lockfileExists = await checkLockfileProcessor.checkIfLockExists(); + if (!lockfileExists) { + ora.start("Creating i18n.lock..."); + for (const bucket of buckets) { + for (const bucketPath of bucket.paths) { + const sourceLocale = resolveOverriddenLocale( + i18nConfig!.locale.source, + bucketPath.delimiter, + ); + const bucketLoader = createBucketLoader( + bucket.type, + bucketPath.pathPattern, + { + defaultLocale: sourceLocale, + injectLocale: bucket.injectLocale, + formatter: i18nConfig!.formatter, + }, + bucket.lockedKeys, + bucket.lockedPatterns, + bucket.ignoredKeys, + ); + bucketLoader.setDefaultLocale(sourceLocale); + await bucketLoader.init(); + + const sourceData = await bucketLoader.pull( + i18nConfig!.locale.source, + ); + + const deltaProcessor = createDeltaProcessor(bucketPath.pathPattern); + const checksums = await deltaProcessor.createChecksums(sourceData); + await deltaProcessor.saveChecksums(checksums); + } + } + ora.succeed("Localization cache initialized"); + } else { + ora.succeed("Localization cache loaded"); + } + + if (flags.frozen) { + ora.start("Checking for lockfile updates..."); + let requiresUpdate: string | null = null; + bucketLoop: for (const bucket of buckets) { + for (const bucketPath of bucket.paths) { + const sourceLocale = resolveOverriddenLocale( + i18nConfig!.locale.source, + bucketPath.delimiter, + ); + + const bucketLoader = createBucketLoader( + bucket.type, + bucketPath.pathPattern, + { + defaultLocale: sourceLocale, + returnUnlocalizedKeys: true, + injectLocale: bucket.injectLocale, + }, + bucket.lockedKeys, + bucket.lockedPatterns, + bucket.ignoredKeys, + ); + bucketLoader.setDefaultLocale(sourceLocale); + await bucketLoader.init(); + + const { unlocalizable: sourceUnlocalizable, ...sourceData } = + await bucketLoader.pull(i18nConfig!.locale.source); + const deltaProcessor = createDeltaProcessor(bucketPath.pathPattern); + const sourceChecksums = + await deltaProcessor.createChecksums(sourceData); + const savedChecksums = await deltaProcessor.loadChecksums(); + + // Get updated data by comparing current checksums with saved checksums + const updatedSourceData = _.pickBy( + sourceData, + (value, key) => sourceChecksums[key] !== savedChecksums[key], + ); + + // translation was updated in the source file + if (Object.keys(updatedSourceData).length > 0) { + requiresUpdate = "updated"; + break bucketLoop; + } + + for (const _targetLocale of targetLocales) { + const targetLocale = resolveOverriddenLocale( + _targetLocale, + bucketPath.delimiter, + ); + const { unlocalizable: targetUnlocalizable, ...targetData } = + await bucketLoader.pull(targetLocale); + + const missingKeys = _.difference( + Object.keys(sourceData), + Object.keys(targetData), + ); + const extraKeys = _.difference( + Object.keys(targetData), + Object.keys(sourceData), + ); + const unlocalizableDataDiff = !_.isEqual( + sourceUnlocalizable, + targetUnlocalizable, + ); + + // translation is missing in the target file + if (missingKeys.length > 0) { + requiresUpdate = "missing"; + break bucketLoop; + } + + // target file has extra translations + if (extraKeys.length > 0) { + requiresUpdate = "extra"; + break bucketLoop; + } + + // unlocalizable keys do not match + if (unlocalizableDataDiff) { + requiresUpdate = "unlocalizable"; + break bucketLoop; + } + } + } + } + + if (requiresUpdate) { + const message = { + updated: "Source file has been updated.", + missing: "Target file is missing translations.", + extra: + "Target file has extra translations not present in the source file.", + unlocalizable: + "Unlocalizable data (such as booleans, dates, URLs, etc.) do not match.", + }[requiresUpdate]; + ora.fail( + `Localization data has changed; please update i18n.lock or run without --frozen.`, + ); + ora.fail(` Details: ${message}`); + throw new Error( + `Localization data has changed; please update i18n.lock or run without --frozen. Details: ${message}`, + ); + } else { + ora.succeed("No lockfile updates required."); + } + } + + // Process each bucket + for (const bucket of buckets) { + try { + console.log(); + ora.info(`Processing bucket: ${bucket.type}`); + for (const bucketPath of bucket.paths) { + const bucketOra = Ora({ indent: 2 }).info( + `Processing path: ${bucketPath.pathPattern}`, + ); + + const sourceLocale = resolveOverriddenLocale( + i18nConfig!.locale.source, + bucketPath.delimiter, + ); + + const bucketLoader = createBucketLoader( + bucket.type, + bucketPath.pathPattern, + { + defaultLocale: sourceLocale, + injectLocale: bucket.injectLocale, + formatter: i18nConfig!.formatter, + }, + bucket.lockedKeys, + bucket.lockedPatterns, + bucket.ignoredKeys, + ); + bucketLoader.setDefaultLocale(sourceLocale); + await bucketLoader.init(); + let sourceData = await bucketLoader.pull(sourceLocale); + + for (const _targetLocale of targetLocales) { + const targetLocale = resolveOverriddenLocale( + _targetLocale, + bucketPath.delimiter, + ); + try { + bucketOra.start( + `[${sourceLocale} -> ${targetLocale}] (0%) Localization in progress...`, + ); + + sourceData = await bucketLoader.pull(sourceLocale); + + const targetData = await bucketLoader.pull(targetLocale); + const deltaProcessor = createDeltaProcessor( + bucketPath.pathPattern, + ); + const checksums = await deltaProcessor.loadChecksums(); + const delta = await deltaProcessor.calculateDelta({ + sourceData, + targetData, + checksums, + }); + let processableData = _.chain(sourceData) + .entries() + .filter( + ([key, value]) => + delta.added.includes(key) || + delta.updated.includes(key) || + !!flags.force, + ) + .fromPairs() + .value(); + + if (flags.key) { + processableData = _.pickBy( + processableData, + (_, key) => key === flags.key, + ); + } + if (flags.verbose) { + bucketOra.info(JSON.stringify(processableData, null, 2)); + } + + bucketOra.start( + `[${sourceLocale} -> ${targetLocale}] [${ + Object.keys(processableData).length + } entries] (0%) AI localization in progress...`, + ); + let processPayload = createProcessor(i18nConfig!.provider, { + apiKey: settings.auth.apiKey, + apiUrl: settings.auth.apiUrl, + }); + processPayload = withExponentialBackoff( + processPayload, + 3, + 1000, + ); + + const processedTargetData = await processPayload( + { + sourceLocale, + sourceData, + processableData, + targetLocale, + targetData, + }, + (progress, sourceChunk, processedChunk) => { + bucketOra.text = `[${sourceLocale} -> ${targetLocale}] [${ + Object.keys(processableData).length + } entries] (${progress}%) AI localization in progress...`; + }, + ); + + if (flags.verbose) { + bucketOra.info(JSON.stringify(processedTargetData, null, 2)); + } + + let finalTargetData = _.merge( + {}, + sourceData, + targetData, + processedTargetData, + ); + + // rename keys + finalTargetData = _.chain(finalTargetData) + .entries() + .map(([key, value]) => { + const renaming = delta.renamed.find( + ([oldKey, newKey]) => oldKey === key, + ); + if (!renaming) { + return [key, value]; + } + return [renaming[1], value]; + }) + .fromPairs() + .value(); + + if (flags.interactive) { + bucketOra.stop(); + const reviewedData = await reviewChanges({ + pathPattern: bucketPath.pathPattern, + targetLocale, + currentData: targetData, + proposedData: finalTargetData, + sourceData, + force: flags.force!, + }); + + finalTargetData = reviewedData; + bucketOra.start( + `Applying changes to ${bucketPath} (${targetLocale})`, + ); + } + + const finalDiffSize = _.chain(finalTargetData) + .omitBy((value, key) => { + const targetValue = targetData[key]; + + // For objects (like plural variations), use deep equality + // For primitives (strings, numbers), use strict equality + if (typeof value === "object" && value !== null) { + return _.isEqual(value, targetValue); + } + return value === targetValue; + }) + .size() + .value(); + + // Push to bucket all the time as there might be changes to unlocalizable keys + await bucketLoader.push(targetLocale, finalTargetData); + + if (finalDiffSize > 0 || flags.force) { + bucketOra.succeed( + `[${sourceLocale} -> ${targetLocale}] Localization completed`, + ); + } else { + bucketOra.succeed( + `[${sourceLocale} -> ${targetLocale}] Localization completed (no changes).`, + ); + } + } catch (_error: any) { + const error = new LocalizationError( + `[${sourceLocale} -> ${targetLocale}] Localization failed: ${_error.message}`, + { + bucket: bucket.type, + sourceLocale, + targetLocale, + pathPattern: bucketPath.pathPattern, + }, + ); + errorDetails.push({ + type: "locale_error", + bucket: bucket.type, + locale: `${sourceLocale} -> ${targetLocale}`, + pathPattern: bucketPath.pathPattern, + message: _error.message, + stack: _error.stack, + }); + if (flags.strict) { + throw error; + } else { + bucketOra.fail(error.message); + hasErrors = true; + } + } + } + + const deltaProcessor = createDeltaProcessor(bucketPath.pathPattern); + const checksums = await deltaProcessor.createChecksums(sourceData); + if (!flags.locale?.length) { + await deltaProcessor.saveChecksums(checksums); + } + } + } catch (_error: any) { + const error = new BucketProcessingError( + `Failed to process bucket ${bucket.type}: ${_error.message}`, + bucket.type, + ); + errorDetails.push({ + type: "bucket_error", + bucket: bucket.type, + message: _error.message, + stack: _error.stack, + }); + if (flags.strict) { + throw error; + } else { + ora.fail(error.message); + hasErrors = true; + } + } + } + console.log(); + if (!hasErrors) { + ora.succeed("Localization completed."); + await trackEvent(email, "cmd.i18n.success", { + i18nConfig: { + sourceLocale: i18nConfig!.locale.source, + targetLocales: i18nConfig!.locale.targets, + bucketTypes: Object.keys(i18nConfig!.buckets), + }, + flags, + bucketCount: buckets.length, + localeCount: targetLocales.length, + processedSuccessfully: true, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + } else { + ora.warn("Localization completed with errors."); + await trackEvent(email, "cmd.i18n.error", { + flags, + ...aggregateErrorAnalytics( + errorDetails, + buckets, + targetLocales, + i18nConfig!, + ), + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } catch (error: any) { + ora.fail(error.message); + + // Use robust error type detection + const errorType = getCLIErrorType(error); + + // Extract additional context from typed errors + let errorContext: any = {}; + if (isLocalizationError(error)) { + errorContext = { + bucket: error.bucket, + sourceLocale: error.sourceLocale, + targetLocale: error.targetLocale, + pathPattern: error.pathPattern, + }; + } else if (isBucketProcessingError(error)) { + errorContext = { + bucket: error.bucket, + }; + } + + await trackEvent(email, "cmd.i18n.error", { + flags, + errorType, + errorName: error.name || "Error", + errorMessage: error.message, + errorStack: error.stack, + errorContext, + fatal: true, + errorCount: errorDetails.length + 1, + previousErrors: createPreviousErrorContext(errorDetails), + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + }); + +function parseFlags(options: any) { + return Z.object({ + apiKey: Z.string().optional(), + locale: Z.array(localeCodeSchema).optional(), + bucket: Z.array(bucketTypeSchema).optional(), + force: Z.boolean().optional(), + frozen: Z.boolean().optional(), + verbose: Z.boolean().optional(), + strict: Z.boolean().optional(), + key: Z.string().optional(), + file: Z.array(Z.string()).optional(), + interactive: Z.boolean().prefault(false), + debug: Z.boolean().prefault(false), + }).parse(options); +} + +// Export validateAuth for use in other commands +export async function validateAuth(settings: ReturnType<typeof getSettings>) { + if (!settings.auth.apiKey) { + throw new AuthenticationError({ + message: + "Not authenticated. Please run `lingo.dev login` to authenticate.", + docUrl: "authError", + }); + } + + const authenticator = createAuthenticator({ + apiKey: settings.auth.apiKey, + apiUrl: settings.auth.apiUrl, + }); + const user = await authenticator.whoami(); + if (!user) { + throw new AuthenticationError({ + message: "Invalid API key. Please run `lingo.dev login` to authenticate.", + docUrl: "authError", + }); + } + + return user; +} + +function validateParams( + i18nConfig: I18nConfig, + flags: ReturnType<typeof parseFlags>, +) { + if (!i18nConfig.buckets || !Object.keys(i18nConfig.buckets).length) { + throw new ConfigError({ + message: + "No buckets found in i18n.json. Please add at least one bucket containing i18n content.", + docUrl: "bucketNotFound", + }); + } else if ( + flags.locale?.some((locale) => !i18nConfig.locale.targets.includes(locale)) + ) { + throw new ValidationError({ + message: `One or more specified locales do not exist in i18n.json locale.targets. Please add them to the list and try again.`, + docUrl: "localeTargetNotFound", + }); + } else if ( + flags.bucket?.some( + (bucket) => + !i18nConfig.buckets[bucket as keyof typeof i18nConfig.buckets], + ) + ) { + throw new ValidationError({ + message: `One or more specified buckets do not exist in i18n.json. Please add them to the list and try again.`, + docUrl: "bucketNotFound", + }); + } +} + +async function reviewChanges(args: { + pathPattern: string; + targetLocale: string; + currentData: Record<string, any>; + proposedData: Record<string, any>; + sourceData: Record<string, any>; + force: boolean; +}): Promise<Record<string, any>> { + const currentStr = JSON.stringify(args.currentData, null, 2); + const proposedStr = JSON.stringify(args.proposedData, null, 2); + + // Early return if no changes + if (currentStr === proposedStr && !args.force) { + console.log( + `\n${chalk.blue(args.pathPattern)} (${chalk.yellow( + args.targetLocale, + )}): ${chalk.gray("No changes to review")}`, + ); + return args.proposedData; + } + + const patch = createTwoFilesPatch( + `${args.pathPattern} (current)`, + `${args.pathPattern} (proposed)`, + currentStr, + proposedStr, + undefined, + undefined, + { context: 3 }, + ); + + // Color the diff output + const coloredDiff = patch + .split("\n") + .map((line) => { + if (line.startsWith("+")) return chalk.green(line); + if (line.startsWith("-")) return chalk.red(line); + if (line.startsWith("@")) return chalk.cyan(line); + return line; + }) + .join("\n"); + + console.log( + `\nReviewing changes for ${chalk.blue(args.pathPattern)} (${chalk.yellow( + args.targetLocale, + )}):`, + ); + console.log(coloredDiff); + + const { action } = await inquirer.prompt([ + { + type: "list", + name: "action", + message: "Choose action:", + choices: [ + { name: "Approve changes", value: "approve" }, + { name: "Skip changes", value: "skip" }, + { name: "Edit individually", value: "edit" }, + ], + default: "approve", + }, + ]); + + if (action === "approve") { + return args.proposedData; + } + + if (action === "skip") { + return args.currentData; + } + + // If edit was chosen, prompt for each changed value + const customData = { ...args.currentData }; + const changes = _.reduce( + args.proposedData, + (result: string[], value: string, key: string) => { + if (args.currentData[key] !== value) { + result.push(key); + } + return result; + }, + [], + ); + + for (const key of changes) { + console.log(`\nEditing value for: ${chalk.cyan(key)}`); + console.log(chalk.gray("Source text:"), chalk.blue(args.sourceData[key])); + console.log( + chalk.gray("Current value:"), + chalk.red(args.currentData[key] || "(empty)"), + ); + console.log( + chalk.gray("Suggested value:"), + chalk.green(args.proposedData[key]), + ); + console.log( + chalk.gray( + "\nYour editor will open. Edit the text and save to continue.", + ), + ); + console.log(chalk.gray("------------")); + + try { + // Prepare the editor content with a header comment and the suggested value + const editorContent = [ + "# Edit the translation below.", + "# Lines starting with # will be ignored.", + "# Save and exit the editor to continue.", + "#", + `# Source text (${chalk.blue("English")}):`, + `# ${args.sourceData[key]}`, + "#", + `# Current value (${chalk.red(args.targetLocale)}):`, + `# ${args.currentData[key] || "(empty)"}`, + "#", + args.proposedData[key], + ].join("\n"); + + const result = externalEditor.edit(editorContent); + + // Clean up the result by removing comments and trimming + const customValue = result + .split("\n") + .filter((line) => !line.startsWith("#")) + .join("\n") + .trim(); + + if (customValue) { + customData[key] = customValue; + } else { + console.log( + chalk.yellow("Empty value provided, keeping the current value."), + ); + customData[key] = args.currentData[key] || args.proposedData[key]; + } + } catch (error) { + console.log( + chalk.red("Error while editing, keeping the suggested value."), + ); + customData[key] = args.proposedData[key]; + } + } + + return customData; +} diff --git a/packages/cli/src/cli/cmd/init.ts b/packages/cli/src/cli/cmd/init.ts new file mode 100644 index 000000000..69c9d9d1c --- /dev/null +++ b/packages/cli/src/cli/cmd/init.ts @@ -0,0 +1,263 @@ +import { InteractiveCommand, InteractiveOption } from "interactive-commander"; +import Ora from "ora"; +import { getConfig, saveConfig } from "../utils/config"; +import { + defaultConfig, + LocaleCode, + resolveLocaleCode, + bucketTypes, +} from "@lingo.dev/_spec"; +import fs from "fs"; +import path from "path"; +import _ from "lodash"; +import { checkbox, confirm, input } from "@inquirer/prompts"; +import { login } from "./login"; +import { getSettings, saveSettings } from "../utils/settings"; +import { createAuthenticator } from "../utils/auth"; +import findLocaleFiles from "../utils/find-locale-paths"; +import { ensurePatterns } from "../utils/ensure-patterns"; +import updateGitignore from "../utils/update-gitignore"; +import initCICD from "../utils/init-ci-cd"; +import open from "open"; +import cursorInitCmd from "./init/cursor"; + +const openUrl = (path: string) => { + const settings = getSettings(undefined); + open(`${settings.auth.webUrl}${path}`, { wait: false }); +}; + +const throwHelpError = (option: string, value: string) => { + if (value === "help") { + openUrl("/go/call"); + } + throw new Error( + `Invalid ${option}: ${value}\n\nDo you need support for ${value} ${option}? Type "help" and we will.`, + ); +}; + +export default new InteractiveCommand() + .command("init") + .description("Create i18n.json configuration file for a new project") + .helpOption("-h, --help", "Show help") + .addOption( + new InteractiveOption( + "-f --force", + "Overwrite existing Lingo.dev configuration instead of aborting initialization (destructive operation)", + ) + .prompt(undefined) + .default(false), + ) + .addOption( + new InteractiveOption( + "-s --source <locale>", + "Primary language of your application that content will be translated from. Defaults to 'en'", + ) + .argParser((value) => { + try { + resolveLocaleCode(value as LocaleCode); + } catch (e) { + throwHelpError("locale", value); + } + return value; + }) + .default("en"), + ) + .addOption( + new InteractiveOption( + "-t --targets <locale...>", + "Target languages to translate to. Accepts locale codes like 'es', 'fr', 'de-AT' separated by commas or spaces. Defaults to 'es'", + ) + .argParser((value) => { + const values = ( + value.includes(",") ? value.split(",") : value.split(" ") + ) as LocaleCode[]; + values.forEach((value) => { + try { + resolveLocaleCode(value); + } catch (e) { + throwHelpError("locale", value); + } + }); + return values; + }) + .default("es"), + ) + .addOption( + new InteractiveOption( + "-b, --bucket <type>", + "File format for your translation files. Must match a supported type such as json, yaml, or android", + ) + .argParser((value) => { + if (!bucketTypes.includes(value as (typeof bucketTypes)[number])) { + throwHelpError("bucket format", value); + } + return value; + }) + .default("json"), + ) + .addOption( + new InteractiveOption( + "-p, --paths [path...]", + "File paths containing translations when using --no-interactive mode. Specify paths with [locale] placeholder, separated by commas or spaces", + ) + .argParser((value) => { + if (!value || value.length === 0) return []; + const values = value.includes(",") + ? value.split(",") + : value.split(" "); + + for (const p of values) { + try { + const dirPath = path.dirname(p); + const stats = fs.statSync(dirPath); + if (!stats.isDirectory()) { + throw new Error(`${dirPath} is not a directory`); + } + } catch (err) { + throw new Error(`Invalid path: ${p}`); + } + } + return values; + + }) + .prompt(undefined) // make non-interactive + .default([]), + ) + .action(async (options) => { + const settings = getSettings(undefined); + const isInteractive = options.interactive; + + const spinner = Ora().start("Initializing Lingo.dev project"); + + let existingConfig = await getConfig(false); + if (existingConfig && !options.force) { + spinner.fail("Lingo.dev project already initialized"); + return process.exit(1); + } + + const newConfig = _.cloneDeep(defaultConfig); + + newConfig.locale.source = options.source; + newConfig.locale.targets = options.targets; + + if (!isInteractive) { + newConfig.buckets = { + [options.bucket]: { + include: options.paths || [], + }, + }; + } else { + let selectedPatterns: string[] = []; + const localeFiles = findLocaleFiles(options.bucket); + + if (!localeFiles) { + spinner.warn( + `Bucket type "${options.bucket}" does not supported automatic initialization. Add paths to "i18n.json" manually.`, + ); + newConfig.buckets = { + [options.bucket]: { + include: options.paths || [], + }, + }; + } else { + const { patterns, defaultPatterns } = localeFiles; + + if (patterns.length > 0) { + spinner.succeed("Found existing locale files:"); + + selectedPatterns = await checkbox({ + message: "Select the paths to use", + choices: patterns.map((value) => ({ + value, + })), + }); + } else { + spinner.succeed("No existing locale files found."); + } + + if (selectedPatterns.length === 0) { + const useDefault = await confirm({ + message: `Use (and create) default path ${defaultPatterns.join( + ", ", + )}?`, + }); + if (useDefault) { + ensurePatterns(defaultPatterns, options.source); + selectedPatterns = defaultPatterns; + } + } + + if (selectedPatterns.length === 0) { + const customPaths = await input({ + message: "Enter paths to use", + }); + selectedPatterns = customPaths.includes(",") + ? customPaths.split(",") + : customPaths.split(" "); + } + + newConfig.buckets = { + [options.bucket]: { + include: selectedPatterns || [], + }, + }; + } + } + + await saveConfig(newConfig); + + spinner.succeed("Lingo.dev project initialized"); + + if (isInteractive) { + await initCICD(spinner); + + const openDocs = await confirm({ + message: "Would you like to see our docs?", + }); + if (openDocs) { + openUrl("/go/docs"); + } + } + + const authenticator = createAuthenticator({ + apiKey: settings.auth.apiKey, + apiUrl: settings.auth.apiUrl, + }); + const auth = await authenticator.whoami(); + if (!auth) { + if (isInteractive) { + const doAuth = await confirm({ + message: "It looks like you are not logged into the CLI. Login now?", + }); + if (doAuth) { + const apiKey = await login(settings.auth.webUrl); + settings.auth.apiKey = apiKey; + await saveSettings(settings); + + const newAuthenticator = createAuthenticator({ + apiKey: settings.auth.apiKey, + apiUrl: settings.auth.apiUrl, + }); + const auth = await newAuthenticator.whoami(); + if (auth) { + Ora().succeed(`Authenticated as ${auth?.email}`); + } else { + Ora().fail("Authentication failed."); + } + } + } else { + Ora().warn( + "You are not logged in. Run `npx lingo.dev@latest login` to login.", + ); + } + } else { + Ora().succeed(`Authenticated as ${auth.email}`); + } + + updateGitignore(); + + if (!isInteractive) { + Ora().info("Please see https://lingo.dev/cli"); + } + }) + .addCommand(cursorInitCmd); diff --git a/packages/cli/src/cli/cmd/init/cursor.ts b/packages/cli/src/cli/cmd/init/cursor.ts new file mode 100644 index 000000000..5dfd7580e --- /dev/null +++ b/packages/cli/src/cli/cmd/init/cursor.ts @@ -0,0 +1,58 @@ +import { InteractiveCommand, InteractiveOption } from "interactive-commander"; +import Ora from "ora"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { confirm } from "@inquirer/prompts"; + +// Get the directory of this file +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +// Access agents.md from package root (works in both dev and production) +// Resolve from current file location: try both paths to handle dev and bundled environments +const AGENTS_MD = fs.existsSync(path.resolve(__dirname, "../agents.md")) + ? path.resolve(__dirname, "../agents.md") + : path.resolve(__dirname, "../../../../agents.md"); +// Create .cursorrules in user's current working directory +const CURSORRULES = path.resolve(process.cwd(), ".cursorrules"); + +export default new InteractiveCommand() + .command("cursor") + .description("Initialize .cursorrules with i18n-specific instructions for Cursor AI.") + .addOption( + new InteractiveOption("-f, --force", "Overwrite .cursorrules without prompt.") + .default(false) + ) + .action(async (options) => { + const spinner = Ora(); + // Read agents.md + let template: string; + try { + template = fs.readFileSync(AGENTS_MD, "utf-8"); + } catch (err) { + spinner.fail("Template file agents.md not found. Please reinstall the package."); + return process.exit(1); + } + // Check for existing .cursorrules + const exists = fs.existsSync(CURSORRULES); + let shouldWrite; + if (exists && !options.force) { + shouldWrite = await confirm({ + message: ".cursorrules already exists. Overwrite?", + }); + if (!shouldWrite) { + spinner.info("Skipped: .cursorrules left unchanged."); + return; + } + } + try { + fs.writeFileSync(CURSORRULES, template); + spinner.succeed("Created .cursorrules"); + spinner.info( + ".cursorrules has been created with i18n-specific instructions for Cursor AI.", + ); + } catch (err) { + spinner.fail(`Failed to write .cursorrules: ${err}`); + process.exit(1); + } + }); diff --git a/packages/cli/src/cli/cmd/lockfile.ts b/packages/cli/src/cli/cmd/lockfile.ts new file mode 100644 index 000000000..23fcf1bc5 --- /dev/null +++ b/packages/cli/src/cli/cmd/lockfile.ts @@ -0,0 +1,65 @@ +import { Command } from "interactive-commander"; +import Z from "zod"; +import Ora from "ora"; +import { createLockfileHelper } from "../utils/lockfile"; +import { bucketTypeSchema, resolveOverriddenLocale } from "@lingo.dev/_spec"; +import { getConfig } from "../utils/config"; +import createBucketLoader from "../loaders"; +import { getBuckets } from "../utils/buckets"; + +export default new Command() + .command("lockfile") + .description( + "Generate or refresh i18n.lock based on the current source locale content", + ) + .helpOption("-h, --help", "Show help") + .option( + "-f, --force", + "Overwrite existing lockfile to reset translation tracking", + ) + .action(async (options) => { + const flags = flagsSchema.parse(options); + const ora = Ora(); + + const lockfileHelper = createLockfileHelper(); + if (lockfileHelper.isLockfileExists() && !flags.force) { + ora.warn( + `Lockfile won't be created because it already exists. Use --force to overwrite.`, + ); + } else { + const i18nConfig = getConfig(); + const buckets = getBuckets(i18nConfig!); + + for (const bucket of buckets) { + for (const bucketConfig of bucket.paths) { + const sourceLocale = resolveOverriddenLocale( + i18nConfig!.locale.source, + bucketConfig.delimiter, + ); + const bucketLoader = createBucketLoader( + bucket.type, + bucketConfig.pathPattern, + { + defaultLocale: sourceLocale, + formatter: i18nConfig!.formatter, + }, + bucket.lockedKeys, + bucket.lockedPatterns, + bucket.ignoredKeys, + ); + bucketLoader.setDefaultLocale(sourceLocale); + + const sourceData = await bucketLoader.pull(sourceLocale); + lockfileHelper.registerSourceData( + bucketConfig.pathPattern, + sourceData, + ); + } + } + ora.succeed("Lockfile created"); + } + }); + +const flagsSchema = Z.object({ + force: Z.boolean().prefault(false), +}); diff --git a/packages/cli/src/cli/cmd/login.ts b/packages/cli/src/cli/cmd/login.ts new file mode 100644 index 000000000..beffb7b98 --- /dev/null +++ b/packages/cli/src/cli/cmd/login.ts @@ -0,0 +1,84 @@ +import { Command } from "interactive-commander"; +import Ora from "ora"; +import express from "express"; +import cors from "cors"; +import open from "open"; +import readline from "readline/promises"; +import { getSettings, saveSettings } from "../utils/settings"; +import { + renderClear, + renderSpacer, + renderBanner, + renderHero, +} from "../utils/ui"; + +export default new Command() + .command("login") + .description( + "Open browser to authenticate with lingo.dev and save your API key", + ) + .helpOption("-h, --help", "Show help") + .action(async () => { + try { + await renderClear(); + await renderSpacer(); + await renderBanner(); + await renderHero(); + await renderSpacer(); + + const settings = await getSettings(undefined); + const apiKey = await login(settings.auth.webUrl); + settings.auth.apiKey = apiKey; + await saveSettings(settings); + Ora().succeed("Successfully logged in"); + } catch (error: any) { + Ora().fail(error.message); + process.exit(1); + } + }); + +export async function login(webAppUrl: string) { + await readline + .createInterface({ + input: process.stdin, + output: process.stdout, + }) + .question( + ` +Press Enter to open the browser for authentication. + +--- + +Having issues? Put LINGODOTDEV_API_KEY in your .env file instead. + `.trim() + "\n", + ); + + const spinner = Ora().start("Waiting for the API key"); + const apiKey = await waitForApiKey(async (port) => { + await open(`${webAppUrl}/app/cli?port=${port}`, { wait: false }); + }); + spinner.succeed("API key received"); + + return apiKey; +} + +async function waitForApiKey(cb: (port: string) => void): Promise<string> { + const app = express(); + app.use(express.json()); + app.use(cors()); + + return new Promise((resolve) => { + const server = app.listen(0, async () => { + const port = (server.address() as any).port; + cb(port.toString()); + }); + + app.post("/", (req, res) => { + const apiKey = req.body.apiKey; + res.end(); + server.close(() => { + resolve(apiKey); + }); + }); + }); +} diff --git a/packages/cli/src/cli/cmd/logout.ts b/packages/cli/src/cli/cmd/logout.ts new file mode 100644 index 000000000..eb7686fa8 --- /dev/null +++ b/packages/cli/src/cli/cmd/logout.ts @@ -0,0 +1,31 @@ +import { Command } from "interactive-commander"; +import Ora from "ora"; +import { getSettings, saveSettings } from "../utils/settings"; +import { + renderClear, + renderSpacer, + renderBanner, + renderHero, +} from "../utils/ui"; + +export default new Command() + .command("logout") + .description("Log out by removing saved authentication credentials") + .helpOption("-h, --help", "Show help") + .action(async () => { + try { + await renderClear(); + await renderSpacer(); + await renderBanner(); + await renderHero(); + await renderSpacer(); + + const settings = await getSettings(undefined); + settings.auth.apiKey = ""; + await saveSettings(settings); + Ora().succeed("Successfully logged out"); + } catch (error: any) { + Ora().fail(error.message); + process.exit(1); + } + }); diff --git a/packages/cli/src/cli/cmd/may-the-fourth.ts b/packages/cli/src/cli/cmd/may-the-fourth.ts new file mode 100644 index 000000000..bf96ede87 --- /dev/null +++ b/packages/cli/src/cli/cmd/may-the-fourth.ts @@ -0,0 +1,96 @@ +import { Command } from "interactive-commander"; +import * as cp from "node:child_process"; +import figlet from "figlet"; +import chalk from "chalk"; +import { vice } from "gradient-string"; +import { setTimeout } from "node:timers/promises"; + +export const colors = { + orange: "#ff6600", + green: "#6ae300", + blue: "#0090ff", + yellow: "#ffcc00", + grey: "#808080", + red: "#ff0000", +}; + +export default new Command() + .command("may-the-fourth") + .description("May the Fourth be with you") + .helpOption("-h, --help", "Show help") + .action(async () => { + await renderClear(); + await renderBanner(); + await renderSpacer(); + + console.log(chalk.hex(colors.yellow)("Loading the Star Wars movie...")); + await renderSpacer(); + + await new Promise<void>((resolve, reject) => { + const ssh = cp.spawn("ssh", ["starwarstel.net"], { + stdio: "inherit", + }); + + ssh.on("close", (code) => { + if (code !== 0) { + console.error(`SSH process exited with code ${code}`); + // Optionally reject the promise if the exit code is non-zero + // reject(new Error(`SSH process exited with code ${code}`)); + } + resolve(); // Resolve the promise when SSH closes + }); + + ssh.on("error", (err) => { + console.error("Failed to start SSH process:", err); + reject(err); // Reject the promise on error + }); + }); + + // This code now runs after the SSH process has finished + await renderSpacer(); + console.log( + `${chalk.hex(colors.green)("We hope you enjoyed it! :)")} ${chalk.hex( + colors.blue, + )("May the Fourth be with you! 🚀")}`, + ); + await renderSpacer(); + console.log(chalk.dim(`---`)); + await renderSpacer(); + await renderHero(); + }); + +async function renderClear() { + console.log("\x1Bc"); +} + +async function renderSpacer() { + console.log(" "); +} + +async function renderBanner() { + console.log( + vice( + figlet.textSync("LINGO.DEV", { + font: "ANSI Shadow", + horizontalLayout: "default", + verticalLayout: "default", + }), + ), + ); +} + +async function renderHero() { + console.log( + `⚡️ ${chalk.hex(colors.green)( + "Lingo.dev", + )} - open-source, AI-powered i18n CLI for web & mobile localization.`, + ); + console.log(" "); + console.log(chalk.hex(colors.blue)("📚 Docs: https://lingo.dev/go/docs")); + console.log( + chalk.hex(colors.blue)("⭐ Star the repo: https://lingo.dev/go/gh"), + ); + console.log( + chalk.hex(colors.blue)("🎮 Join Discord: https://lingo.dev/go/discord"), + ); +} diff --git a/packages/cli/src/cli/cmd/mcp.ts b/packages/cli/src/cli/cmd/mcp.ts new file mode 100644 index 000000000..f7d216983 --- /dev/null +++ b/packages/cli/src/cli/cmd/mcp.ts @@ -0,0 +1,67 @@ +import { Command } from "interactive-commander"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import Z from "zod"; +import { ReplexicaEngine } from "@lingo.dev/_sdk"; +import { getSettings } from "../utils/settings"; +import { createAuthenticator } from "../utils/auth"; + +export default new Command() + .command("mcp") + .description( + "Start a Model Context Protocol (MCP) server for AI assistant integration", + ) + .helpOption("-h, --help", "Show help") + .action(async (_, program) => { + const apiKey = program.args[0]; + const settings = getSettings(apiKey); + + if (!settings.auth.apiKey) { + console.error("No API key provided"); + return; + } + + const authenticator = createAuthenticator({ + apiUrl: settings.auth.apiUrl, + apiKey: settings.auth.apiKey!, + }); + const auth = await authenticator.whoami(); + + if (!auth) { + console.error("Not authenticated"); + return; + } else { + console.log(`Authenticated as ${auth.email}`); + } + + const replexicaEngine = new ReplexicaEngine({ + apiKey: settings.auth.apiKey, + apiUrl: settings.auth.apiUrl, + }); + + const server = new McpServer({ + name: "Lingo.dev", + version: "1.0.0", + }); + + server.tool( + "translate", + "Detect language and translate text with Lingo.dev.", + { + text: Z.string() as any, + targetLocale: Z.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/) as any, + }, + async ({ text, targetLocale }) => { + const sourceLocale = await replexicaEngine.recognizeLocale(text); + const data = await replexicaEngine.localizeText(text, { + sourceLocale, + targetLocale, + }); + return { content: [{ type: "text", text: data }] }; + }, + ); + + const transport = new StdioServerTransport(); + await server.connect(transport); + console.log("Lingo.dev MCP Server running on stdio"); + }); diff --git a/packages/cli/src/cli/cmd/purge.ts b/packages/cli/src/cli/cmd/purge.ts new file mode 100644 index 000000000..940a71267 --- /dev/null +++ b/packages/cli/src/cli/cmd/purge.ts @@ -0,0 +1,193 @@ +import { Command } from "interactive-commander"; +import _ from "lodash"; +import Ora from "ora"; +import { getConfig } from "../utils/config"; +import { getBuckets } from "../utils/buckets"; +import { resolveOverriddenLocale } from "@lingo.dev/_spec"; +import createBucketLoader from "../loaders"; +import { minimatch } from "minimatch"; +import { confirm } from "@inquirer/prompts"; + +interface PurgeOptions { + bucket?: string[]; + file?: string[]; + key?: string; + locale?: string[]; + yesReally?: boolean; +} + +export default new Command() + .command("purge") + .description( + "WARNING: Permanently delete translation entries from bucket path patterns defined in i18n.json. This is a destructive operation that cannot be undone. Without any filters, ALL managed keys will be removed from EVERY target locale.", + ) + .helpOption("-h, --help", "Show help") + .option( + "--bucket <bucket>", + "Limit the purge to specific bucket types defined under `buckets` in i18n.json. Repeat the flag to include multiple bucket types. Defaults to all buckets", + (val: string, prev: string[]) => (prev ? [...prev, val] : [val]), + ) + .option( + "--file [files...]", + "Filter which file paths to purge by matching against path patterns. Only paths containing any of these values will be processed. Examples: --file messages.json --file admin/", + ) + .option( + "--key <key>", + "Filter which keys to delete using prefix matching on dot-separated key paths. Example: 'auth.login' matches all keys starting with auth.login. Omit this option to delete ALL keys. Keys marked as locked or ignored in i18n.json are automatically skipped", + (val: string) => encodeURIComponent(val), + ) + .option( + "--locale <locale>", + "Limit purging to specific target locale codes from i18n.json. Repeat the flag to include multiple locales. Defaults to all configured target locales. Warning: Including the source locale will delete content from it as well.", + (val: string, prev: string[]) => (prev ? [...prev, val] : [val]), + ) + .option( + "--yes-really", + "Bypass safety confirmations for destructive operations. Use with extreme caution - this will delete translation keys without asking for confirmation. Intended for automated scripts and CI environments only.", + ) + .action(async function (options: PurgeOptions) { + const ora = Ora(); + try { + ora.start("Loading configuration..."); + const i18nConfig = getConfig(); + if (!i18nConfig) { + throw new Error("i18n.json not found. Please run `lingo.dev init`."); + } + ora.succeed("Configuration loaded"); + + let buckets = getBuckets(i18nConfig); + if (options.bucket && options.bucket.length) { + buckets = buckets.filter((bucket) => + options.bucket!.includes(bucket.type), + ); + } + if (options.file && options.file.length) { + buckets = buckets + .map((bucket) => { + const paths = bucket.paths.filter((bucketPath) => + options.file?.some((f) => bucketPath.pathPattern.includes(f)), + ); + return { ...bucket, paths }; + }) + .filter((bucket) => bucket.paths.length > 0); + if (buckets.length === 0) { + ora.fail("All files were filtered out by --file option."); + process.exit(1); + } + } + const sourceLocale = i18nConfig.locale.source; + const targetLocales = + options.locale && options.locale.length + ? options.locale + : i18nConfig.locale.targets; + let removedAny = false; + for (const bucket of buckets) { + console.log(); + ora.info(`Processing bucket: ${bucket.type}`); + for (const bucketPath of bucket.paths) { + for (const _targetLocale of targetLocales) { + const targetLocale = resolveOverriddenLocale( + _targetLocale, + bucketPath.delimiter, + ); + const bucketOra = Ora({ indent: 2 }).start( + `Processing path: ${bucketPath.pathPattern} [${targetLocale}]`, + ); + try { + const bucketLoader = createBucketLoader( + bucket.type, + bucketPath.pathPattern, + { + defaultLocale: sourceLocale, + injectLocale: bucket.injectLocale, + formatter: i18nConfig!.formatter, + }, + bucket.lockedKeys, + bucket.lockedPatterns, + bucket.ignoredKeys, + ); + await bucketLoader.init(); + bucketLoader.setDefaultLocale(sourceLocale); + await bucketLoader.pull(sourceLocale); + let targetData = await bucketLoader.pull(targetLocale); + if (!targetData || Object.keys(targetData).length === 0) { + bucketOra.info( + `No translations found for ${bucketPath.pathPattern} [${targetLocale}]`, + ); + continue; + } + let newData = { ...targetData }; + let keysToRemove: string[] = []; + if (options.key) { + // minimatch for key patterns + keysToRemove = Object.keys(newData).filter((k) => + minimatch(k, options.key!), + ); + } else { + // No key specified: remove all keys + keysToRemove = Object.keys(newData); + } + if (keysToRemove.length > 0) { + // Show what will be deleted + if (options.key) { + bucketOra.info( + `About to delete ${keysToRemove.length} key(s) matching '${options.key}' from ${bucketPath.pathPattern} [${targetLocale}]:\n ${keysToRemove.slice(0, 10).join(", ")}${keysToRemove.length > 10 ? ", ..." : ""}`, + ); + } else { + bucketOra.info( + `About to delete all (${keysToRemove.length}) keys from ${bucketPath.pathPattern} [${targetLocale}]`, + ); + } + + if (!options.yesReally) { + bucketOra.warn( + "This is a destructive operation. If you are sure, type 'y' to continue. (Use --yes-really to skip this check.)", + ); + const confirmed = await confirm({ + message: `Delete these keys from ${bucketPath.pathPattern} [${targetLocale}]?`, + default: false, + }); + if (!confirmed) { + bucketOra.info("Skipped by user."); + continue; + } + } + for (const key of keysToRemove) { + delete newData[key]; + } + removedAny = true; + await bucketLoader.push(targetLocale, newData); + if (options.key) { + bucketOra.succeed( + `Removed ${keysToRemove.length} key(s) matching '${options.key}' from ${bucketPath.pathPattern} [${targetLocale}]`, + ); + } else { + bucketOra.succeed( + `Removed all keys (${keysToRemove.length}) from ${bucketPath.pathPattern} [${targetLocale}]`, + ); + } + } else if (options.key) { + bucketOra.info( + `No keys matching '${options.key}' found in ${bucketPath.pathPattern} [${targetLocale}]`, + ); + } else { + bucketOra.info("No keys to remove."); + } + } catch (error) { + const err = error as Error; + bucketOra.fail(`Failed: ${err.message}`); + } + } + } + } + if (!removedAny) { + ora.info("No keys were removed."); + } else { + ora.succeed("Purge completed."); + } + } catch (error) { + const err = error as Error; + ora.fail(err.message); + process.exit(1); + } + }); diff --git a/packages/cli/src/cli/cmd/run/_const.ts b/packages/cli/src/cli/cmd/run/_const.ts new file mode 100644 index 000000000..e3aaaaf2c --- /dev/null +++ b/packages/cli/src/cli/cmd/run/_const.ts @@ -0,0 +1,13 @@ +import chalk from "chalk"; +import { ListrDefaultRendererLogLevels } from "listr2"; +import { colors } from "../../constants"; + +export const commonTaskRendererOptions = { + color: { + [ListrDefaultRendererLogLevels.COMPLETED]: (msg?: string) => + msg ? chalk.hex(colors.green)(msg) : chalk.hex(colors.green)(""), + }, + icon: { + [ListrDefaultRendererLogLevels.COMPLETED]: chalk.hex(colors.green)("✓"), + }, +}; diff --git a/packages/cli/src/cli/cmd/run/_types.ts b/packages/cli/src/cli/cmd/run/_types.ts new file mode 100644 index 000000000..e101f6b69 --- /dev/null +++ b/packages/cli/src/cli/cmd/run/_types.ts @@ -0,0 +1,58 @@ +import { + bucketTypeSchema, + I18nConfig, + localeCodeSchema, + bucketTypes, +} from "@lingo.dev/_spec"; +import { z } from "zod"; +import { ILocalizer } from "../../localizer/_types"; + +export type CmdRunContext = { + flags: CmdRunFlags; + config: I18nConfig | null; + localizer: ILocalizer | null; + tasks: CmdRunTask[]; + results: Map<CmdRunTask, CmdRunTaskResult>; +}; + +export type CmdRunTaskResult = { + status: "success" | "error" | "skipped"; + error?: Error; + pathPattern?: string; + sourceLocale?: string; + targetLocale?: string; +}; + +export type CmdRunTask = { + sourceLocale: string; + targetLocale: string; + bucketType: (typeof bucketTypes)[number]; + bucketPathPattern: string; + injectLocale: string[]; + lockedKeys: string[]; + lockedPatterns: string[]; + ignoredKeys: string[]; + onlyKeys: string[]; + formatter?: "prettier" | "biome"; +}; + +export const flagsSchema = z.object({ + bucket: z.array(bucketTypeSchema).optional(), + key: z.array(z.string()).optional(), + file: z.array(z.string()).optional(), + apiKey: z.string().optional(), + force: z.boolean().optional(), + frozen: z.boolean().optional(), + verbose: z.boolean().optional(), + strict: z.boolean().optional(), + interactive: z.boolean().prefault(false), + concurrency: z.number().positive().prefault(10), + debug: z.boolean().prefault(false), + sourceLocale: z.string().optional(), + targetLocale: z.array(z.string()).optional(), + watch: z.boolean().prefault(false), + debounce: z.number().positive().prefault(5000), // 5 seconds default + sound: z.boolean().optional(), + pseudo: z.boolean().optional(), +}); +export type CmdRunFlags = z.infer<typeof flagsSchema>; diff --git a/packages/cli/src/cli/cmd/run/_utils.ts b/packages/cli/src/cli/cmd/run/_utils.ts new file mode 100644 index 000000000..5afbd287d --- /dev/null +++ b/packages/cli/src/cli/cmd/run/_utils.ts @@ -0,0 +1,22 @@ +import { CmdRunContext } from "./_types"; + +/** + * Determines the user's email for tracking purposes. + * Returns null if using BYOK mode or if authentication fails. + */ +export async function determineEmail( + ctx: CmdRunContext, +): Promise<string | null> { + const isByokMode = !!ctx.config?.provider; + + if (isByokMode) { + return null; + } else { + try { + const authStatus = await ctx.localizer?.checkAuth(); + return authStatus?.username || null; + } catch { + return null; + } + } +} diff --git a/packages/cli/src/cli/cmd/run/execute.spec.ts b/packages/cli/src/cli/cmd/run/execute.spec.ts new file mode 100644 index 000000000..f46e13caf --- /dev/null +++ b/packages/cli/src/cli/cmd/run/execute.spec.ts @@ -0,0 +1,309 @@ +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); + }); + }); +}); diff --git a/packages/cli/src/cli/cmd/run/execute.ts b/packages/cli/src/cli/cmd/run/execute.ts new file mode 100644 index 000000000..3a99e2a9d --- /dev/null +++ b/packages/cli/src/cli/cmd/run/execute.ts @@ -0,0 +1,369 @@ +import chalk from "chalk"; +import { Listr, ListrTask } from "listr2"; +import pLimit, { LimitFunction } from "p-limit"; +import _ from "lodash"; +import { minimatch } from "minimatch"; + +import { colors } from "../../constants"; +import { CmdRunContext, CmdRunTask, CmdRunTaskResult } from "./_types"; +import { commonTaskRendererOptions } from "./_const"; +import createBucketLoader from "../../loaders"; +import { createDeltaProcessor, Delta } from "../../utils/delta"; + +const WARN_CONCURRENCY_COUNT = 30; + +export default async function execute(input: CmdRunContext) { + const effectiveConcurrency = Math.min( + input.flags.concurrency, + input.tasks.length, + ); + + if (effectiveConcurrency >= WARN_CONCURRENCY_COUNT) { + console.warn( + chalk.yellow( + `⚠️ High concurrency (${effectiveConcurrency}) may cause failures in some environments.`, + ), + ); + } + + console.log(chalk.hex(colors.orange)(`[Localization]`)); + + return new Listr<CmdRunContext>( + [ + { + title: "Initializing localization engine", + task: async (ctx, task) => { + task.title = `Localization engine ${chalk.hex(colors.green)( + "ready", + )} (${ctx.localizer!.id})`; + }, + }, + { + title: `Processing localization tasks ${chalk.dim( + `(tasks: ${input.tasks.length}, concurrency: ${effectiveConcurrency})`, + )}`, + task: async (ctx, task) => { + if (input.tasks.length < 1) { + task.title = `Skipping, nothing to localize.`; + task.skip(); + return; + } + + // Preload checksums for all unique bucket path patterns before starting any workers + const initialChecksumsMap = new Map<string, Record<string, string>>(); + const uniqueBucketPatterns = _.uniq( + ctx.tasks.map((t) => t.bucketPathPattern), + ); + for (const bucketPathPattern of uniqueBucketPatterns) { + const deltaProcessor = createDeltaProcessor(bucketPathPattern); + const checksums = await deltaProcessor.loadChecksums(); + initialChecksumsMap.set(bucketPathPattern, checksums); + } + + const i18nLimiter = pLimit(effectiveConcurrency); + const ioLimiter = pLimit(1); + + const perFileIoLimiters = new Map<string, LimitFunction>(); + const getFileIoLimiter = ( + bucketPathPattern: string, + ): LimitFunction => { + const lockKey = bucketPathPattern; + + if (!perFileIoLimiters.has(lockKey)) { + perFileIoLimiters.set(lockKey, pLimit(1)); + } + return perFileIoLimiters.get(lockKey)!; + }; + + const workersCount = effectiveConcurrency; + + const workerTasks: ListrTask[] = []; + for (let i = 0; i < workersCount; i++) { + const assignedTasks = ctx.tasks.filter( + (_, idx) => idx % workersCount === i, + ); + workerTasks.push( + createWorkerTask({ + ctx, + assignedTasks, + ioLimiter, + i18nLimiter, + initialChecksumsMap, + getFileIoLimiter, + onDone() { + task.title = createExecutionProgressMessage(ctx); + }, + }), + ); + } + + return task.newListr(workerTasks, { + concurrent: true, + exitOnError: false, + rendererOptions: { + ...commonTaskRendererOptions, + collapseSubtasks: true, + }, + }); + }, + }, + ], + { + exitOnError: false, + rendererOptions: commonTaskRendererOptions, + }, + ).run(input); +} + +function createWorkerStatusMessage(args: { + assignedTask: CmdRunTask; + percentage: number; +}) { + const displayPath = args.assignedTask.bucketPathPattern.replace( + "[locale]", + args.assignedTask.targetLocale, + ); + return `[${chalk.hex(colors.yellow)( + `${args.percentage}%`, + )}] Processing: ${chalk.dim(displayPath)} (${chalk.hex(colors.yellow)( + args.assignedTask.sourceLocale, + )} -> ${chalk.hex(colors.yellow)(args.assignedTask.targetLocale)})`; +} + +function createExecutionProgressMessage(ctx: CmdRunContext) { + const succeededTasksCount = countTasks( + ctx, + (_t, result) => result.status === "success", + ); + const failedTasksCount = countTasks( + ctx, + (_t, result) => result.status === "error", + ); + const skippedTasksCount = countTasks( + ctx, + (_t, result) => result.status === "skipped", + ); + + return `Processed ${chalk.green(succeededTasksCount)}/${ + ctx.tasks.length + }, Failed ${chalk.red(failedTasksCount)}, Skipped ${chalk.dim( + skippedTasksCount, + )}`; +} + +function createLoaderForTask(assignedTask: CmdRunTask) { + const bucketLoader = createBucketLoader( + assignedTask.bucketType, + assignedTask.bucketPathPattern, + { + defaultLocale: assignedTask.sourceLocale, + injectLocale: assignedTask.injectLocale, + formatter: assignedTask.formatter, + }, + assignedTask.lockedKeys, + assignedTask.lockedPatterns, + assignedTask.ignoredKeys, + ); + bucketLoader.setDefaultLocale(assignedTask.sourceLocale); + + return bucketLoader; +} + +function createWorkerTask(args: { + ctx: CmdRunContext; + assignedTasks: CmdRunTask[]; + ioLimiter: LimitFunction; + i18nLimiter: LimitFunction; + onDone: () => void; + initialChecksumsMap: Map<string, Record<string, string>>; + getFileIoLimiter: (bucketPathPattern: string) => LimitFunction; +}): ListrTask { + return { + title: "Initializing...", + task: async (_subCtx: any, subTask: any) => { + for (const assignedTask of args.assignedTasks) { + subTask.title = createWorkerStatusMessage({ + assignedTask, + percentage: 0, + }); + const bucketLoader = createLoaderForTask(assignedTask); + const deltaProcessor = createDeltaProcessor( + assignedTask.bucketPathPattern, + ); + + // Get initial checksums from the preloaded map + const initialChecksums = + args.initialChecksumsMap.get(assignedTask.bucketPathPattern) || {}; + + const taskResult = await args.i18nLimiter(async () => { + try { + // Pull operations must be serialized per-file for single-file formats + // where multiple locales share the same file (e.g., xcode-xcstrings) + const fileIoLimiter = args.getFileIoLimiter( + assignedTask.bucketPathPattern, + ); + const sourceData = await fileIoLimiter(async () => + bucketLoader.pull(assignedTask.sourceLocale), + ); + const hints = await fileIoLimiter(async () => + bucketLoader.pullHints(), + ); + const targetData = await fileIoLimiter(async () => + bucketLoader.pull(assignedTask.targetLocale), + ); + const delta = await deltaProcessor.calculateDelta({ + sourceData, + targetData, + checksums: initialChecksums, + }); + + const processableData = _.chain(sourceData) + .entries() + .filter( + ([key, value]) => + delta.added.includes(key) || + delta.updated.includes(key) || + !!args.ctx.flags.force, + ) + .filter( + ([key]) => + !assignedTask.onlyKeys.length || + assignedTask.onlyKeys?.some((pattern) => + minimatch(key, pattern), + ), + ) + .fromPairs() + .value(); + + if (!Object.keys(processableData).length) { + await fileIoLimiter(async () => { + // re-push in case some of the unlocalizable / meta data changed + await bucketLoader.push(assignedTask.targetLocale, targetData); + }); + return { + status: "skipped", + pathPattern: assignedTask.bucketPathPattern, + sourceLocale: assignedTask.sourceLocale, + targetLocale: assignedTask.targetLocale, + } satisfies CmdRunTaskResult; + } + + const relevantHints = _.pick(hints, Object.keys(processableData)); + const processedTargetData = await args.ctx.localizer!.localize( + { + sourceLocale: assignedTask.sourceLocale, + targetLocale: assignedTask.targetLocale, + sourceData, + targetData, + processableData, + hints: relevantHints, + }, + async (progress, _sourceChunk, processedChunk) => { + // write translated chunks as they are received from LLM + await fileIoLimiter(async () => { + // pull the latest source data before pushing for buckets that store all locales in a single file + await bucketLoader.pull(assignedTask.sourceLocale); + // pull the latest target data to include all already processed chunks + const latestTargetData = await bucketLoader.pull( + assignedTask.targetLocale, + ); + + // add the new chunk to target data + const _partialData = _.merge( + {}, + latestTargetData, + processedChunk, + ); + // process renamed keys + const finalChunkTargetData = processRenamedKeys( + delta, + _partialData, + ); + // push final chunk to the target locale + await bucketLoader.push( + assignedTask.targetLocale, + finalChunkTargetData, + ); + }); + + subTask.title = createWorkerStatusMessage({ + assignedTask, + percentage: progress, + }); + }, + ); + + const finalTargetData = _.merge( + {}, + sourceData, + targetData, + processedTargetData, + ); + const finalRenamedTargetData = processRenamedKeys( + delta, + finalTargetData, + ); + + await fileIoLimiter(async () => { + // not all localizers have progress callback (eg. explicit localizer), + // the final target data might not be pushed yet - push now to ensure it's up to date + await bucketLoader.pull(assignedTask.sourceLocale); + await bucketLoader.push( + assignedTask.targetLocale, + finalRenamedTargetData, + ); + + const checksums = + await deltaProcessor.createChecksums(sourceData); + if (!args.ctx.flags.targetLocale?.length) { + await deltaProcessor.saveChecksums(checksums); + } + }); + + return { + status: "success", + pathPattern: assignedTask.bucketPathPattern, + sourceLocale: assignedTask.sourceLocale, + targetLocale: assignedTask.targetLocale, + } satisfies CmdRunTaskResult; + } catch (error) { + return { + status: "error", + error: error as Error, + pathPattern: assignedTask.bucketPathPattern, + sourceLocale: assignedTask.sourceLocale, + targetLocale: assignedTask.targetLocale, + } satisfies CmdRunTaskResult; + } + }); + + args.ctx.results.set(assignedTask, taskResult); + } + + subTask.title = "Done"; + }, + }; +} + +function countTasks( + ctx: CmdRunContext, + predicate: (task: CmdRunTask, result: CmdRunTaskResult) => boolean, +) { + return Array.from(ctx.results.entries()).filter(([task, result]) => + predicate(task, result), + ).length; +} + +function processRenamedKeys(delta: Delta, targetData: Record<string, string>) { + return _.chain(targetData) + .entries() + .map(([key, value]) => { + const renaming = delta.renamed.find(([oldKey]) => oldKey === key); + if (!renaming) { + return [key, value]; + } + return [renaming[1], value]; + }) + .fromPairs() + .value(); +} diff --git a/packages/cli/src/cli/cmd/run/frozen.ts b/packages/cli/src/cli/cmd/run/frozen.ts new file mode 100644 index 000000000..d762806dd --- /dev/null +++ b/packages/cli/src/cli/cmd/run/frozen.ts @@ -0,0 +1,176 @@ +import chalk from "chalk"; +import { Listr } from "listr2"; +import _ from "lodash"; +import { minimatch } from "minimatch"; + +import { colors } from "../../constants"; +import { CmdRunContext } from "./_types"; +import { commonTaskRendererOptions } from "./_const"; +import { getBuckets } from "../../utils/buckets"; +import createBucketLoader from "../../loaders"; +import { createDeltaProcessor } from "../../utils/delta"; +import { resolveOverriddenLocale } from "@lingo.dev/_spec"; + +export default async function frozen(input: CmdRunContext) { + console.log(chalk.hex(colors.orange)("[Frozen]")); + + // Prepare filtered buckets consistently with the planning step + let buckets = getBuckets(input.config!); + if (input.flags.bucket?.length) { + buckets = buckets.filter((b) => input.flags.bucket!.includes(b.type)); + } + + if (input.flags.file?.length) { + buckets = buckets + .map((bucket: any) => { + const paths = bucket.paths.filter((p: any) => + input.flags.file!.some( + (f) => p.pathPattern.includes(f) || minimatch(p.pathPattern, f), + ), + ); + return { ...bucket, paths }; + }) + .filter((bucket: any) => bucket.paths.length > 0); + } + + const _sourceLocale = input.flags.sourceLocale || input.config!.locale.source; + const _targetLocales = + input.flags.targetLocale || input.config!.locale.targets; + + return new Listr<CmdRunContext>( + [ + { + title: "Setting up localization cache", + task: async (_ctx, task) => { + const checkLockfileProcessor = createDeltaProcessor(""); + const lockfileExists = + await checkLockfileProcessor.checkIfLockExists(); + if (!lockfileExists) { + for (const bucket of buckets) { + for (const bucketPath of bucket.paths) { + const resolvedSourceLocale = resolveOverriddenLocale( + _sourceLocale, + bucketPath.delimiter, + ); + + const loader = createBucketLoader( + bucket.type, + bucketPath.pathPattern, + { + defaultLocale: resolvedSourceLocale, + injectLocale: bucket.injectLocale, + formatter: input.config!.formatter, + }, + bucket.lockedKeys, + bucket.lockedPatterns, + bucket.ignoredKeys, + ); + loader.setDefaultLocale(resolvedSourceLocale); + await loader.init(); + + const sourceData = await loader.pull(_sourceLocale); + + const delta = createDeltaProcessor(bucketPath.pathPattern); + const checksums = await delta.createChecksums(sourceData); + await delta.saveChecksums(checksums); + } + } + task.title = "Localization cache initialized"; + } else { + task.title = "Localization cache loaded"; + } + }, + }, + { + title: "Validating frozen state", + enabled: () => !!input.flags.frozen, + task: async (_ctx, task) => { + for (const bucket of buckets) { + for (const bucketPath of bucket.paths) { + const resolvedSourceLocale = resolveOverriddenLocale( + _sourceLocale, + bucketPath.delimiter, + ); + + const loader = createBucketLoader( + bucket.type, + bucketPath.pathPattern, + { + defaultLocale: resolvedSourceLocale, + returnUnlocalizedKeys: true, + injectLocale: bucket.injectLocale, + }, + bucket.lockedKeys, + bucket.lockedPatterns, + bucket.ignoredKeys, + ); + loader.setDefaultLocale(resolvedSourceLocale); + await loader.init(); + + const { unlocalizable: srcUnlocalizable, ...src } = + await loader.pull(_sourceLocale); + + const delta = createDeltaProcessor(bucketPath.pathPattern); + const sourceChecksums = await delta.createChecksums(src); + const savedChecksums = await delta.loadChecksums(); + + const updatedSourceData = _.pickBy( + src, + (value, key) => sourceChecksums[key] !== savedChecksums[key], + ); + if (Object.keys(updatedSourceData).length > 0) { + throw new Error( + `Localization data has changed; please update i18n.lock or run without --frozen. Details: Source file has been updated.`, + ); + } + + for (const _tgt of _targetLocales) { + const resolvedTargetLocale = resolveOverriddenLocale( + _tgt, + bucketPath.delimiter, + ); + const { unlocalizable: tgtUnlocalizable, ...tgt } = + await loader.pull(resolvedTargetLocale); + + const missingKeys = _.difference( + Object.keys(src), + Object.keys(tgt), + ); + if (missingKeys.length > 0) { + throw new Error( + `Localization data has changed; please update i18n.lock or run without --frozen. Details: Target file is missing translations.`, + ); + } + + const extraKeys = _.difference( + Object.keys(tgt), + Object.keys(src), + ); + if (extraKeys.length > 0) { + throw new Error( + `Localization data has changed; please update i18n.lock or run without --frozen. Details: Target file has extra translations not present in the source file.`, + ); + } + + const unlocalizableDataDiff = !_.isEqual( + srcUnlocalizable, + tgtUnlocalizable, + ); + if (unlocalizableDataDiff) { + throw new Error( + `Localization data has changed; please update i18n.lock or run without --frozen. Details: Unlocalizable data (such as booleans, dates, URLs, etc.) do not match.`, + ); + } + } + } + } + + task.title = "No lockfile updates required"; + }, + }, + ], + { + rendererOptions: commonTaskRendererOptions, + }, + ).run(input); +} diff --git a/packages/cli/src/cli/cmd/run/index.ts b/packages/cli/src/cli/cmd/run/index.ts new file mode 100644 index 000000000..c8a3f4833 --- /dev/null +++ b/packages/cli/src/cli/cmd/run/index.ts @@ -0,0 +1,194 @@ +import { Command } from "interactive-commander"; +import { exec } from "child_process"; +import path from "path"; +import { fileURLToPath } from "url"; +import os from "os"; +import setup from "./setup"; +import plan from "./plan"; +import execute from "./execute"; +import watch from "./watch"; +import { CmdRunContext, flagsSchema } from "./_types"; +import frozen from "./frozen"; +import { + renderClear, + renderSpacer, + renderBanner, + renderHero, + pauseIfDebug, + renderSummary, +} from "../../utils/ui"; +import trackEvent from "../../utils/observability"; +import { determineEmail } from "./_utils"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function playSound(type: "success" | "failure") { + const platform = os.platform(); + + return new Promise<void>((resolve) => { + const assetDir = path.join(__dirname, "../assets"); + const soundFiles = [path.join(assetDir, `${type}.mp3`)]; + + let command = ""; + + if (platform === "linux") { + command = soundFiles + .map( + (file) => + `mpg123 -q "${file}" 2>/dev/null || aplay "${file}" 2>/dev/null`, + ) + .join(" || "); + } else if (platform === "darwin") { + command = soundFiles.map((file) => `afplay "${file}"`).join(" || "); + } else if (platform === "win32") { + command = `powershell -c "try { (New-Object Media.SoundPlayer '${soundFiles[1]}').PlaySync() } catch { Start-Process -FilePath '${soundFiles[0]}' -WindowStyle Hidden -Wait }"`; + } else { + command = soundFiles + .map( + (file) => + `aplay "${file}" 2>/dev/null || afplay "${file}" 2>/dev/null`, + ) + .join(" || "); + } + + exec(command, () => { + resolve(); + }); + setTimeout(resolve, 3000); + }); +} + +export default new Command() + .command("run") + .description("Run localization pipeline") + .helpOption("-h, --help", "Show help") + .option( + "--source-locale <source-locale>", + "Override the source locale from i18n.json for this run", + ) + .option( + "--target-locale <target-locale>", + "Limit processing to the listed target locale codes from i18n.json. Repeat the flag to include multiple locales. Defaults to all configured target locales", + (val: string, prev: string[]) => (prev ? [...prev, val] : [val]), + ) + .option( + "--bucket <bucket>", + "Limit processing to specific bucket types defined in i18n.json (e.g., json, yaml, android). Repeat the flag to include multiple bucket types. Defaults to all configured buckets", + (val: string, prev: string[]) => (prev ? [...prev, val] : [val]), + ) + .option( + "--file <file>", + "Filter bucket path pattern values by substring match. Examples: messages.json or locale/. Repeat to add multiple filters", + (val: string, prev: string[]) => (prev ? [...prev, val] : [val]), + ) + .option( + "--key <key>", + "Filter keys by prefix matching on dot-separated paths. Example: auth.login to match all keys starting with auth.login. Repeat for multiple patterns", + (val: string, prev: string[]) => + prev ? [...prev, encodeURIComponent(val)] : [encodeURIComponent(val)], + ) + .option( + "--force", + "Force re-translation of all keys, bypassing change detection. Useful when you want to regenerate translations with updated AI models or translation settings", + ) + .option( + "--frozen", + "Validate translations are up-to-date without making changes - fails if source files, target files, or lockfile are out of sync. Ideal for CI/CD to ensure translation consistency before deployment", + ) + .option( + "--api-key <api-key>", + "Override API key from settings or environment variables", + ) + .option("--debug", "Pause before processing to allow attaching a debugger.") + .option( + "--concurrency <concurrency>", + "Number of translation jobs to run concurrently. Higher values can speed up large translation batches but may increase memory usage. Defaults to 10 (maximum 10)", + (val: string) => parseInt(val), + ) + .option( + "--watch", + "Watch source locale files continuously and retranslate automatically when files change", + ) + .option( + "--debounce <milliseconds>", + "Delay in milliseconds after file changes before retranslating in watch mode. Defaults to 5000", + (val: string) => parseInt(val), + ) + .option( + "--sound", + "Play audio feedback when translations complete (success or failure sounds)", + ) + .option( + "--pseudo", + "Enable pseudo-localization mode: automatically pseudo-translates all extracted strings with accented characters and visual markers without calling any external API. Useful for testing UI internationalization readiness", + ) + .action(async (args) => { + let email: string | null = null; + try { + const ctx: CmdRunContext = { + flags: flagsSchema.parse(args), + config: null, + results: new Map(), + tasks: [], + localizer: null, + }; + + await pauseIfDebug(ctx.flags.debug); + await renderClear(); + await renderSpacer(); + await renderBanner(); + await renderHero(); + await renderSpacer(); + + await setup(ctx); + + email = await determineEmail(ctx); + + await trackEvent(email, "cmd.run.start", { + config: ctx.config, + flags: ctx.flags, + }); + + await renderSpacer(); + + await plan(ctx); + await renderSpacer(); + + await frozen(ctx); + await renderSpacer(); + + await execute(ctx); + await renderSpacer(); + + await renderSummary(ctx.results); + await renderSpacer(); + + // Play sound after main tasks complete if sound flag is enabled + if (ctx.flags.sound) { + await playSound("success"); + } + + // If watch mode is enabled, start watching for changes + if (ctx.flags.watch) { + await watch(ctx); + } + + await trackEvent(email, "cmd.run.success", { + config: ctx.config, + flags: ctx.flags, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + } catch (error: any) { + await trackEvent(email, "cmd.run.error", { + flags: args, + error: error.message, + authenticated: !!email, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + // Play sad sound if sound flag is enabled + if (args.sound) { + await playSound("failure"); + } + throw error; + } + }); diff --git a/packages/cli/src/cli/cmd/run/plan.ts b/packages/cli/src/cli/cmd/run/plan.ts new file mode 100644 index 000000000..892326934 --- /dev/null +++ b/packages/cli/src/cli/cmd/run/plan.ts @@ -0,0 +1,153 @@ +import chalk from "chalk"; +import { Listr } from "listr2"; +import { minimatch } from "minimatch"; + +import { colors } from "../../constants"; +import { resolveOverriddenLocale } from "@lingo.dev/_spec"; +import { getBuckets } from "../../utils/buckets"; +import { commonTaskRendererOptions } from "./_const"; +import { CmdRunContext } from "./_types"; + +export default async function plan( + input: CmdRunContext, +): Promise<CmdRunContext> { + console.log(chalk.hex(colors.orange)("[Planning]")); + + let buckets = getBuckets(input.config!); + if (input.flags.bucket) { + buckets = buckets.filter((b) => input.flags.bucket!.includes(b.type)); + } + + const _sourceLocale = input.flags.sourceLocale || input.config!.locale.source; + if (!_sourceLocale) { + throw new Error( + `No source locale provided. Use --source-locale to specify the source locale or add it to i18n.json (locale.source)`, + ); + } + const _targetLocales = + input.flags.targetLocale || input.config!.locale.targets; + if (!_targetLocales.length) { + throw new Error( + `No target locales provided. Use --target-locale to specify the target locales or add them to i18n.json (locale.targets)`, + ); + } + + return new Listr<CmdRunContext>( + [ + { + title: "Locating content buckets", + task: async (ctx, task) => { + const bucketCount = buckets.length; + const bucketFilter = input.flags.bucket + ? ` ${chalk.dim( + `(filtered by: ${chalk.hex(colors.yellow)( + input.flags.bucket!.join(", "), + )})`, + )}` + : ""; + task.title = `Found ${chalk.hex(colors.yellow)( + bucketCount.toString(), + )} bucket(s)${bucketFilter}`; + }, + }, + { + title: "Detecting locales", + task: async (ctx, task) => { + task.title = `Found ${chalk.hex(colors.yellow)( + _targetLocales.length.toString(), + )} target locale(s)`; + }, + }, + { + title: "Locating localizable files", + task: async (ctx, task) => { + const patterns: string[] = []; + + for (const bucket of buckets) { + for (const bucketPath of bucket.paths) { + if (input.flags.file) { + if ( + !input.flags.file.some( + (f) => + bucketPath.pathPattern.includes(f) || + minimatch(bucketPath.pathPattern, f), + ) + ) { + continue; + } + } + + patterns.push(bucketPath.pathPattern); + } + } + + const fileFilter = input.flags.file + ? ` ${chalk.dim( + `(filtered by: ${chalk.hex(colors.yellow)( + input.flags.file.join(", "), + )})`, + )}` + : ""; + task.title = `Found ${chalk.hex(colors.yellow)( + patterns.length.toString(), + )} path pattern(s)${fileFilter}`; + }, + }, + { + title: "Computing translation tasks", + task: async (ctx, task) => { + for (const bucket of buckets) { + for (const bucketPath of bucket.paths) { + if (input.flags.file) { + if ( + !input.flags.file.some( + (f) => + bucketPath.pathPattern.includes(f) || + minimatch(bucketPath.pathPattern, f), + ) + ) { + continue; + } + } + + const sourceLocale = resolveOverriddenLocale( + _sourceLocale, + bucketPath.delimiter, + ); + + for (const _targetLocale of _targetLocales) { + const targetLocale = resolveOverriddenLocale( + _targetLocale, + bucketPath.delimiter, + ); + + // Skip if source and target are identical (shouldn't happen but guard) + if (sourceLocale === targetLocale) continue; + + ctx.tasks.push({ + sourceLocale, + targetLocale, + bucketType: bucket.type, + bucketPathPattern: bucketPath.pathPattern, + injectLocale: bucket.injectLocale || [], + lockedKeys: bucket.lockedKeys || [], + lockedPatterns: bucket.lockedPatterns || [], + ignoredKeys: bucket.ignoredKeys || [], + onlyKeys: input.flags.key || [], + formatter: input.config!.formatter, + }); + } + } + } + + task.title = `Prepared ${chalk.hex(colors.green)( + ctx.tasks.length.toString(), + )} translation task(s)`; + }, + }, + ], + { + rendererOptions: commonTaskRendererOptions, + }, + ).run(input); +} diff --git a/packages/cli/src/cli/cmd/run/setup.ts b/packages/cli/src/cli/cmd/run/setup.ts new file mode 100644 index 000000000..bcd88edda --- /dev/null +++ b/packages/cli/src/cli/cmd/run/setup.ts @@ -0,0 +1,129 @@ +import chalk from "chalk"; +import { Listr } from "listr2"; +import { colors } from "../../constants"; +import { CmdRunContext, flagsSchema } from "./_types"; +import { commonTaskRendererOptions } from "./_const"; +import { getConfigOrThrow } from "../../utils/config"; +import createLocalizer from "../../localizer"; + +export default async function setup(input: CmdRunContext) { + console.log(chalk.hex(colors.orange)("[Setup]")); + + return new Listr<CmdRunContext>( + [ + { + title: "Setting up the environment", + task: async (ctx, task) => { + // setup gitignore, etc here + task.title = `Environment setup completed`; + }, + }, + { + title: "Loading i18n configuration", + task: async (ctx, task) => { + ctx.config = getConfigOrThrow(true); + + if ( + !ctx.config.buckets || + !Object.keys(ctx.config.buckets).length + ) { + throw new Error( + "No buckets found in i18n.json. Please add at least one bucket containing i18n content.", + ); + } else if ( + ctx.flags.bucket?.some( + (bucket) => + !ctx.config?.buckets[bucket as keyof typeof ctx.config.buckets], + ) + ) { + throw new Error( + `One or more specified buckets do not exist in i18n.json. Please add them to the list first and try again.`, + ); + } + task.title = `Loaded i18n configuration`; + }, + }, + { + title: "Selecting localization provider", + task: async (ctx, task) => { + const provider = ctx.flags.pseudo ? "pseudo" : ctx.config?.provider; + const vNext = ctx.config?.vNext; + ctx.localizer = createLocalizer(provider, ctx.flags.apiKey, vNext); + if (!ctx.localizer) { + throw new Error( + "Could not create localization provider. Please check your i18n.json configuration.", + ); + } + task.title = + ctx.localizer.id === "Lingo.dev" || ctx.localizer.id === "Lingo.dev vNext" + ? `Using ${chalk.hex(colors.green)(ctx.localizer.id)} provider` + : ctx.localizer.id === "pseudo" + ? `Using ${chalk.hex(colors.blue)("pseudo")} mode for testing` + : `Using raw ${chalk.hex(colors.yellow)(ctx.localizer.id)} API`; + }, + }, + { + title: "Checking authentication", + enabled: (ctx) => + (ctx.localizer?.id === "Lingo.dev" || ctx.localizer?.id === "Lingo.dev vNext") && !ctx.flags.pseudo, + task: async (ctx, task) => { + const authStatus = await ctx.localizer!.checkAuth(); + if (!authStatus.authenticated) { + throw new Error(authStatus.error || "Authentication failed"); + } + task.title = `Authenticated as ${chalk.hex(colors.yellow)( + authStatus.username, + )}`; + }, + }, + { + title: "Validating configuration", + enabled: (ctx) => ctx.localizer?.id !== "Lingo.dev" && ctx.localizer?.id !== "Lingo.dev vNext", + task: async (ctx, task) => { + const validationStatus = await ctx.localizer!.validateSettings!(); + if (!validationStatus.valid) { + throw new Error( + validationStatus.error || "Configuration validation failed", + ); + } + task.title = `Configuration validated`; + }, + }, + { + title: "Initializing localization provider", + async task(ctx, task) { + const isLingoDotDev = ctx.localizer!.id === "Lingo.dev"; + const isPseudo = ctx.localizer!.id === "pseudo"; + + const subTasks = isLingoDotDev + ? [ + "Brand voice enabled", + "Translation memory connected", + "Glossary enabled", + "Quality assurance enabled", + ].map((title) => ({ title, task: () => {} })) + : isPseudo + ? [ + "Pseudo-localization mode active", + "Character replacement configured", + "No external API calls", + ].map((title) => ({ title, task: () => {} })) + : [ + "Skipping brand voice", + "Skipping glossary", + "Skipping translation memory", + "Skipping quality assurance", + ].map((title) => ({ title, task: () => {}, skip: true })); + + return task.newListr(subTasks, { + concurrent: true, + rendererOptions: { collapseSubtasks: false }, + }); + }, + }, + ], + { + rendererOptions: commonTaskRendererOptions, + }, + ).run(input); +} diff --git a/packages/cli/src/cli/cmd/run/watch.ts b/packages/cli/src/cli/cmd/run/watch.ts new file mode 100644 index 000000000..2fc52d96e --- /dev/null +++ b/packages/cli/src/cli/cmd/run/watch.ts @@ -0,0 +1,195 @@ +import * as chokidar from "chokidar"; +import chalk from "chalk"; +import { minimatch } from "minimatch"; +import { colors } from "../../constants"; +import { CmdRunContext } from "./_types"; +import plan from "./plan"; +import execute from "./execute"; +import { renderSummary } from "../../utils/ui"; +import { getBuckets } from "../../utils/buckets"; + +interface WatchState { + isRunning: boolean; + pendingChanges: Set<string>; + debounceTimer?: NodeJS.Timeout; +} + +export default async function watch(ctx: CmdRunContext) { + const debounceDelay = ctx.flags.debounce || 5000; // Use configured debounce or 5s default + + console.log(chalk.hex(colors.orange)("[Watch Mode]")); + console.log( + `👀 Watching for changes... (Press ${chalk.yellow("Ctrl+C")} to stop)`, + ); + console.log(chalk.dim(` Debounce delay: ${debounceDelay}ms`)); + console.log(""); + + const state: WatchState = { + isRunning: false, + pendingChanges: new Set(), + }; + + // Get all source file patterns to watch + const watchPatterns = await getWatchPatterns(ctx); + + if (watchPatterns.length === 0) { + console.log(chalk.yellow("⚠️ No source files found to watch")); + return; + } + + console.log(chalk.dim(`Watching ${watchPatterns.length} file pattern(s):`)); + watchPatterns.forEach((pattern) => { + console.log(chalk.dim(` • ${pattern}`)); + }); + console.log(""); + + // Initialize file watcher + const watcher = chokidar.watch(watchPatterns, { + ignoreInitial: true, + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 500, + pollInterval: 100, + }, + }); + + // Handle file changes + watcher.on("change", (path) => { + handleFileChange(path, state, ctx); + }); + + watcher.on("add", (path) => { + handleFileChange(path, state, ctx); + }); + + watcher.on("unlink", (path) => { + handleFileChange(path, state, ctx); + }); + + watcher.on("error", (error) => { + console.error( + chalk.red( + `Watch error: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + }); + + // Handle graceful shutdown + process.on("SIGINT", () => { + console.log(chalk.yellow("\n\n🛑 Stopping watch mode...")); + watcher.close(); + process.exit(0); + }); + + // Keep the process running + await new Promise(() => {}); // Never resolves, keeps process alive +} + +async function getWatchPatterns(ctx: CmdRunContext): Promise<string[]> { + if (!ctx.config) return []; + + const buckets = getBuckets(ctx.config); + const patterns: string[] = []; + + for (const bucket of buckets) { + // Skip if specific buckets are filtered + if (ctx.flags.bucket && !ctx.flags.bucket.includes(bucket.type)) { + continue; + } + + for (const bucketPath of bucket.paths) { + // Skip if specific files are filtered + if (ctx.flags.file) { + if ( + !ctx.flags.file.some( + (f) => + bucketPath.pathPattern.includes(f) || + minimatch(bucketPath.pathPattern, f), + ) + ) { + continue; + } + } + + // Get the source locale pattern (replace [locale] with source locale) + const sourceLocale = ctx.flags.sourceLocale || ctx.config.locale.source; + const sourcePattern = bucketPath.pathPattern.replace( + "[locale]", + sourceLocale, + ); + + patterns.push(sourcePattern); + } + } + + return patterns; +} + +function handleFileChange( + filePath: string, + state: WatchState, + ctx: CmdRunContext, +) { + const debounceDelay = ctx.flags.debounce || 5000; // Use configured debounce or 5s default + + state.pendingChanges.add(filePath); + + console.log(chalk.dim(`📝 File changed: ${filePath}`)); + + // Clear existing debounce timer + if (state.debounceTimer) { + clearTimeout(state.debounceTimer); + } + + // Set new debounce timer + state.debounceTimer = setTimeout(async () => { + if (state.isRunning) { + console.log( + chalk.yellow("⏳ Translation already in progress, skipping..."), + ); + return; + } + + await triggerRetranslation(state, ctx); + }, debounceDelay); +} + +async function triggerRetranslation(state: WatchState, ctx: CmdRunContext) { + if (state.isRunning) return; + + state.isRunning = true; + + try { + const changedFiles = Array.from(state.pendingChanges); + state.pendingChanges.clear(); + + console.log(chalk.hex(colors.green)("\n🔄 Triggering retranslation...")); + console.log(chalk.dim(`Changed files: ${changedFiles.join(", ")}`)); + console.log(""); + + // Create a new context for this run (preserve original flags but reset tasks/results) + const runCtx: CmdRunContext = { + ...ctx, + tasks: [], + results: new Map(), + }; + + // Re-run the translation pipeline + await plan(runCtx); + + if (runCtx.tasks.length === 0) { + console.log(chalk.dim("✨ No translation tasks needed")); + } else { + await execute(runCtx); + await renderSummary(runCtx.results); + } + + console.log(chalk.hex(colors.green)("✅ Retranslation completed")); + console.log(chalk.dim("👀 Continuing to watch for changes...\n")); + } catch (error: any) { + console.error(chalk.red(`❌ Retranslation failed: ${error.message}`)); + console.log(chalk.dim("👀 Continuing to watch for changes...\n")); + } finally { + state.isRunning = false; + } +} diff --git a/packages/cli/src/cli/cmd/show/_shared-key-command.ts b/packages/cli/src/cli/cmd/show/_shared-key-command.ts new file mode 100644 index 000000000..ee66db50a --- /dev/null +++ b/packages/cli/src/cli/cmd/show/_shared-key-command.ts @@ -0,0 +1,112 @@ +import { resolveOverriddenLocale, I18nConfig } from "@lingo.dev/_spec"; +import createBucketLoader from "../../loaders"; +import { + matchesKeyPattern, + formatDisplayValue, +} from "../../utils/key-matching"; + +export type KeyFilterType = "lockedKeys" | "ignoredKeys"; + +export interface KeyCommandOptions { + bucket?: string; +} + +export interface KeyCommandConfig { + filterType: KeyFilterType; + displayName: string; // e.g., "locked", "ignored" +} + +export async function executeKeyCommand( + i18nConfig: I18nConfig, + buckets: any[], + options: KeyCommandOptions, + config: KeyCommandConfig, +): Promise<void> { + let hasAnyKeys = false; + + for (const bucket of buckets) { + // Filter by bucket name if specified + if (options.bucket && bucket.type !== options.bucket) { + continue; + } + + // Skip buckets without the specified key patterns + const keyPatterns = bucket[config.filterType]; + if (!keyPatterns || keyPatterns.length === 0) { + continue; + } + + hasAnyKeys = true; + + console.log(`\nBucket: ${bucket.type}`); + console.log( + `${capitalize(config.displayName)} key patterns: ${keyPatterns.join(", ")}`, + ); + + for (const bucketConfig of bucket.paths) { + const sourceLocale = resolveOverriddenLocale( + i18nConfig.locale.source, + bucketConfig.delimiter, + ); + const sourcePath = bucketConfig.pathPattern.replace( + /\[locale\]/g, + sourceLocale, + ); + + try { + // Create a loader to read the source file + const loader = createBucketLoader( + bucket.type, + bucketConfig.pathPattern, + { + defaultLocale: sourceLocale, + injectLocale: bucket.injectLocale, + }, + [], // Don't apply any filtering when reading + [], + [], + ); + loader.setDefaultLocale(sourceLocale); + + // Read the source file content + const data = await loader.pull(sourceLocale); + + if (!data || Object.keys(data).length === 0) { + continue; + } + + // Filter keys that match the patterns + const matchedEntries = Object.entries(data).filter(([key]) => + matchesKeyPattern(key, keyPatterns), + ); + + if (matchedEntries.length > 0) { + console.log(`\nMatches in ${sourcePath}:`); + for (const [key, value] of matchedEntries) { + const displayValue = formatDisplayValue(value); + console.log(` - ${key}: ${displayValue}`); + } + console.log( + `Total: ${matchedEntries.length} ${config.displayName} key(s)`, + ); + } + } catch (error: any) { + console.error(` Error reading ${sourcePath}: ${error.message}`); + } + } + } + + if (!hasAnyKeys) { + if (options.bucket) { + console.log( + `No ${config.displayName} keys configured for bucket: ${options.bucket}`, + ); + } else { + console.log(`No ${config.displayName} keys configured in any bucket.`); + } + } +} + +function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/packages/cli/src/cli/cmd/show/config.ts b/packages/cli/src/cli/cmd/show/config.ts new file mode 100644 index 000000000..33b5d5b47 --- /dev/null +++ b/packages/cli/src/cli/cmd/show/config.ts @@ -0,0 +1,15 @@ +import { Command } from "interactive-commander"; +import _ from "lodash"; +import { defaultConfig } from "@lingo.dev/_spec"; +import { getConfig } from "../../utils/config"; + +export default new Command() + .command("config") + .description("Print effective i18n.json after merging with defaults") + .helpOption("-h, --help", "Show help") + .action(async (options) => { + const fileConfig = getConfig(false); + const config = _.merge({}, defaultConfig, fileConfig); + + console.log(JSON.stringify(config, null, 2)); + }); diff --git a/packages/cli/src/cli/cmd/show/files.ts b/packages/cli/src/cli/cmd/show/files.ts new file mode 100644 index 000000000..6cd4ab598 --- /dev/null +++ b/packages/cli/src/cli/cmd/show/files.ts @@ -0,0 +1,85 @@ +import { Command } from "interactive-commander"; +import _ from "lodash"; +import Ora from "ora"; +import { getConfig } from "../../utils/config"; +import { CLIError } from "../../utils/errors"; +import { getBuckets } from "../../utils/buckets"; +import { resolveOverriddenLocale } from "@lingo.dev/_spec"; + +export default new Command() + .command("files") + .description( + "Expand each bucket's path pattern into concrete source and target file paths", + ) + .option( + "--source", + "Only list the source locale variant for each path pattern", + ) + .option( + "--target", + "Only list the target locale variants for each configured locale", + ) + .helpOption("-h, --help", "Show help") + .action(async (type) => { + const ora = Ora(); + try { + try { + const i18nConfig = await getConfig(); + + if (!i18nConfig) { + throw new CLIError({ + message: + "i18n.json not found. Please run `lingo.dev init` to initialize the project.", + docUrl: "i18nNotFound", + }); + } + + const buckets = getBuckets(i18nConfig); + for (const bucket of buckets) { + for (const bucketConfig of bucket.paths) { + const sourceLocale = resolveOverriddenLocale( + i18nConfig.locale.source, + bucketConfig.delimiter, + ); + const sourcePath = bucketConfig.pathPattern.replace( + /\[locale\]/g, + sourceLocale, + ); + const targetPaths = i18nConfig.locale.targets.map( + (_targetLocale) => { + const targetLocale = resolveOverriddenLocale( + _targetLocale, + bucketConfig.delimiter, + ); + return bucketConfig.pathPattern.replace( + /\[locale\]/g, + targetLocale, + ); + }, + ); + + const result: string[] = []; + if (!type.source && !type.target) { + result.push(sourcePath, ...targetPaths); + } else if (type.source) { + result.push(sourcePath); + } else if (type.target) { + result.push(...targetPaths); + } + + result.forEach((path) => { + console.log(path); + }); + } + } + } catch (error: any) { + throw new CLIError({ + message: `Failed to expand placeholdered globs: ${error.message}`, + docUrl: "placeHolderFailed", + }); + } + } catch (error: any) { + ora.fail(error.message); + process.exit(1); + } + }); diff --git a/packages/cli/src/cli/cmd/show/ignored-keys.ts b/packages/cli/src/cli/cmd/show/ignored-keys.ts new file mode 100644 index 000000000..ed7b32631 --- /dev/null +++ b/packages/cli/src/cli/cmd/show/ignored-keys.ts @@ -0,0 +1,38 @@ +import { Command } from "interactive-commander"; +import Ora from "ora"; +import { getConfig } from "../../utils/config"; +import { CLIError } from "../../utils/errors"; +import { getBuckets } from "../../utils/buckets"; +import { executeKeyCommand } from "./_shared-key-command"; + +export default new Command() + .command("ignored-keys") + .description( + "Show which key-value pairs in source files match ignoredKeys patterns", + ) + .option("--bucket <name>", "Only show ignored keys for a specific bucket") + .helpOption("-h, --help", "Show help") + .action(async (options) => { + const ora = Ora(); + try { + const i18nConfig = await getConfig(); + + if (!i18nConfig) { + throw new CLIError({ + message: + "i18n.json not found. Please run `lingo.dev init` to initialize the project.", + docUrl: "i18nNotFound", + }); + } + + const buckets = getBuckets(i18nConfig); + + await executeKeyCommand(i18nConfig, buckets, options, { + filterType: "ignoredKeys", + displayName: "ignored", + }); + } catch (error: any) { + ora.fail(error.message); + process.exit(1); + } + }); diff --git a/packages/cli/src/cli/cmd/show/index.ts b/packages/cli/src/cli/cmd/show/index.ts new file mode 100644 index 000000000..5ade1d2fe --- /dev/null +++ b/packages/cli/src/cli/cmd/show/index.ts @@ -0,0 +1,17 @@ +import { Command } from "interactive-commander"; +import _ from "lodash"; +import configCmd from "./config"; +import localeCmd from "./locale"; +import filesCmd from "./files"; +import lockedKeysCmd from "./locked-keys"; +import ignoredKeysCmd from "./ignored-keys"; + +export default new Command() + .command("show") + .description("Display configuration, locales, and file paths") + .helpOption("-h, --help", "Show help") + .addCommand(configCmd) + .addCommand(localeCmd) + .addCommand(filesCmd) + .addCommand(lockedKeysCmd) + .addCommand(ignoredKeysCmd); diff --git a/packages/cli/src/cli/cmd/show/locale.ts b/packages/cli/src/cli/cmd/show/locale.ts new file mode 100644 index 000000000..de9d2ac7f --- /dev/null +++ b/packages/cli/src/cli/cmd/show/locale.ts @@ -0,0 +1,37 @@ +import { Command } from "interactive-commander"; +import _ from "lodash"; +import Z from "zod"; +import Ora from "ora"; +import { localeCodes } from "@lingo.dev/_spec"; +import { CLIError } from "../../utils/errors"; + +export default new Command() + .command("locale") + .description("List supported locale codes") + .helpOption("-h, --help", "Show help") + // argument can be equal either "sources" or "targets" + .argument( + "<type>", + 'Type of locales to show: "sources" or "targets" - both show the full supported locale list', + ) + .action(async (type) => { + const ora = Ora(); + try { + switch (type) { + default: + throw new CLIError({ + message: `Invalid type: ${type}`, + docUrl: "invalidType", + }); + case "sources": + localeCodes.forEach((locale) => console.log(locale)); + break; + case "targets": + localeCodes.forEach((locale) => console.log(locale)); + break; + } + } catch (error: any) { + ora.fail(error.message); + process.exit(1); + } + }); diff --git a/packages/cli/src/cli/cmd/show/locked-keys.ts b/packages/cli/src/cli/cmd/show/locked-keys.ts new file mode 100644 index 000000000..c1ff62d97 --- /dev/null +++ b/packages/cli/src/cli/cmd/show/locked-keys.ts @@ -0,0 +1,38 @@ +import { Command } from "interactive-commander"; +import Ora from "ora"; +import { getConfig } from "../../utils/config"; +import { CLIError } from "../../utils/errors"; +import { getBuckets } from "../../utils/buckets"; +import { executeKeyCommand } from "./_shared-key-command"; + +export default new Command() + .command("locked-keys") + .description( + "Show which key-value pairs in source files match lockedKeys patterns", + ) + .option("--bucket <name>", "Only show locked keys for a specific bucket") + .helpOption("-h, --help", "Show help") + .action(async (options) => { + const ora = Ora(); + try { + const i18nConfig = await getConfig(); + + if (!i18nConfig) { + throw new CLIError({ + message: + "i18n.json not found. Please run `lingo.dev init` to initialize the project.", + docUrl: "i18nNotFound", + }); + } + + const buckets = getBuckets(i18nConfig); + + await executeKeyCommand(i18nConfig, buckets, options, { + filterType: "lockedKeys", + displayName: "locked", + }); + } catch (error: any) { + ora.fail(error.message); + process.exit(1); + } + }); diff --git a/packages/cli/src/cli/cmd/status.ts b/packages/cli/src/cli/cmd/status.ts new file mode 100644 index 000000000..452f22538 --- /dev/null +++ b/packages/cli/src/cli/cmd/status.ts @@ -0,0 +1,711 @@ +import { + bucketTypeSchema, + I18nConfig, + localeCodeSchema, + resolveOverriddenLocale, +} from "@lingo.dev/_spec"; +import { Command } from "interactive-commander"; +import Z from "zod"; +import _ from "lodash"; +import * as path from "path"; +import { getConfigOrThrow } from "../utils/config"; +import { getSettings } from "../utils/settings"; +import { CLIError } from "../utils/errors"; +import Ora from "ora"; +import createBucketLoader from "../loaders"; +import { createAuthenticator } from "../utils/auth"; +import { getBuckets } from "../utils/buckets"; +import chalk from "chalk"; +import Table from "cli-table3"; +import { createDeltaProcessor } from "../utils/delta"; +import trackEvent from "../utils/observability"; +import { minimatch } from "minimatch"; +import { exitGracefully } from "../utils/exit-gracefully"; + +// Define types for our language stats +interface LanguageStats { + complete: number; + missing: number; + updated: number; + words: number; +} + +export default new Command() + .command("status") + .description("Show the status of the localization process") + .helpOption("-h, --help", "Show help") + .option( + "--locale <locale>", + "Limit the report to specific target locales from i18n.json. Repeat the flag to include multiple locales. Defaults to all configured target locales", + (val: string, prev: string[]) => (prev ? [...prev, val] : [val]), + ) + .option( + "--bucket <bucket>", + "Limit the report to specific bucket types defined in i18n.json (e.g., json, yaml, android). Repeat the flag to include multiple bucket types. Defaults to all buckets", + (val: string, prev: string[]) => (prev ? [...prev, val] : [val]), + ) + .option( + "--file [files...]", + "Filter the status report to only include files whose paths contain these substrings. Example: 'components' to match any file path containing 'components'", + ) + .option( + "--force", + "Force all keys to be counted as needing translation, bypassing change detection. Shows word estimates for a complete retranslation regardless of current translation status", + ) + .option( + "--verbose", + "Print detailed output showing missing and updated key counts with example key names for each file and locale", + ) + .option( + "--api-key <api-key>", + "Override the API key from settings or environment variables for this run", + ) + .action(async function (options) { + const ora = Ora(); + const flags = parseFlags(options); + let email: string | null = null; + + try { + ora.start("Loading configuration..."); + const i18nConfig = getConfigOrThrow(); + const settings = getSettings(flags.apiKey); + ora.succeed("Configuration loaded"); + + // Try to authenticate, but continue even if not authenticated + try { + ora.start("Checking authentication status..."); + const auth = await tryAuthenticate(settings); + if (auth) { + email = auth.email; + ora.succeed(`Authenticated as ${auth.email}`); + } else { + ora.info( + "Not authenticated. Continuing without authentication. (Run `lingo.dev login` to authenticate)", + ); + } + } catch (error) { + ora.info("Authentication failed. Continuing without authentication."); + } + + ora.start("Validating localization configuration..."); + validateParams(i18nConfig, flags); + ora.succeed("Localization configuration is valid"); + + // Track event with or without authentication + trackEvent(email, "cmd.status.start", { + i18nConfig, + flags, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + let buckets = getBuckets(i18nConfig!); + if (flags.bucket?.length) { + buckets = buckets.filter((bucket: any) => + flags.bucket!.includes(bucket.type), + ); + } + ora.succeed("Buckets retrieved"); + + if (flags.file?.length) { + buckets = buckets + .map((bucket: any) => { + const paths = bucket.paths.filter((path: any) => + flags.file!.find( + (file) => + path.pathPattern?.includes(file) || + path.pathPattern?.match(file) || + minimatch(path.pathPattern, file), + ), + ); + return { ...bucket, paths }; + }) + .filter((bucket: any) => bucket.paths.length > 0); + if (buckets.length === 0) { + ora.fail( + "No buckets found. All buckets were filtered out by --file option.", + ); + process.exit(1); + } else { + ora.info(`\x1b[36mProcessing only filtered buckets:\x1b[0m`); + buckets.map((bucket: any) => { + ora.info(` ${bucket.type}:`); + bucket.paths.forEach((path: any) => { + ora.info(` - ${path.pathPattern}`); + }); + }); + } + } + + const targetLocales = flags.locale?.length + ? flags.locale + : i18nConfig!.locale.targets; + + // Global stats + let totalSourceKeyCount = 0; + let uniqueKeysToTranslate = 0; + let totalExistingTranslations = 0; + const totalWordCount = new Map<string, number>(); // Words per language + const languageStats: Record<string, LanguageStats> = {}; + + // Initialize per-language stats + for (const locale of targetLocales) { + languageStats[locale] = { + complete: 0, + missing: 0, + updated: 0, + words: 0, + }; + totalWordCount.set(locale, 0); + } + + // Per-file stats + const fileStats: Record< + string, + { + path: string; + sourceKeys: number; + wordCount: number; + languageStats: Record< + string, + { + complete: number; + missing: number; + updated: number; + words: number; + } + >; + } + > = {}; + + // Process each bucket + for (const bucket of buckets) { + try { + console.log(); + ora.info(`Analyzing bucket: ${bucket.type}`); + + for (const bucketPath of bucket.paths) { + const bucketOra = Ora({ indent: 2 }).info( + `Analyzing path: ${bucketPath.pathPattern}`, + ); + + const sourceLocale = resolveOverriddenLocale( + i18nConfig!.locale.source, + bucketPath.delimiter, + ); + const bucketLoader = createBucketLoader( + bucket.type, + bucketPath.pathPattern, + { + defaultLocale: sourceLocale, + injectLocale: bucket.injectLocale, + formatter: i18nConfig!.formatter, + }, + bucket.lockedKeys, + bucket.lockedPatterns, + bucket.ignoredKeys, + ); + + bucketLoader.setDefaultLocale(sourceLocale); + await bucketLoader.init(); + + // Initialize file stats + const filePath = bucketPath.pathPattern; + if (!fileStats[filePath]) { + fileStats[filePath] = { + path: filePath, + sourceKeys: 0, + wordCount: 0, + languageStats: {}, + }; + + for (const locale of targetLocales) { + fileStats[filePath].languageStats[locale] = { + complete: 0, + missing: 0, + updated: 0, + words: 0, + }; + } + } + + // Get source data and count source keys + const sourceData = await bucketLoader.pull(sourceLocale); + const sourceKeys = Object.keys(sourceData); + fileStats[filePath].sourceKeys = sourceKeys.length; + totalSourceKeyCount += sourceKeys.length; + + // Calculate source word count + let sourceWordCount = 0; + for (const key of sourceKeys) { + const value = sourceData[key]; + if (typeof value === "string") { + const words = value.trim().split(/\s+/).length; + sourceWordCount += words; + } + } + fileStats[filePath].wordCount = sourceWordCount; + + // Process each target locale + for (const _targetLocale of targetLocales) { + const targetLocale = resolveOverriddenLocale( + _targetLocale, + bucketPath.delimiter, + ); + bucketOra.start( + `[${sourceLocale} -> ${targetLocale}] Analyzing translation status...`, + ); + + let targetData = {}; + let fileExists = true; + + try { + targetData = await bucketLoader.pull(targetLocale); + } catch (error) { + fileExists = false; + bucketOra.info( + `[${sourceLocale} -> ${targetLocale}] Target file not found, assuming all keys need translation.`, + ); + } + + if (!fileExists) { + // All keys are missing for this locale + fileStats[filePath].languageStats[_targetLocale].missing = + sourceKeys.length; + fileStats[filePath].languageStats[_targetLocale].words = + sourceWordCount; + languageStats[_targetLocale].missing += sourceKeys.length; + languageStats[_targetLocale].words += sourceWordCount; + totalWordCount.set( + _targetLocale, + (totalWordCount.get(_targetLocale) || 0) + sourceWordCount, + ); + + bucketOra.succeed( + `[${sourceLocale} -> ${targetLocale}] ${chalk.red( + `0% complete`, + )} (0/${sourceKeys.length} keys) - file not found`, + ); + continue; + } + + // Calculate delta for existing file + const deltaProcessor = createDeltaProcessor( + bucketPath.pathPattern, + ); + const checksums = await deltaProcessor.loadChecksums(); + const delta = await deltaProcessor.calculateDelta({ + sourceData, + targetData, + checksums, + }); + + const missingKeys = delta.added; + const updatedKeys = delta.updated; + const completeKeys = sourceKeys.filter( + (key) => + !missingKeys.includes(key) && !updatedKeys.includes(key), + ); + + // Count words that need translation + let wordsToTranslate = 0; + const keysToProcess = flags.force + ? sourceKeys + : [...missingKeys, ...updatedKeys]; + + for (const key of keysToProcess) { + const value = sourceData[String(key)]; + if (typeof value === "string") { + const words = value.trim().split(/\s+/).length; + wordsToTranslate += words; + } + } + + // Update file stats + fileStats[filePath].languageStats[_targetLocale].missing = + missingKeys.length; + fileStats[filePath].languageStats[_targetLocale].updated = + updatedKeys.length; + fileStats[filePath].languageStats[_targetLocale].complete = + completeKeys.length; + fileStats[filePath].languageStats[_targetLocale].words = + wordsToTranslate; + + // Update global stats + languageStats[_targetLocale].missing += missingKeys.length; + languageStats[_targetLocale].updated += updatedKeys.length; + languageStats[_targetLocale].complete += completeKeys.length; + languageStats[_targetLocale].words += wordsToTranslate; + totalWordCount.set( + _targetLocale, + (totalWordCount.get(_targetLocale) || 0) + wordsToTranslate, + ); + + // Display progress + const totalKeysInFile = sourceKeys.length; + const completionPercent = ( + (completeKeys.length / totalKeysInFile) * + 100 + ).toFixed(1); + + if (missingKeys.length === 0 && updatedKeys.length === 0) { + bucketOra.succeed( + `[${sourceLocale} -> ${targetLocale}] ${chalk.green( + `100% complete`, + )} (${completeKeys.length}/${totalKeysInFile} keys)`, + ); + } else { + const message = `[${sourceLocale} -> ${targetLocale}] ${ + parseFloat(completionPercent) > 50 + ? chalk.yellow(`${completionPercent}% complete`) + : chalk.red(`${completionPercent}% complete`) + } (${completeKeys.length}/${totalKeysInFile} keys)`; + + bucketOra.succeed(message); + + if (flags.verbose) { + if (missingKeys.length > 0) { + console.log( + ` ${chalk.red(`Missing:`)} ${missingKeys.length} keys, ~${wordsToTranslate} words`, + ); + console.log( + ` ${chalk.red(`Missing:`)} ${ + missingKeys.length + } keys, ~${wordsToTranslate} words`, + ); + console.log( + ` ${chalk.dim( + `Example missing: ${missingKeys + .slice(0, 2) + .join(", ")}${missingKeys.length > 2 ? "..." : ""}`, + )}`, + ); + } + if (updatedKeys.length > 0) { + console.log( + ` ${chalk.yellow(`Updated:`)} ${ + updatedKeys.length + } keys that changed in source`, + ); + } + } + } + } + } + } catch (error: any) { + ora.fail(`Failed to analyze bucket ${bucket.type}: ${error.message}`); + } + } + + // Calculate unique keys needing translation and keys fully translated + // Count unique keys that need translation + const totalKeysNeedingTranslation = Object.values(languageStats).reduce( + (sum, stats) => { + return sum + stats.missing + stats.updated; + }, + 0, + ); + + // Calculate keys that are completely translated + const totalCompletedKeys = + totalSourceKeyCount - + totalKeysNeedingTranslation / targetLocales.length; + + // Summary output + console.log(); + ora.succeed(chalk.green(`Localization status completed.`)); + + // Create a visually impactful main header + console.log(chalk.bold.cyan(`\n╔════════════════════════════════════╗`)); + console.log(chalk.bold.cyan(`║ LOCALIZATION STATUS REPORT ║`)); + console.log(chalk.bold.cyan(`╚════════════════════════════════════╝`)); + + // Source content overview + console.log(chalk.bold(`\n📝 SOURCE CONTENT:`)); + console.log( + `• Source language: ${chalk.green(i18nConfig!.locale.source)}`, + ); + console.log( + `• Source keys: ${chalk.yellow( + totalSourceKeyCount.toString(), + )} keys across all files`, + ); + + // Create a language-by-language breakdown table + console.log(chalk.bold(`\n🌐 LANGUAGE BY LANGUAGE BREAKDOWN:`)); + + // Create a new table instance with cli-table3 + const table = new Table({ + head: [ + "Language", + "Status", + "Complete", + "Missing", + "Updated", + "Total Keys", + "Words to Translate", + ], + style: { + head: ["white"], // White color for headers + border: [], // No color for borders + }, + colWidths: [12, 20, 18, 12, 12, 12, 15], // Explicit column widths, making Status column wider + }); + + // Data rows + let totalWordsToTranslate = 0; + for (const locale of targetLocales) { + const stats = languageStats[locale]; + const percentComplete = ( + (stats.complete / totalSourceKeyCount) * + 100 + ).toFixed(1); + const totalNeeded = stats.missing + stats.updated; + + // Determine status text and color + let statusText; + let statusColor; + if (stats.missing === totalSourceKeyCount) { + statusText = "🔴 Not started"; + statusColor = chalk.red; + } else if (stats.missing === 0 && stats.updated === 0) { + statusText = "✅ Complete"; + statusColor = chalk.green; + } else if (parseFloat(percentComplete) > 80) { + statusText = "🟡 Almost done"; + statusColor = chalk.yellow; + } else if (parseFloat(percentComplete) > 0) { + statusText = "🟠 In progress"; + statusColor = chalk.yellow; + } else { + statusText = "🔴 Not started"; + statusColor = chalk.red; + } + + // Create row data + const words = totalWordCount.get(locale) || 0; + totalWordsToTranslate += words; + + // Add row to the table + table.push([ + locale, + statusColor(statusText), + `${stats.complete}/${totalSourceKeyCount} (${percentComplete}%)`, + stats.missing > 0 ? chalk.red(stats.missing.toString()) : "0", + stats.updated > 0 ? chalk.yellow(stats.updated.toString()) : "0", + totalNeeded > 0 ? chalk.magenta(totalNeeded.toString()) : "0", + words > 0 ? `~${words.toLocaleString()}` : "0", + ]); + } + + // Display the table + console.log(table.toString()); + + // Total usage summary + console.log(chalk.bold(`\n📊 USAGE ESTIMATE:`)); + console.log( + `• WORDS TO BE CONSUMED: ~${chalk.yellow.bold( + totalWordsToTranslate.toLocaleString(), + )} words across all languages`, + ); + console.log( + ` (Words are counted from source language for keys that need translation in target languages)`, + ); + + // Breakdown by language if we have multiple languages + if (targetLocales.length > 1) { + console.log(`• Per-language breakdown:`); + for (const locale of targetLocales) { + const words = totalWordCount.get(locale) || 0; + const percent = + totalWordsToTranslate > 0 + ? ((words / totalWordsToTranslate) * 100).toFixed(1) + : "0.0"; + console.log( + ` - ${locale}: ~${words.toLocaleString()} words (${percent}% of total)`, + ); + } + } + + // Detailed stats if flags.confirm is specified + if (flags.confirm && Object.keys(fileStats).length > 0) { + console.log(chalk.bold(`\n📑 BREAKDOWN BY FILE:`)); + + Object.entries(fileStats) + .sort((a, b) => b[1].wordCount - a[1].wordCount) // Sort by word count + .forEach(([path, stats]) => { + // Skip files with no source keys + if (stats.sourceKeys === 0) return; + + console.log(chalk.bold(`\n• ${path}:`)); + console.log( + ` ${ + stats.sourceKeys + } source keys, ~${stats.wordCount.toLocaleString()} source words`, + ); + + // Create file detail table + const fileTable = new Table({ + head: ["Language", "Status", "Details"], + style: { + head: ["white"], + border: [], + }, + colWidths: [12, 20, 50], // Explicit column widths for file detail table + }); + + for (const locale of targetLocales) { + const langStats = stats.languageStats[locale]; + const complete = langStats.complete; + const total = stats.sourceKeys; + const completion = ((complete / total) * 100).toFixed(1); + + let status = "✅ Complete"; + let statusColor = chalk.green; + + if (langStats.missing === total) { + status = "❌ Not started"; + statusColor = chalk.red; + } else if (langStats.missing > 0 || langStats.updated > 0) { + status = `⚠️ ${completion}% complete`; + statusColor = chalk.yellow; + } + + // Show counts only if there's something missing or updated + let details = ""; + if (langStats.missing > 0 || langStats.updated > 0) { + const parts = []; + if (langStats.missing > 0) + parts.push(`${langStats.missing} missing`); + if (langStats.updated > 0) + parts.push(`${langStats.updated} changed`); + details = `${parts.join(", ")}, ~${langStats.words} words`; + } else { + details = "All keys translated"; + } + + fileTable.push([locale, statusColor(status), details]); + } + + console.log(fileTable.toString()); + }); + } + + // Find fully translated and missing languages + const completeLanguages = targetLocales.filter( + (locale) => + languageStats[locale].missing === 0 && + languageStats[locale].updated === 0, + ); + + const missingLanguages = targetLocales.filter( + (locale) => languageStats[locale].complete === 0, + ); + + // Add optimization tips + console.log(chalk.bold.green(`\n💡 OPTIMIZATION TIPS:`)); + + if (missingLanguages.length > 0) { + console.log( + `• ${chalk.yellow(missingLanguages.join(", "))} ${ + missingLanguages.length === 1 ? "has" : "have" + } no translations yet`, + ); + } + + if (completeLanguages.length > 0) { + console.log( + `• ${chalk.green(completeLanguages.join(", "))} ${ + completeLanguages.length === 1 ? "is" : "are" + } completely translated`, + ); + } + + // Other tips + if (targetLocales.length > 1) { + console.log(`• Translating one language at a time reduces complexity`); + console.log( + `• Try 'lingo.dev@latest i18n --locale ${targetLocales[0]}' to process just one language`, + ); + } + + // Track successful completion + trackEvent(email, "cmd.status.success", { + i18nConfig, + flags, + totalSourceKeyCount, + languageStats, + totalWordsToTranslate, + authenticated: !!email, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + exitGracefully(); + } catch (error: any) { + ora.fail(error.message); + trackEvent(email, "cmd.status.error", { + flags, + error: error.message, + authenticated: !!email, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + process.exit(1); + } + }); + +function parseFlags(options: any) { + return Z.object({ + locale: Z.array(localeCodeSchema).optional(), + bucket: Z.array(bucketTypeSchema).optional(), + force: Z.boolean().optional(), + confirm: Z.boolean().optional(), + verbose: Z.boolean().optional(), + file: Z.array(Z.string()).optional(), + apiKey: Z.string().optional(), + }).parse(options); +} + +async function tryAuthenticate(settings: ReturnType<typeof getSettings>) { + if (!settings.auth.apiKey) { + return null; + } + + try { + const authenticator = createAuthenticator({ + apiKey: settings.auth.apiKey, + apiUrl: settings.auth.apiUrl, + }); + const user = await authenticator.whoami(); + return user; + } catch (error) { + return null; + } +} + +function validateParams( + i18nConfig: I18nConfig, + flags: ReturnType<typeof parseFlags>, +) { + if (!i18nConfig.buckets || !Object.keys(i18nConfig.buckets).length) { + throw new CLIError({ + message: + "No buckets found in i18n.json. Please add at least one bucket containing i18n content.", + docUrl: "bucketNotFound", + }); + } else if ( + flags.locale?.some((locale) => !i18nConfig.locale.targets.includes(locale)) + ) { + throw new CLIError({ + message: `One or more specified locales do not exist in i18n.json locale.targets. Please add them to the list and try again.`, + docUrl: "localeTargetNotFound", + }); + } else if ( + flags.bucket?.some( + (bucket) => + !i18nConfig.buckets[bucket as keyof typeof i18nConfig.buckets], + ) + ) { + throw new CLIError({ + message: `One or more specified buckets do not exist in i18n.json. Please add them to the list and try again.`, + docUrl: "bucketNotFound", + }); + } +} diff --git a/packages/cli/src/cli/constants.ts b/packages/cli/src/cli/constants.ts new file mode 100644 index 000000000..4308a3754 --- /dev/null +++ b/packages/cli/src/cli/constants.ts @@ -0,0 +1,9 @@ +export const colors = { + orange: "#ff6600", + green: "#6ae300", + blue: "#0090ff", + yellow: "#ffcc00", + grey: "#808080", + red: "#ff0000", + white: "#ffffff", +}; diff --git a/packages/cli/src/cli/index.spec.ts b/packages/cli/src/cli/index.spec.ts new file mode 100644 index 000000000..a9a5ca85f --- /dev/null +++ b/packages/cli/src/cli/index.spec.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from "vitest"; + +describe("lingo.dev", () => { + it("should work", () => { + expect(true).toBe(true); + }); +}); diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts new file mode 100644 index 000000000..151b5c6b7 --- /dev/null +++ b/packages/cli/src/cli/index.ts @@ -0,0 +1,75 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import { InteractiveCommand } from "interactive-commander"; +import figlet from "figlet"; +import { vice } from "gradient-string"; + +import authCmd from "./cmd/auth"; +import loginCmd from "./cmd/login"; +import logoutCmd from "./cmd/logout"; +import initCmd from "./cmd/init"; +import showCmd from "./cmd/show"; +import configCmd from "./cmd/config"; +import i18nCmd from "./cmd/i18n"; +import lockfileCmd from "./cmd/lockfile"; +import cleanupCmd from "./cmd/cleanup"; +import mcpCmd from "./cmd/mcp"; +import ciCmd from "./cmd/ci"; +import statusCmd from "./cmd/status"; +import mayTheFourthCmd from "./cmd/may-the-fourth"; +import packageJson from "../../package.json"; +import run from "./cmd/run"; +import purgeCmd from "./cmd/purge"; + +export default new InteractiveCommand() + .name("lingo.dev") + .description("Lingo.dev CLI") + .helpOption("-h, --help", "Show help") + .addHelpText( + "beforeAll", + ` +${vice( + figlet.textSync("LINGO.DEV", { + font: "ANSI Shadow", + horizontalLayout: "default", + verticalLayout: "default", + }), +)} + +⚡️ AI-powered open-source CLI for web & mobile localization. + +Star the the repo :) https://github.com/LingoDotDev/lingo.dev +`, + ) + .version(`v${packageJson.version}`, "-v, --version", "Show version") + .addCommand(initCmd) + .interactive( + "-y, --no-interactive", + "Run every command in non-interactive mode (no prompts); required when scripting", + ) // all interactive commands above + .addCommand(i18nCmd) + .addCommand(authCmd) + .addCommand(loginCmd) + .addCommand(logoutCmd) + .addCommand(showCmd) + .addCommand(configCmd) + .addCommand(lockfileCmd) + .addCommand(cleanupCmd) + .addCommand(mcpCmd) + .addCommand(ciCmd) + .addCommand(statusCmd) + .addCommand(mayTheFourthCmd, { hidden: true }) + .addCommand(run) + .addCommand(purgeCmd) + .exitOverride((err) => { + // Exit with code 0 when help or version is displayed + if ( + err.code === "commander.helpDisplayed" || + err.code === "commander.version" || + err.code === "commander.help" + ) { + process.exit(0); + } + process.exit(1); + }); diff --git a/packages/cli/src/cli/loaders/_types.ts b/packages/cli/src/cli/loaders/_types.ts new file mode 100644 index 000000000..d43ac3d0e --- /dev/null +++ b/packages/cli/src/cli/loaders/_types.ts @@ -0,0 +1,27 @@ +export interface ILoaderDefinition<I, O, C> { + init?(): Promise<C>; + pull( + locale: string, + input: I, + initCtx: C, + originalLocale: string, + originalInput: I, + ): Promise<O>; + push( + locale: string, + data: O, + originalInput: I | null, + originalLocale: string, + pullInput: I | null, + pullOutput: O | null, + ): Promise<I>; + pullHints?(originalInput: I): Promise<O | undefined>; +} + +export interface ILoader<I, O, C = void> extends ILoaderDefinition<I, O, C> { + setDefaultLocale(locale: string): this; + init(): Promise<C>; + pull(locale: string, input: I): Promise<O>; + push(locale: string, data: O): Promise<I>; + pullHints(originalInput?: I): Promise<O | undefined>; +} diff --git a/packages/cli/src/cli/loaders/_utils.ts b/packages/cli/src/cli/loaders/_utils.ts new file mode 100644 index 000000000..496a8f6ab --- /dev/null +++ b/packages/cli/src/cli/loaders/_utils.ts @@ -0,0 +1,129 @@ +import { ILoader, ILoaderDefinition } from "./_types"; + +export function composeLoaders( + ...loaders: ILoader<any, any, any>[] +): ILoader<any, any> { + return { + init: async () => { + for (const loader of loaders) { + await loader.init?.(); + } + }, + setDefaultLocale(locale: string) { + for (const loader of loaders) { + loader.setDefaultLocale?.(locale); + } + return this; + }, + pull: async (locale, input) => { + let result: any = input; + for (let i = 0; i < loaders.length; i++) { + result = await loaders[i].pull(locale, result); + } + return result; + }, + push: async (locale, data) => { + let result: any = data; + for (let i = loaders.length - 1; i >= 0; i--) { + result = await loaders[i].push(locale, result); + } + return result; + }, + pullHints: async (originalInput?) => { + let result: any = originalInput; + for (let i = 0; i < loaders.length; i++) { + const subResult = await loaders[i].pullHints?.(result); + if (subResult) { + result = subResult; + } + } + return result; + }, + }; +} + +export function createLoader<I, O, C>( + lDefinition: ILoaderDefinition<I, O, C>, +): ILoader<I, O, C> { + const state = { + defaultLocale: undefined as string | undefined, + originalInput: undefined as I | undefined | null, + // Store pullInput and pullOutput per-locale to avoid race conditions + // when multiple locales are processed concurrently + pullInputByLocale: new Map<string, I | null>(), + pullOutputByLocale: new Map<string, O | null>(), + initCtx: undefined as C | undefined, + }; + return { + async init() { + if (state.initCtx) { + return state.initCtx; + } + state.initCtx = await lDefinition.init?.(); + return state.initCtx as C; + }, + setDefaultLocale(locale) { + if (state.defaultLocale) { + throw new Error("Default locale already set"); + } + state.defaultLocale = locale; + return this; + }, + async pullHints(originalInput?: I) { + return lDefinition.pullHints?.(originalInput || state.originalInput!); + }, + async pull(locale, input) { + if (!state.defaultLocale) { + throw new Error("Default locale not set"); + } + if (state.originalInput === undefined && locale !== state.defaultLocale) { + throw new Error("The first pull must be for the default locale"); + } + if (locale === state.defaultLocale) { + state.originalInput = input || null; + } + + state.pullInputByLocale.set(locale, input || null); + const result = await lDefinition.pull( + locale, + input, + state.initCtx!, + state.defaultLocale, + state.originalInput!, + ); + state.pullOutputByLocale.set(locale, result); + + return result; + }, + async push(locale, data) { + if (!state.defaultLocale) { + throw new Error("Default locale not set"); + } + if (state.originalInput === undefined) { + throw new Error("Cannot push data without pulling first"); + } + + // Use locale-specific pullInput/pullOutput if available, + // otherwise fall back to the default locale's values for backward compatibility + // (some loaders push for locales that were never explicitly pulled) + const pullInput = + state.pullInputByLocale.get(locale) ?? + state.pullInputByLocale.get(state.defaultLocale) ?? + null; + const pullOutput = + state.pullOutputByLocale.get(locale) ?? + state.pullOutputByLocale.get(state.defaultLocale) ?? + null; + + const pushResult = await lDefinition.push( + locale, + data, + state.originalInput, + state.defaultLocale, + pullInput!, + pullOutput!, + ); + return pushResult; + }, + }; +} diff --git a/packages/cli/src/cli/loaders/ail.spec.ts b/packages/cli/src/cli/loaders/ail.spec.ts new file mode 100644 index 000000000..e8202afca --- /dev/null +++ b/packages/cli/src/cli/loaders/ail.spec.ts @@ -0,0 +1,393 @@ +import { describe, test, expect } from "vitest"; +import createAilLoader from "./ail"; + +describe("ail loader", () => { + test("should extract single entry", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const input = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="en" value="Welcome to Setup"/> + </ENTRY> +</DICTIONARY>`; + + const result = await loader.pull("en", input); + expect(result["Control.Text.WelcomeDlg#Title"]).toBe("Welcome to Setup"); + }); + + test("should extract multiple entries", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const input = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="en" value="Welcome to Setup"/> + </ENTRY> + <ENTRY id="Property.ProductName"> + <STRING lang="en" value="My Application"/> + </ENTRY> + <ENTRY id="Control.Text.InstallDlg#NextButton"> + <STRING lang="en" value="Install"/> + </ENTRY> +</DICTIONARY>`; + + const result = await loader.pull("en", input); + expect(result["Control.Text.WelcomeDlg#Title"]).toBe("Welcome to Setup"); + expect(result["Property.ProductName"]).toBe("My Application"); + expect(result["Control.Text.InstallDlg#NextButton"]).toBe("Install"); + }); + + test("should extract only specified locale", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const input = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="en" value="Welcome to Setup"/> + <STRING lang="de" value="Willkommen"/> + <STRING lang="fr" value="Bienvenue"/> + </ENTRY> +</DICTIONARY>`; + + const result = await loader.pull("en", input); + expect(result["Control.Text.WelcomeDlg#Title"]).toBe("Welcome to Setup"); + expect(Object.keys(result).length).toBe(1); + }); + + test("should extract German locale", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("de"); + const input = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="en" value="Welcome to Setup"/> + <STRING lang="de" value="Willkommen"/> + </ENTRY> +</DICTIONARY>`; + + const result = await loader.pull("de", input); + expect(result["Control.Text.WelcomeDlg#Title"]).toBe("Willkommen"); + }); + + test("should skip entries without matching locale", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const input = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="de" value="Willkommen"/> + <STRING lang="fr" value="Bienvenue"/> + </ENTRY> + <ENTRY id="Property.ProductName"> + <STRING lang="en" value="My Application"/> + </ENTRY> +</DICTIONARY>`; + + const result = await loader.pull("en", input); + expect(result["Control.Text.WelcomeDlg#Title"]).toBeUndefined(); + expect(result["Property.ProductName"]).toBe("My Application"); + }); + + test("should skip entries without id attribute", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const input = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY> + <STRING lang="en" value="No ID"/> + </ENTRY> + <ENTRY id="Property.ProductName"> + <STRING lang="en" value="My Application"/> + </ENTRY> +</DICTIONARY>`; + + const result = await loader.pull("en", input); + expect(Object.keys(result).length).toBe(1); + expect(result["Property.ProductName"]).toBe("My Application"); + }); + + test("should handle empty DICTIONARY", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const input = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> +</DICTIONARY>`; + + const result = await loader.pull("en", input); + expect(Object.keys(result).length).toBe(0); + }); + + test("should handle empty input", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const result = await loader.pull("en", ""); + expect(Object.keys(result).length).toBe(0); + }); + + test("should handle special characters in values", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const input = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Message.Warning"> + <STRING lang="en" value="Error: "File not found" & other issues"/> + </ENTRY> +</DICTIONARY>`; + + const result = await loader.pull("en", input); + expect(result["Message.Warning"]).toBe('Error: "File not found" & other issues'); + }); + + test("should push translation to new locale", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const originalInput = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="en" value="Welcome to Setup"/> + </ENTRY> +</DICTIONARY>`; + + await loader.pull("en", originalInput); + + const translations = { + "Control.Text.WelcomeDlg#Title": "Willkommen", + }; + + const output = await loader.push("de", translations, originalInput); + + expect(output).toContain('lang="en"'); + expect(output).toContain('value="Welcome to Setup"'); + expect(output).toContain('lang="de"'); + expect(output).toContain('value="Willkommen"'); + }); + + test("should update existing locale translation", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const originalInput = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="en" value="Welcome to Setup"/> + <STRING lang="de" value="Old Translation"/> + </ENTRY> +</DICTIONARY>`; + + await loader.pull("en", originalInput); + + const translations = { + "Control.Text.WelcomeDlg#Title": "Willkommen", + }; + + const output = await loader.push("de", translations, originalInput); + + expect(output).toContain('lang="de"'); + expect(output).toContain('value="Willkommen"'); + expect(output).not.toContain("Old Translation"); + }); + + test("should preserve other locales when pushing", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const originalInput = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="en" value="Welcome to Setup"/> + <STRING lang="fr" value="Bienvenue"/> + </ENTRY> +</DICTIONARY>`; + + await loader.pull("en", originalInput); + + const translations = { + "Control.Text.WelcomeDlg#Title": "Willkommen", + }; + + const output = await loader.push("de", translations, originalInput); + + expect(output).toContain('lang="en"'); + expect(output).toContain('lang="fr"'); + expect(output).toContain('value="Bienvenue"'); + expect(output).toContain('lang="de"'); + expect(output).toContain('value="Willkommen"'); + }); + + test("should add new entry when pushing new key", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const originalInput = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="en" value="Welcome to Setup"/> + </ENTRY> +</DICTIONARY>`; + + await loader.pull("en", originalInput); + + const translations = { + "Control.Text.WelcomeDlg#Title": "Welcome to Setup", + "Property.ProductName": "My Application", + }; + + const output = await loader.push("en", translations, originalInput); + + expect(output).toContain('id="Control.Text.WelcomeDlg#Title"'); + expect(output).toContain('id="Property.ProductName"'); + expect(output).toContain('value="My Application"'); + }); + + test("should preserve DICTIONARY attributes", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const originalInput = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage" ignore="de"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="en" value="Welcome to Setup"/> + </ENTRY> +</DICTIONARY>`; + + await loader.pull("en", originalInput); + + const translations = { + "Control.Text.WelcomeDlg#Title": "Willkommen", + }; + + const output = await loader.push("de", translations, originalInput); + + expect(output).toContain('type="multilanguage"'); + expect(output).toContain('ignore="de"'); + }); + + test("should handle multiple translations", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const originalInput = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="en" value="Welcome to Setup"/> + </ENTRY> + <ENTRY id="Property.ProductName"> + <STRING lang="en" value="My Application"/> + </ENTRY> + <ENTRY id="Control.Text.InstallDlg#NextButton"> + <STRING lang="en" value="Install"/> + </ENTRY> +</DICTIONARY>`; + + await loader.pull("en", originalInput); + + const translations = { + "Control.Text.WelcomeDlg#Title": "Willkommen", + "Property.ProductName": "Meine Anwendung", + "Control.Text.InstallDlg#NextButton": "Installieren", + }; + + const output = await loader.push("de", translations, originalInput); + + expect(output).toContain('value="Willkommen"'); + expect(output).toContain('value="Meine Anwendung"'); + expect(output).toContain('value="Installieren"'); + }); + + test("should create new AIL file from scratch", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + + // Pull from empty input first + await loader.pull("en", ""); + + const translations = { + "Control.Text.WelcomeDlg#Title": "Welcome to Setup", + "Property.ProductName": "My Application", + }; + + const output = await loader.push("en", translations, ""); + + expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>'); + expect(output).toContain('<DICTIONARY'); + expect(output).toContain('type="multilanguage"'); + expect(output).toContain('id="Control.Text.WelcomeDlg#Title"'); + expect(output).toContain('id="Property.ProductName"'); + expect(output).toContain('lang="en"'); + expect(output).toContain('value="Welcome to Setup"'); + expect(output).toContain('value="My Application"'); + }); + + test("should handle entries with empty values", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const input = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.EmptyValue"> + <STRING lang="en" value=""/> + </ENTRY> + <ENTRY id="Property.ProductName"> + <STRING lang="en" value="My Application"/> + </ENTRY> +</DICTIONARY>`; + + const result = await loader.pull("en", input); + // Empty values should not be extracted + expect(result["Control.Text.EmptyValue"]).toBeUndefined(); + expect(result["Property.ProductName"]).toBe("My Application"); + }); + + test("should handle property style IDs", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const input = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Property.ARPCOMMENTS"> + <STRING lang="en" value="Application Comments"/> + </ENTRY> + <ENTRY id="Property.ARPCONTACT"> + <STRING lang="en" value="Support Contact"/> + </ENTRY> +</DICTIONARY>`; + + const result = await loader.pull("en", input); + expect(result["Property.ARPCOMMENTS"]).toBe("Application Comments"); + expect(result["Property.ARPCONTACT"]).toBe("Support Contact"); + }); + + test("should handle control style IDs", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const input = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Control.Text.WelcomeDlg#Title"> + <STRING lang="en" value="Welcome"/> + </ENTRY> + <ENTRY id="Control.Text.WelcomeDlg#Description"> + <STRING lang="en" value="Setup will guide you"/> + </ENTRY> +</DICTIONARY>`; + + const result = await loader.pull("en", input); + expect(result["Control.Text.WelcomeDlg#Title"]).toBe("Welcome"); + expect(result["Control.Text.WelcomeDlg#Description"]).toBe("Setup will guide you"); + }); + + test("should handle XML entities in pushed values", async () => { + const loader = createAilLoader(); + loader.setDefaultLocale("en"); + const originalInput = `<?xml version="1.0" encoding="UTF-8"?> +<DICTIONARY type="multilanguage"> + <ENTRY id="Message.Warning"> + <STRING lang="en" value="Original"/> + </ENTRY> +</DICTIONARY>`; + + await loader.pull("en", originalInput); + + const translations = { + "Message.Warning": 'Error: "File not found" & other issues', + }; + + const output = await loader.push("en", translations, originalInput); + + expect(output).toContain("""); + expect(output).toContain("&"); + }); +}); diff --git a/packages/cli/src/cli/loaders/ail.ts b/packages/cli/src/cli/loaders/ail.ts new file mode 100644 index 000000000..131512e11 --- /dev/null +++ b/packages/cli/src/cli/loaders/ail.ts @@ -0,0 +1,182 @@ +import { parseStringPromise, Builder } from "xml2js"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +// Advanced Installer AIL (localization dictionary) file format +// Structure: +// <DICTIONARY type="multilanguage"> +// <ENTRY id="Control.Text.WelcomeDlg#Title"> +// <STRING lang="en" value="Welcome"/> +// <STRING lang="de" value="Willkommen"/> +// </ENTRY> +// </DICTIONARY> + +interface StringNode { + $: { + lang: string; + value: string; + }; +} + +interface EntryNode { + $?: { + id: string; + }; + STRING?: StringNode[]; +} + +interface DictionaryNode { + $?: Record<string, string>; // Preserve attributes like type="multilanguage", ignore="..." + ENTRY?: EntryNode[]; +} + +interface ParsedAIL { + DICTIONARY: DictionaryNode; +} + +export default function createAilLoader(): ILoader< + string, + Record<string, string> +> { + return createLoader({ + async pull(locale, input) { + const result: Record<string, string> = {}; + + if (!input || !input.trim()) { + return result; + } + + try { + // Parse AIL XML + const parsed = (await parseStringPromise(input, { + explicitArray: true, // Always use arrays for consistency + mergeAttrs: false, // Keep attributes separate in $ + trim: true, + explicitRoot: true, + })) as ParsedAIL; + + const dictionary = parsed.DICTIONARY; + if (!dictionary) { + return result; + } + + const entries = dictionary.ENTRY || []; + + // Extract entries for source locale + for (const entry of entries) { + const id = entry.$?.id; + if (!id) { + // Skip entries without id + continue; + } + + const strings = entry.STRING || []; + + // Find STRING element matching source locale + const sourceString = strings.find( + (s) => s.$?.lang === locale + ); + + if (sourceString?.$.value) { + result[id] = sourceString.$.value; + } + } + + return result; + } catch (error) { + console.error("Failed to parse AIL file:", error); + return result; + } + }, + + async push(locale, data, originalInput) { + if (!originalInput || !originalInput.trim()) { + // Create new AIL file from scratch + const dictionary: DictionaryNode = { + $: { type: "multilanguage" }, + ENTRY: Object.entries(data).map(([id, value]) => ({ + $: { id }, + STRING: [ + { + $: { lang: locale, value }, + }, + ], + })), + }; + + const builder = new Builder({ + xmldec: { version: "1.0", encoding: "UTF-8" }, + headless: false, + }); + + return builder.buildObject({ DICTIONARY: dictionary }); + } + + try { + // Parse original AIL XML + const parsed = (await parseStringPromise(originalInput, { + explicitArray: true, + mergeAttrs: false, + trim: true, + explicitRoot: true, + })) as ParsedAIL; + + const dictionary = parsed.DICTIONARY; + if (!dictionary) { + throw new Error("No DICTIONARY root element found"); + } + + const entries = dictionary.ENTRY || []; + + // Update or add translations for target locale + for (const [id, value] of Object.entries(data)) { + // Find existing entry by id + let entry = entries.find((e) => e.$?.id === id); + + if (!entry) { + // Create new entry if doesn't exist + entry = { + $: { id }, + STRING: [], + }; + entries.push(entry); + } + + // Ensure STRING array exists + if (!entry.STRING) { + entry.STRING = []; + } + + // Find or create STRING element for target locale + let targetString = entry.STRING.find( + (s) => s.$?.lang === locale + ); + + if (targetString) { + // Update existing translation + targetString.$.value = value; + } else { + // Add new translation + entry.STRING.push({ + $: { lang: locale, value }, + }); + } + } + + // Update ENTRY array in dictionary + dictionary.ENTRY = entries; + + // Build XML output + const builder = new Builder({ + xmldec: { version: "1.0", encoding: "UTF-8" }, + headless: false, + }); + + return builder.buildObject({ DICTIONARY: dictionary }); + } catch (error) { + console.error("Failed to build AIL file:", error); + throw error; + } + }, + }); +} diff --git a/packages/cli/src/cli/loaders/android.spec.ts b/packages/cli/src/cli/loaders/android.spec.ts new file mode 100644 index 000000000..72ba0ccfd --- /dev/null +++ b/packages/cli/src/cli/loaders/android.spec.ts @@ -0,0 +1,910 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fs from "fs/promises"; +import createAndroidLoader from "./android"; + +describe("android loader", () => { + const setupMocks = (input: string) => { + vi.mock("fs/promises"); + vi.mocked(fs.readFile).mockResolvedValue(input); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should correctly handle basic string resources", async () => { + const input = ` + <resources> + <string name="hello">Hello World</string> + <string name="app_name">My App</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + hello: "Hello World", + app_name: "My App", + }); + }); + + it("should correctly handle string arrays", async () => { + const input = ` + <resources> + <string-array name="planets"> + <item>Mercury</item> + <item>Venus</item> + <item>Earth</item> + <item>Mars</item> + </string-array> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + planets: ["Mercury", "Venus", "Earth", "Mars"], + }); + }); + + it("should correctly handle plurals with different quantity types", async () => { + const input = ` + <resources> + <plurals name="numberOfSongsAvailable"> + <item quantity="zero">No songs found.</item> + <item quantity="one">1 song found.</item> + <item quantity="few">%d songs found.</item> + <item quantity="many">%d songs found.</item> + <item quantity="other">%d songs found.</item> + </plurals> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + numberOfSongsAvailable: { + zero: "No songs found.", + one: "1 song found.", + few: "%d songs found.", + many: "%d songs found.", + other: "%d songs found.", + }, + }); + }); + + it("should correctly handle HTML markup in strings", async () => { + const input = ` + <resources> + <string name="welcome">Welcome to <b>Android</b>!</string> + <string name="formatted">This is <i>italic</i> and this is <b>bold</b></string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + welcome: "Welcome to <b>Android</b>!", + formatted: "This is <i>italic</i> and this is <b>bold</b>", + }); + }); + + it("should correctly handle HTML markup in strings during push without duplication", async () => { + const input = ` + <resources> + <string name="terms_of_use"><u>Terms of Use</u></string> + <string name="welcome">Welcome to <b>Android</b>!</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + + // Pull first to initialize loader state + await androidLoader.pull("en", input); + + // Push translated content with HTML + const pushed = await androidLoader.push("es", { + terms_of_use: "<u>Términos de uso</u>", + welcome: "Bienvenido a <b>Android</b>!", + }); + + // Verify no duplication - should only contain escaped HTML, not both escaped and unescaped + expect(pushed).toContain("<u>Términos de uso</u>"); + expect(pushed).not.toContain("<u>Términos de uso</u><u>"); + expect(pushed).not.toContain("<u>Terms of Use</u><u>Términos de uso</u>"); + + expect(pushed).toContain("<b>Android</b>"); + expect(pushed).not.toContain("<b>Android</b><b>"); + }); + + it("should correctly handle format strings", async () => { + const input = ` + <resources> + <string name="welcome_messages">Hello, %1$s! You have %2$d new messages.</string> + <string name="complex_format">Value: %1$.2f, Text: %2$s, Number: %3$d</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + welcome_messages: "Hello, %1$s! You have %2$d new messages.", + complex_format: "Value: %1$.2f, Text: %2$s, Number: %3$d", + }); + }); + + it("should correctly handle single quote escaping", async () => { + const input = ` + <resources> + <string name="apostrophe">Don\\'t forget me</string> + <string name="escaped_quotes">This has \\'single\\' quotes</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + // Now expect normalized apostrophes in the JS object + expect(result).toEqual({ + apostrophe: "Don't forget me", + escaped_quotes: "This has 'single' quotes", + }); + + // When pushing back, apostrophes should be escaped again + const pushed = await androidLoader.push("en", result); + expect(pushed).toContain("Don\\'t forget me"); + expect(pushed).toContain("This has \\'single\\' quotes"); + }); + + it("should correctly handle CDATA sections", async () => { + const input = ` + <resources> + <string name="html_content"><![CDATA[<html><body><h1>Title</h1><p>Paragraph</p></body></html>]]></string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + html_content: "<html><body><h1>Title</h1><p>Paragraph</p></body></html>", + }); + }); + + it("should correctly handle multiple CDATA sections in a single string", async () => { + const input = ` + <resources> + <string name="multiple_cdata"><![CDATA[<first>section</first>]]><![CDATA[<second>section</second>]]></string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + multiple_cdata: "<first>section</first><second>section</second>", + }); + }); + + it("should correctly handle nested HTML tags with attributes", async () => { + const input = ` + <resources> + <string name="complex_html">This is <span style="color:red">red text</span> and <a href="https://example.com">a link</a></string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + complex_html: + 'This is <span style="color:red">red text</span> and <a href="https://example.com">a link</a>', + }); + }); + + it("should correctly handle XML entities in strings", async () => { + const input = ` + <resources> + <string name="entities">This string contains <brackets> and &ampersands</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + entities: "This string contains <brackets> and &ersands", + }); + }); + + it("should correctly handle empty strings", async () => { + const input = ` + <resources> + <string name="empty"></string> + <string name="whitespace"> </string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + empty: "", + whitespace: " ", + }); + }); + + it("should correctly handle very long strings", async () => { + const longText = "This is a very long string.".repeat(100); + const input = ` + <resources> + <string name="long_text">${longText}</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + long_text: longText, + }); + }); + + it("should correctly handle strings with newlines and whitespace", async () => { + const input = ` + <resources> + <string name="multiline">Line 1 +Line 2 + Line 3 with indent</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + multiline: "Line 1\nLine 2\n Line 3 with indent", + }); + }); + + it("should correctly handle Unicode characters", async () => { + const input = ` + <resources> + <string name="unicode">Unicode: 你好, こんにちは, Привет, مرحبا, 안녕하세요</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + unicode: "Unicode: 你好, こんにちは, Привет, مرحبا, 안녕하세요", + }); + }); + + it("should skip non-translatable strings", async () => { + const input = ` + <resources> + <string name="app_name" translatable="false">My App</string> + <string name="welcome">Welcome</string> + <string name="version" translatable="false">1.0.0</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + welcome: "Welcome", + }); + expect(result.app_name).toBeUndefined(); + expect(result.version).toBeUndefined(); + }); + + it("should correctly push string resources back to XML", async () => { + const payload = { + hello: "Hola", + welcome: "Bienvenido", + }; + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + await androidLoader.pull( + "en", + ` + <resources> + <string name="hello">Hello</string> + <string name="welcome">Welcome</string> + </resources> + `, + ); + + const result = await androidLoader.push("es", payload); + + expect(result).toContain('<string name="hello">Hola</string>'); + expect(result).toContain('<string name="welcome">Bienvenido</string>'); + }); + + it("should correctly push string arrays back to XML", async () => { + const payload = { + planets: ["Mercurio", "Venus", "Tierra", "Marte"], + }; + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + await androidLoader.pull( + "en", + ` + <resources> + <string-array name="planets"> + <item>Mercury</item> + <item>Venus</item> + <item>Earth</item> + <item>Mars</item> + </string-array> + </resources> + `, + ); + + const result = await androidLoader.push("es", payload); + + expect(result).toContain('<string-array name="planets">'); + expect(result).toContain("<item>Mercurio</item>"); + expect(result).toContain("<item>Venus</item>"); + expect(result).toContain("<item>Tierra</item>"); + expect(result).toContain("<item>Marte</item>"); + }); + + it("should correctly push plurals back to XML", async () => { + const payload = { + numberOfSongsAvailable: { + zero: "No se encontraron canciones.", + one: "1 canción encontrada.", + few: "%d canciones encontradas.", + many: "%d canciones encontradas.", + other: "%d canciones encontradas.", + }, + }; + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + await androidLoader.pull( + "en", + ` + <resources> + <plurals name="numberOfSongsAvailable"> + <item quantity="zero">No songs found.</item> + <item quantity="one">1 song found.</item> + <item quantity="few">%d songs found.</item> + <item quantity="many">%d songs found.</item> + <item quantity="other">%d songs found.</item> + </plurals> + </resources> + `, + ); + + const result = await androidLoader.push("es", payload); + + expect(result).toContain('<plurals name="numberOfSongsAvailable">'); + expect(result).toContain( + '<item quantity="zero">No se encontraron canciones.</item>', + ); + expect(result).toContain( + '<item quantity="one">1 canción encontrada.</item>', + ); + expect(result).toContain( + '<item quantity="few">%d canciones encontradas.</item>', + ); + expect(result).toContain( + '<item quantity="many">%d canciones encontradas.</item>', + ); + expect(result).toContain( + '<item quantity="other">%d canciones encontradas.</item>', + ); + }); + + it("should correctly handle mixed resource types", async () => { + const payload = { + app_name: "Mi Aplicación", + planets: ["Mercurio", "Venus", "Tierra", "Marte"], + numberOfSongsAvailable: { + zero: "No se encontraron canciones.", + one: "1 canción encontrada.", + other: "%d canciones encontradas.", + }, + }; + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + await androidLoader.pull( + "en", + ` + <resources> + <string name="app_name">My App</string> + <string-array name="planets"> + <item>Mercury</item> + <item>Venus</item> + <item>Earth</item> + <item>Mars</item> + </string-array> + <plurals name="numberOfSongsAvailable"> + <item quantity="zero">No songs found.</item> + <item quantity="one">1 song found.</item> + <item quantity="other">%d songs found.</item> + </plurals> + </resources> + `, + ); + + const result = await androidLoader.push("es", payload); + + expect(result).toContain('<string name="app_name">Mi Aplicación</string>'); + expect(result).toContain('<string-array name="planets">'); + expect(result).toContain('<plurals name="numberOfSongsAvailable">'); + }); + + it("should correctly handle Unicode escape sequences", async () => { + const input = ` + <resources> + <string name="unicode_escape">Unicode escape: \\u0041\\u0042\\u0043 and \\u65e5\\u672c\\u8a9e</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + unicode_escape: + "Unicode escape: \\u0041\\u0042\\u0043 and \\u65e5\\u672c\\u8a9e", + }); + + const pushed = await androidLoader.push("en", result); + expect(pushed).toContain( + "Unicode escape: \\u0041\\u0042\\u0043 and \\u65e5\\u672c\\u8a9e", + ); + }); + + it("should correctly handle double quote escaping", async () => { + const input = ` + <resources> + <string name="double_quotes">He said, \\"Hello World\\"</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + double_quotes: 'He said, \\"Hello World\\"', + }); + + const pushed = await androidLoader.push("en", result); + expect(pushed).toContain('He said, \\"Hello World\\"'); + }); + + it("should correctly handle resource references", async () => { + const input = ` + <resources> + <string name="welcome_message">Welcome to @string/app_name</string> + <string name="app_name">My App</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + welcome_message: "Welcome to @string/app_name", + app_name: "My App", + }); + + const pushed = await androidLoader.push("en", result); + expect(pushed).toContain( + '<string name="welcome_message">Welcome to @string/app_name</string>', + ); + }); + + it("should correctly handle tools namespace attributes", async () => { + const input = ` + <resources> + <string name="debug_only" tools:ignore="MissingTranslation">Debug message</string> + <string name="normal">Normal message</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + debug_only: "Debug message", + normal: "Normal message", + }); + }); + + it("should correctly handle whitespace preservation with double quotes", async () => { + const input = ` + <resources> + <string name="preserved_whitespace">" This string preserves whitespace "</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + preserved_whitespace: '" This string preserves whitespace "', + }); + + const pushed = await androidLoader.push("en", result); + expect(pushed).toContain( + '<string name="preserved_whitespace">" This string preserves whitespace "</string>', + ); + }); + + it("should correctly handle special characters that need escaping", async () => { + const input = ` + <resources> + <string name="special_chars">Special chars: \\@, \\?, \\#, \\$, \\%</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + expect(result).toEqual({ + special_chars: "Special chars: \\@, \\?, \\#, \\$, \\%", + }); + + const pushed = await androidLoader.push("en", result); + expect(pushed).toContain("Special chars: \\@, \\?, \\#, \\$, \\%"); + }); + + it("should correctly handle apostrophes in text", async () => { + const input = ` + <resources> + <string name="sign_in_agreement_text_1">J\'accepte les</string> + <string name="sign_in_agreement_text_2"> et je reconnais la </string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + // During pull, escaped apostrophes should be normalized to simple apostrophes + expect(result).toEqual({ + sign_in_agreement_text_1: "J'accepte les", + sign_in_agreement_text_2: " et je reconnais la ", + }); + + // When pushing back, apostrophes should be escaped with backslash + const pushed = await androidLoader.push("en", result); + expect(pushed).toContain("J\\'accepte les"); + expect(pushed).toContain(" et je reconnais la "); + }); + + it("should escape apostrophes even in strings wrapped with double quotes", async () => { + const input = ` + <resources> + <string name="quoted_apostrophe">"J'accepte les terms"</string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + // During pull, the double quotes around the content should be preserved + expect(result).toEqual({ + quoted_apostrophe: '"J\'accepte les terms"', + }); + + // When pushing back, apostrophes should be escaped even in double-quoted strings + const pushed = await androidLoader.push("en", result); + expect(pushed).toContain('"J\\\'accepte les terms"'); + }); + + it("should correctly handle strings with apostrophes and avoid double escaping", async () => { + const input = ` + <resources> + <string name="welcome_message">Please don't hesitate to contact us</string> + <item quantity="one">- %d user\'s item</item> + <item quantity="other">- %d user\'s items</item> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const result = await androidLoader.pull("en", input); + + // During pull, escaped apostrophes should be properly handled + expect(result.welcome_message).toBe("Please don't hesitate to contact us"); + + // When pushing back, apostrophes should be escaped but not double-escaped + const pushed = await androidLoader.push("en", { + welcome_message: "Please don't hesitate to contact us", + item_count: { + one: "- %d user's item", + other: "- %d user's items", + }, + }); + + expect(pushed).toContain("Please don\\'t hesitate to contact us"); + expect(pushed).toContain("- %d user\\'s item"); + expect(pushed).not.toContain("- %d user\\\\'s item"); + }); + + // Tests for Issue Fixes + + it("should preserve whitespace in array items during pull and push", async () => { + const input = ` + <resources> + <string-array name="mixed_items"> + <item> Item with spaces </item> + <item> </item> + </string-array> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const pulled = await androidLoader.pull("en", input); + + expect(pulled.mixed_items).toEqual([" Item with spaces ", " "]); + + const pushed = await androidLoader.push("en", { + mixed_items: [" Elemento con espacios ", " "], + }); + + expect(pushed).toContain("<item> Elemento con espacios </item>"); + expect(pushed).toContain("<item> </item>"); + }); + + it("should retain CDATA wrappers for translated strings", async () => { + const input = ` + <resources> + <string name="cdata_example"><![CDATA[Special <tag> ]]></string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + await androidLoader.pull("en", input); + + const pushed = await androidLoader.push("es", { + cdata_example: "Especial <tag> ", + }); + + expect(pushed).toContain( + '<string name="cdata_example"><![CDATA[Especial <tag> ]]></string>', + ); + }); + + it("should escape apostrophes in CDATA sections", async () => { + const input = ` + <resources> + <string name="review_info"><![CDATA[Hosts can't see your review until they've written one. <u>Learn more</u>]]></string> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const pulled = await androidLoader.pull("en", input); + + expect(pulled.review_info).toBe( + "Hosts can't see your review until they've written one. <u>Learn more</u>", + ); + + const pushed = await androidLoader.push("fr", { + review_info: + "Les hôtes ne peuvent voir votre avis qu'après en avoir écrit un. <u>En savoir plus</u>", + }); + + // Apostrophes must be escaped even inside CDATA (Android AAPT requirement) + expect(pushed).toContain("qu\\'après"); + expect(pushed).toContain("<![CDATA["); + expect(pushed).toContain("]]>"); + // HTML tags should NOT be escaped inside CDATA + expect(pushed).toContain("<u>En savoir plus</u>"); + expect(pushed).not.toContain("<u>"); + }); + + it("should preserve resource ordering after push", async () => { + const input = ` + <resources> + <string name="first">First</string> + <string-array name="colors"> + <item>Red</item> + <item>Green</item> + </string-array> + <plurals name="messages"> + <item quantity="one">%d message</item> + <item quantity="other">%d messages</item> + </plurals> + <bool name="show_tutorial">true</bool> + <integer name="retry_count">3</integer> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + const roundTrip = await androidLoader.pull("en", input); + const pushed = await androidLoader.push("en", roundTrip); + + const order = Array.from( + pushed.matchAll( + /<(string|string-array|plurals|bool|integer)\s+name="([^"]+)"/g, + ), + ).map(([, , name]) => name); + + expect(order).toEqual([ + "first", + "colors", + "messages", + "show_tutorial", + "retry_count", + ]); + }); + + it("should preserve XML declaration from source file", async () => { + const input = `<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="test">Test</string> +</resources>`; + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + await androidLoader.pull("en", input); + + const result = await androidLoader.push("es", { test: "Prueba" }); + + expect(result).toMatch(/^<\?xml version="1\.0" encoding="utf-8"\?>/); + }); + + it('should exclude translatable="false" items from target locale', async () => { + const input = ` + <resources> + <string name="app_name">My App</string> + <string name="api_url" translatable="false">https://api.example.com</string> + <string name="debug_key" translatable="false">DEBUG_KEY</string> + <string-array name="colors"> + <item>Red</item> + </string-array> + <string-array name="urls" translatable="false"> + <item>https://example.com</item> + </string-array> + <plurals name="items"> + <item quantity="one">%d item</item> + <item quantity="other">%d items</item> + </plurals> + <plurals name="bytes" translatable="false"> + <item quantity="one">%d byte</item> + <item quantity="other">%d bytes</item> + </plurals> + <bool name="show_tutorial">true</bool> + <bool name="is_debug" translatable="false">false</bool> + <integer name="timeout">30</integer> + <integer name="version" translatable="false">42</integer> + </resources> + `.trim(); + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + await androidLoader.pull("en", input); + + const result = await androidLoader.push("es", { + app_name: "Mi Aplicación", + colors: ["Rojo"], + items: { one: "%d elemento", other: "%d elementos" }, + show_tutorial: true, + timeout: 30, + }); + + // Check that translatable="false" items are NOT included + expect(result).not.toContain('name="api_url"'); + expect(result).not.toContain("https://api.example.com"); + expect(result).not.toContain('name="debug_key"'); + expect(result).not.toContain("DEBUG_KEY"); + expect(result).not.toContain('name="urls"'); + expect(result).not.toContain('name="bytes"'); + expect(result).not.toContain('name="is_debug"'); + expect(result).not.toContain('name="version"'); + + // Check that translatable items are translated + expect(result).toContain("Mi Aplicación"); + expect(result).toContain("Rojo"); + expect(result).toContain("elemento"); + expect(result).toContain('name="app_name"'); + expect(result).toContain('name="colors"'); + expect(result).toContain('name="items"'); + expect(result).toContain('name="show_tutorial"'); + expect(result).toContain('name="timeout"'); + }); + + it("should use 4-space indentation by default", async () => { + const input = `<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="test">Test</string> + <string name="another">Another</string> +</resources>`; + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + await androidLoader.pull("en", input); + + const result = await androidLoader.push("es", { + test: "Prueba", + another: "Otro", + }); + + // Check for 4-space indentation (default) + // Note: Users should use formatters (Prettier/Biome) for custom indentation + expect(result).toContain('\n <string name="test">'); + expect(result).toContain('\n <string name="another">'); + }); + + it("should preserve XML declaration encoding from source file", async () => { + const inputUtf8 = `<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="test">Test</string> +</resources>`; + + const inputUpperUTF8 = `<?xml version="1.0" encoding="UTF-8"?> +<resources> + <string name="test">Test</string> +</resources>`; + + const inputISO = `<?xml version="1.0" encoding="ISO-8859-1"?> +<resources> + <string name="test">Test</string> +</resources>`; + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + + // Test lowercase utf-8 + await androidLoader.pull("en", inputUtf8); + let result = await androidLoader.push("es", { test: "Prueba" }); + expect(result).toMatch(/^<\?xml version="1\.0" encoding="utf-8"\?>/); + + // Test uppercase UTF-8 + await androidLoader.pull("en", inputUpperUTF8); + result = await androidLoader.push("es", { test: "Prueba" }); + expect(result).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/); + + // Test ISO-8859-1 + await androidLoader.pull("en", inputISO); + result = await androidLoader.push("es", { test: "Prueba" }); + expect(result).toMatch(/^<\?xml version="1\.0" encoding="ISO-8859-1"\?>/); + }); + + it("should preserve XML version from source file", async () => { + const inputV10 = `<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="test">Test</string> +</resources>`; + + const inputV11 = `<?xml version="1.1" encoding="utf-8"?> +<resources> + <string name="test">Test</string> +</resources>`; + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + + // Test version 1.0 + await androidLoader.pull("en", inputV10); + let result = await androidLoader.push("es", { test: "Prueba" }); + expect(result).toMatch(/^<\?xml version="1\.0"/); + + // Test version 1.1 + await androidLoader.pull("en", inputV11); + result = await androidLoader.push("es", { test: "Prueba" }); + expect(result).toMatch(/^<\?xml version="1\.1"/); + }); + + it("should omit XML declaration when source has none", async () => { + const inputNoDeclaration = `<resources> + <string name="test">Test</string> +</resources>`; + + const androidLoader = createAndroidLoader().setDefaultLocale("en"); + await androidLoader.pull("en", inputNoDeclaration); + + const result = await androidLoader.push("es", { test: "Prueba" }); + + // Should start immediately with the root element (no declaration) + expect(result).not.toMatch(/^<\?xml/); + expect(result.trim().startsWith("<resources>")).toBe(true); + }); +}); diff --git a/packages/cli/src/cli/loaders/android.ts b/packages/cli/src/cli/loaders/android.ts new file mode 100644 index 000000000..77585ac0c --- /dev/null +++ b/packages/cli/src/cli/loaders/android.ts @@ -0,0 +1,1511 @@ +import { createRequire } from "node:module"; +import { parseStringPromise, type XmlDeclarationAttributes } from "xml2js"; +import { ILoader } from "./_types"; +import { CLIError } from "../utils/errors"; +import { createLoader } from "./_utils"; + +interface SaxParser { + onopentag: (node: { + name: string; + attributes: Record<string, string>; + }) => void; + onclosetag: (name: string) => void; + ontext: (text: string) => void; + oncdata: (cdata: string) => void; + write(data: string): SaxParser; + close(): SaxParser; +} + +interface SaxModule { + parser( + strict: boolean, + options?: { trim?: boolean; normalize?: boolean; lowercase?: boolean }, + ): SaxParser; +} + +const require = createRequire(import.meta.url); +const sax: SaxModule = require("sax") as SaxModule; + +const defaultAndroidResourcesXml = `<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources>`; + +type AndroidResourceType = + | "string" + | "string-array" + | "plurals" + | "bool" + | "integer"; + +type PrimitiveValue = boolean | number | string; + +type ContentSegment = + | { kind: "text"; value: string } + | { kind: "cdata"; value: string }; + +interface TextualMeta { + segments: ContentSegment[]; + hasCdata: boolean; +} + +interface ArrayItemMeta extends TextualMeta { + quantity?: string; +} + +interface StringResourceNode { + type: "string"; + name: string; + translatable: boolean; + node: any; + meta: TextualMeta; +} + +interface StringArrayItemNode { + node: any; + meta: TextualMeta; +} + +interface StringArrayResourceNode { + type: "string-array"; + name: string; + translatable: boolean; + node: any; + items: StringArrayItemNode[]; +} + +interface PluralsItemNode { + node: any; + quantity: string; + meta: TextualMeta; +} + +interface PluralsResourceNode { + type: "plurals"; + name: string; + translatable: boolean; + node: any; + items: PluralsItemNode[]; +} + +interface BoolResourceNode { + type: "bool"; + name: string; + translatable: boolean; + node: any; + meta: TextualMeta; +} + +interface IntegerResourceNode { + type: "integer"; + name: string; + translatable: boolean; + node: any; + meta: TextualMeta; +} + +type AndroidResourceNode = + | StringResourceNode + | StringArrayResourceNode + | PluralsResourceNode + | BoolResourceNode + | IntegerResourceNode; + +interface AndroidDocument { + resources: any; + resourceNodes: AndroidResourceNode[]; +} + +interface XmlDeclarationOptions { + xmldec?: XmlDeclarationAttributes; + headless: boolean; +} + +export default function createAndroidLoader(): ILoader< + string, + Record<string, any> +> { + return createLoader({ + async pull(locale, input) { + try { + if (!input) { + return {}; + } + + const document = await parseAndroidDocument(input); + return buildPullResult(document); + } catch (error) { + console.error("Error parsing Android resource file:", error); + throw new CLIError({ + message: "Failed to parse Android resource file", + docUrl: "androidResourceError", + }); + } + }, + async push( + locale, + payload, + originalInput, + originalLocale, + pullInput, + pullOutput, + ) { + try { + const selectedBase = selectBaseXml( + locale, + originalLocale, + pullInput, + originalInput, + ); + + const existingDocument = await parseAndroidDocument(selectedBase); + const sourceDocument = await parseAndroidDocument(originalInput); + const translatedDocument = buildTranslatedDocument( + payload, + existingDocument, + sourceDocument, + ); + + const referenceXml = + selectedBase || originalInput || defaultAndroidResourcesXml; + const declaration = resolveXmlDeclaration(referenceXml); + + return buildAndroidXml(translatedDocument, declaration); + } catch (error) { + console.error("Error generating Android resource file:", error); + throw new CLIError({ + message: "Failed to generate Android resource file", + docUrl: "androidResourceError", + }); + } + }, + }); +} + +function resolveXmlDeclaration(xml: string | null): XmlDeclarationOptions { + if (!xml) { + const xmldec: XmlDeclarationAttributes = { + version: "1.0", + encoding: "utf-8", + }; + return { + xmldec, + headless: false, + }; + } + + const match = xml.match( + /<\?xml\s+version="([^"]+)"(?:\s+encoding="([^"]+)")?\s*\?>/, + ); + if (match) { + const version = match[1] && match[1].trim().length > 0 ? match[1] : "1.0"; + const encoding = + match[2] && match[2].trim().length > 0 ? match[2] : undefined; + const xmldec: XmlDeclarationAttributes = encoding + ? { version, encoding } + : { version }; + return { + xmldec, + headless: false, + }; + } + + return { headless: true }; +} + +async function parseAndroidDocument( + input?: string | null, +): Promise<AndroidDocument> { + const xmlToParse = + input && input.trim().length > 0 ? input : defaultAndroidResourcesXml; + + const parsed = await parseStringPromise(xmlToParse, { + explicitArray: true, + explicitChildren: true, + preserveChildrenOrder: true, + charsAsChildren: true, + includeWhiteChars: true, + mergeAttrs: false, + normalize: false, + normalizeTags: false, + trim: false, + attrkey: "$", + charkey: "_", + childkey: "$$", + }); + + if (!parsed || !parsed.resources) { + return { + resources: { $$: [] }, + resourceNodes: [], + }; + } + + const resourcesNode = parsed.resources; + resourcesNode["#name"] = resourcesNode["#name"] ?? "resources"; + resourcesNode.$$ = resourcesNode.$$ ?? []; + + const metadata = extractResourceMetadata(xmlToParse); + + const resourceNodes: AndroidResourceNode[] = []; + let metaIndex = 0; + + for (const child of resourcesNode.$$ as any[]) { + const elementName = child?.["#name"]; + if (!isResourceElementName(elementName)) { + continue; + } + + const meta = metadata[metaIndex++]; + if (!meta || meta.type !== elementName) { + continue; + } + + const name = child?.$?.name ?? meta.name; + if (!name) { + continue; + } + + const translatable = + (child?.$?.translatable ?? "").toLowerCase() !== "false"; + + switch (meta.type) { + case "string": { + resourceNodes.push({ + type: "string", + name, + translatable, + node: child, + meta: cloneTextMeta(meta.meta), + }); + break; + } + case "string-array": { + const itemNodes = (child?.item ?? []) as any[]; + const items: StringArrayItemNode[] = []; + const templateItems = meta.items; + + for ( + let i = 0; + i < Math.max(itemNodes.length, templateItems.length); + i++ + ) { + const nodeItem = itemNodes[i]; + const templateItem = + templateItems[i] ?? templateItems[templateItems.length - 1]; + if (!nodeItem) { + continue; + } + items.push({ + node: nodeItem, + meta: cloneTextMeta(templateItem.meta), + }); + } + + resourceNodes.push({ + type: "string-array", + name, + translatable, + node: child, + items, + }); + break; + } + case "plurals": { + const itemNodes = (child?.item ?? []) as any[]; + const templateItems = meta.items; + const items: PluralsItemNode[] = []; + + for (const templateItem of templateItems) { + const quantity = templateItem.quantity; + if (!quantity) { + continue; + } + const nodeItem = itemNodes.find( + (item: any) => item?.$?.quantity === quantity, + ); + if (!nodeItem) { + continue; + } + items.push({ + node: nodeItem, + quantity, + meta: cloneTextMeta(templateItem.meta), + }); + } + + resourceNodes.push({ + type: "plurals", + name, + translatable, + node: child, + items, + }); + break; + } + case "bool": { + resourceNodes.push({ + type: "bool", + name, + translatable, + node: child, + meta: cloneTextMeta(meta.meta), + }); + break; + } + case "integer": { + resourceNodes.push({ + type: "integer", + name, + translatable, + node: child, + meta: cloneTextMeta(meta.meta), + }); + break; + } + } + } + + return { resources: resourcesNode, resourceNodes }; +} + +function buildPullResult(document: AndroidDocument): Record<string, any> { + const result: Record<string, any> = {}; + + for (const resource of document.resourceNodes) { + if (!isTranslatable(resource)) { + continue; + } + + switch (resource.type) { + case "string": { + result[resource.name] = decodeAndroidText( + segmentsToString(resource.meta.segments), + ); + break; + } + case "string-array": { + result[resource.name] = resource.items.map((item) => + decodeAndroidText(segmentsToString(item.meta.segments)), + ); + break; + } + case "plurals": { + const pluralMap: Record<string, string> = {}; + for (const item of resource.items) { + pluralMap[item.quantity] = decodeAndroidText( + segmentsToString(item.meta.segments), + ); + } + result[resource.name] = pluralMap; + break; + } + case "bool": { + const value = segmentsToString(resource.meta.segments).trim(); + result[resource.name] = value === "true"; + break; + } + case "integer": { + const value = parseInt( + segmentsToString(resource.meta.segments).trim(), + 10, + ); + result[resource.name] = Number.isNaN(value) ? 0 : value; + break; + } + } + } + + return result; +} + +function isTranslatable(resource: AndroidResourceNode): boolean { + return resource.translatable; +} + +function buildTranslatedDocument( + payload: Record<string, any>, + existingDocument: AndroidDocument, + sourceDocument: AndroidDocument, +): AndroidDocument { + const templateDocument = sourceDocument; + const finalDocument = cloneDocumentStructure(templateDocument); + + const templateMap = createResourceMap(templateDocument); + const existingMap = createResourceMap(existingDocument); + const payloadEntries = payload ?? {}; + const finalMap = createResourceMap(finalDocument); + + for (const resource of finalDocument.resourceNodes) { + if (!resource.translatable) { + continue; + } + + const templateResource = templateMap.get(resource.name); + let translationValue: any; + + if ( + Object.prototype.hasOwnProperty.call(payloadEntries, resource.name) && + payloadEntries[resource.name] !== undefined && + payloadEntries[resource.name] !== null + ) { + translationValue = payloadEntries[resource.name]; + } else if (existingMap.has(resource.name)) { + translationValue = extractValueFromResource( + existingMap.get(resource.name)!, + ); + } else { + translationValue = extractValueFromResource(templateResource ?? resource); + } + + updateResourceNode(resource, translationValue, templateResource); + } + + for (const resource of existingDocument.resourceNodes) { + if (finalMap.has(resource.name)) { + continue; + } + if (!isTranslatable(resource)) { + continue; + } + const cloned = cloneResourceNode(resource); + appendResourceNode(finalDocument, cloned); + finalMap.set(cloned.name, cloned); + } + + for (const [name, value] of Object.entries(payloadEntries)) { + if (finalMap.has(name)) { + continue; + } + try { + const inferred = createResourceNodeFromValue(name, value); + appendResourceNode(finalDocument, inferred); + finalMap.set(name, inferred); + } catch (error) { + if (error instanceof CLIError) { + throw error; + } + } + } + + return finalDocument; +} + +function buildAndroidXml( + document: AndroidDocument, + declaration: XmlDeclarationOptions, +): string { + const xmlBody = serializeElement(document.resources); + + if (declaration.headless) { + return xmlBody; + } + + if (declaration.xmldec) { + const { version, encoding } = declaration.xmldec; + const encodingPart = encoding ? ` encoding="${encoding}"` : ""; + return `<?xml version="${version}"${encodingPart}?>\n${xmlBody}`; + } + + return `<?xml version="1.0" encoding="utf-8"?>\n${xmlBody}`; +} + +function selectBaseXml( + locale: string, + originalLocale: string, + pullInput: string | null, + originalInput: string | null, +): string | null { + if (locale === originalLocale) { + return pullInput ?? originalInput; + } + return pullInput ?? originalInput; +} + +function updateResourceNode( + target: AndroidResourceNode, + rawValue: any, + template: AndroidResourceNode | undefined, +): void { + switch (target.type) { + case "string": { + const value = asString(rawValue, target.name); + const templateMeta = + template && template.type === "string" ? template.meta : target.meta; + const useCdata = templateMeta.hasCdata; + setTextualNodeContent(target.node, value, useCdata); + target.meta = makeTextMeta([ + { kind: useCdata ? "cdata" : "text", value }, + ]); + break; + } + case "string-array": { + const values = asStringArray(rawValue, target.name); + const templateItems = + template && template.type === "string-array" + ? template.items + : target.items; + const maxLength = Math.max(target.items.length, templateItems.length); + for (let index = 0; index < maxLength; index++) { + const targetItem = target.items[index]; + const templateItem = + templateItems[index] ?? + templateItems[templateItems.length - 1] ?? + target.items[index]; + if (!targetItem || !templateItem) { + continue; + } + const translation = + index < values.length + ? values[index] + : segmentsToString(templateItem.meta.segments); + const useCdata = templateItem.meta.hasCdata; + setTextualNodeContent(targetItem.node, translation, useCdata); + targetItem.meta = makeTextMeta([ + { kind: useCdata ? "cdata" : "text", value: translation }, + ]); + } + break; + } + case "plurals": { + const pluralValues = asPluralMap(rawValue, target.name); + const templateItems = + template && template.type === "plurals" ? template.items : target.items; + const templateMap = new Map( + templateItems.map((item) => [item.quantity, item]), + ); + for (const item of target.items) { + const templateItem = + templateMap.get(item.quantity) ?? templateMap.values().next().value; + const fallback = templateItem + ? segmentsToString(templateItem.meta.segments) + : segmentsToString(item.meta.segments); + const translation = + typeof pluralValues[item.quantity] === "string" + ? pluralValues[item.quantity] + : fallback; + const useCdata = templateItem + ? templateItem.meta.hasCdata + : item.meta.hasCdata; + setTextualNodeContent(item.node, translation, useCdata); + item.meta = makeTextMeta([ + { kind: useCdata ? "cdata" : "text", value: translation }, + ]); + } + break; + } + case "bool": { + const boolValue = asBoolean(rawValue, target.name); + const strValue = boolValue ? "true" : "false"; + setTextualNodeContent(target.node, strValue, false); + target.meta = makeTextMeta([{ kind: "text", value: strValue }]); + break; + } + case "integer": { + const intValue = asInteger(rawValue, target.name); + const strValue = intValue.toString(); + setTextualNodeContent(target.node, strValue, false); + target.meta = makeTextMeta([{ kind: "text", value: strValue }]); + break; + } + } +} + +function appendResourceNode( + document: AndroidDocument, + resourceNode: AndroidResourceNode, +): void { + document.resources.$$ = document.resources.$$ ?? []; + const children = document.resources.$$ as any[]; + + if ( + children.length === 0 || + (children[children.length - 1]["#name"] !== "__text__" && + children[children.length - 1]["#name"] !== "__comment__") + ) { + children.push({ "#name": "__text__", _: "\n " }); + } + + children.push(resourceNode.node); + children.push({ "#name": "__text__", _: "\n" }); + document.resourceNodes.push(resourceNode); +} + +function setTextualNodeContent( + node: any, + value: string, + useCdata: boolean, +): void { + // CDATA needs apostrophe escaping but not XML entity escaping + const escapedValue = useCdata + ? escapeApostrophesOnly(value) + : escapeAndroidString(value); + node._ = escapedValue; + + // Replace entire children array to avoid duplicating inline HTML elements + // When inline HTML exists (e.g., <b>text</b>), xml2js creates element nodes + // in node.$$ that would otherwise be serialized alongside the escaped text + node.$$ = [ + { "#name": useCdata ? "__cdata" : "__text__", _: escapedValue } + ]; +} + +function buildResourceNameMap( + document: AndroidDocument, +): Map<string, AndroidResourceNode> { + const map = new Map<string, AndroidResourceNode>(); + for (const node of document.resourceNodes) { + if (!map.has(node.name)) { + map.set(node.name, node); + } + } + return map; +} + +function createResourceMap( + document: AndroidDocument, +): Map<string, AndroidResourceNode> { + return buildResourceNameMap(document); +} + +function cloneResourceNode(resource: AndroidResourceNode): AndroidResourceNode { + switch (resource.type) { + case "string": { + const nodeClone = deepClone(resource.node); + return { + type: "string", + name: resource.name, + translatable: resource.translatable, + node: nodeClone, + meta: cloneTextMeta(resource.meta), + }; + } + case "string-array": { + const nodeClone = deepClone(resource.node); + const itemNodes = (nodeClone.item ?? []) as any[]; + const items: StringArrayItemNode[] = itemNodes.map((itemNode, index) => { + const templateMeta = + resource.items[index]?.meta ?? + resource.items[resource.items.length - 1]?.meta ?? + makeTextMeta([]); + return { + node: itemNode, + meta: cloneTextMeta(templateMeta), + }; + }); + return { + type: "string-array", + name: resource.name, + translatable: resource.translatable, + node: nodeClone, + items, + }; + } + case "plurals": { + const nodeClone = deepClone(resource.node); + const itemNodes = (nodeClone.item ?? []) as any[]; + const items: PluralsItemNode[] = []; + for (const templateItem of resource.items) { + const cloneNode = itemNodes.find( + (item: any) => item?.$?.quantity === templateItem.quantity, + ); + if (!cloneNode) { + continue; + } + items.push({ + node: cloneNode, + quantity: templateItem.quantity, + meta: cloneTextMeta(templateItem.meta), + }); + } + return { + type: "plurals", + name: resource.name, + translatable: resource.translatable, + node: nodeClone, + items, + }; + } + case "bool": { + const nodeClone = deepClone(resource.node); + return { + type: "bool", + name: resource.name, + translatable: resource.translatable, + node: nodeClone, + meta: cloneTextMeta(resource.meta), + }; + } + case "integer": { + const nodeClone = deepClone(resource.node); + return { + type: "integer", + name: resource.name, + translatable: resource.translatable, + node: nodeClone, + meta: cloneTextMeta(resource.meta), + }; + } + } +} + +function cloneTextMeta(meta: TextualMeta): TextualMeta { + return { + hasCdata: meta.hasCdata, + segments: meta.segments.map((segment) => ({ ...segment })), + }; +} + +function asString(value: any, name: string): string { + if (typeof value === "string") { + return value; + } + throw new CLIError({ + message: `Expected string value for resource "${name}"`, + docUrl: "androidResourceError", + }); +} + +function asStringArray(value: any, name: string): string[] { + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return value; + } + throw new CLIError({ + message: `Expected array of strings for resource "${name}"`, + docUrl: "androidResourceError", + }); +} + +function asPluralMap(value: any, name: string): Record<string, string> { + if (value && typeof value === "object" && !Array.isArray(value)) { + const result: Record<string, string> = {}; + for (const [quantity, pluralValue] of Object.entries(value)) { + if (typeof pluralValue !== "string") { + throw new CLIError({ + message: `Expected plural item "${quantity}" of "${name}" to be a string`, + docUrl: "androidResourceError", + }); + } + result[quantity] = pluralValue; + } + return result; + } + throw new CLIError({ + message: `Expected object value for plurals resource "${name}"`, + docUrl: "androidResourceError", + }); +} + +function asBoolean(value: any, name: string): boolean { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + if (value === "true" || value === "false") { + return value === "true"; + } + } + throw new CLIError({ + message: `Expected boolean value for resource "${name}"`, + docUrl: "androidResourceError", + }); +} + +function asInteger(value: any, name: string): number { + if (typeof value === "number" && Number.isInteger(value)) { + return value; + } + throw new CLIError({ + message: `Expected number value for resource "${name}"`, + docUrl: "androidResourceError", + }); +} + +function escapeAndroidString(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/(?<!\\)'/g, "\\'"); +} + +function escapeApostrophesOnly(value: string): string { + // Even inside CDATA, apostrophes must be escaped for Android AAPT + return value.replace(/(?<!\\)'/g, "\\'"); +} + +function segmentsToString(segments: ContentSegment[]): string { + return segments.map((segment) => segment.value).join(""); +} + +function makeTextMeta(segments: ContentSegment[]): TextualMeta { + return { + segments, + hasCdata: segments.some((segment) => segment.kind === "cdata"), + }; +} + +function createResourceNodeFromValue( + name: string, + value: any, +): AndroidResourceNode { + const inferredType = inferTypeFromValue(value); + + switch (inferredType) { + case "string": { + const stringValue = asString(value, name); + const escaped = escapeAndroidString(stringValue); + const node = { + "#name": "string", + $: { name }, + _: escaped, + $$: [{ "#name": "__text__", _: escaped }], + }; + return { + type: "string", + name, + translatable: true, + node, + meta: makeTextMeta([{ kind: "text", value: stringValue }]), + }; + } + case "string-array": { + const items = asStringArray(value, name); + const node = { + "#name": "string-array", + $: { name }, + $$: [] as any[], + item: [] as any[], + }; + const itemNodes: StringArrayItemNode[] = []; + for (const itemValue of items) { + const escaped = escapeAndroidString(itemValue); + const itemNode = { + "#name": "item", + _: escaped, + $$: [{ "#name": "__text__", _: escaped }], + }; + node.$$!.push(itemNode); + node.item!.push(itemNode); + itemNodes.push({ + node: itemNode, + meta: makeTextMeta([{ kind: "text", value: itemValue }]), + }); + } + return { + type: "string-array", + name, + translatable: true, + node, + items: itemNodes, + }; + } + case "plurals": { + const pluralMap = asPluralMap(value, name); + const node = { + "#name": "plurals", + $: { name }, + $$: [] as any[], + item: [] as any[], + }; + const items: PluralsItemNode[] = []; + for (const [quantity, pluralValue] of Object.entries(pluralMap)) { + const escaped = escapeAndroidString(pluralValue); + const itemNode = { + "#name": "item", + $: { quantity }, + _: escaped, + $$: [{ "#name": "__text__", _: escaped }], + }; + node.$$!.push(itemNode); + node.item!.push(itemNode); + items.push({ + node: itemNode, + quantity, + meta: makeTextMeta([{ kind: "text", value: pluralValue }]), + }); + } + return { + type: "plurals", + name, + translatable: true, + node, + items, + }; + } + case "bool": { + const boolValue = asBoolean(value, name); + const textValue = boolValue ? "true" : "false"; + const node = { + "#name": "bool", + $: { name }, + _: textValue, + $$: [{ "#name": "__text__", _: textValue }], + }; + return { + type: "bool", + name, + translatable: true, + node, + meta: makeTextMeta([{ kind: "text", value: textValue }]), + }; + } + case "integer": { + const intValue = asInteger(value, name); + const textValue = intValue.toString(); + const node = { + "#name": "integer", + $: { name }, + _: textValue, + $$: [{ "#name": "__text__", _: textValue }], + }; + return { + type: "integer", + name, + translatable: true, + node, + meta: makeTextMeta([{ kind: "text", value: textValue }]), + }; + } + } +} + +function cloneDocumentStructure(document: AndroidDocument): AndroidDocument { + // Filter first - only keep translatable resources + const translatableResources = document.resourceNodes.filter(isTranslatable); + + const resourcesClone = deepClone(document.resources); + const lookup = buildResourceLookup(resourcesClone); + const resourceNodes: AndroidResourceNode[] = []; + + for (const resource of translatableResources) { + const cloned = cloneResourceNodeFromLookup(resource, lookup); + resourceNodes.push(cloned); + } + + // Clean up XML structure - only keep translatable resource nodes + if (resourcesClone.$$ && Array.isArray(resourcesClone.$$)) { + const includedKeys = new Set( + resourceNodes.map((r) => resourceLookupKey(r.type, r.name)), + ); + + // Filter out non-translatable resources + let filtered = resourcesClone.$$.filter((child: any) => { + const elementName = child?.["#name"]; + const name = child?.$?.name; + if (!isResourceElementName(elementName) || !name) { + return true; // Keep whitespace, comments, etc. + } + return includedKeys.has(resourceLookupKey(elementName, name)); + }); + + // Remove consecutive whitespace nodes (fixes extra blank lines) + const cleaned: any[] = []; + let lastWasWhitespace = false; + + for (const child of filtered) { + const isWhitespace = + child?.["#name"] === "__text__" && (!child._ || child._.trim() === ""); + + if (isWhitespace) { + if (!lastWasWhitespace) { + cleaned.push(child); + lastWasWhitespace = true; + } + // Skip consecutive whitespace + } else { + cleaned.push(child); + lastWasWhitespace = false; + } + } + + resourcesClone.$$ = cleaned; + } + + return { + resources: resourcesClone, + resourceNodes, + }; +} + +function buildResourceLookup(resources: any): Map<string, any[]> { + const lookup = new Map<string, any[]>(); + const children = Array.isArray(resources.$$) ? resources.$$ : []; + for (const child of children) { + const type = child?.["#name"]; + const name = child?.$?.name; + if (!type || !name || !isResourceElementName(type)) { + continue; + } + const key = resourceLookupKey(type, name); + if (!lookup.has(key)) { + lookup.set(key, []); + } + lookup.get(key)!.push(child); + } + return lookup; +} + +function cloneResourceNodeFromLookup( + resource: AndroidResourceNode, + lookup: Map<string, any[]>, +): AndroidResourceNode { + const node = takeResourceNode(lookup, resource.type, resource.name); + if (!node) { + return cloneResourceNode(resource); + } + + switch (resource.type) { + case "string": { + return { + type: "string", + name: resource.name, + translatable: resource.translatable, + node, + meta: cloneTextMeta(resource.meta), + }; + } + case "string-array": { + const childItems = (Array.isArray(node.$$) ? node.$$ : []).filter( + (child: any) => child?.["#name"] === "item", + ); + node.item = childItems; + if (childItems.length < resource.items.length) { + return cloneResourceNode(resource); + } + const items: StringArrayItemNode[] = resource.items.map((item, index) => { + const nodeItem = childItems[index]; + if (!nodeItem) { + return { + node: deepClone(item.node), + meta: cloneTextMeta(item.meta), + }; + } + return { + node: nodeItem, + meta: cloneTextMeta(item.meta), + }; + }); + return { + type: "string-array", + name: resource.name, + translatable: resource.translatable, + node, + items, + }; + } + case "plurals": { + const childItems = (Array.isArray(node.$$) ? node.$$ : []).filter( + (child: any) => child?.["#name"] === "item", + ); + node.item = childItems; + const itemMap = new Map<string, any>(); + for (const item of childItems) { + if (item?.$?.quantity) { + itemMap.set(item.$.quantity, item); + } + } + const items: PluralsItemNode[] = []; + for (const templateItem of resource.items) { + const nodeItem = itemMap.get(templateItem.quantity); + if (!nodeItem) { + return cloneResourceNode(resource); + } + items.push({ + node: nodeItem, + quantity: templateItem.quantity, + meta: cloneTextMeta(templateItem.meta), + }); + } + return { + type: "plurals", + name: resource.name, + translatable: resource.translatable, + node, + items, + }; + } + case "bool": { + return { + type: "bool", + name: resource.name, + translatable: resource.translatable, + node, + meta: cloneTextMeta(resource.meta), + }; + } + case "integer": { + return { + type: "integer", + name: resource.name, + translatable: resource.translatable, + node, + meta: cloneTextMeta(resource.meta), + }; + } + } +} + +function takeResourceNode( + lookup: Map<string, any[]>, + type: AndroidResourceType, + name: string, +): any | undefined { + const key = resourceLookupKey(type, name); + const list = lookup.get(key); + if (!list || list.length === 0) { + return undefined; + } + return list.shift(); +} + +function resourceLookupKey(type: string, name: string): string { + return `${type}:${name}`; +} + +function extractValueFromResource(resource: AndroidResourceNode): any { + switch (resource.type) { + case "string": + return decodeAndroidText(segmentsToString(resource.meta.segments)); + case "string-array": + return resource.items.map((item) => + decodeAndroidText(segmentsToString(item.meta.segments)), + ); + case "plurals": { + const result: Record<string, string> = {}; + for (const item of resource.items) { + result[item.quantity] = decodeAndroidText( + segmentsToString(item.meta.segments), + ); + } + return result; + } + case "bool": { + const value = segmentsToString(resource.meta.segments).trim(); + return value === "true"; + } + case "integer": { + const value = parseInt( + segmentsToString(resource.meta.segments).trim(), + 10, + ); + return Number.isNaN(value) ? 0 : value; + } + } +} + +function inferTypeFromValue(value: any): AndroidResourceType { + if (typeof value === "string") { + return "string"; + } + if (Array.isArray(value)) { + return "string-array"; + } + if (value && typeof value === "object") { + return "plurals"; + } + if (typeof value === "boolean") { + return "bool"; + } + if (typeof value === "number" && Number.isInteger(value)) { + return "integer"; + } + throw new CLIError({ + message: "Unable to infer Android resource type from payload", + docUrl: "androidResourceError", + }); +} + +function extractResourceMetadata(xml: string) { + interface StackEntry { + name: string; + rawName: string; + attributes: Record<string, string>; + segments: ContentSegment[]; + items: Array<{ quantity?: string; meta: TextualMeta }>; + } + + interface StringMeta { + type: "string"; + name: string; + translatable: boolean; + meta: TextualMeta; + } + + interface StringArrayMeta { + type: "string-array"; + name: string; + translatable: boolean; + items: Array<{ meta: TextualMeta }>; + } + + interface PluralsMeta { + type: "plurals"; + name: string; + translatable: boolean; + items: Array<{ quantity: string; meta: TextualMeta }>; + } + + interface BoolMeta { + type: "bool"; + name: string; + translatable: boolean; + meta: TextualMeta; + } + + interface IntegerMeta { + type: "integer"; + name: string; + translatable: boolean; + meta: TextualMeta; + } + + type ResourceMeta = + | StringMeta + | StringArrayMeta + | PluralsMeta + | BoolMeta + | IntegerMeta; + + const parser = sax.parser(true, { + trim: false, + normalize: false, + lowercase: false, + }); + + const stack: StackEntry[] = []; + const result: ResourceMeta[] = []; + + parser.onopentag = (node) => { + const lowerName = node.name.toLowerCase(); + const attributes: Record<string, string> = {}; + for (const [key, value] of Object.entries(node.attributes ?? {})) { + attributes[key.toLowerCase()] = String(value); + } + stack.push({ + name: lowerName, + rawName: node.name, + attributes, + segments: [], + items: [], + }); + + if ( + lowerName !== "resources" && + lowerName !== "item" && + !isResourceElementName(lowerName) + ) { + const attrString = Object.entries(node.attributes ?? {}) + .map( + ([key, value]) => ` ${key}="${escapeAttributeValue(String(value))}"`, + ) + .join(""); + appendSegmentToNearestResource(stack, { + kind: "text", + value: `<${node.name}${attrString}>`, + }); + } + }; + + parser.ontext = (text) => { + if (!text) { + return; + } + appendSegmentToNearestResource(stack, { kind: "text", value: text }); + }; + + parser.oncdata = (cdata) => { + appendSegmentToNearestResource(stack, { kind: "cdata", value: cdata }); + }; + + parser.onclosetag = () => { + const entry = stack.pop(); + if (!entry) { + return; + } + + const parent = stack[stack.length - 1]; + + if (entry.name === "item" && parent) { + const meta = makeTextMeta(entry.segments); + parent.items.push({ + quantity: entry.attributes.quantity, + meta, + }); + return; + } + + if ( + entry.name !== "resources" && + entry.name !== "item" && + !isResourceElementName(entry.name) + ) { + appendSegmentToNearestResource(stack, { + kind: "text", + value: `</${entry.rawName}>`, + }); + return; + } + + if (!isResourceElementName(entry.name)) { + return; + } + + const name = entry.attributes.name; + if (!name) { + return; + } + + const translatable = + (entry.attributes.translatable ?? "").toLowerCase() !== "false"; + + switch (entry.name) { + case "string": { + result.push({ + type: "string", + name, + translatable, + meta: makeTextMeta(entry.segments), + }); + break; + } + case "string-array": { + result.push({ + type: "string-array", + name, + translatable, + items: entry.items.map((item) => ({ + meta: cloneTextMeta(item.meta), + })), + }); + break; + } + case "plurals": { + const items: Array<{ quantity: string; meta: TextualMeta }> = []; + for (const item of entry.items) { + if (!item.quantity) { + continue; + } + items.push({ + quantity: item.quantity, + meta: cloneTextMeta(item.meta), + }); + } + result.push({ + type: "plurals", + name, + translatable, + items, + }); + break; + } + case "bool": { + result.push({ + type: "bool", + name, + translatable, + meta: makeTextMeta(entry.segments), + }); + break; + } + case "integer": { + result.push({ + type: "integer", + name, + translatable, + meta: makeTextMeta(entry.segments), + }); + break; + } + } + }; + + parser.write(xml).close(); + + return result; +} + +function appendSegmentToNearestResource( + stack: Array<{ + name: string; + segments: ContentSegment[]; + attributes: Record<string, string>; + }>, + segment: ContentSegment, +) { + for (let index = stack.length - 1; index >= 0; index--) { + const entry = stack[index]; + if ( + entry.name === "string" || + entry.name === "item" || + entry.name === "bool" || + entry.name === "integer" + ) { + entry.segments.push(segment); + return; + } + } +} + +function isResourceElementName( + value: string | undefined, +): value is AndroidResourceType { + return ( + value === "string" || + value === "string-array" || + value === "plurals" || + value === "bool" || + value === "integer" + ); +} + +function deepClone<T>(value: T): T { + return value === undefined ? value : JSON.parse(JSON.stringify(value)); +} + +function serializeElement(node: any): string { + if (!node) { + return ""; + } + + const name = node["#name"] ?? "resources"; + + if (name === "__text__") { + return node._ ?? ""; + } + + if (name === "__cdata") { + return `<![CDATA[${node._ ?? ""}]]>`; + } + + if (name === "__comment__") { + return `<!--${node._ ?? ""}-->`; + } + + const attributes = node.$ ?? {}; + const attrString = Object.entries(attributes) + .map(([key, value]) => ` ${key}="${escapeAttributeValue(String(value))}"`) + .join(""); + + const children = Array.isArray(node.$$) ? node.$$ : []; + + if (children.length === 0) { + const textContent = node._ ?? ""; + return `<${name}${attrString}>${textContent}</${name}>`; + } + + const childContent = children.map(serializeElement).join(""); + return `<${name}${attrString}>${childContent}</${name}>`; +} + +function escapeAttributeValue(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/'/g, "'"); +} + +function decodeAndroidText(value: string): string { + return value.replace(/\\'/g, "'"); +} diff --git a/packages/cli/src/cli/loaders/csv-per-locale.spec.ts b/packages/cli/src/cli/loaders/csv-per-locale.spec.ts new file mode 100644 index 000000000..7436b602b --- /dev/null +++ b/packages/cli/src/cli/loaders/csv-per-locale.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import createCsvPerLocaleLoader from "./csv-per-locale"; + +describe("csv-per-locale loader", () => { + const sampleCsv = `id,name,description +35,Hello,Welcome +36,Bye,Farewell`; + + const sampleCsvWithDuplicates = `id,name,name,description +35,Hello,Hi,Welcome +36,Bye,Goodbye,Farewell`; + + it("pull should parse CSV to array of objects", async () => { + const loader = createCsvPerLocaleLoader(); + loader.setDefaultLocale("en"); + + const result = await loader.pull("en", sampleCsv); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: "35", + name: "Hello", + description: "Welcome", + }); + expect(result[1]).toEqual({ + id: "36", + name: "Bye", + description: "Farewell", + }); + }); + + it("pull should handle duplicate headers by deduplicating", async () => { + const loader = createCsvPerLocaleLoader(); + loader.setDefaultLocale("en"); + + const result = await loader.pull("en", sampleCsvWithDuplicates); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: "35", + name: "Hello", + name__2: "Hi", + description: "Welcome", + }); + expect(result[1]).toEqual({ + id: "36", + name: "Bye", + name__2: "Goodbye", + description: "Farewell", + }); + }); + + it("pull should return empty object for empty CSV", async () => { + const loader = createCsvPerLocaleLoader(); + loader.setDefaultLocale("en"); + + const result = await loader.pull("en", ""); + expect(result).toEqual({}); + }); + + it("push should serialize array back to CSV preserving original headers", async () => { + const loader = createCsvPerLocaleLoader(); + loader.setDefaultLocale("en"); + const originalInput = sampleCsv; + await loader.pull("en", originalInput); + + const data = [ + { id: "35", name: "Hello edited", description: "Welcome edited" }, + { id: "36", name: "Bye edited", description: "Farewell edited" }, + ]; + + // @ts-expect-error - originalInput is used internally but not in public interface + const csv = await loader.push("en", data, originalInput); + + expect(csv).toContain("id,name,description"); + expect(csv).toContain("35,Hello edited,Welcome edited"); + expect(csv).toContain("36,Bye edited,Farewell edited"); + }); + + it("push should preserve all columns from original CSV including duplicates", async () => { + const loader = createCsvPerLocaleLoader(); + loader.setDefaultLocale("en"); + const originalInput = sampleCsvWithDuplicates; + await loader.pull("en", originalInput); + + const data = [ + { id: "35", name: "Hola", name__2: "Hola2", description: "Bienvenido" }, + { id: "36", name: "Adiós", name__2: "Adiós2", description: "Despedida" }, + ]; + + // @ts-expect-error - originalInput is used internally but not in public interface + const csv = await loader.push("en", data, originalInput); + + expect(csv).toContain("id,name,name,description"); + expect(csv).toContain("35,Hola,Hola2,Bienvenido"); + expect(csv).toContain("36,Adiós,Adiós2,Despedida"); + }); + + it("push should handle object with numeric keys", async () => { + const loader = createCsvPerLocaleLoader(); + loader.setDefaultLocale("en"); + const originalInput = sampleCsv; + await loader.pull("en", originalInput); + + const data = { + 0: { id: "35", name: "Hola", description: "Bienvenido" }, + 1: { id: "36", name: "Adiós", description: "Despedida" }, + }; + + // @ts-expect-error - originalInput is used internally but not in public interface + const csv = await loader.push("en", data, originalInput); + + expect(csv).toContain("id,name,description"); + expect(csv).toContain("35,Hola,Bienvenido"); + expect(csv).toContain("36,Adiós,Despedida"); + }); +}); \ No newline at end of file diff --git a/packages/cli/src/cli/loaders/csv-per-locale.ts b/packages/cli/src/cli/loaders/csv-per-locale.ts new file mode 100644 index 000000000..f14d72311 --- /dev/null +++ b/packages/cli/src/cli/loaders/csv-per-locale.ts @@ -0,0 +1,68 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import { parse } from "csv-parse/sync"; +import { stringify } from "csv-stringify/sync"; + +function dedupeHeaders(headers: string[]) { + const seen = new Map<string, number>(); + + return headers.map((h) => { + const count = seen.get(h) ?? 0; + seen.set(h, count + 1); + return count === 0 ? h : `${h}__${count + 1}`; + }); +} + +export default function createCsvPerLocaleLoader(): ILoader< + string, + Record<string, any> +> { + return createLoader({ + async pull(_locale, input) { + if (!input?.trim()) return {}; + + + const parsed = parse(input, { + skip_empty_lines: true, + columns: (headers: string[]) => { + const dedupedHeaders = dedupeHeaders(headers); + return dedupedHeaders; + }, + }) as Array<Record<string, any>>; + + if (parsed.length === 0) return {}; + + return parsed; + }, + async push(_locale, data, originalInput) { + + const rawRows = parse(originalInput || "", { + skip_empty_lines: true, + }) as string[][]; + + const originalHeaders = rawRows[0]; + + const dedupedHeaders = dedupeHeaders(originalHeaders); + + const columns = originalHeaders.map((header, i) => ({ + key: dedupedHeaders[i], + header, + })); + + const rows = Object.values(data).filter( + (row) => + row && + Object.values(row).some( + (v) => v !== undefined && v !== null, + ), + ); + // const output = stringify(rows, { header: true }).trimEnd(); + const output = stringify(rows, { + header: true, + columns, + }).trimEnd(); + + return output; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/csv.spec.ts b/packages/cli/src/cli/loaders/csv.spec.ts new file mode 100644 index 000000000..8998d6b0b --- /dev/null +++ b/packages/cli/src/cli/loaders/csv.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { parse } from "csv-parse/sync"; +import createCsvLoader from "./csv"; + +// Helper to build CSV strings easily +function buildCsv(rows: string[][]): string { + return rows.map((r) => r.join(",")).join("\n"); +} + +describe("csv loader", () => { + const sampleCsv = buildCsv([ + ["id", "en", "es"], + ["hello", "Hello", "Hola"], + ["bye", "Bye", "Adiós"], + ["unused", "", "Sin uso"], + ]); + + it("pull should extract translation map for the requested locale and skip empty values", async () => { + const loader = createCsvLoader(); + loader.setDefaultLocale("en"); + + const enResult = await loader.pull("en", sampleCsv); + expect(enResult).toEqual({ hello: "Hello", bye: "Bye" }); + + const esResult = await loader.pull("es", sampleCsv); + expect(esResult).toEqual({ + hello: "Hola", + bye: "Adiós", + unused: "Sin uso", + }); + }); + + it("push should update existing rows and append new keys for the same locale", async () => { + const loader = createCsvLoader(); + loader.setDefaultLocale("en"); + await loader.pull("en", sampleCsv); + + const updatedCsv = await loader.push("en", { + hello: "Hello edited", + newKey: "New Message", + }); + + const parsed = parse(updatedCsv, { columns: true, skip_empty_lines: true }); + expect(parsed).toEqual([ + { id: "hello", en: "Hello edited", es: "Hola" }, + { id: "bye", en: "Bye", es: "Adiós" }, + { id: "unused", en: "", es: "Sin uso" }, + { id: "", en: "New Message", es: "" }, + ]); + }); + + it("push should add a new locale column when pushing for a different locale", async () => { + const loader = createCsvLoader(); + loader.setDefaultLocale("en"); + await loader.pull("en", sampleCsv); + + const esCsv = await loader.push("es", { + hello: "Hola", + bye: "Adiós", + }); + + const parsed = parse(esCsv, { columns: true, skip_empty_lines: true }); + expect(parsed).toEqual([ + { id: "hello", en: "Hello", es: "Hola" }, + { id: "bye", en: "Bye", es: "Adiós" }, + { id: "unused", en: "", es: "Sin uso" }, + ]); + }); + + it("push should add a completely new locale column when it previously didn't exist", async () => { + const loader = createCsvLoader(); + loader.setDefaultLocale("en"); + await loader.pull("en", sampleCsv); // sampleCsv only has en & es columns + + const frCsv = await loader.push("fr", { + hello: "Bonjour", + bye: "Au revoir", + }); + + const parsed = parse(frCsv, { columns: true, skip_empty_lines: true }); + // Expect new column 'fr' to exist alongside existing ones, with empty strings when no translation provided + expect(parsed).toEqual([ + { id: "hello", en: "Hello", es: "Hola", fr: "Bonjour" }, + { id: "bye", en: "Bye", es: "Adiós", fr: "Au revoir" }, + { id: "unused", en: "", es: "Sin uso", fr: "" }, + ]); + }); + + it("should throw an error if the first pull is not for the default locale", async () => { + const loader = createCsvLoader(); + loader.setDefaultLocale("en"); + + await expect(loader.pull("es", sampleCsv)).rejects.toThrow( + "The first pull must be for the default locale", + ); + }); +}); diff --git a/packages/cli/src/cli/loaders/csv.ts b/packages/cli/src/cli/loaders/csv.ts new file mode 100644 index 000000000..982bdaf58 --- /dev/null +++ b/packages/cli/src/cli/loaders/csv.ts @@ -0,0 +1,107 @@ +import { parse } from "csv-parse/sync"; +import { stringify } from "csv-stringify/sync"; +import _ from "lodash"; +import { ILoader } from "./_types"; +import { composeLoaders, createLoader } from "./_utils"; + +/** + * Tries to detect the key column name from a csvString. + * + * Current logic: get first cell > 'KEY' fallback if empty + */ +export function detectKeyColumnName(csvString: string) { + const row: string[] | undefined = parse(csvString)[0]; + const firstColumn = row?.[0]?.trim(); + return firstColumn || "KEY"; +} + +export default function createCsvLoader() { + return composeLoaders(_createCsvLoader(), createPullOutputCleaner()); +} + +type InternalTransferState = { + keyColumnName: string; + inputParsed: Record<string, any>[]; + items: Record<string, string>; +}; + +function _createCsvLoader(): ILoader<string, InternalTransferState> { + return createLoader({ + async pull(locale, input) { + const keyColumnName = detectKeyColumnName( + input.split("\n").find((l) => l.length)!, + ); + const inputParsed = parse(input, { + columns: true, + skip_empty_lines: true, + relax_column_count_less: true, + }) as Record<string, any>[]; + + const items: Record<string, string> = {}; + + // Assign keys that already have translation so AI doesn't re-generate it. + _.forEach(inputParsed, (row) => { + const key = row[keyColumnName]; + if (key && row[locale] && row[locale].trim() !== "") { + items[key] = row[locale]; + } + }); + + return { + inputParsed, + keyColumnName, + items, + }; + }, + async push(locale, { inputParsed, keyColumnName, items }) { + const columns = + inputParsed.length > 0 + ? Object.keys(inputParsed[0]) + : [keyColumnName, locale]; + if (!columns.includes(locale)) { + columns.push(locale); + } + + const updatedRows = inputParsed.map((row) => ({ + ...row, + [locale]: items[row[keyColumnName]] || row[locale] || "", + })); + const existingKeys = new Set( + inputParsed.map((row) => row[keyColumnName]), + ); + + Object.entries(items).forEach(([key, value]) => { + if (!existingKeys.has(key)) { + const newRow: Record<string, string> = { + [keyColumnName]: key, + ...Object.fromEntries(columns.map((column) => [column, ""])), + }; + newRow[locale] = value; + updatedRows.push(newRow); + } + }); + + return stringify(updatedRows, { + header: true, + columns, + }); + }, + }); +} + +/** + * This is a simple extra loader that is used to clean the data written to lockfile + */ +function createPullOutputCleaner(): ILoader< + InternalTransferState, + Record<string, string> +> { + return createLoader({ + async pull(_locale, input) { + return input.items; + }, + async push(_locale, data, _oI, _oL, pullInput) { + return { ...pullInput!, items: data }; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/dato/_base.ts b/packages/cli/src/cli/loaders/dato/_base.ts new file mode 100644 index 000000000..fe5676f87 --- /dev/null +++ b/packages/cli/src/cli/loaders/dato/_base.ts @@ -0,0 +1,58 @@ +import Z from "zod"; + +// DatoCMS config +export const datoConfigSchema = Z.object({ + project: Z.string(), + models: Z.record( + Z.string(), + Z.object({ + records: Z.array(Z.string()).optional(), + fields: Z.array(Z.string()).optional(), + }), + ), +}); + +export type DatoConfig = Z.infer<typeof datoConfigSchema>; + +// DatoCMS settings +export const datoSettingsSchema = Z.object({ + auth: Z.object({ + apiKey: Z.string(), + }), +}); + +export type DatoSettings = Z.infer<typeof datoSettingsSchema>; + +export const DEFAULT_LOCALE = "en"; + +// + +export type DatoRecordPayload = { + [field: string]: { + [locale: string]: DatoValue; + }; +}; + +export type DatoValue = DatoSimpleValue | DatoComplexValue; +export type DatoSimpleValue = DatoPrimitive | DastDocument; +export type DatoComplexValue = DatoBlock | DatoBlock[]; + +export type DatoPrimitive = null | string | boolean | number; + +export type DastDocument = { + schema: "dast"; + document: DastDocumentNode; +}; + +export type DastDocumentNode = { + type: "root" | "span" | "paragraph"; + value?: DatoPrimitive; + children?: DastDocumentNode[]; +}; + +export type DatoBlock = { + id?: string; + type: "item"; + attributes: Record<string, DatoSimpleValue>; + relationships: any; +}; diff --git a/packages/cli/src/cli/loaders/dato/_utils.ts b/packages/cli/src/cli/loaders/dato/_utils.ts new file mode 100644 index 000000000..fc714ea61 --- /dev/null +++ b/packages/cli/src/cli/loaders/dato/_utils.ts @@ -0,0 +1,298 @@ +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()]); + } + } +} diff --git a/packages/cli/src/cli/loaders/dato/api.ts b/packages/cli/src/cli/loaders/dato/api.ts new file mode 100644 index 000000000..d747186ec --- /dev/null +++ b/packages/cli/src/cli/loaders/dato/api.ts @@ -0,0 +1,241 @@ +import _ from "lodash"; +import { ILoader } from "../_types"; +import { createLoader } from "../_utils"; +import createDatoClient, { DatoClient } from "./_utils"; +import { SimpleSchemaTypes } from "@datocms/cma-client-node"; +import { DatoConfig } from "./_base"; +import inquirer from "inquirer"; + +export type DatoApiLoaderOutput = { + [modelId: string]: { + fields: SimpleSchemaTypes.Field[]; + records: SimpleSchemaTypes.Item[]; + }; +}; + +export type DatoApiLoaderCtx = { + models: { + [modelId: string]: { + fields: SimpleSchemaTypes.Field[]; + records: SimpleSchemaTypes.Item[]; + }; + }; +}; + +export default function createDatoApiLoader( + config: DatoConfig, + onConfigUpdate: (config: DatoConfig) => void, +): ILoader<void, DatoApiLoaderOutput, DatoApiLoaderCtx> { + const dato = createDatoClient({ + apiKey: process.env.DATO_API_TOKEN || "", + projectId: config.project, + }); + return createLoader({ + init: async () => { + const result: DatoApiLoaderCtx = { + models: {}, + }; + const updatedConfig = _.cloneDeep(config); + console.log(`Initializing DatoCMS loader...`); + + const project = await dato.findProject(); + const modelChoices = await getModelChoices(dato, config); + const selectedModels = await promptModelSelection(modelChoices); + + for (const modelId of selectedModels) { + if (!updatedConfig.models[modelId]) { + updatedConfig.models[modelId] = { + fields: [], + records: [], + }; + } + } + + for (const modelId of Object.keys(updatedConfig.models)) { + if (!selectedModels.includes(modelId)) { + delete updatedConfig.models[modelId]; + } + } + + for (const modelId of _.keys(updatedConfig.models)) { + const { modelName, fields } = await getModelFields(dato, modelId); + + if (fields.length > 0) { + result.models[modelId] = { fields: [], records: [] }; + + const fieldInfos = await getFieldDetails(dato, fields); + const fieldChoices = createFieldChoices(fieldInfos); + const selectedFields = await promptFieldSelection( + modelName, + fieldChoices, + ); + + for (const fieldInfo of fieldInfos) { + const isLocalized = await updateFieldLocalization( + dato, + fieldInfo, + selectedFields.includes(fieldInfo.id), + ); + if (isLocalized) { + result.models[modelId].fields.push(fieldInfo); + updatedConfig.models[modelId].fields = _.uniq([ + ...(updatedConfig.models[modelId].fields || []), + fieldInfo.api_key, + ]); + } + } + + const records = await dato.findRecordsForModel(modelId); + const recordChoices = createRecordChoices( + records, + config.models[modelId]?.records || [], + project, + ); + const selectedRecords = await promptRecordSelection( + modelName, + recordChoices, + ); + + result.models[modelId].records = records.filter((record) => + selectedRecords.includes(record.id), + ); + updatedConfig.models[modelId].records = selectedRecords; + } + } + console.log(`DatoCMS loader initialized.`); + onConfigUpdate(updatedConfig); + return result; + }, + async pull(locale, input, initCtx) { + const result: DatoApiLoaderOutput = {}; + + for (const modelId of _.keys(initCtx?.models || {})) { + let records = initCtx?.models[modelId].records || []; + const recordIds = records.map((record) => record.id); + records = await dato.findRecords(recordIds); + console.log(`Fetched ${records.length} records for model ${modelId}`); + + if (records.length > 0) { + result[modelId] = { + fields: initCtx?.models?.[modelId]?.fields || [], + records: records, + }; + } + } + return result; + }, + async push(locale, data, originalInput) { + for (const modelId of _.keys(data)) { + for (let i = 0; i < data[modelId].records.length; i++) { + const record = data[modelId].records[i]; + console.log( + `Updating record ${i + 1}/${ + data[modelId].records.length + } for model ${modelId}...`, + ); + await dato.updateRecord(record.id, record); + } + } + }, + }); +} + +export async function getModelFields(dato: any, modelId: string) { + const modelInfo = await dato.findModel(modelId); + return { + modelName: modelInfo.name, + fields: _.filter(modelInfo.fields, (field) => field.type === "field"), + }; +} + +export async function getFieldDetails( + dato: DatoClient, + fields: SimpleSchemaTypes.Field[], +) { + return Promise.all(fields.map((field) => dato.findField(field.id))); +} + +export function createFieldChoices(fieldInfos: SimpleSchemaTypes.Field[]) { + return fieldInfos.map((field) => ({ + name: field.label, + value: field.id, + checked: field.localized, + })); +} + +export async function promptFieldSelection(modelName: string, choices: any[]) { + const { selectedFields } = await inquirer.prompt([ + { + type: "checkbox", + name: "selectedFields", + message: `Select fields to enable localization for model "${modelName}":`, + choices, + pageSize: process.stdout.rows - 4, // Subtract some rows for prompt text and margins + }, + ]); + return selectedFields; +} + +export async function updateFieldLocalization( + dato: any, + fieldInfo: SimpleSchemaTypes.Field, + shouldBeLocalized: boolean, +) { + if (shouldBeLocalized !== fieldInfo.localized) { + console.log( + `${shouldBeLocalized ? "Enabling" : "Disabling"} localization for ${ + fieldInfo.label + }...`, + ); + await dato.updateField(fieldInfo.id, { localized: shouldBeLocalized }); + } + return shouldBeLocalized; +} + +export function createRecordChoices( + records: SimpleSchemaTypes.Item[], + selectedIds: string[] = [], + project: SimpleSchemaTypes.Site, +) { + return records.map((record) => ({ + name: `${record.id} - https://${project.internal_domain}/editor/item_types/${record.item_type.id}/items/${record.id}`, + value: record.id, + checked: selectedIds?.includes(record.id), + })); +} + +export async function promptRecordSelection(modelName: string, choices: any[]) { + const { selectedRecords } = await inquirer.prompt([ + { + type: "checkbox", + name: "selectedRecords", + message: `Select records to include for model "${modelName}":`, + choices, + pageSize: process.stdout.rows - 4, // Subtract some rows for prompt text and margins + }, + ]); + return selectedRecords; +} + +export async function getModelChoices(dato: DatoClient, config: DatoConfig) { + const models = await dato.findModels(); + return models.map((model) => ({ + name: `${model.name} (${model.api_key})`, + value: model.id, + checked: config.models[model.id] !== undefined, + pageSize: process.stdout.rows - 4, // Subtract some rows for prompt text and margins + })); +} + +export async function promptModelSelection(choices: any[]) { + const { selectedModels } = await inquirer.prompt([ + { + type: "checkbox", + name: "selectedModels", + message: "Select models to include:", + choices, + pageSize: process.stdout.rows - 4, // Subtract some rows for prompt text and margins + }, + ]); + return selectedModels; +} diff --git a/packages/cli/src/cli/loaders/dato/extract.ts b/packages/cli/src/cli/loaders/dato/extract.ts new file mode 100644 index 000000000..cd9c81135 --- /dev/null +++ b/packages/cli/src/cli/loaders/dato/extract.ts @@ -0,0 +1,355 @@ +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)) + ); +} diff --git a/packages/cli/src/cli/loaders/dato/filter.ts b/packages/cli/src/cli/loaders/dato/filter.ts new file mode 100644 index 000000000..900a3f768 --- /dev/null +++ b/packages/cli/src/cli/loaders/dato/filter.ts @@ -0,0 +1,73 @@ +import _ from "lodash"; +import fs from "fs"; +import { ILoader } from "../_types"; +import { createLoader } from "../_utils"; +import { DatoApiLoaderOutput } from "./api"; + +export type DatoFilterLoaderOutput = { + [modelId: string]: { + [recordId: string]: { + [fieldName: string]: any; + }; + }; +}; + +export default function createDatoFilterLoader(): ILoader< + DatoApiLoaderOutput, + DatoFilterLoaderOutput +> { + return createLoader({ + async pull(locale, input) { + const result: DatoFilterLoaderOutput = {}; + + for (const [modelId, modelInfo] of _.entries(input)) { + result[modelId] = {}; + for (const record of modelInfo.records) { + result[modelId][record.id] = _.chain(modelInfo.fields) + .mapKeys((field) => field.api_key) + .mapValues((field) => _.get(record, [field.api_key, locale])) + .value(); + } + } + + return result; + }, + async push(locale, data, originalInput, originalLocale) { + const result = _.cloneDeep(originalInput || {}); + + for (const [modelId, modelInfo] of _.entries(result)) { + for (const record of modelInfo.records) { + for (const [fieldId, fieldValue] of _.entries(record)) { + const fieldInfo = modelInfo.fields.find( + (field) => field.api_key === fieldId, + ); + if (fieldInfo) { + const sourceFieldValue = _.get(fieldValue, [originalLocale]); + const targetFieldValue = _.get(data, [ + modelId, + record.id, + fieldId, + ]); + if (targetFieldValue) { + _.set(record, [fieldId, locale], targetFieldValue); + } else { + _.set(record, [fieldId, locale], sourceFieldValue); + } + + _.chain(fieldValue) + .keys() + .reject((loc) => loc === locale || loc === originalLocale) + .filter((loc) => _.isEmpty(_.get(fieldValue, [loc]))) + .forEach((loc) => + _.set(record, [fieldId, loc], sourceFieldValue), + ) + .value(); + } + } + } + } + + return result; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/dato/index.ts b/packages/cli/src/cli/loaders/dato/index.ts new file mode 100644 index 000000000..bec0decd5 --- /dev/null +++ b/packages/cli/src/cli/loaders/dato/index.ts @@ -0,0 +1,31 @@ +import fs from "fs"; +import JSON5 from "json5"; +import { composeLoaders } from "../_utils"; +import { datoConfigSchema } from "./_base"; +import createDatoFilterLoader from "./filter"; +import createDatoApiLoader from "./api"; +import createDatoExtractLoader from "./extract"; + +export default function createDatoLoader(configFilePath: string) { + try { + const configContent = fs.readFileSync(configFilePath, "utf-8"); + const datoConfig = datoConfigSchema.parse(JSON5.parse(configContent)); + + return composeLoaders( + createDatoApiLoader(datoConfig, (updatedConfig) => + fs.writeFileSync( + configFilePath, + JSON5.stringify(updatedConfig, null, 2), + ), + ), + createDatoFilterLoader(), + createDatoExtractLoader(), + ); + } catch (error: any) { + throw new Error( + [`Failed to parse DatoCMS config file.`, `Error: ${error.message}`].join( + "\n\n", + ), + ); + } +} diff --git a/packages/cli/src/cli/loaders/ejs.spec.ts b/packages/cli/src/cli/loaders/ejs.spec.ts new file mode 100644 index 000000000..8d5dab1a4 --- /dev/null +++ b/packages/cli/src/cli/loaders/ejs.spec.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from "vitest"; +import createEjsLoader from "./ejs"; + +describe("EJS Loader", () => { + const loader = createEjsLoader().setDefaultLocale("en"); + + describe("pull", () => { + it("should extract translatable text from simple EJS template", async () => { + const input = ` + <h1>Welcome to our website</h1> + <p>Hello <%= name %>, you have <%= messages.length %> messages.</p> + <footer>© 2024 Our Company</footer> + `; + + const result = await loader.pull("en", input); + + // Check that we have extracted some translatable content + expect(Object.keys(result).length).toBeGreaterThan(0); + + // Check that the EJS variables are not included in the translatable text + const allValues = Object.values(result).join(" "); + expect(allValues).not.toContain("<%= name %>"); + expect(allValues).not.toContain("<%= messages.length %>"); + + // Check that we have the main content + expect(allValues).toContain("Welcome to our website"); + expect(allValues).toContain("Hello"); + expect(allValues).toContain("messages"); + expect(allValues).toContain("© 2024 Our Company"); + }); + + it("should handle EJS templates with various tag types", async () => { + const input = ` + <div> + <h2>User Dashboard</h2> + <% if (user.isAdmin) { %> + <p>Admin Panel</p> + <% } %> + <%# This is a comment %> + <p>Welcome back, <%- user.name %></p> + <span>Last login: <%= formatDate(user.lastLogin) %></span> + </div> + `; + + const result = await loader.pull("en", input); + + expect(result).toHaveProperty("text_0"); + expect(result).toHaveProperty("text_1"); + expect(Object.keys(result).length).toBeGreaterThan(0); + }); + + it("should handle empty input", async () => { + const result = await loader.pull("en", ""); + expect(result).toEqual({}); + }); + + it("should handle input with only EJS tags", async () => { + const input = "<%= variable %><% if (condition) { %><% } %>"; + const result = await loader.pull("en", input); + expect(result).toEqual({}); + }); + + it("should handle mixed content", async () => { + const input = ` + Welcome <%= user.name %>! + <% for (let i = 0; i < items.length; i++) { %> + Item: <%= items[i].name %> + <% } %> + Thank you for visiting. + `; + + const result = await loader.pull("en", input); + expect(Object.keys(result).length).toBeGreaterThan(0); + expect( + Object.values(result).some((text) => text.includes("Welcome")), + ).toBe(true); + expect( + Object.values(result).some((text) => text.includes("Thank you")), + ).toBe(true); + }); + }); + + describe("push", () => { + it("should reconstruct EJS template with translated content", async () => { + const originalInput = `<h1>Welcome</h1><p>Hello <%= name %></p>`; + + // First pull to get the structure + const pulled = await loader.pull("en", originalInput); + + // Static translated data object based on actual loader behavior + const translated = { + text_0: "Bienvenido", + text_1: "Hola", + }; + + const result = await loader.push("es", translated); + + // Test against the expected reconstructed string + const expectedOutput = `<h1>Bienvenido</h1><p>Hola <%= name %></p>`; + + expect(result).toBe(expectedOutput); + }); + + it("should handle complex EJS templates", async () => { + const originalInput = `<h2>Dashboard</h2><% if (user) { %><p>Welcome</p><% } %>`; + + const pulled = await loader.pull("en", originalInput); + + // Static translated data object + const translated = { + text_0: "Tablero", + text_1: "Bienvenido", + }; + + const result = await loader.push("es", translated); + + // Test against the expected reconstructed string + const expectedOutput = `<h2>Tablero</h2><% if (user) { %><p>Bienvenido</p><% } %>`; + + expect(result).toBe(expectedOutput); + }); + + it("should handle missing original input", async () => { + const translated = { + text_0: "Hello World", + text_1: "This is a test", + }; + + const result = await loader.push("es", translated); + + expect(result).toContain("Hello World"); + expect(result).toContain("This is a test"); + }); + }); + + describe("round trip", () => { + it("should maintain EJS functionality after round trip", async () => { + const originalInput = ` + <h1>Welcome <%= title %></h1> + <% if (showMessage) { %> + <p>Hello <%= user.name %>, you have <%= count %> new messages.</p> + <% } %> + <ul> + <% items.forEach(function(item) { %> + <li><%= item.name %> - $<%= item.price %></li> + <% }); %> + </ul> + <footer>Contact us at info@company.com</footer> + `; + + // Pull original content + const pulled = await loader.pull("en", originalInput); + + // Push back without translation (should be identical) + const reconstructed = await loader.push("en", pulled); + + // Verify EJS tags are preserved + expect(reconstructed).toContain("<%= title %>"); + expect(reconstructed).toContain("<% if (showMessage) { %>"); + expect(reconstructed).toContain("<%= user.name %>"); + expect(reconstructed).toContain("<%= count %>"); + expect(reconstructed).toContain("<% items.forEach(function(item) { %>"); + expect(reconstructed).toContain("<%= item.name %>"); + expect(reconstructed).toContain("<%= item.price %>"); + expect(reconstructed).toContain("<% }); %>"); + expect(reconstructed).toContain("Contact us at info@company.com"); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/ejs.ts b/packages/cli/src/cli/loaders/ejs.ts new file mode 100644 index 000000000..6ebc225ba --- /dev/null +++ b/packages/cli/src/cli/loaders/ejs.ts @@ -0,0 +1,183 @@ +import * as ejs from "ejs"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +interface EjsParseResult { + content: string; + translatable: Record<string, string>; +} + +function parseEjsForTranslation(input: string): EjsParseResult { + const translatable: Record<string, string> = {}; + let counter = 0; + + // Regular expression for all EJS tags + const ejsTagRegex = /<%[\s\S]*?%>/g; + + // Split content by EJS tags, preserving both text and EJS parts + const parts: Array<{ type: "text" | "ejs"; content: string }> = []; + let lastIndex = 0; + let match; + + while ((match = ejsTagRegex.exec(input)) !== null) { + // Add text before the tag + if (match.index > lastIndex) { + parts.push({ + type: "text", + content: input.slice(lastIndex, match.index), + }); + } + // Add the EJS tag + parts.push({ + type: "ejs", + content: match[0], + }); + lastIndex = match.index + match[0].length; + } + + // Add remaining text after the last tag + if (lastIndex < input.length) { + parts.push({ + type: "text", + content: input.slice(lastIndex), + }); + } + + // Build the template and extract translatable content + let template = ""; + + for (const part of parts) { + if (part.type === "ejs") { + // Keep EJS tags as-is + template += part.content; + } else { + // For text content, extract translatable parts while preserving HTML structure + const textContent = part.content; + + // Extract text content from HTML tags while preserving structure + const htmlTagRegex = /<[^>]+>/g; + const textParts: Array<{ type: "html" | "text"; content: string }> = []; + let lastTextIndex = 0; + let htmlMatch; + + while ((htmlMatch = htmlTagRegex.exec(textContent)) !== null) { + // Add text before the HTML tag + if (htmlMatch.index > lastTextIndex) { + const textBefore = textContent.slice(lastTextIndex, htmlMatch.index); + if (textBefore.trim()) { + textParts.push({ type: "text", content: textBefore }); + } else { + textParts.push({ type: "html", content: textBefore }); + } + } + // Add the HTML tag + textParts.push({ type: "html", content: htmlMatch[0] }); + lastTextIndex = htmlMatch.index + htmlMatch[0].length; + } + + // Add remaining text after the last HTML tag + if (lastTextIndex < textContent.length) { + const remainingText = textContent.slice(lastTextIndex); + if (remainingText.trim()) { + textParts.push({ type: "text", content: remainingText }); + } else { + textParts.push({ type: "html", content: remainingText }); + } + } + + // If no HTML tags found, treat entire content as text + if (textParts.length === 0) { + const trimmedContent = textContent.trim(); + if (trimmedContent) { + textParts.push({ type: "text", content: textContent }); + } else { + textParts.push({ type: "html", content: textContent }); + } + } + + // Process text parts + for (const textPart of textParts) { + if (textPart.type === "text") { + const trimmedContent = textPart.content.trim(); + if (trimmedContent) { + const key = `text_${counter++}`; + translatable[key] = trimmedContent; + template += textPart.content.replace( + trimmedContent, + `__LINGO_PLACEHOLDER_${key}__`, + ); + } else { + template += textPart.content; + } + } else { + template += textPart.content; + } + } + } + } + + return { content: template, translatable }; +} + +function reconstructEjsWithTranslation( + template: string, + translatable: Record<string, string>, +): string { + let result = template; + + // Replace placeholders with translated content + for (const [key, value] of Object.entries(translatable)) { + const placeholder = `__LINGO_PLACEHOLDER_${key}__`; + result = result.replace(new RegExp(placeholder, "g"), value); + } + + return result; +} + +export default function createEjsLoader(): ILoader< + string, + Record<string, any> +> { + return createLoader({ + async pull(locale, input) { + if (!input || input.trim() === "") { + return {}; + } + + try { + const parseResult = parseEjsForTranslation(input); + return parseResult.translatable; + } catch (error) { + console.warn( + "Warning: Could not parse EJS template, treating as plain text", + ); + // Fallback: treat entire input as translatable content + return { content: input.trim() }; + } + }, + + async push(locale, data, originalInput) { + if (!originalInput) { + // If no original input, reconstruct from data + return Object.values(data).join("\n"); + } + + try { + const parseResult = parseEjsForTranslation(originalInput); + + // Merge original translatable content with new translations + const mergedTranslatable = { ...parseResult.translatable, ...data }; + + return reconstructEjsWithTranslation( + parseResult.content, + mergedTranslatable, + ); + } catch (error) { + console.warn( + "Warning: Could not reconstruct EJS template, returning translated data", + ); + return Object.values(data).join("\n"); + } + }, + }); +} diff --git a/packages/cli/src/cli/loaders/ensure-key-order.spec.ts b/packages/cli/src/cli/loaders/ensure-key-order.spec.ts new file mode 100644 index 000000000..baf966e64 --- /dev/null +++ b/packages/cli/src/cli/loaders/ensure-key-order.spec.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import createEnsureKeyOrderLoader from "./ensure-key-order"; + +describe("ensure-key-order loader", () => { + const loader = createEnsureKeyOrderLoader(); + loader.setDefaultLocale("en"); + + it("should return input unchanged on pull", async () => { + const input = { b: 1, a: 2 }; + const result = await loader.pull("en", input); + expect(result).toEqual(input); + }); + + it("should reorder keys to match original input order on push", async () => { + const originalInput = { a: 1, b: 2, c: 3 }; + await loader.pull("en", originalInput); + const data = { b: 22, a: 11, c: 33 }; + const result = await loader.push("en", data); + expect(result).toEqual({ a: 11, b: 22, c: 33 }); + }); + + it("should reorder keys in objects of nested arrays to match original input order on push", async () => { + const originalInput = [ + { a: 1, b: 2, c: 3 }, + { a: 4, b: 5, c: 6 }, + { + values: [ + { a: 7, b: 8, c: 9 }, + { a: 10, b: 11, c: 12 }, + ], + }, + ]; + await loader.pull("en", originalInput); + const data = [ + { b: 22, a: 11, c: 33 }, + { b: 55, c: 66, a: 44 }, + { + values: [ + { b: 88, c: 99, a: 77 }, + { c: 122, b: 111, a: 100 }, + ], + }, + ]; + const result = await loader.push("en", data); + expect(result).toEqual([ + { a: 11, b: 22, c: 33 }, + { a: 44, b: 55, c: 66 }, + { + values: [ + { a: 77, b: 88, c: 99 }, + { a: 100, b: 111, c: 122 }, + ], + }, + ]); + }); + + it("should reorder falsy keys to match original input order on push", async () => { + const originalInput = { + a: 1, + b: 0, + c: null, + d: "a", + e: false, + g: "", + h: undefined, + }; + await loader.pull("en", originalInput); + const data = { + b: 0, + a: 11, + c: null, + d: "b", + e: false, + g: "", + h: undefined, + }; + const result = await loader.push("en", data); + expect(result).toEqual({ + a: 11, + b: 0, + c: null, + d: "b", + e: false, + g: "", + h: undefined, + }); + }); + + it("should handle nested objects and preserve key order", async () => { + const originalInput = { x: { b: 2, a: 1 }, y: 3, z: { d: 9, f: 7, e: 8 } }; + await loader.pull("en", originalInput); + const data = { x: { a: 11, b: 22 }, z: { d: 99, e: 88, f: 77 }, y: 33 }; + const result = await loader.push("en", data); + expect(result).toEqual({ + x: { b: 22, a: 11 }, + y: 33, + z: { d: 99, e: 88, f: 77 }, + }); + }); + + it("should skip keys not in original input of source locale", async () => { + const originalInput = { a: 1, b: 2 }; + await loader.pull("en", originalInput); + const data = { a: 11, b: 22, c: 33 }; + const result = await loader.push("en", data); + expect(result).toEqual({ a: 11, b: 22 }); + }); + + it("should skip keys not in the target locale data", async () => { + const originalInput = { a: 1, b: 2, c: 2 }; + await loader.pull("en", originalInput); + const data = { a: 11, c: 33 }; + const result = await loader.push("en", data); + expect(result).toEqual({ a: 11, c: 33 }); + }); +}); diff --git a/packages/cli/src/cli/loaders/ensure-key-order.ts b/packages/cli/src/cli/loaders/ensure-key-order.ts new file mode 100644 index 000000000..603bdb927 --- /dev/null +++ b/packages/cli/src/cli/loaders/ensure-key-order.ts @@ -0,0 +1,46 @@ +import _ from "lodash"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createEnsureKeyOrderLoader(): ILoader< + Record<string, any>, + Record<string, any> +> { + return createLoader({ + pull: async (_locale, input) => { + return input; + }, + push: async (_locale, data, originalInput) => { + if (!originalInput || !data) { + return data; + } + return reorderKeys(data, originalInput); + }, + }); +} + +function reorderKeys( + data: Record<string, any>, + originalInput: Record<string, any>, +): Record<string, any> { + if (_.isArray(originalInput) && _.isArray(data)) { + // If both are arrays, recursively reorder keys in each element + return data.map((item, idx) => reorderKeys(item, originalInput[idx] ?? {})); + } + if (!_.isObject(data) || _.isArray(data) || _.isDate(data)) { + return data; + } + + const orderedData: Record<string, any> = {}; + const originalKeys = Object.keys(originalInput); + const dataKeys = new Set(Object.keys(data)); + + for (const key of originalKeys) { + if (dataKeys.has(key)) { + orderedData[key] = reorderKeys(data[key], originalInput[key]); + dataKeys.delete(key); + } + } + + return orderedData; +} diff --git a/packages/cli/src/cli/loaders/flat.spec.ts b/packages/cli/src/cli/loaders/flat.spec.ts new file mode 100644 index 000000000..cc84d3ae8 --- /dev/null +++ b/packages/cli/src/cli/loaders/flat.spec.ts @@ -0,0 +1,285 @@ +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"], + }); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/flat.ts b/packages/cli/src/cli/loaders/flat.ts new file mode 100644 index 000000000..b7585119b --- /dev/null +++ b/packages/cli/src/cli/loaders/flat.ts @@ -0,0 +1,226 @@ +import { flatten, unflatten } from "flat"; +import { ILoader } from "./_types"; +import { composeLoaders, createLoader } from "./_utils"; +import _ from "lodash"; + +export const OBJECT_NUMERIC_KEY_PREFIX = "__lingodotdev__obj__"; + +/** + * Options for configuring the flat loader behavior + */ +export interface FlatLoaderOptions { + /** + * Optional predicate to determine if an object should be preserved (not flattened) + * Use this to prevent flattening of special objects like ICU plurals + */ + shouldPreserveObject?: (value: any) => boolean; +} + +/** + * Creates a flat loader that flattens nested objects into dot-notation keys + * + * @param options - Configuration options for the loader + * @param options.shouldPreserveObject - Predicate to identify objects that should not be flattened + */ +export default function createFlatLoader(options?: FlatLoaderOptions) { + const composedLoader = composeLoaders( + createDenormalizeLoader(options), + createNormalizeLoader(), + ); + + return { + ...composedLoader, + pullHints: async (input: Record<string, any>) => { + if (!input || typeof input !== "object") { + return {}; + } + return flattenHints(input); + }, + }; +} + +type DenormalizeResult = { + denormalized: Record<string, string>; + keysMap: Record<string, string>; +}; + +function createDenormalizeLoader( + options?: FlatLoaderOptions, +): ILoader<Record<string, any>, DenormalizeResult> { + return createLoader({ + pull: async (locale, input) => { + const inputDenormalized = denormalizeObjectKeys(input || {}); + + // First pass: extract preserved objects before flattening (if predicate provided) + const preservedObjects: Record<string, any> = {}; + const nonPreservedInput: Record<string, any> = {}; + + for (const [key, value] of Object.entries(inputDenormalized)) { + if (options?.shouldPreserveObject?.(value)) { + preservedObjects[key] = value; + } else { + nonPreservedInput[key] = value; + } + } + + // Flatten only non-preserved objects + const flattened: Record<string, string> = flatten(nonPreservedInput, { + delimiter: "/", + transformKey(key) { + return encodeURIComponent(String(key)); + }, + }); + + // Merge preserved objects back (they stay as objects, not flattened) + // BUT: encode their keys too! + const denormalized: Record<string, any> = { ...flattened }; + + for (const [key, value] of Object.entries(preservedObjects)) { + const encodedKey = encodeURIComponent(String(key)); + denormalized[encodedKey] = value; + } + + const keysMap = buildDenormalizedKeysMap(denormalized); + return { denormalized, keysMap }; + }, + push: async (locale, { denormalized }) => { + const normalized = normalizeObjectKeys(denormalized); + return normalized; + }, + }); +} + +function createNormalizeLoader(): ILoader< + DenormalizeResult, + Record<string, string> +> { + return createLoader({ + pull: async (locale, input) => { + const normalized = normalizeObjectKeys(input.denormalized); + return normalized; + }, + push: async (locale, data, originalInput) => { + const keysMap = originalInput?.keysMap ?? {}; + const input = mapDenormalizedKeys(data, keysMap); + const denormalized: Record<string, any> = unflatten(input, { + delimiter: "/", + transformKey(key) { + return decodeURIComponent(String(key)); + }, + }); + return { denormalized, keysMap: keysMap || {} }; + }, + }); +} + +export function buildDenormalizedKeysMap(obj: Record<string, string>) { + if (!obj) return {}; + + return Object.keys(obj).reduce( + (acc, key) => { + if (key) { + const normalizedKey = `${key}`.replace(OBJECT_NUMERIC_KEY_PREFIX, ""); + acc[normalizedKey] = key; + } + return acc; + }, + {} as Record<string, string>, + ); +} + +export function mapDenormalizedKeys( + obj: Record<string, any>, + denormalizedKeysMap: Record<string, string>, +) { + return Object.keys(obj).reduce( + (acc, key) => { + const denormalizedKey = denormalizedKeysMap[key] ?? key; + acc[denormalizedKey] = obj[key]; + return acc; + }, + {} as Record<string, string>, + ); +} + +export function denormalizeObjectKeys( + obj: Record<string, any>, +): Record<string, any> { + if (_.isObject(obj) && !_.isArray(obj)) { + return _.transform( + obj, + (result, value, key) => { + const newKey = !isNaN(Number(key)) + ? `${OBJECT_NUMERIC_KEY_PREFIX}${key}` + : key; + result[newKey] = + _.isObject(value) && !_.isDate(value) + ? denormalizeObjectKeys(value) + : value; + }, + {} as Record<string, any>, + ); + } else { + return obj; + } +} + +export function normalizeObjectKeys( + obj: Record<string, any>, +): Record<string, any> { + if (_.isObject(obj) && !_.isArray(obj)) { + return _.transform( + obj, + (result, value, key) => { + const newKey = `${key}`.replace(OBJECT_NUMERIC_KEY_PREFIX, ""); + result[newKey] = + _.isObject(value) && !_.isDate(value) + ? normalizeObjectKeys(value) + : value; + }, + {} as Record<string, any>, + ); + } else { + return obj; + } +} + +function flattenHints( + obj: Record<string, any>, + parentHints: string[] = [], + parentPath: string = "", +): Record<string, string[]> { + const result: Record<string, string[]> = {}; + + for (const [key, _value] of Object.entries(obj)) { + if (_.isObject(_value) && !_.isArray(_value)) { + const value = _value as Record<string, any>; + const currentHints = [...parentHints]; + const currentPath = parentPath ? `${parentPath}/${key}` : key; + + // Add this level's hint if it exists + if (value.hint && typeof value.hint === "string") { + currentHints.push(value.hint); + } + + // Process nested objects (excluding the hint property) + const nestedObj = _.omit(value, "hint"); + + // If this is a leaf node (no nested objects), add to result + if (Object.keys(nestedObj).length === 0) { + if (currentHints.length > 0) { + result[currentPath] = currentHints; + } + } else { + // Recursively process nested objects + const nestedComments = flattenHints( + nestedObj, + currentHints, + currentPath, + ); + Object.assign(result, nestedComments); + } + } + } + + return result; +} diff --git a/packages/cli/src/cli/loaders/flutter.spec.ts b/packages/cli/src/cli/loaders/flutter.spec.ts new file mode 100644 index 000000000..c7cac028b --- /dev/null +++ b/packages/cli/src/cli/loaders/flutter.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import createFlutterLoader from "./flutter"; + +const locale = "en"; +const originalLocale = "en"; + +describe("createFlutterLoader", () => { + describe("pull", () => { + it("should remove metadata keys starting with @", async () => { + const loader = createFlutterLoader(); + loader.setDefaultLocale(locale); + const input = { + "@metadata": "some-data", + hello: "world", + another_key: "another_value", + "@@locale": "en", + }; + const expected = { + hello: "world", + another_key: "another_value", + }; + const result = await loader.pull("en", input); + expect(result).toEqual(expected); + }); + + it("should return an empty object if all keys are metadata", async () => { + const loader = createFlutterLoader(); + loader.setDefaultLocale(locale); + const input = { + "@metadata": "some-data", + "@@locale": "en", + }; + const expected = {}; + const result = await loader.pull("en", input); + expect(result).toEqual(expected); + }); + + it("should return the same object if no keys are metadata", async () => { + const loader = createFlutterLoader(); + loader.setDefaultLocale(locale); + const input = { + hello: "world", + another_key: "another_value", + }; + const expected = { + hello: "world", + another_key: "another_value", + }; + const result = await loader.pull("en", input); + expect(result).toEqual(expected); + }); + + it("should handle empty input", async () => { + const loader = createFlutterLoader(); + loader.setDefaultLocale(locale); + const input = {}; + const expected = {}; + const result = await loader.pull("en", input); + expect(result).toEqual(expected); + }); + }); + + describe("push", () => { + it("should merge data and add locale", async () => { + const loader = createFlutterLoader(); + loader.setDefaultLocale(locale); + const originalInput = { + hello: "world", + "@metadata": "some-data", + }; + await loader.pull(originalLocale, originalInput); + const data = { + foo: "bar", + hello: "monde", + }; + const expected = { + hello: "monde", + foo: "bar", + "@metadata": "some-data", + "@@locale": "fr", + }; + const result = await loader.push("fr", data); + expect(result).toEqual(expected); + }); + + it("should handle empty original input", async () => { + const loader = createFlutterLoader(); + loader.setDefaultLocale(locale); + const originalInput = {}; + await loader.pull(originalLocale, originalInput); + const data = { + foo: "bar", + }; + const expected = { + foo: "bar", + "@@locale": "en", + }; + const result = await loader.push("en", data); + expect(result).toEqual(expected); + }); + + it("should handle empty data, not add extra keys from originalInput", async () => { + const loader = createFlutterLoader(); + loader.setDefaultLocale(locale); + const originalInput = { + hello: "world", + }; + await loader.pull(originalLocale, originalInput); + const data = { + goodbye: "moon", + }; + const expected = { + goodbye: "moon", + "@@locale": "en", + }; + const result = await loader.push("en", data); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/flutter.ts b/packages/cli/src/cli/loaders/flutter.ts new file mode 100644 index 000000000..dad69e013 --- /dev/null +++ b/packages/cli/src/cli/loaders/flutter.ts @@ -0,0 +1,28 @@ +import _ from "lodash"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createFlutterLoader(): ILoader< + Record<string, any>, + Record<string, any> +> { + return createLoader({ + async pull(locale, input) { + // skip all metadata (keys starting with @) + const result = _.pickBy(input, (value, key) => !_isMetadataKey(key)); + return result; + }, + async push(locale, data, originalInput) { + // find all metadata keys in originalInput + const metadata = _.pickBy(originalInput, (value, key) => + _isMetadataKey(key), + ); + const result = _.merge({}, metadata, { "@@locale": locale }, data); + return result; + }, + }); +} + +function _isMetadataKey(key: string) { + return key.startsWith("@"); +} diff --git a/packages/cli/src/cli/loaders/formatters/_base.ts b/packages/cli/src/cli/loaders/formatters/_base.ts new file mode 100644 index 000000000..75536fd28 --- /dev/null +++ b/packages/cli/src/cli/loaders/formatters/_base.ts @@ -0,0 +1,37 @@ +import path from "path"; +import { ILoader } from "../_types"; +import { createLoader } from "../_utils"; + +export type BaseFormatterOptions = { + bucketPathPattern: string; + stage?: "pull" | "push" | "both"; + alwaysFormat?: boolean; +}; + +export function createBaseFormatterLoader( + options: BaseFormatterOptions, + formatFn: (data: string, filePath: string) => Promise<string>, +): ILoader<string, string> { + const stage = options.stage || "both"; + + const formatData = async (locale: string, data: string) => { + const draftPath = options.bucketPathPattern.replaceAll("[locale]", locale); + const finalPath = path.resolve(draftPath); + return await formatFn(data, finalPath); + }; + + return createLoader({ + async pull(locale, data) { + if (!["pull", "both"].includes(stage)) { + return data; + } + return await formatData(locale, data); + }, + async push(locale, data) { + if (!["push", "both"].includes(stage)) { + return data; + } + return await formatData(locale, data); + }, + }); +} diff --git a/packages/cli/src/cli/loaders/formatters/biome.ts b/packages/cli/src/cli/loaders/formatters/biome.ts new file mode 100644 index 000000000..4f4311486 --- /dev/null +++ b/packages/cli/src/cli/loaders/formatters/biome.ts @@ -0,0 +1,113 @@ +import path from "path"; +import fs from "fs/promises"; +import { Biome, Distribution } from "@biomejs/js-api"; +import { parse as parseJsonc } from "jsonc-parser"; +import { ILoader } from "../_types"; +import { createBaseFormatterLoader } from "./_base"; + +export type BiomeLoaderOptions = { + bucketPathPattern: string; + stage?: "pull" | "push" | "both"; + alwaysFormat?: boolean; +}; + +export default function createBiomeLoader( + options: BiomeLoaderOptions, +): ILoader<string, string> { + return createBaseFormatterLoader(options, async (data, filePath) => { + return await formatDataWithBiome(data, filePath, options); + }); +} + +async function findBiomeConfig(startPath: string): Promise<string | null> { + let currentDir = path.dirname(startPath); + const root = path.parse(currentDir).root; + + while (currentDir !== root) { + for (const configName of ["biome.json", "biome.jsonc"]) { + const configPath = path.join(currentDir, configName); + try { + await fs.access(configPath); + return configPath; + } catch { + // Config file doesn't exist, continue searching + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + + return null; +} + +async function formatDataWithBiome( + data: string, + filePath: string, + options: BiomeLoaderOptions, +): Promise<string> { + let configPath: string | null = null; + + try { + const biome = await Biome.create({ + distribution: Distribution.NODE, + }); + + // Open a project (required in v3.0.0+) + const openResult = biome.openProject("."); + const projectKey = openResult.projectKey; + + // Load config from biome.json/biome.jsonc if exists + configPath = await findBiomeConfig(filePath); + if (!configPath && !options.alwaysFormat) { + console.log(); + console.log( + `⚠️ Biome config not found for ${path.basename(filePath)} - skipping formatting`, + ); + return data; + } + + if (configPath) { + const configContent = await fs.readFile(configPath, "utf-8"); + try { + // Parse JSONC (JSON with comments) properly using jsonc-parser + const config = parseJsonc(configContent); + + // WORKAROUND: Biome JS API v3 has a bug where applying the full config + // causes formatter settings to be ignored. Apply only relevant sections. + // Specifically, exclude $schema, vcs, and files from the config. + const { $schema, vcs, files, ...relevantConfig } = config; + + biome.applyConfiguration(projectKey, relevantConfig); + } catch (parseError) { + throw new Error( + `Invalid Biome configuration in ${configPath}: ${parseError instanceof Error ? parseError.message : "JSON parse error"}`, + ); + } + } + + const formatted = biome.formatContent(projectKey, data, { + filePath, + }); + + return formatted.content; + } catch (error) { + // Extract error message from Biome + const errorMessage = + error instanceof Error + ? error.message || (error as any).stackTrace?.toString().split("\n")[0] + : ""; + + if (errorMessage?.includes("does not exist in the workspace")) { + // Biome says "file does not exist in workspace" for unsupported formats - skip + } else { + console.log(`⚠️ Biome skipped ${path.basename(filePath)}`); + if (errorMessage) { + console.log(` ${errorMessage}`); + } + } + + return data; // Fallback to unformatted + } +} diff --git a/packages/cli/src/cli/loaders/formatters/index.ts b/packages/cli/src/cli/loaders/formatters/index.ts new file mode 100644 index 000000000..c744660c4 --- /dev/null +++ b/packages/cli/src/cli/loaders/formatters/index.ts @@ -0,0 +1,33 @@ +import createPrettierLoader, { PrettierLoaderOptions } from "./prettier"; +import createBiomeLoader from "./biome"; +import { ILoader } from "../_types"; +import { Options } from "prettier"; + +export type FormatterType = "prettier" | "biome" | undefined; +export type ParserType = Options["parser"]; + +export function createFormatterLoader( + formatterType: FormatterType, + parser: ParserType, + bucketPathPattern: string, +): ILoader<string, string> { + // If explicitly set to undefined, auto-detect (prefer prettier for backward compatibility) + if (formatterType === undefined) { + return createPrettierLoader({ parser, bucketPathPattern }); + } + + if (formatterType === "prettier") { + return createPrettierLoader({ parser, bucketPathPattern }); + } + + if (formatterType === "biome") { + return createBiomeLoader({ bucketPathPattern }); + } + + throw new Error(`Unknown formatter: ${formatterType}`); +} + +// Re-export for direct access if needed +export { createPrettierLoader, createBiomeLoader }; +export type { PrettierLoaderOptions }; +export type { BiomeLoaderOptions } from "./biome"; diff --git a/packages/cli/src/cli/loaders/formatters/prettier.ts b/packages/cli/src/cli/loaders/formatters/prettier.ts new file mode 100644 index 000000000..cf34f2005 --- /dev/null +++ b/packages/cli/src/cli/loaders/formatters/prettier.ts @@ -0,0 +1,81 @@ +import prettier, { Options } from "prettier"; +import { ILoader } from "../_types"; +import { createBaseFormatterLoader } from "./_base"; + +export type PrettierLoaderOptions = { + parser: Options["parser"]; + bucketPathPattern: string; + stage?: "pull" | "push" | "both"; + alwaysFormat?: boolean; +}; + +export default function createPrettierLoader( + options: PrettierLoaderOptions, +): ILoader<string, string> { + return createBaseFormatterLoader(options, async (data, filePath) => { + return await formatDataWithPrettier(data, filePath, options); + }); +} + +async function loadPrettierConfig(filePath: string) { + try { + const config = await prettier.resolveConfig(filePath); + return config; + } catch (error) { + return {}; + } +} + +async function formatDataWithPrettier( + data: string, + filePath: string, + options: PrettierLoaderOptions, +): Promise<string> { + const prettierConfig = await loadPrettierConfig(filePath); + + // Skip formatting if no config found and alwaysFormat is not enabled + if (!prettierConfig && !options.alwaysFormat) { + return data; + } + + const config: Options = { + ...(prettierConfig || { printWidth: 2500, bracketSameLine: false }), + parser: options.parser, + // For HTML parser, preserve comments and quotes + ...(options.parser === "html" + ? { + htmlWhitespaceSensitivity: "ignore", + singleQuote: false, + embeddedLanguageFormatting: "off", + } + : {}), + }; + + try { + // format with prettier + return await prettier.format(data, config); + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("Cannot find package") + ) { + console.log(); + console.log( + "⚠️ Prettier plugins are not installed. Formatting without plugins.", + ); + console.log( + "⚠️ To use prettier plugins install project dependencies before running Lingo.dev.", + ); + + config.plugins = []; + + // clear file system structure cache + await prettier.clearConfigCache(); + + // format again without plugins + return await prettier.format(data, config); + } + + throw error; + } +} diff --git a/packages/cli/src/cli/loaders/html.ts b/packages/cli/src/cli/loaders/html.ts new file mode 100644 index 000000000..9cbad5d42 --- /dev/null +++ b/packages/cli/src/cli/loaders/html.ts @@ -0,0 +1,435 @@ +import * as htmlparser2 from "htmlparser2"; +import { DomHandler, Element, AnyNode, Text } from "domhandler"; +import * as domutils from "domutils"; +import * as DomSerializer from "dom-serializer"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createHtmlLoader(): ILoader< + string, + Record<string, string> +> { + + // Based on WHATWG HTML spec: https://html.spec.whatwg.org/multipage/indices.html + // Phrasing content = inline elements that should be preserved within text + const PHRASING_ELEMENTS = new Set([ + // Text-level semantics + "a", + "abbr", + "b", + "bdi", + "bdo", + "br", + "cite", + "code", + "data", + "dfn", + "em", + "i", + "kbd", + "mark", + "q", + "ruby", + "s", + "samp", + "small", + "span", + "strong", + "sub", + "sup", + "time", + "u", + "var", + "wbr", + // Media + "audio", + "img", + "video", + "picture", + // Interactive + "button", + "input", + "label", + "select", + "textarea", + // Embedded + "canvas", + "iframe", + "object", + "svg", + "math", + // Other + "del", + "ins", + "map", + "area", + ]); + + // Block elements create translation boundaries + const BLOCK_ELEMENTS = new Set([ + "div", + "p", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "ul", + "ol", + "li", + "dl", + "dt", + "dd", + "blockquote", + "pre", + "article", + "aside", + "nav", + "section", + "header", + "footer", + "main", + "figure", + "figcaption", + "table", + "thead", + "tbody", + "tfoot", + "tr", + "td", + "th", + "caption", + "form", + "fieldset", + "legend", + "details", + "summary", + "address", + "hr", + "search", + "dialog", + "noscript", + "title", // <title> should be treated as a block element for translation + ]); + + // Tags whose content should never be translated + const UNLOCALIZABLE_TAGS = new Set(["script", "style"]); + + // Attributes that should be translated separately + const LOCALIZABLE_ATTRIBUTES: Record<string, string[]> = { + meta: ["content"], + img: ["alt", "title"], + input: ["placeholder", "title"], + textarea: ["placeholder", "title"], + a: ["title"], + abbr: ["title"], + button: ["title"], + link: ["title"], + }; + + return createLoader({ + async pull(locale, input) { + const result: Record<string, string> = {}; + + // Parse HTML with htmlparser2 (preserves structure, no foster parenting) + const handler = new DomHandler(); + const parser = new htmlparser2.Parser(handler, { + lowerCaseTags: false, + lowerCaseAttributeNames: false, + }); + parser.write(input); + parser.end(); + + const dom = handler.dom; + + // Check if element is inside an unlocalizable tag + function isInsideUnlocalizableTag(element: Element): boolean { + let current = element.parent; + while (current && current.type === "tag") { + if (UNLOCALIZABLE_TAGS.has((current as Element).name.toLowerCase())) { + return true; + } + current = current.parent; + } + return false; + } + + // Check if element contains any translatable text (not just whitespace) + function hasTranslatableContent(element: Element): boolean { + const text = domutils.textContent(element); + return text.trim().length > 0; + } + + // Check if element is a "leaf" block (contains text with inline elements, not nested blocks) + function isLeafBlock(element: Element): boolean { + // A leaf block contains text and/or phrasing elements, but no other block elements + const childElements = element.children.filter( + (child): child is Element => child.type === "tag" + ); + for (const child of childElements) { + if (BLOCK_ELEMENTS.has(child.name.toLowerCase())) { + return false; + } + } + return hasTranslatableContent(element); + } + + // Get innerHTML equivalent (serialize children) + function getInnerHTML(element: Element): string { + return element.children + .map(child => DomSerializer.default(child, { encodeEntities: false })) + .join(''); + } + + // Extract localizable attributes from element + function extractAttributes(element: Element, path: string): void { + const tagName = element.name.toLowerCase(); + const attrs = LOCALIZABLE_ATTRIBUTES[tagName]; + if (!attrs) return; + + for (const attr of attrs) { + const value = element.attribs?.[attr]; + if (value && value.trim()) { + result[`${path}#${attr}`] = value.trim(); + } + } + } + + // Recursively extract translation units from element tree + function extractFromElement( + element: Element, + pathParts: (string | number)[], + ): void { + const path = pathParts.join("/"); + + // Skip if inside unlocalizable tag + if (isInsideUnlocalizableTag(element)) { + return; + } + + // Extract localizable attributes + extractAttributes(element, path); + + const tagName = element.name.toLowerCase(); + + // If this is a leaf block element (contains text but no nested blocks), extract it + if (BLOCK_ELEMENTS.has(tagName) && isLeafBlock(element)) { + // Get innerHTML (preserves inline elements) + const content = getInnerHTML(element).trim(); + if (content) { + result[path] = content; + } + // Don't recurse into children - innerHTML captures everything + return; + } + + // If this is a standalone phrasing element with text content, extract it + if (PHRASING_ELEMENTS.has(tagName) && hasTranslatableContent(element)) { + const content = getInnerHTML(element).trim(); + if (content) { + result[path] = content; + } + // Don't recurse - innerHTML captures everything + return; + } + + // For structural/container elements, recurse into children + let childIndex = 0; + const childElements = element.children.filter( + (child): child is Element => child.type === "tag" + ); + for (const child of childElements) { + extractFromElement(child, [...pathParts, childIndex++]); + } + } + + // Find head and body elements + const html = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "html", + dom, + true + ) as Element | null; + + if (html) { + const head = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "head", + html.children, + true + ) as Element | null; + + const body = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "body", + html.children, + true + ) as Element | null; + + // Process head children + if (head) { + let headIndex = 0; + const headChildren = head.children.filter( + (child): child is Element => child.type === "tag" + ); + for (const child of headChildren) { + extractFromElement(child, ["head", headIndex++]); + } + } + + // Process body children + if (body) { + let bodyIndex = 0; + const bodyChildren = body.children.filter( + (child): child is Element => child.type === "tag" + ); + for (const child of bodyChildren) { + extractFromElement(child, ["body", bodyIndex++]); + } + } + } else { + // Handle HTML fragments (no <html> element) - process root elements directly + let rootIndex = 0; + const rootElements = dom.filter( + (child): child is Element => child.type === "tag" + ); + for (const child of rootElements) { + extractFromElement(child, [rootIndex++]); + } + } + + return result; + }, + + async push(locale, data, originalInput) { + // Parse original HTML + const handler = new DomHandler(); + const parser = new htmlparser2.Parser(handler, { + lowerCaseTags: false, + lowerCaseAttributeNames: false, + }); + parser.write( + originalInput ?? "<!DOCTYPE html><html><head></head><body></body></html>" + ); + parser.end(); + + const dom = handler.dom; + + // Find HTML element and set lang attribute + const html = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "html", + dom, + true + ) as Element | null; + + if (html) { + html.attribs = html.attribs || {}; + html.attribs.lang = locale; + } + + // Helper to traverse child elements by numeric indices + function traverseByIndices( + element: Element | null, + indices: string[] + ): Element | null { + let current = element; + + for (const indexStr of indices) { + if (!current) return null; + + const index = parseInt(indexStr, 10); + const children: Element[] = current.children.filter( + (child): child is Element => child.type === "tag" + ); + + if (index >= children.length) { + return null; // Path doesn't exist + } + + current = children[index]; + } + + return current; + } + + // Resolve path to element in the DOM + function resolvePathToElement(path: string): Element | null { + const parts = path.split("/"); + const [rootTag, ...indices] = parts; + + let current: Element | null = null; + + if (html) { + // Full HTML document with <html>, <head>, <body> + // Find head or body + if (rootTag === "head") { + current = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "head", + html.children, + true + ) as Element | null; + } else if (rootTag === "body") { + current = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "body", + html.children, + true + ) as Element | null; + } + + if (!current) return null; + + // Traverse by indices + return traverseByIndices(current, indices); + } else { + // HTML fragment - no <html> element + // Path is just numeric indices from root + const rootElements = dom.filter( + (child): child is Element => child.type === "tag" + ); + + // First part is the root index + const rootIndex = parseInt(rootTag, 10); + if (rootIndex >= rootElements.length) { + return null; + } + + current = rootElements[rootIndex]; + + // Traverse remaining indices + return traverseByIndices(current, indices); + } + } + + // Apply translations + for (const [path, value] of Object.entries(data)) { + const [nodePath, attribute] = path.split("#"); + + const element = resolvePathToElement(nodePath); + if (!element) { + console.warn(`Path not found in original HTML: ${nodePath}`); + continue; + } + + if (attribute) { + // Set attribute + element.attribs = element.attribs || {}; + element.attribs[attribute] = value; + } else { + // Set innerHTML (parse value as HTML and replace children) + if (value) { + const valueHandler = new DomHandler(); + const valueParser = new htmlparser2.Parser(valueHandler); + valueParser.write(value); + valueParser.end(); + + element.children = valueHandler.dom; + } else { + // If value is empty/null, clear children + element.children = []; + } + } + } + + // Serialize back to HTML + return DomSerializer.default(dom, { encodeEntities: false }); + }, + }); +} diff --git a/packages/cli/src/cli/loaders/ignored-keys-buckets.spec.ts b/packages/cli/src/cli/loaders/ignored-keys-buckets.spec.ts new file mode 100644 index 000000000..bb397d6a0 --- /dev/null +++ b/packages/cli/src/cli/loaders/ignored-keys-buckets.spec.ts @@ -0,0 +1,720 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import fs from "fs/promises"; +import dedent from "dedent"; +import createBucketLoader from "./index"; + +describe("ignored keys support across buckets", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + setupFileMocks(); + }); + + it("android: should omit ignored keys on pull", async () => { + const input = ` + <resources> + <string name="button.title">Submit</string> + <string name="button.description">Description</string> + </resources> + `.trim(); + mockFileOperations(input); + + const loader = createBucketLoader( + "android", + "values-[locale]/strings.xml", + { defaultLocale: "en" }, + [], + [], + ["button.description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ "button.title": "Submit" }); + }); + + it("csv: should omit ignored keys on pull", async () => { + const input = `id,en\nbutton.title,Submit\nbutton.description,Description`; + mockFileOperations(input); + + const loader = createBucketLoader( + "csv", + "i18n.csv", + { defaultLocale: "en" }, + [], + [], + ["button.description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ "button.title": "Submit" }); + }); + + it("html: should omit ignored keys (by prefix) on pull", async () => { + const input = dedent` + <html> + <head> + <title>My Page + + + +

    Hello

    +

    Paragraph

    + + + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "html", + "i18n/[locale].html", + { defaultLocale: "en" }, + [], + [], + ["head"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(Object.keys(data).some((k) => k.startsWith("head"))).toBe(false); + }); + + it("ejs: should omit ignored keys on pull", async () => { + const input = `

    Welcome

    Hello <%= name %>

    `; + mockFileOperations(input); + + const loader = createBucketLoader( + "ejs", + "templates/[locale].ejs", + { defaultLocale: "en" }, + [], + [], + ["text_*"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({}); + }); + + it("json: should omit ignored keys on pull", async () => { + const input = JSON.stringify({ title: "Submit", description: "Desc" }); + mockFileOperations(input); + + const loader = createBucketLoader( + "json", + "i18n/[locale].json", + { defaultLocale: "en" }, + [], + [], + ["description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ title: "Submit" }); + }); + + it("json5: should omit ignored keys on pull", async () => { + const input = `{ + // comment + title: "Submit", + description: "Desc" + }`; + mockFileOperations(input); + + const loader = createBucketLoader( + "json5", + "i18n/[locale].json5", + { defaultLocale: "en" }, + [], + [], + ["description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ title: "Submit" }); + }); + + it("jsonc: should omit ignored keys on pull", async () => { + const input = `{ + // comment + "title": "Submit", + "description": "Desc" + }`; + mockFileOperations(input); + + const loader = createBucketLoader( + "jsonc", + "i18n/[locale].jsonc", + { defaultLocale: "en" }, + [], + [], + ["description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ title: "Submit" }); + }); + + it("markdown: should omit ignored keys (frontmatter) on pull", async () => { + const input = dedent` + --- + title: Test Markdown + date: 2023-05-25 + --- + + # Heading 1 + + Content. + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "markdown", + "i18n/[locale].md", + { defaultLocale: "en" }, + [], + [], + ["fm-attr-title"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(Object.keys(data)).not.toContain("fm-attr-title"); + }); + + it("markdoc: should omit ignored keys by semantic prefix on pull", async () => { + const input = dedent` + --- + title: My Page + --- + + # Heading 1 + + Hello world + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "markdoc", + "docs/[locale].md", + { defaultLocale: "en" }, + [], + [], + ["heading"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(Object.keys(data).some((k) => k.startsWith("heading"))).toBe(false); + }); + + it("mdx: should omit ignored section keys on pull", async () => { + const input = dedent` + --- + title: Hello + --- + + # Title + + Paragraph + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "mdx", + "i18n/[locale].mdx", + { defaultLocale: "en", formatter: undefined }, + [], + [], + ["md-section-0"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(Object.keys(data)).not.toContain("md-section-0"); + }); + + it("po: should omit ignored keys on pull", async () => { + const input = dedent` + #: hello.py:1 + msgid "Hello" + msgstr "" + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "po", + "i18n/[locale].po", + { defaultLocale: "en" }, + [], + [], + ["Hello"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({}); + }); + + it("properties: should omit ignored keys on pull", async () => { + const input = dedent` + welcome.message=Welcome + error.message=Error + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "properties", + "i18n/[locale].properties", + { defaultLocale: "en" }, + [], + [], + ["error.message"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ "welcome.message": "Welcome" }); + }); + + it("xcode-strings: should omit ignored keys on pull", async () => { + const input = `"hello" = "Hello!";\n"bye" = "Bye!";`; + mockFileOperations(input); + + const loader = createBucketLoader( + "xcode-strings", + "i18n/[locale].strings", + { defaultLocale: "en" }, + [], + [], + ["bye"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ hello: "Hello!" }); + }); + + it("xcode-stringsdict: should omit ignored keys on pull", async () => { + const input = dedent` + + + + + greeting + Hello! + items_count + + NSStringLocalizedFormatKey + %#@items@ + items + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d item + other + %d items + + + + + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "xcode-stringsdict", + "i18n/[locale].stringsdict", + { defaultLocale: "en" }, + [], + [], + ["items_count"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(Object.keys(data)).toContain("greeting"); + expect(Object.keys(data).some((k) => k.startsWith("items_count"))).toBe( + false, + ); + }); + + it("xcode-xcstrings: should omit ignored keys on pull", async () => { + const input = dedent` + { + "sourceLanguage": "en", + "strings": { + "greeting": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Hello!" } } + } + }, + "message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Welcome" } } + } + } + } + } + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "xcode-xcstrings", + "i18n/[locale].xcstrings", + { defaultLocale: "en" }, + [], + [], + ["message"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ greeting: "Hello!" }); + }); + + it("xcode-xcstrings-v2: should omit ignored string keys on pull", async () => { + const input = dedent` + { + "sourceLanguage": "en", + "strings": { + "hello": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Hello" } } + } + }, + "world": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "World" } } + } + } + } + } + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "xcode-xcstrings-v2", + "i18n/[locale].xcstrings", + { defaultLocale: "en" }, + [], + [], + ["world"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + // xcode-xcstrings-v2 uses flat loader, so keys are paths like "hello/stringUnit" + expect(Object.keys(data).some((k) => k.startsWith("hello"))).toBe(true); + expect(Object.keys(data).some((k) => k.startsWith("world"))).toBe(false); + }); + + it("yaml: should omit ignored keys on pull", async () => { + const input = dedent` + title: Submit + description: Desc + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "yaml", + "i18n/[locale].yml", + { defaultLocale: "en" }, + [], + [], + ["description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ title: "Submit" }); + }); + + it("yaml-root-key: should omit ignored keys on pull", async () => { + const input = dedent` + en: + title: Submit + description: Desc + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "yaml-root-key", + "i18n/[locale].yml", + { defaultLocale: "en" }, + [], + [], + ["description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ title: "Submit" }); + }); + + it("flutter: should omit ignored keys on pull", async () => { + const input = JSON.stringify( + { + "@@locale": "en", + greeting: "Hello, {name}!", + "@greeting": { description: "d" }, + farewell: "Goodbye!", + }, + null, + 2, + ); + mockFileOperations(input); + + const loader = createBucketLoader( + "flutter", + "lib/l10n/app_[locale].arb", + { defaultLocale: "en" }, + [], + [], + ["farewell"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(Object.keys(data)).toContain("greeting"); + expect(Object.keys(data)).not.toContain("farewell"); + }); + + it("xliff: should omit ignored keys on pull", async () => { + const input = dedent` + + + + + Hello + Goodbye + + + + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "xliff", + "i18n/[locale].xliff", + { defaultLocale: "en" }, + [], + [], + ["farewell"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(Object.keys(data)).toContain("greeting"); + expect(Object.keys(data)).not.toContain("farewell"); + }); + + it("xml: should omit ignored keys on pull", async () => { + const input = `HelloDesc`; + mockFileOperations(input); + + const loader = createBucketLoader( + "xml", + "i18n/[locale].xml", + { defaultLocale: "en" }, + [], + [], + ["root/description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(Object.keys(data)).toContain("root/title"); + expect(Object.keys(data)).not.toContain("root/description"); + }); + + it("srt: should omit ignored keys on pull", async () => { + const input = dedent` + 1 + 00:00:01,000 --> 00:00:04,000 + Hello + + 2 + 00:00:05,000 --> 00:00:06,000 + World + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "srt", + "i18n/[locale].srt", + { defaultLocale: "en" }, + [], + [], + ["1#*"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + // Expect only entry 2 remains + const keys = Object.keys(data); + expect(keys.length).toBe(1); + expect(keys[0].startsWith("2#")).toBe(true); + }); + + it("vtt: should omit ignored keys on pull", async () => { + const input = dedent` + WEBVTT + + 00:00:00.000 --> 00:00:02.000 + First + + 00:00:02.000 --> 00:00:04.000 + Second + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "vtt", + "i18n/[locale].vtt", + { defaultLocale: "en" }, + [], + [], + ["0#*"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + // One cue should be filtered + expect(Object.keys(data).length).toBe(1); + }); + + it("php: should omit ignored keys on pull", async () => { + const input = dedent` + 'Submit', + 'description' => 'Desc', + ]; + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "php", + "i18n/[locale].php", + { defaultLocale: "en" }, + [], + [], + ["description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ title: "Submit" }); + }); + + it("vue-json: should omit ignored keys on pull", async () => { + const input = dedent` + + + {"en": {"title": "Hello", "description": "Desc"}} + + + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "vue-json", + "i18n/App.vue", + { defaultLocale: "en" }, + [], + [], + ["description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ title: "Hello" }); + }); + + it("typescript: should omit ignored keys on pull", async () => { + const input = dedent` + export default { + title: "Submit", + description: "Desc" + }; + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "typescript", + "i18n/[locale].ts", + { defaultLocale: "en" }, + [], + [], + ["description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ title: "Submit" }); + }); + + it("txt: should omit ignored keys on pull", async () => { + const input = dedent` + First line + Second line + `; + mockFileOperations(input); + + const loader = createBucketLoader( + "txt", + "fastlane/metadata/[locale]/description.txt", + { defaultLocale: "en" }, + [], + [], + ["1"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(Object.keys(data)).toEqual(["2"]); + }); + + it("json-dictionary: should omit ignored keys on pull (wildcard)", async () => { + const input = JSON.stringify( + { + title: { en: "Title" }, + pages: [ + { + elements: [ + { title: { en: "E1" }, description: { en: "D1" } }, + { title: { en: "E2" }, description: { en: "D2" } }, + ], + }, + ], + }, + null, + 2, + ); + mockFileOperations(input); + + const loader = createBucketLoader( + "json-dictionary", + "i18n/[locale].json", + { defaultLocale: "en" }, + [], + [], + ["pages/*/elements/*/description"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + const keys = Object.keys(data); + expect(keys).toContain("title"); + expect(keys).toContain("pages/0/elements/0/title"); + expect(keys.find((k) => k.includes("/description"))).toBeUndefined(); + }); +}); + +function setupFileMocks() { + vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + access: vi.fn(), + }, + })); + + vi.mock("path", () => ({ + default: { + resolve: vi.fn((path) => path), + dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/")), + }, + })); +} + +function mockFileOperations(input: string) { + (fs.access as any).mockImplementation(() => Promise.resolve()); + (fs.readFile as any).mockImplementation(() => Promise.resolve(input)); + (fs.writeFile as any).mockImplementation(() => Promise.resolve()); +} diff --git a/packages/cli/src/cli/loaders/ignored-keys.spec.ts b/packages/cli/src/cli/loaders/ignored-keys.spec.ts new file mode 100644 index 000000000..e8632d7d1 --- /dev/null +++ b/packages/cli/src/cli/loaders/ignored-keys.spec.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from "vitest"; +import createIgnoredKeysLoader from "./ignored-keys"; + +// Helper values +const defaultLocale = "en"; +const targetLocale = "es"; + +// Common ignored keys list used across tests +const IGNORED_KEYS = ["meta", "todo", "pages/*/title"]; + +/** + * Creates a fresh loader instance with the default locale already set. + */ +function createLoader() { + const loader = createIgnoredKeysLoader(IGNORED_KEYS); + loader.setDefaultLocale(defaultLocale); + return loader; +} + +describe("ignored-keys loader", () => { + it("should omit the ignored keys when pulling the default locale", async () => { + const loader = createLoader(); + const input = { + greeting: "hello", + meta: "some meta information", + todo: "translation pending", + }; + + const result = await loader.pull(defaultLocale, input); + + expect(result).toEqual({ greeting: "hello" }); + }); + + it("should omit the ignored keys when pulling a target locale", async () => { + const loader = createLoader(); + + // First pull for the default locale (required by createLoader) + await loader.pull(defaultLocale, { + greeting: "hello", + meta: "meta en", + }); + + // Now pull the target locale + const targetInput = { + greeting: "hola", + meta: "meta es", + todo: "todo es", + }; + const result = await loader.pull(targetLocale, targetInput); + + expect(result).toEqual({ greeting: "hola" }); + }); + + it("should remove ignored keys when pushing a target locale", async () => { + const loader = createLoader(); + + // Initial pull for the default locale + await loader.pull(defaultLocale, { + greeting: "hello", + meta: "meta en", + todo: "todo en", + }); + + // Pull for the target locale (simulating a translator editing the file) + const targetInput = { + greeting: "hola", + meta: "meta es", + todo: "todo es", + }; + await loader.pull(targetLocale, targetInput); + + // Data that will be pushed (may still contain ignored keys from translation) + const dataToPush = { + greeting: "hola", + meta: "should be removed", + todo: "should be removed", + }; + + const pushResult = await loader.push(targetLocale, dataToPush); + + // The loader should have removed the ignored keys completely. + expect(pushResult).toEqual({ + greeting: "hola", + }); + }); + + it("should omit keys matching wildcard patterns when pulling the default locale", async () => { + const loader = createLoader(); + const input = { + greeting: "hello", + meta: "some meta information", + "pages/0/title": "Title 0", + "pages/0/content": "Content 0", + "pages/foo/title": "Foo Title", + "pages/foo/content": "Foo Content", + "pages/bar/notitle": "No Title", + "pages/bar/content": "No Content", + }; + const result = await loader.pull(defaultLocale, input); + expect(result).toEqual({ + greeting: "hello", + "pages/0/content": "Content 0", + "pages/foo/content": "Foo Content", + "pages/bar/notitle": "No Title", + "pages/bar/content": "No Content", + }); + }); + + it("should omit keys matching wildcard patterns when pulling a target locale", async () => { + const loader = createLoader(); + await loader.pull(defaultLocale, { + greeting: "hello", + meta: "meta en", + "pages/0/title": "Title 0", + "pages/0/content": "Content 0", + "pages/foo/title": "Foo Title", + "pages/foo/content": "Foo Content", + "pages/bar/notitle": "No Title", + "pages/bar/content": "No Content", + }); + const targetInput = { + greeting: "hola", + meta: "meta es", + "pages/0/title": "Title 0", + "pages/0/content": "Contenido 0", + "pages/foo/title": "Foo Title", + "pages/foo/content": "Contenido Foo", + "pages/bar/notitle": "No Title", + "pages/bar/content": "No Content", + }; + const result = await loader.pull(targetLocale, targetInput); + expect(result).toEqual({ + greeting: "hola", + "pages/0/content": "Contenido 0", + "pages/foo/content": "Contenido Foo", + "pages/bar/notitle": "No Title", + "pages/bar/content": "No Content", + }); + }); + + it("should remove wildcard-ignored keys when pushing a target locale", async () => { + const loader = createLoader(); + await loader.pull(defaultLocale, { + greeting: "hello", + meta: "meta en", + "pages/0/title": "Title 0", + "pages/0/content": "Content 0", + "pages/foo/title": "Foo Title", + "pages/foo/content": "Foo Content", + "pages/bar/notitle": "No Title", + "pages/bar/content": "No Content", + }); + await loader.pull(targetLocale, { + greeting: "hola", + meta: "meta es", + "pages/0/title": "Título 0", + "pages/0/content": "Contenido 0", + "pages/foo/title": "Título Foo", + "pages/foo/content": "Contenido Foo", + "pages/bar/notitle": "No Título", + "pages/bar/content": "Contenido Bar", + }); + const dataToPush = { + greeting: "hola", + meta: "should be removed", + "pages/0/title": "should be removed", + "pages/0/content": "Contenido Nuveo", + "pages/foo/title": "should be removed", + "pages/foo/content": "Contenido Nuevo Foo", + "pages/bar/notitle": "No Título", + "pages/bar/content": "Contenido Nuevo Bar", + }; + const pushResult = await loader.push(targetLocale, dataToPush); + expect(pushResult).toEqual({ + greeting: "hola", + "pages/0/content": "Contenido Nuveo", + "pages/foo/content": "Contenido Nuevo Foo", + "pages/bar/notitle": "No Título", + "pages/bar/content": "Contenido Nuevo Bar", + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/ignored-keys.ts b/packages/cli/src/cli/loaders/ignored-keys.ts new file mode 100644 index 000000000..6f995de55 --- /dev/null +++ b/packages/cli/src/cli/loaders/ignored-keys.ts @@ -0,0 +1,24 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import _ from "lodash"; +import { matchesKeyPattern } from "../utils/key-matching"; + +export default function createIgnoredKeysLoader( + ignoredKeys: string[], +): ILoader, Record> { + return createLoader({ + pull: async (locale, data) => { + const result = _.omitBy(data, (value, key) => + matchesKeyPattern(key, ignoredKeys), + ); + return result; + }, + push: async (locale, data, originalInput, originalLocale, pullInput) => { + // Remove ignored keys from the data being pushed + const result = _.omitBy(data, (value, key) => + matchesKeyPattern(key, ignoredKeys), + ); + return result; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/index.spec.ts b/packages/cli/src/cli/loaders/index.spec.ts new file mode 100644 index 000000000..2cb3dc1eb --- /dev/null +++ b/packages/cli/src/cli/loaders/index.spec.ts @@ -0,0 +1,4336 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import dedent from "dedent"; +import _ from "lodash"; +import fs from "fs/promises"; +import createBucketLoader from "./index"; +import createTextFileLoader from "./text-file"; + +describe("bucket loaders", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + describe("android bucket loader", () => { + it("should load android data", async () => { + setupFileMocks(); + + const input = ` + + Submit + + `.trim(); + const expectedOutput = { "button.title": "Submit" }; + + mockFileOperations(input); + + const androidLoader = createBucketLoader( + "android", + "values-[locale]/strings.xml", + { + defaultLocale: "en", + }, + ); + androidLoader.setDefaultLocale("en"); + const data = await androidLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should skip non-translatable strings", async () => { + setupFileMocks(); + + const input = ` + + MyApp + Submit + 1.0.0 + + `.trim(); + const expectedOutput = { "button.title": "Submit" }; + + mockFileOperations(input); + + const androidLoader = createBucketLoader( + "android", + "values-[locale]/strings.xml", + { + defaultLocale: "en", + }, + ); + androidLoader.setDefaultLocale("en"); + const data = await androidLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save android data", async () => { + setupFileMocks(); + + // Use proper Android Studio format: XML declaration + 4-space indentation + const input = ` + + Submit +`; + const payload = { "button.title": "Enviar" }; + // Output preserves XML declaration and uses 4-space indentation (Android standard) + const expectedOutput = ` + + Enviar +`; + + mockFileOperations(input); + + const androidLoader = createBucketLoader( + "android", + "values-[locale]/strings.xml", + { + defaultLocale: "en", + }, + ); + androidLoader.setDefaultLocale("en"); + await androidLoader.pull("en"); + + await androidLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "values-es/strings.xml", + expectedOutput, + { + encoding: "utf-8", + flag: "w", + }, + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = ` + + Original + Hello +`; + + mockFileOperations(input); + + const androidLoader = createBucketLoader( + "android", + "values-[locale]/strings.xml", + { defaultLocale: "en" }, + ["locked_key"], + ); + androidLoader.setDefaultLocale("en"); + const data = await androidLoader.pull("en"); + + expect(data).toEqual({ unlocked_key: "Hello" }); + }); + }); + + describe("csv bucket loader", () => { + it("should load csv data ('KEY' as key, from automatic fallback", async () => { + setupFileMocks(); + + const input = ` ,KEY,en\n,button.title,Submit`; + const expectedOutput = { "button.title": "Submit" }; + + mockFileOperations(input); + + const csvLoader = createBucketLoader("csv", "i18n.csv", { + defaultLocale: "en", + }); + csvLoader.setDefaultLocale("en"); + const data = await csvLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should load csv data ('id' as key, first cell)", async () => { + setupFileMocks(); + + const input = `id,en\nbutton.title,Submit`; + const expectedOutput = { "button.title": "Submit" }; + + mockFileOperations(input); + + const csvLoader = createBucketLoader("csv", "i18n.csv", { + defaultLocale: "en", + }); + csvLoader.setDefaultLocale("en"); + const data = await csvLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save csv data", async () => { + setupFileMocks(); + + const input = `id,en,es\nbutton.title,Submit,`; + const payload = { "button.title": "Enviar" }; + const expectedOutput = `id,en,es\nbutton.title,Submit,Enviar`; + + mockFileOperations(input); + + const csvLoader = createBucketLoader("csv", "i18n.csv", { + defaultLocale: "en", + }); + csvLoader.setDefaultLocale("en"); + await csvLoader.pull("en"); + + await csvLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith("i18n.csv", expectedOutput, { + encoding: "utf-8", + flag: "w", + }); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = `id,en\nlocked_key,Original\nunlocked_key,Hello`; + + mockFileOperations(input); + + const csvLoader = createBucketLoader( + "csv", + "i18n.csv", + { + defaultLocale: "en", + }, + ["locked_key"], + ); + csvLoader.setDefaultLocale("en"); + const data = await csvLoader.pull("en"); + + expect(data).toEqual({ unlocked_key: "Hello" }); + }); + }); + + describe("flutter bucket loader", () => { + it("should load flutter data", async () => { + setupFileMocks(); + + const input = `{ + "@@locale": "en", + "greeting": "Hello, {name}!", + "@greeting": { + "description": "A greeting with a name placeholder", + "placeholders": { + "name": { + "type": "String", + "example": "John" + } + } + } + }`; + const expectedOutput = { greeting: "Hello, {name}!" }; + + mockFileOperations(input); + + const flutterLoader = createBucketLoader( + "flutter", + "lib/l10n/app_[locale].arb", + { + defaultLocale: "en", + }, + ); + flutterLoader.setDefaultLocale("en"); + const data = await flutterLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save flutter data", async () => { + setupFileMocks(); + + const input = `{ + "@@locale": "en", + "greeting": "Hello, {name}!", + "@greeting": { + "description": "A greeting with a name placeholder", + "placeholders": { + "name": { + "type": "String", + "example": "John" + } + } + } + }`; + const payload = { greeting: "¡Hola, {name}!" }; + const expectedOutput = JSON.stringify( + { + "@@locale": "es", + greeting: "¡Hola, {name}!", + "@greeting": { + description: "A greeting with a name placeholder", + placeholders: { + name: { + type: "String", + example: "John", + }, + }, + }, + }, + null, + 2, + ); + + mockFileOperations(input); + + const flutterLoader = createBucketLoader( + "flutter", + "lib/l10n/app_[locale].arb", + { + defaultLocale: "en", + }, + ); + flutterLoader.setDefaultLocale("en"); + await flutterLoader.pull("en"); + + await flutterLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "lib/l10n/app_es.arb", + expectedOutput, + { + encoding: "utf-8", + flag: "w", + }, + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = `{ + "@@locale": "en", + "locked_key": "Original", + "unlocked_key": "Hello" + }`; + + mockFileOperations(input); + + const flutterLoader = createBucketLoader( + "flutter", + "lib/l10n/app_[locale].arb", + { defaultLocale: "en" }, + ["locked_key"], + ); + flutterLoader.setDefaultLocale("en"); + const data = await flutterLoader.pull("en"); + + expect(data).toEqual({ unlocked_key: "Hello" }); + }); + }); + + describe("html bucket loader", () => { + it("should load html data with inline elements preserved", async () => { + setupFileMocks(); + + const input = ` + + + My Page + + + +

    Hello, world!

    +

    + This is a paragraph with a + link + and + + bold and italic text + + . +

    + + + `.trim(); + const expectedOutput = { + "head/0": "My Page", + "head/1#content": "Page description", + "body/0": "Hello, world!", + "body/1": `This is a paragraph with a + link + and + + bold and + italic text + + .`, + }; + + mockFileOperations(input); + + const htmlLoader = createBucketLoader("html", "i18n/[locale].html", { + defaultLocale: "en", + }); + htmlLoader.setDefaultLocale("en"); + const data = await htmlLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save html data with inline elements preserved", async () => { + const input = dedent` + + + My Page + + + +

    Hello, world!

    +

    + This is a paragraph with a link and bold and italic text +

    + + + `.trim(); + const payload = { + "head/0": "Mi Página", + "head/1#content": "Descripción de la página", + "body/0": "¡Hola, mundo!", + "body/1": + 'Este es un párrafo con un enlace y texto en negrita y texto en cursiva', + }; + const expectedOutput = + '\n' + + " \n" + + " Mi Página\n" + + ' \n' + + " \n" + + " \n" + + "

    ¡Hola, mundo!

    \n" + + "

    \n" + + " Este es un párrafo con un\n" + + ' enlace\n' + + " y\n" + + " \n" + + " texto en negrita y\n" + + " texto en cursiva\n" + + " \n" + + "

    \n" + + " \n" + + ""; + + mockFileOperations(input); + + const htmlLoader = createBucketLoader("html", "i18n/[locale].html", { + defaultLocale: "en", + }); + htmlLoader.setDefaultLocale("en"); + await htmlLoader.pull("en"); + + await htmlLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.html", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = ` + + + Locked Title + + +

    Hello

    + +`; + + mockFileOperations(input); + + const htmlLoader = createBucketLoader( + "html", + "i18n/[locale].html", + { defaultLocale: "en" }, + ["head/0"], + ); + htmlLoader.setDefaultLocale("en"); + const data = await htmlLoader.pull("en"); + + // Title is locked, only body text should remain + expect(Object.values(data)).toContain("Hello"); + expect(Object.keys(data)).not.toContain("head/0"); + }); + }); + + describe("jsonc bucket loader", () => { + it("should load jsonc data with comments", async () => { + setupFileMocks(); + + const input = `{ + // This is a comment for title + "title": "Submit", + /* This is a block comment for description */ + "description": "Button description", + "nested": { + // Nested comment + "key": "value" + } + }`; + const expectedOutput = { + title: "Submit", + description: "Button description", + "nested/key": "value", + }; + + mockFileOperations(input); + + const jsoncLoader = createBucketLoader("jsonc", "i18n/[locale].jsonc", { + defaultLocale: "en", + }); + jsoncLoader.setDefaultLocale("en"); + const data = await jsoncLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save jsonc data", async () => { + setupFileMocks(); + + const input = `{ + // This is a comment + "title": "Submit" + }`; + const payload = { title: "Enviar" }; + const expectedOutput = JSON.stringify(payload, null, 2); + + mockFileOperations(input); + + const jsoncLoader = createBucketLoader("jsonc", "i18n/[locale].jsonc", { + defaultLocale: "en", + }); + jsoncLoader.setDefaultLocale("en"); + await jsoncLoader.pull("en"); + + await jsoncLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.jsonc", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should extract hints from jsonc comments", async () => { + setupFileMocks(); + + const input = `{ + "key1": "value1", // This is a comment for key1 + "key2": "value2" /* This is a comment for key2 */, + // This is a comment for key3 + "key3": "value3", + /* This is a block comment for key4 */ + "key4": "value4", + /* + This is a comment for key5 + */ + "key5": "value5", + // This is a comment for key6 + "key6": { + // This is a comment for key7 + "key7": "value7" + } + }`; + + mockFileOperations(input); + + const jsoncLoader = createBucketLoader("jsonc", "i18n/[locale].jsonc", { + defaultLocale: "en", + }); + jsoncLoader.setDefaultLocale("en"); + await jsoncLoader.pull("en"); + + const hints = await jsoncLoader.pullHints(); + + expect(hints).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 jsonc with trailing commas", async () => { + setupFileMocks(); + + const input = `{ + "hello": "Hello", + "world": "World", + "array": [ + "item1", + "item2", + ], + }`; + const expectedOutput = { + hello: "Hello", + world: "World", + "array/0": "item1", + "array/1": "item2", + }; + + mockFileOperations(input); + + const jsoncLoader = createBucketLoader("jsonc", "i18n/[locale].jsonc", { + defaultLocale: "en", + }); + jsoncLoader.setDefaultLocale("en"); + const data = await jsoncLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should handle invalid jsonc gracefully", async () => { + setupFileMocks(); + + const input = `{ + "hello": "Hello" + "world": "World" // missing comma + invalid: syntax + }`; + + mockFileOperations(input); + + const jsoncLoader = createBucketLoader("jsonc", "i18n/[locale].jsonc", { + defaultLocale: "en", + }); + jsoncLoader.setDefaultLocale("en"); + + await expect(jsoncLoader.pull("en")).rejects.toThrow( + "Failed to parse JSONC", + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = `{ + "locked_key": "Original", + "unlocked_key": "Hello" + }`; + + mockFileOperations(input); + + const jsoncLoader = createBucketLoader( + "jsonc", + "i18n/[locale].jsonc", + { + defaultLocale: "en", + }, + ["locked_key"], + ); + jsoncLoader.setDefaultLocale("en"); + const data = await jsoncLoader.pull("en"); + + expect(data).toEqual({ unlocked_key: "Hello" }); + }); + }); + + describe("json bucket loader", () => { + it("should load json data", async () => { + setupFileMocks(); + + const input = { "button.title": "Submit" }; + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader("json", "i18n/[locale].json", { + defaultLocale: "en", + }); + jsonLoader.setDefaultLocale("en"); + const data = await jsonLoader.pull("en"); + + expect(data).toEqual(input); + }); + + it("should save json data", async () => { + setupFileMocks(); + + const input = { "button.title": "Submit" }; + const payload = { "button.title": "Enviar" }; + const expectedOutput = JSON.stringify(payload, null, 2); + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader("json", "i18n/[locale].json", { + defaultLocale: "en", + }); + jsonLoader.setDefaultLocale("en"); + await jsonLoader.pull("en"); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.json", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should save json data with numeric keys", async () => { + setupFileMocks(); + + const input = { messages: { "1": "foo", "2": "bar", "3": "bar" } }; + const payload = { + "messages/1": "foo", + "messages/2": "bar", + "messages/3": "bar", + }; + const expectedOutput = JSON.stringify(input, null, 2); + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader("json", "i18n/[locale].json", { + defaultLocale: "en", + }); + jsonLoader.setDefaultLocale("en"); + await jsonLoader.pull("en"); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.json", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should save json data with array", async () => { + setupFileMocks(); + + const input = { messages: ["foo", "bar"] }; + const payload = { "messages/0": "foo", "messages/1": "bar" }; + const expectedOutput = dedent` + { + "messages": ["foo", "bar"] + } + `.trim(); + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader("json", "i18n/[locale].json", { + defaultLocale: "en", + }); + jsonLoader.setDefaultLocale("en"); + await jsonLoader.pull("en"); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.json", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should return keys in correct order, should not use key values from original input for missing keys", async () => { + setupFileMocks(); + + const input = { + "button.title": "Submit", + "button.subtitle": "Submit subtitle", + "button.description": "Submit description", + }; + const payload = { + "button.subtitle": "Subtítulo de envío", + "button.title": "Enviar", + }; + const expectedOutput = JSON.stringify( + { + "button.title": "Enviar", + "button.subtitle": "Subtítulo de envío", + }, + null, + 2, + ); + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader("json", "i18n/[locale].json", { + defaultLocale: "en", + }); + jsonLoader.setDefaultLocale("en"); + await jsonLoader.pull("en"); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.json", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should load and save json data for paths with multiple locales", async () => { + setupFileMocks(); + + const input = { "button.title": "Submit" }; + const payload = { "button.title": "Enviar" }; + const expectedOutput = JSON.stringify(payload, null, 2); + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader( + "json", + "i18n/[locale]/[locale].json", + { + defaultLocale: "en", + }, + ); + jsonLoader.setDefaultLocale("en"); + const data = await jsonLoader.pull("en"); + + await jsonLoader.push("es", payload); + + expect(data).toEqual(input); + expect(fs.access).toHaveBeenCalledWith("i18n/en/en.json"); + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es/es.json", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should remove injected locales from json data", async () => { + setupFileMocks(); + + const input = { + "button.title": "Submit", + settings: { locale: "en" }, + "not-a-locale": "bar", + }; + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader("json", "i18n/[locale].json", { + defaultLocale: "en", + injectLocale: ["settings/locale", "not-a-locale"], + }); + jsonLoader.setDefaultLocale("en"); + const data = await jsonLoader.pull("en"); + + expect(data).toEqual({ "button.title": "Submit", "not-a-locale": "bar" }); + }); + + it("should inject locales into json data", async () => { + setupFileMocks(); + + const input = { + "button.title": "Submit", + "not-a-locale": "bar", + settings: { locale: "en" }, + }; + const payload = { "button.title": "Enviar", "not-a-locale": "bar" }; + const expectedOutput = JSON.stringify( + { ...payload, settings: { locale: "es" } }, + null, + 2, + ); + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader("json", "i18n/[locale].json", { + defaultLocale: "en", + injectLocale: ["settings/locale", "not-a-locale"], + }); + jsonLoader.setDefaultLocale("en"); + await jsonLoader.pull("en"); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.json", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + }); + + describe("locked keys functionality", () => { + it("should respect locked keys for JSON format", async () => { + setupFileMocks(); + + const input = { + "button.title": "Submit", + "button.description": "Submit description", + "locked.key": "Should not change", + nested: { + locked: "This is locked", + unlocked: "This can change", + }, + }; + const payload = { + "button.title": "Enviar", + "button.description": "Descripción de envío", + "locked.key": "This should not be applied", + "nested/locked": "This should not be applied either", + "nested/unlocked": "Este puede cambiar", + }; + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader( + "json", + "i18n/[locale].json", + { defaultLocale: "en" }, + ["locked.key", "nested/locked"], + ); + + jsonLoader.setDefaultLocale("en"); + await jsonLoader.pull("en"); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + // Check that locked keys retain their original values + expect(writtenContent["locked.key"]).toBe("Should not change"); + expect(writtenContent.nested.locked).toBe("This is locked"); + + // Check that unlocked keys are updated + expect(writtenContent["button.title"]).toBe("Enviar"); + expect(writtenContent["button.description"]).toBe("Descripción de envío"); + expect(writtenContent.nested.unlocked).toBe("Este puede cambiar"); + }); + + it("should handle deeply nested locked keys", async () => { + setupFileMocks(); + + const input = { + level1: { + level2: { + level3: { + locked: "This is locked deep", + unlocked: "This can change", + }, + }, + }, + }; + const payload = { + "level1/level2/level3/locked": "This should not be applied", + "level1/level2/level3/unlocked": "This should change", + }; + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader( + "json", + "i18n/[locale].json", + { defaultLocale: "en" }, + ["level1/level2/level3/locked"], + ); + + jsonLoader.setDefaultLocale("en"); + await jsonLoader.pull("en"); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + // Check that deeply nested locked key retains its original value + expect(writtenContent.level1.level2.level3.locked).toBe( + "This is locked deep", + ); + + // Check that unlocked key is updated + expect(writtenContent.level1.level2.level3.unlocked).toBe( + "This should change", + ); + }); + + it("should lock keys that are arrays", async () => { + setupFileMocks(); + + const input = { + messages: ["first", "second", "third"], + unlocked: ["can", "be", "changed"], + }; + const payload = { + "messages/0": "should not change", + "messages/1": "should not change either", + "messages/2": "should definitely not change", + "unlocked/0": "should", + "unlocked/1": "definitely", + "unlocked/2": "change", + }; + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader( + "json", + "i18n/[locale].json", + { defaultLocale: "en" }, + ["messages/0", "messages/1", "messages/2"], + ); + + jsonLoader.setDefaultLocale("en"); + await jsonLoader.pull("en"); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + // Check that locked array elements retain their original values + expect(writtenContent.messages[0]).toBe("first"); + expect(writtenContent.messages[1]).toBe("second"); + expect(writtenContent.messages[2]).toBe("third"); + + // Check that unlocked array elements are updated + expect(writtenContent.unlocked[0]).toBe("should"); + expect(writtenContent.unlocked[1]).toBe("definitely"); + expect(writtenContent.unlocked[2]).toBe("change"); + }); + }); + + describe("ignored keys functionality", () => { + it("should omit ignored keys for JSON format", async () => { + setupFileMocks(); + + const input = { + "button.title": "Submit", + "button.description": "Submit description", + "ignored.key": "Should be ignored", + nested: { + ignored: "This is ignored", + kept: "This is kept", + }, + }; + const payload = { + "button.title": "Enviar", + "button.description": "Descripción de envío", + "nested/kept": "Esto se mantiene", + }; + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader( + "json", + "i18n/[locale].json", + { defaultLocale: "en" }, + undefined, // lockedKeys + undefined, // lockedPatterns + ["ignored.key", "nested/ignored"], // ignoredKeys + ); + + jsonLoader.setDefaultLocale("en"); + const pulledData = await jsonLoader.pull("en"); + + // Verify ignored keys are not in pulled data + expect(pulledData).toEqual({ + "button.title": "Submit", + "button.description": "Submit description", + "nested/kept": "This is kept", + }); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + // Check that ignored keys are completely removed from output + expect(writtenContent["ignored.key"]).toBeUndefined(); + expect(writtenContent.nested?.ignored).toBeUndefined(); + + // Check that non-ignored keys are updated + expect(writtenContent["button.title"]).toBe("Enviar"); + expect(writtenContent["button.description"]).toBe("Descripción de envío"); + expect(writtenContent.nested.kept).toBe("Esto se mantiene"); + }); + + it("should handle wildcard patterns in ignored keys", async () => { + setupFileMocks(); + + const input = { + "button.title": "Submit", + wildcard_a: "Value A", + wildcard_b: "Value B", + other: "Other value", + }; + const payload = { + "button.title": "Enviar", + other: "Otro valor", + }; + + mockFileOperations(JSON.stringify(input)); + + const jsonLoader = createBucketLoader( + "json", + "i18n/[locale].json", + { defaultLocale: "en" }, + undefined, // lockedKeys + undefined, // lockedPatterns + ["wildcard_*"], // ignoredKeys with wildcard + ); + + jsonLoader.setDefaultLocale("en"); + const pulledData = await jsonLoader.pull("en"); + + // Verify wildcard ignored keys are not in pulled data + expect(pulledData).toEqual({ + "button.title": "Submit", + other: "Other value", + }); + + await jsonLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + // Check that wildcard ignored keys are completely removed from output + expect(writtenContent["wildcard_a"]).toBeUndefined(); + expect(writtenContent["wildcard_b"]).toBeUndefined(); + + // Check that non-ignored keys are updated + expect(writtenContent["button.title"]).toBe("Enviar"); + expect(writtenContent.other).toBe("Otro valor"); + }); + }); + + describe("mdx bucket loader", () => { + it("should skip locked keys", async () => { + setupFileMocks(); + + const input = dedent` +--- +title: Test Mdx +category: test +--- + +# Heading 1 +`; + const expectedPayload = { + "meta/title": "Test Mdx", + "content/0": "\n# Heading 1", + }; + + mockFileOperations(input); + + const mdxLoader = createBucketLoader( + "mdx", + "i18n/[locale].mdx", + { defaultLocale: "en" }, + ["meta/category"], + ); + + mdxLoader.setDefaultLocale("en"); + const data = await mdxLoader.pull("en"); + + expect(data).toEqual(expectedPayload); + }); + }); + + describe("markdown bucket loader", () => { + it("should load markdown data", async () => { + setupFileMocks(); + + const input = `--- +title: Test Markdown +date: 2023-05-25 +--- + +# Heading 1 + +This is a paragraph. + +## Heading 2 + +Another paragraph with **bold** and *italic* text.`; + const expectedOutput = { + "fm-attr-title": "Test Markdown", + "md-section-0": "# Heading 1", + "md-section-1": "This is a paragraph.", + "md-section-2": "## Heading 2", + "md-section-3": "Another paragraph with **bold** and _italic_ text.", + }; + + mockFileOperations(input); + + const markdownLoader = createBucketLoader( + "markdown", + "i18n/[locale].md", + { + defaultLocale: "en", + }, + ); + markdownLoader.setDefaultLocale("en"); + const data = await markdownLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save markdown data", async () => { + setupFileMocks(); + + const input = `--- +title: Test Markdown +date: 2023-05-25 +--- + +# Heading 1 + +This is a paragraph. + +## Heading 2 + +Another paragraph with **bold** and *italic* text.`; + const payload = { + "fm-attr-title": "Prueba Markdown", + "fm-attr-date": "2023-05-25", + "md-section-0": "# Encabezado 1", + "md-section-1": "Esto es un párrafo.", + "md-section-2": "## Encabezado 2", + "md-section-3": "Otro párrafo con texto en **negrita** y en _cursiva_.", + }; + const expectedOutput = `--- +title: Prueba Markdown +date: 2023-05-25 +--- + +# Encabezado 1 + +Esto es un párrafo. + +## Encabezado 2 + +Otro párrafo con texto en **negrita** y en _cursiva_. +`.trim(); + + mockFileOperations(input); + + const markdownLoader = createBucketLoader( + "markdown", + "i18n/[locale].md", + { + defaultLocale: "en", + }, + ); + markdownLoader.setDefaultLocale("en"); + await markdownLoader.pull("en"); + + await markdownLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.md", expectedOutput, { + encoding: "utf-8", + flag: "w", + }); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = dedent`--- +title: Locked Title +--- + +Content here.`; + + mockFileOperations(input); + + const markdownLoader = createBucketLoader( + "markdown", + "i18n/[locale].md", + { defaultLocale: "en" }, + ["fm-attr-title"], + ); + markdownLoader.setDefaultLocale("en"); + const data = await markdownLoader.pull("en"); + + // frontmatter title removed + expect(Object.keys(data)).not.toContain("fm-attr-title"); + }); + }); + + describe("properties bucket loader", () => { + it("should load properties data", async () => { + setupFileMocks(); + + const input = ` +# General messages +welcome.message=Welcome to our application! +error.message=An error has occurred. Please try again later. + +# User-related messages +user.login=Please enter your username and password. +user.username=Username +user.password=Password + `.trim(); + const expectedOutput = { + "welcome.message": "Welcome to our application!", + "error.message": "An error has occurred. Please try again later.", + "user.login": "Please enter your username and password.", + "user.username": "Username", + "user.password": "Password", + }; + + mockFileOperations(input); + + const propertiesLoader = createBucketLoader( + "properties", + "i18n/[locale].properties", + { + defaultLocale: "en", + }, + ); + propertiesLoader.setDefaultLocale("en"); + const data = await propertiesLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save properties data", async () => { + setupFileMocks(); + + const input = ` +# General messages +welcome.message=Welcome to our application! +error.message=An error has occurred. Please try again later. + +# User-related messages +user.login=Please enter your username and password. +user.username=Username +user.password=Password + `.trim(); + const payload = { + "welcome.message": "Bienvenido a nuestra aplicación!", + "error.message": + "Se ha producido un error. Por favor, inténtelo de nuevo más tarde.", + "user.login": + "Por favor, introduzca su nombre de usuario y contraseña.", + "user.username": "Nombre de usuario", + "user.password": "Contraseña", + }; + const expectedOutput = ` +welcome.message=Bienvenido a nuestra aplicación! +error.message=Se ha producido un error. Por favor, inténtelo de nuevo más tarde. +user.login=Por favor, introduzca su nombre de usuario y contraseña. +user.username=Nombre de usuario +user.password=Contraseña + `.trim(); + + mockFileOperations(input); + + const propertiesLoader = createBucketLoader( + "properties", + "i18n/[locale].properties", + { + defaultLocale: "en", + }, + ); + propertiesLoader.setDefaultLocale("en"); + await propertiesLoader.pull("en"); + + await propertiesLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.properties", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = `locked=Original\nunlocked=Hello`; + + mockFileOperations(input); + + const propertiesLoader = createBucketLoader( + "properties", + "i18n/[locale].properties", + { defaultLocale: "en" }, + ["locked"], + ); + propertiesLoader.setDefaultLocale("en"); + const data = await propertiesLoader.pull("en"); + + expect(data).toEqual({ unlocked: "Hello" }); + }); + }); + + describe("xcode-strings bucket loader", () => { + it("should load xcode-strings", async () => { + setupFileMocks(); + + const input = ` +"key1" = "value1"; +"key2" = "value2"; +"key3" = "Line 1\\nLine 2\\"quoted\\""; + `.trim(); + const expectedOutput = { + key1: "value1", + key2: "value2", + key3: 'Line 1\nLine 2"quoted"', + }; + + mockFileOperations(input); + + const xcodeStringsLoader = createBucketLoader( + "xcode-strings", + "i18n/[locale].strings", + { + defaultLocale: "en", + }, + ); + xcodeStringsLoader.setDefaultLocale("en"); + const data = await xcodeStringsLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save xcode-strings", async () => { + setupFileMocks(); + + const input = ` +"hello" = "Hello!"; + `.trim(); + const payload = { hello: "¡Hola!" }; + const expectedOutput = `"hello" = "¡Hola!";`; + + mockFileOperations(input); + + const xcodeStringsLoader = createBucketLoader( + "xcode-strings", + "i18n/[locale].strings", + { + defaultLocale: "en", + }, + ); + xcodeStringsLoader.setDefaultLocale("en"); + await xcodeStringsLoader.pull("en"); + + await xcodeStringsLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.strings", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = ` +"locked" = "Original"; +"hello" = "Hello!"; + `.trim(); + + mockFileOperations(input); + + const xcodeStringsLoader = createBucketLoader( + "xcode-strings", + "i18n/[locale].strings", + { defaultLocale: "en" }, + ["locked"], + ); + xcodeStringsLoader.setDefaultLocale("en"); + const data = await xcodeStringsLoader.pull("en"); + + expect(data).toEqual({ hello: "Hello!" }); + }); + }); + + describe("xcode-stringsdict bucket loader", () => { + it("should load xcode-stringsdict", async () => { + setupFileMocks(); + + const input = ` + + + + + greeting + Hello! + items_count + + NSStringLocalizedFormatKey + %#@items@ + items + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d item + other + %d items + + + + + `.trim(); + const expectedOutput = { + greeting: "Hello!", + "items_count/NSStringLocalizedFormatKey": "%#@items@", + "items_count/items/NSStringFormatSpecTypeKey": "NSStringPluralRuleType", + "items_count/items/NSStringFormatValueTypeKey": "d", + "items_count/items/one": "%d item", + "items_count/items/other": "%d items", + }; + + mockFileOperations(input); + + const xcodeStringsdictLoader = createBucketLoader( + "xcode-stringsdict", + "i18n/[locale].stringsdict", + { + defaultLocale: "en", + }, + ); + xcodeStringsdictLoader.setDefaultLocale("en"); + const data = await xcodeStringsdictLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save xcode-stringsdict", async () => { + setupFileMocks(); + + const input = ` + + + + + greeting + Hello! + + + `.trim(); + const payload = { greeting: "¡Hola!" }; + const expectedOutput = ` + + + + greeting + ¡Hola! + + + `.trim(); + + mockFileOperations(input); + + const xcodeStringsdictLoader = createBucketLoader( + "xcode-stringsdict", + "[locale].lproj/Localizable.stringsdict", + { + defaultLocale: "en", + }, + ); + xcodeStringsdictLoader.setDefaultLocale("en"); + await xcodeStringsdictLoader.pull("en"); + + await xcodeStringsdictLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "es.lproj/Localizable.stringsdict", + expectedOutput, + { + encoding: "utf-8", + flag: "w", + }, + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = ` + + + + + locked + Original + hello + Hello! + + + `.trim(); + + mockFileOperations(input); + + const xcodeStringsdictLoader = createBucketLoader( + "xcode-stringsdict", + "i18n/[locale].stringsdict", + { defaultLocale: "en" }, + ["locked"], + ); + xcodeStringsdictLoader.setDefaultLocale("en"); + const data = await xcodeStringsdictLoader.pull("en"); + + expect(data).toEqual({ hello: "Hello!" }); + }); + }); + + describe("xcode-xcstrings bucket loader", () => { + it("should load xcode-xcstrings", async () => { + setupFileMocks(); + + const input = JSON.stringify({ + sourceLanguage: "en", + strings: { + greeting: { + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Hello!", + }, + }, + }, + }, + message: { + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Welcome to our app", + }, + }, + }, + }, + items_count: { + extractionState: "manual", + localizations: { + en: { + variations: { + plural: { + zero: { + stringUnit: { + state: "translated", + value: "No items", + }, + }, + one: { + stringUnit: { + state: "translated", + value: "%d item", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d items", + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const expectedOutput = { + greeting: "Hello!", + message: "Welcome to our app", + "items_count/zero": "No items", + "items_count/one": "{variable:0} item", + "items_count/other": "{variable:0} items", + }; + + mockFileOperations(input); + + const xcodeXcstringsLoader = createBucketLoader( + "xcode-xcstrings", + "i18n/[locale].xcstrings", + { + defaultLocale: "en", + }, + ); + xcodeXcstringsLoader.setDefaultLocale("en"); + const data = await xcodeXcstringsLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should load keys without default locale entries and use the key as value", async () => { + setupFileMocks(); + + const input = JSON.stringify({ + sourceLanguage: "en", + strings: { + greeting: { + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Hello!", + }, + }, + }, + }, + " and ": { + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: " and ", + }, + }, + }, + }, + key_with_no_default: { + extractionState: "manual", + localizations: { + fr: { + stringUnit: { + state: "translated", + value: "Valeur traduite", + }, + }, + }, + }, + }, + }); + + const expectedOutput = { + greeting: "Hello!", + "%20and%20": " and ", + key_with_no_default: "key_with_no_default", + }; + + mockFileOperations(input); + + const xcodeXcstringsLoader = createBucketLoader( + "xcode-xcstrings", + "i18n/[locale].xcstrings", + { + defaultLocale: "en", + }, + ); + xcodeXcstringsLoader.setDefaultLocale("en"); + const data = await xcodeXcstringsLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save xcode-xcstrings", async () => { + setupFileMocks(); + + const originalInput = { + sourceLanguage: "en", + strings: { + greeting: { + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Hello!", + }, + }, + }, + }, + }, + }; + + mockFileOperations(JSON.stringify(originalInput)); + + const payload = { + greeting: "Bonjour!", + message: "Bienvenue dans notre application", + "items_count/zero": "Aucun élément", + "items_count/one": "%d élément", + "items_count/other": "%d éléments", + }; + + const xcodeXcstringsLoader = createBucketLoader( + "xcode-xcstrings", + "i18n/[locale].xcstrings", + { + defaultLocale: "en", + }, + ); + xcodeXcstringsLoader.setDefaultLocale("en"); + await xcodeXcstringsLoader.pull("en"); + await xcodeXcstringsLoader.push("fr", payload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + expect(writtenContent.strings.greeting.localizations.fr).toBeDefined(); + expect( + writtenContent.strings.greeting.localizations.fr.stringUnit.value, + ).toBe("Bonjour!"); + + if (writtenContent.strings.message) { + expect( + writtenContent.strings.message.localizations.fr.stringUnit.value, + ).toBe("Bienvenue dans notre application"); + } + + if (writtenContent.strings.items_count) { + expect( + writtenContent.strings.items_count.localizations.fr.variations.plural + .zero.stringUnit.value, + ).toBe("Aucun élément"); + expect( + writtenContent.strings.items_count.localizations.fr.variations.plural + .one.stringUnit.value, + ).toBe("%d élément"); + expect( + writtenContent.strings.items_count.localizations.fr.variations.plural + .other.stringUnit.value, + ).toBe("%d éléments"); + } + }); + + it("should maintain ASCII ordering with empty strings, whitespace, and numbers", async () => { + setupFileMocks(); + + const input = `{ + "sourceLanguage": "en", + "strings": { + "": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Empty key" + } + } + } + }, + " ": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Space key" + } + } + } + }, + "25": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Numeric key" + } + } + } + }, + "apple": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apple" + } + } + } + } +}`; + + mockFileOperations(input); + + const xcodeXcstringsLoader = createBucketLoader( + "xcode-xcstrings", + "i18n/[locale].xcstrings", + { + defaultLocale: "en", + }, + ); + xcodeXcstringsLoader.setDefaultLocale("en"); + const data = await xcodeXcstringsLoader.pull("en"); + + Object.keys(data).forEach((key) => { + if (key === "") { + expect(data[key]).toBe("Empty key"); + } else if (key.includes("%20") || key === " ") { + expect(data[key]).toBe("Space key"); + } else if (key === "25") { + expect(data[key]).toBe("Numeric key"); + } else if (key === "apple") { + expect(data[key]).toBe("Apple"); + } + }); + + const payload: Record = {}; + + Object.keys(data).forEach((key) => { + if (key === "") { + payload[key] = "Vide"; + } else if (key.includes("%20") || key === " ") { + payload[key] = "Espace"; + } else if (key === "25") { + payload[key] = "Numérique"; + } else if (key === "apple") { + payload[key] = "Pomme"; + } + }); + + await xcodeXcstringsLoader.pull("en"); + await xcodeXcstringsLoader.push("fr", payload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + if (writtenContent.strings[""]) { + expect( + writtenContent.strings[""].localizations.fr.stringUnit.value, + ).toBe("Vide"); + } + + const hasSpaceKey = Object.keys(writtenContent.strings).some( + (key) => key === " " || key === "%20" || key.includes("%20"), + ); + if (hasSpaceKey) { + const spaceKey = Object.keys(writtenContent.strings).find( + (key) => key === " " || key === "%20" || key.includes("%20"), + ); + if (spaceKey) { + expect( + writtenContent.strings[spaceKey].localizations.fr.stringUnit.value, + ).toBe("Espace"); + } + } + + if (writtenContent.strings["25"]) { + expect( + writtenContent.strings["25"].localizations.fr.stringUnit.value, + ).toBe("Numérique"); + } + + if (writtenContent.strings["apple"]) { + expect( + writtenContent.strings["apple"].localizations.fr.stringUnit.value, + ).toBe("Pomme"); + } + + const stringKeys = Object.keys(writtenContent.strings); + + expect(stringKeys.includes("25")).toBe(true); + expect(stringKeys.includes("")).toBe(true); + expect(stringKeys.includes(" ") || stringKeys.includes("%20")).toBe(true); + expect(stringKeys.includes("apple")).toBe(true); + + expect(stringKeys.indexOf("25")).toBeLessThan(stringKeys.indexOf("")); + + const spaceIdx = + stringKeys.indexOf(" ") === -1 + ? stringKeys.indexOf("%20") + : stringKeys.indexOf(" "); + if (spaceIdx !== -1) { + expect(stringKeys.indexOf("")).toBeLessThan(spaceIdx); + } + + if (spaceIdx !== -1) { + expect(spaceIdx).toBeLessThan(stringKeys.indexOf("apple")); + } + }); + + it("should respect shouldTranslate: false flag", async () => { + setupFileMocks(); + + const input = `{ + "sourceLanguage": "en", + "strings": { + "do_not_translate": { + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This should not be translated" + } + } + } + }, + "normal_key": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This should be translated" + } + } + } + } + } +}`; + + mockFileOperations(input); + + const xcodeXcstringsLoader = createBucketLoader( + "xcode-xcstrings", + "i18n/[locale].xcstrings", + { + defaultLocale: "en", + }, + ); + xcodeXcstringsLoader.setDefaultLocale("en"); + + const data = await xcodeXcstringsLoader.pull("en"); + + expect(data).toHaveProperty("normal_key", "This should be translated"); + expect(data).not.toHaveProperty("do_not_translate"); + + const payload = { + normal_key: "Ceci devrait être traduit", + }; + + await xcodeXcstringsLoader.push("fr", payload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + expect( + writtenContent.strings.normal_key.localizations.fr.stringUnit.value, + ).toBe("Ceci devrait être traduit"); + + expect(writtenContent.strings.do_not_translate).toHaveProperty( + "shouldTranslate", + false, + ); + + expect( + writtenContent.strings.do_not_translate.localizations, + ).not.toHaveProperty("fr"); + + await xcodeXcstringsLoader.push("fr", {}); + + const secondWriteFileCall = (fs.writeFile as any).mock.calls[1]; + const secondWrittenContent = JSON.parse(secondWriteFileCall[1]); + + expect(secondWrittenContent.strings.do_not_translate).toHaveProperty( + "shouldTranslate", + false, + ); + }); + + it("should extract and restore variables during pull/push", async () => { + setupFileMocks(); + + const input = JSON.stringify({ + sourceLanguage: "en", + strings: { + message: { + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Value: %d items", + }, + }, + }, + }, + }, + }); + + mockFileOperations(input); + + const xcLoader = createBucketLoader( + "xcode-xcstrings", + "i18n/[locale].xcstrings", + { + defaultLocale: "en", + }, + ); + xcLoader.setDefaultLocale("en"); + + const data = await xcLoader.pull("en"); + + expect(data).toEqual({ message: "Value: {variable:0} items" }); + + const payload = { + message: "Valeur: {variable:0} éléments", + }; + + await xcLoader.push("fr", payload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + expect( + writtenContent.strings.message.localizations.fr.stringUnit.value, + ).toBe("Valeur: %d éléments"); + }); + + it("should extract hints from xcstrings comments", async () => { + setupFileMocks(); + + const input = JSON.stringify({ + sourceLanguage: "en", + strings: { + welcome_message: { + comment: "Greeting displayed on the main screen", + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Welcome!", + }, + }, + }, + }, + user_count: { + comment: "Number of active users - supports pluralization", + extractionState: "manual", + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 user", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d users", + }, + }, + }, + }, + }, + }, + }, + no_comment: { + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "No comment here", + }, + }, + }, + }, + }, + }); + + mockFileOperations(input); + + const xcodeXcstringsLoader = createBucketLoader( + "xcode-xcstrings", + "i18n/[locale].xcstrings", + { + defaultLocale: "en", + }, + ); + xcodeXcstringsLoader.setDefaultLocale("en"); + await xcodeXcstringsLoader.pull("en"); + + const hints = await xcodeXcstringsLoader.pullHints(); + + // Note: The output is flattened because xcode-xcstrings bucket loader goes through the flat loader + expect(hints).toEqual({ + welcome_message: ["Greeting displayed on the main screen"], + user_count: ["Number of active users - supports pluralization"], + "user_count/one": ["Number of active users - supports pluralization"], + "user_count/other": ["Number of active users - supports pluralization"], + }); + }); + + it("should handle xcstrings without comments in full-stack loader", async () => { + setupFileMocks(); + + const input = JSON.stringify({ + sourceLanguage: "en", + strings: { + simple_key: { + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Simple value", + }, + }, + }, + }, + }, + }); + + mockFileOperations(input); + + const xcodeXcstringsLoader = createBucketLoader( + "xcode-xcstrings", + "i18n/[locale].xcstrings", + { + defaultLocale: "en", + }, + ); + xcodeXcstringsLoader.setDefaultLocale("en"); + await xcodeXcstringsLoader.pull("en"); + + const hints = await xcodeXcstringsLoader.pullHints(); + + expect(hints).toEqual({}); + }); + + it("should properly filter lockedKeys from data during pull operations", async () => { + setupFileMocks(); + + const input = JSON.stringify({ + sourceLanguage: "en", + strings: { + welcome_message: { + comment: "Welcome message - should be locked", + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Hello, world!", + }, + }, + es: { + stringUnit: { + state: "translated", + value: "¡Hola, mundo!", + }, + }, + }, + }, + user_count: { + comment: "Number of users - should be translatable", + extractionState: "manual", + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 user", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d users", + }, + }, + }, + }, + }, + es: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 usuario", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d usuarios", + }, + }, + }, + }, + }, + }, + }, + api_key: { + comment: "API key - should be locked with wildcard pattern", + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "sk-1234567890abcdef", + }, + }, + }, + }, + }, + }); + + mockFileOperations(input); + + // Test with lockedKeys including both specific keys and wildcard patterns + const xcodeXcstringsLoaderWithLockedKeys = createBucketLoader( + "xcode-xcstrings", + "i18n/[locale].xcstrings", + { + defaultLocale: "en", + }, + ["welcome_message", "api*"], // lockedKeys parameter + ); + xcodeXcstringsLoaderWithLockedKeys.setDefaultLocale("en"); + + // First pull the default locale to initialize the loader + await xcodeXcstringsLoaderWithLockedKeys.pull("en"); + + // Pull data for translation - should filter out locked keys + const dataForTranslation = + await xcodeXcstringsLoaderWithLockedKeys.pull("es"); + + // Locked keys should be filtered out + expect(dataForTranslation).not.toHaveProperty("welcome_message"); + expect(dataForTranslation).not.toHaveProperty("api_key"); + + // Non-locked keys should remain + expect(dataForTranslation).toHaveProperty("user_count/one"); + expect(dataForTranslation).toHaveProperty("user_count/other"); + expect(dataForTranslation["user_count/one"]).toBe("1 usuario"); + expect(dataForTranslation["user_count/other"]).toBe( + "{variable:0} usuarios", + ); + + // Test that push operations preserve locked keys from original + + const translationPayload = { + "user_count/one": "1 usuario nuevo", + "user_count/other": "{variable:0} usuarios nuevos", + // Attempt to overwrite locked keys - should be ignored + welcome_message: "This should be ignored", + api_key: "This should also be ignored", + }; + + await xcodeXcstringsLoaderWithLockedKeys.push("es", translationPayload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + // Locked keys should preserve their original values from the input + // Since welcome_message was locked, the Spanish translation should not be overwritten + // But it might be replaced with the English value due to how the xcstrings loader works + // The important thing is that it wasn't sent for translation + expect( + writtenContent.strings.welcome_message.localizations.es, + ).toBeDefined(); + expect( + writtenContent.strings.welcome_message.localizations.es.stringUnit + .value, + ).toMatch(/Hello, world!|¡Hola, mundo!/); + expect( + writtenContent.strings.api_key.localizations.en.stringUnit.value, + ).toBe("sk-1234567890abcdef"); + // The api_key is locked, so it should preserve the original value even if we tried to overwrite it + if (writtenContent.strings.api_key.localizations.es) { + expect( + writtenContent.strings.api_key.localizations.es.stringUnit.value, + ).toBe("sk-1234567890abcdef"); + } + + // Non-locked keys should have new translations + expect( + writtenContent.strings.user_count.localizations.es.variations.plural.one + .stringUnit.value, + ).toBe("1 usuario nuevo"); + expect( + writtenContent.strings.user_count.localizations.es.variations.plural + .other.stringUnit.value, + ).toBe("%d usuarios nuevos"); + }); + }); + + describe("yaml bucket loader", () => { + it("should load yaml", async () => { + setupFileMocks(); + + const input = ` + greeting: Hello! + `.trim(); + const expectedOutput = { greeting: "Hello!" }; + + mockFileOperations(input); + + const yamlLoader = createBucketLoader("yaml", "i18n/[locale].yaml", { + defaultLocale: "en", + }); + yamlLoader.setDefaultLocale("en"); + const data = await yamlLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = `locked: Original\nhello: Hello!`; + + mockFileOperations(input); + + const yamlLoader = createBucketLoader( + "yaml", + "i18n/[locale].yaml", + { + defaultLocale: "en", + }, + ["locked"], + ); + yamlLoader.setDefaultLocale("en"); + const data = await yamlLoader.pull("en"); + + expect(data).toEqual({ hello: "Hello!" }); + }); + + it("should save yaml", async () => { + setupFileMocks(); + + const input = ` + greeting: Hello! + `.trim(); + const payload = { greeting: "¡Hola!" }; + const expectedOutput = `greeting: ¡Hola!`; + + mockFileOperations(input); + + const yamlLoader = createBucketLoader("yaml", "i18n/[locale].yaml", { + defaultLocale: "en", + }); + yamlLoader.setDefaultLocale("en"); + await yamlLoader.pull("en"); + + await yamlLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.yaml", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + describe("yaml with quoted keys and values", async () => { + it.each([ + ["double quoted values", `greeting: "Hello!"`, `greeting: "¡Hola!"`], + ["double quoted keys", `"greeting": Hello!`, `"greeting": ¡Hola!`], + [ + "double quoted keys and values", + `"greeting": "Hello!"`, + `"greeting": "¡Hola!"`, + ], + ])( + "should return correct value for %s", + async (_, input, expectedOutput) => { + const payload = { greeting: "¡Hola!" }; + + mockFileOperations(input); + + const yamlLoader = createBucketLoader("yaml", "i18n/[locale].yaml", { + defaultLocale: "en", + }); + yamlLoader.setDefaultLocale("en"); + await yamlLoader.pull("en"); + + await yamlLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.yaml", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }, + ); + }); + }); + + describe("yaml-root-key bucket loader", () => { + it("should load yaml-root-key", async () => { + setupFileMocks(); + + const input = ` + en: + greeting: Hello! + `.trim(); + const expectedOutput = { greeting: "Hello!" }; + + mockFileOperations(input); + + const yamlRootKeyLoader = createBucketLoader( + "yaml-root-key", + "i18n/[locale].yaml", + { + defaultLocale: "en", + }, + ); + yamlRootKeyLoader.setDefaultLocale("en"); + const data = await yamlRootKeyLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save yaml-root-key", async () => { + setupFileMocks(); + + const input = ` + en: + greeting: Hello! + `.trim(); + const payload = { greeting: "¡Hola!" }; + const expectedOutput = `es:\n greeting: ¡Hola!`; + + mockFileOperations(input); + + const yamlRootKeyLoader = createBucketLoader( + "yaml-root-key", + "i18n/[locale].yaml", + { + defaultLocale: "en", + }, + ); + yamlRootKeyLoader.setDefaultLocale("en"); + await yamlRootKeyLoader.pull("en"); + + await yamlRootKeyLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.yaml", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + }); + + describe("vtt bucket loader", () => { + it("should load complex vtt data", async () => { + setupFileMocks(); + + const input = ` + WEBVTT + +00:00:00.000 --> 00:00:01.000 +Hello world! + +00:00:30.000 --> 00:00:31.000 align:start line:0% +This is a subtitle + +00:01:00.000 --> 00:01:01.000 +Foo + +00:01:50.000 --> 00:01:51.000 +Bar + `.trim(); + + const expectedOutput = { + "0#0-1#": "Hello world!", + "1#30-31#": "This is a subtitle", + "2#60-61#": "Foo", + "3#110-111#": "Bar", + }; + + mockFileOperations(input); + + const vttLoader = createBucketLoader("vtt", "i18n/[locale].vtt", { + defaultLocale: "en", + }); + vttLoader.setDefaultLocale("en"); + const data = await vttLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save complex vtt data", async () => { + setupFileMocks(); + const input = ` + WEBVTT + +00:00:00.000 --> 00:00:01.000 +Hello world! + +00:00:30.000 --> 00:00:31.000 align:start line:0% +This is a subtitle + +00:01:00.000 --> 00:01:01.000 +Foo + +00:01:50.000 --> 00:01:51.000 +Bar + `.trim(); + + const payload = { + "0#0-1#": "¡Hola mundo!", + "1#30-31#": "Este es un subtítulo", + "2#60-61#": "Foo", + "3#110-111#": "Bar", + }; + + const expectedOutput = ` + WEBVTT + +00:00:00.000 --> 00:00:01.000 +¡Hola mundo! + +00:00:30.000 --> 00:00:31.000 +Este es un subtítulo + +00:01:00.000 --> 00:01:01.000 +Foo + +00:01:50.000 --> 00:01:51.000 +Bar`.trim(); + + mockFileOperations(input); + + const vttLoader = createBucketLoader("vtt", "i18n/[locale].vtt", { + defaultLocale: "en", + }); + vttLoader.setDefaultLocale("en"); + await vttLoader.pull("en"); + + await vttLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.vtt", expectedOutput, { + encoding: "utf-8", + flag: "w", + }); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + const input = ` +WEBVTT + +00:00:00.000 --> 00:00:01.000 +Hello world! + +00:00:30.000 --> 00:00:31.000 +Another cue + `.trim(); + + mockFileOperations(input); + + const vttLoader = createBucketLoader( + "vtt", + "i18n/[locale].vtt", + { + defaultLocale: "en", + }, + ["0#*"], + ); + vttLoader.setDefaultLocale("en"); + const data = await vttLoader.pull("en"); + + // First cue (index 0) locked, so only second remains + expect(Object.values(data)).toContain("Another cue"); + expect(Object.values(data)).not.toContain("Hello world!"); + }); + }); + + describe("XML bucket loader", () => { + it("should load XML data", async () => { + setupFileMocks(); + + const input = ` + Test XML + 2023-05-25 + +
    Introduction
    +
    + + Detailed text. + +
    +
    +
    `; + + const expectedOutput = { + "root/title": "Test XML", + "root/content/section/0": "Introduction", + "root/content/section/1/text": "Detailed text.", + }; + + mockFileOperations(input); + + const xmlLoader = createBucketLoader("xml", "i18n/[locale].xml", { + defaultLocale: "en", + }); + xmlLoader.setDefaultLocale("en"); + const data = await xmlLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save XML data", async () => { + setupFileMocks(); + + const input = ` + Test XML + 2023-05-25 + +
    Introduction
    +
    + + Detailed text. + +
    +
    +
    `; + + const payload = { + "root/title": "Prueba XML", + "root/date": "2023-05-25", + "root/content/section/0": "Introducción", + "root/content/section/1/text": "Detalles texto.", + }; + + let expectedOutput = ` + + Prueba XML + 2023-05-25 + +
    Introducción
    +
    + Detalles texto. +
    +
    +
    ` + .replace(/\s+/g, " ") + .replace(/>\s+<") + .trim(); + mockFileOperations(input); + const xmlLoader = createBucketLoader("xml", "i18n/[locale].xml", { + defaultLocale: "en", + }); + xmlLoader.setDefaultLocale("en"); + await xmlLoader.pull("en"); + + await xmlLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.xml", expectedOutput, { + encoding: "utf-8", + flag: "w", + }); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + const input = `OriginalHello!`; + mockFileOperations(input); + + const xmlLoader = createBucketLoader( + "xml", + "i18n/[locale].xml", + { + defaultLocale: "en", + }, + ["root/locked"], + ); + xmlLoader.setDefaultLocale("en"); + const data = await xmlLoader.pull("en"); + + expect(data).toEqual({ "root/hello": "Hello!" }); + }); + }); + + describe("srt bucket loader", () => { + it("should load srt", async () => { + setupFileMocks(); + + const input = ` +1 +00:00:00,000 --> 00:00:01,000 +Hello! + +2 +00:00:01,000 --> 00:00:02,000 +World! + `.trim(); + const expectedOutput = { + "1#00:00:00,000-00:00:01,000": "Hello!", + "2#00:00:01,000-00:00:02,000": "World!", + }; + + mockFileOperations(input); + + const srtLoader = createBucketLoader("srt", "i18n/[locale].srt", { + defaultLocale: "en", + }); + srtLoader.setDefaultLocale("en"); + const data = await srtLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save srt", async () => { + setupFileMocks(); + + const input = ` +1 +00:00:00,000 --> 00:00:01,000 +Hello! + +2 +00:00:01,000 --> 00:00:02,000 +World! + `.trim(); + + const payload = { + "1#00:00:00,000-00:00:01,000": "¡Hola!", + "2#00:00:01,000-00:00:02,000": "Mundo!", + }; + + const expectedOutput = `1 +00:00:00,000 --> 00:00:01,000 +¡Hola! + +2 +00:00:01,000 --> 00:00:02,000 +Mundo!`; + + mockFileOperations(input); + + const srtLoader = createBucketLoader("srt", "i18n/[locale].srt", { + defaultLocale: "en", + }); + srtLoader.setDefaultLocale("en"); + await srtLoader.pull("en"); + + await srtLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.srt", expectedOutput, { + encoding: "utf-8", + flag: "w", + }); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + const input = ` +1 +00:00:00,000 --> 00:00:01,000 +Hello! + +2 +00:00:01,000 --> 00:00:02,000 +World! + `.trim(); + + mockFileOperations(input); + + const srtLoader = createBucketLoader( + "srt", + "i18n/[locale].srt", + { + defaultLocale: "en", + }, + ["1#00:00:00,000-00:00:01,000"], + ); + srtLoader.setDefaultLocale("en"); + const data = await srtLoader.pull("en"); + + expect(data).toEqual({ "2#00:00:01,000-00:00:02,000": "World!" }); + }); + }); + + describe("xliff bucket loader", () => { + it("should load xliff data", async () => { + setupFileMocks(); + + const input = ` + + + + + Hello + + + + + An application to manipulate and process XLIFF documents + + + + + XLIFF Data Manager + + + + + + Group + + + + + + `.trim(); + + // Keys must be encoded (e.g. / replaced with %2F) + const expectedOutput = { + "resources%2Fnamespace1%2Fgroup%2FgroupUnits%2FgroupUnit%2Fsource": + "Group", + "resources%2Fnamespace1%2Fkey.nested%2Fsource": "XLIFF Data Manager", + "resources%2Fnamespace1%2Fkey1%2Fsource": "Hello", + "resources%2Fnamespace1%2Fkey2%2Fsource": + "An application to manipulate and process XLIFF documents", + sourceLanguage: "en-US", + }; + + mockFileOperations(input); + + const xliffLoader = createBucketLoader("xliff", "i18n/[locale].xliff", { + defaultLocale: "en", + }); + xliffLoader.setDefaultLocale("en"); + const data = await xliffLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save xliff data", async () => { + setupFileMocks(); + + const input = ` + + + + + Hello + + + + + An application to manipulate and process XLIFF documents + + + + + XLIFF Data Manager + + + + + + Group + + + + + + `.trim(); + // Keys must be encoded (e.g. / replaced with %2F) + const payload = { + "resources%2Fnamespace1%2Fgroup%2FgroupUnits%2FgroupUnit%2Fsource": + "Grupo", + "resources%2Fnamespace1%2Fkey.nested%2Fsource": + "Administrador de Datos XLIFF", + "resources%2Fnamespace1%2Fkey1%2Fsource": "Hola", + "resources%2Fnamespace1%2Fkey2%2Fsource": + "Una aplicación para manipular y procesar documentos XLIFF", + sourceLanguage: "es-ES", + }; + + const expectedOutput = ` + + + + + Hola + + + + + Una aplicación para manipular y procesar documentos XLIFF + + + + + Administrador de Datos XLIFF + + + + + + Grupo + + + + +`.trim(); + + mockFileOperations(input); + + const xliffLoader = createBucketLoader("xliff", "i18n/[locale].xlf", { + defaultLocale: "en", + }); + xliffLoader.setDefaultLocale("en"); + await xliffLoader.pull("en"); + + await xliffLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.xlf", expectedOutput, { + encoding: "utf-8", + flag: "w", + }); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = ` + + + + + Original + + + + + Hello + + + + + `.trim(); + + mockFileOperations(input); + + const xliffLoader = createBucketLoader( + "xliff", + "i18n/[locale].xliff", + { + defaultLocale: "en", + }, + ["resources%2Fnamespace1%2Flocked%2Fsource"], + ); + xliffLoader.setDefaultLocale("en"); + const data = await xliffLoader.pull("en"); + + expect(data).toEqual({ + "resources%2Fnamespace1%2Fkey1%2Fsource": "Hello", + sourceLanguage: "en-US", + }); + }); + }); + + describe("text-file", () => { + describe("when there is no target locale file", () => { + it("should preserve trailing new line based on the source locale", async () => { + setupFileMocks(); + + const input = "Hello\n"; + const expectedOutput = "Hola\n"; + + mockFileOperationsForPaths({ + "i18n/en.txt": input, + "i18n/es.txt": "", + }); + + const textFileLoader = createTextFileLoader("i18n/[locale].txt"); + textFileLoader.setDefaultLocale("en"); + await textFileLoader.pull("en"); + + await textFileLoader.push("es", "Hola"); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.txt", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should not add trailing new line based on the source locale", async () => { + setupFileMocks(); + + const input = "Hello"; + const expectedOutput = "Hola"; + + mockFileOperationsForPaths({ + "i18n/en.txt": input, + "i18n/es.txt": "", + }); + + const textFileLoader = createTextFileLoader("i18n/[locale].txt"); + textFileLoader.setDefaultLocale("en"); + await textFileLoader.pull("en"); + + await textFileLoader.push("es", "Hola"); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.txt", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + }); + + describe("when there is a target locale file", () => { + it("should preserve trailing new lines based on the target locale", async () => { + setupFileMocks(); + + const input = "Hello"; + const targetInput = "Hola\n"; + const expectedOutput = "Hola (translated)\n"; + + mockFileOperationsForPaths({ + "i18n/en.txt": input, + "i18n/es.txt": targetInput, + }); + + const textFileLoader = createTextFileLoader("i18n/[locale].txt"); + textFileLoader.setDefaultLocale("en"); + await textFileLoader.pull("en"); + + await textFileLoader.push("es", "Hola (translated)"); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.txt", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should not add trailing new line based on the target locale", async () => { + setupFileMocks(); + + const input = "Hello\n"; + const targetInput = "Hola"; + const expectedOutput = "Hola (translated)"; + + mockFileOperationsForPaths({ + "i18n/en.txt": input, + "i18n/es.txt": targetInput, + }); + + const textFileLoader = createTextFileLoader("i18n/[locale].txt"); + textFileLoader.setDefaultLocale("en"); + await textFileLoader.pull("en"); + + await textFileLoader.push("es", "Hola (translated)"); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.txt", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + }); + }); + + describe("php bucket loader", () => { + it("should load php array", async () => { + setupFileMocks(); + + const input = ` 'Submit'];`; + const expectedOutput = { "button.title": "Submit" }; + + mockFileOperations(input); + + const phpLoader = createBucketLoader("php", "i18n/[locale].php", { + defaultLocale: "en", + }); + phpLoader.setDefaultLocale("en"); + const data = await phpLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save php array", async () => { + setupFileMocks(); + + const input = ` 'Submit', + 'button.description' => ['Hello', 'Goodbye'], + 'button.index' => 1, + 'button.class' => null, +);`; + const expectedOutput = ` 'Enviar', + 'button.description' => array( + 'Hola', + 'Adiós' + ), + 'button.index' => 1, + 'button.class' => null +);`; + + mockFileOperations(input); + + const phpLoader = createBucketLoader("php", "i18n/[locale].php", { + defaultLocale: "en", + }); + phpLoader.setDefaultLocale("en"); + await phpLoader.pull("en"); + + await phpLoader.push("es", { + "button.title": "Enviar", + "button.description/0": "Hola", + "button.description/1": "Adiós", + }); + + expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.php", expectedOutput, { + encoding: "utf-8", + flag: "w", + }); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = ` 'Original',\n 'hello' => 'Hello'\n];`; + + mockFileOperations(input); + + const phpLoader = createBucketLoader( + "php", + "i18n/[locale].php", + { + defaultLocale: "en", + }, + ["locked"], + ); + phpLoader.setDefaultLocale("en"); + const data = await phpLoader.pull("en"); + + expect(data).toEqual({ hello: "Hello" }); + }); + }); + + describe("po bucket loader", () => { + it("should load po file", async () => { + setupFileMocks(); + + const input = `msgid "Hello"\nmsgstr "Hello"`; + const expectedOutput = { "Hello/singular": "Hello" }; + + mockFileOperations(input); + + const poLoader = createBucketLoader("po", "i18n/[locale].po", { + defaultLocale: "en", + }); + poLoader.setDefaultLocale("en"); + const data = await poLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save po file", async () => { + setupFileMocks(); + + const input = `msgid "Hello"\nmsgstr "Hello"`; + const expectedOutput = `msgid "Hello"\nmsgstr "Hola"`; + + mockFileOperations(input); + + const poLoader = createBucketLoader("po", "i18n/[locale].po", { + defaultLocale: "en", + }); + poLoader.setDefaultLocale("en"); + await poLoader.pull("en"); + + await poLoader.push("es", { + "Hello/singular": "Hola", + }); + + expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.po", expectedOutput, { + encoding: "utf-8", + flag: "w", + }); + }); + + it("should extract and restore variables", async () => { + setupFileMocks(); + + const input = `msgid "You have %(count)d items"\nmsgstr "You have %(count)d items"\n\n#~ msgid "I am obsolete"\n#~ msgstr "I am obsolete"`; + + const expectedPullOutput = { + "You%20have%20%25(count)d%20items/singular": + "You have {variable:0} items", + }; + + mockFileOperations(input); + + const poLoader = createBucketLoader("po", "i18n/[locale].po", { + defaultLocale: "en", + }); + poLoader.setDefaultLocale("en"); + + const data = await poLoader.pull("en"); + + expect(data).toEqual(expectedPullOutput); + + const payload = { + "You%20have%20%25(count)d%20items/singular": + "Sie haben {variable:0} Elemente", + }; + + await poLoader.push("de", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/de.po", + `msgid "You have %(count)d items"\nmsgstr "Sie haben %(count)d Elemente"`, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + const input = `# Comment\n\nmsgid "greeting"\nmsgstr "Hello"\n\nmsgid "farewell"\nmsgstr "Bye"`; + const payload = input; // same for mocking + + mockFileOperations(payload); + + const poLoader = createBucketLoader( + "po", + "i18n/[locale].po", + { + defaultLocale: "en", + }, + ["greeting/singular"], + ); + poLoader.setDefaultLocale("en"); + const data = await poLoader.pull("en"); + + // Only farewell remains (po loader returns structured values, flattened to keys) + expect(Object.keys(data)).toContain("farewell/singular"); + expect(Object.keys(data)).not.toContain("greeting/singular"); + }); + }); + + describe("vue-json bucket loader", () => { + const template = ``; + const script = ``; + + it("should load vue-json file", async () => { + setupFileMocks(); + + const input = `${template} + + +{ + "en": { + "hello": "hello world!" + } +} + + +${script}`; + const expectedOutput = { hello: "hello world!" }; + + mockFileOperations(input); + + const vueLoader = createBucketLoader("vue-json", "i18n/[locale].vue", { + defaultLocale: "en", + }); + vueLoader.setDefaultLocale("en"); + const data = await vueLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save vue-json file", async () => { + setupFileMocks(); + + const input = `${template} + + +{ + "en": { + "hello": "hello world!" + } +} + + +${script}`; + const expectedOutput = `${template} + + +{ + "en": { + "hello": "hello world!" + }, + "es": { + "hello": "hola mundo!" + } +} + + +${script}`; + + mockFileOperations(input); + + const vueLoader = createBucketLoader("vue-json", "i18n/App.vue", { + defaultLocale: "en", + }); + vueLoader.setDefaultLocale("en"); + await vueLoader.pull("en"); + + await vueLoader.push("es", { + hello: "hola mundo!", + }); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/App.vue", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should ignore vue file without i18n tag", async () => { + setupFileMocks(); + + const input = `${template} + +${script}`; + const expectedOutput = `${template} + +${script}`; + + mockFileOperations(input); + + const vueLoader = createBucketLoader("vue-json", "i18n/App.vue", { + defaultLocale: "en", + }); + vueLoader.setDefaultLocale("en"); + await vueLoader.pull("en"); + + await vueLoader.push("es", { + hello: "hola mundo!", + }); + + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/App.vue", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = `${template} + + +{ + "en": { + "locked": "Original", + "hello": "Hello!" + } +} + + +${script}`; + + mockFileOperations(input); + + const vueLoader = createBucketLoader( + "vue-json", + "i18n/[locale].vue", + { + defaultLocale: "en", + }, + ["locked"], + ); + vueLoader.setDefaultLocale("en"); + const data = await vueLoader.pull("en"); + + expect(data).toEqual({ hello: "Hello!" }); + }); + }); + describe("ejs bucket loader", () => { + it("should load ejs data", async () => { + setupFileMocks(); + + const input = ` + + + Welcome Page + + +

    Hello <%= user.name %>!

    + <% if (user.isLoggedIn) { %> +

    Welcome back to our application.

    +

    You have <%= notifications.length %> new notifications.

    + <% } else { %> +

    Please log in to continue.

    + <% } %> +
      + <% items.forEach(function(item, index) { %> +
    • Item <%= index + 1 %>: <%= item.title %>
    • + <% }); %> +
    +
    © 2024 My Company. All rights reserved.
    + +`; + + const expectedOutput = { + text_0: "Welcome Page", + text_1: "Hello", + text_2: "!", + text_3: "Welcome back to our application.", + text_4: "You have", + text_5: "new notifications.", + text_6: "Please log in to continue.", + text_7: "Item", + text_8: ":", + text_9: "© 2024 My Company. All rights reserved.", + }; + + mockFileOperations(input); + + const ejsLoader = createBucketLoader("ejs", "templates/[locale].ejs", { + defaultLocale: "en", + }); + ejsLoader.setDefaultLocale("en"); + const data = await ejsLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save ejs data", async () => { + setupFileMocks(); + + const input = ` + + + Welcome Page + + +

    Hello <%= user.name %>!

    +

    Welcome to our application.

    +
    © 2024 My Company. All rights reserved.
    + +`; + + const payload = { + text_0: "Página de Bienvenida", + text_1: "Hola", + text_2: "!", + text_3: "Bienvenido a nuestra aplicación.", + text_4: "© 2024 Mi Empresa. Todos los derechos reservados.", + }; + + const expectedOutput = ` + + + Página de Bienvenida + + +

    Hola <%= user.name %>!

    +

    Bienvenido a nuestra aplicación.

    +
    © 2024 Mi Empresa. Todos los derechos reservados.
    + +`; + + mockFileOperations(input); + + const ejsLoader = createBucketLoader("ejs", "templates/[locale].ejs", { + defaultLocale: "en", + }); + ejsLoader.setDefaultLocale("en"); + await ejsLoader.pull("en"); + + await ejsLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "templates/es.ejs", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = ` + + + Welcome Page + + +

    Hello <%= user.name %>!

    +

    Welcome to our application.

    + +`; + + mockFileOperations(input); + + const ejsLoader = createBucketLoader( + "ejs", + "templates/[locale].ejs", + { + defaultLocale: "en", + }, + ["text_0"], + ); + ejsLoader.setDefaultLocale("en"); + const data = await ejsLoader.pull("en"); + + // text_0 (title) is locked; remaining translatables present + expect(Object.keys(data)).not.toContain("text_0"); + expect(Object.keys(data)).toContain("text_1"); + }); + }); + + describe("txt bucket loader", () => { + it("should load txt", async () => { + setupFileMocks(); + + const input = `Welcome to our application! +This is a sample text file for fastlane metadata. +It contains app description that needs to be translated.`; + + const expectedOutput = { + "1": "Welcome to our application!", + "2": "This is a sample text file for fastlane metadata.", + "3": "It contains app description that needs to be translated.", + }; + + mockFileOperations(input); + + const txtLoader = createBucketLoader( + "txt", + "fastlane/metadata/[locale]/description.txt", + { + defaultLocale: "en", + }, + ); + txtLoader.setDefaultLocale("en"); + const data = await txtLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should save txt", async () => { + setupFileMocks(); + + const input = `Welcome to our application! +This is a sample text file for fastlane metadata. +It contains app description that needs to be translated.`; + + const payload = { + "1": "¡Bienvenido a nuestra aplicación!", + "2": "Este es un archivo de texto de muestra para metadatos de fastlane.", + "3": "Contiene la descripción de la aplicación que necesita ser traducida.", + }; + + const expectedOutput = `¡Bienvenido a nuestra aplicación! +Este es un archivo de texto de muestra para metadatos de fastlane. +Contiene la descripción de la aplicación que necesita ser traducida.`; + + mockFileOperations(input); + + const txtLoader = createBucketLoader( + "txt", + "fastlane/metadata/[locale]/description.txt", + { + defaultLocale: "en", + }, + ); + txtLoader.setDefaultLocale("en"); + await txtLoader.pull("en"); + + await txtLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "fastlane/metadata/es/description.txt", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should handle empty txt files", async () => { + setupFileMocks(); + + const input = ""; + const expectedOutput = {}; + + mockFileOperations(input); + + const txtLoader = createBucketLoader( + "txt", + "fastlane/metadata/[locale]/description.txt", + { + defaultLocale: "en", + }, + ); + txtLoader.setDefaultLocale("en"); + const data = await txtLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should filter out empty lines during pull", async () => { + setupFileMocks(); + + const input = `Line 1 + +Line 3`; + const expectedOutput = { + "1": "Line 1", + "3": "Line 3", + }; + + mockFileOperations(input); + + const txtLoader = createBucketLoader( + "txt", + "fastlane/metadata/[locale]/description.txt", + { + defaultLocale: "en", + }, + ); + txtLoader.setDefaultLocale("en"); + const data = await txtLoader.pull("en"); + + expect(data).toEqual(expectedOutput); + }); + + it("should reconstruct file with empty lines restored", async () => { + setupFileMocks(); + + const input = `Line 1 + +Line 3`; + + const payload = { + "1": "Línea 1", + "3": "Línea 3", + }; + + const expectedOutput = `Línea 1 + +Línea 3`; + + mockFileOperations(input); + + const txtLoader = createBucketLoader( + "txt", + "fastlane/metadata/[locale]/description.txt", + { + defaultLocale: "en", + }, + ); + txtLoader.setDefaultLocale("en"); + await txtLoader.pull("en"); + + await txtLoader.push("es", payload); + + expect(fs.writeFile).toHaveBeenCalledWith( + "fastlane/metadata/es/description.txt", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + + const input = `Secret\nHello`; + mockFileOperations(input); + + const txtLoader = createBucketLoader( + "txt", + "fastlane/metadata/[locale]/description.txt", + { defaultLocale: "en" }, + ["1"], + ); + txtLoader.setDefaultLocale("en"); + const data = await txtLoader.pull("en"); + + expect(data).toEqual({ 2: "Hello" } as any); + }); + }); + + describe("json-dictionary bucket loader", () => { + it("should add target locale keys only where source locale keys exist", async () => { + setupFileMocks(); + const input = { + title: { en: "I am a title" }, + logoPosition: "right", + pages: [ + { + name: "Welcome to my world", + elements: [ + { + title: { en: "I am an element title" }, + description: { en: "I am an element description" }, + }, + ], + }, + ], + }; + mockFileOperations(JSON.stringify(input)); + const loader = createBucketLoader( + "json-dictionary", + "i18n/[locale].json", + { + defaultLocale: "en", + }, + ); + loader.setDefaultLocale("en"); + await loader.pull("en"); + await loader.push("es", { + title: "Yo soy un titulo", + "pages/0/elements/0/title": "Yo soy un elemento de titulo", + "pages/0/elements/0/description": "Yo soy una descripcion de elemento", + }); + const expectedOutput = `{ + "title": { + "en": "I am a title", + "es": "Yo soy un titulo" + }, + "logoPosition": "right", + "pages": [ + { + "name": "Welcome to my world", + "elements": [ + { + "title": { + "en": "I am an element title", + "es": "Yo soy un elemento de titulo" + }, + "description": { + "en": "I am an element description", + "es": "Yo soy una descripcion de elemento" + } + } + ] + } + ] +}`; + expect(fs.writeFile).toHaveBeenCalledWith( + "i18n/es.json", + expectedOutput, + { encoding: "utf-8", flag: "w" }, + ); + }); + + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + const input = { + title: { en: "I am a title" }, + subtitle: { en: "Sub" }, + }; + mockFileOperations(JSON.stringify(input)); + const loader = createBucketLoader( + "json-dictionary", + "i18n/[locale].json", + { defaultLocale: "en" }, + ["title"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ subtitle: "Sub" }); + }); + }); + + describe("yaml-root-key bucket loader", () => { + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + const input = `en:\n locked: Original\n hello: Hello!`; + mockFileOperations(input); + const loader = createBucketLoader( + "yaml-root-key", + "i18n/[locale].yml", + { defaultLocale: "en" }, + ["locked"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ hello: "Hello!" }); + }); + }); + + describe("xcode-xcstrings-v2 bucket loader", () => { + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + const input = JSON.stringify({ + sourceLanguage: "en", + strings: { + locked: { + extractionState: "manual", + localizations: { + en: { stringUnit: { state: "translated", value: "Original" } }, + }, + }, + hello: { + extractionState: "manual", + localizations: { + en: { stringUnit: { state: "translated", value: "Hello" } }, + }, + }, + }, + }); + mockFileOperations(input); + + const loader = createBucketLoader( + "xcode-xcstrings-v2", + "i18n/[locale].xcstrings", + { defaultLocale: "en" }, + ["locked"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + // v2 uses semantic path keys + expect(data).toEqual({ "hello/stringUnit": "Hello" }); + }); + + it("should handle full pipeline: plural forms with variables through xcode-xcstrings-v2 → variable → flat loaders", async () => { + setupFileMocks(); + + const input = JSON.stringify({ + sourceLanguage: "en", + strings: { + item_count: { + comment: "Number of items with format specifier", + extractionState: "manual", + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 item", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d items", + }, + }, + }, + }, + }, + }, + }, + notification: { + comment: "Notification with substitutions", + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "You have %#@COUNT@", + }, + substitutions: { + COUNT: { + formatSpecifier: "d", + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "%arg notification", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%arg notifications", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + mockFileOperations(input); + + const loader = createBucketLoader( + "xcode-xcstrings-v2", + "i18n/[locale].xcstrings", + { defaultLocale: "en" }, + ); + loader.setDefaultLocale("en"); + + // Pull English - should convert plurals to ICU and extract variables + const enData = await loader.pull("en"); + + // After full pipeline: xcode-xcstrings-v2 → flat → variable + // The flat loader doesn't unpack variations/plural ICU strings into separate paths + // It keeps the ICU string intact at the variations/plural level + expect(enData).toHaveProperty("item_count/variations/plural"); + expect(typeof enData["item_count/variations/plural"]).toBe("string"); + // Variables should be extracted from the ICU string + expect(enData["item_count/variations/plural"]).toContain("{variable:0}"); + expect(enData["item_count/variations/plural"]).toContain("1 item"); + + // Notification with substitutions + expect(enData).toHaveProperty("notification/stringUnit"); + expect(enData).toHaveProperty( + "notification/substitutions/COUNT/variations/plural", + ); + expect( + typeof enData["notification/substitutions/COUNT/variations/plural"], + ).toBe("string"); + expect( + enData["notification/substitutions/COUNT/variations/plural"], + ).toContain("{variable:0}"); + + // Push Spanish translation - using ICU format at the plural level + const esPayload = { + "item_count/variations/plural": + "{count, plural, one {1 artículo} other {{variable:0} artículos}}", + "notification/stringUnit": "Tienes %#@COUNT@", + "notification/substitutions/COUNT/variations/plural": + "{count, plural, one {{variable:0} notificación} other {{variable:0} notificaciones}}", + }; + + await loader.push("es", esPayload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + // Verify Spanish plural forms are written correctly (ICU is parsed back to xcstrings format) + expect( + writtenContent.strings.item_count.localizations.es.variations.plural.one + .stringUnit.value, + ).toBe("1 artículo"); + expect( + writtenContent.strings.item_count.localizations.es.variations.plural + .other.stringUnit.value, + ).toBe("%d artículos"); + + // Verify substitutions are written correctly with format specifier restored + expect( + writtenContent.strings.notification.localizations.es.stringUnit.value, + ).toBe("Tienes %#@COUNT@"); + // Note: %arg becomes %a because variable loader only captures standard format specifiers + expect( + writtenContent.strings.notification.localizations.es.substitutions.COUNT + .variations.plural.one.stringUnit.value, + ).toBe("%a notificación"); + expect( + writtenContent.strings.notification.localizations.es.substitutions.COUNT + .variations.plural.other.stringUnit.value, + ).toBe("%a notificaciones"); + expect( + writtenContent.strings.notification.localizations.es.substitutions.COUNT + .formatSpecifier, + ).toBe("d"); + }); + + it("should handle Russian locale with locale-specific plural forms (few/many) through full pipeline", async () => { + setupFileMocks(); + + const input = JSON.stringify({ + sourceLanguage: "en", + strings: { + items: { + extractionState: "manual", + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 item", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d items", + }, + }, + }, + }, + }, + }, + }, + }, + }); + + mockFileOperations(input); + + const loader = createBucketLoader( + "xcode-xcstrings-v2", + "i18n/[locale].xcstrings", + { defaultLocale: "en" }, + ); + loader.setDefaultLocale("en"); + + await loader.pull("en"); + + // Backend returns Russian with locale-specific forms (one/few/many/other) in ICU format + const ruPayload = { + "items/variations/plural": + "{count, plural, one {1 предмет} few {{variable:0} предмета} many {{variable:0} предметов} other {{variable:0} элементов}}", + }; + + await loader.push("ru", ruPayload); + + expect(fs.writeFile).toHaveBeenCalled(); + const writeFileCall = (fs.writeFile as any).mock.calls[0]; + const writtenContent = JSON.parse(writeFileCall[1]); + + // Verify all Russian plural forms are present and variables are restored + expect( + writtenContent.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("one"); + expect( + writtenContent.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("few"); + expect( + writtenContent.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("many"); + expect( + writtenContent.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("other"); + + expect( + writtenContent.strings.items.localizations.ru.variations.plural.one + .stringUnit.value, + ).toBe("1 предмет"); + expect( + writtenContent.strings.items.localizations.ru.variations.plural.few + .stringUnit.value, + ).toBe("%d предмета"); + expect( + writtenContent.strings.items.localizations.ru.variations.plural.many + .stringUnit.value, + ).toBe("%d предметов"); + expect( + writtenContent.strings.items.localizations.ru.variations.plural.other + .stringUnit.value, + ).toBe("%d элементов"); + }); + }); + + describe("xcode-xcstrings-v2 bucket loader with locked keys containing spaces", () => { + it("should properly filter locked keys with spaces during pull operations", async () => { + setupFileMocks(); + + const input = JSON.stringify({ + sourceLanguage: "en", + strings: { + "hello world": { + comment: "Greeting - should be locked", + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Hello, world!", + }, + }, + es: { + stringUnit: { + state: "translated", + value: "¡Hola, mundo!", + }, + }, + }, + }, + "%lld unit_days": { + comment: "Days count - should be locked", + extractionState: "manual", + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "%lld day", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%lld days", + }, + }, + }, + }, + }, + es: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "%lld día", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%lld días", + }, + }, + }, + }, + }, + }, + }, + regular_key: { + comment: "Regular translatable key", + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Regular", + }, + }, + es: { + stringUnit: { + state: "translated", + value: "Regular", + }, + }, + }, + }, + }, + }); + + mockFileOperations(input); + + // Test with lockedKeys including keys with spaces and special characters + const xcodeXcstringsV2LoaderWithLockedKeys = createBucketLoader( + "xcode-xcstrings-v2", + "i18n/[locale].xcstrings", + { + defaultLocale: "en", + }, + ["hello world", "%lld unit_days"], // lockedKeys with spaces and special chars + ); + xcodeXcstringsV2LoaderWithLockedKeys.setDefaultLocale("en"); + + // First pull the default locale to initialize the loader + await xcodeXcstringsV2LoaderWithLockedKeys.pull("en"); + + // Pull data for translation - should filter out locked keys + const dataForTranslation = + await xcodeXcstringsV2LoaderWithLockedKeys.pull("es"); + + // Locked keys should be filtered out (they get flattened and encoded) + // After flat loader, keys become "hello%20world/stringUnit" and "%25lld%20unit_days/variations/plural" + expect(dataForTranslation).not.toHaveProperty("hello%20world/stringUnit"); + expect(dataForTranslation).not.toHaveProperty("%25lld%20unit_days/variations/plural"); + + // Non-locked keys should remain (flattened) + expect(dataForTranslation).toHaveProperty("regular_key/stringUnit"); + expect(dataForTranslation["regular_key/stringUnit"]).toBe("Regular"); + }); + + }); + + describe("typescript bucket loader", () => { + it("should respect locked keys (pull)", async () => { + setupFileMocks(); + const input = `export default { locked: "Original", hello: "Hello" };`; + mockFileOperations(input); + const loader = createBucketLoader( + "typescript", + "i18n/[locale].ts", + { defaultLocale: "en" }, + ["locked"], + ); + loader.setDefaultLocale("en"); + const data = await loader.pull("en"); + expect(data).toEqual({ hello: "Hello" }); + }); + }); +}); + +function setupFileMocks() { + vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + access: vi.fn(), + }, + })); + + vi.mock("path", () => ({ + default: { + resolve: vi.fn((path) => path), + dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/")), + }, + })); +} + +function mockFileOperations(input: string) { + (fs.access as any).mockImplementation(() => Promise.resolve()); + (fs.readFile as any).mockImplementation(() => Promise.resolve(input)); + (fs.writeFile as any).mockImplementation(() => Promise.resolve()); +} + +function mockFileOperationsForPaths(input: Record) { + (fs.access as any).mockImplementation((path) => + input.hasOwnProperty(path) + ? Promise.resolve() + : Promise.reject(`fs.access: ${path} not mocked`), + ); + (fs.readFile as any).mockImplementation((path) => + input.hasOwnProperty(path) + ? Promise.resolve(input[path]) + : Promise.reject(`fs.readFile: ${path} not mocked`), + ); + (fs.writeFile as any).mockImplementation((path) => + input.hasOwnProperty(path) + ? Promise.resolve() + : Promise.reject(`fs:writeFile: ${path} not mocked`), + ); +} diff --git a/packages/cli/src/cli/loaders/index.ts b/packages/cli/src/cli/loaders/index.ts new file mode 100644 index 000000000..583e7f646 --- /dev/null +++ b/packages/cli/src/cli/loaders/index.ts @@ -0,0 +1,503 @@ +import Z from "zod"; +import { bucketTypeSchema } from "@lingo.dev/_spec"; +import { composeLoaders } from "./_utils"; +import createJsonLoader from "./json"; +import createJson5Loader from "./json5"; +import createJsoncLoader from "./jsonc"; +import createFlatLoader from "./flat"; +import createTextFileLoader from "./text-file"; +import createYamlLoader from "./yaml"; +import createRootKeyLoader from "./root-key"; +import createFlutterLoader from "./flutter"; +import { ILoader } from "./_types"; +import createAilLoader from "./ail"; +import createAndroidLoader from "./android"; +import createCsvLoader from "./csv"; +import createHtmlLoader from "./html"; +import createMarkdownLoader from "./markdown"; +import createMarkdocLoader from "./markdoc"; +import createMjmlLoader from "./mjml"; +import createPropertiesLoader from "./properties"; +import createXcodeStringsLoader from "./xcode-strings"; +import createXcodeStringsdictLoader from "./xcode-stringsdict"; +import createXcodeXcstringsLoader from "./xcode-xcstrings"; +import createXcodeXcstringsV2Loader from "./xcode-xcstrings-v2"; +import createUnlocalizableLoader from "./unlocalizable"; +import { createFormatterLoader, FormatterType } from "./formatters"; +import createPoLoader from "./po"; +import createXliffLoader from "./xliff"; +import createXmlLoader from "./xml"; +import createSrtLoader from "./srt"; +import createDatoLoader from "./dato"; +import createVttLoader from "./vtt"; +import createVariableLoader from "./variable"; +import createSyncLoader from "./sync"; +import createPlutilJsonTextLoader from "./plutil-json-loader"; +import createPhpLoader from "./php"; +import createVueJsonLoader from "./vue-json"; +import createTypescriptLoader from "./typescript"; +import createInjectLocaleLoader from "./inject-locale"; +import createLockedKeysLoader from "./locked-keys"; +import createMdxFrontmatterSplitLoader from "./mdx2/frontmatter-split"; +import createMdxCodePlaceholderLoader from "./mdx2/code-placeholder"; +import createLocalizableMdxDocumentLoader from "./mdx2/localizable-document"; +import createMdxSectionsSplit2Loader from "./mdx2/sections-split-2"; +import createLockedPatternsLoader from "./locked-patterns"; +import createIgnoredKeysLoader from "./ignored-keys"; +import createEjsLoader from "./ejs"; +import createTwigLoader from "./twig"; +import createEnsureKeyOrderLoader from "./ensure-key-order"; +import createTxtLoader from "./txt"; +import createJsonKeysLoader from "./json-dictionary"; +import createCsvPerLocaleLoader from "./csv-per-locale"; + +type BucketLoaderOptions = { + returnUnlocalizedKeys?: boolean; + defaultLocale: string; + injectLocale?: string[]; + targetLocale?: string; + formatter?: FormatterType; +}; + +/** + * Helper function to encode keys for buckets that use flat loader + * The flat loader encodes keys using encodeURIComponent, so we need to + * encode locked/ignored keys patterns to match against the encoded keys + */ +function encodeKeys(keys: string[]): string[] { + return keys.map((key) => encodeURIComponent(key)); +} + +/** + * Normalizes patterns for CSV buckets (csv, csv-per-locale) + * Automatically adds "*\/" prefix if pattern doesn't contain "/" and doesn't start with "*\/" + * This allows users to write "id" instead of "*\/id" + */ + +function normalizeCsvPatterns(patterns: string[]): string[] { + return patterns.map((pattern) => { + if (pattern.includes("/") || pattern.startsWith("*/")) { + return pattern; + } + return `*/${pattern}`; + }); +} + +export default function createBucketLoader( + bucketType: Z.infer, + bucketPathPattern: string, + options: BucketLoaderOptions, + lockedKeys?: string[], + lockedPatterns?: string[], + ignoredKeys?: string[], +): ILoader> { + switch (bucketType) { + default: + throw new Error(`Unsupported bucket type: ${bucketType}`); + case "ail": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createAilLoader(), + createEnsureKeyOrderLoader(), + createFlatLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "android": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createAndroidLoader(), + createEnsureKeyOrderLoader(), + createFlatLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "csv": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createCsvLoader(), + createEnsureKeyOrderLoader(), + createFlatLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "csv-per-locale": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createCsvPerLocaleLoader(), + createEnsureKeyOrderLoader(), + createFlatLoader(), + createLockedKeysLoader(normalizeCsvPatterns(lockedKeys || [])), + createIgnoredKeysLoader(normalizeCsvPatterns(ignoredKeys || [])), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "html": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createFormatterLoader(options.formatter, "html", bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createHtmlLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "ejs": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createEjsLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "json": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createFormatterLoader(options.formatter, "json", bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createJsonLoader(), + createEnsureKeyOrderLoader(), + createFlatLoader(), + createInjectLocaleLoader(options.injectLocale), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "json5": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createJson5Loader(), + createEnsureKeyOrderLoader(), + createFlatLoader(), + createInjectLocaleLoader(options.injectLocale), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "jsonc": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createJsoncLoader(), + createEnsureKeyOrderLoader(), + createFlatLoader(), + createInjectLocaleLoader(options.injectLocale), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "markdown": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createFormatterLoader(options.formatter, "markdown", bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createMarkdownLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "markdoc": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createMarkdocLoader(), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "mdx": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createFormatterLoader(options.formatter, "mdx", bucketPathPattern), + createMdxCodePlaceholderLoader(), + createLockedPatternsLoader(lockedPatterns), + createMdxFrontmatterSplitLoader(), + createMdxSectionsSplit2Loader(), + createLocalizableMdxDocumentLoader(), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "mjml": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createFormatterLoader(options.formatter, "html", bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createMjmlLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "po": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createPoLoader(), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createVariableLoader({ type: "python" }), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "properties": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createPropertiesLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "xcode-strings": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createXcodeStringsLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "xcode-stringsdict": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createXcodeStringsdictLoader(), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "xcode-xcstrings": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createPlutilJsonTextLoader(), + createLockedPatternsLoader(lockedPatterns), + createJsonLoader(), + createXcodeXcstringsLoader(options.defaultLocale), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(encodeKeys(lockedKeys || [])), + createIgnoredKeysLoader(encodeKeys(ignoredKeys || [])), + createSyncLoader(), + createVariableLoader({ type: "ieee" }), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "xcode-xcstrings-v2": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createPlutilJsonTextLoader(), + createLockedPatternsLoader(lockedPatterns), + createJsonLoader(), + createXcodeXcstringsV2Loader(options.defaultLocale), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(encodeKeys(lockedKeys || [])), + createIgnoredKeysLoader(encodeKeys(ignoredKeys || [])), + createSyncLoader(), + createVariableLoader({ type: "ieee" }), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "yaml": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createFormatterLoader(options.formatter, "yaml", bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createYamlLoader(), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "yaml-root-key": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createFormatterLoader(options.formatter, "yaml", bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createYamlLoader(), + createRootKeyLoader(true), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "flutter": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createFormatterLoader(options.formatter, "json", bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createJsonLoader(), + createEnsureKeyOrderLoader(), + createFlutterLoader(), + createFlatLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "xliff": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createXliffLoader(), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "xml": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createXmlLoader(), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "srt": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createSrtLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "dato": + return composeLoaders( + createDatoLoader(bucketPathPattern), + createSyncLoader(), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "vtt": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createVttLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "php": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createPhpLoader(), + createSyncLoader(), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "vue-json": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createVueJsonLoader(), + createSyncLoader(), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "typescript": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createFormatterLoader( + options.formatter, + "typescript", + bucketPathPattern, + ), + createLockedPatternsLoader(lockedPatterns), + createTypescriptLoader(), + createFlatLoader(), + createEnsureKeyOrderLoader(), + createSyncLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "twig": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createTwigLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "txt": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createTxtLoader(), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + case "json-dictionary": + return composeLoaders( + createTextFileLoader(bucketPathPattern), + createFormatterLoader(options.formatter, "json", bucketPathPattern), + createLockedPatternsLoader(lockedPatterns), + createJsonLoader(), + createJsonKeysLoader(), + createEnsureKeyOrderLoader(), + createFlatLoader(), + createInjectLocaleLoader(options.injectLocale), + createLockedKeysLoader(lockedKeys || []), + createIgnoredKeysLoader(ignoredKeys || []), + createSyncLoader(), + createUnlocalizableLoader(options.returnUnlocalizedKeys), + ); + } +} diff --git a/packages/cli/src/cli/loaders/inject-locale.spec.ts b/packages/cli/src/cli/loaders/inject-locale.spec.ts new file mode 100644 index 000000000..c2b8a88ae --- /dev/null +++ b/packages/cli/src/cli/loaders/inject-locale.spec.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from "vitest"; +import createInjectLocaleLoader from "./inject-locale"; + +const locale = "en"; +const originalLocale = "en"; + +describe("createInjectLocaleLoader", () => { + describe("pull", () => { + it("should return data unchanged if injectLocaleKeys is not provided", async () => { + const loader = createInjectLocaleLoader(); + loader.setDefaultLocale(locale); + const data = { a: 1, b: 2, locale: "en" }; + const result = await loader.pull(locale, data); + expect(result).toEqual(data); + }); + + it("should omit keys where value matches locale", async () => { + const loader = createInjectLocaleLoader([ + "lang", + "meta.locale", + "obj.locale", + ]); + loader.setDefaultLocale(locale); + const data = { + lang: "en", + value: 42, + meta: { locale: "en", other: 1 }, + obj: { locale: "en" }, + }; + const result = await loader.pull(locale, data); + expect(result).toEqual({ value: 42, meta: { other: 1 }, obj: {} }); + }); + + it("should not omit keys if their value does not match locale", async () => { + const loader = createInjectLocaleLoader(["lang", "meta.locale"]); + loader.setDefaultLocale(locale); + const data = { lang: "fr", value: 42, meta: { locale: "de", other: 1 } }; + const result = await loader.pull(locale, data); + expect(result).toEqual(data); + }); + + it("should handle empty data object", async () => { + const loader = createInjectLocaleLoader(["lang"]); + loader.setDefaultLocale(locale); + const data = {}; + const result = await loader.pull(locale, data); + expect(result).toEqual({}); + }); + + it("should omit keys matching wildcard pattern where value matches locale", async () => { + const loader = createInjectLocaleLoader([ + "pages.*.locale", + "meta/*/lang", + ]); + loader.setDefaultLocale(locale); + const data = { + pages: { + foo: { locale: "en", value: 1 }, + bar: { locale: "en", value: 2 }, + baz: { locale: "fr", value: 3 }, + }, + other: 42, + "meta/a/lang": "en", + "meta/b/lang": "fr", + "meta/c/lang": "en", + }; + const result = await loader.pull(locale, data); + expect(result).toEqual({ + pages: { + foo: { value: 1 }, + bar: { value: 2 }, + baz: { locale: "fr", value: 3 }, + }, + other: 42, + "meta/b/lang": "fr", + }); + }); + }); + + describe("push", () => { + it("should return data unchanged if injectLocaleKeys is not provided", async () => { + const loader = createInjectLocaleLoader(); + loader.setDefaultLocale(locale); + await loader.pull(locale, { a: 1 }); + const data = { a: 2 }; + const result = await loader.push(locale, data); + expect(result).toEqual(data); + }); + + it("should set injectLocaleKeys to new locale if they matched originalLocale", async () => { + const loader = createInjectLocaleLoader(["lang", "meta.locale"]); + loader.setDefaultLocale(originalLocale); + const originalInput = { + lang: "en", + value: 42, + meta: { locale: "en", other: 1 }, + }; + await loader.pull(originalLocale, originalInput); + const data = { value: 99, meta: { other: 2 } }; + const result = await loader.push("fr", data); + expect(result).toEqual({ + lang: "fr", + value: 99, + meta: { locale: "fr", other: 2 }, + }); + }); + + it("should not change injectLocaleKeys if they do not match originalLocale", async () => { + const loader = createInjectLocaleLoader([ + "lang", + "meta.locale", + "obj.locale", + ]); + loader.setDefaultLocale(originalLocale); + const originalInput = { + lang: "de", + value: 42, + meta: { locale: "es", other: 1 }, + obj: { locale: "fr" }, + }; + await loader.pull(originalLocale, originalInput); + const data = { + lang: "de", + value: 99, + meta: { locale: "es", other: 2 }, + obj: { locale: "fr" }, + }; + const result = await loader.push("fr", data); + expect(result).toEqual({ + lang: "de", + value: 99, + meta: { locale: "es", other: 2 }, + obj: { locale: "fr" }, + }); + }); + + it("should update injectLocaleKeys, does not add extra keys from originalInput", async () => { + const loader = createInjectLocaleLoader([ + "lang", + "meta.locale", + "obj.locale", + ]); + loader.setDefaultLocale(originalLocale); + const originalInput = { + lang: "en", + value: 1, + meta: { locale: "en", other: 1 }, + obj: { locale: "en" }, + extra: 5, + }; + await loader.pull(originalLocale, originalInput); + const data = { value: 2, meta: { other: 2 } }; + const result = await loader.push("fr", data); + expect(result).toEqual({ + lang: "fr", + value: 2, + meta: { locale: "fr", other: 2 }, + obj: { locale: "fr" }, + }); + }); + + it("should not inject locale if it was not in originalInput", async () => { + const loader = createInjectLocaleLoader(["lang"]); + loader.setDefaultLocale(originalLocale); + const originalInput = { value: 1, meta: { other: 1 } }; + await loader.pull(originalLocale, originalInput); + const data = { value: 2, meta: { other: 2 } }; + }); + + it("should set wildcard-matched keys to new locale if they matched originalLocale", async () => { + const loader = createInjectLocaleLoader([ + "pages.*.locale", + "meta/*/lang", + ]); + loader.setDefaultLocale(originalLocale); + const originalInput = { + pages: { + foo: { locale: "en", value: 1 }, + bar: { locale: "en", value: 2 }, + baz: { locale: "fr", value: 3 }, + }, + "meta/a/lang": "en", + "meta/b/lang": "fr", + "meta/c/lang": "en", + }; + await loader.pull(originalLocale, originalInput); + const data = { + pages: { + foo: { value: 10 }, + bar: { value: 20 }, + baz: { locale: "fr", value: 30 }, + }, + "meta/b/lang": "fr", + }; + const result = await loader.push("de", data); + expect(result).toEqual({ + pages: { + foo: { locale: "de", value: 10 }, + bar: { locale: "de", value: 20 }, + baz: { locale: "fr", value: 30 }, + }, + "meta/a/lang": "de", + "meta/b/lang": "fr", + "meta/c/lang": "de", + }); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/inject-locale.ts b/packages/cli/src/cli/loaders/inject-locale.ts new file mode 100644 index 000000000..fc7f475b4 --- /dev/null +++ b/packages/cli/src/cli/loaders/inject-locale.ts @@ -0,0 +1,69 @@ +import _ from "lodash"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import { minimatch } from "minimatch"; + +export default function createInjectLocaleLoader( + injectLocaleKeys?: string[], +): ILoader, Record> { + return createLoader({ + async pull(locale, data) { + if (!injectLocaleKeys) { + return data; + } + const omitKeys = _getKeysWithLocales(data, injectLocaleKeys, locale); + const result = _.omit(data, omitKeys); + return result; + }, + async push(locale, data, originalInput, originalLocale) { + if (!injectLocaleKeys || !originalInput) { + return data; + } + + const localeKeys = _getKeysWithLocales( + originalInput, + injectLocaleKeys, + originalLocale, + ); + + localeKeys.forEach((key) => { + _.set(data, key, locale); + }); + + return data; + }, + }); +} + +function _getKeysWithLocales( + data: Record, + injectLocaleKeys: string[], + locale: string, +) { + const allKeys = _getAllKeys(data); + return allKeys.filter((key) => { + return ( + injectLocaleKeys.some((pattern) => minimatch(key, pattern)) && + _.get(data, key) === locale + ); + }); +} + +// Helper to get all deep keys in lodash path style (e.g., 'a.b.c') +function _getAllKeys(obj: Record, prefix = ""): string[] { + let keys: string[] = []; + for (const key in obj) { + if (!Object.prototype.hasOwnProperty.call(obj, key)) continue; + const path = prefix ? `${prefix}.${key}` : key; + if ( + typeof obj[key] === "object" && + obj[key] !== null && + !Array.isArray(obj[key]) + ) { + keys = keys.concat(_getAllKeys(obj[key], path)); + } else { + keys.push(path); + } + } + return keys; +} diff --git a/packages/cli/src/cli/loaders/json-dictionary.spec.ts b/packages/cli/src/cli/loaders/json-dictionary.spec.ts new file mode 100644 index 000000000..63756d99d --- /dev/null +++ b/packages/cli/src/cli/loaders/json-dictionary.spec.ts @@ -0,0 +1,134 @@ +import { forEach } from "lodash"; +import createJsonKeysLoader from "./json-dictionary"; +import { describe, it, expect } from "vitest"; + +describe("json-dictionary loader", () => { + const input = { + title: { + en: "I am a title", + }, + logoPosition: "right", + pages: [ + { + name: "Welcome to my world", + elements: [ + { + title: { + en: "I am an element title", + }, + description: { + en: "I am an element description", + }, + }, + ], + }, + ], + }; + + it("should return nested object of only translatable keys on pull", async () => { + const loader = createJsonKeysLoader(); + loader.setDefaultLocale("en"); + const pulled = await loader.pull("en", input); + expect(pulled).toEqual({ + title: "I am a title", + pages: [ + { + elements: [ + { + title: "I am an element title", + description: "I am an element description", + }, + ], + }, + ], + }); + }); + + it("should add target locale keys only where source locale keys exist on push", async () => { + const loader = createJsonKeysLoader(); + loader.setDefaultLocale("en"); + const pulled = await loader.pull("en", input); + const output = await loader.push("es", { + title: "Yo soy un titulo", + logoPosition: "right", + pages: [ + { + name: "Welcome to my world", + elements: [ + { + title: "Yo soy un elemento de titulo", + description: "Yo soy una descripcion de elemento", + }, + ], + }, + ], + }); + expect(output).toEqual({ + title: { en: "I am a title", es: "Yo soy un titulo" }, + logoPosition: "right", + pages: [ + { + name: "Welcome to my world", + elements: [ + { + title: { + en: "I am an element title", + es: "Yo soy un elemento de titulo", + }, + description: { + en: "I am an element description", + es: "Yo soy una descripcion de elemento", + }, + }, + ], + }, + ], + }); + }); + + it("should correctly order locale keys on push", async () => { + const loader = createJsonKeysLoader(); + loader.setDefaultLocale("en"); + const pulled = await loader.pull("en", { + data: { + en: "foo1", + es: "foo2", + de: "foo3", + }, + }); + const output = await loader.push("fr", { data: "foo4" }); + expect(Object.keys(output.data)).toEqual(["en", "de", "es", "fr"]); + }); + + it("should not add target locale keys to non-object values", async () => { + const loader = createJsonKeysLoader(); + loader.setDefaultLocale("en"); + const data = { foo: 123, bar: true, baz: null }; + const pulled = await loader.pull("en", data); + expect(pulled).toEqual({}); + const output = await loader.push("es", pulled); + expect(output).toEqual({ + foo: 123, + bar: true, + baz: null, + }); + }); + + it("should handle locale keys on top level", async () => { + const loader = createJsonKeysLoader(); + loader.setDefaultLocale("en"); + const pulled = await loader.pull("en", { + en: "foo1", + es: "foo2", + other: "bar", + }); + expect(pulled).toEqual({ "--content--": "foo1" }); + const output = await loader.push("fr", { "--content--": "foo3" }); + expect(output).toEqual({ + en: "foo1", + es: "foo2", + fr: "foo3", + other: "bar", + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/json-dictionary.ts b/packages/cli/src/cli/loaders/json-dictionary.ts new file mode 100644 index 000000000..cffce9405 --- /dev/null +++ b/packages/cli/src/cli/loaders/json-dictionary.ts @@ -0,0 +1,143 @@ +import _ from "lodash"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +const TOP_LEVEL_KEY = "--content--"; + +export default function createJsonDictionaryLoader(): ILoader< + Record, + Record +> { + return createLoader({ + pull: async (locale, input) => { + const result = extractTranslatables(input, locale); + + // if locale keys are on top level, only single string is extracted and returned under special key + if (typeof result === "string") { + return { [TOP_LEVEL_KEY]: result }; + } + + return result; + }, + push: async (locale, data, originalInput, originalLocale) => { + if (!originalInput) { + throw new Error("Error while parsing json-dictionary bucket"); + } + const input = _.cloneDeep(originalInput); + + // if content is under special key, locale keys are on top level + if ( + Object.keys(data).length === 1 && + Object.keys(data)[0] === TOP_LEVEL_KEY + ) { + setNestedLocale( + { [TOP_LEVEL_KEY]: input }, + [TOP_LEVEL_KEY], + locale, + data[TOP_LEVEL_KEY], + originalLocale, + ); + return input; + } + + // set the translation under the target locale key, use value from data (which is now a string) + function walk(obj: any, dataNode: any, path: string[] = []) { + if (Array.isArray(obj) && Array.isArray(dataNode)) { + obj.forEach((item, idx) => + walk(item, dataNode[idx], [...path, String(idx)]), + ); + } else if ( + obj && + typeof obj === "object" && + dataNode && + typeof dataNode === "object" && + !Array.isArray(dataNode) + ) { + for (const key of Object.keys(obj)) { + if (dataNode.hasOwnProperty(key)) { + walk(obj[key], dataNode[key], [...path, key]); + } + } + } else if ( + obj && + typeof obj === "object" && + !Array.isArray(obj) && + typeof dataNode === "string" + ) { + // dataNode is the new string for the target locale + setNestedLocale(input, path, locale, dataNode, originalLocale); + } + } + walk(input, data); + + return input; + }, + }); +} + +// extract all keys that match locale from object +function extractTranslatables(obj: any, locale: string): any { + if (Array.isArray(obj)) { + return obj.map((item) => extractTranslatables(item, locale)); + } else if (isTranslatableObject(obj, locale)) { + return obj[locale]; + } else if (obj && typeof obj === "object") { + const result: any = {}; + for (const key of Object.keys(obj)) { + const value = extractTranslatables(obj[key], locale); + if ( + (typeof value === "object" && + value !== null && + Object.keys(value).length > 0) || + (Array.isArray(value) && value.length > 0) || + (typeof value === "string" && value.length > 0) + ) { + result[key] = value; + } + } + return result; + } + return undefined; +} + +function isTranslatableObject(obj: any, locale: string): boolean { + return ( + obj && + typeof obj === "object" && + !Array.isArray(obj) && + Object.prototype.hasOwnProperty.call(obj, locale) + ); +} + +function setNestedLocale( + obj: any, + path: string[], + locale: string, + value: string, + originalLocale: string, +) { + let curr = obj; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (!(key in curr)) curr[key] = {}; + curr = curr[key]; + } + const last = path[path.length - 1]; + if (curr[last] && typeof curr[last] === "object") { + curr[last][locale] = value; + // Reorder keys: source locale first, then others alphabetically + if (originalLocale && curr[last][originalLocale]) { + const entries = Object.entries(curr[last]); + const first = entries.filter(([k]) => k === originalLocale); + const rest = entries + .filter(([k]) => k !== originalLocale) + .sort(([a], [b]) => a.localeCompare(b)); + const ordered = [...first, ...rest]; + const reordered: Record = {}; + for (const [k, v] of ordered) { + reordered[k] = v as string; + } + curr[last] = reordered; + } + } +} diff --git a/packages/cli/src/cli/loaders/json-sorting.test.ts b/packages/cli/src/cli/loaders/json-sorting.test.ts new file mode 100644 index 000000000..d8bc9d53b --- /dev/null +++ b/packages/cli/src/cli/loaders/json-sorting.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vitest"; + +import createJsonSortingLoader from "./json-sorting"; + +describe("JSON Sorting Loader", () => { + const loader = createJsonSortingLoader(); + loader.setDefaultLocale("en"); + + describe("pull", () => { + it("should return input unchanged", async () => { + const input = { b: 1, a: 2 }; + const result = await loader.pull("en", input); + expect(result).toEqual(input); + }); + }); + + describe("push", () => { + it("should sort object keys at root level", async () => { + const input = { zebra: 1, apple: 2, banana: 3 }; + const expected = { apple: 2, banana: 3, zebra: 1 }; + + const result = await loader.push("en", input); + expect(result).toEqual(expected); + }); + + it("should sort nested object keys", async () => { + const input = { + b: { + z: 1, + y: 2, + x: 3, + }, + a: 1, + }; + const expected = { + a: 1, + b: { + x: 3, + y: 2, + z: 1, + }, + }; + + const result = await loader.push("en", input); + expect(result).toEqual(expected); + }); + + it("should handle arrays by sorting their object elements", async () => { + const input = { + items: [ + { b: 1, a: 2 }, + { d: 3, c: 4 }, + ], + }; + const expected = { + items: [ + { a: 2, b: 1 }, + { c: 4, d: 3 }, + ], + }; + + const result = await loader.push("en", input); + expect(result).toEqual(expected); + }); + + it("should handle mixed nested structures", async () => { + const input = { + zebra: [ + { beta: 2, alpha: 1 }, + { delta: 4, gamma: 3 }, + ], + apple: { + two: 2, + one: 1, + }, + }; + const expected = { + apple: { + one: 1, + two: 2, + }, + zebra: [ + { alpha: 1, beta: 2 }, + { delta: 4, gamma: 3 }, + ], + }; + + const result = await loader.push("en", input); + expect(result).toEqual(expected); + }); + + it("should handle null and primitive values", async () => { + const input = { + c: null, + b: "string", + a: 123, + d: true, + }; + const expected = { + a: 123, + b: "string", + c: null, + d: true, + }; + + const result = await loader.push("en", input); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/json-sorting.ts b/packages/cli/src/cli/loaders/json-sorting.ts new file mode 100644 index 000000000..16c254f7b --- /dev/null +++ b/packages/cli/src/cli/loaders/json-sorting.ts @@ -0,0 +1,33 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createJsonSortingLoader(): ILoader< + Record, + Record +> { + return createLoader({ + async pull(locale, input) { + return input; + }, + async push(locale, data, originalInput) { + return sortObjectDeep(data); + }, + }); +} + +function sortObjectDeep(obj: any): any { + if (Array.isArray(obj)) { + return obj.map(sortObjectDeep); + } + + if (obj !== null && typeof obj === "object") { + return Object.keys(obj) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) + .reduce((result: any, key) => { + result[key] = sortObjectDeep(obj[key]); + return result; + }, {}); + } + + return obj; +} diff --git a/packages/cli/src/cli/loaders/json.ts b/packages/cli/src/cli/loaders/json.ts new file mode 100644 index 000000000..25c741487 --- /dev/null +++ b/packages/cli/src/cli/loaders/json.ts @@ -0,0 +1,25 @@ +import { jsonrepair } from "jsonrepair"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createJsonLoader(): ILoader< + string, + Record +> { + return createLoader({ + pull: async (locale, input) => { + const jsonString = input || "{}"; + let result: Record; + try { + result = JSON.parse(jsonString); + } catch (error) { + result = JSON.parse(jsonrepair(jsonString)); + } + return result; + }, + push: async (locale, data) => { + const serializedData = JSON.stringify(data, null, 2); + return serializedData; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/json5.spec.ts b/packages/cli/src/cli/loaders/json5.spec.ts new file mode 100644 index 000000000..0719b7318 --- /dev/null +++ b/packages/cli/src/cli/loaders/json5.spec.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import createJson5Loader from "./json5"; + +describe("json5 loader", () => { + it("pull should parse valid JSON5 format", async () => { + const loader = createJson5Loader(); + loader.setDefaultLocale("en"); + const json5Input = `{ + // Comments are allowed in JSON5 + hello: "Hello", + 'single-quotes': 'work too', + unquoted: 'keys work', + trailing: 'comma is ok', + }`; + + const result = await loader.pull("en", json5Input); + expect(result).toEqual({ + hello: "Hello", + "single-quotes": "work too", + unquoted: "keys work", + trailing: "comma is ok", + }); + }); + + it("pull should parse regular JSON as fallback", async () => { + const loader = createJson5Loader(); + loader.setDefaultLocale("en"); + const jsonInput = '{"hello": "Hello", "world": "World"}'; + + const result = await loader.pull("en", jsonInput); + expect(result).toEqual({ + hello: "Hello", + world: "World", + }); + }); + + it("pull should handle empty input", async () => { + const loader = createJson5Loader(); + loader.setDefaultLocale("en"); + const result = await loader.pull("en", ""); + expect(result).toEqual({}); + }); + + it("pull should handle null/undefined input", async () => { + const loader = createJson5Loader(); + loader.setDefaultLocale("en"); + const result = await loader.pull("en", null as any); + expect(result).toEqual({}); + }); + + it("pull should handle JSON5 with multiline strings", async () => { + const loader = createJson5Loader(); + loader.setDefaultLocale("en"); + const json5Input = `{ + multiline: "This is a \\ +long string that \\ +spans multiple lines" + }`; + + const result = await loader.pull("en", json5Input); + expect(result).toEqual({ + multiline: "This is a long string that spans multiple lines", + }); + }); + + it("pull should handle JSON5 with hexadecimal numbers", async () => { + const loader = createJson5Loader(); + loader.setDefaultLocale("en"); + const json5Input = `{ + hex: 0xdecaf, + positive: +123, + negative: -456 + }`; + + const result = await loader.pull("en", json5Input); + expect(result).toEqual({ + hex: 0xdecaf, + positive: 123, + negative: -456, + }); + }); + + it("pull should throw error for invalid JSON5", async () => { + const loader = createJson5Loader(); + loader.setDefaultLocale("en"); + const invalidInput = `{ + hello: "Hello" + world: "World" // missing comma + invalid: syntax + }`; + + await expect(loader.pull("en", invalidInput)).rejects.toThrow(); + }); + + it("push should serialize data to JSON5 format", async () => { + const loader = createJson5Loader(); + loader.setDefaultLocale("en"); + // Need to call pull first to initialize the loader state + await loader.pull("en", "{}"); + + const data = { + hello: "Hello", + world: "World", + nested: { + key: "value", + }, + }; + + const result = await loader.push("en", data); + const expectedOutput = `{ + hello: 'Hello', + world: 'World', + nested: { + key: 'value', + }, +}`; + + expect(result).toBe(expectedOutput); + }); + + it("push should handle empty object", async () => { + const loader = createJson5Loader(); + loader.setDefaultLocale("en"); + // Need to call pull first to initialize the loader state + await loader.pull("en", "{}"); + + const result = await loader.push("en", {}); + expect(result).toBe("{}"); + }); + + it("push should handle complex nested data", async () => { + const loader = createJson5Loader(); + loader.setDefaultLocale("en"); + // Need to call pull first to initialize the loader state + await loader.pull("en", "{}"); + + const data = { + strings: ["hello", "world"], + numbers: [1, 2, 3], + nested: { + deep: { + key: "value", + }, + }, + }; + + const result = await loader.push("en", data); + + // Parse the result back to verify it's valid JSON5 + const JSON5 = await import("json5"); + const parsed = JSON5.default.parse(result); + expect(parsed).toEqual(data); + }); +}); diff --git a/packages/cli/src/cli/loaders/json5.ts b/packages/cli/src/cli/loaders/json5.ts new file mode 100644 index 000000000..2b86f61cf --- /dev/null +++ b/packages/cli/src/cli/loaders/json5.ts @@ -0,0 +1,19 @@ +import JSON5 from "json5"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createJson5Loader(): ILoader< + string, + Record +> { + return createLoader({ + pull: async (locale, input) => { + const json5String = input || "{}"; + return JSON5.parse(json5String); + }, + push: async (locale, data) => { + const serializedData = JSON5.stringify(data, null, 2); + return serializedData; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/jsonc.spec.ts b/packages/cli/src/cli/loaders/jsonc.spec.ts new file mode 100644 index 000000000..f8745fb49 --- /dev/null +++ b/packages/cli/src/cli/loaders/jsonc.spec.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; +import createJsoncLoader from "./jsonc"; + +describe("jsonc loader", () => { + it("pull should parse valid JSONC format with comments", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + const jsoncInput = `{ + // Comments are allowed in JSONC + "hello": "Hello", + "world": "World", // Trailing comment + /* Block comment */ + "nested": { + "key": "value" + } + }`; + + const result = await loader.pull("en", jsoncInput); + expect(result).toEqual({ + hello: "Hello", + world: "World", + nested: { + key: "value", + }, + }); + }); + + it("pull should parse JSONC with trailing commas", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + const jsoncInput = `{ + "hello": "Hello", + "world": "World", + "array": [ + "item1", + "item2", + ], + }`; + + const result = await loader.pull("en", jsoncInput); + expect(result).toEqual({ + hello: "Hello", + world: "World", + array: ["item1", "item2"], + }); + }); + + it("pull should parse regular JSON as valid JSONC", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + const jsonInput = '{"hello": "Hello", "world": "World"}'; + + const result = await loader.pull("en", jsonInput); + expect(result).toEqual({ + hello: "Hello", + world: "World", + }); + }); + + it("pull should handle empty input", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + const result = await loader.pull("en", ""); + expect(result).toEqual({}); + }); + + it("pull should handle null/undefined input", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + const result = await loader.pull("en", null as any); + expect(result).toEqual({}); + }); + + it("pull should handle JSONC with mixed comment styles", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + const jsoncInput = `{ + // Line comment + "title": "Hello", + /* + * Multi-line + * block comment + */ + "description": "World", + "version": "1.0.0" // Another line comment + }`; + + const result = await loader.pull("en", jsoncInput); + expect(result).toEqual({ + title: "Hello", + description: "World", + version: "1.0.0", + }); + }); + + it("pull should throw error for invalid JSONC", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + const invalidInput = `{ + "hello": "Hello" + "world": "World" // missing comma + invalid: syntax + }`; + + await expect(loader.pull("en", invalidInput)).rejects.toThrow( + "Failed to parse JSONC", + ); + }); + + it("push should serialize data to JSON format", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + // Need to call pull first to initialize the loader state + await loader.pull("en", "{}"); + + const data = { + hello: "Hello", + world: "World", + nested: { + key: "value", + }, + }; + + const result = await loader.push("en", data); + const expectedOutput = `{ + "hello": "Hello", + "world": "World", + "nested": { + "key": "value" + } +}`; + + expect(result).toBe(expectedOutput); + }); + + it("push should handle empty object", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + // Need to call pull first to initialize the loader state + await loader.pull("en", "{}"); + + const result = await loader.push("en", {}); + expect(result).toBe("{}"); + }); + + it("push should handle complex nested data", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + // Need to call pull first to initialize the loader state + await loader.pull("en", "{}"); + + const data = { + strings: ["hello", "world"], + numbers: [1, 2, 3], + nested: { + deep: { + key: "value", + }, + }, + }; + + const result = await loader.push("en", data); + + // Parse the result back to verify it's valid JSON + const parsed = JSON.parse(result); + expect(parsed).toEqual(data); + }); + + it("pull should handle JSONC with Unicode escape sequences", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + const jsoncInput = `{ + // Unicode characters + "unicode": "\\u0048\\u0065\\u006c\\u006c\\u006f", + "emoji": "🚀" + }`; + + const result = await loader.pull("en", jsoncInput); + expect(result).toEqual({ + unicode: "Hello", + emoji: "🚀", + }); + }); + + it("pullHints should extract comments from JSONC", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + const jsoncInput = `{ + "key1": "value1", // This is a comment for key1 + "key2": "value2" /* This is a comment for key2 */, + // This is a comment for key3 + "key3": "value3", + /* This is a block comment for key4 */ + "key4": "value4", + /* + This is a comment for key5 + */ + "key5": "value5", + // This is a comment for key6 + "key6": { + // This is a comment for key7 + "key7": "value7" + } + }`; + + // First call pull to initialize the loader state + await loader.pull("en", jsoncInput); + const comments = await loader.pullHints(jsoncInput); + + expect(comments).toEqual({ + 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" }, + }, + }); + }); + + it("pullHints should extract comments from arrays", async () => { + const loader = createJsoncLoader(); + loader.setDefaultLocale("en"); + const jsoncInput = `{ + "items": [ + { + "value": "First item", + "type": "heading" + }, + { + // This is a hint for the second item + "value": "Second item", + "type": "text" + }, + { + // This is a hint for the third item + "value": "Third item", + "type": "text" + } + ] + }`; + + await loader.pull("en", jsoncInput); + const comments = await loader.pullHints(jsoncInput); + + expect(comments).toEqual({ + items: { + "1": { + value: { hint: "This is a hint for the second item" }, + }, + "2": { + value: { hint: "This is a hint for the third item" }, + }, + }, + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/jsonc.ts b/packages/cli/src/cli/loaders/jsonc.ts new file mode 100644 index 000000000..0402dd53a --- /dev/null +++ b/packages/cli/src/cli/loaders/jsonc.ts @@ -0,0 +1,357 @@ +import { parse, ParseError } from "jsonc-parser"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +interface CommentInfo { + hint?: string; + [key: string]: any; +} + +function extractCommentsFromJsonc(jsoncString: string): Record { + const lines = jsoncString.split("\n"); + const comments: Record = {}; + + // Parse to validate structure + const errors: ParseError[] = []; + const result = parse(jsoncString, errors, { + allowTrailingComma: true, + disallowComments: false, + allowEmptyContent: true, + }); + + if (errors.length > 0) { + return {}; + } + + // Track nesting context with array indices + const contextStack: Array<{ key: string; isArray: boolean; arrayIndex?: number }> = []; + let arrayObjectCount: Record = {}; // Track object count per array depth + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmedLine = line.trim(); + + if (!trimmedLine) continue; + + // Handle different comment types + const commentData = extractCommentFromLine(line, lines, i); + if (commentData.hint) { + let keyInfo; + + if (commentData.isInline) { + // For inline comments, extract key from the same line + const keyMatch = line.match(/^\s*["']?([^"':,\s]+)["']?\s*:/); + if (keyMatch) { + const key = keyMatch[1]; + const path = contextStack.map((ctx) => ctx.arrayIndex !== undefined ? String(ctx.arrayIndex) : ctx.key).filter(Boolean); + keyInfo = { key, path }; + } + } else { + // For standalone comments, find the next key + keyInfo = findAssociatedKey(lines, commentData.lineIndex, contextStack, arrayObjectCount); + } + + if (keyInfo && keyInfo.key) { + setCommentAtPath(comments, keyInfo.path, keyInfo.key, commentData.hint); + } + + // Skip processed lines for multi-line comments + i = commentData.endIndex; + continue; + } + + // Update context for object/array nesting + updateContext(contextStack, line, result, arrayObjectCount); + } + + return comments; +} + +function extractCommentFromLine( + line: string, + lines: string[], + lineIndex: number, +): { + hint: string | null; + lineIndex: number; + endIndex: number; + isInline: boolean; +} { + const trimmed = line.trim(); + + // Single-line comment (standalone) + if (trimmed.startsWith("//")) { + const hint = trimmed.replace(/^\/\/\s*/, "").trim(); + return { hint, lineIndex, endIndex: lineIndex, isInline: false }; + } + + // Block comment (standalone or multi-line) + if (trimmed.startsWith("/*")) { + const blockResult = extractBlockComment(lines, lineIndex); + return { ...blockResult, isInline: false }; + } + + // Inline comments (after JSON content) + // Handle single-line inline comments + const singleInlineMatch = line.match(/^(.+?)\s*\/\/\s*(.+)$/); + if (singleInlineMatch && singleInlineMatch[1].includes(":")) { + const hint = singleInlineMatch[2].trim(); + return { hint, lineIndex, endIndex: lineIndex, isInline: true }; + } + + // Handle block inline comments + const blockInlineMatch = line.match(/^(.+?)\s*\/\*\s*(.*?)\s*\*\/.*$/); + if (blockInlineMatch && blockInlineMatch[1].includes(":")) { + const hint = blockInlineMatch[2].trim(); + return { hint, lineIndex, endIndex: lineIndex, isInline: true }; + } + + return { hint: null, lineIndex, endIndex: lineIndex, isInline: false }; +} + +function extractBlockComment( + lines: string[], + startIndex: number, +): { hint: string | null; lineIndex: number; endIndex: number } { + const startLine = lines[startIndex]; + + // Single-line block comment + const singleMatch = startLine.match(/\/\*\s*(.*?)\s*\*\//); + if (singleMatch) { + return { + hint: singleMatch[1].trim(), + lineIndex: startIndex, + endIndex: startIndex, + }; + } + + // Multi-line block comment + const commentParts: string[] = []; + let endIndex = startIndex; + + // Extract content from first line + const firstContent = startLine.replace(/.*?\/\*\s*/, "").trim(); + if (firstContent && !firstContent.includes("*/")) { + commentParts.push(firstContent); + } + + // Process subsequent lines + for (let i = startIndex + 1; i < lines.length; i++) { + const line = lines[i]; + endIndex = i; + + if (line.includes("*/")) { + const lastContent = line + .replace(/\*\/.*$/, "") + .replace(/^\s*\*?\s*/, "") + .trim(); + if (lastContent) { + commentParts.push(lastContent); + } + break; + } else { + const content = line.replace(/^\s*\*?\s*/, "").trim(); + if (content) { + commentParts.push(content); + } + } + } + + return { + hint: commentParts.join(" ").trim() || null, + lineIndex: startIndex, + endIndex, + }; +} + +function findAssociatedKey( + lines: string[], + commentLineIndex: number, + contextStack: Array<{ key: string; isArray: boolean; arrayIndex?: number }>, + arrayObjectCount: Record, +): { key: string | null; path: string[] } { + // Look for the next key after the comment + for (let i = commentLineIndex + 1; i < lines.length; i++) { + const line = lines[i].trim(); + + if ( + !line || + line.startsWith("//") || + line.startsWith("/*") + ) { + continue; + } + + // Check if we're about to enter an array object + if (line === "{" && contextStack.length > 0) { + const parent = contextStack[contextStack.length - 1]; + if (parent.isArray) { + // Get the current array index from arrayObjectCount + const depth = contextStack.length - 1; + const arrayIndex = arrayObjectCount[depth] || 0; + + // Continue looking for the key inside this object + for (let j = i + 1; j < lines.length; j++) { + const innerLine = lines[j].trim(); + if (!innerLine || innerLine.startsWith("//") || innerLine.startsWith("/*")) continue; + + const keyMatch = innerLine.match(/^\s*["']?([^"':,\s]+)["']?\s*:/); + if (keyMatch) { + const key = keyMatch[1]; + const path = contextStack + .map((ctx) => ctx.arrayIndex !== undefined ? String(ctx.arrayIndex) : ctx.key) + .filter(Boolean); + path.push(String(arrayIndex)); + return { key, path }; + } + + if (innerLine === "}") break; + } + } + } + + if (line === "{" || line === "}") { + continue; + } + + // Extract key from line + const keyMatch = line.match(/^\s*["']?([^"':,\s]+)["']?\s*:/); + if (keyMatch) { + const key = keyMatch[1]; + const path = contextStack + .map((ctx) => ctx.arrayIndex !== undefined ? String(ctx.arrayIndex) : ctx.key) + .filter(Boolean); + return { key, path }; + } + } + + return { key: null, path: [] }; +} + +function updateContext( + contextStack: Array<{ key: string; isArray: boolean; arrayIndex?: number }>, + line: string, + parsedJson: any, + arrayObjectCount: Record, +): void { + const trimmed = line.trim(); + + // Track opening of arrays + const arrayMatch = line.match(/^\s*["']?([^"':,\s]+)["']?\s*:\s*\[/); + if (arrayMatch) { + const depth = contextStack.length; + arrayObjectCount[depth] = 0; // Initialize counter for this array + contextStack.push({ key: arrayMatch[1], isArray: true }); + return; + } + + // Track opening of objects + const openBraces = (line.match(/\{/g) || []).length; + const closeBraces = (line.match(/\}/g) || []).length; + + if (openBraces > closeBraces) { + // Extract the key that's opening this object + const keyMatch = line.match(/^\s*["']?([^"':,\s]+)["']?\s*:\s*\{/); + if (keyMatch) { + contextStack.push({ key: keyMatch[1], isArray: false }); + } else if (trimmed === '{' && contextStack.length > 0) { + // This is an object within an array + const parent = contextStack[contextStack.length - 1]; + if (parent.isArray) { + const depth = contextStack.length - 1; + const arrayIndex = arrayObjectCount[depth] || 0; + contextStack.push({ key: '', isArray: false, arrayIndex }); + arrayObjectCount[depth]++; + } + } + } + + // Track closing of objects and arrays + const openBrackets = (line.match(/\[/g) || []).length; + const closeBrackets = (line.match(/\]/g) || []).length; + + if (closeBraces > openBraces) { + for (let i = 0; i < closeBraces - openBraces; i++) { + contextStack.pop(); + } + } + + if (closeBrackets > openBrackets) { + for (let i = 0; i < closeBrackets - openBrackets; i++) { + const popped = contextStack.pop(); + if (popped?.isArray) { + const depth = contextStack.length; + delete arrayObjectCount[depth]; // Clean up counter + } + } + } +} + +function setCommentAtPath( + comments: Record, + path: string[], + key: string, + hint: string, +): void { + let current = comments; + + // Navigate to the correct nested location + for (const pathKey of path) { + if (!current[pathKey]) { + current[pathKey] = {}; + } + current = current[pathKey]; + } + + // Set the hint for the key + if (!current[key]) { + current[key] = {}; + } + + if (typeof current[key] === "object" && current[key] !== null) { + current[key].hint = hint; + } else { + current[key] = { hint }; + } +} + +export default function createJsoncLoader(): ILoader< + string, + Record +> { + return createLoader({ + pull: async (locale, input) => { + const jsoncString = input || "{}"; + const errors: ParseError[] = []; + const result = parse(jsoncString, errors, { + allowTrailingComma: true, + disallowComments: false, + allowEmptyContent: true, + }); + + if (errors.length > 0) { + throw new Error(`Failed to parse JSONC: ${errors[0].error}`); + } + + return result || {}; + }, + push: async (locale, data) => { + // JSONC parser's stringify preserves formatting but doesn't add comments + // We'll use standard JSON.stringify with pretty formatting for output + const serializedData = JSON.stringify(data, null, 2); + return serializedData; + }, + pullHints: async (input) => { + if (!input || typeof input !== "string") { + return {}; + } + + try { + return extractCommentsFromJsonc(input); + } catch (error) { + console.warn("Failed to extract comments from JSONC:", error); + return {}; + } + }, + }); +} diff --git a/packages/cli/src/cli/loaders/locked-keys.spec.ts b/packages/cli/src/cli/loaders/locked-keys.spec.ts new file mode 100644 index 000000000..dcb2e7947 --- /dev/null +++ b/packages/cli/src/cli/loaders/locked-keys.spec.ts @@ -0,0 +1,172 @@ +import { describe, it, expect } from "vitest"; +import createLockedKeysLoader from "./locked-keys"; + +describe("createLockedKeysLoader", () => { + const lockedKeys = ["common.locked", "feature.settings"]; + const locale = "en"; + + describe("pull", () => { + it("should remove locked keys from the data", async () => { + const loader = createLockedKeysLoader(lockedKeys); + loader.setDefaultLocale(locale); + const data = { + "common.title": "Title", + "common.locked.label": "Locked Label", + "feature.settings.title": "Settings", + "feature.enabled": true, + }; + + const result = await loader.pull(locale, data); + + expect(result).toEqual({ + "common.title": "Title", + "feature.enabled": true, + }); + }); + + it("should remove locked keys with wildcard from the data", async () => { + const loader = createLockedKeysLoader(["settings/*/locked"]); + loader.setDefaultLocale(locale); + const data = { + "common.title": "Title", + "settings/default/locked": "Foo", + "settings/default/notifications": "Enabled", + "settings/global/locked": "Bar", + "settings/global/notifications": "Disabled", + "settings/user/locked": "Baz", + "settings/user/notifications": "Enabled", + }; + + const result = await loader.pull(locale, data); + + expect(result).toEqual({ + "common.title": "Title", + "settings/default/notifications": "Enabled", + "settings/global/notifications": "Disabled", + "settings/user/notifications": "Enabled", + }); + }); + + it("should return the same data if no keys are locked", async () => { + const loader = createLockedKeysLoader([]); + loader.setDefaultLocale(locale); + const data = { + "common.title": "Title", + "feature.enabled": true, + }; + + const result = await loader.pull(locale, data); + + expect(result).toEqual(data); + }); + + it("should handle empty data object", async () => { + const loader = createLockedKeysLoader(lockedKeys); + loader.setDefaultLocale(locale); + const data = {}; + + const result = await loader.pull(locale, data); + + expect(result).toEqual({}); + }); + + it("should not remove keys that partially match but do not start with a locked key", async () => { + const loader = createLockedKeysLoader(["locked"]); + loader.setDefaultLocale(locale); + const data = { + "locked.a": 1, + "is.locked.b": 1, + "notlocked.c": 1, + }; + + const result = await loader.pull("en", data); + + expect(result).toEqual({ + "is.locked.b": 1, + "notlocked.c": 1, + }); + }); + }); + + describe("push", () => { + const originalInput = { + "common.title": "Original Title", + "common.locked.label": "Original Locked Label", + "feature.settings.title": "Original Settings", + "feature.enabled": false, + }; + + it("should merge new data with original, preserving locked keys from original", async () => { + const loader = createLockedKeysLoader(lockedKeys); + loader.setDefaultLocale(locale); + await loader.pull(locale, originalInput); + const data = { + "common.title": "New Title", + "common.locked.label": "New Locked Label", + "feature.enabled": true, + "new.feature": "hello", + }; + + const result = await loader.push(locale, data); + + expect(result).toEqual({ + "common.title": "New Title", + "common.locked.label": "Original Locked Label", + "feature.settings.title": "Original Settings", + "feature.enabled": true, + "new.feature": "hello", + }); + }); + + it("should merge new data with original, preserving wildcard locked keys from original", async () => { + const loader = createLockedKeysLoader(["settings/*/locked"]); + loader.setDefaultLocale(locale); + + const originalInputWithWildcardKeys = { + "common.title": "Some Title", + "settings/default/locked": "Foo", + "settings/default/notifications": "Enabled", + "settings/global/locked": "Bar", + "settings/global/notifications": "Disabled", + "settings/user/locked": "Baz", + "settings/user/notifications": "Enabled", + }; + await loader.pull(locale, originalInputWithWildcardKeys); + const data = { + "common.title": "Better Title", + "settings/default/notifications": "Maybe", + "settings/global/notifications": "Perhaps", + "settings/user/notifications": "Unknown", + }; + + const result = await loader.push(locale, data); + + expect(result).toEqual({ + "common.title": "Better Title", + "settings/default/locked": "Foo", + "settings/default/notifications": "Maybe", + "settings/global/locked": "Bar", + "settings/global/notifications": "Perhaps", + "settings/user/locked": "Baz", + "settings/user/notifications": "Unknown", + }); + }); + + it("should handle undefined original input", async () => { + const loader = createLockedKeysLoader(lockedKeys); + loader.setDefaultLocale(locale); + await loader.pull(locale, undefined as any); + const data = { + "common.title": "New Title", + "new.feature": "hello", + }; + + const result = await loader.push(locale, data); + + expect(result).toEqual({ + "common.title": "New Title", + "new.feature": "hello", + }); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/locked-keys.ts b/packages/cli/src/cli/loaders/locked-keys.ts new file mode 100644 index 000000000..bbece680a --- /dev/null +++ b/packages/cli/src/cli/loaders/locked-keys.ts @@ -0,0 +1,24 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import _ from "lodash"; +import { matchesKeyPattern } from "../utils/key-matching"; + +export default function createLockedKeysLoader( + lockedKeys: string[], +): ILoader, Record> { + return createLoader({ + pull: async (locale, data) => { + return _.pickBy( + data, + (value, key) => !matchesKeyPattern(key, lockedKeys), + ); + }, + push: async (locale, data, originalInput) => { + const lockedSubObject = _.chain(originalInput) + .pickBy((value, key) => matchesKeyPattern(key, lockedKeys)) + .value(); + + return _.merge({}, data, lockedSubObject); + }, + }); +} diff --git a/packages/cli/src/cli/loaders/locked-patterns.spec.ts b/packages/cli/src/cli/loaders/locked-patterns.spec.ts new file mode 100644 index 000000000..eb2710f06 --- /dev/null +++ b/packages/cli/src/cli/loaders/locked-patterns.spec.ts @@ -0,0 +1,307 @@ +import { describe, it, expect } from "vitest"; +import createLockedPatternsLoader from "./locked-patterns"; +import dedent from "dedent"; + +describe("Locked Patterns Loader", () => { + describe("Basic functionality", () => { + it("should do nothing when no patterns are provided", async () => { + const loader = createLockedPatternsLoader(); + loader.setDefaultLocale("en"); + + const md = dedent` + # Title + + Some content. + + !params + + !! parameter_name + + !type string + `; + + const result = await loader.pull("en", md); + + const placeholderRegex = /\{\/\* LOCKED_PATTERN_[0-9a-f]+\s*\*\/\}/g; + const placeholders = result.match(placeholderRegex) || []; + expect(placeholders.length).toBe(0); // No patterns should be replaced + + expect(result).toBe(md); + + const pushed = await loader.push("es", result); + expect(pushed).toBe(md); + }); + + it("should preserve content matching patterns", async () => { + const loader = createLockedPatternsLoader([ + "!params", + "!! [\\w_]+", + "!type [\\w<>\\[\\]\"',]+", + ]); + loader.setDefaultLocale("en"); + + const md = dedent` + # Title + + Some content. + + !params + + !! parameter_name + + !type string + `; + + const result = await loader.pull("en", md); + + const placeholderRegex = /\{\/\* LOCKED_PATTERN_[0-9a-f]+\s*\*\/\}/g; + const placeholders = result.match(placeholderRegex) || []; + expect(placeholders.length).toBe(3); // Three patterns should be replaced + + const sanitizedContent = result.replace( + placeholderRegex, + "{/* PLACEHOLDER */}", + ); + + const expectedSanitized = dedent` + # Title + + Some content. + + {/* PLACEHOLDER */} + + {/* PLACEHOLDER */} + + {/* PLACEHOLDER */} + `; + + expect(sanitizedContent).toBe(expectedSanitized); + + const translated = result + .replace("# Title", "# Título") + .replace("Some content.", "Algún contenido."); + + const pushed = await loader.push("es", translated); + + const expectedPushed = dedent` + # Título + + Algún contenido. + + !params + + !! parameter_name + + !type string + `; + + expect(pushed).toBe(expectedPushed); + }); + }); + + describe("Real-world patterns", () => { + it("should handle !hover syntax in code blocks", async () => { + const loader = createLockedPatternsLoader([ + "// !hover[\\s\\S]*?(?=\\n|$)", + "// !hover\\([\\d:]+\\)[\\s\\S]*?(?=\\n|$)", + ]); + loader.setDefaultLocale("en"); + + const md = dedent` + \`\`\`js + const x = 1; + const pubkey = "vines1vzrYbzLMRdu58ou5XTby4qAqVRLmqo36NKPTg"; + \`\`\` + `; + + const result = await loader.pull("en", md); + + const placeholderRegex = /\{\/\* LOCKED_PATTERN_[0-9a-f]+\s*\*\/\}/g; + const placeholders = result.match(placeholderRegex) || []; + expect(placeholders.length).toBe(0); // No patterns should be replaced + + const pushed = await loader.push("es", result); + + expect(pushed).toBe(md); + }); + + it("should handle !! parameter headings", async () => { + const loader = createLockedPatternsLoader(["!! [\\w_]+"]); + loader.setDefaultLocale("en"); + + const md = dedent` + # Parameters + + !! pubkey + + The public key of the account to query. + + !! encoding + + Encoding format for the returned Account data. + `; + + const result = await loader.pull("en", md); + + const placeholderRegex = /\{\/\* LOCKED_PATTERN_[0-9a-f]+\s*\*\/\}/g; + const placeholders = result.match(placeholderRegex) || []; + expect(placeholders.length).toBe(2); // Two patterns should be replaced + + const sanitizedContent = result.replace( + placeholderRegex, + "{/* PLACEHOLDER */}", + ); + + const expectedSanitized = dedent` + # Parameters + + {/* PLACEHOLDER */} + + The public key of the account to query. + + {/* PLACEHOLDER */} + + Encoding format for the returned Account data. + `; + + expect(sanitizedContent).toBe(expectedSanitized); + + const translated = result + .replace("# Parameters", "# Parámetros") + .replace( + "The public key of the account to query.", + "La clave pública de la cuenta a consultar.", + ) + .replace( + "Encoding format for the returned Account data.", + "Formato de codificación para los datos de la cuenta devueltos.", + ); + + const pushed = await loader.push("es", translated); + + const expectedPushed = dedent` + # Parámetros + + !! pubkey + + La clave pública de la cuenta a consultar. + + !! encoding + + Formato de codificación para los datos de la cuenta devueltos. + `; + + expect(pushed).toBe(expectedPushed); + }); + + it("should handle !type, !required, and !values declarations", async () => { + const loader = createLockedPatternsLoader([ + "!! [\\w_]+", + "!type [\\w<>\\[\\]\"',]+", + "!required", + "!values [\\s\\S]*?(?=\\n\\n|$)", + ]); + loader.setDefaultLocale("en"); + + const md = dedent` + !! pubkey + + !type string + !required + + The public key of the account to query. + + !! encoding + + !type string + !values "base58" (default), "base64", "jsonParsed" + + Encoding format for the returned Account data. + `; + + const result = await loader.pull("en", md); + + const placeholderRegex = /\{\/\* LOCKED_PATTERN_[0-9a-f]+\s*\*\/\}/g; + const placeholders = result.match(placeholderRegex) || []; + expect(placeholders.length).toBe(6); // Six patterns should be replaced + + const sanitizedContent = result.replace( + placeholderRegex, + "{/* PLACEHOLDER */}", + ); + + const expectedSanitized = dedent` + {/* PLACEHOLDER */} + + {/* PLACEHOLDER */} + {/* PLACEHOLDER */} + + The public key of the account to query. + + {/* PLACEHOLDER */} + + {/* PLACEHOLDER */} + {/* PLACEHOLDER */} + + Encoding format for the returned Account data. + `; + + expect(sanitizedContent).toBe(expectedSanitized); + + const translated = result + .replace( + "The public key of the account to query.", + "La clave pública de la cuenta a consultar.", + ) + .replace( + "Encoding format for the returned Account data.", + "Formato de codificación para los datos de la cuenta devueltos.", + ); + + const pushed = await loader.push("es", translated); + + const expectedPushed = dedent` + !! pubkey + + !type string + !required + + La clave pública de la cuenta a consultar. + + !! encoding + + !type string + !values "base58" (default), "base64", "jsonParsed" + + Formato de codificación para los datos de la cuenta devueltos. + `; + + expect(pushed).toBe(expectedPushed); + }); + }); + + describe("placeholder format edge cases (regression)", () => { + it("should handle placeholder at content start without triggering gray-matter", async () => { + const loader = createLockedPatternsLoader(["^!! [\\w_]+"]); + loader.setDefaultLocale("en"); + + const md = dedent` + !! parameter_name + + Some description text. + `; + + const pulled = await loader.pull("en", md); + + expect(pulled).toMatch(/^\{\/\* LOCKED_PATTERN/); + + const matter = require('gray-matter'); + const mdxDocument = matter.stringify(pulled, { title: 'Test' }); + + expect(mdxDocument).toContain('{/*'); + + const restored = await loader.push("en", pulled); + expect(restored).toBe(md); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/locked-patterns.ts b/packages/cli/src/cli/loaders/locked-patterns.ts new file mode 100644 index 000000000..e6febfa54 --- /dev/null +++ b/packages/cli/src/cli/loaders/locked-patterns.ts @@ -0,0 +1,105 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import { md5 } from "../utils/md5"; + +/** + * Extracts content matching regex patterns and replaces it with placeholders. + * Returns the transformed content and a mapping of placeholders to original content. + */ +function extractLockedPatterns( + content: string, + patterns: string[] = [], +): { + content: string; + lockedPlaceholders: Record; +} { + let finalContent = content; + const lockedPlaceholders: Record = {}; + + if (!patterns || patterns.length === 0) { + return { content: finalContent, lockedPlaceholders }; + } + + for (const patternStr of patterns) { + try { + const pattern = new RegExp(patternStr, "gm"); + const matches = Array.from(finalContent.matchAll(pattern)); + + for (const match of matches) { + const matchedText = match[0]; + const matchHash = md5(matchedText); + const placeholder = `{/* LOCKED_PATTERN_${matchHash} */}`; + + lockedPlaceholders[placeholder] = matchedText; + finalContent = finalContent.replace(matchedText, placeholder); + } + } catch (error) { + console.warn(`Invalid regex pattern: ${patternStr}`); + } + } + + return { + content: finalContent, + lockedPlaceholders, + }; +} + +/** + * Creates a loader that preserves content matching regex patterns during translation. + * + * This loader extracts content matching the provided regex patterns and replaces it + * with placeholders before translation. After translation, the placeholders are + * restored with the original content. + * + * This is useful for preserving technical terms, code snippets, URLs, template + * variables, and other non-translatable content within translatable files. + * + * Works with any string-based format (JSON, YAML, XML, Markdown, HTML, etc.). + * Note: For structured formats (JSON, XML, YAML), ensure patterns only match + * content within values, not structural syntax, to avoid breaking parsing. + * + * @param defaultPatterns - Array of regex pattern strings to match and preserve + * @returns A loader that handles pattern locking/unlocking + */ +export default function createLockedPatternsLoader( + defaultPatterns?: string[], +): ILoader { + return createLoader({ + async pull(locale, input, initCtx, originalLocale) { + const patterns = defaultPatterns || []; + + const { content } = extractLockedPatterns(input || "", patterns); + + return content; + }, + + async push( + locale, + data, + originalInput, + originalLocale, + pullInput, + pullOutput, + ) { + const patterns = defaultPatterns || []; + + if (!pullInput) { + return data; + } + + const { lockedPlaceholders } = extractLockedPatterns( + pullInput as string, + patterns, + ); + + let result = data; + for (const [placeholder, original] of Object.entries( + lockedPlaceholders, + )) { + result = result.replaceAll(placeholder, original); + } + + return result; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/markdoc.spec.ts b/packages/cli/src/cli/loaders/markdoc.spec.ts new file mode 100644 index 000000000..e40eab772 --- /dev/null +++ b/packages/cli/src/cli/loaders/markdoc.spec.ts @@ -0,0 +1,571 @@ +import { describe, it, expect } from "vitest"; +import createMarkdocLoader from "./markdoc"; + +describe("markdoc loader", () => { + describe("block-level tag", () => { + it("should extract text content from block-level tag", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% foo %} +This is content inside of a block-level tag +{% /foo %}`; + + const output = await loader.pull("en", input); + + // Should extract the text content with semantic keys + const contents = Object.values(output); + + expect(contents).toContain("This is content inside of a block-level tag"); + }); + + it("should preserve tag structure on push", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% foo %} +This is content inside of a block-level tag +{% /foo %}`; + + const pulled = await loader.pull("en", input); + const pushed = await loader.push("en", pulled); + + expect(pushed.trim()).toBe(input.trim()); + }); + + it("should apply translations on push", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% example %} +This paragraph is nested within a Markdoc tag. +{% /example %}`; + + const pulled = await loader.pull("en", input); + + // Modify the content using semantic keys + const translated = { ...pulled }; + const contentKey = Object.keys(translated).find( + (k) => + translated[k] === "This paragraph is nested within a Markdoc tag.", + ); + if (contentKey) { + translated[contentKey] = + "Este párrafo está anidado dentro de una etiqueta Markdoc."; + } + + const pushed = await loader.push("es", translated); + + expect(pushed).toContain( + "Este párrafo está anidado dentro de una etiqueta Markdoc.", + ); + expect(pushed).toContain("{% example %}"); + expect(pushed).toContain("{% /example %}"); + }); + }); + + describe("self-closing tag", () => { + it("should handle self-closing tag with no content", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% example /%}`; + + const output = await loader.pull("en", input); + + // Should have the tag but no text content + expect(output).toBeDefined(); + }); + + it("should preserve self-closing tag on push", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% example /%}`; + + const pulled = await loader.pull("en", input); + const pushed = await loader.push("en", pulled); + + expect(pushed.trim()).toBe(input.trim()); + }); + }); + + describe("inline tag", () => { + it("should extract text from inline tag", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `This is a paragraph {% foo %}that contains a tag{% /foo %}`; + + const output = await loader.pull("en", input); + + // Should extract both text segments + const contents = Object.values(output); + + expect(contents).toContain("This is a paragraph "); + expect(contents).toContain("that contains a tag"); + }); + + it("should preserve inline tag structure on push", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `This is a paragraph {% foo %}that contains a tag{% /foo %}`; + + const pulled = await loader.pull("en", input); + const pushed = await loader.push("en", pulled); + + expect(pushed.trim()).toBe(input.trim()); + }); + + it("should apply translations to inline tag content", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `This is a paragraph {% foo %}that contains a tag{% /foo %}`; + + const pulled = await loader.pull("en", input); + + // Translate both text segments + const translated = { ...pulled }; + Object.keys(translated).forEach((key) => { + if (translated[key] === "This is a paragraph ") { + translated[key] = "Este es un párrafo "; + } else if (translated[key] === "that contains a tag") { + translated[key] = "que contiene una etiqueta"; + } + }); + + const pushed = await loader.push("es", translated); + + expect(pushed).toContain("Este es un párrafo"); + expect(pushed).toContain("que contiene una etiqueta"); + expect(pushed).toContain("{% foo %}"); + expect(pushed).toContain("{% /foo %}"); + }); + }); + + describe("inline tag only content", () => { + it("should handle inline tag as sole paragraph content", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% foo %}This is content inside of an inline tag{% /foo %}`; + + const output = await loader.pull("en", input); + + const contents = Object.values(output); + + expect(contents).toContain("This is content inside of an inline tag"); + }); + + it("should preserve inline-only tag structure on push", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% foo %}This is content inside of an inline tag{% /foo %}`; + + const pulled = await loader.pull("en", input); + const pushed = await loader.push("en", pulled); + + expect(pushed.trim()).toBe(input.trim()); + }); + }); + + describe("mixed content", () => { + it("should handle document with multiple tags and text", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `# Heading + +This is a paragraph. + +{% note %} +Important information here. +{% /note %} + +Another paragraph with {% inline %}inline content{% /inline %}. + +{% self-closing /%}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + // Verify structure is preserved + expect(pushed).toContain("# Heading"); + expect(pushed).toContain("{% note %}"); + expect(pushed).toContain("{% /note %}"); + expect(pushed).toContain("{% inline %}"); + expect(pushed).toContain("{% /inline %}"); + expect(pushed).toContain("{% self-closing /%}"); + }); + }); + + describe("nested tags", () => { + it("should handle nested tags", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% outer %} +Outer content +{% inner %} +Inner content +{% /inner %} +More outer content +{% /outer %}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed).toContain("{% outer %}"); + expect(pushed).toContain("{% inner %}"); + expect(pushed).toContain("{% /inner %}"); + expect(pushed).toContain("{% /outer %}"); + }); + }); + + describe("interpolation", () => { + it("should preserve variable interpolation", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `Hello {% $username %}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed.trim()).toBe(input.trim()); + }); + + it("should preserve function interpolation", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `Result: {% calculateValue() %}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed.trim()).toBe(input.trim()); + }); + + it("should preserve interpolation in middle of text", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `This is {% $var %} some text.`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed.trim()).toBe(input.trim()); + }); + + it("should translate text around interpolation", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `Hello {% $username %}, welcome!`; + + const output = await loader.pull("en", input); + + // Should extract text segments but not interpolation + const textContents = Object.values(output).filter( + (v) => typeof v === "string", + ); + + expect(textContents).toContain("Hello "); + expect(textContents).toContain(", welcome!"); + + // Translate the text segments + const translated = { ...output }; + Object.keys(translated).forEach((key) => { + if (translated[key] === "Hello ") { + translated[key] = "Hola "; + } else if (translated[key] === ", welcome!") { + translated[key] = ", ¡bienvenido!"; + } + }); + + const pushed = await loader.push("es", translated); + + expect(pushed).toContain("Hola"); + expect(pushed).toContain("¡bienvenido!"); + expect(pushed).toContain("{% $username %}"); + }); + + it("should handle interpolation in tags", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% callout %} +The value is {% $value %} today. +{% /callout %}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed).toContain("{% callout %}"); + expect(pushed).toContain("{% $value %}"); + expect(pushed).toContain("{% /callout %}"); + }); + }); + + describe("annotations", () => { + it("should preserve annotations with shorthand class attribute", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `# Heading {% .example %}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed).toContain("# Heading"); + expect(pushed).toContain("{% .example %}"); + }); + + it("should preserve annotations with shorthand id attribute", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `# Heading {% #main-title %}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed).toContain("# Heading"); + expect(pushed).toContain("{% #main-title %}"); + }); + + it("should preserve annotations with multiple shorthand attributes", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `# Heading {% #foo .bar .baz %}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed).toContain("# Heading"); + expect(pushed).toContain("{% #foo .bar .baz %}"); + }); + + it("should translate heading text with annotations", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `# Welcome {% .hero-title %}`; + + const output = await loader.pull("en", input); + + // Find and translate the heading text (note: has trailing space) + const translated = { ...output }; + Object.keys(translated).forEach((key) => { + if (translated[key] === "Welcome ") { + translated[key] = "Bienvenido "; + } + }); + + const pushed = await loader.push("es", translated); + + expect(pushed).toContain("Bienvenido"); + expect(pushed).toContain("{% .hero-title %}"); + }); + }); + + describe("tag attributes", () => { + it("should preserve tags with full attributes", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% callout type="note" %} +This is important information. +{% /callout %}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed).toContain('{% callout type="note" %}'); + expect(pushed).toContain("{% /callout %}"); + }); + + it("should preserve tags with multiple attributes", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% image src="logo.png" alt="Company Logo" width="200" /%}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed.trim()).toBe(input.trim()); + }); + + it("should preserve tags with array attributes", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% chart data=[1, 2, 3] /%}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed.trim()).toBe(input.trim()); + }); + + it("should translate content in tags with attributes", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% callout type="warning" %} +Please read carefully. +{% /callout %}`; + + const output = await loader.pull("en", input); + + // Translate the content + const translated = { ...output }; + Object.keys(translated).forEach((key) => { + if (translated[key] === "Please read carefully.") { + translated[key] = "Por favor lea con atención."; + } + }); + + const pushed = await loader.push("es", translated); + + expect(pushed).toContain("Por favor lea con atención."); + expect(pushed).toContain('{% callout type="warning" %}'); + expect(pushed).toContain("{% /callout %}"); + }); + }); + + describe("primary attributes", () => { + it("should preserve tags with primary attribute", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% if $showContent %} +Content is visible. +{% /if %}`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed).toContain("{% if $showContent %}"); + expect(pushed).toContain("{% /if %}"); + }); + + it("should translate content in tags with primary attribute", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `{% if $showContent %} +Content is visible. +{% /if %}`; + + const output = await loader.pull("en", input); + + // Translate the content + const translated = { ...output }; + Object.keys(translated).forEach((key) => { + if (translated[key] === "Content is visible.") { + translated[key] = "El contenido es visible."; + } + }); + + const pushed = await loader.push("es", translated); + + expect(pushed).toContain("El contenido es visible."); + expect(pushed).toContain("{% if $showContent %}"); + }); + }); + + describe("frontmatter", () => { + it("should extract frontmatter attributes", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `--- +title: My Document +description: A sample document +author: John Doe +--- + +# Heading + +Content here.`; + + const output = await loader.pull("en", input); + + expect(output["fm-attr-title"]).toBe("My Document"); + expect(output["fm-attr-description"]).toBe("A sample document"); + expect(output["fm-attr-author"]).toBe("John Doe"); + }); + + it("should preserve frontmatter on push", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `--- +title: My Document +description: A sample document +--- + +# Heading + +Content here.`; + + const pulled = await loader.pull("en", input); + const pushed = await loader.push("en", pulled); + + expect(pushed).toContain("title: My Document"); + expect(pushed).toContain("description: A sample document"); + expect(pushed).toContain("# Heading"); + expect(pushed).toContain("Content here."); + }); + + it("should translate frontmatter attributes", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `--- +title: Welcome +description: This is a guide +--- + +# Content + +Some text.`; + + const pulled = await loader.pull("en", input); + + // Translate frontmatter + const translated = { ...pulled }; + translated["fm-attr-title"] = "Bienvenido"; + translated["fm-attr-description"] = "Esta es una guía"; + + const pushed = await loader.push("es", translated); + + expect(pushed).toContain("title: Bienvenido"); + expect(pushed).toContain("description: Esta es una guía"); + }); + + it("should handle documents without frontmatter", async () => { + const loader = createMarkdocLoader(); + loader.setDefaultLocale("en"); + + const input = `# Heading + +Content without frontmatter.`; + + const output = await loader.pull("en", input); + const pushed = await loader.push("en", output); + + expect(pushed).not.toContain("---"); + expect(pushed).toContain("# Heading"); + expect(pushed).toContain("Content without frontmatter."); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/markdoc.ts b/packages/cli/src/cli/loaders/markdoc.ts new file mode 100644 index 000000000..596fa97ee --- /dev/null +++ b/packages/cli/src/cli/loaders/markdoc.ts @@ -0,0 +1,239 @@ +import Markdoc from "@markdoc/markdoc"; +import YAML from "yaml"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +type MarkdocNode = { + $$mdtype?: string; + type: string; + tag?: string; + attributes?: Record; + children?: MarkdocNode[]; + [key: string]: any; +}; + +type NodeCounter = { + [nodeType: string]: number; +}; + +type NodePathMap = { + [semanticKey: string]: string; // maps semantic key to AST path +}; + +const FM_ATTR_PREFIX = "fm-attr-"; + +export default function createMarkdocLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, input) { + const ast = Markdoc.parse(input) as unknown as MarkdocNode; + const result: Record = {}; + const counters: NodeCounter = {}; + + // Traverse the AST and extract text content with semantic keys + traverseAndExtract(ast, "", result, counters); + + // Extract frontmatter if present + if (ast.attributes?.frontmatter) { + const frontmatter = YAML.parse(ast.attributes.frontmatter); + Object.entries(frontmatter).forEach(([key, value]) => { + if (typeof value === "string") { + result[`${FM_ATTR_PREFIX}${key}`] = value; + } + }); + } + + return result; + }, + + async push(locale, data, originalInput) { + if (!originalInput) { + throw new Error("Original input is required for push"); + } + + const ast = Markdoc.parse(originalInput) as unknown as MarkdocNode; + const counters: NodeCounter = {}; + const pathMap: NodePathMap = {}; + + // Build path map from semantic keys to AST paths + buildPathMap(ast, "", counters, pathMap); + + // Extract frontmatter from data + const frontmatterEntries = Object.entries(data) + .filter(([key]) => key.startsWith(FM_ATTR_PREFIX)) + .map(([key, value]) => [key.replace(FM_ATTR_PREFIX, ""), value]); + + // Update frontmatter in AST if present + if (frontmatterEntries.length > 0 && ast.attributes) { + const frontmatter = Object.fromEntries(frontmatterEntries); + ast.attributes.frontmatter = YAML.stringify(frontmatter, { + defaultStringType: "PLAIN", + }).trim(); + } + + // Filter out frontmatter keys from translation data + const contentData = Object.fromEntries( + Object.entries(data).filter(([key]) => !key.startsWith(FM_ATTR_PREFIX)), + ); + + // Apply translations using the path map + applyTranslations(ast, "", contentData, pathMap); + + // Format back to string + return Markdoc.format(ast); + }, + }); +} + +function getSemanticNodeType(node: MarkdocNode): string | null { + // For custom tags, use the tag name instead of "tag" + if (node.type === "tag") return node.tag || "tag"; + return node.type; +} + +function traverseAndExtract( + node: MarkdocNode, + path: string, + result: Record, + counters: NodeCounter, + parentType?: string, +) { + if (!node || typeof node !== "object") { + return; + } + + // Determine the semantic type for this node + let semanticType = parentType; + const nodeSemanticType = getSemanticNodeType(node); + + // Use node's own semantic type for structural elements + if ( + nodeSemanticType && + !["text", "strong", "em", "inline", "link"].includes(nodeSemanticType) + ) { + semanticType = nodeSemanticType; + } + + // If this is a text node, extract its content only if it's a string + // Skip interpolation nodes (where content is a Variable or Function object) + if (node.type === "text" && node.attributes?.content) { + const content = node.attributes.content; + + // Only extract if content is a string (not interpolation) + if (typeof content === "string" && content.trim()) { + if (semanticType) { + const index = counters[semanticType] || 0; + counters[semanticType] = index + 1; + const semanticKey = `${semanticType}-${index}`; + result[semanticKey] = content; + } + } + } + + // If the node has children, traverse them + if (Array.isArray(node.children)) { + node.children.forEach((child, index) => { + const childPath = path + ? `${path}/children/${index}` + : `children/${index}`; + traverseAndExtract(child, childPath, result, counters, semanticType); + }); + } +} + +function buildPathMap( + node: MarkdocNode, + path: string, + counters: NodeCounter, + pathMap: NodePathMap, + parentType?: string, +) { + if (!node || typeof node !== "object") { + return; + } + + // Determine the semantic type for this node + let semanticType = parentType; + const nodeSemanticType = getSemanticNodeType(node); + + // Use node's own semantic type for structural elements + if ( + nodeSemanticType && + !["text", "strong", "em", "inline", "link"].includes(nodeSemanticType) + ) { + semanticType = nodeSemanticType; + } + + // Build the map from semantic keys to AST paths + if (node.type === "text" && node.attributes?.content) { + const content = node.attributes.content; + + if (typeof content === "string" && content.trim()) { + if (semanticType) { + const index = counters[semanticType] || 0; + counters[semanticType] = index + 1; + const semanticKey = `${semanticType}-${index}`; + const contentPath = path + ? `${path}/attributes/content` + : "attributes/content"; + pathMap[semanticKey] = contentPath; + } + } + } + + // Recursively build map for children + if (Array.isArray(node.children)) { + node.children.forEach((child, index) => { + const childPath = path + ? `${path}/children/${index}` + : `children/${index}`; + buildPathMap(child, childPath, counters, pathMap, semanticType); + }); + } +} + +function applyTranslations( + node: MarkdocNode, + path: string, + data: Record, + pathMap: NodePathMap, +) { + if (!node || typeof node !== "object") { + return; + } + + // Check if we have a translation for this node's text content + // Only apply translations to string content (not interpolation) + if (node.type === "text" && node.attributes?.content) { + const content = node.attributes.content; + + // Only apply translation if content is currently a string + if (typeof content === "string") { + const contentPath = path + ? `${path}/attributes/content` + : "attributes/content"; + + // Find the semantic key for this path + const semanticKey = Object.keys(pathMap).find( + (key) => pathMap[key] === contentPath, + ); + + if (semanticKey && data[semanticKey] !== undefined) { + node.attributes.content = data[semanticKey]; + } + } + // If content is an object (Variable/Function), leave it unchanged + } + + // Recursively apply translations to children + if (Array.isArray(node.children)) { + node.children.forEach((child, index) => { + const childPath = path + ? `${path}/children/${index}` + : `children/${index}`; + applyTranslations(child, childPath, data, pathMap); + }); + } +} diff --git a/packages/cli/src/cli/loaders/markdown.ts b/packages/cli/src/cli/loaders/markdown.ts new file mode 100644 index 000000000..af8e0d981 --- /dev/null +++ b/packages/cli/src/cli/loaders/markdown.ts @@ -0,0 +1,74 @@ +import matter from "gray-matter"; +import YAML from "yaml"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +const SECTION_REGEX = + /^(#{1,6}\s.*$|[-=*]{3,}$|!\[.*\]\(.*\)$|\[.*\]\(.*\)$)/gm; +const MD_SECTION_PREFIX = "md-section-"; +const FM_ATTR_PREFIX = "fm-attr-"; + +const yamlEngine = { + parse: (str: string) => YAML.parse(str), + stringify: (obj: any) => YAML.stringify(obj, { defaultStringType: "PLAIN" }), +}; + +export default function createMarkdownLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, input) { + const { data: frontmatter, content } = matter(input, { + engines: { + yaml: yamlEngine, + }, + }); + + const sections = content + .split(SECTION_REGEX) + .map((section) => section?.trim() ?? "") + .filter(Boolean); + + return { + ...Object.fromEntries( + sections + .map((section, index) => [`${MD_SECTION_PREFIX}${index}`, section]) + .filter(([, section]) => Boolean(section)), + ), + ...Object.fromEntries( + Object.entries(frontmatter).map(([key, value]) => [ + `${FM_ATTR_PREFIX}${key}`, + value, + ]), + ), + }; + }, + async push(locale, data: Record) { + const frontmatter = Object.fromEntries( + Object.entries(data) + .filter(([key]) => key.startsWith(FM_ATTR_PREFIX)) + .map(([key, value]) => [key.replace(FM_ATTR_PREFIX, ""), value]), + ); + + let content = Object.entries(data) + .filter(([key]) => key.startsWith(MD_SECTION_PREFIX)) + .sort( + ([a], [b]) => Number(a.split("-").pop()) - Number(b.split("-").pop()), + ) + .map(([, value]) => value?.trim() ?? "") + .filter(Boolean) + .join("\n\n"); + + if (Object.keys(frontmatter).length > 0) { + content = `\n${content}`; + } + + return matter.stringify(content, frontmatter, { + engines: { + yaml: yamlEngine, + }, + }); + }, + }); +} diff --git a/packages/cli/src/cli/loaders/mdx.spec.ts b/packages/cli/src/cli/loaders/mdx.spec.ts new file mode 100644 index 000000000..d7d4e825d --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { + createMdxFormatLoader, + createDoubleSerializationLoader, + createMdxStructureLoader, +} from "./mdx"; + +// Helper to traverse mdast tree +function traverse(node: any, visitor: (n: any) => void) { + visitor(node); + if (node && Array.isArray(node.children)) { + node.children.forEach((child: any) => traverse(child, visitor)); + } +} + +describe("mdx loader", () => { + const mdxSample = `\n# Heading\n\nHere is some code:\n\n\u0060\u0060\u0060js\nconsole.log("hello");\n\u0060\u0060\u0060\n\nSome inline \u0060world\u0060 and more text.\n`; + + describe("createMdxFormatLoader", () => { + it("should strip values of code and inlineCode nodes on pull", async () => { + const loader = createMdxFormatLoader(); + loader.setDefaultLocale("en"); + + const ast = await loader.pull("en", mdxSample); + + // Assert that every code or inlineCode node now has an empty value + traverse(ast, (node) => { + if (node?.type === "code" || node?.type === "inlineCode") { + expect(node.value).toBe(""); + } + }); + }); + + it("should preserve original code & inlineCode content on push when incoming value is empty", async () => { + const loader = createMdxFormatLoader(); + loader.setDefaultLocale("en"); + + const pulledAst = await loader.pull("en", mdxSample); + const output = await loader.push("es", pulledAst); + + // The serialized output must still contain the original code and inline code content + expect(output).toContain('console.log("hello");'); + expect(output).toMatch(/`world`/); + }); + }); + + describe("createDoubleSerializationLoader", () => { + it("should return the same content on pull", async () => { + const loader = createDoubleSerializationLoader(); + loader.setDefaultLocale("en"); + const input = "# Hello"; + const output = await loader.pull("en", input); + expect(output).toBe(input); + }); + + it("should reformat markdown on push", async () => { + const loader = createDoubleSerializationLoader(); + loader.setDefaultLocale("en"); + const input = "# Hello "; + const expectedOutput = "# Hello\n"; + await loader.pull("en", input); + const output = await loader.push("en", input); + expect(output).toBe(expectedOutput); + }); + }); + + describe("createMdxStructureLoader", () => { + it("should extract values from keys ending with /value on pull", async () => { + const loader = createMdxStructureLoader(); + loader.setDefaultLocale("en"); + const input = { + "title/value": "Hello", + "title/type": "string", + "content/value": "Some content", + unrelated: "field", + }; + const output = await loader.pull("en", input); + expect(output).toEqual({ + "title/value": "Hello", + "content/value": "Some content", + }); + }); + + it("should merge translated data with non-value keys on push, should not include untranslated keys from originalInput", async () => { + const loader = createMdxStructureLoader(); + loader.setDefaultLocale("en"); + const originalInput = { + "title/value": "Hello", + "title/type": "string", + "content/value": "Some content", + "untranslated/value": "untranslated", + unrelated: "field", + }; + await loader.pull("en", originalInput); + const translatedData = { + "title/value": "Hola", + "content/value": "Algun contenido", + }; + const output = await loader.push("es", translatedData); + expect(output).toEqual({ + "title/value": "Hola", + "title/type": "string", + "content/value": "Algun contenido", + unrelated: "field", + }); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/mdx.ts b/packages/cli/src/cli/loaders/mdx.ts new file mode 100644 index 000000000..fb415311c --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx.ts @@ -0,0 +1,150 @@ +import _ from "lodash"; +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkFrontmatter from "remark-frontmatter"; +import remarkGfm from "remark-gfm"; +import remarkStringify from "remark-stringify"; +import remarkMdxFrontmatter from "remark-mdx-frontmatter"; +import { VFile } from "vfile"; +import { Root, RootContent, RootContentMap } from "mdast"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +const parser = unified() + .use(remarkParse) + .use(remarkFrontmatter, ["yaml"]) + .use(remarkGfm); +const serializer = unified() + .use(remarkStringify) + .use(remarkFrontmatter, ["yaml"]) + .use(remarkGfm); + +export function createMdxFormatLoader(): ILoader> { + const skippedTypes: (keyof RootContentMap | "root")[] = [ + "code", + "inlineCode", + ]; + return createLoader({ + async pull(locale, input) { + const file = new VFile(input); + const ast = parser.parse(file); + + const result = _.cloneDeep(ast); + + traverseMdast(result, (node) => { + if (skippedTypes.includes(node.type)) { + if ("value" in node) { + node.value = ""; + } + } + }); + + return result; + }, + + async push( + locale, + data, + originalInput, + originalLocale, + pullInput, + pullOutput, + ) { + const file = new VFile(originalInput); + const ast = parser.parse(file); + + const result = _.cloneDeep(ast); + + traverseMdast(result, (node, indexPath) => { + if ("value" in node) { + const incomingValue = findNodeByIndexPath(data, indexPath); + if ( + incomingValue && + "value" in incomingValue && + !_.isEmpty(incomingValue.value) + ) { + node.value = incomingValue.value; + } + } + }); + + return String(serializer.stringify(result)); + }, + }); +} + +export function createDoubleSerializationLoader(): ILoader { + return createLoader({ + async pull(locale, input) { + return input; + }, + + async push(locale, data) { + const file = new VFile(data); + const ast = parser.parse(file); + + const finalContent = String(serializer.stringify(ast)); + return finalContent; + }, + }); +} + +export function createMdxStructureLoader(): ILoader< + Record, + Record +> { + return createLoader({ + async pull(locale, input) { + const result = _.pickBy(input, (value, key) => _isValueKey(key)); + return result; + }, + async push(locale, data, originalInput) { + const nonValueKeys = _.pickBy( + originalInput, + (value, key) => !_isValueKey(key), + ); + const result = _.merge({}, nonValueKeys, data); + + return result; + }, + }); +} + +function _isValueKey(key: string) { + return key.endsWith("/value"); +} + +function traverseMdast( + ast: Root | RootContent, + visitor: (node: Root | RootContent, path: number[]) => void, + indexPath: number[] = [], +) { + visitor(ast, indexPath); + + if ("children" in ast && Array.isArray(ast.children)) { + for (let i = 0; i < ast.children.length; i++) { + traverseMdast(ast.children[i], visitor, [...indexPath, i]); + } + } +} + +function findNodeByIndexPath( + ast: Root | RootContent, + indexPath: number[], +): Root | RootContent | null { + let result: Root | RootContent | null = null; + + const stringifiedIndexPath = indexPath.join("."); + traverseMdast(ast, (node, path) => { + if (result) { + return; + } + + const currentStringifiedPath = path.join("."); + if (currentStringifiedPath === stringifiedIndexPath) { + result = node; + } + }); + + return result; +} diff --git a/packages/cli/src/cli/loaders/mdx2/_types.ts b/packages/cli/src/cli/loaders/mdx2/_types.ts new file mode 100644 index 000000000..f6519926d --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx2/_types.ts @@ -0,0 +1,18 @@ +export interface RawMdx { + frontmatter: Record; + content: string; +} + +export interface PlaceholderedMdx extends RawMdx { + codePlaceholders: Record; +} + +export interface SectionedMdx { + frontmatter: Record; + sections: Record; +} + +export type LocalizableMdxDocument = { + meta: Record; + content: Record; +}; diff --git a/packages/cli/src/cli/loaders/mdx2/_utils.ts b/packages/cli/src/cli/loaders/mdx2/_utils.ts new file mode 100644 index 000000000..095a96474 --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx2/_utils.ts @@ -0,0 +1,13 @@ +import { Root, RootContent } from "mdast"; + +export function traverseMdast( + ast: Root | RootContent, + visitor: (node: Root | RootContent) => void, +) { + visitor(ast); + if ("children" in ast && Array.isArray(ast.children)) { + for (const child of ast.children) { + traverseMdast(child, visitor); + } + } +} diff --git a/packages/cli/src/cli/loaders/mdx2/code-placeholder.spec.ts b/packages/cli/src/cli/loaders/mdx2/code-placeholder.spec.ts new file mode 100644 index 000000000..1a574d821 --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx2/code-placeholder.spec.ts @@ -0,0 +1,984 @@ +import { describe, it, expect } from "vitest"; +import createMdxCodePlaceholderLoader from "./code-placeholder"; +import dedent from "dedent"; +import { md5 } from "../../utils/md5"; + +const PLACEHOLDER_REGEX = /\{\/\* CODE_PLACEHOLDER_[0-9a-f]+\s*\*\/\}/g; + +const sampleContent = dedent` +Paragraph with some code: + +\`\`\`js +console.log("foo"); +\`\`\` +`; + +describe("MDX Code Placeholder Loader", () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + it("should replace fenced code with placeholder on pull", async () => { + const result = await loader.pull("en", sampleContent); + const hash = md5('```js\nconsole.log("foo");\n```'); + const expected = `Paragraph with some code:\n\n{/* CODE_PLACEHOLDER_${hash} */}`; + expect(result.trim()).toBe(expected); + }); + + it("should restore fenced code from placeholder on push", async () => { + const pulled = await loader.pull("en", sampleContent); + const translated = pulled.replace("Paragraph", "Párrafo"); + const output = await loader.push("es", translated); + const expected = dedent` + Párrafo with some code: + + \`\`\`js + console.log("foo"); + \`\`\` + `; + expect(output.trim()).toBe(expected.trim()); + }); + + describe("round-trip scenarios", () => { + it("round-trips a fenced block with language tag", async () => { + const md = dedent` + Example: + + \`\`\`js + console.log() + \`\`\` + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("round-trips a fenced block without language tag", async () => { + const md = dedent` + Intro: + + \`\`\` + generic code + \`\`\` + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("round-trips a meta-tagged fenced block", async () => { + const md = dedent` + Meta: + + \`\`\`js {1,2} title="Sample" + line1 + line2 + \`\`\` + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("round-trips a fenced block inside a blockquote", async () => { + const md = dedent` + > Quote start + > \`\`\`ts + > let x = 42; + > \`\`\` + > Quote end + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("round-trips multiple separated fenced blocks", async () => { + const md = dedent` + A: + + \`\`\`js + 1 + \`\`\` + + B: + + \`\`\`js + 2 + \`\`\` + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("round-trips adjacent fenced blocks", async () => { + const md = dedent` + \`\`\` + a() + \`\`\` + \`\`\` + b() + \`\`\` + `; + const expected = dedent` + \`\`\` + a() + \`\`\` + + \`\`\` + b() + \`\`\` + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(expected); + }); + + it("round-trips an indented fenced block", async () => { + const md = dedent` + Outer: + + \`\`\`py + pass + \`\`\` + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("round-trips a fenced block after a heading", async () => { + const md = dedent` + # Title + + \`\`\`bash + echo hi + \`\`\` + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("round-trips a fenced block inside a list item", async () => { + const md = ` +- item: + + \`\`\`js + io() + \`\`\` + `.trim(); + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("round-trips a fenced block inside JSX component", async () => { + const md = dedent` + + + \`\`\`js + x + \`\`\` + + + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("round-trips a fenced block inside JSX component - adds new lines", async () => { + const md = dedent` + + \`\`\`js + x + \`\`\` + + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe( + dedent` + + + \`\`\`js + x + \`\`\` + + + `, + ); + }); + + it("round-trips a large JSON fenced block", async () => { + const md = dedent` + \`\`\`shell + { "key": [1,2,3] } + \`\`\` + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("handles identical code snippets correctly", async () => { + const md = dedent` + First paragraph: + + \`\`\`shell + echo "hello world" + \`\`\` + + Second paragraph: + \`\`\`shell + echo "hello world" + \`\`\` + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe( + dedent` + First paragraph: + + \`\`\`shell + echo "hello world" + \`\`\` + + Second paragraph: + + \`\`\`shell + echo "hello world" + \`\`\` + `, + ); + }); + + it("handles fenced code blocks inside quotes correctly", async () => { + const md = dedent` + > Code snippet inside quote: + > + > \`\`\`shell + > npx -y mucho@latest install + > \`\`\` + `; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("round-trips an image block with surrounding blank lines unchanged", async () => { + const md = dedent` + Text above. + + ![](https://example.com/img.png) + + Text below. + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("round-trips and adds blank lines around an image block when missing", async () => { + const md = dedent` + Text above. + ![](https://example.com/img.png) + Text below. + `; + + const expected = dedent` + Text above. + + ![](https://example.com/img.png) + + Text below. + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(expected); + }); + + it("keeps image inside blockquote as-is", async () => { + const md = dedent` + > ![](https://example.com/img.png) + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("leaves incomplete fences untouched", async () => { + const md = "```js\nno close"; + const pulled = await loader.pull("en", md); + expect(pulled).toBe(md); + + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + // Edge cases for image spacing + + it("adds blank line after image when only before exists", async () => { + const md = dedent` + Before. + + ![alt](https://example.com/i.png) + After. + `; + + const expected = dedent` + Before. + + ![alt](https://example.com/i.png) + + After. + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(expected); + }); + + it("adds blank line before image when only after exists", async () => { + const md = dedent` + Before. + ![alt](https://example.com/i.png) + + After. + `; + + const expected = dedent` + Before. + + ![alt](https://example.com/i.png) + + After. + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(expected); + }); + + it("inserts spacing between consecutive images", async () => { + const md = dedent` + ![](a.png) + ![](b.png) + `; + + const expected = dedent` + ![](a.png) + + ![](b.png) + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(expected); + }); + + it("handles image inside JSX component - adds blank lines", async () => { + const md = dedent` + + ![](pic.png) + + `; + + const expected = dedent` + + + ![](pic.png) + + + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(expected); + }); + }); + + describe("inline code placeholder", () => { + it("should replace inline code with placeholder on pull", async () => { + const md = "This is some `inline()` code."; + const pulled = await loader.pull("en", md); + const hash = md5("`inline()`"); + const expected = `This is some {/* INLINE_CODE_PLACEHOLDER_${hash} */} code.`; + expect(pulled).toBe(expected); + }); + + it("should restore inline code from placeholder on push", async () => { + const md = "Some `code` here."; + const pulled = await loader.pull("en", md); + const translated = pulled.replace("Some", "Algún"); + const pushed = await loader.push("es", translated); + expect(pushed).toBe("Algún `code` here."); + }); + + it("round-trips multiple inline code snippets", async () => { + const md = "Use `a` and `b` and `c`."; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("handles identical inline snippets correctly", async () => { + const md = "Repeat `x` and `x` again."; + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("retains custom inline code in target locale when it differs from source", async () => { + const enMd = "Use `foo` function."; + const ruMd = "Используйте `бар` функцию."; + + // Pull English source to establish originalInput in loader state + await loader.pull("en", enMd); + + // Pull Russian content (with its own inline code value) + const ruPulled = await loader.pull("ru", ruMd); + // Simulate translator editing surrounding text but keeping placeholder intact + const ruTranslated = ruPulled.replace("Используйте", "Примените"); + + // Push back to Russian locale and ensure inline code is preserved + const ruPushed = await loader.push("ru", ruTranslated); + expect(ruPushed).toBe("Примените `бар` функцию."); + }); + }); + + describe("Image URLs with Parentheses", () => { + it("should handle image URLs with parentheses", async () => { + const md = dedent` + Text above. + + ![](https://example.com/image(with)parentheses.jpg) + + Text below. + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("should handle image URLs with nested parentheses", async () => { + const md = dedent` + Text above. + + ![Alt text](https://example.com/image(with(nested)parentheses).jpg) + + Text below. + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("should handle image URLs with parentheses in blockquotes", async () => { + const md = dedent` + > ![Blockquote image](https://example.com/image(in)blockquote.jpg) + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(md); + }); + + it("should handle image URLs with parentheses in JSX components", async () => { + const md = dedent` + + ![Component image](https://example.com/image(in)component.jpg) + + `; + + const expected = dedent` + + + ![Component image](https://example.com/image(in)component.jpg) + + + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("es", pulled); + expect(pushed).toBe(expected); + }); + }); + + describe("placeholder replacement bugs", () => { + it("should handle special $ characters in code content correctly", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + // Code containing special $ characters that have special meaning in replaceAll + const content = dedent` + Text before. + + \`\`\`js + const price = "$100"; + const template = "$\`text\`"; + const special = "$&$'$\`"; + \`\`\` + + Text after. + `; + + // Pull and then push the same content + const pulled = await loader.pull("en", content); + const translated = pulled.replace("Text before", "Texto antes"); + const pushed = await loader.push("en", translated); + + // Should not contain any placeholders + expect(pushed).not.toMatch(/\{\/\* CODE_PLACEHOLDER_[0-9a-f]+\s*\*\/\}/); + + // Should preserve all special $ characters exactly as they were + expect(pushed).toContain('const price = "$100";'); + expect(pushed).toContain('const template = "$`text`";'); + expect(pushed).toContain('const special = "$&$\'$`";'); + expect(pushed).toContain("Texto antes"); + }); + + it("should handle inline code with $ characters correctly", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + const content = "Use `$price` and `$&` and `$\`` in your code."; + + // Pull and then push the same content + const pulled = await loader.pull("en", content); + const translated = pulled.replace("Use", "Utilize"); + const pushed = await loader.push("en", translated); + + // Should not contain any placeholders + expect(pushed).not.toMatch(/\{\/\* INLINE_CODE_PLACEHOLDER_[0-9a-f]+\s*\*\/\}/); + + // Should preserve all special $ characters + expect(pushed).toContain("`$price`"); + expect(pushed).toContain("`$&`"); + expect(pushed).toContain("`$\``"); + expect(pushed).toContain("Utilize"); + }); + + it("should not leave placeholders when content matches", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + const content = "Use the `getData()` function."; + + // Pull and then push the same content - should work correctly + const pulled = await loader.pull("en", content); + const translated = pulled.replace("Use", "Utilize"); + const pushed = await loader.push("en", translated); + + // Should not contain any placeholders + expect(pushed).not.toMatch(/\{\/\* INLINE_CODE_PLACEHOLDER_[0-9a-f]+\s*\*\/\}/); + expect(pushed).not.toMatch(/\{\/\* CODE_PLACEHOLDER_[0-9a-f]+\s*\*\/\}/); + expect(pushed).toContain("`getData()`"); + expect(pushed).toContain("Utilize"); + }); + + it("should replace all placeholders including those from different sources", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + // Simulate the exact scenario from the user's bug report + const englishContent = "Use the `getData()` function."; + const arabicContent = "استخدم `الحصول_على_البيانات()` الدالة."; + + // First pull English (required as default locale) + await loader.pull("en", englishContent); + + // Pull Arabic content to create placeholders + const arabicPulled = await loader.pull("ar", arabicContent); + + // Simulate translation: translator changes text but keeps placeholder + const arabicTranslated = arabicPulled.replace("استخدم", "قم بتطبيق"); + + // Push back - this should now work correctly with the fix + const pushedResult = await loader.push("ar", arabicTranslated); + + // The fix: ALL placeholders should be replaced, including Arabic ones + expect(pushedResult).not.toMatch( + /\{\/\* INLINE_CODE_PLACEHOLDER_[0-9a-f]+\s*\*\/\}/, + ); + expect(pushedResult).not.toMatch(/\{\/\* CODE_PLACEHOLDER_[0-9a-f]+\s*\*\/\}/); + + // The Arabic inline code should be preserved and translated text should be there + expect(pushedResult).toContain("`الحصول_على_البيانات()`"); + expect(pushedResult).toContain("قم بتطبيق"); + }); + + it("should replace placeholders even when pullInput state is overwritten", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + const englishContent = "Use the `getData()` function."; + const arabicContent = "استخدم `الحصول_على_البيانات()` الدالة."; + + // First pull English (required as default locale) + await loader.pull("en", englishContent); + + // Pull Arabic content to create placeholders + const arabicPulled = await loader.pull("ar", arabicContent); + + // Simulate translation: translator changes text but keeps placeholder + const arabicTranslated = arabicPulled.replace("استخدم", "قم بتطبيق"); + + // Now pull English again, overwriting pullInput state + // This simulates the real-world scenario where the loader state gets out of sync + await loader.pull("en", englishContent); + + // Push the Arabic translation - should work despite state being overwritten + const pushedResult = await loader.push("ar", arabicTranslated); + + // All placeholders should be replaced, even when not in current pullInput + expect(pushedResult).not.toMatch( + /\{\/\* INLINE_CODE_PLACEHOLDER_[0-9a-f]+\s*\*\/\}/, + ); + expect(pushedResult).not.toMatch(/\{\/\* CODE_PLACEHOLDER_[0-9a-f]+\s*\*\/\}/); + expect(pushedResult).toContain("`الحصول_على_البيانات()`"); + expect(pushedResult).toContain("قم بتطبيق"); + }); + }); + + describe("raw code outside fences", () => { + it("should handle raw JavaScript code outside fences", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + // Test case matching user's file structure - raw JS between JSX components + const md = dedent` + + + // Attach to button click + document.getElementById('executeBtn')?.addEventListener('click', executeClientSideWorkflow); + + + Content here + + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("en", pulled); + + // Should round-trip correctly + expect(pushed).toBe(md); + }); + + it("should handle mixed code blocks and raw code", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + const md = dedent` + Here's a code block: + + \`\`\`typescript + const x = 1; + \`\`\` + + Now some raw code outside: + // This is outside + const y = 2; + + And another block: + + \`\`\`javascript + const z = 3; + \`\`\` + `; + + const pulled = await loader.pull("en", md); + const pushed = await loader.push("en", pulled); + + // Should preserve raw code outside fences + expect(pushed).toContain("// This is outside"); + expect(pushed).toContain("const y = 2;"); + }); + + it("should handle code blocks with extra blank lines added by translation", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + // English source - no extra blank lines + const enMd = dedent` + + \`\`\`bash + npm install + \`\`\` + + `; + + // Pull English to establish placeholders + const enPulled = await loader.pull("en", enMd); + + // German translation with extra blank lines (simulating AI translation behavior) + const deMd = dedent` + + + \`\`\`bash + npm install + \`\`\` + + + `; + + // Pull German version + const dePulled = await loader.pull("de", deMd); + + // Push back - should restore code blocks correctly + const dePushed = await loader.push("de", dePulled); + + // The code block should be present and not replaced with placeholder + expect(dePushed).toContain("```bash"); + expect(dePushed).toContain("npm install"); + expect(dePushed).not.toMatch(/\{\/\* CODE_PLACEHOLDER_/); + }); + + it("should preserve double newlines around placeholders for section splitting", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + // Test that placeholders maintain double newlines so section-split works correctly + const md = dedent` + Text before. + + \`\`\`typescript + code1 + \`\`\` + + Text between. + + \`\`\`javascript + code2 + \`\`\` + + Text after. + `; + + const pulled = await loader.pull("en", md); + + // Verify placeholders are surrounded by double newlines for proper section splitting + const placeholders = pulled.match(/\{\/\* CODE_PLACEHOLDER_[a-f0-9]+\s*\*\/\}/g); + expect(placeholders).toHaveLength(2); + + // Check that each placeholder has double newlines around it + for (const placeholder of placeholders!) { + // Should have \n\n before (except at start) and \n\n after (except at end) + const placeholderIndex = pulled.indexOf(placeholder); + + // Check for double newline after (unless at end) + const afterPlaceholder = pulled.substring( + placeholderIndex + placeholder.length, + placeholderIndex + placeholder.length + 2, + ); + if (placeholderIndex + placeholder.length < pulled.length - 2) { + expect(afterPlaceholder).toBe("\n\n"); + } + } + + // Ensure we can split on \n\n and get separate sections + const sections = pulled.split("\n\n").filter(Boolean); + expect(sections.length).toBeGreaterThanOrEqual(5); // Text + placeholder + text + placeholder + text + }); + }); +}); + +describe("adjacent code blocks bug", () => { + it("should handle closing fence followed immediately by opening fence", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + // This reproduces the actual bug from the user's file + const md = dedent` + \`\`\`typescript + function example() { + return true; + } + \`\`\` + + \`\`\`typescript + import { Something } from 'somewhere'; + \`\`\` + `; + + const pulled = await loader.pull("en", md); + + console.log("PULLED CONTENT:"); + console.log(pulled); + console.log("___"); + + // The bug: placeholder is concatenated with "typescript" from next block + const bugPattern = /\{\/\* CODE_PLACEHOLDER_[a-f0-9]+\s*\*\/\}typescript/; + expect(pulled).not.toMatch(bugPattern); + + // Should have proper separation + expect(pulled).toMatch( + /\{\/\* CODE_PLACEHOLDER_[a-f0-9]+\s*\*\/\}\n\n\{\/\* CODE_PLACEHOLDER_[a-f0-9]+\s*\*\/\}/, + ); + }); +}); + +describe("$ special character handling in replacement functions", () => { + it("should preserve $ characters in ensureTrailingFenceNewline", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + // Tests fix for lines 38, 68: replaceAll(match, () => replacement) + // Code block with $ that would trigger special replacement behavior if not using function replacer + const content = dedent` + Some text + \`\`\`js + console.log('Current period cost: $' + amount); + const template = \`Price: $\${price}\`; + \`\`\` + More text + `; + + const pulled = await loader.pull("en", content); + const pushed = await loader.push("en", pulled); + + // All $ characters should be preserved exactly + expect(pushed).toContain("console.log('Current period cost: $' + amount);"); + expect(pushed).toContain("const template = `Price: $"); + }); + + it("should preserve $ characters in ensureSurroundingImageNewlines", async () => { + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + // Tests fix for line 38: replaceAll(match, () => replacement) in image handling + // Image with $ in URL and alt text that would break with string replacer + const content = dedent` + Here is an image: + ![Price: $100](https://api.example.com/chart?price=$500¤cy=$USD) + End of text + `; + + const pulled = await loader.pull("en", content); + const pushed = await loader.push("en", pulled); + + // All $ characters in URL and alt text should be preserved + expect(pushed).toContain("![Price: $100]"); + expect(pushed).toContain("price=$500¤cy=$USD"); + }); +}); + +describe("placeholder format edge cases (regression)", () => { + // Tests for edge cases that would fail with `---` or `___` placeholder formats + // These tests pass with current `{/* */}` format and document why it's used + + it("should handle placeholder at content start (gray-matter edge case)", async () => { + // Korean translation scenario: inline code moves to sentence start + // Would fail with `---` format (gray-matter engine detection) + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + const englishMdx = `Understanding the \`code\` directory.`; + await loader.pull("en", englishMdx); + + const koreanMdx = `\`코드\` 디렉토리와 그 내용을 이해합니다.`; + const pulled = await loader.pull("ko", koreanMdx); + + expect(pulled).toMatch(/^\{\/\* INLINE_CODE_PLACEHOLDER/); + + // Simulate frontmatter-split: matter.stringify with placeholder at start + const matter = require('gray-matter'); + const mdxDocument = matter.stringify(pulled, { title: 'Test' }); + + expect(mdxDocument).toContain('{/*'); + + const restored = await loader.push("ko", pulled, koreanMdx, "en", koreanMdx); + expect(restored).toBe(koreanMdx); + }); + + it("should preserve placeholder without Markdown emphasis parsing", async () => { + // Would fail with `___` format (parsed as bold-italic) + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + const mdContent = dedent` + Paragraph with \`inline code\` in middle. + + More text with \`code\` here. + `; + + const pulled = await loader.pull("en", mdContent); + + // Simulate Markdown parsing + const { unified } = require('unified'); + const remarkParse = require('remark-parse').default; + const remarkStringify = require('remark-stringify').default; + + const processor = unified().use(remarkParse).use(remarkStringify); + const parsed = processor.stringify(processor.parse(pulled)); + + // JSX comments get escaped by Markdown but structure is preserved + // Underscores in placeholder names get escaped: INLINE\_CODE\_PLACEHOLDER + expect(parsed).toMatch(/INLINE[_\\]*CODE[_\\]*PLACEHOLDER/); + expect(parsed).not.toContain('***'); // Not parsed as bold-italic + expect(parsed).not.toMatch(/___CODE.*___/); // Not using underscore format + + const restored = await loader.push("en", pulled, mdContent, "en", mdContent); + expect(restored).toBe(mdContent); + }); + + it("should handle multiple placeholders at different positions", async () => { + // Tests placeholders at start, middle, and after newlines + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + const mdContent = dedent` + \`start\` with code + + Middle has \`code\` too. + + \`another\` line starts with code. + `; + + const pulled = await loader.pull("en", mdContent); + const placeholders = pulled.match(/\{\/\* INLINE_CODE_PLACEHOLDER_[a-f0-9]+\s*\*\/\}/g); + expect(placeholders).toHaveLength(3); + + const matter = require('gray-matter'); + const mdxDoc = matter.stringify(pulled, { + title: 'Multiple Placeholders' + }); + + expect(mdxDoc).toContain('{/*'); + + const restored = await loader.push("en", pulled, mdContent, "en", mdContent); + expect(restored).toBe(mdContent); + }); + + it("should handle code block placeholder at document start", async () => { + // Would fail with `---` format at document start + const loader = createMdxCodePlaceholderLoader(); + loader.setDefaultLocale("en"); + + const mdContent = dedent` + \`\`\`js + console.log("test"); + \`\`\` + + Text after code block. + `; + + const pulled = await loader.pull("en", mdContent); + expect(pulled).toMatch(/^\{\/\* CODE_PLACEHOLDER/); + + const matter = require('gray-matter'); + const mdxDoc = matter.stringify(pulled, { title: 'Code First' }); + + expect(mdxDoc).toContain('title: Code First'); + expect(mdxDoc).toContain('{/*'); + + const restored = await loader.push("en", pulled, mdContent, "en", mdContent); + expect(restored).toBe(mdContent); + }); +}); diff --git a/packages/cli/src/cli/loaders/mdx2/code-placeholder.ts b/packages/cli/src/cli/loaders/mdx2/code-placeholder.ts new file mode 100644 index 000000000..b1906fbc0 --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx2/code-placeholder.ts @@ -0,0 +1,172 @@ +import { ILoader } from "../_types"; +import { createLoader } from "../_utils"; +import { md5 } from "../../utils/md5"; +import _ from "lodash"; + +const fenceRegex = /([ \t]*)(^>\s*)?```([\s\S]*?)```/gm; +const inlineCodeRegex = /(? ' for blockquotes +const imageRegex = + /([ \t]*)(^>\s*)?!\[[^\]]*?\]\(([^()]*(\([^()]*\)[^()]*)*)\)/gm; + +/** + * Ensures that markdown image tags are surrounded by blank lines (\n\n) so that they are properly + * treated as separate blocks during subsequent processing and serialization. + * + * Behaviour mirrors `ensureTrailingFenceNewline` logic for code fences: + * • If an image tag is already inside a blockquote (starts with `>` after trimming) we leave it untouched. + * • Otherwise we add two newlines before and after the image tag, then later collapse multiple + * consecutive blank lines back to exactly one separation using lodash chain logic. + */ +function ensureSurroundingImageNewlines(_content: string) { + let found = false; + let content = _content; + let workingContent = content; + + do { + found = false; + const matches = workingContent.match(imageRegex); + if (matches) { + const match = matches[0]; + + const replacement = match.trim().startsWith(">") + ? match + : `\n\n${match}\n\n`; + + content = content.replaceAll(match, () => replacement); + workingContent = workingContent.replaceAll(match, ""); + found = true; + } + } while (found); + + content = _.chain(content) + .split("\n\n") + .map((section) => _.trim(section, "\n")) + .filter(Boolean) + .join("\n\n") + .value(); + + return content; +} + +function ensureTrailingFenceNewline(_content: string) { + let found = false; + let content = _content; + let workingContent = content; + + do { + found = false; + const matches = workingContent.match(fenceRegex); + if (matches) { + const match = matches[0]; + + const replacement = match.trim().startsWith(">") + ? match + : `\n\n${match}\n\n`; + content = content.replaceAll(match, () => replacement); + workingContent = workingContent.replaceAll(match, ""); + found = true; + } + } while (found); + + content = _.chain(content) + .split("\n\n") + .map((section) => _.trim(section, "\n")) + .filter(Boolean) + .join("\n\n") + .value(); + + return content; +} + +// Helper that replaces code (block & inline) with stable placeholders and returns +// both the transformed content and the placeholder → original mapping so it can +// later be restored. Extracted so that we can reuse the exact same logic in both +// `pull` and `push` phases (e.g. to recreate the mapping from `originalInput`). +function extractCodePlaceholders(content: string): { + content: string; + codePlaceholders: Record; +} { + let finalContent = content; + finalContent = ensureTrailingFenceNewline(finalContent); + finalContent = ensureSurroundingImageNewlines(finalContent); + + const codePlaceholders: Record = {}; + + const codeBlockMatches = finalContent.matchAll(fenceRegex); + for (const match of codeBlockMatches) { + const codeBlock = match[0]; + const codeBlockHash = md5(codeBlock); + const placeholder = `{/* CODE_PLACEHOLDER_${codeBlockHash} */}`; + + codePlaceholders[placeholder] = codeBlock; + + const replacement = codeBlock.trim().startsWith(">") + ? `> ${placeholder}` + : `${placeholder}`; + finalContent = finalContent.replace(codeBlock, () => replacement); + } + + const inlineCodeMatches = finalContent.matchAll(inlineCodeRegex); + for (const match of inlineCodeMatches) { + const inlineCode = match[0]; + const inlineCodeHash = md5(inlineCode); + const placeholder = `{/* INLINE_CODE_PLACEHOLDER_${inlineCodeHash} */}`; + codePlaceholders[placeholder] = inlineCode; + const replacement = placeholder; + finalContent = finalContent.replace(inlineCode, () => replacement); + } + + return { + content: finalContent, + codePlaceholders, + }; +} + +export default function createMdxCodePlaceholderLoader(): ILoader< + string, + string +> { + // Keep a global registry of all placeholders we've ever created + // This solves the state synchronization issue + const globalPlaceholderRegistry: Record = {}; + + return createLoader({ + async pull(locale, input) { + const response = extractCodePlaceholders(input); + + // Register all placeholders we create so we can use them later + Object.assign(globalPlaceholderRegistry, response.codePlaceholders); + + return response.content; + }, + + async push(locale, data, originalInput, originalLocale, pullInput) { + const sourceInfo = extractCodePlaceholders(originalInput ?? ""); + const currentInfo = extractCodePlaceholders(pullInput ?? ""); + + // Use the global registry to ensure all placeholders can be replaced, + // including those from previous pulls that are no longer in current state + const codePlaceholders = _.merge( + sourceInfo.codePlaceholders, + currentInfo.codePlaceholders, + globalPlaceholderRegistry, // Include ALL placeholders ever created + ); + + let result = data; + for (const [placeholder, original] of Object.entries(codePlaceholders)) { + const replacement = original.startsWith(">") + ? _.trimStart(original, "> ") + : original; + + // Use function replacer to avoid special $ character handling + // When using a string, $ has special meaning (e.g., $` inserts text before match) + result = result.replaceAll(placeholder, () => replacement); + } + + return result; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/mdx2/frontmatter-split.spec.ts b/packages/cli/src/cli/loaders/mdx2/frontmatter-split.spec.ts new file mode 100644 index 000000000..ee2b2da19 --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx2/frontmatter-split.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import createMdxFrontmatterSplitLoader from "./frontmatter-split"; +import matter from "gray-matter"; + +const sampleMdx = `--- +title: Hello +published: true +tags: + - foo + - bar +--- + +# Heading + +This is some text.`; + +// Helper to derive expected content string from the original sample – this mirrors what gray-matter returns +const { content: originalContent } = matter(sampleMdx); + +describe("mdx frontmatter split loader", () => { + it("should split frontmatter and content on pull", async () => { + const loader = createMdxFrontmatterSplitLoader(); + loader.setDefaultLocale("en"); + + const result = await loader.pull("en", sampleMdx); + + expect(result).toEqual({ + frontmatter: { + title: "Hello", + published: true, + tags: ["foo", "bar"], + }, + content: originalContent, + }); + }); + + it("should merge frontmatter and content on push", async () => { + const loader = createMdxFrontmatterSplitLoader(); + loader.setDefaultLocale("en"); + + const pulled = await loader.pull("en", sampleMdx); + // modify the data + pulled.frontmatter.title = "Hola"; + pulled.content = pulled.content.replace("# Heading", "# Título"); + + const output = await loader.push("es", pulled); + + const expectedOutput = ` +--- +title: Hola +published: true +tags: + - foo + - bar +--- + +# Título + +This is some text. +`.trim(); + + expect(output).toBe(expectedOutput); + }); +}); diff --git a/packages/cli/src/cli/loaders/mdx2/frontmatter-split.ts b/packages/cli/src/cli/loaders/mdx2/frontmatter-split.ts new file mode 100644 index 000000000..5d3371916 --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx2/frontmatter-split.ts @@ -0,0 +1,55 @@ +import matter from "gray-matter"; +import YAML from "yaml"; +import { ILoader } from "../_types"; +import { createLoader } from "../_utils"; +import { RawMdx } from "./_types"; + +export default function createMdxFrontmatterSplitLoader(): ILoader< + string, + RawMdx +> { + const fmEngine = createFmEngine(); + + return createLoader({ + async pull(locale, input) { + const source = input || ""; + const { data: frontmatter, content } = fmEngine.parse(source); + + return { + frontmatter: frontmatter as Record, + content, + }; + }, + + async push(locale, data) { + const { frontmatter = {}, content = "" } = data || ({} as RawMdx); + + const result = fmEngine.stringify(content, frontmatter).trim(); + + return result; + }, + }); +} + +function createFmEngine() { + const yamlEngine = { + parse: (str: string) => YAML.parse(str), + stringify: (obj: any) => + YAML.stringify(obj, { defaultStringType: "PLAIN" }), + }; + + return { + parse: (input: string) => + matter(input, { + engines: { + yaml: yamlEngine, + }, + }), + stringify: (content: string, frontmatter: Record) => + matter.stringify(content, frontmatter, { + engines: { + yaml: yamlEngine, + }, + }), + }; +} diff --git a/packages/cli/src/cli/loaders/mdx2/localizable-document.spec.ts b/packages/cli/src/cli/loaders/mdx2/localizable-document.spec.ts new file mode 100644 index 000000000..057a1c326 --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx2/localizable-document.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import createLocalizableMdxDocumentLoader from "./localizable-document"; + +describe("mdx localizable document loader", () => { + it("should map to meta/content on pull and reconstruct on push", async () => { + const loader = createLocalizableMdxDocumentLoader(); + loader.setDefaultLocale("en"); + + const headingSection = "## Heading One\nSome paragraph."; + const pulled = await loader.pull("en", { + frontmatter: { + title: "Sample", + }, + sections: { + "0": headingSection, + }, + }); + + // Validate structure + expect(pulled).toHaveProperty("meta"); + expect(pulled).toHaveProperty("content"); + + // Expect meta matches frontmatter + expect(pulled.meta.title).toBe("Sample"); + + // Modify + pulled.meta.title = "Hola"; + + // Try push + const pushed = await loader.push("es", pulled); + + // After push we should get original MDX string reflect changes + expect(pushed.frontmatter.title).toBe("Hola"); + // sections should persist + expect(pushed.sections["0"]).toBe(headingSection); + }); +}); diff --git a/packages/cli/src/cli/loaders/mdx2/localizable-document.ts b/packages/cli/src/cli/loaders/mdx2/localizable-document.ts new file mode 100644 index 000000000..bcfd26257 --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx2/localizable-document.ts @@ -0,0 +1,26 @@ +import { ILoader } from "../_types"; +import { createLoader } from "../_utils"; +import { LocalizableMdxDocument, SectionedMdx } from "./_types"; + +export default function createLocalizableMdxDocumentLoader(): ILoader< + SectionedMdx, + LocalizableMdxDocument +> { + return createLoader({ + async pull(_locale, input) { + return { + meta: input.frontmatter, + content: input.sections, + }; + }, + + async push(_locale, data, originalInput, _originalLocale, pullInput) { + const result: SectionedMdx = { + frontmatter: data.meta || {}, + sections: data.content || {}, + }; + + return result; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/mdx2/section-split.spec.ts b/packages/cli/src/cli/loaders/mdx2/section-split.spec.ts new file mode 100644 index 000000000..96ab8e647 --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx2/section-split.spec.ts @@ -0,0 +1,95 @@ +// #region Imports +import { describe, it, expect } from "vitest"; +import createMdxSectionSplitLoader from "./section-split"; +import dedent from "dedent"; +// #endregion + +describe("mdx section split loader", () => { + const sampleMdxContent = dedent` + ## Heading One + Some paragraph text. + + + + + + Some content inside another component. + + + + ### Sub Heading + More text here. + `; + + it("should split content into section map keyed by index", async () => { + const loader = createMdxSectionSplitLoader(); + loader.setDefaultLocale("en"); + + const result = await loader.pull("en", { + frontmatter: {}, + codePlaceholders: {}, + content: sampleMdxContent, + }); + + // Build expected segments + const seg0 = "## Heading One\nSome paragraph text."; + const seg1 = ''; + const seg2 = ''; + const seg3 = ""; + const seg4 = "Some content inside another component."; + const seg5 = ""; + const seg6 = ""; + const seg7 = "### Sub Heading\nMore text here."; + + const expected = { + "0": seg0, + "1": seg1, + "2": seg2, + "3": seg3, + "4": seg4, + "5": seg5, + "6": seg6, + "7": seg7, + }; + + expect(result.sections).toEqual(expected); + }); + + it("should merge sections back into MDX content on push", async () => { + const loader = createMdxSectionSplitLoader(); + loader.setDefaultLocale("en"); + + // First pull to split the sample content into sections + const pulled = await loader.pull("en", { + frontmatter: {}, + codePlaceholders: {}, + content: sampleMdxContent, + }); + + // Push to merge the sections back into MDX + const pushed = await loader.push("en", { + ...pulled, + sections: { + ...pulled.sections, + "4": "Hello world!", + }, + }); + + const expectedContent = dedent` + ## Heading One + Some paragraph text. + + + + + Hello world! + + + + ### Sub Heading + More text here. + `; + + expect(pushed.content).toBe(expectedContent); + }); +}); diff --git a/packages/cli/src/cli/loaders/mdx2/section-split.ts b/packages/cli/src/cli/loaders/mdx2/section-split.ts new file mode 100644 index 000000000..b43f823ad --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx2/section-split.ts @@ -0,0 +1,516 @@ +/** + * Optimized version of the section joining algorithm + * + * This implementation focuses on performance and maintainability: + * 1. Uses a lookup table for faster section type determination + * 2. Uses a matrix for faster spacing determination + * 3. Reduces string concatenations by using an array and joining at the end + * 4. Adds detailed comments for better maintainability + */ + +import { unified } from "unified"; +import _ from "lodash"; +import remarkParse from "remark-parse"; +import remarkGfm from "remark-gfm"; +import remarkMdx from "remark-mdx"; +import { VFile } from "vfile"; +import { Root, RootContent } from "mdast"; +import { PlaceholderedMdx, SectionedMdx } from "./_types"; +import { traverseMdast } from "./_utils"; +import { createLoader } from "../_utils"; +import { ILoader } from "../_types"; + +/** + * MDX Section Splitter + * + * This module splits MDX content into logical sections, with special handling for JSX/HTML tags. + * + * Key features: + * - Splits content at headings (h1-h6) + * - Treats JSX/HTML opening tags as separate sections + * - Treats JSX/HTML closing tags as separate sections + * - Treats self-closing JSX/HTML tags as separate sections + * - Handles nested components properly + * - Preserves content between tags as separate sections + * - Intelligently joins sections with appropriate spacing + */ + +// Create a parser instance for GitHub-flavoured Markdown and MDX JSX +const parser = unified().use(remarkParse).use(remarkGfm).use(remarkMdx); + +// Interface for section boundaries +interface Boundary { + /** 0-based offset into content where the boundary begins */ + start: number; + /** 0-based offset into content where the boundary ends */ + end: number; + /** Whether the boundary node itself should be isolated as its own section */ + isolateSelf: boolean; +} + +// Section types for intelligent joining +enum SectionType { + HEADING = 0, + JSX_OPENING_TAG = 1, + JSX_CLOSING_TAG = 2, + JSX_SELF_CLOSING_TAG = 3, + CONTENT = 4, + UNKNOWN = 5, +} + +// Spacing matrix for fast lookup +// [prevType][currentType] = spacing +const SPACING_MATRIX = [ + // HEADING as previous type + ["\n\n", "\n\n", "\n\n", "\n\n", "\n\n", "\n\n"], + // JSX_OPENING_TAG as previous type + ["\n\n", "\n", "\n", "\n", "\n", "\n\n"], + // JSX_CLOSING_TAG as previous type + ["\n\n", "\n", "\n", "\n", "\n\n", "\n\n"], + // JSX_SELF_CLOSING_TAG as previous type + ["\n\n", "\n", "\n", "\n", "\n", "\n\n"], + // CONTENT as previous type + ["\n\n", "\n\n", "\n", "\n\n", "\n\n", "\n\n"], + // UNKNOWN as previous type + ["\n\n", "\n\n", "\n\n", "\n\n", "\n\n", "\n\n"], +]; + +/** + * Creates a loader that splits MDX content into logical sections. + * + * A new section starts at: + * • Any heading (level 1-6) + * • Any JSX/HTML opening tag ( or
    etc.) + * • Any JSX/HTML closing tag ( or
    etc.) + * • Any self-closing JSX/HTML tag ( or
    etc.) + */ +export default function createMdxSectionSplitLoader(): ILoader< + PlaceholderedMdx, + SectionedMdx +> { + return createLoader({ + async pull(_locale, input) { + // Extract input or use defaults + const { + frontmatter = {}, + content = "", + codePlaceholders = {}, + } = input || + ({ + frontmatter: {}, + content: "", + codePlaceholders: {}, + } as PlaceholderedMdx); + + // Skip processing for empty content + if (!content.trim()) { + return { + frontmatter, + sections: {}, + }; + } + + // Parse the content to get the AST + const file = new VFile(content); + const ast = parser.parse(file) as Root; + + // Process the AST to find section boundaries + const boundaries = findSectionBoundaries(ast, content); + + // Build sections from boundaries + const sections = createSectionsFromBoundaries(boundaries, content); + + return { + frontmatter, + sections, + }; + }, + + async push(_locale, data, originalInput, _originalLocale) { + // Get sections as array + const sectionsArray = Object.values(data.sections); + + // If no sections, return empty content + if (sectionsArray.length === 0) { + return { + frontmatter: data.frontmatter, + content: "", + codePlaceholders: originalInput?.codePlaceholders ?? {}, + }; + } + + // Optimize by pre-allocating result array and determining section types once + const resultParts: string[] = new Array(sectionsArray.length * 2 - 1); + const sectionTypes: SectionType[] = new Array(sectionsArray.length); + + // Determine section types for all sections + for (let i = 0; i < sectionsArray.length; i++) { + sectionTypes[i] = determineJsxSectionType(sectionsArray[i]); + } + + // Add first section without spacing + resultParts[0] = sectionsArray[0]; + + // Add remaining sections with appropriate spacing + for (let i = 1, j = 1; i < sectionsArray.length; i++, j += 2) { + const prevType = sectionTypes[i - 1]; + const currentType = sectionTypes[i]; + + // Get spacing from matrix for better performance + resultParts[j] = SPACING_MATRIX[prevType][currentType]; + resultParts[j + 1] = sectionsArray[i]; + } + + // Join all parts into final content + const content = resultParts.join(""); + + return { + frontmatter: data.frontmatter, + content, + codePlaceholders: originalInput?.codePlaceholders ?? {}, + }; + }, + }); +} + +/** + * Determines the type of a section based on its content. + * Optimized with regex caching and early returns. + */ +function determineJsxSectionType(section: string): SectionType { + section = section.trim(); + + // Early returns for common cases + if (!section) return SectionType.UNKNOWN; + + const firstChar = section.charAt(0); + const lastChar = section.charAt(section.length - 1); + + // Check for headings (starts with #) + if (firstChar === "#") { + // Ensure it's a proper heading with space after # + if (/^#{1,6}\s/.test(section)) { + return SectionType.HEADING; + } + } + + // Check for JSX/HTML tags (starts with <) + if (firstChar === "<") { + // Self-closing tag (ends with />) + if (section.endsWith("/>")) { + return SectionType.JSX_SELF_CLOSING_TAG; + } + + // Closing tag (starts with ) + if (lastChar === ">") { + return SectionType.JSX_OPENING_TAG; + } + } + + // Default to content + return SectionType.CONTENT; +} + +/** + * Determines if a node is a JSX or HTML element. + */ +function isJsxOrHtml(node: Root | RootContent): boolean { + return ( + node.type === "mdxJsxFlowElement" || + node.type === "mdxJsxTextElement" || + node.type === "html" + ); +} + +/** + * Finds the end position of an opening tag in a text string. + * Optimized to handle nested angle brackets correctly. + */ +function findOpeningTagEnd(text: string): number { + let depth = 0; + let inQuotes = false; + let quoteChar = ""; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + + // Handle quotes (to avoid counting angle brackets inside attribute values) + if ((char === '"' || char === "'") && (i === 0 || text[i - 1] !== "\\")) { + if (!inQuotes) { + inQuotes = true; + quoteChar = char; + } else if (char === quoteChar) { + inQuotes = false; + } + } + + // Only count angle brackets when not in quotes + if (!inQuotes) { + if (char === "<") depth++; + if (char === ">") { + depth--; + if (depth === 0) return i + 1; + } + } + } + return -1; +} + +/** + * Finds the start position of a closing tag in a text string. + * Optimized to handle nested components correctly. + */ +function findClosingTagStart(text: string): number { + // Extract the tag name from the opening tag to match the correct closing tag + const openTagMatch = /<([^\s/>]+)/.exec(text); + if (!openTagMatch) return -1; + + const tagName = openTagMatch[1]; + const closingTagRegex = new RegExp(``, "g"); + + // Find the last occurrence of the closing tag + let lastMatch = null; + let match; + + while ((match = closingTagRegex.exec(text)) !== null) { + lastMatch = match; + } + + return lastMatch ? lastMatch.index : -1; +} + +/** + * Processes a JSX/HTML node to extract opening and closing tags as separate boundaries. + */ +function processJsxNode( + node: RootContent, + content: string, + boundaries: Boundary[], +): void { + // Skip nodes without valid position information + if ( + !node.position || + typeof node.position.start.offset !== "number" || + typeof node.position.end.offset !== "number" + ) { + return; + } + + const nodeStart = node.position.start.offset; + const nodeEnd = node.position.end.offset; + const nodeContent = content.slice(nodeStart, nodeEnd); + + // Handle HTML nodes using regex + if (node.type === "html") { + extractHtmlTags(nodeStart, nodeContent, boundaries); + return; + } + + // Handle MDX JSX elements + if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") { + const isSelfClosing = (node as any).selfClosing === true; + + if (isSelfClosing) { + // Self-closing tag - treat as a single section + boundaries.push({ + start: nodeStart, + end: nodeEnd, + isolateSelf: true, + }); + } else { + extractJsxTags(node, nodeContent, boundaries); + + // Process children recursively to handle nested components + if ((node as any).children) { + for (const child of (node as any).children) { + if (isJsxOrHtml(child)) { + processJsxNode(child, content, boundaries); + } + } + } + } + } +} + +/** + * Extracts HTML tags using regex and adds them as boundaries. + * Optimized with a more precise regex pattern. + */ +function extractHtmlTags( + nodeStart: number, + nodeContent: string, + boundaries: Boundary[], +): void { + // More precise regex for HTML tags that handles attributes better + const tagRegex = + /<\/?[a-zA-Z][a-zA-Z0-9:._-]*(?:\s+[a-zA-Z:_][a-zA-Z0-9:._-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^'">\s]+))?)*\s*\/?>/g; + let match; + + while ((match = tagRegex.exec(nodeContent)) !== null) { + const tagStart = nodeStart + match.index; + const tagEnd = tagStart + match[0].length; + + boundaries.push({ + start: tagStart, + end: tagEnd, + isolateSelf: true, + }); + } +} + +/** + * Extracts opening and closing JSX tags and adds them as boundaries. + */ +function extractJsxTags( + node: RootContent, + nodeContent: string, + boundaries: Boundary[], +): void { + const nodeStart = node.position!.start.offset; + const nodeEnd = node.position!.end.offset; + + if (!nodeStart || !nodeEnd) { + return; + } + + // Find the opening tag + const openingTagEnd = findOpeningTagEnd(nodeContent); + if (openingTagEnd > 0) { + boundaries.push({ + start: nodeStart, + end: nodeStart + openingTagEnd, + isolateSelf: true, + }); + } + + // Find the closing tag + const closingTagStart = findClosingTagStart(nodeContent); + if (closingTagStart > 0 && closingTagStart < nodeContent.length) { + boundaries.push({ + start: nodeStart + closingTagStart, + end: nodeEnd, + isolateSelf: true, + }); + } +} + +/** + * Finds all section boundaries in the AST. + */ +function findSectionBoundaries(ast: Root, content: string): Boundary[] { + const boundaries: Boundary[] = []; + + // Use a Map to cache node positions for faster lookups + const nodePositions = new Map(); + + // Pre-process nodes to cache their positions + traverseMdast(ast, (node: any) => { + if ( + node.position && + typeof node.position.start.offset === "number" && + typeof node.position.end.offset === "number" + ) { + nodePositions.set(node, { + start: node.position.start.offset, + end: node.position.end.offset, + }); + } + }); + + for (const child of ast.children) { + const position = nodePositions.get(child); + if (!position) continue; + + if (child.type === "heading") { + // Heading marks the beginning of a new section including itself + boundaries.push({ + start: position.start, + end: position.end, + isolateSelf: false, + }); + } else if (isJsxOrHtml(child)) { + // Process JSX/HTML nodes to extract tags as separate sections + processJsxNode(child, content, boundaries); + } + } + + // Sort boundaries by start position + return boundaries.sort((a, b) => a.start - b.start); +} + +/** + * Creates sections from the identified boundaries. + * Optimized to reduce unnecessary string operations. + */ +function createSectionsFromBoundaries( + boundaries: Boundary[], + content: string, +): Record { + const sections: Record = {}; + + // Early return for empty content or no boundaries + if (!content.trim() || boundaries.length === 0) { + const trimmed = content.trim(); + if (trimmed) { + sections["0"] = trimmed; + } + return sections; + } + + let idx = 0; + let lastEnd = 0; + + // Pre-allocate array with estimated capacity + const sectionsArray: string[] = []; + + // Process each boundary and the content between boundaries + for (let i = 0; i < boundaries.length; i++) { + const { start, end, isolateSelf } = boundaries[i]; + + // Capture content before this boundary if any + if (start > lastEnd) { + const segment = content.slice(lastEnd, start).trim(); + if (segment) { + sectionsArray.push(segment); + } + } + + if (isolateSelf) { + // Extract the boundary itself as a section + const segment = content.slice(start, end).trim(); + if (segment) { + sectionsArray.push(segment); + } + lastEnd = end; + } else { + // For non-isolated boundaries (like headings), include them with following content + const nextStart = + i + 1 < boundaries.length ? boundaries[i + 1].start : content.length; + const segment = content.slice(start, nextStart).trim(); + if (segment) { + sectionsArray.push(segment); + } + lastEnd = nextStart; + } + } + + // Capture any content after the last boundary + if (lastEnd < content.length) { + const segment = content.slice(lastEnd).trim(); + if (segment) { + sectionsArray.push(segment); + } + } + + // Convert array to object with sequential keys + sectionsArray.forEach((section, index) => { + sections[index.toString()] = section; + }); + + return sections; +} diff --git a/packages/cli/src/cli/loaders/mdx2/sections-split-2.ts b/packages/cli/src/cli/loaders/mdx2/sections-split-2.ts new file mode 100644 index 000000000..9578a1489 --- /dev/null +++ b/packages/cli/src/cli/loaders/mdx2/sections-split-2.ts @@ -0,0 +1,39 @@ +import { ILoader } from "../_types"; +import { createLoader } from "../_utils"; +import { PlaceholderedMdx, SectionedMdx } from "./_types"; +import _ from "lodash"; + +export default function createMdxSectionsSplit2Loader(): ILoader< + PlaceholderedMdx, + SectionedMdx +> { + return createLoader({ + async pull(locale, input) { + const sections = _.chain(input.content) + .split("\n\n") + .filter(Boolean) + .map((section, index) => [index, section]) + .fromPairs() + .value(); + + const result: SectionedMdx = { + frontmatter: input.frontmatter, + sections, + }; + + return result; + }, + + async push(locale, data, originalInput, _originalLocale, pullInput) { + const content = _.chain(data.sections).values().join("\n\n").value(); + + const result: PlaceholderedMdx = { + frontmatter: data.frontmatter, + codePlaceholders: pullInput?.codePlaceholders || {}, + content, + }; + + return result; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/mjml.spec.ts b/packages/cli/src/cli/loaders/mjml.spec.ts new file mode 100644 index 000000000..cdb29b347 --- /dev/null +++ b/packages/cli/src/cli/loaders/mjml.spec.ts @@ -0,0 +1,836 @@ +import { describe, test, expect } from "vitest"; +import createMjmlLoader from "./mjml"; + +// Helper function to find a key by matching content or partial path +function findKeyByContent(result: Record, contentOrPath: string): string | undefined { + // First try exact match + if (result[contentOrPath]) { + return contentOrPath; + } + + // Try to find by content value + const byValue = Object.keys(result).find(key => result[key] === contentOrPath); + if (byValue) return byValue; + + // Try to find by partial path (e.g., "mj-text" finds first mj-text key) + const byPartialPath = Object.keys(result).find(key => key.includes(contentOrPath)); + if (byPartialPath) return byPartialPath; + + return undefined; +} + +// Helper to find key by path pattern and element type +function findKeyByPattern(result: Record, pattern: string, elementType: string): string | undefined { + // Match keys that contain the pattern and element type + return Object.keys(result).find(key => + key.includes(pattern) && key.includes(elementType) + ); +} + +describe("mjml loader", () => { + test("should extract text from mj-text component", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + Hello World + + + +`; + + const result = await loader.pull("en", input); + + // Find the mj-text key (now uses content-based hash) + const textKey = findKeyByContent(result, "Hello World"); + expect(textKey).toBeDefined(); + expect(result[textKey!]).toBe("Hello World"); + }); + + test("content-based hash keys should be deterministic across multiple pulls", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + Hello World + Click Me + + + +`; + + // Pull twice and compare keys + const result1 = await loader.pull("en", input); + const result2 = await loader.pull("en", input); + + console.log("First pull keys:", Object.keys(result1)); + console.log("Second pull keys:", Object.keys(result2)); + + // Keys should be identical + expect(Object.keys(result1).sort()).toEqual(Object.keys(result2).sort()); + // Values should be identical + expect(result1).toEqual(result2); + }); + + test("should extract text from mj-button component", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + Click Me + + + +`; + + const result = await loader.pull("en", input); + + // Content-based hashing - find the actual key + const buttonKeys = Object.keys(result).filter(k => k.includes('mj-button')); + expect(buttonKeys.length).toBe(1); + expect(result[buttonKeys[0]]).toBe("Click Me"); + }); + + test("should extract alt attribute from mj-image component", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + + + +`; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-image/0#alt"]).toBe("A beautiful image"); + }); + + test("should extract title attribute from mj-button", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + Click + + + +`; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-button/0"]).toBe("Click"); + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-button/0#title"]).toBe("Hover text"); + }); + + test("should extract multiple text components", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + First paragraph + Second paragraph + + + +`; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0"]).toBe("First paragraph"); + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/1"]).toBe("Second paragraph"); + }); + + test("should extract from nested sections and columns", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + Column 1 + + + Column 2 + + + +`; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0"]).toBe("Column 1"); + expect(result["mjml/mj-body/0/mj-section/0/mj-column/1/mj-text/0"]).toBe("Column 2"); + }); + + test("should push translated content back to MJML", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + Hello World + + + +`; + + await loader.pull("en", input); + + const translations = { + "mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0": "Hola Mundo", + }; + + const output = await loader.push("es", translations, input); + + expect(output).toContain("Hola Mundo"); + expect(output).toContain(""); + expect(output).toContain(""); + }); + + test("should push translated attributes back to MJML", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + + + +`; + + await loader.pull("en", input); + + const translations = { + "mjml/mj-body/0/mj-section/0/mj-column/0/mj-image/0#alt": "Una imagen hermosa", + }; + + const output = await loader.push("es", translations, input); + + expect(output).toContain("Una imagen hermosa"); + expect(output).toContain('alt="Una imagen hermosa"'); + }); + + test("should handle mj-title component", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + Email Title + + + + + Content + + + +`; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-head/0/mj-title/0"]).toBe("Email Title"); + }); + + test("should handle mj-preview component", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + This is the preview text + + + + + Content + + + +`; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-head/0/mj-preview/0"]).toBe("This is the preview text"); + }); + + test("should handle empty text content", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + + + +`; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0"]).toBeUndefined(); + }); + + test("should extract text from HTML elements inside mj-table", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + + +

    First steps

    +

    + How to get started? + Read the guide + and learn more. +

    + + +
    +
    +
    +
    +
    `; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-table/0/tr/0/td/0/p/0"]).toBe("First steps"); + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-table/0/tr/0/td/0/p/1"]).toContain("How to get started?"); + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-table/0/tr/0/td/0/p/1"]).toContain('Read the guide'); + }); + + test("should translate HTML elements inside mj-table", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + + +

    First steps

    + + +
    +
    +
    +
    +
    `; + + await loader.pull("en", input); + + const translations = { + "mjml/mj-body/0/mj-section/0/mj-column/0/mj-table/0/tr/0/td/0/p/0": "Primeros pasos", + }; + + const output = await loader.push("es", translations, input); + + expect(output).toContain("Primeros pasos"); + expect(output).not.toContain("First steps"); + }); + + test("should handle whitespace-only text content", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + + + +`; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0"]).toBeUndefined(); + }); + + test("should preserve trailing whitespace in mixed HTML content", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + Get started with GitProtect.io + + + + +`; + + await loader.pull("en", input); + + const translations = { + "mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0": "Comience con GitProtect.io", + }; + + const output = await loader.push("es", translations, input); + + // Should have space between "con" and "" + expect(output).toContain("Comience con GitProtect.io"); + // Should NOT have missing space (this would be wrong) + expect(output).not.toContain("Comience con"); + }); + + test("should preserve Razor variables in text content", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + Hello @Model.Name + + + +`; + + await loader.pull("en", input); + + const translations = { + "mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0": "Hola @Model.Name", + }; + + const output = await loader.push("es", translations, input); + + expect(output).toContain("Hola @Model.Name"); + // Verify variable name not translated + expect(output).not.toContain("@Modelo.Nombre"); + }); + + test("should not extract content from mj-raw blocks", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + @foreach (var x in Model.Items) { + Item text + } + + + +`; + + const result = await loader.pull("en", input); + + // Should NOT extract mj-raw content + const allValues = Object.values(result); + expect(allValues.find((v) => v.includes("@foreach"))).toBeUndefined(); + expect(allValues.find((v) => v.includes("}"))).toBeUndefined(); + + // Should extract mj-text content + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0"]).toBe("Item text"); + }); + + test("should preserve Razor expressions in attributes", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + + + +`; + + await loader.pull("en", input); + + const translations = { + "mjml/mj-body/0/mj-section/0/mj-column/0/mj-image/0#alt": "Logo de la empresa", + }; + + const output = await loader.push("es", translations, input); + + // Verify Razor expression preserved in src + expect(output).toContain("@System.Net.WebUtility.HtmlEncode"); + expect(output).toContain("Model.LogoUrl"); + // Verify translated alt attribute + expect(output).toContain("Logo de la empresa"); + }); + + test("should extract from mj-navbar-link components", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + Home + About + + + + +`; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-navbar/0/mj-navbar-link/0"]).toBe("Home"); + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-navbar/0/mj-navbar-link/1"]).toBe("About"); + }); + + test("should extract from mj-accordion components", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + + FAQ 1 + Answer to question 1 + + + + + +`; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-accordion/0/mj-accordion-element/0/mj-accordion-title/0"]).toBe("FAQ 1"); + expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-accordion/0/mj-accordion-element/0/mj-accordion-text/0"]).toBe("Answer to question 1"); + }); + + test("should handle HTML with inline Razor variables", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + Welcome back, @Model.FirstName! + Your last login was @Model.LastLoginDate. + + + + +`; + + await loader.pull("en", input); + + const translations = { + "mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0": + "Bienvenido de nuevo, @Model.FirstName! Tu último inicio de sesión fue @Model.LastLoginDate.", + }; + + const output = await loader.push("es", translations, input); + + expect(output).toContain("Bienvenido de nuevo"); + expect(output).toContain("@Model.FirstName"); + expect(output).toContain("@Model.LastLoginDate"); + }); + + test("should handle mj-wrapper structure", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + Wrapped content + + + + +`; + + const result = await loader.pull("en", input); + + expect(result["mjml/mj-body/0/mj-wrapper/0/mj-section/0/mj-column/0/mj-text/0"]).toBe("Wrapped content"); + }); + + test("should preserve space between text and inline tag (real CloudServiceCreated bug)", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const input = ` + + + + + + Get started with GitProtect.io by Xopero ONE + + + + +`; + + const pulled = await loader.pull("en", input); + + // Translator translates, keeping the HTML structure + const translations = { + "mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0": "Comience con GitProtect.io by Xopero ONE", + }; + + const output = await loader.push("es", translations, input); + + // Critical: space must be preserved before + expect(output).toContain("Comience con "); + // This would be the bug: missing space + expect(output).not.toContain("Comience con"); + + // Verify the full text is correct + expect(output).toContain("Comience con GitProtect.io by Xopero ONE"); + }); + + test("should preserve trailing space when span ends with space (exact CloudServiceCreated structure)", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + // This is the EXACT structure from CloudServiceCreated.mjml line 148-149 + const input = ` + + + + + + Get started with GitProtect.io by Xopero ONE + + + + +`; + + const pulled = await loader.pull("en", input); + const key = "mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0"; + + // Check what was extracted + console.log("Extracted:", JSON.stringify(pulled[key])); + + // The extracted value should preserve the space + expect(pulled[key]).toContain("Get started with "); + + // Translator provides Spanish without wrapper + const translations = { + [key]: "Comience con GitProtect.io by Xopero ONE", + }; + + const output = await loader.push("es", translations, input); + + // The output should have space before + // Either as: Comience con + // Or as: Comience con + expect(output).toMatch(/Comience con (<\/span>)?/); + expect(output).not.toContain("Comience con"); + }); + + test("should handle empty input string in pull", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const result = await loader.pull("en", ""); + + expect(result).toEqual({}); + }); + + test("should handle whitespace-only input in pull", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const result = await loader.pull("en", " \n\t "); + + expect(result).toEqual({}); + }); + + test("should handle empty input string in push", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + // Need to pull first to initialize state + await loader.pull("en", ""); + const output = await loader.push("es", {}, ""); + + expect(output).toBe(""); + }); + + test("should handle whitespace-only input in push", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + // Need to pull first to initialize state + await loader.pull("en", " \n\t "); + const output = await loader.push("es", {}, " \n\t "); + + expect(output).toBe(" \n\t "); + }); + + test("should not add XML declaration to output", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const inputWithoutDeclaration = ` + + + + Hello World + + + +`; + + await loader.pull("en", inputWithoutDeclaration); + + const translations = { + "mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0": "Hola Mundo", + }; + + const output = await loader.push("es", translations, inputWithoutDeclaration); + + // Should NOT start with XML declaration + expect(output).not.toMatch(/^<\?xml/); + // Should start with + expect(output.trim()).toMatch(/^/); + expect(output).toContain("Hola Mundo"); + }); + + test("should handle input with XML declaration and not duplicate it", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + const inputWithDeclaration = ` + + + + + Hello World + + + +`; + + await loader.pull("en", inputWithDeclaration); + + const translations = { + "mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0": "Hola Mundo", + }; + + const output = await loader.push("es", translations, inputWithDeclaration); + + // Should not duplicate XML declaration + const declarationMatches = output.match(/<\?xml/g); + expect(declarationMatches).toBeNull(); // No XML declaration in output + expect(output).toContain("Hola Mundo"); + }); + + test("should match structure of source file without XML declaration", async () => { + const loader = createMjmlLoader(); + loader.setDefaultLocale("en"); + + // Source file format (en-US) + const sourceInput = ` + + + + + + + +`; + + await loader.pull("en", sourceInput); + + const translations = { + "mjml/mj-body/0/mj-section/0/mj-column/0/mj-image/0#alt": "Logo de GitProtect", + }; + + const output = await loader.push("es", translations, sourceInput); + + // Generated file should match source structure + expect(output.trim()).toMatch(/^/); + expect(output).not.toMatch(/^<\?xml/); + expect(output).toContain("Logo de GitProtect"); + expect(output).toContain(""); + expect(output).toContain(""); + }); +}); diff --git a/packages/cli/src/cli/loaders/mjml.ts b/packages/cli/src/cli/loaders/mjml.ts new file mode 100644 index 000000000..6f1155339 --- /dev/null +++ b/packages/cli/src/cli/loaders/mjml.ts @@ -0,0 +1,343 @@ +import { parseStringPromise, Builder } from "xml2js"; +import * as htmlparser2 from "htmlparser2"; +import { DomHandler } from "domhandler"; +import * as DomSerializer from "dom-serializer"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +const LOCALIZABLE_COMPONENTS = [ + "mj-text", + "mj-button", + "mj-title", + "mj-preview", + "mj-navbar-link", + "mj-accordion-title", + "mj-accordion-text", + "p", + "h1", "h2", "h3", "h4", "h5", "h6", + "li", +]; + +const LOCALIZABLE_ATTRIBUTES: Record = { + "mj-image": ["alt", "title"], + "mj-button": ["title", "aria-label"], + "mj-social-element": ["title", "alt"], + "img": ["alt", "title"], + "a": ["title", "aria-label"], +}; + +export default function createMjmlLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, input) { + const result: Record = {}; + + // Handle empty input + if (!input || input.trim() === "") { + return result; + } + + try { + const parsed = await parseStringPromise(input, { + explicitArray: true, + explicitChildren: true, + preserveChildrenOrder: true, + charsAsChildren: true, + includeWhiteChars: true, + mergeAttrs: false, + trim: false, + attrkey: "$", + charkey: "_", + childkey: "$$", + }); + + if (!parsed || typeof parsed !== "object") { + console.error("Failed to parse MJML: invalid parsed structure"); + return result; + } + + const rootKey = Object.keys(parsed).find(key => !key.startsWith("_") && !key.startsWith("$")); + const rootNode = rootKey ? parsed[rootKey] : parsed; + const rootPath = rootNode["#name"] || rootKey || ""; + + traverse(rootNode, (node, path, componentName) => { + if (typeof node !== "object") return; + + const localizableAttrs = LOCALIZABLE_ATTRIBUTES[componentName]; + if (localizableAttrs && node.$) { + localizableAttrs.forEach((attr) => { + const attrValue = node.$[attr]; + if (attrValue) { + result[`${path}#${attr}`] = attrValue; + } + }); + } + + if (LOCALIZABLE_COMPONENTS.includes(componentName)) { + const innerHTML = getInnerHTML(node); + if (innerHTML) { + result[path] = innerHTML; + return "SKIP_CHILDREN"; + } + } + + return undefined; + }, rootPath); + } catch (error) { + console.error("Failed to parse MJML:", error); + } + + return result; + }, + + async push(locale, data, originalInput) { + // Handle empty input + if (!originalInput || originalInput.trim() === "") { + return originalInput || ""; + } + + try { + const parsed = await parseStringPromise(originalInput, { + explicitArray: true, + explicitChildren: true, + preserveChildrenOrder: true, + charsAsChildren: true, + includeWhiteChars: true, + mergeAttrs: false, + trim: false, + attrkey: "$", + charkey: "_", + childkey: "$$", + }); + + if (!parsed || typeof parsed !== "object") { + console.error("Failed to parse MJML for push: invalid parsed structure"); + return originalInput || ""; + } + + const rootKey = Object.keys(parsed).find(key => !key.startsWith("_") && !key.startsWith("$")); + const rootNode = rootKey ? parsed[rootKey] : parsed; + const rootPath = rootNode["#name"] || rootKey || ""; + + traverse(rootNode, (node, path, componentName) => { + if (typeof node !== "object") return; + + const localizableAttrs = LOCALIZABLE_ATTRIBUTES[componentName]; + if (localizableAttrs && node.$) { + localizableAttrs.forEach((attr) => { + const attrKey = `${path}#${attr}`; + if (data[attrKey] !== undefined) { + node.$[attr] = data[attrKey]; + } + }); + } + + if (LOCALIZABLE_COMPONENTS.includes(componentName) && data[path]) { + setInnerHTML(node, data[path]); + return "SKIP_CHILDREN"; + } + + return undefined; + }, rootPath); + + return serializeMjml(parsed); + } catch (error) { + console.error("Failed to build MJML:", error); + return ""; + } + }, + }); +} + +function traverse( + node: any, + visitor: (node: any, path: string, componentName: string) => string | undefined, + path: string = "", +) { + if (!node || typeof node !== "object") { + return; + } + + const children = node.$$; + if (!Array.isArray(children)) { + return; + } + + const elementCounts = new Map(); + + children.forEach((child: any) => { + const elementName = child["#name"]; + + if (!elementName || elementName.startsWith("__")) { + return; + } + + const currentIndex = elementCounts.get(elementName) || 0; + elementCounts.set(elementName, currentIndex + 1); + + const currentPath = path + ? `${path}/${elementName}/${currentIndex}` + : `${elementName}/${currentIndex}`; + + const result = visitor(child, currentPath, elementName); + + if (result !== "SKIP_CHILDREN") { + traverse(child, visitor, currentPath); + } + }); +} + +function getInnerHTML(node: any): string | null { + if (!node.$$ || !Array.isArray(node.$$)) { + return null; + } + + let html = ""; + node.$$.forEach((child: any) => { + html += serializeXmlNode(child); + }); + + return html.trim() || null; +} + +function setInnerHTML(node: any, htmlContent: string): void { + const handler = new DomHandler(); + const parser = new htmlparser2.Parser(handler); + parser.write(htmlContent); + parser.end(); + + const newChildren: any[] = []; + + for (const domNode of handler.dom) { + const xmlNode = convertDomToXmlNode(domNode); + if (xmlNode) { + newChildren.push(xmlNode); + } + } + + node.$$ = newChildren; + node._ = htmlContent; +} + +function serializeXmlNode(node: any): string { + const name = node["#name"]; + + if (name === "__text__") { + return node._ || ""; + } + + if (name === "__cdata") { + return ``; + } + + if (!name || name.startsWith("__")) { + return ""; + } + + const attrs = node.$ || {}; + const attrString = Object.entries(attrs) + .map(([key, value]) => ` ${key}="${escapeAttributeValue(String(value))}"`) + .join(""); + + const children = node.$$ || []; + if (children.length === 0) { + const textContent = node._ || ""; + if (textContent) { + return `<${name}${attrString}>${textContent}`; + } + return `<${name}${attrString} />`; + } + + const childContent = children.map(serializeXmlNode).join(""); + return `<${name}${attrString}>${childContent}`; +} + +function convertDomToXmlNode(domNode: any): any { + if (domNode.type === "text") { + return { + "#name": "__text__", + "_": domNode.data, + }; + } + + if (domNode.type === "tag") { + const xmlNode: any = { + "#name": domNode.name, + "$": domNode.attribs || {}, + "$$": [], + }; + + if (domNode.children && domNode.children.length > 0) { + for (const child of domNode.children) { + const xmlChild = convertDomToXmlNode(child); + if (xmlChild) { + xmlNode.$$.push(xmlChild); + } + } + } + + return xmlNode; + } + + return null; +} + +function serializeMjml(parsed: any): string { + const rootKey = Object.keys(parsed).find(key => !key.startsWith("_") && !key.startsWith("$")); + const rootNode = rootKey ? parsed[rootKey] : parsed; + + const body = serializeElement(rootNode); + + // Don't add XML declaration - xml2js already preserves it in the parsed object + // or it will be added by the consumer if needed + return body; +} + +function serializeElement(node: any, indent: string = ""): string { + if (!node) { + return ""; + } + + const name = node["#name"] ?? "mjml"; + + if (name === "__text__") { + return node._ ?? ""; + } + + if (name === "__cdata") { + return ``; + } + + if (name === "__comment__") { + return ``; + } + + const attributes = node.$ ?? {}; + const attrString = Object.entries(attributes) + .map(([key, value]) => ` ${key}="${escapeAttributeValue(String(value))}"`) + .join(""); + + const children = Array.isArray(node.$$) ? node.$$ : []; + + if (children.length === 0) { + const textContent = node._ ?? ""; + if (textContent) { + return `${indent}<${name}${attrString}>${textContent}`; + } + return `${indent}<${name}${attrString} />`; + } + + const childContent = children.map((child: any) => serializeElement(child, indent)).join(""); + return `${indent}<${name}${attrString}>${childContent}`; +} + +function escapeAttributeValue(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">") + .replace(/'/g, "'"); +} diff --git a/packages/cli/src/cli/loaders/passthrough.ts b/packages/cli/src/cli/loaders/passthrough.ts new file mode 100644 index 000000000..43e3b3ff0 --- /dev/null +++ b/packages/cli/src/cli/loaders/passthrough.ts @@ -0,0 +1,13 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createPassThroughLoader( + state: any, +): ILoader { + return createLoader({ + pull: async () => state.data, + push: async (locale, data) => { + state.data = data; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/php.ts b/packages/cli/src/cli/loaders/php.ts new file mode 100644 index 000000000..128846199 --- /dev/null +++ b/packages/cli/src/cli/loaders/php.ts @@ -0,0 +1,93 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import { fromString } from "php-array-reader"; + +export default function createPhpLoader(): ILoader< + string, + Record +> { + return createLoader({ + pull: async (locale, input) => { + try { + const output = fromString(input); + return output; + } catch (error) { + throw new Error(`Error parsing PHP file for locale ${locale}`); + } + }, + push: async (locale, data, originalInput) => { + const output = toPhpString(data, originalInput); + return output; + }, + }); +} + +function toPhpString( + data: Record, + originalPhpString: string | null, +) { + const defaultFilePrefix = " + `${indent(indentLevel)}${toPhpArray( + value, + shortSyntax, + indentLevel + 1, + )}`, + ) + .join(",\n")}\n${indent(indentLevel - 1)}${arrayEnd}`; + } + + const output = `${arrayStart}\n${Object.entries(data) + .map( + ([key, value]) => + `${indent(indentLevel)}'${key}' => ${toPhpArray( + value, + shortSyntax, + indentLevel + 1, + )}`, + ) + .join(",\n")}\n${indent(indentLevel - 1)}${arrayEnd}`; + return output; +} + +function indent(level: number) { + return " ".repeat(level); +} + +function escapePhpString(str: string) { + return str + .replaceAll("\\", "\\\\") + .replaceAll("'", "\\'") + .replaceAll("\r", "\\r") + .replaceAll("\n", "\\n") + .replaceAll("\t", "\\t"); +} diff --git a/packages/cli/src/cli/loaders/plutil-json-loader.ts b/packages/cli/src/cli/loaders/plutil-json-loader.ts new file mode 100644 index 000000000..d7b2f126a --- /dev/null +++ b/packages/cli/src/cli/loaders/plutil-json-loader.ts @@ -0,0 +1,17 @@ +import { formatPlutilStyle } from "../utils/plutil-formatter"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createPlutilJsonTextLoader(): ILoader { + return createLoader({ + async pull(locale, data) { + return data; + }, + async push(locale, data, originalInput) { + const jsonData = JSON.parse(data); + const result = formatPlutilStyle(jsonData, originalInput || ""); + + return result; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/po/_types.ts b/packages/cli/src/cli/loaders/po/_types.ts new file mode 100644 index 000000000..9271b0fea --- /dev/null +++ b/packages/cli/src/cli/loaders/po/_types.ts @@ -0,0 +1,8 @@ +export type PoTranslationEntry = { + id: string; + value: string; + pluralValue?: string; + context?: string; + metadata?: Record; + flags?: string[]; +}; diff --git a/packages/cli/src/cli/loaders/po/index.spec.ts b/packages/cli/src/cli/loaders/po/index.spec.ts new file mode 100644 index 000000000..febfc351e --- /dev/null +++ b/packages/cli/src/cli/loaders/po/index.spec.ts @@ -0,0 +1,471 @@ +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 , 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 \n" + "Language-Team: LANGUAGE \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); + }); + + it("should preserve existing Language header when pushing translations with metadata in first section", async () => { + const loader = createLoader(); + const sourceInput = `msgid "" +msgstr "" +"Language: en\\n" +"Content-Type: text/plain; charset=utf-8\\n" +"Content-Transfer-Encoding: 8bit\\n" +"X-Generator: next-intl\\n" +#: hello.py:1 +msgid "Hello world" +msgstr "Hello world"`; + + const targetInput = `msgid "" +msgstr "" +"Language: es\\n" +"Content-Type: text/plain; charset=utf-8\\n" +"Content-Transfer-Encoding: 8bit\\n" +"X-Generator: next-intl\\n" +#: hello.py:1 +msgid "Hello world" +msgstr ""`; + + await loader.pull("en", sourceInput); + await loader.pull("es", targetInput); + + const updatedData = { + "Hello world": { + singular: "Hola mundo", + plural: null, + }, + }; + + const result = await loader.push("es", updatedData); + + expect(result).toContain('"Language: es\\n"'); + expect(result).not.toContain('"Language: en\\n"'); + expect(result).toContain('msgstr "Hola mundo"'); + }); + + it("should preserve Language header for each locale when multiple target locales are pulled before push", async () => { + // This test verifies the fix for a bug where pulling multiple target locales + // before pushing would cause the Language header to be overwritten with the + // wrong locale's value (e.g., es.po would get "Language: en" instead of "Language: es") + const loader = createLoader(); + + const sourceInput = `msgid "" +msgstr "" +"Language: en\\n" +"Content-Type: text/plain; charset=utf-8\\n" + +#: hello.py:1 +msgid "Hello" +msgstr "Hello"`; + + const spanishInput = `msgid "" +msgstr "" +"Language: es\\n" +"Content-Type: text/plain; charset=utf-8\\n" + +#: hello.py:1 +msgid "Hello" +msgstr ""`; + + const portugueseInput = `msgid "" +msgstr "" +"Language: pt\\n" +"Content-Type: text/plain; charset=utf-8\\n" + +#: hello.py:1 +msgid "Hello" +msgstr ""`; + + await loader.pull("en", sourceInput); + + // Pull multiple target locales (simulates concurrent processing) + await loader.pull("es", spanishInput); + await loader.pull("pt", portugueseInput); + + const spanishResult = await loader.push("es", { + Hello: { singular: "Hola", plural: null }, + }); + + const portugueseResult = await loader.push("pt", { + Hello: { singular: "Olá", plural: null }, + }); + + expect(spanishResult).toContain('"Language: es\\n"'); + expect(spanishResult).not.toContain('"Language: pt\\n"'); + expect(spanishResult).not.toContain('"Language: en\\n"'); + expect(spanishResult).toContain('msgstr "Hola"'); + + expect(portugueseResult).toContain('"Language: pt\\n"'); + expect(portugueseResult).not.toContain('"Language: es\\n"'); + expect(portugueseResult).not.toContain('"Language: en\\n"'); + expect(portugueseResult).toContain('msgstr "Olá"'); + }); +}); + +function createLoader(params: PoLoaderParams = { multiline: false }) { + return createPoLoader(params).setDefaultLocale("en"); +} diff --git a/packages/cli/src/cli/loaders/po/index.ts b/packages/cli/src/cli/loaders/po/index.ts new file mode 100644 index 000000000..8363a426c --- /dev/null +++ b/packages/cli/src/cli/loaders/po/index.ts @@ -0,0 +1,218 @@ +import _ from "lodash"; +import gettextParser from "gettext-parser"; +import { GetTextTranslations } from "gettext-parser"; +import { ILoader } from "../_types"; +import { composeLoaders, createLoader } from "../_utils"; + +export type PoTranslationEntry = GetTextTranslations["translations"][""]; +export type PoTranslationValue = { singular: string; plural: string | null }; + +export type PoLoaderParams = { + multiline: boolean; +}; + +export default function createPoLoader( + params: PoLoaderParams = { multiline: false }, +): ILoader> { + return composeLoaders(createPoDataLoader(params), createPoContentLoader()); +} + +export function createPoDataLoader( + params: PoLoaderParams, +): ILoader { + return createLoader({ + async pull(locale, input) { + const parsedPo = gettextParser.po.parse(input); + const result: PoTranslationEntry = {}; + const sections = input.split("\n\n").filter(Boolean); + for (const section of sections) { + const sectionPo = gettextParser.po.parse(section); + // skip section with no translations (some sections might have only obsolete entries) + if (Object.keys(sectionPo.translations).length === 0) { + continue; + } + + const contextKey = _.keys(sectionPo.translations)[0]; + const entries = sectionPo.translations[contextKey]; + Object.entries(entries).forEach(([msgid, entry]) => { + if (msgid && entry.msgid) { + const context = entry.msgctxt || ""; + const fullEntry = parsedPo.translations[context]?.[msgid]; + if (fullEntry) { + result[msgid] = fullEntry; + } + } + }); + } + return result; + }, + + async push(locale, data, originalInput, originalLocale, pullInput) { + // Parse each section to maintain structure + const currentSections = pullInput?.split("\n\n").filter(Boolean) || []; + const originalSections = + originalInput?.split("\n\n").filter(Boolean) || []; + const result = originalSections + .map((section) => { + const sectionPo = gettextParser.po.parse(section); + // skip section with no translations (some sections might have only obsolete entries) + if (Object.keys(sectionPo.translations).length === 0) { + return null; + } + + const contextKey = _.keys(sectionPo.translations)[0]; + const entries = sectionPo.translations[contextKey]; + const msgid = Object.keys(entries).find((key) => entries[key].msgid); + + // If the section is empty, try to find it in the current sections + const currentSection = currentSections.find((cs) => { + const csPo = gettextParser.po.parse(cs); + const csContextKey = _.keys(csPo.translations)[0]; + const csEntries = csPo.translations[csContextKey]; + const csMsgid = Object.keys(csEntries).find( + (key) => csEntries[key].msgid, + ); + return csMsgid === msgid; + }); + + if (!msgid) { + if (currentSection) { + return currentSection; + } + return section; + } + if (data[msgid]) { + // Preserve headers from the target file + const headers = currentSection + ? gettextParser.po.parse(currentSection).headers + : sectionPo.headers; + + const updatedPo = _.merge({}, sectionPo, { + headers, + translations: { + [contextKey]: { + [msgid]: { + msgstr: data[msgid].msgstr, + }, + }, + }, + }); + const updatedSection = gettextParser.po + .compile(updatedPo, { foldLength: params.multiline ? 76 : false }) + .toString() + .replace( + [`msgid ""`, `msgstr "Content-Type: text/plain\\n"`].join("\n"), + "", + ) + .trim(); + return preserveCommentOrder(updatedSection, section); + } + return section.trim(); + }) + .filter(Boolean) + .join("\n\n"); + return result; + }, + }); +} + +export function createPoContentLoader(): ILoader< + PoTranslationEntry, + Record +> { + return createLoader({ + async pull(locale, input, initCtx, originalLocale) { + const result = _.chain(input) + .entries() + .filter(([, entry]) => !!entry.msgid) + .map(([, entry]) => { + const singularFallback = + locale === originalLocale ? entry.msgid : null; + const pluralFallback = + locale === originalLocale + ? entry.msgid_plural || entry.msgid + : null; + const hasPlural = entry.msgstr.length > 1; + return [ + entry.msgid, + { + singular: entry.msgstr[0] || singularFallback, + plural: hasPlural + ? ((entry.msgstr[1] || pluralFallback) as string | null) + : null, + }, + ]; + }) + .fromPairs() + .value(); + return result; + }, + async push(locale, data, originalInput) { + const result = _.chain(originalInput) + .entries() + .map(([, entry]) => [ + entry.msgid, + { + ...entry, + msgstr: [ + data[entry.msgid]?.singular, + data[entry.msgid]?.plural || null, + ].filter(Boolean), + }, + ]) + .fromPairs() + .value(); + + return result; + }, + }); +} + +function preserveCommentOrder(section: string, originalSection: string) { + // Split both sections into lines + const sectionLines = section.split(/\r?\n/); + const originalLines = originalSection.split(/\r?\n/); + + // Helper: is a comment line + const isComment = (line: string) => line.trim().startsWith("#"); + + // Extract comment lines and their indices + const sectionComments = sectionLines.filter(isComment); + const nonCommentLines = sectionLines.filter((line) => !isComment(line)); + + // If there are no comments in the section, return the section as is + if (sectionComments.length <= 1) { + return section; + } + + // Extract the order of comment lines from the original section + const originalCommentOrder = originalLines.filter(isComment); + + // Build a map from comment content (trimmed) to the actual comment line in the new section + const commentMap = new Map(); + for (const line of sectionComments) { + commentMap.set(line.trim(), line); + } + + // Reorder comments to match the original order, using the new section's comment lines + const reorderedComments: string[] = []; + for (const orig of originalCommentOrder) { + const trimmed = orig.trim(); + if (commentMap.has(trimmed)) { + reorderedComments.push(commentMap.get(trimmed)!); + commentMap.delete(trimmed); + } + } + // Add any new comments from the new section that weren't in the original, preserving their order + for (const line of sectionComments) { + if (!originalCommentOrder.some((orig) => orig.trim() === line.trim())) { + reorderedComments.push(line); + } + } + + // Reconstruct the section: comments (in order) + non-comment lines (in order) + return [...reorderedComments, ...nonCommentLines] + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} diff --git a/packages/cli/src/cli/loaders/properties.ts b/packages/cli/src/cli/loaders/properties.ts new file mode 100644 index 000000000..c172dcd3d --- /dev/null +++ b/packages/cli/src/cli/loaders/properties.ts @@ -0,0 +1,50 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createPropertiesLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, text) { + const result: Record = {}; + const lines = text.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip empty lines and comments + if (isSkippableLine(trimmed)) { + continue; + } + + const { key, value } = parsePropertyLine(trimmed); + if (key) { + result[key] = value; + } + } + + return result; + }, + async push(locale, payload) { + const result = Object.entries(payload) + .filter(([_, value]) => value != null) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + return result; + }, + }); +} + +function isSkippableLine(line: string): boolean { + return !line || line.startsWith("#"); +} + +function parsePropertyLine(line: string): { key: string; value: string } { + const [key, ...valueParts] = line.split("="); + return { + key: key?.trim() || "", + value: valueParts.join("=").trim(), + }; +} diff --git a/packages/cli/src/cli/loaders/root-key.ts b/packages/cli/src/cli/loaders/root-key.ts new file mode 100644 index 000000000..4768c3e6e --- /dev/null +++ b/packages/cli/src/cli/loaders/root-key.ts @@ -0,0 +1,20 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createRootKeyLoader( + replaceAll = false, +): ILoader, Record> { + return createLoader({ + async pull(locale, input) { + const result = input[locale]; + return result; + }, + async push(locale, data, originalInput) { + const result = { + ...(replaceAll ? {} : originalInput), + [locale]: data, + }; + return result; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/srt.ts b/packages/cli/src/cli/loaders/srt.ts new file mode 100644 index 000000000..0c2933d58 --- /dev/null +++ b/packages/cli/src/cli/loaders/srt.ts @@ -0,0 +1,42 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import srtParser from "srt-parser-2"; + +export default function createSrtLoader(): ILoader< + string, + Record +> { + const parser = new srtParser(); + return createLoader({ + async pull(locale, input) { + const parsed = parser.fromSrt(input) || []; + const result: Record = {}; + + parsed.forEach((entry) => { + const key = `${entry.id}#${entry.startTime}-${entry.endTime}`; + result[key] = entry.text; + }); + + return result; + }, + + async push(locale, payload) { + const output = Object.entries(payload).map(([key, text]) => { + const [id, timeRange] = key.split("#"); + const [startTime, endTime] = timeRange.split("-"); + + return { + id: id, + startTime: startTime, + startSeconds: 0, + endTime: endTime, + endSeconds: 0, + text: text, + }; + }); + + const srtContent = parser.toSrt(output).trim().replace(/\r?\n/g, "\n"); + return srtContent; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/sync.ts b/packages/cli/src/cli/loaders/sync.ts new file mode 100644 index 000000000..73bc85626 --- /dev/null +++ b/packages/cli/src/cli/loaders/sync.ts @@ -0,0 +1,30 @@ +import _ from "lodash"; + +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createSyncLoader(): ILoader< + Record, + Record +> { + return createLoader({ + async pull(locale, input, initCtx, originalLocale, originalInput) { + if (!originalInput) { + return input; + } + + return _.chain(originalInput) + .mapValues((value, key) => input[key]) + .value() as Record; + }, + async push(locale, data, originalInput) { + if (!originalInput) { + return data; + } + + return _.chain(originalInput || {}) + .mapValues((value, key) => data[key]) + .value(); + }, + }); +} diff --git a/packages/cli/src/cli/loaders/text-file.ts b/packages/cli/src/cli/loaders/text-file.ts new file mode 100644 index 000000000..4e676c812 --- /dev/null +++ b/packages/cli/src/cli/loaders/text-file.ts @@ -0,0 +1,73 @@ +import fs from "fs/promises"; +import path from "path"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createTextFileLoader( + pathPattern: string, +): ILoader { + return createLoader({ + async pull(locale) { + const result = await readFileForLocale(pathPattern, locale); + const trimmedResult = result.trim(); + return trimmedResult; + }, + async push(locale, data, _, originalLocale) { + const draftPath = pathPattern.replaceAll("[locale]", locale); + const finalPath = path.resolve(draftPath); + + // Create parent directories if needed + const dirPath = path.dirname(finalPath); + await fs.mkdir(dirPath, { recursive: true }); + + const trimmedPayload = data.trim(); + + // Add trailing new line if needed + const trailingNewLine = await getTrailingNewLine( + pathPattern, + locale, + originalLocale, + ); + let finalPayload = trimmedPayload + trailingNewLine; + + await fs.writeFile(finalPath, finalPayload, { + encoding: "utf-8", + flag: "w", + }); + }, + }); +} + +async function readFileForLocale(pathPattern: string, locale: string) { + const draftPath = pathPattern.replaceAll("[locale]", locale); + const finalPath = path.resolve(draftPath); + const exists = await fs + .access(finalPath) + .then(() => true) + .catch(() => false); + if (!exists) { + return ""; + } + return fs.readFile(finalPath, "utf-8"); +} + +async function getTrailingNewLine( + pathPattern: string, + locale: string, + originalLocale: string, +) { + let templateData = await readFileForLocale(pathPattern, locale); + if (!templateData) { + templateData = await readFileForLocale(pathPattern, originalLocale); + } + + if (templateData?.match(/[\r\n]$/)) { + const ending = templateData?.includes("\r\n") + ? "\r\n" + : templateData?.includes("\r") + ? "\r" + : "\n"; + return ending; + } + return ""; +} diff --git a/packages/cli/src/cli/loaders/twig.spec.ts b/packages/cli/src/cli/loaders/twig.spec.ts new file mode 100644 index 000000000..a2ece4aa3 --- /dev/null +++ b/packages/cli/src/cli/loaders/twig.spec.ts @@ -0,0 +1,430 @@ +import { describe, test, expect } from "vitest"; +import createTwigLoader from "./twig"; + +describe("twig loader", () => { + test("should extract text from paragraph", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    Hello World

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("Hello World"); + }); + + test("should preserve Twig variables in text", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    Welcome {{ user.name }}

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("Welcome {{ user.name }}"); + }); + + test("should extract leaf block with inline HTML", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    Text with emphasis and spans

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("Text with emphasis and spans"); + }); + + test("should preserve Twig control blocks", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `{% set name = 'John' %}

    Hello {{ name }}

    {% if showMore %}

    More content

    {% endif %}`; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("Hello {{ name }}"); + expect(result["1"]).toBe("More content"); + }); + + test("should preserve Twig comments", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `{# This is a comment #}

    Hello World

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("Hello World"); + }); + + test("should extract alt attribute", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `copy icon`; + const result = await loader.pull("en", input); + expect(result["0#alt"]).toBe("copy icon"); + }); + + test("should extract title attribute from multiple elements", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `Link`; + const result = await loader.pull("en", input); + expect(result["0#title"]).toBe("Copy checksum"); + expect(result["0"]).toBe("Copy"); + expect(result["1#title"]).toBe("View details"); + expect(result["1"]).toBe("Link"); + }); + + test("should extract aria-label attribute", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = ``; + const result = await loader.pull("en", input); + expect(result["0#aria-label"]).toBe("Close dialog"); + expect(result["0"]).toBe("X"); + }); + + test("should extract placeholder from input", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = ``; + const result = await loader.pull("en", input); + expect(result["0#placeholder"]).toBe("Enter your name"); + }); + + test("should extract placeholder from textarea", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = ``; + const result = await loader.pull("en", input); + expect(result["0#placeholder"]).toBe("Enter description"); + }); + + test("should extract meta content attribute", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = ``; + const result = await loader.pull("en", input); + expect(result["head/0#content"]).toBe("Site description"); + }); + + test("should handle nested block elements", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    Title

    Paragraph

    `; + const result = await loader.pull("en", input); + expect(result["0/0"]).toBe("Title"); + expect(result["0/1"]).toBe("Paragraph"); + }); + + test("should handle multiple paragraphs", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    First paragraph

    Second paragraph

    Third paragraph

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("First paragraph"); + expect(result["1"]).toBe("Second paragraph"); + expect(result["2"]).toBe("Third paragraph"); + }); + + test("should skip script tags", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    Hello

    `; + const result = await loader.pull("en", input); + expect(result["0/script"]).toBeUndefined(); + expect(result["0/0"]).toBe("Hello"); + }); + + test("should skip style tags", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    Hello

    `; + const result = await loader.pull("en", input); + expect(result["0/style"]).toBeUndefined(); + expect(result["0/0"]).toBe("Hello"); + }); + + test("should handle HTML fragments without html/body tags", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    Content

    `; + const result = await loader.pull("en", input); + expect(result["0/0"]).toBe("Content"); + }); + + test("should handle full HTML document structure", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `Page Title

    Body content

    `; + const result = await loader.pull("en", input); + expect(result["head/0"]).toBe("Page Title"); + expect(result["body/0"]).toBe("Body content"); + }); + + test("should ignore empty elements", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    Content

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBeUndefined(); + expect(result["1"]).toBeUndefined(); + expect(result["2"]).toBe("Content"); + }); + + test("should handle Twig filters", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    {{ data|dateSort[0] }}

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("{{ data|dateSort[0] }}"); + }); + + test("should handle Twig set block", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `{% set up = data|dateSort[0] %}

    {{ up['name'] }}

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("{{ up['name'] }}"); + }); + + test("should handle Twig for loops", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `{% for item in items %}

    {{ item.name }}

    {% endfor %}`; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("{{ item.name }}"); + }); + + test("should handle complex real-world template", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `{% set up = data|dateSort[0] %} +
    +

    {{ up['name'] }}

    + View changelog +

    SHA256 Checksum: {{ value.checksum }}

    + + {% if up['beta'] %} +

    BETA Version. Do not use in production environments

    + {% endif %} +
    `; + const result = await loader.pull("en", input); + + expect(result["0/0"]).toBe("{{ up['name'] }}"); + expect(result["0/1"]).toBe("View changelog"); + expect(result["0/2"]).toBe("SHA256 Checksum: {{ value.checksum }}"); + expect(result["0/3#aria-label"]).toBe("Copy checksum"); + expect(result["0/3#title"]).toBe("Copy checksum"); + expect(result["0/3/0#alt"]).toBe("copy"); + expect(result["0/4"]).toBe("BETA Version. Do not use in production environments"); + }); + + test("should push translations back to template", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const originalInput = `

    Hello World

    `; + await loader.pull("en", originalInput); + + const translated = { "0": "Hola Mundo" }; + const output = await loader.push("es", translated); + + expect(output).toBe(`

    Hola Mundo

    `); + }); + + test("should push translations with Twig variables", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const originalInput = `

    Welcome {{ user.name }}

    `; + await loader.pull("en", originalInput); + + const translated = { "0": "Bienvenido {{ user.name }}" }; + const output = await loader.push("es", translated); + + expect(output).toBe(`

    Bienvenido {{ user.name }}

    `); + }); + + test("should push translations with inline HTML preserved", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const originalInput = `

    Text with emphasis

    `; + await loader.pull("en", originalInput); + + const translated = { "0": "Texto con énfasis" }; + const output = await loader.push("es", translated); + + expect(output).toBe(`

    Texto con énfasis

    `); + }); + + test("should push attribute translations", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const originalInput = ``; + await loader.pull("en", originalInput); + + const translated = { + "0": "Copiar", + "0#title": "Copiar" + }; + const output = await loader.push("es", translated); + + expect(output).toContain('title="Copiar"'); + expect(output).toContain('>Copiar'); + }); + + test("should push translations preserving Twig blocks", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const originalInput = `{% set name = 'John' %}

    Hello {{ name }}

    {% if true %}

    More

    {% endif %}`; + await loader.pull("en", originalInput); + + const translated = { + "0": "Hola {{ name }}", + "1": "Más" + }; + const output = await loader.push("es", translated); + + expect(output).toContain("{% set name = 'John' %}"); + expect(output).toContain("

    Hola {{ name }}

    "); + expect(output).toContain("{% if true %}"); + expect(output).toContain("

    Más

    "); + expect(output).toContain("{% endif %}"); + }); + + test("should set lang attribute on html element", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const originalInput = `Test

    Content

    `; + await loader.pull("en", originalInput); + + const translated = { + "head/0": "Prueba", + "body/0": "Contenido" + }; + const output = await loader.push("es", translated); + + expect(output).toContain(''); + expect(output).toContain("Prueba"); + expect(output).toContain("

    Contenido

    "); + }); + + test("should handle multiple translations in complex structure", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const originalInput = `
    +

    Title

    +

    First paragraph

    +

    Second paragraph

    + +
    `; + await loader.pull("en", originalInput); + + const translated = { + "0/0": "Título", + "0/1": "Primer párrafo", + "0/2": "Segundo párrafo", + "0/3": "Enviar", + "0/3#title": "Haz clic aquí" + }; + const output = await loader.push("es", translated); + + expect(output).toContain("

    Título

    "); + expect(output).toContain("

    Primer párrafo

    "); + expect(output).toContain("

    Segundo párrafo

    "); + expect(output).toContain('title="Haz clic aquí"'); + expect(output).toContain(">Enviar"); + }); + + test("should handle Twig array access syntax", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    {{ up['name'] }} and {{ data['value'] }}

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("{{ up['name'] }} and {{ data['value'] }}"); + }); + + test("should handle mixed Twig and HTML", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `
    + {% if condition %} +

    Show this text with {{ variable }}

    + {% else %} +

    Show that

    + {% endif %} +
    `; + const result = await loader.pull("en", input); + expect(result["0/0"]).toBe("Show this text with {{ variable }}"); + expect(result["0/1"]).toBe("Show that"); + }); + + test("should handle list items", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `
      +
    • First item
    • +
    • Second item
    • +
    • Third item
    • +
    `; + const result = await loader.pull("en", input); + expect(result["0/0"]).toBe("First item"); + expect(result["0/1"]).toBe("Second item"); + expect(result["0/2"]).toBe("Third item"); + }); + + test("should handle table structure", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = ` + + + + + + + + + + + + +
    Header 1Header 2
    Cell 1Cell 2
    `; + const result = await loader.pull("en", input); + expect(result["0/0/0/0"]).toBe("Header 1"); + expect(result["0/0/0/1"]).toBe("Header 2"); + expect(result["0/1/0/0"]).toBe("Cell 1"); + expect(result["0/1/0/1"]).toBe("Cell 2"); + }); + + test("should extract leaf block with phrasing elements", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `
    Just a span
    `; + const result = await loader.pull("en", input); + // The div is a leaf block (no nested blocks), so it's extracted with innerHTML + expect(result["0"]).toBe("Just a span"); + }); + + test("should handle br tags within text", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    Line one
    Line two

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("Line one
    Line two"); + }); + + test("should handle abbr with title attribute", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    The WWW is great

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("The WWW is great"); + expect(result["0/0#title"]).toBeUndefined(); // abbr is inline, title extracted from leaf block + }); + + test("should handle links with title", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `Click here`; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("Click here"); + expect(result["0#title"]).toBe("Learn more"); + }); + + test("should preserve whitespace structure", async () => { + const loader = createTwigLoader(); + loader.setDefaultLocale("en"); + const input = `

    Text with spaces

    `; + const result = await loader.pull("en", input); + expect(result["0"]).toBe("Text with spaces"); + }); +}); diff --git a/packages/cli/src/cli/loaders/twig.ts b/packages/cli/src/cli/loaders/twig.ts new file mode 100644 index 000000000..fef043c4e --- /dev/null +++ b/packages/cli/src/cli/loaders/twig.ts @@ -0,0 +1,485 @@ +import * as htmlparser2 from "htmlparser2"; +import { DomHandler, Element, AnyNode, Text } from "domhandler"; +import * as domutils from "domutils"; +import * as DomSerializer from "dom-serializer"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createTwigLoader(): ILoader< + string, + Record +> { + + // Based on WHATWG HTML spec: https://html.spec.whatwg.org/multipage/indices.html + // Phrasing content = inline elements that should be preserved within text + const PHRASING_ELEMENTS = new Set([ + // Text-level semantics + "a", + "abbr", + "b", + "bdi", + "bdo", + "br", + "cite", + "code", + "data", + "dfn", + "em", + "i", + "kbd", + "mark", + "q", + "ruby", + "s", + "samp", + "small", + "span", + "strong", + "sub", + "sup", + "time", + "u", + "var", + "wbr", + // Media + "audio", + "img", + "video", + "picture", + // Interactive + "button", + "input", + "label", + "select", + "textarea", + // Embedded + "canvas", + "iframe", + "object", + "svg", + "math", + // Other + "del", + "ins", + "map", + "area", + ]); + + // Block elements create translation boundaries + const BLOCK_ELEMENTS = new Set([ + "div", + "p", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "ul", + "ol", + "li", + "dl", + "dt", + "dd", + "blockquote", + "pre", + "article", + "aside", + "nav", + "section", + "header", + "footer", + "main", + "figure", + "figcaption", + "table", + "thead", + "tbody", + "tfoot", + "tr", + "td", + "th", + "caption", + "form", + "fieldset", + "legend", + "details", + "summary", + "address", + "hr", + "search", + "dialog", + "noscript", + "title", + ]); + + // Tags whose content should never be translated + const UNLOCALIZABLE_TAGS = new Set(["script", "style"]); + + // Attributes that should be translated separately + const LOCALIZABLE_ATTRIBUTES: Record = { + meta: ["content"], + img: ["alt", "title"], + input: ["placeholder", "title", "aria-label"], + textarea: ["placeholder", "title", "aria-label"], + button: ["title", "aria-label"], + a: ["title", "aria-label"], + abbr: ["title"], + link: ["title"], + }; + + // Preprocess Twig: Replace Twig control blocks with placeholders + function preprocessTwig(input: string): { processed: string; twigBlocks: string[] } { + const twigBlocks: string[] = []; + let counter = 0; + + // Replace {% ... %} blocks (but NOT {{ ... }}) + // {{ }} expressions are kept as-is - they're part of the translatable content + const processed = input.replace(/\{%[\s\S]*?%\}/g, (match) => { + twigBlocks.push(match); + return `__TWIG_BLOCK_${counter++}__`; + }); + + // Also replace {# ... #} comments + return { + processed: processed.replace(/\{#[\s\S]*?#\}/g, (match) => { + twigBlocks.push(match); + return `__TWIG_BLOCK_${counter++}__`; + }), + twigBlocks + }; + } + + // Postprocess: Restore Twig blocks from placeholders + function postprocessTwig(text: string, twigBlocks: string[]): string { + return text.replace(/__TWIG_BLOCK_(\d+)__/g, (_, index) => { + return twigBlocks[parseInt(index, 10)] || ""; + }); + } + + return createLoader({ + async pull(locale, input) { + const result: Record = {}; + + // Preprocess Twig syntax + const { processed, twigBlocks } = preprocessTwig(input); + + // Parse HTML with htmlparser2 (preserves structure, no foster parenting) + const handler = new DomHandler(); + const parser = new htmlparser2.Parser(handler, { + lowerCaseTags: false, + lowerCaseAttributeNames: false, + }); + parser.write(processed); + parser.end(); + + const dom = handler.dom; + + // Check if element is inside an unlocalizable tag + function isInsideUnlocalizableTag(element: Element): boolean { + let current = element.parent; + while (current && current.type === "tag") { + if (UNLOCALIZABLE_TAGS.has((current as Element).name.toLowerCase())) { + return true; + } + current = current.parent; + } + return false; + } + + // Check if element contains any translatable text (not just whitespace) + function hasTranslatableContent(element: Element): boolean { + const text = domutils.textContent(element); + return text.trim().length > 0; + } + + // Check if element is a "leaf" block (contains text with inline elements, not nested blocks) + function isLeafBlock(element: Element): boolean { + // A leaf block contains text and/or phrasing elements, but no other block elements + const childElements = element.children.filter( + (child): child is Element => child.type === "tag" + ); + for (const child of childElements) { + if (BLOCK_ELEMENTS.has(child.name.toLowerCase())) { + return false; + } + } + return hasTranslatableContent(element); + } + + // Get innerHTML equivalent (serialize children) + function getInnerHTML(element: Element): string { + const html = element.children + .map(child => DomSerializer.default(child, { encodeEntities: false })) + .join(''); + + // Restore Twig blocks in the innerHTML + return postprocessTwig(html, twigBlocks); + } + + // Extract localizable attributes from element + function extractAttributes(element: Element, path: string): void { + const tagName = element.name.toLowerCase(); + const attrs = LOCALIZABLE_ATTRIBUTES[tagName]; + if (!attrs) return; + + for (const attr of attrs) { + const value = element.attribs?.[attr]; + if (value && value.trim()) { + // Restore Twig blocks in attribute values + const restoredValue = postprocessTwig(value.trim(), twigBlocks); + result[`${path}#${attr}`] = restoredValue; + } + } + } + + // Recursively extract translation units from element tree + function extractFromElement( + element: Element, + pathParts: (string | number)[], + ): void { + const path = pathParts.join("/"); + + // Skip if inside unlocalizable tag + if (isInsideUnlocalizableTag(element)) { + return; + } + + // Extract localizable attributes + extractAttributes(element, path); + + const tagName = element.name.toLowerCase(); + + // If this is a leaf block element (contains text but no nested blocks), extract it + if (BLOCK_ELEMENTS.has(tagName) && isLeafBlock(element)) { + // Get innerHTML (preserves inline elements + Twig syntax) + const content = getInnerHTML(element).trim(); + if (content) { + result[path] = content; + } + // Don't recurse into children - innerHTML captures everything + return; + } + + // If this is a standalone phrasing element with text content, extract it + if (PHRASING_ELEMENTS.has(tagName) && hasTranslatableContent(element)) { + const content = getInnerHTML(element).trim(); + if (content) { + result[path] = content; + } + // Don't recurse - innerHTML captures everything + return; + } + + // For structural/container elements, recurse into children + let childIndex = 0; + const childElements = element.children.filter( + (child): child is Element => child.type === "tag" + ); + for (const child of childElements) { + extractFromElement(child, [...pathParts, childIndex++]); + } + } + + // Find head and body elements + const html = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "html", + dom, + true + ) as Element | null; + + if (html) { + const head = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "head", + html.children, + true + ) as Element | null; + + const body = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "body", + html.children, + true + ) as Element | null; + + // Process head children + if (head) { + let headIndex = 0; + const headChildren = head.children.filter( + (child): child is Element => child.type === "tag" + ); + for (const child of headChildren) { + extractFromElement(child, ["head", headIndex++]); + } + } + + // Process body children + if (body) { + let bodyIndex = 0; + const bodyChildren = body.children.filter( + (child): child is Element => child.type === "tag" + ); + for (const child of bodyChildren) { + extractFromElement(child, ["body", bodyIndex++]); + } + } + } else { + // Handle HTML fragments (no element) - process root elements directly + let rootIndex = 0; + const rootElements = dom.filter( + (child): child is Element => child.type === "tag" + ); + for (const child of rootElements) { + extractFromElement(child, [rootIndex++]); + } + } + + return result; + }, + + async push(locale, data, originalInput) { + // Preprocess Twig syntax in original input + const { processed, twigBlocks } = preprocessTwig(originalInput || ""); + + // Parse original HTML + const handler = new DomHandler(); + const parser = new htmlparser2.Parser(handler, { + lowerCaseTags: false, + lowerCaseAttributeNames: false, + }); + parser.write(processed || ""); + parser.end(); + + const dom = handler.dom; + + // Find HTML element and set lang attribute + const html = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "html", + dom, + true + ) as Element | null; + + if (html) { + html.attribs = html.attribs || {}; + html.attribs.lang = locale; + } + + // Helper to traverse child elements by numeric indices + function traverseByIndices( + element: Element | null, + indices: string[] + ): Element | null { + let current = element; + + for (const indexStr of indices) { + if (!current) return null; + + const index = parseInt(indexStr, 10); + const children: Element[] = current.children.filter( + (child): child is Element => child.type === "tag" + ); + + if (index >= children.length) { + return null; // Path doesn't exist + } + + current = children[index]; + } + + return current; + } + + // Resolve path to element in the DOM + function resolvePathToElement(path: string): Element | null { + const parts = path.split("/"); + const [rootTag, ...indices] = parts; + + let current: Element | null = null; + + if (html) { + // Full HTML document with , , + // Find head or body + if (rootTag === "head") { + current = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "head", + html.children, + true + ) as Element | null; + } else if (rootTag === "body") { + current = domutils.findOne( + (elem) => elem.type === "tag" && elem.name.toLowerCase() === "body", + html.children, + true + ) as Element | null; + } + + if (!current) return null; + + // Traverse by indices + return traverseByIndices(current, indices); + } else { + // HTML fragment - no element + // Path is just numeric indices from root + const rootElements = dom.filter( + (child): child is Element => child.type === "tag" + ); + + // First part is the root index + const rootIndex = parseInt(rootTag, 10); + if (rootIndex >= rootElements.length) { + return null; + } + + current = rootElements[rootIndex]; + + // Traverse remaining indices + return traverseByIndices(current, indices); + } + } + + // Apply translations + for (const [path, value] of Object.entries(data)) { + const [nodePath, attribute] = path.split("#"); + + const element = resolvePathToElement(nodePath); + if (!element) { + console.warn(`Path not found in original template: ${nodePath}`); + continue; + } + + if (attribute) { + // Set attribute (value already contains Twig syntax if any) + element.attribs = element.attribs || {}; + element.attribs[attribute] = value; + } else { + // Set innerHTML (parse value as HTML and replace children) + // Value may contain Twig syntax ({{ }}) which we need to preserve + if (value) { + // Preprocess the translated value to handle any Twig blocks + const { processed: processedValue, twigBlocks: valueTwigBlocks } = preprocessTwig(value); + + const valueHandler = new DomHandler(); + const valueParser = new htmlparser2.Parser(valueHandler); + valueParser.write(processedValue); + valueParser.end(); + + element.children = valueHandler.dom; + + // Postprocess the children to restore Twig blocks + element.children.forEach((child: any) => { + if (child.type === "text" && child.data) { + child.data = postprocessTwig(child.data, valueTwigBlocks); + } + }); + } else { + // If value is empty/null, clear children + element.children = []; + } + } + } + + // Serialize back to HTML and restore Twig blocks + const serialized = DomSerializer.default(dom, { encodeEntities: false }); + return postprocessTwig(serialized, twigBlocks); + }, + }); +} diff --git a/packages/cli/src/cli/loaders/txt.ts b/packages/cli/src/cli/loaders/txt.ts new file mode 100644 index 000000000..2d50cccae --- /dev/null +++ b/packages/cli/src/cli/loaders/txt.ts @@ -0,0 +1,29 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createTxtLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, input) { + const result: Record = {}; + + if (input !== undefined && input !== null && input !== "") { + const lines = input.split("\n"); + lines.forEach((line, index) => { + result[String(index + 1)] = line; + }); + } + + return result; + }, + + async push(locale, payload) { + const sortedEntries = Object.entries(payload).sort( + ([a], [b]) => parseInt(a) - parseInt(b), + ); + return sortedEntries.map(([_, value]) => value).join("\n"); + }, + }); +} diff --git a/packages/cli/src/cli/loaders/typescript/cjs-interop.ts b/packages/cli/src/cli/loaders/typescript/cjs-interop.ts new file mode 100644 index 000000000..eefcbf75e --- /dev/null +++ b/packages/cli/src/cli/loaders/typescript/cjs-interop.ts @@ -0,0 +1,68 @@ +/** + * @fileoverview Helpers for CommonJS ⇆ ES Module inter-op quirks. + */ + +/** + * Resolve the actual default export value of a CommonJS module that has been + * imported via an ES-module `import` statement. + * + * Why is this needed? + * ------------------- + * When a package that is published as **CommonJS** (for example, `@babel/traverse`) + * is imported inside native **ESM** code (or via a bundler in ESM mode) the + * runtime value you receive is not consistent across environments: + * + * • **Node.js** (native ESM) wraps the CJS module in an object like + * `{ default: moduleExports, …namedReExports }`. + * • **esbuild / Vite / Vitest** may decide to mimic TypeScript's + * `esModuleInterop` behaviour and give you `moduleExports` directly. + * • Other tools can produce yet different shapes. + * + * If you blindly assume one shape, you will hit runtime errors such as + * `TypeError: traverse is not a function` when the actual function lives on the + * `.default` property — or the opposite, depending on the environment. + * + * This helper inspects the imported value at runtime and returns what looks like + * the real default export regardless of how it was wrapped. It hides the ugly + * `typeof mod === "function" ? … : mod.default` branching behind a single call + * site. + * + * Example + * ------- + * ```ts + * import traverseModule from "@babel/traverse"; + * import { resolveCjsExport } from "../utils/cjs-interop"; + * + * const traverse = resolveCjsExport( + * traverseModule, + * "@babel/traverse", + * ); + * ``` + * + * @template T Expected type of the resolved export. + * @param mod The runtime value returned by the `import` statement. + * @param name Friendly name of the module (for error messages). + * @returns The resolved default export value. + */ +export function resolveCjsExport(mod: T, name: string = "module"): T { + // If the module value itself is callable or clearly not an object, assume it's + // already the export we want (covers most bundler scenarios). + if (typeof mod === "function" || typeof mod !== "object" || mod === null) { + return mod as T; + } + + // Otherwise, look for a `.default` property which is common in Node's CJS->ESM + // wrapper as well as in Babel's `interopRequireDefault` helpers. + if ("default" in mod && typeof mod.default !== "undefined") { + return mod.default as T; + } + + // Give up: log the mysterious shape and throw to fail fast. + /* eslint-disable no-console */ + console.error( + `[resolveCjsExport] Unable to determine default export for ${name}.`, + "Received value:", + mod, + ); + throw new Error(`Failed to resolve default export for ${name}.`); +} diff --git a/packages/cli/src/cli/loaders/typescript/index.spec.ts b/packages/cli/src/cli/loaders/typescript/index.spec.ts new file mode 100644 index 000000000..29ece161a --- /dev/null +++ b/packages/cli/src/cli/loaders/typescript/index.spec.ts @@ -0,0 +1,346 @@ +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 +}); diff --git a/packages/cli/src/cli/loaders/typescript/index.ts b/packages/cli/src/cli/loaders/typescript/index.ts new file mode 100644 index 000000000..236d6540a --- /dev/null +++ b/packages/cli/src/cli/loaders/typescript/index.ts @@ -0,0 +1,354 @@ +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 +> { + 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 { + let extracted: Record = {}; + + traverse(ast, { + ExportDefaultDeclaration(path: NodePath) { + 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(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 { + const obj: Record = {}; + + 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, +): boolean { + let modified = false; + + traverse(ast, { + ExportDefaultDeclaration(path: NodePath) { + 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, +): 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, + data: Record, +): 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); +} diff --git a/packages/cli/src/cli/loaders/unlocalizable.spec.ts b/packages/cli/src/cli/loaders/unlocalizable.spec.ts new file mode 100644 index 000000000..4d79f11ae --- /dev/null +++ b/packages/cli/src/cli/loaders/unlocalizable.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import createUnlocalizableLoader from "./unlocalizable"; + +describe("unlocalizable loader", () => { + const data = { + foo: "bar", + num: 1, + numStr: "1.0", + empty: "", + boolTrue: true, + boolFalse: false, + boolStr: "false", + isoDate: "2025-02-21", + isoDateTime: "2025-02-21T00:00:00.000Z", + bar: "foo", + url: "https://example.com", + systemId: "Ab1cdefghijklmnopqrst2", + }; + + it("should remove unlocalizable keys on pull", async () => { + const loader = createUnlocalizableLoader(); + loader.setDefaultLocale("en"); + const result = await loader.pull("en", data); + + expect(result).toEqual({ + foo: "bar", + numStr: "1.0", + boolStr: "false", + bar: "foo", + }); + }); + + it("should handle unlocalizable keys on push", async () => { + const pushData = { + foo: "bar-es", + bar: "foo-es", + numStr: "2.0", + boolStr: "true", + }; + + const loader = createUnlocalizableLoader(); + loader.setDefaultLocale("en"); + await loader.pull("en", data); + const result = await loader.push("es", pushData); + + expect(result).toEqual({ ...data, ...pushData }); + }); + + describe("return unlocalizable keys", () => { + describe.each([true, false])("%s", (returnUnlocalizedKeys) => { + it("should return unlocalizable keys on pull", async () => { + const loader = createUnlocalizableLoader(returnUnlocalizedKeys); + loader.setDefaultLocale("en"); + const result = await loader.pull("en", data); + + const extraUnlocalizableData = returnUnlocalizedKeys + ? { + unlocalizable: { + num: 1, + empty: "", + boolTrue: true, + boolFalse: false, + isoDate: "2025-02-21", + isoDateTime: "2025-02-21T00:00:00.000Z", + url: "https://example.com", + systemId: "Ab1cdefghijklmnopqrst2", + }, + } + : {}; + + expect(result).toEqual({ + foo: "bar", + numStr: "1.0", + boolStr: "false", + bar: "foo", + ...extraUnlocalizableData, + }); + }); + + it("should not affect push", async () => { + const pushData = { + foo: "bar-es", + bar: "foo-es", + numStr: "2.0", + boolStr: "true", + }; + + const loader = createUnlocalizableLoader(returnUnlocalizedKeys); + loader.setDefaultLocale("en"); + await loader.pull("en", data); + const result = await loader.push("es", pushData); + + const expectedData = { ...data, ...pushData }; + expect(result).toEqual(expectedData); + }); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/unlocalizable.ts b/packages/cli/src/cli/loaders/unlocalizable.ts new file mode 100644 index 000000000..227f7c910 --- /dev/null +++ b/packages/cli/src/cli/loaders/unlocalizable.ts @@ -0,0 +1,74 @@ +import _ from "lodash"; +import _isUrl from "is-url"; +import { isValid, parseISO } from "date-fns"; + +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createUnlocalizableLoader( + returnUnlocalizedKeys: boolean = false, +): ILoader, Record> { + return createLoader({ + async pull(locale, input) { + const unlocalizableKeys = _getUnlocalizableKeys(input); + + const result = _.omitBy(input, (_, key) => + unlocalizableKeys.includes(key), + ); + + if (returnUnlocalizedKeys) { + result.unlocalizable = _.omitBy( + input, + (_, key) => !unlocalizableKeys.includes(key), + ); + } + + return result; + }, + async push(locale, data, originalInput) { + const unlocalizableKeys = _getUnlocalizableKeys(originalInput); + + const result = _.merge( + {}, + data, + _.omitBy(originalInput, (_, key) => !unlocalizableKeys.includes(key)), + ); + + return result; + }, + }); +} + +function _isSystemId(v: string) { + return /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)[A-Za-z0-9]{22}$/.test(v); +} + +function _isIsoDate(v: string) { + return isValid(parseISO(v)); +} + +function _getUnlocalizableKeys(input?: Record | null) { + const rules = { + isEmpty: (v: any) => _.isEmpty(v), + isNumber: (v: any) => typeof v === "number" || /^[0-9]+$/.test(v), + isBoolean: (v: any) => _.isBoolean(v), + isIsoDate: (v: any) => _.isString(v) && _isIsoDate(v), + isSystemId: (v: any) => _.isString(v) && _isSystemId(v), + isUrl: (v: any) => _.isString(v) && _isUrl(v), + }; + + if (!input) { + return []; + } + + return Object.entries(input) + .filter(([key, value]) => { + for (const [ruleName, rule] of Object.entries(rules)) { + if (rule(value)) { + return true; + } + } + return false; + }) + .map(([key, _]) => key); +} diff --git a/packages/cli/src/cli/loaders/variable/index.spec.ts b/packages/cli/src/cli/loaders/variable/index.spec.ts new file mode 100644 index 000000000..8a22a9195 --- /dev/null +++ b/packages/cli/src/cli/loaders/variable/index.spec.ts @@ -0,0 +1,298 @@ +import { describe, it, expect } from "vitest"; +import createVariableLoader, { VariableLoaderParams } from "./index"; + +describe("createVariableLoader", () => { + describe("ieee format", () => { + it("extracts variables during pull", async () => { + const loader = createLoader("ieee"); + const input = { + simple: "Hello %s!", + multiple: "Value: %d and %f", + complex: "Precision %.2f with position %1$d", + }; + + const result = await loader.pull("en", input); + expect(result).toEqual({ + simple: "Hello {variable:0}!", + multiple: "Value: {variable:0} and {variable:1}", + complex: "Precision {variable:0} with position {variable:1}", + }); + }); + + it("restores variables during push", async () => { + const loader = createLoader("ieee"); + const input = { + simple: "Hello %s!", + multiple: "Value: %d and %f", + complex: "Precision %.2f with position %1$d", + }; + + const payload = { + simple: "[updated] Hello {variable:0}!", + multiple: "[updated] Value: {variable:0} and {variable:1}", + complex: "[updated] Precision {variable:0} with position {variable:1}", + }; + + await loader.pull("en", input); + const result = await loader.push("en", payload); + + expect(result).toEqual({ + simple: "[updated] Hello %s!", + multiple: "[updated] Value: %d and %f", + complex: "[updated] Precision %.2f with position %1$d", + }); + }); + + it("handles empty input", async () => { + const loader = createLoader("ieee"); + const result = await loader.pull("en", {}); + expect(result).toEqual({}); + }); + + it("preserves variable order for target locale during push", async () => { + const loader = createLoader("ieee"); + + const sourceInput = { + message: "Value: %d and %f", + }; + + // Pull the default (source) locale first + await loader.pull("en", sourceInput); + + // Target locale has variables in different order due to linguistic specifics + const targetInput = { + message: "Wert: %f und %d", + }; + + // Pull the target locale to capture its variable ordering + await loader.pull("de", targetInput); + + // Translator updates the string while keeping placeholders + const payload = { + message: "[aktualisiert] Wert: {variable:1} und {variable:0}", + }; + + // Push the updated translation back + const result = await loader.push("de", payload); + + expect(result).toEqual({ + message: "[aktualisiert] Wert: %f und %d", + }); + }); + + it("extracts variables with positional specifiers during pull", async () => { + const loader = createLoader("ieee"); + const input = { + message: "You have %2$d new items and %1$s.", + }; + + const result = await loader.pull("en", input); + expect(result).toEqual({ + message: "You have {variable:0} new items and {variable:1}.", + }); + }); + + it("restores variables with positional specifiers during push", async () => { + const loader = createLoader("ieee"); + const input = { + message: "You have %2$d new items and %1$s.", + }; + + const payload = { + message: "[updated] You have {variable:0} new items and {variable:1}.", + }; + + await loader.pull("en", input); + const result = await loader.push("en", payload); + + expect(result).toEqual({ + message: "[updated] You have %2$d new items and %1$s.", + }); + }); + }); + + describe("python format", () => { + it("extracts python variables during pull", async () => { + const loader = createLoader("python"); + const input = { + simple: "Hello %(name)s!", + multiple: "Value: %(num)d and %(float)f", + }; + + const result = await loader.pull("en", input); + expect(result).toEqual({ + simple: "Hello {variable:0}!", + multiple: "Value: {variable:0} and {variable:1}", + }); + }); + + it("restores python variables during push", async () => { + const loader = createLoader("python"); + const input = { + simple: "Hello %(name)s!", + multiple: "Value: %(num)d and %(float)f", + }; + + const payload = { + simple: "[updated] Hello {variable:0}!", + multiple: "[updated] Value: {variable:0} and {variable:1}", + }; + + await loader.pull("en", input); + const result = await loader.push("en", input); + expect(result).toEqual({ + simple: "Hello %(name)s!", + multiple: "Value: %(num)d and %(float)f", + }); + }); + + it("preserves variable order for target locale during push", async () => { + const loader = createLoader("python"); + + const sourceInput = { + message: "Hello %(name)s, you have %(count)d items.", + }; + + // Pull default locale first + await loader.pull("en", sourceInput); + + // Target locale with reversed variable order + const targetInput = { + message: "Du hast %(count)d Artikel, %(name)s.", + }; + await loader.pull("de", targetInput); + + const payload = { + message: "[aktualisiert] Du hast {variable:1} Artikel, {variable:0}.", + }; + + const result = await loader.push("de", payload); + + expect(result).toEqual({ + message: "[aktualisiert] Du hast %(count)d Artikel, %(name)s.", + }); + }); + }); + + it("throws error for unsupported format type", () => { + expect(() => { + // @ts-expect-error Testing invalid type + createVariableLoader({ type: "invalid" }); + }).toThrow("Unsupported variable format type: invalid"); + }); + + describe("replaceAll behavior", () => { + it("should handle multiple occurrences of same variable in a string", async () => { + const loader = createLoader("ieee"); + const input = { + repeated: "Test %d and %d and %d", + }; + + // Pull to extract variables + await loader.pull("en", input); + + // Backend might return translation with multiple placeholders + const payload = { + repeated: "Prueba {variable:0} y {variable:1} y {variable:2}", + }; + + const result = await loader.push("en", payload); + + // All placeholders should be restored correctly + expect(result).toEqual({ + repeated: "Prueba %d y %d y %d", + }); + }); + + it("should handle variable restoration in ICU-like plural strings", async () => { + const loader = createLoader("ieee"); + + // Simulates what comes from xcode-xcstrings-v2 loader after ICU conversion + const input = { + pluralString: "{count, plural, one {%d item} other {%d items}}", + }; + + await loader.pull("en", input); + + const payload = { + pluralString: + "{count, plural, one {{variable:0} artículo} other {{variable:0} artículos}}", + }; + + const result = await loader.push("es", payload); + + // Both occurrences of {variable:0} should be replaced with %d + expect(result.pluralString).toBe( + "{count, plural, one {%d artículo} other {%d artículos}}", + ); + }); + + it("should handle multiple different variables in ICU-like format", async () => { + const loader = createLoader("ieee"); + + const input = { + complex: + "{count, plural, one {%1$d file of %2$d MB} other {%1$d files of %2$d MB}}", + }; + + await loader.pull("en", input); + + const payload = { + complex: + "{count, plural, one {{variable:0} fichier de {variable:1} Mo} other {{variable:0} fichiers de {variable:1} Mo}}", + }; + + const result = await loader.push("fr", payload); + + // Should restore all variable occurrences correctly + expect(result.complex).toBe( + "{count, plural, one {%1$d fichier de %2$d Mo} other {%1$d fichiers de %2$d Mo}}", + ); + }); + + it("should handle python format with ICU plurals", async () => { + const loader = createLoader("python"); + + const input = { + pythonPlural: + "{count, plural, one {%(num)d item} other {%(num)d items}}", + }; + + await loader.pull("en", input); + + const payload = { + pythonPlural: + "{count, plural, one {{variable:0} artículo} other {{variable:0} artículos}}", + }; + + const result = await loader.push("es", payload); + + expect(result.pythonPlural).toBe( + "{count, plural, one {%(num)d artículo} other {%(num)d artículos}}", + ); + }); + + it("should handle same variable appearing many times", async () => { + const loader = createLoader("ieee"); + + const input = { + manyRepeats: "Test: %s, %s, %s, %s, %s", + }; + + await loader.pull("en", input); + + const payload = { + manyRepeats: + "Prueba: {variable:0}, {variable:1}, {variable:2}, {variable:3}, {variable:4}", + }; + + const result = await loader.push("es", payload); + + expect(result.manyRepeats).toBe("Prueba: %s, %s, %s, %s, %s"); + }); + }); +}); + +function createLoader(type: VariableLoaderParams["type"]) { + return createVariableLoader({ type }).setDefaultLocale("en"); +} diff --git a/packages/cli/src/cli/loaders/variable/index.ts b/packages/cli/src/cli/loaders/variable/index.ts new file mode 100644 index 000000000..8a78984db --- /dev/null +++ b/packages/cli/src/cli/loaders/variable/index.ts @@ -0,0 +1,110 @@ +import _ from "lodash"; +import { ILoader } from "../_types"; +import { composeLoaders, createLoader } from "../_utils"; + +export type VariableLoaderParams = { + type: "ieee" | "python"; +}; + +export default function createVariableLoader( + params: VariableLoaderParams, +): ILoader, Record> { + return composeLoaders(variableExtractLoader(params), variableContentLoader()); +} + +type VariableExtractionPayload = { + variables: string[]; + value: string; +}; + +function variableExtractLoader( + params: VariableLoaderParams, +): ILoader, Record> { + const specifierPattern = getFormatSpecifierPattern(params.type); + return createLoader({ + pull: async (locale, input, initXtx, originalLocale, originalInput) => { + const result: Record = {}; + const inputValues = _.omitBy(input, _.isEmpty); + for (const [key, value] of Object.entries(inputValues)) { + const originalValue = originalInput[key]; + + // Extract format specifiers from the original value + const matches = originalValue.match(specifierPattern) || []; + result[key] = result[key] || { + value, + variables: [], + }; + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const currentValue = result[key].value; + const newValue = currentValue?.replace(match, `{variable:${i}}`); + + result[key].value = newValue; + result[key].variables[i] = match; + } + } + return result; + }, + push: async ( + locale, + data, + originalInput, + originalDefaultLocale, + pullInput, + pullOutput, + ) => { + const result: Record = {}; + for (const [key, valueObj] of Object.entries(data)) { + result[key] = valueObj.value; + + for (let i = 0; i < valueObj.variables.length; i++) { + const variable = valueObj.variables[i]; + const currentValue = result[key]; + if (typeof currentValue === "string") { + const newValue = currentValue?.replaceAll( + `{variable:${i}}`, + variable, + ); + result[key] = newValue; + } + } + } + return result; + }, + }); +} + +function variableContentLoader(): ILoader< + Record, + Record +> { + return createLoader({ + pull: async (locale, input) => { + const result = _.mapValues(input, (payload) => payload.value); + return result; + }, + push: async (locale, data, originalInput, defaultLocale, pullInput) => { + const result: Record = _.cloneDeep( + originalInput || {}, + ); + for (const [key, originalValueObj] of Object.entries(result)) { + result[key] = { + ...originalValueObj, + value: data[key], + }; + } + return result; + }, + }); +} + +function getFormatSpecifierPattern(type: VariableLoaderParams["type"]): RegExp { + switch (type) { + case "ieee": + return /%(?:\d+\$)?[+-]?(?:[ 0]|'.)?-?\d*(?:\.\d+)?(?:[hljztL]|ll|hh)?[@diuoxXfFeEgGaAcspn%]/g; + case "python": + return /%\([^)]+\)[diouxXeEfFgGcrs%]/g; + default: + throw new Error(`Unsupported variable format type: ${type}`); + } +} diff --git a/packages/cli/src/cli/loaders/vtt.ts b/packages/cli/src/cli/loaders/vtt.ts new file mode 100644 index 000000000..4c7a5e9d8 --- /dev/null +++ b/packages/cli/src/cli/loaders/vtt.ts @@ -0,0 +1,48 @@ +import webvtt from "node-webvtt"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createVttLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, input) { + if (!input) { + return ""; // if VTT file does not exist yet we can not parse it - return empty string + } + const vtt = webvtt.parse(input)?.cues; + if (Object.keys(vtt).length === 0) { + return {}; + } else { + return vtt.reduce((result: any, cue: any, index: number) => { + const key = `${index}#${cue.start}-${cue.end}#${cue.identifier}`; + result[key] = cue.text; + return result; + }, {}); + } + }, + async push(locale, payload) { + const output = Object.entries(payload).map(([key, text]) => { + const [id, timeRange, identifier] = key.split("#"); + const [startTime, endTime] = timeRange.split("-"); + + return { + end: Number(endTime), + identifier: identifier, + start: Number(startTime), + styles: "", + text: text, + }; + }); + + const input = { + valid: true, + strict: true, + cues: output, + }; + + return webvtt.compile(input); + }, + }); +} diff --git a/packages/cli/src/cli/loaders/vue-json.ts b/packages/cli/src/cli/loaders/vue-json.ts new file mode 100644 index 000000000..82d36558d --- /dev/null +++ b/packages/cli/src/cli/loaders/vue-json.ts @@ -0,0 +1,46 @@ +import { jsonrepair } from "jsonrepair"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +export default function createVueJsonLoader(): ILoader< + string, + Record +> { + return createLoader({ + pull: async (locale, input, ctx) => { + const parsed = parseVueFile(input); + return parsed?.i18n?.[locale] ?? {}; + }, + push: async (locale, data, originalInput) => { + const parsed = parseVueFile(originalInput ?? ""); + if (!parsed) { + return originalInput ?? ""; + } + + parsed.i18n[locale] = data; + return `${parsed.before}\n${JSON.stringify( + parsed.i18n, + null, + 2, + )}\n${parsed.after}`; + }, + }); +} + +function parseVueFile(input: string) { + const match = input.match(/^([\s\S]*)([\s\S]*)<\/i18n>([\s\S]*)$/); + + if (!match) { + return null; + } + + const [, before, jsonString = "{}", after] = match; + let i18n: Record; + try { + i18n = JSON.parse(jsonString); + } catch (error) { + i18n = JSON.parse(jsonrepair(jsonString)); + } + + return { before, after, i18n }; +} diff --git a/packages/cli/src/cli/loaders/xcode-strings.spec.ts b/packages/cli/src/cli/loaders/xcode-strings.spec.ts new file mode 100644 index 000000000..4f237ee62 --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-strings.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import createXcodeStringsLoader from "./xcode-strings"; + +describe("xcode-strings loader", () => { + it("should parse single-line entries", async () => { + const loader = createXcodeStringsLoader(); + loader.setDefaultLocale("en"); + const input = `"hello" = "Hello"; +"world" = "World";`; + + const result = await loader.pull("en", input); + expect(result).toEqual({ + hello: "Hello", + world: "World", + }); + }); + + it("should parse multi-line string values", async () => { + const loader = createXcodeStringsLoader(); + loader.setDefaultLocale("en"); + const input = `"greeting" = "Hello"; +"multiline" = "This is line one +This is line two +This is line three"; +"another" = "Another value";`; + + const result = await loader.pull("en", input); + expect(result).toEqual({ + greeting: "Hello", + multiline: "This is line one\nThis is line two\nThis is line three", + another: "Another value", + }); + }); + + it("should handle multi-line string with placeholders", async () => { + const loader = createXcodeStringsLoader(); + loader.setDefaultLocale("en"); + const input = `"add_new_reference_share_text" = "Hi! +Could you stop by quickly and tell us what you thought of our experience together? Your words will be super important to boost my profile on Worldpackers! +[url] +";`; + + const result = await loader.pull("en", input); + expect(result).toEqual({ + add_new_reference_share_text: + "Hi!\nCould you stop by quickly and tell us what you thought of our experience together? Your words will be super important to boost my profile on Worldpackers!\n[url]\n", + }); + }); + + it("should skip comments", async () => { + const loader = createXcodeStringsLoader(); + loader.setDefaultLocale("en"); + const input = `// This is a comment +"hello" = "Hello"; +// Another comment +"world" = "World";`; + + const result = await loader.pull("en", input); + expect(result).toEqual({ + hello: "Hello", + world: "World", + }); + }); + + it("should skip empty lines", async () => { + const loader = createXcodeStringsLoader(); + loader.setDefaultLocale("en"); + const input = `"hello" = "Hello"; + +"world" = "World";`; + + const result = await loader.pull("en", input); + expect(result).toEqual({ + hello: "Hello", + world: "World", + }); + }); + + it("should handle escaped characters in single-line values", async () => { + const loader = createXcodeStringsLoader(); + loader.setDefaultLocale("en"); + const input = `"escaped_quote" = "He said \\"Hello\\""; +"escaped_newline" = "Line 1\\nLine 2"; +"escaped_backslash" = "Path: C:\\\\Users";`; + + const result = await loader.pull("en", input); + expect(result).toEqual({ + escaped_quote: 'He said "Hello"', + escaped_newline: "Line 1\nLine 2", + escaped_backslash: "Path: C:\\Users", + }); + }); + + it("should handle empty values", async () => { + const loader = createXcodeStringsLoader(); + loader.setDefaultLocale("en"); + const input = `"empty" = "";`; + + const result = await loader.pull("en", input); + expect(result).toEqual({ + empty: "", + }); + }); + + it("push should convert object to .strings format", async () => { + const loader = createXcodeStringsLoader(); + loader.setDefaultLocale("en"); + // Need to call pull first to initialize the loader state + await loader.pull("en", ""); + + const payload = { + hello: "Hello", + world: "World", + }; + + const result = await loader.push("en", payload); + expect(result).toBe(`"hello" = "Hello"; +"world" = "World";`); + }); + + it("push should escape special characters", async () => { + const loader = createXcodeStringsLoader(); + loader.setDefaultLocale("en"); + // Need to call pull first to initialize the loader state + await loader.pull("en", ""); + + const payload = { + escaped_quote: 'He said "Hello"', + escaped_newline: "Line 1\nLine 2", + escaped_backslash: "Path: C:\\Users", + }; + + const result = await loader.push("en", payload); + expect(result).toBe( + `"escaped_quote" = "He said \\"Hello\\""; +"escaped_newline" = "Line 1\\nLine 2"; +"escaped_backslash" = "Path: C:\\\\Users";`, + ); + }); + + it("push should handle multi-line values by escaping newlines", async () => { + const loader = createXcodeStringsLoader(); + loader.setDefaultLocale("en"); + // Need to call pull first to initialize the loader state + await loader.pull("en", ""); + + const payload = { + multiline: "This is line one\nThis is line two\nThis is line three", + }; + + const result = await loader.push("en", payload); + expect(result).toBe( + `"multiline" = "This is line one\\nThis is line two\\nThis is line three";`, + ); + }); + + it("should handle mixed single-line and multi-line entries", async () => { + const loader = createXcodeStringsLoader(); + loader.setDefaultLocale("en"); + const input = `"single1" = "Value 1"; +"multi" = "Line 1 +Line 2"; +"single2" = "Value 2";`; + + const result = await loader.pull("en", input); + expect(result).toEqual({ + single1: "Value 1", + multi: "Line 1\nLine 2", + single2: "Value 2", + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/xcode-strings.ts b/packages/cli/src/cli/loaders/xcode-strings.ts new file mode 100644 index 000000000..fca730ea4 --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-strings.ts @@ -0,0 +1,35 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import { Tokenizer } from "./xcode-strings/tokenizer"; +import { Parser } from "./xcode-strings/parser"; +import { escapeString } from "./xcode-strings/escape"; + +export default function createXcodeStringsLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, input) { + // Tokenize the input + const tokenizer = new Tokenizer(input); + const tokens = tokenizer.tokenize(); + + // Parse tokens into key-value pairs + const parser = new Parser(tokens); + const result = parser.parse(); + + return result; + }, + + async push(locale, payload) { + const lines = Object.entries(payload) + .filter(([_, value]) => value != null) + .map(([key, value]) => { + const escapedValue = escapeString(value); + return `"${key}" = "${escapedValue}";`; + }); + + return lines.join("\n"); + }, + }); +} diff --git a/packages/cli/src/cli/loaders/xcode-strings/escape.ts b/packages/cli/src/cli/loaders/xcode-strings/escape.ts new file mode 100644 index 000000000..c5f5f62f7 --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-strings/escape.ts @@ -0,0 +1,84 @@ +/** + * Unescape a string value from .strings file format + * Handles: \", \\, \n, \t, etc. + */ +export function unescapeString(raw: string): string { + let result = ""; + let i = 0; + + while (i < raw.length) { + if (raw[i] === "\\" && i + 1 < raw.length) { + const nextChar = raw[i + 1]; + switch (nextChar) { + case '"': + result += '"'; + i += 2; + break; + case "\\": + result += "\\"; + i += 2; + break; + case "n": + result += "\n"; + i += 2; + break; + case "t": + result += "\t"; + i += 2; + break; + case "r": + result += "\r"; + i += 2; + break; + default: + // Unknown escape - keep as-is + result += raw[i]; + i++; + break; + } + } else { + result += raw[i]; + i++; + } + } + + return result; +} + +/** + * Escape a string value for .strings file format + * Escapes: \, ", newlines to \n + */ +export function escapeString(str: string): string { + if (str == null) { + return ""; + } + + let result = ""; + + for (let i = 0; i < str.length; i++) { + const char = str[i]; + switch (char) { + case "\\": + result += "\\\\"; + break; + case '"': + result += '\\"'; + break; + case "\n": + result += "\\n"; + break; + case "\r": + result += "\\r"; + break; + case "\t": + result += "\\t"; + break; + default: + result += char; + break; + } + } + + return result; +} diff --git a/packages/cli/src/cli/loaders/xcode-strings/parser.ts b/packages/cli/src/cli/loaders/xcode-strings/parser.ts new file mode 100644 index 000000000..b47fe5519 --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-strings/parser.ts @@ -0,0 +1,102 @@ +import { Token, TokenType } from "./types"; +import { unescapeString } from "./escape"; + +export class Parser { + private tokens: Token[]; + private pos: number; + + constructor(tokens: Token[]) { + this.tokens = tokens; + this.pos = 0; + } + + parse(): Record { + const result: Record = {}; + + while (this.pos < this.tokens.length) { + const token = this.current(); + + // Skip comments + if ( + token.type === TokenType.COMMENT_SINGLE || + token.type === TokenType.COMMENT_MULTI + ) { + this.advance(); + continue; + } + + // End of file + if (token.type === TokenType.EOF) { + break; + } + + // Expect entry: STRING "=" STRING ";" + if (token.type === TokenType.STRING) { + const entry = this.parseEntry(); + if (entry) { + result[entry.key] = entry.value; + } + continue; + } + + // Skip unexpected tokens gracefully + this.advance(); + } + + return result; + } + + private parseEntry(): { key: string; value: string } | null { + // Current token should be STRING (key) + const keyToken = this.current(); + if (keyToken.type !== TokenType.STRING) { + return null; + } + const key = keyToken.value; + this.advance(); + + // Expect '=' + if (!this.expect(TokenType.EQUALS)) { + // Missing '=' - skip this entry + return null; + } + + // Expect STRING (value) + const valueToken = this.current(); + if (valueToken.type !== TokenType.STRING) { + // Missing value - skip this entry + return null; + } + const rawValue = valueToken.value; + this.advance(); + + // Expect ';' + if (!this.expect(TokenType.SEMICOLON)) { + // Missing ';' - but still process the entry + // (more forgiving) + } + + // Unescape the value + const value = unescapeString(rawValue); + + return { key, value }; + } + + private current(): Token { + return this.tokens[this.pos]; + } + + private advance(): void { + if (this.pos < this.tokens.length) { + this.pos++; + } + } + + private expect(type: TokenType): boolean { + if (this.current()?.type === type) { + this.advance(); + return true; + } + return false; + } +} diff --git a/packages/cli/src/cli/loaders/xcode-strings/tokenizer.ts b/packages/cli/src/cli/loaders/xcode-strings/tokenizer.ts new file mode 100644 index 000000000..47976abc5 --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-strings/tokenizer.ts @@ -0,0 +1,200 @@ +import { Token, TokenType, Position } from "./types"; + +export class Tokenizer { + private input: string; + private pos: number; + private line: number; + private column: number; + + constructor(input: string) { + this.input = input; + this.pos = 0; + this.line = 1; + this.column = 1; + } + + tokenize(): Token[] { + const tokens: Token[] = []; + + while (this.pos < this.input.length) { + const char = this.current(); + + // Skip whitespace + if (this.isWhitespace(char)) { + this.advance(); + continue; + } + + // Handle comments + if (char === "/" && this.peek() === "/") { + tokens.push(this.scanSingleLineComment()); + continue; + } + + if (char === "/" && this.peek() === "*") { + tokens.push(this.scanMultiLineComment()); + continue; + } + + // Handle strings + if (char === '"') { + tokens.push(this.scanString()); + continue; + } + + // Handle operators + if (char === "=") { + tokens.push(this.makeToken(TokenType.EQUALS, "=")); + this.advance(); + continue; + } + + if (char === ";") { + tokens.push(this.makeToken(TokenType.SEMICOLON, ";")); + this.advance(); + continue; + } + + // Unexpected character - skip it + // (More forgiving than throwing error) + this.advance(); + } + + tokens.push(this.makeToken(TokenType.EOF, "")); + return tokens; + } + + private scanString(): Token { + const start = this.getPosition(); + let value = ""; + + this.advance(); // Skip opening " + + while (this.pos < this.input.length) { + const char = this.current(); + + if (char === "\\") { + // Escape sequence - preserve both \ and next char + this.advance(); + if (this.pos < this.input.length) { + const nextChar = this.current(); + value += "\\" + nextChar; + this.advance(); + } + continue; + } + + if (char === '"') { + // End of string + this.advance(); // Skip closing " + return { + type: TokenType.STRING, + value, + ...start, + }; + } + + // Regular character (including actual newlines) + value += char; + this.advance(); + } + + // Unterminated string - return what we have + return { + type: TokenType.STRING, + value, + ...start, + }; + } + + private scanSingleLineComment(): Token { + const start = this.getPosition(); + let value = ""; + + this.advance(); // Skip first '/' + this.advance(); // Skip second '/' + + while (this.pos < this.input.length && this.current() !== "\n") { + value += this.current(); + this.advance(); + } + + return { + type: TokenType.COMMENT_SINGLE, + value, + ...start, + }; + } + + private scanMultiLineComment(): Token { + const start = this.getPosition(); + let value = ""; + + this.advance(); // Skip '/' + this.advance(); // Skip '*' + + while (this.pos < this.input.length) { + if (this.current() === "*" && this.peek() === "/") { + this.advance(); // Skip '*' + this.advance(); // Skip '/' + return { + type: TokenType.COMMENT_MULTI, + value, + ...start, + }; + } + + value += this.current(); + this.advance(); + } + + // Unterminated comment - return what we have + return { + type: TokenType.COMMENT_MULTI, + value, + ...start, + }; + } + + private current(): string { + return this.input[this.pos]; + } + + private peek(): string | null { + if (this.pos + 1 < this.input.length) { + return this.input[this.pos + 1]; + } + return null; + } + + private advance(): void { + if (this.pos < this.input.length) { + if (this.current() === "\n") { + this.line++; + this.column = 1; + } else { + this.column++; + } + this.pos++; + } + } + + private isWhitespace(char: string): boolean { + return char === " " || char === "\t" || char === "\n" || char === "\r"; + } + + private getPosition(): Position { + return { + line: this.line, + column: this.column, + }; + } + + private makeToken(type: TokenType, value: string): Token { + return { + type, + value, + ...this.getPosition(), + }; + } +} diff --git a/packages/cli/src/cli/loaders/xcode-strings/types.ts b/packages/cli/src/cli/loaders/xcode-strings/types.ts new file mode 100644 index 000000000..8d6084d65 --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-strings/types.ts @@ -0,0 +1,20 @@ +export enum TokenType { + COMMENT_SINGLE = "COMMENT_SINGLE", + COMMENT_MULTI = "COMMENT_MULTI", + STRING = "STRING", + EQUALS = "EQUALS", + SEMICOLON = "SEMICOLON", + EOF = "EOF", +} + +export interface Token { + type: TokenType; + value: string; + line: number; + column: number; +} + +export interface Position { + line: number; + column: number; +} diff --git a/packages/cli/src/cli/loaders/xcode-stringsdict.ts b/packages/cli/src/cli/loaders/xcode-stringsdict.ts new file mode 100644 index 000000000..38dd5b3ad --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-stringsdict.ts @@ -0,0 +1,41 @@ +import plist from "plist"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import { CLIError } from "../utils/errors"; + +const emptyData = [ + '', + '', + '', + "", + "", +].join("\n"); + +export default function createXcodeStringsdictLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, input) { + try { + const parsed = plist.parse(input || emptyData); + if (typeof parsed !== "object" || parsed === null) { + throw new CLIError({ + message: "Invalid .stringsdict format", + docUrl: "invalidStringDict", + }); + } + return parsed as Record; + } catch (error: any) { + throw new CLIError({ + message: `Invalid .stringsdict format: ${error.message}`, + docUrl: "invalidStringDict", + }); + } + }, + async push(locale, payload) { + const plistContent = plist.build(payload); + return plistContent; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/xcode-xcstrings-lock-compatibility.spec.ts b/packages/cli/src/cli/loaders/xcode-xcstrings-lock-compatibility.spec.ts new file mode 100644 index 000000000..a449b189d --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-xcstrings-lock-compatibility.spec.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { MD5 } from "object-hash"; + +/** + * Test suite for xcode-xcstrings pluralization lock file format. + * + * With the ICU loader approach, lock files contain checksums of ICU MessageFormat objects. + * This is a NEW format for pluralization (not backward compatible with non-plural keys). + * + * Example lock file format: + * item_count: + * + * vs old format (would have been): + * item_count/zero: + * item_count/one: + * item_count/other: + */ +describe("xcode-xcstrings ICU lock file format", () => { + it("should compute checksums on ICU format objects", async () => { + // This is what xcstrings-icu loader produces + const sourceData = { + welcome_message: "Hello!", + item_count: { + icu: "{count, plural, =0 {No items} one {# item} other {# items}}", + _meta: { + variables: { + count: { + format: "%d", + role: "plural", + }, + }, + }, + }, + }; + + // Compute checksums on this format (what goes into lock file) + const checksums: Record = {}; + for (const [key, value] of Object.entries(sourceData)) { + checksums[key] = MD5(value); + } + + // Verify we have ICU object keys in checksums + expect(checksums).toHaveProperty("item_count"); + expect(checksums).toHaveProperty("welcome_message"); + + // No flattened keys + expect(checksums).not.toHaveProperty("item_count/zero"); + expect(checksums).not.toHaveProperty("item_count/one"); + expect(checksums).not.toHaveProperty("item_count/other"); + }); + + it("should have consistent ICU object structure for checksums", () => { + const icuObject = { + icu: "{count, plural, one {1 item} other {# items}}", + _meta: { + variables: { + count: { + format: "%d", + role: "plural", + }, + }, + }, + }; + + const checksum1 = MD5(icuObject); + const checksum2 = MD5(icuObject); + + // Checksums should be deterministic + expect(checksum1).toBe(checksum2); + expect(typeof checksum1).toBe("string"); + expect(checksum1.length).toBeGreaterThan(0); + }); + + it("should change checksum when ICU string changes", () => { + const icuObject1 = { + icu: "{count, plural, one {1 item} other {# items}}", + _meta: { variables: { count: { format: "%d", role: "plural" } } }, + }; + + const icuObject2 = { + icu: "{count, plural, one {1 elemento} other {# elementos}}", // Spanish translation + _meta: { variables: { count: { format: "%d", role: "plural" } } }, + }; + + const checksum1 = MD5(icuObject1); + const checksum2 = MD5(icuObject2); + + // Different ICU strings should produce different checksums + expect(checksum1).not.toBe(checksum2); + }); + + it("should preserve ICU objects (not flatten them)", () => { + // ICU objects should NOT be flattened into item_count/one, item_count/other + // They should remain as single objects + + const icuObject = { + icu: "{count, plural, =0 {No items} one {# item} other {# items}}", + [Symbol.for("@lingo.dev/icu-plural-object")]: true, + }; + + // Verify it's recognized as ICU object + expect(icuObject).toHaveProperty("icu"); + expect(icuObject.icu).toContain("plural"); + + // Should have symbol marker + expect(Symbol.for("@lingo.dev/icu-plural-object") in icuObject).toBe(true); + }); + + it("should handle mixed content (plurals and regular strings)", () => { + const sourceData = { + simple_string: "Hello!", + plural_key: { + icu: "{count, plural, one {1 item} other {# items}}", + _meta: { variables: { count: { format: "%d", role: "plural" } } }, + }, + another_string: "Welcome!", + }; + + const checksums: Record = {}; + for (const [key, value] of Object.entries(sourceData)) { + checksums[key] = MD5(value); + } + + // All keys should have checksums + expect(Object.keys(checksums).sort()).toEqual([ + "another_string", + "plural_key", + "simple_string", + ]); + + // Each should have unique checksum + const uniqueChecksums = new Set(Object.values(checksums)); + expect(uniqueChecksums.size).toBe(3); + }); +}); diff --git a/packages/cli/src/cli/loaders/xcode-xcstrings-v2.spec.ts b/packages/cli/src/cli/loaders/xcode-xcstrings-v2.spec.ts new file mode 100644 index 000000000..db8fdff92 --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-xcstrings-v2.spec.ts @@ -0,0 +1,1439 @@ +import { describe, it, expect } from "vitest"; +import createXcodeXcstringsV2Loader from "./xcode-xcstrings-v2"; + +describe("loaders/xcode-xcstrings-v2", () => { + const defaultLocale = "en"; + + const mockInput = { + sourceLanguage: "en", + strings: { + "app.title": { + comment: "The main app title", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "My App", + }, + }, + es: { + stringUnit: { + state: "translated", + value: "Mi App", + }, + }, + }, + }, + item_count: { + comment: "Number of items", + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 item", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d items", + }, + }, + }, + }, + }, + es: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 artículo", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d artículos", + }, + }, + }, + }, + }, + }, + }, + notification_message: { + comment: "Notification with substitutions", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "You have %#@COUNT@ pending", + }, + substitutions: { + COUNT: { + argNum: 1, + formatSpecifier: "lld", + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 notification", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%arg notifications", + }, + }, + }, + }, + }, + }, + }, + es: { + stringUnit: { + state: "translated", + value: "Tienes %#@COUNT@ pendientes", + }, + substitutions: { + COUNT: { + argNum: 1, + formatSpecifier: "lld", + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 notificación", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%arg notificaciones", + }, + }, + }, + }, + }, + }, + }, + }, + }, + phrase: { + comment: "String set for Siri", + localizations: { + en: { + stringSet: { + state: "translated", + values: ["First variant", "Second variant"], + }, + }, + es: { + stringSet: { + state: "translated", + values: ["Primera variante", "Segunda variante"], + }, + }, + }, + }, + "key.no-translate": { + shouldTranslate: false, + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Do not translate", + }, + }, + }, + }, + "key.source-only": { + localizations: {}, + }, + "key.missing-localization": { + localizations: { + es: { + stringUnit: { + state: "translated", + value: "solo español", + }, + }, + }, + }, + }, + version: "1.0", + }; + + describe("pull", () => { + it("should pull simple string translations", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const result = await loader.pull("es", mockInput); + + expect(result).toMatchObject({ + "app.title": { + stringUnit: "Mi App", + }, + }); + }); + + it("should pull plural variations and convert to ICU", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const result = await loader.pull("es", mockInput); + + expect(result["item_count"]).toHaveProperty("variations"); + expect(result["item_count"].variations).toHaveProperty("plural"); + expect(typeof result["item_count"].variations.plural).toBe("string"); + expect(result["item_count"].variations.plural).toMatch( + /^\{[\w]+,\s*plural,/, + ); + }); + + it("should pull substitutions with ICU conversion", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const result = await loader.pull("es", mockInput); + + expect(result["notification_message"]).toHaveProperty("stringUnit"); + expect(result["notification_message"].stringUnit).toBe( + "Tienes %#@COUNT@ pendientes", + ); + + expect(result["notification_message"]).toHaveProperty("substitutions"); + expect(result["notification_message"].substitutions).toHaveProperty( + "COUNT", + ); + expect( + typeof result["notification_message"].substitutions.COUNT.variations + .plural, + ).toBe("string"); + expect( + result["notification_message"].substitutions.COUNT.variations.plural, + ).toMatch(/^\{[\w]+,\s*plural,/); + }); + + it("should pull stringSet arrays", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const result = await loader.pull("es", mockInput); + + expect(result["phrase"]).toHaveProperty("stringSet"); + expect(result["phrase"].stringSet).toEqual([ + "Primera variante", + "Segunda variante", + ]); + }); + + it("should skip keys marked with shouldTranslate: false", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const result = await loader.pull("en", mockInput); + + expect(result).not.toHaveProperty("key.no-translate"); + }); + + it("should handle source language fallback for missing localizations", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const result = await loader.pull("en", mockInput); + + expect(result["key.source-only"]).toHaveProperty("stringUnit"); + expect(result["key.source-only"].stringUnit).toBe("key.source-only"); + }); + + it("should not include missing localizations for non-source language", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const result = await loader.pull("es", mockInput); + + expect(result).not.toHaveProperty("key.source-only"); + }); + + it("should handle zero form in plural variations", async () => { + const inputWithZero = { + sourceLanguage: "en", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + zero: { + stringUnit: { + state: "translated", + value: "No items", + }, + }, + one: { + stringUnit: { + state: "translated", + value: "1 item", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d items", + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + const result = await loader.pull("en", inputWithZero); + + // English "zero" is optional, so it should be converted to =0 + expect(result["items"].variations.plural).toContain("=0 {No items}"); + expect(result["items"].variations.plural).toContain("one {1 item}"); + expect(result["items"].variations.plural).toContain("other {%d items}"); + }); + + it("should handle exact match forms (=0, =1) in ICU strings", async () => { + const inputWithExact = { + sourceLanguage: "en", + strings: { + downloads: { + localizations: { + en: { + variations: { + plural: { + "=0": { + stringUnit: { + state: "translated", + value: "No downloads", + }, + }, + "=1": { + stringUnit: { + state: "translated", + value: "One download", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d downloads", + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + const result = await loader.pull("en", inputWithExact); + + expect(result["downloads"].variations.plural).toContain( + "=0 {No downloads}", + ); + expect(result["downloads"].variations.plural).toContain( + "=1 {One download}", + ); + }); + + it("should handle different format specifiers in plural forms", async () => { + const inputWithSpecifiers = { + sourceLanguage: "en", + strings: { + messages: { + localizations: { + en: { + stringUnit: { + state: "translated", + value: "You have %#@COUNT@", + }, + substitutions: { + COUNT: { + formatSpecifier: "lld", + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "%arg message", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%@ messages", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + const result = await loader.pull("en", inputWithSpecifiers); + + const icuString = + result["messages"].substitutions.COUNT.variations.plural; + expect(icuString).toContain("%arg message"); + expect(icuString).toContain("%@ messages"); + }); + + it("should handle multiple substitutions in one string", async () => { + const inputWithMultipleSubs = { + sourceLanguage: "en", + strings: { + complex: { + localizations: { + en: { + stringUnit: { + state: "translated", + value: "%#@FILES@ in %#@FOLDERS@", + }, + substitutions: { + FILES: { + formatSpecifier: "d", + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 file", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d files", + }, + }, + }, + }, + }, + FOLDERS: { + formatSpecifier: "d", + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 folder", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d folders", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + const result = await loader.pull("en", inputWithMultipleSubs); + + expect(result["complex"].stringUnit).toBe("%#@FILES@ in %#@FOLDERS@"); + expect(result["complex"].substitutions).toHaveProperty("FILES"); + expect(result["complex"].substitutions).toHaveProperty("FOLDERS"); + expect(result["complex"].substitutions.FILES.variations.plural).toMatch( + /^\{[\w]+,\s*plural,/, + ); + expect(result["complex"].substitutions.FOLDERS.variations.plural).toMatch( + /^\{[\w]+,\s*plural,/, + ); + }); + + it("should handle stringSet with empty array", async () => { + const inputWithEmptySet = { + sourceLanguage: "en", + strings: { + empty_phrase: { + localizations: { + en: { + stringSet: { + state: "translated", + values: [], + }, + }, + }, + }, + }, + }; + + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + const result = await loader.pull("en", inputWithEmptySet); + + // Empty stringSet should not be included + expect(result["empty_phrase"]).toEqual({}); + }); + + it("should work with constructor defaultLocale parameter", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + // Pass defaultLocale via constructor and call setDefaultLocale + loader.setDefaultLocale("en"); + const result = await loader.pull("en", mockInput); + + // Should work the same as when defaultLocale is set explicitly + expect(result["key.source-only"]).toHaveProperty("stringUnit"); + expect(result["key.source-only"].stringUnit).toBe("key.source-only"); + }); + }); + + describe("push", () => { + it("should push simple string translations", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + // Must pull first to initialize state + await loader.pull(defaultLocale, mockInput); + + const payload = { + "app.title": { + stringUnit: "Mon Application", + }, + }; + + const result = await loader.push("fr", payload); + + expect(result!.strings["app.title"].localizations["fr"]).toEqual({ + stringUnit: { + state: "translated", + value: "Mon Application", + }, + }); + }); + + it("should push plural variations from ICU format", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + // Must pull first to initialize state (pull both locales to get full state) + await loader.pull(defaultLocale, mockInput); + await loader.pull("es", mockInput); + + const payload = { + item_count: { + variations: { + plural: "{count, plural, one {1 article} other {%d articles}}", + }, + }, + }; + + const result = await loader.push("fr", payload); + + expect(result).not.toBeNull(); + expect(result!.strings["item_count"]).toBeDefined(); + expect(result!.strings["item_count"].localizations).toBeDefined(); + expect(result!.strings["item_count"].localizations["fr"]).toBeDefined(); + expect(result!.strings["item_count"].localizations["fr"]).toHaveProperty( + "variations", + ); + expect( + result!.strings["item_count"].localizations["fr"].variations, + ).toHaveProperty("plural"); + expect( + result!.strings["item_count"].localizations["fr"].variations.plural, + ).toHaveProperty("one"); + expect( + result!.strings["item_count"].localizations["fr"].variations.plural, + ).toHaveProperty("other"); + }); + + it("should push substitutions with plural variations", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + // Must pull first to initialize state (pull both locales to get full state) + await loader.pull(defaultLocale, mockInput); + await loader.pull("es", mockInput); + + const payload = { + notification_message: { + stringUnit: "Vous avez %#@COUNT@ en attente", + substitutions: { + COUNT: { + variations: { + plural: + "{count, plural, one {1 notification} other {%lld notifications}}", + }, + }, + }, + }, + }; + + const result = await loader.push("fr", payload); + + expect( + result!.strings["notification_message"].localizations["fr"].stringUnit, + ).toEqual({ + state: "translated", + value: "Vous avez %#@COUNT@ en attente", + }); + + expect( + result!.strings["notification_message"].localizations["fr"], + ).toHaveProperty("substitutions"); + expect( + result!.strings["notification_message"].localizations["fr"] + .substitutions.COUNT, + ).toHaveProperty("formatSpecifier"); + expect( + result!.strings["notification_message"].localizations["fr"] + .substitutions.COUNT.formatSpecifier, + ).toBe("lld"); + }); + + it("should push plural variations with locale-specific forms (Russian few/many)", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + // Pull English (has one/other) to initialize + await loader.pull(defaultLocale, mockInput); + + // Push Russian with extra plural forms (one/few/many/other) + // This simulates backend adding locale-specific forms + const payload = { + item_count: { + variations: { + plural: + "{count, plural, one {1 артикул} few {%d артикула} many {%d артикулов} other {%d артикулов}}", + }, + }, + }; + + const result = await loader.push("ru", payload); + + expect( + result!.strings["item_count"].localizations["ru"].variations.plural, + ).toHaveProperty("one"); + expect( + result!.strings["item_count"].localizations["ru"].variations.plural, + ).toHaveProperty("few"); + expect( + result!.strings["item_count"].localizations["ru"].variations.plural, + ).toHaveProperty("many"); + expect( + result!.strings["item_count"].localizations["ru"].variations.plural, + ).toHaveProperty("other"); + + // Verify all forms have format specifiers restored (not {variable:0}) + expect( + result!.strings["item_count"].localizations["ru"].variations.plural.few + .stringUnit.value, + ).toBe("%d артикула"); + expect( + result!.strings["item_count"].localizations["ru"].variations.plural.many + .stringUnit.value, + ).toBe("%d артикулов"); + expect( + result!.strings["item_count"].localizations["ru"].variations.plural + .other.stringUnit.value, + ).toBe("%d артикулов"); + }); + + it("should push stringSet arrays", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + // Must pull first to initialize state + await loader.pull(defaultLocale, mockInput); + + const payload = { + phrase: { + stringSet: ["Première variante", "Deuxième variante"], + }, + }; + + const result = await loader.push("fr", payload); + + expect(result!.strings["phrase"].localizations["fr"]).toEqual({ + stringSet: { + state: "translated", + values: ["Première variante", "Deuxième variante"], + }, + }); + }); + + it("should preserve shouldTranslate flag", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + // Must pull first to initialize state + await loader.pull(defaultLocale, mockInput); + + const payload = { + "key.no-translate": { + stringUnit: "Still do not translate", + }, + }; + + const result = await loader.push("fr", payload); + + expect(result!.strings["key.no-translate"].shouldTranslate).toBe(false); + }); + + it("should preserve extractionState from original", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + const inputWithExtraction = { + ...mockInput, + strings: { + test: { + extractionState: "manual", + localizations: { + en: { + stringUnit: { state: "translated", value: "Test" }, + }, + }, + }, + }, + }; + + // Must pull first to initialize state + await loader.pull(defaultLocale, inputWithExtraction); + + const payload = { + test: { + stringUnit: "Prueba", + }, + }; + + const result = await loader.push("es", payload); + + expect(result!.strings.test.extractionState).toBe("manual"); + }); + + it("should push to source locale (defaultLocale)", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + await loader.pull(defaultLocale, mockInput); + + const payload = { + "app.title": { + stringUnit: "Updated App", + }, + }; + + const result = await loader.push("en", payload); + + expect(result!.strings["app.title"].localizations["en"]).toEqual({ + stringUnit: { + state: "translated", + value: "Updated App", + }, + }); + }); + + it("should push exact match forms (=0, =1) from ICU", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + await loader.pull(defaultLocale, mockInput); + + const payload = { + downloads: { + variations: { + plural: + "{count, plural, =0 {No downloads} =1 {One download} other {%d downloads}}", + }, + }, + }; + + const result = await loader.push("fr", payload); + + // Exact matches (=0, =1) should be converted back to CLDR names (zero, one) + expect( + result!.strings["downloads"].localizations["fr"].variations.plural, + ).toHaveProperty("zero"); + expect( + result!.strings["downloads"].localizations["fr"].variations.plural, + ).toHaveProperty("one"); + expect( + result!.strings["downloads"].localizations["fr"].variations.plural[ + "zero" + ].stringUnit.value, + ).toBe("No downloads"); + expect( + result!.strings["downloads"].localizations["fr"].variations.plural[ + "one" + ].stringUnit.value, + ).toBe("One download"); + }); + + it("should push multiple substitutions correctly", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + const inputWithMultipleSubs = { + sourceLanguage: "en", + strings: { + complex: { + localizations: { + en: { + stringUnit: { + state: "translated", + value: "%#@FILES@ in %#@FOLDERS@", + }, + substitutions: { + FILES: { + formatSpecifier: "d", + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 file", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d files", + }, + }, + }, + }, + }, + FOLDERS: { + formatSpecifier: "d", + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 folder", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d folders", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + await loader.pull(defaultLocale, inputWithMultipleSubs); + + const payload = { + complex: { + stringUnit: "%#@FILES@ dans %#@FOLDERS@", + substitutions: { + FILES: { + variations: { + plural: "{count, plural, one {1 fichier} other {%d fichiers}}", + }, + }, + FOLDERS: { + variations: { + plural: "{count, plural, one {1 dossier} other {%d dossiers}}", + }, + }, + }, + }, + }; + + const result = await loader.push("fr", payload); + + expect( + result!.strings["complex"].localizations["fr"].stringUnit.value, + ).toBe("%#@FILES@ dans %#@FOLDERS@"); + expect( + result!.strings["complex"].localizations["fr"].substitutions.FILES + .variations.plural, + ).toHaveProperty("one"); + expect( + result!.strings["complex"].localizations["fr"].substitutions.FILES + .variations.plural, + ).toHaveProperty("other"); + expect( + result!.strings["complex"].localizations["fr"].substitutions.FOLDERS + .variations.plural, + ).toHaveProperty("one"); + expect( + result!.strings["complex"].localizations["fr"].substitutions.FOLDERS + .variations.plural, + ).toHaveProperty("other"); + }); + + it("should handle missing formatSpecifier in substitutions with fallback", async () => { + const inputNoFormatSpec = { + sourceLanguage: "en", + strings: { + test: { + localizations: { + en: { + stringUnit: { + state: "translated", + value: "You have %#@COUNT@", + }, + substitutions: { + COUNT: { + // Missing formatSpecifier + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 item", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d items", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + await loader.pull(defaultLocale, inputNoFormatSpec); + + const payload = { + test: { + stringUnit: "Vous avez %#@COUNT@", + substitutions: { + COUNT: { + variations: { + plural: "{count, plural, one {1 article} other {%d articles}}", + }, + }, + }, + }, + }; + + const result = await loader.push("fr", payload); + + // Should use subName as fallback + expect( + result!.strings["test"].localizations["fr"].substitutions.COUNT + .formatSpecifier, + ).toBe("COUNT"); + }); + + it("should throw error for malformed ICU plural string (unclosed brace)", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + await loader.pull(defaultLocale, mockInput); + + const payload = { + item_count: { + variations: { + plural: "{count, plural, one {1 item} other {%d items}", + }, + }, + }; + + await expect(loader.push("fr", payload)).rejects.toThrow( + /Unclosed brace|Failed to write plural translation/, + ); + }); + + it("should handle nested braces in ICU strings", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + await loader.pull(defaultLocale, mockInput); + + // ICU with nested structure (contrived example but tests parser) + const payload = { + nested: { + variations: { + plural: + "{count, plural, one {Item: {name}} other {Items: {names}}}", + }, + }, + }; + + const result = await loader.push("fr", payload); + + expect( + result!.strings["nested"].localizations["fr"].variations.plural.one + .stringUnit.value, + ).toBe("Item: {name}"); + expect( + result!.strings["nested"].localizations["fr"].variations.plural.other + .stringUnit.value, + ).toBe("Items: {names}"); + }); + }); + + describe("pullHints", () => { + it("should pull hints from comments", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + // Must pull first to initialize state + await loader.pull(defaultLocale, mockInput); + + const hints = await loader.pullHints(); + + expect(hints).toBeDefined(); + expect(hints!["app.title"]).toEqual({ + hint: "The main app title", + }); + expect(hints!["item_count"]).toEqual({ + hint: "Number of items", + }); + expect(hints!["notification_message"]).toEqual({ + hint: "Notification with substitutions", + }); + }); + + it("should return empty object for input without comments", async () => { + const loader = createXcodeXcstringsV2Loader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + const inputNoComments = { + sourceLanguage: "en", + strings: { + test: { + localizations: { + en: { + stringUnit: { state: "translated", value: "Test" }, + }, + }, + }, + }, + }; + + // Must pull first to initialize state + await loader.pull(defaultLocale, inputNoComments); + + const hints = await loader.pullHints(); + + expect(hints).toEqual({}); + }); + }); + + describe("ICU plural form normalization", () => { + it("should convert optional English forms (zero) to exact match (=0)", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const input = { + sourceLanguage: "en", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + zero: { stringUnit: { value: "No items" } }, + one: { stringUnit: { value: "1 item" } }, + other: { stringUnit: { value: "%d items" } }, + }, + }, + }, + }, + }, + }, + }; + + const result = await loader.pull("en", input); + + // English: one/other are required, zero is optional → becomes =0 + expect(result.items.variations.plural).toBe( + "{count, plural, =0 {No items} one {1 item} other {%d items}}", + ); + }); + + it("should keep required Russian forms as CLDR keywords", async () => { + const loader = createXcodeXcstringsV2Loader("ru"); + loader.setDefaultLocale("ru"); + + const input = { + sourceLanguage: "ru", + strings: { + items: { + localizations: { + ru: { + variations: { + plural: { + one: { stringUnit: { value: "1 предмет" } }, + few: { stringUnit: { value: "%d предмета" } }, + many: { stringUnit: { value: "%d предметов" } }, + other: { stringUnit: { value: "%d элементов" } }, + }, + }, + }, + }, + }, + }, + }; + + const result = await loader.pull("ru", input); + + // Russian: all forms are required, stay as CLDR keywords + expect(result.items.variations.plural).toContain("one {"); + expect(result.items.variations.plural).toContain("few {"); + expect(result.items.variations.plural).toContain("many {"); + expect(result.items.variations.plural).toContain("other {"); + expect(result.items.variations.plural).not.toContain("="); + }); + + it("should convert Chinese optional one to =1", async () => { + const loader = createXcodeXcstringsV2Loader("zh"); + loader.setDefaultLocale("zh"); + + const input = { + sourceLanguage: "zh", + strings: { + items: { + localizations: { + zh: { + variations: { + plural: { + one: { stringUnit: { value: "1 件商品" } }, + other: { stringUnit: { value: "%d 件商品" } }, + }, + }, + }, + }, + }, + }, + }; + + const result = await loader.pull("zh", input); + + // Chinese: only "other" is required, "one" is optional → becomes =1 + expect(result.items.variations.plural).toBe( + "{count, plural, =1 {1 件商品} other {%d 件商品}}", + ); + }); + + it("should round-trip: xcstrings → ICU (=0) → xcstrings (zero)", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const originalInput = { + sourceLanguage: "en", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + zero: { stringUnit: { value: "No items" } }, + one: { stringUnit: { value: "1 item" } }, + other: { stringUnit: { value: "%d items" } }, + }, + }, + }, + }, + }, + }, + }; + + // Pull: xcstrings → ICU (converts zero to =0) + const pulled = await loader.pull("en", originalInput); + expect(pulled.items.variations.plural).toContain("=0 {No items}"); + + // Push back: ICU → xcstrings (converts =0 back to zero) + const pushed = await loader.push("en", pulled); + + expect( + pushed.strings.items.localizations.en.variations.plural, + ).toHaveProperty("zero"); + expect( + pushed.strings.items.localizations.en.variations.plural, + ).toHaveProperty("one"); + expect( + pushed.strings.items.localizations.en.variations.plural, + ).toHaveProperty("other"); + expect( + pushed.strings.items.localizations.en.variations.plural.zero.stringUnit + .value, + ).toBe("No items"); + }); + }); + + describe("Backend response filtering", () => { + it("should filter out invalid plural forms for English (keep only one, other, exact matches)", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const originalInput = { + sourceLanguage: "en", + version: "1.0", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { state: "translated", value: "1 item" }, + }, + other: { + stringUnit: { state: "translated", value: "%d items" }, + }, + }, + }, + }, + }, + }, + }, + }; + + // Initialize loader state + await loader.pull("en", originalInput); + + // Simulate backend response with extra forms that English doesn't need + const backendResponse = { + "items/variations/plural": + "{count, plural, one {1 item} few {%d items (few)} many {%d items (many)} other {%d items}}", + }; + + const pushed = await loader.push("en", backendResponse); + + // Should only have 'one' and 'other', not 'few' or 'many' + expect( + pushed.strings.items.localizations.en.variations.plural, + ).toHaveProperty("one"); + expect( + pushed.strings.items.localizations.en.variations.plural, + ).toHaveProperty("other"); + expect( + pushed.strings.items.localizations.en.variations.plural, + ).not.toHaveProperty("few"); + expect( + pushed.strings.items.localizations.en.variations.plural, + ).not.toHaveProperty("many"); + }); + + it("should keep all required plural forms for Russian", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const originalInput = { + sourceLanguage: "en", + version: "1.0", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { state: "translated", value: "1 item" }, + }, + other: { + stringUnit: { state: "translated", value: "%d items" }, + }, + }, + }, + }, + }, + }, + }, + }; + + // Initialize loader state + await loader.pull("en", originalInput); + + // Russian requires: one, few, many, other + const backendResponse = { + items: { + variations: { + plural: + "{count, plural, one {%d товар} few {%d товара} many {%d товаров} other {%d товаров}}", + }, + }, + }; + + const pushed = await loader.push("ru", backendResponse); + + // Should keep all Russian forms + expect( + pushed.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("one"); + expect( + pushed.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("few"); + expect( + pushed.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("many"); + expect( + pushed.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("other"); + }); + + it("should filter out optional forms for Chinese (keep only other, exact matches)", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const originalInput = { + sourceLanguage: "en", + version: "1.0", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { state: "translated", value: "1 item" }, + }, + other: { + stringUnit: { state: "translated", value: "%d items" }, + }, + }, + }, + }, + }, + }, + }, + }; + + // Initialize loader state + await loader.pull("en", originalInput); + + // Chinese only requires 'other', but backend might return 'one' + const backendResponse = { + items: { + variations: { + plural: "{count, plural, one {1 件商品} other {%d 件商品}}", + }, + }, + }; + + const pushed = await loader.push("zh", backendResponse); + + // Should only have 'other', not 'one' + expect( + pushed.strings.items.localizations.zh.variations.plural, + ).not.toHaveProperty("one"); + expect( + pushed.strings.items.localizations.zh.variations.plural, + ).toHaveProperty("other"); + }); + + it("should always preserve exact match forms (=0, =1, =2) for any locale", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const originalInput = { + sourceLanguage: "en", + version: "1.0", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { state: "translated", value: "1 item" }, + }, + other: { + stringUnit: { state: "translated", value: "%d items" }, + }, + }, + }, + }, + }, + }, + }, + }; + + // Initialize loader state + await loader.pull("en", originalInput); + + // Backend returns exact matches along with required forms + const backendResponseEn = { + items: { + variations: { + plural: + "{count, plural, =0 {No items} =1 {One item} other {%d items}}", + }, + }, + }; + + // Test for English + const pushedEn = await loader.push("en", backendResponseEn); + expect( + pushedEn.strings.items.localizations.en.variations.plural, + ).toHaveProperty("zero"); // =0 → zero + expect( + pushedEn.strings.items.localizations.en.variations.plural, + ).toHaveProperty("one"); // =1 → one + expect( + pushedEn.strings.items.localizations.en.variations.plural, + ).toHaveProperty("other"); + + // Test for Chinese (which doesn't normally use 'one', but exact matches should be kept) + const backendResponseZh = { + items: { + variations: { + plural: + "{count, plural, =0 {没有商品} =1 {一件商品} other {%d 件商品}}", + }, + }, + }; + const pushedZh = await loader.push("zh", backendResponseZh); + expect( + pushedZh.strings.items.localizations.zh.variations.plural, + ).toHaveProperty("zero"); // =0 → zero + expect( + pushedZh.strings.items.localizations.zh.variations.plural, + ).toHaveProperty("one"); // =1 → one + expect( + pushedZh.strings.items.localizations.zh.variations.plural, + ).toHaveProperty("other"); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/xcode-xcstrings-v2.ts b/packages/cli/src/cli/loaders/xcode-xcstrings-v2.ts new file mode 100644 index 000000000..1617df8ed --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-xcstrings-v2.ts @@ -0,0 +1,419 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import _ from "lodash"; + +/** + * CLDR plural categories + */ +const CLDR_PLURAL_CATEGORIES = new Set([ + "zero", + "one", + "two", + "few", + "many", + "other", +]); + +/** + * Get CLDR plural categories used by a locale + * @param locale - The locale to check (e.g., "en", "ru", "zh") + * @returns Array of plural category names used by this locale + */ +function getRequiredPluralCategories(locale: string): string[] { + try { + const pluralRules = new Intl.PluralRules(locale); + const categories = pluralRules.resolvedOptions().pluralCategories; + if (!categories || categories.length === 0) { + return ["other"]; + } + return categories; + } catch (error) { + // Fallback for unsupported locales - 'other' is the only universally required form + return ["other"]; + } +} + +/** + * Check if a plural form is valid for a given locale + * Always allows exact match forms (=0, =1, =2) + * @param form - The plural form to check (e.g., "one", "few", "=0") + * @param locale - The target locale + * @returns true if the form should be kept + */ +function isValidPluralForm(form: string, locale: string): boolean { + // Always allow exact match forms (=0, =1, =2, etc.) + if (form.startsWith("=")) return true; + + // Check if form is a required CLDR category for this locale + const requiredCategories = getRequiredPluralCategories(locale); + return requiredCategories.includes(form); +} + +/** + * Build ICU MessageFormat string from xcstrings plural forms + * Converts optional CLDR forms to exact match syntax for better backend understanding + * @param forms - Plural forms from xcstrings + * @param sourceLocale - Source language locale to determine required vs optional forms + * @returns ICU MessageFormat string + */ +function buildIcuPluralString( + forms: Record, + sourceLocale: string, +): string { + const requiredCategories = new Set(getRequiredPluralCategories(sourceLocale)); + + const parts = Object.entries(forms).map(([form, text]) => { + // Convert optional CLDR forms to exact match syntax + let normalizedForm = form; + if (!requiredCategories.has(form)) { + if (form === "zero") normalizedForm = "=0"; + else if (form === "one") normalizedForm = "=1"; + else if (form === "two") normalizedForm = "=2"; + } + return `${normalizedForm} {${text}}`; + }); + + return `{count, plural, ${parts.join(" ")}}`; +} + +function parseIcuPluralString( + icuString: string, + locale: string, +): Record { + const pluralMatch = icuString.match(/\{[\w]+,\s*plural,\s*(.+)\}$/); + if (!pluralMatch) { + throw new Error(`Invalid ICU plural format: ${icuString}`); + } + + const formsText = pluralMatch[1]; + const forms: Record = {}; + const exactMatches = new Set(); // Track which forms came from exact matches + + let i = 0; + while (i < formsText.length) { + // Skip whitespace + while (i < formsText.length && /\s/.test(formsText[i])) { + i++; + } + + if (i >= formsText.length) break; + + // Read form name (e.g., "one", "other", "=0") + let formName = ""; + if (formsText[i] === "=") { + // Exact match like =0, =1 + formName += formsText[i]; + i++; + while (i < formsText.length && /\d/.test(formsText[i])) { + formName += formsText[i]; + i++; + } + } else { + // CLDR keyword like "one", "other" + while (i < formsText.length && /\w/.test(formsText[i])) { + formName += formsText[i]; + i++; + } + } + + if (!formName) break; + + // Convert exact match syntax back to CLDR category names + if (formName === "=0") { + formName = "zero"; + exactMatches.add("zero"); + } else if (formName === "=1") { + formName = "one"; + exactMatches.add("one"); + } else if (formName === "=2") { + formName = "two"; + exactMatches.add("two"); + } + + // Skip whitespace and find opening brace + while (i < formsText.length && /\s/.test(formsText[i])) { + i++; + } + + if (i >= formsText.length || formsText[i] !== "{") { + throw new Error(`Expected '{' after form name '${formName}'`); + } + + // Find matching closing brace + i++; // skip opening brace + let braceCount = 1; + let formText = ""; + + while (i < formsText.length && braceCount > 0) { + if (formsText[i] === "{") { + braceCount++; + formText += formsText[i]; + } else if (formsText[i] === "}") { + braceCount--; + if (braceCount > 0) { + formText += formsText[i]; + } + } else { + formText += formsText[i]; + } + i++; + } + + if (braceCount !== 0) { + throw new Error( + `Unclosed brace for form '${formName}' in ICU: ${icuString}`, + ); + } + + forms[formName] = formText; + } + + const filteredForms: Record = {}; + for (const [form, text] of Object.entries(forms)) { + if (exactMatches.has(form) || isValidPluralForm(form, locale)) { + filteredForms[form] = text; + } + } + + return filteredForms; +} + +/** + * Check if a value looks like an ICU plural string + */ +function isIcuPluralString(value: any): boolean { + return typeof value === "string" && /^\{[\w]+,\s*plural,\s*.+\}$/.test(value); +} + +export default function createXcodeXcstringsV2Loader( + defaultLocale: string, +): ILoader, Record> { + return createLoader({ + async pull(locale, input, initCtx) { + const resultData: Record = {}; + const isSourceLanguage = locale === defaultLocale; + + for (const [translationKey, _translationEntity] of Object.entries( + (input as any).strings, + )) { + const rootTranslationEntity = _translationEntity as any; + + if (rootTranslationEntity.shouldTranslate === false) { + continue; + } + + const langTranslationEntity = + rootTranslationEntity?.localizations?.[locale]; + + if (langTranslationEntity) { + if (!resultData[translationKey]) { + resultData[translationKey] = {}; + } + + if ("stringUnit" in langTranslationEntity) { + resultData[translationKey].stringUnit = + langTranslationEntity.stringUnit.value; + + if ("substitutions" in langTranslationEntity) { + resultData[translationKey].substitutions = {}; + + for (const [subName, subData] of Object.entries( + langTranslationEntity.substitutions as any, + )) { + const pluralForms = (subData as any).variations?.plural; + if (pluralForms) { + const forms: Record = {}; + for (const [form, formData] of Object.entries(pluralForms)) { + forms[form] = (formData as any).stringUnit.value; + } + + const icuString = buildIcuPluralString(forms, locale); + resultData[translationKey].substitutions[subName] = { + variations: { + plural: icuString, + }, + }; + } + } + } + } else if ("stringSet" in langTranslationEntity) { + const values = langTranslationEntity.stringSet.values; + if (Array.isArray(values) && values.length > 0) { + resultData[translationKey].stringSet = values; + } + } else if ("variations" in langTranslationEntity) { + if ("plural" in langTranslationEntity.variations) { + const pluralForms = langTranslationEntity.variations.plural; + + const forms: Record = {}; + for (const [form, formData] of Object.entries(pluralForms)) { + if ((formData as any)?.stringUnit?.value) { + forms[form] = (formData as any).stringUnit.value; + } + } + + const icuString = buildIcuPluralString(forms, locale); + resultData[translationKey].variations = { + plural: icuString, + }; + } + } + } else if (isSourceLanguage) { + if (!resultData[translationKey]) { + resultData[translationKey] = {}; + } + resultData[translationKey].stringUnit = translationKey; + } + } + + return resultData; + }, + + async push(locale, payload, originalInput) { + const langDataToMerge: any = {}; + langDataToMerge.strings = {}; + + const input = _.cloneDeep(originalInput) || { + sourceLanguage: locale, + strings: {}, + }; + + for (const [baseKey, keyData] of Object.entries(payload)) { + if (!keyData || typeof keyData !== "object") { + continue; + } + + const hasDoNotTranslateFlag = + originalInput && + (originalInput as any).strings && + (originalInput as any).strings[baseKey] && + (originalInput as any).strings[baseKey].shouldTranslate === false; + + const localizationData: any = {}; + + if ("stringUnit" in keyData) { + localizationData.stringUnit = { + state: "translated", + value: keyData.stringUnit, + }; + } + + if ("substitutions" in keyData && keyData.substitutions) { + const subs: any = {}; + + for (const [subName, subData] of Object.entries( + keyData.substitutions as any, + )) { + const pluralValue = (subData as any)?.variations?.plural; + + if (pluralValue && isIcuPluralString(pluralValue)) { + try { + const pluralForms = parseIcuPluralString(pluralValue, locale); + const pluralOut: any = {}; + for (const [form, text] of Object.entries(pluralForms)) { + pluralOut[form] = { + stringUnit: { + state: "translated", + value: text, + }, + }; + } + + const sourceLocale = + (originalInput as any)?.sourceLanguage || "en"; + const origFormatSpec = + (originalInput as any)?.strings?.[baseKey]?.localizations?.[ + sourceLocale + ]?.substitutions?.[subName]?.formatSpecifier || subName; + + subs[subName] = { + formatSpecifier: origFormatSpec, + variations: { + plural: pluralOut, + }, + }; + } catch (error) { + throw new Error( + `Failed to write substitution plural translation for key "${baseKey}/substitutions/${subName}" (locale: ${locale}).\n` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + if (Object.keys(subs).length > 0) { + localizationData.substitutions = subs; + } + } + + if ("stringSet" in keyData && Array.isArray(keyData.stringSet)) { + localizationData.stringSet = { + state: "translated", + values: keyData.stringSet, + }; + } + + if ("variations" in keyData && keyData.variations?.plural) { + const pluralValue = keyData.variations.plural; + + if (isIcuPluralString(pluralValue)) { + try { + const pluralForms = parseIcuPluralString(pluralValue, locale); + const pluralOut: any = {}; + for (const [form, text] of Object.entries(pluralForms)) { + pluralOut[form] = { + stringUnit: { + state: "translated", + value: text, + }, + }; + } + + localizationData.variations = { + plural: pluralOut, + }; + } catch (error) { + throw new Error( + `Failed to write plural translation for key "${baseKey}" (locale: ${locale}).\n` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + if (Object.keys(localizationData).length > 0) { + langDataToMerge.strings[baseKey] = { + extractionState: originalInput?.strings?.[baseKey]?.extractionState, + localizations: { + [locale]: localizationData, + }, + }; + + if (hasDoNotTranslateFlag) { + langDataToMerge.strings[baseKey].shouldTranslate = false; + } + } + } + + return _.merge(input, langDataToMerge); + }, + + async pullHints(input) { + const hints: Record = {}; + + for (const [translationKey, _translationEntity] of Object.entries( + (input as any).strings || {}, + )) { + const rootTranslationEntity = _translationEntity as any; + if ( + rootTranslationEntity.comment && + typeof rootTranslationEntity.comment === "string" + ) { + hints[translationKey] = { hint: rootTranslationEntity.comment }; + } + } + + return hints; + }, + }); +} diff --git a/packages/cli/src/cli/loaders/xcode-xcstrings.spec.ts b/packages/cli/src/cli/loaders/xcode-xcstrings.spec.ts new file mode 100644 index 000000000..25e563bcd --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-xcstrings.spec.ts @@ -0,0 +1,652 @@ +import { describe, it, expect } from "vitest"; +import createXcodeXcstringsLoader, { _removeLocale } from "./xcode-xcstrings"; + +describe("loaders/xcode-xcstrings", () => { + const defaultLocale = "en"; + const mockInput = { + sourceLanguage: "en", + strings: { + "app.title": { + localizations: { + en: { + stringUnit: { + state: "translated", + value: "My App", + }, + }, + es: { + stringUnit: { + state: "translated", + value: "Mi App", + }, + }, + }, + }, + "items.count": { + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 item", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d items", + }, + }, + }, + }, + }, + es: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 artículo", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d artículos", + }, + }, + }, + }, + }, + }, + }, + "key.no-translate": { + shouldTranslate: false, + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Do not translate", + }, + }, + }, + }, + "key.source-only": { + localizations: {}, + }, + "key.missing-localization": { + localizations: { + es: { + stringUnit: { + state: "translated", + value: "solo español", + }, + }, + }, + }, + }, + version: "1.0", + }; + + describe("pull", () => { + it("should pull simple string translations for a given locale", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const result = await loader.pull("es", mockInput); + expect(result).toEqual({ + "app.title": "Mi App", + "items.count": { + one: "1 artículo", + other: "%d artículos", + }, + "key.missing-localization": "solo español", + }); + }); + + it("should pull plural translations for a given locale", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + const result = await loader.pull("en", mockInput); + expect(result["items.count"]).toEqual({ + one: "1 item", + other: "%d items", + }); + }); + + it("should use the key as value for the source language if no translation is available", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + const result = await loader.pull("en", mockInput); + expect(result["key.source-only"]).toBe("key.source-only"); + expect(result["key.missing-localization"]).toBe( + "key.missing-localization", + ); + }); + + it("should not use key as value if not source language", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const result = await loader.pull("es", mockInput); + expect(result["key.source-only"]).toBeUndefined(); + }); + + it("should skip keys marked with shouldTranslate: false", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + const result = await loader.pull("en", mockInput); + expect(result["key.no-translate"]).toBeUndefined(); + }); + + it("should return an empty object for a locale with no translations", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const result = await loader.pull("fr", mockInput); + expect(result).toEqual({}); + }); + }); + + describe("push", () => { + it("should push simple string translations", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const payload = { + "app.title": "Mon App", + }; + const result = await loader.push("fr", payload); + expect(result).not.toBeNull(); + expect(result!.version).toBe("1.0"); + expect(result!.strings["app.title"].localizations.fr).toEqual({ + stringUnit: { + state: "translated", + value: "Mon App", + }, + }); + }); + + it("should push plural translations in plain object format", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const payload = { + "items.count": { + one: "1 article", + other: "%d articles", + }, + }; + const result = await loader.push("fr", payload); + expect(result).not.toBeNull(); + expect(result!.strings["items.count"].localizations.fr).toEqual({ + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 article", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d articles", + }, + }, + }, + }, + }); + }); + + it("should merge translations into existing input", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const payload = { + "app.title": "Mi App (actualizado)", + }; + const result = await loader.push("es", payload); + expect(result).not.toBeNull(); + // check new value + expect( + result!.strings["app.title"].localizations.es.stringUnit.value, + ).toBe("Mi App (actualizado)"); + // check existing value is untouched + expect( + result!.strings["app.title"].localizations.en.stringUnit.value, + ).toBe("My App"); + }); + + it("should preserve the shouldTranslate: false flag", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const payload = { + "key.no-translate": "Ne pas traduire", + }; + const result = await loader.push("fr", payload); + expect(result).not.toBeNull(); + expect(result!.strings["key.no-translate"].shouldTranslate).toBe(false); + expect( + result!.strings["key.no-translate"].localizations.fr.stringUnit.value, + ).toBe("Ne pas traduire"); + }); + + it("should handle pushing to a null or undefined originalInput", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, { strings: {} }); + const payload = { + greeting: "Hello", + }; + const result = await loader.push("en", payload); + expect(result).toEqual({ + strings: { + greeting: { + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Hello", + }, + }, + }, + }, + }, + }); + }); + + it("should skip null and undefined values in payload", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const payload = { + "app.title": "new title", + "key.null": null, + "key.undefined": undefined, + }; + const result = await loader.push("en", payload); + expect(result).not.toBeNull(); + expect(Object.keys(result!.strings)).not.toContain("key.null"); + expect(Object.keys(result!.strings)).not.toContain("key.undefined"); + expect( + result!.strings["app.title"].localizations.en.stringUnit.value, + ).toBe("new title"); + }); + + it("should remove the pushed locale from original input", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + const payload = { + "app.title": "new title", + }; + const result = await loader.push("en", payload); + expect(result).not.toBeNull(); + expect(result!.strings["app.title"].localizations.en.stringUnit).toEqual({ + state: "translated", + value: "new title", + }); + expect(result!.strings["items.count"].localizations.en).toBeUndefined(); + expect( + result!.strings["key.no-translate"].localizations.en, + ).toBeUndefined(); + expect( + result!.strings["key.source-only"].localizations.en, + ).toBeUndefined(); + expect( + result!.strings["key.missing-localization"].localizations.en, + ).toBeUndefined(); + }); + }); + + describe("stringSet support", () => { + it("should pull and push stringSet.values as array", async () => { + const input = { + sourceLanguage: "en", + strings: { + "app.shortcut": { + extractionState: "extracted_with_value", + localizations: { + en: { stringSet: { state: "new", values: ["Open", "Launch"] } }, + }, + }, + }, + version: "1.0", + }; + + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, input); + const pulled = await loader.pull("en", input); + + expect(pulled["app.shortcut"]).toEqual(["Open", "Launch"]); + + const pushed = await loader.push("es", { "app.shortcut": ["Abrir"] }, input); + expect(pushed!.strings["app.shortcut"].localizations.es).toEqual({ + stringSet: { state: "translated", values: ["Abrir"] }, + }); + }); + }); + + describe("_removeLocale", () => { + it("should remove the locale from the input", () => { + const input = { + sourceLanguage: "en", + strings: { + key1: { + localizations: { + en: { stringUnit: { state: "translated", value: "Hello" } }, + es: { stringUnit: { state: "translated", value: "Hola" } }, + }, + }, + key2: { + localizations: { + en: { stringUnit: { state: "translated", value: "World" } }, + fr: { stringUnit: { state: "translated", value: "Monde" } }, + }, + }, + key3: { + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { state: "translated", value: "1 item" }, + }, + }, + }, + }, + fr: { + variations: { + plural: { + one: { + stringUnit: { state: "translated", value: "1 article" }, + }, + }, + }, + }, + }, + }, + }, + }; + const result = _removeLocale(input, "en"); + expect(result).toEqual({ + sourceLanguage: "en", + strings: { + key1: { + localizations: { + es: { stringUnit: { state: "translated", value: "Hola" } }, + }, + }, + key2: { + localizations: { + fr: { stringUnit: { state: "translated", value: "Monde" } }, + }, + }, + key3: { + localizations: { + fr: { + variations: { + plural: { + one: { + stringUnit: { state: "translated", value: "1 article" }, + }, + }, + }, + }, + }, + }, + }, + }); + }); + + it("should do nothing if the locale does not exist", () => { + const input = { + sourceLanguage: "en", + strings: { + key1: { + localizations: { + en: { stringUnit: { state: "translated", value: "Hello" } }, + es: { stringUnit: { state: "translated", value: "Hola" } }, + }, + }, + }, + }; + const result = _removeLocale(input, "fr"); + expect(result).toEqual({ + sourceLanguage: "en", + strings: { + key1: { + localizations: { + en: { stringUnit: { state: "translated", value: "Hello" } }, + es: { stringUnit: { state: "translated", value: "Hola" } }, + }, + }, + }, + }); + }); + + it("should handle empty strings object", () => { + const input = { + sourceLanguage: "en", + strings: {}, + }; + const result = _removeLocale(input, "en"); + expect(result).toEqual({ + sourceLanguage: "en", + strings: {}, + }); + }); + + it("should handle keys with no localizations", () => { + const input = { + sourceLanguage: "en", + strings: { + key1: { + localizations: {}, + }, + }, + }; + const result = _removeLocale(input, "en"); + expect(result).toEqual({ + sourceLanguage: "en", + strings: { + key1: { + localizations: {}, + }, + }, + }); + }); + }); + + describe("pullHints", () => { + it("should extract comments from xcstrings format", async () => { + const inputWithComments = { + sourceLanguage: "en", + strings: { + welcome_message: { + comment: "Greeting shown on the main screen", + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Welcome!", + }, + }, + }, + }, + user_count: { + comment: "Number of active users", + extractionState: "manual", + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 user", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d users", + }, + }, + }, + }, + }, + }, + }, + no_comment_key: { + extractionState: "manual", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "No comment", + }, + }, + }, + }, + }, + }; + + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, inputWithComments); + + const hints = await loader.pullHints(inputWithComments); + + expect(hints).toEqual({ + welcome_message: { hint: "Greeting shown on the main screen" }, + user_count: { hint: "Number of active users" }, + "user_count/one": { hint: "Number of active users" }, + "user_count/other": { hint: "Number of active users" }, + }); + }); + + it("should handle empty input", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + + const hints1 = await loader.pullHints({}); + expect(hints1).toEqual({}); + + const hints2 = await loader.pullHints(null as any); + expect(hints2).toEqual({}); + + const hints3 = await loader.pullHints(undefined as any); + expect(hints3).toEqual({}); + }); + + it("should handle xcstrings without comments", async () => { + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, mockInput); + + const hints = await loader.pullHints(mockInput); + expect(hints).toEqual({}); + }); + + it("should handle strings with only some having comments", async () => { + const inputWithMixedComments = { + sourceLanguage: "en", + strings: { + with_comment: { + comment: "This has a comment", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Value with comment", + }, + }, + }, + }, + without_comment: { + localizations: { + en: { + stringUnit: { + state: "translated", + value: "Value without comment", + }, + }, + }, + }, + }, + }; + + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, inputWithMixedComments); + + const hints = await loader.pullHints(inputWithMixedComments); + + expect(hints).toEqual({ + with_comment: { hint: "This has a comment" }, + }); + }); + + it("should handle multiple locales with same comment", async () => { + const inputWithMultipleLocales = { + sourceLanguage: "en", + strings: { + multi_locale: { + comment: "Available in multiple languages", + localizations: { + en: { + stringUnit: { + state: "translated", + value: "English", + }, + }, + es: { + stringUnit: { + state: "translated", + value: "Español", + }, + }, + fr: { + variations: { + plural: { + one: { + stringUnit: { + state: "translated", + value: "1 français", + }, + }, + other: { + stringUnit: { + state: "translated", + value: "%d français", + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const loader = createXcodeXcstringsLoader(defaultLocale); + loader.setDefaultLocale(defaultLocale); + await loader.pull(defaultLocale, inputWithMultipleLocales); + + const hints = await loader.pullHints(inputWithMultipleLocales); + + expect(hints).toEqual({ + multi_locale: { hint: "Available in multiple languages" }, + "multi_locale/one": { hint: "Available in multiple languages" }, + "multi_locale/other": { hint: "Available in multiple languages" }, + }); + }); + }); +}); diff --git a/packages/cli/src/cli/loaders/xcode-xcstrings.ts b/packages/cli/src/cli/loaders/xcode-xcstrings.ts new file mode 100644 index 000000000..c51a711f3 --- /dev/null +++ b/packages/cli/src/cli/loaders/xcode-xcstrings.ts @@ -0,0 +1,189 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import _ from "lodash"; + +export default function createXcodeXcstringsLoader( + defaultLocale: string, +): ILoader, Record> { + return createLoader({ + async pull(locale, input, initCtx) { + const resultData: Record = {}; + const isSourceLanguage = locale === defaultLocale; + + for (const [translationKey, _translationEntity] of Object.entries( + (input as any).strings, + )) { + const rootTranslationEntity = _translationEntity as any; + + if (rootTranslationEntity.shouldTranslate === false) { + continue; + } + + const langTranslationEntity = + rootTranslationEntity?.localizations?.[locale]; + + if (langTranslationEntity) { + if ("stringUnit" in langTranslationEntity) { + resultData[translationKey] = langTranslationEntity.stringUnit.value; + } else if ("stringSet" in langTranslationEntity) { + const values = langTranslationEntity.stringSet.values; + if (Array.isArray(values) && values.length > 0) { + resultData[translationKey] = values; + } + } else if ("variations" in langTranslationEntity) { + if ("plural" in langTranslationEntity.variations) { + resultData[translationKey] = {}; + const pluralForms = langTranslationEntity.variations.plural; + for (const form in pluralForms) { + if (pluralForms[form]?.stringUnit?.value) { + resultData[translationKey][form] = + pluralForms[form].stringUnit.value; + } + } + } + } + } else if (isSourceLanguage) { + resultData[translationKey] = translationKey; + } + } + + return resultData; + }, + async push(locale, payload, originalInput) { + const langDataToMerge: any = {}; + langDataToMerge.strings = {}; + + const input = _.cloneDeep(originalInput) || { + sourceLanguage: locale, + strings: {}, + }; + + for (const [key, value] of Object.entries(payload)) { + if (value === null || value === undefined) { + continue; + } + + const hasDoNotTranslateFlag = + originalInput && + (originalInput as any).strings && + (originalInput as any).strings[key] && + (originalInput as any).strings[key].shouldTranslate === false; + + if (typeof value === "string") { + langDataToMerge.strings[key] = { + extractionState: originalInput?.strings?.[key]?.extractionState, + localizations: { + [locale]: { + stringUnit: { + state: "translated", + value, + }, + }, + }, + }; + + if (hasDoNotTranslateFlag) { + langDataToMerge.strings[key].shouldTranslate = false; + } + } else if (Array.isArray(value)) { + langDataToMerge.strings[key] = { + extractionState: originalInput?.strings?.[key]?.extractionState, + localizations: { + [locale]: { + stringSet: { + state: "translated", + values: value, + }, + }, + }, + }; + + if (hasDoNotTranslateFlag) { + langDataToMerge.strings[key].shouldTranslate = false; + } + } else { + const updatedVariations: any = {}; + + for (const form in value) { + updatedVariations[form] = { + stringUnit: { + state: "translated", + value: value[form], + }, + }; + } + + langDataToMerge.strings[key] = { + extractionState: "manual", + localizations: { + [locale]: { + variations: { + plural: updatedVariations, + }, + }, + }, + }; + + if (hasDoNotTranslateFlag) { + langDataToMerge.strings[key].shouldTranslate = false; + } + } + } + + const originalInputWithoutLocale = originalInput + ? _removeLocale(originalInput, locale) + : {}; + + const result = _.merge({}, originalInputWithoutLocale, langDataToMerge); + return result; + }, + async pullHints(originalInput) { + if (!originalInput || !originalInput.strings) { + return {}; + } + + const hints: Record = {}; + + for (const [translationKey, translationEntity] of Object.entries( + originalInput.strings, + )) { + const entity = translationEntity as any; + + // Extract comment field if it exists + if (entity.comment && typeof entity.comment === "string") { + hints[translationKey] = { hint: entity.comment }; + } + + // For plural forms, we might want to include the base comment for all variants + if (entity.localizations) { + for (const [locale, localization] of Object.entries( + entity.localizations, + )) { + if ((localization as any).variations?.plural) { + const pluralForms = (localization as any).variations.plural; + for (const form in pluralForms) { + const pluralKey = `${translationKey}/${form}`; + if (entity.comment && typeof entity.comment === "string") { + hints[pluralKey] = { hint: entity.comment }; + } + } + } + } + } + } + + return hints; + }, + }); +} + +export function _removeLocale(input: Record, locale: string) { + const { strings } = input; + const newStrings = _.cloneDeep(strings); + for (const [key, value] of Object.entries(newStrings)) { + if ((value as any).localizations?.[locale]) { + delete (value as any).localizations[locale]; + } + } + return { ...input, strings: newStrings }; +} diff --git a/packages/cli/src/cli/loaders/xliff.spec.ts b/packages/cli/src/cli/loaders/xliff.spec.ts new file mode 100644 index 000000000..47631c139 --- /dev/null +++ b/packages/cli/src/cli/loaders/xliff.spec.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import dedent from "dedent"; +import createXliffLoader from "./xliff"; + +function normalize(xml: string) { + return xml.trim().replace(/\r?\n/g, "\n"); +} + +describe("XLIFF loader", () => { + it("round-trips a simple file without changes", async () => { + const input = dedent` + + + + + Hello + Hello + + + + `; + + const loader = createXliffLoader(); + loader.setDefaultLocale("en"); + + const data = await loader.pull("en", input); + expect(data).toEqual({ hello: "Hello" }); + + // push back identical payload + const output = await loader.push("en", data); + expect(normalize(output)).toBe(normalize(input)); + }); + + it("handles duplicate resnames deterministically", async () => { + const input = dedent` + + + + AA + BB + + + `; + + const loader = createXliffLoader(); + loader.setDefaultLocale("en"); + const pulled = await loader.pull("en", input); + expect(pulled).toEqual({ + dup_key: "A", + "dup_key#b": "B", + }); + + // translate and push + const esPayload = { + dup_key: "AA", + "dup_key#b": "BB", + } as const; + + const esXml = await loader.push("es", esPayload); + + // Pull from Spanish to verify the values were set correctly + const loaderEs = createXliffLoader(); + loaderEs.setDefaultLocale("en"); + await loaderEs.pull("en", input); // pull original first + const pullEs = await loaderEs.pull("es", esXml); + + // Should get the translated values, not the original + expect(pullEs).toEqual({ + dup_key: "AA", + "dup_key#b": "BB", + }); + }); + + it("wraps XML-sensitive target in CDATA", async () => { + const input = dedent` + + + + 5 < 75 < 7 + + + `; + + const loader = createXliffLoader(); + loader.setDefaultLocale("en"); + await loader.pull("en", input); + + const out = await loader.push("es", { expr: "5 < 7 & 8 > 3" }); + + expect(out.includes(" 3]]>")).toBe(true); + }); + + it("creates skeleton for missing locale", async () => { + const loader = createXliffLoader(); + loader.setDefaultLocale("en"); + + // pulling default locale from scratch (empty) + await loader.pull("en", ""); + + const payload = { key1: "Valor" }; + const esXml = await loader.push("es", payload); + + // Ensure skeleton contains our translated value + expect(esXml.includes("Valor")).toBe(true); + expect(esXml.includes('target-language="es"')); + }); +}); diff --git a/packages/cli/src/cli/loaders/xliff.ts b/packages/cli/src/cli/loaders/xliff.ts new file mode 100644 index 000000000..f57bd9b63 --- /dev/null +++ b/packages/cli/src/cli/loaders/xliff.ts @@ -0,0 +1,590 @@ +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; +import { JSDOM } from "jsdom"; + +/** + * Creates a comprehensive XLIFF loader supporting versions 1.2 and 2.0 + * with deterministic key generation and structure preservation + */ +export default function createXliffLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, input, _ctx, originalLocale) { + const trimmedInput = (input ?? "").trim(); + + if (!trimmedInput) { + return createEmptyResult(originalLocale, locale); + } + + try { + const dom = new JSDOM(trimmedInput, { contentType: "text/xml" }); + const document = dom.window.document; + + // Check for parsing errors + const parserError = document.querySelector("parsererror"); + if (parserError) { + throw new Error(`XML parsing failed: ${parserError.textContent}`); + } + + const xliffElement = document.documentElement; + if (!xliffElement || xliffElement.tagName !== "xliff") { + throw new Error("Invalid XLIFF: missing root element"); + } + + const version = xliffElement.getAttribute("version") || "1.2"; + const isV2 = version === "2.0"; + + if (isV2) { + return pullV2(xliffElement, locale, originalLocale); + } else { + return pullV1(xliffElement, locale, originalLocale); + } + } catch (error: any) { + throw new Error(`Failed to parse XLIFF file: ${error.message}`); + } + }, + + async push(locale, translations, originalInput, originalLocale, pullInput) { + if (!originalInput) { + // Create new file from scratch + return pushNewFile(locale, translations, originalLocale); + } + + try { + const dom = new JSDOM(originalInput, { contentType: "text/xml" }); + const document = dom.window.document; + const xliffElement = document.documentElement; + const version = xliffElement.getAttribute("version") || "1.2"; + const isV2 = version === "2.0"; + + if (isV2) { + return pushV2( + dom, + xliffElement, + locale, + translations, + originalLocale, + originalInput, + ); + } else { + return pushV1( + dom, + xliffElement, + locale, + translations, + originalLocale, + originalInput, + ); + } + } catch (error: any) { + throw new Error(`Failed to update XLIFF file: ${error.message}`); + } + }, + }); +} + +/* -------------------------------------------------------------------------- */ +/* Version 1.2 Support */ +/* -------------------------------------------------------------------------- */ + +function pullV1( + xliffElement: Element, + locale: string, + originalLocale: string, +): Record { + const result: Record = {}; + const fileElement = xliffElement.querySelector("file"); + + if (!fileElement) { + return result; + } + + const sourceLanguage = + fileElement.getAttribute("source-language") || originalLocale; + const isSourceLocale = sourceLanguage === locale; + const bodyElement = fileElement.querySelector("body"); + + if (!bodyElement) { + return result; + } + + const transUnits = bodyElement.querySelectorAll("trans-unit"); + const seenKeys = new Set(); + + transUnits.forEach((unit) => { + let key = getTransUnitKey(unit as Element); + if (!key) return; + + // Handle duplicates deterministically + if (seenKeys.has(key)) { + const id = (unit as Element).getAttribute("id")?.trim(); + if (id) { + key = `${key}#${id}`; + } else { + let counter = 1; + let newKey = `${key}__${counter}`; + while (seenKeys.has(newKey)) { + counter++; + newKey = `${key}__${counter}`; + } + key = newKey; + } + } + seenKeys.add(key); + + const elementName = isSourceLocale ? "source" : "target"; + const textElement = (unit as Element).querySelector(elementName); + + if (textElement) { + result[key] = extractTextContent(textElement); + } else if (isSourceLocale) { + result[key] = key; // fallback for source + } else { + result[key] = ""; // empty for missing target + } + }); + + return result; +} + +function pushV1( + dom: JSDOM, + xliffElement: Element, + locale: string, + translations: Record, + originalLocale: string, + originalInput?: string, +): string { + const document = dom.window.document; + const fileElement = xliffElement.querySelector("file"); + + if (!fileElement) { + throw new Error("Invalid XLIFF 1.2: missing element"); + } + + // Update language attributes + const sourceLanguage = + fileElement.getAttribute("source-language") || originalLocale; + const isSourceLocale = sourceLanguage === locale; + + if (!isSourceLocale) { + fileElement.setAttribute("target-language", locale); + } + + let bodyElement = fileElement.querySelector("body"); + if (!bodyElement) { + bodyElement = document.createElement("body"); + fileElement.appendChild(bodyElement); + } + + // Build current index + const existingUnits = new Map(); + const seenKeys = new Set(); + + bodyElement.querySelectorAll("trans-unit").forEach((unit) => { + let key = getTransUnitKey(unit as Element); + if (!key) return; + + if (seenKeys.has(key)) { + const id = (unit as Element).getAttribute("id")?.trim(); + if (id) { + key = `${key}#${id}`; + } else { + let counter = 1; + let newKey = `${key}__${counter}`; + while (seenKeys.has(newKey)) { + counter++; + newKey = `${key}__${counter}`; + } + key = newKey; + } + } + seenKeys.add(key); + existingUnits.set(key, unit as Element); + }); + + // Update/create translation units + Object.entries(translations).forEach(([key, value]) => { + let unit = existingUnits.get(key); + + if (!unit) { + unit = document.createElement("trans-unit"); + unit.setAttribute("id", key); + unit.setAttribute("resname", key); + unit.setAttribute("restype", "string"); + unit.setAttribute("datatype", "plaintext"); + + const sourceElement = document.createElement("source"); + setTextContent(sourceElement, isSourceLocale ? value : key); + unit.appendChild(sourceElement); + + if (!isSourceLocale) { + const targetElement = document.createElement("target"); + targetElement.setAttribute("state", value ? "translated" : "new"); + setTextContent(targetElement, value); + unit.appendChild(targetElement); + } + + bodyElement.appendChild(unit); + existingUnits.set(key, unit); + } else { + updateTransUnitV1(unit, key, value, isSourceLocale); + } + }); + + // Remove orphaned units + const translationKeys = new Set(Object.keys(translations)); + existingUnits.forEach((unit, key) => { + if (!translationKeys.has(key)) { + unit.parentNode?.removeChild(unit); + } + }); + + return serializeWithDeclaration( + dom, + extractXmlDeclaration(originalInput || ""), + ); +} + +function updateTransUnitV1( + unit: Element, + key: string, + value: string, + isSourceLocale: boolean, +): void { + const document = unit.ownerDocument!; + + if (isSourceLocale) { + let sourceElement = unit.querySelector("source"); + if (!sourceElement) { + sourceElement = document.createElement("source"); + unit.appendChild(sourceElement); + } + setTextContent(sourceElement, value); + } else { + let targetElement = unit.querySelector("target"); + if (!targetElement) { + targetElement = document.createElement("target"); + unit.appendChild(targetElement); + } + + setTextContent(targetElement, value); + targetElement.setAttribute("state", value.trim() ? "translated" : "new"); + } +} + +/* -------------------------------------------------------------------------- */ +/* Version 2.0 Support */ +/* -------------------------------------------------------------------------- */ + +function pullV2( + xliffElement: Element, + locale: string, + originalLocale: string, +): Record { + const result: Record = {}; + + // Add source language metadata + const srcLang = xliffElement.getAttribute("srcLang") || originalLocale; + result.sourceLanguage = srcLang; + + const fileElements = xliffElement.querySelectorAll("file"); + + fileElements.forEach((fileElement) => { + const fileId = fileElement.getAttribute("id"); + if (!fileId) return; + + traverseUnitsV2(fileElement, fileId, "", result); + }); + + return result; +} + +function traverseUnitsV2( + container: Element, + fileId: string, + currentPath: string, + result: Record, +): void { + Array.from(container.children).forEach((child) => { + const tagName = child.tagName; + + if (tagName === "unit") { + const unitId = child.getAttribute("id")?.trim(); + if (!unitId) return; + + const key = `resources/${fileId}/${currentPath}${unitId}/source`; + const segment = child.querySelector("segment"); + const source = segment?.querySelector("source"); + + if (source) { + result[key] = extractTextContent(source); + } else { + result[key] = unitId; // fallback + } + } else if (tagName === "group") { + const groupId = child.getAttribute("id")?.trim(); + const newPath = groupId + ? `${currentPath}${groupId}/groupUnits/` + : currentPath; + traverseUnitsV2(child, fileId, newPath, result); + } + }); +} + +function pushV2( + dom: JSDOM, + xliffElement: Element, + locale: string, + translations: Record, + originalLocale: string, + originalInput?: string, +): string { + const document = dom.window.document; + + // Handle sourceLanguage metadata + if (translations.sourceLanguage) { + xliffElement.setAttribute("srcLang", translations.sourceLanguage); + delete translations.sourceLanguage; // Don't process as regular translation + } + + // Build index of existing units + const existingUnits = new Map(); + const fileElements = xliffElement.querySelectorAll("file"); + + fileElements.forEach((fileElement) => { + const fileId = fileElement.getAttribute("id"); + if (!fileId) return; + + indexUnitsV2(fileElement, fileId, "", existingUnits); + }); + + // Update existing units + Object.entries(translations).forEach(([key, value]) => { + const unit = existingUnits.get(key); + if (unit) { + updateUnitV2(unit, value); + } else { + // For new units, we'd need to create the structure + // This is complex in V2 due to the hierarchical nature + console.warn(`Cannot create new unit for key: ${key} in XLIFF 2.0`); + } + }); + + return serializeWithDeclaration( + dom, + extractXmlDeclaration(originalInput || ""), + ); +} + +function indexUnitsV2( + container: Element, + fileId: string, + currentPath: string, + index: Map, +): void { + Array.from(container.children).forEach((child) => { + const tagName = child.tagName; + + if (tagName === "unit") { + const unitId = child.getAttribute("id")?.trim(); + if (!unitId) return; + + const key = `resources/${fileId}/${currentPath}${unitId}/source`; + index.set(key, child); + } else if (tagName === "group") { + const groupId = child.getAttribute("id")?.trim(); + const newPath = groupId + ? `${currentPath}${groupId}/groupUnits/` + : currentPath; + indexUnitsV2(child, fileId, newPath, index); + } + }); +} + +function updateUnitV2(unit: Element, value: string): void { + const document = unit.ownerDocument!; + + let segment = unit.querySelector("segment"); + if (!segment) { + segment = document.createElement("segment"); + unit.appendChild(segment); + } + + let source = segment.querySelector("source"); + if (!source) { + source = document.createElement("source"); + segment.appendChild(source); + } + + setTextContent(source, value); +} + +/* -------------------------------------------------------------------------- */ +/* Utilities */ +/* -------------------------------------------------------------------------- */ + +function getTransUnitKey(transUnit: Element): string { + const resname = transUnit.getAttribute("resname")?.trim(); + if (resname) return resname; + + const id = transUnit.getAttribute("id")?.trim(); + if (id) return id; + + const sourceElement = transUnit.querySelector("source"); + if (sourceElement) { + const sourceText = extractTextContent(sourceElement).trim(); + if (sourceText) return sourceText; + } + + return ""; +} + +function extractTextContent(element: Element): string { + // Handle CDATA sections + const cdataNode = Array.from(element.childNodes).find( + (node) => node.nodeType === element.CDATA_SECTION_NODE, + ); + + if (cdataNode) { + return cdataNode.nodeValue || ""; + } + + return element.textContent || ""; +} + +function setTextContent(element: Element, content: string): void { + const document = element.ownerDocument!; + + // Clear existing content + while (element.firstChild) { + element.removeChild(element.firstChild); + } + + // Use CDATA if content contains XML-sensitive characters + if (/[<>&"']/.test(content)) { + const cdataSection = document.createCDATASection(content); + element.appendChild(cdataSection); + } else { + element.textContent = content; + } +} + +function extractXmlDeclaration(xmlContent: string): string { + const match = xmlContent.match(/^<\?xml[^>]*\?>/); + return match ? match[0] : ""; +} + +function serializeWithDeclaration(dom: JSDOM, declaration: string): string { + let serialized = dom.serialize(); + + // Add proper indentation for readability + serialized = formatXml(serialized); + + if (declaration) { + serialized = `${declaration}\n${serialized}`; + } + + return serialized; +} + +function formatXml(xml: string): string { + // Parse and reformat XML with proper indentation using JSDOM + const dom = new JSDOM(xml, { contentType: "text/xml" }); + const doc = dom.window.document; + + function formatElement(element: Element, depth: number = 0): string { + const indent = " ".repeat(depth); + const tagName = element.tagName; + const attributes = Array.from(element.attributes) + .map((attr) => `${attr.name}="${attr.value}"`) + .join(" "); + + const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`; + + // Check for CDATA sections first + const cdataNode = Array.from(element.childNodes).find( + (node) => node.nodeType === element.CDATA_SECTION_NODE, + ); + + if (cdataNode) { + return `${indent}${openTag}`; + } + + // Check if element has only text content + const textContent = element.textContent?.trim() || ""; + const hasOnlyText = + element.childNodes.length === 1 && element.childNodes[0].nodeType === 3; + + if (hasOnlyText && textContent) { + return `${indent}${openTag}${textContent}`; + } + + // Element has child elements + const children = Array.from(element.children); + if (children.length === 0) { + return `${indent}${openTag}`; + } + + let result = `${indent}${openTag}\n`; + for (const child of children) { + result += formatElement(child, depth + 1) + "\n"; + } + result += `${indent}`; + + return result; + } + + return formatElement(doc.documentElement); +} + +function createEmptyResult( + originalLocale: string, + locale: string, +): Record { + return {}; +} + +function pushNewFile( + locale: string, + translations: Record, + originalLocale: string, +): string { + const skeleton = ` + + +
    + +
    +
    `; + + const dom = new JSDOM(skeleton, { contentType: "text/xml" }); + const document = dom.window.document; + const bodyElement = document.querySelector("body")!; + + Object.entries(translations).forEach(([key, value]) => { + const unit = document.createElement("trans-unit"); + unit.setAttribute("id", key); + unit.setAttribute("resname", key); + unit.setAttribute("restype", "string"); + unit.setAttribute("datatype", "plaintext"); + + const sourceElement = document.createElement("source"); + setTextContent(sourceElement, key); + unit.appendChild(sourceElement); + + const targetElement = document.createElement("target"); + targetElement.setAttribute("state", value ? "translated" : "new"); + setTextContent(targetElement, value); + unit.appendChild(targetElement); + + bodyElement.appendChild(unit); + }); + + return serializeWithDeclaration( + dom, + '', + ); +} diff --git a/packages/cli/src/cli/loaders/xml.ts b/packages/cli/src/cli/loaders/xml.ts new file mode 100644 index 000000000..f979300f6 --- /dev/null +++ b/packages/cli/src/cli/loaders/xml.ts @@ -0,0 +1,52 @@ +import { parseStringPromise, Builder } from "xml2js"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +function normalizeXMLString(xmlString: string): string { + return xmlString + .replace(/\s+/g, " ") + .replace(/>\s+<") + .replace("\n", "") + .trim(); +} + +export default function createXmlLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, input) { + let result: Record = {}; + + try { + const parsed = await parseStringPromise(input, { + explicitArray: false, + mergeAttrs: false, + normalize: true, + preserveChildrenOrder: true, + normalizeTags: true, + includeWhiteChars: true, + trim: true, + }); + result = parsed; + } catch (error) { + console.error("Failed to parse XML:", error); + result = {}; + } + + return result; + }, + + async push(locale, data) { + try { + const builder = new Builder({ headless: true }); + const xmlOutput = builder.buildObject(data); + const expectedOutput = normalizeXMLString(xmlOutput); + return expectedOutput; + } catch (error) { + console.error("Failed to build XML:", error); + return ""; + } + }, + }); +} diff --git a/packages/cli/src/cli/loaders/yaml.spec.ts b/packages/cli/src/cli/loaders/yaml.spec.ts new file mode 100644 index 000000000..e9abaac66 --- /dev/null +++ b/packages/cli/src/cli/loaders/yaml.spec.ts @@ -0,0 +1,453 @@ +import { describe, expect, it } from "vitest"; +import createYamlLoader from "./yaml"; + +describe("yaml loader", () => { + it("pull should parse valid YAML format", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + const yamlInput = `hello: Hello +world: World +nested: + key: value`; + + const result = await loader.pull("en", yamlInput); + expect(result).toEqual({ + hello: "Hello", + world: "World", + nested: { + key: "value", + }, + }); + }); + + it("pull should handle empty input", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + const result = await loader.pull("en", ""); + expect(result).toEqual({}); + }); + + it("pull should parse YAML with literal block scalars", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + const yamlInput = `description: | + This is a multi-line + description with + - bullet points + - and more items`; + + const result = await loader.pull("en", yamlInput); + expect(result).toEqual({ + description: + "This is a multi-line\ndescription with\n- bullet points\n- and more items\n", + }); + }); + + it("push should preserve literal block scalar format when input uses it", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Input with literal block scalar + const yamlInput = `system_prompt: | + You are a compliance expert. + Standard Rules: + 1. **Rule One** + - Do not make guarantees + - Flag phrases: "guaranteed returns" + 2. **Rule Two** + - Past performance disclaimer`; + + await loader.pull("en", yamlInput); + + const data = { + system_prompt: `You are a compliance expert. +Standard Rules: +1. **Rule One** + - Do not make guarantees + - Flag phrases: "guaranteed returns" +2. **Rule Two** + - Past performance disclaimer`, + }; + + const result = await loader.push("en", data, yamlInput); + + // Should NOT contain backslash escaping before dashes + expect(result).not.toContain("\\ -"); + + // Should use literal block scalar format (|- or |) + expect(result).toMatch(/system_prompt:\s*\|[-+]?/); + + // Verify the content is correctly formatted + expect(result).toContain(" - Do not make guarantees"); + expect(result).toContain(" - Flag phrases:"); + expect(result).toContain(" - Past performance disclaimer"); + }); + + it("push should NOT use backslash escaping for indented dashes with literal block scalars", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Original input uses literal block scalar + const originalInput = `content: | + List of items: + - First item + - Second item + - Nested item`; + + await loader.pull("en", originalInput); + + const data = { + content: `List of items: +- First item +- Second item + - Nested item`, + }; + + const result = await loader.push("en", data, originalInput); + + // Critical: Should NOT have backslash escaping + expect(result).not.toMatch(/^\s*\\\s+-/m); + expect(result).not.toContain("\\ -"); + + // Should preserve literal block scalar + expect(result).toMatch(/content:\s*\|[-+]?/); + }); + + it("push should use QUOTE_DOUBLE when original input has quoted strings", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Input with double-quoted strings (no literal block scalars) + const yamlInput = `"hello": "Hello World" +"world": "Another string"`; + + await loader.pull("en", yamlInput); + + const data = { + hello: "Hello World", + world: "Another string", + }; + + const result = await loader.push("en", data, yamlInput); + + // Should use double quotes + expect(result).toContain('"hello"'); + expect(result).toContain('"Hello World"'); + }); + + it("push should default to PLAIN format when no style indicators", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + const yamlInput = `hello: Hello World +world: Another string`; + + await loader.pull("en", yamlInput); + + const data = { + hello: "Hello World", + world: "Another string", + }; + + const result = await loader.push("en", data, yamlInput); + + // Should use plain format (no quotes, no literal scalars for simple strings) + expect(result).toContain("hello: Hello World"); + expect(result).toContain("world: Another string"); + }); + + it("push should handle complex content with literal block scalars and quotes", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Input that has both literal block scalars and embedded quotes + const yamlInput = `system_prompt: | + Rules: + 1. **Rule** + - Do not use "guaranteed" language + - Flag: "no risk" +user_prompt: Simple text`; + + await loader.pull("en", yamlInput); + + const data = { + system_prompt: `Rules: +1. **Rule** + - Do not use "guaranteed" language + - Flag: "no risk"`, + user_prompt: "Simple text", + }; + + const result = await loader.push("en", data, yamlInput); + + // Should detect literal block scalar and use PLAIN format + expect(result).toMatch(/system_prompt:\s*\|[-+]?/); + + // Should NOT have backslash escaping + expect(result).not.toContain("\\ -"); + + // Embedded quotes should be preserved in the content + expect(result).toContain('"guaranteed"'); + expect(result).toContain('"no risk"'); + }); + + it("pull should handle YAML with comments", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + const yamlInput = `# This is a comment +hello: Hello # inline comment +world: World`; + + const result = await loader.pull("en", yamlInput); + expect(result).toEqual({ + hello: "Hello", + world: "World", + }); + }); + + it("push should handle empty object", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + await loader.pull("en", "{}"); + + const result = await loader.push("en", {}); + expect(result.trim()).toBe("{}"); + }); + + it("push should handle arrays", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + const yamlInput = `items: + - first + - second + - third`; + + await loader.pull("en", yamlInput); + + const data = { + items: ["first", "second", "third"], + }; + + const result = await loader.push("en", data, yamlInput); + + // Verify it parses back correctly + const reparsed = await loader.pull("en", result); + expect(reparsed).toEqual(data); + }); + + it("push should preserve mixed value quoting (some quoted, some unquoted)", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Mixed quoting: some values quoted, some plain, some YAML references + const yamlInput = `gender: + f: "Feminine" + m: "Masculine" + female: :@f + male: :@m`; + + await loader.pull("en", yamlInput); + + const data = { + gender: { + f: "Femenino", + m: "Masculino", + female: ":@f", + male: ":@m", + }, + }; + + const result = await loader.push("en", data, yamlInput); + + // Quoted values should remain quoted + expect(result).toContain('"Femenino"'); + expect(result).toContain('"Masculino"'); + + // YAML references should remain unquoted + expect(result).toContain("female: :@f"); + expect(result).toContain("male: :@m"); + + // Should NOT quote all values globally + expect(result).not.toMatch(/female:\s*":@f"/); + expect(result).not.toMatch(/male:\s*":@m"/); + }); + + it("push should preserve mixed key quoting (some keys quoted, some unquoted)", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Mixed key quoting: one key quoted, others plain + const yamlInput = `gender: + f: Feminine + "m": Masculine + n: Neutral`; + + await loader.pull("en", yamlInput); + + const data = { + gender: { + f: "Femenino", + m: "Masculino", + n: "Neutro", + }, + }; + + const result = await loader.push("en", data, yamlInput); + + // Only 'm' key should be quoted + expect(result).toMatch(/"m":\s*Masculino/); + + // Other keys should NOT be quoted + expect(result).toMatch(/f:\s*Femenino/); + expect(result).not.toContain('"f":'); + expect(result).toMatch(/n:\s*Neutro/); + expect(result).not.toContain('"n":'); + + // Root key should not be quoted + expect(result).not.toContain('"gender":'); + }); + + it("push should preserve both mixed key and value quoting simultaneously", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Complex scenario: mixed keys AND mixed values + const yamlInput = `config: + "special-key": "quoted value" + normalKey: plain value + anotherKey: "another quoted"`; + + await loader.pull("en", yamlInput); + + const data = { + config: { + "special-key": "valor citado", + normalKey: "valor plano", + anotherKey: "otro citado", + }, + }; + + const result = await loader.push("en", data, yamlInput); + + // Quoted key should remain quoted + expect(result).toMatch(/"special-key":/); + + // Other keys should not be quoted + expect(result).toMatch(/normalKey:/); + expect(result).not.toContain('"normalKey"'); + expect(result).toMatch(/anotherKey:/); + expect(result).not.toContain('"anotherKey"'); + + // Quoted values should remain quoted + expect(result).toContain('"valor citado"'); + expect(result).toContain('"otro citado"'); + + // Plain value should remain plain + expect(result).toMatch(/normalKey:\s*valor plano/); + }); + + it("push should preserve nested mixed quoting", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Nested structure with mixed quoting at different levels + const yamlInput = `i18n: + inflections: + gender: + f: "Feminine" + "m": "Masculine" + female: :@f`; + + await loader.pull("en", yamlInput); + + const data = { + i18n: { + inflections: { + gender: { + f: "Femenino", + m: "Masculino", + female: ":@f", + }, + }, + }, + }; + + const result = await loader.push("en", data, yamlInput); + + // Only 'm' key should be quoted + expect(result).toMatch(/"m":/); + expect(result).not.toContain('"f":'); + + // Parent keys should not be quoted + expect(result).not.toContain('"i18n":'); + expect(result).not.toContain('"inflections":'); + expect(result).not.toContain('"gender":'); + + // Quoted value should be quoted, reference unquoted + expect(result).toContain('"Femenino"'); + expect(result).toMatch(/female:\s*:@f/); + }); + + it("push should preserve quoting in yaml-root-key format (locale as root)", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + const yamlInput = `en: + "greeting": "Hello!" + message: Welcome`; + + await loader.pull("en", yamlInput); + + const data = { + en: { + greeting: "¡Hola!", + message: "Bienvenido", + }, + }; + + const result = await loader.push("en", data, yamlInput); + + // The quoted key and value should remain quoted + expect(result).toContain('"greeting":'); + expect(result).toContain('"¡Hola!"'); + + // The unquoted key and value should remain unquoted + expect(result).toMatch(/\smessage:\s/); // message key unquoted + expect(result).not.toContain('"message"'); + expect(result).toMatch(/message:\s*Bienvenido/); // value unquoted + }); + + it("push should preserve quoting across different locale keys", async () => { + const loader = createYamlLoader(); + loader.setDefaultLocale("en"); + + // Source has 'en:' root key + const yamlInput = `en: + navigation: + home: "Home" + forms: + "message_label": "Message"`; + + await loader.pull("en", yamlInput); + + // Target has 'es:' root key (different from source!) + const data = { + es: { + navigation: { + home: "Inicio", + }, + forms: { + message_label: "Mensaje", + }, + }, + }; + + const result = await loader.push("es", data, yamlInput); + + // Quoting should be preserved despite different root keys (en vs es) + expect(result).toContain('home: "Inicio"'); + expect(result).toContain('"message_label":'); + expect(result).toContain('"Mensaje"'); + }); +}); diff --git a/packages/cli/src/cli/loaders/yaml.ts b/packages/cli/src/cli/loaders/yaml.ts new file mode 100644 index 000000000..fcfe1b92c --- /dev/null +++ b/packages/cli/src/cli/loaders/yaml.ts @@ -0,0 +1,314 @@ +import YAML, { ToStringOptions } from "yaml"; +import { ILoader } from "./_types"; +import { createLoader } from "./_utils"; + +interface QuotingMetadata { + keys: Map; + values: Map; +} + +export default function createYamlLoader(): ILoader< + string, + Record +> { + return createLoader({ + async pull(locale, input) { + return YAML.parse(input) || {}; + }, + async push(locale, payload, originalInput) { + // If no original input, use simple stringify + if (!originalInput || !originalInput.trim()) { + return YAML.stringify(payload, { + lineWidth: -1, + defaultKeyType: "PLAIN", + defaultStringType: "PLAIN", + }); + } + + try { + // Parse source and extract quoting metadata + const sourceDoc = YAML.parseDocument(originalInput); + + // Create output document - let the library handle smart quoting + const outputDoc = YAML.parseDocument( + YAML.stringify(payload, { + lineWidth: -1, + defaultKeyType: "PLAIN", + }), + ); + + // Detect if this is yaml-root-key format by comparing structures + const isRootKeyFormat = detectRootKeyFormat(sourceDoc, outputDoc); + + // Extract and apply metadata with root-key awareness + const metadata = extractQuotingMetadata(sourceDoc, isRootKeyFormat); + applyQuotingMetadata(outputDoc, metadata, isRootKeyFormat); + + return outputDoc.toString({ lineWidth: -1 }); + } catch (error) { + console.warn("Failed to preserve YAML formatting:", error); + // Fallback to current behavior + return YAML.stringify(payload, { + lineWidth: -1, + defaultKeyType: getKeyType(originalInput), + defaultStringType: getStringType(originalInput), + }); + } + }, + }); +} + +// Detect if this is yaml-root-key format by comparing source and output structures +function detectRootKeyFormat( + sourceDoc: YAML.Document, + outputDoc: YAML.Document, +): boolean { + const sourceRoot = sourceDoc.contents; + const outputRoot = outputDoc.contents; + + // Both must be maps with single root key + if (!isYAMLMap(sourceRoot) || !isYAMLMap(outputRoot)) { + return false; + } + + const sourceMap = sourceRoot as any; + const outputMap = outputRoot as any; + + if ( + !sourceMap.items || + sourceMap.items.length !== 1 || + !outputMap.items || + outputMap.items.length !== 1 + ) { + return false; + } + + const sourceRootKey = getKeyValue(sourceMap.items[0].key); + const outputRootKey = getKeyValue(outputMap.items[0].key); + + // If both have single root keys that are DIFFERENT strings, it's yaml-root-key format + // (e.g., source has "en:", output has "es:") + if ( + sourceRootKey !== outputRootKey && + typeof sourceRootKey === "string" && + typeof outputRootKey === "string" + ) { + return true; + } + + return false; +} + +// Extract quoting metadata from source document +function extractQuotingMetadata( + doc: YAML.Document, + skipRootKey: boolean, +): QuotingMetadata { + const metadata: QuotingMetadata = { + keys: new Map(), + values: new Map(), + }; + const root = doc.contents; + if (!root) return metadata; + + let startNode: any = root; + + // If yaml-root-key format, skip the locale root key + if (skipRootKey && isYAMLMap(root)) { + const rootMap = root as any; + if (rootMap.items && rootMap.items.length === 1) { + startNode = rootMap.items[0].value; + } + } + + walkAndExtract(startNode, [], metadata); + return metadata; +} + +// Walk AST and extract quoting information +function walkAndExtract( + node: any, + path: string[], + metadata: QuotingMetadata, +): void { + if (isScalar(node)) { + // Store non-PLAIN value quoting types + if (node.type && node.type !== "PLAIN") { + metadata.values.set(path.join("."), node.type); + } + } else if (isYAMLMap(node)) { + if (node.items && Array.isArray(node.items)) { + for (const pair of node.items) { + if (pair && pair.key) { + const key = getKeyValue(pair.key); + if (key !== null && key !== undefined) { + const keyPath = [...path, String(key)].join("."); + + // Store non-PLAIN key quoting types + if (pair.key.type && pair.key.type !== "PLAIN") { + metadata.keys.set(keyPath, pair.key.type); + } + + // Continue walking values + if (pair.value) { + walkAndExtract(pair.value, [...path, String(key)], metadata); + } + } + } + } + } + } else if (isYAMLSeq(node)) { + if (node.items && Array.isArray(node.items)) { + for (let i = 0; i < node.items.length; i++) { + if (node.items[i]) { + walkAndExtract(node.items[i], [...path, String(i)], metadata); + } + } + } + } +} + +// Apply quoting metadata to output document +function applyQuotingMetadata( + doc: YAML.Document, + metadata: QuotingMetadata, + skipRootKey: boolean, +): void { + const root = doc.contents; + if (!root) return; + + let startNode: any = root; + + // If yaml-root-key format, skip the locale root key + if (skipRootKey && isYAMLMap(root)) { + const rootMap = root as any; + if (rootMap.items && rootMap.items.length === 1) { + startNode = rootMap.items[0].value; + } + } + + walkAndApply(startNode, [], metadata); +} + +// Walk AST and apply quoting information +function walkAndApply( + node: any, + path: string[], + metadata: QuotingMetadata, +): void { + if (isScalar(node)) { + // Apply value quoting + const pathKey = path.join("."); + const quoteType = metadata.values.get(pathKey); + if (quoteType) { + node.type = quoteType; + } + } else if (isYAMLMap(node)) { + if (node.items && Array.isArray(node.items)) { + for (const pair of node.items) { + if (pair && pair.key) { + const key = getKeyValue(pair.key); + if (key !== null && key !== undefined) { + const keyPath = [...path, String(key)].join("."); + + // Apply key quoting + const keyQuoteType = metadata.keys.get(keyPath); + if (keyQuoteType) { + pair.key.type = keyQuoteType; + } + + // Continue walking values + if (pair.value) { + walkAndApply(pair.value, [...path, String(key)], metadata); + } + } + } + } + } + } else if (isYAMLSeq(node)) { + if (node.items && Array.isArray(node.items)) { + for (let i = 0; i < node.items.length; i++) { + if (node.items[i]) { + walkAndApply(node.items[i], [...path, String(i)], metadata); + } + } + } + } +} + +// Type guards using YAML library's built-in functions +function isScalar(node: any): boolean { + return YAML.isScalar(node); +} + +function isYAMLMap(node: any): boolean { + return YAML.isMap(node); +} + +function isYAMLSeq(node: any): boolean { + return YAML.isSeq(node); +} + +function getKeyValue(key: any): string | number | null { + if (key === null || key === undefined) { + return null; + } + // Scalar key + if (typeof key === "object" && "value" in key) { + return key.value; + } + // Already a primitive + if (typeof key === "string" || typeof key === "number") { + return key; + } + return null; +} + +// check if the yaml keys are using double quotes or single quotes +function getKeyType( + yamlString: string | null, +): ToStringOptions["defaultKeyType"] { + if (yamlString) { + const lines = yamlString.split("\n"); + const hasDoubleQuotes = lines.find((line) => { + return line.trim().startsWith('"') && line.trim().match('":'); + }); + if (hasDoubleQuotes) { + return "QUOTE_DOUBLE"; + } + } + return "PLAIN"; +} + +// check if the yaml string values are using double quotes or single quotes +function getStringType( + yamlString: string | null, +): ToStringOptions["defaultStringType"] { + if (yamlString) { + const lines = yamlString.split("\n"); + + // Check if the file uses literal block scalars (|, |-, |+) + const hasLiteralBlockScalar = lines.find((line) => { + const trimmedLine = line.trim(); + return trimmedLine.match(/:\s*\|[-+]?\s*$/); + }); + + // If literal block scalars are used, always use PLAIN to preserve them + if (hasLiteralBlockScalar) { + return "PLAIN"; + } + + // Otherwise, check for double quotes on string values + const hasDoubleQuotes = lines.find((line) => { + const trimmedLine = line.trim(); + return ( + (trimmedLine.startsWith('"') || trimmedLine.match(/:\s*"/)) && + (trimmedLine.endsWith('"') || trimmedLine.endsWith('",')) + ); + }); + if (hasDoubleQuotes) { + return "QUOTE_DOUBLE"; + } + } + return "PLAIN"; +} diff --git a/packages/cli/src/cli/localizer/_types.ts b/packages/cli/src/cli/localizer/_types.ts new file mode 100644 index 000000000..1ee0bfe4c --- /dev/null +++ b/packages/cli/src/cli/localizer/_types.ts @@ -0,0 +1,30 @@ +import { I18nConfig } from "@lingo.dev/_spec"; + +export type LocalizerData = { + sourceLocale: string; + sourceData: Record; + processableData: Record; + targetLocale: string; + targetData: Record; + hints: Record; +}; + +export type LocalizerProgressFn = ( + progress: number, + sourceChunk: Record, + processedChunk: Record, +) => void; + +export interface ILocalizer { + id: "Lingo.dev" | "Lingo.dev vNext" | "pseudo" | NonNullable["id"]; + checkAuth: () => Promise<{ + authenticated: boolean; + username?: string; + error?: string; + }>; + validateSettings?: () => Promise<{ valid: boolean; error?: string }>; + localize: ( + input: LocalizerData, + onProgress?: LocalizerProgressFn, + ) => Promise; +} diff --git a/packages/cli/src/cli/localizer/explicit.ts b/packages/cli/src/cli/localizer/explicit.ts new file mode 100644 index 000000000..eb87b158a --- /dev/null +++ b/packages/cli/src/cli/localizer/explicit.ts @@ -0,0 +1,233 @@ +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { createOpenAI } from "@ai-sdk/openai"; +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { createMistral } from "@ai-sdk/mistral"; +import { I18nConfig } from "@lingo.dev/_spec"; +import chalk from "chalk"; +import dedent from "dedent"; +import { ILocalizer, LocalizerData } from "./_types"; +import { LanguageModel, ModelMessage, generateText } from "ai"; +import { colors } from "../constants"; +import { jsonrepair } from "jsonrepair"; +import { createOllama } from "ollama-ai-provider-v2"; + +export default function createExplicitLocalizer( + provider: NonNullable, +): ILocalizer { + const settings = provider.settings || {}; + + switch (provider.id) { + default: + throw new Error( + dedent` + You're trying to use unsupported provider: ${chalk.dim(provider.id)}. + + To fix this issue: + 1. Switch to one of the supported providers, or + 2. Remove the ${chalk.italic( + "provider", + )} node from your i18n.json configuration to switch to ${chalk.hex( + colors.green, + )("Lingo.dev")} + + ${chalk.hex(colors.blue)("Docs: https://lingo.dev/go/docs")} + `, + ); + case "openai": + return createAiSdkLocalizer({ + factory: (params) => createOpenAI(params).languageModel(provider.model), + id: provider.id, + prompt: provider.prompt, + apiKeyName: "OPENAI_API_KEY", + baseUrl: provider.baseUrl, + settings, + }); + case "anthropic": + return createAiSdkLocalizer({ + factory: (params) => + createAnthropic(params).languageModel(provider.model), + id: provider.id, + prompt: provider.prompt, + apiKeyName: "ANTHROPIC_API_KEY", + baseUrl: provider.baseUrl, + settings, + }); + case "google": + return createAiSdkLocalizer({ + factory: (params) => + createGoogleGenerativeAI(params).languageModel(provider.model), + id: provider.id, + prompt: provider.prompt, + apiKeyName: "GOOGLE_API_KEY", + baseUrl: provider.baseUrl, + settings, + }); + case "openrouter": + return createAiSdkLocalizer({ + factory: (params) => + createOpenRouter(params).languageModel(provider.model), + id: provider.id, + prompt: provider.prompt, + apiKeyName: "OPENROUTER_API_KEY", + baseUrl: provider.baseUrl, + settings, + }); + case "ollama": + return createAiSdkLocalizer({ + factory: (_params) => createOllama().languageModel(provider.model), + id: provider.id, + prompt: provider.prompt, + skipAuth: true, + settings, + }); + case "mistral": + return createAiSdkLocalizer({ + factory: (params) => + createMistral(params).languageModel(provider.model), + id: provider.id, + prompt: provider.prompt, + apiKeyName: "MISTRAL_API_KEY", + baseUrl: provider.baseUrl, + settings, + }); + } +} + +function createAiSdkLocalizer(params: { + factory: (params: { apiKey?: string; baseUrl?: string }) => LanguageModel; + id: NonNullable["id"]; + prompt: string; + apiKeyName?: string; + baseUrl?: string; + skipAuth?: boolean; + settings?: { temperature?: number }; +}): ILocalizer { + const skipAuth = params.skipAuth === true; + + const apiKey = process.env[params?.apiKeyName ?? ""]; + if (!skipAuth && (!apiKey || !params.apiKeyName)) { + throw new Error( + dedent` + You're trying to use raw ${chalk.dim(params.id)} API for translation. ${ + params.apiKeyName + ? `However, ${chalk.dim( + params.apiKeyName, + )} environment variable is not set.` + : "However, that provider is unavailable." + } + + To fix this issue: + 1. ${ + params.apiKeyName + ? `Set ${chalk.dim( + params.apiKeyName, + )} in your environment variables` + : "Set the environment variable for your provider (if required)" + }, or + 2. Remove the ${chalk.italic( + "provider", + )} node from your i18n.json configuration to switch to ${chalk.hex( + colors.green, + )("Lingo.dev")} + + ${chalk.hex(colors.blue)("Docs: https://lingo.dev/go/docs")} + `, + ); + } + + const model = params.factory( + skipAuth ? {} : { apiKey, baseUrl: params.baseUrl }, + ); + + return { + id: params.id, + checkAuth: async () => { + // For BYOK providers, auth check is not meaningful + // Configuration validation happens in validateSettings + return { authenticated: true, username: "anonymous" }; + }, + validateSettings: async () => { + try { + await generateText({ + model, + ...params.settings, + messages: [ + { role: "system", content: "You are an echo server" }, + { role: "user", content: "OK" }, + { role: "assistant", content: "OK" }, + { role: "user", content: "OK" }, + ], + }); + + return { valid: true }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { valid: false, error: errorMessage }; + } + }, + localize: async (input: LocalizerData) => { + const systemPrompt = params.prompt + .replaceAll("{source}", input.sourceLocale) + .replaceAll("{target}", input.targetLocale); + const shots = [ + [ + { + sourceLocale: "en", + targetLocale: "es", + data: { + message: "Hello, world!", + }, + }, + { + sourceLocale: "en", + targetLocale: "es", + data: { + message: "Hola, mundo!", + }, + }, + ], + ]; + + const payload = { + sourceLocale: input.sourceLocale, + targetLocale: input.targetLocale, + data: input.processableData, + }; + + const response = await generateText({ + model, + ...params.settings, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: "OK" }, + ...shots.flatMap( + ([userShot, assistantShot]) => + [ + { role: "user", content: JSON.stringify(userShot) }, + { role: "assistant", content: JSON.stringify(assistantShot) }, + ] as ModelMessage[], + ), + { role: "user", content: JSON.stringify(payload) }, + ], + }); + + const result = JSON.parse(response.text); + + // Handle both object and string responses + if (typeof result.data === "object" && result.data !== null) { + return result.data; + } + + // Handle string responses - extract and repair JSON + const index = result.data.indexOf("{"); + const lastIndex = result.data.lastIndexOf("}"); + const trimmed = result.data.slice(index, lastIndex + 1); + const repaired = jsonrepair(trimmed); + const finalResult = JSON.parse(repaired); + + return finalResult.data; + }, + }; +} diff --git a/packages/cli/src/cli/localizer/index.ts b/packages/cli/src/cli/localizer/index.ts new file mode 100644 index 000000000..122bdf63b --- /dev/null +++ b/packages/cli/src/cli/localizer/index.ts @@ -0,0 +1,28 @@ +import { I18nConfig } from "@lingo.dev/_spec"; + +import createLingoDotDevLocalizer from "./lingodotdev"; +import createLingoDotDevVNextLocalizer from "./lingodotdev-vnext"; +import createExplicitLocalizer from "./explicit"; +import createPseudoLocalizer from "./pseudo"; +import { ILocalizer } from "./_types"; + +export default function createLocalizer( + provider: I18nConfig["provider"] | "pseudo" | null | undefined, + apiKey?: string, + vNext?: string, +): ILocalizer { + if (provider === "pseudo") { + return createPseudoLocalizer(); + } + + // Check if vNext is configured + if (vNext) { + return createLingoDotDevVNextLocalizer(vNext); + } + + if (!provider) { + return createLingoDotDevLocalizer(apiKey); + } else { + return createExplicitLocalizer(provider); + } +} diff --git a/packages/cli/src/cli/localizer/lingodotdev-vnext.ts b/packages/cli/src/cli/localizer/lingodotdev-vnext.ts new file mode 100644 index 000000000..ed4281d49 --- /dev/null +++ b/packages/cli/src/cli/localizer/lingodotdev-vnext.ts @@ -0,0 +1,246 @@ +import dedent from "dedent"; +import { ILocalizer, LocalizerData } from "./_types"; +import chalk from "chalk"; +import { colors } from "../constants"; +import { getSettings } from "../utils/settings"; +import { createId } from "@paralleldrive/cuid2"; + +/** + * Creates a custom engine for Lingo.dev vNext that sends requests to: + * https://api.lingo.dev/process//localize + */ +function createVNextEngine(config: { apiKey: string; apiUrl: string; processId: string }) { + const endpoint = `${config.apiUrl}/process/${config.processId}/localize`; + + return { + async localizeChunk( + sourceLocale: string | null, + targetLocale: string, + payload: { + data: Record; + reference?: Record>; + hints?: Record; + }, + workflowId: string, + fast: boolean, + signal?: AbortSignal, + ): Promise> { + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + "X-API-Key": config.apiKey, + }, + body: JSON.stringify( + { + params: { workflowId, fast }, + sourceLocale, + targetLocale, + data: payload.data, + reference: payload.reference, + hints: payload.hints, + }, + null, + 2, + ), + signal, + }); + + if (!res.ok) { + if (res.status >= 500 && res.status < 600) { + const errorText = await res.text(); + throw new Error( + `Server error (${res.status}): ${res.statusText}. ${errorText}. This may be due to temporary service issues.`, + ); + } else if (res.status === 400) { + throw new Error(`Invalid request: ${res.statusText}`); + } else { + const errorText = await res.text(); + throw new Error(errorText); + } + } + + const jsonResponse = await res.json(); + + if (!jsonResponse.data && jsonResponse.error) { + throw new Error(jsonResponse.error); + } + + return jsonResponse.data || {}; + }, + + async whoami(signal?: AbortSignal): Promise<{ email: string; id: string } | null> { + // vNext uses a simple response for whoami + return { email: "vnext-user", id: config.processId }; + }, + + async localizeObject( + obj: Record, + params: { + sourceLocale: string | null; + targetLocale: string; + fast?: boolean; + reference?: Record>; + hints?: Record; + }, + progressCallback?: ( + progress: number, + sourceChunk: Record, + processedChunk: Record, + ) => void, + signal?: AbortSignal, + ): Promise> { + const chunkedPayload = extractPayloadChunks(obj); + const processedPayloadChunks: Record[] = []; + + const workflowId = createId(); + for (let i = 0; i < chunkedPayload.length; i++) { + const chunk = chunkedPayload[i]; + const percentageCompleted = Math.round( + ((i + 1) / chunkedPayload.length) * 100, + ); + + const processedPayloadChunk = await this.localizeChunk( + params.sourceLocale, + params.targetLocale, + { data: chunk, reference: params.reference, hints: params.hints }, + workflowId, + params.fast || false, + signal, + ); + + if (progressCallback) { + progressCallback(percentageCompleted, chunk, processedPayloadChunk); + } + + processedPayloadChunks.push(processedPayloadChunk); + } + + return Object.assign({}, ...processedPayloadChunks); + }, + }; +} + +/** + * Helper functions for chunking payloads + */ +function extractPayloadChunks( + payload: Record, + batchSize: number = 25, + idealBatchItemSize: number = 250, +): Record[] { + const result: Record[] = []; + let currentChunk: Record = {}; + let currentChunkItemCount = 0; + + const payloadEntries = Object.entries(payload); + for (let i = 0; i < payloadEntries.length; i++) { + const [key, value] = payloadEntries[i]; + currentChunk[key] = value; + currentChunkItemCount++; + + const currentChunkSize = countWordsInRecord(currentChunk); + if ( + currentChunkSize > idealBatchItemSize || + currentChunkItemCount >= batchSize || + i === payloadEntries.length - 1 + ) { + result.push(currentChunk); + currentChunk = {}; + currentChunkItemCount = 0; + } + } + + return result; +} + +function countWordsInRecord( + payload: any | Record | Array, +): number { + if (Array.isArray(payload)) { + return payload.reduce( + (acc, item) => acc + countWordsInRecord(item), + 0, + ); + } else if (typeof payload === "object" && payload !== null) { + return Object.values(payload).reduce( + (acc: number, item) => acc + countWordsInRecord(item), + 0, + ); + } else if (typeof payload === "string") { + return payload.trim().split(/\s+/).filter(Boolean).length; + } else { + return 0; + } +} + +export default function createLingoDotDevVNextLocalizer( + processId: string, +): ILocalizer { + const settings = getSettings(undefined); + + // Use LINGO_API_KEY from environment or settings + const apiKey = process.env.LINGO_API_KEY || settings.auth.vnext?.apiKey; + + if (!apiKey) { + throw new Error( + dedent` + You're trying to use ${chalk.hex(colors.green)( + "Lingo.dev vNext", + )} provider, however, no API key is configured. + + To fix this issue: + 1. Set ${chalk.dim("LINGO_API_KEY")} environment variable, or + 2. Add the key to your ${chalk.dim("~/.lingodotdevrc")} file under ${chalk.dim("[auth.vnext]")} section. + `, + ); + } + + // Use LINGO_API_URL from environment or default to api.lingo.dev + const apiUrl = process.env.LINGO_API_URL || "https://api.lingo.dev"; + + const engine = createVNextEngine({ + apiKey, + apiUrl, + processId, + }); + + return { + id: "Lingo.dev vNext", + checkAuth: async () => { + try { + const response = await engine.whoami(); + return { + authenticated: !!response, + username: response?.email, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { authenticated: false, error: errorMessage }; + } + }, + localize: async (input: LocalizerData, onProgress) => { + // Nothing to translate – return the input as-is. + if (!Object.keys(input.processableData).length) { + return input.processableData; + } + + const processedData = await engine.localizeObject( + input.processableData, + { + sourceLocale: input.sourceLocale, + targetLocale: input.targetLocale, + reference: { + [input.sourceLocale]: input.sourceData, + [input.targetLocale]: input.targetData, + }, + hints: input.hints, + }, + onProgress, + ); + + return processedData; + }, + }; +} diff --git a/packages/cli/src/cli/localizer/lingodotdev.ts b/packages/cli/src/cli/localizer/lingodotdev.ts new file mode 100644 index 000000000..6e2834486 --- /dev/null +++ b/packages/cli/src/cli/localizer/lingodotdev.ts @@ -0,0 +1,71 @@ +import dedent from "dedent"; +import { ILocalizer, LocalizerData } from "./_types"; +import chalk from "chalk"; +import { colors } from "../constants"; +import { LingoDotDevEngine } from "@lingo.dev/_sdk"; +import { getSettings } from "../utils/settings"; + +export default function createLingoDotDevLocalizer( + explicitApiKey?: string, +): ILocalizer { + const { auth } = getSettings(explicitApiKey); + + if (!auth) { + throw new Error( + dedent` + You're trying to use ${chalk.hex(colors.green)( + "Lingo.dev", + )} provider, however, you are not authenticated. + + To fix this issue: + 1. Run ${chalk.dim("lingo.dev login")} to authenticate, or + 2. Use the ${chalk.dim("--api-key")} flag to provide an API key. + 3. Set ${chalk.dim("LINGODOTDEV_API_KEY")} environment variable. + `, + ); + } + + const engine = new LingoDotDevEngine({ + apiKey: auth.apiKey, + apiUrl: auth.apiUrl, + }); + + return { + id: "Lingo.dev", + checkAuth: async () => { + try { + const response = await engine.whoami(); + return { + authenticated: !!response, + username: response?.email, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { authenticated: false, error: errorMessage }; + } + }, + localize: async (input: LocalizerData, onProgress) => { + // Nothing to translate – return the input as-is. + if (!Object.keys(input.processableData).length) { + return input; + } + + const processedData = await engine.localizeObject( + input.processableData, + { + sourceLocale: input.sourceLocale, + targetLocale: input.targetLocale, + reference: { + [input.sourceLocale]: input.sourceData, + [input.targetLocale]: input.targetData, + }, + hints: input.hints, + }, + onProgress, + ); + + return processedData; + }, + }; +} diff --git a/packages/cli/src/cli/localizer/pseudo.ts b/packages/cli/src/cli/localizer/pseudo.ts new file mode 100644 index 000000000..d20a3e20d --- /dev/null +++ b/packages/cli/src/cli/localizer/pseudo.ts @@ -0,0 +1,37 @@ +import { ILocalizer, LocalizerData } from "./_types"; +import { pseudoLocalizeObject } from "../../utils/pseudo-localize"; + +/** + * Creates a pseudo-localizer that doesn't call any external API. + * Instead, it performs character replacement with accented versions, + * useful for testing UI internationalization readiness. + */ +export default function createPseudoLocalizer(): ILocalizer { + return { + id: "pseudo", + checkAuth: async () => { + return { + authenticated: true, + }; + }, + localize: async (input: LocalizerData, onProgress) => { + // Nothing to translate – return the input as-is. + if (!Object.keys(input.processableData).length) { + return input; + } + + // Pseudo-localize all strings in the processable data + const processedData = pseudoLocalizeObject(input.processableData, { + addMarker: true, + addLengthMarker: false, + }); + + // Call progress callback if provided, simulating completion + if (onProgress) { + onProgress(100, input.processableData, processedData); + } + + return processedData; + }, + }; +} diff --git a/packages/cli/src/cli/processor/_base.ts b/packages/cli/src/cli/processor/_base.ts new file mode 100644 index 000000000..1ae40c1b0 --- /dev/null +++ b/packages/cli/src/cli/processor/_base.ts @@ -0,0 +1,18 @@ +export type LocalizerInput = { + sourceLocale: string; + sourceData: Record; + processableData: Record; + targetLocale: string; + targetData: Record; +}; + +export type LocalizerProgressFn = ( + progress: number, + sourceChunk: Record, + processedChunk: Record, +) => void; + +export type LocalizerFn = ( + input: LocalizerInput, + onProgress: LocalizerProgressFn, +) => Promise>; diff --git a/packages/cli/src/cli/processor/basic.ts b/packages/cli/src/cli/processor/basic.ts new file mode 100644 index 000000000..ed962adf6 --- /dev/null +++ b/packages/cli/src/cli/processor/basic.ts @@ -0,0 +1,143 @@ +import { generateText, LanguageModel } from "ai"; +import { LocalizerInput, LocalizerProgressFn } from "./_base"; +import _ from "lodash"; + +type ModelSettings = { + temperature?: number; +}; + +export function createBasicTranslator( + model: LanguageModel, + systemPrompt: string, + settings: ModelSettings = {}, +) { + return async (input: LocalizerInput, onProgress: LocalizerProgressFn) => { + const chunks = extractPayloadChunks(input.processableData); + + const subResults: Record[] = []; + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const result = await doJob({ + ...input, + processableData: chunk, + }); + subResults.push(result); + onProgress((i / chunks.length) * 100, chunk, result); + } + + const result = _.merge({}, ...subResults); + + return result; + }; + + async function doJob(input: LocalizerInput) { + if (!Object.keys(input.processableData).length) { + return input.processableData; + } + + const response = await generateText({ + model, + ...settings, + messages: [ + { + role: "system", + content: JSON.stringify({ + role: "system", + content: systemPrompt + .replaceAll("{source}", input.sourceLocale) + .replaceAll("{target}", input.targetLocale), + }), + }, + { + role: "user", + content: JSON.stringify({ + sourceLocale: "en", + targetLocale: "es", + data: { + message: "Hello, world!", + }, + }), + }, + { + role: "assistant", + content: JSON.stringify({ + sourceLocale: "en", + targetLocale: "es", + data: { + message: "Hola, mundo!", + }, + }), + }, + { + role: "user", + content: JSON.stringify({ + sourceLocale: input.sourceLocale, + targetLocale: input.targetLocale, + data: input.processableData, + }), + }, + ], + }); + + const result = JSON.parse(response.text); + + return result?.data || {}; + } +} + +/** + * Extract payload chunks based on the ideal chunk size + * @param payload - The payload to be chunked + * @returns An array of payload chunks + */ +function extractPayloadChunks( + payload: Record, +): Record[] { + const idealBatchItemSize = 250; + const batchSize = 25; + const result: Record[] = []; + let currentChunk: Record = {}; + let currentChunkItemCount = 0; + + const payloadEntries = Object.entries(payload); + for (let i = 0; i < payloadEntries.length; i++) { + const [key, value] = payloadEntries[i]; + currentChunk[key] = value; + currentChunkItemCount++; + + const currentChunkSize = countWordsInRecord(currentChunk); + if ( + currentChunkSize > idealBatchItemSize || + currentChunkItemCount >= batchSize || + i === payloadEntries.length - 1 + ) { + result.push(currentChunk); + currentChunk = {}; + currentChunkItemCount = 0; + } + } + + return result; +} + +/** + * Count words in a record or array + * @param payload - The payload to count words in + * @returns The total number of words + */ +function countWordsInRecord( + payload: any | Record | Array, +): number { + if (Array.isArray(payload)) { + return payload.reduce((acc, item) => acc + countWordsInRecord(item), 0); + } else if (typeof payload === "object" && payload !== null) { + return Object.values(payload).reduce( + (acc: number, item) => acc + countWordsInRecord(item), + 0, + ); + } else if (typeof payload === "string") { + return payload.trim().split(/\s+/).filter(Boolean).length; + } else { + return 0; + } +} diff --git a/packages/cli/src/cli/processor/index.ts b/packages/cli/src/cli/processor/index.ts new file mode 100644 index 000000000..1a92fe2f0 --- /dev/null +++ b/packages/cli/src/cli/processor/index.ts @@ -0,0 +1,133 @@ +import { I18nConfig } from "@lingo.dev/_spec"; +import chalk from "chalk"; +import dedent from "dedent"; +import { LocalizerFn } from "./_base"; +import { createLingoLocalizer } from "./lingo"; +import { createBasicTranslator } from "./basic"; +import { createOpenAI } from "@ai-sdk/openai"; +import { colors } from "../constants"; +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { createMistral } from "@ai-sdk/mistral"; +import { createOllama } from "ollama-ai-provider-v2"; + +export default function createProcessor( + provider: I18nConfig["provider"], + params: { apiKey?: string; apiUrl: string }, +): LocalizerFn { + if (!provider) { + const result = createLingoLocalizer(params); + return result; + } else { + const model = getPureModelProvider(provider); + const settings = provider.settings || {}; + const result = createBasicTranslator(model, provider.prompt, settings); + return result; + } +} + +function getPureModelProvider(provider: I18nConfig["provider"]) { + const createMissingKeyErrorMessage = ( + providerId: string, + envVar?: string, + ) => dedent` + You're trying to use raw ${chalk.dim(providerId)} API for translation. ${ + envVar + ? `However, ${chalk.dim(envVar)} environment variable is not set.` + : "However, that provider is unavailable." + } + + To fix this issue: + 1. ${ + envVar + ? `Set ${chalk.dim(envVar)} in your environment variables` + : "Set the environment variable for your provider (if required)" + }, or + 2. Remove the ${chalk.italic( + "provider", + )} node from your i18n.json configuration to switch to ${chalk.hex( + colors.green, + )("Lingo.dev")} + + ${chalk.hex(colors.blue)("Docs: https://lingo.dev/go/docs")} +`; + + const createUnsupportedProviderErrorMessage = (providerId?: string) => + dedent` + You're trying to use unsupported provider: ${chalk.dim(providerId)}. + + To fix this issue: + 1. Switch to one of the supported providers, or + 2. Remove the ${chalk.italic( + "provider", + )} node from your i18n.json configuration to switch to ${chalk.hex( + colors.green, + )("Lingo.dev")} + + ${chalk.hex(colors.blue)("Docs: https://lingo.dev/go/docs")} + `; + + switch (provider?.id) { + case "openai": { + if (!process.env.OPENAI_API_KEY) { + throw new Error( + createMissingKeyErrorMessage("OpenAI", "OPENAI_API_KEY"), + ); + } + return createOpenAI({ + apiKey: process.env.OPENAI_API_KEY, + baseURL: provider.baseUrl, + })(provider.model); + } + case "anthropic": { + if (!process.env.ANTHROPIC_API_KEY) { + throw new Error( + createMissingKeyErrorMessage("Anthropic", "ANTHROPIC_API_KEY"), + ); + } + return createAnthropic({ + apiKey: process.env.ANTHROPIC_API_KEY, + })(provider.model); + } + case "google": { + if (!process.env.GOOGLE_API_KEY) { + throw new Error( + createMissingKeyErrorMessage("Google", "GOOGLE_API_KEY"), + ); + } + return createGoogleGenerativeAI({ + apiKey: process.env.GOOGLE_API_KEY, + })(provider.model); + } + case "openrouter": { + if (!process.env.OPENROUTER_API_KEY) { + throw new Error( + createMissingKeyErrorMessage("OpenRouter", "OPENROUTER_API_KEY"), + ); + } + return createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, + baseURL: provider.baseUrl, + })(provider.model); + } + case "ollama": { + // No API key check needed for Ollama + return createOllama()(provider.model); + } + case "mistral": { + if (!process.env.MISTRAL_API_KEY) { + throw new Error( + createMissingKeyErrorMessage("Mistral", "MISTRAL_API_KEY"), + ); + } + return createMistral({ + apiKey: process.env.MISTRAL_API_KEY, + baseURL: provider.baseUrl, + })(provider.model); + } + default: { + throw new Error(createUnsupportedProviderErrorMessage(provider?.id)); + } + } +} diff --git a/packages/cli/src/cli/processor/lingo.ts b/packages/cli/src/cli/processor/lingo.ts new file mode 100644 index 000000000..41a8dc873 --- /dev/null +++ b/packages/cli/src/cli/processor/lingo.ts @@ -0,0 +1,33 @@ +import { LingoDotDevEngine } from "@lingo.dev/_sdk"; +import { LocalizerInput, LocalizerProgressFn } from "./_base"; + +export function createLingoLocalizer(params: { + apiKey?: string; + apiUrl: string; +}) { + return async (input: LocalizerInput, onProgress: LocalizerProgressFn) => { + if (!Object.keys(input.processableData).length) { + return input.processableData; + } + + const lingo = new LingoDotDevEngine({ + apiKey: params.apiKey, + apiUrl: params.apiUrl, + }); + + const result = await lingo.localizeObject( + input.processableData, + { + sourceLocale: input.sourceLocale, + targetLocale: input.targetLocale, + reference: { + [input.sourceLocale]: input.sourceData, + [input.targetLocale]: input.targetData, + }, + }, + onProgress, + ); + + return result; + }; +} diff --git a/packages/cli/src/cli/utils/auth.ts b/packages/cli/src/cli/utils/auth.ts new file mode 100644 index 000000000..0e2ede9dc --- /dev/null +++ b/packages/cli/src/cli/utils/auth.ts @@ -0,0 +1,87 @@ +import { CLIError } from "./errors"; +import { + checkCloudflareStatus, + formatCloudflareStatusMessage, +} from "./cloudflare-status"; + +export type AuthenticatorParams = { + apiUrl: string; + apiKey: string; +}; + +export type AuthPayload = { + email: string; + id: string; +}; + +export function createAuthenticator(params: AuthenticatorParams) { + return { + async whoami(): Promise { + try { + const res = await fetch(`${params.apiUrl}/whoami`, { + method: "POST", + headers: { + Authorization: `Bearer ${params.apiKey}`, + ContentType: "application/json", + }, + }); + + if (res.ok) { + const payload = await res.json(); + if (!payload?.email) { + return null; + } + + return { + email: payload.email, + id: payload.id, + }; + } + + if (res.status >= 500 && res.status < 600) { + const originalErrorMessage = `Server error (${res.status}): ${res.statusText}. Please try again later.`; + + const cloudflareStatus = await checkCloudflareStatus(); + + if (!cloudflareStatus) { + throw new CLIError({ + message: originalErrorMessage, + docUrl: "connectionFailed", + }); + } + + if (cloudflareStatus.status.indicator !== "none") { + const cloudflareMessage = + formatCloudflareStatusMessage(cloudflareStatus); + throw new CLIError({ + message: cloudflareMessage, + docUrl: "connectionFailed", + }); + } + + throw new CLIError({ + message: originalErrorMessage, + docUrl: "connectionFailed", + }); + } + + return null; + } catch (error) { + if (error instanceof CLIError) { + throw error; + } + + const isNetworkError = + error instanceof TypeError && error.message === "fetch failed"; + if (isNetworkError) { + throw new CLIError({ + message: `Failed to connect to the API at ${params.apiUrl}. Please check your connection and try again.`, + docUrl: "connectionFailed", + }); + } else { + throw error; + } + } + }, + }; +} diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts new file mode 100644 index 000000000..ec2be41fb --- /dev/null +++ b/packages/cli/src/cli/utils/buckets.spec.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi } from "vitest"; +import { getBuckets } from "./buckets"; +import { glob, Path } from "glob"; + +vi.mock("glob", () => ({ + glob: { + sync: vi.fn(), + }, +})); + +describe("getBuckets", () => { + const makeI18nConfig = (include: any[]) => ({ + $schema: "https://lingo.dev/schema/i18n.json", + version: 0, + locale: { + source: "en", + targets: ["fr", "es"], + }, + buckets: { + json: { + include, + }, + }, + }); + + it("should return correct buckets", () => { + mockGlobSync(["src/i18n/en.json"], ["src/translations/en/messages.json"]); + + const i18nConfig = makeI18nConfig([ + "src/i18n/[locale].json", + "src/translations/[locale]/messages.json", + ]); + const buckets = getBuckets(i18nConfig); + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { pathPattern: "src/i18n/[locale].json", delimiter: null }, + { + pathPattern: "src/translations/[locale]/messages.json", + delimiter: null, + }, + ], + }, + ]); + }); + + it("should return correct buckets for paths with asterisk", () => { + mockGlobSync( + [ + "src/translations/landing.en.json", + "src/translations/app.en.json", + "src/translations/email.en.json", + ], + [ + "src/locale/landing/messages.en.json", + "src/locale/app/data.en.json", + "src/locale/email/custom.en.json", + ], + [ + "src/i18n/landing/en.messages.json", + "src/i18n/app/en.data.json", + "src/i18n/email/en.custom.json", + ], + [ + "src/i18n/data-landing-en-strings/en.messages.json", + "src/i18n/data-app-en-strings/en.data.json", + "src/i18n/data-email-en-strings/en.custom.json", + ], + ); + + const i18nConfig = makeI18nConfig([ + "src/translations/*.[locale].json", + "src/locale/*/*.[locale].json", + "src/i18n/*/[locale].*.json", + "src/i18n/data-*-[locale]-*/[locale].*.json", + ]); + const buckets = getBuckets(i18nConfig); + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { + pathPattern: "src/translations/landing.[locale].json", + delimiter: null, + }, + { + pathPattern: "src/translations/app.[locale].json", + delimiter: null, + }, + { + pathPattern: "src/translations/email.[locale].json", + delimiter: null, + }, + { + pathPattern: "src/locale/landing/messages.[locale].json", + delimiter: null, + }, + { pathPattern: "src/locale/app/data.[locale].json", delimiter: null }, + { + pathPattern: "src/locale/email/custom.[locale].json", + delimiter: null, + }, + { + pathPattern: "src/i18n/landing/[locale].messages.json", + delimiter: null, + }, + { pathPattern: "src/i18n/app/[locale].data.json", delimiter: null }, + { + pathPattern: "src/i18n/email/[locale].custom.json", + delimiter: null, + }, + { + pathPattern: + "src/i18n/data-landing-[locale]-strings/[locale].messages.json", + delimiter: null, + }, + { + pathPattern: + "src/i18n/data-app-[locale]-strings/[locale].data.json", + delimiter: null, + }, + { + pathPattern: + "src/i18n/data-email-[locale]-strings/[locale].custom.json", + delimiter: null, + }, + ], + }, + ]); + }); + + it("should return correct bucket with delimiter", () => { + mockGlobSync(["src/i18n/en.json"]); + const i18nConfig = makeI18nConfig([ + { path: "src/i18n/[locale].json", delimiter: "-" }, + ]); + const buckets = getBuckets(i18nConfig); + expect(buckets).toEqual([ + { + type: "json", + paths: [{ pathPattern: "src/i18n/[locale].json", delimiter: "-" }], + }, + ]); + }); + + it("should return bucket with multiple locale placeholders", () => { + mockGlobSync( + ["src/i18n/en/en.json"], + ["src/en/translations/en/messages.json"], + ); + const i18nConfig = makeI18nConfig([ + "src/i18n/[locale]/[locale].json", + "src/[locale]/translations/[locale]/messages.json", + ]); + const buckets = getBuckets(i18nConfig); + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { pathPattern: "src/i18n/[locale]/[locale].json", delimiter: null }, + { + pathPattern: "src/[locale]/translations/[locale]/messages.json", + delimiter: null, + }, + ], + }, + ]); + }); +}); + +function mockGlobSync(...args: string[][]) { + args.forEach((files) => { + vi.mocked(glob.sync).mockReturnValueOnce( + files.map( + (file) => ({ isFile: () => true, fullpath: () => file }) as Path, + ), + ); + }); +} diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts new file mode 100644 index 000000000..cc03e61b2 --- /dev/null +++ b/packages/cli/src/cli/utils/buckets.ts @@ -0,0 +1,191 @@ +import _ from "lodash"; +import path from "path"; +import { glob } from "glob"; +import { CLIError } from "./errors"; +import { + I18nConfig, + resolveOverriddenLocale, + BucketItem, + LocaleDelimiter, +} from "@lingo.dev/_spec"; +import { bucketTypeSchema } from "@lingo.dev/_spec"; +import Z from "zod"; +import { getConfigRoot } from "./config"; + +type BucketConfig = { + type: Z.infer; + paths: Array<{ pathPattern: string; delimiter?: LocaleDelimiter }>; + injectLocale?: string[]; + lockedKeys?: string[]; + lockedPatterns?: string[]; + ignoredKeys?: string[]; +}; + +export function getBuckets(i18nConfig: I18nConfig) { + const result = Object.entries(i18nConfig.buckets).map( + ([bucketType, bucketEntry]) => { + const includeItems = bucketEntry.include.map((item) => + resolveBucketItem(item), + ); + const excludeItems = bucketEntry.exclude?.map((item) => + resolveBucketItem(item), + ); + const config: BucketConfig = { + type: bucketType as Z.infer, + paths: extractPathPatterns( + i18nConfig.locale.source, + includeItems, + excludeItems, + ), + }; + if (bucketEntry.injectLocale) { + config.injectLocale = bucketEntry.injectLocale; + } + if (bucketEntry.lockedKeys) { + config.lockedKeys = bucketEntry.lockedKeys; + } + if (bucketEntry.lockedPatterns) { + config.lockedPatterns = bucketEntry.lockedPatterns; + } + if (bucketEntry.ignoredKeys) { + config.ignoredKeys = bucketEntry.ignoredKeys; + } + return config; + }, + ); + + return result; +} + +function extractPathPatterns( + sourceLocale: string, + include: BucketItem[], + exclude?: BucketItem[], +) { + const includedPatterns = include.flatMap((pattern) => + expandPlaceholderedGlob( + pattern.path, + resolveOverriddenLocale(sourceLocale, pattern.delimiter), + ).map((pathPattern) => ({ + pathPattern, + delimiter: pattern.delimiter, + })), + ); + const excludedPatterns = exclude?.flatMap((pattern) => + expandPlaceholderedGlob( + pattern.path, + resolveOverriddenLocale(sourceLocale, pattern.delimiter), + ).map((pathPattern) => ({ + pathPattern, + delimiter: pattern.delimiter, + })), + ); + const result = _.differenceBy( + includedPatterns, + excludedPatterns ?? [], + (item) => item.pathPattern, + ); + return result; +} + +// Windows path normalization helper function +function normalizePath(filepath: string): string { + const normalized = path.normalize(filepath); + // Ensure case consistency on Windows + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +// Path expansion +function expandPlaceholderedGlob( + _pathPattern: string, + sourceLocale: string, +): string[] { + const configRoot = getConfigRoot() || process.cwd(); + + const absolutePathPattern = path.resolve(configRoot, _pathPattern); + const pathPattern = normalizePath( + path.relative(configRoot, absolutePathPattern), + ); + if (pathPattern.startsWith("..")) { + throw new CLIError({ + message: `Invalid path pattern: ${pathPattern}. Path pattern must be within the config root directory.`, + docUrl: "invalidPathPattern", + }); + } + + // Throw error if pathPattern contains "**" – we don't support recursive path patterns + if (pathPattern.includes("**")) { + throw new CLIError({ + message: `Invalid path pattern: ${pathPattern}. Recursive path patterns are not supported.`, + docUrl: "invalidPathPattern", + }); + } + + // Break down path pattern into parts + const pathPatternChunks = pathPattern.split(path.sep); + // Find the index of the segment containing "[locale]" + const localeSegmentIndexes = pathPatternChunks.reduce( + (indexes, segment, index) => { + if (segment.includes("[locale]")) { + indexes.push(index); + } + return indexes; + }, + [] as number[], + ); + // substitute [locale] in pathPattern with sourceLocale + const sourcePathPattern = pathPattern.replaceAll(/\[locale\]/g, sourceLocale); + // Convert to Unix-style for Windows compatibility + const unixStylePattern = sourcePathPattern.replace(/\\/g, "/"); + + // get all files that match the sourcePathPattern + const sourcePaths = glob + .sync(unixStylePattern, { + follow: true, + withFileTypes: true, + windowsPathsNoEscape: true, // Windows path support + cwd: configRoot, + }) + .filter((file) => file.isFile() || file.isSymbolicLink()) + .map((file) => file.fullpath()) + .map((fullpath) => normalizePath(path.relative(configRoot, fullpath))); + + // transform each source file path back to [locale] placeholder paths + const placeholderedPaths = sourcePaths.map((sourcePath) => { + // Normalize path returned by glob for platform compatibility + const normalizedSourcePath = normalizePath( + sourcePath.replace(/\//g, path.sep), + ); + const sourcePathChunks = normalizedSourcePath.split(path.sep); + localeSegmentIndexes.forEach((localeSegmentIndex) => { + // Find the position of the "[locale]" placeholder within the segment + const pathPatternChunk = pathPatternChunks[localeSegmentIndex]; + const sourcePathChunk = sourcePathChunks[localeSegmentIndex]; + const regexp = new RegExp( + "(" + + pathPatternChunk + .replaceAll(".", "\\.") + .replaceAll("*", ".*") + .replace("[locale]", `)${sourceLocale}(`) + + ")", + ); + const match = sourcePathChunk.match(regexp); + if (match) { + const [, prefix, suffix] = match; + const placeholderedSegment = prefix + "[locale]" + suffix; + sourcePathChunks[localeSegmentIndex] = placeholderedSegment; + } + }); + const placeholderedPath = sourcePathChunks.join(path.sep); + return placeholderedPath; + }); + // return the placeholdered paths + return placeholderedPaths; +} + +function resolveBucketItem(bucketItem: string | BucketItem): BucketItem { + if (typeof bucketItem === "string") { + return { path: bucketItem, delimiter: null }; + } + return bucketItem; +} diff --git a/packages/cli/src/cli/utils/cache.ts b/packages/cli/src/cli/utils/cache.ts new file mode 100644 index 000000000..81ce2a732 --- /dev/null +++ b/packages/cli/src/cli/utils/cache.ts @@ -0,0 +1,106 @@ +import path from "path"; +import fs from "fs"; +import { getConfigRoot } from "./config"; + +interface CacheRow { + targetLocale: string; + key: string; + source: string; + processed: string; +} + +interface NormalizedCacheItem { + source: string; + result: string; +} + +type NormalizedCache = Record; + +interface NormalizedLocaleCache { + [targetLocale: string]: NormalizedCache; +} + +export const cacheChunk = ( + targetLocale: string, + sourceChunk: Record, + processedChunk: Record, +) => { + const rows = Object.entries(sourceChunk).map(([key, source]) => ({ + targetLocale, + key, + source, + processed: processedChunk[key], + })); + _appendToCache(rows); +}; + +export function getNormalizedCache() { + const rows = _loadCache(); + if (!rows.length) { + return null; + } + + const normalized: NormalizedLocaleCache = {}; + + for (const row of rows) { + if (!normalized[row.targetLocale]) { + normalized[row.targetLocale] = {}; + } + + normalized[row.targetLocale][row.key] = { + source: row.source, + result: row.processed, + }; + } + + return normalized; +} + +export function deleteCache() { + const cacheFilePath = _getCacheFilePath(); + try { + fs.unlinkSync(cacheFilePath); + } catch (e) { + // file might not exist + } +} + +function _loadCache() { + const cacheFilePath = _getCacheFilePath(); + if (!fs.existsSync(cacheFilePath)) { + return []; + } + const content = fs.readFileSync(cacheFilePath, "utf-8"); + const result = _parseJSONLines(content); + return result; +} + +function _appendToCache(rows: CacheRow[]) { + const cacheFilePath = _getCacheFilePath(); + const lines = _buildJSONLines(rows); + fs.appendFileSync(cacheFilePath, lines); +} + +function _getCacheFilePath() { + const configRoot = getConfigRoot() || process.cwd(); + return path.join(configRoot, "i18n.cache"); +} + +function _buildJSONLines(rows: CacheRow[]) { + return rows.map((row) => JSON.stringify(row)).join("\n") + "\n"; +} + +function _parseJSONLines(lines: string) { + return lines + .split("\n") + .map(_tryParseJSON) + .filter((line) => line !== null); +} + +function _tryParseJSON(line: string) { + try { + return JSON.parse(line); + } catch (e) { + return null; + } +} diff --git a/packages/cli/src/cli/utils/cloudflare-status.ts b/packages/cli/src/cli/utils/cloudflare-status.ts new file mode 100644 index 000000000..cb765daa1 --- /dev/null +++ b/packages/cli/src/cli/utils/cloudflare-status.ts @@ -0,0 +1,30 @@ +export interface CloudflareStatusResponse { + status: { + indicator: "none" | "minor" | "major" | "critical"; + description: string; + }; +} + +export async function checkCloudflareStatus(): Promise { + try { + const response = await fetch( + "https://www.cloudflarestatus.com/api/v2/status.json", + { + signal: AbortSignal.timeout(5000), + }, + ); + if (response.ok) { + return await response.json(); + } + } catch (error) {} + return null; +} + +export function formatCloudflareStatusMessage( + status: CloudflareStatusResponse, +): string { + if (status.status.indicator === "none") { + return ""; + } + return `Cloudflare is experiencing ${status.status.indicator} issues: ${status.status.description}. This may be affecting the API connection.`; +} diff --git a/packages/cli/src/cli/utils/config.ts b/packages/cli/src/cli/utils/config.ts new file mode 100644 index 000000000..be18f6018 --- /dev/null +++ b/packages/cli/src/cli/utils/config.ts @@ -0,0 +1,167 @@ +import _ from "lodash"; +import fs from "fs"; +import path from "path"; +import { I18nConfig, parseI18nConfig } from "@lingo.dev/_spec"; + +let _cachedConfigPath: string | null = null; +let _cachedConfigRoot: string | null = null; + +export function getConfig(resave = true): I18nConfig | null { + const configInfo = _findConfigPath(); + if (!configInfo) { + return null; + } + + const { configPath, configRoot } = configInfo; + + const fileContents = fs.readFileSync(configPath, "utf8"); + const rawConfig = JSON.parse(fileContents); + + const result = parseI18nConfig(rawConfig); + const didConfigChange = !_.isEqual(rawConfig, result); + + if (resave && didConfigChange) { + // Ensure the config is saved with the latest version / schema + saveConfig(result); + } + + return result; +} + +export function getConfigOrThrow(resave = true): I18nConfig { + const config = getConfig(resave); + + if (!config) { + // Try to find configs in subdirectories to provide helpful error message + const foundBelow = findConfigsDownwards(); + if (foundBelow.length > 0) { + const configList = foundBelow + .slice(0, 5) // Limit to 5 to avoid overwhelming output + .map((p) => ` - ${p}`) + .join("\n"); + const moreText = + foundBelow.length > 5 + ? `\n ... and ${foundBelow.length - 5} more` + : ""; + throw new Error( + `i18n.json not found in current directory or parent directories.\n\n` + + `Found ${foundBelow.length} config file(s) in subdirectories:\n` + + configList + + moreText + + `\n\nPlease cd into one of these directories, or run \`lingo.dev init\` to initialize a new project.`, + ); + } else { + throw new Error( + `i18n.json not found. Please run \`lingo.dev init\` to initialize the project.`, + ); + } + } + + return config; +} + +export function saveConfig(config: I18nConfig) { + const configInfo = _findConfigPath(); + if (!configInfo) { + throw new Error("Cannot save config: i18n.json not found"); + } + + const serialized = JSON.stringify(config, null, 2); + fs.writeFileSync(configInfo.configPath, serialized); + + return config; +} + +export function getConfigRoot(): string | null { + const configInfo = _findConfigPath(); + return configInfo?.configRoot || null; +} + +export function findConfigsDownwards( + startDir: string = process.cwd(), + maxDepth: number = 3, +): string[] { + const found: string[] = []; + + function search(dir: string, depth: number) { + if (depth > maxDepth) return; + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + // Skip common directories that shouldn't contain configs + if ( + entry.name === "node_modules" || + entry.name === ".git" || + entry.name === "dist" || + entry.name === "build" || + entry.name.startsWith(".") + ) { + continue; + } + + const subDir = path.join(dir, entry.name); + const configPath = path.join(subDir, "i18n.json"); + + if (fs.existsSync(configPath)) { + found.push(path.relative(startDir, configPath)); + } + + search(subDir, depth + 1); + } + } + } catch (error) { + // Ignore permission errors, etc. + } + } + + search(startDir, 0); + return found; +} + +// Private + +function _findConfigPath(): { configPath: string; configRoot: string } | null { + // Use cached path if available + if (_cachedConfigPath && _cachedConfigRoot) { + return { configPath: _cachedConfigPath, configRoot: _cachedConfigRoot }; + } + + const result = _findConfigUpwards(process.cwd()); + if (result) { + _cachedConfigPath = result.configPath; + _cachedConfigRoot = result.configRoot; + } + + return result; +} + +function _findConfigUpwards( + startDir: string, +): { configPath: string; configRoot: string } | null { + let currentDir = path.resolve(startDir); + const root = path.parse(currentDir).root; + + while (true) { + const configPath = path.join(currentDir, "i18n.json"); + + if (fs.existsSync(configPath)) { + return { + configPath, + configRoot: currentDir, + }; + } + + // Check if we've reached the filesystem root + if (currentDir === root) { + break; + } + + // Move up one directory + currentDir = path.dirname(currentDir); + } + + return null; +} diff --git a/packages/cli/src/cli/utils/delta.spec.ts b/packages/cli/src/cli/utils/delta.spec.ts new file mode 100644 index 000000000..bec2e1bbc --- /dev/null +++ b/packages/cli/src/cli/utils/delta.spec.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createDeltaProcessor } from "./delta"; +import * as path from "path"; +import { tryReadFile, writeFile, checkIfFileExists } from "../utils/fs"; +import YAML from "yaml"; + +// Setup mocks before importing the module +vi.mock("object-hash", () => ({ + MD5: vi.fn().mockImplementation((value) => `mocked-hash-${value}`), +})); + +// Mock dependencies +vi.mock("path", () => ({ + join: vi.fn(() => "/mocked/path/i18n.lock"), +})); + +vi.mock("../utils/fs", () => ({ + tryReadFile: vi.fn(), + writeFile: vi.fn(), + checkIfFileExists: vi.fn(), +})); + +// Import MD5 after mocking +import { MD5 } from "object-hash"; + +describe("createDeltaProcessor", () => { + const mockFileKey = "test-file-key"; + let mockProcessor; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset the mock implementation for MD5 + (MD5 as any).mockImplementation((value) => `mocked-hash-${value}`); + // Create a new processor instance for each test + mockProcessor = createDeltaProcessor(mockFileKey); + }); + + describe("checkIfLockExists", () => { + it("should call checkIfFileExists with the correct path", async () => { + (checkIfFileExists as any).mockResolvedValue(true); + + const result = await mockProcessor.checkIfLockExists(); + + expect(path.join).toHaveBeenCalledWith(process.cwd(), "i18n.lock"); + expect(checkIfFileExists).toHaveBeenCalledWith("/mocked/path/i18n.lock"); + expect(result).toBe(true); + }); + }); + + describe("calculateDelta", () => { + it("should correctly identify added keys", async () => { + const sourceData = { key1: "value1", key2: "value2" }; + const targetData = { key1: "value1" }; + const checksums = { key1: "checksum1" }; + + const result = await mockProcessor.calculateDelta({ + sourceData, + targetData, + checksums, + }); + + expect(result.added).toEqual(["key2"]); + expect(result.hasChanges).toBe(true); + }); + + it("should correctly identify removed keys", async () => { + const sourceData = { key1: "value1" }; + const targetData = { key1: "value1", key2: "value2" }; + const checksums = { key1: "checksum1", key2: "checksum2" }; + + const result = await mockProcessor.calculateDelta({ + sourceData, + targetData, + checksums, + }); + + expect(result.removed).toEqual(["key2"]); + expect(result.hasChanges).toBe(true); + }); + + it("should correctly identify updated keys", async () => { + const sourceData = { key1: "new-value1" }; + const targetData = { key1: "value1" }; + const checksums = { key1: "old-checksum" }; // Different from MD5(new-value1) + + const result = await mockProcessor.calculateDelta({ + sourceData, + targetData, + checksums, + }); + + expect(result.updated).toContain("key1"); + expect(result.hasChanges).toBe(true); + }); + + it("should correctly identify renamed keys", async () => { + // Mock to simulate a renamed key (same hash but different key name) + (MD5 as any).mockImplementation((value) => + value === "value1" ? "same-hash" : "other-hash", + ); + + const sourceData = { newKey: "value1" }; + const targetData = { oldKey: "something" }; + const checksums = { oldKey: "same-hash" }; + + const result = await mockProcessor.calculateDelta({ + sourceData, + targetData, + checksums, + }); + + expect(result.renamed).toEqual([["oldKey", "newKey"]]); + expect(result.added).toEqual([]); + expect(result.removed).toEqual([]); + expect(result.hasChanges).toBe(true); + }); + + it("should return hasChanges=false when there are no changes", async () => { + const sourceData = { key1: "value1" }; + const targetData = { key1: "value1" }; + + // Mock to simulate matching checksums + (MD5 as any).mockImplementation((value) => "matching-hash"); + const checksums = { key1: "matching-hash" }; + + const result = await mockProcessor.calculateDelta({ + sourceData, + targetData, + checksums, + }); + + expect(result.added).toEqual([]); + expect(result.removed).toEqual([]); + expect(result.updated).toEqual([]); + expect(result.renamed).toEqual([]); + expect(result.hasChanges).toBe(false); + }); + }); + + describe("loadLock", () => { + it("should return default lock data when no file exists", async () => { + (tryReadFile as any).mockReturnValue(null); + + const result = await mockProcessor.loadLock(); + + expect(result).toEqual({ + version: 1, + checksums: {}, + }); + }); + + it("should parse and return lock file data when it exists", async () => { + const mockYaml = "version: 1\nchecksums:\n fileId:\n key1: checksum1"; + (tryReadFile as any).mockReturnValue(mockYaml); + + const result = await mockProcessor.loadLock(); + + expect(result).toEqual({ + version: 1, + checksums: { + fileId: { + key1: "checksum1", + }, + }, + }); + }); + }); + + describe("saveLock", () => { + it("should stringify and save lock data", async () => { + const lockData = { + version: 1 as const, + checksums: { + fileId: { + key1: "checksum1", + }, + }, + }; + + await mockProcessor.saveLock(lockData); + + expect(writeFile).toHaveBeenCalledWith( + "/mocked/path/i18n.lock", + expect.any(String), + ); + + // Verify the YAML conversion is correct + const yamlArg = (writeFile as any).mock.calls[0][1]; + const parsedBack = YAML.parse(yamlArg); + expect(parsedBack).toEqual(lockData); + }); + }); + + describe("loadChecksums and saveChecksums", () => { + it("should load checksums for the specific file key", async () => { + // Reset MD5 implementation for fileKey hash + (MD5 as any).mockImplementation((value) => "mocked-hash"); + + // Mock the loadLock to return specific data + const mockLockData = { + version: 1 as const, + checksums: { + "mocked-hash": { + key1: "checksum1", + }, + }, + }; + + vi.spyOn(mockProcessor, "loadLock").mockResolvedValue(mockLockData); + + const result = await mockProcessor.loadChecksums(); + + expect(result).toEqual({ + key1: "checksum1", + }); + }); + + it("should save checksums for the specific file key", async () => { + const checksums = { key1: "checksum1" }; + + // Reset MD5 implementation for fileKey hash + (MD5 as any).mockImplementation((value) => "mocked-hash"); + + // Mock loadLock and saveLock + const mockLockData = { + version: 1 as const, + checksums: {}, + }; + vi.spyOn(mockProcessor, "loadLock").mockResolvedValue(mockLockData); + const saveLockSpy = vi + .spyOn(mockProcessor, "saveLock") + .mockResolvedValue(void 0); + + await mockProcessor.saveChecksums(checksums); + + expect(saveLockSpy).toHaveBeenCalledWith({ + version: 1, + checksums: { + "mocked-hash": checksums, + }, + }); + }); + }); + + describe("createChecksums", () => { + it("should create checksums from source data", async () => { + const sourceData = { + key1: "value1", + key2: "value2", + }; + + // Setup counter for mock + let counter = 0; + (MD5 as any).mockImplementation((value) => `mock-hash-${++counter}`); + + const result = await mockProcessor.createChecksums(sourceData); + + expect(result).toEqual({ + key1: "mock-hash-1", + key2: "mock-hash-2", + }); + }); + }); +}); diff --git a/packages/cli/src/cli/utils/delta.ts b/packages/cli/src/cli/utils/delta.ts new file mode 100644 index 000000000..548d4b9af --- /dev/null +++ b/packages/cli/src/cli/utils/delta.ts @@ -0,0 +1,125 @@ +import _ from "lodash"; +import z from "zod"; +import { md5 } from "./md5"; +import { tryReadFile, writeFile, checkIfFileExists } from "../utils/fs"; +import * as path from "path"; +import YAML from "yaml"; +import { getConfigRoot } from "./config"; + +const LockSchema = z.object({ + version: z.literal(1).prefault(1), + checksums: z + .record( + z.string(), // localizable files' keys + // checksums hashmap + z + .record( + // key + z.string(), + // checksum of the key's value in the source locale + z.string(), + ) + .prefault({}), + ) + .prefault({}), +}); +export type LockData = z.infer; + +export type Delta = { + added: string[]; + removed: string[]; + updated: string[]; + renamed: [string, string][]; + hasChanges: boolean; +}; + +export function createDeltaProcessor(fileKey: string) { + const configRoot = getConfigRoot() || process.cwd(); + const lockfilePath = path.join(configRoot, "i18n.lock"); + return { + async checkIfLockExists() { + return checkIfFileExists(lockfilePath); + }, + async calculateDelta(params: { + sourceData: Record; + targetData: Record; + checksums: Record; + }): Promise { + let added = _.difference( + Object.keys(params.sourceData), + Object.keys(params.targetData), + ); + let removed = _.difference( + Object.keys(params.targetData), + Object.keys(params.sourceData), + ); + const updated = Object.keys(params.sourceData).filter( + (key) => + md5(params.sourceData[key]) !== params.checksums[key] && + params.checksums[key], + ); + + const renamed: [string, string][] = []; + for (const addedKey of added) { + const addedHash = md5(params.sourceData[addedKey]); + for (const removedKey of removed) { + if (params.checksums[removedKey] === addedHash) { + renamed.push([removedKey, addedKey]); + break; + } + } + } + added = added.filter( + (key) => !renamed.some(([oldKey, newKey]) => newKey === key), + ); + removed = removed.filter( + (key) => !renamed.some(([oldKey, newKey]) => oldKey === key), + ); + + const hasChanges = [ + added.length > 0, + removed.length > 0, + updated.length > 0, + renamed.length > 0, + ].some((v) => v); + + return { + added, + removed, + updated, + renamed, + hasChanges, + }; + }, + async loadLock() { + const lockfileContent = tryReadFile(lockfilePath, null); + const lockfileYaml = lockfileContent ? YAML.parse(lockfileContent) : null; + const lockfileData: z.infer = lockfileYaml + ? LockSchema.parse(lockfileYaml) + : { + version: 1, + checksums: {}, + }; + return lockfileData; + }, + async saveLock(lockData: LockData) { + const lockfileYaml = YAML.stringify(lockData); + writeFile(lockfilePath, lockfileYaml); + }, + async loadChecksums() { + const id = md5(fileKey); + const lockfileData = await this.loadLock(); + return lockfileData.checksums[id] || {}; + }, + async saveChecksums(checksums: Record) { + const id = md5(fileKey); + const lockfileData = await this.loadLock(); + lockfileData.checksums[id] = checksums; + await this.saveLock(lockfileData); + }, + async createChecksums(sourceData: Record) { + const checksums = _.mapValues(sourceData, (value) => md5(value)); + return checksums; + }, + }; +} diff --git a/packages/cli/src/cli/utils/ensure-patterns.ts b/packages/cli/src/cli/utils/ensure-patterns.ts new file mode 100644 index 000000000..e71b66cf2 --- /dev/null +++ b/packages/cli/src/cli/utils/ensure-patterns.ts @@ -0,0 +1,81 @@ +import fs from "fs"; +import path from "path"; +import { glob } from "glob"; +import _ from "lodash"; +import { LocaleCode, resolveLocaleCode } from "@lingo.dev/_spec"; + +export function ensurePatterns(patterns: string[], source: string) { + if (patterns.length === 0) { + throw new Error("No patterns found"); + } + + patterns.forEach((pattern) => { + const filePath = pattern.replace("[locale]", source); + if (!fs.existsSync(filePath)) { + const defaultContent = getDefaultContent(path.extname(filePath), source); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, defaultContent); + } + }); +} + +function getDefaultContent(ext: string, source: string) { + const defaultGreeting = "Hello from Lingo.dev"; + switch (ext) { + case ".json": + case ".arb": + return `{\n\t"greeting": "${defaultGreeting}"\n}`; + case ".yml": + return `${source}:\n\tgreeting: "${defaultGreeting}"`; + case ".xml": + return `\n\t${defaultGreeting}\n`; + case ".md": + return `# ${defaultGreeting}`; + case ".xcstrings": + return `{ + "sourceLanguage" : "${source}", + "strings" : { + "${defaultGreeting}" : { + "extractionState" : "manual", + "localizations" : { + "${source}" : { + "stringUnit" : { + "state" : "translated", + "value" : "${defaultGreeting}" + } + } + } + } + } +}`; + case ".strings": + return `"greeting" = "${defaultGreeting}";`; + case ".stringsdict": + return ` + + + + key + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + No items + one + One item + other + %d items + + + +`; + default: + throw new Error(`Unsupported file extension: ${ext}`); + } +} diff --git a/packages/cli/src/cli/utils/errors.ts b/packages/cli/src/cli/utils/errors.ts new file mode 100644 index 000000000..648730706 --- /dev/null +++ b/packages/cli/src/cli/utils/errors.ts @@ -0,0 +1,231 @@ +export const docLinks = { + i18nNotFound: "https://lingo.dev/cli", + bucketNotFound: "https://lingo.dev/cli", + authError: "https://lingo.dev/cli", + localeTargetNotFound: "https://lingo.dev/cli", + // corrected key (previously misspelled as "lockFiletNotFound") + lockFileNotFound: "https://lingo.dev/cli", + // legacy alias for backward compatibility + lockFiletNotFound: "https://lingo.dev/cli", + failedReplexicaEngine: "https://lingo.dev/cli", + placeHolderFailed: "https://lingo.dev/cli", + translationFailed: "https://lingo.dev/cli", + connectionFailed: "https://lingo.dev/cli", + invalidType: "https://lingo.dev/cli", + invalidPathPattern: "https://lingo.dev/cli", + // corrected key (previously misspelled as "androidResouceError") + androidResourceError: "https://lingo.dev/cli", + // legacy alias for backward compatibility + androidResouceError: "https://lingo.dev/cli", + invalidBucketType: "https://lingo.dev/cli", + invalidStringDict: "https://lingo.dev/cli", +}; + +type DocLinkKeys = keyof typeof docLinks; + +export class CLIError extends Error { + public readonly docUrl: string; + public readonly errorType: string = "cli_error"; + + constructor({ message, docUrl }: { message: string; docUrl: DocLinkKeys }) { + super(message); + this.docUrl = docLinks[docUrl]; + this.message = `${this.message}\n visit: ${this.docUrl}`; + } +} + +export class ConfigError extends CLIError { + public readonly errorType = "config_error"; + + constructor({ message, docUrl }: { message: string; docUrl: DocLinkKeys }) { + super({ message, docUrl }); + this.name = "ConfigError"; + } +} + +export class AuthenticationError extends CLIError { + public readonly errorType = "auth_error"; + + constructor({ message, docUrl }: { message: string; docUrl: DocLinkKeys }) { + super({ message, docUrl }); + this.name = "AuthenticationError"; + } +} + +export class ValidationError extends CLIError { + public readonly errorType = "validation_error"; + + constructor({ message, docUrl }: { message: string; docUrl: DocLinkKeys }) { + super({ message, docUrl }); + this.name = "ValidationError"; + } +} + +export class LocalizationError extends Error { + public readonly errorType = "locale_error"; + public readonly bucket?: string; + public readonly sourceLocale?: string; + public readonly targetLocale?: string; + public readonly pathPattern?: string; + + constructor( + message: string, + context?: { + bucket?: string; + sourceLocale?: string; + targetLocale?: string; + pathPattern?: string; + }, + ) { + super(message); + this.name = "LocalizationError"; + this.bucket = context?.bucket; + this.sourceLocale = context?.sourceLocale; + this.targetLocale = context?.targetLocale; + this.pathPattern = context?.pathPattern; + } +} + +export class BucketProcessingError extends Error { + public readonly errorType = "bucket_error"; + public readonly bucket: string; + + constructor(message: string, bucket: string) { + super(message); + this.name = "BucketProcessingError"; + this.bucket = bucket; + } +} + +// Type guard functions for robust error detection +export function isConfigError(error: any): error is ConfigError { + return error instanceof ConfigError || error.errorType === "config_error"; +} + +export function isAuthenticationError( + error: any, +): error is AuthenticationError { + return ( + error instanceof AuthenticationError || error.errorType === "auth_error" + ); +} + +export function isValidationError(error: any): error is ValidationError { + return ( + error instanceof ValidationError || error.errorType === "validation_error" + ); +} + +export function isLocalizationError(error: any): error is LocalizationError { + return ( + error instanceof LocalizationError || error.errorType === "locale_error" + ); +} + +export function isBucketProcessingError( + error: any, +): error is BucketProcessingError { + return ( + error instanceof BucketProcessingError || error.errorType === "bucket_error" + ); +} + +export function getCLIErrorType(error: any): string { + if (isConfigError(error)) return "config_error"; + if (isAuthenticationError(error)) return "auth_error"; + if (isValidationError(error)) return "validation_error"; + if (isLocalizationError(error)) return "locale_error"; + if (isBucketProcessingError(error)) return "bucket_error"; + if (error instanceof CLIError) return "cli_error"; + return "unknown_error"; +} + +// Error detail interface for consistent tracking +export interface ErrorDetail { + type: + | "bucket_error" + | "locale_error" + | "validation_error" + | "auth_error" + | "config_error"; + bucket?: string; + locale?: string; + pathPattern?: string; + message: string; + stack?: string; +} + +// Utility to create previous error context for fatal errors +export function createPreviousErrorContext(errorDetails: ErrorDetail[]) { + if (errorDetails.length === 0) return undefined; + + return { + count: errorDetails.length, + types: [...new Set(errorDetails.map((e) => e.type))], + buckets: [...new Set(errorDetails.map((e) => e.bucket).filter(Boolean))], + }; +} + +// Utility to create aggregated error analytics +export function aggregateErrorAnalytics( + errorDetails: ErrorDetail[], + buckets: any[], + targetLocales: string[], + i18nConfig: any, +) { + if (errorDetails.length === 0) { + return { + errorCount: 0, + errorTypes: [], + errorsByBucket: {}, + errorsByType: {}, + firstError: undefined, + bucketCount: buckets.length, + localeCount: targetLocales.length, + i18nConfig: { + sourceLocale: i18nConfig.locale.source, + targetLocales: i18nConfig.locale.targets, + bucketTypes: Object.keys(i18nConfig.buckets), + }, + }; + } + + const errorsByBucket = errorDetails.reduce( + (acc, error) => { + if (error.bucket) { + acc[error.bucket] = (acc[error.bucket] || 0) + 1; + } + return acc; + }, + {} as Record, + ); + + const errorsByType = errorDetails.reduce( + (acc, error) => { + acc[error.type] = (acc[error.type] || 0) + 1; + return acc; + }, + {} as Record, + ); + + return { + errorCount: errorDetails.length, + errorTypes: [...new Set(errorDetails.map((e) => e.type))], + errorsByBucket, + errorsByType, + firstError: { + type: errorDetails[0].type, + bucket: errorDetails[0].bucket, + locale: errorDetails[0].locale, + pathPattern: errorDetails[0].pathPattern, + message: errorDetails[0].message, + }, + bucketCount: buckets.length, + localeCount: targetLocales.length, + i18nConfig: { + sourceLocale: i18nConfig.locale.source, + targetLocales: i18nConfig.locale.targets, + bucketTypes: Object.keys(i18nConfig.buckets), + }, + }; +} diff --git a/packages/cli/src/cli/utils/exec.spec.ts b/packages/cli/src/cli/utils/exec.spec.ts new file mode 100644 index 000000000..af1bbc820 --- /dev/null +++ b/packages/cli/src/cli/utils/exec.spec.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from "vitest"; +import { execAsync, ExecAsyncOptions } from "./exec"; + +// describe('execAsync', () => { +// // Helper function to create a delayed async function +// const createDelayedFunction = (value: any, delay: number) => { +// return async () => { +// console.log(`[${Date.now()}] start`, value); +// await new Promise(resolve => setTimeout(resolve, delay)); +// console.log(`[${Date.now()}] end`, value); +// return value; +// }; +// }; + +// it('run', async () => { +// await execAsync([ +// createDelayedFunction(1, 750), +// createDelayedFunction(2, 750), +// createDelayedFunction(3, 750), +// createDelayedFunction(4, 750), +// ], { +// concurrency: 2, +// delay: 250, +// }); +// }); +// }); + +describe("execAsync", () => { + it("executes all functions and returns their results", async () => { + const fns = [async () => 1, async () => 2, async () => 3]; + const options: ExecAsyncOptions = { concurrency: 1, delay: 0 }; + const results = await execAsync(fns, options); + expect(results).toEqual([1, 2, 3]); + }); + + it("calls onProgress with correct values", async () => { + const fns = [async () => 1, async () => 2, async () => 3]; + const onProgress = vi.fn(); + const options: ExecAsyncOptions = { concurrency: 1, delay: 0, onProgress }; + await execAsync(fns, options); + expect(onProgress).toHaveBeenCalledTimes(4); + expect(onProgress).toHaveBeenNthCalledWith(1, 0, 3); + expect(onProgress).toHaveBeenNthCalledWith(2, 1, 3); + expect(onProgress).toHaveBeenNthCalledWith(3, 2, 3); + expect(onProgress).toHaveBeenNthCalledWith(4, 3, 3); + }); + + it("starts next function if previous finishes before delay", async () => { + const delay = 100; + const fns = [ + vi.fn().mockResolvedValue(1), + vi + .fn() + .mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(2), 50)), + ), + vi.fn().mockResolvedValue(3), + ]; + const options: ExecAsyncOptions = { concurrency: 1, delay }; + const start = Date.now(); + await execAsync(fns, options); + const end = Date.now(); + expect(end - start).toBeLessThan(delay * 3); + }); + + it("respects concurrency limit", async () => { + const concurrency = 2; + const delay = 100; + let maxConcurrent = 0; + let currentConcurrent = 0; + + const fns = Array(5) + .fill(null) + .map(() => async () => { + currentConcurrent++; + maxConcurrent = Math.max(maxConcurrent, currentConcurrent); + await new Promise((resolve) => setTimeout(resolve, delay)); + currentConcurrent--; + }); + + const options: ExecAsyncOptions = { concurrency, delay: 0 }; + await execAsync(fns, options); + expect(maxConcurrent).toBe(concurrency); + }); + + it("handles empty array of functions", async () => { + const options: ExecAsyncOptions = { concurrency: 1, delay: 0 }; + const results = await execAsync([], options); + expect(results).toEqual([]); + }); + + it("handles single function", async () => { + const fn = async () => 42; + const options: ExecAsyncOptions = { concurrency: 1, delay: 0 }; + const results = await execAsync([fn], options); + expect(results).toEqual([42]); + }); +}); diff --git a/packages/cli/src/cli/utils/exec.ts b/packages/cli/src/cli/utils/exec.ts new file mode 100644 index 000000000..6c9028e09 --- /dev/null +++ b/packages/cli/src/cli/utils/exec.ts @@ -0,0 +1,86 @@ +import Z from "zod"; +import pLimit from "p-limit"; + +export type ExecAsyncOptions = Z.infer; + +/** + * Executes functions in parallel with a limit on concurrency and delay between each function call. + * + * The next function is called only after the delay has passed OR the previous function resolved, whichever is earlier (via race). + * + * During the execution, it calls `onProgress` function with the number of functions completed and total count. + * + * When all functions are executed, it returns an array of results. + * @param fns Array of async functions + * @param options Options + */ +export async function execAsync( + fns: (() => Promise)[], + options: ExecAsyncOptions = ExecAsyncSchema.parse({}), +) { + const limit = pLimit(options.concurrency); + const limitedFns = fns.map((fn) => () => limit(fn)); + + const resultPromises: Promise[] = []; + + let completedCount = 0; + options.onProgress?.(completedCount, limitedFns.length); + + for (let i = 0; i < limitedFns.length; i++) { + const fn = limitedFns[i]; + const resultPromise = fn().then((result) => { + completedCount++; + options.onProgress?.(completedCount, limitedFns.length); + return result; + }); + resultPromises.push(resultPromise); + + await Promise.race([resultPromise, delay(options.delay)]); + } + + const results = await Promise.all(resultPromises); + return results; +} + +export type ExecWithRetryOptions = Z.infer; + +export async function execWithRetry( + fn: () => Promise, + options: ExecWithRetryOptions = ExecWithRetrySchema.parse({}), +) { + let lastError: any; + + for (let i = 0; i < options.attempts; i++) { + try { + return await fn(); + } catch (error: any) { + lastError = error; + await delay(options.delay); + } + } + + throw lastError; +} + +// Helpers + +const ExecAsyncSchema = Z.object({ + delay: Z.number().nonnegative().prefault(1000), + concurrency: Z.number().positive().prefault(1), + onProgress: Z.function({ + input: Z.tuple([ + Z.number().positive(), // completed count + Z.number().positive(), // total count + ]), + output: Z.void(), + }).optional(), +}); + +const ExecWithRetrySchema = Z.object({ + delay: Z.number().nonnegative().prefault(0), + attempts: Z.number().positive().prefault(3), +}); + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/cli/src/cli/utils/exit-gracefully.spec.ts b/packages/cli/src/cli/utils/exit-gracefully.spec.ts new file mode 100644 index 000000000..195689353 --- /dev/null +++ b/packages/cli/src/cli/utils/exit-gracefully.spec.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { exitGracefully } from "./exit-gracefully"; + +// Mock process.exit +const mockExit: any = vi.fn(); +const originalProcess = global.process; + +describe("exitGracefully", () => { + beforeEach(() => { + // Mock process.exit + vi.spyOn(process, "exit").mockImplementation(mockExit); + + // Mock process._getActiveHandles and _getActiveRequests + Object.defineProperty(process, "_getActiveHandles", { + value: vi.fn(), + writable: true, + }); + Object.defineProperty(process, "_getActiveRequests", { + value: vi.fn(), + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + mockExit.mockClear(); + }); + + it("should exit immediately when no pending operations", () => { + // Mock no pending operations + (process as any)._getActiveHandles.mockReturnValue([]); + (process as any)._getActiveRequests.mockReturnValue([]); + + exitGracefully(); + + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it("should wait and retry when there are pending operations", () => { + vi.useFakeTimers(); + + // Mock pending operations + (process as any)._getActiveHandles.mockReturnValue([ + { hasRef: () => true, close: () => {} }, + ]); + (process as any)._getActiveRequests.mockReturnValue([]); + + exitGracefully(); + + // Should not exit immediately + expect(mockExit).not.toHaveBeenCalled(); + + // Fast-forward time to trigger retry + vi.advanceTimersByTime(250); + + // Should still not exit if operations are pending + expect(mockExit).not.toHaveBeenCalled(); + + // Fast-forward to max wait time + vi.advanceTimersByTime(1750); + + // Should exit after max wait time + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it("should exit after max wait interval even with pending operations", () => { + vi.useFakeTimers(); + + // Mock persistent pending operations + (process as any)._getActiveHandles.mockReturnValue([ + { hasRef: () => true, close: () => {} }, + ]); + (process as any)._getActiveRequests.mockReturnValue([]); + + exitGracefully(); + + // Fast-forward to max wait time (2000ms) + vi.advanceTimersByTime(2000); + + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it("should handle standard process handles correctly", () => { + // Mock only standard handles + (process as any)._getActiveHandles.mockReturnValue([ + process.stdin, + process.stdout, + process.stderr, + ]); + (process as any)._getActiveRequests.mockReturnValue([]); + + exitGracefully(); + + // Should exit immediately as standard handles are filtered out + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it("should handle timers without ref correctly", () => { + // Mock timers without ref + (process as any)._getActiveHandles.mockReturnValue([ + { hasRef: () => false }, + ]); + (process as any)._getActiveRequests.mockReturnValue([]); + + exitGracefully(); + + // Should exit immediately as timers without ref are filtered out + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it("should detect file watchers correctly", () => { + // Mock file watcher handles + (process as any)._getActiveHandles.mockReturnValue([{ close: () => {} }]); + (process as any)._getActiveRequests.mockReturnValue([]); + + exitGracefully(); + + // Should not exit immediately due to file watcher + expect(mockExit).not.toHaveBeenCalled(); + }); + + it("should detect pending requests correctly", () => { + // Mock pending requests + (process as any)._getActiveHandles.mockReturnValue([]); + (process as any)._getActiveRequests.mockReturnValue([ + { someRequest: true }, + ]); + + exitGracefully(); + + // Should not exit immediately due to pending requests + expect(mockExit).not.toHaveBeenCalled(); + }); + + it("should handle elapsed time parameter correctly", () => { + vi.useFakeTimers(); + + // Mock pending operations + (process as any)._getActiveHandles.mockReturnValue([ + { hasRef: () => true, close: () => {} }, + ]); + (process as any)._getActiveRequests.mockReturnValue([]); + + // Start with 1500ms already elapsed + exitGracefully(1500); + + // Should exit after 500ms more (reaching 2000ms max) + vi.advanceTimersByTime(500); + + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it("should exit immediately when elapsed time exceeds max wait interval", () => { + // Mock pending operations but start with elapsed time > 2000ms + (process as any)._getActiveHandles.mockReturnValue([ + { hasRef: () => true, close: () => {} }, + ]); + (process as any)._getActiveRequests.mockReturnValue([]); + + exitGracefully(2500); + + // Should exit immediately as elapsed time exceeds max wait interval + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it("should handle mixed types of pending operations", () => { + vi.useFakeTimers(); + + // Mock mixed pending operations + (process as any)._getActiveHandles.mockReturnValue([ + { hasRef: () => true, close: () => {} }, + { hasRef: () => false }, // Timer without ref + process.stdin, // Standard handle + ]); + (process as any)._getActiveRequests.mockReturnValue([ + { someRequest: true }, + ]); + + exitGracefully(); + + // Should not exit immediately due to mixed pending operations + expect(mockExit).not.toHaveBeenCalled(); + + // Fast-forward to max wait time + vi.advanceTimersByTime(2000); + + expect(mockExit).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/cli/src/cli/utils/exit-gracefully.ts b/packages/cli/src/cli/utils/exit-gracefully.ts new file mode 100644 index 000000000..67e1b1029 --- /dev/null +++ b/packages/cli/src/cli/utils/exit-gracefully.ts @@ -0,0 +1,56 @@ +const STEP_WAIT_INTERVAL = 250; +const MAX_WAIT_INTERVAL = 2000; + +export function exitGracefully(elapsedMs = 0) { + // Check if there are any pending operations + const hasPendingOperations = checkForPendingOperations(); + + if (hasPendingOperations && elapsedMs < MAX_WAIT_INTERVAL) { + // Wait a bit longer if there are pending operations + setTimeout( + () => exitGracefully(elapsedMs + STEP_WAIT_INTERVAL), + STEP_WAIT_INTERVAL, + ); + } else { + // Exit immediately if no pending operations + process.exit(0); + } +} + +function checkForPendingOperations(): boolean { + // Check for active handles and requests using internal Node.js methods + const activeHandles = (process as any)._getActiveHandles?.() || []; + const activeRequests = (process as any)._getActiveRequests?.() || []; + + // Filter out standard handles that are always present + const nonStandardHandles = activeHandles.filter((handle: any) => { + // Skip standard handles like process.stdin, process.stdout, etc. + if ( + handle === process.stdin || + handle === process.stdout || + handle === process.stderr + ) { + return false; + } + // Skip timers that are part of the normal process + if ( + handle && + typeof handle === "object" && + "hasRef" in handle && + !handle.hasRef() + ) { + return false; + } + return true; + }); + + // Check if there are any file watchers or other async operations + const hasFileWatchers = nonStandardHandles.some( + (handle: any) => handle && typeof handle === "object" && "close" in handle, + ); + + // Check for pending promises or async operations + const hasPendingPromises = activeRequests.length > 0; + + return nonStandardHandles.length > 0 || hasFileWatchers || hasPendingPromises; +} diff --git a/packages/cli/src/cli/utils/exp-backoff.ts b/packages/cli/src/cli/utils/exp-backoff.ts new file mode 100644 index 000000000..08fbafd74 --- /dev/null +++ b/packages/cli/src/cli/utils/exp-backoff.ts @@ -0,0 +1,19 @@ +export function withExponentialBackoff( + fn: (...args: Args) => Promise, + maxAttempts: number = 3, + baseDelay: number = 1000, +): (...args: Args) => Promise { + return async (...args: Args): Promise => { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + return await fn(...args); + } catch (error) { + if (attempt === maxAttempts - 1) throw error; + + const delay = baseDelay * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw new Error("Unreachable code"); + }; +} diff --git a/packages/cli/src/cli/utils/find-locale-paths.spec.ts b/packages/cli/src/cli/utils/find-locale-paths.spec.ts new file mode 100644 index 000000000..0362081e5 --- /dev/null +++ b/packages/cli/src/cli/utils/find-locale-paths.spec.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { glob } from "glob"; +import findLocaleFiles from "./find-locale-paths"; + +vi.mock("glob", () => ({ + glob: { + sync: vi.fn(), + }, +})); + +describe("findLocaleFiles", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should find json locale files", () => { + vi.mocked(glob.sync).mockReturnValue([ + // valid locales + "src/i18n/en.json", + "src/i18n/fr.json", + "src/i18n/en-US.json", + "src/translations/es.json", + + // not a valid locale + "src/xx.json", + "src/settings.json", + ]); + + const result = findLocaleFiles("json"); + + expect(result).toEqual({ + patterns: ["src/i18n/[locale].json", "src/translations/[locale].json"], + defaultPatterns: ["i18n/[locale].json"], + }); + }); + + it("should find yaml locale files", () => { + vi.mocked(glob.sync).mockReturnValue([ + "locales/en.yml", + "locales/fr.yml", + "translations/es.yml", + ]); + + const result = findLocaleFiles("yaml"); + + expect(result).toEqual({ + patterns: ["locales/[locale].yml", "translations/[locale].yml"], + defaultPatterns: ["i18n/[locale].yml"], + }); + }); + + it("should find flutter arb locale files", () => { + vi.mocked(glob.sync).mockReturnValue([ + "lib/l10n/en.arb", + "lib/l10n/es.arb", + "lib/translations/fr.arb", + ]); + + const result = findLocaleFiles("flutter"); + + expect(result).toEqual({ + patterns: ["lib/l10n/[locale].arb", "lib/translations/[locale].arb"], + defaultPatterns: ["i18n/[locale].arb"], + }); + }); + + it("should find locale files in nested directories", () => { + vi.mocked(glob.sync).mockReturnValue([ + // valid locales + "src/locales/en/messages.json", + "src/locales/fr/messages.json", + "src/i18n/es/strings.json", + "src/translations/es.json", + "src/aa/translations/en.json", + "src/aa/bb/foobar/cc/translations/es/values.json", + "src/aa/en.json", + "src/aa/translations/bb/en.json", + "foo/en-US/en-US.json", + "foo/en-US/en-US/messages.json", + "bar/es/baz/es.json", + "bar/es/es.json", + + // not a valid locale + "src/xx/settings.json", + "src/xx.json", + ]); + + const result = findLocaleFiles("json"); + + expect(result).toEqual({ + patterns: [ + "src/locales/[locale]/messages.json", + "src/i18n/[locale]/strings.json", + "src/translations/[locale].json", + "src/aa/translations/[locale].json", + "src/aa/bb/foobar/cc/translations/[locale]/values.json", + "src/aa/[locale].json", + "src/aa/translations/bb/[locale].json", + "foo/[locale]/[locale].json", + "foo/[locale]/[locale]/messages.json", + "bar/[locale]/baz/[locale].json", + "bar/[locale]/[locale].json", + ], + defaultPatterns: ["i18n/[locale].json"], + }); + }); + + it("should return no patterns when no files found", () => { + vi.mocked(glob.sync).mockReturnValue([]); + + const result = findLocaleFiles("json"); + + expect(result).toEqual({ + patterns: [], + defaultPatterns: ["i18n/[locale].json"], + }); + }); + + it("should find xcode-xcstrings locale files", () => { + vi.mocked(glob.sync).mockReturnValue([ + "ios/MyApp/Localizable.xcstrings", + "ios/MyApp/Onboarding/Localizable.xcstrings", + "ios/MyApp/Onboarding/fr.xcstrings", + "ios/MyApp/xx/Localizable.xcstrings", + ]); + + const result = findLocaleFiles("xcode-xcstrings"); + + expect(result).toEqual({ + patterns: [ + "ios/MyApp/Localizable.xcstrings", + "ios/MyApp/Onboarding/Localizable.xcstrings", + "ios/MyApp/xx/Localizable.xcstrings", + ], + defaultPatterns: ["Localizable.xcstrings"], + }); + }); + + it("should return no patterns for xcode-xcstrings when no files found", () => { + vi.mocked(glob.sync).mockReturnValue([]); + + const result = findLocaleFiles("xcode-xcstrings"); + + expect(result).toEqual({ + patterns: [], + defaultPatterns: ["Localizable.xcstrings"], + }); + }); + + it("should return null unsupported bucket type", () => { + expect(findLocaleFiles("invalid")).toBeNull(); + }); +}); diff --git a/packages/cli/src/cli/utils/find-locale-paths.ts b/packages/cli/src/cli/utils/find-locale-paths.ts new file mode 100644 index 000000000..d584e8205 --- /dev/null +++ b/packages/cli/src/cli/utils/find-locale-paths.ts @@ -0,0 +1,109 @@ +import fs from "fs"; +import path from "path"; +import { glob } from "glob"; +import _ from "lodash"; +import { LocaleCode, resolveLocaleCode } from "@lingo.dev/_spec"; + +export default function findLocaleFiles(bucket: string) { + switch (bucket) { + case "json": + return findLocaleFilesWithExtension(".json"); + case "yaml": + return findLocaleFilesWithExtension(".yml"); + case "flutter": + return findLocaleFilesWithExtension(".arb"); + case "android": + return findLocaleFilesWithExtension(".xml"); + case "markdown": + return findLocaleFilesWithExtension(".md"); + case "php": + return findLocaleFilesWithExtension(".php"); + case "po": + return findLocaleFilesWithExtension(".po"); + case "xcode-xcstrings": + return findLocaleFilesForFilename("Localizable.xcstrings"); + case "xcode-strings": + return findLocaleFilesForFilename("Localizable.strings"); + case "xcode-stringsdict": + return findLocaleFilesForFilename("Localizable.stringsdict"); + default: + return null; + } +} + +function findLocaleFilesWithExtension(ext: string) { + const files = glob.sync(`**/*${ext}`, { + ignore: ["node_modules/**", "package*.json", "i18n.json", "lingo.json"], + }); + + const localeFilePattern = new RegExp(`\/([a-z]{2}(-[A-Z]{2})?)${ext}$`); + const localeDirectoryPattern = new RegExp( + `\/([a-z]{2}(-[A-Z]{2})?)\/[^\/]+${ext}$`, + ); + const potentialLocaleFiles = files.filter( + (file: string) => + localeFilePattern.test(file) || localeDirectoryPattern.test(file), + ); + + const potantialLocaleFilesAndPatterns = potentialLocaleFiles + .map((file: string) => { + const matchPotentialLocales = Array.from( + file.matchAll( + new RegExp(`\/([a-z]{2}(-[A-Z]{2})?|[^\/]+)(?=\/|${ext})`, "g"), + ), + ); + const potantialLocales = matchPotentialLocales.map((match) => match[1]); + return { file, potantialLocales }; + }) + .map(({ file, potantialLocales }) => { + for (const locale of potantialLocales) { + try { + resolveLocaleCode(locale as LocaleCode); + return { locale, file }; + } catch (e) {} + } + return { file, locale: null }; + }) + .filter(({ locale }) => locale !== null); + + const localeFilesAndPatterns = potantialLocaleFilesAndPatterns.map( + ({ file, locale }) => { + const pattern = file + .replaceAll(new RegExp(`/${locale}${ext}`, "g"), `/[locale]${ext}`) + .replaceAll(new RegExp(`/${locale}/`, "g"), `/[locale]/`) + .replaceAll(new RegExp(`/${locale}/`, "g"), `/[locale]/`); // for when there are 2 locales one after another + return { pattern, file }; + }, + ); + + const grouppedFilesAndPatterns = _.groupBy(localeFilesAndPatterns, "pattern"); + const patterns = Object.keys(grouppedFilesAndPatterns); + const defaultPatterns = [`i18n/[locale]${ext}`]; + + if (patterns.length > 0) { + return { patterns, defaultPatterns }; + } + + return { patterns: [], defaultPatterns }; +} + +function findLocaleFilesForFilename(fileName: string) { + const pattern = fileName; + const localeFiles = glob.sync(`**/${fileName}`, { + ignore: ["node_modules/**", "package*.json", "i18n.json", "lingo.json"], + }); + + const localeFilesAndPatterns = localeFiles.map((file: string) => ({ + file, + pattern: path.join(path.dirname(file), pattern), + })); + const grouppedFilesAndPatterns = _.groupBy(localeFilesAndPatterns, "pattern"); + const patterns = Object.keys(grouppedFilesAndPatterns); + const defaultPatterns = [fileName]; + + if (patterns.length > 0) { + return { patterns, defaultPatterns }; + } + + return { patterns: [], defaultPatterns }; +} diff --git a/packages/cli/src/cli/utils/fs.ts b/packages/cli/src/cli/utils/fs.ts new file mode 100644 index 000000000..ca876613b --- /dev/null +++ b/packages/cli/src/cli/utils/fs.ts @@ -0,0 +1,27 @@ +import * as fs from "fs"; +import * as path from "path"; + +export function tryReadFile( + filePath: string, + defaultValue: string | null = null, +): string | null { + try { + const content = fs.readFileSync(filePath, "utf-8"); + return content; + } catch (error) { + return defaultValue; + } +} + +export function writeFile(filePath: string, content: string) { + // create dirs + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, content); +} + +export function checkIfFileExists(filePath: string) { + return fs.existsSync(filePath); +} diff --git a/packages/cli/src/cli/utils/init-ci-cd.ts b/packages/cli/src/cli/utils/init-ci-cd.ts new file mode 100644 index 000000000..358f9b4ff --- /dev/null +++ b/packages/cli/src/cli/utils/init-ci-cd.ts @@ -0,0 +1,147 @@ +import { checkbox, confirm } from "@inquirer/prompts"; +import fs from "fs"; +import { Ora } from "ora"; +import path from "path"; + +type Platform = "github" | "bitbucket" | "gitlab"; + +const platforms: Platform[] = ["github", "bitbucket", "gitlab"]; + +export default async function initCICD(spinner: Ora) { + const initializers = getPlatformInitializers(spinner); + + const init = await confirm({ + message: "Would you like to use Lingo.dev in your CI/CD?", + }); + + if (!init) { + spinner.warn( + "CI/CD not initialized. To set it up later, see docs: https://lingo.dev/ci", + ); + return; + } + + const selectedPlatforms: Platform[] = await checkbox({ + message: "Please select CI/CD platform(s) you want to use:", + choices: platforms.map((platform) => ({ + name: initializers[platform].name, + value: platform, + checked: initializers[platform].isEnabled(), + })), + }); + + for (const platform of selectedPlatforms) { + await initializers[platform].init(); + } +} + +function getPlatformInitializers(spinner: Ora) { + return { + github: makeGithubInitializer(spinner), + bitbucket: makeBitbucketInitializer(spinner), + gitlab: makeGitlabInitializer(spinner), + }; +} + +type PlatformConfig = { + name: string; + checkPath: string; + ciConfigPath: string; + ciConfigContent: string; +}; + +function makePlatformInitializer(config: PlatformConfig, spinner: Ora) { + return { + name: config.name, + isEnabled: () => { + const filePath = path.join(process.cwd(), config.checkPath); + return fs.existsSync(filePath); + }, + init: async () => { + const filePath = path.join(process.cwd(), config.ciConfigPath); + const dirPath = path.dirname(filePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + let canWrite = true; + if (fs.existsSync(filePath)) { + canWrite = await confirm({ + message: `File ${filePath} already exists. Do you want to overwrite it?`, + default: false, + }); + } + if (canWrite) { + fs.writeFileSync(filePath, config.ciConfigContent); + spinner.succeed(`CI/CD initialized for ${config.name}`); + } else { + spinner.warn(`CI/CD not initialized for ${config.name}`); + } + }, + }; +} + +function makeGithubInitializer(spinner: Ora) { + return makePlatformInitializer( + { + name: "GitHub Action", + checkPath: ".github", + ciConfigPath: ".github/workflows/i18n.yml", + ciConfigContent: `name: Lingo.dev i18n + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + i18n: + name: Run i18n + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: \${{ secrets.LINGODOTDEV_API_KEY }} +`, + }, + spinner, + ); +} + +function makeBitbucketInitializer(spinner: Ora) { + return makePlatformInitializer( + { + name: "Bitbucket Pipeline", + checkPath: "bitbucket-pipelines.yml", + ciConfigPath: "bitbucket-pipelines.yml", + ciConfigContent: `pipelines: + branches: + main: + - step: + name: Run i18n + script: + - pipe: lingodotdev/lingo.dev:main`, + }, + spinner, + ); +} + +function makeGitlabInitializer(spinner: Ora) { + return makePlatformInitializer( + { + name: "Gitlab CI", + checkPath: ".gitlab-ci.yml", + ciConfigPath: ".gitlab-ci.yml", + ciConfigContent: `lingodotdev: + image: lingodotdev/ci-action:latest + script: + - echo "Done" +`, + }, + spinner, + ); +} diff --git a/packages/cli/src/cli/utils/key-matching.spec.ts b/packages/cli/src/cli/utils/key-matching.spec.ts new file mode 100644 index 000000000..31dd73051 --- /dev/null +++ b/packages/cli/src/cli/utils/key-matching.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { + matchesKeyPattern, + filterEntriesByPattern, + formatDisplayValue, +} from "./key-matching"; + +describe("matchesKeyPattern", () => { + it("should match keys with prefix matching", () => { + const patterns = ["api", "settings"]; + + expect(matchesKeyPattern("api/users", patterns)).toBe(true); + expect(matchesKeyPattern("api/posts", patterns)).toBe(true); + expect(matchesKeyPattern("settings/theme", patterns)).toBe(true); + expect(matchesKeyPattern("other/key", patterns)).toBe(false); + }); + + it("should match keys with glob patterns", () => { + const patterns = ["api/*/users", "settings/*"]; + + expect(matchesKeyPattern("api/v1/users", patterns)).toBe(true); + expect(matchesKeyPattern("api/v2/users", patterns)).toBe(true); + expect(matchesKeyPattern("settings/theme", patterns)).toBe(true); + expect(matchesKeyPattern("settings/notifications", patterns)).toBe(true); + expect(matchesKeyPattern("api/users", patterns)).toBe(false); + }); + + it("should return false for empty patterns", () => { + expect(matchesKeyPattern("any/key", [])).toBe(false); + }); + + it("should handle complex glob patterns", () => { + const patterns = ["steps/*/type", "learningGoals/*/goal"]; + + expect(matchesKeyPattern("steps/0/type", patterns)).toBe(true); + expect(matchesKeyPattern("steps/1/type", patterns)).toBe(true); + expect(matchesKeyPattern("learningGoals/0/goal", patterns)).toBe(true); + expect(matchesKeyPattern("steps/0/name", patterns)).toBe(false); + }); +}); + +describe("filterEntriesByPattern", () => { + it("should filter entries that match patterns", () => { + const entries: [string, any][] = [ + ["api/users", "Users API"], + ["api/posts", "Posts API"], + ["settings/theme", "Dark"], + ["other/key", "Value"], + ]; + const patterns = ["api", "settings"]; + + const result = filterEntriesByPattern(entries, patterns); + + expect(result).toHaveLength(3); + expect(result).toEqual([ + ["api/users", "Users API"], + ["api/posts", "Posts API"], + ["settings/theme", "Dark"], + ]); + }); + + it("should return empty array when no matches", () => { + const entries: [string, any][] = [ + ["key1", "value1"], + ["key2", "value2"], + ]; + const patterns = ["nonexistent"]; + + const result = filterEntriesByPattern(entries, patterns); + + expect(result).toHaveLength(0); + }); +}); + +describe("formatDisplayValue", () => { + it("should return short strings as-is", () => { + expect(formatDisplayValue("Hello")).toBe("Hello"); + expect(formatDisplayValue("Short text")).toBe("Short text"); + }); + + it("should truncate long strings", () => { + const longString = "a".repeat(100); + const result = formatDisplayValue(longString); + + expect(result).toHaveLength(53); // 50 chars + "..." + expect(result.endsWith("...")).toBe(true); + }); + + it("should use custom max length", () => { + const text = "Hello, World!"; + const result = formatDisplayValue(text, 5); + + expect(result).toBe("Hello..."); + }); + + it("should stringify non-string values", () => { + expect(formatDisplayValue(42)).toBe("42"); + expect(formatDisplayValue(true)).toBe("true"); + expect(formatDisplayValue({ key: "value" })).toBe('{"key":"value"}'); + expect(formatDisplayValue(null)).toBe("null"); + }); + + it("should handle arrays", () => { + expect(formatDisplayValue([1, 2, 3])).toBe("[1,2,3]"); + }); +}); diff --git a/packages/cli/src/cli/utils/key-matching.ts b/packages/cli/src/cli/utils/key-matching.ts new file mode 100644 index 000000000..f8d0cdc3e --- /dev/null +++ b/packages/cli/src/cli/utils/key-matching.ts @@ -0,0 +1,32 @@ +import { minimatch } from "minimatch"; + +/** + * Checks if a key matches any of the provided patterns using prefix or glob matching + */ +export function matchesKeyPattern(key: string, patterns: string[]): boolean { + return patterns.some( + (pattern) => key.startsWith(pattern) || minimatch(key, pattern), + ); +} + +/** + * Filters entries based on key matching patterns + */ +export function filterEntriesByPattern( + entries: [string, any][], + patterns: string[], +): [string, any][] { + return entries.filter(([key]) => matchesKeyPattern(key, patterns)); +} + +/** + * Formats a value for display, truncating long strings + */ +export function formatDisplayValue(value: any, maxLength = 50): string { + if (typeof value === "string") { + return value.length > maxLength + ? `${value.substring(0, maxLength)}...` + : value; + } + return JSON.stringify(value); +} diff --git a/packages/cli/src/cli/utils/lockfile.ts b/packages/cli/src/cli/utils/lockfile.ts new file mode 100644 index 000000000..1750318b9 --- /dev/null +++ b/packages/cli/src/cli/utils/lockfile.ts @@ -0,0 +1,98 @@ +import fs from "fs"; +import path from "path"; +import Z from "zod"; +import YAML from "yaml"; +import { MD5 } from "object-hash"; +import _ from "lodash"; +import { getConfigRoot } from "./config"; + +export function createLockfileHelper() { + return { + isLockfileExists: () => { + const lockfilePath = _getLockfilePath(); + return fs.existsSync(lockfilePath); + }, + registerSourceData: ( + pathPattern: string, + sourceData: Record, + ) => { + const lockfile = _loadLockfile(); + + const sectionKey = MD5(pathPattern); + const sectionChecksums = _.mapValues(sourceData, (value) => MD5(value)); + + lockfile.checksums[sectionKey] = sectionChecksums; + + _saveLockfile(lockfile); + }, + registerPartialSourceData: ( + pathPattern: string, + partialSourceData: Record, + ) => { + const lockfile = _loadLockfile(); + + const sectionKey = MD5(pathPattern); + const sectionChecksums = _.mapValues(partialSourceData, (value) => + MD5(value), + ); + + lockfile.checksums[sectionKey] = _.merge( + {}, + lockfile.checksums[sectionKey] ?? {}, + sectionChecksums, + ); + + _saveLockfile(lockfile); + }, + extractUpdatedData: ( + pathPattern: string, + sourceData: Record, + ) => { + const lockfile = _loadLockfile(); + + const sectionKey = MD5(pathPattern); + const currentChecksums = _.mapValues(sourceData, (value) => MD5(value)); + + const savedChecksums = lockfile.checksums[sectionKey] || {}; + const updatedData = _.pickBy( + sourceData, + (value, key) => savedChecksums[key] !== currentChecksums[key], + ); + + return updatedData; + }, + }; + + function _loadLockfile() { + const lockfilePath = _getLockfilePath(); + if (!fs.existsSync(lockfilePath)) { + return LockfileSchema.parse({}); + } + const content = fs.readFileSync(lockfilePath, "utf-8"); + const result = LockfileSchema.parse(YAML.parse(content)); + return result; + } + + function _saveLockfile(lockfile: Z.infer) { + const lockfilePath = _getLockfilePath(); + const content = YAML.stringify(lockfile); + fs.writeFileSync(lockfilePath, content); + } + + function _getLockfilePath() { + const configRoot = getConfigRoot() || process.cwd(); + return path.join(configRoot, "i18n.lock"); + } +} + +const LockfileSchema = Z.object({ + version: Z.literal(1).prefault(1), + checksums: Z.record( + Z.string(), // localizable files' keys + Z.record( + // checksums hashmap + Z.string(), // key + Z.string(), // checksum of the key's value in the source locale + ).prefault({}), + ).prefault({}), +}); diff --git a/packages/cli/src/cli/utils/md5.ts b/packages/cli/src/cli/utils/md5.ts new file mode 100644 index 000000000..1cb9da0b1 --- /dev/null +++ b/packages/cli/src/cli/utils/md5.ts @@ -0,0 +1,5 @@ +import { MD5 } from "object-hash"; + +export function md5(input: any) { + return MD5(input); +} diff --git a/packages/cli/src/cli/utils/observability.ts b/packages/cli/src/cli/utils/observability.ts new file mode 100644 index 000000000..c5e3b1449 --- /dev/null +++ b/packages/cli/src/cli/utils/observability.ts @@ -0,0 +1,124 @@ +import pkg from "node-machine-id"; +const { machineIdSync } = pkg; +import https from "https"; +import { getRepositoryId } from "./repository-id"; + +const POSTHOG_API_KEY = "phc_eR0iSoQufBxNY36k0f0T15UvHJdTfHlh8rJcxsfhfXk"; +const POSTHOG_HOST = "eu.i.posthog.com"; +const POSTHOG_PATH = "/i/v0/e/"; +const REQUEST_TIMEOUT_MS = 3000; +const TRACKING_VERSION = "2.0"; + +function determineDistinctId(email: string | null | undefined): { + distinct_id: string; + distinct_id_source: string; + project_id: string | null; +} { + if (email) { + const projectId = getRepositoryId(); + return { + distinct_id: email, + distinct_id_source: "email", + project_id: projectId, + }; + } + + const repoId = getRepositoryId(); + if (repoId) { + return { + distinct_id: repoId, + distinct_id_source: "git_repo", + project_id: repoId, + }; + } + + const deviceId = `device-${machineIdSync()}`; + if (process.env.DEBUG === "true") { + console.warn( + "[Tracking] Using device ID fallback. Consider using git repository for consistent tracking.", + ); + } + return { + distinct_id: deviceId, + distinct_id_source: "device", + project_id: null, + }; +} + +export default function trackEvent( + email: string | null | undefined, + event: string, + properties?: Record, +): void { + if (process.env.DO_NOT_TRACK === "1") { + return; + } + + setImmediate(() => { + try { + const identityInfo = determineDistinctId(email); + + if (process.env.DEBUG === "true") { + console.log( + `[Tracking] Event: ${event}, ID: ${identityInfo.distinct_id}, Source: ${identityInfo.distinct_id_source}`, + ); + } + + const eventData = { + api_key: POSTHOG_API_KEY, + event, + distinct_id: identityInfo.distinct_id, + properties: { + ...properties, + $lib: "lingo.dev-cli", + $lib_version: process.env.npm_package_version || "unknown", + tracking_version: TRACKING_VERSION, + distinct_id_source: identityInfo.distinct_id_source, + project_id: identityInfo.project_id, + node_version: process.version, + is_ci: !!process.env.CI, + debug_enabled: process.env.DEBUG === "true", + }, + timestamp: new Date().toISOString(), + }; + + const payload = JSON.stringify(eventData); + + const options: https.RequestOptions = { + hostname: POSTHOG_HOST, + path: POSTHOG_PATH, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload).toString(), + }, + timeout: REQUEST_TIMEOUT_MS, + }; + + const req = https.request(options); + + req.on("timeout", () => { + req.destroy(); + }); + + req.on("error", (error) => { + if (process.env.DEBUG === "true") { + console.error("[Tracking] Error ignored:", error.message); + } + }); + + req.write(payload); + req.end(); + + setTimeout(() => { + if (!req.destroyed) { + req.destroy(); + } + }, REQUEST_TIMEOUT_MS); + } catch (error) { + if (process.env.DEBUG === "true") { + console.error("[Tracking] Failed to send event:", error); + } + } + }); +} diff --git a/packages/cli/src/cli/utils/plutil-formatter.spec.ts b/packages/cli/src/cli/utils/plutil-formatter.spec.ts new file mode 100644 index 000000000..f79c54944 --- /dev/null +++ b/packages/cli/src/cli/utils/plutil-formatter.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { formatPlutilStyle } from "./plutil-formatter"; + +describe("plutil-formatter", () => { + it("should format JSON matching Xcode style with existing indentation", () => { + const existingJson = `{ + "sourceLanguage" : "en", + "strings" : { + "something" : { + + } + } +}`; + + const input = { + sourceLanguage: "en", + strings: { + complex_format: { + extractionState: "manual", + localizations: { + ar: { + stringUnit: { + state: "translated", + value: "المستخدم %1$@ لديه %2$lld نقطة ورصيد $%3$.2f", + }, + }, + }, + }, + something: {}, + }, + version: "1.0", + }; + + const expected = `{ + "sourceLanguage" : "en", + "strings" : { + "complex_format" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المستخدم %1$@ لديه %2$lld نقطة ورصيد $%3$.2f" + } + } + } + }, + "something" : { + + } + }, + "version" : "1.0" +}`; + const result = formatPlutilStyle(input, existingJson); + + expect(result).toBe(expected); + }); + + it("should detect and use 4-space indentation", () => { + const existingJson = `{ + "foo": { + "bar": { + } + } +}`; + + const input = { + test: { + nested: {}, + }, + }; + + const expected = `{ + "test" : { + "nested" : { + + } + } +}`; + + const result = formatPlutilStyle(input, existingJson); + expect(result).toBe(expected); + }); + + it("should fallback to 2 spaces when no existing JSON provided", () => { + const input = { + foo: { + bar: {}, + }, + }; + + const expected = `{ + "foo" : { + "bar" : { + + } + } +}`; + + const result = formatPlutilStyle(input); + expect(result).toBe(expected); + }); + + it("should order keys correctly", () => { + const input = { + "2x": {}, + "3ABC": {}, + "3x": {}, + "4K": {}, + "7 min": {}, + "25": {}, + "30": {}, + }; + + const expected = `{ + "2x" : { + + }, + "3ABC" : { + + }, + "3x" : { + + }, + "4K" : { + + }, + "7 min" : { + + }, + "25" : { + + }, + "30" : { + + } +}`; + + const result = formatPlutilStyle(input); + expect(result).toBe(expected); + }); +}); diff --git a/packages/cli/src/cli/utils/plutil-formatter.ts b/packages/cli/src/cli/utils/plutil-formatter.ts new file mode 100644 index 000000000..5b9df299e --- /dev/null +++ b/packages/cli/src/cli/utils/plutil-formatter.ts @@ -0,0 +1,59 @@ +export function formatPlutilStyle( + jsonData: any, + existingJson?: string, +): string { + // Detect indentation from existing JSON if provided + const indent = existingJson ? detectIndentation(existingJson) : " "; + + function format(data: any, level = 0): string { + const currentIndent = indent.repeat(level); + const nextIndent = indent.repeat(level + 1); + + if (typeof data !== "object" || data === null) { + return JSON.stringify(data); + } + + if (Array.isArray(data)) { + if (data.length === 0) return "[]"; + const items = data.map( + (item) => `${nextIndent}${format(item, level + 1)}`, + ); + return `[\n${items.join(",\n")}\n${currentIndent}]`; + } + + const keys = Object.keys(data); + if (keys.length === 0) { + return `{\n\n${currentIndent}}`; // Empty object with proper indentation + } + + // Sort keys to ensure whitespace keys come first + const sortedKeys = keys.sort((a, b) => { + // If both keys are whitespace or both are non-whitespace, maintain stable order + const aIsWhitespace = /^\s*$/.test(a); + const bIsWhitespace = /^\s*$/.test(b); + + if (aIsWhitespace && !bIsWhitespace) return -1; + if (!aIsWhitespace && bIsWhitespace) return 1; + return a.localeCompare(b, undefined, { numeric: true }); + }); + + const items = sortedKeys.map((key) => { + const value = data[key]; + return `${nextIndent}${JSON.stringify(key)} : ${format( + value, + level + 1, + )}`; + }); + + return `{\n${items.join(",\n")}\n${currentIndent}}`; + } + + const result = format(jsonData); + return result; +} + +function detectIndentation(jsonStr: string): string { + // Find the first indented line + const match = jsonStr.match(/\n(\s+)/); + return match ? match[1] : " "; // fallback to 4 spaces if no indentation found +} diff --git a/packages/cli/src/cli/utils/repository-id.spec.ts b/packages/cli/src/cli/utils/repository-id.spec.ts new file mode 100644 index 000000000..e4df3e95f --- /dev/null +++ b/packages/cli/src/cli/utils/repository-id.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getRepositoryId, clearRepositoryIdCache } from "./repository-id"; +import { execSync } from "child_process"; + +vi.mock("child_process"); + +describe("getRepositoryId", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + vi.clearAllMocks(); + clearRepositoryIdCache(); // Clear cache between tests + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("CI environment variables", () => { + it("should detect GitHub repository from GITHUB_REPOSITORY", () => { + process.env.GITHUB_REPOSITORY = "owner/repo"; + expect(getRepositoryId()).toBe("github:owner/071ca222"); + }); + + it("should detect GitLab repository from CI_PROJECT_PATH", () => { + process.env.CI_PROJECT_PATH = "namespace/project"; + expect(getRepositoryId()).toBe("gitlab:namespace/244210e4"); + }); + + it("should detect Bitbucket repository from BITBUCKET_REPO_FULL_NAME", () => { + process.env.BITBUCKET_REPO_FULL_NAME = "workspace/repo"; + expect(getRepositoryId()).toBe("bitbucket:workspace/071ca222"); + }); + + it("should prioritize CI environment variables over git remote", () => { + process.env.GITHUB_REPOSITORY = "owner/repo"; + vi.mocked(execSync).mockReturnValue( + "git@gitlab.com:other/project.git" as any, + ); + expect(getRepositoryId()).toBe("github:owner/071ca222"); + }); + }); + + describe("git remote URL parsing", () => { + it("should parse GitHub SSH URL", () => { + vi.mocked(execSync).mockReturnValue("git@github.com:owner/repo.git" as any); + expect(getRepositoryId()).toBe("github:owner/071ca222"); + }); + + it("should parse GitHub HTTPS URL", () => { + vi.mocked(execSync).mockReturnValue( + "https://github.com/owner/repo.git" as any, + ); + expect(getRepositoryId()).toBe("github:owner/071ca222"); + }); + + it("should parse GitLab SSH URL", () => { + vi.mocked(execSync).mockReturnValue( + "git@gitlab.com:namespace/project.git" as any, + ); + expect(getRepositoryId()).toBe("gitlab:namespace/244210e4"); + }); + + it("should parse GitLab HTTPS URL", () => { + vi.mocked(execSync).mockReturnValue( + "https://gitlab.com/namespace/project.git" as any, + ); + expect(getRepositoryId()).toBe("gitlab:namespace/244210e4"); + }); + + it("should parse Bitbucket SSH URL", () => { + vi.mocked(execSync).mockReturnValue( + "git@bitbucket.org:workspace/repo.git" as any, + ); + expect(getRepositoryId()).toBe("bitbucket:workspace/071ca222"); + }); + + it("should parse Bitbucket HTTPS URL", () => { + vi.mocked(execSync).mockReturnValue( + "https://bitbucket.org/workspace/repo.git" as any, + ); + expect(getRepositoryId()).toBe("bitbucket:workspace/071ca222"); + }); + + it("should parse self-hosted git URL with generic prefix", () => { + vi.mocked(execSync).mockReturnValue( + "git@custom-git.company.com:team/project.git" as any, + ); + expect(getRepositoryId()).toBe("git:team/244210e4"); + }); + + it("should handle URLs without .git extension", () => { + vi.mocked(execSync).mockReturnValue("git@github.com:owner/repo" as any); + expect(getRepositoryId()).toBe("github:owner/071ca222"); + }); + + it("should return null when git command fails", () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error("not a git repository"); + }); + expect(getRepositoryId()).toBe(null); + }); + + it("should return null when git remote is empty", () => { + vi.mocked(execSync).mockReturnValue("" as any); + expect(getRepositoryId()).toBe(null); + }); + + it("should return null when git remote URL is invalid", () => { + vi.mocked(execSync).mockReturnValue("invalid-url" as any); + expect(getRepositoryId()).toBe(null); + }); + }); +}); diff --git a/packages/cli/src/cli/utils/repository-id.ts b/packages/cli/src/cli/utils/repository-id.ts new file mode 100644 index 000000000..99d9703c8 --- /dev/null +++ b/packages/cli/src/cli/utils/repository-id.ts @@ -0,0 +1,103 @@ +import { execSync } from "child_process"; +import { createHash } from "crypto"; + +let cachedGitRepoId: string | null | undefined = undefined; + +function hashProjectName(fullPath: string): string { + const parts = fullPath.split("/"); + if (parts.length !== 2) { + return createHash("sha256").update(fullPath).digest("hex").slice(0, 8); + } + + const [org, project] = parts; + const hashedProject = createHash("sha256") + .update(project) + .digest("hex") + .slice(0, 8); + + return `${org}/${hashedProject}`; +} + +export function clearRepositoryIdCache(): void { + cachedGitRepoId = undefined; +} +export function getRepositoryId(): string | null { + const ciRepoId = getCIRepositoryId(); + if (ciRepoId) return ciRepoId; + + const gitRepoId = getGitRepositoryId(); + if (gitRepoId) return gitRepoId; + + return null; +} + +function getCIRepositoryId(): string | null { + if (process.env.GITHUB_REPOSITORY) { + const hashed = hashProjectName(process.env.GITHUB_REPOSITORY); + return `github:${hashed}`; + } + + if (process.env.CI_PROJECT_PATH) { + const hashed = hashProjectName(process.env.CI_PROJECT_PATH); + return `gitlab:${hashed}`; + } + + if (process.env.BITBUCKET_REPO_FULL_NAME) { + const hashed = hashProjectName(process.env.BITBUCKET_REPO_FULL_NAME); + return `bitbucket:${hashed}`; + } + + return null; +} + +function getGitRepositoryId(): string | null { + if (cachedGitRepoId !== undefined) { + return cachedGitRepoId; + } + + try { + const remoteUrl = execSync("git config --get remote.origin.url", { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }).trim(); + + if (!remoteUrl) { + cachedGitRepoId = null; + return null; + } + + cachedGitRepoId = parseGitUrl(remoteUrl); + return cachedGitRepoId; + } catch { + cachedGitRepoId = null; + return null; + } +} + +function parseGitUrl(url: string): string | null { + const cleanUrl = url.replace(/\.git$/, ""); + + let platform: string | null = null; + if (cleanUrl.includes("github.com")) { + platform = "github"; + } else if (cleanUrl.includes("gitlab.com")) { + platform = "gitlab"; + } else if (cleanUrl.includes("bitbucket.org")) { + platform = "bitbucket"; + } + + const sshMatch = cleanUrl.match(/[@:]([^:/@]+\/[^:/@]+)$/); + const httpsMatch = cleanUrl.match(/\/([^/]+\/[^/]+)$/); + + const repoPath = sshMatch?.[1] || httpsMatch?.[1]; + + if (!repoPath) return null; + + const hashedPath = hashProjectName(repoPath); + + if (platform) { + return `${platform}:${hashedPath}`; + } + + return `git:${hashedPath}`; +} diff --git a/packages/cli/src/cli/utils/settings.ts b/packages/cli/src/cli/utils/settings.ts new file mode 100644 index 000000000..bdaf3c79c --- /dev/null +++ b/packages/cli/src/cli/utils/settings.ts @@ -0,0 +1,242 @@ +import os from "os"; +import path from "path"; +import _ from "lodash"; +import Z from "zod"; +import fs from "fs"; +import Ini from "ini"; + +export type CliSettings = Z.infer; + +export function getSettings(explicitApiKey: string | undefined): CliSettings { + const env = _loadEnv(); + const systemFile = _loadSystemFile(); + const defaults = _loadDefaults(); + + _legacyEnvVarWarning(); + + _envVarsInfo(); + + return { + auth: { + apiKey: + explicitApiKey || + env.LINGODOTDEV_API_KEY || + systemFile.auth?.apiKey || + defaults.auth.apiKey, + apiUrl: + env.LINGODOTDEV_API_URL || + systemFile.auth?.apiUrl || + defaults.auth.apiUrl, + webUrl: + env.LINGODOTDEV_WEB_URL || + systemFile.auth?.webUrl || + defaults.auth.webUrl, + vnext: { + apiKey: + env.LINGO_API_KEY || + systemFile.auth?.vnext?.apiKey, + }, + }, + llm: { + openaiApiKey: env.OPENAI_API_KEY || systemFile.llm?.openaiApiKey, + anthropicApiKey: env.ANTHROPIC_API_KEY || systemFile.llm?.anthropicApiKey, + groqApiKey: env.GROQ_API_KEY || systemFile.llm?.groqApiKey, + googleApiKey: env.GOOGLE_API_KEY || systemFile.llm?.googleApiKey, + openrouterApiKey: + env.OPENROUTER_API_KEY || systemFile.llm?.openrouterApiKey, + mistralApiKey: env.MISTRAL_API_KEY || systemFile.llm?.mistralApiKey, + }, + }; +} + +export function saveSettings(settings: CliSettings): void { + _saveSystemFile(settings); +} + +export function loadSystemSettings() { + return _loadSystemFile(); +} + +const flattenZodObject = (schema: Z.ZodObject, prefix = ""): string[] => { + return Object.entries(schema.shape).flatMap(([key, value]) => { + const newPrefix = prefix ? `${prefix}.${key}` : key; + if (value instanceof Z.ZodObject) { + return flattenZodObject(value, newPrefix); + } + return [newPrefix]; + }); +}; + +const SettingsSchema = Z.object({ + auth: Z.object({ + apiKey: Z.string(), + apiUrl: Z.string(), + webUrl: Z.string(), + vnext: Z.object({ + apiKey: Z.string().optional(), + }).optional(), + }), + llm: Z.object({ + openaiApiKey: Z.string().optional(), + anthropicApiKey: Z.string().optional(), + groqApiKey: Z.string().optional(), + googleApiKey: Z.string().optional(), + openrouterApiKey: Z.string().optional(), + mistralApiKey: Z.string().optional(), + }), +}); + +export const SETTINGS_KEYS = flattenZodObject( + SettingsSchema, +) as readonly string[]; + +// Private + +function _loadDefaults(): CliSettings { + return { + auth: { + apiKey: "", + apiUrl: "https://engine.lingo.dev", + webUrl: "https://lingo.dev", + }, + llm: {}, + }; +} + +function _loadEnv() { + return Z.looseObject({ + LINGODOTDEV_API_KEY: Z.string().optional(), + LINGODOTDEV_API_URL: Z.string().optional(), + LINGODOTDEV_WEB_URL: Z.string().optional(), + LINGO_API_KEY: Z.string().optional(), + OPENAI_API_KEY: Z.string().optional(), + ANTHROPIC_API_KEY: Z.string().optional(), + GROQ_API_KEY: Z.string().optional(), + GOOGLE_API_KEY: Z.string().optional(), + OPENROUTER_API_KEY: Z.string().optional(), + MISTRAL_API_KEY: Z.string().optional(), + }).parse(process.env); +} + +function _loadSystemFile() { + const settingsFilePath = _getSettingsFilePath(); + const content = fs.existsSync(settingsFilePath) + ? fs.readFileSync(settingsFilePath, "utf-8") + : ""; + const data = Ini.parse(content); + + return Z.looseObject({ + auth: Z.looseObject({ + apiKey: Z.string().optional(), + apiUrl: Z.string().optional(), + webUrl: Z.string().optional(), + vnext: Z.object({ + apiKey: Z.string().optional(), + }).optional(), + }).optional(), + llm: Z.looseObject({ + openaiApiKey: Z.string().optional(), + anthropicApiKey: Z.string().optional(), + groqApiKey: Z.string().optional(), + googleApiKey: Z.string().optional(), + openrouterApiKey: Z.string().optional(), + mistralApiKey: Z.string().optional(), + }).optional(), + }).parse(data); +} + +function _saveSystemFile(settings: CliSettings) { + const settingsFilePath = _getSettingsFilePath(); + const content = Ini.stringify(settings); + fs.writeFileSync(settingsFilePath, content); +} + +function _getSettingsFilePath(): string { + const settingsFile = ".lingodotdevrc"; + const homedir = os.homedir(); + const settingsFilePath = path.join(homedir, settingsFile); + return settingsFilePath; +} + +function _legacyEnvVarWarning() { + const env = _loadEnv(); + + if (env.REPLEXICA_API_KEY && !env.LINGODOTDEV_API_KEY) { + console.warn( + "\x1b[33m%s\x1b[0m", + ` +⚠️ WARNING: REPLEXICA_API_KEY env var is deprecated ⚠️ +=========================================================== + +Please use LINGODOTDEV_API_KEY instead. +=========================================================== +`, + ); + } +} + +function _envVarsInfo() { + const env = _loadEnv(); + const systemFile = _loadSystemFile(); + + if (env.LINGODOTDEV_API_KEY && systemFile.auth?.apiKey) { + console.info( + "\x1b[36m%s\x1b[0m", + `ℹ️ Using LINGODOTDEV_API_KEY env var instead of credentials from user config`, + ); + } + if (env.OPENAI_API_KEY && systemFile.llm?.openaiApiKey) { + console.info( + "\x1b[36m%s\x1b[0m", + `ℹ️ Using OPENAI_API_KEY env var instead of key from user config.`, + ); + } + if (env.ANTHROPIC_API_KEY && systemFile.llm?.anthropicApiKey) { + console.info( + "\x1b[36m%s\x1b[0m", + `ℹ️ Using ANTHROPIC_API_KEY env var instead of key from user config`, + ); + } + if (env.GROQ_API_KEY && systemFile.llm?.groqApiKey) { + console.info( + "\x1b[36m%s\x1b[0m", + `ℹ️ Using GROQ_API_KEY env var instead of key from user config`, + ); + } + if (env.GOOGLE_API_KEY && systemFile.llm?.googleApiKey) { + console.info( + "\x1b[36m%s\x1b[0m", + `ℹ️ Using GOOGLE_API_KEY env var instead of key from user config`, + ); + } + if (env.OPENROUTER_API_KEY && systemFile.llm?.openrouterApiKey) { + console.info( + "\x1b[36m%s\x1b[0m", + `ℹ️ Using OPENROUTER_API_KEY env var instead of key from user config`, + ); + } + if (env.MISTRAL_API_KEY && systemFile.llm?.mistralApiKey) { + console.info( + "\x1b[36m%s\x1b[0m", + `ℹ️ Using MISTRAL_API_KEY env var instead of key from user config`, + ); + } + if (env.LINGODOTDEV_API_URL) { + console.info( + "\x1b[36m%s\x1b[0m", + `ℹ️ Using LINGODOTDEV_API_URL: ${env.LINGODOTDEV_API_URL}`, + ); + } + if (env.LINGODOTDEV_WEB_URL) { + console.info( + "\x1b[36m%s\x1b[0m", + `ℹ️ Using LINGODOTDEV_WEB_URL: ${env.LINGODOTDEV_WEB_URL}`, + ); + } + if (env.LINGO_API_KEY && systemFile.auth?.vnext?.apiKey) { + console.info( + "\x1b[36m%s\x1b[0m", + `ℹ️ Using LINGO_API_KEY env var instead of key from user config`, + ); + } +} diff --git a/packages/cli/src/cli/utils/ui.ts b/packages/cli/src/cli/utils/ui.ts new file mode 100644 index 000000000..ab41b19b1 --- /dev/null +++ b/packages/cli/src/cli/utils/ui.ts @@ -0,0 +1,151 @@ +import chalk from "chalk"; +import figlet from "figlet"; +import { vice } from "gradient-string"; +import readline from "readline"; +import { colors } from "../constants"; +import fs from "fs"; // <-- ADD THIS IMPORT + +function isCI(): boolean { + return Boolean(process.env.CI) || fs.existsSync("/.dockerenv"); +} + +export async function renderClear() { + console.log("\x1Bc"); +} + +export async function renderSpacer() { + console.log(" "); +} + +export async function renderBanner() { + console.log( + vice( + figlet.textSync("LINGO.DEV", { + font: "ANSI Shadow", + horizontalLayout: "default", + verticalLayout: "default", + }), + ), + ); +} + +export async function renderHero() { + console.log( + `⚡️ ${chalk.hex(colors.green)( + "Lingo.dev", + )} - open-source, AI-powered i18n CLI for web & mobile localization.`, + ); + console.log(""); + + const label1 = "📚 Docs:"; + const label2 = "⭐ Star the repo:"; + const label3 = "🎮 Join Discord:"; + const maxLabelWidth = 17; // Approximate visual width accounting for emoji + + // --- ADD THIS LOGIC --- + const isCIEnv = isCI(); // <-- USE THE LOCAL HELPER FUNCTION + const docsUrl = isCIEnv + ? "https://lingo.dev/ci" + : "https://lingo.dev/cli"; + // ------------------------ + + console.log( + `${chalk.hex(colors.blue)(label1.padEnd(maxLabelWidth + 1))} ${chalk.hex( + colors.blue, + )(docsUrl)}`, + ); // Docs emoji seems narrower + console.log( + `${chalk.hex(colors.blue)(label2.padEnd(maxLabelWidth))} ${chalk.hex( + colors.blue, + )("https://lingo.dev/go/gh")}`, + ); + console.log( + `${chalk.hex(colors.blue)(label3.padEnd(maxLabelWidth + 1))} ${chalk.hex( + colors.blue, + )("https://lingo.dev/go/discord")}`, + ); +} + +export async function waitForUserPrompt(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(chalk.dim(`[${message}]\n`), () => { + rl.close(); + resolve(); + }); + }); +} + +export async function pauseIfDebug(debug: boolean) { + if (debug) { + await waitForUserPrompt("Press Enter to continue..."); + } +} + +export async function renderSummary(results: Map) { + console.log(chalk.hex(colors.green)("[Done]")); + + const skippedResults = Array.from(results.values()).filter( + (r) => r.status === "skipped", + ); + const succeededResults = Array.from(results.values()).filter( + (r) => r.status === "success", + ); + const failedResults = Array.from(results.values()).filter( + (r) => r.status === "error", + ); + + console.log( + `• ${chalk.hex(colors.yellow)(skippedResults.length)} from cache`, + ); + console.log( + `• ${chalk.hex(colors.yellow)(succeededResults.length)} processed`, + ); + console.log(`• ${chalk.hex(colors.yellow)(failedResults.length)} failed`); + + // Show processed files + if (succeededResults.length > 0) { + console.log(chalk.hex(colors.green)("\n[Processed Files]")); + for (const result of succeededResults) { + const displayPath = + result.pathPattern?.replace("[locale]", result.targetLocale) || + "unknown"; + console.log( + ` ✓ ${chalk.dim(displayPath)} ${chalk.hex(colors.yellow)(`(${result.sourceLocale} → ${result.targetLocale})`)}`, + ); + } + } + + // Show cached files + if (skippedResults.length > 0) { + console.log(chalk.hex(colors.blue)("\n[Cached Files]")); + for (const result of skippedResults) { + const displayPath = + result.pathPattern?.replace("[locale]", result.targetLocale) || + "unknown"; + console.log( + ` ⚡ ${chalk.dim(displayPath)} ${chalk.hex(colors.yellow)(`(${result.sourceLocale} → ${result.targetLocale})`)}`, + ); + } + } + + // Show failed files + if (failedResults.length > 0) { + console.log(chalk.hex(colors.orange)("\n[Failed Files]")); + for (const result of failedResults) { + const displayPath = + result.pathPattern?.replace("[locale]", result.targetLocale) || + "unknown"; + console.log( + ` ❌ ${chalk.dim(displayPath)} ${chalk.hex(colors.yellow)(`(${result.sourceLocale} → ${result.targetLocale})`)}`, + ); + console.log( + ` ${chalk.hex(colors.white)(String(result.error?.message || "Unknown error"))}`, + ); + } + } +} diff --git a/packages/cli/src/cli/utils/update-gitignore.ts b/packages/cli/src/cli/utils/update-gitignore.ts new file mode 100644 index 000000000..3d30e4e7e --- /dev/null +++ b/packages/cli/src/cli/utils/update-gitignore.ts @@ -0,0 +1,42 @@ +import fs from "fs"; +import path from "path"; + +export default function updateGitignore() { + const cacheFile = "i18n.cache"; + const projectRoot = findCurrentProjectRoot(); + if (!projectRoot) { + return; + } + const gitignorePath = path.join(projectRoot, ".gitignore"); + if (!fs.existsSync(gitignorePath)) { + return; + } + + const gitignore = fs.readFileSync(gitignorePath, "utf8").split("\n"); + const cacheIsIgnored = gitignore.includes(cacheFile); + + if (!cacheIsIgnored) { + let content = ""; + + // Ensure there's a trailing newline + content = fs.readFileSync(gitignorePath, "utf8"); + if (content !== "" && !content.endsWith("\n")) { + content += "\n"; + } + + content += `${cacheFile}\n`; + fs.writeFileSync(gitignorePath, content); + } +} + +function findCurrentProjectRoot() { + let currentDir = process.cwd(); + while (currentDir !== path.parse(currentDir).root) { + const gitDirPath = path.join(currentDir, ".git"); + if (fs.existsSync(gitDirPath) && fs.lstatSync(gitDirPath).isDirectory()) { + return currentDir; + } + currentDir = path.dirname(currentDir); + } + return null; +} diff --git a/packages/cli/src/compiler/index.ts b/packages/cli/src/compiler/index.ts new file mode 100644 index 000000000..f9297b33c --- /dev/null +++ b/packages/cli/src/compiler/index.ts @@ -0,0 +1,3 @@ +// Re-export everything but with type checking +export type * from "@lingo.dev/_compiler"; +export { default } from "@lingo.dev/_compiler"; diff --git a/packages/cli/src/locale-codes/index.ts b/packages/cli/src/locale-codes/index.ts new file mode 100644 index 000000000..a3c290a65 --- /dev/null +++ b/packages/cli/src/locale-codes/index.ts @@ -0,0 +1,3 @@ +// Re-export everything but with type checking +export type * from "@lingo.dev/_locales"; +export * from "@lingo.dev/_locales"; diff --git a/packages/cli/src/react/client.ts b/packages/cli/src/react/client.ts new file mode 100644 index 000000000..335dd79f4 --- /dev/null +++ b/packages/cli/src/react/client.ts @@ -0,0 +1,3 @@ +// Re-export everything but with type checking +export type * from "@lingo.dev/_react/client"; +export * from "@lingo.dev/_react/client"; diff --git a/packages/cli/src/react/index.ts b/packages/cli/src/react/index.ts new file mode 100644 index 000000000..445f0243d --- /dev/null +++ b/packages/cli/src/react/index.ts @@ -0,0 +1,3 @@ +// Re-export everything but with type checking +export type * from "@lingo.dev/_react"; +export * from "@lingo.dev/_react"; diff --git a/packages/cli/src/react/react-router.ts b/packages/cli/src/react/react-router.ts new file mode 100644 index 000000000..d8a9a7738 --- /dev/null +++ b/packages/cli/src/react/react-router.ts @@ -0,0 +1,3 @@ +// Re-export everything but with type checking +export type * from "@lingo.dev/_react/react-router"; +export * from "@lingo.dev/_react/react-router"; diff --git a/packages/cli/src/react/rsc.ts b/packages/cli/src/react/rsc.ts new file mode 100644 index 000000000..3b3022a8a --- /dev/null +++ b/packages/cli/src/react/rsc.ts @@ -0,0 +1,3 @@ +// Re-export everything but with type checking +export type * from "@lingo.dev/_react/rsc"; +export * from "@lingo.dev/_react/rsc"; diff --git a/packages/cli/src/sdk/index.ts b/packages/cli/src/sdk/index.ts new file mode 100644 index 000000000..4dbe69e7e --- /dev/null +++ b/packages/cli/src/sdk/index.ts @@ -0,0 +1,3 @@ +// Re-export everything but with type checking +export type * from "@lingo.dev/_sdk"; +export * from "@lingo.dev/_sdk"; diff --git a/packages/cli/src/spec/index.ts b/packages/cli/src/spec/index.ts new file mode 100644 index 000000000..a36102715 --- /dev/null +++ b/packages/cli/src/spec/index.ts @@ -0,0 +1,3 @@ +// Re-export everything but with type checking +export type * from "@lingo.dev/_spec"; +export * from "@lingo.dev/_spec"; diff --git a/packages/cli/src/utils/pseudo-localize.spec.ts b/packages/cli/src/utils/pseudo-localize.spec.ts new file mode 100644 index 000000000..cc3ca48a0 --- /dev/null +++ b/packages/cli/src/utils/pseudo-localize.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { pseudoLocalize, pseudoLocalizeObject } from "./pseudo-localize"; + +describe("pseudoLocalize", () => { + it("should replace characters with accented versions", () => { + const result = pseudoLocalize("hello", { addMarker: false }); + expect(result).toBe("ĥèļļø"); + }); + + it("should add marker by default", () => { + const result = pseudoLocalize("hello"); + expect(result).toBe("ĥèļļø⚡"); + }); + + it("should not add marker when disabled", () => { + const result = pseudoLocalize("hello", { addMarker: false }); + expect(result).not.toContain("⚡"); + }); + + it("should handle uppercase letters", () => { + const result = pseudoLocalize("HELLO", { addMarker: false }); + expect(result).toBe("ĤÈĻĻØ"); + }); + + it("should preserve non-alphabetic characters", () => { + const result = pseudoLocalize("Hello123!", { addMarker: false }); + expect(result).toBe("Ĥèļļø123!"); + }); + + it("should handle empty strings", () => { + const result = pseudoLocalize(""); + expect(result).toBe(""); + }); + + it("should handle strings with spaces", () => { + const result = pseudoLocalize("Hello World", { addMarker: false }); + expect(result).toBe("Ĥèļļø Ŵøŕļð"); + }); + + it("should add length expansion when enabled", () => { + const original = "hello"; + const result = pseudoLocalize(original, { + addMarker: false, + addLengthMarker: true, + lengthExpansion: 30, + }); + // 30% expansion of 5 chars = 2 extra chars (rounded up) + expect(result.length).toBeGreaterThan("ĥèļļø".length); + }); + + it("should handle example from feature proposal", () => { + const result = pseudoLocalize("Submit"); + expect(result).toContain("⚡"); + expect(result.startsWith("Š")).toBe(true); + }); + + it("should handle longer text", () => { + const result = pseudoLocalize("Welcome back!"); + expect(result).toBe("Ŵèļçømè ƀãçķ!⚡"); + }); +}); + +describe("pseudoLocalizeObject", () => { + it("should pseudo-localize string values", () => { + const obj = { greeting: "hello" }; + const result = pseudoLocalizeObject(obj, { addMarker: false }); + expect(result.greeting).toBe("ĥèļļø"); + }); + + it("should handle nested objects", () => { + const obj = { + en: { + greeting: "hello", + farewell: "goodbye", + }, + }; + const result = pseudoLocalizeObject(obj, { addMarker: false }); + expect(result.en.greeting).toBe("ĥèļļø"); + expect(result.en.farewell).toContain("ĝ"); + }); + + it("should handle arrays", () => { + const obj = { + messages: ["hello", "world"], + }; + const result = pseudoLocalizeObject(obj, { addMarker: false }); + expect(Array.isArray(result.messages)).toBe(true); + expect(result.messages[0]).toBe("ĥèļļø"); + }); + + it("should preserve non-string values", () => { + const obj = { + greeting: "hello", + count: 42, + active: true, + nothing: null, + }; + const result = pseudoLocalizeObject(obj, { addMarker: false }); + expect(result.greeting).toBe("ĥèļļø"); + expect(result.count).toBe(42); + expect(result.active).toBe(true); + expect(result.nothing).toBe(null); + }); + + it("should handle complex nested structures", () => { + const obj = { + ui: { + buttons: { + submit: "Submit", + cancel: "Cancel", + }, + messages: ["error", "warning"], + }, + }; + const result = pseudoLocalizeObject(obj, { addMarker: false }); + expect(result.ui.buttons.submit).toContain("Š"); + expect(result.ui.messages[0]).toContain("è"); + }); + + it("should handle empty objects", () => { + const result = pseudoLocalizeObject({}, { addMarker: false }); + expect(result).toEqual({}); + }); +}); diff --git a/packages/cli/src/utils/pseudo-localize.ts b/packages/cli/src/utils/pseudo-localize.ts new file mode 100644 index 000000000..77abbc7b7 --- /dev/null +++ b/packages/cli/src/utils/pseudo-localize.ts @@ -0,0 +1,164 @@ +/** + * Pseudo-localization utility for testing UI internationalization readiness + * without waiting for actual translations. + * + * Implements character replacement with accented versions and optional lengthening, + * following standard i18n practices used by Google, Microsoft, and Mozilla. + */ + +/** + * Character mapping for pseudo-localization (en-XA style) + * Each ASCII character is replaced with a visually similar accented version + */ +const PSEUDO_CHAR_MAP: Record = { + a: "ã", + b: "ƀ", + c: "ç", + d: "ð", + e: "è", + f: "ƒ", + g: "ĝ", + h: "ĥ", + i: "í", + j: "ĵ", + k: "ķ", + l: "ļ", + m: "m", + n: "ñ", + o: "ø", + p: "þ", + q: "q", + r: "ŕ", + s: "š", + t: "ţ", + u: "û", + v: "ṽ", + w: "ŵ", + x: "x", + y: "ý", + z: "ž", + + A: "Ã", + B: "Ḃ", + C: "Ĉ", + D: "Ð", + E: "È", + F: "Ḟ", + G: "Ĝ", + H: "Ĥ", + I: "Í", + J: "Ĵ", + K: "Ķ", + L: "Ļ", + M: "M", + N: "Ñ", + O: "Ø", + P: "Þ", + Q: "Q", + R: "Ŕ", + S: "Š", + T: "Ţ", + U: "Û", + V: "Ṽ", + W: "Ŵ", + X: "X", + Y: "Ý", + Z: "Ž", +}; + +/** + * Pseudo-localizes a string by replacing characters with accented versions + * and optionally extending the length to simulate expansion. + * + * @param text - The text to pseudo-localize + * @param options - Configuration options + * @returns The pseudo-localized text + * + * @example + * ```ts + * pseudoLocalize("Submit") // "Šûbmíţ⚡" + * pseudoLocalize("Welcome back!", { addLengthMarker: true }) // "Ŵêļçømèƀäçķ!⚡" + * ``` + */ +export function pseudoLocalize( + text: string, + options: { + /** + * Add a visual marker (⚡) at the end to indicate pseudo-localization + * @default true + */ + addMarker?: boolean; + /** + * Extend text length by adding padding characters to simulate text expansion. + * Useful for testing UI layout with longer translations. + * @default false + */ + addLengthMarker?: boolean; + /** + * The percentage to extend the text (0-100). + * @default 30 + */ + lengthExpansion?: number; + } = {}, +): string { + const { + addMarker = true, + addLengthMarker = false, + lengthExpansion = 30, + } = options; + + if (!text) { + return text; + } + + // Replace characters with accented versions + let result = ""; + for (const char of text) { + result += PSEUDO_CHAR_MAP[char] ?? char; + } + + // Add length expansion if requested + if (addLengthMarker) { + const extraChars = Math.ceil((text.length * lengthExpansion) / 100); + // Add combining diacritical marks to simulate expansion + result += "̌".repeat(extraChars); + } + + // Add visual marker if requested + if (addMarker) { + result += "⚡"; + } + + return result; +} + +/** + * Pseudo-localizes all strings in an object (recursively). + * Handles nested objects and arrays. + * + * @param obj - The object to pseudo-localize + * @param options - Configuration options for pseudoLocalize + * @returns A new object with all string values pseudo-localized + */ +export function pseudoLocalizeObject( + obj: any, + options?: Parameters[1], +): any { + if (typeof obj === "string") { + return pseudoLocalize(obj, options); + } + + if (Array.isArray(obj)) { + return obj.map((item) => pseudoLocalizeObject(item, options)); + } + + if (obj !== null && typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = pseudoLocalizeObject(value, options); + } + return result; + } + + return obj; +} diff --git a/packages/cli/tests/mock-storage.ts b/packages/cli/tests/mock-storage.ts new file mode 100644 index 000000000..2b2b489db --- /dev/null +++ b/packages/cli/tests/mock-storage.ts @@ -0,0 +1,50 @@ +import { vi } from "vitest"; + +// Types +interface MockStorage { + clear(): void; + set(files: Record): void; +} + +// Global storage type +declare global { + var __mockStorage: Record; +} + +// Initialize global storage +globalThis.__mockStorage = {}; + +// Create mock storage singleton +export const mockStorage: MockStorage = { + clear: () => { + globalThis.__mockStorage = {}; + }, + set: (files: Record) => { + mockStorage.clear(); + Object.entries(files).forEach(([path, content]) => { + const fullPath = `${process.cwd()}/${path}`; + globalThis.__mockStorage[fullPath] = content; + }); + }, +}; + +// Setup fs mock +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn(async (path: string) => { + const content = globalThis.__mockStorage[path]; + if (!content) throw new Error(`File not found: ${path}`); + return content; + }), + writeFile: vi.fn((path, content) => { + globalThis.__mockStorage[path] = content; + return Promise.resolve(); + }), + mkdir: vi.fn(), + access: vi.fn((path) => { + return globalThis.__mockStorage[path] + ? Promise.resolve() + : Promise.reject(new Error("ENOENT")); + }), + }, +})); diff --git a/packages/cli/troubleshooting.md b/packages/cli/troubleshooting.md new file mode 100644 index 000000000..f8551bdfd --- /dev/null +++ b/packages/cli/troubleshooting.md @@ -0,0 +1,9 @@ +## Troubleshooting + +### Error: Dynamic require of "module_name" is not supported + +If you encounter this error when running the cli, it typically means that a dependency is trying to dynamically `require()` a Node.js built-in module or another dependency in a way that's incompatible with the ESM bundle format (`.mjs`). + +The solution is to identify the problematic dependency mentioned in the error stack trace and exclude it from the bundle by adding it to the `external` array in `tsup.config.ts`. + +For example, we encountered this issue with the `debug` package (a dependency of `@babel/traverse`) trying to dynamically require `"tty"`. We resolved it by adding `@babel/*` to the `external` array in `tsup.config.ts`. diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 000000000..ac51b5832 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "allowUnreachableCode": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "types"], + "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"] +} diff --git a/packages/cli/tsconfig.test.json b/packages/cli/tsconfig.test.json new file mode 100644 index 000000000..a64e65041 --- /dev/null +++ b/packages/cli/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "src/**/*.tsx"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 000000000..244802fe7 --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + clean: true, + entry: { + cli: "src/cli/index.ts", + sdk: "src/sdk/index.ts", + spec: "src/spec/index.ts", + react: "src/react/index.ts", + "react/client": "src/react/client.ts", + "react/rsc": "src/react/rsc.ts", + "react/react-router": "src/react/react-router.ts", + compiler: "src/compiler/index.ts", + "locale-codes": "src/locale-codes/index.ts", + }, + outDir: "build", + format: ["cjs", "esm"], + dts: true, + cjsInterop: true, + splitting: true, + shims: true, + bundle: true, + sourcemap: true, + external: [ + "readline/promises", + "@babel/traverse", + "node-machine-id", + "@lingo.dev/_compiler", + "@lingo.dev/_sdk", + "@lingo.dev/_spec", + "@lingo.dev/_react", + "@lingo.dev/_locales", + "@lingo.dev/_logging", + ], + outExtension: (ctx) => ({ + js: ctx.format === "cjs" ? ".cjs" : ".mjs", + }), +}); diff --git a/packages/cli/types/vtt.d.ts b/packages/cli/types/vtt.d.ts new file mode 100644 index 000000000..ba3507968 --- /dev/null +++ b/packages/cli/types/vtt.d.ts @@ -0,0 +1,4 @@ +declare module "node-webvtt" { + export function parse(data: string): any; + export function compile(data: any): string; +} diff --git a/packages/cli/types/xliff.d.ts b/packages/cli/types/xliff.d.ts new file mode 100644 index 000000000..6bbc79b3a --- /dev/null +++ b/packages/cli/types/xliff.d.ts @@ -0,0 +1,4 @@ +declare module "xliff" { + export function xliff2js(data: string): any; + export function js2xliff(data: any): string; +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 000000000..22c7eb22d --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + setupFiles: ["./tests/mock-storage.ts"], + }, +}); diff --git a/packages/compiler/CHANGELOG.md b/packages/compiler/CHANGELOG.md new file mode 100644 index 000000000..0c2cf61b5 --- /dev/null +++ b/packages/compiler/CHANGELOG.md @@ -0,0 +1,503 @@ +# @lingo.dev/\_compiler + +## 0.8.12 + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +- Updated dependencies [[`04c3679`](https://github.com/lingodotdev/lingo.dev/commit/04c3679c69231012f167da1640dc17ac57743d6b), [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59)]: + - @lingo.dev/_spec@0.46.0 + - @lingo.dev/_sdk@0.13.7 + +## 0.8.11 + +### Patch Changes + +- Updated dependencies [[`18ef68f`](https://github.com/lingodotdev/lingo.dev/commit/18ef68f8d51f0d3208cfe1f1d2167e2e1580fdcc)]: + - @lingo.dev/_spec@0.45.0 + - @lingo.dev/_sdk@0.13.6 + +## 0.8.10 + +### Patch Changes + +- [#1726](https://github.com/lingodotdev/lingo.dev/pull/1726) [`68b8496`](https://github.com/lingodotdev/lingo.dev/commit/68b849602a88b9f9aa3097f37ce2f0ccf97c1ad5) Thanks [@vrcprl](https://github.com/vrcprl)! - Observability improvement + +## 0.8.9 + +### Patch Changes + +- Updated dependencies []: + - @lingo.dev/_spec@0.44.5 + - @lingo.dev/_sdk@0.13.5 + +## 0.8.8 + +### Patch Changes + +- [`3b24647`](https://github.com/lingodotdev/lingo.dev/commit/3b246473f6f4773f00ea13211bc2be59a98e0b7c) Thanks [@vrcprl](https://github.com/vrcprl)! - Update Next.js to 15.3.8 to address security vulnerability + +## 0.8.7 + +### Patch Changes + +- [#1672](https://github.com/lingodotdev/lingo.dev/pull/1672) [`29949db`](https://github.com/lingodotdev/lingo.dev/commit/29949db24ff9c8938233ebb42e8189690c3c7813) Thanks [@vrcprl](https://github.com/vrcprl)! - Improve observability + +## 0.8.6 + +### Patch Changes + +- [#1667](https://github.com/lingodotdev/lingo.dev/pull/1667) [`1a857bd`](https://github.com/lingodotdev/lingo.dev/commit/1a857bdf76d50afb3024a2437da5fd60e6721bb9) Thanks [@vrcprl](https://github.com/vrcprl)! - Upd NPM workflows + +- Updated dependencies [[`1a857bd`](https://github.com/lingodotdev/lingo.dev/commit/1a857bdf76d50afb3024a2437da5fd60e6721bb9)]: + - @lingo.dev/_spec@0.44.4 + - @lingo.dev/_sdk@0.13.4 + +## 0.8.5 + +### Patch Changes + +- [#1660](https://github.com/lingodotdev/lingo.dev/pull/1660) [`1b2980d`](https://github.com/lingodotdev/lingo.dev/commit/1b2980d9215eca4f2db101af530680d6eb3be8eb) Thanks [@wotschofsky](https://github.com/wotschofsky)! - Upgrade to non-vulnerable Next.js versions (React2Shell) + +## 0.8.4 + +### Patch Changes + +- Updated dependencies [[`738bf08`](https://github.com/lingodotdev/lingo.dev/commit/738bf08edfe226392ec4534e05864101bc66c39c)]: + - @lingo.dev/_spec@0.44.3 + - @lingo.dev/_sdk@0.13.3 + +## 0.8.3 + +### Patch Changes + +- Updated dependencies [[`f6352b6`](https://github.com/lingodotdev/lingo.dev/commit/f6352b6222e425d5d184c1591a90b1d13a7effbc)]: + - @lingo.dev/_spec@0.44.2 + - @lingo.dev/_sdk@0.13.2 + +## 0.8.2 + +### Patch Changes + +- Updated dependencies [[`ad646a4`](https://github.com/lingodotdev/lingo.dev/commit/ad646a4f44dc2f0771eb3aa2783872b4d0e55f57)]: + - @lingo.dev/_spec@0.44.1 + - @lingo.dev/_sdk@0.13.1 + +## 0.8.1 + +### Patch Changes + +- [#1637](https://github.com/lingodotdev/lingo.dev/pull/1637) [`ec2f00a`](https://github.com/lingodotdev/lingo.dev/commit/ec2f00a0a1127ff4c5333ce4c6d8d691f89c4b17) Thanks [@AleksandrSl](https://github.com/AleksandrSl)! - fix babel CJS/ESM + +## 0.8.0 + +### Minor Changes + +- [#1634](https://github.com/lingodotdev/lingo.dev/pull/1634) [`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Pin all dependencies to exact versions to prevent supply chain attacks. Dependencies no longer use caret (^) or tilde (~) ranges, ensuring full control over version updates and requiring explicit review of all dependency changes. + +### Patch Changes + +- Updated dependencies [[`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144)]: + - @lingo.dev/_sdk@0.13.0 + - @lingo.dev/_spec@0.44.0 + +## 0.7.18 + +### Patch Changes + +- Updated dependencies []: + - @lingo.dev/_spec@0.43.1 + - @lingo.dev/_sdk@0.12.9 + +## 0.7.17 + +### Patch Changes + +- Updated dependencies [[`ac38e8e`](https://github.com/lingodotdev/lingo.dev/commit/ac38e8e8dea0d8c4cd3c8b00e6394bfbd8074611)]: + - @lingo.dev/_spec@0.43.0 + - @lingo.dev/_sdk@0.12.8 + +## 0.7.16 + +### Patch Changes + +- Updated dependencies [[`d72c67c`](https://github.com/lingodotdev/lingo.dev/commit/d72c67c78a4d8f01077db8098b5d973ec98a4c1e)]: + - @lingo.dev/_spec@0.42.0 + - @lingo.dev/_sdk@0.12.7 + +## 0.7.15 + +### Patch Changes + +- [#1231](https://github.com/lingodotdev/lingo.dev/pull/1231) [`44a928b`](https://github.com/lingodotdev/lingo.dev/commit/44a928b473802cd07bec64f94a273ee1b845a0d0) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Compiler now throws errors instead of abruptly exiting the process, allowing parent applications to handle errors gracefully + +## 0.7.14 + +### Patch Changes + +- Updated dependencies [[`b45347c`](https://github.com/lingodotdev/lingo.dev/commit/b45347c38572ee371b2bc494261b7e3e90c4aed1)]: + - @lingo.dev/_spec@0.41.1 + - @lingo.dev/_sdk@0.12.6 + +## 0.7.13 + +### Patch Changes + +- [#1222](https://github.com/lingodotdev/lingo.dev/pull/1222) [`38139c8`](https://github.com/lingodotdev/lingo.dev/commit/38139c81a85001739cece60873c0c6ad711327a4) Thanks [@vrcprl](https://github.com/vrcprl)! - fix regex replacement + +## 0.7.12 + +### Patch Changes + +- Updated dependencies [[`82f5e7c`](https://github.com/lingodotdev/lingo.dev/commit/82f5e7cdde9a2a15b4c2a7fcb8c67ed64eab596b), [`e858174`](https://github.com/lingodotdev/lingo.dev/commit/e858174fd5165e0ea3e3f25fa1fc3edb292bc58f)]: + - @lingo.dev/_spec@0.41.0 + - @lingo.dev/_sdk@0.12.5 + +## 0.7.11 + +### Patch Changes + +- Updated dependencies [[`1fa218c`](https://github.com/lingodotdev/lingo.dev/commit/1fa218c13bf90df6d175fb18264f59c1a10b967c)]: + - @lingo.dev/_spec@0.40.4 + - @lingo.dev/_sdk@0.12.4 + +## 0.7.10 + +### Patch Changes + +- Updated dependencies [[`bbc71b9`](https://github.com/lingodotdev/lingo.dev/commit/bbc71b9948ccc289c9669d8b0c276c9596f6a5e7)]: + - @lingo.dev/_spec@0.40.3 + - @lingo.dev/_sdk@0.12.3 + +## 0.7.9 + +### Patch Changes + +- Updated dependencies [[`6579d70`](https://github.com/lingodotdev/lingo.dev/commit/6579d70bc670c2fdc06c09842d931b07e134151c)]: + - @lingo.dev/_spec@0.40.2 + - @lingo.dev/_sdk@0.12.2 + +## 0.7.8 + +### Patch Changes + +- Updated dependencies [[`a35032e`](https://github.com/lingodotdev/lingo.dev/commit/a35032e7e7a188d1f5e774576352068124526e24)]: + - @lingo.dev/_spec@0.40.1 + - @lingo.dev/_sdk@0.12.1 + +## 0.7.7 + +### Patch Changes + +- [#1130](https://github.com/lingodotdev/lingo.dev/pull/1130) [`bc7b08e`](https://github.com/lingodotdev/lingo.dev/commit/bc7b08ef1245d1af0c68813cb18193d4f14bc7e0) Thanks [@mathio](https://github.com/mathio)! - dictionary path calculation + +## 0.7.6 + +### Patch Changes + +- [#1121](https://github.com/lingodotdev/lingo.dev/pull/1121) [`b6071e4`](https://github.com/lingodotdev/lingo.dev/commit/b6071e4f19dd1823f4f2ce54ba5495538a94d4fd) Thanks [@mathio](https://github.com/mathio)! - compiler: prevent duplicate props + +## 0.7.5 + +### Patch Changes + +- [#1118](https://github.com/lingodotdev/lingo.dev/pull/1118) [`410825c`](https://github.com/lingodotdev/lingo.dev/commit/410825c8bf0029d8ee458514d6f203a7397c8f22) Thanks [@mathio](https://github.com/mathio)! - support Turbopack in Next.js v14 by Compiler + +- [#1116](https://github.com/lingodotdev/lingo.dev/pull/1116) [`bc419ae`](https://github.com/lingodotdev/lingo.dev/commit/bc419aeeb4211d80d3c0ddd65deeab62ad68fea8) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - fix: move vitest from dependencies to devDependencies + +## 0.7.4 + +### Patch Changes + +- [#1072](https://github.com/lingodotdev/lingo.dev/pull/1072) [`3cb1ebe`](https://github.com/lingodotdev/lingo.dev/commit/3cb1ebec5441882678ab30a7d1b532bc2fc397b6) Thanks [@The-Best-Codes](https://github.com/The-Best-Codes)! - Fixed compiler handling of namespace imports (import \* as React from "react") and default imports. + +## 0.7.3 + +### Patch Changes + +- Updated dependencies [[`6af91a0`](https://github.com/lingodotdev/lingo.dev/commit/6af91a083d16f85051fb49a4034789abe784017e), [`6af91a0`](https://github.com/lingodotdev/lingo.dev/commit/6af91a083d16f85051fb49a4034789abe784017e)]: + - @lingo.dev/_spec@0.40.0 + - @lingo.dev/_sdk@0.12.0 + +## 0.7.2 + +### Patch Changes + +- Updated dependencies [[`85dfc10`](https://github.com/lingodotdev/lingo.dev/commit/85dfc10961b116e31b2bb478f42013756ca49974)]: + - @lingo.dev/_sdk@0.11.0 + +## 0.7.1 + +### Patch Changes + +- [#1040](https://github.com/lingodotdev/lingo.dev/pull/1040) [`f897a7d`](https://github.com/lingodotdev/lingo.dev/commit/f897a7d0a3f7a236fb64f19bce9a8d00626d09ca) Thanks [@The-Best-Codes](https://github.com/The-Best-Codes)! - Fixed the compiler to handle type-only react imports. + +## 0.7.0 + +### Minor Changes + +- [#997](https://github.com/lingodotdev/lingo.dev/pull/997) [`bd9538a`](https://github.com/lingodotdev/lingo.dev/commit/bd9538ac6eba0ffc91ffc1fef5db6366c13e9e06) Thanks [@VAIBHAVSING](https://github.com/VAIBHAVSING)! - ### Whitespace Normalization Fix + - Improved `normalizeJsxWhitespace` logic to preserve leading spaces inside JSX elements while removing unnecessary formatting whitespace and extra lines. + - Ensured explicit whitespace (e.g., `{" "}`) is handled correctly without introducing double spaces. + - Added targeted tests (`jsx-content-whitespace.spec.ts`) to verify whitespace handling. + - Cleaned up unnecessary debug/test files created during development. + +## 0.6.3 + +### Patch Changes + +- Updated dependencies [[`afbb978`](https://github.com/lingodotdev/lingo.dev/commit/afbb978fec83d574f2c43b7d68457e435fca9b57)]: + - @lingo.dev/_spec@0.39.3 + - @lingo.dev/_sdk@0.10.2 + +## 0.6.2 + +### Patch Changes + +- [#1023](https://github.com/lingodotdev/lingo.dev/pull/1023) [`9266fd0`](https://github.com/lingodotdev/lingo.dev/commit/9266fd0bcddf4b07ca51d2609af92a9473106f9d) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Update Zod dependency to version 3.25.76 + +- Updated dependencies [[`9266fd0`](https://github.com/lingodotdev/lingo.dev/commit/9266fd0bcddf4b07ca51d2609af92a9473106f9d)]: + - @lingo.dev/_spec@0.39.2 + - @lingo.dev/_sdk@0.10.1 + +## 0.6.1 + +### Patch Changes + +- [#1021](https://github.com/lingodotdev/lingo.dev/pull/1021) [`6baa1a7`](https://github.com/lingodotdev/lingo.dev/commit/6baa1a7e88dbfac3783d1d49695595077fd8d209) Thanks [@mathio](https://github.com/mathio)! - add lingo.dev provider details + +## 0.6.0 + +### Minor Changes + +- [#1010](https://github.com/lingodotdev/lingo.dev/pull/1010) [`864c305`](https://github.com/lingodotdev/lingo.dev/commit/864c30586510e6b69739c20fa42efdf45d8881ed) Thanks [@davidturnbull](https://github.com/davidturnbull)! - improve type safety of compiler params + +### Patch Changes + +- Updated dependencies [[`cb2aa0f`](https://github.com/lingodotdev/lingo.dev/commit/cb2aa0f505d6b7dbc435b526e8a6f62265d1f453)]: + - @lingo.dev/_sdk@0.10.0 + +## 0.5.5 + +### Patch Changes + +- [#1011](https://github.com/lingodotdev/lingo.dev/pull/1011) [`bfcb424`](https://github.com/lingodotdev/lingo.dev/commit/bfcb424eb4479d0d3b767e062d30f02c5bcaeb14) Thanks [@mathio](https://github.com/mathio)! - replace elements with dot in name + +## 0.5.4 + +### Patch Changes + +- [#1002](https://github.com/lingodotdev/lingo.dev/pull/1002) [`2b297ba`](https://github.com/lingodotdev/lingo.dev/commit/2b297babe76f9799c5154d9421fecd1ebbe1bb72) Thanks [@mathio](https://github.com/mathio)! - support custom prompts in compiler + +## 0.5.3 + +### Patch Changes + +- Updated dependencies []: + - @lingo.dev/_sdk@0.9.6 + +## 0.5.2 + +### Patch Changes + +- Updated dependencies []: + - @lingo.dev/_sdk@0.9.5 + +## 0.5.1 + +### Patch Changes + +- [#972](https://github.com/lingodotdev/lingo.dev/pull/972) [`b249484`](https://github.com/lingodotdev/lingo.dev/commit/b249484d6f0060e29cd5b50b3d8ce68b857ccad5) Thanks [@mathio](https://github.com/mathio)! - support components with dot in name + +## 0.5.0 + +### Minor Changes + +- [#958](https://github.com/lingodotdev/lingo.dev/pull/958) [`84fd214`](https://github.com/lingodotdev/lingo.dev/commit/84fd214a21766e7683c5d645fcb8c4c0162eb0b6) Thanks [@chrissiwaffler](https://github.com/chrissiwaffler)! - feat: add Mistral AI as a supported LLM provider + - Added Mistral AI provider support across the entire lingo.dev ecosystem + - Users can now use Mistral models for localization by setting MISTRAL_API_KEY + - Supports all Mistral models available through the @ai-sdk/mistral package + - Configuration via environment variable or user-wide config: `npx lingo.dev@latest config set llm.mistralApiKey ` + +### Patch Changes + +- Updated dependencies []: + - @lingo.dev/_sdk@0.9.4 + +## 0.4.1 + +### Patch Changes + +- Updated dependencies []: + - @lingo.dev/_sdk@0.9.3 + +## 0.4.0 + +### Minor Changes + +- [#932](https://github.com/lingodotdev/lingo.dev/pull/932) [`1bba8ee`](https://github.com/lingodotdev/lingo.dev/commit/1bba8eed6272ae166ceb9b92963404bfe90a4aaa) Thanks [@The-Best-Codes](https://github.com/The-Best-Codes)! - Add support for Next.js Turbopack with the Lingo.dev compiler. + +## 0.3.5 + +### Patch Changes + +- [#947](https://github.com/lingodotdev/lingo.dev/pull/947) [`d80285a`](https://github.com/lingodotdev/lingo.dev/commit/d80285a9b12bd85425564cb00e558812fd0aee40) Thanks [@mathio](https://github.com/mathio)! - remove local variable cache + +## 0.3.4 + +### Patch Changes + +- [#937](https://github.com/lingodotdev/lingo.dev/pull/937) [`4e5983d`](https://github.com/lingodotdev/lingo.dev/commit/4e5983d7e59ebf9eb529c4b7c1c87689432ac873) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Update documentation URLs from docs.lingo.dev to lingo.dev/cli and lingo.dev/compiler + +- Updated dependencies [[`4e5983d`](https://github.com/lingodotdev/lingo.dev/commit/4e5983d7e59ebf9eb529c4b7c1c87689432ac873)]: + - @lingo.dev/_sdk@0.9.2 + +## 0.3.3 + +### Patch Changes + +- [`76cbd9b`](https://github.com/lingodotdev/lingo.dev/commit/76cbd9b2f2e1217421ad1f671bed5b3d64b43333) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - dictionary merging + +## 0.3.2 + +### Patch Changes + +- [`01f253d`](https://github.com/lingodotdev/lingo.dev/commit/01f253dd9759b518f400dff03ab51b460b9b8997) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix dictionary merging + +## 0.3.1 + +### Patch Changes + +- [`8e97256`](https://github.com/lingodotdev/lingo.dev/commit/8e97256ca4e78dd09a967539ca9dec359bd558ef) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix dictionary merging + +## 0.3.0 + +### Minor Changes + +- [#913](https://github.com/lingodotdev/lingo.dev/pull/913) [`1b9b113`](https://github.com/lingodotdev/lingo.dev/commit/1b9b11301978e8caa2555832d027ff93216aa6e1) Thanks [@The-Best-Codes](https://github.com/The-Best-Codes)! - Add support for Ollama as a CLI and Compiler provider. + +- [#922](https://github.com/lingodotdev/lingo.dev/pull/922) [`0329a9c`](https://github.com/lingodotdev/lingo.dev/commit/0329a9cdb5e5a63fcecab4efcd7cce22f155a0e9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add openrouter ais support for compiler + +### Patch Changes + +- [#925](https://github.com/lingodotdev/lingo.dev/pull/925) [`215af19`](https://github.com/lingodotdev/lingo.dev/commit/215af1944667cce66e9c5966f4fb627186687b74) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - improved compiler concurrency, caching, added lingo.dev engine to the compiler, and updated demo apps + +- Updated dependencies []: + - @lingo.dev/_sdk@0.9.1 + +## 0.2.4 + +### Patch Changes + +- [#919](https://github.com/lingodotdev/lingo.dev/pull/919) [`3b6574f`](https://github.com/lingodotdev/lingo.dev/commit/3b6574f0499f3f4d3c48f66ba2b828d2c1c0ceb0) Thanks [@mathio](https://github.com/mathio)! - update package import names + +## 0.2.3 + +### Patch Changes + +- [#911](https://github.com/lingodotdev/lingo.dev/pull/911) [`d7e74c6`](https://github.com/lingodotdev/lingo.dev/commit/d7e74c6cc724da8ae759ba8d8fdb1a64867d505c) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix hyphens in locale names + +## 0.2.2 + +### Patch Changes + +- [#905](https://github.com/lingodotdev/lingo.dev/pull/905) [`1a235a1`](https://github.com/lingodotdev/lingo.dev/commit/1a235a17455fb2631f7426283aa8431209999758) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - remove @/ path mapping in compiler + +## 0.2.1 + +### Patch Changes + +- [#900](https://github.com/lingodotdev/lingo.dev/pull/900) [`fead8e0`](https://github.com/lingodotdev/lingo.dev/commit/fead8e08dc2b2869a093cb25a04f6e0aa78cf6b7) Thanks [@mathio](https://github.com/mathio)! - load API key from env var and env files + +## 0.2.0 + +### Minor Changes + +- [#897](https://github.com/lingodotdev/lingo.dev/pull/897) [`a5da697`](https://github.com/lingodotdev/lingo.dev/commit/a5da697f7efd46de31d17b202d06eb5f655ed9b9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for other providers in the compiler and implement Google AI as a provider. + +## 0.1.13 + +### Patch Changes + +- [#890](https://github.com/lingodotdev/lingo.dev/pull/890) [`145fb74`](https://github.com/lingodotdev/lingo.dev/commit/145fb74c09b42c8810f351be5a641b1366881ae1) Thanks [@mathio](https://github.com/mathio)! - do not parse LingoProvider component + +- [#889](https://github.com/lingodotdev/lingo.dev/pull/889) [`0c45acc`](https://github.com/lingodotdev/lingo.dev/commit/0c45accfc45e63f597758c47033bc58d2f6059b5) Thanks [@mathio](https://github.com/mathio)! - update Groq API error handling + +## 0.1.12 + +### Patch Changes + +- [#887](https://github.com/lingodotdev/lingo.dev/pull/887) [`511a2ec`](https://github.com/lingodotdev/lingo.dev/commit/511a2ecd68a9c5e2800035d5c6a6b5b31b2dc80f) Thanks [@mathio](https://github.com/mathio)! - handle when lingo dir is deleted + +## 0.1.11 + +### Patch Changes + +- [#883](https://github.com/lingodotdev/lingo.dev/pull/883) [`7191444`](https://github.com/lingodotdev/lingo.dev/commit/7191444f67864ea5b5a91a9be759b2445bf186d3) Thanks [@mathio](https://github.com/mathio)! - client-side loading state + +## 0.1.10 + +### Patch Changes + +- [#876](https://github.com/lingodotdev/lingo.dev/pull/876) [`152e96a`](https://github.com/lingodotdev/lingo.dev/commit/152e96a46b98dd25d558ff0e7e20b18b954d375a) Thanks [@vrcprl](https://github.com/vrcprl)! - fix for triggering reload on Windows + +## 0.1.9 + +### Patch Changes + +- [#866](https://github.com/lingodotdev/lingo.dev/pull/866) [`77461a7`](https://github.com/lingodotdev/lingo.dev/commit/77461a7872eec3ea188b3ca6c6f7ce1fd13fdfbb) Thanks [@vrcprl](https://github.com/vrcprl)! - normalize paths in dictionaries + +## 0.1.8 + +### Patch Changes + +- [#861](https://github.com/lingodotdev/lingo.dev/pull/861) [`1bccb7e`](https://github.com/lingodotdev/lingo.dev/commit/1bccb7ed51ac1f13ea79e618bbee551d5529efdc) Thanks [@vrcprl](https://github.com/vrcprl)! - support filePath on Windows + +## 0.1.7 + +### Patch Changes + +- [`5b68641`](https://github.com/lingodotdev/lingo.dev/commit/5b686414f363f8ee4b79fd4e804a434db5cfcb36) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - feat: unshift the plugins + +## 0.1.6 + +### Patch Changes + +- [`7a5898b`](https://github.com/lingodotdev/lingo.dev/commit/7a5898b12dcd0015a5e57236bf65172cedb8a6ee) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - merge dictionaries + +## 0.1.5 + +### Patch Changes + +- [`7013b53`](https://github.com/lingodotdev/lingo.dev/commit/7013b5300d6c2c26f39da62b5ad2c7cf11158c74) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - value.trim() issue + +## 0.1.4 + +### Patch Changes + +- [#853](https://github.com/lingodotdev/lingo.dev/pull/853) [`cb7d5e2`](https://github.com/lingodotdev/lingo.dev/commit/cb7d5e213282c00af658159472183a763f84ca3d) Thanks [@vrcprl](https://github.com/vrcprl)! - Fix groq api key retrieval from .env + +## 0.1.3 + +### Patch Changes + +- [`f42cff8`](https://github.com/lingodotdev/lingo.dev/commit/f42cff8355b1ff7bba1445bd04d11ee4672903c2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - flat reexports + +## 0.1.2 + +### Patch Changes + +- [`920e3f5`](https://github.com/lingodotdev/lingo.dev/commit/920e3f5c3ca1fd51b0919db13a4787cfd616de54) Thanks [@mathio](https://github.com/mathio)! - remove cloneDeep for optimization + +## 0.1.1 + +### Patch Changes + +- [`caef325`](https://github.com/lingodotdev/lingo.dev/commit/caef3253bc99fa7bf7a0b40e5604c3590dcb4958) Thanks [@mathio](https://github.com/mathio)! - release fix + +## 0.1.0 + +### Minor Changes + +- [`e980e84`](https://github.com/lingodotdev/lingo.dev/commit/e980e84178439ad70417d38b425acf9148cfc4b6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added the compiler diff --git a/packages/compiler/README.md b/packages/compiler/README.md new file mode 100644 index 000000000..8452bf574 --- /dev/null +++ b/packages/compiler/README.md @@ -0,0 +1,5 @@ +# Lingo.dev Compiler + +**Lingo.dev Compiler** is a free, open-source compiler middleware, that makes React apps multilingual at build time without requiring any changes to the existing React components. + +Documentation: https://lingo.dev/compiler diff --git a/packages/compiler/package.json b/packages/compiler/package.json new file mode 100644 index 000000000..b076e2c2e --- /dev/null +++ b/packages/compiler/package.json @@ -0,0 +1,80 @@ +{ + "name": "@lingo.dev/_compiler", + "version": "0.8.12", + "description": "Lingo.dev Compiler", + "private": false, + "repository": { + "type": "git", + "url": "https://github.com/lingodotdev/lingo.dev.git", + "directory": "packages/compiler" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "sideEffects": false, + "type": "module", + "main": "build/index.cjs", + "types": "build/index.d.ts", + "module": "build/index.mjs", + "exports": { + ".": { + "types": "./build/index.d.ts", + "import": "./build/index.mjs", + "require": "./build/index.cjs" + } + }, + "files": [ + "build" + ], + "scripts": { + "dev": "tsup --watch", + "build": "pnpm typecheck && tsup", + "typecheck": "tsc --noEmit", + "clean": "rm -rf build", + "test": "vitest --run", + "test:watch": "vitest -w" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/babel__generator": "7.27.0", + "@types/babel__traverse": "7.28.0", + "@types/ini": "4.1.1", + "@types/lodash": "4.17.21", + "@types/object-hash": "3.0.6", + "@types/react": "19.2.7", + "next": "15.3.8", + "tsup": "8.5.1", + "typescript": "5.9.3", + "vitest": "4.0.13" + }, + "dependencies": { + "@ai-sdk/anthropic": "3.0.9", + "@ai-sdk/google": "3.0.6", + "@ai-sdk/groq": "3.0.4", + "@ai-sdk/mistral": "3.0.5", + "@ai-sdk/openai": "3.0.7", + "@babel/generator": "7.28.5", + "@babel/parser": "7.28.5", + "@babel/traverse": "7.28.5", + "@babel/types": "7.28.5", + "@lingo.dev/_sdk": "workspace:*", + "@lingo.dev/_spec": "workspace:*", + "@openrouter/ai-sdk-provider": "6.0.0-alpha.1", + "ai": "6.0.25", + "dedent": "1.7.0", + "dotenv": "16.4.5", + "fast-xml-parser": "5.3.2", + "ini": "5.0.0", + "lodash": "4.17.21", + "node-machine-id": "1.1.12", + "object-hash": "3.0.0", + "ollama-ai-provider-v2": "2.0.0", + "posthog-node": "5.14.0", + "unplugin": "2.3.11", + "zod": "4.1.12" + }, + "packageManager": "pnpm@9.12.3" +} \ No newline at end of file diff --git a/packages/compiler/src/_base.ts b/packages/compiler/src/_base.ts new file mode 100644 index 000000000..0371085ef --- /dev/null +++ b/packages/compiler/src/_base.ts @@ -0,0 +1,207 @@ +import { generate, GeneratorResult } from "./babel-interop"; +import * as t from "@babel/types"; +import * as parser from "@babel/parser"; +import { LocaleCode } from "@lingo.dev/_spec"; + +/** + * Options for configuring Lingo.dev Compiler. + */ +export type CompilerParams = { + /** + * The locale to translate from. + * + * This must match one of the following formats: + * + * - [ISO 639-1 language code](https://en.wikipedia.org/wiki/ISO_639-1) (e.g., `"en"`) + * - [IETF BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag) (e.g., `"en-US"`) + * + * @default "en" + */ + sourceLocale: LocaleCode; + /** + * The locale(s) to translate to. + * + * Each locale must match one of the following formats: + * + * - [ISO 639-1 language code](https://en.wikipedia.org/wiki/ISO_639-1) (e.g., `"en"`) + * - [IETF BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag) (e.g., `"en-US"`) + * + * @default ["es"] + */ + targetLocales: LocaleCode[]; + /** + * The name of the directory where translation files will be stored, relative to `sourceRoot`. + * + * @default "lingo" + */ + lingoDir: string; + /** + * The directory of the source code that will be translated, relative to the current working directory. + * + * @default "src" + */ + sourceRoot: string; + /** + * If `true`, the compiler will generate code for React Server Components (RSC). + * + * When using Vite, this value is always `false`. + * + * When using Next.js, this value is always `true`. + * + * @default false + */ + rsc: boolean; + /** + * If `true`, the compiler will only localize files that use the `"use i18n";` directive. + * + * @default false + */ + useDirective: boolean; + /** + * If `true`, the compiler will log additional information to the console. + * + * @default false + */ + debug: boolean; + /** + * The model(s) to use for translation. + * + * If set to `"lingo.dev"`, the compiler will use Lingo.dev Engine. + * + * If set to an object, the compiler will use the model(s) specified in the object: + * + * - The key is a string that represents the source and target locales, separated by a colon (e.g., `"en:es"`). + * - The value is a string that represents the LLM provider and model, separated by a colon (e.g., `"google:gemini-2.0-flash"`). + * + * You can use `*` as a wildcard to match any locale. + * + * If a model is not specified, an error will be thrown. + * + * @default {} + */ + models: "lingo.dev" | ModelMap; + /** + * Custom system prompt for the translation engine. If set, this prompt will override the default system prompt defined in Compiler. + * Only works with custom models, not with Lingo.dev Engine. + * + * Example: "You are a helpful assistant that translates {SOURCE_LOCALE} to {TARGET_LOCALE}." + * + * @default null + */ + prompt?: string | null; +}; + +/** + * A mapping between locale pairings and the model to use to translate that pairing. + */ +export type ModelMap = { + [key in SourceTargetLocale]?: ModelIdentifier; +}; + +/** + * A pairing of a source and target locale. + */ +export type SourceTargetLocale = + | LocalePair + | AnyTargetLocale + | AnySourceLocale + | AnyLocale; + +/** + * A translation from a specific source locale to a specific target locale. + */ +export type LocalePair = `${LocaleCode}:${LocaleCode}`; + +/** + * A translation from a specific source locale to any target locale. + */ +export type AnyTargetLocale = `${LocaleCode}:${LocaleWildcard}`; + +/** + * A translation from any source locale to a specific target locale. + */ +export type AnySourceLocale = `${LocaleWildcard}:${LocaleCode}`; + +/** + * A translation from any source locale to any target locale. + */ +export type AnyLocale = `${LocaleWildcard}:${LocaleWildcard}`; + +/** + * A wildcard symbol that matches any locale. + */ +export type LocaleWildcard = "*"; + +/** + * The colon-separated identifier of a model to use for translation. + */ +export type ModelIdentifier = `${string}:${string}`; + +export type CompilerInput = { + relativeFilePath: string; + code: string; + params: CompilerParams; +}; + +export type CompilerPayload = CompilerInput & { + ast: t.File; +}; +export type CompilerOutput = { + code: string; + map: GeneratorResult["map"]; +}; + +export type CodeMutation = (payload: CompilerPayload) => CompilerPayload | null; +export type CodeMutationDefinition = CodeMutation; +export function createCodeMutation(spec: CodeMutationDefinition): CodeMutation { + return (payload: CompilerPayload) => { + const result = spec(payload); + return result; + }; +} + +export function createPayload(input: CompilerInput): CompilerPayload { + const ast = parser.parse(input.code, { + sourceType: "module", + plugins: ["jsx", "typescript"], + }); + return { + ...input, + ast, + }; +} + +export function createOutput(payload: CompilerPayload): CompilerOutput { + const generationResult = generate(payload.ast, {}, payload.code); + return { + code: generationResult.code, + map: generationResult.map, + }; +} + +export function composeMutations(...mutations: CodeMutation[]) { + return (input: CompilerPayload) => { + let result = input; + for (const mutate of mutations) { + const intermediateResult = mutate(result); + if (!intermediateResult) { + break; + } else { + result = intermediateResult; + } + } + return result; + }; +} + +export const defaultParams: CompilerParams = { + sourceRoot: "src", + lingoDir: "lingo", + sourceLocale: "en", + targetLocales: ["es"], + rsc: false, + useDirective: false, + debug: false, + models: {}, + prompt: null, +}; diff --git a/packages/compiler/src/_const.ts b/packages/compiler/src/_const.ts new file mode 100644 index 000000000..854debd33 --- /dev/null +++ b/packages/compiler/src/_const.ts @@ -0,0 +1,7 @@ +export const ModuleId = { + ReactClient: ["lingo.dev/react/client", "lingo.dev/react-client"], + ReactRSC: ["lingo.dev/react/rsc", "lingo.dev/react-rsc"], + ReactRouter: ["lingo.dev/react/react-router", "lingo.dev/react-router"], +}; + +export const LCP_DICTIONARY_FILE_NAME = "dictionary.js"; diff --git a/packages/compiler/src/_loader-utils.spec.ts b/packages/compiler/src/_loader-utils.spec.ts new file mode 100644 index 000000000..ac551c7c2 --- /dev/null +++ b/packages/compiler/src/_loader-utils.spec.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as path from "path"; + +// ESM mocks for internal modules used by _loader-utils +vi.mock("./utils/module-params", () => { + return { + parseParametrizedModuleId: vi.fn((rawId: string) => { + const url = new URL(rawId, "module://"); + return { + id: url.pathname.replace(/^\//, ""), + params: Object.fromEntries(url.searchParams.entries()), + }; + }), + }; +}); + +vi.mock("./lib/lcp", () => { + return { + LCP: { + ready: vi.fn(async () => undefined), + getInstance: vi.fn(() => ({ data: { version: 0.1 } })), + }, + }; +}); + +vi.mock("./lib/lcp/server", () => { + return { + LCPServer: { + loadDictionaries: vi.fn(async () => ({})), + }, + }; +}); + +// Import under test AFTER mocks +import { loadDictionary, transformComponent } from "./_loader-utils"; +import { defaultParams } from "./_base"; + +describe("loadDictionary", () => { + beforeEach(async () => { + const lcpMod = await import("./lib/lcp"); + (lcpMod.LCP.ready as any).mockClear(); + (lcpMod.LCP.getInstance as any).mockClear(); + const serverMod = await import("./lib/lcp/server"); + (serverMod.LCPServer.loadDictionaries as any).mockClear(); + }); + + it("returns null when path is not a dictionary file", async () => { + const result = await loadDictionary({ + resourcePath: "/project/src/lingo/not-dictionary.tsx", + resourceQuery: "", + params: {}, + sourceRoot: "src", + lingoDir: "lingo", + isDev: false, + }); + expect(result).toBeNull(); + const lcpMod = await import("./lib/lcp"); + expect(lcpMod.LCP.ready).not.toHaveBeenCalled(); + }); + + it("returns null when locale param is missing", async () => { + // Override parser to drop locale + const parseMod = await import("./utils/module-params"); + (parseMod.parseParametrizedModuleId as any).mockImplementation( + (rawId: string) => ({ id: rawId, params: {} }), + ); + + const result = await loadDictionary({ + resourcePath: "/project/src/lingo/dictionary.js", + resourceQuery: "", + params: {}, + sourceRoot: "src", + lingoDir: "lingo", + isDev: false, + }); + expect(result).toBeNull(); + const lcpMod = await import("./lib/lcp"); + expect(lcpMod.LCP.ready).not.toHaveBeenCalled(); + }); + + it("loads dictionary for provided locale and passes params to server", async () => { + // Restore default module param parser + const parseMod = await import("./utils/module-params"); + (parseMod.parseParametrizedModuleId as any).mockImplementation( + (rawId: string) => { + const url = new URL(rawId, "module://"); + return { + id: url.pathname.replace(/^\//, ""), + params: Object.fromEntries(url.searchParams.entries()), + }; + }, + ); + + const DICT = { version: 0.1, locale: "es", files: {} }; + const serverMod = await import("./lib/lcp/server"); + (serverMod.LCPServer.loadDictionaries as any).mockResolvedValueOnce({ + es: DICT, + }); + + const result = await loadDictionary({ + resourcePath: "/project/src/lingo/dictionary.js", + resourceQuery: "?locale=es", + params: { sourceLocale: "en", targetLocales: ["es"], foo: "bar" }, + sourceRoot: "src", + lingoDir: "lingo", + isDev: true, + }); + + expect(result).toEqual(DICT); + const lcpMod = await import("./lib/lcp"); + expect(lcpMod.LCP.ready).toHaveBeenCalledWith({ + sourceRoot: "src", + lingoDir: "lingo", + isDev: true, + }); + expect(lcpMod.LCP.getInstance).toHaveBeenCalledWith({ + sourceRoot: "src", + lingoDir: "lingo", + isDev: true, + }); + expect(serverMod.LCPServer.loadDictionaries).toHaveBeenCalledWith({ + sourceLocale: "en", + targetLocales: ["es"], + foo: "bar", + lcp: { version: 0.1 }, + }); + }); + + it("throws when dictionary for locale is missing", async () => { + const serverMod = await import("./lib/lcp/server"); + (serverMod.LCPServer.loadDictionaries as any).mockResolvedValueOnce({}); + await expect( + loadDictionary({ + resourcePath: "/project/src/lingo/dictionary.js", + resourceQuery: "?locale=fr", + params: { sourceLocale: "en", targetLocales: ["fr"] }, + sourceRoot: "src", + lingoDir: "lingo", + isDev: false, + }), + ).rejects.toThrow('Dictionary for locale "fr" could not be generated.'); + }); +}); + +describe("transformComponent", () => { + it("returns the same code when nothing to transform and normalizes relativeFilePath", () => { + const code = "export const X = 1;"; + const result = transformComponent({ + code, + params: defaultParams, + resourcePath: path.join("/project", "src", "deep", "file.tsx"), + sourceRoot: "src", + }); + expect(result.code).toContain("export const X = 1;"); + // sanity: should return a code+map object + expect(result.map).toBeDefined(); + }); +}); diff --git a/packages/compiler/src/_loader-utils.ts b/packages/compiler/src/_loader-utils.ts new file mode 100644 index 000000000..0cb29548c --- /dev/null +++ b/packages/compiler/src/_loader-utils.ts @@ -0,0 +1,121 @@ +import _ from "lodash"; +import path from "path"; +import { composeMutations, createOutput, createPayload } from "./_base"; +import { LCP_DICTIONARY_FILE_NAME } from "./_const"; +import { clientDictionaryLoaderMutation } from "./client-dictionary-loader"; +import i18nDirectiveMutation from "./i18n-directive"; +import jsxAttributeFlagMutation from "./jsx-attribute-flag"; +import { lingoJsxAttributeScopeInjectMutation } from "./jsx-attribute-scope-inject"; +import { jsxAttributeScopesExportMutation } from "./jsx-attribute-scopes-export"; +import { jsxFragmentMutation } from "./jsx-fragment"; +import { jsxHtmlLangMutation } from "./jsx-html-lang"; +import jsxProviderMutation from "./jsx-provider"; +import { jsxRemoveAttributesMutation } from "./jsx-remove-attributes"; +import jsxRootFlagMutation from "./jsx-root-flag"; +import jsxScopeFlagMutation from "./jsx-scope-flag"; +import { lingoJsxScopeInjectMutation } from "./jsx-scope-inject"; +import { jsxScopesExportMutation } from "./jsx-scopes-export"; +import { LCP } from "./lib/lcp"; +import { LCPServer } from "./lib/lcp/server"; +import { reactRouterDictionaryLoaderMutation } from "./react-router-dictionary-loader"; +import { rscDictionaryLoaderMutation } from "./rsc-dictionary-loader"; +import { parseParametrizedModuleId } from "./utils/module-params"; + +/** + * Loads a dictionary for a specific locale + */ +export async function loadDictionary(options: { + resourcePath: string; + resourceQuery?: string; + params: any; + sourceRoot: string; + lingoDir: string; + isDev: boolean; +}) { + const { + resourcePath, + resourceQuery = "", + params, + sourceRoot, + lingoDir, + isDev, + } = options; + const fullResourcePath = `${resourcePath}${resourceQuery}`; + + if (!resourcePath.match(LCP_DICTIONARY_FILE_NAME)) { + return null; // Not a dictionary file + } + + const moduleInfo = parseParametrizedModuleId(fullResourcePath); + const locale = moduleInfo.params.locale; + + if (!locale) { + return null; // No locale specified + } + + const lcpParams = { + sourceRoot, + lingoDir, + isDev, + }; + + await LCP.ready(lcpParams); + const lcp = LCP.getInstance(lcpParams); + + const dictionaries = await LCPServer.loadDictionaries({ + ...params, + lcp: lcp.data, + }); + + const dictionary = dictionaries[locale]; + if (!dictionary) { + throw new Error( + `Lingo.dev: Dictionary for locale "${locale}" could not be generated.`, + ); + } + + return dictionary; +} + +/** + * Transforms component code + */ +export function transformComponent(options: { + code: string; + params: any; + resourcePath: string; + sourceRoot: string; +}) { + const { code, params, resourcePath, sourceRoot } = options; + + return _.chain({ + code, + params, + relativeFilePath: path + .relative(path.resolve(process.cwd(), sourceRoot), resourcePath) + .split(path.sep) + .join("/"), // Always normalize for consistent dictionaries + }) + .thru(createPayload) + .thru( + composeMutations( + i18nDirectiveMutation, + jsxFragmentMutation, + jsxAttributeFlagMutation, + jsxProviderMutation, + jsxHtmlLangMutation, + jsxRootFlagMutation, + jsxScopeFlagMutation, + jsxAttributeScopesExportMutation, + jsxScopesExportMutation, + lingoJsxAttributeScopeInjectMutation, + lingoJsxScopeInjectMutation, + rscDictionaryLoaderMutation, + reactRouterDictionaryLoaderMutation, + jsxRemoveAttributesMutation, + clientDictionaryLoaderMutation, + ), + ) + .thru(createOutput) + .value(); +} diff --git a/packages/compiler/src/_utils.spec.ts b/packages/compiler/src/_utils.spec.ts new file mode 100644 index 000000000..237c2c147 --- /dev/null +++ b/packages/compiler/src/_utils.spec.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + // Reset module registry and any mocks so each test gets a fresh copy + vi.resetModules(); + vi.clearAllMocks(); + vi.unmock("path"); +}); + +describe("getDictionaryPath", () => { + it.each([ + { + sourceRoot: "src", + lingoDir: "lingo", + relativeFilePath: "./components/Button.tsx", + expected: "./../lingo/dictionary.js", + }, + { + sourceRoot: "src/app/content", + lingoDir: "i18n", + relativeFilePath: "../../components/Button.tsx", + expected: "./../app/content/i18n/dictionary.js", + }, + ])( + "returns correct path for file $relativeFilePath in $sourceRoot", + async ({ sourceRoot, lingoDir, relativeFilePath, expected }) => { + const { getDictionaryPath } = await import("./_utils"); + + const result = getDictionaryPath({ + sourceRoot, + lingoDir, + relativeFilePath, + }); + expect(result).toBe(expected); + }, + ); + + it("returns POSIX-style relative path on POSIX", async () => { + // Import fresh copy with the real Node "path" module (POSIX on *nix, win32 on Windows) + const { getDictionaryPath } = await import("./_utils"); + + const result = getDictionaryPath({ + sourceRoot: "/project/src", + lingoDir: "lingo", + relativeFilePath: "/project/src/components/Button.tsx", + }); + + expect(result).toBe("./../lingo/dictionary.js"); + // Ensure no back-slashes slip through + expect(result).not.toMatch(/\\/); + }); + + it('returns the same POSIX-style path when the Node "path" API uses win32 semantics (mock Windows)', async () => { + // Force every call to "path.*" inside _utils to use the Windows implementation + vi.mock("path", () => { + const nodePath = require("path") as typeof import("path"); + return { ...nodePath.win32, default: nodePath.win32 }; + }); + + const { getDictionaryPath } = await import("./_utils"); + + const result = getDictionaryPath({ + sourceRoot: "C:\\project\\src", + lingoDir: "lingo", + relativeFilePath: "C:\\project\\src\\components\\Button.tsx", + }); + + expect(result).toBe("./../lingo/dictionary.js"); + expect(result).not.toMatch(/\\/); + }); +}); diff --git a/packages/compiler/src/_utils.ts b/packages/compiler/src/_utils.ts new file mode 100644 index 000000000..91c945959 --- /dev/null +++ b/packages/compiler/src/_utils.ts @@ -0,0 +1,22 @@ +import path from "path"; + +import { LCP_DICTIONARY_FILE_NAME } from "./_const"; + +export type GetDictionaryPathParams = { + sourceRoot: string; + lingoDir: string; + relativeFilePath: string; +}; +export const getDictionaryPath = (params: GetDictionaryPathParams) => { + const toFile = path.resolve( + params.sourceRoot, + params.lingoDir, + LCP_DICTIONARY_FILE_NAME, + ); + const fromDir = path.dirname( + path.resolve(params.sourceRoot, params.relativeFilePath), + ); + const relativePath = path.relative(fromDir, toFile); + const normalizedPath = relativePath.split(path.sep).join(path.posix.sep); + return `./${normalizedPath}`; +}; diff --git a/packages/compiler/src/babel-interop.ts b/packages/compiler/src/babel-interop.ts new file mode 100644 index 000000000..4670f5766 --- /dev/null +++ b/packages/compiler/src/babel-interop.ts @@ -0,0 +1,13 @@ +import _traverse, { NodePath } from "@babel/traverse"; +import _generate, { GeneratorResult } from "@babel/generator"; + +// Handle ESM/CJS interop - these packages may export differently +// @ts-expect-error - Handle both default and named exports +const traverse = typeof _traverse == "function" ? _traverse : _traverse.default; +// @ts-expect-error - Handle both default and named exports +const generate = typeof _generate == "function" ? _generate : _generate.default; + +export type { NodePath }; +export type { GeneratorResult }; + +export { traverse, generate }; diff --git a/packages/compiler/src/client-dictionary-loader.ts b/packages/compiler/src/client-dictionary-loader.ts new file mode 100644 index 000000000..c63442c6c --- /dev/null +++ b/packages/compiler/src/client-dictionary-loader.ts @@ -0,0 +1,44 @@ +import { createCodeMutation } from "./_base"; +import { ModuleId } from "./_const"; +import { getOrCreateImport } from "./utils"; +import { findInvokations } from "./utils/invokations"; +import * as t from "@babel/types"; +import { getDictionaryPath } from "./_utils"; +import { createLocaleImportMap } from "./utils/create-locale-import-map"; + +export const clientDictionaryLoaderMutation = createCodeMutation((payload) => { + const invokations = findInvokations(payload.ast, { + moduleName: ModuleId.ReactClient, + functionName: "loadDictionary", + }); + + const allLocales = Array.from( + new Set([payload.params.sourceLocale, ...payload.params.targetLocales]), + ); + + for (const invokation of invokations) { + const internalDictionaryLoader = getOrCreateImport(payload.ast, { + moduleName: ModuleId.ReactClient, + exportedName: "loadDictionary_internal", + }); + + // Replace the function identifier with internal version + if (t.isIdentifier(invokation.callee)) { + invokation.callee.name = internalDictionaryLoader.importedName; + } + + const dictionaryPath = getDictionaryPath({ + sourceRoot: payload.params.sourceRoot, + lingoDir: payload.params.lingoDir, + relativeFilePath: payload.relativeFilePath, + }); + + // Create locale import map object + const localeImportMap = createLocaleImportMap(allLocales, dictionaryPath); + + // Add the locale import map as the second argument + invokation.arguments.push(localeImportMap); + } + + return payload; +}); diff --git a/packages/compiler/src/i18n-directive.spec.ts b/packages/compiler/src/i18n-directive.spec.ts new file mode 100644 index 000000000..41b2c29fe --- /dev/null +++ b/packages/compiler/src/i18n-directive.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import i18nDirectiveMutation from "./i18n-directive"; +import { createPayload, CompilerParams, defaultParams } from "./_base"; + +describe("i18nDirectiveMutation", () => { + it("should return payload when 'use i18n' directive is present", () => { + const input = ` +"use i18n"; +function Component() { + return
    Hello
    ; +} +`.trim(); + + const result = createPayload({ + code: input, + params: defaultParams, + fileKey: "test", + }); + const mutated = i18nDirectiveMutation(result); + + expect(mutated).not.toBeNull(); + expect(mutated).toEqual(result); + }); + + it("should return null when 'use i18n' directive is not present", () => { + const input = ` +function Component() { + return
    Hello
    ; +} +`.trim(); + + const result = createPayload({ + code: input, + params: { ...defaultParams, useDirective: true }, + fileKey: "test", + }); + const mutated = i18nDirectiveMutation(result); + + expect(mutated).toBeNull(); + }); + + it("should handle multiple directives correctly", () => { + const input = ` +"use strict"; +"use i18n"; +function Component() { + return
    Hello
    ; +} +`.trim(); + + const result = createPayload({ + code: input, + params: defaultParams, + fileKey: "test", + }); + const mutated = i18nDirectiveMutation(result); + + expect(mutated).not.toBeNull(); + expect(mutated).toEqual(result); + }); +}); diff --git a/packages/compiler/src/i18n-directive.ts b/packages/compiler/src/i18n-directive.ts new file mode 100644 index 000000000..00c2b6ff0 --- /dev/null +++ b/packages/compiler/src/i18n-directive.ts @@ -0,0 +1,12 @@ +import { createCodeMutation } from "./_base"; +import { hasI18nDirective } from "./utils"; + +const i18nDirectiveMutation = createCodeMutation((payload) => { + if (!payload.params.useDirective || hasI18nDirective(payload.ast)) { + return payload; + } else { + return null; + } +}); + +export default i18nDirectiveMutation; diff --git a/packages/compiler/src/index.spec.ts b/packages/compiler/src/index.spec.ts new file mode 100644 index 000000000..cfdfc6c03 --- /dev/null +++ b/packages/compiler/src/index.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import compiler from "./index"; + +// Silence logs in tests +vi.spyOn(console, "log").mockImplementation(() => undefined as any); +vi.spyOn(console, "warn").mockImplementation(() => undefined as any); + +vi.mock("./utils/env", () => ({ isRunningInCIOrDocker: () => true })); +vi.mock("./lib/lcp/cache", () => ({ + LCPCache: { ensureDictionaryFile: vi.fn() }, +})); +vi.mock("unplugin", () => ({ + createUnplugin: () => ({ + vite: vi.fn(() => ({ name: "test-plugin" })), + webpack: vi.fn(() => ({ name: "test-plugin" })), + }), +})); + +describe("compiler integration", () => { + beforeEach(() => { + (process as any).env = { ...process.env }; + }); + + it("next() returns a function and sets webpack wrapper when turbopack disabled", () => { + const cfg: any = { webpack: (c: any) => c }; + const out = compiler.next({ + sourceRoot: "src", + models: "lingo.dev", + turbopack: { enabled: false }, + })(cfg); + expect(typeof out.webpack).toBe("function"); + }); + + it("vite() pushes plugin to front and detects framework label", () => { + const cfg: any = { plugins: [{ name: "react-router" }] }; + const out = compiler.vite({})(cfg); + expect(out.plugins[0]).toBeDefined(); + }); +}); diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts new file mode 100644 index 000000000..ef758b00e --- /dev/null +++ b/packages/compiler/src/index.ts @@ -0,0 +1,465 @@ +import { createUnplugin } from "unplugin"; +import type { NextConfig } from "next"; +import packageJson from "../package.json"; +import _ from "lodash"; +import dedent from "dedent"; +import { defaultParams } from "./_base"; +import { LCP_DICTIONARY_FILE_NAME } from "./_const"; +import { LCPCache } from "./lib/lcp/cache"; +import { getInvalidLocales } from "./utils/locales"; +import { + getGroqKeyFromEnv, + getGroqKeyFromRc, + getGoogleKeyFromEnv, + getGoogleKeyFromRc, + getMistralKeyFromEnv, + getMistralKeyFromRc, + getLingoDotDevKeyFromEnv, + getLingoDotDevKeyFromRc, +} from "./utils/llm-api-key"; +import { isRunningInCIOrDocker } from "./utils/env"; +import { providerDetails } from "./lib/lcp/api/provider-details"; +import { loadDictionary, transformComponent } from "./_loader-utils"; +import trackEvent from "./utils/observability"; + +const keyCheckers: Record< + string, + { + checkEnv: () => string | undefined; + checkRc: () => string | undefined; + } +> = { + groq: { + checkEnv: getGroqKeyFromEnv, + checkRc: getGroqKeyFromRc, + }, + google: { + checkEnv: getGoogleKeyFromEnv, + checkRc: getGoogleKeyFromRc, + }, + mistral: { + checkEnv: getMistralKeyFromEnv, + checkRc: getMistralKeyFromRc, + }, + "lingo.dev": { + checkEnv: getLingoDotDevKeyFromEnv, + checkRc: getLingoDotDevKeyFromRc, + }, +}; + +const alreadySentBuildEvent = { value: false }; + +function sendBuildEvent(framework: string, config: any, isDev: boolean) { + if (alreadySentBuildEvent.value) return; + alreadySentBuildEvent.value = true; + trackEvent("compiler.build.start", { + framework, + configuration: config, + isDevMode: isDev, + }); +} + +const unplugin = createUnplugin | undefined>( + (_params, _meta) => { + console.log("ℹ️ Starting Lingo.dev compiler..."); + + const params = _.defaults(_params, defaultParams); + + // Validate if not in CI or Docker + if (!isRunningInCIOrDocker()) { + if (params.models === "lingo.dev") { + validateLLMKeyDetails(["lingo.dev"]); + } else { + const configuredProviders = getConfiguredProviders(params.models); + validateLLMKeyDetails(configuredProviders); + + const invalidLocales = getInvalidLocales( + params.models, + params.sourceLocale, + params.targetLocales, + ); + if (invalidLocales.length > 0) { + throw new Error(dedent` + ⚠️ Lingo.dev Localization Compiler requires LLM model setup for the following locales: ${invalidLocales.join( + ", ", + )}. + + ⭐️ Next steps: + 1. Refer to documentation for help: https://lingo.dev/compiler + 2. If you want to use a different LLM, raise an issue in our open-source repo: https://lingo.dev/go/gh + 3. If you have questions, feature requests, or would like to contribute, join our Discord: https://lingo.dev/go/discord + `); + } + } + } + + LCPCache.ensureDictionaryFile({ + sourceRoot: params.sourceRoot, + lingoDir: params.lingoDir, + }); + + const isDev: boolean = + "dev" in _meta ? !!_meta.dev : process.env.NODE_ENV !== "production"; + sendBuildEvent("unplugin", params, isDev); + + return { + name: packageJson.name, + loadInclude: (id) => !!id.match(LCP_DICTIONARY_FILE_NAME), + async load(id) { + const dictionary = await loadDictionary({ + resourcePath: id, + resourceQuery: "", + params: { + ...params, + models: params.models, + sourceLocale: params.sourceLocale, + targetLocales: params.targetLocales, + }, + sourceRoot: params.sourceRoot, + lingoDir: params.lingoDir, + isDev, + }); + + if (!dictionary) { + return null; + } + + return { + code: `export default ${JSON.stringify(dictionary, null, 2)}`, + }; + }, + transformInclude: (id) => id.endsWith(".tsx") || id.endsWith(".jsx"), + enforce: "pre", + transform(code, id) { + try { + const result = transformComponent({ + code, + params, + resourcePath: id, + sourceRoot: params.sourceRoot, + }); + + return result; + } catch (error) { + console.error("⚠️ Lingo.dev compiler failed to localize your app"); + console.error("⚠️ Details:", error); + + return code; + } + }, + }; + }, +); + +export default { + /** + * Initializes Lingo.dev Compiler for Next.js (App Router). + * + * @param compilerParams - The compiler parameters. + * + * @returns The Next.js configuration. + * + * @example Configuration for Next.js's default template + * ```ts + * import lingoCompiler from "lingo.dev/compiler"; + * import type { NextConfig } from "next"; + * + * const nextConfig: NextConfig = { + * /* config options here *\/ + * }; + * + * export default lingoCompiler.next({ + * sourceRoot: "app", + * models: "lingo.dev", + * })(nextConfig); + * ``` + */ + next: + ( + compilerParams?: Partial & { + turbopack?: { + enabled?: boolean | "auto"; + useLegacyTurbo?: boolean; + }; + }, + ) => + (nextConfig: any = {}): NextConfig => { + const mergedParams = _.merge( + {}, + defaultParams, + { + rsc: true, + turbopack: { + enabled: "auto", + useLegacyTurbo: false, + }, + }, + compilerParams, + ); + + const isDev = process.env.NODE_ENV !== "production"; + sendBuildEvent("Next.js", mergedParams, isDev); + + let turbopackEnabled: boolean; + if (mergedParams.turbopack?.enabled === "auto") { + turbopackEnabled = + process.env.TURBOPACK === "1" || process.env.TURBOPACK === "true"; + } else { + turbopackEnabled = mergedParams.turbopack?.enabled === true; + } + + const supportLegacyTurbo: boolean = + mergedParams.turbopack?.useLegacyTurbo === true; + + const hasWebpackConfig = typeof nextConfig.webpack === "function"; + const hasTurbopackConfig = typeof nextConfig.turbopack === "function"; + if (hasWebpackConfig && turbopackEnabled) { + console.warn( + "⚠️ Turbopack is enabled in the Lingo.dev compiler, but you have webpack config. Lingo.dev will still apply turbopack configuration.", + ); + } + if (hasTurbopackConfig && !turbopackEnabled) { + console.warn( + "⚠️ Turbopack is disabled in the Lingo.dev compiler, but you have turbopack config. Lingo.dev will not apply turbopack configuration.", + ); + } + + // Webpack + const originalWebpack = nextConfig.webpack; + nextConfig.webpack = (config: any, options: any) => { + if (!turbopackEnabled) { + console.log("Applying Lingo.dev webpack configuration..."); + config.plugins.unshift(unplugin.webpack(mergedParams)); + } + + if (typeof originalWebpack === "function") { + return originalWebpack(config, options); + } + return config; + }; + + // Turbopack + if (turbopackEnabled) { + console.log("Applying Lingo.dev Turbopack configuration..."); + + // Check if the legacy turbo flag is set + let turbopackConfigPath = (nextConfig.turbopack ??= {}); + if (supportLegacyTurbo) { + turbopackConfigPath = (nextConfig.experimental ??= {}).turbo ??= {}; + } + + turbopackConfigPath.rules ??= {}; + const rules = turbopackConfigPath.rules; + + // Regex for all relevant files for Lingo.dev + const lingoGlob = `**/*.{ts,tsx,js,jsx}`; + + // The .cjs extension is required for Next.js v14 + const lingoLoaderPath = require.resolve("./lingo-turbopack-loader.cjs"); + + rules[lingoGlob] = { + loaders: [ + { + loader: lingoLoaderPath, + options: mergedParams, + }, + ], + }; + } + + return nextConfig; + }, + /** + * Initializes Lingo.dev Compiler for Vite. + * + * @param compilerParams - The compiler parameters. + * + * @returns The Vite configuration. + * + * @example Configuration for Vite's "react-ts" template + * ```ts + * import { defineConfig, type UserConfig } from "vite"; + * import react from "@vitejs/plugin-react"; + * import lingoCompiler from "lingo.dev/compiler"; + * + * // https://vite.dev/config/ + * const viteConfig: UserConfig = { + * plugins: [react()], + * }; + * + * export default defineConfig(() => + * lingoCompiler.vite({ + * models: "lingo.dev", + * })(viteConfig) + * ); + * ``` + * + * @example Configuration for React Router's default template + * ```ts + * import { reactRouter } from "@react-router/dev/vite"; + * import tailwindcss from "@tailwindcss/vite"; + * import lingoCompiler from "lingo.dev/compiler"; + * import { defineConfig, type UserConfig } from "vite"; + * import tsconfigPaths from "vite-tsconfig-paths"; + * + * const viteConfig: UserConfig = { + * plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], + * }; + * + * export default defineConfig(() => + * lingoCompiler.vite({ + * sourceRoot: "app", + * models: "lingo.dev", + * })(viteConfig) + * ); + * ``` + */ + vite: (compilerParams?: Partial) => (config: any) => { + const mergedParams = _.merge( + {}, + defaultParams, + { rsc: false }, + compilerParams, + ); + + const isDev = process.env.NODE_ENV !== "production"; + const isReactRouter = config.plugins + ?.flat() + ?.some((plugin: any) => plugin.name === "react-router"); + const framework = isReactRouter ? "React Router" : "Vite"; + sendBuildEvent(framework, mergedParams, isDev); + config.plugins.unshift(unplugin.vite(mergedParams)); + return config; + }, +}; + +/** + * Extract a list of supported LLM provider IDs from the locale→model mapping. + * @param models Mapping from locale to ":" strings. + */ +function getConfiguredProviders(models: Record): string[] { + return _.chain(Object.values(models)) + .map((modelString) => modelString.split(":")[0]) // Extract provider ID + .filter(Boolean) // Remove empty strings + .uniq() // Get unique providers + .filter( + (providerId) => + providerDetails.hasOwnProperty(providerId) && + keyCheckers.hasOwnProperty(providerId), + ) // Only check for known and implemented providers + .value(); +} + +/** + * Print helpful information about where the LLM API keys for configured providers + * were discovered. The compiler looks for the key first in the environment + * (incl. .env files) and then in the user-wide configuration. Environment always wins. + * @param configuredProviders List of provider IDs detected in the configuration. + */ +function validateLLMKeyDetails(configuredProviders: string[]): void { + if (configuredProviders.length === 0) { + // No LLM providers configured that we can validate keys for. + return; + } + + const keyStatuses: Record< + string, + { + foundInEnv: boolean; + foundInRc: boolean; + details: (typeof providerDetails)[string]; + } + > = {}; + const missingProviders: string[] = []; + const foundProviders: string[] = []; + + for (const providerId of configuredProviders) { + const details = providerDetails[providerId]; + const checkers = keyCheckers[providerId]; + if (!details || !checkers) continue; // Should not happen due to filter above + + const foundInEnv = !!checkers.checkEnv(); + const foundInRc = !!checkers.checkRc(); + + keyStatuses[providerId] = { foundInEnv, foundInRc, details }; + + if (!foundInEnv && !foundInRc) { + missingProviders.push(providerId); + } else { + foundProviders.push(providerId); + } + } + + if (missingProviders.length > 0) { + console.log(dedent` + \n + 💡 Lingo.dev Localization Compiler is configured to use the following LLM provider(s): ${configuredProviders.join( + ", ", + )}. + + The compiler requires API keys for these providers to work, but the following keys are missing: + `); + + for (const providerId of missingProviders) { + const status = keyStatuses[providerId]; + if (!status) continue; + console.log(dedent` + ⚠️ ${status.details.name} API key is missing. Set ${ + status.details.apiKeyEnvVar + } environment variable. + + 👉 You can set the API key in one of the following ways: + 1. User-wide: Run npx lingo.dev@latest config set ${ + status.details.apiKeyConfigKey || "" + } + 2. Project-wide: Add ${ + status.details.apiKeyEnvVar + }= to .env file in every project that uses Lingo.dev Localization Compiler + 3. Session-wide: Run export ${ + status.details.apiKeyEnvVar + }= in your terminal before running the compiler to set the API key for the current session + + ⭐️ If you don't yet have a ${ + status.details.name + } API key, get one for free at ${status.details.getKeyLink} + `); + } + + const errorMessage = dedent` + \n + ⭐️ Also: + 1. If you want to use a different LLM, update your configuration. Refer to documentation for help: https://lingo.dev/compiler + 2. If the model/provider you want to use isn't supported yet, raise an issue in our open-source repo: https://lingo.dev/go/gh + 3. If you have questions, feature requests, or would like to contribute, join our Discord: https://lingo.dev/go/discord + `; + console.log(errorMessage); + throw new Error("Missing required LLM API keys. See details above."); + } else if (foundProviders.length > 0) { + console.log(dedent` + \n + 🔑 LLM API keys detected for configured providers: ${foundProviders.join( + ", ", + )}. + `); + for (const providerId of foundProviders) { + const status = keyStatuses[providerId]; + if (!status) continue; + let sourceMessage = ""; + if (status.foundInEnv && status.foundInRc) { + sourceMessage = `from both environment variables (${status.details.apiKeyEnvVar}) and your user-wide configuration. The key from the environment will be used because it has higher priority.`; + } else if (status.foundInEnv) { + sourceMessage = `from environment variables (${status.details.apiKeyEnvVar}).`; + } else if (status.foundInRc) { + sourceMessage = `from your user-wide configuration${ + status.details.apiKeyConfigKey + ? ` (${status.details.apiKeyConfigKey})` + : "" + }.`; + } + console.log(dedent` + • ${status.details.name} API key loaded ${sourceMessage} + `); + } + console.log("✨"); + } +} diff --git a/packages/compiler/src/jsx-attribute-flag.spec.ts b/packages/compiler/src/jsx-attribute-flag.spec.ts new file mode 100644 index 000000000..6b916827c --- /dev/null +++ b/packages/compiler/src/jsx-attribute-flag.spec.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from "vitest"; +import jsxAttributeFlagMutation from "./jsx-attribute-flag"; +import { createPayload, createOutput, defaultParams } from "./_base"; + +// Helper function to run mutation and get result +function runMutation(code: string) { + const input = createPayload({ code, params: defaultParams, fileKey: "test" }); + const mutated = jsxAttributeFlagMutation(input); + if (!mutated) throw new Error("Mutation returned null"); + return createOutput(mutated).code; +} + +describe("jsxAttributeFlagMutation", () => { + it("should add data-jsx-attribute-scope to elements with title attribute", () => { + const input = ` +function Component() { + return ; +} +`.trim(); + + const expected = ` +function Component() { + return ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should add data-jsx-attribute-scope to elements with aria-label attribute", () => { + const input = ` +function Component() { + return
    + +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    + +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle multiple localizable attributes on the same element", () => { + const input = ` +function Component() { + return
    + +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    + +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle nested elements with localizable attributes", () => { + const input = ` +function Component() { + return
    +
    + Home + Menu +
    + Company logo +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    +
    + Home + Menu +
    + Company logo +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should ignore elements without localizable attributes", () => { + const input = ` +function Component() { + return
    + No attributes to localize + +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    + No attributes to localize + +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should not ignore component elements (uppercase names)", () => { + const input = ` +function Component() { + return
    + + Text +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    + + Text +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); +}); diff --git a/packages/compiler/src/jsx-attribute-flag.ts b/packages/compiler/src/jsx-attribute-flag.ts new file mode 100644 index 000000000..2611629e8 --- /dev/null +++ b/packages/compiler/src/jsx-attribute-flag.ts @@ -0,0 +1,36 @@ +import { createCodeMutation, CompilerPayload } from "./_base"; +import * as t from "@babel/types"; +import { getJsxAttributeScopes } from "./utils/jsx-attribute-scope"; +import { getAstKey } from "./utils/ast-key"; + +/** + * This mutation identifies JSX elements with localizable attributes + * and adds a data-jsx-attributes attribute with an array of the attribute names + */ +const jsxAttributeFlagMutation = createCodeMutation( + (payload: CompilerPayload) => { + const jsxScopes = getJsxAttributeScopes(payload.ast); + + for (const [jsxScope, attributes] of jsxScopes) { + const scopeKey = getAstKey(jsxScope); + jsxScope.node.openingElement.attributes.push( + t.jsxAttribute( + t.jsxIdentifier("data-jsx-attribute-scope"), + t.jsxExpressionContainer( + t.arrayExpression( + attributes.map((attr) => + t.stringLiteral(`${attr}:${scopeKey}-${attr}`), + ), + ), + ), + ), + ); + } + + return { + ...payload, + }; + }, +); + +export default jsxAttributeFlagMutation; diff --git a/packages/compiler/src/jsx-attribute-scope-inject.spec.ts b/packages/compiler/src/jsx-attribute-scope-inject.spec.ts new file mode 100644 index 000000000..d4d021388 --- /dev/null +++ b/packages/compiler/src/jsx-attribute-scope-inject.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; +import { lingoJsxAttributeScopeInjectMutation } from "./jsx-attribute-scope-inject"; +import { createPayload, createOutput, defaultParams } from "./_base"; +import * as parser from "@babel/parser"; +import { generate } from "./babel-interop"; + +// Helper function to run mutation and get result +function runMutation(code: string, rsc = false) { + const params = { ...defaultParams, rsc }; + const input = createPayload({ code, params, relativeFilePath: "test" }); + const mutated = lingoJsxAttributeScopeInjectMutation(input); + if (!mutated) throw new Error("Mutation returned null"); + return createOutput(mutated).code; +} + +// Helper function to normalize code for comparison +function normalizeCode(code: string) { + const ast = parser.parse(code, { + sourceType: "module", + plugins: ["jsx", "typescript"], + }); + return generate(ast).code; +} + +describe("lingoJsxAttributeScopeInjectMutation", () => { + describe("attribute scopes", () => { + it("should handle JSX elements with attribute scopes", () => { + const input = ` + function Component() { + return
    + +
    ; + } + `.trim(); + + const expected = ` + import { LingoAttributeComponent } from "lingo.dev/react/client"; + function Component() { + return
    + Click me +
    ; + } + `.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should handle JSX elements with attribute scopes in server components", () => { + const input = ` + function Component() { + return ; + } + `.trim(); + + const expected = ` + import { LingoAttributeComponent, loadDictionary } from "lingo.dev/react/rsc"; + function Component() { + return
    + loadDictionary(locale)} + >Visit website +
    ; + } + `.trim(); + + const result = runMutation(input, true); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should handle JSX elements with both attribute scopes and JSX children", () => { + const input = ` + function Component() { + return ; + } + `.trim(); + + const expected = ` + import { LingoAttributeComponent } from "lingo.dev/react/client"; + function Component() { + return
    + + Click here to visit + +
    ; + } + `.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should handle JSX elements with attribute scopes and variables", () => { + const input = ` + function Component({ url, accessibilityLabel }) { + return ; + } + `.trim(); + + const expected = ` + import { LingoAttributeComponent } from "lingo.dev/react/client"; + function Component({ url, accessibilityLabel }) { + return
    + + Visit {url} + +
    ; + } + `.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + }); +}); diff --git a/packages/compiler/src/jsx-attribute-scope-inject.ts b/packages/compiler/src/jsx-attribute-scope-inject.ts new file mode 100644 index 000000000..a3664d4c7 --- /dev/null +++ b/packages/compiler/src/jsx-attribute-scope-inject.ts @@ -0,0 +1,100 @@ +import { createCodeMutation } from "./_base"; +import { getModuleExecutionMode, getOrCreateImport } from "./utils"; +import * as t from "@babel/types"; +import _ from "lodash"; +import { ModuleId } from "./_const"; +import { getJsxElementName, getNestedJsxElements } from "./utils/jsx-element"; +import { collectJsxAttributeScopes } from "./utils/jsx-attribute-scope"; +import { setJsxAttributeValue } from "./utils/jsx-attribute"; + +export const lingoJsxAttributeScopeInjectMutation = createCodeMutation( + (payload) => { + const mode = getModuleExecutionMode(payload.ast, payload.params.rsc); + const jsxAttributeScopes = collectJsxAttributeScopes(payload.ast); + + for (const [jsxScope, attributes] of jsxAttributeScopes) { + // Import LingoComponent based on the module execution mode + const packagePath = + mode === "client" ? ModuleId.ReactClient : ModuleId.ReactRSC; + const lingoComponentImport = getOrCreateImport(payload.ast, { + moduleName: packagePath, + exportedName: "LingoAttributeComponent", + }); + + // Get the original JSX element name + const originalJsxElementName = getJsxElementName(jsxScope); + if (!originalJsxElementName) { + continue; + } + + // Replace the name with the lingo component + jsxScope.node.openingElement.name = t.jsxIdentifier( + lingoComponentImport.importedName, + ); + if (jsxScope.node.closingElement) { + jsxScope.node.closingElement.name = t.jsxIdentifier( + lingoComponentImport.importedName, + ); + } + + // Add $attrAs ($as) prop + const as = /^[A-Z]/.test(originalJsxElementName) + ? t.jsxExpressionContainer(t.identifier(originalJsxElementName)) + : t.stringLiteral(originalJsxElementName); + + jsxScope.node.openingElement.attributes.push( + t.jsxAttribute(t.jsxIdentifier("$attrAs"), as), + ); + + // Add $fileKey prop + setJsxAttributeValue(jsxScope, "$fileKey", payload.relativeFilePath); + + // Add $attributes prop + setJsxAttributeValue( + jsxScope, + "$attributes", + t.objectExpression( + attributes.map((attributeDefinition) => { + const [attribute, key = ""] = attributeDefinition.split(":"); + return t.objectProperty( + t.stringLiteral(attribute), + t.stringLiteral(key), + ); + }), + ), + ); + + // // Extract $variables from original JSX scope + // const $variables = getJsxVariables(originalJsxScope); + // if ($variables.properties.length > 0) { + // setJsxAttributeValue(jsxScope, "$variables", $variables); + // } + + // // Extract nested JSX elements + // const $elements = getNestedJsxElements(originalJsxScope); + // if ($elements.elements.length > 0) { + // setJsxAttributeValue(jsxScope, "$elements", $elements); + // } + + if (mode === "server") { + // Add $loadDictionary prop + const loadDictionaryImport = getOrCreateImport(payload.ast, { + exportedName: "loadDictionary", + moduleName: ModuleId.ReactRSC, + }); + setJsxAttributeValue( + jsxScope, + "$loadDictionary", + t.arrowFunctionExpression( + [t.identifier("locale")], + t.callExpression(t.identifier(loadDictionaryImport.importedName), [ + t.identifier("locale"), + ]), + ), + ); + } + } + + return payload; + }, +); diff --git a/packages/compiler/src/jsx-attribute-scopes-export.spec.ts b/packages/compiler/src/jsx-attribute-scopes-export.spec.ts new file mode 100644 index 000000000..8c7846de3 --- /dev/null +++ b/packages/compiler/src/jsx-attribute-scopes-export.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createPayload, createOutput, defaultParams } from "./_base"; +import { jsxAttributeScopesExportMutation } from "./jsx-attribute-scopes-export"; + +vi.mock("./lib/lcp", () => { + const instance = { + resetScope: vi.fn().mockReturnThis(), + setScopeType: vi.fn().mockReturnThis(), + setScopeHash: vi.fn().mockReturnThis(), + setScopeContext: vi.fn().mockReturnThis(), + setScopeSkip: vi.fn().mockReturnThis(), + setScopeOverrides: vi.fn().mockReturnThis(), + setScopeContent: vi.fn().mockReturnThis(), + save: vi.fn(), + }; + const getInstance = vi.fn(() => instance); + return { + LCP: { + getInstance, + }, + __test__: { instance, getInstance }, + }; +}); +describe("jsxAttributeScopesExportMutation", () => { + beforeEach(() => { + // dynamic import avoids ESM mock timing issues + return import("./lib/lcp").then((lcpMod) => { + (lcpMod.LCP.getInstance as any).mockClear(); + }); + }); + + it("collects attribute scopes and saves to LCP", async () => { + const code = ` +export default function X() { + return
    ; +}`.trim(); + const input = createPayload({ + code, + params: defaultParams, + relativeFilePath: "src/App.tsx", + } as any); + const out = jsxAttributeScopesExportMutation(input); + // Not asserting output code as mutation does not change AST; assert side effects + const lcpMod: any = await import("./lib/lcp"); + const inst = lcpMod.__test__.instance; + expect(lcpMod.LCP.getInstance).toHaveBeenCalled(); + expect(inst.setScopeType).toHaveBeenCalledWith( + "src/App.tsx", + "scope-1", + "attribute", + ); + expect(inst.setScopeContent).toHaveBeenCalledWith( + "src/App.tsx", + "scope-1", + "Hello", + ); + expect(inst.save).toHaveBeenCalled(); + }); +}); diff --git a/packages/compiler/src/jsx-attribute-scopes-export.ts b/packages/compiler/src/jsx-attribute-scopes-export.ts new file mode 100644 index 000000000..99105cf12 --- /dev/null +++ b/packages/compiler/src/jsx-attribute-scopes-export.ts @@ -0,0 +1,53 @@ +import { getJsxAttributeValue } from "./utils"; +import { LCP } from "./lib/lcp"; +import { getJsxAttributeValueHash } from "./utils/hash"; +import { collectJsxAttributeScopes } from "./utils/jsx-attribute-scope"; +import { CompilerPayload } from "./_base"; +import _ from "lodash"; + +// Processes only JSX attribute scopes +export function jsxAttributeScopesExportMutation( + payload: CompilerPayload, +): CompilerPayload { + const attributeScopes = collectJsxAttributeScopes(payload.ast); + if (_.isEmpty(attributeScopes)) { + return payload; + } + + const lcp = LCP.getInstance({ + sourceRoot: payload.params.sourceRoot, + lingoDir: payload.params.lingoDir, + }); + + for (const [scope, attributes] of attributeScopes) { + for (const attributeDefinition of attributes) { + const [attribute, scopeKey] = attributeDefinition.split(":"); + + lcp.resetScope(payload.relativeFilePath, scopeKey); + + const attributeValue = getJsxAttributeValue(scope, attribute); + if (!attributeValue) { + continue; + } + + lcp.setScopeType(payload.relativeFilePath, scopeKey, "attribute"); + + const hash = getJsxAttributeValueHash(String(attributeValue)); + lcp.setScopeHash(payload.relativeFilePath, scopeKey, hash); + + lcp.setScopeContext(payload.relativeFilePath, scopeKey, ""); + lcp.setScopeSkip(payload.relativeFilePath, scopeKey, false); + lcp.setScopeOverrides(payload.relativeFilePath, scopeKey, {}); + + lcp.setScopeContent( + payload.relativeFilePath, + scopeKey, + String(attributeValue), + ); + } + } + + lcp.save(); + + return payload; +} diff --git a/packages/compiler/src/jsx-attribute.spec.ts b/packages/compiler/src/jsx-attribute.spec.ts new file mode 100644 index 000000000..1bc56ee0b --- /dev/null +++ b/packages/compiler/src/jsx-attribute.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { jsxAttributeMutation } from "./jsx-attribute"; +import { createPayload, createOutput, defaultParams } from "./_base"; + +function runMutation(code: string) { + const input = createPayload({ code, params: defaultParams, fileKey: "test" }); + const mutated = jsxAttributeMutation(input); + if (!mutated) return code; + return createOutput(mutated).code; +} + +describe("jsxAttributeMutation", () => { + it("should replace html element with localizable attributes with LingoAttributeComponent", () => { + const input = ` +

    + Lorem ipsum dolor sit amet. +

    + `; + const expected = ` +

    + Lorem ipsum + + dolor + + sit amet. +

    + `; + }); +}); diff --git a/packages/compiler/src/jsx-attribute.ts b/packages/compiler/src/jsx-attribute.ts new file mode 100644 index 000000000..a99240aaf --- /dev/null +++ b/packages/compiler/src/jsx-attribute.ts @@ -0,0 +1,5 @@ +import { createCodeMutation } from "./_base"; + +export const jsxAttributeMutation = createCodeMutation((payload) => { + return payload; +}); diff --git a/packages/compiler/src/jsx-fragment.spec.ts b/packages/compiler/src/jsx-fragment.spec.ts new file mode 100644 index 000000000..8215ab56e --- /dev/null +++ b/packages/compiler/src/jsx-fragment.spec.ts @@ -0,0 +1,236 @@ +import { describe, it, expect } from "vitest"; +import { jsxFragmentMutation } from "./jsx-fragment"; +import { createPayload, createOutput, defaultParams } from "./_base"; + +// Helper function to run mutation and get result +function runMutation(code: string) { + const input = createPayload({ code, params: defaultParams, fileKey: "test" }); + const mutated = jsxFragmentMutation(input); + if (!mutated) return code; // Return original code if no changes made + return createOutput(mutated).code; +} + +describe("jsxFragmentMutation", () => { + it("should transform empty fragment shorthand to explicit Fragment", () => { + const input = ` +function Component() { + return <>; +} +`.trim(); + + const expected = ` +import { Fragment } from "react"; +function Component() { + return ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should transform fragment shorthand with content to explicit Fragment", () => { + const input = ` +function Component() { + return <>Hello world; +} +`.trim(); + + const expected = ` +import { Fragment } from "react"; +function Component() { + return Hello world; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should transform nested fragment shorthand", () => { + const input = ` +function Component() { + return <> +
    Outer content
    + <>Inner fragment + ; +} +`.trim(); + + const expected = ` +import { Fragment } from "react"; +function Component() { + return +
    Outer content
    + Inner fragment +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle existing Fragment import", () => { + const input = ` +import { Fragment } from "react"; + +function Component() { + return <>; +} +`.trim(); + + const expected = ` +import { Fragment } from "react"; +function Component() { + return ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle renamed Fragment import", () => { + const input = ` +import { Fragment as ReactFragment } from "react"; + +function Component() { + return <>; +} +`.trim(); + + const expected = ` +import { Fragment as ReactFragment } from "react"; +function Component() { + return ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle type-only React import and add Fragment import", () => { + const input = ` +import type React from "react"; + +function Component() { + return <>; +} +`.trim(); + + const expected = ` +import type React from "react"; +import { Fragment } from "react"; +function Component() { + return ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should return null (no changes) when no fragments found", () => { + const input = ` +function Component() { + return
    No fragments here
    ; +} +`.trim(); + + const result = runMutation(input); + expect(result).toBe(input); + }); + + it("should handle default import React and use React.Fragment", () => { + const input = ` +import React from "react"; + +function Component() { + return <>; +} +`.trim(); + + const expected = ` +import React from "react"; +function Component() { + return ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle import * as React and use React.Fragment", () => { + const input = ` +import * as React from "react"; + +function Component() { + return <>; +} +`.trim(); + + const expected = ` +import * as React from "react"; +function Component() { + return ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle mixed default and named imports", () => { + const input = ` +import React, { useState } from "react"; + +function Component() { + return <>; +} +`.trim(); + + const expected = ` +import React, { useState } from "react"; +function Component() { + return ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle separate namespace and named imports", () => { + const input = ` +import * as React from "react"; +import { Fragment } from "react"; + +function Component() { + return <>; +} +`.trim(); + + const expected = ` +import * as React from "react"; +import { Fragment } from "react"; +function Component() { + return ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle import * as SomeOtherName from react", () => { + const input = ` +import * as SomeOtherName from "react"; + +function Component() { + return <>; +} +`.trim(); + + const expected = ` +import * as SomeOtherName from "react"; +function Component() { + return ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); +}); diff --git a/packages/compiler/src/jsx-fragment.ts b/packages/compiler/src/jsx-fragment.ts new file mode 100644 index 000000000..b841ae920 --- /dev/null +++ b/packages/compiler/src/jsx-fragment.ts @@ -0,0 +1,105 @@ +import { createCodeMutation } from "./_base"; +import { traverse, NodePath } from "./babel-interop"; +import * as t from "@babel/types"; +import { getOrCreateImport } from "./utils"; +import { CompilerPayload } from "./_base"; + +export function jsxFragmentMutation( + payload: CompilerPayload, +): CompilerPayload | null { + const { ast } = payload; + + let foundFragments = false; + + let fragmentImportName: string | null = null; + + traverse(ast, { + ImportDeclaration(path: NodePath) { + if (path.node.source.value !== "react") return; + + for (const specifier of path.node.specifiers) { + if ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported) && + specifier.imported.name === "Fragment" + ) { + fragmentImportName = specifier.local.name; + path.stop(); + } + } + }, + }); + + traverse(ast, { + JSXFragment(path: NodePath) { + foundFragments = true; + + if (!fragmentImportName) { + const result = getOrCreateImport(ast, { + exportedName: "Fragment", + moduleName: ["react"], + }); + fragmentImportName = result.importedName; + } + + const fragmentElement = t.jsxElement( + t.jsxOpeningElement(t.jsxIdentifier(fragmentImportName), [], false), + t.jsxClosingElement(t.jsxIdentifier(fragmentImportName)), + path.node.children, + false, + ); + + path.replaceWith(fragmentElement); + }, + }); + + return payload; +} + +export function transformFragmentShorthand(ast: t.Node): boolean { + let transformed = false; + + let fragmentImportName: string | null = null; + + traverse(ast, { + ImportDeclaration(path: NodePath) { + if (path.node.source.value !== "react") return; + + for (const specifier of path.node.specifiers) { + if ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported) && + specifier.imported.name === "Fragment" + ) { + fragmentImportName = specifier.local.name; + path.stop(); + } + } + }, + }); + + traverse(ast, { + JSXFragment(path: NodePath) { + transformed = true; + + if (!fragmentImportName) { + const result = getOrCreateImport(ast, { + exportedName: "Fragment", + moduleName: ["react"], + }); + fragmentImportName = result.importedName; + } + + const fragmentElement = t.jsxElement( + t.jsxOpeningElement(t.jsxIdentifier(fragmentImportName), [], false), + t.jsxClosingElement(t.jsxIdentifier(fragmentImportName)), + path.node.children, + false, + ); + + path.replaceWith(fragmentElement); + }, + }); + + return transformed; +} diff --git a/packages/compiler/src/jsx-html-lang.spec.ts b/packages/compiler/src/jsx-html-lang.spec.ts new file mode 100644 index 000000000..cef9ef939 --- /dev/null +++ b/packages/compiler/src/jsx-html-lang.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { createPayload, createOutput, defaultParams } from "./_base"; +import { jsxHtmlLangMutation } from "./jsx-html-lang"; + +function run(code: string, rsc = true) { + const input = createPayload({ + code, + params: { ...defaultParams, rsc }, + relativeFilePath: "app/layout.tsx", + } as any); + const mutated = jsxHtmlLangMutation(input); + return createOutput(mutated!).code.trim(); +} + +describe("jsxHtmlLangMutation", () => { + it("replaces html tag with framework component in server mode", () => { + const input = ` +export default function Root() { + return Hi +}`.trim(); + const out = run(input, true); + expect(out).toMatch(/LingoHtmlComponent/); + }); + + it("replaces html tag with framework component in client mode", () => { + const input = ` +"use client"; +export default function Root() { + return Hi +}`.trim(); + const out = run(input, false); + expect(out).toMatch(/LingoHtmlComponent/); + }); +}); diff --git a/packages/compiler/src/jsx-html-lang.ts b/packages/compiler/src/jsx-html-lang.ts new file mode 100644 index 000000000..1cb259803 --- /dev/null +++ b/packages/compiler/src/jsx-html-lang.ts @@ -0,0 +1,35 @@ +import { traverse, NodePath } from "./babel-interop"; +import * as t from "@babel/types"; +import { createCodeMutation } from "./_base"; +import { getJsxElementName } from "./utils/jsx-element"; +import { getModuleExecutionMode, getOrCreateImport } from "./utils"; +import { ModuleId } from "./_const"; + +export const jsxHtmlLangMutation = createCodeMutation((payload) => { + traverse(payload.ast, { + JSXElement: (path: NodePath) => { + if (getJsxElementName(path)?.toLowerCase() === "html") { + const mode = getModuleExecutionMode(payload.ast, payload.params.rsc); + const packagePath = + mode === "client" ? ModuleId.ReactClient : ModuleId.ReactRSC; + const lingoHtmlComponentImport = getOrCreateImport(payload.ast, { + moduleName: packagePath, + exportedName: "LingoHtmlComponent", + }); + + path.node.openingElement.name = t.jsxIdentifier( + lingoHtmlComponentImport.importedName, + ); + if (path.node.closingElement) { + path.node.closingElement.name = t.jsxIdentifier( + lingoHtmlComponentImport.importedName, + ); + } + + path.skip(); + } + }, + }); + + return payload; +}); diff --git a/packages/compiler/src/jsx-provider.spec.ts b/packages/compiler/src/jsx-provider.spec.ts new file mode 100644 index 000000000..9464d37a0 --- /dev/null +++ b/packages/compiler/src/jsx-provider.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { createPayload, createOutput, defaultParams } from "./_base"; +import jsxProviderMutation from "./jsx-provider"; + +function run(code: string, rsc = true) { + const input = createPayload({ + code, + params: { ...defaultParams, rsc }, + relativeFilePath: "app/layout.tsx", + } as any); + const mutated = jsxProviderMutation(input); + return createOutput(mutated!).code.trim(); +} + +describe("jsxProviderMutation", () => { + it("wraps with LingoProvider in server mode", () => { + const input = ` +export default function Root() { + return Hi +}`.trim(); + const out = run(input, true); + expect(out).toContain("LingoProvider"); + expect(out).toContain("loadDictionary"); + }); + + it("does not modify in client mode", () => { + const input = ` +export default function Root() { + return Hi +}`.trim(); + const out = run(input, false); + expect(out).toContain(""); + expect(out).not.toContain("LingoProvider"); + }); +}); diff --git a/packages/compiler/src/jsx-provider.ts b/packages/compiler/src/jsx-provider.ts new file mode 100644 index 000000000..e7a6f5826 --- /dev/null +++ b/packages/compiler/src/jsx-provider.ts @@ -0,0 +1,110 @@ +import { NodePath, traverse } from "./babel-interop"; +import * as t from "@babel/types"; +import { CompilerPayload, createCodeMutation } from "./_base"; +import { getJsxElementName } from "./utils/jsx-element"; +import { getModuleExecutionMode, getOrCreateImport } from "./utils"; +import { ModuleId } from "./_const"; + +/** + * This mutation is used to wrap the html component with the LingoProvider component. + * It only works with server components. + */ +const jsxProviderMutation = createCodeMutation((payload) => { + traverse(payload.ast, { + JSXElement: (path: NodePath) => { + if (getJsxElementName(path)?.toLowerCase() === "html") { + const mode = getModuleExecutionMode(payload.ast, payload.params.rsc); + if (mode === "client") { + return; + } + + // TODO: later + // replaceHtmlComponent(payload, path); + + const lingoProviderImport = getOrCreateImport(payload.ast, { + moduleName: ModuleId.ReactRSC, + exportedName: "LingoProvider", + }); + const loadDictionaryImport = getOrCreateImport(payload.ast, { + moduleName: ModuleId.ReactRSC, + exportedName: "loadDictionary", + }); + + const loadDictionaryArrow = t.arrowFunctionExpression( + [t.identifier("locale")], + t.callExpression(t.identifier(loadDictionaryImport.importedName), [ + t.identifier("locale"), + ]), + ); + + const providerProps = [ + t.jsxAttribute( + t.jsxIdentifier("loadDictionary"), + t.jsxExpressionContainer(loadDictionaryArrow), + ), + ]; + + const provider = t.jsxElement( + t.jsxOpeningElement( + t.jsxIdentifier(lingoProviderImport.importedName), + providerProps, + false, + ), + t.jsxClosingElement( + t.jsxIdentifier(lingoProviderImport.importedName), + ), + [path.node], + false, + ); + + path.replaceWith(provider); + path.skip(); + } + }, + }); + + return payload; +}); + +export default jsxProviderMutation; + +function replaceHtmlComponent( + payload: CompilerPayload, + path: NodePath, +) { + // Find the parent function and make it async since locale is retrieved from cookies asynchronously + const parentFunction = path.findParent( + (p): p is NodePath => + t.isFunctionDeclaration(p.node) || t.isArrowFunctionExpression(p.node), + ); + if ( + parentFunction?.node.type === "FunctionDeclaration" || + parentFunction?.node.type === "ArrowFunctionExpression" + ) { + parentFunction.node.async = true; + } + + // html lang attribute + const loadLocaleFromCookiesImport = getOrCreateImport(payload.ast, { + moduleName: ModuleId.ReactRSC, + exportedName: "loadLocaleFromCookies", + }); + let langAttribute = path.node.openingElement.attributes.find( + (attr) => attr.type === "JSXAttribute" && attr.name.name === "lang", + ); + if (!t.isJSXAttribute(langAttribute)) { + ((langAttribute = t.jsxAttribute( + t.jsxIdentifier("lang"), + t.stringLiteral(""), + )), + path.node.openingElement.attributes.push(langAttribute)); + } + langAttribute.value = t.jsxExpressionContainer( + t.awaitExpression( + t.callExpression( + t.identifier(loadLocaleFromCookiesImport.importedName), + [], + ), + ), + ); +} diff --git a/packages/compiler/src/jsx-remove-attributes.spec.ts b/packages/compiler/src/jsx-remove-attributes.spec.ts new file mode 100644 index 000000000..279a3714d --- /dev/null +++ b/packages/compiler/src/jsx-remove-attributes.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { jsxRemoveAttributesMutation } from "./jsx-remove-attributes"; +import { createPayload, createOutput, defaultParams } from "./_base"; + +// Helper function to run mutation and get result +function runMutation(code: string) { + const input = createPayload({ code, params: defaultParams, fileKey: "test" }); + const mutated = jsxRemoveAttributesMutation(input); + if (!mutated) return code; // Return original code if no changes made + return createOutput(mutated).code; +} + +describe("jsxRemoveAttributesMutation", () => { + it("should remove only attributes added by compiler", () => { + const input = ` +function Component() { + return
    +

    Hello world

    +

    Good night moon

    +

    Good morning sun

    +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    +

    Hello world

    +

    Good night moon

    +

    Good morning sun

    +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); +}); diff --git a/packages/compiler/src/jsx-remove-attributes.ts b/packages/compiler/src/jsx-remove-attributes.ts new file mode 100644 index 000000000..9716d057a --- /dev/null +++ b/packages/compiler/src/jsx-remove-attributes.ts @@ -0,0 +1,33 @@ +import { createCodeMutation, CompilerPayload } from "./_base"; +import * as t from "@babel/types"; +import { traverse, NodePath } from "./babel-interop"; + +/** + * This mutation identifies JSX elements with data-jsx-* attributes and removes them + */ +export const jsxRemoveAttributesMutation = createCodeMutation( + (payload: CompilerPayload) => { + const ATTRIBUTES_TO_REMOVE = [ + "data-jsx-root", + "data-jsx-scope", + "data-jsx-attribute-scope", + ]; + + traverse(payload.ast, { + JSXElement(path: NodePath) { + const openingElement = path.node.openingElement; + openingElement.attributes = openingElement.attributes.filter((attr) => { + const removeAttr = + t.isJSXAttribute(attr) && + t.isJSXIdentifier(attr.name) && + ATTRIBUTES_TO_REMOVE.includes(attr.name.name as string); + return !removeAttr; + }); + }, + }); + + return { + ...payload, + }; + }, +); diff --git a/packages/compiler/src/jsx-root-flag.spec.ts b/packages/compiler/src/jsx-root-flag.spec.ts new file mode 100644 index 000000000..f61895049 --- /dev/null +++ b/packages/compiler/src/jsx-root-flag.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import jsxRootFlagMutation from "./jsx-root-flag"; +import { createPayload, createOutput, defaultParams } from "./_base"; + +// Helper function to run mutation and get result +function runMutation(code: string) { + const input = createPayload({ code, params: defaultParams, fileKey: "test" }); + const mutated = jsxRootFlagMutation(input); + if (!mutated) throw new Error("Mutation returned null"); + return createOutput(mutated).code; +} + +describe("jsxRootFlagMutation", () => { + it("should add data-jsx-root flag to a single root JSX element", () => { + const input = ` +function Component() { + return
    Hello
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    Hello
    ; +} +`.trim(); + const result = runMutation(input); + + expect(result).toBe(expected.trim()); + }); + + it("should add data-jsx-root flag to multiple root JSX elements", () => { + const input = ` +function Component() { + if (condition) { + return
    True
    ; + } + return False; +} +`.trim(); + + const expected = ` +function Component() { + if (condition) { + return
    True
    ; + } + return False; +} +`.trim(); + const result = runMutation(input); + + expect(result).toBe(expected.trim()); + }); + + it("should not add data-jsx-root flag to nested JSX elements", () => { + const input = ` +function Component() { + return
    + Nested +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    + Nested +
    ; +} +`.trim(); + + const result = runMutation(input); + + expect(result).toBe(expected.trim()); + }); +}); diff --git a/packages/compiler/src/jsx-root-flag.ts b/packages/compiler/src/jsx-root-flag.ts new file mode 100644 index 000000000..d464cb3aa --- /dev/null +++ b/packages/compiler/src/jsx-root-flag.ts @@ -0,0 +1,20 @@ +import { traverse } from "./babel-interop"; +import { createCodeMutation } from "./_base"; +import { getJsxRoots } from "./utils"; +import * as t from "@babel/types"; + +const jsxRootFlagMutation = createCodeMutation((payload) => { + const jsxRoots = getJsxRoots(payload.ast); + + for (const jsxElementPath of jsxRoots) { + jsxElementPath.node.openingElement.attributes.push( + t.jsxAttribute(t.jsxIdentifier("data-jsx-root"), null), + ); + } + + return { + ...payload, + }; +}); + +export default jsxRootFlagMutation; diff --git a/packages/compiler/src/jsx-scope-flag.spec.ts b/packages/compiler/src/jsx-scope-flag.spec.ts new file mode 100644 index 000000000..89323da9c --- /dev/null +++ b/packages/compiler/src/jsx-scope-flag.spec.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from "vitest"; +import jsxScopeFlagMutation from "./jsx-scope-flag"; +import { createPayload, createOutput, defaultParams } from "./_base"; + +// Helper function to run mutation and get result +function runMutation(code: string) { + const input = createPayload({ code, params: defaultParams, fileKey: "test" }); + const mutated = jsxScopeFlagMutation(input); + if (!mutated) throw new Error("Mutation returned null"); + return createOutput(mutated).code; +} + +describe("jsxScopeFlagMutation", () => { + it("should add data-jsx-scope flag to element containing text without text siblings", () => { + const input = ` +function Component() { + return
    + Hello World +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    + Hello World +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should not add flag when element has text siblings", () => { + const input = ` +function Component() { + return
    + Some text + Hello World + More text +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    + Some text + Hello World + More text +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle multiple nested scopes correctly", () => { + const input = ` +function Component() { + return
    +
    +

    First text

    +
    +
    + Text here +
    More text
    +
    +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    +
    +

    First text

    +
    +
    + Text here +
    More text
    +
    +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should not add flag to elements without text content", () => { + const input = ` +function Component() { + return
    + +

    {variable}

    +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    + +

    {variable}

    +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle whitespace-only text nodes correctly", () => { + const input = ` +function Component() { + return
    + + + Hello + + +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return
    + + + Hello + + +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); + + it("should handle JSX in props", () => { + const input = ` +function Component() { + return Hello}> +

    Foobar

    +
    ; +} +`.trim(); + + const expected = ` +function Component() { + return Hello}> +

    Foobar

    +
    ; +} +`.trim(); + const result = runMutation(input); + expect(result).toBe(expected); + }); +}); diff --git a/packages/compiler/src/jsx-scope-flag.ts b/packages/compiler/src/jsx-scope-flag.ts new file mode 100644 index 000000000..3a5bd7c43 --- /dev/null +++ b/packages/compiler/src/jsx-scope-flag.ts @@ -0,0 +1,23 @@ +import { createCodeMutation } from "./_base"; +import * as t from "@babel/types"; +import { getAstKey } from "./utils/ast-key"; +import { getJsxScopes } from "./utils/jsx-scope"; + +const jsxScopeFlagMutation = createCodeMutation((payload) => { + const jsxScopes = getJsxScopes(payload.ast); + + for (const jsxScope of jsxScopes) { + jsxScope.node.openingElement.attributes.push( + t.jsxAttribute( + t.jsxIdentifier("data-jsx-scope"), + t.stringLiteral(getAstKey(jsxScope)), + ), + ); + } + + return { + ...payload, + }; +}); + +export default jsxScopeFlagMutation; diff --git a/packages/compiler/src/jsx-scope-inject.spec.ts b/packages/compiler/src/jsx-scope-inject.spec.ts new file mode 100644 index 000000000..a314b0308 --- /dev/null +++ b/packages/compiler/src/jsx-scope-inject.spec.ts @@ -0,0 +1,584 @@ +import { describe, it, expect } from "vitest"; +import { lingoJsxScopeInjectMutation } from "./jsx-scope-inject"; +import { createPayload, createOutput, defaultParams } from "./_base"; +import * as parser from "@babel/parser"; +import { generate } from "./babel-interop"; + +// Helper function to run mutation and get result +function runMutation(code: string, rsc = false) { + const params = { ...defaultParams, rsc }; + const input = createPayload({ code, params, relativeFilePath: "test" }); + const mutated = lingoJsxScopeInjectMutation(input); + if (!mutated) throw new Error("Mutation returned null"); + return createOutput(mutated).code; +} + +// Helper function to normalize code for comparison +function normalizeCode(code: string) { + const ast = parser.parse(code, { + sourceType: "module", + plugins: ["jsx", "typescript"], + }); + return generate(ast).code; +} + +describe("lingoJsxScopeInjectMutation", () => { + describe("skip", () => { + it("should skip if data-lingo-skip is truthy", () => { + const input = ` +function Component() { + return
    +

    Hello world!

    +
    ; +} + `; + + const expected = ` +function Component() { + return
    +

    Hello world!

    +
    ; +} + `; + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + }); + + describe("transform", () => { + it("should transform elements with data-jsx-scope into LingoComponent", () => { + const input = ` +function Component() { + return
    +

    Hello world!

    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component() { + return
    + +
    ; +} +`.trim(); + + const result = runMutation(input); + + // We normalize both the expected and result to handle formatting differences + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should transform JSX elements differently for server components", () => { + const input = ` +function Component() { + return
    +

    Hello world!

    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent, loadDictionary } from "lingo.dev/react/rsc"; +function Component() { + return
    + loadDictionary(locale)} /> +
    ; +} +`.trim(); + + const result = runMutation(input, true); + + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should skip transformation if no JSX scopes are present", () => { + const input = ` +function Component() { + return
    +

    Hello world!

    +
    ; +} +`.trim(); + + // Input should match output exactly + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(input)); + }); + + it("should preserve JSX expression attributes", () => { + const input = ` +function Component({ dynamicClass }) { + return
    +

    Hello world!

    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component({ + dynamicClass +}) { + return
    + +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should handle boolean attributes correctly", () => { + const input = ` +function Component() { + return
    + +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component() { + return
    + +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + }); + + describe("variables", () => { + it("should handle JSX variables in elements with data-jsx-scope", () => { + const input = ` +function Component({ count, category }) { + return
    +

    You have {count} items in {category}.

    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component({ count, category }) { + return
    + +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should handle JSX variables for server components", () => { + const input = ` +function Component({ count, category }) { + return
    +

    You have {count} items in {category}.

    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent, loadDictionary } from "lingo.dev/react/rsc"; +function Component({ count, category }) { + return
    + loadDictionary(locale)} + /> +
    ; +} +`.trim(); + + const result = runMutation(input, true); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should handle nested JSX elements with variables", () => { + const input = ` +function Component({ count, user }) { + return
    +
    + Welcome {user.name}, you have {count} notifications. +
    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component({ count, user }) { + return
    + +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + }); + + describe("elements", () => { + it("should handle nested JSX elements", () => { + const input = ` +function Component() { + return
    +
    +

    Hello

    + World +
    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component() { + return
    +

    {children}

    , + ({ + children +}) => {children} + ]} + /> +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should handle deeply nested JSX elements", () => { + const input = ` +function Component() { + return
    +
    +

    + + Deeply + + nested +

    +
    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component() { + return
    +

    {children}

    , + ({ + children +}) => {children}, + ({ + children +}) => {children} + ]} + /> +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should handle nested elements with variables", () => { + const input = ` +function Component({ name }) { + return
    +
    +

    Hello {name}

    + Welcome back! +
    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component({ name }) { + return
    +

    {children}

    , + ({ + children +}) => {children} + ]} + /> +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + }); + + describe("functions", () => { + it("should handle simple function calls", () => { + const input = ` +function Component() { + return
    +

    Hello {getName(user)}, you have {getCount()} items

    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component() { + return
    + +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should handle function calls with variables and nested elements", () => { + const input = ` +function Component({ user }) { + return
    +
    +

    {formatName(getName(user))}

    has {count} + Last seen: {formatDate(user.lastSeen)} +
    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component({ user }) { + return
    +

    {children}

    , + ({ children }) => {children}, + ({ children }) => {children} + ]} + $functions={{ + "formatName": [formatName(getName(user))], + "formatDate": [formatDate(user.lastSeen)] + }} + /> +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + }); + + describe("expressions", () => { + it("should extract simple expressions", () => { + const input = ` +function Component() { + return
    +

    Result: {count + 1}

    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component() { + return
    + +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should extract multiple expressions", () => { + const input = ` +function Component() { + return
    +

    First: {count * 2}, Second: {value > 0}

    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component() { + return
    + 0 + ]} + /> +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should handle mixed variables, functions and expressions", () => { + const input = ` +function Component() { + return
    +

    + {count + 1} items by {user.name}, processed by {getName()}} +

    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component() { + return
    + +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should handle expressions in nested elements", () => { + const input = ` +function Component() { + return
    +
    +

    Count: {items.length + offset}

    + Active: {items.filter(i => i.active).length > 0} +
    +
    ; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component() { + return
    +

    {children}

    , + ({ children }) => {children} + ]} + $expressions={[ + items.length + offset, + items.filter(i => i.active).length > 0 + ]} + /> +
    ; +} +`.trim(); + + const result = runMutation(input); + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + }); +}); diff --git a/packages/compiler/src/jsx-scope-inject.ts b/packages/compiler/src/jsx-scope-inject.ts new file mode 100644 index 000000000..d1003a68a --- /dev/null +++ b/packages/compiler/src/jsx-scope-inject.ts @@ -0,0 +1,119 @@ +import { createCodeMutation } from "./_base"; +import { + getJsxAttributeValue, + getModuleExecutionMode, + getOrCreateImport, +} from "./utils"; +import * as t from "@babel/types"; +import _ from "lodash"; +import { ModuleId } from "./_const"; +import { getJsxElementName, getNestedJsxElements } from "./utils/jsx-element"; +import { getJsxVariables } from "./utils/jsx-variables"; +import { getJsxFunctions } from "./utils/jsx-functions"; +import { getJsxExpressions } from "./utils/jsx-expressions"; +import { collectJsxScopes, getJsxScopeAttribute } from "./utils/jsx-scope"; +import { setJsxAttributeValue } from "./utils/jsx-attribute"; + +export const lingoJsxScopeInjectMutation = createCodeMutation((payload) => { + const mode = getModuleExecutionMode(payload.ast, payload.params.rsc); + const jsxScopes = collectJsxScopes(payload.ast); + + for (const jsxScope of jsxScopes) { + const skip = getJsxAttributeValue(jsxScope, "data-lingo-skip"); + if (skip) { + continue; + } + // Import LingoComponent based on the module execution mode + const packagePath = + mode === "client" ? ModuleId.ReactClient : ModuleId.ReactRSC; + const lingoComponentImport = getOrCreateImport(payload.ast, { + moduleName: packagePath, + exportedName: "LingoComponent", + }); + + // Get the original JSX element name + const originalJsxElementName = getJsxElementName(jsxScope); + if (!originalJsxElementName) { + continue; + } + + // Create new JSXElement with original attributes + const newNode = t.jsxElement( + t.jsxOpeningElement( + t.jsxIdentifier(lingoComponentImport.importedName), + jsxScope.node.openingElement.attributes.slice(), // original attributes + true, // selfClosing + ), + null, // no closing element + [], // no children + true, // selfClosing + ); + + // Create a NodePath wrapper for the new node to use setJsxAttributeValue + const newNodePath = { + node: newNode, + } as any; + + // Add $as prop + const as = /^[A-Z]/.test(originalJsxElementName) + ? t.identifier(originalJsxElementName) + : originalJsxElementName; + setJsxAttributeValue(newNodePath, "$as", as); + + // Add $fileKey prop + setJsxAttributeValue(newNodePath, "$fileKey", payload.relativeFilePath); + + // Add $entryKey prop + setJsxAttributeValue( + newNodePath, + "$entryKey", + getJsxScopeAttribute(jsxScope)!, + ); + + // Extract $variables from original JSX scope before lingo component was inserted + const $variables = getJsxVariables(jsxScope); + if ($variables.properties.length > 0) { + setJsxAttributeValue(newNodePath, "$variables", $variables); + } + + // Extract nested JSX elements + const $elements = getNestedJsxElements(jsxScope); + if ($elements.elements.length > 0) { + setJsxAttributeValue(newNodePath, "$elements", $elements); + } + + // Extract nested functions + const $functions = getJsxFunctions(jsxScope); + if ($functions.properties.length > 0) { + setJsxAttributeValue(newNodePath, "$functions", $functions); + } + + // Extract expressions + const $expressions = getJsxExpressions(jsxScope); + if ($expressions.elements.length > 0) { + setJsxAttributeValue(newNodePath, "$expressions", $expressions); + } + + if (mode === "server") { + // Add $loadDictionary prop + const loadDictionaryImport = getOrCreateImport(payload.ast, { + exportedName: "loadDictionary", + moduleName: ModuleId.ReactRSC, + }); + setJsxAttributeValue( + newNodePath, + "$loadDictionary", + t.arrowFunctionExpression( + [t.identifier("locale")], + t.callExpression(t.identifier(loadDictionaryImport.importedName), [ + t.identifier("locale"), + ]), + ), + ); + } + + jsxScope.replaceWith(newNode); + } + + return payload; +}); diff --git a/packages/compiler/src/jsx-scopes-export.spec.ts b/packages/compiler/src/jsx-scopes-export.spec.ts new file mode 100644 index 000000000..de03a1436 --- /dev/null +++ b/packages/compiler/src/jsx-scopes-export.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createPayload, defaultParams } from "./_base"; +import { jsxScopesExportMutation } from "./jsx-scopes-export"; + +vi.mock("./lib/lcp", () => { + const instance = { + resetScope: vi.fn().mockReturnThis(), + setScopeType: vi.fn().mockReturnThis(), + setScopeHash: vi.fn().mockReturnThis(), + setScopeContext: vi.fn().mockReturnThis(), + setScopeSkip: vi.fn().mockReturnThis(), + setScopeOverrides: vi.fn().mockReturnThis(), + setScopeContent: vi.fn().mockReturnThis(), + save: vi.fn(), + }; + const getInstance = vi.fn(() => instance); + return { + LCP: { + getInstance, + }, + __test__: { instance, getInstance }, + }; +}); + +describe("jsxScopesExportMutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("exports element scope with hash/content/flags", async () => { + const code = ` +export default function X(){ + return
    Foobar
    +}`.trim(); + const input = createPayload({ + code, + params: defaultParams, + relativeFilePath: "src/App.tsx", + } as any); + jsxScopesExportMutation(input); + const lcpMod: any = await import("./lib/lcp"); + const inst = lcpMod.__test__.instance; + expect(lcpMod.LCP.getInstance).toHaveBeenCalled(); + expect(inst.setScopeType).toHaveBeenCalledWith( + "src/App.tsx", + "0/declaration/body/0/argument", + "element", + ); + expect(inst.setScopeContent).toHaveBeenCalledWith( + "src/App.tsx", + "0/declaration/body/0/argument", + "Foobar", + ); + expect(inst.save).toHaveBeenCalled(); + }); +}); diff --git a/packages/compiler/src/jsx-scopes-export.ts b/packages/compiler/src/jsx-scopes-export.ts new file mode 100644 index 000000000..6e375e54b --- /dev/null +++ b/packages/compiler/src/jsx-scopes-export.ts @@ -0,0 +1,69 @@ +import { getJsxAttributeValue } from "./utils"; +import _ from "lodash"; +import { getAstKey } from "./utils/ast-key"; +import { LCP } from "./lib/lcp"; +import { getJsxElementHash } from "./utils/hash"; +import { getJsxAttributesMap } from "./utils/jsx-attribute"; +import { extractJsxContent } from "./utils/jsx-content"; +import { collectJsxScopes } from "./utils/jsx-scope"; +import { CompilerPayload } from "./_base"; + +// Processes only JSX element scopes +export function jsxScopesExportMutation( + payload: CompilerPayload, +): CompilerPayload { + const scopes = collectJsxScopes(payload.ast); + if (_.isEmpty(scopes)) { + return payload; + } + + const lcp = LCP.getInstance({ + sourceRoot: payload.params.sourceRoot, + lingoDir: payload.params.lingoDir, + }); + + for (const scope of scopes) { + const scopeKey = getAstKey(scope); + + lcp.resetScope(payload.relativeFilePath, scopeKey); + + lcp.setScopeType(payload.relativeFilePath, scopeKey, "element"); + + const hash = getJsxElementHash(scope); + lcp.setScopeHash(payload.relativeFilePath, scopeKey, hash); + + const context = getJsxAttributeValue(scope, "data-lingo-context"); + lcp.setScopeContext( + payload.relativeFilePath, + scopeKey, + String(context || ""), + ); + + const skip = getJsxAttributeValue(scope, "data-lingo-skip"); + lcp.setScopeSkip( + payload.relativeFilePath, + scopeKey, + Boolean(skip || false), + ); + + const attributesMap = getJsxAttributesMap(scope); + const overrides = _.chain(attributesMap) + .entries() + .filter(([attributeKey]) => + attributeKey.startsWith("data-lingo-override-"), + ) + .map(([k, v]) => [k.split("data-lingo-override-")[1], v]) + .filter(([k]) => !!k) + .filter(([, v]) => !!v) + .fromPairs() + .value(); + lcp.setScopeOverrides(payload.relativeFilePath, scopeKey, overrides); + + const content = extractJsxContent(scope); + lcp.setScopeContent(payload.relativeFilePath, scopeKey, content); + } + + lcp.save(); + + return payload; +} diff --git a/packages/compiler/src/lib/lcp/api.spec.ts b/packages/compiler/src/lib/lcp/api.spec.ts new file mode 100644 index 000000000..642389fa8 --- /dev/null +++ b/packages/compiler/src/lib/lcp/api.spec.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { LCPAPI } from "./api"; +import _ = require("lodash"); + +describe("LCPAPI", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("translate", () => { + // very abstract test to make sure the translate function calls private functions of the class + it("should chunk, translate and merge", async () => { + const modelsMock = {}; + const chunkSpy = vi + .spyOn(LCPAPI as any, "_chunkDictionary") + .mockReturnValue([1, 2, 3]); + const translateSpy = vi + .spyOn(LCPAPI as any, "_translateChunk") + .mockImplementation((_: any, param: number) => param * 10); + const mergeSpy = vi + .spyOn(LCPAPI as any, "_mergeDictionaries") + .mockReturnValue(100); + + const result = await LCPAPI.translate(modelsMock, 0 as any, "en", "es"); + + expect(chunkSpy).toHaveBeenCalledWith(0); + expect(translateSpy).toHaveBeenCalledTimes(3); + expect(translateSpy).toHaveBeenCalledWith( + modelsMock, + 1, + "en", + "es", + undefined, + ); + expect(translateSpy).toHaveBeenCalledWith( + modelsMock, + 2, + "en", + "es", + undefined, + ); + expect(translateSpy).toHaveBeenCalledWith( + modelsMock, + 3, + "en", + "es", + undefined, + ); + expect(mergeSpy).toHaveBeenCalledWith([10, 20, 30]); + expect(result).toEqual(100); + }); + }); + + describe("_chunkDictionary", () => { + it("should split dictionary into chunks of maximum 100 entries", () => { + const result = (LCPAPI as any)._chunkDictionary({ + $schema: "https://lcp.dev/schema/v1/dictionary.json", + version: 0.1, + locale: "en", + files: { + "test1.json": { + entries: _.fromPairs( + _.times(230, (i) => [`entry${i}`, `value${i}`]), + ), + }, + "test2.json": { + entries: _.fromPairs( + _.times(90, (i) => [`entry${i}`, `value${i}`]), + ), + }, + "test3.json": { + entries: _.fromPairs( + _.times(130, (i) => [`entry${i}`, `value${i}`]), + ), + }, + }, + }); + + expect(result.length).toEqual(5); + expect(Object.keys(result[0].files["test1.json"].entries).length).toEqual( + 100, + ); + expect(Object.keys(result[1].files["test1.json"].entries).length).toEqual( + 100, + ); + expect(Object.keys(result[2].files["test1.json"].entries).length).toEqual( + 30, + ); + expect(Object.keys(result[2].files["test2.json"].entries).length).toEqual( + 70, + ); + expect(Object.keys(result[3].files["test2.json"].entries).length).toEqual( + 20, + ); + expect(Object.keys(result[3].files["test3.json"].entries).length).toEqual( + 80, + ); + expect(Object.keys(result[4].files["test3.json"].entries).length).toEqual( + 50, + ); + }); + }); + + describe("_mergeDictionaries", () => { + it("should merge dictionaries into one", () => { + const dictionaries = [ + { + $schema: "https://lcp.dev/schema/v1/dictionary.json", + version: 0.1, + locale: "en", + files: { + "test1.json": { + entries: _.fromPairs( + _.times(10, (i) => [`a-entry${i}`, `value${i}`]), + ), + }, + }, + }, + { + $schema: "https://lcp.dev/schema/v1/dictionary.json", + version: 0.1, + locale: "en", + files: { + "test1.json": { + entries: _.fromPairs( + _.times(10, (i) => [`b-entry${i}`, `value${i}`]), + ), + }, + }, + }, + { + $schema: "https://lcp.dev/schema/v1/dictionary.json", + version: 0.1, + locale: "en", + files: { + "test1.json": { + entries: _.fromPairs( + _.times(5, (i) => [`c-entry${i}`, `value${i}`]), + ), + }, + "test2.json": { + entries: _.fromPairs( + _.times(5, (i) => [`a-entry${i}`, `value${i}`]), + ), + }, + }, + }, + { + $schema: "https://lcp.dev/schema/v1/dictionary.json", + version: 0.1, + locale: "en", + files: { + "test2.json": { + entries: _.fromPairs( + _.times(3, (i) => [`b-entry${i}`, `value${i}`]), + ), + }, + "test3.json": { + entries: _.fromPairs( + _.times(7, (i) => [`a-entry${i}`, `value${i}`]), + ), + }, + }, + }, + { + $schema: "https://lcp.dev/schema/v1/dictionary.json", + version: 0.1, + locale: "en", + files: { + "test3.json": { + entries: _.fromPairs( + _.times(6, (i) => [`b-entry${i}`, `value${i}`]), + ), + }, + }, + }, + ]; + + const result = (LCPAPI as any)._mergeDictionaries(dictionaries); + expect(Object.keys(result.files).length).toEqual(3); + expect(Object.keys(result.files["test1.json"].entries).length).toEqual( + 25, + ); + expect(Object.keys(result.files["test2.json"].entries).length).toEqual(8); + expect(Object.keys(result.files["test3.json"].entries).length).toEqual( + 13, + ); + }); + }); +}); diff --git a/packages/compiler/src/lib/lcp/api/index.ts b/packages/compiler/src/lib/lcp/api/index.ts new file mode 100644 index 000000000..635ceb98f --- /dev/null +++ b/packages/compiler/src/lib/lcp/api/index.ts @@ -0,0 +1,548 @@ +import { createGroq } from "@ai-sdk/groq"; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { createOllama } from "ollama-ai-provider-v2"; +import { createMistral } from "@ai-sdk/mistral"; +import { createOpenAI } from "@ai-sdk/openai"; +import { createAnthropic } from "@ai-sdk/anthropic"; +import { generateText } from "ai"; +import { LingoDotDevEngine } from "@lingo.dev/_sdk"; +import { DictionarySchema } from "../schema"; +import _ from "lodash"; +import { getLocaleModel } from "../../../utils/locales"; +import getSystemPrompt from "./prompt"; +import { obj2xml, xml2obj } from "./xml2obj"; +import shots from "./shots"; +import { + getGroqKey, + getGroqKeyFromEnv, + getGoogleKey, + getGoogleKeyFromEnv, + getOpenRouterKey, + getOpenRouterKeyFromEnv, + getMistralKey, + getMistralKeyFromEnv, + getOpenAIKey, + getOpenAIKeyFromEnv, + getAnthropicKey, + getAnthropicKeyFromEnv, + getLingoDotDevKeyFromEnv, + getLingoDotDevKey, +} from "../../../utils/llm-api-key"; +import dedent from "dedent"; +import { isRunningInCIOrDocker } from "../../../utils/env"; +import { LanguageModel } from "ai"; +import { providerDetails } from "./provider-details"; + +export class LCPAPI { + static async translate( + models: "lingo.dev" | Record, + sourceDictionary: DictionarySchema, + sourceLocale: string, + targetLocale: string, + prompt?: string | null, + ): Promise { + const timeLabel = `LCPAPI.translate: ${targetLocale}`; + console.time(timeLabel); + const chunks = this._chunkDictionary(sourceDictionary); + const translatedChunks = []; + for (const chunk of chunks) { + const translatedChunk = await this._translateChunk( + models, + chunk, + sourceLocale, + targetLocale, + prompt, + ); + translatedChunks.push(translatedChunk); + } + const result = this._mergeDictionaries(translatedChunks); + console.timeEnd(timeLabel); + return result; + } + + private static _chunkDictionary( + dictionary: DictionarySchema, + ): DictionarySchema[] { + const MAX_ENTRIES_PER_CHUNK = 100; + const { files, ...rest } = dictionary; + const chunks: DictionarySchema[] = []; + + let currentChunk: DictionarySchema = { + ...rest, + files: {}, + }; + let currentEntryCount = 0; + + Object.entries(files).forEach(([fileName, file]) => { + const entries = file.entries; + const entryPairs = Object.entries(entries); + + let currentIndex = 0; + while (currentIndex < entryPairs.length) { + const remainingSpace = MAX_ENTRIES_PER_CHUNK - currentEntryCount; + const entriesToAdd = entryPairs.slice( + currentIndex, + currentIndex + remainingSpace, + ); + + if (entriesToAdd.length > 0) { + currentChunk.files[fileName] = currentChunk.files[fileName] || { + entries: {}, + }; + currentChunk.files[fileName].entries = { + ...currentChunk.files[fileName].entries, + ...Object.fromEntries(entriesToAdd), + }; + currentEntryCount += entriesToAdd.length; + } + + currentIndex += entriesToAdd.length; + + if ( + currentEntryCount >= MAX_ENTRIES_PER_CHUNK || + (currentIndex < entryPairs.length && + currentEntryCount + (entryPairs.length - currentIndex) > + MAX_ENTRIES_PER_CHUNK) + ) { + chunks.push(currentChunk); + currentChunk = { ...rest, files: {} }; + currentEntryCount = 0; + } + } + }); + + if (currentEntryCount > 0) { + chunks.push(currentChunk); + } + + return chunks; + } + + private static _mergeDictionaries(dictionaries: DictionarySchema[]) { + const fileNames = _.uniq( + _.flatMap(dictionaries, (dict) => Object.keys(dict.files)), + ); + const files = _(fileNames) + .map((fileName) => { + const entries = dictionaries.reduce((entries, dict) => { + const file = dict.files[fileName]; + if (file) { + entries = _.merge(entries, file.entries); + } + return entries; + }, {}); + return [fileName, { entries }]; + }) + .fromPairs() + .value(); + const dictionary = { + version: dictionaries[0].version, + locale: dictionaries[0].locale, + files, + }; + return dictionary; + } + + private static _createLingoDotDevEngine() { + // Specific check for CI/CD or Docker missing GROQ key + if (isRunningInCIOrDocker()) { + const apiKeyFromEnv = getLingoDotDevKeyFromEnv(); + if (!apiKeyFromEnv) { + this._failMissingLLMKeyCi("lingo.dev"); + } + } + const apiKey = getLingoDotDevKey(); + if (!apiKey) { + throw new Error( + "⚠️ Lingo.dev API key not found. Please set LINGODOTDEV_API_KEY environment variable or configure it user-wide.", + ); + } + console.log(`Creating Lingo.dev client`); + return new LingoDotDevEngine({ + apiKey, + }); + } + + private static async _translateChunk( + models: "lingo.dev" | Record, + sourceDictionary: DictionarySchema, + sourceLocale: string, + targetLocale: string, + prompt?: string | null, + ): Promise { + if (models === "lingo.dev") { + try { + const lingoDotDevEngine = this._createLingoDotDevEngine(); + + console.log( + `✨ Using Lingo.dev Engine to localize from "${sourceLocale}" to "${targetLocale}"`, + ); + + const result = await lingoDotDevEngine.localizeObject( + sourceDictionary, + { + sourceLocale: sourceLocale, + targetLocale: targetLocale, + }, + ); + + return result as DictionarySchema; + } catch (error) { + this._failLLMFailureLocal( + "lingo.dev", + targetLocale, + error instanceof Error ? error.message : "Unknown error", + ); + // This throw is unreachable because the failure method exits, + // but it helps satisfy the TypeScript compiler. + throw error; + } + } else { + const { provider, model } = getLocaleModel( + models, + sourceLocale, + targetLocale, + ); + + if (!provider || !model) { + throw new Error( + dedent` + 🚫 Lingo.dev Localization Engine Not Configured! + + The "models" parameter is missing or incomplete in your Lingo.dev configuration. + + 👉 To fix this, set the "models" parameter to either: + • "lingo.dev" (for the default engine) + • a map of locale-to-model, e.g. { "models": { "en:es": "openai:gpt-3.5-turbo" } } + + Example: + { + // ... + "models": "lingo.dev" + } + + For more details, see: https://lingo.dev/compiler + To get help, join our Discord: https://lingo.dev/go/discord + `, + ); + } + + try { + const aiModel = this._createAiModel(provider, model, targetLocale); + + console.log( + `ℹ️ Using raw LLM API ("${provider}":"${model}") to translate from "${sourceLocale}" to "${targetLocale}"`, + ); + + const response = await generateText({ + model: aiModel, + messages: [ + { + role: "system", + content: getSystemPrompt({ + sourceLocale, + targetLocale, + prompt: prompt ?? undefined, + }), + }, + ...shots.flatMap((shotsTuple) => [ + { + role: "user" as const, + content: obj2xml(shotsTuple[0]), + }, + { + role: "assistant" as const, + content: obj2xml(shotsTuple[1]), + }, + ]), + { + role: "user", + content: obj2xml(sourceDictionary), + }, + ], + }); + + console.log("Response text received for", targetLocale); + let responseText = response.text; + // Extract XML content + responseText = responseText.substring( + responseText.indexOf("<"), + responseText.lastIndexOf(">") + 1, + ); + + return xml2obj(responseText); + } catch (error) { + this._failLLMFailureLocal( + provider, + targetLocale, + error instanceof Error ? error.message : "Unknown error", + ); + // This throw is unreachable because the failure method exits, + // but it helps satisfy the TypeScript compiler. + throw error; + } + } + } + + /** + * Instantiates an AI model based on provider and model ID. + * Includes CI/CD API key checks. + * @param providerId The ID of the AI provider (e.g., "groq", "google"). + * @param modelId The ID of the specific model (e.g., "llama3-8b-8192", "gemini-2.0-flash"). + * @param targetLocale The target locale being translated to (for logging/error messages). + * @returns An instantiated AI LanguageModel. + * @throws Error if the provider is not supported or API key is missing in CI/CD. + */ + private static _createAiModel( + providerId: string, + modelId: string, + targetLocale: string, + ): LanguageModel { + switch (providerId) { + case "groq": { + // Specific check for CI/CD or Docker missing GROQ key + if (isRunningInCIOrDocker()) { + const groqFromEnv = getGroqKeyFromEnv(); + if (!groqFromEnv) { + this._failMissingLLMKeyCi(providerId); + } + } + const groqKey = getGroqKey(); + if (!groqKey) { + throw new Error( + "⚠️ GROQ API key not found. Please set GROQ_API_KEY environment variable or configure it user-wide.", + ); + } + console.log( + `Creating Groq client for ${targetLocale} using model ${modelId}`, + ); + return createGroq({ apiKey: groqKey })(modelId); + } + + case "google": { + // Specific check for CI/CD or Docker missing Google key + if (isRunningInCIOrDocker()) { + const googleFromEnv = getGoogleKeyFromEnv(); + if (!googleFromEnv) { + this._failMissingLLMKeyCi(providerId); + } + } + const googleKey = getGoogleKey(); + if (!googleKey) { + throw new Error( + "⚠️ Google API key not found. Please set GOOGLE_API_KEY environment variable or configure it user-wide.", + ); + } + console.log( + `Creating Google Generative AI client for ${targetLocale} using model ${modelId}`, + ); + return createGoogleGenerativeAI({ apiKey: googleKey })(modelId); + } + case "openrouter": { + // Specific check for CI/CD or Docker missing OpenRouter key + if (isRunningInCIOrDocker()) { + const openRouterFromEnv = getOpenRouterKeyFromEnv(); + if (!openRouterFromEnv) { + this._failMissingLLMKeyCi(providerId); + } + } + const openRouterKey = getOpenRouterKey(); + if (!openRouterKey) { + throw new Error( + "⚠️ OpenRouter API key not found. Please set OPENROUTER_API_KEY environment variable or configure it user-wide.", + ); + } + console.log( + `Creating OpenRouter client for ${targetLocale} using model ${modelId}`, + ); + return createOpenRouter({ + apiKey: openRouterKey, + })(modelId); + } + + case "ollama": { + // No API key check needed for Ollama + console.log( + `Creating Ollama client for ${targetLocale} using model ${modelId} at default Ollama address`, + ); + return createOllama()(modelId); + } + + case "mistral": { + // Specific check for CI/CD or Docker missing Mistral key + if (isRunningInCIOrDocker()) { + const mistralFromEnv = getMistralKeyFromEnv(); + if (!mistralFromEnv) { + this._failMissingLLMKeyCi(providerId); + } + } + const mistralKey = getMistralKey(); + if (!mistralKey) { + throw new Error( + "⚠️ Mistral API key not found. Please set MISTRAL_API_KEY environment variable or configure it user-wide.", + ); + } + console.log( + `Creating Mistral client for ${targetLocale} using model ${modelId}`, + ); + return createMistral({ apiKey: mistralKey })(modelId); + } + + case "openai": { + // Specific check for CI/CD or Docker missing OpenAI key + if (isRunningInCIOrDocker()) { + const openaiFromEnv = getOpenAIKeyFromEnv(); + if (!openaiFromEnv) { + this._failMissingLLMKeyCi(providerId); + } + } + const openaiKey = getOpenAIKey(); + if (!openaiKey) { + throw new Error( + "⚠️ OpenAI API key not found. Please set OPENAI_API_KEY environment variable or configure it user-wide.", + ); + } + console.log( + `Creating OpenAI client for ${targetLocale} using model ${modelId}`, + ); + return createOpenAI({ apiKey: openaiKey })(modelId); + } + + case "anthropic": { + // Specific check for CI/CD or Docker missing Anthropic key + if (isRunningInCIOrDocker()) { + const anthropicFromEnv = getAnthropicKeyFromEnv(); + if (!anthropicFromEnv) { + this._failMissingLLMKeyCi(providerId); + } + } + const anthropicKey = getAnthropicKey(); + if (!anthropicKey) { + throw new Error( + "⚠️ Anthropic API key not found. Please set ANTHROPIC_API_KEY environment variable or configure it user-wide.", + ); + } + console.log( + `Creating Anthropic client for ${targetLocale} using model ${modelId}`, + ); + return createAnthropic({ apiKey: anthropicKey })(modelId); + } + + default: { + throw new Error( + `⚠️ Provider "${providerId}" for locale "${targetLocale}" is not supported. Only "groq", "google", "openrouter", "ollama", "mistral", "openai", and "anthropic" providers are supported at the moment.`, + ); + } + } + } + + /** + * Show an actionable error message and exit the process when the compiler + * is running in CI/CD without a required LLM API key. + * The message explains why this situation is unusual and how to fix it. + * @param providerId The ID of the LLM provider whose key is missing. + */ + private static _failMissingLLMKeyCi(providerId: string): never { + let details = providerDetails[providerId]; + if (!details) { + // Fallback for unsupported provider in failure message logic + throw new Error( + `Internal Error: Missing details for provider "${providerId}" when reporting missing key in CI/CD. You might be using an unsupported provider.`, + ); + } + + const errorMessage = dedent` + 💡 You're using Lingo.dev Localization Compiler, and it detected unlocalized components in your app. + + The compiler needs a ${details.name} API key to translate missing strings, but ${details.apiKeyEnvVar} is not set in the environment. + + This is unexpected: typically you run a full build locally, commit the generated translation files, and push them to CI/CD. + + However, If you want CI/CD to translate the new strings, provide the key with: + • Session-wide: export ${details.apiKeyEnvVar}= + • Project-wide / CI: add ${details.apiKeyEnvVar}= to your pipeline environment variables + + ⭐️ Also: + 1. If you don't yet have a ${details.name} API key, get one for free at ${details.getKeyLink} + 2. If you want to use a different LLM, update your configuration. Refer to documentation for help: https://lingo.dev/compiler + 3. If the model you want to use isn't supported yet, raise an issue in our open-source repo: https://lingo.dev/go/gh + `; + console.log(errorMessage); + throw new Error(`Missing ${details.name} API key in CI/CD environment.`); + } + + /** + * Show an actionable error message and exit the process when an LLM API call + * fails during local compilation. + * @param providerId The ID of the LLM provider that failed. + * @param targetLocale The target locale being translated to. + * @param errorMessage The error message received from the API. + */ + private static _failLLMFailureLocal( + providerId: string, + targetLocale: string, + errorMessage: string, + ): never { + const details = providerDetails[providerId]; + if (!details) { + // Fallback + throw new Error( + `Internal Error: Missing details for provider "${providerId}" when reporting local failure. Original Error: ${errorMessage}`, + ); + } + + const isInvalidApiKey = errorMessage.match("Invalid API Key"); // TODO: This may change per-provider, so might update this later + + if (isInvalidApiKey) { + const message = dedent` + ⚠️ Lingo.dev Compiler requires a valid ${details.name} API key to translate your application. + + It looks like you set ${details.name} API key but it is not valid. Please check your API key and try again. + + Error details from ${details.name} API: ${errorMessage} + + 👉 You can set the API key in one of the following ways: + 1. User-wide: Run npx lingo.dev@latest config set ${details.apiKeyConfigKey} + 2. Project-wide: Add ${details.apiKeyEnvVar}= to .env file in every project that uses Lingo.dev Localization Compiler + 3 Session-wide: Run export ${details.apiKeyEnvVar}= in your terminal before running the compiler to set the API key for the current session + + ⭐️ Also: + 1. If you don't yet have a ${details.name} API key, get one for free at ${details.getKeyLink} + 2. If you want to use a different LLM, raise an issue in our open-source repo: https://lingo.dev/go/gh + 3. If you have questions, feature requests, or would like to contribute, join our Discord: https://lingo.dev/go/discord + `; + console.log(message); + throw new Error(`Invalid ${details.name} API key.`); + } else { + const message = dedent` + ⚠️ Lingo.dev Compiler tried to translate your application to "${targetLocale}" locale via ${ + details.name + } but it failed. + + Error details from ${details.name} API: ${errorMessage} + + This error comes from the ${ + details.name + } API, please check their documentation for more details: ${ + details.docsLink + } + + ⭐️ Also: + 1. Did you set ${ + details.apiKeyEnvVar + ? `${details.apiKeyEnvVar}` + : "the provider API key" + } environment variable correctly ${ + !details.apiKeyEnvVar ? "(if required)" : "" + }? + 2. Did you reach any limits of your ${details.name} account? + 3. If you have questions, feature requests, or would like to contribute, join our Discord: https://lingo.dev/go/discord + `; + console.log(message); + throw new Error( + `Translation failed for locale "${targetLocale}" using ${details.name}: ${errorMessage}`, + ); + } + } +} diff --git a/packages/compiler/src/lib/lcp/api/prompt.spec.ts b/packages/compiler/src/lib/lcp/api/prompt.spec.ts new file mode 100644 index 000000000..ab805f71a --- /dev/null +++ b/packages/compiler/src/lib/lcp/api/prompt.spec.ts @@ -0,0 +1,57 @@ +import prompt from "./prompt"; +import { describe, it, expect, vi } from "vitest"; + +const baseArgs = { + sourceLocale: "en", + targetLocale: "es", +}; + +describe("prompt", () => { + it("returns user-defined prompt with replacements", () => { + const args = { + ...baseArgs, + prompt: "Translate from {SOURCE_LOCALE} to {TARGET_LOCALE}.", + }; + const result = prompt(args); + expect(result).toBe("Translate from en to es."); + }); + + it("trims and replaces variables in user prompt", () => { + const args = { + ...baseArgs, + prompt: " {SOURCE_LOCALE} => {TARGET_LOCALE} ", + }; + const result = prompt(args); + expect(result).toBe("en => es"); + }); + + it("falls back to built-in prompt if no user prompt", () => { + const args = { ...baseArgs }; + const result = prompt(args); + expect(result).toContain("You are an advanced AI localization engine"); + expect(result).toContain("Source language (locale code): en"); + expect(result).toContain("Target language (locale code): es"); + }); + + it("logs when using user-defined prompt", () => { + const spy = vi.spyOn(console, "log"); + const args = { + ...baseArgs, + prompt: "Prompt {SOURCE_LOCALE} {TARGET_LOCALE}", + }; + prompt(args); + expect(spy).toHaveBeenCalledWith( + "✨ Compiler is using user-defined prompt.", + ); + spy.mockRestore(); + }); + + it("returns built-in prompt if user prompt is empty or whitespace", () => { + const args = { + ...baseArgs, + prompt: " ", + }; + const result = prompt(args); + expect(result).toContain("You are an advanced AI localization engine"); + }); +}); diff --git a/packages/compiler/src/lib/lcp/api/prompt.ts b/packages/compiler/src/lib/lcp/api/prompt.ts new file mode 100644 index 000000000..c3d6cb4fe --- /dev/null +++ b/packages/compiler/src/lib/lcp/api/prompt.ts @@ -0,0 +1,60 @@ +interface PromptArguments { + sourceLocale: string; + targetLocale: string; + prompt?: string; +} + +export default (args: PromptArguments) => { + return getUserSystemPrompt(args) || getBuiltInSystemPrompt(args); +}; + +function getUserSystemPrompt(args: PromptArguments): string | undefined { + const userPrompt = args.prompt + ?.trim() + ?.replace("{SOURCE_LOCALE}", args.sourceLocale) + ?.replace("{TARGET_LOCALE}", args.targetLocale); + if (userPrompt) { + console.log("✨ Compiler is using user-defined prompt."); + return userPrompt; + } + return undefined; +} + +function getBuiltInSystemPrompt(args: PromptArguments) { + return ` +# Identity + +You are an advanced AI localization engine. You do state-of-the-art localization for software products. +Your task is to localize pieces of data from one locale to another locale. +You always consider context, cultural nuances of source and target locales, and specific localization requirements. +You replicate the meaning, intent, style, tone, and purpose of the original data. + +## Setup + +Source language (locale code): ${args.sourceLocale} +Target language (locale code): ${args.targetLocale} + +## Guidelines + +Follow these guidelines for translation: + +1. Analyze the source text to understand its overall context and purpose +2. Translate the meaning and intent rather than word-for-word translation +3. Rephrase and restructure sentences to sound natural and fluent in the target language +4. Adapt idiomatic expressions and cultural references for the target audience +5. Maintain the style and tone of the source text +6. You must produce valid UTF-8 encoded output +7. YOU MUST ONLY PRODUCE VALID XML. + +## Special Instructions + +Do not localize any of these technical elements: +- Variables like {variable}, {variable.key}, {data[type]} +- Expressions like +- Functions like , +- Elements like , , , , , + +Remember, you are a context-aware multilingual assistant helping international companies. +Your goal is to perform state-of-the-art localization for software products and content. +`; +} diff --git a/packages/compiler/src/lib/lcp/api/provider-details.spec.ts b/packages/compiler/src/lib/lcp/api/provider-details.spec.ts new file mode 100644 index 000000000..cb261e76b --- /dev/null +++ b/packages/compiler/src/lib/lcp/api/provider-details.spec.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { providerDetails } from "./provider-details"; + +describe("provider-details", () => { + it("should provide data for all supported providers", () => { + expect(Object.keys(providerDetails)).toEqual([ + "groq", + "google", + "openai", + "anthropic", + "openrouter", + "ollama", + "mistral", + "lingo.dev", + ]); + }); +}); diff --git a/packages/compiler/src/lib/lcp/api/provider-details.ts b/packages/compiler/src/lib/lcp/api/provider-details.ts new file mode 100644 index 000000000..b79c25057 --- /dev/null +++ b/packages/compiler/src/lib/lcp/api/provider-details.ts @@ -0,0 +1,69 @@ +import { openrouter } from "@openrouter/ai-sdk-provider"; + +export const providerDetails: Record< + string, + { + name: string; // Display name (e.g., "Groq", "Google") + apiKeyEnvVar?: string; // Environment variable name (e.g., "GROQ_API_KEY") + apiKeyConfigKey?: string; // Config key if applicable (e.g., "llm.groqApiKey") + getKeyLink: string; // Link to get API key + docsLink: string; // Link to API docs for troubleshooting + } +> = { + groq: { + name: "Groq", + apiKeyEnvVar: "GROQ_API_KEY", + apiKeyConfigKey: "llm.groqApiKey", + getKeyLink: "https://groq.com", + docsLink: "https://console.groq.com/docs/errors", + }, + google: { + name: "Google", + apiKeyEnvVar: "GOOGLE_API_KEY", + apiKeyConfigKey: "llm.googleApiKey", + getKeyLink: "https://ai.google.dev/", + docsLink: "https://ai.google.dev/gemini-api/docs/troubleshooting", + }, + openai: { + name: "OpenAI", + apiKeyEnvVar: "OPENAI_API_KEY", + apiKeyConfigKey: "llm.openaiApiKey", + getKeyLink: "https://platform.openai.com/account/api-keys", + docsLink: "https://platform.openai.com/docs", + }, + anthropic: { + name: "Anthropic", + apiKeyEnvVar: "ANTHROPIC_API_KEY", + apiKeyConfigKey: "llm.anthropicApiKey", + getKeyLink: "https://console.anthropic.com/get-api-key", + docsLink: "https://console.anthropic.com/docs", + }, + openrouter: { + name: "OpenRouter", + apiKeyEnvVar: "OPENROUTER_API_KEY", + apiKeyConfigKey: "llm.openrouterApiKey", + getKeyLink: "https://openrouter.ai", + docsLink: "https://openrouter.ai/docs", + }, + ollama: { + name: "Ollama", + apiKeyEnvVar: undefined, // Ollama doesn't require an API key + apiKeyConfigKey: undefined, // Ollama doesn't require an API key + getKeyLink: "https://ollama.com/download", + docsLink: "https://github.com/ollama/ollama/tree/main/docs", + }, + mistral: { + name: "Mistral", + apiKeyEnvVar: "MISTRAL_API_KEY", + apiKeyConfigKey: "llm.mistralApiKey", + getKeyLink: "https://console.mistral.ai", + docsLink: "https://docs.mistral.ai", + }, + "lingo.dev": { + name: "Lingo.dev", + apiKeyEnvVar: "LINGODOTDEV_API_KEY", + apiKeyConfigKey: "auth.apiKey", + getKeyLink: "https://lingo.dev", + docsLink: "https://lingo.dev/docs", + }, +}; diff --git a/packages/compiler/src/lib/lcp/api/shots.ts b/packages/compiler/src/lib/lcp/api/shots.ts new file mode 100644 index 000000000..189f1a23a --- /dev/null +++ b/packages/compiler/src/lib/lcp/api/shots.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { dictionarySchema } from "../schema"; + +export default [ + // Shot #1 + [ + { + version: 0.1, + locale: "en", + files: { + "demo-app/my-custom-header.tsx": { + entries: { + "1z2x3c4v": "Dashboard", + "5t6y7u8i": "Settings", + "9o0p1q2r": "Logout", + }, + }, + "demo-app/my-custom-footer.tsx": { + entries: { + "9k0l1m2n": "© 2025 Lingo.dev. All rights reserved.", + }, + }, + }, + }, + { + version: 0.1, + locale: "es", + files: { + "demo-app/my-custom-header.tsx": { + entries: { + "1z2x3c4v": "Panel de control", + "5t6y7u8i": "Configuración", + "9o0p1q2r": "Cerrar sesión", + }, + }, + "demo-app/my-custom-footer.tsx": { + entries: { + "9k0l1m2n": "© 2025 Lingo.dev. Todos los derechos reservados.", + }, + }, + }, + }, + ], + // More shots here... +] satisfies [ + z.infer, + z.infer, +][]; diff --git a/packages/compiler/src/lib/lcp/api/xml2obj.spec.ts b/packages/compiler/src/lib/lcp/api/xml2obj.spec.ts new file mode 100644 index 000000000..fd5f12531 --- /dev/null +++ b/packages/compiler/src/lib/lcp/api/xml2obj.spec.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { xml2obj, obj2xml } from "./xml2obj"; + +function normalize(xml: string) { + return xml.replace(/\s+/g, " ").trim(); +} + +describe("xml2obj / obj2xml", () => { + it("should convert simple XML to object with key attributes", () => { + const xml = ` + + + 123 + abc + John + Doe + + + `; + const obj = xml2obj(xml); + expect(obj).toEqual({ + user: { + id: 123, + dataValue: "abc", + firstName: "John", + lastName: "Doe", + }, + }); + }); + + it("should preserve complex structures through round-trip conversion", () => { + const original = { + root: { + id: 123, + name: "John & Jane <> \" '", + notes: "Line1\nLine2", + isActive: true, + tags: { + tag: ["a & b", "c < d"], + }, + nestedObj: { + childId: 456, + weirdSymbols: "@#$%^&*()_+", + }, + items: { + item: [ + { keyOne: "value1", keyTwo: "value2" }, + { keyOne: "value3", keyTwo: "value4" }, + ], + }, + }, + } as const; + + const result = xml2obj(obj2xml(original)); + expect(result).toEqual(original); + }); + + it("should handle empty elements, arrays and self-closing tags", () => { + const original = ` + + + + 1.99 + 9.99 + + + `; + const expected = { + products: "", + prices: [1.99, 9.99], + }; + expect(xml2obj(original)).toEqual(expected); + }); + + it("should correctly escape special characters when building XML", () => { + const original = { message: "5 < 6 & 7 > 4" } as const; + const result = xml2obj(obj2xml(original)); + expect(result).toEqual(original); + }); + + it("check 1", () => { + const original = ` + + 0.1.1 + ja + + + + <element:select><element:option>使用済み</element:option><element:option>合計</element:option></element:select> 🚀 あなたの使用状況: {wordType} {subscription.words[wordType]} + + + +`; + + const result = xml2obj(original); + expect(result).toEqual({ + version: "0.1.1", + locale: "ja", + files: { + "routes/($locale).z.tsx": { + entries: { + "1/declaration/body/3/argument": + "使用済み合計 🚀 あなたの使用状況: {wordType} {subscription.words[wordType]}", + }, + }, + }, + }); + }); +}); diff --git a/packages/compiler/src/lib/lcp/api/xml2obj.ts b/packages/compiler/src/lib/lcp/api/xml2obj.ts new file mode 100644 index 000000000..5c5d53203 --- /dev/null +++ b/packages/compiler/src/lib/lcp/api/xml2obj.ts @@ -0,0 +1,127 @@ +import { XMLParser, XMLBuilder } from "fast-xml-parser"; +import _ from "lodash"; + +// Generic tag names used in XML output +const TAG_OBJECT = "object"; +const TAG_ARRAY = "array"; +const TAG_VALUE = "value"; + +/** + * Converts a JavaScript value to a generic XML node structure understood by fast-xml-parser. + */ +function _toGenericNode(value: any, key?: string): Record { + if (_.isArray(value)) { + const children = _.map(value, (item) => _toGenericNode(item)); + return { + [TAG_ARRAY]: { + ...(key ? { key } : {}), + ..._groupChildren(children), + }, + }; + } + + if (_.isPlainObject(value)) { + const children = _.map(Object.entries(value), ([k, v]) => + _toGenericNode(v, k), + ); + return { + [TAG_OBJECT]: { + ...(key ? { key } : {}), + ..._groupChildren(children), + }, + }; + } + + return { + [TAG_VALUE]: { + ...(key ? { key } : {}), + "#text": value ?? "", + }, + }; +} + +/** + * Groups a list of nodes by their tag name so that fast-xml-parser outputs arrays even for single elements. + */ +function _groupChildren(nodes: Record[]): Record { + return _(nodes) + .groupBy((node) => Object.keys(node)[0]) + .mapValues((arr) => _.map(arr, (n) => n[Object.keys(n)[0]])) + .value(); +} + +/** + * Recursively converts a generic XML node back to a JavaScript value. + */ +function _fromGenericNode(tag: string, data: any): any { + if (tag === TAG_VALUE) { + // 123 without attributes is parsed as a primitive (number | string) + // whereas 123 is parsed as an object with a "#text" field. + // Support both shapes. + if (_.isPlainObject(data)) { + return _.get(data, "#text", ""); + } + return data ?? ""; + } + + if (tag === TAG_ARRAY) { + const result: any[] = []; + _.forEach([TAG_VALUE, TAG_OBJECT, TAG_ARRAY], (childTag) => { + const childNodes = _.castArray(_.get(data, childTag, [])); + _.forEach(childNodes, (child) => { + result.push(_fromGenericNode(childTag, child)); + }); + }); + return result; + } + + // TAG_OBJECT + const obj: Record = {}; + _.forEach([TAG_VALUE, TAG_OBJECT, TAG_ARRAY], (childTag) => { + const childNodes = _.castArray(_.get(data, childTag, [])); + _.forEach(childNodes, (child) => { + const key = _.get(child, "key", ""); + obj[key] = _fromGenericNode(childTag, child); + }); + }); + return obj; +} + +export function obj2xml(obj: T): string { + const rootNode = _toGenericNode(obj)[TAG_OBJECT]; + const builder = new XMLBuilder({ + ignoreAttributes: false, + attributeNamePrefix: "", + format: true, + suppressEmptyNode: true, + }); + return builder.build({ [TAG_OBJECT]: rootNode }); +} + +export function xml2obj(xml: string): T { + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "", + parseTagValue: true, + parseAttributeValue: false, + processEntities: true, + isArray: (name) => [TAG_VALUE, TAG_ARRAY, TAG_OBJECT].includes(name), + }); + const parsed = parser.parse(xml); + + // The parser keeps the XML declaration () under the + // pseudo-tag "?xml". Skip it so that we always start the conversion at the + // first real node (i.e. , or ). + const withoutDeclaration = _.omit(parsed, "?xml"); + + const rootTag = Object.keys(withoutDeclaration)[0]; + + // fast-xml-parser treats every , and element as an array + // because we configured the `isArray` option above. This means even the root + // element comes wrapped in an array. Unwrap it so that the recursive + // conversion logic receives the actual node object instead of an array – + // otherwise no children will be found and we would return an empty result. + const rootNode = _.castArray(withoutDeclaration[rootTag])[0]; + + return _fromGenericNode(rootTag, rootNode); +} diff --git a/packages/compiler/src/lib/lcp/cache.spec.ts b/packages/compiler/src/lib/lcp/cache.spec.ts new file mode 100644 index 000000000..dd10466f0 --- /dev/null +++ b/packages/compiler/src/lib/lcp/cache.spec.ts @@ -0,0 +1,456 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { resolve } from "path"; +import { LCPCache, LCPCacheParams } from "./cache"; +import { LCPSchema } from "./schema"; +import { LCP_DICTIONARY_FILE_NAME } from "../../_const"; + +const { mockExistsSync, mockReadFileSync, mockWriteFileSync, mockPrettierFormat, mockPrettierResolveConfig } = vi.hoisted(() => { + return { + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), + mockWriteFileSync: vi.fn(), + mockPrettierFormat: vi.fn(), + mockPrettierResolveConfig: vi.fn(), + }; +}); + +vi.mock("fs", () => ({ + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, +})); + +vi.mock("prettier", () => ({ + format: mockPrettierFormat, + resolveConfig: mockPrettierResolveConfig, +})); + +// 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(); + mockPrettierFormat.mockImplementation( + async (value: string) => value, + ); + }); + + describe("readLocaleDictionary", () => { + it("returns empty dictionary when no cache exists", () => { + mockExistsSync.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", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.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", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.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 () => { + mockExistsSync.mockReturnValue(false); + mockWriteFileSync.mockImplementation(() => {}); + + const dictionary = { + version: 0.1, + locale: "en", + files: { + "test.ts": { + entries: { + key1: "Hello", + }, + }, + }, + }; + + await LCPCache.writeLocaleDictionary(dictionary, params); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + cachePath, + toCachedString({ + version: 0.1, + files: { + "test.ts": { + entries: { + key1: { + content: { + en: "Hello", + }, + hash: "123", + }, + }, + }, + }, + }), + ); + }); + + it("adds new locale to existing cache", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + toCachedString({ + version: 0.1, + files: { + "test.ts": { + entries: { + key1: { + content: { + en: "Hello", + }, + hash: "123", + }, + }, + }, + }, + }), + ); + mockWriteFileSync.mockImplementation(() => {}); + + const dictionary = { + version: 0.1, + locale: "fr", + files: { + "test.ts": { + entries: { + key1: "Bonjour", + }, + }, + }, + }; + + await LCPCache.writeLocaleDictionary(dictionary, params); + + expect(mockWriteFileSync).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 () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + toCachedString({ + version: 0.1, + files: { + "test.ts": { + entries: { + key1: { + content: { + en: "Hello", + fr: "Bonjour", + }, + hash: "123", + }, + }, + }, + }, + }), + ); + mockWriteFileSync.mockImplementation(() => {}); + + const dictionary = { + version: 0.1, + locale: "en", + files: { + "test.ts": { + entries: { + key1: "Hi", + }, + }, + }, + }; + + await LCPCache.writeLocaleDictionary(dictionary, params); + + expect(mockWriteFileSync).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 () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.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", + }, + }, + }, + }, + }), + ); + mockWriteFileSync.mockImplementation(() => {}); + + 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(mockWriteFileSync).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 () => { + mockPrettierResolveConfig.mockResolvedValue({}); + mockPrettierFormat.mockResolvedValue("formatted"); + + const dictionary = { + version: 0.1, + locale: "en", + files: { + "test.ts": { + entries: { + key1: "Hi", + }, + }, + }, + }; + + await LCPCache.writeLocaleDictionary(dictionary, params); + + expect(mockPrettierResolveConfig).toHaveBeenCalledTimes(1); + expect(mockPrettierFormat).toHaveBeenCalledTimes(1); + expect(mockWriteFileSync).toHaveBeenCalledWith(cachePath, "formatted"); + }); + }); +}); diff --git a/packages/compiler/src/lib/lcp/cache.ts b/packages/compiler/src/lib/lcp/cache.ts new file mode 100644 index 000000000..d6faeba4a --- /dev/null +++ b/packages/compiler/src/lib/lcp/cache.ts @@ -0,0 +1,210 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as prettier from "prettier"; +import { DictionaryCacheSchema, DictionarySchema, LCPSchema } from "./schema"; +import _ from "lodash"; +import { LCP_DICTIONARY_FILE_NAME } from "../../_const"; + +export interface LCPCacheParams { + sourceRoot: string; + lingoDir: string; + lcp: LCPSchema; +} + +export class LCPCache { + // make sure the cache file exists, otherwise imports will fail + static ensureDictionaryFile(params: { + sourceRoot: string; + lingoDir: string; + }) { + const cachePath = this._getCachePath(params); + if (!fs.existsSync(cachePath)) { + const dir = path.dirname(cachePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(cachePath, "export default {};"); + } + } + + // read cache entries for given locale, validate entry hash from LCP schema + static readLocaleDictionary( + locale: string, + params: LCPCacheParams, + ): DictionarySchema { + const cache = this._read(params); + const dictionary = this._extractLocaleDictionary(cache, locale, params.lcp); + return dictionary; + } + + // write cache entries for given locale to existing cache file, use hash from LCP schema + static async writeLocaleDictionary( + dictionary: DictionarySchema, + params: LCPCacheParams, + ): Promise { + const currentCache = this._read(params); + const newCache = this._mergeLocaleDictionary( + currentCache, + dictionary, + params.lcp, + ); + await this._write(newCache, params); + } + + // merge dictionary with current cache, sort files, entries and locales to minimize diffs + private static _mergeLocaleDictionary( + currentCache: DictionaryCacheSchema, + dictionary: DictionarySchema, + lcp: LCPSchema, + ): DictionaryCacheSchema { + const files = _(dictionary.files) + .mapValues((file, fileName) => ({ + ...file, + entries: _(file.entries) + .mapValues((entry, entryName) => { + // find if entry exists in current cache, it might contain some locales already + const cachedEntry = + _.get(currentCache, ["files", fileName, "entries", entryName]) ?? + {}; + const hash = _.get(lcp, [ + "files", + fileName, + "scopes", + entryName, + "hash", + ]); + + // reuse existing cache entry if its hash matches LCP schema, ensures the cache is up to date + const cachedEntryContent = + cachedEntry.hash === hash ? cachedEntry.content : {}; + + // sorted by keys (locales) to minimize diffs + const content = _({ + ...cachedEntryContent, + [dictionary.locale]: entry, + }) + .toPairs() + .sortBy([0]) + .fromPairs() + .value(); + return { content, hash }; + }) + .toPairs() + .sortBy([0]) + .fromPairs() + .value(), + })) + .toPairs() + .sortBy([0]) + .fromPairs() + .value(); + + const newCache = { + version: dictionary.version, + files, + }; + return newCache; + } + + // extract dictionary from cache for given locale, validate entry hash from LCP schema + private static _extractLocaleDictionary( + cache: DictionaryCacheSchema, + locale: string, + lcp: LCPSchema, + ): DictionarySchema { + const findCachedEntry = (hash: string) => { + const cachedEntry = _(cache.files) + .flatMap((file) => _.values(file.entries)) + .find((entry) => entry.hash === hash); + if (cachedEntry) { + return cachedEntry.content[locale]; + } + return undefined; + }; + + const files = _(lcp.files) + .mapValues((file) => { + return { + entries: _(file.scopes) + .mapValues((entry) => { + return findCachedEntry(entry.hash); + }) + .pickBy((value) => value !== undefined) + .value(), + }; + }) + .pickBy((file) => !_.isEmpty(file.entries)) + .value(); + + const dictionary = { + version: cache.version, + locale, + files, + }; + return dictionary; + } + + // format with prettier + private static async _format( + cachedContent: string, + cachePath: string, + ): Promise { + try { + const config = await prettier.resolveConfig(cachePath); + const prettierOptions = { + ...(config ?? {}), + parser: config?.parser ? config.parser : "typescript", + }; + return await prettier.format(cachedContent, prettierOptions); + } catch (error) { + // prettier not configured or formatting failed + } + return cachedContent; + } + + // write cache to file as JSON + private static async _write( + dictionaryCache: DictionaryCacheSchema, + params: LCPCacheParams, + ) { + const cachePath = this._getCachePath(params); + const cache = `export default ${JSON.stringify(dictionaryCache, null, 2)};`; + const formattedCache = await this._format(cache, cachePath); + fs.writeFileSync(cachePath, formattedCache); + } + + // read cache from file as JSON + private static _read(params: LCPCacheParams): DictionaryCacheSchema { + const cachePath = this._getCachePath(params); + if (!fs.existsSync(cachePath)) { + return { + version: 0.1, + files: {}, + }; + } + const jsObjectString = fs.readFileSync(cachePath, "utf8"); + + // Remove 'export default' and trailing semicolon before parsing + const cache = jsObjectString + .replace(/^export default/, "") + .replace(/;\s*$/, ""); + + // Use Function constructor to safely evaluate the object + // eslint-disable-next-line no-new-func + const obj = new Function(`return (${cache})`)(); + return obj; + } + + // get cache file path + private static _getCachePath(params: { + sourceRoot: string; + lingoDir: string; + }) { + return path.resolve( + process.cwd(), + params.sourceRoot, + params.lingoDir, + LCP_DICTIONARY_FILE_NAME, + ); + } +} diff --git a/packages/compiler/src/lib/lcp/index.spec.ts b/packages/compiler/src/lib/lcp/index.spec.ts new file mode 100644 index 000000000..c11b3430b --- /dev/null +++ b/packages/compiler/src/lib/lcp/index.spec.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import dedent from "dedent"; + +vi.mock("fs", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + existsSync: vi.fn(() => false), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + readFileSync: vi.fn(() => "{}"), + statSync: vi.fn(() => ({ mtimeMs: Date.now() - 10_000 }) as any), + rmdirSync: vi.fn(), + utimesSync: vi.fn(), + } as any; +}); + +// import after mocks +import { LCP } from "./index"; + +describe("LCP", () => { + beforeEach(() => { + (fs.existsSync as any).mockReset().mockReturnValue(false); + (fs.mkdirSync as any).mockReset(); + (fs.writeFileSync as any).mockReset(); + (fs.readFileSync as any).mockReset().mockReturnValue("{}"); + (fs.statSync as any) + .mockReset() + .mockReturnValue({ mtimeMs: Date.now() - 10_000 }); + (fs.rmdirSync as any).mockReset(); + (fs.utimesSync as any).mockReset(); + }); + + describe("ensureFile", () => { + it("creates meta.json and throws an error", () => { + (fs.existsSync as any).mockReturnValueOnce(false); + expect(() => { + LCP.ensureFile({ sourceRoot: "src", lingoDir: "lingo" }); + }).toThrow(/Lingo.dev Compiler detected missing meta.json file/); + expect(fs.mkdirSync).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it("does not create meta.json if it already exists", () => { + (fs.existsSync as any).mockReturnValue(true); + LCP.ensureFile({ sourceRoot: "src", lingoDir: "lingo" }); + expect(fs.mkdirSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + }); + + describe("getInstance", () => { + it("returns parsed schema when file exists", () => { + (fs.existsSync as any).mockReturnValue(true); + (fs.readFileSync as any).mockReturnValue('{"version":42, "files": {}}'); + const lcp = LCP.getInstance({ sourceRoot: "src", lingoDir: "lingo" }); + expect(lcp.data.version).toBe(42); + }); + + it("returns new instance when file does not exist", () => { + (fs.existsSync as any).mockReturnValue(false); + const lcp = LCP.getInstance({ sourceRoot: "src", lingoDir: "lingo" }); + expect(lcp.data.version).toBe(0.1); + }); + }); + + describe("ready", () => { + it("resolves immediately when meta.json is older than threshold", async () => { + (fs.existsSync as any).mockReturnValue(true); + (fs.statSync as any).mockReturnValue({ mtimeMs: Date.now() - 10_000 }); + await LCP.ready({ sourceRoot: "src", lingoDir: "lingo", isDev: false }); + expect(fs.statSync).toHaveBeenCalled(); + }); + }); + + describe("setScope* chain", () => { + it("modifies internal data and save writes only on change", () => { + const lcp = LCP.getInstance({ sourceRoot: "src", lingoDir: "lingo" }); + (fs.existsSync as any).mockReturnValue(false); + lcp + .resetScope("file.tsx", "scope-1") + .setScopeType("file.tsx", "scope-1", "element") + .setScopeContext("file.tsx", "scope-1", "ctx") + .setScopeHash("file.tsx", "scope-1", "hash") + .setScopeSkip("file.tsx", "scope-1", false) + .setScopeOverrides("file.tsx", "scope-1", { es: "x" }) + .setScopeContent("file.tsx", "scope-1", "Hello"); + + // first save writes + lcp.save(); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + + // mimic that file exists and content matches -> no write + (fs.existsSync as any).mockReturnValue(true); + (fs.readFileSync as any).mockReturnValueOnce( + (fs.writeFileSync as any).mock.calls[0][1], + ); + lcp.save(); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/compiler/src/lib/lcp/index.ts b/packages/compiler/src/lib/lcp/index.ts new file mode 100644 index 000000000..5d663f37d --- /dev/null +++ b/packages/compiler/src/lib/lcp/index.ts @@ -0,0 +1,224 @@ +import * as fs from "fs"; +import _ from "lodash"; +import { LCPFile, LCPSchema, LCPScope } from "./schema"; +import * as path from "path"; +import { LCP_DICTIONARY_FILE_NAME } from "../../_const"; +import dedent from "dedent"; + +const LCP_FILE_NAME = "meta.json"; + +export class LCP { + private constructor( + private readonly filePath: string, + public readonly data: LCPSchema = { + version: 0.1, + }, + ) {} + + public static ensureFile(params: { sourceRoot: string; lingoDir: string }) { + const filePath = path.resolve( + process.cwd(), + params.sourceRoot, + params.lingoDir, + LCP_FILE_NAME, + ); + if (!fs.existsSync(filePath)) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, "{}"); + + try { + fs.rmdirSync(path.resolve(process.cwd(), ".next"), { + recursive: true, + }); + } catch (error) { + // Ignore errors if directory doesn't exist + } + throw new Error(dedent` + ⚠️ Lingo.dev Compiler detected missing meta.json file in lingo directory. + Please restart the build / watch command to regenerate all Lingo.dev Compiler files. + `); + } + } + + public static getInstance(params: { + sourceRoot: string; + lingoDir: string; + }): LCP { + const filePath = path.resolve( + process.cwd(), + params.sourceRoot, + params.lingoDir, + LCP_FILE_NAME, + ); + if (fs.existsSync(filePath)) { + return new LCP(filePath, JSON.parse(fs.readFileSync(filePath, "utf8"))); + } + return new LCP(filePath); + } + + // wait until LCP file stops updating + // this ensures all files were transformed before loading / translating dictionaries + public static async ready(params: { + sourceRoot: string; + lingoDir: string; + isDev: boolean; + }): Promise { + if (params.isDev) { + LCP.ensureFile(params); + } + + const filePath = path.resolve( + process.cwd(), + params.sourceRoot, + params.lingoDir, + LCP_FILE_NAME, + ); + if (fs.existsSync(filePath)) { + const stats = fs.statSync(filePath); + if (Date.now() - stats.mtimeMs > 1500) { + return; + } + } + return new Promise((resolve) => { + setTimeout(() => { + LCP.ready(params).then(resolve); + }, 750); + }); + } + + resetScope(fileKey: string, scopeKey: string): this { + if ( + !_.isObject( + _.get(this.data, ["files" satisfies keyof LCPSchema, fileKey]), + ) + ) { + _.set(this.data, ["files" satisfies keyof LCPSchema, fileKey], {}); + } + + _.set( + this.data, + [ + "files" satisfies keyof LCPSchema, + fileKey, + "scopes" satisfies keyof LCPFile, + scopeKey, + ], + {}, + ); + + return this; + } + + setScopeType( + fileKey: string, + scopeKey: string, + type: "element" | "attribute", + ): this { + return this._setScopeField(fileKey, scopeKey, "type", type); + } + + setScopeContext(fileKey: string, scopeKey: string, context: string): this { + return this._setScopeField(fileKey, scopeKey, "context", context); + } + + setScopeHash(fileKey: string, scopeKey: string, hash: string): this { + return this._setScopeField(fileKey, scopeKey, "hash", hash); + } + + setScopeSkip(fileKey: string, scopeKey: string, skip: boolean): this { + return this._setScopeField(fileKey, scopeKey, "skip", skip); + } + + setScopeOverrides( + fileKey: string, + scopeKey: string, + overrides: Record, + ): this { + return this._setScopeField(fileKey, scopeKey, "overrides", overrides); + } + + setScopeContent(fileKey: string, scopeKey: string, content: string): this { + return this._setScopeField(fileKey, scopeKey, "content", content); + } + + toJSON() { + const files = _(this.data?.files) + .mapValues((file: any, fileName: string) => { + return { + ...file, + scopes: _(file?.scopes).toPairs().sortBy([0]).fromPairs().value(), + }; + }) + .toPairs() + .sortBy([0]) + .fromPairs() + .value(); + return { ...this.data, files }; + } + + toString() { + return JSON.stringify(this.toJSON(), null, 2) + "\n"; + } + + save() { + const hasChanges = + !fs.existsSync(this.filePath) || + fs.readFileSync(this.filePath, "utf8") !== this.toString(); + + if (hasChanges) { + const dir = path.dirname(this.filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.filePath, this.toString()); + + this._triggerLCPReload(); + } + } + + private _triggerLCPReload() { + const dir = path.dirname(this.filePath); + const filePath = path.resolve(dir, LCP_DICTIONARY_FILE_NAME); + if (fs.existsSync(filePath)) { + try { + const now = Math.floor(Date.now() / 1000); // Convert to seconds + fs.utimesSync(filePath, now, now); + } catch (error: any) { + // Non-critical operation - timestamp update is just for triggering reload + if (error?.code === "EINVAL") { + console.warn( + dedent` + ⚠️ Lingo: Auto-reload disabled - system blocks Node.js timestamp updates. + 💡 Fix: Adjust security settings to allow Node.js file modifications. + ⚡ Workaround: Manually refresh browser after translation changes. + 💬 Need help? Join our Discord: https://lingo.dev/go/discord. + `, + ); + } + } + } + } + + private _setScopeField( + fileKey: string, + scopeKey: string, + field: K, + value: LCPScope[K], + ): this { + _.set( + this.data, + [ + "files" satisfies keyof LCPSchema, + fileKey, + "scopes" satisfies keyof LCPFile, + scopeKey, + field, + ], + value, + ); + return this; + } +} diff --git a/packages/compiler/src/lib/lcp/schema.ts b/packages/compiler/src/lib/lcp/schema.ts new file mode 100644 index 000000000..874558a52 --- /dev/null +++ b/packages/compiler/src/lib/lcp/schema.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +// LCP + +export const lcpScope = z.object({ + type: z.enum(["element", "attribute"]), + content: z.string(), + hash: z.string(), + context: z.string().optional(), + skip: z.boolean().optional(), + overrides: z.record(z.string(), z.string()).optional(), +}); + +export type LCPScope = z.infer; + +export const lcpFile = z.object({ + scopes: z.record(z.string(), lcpScope).optional(), +}); + +export type LCPFile = z.infer; + +export const lcpSchema = z.object({ + version: z.number().prefault(0.1), + files: z.record(z.string(), lcpFile).optional(), +}); + +export type LCPSchema = z.infer; + +// Dictionary + +export const dictionaryFile = z.object({ + entries: z.record(z.string(), z.string()), +}); + +export type DictionaryFile = z.infer; + +export const dictionarySchema = z.object({ + version: z.number().prefault(0.1), + locale: z.string(), + files: z.record(z.string(), dictionaryFile), +}); + +export type DictionarySchema = z.infer; + +// Dictionary Cache + +export const dictionaryCacheFile = z.object({ + entries: z.record( + z.string(), + z.object({ + content: z.record(z.string(), z.string()), + hash: z.string(), + }), + ), +}); + +export const dictionaryCacheSchema = z.object({ + version: z.number().prefault(0.1), + files: z.record(z.string(), dictionaryCacheFile), +}); + +export type DictionaryCacheSchema = z.infer; diff --git a/packages/compiler/src/lib/lcp/server.spec.ts b/packages/compiler/src/lib/lcp/server.spec.ts new file mode 100644 index 000000000..a29f99d18 --- /dev/null +++ b/packages/compiler/src/lib/lcp/server.spec.ts @@ -0,0 +1,628 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { LCPServer } from "./server"; +import { LCPSchema } from "./schema"; +import { LCPCache } from "./cache"; +import { LCPAPI } from "./api"; + +describe("LCPServer", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.mock("fs"); + vi.mock("path"); + }); + + describe("loadDictionaries", () => { + it("should load dictionaries for all target locales", async () => { + const lcp: LCPSchema = { + version: 0.1, + files: {}, + }; + const loadDictionaryForLocaleSpy = vi.spyOn( + LCPServer, + "loadDictionaryForLocale", + ); + const dictionaries = await LCPServer.loadDictionaries({ + models: { + "*:*": "groq:mistral-saba-24b", + }, + lcp, + sourceLocale: "en", + targetLocales: ["fr", "es", "de"], + sourceRoot: "src", + lingoDir: "lingo", + }); + + expect(loadDictionaryForLocaleSpy).toHaveBeenCalledTimes(4); + expect(dictionaries).toEqual({ + fr: { + version: 0.1, + locale: "fr", + files: {}, + }, + es: { + version: 0.1, + locale: "es", + files: {}, + }, + de: { + version: 0.1, + locale: "de", + files: {}, + }, + en: { + version: 0.1, + locale: "en", + files: {}, + }, + }); + }); + }); + + describe("loadDictionaryForLocale", () => { + it("should correctly extract the source dictionary when source and target locales are the same", async () => { + // Mock LCPAPI.translate() to ensure it's not called + const translateSpy = vi.spyOn(LCPAPI, "translate"); + vi.spyOn(LCPCache, "writeLocaleDictionary").mockResolvedValue(undefined); + + const lcp: LCPSchema = { + version: 0.1, + files: { + "app/test.tsx": { + scopes: { + key1: { + content: "Hello World", + hash: "abcd1234", + }, + key2: { + content: "Button Text", + hash: "efgh5678", + }, + }, + }, + }, + }; + + const result = await LCPServer.loadDictionaryForLocale({ + lcp, + sourceLocale: "en", + targetLocale: "en", // Same locale + sourceRoot: "src", + lingoDir: "lingo", + }); + + // Verify the structure + expect(result).toEqual({ + version: 0.1, + locale: "en", + files: { + "app/test.tsx": { + entries: { + key1: "Hello World", + key2: "Button Text", + }, + }, + }, + }); + + // Ensure LCPAPI.translate() wasn't called since source == target + expect(translateSpy).not.toHaveBeenCalled(); + }); + + it("should return empty dictionary when source dictionary is empty", async () => { + // Mock LCPAPI.translate() to ensure it's not called + const translateSpy = vi.spyOn(LCPAPI, "translate"); + + const lcp: LCPSchema = { + version: 0.1, + files: {}, + }; + + const result = await LCPServer.loadDictionaryForLocale({ + lcp, + sourceLocale: "en", + targetLocale: "es", + sourceRoot: "src", + lingoDir: "lingo", + }); + + // Verify the structure + expect(result).toEqual({ + version: 0.1, + locale: "es", + files: {}, + }); + + // Ensure LCPAPI.translate() wasn't called since source == target + expect(translateSpy).not.toHaveBeenCalled(); + }); + + it("should handle overrides in source content", async () => { + // Mock LCPAPI.translate() to ensure it's not called + vi.spyOn(LCPCache, "writeLocaleDictionary").mockResolvedValue(undefined); + vi.spyOn(LCPAPI, "translate").mockImplementation(() => + Promise.resolve({ + version: 0.1, + locale: "fr", + files: { + "app/test.tsx": { + entries: { + key1: "Bonjour le monde", + key2: "Texte du bouton", + }, + }, + }, + }), + ); + + const lcp: LCPSchema = { + version: 0.1, + files: { + "app/test.tsx": { + scopes: { + key1: { + content: "Hello World", + hash: "abcd1234", + }, + key2: { + content: "Button Text", + hash: "efgh5678", + }, + key3: { + content: "Original", + hash: "1234abcd", + overrides: { + fr: "Remplacé", // French override for 'key3' + }, + }, + }, + }, + }, + }; + + const result = await LCPServer.loadDictionaryForLocale({ + lcp, + sourceLocale: "en", + targetLocale: "fr", + sourceRoot: "src", + lingoDir: "lingo", + }); + + // Check that the overrides were applied + expect(result.files["app/test.tsx"].entries).toEqual({ + key1: "Bonjour le monde", + key2: "Texte du bouton", + key3: "Remplacé", + }); + expect(result.locale).toBe("fr"); + }); + + it("should create empty dictionary when no files are provided", async () => { + const lcp: LCPSchema = { + version: 0.1, + }; + + const result = await LCPServer.loadDictionaryForLocale({ + lcp, + sourceLocale: "en", + targetLocale: "en", + sourceRoot: "src", + lingoDir: "lingo", + }); + + expect(result).toEqual({ + version: 0.1, + locale: "en", + files: {}, + }); + }); + + it("should read dictionary from cache only, not call LCPAPI.translate()", async () => { + vi.spyOn(LCPCache, "readLocaleDictionary").mockReturnValue({ + version: 0.1, + locale: "en", + files: { + "app/test.tsx": { + entries: { + key1: "Hello World", + key2: "Button Text", + key3: "New text", + }, + }, + }, + }); + const translateSpy = vi + .spyOn(LCPAPI, "translate") + .mockImplementation(() => { + throw new Error("Should not translate anything"); + }); + + const lcp: LCPSchema = { + version: 0.1, + files: { + "app/test.tsx": { + scopes: { + key1: { + content: "Hello World", + }, + }, + }, + }, + }; + + await LCPServer.loadDictionaryForLocale({ + lcp, + sourceLocale: "en", + targetLocale: "fr", + sourceRoot: "src", + lingoDir: "lingo", + }); + + expect(translateSpy).not.toHaveBeenCalled(); + expect(LCPCache.readLocaleDictionary).toHaveBeenCalledWith("fr", { + lcp, + sourceLocale: "en", + lingoDir: "lingo", + sourceRoot: "src", + }); + }); + + it("should write dictionary to cache", async () => { + vi.spyOn(LCPCache, "writeLocaleDictionary").mockResolvedValue(undefined); + vi.spyOn(LCPAPI, "translate").mockReturnValue({ + version: 0.1, + locale: "fr", + files: { + "app/test.tsx": { + entries: { + key1: "Bonjour le monde", + key2: "Texte du bouton", + }, + }, + }, + }); + + const lcp: LCPSchema = { + version: 0.1, + files: { + "app/test.tsx": { + scopes: { + key1: { + content: "Hello World", + hash: "abcd1234", + }, + key2: { + content: "Button Text", + hash: "efgh5678", + }, + }, + }, + }, + }; + + await LCPServer.loadDictionaryForLocale({ + lcp, + sourceLocale: "en", + targetLocale: "fr", + sourceRoot: "src", + lingoDir: "lingo", + }); + + expect(LCPCache.writeLocaleDictionary).toHaveBeenCalledWith( + { + files: { + "app/test.tsx": { + entries: { + key1: "Bonjour le monde", + key2: "Texte du bouton", + }, + }, + }, + locale: "fr", + version: 0.1, + }, + { + lcp, + sourceLocale: "en", + lingoDir: "lingo", + sourceRoot: "src", + }, + ); + }); + + it("should reuse cached keys with matching hash, call LCPAPI.translate() for keys with different hash, fallback to source locale, cache new translations", async () => { + vi.spyOn(LCPCache, "readLocaleDictionary").mockReturnValue({ + version: 0.1, + locale: "fr", + files: { + "app/test.tsx": { + entries: { + key1: "Bonjour le monde", + }, + }, + }, + }); + const writeCacheSpy = vi.spyOn(LCPCache, "writeLocaleDictionary").mockResolvedValue(undefined); + const translateSpy = vi.spyOn(LCPAPI, "translate").mockResolvedValue({ + version: 0.1, + locale: "fr", + files: { + "app/test.tsx": { + entries: { + key2: "Nouveau texte du bouton", + key3: "", // LLM might return empty string + }, + }, + }, + }); + + const lcp: LCPSchema = { + version: 0.1, + files: { + "app/test.tsx": { + scopes: { + key1: { + content: "Hello World", + hash: "abcd1234", + }, + key2: { + content: "Button Text", + hash: "new_hash", + }, + key3: { + content: "New text", + hash: "ijkl4321", + }, + }, + }, + }, + }; + + const models = { + "*:*": "groq:mistral-saba-24b", + }; + + const result = await LCPServer.loadDictionaryForLocale({ + models, + lcp, + sourceLocale: "en", + targetLocale: "fr", + sourceRoot: "src", + lingoDir: "lingo", + }); + + // Verify that only changed content was sent for translation + expect(translateSpy).toHaveBeenCalledWith( + models, + { + version: 0.1, + locale: "en", + files: { + "app/test.tsx": { + entries: { + key2: "Button Text", + key3: "New text", + }, + }, + }, + }, + "en", + "fr", + undefined, + ); + + // Verify final result combines cached and newly translated content + expect(result).toEqual({ + version: 0.1, + locale: "fr", + files: { + "app/test.tsx": { + entries: { + key1: "Bonjour le monde", + key2: "Nouveau texte du bouton", + key3: "New text", // LLM returned empty string, but result contains fallback to source locale string + }, + }, + }, + }); + + // when LLM returns empty string, we cache empty string (the result contains fallback to source locale string) + result.files["app/test.tsx"].entries.key3 = ""; + + // Verify cache is updated with new translations + expect(writeCacheSpy).toHaveBeenCalledWith(result, { + lcp, + sourceLocale: "en", + lingoDir: "lingo", + sourceRoot: "src", + }); + }); + }); + + describe("_getDictionaryDiff", () => { + it("should return diff between source and target dictionaries", () => { + const sourceDictionary = { + version: 0.1, + locale: "en", + files: { + "app/test.tsx": { + entries: { + key1: "Hello World", + key2: "Button Text", + key3: "New Text", + key4: "More text", + }, + }, + }, + }; + + const targetDictionary = { + version: 0.1, + locale: "es", + files: { + "app/test.tsx": { + entries: { + key1: "Hola mundo", + key2: "El texto del botón", + key3: "", // empty string is valid value + }, + }, + }, + }; + + const diff = (LCPServer as any)._getDictionaryDiff( + sourceDictionary, + targetDictionary, + ); + + expect(diff).toEqual({ + version: 0.1, + locale: "en", + files: { + "app/test.tsx": { + entries: { + key4: "More text", + }, + }, + }, + }); + }); + }); + + describe("_mergeDictionaries", () => { + it("should merge dictionaries", () => { + const sourceDictionary = { + version: 0.1, + locale: "es", + files: { + "app/test.tsx": { + entries: { + key2: "", + key3: "Nuevo texto", + }, + }, + "app/test3.tsx": { + entries: { + key1: "Como estas?", + key2: "Yo soy bien", + }, + }, + }, + }; + + const targetDictionary = { + version: 0.1, + locale: "es", + files: { + "app/test.tsx": { + entries: { + key1: "Hola mundo", + key2: "Hola", + }, + }, + "app/test2.tsx": { + entries: { + key1: "Yo soy un programador", + key2: "", + }, + }, + }, + }; + + const merge = (LCPServer as any)._mergeDictionaries( + sourceDictionary, + targetDictionary, + ); + + expect(merge).toEqual({ + version: 0.1, + locale: "es", + files: { + "app/test.tsx": { + entries: { + key1: "Hola mundo", + key2: "", + key3: "Nuevo texto", + }, + }, + "app/test2.tsx": { + entries: { + key1: "Yo soy un programador", + key2: "", + }, + }, + "app/test3.tsx": { + entries: { + key1: "Como estas?", + key2: "Yo soy bien", + }, + }, + }, + }); + }); + + it("should remove empty entries when merging dictionaries", () => { + const sourceDictionary = { + version: 0.1, + locale: "es", + files: { + "app/test.tsx": { + entries: { + key1: "", + key2: "El texto del botón", + }, + }, + "app/test2.tsx": { + entries: { + key1: "Yo soy un programador", + key2: "", + }, + }, + }, + }; + + const targetDictionary = { + version: 0.1, + locale: "es", + files: { + "app/test.tsx": { + entries: { + key1: "Hello world", + key2: "Button Text", + }, + }, + "app/test2.tsx": { + entries: { + key1: "I am a programmer", + key2: "You are a gardener", + }, + }, + }, + }; + + const merge = (LCPServer as any)._mergeDictionaries( + sourceDictionary, + targetDictionary, + true, + ); + + expect(merge).toEqual({ + version: 0.1, + locale: "es", + files: { + "app/test.tsx": { + entries: { + key1: "Hello world", + key2: "El texto del botón", + }, + }, + "app/test2.tsx": { + entries: { + key1: "Yo soy un programador", + key2: "You are a gardener", + }, + }, + }, + }); + }); + }); +}); diff --git a/packages/compiler/src/lib/lcp/server.ts b/packages/compiler/src/lib/lcp/server.ts new file mode 100644 index 000000000..ce5d53d45 --- /dev/null +++ b/packages/compiler/src/lib/lcp/server.ts @@ -0,0 +1,311 @@ +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; + prompt?: string | null; +}; + +export type LCPServerParams = LCPServerBaseParams & { + targetLocales: string[]; +}; + +export type LCPServerParamsForLocale = LCPServerBaseParams & { + targetLocale: string; +}; + +export class LCPServer { + private static inFlightPromise: Promise< + Record + > | null = null; + + static async loadDictionaries( + params: LCPServerParams, + ): Promise> { + // 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 { + 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, + ); + } +} diff --git a/packages/compiler/src/lingo-turbopack-loader.ts b/packages/compiler/src/lingo-turbopack-loader.ts new file mode 100644 index 000000000..456a24164 --- /dev/null +++ b/packages/compiler/src/lingo-turbopack-loader.ts @@ -0,0 +1,45 @@ +import { loadDictionary, transformComponent } from "./_loader-utils"; + +// This loader handles component transformations and dictionary generation +export default async function (this: any, source: string) { + const callback = this.async(); + const params = this.getOptions(); + const isDev = process.env.NODE_ENV !== "production"; + + try { + // Dictionary loading + const dictionary = await loadDictionary({ + resourcePath: this.resourcePath, + resourceQuery: this.resourceQuery, + params, + sourceRoot: params.sourceRoot, + lingoDir: params.lingoDir, + isDev, + }); + + if (dictionary) { + const code = `export default ${JSON.stringify(dictionary, null, 2)};`; + return callback(null, code); + } + + // Component transformation + const result = transformComponent({ + code: source, + params, + resourcePath: this.resourcePath, + sourceRoot: params.sourceRoot, + }); + + return callback( + null, + result.code, + result.map ? JSON.stringify(result.map) : undefined, + ); + } catch (error) { + console.error( + `⚠️ Lingo.dev compiler (Turbopack) failed for ${this.resourcePath}:`, + ); + console.error("⚠️ Details:", error); + callback(error as Error); + } +} diff --git a/packages/compiler/src/react-router-dictionary-loader.ts b/packages/compiler/src/react-router-dictionary-loader.ts new file mode 100644 index 000000000..7903ed8eb --- /dev/null +++ b/packages/compiler/src/react-router-dictionary-loader.ts @@ -0,0 +1,54 @@ +import { createCodeMutation } from "./_base"; +import { ModuleId } from "./_const"; +import { getModuleExecutionMode, getOrCreateImport } from "./utils"; +import { findInvokations } from "./utils/invokations"; +import * as t from "@babel/types"; +import { getDictionaryPath } from "./_utils"; +import { createLocaleImportMap } from "./utils/create-locale-import-map"; + +export const reactRouterDictionaryLoaderMutation = createCodeMutation( + (payload) => { + const mode = getModuleExecutionMode(payload.ast, payload.params.rsc); + if (mode === "server") { + return payload; + } + + const invokations = findInvokations(payload.ast, { + moduleName: ModuleId.ReactRouter, + functionName: "loadDictionary", + }); + + const allLocales = Array.from( + new Set([payload.params.sourceLocale, ...payload.params.targetLocales]), + ); + + for (const invokation of invokations) { + const internalDictionaryLoader = getOrCreateImport(payload.ast, { + moduleName: ModuleId.ReactRouter, + exportedName: "loadDictionary_internal", + }); + + // Replace the function identifier with internal version + if (t.isIdentifier(invokation.callee)) { + invokation.callee.name = internalDictionaryLoader.importedName; + } + + const dictionaryPath = getDictionaryPath({ + sourceRoot: payload.params.sourceRoot, + lingoDir: payload.params.lingoDir, + relativeFilePath: payload.relativeFilePath, + }); + + // Create locale import map object + const localeImportMap = createLocaleImportMap(allLocales, dictionaryPath); + + // Add the locale import map as the second argument + invokation.arguments.push(localeImportMap); + // console.log("invokation modified", JSON.stringify(invokation, null, 2)); + } + + // console.log("dictionary-loader", generate(payload.ast).code); + + return payload; + }, +); diff --git a/packages/compiler/src/rsc-dictionary-loader.ts b/packages/compiler/src/rsc-dictionary-loader.ts new file mode 100644 index 000000000..48eae8b8c --- /dev/null +++ b/packages/compiler/src/rsc-dictionary-loader.ts @@ -0,0 +1,50 @@ +import path from "path"; +import { createCodeMutation } from "./_base"; +import { LCP_DICTIONARY_FILE_NAME, ModuleId } from "./_const"; +import { getModuleExecutionMode, getOrCreateImport } from "./utils"; +import { findInvokations } from "./utils/invokations"; +import * as t from "@babel/types"; +import { getDictionaryPath } from "./_utils"; +import { createLocaleImportMap } from "./utils/create-locale-import-map"; + +export const rscDictionaryLoaderMutation = createCodeMutation((payload) => { + const mode = getModuleExecutionMode(payload.ast, payload.params.rsc); + if (mode === "client") { + return payload; + } + + const invokations = findInvokations(payload.ast, { + moduleName: ModuleId.ReactRSC, + functionName: "loadDictionary", + }); + + const allLocales = Array.from( + new Set([payload.params.sourceLocale, ...payload.params.targetLocales]), + ); + + for (const invokation of invokations) { + const internalDictionaryLoader = getOrCreateImport(payload.ast, { + moduleName: ModuleId.ReactRSC, + exportedName: "loadDictionary_internal", + }); + + // Replace the function identifier with internal version + if (t.isIdentifier(invokation.callee)) { + invokation.callee.name = internalDictionaryLoader.importedName; + } + + const dictionaryPath = getDictionaryPath({ + sourceRoot: payload.params.sourceRoot, + lingoDir: payload.params.lingoDir, + relativeFilePath: payload.relativeFilePath, + }); + + // Create locale import map object + const localeImportMap = createLocaleImportMap(allLocales, dictionaryPath); + + // Add the locale import map as the second argument + invokation.arguments.push(localeImportMap); + } + + return payload; +}); diff --git a/packages/compiler/src/utils/ast-key.spec.ts b/packages/compiler/src/utils/ast-key.spec.ts new file mode 100644 index 000000000..2042cd66c --- /dev/null +++ b/packages/compiler/src/utils/ast-key.spec.ts @@ -0,0 +1,57 @@ +import { it, describe, expect } from "vitest"; +import { parse } from "@babel/parser"; +import { traverse, NodePath } from "../babel-interop"; +import { getAstKey, getAstByKey } from "./ast-key"; + +describe("ast key", () => { + it("getAstKey should calc nodePath key", () => { + const mockData = createMockData(); + + const key = getAstKey(mockData.testElementPath); + + expect(key).toBe(mockData.testElementKey); + }); +}); + +describe("getAstByKey", () => { + it("should retrieve correct node by key", () => { + const mockData = createMockData(); + + const elementPath = getAstByKey(mockData.ast, mockData.testElementKey); + + expect(elementPath).toBe(mockData.testElementPath); + }); +}); + +// helpers + +function createMockData() { + const ast = parse( + ` + export function MyComponent() { + return
    Hello world!
    ; + } +`, + { sourceType: "module", plugins: ["jsx"] }, + ); + + let testElementPath: NodePath | null = null; + traverse(ast, { + JSXElement(nodePath) { + testElementPath = nodePath; + }, + }); + if (!testElementPath) { + throw new Error( + "testElementPath cannot be null - check test case definition", + ); + } + + const testElementKey = `0/declaration/body/0/argument`; + + return { + ast, + testElementPath, + testElementKey, + }; +} diff --git a/packages/compiler/src/utils/ast-key.ts b/packages/compiler/src/utils/ast-key.ts new file mode 100644 index 000000000..04a9d4fd1 --- /dev/null +++ b/packages/compiler/src/utils/ast-key.ts @@ -0,0 +1,70 @@ +import { NodePath } from "../babel-interop"; +import * as t from "@babel/types"; +import { traverse } from "../babel-interop"; + +export function getAstKey(nodePath: NodePath) { + const keyChunks: any[] = []; + + let current: NodePath | null = nodePath; + while (current) { + keyChunks.push(current.key); + current = current.parentPath; + + if (t.isProgram(current?.node)) { + break; + } + } + + const result = keyChunks.reverse().join("/"); + return result; +} + +export function getAstByKey(ast: t.File, key: string) { + const programPath = _getProgramNodePath(ast); + if (!programPath) { + return null; + } + + const keyParts = key.split("/").reverse(); + + let result: NodePath = programPath; + + while (true) { + let currentKeyPart = keyParts.pop(); + if (!currentKeyPart) { + break; + } + const isIntegerPart = Number.isInteger(Number(currentKeyPart)); + if (isIntegerPart) { + const maybeBodyItemsArray = result.get("body"); + const bodyItemsArray = Array.isArray(maybeBodyItemsArray) + ? maybeBodyItemsArray + : [maybeBodyItemsArray]; + const index = Number(currentKeyPart); + const subResult = bodyItemsArray[index]; + result = subResult as NodePath; + } else { + const maybeSubResultArray = result.get(currentKeyPart); + const subResultArray = Array.isArray(maybeSubResultArray) + ? maybeSubResultArray + : [maybeSubResultArray]; + const subResult = subResultArray[0]; + result = subResult; + } + } + + return result; +} + +function _getProgramNodePath(ast: t.File): NodePath | null { + let result: NodePath | null = null; + + traverse(ast, { + Program(nodePath: NodePath) { + result = nodePath; + nodePath.stop(); + }, + }); + + return result; +} diff --git a/packages/compiler/src/utils/create-locale-import-map.spec.ts b/packages/compiler/src/utils/create-locale-import-map.spec.ts new file mode 100644 index 000000000..91f7a3c74 --- /dev/null +++ b/packages/compiler/src/utils/create-locale-import-map.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import * as t from "@babel/types"; +import { createLocaleImportMap } from "./create-locale-import-map"; + +describe("createLocaleImportMap", () => { + const allLocales = ["en", "de", "en-US"]; + const dictionaryPath = "/foo/bar"; + + const objExpr = createLocaleImportMap(allLocales, dictionaryPath); + + it("returns a Babel ObjectExpression", () => { + expect(t.isObjectExpression(objExpr)).toBe(true); + }); + + it("creates one property per locale", () => { + expect(objExpr.properties.length).toBe(allLocales.length); + }); + + it("uses string literal keys and import arrow functions correctly", () => { + for (const prop of objExpr.properties) { + // Ensure property is ObjectProperty + expect(t.isObjectProperty(prop)).toBe(true); + if (!t.isObjectProperty(prop)) continue; + + // Check the key is a string literal matching one of the locales + expect(t.isStringLiteral(prop.key)).toBe(true); + const keyLiteral = prop.key as t.StringLiteral; + expect(allLocales).toContain(keyLiteral.value); + + // Ensure value is an arrow function with no params + expect(t.isArrowFunctionExpression(prop.value)).toBe(true); + const arrowFn = prop.value as t.ArrowFunctionExpression; + expect(arrowFn.params.length).toBe(0); + + // The body should be a call expression to dynamic import + expect(t.isCallExpression(arrowFn.body)).toBe(true); + const callExpr = arrowFn.body as t.CallExpression; + + // Callee is identifier 'import' + expect(t.isIdentifier(callExpr.callee)).toBe(true); + if (t.isIdentifier(callExpr.callee)) { + expect(callExpr.callee.name).toBe("import"); + } + + // Single argument: string literal with proper path + expect(callExpr.arguments.length).toBe(1); + const arg = callExpr.arguments[0]; + expect(t.isStringLiteral(arg)).toBe(true); + if (t.isStringLiteral(arg)) { + expect(arg.value).toBe(`${dictionaryPath}?locale=${keyLiteral.value}`); + } + } + }); +}); diff --git a/packages/compiler/src/utils/create-locale-import-map.ts b/packages/compiler/src/utils/create-locale-import-map.ts new file mode 100644 index 000000000..a18c5dc24 --- /dev/null +++ b/packages/compiler/src/utils/create-locale-import-map.ts @@ -0,0 +1,20 @@ +import * as t from "@babel/types"; + +export function createLocaleImportMap( + allLocales: string[], + dictionaryPath: string, +): t.ObjectExpression { + return t.objectExpression( + allLocales.map((locale) => + t.objectProperty( + t.stringLiteral(locale), + t.arrowFunctionExpression( + [], + t.callExpression(t.identifier("import"), [ + t.stringLiteral(`${dictionaryPath}?locale=${locale}`), + ]), + ), + ), + ), + ); +} diff --git a/packages/compiler/src/utils/env.spec.ts b/packages/compiler/src/utils/env.spec.ts new file mode 100644 index 000000000..2cf54701d --- /dev/null +++ b/packages/compiler/src/utils/env.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { isRunningInCIOrDocker } from "./env"; + +vi.mock("fs", () => { + const mockFs = { existsSync: vi.fn(() => false) } as any; + return { ...mockFs, default: mockFs }; +}); + +import fsAny from "fs"; + +describe("isRunningInCIOrDocker", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + (fsAny as any).existsSync.mockReset().mockReturnValue(false); + }); + afterEach(() => { + process.env = originalEnv; + }); + + it("returns true when CI env var is set", () => { + process.env.CI = "true"; + expect(isRunningInCIOrDocker()).toBe(true); + }); + + it("returns true when /.dockerenv exists", () => { + (fsAny as any).existsSync.mockReturnValueOnce(true); + delete process.env.CI; + expect(isRunningInCIOrDocker()).toBe(true); + }); + + it("returns false otherwise", () => { + delete process.env.CI; + (fsAny as any).existsSync.mockReturnValueOnce(false); + expect(isRunningInCIOrDocker()).toBe(false); + }); +}); diff --git a/packages/compiler/src/utils/env.ts b/packages/compiler/src/utils/env.ts new file mode 100644 index 000000000..c68a72b9b --- /dev/null +++ b/packages/compiler/src/utils/env.ts @@ -0,0 +1,11 @@ +import fs from "fs"; + +/** + * Checks if the compiler is running in CI or Docker environment. + * Returns true if either: + * - CI environment variable is set + * - /.dockerenv file exists (indicating Docker environment) + */ +export function isRunningInCIOrDocker(): boolean { + return Boolean(process.env.CI) || fs.existsSync("/.dockerenv"); +} diff --git a/packages/compiler/src/utils/hash.spec.ts b/packages/compiler/src/utils/hash.spec.ts new file mode 100644 index 000000000..3e41fedc9 --- /dev/null +++ b/packages/compiler/src/utils/hash.spec.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { createPayload } from "../_base"; +import { traverse } from "../babel-interop"; +import * as t from "@babel/types"; +import { getJsxElementHash, getJsxAttributeValueHash } from "./hash"; + +function getFirstJsx(pathCode: string) { + const payload = createPayload({ + code: pathCode, + params: {} as any, + relativeFilePath: "x.tsx", + }); + let found: any; + traverse(payload.ast, { + JSXElement(p) { + if (!found) found = p; + }, + }); + return found as any; +} + +describe("utils/hash", () => { + describe("getJsxElementHash", () => { + it("produces a consistent non-empty hash for same input", () => { + const a = getFirstJsx(`const A = () =>
    hello world
    `); + const first = getJsxElementHash(a); + const second = getJsxElementHash(a); + expect(first).toBeTypeOf("string"); + expect(first.length).toBeGreaterThan(0); + expect(second).toEqual(first); + }); + }); + + describe("getJsxAttributeValueHash", () => { + it("attribute hash returns empty for empty string and stable otherwise", () => { + expect(getJsxAttributeValueHash("")).toBe(""); + expect(getJsxAttributeValueHash("x")).toBe(getJsxAttributeValueHash("x")); + }); + }); +}); diff --git a/packages/compiler/src/utils/hash.ts b/packages/compiler/src/utils/hash.ts new file mode 100644 index 000000000..f3f2c9794 --- /dev/null +++ b/packages/compiler/src/utils/hash.ts @@ -0,0 +1,24 @@ +import { NodePath } from "../babel-interop"; +import * as t from "@babel/types"; +import { MD5 } from "object-hash"; + +export function getJsxElementHash(nodePath: NodePath) { + if (!nodePath.node) { + return ""; + } + + const content = (nodePath.node as any).children + .map((child: any) => child.value) + .join(""); + + const result = MD5(content); + return result; +} + +export function getJsxAttributeValueHash(attributeValue: string) { + if (!attributeValue) { + return ""; + } + const result = MD5(attributeValue); + return result; +} diff --git a/packages/compiler/src/utils/index.spec.ts b/packages/compiler/src/utils/index.spec.ts new file mode 100644 index 000000000..3e8a03062 --- /dev/null +++ b/packages/compiler/src/utils/index.spec.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { createPayload, createOutput } from "../_base"; +import * as t from "@babel/types"; +import { + getJsxRoots, + isGoodJsxText, + getOrCreateImport, + hasI18nDirective, + hasClientDirective, + hasServerDirective, + getModuleExecutionMode, +} from "./index"; + +function parse(code: string) { + return createPayload({ code, params: {} as any, relativeFilePath: "x.tsx" }) + .ast as unknown as t.Node; +} + +describe("getOrCreateImport", () => { + it("inserts import when missing and reuses existing import when present", () => { + const ast = parse(`export const X = 1;`); + const res1 = getOrCreateImport(ast, { + exportedName: "Fragment", + moduleName: ["react"], + }); + expect(res1.importedName).toBe("Fragment"); + const code1 = createOutput({ + code: "", + ast, + params: {} as any, + relativeFilePath: "x.tsx", + }).code; + expect(code1).toMatch(/import\s*\{\s*Fragment\s*\}\s*from\s*["']react["']/); + + // Call again should reuse the same import and not duplicate + const res2 = getOrCreateImport(ast, { + exportedName: "Fragment", + moduleName: ["react"], + }); + expect(res2.importedName).toBe("Fragment"); + const code2 = createOutput({ + code: "", + ast, + params: {} as any, + relativeFilePath: "x.tsx", + }).code; + const matches = + code2.match(/import\s*\{\s*Fragment\s*\}\s*from\s*["']react["']/g) || []; + expect(matches.length).toBe(1); + }); +}); + +describe("getJsxRoots", () => { + it("returns only top-level JSX roots", () => { + const ast = parse(`const X = () => (
    Hello
    );`); + const roots = getJsxRoots(ast); + expect(roots.length).toBe(1); + }); +}); + +describe("isGoodJsxText", () => { + it("detects non-empty JSXText", () => { + const payload = createPayload({ + code: `const X = () => (
    Hello
    );`, + params: {} as any, + relativeFilePath: "x.tsx", + }); + let textPath: any; + // locate JSXText + require("@babel/traverse").default(payload.ast, { + JSXText(p: any) { + if (!textPath) textPath = p; + }, + }); + expect(isGoodJsxText(textPath)).toBe(true); + }); +}); + +describe("hasI18nDirective", () => { + it("returns true when file has use i18n directive", () => { + const ast = parse(`"use i18n"; export const X = 1;`); + expect(hasI18nDirective(ast)).toBe(true); + }); + + it("returns false when file does not have use i18n directive", () => { + const ast = parse(`export const X = 1;`); + expect(hasI18nDirective(ast)).toBe(false); + }); +}); + +describe("hasClientDirective", () => { + it("returns true when file has use client directive", () => { + const ast = parse(`"use client"; export const X = 1;`); + expect(hasClientDirective(ast)).toBe(true); + }); + + it("returns false when file does not have use client directive", () => { + expect(hasClientDirective(parse(`const X = 1;`))).toBe(false); + expect(hasClientDirective(parse(`"use server"; const X=1;`))).toBe(false); + }); +}); + +describe("hasServerDirective", () => { + it("returns true when file has use server directive", () => { + const ast = parse(`"use server"; export const X = 1;`); + expect(hasServerDirective(ast)).toBe(true); + }); + + it("returns false when file does not have use server directive", () => { + expect(hasServerDirective(parse(`const X = 1;`))).toBe(false); + expect(hasServerDirective(parse(`"use client"; const X=1;`))).toBe(false); + }); +}); + +describe("getModuleExecutionMode", () => { + it("returns server by default when RSC enabled and no client directive", () => { + const ast = parse(`export const X = 1;`); + expect(getModuleExecutionMode(ast, true)).toBe("server"); + }); + + it("returns client when use client directive present", () => { + const ast = parse(`"use client"; export const X = 1;`); + expect(getModuleExecutionMode(ast, true)).toBe("client"); + }); + + it("returns client when RSC disabled", () => { + const ast = parse(`export const X = 1;`); + expect(getModuleExecutionMode(ast, false)).toBe("client"); + }); +}); diff --git a/packages/compiler/src/utils/index.ts b/packages/compiler/src/utils/index.ts new file mode 100644 index 000000000..8259a8343 --- /dev/null +++ b/packages/compiler/src/utils/index.ts @@ -0,0 +1,247 @@ +import { traverse } from "../babel-interop"; +import * as t from "@babel/types"; +import _ from "lodash"; + +import { NodePath } from "../babel-interop"; +import { getJsxAttributeValue, setJsxAttributeValue } from "./jsx-attribute"; + +// "root" is a JSXElement node that is the root of the JSX tree, +// meaning it doesn't have JSXElement nodes among its ancestors. +export function getJsxRoots(node: t.Node) { + const result: NodePath[] = []; + + // skip traversing the node if it's a root node + traverse(node, { + JSXElement(path: NodePath) { + result.push(path); + path.skip(); + }, + }); + + return result; +} + +export function isGoodJsxText(path: NodePath) { + return path.node.value?.trim() !== ""; +} + +export function getOrCreateImport( + ast: t.Node, + params: { + exportedName: string; + moduleName: string[]; + }, +): { importedName: string } { + let importedName = params.exportedName; + let existingImport = findExistingImport( + ast, + params.exportedName, + params.moduleName, + ); + + if (existingImport) { + return { importedName: existingImport }; + } + + // Find a unique import name if needed + importedName = generateUniqueImportName(ast, params.exportedName); + + // Create the import declaration + createImportDeclaration( + ast, + importedName, + params.exportedName, + params.moduleName, + ); + + return { importedName }; +} + +function findExistingImport( + ast: t.Node, + exportedName: string, + moduleName: string[], +): string | null { + let result: string | null = null; + + traverse(ast, { + ImportDeclaration(path: NodePath) { + if (!moduleName.includes(path.node.source.value)) { + return; + } + + // Skip type-only imports as they can't be used at runtime + if (path.node.importKind === "type") { + return; + } + + for (const specifier of path.node.specifiers) { + if ( + t.isImportSpecifier(specifier) && + // Skip type-only specifiers as they can't be used at runtime + specifier.importKind !== "type" && + ((t.isIdentifier(specifier.imported) && + specifier.imported.name === exportedName) || + (specifier.importKind === "value" && + t.isIdentifier(specifier.local) && + specifier.local.name === exportedName)) + ) { + result = specifier.local.name; + path.stop(); + return; + } + + // Handle default imports (import React from "react") + if (t.isImportDefaultSpecifier(specifier)) { + // For default imports, we can access the exported name via the default import + // e.g., React.Fragment when we have "import React from 'react'" + result = `${specifier.local.name}.${exportedName}`; + path.stop(); + return; + } + + // Handle namespace imports (import * as React from "react") + if (t.isImportNamespaceSpecifier(specifier)) { + // For namespace imports, we can access the exported name via the namespace + // e.g., React.Fragment when we have "import * as React from 'react'" + result = `${specifier.local.name}.${exportedName}`; + path.stop(); + return; + } + } + }, + }); + + return result; +} + +function generateUniqueImportName(ast: t.Node, baseName: string): string { + const usedNames = new Set(); + + // Collect all identifiers in scope + traverse(ast, { + Identifier(path: NodePath) { + usedNames.add(path.node.name); + }, + }); + + // If the base name is available, use it + if (!usedNames.has(baseName)) { + return baseName; + } + + // Otherwise, append a number until we find an unused name + let counter = 1; + let candidateName = `${baseName}${counter}`; + + while (usedNames.has(candidateName)) { + counter++; + candidateName = `${baseName}${counter}`; + } + + return candidateName; +} + +function createImportDeclaration( + ast: t.Node, + localName: string, + exportedName: string, + moduleName: string[], +): void { + traverse(ast, { + Program(path: NodePath) { + // Create the import specifier + const importSpecifier = t.importSpecifier( + t.identifier(localName), + t.identifier(exportedName), + ); + + // Check if we already have a non-type import from this module + const existingImport = path + .get("body") + .find( + (nodePath: NodePath) => + t.isImportDeclaration(nodePath.node) && + moduleName.includes(nodePath.node.source.value) && + nodePath.node.importKind !== "type", + ); + + if (existingImport && t.isImportDeclaration(existingImport.node)) { + // Add to existing import declaration + existingImport.node.specifiers.push(importSpecifier); + } else { + // Create a new import declaration + const importDeclaration = t.importDeclaration( + [importSpecifier], + t.stringLiteral(moduleName[0]), + ); + + // Add it at the top of the file, after any existing imports + const lastImportIndex = findLastImportIndex(path); + path.node.body.splice(lastImportIndex + 1, 0, importDeclaration); + } + + path.stop(); + }, + }); +} + +function findLastImportIndex(programPath: NodePath): number { + const body = programPath.node.body; + + for (let i = body.length - 1; i >= 0; i--) { + if (t.isImportDeclaration(body[i])) { + return i; + } + } + + return -1; +} + +function _hasFileDirective(ast: t.Node, directiveValue: string): boolean { + let hasDirective = false; + + traverse(ast, { + Directive(path: NodePath) { + if (path.node.value.value === directiveValue) { + hasDirective = true; + path.stop(); // Stop traversal as soon as we find the directive + } + }, + }); + + return hasDirective; +} + +export function hasI18nDirective(ast: t.Node): boolean { + return _hasFileDirective(ast, "use i18n"); +} + +export function hasClientDirective(ast: t.Node): boolean { + return _hasFileDirective(ast, "use client"); +} + +export function hasServerDirective(ast: t.Node): boolean { + return _hasFileDirective(ast, "use server"); +} + +export function getModuleExecutionMode( + ast: t.Node, + rscEnabled: boolean, +): "client" | "server" { + // if rscEnabled is true, then server mode is the default + // if rscEnabled is false, then client mode is the default + // default mode is when there is no directive + + if (rscEnabled) { + if (hasClientDirective(ast)) { + return "client"; + } else { + return "server"; + } + } else { + return "client"; + } +} + +export { getJsxAttributeValue, setJsxAttributeValue }; diff --git a/packages/compiler/src/utils/invokations.spec.ts b/packages/compiler/src/utils/invokations.spec.ts new file mode 100644 index 000000000..e8aeafb9b --- /dev/null +++ b/packages/compiler/src/utils/invokations.spec.ts @@ -0,0 +1,131 @@ +import { it, describe, expect } from "vitest"; +import { parse } from "@babel/parser"; +import * as t from "@babel/types"; +import { findInvokations } from "./invokations"; + +describe("findInvokations", () => { + it("should find named import invocation", () => { + const ast = parseCode(` + import { targetFunc } from 'target-module'; + + function test() { + targetFunc(1, 2); + otherFunc(); + } + `); + + const result = findInvokations(ast, { + moduleName: "target-module", + functionName: "targetFunc", + }); + + expect(result.length).toBe(1); + expect(result[0].type).toBe("CallExpression"); + + const callee = result[0].callee as t.Identifier; + expect(callee.name).toBe("targetFunc"); + }); + + it("should find default import invocation", () => { + const ast = parseCode(` + import defaultFunc from 'target-module'; + + function test() { + defaultFunc('test'); + } + `); + + const result = findInvokations(ast, { + moduleName: "target-module", + functionName: "default", + }); + + expect(result.length).toBe(1); + + const callee = result[0].callee as t.Identifier; + expect(callee.name).toBe("defaultFunc"); + }); + + it("should find namespace import invocation", () => { + const ast = parseCode(` + import * as targetModule from 'target-module'; + + function test() { + targetModule.targetFunc(); + targetModule.otherFunc(); + } + `); + + const result = findInvokations(ast, { + moduleName: "target-module", + functionName: "targetFunc", + }); + + expect(result.length).toBe(1); + + const callee = result[0].callee as t.MemberExpression; + expect((callee.object as t.Identifier).name).toBe("targetModule"); + expect((callee.property as t.Identifier).name).toBe("targetFunc"); + }); + + it("should find renamed import invocation", () => { + const ast = parseCode(` + import { targetFunc as renamedFunc } from 'target-module'; + + function test() { + renamedFunc(); + } + `); + + const result = findInvokations(ast, { + moduleName: "target-module", + functionName: "targetFunc", + }); + + expect(result.length).toBe(1); + + const callee = result[0].callee as t.Identifier; + expect(callee.name).toBe("renamedFunc"); + }); + + it("should return empty array when no matching imports exist", () => { + const ast = parseCode(` + import { otherFunc } from 'other-module'; + + function test() { + otherFunc(); + } + `); + + const result = findInvokations(ast, { + moduleName: "target-module", + functionName: "targetFunc", + }); + + expect(result.length).toBe(0); + }); + + it("should return empty array when import exists but not invoked", () => { + const ast = parseCode(` + import { targetFunc } from 'target-module'; + + function test() { + // No invocation here + } + `); + + const result = findInvokations(ast, { + moduleName: "target-module", + functionName: "targetFunc", + }); + + expect(result.length).toBe(0); + }); +}); + +function parseCode(code: string): t.File { + return parse(code, { + sourceType: "module", + plugins: ["typescript"], + }); +} diff --git a/packages/compiler/src/utils/invokations.ts b/packages/compiler/src/utils/invokations.ts new file mode 100644 index 000000000..83d167f76 --- /dev/null +++ b/packages/compiler/src/utils/invokations.ts @@ -0,0 +1,73 @@ +import * as t from "@babel/types"; +import { traverse, NodePath } from "../babel-interop"; + +export function findInvokations( + ast: t.File, + params: { + moduleName: string[]; + functionName: string; + }, +) { + const result: t.CallExpression[] = []; + + traverse(ast, { + ImportDeclaration(path: NodePath) { + if (!params.moduleName.includes(path.node.source.value)) return; + + const importNames = new Map(); + const specifiers = path.node.specifiers; + + specifiers.forEach((specifier: t.ImportSpecifier | t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier) => { + if ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported) && + specifier.imported.name === params.functionName + ) { + importNames.set(specifier.local.name, true); + } else if ( + t.isImportDefaultSpecifier(specifier) && + params.functionName === "default" + ) { + importNames.set(specifier.local.name, true); + } else if (t.isImportNamespaceSpecifier(specifier)) { + importNames.set(specifier.local.name, "namespace"); + } + }); + + collectCallExpressions(path, importNames, result, params.functionName); + }, + }); + + return result; +} + +function collectCallExpressions( + path: NodePath, + importNames: Map, + result: t.CallExpression[], + functionName: string, +) { + const program = path.findParent((p): p is NodePath => + p.isProgram(), + ); + + if (!program) return; + + program.traverse({ + CallExpression(callPath: NodePath) { + const callee = callPath.node.callee; + + if (t.isIdentifier(callee) && importNames.has(callee.name)) { + result.push(callPath.node); + } else if ( + t.isMemberExpression(callee) && + t.isIdentifier(callee.object) && + importNames.get(callee.object.name) === "namespace" && + t.isIdentifier(callee.property) && + callee.property.name === functionName + ) { + result.push(callPath.node); + } + }, + }); +} diff --git a/packages/compiler/src/utils/jsx-attribute-scope.ts b/packages/compiler/src/utils/jsx-attribute-scope.ts new file mode 100644 index 000000000..f1675baa3 --- /dev/null +++ b/packages/compiler/src/utils/jsx-attribute-scope.ts @@ -0,0 +1,121 @@ +import * as t from "@babel/types"; +import { traverse } from "../babel-interop"; +import { NodePath } from "../babel-interop"; + +export function collectJsxAttributeScopes( + node: t.Node, +): Array<[NodePath, string[]]> { + const result: Array<[NodePath, string[]]> = []; + + traverse(node, { + JSXElement(path: NodePath) { + if (!hasJsxAttributeScopeAttribute(path)) return; + + const localizableAttributes = getJsxAttributeScopeAttribute(path); + if (!localizableAttributes) return; + + result.push([path, localizableAttributes]); + }, + }); + + return result; +} + +export function getJsxAttributeScopes( + node: t.Node, +): Array<[NodePath, string[]]> { + const result: Array<[NodePath, string[]]> = []; + + // List of attributes that should be considered localizable + const LOCALIZABLE_ATTRIBUTES = [ + "title", + "aria-label", + "aria-description", + "alt", + "label", + "description", + "placeholder", + "content", + "subtitle", + ]; + + traverse(node, { + JSXElement(path: NodePath) { + const openingElement = path.node.openingElement; + + // Only process lowercase HTML elements (not components) + const elementName = openingElement.name; + if (!t.isJSXIdentifier(elementName) || !elementName.name) { + return; + } + + const hasAttributeScope = openingElement.attributes.find( + (attr) => + t.isJSXAttribute(attr) && + attr.name.name === "data-jsx-attribute-scope", + ); + if (hasAttributeScope) { + return; + } + + // Find all localizable attributes + const localizableAttrs = openingElement.attributes + .filter( + ( + attr: t.JSXAttribute | t.JSXSpreadAttribute, + ): attr is t.JSXAttribute => { + if (!t.isJSXAttribute(attr) || !t.isStringLiteral(attr.value)) { + return false; + } + + const name = attr.name.name; + return ( + typeof name === "string" && LOCALIZABLE_ATTRIBUTES.includes(name) + ); + }, + ) + .map((attr: t.JSXAttribute) => attr.name.name as string); + + // Only add the element if we found localizable attributes + if (localizableAttrs.length > 0) { + result.push([path, localizableAttrs]); + } + }, + }); + + return result; +} + +export function hasJsxAttributeScopeAttribute(path: NodePath) { + return !!getJsxAttributeScopeAttribute(path); +} + +export function getJsxAttributeScopeAttribute(path: NodePath) { + const attribute = path.node.openingElement.attributes.find( + (attr) => + attr.type === "JSXAttribute" && + attr.name.name === "data-jsx-attribute-scope", + ); + + if (!attribute || !t.isJSXAttribute(attribute)) { + return undefined; + } + + // Handle array of string literals + if ( + t.isJSXExpressionContainer(attribute.value) && + t.isArrayExpression(attribute.value.expression) + ) { + const arrayExpr = attribute.value.expression; + return arrayExpr.elements + .filter((el): el is t.StringLiteral => t.isStringLiteral(el)) + .map((el) => el.value); + } + + // Fallback for single string literal + if (t.isStringLiteral(attribute.value)) { + return [attribute.value.value]; + } + + return undefined; +} diff --git a/packages/compiler/src/utils/jsx-attribute.spec.ts b/packages/compiler/src/utils/jsx-attribute.spec.ts new file mode 100644 index 000000000..acf762ffe --- /dev/null +++ b/packages/compiler/src/utils/jsx-attribute.spec.ts @@ -0,0 +1,146 @@ +import * as t from "@babel/types"; +import { traverse, NodePath } from "../babel-interop"; +import { parse } from "@babel/parser"; +import { + getJsxAttributeValue, + setJsxAttributeValue, + getJsxAttributesMap, +} from "./jsx-attribute"; +import { describe, it, expect } from "vitest"; +import { generate } from "../babel-interop"; + +describe("JSX Attribute Value Utils", () => { + function parseJSX(code: string): t.File { + return parse(code, { + sourceType: "module", + plugins: ["jsx", "typescript"], + }); + } + + function generateCode(ast: t.Node): string { + return generate(ast).code; + } + + function getJSXElementPath(code: string): NodePath { + const ast = parseJSX(code); + let elementPath: NodePath | 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("getJsxAttributeValue", () => { + it("should return undefined for non-existent attribute", () => { + const path = getJSXElementPath("
    "); + const value = getJsxAttributeValue(path, "id"); + expect(value).toBeUndefined(); + }); + + it("should return string value for string attribute", () => { + const path = getJSXElementPath("
    "); + const value = getJsxAttributeValue(path, "className"); + expect(value).toBe("test"); + }); + + it("should return boolean value for boolean attribute", () => { + const path = getJSXElementPath("
    "); + const value = getJsxAttributeValue(path, "disabled"); + expect(value).toBe(true); + }); + + it("should return number value for numeric attribute", () => { + const path = getJSXElementPath("
    "); + const value = getJsxAttributeValue(path, "tabIndex"); + expect(value).toBe(5); + }); + }); + + describe("setJsxAttributeValue", () => { + it("should add a new string attribute", () => { + const path = getJSXElementPath("
    "); + setJsxAttributeValue(path, "className", "test"); + + const code = generateCode(path.node); + + expect(code).toBe('
    '); + }); + + it("should update an existing attribute", () => { + const path = getJSXElementPath("
    "); + setJsxAttributeValue(path, "className", "new"); + + const code = generateCode(path.node); + + expect(code).toBe('
    '); + }); + + it("should add a boolean attribute", () => { + const path = getJSXElementPath("
    "); + setJsxAttributeValue(path, "disabled", true); + + const code = generateCode(path.node); + + expect(code).toBe("
    "); + }); + + it("should add a boolean attribute with null for presence-only", () => { + const path = getJSXElementPath("
    "); + setJsxAttributeValue(path, "disabled", null); + + const code = generateCode(path.node); + + expect(code).toBe("
    "); + }); + + it("should handle numeric attributes", () => { + const path = getJSXElementPath("
    "); + setJsxAttributeValue(path, "tabIndex", 5); + + const code = generateCode(path.node); + + expect(code).toBe("
    "); + }); + }); + + describe("getAttributesMap", () => { + it("should return an empty object for elements with no attributes", () => { + const path = getJSXElementPath("
    "); + const attrs = getJsxAttributesMap(path); + expect(attrs).toEqual({}); + }); + + it("should return all attributes as a map", () => { + const path = getJSXElementPath( + "
    ", + ); + const attrs = getJsxAttributesMap(path); + + expect(attrs).toEqual({ + className: "test", + id: "main", + disabled: true, + tabIndex: 5, + }); + }); + + it("should return null for complex expressions", () => { + const path = getJSXElementPath( + "
    ", + ); + const attrs = getJsxAttributesMap(path); + + expect(attrs).toHaveProperty("data-value"); + expect(attrs["data-value"]).toBeNull(); + }); + }); +}); diff --git a/packages/compiler/src/utils/jsx-attribute.ts b/packages/compiler/src/utils/jsx-attribute.ts new file mode 100644 index 000000000..536bfe005 --- /dev/null +++ b/packages/compiler/src/utils/jsx-attribute.ts @@ -0,0 +1,149 @@ +import * as t from "@babel/types"; +import { NodePath } from "../babel-interop"; +import _ from "lodash"; + +/** + * Gets a map of all JSX attributes from a JSX element + * + * @param nodePath The JSX element node path + * @returns A record mapping attribute names to their values + */ +export function getJsxAttributesMap( + nodePath: NodePath, +): Record { + const attributes = nodePath.node.openingElement.attributes; + + return _.reduce( + attributes, + (result, attr) => { + if (attr.type !== "JSXAttribute" || attr.name.type !== "JSXIdentifier") { + return result; + } + + const name = attr.name.name; + const value = extractAttributeValue(attr); + + return { ...result, [name]: value }; + }, + {} as Record, + ); +} + +/** + * Gets the value of a JSX attribute from a JSX element + * + * @param nodePath The JSX element node path + * @param attributeName The name of the attribute to get + * @returns The attribute value or undefined if not found + */ +export function getJsxAttributeValue( + nodePath: NodePath, + attributeName: string, +) { + const attributes = nodePath.node.openingElement.attributes; + const attribute = _.find( + attributes, + (attr): attr is t.JSXAttribute => + attr.type === "JSXAttribute" && + attr.name.type === "JSXIdentifier" && + attr.name.name === attributeName, + ); + + if (!attribute) { + return undefined; + } + + return extractAttributeValue(attribute); +} + +/** + * Sets the value of a JSX attribute on a JSX element + * + * @param nodePath The JSX element node path + * @param attributeName The name of the attribute to set + * @param value The value to set (string, number, boolean, expression, or null for boolean attributes) + */ +export function setJsxAttributeValue( + nodePath: NodePath, + attributeName: string, + value: any, +) { + const attributes = nodePath.node.openingElement.attributes; + const attributeIndex = _.findIndex( + attributes, + (attr) => + attr.type === "JSXAttribute" && + attr.name.type === "JSXIdentifier" && + attr.name.name === attributeName, + ); + + const jsxValue = createAttributeValue(value); + const jsxAttribute = t.jsxAttribute(t.jsxIdentifier(attributeName), jsxValue); + + if (attributeIndex >= 0) { + attributes[attributeIndex] = jsxAttribute; + } else { + attributes.push(jsxAttribute); + } +} + +/** + * Extracts the value from a JSX attribute + */ +function extractAttributeValue(attribute: t.JSXAttribute) { + if (!attribute.value) { + return true; // Boolean attribute + } + + if (attribute.value.type === "StringLiteral") { + return attribute.value.value; + } + + if (attribute.value.type === "JSXExpressionContainer") { + const expression = attribute.value.expression; + + if (expression.type === "BooleanLiteral") { + return expression.value; + } + + if (expression.type === "NumericLiteral") { + return expression.value; + } + + if (expression.type === "StringLiteral") { + return expression.value; + } + } + // We could return the raw expression for other types + return null; +} + +/** + * Creates an appropriate JSX attribute value based on the input value + */ +function createAttributeValue( + value: any, +): t.StringLiteral | t.JSXExpressionContainer | null { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "string") { + return t.stringLiteral(value); + } + + if (typeof value === "boolean") { + return t.jsxExpressionContainer(t.booleanLiteral(value)); + } + + if (typeof value === "number") { + return t.jsxExpressionContainer(t.numericLiteral(value)); + } + + if (t.isExpression(value)) { + return t.jsxExpressionContainer(value); + } + + // For complex objects/arrays, convert to expression + return t.jsxExpressionContainer(t.stringLiteral(JSON.stringify(value))); +} diff --git a/packages/compiler/src/utils/jsx-content-whitespace.spec.ts b/packages/compiler/src/utils/jsx-content-whitespace.spec.ts new file mode 100644 index 000000000..cb6e6a37f --- /dev/null +++ b/packages/compiler/src/utils/jsx-content-whitespace.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { extractJsxContent } from "./jsx-content"; +import * as t from "@babel/types"; +import { traverse, NodePath } from "../babel-interop"; +import { parse } from "@babel/parser"; + +describe("Whitespace Issue Test", () => { + function parseJSX(code: string): t.File { + return parse(code, { + plugins: ["jsx"], + sourceType: "module", + }); + } + + function getJSXElementPath(code: string): NodePath { + const ast = parseJSX(code); + let result: NodePath; + + traverse(ast, { + JSXElement(path) { + result = path; + path.stop(); + }, + }); + + return result!; + } + + it("should preserve leading space in nested elements", () => { + const path = getJSXElementPath(` +

    + Hello World + From Lingo.dev Compiler +

    + `); + + const content = extractJsxContent(path); + console.log("Extracted content:", JSON.stringify(content)); + + // Let's also check the raw JSX structure to understand what's happening + let jsxTexts: string[] = []; + path.traverse({ + JSXText(textPath) { + jsxTexts.push(JSON.stringify(textPath.node.value)); + }, + }); + console.log("JSXText nodes found:", jsxTexts); + + // The span should have " From Lingo.dev Compiler" with the leading space + expect(content).toContain( + " From Lingo.dev Compiler", + ); + }); + + it("should handle explicit whitespace correctly", () => { + const path = getJSXElementPath(` +
    + Hello{" "} + World +
    + `); + + const content = extractJsxContent(path); + console.log("Explicit whitespace test:", JSON.stringify(content)); + + // Should preserve both the explicit space and the leading space in span + expect(content).toContain("Hello World"); + }); + + it("should preserve space before nested bold element like in HeroSubtitle", () => { + const path = getJSXElementPath(` +

    + Localize your React app in every language in minutes. Scale to millions + from day one. +

    + `); + + const content = extractJsxContent(path); + console.log("HeroSubtitle test content:", JSON.stringify(content)); + + // Let's also check the raw JSX structure + let jsxTexts: string[] = []; + path.traverse({ + JSXText(textPath) { + jsxTexts.push(JSON.stringify(textPath.node.value)); + }, + }); + console.log("HeroSubtitle JSXText nodes found:", jsxTexts); + + // The bold element should have " from day one" with the leading space + expect(content).toContain(" from day one"); + // The full content should preserve the space between "millions" and the bold element + expect(content).toContain("millions from day one"); + }); +}); diff --git a/packages/compiler/src/utils/jsx-content.spec.ts b/packages/compiler/src/utils/jsx-content.spec.ts new file mode 100644 index 000000000..ee88f1833 --- /dev/null +++ b/packages/compiler/src/utils/jsx-content.spec.ts @@ -0,0 +1,260 @@ +import * as t from "@babel/types"; +import { traverse, NodePath } from "../babel-interop"; +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 { + const ast = parseJSX(code); + let elementPath: NodePath | 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("
    Hello world
    "); + const content = extractJsxContent(path); + expect(content).toBe("Hello world"); + }); + + it("should return empty string for elements with no content", () => { + const path = getJSXElementPath("
    "); + const content = extractJsxContent(path); + expect(content).toBe(""); + }); + }); + + describe("whitespaces", () => { + it("should handle multiple whitespaces", () => { + const path = getJSXElementPath("
    Hello world
    "); + const content = extractJsxContent(path); + expect(content).toBe("Hello world"); + }); + + it("should handle multi-line content with whitespaces", () => { + const path = getJSXElementPath("
    \n Hello\n crazy world!
    "); + const content = extractJsxContent(path); + expect(content).toBe("Hello crazy world!"); + }); + + it("should handle whitespaces between elements", () => { + const path = getJSXElementPath( + "
    \n Hello crazy world!
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + "Hello crazy world! ", + ); + }); + + it("should handle explicit whitespaces", () => { + const path = getJSXElementPath( + '
    \n Hello{" "}crazy {" "}world
    ', + ); + const content = extractJsxContent(path); + expect(content).toBe( + "Hello crazy world", + ); + }); + + it("should handle new lines between elements and explicit whitespaces", () => { + const path = getJSXElementPath( + '
    \n Hello \n crazy\n world{" "}\nforever
    ', + ); + const content = extractJsxContent(path); + expect(content).toBe( + "Hellocrazyworld forever", + ); + }); + }); + + describe("variables", () => { + it("should extract content with simple identifiers like {count}", () => { + const path = getJSXElementPath("
    Items: {count}
    "); + const content = extractJsxContent(path); + expect(content).toBe("Items: {count}"); + }); + + it("should handle multiple expressions", () => { + const path = getJSXElementPath( + "
    {count} items in {category}
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe("{count} items in {category}"); + }); + + it("should handle nested elements", () => { + const path = getJSXElementPath( + "
    Total: {count} items
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + "Total: {count} items", + ); + }); + + it("should handle object variables", () => { + const path = getJSXElementPath( + "
    User: {user.profile.name} has {user.private.details.items.count} items
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + "User: {user.profile.name} has {user.private.details.items.count} items", + ); + }); + + it("should handle dynamic variables", () => { + const path = getJSXElementPath( + "
    User {data[currentUserType][currentUserIndex].name} has {items.counts[type]} items of type {typeNames[type]}
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + "User {data[currentUserType][currentUserIndex].name} has {items.counts[type]} items of type {typeNames[type]}", + ); + }); + }); + + describe("nested elements", () => { + it("should handle multiple nested elements with correct indices", () => { + const path = getJSXElementPath( + "
    Hello and welcome to my app
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + "Hello and welcome to my app", + ); + }); + + it("should handle deeply nested elements", () => { + const path = getJSXElementPath( + "", + ); + const content = extractJsxContent(path); + expect(content).toBe( + "Hello wonderful verynested world of the universe", + ); + }); + }); + + describe("function calls", () => { + it("should extract function calls with placeholders", () => { + const path = getJSXElementPath( + "
    Hello {getName(user)} you have {getCount()} items
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + "Hello you have items", + ); + }); + + it("should handle mixed function calls and variables", () => { + const path = getJSXElementPath( + "
    {user.name} called {getFunction()} and {getData(user.id)}
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + "{user.name} called and ", + ); + }); + + it("should handle nested elements with function calls and variables", () => { + const path = getJSXElementPath( + '
    {formatName(getName(user))} has {getCount()} unread messages and {count} in total
    ', + ); + const content = extractJsxContent(path); + expect(content).toBe( + " has unread messages and {count} in total", + ); + }); + + it("should handle functions with chained names", () => { + const path = getJSXElementPath( + "
    {getCount()} items: {user.details.products.items.map((item) => item.value).filter(value => value > 0)}
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + " items: ", + ); + }); + + it("should handle multiple usages of the same function", () => { + const path = getJSXElementPath( + "
    {getCount(foo)} is more than {getCount(bar)}
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + " is more than ", + ); + }); + + it("should handle function calls on classes with 'new' keyword", () => { + const path = getJSXElementPath( + "
    © {new Date().getFullYear()} vitest
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe("© vitest"); + }); + }); + + describe("expressions", () => { + it("should handle mixed content with expressions and text", () => { + const path = getJSXElementPath( + "
    You have {count} new messages and {count * 2} total items.
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + "You have {count} new messages and total items.", + ); + }); + + it("should handle complex expressions", () => { + const path = getJSXElementPath( + "
    {isAdmin ? 'Admin' : 'User'} - {items.filter(i => i.active).length > 0}
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe(" - "); + }); + + it("should handle mixed variables, functions and expressions", () => { + const path = getJSXElementPath( + "
    {count + 1} by {user.name}, processed by {getName()} {length > 0}
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + " by {user.name}, processed by ", + ); + }); + + it("should handle expressions in nested elements", () => { + const path = getJSXElementPath( + "

    Count: {items.length + offset}

    Active: {items.filter(i => i.active).length > 0}
    ", + ); + const content = extractJsxContent(path); + expect(content).toBe( + "Count: Active: ", + ); + }); + }); + }); +}); diff --git a/packages/compiler/src/utils/jsx-content.ts b/packages/compiler/src/utils/jsx-content.ts new file mode 100644 index 000000000..d10448bb4 --- /dev/null +++ b/packages/compiler/src/utils/jsx-content.ts @@ -0,0 +1,210 @@ +import { NodePath } from "../babel-interop"; +import * as t from "@babel/types"; +import { getJsxElementName } from "./jsx-element"; +import _ from "lodash"; + +const WHITESPACE_PLACEHOLDER = "[lingo-whitespace-placeholder]"; + +export function extractJsxContent( + nodePath: NodePath, + replaceWhitespacePlaceholders = true, // do not replace when called recursively +) { + const chunks: string[] = []; + + nodePath.traverse({ + JSXElement(path) { + if (path.parent === nodePath.node) { + const content = extractJsxContent(path, false); + const name = getJsxElementName(path); + chunks.push(`${content}`); + path.skip(); + } + }, + JSXText(path) { + chunks.push(path.node.value); + }, + JSXExpressionContainer(path) { + if (path.parent !== nodePath.node) { + return; + } + + const expr = path.node.expression; + if (t.isCallExpression(expr)) { + let key = ""; + if (t.isIdentifier(expr.callee)) { + key = `${expr.callee.name}`; + } else if (t.isMemberExpression(expr.callee)) { + let firstCallee: t.Expression | t.V8IntrinsicIdentifier = expr.callee; + while ( + t.isMemberExpression(firstCallee) && + t.isCallExpression(firstCallee.object) + ) { + firstCallee = firstCallee.object.callee; + } + + let current: t.Expression | t.V8IntrinsicIdentifier = firstCallee; + const parts: string[] = []; + + while (t.isMemberExpression(current)) { + if (t.isIdentifier(current.property)) { + parts.unshift(current.property.name); + } + current = current.object; + } + + if (t.isIdentifier(current)) { + parts.unshift(current.name); + } + + if ( + t.isMemberExpression(firstCallee) && + t.isNewExpression(firstCallee.object) && + t.isIdentifier(firstCallee.object.callee) + ) { + parts.unshift(firstCallee.object.callee.name); + } + + key = parts.join("."); + } + + chunks.push(``); + } else if (t.isIdentifier(expr)) { + chunks.push(`{${expr.name}}`); + } else if (t.isMemberExpression(expr)) { + let current: t.MemberExpression | t.Identifier = expr; + const parts = []; + + while (t.isMemberExpression(current)) { + if (t.isIdentifier(current.property)) { + if (current.computed) { + parts.unshift(`[${current.property.name}]`); + } else { + parts.unshift(current.property.name); + } + } + current = current.object as t.MemberExpression | t.Identifier; + } + + if (t.isIdentifier(current)) { + parts.unshift(current.name); + chunks.push(`{${parts.join(".").replaceAll(".[", "[")}}`); + } + } else if (isWhitespace(path)) { + chunks.push(WHITESPACE_PLACEHOLDER); + } else if (isExpression(path)) { + chunks.push(""); + } + path.skip(); + }, + }); + + const result = chunks.join(""); + const normalized = normalizeJsxWhitespace(result); + + if (replaceWhitespacePlaceholders) { + return normalized.replaceAll(WHITESPACE_PLACEHOLDER, " "); + } + return normalized; +} + +const compilerProps = ["data-jsx-attribute-scope", "data-jsx-scope"]; + +function isExpression(nodePath: NodePath) { + const isCompilerExpression = + !_.isArray(nodePath.container) && + t.isJSXAttribute(nodePath.container) && + t.isJSXIdentifier(nodePath.container.name) && + compilerProps.includes(nodePath.container.name.name); + return ( + !isCompilerExpression && !t.isJSXEmptyExpression(nodePath.node.expression) + ); +} + +function isWhitespace(nodePath: NodePath) { + const expr = nodePath.node.expression; + return t.isStringLiteral(expr) && expr.value === " "; +} + +function normalizeJsxWhitespace(input: string) { + // Handle single-line content + if (!input.includes("\n")) { + // For single-line content, only trim if it appears to be formatting whitespace + // (e.g., " hello world " should be trimmed to "hello world") + // But preserve meaningful leading/trailing spaces (e.g., " hello" should stay " hello") + + // If the content is mostly whitespace with some text, it's likely formatting + const trimmed = input.trim(); + if (trimmed.length === 0) return ""; + + // Check if we have excessive whitespace (more than 1 space on each side) + const leadingMatch = input.match(/^\s*/); + const trailingMatch = input.match(/\s*$/); + const leadingSpaces = leadingMatch ? leadingMatch[0].length : 0; + const trailingSpaces = trailingMatch ? trailingMatch[0].length : 0; + + if (leadingSpaces > 1 || trailingSpaces > 1) { + // This looks like formatting whitespace, collapse it + return input.replace(/\s+/g, " ").trim(); + } else { + // This looks like meaningful whitespace, preserve it but collapse internal spaces + return input.replace(/\s{2,}/g, " "); + } + } + + // Handle multi-line content + const lines = input.split("\n"); + let result = ""; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmedLine = line.trim(); + + // Skip empty lines + if (trimmedLine === "") continue; + + // Check if this line contains a placeholder (explicit whitespace) + if (trimmedLine.includes(WHITESPACE_PLACEHOLDER)) { + // For lines with placeholders, preserve the original spacing + result += trimmedLine; + } else if ( + trimmedLine.startsWith("") + ) { + // When we encounter an element/function/expression + // Add space only when: + // 1. We have existing content AND + // 2. Result doesn't already end with space or placeholder AND + // 3. The result ends with a word character (indicating text) AND + // 4. The element content starts with a space (indicating word continuation) + const shouldAddSpace = + result && + !result.endsWith(" ") && + !result.endsWith(WHITESPACE_PLACEHOLDER) && + /\w$/.test(result) && + // Check if element content starts with space by looking for "> " pattern + trimmedLine.includes("> "); + + if (shouldAddSpace) { + result += " "; + } + result += trimmedLine; + } else { + // For regular text content, ensure proper spacing + // Only add space if the result doesn't already end with a space or placeholder + if ( + result && + !result.endsWith(" ") && + !result.endsWith(WHITESPACE_PLACEHOLDER) + ) { + result += " "; + } + result += trimmedLine; + } + } + + // Collapse multiple spaces but preserve single spaces around placeholders + result = result.replace(/\s{2,}/g, " "); + return result.trim(); +} diff --git a/packages/compiler/src/utils/jsx-element.spec.ts b/packages/compiler/src/utils/jsx-element.spec.ts new file mode 100644 index 000000000..17cc016ad --- /dev/null +++ b/packages/compiler/src/utils/jsx-element.spec.ts @@ -0,0 +1,124 @@ +import * as t from "@babel/types"; +import { traverse, NodePath } from "../babel-interop"; +import { parse } from "@babel/parser"; +import { getJsxElementName, getNestedJsxElements } from "./jsx-element"; +import { describe, it, expect } from "vitest"; +import { generate } from "../babel-interop"; + +function parseJSX(code: string): t.File { + return parse(code, { + sourceType: "module", + plugins: ["jsx", "typescript"], + }); +} + +function getJSXElementPath(code: string): NodePath { + const ast = parseJSX(code); + let elementPath: NodePath | 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("JSX Element Utils", () => { + describe("getJsxElementName", () => { + it("should return element name for simple elements", () => { + const path = getJSXElementPath("
    Hello
    "); + expect(getJsxElementName(path)).toBe("div"); + }); + + it("should return element name for custom elements", () => { + const path = getJSXElementPath("Hello"); + expect(getJsxElementName(path)).toBe("MyComponent"); + }); + + it("should return element name for elements with dots", () => { + const path = getJSXElementPath("Hello"); + expect(getJsxElementName(path)).toBe("My.Component"); + }); + + it("should return element name for elements with multiple dot", () => { + const path = getJSXElementPath( + "Hello", + ); + expect(getJsxElementName(path)).toBe("My.Very.Custom.React.Component"); + }); + }); + + describe("getNestedJsxElements", () => { + it("should transform single nested element into a function", () => { + const path = getJSXElementPath("
    Hello world
    "); + const result = getNestedJsxElements(path); + + expect(result.elements).toHaveLength(1); + const generatedCode = generate(result.elements[0]).code; + expect(generatedCode).toBe(`({ + children +}) => {children}`); + }); + + it("should handle multiple nested elements", () => { + const path = getJSXElementPath( + "
    Hello and welcome to my app
    ", + ); + const result = getNestedJsxElements(path); + + expect(result.elements).toHaveLength(3); + const generatedCodes = result.elements.map((fn) => generate(fn).code); + expect(generatedCodes).toEqual([ + `({ + children +}) => {children}`, + `({ + children +}) => {children}`, + `({ + children +}) => {children}`, + ]); + }); + + it("should handle deeply nested elements", () => { + const path = getJSXElementPath( + "", + ); + const result = getNestedJsxElements(path); + + // expect(result).toHaveLength(4); + const generatedCodes = result.elements.map((fn) => generate(fn).code); + expect(generatedCodes).toEqual([ + `({ + children +}) => {children}`, + `({ + children +}) => {children}`, + `({ + children +}) => {children}`, + `({ + children +}) => {children}`, + `({ + children +}) => {children}`, + ]); + }); + + it("should return empty array for elements with no nested JSX", () => { + const path = getJSXElementPath("
    Hello world
    "); + const result = getNestedJsxElements(path); + expect(result.elements).toHaveLength(0); + }); + }); +}); diff --git a/packages/compiler/src/utils/jsx-element.ts b/packages/compiler/src/utils/jsx-element.ts new file mode 100644 index 000000000..27c5c637b --- /dev/null +++ b/packages/compiler/src/utils/jsx-element.ts @@ -0,0 +1,60 @@ +import * as t from "@babel/types"; +import { NodePath } from "../babel-interop"; + +export function getJsxElementName(nodePath: NodePath) { + const openingElement = nodePath.node.openingElement; + + // elements with simple (string) name + if (t.isJSXIdentifier(openingElement.name)) { + return openingElement.name.name; + } + + // elements with dots in name + if (t.isJSXMemberExpression(openingElement.name)) { + const memberExpr = openingElement.name; + const parts: string[] = []; + + // Traverse the member expression to collect all parts + let current: t.JSXMemberExpression | t.JSXIdentifier = memberExpr; + while (t.isJSXMemberExpression(current)) { + parts.unshift(current.property.name); + current = current.object; + } + + // Add the base identifier + if (t.isJSXIdentifier(current)) { + parts.unshift(current.name); + } + + return parts.join("."); + } + return null; +} + +export function getNestedJsxElements(nodePath: NodePath) { + const nestedElements: t.JSXElement[] = []; + + nodePath.traverse({ + JSXElement(path) { + if (path.node !== nodePath.node) { + nestedElements.push(path.node); + } + }, + }); + + const arrayOfElements = nestedElements.map((element, index) => { + // Create a function that takes children as param and returns the JSX element + const param = t.identifier("children"); + + // Replace the original children with the param + const clonedElement = t.cloneNode(element); + clonedElement.children = [t.jsxExpressionContainer(param)]; + + return t.arrowFunctionExpression( + [t.objectPattern([t.objectProperty(param, param, false, true)])], + clonedElement, + ); + }); + const result = t.arrayExpression(arrayOfElements); + return result; +} diff --git a/packages/compiler/src/utils/jsx-expressions.test.ts b/packages/compiler/src/utils/jsx-expressions.test.ts new file mode 100644 index 000000000..9c820082a --- /dev/null +++ b/packages/compiler/src/utils/jsx-expressions.test.ts @@ -0,0 +1,103 @@ +import { parse } from "@babel/parser"; +import { traverse, NodePath } from "../babel-interop"; +import { generate } from "../babel-interop"; +import { getJsxExpressions } from "./jsx-expressions"; +import { describe, expect, it } from "vitest"; +import * as t from "@babel/types"; + +function parseJSX(code: string) { + return parse(code, { + plugins: ["jsx"], + sourceType: "module", + }); +} + +describe("getJsxExpressions", () => { + it("extracts simple expressions", () => { + const ast = parseJSX("
    {count + 1}
    "); + let result; + + traverse(ast, { + JSXElement(path: NodePath) { + result = getJsxExpressions(path); + path.stop(); + }, + }); + + expect(generate(result!).code).toBe("[count + 1]"); + }); + + it("extracts multiple expressions", () => { + const ast = parseJSX('
    {count * 2} items in {category + "foo"}
    '); + let result; + + traverse(ast, { + JSXElement(path: NodePath) { + result = getJsxExpressions(path); + path.stop(); + }, + }); + + expect(generate(result!).code).toBe('[count * 2, category + "foo"]'); + }); + + it("extracts complex expressions", () => { + const ast = parseJSX('
    {isAdmin ? "Admin" : user.role}
    '); + let result; + + traverse(ast, { + JSXElement(path: NodePath) { + result = getJsxExpressions(path); + path.stop(); + }, + }); + + expect(generate(result!).code).toBe('[isAdmin ? "Admin" : user.role]'); + }); + + it("skips variables and member expressions", () => { + const ast = parseJSX("
    {count} items in {category.type}
    "); + let result: t.ArrayExpression | undefined; + + traverse(ast, { + JSXElement(path: NodePath) { + result = getJsxExpressions(path); + path.stop(); + }, + }); + + expect(generate(result!).code).toBe("[]"); + }); + + it("skips function calls", () => { + const ast = parseJSX("
    {getName(user)} has {getCount()} items
    "); + let result: t.ArrayExpression | undefined; + + traverse(ast, { + JSXElement(path: NodePath) { + result = getJsxExpressions(path); + path.stop(); + }, + }); + + expect(generate(result!).code).toBe("[]"); + }); + + it("extracts only expressions while skipping variables and functions", () => { + const ast = parseJSX( + '
    {count + 1} by {user.name}, processed at {new Date().getTime() > 1000 ? "late" : "early"}
    ', + ); + let result: t.ArrayExpression | undefined; + + traverse(ast, { + JSXElement(path: NodePath) { + result = getJsxExpressions(path); + path.stop(); + }, + }); + + expect(generate(result!).code).toBe( + '[count + 1, new Date().getTime() > 1000 ? "late" : "early"]', + ); + }); +}); diff --git a/packages/compiler/src/utils/jsx-expressions.ts b/packages/compiler/src/utils/jsx-expressions.ts new file mode 100644 index 000000000..7cb1d176c --- /dev/null +++ b/packages/compiler/src/utils/jsx-expressions.ts @@ -0,0 +1,29 @@ +import { NodePath } from "../babel-interop"; +import * as t from "@babel/types"; +import { Expression } from "@babel/types"; + +export const getJsxExpressions = (nodePath: NodePath) => { + const expressions: Expression[] = []; + nodePath.traverse({ + JSXOpeningElement(path) { + path.skip(); + }, + JSXExpressionContainer(path) { + const expr = path.node.expression; + + // Skip empty expressions, identifiers (variables), member expressions (object paths), and function calls + if ( + !t.isJSXEmptyExpression(expr) && + !t.isIdentifier(expr) && + !t.isMemberExpression(expr) && + !t.isCallExpression(expr) && + !(t.isStringLiteral(expr) && expr.value === " ") // whitespace + ) { + expressions.push(expr); + } + path.skip(); + }, + }); + + return t.arrayExpression(expressions); +}; diff --git a/packages/compiler/src/utils/jsx-functions.spec.ts b/packages/compiler/src/utils/jsx-functions.spec.ts new file mode 100644 index 000000000..02e68edae --- /dev/null +++ b/packages/compiler/src/utils/jsx-functions.spec.ts @@ -0,0 +1,125 @@ +import { parse } from "@babel/parser"; +import { traverse } from "../babel-interop"; +import { generate } from "../babel-interop"; +import { getJsxFunctions } from "./jsx-functions"; +import { describe, expect, it } from "vitest"; + +function parseJSX(code: string) { + return parse(code, { + plugins: ["jsx"], + sourceType: "module", + }); +} + +describe("getJsxFunctions", () => { + it("extracts simple function calls", () => { + const ast = parseJSX("
    {getName()}
    "); + let result; + + traverse(ast, { + JSXElement(path) { + result = getJsxFunctions(path); + path.stop(); + }, + }); + + expect(generate(result).code).toBe('{\n "getName": [getName()]\n}'); + }); + + it("extracts function calls with arguments", () => { + const ast = parseJSX("
    {getName(user, 123)}
    "); + let result; + + traverse(ast, { + JSXElement(path) { + result = getJsxFunctions(path); + path.stop(); + }, + }); + + expect(generate(result).code).toBe( + '{\n "getName": [getName(user, 123)]\n}', + ); + }); + + it("extracts multiple function calls", () => { + const ast = parseJSX("
    {getName(user)} {getCount()}
    "); + let result; + + traverse(ast, { + JSXElement(path) { + result = getJsxFunctions(path); + path.stop(); + }, + }); + + expect(generate(result).code).toBe( + '{\n "getName": [getName(user)],\n "getCount": [getCount()]\n}', + ); + }); + + it("ignores non-function expressions", () => { + const ast = parseJSX("
    {user.name} {getCount()}
    "); + let result; + + traverse(ast, { + JSXElement(path) { + result = getJsxFunctions(path); + path.stop(); + }, + }); + + expect(generate(result).code).toBe('{\n "getCount": [getCount()]\n}'); + }); + + it("extracts function with chained names", () => { + const ast = parseJSX( + "
    {getCount()} {user.details.products.items.map((item) => item.value).filter(value => value > 0)}
    ", + ); + let result; + + traverse(ast, { + JSXElement(path) { + result = getJsxFunctions(path); + path.stop(); + }, + }); + + expect(generate(result!).code).toBe( + '{\n "getCount": [getCount()],\n "user.details.products.items.map": [user.details.products.items.map(item => item.value).filter(value => value > 0)]\n}', + ); + }); + + it("extracts multiple usages of the same function", () => { + const ast = parseJSX( + "
    {getCount(foo)} is more than {getCount(bar)} but less than {getCount(baz)}
    ", + ); + let result; + + traverse(ast, { + JSXElement(path) { + result = getJsxFunctions(path); + path.stop(); + }, + }); + + expect(generate(result!).code).toBe( + '{\n "getCount": [getCount(foo), getCount(bar), getCount(baz)]\n}', + ); + }); + + it("should extract function calls on classes with 'new' keyword", () => { + const ast = parseJSX("
    © {new Date().getFullYear()} vitest
    "); + let result; + traverse(ast, { + JSXElement(path) { + result = getJsxFunctions(path); + path.stop(); + }, + }); + + expect(generate(result!).code).toBe( + '{\n "Date.getFullYear": [new Date().getFullYear()]\n}', + ); + }); +}); diff --git a/packages/compiler/src/utils/jsx-functions.ts b/packages/compiler/src/utils/jsx-functions.ts new file mode 100644 index 000000000..842a132d6 --- /dev/null +++ b/packages/compiler/src/utils/jsx-functions.ts @@ -0,0 +1,65 @@ +import { NodePath } from "../babel-interop"; +import * as t from "@babel/types"; +import { Expression, V8IntrinsicIdentifier } from "@babel/types"; + +export const getJsxFunctions = (nodePath: NodePath) => { + const functions = new Map(); + let fnCounter = 0; + + nodePath.traverse({ + JSXOpeningElement(path) { + path.skip(); + }, + JSXExpressionContainer(path) { + if (t.isCallExpression(path.node.expression)) { + let key = ""; + if (t.isIdentifier(path.node.expression.callee)) { + key = `${path.node.expression.callee.name}`; + } else if (t.isMemberExpression(path.node.expression.callee)) { + let firstCallee: Expression | V8IntrinsicIdentifier = + path.node.expression.callee; + while ( + t.isMemberExpression(firstCallee) && + t.isCallExpression(firstCallee.object) + ) { + firstCallee = firstCallee.object.callee; + } + + let current: Expression | V8IntrinsicIdentifier = firstCallee; + const parts: string[] = []; + + while (t.isMemberExpression(current)) { + if (t.isIdentifier(current.property)) { + parts.unshift(current.property.name); + } + current = current.object; + } + + if (t.isIdentifier(current)) { + parts.unshift(current.name); + } + + if ( + t.isMemberExpression(firstCallee) && + t.isNewExpression(firstCallee.object) && + t.isIdentifier(firstCallee.object.callee) + ) { + parts.unshift(firstCallee.object.callee.name); + } + + key = parts.join("."); + } + const existing = functions.get(key) ?? []; + functions.set(key, [...existing, path.node.expression]); + fnCounter++; + } + path.skip(); + }, + }); + + const properties = Array.from(functions.entries()).map(([name, callExpr]) => + t.objectProperty(t.stringLiteral(name), t.arrayExpression(callExpr)), + ); + + return t.objectExpression(properties); +}; diff --git a/packages/compiler/src/utils/jsx-scope.spec.ts b/packages/compiler/src/utils/jsx-scope.spec.ts new file mode 100644 index 000000000..9fc9ba9d7 --- /dev/null +++ b/packages/compiler/src/utils/jsx-scope.spec.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { parse } from "@babel/parser"; +import { traverse, NodePath } from "../babel-interop"; +import * as t from "@babel/types"; +import { + collectJsxScopes, + getJsxScopes, + hasJsxScopeAttribute, + getJsxScopeAttribute, +} from "./jsx-scope"; + +function parseJSX(code: string): t.File { + return parse(code, { + sourceType: "module", + plugins: ["jsx", "typescript"], + }); +} + +function getJSXElementPaths(ast: t.File): NodePath[] { + const paths: NodePath[] = []; + traverse(ast, { + JSXElement(path) { + paths.push(path); + }, + }); + return paths; +} + +describe("jsx-scope utils", () => { + describe("collectJsxScopes", () => { + it("collects elements with data-jsx-scope attribute", () => { + const ast = parseJSX(` +
    + A + B +
    C
    +
    + `); + const scopes = collectJsxScopes(ast); + expect(scopes).toHaveLength(2); + expect(getJsxScopeAttribute(scopes[0])).toBe("foo"); + expect(getJsxScopeAttribute(scopes[1])).toBe("bar"); + }); + it("returns empty if no elements have data-jsx-scope", () => { + const ast = parseJSX(`
    A
    `); + const scopes = collectJsxScopes(ast); + expect(scopes).toHaveLength(0); + }); + }); + + describe("getJsxScopes", () => { + it("finds elements with non-empty JSXText children and no non-empty siblings", () => { + const ast = parseJSX(` +
    + Text + +
    +
    + Text and Bold +
    +

    + Text here +

    +
    + `); + const scopes = getJsxScopes(ast); + const scopeNames = scopes.map( + (scope) => (scope.node.openingElement.name as t.JSXIdentifier).name, + ); + expect(scopes).toHaveLength(3); + expect(scopeNames).toEqual(["span", "div", "p"]); + }); + it("skips LingoProvider component", () => { + const ast = parseJSX(` +
    + ShouldSkip + Text +
    + `); + const scopes = getJsxScopes(ast); + expect(scopes).toHaveLength(1); + expect((scopes[0].node.openingElement.name as t.JSXIdentifier).name).toBe( + "span", + ); + }); + }); + + describe("hasJsxScopeAttribute", () => { + it("returns true if data-jsx-scope attribute exists", () => { + const ast = parseJSX(`
    A
    `); + const [path] = getJSXElementPaths(ast); + expect(hasJsxScopeAttribute(path)).toBe(true); + }); + it("returns false if data-jsx-scope attribute does not exist", () => { + const ast = parseJSX(`
    A
    `); + const [path] = getJSXElementPaths(ast); + expect(hasJsxScopeAttribute(path)).toBe(false); + }); + }); + + describe("getJsxScopeAttribute", () => { + it("returns the value of data-jsx-scope attribute", () => { + const ast = parseJSX(`
    B
    `); + const [path] = getJSXElementPaths(ast); + expect(getJsxScopeAttribute(path)).toBe("bar"); + }); + it("returns undefined if data-jsx-scope attribute does not exist", () => { + const ast = parseJSX(`
    B
    `); + const [path] = getJSXElementPaths(ast); + expect(getJsxScopeAttribute(path)).toBeUndefined(); + }); + }); +}); diff --git a/packages/compiler/src/utils/jsx-scope.ts b/packages/compiler/src/utils/jsx-scope.ts new file mode 100644 index 000000000..9e07f48f4 --- /dev/null +++ b/packages/compiler/src/utils/jsx-scope.ts @@ -0,0 +1,74 @@ +import { NodePath } from "../babel-interop"; +import * as t from "@babel/types"; +import { traverse } from "../babel-interop"; +import { getJsxElementName } from "./jsx-element"; + +export function collectJsxScopes(ast: t.Node) { + const jsxScopes: NodePath[] = []; + + traverse(ast, { + JSXElement: (path: NodePath) => { + if (!hasJsxScopeAttribute(path)) return; + + path.skip(); + jsxScopes.push(path); + }, + }); + + return jsxScopes; +} + +export function getJsxScopes(node: t.Node) { + const result: NodePath[] = []; + + traverse(node, { + JSXElement(path: NodePath) { + // Skip if the element is LingoProvider + if (getJsxElementName(path) === "LingoProvider") { + return; + } + // Check if element has any non-empty JSXText siblings + const hasNonEmptyTextSiblings = path + .getAllPrevSiblings() + .concat(path.getAllNextSiblings()) + .some( + (sibling: NodePath) => + t.isJSXText(sibling.node) && sibling.node.value?.trim() !== "", + ); + + if (hasNonEmptyTextSiblings) { + return; + } + + // Check if element has at least one non-empty JSXText DIRECT child + const hasNonEmptyTextChild = path + .get("children") + .some( + (child: NodePath) => t.isJSXText(child.node) && child.node.value?.trim() !== "", + ); + + if (hasNonEmptyTextChild) { + result.push(path); + path.skip(); // Skip traversing children since we found a scope + } + }, + }); + + return result; +} + +export function hasJsxScopeAttribute(path: NodePath) { + return !!getJsxScopeAttribute(path); +} + +export function getJsxScopeAttribute(path: NodePath) { + const attribute = path.node.openingElement.attributes.find( + (attr) => + attr.type === "JSXAttribute" && attr.name.name === "data-jsx-scope", + ); + return attribute && + t.isJSXAttribute(attribute) && + t.isStringLiteral(attribute.value) + ? attribute.value.value + : undefined; +} diff --git a/packages/compiler/src/utils/jsx-variables.spec.ts b/packages/compiler/src/utils/jsx-variables.spec.ts new file mode 100644 index 000000000..f0d196c40 --- /dev/null +++ b/packages/compiler/src/utils/jsx-variables.spec.ts @@ -0,0 +1,156 @@ +import * as t from "@babel/types"; +import { traverse, NodePath } from "../babel-interop"; +import { parse } from "@babel/parser"; +import { getJsxVariables } from "./jsx-variables"; +import { describe, it, expect } from "vitest"; + +describe("JSX Variables Utils", () => { + function parseJSX(code: string): t.File { + return parse(code, { + sourceType: "module", + plugins: ["jsx", "typescript"], + }); + } + + function getJSXElementPath(code: string): NodePath { + const ast = parseJSX(code); + let elementPath: NodePath | 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("getJsxVariables", () => { + it("should extract single variable from JSX element", () => { + const path = getJSXElementPath( + "
    You have {count} new messages.
    ", + ); + const result = getJsxVariables(path); + + expect(result.type).toBe("ObjectExpression"); + expect(result.properties).toHaveLength(1); + + const property = result.properties[0] as t.ObjectProperty; + expect((property.key as t.StringLiteral).value).toBe("count"); + expect((property.value as t.Identifier).name).toBe("count"); + }); + + it("should extract multiple variables from JSX element", () => { + const path = getJSXElementPath("
    {count} items in {category}
    "); + const result = getJsxVariables(path); + + expect(result.type).toBe("ObjectExpression"); + expect(result.properties).toHaveLength(2); + + const propertyNames = result.properties + .map((prop) => (prop as t.ObjectProperty).key as t.StringLiteral) + .map((key) => key.value); + + expect(propertyNames).toContain("count"); + expect(propertyNames).toContain("category"); + }); + + it("should extract variables from nested elements", () => { + const path = getJSXElementPath( + "
    Total: {count} in {category}
    ", + ); + const result = getJsxVariables(path); + + expect(result.type).toBe("ObjectExpression"); + expect(result.properties).toHaveLength(2); + + const propertyNames = result.properties + .map((prop) => (prop as t.ObjectProperty).key as t.StringLiteral) + .map((key) => key.value); + + expect(propertyNames).toContain("count"); + expect(propertyNames).toContain("category"); + }); + + it("should return empty object expression when no variables present", () => { + const path = getJSXElementPath("
    Hello world
    "); + const result = getJsxVariables(path); + + expect(result.type).toBe("ObjectExpression"); + expect(result.properties).toHaveLength(0); + }); + + it("should handle duplicate variables by including them only once", () => { + const path = getJSXElementPath( + "
    {count} items ({count} total)
    ", + ); + const result = getJsxVariables(path); + + expect(result.type).toBe("ObjectExpression"); + expect(result.properties).toHaveLength(1); + + const property = result.properties[0] as t.ObjectProperty; + expect((property.key as t.StringLiteral).value).toBe("count"); + expect((property.value as t.Identifier).name).toBe("count"); + }); + + it("should handle variables from objects", () => { + const path = getJSXElementPath( + "
    user {user.name} has {user.profile.details.private.items.count} items
    ", + ); + const result = getJsxVariables(path); + + expect(result.type).toBe("ObjectExpression"); + expect(result.properties).toHaveLength(2); + + const userNameProperty = result.properties[0] as t.ObjectProperty; + expect((userNameProperty.key as t.StringLiteral).value).toBe("user.name"); + expect((userNameProperty.value as t.Identifier).name).toBe("user.name"); + + const countProperty = result.properties[1] as t.ObjectProperty; + expect((countProperty.key as t.StringLiteral).value).toBe( + "user.profile.details.private.items.count", + ); + expect((countProperty.value as t.Identifier).name).toBe( + "user.profile.details.private.items.count", + ); + }); + + it("should handle nested dynamic vatiables", () => { + const path = getJSXElementPath( + "
    User {data[currentUserType][currentUserIndex].name} has {items.counts[type]} items of type {typeNames[type]}
    ", + ); + const result = getJsxVariables(path); + + expect(result.type).toBe("ObjectExpression"); + expect(result.properties).toHaveLength(3); + + const userNameProperty = result.properties[0] as t.ObjectProperty; + expect((userNameProperty.key as t.StringLiteral).value).toBe( + "data[currentUserType][currentUserIndex].name", + ); + expect((userNameProperty.value as t.Identifier).name).toBe( + "data[currentUserType][currentUserIndex].name", + ); + + const countProperty = result.properties[1] as t.ObjectProperty; + expect((countProperty.key as t.StringLiteral).value).toBe( + "items.counts[type]", + ); + expect((countProperty.value as t.Identifier).name).toBe( + "items.counts[type]", + ); + + const typeProperty = result.properties[2] as t.ObjectProperty; + expect((typeProperty.key as t.StringLiteral).value).toBe( + "typeNames[type]", + ); + expect((typeProperty.value as t.Identifier).name).toBe("typeNames[type]"); + }); + }); +}); diff --git a/packages/compiler/src/utils/jsx-variables.ts b/packages/compiler/src/utils/jsx-variables.ts new file mode 100644 index 000000000..4b625c442 --- /dev/null +++ b/packages/compiler/src/utils/jsx-variables.ts @@ -0,0 +1,59 @@ +import { NodePath } from "../babel-interop"; +import * as t from "@babel/types"; +import { Expression } from "@babel/types"; + +export const getJsxVariables = (nodePath: NodePath) => { + /* + example input: + +
    You have {count} new messages.
    + + example output: + + t.objectExpression([ + t.objectProperty(t.identifier("count"), t.identifier("count")), + ]) + */ + + const variables = new Set(); + + nodePath.traverse({ + JSXOpeningElement(path) { + path.skip(); + }, + JSXExpressionContainer(path) { + if (t.isIdentifier(path.node.expression)) { + variables.add(path.node.expression.name); + } else if (t.isMemberExpression(path.node.expression)) { + // Handle nested expressions like object.property.value + let current: Expression = path.node.expression; + const parts: string[] = []; + + while (t.isMemberExpression(current)) { + if (t.isIdentifier(current.property)) { + if (current.computed) { + parts.unshift(`[${current.property.name}]`); + } else { + parts.unshift(current.property.name); + } + } + current = current.object; + } + + if (t.isIdentifier(current)) { + parts.unshift(current.name); + variables.add(parts.join(".").replaceAll(".[", "[")); + } + } + // Skip traversing inside the expression + path.skip(); + }, + }); + + const properties = Array.from(variables).map((name) => + t.objectProperty(t.stringLiteral(name), t.identifier(name)), + ); + + const result = t.objectExpression(properties); + return result; +}; diff --git a/packages/compiler/src/utils/llm-api-key.ts b/packages/compiler/src/utils/llm-api-key.ts new file mode 100644 index 000000000..2ebc417a0 --- /dev/null +++ b/packages/compiler/src/utils/llm-api-key.ts @@ -0,0 +1,108 @@ +import { getRc } from "./rc"; +import _ from "lodash"; +import * as dotenv from "dotenv"; +import path from "path"; + +// Generic function to retrieve key from process.env, with .env file as fallback +export function getKeyFromEnv(envVarName: string): string | undefined { + if (process.env[envVarName]) { + return process.env[envVarName]; + } + const result = dotenv.config({ + path: [ + path.resolve(process.cwd(), ".env"), + path.resolve(process.cwd(), ".env.local"), + path.resolve(process.cwd(), ".env.development"), + ], + }); + return result?.parsed?.[envVarName]; +} + +// Generic function to retrieve key from .lingodotdevrc file +function getKeyFromRc(rcPath: string): string | undefined { + const rc = getRc(); + const result = _.get(rc, rcPath); + return typeof result === "string" ? result : undefined; +} + +export function getGroqKey() { + return getGroqKeyFromEnv() || getGroqKeyFromRc(); +} + +export function getGroqKeyFromRc() { + return getKeyFromRc("llm.groqApiKey"); +} + +export function getGroqKeyFromEnv() { + return getKeyFromEnv("GROQ_API_KEY"); +} + +export function getLingoDotDevKeyFromEnv() { + return getKeyFromEnv("LINGODOTDEV_API_KEY"); +} + +export function getLingoDotDevKeyFromRc() { + return getKeyFromRc("auth.apiKey"); +} + +export function getLingoDotDevKey() { + return getLingoDotDevKeyFromEnv() || getLingoDotDevKeyFromRc(); +} + +export function getGoogleKey() { + return getGoogleKeyFromEnv() || getGoogleKeyFromRc(); +} + +export function getGoogleKeyFromRc() { + return getKeyFromRc("llm.googleApiKey"); +} + +export function getGoogleKeyFromEnv() { + return getKeyFromEnv("GOOGLE_API_KEY"); +} + +export function getOpenRouterKey() { + return getOpenRouterKeyFromEnv() || getOpenRouterKeyFromRc(); +} +export function getOpenRouterKeyFromRc() { + return getKeyFromRc("llm.openrouterApiKey"); +} +export function getOpenRouterKeyFromEnv() { + return getKeyFromEnv("OPENROUTER_API_KEY"); +} + +export function getMistralKey() { + return getMistralKeyFromEnv() || getMistralKeyFromRc(); +} + +export function getMistralKeyFromRc() { + return getKeyFromRc("llm.mistralApiKey"); +} + +export function getMistralKeyFromEnv() { + return getKeyFromEnv("MISTRAL_API_KEY"); +} + +export function getOpenAIKey() { + return getOpenAIKeyFromEnv() || getOpenAIKeyFromRc(); +} + +export function getOpenAIKeyFromRc() { + return getKeyFromRc("llm.openaiApiKey"); +} + +export function getOpenAIKeyFromEnv() { + return getKeyFromEnv("OPENAI_API_KEY"); +} + +export function getAnthropicKey() { + return getAnthropicKeyFromEnv() || getAnthropicKeyFromRc(); +} + +export function getAnthropicKeyFromRc() { + return getKeyFromRc("llm.anthropicApiKey"); +} + +export function getAnthropicKeyFromEnv() { + return getKeyFromEnv("ANTHROPIC_API_KEY"); +} diff --git a/packages/compiler/src/utils/llm-api-keys.spec.ts b/packages/compiler/src/utils/llm-api-keys.spec.ts new file mode 100644 index 000000000..c6c6b5ca8 --- /dev/null +++ b/packages/compiler/src/utils/llm-api-keys.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as dotenv from "dotenv"; +import * as path from "path"; +import { getKeyFromEnv } from "./llm-api-key"; + +const ORIGINAL_ENV = { ...process.env }; + +vi.mock("dotenv"); + +describe("LLM API keys", () => { + describe("getKeyFromEnv", () => { + beforeEach(() => { + vi.resetModules(); + process.env = { ...ORIGINAL_ENV }; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.restoreAllMocks(); + }); + + it("returns API key from process.env if set", () => { + process.env.FOOBAR_API_KEY = "env-key"; + expect(getKeyFromEnv("FOOBAR_API_KEY")).toBe("env-key"); + }); + + it("returns API key from .env file if not in process.env", () => { + delete process.env.FOOBAR_API_KEY; + const fakeEnv = { FOOBAR_API_KEY: "file-key" }; + const configMock = vi + .mocked(dotenv.config) + .mockImplementation((opts: any) => { + if (opts && opts.processEnv) { + Object.assign(opts.processEnv, fakeEnv); + } + return { parsed: fakeEnv }; + }); + expect(getKeyFromEnv("FOOBAR_API_KEY")).toBe("file-key"); + expect(configMock).toHaveBeenCalledWith({ + path: [ + path.resolve(process.cwd(), ".env"), + path.resolve(process.cwd(), ".env.local"), + path.resolve(process.cwd(), ".env.development"), + ], + }); + }); + + it("returns undefined if no GROQ_API_KEY in env or .env file", () => { + delete process.env.GROQ_API_KEY; + vi.mocked(dotenv.config).mockResolvedValue({ parsed: {} }); + expect(getKeyFromEnv("FOOBAR_API_KEY")).toBeUndefined(); + }); + }); +}); diff --git a/packages/compiler/src/utils/locales.spec.ts b/packages/compiler/src/utils/locales.spec.ts new file mode 100644 index 000000000..709fb421d --- /dev/null +++ b/packages/compiler/src/utils/locales.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { getInvalidLocales, getLocaleModel } from "./locales"; + +describe("utils/locales", () => { + describe("getLocaleModel", () => { + const models = { + "en:es": "groq:llama3", + "en:*": "google:g2", + "*:es": "mistral:m-small", + "*:*": "openrouter:gpt", + }; + + it.each([ + ["en", "es", { provider: "groq", model: "llama3" }], + ["en", "fr", { provider: "google", model: "g2" }], + ["de", "es", { provider: "mistral", model: "m-small" }], + ["de", "fr", { provider: "openrouter", model: "gpt" }], + ])("resolves locales", (sourceLocale, targetLocale, expected) => { + expect(getLocaleModel(models, sourceLocale, targetLocale)).toEqual( + expected, + ); + }); + + it("returns undefined for missing mapping", () => { + expect(getLocaleModel({ "en:es": "groq:llama3" }, "en", "fr")).toEqual({ + provider: undefined, + model: undefined, + }); + }); + + it("returns undefined for invalid value", () => { + expect(getLocaleModel({ "en:fr": "invalidFormat" }, "en", "fr")).toEqual({ + provider: undefined, + model: undefined, + }); + }); + }); + + describe("getInvalidLocales", () => { + it("returns targets with unresolved models", () => { + const models = { "en:es": "groq:llama3", "*:fr": "google:g2" }; + const invalid = getInvalidLocales(models, "en", ["es", "fr", "de"]); + expect(invalid).toEqual(["de"]); + }); + }); +}); diff --git a/packages/compiler/src/utils/locales.ts b/packages/compiler/src/utils/locales.ts new file mode 100644 index 000000000..f7173c3f4 --- /dev/null +++ b/packages/compiler/src/utils/locales.ts @@ -0,0 +1,50 @@ +export function getInvalidLocales( + localeModels: Record, + sourceLocale: string, + targetLocales: string[], +) { + return targetLocales.filter((targetLocale) => { + const { provider, model } = getLocaleModel( + localeModels, + sourceLocale, + targetLocale, + ); + + return provider === undefined || model === undefined; + }); +} + +export function getLocaleModel( + localeModels: Record, + sourceLocale: string, + targetLocale: string, +): { provider?: string; model?: string } { + const localeKeys = [ + `${sourceLocale}:${targetLocale}`, + `*:${targetLocale}`, + `${sourceLocale}:*`, + "*:*", + ]; + const modelKey = localeKeys.find((key) => localeModels.hasOwnProperty(key)); + if (modelKey) { + const value = localeModels[modelKey]; + // Split only on the first colon + const firstColonIndex = value?.indexOf(":"); + + if (value && firstColonIndex !== -1 && firstColonIndex !== undefined) { + const provider = value.substring(0, firstColonIndex); + const model = value.substring(firstColonIndex + 1); + + if (provider && model) { + return { provider, model }; + } + } + + // Fallback for strings without a colon or other issues + const [provider, model] = value?.split(":") || []; + if (provider && model) { + return { provider, model }; + } + } + return { provider: undefined, model: undefined }; +} diff --git a/packages/compiler/src/utils/module-params.spec.ts b/packages/compiler/src/utils/module-params.spec.ts new file mode 100644 index 000000000..b7fbf96a4 --- /dev/null +++ b/packages/compiler/src/utils/module-params.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { parseParametrizedModuleId } from "./module-params"; +import _ from "lodash"; + +describe("parseParametrizedModuleId", () => { + it("should extract the module id without parameters", () => { + const result = parseParametrizedModuleId("test-module"); + + expect(result).toEqual({ + id: "test-module", + params: {}, + }); + }); + + it("should extract the module id with a single parameter", () => { + const result = parseParametrizedModuleId("test-module?key=value"); + + expect(result).toEqual({ + id: "test-module", + params: { key: "value" }, + }); + }); + + it("should extract the module id with multiple parameters", () => { + const result = parseParametrizedModuleId( + "test-module?key1=value1&key2=value2&key3=value3", + ); + + expect(result).toEqual({ + id: "test-module", + params: { + key1: "value1", + key2: "value2", + key3: "value3", + }, + }); + }); + + it("should handle parameters with special characters", () => { + const result = parseParametrizedModuleId( + "test-module?key=value%20with%20spaces&special=%21%40%23", + ); + + expect(result).toEqual({ + id: "test-module", + params: { + key: "value with spaces", + special: "!@#", + }, + }); + }); + + it("should handle module ids with path-like structure", () => { + const result = parseParametrizedModuleId( + "parent/child/module?version=1.0.0", + ); + + expect(result).toEqual({ + id: "parent/child/module", + params: { version: "1.0.0" }, + }); + }); +}); diff --git a/packages/compiler/src/utils/module-params.ts b/packages/compiler/src/utils/module-params.ts new file mode 100644 index 000000000..bcfa64f65 --- /dev/null +++ b/packages/compiler/src/utils/module-params.ts @@ -0,0 +1,7 @@ +export function parseParametrizedModuleId(rawId: string) { + const moduleUri = new URL(rawId, "module://"); + return { + id: moduleUri.pathname.replace(/^\//, ""), + params: Object.fromEntries(moduleUri.searchParams.entries()), + }; +} diff --git a/packages/compiler/src/utils/observability.spec.ts b/packages/compiler/src/utils/observability.spec.ts new file mode 100644 index 000000000..e6fb5f544 --- /dev/null +++ b/packages/compiler/src/utils/observability.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import trackEvent from "./observability"; + +vi.mock("./rc", () => ({ getRc: () => ({ auth: {} }) })); +vi.mock("node-machine-id", () => ({ machineId: async () => "device-123" })); + +// Mock PostHog client used by dynamic import inside trackEvent +const capture = vi.fn(async () => undefined); +const shutdown = vi.fn(async () => undefined); +const PostHogMock = vi.fn(function(_key: string, _cfg: any) { + return { capture, shutdown }; +}); +vi.mock("posthog-node", () => ({ PostHog: PostHogMock })); + +describe("trackEvent", () => { + const originalEnv = { ...process.env }; + afterEach(() => { + process.env = originalEnv; + }); + + it("captures the event with properties", async () => { + await trackEvent("test.event", { foo: "bar" }); + expect(PostHogMock).toHaveBeenCalledTimes(1); + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ + event: "test.event", + properties: expect.objectContaining({ foo: "bar" }), + }), + ); + expect(shutdown).toHaveBeenCalledTimes(1); + }); + + it("skips when DO_NOT_TRACK is set", async () => { + process.env = { ...originalEnv, DO_NOT_TRACK: "1" }; + // Should not throw nor attempt network + await expect(trackEvent("test.event", { a: 1 })).resolves.toBeUndefined(); + }); +}); diff --git a/packages/compiler/src/utils/observability.ts b/packages/compiler/src/utils/observability.ts new file mode 100644 index 000000000..9ee682fb8 --- /dev/null +++ b/packages/compiler/src/utils/observability.ts @@ -0,0 +1,126 @@ +import * as machineIdLib from "node-machine-id"; +import { getRc } from "./rc"; +import { getRepositoryId } from "./repository-id"; + +const TRACKING_VERSION = "2.0"; + +export default async function trackEvent( + event: string, + properties?: Record, +) { + if (process.env.DO_NOT_TRACK === "1") { + return; + } + + try { + const identityInfo = await getDistinctId(); + + if (process.env.DEBUG === "true") { + console.log( + `[Tracking] Event: ${event}, ID: ${identityInfo.distinct_id}, Source: ${identityInfo.distinct_id_source}`, + ); + } + + const { PostHog } = await import("posthog-node"); + const posthog = new PostHog( + "phc_eR0iSoQufBxNY36k0f0T15UvHJdTfHlh8rJcxsfhfXk", + { + host: "https://eu.i.posthog.com", + flushAt: 1, + flushInterval: 0, + }, + ); + + await posthog.capture({ + distinctId: identityInfo.distinct_id, + event, + properties: { + ...properties, + isByokMode: properties?.models !== "lingo.dev", + tracking_version: TRACKING_VERSION, + distinct_id_source: identityInfo.distinct_id_source, + project_id: identityInfo.project_id, + meta: { + version: process.env.npm_package_version, + isCi: process.env.CI === "true", + }, + }, + }); + + await posthog.shutdown(); + } catch (error) { + if (process.env.DEBUG === "true") { + console.error("[Tracking] Error:", error); + } + } +} + +async function getDistinctId(): Promise<{ + distinct_id: string; + distinct_id_source: string; + project_id: string | null; +}> { + const email = await tryGetEmail(); + if (email) { + const projectId = getRepositoryId(); + return { + distinct_id: email, + distinct_id_source: "email", + project_id: projectId, + }; + } + + const repoId = getRepositoryId(); + if (repoId) { + return { + distinct_id: repoId, + distinct_id_source: "git_repo", + project_id: repoId, + }; + } + + const deviceId = `device-${await machineIdLib.machineId()}`; + if (process.env.DEBUG === "true") { + console.warn( + "[Tracking] Using device ID fallback. Consider using git repository for consistent tracking.", + ); + } + return { + distinct_id: deviceId, + distinct_id_source: "device", + project_id: null, + }; +} + +async function tryGetEmail(): Promise { + const rc = getRc(); + const apiKey = process.env.LINGODOTDEV_API_KEY || rc?.auth?.apiKey; + const apiUrl = + process.env.LINGODOTDEV_API_URL || + rc?.auth?.apiUrl || + "https://engine.lingo.dev"; + + if (!apiKey) { + return null; + } + + try { + const res = await fetch(`${apiUrl}/whoami`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + ContentType: "application/json", + }, + }); + if (res.ok) { + const payload = await res.json(); + if (payload?.email) { + return payload.email; + } + } + } catch (err) { + // ignore + } + + return null; +} diff --git a/packages/compiler/src/utils/rc.spec.ts b/packages/compiler/src/utils/rc.spec.ts new file mode 100644 index 000000000..106a44fbb --- /dev/null +++ b/packages/compiler/src/utils/rc.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getRc } from "./rc"; + +vi.mock("os", () => ({ default: { homedir: () => "/home/test" } })); +vi.mock("fs", () => { + const mockFs = { + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + } as any; + return { ...mockFs, default: mockFs }; +}); + +import fsAny from "fs"; + +describe("getRc", () => { + beforeEach(() => { + (fsAny as any).existsSync.mockReset().mockReturnValue(false); + (fsAny as any).readFileSync.mockReset().mockReturnValue(""); + }); + + it("returns empty object when rc file missing", () => { + const data = getRc(); + expect(data).toEqual({}); + }); + + it("parses ini file when present", () => { + (fsAny as any).existsSync.mockReturnValue(true); + (fsAny as any).readFileSync.mockReturnValue("[auth]\napiKey=abc\n"); + const data = getRc(); + expect(data).toHaveProperty("auth.apiKey", "abc"); + }); +}); diff --git a/packages/compiler/src/utils/rc.ts b/packages/compiler/src/utils/rc.ts new file mode 100644 index 000000000..fb4f9d92c --- /dev/null +++ b/packages/compiler/src/utils/rc.ts @@ -0,0 +1,15 @@ +import os from "os"; +import path from "path"; +import fs from "fs"; +import Ini from "ini"; + +export function getRc() { + const settingsFile = ".lingodotdevrc"; + const homedir = os.homedir(); + const settingsFilePath = path.join(homedir, settingsFile); + const content = fs.existsSync(settingsFilePath) + ? fs.readFileSync(settingsFilePath, "utf-8") + : ""; + const data = Ini.parse(content); + return data; +} diff --git a/packages/compiler/src/utils/repository-id.ts b/packages/compiler/src/utils/repository-id.ts new file mode 100644 index 000000000..08b7f511b --- /dev/null +++ b/packages/compiler/src/utils/repository-id.ts @@ -0,0 +1,100 @@ +import { execSync } from "child_process"; +import { createHash } from "crypto"; + +let cachedGitRepoId: string | null | undefined = undefined; + +function hashProjectName(fullPath: string): string { + const parts = fullPath.split("/"); + if (parts.length !== 2) { + return createHash("sha256").update(fullPath).digest("hex").slice(0, 8); + } + + const [org, project] = parts; + const hashedProject = createHash("sha256") + .update(project) + .digest("hex") + .slice(0, 8); + + return `${org}/${hashedProject}`; +} + +export function getRepositoryId(): string | null { + const ciRepoId = getCIRepositoryId(); + if (ciRepoId) return ciRepoId; + + const gitRepoId = getGitRepositoryId(); + if (gitRepoId) return gitRepoId; + + return null; +} + +function getCIRepositoryId(): string | null { + if (process.env.GITHUB_REPOSITORY) { + const hashed = hashProjectName(process.env.GITHUB_REPOSITORY); + return `github:${hashed}`; + } + + if (process.env.CI_PROJECT_PATH) { + const hashed = hashProjectName(process.env.CI_PROJECT_PATH); + return `gitlab:${hashed}`; + } + + if (process.env.BITBUCKET_REPO_FULL_NAME) { + const hashed = hashProjectName(process.env.BITBUCKET_REPO_FULL_NAME); + return `bitbucket:${hashed}`; + } + + return null; +} + +function getGitRepositoryId(): string | null { + if (cachedGitRepoId !== undefined) { + return cachedGitRepoId; + } + + try { + const remoteUrl = execSync("git config --get remote.origin.url", { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }).trim(); + + if (!remoteUrl) { + cachedGitRepoId = null; + return null; + } + + cachedGitRepoId = parseGitUrl(remoteUrl); + return cachedGitRepoId; + } catch { + cachedGitRepoId = null; + return null; + } +} + +function parseGitUrl(url: string): string | null { + const cleanUrl = url.replace(/\.git$/, ""); + + let platform: string | null = null; + if (cleanUrl.includes("github.com")) { + platform = "github"; + } else if (cleanUrl.includes("gitlab.com")) { + platform = "gitlab"; + } else if (cleanUrl.includes("bitbucket.org")) { + platform = "bitbucket"; + } + + const sshMatch = cleanUrl.match(/[@:]([^:/@]+\/[^:/@]+)$/); + const httpsMatch = cleanUrl.match(/\/([^/]+\/[^/]+)$/); + + const repoPath = sshMatch?.[1] || httpsMatch?.[1]; + + if (!repoPath) return null; + + const hashedPath = hashProjectName(repoPath); + + if (platform) { + return `${platform}:${hashedPath}`; + } + + return `git:${hashedPath}`; +} diff --git a/packages/compiler/tsconfig.json b/packages/compiler/tsconfig.json new file mode 100644 index 000000000..3afcad194 --- /dev/null +++ b/packages/compiler/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "allowUnreachableCode": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"] +} diff --git a/packages/compiler/tsup.config.ts b/packages/compiler/tsup.config.ts new file mode 100644 index 000000000..4ed8c10ea --- /dev/null +++ b/packages/compiler/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + clean: true, + target: "esnext", + entry: ["src/index.ts", "src/lingo-turbopack-loader.ts"], + outDir: "build", + format: ["cjs", "esm"], + dts: true, + splitting: true, + shims: true, + outExtension: (ctx) => ({ + js: ctx.format === "cjs" ? ".cjs" : ".mjs", + }), +}); diff --git a/packages/compiler/vitest.config.ts b/packages/compiler/vitest.config.ts new file mode 100644 index 000000000..46f59a9b6 --- /dev/null +++ b/packages/compiler/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["./src/**/*.spec.ts*"], + }, +}); diff --git a/packages/locales/CHANGELOG.md b/packages/locales/CHANGELOG.md new file mode 100644 index 000000000..187d7ec22 --- /dev/null +++ b/packages/locales/CHANGELOG.md @@ -0,0 +1,54 @@ +# @lingo.dev/\_locales + +## 0.3.3 + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +## 0.3.2 + +### Patch Changes + +- [#1711](https://github.com/lingodotdev/lingo.dev/pull/1711) [`40dc1bb`](https://github.com/lingodotdev/lingo.dev/commit/40dc1bbd03633d7046da5580858f728dffdcbf81) Thanks [@vrcprl](https://github.com/vrcprl)! - Fix CLDR loading + +## 0.3.1 + +### Patch Changes + +- [#1667](https://github.com/lingodotdev/lingo.dev/pull/1667) [`1a857bd`](https://github.com/lingodotdev/lingo.dev/commit/1a857bdf76d50afb3024a2437da5fd60e6721bb9) Thanks [@vrcprl](https://github.com/vrcprl)! - Upd NPM workflows + +## 0.3.0 + +### Minor Changes + +- [#1634](https://github.com/lingodotdev/lingo.dev/pull/1634) [`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Pin all dependencies to exact versions to prevent supply chain attacks. Dependencies no longer use caret (^) or tilde (~) ranges, ensuring full control over version updates and requiring explicit review of all dependency changes. + +## 0.2.0 + +### Minor Changes + +- [#1614](https://github.com/lingodotdev/lingo.dev/pull/1614) [`0f6ffbf`](https://github.com/lingodotdev/lingo.dev/commit/0f6ffbf7dafafbead768eb9e52787cb6013aa1c3) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - feat: use ISO 639-3 package for comprehensive language code validation + + Replaces hardcoded list of ISO 639-1 (2-letter) language codes with the comprehensive iso-639-3 package, which includes: + - All ISO 639-1 codes (2-letter, ~184 languages) + - All ISO 639-2 codes (3-letter bibliographic and terminologic) + - All ISO 639-3 codes (3-letter, ~8,000 languages) + + This fixes validation issues with 3-letter language codes like: + - `fil` (Filipino) + - `bar` (Bavarian) + - `nap` (Neapolitan) + - `zgh` (Standard Moroccan Tamazight) + + And many other languages that don't have 2-letter ISO 639-1 codes. + +## 0.1.0 + +### Minor Changes + +- [#1124](https://github.com/lingodotdev/lingo.dev/pull/1124) [`40fa69d`](https://github.com/lingodotdev/lingo.dev/commit/40fa69d9525a18c5861b6cce33262968c511ce5a) Thanks [@leen-neel](https://github.com/leen-neel)! - Implemented locales package from #1080 + - Added new `@lingo.dev/_locales` package for locale management + - Includes locale name parsing and validation utilities + - Provides integration helpers for various frameworks + - Supports locale code normalization and fallback handling diff --git a/packages/locales/README.md b/packages/locales/README.md new file mode 100644 index 000000000..99a0c4a8e --- /dev/null +++ b/packages/locales/README.md @@ -0,0 +1,225 @@ +# @lingo.dev/locales + +A JavaScript package that helps developers work with locale codes (like "en-US" or "zh-Hans-CN") and get country/language names in different languages. + +## Features + +- **Locale Parsing**: Break apart locale strings into language, script, and region components +- **Validation**: Check if locale codes are properly formatted and use real ISO codes +- **Name Resolution**: Get localized names for countries, languages, and scripts in 200+ languages +- **Small Bundle Size**: Core package is ~12KB with on-demand data loading +- **Full TypeScript Support**: Complete type definitions included + +## Installation + +```bash +npm install @lingo.dev/locales +``` + +## Usage + +### Locale Parsing + +```typescript +import { + parseLocale, + getLanguageCode, + getScriptCode, + getRegionCode, +} from "@lingo.dev/locales"; + +// Parse complete locale +parseLocale("en-US"); // { language: "en", region: "US" } +parseLocale("zh-Hans-CN"); // { language: "zh", script: "Hans", region: "CN" } +parseLocale("sr-Cyrl-RS"); // { language: "sr", script: "Cyrl", region: "RS" } + +// Extract individual components +getLanguageCode("en-US"); // "en" +getScriptCode("zh-Hans-CN"); // "Hans" +getRegionCode("en-US"); // "US" +``` + +### Validation + +```typescript +import { + isValidLocale, + isValidLanguageCode, + isValidScriptCode, + isValidRegionCode, +} from "@lingo.dev/locales"; + +// Validate complete locales +isValidLocale("en-US"); // true +isValidLocale("en-FAKE"); // false +isValidLocale("xyz-US"); // false + +// Validate individual components +isValidLanguageCode("en"); // true +isValidLanguageCode("xyz"); // false +isValidScriptCode("Hans"); // true +isValidScriptCode("Fake"); // false +isValidRegionCode("US"); // true +isValidRegionCode("ZZ"); // false +``` + +### Name Resolution (Async) + +```typescript +import { + getCountryName, + getLanguageName, + getScriptName, +} from "@lingo.dev/locales"; + +// Get country names in different languages +await getCountryName("US"); // "United States" +await getCountryName("US", "es"); // "Estados Unidos" +await getCountryName("CN", "fr"); // "Chine" + +// Get language names in different languages +await getLanguageName("en"); // "English" +await getLanguageName("en", "es"); // "inglés" +await getLanguageName("zh", "fr"); // "chinois" + +// Get script names in different languages +await getScriptName("Hans"); // "Simplified Han" +await getScriptName("Hans", "es"); // "han simplificado" +await getScriptName("Latn", "zh"); // "拉丁文" +``` + +## API Reference + +### Parsing Functions + +#### `parseLocale(locale: string): LocaleComponents` + +Breaks apart a locale string into its components. + +**Parameters:** + +- `locale` (string): The locale string to parse + +**Returns:** `LocaleComponents` object with `language`, `script`, and `region` properties + +**Examples:** + +```typescript +parseLocale("en-US"); // { language: "en", region: "US" } +parseLocale("zh-Hans-CN"); // { language: "zh", script: "Hans", region: "CN" } +parseLocale("es"); // { language: "es" } +``` + +#### `getLanguageCode(locale: string): string` + +Extracts just the language part from a locale string. + +#### `getScriptCode(locale: string): string | null` + +Extracts the script part from a locale string. + +#### `getRegionCode(locale: string): string | null` + +Extracts the region/country part from a locale string. + +### Validation Functions + +#### `isValidLocale(locale: string): boolean` + +Checks if a locale string is properly formatted and uses real codes. + +#### `isValidLanguageCode(code: string): boolean` + +Checks if a language code is valid (ISO 639-1). + +#### `isValidScriptCode(code: string): boolean` + +Checks if a script code is valid (ISO 15924). + +#### `isValidRegionCode(code: string): boolean` + +Checks if a region code is valid (ISO 3166-1 alpha-2 or UN M.49). + +### Name Resolution Functions + +#### `getCountryName(countryCode: string, displayLanguage = "en"): Promise` + +Gets a country name in the specified language. + +**Parameters:** + +- `countryCode` (string): The country code (e.g., "US", "CN") +- `displayLanguage` (string, optional): The language to display the name in (default: "en") + +**Returns:** Promise - The localized country name + +#### `getLanguageName(languageCode: string, displayLanguage = "en"): Promise` + +Gets a language name in the specified language. + +#### `getScriptName(scriptCode: string, displayLanguage = "en"): Promise` + +Gets a script name in the specified language. + +## Supported Formats + +The package supports both hyphen (`-`) and underscore (`_`) delimiters: + +- `en-US` or `en_US` → `{ language: "en", region: "US" }` +- `zh-Hans-CN` or `zh_Hans_CN` → `{ language: "zh", script: "Hans", region: "CN" }` + +## Data Sources + +- **Locale parsing**: Uses regex-based parsing with ISO standard validation +- **Name resolution**: Uses Unicode CLDR (Common Locale Data Repository) data +- **Validation**: Uses official ISO 639-1, ISO 15924, and ISO 3166-1 standards + +## Performance + +- **Bundle size**: Core package is ~12KB (ESM) / ~14KB (CJS) +- **Runtime data**: Loaded on-demand from GitHub raw URLs +- **Caching**: In-memory cache to avoid repeated network requests +- **Fallback**: Graceful degradation to English when language data is unavailable + +## Error Handling + +All functions include comprehensive error handling: + +```typescript +try { + parseLocale("invalid"); +} catch (error) { + console.log(error.message); // "Invalid locale format: invalid" +} + +try { + await getCountryName("XX"); +} catch (error) { + console.log(error.message); // "Country code "XX" not found" +} +``` + +## TypeScript Support + +Full TypeScript support with comprehensive type definitions: + +```typescript +interface LocaleComponents { + language: string; + script?: string; + region?: string; +} + +type LocaleDelimiter = "-" | "_"; + +interface ParseResult { + components: LocaleComponents; + delimiter: LocaleDelimiter | null; + isValid: boolean; + error?: string; +} +``` + +## License + +Apache 2.0 diff --git a/packages/locales/package.json b/packages/locales/package.json new file mode 100644 index 000000000..17c041f72 --- /dev/null +++ b/packages/locales/package.json @@ -0,0 +1,43 @@ +{ + "name": "@lingo.dev/_locales", + "version": "0.3.3", + "description": "Lingo.dev locales", + "private": false, + "repository": { + "type": "git", + "url": "https://github.com/lingodotdev/lingo.dev.git", + "directory": "packages/locales" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "type": "module", + "sideEffects": false, + "main": "build/index.cjs", + "module": "build/index.mjs", + "types": "build/index.d.ts", + "files": [ + "build", + "localenames-data" + ], + "scripts": { + "dev": "tsup --watch", + "build": "pnpm typecheck && tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "22.13.5", + "tsup": "8.5.1", + "typescript": "5.9.3", + "vitest": "3.2.4" + }, + "dependencies": { + "iso-639-3": "3.0.1" + } +} diff --git a/packages/locales/src/constants.ts b/packages/locales/src/constants.ts new file mode 100644 index 000000000..1a458195f --- /dev/null +++ b/packages/locales/src/constants.ts @@ -0,0 +1,32 @@ +/** + * Shared constants for locale parsing and validation + */ + +/** + * Regular expression for parsing locale strings + * + * This regex is case-sensitive and expects normalized locale strings: + * - Language code: 2-3 lowercase letters (e.g., "en", "zh", "es") + * - Script code: 4 letters with preserved case (e.g., "Hans", "hans", "Cyrl") + * - Region code: 2-3 uppercase letters or digits (e.g., "US", "CN", "123") + * + * Matches locale strings in the format: language[-_]script?[-_]region? + * + * Groups: + * 1. Language code (2-3 lowercase letters) + * 2. Script code (4 letters, optional) + * 3. Region code (2-3 letters or digits, optional) + * + * Examples: + * - "en" -> language: "en" + * - "en-US" -> language: "en", region: "US" + * - "zh-Hans-CN" -> language: "zh", script: "Hans", region: "CN" + * - "sr_Cyrl_RS" -> language: "sr", script: "Cyrl", region: "RS" + * + * Note: The parser automatically normalizes case before applying this regex: + * - Language codes are converted to lowercase + * - Script codes preserve their original case + * - Region codes are converted to uppercase + */ +export const LOCALE_REGEX = + /^([a-z]{2,3})(?:[-_]([A-Za-z]{4}))?(?:[-_]([A-Z]{2}|[0-9]{3}))?$/; diff --git a/packages/locales/src/index.ts b/packages/locales/src/index.ts new file mode 100644 index 000000000..4d47bbad4 --- /dev/null +++ b/packages/locales/src/index.ts @@ -0,0 +1,25 @@ +// Export types +export type { LocaleComponents, LocaleDelimiter, ParseResult } from "./types"; + +// Export constants +export { LOCALE_REGEX } from "./constants"; + +// Export parsing functions +export { + parseLocale, + parseLocaleWithDetails, + getLanguageCode, + getScriptCode, + getRegionCode, +} from "./parser"; + +// Export validation functions +export { + isValidLocale, + isValidLanguageCode, + isValidScriptCode, + isValidRegionCode, +} from "./validation"; + +// Export async name resolution functions +export { getCountryName, getLanguageName, getScriptName } from "./names"; diff --git a/packages/locales/src/names/index.spec.ts b/packages/locales/src/names/index.spec.ts new file mode 100644 index 000000000..be249820c --- /dev/null +++ b/packages/locales/src/names/index.spec.ts @@ -0,0 +1,329 @@ +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; +const mockLoadLanguageNames = loadLanguageNames as ReturnType; +const mockLoadScriptNames = loadScriptNames as ReturnType; + +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"); + }); +}); diff --git a/packages/locales/src/names/index.ts b/packages/locales/src/names/index.ts new file mode 100644 index 000000000..ca88d9b87 --- /dev/null +++ b/packages/locales/src/names/index.ts @@ -0,0 +1,123 @@ +import { + loadTerritoryNames, + loadLanguageNames, + loadScriptNames, +} from "./loader"; + +/** + * Gets a country name in the specified display language + * + * @param countryCode - The ISO country code (e.g., "US", "CN", "DE") + * @param displayLanguage - The language to display the name in (default: "en") + * @returns Promise - The localized country name + * + * @example + * ```typescript + * // Default English + * await getCountryName("US"); // "United States" + * await getCountryName("CN"); // "China" + * + * // Spanish + * await getCountryName("US", "es"); // "Estados Unidos" + * await getCountryName("CN", "es"); // "China" + * + * // French + * await getCountryName("US", "fr"); // "États-Unis" + * ``` + */ +export async function getCountryName( + countryCode: string, + displayLanguage: string = "en", +): Promise { + if (!countryCode) { + throw new Error("Country code is required"); + } + + const territories = await loadTerritoryNames(displayLanguage); + const name = territories[countryCode.toUpperCase()]; + + if (!name) { + throw new Error(`Country code "${countryCode}" not found`); + } + + return name; +} + +/** + * Gets a language name in the specified display language + * + * @param languageCode - The ISO language code (e.g., "en", "zh", "es") + * @param displayLanguage - The language to display the name in (default: "en") + * @returns Promise - The localized language name + * + * @example + * ```typescript + * // Default English + * await getLanguageName("en"); // "English" + * await getLanguageName("zh"); // "Chinese" + * + * // Spanish + * await getLanguageName("en", "es"); // "inglés" + * await getLanguageName("zh", "es"); // "chino" + * + * // Chinese + * await getLanguageName("en", "zh"); // "英语" + * ``` + */ +export async function getLanguageName( + languageCode: string, + displayLanguage: string = "en", +): Promise { + if (!languageCode) { + throw new Error("Language code is required"); + } + + const languages = await loadLanguageNames(displayLanguage); + const name = languages[languageCode.toLowerCase()]; + + if (!name) { + throw new Error(`Language code "${languageCode}" not found`); + } + + return name; +} + +/** + * Gets a script name in the specified display language + * + * @param scriptCode - The ISO script code (e.g., "Hans", "Hant", "Latn") + * @param displayLanguage - The language to display the name in (default: "en") + * @returns Promise - The localized script name + * + * @example + * ```typescript + * // Default English + * await getScriptName("Hans"); // "Simplified" + * await getScriptName("Hant"); // "Traditional" + * await getScriptName("Latn"); // "Latin" + * + * // Spanish + * await getScriptName("Hans", "es"); // "simplificado" + * await getScriptName("Cyrl", "es"); // "cirílico" + * + * // Chinese + * await getScriptName("Latn", "zh"); // "拉丁文" + * ``` + */ +export async function getScriptName( + scriptCode: string, + displayLanguage: string = "en", +): Promise { + if (!scriptCode) { + throw new Error("Script code is required"); + } + + const scripts = await loadScriptNames(displayLanguage); + const name = scripts[scriptCode]; + + if (!name) { + throw new Error(`Script code "${scriptCode}" not found`); + } + + return name; +} diff --git a/packages/locales/src/names/integration.spec.ts b/packages/locales/src/names/integration.spec.ts new file mode 100644 index 000000000..ef3005b77 --- /dev/null +++ b/packages/locales/src/names/integration.spec.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getCountryName, getLanguageName, getScriptName } from "./index"; + +// Mock the loader functions to return predictable data +vi.mock("./loader", () => ({ + loadTerritoryNames: vi.fn(), + loadLanguageNames: vi.fn(), + loadScriptNames: vi.fn(), +})); + +import { + loadTerritoryNames, + loadLanguageNames, + loadScriptNames, +} from "./loader"; + +const mockLoadTerritoryNames = loadTerritoryNames as ReturnType; +const mockLoadLanguageNames = loadLanguageNames as ReturnType; +const mockLoadScriptNames = loadScriptNames as ReturnType; + +describe("Integration Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getCountryName", () => { + it("should get country names in different languages", async () => { + // Mock data for different languages + mockLoadTerritoryNames + .mockResolvedValueOnce({ US: "United States", CN: "China" }) // en + .mockResolvedValueOnce({ US: "Estados Unidos", CN: "China" }) // es + .mockResolvedValueOnce({ US: "États-Unis", CN: "Chine" }); // 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 normalize country codes to uppercase", async () => { + mockLoadTerritoryNames.mockResolvedValue({ US: "United States" }); + + const result = await getCountryName("us"); + expect(result).toBe("United States"); + }); + + it("should handle loader errors gracefully", async () => { + mockLoadTerritoryNames.mockRejectedValue(new Error("Network error")); + + await expect(getCountryName("US")).rejects.toThrow("Network error"); + }); + }); + + describe("getLanguageName", () => { + it("should get language names in different languages", async () => { + mockLoadLanguageNames + .mockResolvedValueOnce({ en: "English", es: "Spanish" }) // en + .mockResolvedValueOnce({ en: "inglés", es: "español" }) // es + .mockResolvedValueOnce({ en: "anglais", es: "espagnol" }); // fr + + const result1 = await getLanguageName("en", "en"); + const result2 = await getLanguageName("en", "es"); + const result3 = await getLanguageName("en", "fr"); + + expect(result1).toBe("English"); + expect(result2).toBe("inglés"); + expect(result3).toBe("anglais"); + }); + + it("should normalize language codes to lowercase", async () => { + mockLoadLanguageNames.mockResolvedValue({ en: "English" }); + + const result = await getLanguageName("EN"); + expect(result).toBe("English"); + }); + }); + + describe("getScriptName", () => { + it("should get script names in different languages", async () => { + mockLoadScriptNames + .mockResolvedValueOnce({ + Hans: "Simplified Han", + Hant: "Traditional Han", + }) // en + .mockResolvedValueOnce({ + Hans: "han simplificado", + Hant: "han tradicional", + }) // es + .mockResolvedValueOnce({ + Hans: "han simplifié", + Hant: "han traditionnel", + }); // fr + + const result1 = await getScriptName("Hans", "en"); + const result2 = await getScriptName("Hans", "es"); + const result3 = await getScriptName("Hans", "fr"); + + expect(result1).toBe("Simplified Han"); + expect(result2).toBe("han simplificado"); + expect(result3).toBe("han simplifié"); + }); + + it("should preserve script code case", async () => { + mockLoadScriptNames.mockResolvedValue({ + Latn: "Latin", + CYRL: "Cyrillic", + hans: "Simplified Han", + }); + + 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 Han"); + }); + }); + + describe("Error handling", () => { + it("should throw for empty inputs", async () => { + await expect(getCountryName("")).rejects.toThrow( + "Country code is required", + ); + await expect(getLanguageName("")).rejects.toThrow( + "Language code is required", + ); + await expect(getScriptName("")).rejects.toThrow( + "Script code is required", + ); + }); + + it("should throw for null/undefined inputs", async () => { + await expect(getCountryName(null as any)).rejects.toThrow( + "Country code is required", + ); + await expect(getLanguageName(undefined as any)).rejects.toThrow( + "Language code is required", + ); + await expect(getScriptName(null as any)).rejects.toThrow( + "Script code is required", + ); + }); + + it("should throw for unknown codes", async () => { + mockLoadTerritoryNames.mockResolvedValue({ US: "United States" }); + mockLoadLanguageNames.mockResolvedValue({ en: "English" }); + mockLoadScriptNames.mockResolvedValue({ Latn: "Latin" }); + + await expect(getCountryName("XX")).rejects.toThrow( + 'Country code "XX" not found', + ); + await expect(getLanguageName("xx")).rejects.toThrow( + 'Language code "xx" not found', + ); + await expect(getScriptName("Xxxx")).rejects.toThrow( + 'Script code "Xxxx" not found', + ); + }); + }); + + describe("Real-world scenarios", () => { + it("should handle Chinese locale names", async () => { + mockLoadLanguageNames.mockResolvedValue({ + en: "英语", + es: "西班牙语", + fr: "法语", + de: "德语", + }); + + const result1 = await getLanguageName("en", "zh"); + const result2 = await getLanguageName("es", "zh"); + const result3 = await getLanguageName("fr", "zh"); + const result4 = await getLanguageName("de", "zh"); + + expect(result1).toBe("英语"); + expect(result2).toBe("西班牙语"); + expect(result3).toBe("法语"); + expect(result4).toBe("德语"); + }); + + it("should handle Arabic locale names", async () => { + mockLoadTerritoryNames.mockResolvedValue({ + US: "الولايات المتحدة", + GB: "المملكة المتحدة", + FR: "فرنسا", + }); + + const result1 = await getCountryName("US", "ar"); + const result2 = await getCountryName("GB", "ar"); + const result3 = await getCountryName("FR", "ar"); + + expect(result1).toBe("الولايات المتحدة"); + expect(result2).toBe("المملكة المتحدة"); + expect(result3).toBe("فرنسا"); + }); + + it("should handle script variants", async () => { + mockLoadScriptNames.mockResolvedValue({ + Hans: "Simplified Han", + Hant: "Traditional Han", + Latn: "Latin", + Cyrl: "Cyrillic", + Arab: "Arabic", + Deva: "Devanagari", + }); + + const result1 = await getScriptName("Hans"); + const result2 = await getScriptName("Hant"); + const result3 = await getScriptName("Latn"); + const result4 = await getScriptName("Cyrl"); + const result5 = await getScriptName("Arab"); + const result6 = await getScriptName("Deva"); + + expect(result1).toBe("Simplified Han"); + expect(result2).toBe("Traditional Han"); + expect(result3).toBe("Latin"); + expect(result4).toBe("Cyrillic"); + expect(result5).toBe("Arabic"); + expect(result6).toBe("Devanagari"); + }); + }); +}); diff --git a/packages/locales/src/names/loader.ts b/packages/locales/src/names/loader.ts new file mode 100644 index 000000000..a2f788539 --- /dev/null +++ b/packages/locales/src/names/loader.ts @@ -0,0 +1,154 @@ +/** + * Data loader for locale names + * Fetches CLDR data directly from GitHub raw URLs + */ + +// Base URL for CLDR data from GitHub +function getCLDRBaseUrl(): string { + if (typeof process !== "undefined" && process.env?.CLDR_BASE_URL) { + return process.env.CLDR_BASE_URL; + } + return "https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/cldr-localenames-full/main"; +} + +interface NameData { + [key: string]: string; +} + +// Cache for loaded data to avoid repeated fetches +const cache = new Map(); + +/** + * Loads country/territory names for a specific display language + */ +export async function loadTerritoryNames( + displayLanguage: string, +): Promise { + const cacheKey = `territories-${displayLanguage}`; + + if (cache.has(cacheKey)) { + return cache.get(cacheKey)!; + } + + try { + // Fetch from GitHub raw URL + const response = await fetch( + `${getCLDRBaseUrl()}/${displayLanguage}/territories.json`, + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const territories = + data?.main?.[displayLanguage]?.localeDisplayNames?.territories || {}; + cache.set(cacheKey, territories); + return territories; + } catch (error) { + // Fallback to English if the requested language is not available + if (displayLanguage !== "en") { + console.warn( + `Failed to load territory names for ${displayLanguage}, falling back to English`, + ); + return loadTerritoryNames("en"); + } + throw new Error( + `Failed to load territory names for ${displayLanguage}: ${error}`, + ); + } +} + +/** + * Loads language names for a specific display language + */ +export async function loadLanguageNames( + displayLanguage: string, +): Promise { + const cacheKey = `languages-${displayLanguage}`; + + if (cache.has(cacheKey)) { + return cache.get(cacheKey)!; + } + + try { + // Fetch from GitHub raw URL + const response = await fetch( + `${getCLDRBaseUrl()}/${displayLanguage}/languages.json`, + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const languages = + data?.main?.[displayLanguage]?.localeDisplayNames?.languages || {}; + cache.set(cacheKey, languages); + return languages; + } catch (error) { + // Fallback to English if the requested language is not available + if (displayLanguage !== "en") { + console.warn( + `Failed to load language names for ${displayLanguage}, falling back to English`, + ); + return loadLanguageNames("en"); + } + throw new Error( + `Failed to load language names for ${displayLanguage}: ${error}`, + ); + } +} + +/** + * Loads script names for a specific display language + */ +export async function loadScriptNames( + displayLanguage: string, +): Promise { + const cacheKey = `scripts-${displayLanguage}`; + + if (cache.has(cacheKey)) { + return cache.get(cacheKey)!; + } + + try { + // Fetch from GitHub raw URL + const response = await fetch( + `${getCLDRBaseUrl()}/${displayLanguage}/scripts.json`, + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const scripts = + data?.main?.[displayLanguage]?.localeDisplayNames?.scripts || {}; + + // Use longer form for Han scripts to match GitHub issue examples + const enhancedScripts = { ...scripts }; + + // Check for alternative Han script names + if (scripts["Hans-alt-stand-alone"]) { + enhancedScripts.Hans = scripts["Hans-alt-stand-alone"]; + } + if (scripts["Hant-alt-stand-alone"]) { + enhancedScripts.Hant = scripts["Hant-alt-stand-alone"]; + } + + cache.set(cacheKey, enhancedScripts); + return enhancedScripts; + } catch (error) { + // Fallback to English if the requested language is not available + if (displayLanguage !== "en") { + console.warn( + `Failed to load script names for ${displayLanguage}, falling back to English`, + ); + return loadScriptNames("en"); + } + throw new Error( + `Failed to load script names for ${displayLanguage}: ${error}`, + ); + } +} diff --git a/packages/locales/src/parser.spec.ts b/packages/locales/src/parser.spec.ts new file mode 100644 index 000000000..7e695aa31 --- /dev/null +++ b/packages/locales/src/parser.spec.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { + parseLocale, + getLanguageCode, + getScriptCode, + getRegionCode, +} from "./parser"; + +describe("parseLocale", () => { + it("should parse basic language-region locales with hyphen", () => { + expect(parseLocale("en-US")).toEqual({ + language: "en", + region: "US", + }); + }); + + it("should parse basic language-region locales with underscore", () => { + expect(parseLocale("en_US")).toEqual({ + language: "en", + region: "US", + }); + }); + + it("should parse language-script-region locales with hyphen", () => { + expect(parseLocale("zh-Hans-CN")).toEqual({ + language: "zh", + script: "Hans", + region: "CN", + }); + }); + + it("should parse language-script-region locales with underscore", () => { + expect(parseLocale("zh_Hans_CN")).toEqual({ + language: "zh", + script: "Hans", + region: "CN", + }); + }); + + it("should parse language-only locales", () => { + expect(parseLocale("es")).toEqual({ + language: "es", + }); + }); + + it("should parse complex script locales", () => { + expect(parseLocale("sr-Cyrl-RS")).toEqual({ + language: "sr", + script: "Cyrl", + region: "RS", + }); + }); + + it("should handle numeric region codes", () => { + expect(parseLocale("es-419")).toEqual({ + language: "es", + region: "419", + }); + }); + + it("should normalize language to lowercase", () => { + expect(parseLocale("EN-US")).toEqual({ + language: "en", + region: "US", + }); + }); + + it("should normalize region to uppercase", () => { + expect(parseLocale("en-us")).toEqual({ + language: "en", + region: "US", + }); + }); + + it("should preserve script case", () => { + expect(parseLocale("zh-hans-cn")).toEqual({ + language: "zh", + script: "hans", + region: "CN", + }); + }); + + it("should throw error for invalid locale format", () => { + expect(() => parseLocale("invalid")).toThrow( + "Invalid locale format: invalid", + ); + }); + + it("should throw error for empty string", () => { + expect(() => parseLocale("")).toThrow("Locale cannot be empty"); + }); + + it("should throw error for non-string input", () => { + expect(() => parseLocale(null as any)).toThrow("Locale must be a string"); + }); +}); + +describe("getLanguageCode", () => { + it("should extract language code from various formats", () => { + expect(getLanguageCode("en-US")).toBe("en"); + expect(getLanguageCode("zh-Hans-CN")).toBe("zh"); + expect(getLanguageCode("es-MX")).toBe("es"); + expect(getLanguageCode("fr_CA")).toBe("fr"); + expect(getLanguageCode("es")).toBe("es"); + }); +}); + +describe("getScriptCode", () => { + it("should extract script code when present", () => { + expect(getScriptCode("zh-Hans-CN")).toBe("Hans"); + expect(getScriptCode("zh-Hant-TW")).toBe("Hant"); + expect(getScriptCode("sr-Cyrl-RS")).toBe("Cyrl"); + }); + + it("should return null when script is not present", () => { + expect(getScriptCode("en-US")).toBeNull(); + expect(getScriptCode("es")).toBeNull(); + }); +}); + +describe("getRegionCode", () => { + it("should extract region code when present", () => { + expect(getRegionCode("en-US")).toBe("US"); + expect(getRegionCode("zh-Hans-CN")).toBe("CN"); + expect(getRegionCode("fr_CA")).toBe("CA"); + }); + + it("should return null when region is not present", () => { + expect(getRegionCode("es")).toBeNull(); + expect(getRegionCode("zh-Hans")).toBeNull(); + }); +}); diff --git a/packages/locales/src/parser.ts b/packages/locales/src/parser.ts new file mode 100644 index 000000000..e41e86586 --- /dev/null +++ b/packages/locales/src/parser.ts @@ -0,0 +1,187 @@ +import type { LocaleComponents, LocaleDelimiter, ParseResult } from "./types"; +import { LOCALE_REGEX } from "./constants"; + +/** + * Normalizes the case of locale components before parsing + * + * @param locale - The locale string to normalize + * @returns The normalized locale string + * + * @example + * normalizeLocaleCase("EN-US") // "en-US" + * normalizeLocaleCase("en-us") // "en-US" + * normalizeLocaleCase("zh-hans-cn") // "zh-Hans-CN" + */ +function normalizeLocaleCase(locale: string): string { + // Split by either hyphen or underscore + const parts = locale.split(/[-_]/); + + if (parts.length === 1) { + // Language only: normalize to lowercase + return parts[0].toLowerCase(); + } + + if (parts.length === 2) { + // Language-region: normalize language to lowercase, region to uppercase + const language = parts[0].toLowerCase(); + const region = parts[1].toUpperCase(); + return `${language}-${region}`; + } + + if (parts.length === 3) { + // Language-script-region: normalize language to lowercase, preserve script case, region to uppercase + const language = parts[0].toLowerCase(); + const script = parts[1]; // Preserve original case as-is + const region = parts[2].toUpperCase(); + return `${language}-${script}-${region}`; + } + + // For any other number of parts, return as-is + return locale; +} + +/** + * Breaks apart a locale string into its components + * + * @param locale - The locale string to parse + * @returns LocaleComponents object with language, script, and region + * + * @example + * ```typescript + * parseLocale("en-US"); // { language: "en", region: "US" } + * parseLocale("en_US"); // { language: "en", region: "US" } + * parseLocale("zh-Hans-CN"); // { language: "zh", script: "Hans", region: "CN" } + * parseLocale("zh_Hans_CN"); // { language: "zh", script: "Hans", region: "CN" } + * parseLocale("es"); // { language: "es" } + * parseLocale("sr-Cyrl-RS"); // { language: "sr", script: "Cyrl", region: "RS" } + * ``` + */ +export function parseLocale(locale: string): LocaleComponents { + if (typeof locale !== "string") { + throw new Error("Locale must be a string"); + } + + if (!locale.trim()) { + throw new Error("Locale cannot be empty"); + } + + // Normalize case before parsing: + // - Language: convert to lowercase (e.g., "EN" -> "en") + // - Script: preserve case (e.g., "Hans", "hans" -> "Hans") + // - Region: convert to uppercase (e.g., "us" -> "US") + const normalizedLocale = normalizeLocaleCase(locale); + + const match = normalizedLocale.match(LOCALE_REGEX); + + if (!match) { + throw new Error(`Invalid locale format: ${locale}`); + } + + const [, language, script, region] = match; + + const components: LocaleComponents = { + language: language.toLowerCase(), + }; + + // Add script if present + if (script) { + components.script = script; + } + + // Add region if present + if (region) { + components.region = region.toUpperCase(); + } + + return components; +} + +/** + * Parses a locale string and returns detailed information about the parsing result + * + * @param locale - The locale string to parse + * @returns ParseResult with components, delimiter, and validation info + */ +export function parseLocaleWithDetails(locale: string): ParseResult { + try { + const components = parseLocale(locale); + + // Determine the delimiter used + let delimiter: LocaleDelimiter | null = null; + if (locale.includes("-")) { + delimiter = "-"; + } else if (locale.includes("_")) { + delimiter = "_"; + } + + return { + components, + delimiter, + isValid: true, + }; + } catch (error) { + return { + components: { language: "" }, + delimiter: null, + isValid: false, + error: error instanceof Error ? error.message : "Unknown parsing error", + }; + } +} + +/** + * Extracts just the language code from a locale string + * + * @param locale - The locale string to parse + * @returns The language code + * + * @example + * ```typescript + * getLanguageCode("en-US"); // "en" + * getLanguageCode("zh-Hans-CN"); // "zh" + * getLanguageCode("es-MX"); // "es" + * getLanguageCode("fr_CA"); // "fr" + * ``` + */ +export function getLanguageCode(locale: string): string { + return parseLocale(locale).language; +} + +/** + * Extracts the script code from a locale string + * + * @param locale - The locale string to parse + * @returns The script code or null if not present + * + * @example + * ```typescript + * getScriptCode("zh-Hans-CN"); // "Hans" + * getScriptCode("zh-Hant-TW"); // "Hant" + * getScriptCode("sr-Cyrl-RS"); // "Cyrl" + * getScriptCode("en-US"); // null + * getScriptCode("es"); // null + * ``` + */ +export function getScriptCode(locale: string): string | null { + const components = parseLocale(locale); + return components.script || null; +} + +/** + * Extracts the region/country code from a locale string + * + * @param locale - The locale string to parse + * @returns The region code or null if not present + * + * @example + * ```typescript + * getRegionCode("en-US"); // "US" + * getRegionCode("zh-Hans-CN"); // "CN" + * getRegionCode("es"); // null + * getRegionCode("fr_CA"); // "CA" + * ``` + */ +export function getRegionCode(locale: string): string | null { + const components = parseLocale(locale); + return components.region || null; +} diff --git a/packages/locales/src/types.ts b/packages/locales/src/types.ts new file mode 100644 index 000000000..b8cf1b2d3 --- /dev/null +++ b/packages/locales/src/types.ts @@ -0,0 +1,30 @@ +/** + * Represents the components of a locale string + */ +export interface LocaleComponents { + /** The language code (e.g., "en", "zh", "es") */ + language: string; + /** The script code (e.g., "Hans", "Hant", "Cyrl") - optional */ + script?: string; + /** The region/country code (e.g., "US", "CN", "RS") - optional */ + region?: string; +} + +/** + * Locale delimiter types + */ +export type LocaleDelimiter = "-" | "_"; + +/** + * Validation result for locale parsing + */ +export interface ParseResult { + /** The parsed locale components */ + components: LocaleComponents; + /** The delimiter used in the original string */ + delimiter: LocaleDelimiter | null; + /** Whether the locale string was valid */ + isValid: boolean; + /** Error message if parsing failed */ + error?: string; +} diff --git a/packages/locales/src/validation.spec.ts b/packages/locales/src/validation.spec.ts new file mode 100644 index 000000000..6d3ae19b7 --- /dev/null +++ b/packages/locales/src/validation.spec.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from "vitest"; +import { + isValidLocale, + isValidLanguageCode, + isValidScriptCode, + isValidRegionCode, +} from "./validation"; + +describe("isValidLocale", () => { + it("should validate basic language-region locales with hyphen", () => { + expect(isValidLocale("en-US")).toBe(true); + expect(isValidLocale("es-MX")).toBe(true); + expect(isValidLocale("fr-CA")).toBe(true); + }); + + it("should validate basic language-region locales with underscore", () => { + expect(isValidLocale("en_US")).toBe(true); + expect(isValidLocale("es_MX")).toBe(true); + expect(isValidLocale("fr_CA")).toBe(true); + }); + + it("should validate language-script-region locales", () => { + expect(isValidLocale("zh-Hans-CN")).toBe(true); + expect(isValidLocale("zh-Hant-TW")).toBe(true); + expect(isValidLocale("sr-Cyrl-RS")).toBe(true); + }); + + it("should validate language-only locales", () => { + expect(isValidLocale("es")).toBe(true); + expect(isValidLocale("fr")).toBe(true); + expect(isValidLocale("zh")).toBe(true); + }); + + it("should validate 3-letter language codes in locales", () => { + // Test ISO 639-2/3 codes that don't have 2-letter equivalents + expect(isValidLocale("fil")).toBe(true); // Filipino + expect(isValidLocale("fil-PH")).toBe(true); // Filipino (Philippines) + expect(isValidLocale("bar")).toBe(true); // Bavarian + expect(isValidLocale("bar-DE")).toBe(true); // Bavarian (Germany) + expect(isValidLocale("nap")).toBe(true); // Neapolitan + expect(isValidLocale("nap-IT")).toBe(true); // Neapolitan (Italy) + expect(isValidLocale("zgh")).toBe(true); // Standard Moroccan Tamazight + expect(isValidLocale("zgh-MA")).toBe(true); // Tamazight (Morocco) + }); + + it("should validate 3-letter language codes with script and region", () => { + // Test complex locales with 3-letter language codes + expect(isValidLocale("fil-Latn-PH")).toBe(true); // Filipino (Latin, Philippines) + }); + + it("should validate locales with numeric region codes", () => { + expect(isValidLocale("es-419")).toBe(true); // Latin America + expect(isValidLocale("en-001")).toBe(true); // World + }); + + it("should reject invalid locale formats", () => { + expect(isValidLocale("invalid")).toBe(false); + expect(isValidLocale("en-")).toBe(false); + expect(isValidLocale("-US")).toBe(false); + expect(isValidLocale("en-US-")).toBe(false); + }); + + it("should reject locales with invalid language codes", () => { + expect(isValidLocale("xyz-US")).toBe(false); + expect(isValidLocale("fake-CN")).toBe(false); + }); + + it("should reject locales with invalid script codes", () => { + expect(isValidLocale("en-Fake-US")).toBe(false); + expect(isValidLocale("zh-Invalid-CN")).toBe(false); + }); + + it("should reject locales with invalid region codes", () => { + expect(isValidLocale("en-US-FAKE")).toBe(false); + expect(isValidLocale("en-ZZ")).toBe(false); + }); + + it("should handle edge cases", () => { + expect(isValidLocale("")).toBe(false); + expect(isValidLocale(" ")).toBe(false); + expect(isValidLocale(null as any)).toBe(false); + expect(isValidLocale(undefined as any)).toBe(false); + }); +}); + +describe("isValidLanguageCode", () => { + it("should validate common language codes", () => { + expect(isValidLanguageCode("en")).toBe(true); + expect(isValidLanguageCode("es")).toBe(true); + expect(isValidLanguageCode("fr")).toBe(true); + expect(isValidLanguageCode("zh")).toBe(true); + expect(isValidLanguageCode("ar")).toBe(true); + expect(isValidLanguageCode("ja")).toBe(true); + expect(isValidLanguageCode("ko")).toBe(true); + }); + + it("should validate less common language codes", () => { + expect(isValidLanguageCode("aa")).toBe(true); // Afar + expect(isValidLanguageCode("zu")).toBe(true); // Zulu + expect(isValidLanguageCode("yi")).toBe(true); // Yiddish + }); + + it("should validate 3-letter ISO 639-2/3 language codes", () => { + // Test the specific codes that were reported as failing + expect(isValidLanguageCode("fil")).toBe(true); // Filipino + expect(isValidLanguageCode("bar")).toBe(true); // Bavarian + expect(isValidLanguageCode("nap")).toBe(true); // Neapolitan + expect(isValidLanguageCode("zgh")).toBe(true); // Standard Moroccan Tamazight + }); + + it("should validate other common 3-letter language codes", () => { + expect(isValidLanguageCode("eng")).toBe(true); // English (ISO 639-2) + expect(isValidLanguageCode("spa")).toBe(true); // Spanish (ISO 639-2) + expect(isValidLanguageCode("fra")).toBe(true); // French (ISO 639-2) + expect(isValidLanguageCode("deu")).toBe(true); // German (ISO 639-2) + expect(isValidLanguageCode("jpn")).toBe(true); // Japanese (ISO 639-2) + }); + + it("should handle case insensitive validation", () => { + expect(isValidLanguageCode("EN")).toBe(true); + expect(isValidLanguageCode("Es")).toBe(true); + expect(isValidLanguageCode("FR")).toBe(true); + }); + + it("should reject invalid language codes", () => { + expect(isValidLanguageCode("xyz")).toBe(false); + expect(isValidLanguageCode("fake")).toBe(false); + expect(isValidLanguageCode("invalid")).toBe(false); + }); + + it("should reject invalid 3-letter language codes", () => { + // Ensure validation is not just accepting any 3-letter code + // Note: "aaa" is valid (Ghotuo language), so using truly invalid codes + expect(isValidLanguageCode("zzz")).toBe(false); + expect(isValidLanguageCode("xxx")).toBe(false); + expect(isValidLanguageCode("fake")).toBe(false); + expect(isValidLanguageCode("test")).toBe(false); + }); + + it("should handle edge cases", () => { + expect(isValidLanguageCode("")).toBe(false); + expect(isValidLanguageCode(" ")).toBe(false); + expect(isValidLanguageCode(null as any)).toBe(false); + expect(isValidLanguageCode(undefined as any)).toBe(false); + }); +}); + +describe("isValidScriptCode", () => { + it("should validate common script codes", () => { + expect(isValidScriptCode("Hans")).toBe(true); // Simplified Chinese + expect(isValidScriptCode("Hant")).toBe(true); // Traditional Chinese + expect(isValidScriptCode("Latn")).toBe(true); // Latin + expect(isValidScriptCode("Cyrl")).toBe(true); // Cyrillic + expect(isValidScriptCode("Arab")).toBe(true); // Arabic + expect(isValidScriptCode("Hira")).toBe(true); // Hiragana + expect(isValidScriptCode("Kana")).toBe(true); // Katakana + }); + + it("should validate less common script codes", () => { + expect(isValidScriptCode("Adlm")).toBe(true); // Adlam + expect(isValidScriptCode("Zzzz")).toBe(true); // Unknown script + expect(isValidScriptCode("Qaaa")).toBe(true); // Private use + }); + + it("should be case sensitive", () => { + expect(isValidScriptCode("hans")).toBe(false); + expect(isValidScriptCode("HANS")).toBe(false); + expect(isValidScriptCode("Hans")).toBe(true); + }); + + it("should reject invalid script codes", () => { + expect(isValidScriptCode("Fake")).toBe(false); + expect(isValidScriptCode("Invalid")).toBe(false); + expect(isValidScriptCode("XYZ")).toBe(false); + }); + + it("should handle edge cases", () => { + expect(isValidScriptCode("")).toBe(false); + expect(isValidScriptCode(" ")).toBe(false); + expect(isValidScriptCode(null as any)).toBe(false); + expect(isValidScriptCode(undefined as any)).toBe(false); + }); +}); + +describe("isValidRegionCode", () => { + it("should validate common country codes", () => { + expect(isValidRegionCode("US")).toBe(true); + expect(isValidRegionCode("CN")).toBe(true); + expect(isValidRegionCode("GB")).toBe(true); + expect(isValidRegionCode("DE")).toBe(true); + expect(isValidRegionCode("FR")).toBe(true); + expect(isValidRegionCode("JP")).toBe(true); + expect(isValidRegionCode("KR")).toBe(true); + }); + + it("should validate numeric region codes", () => { + expect(isValidRegionCode("419")).toBe(true); // Latin America + expect(isValidRegionCode("001")).toBe(true); // World + expect(isValidRegionCode("142")).toBe(true); // Asia + expect(isValidRegionCode("150")).toBe(true); // Europe + }); + + it("should handle case insensitive validation", () => { + expect(isValidRegionCode("us")).toBe(true); + expect(isValidRegionCode("cn")).toBe(true); + expect(isValidRegionCode("gb")).toBe(true); + }); + + it("should reject invalid region codes", () => { + expect(isValidRegionCode("ZZ")).toBe(false); + expect(isValidRegionCode("FAKE")).toBe(false); + expect(isValidRegionCode("INVALID")).toBe(false); + }); + + it("should handle edge cases", () => { + expect(isValidRegionCode("")).toBe(false); + expect(isValidRegionCode(" ")).toBe(false); + expect(isValidRegionCode(null as any)).toBe(false); + expect(isValidRegionCode(undefined as any)).toBe(false); + }); +}); diff --git a/packages/locales/src/validation.ts b/packages/locales/src/validation.ts new file mode 100644 index 000000000..ca9cdec58 --- /dev/null +++ b/packages/locales/src/validation.ts @@ -0,0 +1,687 @@ +import { LOCALE_REGEX } from "./constants"; +import { iso6393, type Language } from "iso-639-3"; + +/** + * Validation functions for locale codes and components + */ + +// Create a set of all valid ISO 639-1, 639-2, and 639-3 language codes +// This includes 2-letter codes (ISO 639-1) and 3-letter codes (ISO 639-2/3) +const VALID_LANGUAGE_CODES = new Set( + iso6393.flatMap((lang: Language) => + [ + lang.iso6391, // 2-letter code (ISO 639-1) + lang.iso6392B, // 3-letter bibliographic code (ISO 639-2) + lang.iso6392T, // 3-letter terminologic code (ISO 639-2) + lang.iso6393, // 3-letter code (ISO 639-3) + ] + .filter((code): code is string => Boolean(code)) + .map((code) => code.toLowerCase()), + ), +); + + +// 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) + ); +} diff --git a/packages/locales/tsconfig.json b/packages/locales/tsconfig.json new file mode 100644 index 000000000..0aa38098b --- /dev/null +++ b/packages/locales/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "allowUnreachableCode": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/packages/locales/tsup.config.ts b/packages/locales/tsup.config.ts new file mode 100644 index 000000000..4186997e1 --- /dev/null +++ b/packages/locales/tsup.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + clean: true, + target: "esnext", + entry: ["src/index.ts"], + outDir: "build", + format: ["cjs", "esm"], + dts: true, + cjsInterop: true, + splitting: false, + // Bundle iso-639-3 since it's ESM-only and can't be required in CJS + noExternal: ["iso-639-3"], + outExtension: (ctx) => ({ + js: ctx.format === "cjs" ? ".cjs" : ".mjs", + }), +}); diff --git a/packages/logging/CHANGELOG.md b/packages/logging/CHANGELOG.md new file mode 100644 index 000000000..ac1130827 --- /dev/null +++ b/packages/logging/CHANGELOG.md @@ -0,0 +1,25 @@ +# @lingo.dev/\_logging + +## 0.3.2 + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +## 0.3.1 + +### Patch Changes + +- [#1667](https://github.com/lingodotdev/lingo.dev/pull/1667) [`1a857bd`](https://github.com/lingodotdev/lingo.dev/commit/1a857bdf76d50afb3024a2437da5fd60e6721bb9) Thanks [@vrcprl](https://github.com/vrcprl)! - Upd NPM workflows + +## 0.3.0 + +### Minor Changes + +- [#1634](https://github.com/lingodotdev/lingo.dev/pull/1634) [`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Pin all dependencies to exact versions to prevent supply chain attacks. Dependencies no longer use caret (^) or tilde (~) ranges, ensuring full control over version updates and requiring explicit review of all dependency changes. + +## 0.2.0 + +### Minor Changes + +- [#1226](https://github.com/lingodotdev/lingo.dev/pull/1226) [`bcdc11c`](https://github.com/lingodotdev/lingo.dev/commit/bcdc11c9d508e0156e289489365f0e6f85b13ba8) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Add production-ready logging infrastructure with automatic log rotation and error resilience. Implements Pino-based logging with rotating file streams, smart directory detection, and graceful fallback handling to ensure CLI stability even when logging fails. diff --git a/packages/logging/package.json b/packages/logging/package.json new file mode 100644 index 000000000..62d3ef4fe --- /dev/null +++ b/packages/logging/package.json @@ -0,0 +1,44 @@ +{ + "name": "@lingo.dev/_logging", + "version": "0.3.2", + "description": "Lingo.dev logging utilities with Pino", + "private": false, + "repository": { + "type": "git", + "url": "https://github.com/lingodotdev/lingo.dev.git", + "directory": "packages/logging" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "type": "module", + "sideEffects": false, + "types": "build/index.d.ts", + "module": "build/index.mjs", + "main": "build/index.cjs", + "files": [ + "build" + ], + "scripts": { + "dev": "tsup --watch", + "build": "pnpm typecheck && tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "env-paths": "3.0.0", + "pino": "9.6.0", + "rotating-file-stream": "3.2.7" + }, + "devDependencies": { + "@types/node": "22.10.2", + "tsup": "8.5.1", + "typescript": "5.9.3", + "vitest": "3.1.2" + } +} diff --git a/packages/logging/src/constants.ts b/packages/logging/src/constants.ts new file mode 100644 index 000000000..bdb4fd954 --- /dev/null +++ b/packages/logging/src/constants.ts @@ -0,0 +1,26 @@ +import envPaths from "env-paths"; + +const paths = envPaths("lingo.dev", { suffix: "" }); + +export const LOG_DIR = paths.log; + +export const DEFAULT_LOG_LEVEL = "info"; + +export const DEFAULT_REDACT_PATHS = [ + "apiKey", + "*.apiKey", + "accessToken", + "*.accessToken", + "password", + "*.password", + "secret", + "*.secret", + "authorization", + "*.authorization", +] as const; + +// Smart defaults for log rotation +export const LOG_ROTATION_MAX_SIZE = "10M"; // Rotate when file reaches 10MB +export const LOG_ROTATION_MAX_FILES = 10; // Keep last 10 rotated files +export const LOG_ROTATION_INTERVAL = "1d"; // Daily rotation +export const LOG_ROTATION_COMPRESS = "gzip" as const; // Compress old files diff --git a/packages/logging/src/detect-project-path.spec.ts b/packages/logging/src/detect-project-path.spec.ts new file mode 100644 index 000000000..ca6cb106b --- /dev/null +++ b/packages/logging/src/detect-project-path.spec.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + mkdirSync, + writeFileSync, + rmSync, + existsSync, + realpathSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectProjectPath } from "./detect-project-path.js"; + +describe("detectProjectPath", () => { + let testRoot: string; + let originalCwd: string; + + beforeEach(() => { + // Save original working directory + originalCwd = process.cwd(); + + // Create and resolve test directory (handles macOS /private symlink) + const tempPath = join(tmpdir(), "lingo-project-detect-test"); + if (existsSync(tempPath)) { + rmSync(tempPath, { recursive: true, force: true }); + } + mkdirSync(tempPath, { recursive: true }); + testRoot = realpathSync(tempPath); + }); + + afterEach(() => { + // Restore original working directory + process.chdir(originalCwd); + + // Clean up test directory + if (existsSync(testRoot)) { + rmSync(testRoot, { recursive: true, force: true }); + } + }); + + it("should detect project path when i18n.json exists", () => { + const projectDir = join(testRoot, "project-with-i18n"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, "i18n.json"), "{}"); + + process.chdir(projectDir); + const detectedPath = detectProjectPath(["i18n.json"]); + + expect(detectedPath).toBe(projectDir); + }); + + it("should detect project path when next.config.js exists", () => { + const projectDir = join(testRoot, "project-with-next"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, "next.config.js"), "module.exports = {}"); + + process.chdir(projectDir); + const detectedPath = detectProjectPath(["next.config.js"]); + + expect(detectedPath).toBe(projectDir); + }); + + it("should detect project path when vite.config.ts exists", () => { + const projectDir = join(testRoot, "project-with-vite"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, "vite.config.ts"), "export default {}"); + + process.chdir(projectDir); + const detectedPath = detectProjectPath(["vite.config.ts"]); + + expect(detectedPath).toBe(projectDir); + }); + + it("should prioritize first config file in array", () => { + const projectDir = join(testRoot, "project-with-multiple"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, "i18n.json"), "{}"); + writeFileSync(join(projectDir, "next.config.js"), "module.exports = {}"); + writeFileSync(join(projectDir, "vite.config.ts"), "export default {}"); + + process.chdir(projectDir); + const detectedPath = detectProjectPath([ + "i18n.json", + "next.config.js", + "vite.config.ts", + ]); + + expect(detectedPath).toBe(projectDir); + }); + + it("should traverse upward through parent directories", () => { + const projectRoot = join(testRoot, "project-root"); + const subDir = join(projectRoot, "src", "components"); + mkdirSync(subDir, { recursive: true }); + writeFileSync(join(projectRoot, "i18n.json"), "{}"); + + process.chdir(subDir); + const detectedPath = detectProjectPath(["i18n.json"]); + + expect(detectedPath).toBe(projectRoot); + }); + + it("should return null when no config files found", () => { + const emptyDir = join(testRoot, "empty-project"); + mkdirSync(emptyDir, { recursive: true }); + + process.chdir(emptyDir); + const detectedPath = detectProjectPath(["i18n.json"]); + + expect(detectedPath).toBeNull(); + }); + + it("should detect next.config.mjs", () => { + const projectDir = join(testRoot, "project-with-next-mjs"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, "next.config.mjs"), "export default {}"); + + process.chdir(projectDir); + const detectedPath = detectProjectPath(["next.config.mjs"]); + + expect(detectedPath).toBe(projectDir); + }); + + it("should detect vite.config.js", () => { + const projectDir = join(testRoot, "project-with-vite-js"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, "vite.config.js"), "export default {}"); + + process.chdir(projectDir); + const detectedPath = detectProjectPath(["vite.config.js"]); + + expect(detectedPath).toBe(projectDir); + }); + + it("should find specific config file in monorepo with multiple projects", () => { + // Simulate monorepo: /repo/i18n.json (CLI) and /repo/apps/web/next.config.ts (Web) + const repoRoot = join(testRoot, "monorepo"); + const webAppDir = join(repoRoot, "apps", "web", "src"); + + mkdirSync(webAppDir, { recursive: true }); + writeFileSync(join(repoRoot, "i18n.json"), "{}"); + writeFileSync( + join(repoRoot, "apps", "web", "next.config.ts"), + "export default {}", + ); + + // From web app subdirectory, search only for i18n.json + process.chdir(webAppDir); + const detectedPath = detectProjectPath(["i18n.json"]); + + // Should find repo root, not the web app directory + expect(detectedPath).toBe(repoRoot); + }); + + it("should respect config file filter parameter", () => { + const projectDir = join(testRoot, "project-with-both"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, "i18n.json"), "{}"); + writeFileSync(join(projectDir, "next.config.js"), "module.exports = {}"); + + process.chdir(projectDir); + + // Search only for next.config files + const detectedPath = detectProjectPath([ + "next.config.js", + "next.config.mjs", + "next.config.ts", + ]); + + expect(detectedPath).toBe(projectDir); + }); +}); diff --git a/packages/logging/src/detect-project-path.ts b/packages/logging/src/detect-project-path.ts new file mode 100644 index 000000000..23c7b6a8c --- /dev/null +++ b/packages/logging/src/detect-project-path.ts @@ -0,0 +1,33 @@ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { cwd } from "node:process"; + +/** + * Detect the project root by searching upward for specified config files. + * + * @param configFiles - Array of config filenames to search for (e.g., ['i18n.json']) + * @returns The project root path, or null if no config file is found + */ +export function detectProjectPath( + configFiles: readonly string[], +): string | null { + let currentDir = cwd(); + const root = resolve("/"); + + while (true) { + for (const configFile of configFiles) { + const configPath = resolve(currentDir, configFile); + if (existsSync(configPath)) { + return currentDir; + } + } + + if (currentDir === root) { + break; + } + + currentDir = dirname(currentDir); + } + + return null; +} diff --git a/packages/logging/src/index.ts b/packages/logging/src/index.ts new file mode 100644 index 000000000..c11a91da3 --- /dev/null +++ b/packages/logging/src/index.ts @@ -0,0 +1,3 @@ +export { initLogger } from "./init-logger.js"; +export { detectProjectPath } from "./detect-project-path.js"; +export type { Logger } from "pino"; diff --git a/packages/logging/src/init-logger.spec.ts b/packages/logging/src/init-logger.spec.ts new file mode 100644 index 000000000..bdca4935c --- /dev/null +++ b/packages/logging/src/init-logger.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { initLogger } from "./init-logger.js"; +import { LOG_DIR } from "./constants.js"; + +describe("initLogger", () => { + const testLogDir = join(tmpdir(), "lingo-logging-test"); + const testSlug = "test-app"; + + beforeEach(() => { + // Clean up test directory before each test + if (existsSync(testLogDir)) { + rmSync(testLogDir, { recursive: true, force: true }); + } + mkdirSync(testLogDir, { recursive: true }); + }); + + afterEach(() => { + // Clean up test directory after each test + if (existsSync(testLogDir)) { + rmSync(testLogDir, { recursive: true, force: true }); + } + }); + + it("should initialize logger with default configuration", () => { + const logger = initLogger(testSlug); + + expect(logger).toBeDefined(); + expect(logger.info).toBeDefined(); + expect(logger.error).toBeDefined(); + expect(logger.warn).toBeDefined(); + expect(logger.debug).toBeDefined(); + }); + + it("should return the same logger instance for the same slug (singleton)", () => { + const logger1 = initLogger(testSlug); + const logger2 = initLogger(testSlug); + + expect(logger1).toBe(logger2); + }); + + it("should create different logger instances for different slugs", () => { + const logger1 = initLogger("app-1"); + const logger2 = initLogger("app-2"); + + expect(logger1).not.toBe(logger2); + }); + + it("should create log directory if it doesn't exist", () => { + const customSlug = "custom-test-app"; + + // Initialize logger (should create directory) + const logger = initLogger(customSlug); + + expect(logger).toBeDefined(); + // Note: We can't easily test the exact directory without mocking, + // but we can verify the logger works + }); + + it("should write logs to the log file", async () => { + const logger = initLogger(testSlug); + const testMessage = "Test log message"; + const testData = { userId: 123, action: "test" }; + + logger.info(testData, testMessage); + + // rotating-file-stream writes asynchronously, so we need to wait + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logFilePath = join(LOG_DIR, `${testSlug}.log`); + expect(existsSync(logFilePath)).toBe(true); + + // Verify log contents + const logContent = readFileSync(logFilePath, "utf-8"); + expect(logContent).toContain(testMessage); + expect(logContent).toContain("userId"); + expect(logContent).toContain("123"); + }); + + it("should apply correct log level", () => { + const logger = initLogger(testSlug); + + // Verify logger has all standard methods + expect(typeof logger.trace).toBe("function"); + expect(typeof logger.debug).toBe("function"); + expect(typeof logger.info).toBe("function"); + expect(typeof logger.warn).toBe("function"); + expect(typeof logger.error).toBe("function"); + expect(typeof logger.fatal).toBe("function"); + }); + + it("should handle multiple loggers with different slugs concurrently", () => { + const slugs = ["app-1", "app-2", "app-3", "app-4", "app-5"]; + const loggers = slugs.map((slug) => initLogger(slug)); + + // All loggers should be defined and unique + expect(loggers).toHaveLength(5); + expect(new Set(loggers).size).toBe(5); + + // Each logger should work independently + loggers.forEach((logger, index) => { + logger.info(`Test message from ${slugs[index]}`); + }); + }); + + it("should not throw errors even if logger operations fail", () => { + // This test verifies that logging operations don't crash the application. + // The implementation wraps logger initialization in try-catch and handles + // runtime errors on the destination stream. + const logger = initLogger("resilient-logger"); + + // Should not throw even if there are issues + expect(() => { + logger.info("Test message"); + logger.error("Test error"); + logger.warn({ data: "complex" }, "Warning with object"); + }).not.toThrow(); + + expect(logger).toBeDefined(); + }); +}); diff --git a/packages/logging/src/init-logger.ts b/packages/logging/src/init-logger.ts new file mode 100644 index 000000000..0b0fb2342 --- /dev/null +++ b/packages/logging/src/init-logger.ts @@ -0,0 +1,96 @@ +import pino from "pino"; +import type { Logger } from "pino"; +import { createStream } from "rotating-file-stream"; +import { basename } from "node:path"; +import type { LoggerCacheEntry, LoggerConfig } from "./types.js"; +import { + LOG_DIR, + DEFAULT_LOG_LEVEL, + DEFAULT_REDACT_PATHS, + LOG_ROTATION_MAX_SIZE, + LOG_ROTATION_MAX_FILES, + LOG_ROTATION_INTERVAL, + LOG_ROTATION_COMPRESS, +} from "./constants.js"; + +const loggerCache = new Map(); + +/** + * Initialize or retrieve a cached logger instance for the given slug. + * Returns the same logger instance when called multiple times with the same slug. + * If logger initialization fails, returns a no-op logger that silently discards all logs. + * + * @param slug - Unique identifier for the logger + * @returns Pino logger instance (or no-op logger on failure) + */ +export function initLogger(slug: string): Logger { + const cached = loggerCache.get(slug); + + if (cached) { + return cached.logger; + } + + try { + const logFilePath = `${LOG_DIR}/${slug}.log`; + + const config: LoggerConfig = { + slug, + logDir: LOG_DIR, + logFilePath, + }; + + const logger = createLogger(config); + + loggerCache.set(slug, { logger, config }); + + return logger; + } catch (error) { + // Log initialization failed - return a no-op logger to prevent CLI crashes + // The CLI will continue to work, but logging will be silently disabled + return createNoOpLogger(); + } +} + +/** + * Create a logger with automatic log rotation. + */ +function createLogger(config: LoggerConfig): Logger { + const logFileName = basename(config.logFilePath); + + // Create rotating file stream with smart defaults + const stream = createStream(logFileName, { + size: LOG_ROTATION_MAX_SIZE, // Rotate at 10MB + interval: LOG_ROTATION_INTERVAL, // Daily rotation + maxFiles: LOG_ROTATION_MAX_FILES, // Keep 10 files + compress: LOG_ROTATION_COMPRESS, // Gzip old files + path: config.logDir, // Directory for logs + }); + + // Handle runtime errors on the stream to prevent crashes + stream.on("error", (err) => { + // Log to stderr so errors are visible but don't crash the CLI + console.error(`[Logger error]: ${err.message}`); + }); + + const logger = pino( + { + level: DEFAULT_LOG_LEVEL, + redact: { + paths: [...DEFAULT_REDACT_PATHS], + censor: "[REDACTED]", + }, + }, + stream, + ); + + return logger; +} + +/** + * Create a no-op logger that discards all log messages. + * Used as a fallback when file logging initialization fails. + */ +function createNoOpLogger(): Logger { + // Pino with level 'silent' discards all logs + return pino({ level: "silent" }); +} diff --git a/packages/logging/src/types.ts b/packages/logging/src/types.ts new file mode 100644 index 000000000..dfa883c44 --- /dev/null +++ b/packages/logging/src/types.ts @@ -0,0 +1,12 @@ +import type { Logger } from "pino"; + +export type LoggerConfig = { + slug: string; + logDir: string; + logFilePath: string; +}; + +export type LoggerCacheEntry = { + logger: Logger; + config: LoggerConfig; +}; diff --git a/packages/logging/tsconfig.json b/packages/logging/tsconfig.json new file mode 100644 index 000000000..b477a865e --- /dev/null +++ b/packages/logging/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/packages/logging/tsup.config.ts b/packages/logging/tsup.config.ts new file mode 100644 index 000000000..2d13ece73 --- /dev/null +++ b/packages/logging/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + clean: true, + target: "esnext", + entry: ["src/index.ts"], + outDir: "build", + format: ["cjs", "esm"], + dts: true, + cjsInterop: true, + splitting: true, + outExtension: (ctx) => ({ + js: ctx.format === "cjs" ? ".cjs" : ".mjs", + }), +}); diff --git a/cmp/compiler/.gitignore b/packages/new-compiler/.gitignore similarity index 88% rename from cmp/compiler/.gitignore rename to packages/new-compiler/.gitignore index a26576b20..3de122dd7 100644 --- a/cmp/compiler/.gitignore +++ b/packages/new-compiler/.gitignore @@ -1,3 +1,5 @@ +# Build output +build/ # Playwright node_modules/ diff --git a/packages/new-compiler/CHANGELOG.md b/packages/new-compiler/CHANGELOG.md new file mode 100644 index 000000000..8564ecc42 --- /dev/null +++ b/packages/new-compiler/CHANGELOG.md @@ -0,0 +1,85 @@ +# @lingo.dev/compiler + +## 0.1.8 + +### Patch Changes + +- Updated dependencies [[`606fd5b`](https://github.com/lingodotdev/lingo.dev/commit/606fd5b10d9d15a42a65d1cb763f59210d3c8842)]: + - lingo.dev@0.121.0 + +## 0.1.7 + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +- Updated dependencies [[`348b2de`](https://github.com/lingodotdev/lingo.dev/commit/348b2de39412101bacb5ed541b0db23f0ca6213d), [`04c3679`](https://github.com/lingodotdev/lingo.dev/commit/04c3679c69231012f167da1640dc17ac57743d6b), [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59), [`797f913`](https://github.com/lingodotdev/lingo.dev/commit/797f9132b5cf05fe457968b691bca10db1fc37bb)]: + - lingo.dev@0.120.0 + +## 0.1.6 + +### Patch Changes + +- Updated dependencies [[`978b817`](https://github.com/lingodotdev/lingo.dev/commit/978b81793dff52abb348b1b0977cb233232721d0)]: + - lingo.dev@0.119.0 + +## 0.1.5 + +### Patch Changes + +- Updated dependencies [[`18ef68f`](https://github.com/lingodotdev/lingo.dev/commit/18ef68f8d51f0d3208cfe1f1d2167e2e1580fdcc), [`d76b729`](https://github.com/lingodotdev/lingo.dev/commit/d76b729ba692f1ec258355ebed5b47d7137b001d)]: + - lingo.dev@0.118.0 + +## 0.1.4 + +### Patch Changes + +- [#1726](https://github.com/lingodotdev/lingo.dev/pull/1726) [`68b8496`](https://github.com/lingodotdev/lingo.dev/commit/68b849602a88b9f9aa3097f37ce2f0ccf97c1ad5) Thanks [@vrcprl](https://github.com/vrcprl)! - Observability improvement + +- Updated dependencies [[`68b8496`](https://github.com/lingodotdev/lingo.dev/commit/68b849602a88b9f9aa3097f37ce2f0ccf97c1ad5)]: + - lingo.dev@0.117.25 + +## 0.1.3 + +### Patch Changes + +- [#1705](https://github.com/lingodotdev/lingo.dev/pull/1705) [`c77c8c8`](https://github.com/lingodotdev/lingo.dev/commit/c77c8c8b8e1db859839b184882d56a0ef7da1ab0) Thanks [@AleksandrSl](https://github.com/AleksandrSl)! - Show logs of the translator initialization to notify about possible problems with LLM keys + +- Updated dependencies [[`c617611`](https://github.com/lingodotdev/lingo.dev/commit/c61761181c5f8145ec2e54f34d33ad04a90968e3)]: + - lingo.dev@0.117.24 + +## 0.1.2 + +### Patch Changes + +- [#1707](https://github.com/lingodotdev/lingo.dev/pull/1707) [`b2d335b`](https://github.com/lingodotdev/lingo.dev/commit/b2d335b37af3e300a402d75f0eb2a0112f81e7ee) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - docs: comprehensive README update with LLM configuration, manual overrides, and advanced features + + Added extensive documentation covering: + - Complete LLM provider configuration (OpenAI, Anthropic, Google, Groq, Mistral, OpenRouter, Ollama) + - Environment variables for all supported providers + - Locale-pair model mapping with wildcard patterns + - Custom translation prompts + - Manual translation overrides using data-lingo-override attribute + - Build modes (translate vs cache-only) and recommended workflows + - Custom locale resolvers (server and client) + - Configuration options table with defaults + - Development configuration (pseudotranslator, translation server port) + - Locale persistence configuration (cookie settings) + - Pluralization configuration + - Updated feature list + - Fixed demo app paths in examples + +- Updated dependencies [[`020424f`](https://github.com/lingodotdev/lingo.dev/commit/020424f2601c535e88c66aeeece5a15fb9b66b70)]: + - lingo.dev@0.117.22 + +## 0.1.1 + +### Patch Changes + +- [`b6e4ea9`](https://github.com/lingodotdev/lingo.dev/commit/b6e4ea9266723499cef5e9a55bd9b052740cfe5e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - No-op release to test Trusted Publishing with OIDC + +## 0.1.0 + +### Minor Changes + +- [#1698](https://github.com/lingodotdev/lingo.dev/pull/1698) [`f24a5e2`](https://github.com/lingodotdev/lingo.dev/commit/f24a5e282d79838b84f46c98dd85460a0ad953c7) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Lingo.dev Compiler v1 Beta diff --git a/packages/new-compiler/README.md b/packages/new-compiler/README.md new file mode 100644 index 000000000..8bd2cc33f --- /dev/null +++ b/packages/new-compiler/README.md @@ -0,0 +1,463 @@ +# @lingo.dev/compiler + +See the main [README](../../README.md) for general information. + +Lingo.dev compiler with automatic translation support for React applications. + +This package provides plugins for multiple bundlers (Vite, Webpack) and Next.js that +automatically transforms React components to inject translation calls. + +## Features + +- **Automatic JSX text transformation** - Automatically detects and transforms translatable text in JSX +- **Opt-in or automatic** - Configure whether to require `'use i18n'` directive or transform all files +- **Multi-bundler support** - Works with Vite, Webpack and Next.js (both Webpack and Turbopack builds) +- **Translation server** - On-demand translation generation during development +- **AI-powered translations** - Support for multiple LLM providers (OpenAI, Anthropic, Google Gemini, Groq, Mistral, OpenRouter, Ollama) and Lingo.dev Engine +- **Manual overrides** - Override AI translations for specific locales using `data-lingo-override` attribute +- **Custom locale resolvers** - Provide your own locale detection and persistence logic +- **Automatic pluralization** - Detects and converts messages to ICU MessageFormat + +## Getting started + +Install the package - `pnpm install @lingo.dev/compiler` + +### Vite + +- Configure the plugin in your vite config. + + ```ts + import { defineConfig } from "vite"; + import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; + + export default defineConfig({ + plugins: [ + lingoCompilerPlugin({ + sourceRoot: "src", + sourceLocale: "en", + targetLocales: ["es", "de", "fr"], + models: "lingo.dev", + dev: { + usePseudotranslator: true, + }, + }), // ...other plugins + ], + }); + ``` + +- Wrap your app with `LingoProvider`. It should be used as early as possible in your app. + e.g. in vite example with tanstack-router `LingoProvider` should be above `RouterProvider`, otherwise code-splitting breaks contexts. + ```tsx + // Imports and other tanstack router setup + if (rootElement && !rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement); + root.render( + + + + + , + ); + } + ``` + +See `demo/new-compiler-vite-react-spa` for the working example + +### Next.js + +- Use `withLingo` function to wrap your existing next config. You will have to make your config async. + + ```ts + import type { NextConfig } from "next"; + import { withLingo } from "@lingo.dev/compiler/next"; + + const nextConfig: NextConfig = {}; + + export default async function (): Promise { + return await withLingo(nextConfig, { + sourceRoot: "./app", + sourceLocale: "en", + targetLocales: ["es", "de", "ru"], + models: "lingo.dev", + dev: { + usePseudotranslator: true, + }, + buildMode: "cache-only", + }); + } + ``` + +- Wrap your app with `LingoProvider`. It should be used as early as possible in your app, root `Layout` is a good place. + ```tsx + export default function RootLayout({ + children, + }: Readonly<{ children: React.ReactNode }>) { + return ( + + + {children} + + + ); + } + ``` + +See `demo/new-compiler-next16` for the working example + +## Configuration Options + +### Core Configuration + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `sourceRoot` | `string` | `"src"` | Root directory of the source code | +| `lingoDir` | `string` | `"lingo"` | Directory for lingo files (`.lingo/`) | +| `sourceLocale` | `LocaleCode` | **(required)** | Source locale (e.g., `"en"`, `"en-US"`) | +| `targetLocales` | `LocaleCode[]` | **(required)** | Target locales to translate to | +| `useDirective` | `boolean` | `false` | Whether to require `'use i18n'` directive | +| `models` | `string \| Record` | `"lingo.dev"` | Model configuration (see below) | +| `prompt` | `string` | `undefined` | Custom translation prompt | +| `buildMode` | `"translate" \| "cache-only"` | `"translate"` | Build mode (see below) | + +### Development Configuration + +Configure development-specific behavior via the `dev` option: + +```ts +{ + dev: { + // Use pseudotranslator (fake translations) instead of real AI + usePseudotranslator: boolean; // default: false + + // Starting port for translation server + translationServerStartPort: number; // default: 60000 + + // Custom translation server URL (advanced) + translationServerUrl?: string; + } +} +``` + +### Locale Persistence + +Configure how locale changes are persisted: + +```ts +{ + localePersistence: { + type: "cookie", + config: { + name: string; // default: "locale" + maxAge: number; // default: 31536000 (1 year) + } + } +} +``` + +### Pluralization + +Configure automatic pluralization detection: + +```ts +{ + pluralization: { + enabled: boolean; // default: true + model: string; // default: "groq:llama-3.1-8b-instant" + } +} +``` + +## Using LLMs for Translation + +### Lingo.dev Engine (Recommended) + +The simplest way to get started is using Lingo.dev Engine: + +```ts +{ + models: "lingo.dev" +} +``` + +Set your API key in `.env`: + +```bash +LINGODOTDEV_API_KEY=your_api_key_here +``` + +Get your API key at [lingo.dev](https://lingo.dev) + +### Direct LLM Providers + +You can use any supported LLM provider directly. Configure using locale-pair mapping: + +```ts +{ + models: { + // Specific locale pair + "en:es": "google:gemini-2.0-flash", + + // Wildcard patterns + "*:de": "groq:llama3-8b-8192", // All translations to German + "en:*": "openai:gpt-4o", // All translations from English + "*:*": "anthropic:claude-3-5-sonnet" // Fallback for all other pairs + } +} +``` + +**Supported Providers:** + +| Provider | Model String Format | Environment Variable | Get API Key | +|----------|---------------------|---------------------|-------------| +| **OpenAI** | `openai:gpt-4o` | `OPENAI_API_KEY` | [platform.openai.com](https://platform.openai.com/account/api-keys) | +| **Anthropic** | `anthropic:claude-3-5-sonnet` | `ANTHROPIC_API_KEY` | [console.anthropic.com](https://console.anthropic.com/get-api-key) | +| **Google** | `google:gemini-2.0-flash` | `GOOGLE_API_KEY` | [ai.google.dev](https://ai.google.dev/) | +| **Groq** | `groq:llama3-8b-8192` | `GROQ_API_KEY` | [groq.com](https://groq.com) | +| **Mistral** | `mistral:mistral-large` | `MISTRAL_API_KEY` | [console.mistral.ai](https://console.mistral.ai) | +| **OpenRouter** | `openrouter:anthropic/claude-3.5-sonnet` | `OPENROUTER_API_KEY` | [openrouter.ai](https://openrouter.ai) | +| **Ollama** | `ollama:llama2` | *(none required)* | [ollama.com/download](https://ollama.com/download) | + +**Example with multiple providers:** + +```ts +{ + sourceLocale: "en", + targetLocales: ["es", "de", "fr", "ja"], + models: { + "en:es": "groq:llama3-8b-8192", // Fast & cheap for Spanish + "en:de": "google:gemini-2.0-flash", // Good for German + "*:*": "openai:gpt-4o" // High quality for everything else + } +} +``` + +### Custom Translation Prompts + +Customize the translation prompt using placeholders: + +```ts +{ + models: "lingo.dev", + prompt: "Translate from {SOURCE_LOCALE} to {TARGET_LOCALE}. Use a formal tone and preserve all technical terms." +} +``` + +## Manual Translation Overrides + +Override AI-generated translations for specific text using the `data-lingo-override` attribute: + +```tsx +export function Welcome() { + return ( +
    + {/* Override translations for brand name */} +

    + Lingo.dev +

    + + {/* Override only specific locales */} +

    + Professional translation +

    + + {/* Works with rich text and interpolations */} +

    {name}", + fr: "Bienvenue {name}" + }}> + Welcome {name} +

    +
    + ); +} +``` + +The `data-lingo-override` attribute: +- Accepts an object with locale codes as keys +- Supports partial overrides (only specify locales you want to override) +- Is automatically removed from the final output +- Works with locale region codes (e.g., `en-US`, `en-GB`) +- Supports rich text with component placeholders (e.g., ``) + +**Use cases:** +- Brand names that shouldn't be translated +- Technical terms requiring specific translations +- Legal text requiring certified translations +- Marketing copy that needs human review + +## Build Modes + +Control how translations are handled at build time: + +### `translate` (default) + +Generate missing translations at build time using configured LLM: + +```ts +{ + buildMode: "translate" +} +``` + +- Generates translations for any missing entries +- Fails build if translation fails +- Best for: Development and CI pipelines with API keys + +### `cache-only` + +Only use existing cached translations: + +```ts +{ + buildMode: "cache-only" +} +``` + +- No API calls made during build +- Fails build if translations are missing +- Best for: Production builds without API keys +- Requires translations to be pre-generated (during dev or in CI) + +**Environment Variable Override:** + +```bash +LINGO_BUILD_MODE=cache-only npm run build +``` + +**Recommended Workflow:** + +1. **Development**: Use `translate` mode with `usePseudotranslator: true` +2. **CI**: Generate real translations with `buildMode: "translate"` and real API keys +3. **Production Build**: Use `buildMode: "cache-only"` (no API keys needed) + +## Custom Locale Resolvers + +Customize how locales are detected and persisted by providing custom resolver files: + +### Custom Server Locale Resolver + +Create `.lingo/locale-resolver.server.ts` (Next.js): + +```ts +// Custom server-side locale detection +export async function getServerLocale(): Promise { + // Your custom logic - e.g., from database, headers, etc. + const { headers } = await import('next/headers'); + const headersList = await headers(); + const acceptLanguage = headersList.get('accept-language'); + + // Parse and return locale + return acceptLanguage?.split(',')[0]?.split('-')[0] || 'en'; +} +``` + +### Custom Client Locale Resolver + +Create `.lingo/locale-resolver.client.ts`: + +```ts +// Custom client-side locale detection and persistence +export function getClientLocale(): string { + // Your custom logic - e.g., from localStorage, URL params, etc. + return localStorage.getItem('user-locale') || 'en'; +} + +export function persistLocale(locale: string): void { + localStorage.setItem('user-locale', locale); + // Optionally update URL, etc. +} +``` + +If these files don't exist, the compiler uses the default cookie-based implementation. + +## Development + +`pnpm install` from project root +`pnpm turbo dev --filter=@lingo.dev/compile` to compile and watch for compiler changes + +Choose the demo you want to work with and run it from the corresponding folder. +`tsdown` in compiler is configured to cleanup the output folder before compilation, which works fine with next, but vite +seems to be dead every time and has to be restarted. + +## Structure + +The compiler is organized into several key modules: + +### Core Directories + +#### `src/plugin/` - Build-time transformation + +- **`transform/`** - Babel AST transformation logic for JSX text extraction +- **`unplugin.ts`** - Universal plugin implementation (Vite, Webpack) +- **`next.ts`** - Next.js-specific plugin with Turbopack and Webpack support +- **`build-translator.ts`** - Batch translation generation at build time +- **`virtual-modules-code-generator.ts`** - Generates code for virtual modules, dev config and locale resolvers for client and server + +#### `src/metadata/` - Translation metadata management + +- **`manager.ts`** - CRUD operations for `.lingo/metadata.json` +- Thread-safe metadata file operations with file locking +- Manages translation entries with hash-based identifiers + +#### `src/translators/` - Translation provider abstraction + +- **`lcp/`** - Lingo.dev Engine integration +- **`pseudotranslator/`** - Development-mode fake translator +- **`pluralization/`** - Automatic ICU MessageFormat detection +- **`translator-factory.ts`** - Provider selection and initialization + +#### `src/translation-server/` - Development server + +- **`translation-server.ts`** - HTTP server for on-demand translations +- **`cli.ts`** - Standalone CLI for translation generation +- WebSocket support for real-time dev widget updates +- Port management (60000-60099 range) + +#### `src/react/` - Runtime translation hooks + +- **`client/`** - Client-side Context-based hooks +- **`server/`** - Server component cache-based hooks (isomorphic) +- **`server-only/`** - Async server-only API (`getServerTranslations`) +- **`shared/`** - Shared utilities (RichText rendering, Context) +- **`next/`** - Next.js-specific middleware and locale switcher + +#### `src/utils/` - Shared utilities + +- **`hash.ts`** - Stable hash generation for translation keys +- **`config-factory.ts`** - Configuration defaults and merging +- **`logger.ts`** - Structured logging utilities +- **`path-helpers.ts`** - File path resolution + +#### `src/widget/` - Development widget + +- In-browser translation editor overlay for development mode + +### Support Directories + +#### `tests/` - End-to-end testing + +- **`e2e/`** - Playwright tests for full build workflows +- **`fixtures/`** - Test applications (Vite, Next.js) +- **`helpers/`** - Test utilities and assertions + +#### `benchmarks/` - Performance benchmarks + +- Translation speed benchmarks +- Metadata I/O performance tests + +#### `old-docs/` - Legacy documentation + +- Historical design documents and notes + +### Entry Points + +- **`src/index.ts`** - Main package exports (plugins, types) +- **`src/types.ts`** - Core TypeScript types + +## Contributing + +This is a beta package under active development. Feedback and contributions are welcome! + +## License + +ISC diff --git a/cmp/compiler/Requirements.md b/packages/new-compiler/Requirements.md similarity index 100% rename from cmp/compiler/Requirements.md rename to packages/new-compiler/Requirements.md diff --git a/cmp/docs/TRANSLATION_ARCHITECTURE.md b/packages/new-compiler/docs/TRANSLATION_ARCHITECTURE.md similarity index 100% rename from cmp/docs/TRANSLATION_ARCHITECTURE.md rename to packages/new-compiler/docs/TRANSLATION_ARCHITECTURE.md diff --git a/cmp/compiler/package.json b/packages/new-compiler/package.json similarity index 73% rename from cmp/compiler/package.json rename to packages/new-compiler/package.json index cd293a414..28302e11b 100644 --- a/cmp/compiler/package.json +++ b/packages/new-compiler/package.json @@ -1,10 +1,16 @@ { "name": "@lingo.dev/compiler", - "version": "0.0.1", - "description": "Lingo.dev Compiler Beta", + "version": "0.1.8", + "description": "Lingo.dev Compiler", "private": false, + "repository": { + "type": "git", + "url": "https://github.com/lingodotdev/lingo.dev.git", + "directory": "packages/new-compiler" + }, "publishConfig": { - "access": "public" + "access": "public", + "provenance": true }, "sideEffects": false, "type": "module", @@ -117,12 +123,13 @@ "clean": "rm -rf build", "test": "vitest --run", "test:watch": "vitest -w", - "test:prepare": "pnpm build && tsx tests/helpers/prepare-fixtures.ts", + "test:e2e:prepare": "tsx tests/helpers/prepare-fixtures.ts", "test:e2e": "playwright test", "test:e2e:next": "playwright test --grep next", "test:e2e:vite": "playwright test --grep vite", "test:e2e:shared": "playwright test tests/e2e/shared", - "test:e2e:dev": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", "test:e2e:report": "playwright show-report" }, @@ -130,44 +137,48 @@ "author": "", "license": "ISC", "devDependencies": { - "@playwright/test": "^1.56.1", - "@types/babel__core": "^7.20.5", - "@types/babel__generator": "^7.27.0", - "@types/babel__traverse": "^7.28.0", - "@types/node": "^22.10.5", - "@types/proper-lockfile": "^4.1.4", - "@types/react": "^18.3.26", - "@types/react-dom": "^19.2.3", - "@types/ws": "^8.18.1", - "next": "^16.1.0", - "tsdown": "^0.16.5", - "tsx": "^4.19.2", - "typescript": "^5.9.3", - "unplugin": "^2.3.11", - "vitest": "^4.0.13" + "@playwright/test": "1.56.1", + "@types/babel__core": "7.20.5", + "@types/babel__generator": "7.27.0", + "@types/babel__traverse": "7.28.0", + "@types/ini": "4.1.1", + "@types/node": "25.0.3", + "@types/proper-lockfile": "4.1.4", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@types/ws": "8.18.1", + "next": "16.1.0", + "tsdown": "0.18.2", + "tsx": "4.21.0", + "typescript": "5.9.3", + "unplugin": "2.3.11", + "vitest": "4.0.16" }, "dependencies": { - "@ai-sdk/google": "^1.2.19", - "@ai-sdk/groq": "^1.2.3", - "@ai-sdk/mistral": "^1.2.8", - "@babel/core": "^7.26.0", - "@babel/generator": "^7.28.5", - "@babel/parser": "^7.28.5", - "@babel/preset-react": "^7.26.3", - "@babel/preset-typescript": "^7.26.0", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@formatjs/icu-messageformat-parser": "^2.11.4", - "@openrouter/ai-sdk-provider": "^0.7.1", - "ai": "^4.3.19", - "dotenv": "^16.4.5", - "fast-xml-parser": "^5.0.8", - "intl-messageformat": "^10.7.18", - "lingo.dev": "^0.117.0", - "lodash": "^4.17.21", - "ollama-ai-provider": "^1.2.0", - "proper-lockfile": "^4.1.2", - "ws": "^8.18.3" + "@ai-sdk/google": "3.0.1", + "@ai-sdk/groq": "3.0.1", + "@ai-sdk/mistral": "3.0.1", + "@babel/core": "7.26.0", + "@babel/generator": "7.28.5", + "@babel/parser": "7.28.5", + "@babel/preset-react": "7.26.3", + "@babel/preset-typescript": "7.26.0", + "@babel/traverse": "7.28.5", + "@babel/types": "7.28.5", + "@formatjs/icu-messageformat-parser": "3.1.1", + "@openrouter/ai-sdk-provider": "1.5.4", + "ai": "6.0.3", + "ai-sdk-ollama": "3.0.0", + "dotenv": "17.2.3", + "fast-xml-parser": "5.3.3", + "ini": "5.0.0", + "intl-messageformat": "11.0.6", + "lingo.dev": "workspace:^", + "lodash": "4.17.21", + "node-machine-id": "1.1.12", + "posthog-node": "5.14.0", + "proper-lockfile": "4.1.2", + "ws": "8.18.3" }, "peerDependencies": { "next": "^15.0.0 || ^16.0.4", diff --git a/cmp/compiler/playwright.config.ts b/packages/new-compiler/playwright.config.ts similarity index 91% rename from cmp/compiler/playwright.config.ts rename to packages/new-compiler/playwright.config.ts index 21a385c71..b6718a58e 100644 --- a/cmp/compiler/playwright.config.ts +++ b/packages/new-compiler/playwright.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ /* Timeout for each test (includes beforeAll/afterAll hooks) */ timeout: 180000, // 3 minutes - accounts for packing compiler, installing deps, starting servers /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: "html", + reporter: process.env.CI ? "list" : "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('')`. */ @@ -38,6 +38,7 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ { + // If we need more than one browser at some point, add them to CI browser installation step too. name: "chromium", use: { ...devices["Desktop Chrome"] }, }, diff --git a/cmp/compiler/src/__test-utils__/mocks.ts b/packages/new-compiler/src/__test-utils__/mocks.ts similarity index 100% rename from cmp/compiler/src/__test-utils__/mocks.ts rename to packages/new-compiler/src/__test-utils__/mocks.ts diff --git a/cmp/compiler/src/index.ts b/packages/new-compiler/src/index.ts similarity index 100% rename from cmp/compiler/src/index.ts rename to packages/new-compiler/src/index.ts diff --git a/cmp/compiler/src/metadata/manager.ts b/packages/new-compiler/src/metadata/manager.ts similarity index 100% rename from cmp/compiler/src/metadata/manager.ts rename to packages/new-compiler/src/metadata/manager.ts diff --git a/cmp/compiler/src/plugin/README.md b/packages/new-compiler/src/plugin/README.md similarity index 100% rename from cmp/compiler/src/plugin/README.md rename to packages/new-compiler/src/plugin/README.md diff --git a/cmp/compiler/src/plugin/build-translator.ts b/packages/new-compiler/src/plugin/build-translator.ts similarity index 96% rename from cmp/compiler/src/plugin/build-translator.ts rename to packages/new-compiler/src/plugin/build-translator.ts index 379600f6d..efb4d9bf9 100644 --- a/cmp/compiler/src/plugin/build-translator.ts +++ b/packages/new-compiler/src/plugin/build-translator.ts @@ -11,12 +11,9 @@ import fs from "fs/promises"; import path from "path"; import type { LingoConfig, MetadataSchema } from "../types"; import { logger } from "../utils/logger"; -import { - startTranslationServer, - type TranslationServer, -} from "../translation-server"; +import { startTranslationServer, type TranslationServer, } from "../translation-server"; import { loadMetadata } from "../metadata/manager"; -import { createCache, type TranslationCache } from "../translators"; +import { createCache, type TranslationCache, TranslationService, } from "../translators"; import { dictionaryFrom } from "../translators/api"; import type { LocaleCode } from "lingo.dev/spec"; @@ -67,10 +64,6 @@ export async function processBuildTranslations( logger.info(`🌍 Build mode: ${buildMode}`); - if (metadataFilePath) { - logger.info(`📋 Using build metadata file: ${metadataFilePath}`); - } - const metadata = await loadMetadata(metadataFilePath); if (!metadata || Object.keys(metadata.entries).length === 0) { @@ -108,7 +101,7 @@ export async function processBuildTranslations( try { translationServer = await startTranslationServer({ - startPort: config.dev.translationServerStartPort, + translationService: new TranslationService(config, logger), onError: (err) => { logger.error("Translation server error:", err); }, @@ -175,7 +168,10 @@ export async function processBuildTranslations( stats, }; } catch (error) { - logger.error("❌ Translation generation failed:", error); + logger.error( + "❌ Translation generation failed:\n", + error instanceof Error ? error.message : error, + ); process.exit(1); } finally { if (translationServer) { diff --git a/cmp/compiler/src/plugin/cleanup.ts b/packages/new-compiler/src/plugin/cleanup.ts similarity index 100% rename from cmp/compiler/src/plugin/cleanup.ts rename to packages/new-compiler/src/plugin/cleanup.ts diff --git a/cmp/compiler/src/plugin/index.ts b/packages/new-compiler/src/plugin/index.ts similarity index 100% rename from cmp/compiler/src/plugin/index.ts rename to packages/new-compiler/src/plugin/index.ts diff --git a/cmp/compiler/src/plugin/next-compiler-loader.ts b/packages/new-compiler/src/plugin/next-compiler-loader.ts similarity index 100% rename from cmp/compiler/src/plugin/next-compiler-loader.ts rename to packages/new-compiler/src/plugin/next-compiler-loader.ts diff --git a/cmp/compiler/src/plugin/next-config-loader.ts b/packages/new-compiler/src/plugin/next-config-loader.ts similarity index 100% rename from cmp/compiler/src/plugin/next-config-loader.ts rename to packages/new-compiler/src/plugin/next-config-loader.ts diff --git a/cmp/compiler/src/plugin/next-locale-client-loader.ts b/packages/new-compiler/src/plugin/next-locale-client-loader.ts similarity index 100% rename from cmp/compiler/src/plugin/next-locale-client-loader.ts rename to packages/new-compiler/src/plugin/next-locale-client-loader.ts diff --git a/cmp/compiler/src/plugin/next-locale-server-loader.ts b/packages/new-compiler/src/plugin/next-locale-server-loader.ts similarity index 100% rename from cmp/compiler/src/plugin/next-locale-server-loader.ts rename to packages/new-compiler/src/plugin/next-locale-server-loader.ts diff --git a/cmp/compiler/src/plugin/next.ts b/packages/new-compiler/src/plugin/next.ts similarity index 97% rename from cmp/compiler/src/plugin/next.ts rename to packages/new-compiler/src/plugin/next.ts index b1c3a7f72..a92545694 100644 --- a/cmp/compiler/src/plugin/next.ts +++ b/packages/new-compiler/src/plugin/next.ts @@ -12,6 +12,7 @@ import { startOrGetTranslationServer } from "../translation-server/translation-s import { cleanupExistingMetadata, getMetadataPath } from "../metadata/manager"; import { registerCleanupOnCurrentProcess } from "./cleanup"; import { useI18nRegex } from "./transform/use-i18n"; +import { TranslationService } from "../translators"; export type LingoNextPluginOptions = PartialLingoConfig; @@ -205,14 +206,12 @@ export async function withLingo( `Initializing Lingo.dev compiler. Is dev mode: ${isDev}. Is main runner: ${isMainRunner()}`, ); - // TODO (AleksandrSl 12/12/2025): Add API keys validation too, so we can log it nicely. - // Try to start up the translation server once. // We have two barriers, a simple one here and a more complex one inside the startTranslationServer which doesn't start the server if it can find one running. // We do not use isMainRunner here, because we need to start the server as early as possible, so the loaders get the translation server url. The main runner in dev mode runs after a dev server process is started. if (isDev && !process.env.LINGO_TRANSLATION_SERVER_URL) { const translationServer = await startOrGetTranslationServer({ - startPort: lingoConfig.dev.translationServerStartPort, + translationService: new TranslationService(lingoConfig, logger), onError: (err) => { logger.error("Translation server error:", err); }, @@ -298,7 +297,6 @@ export async function withLingo( } logger.info("Running post-build translation generation..."); - logger.info(`Build mode: Using metadata file: ${metadataFilePath}`); try { await processBuildTranslations({ @@ -307,7 +305,10 @@ export async function withLingo( metadataFilePath, }); } catch (error) { - logger.error("Translation generation failed:", error); + logger.error( + "Translation generation failed:", + error instanceof Error ? error.message : error, + ); throw error; } }; diff --git a/cmp/compiler/src/plugin/transform/TESTING.md b/packages/new-compiler/src/plugin/transform/TESTING.md similarity index 100% rename from cmp/compiler/src/plugin/transform/TESTING.md rename to packages/new-compiler/src/plugin/transform/TESTING.md diff --git a/cmp/compiler/src/plugin/transform/TRANSFORMATION_PIPELINE.md b/packages/new-compiler/src/plugin/transform/TRANSFORMATION_PIPELINE.md similarity index 100% rename from cmp/compiler/src/plugin/transform/TRANSFORMATION_PIPELINE.md rename to packages/new-compiler/src/plugin/transform/TRANSFORMATION_PIPELINE.md diff --git a/cmp/compiler/src/plugin/transform/__snapshots__/transform.test.ts.snap b/packages/new-compiler/src/plugin/transform/__snapshots__/transform.test.ts.snap similarity index 100% rename from cmp/compiler/src/plugin/transform/__snapshots__/transform.test.ts.snap rename to packages/new-compiler/src/plugin/transform/__snapshots__/transform.test.ts.snap diff --git a/cmp/compiler/src/plugin/transform/babel-compat.ts b/packages/new-compiler/src/plugin/transform/babel-compat.ts similarity index 100% rename from cmp/compiler/src/plugin/transform/babel-compat.ts rename to packages/new-compiler/src/plugin/transform/babel-compat.ts diff --git a/cmp/compiler/src/plugin/transform/index.ts b/packages/new-compiler/src/plugin/transform/index.ts similarity index 100% rename from cmp/compiler/src/plugin/transform/index.ts rename to packages/new-compiler/src/plugin/transform/index.ts diff --git a/cmp/compiler/src/plugin/transform/metadata.ts b/packages/new-compiler/src/plugin/transform/metadata.ts similarity index 100% rename from cmp/compiler/src/plugin/transform/metadata.ts rename to packages/new-compiler/src/plugin/transform/metadata.ts diff --git a/cmp/compiler/src/plugin/transform/parse-override.ts b/packages/new-compiler/src/plugin/transform/parse-override.ts similarity index 100% rename from cmp/compiler/src/plugin/transform/parse-override.ts rename to packages/new-compiler/src/plugin/transform/parse-override.ts diff --git a/cmp/compiler/src/plugin/transform/process-file.ts b/packages/new-compiler/src/plugin/transform/process-file.ts similarity index 100% rename from cmp/compiler/src/plugin/transform/process-file.ts rename to packages/new-compiler/src/plugin/transform/process-file.ts diff --git a/cmp/compiler/src/plugin/transform/transform.test.ts b/packages/new-compiler/src/plugin/transform/transform.test.ts similarity index 100% rename from cmp/compiler/src/plugin/transform/transform.test.ts rename to packages/new-compiler/src/plugin/transform/transform.test.ts diff --git a/cmp/compiler/src/plugin/transform/use-i18n.ts b/packages/new-compiler/src/plugin/transform/use-i18n.ts similarity index 100% rename from cmp/compiler/src/plugin/transform/use-i18n.ts rename to packages/new-compiler/src/plugin/transform/use-i18n.ts diff --git a/cmp/compiler/src/plugin/transform/utils.test.ts b/packages/new-compiler/src/plugin/transform/utils.test.ts similarity index 100% rename from cmp/compiler/src/plugin/transform/utils.test.ts rename to packages/new-compiler/src/plugin/transform/utils.test.ts diff --git a/cmp/compiler/src/plugin/transform/utils.ts b/packages/new-compiler/src/plugin/transform/utils.ts similarity index 100% rename from cmp/compiler/src/plugin/transform/utils.ts rename to packages/new-compiler/src/plugin/transform/utils.ts diff --git a/cmp/compiler/src/plugin/unplugin.ts b/packages/new-compiler/src/plugin/unplugin.ts similarity index 70% rename from cmp/compiler/src/plugin/unplugin.ts rename to packages/new-compiler/src/plugin/unplugin.ts index 028a9d57c..629fccd4d 100644 --- a/cmp/compiler/src/plugin/unplugin.ts +++ b/packages/new-compiler/src/plugin/unplugin.ts @@ -26,6 +26,12 @@ import { processBuildTranslations } from "./build-translator"; import { registerCleanupOnCurrentProcess } from "./cleanup"; import path from "path"; import fs from "fs"; +import { TranslationService } from "../translators"; +import trackEvent from "../utils/observability"; +import { + TRACKING_EVENTS, + sanitizeConfigForTracking, +} from "../utils/tracking-events"; export type LingoPluginOptions = PartialLingoConfig; @@ -33,6 +39,14 @@ let translationServer: TranslationServer; const PLUGIN_NAME = "lingo-compiler"; +// Tracking state +let alreadySentBuildStartEvent = false; +let buildStartTime: number | null = null; +let filesTransformedCount = 0; +let totalEntriesCount = 0; +let hasTransformErrors = false; +let currentFramework: "vite" | "webpack" | "next" | null = null; + function tryLocalOrReturnVirtual( config: LingoConfig, fileName: string, @@ -88,6 +102,23 @@ const virtualModulesLoaders = Object.fromEntries( Object.values(virtualModules).map((value) => [value.virtualId, value.loader]), ); +/** + * Send build start tracking event + */ +function sendBuildStartEvent( + framework: "vite" | "webpack" | "next", + config: LingoConfig, +) { + if (alreadySentBuildStartEvent) return; + alreadySentBuildStartEvent = true; + + trackEvent(TRACKING_EVENTS.BUILD_START, { + framework, + configuration: sanitizeConfigForTracking(config), + environment: config.environment, + }); +} + /** * Universal plugin for Lingo.dev compiler * Supports Vite, Webpack @@ -112,7 +143,7 @@ export const lingoUnplugin = createUnplugin< async function startServer() { const server = await startTranslationServer({ - startPort, + translationService: new TranslationService(config, logger), onError: (err) => { logger.error("Translation server error:", err); }, @@ -148,6 +179,14 @@ export const lingoUnplugin = createUnplugin< async buildStart() { const metadataFilePath = getMetadataPath(); + // Track build start + currentFramework = "vite"; + sendBuildStartEvent("vite", config); + buildStartTime = Date.now(); + filesTransformedCount = 0; + totalEntriesCount = 0; + hasTransformErrors = false; + cleanupExistingMetadata(metadataFilePath); registerCleanupOnCurrentProcess({ cleanup: () => cleanupExistingMetadata(metadataFilePath), @@ -167,9 +206,31 @@ export const lingoUnplugin = createUnplugin< publicOutputPath: "public/translations", metadataFilePath, }); + + if (buildStartTime && !hasTransformErrors) { + trackEvent(TRACKING_EVENTS.BUILD_SUCCESS, { + framework: "vite", + stats: { + totalEntries: totalEntriesCount, + filesTransformed: filesTransformedCount, + buildDuration: Date.now() - buildStartTime, + }, + environment: config.environment, + }); + } } catch (error) { logger.error("Build-time translation processing failed:", error); } + } else if (buildStartTime && !hasTransformErrors) { + trackEvent(TRACKING_EVENTS.BUILD_SUCCESS, { + framework: "vite", + stats: { + totalEntries: totalEntriesCount, + filesTransformed: filesTransformedCount, + buildDuration: Date.now() - buildStartTime, + }, + environment: config.environment, + }); } }, }, @@ -182,6 +243,14 @@ export const lingoUnplugin = createUnplugin< config.environment = webpackMode; compiler.hooks.initialize.tap(PLUGIN_NAME, () => { + // Track build start + currentFramework = "webpack"; + sendBuildStartEvent("webpack", config); + buildStartTime = Date.now(); + filesTransformedCount = 0; + totalEntriesCount = 0; + hasTransformErrors = false; + cleanupExistingMetadata(metadataFilePath); registerCleanupOnCurrentProcess({ cleanup: () => cleanupExistingMetadata(metadataFilePath), @@ -202,10 +271,32 @@ export const lingoUnplugin = createUnplugin< publicOutputPath: "public/translations", metadataFilePath, }); + + if (buildStartTime && !hasTransformErrors) { + trackEvent(TRACKING_EVENTS.BUILD_SUCCESS, { + framework: "webpack", + stats: { + totalEntries: totalEntriesCount, + filesTransformed: filesTransformedCount, + buildDuration: Date.now() - buildStartTime, + }, + environment: config.environment, + }); + } } catch (error) { logger.error("Build-time translation processing failed:", error); throw error; } + } else if (buildStartTime && !hasTransformErrors) { + trackEvent(TRACKING_EVENTS.BUILD_SUCCESS, { + framework: "webpack", + stats: { + totalEntries: totalEntriesCount, + filesTransformed: filesTransformedCount, + buildDuration: Date.now() - buildStartTime, + }, + environment: config.environment, + }); } }); @@ -268,6 +359,10 @@ export const lingoUnplugin = createUnplugin< if (result.newEntries && result.newEntries.length > 0) { await metadataManager.saveMetadataWithEntries(result.newEntries); + // Track stats for observability + totalEntriesCount += result.newEntries.length; + filesTransformedCount++; + logger.debug( `Found ${result.newEntries.length} translatable text(s) in ${id}`, ); @@ -279,6 +374,19 @@ export const lingoUnplugin = createUnplugin< map: result.map, }; } catch (error) { + hasTransformErrors = true; + + // Track error event + if (currentFramework) { + trackEvent(TRACKING_EVENTS.BUILD_ERROR, { + framework: currentFramework, + errorType: "transform", + errorMessage: error instanceof Error ? error.message : "Unknown transform error", + filePath: id, + environment: config.environment, + }); + } + logger.error(`Transform error in ${id}:`, error); return null; } diff --git a/cmp/compiler/src/plugin/vite.ts b/packages/new-compiler/src/plugin/vite.ts similarity index 100% rename from cmp/compiler/src/plugin/vite.ts rename to packages/new-compiler/src/plugin/vite.ts diff --git a/cmp/compiler/src/plugin/webpack.ts b/packages/new-compiler/src/plugin/webpack.ts similarity index 100% rename from cmp/compiler/src/plugin/webpack.ts rename to packages/new-compiler/src/plugin/webpack.ts diff --git a/cmp/compiler/src/react/README.md b/packages/new-compiler/src/react/README.md similarity index 100% rename from cmp/compiler/src/react/README.md rename to packages/new-compiler/src/react/README.md diff --git a/cmp/compiler/src/react/client/index.ts b/packages/new-compiler/src/react/client/index.ts similarity index 100% rename from cmp/compiler/src/react/client/index.ts rename to packages/new-compiler/src/react/client/index.ts diff --git a/cmp/compiler/src/react/client/useTranslation.ts b/packages/new-compiler/src/react/client/useTranslation.ts similarity index 100% rename from cmp/compiler/src/react/client/useTranslation.ts rename to packages/new-compiler/src/react/client/useTranslation.ts diff --git a/cmp/compiler/src/react/next/client.tsx b/packages/new-compiler/src/react/next/client.tsx similarity index 100% rename from cmp/compiler/src/react/next/client.tsx rename to packages/new-compiler/src/react/next/client.tsx diff --git a/cmp/compiler/src/react/next/cookie-locale-resolver.ts b/packages/new-compiler/src/react/next/cookie-locale-resolver.ts similarity index 100% rename from cmp/compiler/src/react/next/cookie-locale-resolver.ts rename to packages/new-compiler/src/react/next/cookie-locale-resolver.ts diff --git a/cmp/compiler/src/react/next/server.tsx b/packages/new-compiler/src/react/next/server.tsx similarity index 100% rename from cmp/compiler/src/react/next/server.tsx rename to packages/new-compiler/src/react/next/server.tsx diff --git a/cmp/compiler/src/react/server-only/index.test.tsx b/packages/new-compiler/src/react/server-only/index.test.tsx similarity index 100% rename from cmp/compiler/src/react/server-only/index.test.tsx rename to packages/new-compiler/src/react/server-only/index.test.tsx diff --git a/cmp/compiler/src/react/server-only/index.ts b/packages/new-compiler/src/react/server-only/index.ts similarity index 100% rename from cmp/compiler/src/react/server-only/index.ts rename to packages/new-compiler/src/react/server-only/index.ts diff --git a/cmp/compiler/src/react/server-only/translations.ts b/packages/new-compiler/src/react/server-only/translations.ts similarity index 100% rename from cmp/compiler/src/react/server-only/translations.ts rename to packages/new-compiler/src/react/server-only/translations.ts diff --git a/cmp/compiler/src/react/server/ServerLingoProvider.tsx b/packages/new-compiler/src/react/server/ServerLingoProvider.tsx similarity index 100% rename from cmp/compiler/src/react/server/ServerLingoProvider.tsx rename to packages/new-compiler/src/react/server/ServerLingoProvider.tsx diff --git a/cmp/compiler/src/react/server/index.ts b/packages/new-compiler/src/react/server/index.ts similarity index 100% rename from cmp/compiler/src/react/server/index.ts rename to packages/new-compiler/src/react/server/index.ts diff --git a/cmp/compiler/src/react/server/useTranslation.ts b/packages/new-compiler/src/react/server/useTranslation.ts similarity index 100% rename from cmp/compiler/src/react/server/useTranslation.ts rename to packages/new-compiler/src/react/server/useTranslation.ts diff --git a/cmp/compiler/src/react/shared/LingoContext.ts b/packages/new-compiler/src/react/shared/LingoContext.ts similarity index 100% rename from cmp/compiler/src/react/shared/LingoContext.ts rename to packages/new-compiler/src/react/shared/LingoContext.ts diff --git a/cmp/compiler/src/react/shared/LingoProvider.tsx b/packages/new-compiler/src/react/shared/LingoProvider.tsx similarity index 100% rename from cmp/compiler/src/react/shared/LingoProvider.tsx rename to packages/new-compiler/src/react/shared/LingoProvider.tsx diff --git a/cmp/compiler/src/react/shared/LocaleSwitcher.tsx b/packages/new-compiler/src/react/shared/LocaleSwitcher.tsx similarity index 100% rename from cmp/compiler/src/react/shared/LocaleSwitcher.tsx rename to packages/new-compiler/src/react/shared/LocaleSwitcher.tsx diff --git a/cmp/compiler/src/react/shared/__snapshots__/render-rich-text.test.tsx.snap b/packages/new-compiler/src/react/shared/__snapshots__/render-rich-text.test.tsx.snap similarity index 100% rename from cmp/compiler/src/react/shared/__snapshots__/render-rich-text.test.tsx.snap rename to packages/new-compiler/src/react/shared/__snapshots__/render-rich-text.test.tsx.snap diff --git a/cmp/compiler/src/react/shared/render-rich-text.test.tsx b/packages/new-compiler/src/react/shared/render-rich-text.test.tsx similarity index 98% rename from cmp/compiler/src/react/shared/render-rich-text.test.tsx rename to packages/new-compiler/src/react/shared/render-rich-text.test.tsx index 7bfad13ef..cd7fc2899 100644 --- a/cmp/compiler/src/react/shared/render-rich-text.test.tsx +++ b/packages/new-compiler/src/react/shared/render-rich-text.test.tsx @@ -28,7 +28,7 @@ const serializer = { // If there's a key prop, include it in the props if (element.key != null) { clone.props = { - ...clone.props, + ...(typeof clone.props === 'object' && clone.props !== null ? clone.props : {}), "data-key": element.key, // Use data-key to make it visible in snapshots }; } diff --git a/cmp/compiler/src/react/shared/render-rich-text.tsx b/packages/new-compiler/src/react/shared/render-rich-text.tsx similarity index 100% rename from cmp/compiler/src/react/shared/render-rich-text.tsx rename to packages/new-compiler/src/react/shared/render-rich-text.tsx diff --git a/cmp/compiler/src/react/shared/test-serializer.ts b/packages/new-compiler/src/react/shared/test-serializer.ts similarity index 100% rename from cmp/compiler/src/react/shared/test-serializer.ts rename to packages/new-compiler/src/react/shared/test-serializer.ts diff --git a/cmp/compiler/src/react/shared/utils.ts b/packages/new-compiler/src/react/shared/utils.ts similarity index 100% rename from cmp/compiler/src/react/shared/utils.ts rename to packages/new-compiler/src/react/shared/utils.ts diff --git a/cmp/compiler/src/react/types.ts b/packages/new-compiler/src/react/types.ts similarity index 100% rename from cmp/compiler/src/react/types.ts rename to packages/new-compiler/src/react/types.ts diff --git a/cmp/compiler/src/translation-server/README.md b/packages/new-compiler/src/translation-server/README.md similarity index 100% rename from cmp/compiler/src/translation-server/README.md rename to packages/new-compiler/src/translation-server/README.md diff --git a/cmp/compiler/src/translation-server/cli.ts b/packages/new-compiler/src/translation-server/cli.ts similarity index 99% rename from cmp/compiler/src/translation-server/cli.ts rename to packages/new-compiler/src/translation-server/cli.ts index 99c60a724..41643a962 100644 --- a/cmp/compiler/src/translation-server/cli.ts +++ b/packages/new-compiler/src/translation-server/cli.ts @@ -444,7 +444,6 @@ export async function main(): Promise { // Start server const { server, url } = await startOrGetTranslationServer({ - startPort, config, // requestTimeout: cliOpts.timeout || 30000, onError: (err) => { diff --git a/cmp/compiler/src/translation-server/index.ts b/packages/new-compiler/src/translation-server/index.ts similarity index 100% rename from cmp/compiler/src/translation-server/index.ts rename to packages/new-compiler/src/translation-server/index.ts diff --git a/cmp/compiler/src/translation-server/logger.ts b/packages/new-compiler/src/translation-server/logger.ts similarity index 100% rename from cmp/compiler/src/translation-server/logger.ts rename to packages/new-compiler/src/translation-server/logger.ts diff --git a/cmp/compiler/src/translation-server/translation-server.test.ts b/packages/new-compiler/src/translation-server/translation-server.test.ts similarity index 100% rename from cmp/compiler/src/translation-server/translation-server.test.ts rename to packages/new-compiler/src/translation-server/translation-server.test.ts diff --git a/cmp/compiler/src/translation-server/translation-server.ts b/packages/new-compiler/src/translation-server/translation-server.ts similarity index 93% rename from cmp/compiler/src/translation-server/translation-server.ts rename to packages/new-compiler/src/translation-server/translation-server.ts index 01fc00831..7c9695cd6 100644 --- a/cmp/compiler/src/translation-server/translation-server.ts +++ b/packages/new-compiler/src/translation-server/translation-server.ts @@ -17,11 +17,7 @@ import { URL } from "url"; import { WebSocket, WebSocketServer } from "ws"; import type { MetadataSchema, TranslationMiddlewareConfig } from "../types"; import { getLogger } from "./logger"; -import { - createCache, - createTranslator, - TranslationService, -} from "../translators"; +import { TranslationService } from "../translators"; import { createEmptyMetadata, getMetadataPath, @@ -33,25 +29,9 @@ import type { LocaleCode } from "lingo.dev/spec"; import { parseLocaleOrThrow } from "../utils/is-valid-locale"; export interface TranslationServerOptions { - /** - * Starting port to try (will find next available if taken) - * @default 3456 - */ - startPort?: number; - - /** - * Configuration for translation generation - */ config: TranslationMiddlewareConfig; - - /** - * Callback when server is ready - */ + translationService?: TranslationService; onReady?: (port: number) => void; - - /** - * Callback on error - */ onError?: (error: Error) => void; } @@ -59,12 +39,11 @@ export class TranslationServer { private server: http.Server | null = null; private url: string | undefined = undefined; private logger; - private config: TranslationMiddlewareConfig; - private configHash: string; - private startPort: number; - private onReadyCallback?: (port: number) => void; - private onErrorCallback?: (error: Error) => void; - private translationService: TranslationService | null = null; + private readonly config: TranslationMiddlewareConfig; + private readonly configHash: string; + private readonly startPort: number; + private readonly onReadyCallback?: (port: number) => void; + private readonly onErrorCallback?: (error: Error) => void; private metadata: MetadataSchema | null = null; private connections: Set = new Set(); private wss: WebSocketServer | null = null; @@ -75,11 +54,16 @@ export class TranslationServer { private isBusy = false; private busyTimeout: NodeJS.Timeout | null = null; private readonly BUSY_DEBOUNCE_MS = 500; // Time after last translation to send "idle" event + private readonly translationService: TranslationService; constructor(options: TranslationServerOptions) { this.config = options.config; this.configHash = hashConfig(options.config); - this.startPort = options.startPort || 60000; + this.translationService = + options.translationService ?? + // Fallback is for CLI start only. + new TranslationService(options.config, getLogger(options.config)); + this.startPort = options.config.dev.translationServerStartPort; this.onReadyCallback = options.onReady; this.onErrorCallback = options.onError; this.logger = getLogger(this.config); @@ -95,19 +79,6 @@ export class TranslationServer { this.logger.info(`🔧 Initializing translator...`); - const translator = createTranslator(this.config, this.logger); - const cache = createCache(this.config); - - this.translationService = new TranslationService( - translator, - cache, - { - sourceLocale: this.config.sourceLocale, - pluralization: this.config.pluralization, - }, - this.logger, - ); - const port = await this.findAvailablePort(this.startPort); return new Promise((resolve, reject) => { @@ -281,14 +252,13 @@ export class TranslationServer { * Start a new server or get the URL of an existing one on the preferred port. * * This method optimizes for the common case where a translation server is already - * running on port 60000. If that port is taken, it checks if it's our service + * running on a preferred port. If that port is taken, it checks if it's our service * by calling the health check endpoint. If it is, we reuse it instead of starting * a new server on a different port. * * @returns URL of the running server (new or existing) */ async startOrGetUrl(): Promise { - // If this instance already has a server running, return its URL if (this.server && this.url) { this.logger.info(`Using existing server instance at ${this.url}`); return this.url; @@ -527,7 +497,6 @@ export class TranslationServer { res.on("end", () => { try { - // Check if response is valid and has the expected structure if (res.statusCode === 200) { const json = JSON.parse(data); // Our translation server returns { status: "ok", port: ..., configHash: ... } @@ -680,11 +649,6 @@ export class TranslationServer { ); return; } - - if (!this.translationService) { - throw new Error("Translation service not initialized"); - } - // Reload metadata to ensure we have the latest entries // (new entries may have been added since server started) await this.reloadMetadata(); @@ -747,10 +711,6 @@ export class TranslationServer { try { const parsedLocale = parseLocaleOrThrow(locale); - if (!this.translationService) { - throw new Error("Translation service not initialized"); - } - // Reload metadata to ensure we have the latest entries // (new entries may have been added since server started) await this.reloadMetadata(); @@ -842,9 +802,6 @@ export function hashConfig(config: Record): string { return crypto.createHash("md5").update(serialized).digest("hex").slice(0, 12); } -/** - * Create and start a translation server - */ export async function startTranslationServer( options: TranslationServerOptions, ): Promise { @@ -856,10 +813,10 @@ export async function startTranslationServer( /** * Create a translation server and start it or reuse an existing one on the preferred port * - * Since we have little control over the dev server start in next, we can start the translation server only in the loader, - * and loaders could be started from multiple processes (it seems) or similar we need a way to avoid starting multiple servers. + * Since we have little control over the dev server start in next, we can start the translation server only in the async config or in the loader, + * they both could be run in different processes, and we need a way to avoid starting multiple servers. * This one will try to start a server on the preferred port (which seems to be an atomic operation), and if it fails, - * it checks if the server already started is ours and returns its url. + * it checks if the server that is already started is ours and returns its url. * * @returns Object containing the server instance and its URL */ diff --git a/cmp/compiler/src/translation-server/ws-events.ts b/packages/new-compiler/src/translation-server/ws-events.ts similarity index 100% rename from cmp/compiler/src/translation-server/ws-events.ts rename to packages/new-compiler/src/translation-server/ws-events.ts diff --git a/cmp/compiler/src/translators/README.md b/packages/new-compiler/src/translators/README.md similarity index 100% rename from cmp/compiler/src/translators/README.md rename to packages/new-compiler/src/translators/README.md diff --git a/cmp/compiler/src/translators/USAGE.md b/packages/new-compiler/src/translators/USAGE.md similarity index 100% rename from cmp/compiler/src/translators/USAGE.md rename to packages/new-compiler/src/translators/USAGE.md diff --git a/cmp/compiler/src/translators/api.ts b/packages/new-compiler/src/translators/api.ts similarity index 100% rename from cmp/compiler/src/translators/api.ts rename to packages/new-compiler/src/translators/api.ts diff --git a/cmp/compiler/src/translators/cache-factory.ts b/packages/new-compiler/src/translators/cache-factory.ts similarity index 91% rename from cmp/compiler/src/translators/cache-factory.ts rename to packages/new-compiler/src/translators/cache-factory.ts index d13cef1ea..e4a197f66 100644 --- a/cmp/compiler/src/translators/cache-factory.ts +++ b/packages/new-compiler/src/translators/cache-factory.ts @@ -8,6 +8,8 @@ import { LocalTranslationCache } from "./local-cache"; import { logger } from "../utils/logger"; import { getCacheDir } from "../utils/path-helpers"; +export type CacheConfig = Pick & PathConfig; + /** * Create a cache instance based on the config * @@ -21,7 +23,7 @@ import { getCacheDir } from "../utils/path-helpers"; * ``` */ export function createCache( - config: Pick & PathConfig, + config: CacheConfig, ): TranslationCache { switch (config.cacheType) { case "local": diff --git a/cmp/compiler/src/translators/cache.ts b/packages/new-compiler/src/translators/cache.ts similarity index 100% rename from cmp/compiler/src/translators/cache.ts rename to packages/new-compiler/src/translators/cache.ts diff --git a/cmp/compiler/src/translators/index.ts b/packages/new-compiler/src/translators/index.ts similarity index 88% rename from cmp/compiler/src/translators/index.ts rename to packages/new-compiler/src/translators/index.ts index 115c51884..5bb61fabb 100644 --- a/cmp/compiler/src/translators/index.ts +++ b/packages/new-compiler/src/translators/index.ts @@ -9,9 +9,8 @@ export type { Translator, TranslatableEntry } from "./api"; // Translators export { PseudoTranslator } from "./pseudotranslator"; -export { Service } from "./lingo"; +export { LingoTranslator } from "./lingo"; export type { LingoTranslatorConfig } from "./lingo"; -export { createTranslator } from "./translator-factory"; // Translation Service (orchestrator) export { TranslationService } from "./translation-service"; diff --git a/cmp/compiler/src/translators/lingo/README.md b/packages/new-compiler/src/translators/lingo/README.md similarity index 100% rename from cmp/compiler/src/translators/lingo/README.md rename to packages/new-compiler/src/translators/lingo/README.md diff --git a/packages/new-compiler/src/translators/lingo/index.ts b/packages/new-compiler/src/translators/lingo/index.ts new file mode 100644 index 000000000..a09f9adff --- /dev/null +++ b/packages/new-compiler/src/translators/lingo/index.ts @@ -0,0 +1,8 @@ +/** + * Lingo Translation Engine + * + * Real AI-powered translation using various LLM providers + */ + +export { LingoTranslator } from "./translator"; +export type { LingoTranslatorConfig } from "./translator"; diff --git a/cmp/compiler/src/translators/lingo/model-factory.ts b/packages/new-compiler/src/translators/lingo/model-factory.ts similarity index 91% rename from cmp/compiler/src/translators/lingo/model-factory.ts rename to packages/new-compiler/src/translators/lingo/model-factory.ts index 9fa15932b..8bb7e5203 100644 --- a/cmp/compiler/src/translators/lingo/model-factory.ts +++ b/packages/new-compiler/src/translators/lingo/model-factory.ts @@ -5,11 +5,12 @@ import { createGroq } from "@ai-sdk/groq"; import { createGoogleGenerativeAI } from "@ai-sdk/google"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; -import { createOllama } from "ollama-ai-provider"; +import { ollama } from "ai-sdk-ollama"; import { createMistral } from "@ai-sdk/mistral"; import type { LanguageModel } from "ai"; import * as dotenv from "dotenv"; import * as path from "path"; +import { formatNoApiKeysError } from "./provider-details"; export type LocaleModel = { provider: string; @@ -169,7 +170,7 @@ export function validateAndGetApiKeys( config: "lingo.dev" | Record, ): ValidatedApiKeys { const keys: ValidatedApiKeys = {}; - const missingKeys: Array<{ provider: string; envVar: string }> = []; + const missingProviders: string[] = []; // Determine which providers are configured let providersToValidate: string[]; @@ -195,7 +196,7 @@ export function validateAndGetApiKeys( if (!providerConfig) { throw new Error( - `⚠️ Unknown provider "${provider}". Supported providers: ${Object.keys(providerDetails).join(", ")}`, + `⚠️ Unknown provider "${provider}". Supported providers: ${Object.keys(providerDetails).join(", ")}`, ); } @@ -208,21 +209,13 @@ export function validateAndGetApiKeys( if (key) { keys[provider] = key; } else { - missingKeys.push({ - provider: providerConfig.name, - envVar: providerConfig.apiKeyEnvVar, - }); + missingProviders.push(provider); } } // If any keys are missing, throw with detailed error - if (missingKeys.length > 0) { - const errorLines = missingKeys.map( - ({ provider, envVar }) => ` - ${provider}: ${envVar}`, - ); - throw new Error( - `⚠️ Missing API keys for configured providers:\n${errorLines.join("\n")}\n\nPlease set the required environment variables.`, - ); + if (missingProviders.length > 0) { + throw new Error(formatNoApiKeysError(missingProviders)); } return keys; @@ -253,6 +246,7 @@ export function createAiModel( ? validatedKeys[model.provider] : undefined; + // TODO (AleksandrSl 25/12/2025): Do we really need to make a second check? Maybe creation should be combined with validation. // Verify key is present for providers that require it if (providerConfig.apiKeyEnvVar && !apiKey) { throw new Error( @@ -273,7 +267,7 @@ export function createAiModel( return createOpenRouter({ apiKey: apiKey! })(model.name); case "ollama": - return createOllama()(model.name); + return ollama(model.name); case "mistral": return createMistral({ apiKey: apiKey! })(model.name); diff --git a/cmp/compiler/src/translators/lingo/prompt.ts b/packages/new-compiler/src/translators/lingo/prompt.ts similarity index 100% rename from cmp/compiler/src/translators/lingo/prompt.ts rename to packages/new-compiler/src/translators/lingo/prompt.ts diff --git a/packages/new-compiler/src/translators/lingo/provider-details.ts b/packages/new-compiler/src/translators/lingo/provider-details.ts new file mode 100644 index 000000000..211a51311 --- /dev/null +++ b/packages/new-compiler/src/translators/lingo/provider-details.ts @@ -0,0 +1,109 @@ +/** + * Provider details for error messages and documentation links + */ + +export interface ProviderDetails { + name: string; // Display name (e.g., "Groq", "Google") + apiKeyEnvVar?: string; // Environment variable name (e.g., "GROQ_API_KEY") + apiKeyConfigKey?: string; // Config key if applicable (e.g., "llm.groqApiKey") + getKeyLink: string; // Link to get API key + docsLink: string; // Link to API docs for troubleshooting +} + +export const providerDetails: Record = { + groq: { + name: "Groq", + apiKeyEnvVar: "GROQ_API_KEY", + apiKeyConfigKey: "llm.groqApiKey", + getKeyLink: "https://groq.com", + docsLink: "https://console.groq.com/docs/errors", + }, + google: { + name: "Google", + apiKeyEnvVar: "GOOGLE_API_KEY", + apiKeyConfigKey: "llm.googleApiKey", + getKeyLink: "https://ai.google.dev/", + docsLink: "https://ai.google.dev/gemini-api/docs/troubleshooting", + }, + openrouter: { + name: "OpenRouter", + apiKeyEnvVar: "OPENROUTER_API_KEY", + apiKeyConfigKey: "llm.openrouterApiKey", + getKeyLink: "https://openrouter.ai", + docsLink: "https://openrouter.ai/docs", + }, + ollama: { + name: "Ollama", + apiKeyEnvVar: undefined, + apiKeyConfigKey: undefined, + getKeyLink: "https://ollama.com/download", + docsLink: "https://github.com/ollama/ollama/tree/main/docs", + }, + mistral: { + name: "Mistral", + apiKeyEnvVar: "MISTRAL_API_KEY", + apiKeyConfigKey: "llm.mistralApiKey", + getKeyLink: "https://console.mistral.ai", + docsLink: "https://docs.mistral.ai", + }, + "lingo.dev": { + name: "Lingo.dev", + apiKeyEnvVar: "LINGODOTDEV_API_KEY", + apiKeyConfigKey: "auth.apiKey", + getKeyLink: "https://lingo.dev", + docsLink: "https://lingo.dev/docs", + }, +}; + +/** + * Format error message when API keys are missing for configured providers + * @param missingProviders List of providers that are missing API keys + * @param allProviders Optional: list of all configured providers for context + */ +export function formatNoApiKeysError( + missingProviders: string[], + allProviders?: string[], +): string { + const lines: string[] = []; + + if (missingProviders.length === 0) { + // No missing providers (shouldn't happen, but handle it) + return "Translation API keys validated successfully."; + } + + // Header + if (allProviders && allProviders.length > missingProviders.length) { + lines.push( + `Missing API keys for ${missingProviders.length} of ${allProviders.length} configured providers.`, + ); + } else { + lines.push(`Missing API keys for configured translation providers.`); + } + + // List missing providers with their environment variables and links + lines.push(`Missing API keys for:`); + for (const providerId of missingProviders) { + const details = providerDetails[providerId]; + if (details) { + if (details.apiKeyEnvVar) { + lines.push( + ` • ${details.name}: ${details.apiKeyEnvVar} → ${details.getKeyLink}`, + ); + } else { + lines.push(` • ${details.name}: ${details.getKeyLink}`); + } + } else { + lines.push(` • ${providerId}: (unknown provider)`); + } + } + + lines.push( + ``, + `👉 Set the required API keys:`, + ` 1. Add to .env file (recommended)`, + ` 2. Or export in terminal: export API_KEY_NAME=`, + ``, + ); + + return lines.join("\n"); +} diff --git a/cmp/compiler/src/translators/lingo/shots.ts b/packages/new-compiler/src/translators/lingo/shots.ts similarity index 100% rename from cmp/compiler/src/translators/lingo/shots.ts rename to packages/new-compiler/src/translators/lingo/shots.ts diff --git a/cmp/compiler/src/translators/lingo/service.ts b/packages/new-compiler/src/translators/lingo/translator.ts similarity index 86% rename from cmp/compiler/src/translators/lingo/service.ts rename to packages/new-compiler/src/translators/lingo/translator.ts index e77e99d6f..3450208c0 100644 --- a/cmp/compiler/src/translators/lingo/service.ts +++ b/packages/new-compiler/src/translators/lingo/translator.ts @@ -1,20 +1,10 @@ import { generateText } from "ai"; import { LingoDotDevEngine } from "lingo.dev/sdk"; -import { - dictionaryFrom, - type DictionarySchema, - type TranslatableEntry, - type Translator, -} from "../api"; +import { dictionaryFrom, type DictionarySchema, type TranslatableEntry, type Translator, } from "../api"; import { getSystemPrompt } from "./prompt"; import { obj2xml, parseXmlFromResponseText } from "../parse-xml"; import { shots } from "./shots"; -import { - createAiModel, - getLocaleModel, - validateAndGetApiKeys, - type ValidatedApiKeys, -} from "./model-factory"; +import { createAiModel, getLocaleModel, validateAndGetApiKeys, type ValidatedApiKeys, } from "./model-factory"; import { Logger } from "../../utils/logger"; import { DEFAULT_TIMEOUTS, withTimeout } from "../../utils/timeout"; import type { LocaleCode } from "lingo.dev/spec"; @@ -31,7 +21,7 @@ export interface LingoTranslatorConfig { /** * Lingo translator using AI models */ -export class Service implements Translator { +export class LingoTranslator implements Translator { private readonly validatedKeys: ValidatedApiKeys; constructor( @@ -51,7 +41,7 @@ export class Service implements Translator { entriesMap: Record, ): Promise> { this.logger.debug( - `[TRACE-LINGO] translate() called for ${locale} with ${Object.keys(entriesMap).length} entries`, + `translate() called for ${locale} with ${Object.keys(entriesMap).length} entries`, ); const sourceDictionary: DictionarySchema = dictionaryFrom( @@ -62,7 +52,7 @@ export class Service implements Translator { ); this.logger.debug( - `[TRACE-LINGO] Created source dictionary with ${Object.keys(sourceDictionary.entries).length} entries`, + `Created source dictionary with ${Object.keys(sourceDictionary.entries).length} entries`, ); const translated = await this.translateDictionary(sourceDictionary, locale); @@ -76,18 +66,17 @@ export class Service implements Translator { sourceDictionary: DictionarySchema, targetLocale: string, ): Promise { + const chunks = this.chunkDictionary(sourceDictionary); this.logger.debug( - `[TRACE-LINGO] Chunking dictionary with ${Object.keys(sourceDictionary.entries).length} entries`, + `Split dictionary with ${Object.keys(sourceDictionary.entries).length} into ${chunks.length} chunks`, ); - const chunks = this.chunkDictionary(sourceDictionary); - this.logger.debug(`[TRACE-LINGO] Split into ${chunks.length} chunks`); const translatedChunks: DictionarySchema[] = []; for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; this.logger.debug( - `[TRACE-LINGO] Translating chunk ${i + 1}/${chunks.length} with ${Object.keys(chunk.entries).length} entries`, + `Translating chunk ${i + 1}/${chunks.length} with ${Object.keys(chunk.entries).length} entries`, ); const chunkStartTime = performance.now(); @@ -95,16 +84,15 @@ export class Service implements Translator { const chunkEndTime = performance.now(); this.logger.debug( - `[TRACE-LINGO] Chunk ${i + 1}/${chunks.length} completed in ${(chunkEndTime - chunkStartTime).toFixed(2)}ms`, + `Chunk ${i + 1}/${chunks.length} completed in ${(chunkEndTime - chunkStartTime).toFixed(2)}ms`, ); translatedChunks.push(translatedChunk); } - this.logger.debug(`[TRACE-LINGO] All chunks translated, merging results`); const result = this.mergeDictionaries(translatedChunks); this.logger.debug( - `[TRACE-LINGO] Merge completed, final dictionary has ${Object.keys(result.entries).length} entries`, + `Merge completed, final dictionary has ${Object.keys(result.entries).length} entries`, ); return result; @@ -139,7 +127,7 @@ export class Service implements Translator { ); } - this.logger.info( + this.logger.debug( `Using Lingo.dev Engine to localize from "${this.config.sourceLocale}" to "${targetLocale}"`, ); @@ -184,7 +172,7 @@ export class Service implements Translator { ); } - this.logger.info( + this.logger.debug( `Using LLM ("${localeModel.provider}":"${localeModel.name}") to translate from "${this.config.sourceLocale}" to "${targetLocale}"`, ); diff --git a/cmp/compiler/src/translators/local-cache.ts b/packages/new-compiler/src/translators/local-cache.ts similarity index 100% rename from cmp/compiler/src/translators/local-cache.ts rename to packages/new-compiler/src/translators/local-cache.ts diff --git a/packages/new-compiler/src/translators/memory-cache.ts b/packages/new-compiler/src/translators/memory-cache.ts new file mode 100644 index 000000000..74fa5ae6e --- /dev/null +++ b/packages/new-compiler/src/translators/memory-cache.ts @@ -0,0 +1,67 @@ +import type { TranslationCache } from "./cache"; +import type { LocaleCode } from "lingo.dev/spec"; + +/** + * In memory translation cache implementation + */ +export class MemoryTranslationCache implements TranslationCache { + private cache: Map> = new Map(); + + constructor() {} + + async get( + locale: LocaleCode, + hashes?: string[], + ): Promise> { + const localeCache = this.cache.get(locale); + if (!localeCache) { + return {}; + } + if (hashes) { + return hashes.reduce( + (acc, hash) => ({ ...acc, [hash]: localeCache.get(hash) }), + {}, + ); + } + return Object.fromEntries(localeCache); + } + + /** + * Update cache with new translations (merge) + */ + async update( + locale: LocaleCode, + translations: Record, + ): Promise { + let localeCache = this.cache.get(locale); + if (!localeCache) { + localeCache = new Map(); + this.cache.set(locale, localeCache); + } + for (const [key, value] of Object.entries(translations)) { + localeCache.set(key, value); + } + } + + /** + * Replace entire cache for a locale + */ + async set( + locale: LocaleCode, + translations: Record, + ): Promise { + this.cache.set(locale, new Map(Object.entries(translations))); + } + + async has(locale: LocaleCode): Promise { + return this.cache.has(locale); + } + + async clear(locale: LocaleCode): Promise { + this.cache.delete(locale); + } + + async clearAll(): Promise { + this.cache.clear(); + } +} diff --git a/cmp/compiler/src/translators/parse-xml.test.ts b/packages/new-compiler/src/translators/parse-xml.test.ts similarity index 100% rename from cmp/compiler/src/translators/parse-xml.test.ts rename to packages/new-compiler/src/translators/parse-xml.test.ts diff --git a/cmp/compiler/src/translators/parse-xml.ts b/packages/new-compiler/src/translators/parse-xml.ts similarity index 100% rename from cmp/compiler/src/translators/parse-xml.ts rename to packages/new-compiler/src/translators/parse-xml.ts diff --git a/cmp/compiler/src/translators/pluralization/icu-validator.test.ts b/packages/new-compiler/src/translators/pluralization/icu-validator.test.ts similarity index 100% rename from cmp/compiler/src/translators/pluralization/icu-validator.test.ts rename to packages/new-compiler/src/translators/pluralization/icu-validator.test.ts diff --git a/cmp/compiler/src/translators/pluralization/icu-validator.ts b/packages/new-compiler/src/translators/pluralization/icu-validator.ts similarity index 100% rename from cmp/compiler/src/translators/pluralization/icu-validator.ts rename to packages/new-compiler/src/translators/pluralization/icu-validator.ts diff --git a/cmp/compiler/src/translators/pluralization/index.ts b/packages/new-compiler/src/translators/pluralization/index.ts similarity index 100% rename from cmp/compiler/src/translators/pluralization/index.ts rename to packages/new-compiler/src/translators/pluralization/index.ts diff --git a/cmp/compiler/src/translators/pluralization/pattern-detector.test.ts b/packages/new-compiler/src/translators/pluralization/pattern-detector.test.ts similarity index 100% rename from cmp/compiler/src/translators/pluralization/pattern-detector.test.ts rename to packages/new-compiler/src/translators/pluralization/pattern-detector.test.ts diff --git a/cmp/compiler/src/translators/pluralization/pattern-detector.ts b/packages/new-compiler/src/translators/pluralization/pattern-detector.ts similarity index 100% rename from cmp/compiler/src/translators/pluralization/pattern-detector.ts rename to packages/new-compiler/src/translators/pluralization/pattern-detector.ts diff --git a/cmp/compiler/src/translators/pluralization/prompt.ts b/packages/new-compiler/src/translators/pluralization/prompt.ts similarity index 100% rename from cmp/compiler/src/translators/pluralization/prompt.ts rename to packages/new-compiler/src/translators/pluralization/prompt.ts diff --git a/cmp/compiler/src/translators/pluralization/service.ts b/packages/new-compiler/src/translators/pluralization/service.ts similarity index 88% rename from cmp/compiler/src/translators/pluralization/service.ts rename to packages/new-compiler/src/translators/pluralization/service.ts index 46e93d811..c2064df50 100644 --- a/cmp/compiler/src/translators/pluralization/service.ts +++ b/packages/new-compiler/src/translators/pluralization/service.ts @@ -41,24 +41,22 @@ export class PluralizationService { ) { const localeModel = parseModelString(config.model); if (!localeModel) { - throw new Error(`Invalid model format: "${config.model}"`); + throw new Error(`Invalid model format in pluralization service: "${config.model}"`); } // Validate and fetch API keys for the pluralization provider // We need to create a models config that validateAndFetchApiKeys can use const modelsConfig: Record = { - "*:*": config.model, // Single model for pluralization + "*:*": config.model, }; - this.logger.info("Validating API keys for pluralization..."); const validatedKeys = validateAndGetApiKeys(modelsConfig); - this.logger.info("✅ API keys validated for pluralization"); this.languageModel = createAiModel(localeModel, validatedKeys); this.sourceLocale = config.sourceLocale; this.prompt = getSystemPrompt({ sourceLocale: config.sourceLocale }); - this.logger.info( + this.logger.debug( `Initialized pluralization service with ${localeModel.provider}:${localeModel.name}`, ); } @@ -74,26 +72,27 @@ export class PluralizationService { candidates: PluralCandidate[], batchSize: number = 10, ): Promise> { - const results = new Map(); - - // Check cache first - const uncachedCandidates = candidates.filter((c) => { - const cached = this.cache.get(c.hash); - if (cached) { - results.set(c.hash, cached); - return false; - } - return true; - }); + const { uncachedCandidates, results } = candidates.reduce( + (acc, c) => { + const cached = this.cache.get(c.hash); + if (cached) { + acc.results.set(c.hash, cached); + } else { + acc.uncachedCandidates.push(c); + } + return acc; + }, + { + uncachedCandidates: [] as PluralCandidate[], + results: new Map(), + }, + ); if (uncachedCandidates.length === 0) { - this.logger.debug( - `All ${candidates.length} candidates found in cache, skipping LLM call`, - ); return results; } - this.logger.info( + this.logger.debug( `Processing ${uncachedCandidates.length} candidates (${candidates.length - uncachedCandidates.length} cached)`, ); @@ -101,7 +100,7 @@ export class PluralizationService { for (let i = 0; i < uncachedCandidates.length; i += batchSize) { const batch = uncachedCandidates.slice(i, i + batchSize); - this.logger.info( + this.logger.debug( `Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(uncachedCandidates.length / batchSize)} (${batch.length} candidates)`, ); @@ -165,7 +164,7 @@ export class PluralizationService { ], }), DEFAULT_TIMEOUTS.AI_API * 2, // Double timeout for batch - `Pluralization with ${this.languageModel.provider}`, + `Pluralization with ${this.languageModel}`, ); const responseText = response.text.trim(); @@ -212,7 +211,7 @@ export class PluralizationService { for (const candidate of candidates) { if (!results.has(candidate.hash)) { this.logger.warn( - `No result returned for candidate: ${candidate.sourceText}`, + `No result returned for a candidate: ${candidate.sourceText}`, ); results.set(candidate.hash, { success: false, @@ -264,7 +263,7 @@ export class PluralizationService { }; } - this.logger.info( + this.logger.debug( `Starting pluralization processing for ${totalEntries} entries`, ); @@ -278,7 +277,7 @@ export class PluralizationService { const candidates = detectPluralCandidates(entriesMap, this.logger); - this.logger.info( + this.logger.debug( `Found ${candidates.length} plural candidates (${((candidates.length / totalEntries) * 100).toFixed(1)}%)`, ); @@ -350,8 +349,7 @@ export class PluralizationService { continue; } - // Update metadata entry in-place - this.logger.info( + this.logger.debug( `Pluralizing: "${entry.sourceText}" -> "${result.icuText}"`, ); entry.sourceText = result.icuText; @@ -361,7 +359,7 @@ export class PluralizationService { const endTime = performance.now(); const duration = endTime - startTime; - this.logger.info( + this.logger.debug( `Pluralization completed: ${pluralized} pluralized, ${rejected} rejected, ${failed} failed in ${duration.toFixed(0)}ms`, ); @@ -374,22 +372,4 @@ export class PluralizationService { durationMs: duration, }; } - - /** - * Clear the cache - */ - clearCache(): void { - this.cache.clear(); - this.logger.debug("Pluralization cache cleared"); - } - - /** - * Get cache statistics - */ - getCacheStats(): { size: number; hits: number } { - return { - size: this.cache.size, - hits: 0, // We don't track hits currently - }; - } } diff --git a/cmp/compiler/src/translators/pluralization/shots.ts b/packages/new-compiler/src/translators/pluralization/shots.ts similarity index 100% rename from cmp/compiler/src/translators/pluralization/shots.ts rename to packages/new-compiler/src/translators/pluralization/shots.ts diff --git a/cmp/compiler/src/translators/pluralization/types.ts b/packages/new-compiler/src/translators/pluralization/types.ts similarity index 100% rename from cmp/compiler/src/translators/pluralization/types.ts rename to packages/new-compiler/src/translators/pluralization/types.ts diff --git a/cmp/compiler/src/translators/pseudotranslator/index.test.ts b/packages/new-compiler/src/translators/pseudotranslator/index.test.ts similarity index 100% rename from cmp/compiler/src/translators/pseudotranslator/index.test.ts rename to packages/new-compiler/src/translators/pseudotranslator/index.test.ts diff --git a/cmp/compiler/src/translators/pseudotranslator/index.ts b/packages/new-compiler/src/translators/pseudotranslator/index.ts similarity index 83% rename from cmp/compiler/src/translators/pseudotranslator/index.ts rename to packages/new-compiler/src/translators/pseudotranslator/index.ts index 5e1eff1cd..78d6ca19f 100644 --- a/cmp/compiler/src/translators/pseudotranslator/index.ts +++ b/packages/new-compiler/src/translators/pseudotranslator/index.ts @@ -21,25 +21,11 @@ export class PseudoTranslator implements Translator { ) {} translate(locale: LocaleCode, entries: Record) { - this.logger.debug( - `[TRACE-PSEUDO] translate() ENTERED for ${locale} with ${Object.keys(entries).length} entries`, - ); const delay = this.config?.delayMedian ?? 0; const actualDelay = this.getRandomDelay(delay); - this.logger.debug( - `[TRACE-PSEUDO] Config delay: ${delay}ms, actual delay: ${actualDelay}ms`, - ); - return new Promise>((resolve) => { - this.logger.debug( - `[TRACE-PSEUDO] Promise created, scheduling setTimeout for ${actualDelay}ms`, - ); - setTimeout(() => { - this.logger.debug( - `[TRACE-PSEUDO] setTimeout callback fired for ${locale}, processing entries`, - ); const result = Object.fromEntries( Object.entries(entries).map(([hash, entry]) => { @@ -47,16 +33,8 @@ export class PseudoTranslator implements Translator { }), ); - this.logger.debug( - `[TRACE-PSEUDO] Pseudolocalization complete, resolving with ${Object.keys(result).length} translations`, - ); resolve(result); - this.logger.debug(`[TRACE-PSEUDO] Promise resolved for ${locale}`); }, actualDelay); - - this.logger.debug( - `[TRACE-PSEUDO] setTimeout scheduled, returning promise`, - ); }); } diff --git a/cmp/compiler/src/translators/translation-service.ts b/packages/new-compiler/src/translators/translation-service.ts similarity index 69% rename from cmp/compiler/src/translators/translation-service.ts rename to packages/new-compiler/src/translators/translation-service.ts index 645300863..f90e4a2d9 100644 --- a/cmp/compiler/src/translators/translation-service.ts +++ b/packages/new-compiler/src/translators/translation-service.ts @@ -10,7 +10,7 @@ import type { TranslationCache } from "./cache"; import type { TranslatableEntry, Translator } from "./api"; -import type { MetadataSchema } from "../types"; +import type { LingoEnvironment, MetadataSchema } from "../types"; import { type PluralizationConfig, PluralizationService, @@ -18,8 +18,11 @@ import { import type { Logger } from "../utils/logger"; import type { LocaleCode } from "lingo.dev/spec"; import { PseudoTranslator } from "./pseudotranslator"; +import { LingoTranslator } from "./lingo"; +import { type CacheConfig, createCache } from "./cache-factory"; +import { MemoryTranslationCache } from "./memory-cache"; -export interface TranslationServiceConfig { +export type TranslationServiceConfig = { /** * Source locale (e.g., "en") */ @@ -30,7 +33,13 @@ export interface TranslationServiceConfig { * If provided, enables automatic pluralization of source messages */ pluralization: Omit; -} + models: "lingo.dev" | Record; + prompt?: string; + environment: LingoEnvironment; + dev?: { + usePseudotranslator?: boolean; + }; +} & CacheConfig; export interface TranslationResult { /** @@ -55,29 +64,73 @@ export interface TranslationError { } export class TranslationService { - private useCache = true; private pluralizationService?: PluralizationService; + private translator: Translator; + private cache: TranslationCache; constructor( - private translator: Translator, - private cache: TranslationCache, private config: TranslationServiceConfig, private logger: Logger, ) { - const isPseudo = this.translator instanceof PseudoTranslator; - this.useCache = !isPseudo; - - // Initialize pluralization service if enabled - // Do this once at construction to avoid repeated API key validation and model creation - if (this.config.pluralization?.enabled !== false && !isPseudo) { - this.logger.info("Initializing pluralization service..."); - this.pluralizationService = new PluralizationService( - { - ...this.config.pluralization, - sourceLocale: this.config.sourceLocale, - }, - this.logger, + const isDev = config.environment === "development"; + + // 1. Explicit dev override takes precedence + if (isDev && config.dev?.usePseudotranslator) { + this.logger.info( + "📝 Using pseudotranslator (dev.usePseudotranslator enabled)", ); + this.translator = new PseudoTranslator({ delayMedian: 100 }, logger); + this.cache = new MemoryTranslationCache(); + } else { + // 2. Try to create real translator + // LingoTranslator constructor will validate and fetch API keys + // If validation fails, it will throw an error with helpful message + try { + const models = config.models; + + this.logger.debug( + `Creating Lingo translator with models: ${JSON.stringify(models)}`, + ); + + this.cache = createCache(config); + this.translator = new LingoTranslator( + { + models, + sourceLocale: config.sourceLocale, + prompt: config.prompt, + }, + this.logger, + ); + + if (this.config.pluralization?.enabled) { + this.pluralizationService = new PluralizationService( + { + ...this.config.pluralization, + sourceLocale: this.config.sourceLocale, + }, + this.logger, + ); + } + } catch (error) { + // 3. Auto-fallback in dev mode if creation fails + if (isDev) { + // Use console.error to ensure visibility in all contexts (loader, server, etc.) + const errorMsg = + error instanceof Error ? error.message : String(error); + this.logger.warn(`⚠️ Translation setup error: \n${errorMsg}\n +⚠️ Auto-fallback to pseudotranslator in development mode. +Set the required API keys for real translations.`); + + this.translator = new PseudoTranslator( + { delayMedian: 100 }, + this.logger, + ); + this.cache = new MemoryTranslationCache(); + } else { + // 4. Fail in production + throw error; + } + } } } @@ -99,38 +152,24 @@ export class TranslationService { // Step 1: Determine which hashes we need to work with const workingHashes = requestedHashes || Object.keys(metadata.entries); - this.logger.info( + this.logger.debug( `Translation requested for ${workingHashes.length} hashes in locale: ${locale}`, ); // Step 2: Check cache first (same for all locales, including source) - this.logger.debug(`[TRACE] Checking cache for locale: ${locale}`); - const cacheStartTime = performance.now(); - const cachedTranslations = this.useCache - ? await this.cache.get(locale) - : {}; - const cacheEndTime = performance.now(); - this.logger.debug( - `[TRACE] Cache check completed in ${(cacheEndTime - cacheStartTime).toFixed(2)}ms, found ${Object.keys(cachedTranslations).length} entries`, - ); + const cachedTranslations = await this.cache.get(locale); // Step 3: Determine what needs translation/pluralization const uncachedHashes = workingHashes.filter( (hash) => !cachedTranslations[hash], ); this.logger.debug( - `[TRACE] ${uncachedHashes.length} hashes need processing, ${workingHashes.length - uncachedHashes.length} are cached`, + `${uncachedHashes.length} hashes need processing, ${workingHashes.length - uncachedHashes.length} are cached`, ); const cachedCount = workingHashes.length - uncachedHashes.length; if (uncachedHashes.length === 0) { - // All cached! - const endTime = performance.now(); - this.logger.info( - `Cache hit for all ${workingHashes.length} hashes in ${locale} in ${(endTime - startTime).toFixed(2)}ms`, - ); - return { translations: this.pickTranslations(cachedTranslations, workingHashes), errors: [], @@ -143,7 +182,7 @@ export class TranslationService { }; } - this.logger.info( + this.logger.debug( `Generating translations for ${uncachedHashes.length} uncached hashes in ${locale}...`, ); @@ -159,12 +198,12 @@ export class TranslationService { // Step 5: Process pluralization for filtered entries if (this.pluralizationService) { - this.logger.info( + this.logger.debug( `Processing pluralization for ${Object.keys(filteredMetadata.entries).length} entries...`, ); const pluralStats = await this.pluralizationService.process(filteredMetadata); - this.logger.info( + this.logger.debug( `Pluralization stats: ${pluralStats.pluralized} pluralized, ${pluralStats.rejected} rejected, ${pluralStats.failed} failed`, ); } @@ -174,7 +213,7 @@ export class TranslationService { const hashesNeedingTranslation: string[] = []; this.logger.debug( - `[TRACE] Checking for overrides in ${uncachedHashes.length} entries`, + `Checking for overrides in ${uncachedHashes.length} entries`, ); for (const hash of uncachedHashes) { @@ -185,7 +224,7 @@ export class TranslationService { if (entry.overrides && entry.overrides[locale]) { overriddenTranslations[hash] = entry.overrides[locale]; this.logger.debug( - `[TRACE] Using override for ${hash} in locale ${locale}: "${entry.overrides[locale]}"`, + `Using override for ${hash} in locale ${locale}: "${entry.overrides[locale]}"`, ); } else { hashesNeedingTranslation.push(hash); @@ -193,23 +232,12 @@ export class TranslationService { } const overrideCount = Object.keys(overriddenTranslations).length; - if (overrideCount > 0) { - this.logger.info( - `Found ${overrideCount} override(s) for locale ${locale}, skipping AI translation for these entries`, - ); - } // Step 7: Prepare entries for translation (excluding overridden ones) - this.logger.debug( - `[TRACE] Preparing ${hashesNeedingTranslation.length} entries for translation (after overrides)`, - ); const entriesToTranslate = this.prepareEntries( filteredMetadata, hashesNeedingTranslation, ); - this.logger.debug( - `[TRACE] Prepared ${Object.keys(entriesToTranslate).length} entries`, - ); // Step 8: Translate or return source text let newTranslations: Record = { ...overriddenTranslations }; @@ -218,7 +246,7 @@ export class TranslationService { if (locale === this.config.sourceLocale) { // For source locale, just return the (possibly pluralized) sourceText this.logger.debug( - `[TRACE] Source locale detected, returning sourceText for ${hashesNeedingTranslation.length} entries`, + `Source locale detected, returning sourceText for ${hashesNeedingTranslation.length} entries`, ); for (const [hash, entry] of Object.entries(entriesToTranslate)) { newTranslations[hash] = entry.text; @@ -227,30 +255,16 @@ export class TranslationService { // For other locales, translate only entries without overrides try { this.logger.debug( - `[TRACE] Calling translator.translate() for ${locale} with ${Object.keys(entriesToTranslate).length} entries`, + `Translating ${locale} with ${Object.keys(entriesToTranslate).length} entries`, ); - this.logger.debug(`[TRACE] About to await translator.translate()...`); - const translateStartTime = performance.now(); - this.logger.debug(`[TRACE] Executing translator.translate() NOW`); const translatedTexts = await this.translator.translate( locale, entriesToTranslate, ); - this.logger.debug(`[TRACE] translator.translate() returned`); - // Merge translated texts with overridden translations newTranslations = { ...overriddenTranslations, ...translatedTexts }; - - const translateEndTime = performance.now(); - this.logger.debug( - `[TRACE] translator.translate() completed in ${(translateEndTime - translateStartTime).toFixed(2)}ms`, - ); - this.logger.debug( - `[TRACE] Received ${Object.keys(translatedTexts).length} translations (+ ${overrideCount} overrides)`, - ); } catch (error) { - // Complete failure - log and return what we have from cache - this.logger.error(`Translation failed completely:`, error); + this.logger.error(`Translation failed:`, error); return { translations: this.pickTranslations( @@ -283,27 +297,16 @@ export class TranslationService { errors.push({ hash, sourceText: entry?.sourceText || "", - error: "Translation not returned by translator", + error: "Translator doesn't return translation", }); } } } // Step 5: Update cache with successful translations (skip for pseudo) - if (this.useCache && Object.keys(newTranslations).length > 0) { + if (Object.keys(newTranslations).length > 0) { try { - this.logger.debug( - `[TRACE] Updating cache with ${Object.keys(newTranslations).length} translations for ${locale}`, - ); - const updateStartTime = performance.now(); await this.cache.update(locale, newTranslations); - const updateEndTime = performance.now(); - this.logger.debug( - `[TRACE] Cache update completed in ${(updateEndTime - updateStartTime).toFixed(2)}ms`, - ); - this.logger.info( - `Updated cache with ${Object.keys(newTranslations).length} translations for ${locale}`, - ); } catch (error) { this.logger.error(`Failed to update cache:`, error); // Don't fail the request if cache update fails @@ -315,7 +318,7 @@ export class TranslationService { const result = this.pickTranslations(allTranslations, workingHashes); const endTime = performance.now(); - this.logger.info( + this.logger.debug( `Translation completed for ${locale}: ${Object.keys(newTranslations).length} new, ${cachedCount} cached, ${errors.length} errors in ${(endTime - startTime).toFixed(2)}ms`, ); diff --git a/cmp/compiler/src/types.ts b/packages/new-compiler/src/types.ts similarity index 100% rename from cmp/compiler/src/types.ts rename to packages/new-compiler/src/types.ts diff --git a/cmp/compiler/src/utils/config-factory.ts b/packages/new-compiler/src/utils/config-factory.ts similarity index 100% rename from cmp/compiler/src/utils/config-factory.ts rename to packages/new-compiler/src/utils/config-factory.ts diff --git a/cmp/compiler/src/utils/hash.spec.ts b/packages/new-compiler/src/utils/hash.spec.ts similarity index 100% rename from cmp/compiler/src/utils/hash.spec.ts rename to packages/new-compiler/src/utils/hash.spec.ts diff --git a/cmp/compiler/src/utils/hash.ts b/packages/new-compiler/src/utils/hash.ts similarity index 100% rename from cmp/compiler/src/utils/hash.ts rename to packages/new-compiler/src/utils/hash.ts diff --git a/cmp/compiler/src/utils/is-valid-locale.ts b/packages/new-compiler/src/utils/is-valid-locale.ts similarity index 100% rename from cmp/compiler/src/utils/is-valid-locale.ts rename to packages/new-compiler/src/utils/is-valid-locale.ts diff --git a/cmp/compiler/src/utils/logger.ts b/packages/new-compiler/src/utils/logger.ts similarity index 100% rename from cmp/compiler/src/utils/logger.ts rename to packages/new-compiler/src/utils/logger.ts diff --git a/packages/new-compiler/src/utils/observability.ts b/packages/new-compiler/src/utils/observability.ts new file mode 100644 index 000000000..ec7b1b041 --- /dev/null +++ b/packages/new-compiler/src/utils/observability.ts @@ -0,0 +1,126 @@ +import * as machineIdLib from "node-machine-id"; +import { getRc } from "./rc"; +import { getRepositoryId } from "./repository-id"; +import { TRACKING_VERSION, COMPILER_PACKAGE } from "./tracking-events"; + +export default async function trackEvent( + event: string, + properties?: Record, +) { + if (process.env.DO_NOT_TRACK === "1") { + return; + } + + try { + const identityInfo = await getDistinctId(); + + if (process.env.DEBUG === "true") { + console.log( + `[Tracking] Event: ${event}, ID: ${identityInfo.distinct_id}, Source: ${identityInfo.distinct_id_source}`, + ); + } + + const { PostHog } = await import("posthog-node"); + const posthog = new PostHog( + "phc_eR0iSoQufBxNY36k0f0T15UvHJdTfHlh8rJcxsfhfXk", + { + host: "https://eu.i.posthog.com", + flushAt: 1, + flushInterval: 0, + }, + ); + + await posthog.capture({ + distinctId: identityInfo.distinct_id, + event, + properties: { + ...properties, + isByokMode: properties?.models !== "lingo.dev", + tracking_version: TRACKING_VERSION, + compiler_package: COMPILER_PACKAGE, + distinct_id_source: identityInfo.distinct_id_source, + project_id: identityInfo.project_id, + meta: { + version: process.env.npm_package_version, + isCi: process.env.CI === "true", + }, + }, + }); + + await posthog.shutdown(); + } catch (error) { + if (process.env.DEBUG === "true") { + console.error("[Tracking] Error:", error); + } + } +} + +async function getDistinctId(): Promise<{ + distinct_id: string; + distinct_id_source: string; + project_id: string | null; +}> { + const email = await tryGetEmail(); + if (email) { + const projectId = getRepositoryId(); + return { + distinct_id: email, + distinct_id_source: "email", + project_id: projectId, + }; + } + + const repoId = getRepositoryId(); + if (repoId) { + return { + distinct_id: repoId, + distinct_id_source: "git_repo", + project_id: repoId, + }; + } + + const deviceId = `device-${await machineIdLib.machineId()}`; + if (process.env.DEBUG === "true") { + console.warn( + "[Tracking] Using device ID fallback. Consider using git repository for consistent tracking.", + ); + } + return { + distinct_id: deviceId, + distinct_id_source: "device", + project_id: null, + }; +} + +async function tryGetEmail(): Promise { + const rc = getRc(); + const apiKey = process.env.LINGODOTDEV_API_KEY || rc?.auth?.apiKey; + const apiUrl = + process.env.LINGODOTDEV_API_URL || + rc?.auth?.apiUrl || + "https://engine.lingo.dev"; + + if (!apiKey) { + return null; + } + + try { + const res = await fetch(`${apiUrl}/whoami`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + ContentType: "application/json", + }, + }); + if (res.ok) { + const payload = await res.json(); + if (payload?.email) { + return payload.email; + } + } + } catch (err) { + // ignore + } + + return null; +} diff --git a/cmp/compiler/src/utils/path-helpers.ts b/packages/new-compiler/src/utils/path-helpers.ts similarity index 100% rename from cmp/compiler/src/utils/path-helpers.ts rename to packages/new-compiler/src/utils/path-helpers.ts diff --git a/packages/new-compiler/src/utils/rc.ts b/packages/new-compiler/src/utils/rc.ts new file mode 100644 index 000000000..fb4f9d92c --- /dev/null +++ b/packages/new-compiler/src/utils/rc.ts @@ -0,0 +1,15 @@ +import os from "os"; +import path from "path"; +import fs from "fs"; +import Ini from "ini"; + +export function getRc() { + const settingsFile = ".lingodotdevrc"; + const homedir = os.homedir(); + const settingsFilePath = path.join(homedir, settingsFile); + const content = fs.existsSync(settingsFilePath) + ? fs.readFileSync(settingsFilePath, "utf-8") + : ""; + const data = Ini.parse(content); + return data; +} diff --git a/packages/new-compiler/src/utils/repository-id.ts b/packages/new-compiler/src/utils/repository-id.ts new file mode 100644 index 000000000..08b7f511b --- /dev/null +++ b/packages/new-compiler/src/utils/repository-id.ts @@ -0,0 +1,100 @@ +import { execSync } from "child_process"; +import { createHash } from "crypto"; + +let cachedGitRepoId: string | null | undefined = undefined; + +function hashProjectName(fullPath: string): string { + const parts = fullPath.split("/"); + if (parts.length !== 2) { + return createHash("sha256").update(fullPath).digest("hex").slice(0, 8); + } + + const [org, project] = parts; + const hashedProject = createHash("sha256") + .update(project) + .digest("hex") + .slice(0, 8); + + return `${org}/${hashedProject}`; +} + +export function getRepositoryId(): string | null { + const ciRepoId = getCIRepositoryId(); + if (ciRepoId) return ciRepoId; + + const gitRepoId = getGitRepositoryId(); + if (gitRepoId) return gitRepoId; + + return null; +} + +function getCIRepositoryId(): string | null { + if (process.env.GITHUB_REPOSITORY) { + const hashed = hashProjectName(process.env.GITHUB_REPOSITORY); + return `github:${hashed}`; + } + + if (process.env.CI_PROJECT_PATH) { + const hashed = hashProjectName(process.env.CI_PROJECT_PATH); + return `gitlab:${hashed}`; + } + + if (process.env.BITBUCKET_REPO_FULL_NAME) { + const hashed = hashProjectName(process.env.BITBUCKET_REPO_FULL_NAME); + return `bitbucket:${hashed}`; + } + + return null; +} + +function getGitRepositoryId(): string | null { + if (cachedGitRepoId !== undefined) { + return cachedGitRepoId; + } + + try { + const remoteUrl = execSync("git config --get remote.origin.url", { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }).trim(); + + if (!remoteUrl) { + cachedGitRepoId = null; + return null; + } + + cachedGitRepoId = parseGitUrl(remoteUrl); + return cachedGitRepoId; + } catch { + cachedGitRepoId = null; + return null; + } +} + +function parseGitUrl(url: string): string | null { + const cleanUrl = url.replace(/\.git$/, ""); + + let platform: string | null = null; + if (cleanUrl.includes("github.com")) { + platform = "github"; + } else if (cleanUrl.includes("gitlab.com")) { + platform = "gitlab"; + } else if (cleanUrl.includes("bitbucket.org")) { + platform = "bitbucket"; + } + + const sshMatch = cleanUrl.match(/[@:]([^:/@]+\/[^:/@]+)$/); + const httpsMatch = cleanUrl.match(/\/([^/]+\/[^/]+)$/); + + const repoPath = sshMatch?.[1] || httpsMatch?.[1]; + + if (!repoPath) return null; + + const hashedPath = hashProjectName(repoPath); + + if (platform) { + return `${platform}:${hashedPath}`; + } + + return `git:${hashedPath}`; +} diff --git a/cmp/compiler/src/utils/timeout.ts b/packages/new-compiler/src/utils/timeout.ts similarity index 100% rename from cmp/compiler/src/utils/timeout.ts rename to packages/new-compiler/src/utils/timeout.ts diff --git a/packages/new-compiler/src/utils/tracking-events.ts b/packages/new-compiler/src/utils/tracking-events.ts new file mode 100644 index 000000000..368257d8b --- /dev/null +++ b/packages/new-compiler/src/utils/tracking-events.ts @@ -0,0 +1,25 @@ +import type { LingoConfig } from "../types"; + +export const TRACKING_EVENTS = { + BUILD_START: "compiler.build.start", + BUILD_SUCCESS: "compiler.build.success", + BUILD_ERROR: "compiler.build.error", +} as const; + +export const TRACKING_VERSION = "3.0"; + +export const COMPILER_PACKAGE = "@lingo.dev/compiler"; + +export function sanitizeConfigForTracking(config: LingoConfig) { + return { + sourceLocale: config.sourceLocale, + targetLocalesCount: config.targetLocales.length, + hasCustomModels: config.models !== "lingo.dev", + isByokMode: config.models !== "lingo.dev", + useDirective: config.useDirective, + buildMode: config.buildMode, + hasPluralisation: config.pluralization.enabled, + hasCustomPrompt: !!config.prompt, + hasCustomLocaleResolver: false, + }; +} diff --git a/cmp/compiler/src/virtual/code-generator.ts b/packages/new-compiler/src/virtual/code-generator.ts similarity index 100% rename from cmp/compiler/src/virtual/code-generator.ts rename to packages/new-compiler/src/virtual/code-generator.ts diff --git a/cmp/compiler/src/virtual/config.ts b/packages/new-compiler/src/virtual/config.ts similarity index 100% rename from cmp/compiler/src/virtual/config.ts rename to packages/new-compiler/src/virtual/config.ts diff --git a/cmp/compiler/src/virtual/locale/client.ts b/packages/new-compiler/src/virtual/locale/client.ts similarity index 100% rename from cmp/compiler/src/virtual/locale/client.ts rename to packages/new-compiler/src/virtual/locale/client.ts diff --git a/cmp/compiler/src/virtual/locale/server.ts b/packages/new-compiler/src/virtual/locale/server.ts similarity index 100% rename from cmp/compiler/src/virtual/locale/server.ts rename to packages/new-compiler/src/virtual/locale/server.ts diff --git a/cmp/compiler/src/widget/lingo-dev-widget.ts b/packages/new-compiler/src/widget/lingo-dev-widget.ts similarity index 100% rename from cmp/compiler/src/widget/lingo-dev-widget.ts rename to packages/new-compiler/src/widget/lingo-dev-widget.ts diff --git a/cmp/compiler/src/widget/types.ts b/packages/new-compiler/src/widget/types.ts similarity index 100% rename from cmp/compiler/src/widget/types.ts rename to packages/new-compiler/src/widget/types.ts diff --git a/cmp/compiler/tests/.gitignore b/packages/new-compiler/tests/.gitignore similarity index 100% rename from cmp/compiler/tests/.gitignore rename to packages/new-compiler/tests/.gitignore diff --git a/cmp/compiler/tests/QUICK_START.md b/packages/new-compiler/tests/QUICK_START.md similarity index 92% rename from cmp/compiler/tests/QUICK_START.md rename to packages/new-compiler/tests/QUICK_START.md index 2aeab94d4..dbfb9ec65 100644 --- a/cmp/compiler/tests/QUICK_START.md +++ b/packages/new-compiler/tests/QUICK_START.md @@ -10,7 +10,7 @@ pnpm install pnpm playwright:install # 3. Prepare test fixtures (takes 2-3 minutes) -pnpm test:prepare +pnpm test:e2e:prepare ``` ## Running Tests @@ -37,7 +37,7 @@ Instead of installing dependencies on every test run, we use a two-stage approac ``` ┌─────────────────────────────────────────────────────────────┐ -│ Stage 1: Preparation (ONE TIME - run pnpm test:prepare) │ +│ Stage 1: Preparation (ONE TIME - run pnpm test:e2e:prepare) │ ├─────────────────────────────────────────────────────────────┤ │ │ │ demo/next16/ ──────┐ │ @@ -79,7 +79,7 @@ Instead of installing dependencies on every test run, we use a two-stage approac **10x faster test execution!** -## When to Re-run `pnpm test:prepare` +## When to Re-run `pnpm test:e2e:prepare` - When demo app `package.json` changes - When you update demo app dependencies @@ -124,17 +124,3 @@ test("my test", async ({ page }) => { } }); ``` - -## CI/CD Integration - -In CI, run both stages: - -```yaml -- name: Prepare fixtures - run: pnpm test:prepare - -- name: Run E2E tests - run: pnpm test:e2e -``` - -You could also cache the `tests/fixtures/` directory to speed up CI runs. diff --git a/cmp/compiler/tests/README.md b/packages/new-compiler/tests/README.md similarity index 92% rename from cmp/compiler/tests/README.md rename to packages/new-compiler/tests/README.md index 2ae0b2da7..f14b181d5 100644 --- a/cmp/compiler/tests/README.md +++ b/packages/new-compiler/tests/README.md @@ -19,7 +19,7 @@ pnpm playwright:install 3. **Prepare test fixtures** (one-time setup): ```bash -pnpm test:prepare +pnpm test:e2e:prepare ``` This will: @@ -28,7 +28,7 @@ This will: - Install dependencies in each fixture - Takes 2-3 minutes but only needs to be run once -**Note:** You only need to re-run `pnpm test:prepare` if: +**Note:** You only need to re-run `pnpm test:e2e:prepare` if: - Demo app dependencies change - You want to test with updated demo apps @@ -86,7 +86,7 @@ tests/ 1. **Preparation Stage** (once): - Demo apps are copied to `tests/fixtures/` - Dependencies are installed in each fixture - - Run with: `pnpm test:prepare` + - Run with: `pnpm test:e2e:prepare` 2. **Test Execution** (fast): - Each test copies the prepared fixture (with node_modules) to a temp directory @@ -158,7 +158,7 @@ test("my test", async ({ page }) => { ### "Fixture not found" error -Run `pnpm test:prepare` to prepare the fixtures before running tests. +Run `pnpm test:e2e:prepare` to prepare the fixtures before running tests. ### Port conflicts @@ -178,4 +178,4 @@ Failed tests may leave temp directories. They're in your OS temp folder with `li ### Outdated fixtures -If demo apps change significantly, re-run `pnpm test:prepare` to update the fixtures. +If demo apps change significantly, re-run `pnpm test:e2e:prepare` to update the fixtures. diff --git a/cmp/compiler/tests/e2e/shared/development.test.ts b/packages/new-compiler/tests/e2e/shared/development.test.ts similarity index 100% rename from cmp/compiler/tests/e2e/shared/development.test.ts rename to packages/new-compiler/tests/e2e/shared/development.test.ts diff --git a/cmp/compiler/tests/helpers/fixture-integrity.ts b/packages/new-compiler/tests/helpers/fixture-integrity.ts similarity index 97% rename from cmp/compiler/tests/helpers/fixture-integrity.ts rename to packages/new-compiler/tests/helpers/fixture-integrity.ts index 9ebddcba1..af6f048d6 100644 --- a/cmp/compiler/tests/helpers/fixture-integrity.ts +++ b/packages/new-compiler/tests/helpers/fixture-integrity.ts @@ -130,7 +130,7 @@ export async function verifyFixtureIntegrity( const checksumsPath = getChecksumFilePath(fixturePath); if (!fsSync.existsSync(checksumsPath)) { errors.push( - "Checksums file not found. Please run 'pnpm test:prepare' to generate it.", + "Checksums file not found. Please run 'pnpm test:e2e:prepare' to generate it.", ); return { valid: false, errors }; } diff --git a/cmp/compiler/tests/helpers/locale-switcher.ts b/packages/new-compiler/tests/helpers/locale-switcher.ts similarity index 100% rename from cmp/compiler/tests/helpers/locale-switcher.ts rename to packages/new-compiler/tests/helpers/locale-switcher.ts diff --git a/cmp/compiler/tests/helpers/prepare-fixtures.ts b/packages/new-compiler/tests/helpers/prepare-fixtures.ts similarity index 80% rename from cmp/compiler/tests/helpers/prepare-fixtures.ts rename to packages/new-compiler/tests/helpers/prepare-fixtures.ts index 068e87c1c..68bb9676c 100644 --- a/cmp/compiler/tests/helpers/prepare-fixtures.ts +++ b/packages/new-compiler/tests/helpers/prepare-fixtures.ts @@ -41,18 +41,35 @@ async function copyDir( */ export async function prepareAllFixtures(): Promise { const fixturesDir = path.join(process.cwd(), "tests", "fixtures"); - const demoDir = path.join(process.cwd(), "..", "demo"); + const demoDir = path.join(process.cwd(), "..", "..", "demo"); console.log("Preparing test fixtures..."); // Create fixtures directory await fs.mkdir(fixturesDir, { recursive: true }); + // Pack the compiler once and reuse for all fixtures + console.log("\n📦 Packing compiler into tarball..."); + const compilerRoot = process.cwd(); + const packResult = await execAsync("pnpm pack --pack-destination /tmp", { + cwd: compilerRoot, + timeout: 60000, + }); + + // Extract tarball filename from pack output + const tarballMatch = packResult.stdout.match(/lingo\.dev-compiler-[\d.]+\.tgz/); + if (!tarballMatch) { + throw new Error(`Failed to find tarball in pack output: ${packResult.stdout}`); + } + const tarballName = tarballMatch[0]; + const tarballPath = path.join("/tmp", tarballName); + console.log(` ✅ Packed compiler: ${tarballName}`); + // Prepare Next.js fixture - await prepareFixture("next", demoDir, fixturesDir); + await prepareFixture("next", demoDir, fixturesDir, tarballPath); // Prepare Vite fixture - await prepareFixture("vite", demoDir, fixturesDir); + await prepareFixture("vite", demoDir, fixturesDir, tarballPath); console.log("\n✅ All fixtures prepared successfully!"); console.log("You can now run tests with: pnpm test:e2e"); @@ -62,12 +79,13 @@ async function prepareFixture( framework: "next" | "vite", demoDir: string, fixturesDir: string, + tarballPath: string, ): Promise { console.log(`\n📦 Preparing ${framework} fixture...`); const sourcePath = path.join( demoDir, - framework === "next" ? "next16" : "vite-react-spa", + framework === "next" ? "new-compiler-next16" : "new-compiler-vite-react-spa", ); const destPath = path.join(fixturesDir, framework); @@ -100,16 +118,14 @@ async function prepareFixture( console.log(` Modified vite.config.ts to disable devtools`); } - // Update package.json to use local compiler with file: reference - console.log(` Updating package.json to use local compiler...`); + // Update package.json to use packed compiler + console.log(` Updating package.json to use packed compiler...`); const packageJsonPath = path.join(destPath, "package.json"); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf-8")); - // Use current directory (compiler/) which contains package.json and build/ - const compilerRoot = process.cwd(); - // Replace workspace:* with file: path + // Replace workspace:* with tarball path if (packageJson.dependencies?.["@lingo.dev/compiler"]) { - packageJson.dependencies["@lingo.dev/compiler"] = `file:${compilerRoot}`; + packageJson.dependencies["@lingo.dev/compiler"] = `file:${tarballPath}`; } await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)); diff --git a/cmp/compiler/tests/helpers/setup-fixture.ts b/packages/new-compiler/tests/helpers/setup-fixture.ts similarity index 98% rename from cmp/compiler/tests/helpers/setup-fixture.ts rename to packages/new-compiler/tests/helpers/setup-fixture.ts index 314cf98e2..696dc089c 100644 --- a/cmp/compiler/tests/helpers/setup-fixture.ts +++ b/packages/new-compiler/tests/helpers/setup-fixture.ts @@ -117,7 +117,7 @@ export async function setupFixture( const fixturePath = path.join(process.cwd(), "tests", "fixtures", framework); if (!fsSync.existsSync(fixturePath)) { throw new Error( - `Fixture for ${framework} not found. Run "pnpm test:prepare" first.`, + `Fixture for ${framework} not found. Run "pnpm test:e2e:prepare" first.`, ); } @@ -126,7 +126,7 @@ export async function setupFixture( console.error(`❌ Fixture integrity check failed for ${framework}:`); errors.forEach((error) => console.error(` - ${error}`)); throw new Error( - `Fixture integrity check failed. Please run "pnpm test:prepare" to recreate fixtures.`, + `Fixture integrity check failed. Please run "pnpm test:e2e:prepare" to recreate fixtures.`, ); } console.log( diff --git a/cmp/compiler/tsconfig.json b/packages/new-compiler/tsconfig.json similarity index 100% rename from cmp/compiler/tsconfig.json rename to packages/new-compiler/tsconfig.json diff --git a/cmp/compiler/tsdown.config.ts b/packages/new-compiler/tsdown.config.ts similarity index 100% rename from cmp/compiler/tsdown.config.ts rename to packages/new-compiler/tsdown.config.ts diff --git a/cmp/compiler/vitest.config.ts b/packages/new-compiler/vitest.config.ts similarity index 100% rename from cmp/compiler/vitest.config.ts rename to packages/new-compiler/vitest.config.ts diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md new file mode 100644 index 000000000..2d4499fbd --- /dev/null +++ b/packages/react/CHANGELOG.md @@ -0,0 +1,129 @@ +# @lingo.dev/\_react + +## 0.7.6 + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +## 0.7.5 + +### Patch Changes + +- [`3b24647`](https://github.com/lingodotdev/lingo.dev/commit/3b246473f6f4773f00ea13211bc2be59a98e0b7c) Thanks [@vrcprl](https://github.com/vrcprl)! - Update Next.js to 15.3.8 to address security vulnerability + +## 0.7.4 + +### Patch Changes + +- [`d7ccd60`](https://github.com/lingodotdev/lingo.dev/commit/d7ccd6000cd980333e7ac4b63da4e2ba624c3de4) Thanks [@vrcprl](https://github.com/vrcprl)! - chore: update React to 19.2.3 to fix CVE-2025-55184 (DoS) and CVE-2025-55183 (source code exposure) + +## 0.7.3 + +### Patch Changes + +- [#1667](https://github.com/lingodotdev/lingo.dev/pull/1667) [`1a857bd`](https://github.com/lingodotdev/lingo.dev/commit/1a857bdf76d50afb3024a2437da5fd60e6721bb9) Thanks [@vrcprl](https://github.com/vrcprl)! - Upd NPM workflows + +## 0.7.2 + +### Patch Changes + +- [#1665](https://github.com/lingodotdev/lingo.dev/pull/1665) [`b898777`](https://github.com/lingodotdev/lingo.dev/commit/b89877729555025e0380451fa495573c2a114a6b) Thanks [@vrcprl](https://github.com/vrcprl)! - Upd react version + +## 0.7.1 + +### Patch Changes + +- [#1660](https://github.com/lingodotdev/lingo.dev/pull/1660) [`1b2980d`](https://github.com/lingodotdev/lingo.dev/commit/1b2980d9215eca4f2db101af530680d6eb3be8eb) Thanks [@wotschofsky](https://github.com/wotschofsky)! - Upgrade to non-vulnerable Next.js versions (React2Shell) + +## 0.7.0 + +### Minor Changes + +- [#1634](https://github.com/lingodotdev/lingo.dev/pull/1634) [`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Pin all dependencies to exact versions to prevent supply chain attacks. Dependencies no longer use caret (^) or tilde (~) ranges, ensuring full control over version updates and requiring explicit review of all dependency changes. + +## 0.6.0 + +### Minor Changes + +- [#1534](https://github.com/lingodotdev/lingo.dev/pull/1534) [`4d2359a`](https://github.com/lingodotdev/lingo.dev/commit/4d2359a3d7164f825bf5ddf62b5d13a4690cb4a2) Thanks [@verma-divyanshu-git](https://github.com/verma-divyanshu-git)! - add Suspense fallback to LingoProviderWrapper + +## 0.5.0 + +### Minor Changes + +- [#1134](https://github.com/lingodotdev/lingo.dev/pull/1134) [`3a642f3`](https://github.com/lingodotdev/lingo.dev/commit/3a642f33c04378706a8382aa0fde36e747fd6af5) Thanks [@mathio](https://github.com/mathio)! - useLingoLocale, setLingoLocale + +## 0.4.3 + +### Patch Changes + +- [#1119](https://github.com/lingodotdev/lingo.dev/pull/1119) [`e898c1e`](https://github.com/lingodotdev/lingo.dev/commit/e898c1eeb34e4dd3e74df26465802b520018acf9) Thanks [@mathio](https://github.com/mathio)! - compiler fallback to source locale + +## 0.4.2 + +### Patch Changes + +- [#1054](https://github.com/lingodotdev/lingo.dev/pull/1054) [`2d67369`](https://github.com/lingodotdev/lingo.dev/commit/2d673697b9cf4d91de2f48444581f8b3fd894cd6) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Fix loadLocaleFromCookies to return default locale instead of null when no cookie is found + +## 0.4.1 + +### Patch Changes + +- [#1011](https://github.com/lingodotdev/lingo.dev/pull/1011) [`bfcb424`](https://github.com/lingodotdev/lingo.dev/commit/bfcb424eb4479d0d3b767e062d30f02c5bcaeb14) Thanks [@mathio](https://github.com/mathio)! - replace elements with dot in name + +## 0.4.0 + +### Minor Changes + +- [`95c23cc`](https://github.com/lingodotdev/lingo.dev/commit/95c23ccbafd335939832dbdd0f995ebcb23082fd) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add className support to language switcher component + +## 0.3.0 + +### Minor Changes + +- [#897](https://github.com/lingodotdev/lingo.dev/pull/897) [`a5da697`](https://github.com/lingodotdev/lingo.dev/commit/a5da697f7efd46de31d17b202d06eb5f655ed9b9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for other providers in the compiler and implement Google AI as a provider. + +## 0.2.4 + +### Patch Changes + +- [#887](https://github.com/lingodotdev/lingo.dev/pull/887) [`511a2ec`](https://github.com/lingodotdev/lingo.dev/commit/511a2ecd68a9c5e2800035d5c6a6b5b31b2dc80f) Thanks [@mathio](https://github.com/mathio)! - handle when lingo dir is deleted + +## 0.2.3 + +### Patch Changes + +- [#883](https://github.com/lingodotdev/lingo.dev/pull/883) [`7191444`](https://github.com/lingodotdev/lingo.dev/commit/7191444f67864ea5b5a91a9be759b2445bf186d3) Thanks [@mathio](https://github.com/mathio)! - client-side loading state + +## 0.2.2 + +### Patch Changes + +- [#867](https://github.com/lingodotdev/lingo.dev/pull/867) [`a7bf553`](https://github.com/lingodotdev/lingo.dev/commit/a7bf5538b5b72e41f90371f6211378aac7d5f800) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Fix template substitution destructive shift() bug that caused rendering failures when translations have different element counts between locales + +- [#868](https://github.com/lingodotdev/lingo.dev/pull/868) [`562e667`](https://github.com/lingodotdev/lingo.dev/commit/562e667471abb51d7dd193217eefb8e8b3f8a686) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - show dictionary error + +## 0.2.1 + +### Patch Changes + +- [`1f9db11`](https://github.com/lingodotdev/lingo.dev/commit/1f9db11a53d8c75ce0e83517b73d43544d0f0fd2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add console log to lingoproviderwrapper + +## 0.2.0 + +### Minor Changes + +- [#838](https://github.com/lingodotdev/lingo.dev/pull/838) [`e75e615`](https://github.com/lingodotdev/lingo.dev/commit/e75e615ab17e279deb5a505dbda682fdfc7ead62) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - switch from tsup to unbuild + +## 0.1.1 + +### Patch Changes + +- [`caef325`](https://github.com/lingodotdev/lingo.dev/commit/caef3253bc99fa7bf7a0b40e5604c3590dcb4958) Thanks [@mathio](https://github.com/mathio)! - release fix + +## 0.1.0 + +### Minor Changes + +- [`e980e84`](https://github.com/lingodotdev/lingo.dev/commit/e980e84178439ad70417d38b425acf9148cfc4b6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added the compiler diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 000000000..5649559e6 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,5 @@ +# Lingo.dev Compiler React + +Set of components to implement Lingo.dev Compiler in modern React frameworks. + +Please refer to [Lingo.dev Compiler documentation](https://lingo.dev/en/compiler/). diff --git a/packages/react/build.config.ts b/packages/react/build.config.ts new file mode 100644 index 000000000..a66733dd7 --- /dev/null +++ b/packages/react/build.config.ts @@ -0,0 +1,41 @@ +import { defineBuildConfig } from "unbuild"; + +export default defineBuildConfig({ + /* Clean the output directory before each build */ + clean: true, + + /* Where generated files are written */ + outDir: "build", + + /* Generate type declarations */ + declaration: true, + + /* Generate source-maps */ + sourcemap: true, + + /* Treat these as external – they must be provided by the host app */ + externals: ["react", "next"], + + /* Transpile every file in src/ one-to-one into build/ keeping the folder structure */ + entries: [ + { + builder: "mkdist", + /* All TS/TSX/JS/JSX files under src become part of the build */ + input: "./src", + /* Mirror the structure in the build directory */ + outDir: "./build", + /* Emit ESM with the standard .js extension */ + format: "esm", + ext: "js", + /* Produce matching .d.ts files next to their JS counterparts */ + declaration: true, + /* Ensure relative imports inside declaration files include the .js extension */ + addRelativeDeclarationExtensions: true, + /* Use React 17+ automatic JSX runtime so output imports jsx from react/jsx-runtime */ + esbuild: { + jsx: "automatic", + jsxImportSource: "react", + }, + }, + ], +}); diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 000000000..ea63439d7 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,72 @@ +{ + "name": "@lingo.dev/_react", + "version": "0.7.6", + "description": "Lingo.dev React Kit", + "private": false, + "repository": { + "type": "git", + "url": "https://github.com/lingodotdev/lingo.dev.git", + "directory": "packages/react" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./build/core/index.d.ts", + "import": "./build/core/index.js" + }, + "./client": { + "types": "./build/client/index.d.ts", + "import": "./build/client/index.js" + }, + "./rsc": { + "types": "./build/rsc/index.d.ts", + "import": "./build/rsc/index.js" + }, + "./react-router": { + "types": "./build/react-router/index.d.ts", + "import": "./build/react-router/index.js" + } + }, + "files": [ + "build" + ], + "scripts": { + "dev": "unbuild && chokidar 'src/**/*' -c 'unbuild'", + "build": "pnpm typecheck && unbuild", + "typecheck": "tsc --noEmit", + "clean": "rm -rf build", + "test": "vitest --run", + "test:watch": "vitest -w" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@testing-library/react": "16.3.0", + "@types/js-cookie": "3.0.6", + "@types/lodash": "4.17.21", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "4.4.1", + "chokidar-cli": "3.0.0", + "next": "15.3.8", + "react": "19.2.3", + "react-dom": "19.2.3", + "tsup": "8.5.1", + "typescript": "5.9.3", + "unbuild": "3.6.1", + "vitest": "3.1.1" + }, + "peerDependencies": { + "next": "15.3.8" + }, + "dependencies": { + "js-cookie": "3.0.5", + "lodash": "4.17.21" + } +} diff --git a/packages/react/src/client/attribute-component.spec.tsx b/packages/react/src/client/attribute-component.spec.tsx new file mode 100644 index 000000000..bbdbeeb2e --- /dev/null +++ b/packages/react/src/client/attribute-component.spec.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from "vitest"; +import React from "react"; +import { LingoContext } from "./context"; + +vi.mock("../core", () => { + return { + LingoAttributeComponent: (props: any) => { + return React.createElement("div", { + "data-testid": "core-attr", + "data-has-dictionary": props.$dictionary ? "yes" : "no", + "data-file": props.$fileKey, + }); + }, + }; +}); + +describe("client/attribute-component", () => { + describe("LingoAttributeComponent wrapper", () => { + it("injects dictionary from context into core attribute component", async () => { + const dictionary = { locale: "en" } as any; + const { LingoAttributeComponent } = await import("./attribute-component"); + const { render, screen } = await import("@testing-library/react"); + + render( + + + , + ); + + const el = await screen.findByTestId("core-attr"); + expect(el.getAttribute("data-has-dictionary")).toBe("yes"); + expect(el.getAttribute("data-file")).toBe("messages"); + }); + }); +}); diff --git a/packages/react/src/client/attribute-component.tsx b/packages/react/src/client/attribute-component.tsx new file mode 100644 index 000000000..c91a5e45c --- /dev/null +++ b/packages/react/src/client/attribute-component.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { + LingoAttributeComponent as LingoCoreAttributeComponent, + LingoAttributeComponentProps as LingoCoreAttributeComponentProps, +} from "../core"; +import { useLingo } from "./context"; + +export type LingoAttributeComponentProps = Omit< + LingoCoreAttributeComponentProps, + "$dictionary" +>; + +export function LingoAttributeComponent(props: LingoAttributeComponentProps) { + const { $attrAs, $attributes, $fileKey, ...rest } = props; + const lingo = useLingo(); + return ( + + ); +} diff --git a/packages/react/src/client/component.lingo-component.spec.tsx b/packages/react/src/client/component.lingo-component.spec.tsx new file mode 100644 index 000000000..fe421eeee --- /dev/null +++ b/packages/react/src/client/component.lingo-component.spec.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import React from "react"; +import { LingoContext } from "./context"; + +// Mock core LingoComponent to capture received props +vi.mock("../core", () => { + return { + LingoComponent: (props: any) => { + return React.createElement("div", { + "data-testid": "core-lingo-component", + "data-has-dictionary": props.$dictionary ? "yes" : "no", + "data-entry": props.$entryKey, + "data-file": props.$fileKey, + }); + }, + }; +}); + +describe("client/component", () => { + describe("LingoComponent wrapper", () => { + it("renders core component with dictionary from context and forwards keys", async () => { + const dictionary = { locale: "en" } as any; + const { LingoComponent } = await import("./component"); + const { render, screen } = await import("@testing-library/react"); + + render( + + + , + ); + + const el = await screen.findByTestId("core-lingo-component"); + expect(el.getAttribute("data-has-dictionary")).toBe("yes"); + expect(el.getAttribute("data-file")).toBe("messages"); + expect(el.getAttribute("data-entry")).toBe("hello"); + }); + }); +}); diff --git a/packages/react/src/client/component.spec.tsx b/packages/react/src/client/component.spec.tsx new file mode 100644 index 000000000..8c0fceb58 --- /dev/null +++ b/packages/react/src/client/component.spec.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import React from "react"; +import { LingoHtmlComponent } from "./component"; +import { LingoContext } from "./context"; + +describe("client/component", () => { + describe("LingoHtmlComponent", () => { + it("sets lang and data-lingodotdev-compiler from context dictionary locale", () => { + const dictionary = { locale: "ja" } as any; + const markup = renderToStaticMarkup( + + + , + ); + expect(markup).toContain("; + +export function LingoComponent(props: LingoComponentProps) { + const { $as, $fileKey, $entryKey, ...rest } = props; + const lingo = useLingo(); + return ( + + ); +} + +export function LingoHtmlComponent( + props: React.HTMLAttributes, +) { + const lingo = useLingo(); + return ( + + ); +} diff --git a/packages/react/src/client/context.spec.tsx b/packages/react/src/client/context.spec.tsx new file mode 100644 index 000000000..61505fb46 --- /dev/null +++ b/packages/react/src/client/context.spec.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { LingoContext, useLingo } from "./context"; + +describe("client/context", () => { + describe("useLingo", () => { + it("has default dictionary shape and useLingo returns it", () => { + const Probe = () => { + const lingo = useLingo(); + return ( +
    + ); + }; + render(); + const el = screen.getByTestId("probe"); + expect(el.getAttribute("data-dict-empty")).toBe("yes"); + }); + + it("provides value via context provider", () => { + const Probe = () => { + const lingo = useLingo(); + return ( +
    + ); + }; + render( + + + , + ); + const el = screen.getByTestId("probe"); + expect(el.getAttribute("data-locale")).toBe("it"); + }); + }); +}); diff --git a/packages/react/src/client/context.ts b/packages/react/src/client/context.ts new file mode 100644 index 000000000..ce23c9ad2 --- /dev/null +++ b/packages/react/src/client/context.ts @@ -0,0 +1,15 @@ +"use client"; + +import { createContext, useContext } from "react"; + +export type LingoContextType = { + dictionary: any; +}; + +export const LingoContext = createContext({ + dictionary: {}, +}); + +export function useLingo() { + return useContext(LingoContext); +} diff --git a/packages/react/src/client/index.ts b/packages/react/src/client/index.ts new file mode 100644 index 000000000..ceb8ef160 --- /dev/null +++ b/packages/react/src/client/index.ts @@ -0,0 +1,7 @@ +export * from "./loader"; +export * from "./context"; +export * from "./provider"; +export * from "./component"; +export * from "./locale-switcher"; +export * from "./attribute-component"; +export * from "./locale"; diff --git a/packages/react/src/client/loader.spec.ts b/packages/react/src/client/loader.spec.ts new file mode 100644 index 000000000..7d3d223d1 --- /dev/null +++ b/packages/react/src/client/loader.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { loadDictionary_internal } from "./loader"; + +vi.mock("../core", () => { + return { + getDictionary: vi.fn(async (locale, loaders) => { + if (locale === "es") return { hello: "Hola" }; + if (locale === "en") return { hello: "Hello" }; + return {}; + }), + }; +}); + +import { getDictionary } from "../core"; + +describe("client/loader", () => { + describe("loadDictionary_internal", () => { + it("delegates to core getDictionary via internal wrapper", async () => { + const loaders = { + en: async () => ({ default: { hello: "Hello" } }), + es: async () => ({ default: { hello: "Hola" } }), + }; + const result = await loadDictionary_internal("es", loaders); + expect(getDictionary).toHaveBeenCalledWith("es", loaders); + expect(result).toEqual({ hello: "Hola" }); + }); + }); +}); diff --git a/packages/react/src/client/loader.ts b/packages/react/src/client/loader.ts new file mode 100644 index 000000000..87b44764c --- /dev/null +++ b/packages/react/src/client/loader.ts @@ -0,0 +1,41 @@ +import { getDictionary } from "../core"; + +/** + * A placeholder function for loading dictionaries that contain localized content. + * + * This function: + * + * - Should be used in client-side rendered applications (e.g., Vite-based apps) + * - Should be passed into the `LingoProviderWrapper` component + * - Is transformed into functional code by Lingo.dev Compiler + * + * @param locale - The locale to load the dictionary for. + * + * @returns Promise that resolves to the dictionary object containing localized content. + * + * @example Use in a Vite application + * ```tsx + * import React from "react"; + * import ReactDOM from "react-dom/client"; + * import { LingoProviderWrapper, loadDictionary } from "lingo.dev/react/client"; + * import { App } from "./App.tsx"; + * + * ReactDOM.createRoot(document.getElementById("root")!).render( + * + * loadDictionary(locale)}> + * + * + * , + * ); + * ``` + */ +export const loadDictionary = async (locale: string | null): Promise => { + return {}; +}; + +export const loadDictionary_internal = async ( + locale: string | null, + dictionaryLoaders: Record Promise> = {}, +): Promise => { + return getDictionary(locale, dictionaryLoaders); +}; diff --git a/packages/react/src/client/locale-switcher.spec.tsx b/packages/react/src/client/locale-switcher.spec.tsx new file mode 100644 index 000000000..08993696f --- /dev/null +++ b/packages/react/src/client/locale-switcher.spec.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; +import { LocaleSwitcher } from "./locale-switcher"; + +vi.mock("./utils", async (orig) => { + const actual = await orig(); + return { + ...(actual as any), + getLocaleFromCookies: vi.fn(() => "es"), + setLocaleInCookies: vi.fn(), + }; +}); + +import { getLocaleFromCookies, setLocaleInCookies } from "./utils"; + +describe("LocaleSwitcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null before determining initial locale", () => { + // This component sets state in an effect, but with jsdom and our mocked + // cookie util returning a value synchronously, it may render immediately. + // We still assert it produces a select afterward. + const { container } = render(); + expect(container.querySelector("select")).toBeTruthy(); + }); + + it("uses cookie locale if valid; otherwise defaults to first provided locale", async () => { + (getLocaleFromCookies as any).mockReturnValueOnce("es"); + render(); + const select = (await screen.findByRole("combobox")) as HTMLSelectElement; + expect(select.value).toBe("es"); + + // invalid cookie -> defaults to first + (getLocaleFromCookies as any).mockReturnValueOnce("fr"); + render(); + const selects = (await screen.findAllByRole( + "combobox", + )) as HTMLSelectElement[]; + expect(selects[1].value).toBe("en"); + }); + + it("on change sets cookie and triggers full reload", async () => { + const reloadSpy = vi.fn(); + Object.defineProperty(window, "location", { + value: { ...window.location, reload: reloadSpy }, + writable: true, + }); + render(); + const select = await screen.findByRole("combobox"); + fireEvent.change(select, { target: { value: "en" } }); + + expect(setLocaleInCookies).toHaveBeenCalledWith("en"); + expect(reloadSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/client/locale-switcher.tsx b/packages/react/src/client/locale-switcher.tsx new file mode 100644 index 000000000..da0313040 --- /dev/null +++ b/packages/react/src/client/locale-switcher.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { getLocaleFromCookies, setLocaleInCookies } from "./utils"; + +/** + * The props for the `LocaleSwitcher` component. + */ +export type LocaleSwitcherProps = { + /** + * An array of locale codes to display in the dropdown. + * + * This should contain both the source and target locales. + */ + locales: string[]; + /** + * A custom class name for the dropddown's `select` element. + */ + className?: string; +}; + +/** + * An unstyled dropdown for switching between locales. + * + * This component: + * + * - Only works in environments that support cookies + * - Gets and sets the current locale from the `"lingo-locale"` cookie + * - Triggers a full page reload when the locale is changed + * + * @example Creating a locale switcher + * ```tsx + * import { LocaleSwitcher } from "lingo.dev/react/client"; + * + * export function App() { + * return ( + *
    + * + *
    + * ); + * } + * ``` + */ +export function LocaleSwitcher(props: LocaleSwitcherProps) { + const { locales } = props; + const [locale, setLocale] = useState(undefined); + + useEffect(() => { + const currentLocale = getLocaleFromCookies(); + const isValidLocale = currentLocale && locales.includes(currentLocale); + setLocale(isValidLocale ? currentLocale : locales[0]); + }, [locales]); + + if (locale === undefined) { + return null; + } + + return ( + + ); + + function handleLocaleChange(newLocale: string): Promise { + setLocaleInCookies(newLocale); + window.location.reload(); + return Promise.resolve(); + } +} diff --git a/packages/react/src/client/locale.spec.ts b/packages/react/src/client/locale.spec.ts new file mode 100644 index 000000000..59857918d --- /dev/null +++ b/packages/react/src/client/locale.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useLingoLocale, setLingoLocale } from "./locale"; + +// Mock the utils module +vi.mock("./utils", async (orig) => { + const actual = await orig(); + return { + ...(actual as any), + getLocaleFromCookies: vi.fn(() => "en"), + setLocaleInCookies: vi.fn(), + }; +}); + +import { getLocaleFromCookies, setLocaleInCookies } from "./utils"; + +// Mock window.location.reload +const mockReload = vi.fn(); +Object.defineProperty(window, "location", { + value: { ...window.location, reload: mockReload }, + writable: true, +}); + +describe("useLingoLocale", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the locale from cookies", () => { + (getLocaleFromCookies as any).mockReturnValue("es"); + const { result } = renderHook(() => useLingoLocale()); + + expect(result.current).toBe("es"); + expect(getLocaleFromCookies).toHaveBeenCalled(); + }); + + it("returns null when no locale is set", () => { + (getLocaleFromCookies as any).mockReturnValue(null); + const { result } = renderHook(() => useLingoLocale()); + + expect(result.current).toBe(null); + }); +}); + +describe("setLingoLocale", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockReload.mockClear(); + }); + + it("sets locale in cookies and reloads page for valid locale", () => { + act(() => { + setLingoLocale("es"); + }); + + expect(setLocaleInCookies).toHaveBeenCalledWith("es"); + expect(mockReload).toHaveBeenCalled(); + }); + + it("accepts various locales", () => { + const validLocales = [ + "en", + "es", + "fr", + "de", + "en-US", + "es-ES", + "fr-CA", + "de-DE", + ]; + + validLocales.forEach((locale) => { + expect(() => { + act(() => { + setLingoLocale(locale); + }); + }).not.toThrow(); + + expect(setLocaleInCookies).toHaveBeenCalledWith(locale); + }); + }); +}); diff --git a/packages/react/src/client/locale.ts b/packages/react/src/client/locale.ts new file mode 100644 index 000000000..29391e6ed --- /dev/null +++ b/packages/react/src/client/locale.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getLocaleFromCookies, setLocaleInCookies } from "./utils"; + +/** + * Gets the current locale used by the Lingo compiler. + * + * @returns The current locale code, or `null` if no locale is set. + */ +export function useLingoLocale(): string | null { + const [locale, setLocale] = useState(null); + useEffect(() => { + setLocale(getLocaleFromCookies()); + }, []); + return locale; +} + +/** + * Sets the current locale used by the Lingo compiler. + * + * **Note:** This function triggers a full page reload to ensure all components + * are re-rendered with the new locale. This is necessary because locale changes + * affect the entire application state. + * + * @param locale - The locale code to set. Must be a valid locale code (e.g., "en", "es", "fr-CA"). + + * + * @example Set the current locale + * ```tsx + * import { setLingoLocale } from "lingo.dev/react/client"; + * + * export function LanguageSwitcher() { + * const handleChange = (event: React.ChangeEvent) => { + * setLingoLocale(event.target.value); + * }; + * + * return ( + * + * ); + * } + * ``` + */ +export function setLingoLocale(locale: string) { + setLocaleInCookies(locale); + window.location.reload(); +} diff --git a/packages/react/src/client/provider.spec.tsx b/packages/react/src/client/provider.spec.tsx new file mode 100644 index 000000000..3d1fc19dc --- /dev/null +++ b/packages/react/src/client/provider.spec.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { LingoProvider, LingoProviderWrapper } from "./provider"; +import { LingoContext } from "./context"; + +vi.mock("./utils", async (orig) => { + const actual = await orig(); + return { + ...(actual as any), + getLocaleFromCookies: vi.fn(() => "en"), + }; +}); + +describe("client/provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("LingoProvider", () => { + it("throws when dictionary is missing", () => { + expect(() => + render( + +
    + , + ), + ).toThrowError(/dictionary is not provided/i); + }); + + it("provides dictionary via context", () => { + const dict = { locale: "en", files: {} }; + const Probe = () => { + return ( + + {(value) => ( +
    + )} + + ); + }; + + render( + + + , + ); + + const el = screen.getByTestId("probe"); + expect(el.getAttribute("data-locale")).toBe("en"); + }); + }); + + describe("LingoProviderWrapper", () => { + it("renders nothing while loading by default, then shows children", async () => { + const deferred = createDeferred<{ + locale: string; + files: Record; + }>(); + const loadDictionary = vi.fn(() => deferred.promise); + + const Child = () =>
    ok
    ; + + const { container, findByTestId } = render( + + + , + ); + + // No fallback by default (renders nothing during load) + expect(container.firstChild).toBeNull(); + + await act(async () => { + deferred.resolve({ locale: "en", files: {} }); + await deferred.promise; + }); + + await waitFor(() => expect(loadDictionary).toHaveBeenCalled()); + const child = await findByTestId("child"); + expect(child).not.toBeNull(); + }); + + it("supports a custom fallback", () => { + const loadDictionary = vi.fn(() => new Promise(() => {})); + + render( + waiting
    } + > +
    + , + ); + + const fallback = screen.getByTestId("fallback"); + expect(fallback).not.toBeNull(); + expect(fallback.textContent).toBe("waiting"); + }); + + it("propagates load errors to the nearest error boundary", async () => { + const loadDictionary = vi.fn().mockRejectedValue(new Error("boom")); + const onError = vi.fn(); + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + render( + + +
    + + , + ); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); + expect(onError.mock.calls[0][0].message).toBe("boom"); + + const errorBoundary = await screen.findByTestId("boundary-error"); + expect(errorBoundary).not.toBeNull(); + expect(errorBoundary.textContent).toBe("error"); + + consoleSpy.mockRestore(); + }); + }); +}); + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +class TestErrorBoundary extends React.Component< + { onError: (error: Error) => void; children: React.ReactNode }, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: Error) { + this.props.onError(error); + } + + render() { + if (this.state.hasError) { + return
    error
    ; + } + + return this.props.children; + } +} diff --git a/packages/react/src/client/provider.tsx b/packages/react/src/client/provider.tsx new file mode 100644 index 000000000..f068b36d4 --- /dev/null +++ b/packages/react/src/client/provider.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { Suspense, useMemo } from "react"; +import type { ReactNode } from "react"; +import { LingoContext } from "./context"; +import { getLocaleFromCookies } from "./utils"; + +/** + * The props for the `LingoProvider` component. + */ +export type LingoProviderProps = { + /** + * The dictionary object that contains localized content. + */ + dictionary: D; + /** + * The child components containing localizable content. + */ + children: ReactNode; +}; + +/** + * A context provider that makes localized content from a preloaded dictionary available to its descendants. + * + * This component: + * + * - Should be placed at the top of the component tree + * - Should be used in client-side applications that preload data from the server (e.g., React Router apps) + * + * @template D - The type of the dictionary object. + * @throws {Error} When no dictionary is provided. + * + * @example Use in a React Router application + * ```tsx + * import { LingoProvider } from "lingo.dev/react/client"; + * import { loadDictionary } from "lingo.dev/react/react-router"; + * import { + * Links, + * Meta, + * Outlet, + * Scripts, + * ScrollRestoration, + * useLoaderData, + * type LoaderFunctionArgs, + * } from "react-router"; + * import "./app.css"; + * + * export const loader = async ({ request }: LoaderFunctionArgs) => { + * return { + * lingoDictionary: await loadDictionary(request), + * }; + * }; + * + * export function Layout({ children }: { children: React.ReactNode }) { + * const { lingoDictionary } = useLoaderData(); + * + * return ( + * + * + * + * + * + * + * + * + * + * {children} + * + * + * + * + * + * ); + * } + * + * export default function App() { + * return ; + * } + * ``` + */ +export function LingoProvider(props: LingoProviderProps) { + if (!props.dictionary) { + throw new Error("LingoProvider: dictionary is not provided."); + } + + return ( + + ); +} + +/** + * The props for the `LingoProviderWrapper` component. + */ +export type LingoProviderWrapperProps = { + /** + * A callback function that loads the dictionary for the current locale. + * + * @param locale - The locale code to load the dictionary for. + * + * @returns The dictionary object containing localized content. + */ + loadDictionary: (locale: string | null) => Promise; + /** + * The child components containing localizable content. + */ + children: ReactNode; + /** + * Optional fallback element rendered while the dictionary is loading. + */ + fallback?: ReactNode; +}; + +/** + * A context provider that loads the dictionary for the current locale and makes localized content available to its descendants. + * + * This component: + * + * - Should be placed at the top of the component tree + * - Should be used in purely client-side rendered applications (e.g., Vite-based apps) + * - Suspends rendering while the dictionary loads (no UI by default, opt-in with `fallback` prop) + * + * @template D - The type of the dictionary object containing localized content. + * + * @example Use in a Vite application with loading UI + * ```tsx file="src/main.tsx" + * import { LingoProviderFallback, LingoProviderWrapper, loadDictionary } from "lingo.dev/react/client"; + * import { StrictMode } from 'react' + * import { createRoot } from 'react-dom/client' + * import './index.css' + * import App from './App.tsx' + * + * createRoot(document.getElementById('root')!).render( + * + * loadDictionary(locale)} + * fallback={} + * > + * + * + * , + * ); + * ``` + */ +export function LingoProviderWrapper(props: LingoProviderWrapperProps) { + const locale = useMemo(() => getLocaleFromCookies(), []); + const resource = useMemo( + () => + createDictionaryResource({ + load: () => props.loadDictionary(locale), + locale, + }), + [props.loadDictionary, locale], + ); + + return ( + + + {props.children} + + + ); +} + +function DictionaryBoundary(props: { + resource: DictionaryResource; + children: ReactNode; +}) { + const dictionary = props.resource.read(); + return ( + {props.children} + ); +} + +type DictionaryResource = { + read(): D; +}; + +function createDictionaryResource(options: { + load: () => Promise; + locale: string | null; +}): DictionaryResource { + let status: "pending" | "success" | "error" = "pending"; + let value: D; + let error: unknown; + + const { locale } = options; + console.log(`[Lingo.dev] Loading dictionary file for locale ${locale}...`); + + const suspender = options + .load() + .then((result) => { + value = result; + status = "success"; + return result; + }) + .catch((err) => { + console.log("[Lingo.dev] Failed to load dictionary:", err); + error = err; + status = "error"; + throw err; + }); + + return { + read(): D { + if (status === "pending") { + throw suspender; + } + if (status === "error") { + throw error; + } + return value; + }, + }; +} + +export function LingoProviderFallback() { + return ( +
    + Loading translations... +
    + ); +} diff --git a/packages/react/src/client/utils.spec.ts b/packages/react/src/client/utils.spec.ts new file mode 100644 index 000000000..a12f4e808 --- /dev/null +++ b/packages/react/src/client/utils.spec.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getLocaleFromCookies, setLocaleInCookies } from "./utils"; + +vi.mock("js-cookie", () => { + return { + default: { + get: vi.fn(), + set: vi.fn(), + }, + }; +}); + +// access mocked module +import Cookies from "js-cookie"; + +describe("client/utils", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getLocaleFromCookies", () => { + it("returns null when document is undefined (SSR)", () => { + const original = globalThis.document; + // @ts-ignore + delete (globalThis as any).document; + expect(getLocaleFromCookies()).toBeNull(); + (globalThis as any).document = original; + }); + + it("returns cookie value when present", () => { + (Cookies.get as any).mockReturnValue("es"); + (globalThis as any).document = {} as any; + expect(getLocaleFromCookies()).toBe("es"); + }); + }); + + describe("setLocaleInCookies", () => { + it("is no-op when document is undefined", () => { + const original = globalThis.document; + // @ts-ignore + delete (globalThis as any).document; + setLocaleInCookies("fr"); + expect(Cookies.set).not.toHaveBeenCalled(); + (globalThis as any).document = original; + }); + + it("writes cookie with expected options", () => { + (globalThis as any).document = {} as any; + setLocaleInCookies("en"); + expect(Cookies.set).toHaveBeenCalledWith("lingo-locale", "en", { + path: "/", + expires: 365, + sameSite: "lax", + }); + }); + }); +}); diff --git a/packages/react/src/client/utils.ts b/packages/react/src/client/utils.ts new file mode 100644 index 000000000..e4bb6e01b --- /dev/null +++ b/packages/react/src/client/utils.ts @@ -0,0 +1,61 @@ +"use client"; + +import { LOCALE_COOKIE_NAME } from "../core"; +import Cookies from "js-cookie"; + +/** + * Gets the current locale from the `"lingo-locale"` cookie. + * + * Defaults to `"en"` if: + * + * - Running in an environment that doesn't support cookies + * - No `"lingo-locale"` cookie is found + * + * @returns The current locale code, or `"en"` as a fallback. + * + * @example Get the current locale + * ```tsx + * import { getLocaleFromCookies } from "lingo.dev/react/client"; + * + * export function App() { + * const currentLocale = getLocaleFromCookies(); + * return
    Current locale: {currentLocale}
    ; + * } + * ``` + */ +export function getLocaleFromCookies(): string | null { + if (typeof document === "undefined") return null; + + return Cookies.get(LOCALE_COOKIE_NAME) ?? null; +} + +/** + * Sets the current locale in the `"lingo-locale"` cookie. + * + * Does nothing in environments that don't support cookies. + * + * @param locale - The locale code to store in the `"lingo-locale"` cookie. + * + * @example Set the current locale + * ```tsx + * import { setLocaleInCookies } from "lingo.dev/react/client"; + * + * export function LanguageButton() { + * const handleClick = () => { + * setLocaleInCookies("es"); + * window.location.reload(); + * }; + * + * return ; + * } + * ``` + */ +export function setLocaleInCookies(locale: string): void { + if (typeof document === "undefined") return; + + Cookies.set(LOCALE_COOKIE_NAME, locale, { + path: "/", + expires: 365, + sameSite: "lax", + }); +} diff --git a/packages/react/src/core/attribute-component.spec.tsx b/packages/react/src/core/attribute-component.spec.tsx new file mode 100644 index 000000000..a563981aa --- /dev/null +++ b/packages/react/src/core/attribute-component.spec.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from "vitest"; +import { render } from "@testing-library/react"; +import React from "react"; +import { LingoAttributeComponent } from "./attribute-component"; + +describe("core/attribute-component", () => { + describe("LingoAttributeComponent", () => { + const dictionary = { + files: { + messages: { + entries: { + title: "Localized Title", + hrefVal: "/localized", + }, + }, + }, + } as const; + + it("maps attributes from dictionary entries and falls back to attribute key when missing", () => { + const { container } = render( + falls back to attribute name (data-test) + "data-test": "missingEntry", + }} + />, + ); + + const a = container.querySelector("a")!; + expect(a.getAttribute("title")).toBe("Localized Title"); + expect(a.getAttribute("href")).toBe("/localized"); + // fallback uses the attribute key name + expect(a.getAttribute("data-test")).toBe("data-test"); + }); + + it("passes through arbitrary props and forwards ref", () => { + const ref = { current: null as HTMLButtonElement | null }; + const { container } = render( + (ref.current = el)} + />, + ); + + const btn = container.querySelector("button")!; + expect(btn.id).toBe("my-btn"); + expect(btn.className).toContain("primary"); + expect(ref.current).toBe(btn); + }); + + it("does not leak $fileKey as a DOM attribute", () => { + const { container } = render( + , + ); + + const host = container.querySelector("div[data-testid='host']")!; + // $fileKey should not be present as a plain attribute + expect(host.getAttribute("$fileKey")).toBeNull(); + }); + }); +}); diff --git a/packages/react/src/core/attribute-component.tsx b/packages/react/src/core/attribute-component.tsx new file mode 100644 index 000000000..712ccf0b5 --- /dev/null +++ b/packages/react/src/core/attribute-component.tsx @@ -0,0 +1,26 @@ +import { createElement } from "react"; +import _ from "lodash"; +import React from "react"; + +export type LingoAttributeComponentProps = { + $as: string; + $attributes: Record; + $fileKey: string; + [key: string]: any; +}; + +export const LingoAttributeComponent = React.forwardRef( + (props: Omit, ref: React.Ref) => { + const { $as, $attributes, $fileKey, $dictionary, ...rest } = props; + + const localizedAttributes = _.mapValues($attributes, (v, k) => { + return $dictionary?.files?.[$fileKey]?.entries?.[v] ?? k; + }); + + return createElement($as, { + ...rest, + ...localizedAttributes, + ref, + }); + }, +); diff --git a/packages/react/src/core/component.spec.tsx b/packages/react/src/core/component.spec.tsx new file mode 100644 index 000000000..cd6f94a73 --- /dev/null +++ b/packages/react/src/core/component.spec.tsx @@ -0,0 +1,567 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { LingoComponent } from "./component"; + +describe("LingoComponent", () => { + const dictionary = { + files: { + messages: { + entries: { + greeting: "Hello {user.profile.name} you have {count} messages", + welcome: + "Welcome incredible fantastic wonderful amazing user ", + complex: + "Hello {user.profile.name}, welcome to wonderful {placeholder} nested world of the universe number {count}", + }, + }, + }, + }; + + it("replaces variables in text", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe("Hello John you have 69 messages"); + }); + + it("replaces variables with JSX", () => { + const { container } = render( + John, + count: 69, + }} + />, + ); + expect(container.innerHTML).toBe( + "
    Hello John you have 69 messages
    ", + ); + }); + + it("replaces element placeholders", () => { + const Icons = { + Rocket: () => 🚀, + }; + + const { container } = render( + {children}, + ({ children }: any) => {children}, + ({ children }: any) => {children}, + ({ children }: any) => {children}, + ({ children }: any) => , + ]} + />, + ); + expect(container.innerHTML).toBe( + '', + ); + }); + + it("handles both variables and elements", () => { + const { container } = render( + {children}, + ({ children }: any) => {children}, + ({ children }: any) => {children}, + ({ children }: any) => {children}, + ({ children }: any) => {children}, + ]} + />, + ); + expect(container.innerHTML).toBe( + "", + ); + }); + + it("falls back to entryKey if value not found", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe("nonexistent"); + }); + + describe("function replacement", () => { + const getName = () => "John"; + const getCount = () => 42; + const formatName = () => "John Doe"; + const getUnread = () => 3; + const fnDictionary = { + files: { + messages: { + entries: { + simple: + "Hello , you have items", + chained: "Hello ", + mixed: + "Welcome , you have {count} items and unread", + nested: + "User has ", + }, + }, + }, + }; + + it("replaces function calls in text", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe("Hello John, you have 42 items"); + }); + + it("handles mixed variables and functions", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe( + "Welcome John Doe, you have 5 items and 3 unread", + ); + }); + + it("handles functions with nested elements", () => { + const { container } = render( + {children}, + ({ children }: any) => {children}, + ]} + />, + ); + expect(container.innerHTML).toBe( + "
    User John has 42
    ", + ); + }); + + it("handles function with chained names", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe("Hello John"); + }); + + it("preserves function placeholder if function not provided", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe( + "Hello John, you have items", + ); + }); + + it("replaces function calls with JSX", () => { + const { container } = render( + John], + getCount: [42], + }} + />, + ); + expect(container.innerHTML).toBe( + "
    Hello John, you have 42 items
    ", + ); + }); + }); + + describe("expression replacement", () => { + const exprDictionary = { + files: { + messages: { + entries: { + simple: "Result: ", + multiple: "First: , Second: ", + mixed: + "Count: , User: {user.name}, Items: ", + nested: + "Value: and Total: ", + }, + }, + }, + }; + + it("replaces simple expressions", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe("Result: 42"); + }); + + it("handles multiple expressions", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe("First: 84, Second: HELLO"); + }); + + it("handles mixed variables and expressions", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe("Count: 43, User: John, Items: 3"); + }); + + it("handles expressions with nested elements", () => { + const { container } = render( + a + b, 0)]} + $elements={[ + ({ children }: any) => {children}, + ({ children }: any) => {children}, + ]} + />, + ); + expect(container.innerHTML).toBe( + "
    Value: 84 and Total: 6
    ", + ); + }); + + it("preserves expression placeholder if not provided", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe("First: 42, Second: "); + }); + + it("replaces expressions with JSX", () => { + const { container } = render( + foo, bar]} + />, + ); + expect(container.innerHTML).toBe( + "
    First: foo, Second: bar
    ", + ); + }); + }); + + describe("array mutation prevention (shift() bug fix)", () => { + const mutationDictionary = { + files: { + test: { + entries: { + elements: + "First text and more", + functions: "Call then ", + expressions: "Value and ", + mixed: + "Element content with and ", + }, + }, + }, + }; + + it("does not mutate elements array during processing", () => { + const elements = [ + ({ children }: any) => {children}, + ({ children }: any) => {children}, + ]; + const originalElements = [...elements]; + + render( + , + ); + + expect(elements).toEqual(originalElements); + expect(elements.length).toBe(2); + }); + + it("does not mutate functions arrays during processing", () => { + const functions = { + fn1: ["result1", "result2"], + fn2: ["result3", "result4"], + }; + const originalFunctions = { + fn1: [...functions.fn1], + fn2: [...functions.fn2], + }; + + render( + , + ); + + expect(functions.fn1).toEqual(originalFunctions.fn1); + expect(functions.fn2).toEqual(originalFunctions.fn2); + expect(functions.fn1.length).toBe(2); + expect(functions.fn2.length).toBe(2); + }); + + it("does not mutate expressions array during processing", () => { + const expressions = ["value1", "value2"]; + const originalExpressions = [...expressions]; + + render( + , + ); + + expect(expressions).toEqual(originalExpressions); + expect(expressions.length).toBe(2); + }); + + it("produces consistent output across multiple renders", () => { + const elements = [ + ({ children }: any) => {children}, + ({ children }: any) => {children}, + ]; + const functions = { fn1: ["result1"] }; + const expressions = ["value1"]; + + const { container: container1 } = render( + , + ); + + const { container: container2 } = render( + , + ); + + expect(container1.innerHTML).toBe(container2.innerHTML); + }); + + it("handles shared arrays across multiple component instances", () => { + const sharedElements = [ + ({ children }: any) => {children}, + ({ children }: any) => {children}, + ]; + + const { container: container1 } = render( +
    + +
    , + ); + + const { container: container2 } = render( +
    + +
    , + ); + + expect(container1.innerHTML).toBe(container2.innerHTML); + expect(sharedElements.length).toBe(2); + }); + + it("extracts inner content when elements array is exhausted", () => { + const { container } = render( + {children}]} + />, + ); + + expect(container.textContent).toBe("First text and more"); + expect(container.innerHTML).toBe( + "
    First text and more
    ", + ); + }); + + it("handles completely empty elements array gracefully", () => { + const { container } = render( + , + ); + + expect(container.textContent).toBe("First text and more"); + expect(container.innerHTML).toBe("
    First text and more
    "); + }); + + it("maintains function index tracking per function name", () => { + const multiCallDictionary = { + files: { + test: { + entries: { + multiCall: + "First , second , third ", + }, + }, + }, + }; + + const { container } = render( + , + ); + + expect(container.textContent).toBe("First A, second B, third C"); + }); + }); +}); diff --git a/packages/react/src/core/component.tsx b/packages/react/src/core/component.tsx new file mode 100644 index 000000000..8d3a0533f --- /dev/null +++ b/packages/react/src/core/component.tsx @@ -0,0 +1,286 @@ +import { + createElement, + ReactNode, + FunctionComponent, + ReactElement, +} from "react"; +import _ from "lodash"; +import React, { useMemo } from "react"; + +export type LingoComponentProps = { + [key: string]: any; + $dictionary: any; + $as: any; + $fileKey: string; + $entryKey: string; + $values?: Record; + $elements?: Array>; + $functions?: Record; + $expressions?: ReactNode[]; +}; + +export const LingoComponent = React.forwardRef( + (props: Omit, ref: React.Ref) => { + const { + $dictionary, + $as, + $fileKey, + $entryKey, + $variables, + $elements, + $functions, + $expressions, + ...rest + } = props; + const maybeValue = $dictionary?.files?.[$fileKey]?.entries?.[$entryKey]; + + const children = useMemo(() => { + return _.flow([ + (nodes) => ifNotEmpty(replaceElements, $elements, nodes), + (nodes) => ifNotEmpty(replaceVariables, $variables, nodes), + (nodes) => ifNotEmpty(replaceFunctions, $functions, nodes), + (nodes) => ifNotEmpty(replaceExpressions, $expressions, nodes), + ])([maybeValue ?? $entryKey]); + }, [ + maybeValue, + $entryKey, + $elements, + $variables, + $functions, + $expressions, + ]); + + const isFragment = $as.toString() === "Symbol(react.fragment)"; + const isLingoComponent = + typeof $as === "function" && + ($as.name === "LingoComponent" || $as.name === "LingoAttributeComponent"); + + const elementProps = { + ...rest, + ...(isLingoComponent ? { $fileKey } : {}), + ...(isFragment ? {} : { ref }), + }; + + return createElement($as, elementProps, ...children); + }, +); + +// testValue needs to be cloned before passing to the callback for the first time only +// it can not be cloned inside the callback because it is called recursively +function ifNotEmpty( + callback: (nodes: ReactNode[], value: T) => ReactNode[], + testValue: T, + nodes: ReactNode[], +): ReactNode[] { + return callback(nodes, _.clone(testValue)); +} + +function replaceVariables( + nodes: ReactNode[], + variables: Record, +): ReactNode[] { + if (_.isEmpty(variables)) { + return nodes; + } + const segments = nodes.map((node) => { + if (typeof node === "string") { + const segments: ReactNode[] = []; + let lastIndex = 0; + const variableRegex = /{([\w\.\[\]]+)}/g; + let match; + + while ((match = variableRegex.exec(node)) !== null) { + if (match.index > lastIndex) { + segments.push(node.slice(lastIndex, match.index)); + } + + const [fullMatch, name] = match; + const value = variables[name]; + segments.push(value ?? fullMatch); + + lastIndex = match.index + fullMatch.length; + } + + if (lastIndex < node.length) { + segments.push(node.slice(lastIndex)); + } + + return segments; + } else if (isReactElement(node)) { + const props = node.props as { children?: ReactNode }; + return createElement( + node.type, + { ...props }, + ...replaceVariables(_.castArray(props.children || []), variables), + ); + } + return node; + }); + + return _.flatMap(segments); +} + +function isReactElement(node: ReactNode): node is ReactElement { + return ( + node !== null && + typeof node === "object" && + "type" in node && + "props" in node + ); +} + +function replaceElements( + nodes: ReactNode[], + elements?: Array, + elementIndex: { current: number } = { current: 0 }, +): ReactNode[] { + const ELEMENT_PATTERN = /(.*?)<\/element:\1>/gs; + + if (_.isEmpty(elements)) { + return nodes.map((node) => { + if (typeof node !== "string") return node; + + return node.replace(ELEMENT_PATTERN, (match, elementName, content) => { + return content; + }); + }); + } + + return nodes + .map((node) => { + if (typeof node !== "string") return node; + + const segments: ReactNode[] = []; + let lastIndex = 0; + + let match; + + while ((match = ELEMENT_PATTERN.exec(node)) !== null) { + if (match.index > lastIndex) { + segments.push(node.slice(lastIndex, match.index)); + } + + const [fullMatch, elementName, content] = match; + const Element = elements?.[elementIndex.current]; + elementIndex.current++; + + const innerContent = replaceElements([content], elements, elementIndex); + if (Element) { + segments.push(createElement(Element, {}, ...innerContent)); + } else { + segments.push(...innerContent); + } + + lastIndex = match.index + fullMatch.length; + } + + if (lastIndex < node.length) { + segments.push(node.slice(lastIndex)); + } + + return segments; + }) + .flat(); +} + +function replaceFunctions( + nodes: ReactNode[], + functions: Record, +): ReactNode[] { + if (_.isEmpty(functions)) { + return nodes; + } + + const functionIndices: Record = {}; + + return nodes + .map((node) => { + if (typeof node === "string") { + const segments: ReactNode[] = []; + let lastIndex = 0; + const functionRegex = //g; + let match; + + while ((match = functionRegex.exec(node)) !== null) { + if (match.index > lastIndex) { + segments.push(node.slice(lastIndex, match.index)); + } + + const [fullMatch, name] = match; + if (!functionIndices[name]) { + functionIndices[name] = 0; + } + const value = functions[name]?.[functionIndices[name]++]; + segments.push(value ?? fullMatch); + + lastIndex = match.index + fullMatch.length; + } + + if (lastIndex < node.length) { + segments.push(node.slice(lastIndex)); + } + + return segments; + } else if (isReactElement(node)) { + const props = node.props as { children?: ReactNode }; + return createElement( + node.type, + { ...props }, + ...replaceFunctions(_.castArray(props.children || []), functions), + ); + } + return node; + }) + .flat(); +} + +function replaceExpressions( + nodes: ReactNode[], + expressions: ReactNode[], +): ReactNode[] { + if (_.isEmpty(expressions)) { + return nodes; + } + + let expressionIndex = 0; + + function processWithIndex(nodeList: ReactNode[]): ReactNode[] { + return nodeList + .map((node) => { + if (typeof node === "string") { + const segments: ReactNode[] = []; + let lastIndex = 0; + const expressionRegex = //g; + let match; + + while ((match = expressionRegex.exec(node)) !== null) { + if (match.index > lastIndex) { + segments.push(node.slice(lastIndex, match.index)); + } + + const value = expressions[expressionIndex++]; + segments.push(value ?? match[0]); + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < node.length) { + segments.push(node.slice(lastIndex)); + } + + return segments; + } else if (isReactElement(node)) { + const props = node.props as { children?: ReactNode }; + return createElement( + node.type, + { ...props }, + ...processWithIndex(_.castArray(props.children || [])), + ); + } + return node; + }) + .flat(); + } + + return processWithIndex(nodes); +} diff --git a/packages/react/src/core/const.ts b/packages/react/src/core/const.ts new file mode 100644 index 000000000..c8159b7d7 --- /dev/null +++ b/packages/react/src/core/const.ts @@ -0,0 +1,9 @@ +/** + * The name of the cookie that stores the current locale. + */ +export const LOCALE_COOKIE_NAME = "lingo-locale"; + +/** + * The name of the header that stores the current locale. + */ +export const LOCALE_HEADER_NAME = "x-lingo-locale"; diff --git a/packages/react/src/core/get-dictionary.spec.ts b/packages/react/src/core/get-dictionary.spec.ts new file mode 100644 index 000000000..b6c37d069 --- /dev/null +++ b/packages/react/src/core/get-dictionary.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from "vitest"; +import { getDictionary } from "./get-dictionary"; + +describe("get-dictionary", () => { + const mockLoaderEn = vi.fn().mockResolvedValue( + Promise.resolve({ + default: { hello: "Hello", goodbye: "Goodbye" }, + otherExport: "ignored", + }), + ); + const mockLoaderEs = vi.fn().mockResolvedValue( + Promise.resolve({ + default: { hello: "Hola", goodbye: "Adiós" }, + }), + ); + const loaders = { + en: mockLoaderEn, + es: mockLoaderEs, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getDictionary", () => { + it("should load dictionary for specific locale using correct async loader", async () => { + const result = await getDictionary("es", loaders); + expect(mockLoaderEs).toHaveBeenCalledTimes(1); + expect(result).toEqual({ hello: "Hola", goodbye: "Adiós" }); + }); + + it("should fallback to first available loader when specific locale not found", async () => { + const result = await getDictionary("fr", loaders); + + expect(mockLoaderEn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ hello: "Hello", goodbye: "Goodbye" }); + }); + + it("should throw error when no loaders are provided", async () => { + expect(() => getDictionary("en", {})).toThrow( + "No available dictionary loaders found", + ); + expect(() => getDictionary("en")).toThrow( + "No available dictionary loaders found", + ); + }); + }); +}); diff --git a/packages/react/src/core/get-dictionary.ts b/packages/react/src/core/get-dictionary.ts new file mode 100644 index 000000000..9fa74204d --- /dev/null +++ b/packages/react/src/core/get-dictionary.ts @@ -0,0 +1,45 @@ +/** + * Loads a dictionary for the specified locale. + * + * This function attempts to load a dictionary using the provided loaders. If the specified + * locale is not available, it falls back to the first available loader. The function + * expects the loader to return a promise that resolves to an object with a `default` property + * containing the dictionary data (the default export from dictionary file). + * + * @param locale - The locale to load the dictionary for. Can be null to use the first available loader. + * @param loaders - A record of locale keys to loader functions. Each loader should return a Promise + * that resolves to an object with a `default` property containing the dictionary. + * @returns A Promise that resolves to the dictionary data (the `default` export from the loader). + * @throws {Error} When no loaders are provided or available. + * + * @example + * ```typescript + * const loaders = { + * 'en': () => import('./en.json'), + * 'es': () => import('./es.json') + * }; + * + * const dictionary = await loadDictionary('en', loaders); + * // Returns the default export from the English dictionary + * ``` + */ +export function getDictionary( + locale: string | null, + loaders: Record Promise> = {}, +) { + const loader = getDictionaryLoader(locale, loaders); + if (!loader) { + throw new Error("No available dictionary loaders found"); + } + return loader().then((value) => value.default); +} + +function getDictionaryLoader( + locale: string | null, + loaders: Record Promise> = {}, +) { + if (locale && loaders[locale]) { + return loaders[locale]; + } + return Object.values(loaders)[0]; +} diff --git a/packages/react/src/core/index.ts b/packages/react/src/core/index.ts new file mode 100644 index 000000000..b566f70b9 --- /dev/null +++ b/packages/react/src/core/index.ts @@ -0,0 +1,4 @@ +export * from "./component"; +export * from "./const"; +export * from "./attribute-component"; +export * from "./get-dictionary"; diff --git a/packages/react/src/react-router/index.ts b/packages/react/src/react-router/index.ts new file mode 100644 index 000000000..ee5286f0f --- /dev/null +++ b/packages/react/src/react-router/index.ts @@ -0,0 +1 @@ +export * from "./loader"; diff --git a/packages/react/src/react-router/loader.spec.ts b/packages/react/src/react-router/loader.spec.ts new file mode 100644 index 000000000..fb680bee9 --- /dev/null +++ b/packages/react/src/react-router/loader.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import { LOCALE_COOKIE_NAME } from "../core"; +import { loadDictionary_internal } from "./loader"; + +describe("loadDictionary_internal", () => { + function createMockRequest(cookieHeader?: string): Request { + const headers = new Headers(); + if (cookieHeader) { + headers.set("Cookie", cookieHeader); + } + return new Request("http://localhost", { headers }); + } + + const mockDictionaryLoader = { + en: vi.fn().mockResolvedValue({ default: { hello: "Hello" } }), + es: vi.fn().mockResolvedValue({ default: { hello: "Hola" } }), + fr: vi.fn().mockResolvedValue({ default: { hello: "Bonjour" } }), + }; + + it("should return first dictionary when no Cookie header is present", async () => { + const request = createMockRequest(); + const result = await loadDictionary_internal(request, mockDictionaryLoader); + + expect(mockDictionaryLoader.en).toHaveBeenCalled(); + expect(result).toEqual({ hello: "Hello" }); + }); + + it("should return first dictionary when Cookie header exists but no lingo-locale cookie", async () => { + const request = createMockRequest("session=abc123; other-cookie=value"); + const result = await loadDictionary_internal(request, mockDictionaryLoader); + + expect(mockDictionaryLoader.en).toHaveBeenCalled(); + expect(result).toEqual({ hello: "Hello" }); + }); + + it("should parse locale from lingo-locale cookie", async () => { + const request = createMockRequest(`${LOCALE_COOKIE_NAME}=es`); + const result = await loadDictionary_internal(request, mockDictionaryLoader); + + expect(mockDictionaryLoader.es).toHaveBeenCalled(); + expect(result).toEqual({ hello: "Hola" }); + }); + + it("should handle lingo-locale cookie with other cookies", async () => { + const request = createMockRequest( + `session=abc; ${LOCALE_COOKIE_NAME}=fr; other=value`, + ); + const result = await loadDictionary_internal(request, mockDictionaryLoader); + + expect(mockDictionaryLoader.fr).toHaveBeenCalled(); + expect(result).toEqual({ hello: "Bonjour" }); + }); + + it("should handle lingo-locale cookie with spaces", async () => { + const request = createMockRequest( + `session=abc; ${LOCALE_COOKIE_NAME}=es ; other=value`, + ); + const result = await loadDictionary_internal(request, mockDictionaryLoader); + + expect(mockDictionaryLoader.es).toHaveBeenCalled(); + expect(result).toEqual({ hello: "Hola" }); + }); + + it("should use explicit locale string when provided", async () => { + const result = await loadDictionary_internal("fr", mockDictionaryLoader); + + expect(mockDictionaryLoader.fr).toHaveBeenCalled(); + expect(result).toEqual({ hello: "Bonjour" }); + }); + + it("should return first dictionary when locale is not available in dictionary loaders", async () => { + const request = createMockRequest(`${LOCALE_COOKIE_NAME}=de`); + const result = await loadDictionary_internal(request, mockDictionaryLoader); + + expect(result).toEqual({ hello: "Hello" }); + }); + + it("should return first dictionary when explicit locale is not available", async () => { + const result = await loadDictionary_internal("de", mockDictionaryLoader); + + expect(result).toEqual({ hello: "Hello" }); + }); + + it("should handle malformed cookie values gracefully", async () => { + const request = createMockRequest(`${LOCALE_COOKIE_NAME}=`); + const result = await loadDictionary_internal(request, mockDictionaryLoader); + + expect(result).toEqual({ hello: "Hello" }); + }); + + it("should handle cookie with equals sign in value", async () => { + const request = createMockRequest(`${LOCALE_COOKIE_NAME}=en=US`); + const mockLoader = { + "en=US": vi.fn().mockResolvedValue({ default: { hello: "Hello US" } }), + }; + const result = await loadDictionary_internal(request, mockLoader); + + expect(mockLoader["en=US"]).toHaveBeenCalled(); + expect(result).toEqual({ hello: "Hello US" }); + }); + + it("should handle empty string locale", async () => { + const result = await loadDictionary_internal("", mockDictionaryLoader); + + expect(result).toEqual({ hello: "Hello" }); + }); + + it("should extract default export from loader result", async () => { + const customLoader = { + custom: vi.fn().mockResolvedValue({ + default: { test: "value" }, + other: { ignored: "data" }, + }), + }; + const result = await loadDictionary_internal("custom", customLoader); + + expect(result).toEqual({ test: "value" }); + }); +}); diff --git a/packages/react/src/react-router/loader.ts b/packages/react/src/react-router/loader.ts new file mode 100644 index 000000000..154f17fcb --- /dev/null +++ b/packages/react/src/react-router/loader.ts @@ -0,0 +1,105 @@ +import { LOCALE_COOKIE_NAME, getDictionary } from "../core"; + +/** + * A placeholder function for loading dictionaries that contain localized content. + * + * This function: + * + * - Should be used in React Router and Remix applications + * - Should be passed into the `LingoProvider` component + * - Is transformed into functional code by Lingo.dev Compiler + * + * @param requestOrExplicitLocale - Either a `Request` object (from loader functions) or an explicit locale string. + * + * @returns Promise that resolves to the dictionary object containing localized content. + * + * @example Use in a React Router application + * ```tsx + * import { LingoProvider } from "lingo.dev/react/client"; + * import { loadDictionary } from "lingo.dev/react/react-router"; + * import { + * Links, + * Meta, + * Outlet, + * Scripts, + * ScrollRestoration, + * useLoaderData, + * type LoaderFunctionArgs, + * } from "react-router"; + * import "./app.css"; + * + * export const loader = async ({ request }: LoaderFunctionArgs) => { + * return { + * lingoDictionary: await loadDictionary(request), + * }; + * }; + * + * export function Layout({ children }: { children: React.ReactNode }) { + * const { lingoDictionary } = useLoaderData(); + * + * return ( + * + * + * + * + * + * + * + * + * + * {children} + * + * + * + * + * + * ); + * } + * + * export default function App() { + * return ; + * } + * ``` + */ +export const loadDictionary = async ( + requestOrExplicitLocale: Request | string, +): Promise => { + return null; +}; + +function loadLocaleFromCookies(request: Request) { + // it's a Request, so get the Cookie header + const cookieHeaderValue = request.headers.get("Cookie"); + + // there's no Cookie header, so return null + if (!cookieHeaderValue) { + return null; + } + + // get the lingo-locale cookie + const cookiePrefix = `${LOCALE_COOKIE_NAME}=`; + const cookie = cookieHeaderValue + .split(";") + .find((cookie) => cookie.trim().startsWith(cookiePrefix)); + + // there's no lingo-locale cookie, so return null + if (!cookie) { + return null; + } + + // extract the locale value from the cookie + return cookie.trim().substring(cookiePrefix.length); +} + +export async function loadDictionary_internal( + requestOrExplicitLocale: Request | string, + dictionaryLoaders: Record Promise>, +) { + // gets the locale (falls back to "en") + const locale = + typeof requestOrExplicitLocale === "string" + ? requestOrExplicitLocale + : loadLocaleFromCookies(requestOrExplicitLocale); + + return getDictionary(locale, dictionaryLoaders); +} diff --git a/packages/react/src/rsc/attribute-component.tsx b/packages/react/src/rsc/attribute-component.tsx new file mode 100644 index 000000000..e74c91f23 --- /dev/null +++ b/packages/react/src/rsc/attribute-component.tsx @@ -0,0 +1,33 @@ +import { + LingoAttributeComponent as LingoCoreAttributeComponent, + LingoAttributeComponentProps as LingoCoreAttributeComponentProps, +} from "../core"; +import { loadDictionaryFromRequest } from "./utils"; + +export type LingoAttributeComponentProps = Omit< + LingoCoreAttributeComponentProps, + "$dictionary" +>; + +export async function LingoAttributeComponent( + props: LingoAttributeComponentProps, +) { + const { + $attrAs, + $attributes, + $fileKey, + $entryKey, + $loadDictionary, + ...rest + } = props; + const dictionary = await loadDictionaryFromRequest($loadDictionary); + return ( + + ); +} diff --git a/packages/react/src/rsc/component.lingo-component.spec.tsx b/packages/react/src/rsc/component.lingo-component.spec.tsx new file mode 100644 index 000000000..b1ff824ab --- /dev/null +++ b/packages/react/src/rsc/component.lingo-component.spec.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from "vitest"; +import React from "react"; + +vi.mock("./utils", () => { + return { + loadDictionaryFromRequest: vi.fn(async (loader: any) => loader("es")), + }; +}); + +// Mock core LingoComponent to capture props +vi.mock("../core", () => { + return { + LingoComponent: (props: any) => { + return React.createElement("div", { + "data-testid": "core-lingo-component", + "data-dictionary-locale": props.$dictionary?.locale ?? "none", + "data-entry": props.$entryKey, + "data-file": props.$fileKey, + }); + }, + }; +}); + +describe("rsc/component", () => { + describe("LingoComponent wrapper", () => { + it("awaits dictionary and forwards props to core component", async () => { + const { LingoComponent } = await import("./component"); + const { render, screen } = await import("@testing-library/react"); + + render( + await LingoComponent({ + $as: "span", + $fileKey: "messages", + $entryKey: "hello", + $loadDictionary: async (locale: string | null) => ({ locale }), + }), + ); + + const el = await screen.findByTestId("core-lingo-component"); + expect(el.getAttribute("data-dictionary-locale")).toBe("es"); + expect(el.getAttribute("data-file")).toBe("messages"); + expect(el.getAttribute("data-entry")).toBe("hello"); + }); + }); +}); diff --git a/packages/react/src/rsc/component.spec.tsx b/packages/react/src/rsc/component.spec.tsx new file mode 100644 index 000000000..25a4759ff --- /dev/null +++ b/packages/react/src/rsc/component.spec.tsx @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from "vitest"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { LingoHtmlComponent } from "./component"; + +vi.mock("./utils", () => { + return { + loadLocaleFromCookies: vi.fn(async () => "nl"), + loadDictionaryFromRequest: vi.fn(), + }; +}); + +describe("rsc/component", () => { + describe("LingoHtmlComponent", () => { + it("sets lang and data-lingodotdev-compiler from cookies-derived locale", async () => { + const element = await LingoHtmlComponent({}); + const markup = renderToStaticMarkup(element); + expect(markup).toContain(" & { + $loadDictionary: (locale: string | null) => Promise; +}; + +export async function LingoComponent(props: LingoComponentProps) { + const { $as, $fileKey, $entryKey, $loadDictionary, ...rest } = props; + const dictionary = await loadDictionaryFromRequest($loadDictionary); + + if ($as.name === "LingoAttributeComponent") { + rest.$loadDictionary = $loadDictionary; + } + + return ( + + ); +} + +export async function LingoHtmlComponent( + props: React.HTMLAttributes, +) { + const locale = await loadLocaleFromCookies(); + return ; +} diff --git a/packages/react/src/rsc/index.ts b/packages/react/src/rsc/index.ts new file mode 100644 index 000000000..e546d5950 --- /dev/null +++ b/packages/react/src/rsc/index.ts @@ -0,0 +1,5 @@ +export * from "./loader"; +export * from "./component"; +export * from "./provider"; +export * from "./utils"; +export * from "./attribute-component"; diff --git a/packages/react/src/rsc/loader.spec.ts b/packages/react/src/rsc/loader.spec.ts new file mode 100644 index 000000000..3cb0d36d3 --- /dev/null +++ b/packages/react/src/rsc/loader.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { loadDictionary_internal } from "./loader"; + +vi.mock("../core", () => { + return { + getDictionary: vi.fn(async (locale, loaders) => { + if (locale === "es") return { hello: "Hola" }; + if (locale === "en") return { hello: "Hello" }; + return {}; + }), + }; +}); + +import { getDictionary } from "../core"; + +describe("rsc/loader", () => { + describe("loadDictionary_internal", () => { + it("delegates to core getDictionary via internal wrapper", async () => { + const loaders = { + en: async () => ({ default: { hello: "Hello" } }), + es: async () => ({ default: { hello: "Hola" } }), + }; + const result = await loadDictionary_internal("es", loaders); + expect(getDictionary).toHaveBeenCalledWith("es", loaders); + expect(result).toEqual({ hello: "Hola" }); + }); + }); +}); diff --git a/packages/react/src/rsc/loader.ts b/packages/react/src/rsc/loader.ts new file mode 100644 index 000000000..0d1233c69 --- /dev/null +++ b/packages/react/src/rsc/loader.ts @@ -0,0 +1,46 @@ +import { getDictionary } from "../core"; + +/** + * A placeholder function for loading dictionaries that contain localized content. + * + * This function: + * + * - Should be used in React Server Components + * - Should be passed into the `LingoProvider` component + * - Is transformed into functional code by Lingo.dev Compiler + * + * @param locale - The locale code for which to load the dictionary. + * + * @returns Promise that resolves to the dictionary object containing localized content. + * + * @example Use in a Next.js (App Router) application + * ```tsx file="app/layout.tsx" + * import { LingoProvider, loadDictionary } from "lingo.dev/react/rsc"; + * + * export default function RootLayout({ + * children, + * }: Readonly<{ + * children: React.ReactNode; + * }>) { + * return ( + * loadDictionary(locale)}> + * + * + * {children} + * + * + * + * ); + * } + * ``` + */ +export const loadDictionary = async (locale: string | null): Promise => { + return {}; +}; + +export const loadDictionary_internal = async ( + locale: string | null, + dictionaryLoaders: Record Promise> = {}, +): Promise => { + return getDictionary(locale, dictionaryLoaders); +}; diff --git a/packages/react/src/rsc/provider.spec.tsx b/packages/react/src/rsc/provider.spec.tsx new file mode 100644 index 000000000..514a29989 --- /dev/null +++ b/packages/react/src/rsc/provider.spec.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +vi.mock("./utils", () => { + return { + loadDictionaryFromRequest: vi.fn(async (loader: any) => loader("en")), + }; +}); + +describe("rsc/provider", () => { + describe("LingoProvider", () => { + it("loads dictionary via helper and renders children through client provider", async () => { + const { LingoProvider } = await import("./provider"); + const loadDictionary = vi.fn(async () => ({ locale: "en" })); + render( + await LingoProvider({ + loadDictionary, + children:
    , + }), + ); + expect(screen.getByTestId("child")).toBeTruthy(); + expect(loadDictionary).toHaveBeenCalledWith("en"); + }); + }); +}); diff --git a/packages/react/src/rsc/provider.tsx b/packages/react/src/rsc/provider.tsx new file mode 100644 index 000000000..39719c11c --- /dev/null +++ b/packages/react/src/rsc/provider.tsx @@ -0,0 +1,61 @@ +import { LingoProvider as LingoClientProvider } from "../client"; +import { loadDictionaryFromRequest, loadLocaleFromCookies } from "./utils"; + +/** + * The props for the `LingoProvider` component. + */ +export type LingoProviderProps = { + /** + * A callback function that loads the dictionary for the current locale. + * + * @param locale - The locale code to load the dictionary for. + * + * @returns The dictionary object containing localized content. + */ + loadDictionary: (locale: string | null) => Promise; + /** + * The child components containing localizable content. + */ + children: React.ReactNode; +}; + +/** + * A context provider that loads the dictionary for the current locale and makes localized content available to its descendants. + * + * This component: + * + * - Should be placed at the top of the component tree + * - Should be used in server-side rendering scenarios with React Server Components (RSC) + * + * @template D - The type of the dictionary object containing localized content. + * + * @example Use in a Next.js (App Router) application + * ```tsx file="app/layout.tsx" + * import { LingoProvider, loadDictionary } from "lingo.dev/react/rsc"; + * + * export default function RootLayout({ + * children, + * }: Readonly<{ + * children: React.ReactNode; + * }>) { + * return ( + * loadDictionary(locale)}> + * + * + * {children} + * + * + * + * ); + * } + * ``` + */ +export async function LingoProvider(props: LingoProviderProps) { + const dictionary = await loadDictionaryFromRequest(props.loadDictionary); + + return ( + + {props.children} + + ); +} diff --git a/packages/react/src/rsc/utils.spec.ts b/packages/react/src/rsc/utils.spec.ts new file mode 100644 index 000000000..b11b8b782 --- /dev/null +++ b/packages/react/src/rsc/utils.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const cookiesSetSpy = vi.fn(); +vi.mock("next/headers", () => { + return { + headers: vi.fn(async () => new Map([["x-lingo-locale", "it"]])), + cookies: vi.fn(async () => ({ + get: (name: string) => + name === "lingo-locale" ? { value: "pt" } : undefined, + set: cookiesSetSpy, + })), + }; +}); + +import { headers, cookies } from "next/headers"; +import { + loadLocaleFromHeaders, + loadLocaleFromCookies, + setLocaleInCookies, + loadDictionaryFromRequest, +} from "./utils"; + +describe("rsc/utils", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("loadLocaleFromHeaders", () => { + it("reads x-lingo-locale header", async () => { + const value = await loadLocaleFromHeaders(); + expect(value).toBe("it"); + expect(headers).toHaveBeenCalled(); + }); + }); + + describe("loadLocaleFromCookies", () => { + it("reads cookie and defaults to 'en' when missing", async () => { + const value = await loadLocaleFromCookies(); + expect(value).toBe("pt"); + }); + + it("defaults to 'en' when cookie is missing", async () => { + (cookies as any).mockResolvedValueOnce({ get: () => undefined }); + const value = await loadLocaleFromCookies(); + expect(value).toBe("en"); + }); + }); + + describe("setLocaleInCookies", () => { + it("writes cookie via next/headers cookies API", async () => { + await setLocaleInCookies("de"); + expect(cookiesSetSpy).toHaveBeenCalledWith("lingo-locale", "de"); + }); + }); + + describe("loadDictionaryFromRequest", () => { + it("uses cookie locale to call loader", async () => { + const loader = vi.fn(async (locale: string) => ({ locale })); + (cookies as any).mockResolvedValueOnce({ get: () => ({ value: "ru" }) }); + const dict = await loadDictionaryFromRequest(loader); + expect(loader).toHaveBeenCalledWith("ru"); + expect(dict).toEqual({ locale: "ru" }); + }); + }); +}); diff --git a/packages/react/src/rsc/utils.ts b/packages/react/src/rsc/utils.ts new file mode 100644 index 000000000..735170122 --- /dev/null +++ b/packages/react/src/rsc/utils.ts @@ -0,0 +1,91 @@ +import { cookies, headers } from "next/headers"; +import { LOCALE_HEADER_NAME, LOCALE_COOKIE_NAME } from "../core"; + +/** + * Gets the current locale code from the `"x-lingo-locale"` header. + * + * @returns Promise that resolves to the current locale code, or `"en"` if no header is found. + * + * @example Get locale from headers in a server component + * ```typescript + * import { loadLocaleFromHeaders } from "lingo.dev/react/rsc"; + * + * export default async function ServerComponent() { + * const locale = await loadLocaleFromHeaders(); + * return
    Current locale: {locale}
    ; + * } + * ``` + */ +export async function loadLocaleFromHeaders() { + const requestHeaders = await headers(); + const result = requestHeaders.get(LOCALE_HEADER_NAME); + return result; +} + +/** + * Gets the current locale code from the `"lingo-locale"` cookie. + * + * @returns Promise that resolves to the current locale code, or `"en"` if no cookie is found. + * + * @example Get locale from cookies in a server component + * ```typescript + * import { loadLocaleFromCookies } from "lingo.dev/react/rsc"; + * + * export default async function ServerComponent() { + * const locale = await loadLocaleFromCookies(); + * return
    User's saved locale: {locale}
    ; + * } + * ``` + */ +export async function loadLocaleFromCookies() { + const requestCookies = await cookies(); + const result = requestCookies.get(LOCALE_COOKIE_NAME)?.value || "en"; + return result; +} + +/** + * Sets the current locale in the `"lingo-locale"` cookie. + * + * @param locale - The locale code to store in the cookie. + * + * @example Set locale in a server action + * ```typescript + * import { setLocaleInCookies } from "lingo.dev/react/rsc"; + * + * export async function changeLocale(locale: string) { + * "use server"; + * await setLocaleInCookies(locale); + * redirect("/"); + * } + * ``` + */ +export async function setLocaleInCookies(locale: string) { + const requestCookies = await cookies(); + requestCookies.set(LOCALE_COOKIE_NAME, locale); +} + +/** + * Loads the dictionary for the current locale. + * + * The current locale is determined by the `"lingo-locale"` cookie. + * + * @param loader - A callback function that loads the dictionary for the current locale. + * + * @returns Promise that resolves to the dictionary object containing localized content. + * + * @example Load dictionary from request in a server component + * ```typescript + * import { loadDictionaryFromRequest, loadDictionary } from "lingo.dev/react/rsc"; + * + * export default async function ServerComponent() { + * const dictionary = await loadDictionaryFromRequest(loadDictionary); + * return
    {dictionary.welcome}
    ; + * } + * ``` + */ +export async function loadDictionaryFromRequest( + loader: (locale: string) => Promise, +) { + const locale = await loadLocaleFromCookies(); + return loader(locale); +} diff --git a/packages/react/src/test/setup.ts b/packages/react/src/test/setup.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 000000000..152d0a43f --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "jsxImportSource": "react", + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "types": ["vitest/globals", "@testing-library/react"], + "allowUnreachableCode": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": [] +} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 000000000..c6a7ea48a --- /dev/null +++ b/packages/react/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + include: ["./src/**/*.spec.ts*"], + }, + plugins: [react()], +}); diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md new file mode 100644 index 000000000..968fc1a5a --- /dev/null +++ b/packages/sdk/CHANGELOG.md @@ -0,0 +1,681 @@ +# @lingo.dev/\_sdk + +## 0.13.7 + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +- Updated dependencies [[`04c3679`](https://github.com/lingodotdev/lingo.dev/commit/04c3679c69231012f167da1640dc17ac57743d6b), [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59)]: + - @lingo.dev/_spec@0.46.0 + +## 0.13.6 + +### Patch Changes + +- Updated dependencies [[`18ef68f`](https://github.com/lingodotdev/lingo.dev/commit/18ef68f8d51f0d3208cfe1f1d2167e2e1580fdcc)]: + - @lingo.dev/_spec@0.45.0 + +## 0.13.5 + +### Patch Changes + +- Updated dependencies []: + - @lingo.dev/_spec@0.44.5 + +## 0.13.4 + +### Patch Changes + +- [#1667](https://github.com/lingodotdev/lingo.dev/pull/1667) [`1a857bd`](https://github.com/lingodotdev/lingo.dev/commit/1a857bdf76d50afb3024a2437da5fd60e6721bb9) Thanks [@vrcprl](https://github.com/vrcprl)! - Upd NPM workflows + +- Updated dependencies [[`1a857bd`](https://github.com/lingodotdev/lingo.dev/commit/1a857bdf76d50afb3024a2437da5fd60e6721bb9)]: + - @lingo.dev/_spec@0.44.4 + +## 0.13.3 + +### Patch Changes + +- Updated dependencies [[`738bf08`](https://github.com/lingodotdev/lingo.dev/commit/738bf08edfe226392ec4534e05864101bc66c39c)]: + - @lingo.dev/_spec@0.44.3 + +## 0.13.2 + +### Patch Changes + +- Updated dependencies [[`f6352b6`](https://github.com/lingodotdev/lingo.dev/commit/f6352b6222e425d5d184c1591a90b1d13a7effbc)]: + - @lingo.dev/_spec@0.44.2 + +## 0.13.1 + +### Patch Changes + +- Updated dependencies [[`ad646a4`](https://github.com/lingodotdev/lingo.dev/commit/ad646a4f44dc2f0771eb3aa2783872b4d0e55f57)]: + - @lingo.dev/_spec@0.44.1 + +## 0.13.0 + +### Minor Changes + +- [#1634](https://github.com/lingodotdev/lingo.dev/pull/1634) [`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Pin all dependencies to exact versions to prevent supply chain attacks. Dependencies no longer use caret (^) or tilde (~) ranges, ensuring full control over version updates and requiring explicit review of all dependency changes. + +### Patch Changes + +- Updated dependencies [[`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144)]: + - @lingo.dev/_spec@0.44.0 + +## 0.12.9 + +### Patch Changes + +- Updated dependencies []: + - @lingo.dev/_spec@0.43.1 + +## 0.12.8 + +### Patch Changes + +- Updated dependencies [[`ac38e8e`](https://github.com/lingodotdev/lingo.dev/commit/ac38e8e8dea0d8c4cd3c8b00e6394bfbd8074611)]: + - @lingo.dev/_spec@0.43.0 + +## 0.12.7 + +### Patch Changes + +- Updated dependencies [[`d72c67c`](https://github.com/lingodotdev/lingo.dev/commit/d72c67c78a4d8f01077db8098b5d973ec98a4c1e)]: + - @lingo.dev/_spec@0.42.0 + +## 0.12.6 + +### Patch Changes + +- [#1230](https://github.com/lingodotdev/lingo.dev/pull/1230) [`b45347c`](https://github.com/lingodotdev/lingo.dev/commit/b45347c38572ee371b2bc494261b7e3e90c4aed1) Thanks [@vrcprl](https://github.com/vrcprl)! - add an xcode-xcstrings-v2 bucket type that supports cldr pluralization rules + +- Updated dependencies [[`b45347c`](https://github.com/lingodotdev/lingo.dev/commit/b45347c38572ee371b2bc494261b7e3e90c4aed1)]: + - @lingo.dev/_spec@0.41.1 + +## 0.12.5 + +### Patch Changes + +- Updated dependencies [[`82f5e7c`](https://github.com/lingodotdev/lingo.dev/commit/82f5e7cdde9a2a15b4c2a7fcb8c67ed64eab596b), [`e858174`](https://github.com/lingodotdev/lingo.dev/commit/e858174fd5165e0ea3e3f25fa1fc3edb292bc58f)]: + - @lingo.dev/_spec@0.41.0 + +## 0.12.4 + +### Patch Changes + +- Updated dependencies [[`1fa218c`](https://github.com/lingodotdev/lingo.dev/commit/1fa218c13bf90df6d175fb18264f59c1a10b967c)]: + - @lingo.dev/_spec@0.40.4 + +## 0.12.3 + +### Patch Changes + +- Updated dependencies [[`bbc71b9`](https://github.com/lingodotdev/lingo.dev/commit/bbc71b9948ccc289c9669d8b0c276c9596f6a5e7)]: + - @lingo.dev/_spec@0.40.3 + +## 0.12.2 + +### Patch Changes + +- Updated dependencies [[`6579d70`](https://github.com/lingodotdev/lingo.dev/commit/6579d70bc670c2fdc06c09842d931b07e134151c)]: + - @lingo.dev/_spec@0.40.2 + +## 0.12.1 + +### Patch Changes + +- Updated dependencies [[`a35032e`](https://github.com/lingodotdev/lingo.dev/commit/a35032e7e7a188d1f5e774576352068124526e24)]: + - @lingo.dev/_spec@0.40.1 + +## 0.12.0 + +### Minor Changes + +- [#1066](https://github.com/lingodotdev/lingo.dev/pull/1066) [`6af91a0`](https://github.com/lingodotdev/lingo.dev/commit/6af91a083d16f85051fb49a4034789abe784017e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add hints support + +### Patch Changes + +- Updated dependencies [[`6af91a0`](https://github.com/lingodotdev/lingo.dev/commit/6af91a083d16f85051fb49a4034789abe784017e)]: + - @lingo.dev/_spec@0.40.0 + +## 0.11.0 + +### Minor Changes + +- [#1049](https://github.com/lingodotdev/lingo.dev/pull/1049) [`85dfc10`](https://github.com/lingodotdev/lingo.dev/commit/85dfc10961b116e31b2bb478f42013756ca49974) Thanks [@VAIBHAVSING](https://github.com/VAIBHAVSING)! - Added new methods to the SDK: + 1. `localizeStringArray`: Localizes an array of strings while maintaining their order. + + Also added comprehensive tests for these methods using Vitest. + +## 0.10.2 + +### Patch Changes + +- Updated dependencies [[`afbb978`](https://github.com/lingodotdev/lingo.dev/commit/afbb978fec83d574f2c43b7d68457e435fca9b57)]: + - @lingo.dev/_spec@0.39.3 + +## 0.10.1 + +### Patch Changes + +- [#1023](https://github.com/lingodotdev/lingo.dev/pull/1023) [`9266fd0`](https://github.com/lingodotdev/lingo.dev/commit/9266fd0bcddf4b07ca51d2609af92a9473106f9d) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Update Zod dependency to version 3.25.76 + +- Updated dependencies [[`9266fd0`](https://github.com/lingodotdev/lingo.dev/commit/9266fd0bcddf4b07ca51d2609af92a9473106f9d)]: + - @lingo.dev/_spec@0.39.2 + +## 0.10.0 + +### Minor Changes + +- [#998](https://github.com/lingodotdev/lingo.dev/pull/998) [`cb2aa0f`](https://github.com/lingodotdev/lingo.dev/commit/cb2aa0f505d6b7dbc435b526e8a6f62265d1f453) Thanks [@VAIBHAVSING](https://github.com/VAIBHAVSING)! - Added support for AbortController to all public SDK methods, enabling consumers to cancel long-running operations using the standard AbortController API. Refactored internal methods to propagate AbortSignal and check for abortion between batch chunks. Updated fetch calls to use AbortSignal for network request cancellation. + +## 0.9.6 + +### Patch Changes + +- Updated dependencies [[`acd5356`](https://github.com/lingodotdev/lingo.dev/commit/acd5356b68d2261576240c173fea790864c3c31d)]: + - @lingo.dev/_spec@0.39.1 + +## 0.9.5 + +### Patch Changes + +- Updated dependencies [[`f644123`](https://github.com/lingodotdev/lingo.dev/commit/f644123ddf6a6254790d08af50141e4dd78c3677)]: + - @lingo.dev/_spec@0.39.0 + +## 0.9.4 + +### Patch Changes + +- Updated dependencies [[`84fd214`](https://github.com/lingodotdev/lingo.dev/commit/84fd214a21766e7683c5d645fcb8c4c0162eb0b6)]: + - @lingo.dev/_spec@0.38.0 + +## 0.9.3 + +### Patch Changes + +- Updated dependencies [[`ce8c75c`](https://github.com/lingodotdev/lingo.dev/commit/ce8c75c7fc1a2124d3e18444bc356c4dfce26434)]: + - @lingo.dev/_spec@0.37.0 + +## 0.9.2 + +### Patch Changes + +- [#937](https://github.com/lingodotdev/lingo.dev/pull/937) [`4e5983d`](https://github.com/lingodotdev/lingo.dev/commit/4e5983d7e59ebf9eb529c4b7c1c87689432ac873) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Update documentation URLs from docs.lingo.dev to lingo.dev/cli and lingo.dev/compiler + +## 0.9.1 + +### Patch Changes + +- Updated dependencies [[`1b9b113`](https://github.com/lingodotdev/lingo.dev/commit/1b9b11301978e8caa2555832d027ff93216aa6e1), [`0329a9c`](https://github.com/lingodotdev/lingo.dev/commit/0329a9cdb5e5a63fcecab4efcd7cce22f155a0e9)]: + - @lingo.dev/_spec@0.36.0 + +## 0.9.0 + +### Minor Changes + +- [#915](https://github.com/lingodotdev/lingo.dev/pull/915) [`6b4b9e6`](https://github.com/lingodotdev/lingo.dev/commit/6b4b9e6cc9a0cb5da8a4df9e9ebda474bf2a18ed) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - feat: enhance 5xx error handling with Cloudflare status integration + +- [#915](https://github.com/lingodotdev/lingo.dev/pull/915) [`6b4b9e6`](https://github.com/lingodotdev/lingo.dev/commit/6b4b9e6cc9a0cb5da8a4df9e9ebda474bf2a18ed) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - feat: enhance 5xx error handling with Cloudflare status integration + +## 0.8.1 + +### Patch Changes + +- Updated dependencies [[`a5da697`](https://github.com/lingodotdev/lingo.dev/commit/a5da697f7efd46de31d17b202d06eb5f655ed9b9)]: + - @lingo.dev/_spec@0.35.0 + +## 0.8.0 + +### Minor Changes + +- [`e980e84`](https://github.com/lingodotdev/lingo.dev/commit/e980e84178439ad70417d38b425acf9148cfc4b6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added the compiler + +### Patch Changes + +- Updated dependencies [[`e980e84`](https://github.com/lingodotdev/lingo.dev/commit/e980e84178439ad70417d38b425acf9148cfc4b6)]: + - @lingo.dev/_spec@0.34.0 + +## 0.7.43 + +### Patch Changes + +- Updated dependencies [[`0272fbf`](https://github.com/lingodotdev/lingo.dev/commit/0272fbf8847240ed9453130237d5843b918f869f)]: + - @lingo.dev/_spec@0.33.3 + +## 0.7.42 + +### Patch Changes + +- [#782](https://github.com/lingodotdev/lingo.dev/pull/782) [`d913c20`](https://github.com/lingodotdev/lingo.dev/commit/d913c20fdf0086741c8b50fd4ddfb38eae304a24) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - parallel processing + +- Updated dependencies [[`d913c20`](https://github.com/lingodotdev/lingo.dev/commit/d913c20fdf0086741c8b50fd4ddfb38eae304a24)]: + - @lingo.dev/_spec@0.33.2 + +## 0.7.41 + +### Patch Changes + +- Updated dependencies [[`3f2aba9`](https://github.com/lingodotdev/lingo.dev/commit/3f2aba9c1d5834faf89a26194f1f3d9f9b878d40)]: + - @lingo.dev/_spec@0.33.1 + +## 0.7.40 + +### Patch Changes + +- Updated dependencies [[`9aa7004`](https://github.com/lingodotdev/lingo.dev/commit/9aa700491446865dc131b80419f681132b888652)]: + - @lingo.dev/_spec@0.33.0 + +## 0.7.39 + +### Patch Changes + +- Updated dependencies [[`5170449`](https://github.com/lingodotdev/lingo.dev/commit/517044905dfc682d6a5fa95b0605b8715e2b72c7)]: + - @lingo.dev/_spec@0.32.0 + +## 0.7.38 + +### Patch Changes + +- Updated dependencies [[`c5ccf81`](https://github.com/lingodotdev/lingo.dev/commit/c5ccf81e9c2bd27bae332306da2a41e41bbeb87d)]: + - @lingo.dev/_spec@0.31.0 + +## 0.7.37 + +### Patch Changes + +- [#649](https://github.com/lingodotdev/lingo.dev/pull/649) [`409018d`](https://github.com/lingodotdev/lingo.dev/commit/409018de74614a1fd99363c6749b0e4be9e1a278) Thanks [@mathio](https://github.com/mathio)! - refactor dependencies + +- Updated dependencies [[`409018d`](https://github.com/lingodotdev/lingo.dev/commit/409018de74614a1fd99363c6749b0e4be9e1a278)]: + - @lingo.dev/_spec@0.30.3 + +## 0.7.36 + +### Patch Changes + +- [#647](https://github.com/lingodotdev/lingo.dev/pull/647) [`235b6d9`](https://github.com/lingodotdev/lingo.dev/commit/235b6d914c5f542ee5f1a8a88085cfd9dea5409e) Thanks [@mathio](https://github.com/mathio)! - update vitest + +- Updated dependencies [[`235b6d9`](https://github.com/lingodotdev/lingo.dev/commit/235b6d914c5f542ee5f1a8a88085cfd9dea5409e)]: + - @lingo.dev/_spec@0.30.2 + +## 0.7.35 + +### Patch Changes + +- [#645](https://github.com/lingodotdev/lingo.dev/pull/645) [`d824b10`](https://github.com/lingodotdev/lingo.dev/commit/d824b106631f45fc428cf01f733aab4842b4fa81) Thanks [@mathio](https://github.com/mathio)! - update dependencies + +- Updated dependencies [[`d824b10`](https://github.com/lingodotdev/lingo.dev/commit/d824b106631f45fc428cf01f733aab4842b4fa81)]: + - @lingo.dev/_spec@0.30.1 + +## 0.7.34 + +### Patch Changes + +- Updated dependencies [[`82efe61`](https://github.com/lingodotdev/lingo.dev/commit/82efe6176db12cc7c5bbeb84f38bc3261f9eec4f), [`82efe61`](https://github.com/lingodotdev/lingo.dev/commit/82efe6176db12cc7c5bbeb84f38bc3261f9eec4f)]: + - @lingo.dev/_spec@0.30.0 + +## 0.7.33 + +### Patch Changes + +- Updated dependencies [[`58f3959`](https://github.com/lingodotdev/lingo.dev/commit/58f39599b3b765ad807e725b4089a5e9b11a01b2)]: + - @lingo.dev/_spec@0.29.0 + +## 0.7.32 + +### Patch Changes + +- Updated dependencies [[`fe922a4`](https://github.com/lingodotdev/lingo.dev/commit/fe922a469c2d5dac23a909a4fb67a6efd56d80d6)]: + - @lingo.dev/_spec@0.28.0 + +## 0.7.31 + +### Patch Changes + +- Updated dependencies [[`2495afd`](https://github.com/lingodotdev/lingo.dev/commit/2495afd69e23700f96e19e5bbf74e393b29c2033), [`516a79c`](https://github.com/lingodotdev/lingo.dev/commit/516a79c75501c5960ae944379f38591806ca43e2), [`2cc6114`](https://github.com/lingodotdev/lingo.dev/commit/2cc61140fccc69ab73d40c7802a2d0e018889475)]: + - @lingo.dev/_spec@0.27.0 + +## 0.7.30 + +### Patch Changes + +- Updated dependencies [[`1dbbfd2`](https://github.com/lingodotdev/lingo.dev/commit/1dbbfd2ed9f5a7e0479dc83f700fb68ee5347a18)]: + - @lingo.dev/_spec@0.26.6 + +## 0.7.29 + +### Patch Changes + +- [#596](https://github.com/lingodotdev/lingo.dev/pull/596) [`61b487e`](https://github.com/lingodotdev/lingo.dev/commit/61b487e1e059328a32c3cdf673255d9d2cd480d9) Thanks [@vrcprl](https://github.com/vrcprl)! - add new locale + +- Updated dependencies [[`61b487e`](https://github.com/lingodotdev/lingo.dev/commit/61b487e1e059328a32c3cdf673255d9d2cd480d9)]: + - @lingo.dev/_spec@0.26.5 + +## 0.7.28 + +### Patch Changes + +- Updated dependencies [[`743d93e`](https://github.com/lingodotdev/lingo.dev/commit/743d93e554841bbd96d23682d8aec63cb4eb3ec8)]: + - @lingo.dev/_spec@0.26.4 + +## 0.7.27 + +### Patch Changes + +- [#574](https://github.com/lingodotdev/lingo.dev/pull/574) [`dde7fbe`](https://github.com/lingodotdev/lingo.dev/commit/dde7fbe57fc9b1d3ce28e192b778921099354dad) Thanks [@mathio](https://github.com/mathio)! - handle errors from i18n when streaming + +## 0.7.26 + +### Patch Changes + +- [#553](https://github.com/lingodotdev/lingo.dev/pull/553) [`95023f2`](https://github.com/lingodotdev/lingo.dev/commit/95023f2c8da3958e8582628a22bf40674f8d2317) Thanks [@vrcprl](https://github.com/vrcprl)! - Add new locales + +- Updated dependencies [[`95023f2`](https://github.com/lingodotdev/lingo.dev/commit/95023f2c8da3958e8582628a22bf40674f8d2317)]: + - @lingo.dev/_spec@0.26.3 + +## 0.7.25 + +### Patch Changes + +- Updated dependencies [[`9089b08`](https://github.com/lingodotdev/lingo.dev/commit/9089b085b968ff3195866e377ecf3016aa06f959)]: + - @lingo.dev/_spec@0.26.2 + +## 0.7.24 + +### Patch Changes + +- Updated dependencies [[`0b48be1`](https://github.com/lingodotdev/lingo.dev/commit/0b48be197e88dac581cc4f257789a04b43acf932)]: + - @lingo.dev/_spec@0.26.1 + +## 0.7.23 + +### Patch Changes + +- [#537](https://github.com/lingodotdev/lingo.dev/pull/537) [`7597b99`](https://github.com/lingodotdev/lingo.dev/commit/7597b99c4869f63a42e6de3c4ed25424498d15ae) Thanks [@mathio](https://github.com/mathio)! - automatic source locale detection + +## 0.7.22 + +### Patch Changes + +- Updated dependencies [[`bafa755`](https://github.com/lingodotdev/lingo.dev/commit/bafa755d9681e93741462eb7bcf9b85073d20fd7)]: + - @lingo.dev/_spec@0.26.0 + +## 0.7.21 + +### Patch Changes + +- Updated dependencies [[`444a731`](https://github.com/lingodotdev/lingo.dev/commit/444a7319a1351e22e5666504169023b4c8a29d5f)]: + - @lingo.dev/_spec@0.25.3 + +## 0.7.20 + +### Patch Changes + +- [#515](https://github.com/lingodotdev/lingo.dev/pull/515) [`fd99a6c`](https://github.com/lingodotdev/lingo.dev/commit/fd99a6ca18ee21774ba5c2b7ce72d1712e374675) Thanks [@mathio](https://github.com/mathio)! - add typesVersions for support of older `moduleResolution` + +## 0.7.19 + +### Patch Changes + +- Updated dependencies [[`ec2902e`](https://github.com/lingodotdev/lingo.dev/commit/ec2902e5dc31fd79cc3b6fbf478ed1f3c4df0345)]: + - @lingo.dev/_spec@0.25.2 + +## 0.7.18 + +### Patch Changes + +- Updated dependencies [[`beb0541`](https://github.com/lingodotdev/lingo.dev/commit/beb05411ee459461e05801a763b1fa28d288e04e)]: + - @lingo.dev/_spec@0.25.1 + +## 0.7.17 + +### Patch Changes + +- [#493](https://github.com/lingodotdev/lingo.dev/pull/493) [`81527a4`](https://github.com/lingodotdev/lingo.dev/commit/81527a457ad8ef7fe735232caacdf2cc575e5b20) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix payload references + +## 0.7.16 + +### Patch Changes + +- Updated dependencies [[`a096300`](https://github.com/lingodotdev/lingo.dev/commit/a0963008ea2a8bbc910b0eaeb20f4e3b3cd641a7)]: + - @lingo.dev/_spec@0.25.0 + +## 0.7.15 + +### Patch Changes + +- [#473](https://github.com/lingodotdev/lingo.dev/pull/473) [`3a99763`](https://github.com/lingodotdev/lingo.dev/commit/3a99763087512ba82955303d6f0567e813f4fa05) Thanks [@vrcprl](https://github.com/vrcprl)! - add new locales + +- Updated dependencies [[`3a99763`](https://github.com/lingodotdev/lingo.dev/commit/3a99763087512ba82955303d6f0567e813f4fa05)]: + - @lingo.dev/_spec@0.24.4 + +## 0.7.14 + +### Patch Changes + +- [#463](https://github.com/lingodotdev/lingo.dev/pull/463) [`f249d8f`](https://github.com/lingodotdev/lingo.dev/commit/f249d8f69d04f0ce40fd94e500e7b829b7ba1ed4) Thanks [@vrcprl](https://github.com/vrcprl)! - set utf-8 encoding explicitly + +## 0.7.13 + +### Patch Changes + +- [`dc8bfc7`](https://github.com/lingodotdev/lingo.dev/commit/dc8bfc7ddc38ade768b8aa11c56669db7eb446e6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - publish deps + +- Updated dependencies [[`dc8bfc7`](https://github.com/lingodotdev/lingo.dev/commit/dc8bfc7ddc38ade768b8aa11c56669db7eb446e6)]: + - @lingo.dev/_spec@0.24.3 + +## 0.7.12 + +### Patch Changes + +- [`6281dbd`](https://github.com/lingodotdev/lingo.dev/commit/6281dbd96bd5cfe54f194a6a1d055c8255a250de) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix sdk/spec exported types + +- Updated dependencies [[`6281dbd`](https://github.com/lingodotdev/lingo.dev/commit/6281dbd96bd5cfe54f194a6a1d055c8255a250de)]: + - @lingo.dev/_spec@0.24.2 + +## 0.7.11 + +### Patch Changes + +- [#419](https://github.com/lingodotdev/lingo.dev/pull/419) [`a45feb1`](https://github.com/lingodotdev/lingo.dev/commit/a45feb1d747f8fa32c42c1726953a04c174e754a) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Replexica is now Lingo.dev! 🎉 + +- Updated dependencies [[`a45feb1`](https://github.com/lingodotdev/lingo.dev/commit/a45feb1d747f8fa32c42c1726953a04c174e754a)]: + - @lingo.dev/_spec@0.24.1 + +## 0.7.10 + +### Patch Changes + +- Updated dependencies [[`003344f`](https://github.com/lingodotdev/lingo.dev/commit/003344ffcca98a391a298707f18476971c4d4c2b)]: + - @replexica/spec@0.24.0 + +## 0.7.9 + +### Patch Changes + +- Updated dependencies [[`a2ada16`](https://github.com/lingodotdev/lingo.dev/commit/a2ada16ecf4cd559d3486f0e4756d58808194f7e)]: + - @replexica/spec@0.23.0 + +## 0.7.8 + +### Patch Changes + +- Updated dependencies [[`e6521b8`](https://github.com/lingodotdev/lingo.dev/commit/e6521b86637c254c011aba89a3558802c04ab3ca)]: + - @replexica/spec@0.22.1 + +## 0.7.7 + +### Patch Changes + +- Updated dependencies [[`cff3c4e`](https://github.com/lingodotdev/lingo.dev/commit/cff3c4eb1a40f82a9c4c095e49cfd9fce053b048)]: + - @replexica/spec@0.22.0 + +## 0.7.6 + +### Patch Changes + +- Updated dependencies [[`58d7b35`](https://github.com/lingodotdev/lingo.dev/commit/58d7b3567e51cc3ef0fad0288c13451381b95a98)]: + - @replexica/spec@0.21.1 + +## 0.7.5 + +### Patch Changes + +- Updated dependencies [[`9cf5299`](https://github.com/lingodotdev/lingo.dev/commit/9cf5299f7efbef70fd83f95177eac49b4d8f8007), [`3ab5de6`](https://github.com/lingodotdev/lingo.dev/commit/3ab5de66d8a913297b46095c2e73823124cc8c5b)]: + - @replexica/spec@0.21.0 + +## 0.7.4 + +### Patch Changes + +- Updated dependencies [[`1556977`](https://github.com/lingodotdev/lingo.dev/commit/1556977332a6f949100283bfa8c9a9ff5e74b156)]: + - @replexica/spec@0.20.0 + +## 0.7.3 + +### Patch Changes + +- [`cbef8f3`](https://github.com/lingodotdev/lingo.dev/commit/cbef8f3cafdc955d61053ce885d98e425acb668d) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - moved jsdom import into the html handler function + +## 0.7.2 + +### Patch Changes + +- Updated dependencies [[`5cb3c93`](https://github.com/lingodotdev/lingo.dev/commit/5cb3c930fff6e30cff5cc2266b794f75a0db646d)]: + - @replexica/spec@0.19.0 + +## 0.7.1 + +### Patch Changes + +- [`db819a4`](https://github.com/lingodotdev/lingo.dev/commit/db819a42412ceb67fedbe729b7d018952686d60b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - reduce default batch size to avoid hitting rate limits + +- [`2c5cbcf`](https://github.com/lingodotdev/lingo.dev/commit/2c5cbcfbf6feb28440255cdea0818c8cefa61d91) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - filter out non extistent keys + +## 0.7.0 + +### Minor Changes + +- [`c42dc2d`](https://github.com/lingodotdev/lingo.dev/commit/c42dc2d5b4efe95e804b5a7e7f6d354cf8622dc7) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add `batchLocalizeText` to sdk + +## 0.6.0 + +### Minor Changes + +- [`a71a88e`](https://github.com/lingodotdev/lingo.dev/commit/a71a88e5c8bd6601b0838c381433a87763142801) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fast mode + +### Patch Changes + +- [`f0a77ad`](https://github.com/lingodotdev/lingo.dev/commit/f0a77ad774a01c30e7e9bc5a0253638176332fd2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - updated default batch size limits in the SDK + +## 0.5.0 + +### Minor Changes + +- [`ebf44cb`](https://github.com/lingodotdev/lingo.dev/commit/ebf44cbb462516abfe660c295c04627796c5a3a7) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - implement recognize locale + +- [`42d0a5a`](https://github.com/lingodotdev/lingo.dev/commit/42d0a5a7a53e296192a31e8f1d67c126793ea280) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added .localizeHtml implementation to SDK + +### Patch Changes + +- Updated dependencies [[`a6b22a3`](https://github.com/lingodotdev/lingo.dev/commit/a6b22a3237f574455d8119f914d82b0b247b4151)]: + - @replexica/spec@0.18.0 + +## 0.4.3 + +### Patch Changes + +- Updated dependencies [[`091ee35`](https://github.com/lingodotdev/lingo.dev/commit/091ee353081795bf8f61c7d41483bc309c7b62ef)]: + - @replexica/spec@0.17.0 + +## 0.4.2 + +### Patch Changes + +- Updated dependencies [[`5e282d7`](https://github.com/lingodotdev/lingo.dev/commit/5e282d7ffa5ca9494aa7046a090bb7c327085a86)]: + - @replexica/spec@0.16.0 + +## 0.4.1 + +### Patch Changes + +- Updated dependencies [[`0071cd6`](https://github.com/lingodotdev/lingo.dev/commit/0071cd66b1c868ad3898fc368390a628c5a67767)]: + - @replexica/spec@0.15.0 + +## 0.4.0 + +### Minor Changes + +- [#264](https://github.com/lingodotdev/lingo.dev/pull/264) [`cdef5b7`](https://github.com/lingodotdev/lingo.dev/commit/cdef5b7bfbee4670c6de62cf4b4f3e0315748e25) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added format specific methods to `@replexica/sdk` + +## 0.3.4 + +### Patch Changes + +- Updated dependencies [[`2859938`](https://github.com/lingodotdev/lingo.dev/commit/28599388a91bf80cea3813bb4b8999bb4df302c9)]: + - @replexica/spec@0.14.1 + +## 0.3.3 + +### Patch Changes + +- Updated dependencies [[`ca9e20e`](https://github.com/lingodotdev/lingo.dev/commit/ca9e20eef9047e20d39ccf9dff74d2f6069d4676), [`2aedf3b`](https://github.com/lingodotdev/lingo.dev/commit/2aedf3bec2d9dffc7b43fc10dea0cab5742d44af), [`626082a`](https://github.com/lingodotdev/lingo.dev/commit/626082a64b88fb3b589acd950afeafe417ce5ddc)]: + - @replexica/spec@0.14.0 + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [[`1601f70`](https://github.com/lingodotdev/lingo.dev/commit/1601f708bdf0ff1786d3bf9b19265ac5b567f740)]: + - @replexica/spec@0.13.0 + +## 0.3.1 + +### Patch Changes + +- Updated dependencies [[`bc5a28c`](https://github.com/lingodotdev/lingo.dev/commit/bc5a28c3c98b619872924b5f913229ac01387524)]: + - @replexica/spec@0.12.1 + +## 0.3.0 + +### Minor Changes + +- [#165](https://github.com/lingodotdev/lingo.dev/pull/165) [`5c2ca37`](https://github.com/lingodotdev/lingo.dev/commit/5c2ca37114663eaeb529a027e33949ef3839549b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Update locale code resolution logic + +### Patch Changes + +- Updated dependencies [[`5c2ca37`](https://github.com/lingodotdev/lingo.dev/commit/5c2ca37114663eaeb529a027e33949ef3839549b)]: + - @replexica/spec@0.12.0 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [[`6870fc7`](https://github.com/lingodotdev/lingo.dev/commit/6870fc758dae9d1adb641576befbd8cda61cd5ea)]: + - @replexica/spec@0.11.0 + +## 0.2.0 + +### Minor Changes + +- [`d6e6d5c`](https://github.com/lingodotdev/lingo.dev/commit/d6e6d5c24b266de3769e95545f74632e7d75c697) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for multisource localization to the CLI + +### Patch Changes + +- Updated dependencies [[`d6e6d5c`](https://github.com/lingodotdev/lingo.dev/commit/d6e6d5c24b266de3769e95545f74632e7d75c697)]: + - @replexica/spec@0.10.0 + +## 0.1.1 + +### Patch Changes + +- Updated dependencies [[`73c9250`](https://github.com/lingodotdev/lingo.dev/commit/73c925084989ccea120cae1617ec87776c88e83e)]: + - @replexica/spec@0.9.0 + +## 0.1.0 + +### Minor Changes + +- [#142](https://github.com/lingodotdev/lingo.dev/pull/142) [`d9b0e51`](https://github.com/lingodotdev/lingo.dev/commit/d9b0e512196329cc781a4d33346f8ca0f3a81e7e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Extract API calling into SDK package diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 000000000..40b8ab7b4 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,19 @@ +# Lingo.dev SDK + +Official SDK for Lingo.dev. + +### Installation + +```bash +npm i lingo.dev +``` + +### Usage + +``` +import {} from 'lingo.dev/sdk'; +``` + +### Documentation + +[Documentation](https://lingo.dev/go/docs) diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 000000000..784560de7 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,44 @@ +{ + "name": "@lingo.dev/_sdk", + "version": "0.13.7", + "description": "Lingo.dev JS SDK", + "private": false, + "repository": { + "type": "git", + "url": "https://github.com/lingodotdev/lingo.dev.git", + "directory": "packages/sdk" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "type": "module", + "sideEffects": false, + "types": "build/index.d.ts", + "module": "build/index.mjs", + "main": "build/index.cjs", + "files": [ + "build" + ], + "scripts": { + "dev": "tsup --watch", + "build": "pnpm typecheck && tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@lingo.dev/_spec": "workspace:*", + "@paralleldrive/cuid2": "2.2.2", + "jsdom": "25.0.1", + "zod": "4.1.12" + }, + "devDependencies": { + "@types/jsdom": "21.1.7", + "tsup": "8.5.1", + "typescript": "5.9.3", + "vitest": "3.1.2" + } +} diff --git a/packages/sdk/src/abort-controller.specs.ts b/packages/sdk/src/abort-controller.specs.ts new file mode 100644 index 000000000..54b475f02 --- /dev/null +++ b/packages/sdk/src/abort-controller.specs.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { LingoDotDevEngine } from "../src/index.js"; + +// Mock fetch globally +global.fetch = vi.fn(); + +describe("AbortController Support", () => { + let engine: LingoDotDevEngine; + + beforeEach(() => { + engine = new LingoDotDevEngine({ + apiKey: "test-key", + apiUrl: "https://test.api.com", + }); + vi.clearAllMocks(); + }); + + describe("localizeText", () => { + it("should pass AbortSignal to fetch", async () => { + const controller = new AbortController(); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ data: { text: "Hola" } }), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + await engine.localizeText( + "Hello", + { sourceLocale: "en", targetLocale: "es" }, + undefined, + controller.signal, + ); + + expect(global.fetch).toHaveBeenCalledWith( + "https://test.api.com/i18n", + expect.objectContaining({ + signal: controller.signal, + }), + ); + }); + + it("should throw error when operation is aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + engine.localizeText( + "Hello", + { sourceLocale: "en", targetLocale: "es" }, + undefined, + controller.signal, + ), + ).rejects.toThrow("Operation was aborted"); + }); + }); + + describe("localizeObject", () => { + it("should pass AbortSignal to internal method", async () => { + const controller = new AbortController(); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ data: { key: "valor" } }), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + await engine.localizeObject( + { key: "value" }, + { sourceLocale: "en", targetLocale: "es" }, + undefined, + controller.signal, + ); + + expect(global.fetch).toHaveBeenCalledWith( + "https://test.api.com/i18n", + expect.objectContaining({ + signal: controller.signal, + }), + ); + }); + }); + + describe("localizeHtml", () => { + it("should pass AbortSignal to internal method", async () => { + const controller = new AbortController(); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ data: { "body/0": "Hola" } }), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + // Mock JSDOM + const mockJSDOM = { + JSDOM: vi.fn().mockImplementation(() => ({ + window: { + document: { + documentElement: { + setAttribute: vi.fn(), + }, + head: { + childNodes: [], + }, + body: { + childNodes: [ + { + nodeType: 3, + textContent: "Hello", + parentElement: null, + }, + ], + }, + }, + }, + serialize: vi.fn().mockReturnValue("Hola"), + })), + }; + + // Mock dynamic import + vi.doMock("jsdom", () => mockJSDOM); + + await engine.localizeHtml( + "Hello", + { sourceLocale: "en", targetLocale: "es" }, + undefined, + controller.signal, + ); + + expect(global.fetch).toHaveBeenCalledWith( + "https://test.api.com/i18n", + expect.objectContaining({ + signal: controller.signal, + }), + ); + }); + }); + + describe("localizeChat", () => { + it("should pass AbortSignal to internal method", async () => { + const controller = new AbortController(); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ data: { chat_0: "Hola" } }), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + await engine.localizeChat( + [{ name: "User", text: "Hello" }], + { sourceLocale: "en", targetLocale: "es" }, + undefined, + controller.signal, + ); + + expect(global.fetch).toHaveBeenCalledWith( + "https://test.api.com/i18n", + expect.objectContaining({ + signal: controller.signal, + }), + ); + }); + }); + + describe("batchLocalizeText", () => { + it("should pass AbortSignal to individual localizeText calls", async () => { + const controller = new AbortController(); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ data: { text: "Hola" } }), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + await engine.batchLocalizeText( + "Hello", + { + sourceLocale: "en", + targetLocales: ["es", "fr"], + }, + controller.signal, + ); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenCalledWith( + "https://test.api.com/i18n", + expect.objectContaining({ + signal: controller.signal, + }), + ); + }); + }); + + describe("recognizeLocale", () => { + it("should pass AbortSignal to fetch", async () => { + const controller = new AbortController(); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ locale: "en" }), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + await engine.recognizeLocale("Hello world", controller.signal); + + expect(global.fetch).toHaveBeenCalledWith( + "https://test.api.com/recognize", + expect.objectContaining({ + signal: controller.signal, + }), + ); + }); + }); + + describe("whoami", () => { + it("should pass AbortSignal to fetch", async () => { + const controller = new AbortController(); + const mockResponse = { + ok: true, + json: vi + .fn() + .mockResolvedValue({ email: "test@example.com", id: "123" }), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + await engine.whoami(controller.signal); + + expect(global.fetch).toHaveBeenCalledWith( + "https://test.api.com/whoami", + expect.objectContaining({ + signal: controller.signal, + }), + ); + }); + }); + + describe("Batch operations abortion", () => { + it("should abort between chunks in _localizeRaw", async () => { + const controller = new AbortController(); + + // Create a large payload that will be split into multiple chunks + const largePayload: Record = {}; + for (let i = 0; i < 100; i++) { + largePayload[`key${i}`] = `value${i}`.repeat(50); // Make values long enough + } + + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ data: { key0: "processed" } }), + }; + + // Mock fetch to abort the controller after the first call + let callCount = 0; + (global.fetch as any).mockImplementation(async () => { + callCount++; + if (callCount === 1) { + // Abort immediately after first call starts + controller.abort(); + } + return mockResponse; + }); + + await expect( + engine._localizeRaw( + largePayload, + { sourceLocale: "en", targetLocale: "es" }, + undefined, + controller.signal, + ), + ).rejects.toThrow("Operation was aborted"); + + // Should have made at least one call + expect(callCount).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/sdk/src/index.spec.ts b/packages/sdk/src/index.spec.ts new file mode 100644 index 000000000..e2fa9e670 --- /dev/null +++ b/packages/sdk/src/index.spec.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi } from "vitest"; +import { LingoDotDevEngine } from "./index"; + +describe("ReplexicaEngine", () => { + it("should pass", () => { + expect(1).toBe(1); + }); + + describe("localizeHtml", () => { + it("should correctly extract, localize, and reconstruct HTML content", async () => { + // Setup test HTML with various edge cases + const inputHtml = ` + + + + Test Page + + + + standalone text +
    +

    Hello World

    +

    + This is a paragraph with + a link + and an + Test image + and some bold and italic text. +

    + + +
    + +`.trim(); + + // Mock the internal localization method + const engine = new LingoDotDevEngine({ apiKey: "test" }); + const mockLocalizeRaw = vi.spyOn(engine as any, "_localizeRaw"); + mockLocalizeRaw.mockImplementation(async (content: any) => { + // Simulate translation by adding 'ES:' prefix to all strings + return Object.fromEntries( + Object.entries(content).map(([key, value]) => [key, `ES:${value}`]), + ); + }); + + // Execute the localization + const result = await engine.localizeHtml(inputHtml, { + sourceLocale: "en", + targetLocale: "es", + }); + + // Verify the extracted content passed to _localizeRaw + expect(mockLocalizeRaw).toHaveBeenCalledWith( + { + "head/0/0": "Test Page", + "head/1#content": "Page description", + "body/0": "standalone text", + "body/1/0/0": "Hello World", + "body/1/1/0": "This is a paragraph with", + "body/1/1/1#title": "Link title", + "body/1/1/1/0": "a link", + "body/1/1/2": "and an", + "body/1/1/3#alt": "Test image", + "body/1/1/4": "and some", + "body/1/1/5/0": "bold", + "body/1/1/5/1/0": "and italic", + "body/1/1/6": "text.", + "body/1/3#placeholder": "Enter text", + }, + { + sourceLocale: "en", + targetLocale: "es", + }, + undefined, + undefined, // AbortSignal + ); + + // Verify the final HTML structure + expect(result).toContain(''); + expect(result).toContain("ES:Test Page"); + expect(result).toContain('content="ES:Page description"'); + expect(result).toContain(">ES:standalone text<"); + expect(result).toContain("

    ES:Hello World

    "); + expect(result).toContain('title="ES:Link title"'); + expect(result).toContain('alt="ES:Test image"'); + expect(result).toContain('placeholder="ES:Enter text"'); + expect(result).toContain( + 'const doNotTranslate = "this text should be ignored"', + ); + }); + }); + + describe("localizeStringArray", () => { + it("should localize an array of strings and maintain order", async () => { + const engine = new LingoDotDevEngine({ apiKey: "test" }); + const mockLocalizeObject = vi.spyOn(engine, "localizeObject"); + mockLocalizeObject.mockImplementation(async (obj: any) => { + // Simulate translation by adding 'ES:' prefix to all string values + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key, `ES:${value}`]), + ); + }); + + const inputArray = ["Hello", "Goodbye", "How are you?"]; + + const result = await engine.localizeStringArray(inputArray, { + sourceLocale: "en", + targetLocale: "es", + }); + + // Verify the mapped object was passed to localizeObject + expect(mockLocalizeObject).toHaveBeenCalledWith( + { + item_0: "Hello", + item_1: "Goodbye", + item_2: "How are you?", + }, + { + sourceLocale: "en", + targetLocale: "es", + }, + ); + + // Verify the result maintains the original order + expect(result).toEqual(["ES:Hello", "ES:Goodbye", "ES:How are you?"]); + expect(result).toHaveLength(3); + }); + + it("should handle empty array", async () => { + const engine = new LingoDotDevEngine({ apiKey: "test" }); + const mockLocalizeObject = vi.spyOn(engine, "localizeObject"); + mockLocalizeObject.mockImplementation(async () => ({})); + + const result = await engine.localizeStringArray([], { + sourceLocale: "en", + targetLocale: "es", + }); + + expect(mockLocalizeObject).toHaveBeenCalledWith( + {}, + { + sourceLocale: "en", + targetLocale: "es", + }, + ); + + expect(result).toEqual([]); + }); + }); + + describe("hints support", () => { + it("should send hints to the backend API", async () => { + // Mock global fetch + const mockFetch = vi.fn(); + global.fetch = mockFetch as any; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + "brand-name": "Optimum", + "team-label": "Equipo de la NHL", + }, + }), + }); + + const engine = new LingoDotDevEngine({ + apiKey: "test-api-key", + apiUrl: "https://test.api.url", + }); + + const hints = { + "brand-name": ["This is a brand name and should not be translated"], + "team-label": ["NHL stands for National Hockey League"], + }; + + await engine.localizeObject( + { + "brand-name": "Optimum", + "team-label": "NHL Team", + }, + { + sourceLocale: "en", + targetLocale: "es", + hints, + }, + ); + + // Verify fetch was called with correct parameters + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[0]).toBe("https://test.api.url/i18n"); + + // Parse the request body to verify hints are included + const requestBody = JSON.parse(fetchCall[1].body); + expect(requestBody.hints).toEqual(hints); + expect(requestBody.data).toEqual({ + "brand-name": "Optimum", + "team-label": "NHL Team", + }); + expect(requestBody.locale).toEqual({ + source: "en", + target: "es", + }); + }); + + it("should handle localizeObject without hints", async () => { + const mockFetch = vi.fn(); + global.fetch = mockFetch as any; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + greeting: "Hola", + }, + }), + }); + + const engine = new LingoDotDevEngine({ + apiKey: "test-api-key", + apiUrl: "https://test.api.url", + }); + + await engine.localizeObject( + { + greeting: "Hello", + }, + { + sourceLocale: "en", + targetLocale: "es", + }, + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(requestBody.hints).toBeUndefined(); + }); + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 000000000..38ea763a0 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,620 @@ +import Z from "zod"; +import { LocaleCode, localeCodeSchema } from "@lingo.dev/_spec"; +import { createId } from "@paralleldrive/cuid2"; + +const engineParamsSchema = Z.object({ + apiKey: Z.string(), + apiUrl: Z.string().url().default("https://engine.lingo.dev"), + batchSize: Z.number().int().gt(0).lte(250).default(25), + idealBatchItemSize: Z.number().int().gt(0).lte(2500).default(250), +}).passthrough(); + +const payloadSchema = Z.record(Z.string(), Z.any()); +const referenceSchema = Z.record(localeCodeSchema, payloadSchema); +const hintsSchema = Z.record(Z.string(), Z.array(Z.string())); + +const localizationParamsSchema = Z.object({ + sourceLocale: Z.union([localeCodeSchema, Z.null()]), + targetLocale: localeCodeSchema, + fast: Z.boolean().optional(), + reference: referenceSchema.optional(), + hints: hintsSchema.optional(), +}); + +/** + * LingoDotDevEngine class for interacting with the LingoDotDev API + * A powerful localization engine that supports various content types including + * plain text, objects, chat sequences, and HTML documents. + */ +export class LingoDotDevEngine { + protected config: Z.infer; + + /** + * Create a new LingoDotDevEngine instance + * @param config - Configuration options for the Engine + */ + constructor(config: Partial>) { + this.config = engineParamsSchema.parse(config); + } + + /** + * Localize content using the Lingo.dev API + * @param payload - The content to be localized + * @param params - Localization parameters including source/target locales and fast mode option + * @param progressCallback - Optional callback function to report progress (0-100) + * @param signal - Optional AbortSignal to cancel the operation + * @returns Localized content + * @internal + */ + async _localizeRaw( + payload: Z.infer, + params: Z.infer, + progressCallback?: ( + progress: number, + sourceChunk: Record, + processedChunk: Record, + ) => void, + signal?: AbortSignal, + ): Promise> { + const finalPayload = payloadSchema.parse(payload); + const finalParams = localizationParamsSchema.parse(params); + + const chunkedPayload = this.extractPayloadChunks(finalPayload); + const processedPayloadChunks: Record[] = []; + + const workflowId = createId(); + for (let i = 0; i < chunkedPayload.length; i++) { + const chunk = chunkedPayload[i]; + const percentageCompleted = Math.round( + ((i + 1) / chunkedPayload.length) * 100, + ); + + const processedPayloadChunk = await this.localizeChunk( + finalParams.sourceLocale, + finalParams.targetLocale, + { data: chunk, reference: params.reference, hints: params.hints }, + workflowId, + params.fast || false, + signal, + ); + + if (progressCallback) { + progressCallback(percentageCompleted, chunk, processedPayloadChunk); + } + + processedPayloadChunks.push(processedPayloadChunk); + } + + return Object.assign({}, ...processedPayloadChunks); + } + + /** + * Localize a single chunk of content + * @param sourceLocale - Source locale + * @param targetLocale - Target locale + * @param payload - Payload containing the chunk to be localized + * @param workflowId - Workflow ID for tracking + * @param fast - Whether to use fast mode + * @param signal - Optional AbortSignal to cancel the operation + * @returns Localized chunk + */ + private async localizeChunk( + sourceLocale: string | null, + targetLocale: string, + payload: { + data: Z.infer; + reference?: Z.infer; + hints?: Z.infer; + }, + workflowId: string, + fast: boolean, + signal?: AbortSignal, + ): Promise> { + const res = await fetch(`${this.config.apiUrl}/i18n`, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify( + { + params: { workflowId, fast }, + locale: { + source: sourceLocale, + target: targetLocale, + }, + data: payload.data, + reference: payload.reference, + hints: payload.hints, + }, + null, + 2, + ), + signal, + }); + + if (!res.ok) { + if (res.status >= 500 && res.status < 600) { + const errorText = await res.text(); + throw new Error( + `Server error (${res.status}): ${res.statusText}. ${errorText}. This may be due to temporary service issues.`, + ); + } else if (res.status === 400) { + throw new Error(`Invalid request: ${res.statusText}`); + } else { + const errorText = await res.text(); + throw new Error(errorText); + } + } + + const jsonResponse = await res.json(); + + // when streaming the error is returned in the response body + if (!jsonResponse.data && jsonResponse.error) { + throw new Error(jsonResponse.error); + } + + return jsonResponse.data || {}; + } + + /** + * Extract payload chunks based on the ideal chunk size + * @param payload - The payload to be chunked + * @returns An array of payload chunks + */ + private extractPayloadChunks( + payload: Record, + ): Record[] { + const result: Record[] = []; + let currentChunk: Record = {}; + let currentChunkItemCount = 0; + + const payloadEntries = Object.entries(payload); + for (let i = 0; i < payloadEntries.length; i++) { + const [key, value] = payloadEntries[i]; + currentChunk[key] = value; + currentChunkItemCount++; + + const currentChunkSize = this.countWordsInRecord(currentChunk); + if ( + currentChunkSize > this.config.idealBatchItemSize || + currentChunkItemCount >= this.config.batchSize || + i === payloadEntries.length - 1 + ) { + result.push(currentChunk); + currentChunk = {}; + currentChunkItemCount = 0; + } + } + + return result; + } + + /** + * Count words in a record or array + * @param payload - The payload to count words in + * @returns The total number of words + */ + private countWordsInRecord( + payload: any | Record | Array, + ): number { + if (Array.isArray(payload)) { + return payload.reduce( + (acc, item) => acc + this.countWordsInRecord(item), + 0, + ); + } else if (typeof payload === "object" && payload !== null) { + return Object.values(payload).reduce( + (acc: number, item) => acc + this.countWordsInRecord(item), + 0, + ); + } else if (typeof payload === "string") { + return payload.trim().split(/\s+/).filter(Boolean).length; + } else { + return 0; + } + } + + /** + * Localize a typical JavaScript object + * @param obj - The object to be localized (strings will be extracted and translated) + * @param params - Localization parameters: + * - sourceLocale: The source language code (e.g., 'en') + * - targetLocale: The target language code (e.g., 'es') + * - fast: Optional boolean to enable fast mode (faster but potentially lower quality) + * @param progressCallback - Optional callback function to report progress (0-100) + * @param signal - Optional AbortSignal to cancel the operation + * @returns A new object with the same structure but localized string values + */ + async localizeObject( + obj: Record, + params: Z.infer, + progressCallback?: ( + progress: number, + sourceChunk: Record, + processedChunk: Record, + ) => void, + signal?: AbortSignal, + ): Promise> { + return this._localizeRaw(obj, params, progressCallback, signal); + } + + /** + * Localize a single text string + * @param text - The text string to be localized + * @param params - Localization parameters: + * - sourceLocale: The source language code (e.g., 'en') + * - targetLocale: The target language code (e.g., 'es') + * - fast: Optional boolean to enable fast mode (faster for bigger batches) + * @param progressCallback - Optional callback function to report progress (0-100) + * @param signal - Optional AbortSignal to cancel the operation + * @returns The localized text string + */ + async localizeText( + text: string, + params: Z.infer, + progressCallback?: (progress: number) => void, + signal?: AbortSignal, + ): Promise { + const response = await this._localizeRaw( + { text }, + params, + progressCallback, + signal, + ); + return response.text || ""; + } + + /** + * Localize a text string to multiple target locales + * @param text - The text string to be localized + * @param params - Localization parameters: + * - sourceLocale: The source language code (e.g., 'en') + * - targetLocales: An array of target language codes (e.g., ['es', 'fr']) + * - fast: Optional boolean to enable fast mode (for bigger batches) + * @param signal - Optional AbortSignal to cancel the operation + * @returns An array of localized text strings + */ + async batchLocalizeText( + text: string, + params: { + sourceLocale: LocaleCode; + targetLocales: LocaleCode[]; + fast?: boolean; + }, + signal?: AbortSignal, + ) { + const responses = await Promise.all( + params.targetLocales.map((targetLocale) => + this.localizeText( + text, + { + sourceLocale: params.sourceLocale, + targetLocale, + fast: params.fast, + }, + undefined, + signal, + ), + ), + ); + + return responses; + } + + /** + * Localize an array of strings + * @param strings - An array of strings to be localized + * @param params - Localization parameters: + * - sourceLocale: The source language code (e.g., 'en') + * - targetLocale: The target language code (e.g., 'es') + * - fast: Optional boolean to enable fast mode (faster for bigger batches) + * @returns An array of localized strings in the same order + */ + async localizeStringArray( + strings: string[], + params: Z.infer, + ): Promise { + const mapped = strings.reduce( + (acc, str, i) => { + acc[`item_${i}`] = str; + return acc; + }, + {} as Record, + ); + + const result = await this.localizeObject(mapped, params); + return Object.values(result); + } + + /** + * Localize a chat sequence while preserving speaker names + * @param chat - Array of chat messages, each with 'name' and 'text' properties + * @param params - Localization parameters: + * - sourceLocale: The source language code (e.g., 'en') + * - targetLocale: The target language code (e.g., 'es') + * - fast: Optional boolean to enable fast mode (faster but potentially lower quality) + * @param progressCallback - Optional callback function to report progress (0-100) + * @param signal - Optional AbortSignal to cancel the operation + * @returns Array of localized chat messages with preserved structure + */ + async localizeChat( + chat: Array<{ name: string; text: string }>, + params: Z.infer, + progressCallback?: (progress: number) => void, + signal?: AbortSignal, + ): Promise> { + const localized = await this._localizeRaw( + { chat }, + params, + progressCallback, + signal, + ); + + return Object.entries(localized).map(([key, value]) => ({ + name: chat[parseInt(key.split("_")[1])].name, + text: value, + })); + } + + /** + * Localize an HTML document while preserving structure and formatting + * Handles both text content and localizable attributes (alt, title, placeholder, meta content) + * @param html - The HTML document string to be localized + * @param params - Localization parameters: + * - sourceLocale: The source language code (e.g., 'en') + * - targetLocale: The target language code (e.g., 'es') + * - fast: Optional boolean to enable fast mode (faster but potentially lower quality) + * @param progressCallback - Optional callback function to report progress (0-100) + * @param signal - Optional AbortSignal to cancel the operation + * @returns The localized HTML document as a string, with updated lang attribute + */ + async localizeHtml( + html: string, + params: Z.infer, + progressCallback?: (progress: number) => void, + signal?: AbortSignal, + ): Promise { + const jsdomPackage = await import("jsdom"); + const { JSDOM } = jsdomPackage; + const dom = new JSDOM(html); + const document = dom.window.document; + + const LOCALIZABLE_ATTRIBUTES: Record = { + meta: ["content"], + img: ["alt"], + input: ["placeholder"], + a: ["title"], + }; + const UNLOCALIZABLE_TAGS = ["script", "style"]; + + const extractedContent: Record = {}; + + const getPath = (node: Node, attribute?: string): string => { + const indices: number[] = []; + let current = node as ChildNode; + let rootParent = ""; + + while (current) { + const parent = current.parentElement as Element; + if (!parent) break; + + if (parent === document.documentElement) { + rootParent = current.nodeName.toLowerCase(); + break; + } + + const siblings = Array.from(parent.childNodes).filter( + (n) => + n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()), + ); + const index = siblings.indexOf(current); + if (index !== -1) { + indices.unshift(index); + } + current = parent; + } + + const basePath = rootParent + ? `${rootParent}/${indices.join("/")}` + : indices.join("/"); + return attribute ? `${basePath}#${attribute}` : basePath; + }; + + const processNode = (node: Node) => { + let parent = node.parentElement; + while (parent) { + if (UNLOCALIZABLE_TAGS.includes(parent.tagName.toLowerCase())) { + return; + } + parent = parent.parentElement; + } + + if (node.nodeType === 3) { + const text = node.textContent?.trim() || ""; + if (text) { + extractedContent[getPath(node)] = text; + } + } else if (node.nodeType === 1) { + const element = node as Element; + const tagName = element.tagName.toLowerCase(); + + const attributes = LOCALIZABLE_ATTRIBUTES[tagName] || []; + attributes.forEach((attr) => { + const value = element.getAttribute(attr); + if (value) { + extractedContent[getPath(element, attr)] = value; + } + }); + + Array.from(element.childNodes) + .filter( + (n) => + n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()), + ) + .forEach(processNode); + } + }; + + Array.from(document.head.childNodes) + .filter( + (n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()), + ) + .forEach(processNode); + Array.from(document.body.childNodes) + .filter( + (n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()), + ) + .forEach(processNode); + + const localizedContent = await this._localizeRaw( + extractedContent, + params, + progressCallback, + signal, + ); + + // Update the DOM with localized content + document.documentElement.setAttribute("lang", params.targetLocale); + + Object.entries(localizedContent).forEach(([path, value]) => { + const [nodePath, attribute] = path.split("#"); + const [rootTag, ...indices] = nodePath.split("/"); + + let parent: Element = rootTag === "head" ? document.head : document.body; + let current: Node | null = parent; + + for (const index of indices) { + const siblings = Array.from(parent.childNodes).filter( + (n) => + n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()), + ); + current = siblings[parseInt(index)] || null; + if (current?.nodeType === 1) { + parent = current as Element; + } + } + + if (current) { + if (attribute) { + (current as Element).setAttribute(attribute, value); + } else { + current.textContent = value; + } + } + }); + + return dom.serialize(); + } + + /** + * Detect the language of a given text + * @param text - The text to analyze + * @param signal - Optional AbortSignal to cancel the operation + * @returns Promise resolving to a locale code (e.g., 'en', 'es', 'fr') + */ + async recognizeLocale( + text: string, + signal?: AbortSignal, + ): Promise { + const response = await fetch(`${this.config.apiUrl}/recognize`, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify({ text }), + signal, + }); + + if (!response.ok) { + if (response.status >= 500 && response.status < 600) { + throw new Error( + `Server error (${response.status}): ${response.statusText}. This may be due to temporary service issues.`, + ); + } + throw new Error(`Error recognizing locale: ${response.statusText}`); + } + + const jsonResponse = await response.json(); + return jsonResponse.locale; + } + + async whoami( + signal?: AbortSignal, + ): Promise<{ email: string; id: string } | null> { + try { + const res = await fetch(`${this.config.apiUrl}/whoami`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + ContentType: "application/json", + }, + signal, + }); + + if (res.ok) { + const payload = await res.json(); + if (!payload?.email) { + return null; + } + + return { + email: payload.email, + id: payload.id, + }; + } + + if (res.status >= 500 && res.status < 600) { + throw new Error( + `Server error (${res.status}): ${res.statusText}. This may be due to temporary service issues.`, + ); + } + + return null; + } catch (error) { + if (error instanceof Error && error.message.includes("Server error")) { + throw error; + } + return null; + } + } +} + +/** + * @deprecated Use LingoDotDevEngine instead. This class is maintained for backwards compatibility. + */ +export class ReplexicaEngine extends LingoDotDevEngine { + private static hasWarnedDeprecation = false; + + constructor(config: Partial>) { + super(config); + if (!ReplexicaEngine.hasWarnedDeprecation) { + console.warn( + "ReplexicaEngine is deprecated and will be removed in a future release. " + + "Please use LingoDotDevEngine instead. " + + "See https://lingo.dev/cli for more information.", + ); + ReplexicaEngine.hasWarnedDeprecation = true; + } + } +} + +/** + * @deprecated Use LingoDotDevEngine instead. This class is maintained for backwards compatibility. + */ +export class LingoEngine extends LingoDotDevEngine { + private static hasWarnedDeprecation = false; + + constructor(config: Partial>) { + super(config); + if (!LingoEngine.hasWarnedDeprecation) { + console.warn( + "LingoEngine is deprecated and will be removed in a future release. " + + "Please use LingoDotDevEngine instead. " + + "See https://lingo.dev/cli for more information.", + ); + LingoEngine.hasWarnedDeprecation = true; + } + } +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 000000000..3afcad194 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "allowUnreachableCode": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"] +} diff --git a/packages/sdk/tsconfig.test.json b/packages/sdk/tsconfig.test.json new file mode 100644 index 000000000..a64e65041 --- /dev/null +++ b/packages/sdk/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "src/**/*.tsx"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts new file mode 100644 index 000000000..2d13ece73 --- /dev/null +++ b/packages/sdk/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + clean: true, + target: "esnext", + entry: ["src/index.ts"], + outDir: "build", + format: ["cjs", "esm"], + dts: true, + cjsInterop: true, + splitting: true, + outExtension: (ctx) => ({ + js: ctx.format === "cjs" ? ".cjs" : ".mjs", + }), +}); diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md new file mode 100644 index 000000000..d05b318a4 --- /dev/null +++ b/packages/spec/CHANGELOG.md @@ -0,0 +1,572 @@ +# @lingo.dev/\_spec + +## 0.46.0 + +### Minor Changes + +- [#1742](https://github.com/lingodotdev/lingo.dev/pull/1742) [`04c3679`](https://github.com/lingodotdev/lingo.dev/commit/04c3679c69231012f167da1640dc17ac57743d6b) Thanks [@cherkanovart](https://github.com/cherkanovart)! - Add csv-per-locale bucket and improve ignoredKeys support for CSV + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc + +- Updated dependencies [[`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59)]: + - @lingo.dev/_locales@0.3.3 + +## 0.45.0 + +### Minor Changes + +- [`18ef68f`](https://github.com/lingodotdev/lingo.dev/commit/18ef68f8d51f0d3208cfe1f1d2167e2e1580fdcc) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - vNext localizer + +## 0.44.5 + +### Patch Changes + +- Updated dependencies [[`40dc1bb`](https://github.com/lingodotdev/lingo.dev/commit/40dc1bbd03633d7046da5580858f728dffdcbf81)]: + - @lingo.dev/_locales@0.3.2 + +## 0.44.4 + +### Patch Changes + +- [#1667](https://github.com/lingodotdev/lingo.dev/pull/1667) [`1a857bd`](https://github.com/lingodotdev/lingo.dev/commit/1a857bdf76d50afb3024a2437da5fd60e6721bb9) Thanks [@vrcprl](https://github.com/vrcprl)! - Upd NPM workflows + +- Updated dependencies [[`1a857bd`](https://github.com/lingodotdev/lingo.dev/commit/1a857bdf76d50afb3024a2437da5fd60e6721bb9)]: + - @lingo.dev/_locales@0.3.1 + +## 0.44.3 + +### Patch Changes + +- [#1655](https://github.com/lingodotdev/lingo.dev/pull/1655) [`738bf08`](https://github.com/lingodotdev/lingo.dev/commit/738bf08edfe226392ec4534e05864101bc66c39c) Thanks [@vrcprl](https://github.com/vrcprl)! - add AIL bucket + +## 0.44.2 + +### Patch Changes + +- [#1653](https://github.com/lingodotdev/lingo.dev/pull/1653) [`f6352b6`](https://github.com/lingodotdev/lingo.dev/commit/f6352b6222e425d5d184c1591a90b1d13a7effbc) Thanks [@vrcprl](https://github.com/vrcprl)! - add Twig bucket + +## 0.44.1 + +### Patch Changes + +- [#1628](https://github.com/lingodotdev/lingo.dev/pull/1628) [`ad646a4`](https://github.com/lingodotdev/lingo.dev/commit/ad646a4f44dc2f0771eb3aa2783872b4d0e55f57) Thanks [@vrcprl](https://github.com/vrcprl)! - Add MJML bucket support + +## 0.44.0 + +### Minor Changes + +- [#1634](https://github.com/lingodotdev/lingo.dev/pull/1634) [`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Pin all dependencies to exact versions to prevent supply chain attacks. Dependencies no longer use caret (^) or tilde (~) ranges, ensuring full control over version updates and requiring explicit review of all dependency changes. + +### Patch Changes + +- Updated dependencies [[`48fab66`](https://github.com/lingodotdev/lingo.dev/commit/48fab66b6806455d9faa1dcb169d4c61194e2144)]: + - @lingo.dev/_locales@0.3.0 + +## 0.43.1 + +### Patch Changes + +- Updated dependencies [[`0f6ffbf`](https://github.com/lingodotdev/lingo.dev/commit/0f6ffbf7dafafbead768eb9e52787cb6013aa1c3)]: + - @lingo.dev/_locales@0.2.0 + +## 0.43.0 + +### Minor Changes + +- [#1585](https://github.com/lingodotdev/lingo.dev/pull/1585) [`ac38e8e`](https://github.com/lingodotdev/lingo.dev/commit/ac38e8e8dea0d8c4cd3c8b00e6394bfbd8074611) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Allow any valid ISO locale code in validation instead of hardcoded list. Validation now accepts any locale conforming to ISO 639-1, ISO 15924, ISO 3166-1, and UN M.49 standards. + +## 0.42.0 + +### Minor Changes + +- [#1583](https://github.com/lingodotdev/lingo.dev/pull/1583) [`d72c67c`](https://github.com/lingodotdev/lingo.dev/commit/d72c67c78a4d8f01077db8098b5d973ec98a4c1e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Allow any valid ISO locale code in validation instead of hardcoded list. Validation now accepts any locale conforming to ISO 639-1, ISO 15924, ISO 3166-1, and UN M.49 standards. + +## 0.41.1 + +### Patch Changes + +- [#1230](https://github.com/lingodotdev/lingo.dev/pull/1230) [`b45347c`](https://github.com/lingodotdev/lingo.dev/commit/b45347c38572ee371b2bc494261b7e3e90c4aed1) Thanks [@vrcprl](https://github.com/vrcprl)! - add an xcode-xcstrings-v2 bucket type that supports cldr pluralization rules + +## 0.41.0 + +### Minor Changes + +- [#1186](https://github.com/lingodotdev/lingo.dev/pull/1186) [`82f5e7c`](https://github.com/lingodotdev/lingo.dev/commit/82f5e7cdde9a2a15b4c2a7fcb8c67ed64eab596b) Thanks [@davidturnbull](https://github.com/davidturnbull)! - Add Markdoc support + +### Patch Changes + +- [#1215](https://github.com/lingodotdev/lingo.dev/pull/1215) [`e858174`](https://github.com/lingodotdev/lingo.dev/commit/e858174fd5165e0ea3e3f25fa1fc3edb292bc58f) Thanks [@vrcprl](https://github.com/vrcprl)! - add provider settings + +## 0.40.4 + +### Patch Changes + +- [#1201](https://github.com/lingodotdev/lingo.dev/pull/1201) [`1fa218c`](https://github.com/lingodotdev/lingo.dev/commit/1fa218c13bf90df6d175fb18264f59c1a10b967c) Thanks [@vrcprl](https://github.com/vrcprl)! - add new languages Malayalam (India), Armenian (Armenia), Macedonian (Macedonia) + +## 0.40.3 + +### Patch Changes + +- [#1192](https://github.com/lingodotdev/lingo.dev/pull/1192) [`bbc71b9`](https://github.com/lingodotdev/lingo.dev/commit/bbc71b9948ccc289c9669d8b0c276c9596f6a5e7) Thanks [@vrcprl](https://github.com/vrcprl)! - Add biome support + +## 0.40.2 + +### Patch Changes + +- [#1171](https://github.com/lingodotdev/lingo.dev/pull/1171) [`6579d70`](https://github.com/lingodotdev/lingo.dev/commit/6579d70bc670c2fdc06c09842d931b07e134151c) Thanks [@vrcprl](https://github.com/vrcprl)! - add el-CY en-IE fr-LU locales + +## 0.40.1 + +### Patch Changes + +- [#1016](https://github.com/lingodotdev/lingo.dev/pull/1016) [`a35032e`](https://github.com/lingodotdev/lingo.dev/commit/a35032e7e7a188d1f5e774576352068124526e24) Thanks [@davidturnbull](https://github.com/davidturnbull)! - feat: add automated config documentation generator for i18n.json schema + +## 0.40.0 + +### Minor Changes + +- [#1066](https://github.com/lingodotdev/lingo.dev/pull/1066) [`6af91a0`](https://github.com/lingodotdev/lingo.dev/commit/6af91a083d16f85051fb49a4034789abe784017e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add hints support for xcode and jsonc buckets + +## 0.39.3 + +### Patch Changes + +- [#1031](https://github.com/lingodotdev/lingo.dev/pull/1031) [`afbb978`](https://github.com/lingodotdev/lingo.dev/commit/afbb978fec83d574f2c43b7d68457e435fca9b57) Thanks [@mathio](https://github.com/mathio)! - add json-dictionary loader support + +## 0.39.2 + +### Patch Changes + +- [#1023](https://github.com/lingodotdev/lingo.dev/pull/1023) [`9266fd0`](https://github.com/lingodotdev/lingo.dev/commit/9266fd0bcddf4b07ca51d2609af92a9473106f9d) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Update Zod dependency to version 3.25.76 + +## 0.39.1 + +### Patch Changes + +- [#995](https://github.com/lingodotdev/lingo.dev/pull/995) [`acd5356`](https://github.com/lingodotdev/lingo.dev/commit/acd5356b68d2261576240c173fea790864c3c31d) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add Icelandic (is) locale support with is-IS regional variant + +## 0.39.0 + +### Minor Changes + +- [#981](https://github.com/lingodotdev/lingo.dev/pull/981) [`f644123`](https://github.com/lingodotdev/lingo.dev/commit/f644123ddf6a6254790d08af50141e4dd78c3677) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add support for plain TXT files to enable translation of fastlane App Store metadata and other plain text content + +## 0.38.0 + +### Minor Changes + +- [#958](https://github.com/lingodotdev/lingo.dev/pull/958) [`84fd214`](https://github.com/lingodotdev/lingo.dev/commit/84fd214a21766e7683c5d645fcb8c4c0162eb0b6) Thanks [@chrissiwaffler](https://github.com/chrissiwaffler)! - feat: add Mistral AI as a supported LLM provider + - Added Mistral AI provider support across the entire lingo.dev ecosystem + - Users can now use Mistral models for localization by setting MISTRAL_API_KEY + - Supports all Mistral models available through the @ai-sdk/mistral package + - Configuration via environment variable or user-wide config: `npx lingo.dev@latest config set llm.mistralApiKey ` + +## 0.37.0 + +### Minor Changes + +- [#956](https://github.com/lingodotdev/lingo.dev/pull/956) [`ce8c75c`](https://github.com/lingodotdev/lingo.dev/commit/ce8c75c7fc1a2124d3e18444bc356c4dfce26434) Thanks [@VAIBHAVSING](https://github.com/VAIBHAVSING)! - feat: add EJS (Embedded JavaScript) templating engine support + - Added EJS loader to support parsing and translating EJS template files + - EJS loader extracts translatable text while preserving EJS tags and expressions + - Updated spec package to include "ejs" in supported bucket types + - Added comprehensive test suite covering various EJS scenarios including conditionals, loops, includes, and mixed content + - Automatically installed EJS dependency (@types/ejs) for TypeScript support + +## 0.36.0 + +### Minor Changes + +- [#913](https://github.com/lingodotdev/lingo.dev/pull/913) [`1b9b113`](https://github.com/lingodotdev/lingo.dev/commit/1b9b11301978e8caa2555832d027ff93216aa6e1) Thanks [@The-Best-Codes](https://github.com/The-Best-Codes)! - Add support for Ollama as a CLI and Compiler provider. + +### Patch Changes + +- [#922](https://github.com/lingodotdev/lingo.dev/pull/922) [`0329a9c`](https://github.com/lingodotdev/lingo.dev/commit/0329a9cdb5e5a63fcecab4efcd7cce22f155a0e9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add openrouter ais support for compiler + +## 0.35.0 + +### Minor Changes + +- [#897](https://github.com/lingodotdev/lingo.dev/pull/897) [`a5da697`](https://github.com/lingodotdev/lingo.dev/commit/a5da697f7efd46de31d17b202d06eb5f655ed9b9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for other providers in the compiler and implement Google AI as a provider. + +## 0.34.0 + +### Minor Changes + +- [`e980e84`](https://github.com/lingodotdev/lingo.dev/commit/e980e84178439ad70417d38b425acf9148cfc4b6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added the compiler + +## 0.33.3 + +### Patch Changes + +- [#805](https://github.com/lingodotdev/lingo.dev/pull/805) [`0272fbf`](https://github.com/lingodotdev/lingo.dev/commit/0272fbf8847240ed9453130237d5843b918f869f) Thanks [@Vicentesan](https://github.com/Vicentesan)! - Introduce the gregorian language (ka-GE) + +## 0.33.2 + +### Patch Changes + +- [#782](https://github.com/lingodotdev/lingo.dev/pull/782) [`d913c20`](https://github.com/lingodotdev/lingo.dev/commit/d913c20fdf0086741c8b50fd4ddfb38eae304a24) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - parallel processing + +## 0.33.1 + +### Patch Changes + +- [#778](https://github.com/lingodotdev/lingo.dev/pull/778) [`3f2aba9`](https://github.com/lingodotdev/lingo.dev/commit/3f2aba9c1d5834faf89a26194f1f3d9f9b878d40) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add ignoredKeys + +## 0.33.0 + +### Minor Changes + +- [#759](https://github.com/lingodotdev/lingo.dev/pull/759) [`9aa7004`](https://github.com/lingodotdev/lingo.dev/commit/9aa700491446865dc131b80419f681132b888652) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Enhance TypeScript loader to support nested fields and arrays + +## 0.32.0 + +### Minor Changes + +- [#757](https://github.com/lingodotdev/lingo.dev/pull/757) [`5170449`](https://github.com/lingodotdev/lingo.dev/commit/517044905dfc682d6a5fa95b0605b8715e2b72c7) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add TypeScript loader for .ts files that extracts string literals from default exports + +## 0.31.0 + +### Minor Changes + +- [#700](https://github.com/lingodotdev/lingo.dev/pull/700) [`c5ccf81`](https://github.com/lingodotdev/lingo.dev/commit/c5ccf81e9c2bd27bae332306da2a41e41bbeb87d) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Add support for locked patterns in MDX loader + + This change adds support for preserving specific patterns in MDX files during translation, including: + - !params syntax for parameter documentation + - !! parameter_name headings + - !type declarations + - !required flags + - !values lists + + The implementation adds a new config version 1.7 with a "lockedPatterns" field that accepts an array of regex patterns to be preserved during translation. + +## 0.30.3 + +### Patch Changes + +- [#649](https://github.com/lingodotdev/lingo.dev/pull/649) [`409018d`](https://github.com/lingodotdev/lingo.dev/commit/409018de74614a1fd99363c6749b0e4be9e1a278) Thanks [@mathio](https://github.com/mathio)! - refactor dependencies + +## 0.30.2 + +### Patch Changes + +- [#647](https://github.com/lingodotdev/lingo.dev/pull/647) [`235b6d9`](https://github.com/lingodotdev/lingo.dev/commit/235b6d914c5f542ee5f1a8a88085cfd9dea5409e) Thanks [@mathio](https://github.com/mathio)! - update vitest + +## 0.30.1 + +### Patch Changes + +- [#645](https://github.com/lingodotdev/lingo.dev/pull/645) [`d824b10`](https://github.com/lingodotdev/lingo.dev/commit/d824b106631f45fc428cf01f733aab4842b4fa81) Thanks [@mathio](https://github.com/mathio)! - update dependencies + +## 0.30.0 + +### Minor Changes + +- [#631](https://github.com/lingodotdev/lingo.dev/pull/631) [`82efe61`](https://github.com/lingodotdev/lingo.dev/commit/82efe6176db12cc7c5bbeb84f38bc3261f9eec4f) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - double formatting for mdx + +- [#631](https://github.com/lingodotdev/lingo.dev/pull/631) [`82efe61`](https://github.com/lingodotdev/lingo.dev/commit/82efe6176db12cc7c5bbeb84f38bc3261f9eec4f) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - advanced mdx support (shout out to @ZYJLiu!) + +## 0.29.0 + +### Minor Changes + +- [#629](https://github.com/lingodotdev/lingo.dev/pull/629) [`58f3959`](https://github.com/lingodotdev/lingo.dev/commit/58f39599b3b765ad807e725b4089a5e9b11a01b2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - advanced mdx support (shout out to @ZYJLiu!) + +## 0.28.0 + +### Minor Changes + +- [#627](https://github.com/lingodotdev/lingo.dev/pull/627) [`fe922a4`](https://github.com/lingodotdev/lingo.dev/commit/fe922a469c2d5dac23a909a4fb67a6efd56d80d6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add support for json/yaml key locking + +## 0.27.0 + +### Minor Changes + +- [#614](https://github.com/lingodotdev/lingo.dev/pull/614) [`2495afd`](https://github.com/lingodotdev/lingo.dev/commit/2495afd69e23700f96e19e5bbf74e393b29c2033) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add basic translators + +### Patch Changes + +- [#616](https://github.com/lingodotdev/lingo.dev/pull/616) [`516a79c`](https://github.com/lingodotdev/lingo.dev/commit/516a79c75501c5960ae944379f38591806ca43e2) Thanks [@mathio](https://github.com/mathio)! - po files --frozen flag + +- [`2cc6114`](https://github.com/lingodotdev/lingo.dev/commit/2cc61140fccc69ab73d40c7802a2d0e018889475) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add Welsh language support + +## 0.26.6 + +### Patch Changes + +- [#605](https://github.com/lingodotdev/lingo.dev/pull/605) [`1dbbfd2`](https://github.com/lingodotdev/lingo.dev/commit/1dbbfd2ed9f5a7e0479dc83f700fb68ee5347a18) Thanks [@mathio](https://github.com/mathio)! - inject locale + +## 0.26.5 + +### Patch Changes + +- [#596](https://github.com/lingodotdev/lingo.dev/pull/596) [`61b487e`](https://github.com/lingodotdev/lingo.dev/commit/61b487e1e059328a32c3cdf673255d9d2cd480d9) Thanks [@vrcprl](https://github.com/vrcprl)! - add new locale + +## 0.26.4 + +### Patch Changes + +- [#584](https://github.com/lingodotdev/lingo.dev/pull/584) [`743d93e`](https://github.com/lingodotdev/lingo.dev/commit/743d93e554841bbd96d23682d8aec63cb4eb3ec8) Thanks [@khalatevarun](https://github.com/khalatevarun)! - Add unit test for utility function in locales.ts + +## 0.26.3 + +### Patch Changes + +- [#553](https://github.com/lingodotdev/lingo.dev/pull/553) [`95023f2`](https://github.com/lingodotdev/lingo.dev/commit/95023f2c8da3958e8582628a22bf40674f8d2317) Thanks [@vrcprl](https://github.com/vrcprl)! - Add new locales + +## 0.26.2 + +### Patch Changes + +- [#546](https://github.com/lingodotdev/lingo.dev/pull/546) [`9089b08`](https://github.com/lingodotdev/lingo.dev/commit/9089b085b968ff3195866e377ecf3016aa06f959) Thanks [@mathio](https://github.com/mathio)! - add helper method to spec + +## 0.26.1 + +### Patch Changes + +- [`0b48be1`](https://github.com/lingodotdev/lingo.dev/commit/0b48be197e88dac581cc4f257789a04b43acf932) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add Kinyarwanda and Kiswahili + +## 0.26.0 + +### Minor Changes + +- [#530](https://github.com/lingodotdev/lingo.dev/pull/530) [`bafa755`](https://github.com/lingodotdev/lingo.dev/commit/bafa755d9681e93741462eb7bcf9b85073d20fd7) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add Kazakh (Kazakhstan) locale (localization engine passed the benchmarks!) + +## 0.25.3 + +### Patch Changes + +- [#518](https://github.com/lingodotdev/lingo.dev/pull/518) [`444a731`](https://github.com/lingodotdev/lingo.dev/commit/444a7319a1351e22e5666504169023b4c8a29d5f) Thanks [@mathio](https://github.com/mathio)! - support JSON messages in block of .vue files + +## 0.25.2 + +### Patch Changes + +- [#498](https://github.com/lingodotdev/lingo.dev/pull/498) [`ec2902e`](https://github.com/lingodotdev/lingo.dev/commit/ec2902e5dc31fd79cc3b6fbf478ed1f3c4df0345) Thanks [@mathio](https://github.com/mathio)! - build json schema for config + +## 0.25.1 + +### Patch Changes + +- [#496](https://github.com/lingodotdev/lingo.dev/pull/496) [`beb0541`](https://github.com/lingodotdev/lingo.dev/commit/beb05411ee459461e05801a763b1fa28d288e04e) Thanks [@mathio](https://github.com/mathio)! - po files + +## 0.25.0 + +### Minor Changes + +- [#485](https://github.com/lingodotdev/lingo.dev/pull/485) [`a096300`](https://github.com/lingodotdev/lingo.dev/commit/a0963008ea2a8bbc910b0eaeb20f4e3b3cd641a7) Thanks [@mathio](https://github.com/mathio)! - add support for php buckets + +## 0.24.4 + +### Patch Changes + +- [#473](https://github.com/lingodotdev/lingo.dev/pull/473) [`3a99763`](https://github.com/lingodotdev/lingo.dev/commit/3a99763087512ba82955303d6f0567e813f4fa05) Thanks [@vrcprl](https://github.com/vrcprl)! - add new locales + +## 0.24.3 + +### Patch Changes + +- [`dc8bfc7`](https://github.com/lingodotdev/lingo.dev/commit/dc8bfc7ddc38ade768b8aa11c56669db7eb446e6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - publish deps + +## 0.24.2 + +### Patch Changes + +- [`6281dbd`](https://github.com/lingodotdev/lingo.dev/commit/6281dbd96bd5cfe54f194a6a1d055c8255a250de) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - fix sdk/spec exported types + +## 0.24.1 + +### Patch Changes + +- [#419](https://github.com/lingodotdev/lingo.dev/pull/419) [`a45feb1`](https://github.com/lingodotdev/lingo.dev/commit/a45feb1d747f8fa32c42c1726953a04c174e754a) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Replexica is now Lingo.dev! 🎉 + +## 0.24.0 + +### Minor Changes + +- [`003344f`](https://github.com/lingodotdev/lingo.dev/commit/003344ffcca98a391a298707f18476971c4d4c2b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add locale delimiter override + +## 0.23.0 + +### Minor Changes + +- [#390](https://github.com/lingodotdev/lingo.dev/pull/390) [`a2ada16`](https://github.com/lingodotdev/lingo.dev/commit/a2ada16ecf4cd559d3486f0e4756d58808194f7e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add explicit regional flag support + +## 0.22.1 + +### Patch Changes + +- [#371](https://github.com/lingodotdev/lingo.dev/pull/371) [`e6521b8`](https://github.com/lingodotdev/lingo.dev/commit/e6521b86637c254c011aba89a3558802c04ab3ca) Thanks [@mathio](https://github.com/mathio)! - support underscore in locale code + +## 0.22.0 + +### Minor Changes + +- [#356](https://github.com/lingodotdev/lingo.dev/pull/356) [`cff3c4e`](https://github.com/lingodotdev/lingo.dev/commit/cff3c4eb1a40f82a9c4c095e49cfd9fce053b048) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add dato support + +## 0.21.1 + +### Patch Changes + +- [`58d7b35`](https://github.com/lingodotdev/lingo.dev/commit/58d7b3567e51cc3ef0fad0288c13451381b95a98) Thanks [@vrcprl](https://github.com/vrcprl)! - Added Telugu (India) locale + +## 0.21.0 + +### Minor Changes + +- [#327](https://github.com/lingodotdev/lingo.dev/pull/327) [`3ab5de6`](https://github.com/lingodotdev/lingo.dev/commit/3ab5de66d8a913297b46095c2e73823124cc8c5b) Thanks [@partik03](https://github.com/partik03)! - added support for xliff loader + +### Patch Changes + +- [`9cf5299`](https://github.com/lingodotdev/lingo.dev/commit/9cf5299f7efbef70fd83f95177eac49b4d8f8007) Thanks [@vrcprl](https://github.com/vrcprl)! - Add Tagalog + +## 0.20.0 + +### Minor Changes + +- [`1556977`](https://github.com/lingodotdev/lingo.dev/commit/1556977332a6f949100283bfa8c9a9ff5e74b156) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add new locales + +## 0.19.0 + +### Minor Changes + +- [`5cb3c93`](https://github.com/lingodotdev/lingo.dev/commit/5cb3c930fff6e30cff5cc2266b794f75a0db646d) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added Latin / Cyrilic modifiers for Serbian + +## 0.18.0 + +### Minor Changes + +- [#300](https://github.com/lingodotdev/lingo.dev/pull/300) [`a6b22a3`](https://github.com/lingodotdev/lingo.dev/commit/a6b22a3237f574455d8119f914d82b0b247b4151) Thanks [@partik03](https://github.com/partik03)! - implemented srt file loader and added support for srt file format in spec + +## 0.17.0 + +### Minor Changes + +- [#275](https://github.com/lingodotdev/lingo.dev/pull/275) [`091ee35`](https://github.com/lingodotdev/lingo.dev/commit/091ee353081795bf8f61c7d41483bc309c7b62ef) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add support for `.po` format + +## 0.16.0 + +### Minor Changes + +- [#268](https://github.com/lingodotdev/lingo.dev/pull/268) [`5e282d7`](https://github.com/lingodotdev/lingo.dev/commit/5e282d7ffa5ca9494aa7046a090bb7c327085a86) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - composable loaders + +## 0.15.0 + +### Minor Changes + +- [`0071cd6`](https://github.com/lingodotdev/lingo.dev/commit/0071cd66b1c868ad3898fc368390a628c5a67767) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add csv format support + +## 0.14.1 + +### Patch Changes + +- [`2859938`](https://github.com/lingodotdev/lingo.dev/commit/28599388a91bf80cea3813bb4b8999bb4df302c9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add missing locales + +## 0.14.0 + +### Minor Changes + +- [`ca9e20e`](https://github.com/lingodotdev/lingo.dev/commit/ca9e20eef9047e20d39ccf9dff74d2f6069d4676) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - .strings support + +- [`2aedf3b`](https://github.com/lingodotdev/lingo.dev/commit/2aedf3bec2d9dffc7b43fc10dea0cab5742d44af) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added support for .stringsdict + +- [`626082a`](https://github.com/lingodotdev/lingo.dev/commit/626082a64b88fb3b589acd950afeafe417ce5ddc) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - added Flutter .arb support + +## 0.13.0 + +### Minor Changes + +- [#181](https://github.com/lingodotdev/lingo.dev/pull/181) [`1601f70`](https://github.com/lingodotdev/lingo.dev/commit/1601f708bdf0ff1786d3bf9b19265ac5b567f740) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Added support for .properties file + +## 0.12.1 + +### Patch Changes + +- [`bc5a28c`](https://github.com/lingodotdev/lingo.dev/commit/bc5a28c3c98b619872924b5f913229ac01387524) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Fix spec imports + +## 0.12.0 + +### Minor Changes + +- [#165](https://github.com/lingodotdev/lingo.dev/pull/165) [`5c2ca37`](https://github.com/lingodotdev/lingo.dev/commit/5c2ca37114663eaeb529a027e33949ef3839549b) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Update locale code resolution logic + +## 0.11.0 + +### Minor Changes + +- [`6870fc7`](https://github.com/lingodotdev/lingo.dev/commit/6870fc758dae9d1adb641576befbd8cda61cd5ea) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Fix version number bumping in 1.2 config autoupgrade + +## 0.10.0 + +### Minor Changes + +- [`d6e6d5c`](https://github.com/lingodotdev/lingo.dev/commit/d6e6d5c24b266de3769e95545f74632e7d75c697) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add support for multisource localization to the CLI + +## 0.9.0 + +### Minor Changes + +- [#158](https://github.com/lingodotdev/lingo.dev/pull/158) [`73c9250`](https://github.com/lingodotdev/lingo.dev/commit/73c925084989ccea120cae1617ec87776c88e83e) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Configuration spec v1.1: Improved bucket config structure, to support exclusion patterns + +## 0.8.0 + +### Minor Changes + +- [`8c8e7dd`](https://github.com/lingodotdev/lingo.dev/commit/8c8e7dd4d35669d484240d643427612ecdaf73eb) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Added new locales + +## 0.7.0 + +### Minor Changes + +- [`c0be1a2`](https://github.com/lingodotdev/lingo.dev/commit/c0be1a29e3069ef2c8bdc4e4f52d2fb17abdb1f5) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Replaced `replexica config` with `replexica show config`. Added `replexica show locale sources` and `replexica show locale targets`. + +## 0.6.0 + +### Minor Changes + +- [`10252ce`](https://github.com/lingodotdev/lingo.dev/commit/10252ceaa2685cc23f4dbeb6ac985cc2148853e2) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Add android support + +## 0.5.1 + +### Patch Changes + +- [`088de18`](https://github.com/lingodotdev/lingo.dev/commit/088de18a53f45fa8df5833fe81ed96a2ed231299) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Fix @replexica/config reference + +## 0.5.0 + +### Minor Changes + +- [#99](https://github.com/lingodotdev/lingo.dev/pull/99) [`4e94058`](https://github.com/lingodotdev/lingo.dev/commit/4e940582ea8ebe5a058b76fb33420729f7bfdcef) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - Added support for i18n lockfiles to improve AI localization performance. + +## 0.4.1 + +### Patch Changes + +- [#94](https://github.com/lingodotdev/lingo.dev/pull/94) [`abab45c`](https://github.com/lingodotdev/lingo.dev/commit/abab45cc91675f507499bf84350b080cd647c464) Thanks [@vrcprl](https://github.com/vrcprl)! - Locales mapping (ex. en -> en-US) + +## 0.4.0 + +### Minor Changes + +- [#87](https://github.com/lingodotdev/lingo.dev/pull/87) [`07657c6`](https://github.com/lingodotdev/lingo.dev/commit/07657c611306797d605718e13ce6b2c920a5a94e) Thanks [@vrcprl](https://github.com/vrcprl)! - added new core locales : ja de pt it ru uk hi zh ko tr ar and source locales yue pl sk th + +## 0.3.0 + +### Minor Changes + +- [`830d4a4`](https://github.com/lingodotdev/lingo.dev/commit/830d4a441c4d1177c9356756a9e9afc170a386d6) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - add support for shyriiwook language + +## 0.2.0 + +### Minor Changes + +- [#76](https://github.com/lingodotdev/lingo.dev/pull/76) [`69d487c`](https://github.com/lingodotdev/lingo.dev/commit/69d487c0b4c8e22f9c86867ebf6cc55ea2875dbf) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - enable french, catalan in source/target mode, and czech in source-only mode + +## 0.1.0 + +### Minor Changes + +- [#73](https://github.com/lingodotdev/lingo.dev/pull/73) [`94ab265`](https://github.com/lingodotdev/lingo.dev/commit/94ab26551577b5dfab629ffee3c82e59b56ce25d) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - intro a `@replexica/spec` package containing common definitions, constants, schemas, and types + +- [#75](https://github.com/lingodotdev/lingo.dev/pull/75) [`b11b48e`](https://github.com/lingodotdev/lingo.dev/commit/b11b48e7c3ab05dd8de0ddcfe5cb4589786abbf9) Thanks [@maxprilutskiy](https://github.com/maxprilutskiy)! - framework-agnostic i18n support diff --git a/packages/spec/README.md b/packages/spec/README.md new file mode 100644 index 000000000..5e40f9d82 --- /dev/null +++ b/packages/spec/README.md @@ -0,0 +1,19 @@ +# Lingo.dev Spec + +A utility package for Lingo.dev. + +### Installation + +```bash +npm i lingo.dev +``` + +### Usage + +``` +import {} from 'lingo.dev/spec'; +``` + +### Documentation + +[Documentation](https://lingo.dev/go/docs) diff --git a/packages/spec/package.json b/packages/spec/package.json new file mode 100644 index 000000000..a273c7af3 --- /dev/null +++ b/packages/spec/package.json @@ -0,0 +1,43 @@ +{ + "name": "@lingo.dev/_spec", + "version": "0.46.0", + "description": "Lingo.dev open specification", + "private": false, + "repository": { + "type": "git", + "url": "https://github.com/lingodotdev/lingo.dev.git", + "directory": "packages/spec" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "type": "module", + "sideEffects": false, + "types": "build/index.d.ts", + "module": "build/index.mjs", + "main": "build/index.cjs", + "files": [ + "build" + ], + "scripts": { + "dev": "tsup --watch", + "build": "pnpm typecheck && tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@lingo.dev/_locales": "workspace:*", + "zod": "4.1.12" + }, + "devDependencies": { + "@types/node": "22.13.5", + "tsup": "8.5.1", + "typescript": "5.9.3", + "vitest": "3.1.2" + } +} diff --git a/packages/spec/src/config.spec.ts b/packages/spec/src/config.spec.ts new file mode 100644 index 000000000..1fdc665fb --- /dev/null +++ b/packages/spec/src/config.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { + parseI18nConfig, + defaultConfig, + LATEST_CONFIG_DEFINITION, +} from "./config"; + +// Helper function to create a v0 config +const createV0Config = () => ({ + version: 0, +}); + +// Helper function to create a v1 config +const createV1Config = () => ({ + version: 1, + locale: { + source: "en", + targets: ["es"], + }, + buckets: { + "src/ui/[locale]/.json": "json", + "src/blog/[locale]/*.md": "markdown", + }, +}); + +// Helper function to create a v1.1 config +const createV1_1Config = () => ({ + version: 1.1, + locale: { + source: "en", + targets: ["es", "fr", "pt-PT", "pt_BR"], + }, + buckets: { + json: { + include: ["src/ui/[locale]/.json"], + }, + markdown: { + include: ["src/blog/[locale]/*.md"], + exclude: ["src/blog/[locale]/drafts.md"], + }, + }, +}); + +const createV1_2Config = () => ({ + ...createV1_1Config(), + version: 1.2, +}); + +const createV1_3Config = () => ({ + ...createV1_2Config(), + version: 1.3, +}); + +const createV1_4Config = () => ({ + ...createV1_3Config(), + version: 1.4, + $schema: "https://lingo.dev/schema/i18n.json", +}); + +const createInvalidLocaleConfig = () => ({ + version: 1, + locale: { + source: "bbbb", + targets: ["es", "aaaa"], + }, + buckets: { + "src/ui/[locale]/.json": "json", + "src/blog/[locale]/*.md": "markdown", + }, +}); + +describe("I18n Config Parser", () => { + it("should upgrade v0 config to latest version", () => { + const v0Config = createV0Config(); + const result = parseI18nConfig(v0Config); + + expect(result["$schema"]).toBeDefined(); + expect(result.version).toBe(LATEST_CONFIG_DEFINITION.defaultValue.version); + expect(result.locale).toEqual(defaultConfig.locale); + expect(result.buckets).toEqual({}); + }); + + it("should upgrade v1 config to latest version", () => { + const v1Config = createV1Config(); + const result = parseI18nConfig(v1Config); + + expect(result["$schema"]).toBeDefined(); + expect(result.version).toBe(LATEST_CONFIG_DEFINITION.defaultValue.version); + expect(result.locale).toEqual(v1Config.locale); + expect(result.buckets).toEqual({ + json: { + include: ["src/ui/[locale]/.json"], + }, + markdown: { + include: ["src/blog/[locale]/*.md"], + }, + }); + }); + + it("should handle empty config and use defaults", () => { + const emptyConfig = {}; + const result = parseI18nConfig(emptyConfig); + + expect(result).toEqual(defaultConfig); + }); + + it("should ignore extra fields in the config", () => { + const configWithExtra = { + ...createV1_4Config(), + extraField: "should be ignored", + }; + const result = parseI18nConfig(configWithExtra); + + expect(result).not.toHaveProperty("extraField"); + expect(result).toEqual(createV1_4Config()); + }); + + it("should throw an error for unsupported locales", () => { + const invalidLocaleConfig = createInvalidLocaleConfig(); + expect(() => parseI18nConfig(invalidLocaleConfig)).toThrow( + `\nUnsupported locale: ${invalidLocaleConfig.locale.source}\nUnsupported locale: ${invalidLocaleConfig.locale.targets[1]}`, + ); + }); +}); diff --git a/packages/spec/src/config.ts b/packages/spec/src/config.ts new file mode 100644 index 000000000..dc5d8154b --- /dev/null +++ b/packages/spec/src/config.ts @@ -0,0 +1,530 @@ +import Z from "zod"; +import { localeCodeSchema } from "./locales"; +import { bucketTypeSchema } from "./formats"; + +// common +export const localeSchema = Z.object({ + source: localeCodeSchema.describe( + "Primary source locale code of your content (e.g. 'en', 'en-US', 'pt_BR', or 'pt-rBR'). Must be one of the supported locale codes – either a short ISO-639 language code or a full locale identifier using '-', '_' or Android '-r' notation.", + ), + targets: Z.array(localeCodeSchema).describe( + "List of target locale codes to translate to.", + ), +}).describe("Locale configuration block."); + +// factories +type ConfigDefinition< + T extends Z.ZodRawShape, + _P extends Z.ZodRawShape = any, +> = { + schema: Z.ZodObject; + defaultValue: Z.infer>; + parse: (rawConfig: unknown) => Z.infer>; +}; +const createConfigDefinition = < + T extends Z.ZodRawShape, + _P extends Z.ZodRawShape = any, +>( + definition: ConfigDefinition, +) => definition; + +type ConfigDefinitionExtensionParams< + T extends Z.ZodRawShape, + P extends Z.ZodRawShape, +> = { + createSchema: (baseSchema: Z.ZodObject

    ) => Z.ZodObject; + createDefaultValue: ( + baseDefaultValue: Z.infer>, + ) => Z.infer>; + createUpgrader: ( + config: Z.infer>, + schema: Z.ZodObject, + defaultValue: Z.infer>, + ) => Z.infer>; +}; +const extendConfigDefinition = < + T extends Z.ZodRawShape, + P extends Z.ZodRawShape, +>( + definition: ConfigDefinition, + params: ConfigDefinitionExtensionParams, +) => { + const schema = params.createSchema(definition.schema); + const defaultValue = params.createDefaultValue(definition.defaultValue); + const upgrader = (config: Z.infer>) => + params.createUpgrader(config, schema, defaultValue); + + return createConfigDefinition({ + schema, + defaultValue, + parse: (rawConfig) => { + const safeResult = schema.safeParse(rawConfig); + if (safeResult.success) { + return safeResult.data; + } + + const localeErrors = safeResult.error.issues + .filter((issue) => issue.message.includes("Invalid locale code")) + .map((issue) => { + let unsupportedLocale = ""; + const path = issue.path; + + const config = rawConfig as { locale?: { [key: string]: any } }; + + if (config.locale) { + unsupportedLocale = path.reduce((acc, key) => { + if (acc && typeof acc === "object" && key in acc) { + return acc[key]; + } + return acc; + }, config.locale); + } + + return `Unsupported locale: ${unsupportedLocale}`; + }); + + if (localeErrors.length > 0) { + throw new Error(`\n${localeErrors.join("\n")}`); + } + + const baseConfig = definition.parse(rawConfig); + const result = upgrader(baseConfig); + return result; + }, + }); +}; + +// any -> v0 +const configV0Schema = Z.object({ + version: Z.union([Z.number(), Z.string()]) + .default(0) + .describe("The version number of the schema."), +}); +export const configV0Definition = createConfigDefinition({ + schema: configV0Schema, + defaultValue: { version: 0 }, + parse: (rawConfig) => { + return configV0Schema.parse(rawConfig); + }, +}); + +// v0 -> v1 +export const configV1Definition = extendConfigDefinition(configV0Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + locale: localeSchema, + buckets: Z.record(Z.string(), bucketTypeSchema) + .default({}) + .describe( + "Mapping of source file paths (glob patterns) to bucket types.", + ) + .optional(), + }), + createDefaultValue: () => ({ + version: 1, + locale: { + source: "en" as const, + targets: ["es" as const], + }, + buckets: {}, + }), + createUpgrader: () => ({ + version: 1, + locale: { + source: "en" as const, + targets: ["es" as const], + }, + buckets: {}, + }), +}); + +// v1 -> v1.1 +export const configV1_1Definition = extendConfigDefinition(configV1Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z.partialRecord( + bucketTypeSchema, + Z.object({ + include: Z.array(Z.string()) + .default([]) + .describe( + "File paths or glob patterns to include for this bucket.", + ), + exclude: Z.array(Z.string()) + .optional() + .describe( + "File paths or glob patterns to exclude from this bucket.", + ), + }), + ).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.1, + buckets: {}, + }), + createUpgrader: (oldConfig, schema) => { + const upgradedConfig: Z.infer = { + ...oldConfig, + version: 1.1, + buckets: {}, + }; + + // Transform buckets from v1 to v1.1 format + if (oldConfig.buckets) { + for (const [bucketPath, bucketType] of Object.entries( + oldConfig.buckets, + )) { + if (!upgradedConfig.buckets[bucketType]) { + upgradedConfig.buckets[bucketType] = { + include: [], + }; + } + upgradedConfig.buckets[bucketType]?.include.push(bucketPath); + } + } + + return upgradedConfig; + }, +}); + +// v1.1 -> v1.2 +// Changes: Add "extraSource" optional field to the locale node of the config +export const configV1_2Definition = extendConfigDefinition( + configV1_1Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + locale: localeSchema.extend({ + extraSource: localeCodeSchema + .optional() + .describe( + "Optional extra source locale code used as fallback during translation.", + ), + }), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.2, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.2, + }), + }, +); + +// v1.2 -> v1.3 +// Changes: Support both string paths and {path, delimiter} objects in bucket include/exclude arrays +export const bucketItemSchema = Z.object({ + path: Z.string().describe("Path pattern containing a [locale] placeholder."), + delimiter: Z.union([Z.literal("-"), Z.literal("_"), Z.literal(null)]) + .optional() + .describe( + "Delimiter that replaces the [locale] placeholder in the path (default: no delimiter).", + ), +}).describe( + "Bucket path item. Either a string path or an object specifying path and delimiter.", +); +export type BucketItem = Z.infer; + +// Define a base bucket value schema that can be reused and extended +export const bucketValueSchemaV1_3 = Z.object({ + include: Z.array(Z.union([Z.string(), bucketItemSchema])) + .default([]) + .describe("Glob patterns or bucket items to include for this bucket."), + exclude: Z.array(Z.union([Z.string(), bucketItemSchema])) + .optional() + .describe("Glob patterns or bucket items to exclude from this bucket."), + injectLocale: Z.array(Z.string()) + .optional() + .describe( + "Keys within files where the current locale should be injected or removed.", + ), +}).describe("Configuration options for a translation bucket."); + +export const configV1_3Definition = extendConfigDefinition( + configV1_2Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z.partialRecord( + bucketTypeSchema, + bucketValueSchemaV1_3, + ).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.3, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.3, + }), + }, +); + +const configSchema = "https://lingo.dev/schema/i18n.json"; + +// v1.3 -> v1.4 +// Changes: Add $schema to the config +export const configV1_4Definition = extendConfigDefinition( + configV1_3Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + $schema: Z.string().default(configSchema), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.4, + $schema: configSchema, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.4, + $schema: configSchema, + }), + }, +); + +// v1.4 -> v1.5 +// Changes: add "provider" field to the config +const providerSchema = Z.object({ + id: Z.enum([ + "openai", + "anthropic", + "google", + "ollama", + "openrouter", + "mistral", + ]).describe("Identifier of the translation provider service."), + model: Z.string().describe("Model name to use for translations."), + prompt: Z.string().describe( + "Prompt template used when requesting translations.", + ), + baseUrl: Z.string() + .optional() + .describe("Custom base URL for the provider API (optional)."), +}).describe("Configuration for the machine-translation provider."); +export const configV1_5Definition = extendConfigDefinition( + configV1_4Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + provider: providerSchema.optional(), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.5, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.5, + }), + }, +); + +// v1.5 -> v1.6 +// Changes: Add "lockedKeys" string array to bucket config +export const bucketValueSchemaV1_6 = bucketValueSchemaV1_3.extend({ + lockedKeys: Z.array(Z.string()) + .optional() + .describe( + "Keys that must remain unchanged and should never be overwritten by translations.", + ), +}); + +export const configV1_6Definition = extendConfigDefinition( + configV1_5Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z.partialRecord( + bucketTypeSchema, + bucketValueSchemaV1_6, + ).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.6, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.6, + }), + }, +); + +// Changes: Add "lockedPatterns" string array of regex patterns to bucket config +export const bucketValueSchemaV1_7 = bucketValueSchemaV1_6.extend({ + lockedPatterns: Z.array(Z.string()) + .optional() + .describe( + "Regular expression patterns whose matched content should remain locked during translation.", + ), +}); + +export const configV1_7Definition = extendConfigDefinition( + configV1_6Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z.partialRecord( + bucketTypeSchema, + bucketValueSchemaV1_7, + ).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.7, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.7, + }), + }, +); + +// v1.7 -> v1.8 +// Changes: Add "ignoredKeys" string array to bucket config +export const bucketValueSchemaV1_8 = bucketValueSchemaV1_7.extend({ + ignoredKeys: Z.array(Z.string()) + .optional() + .describe( + "Keys that should be completely ignored by translation processes.", + ), +}); + +export const configV1_8Definition = extendConfigDefinition( + configV1_7Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z.partialRecord( + bucketTypeSchema, + bucketValueSchemaV1_8, + ).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.8, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.8, + }), + }, +); + +// v1.8 -> v1.9 +// Changes: Add "formatter" field to top-level config +export const configV1_9Definition = extendConfigDefinition( + configV1_8Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + formatter: Z.enum(["prettier", "biome"]) + .optional() + .describe( + "Code formatter to use for all buckets. Defaults to 'prettier' if not specified and a prettier config is found.", + ), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.9, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.9, + }), + }, +); + +// v1.9 -> v1.10 +// Changes: Add "settings" field to provider config for model-specific parameters +const modelSettingsSchema = Z.object({ + temperature: Z.number() + .min(0) + .max(2) + .optional() + .describe( + "Controls randomness in model outputs (0=deterministic, 2=very random). Some models like GPT-5 require temperature=1.", + ), +}) + .optional() + .describe("Model-specific settings for translation requests."); + +const providerSchemaV1_10 = Z.object({ + id: Z.enum([ + "openai", + "anthropic", + "google", + "ollama", + "openrouter", + "mistral", + ]).describe("Identifier of the translation provider service."), + model: Z.string().describe("Model name to use for translations."), + prompt: Z.string().describe( + "Prompt template used when requesting translations.", + ), + baseUrl: Z.string() + .optional() + .describe("Custom base URL for the provider API (optional)."), + settings: modelSettingsSchema, +}).describe("Configuration for the machine-translation provider."); + +export const configV1_10Definition = extendConfigDefinition( + configV1_9Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + provider: providerSchemaV1_10.optional(), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: "1.10", + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: "1.10", + }), + }, +); + +// v1.10 -> v1.11 +// Changes: Add "vNext" field for Lingo.dev vNext provider +export const configV1_11Definition = extendConfigDefinition( + configV1_10Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + vNext: Z.string() + .optional(), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: "1.11", + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: "1.11", + }), + }, +); + +// exports +export const LATEST_CONFIG_DEFINITION = configV1_11Definition; + +export type I18nConfig = Z.infer<(typeof LATEST_CONFIG_DEFINITION)["schema"]>; + +export function parseI18nConfig(rawConfig: unknown) { + try { + const result = LATEST_CONFIG_DEFINITION.parse(rawConfig); + return result; + } catch (error: any) { + throw new Error(`Failed to parse config: ${error.message}`); + } +} + +export const defaultConfig = LATEST_CONFIG_DEFINITION.defaultValue; diff --git a/packages/spec/src/formats.ts b/packages/spec/src/formats.ts new file mode 100644 index 000000000..96483ca67 --- /dev/null +++ b/packages/spec/src/formats.ts @@ -0,0 +1,41 @@ +import Z from "zod"; + +export const bucketTypes = [ + "ail", + "android", + "csv", + "ejs", + "flutter", + "html", + "json", + "json5", + "jsonc", + "markdown", + "markdoc", + "mdx", + "mjml", + "twig", + "xcode-strings", + "xcode-stringsdict", + "xcode-xcstrings", + "xcode-xcstrings-v2", + "yaml", + "yaml-root-key", + "properties", + "po", + "xliff", + "xml", + "srt", + "dato", + "compiler", + "vtt", + "php", + "po", + "vue-json", + "typescript", + "txt", + "json-dictionary", + "csv-per-locale", +] as const; + +export const bucketTypeSchema = Z.enum(bucketTypes); diff --git a/packages/spec/src/index.spec.ts b/packages/spec/src/index.spec.ts new file mode 100644 index 000000000..1b6af7788 --- /dev/null +++ b/packages/spec/src/index.spec.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from "vitest"; + +describe("Test suite", () => { + it("should pass", () => { + expect(1).toBe(1); + }); +}); diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts new file mode 100644 index 000000000..fb3e6fccb --- /dev/null +++ b/packages/spec/src/index.ts @@ -0,0 +1,3 @@ +export * from "./locales"; +export * from "./formats"; +export * from "./config"; diff --git a/packages/spec/src/json-schema.ts b/packages/spec/src/json-schema.ts new file mode 100644 index 000000000..f5c5e3923 --- /dev/null +++ b/packages/spec/src/json-schema.ts @@ -0,0 +1,14 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { toJSONSchema } from "zod"; +import { LATEST_CONFIG_DEFINITION } from "./config"; + +export default function buildJsonSchema() { + const configSchema = toJSONSchema(LATEST_CONFIG_DEFINITION.schema); + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + fs.writeFileSync( + `${currentDir}/../build/i18n.schema.json`, + JSON.stringify(configSchema, null, 2), + ); +} diff --git a/packages/spec/src/locales.spec.ts b/packages/spec/src/locales.spec.ts new file mode 100644 index 000000000..b3dd09abc --- /dev/null +++ b/packages/spec/src/locales.spec.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from "vitest"; +import { + getLocaleCodeDelimiter, + localeCodeSchema, + normalizeLocale, + resolveLocaleCode, + resolveOverriddenLocale, +} from "./locales"; + +describe("normalizeLocale", () => { + it("should return normalized locale for short locale codes", () => { + expect(normalizeLocale("en")).toEqual("en"); + expect(normalizeLocale("fr")).toEqual("fr"); + }); + + it("should return normalized locale for full locale codes", () => { + expect(normalizeLocale("en-US")).toEqual("en-US"); + expect(normalizeLocale("fr-FR")).toEqual("fr-FR"); + }); + + it("should return normalized locale for full underscore locale codes", () => { + expect(normalizeLocale("en_US")).toEqual("en-US"); + expect(normalizeLocale("fr_FR")).toEqual("fr-FR"); + expect(normalizeLocale("zh_Hans_CN")).toEqual("zh-Hans-CN"); + }); + + it("should return normalized locale for full explicit region locale codes", () => { + expect(normalizeLocale("en-rUS")).toEqual("en-US"); + expect(normalizeLocale("fr-rFR")).toEqual("fr-FR"); + expect(normalizeLocale("zh-rCN")).toEqual("zh-CN"); + }); +}); + +describe("resolveLocaleCode", () => { + it("should resolve a short locale code to the first full locale code in the map", () => { + expect(resolveLocaleCode("en")).toEqual("en-US"); + expect(resolveLocaleCode("fr")).toEqual("fr-FR"); + expect(resolveLocaleCode("az")).toEqual("az-AZ"); + }); + + it("should return the full locale code if it is already provided", () => { + expect(resolveLocaleCode("en-US")).toEqual("en-US"); + expect(resolveLocaleCode("fr-CA")).toEqual("fr-CA"); + expect(resolveLocaleCode("es-MX")).toEqual("es-MX"); + }); + + it("should throw an error for an invalid or unsupported locale code", () => { + expect(() => resolveLocaleCode("az-US")).toThrow("Invalid locale code"); + expect(() => resolveLocaleCode("au")).toThrow("Invalid locale code"); + }); + + it("should return first code for locales with multiple variants", () => { + expect(resolveLocaleCode("sr")).toEqual("sr-RS"); + expect(resolveLocaleCode("zh")).toEqual("zh-CN"); + }); +}); + +describe("getLocaleCodeDelimiter", () => { + it("should return '-' for locale codes with hyphen delimiter", () => { + expect(getLocaleCodeDelimiter("en-US")).toEqual("-"); + expect(getLocaleCodeDelimiter("fr-FR")).toEqual("-"); + }); + + it("should return '_' for locale codes with underscore delimiter", () => { + expect(getLocaleCodeDelimiter("en_US")).toEqual("_"); + expect(getLocaleCodeDelimiter("fr_FR")).toEqual("_"); + }); + + it("should return undefined for locale codes without a recognized delimiter", () => { + expect(getLocaleCodeDelimiter("enUS")).toBeNull(); + expect(getLocaleCodeDelimiter("frFR")).toBeNull(); + expect(getLocaleCodeDelimiter("kaGE")).toBeNull(); + }); +}); + +describe("resolveOverridenLocale", () => { + it("should return the same locale if no delimiter is provided", () => { + expect(resolveOverriddenLocale("en-US")).toEqual("en-US"); + expect(resolveOverriddenLocale("fr_FR")).toEqual("fr_FR"); + }); + + it("should replace the delimiter with the specified one", () => { + expect(resolveOverriddenLocale("en-US", "_")).toEqual("en_US"); + expect(resolveOverriddenLocale("fr_FR", "-")).toEqual("fr-FR"); + }); + + it("should return the same locale if no recognized delimiter is found", () => { + expect(resolveOverriddenLocale("enUS", "_")).toEqual("enUS"); + expect(resolveOverriddenLocale("frFR", "-")).toEqual("frFR"); + }); +}); + +describe("localeCodeSchema validation", () => { + describe("standard BCP 47 format (hyphen)", () => { + it("should accept language-region codes", () => { + expect(localeCodeSchema.safeParse("en-US").success).toBe(true); + expect(localeCodeSchema.safeParse("fr-CA").success).toBe(true); + expect(localeCodeSchema.safeParse("es-MX").success).toBe(true); + }); + + it("should accept language-script-region codes", () => { + expect(localeCodeSchema.safeParse("zh-Hans-CN").success).toBe(true); + expect(localeCodeSchema.safeParse("sr-Latn-RS").success).toBe(true); + expect(localeCodeSchema.safeParse("sr-Cyrl-RS").success).toBe(true); + }); + }); + + describe("underscore format (Java/Android)", () => { + it("should accept language_region codes", () => { + expect(localeCodeSchema.safeParse("en_US").success).toBe(true); + expect(localeCodeSchema.safeParse("pt_BR").success).toBe(true); + expect(localeCodeSchema.safeParse("de_DE").success).toBe(true); + }); + + it("should accept language_script_region codes", () => { + expect(localeCodeSchema.safeParse("zh_Hans_CN").success).toBe(true); + expect(localeCodeSchema.safeParse("sr_Cyrl_RS").success).toBe(true); + }); + }); + + describe("Android r-prefix format", () => { + it("should accept language-rRegion codes", () => { + expect(localeCodeSchema.safeParse("en-rUS").success).toBe(true); + expect(localeCodeSchema.safeParse("fr-rCA").success).toBe(true); + expect(localeCodeSchema.safeParse("es-rMX").success).toBe(true); + expect(localeCodeSchema.safeParse("zh-rCN").success).toBe(true); + }); + }); + + describe("invalid locale codes", () => { + it("should reject invalid language codes", () => { + expect(localeCodeSchema.safeParse("invalid-US").success).toBe(false); + expect(localeCodeSchema.safeParse("xx-US").success).toBe(false); + }); + + it("should reject invalid region codes", () => { + expect(localeCodeSchema.safeParse("en-FAKE").success).toBe(false); + expect(localeCodeSchema.safeParse("en-ZZ").success).toBe(false); + }); + + it("should reject invalid script codes", () => { + expect(localeCodeSchema.safeParse("zh-Fake-CN").success).toBe(false); + }); + }); +}); diff --git a/packages/spec/src/locales.ts b/packages/spec/src/locales.ts new file mode 100644 index 000000000..dac57f1b5 --- /dev/null +++ b/packages/spec/src/locales.ts @@ -0,0 +1,340 @@ +import Z from "zod"; +import { isValidLocale } from "@lingo.dev/_locales"; + +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) => { + // Normalize locale before validation + const normalized = normalizeLocale(value); + return isValidLocale(normalized); + }, + { + 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"); +} diff --git a/packages/spec/tsconfig.json b/packages/spec/tsconfig.json new file mode 100644 index 000000000..3afcad194 --- /dev/null +++ b/packages/spec/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "allowUnreachableCode": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"] +} diff --git a/packages/spec/tsconfig.test.json b/packages/spec/tsconfig.test.json new file mode 100644 index 000000000..a64e65041 --- /dev/null +++ b/packages/spec/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "src/**/*.tsx"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/spec/tsup.config.bundled_rhh6rcllfv.mjs b/packages/spec/tsup.config.bundled_rhh6rcllfv.mjs new file mode 100644 index 000000000..bfe190232 --- /dev/null +++ b/packages/spec/tsup.config.bundled_rhh6rcllfv.mjs @@ -0,0 +1,763 @@ +// tsup.config.ts +import { defineConfig } from "tsup"; + +// src/json-schema.ts +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { zodToJsonSchema } from "zod-to-json-schema"; + +// src/config.ts +import Z3 from "zod"; + +// src/locales.ts +import Z from "zod"; +import { isValidLocale } from "@lingo.dev/_locales"; +var 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"], +}; +var localeCodesShort = Object.keys(localeMap); +var localeCodesFull = Object.values(localeMap).flat(); +var localeCodesFullUnderscore = localeCodesFull.map((value) => + value.replace("-", "_"), +); +var localeCodesFullExplicitRegion = localeCodesFull.map((value) => { + const chunks = value.split("-"); + const result = [chunks[0], "-r", chunks.slice(1).join("-")].join(""); + return result; +}); +var localeCodes = [ + ...localeCodesShort, + ...localeCodesFull, + ...localeCodesFullUnderscore, + ...localeCodesFullExplicitRegion, +]; +var localeCodeSchema = Z.string().refine( + (value) => { + const normalized = normalizeLocale(value); + return isValidLocale(normalized); + }, + { + message: "Invalid locale code", + }, +); +function normalizeLocale(locale) { + return locale.replaceAll("_", "-").replace(/([a-z]{2,3}-)r/, "$1"); +} + +// src/formats.ts +import Z2 from "zod"; +var bucketTypes = [ + "android", + "csv", + "ejs", + "flutter", + "html", + "json", + "json5", + "jsonc", + "markdown", + "markdoc", + "mdx", + "xcode-strings", + "xcode-stringsdict", + "xcode-xcstrings", + "xcode-xcstrings-v2", + "yaml", + "yaml-root-key", + "properties", + "po", + "xliff", + "xml", + "srt", + "dato", + "compiler", + "vtt", + "php", + "po", + "vue-json", + "typescript", + "txt", + "json-dictionary", +]; +var bucketTypeSchema = Z2.enum(bucketTypes); + +// src/config.ts +var localeSchema = Z3.object({ + source: localeCodeSchema.describe( + "Primary source locale code of your content (e.g. 'en', 'en-US', 'pt_BR', or 'pt-rBR'). Must be one of the supported locale codes \u2013 either a short ISO-639 language code or a full locale identifier using '-', '_' or Android '-r' notation.", + ), + targets: Z3.array(localeCodeSchema).describe( + "List of target locale codes to translate to.", + ), +}).describe("Locale configuration block."); +var createConfigDefinition = (definition) => definition; +var extendConfigDefinition = (definition, params) => { + const schema = params.createSchema(definition.schema); + const defaultValue = params.createDefaultValue(definition.defaultValue); + const upgrader = (config) => + params.createUpgrader(config, schema, defaultValue); + return createConfigDefinition({ + schema, + defaultValue, + parse: (rawConfig) => { + const safeResult = schema.safeParse(rawConfig); + if (safeResult.success) { + return safeResult.data; + } + const localeErrors = safeResult.error.errors + .filter((issue) => issue.message.includes("Invalid locale code")) + .map((issue) => { + let unsupportedLocale = ""; + const path2 = issue.path; + const config = rawConfig; + if (config.locale) { + unsupportedLocale = path2.reduce((acc, key) => { + if (acc && typeof acc === "object" && key in acc) { + return acc[key]; + } + return acc; + }, config.locale); + } + return `Unsupported locale: ${unsupportedLocale}`; + }); + if (localeErrors.length > 0) { + throw new Error(` +${localeErrors.join("\n")}`); + } + const baseConfig = definition.parse(rawConfig); + const result = upgrader(baseConfig); + return result; + }, + }); +}; +var configV0Schema = Z3.object({ + version: Z3.union([Z3.number(), Z3.string()]) + .default(0) + .describe("The version number of the schema."), +}); +var configV0Definition = createConfigDefinition({ + schema: configV0Schema, + defaultValue: { version: 0 }, + parse: (rawConfig) => { + return configV0Schema.parse(rawConfig); + }, +}); +var configV1Definition = extendConfigDefinition(configV0Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + locale: localeSchema, + buckets: Z3.record(Z3.string(), bucketTypeSchema) + .default({}) + .describe( + "Mapping of source file paths (glob patterns) to bucket types.", + ) + .optional(), + }), + createDefaultValue: () => ({ + version: 1, + locale: { + source: "en", + targets: ["es"], + }, + buckets: {}, + }), + createUpgrader: () => ({ + version: 1, + locale: { + source: "en", + targets: ["es"], + }, + buckets: {}, + }), +}); +var configV1_1Definition = extendConfigDefinition(configV1Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z3.record( + bucketTypeSchema, + Z3.object({ + include: Z3.array(Z3.string()) + .default([]) + .describe( + "File paths or glob patterns to include for this bucket.", + ), + exclude: Z3.array(Z3.string()) + .default([]) + .optional() + .describe( + "File paths or glob patterns to exclude from this bucket.", + ), + }), + ).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.1, + buckets: {}, + }), + createUpgrader: (oldConfig, schema) => { + const upgradedConfig = { + ...oldConfig, + version: 1.1, + buckets: {}, + }; + if (oldConfig.buckets) { + for (const [bucketPath, bucketType] of Object.entries( + oldConfig.buckets, + )) { + if (!upgradedConfig.buckets[bucketType]) { + upgradedConfig.buckets[bucketType] = { + include: [], + }; + } + upgradedConfig.buckets[bucketType]?.include.push(bucketPath); + } + } + return upgradedConfig; + }, +}); +var configV1_2Definition = extendConfigDefinition(configV1_1Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + locale: localeSchema.extend({ + extraSource: localeCodeSchema + .optional() + .describe( + "Optional extra source locale code used as fallback during translation.", + ), + }), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.2, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.2, + }), +}); +var bucketItemSchema = Z3.object({ + path: Z3.string().describe("Path pattern containing a [locale] placeholder."), + delimiter: Z3.union([Z3.literal("-"), Z3.literal("_"), Z3.literal(null)]) + .optional() + .describe( + "Delimiter that replaces the [locale] placeholder in the path (default: no delimiter).", + ), +}).describe( + "Bucket path item. Either a string path or an object specifying path and delimiter.", +); +var bucketValueSchemaV1_3 = Z3.object({ + include: Z3.array(Z3.union([Z3.string(), bucketItemSchema])) + .default([]) + .describe("Glob patterns or bucket items to include for this bucket."), + exclude: Z3.array(Z3.union([Z3.string(), bucketItemSchema])) + .default([]) + .optional() + .describe("Glob patterns or bucket items to exclude from this bucket."), + injectLocale: Z3.array(Z3.string()) + .optional() + .describe( + "Keys within files where the current locale should be injected or removed.", + ), +}).describe("Configuration options for a translation bucket."); +var configV1_3Definition = extendConfigDefinition(configV1_2Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z3.record(bucketTypeSchema, bucketValueSchemaV1_3).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.3, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.3, + }), +}); +var configSchema = "https://lingo.dev/schema/i18n.json"; +var configV1_4Definition = extendConfigDefinition(configV1_3Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + $schema: Z3.string().default(configSchema), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.4, + $schema: configSchema, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.4, + $schema: configSchema, + }), +}); +var providerSchema = Z3.object({ + id: Z3.enum([ + "openai", + "anthropic", + "google", + "ollama", + "openrouter", + "mistral", + ]).describe("Identifier of the translation provider service."), + model: Z3.string().describe("Model name to use for translations."), + prompt: Z3.string().describe( + "Prompt template used when requesting translations.", + ), + baseUrl: Z3.string() + .optional() + .describe("Custom base URL for the provider API (optional)."), +}).describe("Configuration for the machine-translation provider."); +var configV1_5Definition = extendConfigDefinition(configV1_4Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + provider: providerSchema.optional(), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.5, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.5, + }), +}); +var bucketValueSchemaV1_6 = bucketValueSchemaV1_3.extend({ + lockedKeys: Z3.array(Z3.string()) + .default([]) + .optional() + .describe( + "Keys that must remain unchanged and should never be overwritten by translations.", + ), +}); +var configV1_6Definition = extendConfigDefinition(configV1_5Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z3.record(bucketTypeSchema, bucketValueSchemaV1_6).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.6, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.6, + }), +}); +var bucketValueSchemaV1_7 = bucketValueSchemaV1_6.extend({ + lockedPatterns: Z3.array(Z3.string()) + .default([]) + .optional() + .describe( + "Regular expression patterns whose matched content should remain locked during translation.", + ), +}); +var configV1_7Definition = extendConfigDefinition(configV1_6Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z3.record(bucketTypeSchema, bucketValueSchemaV1_7).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.7, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.7, + }), +}); +var bucketValueSchemaV1_8 = bucketValueSchemaV1_7.extend({ + ignoredKeys: Z3.array(Z3.string()) + .default([]) + .optional() + .describe( + "Keys that should be completely ignored by translation processes.", + ), +}); +var configV1_8Definition = extendConfigDefinition(configV1_7Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z3.record(bucketTypeSchema, bucketValueSchemaV1_8).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.8, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.8, + }), +}); +var configV1_9Definition = extendConfigDefinition(configV1_8Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + formatter: Z3.enum(["prettier", "biome"]) + .optional() + .describe( + "Code formatter to use for all buckets. Defaults to 'prettier' if not specified and a prettier config is found.", + ), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.9, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.9, + }), +}); +var modelSettingsSchema = Z3.object({ + temperature: Z3.number() + .min(0) + .max(2) + .optional() + .describe( + "Controls randomness in model outputs (0=deterministic, 2=very random). Some models like GPT-5 require temperature=1.", + ), +}) + .optional() + .describe("Model-specific settings for translation requests."); +var providerSchemaV1_10 = Z3.object({ + id: Z3.enum([ + "openai", + "anthropic", + "google", + "ollama", + "openrouter", + "mistral", + ]).describe("Identifier of the translation provider service."), + model: Z3.string().describe("Model name to use for translations."), + prompt: Z3.string().describe( + "Prompt template used when requesting translations.", + ), + baseUrl: Z3.string() + .optional() + .describe("Custom base URL for the provider API (optional)."), + settings: modelSettingsSchema, +}).describe("Configuration for the machine-translation provider."); +var configV1_10Definition = extendConfigDefinition(configV1_9Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + provider: providerSchemaV1_10.optional(), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: "1.10", + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: "1.10", + }), +}); +var LATEST_CONFIG_DEFINITION = configV1_10Definition; +var defaultConfig = LATEST_CONFIG_DEFINITION.defaultValue; + +// src/json-schema.ts +var __injected_import_meta_url__ = + "file:///C:/Users/TUF%20GAMING/OneDrive/Desktop/lingo.dev%20opensource/lingo.dev/packages/spec/src/json-schema.ts"; +function buildJsonSchema() { + const configSchema2 = zodToJsonSchema(LATEST_CONFIG_DEFINITION.schema); + const currentDir = path.dirname(fileURLToPath(__injected_import_meta_url__)); + fs.writeFileSync( + `${currentDir}/../build/i18n.schema.json`, + JSON.stringify(configSchema2, null, 2), + ); +} + +// tsup.config.ts +var tsup_config_default = defineConfig({ + clean: true, + target: "esnext", + entry: ["src/index.ts"], + outDir: "build", + format: ["cjs", "esm"], + dts: true, + cjsInterop: true, + splitting: true, + outExtension: (ctx) => ({ + js: ctx.format === "cjs" ? ".cjs" : ".mjs", + }), + onSuccess: async () => { + buildJsonSchema(); + }, +}); +export { tsup_config_default as default }; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidHN1cC5jb25maWcudHMiLCAic3JjL2pzb24tc2NoZW1hLnRzIiwgInNyYy9jb25maWcudHMiLCAic3JjL2xvY2FsZXMudHMiLCAic3JjL2Zvcm1hdHMudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9faW5qZWN0ZWRfZmlsZW5hbWVfXyA9IFwiQzpcXFxcVXNlcnNcXFxcVFVGIEdBTUlOR1xcXFxPbmVEcml2ZVxcXFxEZXNrdG9wXFxcXGxpbmdvLmRldiBvcGVuc291cmNlXFxcXGxpbmdvLmRldlxcXFxwYWNrYWdlc1xcXFxzcGVjXFxcXHRzdXAuY29uZmlnLnRzXCI7Y29uc3QgX19pbmplY3RlZF9kaXJuYW1lX18gPSBcIkM6XFxcXFVzZXJzXFxcXFRVRiBHQU1JTkdcXFxcT25lRHJpdmVcXFxcRGVza3RvcFxcXFxsaW5nby5kZXYgb3BlbnNvdXJjZVxcXFxsaW5nby5kZXZcXFxccGFja2FnZXNcXFxcc3BlY1wiO2NvbnN0IF9faW5qZWN0ZWRfaW1wb3J0X21ldGFfdXJsX18gPSBcImZpbGU6Ly8vQzovVXNlcnMvVFVGJTIwR0FNSU5HL09uZURyaXZlL0Rlc2t0b3AvbGluZ28uZGV2JTIwb3BlbnNvdXJjZS9saW5nby5kZXYvcGFja2FnZXMvc3BlYy90c3VwLmNvbmZpZy50c1wiO2ltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gXCJ0c3VwXCI7XHJcbmltcG9ydCBidWlsZEpzb25TY2hlbWEgZnJvbSBcIi4vc3JjL2pzb24tc2NoZW1hXCI7XHJcblxyXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xyXG4gIGNsZWFuOiB0cnVlLFxyXG4gIHRhcmdldDogXCJlc25leHRcIixcclxuICBlbnRyeTogW1wic3JjL2luZGV4LnRzXCJdLFxyXG4gIG91dERpcjogXCJidWlsZFwiLFxyXG4gIGZvcm1hdDogW1wiY2pzXCIsIFwiZXNtXCJdLFxyXG4gIGR0czogdHJ1ZSxcclxuICBjanNJbnRlcm9wOiB0cnVlLFxyXG4gIHNwbGl0dGluZzogdHJ1ZSxcclxuICBvdXRFeHRlbnNpb246IChjdHgpID0+ICh7XHJcbiAgICBqczogY3R4LmZvcm1hdCA9PT0gXCJjanNcIiA/IFwiLmNqc1wiIDogXCIubWpzXCIsXHJcbiAgfSksXHJcbiAgb25TdWNjZXNzOiBhc3luYyAoKSA9PiB7XHJcbiAgICBidWlsZEpzb25TY2hlbWEoKTtcclxuICB9LFxyXG59KTtcclxuIiwgImNvbnN0IF9faW5qZWN0ZWRfZmlsZW5hbWVfXyA9IFwiQzpcXFxcVXNlcnNcXFxcVFVGIEdBTUlOR1xcXFxPbmVEcml2ZVxcXFxEZXNrdG9wXFxcXGxpbmdvLmRldiBvcGVuc291cmNlXFxcXGxpbmdvLmRldlxcXFxwYWNrYWdlc1xcXFxzcGVjXFxcXHNyY1xcXFxqc29uLXNjaGVtYS50c1wiO2NvbnN0IF9faW5qZWN0ZWRfZGlybmFtZV9fID0gXCJDOlxcXFxVc2Vyc1xcXFxUVUYgR0FNSU5HXFxcXE9uZURyaXZlXFxcXERlc2t0b3BcXFxcbGluZ28uZGV2IG9wZW5zb3VyY2VcXFxcbGluZ28uZGV2XFxcXHBhY2thZ2VzXFxcXHNwZWNcXFxcc3JjXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9DOi9Vc2Vycy9UVUYlMjBHQU1JTkcvT25lRHJpdmUvRGVza3RvcC9saW5nby5kZXYlMjBvcGVuc291cmNlL2xpbmdvLmRldi9wYWNrYWdlcy9zcGVjL3NyYy9qc29uLXNjaGVtYS50c1wiO2ltcG9ydCBmcyBmcm9tIFwiZnNcIjtcclxuaW1wb3J0IHBhdGggZnJvbSBcInBhdGhcIjtcclxuaW1wb3J0IHsgZmlsZVVSTFRvUGF0aCB9IGZyb20gXCJ1cmxcIjtcclxuaW1wb3J0IHsgem9kVG9Kc29uU2NoZW1hIH0gZnJvbSBcInpvZC10by1qc29uLXNjaGVtYVwiO1xyXG5pbXBvcnQgeyBMQVRFU1RfQ09ORklHX0RFRklOSVRJT04gfSBmcm9tIFwiLi9jb25maWdcIjtcclxuXHJcbmV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIGJ1aWxkSnNvblNjaGVtYSgpIHtcclxuICBjb25zdCBjb25maWdTY2hlbWEgPSB6b2RUb0pzb25TY2hlbWEoTEFURVNUX0NPTkZJR19ERUZJTklUSU9OLnNjaGVtYSk7XHJcbiAgY29uc3QgY3VycmVudERpciA9IHBhdGguZGlybmFtZShmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCkpO1xyXG4gIGZzLndyaXRlRmlsZVN5bmMoXHJcbiAgICBgJHtjdXJyZW50RGlyfS8uLi9idWlsZC9pMThuLnNjaGVtYS5qc29uYCxcclxuICAgIEpTT04uc3RyaW5naWZ5KGNvbmZpZ1NjaGVtYSwgbnVsbCwgMiksXHJcbiAgKTtcclxufVxyXG4iLCAiY29uc3QgX19pbmplY3RlZF9maWxlbmFtZV9fID0gXCJDOlxcXFxVc2Vyc1xcXFxUVUYgR0FNSU5HXFxcXE9uZURyaXZlXFxcXERlc2t0b3BcXFxcbGluZ28uZGV2IG9wZW5zb3VyY2VcXFxcbGluZ28uZGV2XFxcXHBhY2thZ2VzXFxcXHNwZWNcXFxcc3JjXFxcXGNvbmZpZy50c1wiO2NvbnN0IF9faW5qZWN0ZWRfZGlybmFtZV9fID0gXCJDOlxcXFxVc2Vyc1xcXFxUVUYgR0FNSU5HXFxcXE9uZURyaXZlXFxcXERlc2t0b3BcXFxcbGluZ28uZGV2IG9wZW5zb3VyY2VcXFxcbGluZ28uZGV2XFxcXHBhY2thZ2VzXFxcXHNwZWNcXFxcc3JjXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9DOi9Vc2Vycy9UVUYlMjBHQU1JTkcvT25lRHJpdmUvRGVza3RvcC9saW5nby5kZXYlMjBvcGVuc291cmNlL2xpbmdvLmRldi9wYWNrYWdlcy9zcGVjL3NyYy9jb25maWcudHNcIjtpbXBvcnQgWiBmcm9tIFwiem9kXCI7XHJcbmltcG9ydCB7IGxvY2FsZUNvZGVTY2hlbWEgfSBmcm9tIFwiLi9sb2NhbGVzXCI7XHJcbmltcG9ydCB7IGJ1Y2tldFR5cGVTY2hlbWEgfSBmcm9tIFwiLi9mb3JtYXRzXCI7XHJcblxyXG4vLyBjb21tb25cclxuZXhwb3J0IGNvbnN0IGxvY2FsZVNjaGVtYSA9IFoub2JqZWN0KHtcclxuICBzb3VyY2U6IGxvY2FsZUNvZGVTY2hlbWEuZGVzY3JpYmUoXHJcbiAgICBcIlByaW1hcnkgc291cmNlIGxvY2FsZSBjb2RlIG9mIHlvdXIgY29udGVudCAoZS5nLiAnZW4nLCAnZW4tVVMnLCAncHRfQlInLCBvciAncHQtckJSJykuIE11c3QgYmUgb25lIG9mIHRoZSBzdXBwb3J0ZWQgbG9jYWxlIGNvZGVzIFx1MjAxMyBlaXRoZXIgYSBzaG9ydCBJU08tNjM5IGxhbmd1YWdlIGNvZGUgb3IgYSBmdWxsIGxvY2FsZSBpZGVudGlmaWVyIHVzaW5nICctJywgJ18nIG9yIEFuZHJvaWQgJy1yJyBub3RhdGlvbi5cIixcclxuICApLFxyXG4gIHRhcmdldHM6IFouYXJyYXkobG9jYWxlQ29kZVNjaGVtYSkuZGVzY3JpYmUoXHJcbiAgICBcIkxpc3Qgb2YgdGFyZ2V0IGxvY2FsZSBjb2RlcyB0byB0cmFuc2xhdGUgdG8uXCIsXHJcbiAgKSxcclxufSkuZGVzY3JpYmUoXCJMb2NhbGUgY29uZmlndXJhdGlvbiBibG9jay5cIik7XHJcblxyXG4vLyBmYWN0b3JpZXNcclxudHlwZSBDb25maWdEZWZpbml0aW9uPFxyXG4gIFQgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4gIF9QIGV4dGVuZHMgWi5ab2RSYXdTaGFwZSA9IGFueSxcclxuPiA9IHtcclxuICBzY2hlbWE6IFouWm9kT2JqZWN0PFQ+O1xyXG4gIGRlZmF1bHRWYWx1ZTogWi5pbmZlcjxaLlpvZE9iamVjdDxUPj47XHJcbiAgcGFyc2U6IChyYXdDb25maWc6IHVua25vd24pID0+IFouaW5mZXI8Wi5ab2RPYmplY3Q8VD4+O1xyXG59O1xyXG5jb25zdCBjcmVhdGVDb25maWdEZWZpbml0aW9uID0gPFxyXG4gIFQgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4gIF9QIGV4dGVuZHMgWi5ab2RSYXdTaGFwZSA9IGFueSxcclxuPihcclxuICBkZWZpbml0aW9uOiBDb25maWdEZWZpbml0aW9uPFQsIF9QPixcclxuKSA9PiBkZWZpbml0aW9uO1xyXG5cclxudHlwZSBDb25maWdEZWZpbml0aW9uRXh0ZW5zaW9uUGFyYW1zPFxyXG4gIFQgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4gIFAgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4+ID0ge1xyXG4gIGNyZWF0ZVNjaGVtYTogKGJhc2VTY2hlbWE6IFouWm9kT2JqZWN0PFA+KSA9PiBaLlpvZE9iamVjdDxUPjtcclxuICBjcmVhdGVEZWZhdWx0VmFsdWU6IChcclxuICAgIGJhc2VEZWZhdWx0VmFsdWU6IFouaW5mZXI8Wi5ab2RPYmplY3Q8UD4+LFxyXG4gICkgPT4gWi5pbmZlcjxaLlpvZE9iamVjdDxUPj47XHJcbiAgY3JlYXRlVXBncmFkZXI6IChcclxuICAgIGNvbmZpZzogWi5pbmZlcjxaLlpvZE9iamVjdDxQPj4sXHJcbiAgICBzY2hlbWE6IFouWm9kT2JqZWN0PFQ+LFxyXG4gICAgZGVmYXVsdFZhbHVlOiBaLmluZmVyPFouWm9kT2JqZWN0PFQ+PixcclxuICApID0+IFouaW5mZXI8Wi5ab2RPYmplY3Q8VD4+O1xyXG59O1xyXG5jb25zdCBleHRlbmRDb25maWdEZWZpbml0aW9uID0gPFxyXG4gIFQgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4gIFAgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4+KFxyXG4gIGRlZmluaXRpb246IENvbmZpZ0RlZmluaXRpb248UCwgYW55PixcclxuICBwYXJhbXM6IENvbmZpZ0RlZmluaXRpb25FeHRlbnNpb25QYXJhbXM8VCwgUD4sXHJcbikgPT4ge1xyXG4gIGNvbnN0IHNjaGVtYSA9IHBhcmFtcy5jcmVhdGVTY2hlbWEoZGVmaW5pdGlvbi5zY2hlbWEpO1xyXG4gIGNvbnN0IGRlZmF1bHRWYWx1ZSA9IHBhcmFtcy5jcmVhdGVEZWZhdWx0VmFsdWUoZGVmaW5pdGlvbi5kZWZhdWx0VmFsdWUpO1xyXG4gIGNvbnN0IHVwZ3JhZGVyID0gKGNvbmZpZzogWi5pbmZlcjxaLlpvZE9iamVjdDxQPj4pID0+XHJcbiAgICBwYXJhbXMuY3JlYXRlVXBncmFkZXIoY29uZmlnLCBzY2hlbWEsIGRlZmF1bHRWYWx1ZSk7XHJcblxyXG4gIHJldHVybiBjcmVhdGVDb25maWdEZWZpbml0aW9uKHtcclxuICAgIHNjaGVtYSxcclxuICAgIGRlZmF1bHRWYWx1ZSxcclxuICAgIHBhcnNlOiAocmF3Q29uZmlnKSA9PiB7XHJcbiAgICAgIGNvbnN0IHNhZmVSZXN1bHQgPSBzY2hlbWEuc2FmZVBhcnNlKHJhd0NvbmZpZyk7XHJcbiAgICAgIGlmIChzYWZlUmVzdWx0LnN1Y2Nlc3MpIHtcclxuICAgICAgICByZXR1cm4gc2FmZVJlc3VsdC5kYXRhO1xyXG4gICAgICB9XHJcblxyXG4gICAgICBjb25zdCBsb2NhbGVFcnJvcnMgPSBzYWZlUmVzdWx0LmVycm9yLmVycm9yc1xyXG4gICAgICAgIC5maWx0ZXIoKGlzc3VlKSA9PiBpc3N1ZS5tZXNzYWdlLmluY2x1ZGVzKFwiSW52YWxpZCBsb2NhbGUgY29kZVwiKSlcclxuICAgICAgICAubWFwKChpc3N1ZSkgPT4ge1xyXG4gICAgICAgICAgbGV0IHVuc3VwcG9ydGVkTG9jYWxlID0gXCJcIjtcclxuICAgICAgICAgIGNvbnN0IHBhdGggPSBpc3N1ZS5wYXRoO1xyXG5cclxuICAgICAgICAgIGNvbnN0IGNvbmZpZyA9IHJhd0NvbmZpZyBhcyB7IGxvY2FsZT86IHsgW2tleTogc3RyaW5nXTogYW55IH0gfTtcclxuXHJcbiAgICAgICAgICBpZiAoY29uZmlnLmxvY2FsZSkge1xyXG4gICAgICAgICAgICB1bnN1cHBvcnRlZExvY2FsZSA9IHBhdGgucmVkdWNlPGFueT4oKGFjYywga2V5KSA9PiB7XHJcbiAgICAgICAgICAgICAgaWYgKGFjYyAmJiB0eXBlb2YgYWNjID09PSBcIm9iamVjdFwiICYmIGtleSBpbiBhY2MpIHtcclxuICAgICAgICAgICAgICAgIHJldHVybiBhY2Nba2V5XTtcclxuICAgICAgICAgICAgICB9XHJcbiAgICAgICAgICAgICAgcmV0dXJuIGFjYztcclxuICAgICAgICAgICAgfSwgY29uZmlnLmxvY2FsZSk7XHJcbiAgICAgICAgICB9XHJcblxyXG4gICAgICAgICAgcmV0dXJuIGBVbnN1cHBvcnRlZCBsb2NhbGU6ICR7dW5zdXBwb3J0ZWRMb2NhbGV9YDtcclxuICAgICAgICB9KTtcclxuXHJcbiAgICAgIGlmIChsb2NhbGVFcnJvcnMubGVuZ3RoID4gMCkge1xyXG4gICAgICAgIHRocm93IG5ldyBFcnJvcihgXFxuJHtsb2NhbGVFcnJvcnMuam9pbihcIlxcblwiKX1gKTtcclxuICAgICAgfVxyXG5cclxuICAgICAgY29uc3QgYmFzZUNvbmZpZyA9IGRlZmluaXRpb24ucGFyc2UocmF3Q29uZmlnKTtcclxuICAgICAgY29uc3QgcmVzdWx0ID0gdXBncmFkZXIoYmFzZUNvbmZpZyk7XHJcbiAgICAgIHJldHVybiByZXN1bHQ7XHJcbiAgICB9LFxyXG4gIH0pO1xyXG59O1xyXG5cclxuLy8gYW55IC0+IHYwXHJcbmNvbnN0IGNvbmZpZ1YwU2NoZW1hID0gWi5vYmplY3Qoe1xyXG4gIHZlcnNpb246IFoudW5pb24oW1oubnVtYmVyKCksIFouc3RyaW5nKCldKVxyXG4gICAgLmRlZmF1bHQoMClcclxuICAgIC5kZXNjcmliZShcIlRoZSB2ZXJzaW9uIG51bWJlciBvZiB0aGUgc2NoZW1hLlwiKSxcclxufSk7XHJcbmV4cG9ydCBjb25zdCBjb25maWdWMERlZmluaXRpb24gPSBjcmVhdGVDb25maWdEZWZpbml0aW9uKHtcclxuICBzY2hlbWE6IGNvbmZpZ1YwU2NoZW1hLFxyXG4gIGRlZmF1bHRWYWx1ZTogeyB2ZXJzaW9uOiAwIH0sXHJcbiAgcGFyc2U6IChyYXdDb25maWcpID0+IHtcclxuICAgIHJldHVybiBjb25maWdWMFNjaGVtYS5wYXJzZShyYXdDb25maWcpO1xyXG4gIH0sXHJcbn0pO1xyXG5cclxuLy8gdjAgLT4gdjFcclxuZXhwb3J0IGNvbnN0IGNvbmZpZ1YxRGVmaW5pdGlvbiA9IGV4dGVuZENvbmZpZ0RlZmluaXRpb24oY29uZmlnVjBEZWZpbml0aW9uLCB7XHJcbiAgY3JlYXRlU2NoZW1hOiAoYmFzZVNjaGVtYSkgPT5cclxuICAgIGJhc2VTY2hlbWEuZXh0ZW5kKHtcclxuICAgICAgbG9jYWxlOiBsb2NhbGVTY2hlbWEsXHJcbiAgICAgIGJ1Y2tldHM6IFoucmVjb3JkKFouc3RyaW5nKCksIGJ1Y2tldFR5cGVTY2hlbWEpXHJcbiAgICAgICAgLmRlZmF1bHQoe30pXHJcbiAgICAgICAgLmRlc2NyaWJlKFxyXG4gICAgICAgICAgXCJNYXBwaW5nIG9mIHNvdXJjZSBmaWxlIHBhdGhzIChnbG9iIHBhdHRlcm5zKSB0byBidWNrZXQgdHlwZXMuXCIsXHJcbiAgICAgICAgKVxyXG4gICAgICAgIC5vcHRpb25hbCgpLFxyXG4gICAgfSksXHJcbiAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoKSA9PiAoe1xyXG4gICAgdmVyc2lvbjogMSxcclxuICAgIGxvY2FsZToge1xyXG4gICAgICBzb3VyY2U6IFwiZW5cIiBhcyBjb25zdCxcclxuICAgICAgdGFyZ2V0czogW1wiZXNcIiBhcyBjb25zdF0sXHJcbiAgICB9LFxyXG4gICAgYnVja2V0czoge30sXHJcbiAgfSksXHJcbiAgY3JlYXRlVXBncmFkZXI6ICgpID0+ICh7XHJcbiAgICB2ZXJzaW9uOiAxLFxyXG4gICAgbG9jYWxlOiB7XHJcbiAgICAgIHNvdXJjZTogXCJlblwiIGFzIGNvbnN0LFxyXG4gICAgICB0YXJnZXRzOiBbXCJlc1wiIGFzIGNvbnN0XSxcclxuICAgIH0sXHJcbiAgICBidWNrZXRzOiB7fSxcclxuICB9KSxcclxufSk7XHJcblxyXG4vLyB2MSAtPiB2MS4xXHJcbmV4cG9ydCBjb25zdCBjb25maWdWMV8xRGVmaW5pdGlvbiA9IGV4dGVuZENvbmZpZ0RlZmluaXRpb24oY29uZmlnVjFEZWZpbml0aW9uLCB7XHJcbiAgY3JlYXRlU2NoZW1hOiAoYmFzZVNjaGVtYSkgPT5cclxuICAgIGJhc2VTY2hlbWEuZXh0ZW5kKHtcclxuICAgICAgYnVja2V0czogWi5yZWNvcmQoXHJcbiAgICAgICAgYnVja2V0VHlwZVNjaGVtYSxcclxuICAgICAgICBaLm9iamVjdCh7XHJcbiAgICAgICAgICBpbmNsdWRlOiBaLmFycmF5KFouc3RyaW5nKCkpXHJcbiAgICAgICAgICAgIC5kZWZhdWx0KFtdKVxyXG4gICAgICAgICAgICAuZGVzY3JpYmUoXHJcbiAgICAgICAgICAgICAgXCJGaWxlIHBhdGhzIG9yIGdsb2IgcGF0dGVybnMgdG8gaW5jbHVkZSBmb3IgdGhpcyBidWNrZXQuXCIsXHJcbiAgICAgICAgICAgICksXHJcbiAgICAgICAgICBleGNsdWRlOiBaLmFycmF5KFouc3RyaW5nKCkpXHJcbiAgICAgICAgICAgIC5kZWZhdWx0KFtdKVxyXG4gICAgICAgICAgICAub3B0aW9uYWwoKVxyXG4gICAgICAgICAgICAuZGVzY3JpYmUoXHJcbiAgICAgICAgICAgICAgXCJGaWxlIHBhdGhzIG9yIGdsb2IgcGF0dGVybnMgdG8gZXhjbHVkZSBmcm9tIHRoaXMgYnVja2V0LlwiLFxyXG4gICAgICAgICAgICApLFxyXG4gICAgICAgIH0pLFxyXG4gICAgICApLmRlZmF1bHQoe30pLFxyXG4gICAgfSksXHJcbiAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgIC4uLmJhc2VEZWZhdWx0VmFsdWUsXHJcbiAgICB2ZXJzaW9uOiAxLjEsXHJcbiAgICBidWNrZXRzOiB7fSxcclxuICB9KSxcclxuICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZywgc2NoZW1hKSA9PiB7XHJcbiAgICBjb25zdCB1cGdyYWRlZENvbmZpZzogWi5pbmZlcjx0eXBlb2Ygc2NoZW1hPiA9IHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjEsXHJcbiAgICAgIGJ1Y2tldHM6IHt9LFxyXG4gICAgfTtcclxuXHJcbiAgICAvLyBUcmFuc2Zvcm0gYnVja2V0cyBmcm9tIHYxIHRvIHYxLjEgZm9ybWF0XHJcbiAgICBpZiAob2xkQ29uZmlnLmJ1Y2tldHMpIHtcclxuICAgICAgZm9yIChjb25zdCBbYnVja2V0UGF0aCwgYnVja2V0VHlwZV0gb2YgT2JqZWN0LmVudHJpZXMoXHJcbiAgICAgICAgb2xkQ29uZmlnLmJ1Y2tldHMsXHJcbiAgICAgICkpIHtcclxuICAgICAgICBpZiAoIXVwZ3JhZGVkQ29uZmlnLmJ1Y2tldHNbYnVja2V0VHlwZV0pIHtcclxuICAgICAgICAgIHVwZ3JhZGVkQ29uZmlnLmJ1Y2tldHNbYnVja2V0VHlwZV0gPSB7XHJcbiAgICAgICAgICAgIGluY2x1ZGU6IFtdLFxyXG4gICAgICAgICAgfTtcclxuICAgICAgICB9XHJcbiAgICAgICAgdXBncmFkZWRDb25maWcuYnVja2V0c1tidWNrZXRUeXBlXT8uaW5jbHVkZS5wdXNoKGJ1Y2tldFBhdGgpO1xyXG4gICAgICB9XHJcbiAgICB9XHJcblxyXG4gICAgcmV0dXJuIHVwZ3JhZGVkQ29uZmlnO1xyXG4gIH0sXHJcbn0pO1xyXG5cclxuLy8gdjEuMSAtPiB2MS4yXHJcbi8vIENoYW5nZXM6IEFkZCBcImV4dHJhU291cmNlXCIgb3B0aW9uYWwgZmllbGQgdG8gdGhlIGxvY2FsZSBub2RlIG9mIHRoZSBjb25maWdcclxuZXhwb3J0IGNvbnN0IGNvbmZpZ1YxXzJEZWZpbml0aW9uID0gZXh0ZW5kQ29uZmlnRGVmaW5pdGlvbihcclxuICBjb25maWdWMV8xRGVmaW5pdGlvbixcclxuICB7XHJcbiAgICBjcmVhdGVTY2hlbWE6IChiYXNlU2NoZW1hKSA9PlxyXG4gICAgICBiYXNlU2NoZW1hLmV4dGVuZCh7XHJcbiAgICAgICAgbG9jYWxlOiBsb2NhbGVTY2hlbWEuZXh0ZW5kKHtcclxuICAgICAgICAgIGV4dHJhU291cmNlOiBsb2NhbGVDb2RlU2NoZW1hXHJcbiAgICAgICAgICAgIC5vcHRpb25hbCgpXHJcbiAgICAgICAgICAgIC5kZXNjcmliZShcclxuICAgICAgICAgICAgICBcIk9wdGlvbmFsIGV4dHJhIHNvdXJjZSBsb2NhbGUgY29kZSB1c2VkIGFzIGZhbGxiYWNrIGR1cmluZyB0cmFuc2xhdGlvbi5cIixcclxuICAgICAgICAgICAgKSxcclxuICAgICAgICB9KSxcclxuICAgICAgfSksXHJcbiAgICBjcmVhdGVEZWZhdWx0VmFsdWU6IChiYXNlRGVmYXVsdFZhbHVlKSA9PiAoe1xyXG4gICAgICAuLi5iYXNlRGVmYXVsdFZhbHVlLFxyXG4gICAgICB2ZXJzaW9uOiAxLjIsXHJcbiAgICB9KSxcclxuICAgIGNyZWF0ZVVwZ3JhZGVyOiAob2xkQ29uZmlnKSA9PiAoe1xyXG4gICAgICAuLi5vbGRDb25maWcsXHJcbiAgICAgIHZlcnNpb246IDEuMixcclxuICAgIH0pLFxyXG4gIH0sXHJcbik7XHJcblxyXG4vLyB2MS4yIC0+IHYxLjNcclxuLy8gQ2hhbmdlczogU3VwcG9ydCBib3RoIHN0cmluZyBwYXRocyBhbmQge3BhdGgsIGRlbGltaXRlcn0gb2JqZWN0cyBpbiBidWNrZXQgaW5jbHVkZS9leGNsdWRlIGFycmF5c1xyXG5leHBvcnQgY29uc3QgYnVja2V0SXRlbVNjaGVtYSA9IFoub2JqZWN0KHtcclxuICBwYXRoOiBaLnN0cmluZygpLmRlc2NyaWJlKFwiUGF0aCBwYXR0ZXJuIGNvbnRhaW5pbmcgYSBbbG9jYWxlXSBwbGFjZWhvbGRlci5cIiksXHJcbiAgZGVsaW1pdGVyOiBaLnVuaW9uKFtaLmxpdGVyYWwoXCItXCIpLCBaLmxpdGVyYWwoXCJfXCIpLCBaLmxpdGVyYWwobnVsbCldKVxyXG4gICAgLm9wdGlvbmFsKClcclxuICAgIC5kZXNjcmliZShcclxuICAgICAgXCJEZWxpbWl0ZXIgdGhhdCByZXBsYWNlcyB0aGUgW2xvY2FsZV0gcGxhY2Vob2xkZXIgaW4gdGhlIHBhdGggKGRlZmF1bHQ6IG5vIGRlbGltaXRlcikuXCIsXHJcbiAgICApLFxyXG59KS5kZXNjcmliZShcclxuICBcIkJ1Y2tldCBwYXRoIGl0ZW0uIEVpdGhlciBhIHN0cmluZyBwYXRoIG9yIGFuIG9iamVjdCBzcGVjaWZ5aW5nIHBhdGggYW5kIGRlbGltaXRlci5cIixcclxuKTtcclxuZXhwb3J0IHR5cGUgQnVja2V0SXRlbSA9IFouaW5mZXI8dHlwZW9mIGJ1Y2tldEl0ZW1TY2hlbWE+O1xyXG5cclxuLy8gRGVmaW5lIGEgYmFzZSBidWNrZXQgdmFsdWUgc2NoZW1hIHRoYXQgY2FuIGJlIHJldXNlZCBhbmQgZXh0ZW5kZWRcclxuZXhwb3J0IGNvbnN0IGJ1Y2tldFZhbHVlU2NoZW1hVjFfMyA9IFoub2JqZWN0KHtcclxuICBpbmNsdWRlOiBaLmFycmF5KFoudW5pb24oW1ouc3RyaW5nKCksIGJ1Y2tldEl0ZW1TY2hlbWFdKSlcclxuICAgIC5kZWZhdWx0KFtdKVxyXG4gICAgLmRlc2NyaWJlKFwiR2xvYiBwYXR0ZXJucyBvciBidWNrZXQgaXRlbXMgdG8gaW5jbHVkZSBmb3IgdGhpcyBidWNrZXQuXCIpLFxyXG4gIGV4Y2x1ZGU6IFouYXJyYXkoWi51bmlvbihbWi5zdHJpbmcoKSwgYnVja2V0SXRlbVNjaGVtYV0pKVxyXG4gICAgLmRlZmF1bHQoW10pXHJcbiAgICAub3B0aW9uYWwoKVxyXG4gICAgLmRlc2NyaWJlKFwiR2xvYiBwYXR0ZXJucyBvciBidWNrZXQgaXRlbXMgdG8gZXhjbHVkZSBmcm9tIHRoaXMgYnVja2V0LlwiKSxcclxuICBpbmplY3RMb2NhbGU6IFouYXJyYXkoWi5zdHJpbmcoKSlcclxuICAgIC5vcHRpb25hbCgpXHJcbiAgICAuZGVzY3JpYmUoXHJcbiAgICAgIFwiS2V5cyB3aXRoaW4gZmlsZXMgd2hlcmUgdGhlIGN1cnJlbnQgbG9jYWxlIHNob3VsZCBiZSBpbmplY3RlZCBvciByZW1vdmVkLlwiLFxyXG4gICAgKSxcclxufSkuZGVzY3JpYmUoXCJDb25maWd1cmF0aW9uIG9wdGlvbnMgZm9yIGEgdHJhbnNsYXRpb24gYnVja2V0LlwiKTtcclxuXHJcbmV4cG9ydCBjb25zdCBjb25maWdWMV8zRGVmaW5pdGlvbiA9IGV4dGVuZENvbmZpZ0RlZmluaXRpb24oXHJcbiAgY29uZmlnVjFfMkRlZmluaXRpb24sXHJcbiAge1xyXG4gICAgY3JlYXRlU2NoZW1hOiAoYmFzZVNjaGVtYSkgPT5cclxuICAgICAgYmFzZVNjaGVtYS5leHRlbmQoe1xyXG4gICAgICAgIGJ1Y2tldHM6IFoucmVjb3JkKGJ1Y2tldFR5cGVTY2hlbWEsIGJ1Y2tldFZhbHVlU2NoZW1hVjFfMykuZGVmYXVsdCh7fSksXHJcbiAgICAgIH0pLFxyXG4gICAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgICAgLi4uYmFzZURlZmF1bHRWYWx1ZSxcclxuICAgICAgdmVyc2lvbjogMS4zLFxyXG4gICAgfSksXHJcbiAgICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZykgPT4gKHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjMsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuY29uc3QgY29uZmlnU2NoZW1hID0gXCJodHRwczovL2xpbmdvLmRldi9zY2hlbWEvaTE4bi5qc29uXCI7XHJcblxyXG4vLyB2MS4zIC0+IHYxLjRcclxuLy8gQ2hhbmdlczogQWRkICRzY2hlbWEgdG8gdGhlIGNvbmZpZ1xyXG5leHBvcnQgY29uc3QgY29uZmlnVjFfNERlZmluaXRpb24gPSBleHRlbmRDb25maWdEZWZpbml0aW9uKFxyXG4gIGNvbmZpZ1YxXzNEZWZpbml0aW9uLFxyXG4gIHtcclxuICAgIGNyZWF0ZVNjaGVtYTogKGJhc2VTY2hlbWEpID0+XHJcbiAgICAgIGJhc2VTY2hlbWEuZXh0ZW5kKHtcclxuICAgICAgICAkc2NoZW1hOiBaLnN0cmluZygpLmRlZmF1bHQoY29uZmlnU2NoZW1hKSxcclxuICAgICAgfSksXHJcbiAgICBjcmVhdGVEZWZhdWx0VmFsdWU6IChiYXNlRGVmYXVsdFZhbHVlKSA9PiAoe1xyXG4gICAgICAuLi5iYXNlRGVmYXVsdFZhbHVlLFxyXG4gICAgICB2ZXJzaW9uOiAxLjQsXHJcbiAgICAgICRzY2hlbWE6IGNvbmZpZ1NjaGVtYSxcclxuICAgIH0pLFxyXG4gICAgY3JlYXRlVXBncmFkZXI6IChvbGRDb25maWcpID0+ICh7XHJcbiAgICAgIC4uLm9sZENvbmZpZyxcclxuICAgICAgdmVyc2lvbjogMS40LFxyXG4gICAgICAkc2NoZW1hOiBjb25maWdTY2hlbWEsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gdjEuNCAtPiB2MS41XHJcbi8vIENoYW5nZXM6IGFkZCBcInByb3ZpZGVyXCIgZmllbGQgdG8gdGhlIGNvbmZpZ1xyXG5jb25zdCBwcm92aWRlclNjaGVtYSA9IFoub2JqZWN0KHtcclxuICBpZDogWi5lbnVtKFtcclxuICAgIFwib3BlbmFpXCIsXHJcbiAgICBcImFudGhyb3BpY1wiLFxyXG4gICAgXCJnb29nbGVcIixcclxuICAgIFwib2xsYW1hXCIsXHJcbiAgICBcIm9wZW5yb3V0ZXJcIixcclxuICAgIFwibWlzdHJhbFwiLFxyXG4gIF0pLmRlc2NyaWJlKFwiSWRlbnRpZmllciBvZiB0aGUgdHJhbnNsYXRpb24gcHJvdmlkZXIgc2VydmljZS5cIiksXHJcbiAgbW9kZWw6IFouc3RyaW5nKCkuZGVzY3JpYmUoXCJNb2RlbCBuYW1lIHRvIHVzZSBmb3IgdHJhbnNsYXRpb25zLlwiKSxcclxuICBwcm9tcHQ6IFouc3RyaW5nKCkuZGVzY3JpYmUoXHJcbiAgICBcIlByb21wdCB0ZW1wbGF0ZSB1c2VkIHdoZW4gcmVxdWVzdGluZyB0cmFuc2xhdGlvbnMuXCIsXHJcbiAgKSxcclxuICBiYXNlVXJsOiBaLnN0cmluZygpXHJcbiAgICAub3B0aW9uYWwoKVxyXG4gICAgLmRlc2NyaWJlKFwiQ3VzdG9tIGJhc2UgVVJMIGZvciB0aGUgcHJvdmlkZXIgQVBJIChvcHRpb25hbCkuXCIpLFxyXG59KS5kZXNjcmliZShcIkNvbmZpZ3VyYXRpb24gZm9yIHRoZSBtYWNoaW5lLXRyYW5zbGF0aW9uIHByb3ZpZGVyLlwiKTtcclxuZXhwb3J0IGNvbnN0IGNvbmZpZ1YxXzVEZWZpbml0aW9uID0gZXh0ZW5kQ29uZmlnRGVmaW5pdGlvbihcclxuICBjb25maWdWMV80RGVmaW5pdGlvbixcclxuICB7XHJcbiAgICBjcmVhdGVTY2hlbWE6IChiYXNlU2NoZW1hKSA9PlxyXG4gICAgICBiYXNlU2NoZW1hLmV4dGVuZCh7XHJcbiAgICAgICAgcHJvdmlkZXI6IHByb3ZpZGVyU2NoZW1hLm9wdGlvbmFsKCksXHJcbiAgICAgIH0pLFxyXG4gICAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgICAgLi4uYmFzZURlZmF1bHRWYWx1ZSxcclxuICAgICAgdmVyc2lvbjogMS41LFxyXG4gICAgfSksXHJcbiAgICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZykgPT4gKHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjUsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gdjEuNSAtPiB2MS42XHJcbi8vIENoYW5nZXM6IEFkZCBcImxvY2tlZEtleXNcIiBzdHJpbmcgYXJyYXkgdG8gYnVja2V0IGNvbmZpZ1xyXG5leHBvcnQgY29uc3QgYnVja2V0VmFsdWVTY2hlbWFWMV82ID0gYnVja2V0VmFsdWVTY2hlbWFWMV8zLmV4dGVuZCh7XHJcbiAgbG9ja2VkS2V5czogWi5hcnJheShaLnN0cmluZygpKVxyXG4gICAgLmRlZmF1bHQoW10pXHJcbiAgICAub3B0aW9uYWwoKVxyXG4gICAgLmRlc2NyaWJlKFxyXG4gICAgICBcIktleXMgdGhhdCBtdXN0IHJlbWFpbiB1bmNoYW5nZWQgYW5kIHNob3VsZCBuZXZlciBiZSBvdmVyd3JpdHRlbiBieSB0cmFuc2xhdGlvbnMuXCIsXHJcbiAgICApLFxyXG59KTtcclxuXHJcbmV4cG9ydCBjb25zdCBjb25maWdWMV82RGVmaW5pdGlvbiA9IGV4dGVuZENvbmZpZ0RlZmluaXRpb24oXHJcbiAgY29uZmlnVjFfNURlZmluaXRpb24sXHJcbiAge1xyXG4gICAgY3JlYXRlU2NoZW1hOiAoYmFzZVNjaGVtYSkgPT5cclxuICAgICAgYmFzZVNjaGVtYS5leHRlbmQoe1xyXG4gICAgICAgIGJ1Y2tldHM6IFoucmVjb3JkKGJ1Y2tldFR5cGVTY2hlbWEsIGJ1Y2tldFZhbHVlU2NoZW1hVjFfNikuZGVmYXVsdCh7fSksXHJcbiAgICAgIH0pLFxyXG4gICAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgICAgLi4uYmFzZURlZmF1bHRWYWx1ZSxcclxuICAgICAgdmVyc2lvbjogMS42LFxyXG4gICAgfSksXHJcbiAgICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZykgPT4gKHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjYsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gQ2hhbmdlczogQWRkIFwibG9ja2VkUGF0dGVybnNcIiBzdHJpbmcgYXJyYXkgb2YgcmVnZXggcGF0dGVybnMgdG8gYnVja2V0IGNvbmZpZ1xyXG5leHBvcnQgY29uc3QgYnVja2V0VmFsdWVTY2hlbWFWMV83ID0gYnVja2V0VmFsdWVTY2hlbWFWMV82LmV4dGVuZCh7XHJcbiAgbG9ja2VkUGF0dGVybnM6IFouYXJyYXkoWi5zdHJpbmcoKSlcclxuICAgIC5kZWZhdWx0KFtdKVxyXG4gICAgLm9wdGlvbmFsKClcclxuICAgIC5kZXNjcmliZShcclxuICAgICAgXCJSZWd1bGFyIGV4cHJlc3Npb24gcGF0dGVybnMgd2hvc2UgbWF0Y2hlZCBjb250ZW50IHNob3VsZCByZW1haW4gbG9ja2VkIGR1cmluZyB0cmFuc2xhdGlvbi5cIixcclxuICAgICksXHJcbn0pO1xyXG5cclxuZXhwb3J0IGNvbnN0IGNvbmZpZ1YxXzdEZWZpbml0aW9uID0gZXh0ZW5kQ29uZmlnRGVmaW5pdGlvbihcclxuICBjb25maWdWMV82RGVmaW5pdGlvbixcclxuICB7XHJcbiAgICBjcmVhdGVTY2hlbWE6IChiYXNlU2NoZW1hKSA9PlxyXG4gICAgICBiYXNlU2NoZW1hLmV4dGVuZCh7XHJcbiAgICAgICAgYnVja2V0czogWi5yZWNvcmQoYnVja2V0VHlwZVNjaGVtYSwgYnVja2V0VmFsdWVTY2hlbWFWMV83KS5kZWZhdWx0KHt9KSxcclxuICAgICAgfSksXHJcbiAgICBjcmVhdGVEZWZhdWx0VmFsdWU6IChiYXNlRGVmYXVsdFZhbHVlKSA9PiAoe1xyXG4gICAgICAuLi5iYXNlRGVmYXVsdFZhbHVlLFxyXG4gICAgICB2ZXJzaW9uOiAxLjcsXHJcbiAgICB9KSxcclxuICAgIGNyZWF0ZVVwZ3JhZGVyOiAob2xkQ29uZmlnKSA9PiAoe1xyXG4gICAgICAuLi5vbGRDb25maWcsXHJcbiAgICAgIHZlcnNpb246IDEuNyxcclxuICAgIH0pLFxyXG4gIH0sXHJcbik7XHJcblxyXG4vLyB2MS43IC0+IHYxLjhcclxuLy8gQ2hhbmdlczogQWRkIFwiaWdub3JlZEtleXNcIiBzdHJpbmcgYXJyYXkgdG8gYnVja2V0IGNvbmZpZ1xyXG5leHBvcnQgY29uc3QgYnVja2V0VmFsdWVTY2hlbWFWMV84ID0gYnVja2V0VmFsdWVTY2hlbWFWMV83LmV4dGVuZCh7XHJcbiAgaWdub3JlZEtleXM6IFouYXJyYXkoWi5zdHJpbmcoKSlcclxuICAgIC5kZWZhdWx0KFtdKVxyXG4gICAgLm9wdGlvbmFsKClcclxuICAgIC5kZXNjcmliZShcclxuICAgICAgXCJLZXlzIHRoYXQgc2hvdWxkIGJlIGNvbXBsZXRlbHkgaWdub3JlZCBieSB0cmFuc2xhdGlvbiBwcm9jZXNzZXMuXCIsXHJcbiAgICApLFxyXG59KTtcclxuXHJcbmV4cG9ydCBjb25zdCBjb25maWdWMV84RGVmaW5pdGlvbiA9IGV4dGVuZENvbmZpZ0RlZmluaXRpb24oXHJcbiAgY29uZmlnVjFfN0RlZmluaXRpb24sXHJcbiAge1xyXG4gICAgY3JlYXRlU2NoZW1hOiAoYmFzZVNjaGVtYSkgPT5cclxuICAgICAgYmFzZVNjaGVtYS5leHRlbmQoe1xyXG4gICAgICAgIGJ1Y2tldHM6IFoucmVjb3JkKGJ1Y2tldFR5cGVTY2hlbWEsIGJ1Y2tldFZhbHVlU2NoZW1hVjFfOCkuZGVmYXVsdCh7fSksXHJcbiAgICAgIH0pLFxyXG4gICAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgICAgLi4uYmFzZURlZmF1bHRWYWx1ZSxcclxuICAgICAgdmVyc2lvbjogMS44LFxyXG4gICAgfSksXHJcbiAgICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZykgPT4gKHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjgsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gdjEuOCAtPiB2MS45XHJcbi8vIENoYW5nZXM6IEFkZCBcImZvcm1hdHRlclwiIGZpZWxkIHRvIHRvcC1sZXZlbCBjb25maWdcclxuZXhwb3J0IGNvbnN0IGNvbmZpZ1YxXzlEZWZpbml0aW9uID0gZXh0ZW5kQ29uZmlnRGVmaW5pdGlvbihcclxuICBjb25maWdWMV84RGVmaW5pdGlvbixcclxuICB7XHJcbiAgICBjcmVhdGVTY2hlbWE6IChiYXNlU2NoZW1hKSA9PlxyXG4gICAgICBiYXNlU2NoZW1hLmV4dGVuZCh7XHJcbiAgICAgICAgZm9ybWF0dGVyOiBaLmVudW0oW1wicHJldHRpZXJcIiwgXCJiaW9tZVwiXSlcclxuICAgICAgICAgIC5vcHRpb25hbCgpXHJcbiAgICAgICAgICAuZGVzY3JpYmUoXHJcbiAgICAgICAgICAgIFwiQ29kZSBmb3JtYXR0ZXIgdG8gdXNlIGZvciBhbGwgYnVja2V0cy4gRGVmYXVsdHMgdG8gJ3ByZXR0aWVyJyBpZiBub3Qgc3BlY2lmaWVkIGFuZCBhIHByZXR0aWVyIGNvbmZpZyBpcyBmb3VuZC5cIixcclxuICAgICAgICAgICksXHJcbiAgICAgIH0pLFxyXG4gICAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgICAgLi4uYmFzZURlZmF1bHRWYWx1ZSxcclxuICAgICAgdmVyc2lvbjogMS45LFxyXG4gICAgfSksXHJcbiAgICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZykgPT4gKHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjksXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gdjEuOSAtPiB2MS4xMFxyXG4vLyBDaGFuZ2VzOiBBZGQgXCJzZXR0aW5nc1wiIGZpZWxkIHRvIHByb3ZpZGVyIGNvbmZpZyBmb3IgbW9kZWwtc3BlY2lmaWMgcGFyYW1ldGVyc1xyXG5jb25zdCBtb2RlbFNldHRpbmdzU2NoZW1hID0gWi5vYmplY3Qoe1xyXG4gIHRlbXBlcmF0dXJlOiBaLm51bWJlcigpXHJcbiAgICAubWluKDApXHJcbiAgICAubWF4KDIpXHJcbiAgICAub3B0aW9uYWwoKVxyXG4gICAgLmRlc2NyaWJlKFxyXG4gICAgICBcIkNvbnRyb2xzIHJhbmRvbW5lc3MgaW4gbW9kZWwgb3V0cHV0cyAoMD1kZXRlcm1pbmlzdGljLCAyPXZlcnkgcmFuZG9tKS4gU29tZSBtb2RlbHMgbGlrZSBHUFQtNSByZXF1aXJlIHRlbXBlcmF0dXJlPTEuXCIsXHJcbiAgICApLFxyXG59KVxyXG4gIC5vcHRpb25hbCgpXHJcbiAgLmRlc2NyaWJlKFwiTW9kZWwtc3BlY2lmaWMgc2V0dGluZ3MgZm9yIHRyYW5zbGF0aW9uIHJlcXVlc3RzLlwiKTtcclxuXHJcbmNvbnN0IHByb3ZpZGVyU2NoZW1hVjFfMTAgPSBaLm9iamVjdCh7XHJcbiAgaWQ6IFouZW51bShbXHJcbiAgICBcIm9wZW5haVwiLFxyXG4gICAgXCJhbnRocm9waWNcIixcclxuICAgIFwiZ29vZ2xlXCIsXHJcbiAgICBcIm9sbGFtYVwiLFxyXG4gICAgXCJvcGVucm91dGVyXCIsXHJcbiAgICBcIm1pc3RyYWxcIixcclxuICBdKS5kZXNjcmliZShcIklkZW50aWZpZXIgb2YgdGhlIHRyYW5zbGF0aW9uIHByb3ZpZGVyIHNlcnZpY2UuXCIpLFxyXG4gIG1vZGVsOiBaLnN0cmluZygpLmRlc2NyaWJlKFwiTW9kZWwgbmFtZSB0byB1c2UgZm9yIHRyYW5zbGF0aW9ucy5cIiksXHJcbiAgcHJvbXB0OiBaLnN0cmluZygpLmRlc2NyaWJlKFxyXG4gICAgXCJQcm9tcHQgdGVtcGxhdGUgdXNlZCB3aGVuIHJlcXVlc3RpbmcgdHJhbnNsYXRpb25zLlwiLFxyXG4gICksXHJcbiAgYmFzZVVybDogWi5zdHJpbmcoKVxyXG4gICAgLm9wdGlvbmFsKClcclxuICAgIC5kZXNjcmliZShcIkN1c3RvbSBiYXNlIFVSTCBmb3IgdGhlIHByb3ZpZGVyIEFQSSAob3B0aW9uYWwpLlwiKSxcclxuICBzZXR0aW5nczogbW9kZWxTZXR0aW5nc1NjaGVtYSxcclxufSkuZGVzY3JpYmUoXCJDb25maWd1cmF0aW9uIGZvciB0aGUgbWFjaGluZS10cmFuc2xhdGlvbiBwcm92aWRlci5cIik7XHJcblxyXG5leHBvcnQgY29uc3QgY29uZmlnVjFfMTBEZWZpbml0aW9uID0gZXh0ZW5kQ29uZmlnRGVmaW5pdGlvbihcclxuICBjb25maWdWMV85RGVmaW5pdGlvbixcclxuICB7XHJcbiAgICBjcmVhdGVTY2hlbWE6IChiYXNlU2NoZW1hKSA9PlxyXG4gICAgICBiYXNlU2NoZW1hLmV4dGVuZCh7XHJcbiAgICAgICAgcHJvdmlkZXI6IHByb3ZpZGVyU2NoZW1hVjFfMTAub3B0aW9uYWwoKSxcclxuICAgICAgfSksXHJcbiAgICBjcmVhdGVEZWZhdWx0VmFsdWU6IChiYXNlRGVmYXVsdFZhbHVlKSA9PiAoe1xyXG4gICAgICAuLi5iYXNlRGVmYXVsdFZhbHVlLFxyXG4gICAgICB2ZXJzaW9uOiBcIjEuMTBcIixcclxuICAgIH0pLFxyXG4gICAgY3JlYXRlVXBncmFkZXI6IChvbGRDb25maWcpID0+ICh7XHJcbiAgICAgIC4uLm9sZENvbmZpZyxcclxuICAgICAgdmVyc2lvbjogXCIxLjEwXCIsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gZXhwb3J0c1xyXG5leHBvcnQgY29uc3QgTEFURVNUX0NPTkZJR19ERUZJTklUSU9OID0gY29uZmlnVjFfMTBEZWZpbml0aW9uO1xyXG5cclxuZXhwb3J0IHR5cGUgSTE4bkNvbmZpZyA9IFouaW5mZXI8KHR5cGVvZiBMQVRFU1RfQ09ORklHX0RFRklOSVRJT04pW1wic2NoZW1hXCJdPjtcclxuXHJcbmV4cG9ydCBmdW5jdGlvbiBwYXJzZUkxOG5Db25maWcocmF3Q29uZmlnOiB1bmtub3duKSB7XHJcbiAgdHJ5IHtcclxuICAgIGNvbnN0IHJlc3VsdCA9IExBVEVTVF9DT05GSUdfREVGSU5JVElPTi5wYXJzZShyYXdDb25maWcpO1xyXG4gICAgcmV0dXJuIHJlc3VsdDtcclxuICB9IGNhdGNoIChlcnJvcjogYW55KSB7XHJcbiAgICB0aHJvdyBuZXcgRXJyb3IoYEZhaWxlZCB0byBwYXJzZSBjb25maWc6ICR7ZXJyb3IubWVzc2FnZX1gKTtcclxuICB9XHJcbn1cclxuXHJcbmV4cG9ydCBjb25zdCBkZWZhdWx0Q29uZmlnID0gTEFURVNUX0NPTkZJR19ERUZJTklUSU9OLmRlZmF1bHRWYWx1ZTtcclxuIiwgImNvbnN0IF9faW5qZWN0ZWRfZmlsZW5hbWVfXyA9IFwiQzpcXFxcVXNlcnNcXFxcVFVGIEdBTUlOR1xcXFxPbmVEcml2ZVxcXFxEZXNrdG9wXFxcXGxpbmdvLmRldiBvcGVuc291cmNlXFxcXGxpbmdvLmRldlxcXFxwYWNrYWdlc1xcXFxzcGVjXFxcXHNyY1xcXFxsb2NhbGVzLnRzXCI7Y29uc3QgX19pbmplY3RlZF9kaXJuYW1lX18gPSBcIkM6XFxcXFVzZXJzXFxcXFRVRiBHQU1JTkdcXFxcT25lRHJpdmVcXFxcRGVza3RvcFxcXFxsaW5nby5kZXYgb3BlbnNvdXJjZVxcXFxsaW5nby5kZXZcXFxccGFja2FnZXNcXFxcc3BlY1xcXFxzcmNcIjtjb25zdCBfX2luamVjdGVkX2ltcG9ydF9tZXRhX3VybF9fID0gXCJmaWxlOi8vL0M6L1VzZXJzL1RVRiUyMEdBTUlORy9PbmVEcml2ZS9EZXNrdG9wL2xpbmdvLmRldiUyMG9wZW5zb3VyY2UvbGluZ28uZGV2L3BhY2thZ2VzL3NwZWMvc3JjL2xvY2FsZXMudHNcIjtpbXBvcnQgWiBmcm9tIFwiem9kXCI7XHJcbmltcG9ydCB7IGlzVmFsaWRMb2NhbGUgfSBmcm9tIFwiQGxpbmdvLmRldi9fbG9jYWxlc1wiO1xyXG5cclxuY29uc3QgbG9jYWxlTWFwID0ge1xyXG4gIC8vIFVyZHUgKFBha2lzdGFuKVxyXG4gIHVyOiBbXCJ1ci1QS1wiXSxcclxuICAvLyBWaWV0bmFtZXNlIChWaWV0bmFtKVxyXG4gIHZpOiBbXCJ2aS1WTlwiXSxcclxuICAvLyBUdXJraXNoIChUdXJrZXkpXHJcbiAgdHI6IFtcInRyLVRSXCJdLFxyXG4gIC8vIFRhbWlsIChJbmRpYSlcclxuICB0YTogW1xyXG4gICAgXCJ0YS1JTlwiLCAvLyBJbmRpYVxyXG4gICAgXCJ0YS1TR1wiLCAvLyBTaW5nYXBvcmVcclxuICBdLFxyXG4gIC8vIFNlcmJpYW5cclxuICBzcjogW1xyXG4gICAgXCJzci1SU1wiLCAvLyBTZXJiaWFuIChMYXRpbilcclxuICAgIFwic3ItTGF0bi1SU1wiLCAvLyBTZXJiaWFuIChMYXRpbilcclxuICAgIFwic3ItQ3lybC1SU1wiLCAvLyBTZXJiaWFuIChDeXJpbGxpYylcclxuICBdLFxyXG4gIC8vIEh1bmdhcmlhbiAoSHVuZ2FyeSlcclxuICBodTogW1wiaHUtSFVcIl0sXHJcbiAgLy8gSGVicmV3IChJc3JhZWwpXHJcbiAgaGU6IFtcImhlLUlMXCJdLFxyXG4gIC8vIEVzdG9uaWFuIChFc3RvbmlhKVxyXG4gIGV0OiBbXCJldC1FRVwiXSxcclxuICAvLyBHcmVla1xyXG4gIGVsOiBbXHJcbiAgICBcImVsLUdSXCIsIC8vIEdyZWVjZVxyXG4gICAgXCJlbC1DWVwiLCAvLyBDeXBydXNcclxuICBdLFxyXG4gIC8vIERhbmlzaCAoRGVubWFyaylcclxuICBkYTogW1wiZGEtREtcIl0sXHJcbiAgLy8gQXplcmJhaWphbmkgKEF6ZXJiYWlqYW4pXHJcbiAgYXo6IFtcImF6LUFaXCJdLFxyXG4gIC8vIFRoYWkgKFRoYWlsYW5kKVxyXG4gIHRoOiBbXCJ0aC1USFwiXSxcclxuICAvLyBTd2VkaXNoIChTd2VkZW4pXHJcbiAgc3Y6IFtcInN2LVNFXCJdLFxyXG4gIC8vIEVuZ2xpc2hcclxuICBlbjogW1xyXG4gICAgXCJlbi1VU1wiLCAvLyBVbml0ZWQgU3RhdGVzXHJcbiAgICBcImVuLUdCXCIsIC8vIFVuaXRlZCBLaW5nZG9tXHJcbiAgICBcImVuLUFVXCIsIC8vIEF1c3RyYWxpYVxyXG4gICAgXCJlbi1DQVwiLCAvLyBDYW5hZGFcclxuICAgIFwiZW4tU0dcIiwgLy8gU2luZ2Fwb3JlXHJcbiAgICBcImVuLUlFXCIsIC8vIElyZWxhbmRcclxuICBdLFxyXG4gIC8vIFNwYW5pc2hcclxuICBlczogW1xyXG4gICAgXCJlcy1FU1wiLCAvLyBTcGFpblxyXG4gICAgXCJlcy00MTlcIiwgLy8gTGF0aW4gQW1lcmljYVxyXG4gICAgXCJlcy1NWFwiLCAvLyBNZXhpY29cclxuICAgIFwiZXMtQVJcIiwgLy8gQXJnZW50aW5hXHJcbiAgXSxcclxuICAvLyBGcmVuY2hcclxuICBmcjogW1xyXG4gICAgXCJmci1GUlwiLCAvLyBGcmFuY2VcclxuICAgIFwiZnItQ0FcIiwgLy8gQ2FuYWRhXHJcbiAgICBcImZyLUJFXCIsIC8vIEJlbGdpdW1cclxuICAgIFwiZnItTFVcIiwgLy8gTHV4ZW1ib3VyZ1xyXG4gIF0sXHJcbiAgLy8gQ2F0YWxhbiAoU3BhaW4pXHJcbiAgY2E6IFtcImNhLUVTXCJdLFxyXG4gIC8vIEphcGFuZXNlIChKYXBhbilcclxuICBqYTogW1wiamEtSlBcIl0sXHJcbiAgLy8gS2F6YWtoIChLYXpha2hzdGFuKVxyXG4gIGtrOiBbXCJray1LWlwiXSxcclxuICAvLyBHZXJtYW5cclxuICBkZTogW1xyXG4gICAgXCJkZS1ERVwiLCAvLyBHZXJtYW55XHJcbiAgICBcImRlLUFUXCIsIC8vIEF1c3RyaWFcclxuICAgIFwiZGUtQ0hcIiwgLy8gU3dpdHplcmxhbmRcclxuICBdLFxyXG4gIC8vIFBvcnR1Z3Vlc2VcclxuICBwdDogW1xyXG4gICAgXCJwdC1QVFwiLCAvLyBQb3J0dWdhbFxyXG4gICAgXCJwdC1CUlwiLCAvLyBCcmF6aWxcclxuICBdLFxyXG4gIC8vIEl0YWxpYW5cclxuICBpdDogW1xyXG4gICAgXCJpdC1JVFwiLCAvLyBJdGFseVxyXG4gICAgXCJpdC1DSFwiLCAvLyBTd2l0emVybGFuZFxyXG4gIF0sXHJcbiAgLy8gUnVzc2lhblxyXG4gIHJ1OiBbXHJcbiAgICBcInJ1LVJVXCIsIC8vIFJ1c3NpYVxyXG4gICAgXCJydS1CWVwiLCAvLyBCZWxhcnVzXHJcbiAgXSxcclxuICAvLyBVa3JhaW5pYW4gKFVrcmFpbmUpXHJcbiAgdWs6IFtcInVrLVVBXCJdLFxyXG4gIC8vIEJlbGFydXNpYW4gKEJlbGFydXMpXHJcbiAgYmU6IFtcImJlLUJZXCJdLFxyXG4gIC8vIEhpbmRpIChJbmRpYSlcclxuICBoaTogW1wiaGktSU5cIl0sXHJcbiAgLy8gQ2hpbmVzZVxyXG4gIHpoOiBbXHJcbiAgICBcInpoLUNOXCIsIC8vIFNpbXBsaWZpZWQgQ2hpbmVzZSAoQ2hpbmEpXHJcbiAgICBcInpoLVRXXCIsIC8vIFRyYWRpdGlvbmFsIENoaW5lc2UgKFRhaXdhbilcclxuICAgIFwiemgtSEtcIiwgLy8gVHJhZGl0aW9uYWwgQ2hpbmVzZSAoSG9uZyBLb25nKVxyXG4gICAgXCJ6aC1TR1wiLCAvLyBTaW1wbGlmaWVkIENoaW5lc2UgKFNpbmdhcG9yZSlcclxuICAgIFwiemgtSGFuc1wiLCAvLyBTaW1wbGlmaWVkIENoaW5lc2VcclxuICAgIFwiemgtSGFudFwiLCAvLyBUcmFkaXRpb25hbCBDaGluZXNlXHJcbiAgICBcInpoLUhhbnQtSEtcIiwgLy8gVHJhZGl0aW9uYWwgQ2hpbmVzZSAoSG9uZyBLb25nKVxyXG4gICAgXCJ6aC1IYW50LVRXXCIsIC8vIFRyYWRpdGlvbmFsIENoaW5lc2UgKFRhaXdhbilcclxuICAgIFwiemgtSGFudC1DTlwiLCAvLyBUcmFkaXRpb25hbCBDaGluZXNlIChDaGluYSlcclxuICAgIFwiemgtSGFucy1IS1wiLCAvLyBTaW1wbGlmaWVkIENoaW5lc2UgKEhvbmcgS29uZylcclxuICAgIFwiemgtSGFucy1UV1wiLCAvLyBTaW1wbGlmaWVkIENoaW5lc2UgKENoaW5hKVxyXG4gICAgXCJ6aC1IYW5zLUNOXCIsIC8vIFNpbXBsaWZpZWQgQ2hpbmVzZSAoQ2hpbmEpXHJcbiAgXSxcclxuICAvLyBLb3JlYW4gKFNvdXRoIEtvcmVhKVxyXG4gIGtvOiBbXCJrby1LUlwiXSxcclxuICAvLyBBcmFiaWNcclxuICBhcjogW1xyXG4gICAgXCJhci1FR1wiLCAvLyBFZ3lwdFxyXG4gICAgXCJhci1TQVwiLCAvLyBTYXVkaSBBcmFiaWFcclxuICAgIFwiYXItQUVcIiwgLy8gVW5pdGVkIEFyYWIgRW1pcmF0ZXNcclxuICAgIFwiYXItTUFcIiwgLy8gTW9yb2Njb1xyXG4gIF0sXHJcbiAgLy8gQnVsZ2FyaWFuIChCdWxnYXJpYSlcclxuICBiZzogW1wiYmctQkdcIl0sXHJcbiAgLy8gQ3plY2ggKEN6ZWNoIFJlcHVibGljKVxyXG4gIGNzOiBbXCJjcy1DWlwiXSxcclxuICAvLyBXZWxzaCAoV2FsZXMpXHJcbiAgY3k6IFtcImN5LUdCXCJdLFxyXG4gIC8vIER1dGNoXHJcbiAgbmw6IFtcclxuICAgIFwibmwtTkxcIiwgLy8gTmV0aGVybGFuZHNcclxuICAgIFwibmwtQkVcIiwgLy8gQmVsZ2l1bVxyXG4gIF0sXHJcbiAgLy8gUG9saXNoIChQb2xhbmQpXHJcbiAgcGw6IFtcInBsLVBMXCJdLFxyXG4gIC8vIEluZG9uZXNpYW4gKEluZG9uZXNpYSlcclxuICBpZDogW1wiaWQtSURcIl0sXHJcbiAgaXM6IFtcImlzLUlTXCJdLFxyXG4gIC8vIE1hbGF5IChNYWxheXNpYSlcclxuICBtczogW1wibXMtTVlcIl0sXHJcbiAgLy8gRmlubmlzaCAoRmlubGFuZClcclxuICBmaTogW1wiZmktRklcIl0sXHJcbiAgLy8gQmFzcXVlIChTcGFpbilcclxuICBldTogW1wiZXUtRVNcIl0sXHJcbiAgLy8gQ3JvYXRpYW4gKENyb2F0aWEpXHJcbiAgaHI6IFtcImhyLUhSXCJdLFxyXG4gIC8vIEhlYnJldyAoSXNyYWVsKSAtIGFsdGVybmF0aXZlIGNvZGVcclxuICBpdzogW1wiaXctSUxcIl0sXHJcbiAgLy8gS2htZXIgKENhbWJvZGlhKVxyXG4gIGttOiBbXCJrbS1LSFwiXSxcclxuICAvLyBMYXR2aWFuIChMYXR2aWEpXHJcbiAgbHY6IFtcImx2LUxWXCJdLFxyXG4gIC8vIExpdGh1YW5pYW4gKExpdGh1YW5pYSlcclxuICBsdDogW1wibHQtTFRcIl0sXHJcbiAgLy8gTm9yd2VnaWFuXHJcbiAgbm86IFtcclxuICAgIFwibm8tTk9cIiwgLy8gTm9yd2F5IChsZWdhY3kpXHJcbiAgICBcIm5iLU5PXCIsIC8vIE5vcndlZ2lhbiBCb2ttXHUwMEU1bFxyXG4gICAgXCJubi1OT1wiLCAvLyBOb3J3ZWdpYW4gTnlub3Jza1xyXG4gIF0sXHJcbiAgLy8gUm9tYW5pYW4gKFJvbWFuaWEpXHJcbiAgcm86IFtcInJvLVJPXCJdLFxyXG4gIC8vIFNsb3ZhayAoU2xvdmFraWEpXHJcbiAgc2s6IFtcInNrLVNLXCJdLFxyXG4gIC8vIFN3YWhpbGlcclxuICBzdzogW1xyXG4gICAgXCJzdy1UWlwiLCAvLyBUYW56YW5pYVxyXG4gICAgXCJzdy1LRVwiLCAvLyBLZW55YVxyXG4gICAgXCJzdy1VR1wiLCAvLyBVZ2FuZGFcclxuICAgIFwic3ctQ0RcIiwgLy8gRGVtb2NyYXRpYyBSZXB1YmxpYyBvZiBDb25nb1xyXG4gICAgXCJzdy1SV1wiLCAvLyBSd2FuZGFcclxuICBdLFxyXG4gIC8vIFBlcnNpYW4gKElyYW4pXHJcbiAgZmE6IFtcImZhLUlSXCJdLFxyXG4gIC8vIEZpbGlwaW5vIChQaGlsaXBwaW5lcylcclxuICBmaWw6IFtcImZpbC1QSFwiXSxcclxuICAvLyBQdW5qYWJpXHJcbiAgcGE6IFtcclxuICAgIFwicGEtSU5cIiwgLy8gSW5kaWFcclxuICAgIFwicGEtUEtcIiwgLy8gUGFraXN0YW5cclxuICBdLFxyXG4gIC8vIEJlbmdhbGlcclxuICBibjogW1xyXG4gICAgXCJibi1CRFwiLCAvLyBCYW5nbGFkZXNoXHJcbiAgICBcImJuLUlOXCIsIC8vIEluZGlhXHJcbiAgXSxcclxuICAvLyBJcmlzaCAoSXJlbGFuZClcclxuICBnYTogW1wiZ2EtSUVcIl0sXHJcbiAgLy8gR2FsaWNpYW4gKFNwYWluKVxyXG4gIGdsOiBbXCJnbC1FU1wiXSxcclxuICAvLyBNYWx0ZXNlIChNYWx0YSlcclxuICBtdDogW1wibXQtTVRcIl0sXHJcbiAgLy8gU2xvdmVuaWFuIChTbG92ZW5pYSlcclxuICBzbDogW1wic2wtU0lcIl0sXHJcbiAgLy8gQWxiYW5pYW4gKEFsYmFuaWEpXHJcbiAgc3E6IFtcInNxLUFMXCJdLFxyXG4gIC8vIEJhdmFyaWFuIChHZXJtYW55KVxyXG4gIGJhcjogW1wiYmFyLURFXCJdLFxyXG4gIC8vIE5lYXBvbGl0YW4gKEl0YWx5KVxyXG4gIG5hcDogW1wibmFwLUlUXCJdLFxyXG4gIC8vIEFmcmlrYWFucyAoU291dGggQWZyaWNhKVxyXG4gIGFmOiBbXCJhZi1aQVwiXSxcclxuICAvLyBVemJlayAoTGF0aW4pXHJcbiAgdXo6IFtcInV6LUxhdG5cIl0sXHJcbiAgLy8gU29tYWxpIChTb21hbGlhKVxyXG4gIHNvOiBbXCJzby1TT1wiXSxcclxuICAvLyBUaWdyaW55YSAoRXRoaW9waWEpXHJcbiAgdGk6IFtcInRpLUVUXCJdLFxyXG4gIC8vIFN0YW5kYXJkIE1vcm9jY2FuIFRhbWF6aWdodCAoTW9yb2NjbylcclxuICB6Z2g6IFtcInpnaC1NQVwiXSxcclxuICAvLyBUYWdhbG9nIChQaGlsaXBwaW5lcylcclxuICB0bDogW1widGwtUEhcIl0sXHJcbiAgLy8gVGVsdWd1IChJbmRpYSlcclxuICB0ZTogW1widGUtSU5cIl0sXHJcbiAgLy8gS2lueWFyd2FuZGEgKFJ3YW5kYSlcclxuICBydzogW1wicnctUldcIl0sXHJcbiAgLy8gR2VvcmdpYW4gKEdlb3JnaWEpXHJcbiAga2E6IFtcImthLUdFXCJdLFxyXG4gIC8vIE1hbGF5YWxhbSAoSW5kaWEpXHJcbiAgbWw6IFtcIm1sLUlOXCJdLFxyXG4gIC8vIEFybWVuaWFuIChBcm1lbmlhKVxyXG4gIGh5OiBbXCJoeS1BTVwiXSxcclxuICAvLyBNYWNlZG9uaWFuIChNYWNlZG9uaWEpXHJcbiAgbWs6IFtcIm1rLU1LXCJdLFxyXG59IGFzIGNvbnN0O1xyXG5cclxuZXhwb3J0IHR5cGUgTG9jYWxlQ29kZVNob3J0ID0ga2V5b2YgdHlwZW9mIGxvY2FsZU1hcDtcclxuZXhwb3J0IHR5cGUgTG9jYWxlQ29kZUZ1bGwgPSAodHlwZW9mIGxvY2FsZU1hcClbTG9jYWxlQ29kZVNob3J0XVtudW1iZXJdO1xyXG5leHBvcnQgdHlwZSBMb2NhbGVDb2RlID0gTG9jYWxlQ29kZVNob3J0IHwgTG9jYWxlQ29kZUZ1bGw7XHJcbmV4cG9ydCB0eXBlIExvY2FsZURlbGltaXRlciA9IFwiLVwiIHwgXCJfXCIgfCBudWxsO1xyXG5cclxuZXhwb3J0IGNvbnN0IGxvY2FsZUNvZGVzU2hvcnQgPSBPYmplY3Qua2V5cyhsb2NhbGVNYXApIGFzIExvY2FsZUNvZGVTaG9ydFtdO1xyXG5leHBvcnQgY29uc3QgbG9jYWxlQ29kZXNGdWxsID0gT2JqZWN0LnZhbHVlcyhcclxuICBsb2NhbGVNYXAsXHJcbikuZmxhdCgpIGFzIExvY2FsZUNvZGVGdWxsW107XHJcbmV4cG9ydCBjb25zdCBsb2NhbGVDb2Rlc0Z1bGxVbmRlcnNjb3JlID0gbG9jYWxlQ29kZXNGdWxsLm1hcCgodmFsdWUpID0+XHJcbiAgdmFsdWUucmVwbGFjZShcIi1cIiwgXCJfXCIpLFxyXG4pO1xyXG5leHBvcnQgY29uc3QgbG9jYWxlQ29kZXNGdWxsRXhwbGljaXRSZWdpb24gPSBsb2NhbGVDb2Rlc0Z1bGwubWFwKCh2YWx1ZSkgPT4ge1xyXG4gIGNvbnN0IGNodW5rcyA9IHZhbHVlLnNwbGl0KFwiLVwiKTtcclxuICBjb25zdCByZXN1bHQgPSBbY2h1bmtzWzBdLCBcIi1yXCIsIGNodW5rcy5zbGljZSgxKS5qb2luKFwiLVwiKV0uam9pbihcIlwiKTtcclxuICByZXR1cm4gcmVzdWx0O1xyXG59KTtcclxuZXhwb3J0IGNvbnN0IGxvY2FsZUNvZGVzID0gW1xyXG4gIC4uLmxvY2FsZUNvZGVzU2hvcnQsXHJcbiAgLi4ubG9jYWxlQ29kZXNGdWxsLFxyXG4gIC4uLmxvY2FsZUNvZGVzRnVsbFVuZGVyc2NvcmUsXHJcbiAgLi4ubG9jYWxlQ29kZXNGdWxsRXhwbGljaXRSZWdpb24sXHJcbl0gYXMgTG9jYWxlQ29kZVtdO1xyXG5cclxuZXhwb3J0IGNvbnN0IGxvY2FsZUNvZGVTY2hlbWEgPSBaLnN0cmluZygpLnJlZmluZShcclxuICAodmFsdWUpID0+IHtcclxuICAgIC8vIE5vcm1hbGl6ZSBsb2NhbGUgYmVmb3JlIHZhbGlkYXRpb25cclxuICAgIGNvbnN0IG5vcm1hbGl6ZWQgPSBub3JtYWxpemVMb2NhbGUodmFsdWUpO1xyXG4gICAgcmV0dXJuIGlzVmFsaWRMb2NhbGUobm9ybWFsaXplZCk7XHJcbiAgfSxcclxuICB7XHJcbiAgICBtZXNzYWdlOiBcIkludmFsaWQgbG9jYWxlIGNvZGVcIixcclxuICB9LFxyXG4pO1xyXG5cclxuLyoqXHJcbiAqIFJlc29sdmVzIGEgbG9jYWxlIGNvZGUgdG8gaXRzIGZ1bGwgbG9jYWxlIHJlcHJlc2VudGF0aW9uLlxyXG4gKlxyXG4gKiAgSWYgdGhlIHByb3ZpZGVkIGxvY2FsZSBjb2RlIGlzIGFscmVhZHkgYSBmdWxsIGxvY2FsZSBjb2RlLCBpdCByZXR1cm5zIGFzIGlzLlxyXG4gKiAgSWYgdGhlIHByb3ZpZGVkIGxvY2FsZSBjb2RlIGlzIGEgc2hvcnQgbG9jYWxlIGNvZGUsIGl0IHJldHVybnMgdGhlIGZpcnN0IGNvcnJlc3BvbmRpbmcgZnVsbCBsb2NhbGUuXHJcbiAqICBJZiB0aGUgbG9jYWxlIGNvZGUgaXMgbm90IGZvdW5kLCBpdCB0aHJvd3MgYW4gZXJyb3IuXHJcbiAqXHJcbiAqIEBwYXJhbSB7bG9jYWxlQ29kZXN9IHZhbHVlIC0gVGhlIGxvY2FsZSBjb2RlIHRvIHJlc29sdmUgKGVpdGhlciBzaG9ydCBvciBmdWxsKVxyXG4gKiBAcmV0dXJuIHtMb2NhbGVDb2RlRnVsbH0gVGhlIHJlc29sdmVkIGZ1bGwgbG9jYWxlIGNvZGVcclxuICogQHRocm93cyB7RXJyb3J9IElmIHRoZSBwcm92aWRlZCBsb2NhbGUgY29kZSBpcyBpbnZhbGlkLlxyXG4gKi9cclxuZXhwb3J0IGNvbnN0IHJlc29sdmVMb2NhbGVDb2RlID0gKHZhbHVlOiBzdHJpbmcpOiBMb2NhbGVDb2RlRnVsbCA9PiB7XHJcbiAgY29uc3QgZXhpc3RpbmdGdWxsTG9jYWxlQ29kZSA9IE9iamVjdC52YWx1ZXMobG9jYWxlTWFwKVxyXG4gICAgLmZsYXQoKVxyXG4gICAgLmluY2x1ZGVzKHZhbHVlIGFzIGFueSk7XHJcbiAgaWYgKGV4aXN0aW5nRnVsbExvY2FsZUNvZGUpIHtcclxuICAgIHJldHVybiB2YWx1ZSBhcyBMb2NhbGVDb2RlRnVsbDtcclxuICB9XHJcblxyXG4gIGNvbnN0IGV4aXN0aW5nU2hvcnRMb2NhbGVDb2RlID0gT2JqZWN0LmtleXMobG9jYWxlTWFwKS5pbmNsdWRlcyh2YWx1ZSk7XHJcbiAgaWYgKGV4aXN0aW5nU2hvcnRMb2NhbGVDb2RlKSB7XHJcbiAgICBjb25zdCBjb3JyZXNwb25kaW5nRnVsbExvY2FsZXMgPSBsb2NhbGVNYXBbdmFsdWUgYXMgTG9jYWxlQ29kZVNob3J0XTtcclxuICAgIGNvbnN0IGZhbGxiYWNrRnVsbExvY2FsZSA9IGNvcnJlc3BvbmRpbmdGdWxsTG9jYWxlc1swXTtcclxuICAgIHJldHVybiBmYWxsYmFja0Z1bGxMb2NhbGU7XHJcbiAgfVxyXG5cclxuICB0aHJvdyBuZXcgRXJyb3IoYEludmFsaWQgbG9jYWxlIGNvZGU6ICR7dmFsdWV9YCk7XHJcbn07XHJcblxyXG4vKipcclxuICogRGV0ZXJtaW5lcyB0aGUgZGVsaW1pdGVyIHVzZWQgaW4gYSBsb2NhbGUgY29kZVxyXG4gKlxyXG4gKiBAcGFyYW0ge3N0cmluZ30gbG9jYWxlIC0gdGhlIGxvY2FsZSBzdHJpbmcgKGUuZy4sXCJlbl9VU1wiLFwiZW4tR0JcIilcclxuICogQHJldHVybiB7IHN0cmluZyB8IG51bGx9IC0gVGhlIGRlbGltaXRlciAoXCJfXCIgb3IgXCItXCIpIGlmIGZvdW5kLCBvdGhlcndpc2UgYG51bGxgLlxyXG4gKi9cclxuXHJcbmV4cG9ydCBjb25zdCBnZXRMb2NhbGVDb2RlRGVsaW1pdGVyID0gKGxvY2FsZTogc3RyaW5nKTogTG9jYWxlRGVsaW1pdGVyID0+IHtcclxuICBpZiAobG9jYWxlLmluY2x1ZGVzKFwiX1wiKSkge1xyXG4gICAgcmV0dXJuIFwiX1wiO1xyXG4gIH0gZWxzZSBpZiAobG9jYWxlLmluY2x1ZGVzKFwiLVwiKSkge1xyXG4gICAgcmV0dXJuIFwiLVwiO1xyXG4gIH0gZWxzZSB7XHJcbiAgICByZXR1cm4gbnVsbDtcclxuICB9XHJcbn07XHJcblxyXG4vKipcclxuICogUmVwbGFjZXMgdGhlIGRlbGltaXRlciBpbiBhIGxvY2FsZSBzdHJpbmcgd2l0aCB0aGUgc3BlY2lmaWVkIGRlbGltaXRlci5cclxuICpcclxuICogQHBhcmFtIHtzdHJpbmd9bG9jYWxlIC0gVGhlIGxvY2FsZSBzdHJpbmcgKGUuZy4sXCJlbl9VU1wiLCBcImVuLUdCXCIpLlxyXG4gKiBAcGFyYW0ge1wiLVwiIHwgXCJfXCIgfCBudWxsfSBbZGVsaW1pdGVyXSAtIFRoZSBuZXcgZGVsaW1pdGVyIHRvIHJlcGxhY2UgdGhlIGV4aXN0aW5nIG9uZS5cclxuICogQHJldHVybnMge3N0cmluZ30gVGhlIGxvY2FsZSBzdHJpbmcgd2l0aCB0aGUgcmVwbGFjZWQgZGVsaW1pdGVyLCBvciB0aGUgb3JpZ2luYWwgbG9jYWxlIGlmIG5vIGRlbGltaXRlciBpcyBwcm92aWRlZC5cclxuICovXHJcblxyXG5leHBvcnQgY29uc3QgcmVzb2x2ZU92ZXJyaWRkZW5Mb2NhbGUgPSAoXHJcbiAgbG9jYWxlOiBzdHJpbmcsXHJcbiAgZGVsaW1pdGVyPzogTG9jYWxlRGVsaW1pdGVyLFxyXG4pOiBzdHJpbmcgPT4ge1xyXG4gIGlmICghZGVsaW1pdGVyKSB7XHJcbiAgICByZXR1cm4gbG9jYWxlO1xyXG4gIH1cclxuXHJcbiAgY29uc3QgY3VycmVudERlbGltaXRlciA9IGdldExvY2FsZUNvZGVEZWxpbWl0ZXIobG9jYWxlKTtcclxuICBpZiAoIWN1cnJlbnREZWxpbWl0ZXIpIHtcclxuICAgIHJldHVybiBsb2NhbGU7XHJcbiAgfVxyXG5cclxuICByZXR1cm4gbG9jYWxlLnJlcGxhY2UoY3VycmVudERlbGltaXRlciwgZGVsaW1pdGVyKTtcclxufTtcclxuXHJcbi8qKlxyXG4gKiBOb3JtYWxpemVzIGEgbG9jYWxlIHN0cmluZyBieSByZXBsYWNpbmcgdW5kZXJzY29yZXMgd2l0aCBoeXBoZW5zXHJcbiAqIGFuZCByZW1vdmluZyB0aGUgXCJyXCIgaW4gY2VydGFpbiByZWdpb25hbCBjb2RlcyAoZS5nLiwgXCJmci1yQ0FcIiBcdTIxOTIgXCJmci1DQVwiKVxyXG4gKlxyXG4gKiBAcGFyYW0ge3N0cmluZ30gbG9jYWxlIC0gVGhlIGxvY2FsZSBzdHJpbmcgKGUuZy4sXCJlbl9VU1wiLCBcImVuLUdCXCIpLlxyXG4gKiBAcmV0dXJuIHtzdHJpbmd9IFRoZSBub3JtYWxpemVkIGxvY2FsZSBzdHJpbmcuXHJcbiAqL1xyXG5cclxuZXhwb3J0IGZ1bmN0aW9uIG5vcm1hbGl6ZUxvY2FsZShsb2NhbGU6IHN0cmluZyk6IHN0cmluZyB7XHJcbiAgcmV0dXJuIGxvY2FsZS5yZXBsYWNlQWxsKFwiX1wiLCBcIi1cIikucmVwbGFjZSgvKFthLXpdezIsM30tKXIvLCBcIiQxXCIpO1xyXG59XHJcbiIsICJjb25zdCBfX2luamVjdGVkX2ZpbGVuYW1lX18gPSBcIkM6XFxcXFVzZXJzXFxcXFRVRiBHQU1JTkdcXFxcT25lRHJpdmVcXFxcRGVza3RvcFxcXFxsaW5nby5kZXYgb3BlbnNvdXJjZVxcXFxsaW5nby5kZXZcXFxccGFja2FnZXNcXFxcc3BlY1xcXFxzcmNcXFxcZm9ybWF0cy50c1wiO2NvbnN0IF9faW5qZWN0ZWRfZGlybmFtZV9fID0gXCJDOlxcXFxVc2Vyc1xcXFxUVUYgR0FNSU5HXFxcXE9uZURyaXZlXFxcXERlc2t0b3BcXFxcbGluZ28uZGV2IG9wZW5zb3VyY2VcXFxcbGluZ28uZGV2XFxcXHBhY2thZ2VzXFxcXHNwZWNcXFxcc3JjXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9DOi9Vc2Vycy9UVUYlMjBHQU1JTkcvT25lRHJpdmUvRGVza3RvcC9saW5nby5kZXYlMjBvcGVuc291cmNlL2xpbmdvLmRldi9wYWNrYWdlcy9zcGVjL3NyYy9mb3JtYXRzLnRzXCI7aW1wb3J0IFogZnJvbSBcInpvZFwiO1xyXG5cclxuZXhwb3J0IGNvbnN0IGJ1Y2tldFR5cGVzID0gW1xyXG4gIFwiYW5kcm9pZFwiLFxyXG4gIFwiY3N2XCIsXHJcbiAgXCJlanNcIixcclxuICBcImZsdXR0ZXJcIixcclxuICBcImh0bWxcIixcclxuICBcImpzb25cIixcclxuICBcImpzb241XCIsXHJcbiAgXCJqc29uY1wiLFxyXG4gIFwibWFya2Rvd25cIixcclxuICBcIm1hcmtkb2NcIixcclxuICBcIm1keFwiLFxyXG4gIFwieGNvZGUtc3RyaW5nc1wiLFxyXG4gIFwieGNvZGUtc3RyaW5nc2RpY3RcIixcclxuICBcInhjb2RlLXhjc3RyaW5nc1wiLFxyXG4gIFwieGNvZGUteGNzdHJpbmdzLXYyXCIsXHJcbiAgXCJ5YW1sXCIsXHJcbiAgXCJ5YW1sLXJvb3Qta2V5XCIsXHJcbiAgXCJwcm9wZXJ0aWVzXCIsXHJcbiAgXCJwb1wiLFxyXG4gIFwieGxpZmZcIixcclxuICBcInhtbFwiLFxyXG4gIFwic3J0XCIsXHJcbiAgXCJkYXRvXCIsXHJcbiAgXCJjb21waWxlclwiLFxyXG4gIFwidnR0XCIsXHJcbiAgXCJwaHBcIixcclxuICBcInBvXCIsXHJcbiAgXCJ2dWUtanNvblwiLFxyXG4gIFwidHlwZXNjcmlwdFwiLFxyXG4gIFwidHh0XCIsXHJcbiAgXCJqc29uLWRpY3Rpb25hcnlcIixcclxuXSBhcyBjb25zdDtcclxuXHJcbmV4cG9ydCBjb25zdCBidWNrZXRUeXBlU2NoZW1hID0gWi5lbnVtKGJ1Y2tldFR5cGVzKTtcclxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUF1WixTQUFTLG9CQUFvQjs7O0FDQWYsT0FBTyxRQUFRO0FBQ3BiLE9BQU8sVUFBVTtBQUNqQixTQUFTLHFCQUFxQjtBQUM5QixTQUFTLHVCQUF1Qjs7O0FDSDJYLE9BQU9BLFFBQU87OztBQ0FaLE9BQU8sT0FBTztBQUMzYSxTQUFTLHFCQUFxQjtBQUU5QixJQUFNLFlBQVk7QUFBQTtBQUFBLEVBRWhCLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUEsRUFDWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJO0FBQUEsSUFDRjtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsRUFDRjtBQUFBO0FBQUEsRUFFQSxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJO0FBQUEsSUFDRjtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsRUFDRjtBQUFBO0FBQUEsRUFFQSxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixLQUFLLENBQUMsUUFBUTtBQUFBO0FBQUEsRUFFZCxJQUFJO0FBQUEsSUFDRjtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsRUFDRjtBQUFBO0FBQUEsRUFFQSxJQUFJO0FBQUEsSUFDRjtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsRUFDRjtBQUFBO0FBQUEsRUFFQSxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixLQUFLLENBQUMsUUFBUTtBQUFBO0FBQUEsRUFFZCxLQUFLLENBQUMsUUFBUTtBQUFBO0FBQUEsRUFFZCxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsU0FBUztBQUFBO0FBQUEsRUFFZCxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixLQUFLLENBQUMsUUFBUTtBQUFBO0FBQUEsRUFFZCxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUNkO0FBT08sSUFBTSxtQkFBbUIsT0FBTyxLQUFLLFNBQVM7QUFDOUMsSUFBTSxrQkFBa0IsT0FBTztBQUFBLEVBQ3BDO0FBQ0YsRUFBRSxLQUFLO0FBQ0EsSUFBTSw0QkFBNEIsZ0JBQWdCO0FBQUEsRUFBSSxDQUFDLFVBQzVELE1BQU0sUUFBUSxLQUFLLEdBQUc7QUFDeEI7QUFDTyxJQUFNLGdDQUFnQyxnQkFBZ0IsSUFBSSxDQUFDLFVBQVU7QUFDMUUsUUFBTSxTQUFTLE1BQU0sTUFBTSxHQUFHO0FBQzlCLFFBQU0sU0FBUyxDQUFDLE9BQU8sQ0FBQyxHQUFHLE1BQU0sT0FBTyxNQUFNLENBQUMsRUFBRSxLQUFLLEdBQUcsQ0FBQyxFQUFFLEtBQUssRUFBRTtBQUNuRSxTQUFPO0FBQ1QsQ0FBQztBQUNNLElBQU0sY0FBYztBQUFBLEVBQ3pCLEdBQUc7QUFBQSxFQUNILEdBQUc7QUFBQSxFQUNILEdBQUc7QUFBQSxFQUNILEdBQUc7QUFDTDtBQUVPLElBQU0sbUJBQW1CLEVBQUUsT0FBTyxFQUFFO0FBQUEsRUFDekMsQ0FBQyxVQUFVO0FBRVQsVUFBTSxhQUFhLGdCQUFnQixLQUFLO0FBQ3hDLFdBQU8sY0FBYyxVQUFVO0FBQUEsRUFDakM7QUFBQSxFQUNBO0FBQUEsSUFDRSxTQUFTO0FBQUEsRUFDWDtBQUNGO0FBZ0ZPLFNBQVMsZ0JBQWdCLFFBQXdCO0FBQ3RELFNBQU8sT0FBTyxXQUFXLEtBQUssR0FBRyxFQUFFLFFBQVEsa0JBQWtCLElBQUk7QUFDbkU7OztBQ25WNlosT0FBT0MsUUFBTztBQUVwYSxJQUFNLGNBQWM7QUFBQSxFQUN6QjtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUNGO0FBRU8sSUFBTSxtQkFBbUJDLEdBQUUsS0FBSyxXQUFXOzs7QUYvQjNDLElBQU0sZUFBZUMsR0FBRSxPQUFPO0FBQUEsRUFDbkMsUUFBUSxpQkFBaUI7QUFBQSxJQUN2QjtBQUFBLEVBQ0Y7QUFBQSxFQUNBLFNBQVNBLEdBQUUsTUFBTSxnQkFBZ0IsRUFBRTtBQUFBLElBQ2pDO0FBQUEsRUFDRjtBQUNGLENBQUMsRUFBRSxTQUFTLDZCQUE2QjtBQVd6QyxJQUFNLHlCQUF5QixDQUk3QixlQUNHO0FBZ0JMLElBQU0seUJBQXlCLENBSTdCLFlBQ0EsV0FDRztBQUNILFFBQU0sU0FBUyxPQUFPLGFBQWEsV0FBVyxNQUFNO0FBQ3BELFFBQU0sZUFBZSxPQUFPLG1CQUFtQixXQUFXLFlBQVk7QUFDdEUsUUFBTSxXQUFXLENBQUMsV0FDaEIsT0FBTyxlQUFlLFFBQVEsUUFBUSxZQUFZO0FBRXBELFNBQU8sdUJBQXVCO0FBQUEsSUFDNUI7QUFBQSxJQUNBO0FBQUEsSUFDQSxPQUFPLENBQUMsY0FBYztBQUNwQixZQUFNLGFBQWEsT0FBTyxVQUFVLFNBQVM7QUFDN0MsVUFBSSxXQUFXLFNBQVM7QUFDdEIsZUFBTyxXQUFXO0FBQUEsTUFDcEI7QUFFQSxZQUFNLGVBQWUsV0FBVyxNQUFNLE9BQ25DLE9BQU8sQ0FBQyxVQUFVLE1BQU0sUUFBUSxTQUFTLHFCQUFxQixDQUFDLEVBQy9ELElBQUksQ0FBQyxVQUFVO0FBQ2QsWUFBSSxvQkFBb0I7QUFDeEIsY0FBTUMsUUFBTyxNQUFNO0FBRW5CLGNBQU0sU0FBUztBQUVmLFlBQUksT0FBTyxRQUFRO0FBQ2pCLDhCQUFvQkEsTUFBSyxPQUFZLENBQUMsS0FBSyxRQUFRO0FBQ2pELGdCQUFJLE9BQU8sT0FBTyxRQUFRLFlBQVksT0FBTyxLQUFLO0FBQ2hELHFCQUFPLElBQUksR0FBRztBQUFBLFlBQ2hCO0FBQ0EsbUJBQU87QUFBQSxVQUNULEdBQUcsT0FBTyxNQUFNO0FBQUEsUUFDbEI7QUFFQSxlQUFPLHVCQUF1QixpQkFBaUI7QUFBQSxNQUNqRCxDQUFDO0FBRUgsVUFBSSxhQUFhLFNBQVMsR0FBRztBQUMzQixjQUFNLElBQUksTUFBTTtBQUFBLEVBQUssYUFBYSxLQUFLLElBQUksQ0FBQyxFQUFFO0FBQUEsTUFDaEQ7QUFFQSxZQUFNLGFBQWEsV0FBVyxNQUFNLFNBQVM7QUFDN0MsWUFBTSxTQUFTLFNBQVMsVUFBVTtBQUNsQyxhQUFPO0FBQUEsSUFDVDtBQUFBLEVBQ0YsQ0FBQztBQUNIO0FBR0EsSUFBTSxpQkFBaUJELEdBQUUsT0FBTztBQUFBLEVBQzlCLFNBQVNBLEdBQUUsTUFBTSxDQUFDQSxHQUFFLE9BQU8sR0FBR0EsR0FBRSxPQUFPLENBQUMsQ0FBQyxFQUN0QyxRQUFRLENBQUMsRUFDVCxTQUFTLG1DQUFtQztBQUNqRCxDQUFDO0FBQ00sSUFBTSxxQkFBcUIsdUJBQXVCO0FBQUEsRUFDdkQsUUFBUTtBQUFBLEVBQ1IsY0FBYyxFQUFFLFNBQVMsRUFBRTtBQUFBLEVBQzNCLE9BQU8sQ0FBQyxjQUFjO0FBQ3BCLFdBQU8sZUFBZSxNQUFNLFNBQVM7QUFBQSxFQUN2QztBQUNGLENBQUM7QUFHTSxJQUFNLHFCQUFxQix1QkFBdUIsb0JBQW9CO0FBQUEsRUFDM0UsY0FBYyxDQUFDLGVBQ2IsV0FBVyxPQUFPO0FBQUEsSUFDaEIsUUFBUTtBQUFBLElBQ1IsU0FBU0EsR0FBRSxPQUFPQSxHQUFFLE9BQU8sR0FBRyxnQkFBZ0IsRUFDM0MsUUFBUSxDQUFDLENBQUMsRUFDVjtBQUFBLE1BQ0M7QUFBQSxJQUNGLEVBQ0MsU0FBUztBQUFBLEVBQ2QsQ0FBQztBQUFBLEVBQ0gsb0JBQW9CLE9BQU87QUFBQSxJQUN6QixTQUFTO0FBQUEsSUFDVCxRQUFRO0FBQUEsTUFDTixRQUFRO0FBQUEsTUFDUixTQUFTLENBQUMsSUFBYTtBQUFBLElBQ3pCO0FBQUEsSUFDQSxTQUFTLENBQUM7QUFBQSxFQUNaO0FBQUEsRUFDQSxnQkFBZ0IsT0FBTztBQUFBLElBQ3JCLFNBQVM7QUFBQSxJQUNULFFBQVE7QUFBQSxNQUNOLFFBQVE7QUFBQSxNQUNSLFNBQVMsQ0FBQyxJQUFhO0FBQUEsSUFDekI7QUFBQSxJQUNBLFNBQVMsQ0FBQztBQUFBLEVBQ1o7QUFDRixDQUFDO0FBR00sSUFBTSx1QkFBdUIsdUJBQXVCLG9CQUFvQjtBQUFBLEVBQzdFLGNBQWMsQ0FBQyxlQUNiLFdBQVcsT0FBTztBQUFBLElBQ2hCLFNBQVNBLEdBQUU7QUFBQSxNQUNUO0FBQUEsTUFDQUEsR0FBRSxPQUFPO0FBQUEsUUFDUCxTQUFTQSxHQUFFLE1BQU1BLEdBQUUsT0FBTyxDQUFDLEVBQ3hCLFFBQVEsQ0FBQyxDQUFDLEVBQ1Y7QUFBQSxVQUNDO0FBQUEsUUFDRjtBQUFBLFFBQ0YsU0FBU0EsR0FBRSxNQUFNQSxHQUFFLE9BQU8sQ0FBQyxFQUN4QixRQUFRLENBQUMsQ0FBQyxFQUNWLFNBQVMsRUFDVDtBQUFBLFVBQ0M7QUFBQSxRQUNGO0FBQUEsTUFDSixDQUFDO0FBQUEsSUFDSCxFQUFFLFFBQVEsQ0FBQyxDQUFDO0FBQUEsRUFDZCxDQUFDO0FBQUEsRUFDSCxvQkFBb0IsQ0FBQyxzQkFBc0I7QUFBQSxJQUN6QyxHQUFHO0FBQUEsSUFDSCxTQUFTO0FBQUEsSUFDVCxTQUFTLENBQUM7QUFBQSxFQUNaO0FBQUEsRUFDQSxnQkFBZ0IsQ0FBQyxXQUFXLFdBQVc7QUFDckMsVUFBTSxpQkFBeUM7QUFBQSxNQUM3QyxHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsTUFDVCxTQUFTLENBQUM7QUFBQSxJQUNaO0FBR0EsUUFBSSxVQUFVLFNBQVM7QUFDckIsaUJBQVcsQ0FBQyxZQUFZLFVBQVUsS0FBSyxPQUFPO0FBQUEsUUFDNUMsVUFBVTtBQUFBLE1BQ1osR0FBRztBQUNELFlBQUksQ0FBQyxlQUFlLFFBQVEsVUFBVSxHQUFHO0FBQ3ZDLHlCQUFlLFFBQVEsVUFBVSxJQUFJO0FBQUEsWUFDbkMsU0FBUyxDQUFDO0FBQUEsVUFDWjtBQUFBLFFBQ0Y7QUFDQSx1QkFBZSxRQUFRLFVBQVUsR0FBRyxRQUFRLEtBQUssVUFBVTtBQUFBLE1BQzdEO0FBQUEsSUFDRjtBQUVBLFdBQU87QUFBQSxFQUNUO0FBQ0YsQ0FBQztBQUlNLElBQU0sdUJBQXVCO0FBQUEsRUFDbEM7QUFBQSxFQUNBO0FBQUEsSUFDRSxjQUFjLENBQUMsZUFDYixXQUFXLE9BQU87QUFBQSxNQUNoQixRQUFRLGFBQWEsT0FBTztBQUFBLFFBQzFCLGFBQWEsaUJBQ1YsU0FBUyxFQUNUO0FBQUEsVUFDQztBQUFBLFFBQ0Y7QUFBQSxNQUNKLENBQUM7QUFBQSxJQUNILENBQUM7QUFBQSxJQUNILG9CQUFvQixDQUFDLHNCQUFzQjtBQUFBLE1BQ3pDLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsSUFDQSxnQkFBZ0IsQ0FBQyxlQUFlO0FBQUEsTUFDOUIsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxFQUNGO0FBQ0Y7QUFJTyxJQUFNLG1CQUFtQkEsR0FBRSxPQUFPO0FBQUEsRUFDdkMsTUFBTUEsR0FBRSxPQUFPLEVBQUUsU0FBUyxpREFBaUQ7QUFBQSxFQUMzRSxXQUFXQSxHQUFFLE1BQU0sQ0FBQ0EsR0FBRSxRQUFRLEdBQUcsR0FBR0EsR0FBRSxRQUFRLEdBQUcsR0FBR0EsR0FBRSxRQUFRLElBQUksQ0FBQyxDQUFDLEVBQ2pFLFNBQVMsRUFDVDtBQUFBLElBQ0M7QUFBQSxFQUNGO0FBQ0osQ0FBQyxFQUFFO0FBQUEsRUFDRDtBQUNGO0FBSU8sSUFBTSx3QkFBd0JBLEdBQUUsT0FBTztBQUFBLEVBQzVDLFNBQVNBLEdBQUUsTUFBTUEsR0FBRSxNQUFNLENBQUNBLEdBQUUsT0FBTyxHQUFHLGdCQUFnQixDQUFDLENBQUMsRUFDckQsUUFBUSxDQUFDLENBQUMsRUFDVixTQUFTLDJEQUEyRDtBQUFBLEVBQ3ZFLFNBQVNBLEdBQUUsTUFBTUEsR0FBRSxNQUFNLENBQUNBLEdBQUUsT0FBTyxHQUFHLGdCQUFnQixDQUFDLENBQUMsRUFDckQsUUFBUSxDQUFDLENBQUMsRUFDVixTQUFTLEVBQ1QsU0FBUyw0REFBNEQ7QUFBQSxFQUN4RSxjQUFjQSxHQUFFLE1BQU1BLEdBQUUsT0FBTyxDQUFDLEVBQzdCLFNBQVMsRUFDVDtBQUFBLElBQ0M7QUFBQSxFQUNGO0FBQ0osQ0FBQyxFQUFFLFNBQVMsaURBQWlEO0FBRXRELElBQU0sdUJBQXVCO0FBQUEsRUFDbEM7QUFBQSxFQUNBO0FBQUEsSUFDRSxjQUFjLENBQUMsZUFDYixXQUFXLE9BQU87QUFBQSxNQUNoQixTQUFTQSxHQUFFLE9BQU8sa0JBQWtCLHFCQUFxQixFQUFFLFFBQVEsQ0FBQyxDQUFDO0FBQUEsSUFDdkUsQ0FBQztBQUFBLElBQ0gsb0JBQW9CLENBQUMsc0JBQXNCO0FBQUEsTUFDekMsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxJQUNBLGdCQUFnQixDQUFDLGVBQWU7QUFBQSxNQUM5QixHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsSUFDWDtBQUFBLEVBQ0Y7QUFDRjtBQUVBLElBQU0sZUFBZTtBQUlkLElBQU0sdUJBQXVCO0FBQUEsRUFDbEM7QUFBQSxFQUNBO0FBQUEsSUFDRSxjQUFjLENBQUMsZUFDYixXQUFXLE9BQU87QUFBQSxNQUNoQixTQUFTQSxHQUFFLE9BQU8sRUFBRSxRQUFRLFlBQVk7QUFBQSxJQUMxQyxDQUFDO0FBQUEsSUFDSCxvQkFBb0IsQ0FBQyxzQkFBc0I7QUFBQSxNQUN6QyxHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsTUFDVCxTQUFTO0FBQUEsSUFDWDtBQUFBLElBQ0EsZ0JBQWdCLENBQUMsZUFBZTtBQUFBLE1BQzlCLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxNQUNULFNBQVM7QUFBQSxJQUNYO0FBQUEsRUFDRjtBQUNGO0FBSUEsSUFBTSxpQkFBaUJBLEdBQUUsT0FBTztBQUFBLEVBQzlCLElBQUlBLEdBQUUsS0FBSztBQUFBLElBQ1Q7QUFBQSxJQUNBO0FBQUEsSUFDQTtBQUFBLElBQ0E7QUFBQSxJQUNBO0FBQUEsSUFDQTtBQUFBLEVBQ0YsQ0FBQyxFQUFFLFNBQVMsaURBQWlEO0FBQUEsRUFDN0QsT0FBT0EsR0FBRSxPQUFPLEVBQUUsU0FBUyxxQ0FBcUM7QUFBQSxFQUNoRSxRQUFRQSxHQUFFLE9BQU8sRUFBRTtBQUFBLElBQ2pCO0FBQUEsRUFDRjtBQUFBLEVBQ0EsU0FBU0EsR0FBRSxPQUFPLEVBQ2YsU0FBUyxFQUNULFNBQVMsa0RBQWtEO0FBQ2hFLENBQUMsRUFBRSxTQUFTLHFEQUFxRDtBQUMxRCxJQUFNLHVCQUF1QjtBQUFBLEVBQ2xDO0FBQUEsRUFDQTtBQUFBLElBQ0UsY0FBYyxDQUFDLGVBQ2IsV0FBVyxPQUFPO0FBQUEsTUFDaEIsVUFBVSxlQUFlLFNBQVM7QUFBQSxJQUNwQyxDQUFDO0FBQUEsSUFDSCxvQkFBb0IsQ0FBQyxzQkFBc0I7QUFBQSxNQUN6QyxHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsSUFDWDtBQUFBLElBQ0EsZ0JBQWdCLENBQUMsZUFBZTtBQUFBLE1BQzlCLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsRUFDRjtBQUNGO0FBSU8sSUFBTSx3QkFBd0Isc0JBQXNCLE9BQU87QUFBQSxFQUNoRSxZQUFZQSxHQUFFLE1BQU1BLEdBQUUsT0FBTyxDQUFDLEVBQzNCLFFBQVEsQ0FBQyxDQUFDLEVBQ1YsU0FBUyxFQUNUO0FBQUEsSUFDQztBQUFBLEVBQ0Y7QUFDSixDQUFDO0FBRU0sSUFBTSx1QkFBdUI7QUFBQSxFQUNsQztBQUFBLEVBQ0E7QUFBQSxJQUNFLGNBQWMsQ0FBQyxlQUNiLFdBQVcsT0FBTztBQUFBLE1BQ2hCLFNBQVNBLEdBQUUsT0FBTyxrQkFBa0IscUJBQXFCLEVBQUUsUUFBUSxDQUFDLENBQUM7QUFBQSxJQUN2RSxDQUFDO0FBQUEsSUFDSCxvQkFBb0IsQ0FBQyxzQkFBc0I7QUFBQSxNQUN6QyxHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsSUFDWDtBQUFBLElBQ0EsZ0JBQWdCLENBQUMsZUFBZTtBQUFBLE1BQzlCLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsRUFDRjtBQUNGO0FBR08sSUFBTSx3QkFBd0Isc0JBQXNCLE9BQU87QUFBQSxFQUNoRSxnQkFBZ0JBLEdBQUUsTUFBTUEsR0FBRSxPQUFPLENBQUMsRUFDL0IsUUFBUSxDQUFDLENBQUMsRUFDVixTQUFTLEVBQ1Q7QUFBQSxJQUNDO0FBQUEsRUFDRjtBQUNKLENBQUM7QUFFTSxJQUFNLHVCQUF1QjtBQUFBLEVBQ2xDO0FBQUEsRUFDQTtBQUFBLElBQ0UsY0FBYyxDQUFDLGVBQ2IsV0FBVyxPQUFPO0FBQUEsTUFDaEIsU0FBU0EsR0FBRSxPQUFPLGtCQUFrQixxQkFBcUIsRUFBRSxRQUFRLENBQUMsQ0FBQztBQUFBLElBQ3ZFLENBQUM7QUFBQSxJQUNILG9CQUFvQixDQUFDLHNCQUFzQjtBQUFBLE1BQ3pDLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsSUFDQSxnQkFBZ0IsQ0FBQyxlQUFlO0FBQUEsTUFDOUIsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxFQUNGO0FBQ0Y7QUFJTyxJQUFNLHdCQUF3QixzQkFBc0IsT0FBTztBQUFBLEVBQ2hFLGFBQWFBLEdBQUUsTUFBTUEsR0FBRSxPQUFPLENBQUMsRUFDNUIsUUFBUSxDQUFDLENBQUMsRUFDVixTQUFTLEVBQ1Q7QUFBQSxJQUNDO0FBQUEsRUFDRjtBQUNKLENBQUM7QUFFTSxJQUFNLHVCQUF1QjtBQUFBLEVBQ2xDO0FBQUEsRUFDQTtBQUFBLElBQ0UsY0FBYyxDQUFDLGVBQ2IsV0FBVyxPQUFPO0FBQUEsTUFDaEIsU0FBU0EsR0FBRSxPQUFPLGtCQUFrQixxQkFBcUIsRUFBRSxRQUFRLENBQUMsQ0FBQztBQUFBLElBQ3ZFLENBQUM7QUFBQSxJQUNILG9CQUFvQixDQUFDLHNCQUFzQjtBQUFBLE1BQ3pDLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsSUFDQSxnQkFBZ0IsQ0FBQyxlQUFlO0FBQUEsTUFDOUIsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxFQUNGO0FBQ0Y7QUFJTyxJQUFNLHVCQUF1QjtBQUFBLEVBQ2xDO0FBQUEsRUFDQTtBQUFBLElBQ0UsY0FBYyxDQUFDLGVBQ2IsV0FBVyxPQUFPO0FBQUEsTUFDaEIsV0FBV0EsR0FBRSxLQUFLLENBQUMsWUFBWSxPQUFPLENBQUMsRUFDcEMsU0FBUyxFQUNUO0FBQUEsUUFDQztBQUFBLE1BQ0Y7QUFBQSxJQUNKLENBQUM7QUFBQSxJQUNILG9CQUFvQixDQUFDLHNCQUFzQjtBQUFBLE1BQ3pDLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsSUFDQSxnQkFBZ0IsQ0FBQyxlQUFlO0FBQUEsTUFDOUIsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxFQUNGO0FBQ0Y7QUFJQSxJQUFNLHNCQUFzQkEsR0FBRSxPQUFPO0FBQUEsRUFDbkMsYUFBYUEsR0FBRSxPQUFPLEVBQ25CLElBQUksQ0FBQyxFQUNMLElBQUksQ0FBQyxFQUNMLFNBQVMsRUFDVDtBQUFBLElBQ0M7QUFBQSxFQUNGO0FBQ0osQ0FBQyxFQUNFLFNBQVMsRUFDVCxTQUFTLG1EQUFtRDtBQUUvRCxJQUFNLHNCQUFzQkEsR0FBRSxPQUFPO0FBQUEsRUFDbkMsSUFBSUEsR0FBRSxLQUFLO0FBQUEsSUFDVDtBQUFBLElBQ0E7QUFBQSxJQUNBO0FBQUEsSUFDQTtBQUFBLElBQ0E7QUFBQSxJQUNBO0FBQUEsRUFDRixDQUFDLEVBQUUsU0FBUyxpREFBaUQ7QUFBQSxFQUM3RCxPQUFPQSxHQUFFLE9BQU8sRUFBRSxTQUFTLHFDQUFxQztBQUFBLEVBQ2hFLFFBQVFBLEdBQUUsT0FBTyxFQUFFO0FBQUEsSUFDakI7QUFBQSxFQUNGO0FBQUEsRUFDQSxTQUFTQSxHQUFFLE9BQU8sRUFDZixTQUFTLEVBQ1QsU0FBUyxrREFBa0Q7QUFBQSxFQUM5RCxVQUFVO0FBQ1osQ0FBQyxFQUFFLFNBQVMscURBQXFEO0FBRTFELElBQU0sd0JBQXdCO0FBQUEsRUFDbkM7QUFBQSxFQUNBO0FBQUEsSUFDRSxjQUFjLENBQUMsZUFDYixXQUFXLE9BQU87QUFBQSxNQUNoQixVQUFVLG9CQUFvQixTQUFTO0FBQUEsSUFDekMsQ0FBQztBQUFBLElBQ0gsb0JBQW9CLENBQUMsc0JBQXNCO0FBQUEsTUFDekMsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxJQUNBLGdCQUFnQixDQUFDLGVBQWU7QUFBQSxNQUM5QixHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsSUFDWDtBQUFBLEVBQ0Y7QUFDRjtBQUdPLElBQU0sMkJBQTJCO0FBYWpDLElBQU0sZ0JBQWdCLHlCQUF5Qjs7O0FEcmZ1TixJQUFNLCtCQUErQjtBQU1uUyxTQUFSLGtCQUFtQztBQUN4QyxRQUFNRSxnQkFBZSxnQkFBZ0IseUJBQXlCLE1BQU07QUFDcEUsUUFBTSxhQUFhLEtBQUssUUFBUSxjQUFjLDRCQUFlLENBQUM7QUFDOUQsS0FBRztBQUFBLElBQ0QsR0FBRyxVQUFVO0FBQUEsSUFDYixLQUFLLFVBQVVBLGVBQWMsTUFBTSxDQUFDO0FBQUEsRUFDdEM7QUFDRjs7O0FEVkEsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsT0FBTztBQUFBLEVBQ1AsUUFBUTtBQUFBLEVBQ1IsT0FBTyxDQUFDLGNBQWM7QUFBQSxFQUN0QixRQUFRO0FBQUEsRUFDUixRQUFRLENBQUMsT0FBTyxLQUFLO0FBQUEsRUFDckIsS0FBSztBQUFBLEVBQ0wsWUFBWTtBQUFBLEVBQ1osV0FBVztBQUFBLEVBQ1gsY0FBYyxDQUFDLFNBQVM7QUFBQSxJQUN0QixJQUFJLElBQUksV0FBVyxRQUFRLFNBQVM7QUFBQSxFQUN0QztBQUFBLEVBQ0EsV0FBVyxZQUFZO0FBQ3JCLG9CQUFnQjtBQUFBLEVBQ2xCO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFsiWiIsICJaIiwgIloiLCAiWiIsICJwYXRoIiwgImNvbmZpZ1NjaGVtYSJdCn0K diff --git a/packages/spec/tsup.config.bundled_sgm3hgr33pq.mjs b/packages/spec/tsup.config.bundled_sgm3hgr33pq.mjs new file mode 100644 index 000000000..bfe190232 --- /dev/null +++ b/packages/spec/tsup.config.bundled_sgm3hgr33pq.mjs @@ -0,0 +1,763 @@ +// tsup.config.ts +import { defineConfig } from "tsup"; + +// src/json-schema.ts +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { zodToJsonSchema } from "zod-to-json-schema"; + +// src/config.ts +import Z3 from "zod"; + +// src/locales.ts +import Z from "zod"; +import { isValidLocale } from "@lingo.dev/_locales"; +var 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"], +}; +var localeCodesShort = Object.keys(localeMap); +var localeCodesFull = Object.values(localeMap).flat(); +var localeCodesFullUnderscore = localeCodesFull.map((value) => + value.replace("-", "_"), +); +var localeCodesFullExplicitRegion = localeCodesFull.map((value) => { + const chunks = value.split("-"); + const result = [chunks[0], "-r", chunks.slice(1).join("-")].join(""); + return result; +}); +var localeCodes = [ + ...localeCodesShort, + ...localeCodesFull, + ...localeCodesFullUnderscore, + ...localeCodesFullExplicitRegion, +]; +var localeCodeSchema = Z.string().refine( + (value) => { + const normalized = normalizeLocale(value); + return isValidLocale(normalized); + }, + { + message: "Invalid locale code", + }, +); +function normalizeLocale(locale) { + return locale.replaceAll("_", "-").replace(/([a-z]{2,3}-)r/, "$1"); +} + +// src/formats.ts +import Z2 from "zod"; +var bucketTypes = [ + "android", + "csv", + "ejs", + "flutter", + "html", + "json", + "json5", + "jsonc", + "markdown", + "markdoc", + "mdx", + "xcode-strings", + "xcode-stringsdict", + "xcode-xcstrings", + "xcode-xcstrings-v2", + "yaml", + "yaml-root-key", + "properties", + "po", + "xliff", + "xml", + "srt", + "dato", + "compiler", + "vtt", + "php", + "po", + "vue-json", + "typescript", + "txt", + "json-dictionary", +]; +var bucketTypeSchema = Z2.enum(bucketTypes); + +// src/config.ts +var localeSchema = Z3.object({ + source: localeCodeSchema.describe( + "Primary source locale code of your content (e.g. 'en', 'en-US', 'pt_BR', or 'pt-rBR'). Must be one of the supported locale codes \u2013 either a short ISO-639 language code or a full locale identifier using '-', '_' or Android '-r' notation.", + ), + targets: Z3.array(localeCodeSchema).describe( + "List of target locale codes to translate to.", + ), +}).describe("Locale configuration block."); +var createConfigDefinition = (definition) => definition; +var extendConfigDefinition = (definition, params) => { + const schema = params.createSchema(definition.schema); + const defaultValue = params.createDefaultValue(definition.defaultValue); + const upgrader = (config) => + params.createUpgrader(config, schema, defaultValue); + return createConfigDefinition({ + schema, + defaultValue, + parse: (rawConfig) => { + const safeResult = schema.safeParse(rawConfig); + if (safeResult.success) { + return safeResult.data; + } + const localeErrors = safeResult.error.errors + .filter((issue) => issue.message.includes("Invalid locale code")) + .map((issue) => { + let unsupportedLocale = ""; + const path2 = issue.path; + const config = rawConfig; + if (config.locale) { + unsupportedLocale = path2.reduce((acc, key) => { + if (acc && typeof acc === "object" && key in acc) { + return acc[key]; + } + return acc; + }, config.locale); + } + return `Unsupported locale: ${unsupportedLocale}`; + }); + if (localeErrors.length > 0) { + throw new Error(` +${localeErrors.join("\n")}`); + } + const baseConfig = definition.parse(rawConfig); + const result = upgrader(baseConfig); + return result; + }, + }); +}; +var configV0Schema = Z3.object({ + version: Z3.union([Z3.number(), Z3.string()]) + .default(0) + .describe("The version number of the schema."), +}); +var configV0Definition = createConfigDefinition({ + schema: configV0Schema, + defaultValue: { version: 0 }, + parse: (rawConfig) => { + return configV0Schema.parse(rawConfig); + }, +}); +var configV1Definition = extendConfigDefinition(configV0Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + locale: localeSchema, + buckets: Z3.record(Z3.string(), bucketTypeSchema) + .default({}) + .describe( + "Mapping of source file paths (glob patterns) to bucket types.", + ) + .optional(), + }), + createDefaultValue: () => ({ + version: 1, + locale: { + source: "en", + targets: ["es"], + }, + buckets: {}, + }), + createUpgrader: () => ({ + version: 1, + locale: { + source: "en", + targets: ["es"], + }, + buckets: {}, + }), +}); +var configV1_1Definition = extendConfigDefinition(configV1Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z3.record( + bucketTypeSchema, + Z3.object({ + include: Z3.array(Z3.string()) + .default([]) + .describe( + "File paths or glob patterns to include for this bucket.", + ), + exclude: Z3.array(Z3.string()) + .default([]) + .optional() + .describe( + "File paths or glob patterns to exclude from this bucket.", + ), + }), + ).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.1, + buckets: {}, + }), + createUpgrader: (oldConfig, schema) => { + const upgradedConfig = { + ...oldConfig, + version: 1.1, + buckets: {}, + }; + if (oldConfig.buckets) { + for (const [bucketPath, bucketType] of Object.entries( + oldConfig.buckets, + )) { + if (!upgradedConfig.buckets[bucketType]) { + upgradedConfig.buckets[bucketType] = { + include: [], + }; + } + upgradedConfig.buckets[bucketType]?.include.push(bucketPath); + } + } + return upgradedConfig; + }, +}); +var configV1_2Definition = extendConfigDefinition(configV1_1Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + locale: localeSchema.extend({ + extraSource: localeCodeSchema + .optional() + .describe( + "Optional extra source locale code used as fallback during translation.", + ), + }), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.2, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.2, + }), +}); +var bucketItemSchema = Z3.object({ + path: Z3.string().describe("Path pattern containing a [locale] placeholder."), + delimiter: Z3.union([Z3.literal("-"), Z3.literal("_"), Z3.literal(null)]) + .optional() + .describe( + "Delimiter that replaces the [locale] placeholder in the path (default: no delimiter).", + ), +}).describe( + "Bucket path item. Either a string path or an object specifying path and delimiter.", +); +var bucketValueSchemaV1_3 = Z3.object({ + include: Z3.array(Z3.union([Z3.string(), bucketItemSchema])) + .default([]) + .describe("Glob patterns or bucket items to include for this bucket."), + exclude: Z3.array(Z3.union([Z3.string(), bucketItemSchema])) + .default([]) + .optional() + .describe("Glob patterns or bucket items to exclude from this bucket."), + injectLocale: Z3.array(Z3.string()) + .optional() + .describe( + "Keys within files where the current locale should be injected or removed.", + ), +}).describe("Configuration options for a translation bucket."); +var configV1_3Definition = extendConfigDefinition(configV1_2Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z3.record(bucketTypeSchema, bucketValueSchemaV1_3).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.3, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.3, + }), +}); +var configSchema = "https://lingo.dev/schema/i18n.json"; +var configV1_4Definition = extendConfigDefinition(configV1_3Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + $schema: Z3.string().default(configSchema), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.4, + $schema: configSchema, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.4, + $schema: configSchema, + }), +}); +var providerSchema = Z3.object({ + id: Z3.enum([ + "openai", + "anthropic", + "google", + "ollama", + "openrouter", + "mistral", + ]).describe("Identifier of the translation provider service."), + model: Z3.string().describe("Model name to use for translations."), + prompt: Z3.string().describe( + "Prompt template used when requesting translations.", + ), + baseUrl: Z3.string() + .optional() + .describe("Custom base URL for the provider API (optional)."), +}).describe("Configuration for the machine-translation provider."); +var configV1_5Definition = extendConfigDefinition(configV1_4Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + provider: providerSchema.optional(), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.5, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.5, + }), +}); +var bucketValueSchemaV1_6 = bucketValueSchemaV1_3.extend({ + lockedKeys: Z3.array(Z3.string()) + .default([]) + .optional() + .describe( + "Keys that must remain unchanged and should never be overwritten by translations.", + ), +}); +var configV1_6Definition = extendConfigDefinition(configV1_5Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z3.record(bucketTypeSchema, bucketValueSchemaV1_6).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.6, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.6, + }), +}); +var bucketValueSchemaV1_7 = bucketValueSchemaV1_6.extend({ + lockedPatterns: Z3.array(Z3.string()) + .default([]) + .optional() + .describe( + "Regular expression patterns whose matched content should remain locked during translation.", + ), +}); +var configV1_7Definition = extendConfigDefinition(configV1_6Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z3.record(bucketTypeSchema, bucketValueSchemaV1_7).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.7, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.7, + }), +}); +var bucketValueSchemaV1_8 = bucketValueSchemaV1_7.extend({ + ignoredKeys: Z3.array(Z3.string()) + .default([]) + .optional() + .describe( + "Keys that should be completely ignored by translation processes.", + ), +}); +var configV1_8Definition = extendConfigDefinition(configV1_7Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + buckets: Z3.record(bucketTypeSchema, bucketValueSchemaV1_8).default({}), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.8, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.8, + }), +}); +var configV1_9Definition = extendConfigDefinition(configV1_8Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + formatter: Z3.enum(["prettier", "biome"]) + .optional() + .describe( + "Code formatter to use for all buckets. Defaults to 'prettier' if not specified and a prettier config is found.", + ), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: 1.9, + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: 1.9, + }), +}); +var modelSettingsSchema = Z3.object({ + temperature: Z3.number() + .min(0) + .max(2) + .optional() + .describe( + "Controls randomness in model outputs (0=deterministic, 2=very random). Some models like GPT-5 require temperature=1.", + ), +}) + .optional() + .describe("Model-specific settings for translation requests."); +var providerSchemaV1_10 = Z3.object({ + id: Z3.enum([ + "openai", + "anthropic", + "google", + "ollama", + "openrouter", + "mistral", + ]).describe("Identifier of the translation provider service."), + model: Z3.string().describe("Model name to use for translations."), + prompt: Z3.string().describe( + "Prompt template used when requesting translations.", + ), + baseUrl: Z3.string() + .optional() + .describe("Custom base URL for the provider API (optional)."), + settings: modelSettingsSchema, +}).describe("Configuration for the machine-translation provider."); +var configV1_10Definition = extendConfigDefinition(configV1_9Definition, { + createSchema: (baseSchema) => + baseSchema.extend({ + provider: providerSchemaV1_10.optional(), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: "1.10", + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: "1.10", + }), +}); +var LATEST_CONFIG_DEFINITION = configV1_10Definition; +var defaultConfig = LATEST_CONFIG_DEFINITION.defaultValue; + +// src/json-schema.ts +var __injected_import_meta_url__ = + "file:///C:/Users/TUF%20GAMING/OneDrive/Desktop/lingo.dev%20opensource/lingo.dev/packages/spec/src/json-schema.ts"; +function buildJsonSchema() { + const configSchema2 = zodToJsonSchema(LATEST_CONFIG_DEFINITION.schema); + const currentDir = path.dirname(fileURLToPath(__injected_import_meta_url__)); + fs.writeFileSync( + `${currentDir}/../build/i18n.schema.json`, + JSON.stringify(configSchema2, null, 2), + ); +} + +// tsup.config.ts +var tsup_config_default = defineConfig({ + clean: true, + target: "esnext", + entry: ["src/index.ts"], + outDir: "build", + format: ["cjs", "esm"], + dts: true, + cjsInterop: true, + splitting: true, + outExtension: (ctx) => ({ + js: ctx.format === "cjs" ? ".cjs" : ".mjs", + }), + onSuccess: async () => { + buildJsonSchema(); + }, +}); +export { tsup_config_default as default }; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidHN1cC5jb25maWcudHMiLCAic3JjL2pzb24tc2NoZW1hLnRzIiwgInNyYy9jb25maWcudHMiLCAic3JjL2xvY2FsZXMudHMiLCAic3JjL2Zvcm1hdHMudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9faW5qZWN0ZWRfZmlsZW5hbWVfXyA9IFwiQzpcXFxcVXNlcnNcXFxcVFVGIEdBTUlOR1xcXFxPbmVEcml2ZVxcXFxEZXNrdG9wXFxcXGxpbmdvLmRldiBvcGVuc291cmNlXFxcXGxpbmdvLmRldlxcXFxwYWNrYWdlc1xcXFxzcGVjXFxcXHRzdXAuY29uZmlnLnRzXCI7Y29uc3QgX19pbmplY3RlZF9kaXJuYW1lX18gPSBcIkM6XFxcXFVzZXJzXFxcXFRVRiBHQU1JTkdcXFxcT25lRHJpdmVcXFxcRGVza3RvcFxcXFxsaW5nby5kZXYgb3BlbnNvdXJjZVxcXFxsaW5nby5kZXZcXFxccGFja2FnZXNcXFxcc3BlY1wiO2NvbnN0IF9faW5qZWN0ZWRfaW1wb3J0X21ldGFfdXJsX18gPSBcImZpbGU6Ly8vQzovVXNlcnMvVFVGJTIwR0FNSU5HL09uZURyaXZlL0Rlc2t0b3AvbGluZ28uZGV2JTIwb3BlbnNvdXJjZS9saW5nby5kZXYvcGFja2FnZXMvc3BlYy90c3VwLmNvbmZpZy50c1wiO2ltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gXCJ0c3VwXCI7XHJcbmltcG9ydCBidWlsZEpzb25TY2hlbWEgZnJvbSBcIi4vc3JjL2pzb24tc2NoZW1hXCI7XHJcblxyXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xyXG4gIGNsZWFuOiB0cnVlLFxyXG4gIHRhcmdldDogXCJlc25leHRcIixcclxuICBlbnRyeTogW1wic3JjL2luZGV4LnRzXCJdLFxyXG4gIG91dERpcjogXCJidWlsZFwiLFxyXG4gIGZvcm1hdDogW1wiY2pzXCIsIFwiZXNtXCJdLFxyXG4gIGR0czogdHJ1ZSxcclxuICBjanNJbnRlcm9wOiB0cnVlLFxyXG4gIHNwbGl0dGluZzogdHJ1ZSxcclxuICBvdXRFeHRlbnNpb246IChjdHgpID0+ICh7XHJcbiAgICBqczogY3R4LmZvcm1hdCA9PT0gXCJjanNcIiA/IFwiLmNqc1wiIDogXCIubWpzXCIsXHJcbiAgfSksXHJcbiAgb25TdWNjZXNzOiBhc3luYyAoKSA9PiB7XHJcbiAgICBidWlsZEpzb25TY2hlbWEoKTtcclxuICB9LFxyXG59KTtcclxuIiwgImNvbnN0IF9faW5qZWN0ZWRfZmlsZW5hbWVfXyA9IFwiQzpcXFxcVXNlcnNcXFxcVFVGIEdBTUlOR1xcXFxPbmVEcml2ZVxcXFxEZXNrdG9wXFxcXGxpbmdvLmRldiBvcGVuc291cmNlXFxcXGxpbmdvLmRldlxcXFxwYWNrYWdlc1xcXFxzcGVjXFxcXHNyY1xcXFxqc29uLXNjaGVtYS50c1wiO2NvbnN0IF9faW5qZWN0ZWRfZGlybmFtZV9fID0gXCJDOlxcXFxVc2Vyc1xcXFxUVUYgR0FNSU5HXFxcXE9uZURyaXZlXFxcXERlc2t0b3BcXFxcbGluZ28uZGV2IG9wZW5zb3VyY2VcXFxcbGluZ28uZGV2XFxcXHBhY2thZ2VzXFxcXHNwZWNcXFxcc3JjXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9DOi9Vc2Vycy9UVUYlMjBHQU1JTkcvT25lRHJpdmUvRGVza3RvcC9saW5nby5kZXYlMjBvcGVuc291cmNlL2xpbmdvLmRldi9wYWNrYWdlcy9zcGVjL3NyYy9qc29uLXNjaGVtYS50c1wiO2ltcG9ydCBmcyBmcm9tIFwiZnNcIjtcclxuaW1wb3J0IHBhdGggZnJvbSBcInBhdGhcIjtcclxuaW1wb3J0IHsgZmlsZVVSTFRvUGF0aCB9IGZyb20gXCJ1cmxcIjtcclxuaW1wb3J0IHsgem9kVG9Kc29uU2NoZW1hIH0gZnJvbSBcInpvZC10by1qc29uLXNjaGVtYVwiO1xyXG5pbXBvcnQgeyBMQVRFU1RfQ09ORklHX0RFRklOSVRJT04gfSBmcm9tIFwiLi9jb25maWdcIjtcclxuXHJcbmV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIGJ1aWxkSnNvblNjaGVtYSgpIHtcclxuICBjb25zdCBjb25maWdTY2hlbWEgPSB6b2RUb0pzb25TY2hlbWEoTEFURVNUX0NPTkZJR19ERUZJTklUSU9OLnNjaGVtYSk7XHJcbiAgY29uc3QgY3VycmVudERpciA9IHBhdGguZGlybmFtZShmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCkpO1xyXG4gIGZzLndyaXRlRmlsZVN5bmMoXHJcbiAgICBgJHtjdXJyZW50RGlyfS8uLi9idWlsZC9pMThuLnNjaGVtYS5qc29uYCxcclxuICAgIEpTT04uc3RyaW5naWZ5KGNvbmZpZ1NjaGVtYSwgbnVsbCwgMiksXHJcbiAgKTtcclxufVxyXG4iLCAiY29uc3QgX19pbmplY3RlZF9maWxlbmFtZV9fID0gXCJDOlxcXFxVc2Vyc1xcXFxUVUYgR0FNSU5HXFxcXE9uZURyaXZlXFxcXERlc2t0b3BcXFxcbGluZ28uZGV2IG9wZW5zb3VyY2VcXFxcbGluZ28uZGV2XFxcXHBhY2thZ2VzXFxcXHNwZWNcXFxcc3JjXFxcXGNvbmZpZy50c1wiO2NvbnN0IF9faW5qZWN0ZWRfZGlybmFtZV9fID0gXCJDOlxcXFxVc2Vyc1xcXFxUVUYgR0FNSU5HXFxcXE9uZURyaXZlXFxcXERlc2t0b3BcXFxcbGluZ28uZGV2IG9wZW5zb3VyY2VcXFxcbGluZ28uZGV2XFxcXHBhY2thZ2VzXFxcXHNwZWNcXFxcc3JjXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9DOi9Vc2Vycy9UVUYlMjBHQU1JTkcvT25lRHJpdmUvRGVza3RvcC9saW5nby5kZXYlMjBvcGVuc291cmNlL2xpbmdvLmRldi9wYWNrYWdlcy9zcGVjL3NyYy9jb25maWcudHNcIjtpbXBvcnQgWiBmcm9tIFwiem9kXCI7XHJcbmltcG9ydCB7IGxvY2FsZUNvZGVTY2hlbWEgfSBmcm9tIFwiLi9sb2NhbGVzXCI7XHJcbmltcG9ydCB7IGJ1Y2tldFR5cGVTY2hlbWEgfSBmcm9tIFwiLi9mb3JtYXRzXCI7XHJcblxyXG4vLyBjb21tb25cclxuZXhwb3J0IGNvbnN0IGxvY2FsZVNjaGVtYSA9IFoub2JqZWN0KHtcclxuICBzb3VyY2U6IGxvY2FsZUNvZGVTY2hlbWEuZGVzY3JpYmUoXHJcbiAgICBcIlByaW1hcnkgc291cmNlIGxvY2FsZSBjb2RlIG9mIHlvdXIgY29udGVudCAoZS5nLiAnZW4nLCAnZW4tVVMnLCAncHRfQlInLCBvciAncHQtckJSJykuIE11c3QgYmUgb25lIG9mIHRoZSBzdXBwb3J0ZWQgbG9jYWxlIGNvZGVzIFx1MjAxMyBlaXRoZXIgYSBzaG9ydCBJU08tNjM5IGxhbmd1YWdlIGNvZGUgb3IgYSBmdWxsIGxvY2FsZSBpZGVudGlmaWVyIHVzaW5nICctJywgJ18nIG9yIEFuZHJvaWQgJy1yJyBub3RhdGlvbi5cIixcclxuICApLFxyXG4gIHRhcmdldHM6IFouYXJyYXkobG9jYWxlQ29kZVNjaGVtYSkuZGVzY3JpYmUoXHJcbiAgICBcIkxpc3Qgb2YgdGFyZ2V0IGxvY2FsZSBjb2RlcyB0byB0cmFuc2xhdGUgdG8uXCIsXHJcbiAgKSxcclxufSkuZGVzY3JpYmUoXCJMb2NhbGUgY29uZmlndXJhdGlvbiBibG9jay5cIik7XHJcblxyXG4vLyBmYWN0b3JpZXNcclxudHlwZSBDb25maWdEZWZpbml0aW9uPFxyXG4gIFQgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4gIF9QIGV4dGVuZHMgWi5ab2RSYXdTaGFwZSA9IGFueSxcclxuPiA9IHtcclxuICBzY2hlbWE6IFouWm9kT2JqZWN0PFQ+O1xyXG4gIGRlZmF1bHRWYWx1ZTogWi5pbmZlcjxaLlpvZE9iamVjdDxUPj47XHJcbiAgcGFyc2U6IChyYXdDb25maWc6IHVua25vd24pID0+IFouaW5mZXI8Wi5ab2RPYmplY3Q8VD4+O1xyXG59O1xyXG5jb25zdCBjcmVhdGVDb25maWdEZWZpbml0aW9uID0gPFxyXG4gIFQgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4gIF9QIGV4dGVuZHMgWi5ab2RSYXdTaGFwZSA9IGFueSxcclxuPihcclxuICBkZWZpbml0aW9uOiBDb25maWdEZWZpbml0aW9uPFQsIF9QPixcclxuKSA9PiBkZWZpbml0aW9uO1xyXG5cclxudHlwZSBDb25maWdEZWZpbml0aW9uRXh0ZW5zaW9uUGFyYW1zPFxyXG4gIFQgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4gIFAgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4+ID0ge1xyXG4gIGNyZWF0ZVNjaGVtYTogKGJhc2VTY2hlbWE6IFouWm9kT2JqZWN0PFA+KSA9PiBaLlpvZE9iamVjdDxUPjtcclxuICBjcmVhdGVEZWZhdWx0VmFsdWU6IChcclxuICAgIGJhc2VEZWZhdWx0VmFsdWU6IFouaW5mZXI8Wi5ab2RPYmplY3Q8UD4+LFxyXG4gICkgPT4gWi5pbmZlcjxaLlpvZE9iamVjdDxUPj47XHJcbiAgY3JlYXRlVXBncmFkZXI6IChcclxuICAgIGNvbmZpZzogWi5pbmZlcjxaLlpvZE9iamVjdDxQPj4sXHJcbiAgICBzY2hlbWE6IFouWm9kT2JqZWN0PFQ+LFxyXG4gICAgZGVmYXVsdFZhbHVlOiBaLmluZmVyPFouWm9kT2JqZWN0PFQ+PixcclxuICApID0+IFouaW5mZXI8Wi5ab2RPYmplY3Q8VD4+O1xyXG59O1xyXG5jb25zdCBleHRlbmRDb25maWdEZWZpbml0aW9uID0gPFxyXG4gIFQgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4gIFAgZXh0ZW5kcyBaLlpvZFJhd1NoYXBlLFxyXG4+KFxyXG4gIGRlZmluaXRpb246IENvbmZpZ0RlZmluaXRpb248UCwgYW55PixcclxuICBwYXJhbXM6IENvbmZpZ0RlZmluaXRpb25FeHRlbnNpb25QYXJhbXM8VCwgUD4sXHJcbikgPT4ge1xyXG4gIGNvbnN0IHNjaGVtYSA9IHBhcmFtcy5jcmVhdGVTY2hlbWEoZGVmaW5pdGlvbi5zY2hlbWEpO1xyXG4gIGNvbnN0IGRlZmF1bHRWYWx1ZSA9IHBhcmFtcy5jcmVhdGVEZWZhdWx0VmFsdWUoZGVmaW5pdGlvbi5kZWZhdWx0VmFsdWUpO1xyXG4gIGNvbnN0IHVwZ3JhZGVyID0gKGNvbmZpZzogWi5pbmZlcjxaLlpvZE9iamVjdDxQPj4pID0+XHJcbiAgICBwYXJhbXMuY3JlYXRlVXBncmFkZXIoY29uZmlnLCBzY2hlbWEsIGRlZmF1bHRWYWx1ZSk7XHJcblxyXG4gIHJldHVybiBjcmVhdGVDb25maWdEZWZpbml0aW9uKHtcclxuICAgIHNjaGVtYSxcclxuICAgIGRlZmF1bHRWYWx1ZSxcclxuICAgIHBhcnNlOiAocmF3Q29uZmlnKSA9PiB7XHJcbiAgICAgIGNvbnN0IHNhZmVSZXN1bHQgPSBzY2hlbWEuc2FmZVBhcnNlKHJhd0NvbmZpZyk7XHJcbiAgICAgIGlmIChzYWZlUmVzdWx0LnN1Y2Nlc3MpIHtcclxuICAgICAgICByZXR1cm4gc2FmZVJlc3VsdC5kYXRhO1xyXG4gICAgICB9XHJcblxyXG4gICAgICBjb25zdCBsb2NhbGVFcnJvcnMgPSBzYWZlUmVzdWx0LmVycm9yLmVycm9yc1xyXG4gICAgICAgIC5maWx0ZXIoKGlzc3VlKSA9PiBpc3N1ZS5tZXNzYWdlLmluY2x1ZGVzKFwiSW52YWxpZCBsb2NhbGUgY29kZVwiKSlcclxuICAgICAgICAubWFwKChpc3N1ZSkgPT4ge1xyXG4gICAgICAgICAgbGV0IHVuc3VwcG9ydGVkTG9jYWxlID0gXCJcIjtcclxuICAgICAgICAgIGNvbnN0IHBhdGggPSBpc3N1ZS5wYXRoO1xyXG5cclxuICAgICAgICAgIGNvbnN0IGNvbmZpZyA9IHJhd0NvbmZpZyBhcyB7IGxvY2FsZT86IHsgW2tleTogc3RyaW5nXTogYW55IH0gfTtcclxuXHJcbiAgICAgICAgICBpZiAoY29uZmlnLmxvY2FsZSkge1xyXG4gICAgICAgICAgICB1bnN1cHBvcnRlZExvY2FsZSA9IHBhdGgucmVkdWNlPGFueT4oKGFjYywga2V5KSA9PiB7XHJcbiAgICAgICAgICAgICAgaWYgKGFjYyAmJiB0eXBlb2YgYWNjID09PSBcIm9iamVjdFwiICYmIGtleSBpbiBhY2MpIHtcclxuICAgICAgICAgICAgICAgIHJldHVybiBhY2Nba2V5XTtcclxuICAgICAgICAgICAgICB9XHJcbiAgICAgICAgICAgICAgcmV0dXJuIGFjYztcclxuICAgICAgICAgICAgfSwgY29uZmlnLmxvY2FsZSk7XHJcbiAgICAgICAgICB9XHJcblxyXG4gICAgICAgICAgcmV0dXJuIGBVbnN1cHBvcnRlZCBsb2NhbGU6ICR7dW5zdXBwb3J0ZWRMb2NhbGV9YDtcclxuICAgICAgICB9KTtcclxuXHJcbiAgICAgIGlmIChsb2NhbGVFcnJvcnMubGVuZ3RoID4gMCkge1xyXG4gICAgICAgIHRocm93IG5ldyBFcnJvcihgXFxuJHtsb2NhbGVFcnJvcnMuam9pbihcIlxcblwiKX1gKTtcclxuICAgICAgfVxyXG5cclxuICAgICAgY29uc3QgYmFzZUNvbmZpZyA9IGRlZmluaXRpb24ucGFyc2UocmF3Q29uZmlnKTtcclxuICAgICAgY29uc3QgcmVzdWx0ID0gdXBncmFkZXIoYmFzZUNvbmZpZyk7XHJcbiAgICAgIHJldHVybiByZXN1bHQ7XHJcbiAgICB9LFxyXG4gIH0pO1xyXG59O1xyXG5cclxuLy8gYW55IC0+IHYwXHJcbmNvbnN0IGNvbmZpZ1YwU2NoZW1hID0gWi5vYmplY3Qoe1xyXG4gIHZlcnNpb246IFoudW5pb24oW1oubnVtYmVyKCksIFouc3RyaW5nKCldKVxyXG4gICAgLmRlZmF1bHQoMClcclxuICAgIC5kZXNjcmliZShcIlRoZSB2ZXJzaW9uIG51bWJlciBvZiB0aGUgc2NoZW1hLlwiKSxcclxufSk7XHJcbmV4cG9ydCBjb25zdCBjb25maWdWMERlZmluaXRpb24gPSBjcmVhdGVDb25maWdEZWZpbml0aW9uKHtcclxuICBzY2hlbWE6IGNvbmZpZ1YwU2NoZW1hLFxyXG4gIGRlZmF1bHRWYWx1ZTogeyB2ZXJzaW9uOiAwIH0sXHJcbiAgcGFyc2U6IChyYXdDb25maWcpID0+IHtcclxuICAgIHJldHVybiBjb25maWdWMFNjaGVtYS5wYXJzZShyYXdDb25maWcpO1xyXG4gIH0sXHJcbn0pO1xyXG5cclxuLy8gdjAgLT4gdjFcclxuZXhwb3J0IGNvbnN0IGNvbmZpZ1YxRGVmaW5pdGlvbiA9IGV4dGVuZENvbmZpZ0RlZmluaXRpb24oY29uZmlnVjBEZWZpbml0aW9uLCB7XHJcbiAgY3JlYXRlU2NoZW1hOiAoYmFzZVNjaGVtYSkgPT5cclxuICAgIGJhc2VTY2hlbWEuZXh0ZW5kKHtcclxuICAgICAgbG9jYWxlOiBsb2NhbGVTY2hlbWEsXHJcbiAgICAgIGJ1Y2tldHM6IFoucmVjb3JkKFouc3RyaW5nKCksIGJ1Y2tldFR5cGVTY2hlbWEpXHJcbiAgICAgICAgLmRlZmF1bHQoe30pXHJcbiAgICAgICAgLmRlc2NyaWJlKFxyXG4gICAgICAgICAgXCJNYXBwaW5nIG9mIHNvdXJjZSBmaWxlIHBhdGhzIChnbG9iIHBhdHRlcm5zKSB0byBidWNrZXQgdHlwZXMuXCIsXHJcbiAgICAgICAgKVxyXG4gICAgICAgIC5vcHRpb25hbCgpLFxyXG4gICAgfSksXHJcbiAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoKSA9PiAoe1xyXG4gICAgdmVyc2lvbjogMSxcclxuICAgIGxvY2FsZToge1xyXG4gICAgICBzb3VyY2U6IFwiZW5cIiBhcyBjb25zdCxcclxuICAgICAgdGFyZ2V0czogW1wiZXNcIiBhcyBjb25zdF0sXHJcbiAgICB9LFxyXG4gICAgYnVja2V0czoge30sXHJcbiAgfSksXHJcbiAgY3JlYXRlVXBncmFkZXI6ICgpID0+ICh7XHJcbiAgICB2ZXJzaW9uOiAxLFxyXG4gICAgbG9jYWxlOiB7XHJcbiAgICAgIHNvdXJjZTogXCJlblwiIGFzIGNvbnN0LFxyXG4gICAgICB0YXJnZXRzOiBbXCJlc1wiIGFzIGNvbnN0XSxcclxuICAgIH0sXHJcbiAgICBidWNrZXRzOiB7fSxcclxuICB9KSxcclxufSk7XHJcblxyXG4vLyB2MSAtPiB2MS4xXHJcbmV4cG9ydCBjb25zdCBjb25maWdWMV8xRGVmaW5pdGlvbiA9IGV4dGVuZENvbmZpZ0RlZmluaXRpb24oY29uZmlnVjFEZWZpbml0aW9uLCB7XHJcbiAgY3JlYXRlU2NoZW1hOiAoYmFzZVNjaGVtYSkgPT5cclxuICAgIGJhc2VTY2hlbWEuZXh0ZW5kKHtcclxuICAgICAgYnVja2V0czogWi5yZWNvcmQoXHJcbiAgICAgICAgYnVja2V0VHlwZVNjaGVtYSxcclxuICAgICAgICBaLm9iamVjdCh7XHJcbiAgICAgICAgICBpbmNsdWRlOiBaLmFycmF5KFouc3RyaW5nKCkpXHJcbiAgICAgICAgICAgIC5kZWZhdWx0KFtdKVxyXG4gICAgICAgICAgICAuZGVzY3JpYmUoXHJcbiAgICAgICAgICAgICAgXCJGaWxlIHBhdGhzIG9yIGdsb2IgcGF0dGVybnMgdG8gaW5jbHVkZSBmb3IgdGhpcyBidWNrZXQuXCIsXHJcbiAgICAgICAgICAgICksXHJcbiAgICAgICAgICBleGNsdWRlOiBaLmFycmF5KFouc3RyaW5nKCkpXHJcbiAgICAgICAgICAgIC5kZWZhdWx0KFtdKVxyXG4gICAgICAgICAgICAub3B0aW9uYWwoKVxyXG4gICAgICAgICAgICAuZGVzY3JpYmUoXHJcbiAgICAgICAgICAgICAgXCJGaWxlIHBhdGhzIG9yIGdsb2IgcGF0dGVybnMgdG8gZXhjbHVkZSBmcm9tIHRoaXMgYnVja2V0LlwiLFxyXG4gICAgICAgICAgICApLFxyXG4gICAgICAgIH0pLFxyXG4gICAgICApLmRlZmF1bHQoe30pLFxyXG4gICAgfSksXHJcbiAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgIC4uLmJhc2VEZWZhdWx0VmFsdWUsXHJcbiAgICB2ZXJzaW9uOiAxLjEsXHJcbiAgICBidWNrZXRzOiB7fSxcclxuICB9KSxcclxuICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZywgc2NoZW1hKSA9PiB7XHJcbiAgICBjb25zdCB1cGdyYWRlZENvbmZpZzogWi5pbmZlcjx0eXBlb2Ygc2NoZW1hPiA9IHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjEsXHJcbiAgICAgIGJ1Y2tldHM6IHt9LFxyXG4gICAgfTtcclxuXHJcbiAgICAvLyBUcmFuc2Zvcm0gYnVja2V0cyBmcm9tIHYxIHRvIHYxLjEgZm9ybWF0XHJcbiAgICBpZiAob2xkQ29uZmlnLmJ1Y2tldHMpIHtcclxuICAgICAgZm9yIChjb25zdCBbYnVja2V0UGF0aCwgYnVja2V0VHlwZV0gb2YgT2JqZWN0LmVudHJpZXMoXHJcbiAgICAgICAgb2xkQ29uZmlnLmJ1Y2tldHMsXHJcbiAgICAgICkpIHtcclxuICAgICAgICBpZiAoIXVwZ3JhZGVkQ29uZmlnLmJ1Y2tldHNbYnVja2V0VHlwZV0pIHtcclxuICAgICAgICAgIHVwZ3JhZGVkQ29uZmlnLmJ1Y2tldHNbYnVja2V0VHlwZV0gPSB7XHJcbiAgICAgICAgICAgIGluY2x1ZGU6IFtdLFxyXG4gICAgICAgICAgfTtcclxuICAgICAgICB9XHJcbiAgICAgICAgdXBncmFkZWRDb25maWcuYnVja2V0c1tidWNrZXRUeXBlXT8uaW5jbHVkZS5wdXNoKGJ1Y2tldFBhdGgpO1xyXG4gICAgICB9XHJcbiAgICB9XHJcblxyXG4gICAgcmV0dXJuIHVwZ3JhZGVkQ29uZmlnO1xyXG4gIH0sXHJcbn0pO1xyXG5cclxuLy8gdjEuMSAtPiB2MS4yXHJcbi8vIENoYW5nZXM6IEFkZCBcImV4dHJhU291cmNlXCIgb3B0aW9uYWwgZmllbGQgdG8gdGhlIGxvY2FsZSBub2RlIG9mIHRoZSBjb25maWdcclxuZXhwb3J0IGNvbnN0IGNvbmZpZ1YxXzJEZWZpbml0aW9uID0gZXh0ZW5kQ29uZmlnRGVmaW5pdGlvbihcclxuICBjb25maWdWMV8xRGVmaW5pdGlvbixcclxuICB7XHJcbiAgICBjcmVhdGVTY2hlbWE6IChiYXNlU2NoZW1hKSA9PlxyXG4gICAgICBiYXNlU2NoZW1hLmV4dGVuZCh7XHJcbiAgICAgICAgbG9jYWxlOiBsb2NhbGVTY2hlbWEuZXh0ZW5kKHtcclxuICAgICAgICAgIGV4dHJhU291cmNlOiBsb2NhbGVDb2RlU2NoZW1hXHJcbiAgICAgICAgICAgIC5vcHRpb25hbCgpXHJcbiAgICAgICAgICAgIC5kZXNjcmliZShcclxuICAgICAgICAgICAgICBcIk9wdGlvbmFsIGV4dHJhIHNvdXJjZSBsb2NhbGUgY29kZSB1c2VkIGFzIGZhbGxiYWNrIGR1cmluZyB0cmFuc2xhdGlvbi5cIixcclxuICAgICAgICAgICAgKSxcclxuICAgICAgICB9KSxcclxuICAgICAgfSksXHJcbiAgICBjcmVhdGVEZWZhdWx0VmFsdWU6IChiYXNlRGVmYXVsdFZhbHVlKSA9PiAoe1xyXG4gICAgICAuLi5iYXNlRGVmYXVsdFZhbHVlLFxyXG4gICAgICB2ZXJzaW9uOiAxLjIsXHJcbiAgICB9KSxcclxuICAgIGNyZWF0ZVVwZ3JhZGVyOiAob2xkQ29uZmlnKSA9PiAoe1xyXG4gICAgICAuLi5vbGRDb25maWcsXHJcbiAgICAgIHZlcnNpb246IDEuMixcclxuICAgIH0pLFxyXG4gIH0sXHJcbik7XHJcblxyXG4vLyB2MS4yIC0+IHYxLjNcclxuLy8gQ2hhbmdlczogU3VwcG9ydCBib3RoIHN0cmluZyBwYXRocyBhbmQge3BhdGgsIGRlbGltaXRlcn0gb2JqZWN0cyBpbiBidWNrZXQgaW5jbHVkZS9leGNsdWRlIGFycmF5c1xyXG5leHBvcnQgY29uc3QgYnVja2V0SXRlbVNjaGVtYSA9IFoub2JqZWN0KHtcclxuICBwYXRoOiBaLnN0cmluZygpLmRlc2NyaWJlKFwiUGF0aCBwYXR0ZXJuIGNvbnRhaW5pbmcgYSBbbG9jYWxlXSBwbGFjZWhvbGRlci5cIiksXHJcbiAgZGVsaW1pdGVyOiBaLnVuaW9uKFtaLmxpdGVyYWwoXCItXCIpLCBaLmxpdGVyYWwoXCJfXCIpLCBaLmxpdGVyYWwobnVsbCldKVxyXG4gICAgLm9wdGlvbmFsKClcclxuICAgIC5kZXNjcmliZShcclxuICAgICAgXCJEZWxpbWl0ZXIgdGhhdCByZXBsYWNlcyB0aGUgW2xvY2FsZV0gcGxhY2Vob2xkZXIgaW4gdGhlIHBhdGggKGRlZmF1bHQ6IG5vIGRlbGltaXRlcikuXCIsXHJcbiAgICApLFxyXG59KS5kZXNjcmliZShcclxuICBcIkJ1Y2tldCBwYXRoIGl0ZW0uIEVpdGhlciBhIHN0cmluZyBwYXRoIG9yIGFuIG9iamVjdCBzcGVjaWZ5aW5nIHBhdGggYW5kIGRlbGltaXRlci5cIixcclxuKTtcclxuZXhwb3J0IHR5cGUgQnVja2V0SXRlbSA9IFouaW5mZXI8dHlwZW9mIGJ1Y2tldEl0ZW1TY2hlbWE+O1xyXG5cclxuLy8gRGVmaW5lIGEgYmFzZSBidWNrZXQgdmFsdWUgc2NoZW1hIHRoYXQgY2FuIGJlIHJldXNlZCBhbmQgZXh0ZW5kZWRcclxuZXhwb3J0IGNvbnN0IGJ1Y2tldFZhbHVlU2NoZW1hVjFfMyA9IFoub2JqZWN0KHtcclxuICBpbmNsdWRlOiBaLmFycmF5KFoudW5pb24oW1ouc3RyaW5nKCksIGJ1Y2tldEl0ZW1TY2hlbWFdKSlcclxuICAgIC5kZWZhdWx0KFtdKVxyXG4gICAgLmRlc2NyaWJlKFwiR2xvYiBwYXR0ZXJucyBvciBidWNrZXQgaXRlbXMgdG8gaW5jbHVkZSBmb3IgdGhpcyBidWNrZXQuXCIpLFxyXG4gIGV4Y2x1ZGU6IFouYXJyYXkoWi51bmlvbihbWi5zdHJpbmcoKSwgYnVja2V0SXRlbVNjaGVtYV0pKVxyXG4gICAgLmRlZmF1bHQoW10pXHJcbiAgICAub3B0aW9uYWwoKVxyXG4gICAgLmRlc2NyaWJlKFwiR2xvYiBwYXR0ZXJucyBvciBidWNrZXQgaXRlbXMgdG8gZXhjbHVkZSBmcm9tIHRoaXMgYnVja2V0LlwiKSxcclxuICBpbmplY3RMb2NhbGU6IFouYXJyYXkoWi5zdHJpbmcoKSlcclxuICAgIC5vcHRpb25hbCgpXHJcbiAgICAuZGVzY3JpYmUoXHJcbiAgICAgIFwiS2V5cyB3aXRoaW4gZmlsZXMgd2hlcmUgdGhlIGN1cnJlbnQgbG9jYWxlIHNob3VsZCBiZSBpbmplY3RlZCBvciByZW1vdmVkLlwiLFxyXG4gICAgKSxcclxufSkuZGVzY3JpYmUoXCJDb25maWd1cmF0aW9uIG9wdGlvbnMgZm9yIGEgdHJhbnNsYXRpb24gYnVja2V0LlwiKTtcclxuXHJcbmV4cG9ydCBjb25zdCBjb25maWdWMV8zRGVmaW5pdGlvbiA9IGV4dGVuZENvbmZpZ0RlZmluaXRpb24oXHJcbiAgY29uZmlnVjFfMkRlZmluaXRpb24sXHJcbiAge1xyXG4gICAgY3JlYXRlU2NoZW1hOiAoYmFzZVNjaGVtYSkgPT5cclxuICAgICAgYmFzZVNjaGVtYS5leHRlbmQoe1xyXG4gICAgICAgIGJ1Y2tldHM6IFoucmVjb3JkKGJ1Y2tldFR5cGVTY2hlbWEsIGJ1Y2tldFZhbHVlU2NoZW1hVjFfMykuZGVmYXVsdCh7fSksXHJcbiAgICAgIH0pLFxyXG4gICAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgICAgLi4uYmFzZURlZmF1bHRWYWx1ZSxcclxuICAgICAgdmVyc2lvbjogMS4zLFxyXG4gICAgfSksXHJcbiAgICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZykgPT4gKHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjMsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuY29uc3QgY29uZmlnU2NoZW1hID0gXCJodHRwczovL2xpbmdvLmRldi9zY2hlbWEvaTE4bi5qc29uXCI7XHJcblxyXG4vLyB2MS4zIC0+IHYxLjRcclxuLy8gQ2hhbmdlczogQWRkICRzY2hlbWEgdG8gdGhlIGNvbmZpZ1xyXG5leHBvcnQgY29uc3QgY29uZmlnVjFfNERlZmluaXRpb24gPSBleHRlbmRDb25maWdEZWZpbml0aW9uKFxyXG4gIGNvbmZpZ1YxXzNEZWZpbml0aW9uLFxyXG4gIHtcclxuICAgIGNyZWF0ZVNjaGVtYTogKGJhc2VTY2hlbWEpID0+XHJcbiAgICAgIGJhc2VTY2hlbWEuZXh0ZW5kKHtcclxuICAgICAgICAkc2NoZW1hOiBaLnN0cmluZygpLmRlZmF1bHQoY29uZmlnU2NoZW1hKSxcclxuICAgICAgfSksXHJcbiAgICBjcmVhdGVEZWZhdWx0VmFsdWU6IChiYXNlRGVmYXVsdFZhbHVlKSA9PiAoe1xyXG4gICAgICAuLi5iYXNlRGVmYXVsdFZhbHVlLFxyXG4gICAgICB2ZXJzaW9uOiAxLjQsXHJcbiAgICAgICRzY2hlbWE6IGNvbmZpZ1NjaGVtYSxcclxuICAgIH0pLFxyXG4gICAgY3JlYXRlVXBncmFkZXI6IChvbGRDb25maWcpID0+ICh7XHJcbiAgICAgIC4uLm9sZENvbmZpZyxcclxuICAgICAgdmVyc2lvbjogMS40LFxyXG4gICAgICAkc2NoZW1hOiBjb25maWdTY2hlbWEsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gdjEuNCAtPiB2MS41XHJcbi8vIENoYW5nZXM6IGFkZCBcInByb3ZpZGVyXCIgZmllbGQgdG8gdGhlIGNvbmZpZ1xyXG5jb25zdCBwcm92aWRlclNjaGVtYSA9IFoub2JqZWN0KHtcclxuICBpZDogWi5lbnVtKFtcclxuICAgIFwib3BlbmFpXCIsXHJcbiAgICBcImFudGhyb3BpY1wiLFxyXG4gICAgXCJnb29nbGVcIixcclxuICAgIFwib2xsYW1hXCIsXHJcbiAgICBcIm9wZW5yb3V0ZXJcIixcclxuICAgIFwibWlzdHJhbFwiLFxyXG4gIF0pLmRlc2NyaWJlKFwiSWRlbnRpZmllciBvZiB0aGUgdHJhbnNsYXRpb24gcHJvdmlkZXIgc2VydmljZS5cIiksXHJcbiAgbW9kZWw6IFouc3RyaW5nKCkuZGVzY3JpYmUoXCJNb2RlbCBuYW1lIHRvIHVzZSBmb3IgdHJhbnNsYXRpb25zLlwiKSxcclxuICBwcm9tcHQ6IFouc3RyaW5nKCkuZGVzY3JpYmUoXHJcbiAgICBcIlByb21wdCB0ZW1wbGF0ZSB1c2VkIHdoZW4gcmVxdWVzdGluZyB0cmFuc2xhdGlvbnMuXCIsXHJcbiAgKSxcclxuICBiYXNlVXJsOiBaLnN0cmluZygpXHJcbiAgICAub3B0aW9uYWwoKVxyXG4gICAgLmRlc2NyaWJlKFwiQ3VzdG9tIGJhc2UgVVJMIGZvciB0aGUgcHJvdmlkZXIgQVBJIChvcHRpb25hbCkuXCIpLFxyXG59KS5kZXNjcmliZShcIkNvbmZpZ3VyYXRpb24gZm9yIHRoZSBtYWNoaW5lLXRyYW5zbGF0aW9uIHByb3ZpZGVyLlwiKTtcclxuZXhwb3J0IGNvbnN0IGNvbmZpZ1YxXzVEZWZpbml0aW9uID0gZXh0ZW5kQ29uZmlnRGVmaW5pdGlvbihcclxuICBjb25maWdWMV80RGVmaW5pdGlvbixcclxuICB7XHJcbiAgICBjcmVhdGVTY2hlbWE6IChiYXNlU2NoZW1hKSA9PlxyXG4gICAgICBiYXNlU2NoZW1hLmV4dGVuZCh7XHJcbiAgICAgICAgcHJvdmlkZXI6IHByb3ZpZGVyU2NoZW1hLm9wdGlvbmFsKCksXHJcbiAgICAgIH0pLFxyXG4gICAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgICAgLi4uYmFzZURlZmF1bHRWYWx1ZSxcclxuICAgICAgdmVyc2lvbjogMS41LFxyXG4gICAgfSksXHJcbiAgICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZykgPT4gKHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjUsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gdjEuNSAtPiB2MS42XHJcbi8vIENoYW5nZXM6IEFkZCBcImxvY2tlZEtleXNcIiBzdHJpbmcgYXJyYXkgdG8gYnVja2V0IGNvbmZpZ1xyXG5leHBvcnQgY29uc3QgYnVja2V0VmFsdWVTY2hlbWFWMV82ID0gYnVja2V0VmFsdWVTY2hlbWFWMV8zLmV4dGVuZCh7XHJcbiAgbG9ja2VkS2V5czogWi5hcnJheShaLnN0cmluZygpKVxyXG4gICAgLmRlZmF1bHQoW10pXHJcbiAgICAub3B0aW9uYWwoKVxyXG4gICAgLmRlc2NyaWJlKFxyXG4gICAgICBcIktleXMgdGhhdCBtdXN0IHJlbWFpbiB1bmNoYW5nZWQgYW5kIHNob3VsZCBuZXZlciBiZSBvdmVyd3JpdHRlbiBieSB0cmFuc2xhdGlvbnMuXCIsXHJcbiAgICApLFxyXG59KTtcclxuXHJcbmV4cG9ydCBjb25zdCBjb25maWdWMV82RGVmaW5pdGlvbiA9IGV4dGVuZENvbmZpZ0RlZmluaXRpb24oXHJcbiAgY29uZmlnVjFfNURlZmluaXRpb24sXHJcbiAge1xyXG4gICAgY3JlYXRlU2NoZW1hOiAoYmFzZVNjaGVtYSkgPT5cclxuICAgICAgYmFzZVNjaGVtYS5leHRlbmQoe1xyXG4gICAgICAgIGJ1Y2tldHM6IFoucmVjb3JkKGJ1Y2tldFR5cGVTY2hlbWEsIGJ1Y2tldFZhbHVlU2NoZW1hVjFfNikuZGVmYXVsdCh7fSksXHJcbiAgICAgIH0pLFxyXG4gICAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgICAgLi4uYmFzZURlZmF1bHRWYWx1ZSxcclxuICAgICAgdmVyc2lvbjogMS42LFxyXG4gICAgfSksXHJcbiAgICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZykgPT4gKHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjYsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gQ2hhbmdlczogQWRkIFwibG9ja2VkUGF0dGVybnNcIiBzdHJpbmcgYXJyYXkgb2YgcmVnZXggcGF0dGVybnMgdG8gYnVja2V0IGNvbmZpZ1xyXG5leHBvcnQgY29uc3QgYnVja2V0VmFsdWVTY2hlbWFWMV83ID0gYnVja2V0VmFsdWVTY2hlbWFWMV82LmV4dGVuZCh7XHJcbiAgbG9ja2VkUGF0dGVybnM6IFouYXJyYXkoWi5zdHJpbmcoKSlcclxuICAgIC5kZWZhdWx0KFtdKVxyXG4gICAgLm9wdGlvbmFsKClcclxuICAgIC5kZXNjcmliZShcclxuICAgICAgXCJSZWd1bGFyIGV4cHJlc3Npb24gcGF0dGVybnMgd2hvc2UgbWF0Y2hlZCBjb250ZW50IHNob3VsZCByZW1haW4gbG9ja2VkIGR1cmluZyB0cmFuc2xhdGlvbi5cIixcclxuICAgICksXHJcbn0pO1xyXG5cclxuZXhwb3J0IGNvbnN0IGNvbmZpZ1YxXzdEZWZpbml0aW9uID0gZXh0ZW5kQ29uZmlnRGVmaW5pdGlvbihcclxuICBjb25maWdWMV82RGVmaW5pdGlvbixcclxuICB7XHJcbiAgICBjcmVhdGVTY2hlbWE6IChiYXNlU2NoZW1hKSA9PlxyXG4gICAgICBiYXNlU2NoZW1hLmV4dGVuZCh7XHJcbiAgICAgICAgYnVja2V0czogWi5yZWNvcmQoYnVja2V0VHlwZVNjaGVtYSwgYnVja2V0VmFsdWVTY2hlbWFWMV83KS5kZWZhdWx0KHt9KSxcclxuICAgICAgfSksXHJcbiAgICBjcmVhdGVEZWZhdWx0VmFsdWU6IChiYXNlRGVmYXVsdFZhbHVlKSA9PiAoe1xyXG4gICAgICAuLi5iYXNlRGVmYXVsdFZhbHVlLFxyXG4gICAgICB2ZXJzaW9uOiAxLjcsXHJcbiAgICB9KSxcclxuICAgIGNyZWF0ZVVwZ3JhZGVyOiAob2xkQ29uZmlnKSA9PiAoe1xyXG4gICAgICAuLi5vbGRDb25maWcsXHJcbiAgICAgIHZlcnNpb246IDEuNyxcclxuICAgIH0pLFxyXG4gIH0sXHJcbik7XHJcblxyXG4vLyB2MS43IC0+IHYxLjhcclxuLy8gQ2hhbmdlczogQWRkIFwiaWdub3JlZEtleXNcIiBzdHJpbmcgYXJyYXkgdG8gYnVja2V0IGNvbmZpZ1xyXG5leHBvcnQgY29uc3QgYnVja2V0VmFsdWVTY2hlbWFWMV84ID0gYnVja2V0VmFsdWVTY2hlbWFWMV83LmV4dGVuZCh7XHJcbiAgaWdub3JlZEtleXM6IFouYXJyYXkoWi5zdHJpbmcoKSlcclxuICAgIC5kZWZhdWx0KFtdKVxyXG4gICAgLm9wdGlvbmFsKClcclxuICAgIC5kZXNjcmliZShcclxuICAgICAgXCJLZXlzIHRoYXQgc2hvdWxkIGJlIGNvbXBsZXRlbHkgaWdub3JlZCBieSB0cmFuc2xhdGlvbiBwcm9jZXNzZXMuXCIsXHJcbiAgICApLFxyXG59KTtcclxuXHJcbmV4cG9ydCBjb25zdCBjb25maWdWMV84RGVmaW5pdGlvbiA9IGV4dGVuZENvbmZpZ0RlZmluaXRpb24oXHJcbiAgY29uZmlnVjFfN0RlZmluaXRpb24sXHJcbiAge1xyXG4gICAgY3JlYXRlU2NoZW1hOiAoYmFzZVNjaGVtYSkgPT5cclxuICAgICAgYmFzZVNjaGVtYS5leHRlbmQoe1xyXG4gICAgICAgIGJ1Y2tldHM6IFoucmVjb3JkKGJ1Y2tldFR5cGVTY2hlbWEsIGJ1Y2tldFZhbHVlU2NoZW1hVjFfOCkuZGVmYXVsdCh7fSksXHJcbiAgICAgIH0pLFxyXG4gICAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgICAgLi4uYmFzZURlZmF1bHRWYWx1ZSxcclxuICAgICAgdmVyc2lvbjogMS44LFxyXG4gICAgfSksXHJcbiAgICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZykgPT4gKHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjgsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gdjEuOCAtPiB2MS45XHJcbi8vIENoYW5nZXM6IEFkZCBcImZvcm1hdHRlclwiIGZpZWxkIHRvIHRvcC1sZXZlbCBjb25maWdcclxuZXhwb3J0IGNvbnN0IGNvbmZpZ1YxXzlEZWZpbml0aW9uID0gZXh0ZW5kQ29uZmlnRGVmaW5pdGlvbihcclxuICBjb25maWdWMV84RGVmaW5pdGlvbixcclxuICB7XHJcbiAgICBjcmVhdGVTY2hlbWE6IChiYXNlU2NoZW1hKSA9PlxyXG4gICAgICBiYXNlU2NoZW1hLmV4dGVuZCh7XHJcbiAgICAgICAgZm9ybWF0dGVyOiBaLmVudW0oW1wicHJldHRpZXJcIiwgXCJiaW9tZVwiXSlcclxuICAgICAgICAgIC5vcHRpb25hbCgpXHJcbiAgICAgICAgICAuZGVzY3JpYmUoXHJcbiAgICAgICAgICAgIFwiQ29kZSBmb3JtYXR0ZXIgdG8gdXNlIGZvciBhbGwgYnVja2V0cy4gRGVmYXVsdHMgdG8gJ3ByZXR0aWVyJyBpZiBub3Qgc3BlY2lmaWVkIGFuZCBhIHByZXR0aWVyIGNvbmZpZyBpcyBmb3VuZC5cIixcclxuICAgICAgICAgICksXHJcbiAgICAgIH0pLFxyXG4gICAgY3JlYXRlRGVmYXVsdFZhbHVlOiAoYmFzZURlZmF1bHRWYWx1ZSkgPT4gKHtcclxuICAgICAgLi4uYmFzZURlZmF1bHRWYWx1ZSxcclxuICAgICAgdmVyc2lvbjogMS45LFxyXG4gICAgfSksXHJcbiAgICBjcmVhdGVVcGdyYWRlcjogKG9sZENvbmZpZykgPT4gKHtcclxuICAgICAgLi4ub2xkQ29uZmlnLFxyXG4gICAgICB2ZXJzaW9uOiAxLjksXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gdjEuOSAtPiB2MS4xMFxyXG4vLyBDaGFuZ2VzOiBBZGQgXCJzZXR0aW5nc1wiIGZpZWxkIHRvIHByb3ZpZGVyIGNvbmZpZyBmb3IgbW9kZWwtc3BlY2lmaWMgcGFyYW1ldGVyc1xyXG5jb25zdCBtb2RlbFNldHRpbmdzU2NoZW1hID0gWi5vYmplY3Qoe1xyXG4gIHRlbXBlcmF0dXJlOiBaLm51bWJlcigpXHJcbiAgICAubWluKDApXHJcbiAgICAubWF4KDIpXHJcbiAgICAub3B0aW9uYWwoKVxyXG4gICAgLmRlc2NyaWJlKFxyXG4gICAgICBcIkNvbnRyb2xzIHJhbmRvbW5lc3MgaW4gbW9kZWwgb3V0cHV0cyAoMD1kZXRlcm1pbmlzdGljLCAyPXZlcnkgcmFuZG9tKS4gU29tZSBtb2RlbHMgbGlrZSBHUFQtNSByZXF1aXJlIHRlbXBlcmF0dXJlPTEuXCIsXHJcbiAgICApLFxyXG59KVxyXG4gIC5vcHRpb25hbCgpXHJcbiAgLmRlc2NyaWJlKFwiTW9kZWwtc3BlY2lmaWMgc2V0dGluZ3MgZm9yIHRyYW5zbGF0aW9uIHJlcXVlc3RzLlwiKTtcclxuXHJcbmNvbnN0IHByb3ZpZGVyU2NoZW1hVjFfMTAgPSBaLm9iamVjdCh7XHJcbiAgaWQ6IFouZW51bShbXHJcbiAgICBcIm9wZW5haVwiLFxyXG4gICAgXCJhbnRocm9waWNcIixcclxuICAgIFwiZ29vZ2xlXCIsXHJcbiAgICBcIm9sbGFtYVwiLFxyXG4gICAgXCJvcGVucm91dGVyXCIsXHJcbiAgICBcIm1pc3RyYWxcIixcclxuICBdKS5kZXNjcmliZShcIklkZW50aWZpZXIgb2YgdGhlIHRyYW5zbGF0aW9uIHByb3ZpZGVyIHNlcnZpY2UuXCIpLFxyXG4gIG1vZGVsOiBaLnN0cmluZygpLmRlc2NyaWJlKFwiTW9kZWwgbmFtZSB0byB1c2UgZm9yIHRyYW5zbGF0aW9ucy5cIiksXHJcbiAgcHJvbXB0OiBaLnN0cmluZygpLmRlc2NyaWJlKFxyXG4gICAgXCJQcm9tcHQgdGVtcGxhdGUgdXNlZCB3aGVuIHJlcXVlc3RpbmcgdHJhbnNsYXRpb25zLlwiLFxyXG4gICksXHJcbiAgYmFzZVVybDogWi5zdHJpbmcoKVxyXG4gICAgLm9wdGlvbmFsKClcclxuICAgIC5kZXNjcmliZShcIkN1c3RvbSBiYXNlIFVSTCBmb3IgdGhlIHByb3ZpZGVyIEFQSSAob3B0aW9uYWwpLlwiKSxcclxuICBzZXR0aW5nczogbW9kZWxTZXR0aW5nc1NjaGVtYSxcclxufSkuZGVzY3JpYmUoXCJDb25maWd1cmF0aW9uIGZvciB0aGUgbWFjaGluZS10cmFuc2xhdGlvbiBwcm92aWRlci5cIik7XHJcblxyXG5leHBvcnQgY29uc3QgY29uZmlnVjFfMTBEZWZpbml0aW9uID0gZXh0ZW5kQ29uZmlnRGVmaW5pdGlvbihcclxuICBjb25maWdWMV85RGVmaW5pdGlvbixcclxuICB7XHJcbiAgICBjcmVhdGVTY2hlbWE6IChiYXNlU2NoZW1hKSA9PlxyXG4gICAgICBiYXNlU2NoZW1hLmV4dGVuZCh7XHJcbiAgICAgICAgcHJvdmlkZXI6IHByb3ZpZGVyU2NoZW1hVjFfMTAub3B0aW9uYWwoKSxcclxuICAgICAgfSksXHJcbiAgICBjcmVhdGVEZWZhdWx0VmFsdWU6IChiYXNlRGVmYXVsdFZhbHVlKSA9PiAoe1xyXG4gICAgICAuLi5iYXNlRGVmYXVsdFZhbHVlLFxyXG4gICAgICB2ZXJzaW9uOiBcIjEuMTBcIixcclxuICAgIH0pLFxyXG4gICAgY3JlYXRlVXBncmFkZXI6IChvbGRDb25maWcpID0+ICh7XHJcbiAgICAgIC4uLm9sZENvbmZpZyxcclxuICAgICAgdmVyc2lvbjogXCIxLjEwXCIsXHJcbiAgICB9KSxcclxuICB9LFxyXG4pO1xyXG5cclxuLy8gZXhwb3J0c1xyXG5leHBvcnQgY29uc3QgTEFURVNUX0NPTkZJR19ERUZJTklUSU9OID0gY29uZmlnVjFfMTBEZWZpbml0aW9uO1xyXG5cclxuZXhwb3J0IHR5cGUgSTE4bkNvbmZpZyA9IFouaW5mZXI8KHR5cGVvZiBMQVRFU1RfQ09ORklHX0RFRklOSVRJT04pW1wic2NoZW1hXCJdPjtcclxuXHJcbmV4cG9ydCBmdW5jdGlvbiBwYXJzZUkxOG5Db25maWcocmF3Q29uZmlnOiB1bmtub3duKSB7XHJcbiAgdHJ5IHtcclxuICAgIGNvbnN0IHJlc3VsdCA9IExBVEVTVF9DT05GSUdfREVGSU5JVElPTi5wYXJzZShyYXdDb25maWcpO1xyXG4gICAgcmV0dXJuIHJlc3VsdDtcclxuICB9IGNhdGNoIChlcnJvcjogYW55KSB7XHJcbiAgICB0aHJvdyBuZXcgRXJyb3IoYEZhaWxlZCB0byBwYXJzZSBjb25maWc6ICR7ZXJyb3IubWVzc2FnZX1gKTtcclxuICB9XHJcbn1cclxuXHJcbmV4cG9ydCBjb25zdCBkZWZhdWx0Q29uZmlnID0gTEFURVNUX0NPTkZJR19ERUZJTklUSU9OLmRlZmF1bHRWYWx1ZTtcclxuIiwgImNvbnN0IF9faW5qZWN0ZWRfZmlsZW5hbWVfXyA9IFwiQzpcXFxcVXNlcnNcXFxcVFVGIEdBTUlOR1xcXFxPbmVEcml2ZVxcXFxEZXNrdG9wXFxcXGxpbmdvLmRldiBvcGVuc291cmNlXFxcXGxpbmdvLmRldlxcXFxwYWNrYWdlc1xcXFxzcGVjXFxcXHNyY1xcXFxsb2NhbGVzLnRzXCI7Y29uc3QgX19pbmplY3RlZF9kaXJuYW1lX18gPSBcIkM6XFxcXFVzZXJzXFxcXFRVRiBHQU1JTkdcXFxcT25lRHJpdmVcXFxcRGVza3RvcFxcXFxsaW5nby5kZXYgb3BlbnNvdXJjZVxcXFxsaW5nby5kZXZcXFxccGFja2FnZXNcXFxcc3BlY1xcXFxzcmNcIjtjb25zdCBfX2luamVjdGVkX2ltcG9ydF9tZXRhX3VybF9fID0gXCJmaWxlOi8vL0M6L1VzZXJzL1RVRiUyMEdBTUlORy9PbmVEcml2ZS9EZXNrdG9wL2xpbmdvLmRldiUyMG9wZW5zb3VyY2UvbGluZ28uZGV2L3BhY2thZ2VzL3NwZWMvc3JjL2xvY2FsZXMudHNcIjtpbXBvcnQgWiBmcm9tIFwiem9kXCI7XHJcbmltcG9ydCB7IGlzVmFsaWRMb2NhbGUgfSBmcm9tIFwiQGxpbmdvLmRldi9fbG9jYWxlc1wiO1xyXG5cclxuY29uc3QgbG9jYWxlTWFwID0ge1xyXG4gIC8vIFVyZHUgKFBha2lzdGFuKVxyXG4gIHVyOiBbXCJ1ci1QS1wiXSxcclxuICAvLyBWaWV0bmFtZXNlIChWaWV0bmFtKVxyXG4gIHZpOiBbXCJ2aS1WTlwiXSxcclxuICAvLyBUdXJraXNoIChUdXJrZXkpXHJcbiAgdHI6IFtcInRyLVRSXCJdLFxyXG4gIC8vIFRhbWlsIChJbmRpYSlcclxuICB0YTogW1xyXG4gICAgXCJ0YS1JTlwiLCAvLyBJbmRpYVxyXG4gICAgXCJ0YS1TR1wiLCAvLyBTaW5nYXBvcmVcclxuICBdLFxyXG4gIC8vIFNlcmJpYW5cclxuICBzcjogW1xyXG4gICAgXCJzci1SU1wiLCAvLyBTZXJiaWFuIChMYXRpbilcclxuICAgIFwic3ItTGF0bi1SU1wiLCAvLyBTZXJiaWFuIChMYXRpbilcclxuICAgIFwic3ItQ3lybC1SU1wiLCAvLyBTZXJiaWFuIChDeXJpbGxpYylcclxuICBdLFxyXG4gIC8vIEh1bmdhcmlhbiAoSHVuZ2FyeSlcclxuICBodTogW1wiaHUtSFVcIl0sXHJcbiAgLy8gSGVicmV3IChJc3JhZWwpXHJcbiAgaGU6IFtcImhlLUlMXCJdLFxyXG4gIC8vIEVzdG9uaWFuIChFc3RvbmlhKVxyXG4gIGV0OiBbXCJldC1FRVwiXSxcclxuICAvLyBHcmVla1xyXG4gIGVsOiBbXHJcbiAgICBcImVsLUdSXCIsIC8vIEdyZWVjZVxyXG4gICAgXCJlbC1DWVwiLCAvLyBDeXBydXNcclxuICBdLFxyXG4gIC8vIERhbmlzaCAoRGVubWFyaylcclxuICBkYTogW1wiZGEtREtcIl0sXHJcbiAgLy8gQXplcmJhaWphbmkgKEF6ZXJiYWlqYW4pXHJcbiAgYXo6IFtcImF6LUFaXCJdLFxyXG4gIC8vIFRoYWkgKFRoYWlsYW5kKVxyXG4gIHRoOiBbXCJ0aC1USFwiXSxcclxuICAvLyBTd2VkaXNoIChTd2VkZW4pXHJcbiAgc3Y6IFtcInN2LVNFXCJdLFxyXG4gIC8vIEVuZ2xpc2hcclxuICBlbjogW1xyXG4gICAgXCJlbi1VU1wiLCAvLyBVbml0ZWQgU3RhdGVzXHJcbiAgICBcImVuLUdCXCIsIC8vIFVuaXRlZCBLaW5nZG9tXHJcbiAgICBcImVuLUFVXCIsIC8vIEF1c3RyYWxpYVxyXG4gICAgXCJlbi1DQVwiLCAvLyBDYW5hZGFcclxuICAgIFwiZW4tU0dcIiwgLy8gU2luZ2Fwb3JlXHJcbiAgICBcImVuLUlFXCIsIC8vIElyZWxhbmRcclxuICBdLFxyXG4gIC8vIFNwYW5pc2hcclxuICBlczogW1xyXG4gICAgXCJlcy1FU1wiLCAvLyBTcGFpblxyXG4gICAgXCJlcy00MTlcIiwgLy8gTGF0aW4gQW1lcmljYVxyXG4gICAgXCJlcy1NWFwiLCAvLyBNZXhpY29cclxuICAgIFwiZXMtQVJcIiwgLy8gQXJnZW50aW5hXHJcbiAgXSxcclxuICAvLyBGcmVuY2hcclxuICBmcjogW1xyXG4gICAgXCJmci1GUlwiLCAvLyBGcmFuY2VcclxuICAgIFwiZnItQ0FcIiwgLy8gQ2FuYWRhXHJcbiAgICBcImZyLUJFXCIsIC8vIEJlbGdpdW1cclxuICAgIFwiZnItTFVcIiwgLy8gTHV4ZW1ib3VyZ1xyXG4gIF0sXHJcbiAgLy8gQ2F0YWxhbiAoU3BhaW4pXHJcbiAgY2E6IFtcImNhLUVTXCJdLFxyXG4gIC8vIEphcGFuZXNlIChKYXBhbilcclxuICBqYTogW1wiamEtSlBcIl0sXHJcbiAgLy8gS2F6YWtoIChLYXpha2hzdGFuKVxyXG4gIGtrOiBbXCJray1LWlwiXSxcclxuICAvLyBHZXJtYW5cclxuICBkZTogW1xyXG4gICAgXCJkZS1ERVwiLCAvLyBHZXJtYW55XHJcbiAgICBcImRlLUFUXCIsIC8vIEF1c3RyaWFcclxuICAgIFwiZGUtQ0hcIiwgLy8gU3dpdHplcmxhbmRcclxuICBdLFxyXG4gIC8vIFBvcnR1Z3Vlc2VcclxuICBwdDogW1xyXG4gICAgXCJwdC1QVFwiLCAvLyBQb3J0dWdhbFxyXG4gICAgXCJwdC1CUlwiLCAvLyBCcmF6aWxcclxuICBdLFxyXG4gIC8vIEl0YWxpYW5cclxuICBpdDogW1xyXG4gICAgXCJpdC1JVFwiLCAvLyBJdGFseVxyXG4gICAgXCJpdC1DSFwiLCAvLyBTd2l0emVybGFuZFxyXG4gIF0sXHJcbiAgLy8gUnVzc2lhblxyXG4gIHJ1OiBbXHJcbiAgICBcInJ1LVJVXCIsIC8vIFJ1c3NpYVxyXG4gICAgXCJydS1CWVwiLCAvLyBCZWxhcnVzXHJcbiAgXSxcclxuICAvLyBVa3JhaW5pYW4gKFVrcmFpbmUpXHJcbiAgdWs6IFtcInVrLVVBXCJdLFxyXG4gIC8vIEJlbGFydXNpYW4gKEJlbGFydXMpXHJcbiAgYmU6IFtcImJlLUJZXCJdLFxyXG4gIC8vIEhpbmRpIChJbmRpYSlcclxuICBoaTogW1wiaGktSU5cIl0sXHJcbiAgLy8gQ2hpbmVzZVxyXG4gIHpoOiBbXHJcbiAgICBcInpoLUNOXCIsIC8vIFNpbXBsaWZpZWQgQ2hpbmVzZSAoQ2hpbmEpXHJcbiAgICBcInpoLVRXXCIsIC8vIFRyYWRpdGlvbmFsIENoaW5lc2UgKFRhaXdhbilcclxuICAgIFwiemgtSEtcIiwgLy8gVHJhZGl0aW9uYWwgQ2hpbmVzZSAoSG9uZyBLb25nKVxyXG4gICAgXCJ6aC1TR1wiLCAvLyBTaW1wbGlmaWVkIENoaW5lc2UgKFNpbmdhcG9yZSlcclxuICAgIFwiemgtSGFuc1wiLCAvLyBTaW1wbGlmaWVkIENoaW5lc2VcclxuICAgIFwiemgtSGFudFwiLCAvLyBUcmFkaXRpb25hbCBDaGluZXNlXHJcbiAgICBcInpoLUhhbnQtSEtcIiwgLy8gVHJhZGl0aW9uYWwgQ2hpbmVzZSAoSG9uZyBLb25nKVxyXG4gICAgXCJ6aC1IYW50LVRXXCIsIC8vIFRyYWRpdGlvbmFsIENoaW5lc2UgKFRhaXdhbilcclxuICAgIFwiemgtSGFudC1DTlwiLCAvLyBUcmFkaXRpb25hbCBDaGluZXNlIChDaGluYSlcclxuICAgIFwiemgtSGFucy1IS1wiLCAvLyBTaW1wbGlmaWVkIENoaW5lc2UgKEhvbmcgS29uZylcclxuICAgIFwiemgtSGFucy1UV1wiLCAvLyBTaW1wbGlmaWVkIENoaW5lc2UgKENoaW5hKVxyXG4gICAgXCJ6aC1IYW5zLUNOXCIsIC8vIFNpbXBsaWZpZWQgQ2hpbmVzZSAoQ2hpbmEpXHJcbiAgXSxcclxuICAvLyBLb3JlYW4gKFNvdXRoIEtvcmVhKVxyXG4gIGtvOiBbXCJrby1LUlwiXSxcclxuICAvLyBBcmFiaWNcclxuICBhcjogW1xyXG4gICAgXCJhci1FR1wiLCAvLyBFZ3lwdFxyXG4gICAgXCJhci1TQVwiLCAvLyBTYXVkaSBBcmFiaWFcclxuICAgIFwiYXItQUVcIiwgLy8gVW5pdGVkIEFyYWIgRW1pcmF0ZXNcclxuICAgIFwiYXItTUFcIiwgLy8gTW9yb2Njb1xyXG4gIF0sXHJcbiAgLy8gQnVsZ2FyaWFuIChCdWxnYXJpYSlcclxuICBiZzogW1wiYmctQkdcIl0sXHJcbiAgLy8gQ3plY2ggKEN6ZWNoIFJlcHVibGljKVxyXG4gIGNzOiBbXCJjcy1DWlwiXSxcclxuICAvLyBXZWxzaCAoV2FsZXMpXHJcbiAgY3k6IFtcImN5LUdCXCJdLFxyXG4gIC8vIER1dGNoXHJcbiAgbmw6IFtcclxuICAgIFwibmwtTkxcIiwgLy8gTmV0aGVybGFuZHNcclxuICAgIFwibmwtQkVcIiwgLy8gQmVsZ2l1bVxyXG4gIF0sXHJcbiAgLy8gUG9saXNoIChQb2xhbmQpXHJcbiAgcGw6IFtcInBsLVBMXCJdLFxyXG4gIC8vIEluZG9uZXNpYW4gKEluZG9uZXNpYSlcclxuICBpZDogW1wiaWQtSURcIl0sXHJcbiAgaXM6IFtcImlzLUlTXCJdLFxyXG4gIC8vIE1hbGF5IChNYWxheXNpYSlcclxuICBtczogW1wibXMtTVlcIl0sXHJcbiAgLy8gRmlubmlzaCAoRmlubGFuZClcclxuICBmaTogW1wiZmktRklcIl0sXHJcbiAgLy8gQmFzcXVlIChTcGFpbilcclxuICBldTogW1wiZXUtRVNcIl0sXHJcbiAgLy8gQ3JvYXRpYW4gKENyb2F0aWEpXHJcbiAgaHI6IFtcImhyLUhSXCJdLFxyXG4gIC8vIEhlYnJldyAoSXNyYWVsKSAtIGFsdGVybmF0aXZlIGNvZGVcclxuICBpdzogW1wiaXctSUxcIl0sXHJcbiAgLy8gS2htZXIgKENhbWJvZGlhKVxyXG4gIGttOiBbXCJrbS1LSFwiXSxcclxuICAvLyBMYXR2aWFuIChMYXR2aWEpXHJcbiAgbHY6IFtcImx2LUxWXCJdLFxyXG4gIC8vIExpdGh1YW5pYW4gKExpdGh1YW5pYSlcclxuICBsdDogW1wibHQtTFRcIl0sXHJcbiAgLy8gTm9yd2VnaWFuXHJcbiAgbm86IFtcclxuICAgIFwibm8tTk9cIiwgLy8gTm9yd2F5IChsZWdhY3kpXHJcbiAgICBcIm5iLU5PXCIsIC8vIE5vcndlZ2lhbiBCb2ttXHUwMEU1bFxyXG4gICAgXCJubi1OT1wiLCAvLyBOb3J3ZWdpYW4gTnlub3Jza1xyXG4gIF0sXHJcbiAgLy8gUm9tYW5pYW4gKFJvbWFuaWEpXHJcbiAgcm86IFtcInJvLVJPXCJdLFxyXG4gIC8vIFNsb3ZhayAoU2xvdmFraWEpXHJcbiAgc2s6IFtcInNrLVNLXCJdLFxyXG4gIC8vIFN3YWhpbGlcclxuICBzdzogW1xyXG4gICAgXCJzdy1UWlwiLCAvLyBUYW56YW5pYVxyXG4gICAgXCJzdy1LRVwiLCAvLyBLZW55YVxyXG4gICAgXCJzdy1VR1wiLCAvLyBVZ2FuZGFcclxuICAgIFwic3ctQ0RcIiwgLy8gRGVtb2NyYXRpYyBSZXB1YmxpYyBvZiBDb25nb1xyXG4gICAgXCJzdy1SV1wiLCAvLyBSd2FuZGFcclxuICBdLFxyXG4gIC8vIFBlcnNpYW4gKElyYW4pXHJcbiAgZmE6IFtcImZhLUlSXCJdLFxyXG4gIC8vIEZpbGlwaW5vIChQaGlsaXBwaW5lcylcclxuICBmaWw6IFtcImZpbC1QSFwiXSxcclxuICAvLyBQdW5qYWJpXHJcbiAgcGE6IFtcclxuICAgIFwicGEtSU5cIiwgLy8gSW5kaWFcclxuICAgIFwicGEtUEtcIiwgLy8gUGFraXN0YW5cclxuICBdLFxyXG4gIC8vIEJlbmdhbGlcclxuICBibjogW1xyXG4gICAgXCJibi1CRFwiLCAvLyBCYW5nbGFkZXNoXHJcbiAgICBcImJuLUlOXCIsIC8vIEluZGlhXHJcbiAgXSxcclxuICAvLyBJcmlzaCAoSXJlbGFuZClcclxuICBnYTogW1wiZ2EtSUVcIl0sXHJcbiAgLy8gR2FsaWNpYW4gKFNwYWluKVxyXG4gIGdsOiBbXCJnbC1FU1wiXSxcclxuICAvLyBNYWx0ZXNlIChNYWx0YSlcclxuICBtdDogW1wibXQtTVRcIl0sXHJcbiAgLy8gU2xvdmVuaWFuIChTbG92ZW5pYSlcclxuICBzbDogW1wic2wtU0lcIl0sXHJcbiAgLy8gQWxiYW5pYW4gKEFsYmFuaWEpXHJcbiAgc3E6IFtcInNxLUFMXCJdLFxyXG4gIC8vIEJhdmFyaWFuIChHZXJtYW55KVxyXG4gIGJhcjogW1wiYmFyLURFXCJdLFxyXG4gIC8vIE5lYXBvbGl0YW4gKEl0YWx5KVxyXG4gIG5hcDogW1wibmFwLUlUXCJdLFxyXG4gIC8vIEFmcmlrYWFucyAoU291dGggQWZyaWNhKVxyXG4gIGFmOiBbXCJhZi1aQVwiXSxcclxuICAvLyBVemJlayAoTGF0aW4pXHJcbiAgdXo6IFtcInV6LUxhdG5cIl0sXHJcbiAgLy8gU29tYWxpIChTb21hbGlhKVxyXG4gIHNvOiBbXCJzby1TT1wiXSxcclxuICAvLyBUaWdyaW55YSAoRXRoaW9waWEpXHJcbiAgdGk6IFtcInRpLUVUXCJdLFxyXG4gIC8vIFN0YW5kYXJkIE1vcm9jY2FuIFRhbWF6aWdodCAoTW9yb2NjbylcclxuICB6Z2g6IFtcInpnaC1NQVwiXSxcclxuICAvLyBUYWdhbG9nIChQaGlsaXBwaW5lcylcclxuICB0bDogW1widGwtUEhcIl0sXHJcbiAgLy8gVGVsdWd1IChJbmRpYSlcclxuICB0ZTogW1widGUtSU5cIl0sXHJcbiAgLy8gS2lueWFyd2FuZGEgKFJ3YW5kYSlcclxuICBydzogW1wicnctUldcIl0sXHJcbiAgLy8gR2VvcmdpYW4gKEdlb3JnaWEpXHJcbiAga2E6IFtcImthLUdFXCJdLFxyXG4gIC8vIE1hbGF5YWxhbSAoSW5kaWEpXHJcbiAgbWw6IFtcIm1sLUlOXCJdLFxyXG4gIC8vIEFybWVuaWFuIChBcm1lbmlhKVxyXG4gIGh5OiBbXCJoeS1BTVwiXSxcclxuICAvLyBNYWNlZG9uaWFuIChNYWNlZG9uaWEpXHJcbiAgbWs6IFtcIm1rLU1LXCJdLFxyXG59IGFzIGNvbnN0O1xyXG5cclxuZXhwb3J0IHR5cGUgTG9jYWxlQ29kZVNob3J0ID0ga2V5b2YgdHlwZW9mIGxvY2FsZU1hcDtcclxuZXhwb3J0IHR5cGUgTG9jYWxlQ29kZUZ1bGwgPSAodHlwZW9mIGxvY2FsZU1hcClbTG9jYWxlQ29kZVNob3J0XVtudW1iZXJdO1xyXG5leHBvcnQgdHlwZSBMb2NhbGVDb2RlID0gTG9jYWxlQ29kZVNob3J0IHwgTG9jYWxlQ29kZUZ1bGw7XHJcbmV4cG9ydCB0eXBlIExvY2FsZURlbGltaXRlciA9IFwiLVwiIHwgXCJfXCIgfCBudWxsO1xyXG5cclxuZXhwb3J0IGNvbnN0IGxvY2FsZUNvZGVzU2hvcnQgPSBPYmplY3Qua2V5cyhsb2NhbGVNYXApIGFzIExvY2FsZUNvZGVTaG9ydFtdO1xyXG5leHBvcnQgY29uc3QgbG9jYWxlQ29kZXNGdWxsID0gT2JqZWN0LnZhbHVlcyhcclxuICBsb2NhbGVNYXAsXHJcbikuZmxhdCgpIGFzIExvY2FsZUNvZGVGdWxsW107XHJcbmV4cG9ydCBjb25zdCBsb2NhbGVDb2Rlc0Z1bGxVbmRlcnNjb3JlID0gbG9jYWxlQ29kZXNGdWxsLm1hcCgodmFsdWUpID0+XHJcbiAgdmFsdWUucmVwbGFjZShcIi1cIiwgXCJfXCIpLFxyXG4pO1xyXG5leHBvcnQgY29uc3QgbG9jYWxlQ29kZXNGdWxsRXhwbGljaXRSZWdpb24gPSBsb2NhbGVDb2Rlc0Z1bGwubWFwKCh2YWx1ZSkgPT4ge1xyXG4gIGNvbnN0IGNodW5rcyA9IHZhbHVlLnNwbGl0KFwiLVwiKTtcclxuICBjb25zdCByZXN1bHQgPSBbY2h1bmtzWzBdLCBcIi1yXCIsIGNodW5rcy5zbGljZSgxKS5qb2luKFwiLVwiKV0uam9pbihcIlwiKTtcclxuICByZXR1cm4gcmVzdWx0O1xyXG59KTtcclxuZXhwb3J0IGNvbnN0IGxvY2FsZUNvZGVzID0gW1xyXG4gIC4uLmxvY2FsZUNvZGVzU2hvcnQsXHJcbiAgLi4ubG9jYWxlQ29kZXNGdWxsLFxyXG4gIC4uLmxvY2FsZUNvZGVzRnVsbFVuZGVyc2NvcmUsXHJcbiAgLi4ubG9jYWxlQ29kZXNGdWxsRXhwbGljaXRSZWdpb24sXHJcbl0gYXMgTG9jYWxlQ29kZVtdO1xyXG5cclxuZXhwb3J0IGNvbnN0IGxvY2FsZUNvZGVTY2hlbWEgPSBaLnN0cmluZygpLnJlZmluZShcclxuICAodmFsdWUpID0+IHtcclxuICAgIC8vIE5vcm1hbGl6ZSBsb2NhbGUgYmVmb3JlIHZhbGlkYXRpb25cclxuICAgIGNvbnN0IG5vcm1hbGl6ZWQgPSBub3JtYWxpemVMb2NhbGUodmFsdWUpO1xyXG4gICAgcmV0dXJuIGlzVmFsaWRMb2NhbGUobm9ybWFsaXplZCk7XHJcbiAgfSxcclxuICB7XHJcbiAgICBtZXNzYWdlOiBcIkludmFsaWQgbG9jYWxlIGNvZGVcIixcclxuICB9LFxyXG4pO1xyXG5cclxuLyoqXHJcbiAqIFJlc29sdmVzIGEgbG9jYWxlIGNvZGUgdG8gaXRzIGZ1bGwgbG9jYWxlIHJlcHJlc2VudGF0aW9uLlxyXG4gKlxyXG4gKiAgSWYgdGhlIHByb3ZpZGVkIGxvY2FsZSBjb2RlIGlzIGFscmVhZHkgYSBmdWxsIGxvY2FsZSBjb2RlLCBpdCByZXR1cm5zIGFzIGlzLlxyXG4gKiAgSWYgdGhlIHByb3ZpZGVkIGxvY2FsZSBjb2RlIGlzIGEgc2hvcnQgbG9jYWxlIGNvZGUsIGl0IHJldHVybnMgdGhlIGZpcnN0IGNvcnJlc3BvbmRpbmcgZnVsbCBsb2NhbGUuXHJcbiAqICBJZiB0aGUgbG9jYWxlIGNvZGUgaXMgbm90IGZvdW5kLCBpdCB0aHJvd3MgYW4gZXJyb3IuXHJcbiAqXHJcbiAqIEBwYXJhbSB7bG9jYWxlQ29kZXN9IHZhbHVlIC0gVGhlIGxvY2FsZSBjb2RlIHRvIHJlc29sdmUgKGVpdGhlciBzaG9ydCBvciBmdWxsKVxyXG4gKiBAcmV0dXJuIHtMb2NhbGVDb2RlRnVsbH0gVGhlIHJlc29sdmVkIGZ1bGwgbG9jYWxlIGNvZGVcclxuICogQHRocm93cyB7RXJyb3J9IElmIHRoZSBwcm92aWRlZCBsb2NhbGUgY29kZSBpcyBpbnZhbGlkLlxyXG4gKi9cclxuZXhwb3J0IGNvbnN0IHJlc29sdmVMb2NhbGVDb2RlID0gKHZhbHVlOiBzdHJpbmcpOiBMb2NhbGVDb2RlRnVsbCA9PiB7XHJcbiAgY29uc3QgZXhpc3RpbmdGdWxsTG9jYWxlQ29kZSA9IE9iamVjdC52YWx1ZXMobG9jYWxlTWFwKVxyXG4gICAgLmZsYXQoKVxyXG4gICAgLmluY2x1ZGVzKHZhbHVlIGFzIGFueSk7XHJcbiAgaWYgKGV4aXN0aW5nRnVsbExvY2FsZUNvZGUpIHtcclxuICAgIHJldHVybiB2YWx1ZSBhcyBMb2NhbGVDb2RlRnVsbDtcclxuICB9XHJcblxyXG4gIGNvbnN0IGV4aXN0aW5nU2hvcnRMb2NhbGVDb2RlID0gT2JqZWN0LmtleXMobG9jYWxlTWFwKS5pbmNsdWRlcyh2YWx1ZSk7XHJcbiAgaWYgKGV4aXN0aW5nU2hvcnRMb2NhbGVDb2RlKSB7XHJcbiAgICBjb25zdCBjb3JyZXNwb25kaW5nRnVsbExvY2FsZXMgPSBsb2NhbGVNYXBbdmFsdWUgYXMgTG9jYWxlQ29kZVNob3J0XTtcclxuICAgIGNvbnN0IGZhbGxiYWNrRnVsbExvY2FsZSA9IGNvcnJlc3BvbmRpbmdGdWxsTG9jYWxlc1swXTtcclxuICAgIHJldHVybiBmYWxsYmFja0Z1bGxMb2NhbGU7XHJcbiAgfVxyXG5cclxuICB0aHJvdyBuZXcgRXJyb3IoYEludmFsaWQgbG9jYWxlIGNvZGU6ICR7dmFsdWV9YCk7XHJcbn07XHJcblxyXG4vKipcclxuICogRGV0ZXJtaW5lcyB0aGUgZGVsaW1pdGVyIHVzZWQgaW4gYSBsb2NhbGUgY29kZVxyXG4gKlxyXG4gKiBAcGFyYW0ge3N0cmluZ30gbG9jYWxlIC0gdGhlIGxvY2FsZSBzdHJpbmcgKGUuZy4sXCJlbl9VU1wiLFwiZW4tR0JcIilcclxuICogQHJldHVybiB7IHN0cmluZyB8IG51bGx9IC0gVGhlIGRlbGltaXRlciAoXCJfXCIgb3IgXCItXCIpIGlmIGZvdW5kLCBvdGhlcndpc2UgYG51bGxgLlxyXG4gKi9cclxuXHJcbmV4cG9ydCBjb25zdCBnZXRMb2NhbGVDb2RlRGVsaW1pdGVyID0gKGxvY2FsZTogc3RyaW5nKTogTG9jYWxlRGVsaW1pdGVyID0+IHtcclxuICBpZiAobG9jYWxlLmluY2x1ZGVzKFwiX1wiKSkge1xyXG4gICAgcmV0dXJuIFwiX1wiO1xyXG4gIH0gZWxzZSBpZiAobG9jYWxlLmluY2x1ZGVzKFwiLVwiKSkge1xyXG4gICAgcmV0dXJuIFwiLVwiO1xyXG4gIH0gZWxzZSB7XHJcbiAgICByZXR1cm4gbnVsbDtcclxuICB9XHJcbn07XHJcblxyXG4vKipcclxuICogUmVwbGFjZXMgdGhlIGRlbGltaXRlciBpbiBhIGxvY2FsZSBzdHJpbmcgd2l0aCB0aGUgc3BlY2lmaWVkIGRlbGltaXRlci5cclxuICpcclxuICogQHBhcmFtIHtzdHJpbmd9bG9jYWxlIC0gVGhlIGxvY2FsZSBzdHJpbmcgKGUuZy4sXCJlbl9VU1wiLCBcImVuLUdCXCIpLlxyXG4gKiBAcGFyYW0ge1wiLVwiIHwgXCJfXCIgfCBudWxsfSBbZGVsaW1pdGVyXSAtIFRoZSBuZXcgZGVsaW1pdGVyIHRvIHJlcGxhY2UgdGhlIGV4aXN0aW5nIG9uZS5cclxuICogQHJldHVybnMge3N0cmluZ30gVGhlIGxvY2FsZSBzdHJpbmcgd2l0aCB0aGUgcmVwbGFjZWQgZGVsaW1pdGVyLCBvciB0aGUgb3JpZ2luYWwgbG9jYWxlIGlmIG5vIGRlbGltaXRlciBpcyBwcm92aWRlZC5cclxuICovXHJcblxyXG5leHBvcnQgY29uc3QgcmVzb2x2ZU92ZXJyaWRkZW5Mb2NhbGUgPSAoXHJcbiAgbG9jYWxlOiBzdHJpbmcsXHJcbiAgZGVsaW1pdGVyPzogTG9jYWxlRGVsaW1pdGVyLFxyXG4pOiBzdHJpbmcgPT4ge1xyXG4gIGlmICghZGVsaW1pdGVyKSB7XHJcbiAgICByZXR1cm4gbG9jYWxlO1xyXG4gIH1cclxuXHJcbiAgY29uc3QgY3VycmVudERlbGltaXRlciA9IGdldExvY2FsZUNvZGVEZWxpbWl0ZXIobG9jYWxlKTtcclxuICBpZiAoIWN1cnJlbnREZWxpbWl0ZXIpIHtcclxuICAgIHJldHVybiBsb2NhbGU7XHJcbiAgfVxyXG5cclxuICByZXR1cm4gbG9jYWxlLnJlcGxhY2UoY3VycmVudERlbGltaXRlciwgZGVsaW1pdGVyKTtcclxufTtcclxuXHJcbi8qKlxyXG4gKiBOb3JtYWxpemVzIGEgbG9jYWxlIHN0cmluZyBieSByZXBsYWNpbmcgdW5kZXJzY29yZXMgd2l0aCBoeXBoZW5zXHJcbiAqIGFuZCByZW1vdmluZyB0aGUgXCJyXCIgaW4gY2VydGFpbiByZWdpb25hbCBjb2RlcyAoZS5nLiwgXCJmci1yQ0FcIiBcdTIxOTIgXCJmci1DQVwiKVxyXG4gKlxyXG4gKiBAcGFyYW0ge3N0cmluZ30gbG9jYWxlIC0gVGhlIGxvY2FsZSBzdHJpbmcgKGUuZy4sXCJlbl9VU1wiLCBcImVuLUdCXCIpLlxyXG4gKiBAcmV0dXJuIHtzdHJpbmd9IFRoZSBub3JtYWxpemVkIGxvY2FsZSBzdHJpbmcuXHJcbiAqL1xyXG5cclxuZXhwb3J0IGZ1bmN0aW9uIG5vcm1hbGl6ZUxvY2FsZShsb2NhbGU6IHN0cmluZyk6IHN0cmluZyB7XHJcbiAgcmV0dXJuIGxvY2FsZS5yZXBsYWNlQWxsKFwiX1wiLCBcIi1cIikucmVwbGFjZSgvKFthLXpdezIsM30tKXIvLCBcIiQxXCIpO1xyXG59XHJcbiIsICJjb25zdCBfX2luamVjdGVkX2ZpbGVuYW1lX18gPSBcIkM6XFxcXFVzZXJzXFxcXFRVRiBHQU1JTkdcXFxcT25lRHJpdmVcXFxcRGVza3RvcFxcXFxsaW5nby5kZXYgb3BlbnNvdXJjZVxcXFxsaW5nby5kZXZcXFxccGFja2FnZXNcXFxcc3BlY1xcXFxzcmNcXFxcZm9ybWF0cy50c1wiO2NvbnN0IF9faW5qZWN0ZWRfZGlybmFtZV9fID0gXCJDOlxcXFxVc2Vyc1xcXFxUVUYgR0FNSU5HXFxcXE9uZURyaXZlXFxcXERlc2t0b3BcXFxcbGluZ28uZGV2IG9wZW5zb3VyY2VcXFxcbGluZ28uZGV2XFxcXHBhY2thZ2VzXFxcXHNwZWNcXFxcc3JjXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9DOi9Vc2Vycy9UVUYlMjBHQU1JTkcvT25lRHJpdmUvRGVza3RvcC9saW5nby5kZXYlMjBvcGVuc291cmNlL2xpbmdvLmRldi9wYWNrYWdlcy9zcGVjL3NyYy9mb3JtYXRzLnRzXCI7aW1wb3J0IFogZnJvbSBcInpvZFwiO1xyXG5cclxuZXhwb3J0IGNvbnN0IGJ1Y2tldFR5cGVzID0gW1xyXG4gIFwiYW5kcm9pZFwiLFxyXG4gIFwiY3N2XCIsXHJcbiAgXCJlanNcIixcclxuICBcImZsdXR0ZXJcIixcclxuICBcImh0bWxcIixcclxuICBcImpzb25cIixcclxuICBcImpzb241XCIsXHJcbiAgXCJqc29uY1wiLFxyXG4gIFwibWFya2Rvd25cIixcclxuICBcIm1hcmtkb2NcIixcclxuICBcIm1keFwiLFxyXG4gIFwieGNvZGUtc3RyaW5nc1wiLFxyXG4gIFwieGNvZGUtc3RyaW5nc2RpY3RcIixcclxuICBcInhjb2RlLXhjc3RyaW5nc1wiLFxyXG4gIFwieGNvZGUteGNzdHJpbmdzLXYyXCIsXHJcbiAgXCJ5YW1sXCIsXHJcbiAgXCJ5YW1sLXJvb3Qta2V5XCIsXHJcbiAgXCJwcm9wZXJ0aWVzXCIsXHJcbiAgXCJwb1wiLFxyXG4gIFwieGxpZmZcIixcclxuICBcInhtbFwiLFxyXG4gIFwic3J0XCIsXHJcbiAgXCJkYXRvXCIsXHJcbiAgXCJjb21waWxlclwiLFxyXG4gIFwidnR0XCIsXHJcbiAgXCJwaHBcIixcclxuICBcInBvXCIsXHJcbiAgXCJ2dWUtanNvblwiLFxyXG4gIFwidHlwZXNjcmlwdFwiLFxyXG4gIFwidHh0XCIsXHJcbiAgXCJqc29uLWRpY3Rpb25hcnlcIixcclxuXSBhcyBjb25zdDtcclxuXHJcbmV4cG9ydCBjb25zdCBidWNrZXRUeXBlU2NoZW1hID0gWi5lbnVtKGJ1Y2tldFR5cGVzKTtcclxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUF1WixTQUFTLG9CQUFvQjs7O0FDQWYsT0FBTyxRQUFRO0FBQ3BiLE9BQU8sVUFBVTtBQUNqQixTQUFTLHFCQUFxQjtBQUM5QixTQUFTLHVCQUF1Qjs7O0FDSDJYLE9BQU9BLFFBQU87OztBQ0FaLE9BQU8sT0FBTztBQUMzYSxTQUFTLHFCQUFxQjtBQUU5QixJQUFNLFlBQVk7QUFBQTtBQUFBLEVBRWhCLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUk7QUFBQSxJQUNGO0FBQUE7QUFBQSxJQUNBO0FBQUE7QUFBQSxFQUNGO0FBQUE7QUFBQSxFQUVBLElBQUksQ0FBQyxPQUFPO0FBQUE7QUFBQSxFQUVaLElBQUksQ0FBQyxPQUFPO0FBQUEsRUFDWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJO0FBQUEsSUFDRjtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsRUFDRjtBQUFBO0FBQUEsRUFFQSxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJO0FBQUEsSUFDRjtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsRUFDRjtBQUFBO0FBQUEsRUFFQSxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixLQUFLLENBQUMsUUFBUTtBQUFBO0FBQUEsRUFFZCxJQUFJO0FBQUEsSUFDRjtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsRUFDRjtBQUFBO0FBQUEsRUFFQSxJQUFJO0FBQUEsSUFDRjtBQUFBO0FBQUEsSUFDQTtBQUFBO0FBQUEsRUFDRjtBQUFBO0FBQUEsRUFFQSxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixLQUFLLENBQUMsUUFBUTtBQUFBO0FBQUEsRUFFZCxLQUFLLENBQUMsUUFBUTtBQUFBO0FBQUEsRUFFZCxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsU0FBUztBQUFBO0FBQUEsRUFFZCxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixLQUFLLENBQUMsUUFBUTtBQUFBO0FBQUEsRUFFZCxJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUFBO0FBQUEsRUFFWixJQUFJLENBQUMsT0FBTztBQUNkO0FBT08sSUFBTSxtQkFBbUIsT0FBTyxLQUFLLFNBQVM7QUFDOUMsSUFBTSxrQkFBa0IsT0FBTztBQUFBLEVBQ3BDO0FBQ0YsRUFBRSxLQUFLO0FBQ0EsSUFBTSw0QkFBNEIsZ0JBQWdCO0FBQUEsRUFBSSxDQUFDLFVBQzVELE1BQU0sUUFBUSxLQUFLLEdBQUc7QUFDeEI7QUFDTyxJQUFNLGdDQUFnQyxnQkFBZ0IsSUFBSSxDQUFDLFVBQVU7QUFDMUUsUUFBTSxTQUFTLE1BQU0sTUFBTSxHQUFHO0FBQzlCLFFBQU0sU0FBUyxDQUFDLE9BQU8sQ0FBQyxHQUFHLE1BQU0sT0FBTyxNQUFNLENBQUMsRUFBRSxLQUFLLEdBQUcsQ0FBQyxFQUFFLEtBQUssRUFBRTtBQUNuRSxTQUFPO0FBQ1QsQ0FBQztBQUNNLElBQU0sY0FBYztBQUFBLEVBQ3pCLEdBQUc7QUFBQSxFQUNILEdBQUc7QUFBQSxFQUNILEdBQUc7QUFBQSxFQUNILEdBQUc7QUFDTDtBQUVPLElBQU0sbUJBQW1CLEVBQUUsT0FBTyxFQUFFO0FBQUEsRUFDekMsQ0FBQyxVQUFVO0FBRVQsVUFBTSxhQUFhLGdCQUFnQixLQUFLO0FBQ3hDLFdBQU8sY0FBYyxVQUFVO0FBQUEsRUFDakM7QUFBQSxFQUNBO0FBQUEsSUFDRSxTQUFTO0FBQUEsRUFDWDtBQUNGO0FBZ0ZPLFNBQVMsZ0JBQWdCLFFBQXdCO0FBQ3RELFNBQU8sT0FBTyxXQUFXLEtBQUssR0FBRyxFQUFFLFFBQVEsa0JBQWtCLElBQUk7QUFDbkU7OztBQ25WNlosT0FBT0MsUUFBTztBQUVwYSxJQUFNLGNBQWM7QUFBQSxFQUN6QjtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUFBLEVBQ0E7QUFBQSxFQUNBO0FBQUEsRUFDQTtBQUNGO0FBRU8sSUFBTSxtQkFBbUJDLEdBQUUsS0FBSyxXQUFXOzs7QUYvQjNDLElBQU0sZUFBZUMsR0FBRSxPQUFPO0FBQUEsRUFDbkMsUUFBUSxpQkFBaUI7QUFBQSxJQUN2QjtBQUFBLEVBQ0Y7QUFBQSxFQUNBLFNBQVNBLEdBQUUsTUFBTSxnQkFBZ0IsRUFBRTtBQUFBLElBQ2pDO0FBQUEsRUFDRjtBQUNGLENBQUMsRUFBRSxTQUFTLDZCQUE2QjtBQVd6QyxJQUFNLHlCQUF5QixDQUk3QixlQUNHO0FBZ0JMLElBQU0seUJBQXlCLENBSTdCLFlBQ0EsV0FDRztBQUNILFFBQU0sU0FBUyxPQUFPLGFBQWEsV0FBVyxNQUFNO0FBQ3BELFFBQU0sZUFBZSxPQUFPLG1CQUFtQixXQUFXLFlBQVk7QUFDdEUsUUFBTSxXQUFXLENBQUMsV0FDaEIsT0FBTyxlQUFlLFFBQVEsUUFBUSxZQUFZO0FBRXBELFNBQU8sdUJBQXVCO0FBQUEsSUFDNUI7QUFBQSxJQUNBO0FBQUEsSUFDQSxPQUFPLENBQUMsY0FBYztBQUNwQixZQUFNLGFBQWEsT0FBTyxVQUFVLFNBQVM7QUFDN0MsVUFBSSxXQUFXLFNBQVM7QUFDdEIsZUFBTyxXQUFXO0FBQUEsTUFDcEI7QUFFQSxZQUFNLGVBQWUsV0FBVyxNQUFNLE9BQ25DLE9BQU8sQ0FBQyxVQUFVLE1BQU0sUUFBUSxTQUFTLHFCQUFxQixDQUFDLEVBQy9ELElBQUksQ0FBQyxVQUFVO0FBQ2QsWUFBSSxvQkFBb0I7QUFDeEIsY0FBTUMsUUFBTyxNQUFNO0FBRW5CLGNBQU0sU0FBUztBQUVmLFlBQUksT0FBTyxRQUFRO0FBQ2pCLDhCQUFvQkEsTUFBSyxPQUFZLENBQUMsS0FBSyxRQUFRO0FBQ2pELGdCQUFJLE9BQU8sT0FBTyxRQUFRLFlBQVksT0FBTyxLQUFLO0FBQ2hELHFCQUFPLElBQUksR0FBRztBQUFBLFlBQ2hCO0FBQ0EsbUJBQU87QUFBQSxVQUNULEdBQUcsT0FBTyxNQUFNO0FBQUEsUUFDbEI7QUFFQSxlQUFPLHVCQUF1QixpQkFBaUI7QUFBQSxNQUNqRCxDQUFDO0FBRUgsVUFBSSxhQUFhLFNBQVMsR0FBRztBQUMzQixjQUFNLElBQUksTUFBTTtBQUFBLEVBQUssYUFBYSxLQUFLLElBQUksQ0FBQyxFQUFFO0FBQUEsTUFDaEQ7QUFFQSxZQUFNLGFBQWEsV0FBVyxNQUFNLFNBQVM7QUFDN0MsWUFBTSxTQUFTLFNBQVMsVUFBVTtBQUNsQyxhQUFPO0FBQUEsSUFDVDtBQUFBLEVBQ0YsQ0FBQztBQUNIO0FBR0EsSUFBTSxpQkFBaUJELEdBQUUsT0FBTztBQUFBLEVBQzlCLFNBQVNBLEdBQUUsTUFBTSxDQUFDQSxHQUFFLE9BQU8sR0FBR0EsR0FBRSxPQUFPLENBQUMsQ0FBQyxFQUN0QyxRQUFRLENBQUMsRUFDVCxTQUFTLG1DQUFtQztBQUNqRCxDQUFDO0FBQ00sSUFBTSxxQkFBcUIsdUJBQXVCO0FBQUEsRUFDdkQsUUFBUTtBQUFBLEVBQ1IsY0FBYyxFQUFFLFNBQVMsRUFBRTtBQUFBLEVBQzNCLE9BQU8sQ0FBQyxjQUFjO0FBQ3BCLFdBQU8sZUFBZSxNQUFNLFNBQVM7QUFBQSxFQUN2QztBQUNGLENBQUM7QUFHTSxJQUFNLHFCQUFxQix1QkFBdUIsb0JBQW9CO0FBQUEsRUFDM0UsY0FBYyxDQUFDLGVBQ2IsV0FBVyxPQUFPO0FBQUEsSUFDaEIsUUFBUTtBQUFBLElBQ1IsU0FBU0EsR0FBRSxPQUFPQSxHQUFFLE9BQU8sR0FBRyxnQkFBZ0IsRUFDM0MsUUFBUSxDQUFDLENBQUMsRUFDVjtBQUFBLE1BQ0M7QUFBQSxJQUNGLEVBQ0MsU0FBUztBQUFBLEVBQ2QsQ0FBQztBQUFBLEVBQ0gsb0JBQW9CLE9BQU87QUFBQSxJQUN6QixTQUFTO0FBQUEsSUFDVCxRQUFRO0FBQUEsTUFDTixRQUFRO0FBQUEsTUFDUixTQUFTLENBQUMsSUFBYTtBQUFBLElBQ3pCO0FBQUEsSUFDQSxTQUFTLENBQUM7QUFBQSxFQUNaO0FBQUEsRUFDQSxnQkFBZ0IsT0FBTztBQUFBLElBQ3JCLFNBQVM7QUFBQSxJQUNULFFBQVE7QUFBQSxNQUNOLFFBQVE7QUFBQSxNQUNSLFNBQVMsQ0FBQyxJQUFhO0FBQUEsSUFDekI7QUFBQSxJQUNBLFNBQVMsQ0FBQztBQUFBLEVBQ1o7QUFDRixDQUFDO0FBR00sSUFBTSx1QkFBdUIsdUJBQXVCLG9CQUFvQjtBQUFBLEVBQzdFLGNBQWMsQ0FBQyxlQUNiLFdBQVcsT0FBTztBQUFBLElBQ2hCLFNBQVNBLEdBQUU7QUFBQSxNQUNUO0FBQUEsTUFDQUEsR0FBRSxPQUFPO0FBQUEsUUFDUCxTQUFTQSxHQUFFLE1BQU1BLEdBQUUsT0FBTyxDQUFDLEVBQ3hCLFFBQVEsQ0FBQyxDQUFDLEVBQ1Y7QUFBQSxVQUNDO0FBQUEsUUFDRjtBQUFBLFFBQ0YsU0FBU0EsR0FBRSxNQUFNQSxHQUFFLE9BQU8sQ0FBQyxFQUN4QixRQUFRLENBQUMsQ0FBQyxFQUNWLFNBQVMsRUFDVDtBQUFBLFVBQ0M7QUFBQSxRQUNGO0FBQUEsTUFDSixDQUFDO0FBQUEsSUFDSCxFQUFFLFFBQVEsQ0FBQyxDQUFDO0FBQUEsRUFDZCxDQUFDO0FBQUEsRUFDSCxvQkFBb0IsQ0FBQyxzQkFBc0I7QUFBQSxJQUN6QyxHQUFHO0FBQUEsSUFDSCxTQUFTO0FBQUEsSUFDVCxTQUFTLENBQUM7QUFBQSxFQUNaO0FBQUEsRUFDQSxnQkFBZ0IsQ0FBQyxXQUFXLFdBQVc7QUFDckMsVUFBTSxpQkFBeUM7QUFBQSxNQUM3QyxHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsTUFDVCxTQUFTLENBQUM7QUFBQSxJQUNaO0FBR0EsUUFBSSxVQUFVLFNBQVM7QUFDckIsaUJBQVcsQ0FBQyxZQUFZLFVBQVUsS0FBSyxPQUFPO0FBQUEsUUFDNUMsVUFBVTtBQUFBLE1BQ1osR0FBRztBQUNELFlBQUksQ0FBQyxlQUFlLFFBQVEsVUFBVSxHQUFHO0FBQ3ZDLHlCQUFlLFFBQVEsVUFBVSxJQUFJO0FBQUEsWUFDbkMsU0FBUyxDQUFDO0FBQUEsVUFDWjtBQUFBLFFBQ0Y7QUFDQSx1QkFBZSxRQUFRLFVBQVUsR0FBRyxRQUFRLEtBQUssVUFBVTtBQUFBLE1BQzdEO0FBQUEsSUFDRjtBQUVBLFdBQU87QUFBQSxFQUNUO0FBQ0YsQ0FBQztBQUlNLElBQU0sdUJBQXVCO0FBQUEsRUFDbEM7QUFBQSxFQUNBO0FBQUEsSUFDRSxjQUFjLENBQUMsZUFDYixXQUFXLE9BQU87QUFBQSxNQUNoQixRQUFRLGFBQWEsT0FBTztBQUFBLFFBQzFCLGFBQWEsaUJBQ1YsU0FBUyxFQUNUO0FBQUEsVUFDQztBQUFBLFFBQ0Y7QUFBQSxNQUNKLENBQUM7QUFBQSxJQUNILENBQUM7QUFBQSxJQUNILG9CQUFvQixDQUFDLHNCQUFzQjtBQUFBLE1BQ3pDLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsSUFDQSxnQkFBZ0IsQ0FBQyxlQUFlO0FBQUEsTUFDOUIsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxFQUNGO0FBQ0Y7QUFJTyxJQUFNLG1CQUFtQkEsR0FBRSxPQUFPO0FBQUEsRUFDdkMsTUFBTUEsR0FBRSxPQUFPLEVBQUUsU0FBUyxpREFBaUQ7QUFBQSxFQUMzRSxXQUFXQSxHQUFFLE1BQU0sQ0FBQ0EsR0FBRSxRQUFRLEdBQUcsR0FBR0EsR0FBRSxRQUFRLEdBQUcsR0FBR0EsR0FBRSxRQUFRLElBQUksQ0FBQyxDQUFDLEVBQ2pFLFNBQVMsRUFDVDtBQUFBLElBQ0M7QUFBQSxFQUNGO0FBQ0osQ0FBQyxFQUFFO0FBQUEsRUFDRDtBQUNGO0FBSU8sSUFBTSx3QkFBd0JBLEdBQUUsT0FBTztBQUFBLEVBQzVDLFNBQVNBLEdBQUUsTUFBTUEsR0FBRSxNQUFNLENBQUNBLEdBQUUsT0FBTyxHQUFHLGdCQUFnQixDQUFDLENBQUMsRUFDckQsUUFBUSxDQUFDLENBQUMsRUFDVixTQUFTLDJEQUEyRDtBQUFBLEVBQ3ZFLFNBQVNBLEdBQUUsTUFBTUEsR0FBRSxNQUFNLENBQUNBLEdBQUUsT0FBTyxHQUFHLGdCQUFnQixDQUFDLENBQUMsRUFDckQsUUFBUSxDQUFDLENBQUMsRUFDVixTQUFTLEVBQ1QsU0FBUyw0REFBNEQ7QUFBQSxFQUN4RSxjQUFjQSxHQUFFLE1BQU1BLEdBQUUsT0FBTyxDQUFDLEVBQzdCLFNBQVMsRUFDVDtBQUFBLElBQ0M7QUFBQSxFQUNGO0FBQ0osQ0FBQyxFQUFFLFNBQVMsaURBQWlEO0FBRXRELElBQU0sdUJBQXVCO0FBQUEsRUFDbEM7QUFBQSxFQUNBO0FBQUEsSUFDRSxjQUFjLENBQUMsZUFDYixXQUFXLE9BQU87QUFBQSxNQUNoQixTQUFTQSxHQUFFLE9BQU8sa0JBQWtCLHFCQUFxQixFQUFFLFFBQVEsQ0FBQyxDQUFDO0FBQUEsSUFDdkUsQ0FBQztBQUFBLElBQ0gsb0JBQW9CLENBQUMsc0JBQXNCO0FBQUEsTUFDekMsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxJQUNBLGdCQUFnQixDQUFDLGVBQWU7QUFBQSxNQUM5QixHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsSUFDWDtBQUFBLEVBQ0Y7QUFDRjtBQUVBLElBQU0sZUFBZTtBQUlkLElBQU0sdUJBQXVCO0FBQUEsRUFDbEM7QUFBQSxFQUNBO0FBQUEsSUFDRSxjQUFjLENBQUMsZUFDYixXQUFXLE9BQU87QUFBQSxNQUNoQixTQUFTQSxHQUFFLE9BQU8sRUFBRSxRQUFRLFlBQVk7QUFBQSxJQUMxQyxDQUFDO0FBQUEsSUFDSCxvQkFBb0IsQ0FBQyxzQkFBc0I7QUFBQSxNQUN6QyxHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsTUFDVCxTQUFTO0FBQUEsSUFDWDtBQUFBLElBQ0EsZ0JBQWdCLENBQUMsZUFBZTtBQUFBLE1BQzlCLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxNQUNULFNBQVM7QUFBQSxJQUNYO0FBQUEsRUFDRjtBQUNGO0FBSUEsSUFBTSxpQkFBaUJBLEdBQUUsT0FBTztBQUFBLEVBQzlCLElBQUlBLEdBQUUsS0FBSztBQUFBLElBQ1Q7QUFBQSxJQUNBO0FBQUEsSUFDQTtBQUFBLElBQ0E7QUFBQSxJQUNBO0FBQUEsSUFDQTtBQUFBLEVBQ0YsQ0FBQyxFQUFFLFNBQVMsaURBQWlEO0FBQUEsRUFDN0QsT0FBT0EsR0FBRSxPQUFPLEVBQUUsU0FBUyxxQ0FBcUM7QUFBQSxFQUNoRSxRQUFRQSxHQUFFLE9BQU8sRUFBRTtBQUFBLElBQ2pCO0FBQUEsRUFDRjtBQUFBLEVBQ0EsU0FBU0EsR0FBRSxPQUFPLEVBQ2YsU0FBUyxFQUNULFNBQVMsa0RBQWtEO0FBQ2hFLENBQUMsRUFBRSxTQUFTLHFEQUFxRDtBQUMxRCxJQUFNLHVCQUF1QjtBQUFBLEVBQ2xDO0FBQUEsRUFDQTtBQUFBLElBQ0UsY0FBYyxDQUFDLGVBQ2IsV0FBVyxPQUFPO0FBQUEsTUFDaEIsVUFBVSxlQUFlLFNBQVM7QUFBQSxJQUNwQyxDQUFDO0FBQUEsSUFDSCxvQkFBb0IsQ0FBQyxzQkFBc0I7QUFBQSxNQUN6QyxHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsSUFDWDtBQUFBLElBQ0EsZ0JBQWdCLENBQUMsZUFBZTtBQUFBLE1BQzlCLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsRUFDRjtBQUNGO0FBSU8sSUFBTSx3QkFBd0Isc0JBQXNCLE9BQU87QUFBQSxFQUNoRSxZQUFZQSxHQUFFLE1BQU1BLEdBQUUsT0FBTyxDQUFDLEVBQzNCLFFBQVEsQ0FBQyxDQUFDLEVBQ1YsU0FBUyxFQUNUO0FBQUEsSUFDQztBQUFBLEVBQ0Y7QUFDSixDQUFDO0FBRU0sSUFBTSx1QkFBdUI7QUFBQSxFQUNsQztBQUFBLEVBQ0E7QUFBQSxJQUNFLGNBQWMsQ0FBQyxlQUNiLFdBQVcsT0FBTztBQUFBLE1BQ2hCLFNBQVNBLEdBQUUsT0FBTyxrQkFBa0IscUJBQXFCLEVBQUUsUUFBUSxDQUFDLENBQUM7QUFBQSxJQUN2RSxDQUFDO0FBQUEsSUFDSCxvQkFBb0IsQ0FBQyxzQkFBc0I7QUFBQSxNQUN6QyxHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsSUFDWDtBQUFBLElBQ0EsZ0JBQWdCLENBQUMsZUFBZTtBQUFBLE1BQzlCLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsRUFDRjtBQUNGO0FBR08sSUFBTSx3QkFBd0Isc0JBQXNCLE9BQU87QUFBQSxFQUNoRSxnQkFBZ0JBLEdBQUUsTUFBTUEsR0FBRSxPQUFPLENBQUMsRUFDL0IsUUFBUSxDQUFDLENBQUMsRUFDVixTQUFTLEVBQ1Q7QUFBQSxJQUNDO0FBQUEsRUFDRjtBQUNKLENBQUM7QUFFTSxJQUFNLHVCQUF1QjtBQUFBLEVBQ2xDO0FBQUEsRUFDQTtBQUFBLElBQ0UsY0FBYyxDQUFDLGVBQ2IsV0FBVyxPQUFPO0FBQUEsTUFDaEIsU0FBU0EsR0FBRSxPQUFPLGtCQUFrQixxQkFBcUIsRUFBRSxRQUFRLENBQUMsQ0FBQztBQUFBLElBQ3ZFLENBQUM7QUFBQSxJQUNILG9CQUFvQixDQUFDLHNCQUFzQjtBQUFBLE1BQ3pDLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsSUFDQSxnQkFBZ0IsQ0FBQyxlQUFlO0FBQUEsTUFDOUIsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxFQUNGO0FBQ0Y7QUFJTyxJQUFNLHdCQUF3QixzQkFBc0IsT0FBTztBQUFBLEVBQ2hFLGFBQWFBLEdBQUUsTUFBTUEsR0FBRSxPQUFPLENBQUMsRUFDNUIsUUFBUSxDQUFDLENBQUMsRUFDVixTQUFTLEVBQ1Q7QUFBQSxJQUNDO0FBQUEsRUFDRjtBQUNKLENBQUM7QUFFTSxJQUFNLHVCQUF1QjtBQUFBLEVBQ2xDO0FBQUEsRUFDQTtBQUFBLElBQ0UsY0FBYyxDQUFDLGVBQ2IsV0FBVyxPQUFPO0FBQUEsTUFDaEIsU0FBU0EsR0FBRSxPQUFPLGtCQUFrQixxQkFBcUIsRUFBRSxRQUFRLENBQUMsQ0FBQztBQUFBLElBQ3ZFLENBQUM7QUFBQSxJQUNILG9CQUFvQixDQUFDLHNCQUFzQjtBQUFBLE1BQ3pDLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsSUFDQSxnQkFBZ0IsQ0FBQyxlQUFlO0FBQUEsTUFDOUIsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxFQUNGO0FBQ0Y7QUFJTyxJQUFNLHVCQUF1QjtBQUFBLEVBQ2xDO0FBQUEsRUFDQTtBQUFBLElBQ0UsY0FBYyxDQUFDLGVBQ2IsV0FBVyxPQUFPO0FBQUEsTUFDaEIsV0FBV0EsR0FBRSxLQUFLLENBQUMsWUFBWSxPQUFPLENBQUMsRUFDcEMsU0FBUyxFQUNUO0FBQUEsUUFDQztBQUFBLE1BQ0Y7QUFBQSxJQUNKLENBQUM7QUFBQSxJQUNILG9CQUFvQixDQUFDLHNCQUFzQjtBQUFBLE1BQ3pDLEdBQUc7QUFBQSxNQUNILFNBQVM7QUFBQSxJQUNYO0FBQUEsSUFDQSxnQkFBZ0IsQ0FBQyxlQUFlO0FBQUEsTUFDOUIsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxFQUNGO0FBQ0Y7QUFJQSxJQUFNLHNCQUFzQkEsR0FBRSxPQUFPO0FBQUEsRUFDbkMsYUFBYUEsR0FBRSxPQUFPLEVBQ25CLElBQUksQ0FBQyxFQUNMLElBQUksQ0FBQyxFQUNMLFNBQVMsRUFDVDtBQUFBLElBQ0M7QUFBQSxFQUNGO0FBQ0osQ0FBQyxFQUNFLFNBQVMsRUFDVCxTQUFTLG1EQUFtRDtBQUUvRCxJQUFNLHNCQUFzQkEsR0FBRSxPQUFPO0FBQUEsRUFDbkMsSUFBSUEsR0FBRSxLQUFLO0FBQUEsSUFDVDtBQUFBLElBQ0E7QUFBQSxJQUNBO0FBQUEsSUFDQTtBQUFBLElBQ0E7QUFBQSxJQUNBO0FBQUEsRUFDRixDQUFDLEVBQUUsU0FBUyxpREFBaUQ7QUFBQSxFQUM3RCxPQUFPQSxHQUFFLE9BQU8sRUFBRSxTQUFTLHFDQUFxQztBQUFBLEVBQ2hFLFFBQVFBLEdBQUUsT0FBTyxFQUFFO0FBQUEsSUFDakI7QUFBQSxFQUNGO0FBQUEsRUFDQSxTQUFTQSxHQUFFLE9BQU8sRUFDZixTQUFTLEVBQ1QsU0FBUyxrREFBa0Q7QUFBQSxFQUM5RCxVQUFVO0FBQ1osQ0FBQyxFQUFFLFNBQVMscURBQXFEO0FBRTFELElBQU0sd0JBQXdCO0FBQUEsRUFDbkM7QUFBQSxFQUNBO0FBQUEsSUFDRSxjQUFjLENBQUMsZUFDYixXQUFXLE9BQU87QUFBQSxNQUNoQixVQUFVLG9CQUFvQixTQUFTO0FBQUEsSUFDekMsQ0FBQztBQUFBLElBQ0gsb0JBQW9CLENBQUMsc0JBQXNCO0FBQUEsTUFDekMsR0FBRztBQUFBLE1BQ0gsU0FBUztBQUFBLElBQ1g7QUFBQSxJQUNBLGdCQUFnQixDQUFDLGVBQWU7QUFBQSxNQUM5QixHQUFHO0FBQUEsTUFDSCxTQUFTO0FBQUEsSUFDWDtBQUFBLEVBQ0Y7QUFDRjtBQUdPLElBQU0sMkJBQTJCO0FBYWpDLElBQU0sZ0JBQWdCLHlCQUF5Qjs7O0FEcmZ1TixJQUFNLCtCQUErQjtBQU1uUyxTQUFSLGtCQUFtQztBQUN4QyxRQUFNRSxnQkFBZSxnQkFBZ0IseUJBQXlCLE1BQU07QUFDcEUsUUFBTSxhQUFhLEtBQUssUUFBUSxjQUFjLDRCQUFlLENBQUM7QUFDOUQsS0FBRztBQUFBLElBQ0QsR0FBRyxVQUFVO0FBQUEsSUFDYixLQUFLLFVBQVVBLGVBQWMsTUFBTSxDQUFDO0FBQUEsRUFDdEM7QUFDRjs7O0FEVkEsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsT0FBTztBQUFBLEVBQ1AsUUFBUTtBQUFBLEVBQ1IsT0FBTyxDQUFDLGNBQWM7QUFBQSxFQUN0QixRQUFRO0FBQUEsRUFDUixRQUFRLENBQUMsT0FBTyxLQUFLO0FBQUEsRUFDckIsS0FBSztBQUFBLEVBQ0wsWUFBWTtBQUFBLEVBQ1osV0FBVztBQUFBLEVBQ1gsY0FBYyxDQUFDLFNBQVM7QUFBQSxJQUN0QixJQUFJLElBQUksV0FBVyxRQUFRLFNBQVM7QUFBQSxFQUN0QztBQUFBLEVBQ0EsV0FBVyxZQUFZO0FBQ3JCLG9CQUFnQjtBQUFBLEVBQ2xCO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFsiWiIsICJaIiwgIloiLCAiWiIsICJwYXRoIiwgImNvbmZpZ1NjaGVtYSJdCn0K diff --git a/packages/spec/tsup.config.ts b/packages/spec/tsup.config.ts new file mode 100644 index 000000000..297ea8cb4 --- /dev/null +++ b/packages/spec/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsup"; +import buildJsonSchema from "./src/json-schema"; + +export default defineConfig({ + clean: true, + target: "esnext", + entry: ["src/index.ts"], + outDir: "build", + format: ["cjs", "esm"], + dts: true, + cjsInterop: true, + splitting: true, + outExtension: (ctx) => ({ + js: ctx.format === "cjs" ? ".cjs" : ".mjs", + }), + onSuccess: async () => { + buildJsonSchema(); + }, +}); diff --git a/cmp/pnpm-lock.yaml b/pnpm-lock.yaml similarity index 55% rename from cmp/pnpm-lock.yaml rename to pnpm-lock.yaml index 4932e7669..d3197be04 100644 --- a/cmp/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,152 +7,62 @@ settings: importers: .: - devDependencies: - husky: - specifier: ^9.1.7 - version: 9.1.7 - nano-staged: - specifier: ^0.9.0 - version: 0.9.0 - oxlint: - specifier: ^1.29.0 - version: 1.29.0(oxlint-tsgolint@0.8.0) - oxlint-tsgolint: - specifier: ^0.8.0 - version: 0.8.0 - prettier: - specifier: ^3.6.2 - version: 3.6.2 - turbo: - specifier: ^2.6.1 - version: 2.6.1 - - compiler: dependencies: - '@ai-sdk/google': - specifier: ^1.2.19 - version: 1.2.22(zod@3.25.76) - '@ai-sdk/groq': - specifier: ^1.2.3 - version: 1.2.9(zod@3.25.76) - '@ai-sdk/mistral': - specifier: ^1.2.8 - version: 1.2.8(zod@3.25.76) - '@babel/core': - specifier: ^7.26.0 - version: 7.28.5 + '@changesets/changelog-github': + specifier: 0.5.1 + version: 0.5.1(encoding@0.1.13) + '@changesets/cli': + specifier: 2.29.7 + version: 2.29.7(@types/node@25.0.3) + minimatch: + specifier: 10.1.1 + version: 10.1.1 + node-machine-id: + specifier: 1.1.12 + version: 1.1.12 + devDependencies: '@babel/generator': - specifier: ^7.28.5 + specifier: 7.28.5 version: 7.28.5 '@babel/parser': - specifier: ^7.28.5 + specifier: 7.28.5 version: 7.28.5 - '@babel/preset-react': - specifier: ^7.26.3 - version: 7.28.5(@babel/core@7.28.5) - '@babel/preset-typescript': - specifier: ^7.26.0 - version: 7.28.5(@babel/core@7.28.5) '@babel/traverse': - specifier: ^7.28.5 + specifier: 7.28.5 version: 7.28.5 '@babel/types': - specifier: ^7.28.5 + specifier: 7.28.5 version: 7.28.5 - '@formatjs/icu-messageformat-parser': - specifier: ^2.11.4 - version: 2.11.4 - '@openrouter/ai-sdk-provider': - specifier: ^0.7.1 - version: 0.7.5(ai@4.3.19(react@19.2.1)(zod@3.25.76))(zod@3.25.76) - ai: - specifier: ^4.3.19 - version: 4.3.19(react@19.2.1)(zod@3.25.76) - dotenv: - specifier: ^16.4.5 - version: 16.6.1 - fast-xml-parser: - specifier: ^5.0.8 - version: 5.3.2 - intl-messageformat: - specifier: ^10.7.18 - version: 10.7.18 - lingo.dev: - specifier: ^0.117.0 - version: 0.117.0(@types/node@22.19.1)(@types/react@18.3.27)(encoding@0.1.13)(next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) - lodash: - specifier: ^4.17.21 - version: 4.17.21 - ollama-ai-provider: - specifier: ^1.2.0 - version: 1.2.0(zod@3.25.76) - proper-lockfile: - specifier: ^4.1.2 - version: 4.1.2 - react: - specifier: ^19.0.0 - version: 19.2.1 - react-dom: - specifier: ' ^19.0.0' - version: 19.2.1(react@19.2.1) - ws: - specifier: ^8.18.3 - version: 8.18.3 - devDependencies: - '@playwright/test': - specifier: ^1.56.1 - version: 1.56.1 - '@types/babel__core': - specifier: ^7.20.5 - version: 7.20.5 - '@types/babel__generator': - specifier: ^7.27.0 - version: 7.27.0 + '@commitlint/cli': + specifier: 19.8.1 + version: 19.8.1(@types/node@25.0.3)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: 19.8.1 + version: 19.8.1 '@types/babel__traverse': - specifier: ^7.28.0 + specifier: 7.28.0 version: 7.28.0 - '@types/node': - specifier: ^22.10.5 - version: 22.19.1 - '@types/proper-lockfile': - specifier: ^4.1.4 - version: 4.1.4 - '@types/react': - specifier: ^18.3.26 - version: 18.3.27 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@18.3.27) - '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 - next: - specifier: ^16.1.0 - version: 16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - tsdown: - specifier: ^0.16.5 - version: 0.16.6(typescript@5.9.3) - tsx: - specifier: ^4.19.2 - version: 4.20.6 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - unplugin: - specifier: ^2.3.11 - version: 2.3.11 - vitest: - specifier: ^4.0.13 - version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + commitlint: + specifier: 19.8.1 + version: 19.8.1(@types/node@25.0.3)(typescript@5.9.3) + husky: + specifier: 9.1.7 + version: 9.1.7 + syncpack: + specifier: 13.0.4 + version: 13.0.4(typescript@5.9.3) + turbo: + specifier: 2.6.1 + version: 2.6.1 - demo/next16: + demo/new-compiler-next16: dependencies: '@lingo.dev/compiler': specifier: workspace:* - version: link:../../compiler + version: link:../../packages/new-compiler next: - specifier: ^16.0.4 - version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 16.0.4 + version: 16.0.4(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: specifier: 19.2.0 version: 19.2.0 @@ -161,250 +71,990 @@ importers: version: 19.2.0(react@19.2.0) devDependencies: '@tailwindcss/postcss': - specifier: ^4 + specifier: '4' version: 4.1.17 '@types/node': - specifier: ^20 + specifier: '20' version: 20.19.25 '@types/react': - specifier: ^19 - version: 19.2.6 + specifier: '19' + version: 19.2.7 '@types/react-dom': - specifier: ^19 - version: 19.2.3(@types/react@19.2.6) + specifier: '19' + version: 19.2.3(@types/react@19.2.7) eslint: - specifier: ^9 + specifier: '9' version: 9.39.1(jiti@2.6.1) eslint-config-next: specifier: 16.0.3 - version: 16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + version: 16.0.3(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) tailwindcss: - specifier: ^4 + specifier: '4' version: 4.1.17 tsx: - specifier: ^4.20.6 + specifier: 4.20.6 version: 4.20.6 typescript: - specifier: ^5 - version: 5.9.3 - - demo/react-router: - dependencies: - '@lingo.dev/compiler': - specifier: workspace:* - version: link:../../compiler - '@react-router/node': - specifier: ^7.9.6 - version: 7.9.6(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3) - '@react-router/serve': - specifier: ^7.9.6 - version: 7.9.6(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3) - isbot: - specifier: ^5.1.32 - version: 5.1.32 - react: - specifier: ^19.2.0 - version: 19.2.0 - react-dom: - specifier: ^19.2.0 - version: 19.2.0(react@19.2.0) - react-router: - specifier: ^7.9.6 - version: 7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - devDependencies: - '@react-router/dev': - specifier: ^7.9.6 - version: 7.9.6(@react-router/serve@7.9.6(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3))(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1) - '@tailwindcss/vite': - specifier: ^4.1.17 - version: 4.1.17(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) - '@types/react': - specifier: ^19.2.7 - version: 19.2.7 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.7) - '@vitejs/plugin-react': - specifier: ^5.1.1 - version: 5.1.1(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) - autoprefixer: - specifier: ^10.4.22 - version: 10.4.22(postcss@8.5.6) - postcss: - specifier: ^8.5.6 - version: 8.5.6 - tailwindcss: - specifier: ^4.1.17 - version: 4.1.17 - typescript: - specifier: ^5.9.3 + specifier: '5' version: 5.9.3 - vite: - specifier: ^7.2.4 - version: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) - demo/vite-react-spa: + demo/new-compiler-vite-react-spa: dependencies: '@lingo.dev/compiler': specifier: workspace:* - version: link:../../compiler + version: link:../../packages/new-compiler '@tailwindcss/vite': - specifier: ^4.0.6 - version: 4.1.17(vite@7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + specifier: 4.1.18 + version: 4.1.18(vite@7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/react-devtools': - specifier: ^0.7.0 - version: 0.7.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.10) + specifier: 0.7.0 + version: 0.7.0(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(csstype@3.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.10) '@tanstack/react-router': - specifier: ^1.132.0 - version: 1.139.14(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 1.132.0 + version: 1.132.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/react-router-devtools': - specifier: ^1.132.0 - version: 1.139.14(@tanstack/react-router@1.139.14(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@tanstack/router-core@1.139.14)(@types/node@22.19.1)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.10)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + specifier: 1.132.0 + version: 1.132.0(@tanstack/react-router@1.132.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@tanstack/router-core@1.143.3)(@types/node@22.10.2)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.10)(terser@5.44.1)(tiny-invariant@1.3.3)(tsx@4.21.0)(yaml@2.8.2) '@tanstack/router-plugin': - specifier: ^1.132.0 - version: 1.139.14(@tanstack/react-router@1.139.14(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))(webpack@5.103.0) + specifier: 1.132.0 + version: 1.132.0(@tanstack/react-router@1.132.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) lucide-react: - specifier: ^0.545.0 + specifier: 0.545.0 version: 0.545.0(react@19.2.0) react: - specifier: ^19.2.0 + specifier: 19.2.0 version: 19.2.0 react-dom: - specifier: ^19.2.0 + specifier: 19.2.0 version: 19.2.0(react@19.2.0) tailwindcss: - specifier: ^4.0.6 - version: 4.1.17 + specifier: 4.1.18 + version: 4.1.18 devDependencies: '@tanstack/devtools-vite': - specifier: ^0.3.11 - version: 0.3.11(vite@7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + specifier: 0.3.11 + version: 0.3.11(vite@7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/dom': - specifier: ^10.4.0 - version: 10.4.1 + specifier: 10.4.0 + version: 10.4.0 '@testing-library/react': - specifier: ^16.2.0 - version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 16.2.0 + version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@types/node': - specifier: ^22.10.2 - version: 22.19.1 + specifier: 22.10.2 + version: 22.10.2 '@types/react': - specifier: ^19.2.0 - version: 19.2.7 + specifier: 19.2.0 + version: 19.2.0 '@types/react-dom': - specifier: ^19.2.0 - version: 19.2.3(@types/react@19.2.7) + specifier: 19.2.0 + version: 19.2.0(@types/react@19.2.0) '@vitejs/plugin-react': - specifier: ^5.0.4 - version: 5.1.1(vite@7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + specifier: 5.0.4 + version: 5.0.4(vite@7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) jsdom: - specifier: ^27.0.0 - version: 27.2.0 + specifier: 27.0.0 + version: 27.0.0 typescript: - specifier: ^5.7.2 - version: 5.9.3 + specifier: 5.7.2 + version: 5.7.2 vite: - specifier: ^7.3.0 - version: 7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + specifier: 7.3.0 + version: 7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web-vitals: - specifier: ^5.1.0 + specifier: 5.1.0 version: 5.1.0 - demo/webpack-react: + integrations/directus: dependencies: - '@lingo.dev/compiler': + '@replexica/sdk': + specifier: 0.7.12 + version: 0.7.12(@types/node@25.0.3)(encoding@0.1.13) + devDependencies: + '@directus/extensions-sdk': + specifier: 17.0.3 + version: 17.0.3(@types/node@25.0.3)(@unhead/vue@1.11.20(vue@3.5.24(typescript@5.9.3)))(jiti@2.6.1)(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)))(sharp@0.34.5)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(ws@8.18.3)(yaml@2.8.2) + tsup: + specifier: 8.5.1 + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.0.13 + version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + legacy/cli: + dependencies: + lingo.dev: + specifier: '*' + version: 0.117.21(@types/node@25.0.3)(@types/react@19.2.7)(encoding@0.1.13)(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + + legacy/sdk: + dependencies: + lingo.dev: + specifier: '*' + version: 0.117.21(@types/node@25.0.3)(@types/react@19.2.7)(encoding@0.1.13)(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + + packages/cli: + dependencies: + '@ai-sdk/anthropic': + specifier: 3.0.9 + version: 3.0.9(zod@4.1.12) + '@ai-sdk/google': + specifier: 3.0.6 + version: 3.0.6(zod@4.1.12) + '@ai-sdk/mistral': + specifier: 3.0.5 + version: 3.0.5(zod@4.1.12) + '@ai-sdk/openai': + specifier: 3.0.7 + version: 3.0.7(zod@4.1.12) + '@babel/generator': + specifier: 7.28.5 + version: 7.28.5 + '@babel/parser': + specifier: 7.28.5 + version: 7.28.5 + '@babel/traverse': + specifier: 7.28.5 + version: 7.28.5 + '@babel/types': + specifier: 7.28.5 + version: 7.28.5 + '@biomejs/js-api': + specifier: 3.0.0 + version: 3.0.0(@biomejs/wasm-nodejs@2.3.7) + '@biomejs/wasm-nodejs': + specifier: 2.3.7 + version: 2.3.7 + '@datocms/cma-client-node': + specifier: 4.0.1 + version: 4.0.1 + '@gitbeaker/rest': + specifier: 39.34.3 + version: 39.34.3 + '@inkjs/ui': + specifier: 2.0.0 + version: 2.0.0(ink@4.2.0(@types/react@19.2.7)(react@19.2.3)) + '@inquirer/prompts': + specifier: 7.8.0 + version: 7.8.0(@types/node@22.10.2) + '@lingo.dev/_compiler': + specifier: workspace:* + version: link:../compiler + '@lingo.dev/_locales': + specifier: workspace:* + version: link:../locales + '@lingo.dev/_react': + specifier: workspace:* + version: link:../react + '@lingo.dev/_sdk': specifier: workspace:* - version: link:../../compiler + version: link:../sdk + '@lingo.dev/_spec': + specifier: workspace:* + version: link:../spec + '@markdoc/markdoc': + specifier: 0.5.4 + version: 0.5.4(@types/react@19.2.7)(react@19.2.3) + '@modelcontextprotocol/sdk': + specifier: 1.22.0 + version: 1.22.0 + '@openrouter/ai-sdk-provider': + specifier: 6.0.0-alpha.1 + version: 6.0.0-alpha.1(zod@4.1.12) + '@paralleldrive/cuid2': + specifier: 2.2.2 + version: 2.2.2 + '@types/ejs': + specifier: 3.1.5 + version: 3.1.5 + ai: + specifier: 6.0.25 + version: 6.0.25(zod@4.1.12) + bitbucket: + specifier: 2.12.0 + version: 2.12.0(encoding@0.1.13) + chalk: + specifier: 5.6.2 + version: 5.6.2 + chokidar: + specifier: 4.0.3 + version: 4.0.3 + cli-progress: + specifier: 3.12.0 + version: 3.12.0 + cli-table3: + specifier: 0.6.5 + version: 0.6.5 + cors: + specifier: 2.8.5 + version: 2.8.5 + csv-parse: + specifier: 5.6.0 + version: 5.6.0 + csv-stringify: + specifier: 6.6.0 + version: 6.6.0 + date-fns: + specifier: 4.1.0 + version: 4.1.0 + dedent: + specifier: 1.7.0 + version: 1.7.0 + diff: + specifier: 7.0.0 + version: 7.0.0 + dom-serializer: + specifier: 2.0.0 + version: 2.0.0 + domhandler: + specifier: 5.0.3 + version: 5.0.3 + domutils: + specifier: 3.2.2 + version: 3.2.2 + dotenv: + specifier: 16.4.7 + version: 16.4.7 + ejs: + specifier: 3.1.10 + version: 3.1.10 + express: + specifier: 5.1.0 + version: 5.1.0 + external-editor: + specifier: 3.1.0 + version: 3.1.0 + figlet: + specifier: 1.9.4 + version: 1.9.4 + flat: + specifier: 6.0.1 + version: 6.0.1 + gettext-parser: + specifier: 8.0.0 + version: 8.0.0 + glob: + specifier: 11.1.0 + version: 11.1.0 + gradient-string: + specifier: 3.0.0 + version: 3.0.0 + gray-matter: + specifier: 4.0.3 + version: 4.0.3 + htmlparser2: + specifier: 10.0.0 + version: 10.0.0 + ini: + specifier: 5.0.0 + version: 5.0.0 + ink: + specifier: 4.2.0 + version: 4.2.0(@types/react@19.2.7)(react@19.2.3) + ink-progress-bar: + specifier: 3.0.0 + version: 3.0.0 + ink-spinner: + specifier: 5.0.0 + version: 5.0.0(ink@4.2.0(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) + inquirer: + specifier: 12.6.0 + version: 12.6.0(@types/node@22.10.2) + interactive-commander: + specifier: 0.5.194 + version: 0.5.194(@types/node@22.10.2) + is-url: + specifier: 1.2.4 + version: 1.2.4 + jsdom: + specifier: 25.0.1 + version: 25.0.1 + json5: + specifier: 2.2.3 + version: 2.2.3 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 + jsonrepair: + specifier: 3.13.1 + version: 3.13.1 + listr2: + specifier: 8.3.2 + version: 8.3.2 + lodash: + specifier: 4.17.21 + version: 4.17.21 + marked: + specifier: 15.0.6 + version: 15.0.6 + mdast-util-from-markdown: + specifier: 2.0.2 + version: 2.0.2 + mdast-util-gfm: + specifier: 3.1.0 + version: 3.1.0 + micromark-extension-gfm: + specifier: 3.0.0 + version: 3.0.0 + node-machine-id: + specifier: 1.1.12 + version: 1.1.12 + node-webvtt: + specifier: 1.9.4 + version: 1.9.4 + object-hash: + specifier: 3.0.0 + version: 3.0.0 + octokit: + specifier: 4.0.2 + version: 4.0.2 + ollama-ai-provider-v2: + specifier: 2.0.0 + version: 2.0.0(ai@6.0.25(zod@4.1.12))(zod@4.1.12) + open: + specifier: 10.2.0 + version: 10.2.0 + ora: + specifier: 8.1.1 + version: 8.1.1 + p-limit: + specifier: 6.2.0 + version: 6.2.0 + php-array-reader: + specifier: 2.1.2 + version: 2.1.2 + plist: + specifier: 3.1.0 + version: 3.1.0 + posthog-node: + specifier: 5.14.0 + version: 5.14.0 + prettier: + specifier: 3.6.2 + version: 3.6.2 react: - specifier: ^19.2.1 - version: 19.2.1 - react-dom: - specifier: ^19.2.1 - version: 19.2.1(react@19.2.1) - react-router-dom: - specifier: ^7.10.1 - version: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: 19.2.3 + version: 19.2.3 + rehype-stringify: + specifier: 10.0.1 + version: 10.0.1 + remark-disable-tokenizers: + specifier: 1.1.1 + version: 1.1.1 + remark-frontmatter: + specifier: 5.0.0 + version: 5.0.0 + remark-gfm: + specifier: 4.0.1 + version: 4.0.1 + remark-mdx: + specifier: 3.1.1 + version: 3.1.1 + remark-mdx-frontmatter: + specifier: 5.2.0 + version: 5.2.0 + remark-parse: + specifier: 11.0.0 + version: 11.0.0 + remark-rehype: + specifier: 11.1.2 + version: 11.1.2 + remark-stringify: + specifier: 11.0.0 + version: 11.0.0 + sax: + specifier: 1.4.3 + version: 1.4.3 + srt-parser-2: + specifier: 1.2.3 + version: 1.2.3 + unified: + specifier: 11.0.5 + version: 11.0.5 + unist-util-visit: + specifier: 5.0.0 + version: 5.0.0 + vfile: + specifier: 6.0.3 + version: 6.0.3 + xliff: + specifier: 6.2.2 + version: 6.2.2 + xml2js: + specifier: 0.6.2 + version: 0.6.2 + xpath: + specifier: 0.0.34 + version: 0.0.34 + yaml: + specifier: 2.8.1 + version: 2.8.1 + zod: + specifier: 4.1.12 + version: 4.1.12 + devDependencies: + '@types/babel__generator': + specifier: 7.27.0 + version: 7.27.0 + '@types/chokidar': + specifier: 2.1.7 + version: 2.1.7 + '@types/cli-progress': + specifier: 3.11.6 + version: 3.11.6 + '@types/cors': + specifier: 2.8.19 + version: 2.8.19 + '@types/diff': + specifier: 7.0.0 + version: 7.0.0 + '@types/express': + specifier: 5.0.5 + version: 5.0.5 + '@types/figlet': + specifier: 1.7.0 + version: 1.7.0 + '@types/gettext-parser': + specifier: 4.0.4 + version: 4.0.4 + '@types/glob': + specifier: 8.1.0 + version: 8.1.0 + '@types/ini': + specifier: 4.1.1 + version: 4.1.1 + '@types/is-url': + specifier: 1.2.32 + version: 1.2.32 + '@types/jsdom': + specifier: 21.1.7 + version: 21.1.7 + '@types/lodash': + specifier: 4.17.21 + version: 4.17.21 + '@types/mdast': + specifier: 4.0.4 + version: 4.0.4 + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/node-gettext': + specifier: 3.0.6 + version: 3.0.6 + '@types/object-hash': + specifier: 3.0.6 + version: 3.0.6 + '@types/plist': + specifier: 3.0.5 + version: 3.0.5 + '@types/react': + specifier: 19.2.7 + version: 19.2.7 + '@types/xml2js': + specifier: 0.4.14 + version: 0.4.14 + tsup: + specifier: 8.5.1 + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 3.1.2 + version: 3.1.2(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + + packages/compiler: + dependencies: + '@ai-sdk/anthropic': + specifier: 3.0.9 + version: 3.0.9(zod@4.1.12) + '@ai-sdk/google': + specifier: 3.0.6 + version: 3.0.6(zod@4.1.12) + '@ai-sdk/groq': + specifier: 3.0.4 + version: 3.0.4(zod@4.1.12) + '@ai-sdk/mistral': + specifier: 3.0.5 + version: 3.0.5(zod@4.1.12) + '@ai-sdk/openai': + specifier: 3.0.7 + version: 3.0.7(zod@4.1.12) + '@babel/generator': + specifier: 7.28.5 + version: 7.28.5 + '@babel/parser': + specifier: 7.28.5 + version: 7.28.5 + '@babel/traverse': + specifier: 7.28.5 + version: 7.28.5 + '@babel/types': + specifier: 7.28.5 + version: 7.28.5 + '@lingo.dev/_sdk': + specifier: workspace:* + version: link:../sdk + '@lingo.dev/_spec': + specifier: workspace:* + version: link:../spec + '@openrouter/ai-sdk-provider': + specifier: 6.0.0-alpha.1 + version: 6.0.0-alpha.1(zod@4.1.12) + ai: + specifier: 6.0.25 + version: 6.0.25(zod@4.1.12) + dedent: + specifier: 1.7.0 + version: 1.7.0 + dotenv: + specifier: 16.4.5 + version: 16.4.5 + fast-xml-parser: + specifier: 5.3.2 + version: 5.3.2 + ini: + specifier: 5.0.0 + version: 5.0.0 + lodash: + specifier: 4.17.21 + version: 4.17.21 + node-machine-id: + specifier: 1.1.12 + version: 1.1.12 + object-hash: + specifier: 3.0.0 + version: 3.0.0 + ollama-ai-provider-v2: + specifier: 2.0.0 + version: 2.0.0(ai@6.0.25(zod@4.1.12))(zod@4.1.12) + posthog-node: + specifier: 5.14.0 + version: 5.14.0 + unplugin: + specifier: 2.3.11 + version: 2.3.11 + zod: + specifier: 4.1.12 + version: 4.1.12 + devDependencies: + '@types/babel__generator': + specifier: 7.27.0 + version: 7.27.0 + '@types/babel__traverse': + specifier: 7.28.0 + version: 7.28.0 + '@types/ini': + specifier: 4.1.1 + version: 4.1.1 + '@types/lodash': + specifier: 4.17.21 + version: 4.17.21 + '@types/object-hash': + specifier: 3.0.6 + version: 3.0.6 + '@types/react': + specifier: 19.2.7 + version: 19.2.7 + next: + specifier: 15.3.8 + version: 15.3.8(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + tsup: + specifier: 8.5.1 + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.0.13 + version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + packages/locales: + dependencies: + iso-639-3: + specifier: 3.0.1 + version: 3.0.1 + devDependencies: + '@types/node': + specifier: 22.13.5 + version: 22.13.5 + tsup: + specifier: 8.5.1 + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.13.5)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + packages/logging: + dependencies: + env-paths: + specifier: 3.0.0 + version: 3.0.0 + pino: + specifier: 9.6.0 + version: 9.6.0 + rotating-file-stream: + specifier: 3.2.7 + version: 3.2.7 devDependencies: + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + tsup: + specifier: 8.5.1 + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 3.1.2 + version: 3.1.2(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + packages/new-compiler: + dependencies: + '@ai-sdk/google': + specifier: 3.0.1 + version: 3.0.1(zod@4.1.12) + '@ai-sdk/groq': + specifier: 3.0.1 + version: 3.0.1(zod@4.1.12) + '@ai-sdk/mistral': + specifier: 3.0.1 + version: 3.0.1(zod@4.1.12) '@babel/core': - specifier: ^7.28.5 + specifier: 7.26.0 + version: 7.26.0 + '@babel/generator': + specifier: 7.28.5 + version: 7.28.5 + '@babel/parser': + specifier: 7.28.5 version: 7.28.5 - '@babel/preset-env': - specifier: ^7.28.5 - version: 7.28.5(@babel/core@7.28.5) '@babel/preset-react': - specifier: ^7.28.5 - version: 7.28.5(@babel/core@7.28.5) + specifier: 7.26.3 + version: 7.26.3(@babel/core@7.26.0) '@babel/preset-typescript': - specifier: ^7.28.5 - version: 7.28.5(@babel/core@7.28.5) + specifier: 7.26.0 + version: 7.26.0(@babel/core@7.26.0) + '@babel/traverse': + specifier: 7.28.5 + version: 7.28.5 + '@babel/types': + specifier: 7.28.5 + version: 7.28.5 + '@formatjs/icu-messageformat-parser': + specifier: 3.1.1 + version: 3.1.1 + '@openrouter/ai-sdk-provider': + specifier: 1.5.4 + version: 1.5.4(ai@6.0.3(zod@4.1.12))(zod@4.1.12) + ai: + specifier: 6.0.3 + version: 6.0.3(zod@4.1.12) + ai-sdk-ollama: + specifier: 3.0.0 + version: 3.0.0(ai@6.0.3(zod@4.1.12))(zod@4.1.12) + dotenv: + specifier: 17.2.3 + version: 17.2.3 + fast-xml-parser: + specifier: 5.3.3 + version: 5.3.3 + ini: + specifier: 5.0.0 + version: 5.0.0 + intl-messageformat: + specifier: 11.0.6 + version: 11.0.6 + lingo.dev: + specifier: workspace:^ + version: link:../cli + lodash: + specifier: 4.17.21 + version: 4.17.21 + node-machine-id: + specifier: 1.1.12 + version: 1.1.12 + posthog-node: + specifier: 5.14.0 + version: 5.14.0 + proper-lockfile: + specifier: 4.1.2 + version: 4.1.2 + react: + specifier: ^19.0.0 + version: 19.2.3 + react-dom: + specifier: ' ^19.0.0' + version: 19.2.3(react@19.2.3) + ws: + specifier: 8.18.3 + version: 8.18.3 + devDependencies: + '@playwright/test': + specifier: 1.56.1 + version: 1.56.1 + '@types/babel__core': + specifier: 7.20.5 + version: 7.20.5 + '@types/babel__generator': + specifier: 7.27.0 + version: 7.27.0 + '@types/babel__traverse': + specifier: 7.28.0 + version: 7.28.0 + '@types/ini': + specifier: 4.1.1 + version: 4.1.1 + '@types/node': + specifier: 25.0.3 + version: 25.0.3 + '@types/proper-lockfile': + specifier: 4.1.4 + version: 4.1.4 '@types/react': - specifier: ^19.2.7 + specifier: 19.2.7 version: 19.2.7 '@types/react-dom': - specifier: ^19.2.3 + specifier: 19.2.3 version: 19.2.3(@types/react@19.2.7) - babel-loader: - specifier: ^10.0.0 - version: 10.0.0(@babel/core@7.28.5)(webpack@5.103.0) - css-loader: - specifier: ^7.1.2 - version: 7.1.2(webpack@5.103.0) - html-webpack-plugin: - specifier: ^5.6.5 - version: 5.6.5(webpack@5.103.0) - rimraf: - specifier: ^6.1.2 - version: 6.1.2 - style-loader: - specifier: ^4.0.0 - version: 4.0.0(webpack@5.103.0) + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 + next: + specifier: 16.1.0 + version: 16.1.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + tsdown: + specifier: 0.18.2 + version: 0.18.2(synckit@0.11.11)(typescript@5.9.3) + tsx: + specifier: 4.21.0 + version: 4.21.0 typescript: - specifier: ^5.9.3 + specifier: 5.9.3 version: 5.9.3 - webpack: - specifier: ^5.103.0 - version: 5.103.0(webpack-cli@6.0.1) - webpack-cli: - specifier: ^6.0.1 - version: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.103.0) - webpack-dev-server: - specifier: ^5.2.2 - version: 5.2.2(webpack-cli@6.0.1)(webpack@5.103.0) - -packages: - - '@acemir/cssom@0.9.24': - resolution: {integrity: sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==} - - '@ai-sdk/anthropic@1.2.11': - resolution: {integrity: sha512-lZLcEMh8MXY4NVSrN/7DyI2rnid8k7cn/30nMmd3bwJrnIsOuIuuFvY8f0nj+pFcTi6AYK7ujLdqW5dQVz1YQw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.0.0 + unplugin: + specifier: 2.3.11 + version: 2.3.11 + vitest: + specifier: 4.0.16 + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@ai-sdk/google@1.2.19': - resolution: {integrity: sha512-Xgl6eftIRQ4srUdCzxM112JuewVMij5q4JLcNmHcB68Bxn9dpr3MVUSPlJwmameuiQuISIA8lMB+iRiRbFsaqA==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.0.0 + packages/react: + dependencies: + js-cookie: + specifier: 3.0.5 + version: 3.0.5 + lodash: + specifier: 4.17.21 + version: 4.17.21 + devDependencies: + '@testing-library/react': + specifier: 16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/js-cookie': + specifier: 3.0.6 + version: 3.0.6 + '@types/lodash': + specifier: 4.17.21 + version: 4.17.21 + '@types/react': + specifier: 19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: 4.4.1 + version: 4.4.1(vite@6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + chokidar-cli: + specifier: 3.0.0 + version: 3.0.0 + next: + specifier: 15.3.8 + version: 15.3.8(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) + tsup: + specifier: 8.5.1 + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: 5.9.3 + version: 5.9.3 + unbuild: + specifier: 3.6.1 + version: 3.6.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)) + vitest: + specifier: 3.1.1 + version: 3.1.1(@types/debug@4.1.12)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + packages/sdk: + dependencies: + '@lingo.dev/_spec': + specifier: workspace:* + version: link:../spec + '@paralleldrive/cuid2': + specifier: 2.2.2 + version: 2.2.2 + jsdom: + specifier: 25.0.1 + version: 25.0.1 + zod: + specifier: 4.1.12 + version: 4.1.12 + devDependencies: + '@types/jsdom': + specifier: 21.1.7 + version: 21.1.7 + tsup: + specifier: 8.5.1 + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 3.1.2 + version: 3.1.2(@types/debug@4.1.12)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + packages/spec: + dependencies: + '@lingo.dev/_locales': + specifier: workspace:* + version: link:../locales + zod: + specifier: 4.1.12 + version: 4.1.12 + devDependencies: + '@types/node': + specifier: 22.13.5 + version: 22.13.5 + tsup: + specifier: 8.5.1 + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 3.1.2 + version: 3.1.2(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + scripts/docs: + dependencies: + zod: + specifier: 3.25.76 + version: 3.25.76 + zod-to-json-schema: + specifier: 3.25.0 + version: 3.25.0(zod@3.25.76) + devDependencies: + '@lingo.dev/_spec': + specifier: workspace:* + version: link:../../packages/spec + '@octokit/rest': + specifier: 20.1.2 + version: 20.1.2 + '@types/mdast': + specifier: 4.0.4 + version: 4.0.4 + '@types/node': + specifier: 24.0.10 + version: 24.0.10 + '@vitest/ui': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4) + commander: + specifier: 12.0.0 + version: 12.0.0 + remark-stringify: + specifier: 11.0.0 + version: 11.0.0 + tsx: + specifier: 4.20.6 + version: 4.20.6 + typescript: + specifier: 5.9.3 + version: 5.9.3 + unified: + specifier: 11.0.5 + version: 11.0.5 + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + +packages: + + '@acemir/cssom@0.9.30': + resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} + + '@ai-sdk/anthropic@1.2.11': + resolution: {integrity: sha512-lZLcEMh8MXY4NVSrN/7DyI2rnid8k7cn/30nMmd3bwJrnIsOuIuuFvY8f0nj+pFcTi6AYK7ujLdqW5dQVz1YQw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + + '@ai-sdk/anthropic@3.0.9': + resolution: {integrity: sha512-QBD4qDnwIHd+N5PpjxXOaWJig1aRB43J0PM5ZUe6Yyl9Qq2bUmraQjvNznkuFKy+hMFDgj0AvgGogTiO5TC+qA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/gateway@3.0.10': + resolution: {integrity: sha512-sRlPMKd38+fdp2y11USW44c0o8tsIsT6T/pgyY04VXC3URjIRnkxugxd9AkU2ogfpPDMz50cBAGPnMxj+6663Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/gateway@3.0.2': + resolution: {integrity: sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/google@1.2.22': - resolution: {integrity: sha512-Ppxu3DIieF1G9pyQ5O1Z646GYR0gkC57YdBqXJ82qvCdhEhZHu0TWhmnOoeIWe2olSbuDeoOY+MfJrW8dzS3Hw==} + '@ai-sdk/google@1.2.19': + resolution: {integrity: sha512-Xgl6eftIRQ4srUdCzxM112JuewVMij5q4JLcNmHcB68Bxn9dpr3MVUSPlJwmameuiQuISIA8lMB+iRiRbFsaqA==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 + '@ai-sdk/google@3.0.1': + resolution: {integrity: sha512-gh7i4lEvd1CElmefkq7+RoUhNkhP2OTshzVxSt7/Vh2AV5wTPLhduKJMg1c7SFwErytqffO3el/M/LlfCsqzEw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/google@3.0.6': + resolution: {integrity: sha512-Nr7E+ouWd/bKO9SFlgLnJJ1+fiGHC07KAeFr08faT+lvkECWlxVox3aL0dec8uCgBDUghYbq7f4S5teUrCc+QQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/groq@1.2.3': resolution: {integrity: sha512-MGPo+ROdJfavrkI4SgJSUOtT6cFjEZEyu7sKKI1PWE3FBTp0oYxSfsmAFWebXGI1G+v70XPFiH9IObBYUiEMvQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 - '@ai-sdk/groq@1.2.9': - resolution: {integrity: sha512-7MoDaxm8yWtiRbD1LipYZG0kBl+Xe0sv/EeyxnHnGPZappXdlgtdOgTZVjjXkT3nWP30jjZi9A45zoVrBMb3Xg==} + '@ai-sdk/groq@3.0.1': + resolution: {integrity: sha512-scG4Esc0AuFXxKDrcoXuO0ufPg/kFtavnGgll/LCqN7UqQ46mcNIBBaF9TT5LrkgAS8tEOUGLNrbU635HvscIA==} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/groq@3.0.4': + resolution: {integrity: sha512-xhqeUZ9DclSZntfIAsIDsMzk2QpeWA9NMAdyrfP3Lbu+Nqccv1fWPoy9SaYwRwdluUUvc45gl9GtMDnWUbO0oA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 '@ai-sdk/mistral@1.2.8': resolution: {integrity: sha512-lv857D9UJqCVxiq2Fcu7mSPTypEHBUqLl1K+lCaP6X/7QAkcaxI36QDONG+tOhGHJOXTsS114u8lrUTaEiGXbg==} @@ -412,12 +1062,30 @@ packages: peerDependencies: zod: ^3.0.0 + '@ai-sdk/mistral@3.0.1': + resolution: {integrity: sha512-Uc2FW8OLOY9is5tuLxCOOfDd11s38agtLUcs10oaIDGDwMeae/kAZufmkrPQq8aaGWtjPs1Sp06fJVcIMz7V7g==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/mistral@3.0.5': + resolution: {integrity: sha512-ymrHvsVBcafjZmdjLY4677o9yUvRhht3Hbdz1kTCqAzpJ5bWlHQikU9KaA5EAE0fvArixQmUNpTxSXuI26VQ2g==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai@1.3.22': resolution: {integrity: sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 + '@ai-sdk/openai@3.0.7': + resolution: {integrity: sha512-CBoYn1U59Lop8yBL9KuVjHCKc/B06q9Qo0SasRwHoyMEq+X4I8LQZu3a8Ck1jwwcZTTxfyiExB70LtIRSynBDA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@2.2.3': resolution: {integrity: sha512-o3fWTzkxzI5Af7U7y794MZkYNEsxbjLam2nxyoUZSScqkacb7vZ3EYHLh21+xCcSSzEC161C7pZAGHtC0hTUMw==} engines: {node: '>=18'} @@ -430,6 +1098,18 @@ packages: peerDependencies: zod: ^3.23.8 + '@ai-sdk/provider-utils@4.0.1': + resolution: {integrity: sha512-de2v8gH9zj47tRI38oSxhQIewmNc+OZjYIOOaMoVWKL65ERSav2PYYZHPSPCrfOeLMkv+Dyh8Y0QGwkO29wMWQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.4': + resolution: {integrity: sha512-VxhX0B/dWGbpNHxrKCWUAJKXIXV015J4e7qYjdIU9lLWeptk0KMLGcqkB4wFxff5Njqur8dt8wRi1MN9lZtDqg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@1.1.0': resolution: {integrity: sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew==} engines: {node: '>=18'} @@ -438,6 +1118,14 @@ packages: resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.0': + resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==} + engines: {node: '>=18'} + + '@ai-sdk/provider@3.0.2': + resolution: {integrity: sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw==} + engines: {node: '>=18'} + '@ai-sdk/react@1.2.12': resolution: {integrity: sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==} engines: {node: '>=18'} @@ -474,18 +1162,151 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@asamuzakjp/css-color@4.1.0': - resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==} + '@asamuzakjp/css-color@4.1.1': + resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} - '@asamuzakjp/dom-selector@6.7.5': - resolution: {integrity: sha512-Eks6dY8zau4m4wNRQjRVaKQRTalNcPcBvU1ZQ35w5kKRk1gUeNCkVLsRiATurjASTp3TKM4H10wsI50nx3NZdw==} + '@asamuzakjp/dom-selector@6.7.6': + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-sesv2@3.958.0': + resolution: {integrity: sha512-3x3n8IIxIMAkdpt9wy9zS7MO2lqTcJwQTdHMn6BlD7YUohb+r5Q4KCOEQ2uHWd4WIJv2tlbXnfypHaXReO/WXA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-sso@3.958.0': + resolution: {integrity: sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/core@3.957.0': + resolution: {integrity: sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-env@3.957.0': + resolution: {integrity: sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-http@3.957.0': + resolution: {integrity: sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-ini@3.958.0': + resolution: {integrity: sha512-u7twvZa1/6GWmPBZs6DbjlegCoNzNjBsMS/6fvh5quByYrcJr/uLd8YEr7S3UIq4kR/gSnHqcae7y2nL2bqZdg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-login@3.958.0': + resolution: {integrity: sha512-sDwtDnBSszUIbzbOORGh5gmXGl9aK25+BHb4gb1aVlqB+nNL2+IUEJA62+CE55lXSH8qXF90paivjK8tOHTwPA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-node@3.958.0': + resolution: {integrity: sha512-vdoZbNG2dt66I7EpN3fKCzi6fp9xjIiwEA/vVVgqO4wXCGw8rKPIdDUus4e13VvTr330uQs2W0UNg/7AgtquEQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-process@3.957.0': + resolution: {integrity: sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-sso@3.958.0': + resolution: {integrity: sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.958.0': + resolution: {integrity: sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-host-header@3.957.0': + resolution: {integrity: sha512-BBgKawVyfQZglEkNTuBBdC3azlyqNXsvvN4jPkWAiNYcY0x1BasaJFl+7u/HisfULstryweJq/dAvIZIxzlZaA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-logger@3.957.0': + resolution: {integrity: sha512-w1qfKrSKHf9b5a8O76yQ1t69u6NWuBjr5kBX+jRWFx/5mu6RLpqERXRpVJxfosbep7k3B+DSB5tZMZ82GKcJtQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.957.0': + resolution: {integrity: sha512-D2H/WoxhAZNYX+IjkKTdOhOkWQaK0jjJrDBj56hKjU5c9ltQiaX/1PqJ4dfjHntEshJfu0w+E6XJ+/6A6ILBBA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.957.0': + resolution: {integrity: sha512-5B2qY2nR2LYpxoQP0xUum5A1UNvH2JQpLHDH1nWFNF/XetV7ipFHksMxPNhtJJ6ARaWhQIDXfOUj0jcnkJxXUg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-user-agent@3.957.0': + resolution: {integrity: sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/nested-clients@3.958.0': + resolution: {integrity: sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/region-config-resolver@3.957.0': + resolution: {integrity: sha512-V8iY3blh8l2iaOqXWW88HbkY5jDoWjH56jonprG/cpyqqCnprvpMUZWPWYJoI8rHRf2bqzZeql1slxG6EnKI7A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.957.0': + resolution: {integrity: sha512-t6UfP1xMUigMMzHcb7vaZcjv7dA2DQkk9C/OAP1dKyrE0vb4lFGDaTApi17GN6Km9zFxJthEMUbBc7DL0hq1Bg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/token-providers@3.958.0': + resolution: {integrity: sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/types@3.957.0': + resolution: {integrity: sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-arn-parser@3.957.0': + resolution: {integrity: sha512-Aj6m+AyrhWyg8YQ4LDPg2/gIfGHCEcoQdBt5DeSFogN5k9mmJPOJ+IAmNSWmWRjpOxEy6eY813RNDI6qS97M0g==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-endpoints@3.957.0': + resolution: {integrity: sha512-xwF9K24mZSxcxKS3UKQFeX/dPYkEps9wF1b+MGON7EvnbcucrJGyQyK1v1xFPn1aqXkBTFi+SZaMRx5E5YCVFw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-locate-window@3.957.0': + resolution: {integrity: sha512-nhmgKHnNV9K+i9daumaIz8JTLsIIML9PE/HUks5liyrjUzenjW/aHoc7WJ9/Td/gPZtayxFnXQSJRb/fDlBuJw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-user-agent-browser@3.957.0': + resolution: {integrity: sha512-exueuwxef0lUJRnGaVkNSC674eAiWU07ORhxBnevFFZEKisln+09Qrtw823iyv5I1N8T+wKfh95xvtWQrNKNQw==} + + '@aws-sdk/util-user-agent-node@3.957.0': + resolution: {integrity: sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.957.0': + resolution: {integrity: sha512-Ai5iiQqS8kJ5PjzMhWcLKN0G2yasAkvpnPlq2EnqlIMdB48HsizElt62qcktdxp4neRMyGkFq4NzgmDbXnhRiA==} + engines: {node: '>=18.0.0'} + + '@aws/lambda-invoke-store@0.2.2': + resolution: {integrity: sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -494,6 +1315,10 @@ packages: resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} engines: {node: '>=6.9.0'} + '@babel/core@7.26.0': + resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} + engines: {node: '>=6.9.0'} + '@babel/core@7.28.5': resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} engines: {node: '>=6.9.0'} @@ -516,17 +1341,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-create-regexp-features-plugin@7.28.5': - resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-define-polyfill-provider@0.6.5': - resolution: {integrity: sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} @@ -553,12 +1367,6 @@ packages: resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} engines: {node: '>=6.9.0'} - '@babel/helper-remap-async-to-generator@7.27.1': - resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-replace-supers@7.27.1': resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==} engines: {node: '>=6.9.0'} @@ -581,10 +1389,6 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helper-wrap-function@7.28.3': - resolution: {integrity: sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==} - engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.4': resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} @@ -594,54 +1398,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': - resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1': - resolution: {integrity: sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1': - resolution: {integrity: sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1': - resolution: {integrity: sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.13.0 - - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.3': - resolution: {integrity: sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': - resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-assertions@7.27.1': - resolution: {integrity: sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-jsx@7.27.1': resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} engines: {node: '>=6.9.0'} @@ -654,419 +1410,238 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6': - resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-arrow-functions@7.27.1': - resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + '@babel/plugin-transform-modules-commonjs@7.27.1': + resolution: {integrity: sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-async-generator-functions@7.28.0': - resolution: {integrity: sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==} + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-async-to-generator@7.27.1': - resolution: {integrity: sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==} + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-block-scoped-functions@7.27.1': - resolution: {integrity: sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==} + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-block-scoping@7.28.5': - resolution: {integrity: sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g==} + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-class-properties@7.27.1': - resolution: {integrity: sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==} + '@babel/plugin-transform-react-jsx@7.27.1': + resolution: {integrity: sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-class-static-block@7.28.3': - resolution: {integrity: sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.12.0 - - '@babel/plugin-transform-classes@7.28.4': - resolution: {integrity: sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==} + '@babel/plugin-transform-react-pure-annotations@7.27.1': + resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-computed-properties@7.27.1': - resolution: {integrity: sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==} + '@babel/plugin-transform-typescript@7.28.5': + resolution: {integrity: sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-destructuring@7.28.5': - resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + '@babel/preset-react@7.26.3': + resolution: {integrity: sha512-Nl03d6T9ky516DGK2YMxrTqvnpUW63TnJMOMonj+Zae0JiPC5BC9xPMSL6L8fiSpA5vP88qfygavVQvnLp+6Cw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-dotall-regex@7.27.1': - resolution: {integrity: sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==} + '@babel/preset-typescript@7.26.0': + resolution: {integrity: sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-duplicate-keys@7.27.1': - resolution: {integrity: sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==} + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1': - resolution: {integrity: sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-dynamic-import@7.27.1': - resolution: {integrity: sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==} + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-explicit-resource-management@7.28.0': - resolution: {integrity: sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==} + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-exponentiation-operator@7.28.5': - resolution: {integrity: sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==} + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-export-namespace-from@7.27.1': - resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-for-of@7.27.1': - resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} - engines: {node: '>=6.9.0'} + '@biomejs/js-api@3.0.0': + resolution: {integrity: sha512-5QcGJFj9IO+yXl76ICjvkdE38uxRcTDsBzcCZHEZ+ma+Te/nbvJg4A3KtAds9HCrEF0JKLWiyjMhAbqazuJvYA==} peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-function-name@7.27.1': - resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-json-strings@7.27.1': - resolution: {integrity: sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-literals@7.27.1': - resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-logical-assignment-operators@7.28.5': - resolution: {integrity: sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-member-expression-literals@7.27.1': - resolution: {integrity: sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-amd@7.27.1': - resolution: {integrity: sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-commonjs@7.27.1': - resolution: {integrity: sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-systemjs@7.28.5': - resolution: {integrity: sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-umd@7.27.1': - resolution: {integrity: sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-named-capturing-groups-regex@7.27.1': - resolution: {integrity: sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-new-target@7.27.1': - resolution: {integrity: sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-nullish-coalescing-operator@7.27.1': - resolution: {integrity: sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@biomejs/wasm-bundler': ^2.2.0 + '@biomejs/wasm-nodejs': ^2.2.0 + '@biomejs/wasm-web': ^2.2.0 + peerDependenciesMeta: + '@biomejs/wasm-bundler': + optional: true + '@biomejs/wasm-nodejs': + optional: true + '@biomejs/wasm-web': + optional: true - '@babel/plugin-transform-numeric-separator@7.27.1': - resolution: {integrity: sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@biomejs/wasm-nodejs@2.3.7': + resolution: {integrity: sha512-63TN+Xw0OA4CNPWu4bghWXpSPpyEElZsNwiK0Hqr2Y+r21tj2KOP23DqQs7Gd6Fvc6nyr3I3lTneGGfgmpq3DQ==} - '@babel/plugin-transform-object-rest-spread@7.28.4': - resolution: {integrity: sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/apply-release-plan@7.0.14': + resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==} - '@babel/plugin-transform-object-super@7.27.1': - resolution: {integrity: sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/assemble-release-plan@6.0.9': + resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==} - '@babel/plugin-transform-optional-catch-binding@7.27.1': - resolution: {integrity: sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} - '@babel/plugin-transform-optional-chaining@7.28.5': - resolution: {integrity: sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/changelog-github@0.5.1': + resolution: {integrity: sha512-BVuHtF+hrhUScSoHnJwTELB4/INQxVFc+P/Qdt20BLiBFIHFJDDUaGsZw+8fQeJTRP5hJZrzpt3oZWh0G19rAQ==} - '@babel/plugin-transform-parameters@7.27.7': - resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/cli@2.29.7': + resolution: {integrity: sha512-R7RqWoaksyyKXbKXBTbT4REdy22yH81mcFK6sWtqSanxUCbUi9Uf+6aqxZtDQouIqPdem2W56CdxXgsxdq7FLQ==} + hasBin: true - '@babel/plugin-transform-private-methods@7.27.1': - resolution: {integrity: sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/config@3.1.2': + resolution: {integrity: sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog==} - '@babel/plugin-transform-private-property-in-object@7.27.1': - resolution: {integrity: sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} - '@babel/plugin-transform-property-literals@7.27.1': - resolution: {integrity: sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/get-dependents-graph@2.1.3': + resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} - '@babel/plugin-transform-react-display-name@7.28.0': - resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/get-github-info@0.6.0': + resolution: {integrity: sha512-v/TSnFVXI8vzX9/w3DU2Ol+UlTZcu3m0kXTjTT4KlAdwSvwutcByYwyYn9hwerPWfPkT2JfpoX0KgvCEi8Q/SA==} - '@babel/plugin-transform-react-jsx-development@7.27.1': - resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/get-release-plan@4.0.14': + resolution: {integrity: sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g==} - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} - '@babel/plugin-transform-react-jsx@7.27.1': - resolution: {integrity: sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} - '@babel/plugin-transform-react-pure-annotations@7.27.1': - resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/parse@0.4.2': + resolution: {integrity: sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA==} - '@babel/plugin-transform-regenerator@7.28.4': - resolution: {integrity: sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} - '@babel/plugin-transform-regexp-modifiers@7.27.1': - resolution: {integrity: sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 + '@changesets/read@0.6.6': + resolution: {integrity: sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg==} - '@babel/plugin-transform-reserved-words@7.27.1': - resolution: {integrity: sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} - '@babel/plugin-transform-shorthand-properties@7.27.1': - resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} - '@babel/plugin-transform-spread@7.27.1': - resolution: {integrity: sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} - '@babel/plugin-transform-sticky-regex@7.27.1': - resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@babel/plugin-transform-template-literals@7.27.1': - resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} - '@babel/plugin-transform-typeof-symbol@7.27.1': - resolution: {integrity: sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@commitlint/cli@19.8.1': + resolution: {integrity: sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==} + engines: {node: '>=v18'} + hasBin: true - '@babel/plugin-transform-typescript@7.28.5': - resolution: {integrity: sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@commitlint/config-conventional@19.8.1': + resolution: {integrity: sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==} + engines: {node: '>=v18'} - '@babel/plugin-transform-unicode-escapes@7.27.1': - resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@commitlint/config-validator@19.8.1': + resolution: {integrity: sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ==} + engines: {node: '>=v18'} - '@babel/plugin-transform-unicode-property-regex@7.27.1': - resolution: {integrity: sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@commitlint/ensure@19.8.1': + resolution: {integrity: sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw==} + engines: {node: '>=v18'} - '@babel/plugin-transform-unicode-regex@7.27.1': - resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@commitlint/execute-rule@19.8.1': + resolution: {integrity: sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA==} + engines: {node: '>=v18'} - '@babel/plugin-transform-unicode-sets-regex@7.27.1': - resolution: {integrity: sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 + '@commitlint/format@19.8.1': + resolution: {integrity: sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw==} + engines: {node: '>=v18'} - '@babel/preset-env@7.28.5': - resolution: {integrity: sha512-S36mOoi1Sb6Fz98fBfE+UZSpYw5mJm0NUHtIKrOuNcqeFauy1J6dIvXm2KRVKobOSaGq4t/hBXdN4HGU3wL9Wg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@commitlint/is-ignored@19.8.1': + resolution: {integrity: sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg==} + engines: {node: '>=v18'} - '@babel/preset-modules@0.1.6-no-external-plugins': - resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} - peerDependencies: - '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + '@commitlint/lint@19.8.1': + resolution: {integrity: sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw==} + engines: {node: '>=v18'} - '@babel/preset-react@7.28.5': - resolution: {integrity: sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@commitlint/load@19.8.1': + resolution: {integrity: sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A==} + engines: {node: '>=v18'} - '@babel/preset-typescript@7.28.5': - resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@commitlint/message@19.8.1': + resolution: {integrity: sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg==} + engines: {node: '>=v18'} - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} - engines: {node: '>=6.9.0'} + '@commitlint/parse@19.8.1': + resolution: {integrity: sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw==} + engines: {node: '>=v18'} - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} + '@commitlint/read@19.8.1': + resolution: {integrity: sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ==} + engines: {node: '>=v18'} - '@babel/traverse@7.28.5': - resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} - engines: {node: '>=6.9.0'} + '@commitlint/resolve-extends@19.8.1': + resolution: {integrity: sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg==} + engines: {node: '>=v18'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} + '@commitlint/rules@19.8.1': + resolution: {integrity: sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw==} + engines: {node: '>=v18'} - '@biomejs/js-api@3.0.0': - resolution: {integrity: sha512-5QcGJFj9IO+yXl76ICjvkdE38uxRcTDsBzcCZHEZ+ma+Te/nbvJg4A3KtAds9HCrEF0JKLWiyjMhAbqazuJvYA==} - peerDependencies: - '@biomejs/wasm-bundler': ^2.2.0 - '@biomejs/wasm-nodejs': ^2.2.0 - '@biomejs/wasm-web': ^2.2.0 - peerDependenciesMeta: - '@biomejs/wasm-bundler': - optional: true - '@biomejs/wasm-nodejs': - optional: true - '@biomejs/wasm-web': - optional: true + '@commitlint/to-lines@19.8.1': + resolution: {integrity: sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg==} + engines: {node: '>=v18'} - '@biomejs/wasm-nodejs@2.3.7': - resolution: {integrity: sha512-63TN+Xw0OA4CNPWu4bghWXpSPpyEElZsNwiK0Hqr2Y+r21tj2KOP23DqQs7Gd6Fvc6nyr3I3lTneGGfgmpq3DQ==} + '@commitlint/top-level@19.8.1': + resolution: {integrity: sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw==} + engines: {node: '>=v18'} - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} + '@commitlint/types@19.8.1': + resolution: {integrity: sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==} + engines: {node: '>=v18'} '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} @@ -1092,8 +1667,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-syntax-patches-for-csstree@1.0.20': - resolution: {integrity: sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==} + '@csstools/css-syntax-patches-for-csstree@1.0.22': + resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} engines: {node: '>=18'} '@csstools/css-tokenizer@3.0.4': @@ -1109,9 +1684,94 @@ packages: '@datocms/rest-client-utils@4.0.2': resolution: {integrity: sha512-aKg8bl9mHebWSnBgX1f3Y9hGkX0b+fHlEmhTvZ2JqzrVcu7RZjEWe612RFpyuTISv3hgIRbgmcE7KDfJe/WnfA==} - '@discoveryjs/json-ext@0.6.3': - resolution: {integrity: sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==} - engines: {node: '>=14.17.0'} + '@directus/composables@11.2.7': + resolution: {integrity: sha512-vyCljJOce30/0pjtZGTo7f6mAMQNWvQgm9rafXXOgd1W57JqwRMwi8xDPn4Peuu3PRLNf9wlctXwUPH0fXljLw==} + peerDependencies: + vue: 3.5.24 + + '@directus/constants@14.0.0': + resolution: {integrity: sha512-l4gsGIiZBV5WYkNTv3+HjxDBZJEkwPSo5tYpJ3fk6xOr7b2Km60aq8LOBmfuKwlLGLnvUzD4Y4S3ebgrwBE2iQ==} + + '@directus/extensions-sdk@17.0.3': + resolution: {integrity: sha512-V9AhY5cXNmDyOCG9Me2soU/RekAI/ojhInFov8KAesbKq5fj3LfpRqnDfnvUz7Xlzuzhfq/4H2tBMOYbBH212A==} + engines: {node: '>=20.19.0'} + hasBin: true + + '@directus/extensions@3.0.14': + resolution: {integrity: sha512-BuhsKCSiqfJlozkfsPKp007nQbxM0oYFfeLutFuhmSJc9Nb+lryghRWeS6nAqahJjlWku+MBahsZkfmieWrMBg==} + peerDependencies: + knex: 3.1.0 + pino: 9.7.0 + vue: 3.5.24 + vue-router: 4.6.3 + peerDependenciesMeta: + knex: + optional: true + pino: + optional: true + vue: + optional: true + vue-router: + optional: true + + '@directus/schema@13.0.4': + resolution: {integrity: sha512-CANZ1vI+0kSjbqeKS9I4aysTPit9IP8ALSPXc93JpzSKGWMvEdCbaQAyoAIEgRAP9owysUjC6YVUdQpkRFl5sw==} + + '@directus/system-data@3.4.2': + resolution: {integrity: sha512-/XsKZr6fOxvUUshkTe37+ztf12Bz9A104FZFcOm2mhVU9Vd5BCkvuLAhRDpiY6RXR5QSmEIN6fthrvFrNoYDPQ==} + + '@directus/themes@1.1.8': + resolution: {integrity: sha512-p2hGLTdPxN+9/HD/s2dGQFQJr4yv0jeZKRsaVFDHrabQLRU047NNR4p6uDFHAyrU2QK3QYVC2eYXawUUTh0DCw==} + peerDependencies: + '@unhead/vue': 1.11.20 + pinia: 2.3.1 + vue: 3.5.24 + + '@directus/types@13.4.0': + resolution: {integrity: sha512-/OXBSftChg/g238mB7JvJTv1+SH+TExYHm1+KiKejK2DjK3E1NZWgFpDtVtCDU8dgXI/BLZbdApyuBI9QhpeQQ==} + peerDependencies: + deep-diff: 1.0.2 + express: 4.21.2 + graphql: 16.12.0 + knex: 3.1.0 + nodemailer: 7.0.10 + openapi3-ts: 4.5.0 + pino: 9.7.0 + sharp: 0.34.5 + vue: 3.5.24 + vue-router: 4.6.3 + ws: 8.18.3 + peerDependenciesMeta: + deep-diff: + optional: true + express: + optional: true + graphql: + optional: true + knex: + optional: true + nodemailer: + optional: true + openapi3-ts: + optional: true + pino: + optional: true + sharp: + optional: true + vue: + optional: true + vue-router: + optional: true + ws: + optional: true + + '@directus/utils@13.0.13': + resolution: {integrity: sha512-qUOiSki7GpGxBVKSW7dGusgOFKph3eDE5ObiMi3kYtisziTKGukCVAg8ZxMWD8jq3exqUF2Rc3uJzmZwWnV2Dg==} + peerDependencies: + vue: 3.5.24 + peerDependenciesMeta: + vue: + optional: true '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -1456,8 +2116,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.1': @@ -1472,20 +2132,20 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@formatjs/ecma402-abstract@2.3.6': - resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} + '@formatjs/ecma402-abstract@3.0.5': + resolution: {integrity: sha512-TF0uoOhPhbzzAuKgOA3s8M20wZm5f6IWDq6dBVkl8gKvS7vq84AkzR9ts0oLN0pbDy6TDx0pESUgyJgMJQItDg==} - '@formatjs/fast-memoize@2.2.7': - resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + '@formatjs/fast-memoize@3.0.1': + resolution: {integrity: sha512-kzk635kEmsxrrEWQXY7uKRocFCVXR4es5OQqcqCGg2NPtQztG/OBkE9THHu6UOTxpfyIkZhh6DjPBZGRp7y3og==} - '@formatjs/icu-messageformat-parser@2.11.4': - resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} + '@formatjs/icu-messageformat-parser@3.1.1': + resolution: {integrity: sha512-Nntjlw21Yp2fFAkOeJmWhJg0nxoCeRTNF/+zNmLpAQGjdsaPJ7QabmbG3e/IHjVe55LGma4NuhYdaygJCuXVHQ==} - '@formatjs/icu-skeleton-parser@1.8.16': - resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} + '@formatjs/icu-skeleton-parser@2.0.5': + resolution: {integrity: sha512-SAbpKkyvD6F7ujlEDSqVR0KDi0IR8lT2X4A9wTehTEeIZy4bIJqu68tlNdC94IGbrZh2n3LXcS0VTKfcFnUhUg==} - '@formatjs/intl-localematcher@0.6.2': - resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@formatjs/intl-localematcher@0.7.3': + resolution: {integrity: sha512-NaeABectKdTCOnlH9VFGmMS3K0JuR7Soc2t5R2MCkBrM3H/hlKVYh0XSrcjjPkbjIdrF7L/Bzx9JtGuVaSfYlA==} '@gitbeaker/core@39.34.3': resolution: {integrity: sha512-/3qBXme2MjO38QU2F/MYGon9a4wHKrgtwNzdHHdjpbYJ2/wOGNgbEWSZcibcFkiWVgAjbPXdYqC5sY8hcwGO1w==} @@ -1499,6 +2159,26 @@ packages: resolution: {integrity: sha512-SuceThS6WhJtqNNcKmW8j0yUU7aXA4k5a29OWcd6bn7peQ3MXlIpbfvLLRnmuUaYUuxHLnUzZhAfuxaNf4DVtQ==} engines: {node: '>=18.0.0'} + '@hapi/address@5.1.1': + resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} + engines: {node: '>=14.0.0'} + + '@hapi/formula@3.0.2': + resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} + + '@hapi/hoek@11.0.7': + resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} + + '@hapi/pinpoint@2.0.1': + resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} + + '@hapi/tlds@1.1.4': + resolution: {integrity: sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==} + engines: {node: '>=14.0.0'} + + '@hapi/topo@6.0.2': + resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1832,61 +2512,28 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jsonjoy.com/base64@1.1.2': - resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' + '@lingo.dev/_compiler@0.8.8': + resolution: {integrity: sha512-gSqUFF6VxFAJCyjTJvCDqDIu+RBfmLEcRL1M8Z3tTtH9AHIXTTXUV+6t2udQJ1dTsytXPdFbpdNXknoMHMJvkQ==} - '@jsonjoy.com/buffers@1.2.1': - resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' + '@lingo.dev/_locales@0.3.1': + resolution: {integrity: sha512-9iTwe+14un1WnS/Ao2Mea5tmEV2IXSo41fJoV+FLOc4ZGfVcoGy4LgLf6QAUdYMuIpyitGu0qkPmUvAqoWoH9Q==} - '@jsonjoy.com/codegen@1.0.0': - resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==} - engines: {node: '>=10.0'} + '@lingo.dev/_react@0.7.5': + resolution: {integrity: sha512-c6HkkgVdlUBkRBo4Atj4L9xGB4l2YdSejD14Uls0nb3S2KTLhD3OwnSHpeZHdOneO3+JDxqSJ7i0emJLiPXIvQ==} peerDependencies: - tslib: '2' + next: 15.3.8 - '@jsonjoy.com/json-pack@1.21.0': - resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' + '@lingo.dev/_sdk@0.13.4': + resolution: {integrity: sha512-K3Sk0cvnpV5AtaYlpimeAAweCxahOJGWIuNrTJyE7NQer3Nbb3keCUy8DfVlpthAX8dOe+A1UBAWJokuaf568A==} - '@jsonjoy.com/json-pointer@1.0.2': - resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' + '@lingo.dev/_spec@0.44.4': + resolution: {integrity: sha512-SJifegtTD+VF0WUZtZVQytCX/YdOSLqCt9WTc0EXIx2t8arPnIgE2+rmPCJ84GYQhFc5BaTbYEo2pQhKQCOMNg==} - '@jsonjoy.com/util@1.9.0': - resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@leichtgewicht/ip-codec@2.0.5': - resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} - - '@lingo.dev/_compiler@0.8.0': - resolution: {integrity: sha512-dY/OF217WJbxCym1umVeJTs/D6y67A7bX+huBWUYI++BHzCBJJ6tzfxZOpRGa+UxfK+iabMX2PrXGv/leVLSOg==} - - '@lingo.dev/_locales@0.3.0': - resolution: {integrity: sha512-2EbSJu6IJOA1PZxJvT+a52Nogqw4udVhdr8zK+jMs0/46P+8k1Xquw67f/PhID7sv4qXYh/aILFg4kBut0ieyg==} + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} - '@lingo.dev/_react@0.7.0': - resolution: {integrity: sha512-kcQ0WrLp6kbNwzBeZBnBxUQL3QKNACrmzbiWwBbZ8q6AOPRtM+wKeFs+SjuGougw8yfVB2bgPnNUz8AoCiopOQ==} - peerDependencies: - next: 15.2.4 - - '@lingo.dev/_sdk@0.13.0': - resolution: {integrity: sha512-MWrcVzhnD6u2Rc3I9cbZ5cPYtHK8Ha4BvRTfEPxp5U6gFiHRPYFteSx/t38OdQyYC5fE9Jb8vLBWfHLeetEvMQ==} - - '@lingo.dev/_spec@0.44.0': - resolution: {integrity: sha512-RJ8jmkw+nuUe3IIGOKjwblszC+lo0N6woJwMZqwMLvTqMpTVx2NwZ4zi32uQiA8k4X7Y+0F5zcAoG3bBsmzJlQ==} + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} '@markdoc/markdoc@0.5.4': resolution: {integrity: sha512-36YFNlqFk//gVNGm5xZaTWVwbAVF2AOmVjf1tiUrS6tCoD/YSkVy2E3CkAfhc5MlKcjparL/QFHCopxL4zRyaQ==} @@ -1900,9 +2547,6 @@ packages: react: optional: true - '@mjackson/node-fetch-server@0.2.0': - resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} - '@modelcontextprotocol/sdk@1.22.0': resolution: {integrity: sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==} engines: {node: '>=18'} @@ -1915,20 +2559,32 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@napi-rs/wasm-runtime@1.0.7': - resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + '@napi-rs/wasm-runtime@1.1.0': + resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} - '@next/env@16.0.7': - resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==} + '@next/env@15.3.8': + resolution: {integrity: sha512-SAfHg0g91MQVMPioeFeDjE+8UPF3j3BvHjs8ZKJAUz1BG7eMPvfCKOAgNWJ6s1MLNeP6O2InKQRTNblxPWuq+Q==} + + '@next/env@16.0.4': + resolution: {integrity: sha512-FDPaVoB1kYhtOz6Le0Jn2QV7RZJ3Ngxzqri7YX4yu3Ini+l5lciR7nA9eNDpKTmDm7LWZtxSju+/CQnwRBn2pA==} '@next/env@16.1.0': resolution: {integrity: sha512-Dd23XQeFHmhf3KBW76leYVkejHlCdB7erakC2At2apL1N08Bm+dLYNP+nNHh0tzUXfPQcNcXiQyacw0PG4Fcpw==} + '@next/env@16.1.1': + resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==} + '@next/eslint-plugin-next@16.0.3': resolution: {integrity: sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==} - '@next/swc-darwin-arm64@16.0.7': - resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==} + '@next/swc-darwin-arm64@15.3.5': + resolution: {integrity: sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-arm64@16.0.4': + resolution: {integrity: sha512-TN0cfB4HT2YyEio9fLwZY33J+s+vMIgC84gQCOLZOYusW7ptgjIn8RwxQt0BUpoo9XRRVVWEHLld0uhyux1ZcA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -1939,8 +2595,20 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.0.7': - resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==} + '@next/swc-darwin-arm64@16.1.1': + resolution: {integrity: sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.3.5': + resolution: {integrity: sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-darwin-x64@16.0.4': + resolution: {integrity: sha512-XsfI23jvimCaA7e+9f3yMCoVjrny2D11G6H8NCcgv+Ina/TQhKPXB9P4q0WjTuEoyZmcNvPdrZ+XtTh3uPfH7Q==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -1951,8 +2619,20 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.0.7': - resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==} + '@next/swc-darwin-x64@16.1.1': + resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.3.5': + resolution: {integrity: sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-gnu@16.0.4': + resolution: {integrity: sha512-uo8X7qHDy4YdJUhaoJDMAbL8VT5Ed3lijip2DdBHIB4tfKAvB1XBih6INH2L4qIi4jA0Qq1J0ErxcOocBmUSwg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1963,8 +2643,20 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.0.7': - resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==} + '@next/swc-linux-arm64-gnu@16.1.1': + resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.3.5': + resolution: {integrity: sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.0.4': + resolution: {integrity: sha512-pvR/AjNIAxsIz0PCNcZYpH+WmNIKNLcL4XYEfo+ArDi7GsxKWFO5BvVBLXbhti8Coyv3DE983NsitzUsGH5yTw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1975,8 +2667,20 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.0.7': - resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==} + '@next/swc-linux-arm64-musl@16.1.1': + resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.3.5': + resolution: {integrity: sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.0.4': + resolution: {integrity: sha512-2hebpsd5MRRtgqmT7Jj/Wze+wG+ZEXUK2KFFL4IlZ0amEEFADo4ywsifJNeFTQGsamH3/aXkKWymDvgEi+pc2Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1987,8 +2691,20 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.0.7': - resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==} + '@next/swc-linux-x64-gnu@16.1.1': + resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.3.5': + resolution: {integrity: sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.0.4': + resolution: {integrity: sha512-pzRXf0LZZ8zMljH78j8SeLncg9ifIOp3ugAFka+Bq8qMzw6hPXOc7wydY7ardIELlczzzreahyTpwsim/WL3Sg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1999,8 +2715,20 @@ packages: cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.0.7': - resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==} + '@next/swc-linux-x64-musl@16.1.1': + resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.3.5': + resolution: {integrity: sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-arm64-msvc@16.0.4': + resolution: {integrity: sha512-7G/yJVzum52B5HOqqbQYX9bJHkN+c4YyZ2AIvEssMHQlbAWOn3iIJjD4sM6ihWsBxuljiTKJovEYlD1K8lCUHw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -2011,8 +2739,20 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.0.7': - resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==} + '@next/swc-win32-arm64-msvc@16.1.1': + resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.3.5': + resolution: {integrity: sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.0.4': + resolution: {integrity: sha512-0Vy4g8SSeVkuU89g2OFHqGKM4rxsQtihGfenjx2tRckPrge5+gtFnRWGAAwvGXr0ty3twQvcnYjEyOrLHJ4JWA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2023,6 +2763,12 @@ packages: cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@16.1.1': + resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -2043,18 +2789,6 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@npmcli/git@4.1.0': - resolution: {integrity: sha512-9hwoB3gStVfa0N31ymBmrX+GuDGdVA/QWShZVqE0HK2Af+7QGGrCTbZia/SW0ImUTjTne7SP91qxDmtXvDHRPQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - '@npmcli/package-json@4.0.1': - resolution: {integrity: sha512-lRCEGdHZomFsURroh522YvA/2cVb9oPIJrjHanCJZkiasz1BzcnLr3tBJhlV7S86MBJBuAQ33is2D60YitZL2Q==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - '@npmcli/promise-spawn@6.0.2': - resolution: {integrity: sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - '@octokit/app@15.1.6': resolution: {integrity: sha512-WELCamoCJo9SN0lf3SWZccf68CF0sBNPQuLYmZ/n87p5qvBJDe9aBtr5dHkh7T9nxWZ608pizwsUbypSzZAiUw==} engines: {node: '>= 18'} @@ -2075,6 +2809,10 @@ packages: resolution: {integrity: sha512-/R8vgeoulp7rJs+wfJ2LtXEVC7pjQTIqDab7wPKwVG6+2v/lUnCOub6vaHmysQBbb45FknM3tbHW8TOVqYHxCw==} engines: {node: '>= 18'} + '@octokit/auth-token@4.0.0': + resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} + engines: {node: '>= 18'} + '@octokit/auth-token@5.1.2': resolution: {integrity: sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==} engines: {node: '>= 18'} @@ -2083,6 +2821,10 @@ packages: resolution: {integrity: sha512-d5gWJla3WdSl1yjbfMpET+hUSFCE15qM0KVSB0H1shyuJihf/RL1KqWoZMIaonHvlNojkL9XtLFp8QeLe+1iwA==} engines: {node: '>= 18'} + '@octokit/core@5.2.2': + resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} + engines: {node: '>= 18'} + '@octokit/core@6.1.6': resolution: {integrity: sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==} engines: {node: '>= 18'} @@ -2091,6 +2833,14 @@ packages: resolution: {integrity: sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==} engines: {node: '>= 18'} + '@octokit/endpoint@9.0.6': + resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} + engines: {node: '>= 18'} + + '@octokit/graphql@7.1.1': + resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} + engines: {node: '>= 18'} + '@octokit/graphql@8.2.2': resolution: {integrity: sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==} engines: {node: '>= 18'} @@ -2122,6 +2872,12 @@ packages: peerDependencies: '@octokit/core': '>=6' + '@octokit/plugin-paginate-rest@11.4.4-cjs.2': + resolution: {integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + '@octokit/plugin-paginate-rest@11.6.0': resolution: {integrity: sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==} engines: {node: '>= 18'} @@ -2134,6 +2890,18 @@ packages: peerDependencies: '@octokit/core': '>=6' + '@octokit/plugin-request-log@4.0.1': + resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1': + resolution: {integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': ^5 + '@octokit/plugin-rest-endpoint-methods@13.5.0': resolution: {integrity: sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==} engines: {node: '>= 18'} @@ -2152,14 +2920,26 @@ packages: peerDependencies: '@octokit/core': ^6.1.3 + '@octokit/request-error@5.1.1': + resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} + engines: {node: '>= 18'} + '@octokit/request-error@6.1.8': resolution: {integrity: sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==} engines: {node: '>= 18'} + '@octokit/request@8.4.1': + resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} + engines: {node: '>= 18'} + '@octokit/request@9.2.4': resolution: {integrity: sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==} engines: {node: '>= 18'} + '@octokit/rest@20.1.2': + resolution: {integrity: sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==} + engines: {node: '>= 18'} + '@octokit/types@13.10.0': resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} @@ -2181,366 +2961,629 @@ packages: ai: ^4.3.16 zod: ^3.25.34 - '@openrouter/ai-sdk-provider@0.7.5': - resolution: {integrity: sha512-zm8vBhQ+GhxN03Y41xviB0nDa20uN77QnMXsIwDeJPqsul8+KycrYFxY4ulXpumeKxjKyOhfyA7a7CJpcYq2ng==} + '@openrouter/ai-sdk-provider@1.5.4': + resolution: {integrity: sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw==} engines: {node: '>=18'} peerDependencies: - ai: ^4.3.17 - zod: ^3.25.34 - - '@opentelemetry/api@1.9.0': - resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} - engines: {node: '>=8.0.0'} - - '@oxc-project/runtime@0.96.0': - resolution: {integrity: sha512-34lh4o9CcSw09Hx6fKihPu85+m+4pmDlkXwJrLvN5nMq5JrcGhhihVM415zDqT8j8IixO1PYYdQZRN4SwQCncg==} - engines: {node: ^20.19.0 || >=22.12.0} - - '@oxc-project/types@0.98.0': - resolution: {integrity: sha512-Vzmd6FsqVuz5HQVcRC/hrx7Ujo3WEVeQP7C2UNP5uy1hUY4SQvMB+93jxkI1KRHz9a/6cni3glPOtvteN+zpsw==} - - '@oxlint-tsgolint/darwin-arm64@0.8.0': - resolution: {integrity: sha512-k9b+gy8F2X2uDvjn2WwlHFlI0rfMY7h2B+G/fq7xLcLF6rktR2vvNiJrIkGZ+bRIbQIi3kBSU1/w2Q/Uei/mGA==} - cpu: [arm64] - os: [darwin] - - '@oxlint-tsgolint/darwin-x64@0.8.0': - resolution: {integrity: sha512-KPAHaKWIb9zXGwjK/9LWmW46PczearH0q6e/ZY7NFz0GBIgO00tJ/ufPp7UXQy67TfkuWSq7kg11ciiNrLMONA==} - cpu: [x64] - os: [darwin] - - '@oxlint-tsgolint/linux-arm64@0.8.0': - resolution: {integrity: sha512-my+nslIogIlOxYOHwkFstnpaSb4JVMx3/8lnXmYKyH8GGl/zizD2M3Szbr3OGtZUPfUhJiG5R6hYjVAiHoEf8w==} - cpu: [arm64] - os: [linux] - - '@oxlint-tsgolint/linux-x64@0.8.0': - resolution: {integrity: sha512-3lnwHrivrqgeaY3NO4bxMpFoLQmwDxASTKkvNwob5trRmowhPfPOXqIns2KcdWADHIXBZLJWVA9RMGuix/gybQ==} - cpu: [x64] - os: [linux] - - '@oxlint-tsgolint/win32-arm64@0.8.0': - resolution: {integrity: sha512-tnEu/DcU17OtO1jExuo0VqMJ0tb2kr6I/IbdlC+ZbvitpavSPX/B1aEpmoJHtj8EQr6jykWD3LcGmuj+KRWgww==} - cpu: [arm64] - os: [win32] - - '@oxlint-tsgolint/win32-x64@0.8.0': - resolution: {integrity: sha512-Cj1N5s24Ikm0lAtMBwBYeElZyrfaB69QN6Gr7SzBgpSMwwYXm8e1nDH1XR/CQgraBJAwGS9hSeyuF13B9MO6+Q==} - cpu: [x64] - os: [win32] - - '@oxlint/darwin-arm64@1.29.0': - resolution: {integrity: sha512-XYsieDAI0kXJyvayHnmOW1qVydqklRRVT4O5eZmO/rdNCku5CoXsZvBvkPc3U8/9V1mRuen1sxbM9T5JsZqhdA==} - cpu: [arm64] - os: [darwin] - - '@oxlint/darwin-x64@1.29.0': - resolution: {integrity: sha512-s+Ch5/4zDJ6wsOk95xY3BS5mtE2JzHLz7gVZ9OWA9EvhVO84wz2YbDp2JaA314yyqhlX5SAkZ6fj3BRMIcQIqg==} - cpu: [x64] - os: [darwin] - - '@oxlint/linux-arm64-gnu@1.29.0': - resolution: {integrity: sha512-qLCgdUkDBG8muK1o3mPgf31rvCPzj1Xff9DHlJjfv+B0ee/hJ2LAoK8EIsQedfQuuiAccOe9GG65BivGCTgKOg==} - cpu: [arm64] - os: [linux] + ai: ^5.0.0 + zod: ^3.24.1 || ^v4 - '@oxlint/linux-arm64-musl@1.29.0': - resolution: {integrity: sha512-qe62yb1fyW51wo1VBpx9AJJ1Ih1T8NYDeR9AmpNGkrmKN8u3pPbcGXM4mCrOwpwJUG9M/oFvCIlIz2RhawHlkA==} - cpu: [arm64] - os: [linux] + '@openrouter/ai-sdk-provider@6.0.0-alpha.1': + resolution: {integrity: sha512-N91glWtq6XFl8Kvgft14BiDeCLABHatylAVKHOWMRJHnBl6iKblI4iZgcipnF9Pj8ZRUO752qpPoZVC+L2C6tA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^4.3.5 - '@oxlint/linux-x64-gnu@1.29.0': - resolution: {integrity: sha512-4x7p2iVoSE2aT9qI1JOLxUAv3UuzMYGBYWBA4ZF8ln99AdUo1eo0snFacPNd6I/ZZNcv5TegXC+0EUhp5MfYBw==} - cpu: [x64] - os: [linux] + '@openrouter/sdk@0.1.27': + resolution: {integrity: sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ==} - '@oxlint/linux-x64-musl@1.29.0': - resolution: {integrity: sha512-BdH5gdRpaYpyZn2Zm+MCS4b1YmXNe7QyQhw0fawuou+N1LrdAyELgvqI5xXZ1MXCgWDOa6WJaoE6VOPaDc29GA==} - cpu: [x64] - os: [linux] + '@openrouter/sdk@0.3.15': + resolution: {integrity: sha512-tmiMQGu6L1fHD9NpIABN9LALEZitqM27CFebSVyJTQDcxCcR3m1F6v1O1MnOlLmedCFU+/BojniRGFAXo1F3Bw==} - '@oxlint/win32-arm64@1.29.0': - resolution: {integrity: sha512-y+j9ZDrnMxvRTNIstZKFY7gJD07nT++c4cGmub1ENvhoHVToiQAAZQUOLDhXXRzCrFoG/cFJXJf72uowHZPbcg==} - cpu: [arm64] - os: [win32] + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} - '@oxlint/win32-x64@1.29.0': - resolution: {integrity: sha512-F1iRtq8VT96lT8hqOubLyV0GxgIK/XdXk2kFLXdCspiI2ngXeNmTTvmPxrj+WFL6fpJPgv7VKWRb/zEHJnNOrg==} - cpu: [x64] - os: [win32] + '@oxc-project/types@0.103.0': + resolution: {integrity: sha512-bkiYX5kaXWwUessFRSoXFkGIQTmc6dLGdxuRTrC+h8PSnIdZyuXHHlLAeTmOue5Br/a0/a7dHH0Gca6eXn9MKg==} '@paralleldrive/cuid2@2.2.2': resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} '@playwright/test@1.56.1': resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} engines: {node: '>=18'} hasBin: true - '@posthog/core@1.6.0': - resolution: {integrity: sha512-Tbh8UACwbb7jFdDC7wwXHtfNzO+4wKh3VbyMHmp2UBe6w1jliJixexTJNfkqdGZm+ht3M10mcKvGGPnoZ2zLBg==} - - '@quansync/fs@0.1.5': - resolution: {integrity: sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA==} - - '@react-router/dev@7.9.6': - resolution: {integrity: sha512-pBkbczGwI+NcZPcK8JPvWGWdjUpT/+okXYp6IXvt7zI3WLxr5hQLLRox5FkLiVxkykbqARO1hk9NRp9KFwJ2sA==} - engines: {node: '>=20.0.0'} + '@playwright/test@1.57.0': + resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + engines: {node: '>=18'} hasBin: true - peerDependencies: - '@react-router/serve': ^7.9.6 - '@vitejs/plugin-rsc': '*' - react-router: ^7.9.6 - typescript: ^5.1.0 - vite: ^5.1.0 || ^6.0.0 || ^7.0.0 - wrangler: ^3.28.2 || ^4.0.0 - peerDependenciesMeta: - '@react-router/serve': - optional: true - '@vitejs/plugin-rsc': - optional: true - typescript: - optional: true - wrangler: - optional: true - '@react-router/express@7.9.6': - resolution: {integrity: sha512-YykIWqZSkcaOnC72k0BtPZJK9781Ge623pWkTn0svzFLsqWW2/tX/Y1/Le6eG2xWrGeGfaeTSzi9dy3agP0OIw==} - engines: {node: '>=20.0.0'} - peerDependencies: - express: ^4.17.1 || ^5 - react-router: 7.9.6 - typescript: ^5.1.0 - peerDependenciesMeta: - typescript: - optional: true + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@react-router/node@7.9.6': - resolution: {integrity: sha512-XzU8gPHwSl2Qh8/bOV30npbpH2fWOO3sFg+SwhX3+IddD1a/0C2KQzRiW/qAngkvZTJVdbca5Qp+FJjCCE7sNw==} - engines: {node: '>=20.0.0'} - peerDependencies: - react-router: 7.9.6 - typescript: ^5.1.0 - peerDependenciesMeta: - typescript: - optional: true + '@posthog/core@1.6.0': + resolution: {integrity: sha512-Tbh8UACwbb7jFdDC7wwXHtfNzO+4wKh3VbyMHmp2UBe6w1jliJixexTJNfkqdGZm+ht3M10mcKvGGPnoZ2zLBg==} - '@react-router/serve@7.9.6': - resolution: {integrity: sha512-qIT8hp1RJ0VAHyXpfuwoO31b9evrjPLRhUugqYJ7BZLpyAwhRsJIaQvvj60yZwWBMF2/3LdZu7M39rf0FhL6Iw==} - engines: {node: '>=20.0.0'} - hasBin: true - peerDependencies: - react-router: 7.9.6 + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@remix-run/node-fetch-server@0.9.0': - resolution: {integrity: sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA==} + '@replexica/sdk@0.7.12': + resolution: {integrity: sha512-8yLWWMsBQbAxq1kCa8rSPDfZBBICR5881DB9KkcwWdiY+JyaeC0WJG/uRy4HvlB2ZDxKVXiexnsYHafaI+icGQ==} + deprecated: 'Replexica is now Lingo.dev! Please use our new SDK package by running: npm install lingo.dev. Visit https://lingo.dev for the latest features and documentation.' - '@rolldown/binding-android-arm64@1.0.0-beta.51': - resolution: {integrity: sha512-Ctn8FUXKWWQI9pWC61P1yumS9WjQtelNS9riHwV7oCkknPGaAry4o7eFx2KgoLMnI2BgFJYpW7Im8/zX3BuONg==} + '@rolldown/binding-android-arm64@1.0.0-beta.55': + resolution: {integrity: sha512-5cPpHdO+zp+klznZnIHRO1bMHDq5hS9cqXodEKAaa/dQTPDjnE91OwAsy3o1gT2x4QaY8NzdBXAvutYdaw0WeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-beta.51': - resolution: {integrity: sha512-EL1aRW2Oq15ShUEkBPsDtLMO8GTqfb/ktM/dFaVzXKQiEE96Ss6nexMgfgQrg8dGnNpndFyffVDb5IdSibsu1g==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.55': + resolution: {integrity: sha512-l0887CGU2SXZr0UJmeEcXSvtDCOhDTTYXuoWbhrEJ58YQhQk24EVhDhHMTyjJb1PBRniUgNc1G0T51eF8z+TWw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.51': - resolution: {integrity: sha512-uGtYKlFen9pMIPvkHPWZVDtmYhMQi5g5Ddsndg1gf3atScKYKYgs5aDP4DhHeTwGXQglhfBG7lEaOIZ4UAIWww==} + '@rolldown/binding-darwin-x64@1.0.0-beta.55': + resolution: {integrity: sha512-d7qP2AVYzN0tYIP4vJ7nmr26xvmlwdkLD/jWIc9Z9dqh5y0UGPigO3m5eHoHq9BNazmwdD9WzDHbQZyXFZjgtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-beta.51': - resolution: {integrity: sha512-JRoVTQtHYbZj1P07JLiuTuXjiBtIa7ag7/qgKA6CIIXnAcdl4LrOf7nfDuHPJcuRKaP5dzecMgY99itvWfmUFQ==} + '@rolldown/binding-freebsd-x64@1.0.0-beta.55': + resolution: {integrity: sha512-j311E4NOB0VMmXHoDDZhrWidUf7L/Sa6bu/+i2cskvHKU40zcUNPSYeD2YiO2MX+hhDFa5bJwhliYfs+bTrSZw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.51': - resolution: {integrity: sha512-BKATVnpPZ0TYBW9XfDwyd4kPGgvf964HiotIwUgpMrFOFYWqpZ+9ONNzMV4UFAYC7Hb5C2qgYQk/qj2OnAd4RQ==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.55': + resolution: {integrity: sha512-lAsaYWhfNTW2A/9O7zCpb5eIJBrFeNEatOS/DDOZ5V/95NHy50g4b/5ViCqchfyFqRb7MKUR18/+xWkIcDkeIw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.51': - resolution: {integrity: sha512-xLd7da5jkfbVsBCm1buIRdWtuXY8+hU3+6ESXY/Tk5X5DPHaifrUblhYDgmA34dQt6WyNC2kfXGgrduPEvDI6Q==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.55': + resolution: {integrity: sha512-2x6ffiVLZrQv7Xii9+JdtyT1U3bQhKj59K3eRnYlrXsKyjkjfmiDUVx2n+zSyijisUqD62fcegmx2oLLfeTkCA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.51': - resolution: {integrity: sha512-EQFXTgHxxTzv3t5EmjUP/DfxzFYx9sMndfLsYaAY4DWF6KsK1fXGYsiupif6qPTViPC9eVmRm78q0pZU/kuIPg==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.55': + resolution: {integrity: sha512-QbNncvqAXziya5wleI+OJvmceEE15vE4yn4qfbI/hwT/+8ZcqxyfRZOOh62KjisXxp4D0h3JZspycXYejxAU3w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.51': - resolution: {integrity: sha512-p5P6Xpa68w3yFaAdSzIZJbj+AfuDnMDqNSeglBXM7UlJT14Q4zwK+rV+8Mhp9MiUb4XFISZtbI/seBprhkQbiQ==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.55': + resolution: {integrity: sha512-YZCTZZM+rujxwVc6A+QZaNMJXVtmabmFYLG2VGQTKaBfYGvBKUgtbMEttnp/oZ88BMi2DzadBVhOmfQV8SuHhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.51': - resolution: {integrity: sha512-sNVVyLa8HB8wkFipdfz1s6i0YWinwpbMWk5hO5S+XAYH2UH67YzUT13gs6wZTKg2x/3gtgXzYnHyF5wMIqoDAw==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.55': + resolution: {integrity: sha512-28q9OQ/DDpFh2keS4BVAlc3N65/wiqKbk5K1pgLdu/uWbKa8hgUJofhXxqO+a+Ya2HVTUuYHneWsI2u+eu3N5Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-beta.51': - resolution: {integrity: sha512-e/JMTz9Q8+T3g/deEi8DK44sFWZWGKr9AOCW5e8C8SCVWzAXqYXAG7FXBWBNzWEZK0Rcwo9TQHTQ9Q0gXgdCaA==} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.55': + resolution: {integrity: sha512-LiCA4BjCnm49B+j1lFzUtlC+4ZphBv0d0g5VqrEJua/uyv9Ey1v9tiaMql1C8c0TVSNDUmrkfHQ71vuQC7YfpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.51': - resolution: {integrity: sha512-We3LWqSu6J9s5Y0MK+N7fUiiu37aBGPG3Pc347EoaROuAwkCS2u9xJ5dpIyLW4B49CIbS3KaPmn4kTgPb3EyPw==} + '@rolldown/binding-wasm32-wasi@1.0.0-beta.55': + resolution: {integrity: sha512-nZ76tY7T0Oe8vamz5Cv5CBJvrqeQxwj1WaJ2GxX8Msqs0zsQMMcvoyxOf0glnJlxxgKjtoBxAOxaAU8ERbW6Tg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.51': - resolution: {integrity: sha512-fj56buHRuMM+r/cb6ZYfNjNvO/0xeFybI6cTkTROJatdP4fvmQ1NS8D/Lm10FCSDEOkqIz8hK3TGpbAThbPHsA==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.55': + resolution: {integrity: sha512-TFVVfLfhL1G+pWspYAgPK/FSqjiBtRKYX9hixfs508QVEZPQlubYAepHPA7kEa6lZXYj5ntzF87KC6RNhxo+ew==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.51': - resolution: {integrity: sha512-fkqEqaeEx8AySXiDm54b/RdINb3C0VovzJA3osMhZsbn6FoD73H0AOIiaVAtGr6x63hefruVKTX8irAm4Jkt2w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.51': - resolution: {integrity: sha512-CWuLG/HMtrVcjKGa0C4GnuxONrku89g0+CsH8nT0SNhOtREXuzwgjIXNJImpE/A/DMf9JF+1Xkrq/YRr+F/rCg==} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.55': + resolution: {integrity: sha512-j1WBlk0p+ISgLzMIgl0xHp1aBGXenoK2+qWYc/wil2Vse7kVOdFq9aeQ8ahK6/oxX2teQ5+eDvgjdywqTL+daA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.47': - resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==} + '@rolldown/pluginutils@1.0.0-beta.29': + resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==} + + '@rolldown/pluginutils@1.0.0-beta.38': + resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==} + + '@rolldown/pluginutils@1.0.0-beta.55': + resolution: {integrity: sha512-vajw/B3qoi7aYnnD4BQ4VoCcXQWnF0roSwE2iynbNxgW4l9mFwtLmLmUhpDdcTBfKyZm1p/T0D13qG94XBLohA==} + + '@rollup/plugin-alias@5.1.1': + resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-commonjs@28.0.9': + resolution: {integrity: sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-json@6.1.0': + resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@6.0.3': + resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-terser@0.4.4': + resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-virtual@3.0.2': + resolution: {integrity: sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true - '@rolldown/pluginutils@1.0.0-beta.51': - resolution: {integrity: sha512-51/8cNXMrqWqX3o8DZidhwz1uYq0BhHDDSfVygAND1Skx5s1TDw3APSSxCMcFFedwgqGcx34gRouwY+m404BBQ==} + '@rollup/rollup-android-arm-eabi@4.52.5': + resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} + cpu: [arm] + os: [android] - '@rollup/rollup-android-arm-eabi@4.53.3': - resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} + '@rollup/rollup-android-arm-eabi@4.54.0': + resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.53.3': - resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} + '@rollup/rollup-android-arm64@4.52.5': + resolution: {integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-android-arm64@4.54.0': + resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.53.3': - resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} + '@rollup/rollup-darwin-arm64@4.52.5': + resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-arm64@4.54.0': + resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.53.3': - resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} + '@rollup/rollup-darwin-x64@4.52.5': + resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.53.3': - resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} + '@rollup/rollup-darwin-x64@4.54.0': + resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.52.5': + resolution: {integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-arm64@4.54.0': + resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.53.3': - resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} + '@rollup/rollup-freebsd-x64@4.52.5': + resolution: {integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.54.0': + resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': - resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} + '@rollup/rollup-linux-arm-gnueabihf@4.52.5': + resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.53.3': - resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.53.3': - resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} - cpu: [arm64] + '@rollup/rollup-linux-arm-musleabihf@4.52.5': + resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.54.0': + resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} + cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.53.3': - resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} + '@rollup/rollup-linux-arm64-gnu@4.52.5': + resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.53.3': - resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} - cpu: [loong64] + '@rollup/rollup-linux-arm64-gnu@4.54.0': + resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} + cpu: [arm64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.53.3': - resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} - cpu: [ppc64] + '@rollup/rollup-linux-arm64-musl@4.52.5': + resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==} + cpu: [arm64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.53.3': - resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} - cpu: [riscv64] + '@rollup/rollup-linux-arm64-musl@4.54.0': + resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} + cpu: [arm64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.53.3': - resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} - cpu: [riscv64] + '@rollup/rollup-linux-loong64-gnu@4.52.5': + resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==} + cpu: [loong64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.53.3': - resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} - cpu: [s390x] + '@rollup/rollup-linux-loong64-gnu@4.54.0': + resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} + cpu: [loong64] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.53.3': - resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} + '@rollup/rollup-linux-ppc64-gnu@4.52.5': + resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.54.0': + resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.52.5': + resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.54.0': + resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.52.5': + resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.54.0': + resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.52.5': + resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.54.0': + resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.52.5': + resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.53.3': - resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} + '@rollup/rollup-linux-x64-gnu@4.54.0': + resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] - '@rollup/rollup-openharmony-arm64@4.53.3': - resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} + '@rollup/rollup-linux-x64-musl@4.52.5': + resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.54.0': + resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.52.5': + resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-openharmony-arm64@4.54.0': + resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.53.3': - resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} + '@rollup/rollup-win32-arm64-msvc@4.52.5': + resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-arm64-msvc@4.54.0': + resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.53.3': - resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} + '@rollup/rollup-win32-ia32-msvc@4.52.5': + resolution: {integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.53.3': - resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} + '@rollup/rollup-win32-ia32-msvc@4.54.0': + resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.52.5': + resolution: {integrity: sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.54.0': + resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.52.5': + resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.53.3': - resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} + '@rollup/rollup-win32-x64-msvc@4.54.0': + resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==} cpu: [x64] os: [win32] '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sinclair/typebox@0.34.41': + resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==} + + '@sindresorhus/merge-streams@2.3.0': + resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} + engines: {node: '>=18'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@smithy/abort-controller@4.2.7': + resolution: {integrity: sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.5': + resolution: {integrity: sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.20.0': + resolution: {integrity: sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.7': + resolution: {integrity: sha512-CmduWdCiILCRNbQWFR0OcZlUPVtyE49Sr8yYL0rZQ4D/wKxiNzBNS/YHemvnbkIWj623fplgkexUd/c9CAKdoA==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.8': + resolution: {integrity: sha512-h/Fi+o7mti4n8wx1SR6UHWLaakwHRx29sizvp8OOm7iqwKGFneT06GCSFhml6Bha5BT6ot5pj3CYZnCHhGC2Rg==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.7': + resolution: {integrity: sha512-PU/JWLTBCV1c8FtB8tEFnY4eV1tSfBc7bDBADHfn1K+uRbPgSJ9jnJp0hyjiFN2PMdPzxsf1Fdu0eo9fJ760Xw==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.7': + resolution: {integrity: sha512-ncvgCr9a15nPlkhIUx3CU4d7E7WEuVJOV7fS7nnK2hLtPK9tYRBkMHQbhXU1VvvKeBm/O0x26OEoBq+ngFpOEQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.0': + resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.7': + resolution: {integrity: sha512-GszfBfCcvt7kIbJ41LuNa5f0wvQCHhnGx/aDaZJCCT05Ld6x6U2s0xsc/0mBFONBZjQJp2U/0uSJ178OXOwbhg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.1': + resolution: {integrity: sha512-gpLspUAoe6f1M6H0u4cVuFzxZBrsGZmjx2O9SigurTx4PbntYa4AJ+o0G0oGm1L2oSX6oBhcGHwrfJHup2JnJg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.17': + resolution: {integrity: sha512-MqbXK6Y9uq17h+4r0ogu/sBT6V/rdV+5NvYL7ZV444BKfQygYe8wAhDrVXagVebN6w2RE0Fm245l69mOsPGZzg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.8': + resolution: {integrity: sha512-8rDGYen5m5+NV9eHv9ry0sqm2gI6W7mc1VSFMtn6Igo25S507/HaOX9LTHAS2/J32VXD0xSzrY0H5FJtOMS4/w==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.7': + resolution: {integrity: sha512-bsOT0rJ+HHlZd9crHoS37mt8qRRN/h9jRve1SXUhVbkRzu0QaNYZp1i1jha4n098tsvROjcwfLlfvcFuJSXEsw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.7': + resolution: {integrity: sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.7': + resolution: {integrity: sha512-NELpdmBOO6EpZtWgQiHjoShs1kmweaiNuETUpuup+cmm/xJYjT4eUjfhrXRP4jCOaAsS3c3yPsP3B+K+/fyPCQ==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.7': + resolution: {integrity: sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.7': + resolution: {integrity: sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.7': + resolution: {integrity: sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.7': + resolution: {integrity: sha512-3X5ZvzUHmlSTHAXFlswrS6EGt8fMSIxX/c3Rm1Pni3+wYWB6cjGocmRIoqcQF9nU5OgGmL0u7l9m44tSUpfj9w==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.7': + resolution: {integrity: sha512-YB7oCbukqEb2Dlh3340/8g8vNGbs/QsNNRms+gv3N2AtZz9/1vSBx6/6tpwQpZMEJFs7Uq8h4mmOn48ZZ72MkA==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.2': + resolution: {integrity: sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.7': + resolution: {integrity: sha512-9oNUlqBlFZFOSdxgImA6X5GFuzE7V2H7VG/7E70cdLhidFbdtvxxt81EHgykGK5vq5D3FafH//X+Oy31j3CKOg==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.10.2': + resolution: {integrity: sha512-D5z79xQWpgrGpAHb054Fn2CCTQZpog7JELbVQ6XAvXs5MNKWf28U9gzSBlJkOyMl9LA1TZEjRtwvGXfP0Sl90g==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.11.0': + resolution: {integrity: sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.7': + resolution: {integrity: sha512-/RLtVsRV4uY3qPWhBDsjwahAtt3x2IsMGnP5W1b2VZIe+qgCqkLxI1UOHDZp1Q1QSOrdOR32MF3Ph2JfWT1VHg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.0': + resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.0': + resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.1': + resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.0': + resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.0': + resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.16': + resolution: {integrity: sha512-/eiSP3mzY3TsvUOYMeL4EqUX6fgUOj2eUOU4rMMgVbq67TiRLyxT7Xsjxq0bW3OwuzK009qOwF0L2OgJqperAQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.19': + resolution: {integrity: sha512-3a4+4mhf6VycEJyHIQLypRbiwG6aJvbQAeRAVXydMmfweEPnLLabRbdyo/Pjw8Rew9vjsh5WCdhmDaHkQnhhhA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.2.7': + resolution: {integrity: sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.0': + resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.7': + resolution: {integrity: sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.7': + resolution: {integrity: sha512-SvDdsQyF5CIASa4EYVT02LukPHVzAgUA4kMAuZ97QJc2BpAqZfA4PINB8/KOoCXEw9tsuv/jQjMeaHFvxdLNGg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.8': + resolution: {integrity: sha512-ZnnBhTapjM0YPGUSmOs0Mcg/Gg87k503qG4zU2v/+Js2Gu+daKOJMeqcQns8ajepY8tgzzfYxl6kQyZKml6O2w==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.0': + resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.0': + resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.0': + resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + engines: {node: '>=18.0.0'} + '@solid-primitives/event-listener@2.4.3': resolution: {integrity: sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg==} peerDependencies: @@ -2551,89 +3594,211 @@ packages: peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/resize-observer@2.1.3': - resolution: {integrity: sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ==} - peerDependencies: - solid-js: ^1.6.12 - '@solid-primitives/rootless@1.5.2': resolution: {integrity: sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ==} peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/static-store@0.1.2': - resolution: {integrity: sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw==} - peerDependencies: - solid-js: ^1.6.12 - '@solid-primitives/utils@6.3.2': resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} peerDependencies: solid-js: ^1.6.12 - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/core-darwin-arm64@1.15.3': + resolution: {integrity: sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.3': + resolution: {integrity: sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.3': + resolution: {integrity: sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.3': + resolution: {integrity: sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.3': + resolution: {integrity: sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.3': + resolution: {integrity: sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.3': + resolution: {integrity: sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.3': + resolution: {integrity: sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.3': + resolution: {integrity: sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.3': + resolution: {integrity: sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.3': + resolution: {integrity: sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + '@tailwindcss/node@4.1.17': resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==} + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + '@tailwindcss/oxide-android-arm64@4.1.17': resolution: {integrity: sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==} engines: {node: '>= 10'} cpu: [arm64] os: [android] + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + '@tailwindcss/oxide-darwin-arm64@4.1.17': resolution: {integrity: sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@tailwindcss/oxide-darwin-x64@4.1.17': resolution: {integrity: sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@tailwindcss/oxide-freebsd-x64@4.1.17': resolution: {integrity: sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==} engines: {node: '>= 10'} cpu: [x64] os: [freebsd] + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': resolution: {integrity: sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': resolution: {integrity: sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@tailwindcss/oxide-linux-arm64-musl@4.1.17': resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@tailwindcss/oxide-linux-x64-gnu@4.1.17': resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@tailwindcss/oxide-linux-x64-musl@4.1.17': resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@tailwindcss/oxide-wasm32-wasi@4.1.17': resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==} engines: {node: '>=14.0.0'} @@ -2646,38 +3811,66 @@ packages: - '@emnapi/wasi-threads' - tslib + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': resolution: {integrity: sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@tailwindcss/oxide-win32-x64-msvc@4.1.17': resolution: {integrity: sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@tailwindcss/oxide@4.1.17': resolution: {integrity: sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==} engines: {node: '>= 10'} + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} + '@tailwindcss/postcss@4.1.17': resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==} - '@tailwindcss/vite@4.1.17': - resolution: {integrity: sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==} + '@tailwindcss/vite@4.1.18': + resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 - '@tanstack/devtools-client@0.0.3': - resolution: {integrity: sha512-kl0r6N5iIL3t9gGDRAv55VRM3UIyMKVH83esRGq7xBjYsRLe/BeCIN2HqrlJkObUXQMKhy7i8ejuGOn+bDqDBw==} - engines: {node: '>=18'} - '@tanstack/devtools-client@0.0.4': resolution: {integrity: sha512-LefnH9KE9uRDEWifc3QDcooskA8ikfs41bybDTgpYQpyTUspZnaEdUdya9Hry0KYxZ8nos0S3nNbsP79KHqr6Q==} engines: {node: '>=18'} + '@tanstack/devtools-event-bus@0.3.2': + resolution: {integrity: sha512-yJT2As/drc+Epu0nsqCsJaKaLcaNGufiNxSlp/+/oeTD0jsBxF9/PJBfh66XVpYXkKr97b8689mSu7QMef0Rrw==} + engines: {node: '>=18'} + '@tanstack/devtools-event-bus@0.3.3': resolution: {integrity: sha512-lWl88uLAz7ZhwNdLH6A3tBOSEuBCrvnY9Fzr5JPdzJRFdM5ZFdyNWz1Bf5l/F3GU57VodrN0KCFi9OA26H5Kpg==} engines: {node: '>=18'} @@ -2686,8 +3879,8 @@ packages: resolution: {integrity: sha512-RL1f5ZlfZMpghrCIdzl6mLOFLTuhqmPNblZgBaeKfdtk5rfbjykurv+VfYydOFXj0vxVIoA2d/zT7xfD7Ph8fw==} engines: {node: '>=18'} - '@tanstack/devtools-ui@0.4.4': - resolution: {integrity: sha512-5xHXFyX3nom0UaNfiOM92o6ziaHjGo3mcSGe2HD5Xs8dWRZNpdZ0Smd0B9ddEhy0oB+gXyMzZgUJb9DmrZV0Mg==} + '@tanstack/devtools-ui@0.3.5': + resolution: {integrity: sha512-DU8OfLntngnph+Tb7ivQvh4F4w+rDu6r01fXlhjq/Nmgdr0gtsOox4kdmyq5rCs+C6aPgP3M7+BE+fv4dN+VvA==} engines: {node: '>=18'} peerDependencies: solid-js: '>=1.9.7' @@ -2698,18 +3891,22 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 - '@tanstack/devtools@0.7.0': - resolution: {integrity: sha512-AlAoCqJhWLg9GBEaoV1g/j+X/WA1aJSWOsekxeuZpYeS2hdVuKAjj04KQLUMJhtLfNl2s2E+TCj7ZRtWyY3U4w==} + '@tanstack/devtools@0.6.14': + resolution: {integrity: sha512-dOtHoeLjjcHeNscu+ZEf89EFboQsy0ggb6pf8Sha59qBUeQbjUsaAvwP8Ogwg89oJxFQbTP7DKYNBNw5CxlNEA==} engines: {node: '>=18'} peerDependencies: solid-js: '>=1.9.7' - '@tanstack/history@1.139.0': - resolution: {integrity: sha512-l6wcxwDBeh/7Dhles23U1O8lp9kNJmAb2yNjekR6olZwCRNAVA8TCXlVCrueELyFlYZqvQkh0ofxnzG62A1Kkg==} + '@tanstack/history@1.132.0': + resolution: {integrity: sha512-GG2R9I6QSlbNR9fEuX2sQCigY6K28w51h2634TWmkaHXlzQw+rWuIWr4nAGM9doA+kWRi1LFSFMvAiG3cOqjXQ==} + engines: {node: '>=12'} + + '@tanstack/history@1.141.0': + resolution: {integrity: sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ==} engines: {node: '>=12'} - '@tanstack/react-devtools@0.7.11': - resolution: {integrity: sha512-a2Lmz8x+JoDrsU6f7uKRcyyY+k8mA/n5mb9h7XJ3Fz/y3+sPV9t7vAW1s5lyNkQyyDt6V1Oim99faLthoJSxMw==} + '@tanstack/react-devtools@0.7.0': + resolution: {integrity: sha512-HJH+oNBYQotgsKWAQqvkY8KnfQbbZptHbrkPGVaIwj393vVFGT1BuXMYy+rlmOYxczyerb90ltRFgsQyUtJTuw==} engines: {node: '>=18'} peerDependencies: '@types/react': '>=16.8' @@ -2717,58 +3914,59 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-router-devtools@1.139.14': - resolution: {integrity: sha512-ibMv0qHNvjY1IfiZEYnsc9a8zORk+m1z3/xyGRmJ9pzTjlgGbWBfPIArtbMZjOn+c0Qy6/ti9X/ZIHXyqGYHog==} + '@tanstack/react-router-devtools@1.132.0': + resolution: {integrity: sha512-MxejQ9aPW2BcMEg3jKT3RhFLcdkdL7uLBA+o8NUXH7WEiP8UiAyOSLVuK+EF6e1ITFb+3IlgjSX8U5V4IqxyeA==} engines: {node: '>=12'} peerDependencies: - '@tanstack/react-router': ^1.139.14 - '@tanstack/router-core': ^1.139.14 + '@tanstack/react-router': ^1.132.0 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - peerDependenciesMeta: - '@tanstack/router-core': - optional: true - '@tanstack/react-router@1.139.14': - resolution: {integrity: sha512-eNQvFu2F+7tjCRLUiXWCHZv5OhNjn/0LP6k7o5IiOg5+JR1TOu2ztxhk1EqZfBHrebuenTFQHyFXfXVDi+3wkA==} + '@tanstack/react-router@1.132.0': + resolution: {integrity: sha512-tGNmQrFc4zWQZvjqYnC8ib84H/9QokRl73hr0P2XlxCY2KAgPTk2QjdzW03LqXgQZRXg7++vKznJt4LS9/M3iA==} engines: {node: '>=12'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-store@0.8.0': - resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} + '@tanstack/react-store@0.7.7': + resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.139.14': - resolution: {integrity: sha512-OjNeTlAti75G+8djiAaQsfio4mpnn9HBFfION15nzIgmv+VX6wOS/OyOYKkaKf+QSecXcjajyV3HHc8YornH/A==} + '@tanstack/router-core@1.132.0': + resolution: {integrity: sha512-Iy33xln3oICuifgs4ltukWQLkd2F8ysUxalBFYZ8rs7N8EUfL2xIKnv4kEyffGDMwAxnNzSq/jS7XKy00KmC8Q==} engines: {node: '>=12'} - '@tanstack/router-devtools-core@1.139.14': - resolution: {integrity: sha512-MJUn96EQFEPjMjDIbs5Ot3SfoV5ggcgAJgZTbczZOvD8FhdbClE3v7sqsiK8O0Eu5gZUx1xQ3ou0fBpC4qatzA==} + '@tanstack/router-core@1.143.3': + resolution: {integrity: sha512-X0OiWyoGkgZoVtmKkkS8r32mMwde4aJdKmkzfsdYvLMdI+F9sOgsam7Te2xu/gkZwRY5guUEduXoX5shRcAPIQ==} engines: {node: '>=12'} - peerDependencies: - '@tanstack/router-core': ^1.139.14 + + '@tanstack/router-devtools-core@1.132.0': + resolution: {integrity: sha512-49nNxFGqj/Pt4hg/3veTGeKflRi39oIoIa4EfosckkE9sRKbRV8oiS+oO3hHk+GyJtmYHzlSPZ7sSW0nEwpGhQ==} + engines: {node: '>=12'} + peerDependencies: + '@tanstack/router-core': ^1.132.0 csstype: ^3.0.10 solid-js: '>=1.9.5' + tiny-invariant: ^1.3.3 peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.139.14': - resolution: {integrity: sha512-qRFOVyKph4I3j3c91W6jQLe3vuD4xHSUwZ9wWuIm+uk1NAOfwi2UBGhbzjLGSnRMtOVCHVLqD60sxaRvyZe7zQ==} + '@tanstack/router-generator@1.132.0': + resolution: {integrity: sha512-3N1rHNzxLzvFXO9iLguz/J4GTr1I6uCPkr5R13kP15jGCZ/AQIDeZjT8jQPZA9gAUAl9+yOyUIRXeL09a6RVVg==} engines: {node: '>=12'} - '@tanstack/router-plugin@1.139.14': - resolution: {integrity: sha512-jmDY5aF7ivjKKdBF8+VNSKmMpX9yJU5SiqoKbSvTJ715XVwxqKVKhsW2oaT67q0NhesSUsJ7LciJdt3tZb+8zQ==} + '@tanstack/router-plugin@1.132.0': + resolution: {integrity: sha512-ycAhaxf871tmAfmDOxzXOMu9yXM/4PVDOi2/IrcHdqZx3Lgh0l3tBVVdz5P9LY+jxy2spZW1E8GSKbMCNcGdGA==} engines: {node: '>=12'} peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.139.14 + '@tanstack/react-router': ^1.132.0 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' - vite-plugin-solid: ^2.11.10 + vite-plugin-solid: ^2.11.8 webpack: '>=5.92.0' peerDependenciesMeta: '@rsbuild/core': @@ -2782,21 +3980,43 @@ packages: webpack: optional: true - '@tanstack/router-utils@1.139.0': - resolution: {integrity: sha512-jT7D6NimWqoFSkid4vCno8gvTyfL1+NHpgm3es0B2UNhKKRV3LngOGilm1m6v8Qvk/gy6Fh/tvB+s+hBl6GhOg==} + '@tanstack/router-utils@1.132.0': + resolution: {integrity: sha512-WDnvAi9kO20joLDzlsTvfgXNv+FgQ4G98xAD8r4jKWoTdTTG05DU2sRYimtbdq4Q7E3uVdvyvPdhRy45wan7bw==} engines: {node: '>=12'} + '@tanstack/store@0.7.7': + resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} - '@tanstack/virtual-file-routes@1.139.0': - resolution: {integrity: sha512-9PImF1d1tovTUIpjFVa0W7Fwj/MHif7BaaczgJJfbv3sDt1Gh+oW9W9uCw9M3ndEJynnp5ZD/TTs0RGubH5ssg==} + '@tanstack/virtual-file-routes@1.132.0': + resolution: {integrity: sha512-d3do4ih9IdLPBVY4Gb8x7Ho7z0oFDLpxoao7uNVkfWtYU7nc3B+rnnVejXIgprmI5gt1hNzyNDJFr8G/W926GA==} engines: {node: '>=12'} + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} + '@testing-library/react@16.2.0': + resolution: {integrity: sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@testing-library/react@16.3.0': resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} engines: {node: '>=18'} @@ -2836,18 +4056,25 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/bonjour@3.5.13': - resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/connect-history-api-fallback@1.5.4': - resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==} + '@types/chokidar@2.1.7': + resolution: {integrity: sha512-A7/MFHf6KF7peCzjEC1BBTF8jpmZTokb3vr/A0NxRGfwRLK3Ws+Hq6ugVn6cJIMfM6wkCak/aplWrxbTcu8oig==} + deprecated: This is a stub types definition. chokidar provides its own type definitions, so you do not need this installed. + + '@types/cli-progress@3.11.6': + resolution: {integrity: sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==} '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/conventional-commits-parser@5.0.2': + resolution: {integrity: sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -2857,15 +4084,12 @@ packages: '@types/diff-match-patch@1.0.36': resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} + '@types/diff@7.0.0': + resolution: {integrity: sha512-sVpkpbnTJL9CYoDf4U+tHaQLe5HiTaHWY7m9FuYA7oMCHwC9ie0Vh9eIGapyzYrU3+pILlSY2fAc4elfw5m4dg==} + '@types/ejs@3.1.5': resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} - '@types/eslint-scope@3.7.7': - resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} - - '@types/eslint@9.6.1': - resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -2875,20 +4099,44 @@ packages: '@types/express-serve-static-core@4.19.7': resolution: {integrity: sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==} - '@types/express@4.17.25': - resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/express-serve-static-core@5.1.0': + resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==} + + '@types/express@4.17.21': + resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + + '@types/express@5.0.5': + resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==} + + '@types/figlet@1.7.0': + resolution: {integrity: sha512-KwrT7p/8Eo3Op/HBSIwGXOsTZKYiM9NpWRBJ5sVjWP/SmlS+oxxRvJht/FNAtliJvja44N3ul1yATgohnVBV0Q==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/gettext-parser@4.0.4': + resolution: {integrity: sha512-/r+YfxWZjPwt4HMAO3ay+2e3/IWJxBJxISqKFsWJW/87XllM+r5wHvioVrD45mduQ0UR7OnzMUbMe1PZfukswg==} + + '@types/glob@8.1.0': + resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==} '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/html-minifier-terser@6.1.0': - resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==} - '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - '@types/http-proxy@1.17.17': - resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==} + '@types/ini@4.1.1': + resolution: {integrity: sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==} + + '@types/is-url@1.2.32': + resolution: {integrity: sha512-46VLdbWI8Sc+hPexQ6NLNR2YpoDyDZIpASHkJQ2Yr+Kf9Giw6LdCTkwOdsnHKPQeh7xTjTmSnxbE8qpxYuCiHA==} + + '@types/js-cookie@3.0.6': + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2899,6 +4147,9 @@ packages: '@types/linkify-it@3.0.5': resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} + '@types/lodash@4.17.21': + resolution: {integrity: sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==} + '@types/markdown-it@12.2.3': resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} @@ -2911,20 +4162,41 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/minimatch@5.1.2': + resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node-forge@1.3.14': - resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} + '@types/node-gettext@3.0.6': + resolution: {integrity: sha512-A0W1IyyW3Ya+Wj6fDDWWwnXWNgrDNvKkq6xKj5Korc7YIo9023LP8qVTzgwQ5SUIANg2Pm2ggA7bfTcwoPPDUQ==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} '@types/node@20.19.25': resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} - '@types/node@22.19.1': - resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/node@22.10.2': + resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==} + + '@types/node@22.13.5': + resolution: {integrity: sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==} - '@types/prop-types@15.7.15': - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/node@24.0.10': + resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==} + + '@types/node@25.0.3': + resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + + '@types/nodemailer@7.0.3': + resolution: {integrity: sha512-fC8w49YQ868IuPWRXqPfLf+MuTRex5Z1qxMoG8rr70riqqbOp2F5xgOKE9fODEBPzpnvjkJXFgK6IL2xgMSTnA==} + + '@types/object-hash@3.0.6': + resolution: {integrity: sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==} + + '@types/plist@3.0.5': + resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} '@types/proper-lockfile@4.1.4': resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} @@ -2935,22 +4207,27 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.2.0': + resolution: {integrity: sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@18.3.27': - resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} - - '@types/react@19.2.6': - resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==} + '@types/react@19.2.0': + resolution: {integrity: sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==} '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} - '@types/retry@0.12.2': - resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} + '@types/readable-stream@4.0.23': + resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} '@types/retry@0.12.5': resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} @@ -2961,18 +4238,18 @@ packages: '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - '@types/serve-index@1.9.4': - resolution: {integrity: sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==} - '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} - '@types/sockjs@0.3.36': - resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==} + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} '@types/tinycolor2@1.4.6': resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2982,68 +4259,95 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.47.0': - resolution: {integrity: sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==} + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + + '@typescript-eslint/eslint-plugin@8.48.0': + resolution: {integrity: sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.47.0 + '@typescript-eslint/parser': ^8.48.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.47.0': - resolution: {integrity: sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==} + '@typescript-eslint/parser@8.48.0': + resolution: {integrity: sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.47.0': - resolution: {integrity: sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==} + '@typescript-eslint/project-service@8.48.0': + resolution: {integrity: sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.47.0': - resolution: {integrity: sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==} + '@typescript-eslint/scope-manager@8.48.0': + resolution: {integrity: sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.47.0': - resolution: {integrity: sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==} + '@typescript-eslint/tsconfig-utils@8.48.0': + resolution: {integrity: sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/tsconfig-utils@8.50.1': + resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.47.0': - resolution: {integrity: sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==} + '@typescript-eslint/type-utils@8.48.0': + resolution: {integrity: sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.47.0': - resolution: {integrity: sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==} + '@typescript-eslint/types@8.48.0': + resolution: {integrity: sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.47.0': - resolution: {integrity: sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==} + '@typescript-eslint/types@8.50.1': + resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.48.0': + resolution: {integrity: sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.47.0': - resolution: {integrity: sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==} + '@typescript-eslint/utils@8.48.0': + resolution: {integrity: sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.47.0': - resolution: {integrity: sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==} + '@typescript-eslint/visitor-keys@8.48.0': + resolution: {integrity: sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@unhead/dom@1.11.20': + resolution: {integrity: sha512-jgfGYdOH+xHJF/j8gudjsYu3oIjFyXhCWcgKaw3vQnT616gSqyqnGQGOItL+BQtQZACKNISwIfx5PuOtztMKLA==} + + '@unhead/schema@1.11.20': + resolution: {integrity: sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA==} + + '@unhead/shared@1.11.20': + resolution: {integrity: sha512-1MOrBkGgkUXS+sOKz/DBh4U20DNoITlJwpmvSInxEUNhghSNb56S0RnaHRq0iHkhrO/cDgz2zvfdlRpoPLGI3w==} + + '@unhead/vue@1.11.20': + resolution: {integrity: sha512-sqQaLbwqY9TvLEGeq8Fd7+F2TIuV3nZ5ihVISHjWpAM3y7DwNWRU7NmT9+yYT+2/jw1Vjwdkv5/HvDnvCLrgmg==} + peerDependencies: + vue: '>=2.7 || >=3' + '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} cpu: [arm] @@ -3139,17 +4443,94 @@ packages: cpu: [x64] os: [win32] - '@vitejs/plugin-react@5.1.1': - resolution: {integrity: sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==} + '@vercel/oidc@3.0.5': + resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} + engines: {node: '>= 20'} + + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + + '@vitejs/plugin-react@4.4.1': + resolution: {integrity: sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + + '@vitejs/plugin-react@5.0.4': + resolution: {integrity: sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/expect@4.0.14': - resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} + '@vitejs/plugin-vue@6.0.1': + resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 + vue: ^3.2.25 + + '@vitest/expect@3.1.1': + resolution: {integrity: sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==} + + '@vitest/expect@3.1.2': + resolution: {integrity: sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/expect@4.0.13': + resolution: {integrity: sha512-zYtcnNIBm6yS7Gpr7nFTmq8ncowlMdOJkWLqYvhr/zweY6tFbDkDi8BPPOeHxEtK1rSI69H7Fd4+1sqvEGli6w==} + + '@vitest/expect@4.0.16': + resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + + '@vitest/mocker@3.1.1': + resolution: {integrity: sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/mocker@3.1.2': + resolution: {integrity: sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/mocker@4.0.13': + resolution: {integrity: sha512-eNCwzrI5djoauklwP1fuslHBjrbR8rqIVbvNlAnkq1OTa6XT+lX68mrtPirNM9TnR69XUPt4puBCx2Wexseylg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true - '@vitest/mocker@4.0.14': - resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} + '@vitest/mocker@4.0.16': + resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0-0 @@ -3159,119 +4540,139 @@ packages: vite: optional: true - '@vitest/pretty-format@4.0.14': - resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==} + '@vitest/pretty-format@3.1.1': + resolution: {integrity: sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==} - '@vitest/runner@4.0.14': - resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} + '@vitest/pretty-format@3.1.2': + resolution: {integrity: sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==} - '@vitest/snapshot@4.0.14': - resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/spy@4.0.14': - resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} + '@vitest/pretty-format@4.0.13': + resolution: {integrity: sha512-ooqfze8URWbI2ozOeLDMh8YZxWDpGXoeY3VOgcDnsUxN0jPyPWSUvjPQWqDGCBks+opWlN1E4oP1UYl3C/2EQA==} - '@vitest/utils@4.0.14': - resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==} + '@vitest/pretty-format@4.0.16': + resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} - '@webassemblyjs/ast@1.14.1': - resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + '@vitest/runner@3.1.1': + resolution: {integrity: sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==} - '@webassemblyjs/floating-point-hex-parser@1.13.2': - resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + '@vitest/runner@3.1.2': + resolution: {integrity: sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==} - '@webassemblyjs/helper-api-error@1.13.2': - resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@webassemblyjs/helper-buffer@1.14.1': - resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + '@vitest/runner@4.0.13': + resolution: {integrity: sha512-9IKlAru58wcVaWy7hz6qWPb2QzJTKt+IOVKjAx5vb5rzEFPTL6H4/R9BMvjZ2ppkxKgTrFONEJFtzvnyEpiT+A==} - '@webassemblyjs/helper-numbers@1.13.2': - resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + '@vitest/runner@4.0.16': + resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} - '@webassemblyjs/helper-wasm-bytecode@1.13.2': - resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + '@vitest/snapshot@3.1.1': + resolution: {integrity: sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==} - '@webassemblyjs/helper-wasm-section@1.14.1': - resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + '@vitest/snapshot@3.1.2': + resolution: {integrity: sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==} - '@webassemblyjs/ieee754@1.13.2': - resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} - '@webassemblyjs/leb128@1.13.2': - resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + '@vitest/snapshot@4.0.13': + resolution: {integrity: sha512-hb7Usvyika1huG6G6l191qu1urNPsq1iFc2hmdzQY3F5/rTgqQnwwplyf8zoYHkpt7H6rw5UfIw6i/3qf9oSxQ==} - '@webassemblyjs/utf8@1.13.2': - resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + '@vitest/snapshot@4.0.16': + resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} - '@webassemblyjs/wasm-edit@1.14.1': - resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + '@vitest/spy@3.1.1': + resolution: {integrity: sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==} - '@webassemblyjs/wasm-gen@1.14.1': - resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + '@vitest/spy@3.1.2': + resolution: {integrity: sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==} - '@webassemblyjs/wasm-opt@1.14.1': - resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@webassemblyjs/wasm-parser@1.14.1': - resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + '@vitest/spy@4.0.13': + resolution: {integrity: sha512-hSu+m4se0lDV5yVIcNWqjuncrmBgwaXa2utFLIrBkQCQkt+pSwyZTPFQAZiiF/63j8jYa8uAeUZ3RSfcdWaYWw==} - '@webassemblyjs/wast-printer@1.14.1': - resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@vitest/spy@4.0.16': + resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} - '@webpack-cli/configtest@3.0.1': - resolution: {integrity: sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==} - engines: {node: '>=18.12.0'} + '@vitest/ui@3.2.4': + resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} peerDependencies: - webpack: ^5.82.0 - webpack-cli: 6.x.x + vitest: 3.2.4 - '@webpack-cli/info@3.0.1': - resolution: {integrity: sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==} - engines: {node: '>=18.12.0'} + '@vitest/ui@4.0.16': + resolution: {integrity: sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==} peerDependencies: - webpack: ^5.82.0 - webpack-cli: 6.x.x + vitest: 4.0.16 - '@webpack-cli/serve@3.0.1': - resolution: {integrity: sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==} - engines: {node: '>=18.12.0'} + '@vitest/utils@3.1.1': + resolution: {integrity: sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==} + + '@vitest/utils@3.1.2': + resolution: {integrity: sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + '@vitest/utils@4.0.13': + resolution: {integrity: sha512-ydozWyQ4LZuu8rLp47xFUWis5VOKMdHjXCWhs1LuJsTNKww+pTHQNK4e0assIB9K80TxFyskENL6vCu3j34EYA==} + + '@vitest/utils@4.0.16': + resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + + '@vue/compiler-core@3.5.24': + resolution: {integrity: sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==} + + '@vue/compiler-dom@3.5.24': + resolution: {integrity: sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==} + + '@vue/compiler-sfc@3.5.24': + resolution: {integrity: sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==} + + '@vue/compiler-ssr@3.5.24': + resolution: {integrity: sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/reactivity@3.5.24': + resolution: {integrity: sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==} + + '@vue/runtime-core@3.5.24': + resolution: {integrity: sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==} + + '@vue/runtime-dom@3.5.24': + resolution: {integrity: sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==} + + '@vue/server-renderer@3.5.24': + resolution: {integrity: sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==} peerDependencies: - webpack: ^5.82.0 - webpack-cli: 6.x.x - webpack-dev-server: '*' - peerDependenciesMeta: - webpack-dev-server: - optional: true + vue: 3.5.24 + + '@vue/shared@3.5.24': + resolution: {integrity: sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==} '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} - '@xtuc/ieee754@1.2.0': - resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} - - '@xtuc/long@4.2.2': - resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + JSONStream@1.3.5: + resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} + hasBin: true abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - acorn-import-phases@1.0.4: - resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} - engines: {node: '>=10.13.0'} - peerDependencies: - acorn: ^8.14.0 - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3286,6 +4687,12 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai-sdk-ollama@3.0.0: + resolution: {integrity: sha512-ik0BkfaB0jxR62s+kx64UTmZVVP/qsy/tCU2K5L341rF4L0f9ofE/lA34q0DIX4c293wvIMsQFRuuZy86CAwjQ==} + engines: {node: '>=22'} + peerDependencies: + ai: ^6.0.0 + ai@4.2.10: resolution: {integrity: sha512-rOfKbNRWlzwxbFll6W9oAdnC0R5VVbAJoof+p92CatHzA3reqQZmYn33IBnj+CgqeXYUsH9KX9Wnj7g2wCHc9Q==} engines: {node: '>=18'} @@ -3306,23 +4713,17 @@ packages: react: optional: true - ai@4.3.19: - resolution: {integrity: sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q==} + ai@6.0.25: + resolution: {integrity: sha512-KErk9JWkRaN4j9Xzxuo+twa0TxcYKdYbrRV8iGktduvUeGb0Yd5seWe3yOfuLGERbDBiKI1ajQz28O2FG3WO5A==} engines: {node: '>=18'} peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.23.8 - peerDependenciesMeta: - react: - optional: true + zod: ^3.25.76 || ^4.1.8 - ajv-formats@2.1.1: - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + ai@6.0.3: + resolution: {integrity: sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==} + engines: {node: '>=18'} peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true + zod: ^3.25.76 || ^4.1.8 ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} @@ -3332,17 +4733,16 @@ packages: ajv: optional: true - ajv-keywords@5.1.0: - resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} - peerDependencies: - ajv: ^8.8.2 - ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -3355,10 +4755,9 @@ packages: resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} engines: {node: '>=18'} - ansi-html-community@0.0.8: - resolution: {integrity: sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==} - engines: {'0': node >= 0.8.0} - hasBin: true + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3368,6 +4767,10 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -3384,13 +4787,13 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -3408,13 +4811,17 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} array-includes@3.1.9: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -3467,12 +4874,16 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + auto-bind@5.0.1: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - autoprefixer@10.4.22: - resolution: {integrity: sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==} + autoprefixer@10.4.23: + resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -3486,34 +4897,15 @@ packages: resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} engines: {node: '>=4'} + axios@1.12.2: + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - babel-dead-code-elimination@1.0.10: - resolution: {integrity: sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==} - - babel-loader@10.0.0: - resolution: {integrity: sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==} - engines: {node: ^18.20.0 || ^20.10.0 || >=22.0.0} - peerDependencies: - '@babel/core': ^7.12.0 - webpack: '>=5.61.0' - - babel-plugin-polyfill-corejs2@0.4.14: - resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - babel-plugin-polyfill-corejs3@0.13.0: - resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - babel-plugin-polyfill-regenerator@0.6.5: - resolution: {integrity: sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + babel-dead-code-elimination@1.0.11: + resolution: {integrity: sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ==} bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -3524,23 +4916,20 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.9.8: - resolution: {integrity: sha512-Y1fOuNDowLfgKOypdc9SPABfoWXuZHBOyCS4cD52IeZBhr4Md6CLLs6atcxVrzRmQ06E7hSlm5bHHApPKR/byA==} + baseline-browser-mapping@2.9.11: + resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true - basic-auth@2.0.1: - resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} - engines: {node: '>= 0.8'} - - batch@0.6.1: - resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} - before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} before-after-hook@3.0.2: resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -3548,8 +4937,8 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - birpc@2.8.0: - resolution: {integrity: sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==} + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} bitbucket@2.12.0: resolution: {integrity: sha512-YqaiTPEmn5mkwdU2gGcJZcQ6B8/DhCHhc3SSYqSpnef6nSTTSa/2GSBoLEgPLqAuqrQITGKq8MgYkfDMtnJPuw==} @@ -3557,23 +4946,19 @@ packages: blacklist@1.1.4: resolution: {integrity: sha512-DWdfwimA1WQxVC69Vs1Fy525NbYwisMSCdYQmW9zyzOByz9OB/tQwrKZ3T3pbTkuFjnkJFlJuyiDjPiXL5kzew==} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - body-parser@2.2.0: - resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + body-parser@2.2.1: + resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==} engines: {node: '>=18'} - bonjour-service@1.3.0: - resolution: {integrity: sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==} - boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + bowser@2.13.1: + resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -3599,6 +4984,16 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -3623,26 +5018,35 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - camel-case@4.1.2: - resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} camelcase@8.0.0: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} - caniuse-lite@1.0.30001756: - resolution: {integrity: sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==} + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001760: - resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + caniuse-lite@1.0.30001761: + resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@6.2.1: - resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk-template@1.1.2: + resolution: {integrity: sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==} + engines: {node: '>=14.16'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3669,6 +5073,15 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + chokidar-cli@3.0.0: + resolution: {integrity: sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==} + engines: {node: '>= 8.10.0'} + hasBin: true + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -3677,17 +5090,12 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chrome-trace-event@1.0.4: - resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} - engines: {node: '>=6.0'} - ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} - clean-css@5.3.3: - resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} - engines: {node: '>= 10.0'} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} @@ -3732,9 +5140,12 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - clone-deep@4.0.1: - resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} - engines: {node: '>=6'} + cliui@5.0.0: + resolution: {integrity: sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} clone@2.1.2: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} @@ -3748,13 +5159,25 @@ packages: resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.19: + resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -3765,10 +5188,26 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@12.0.0: + resolution: {integrity: sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==} + engines: {node: '>=18'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@14.0.2: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} @@ -3776,32 +5215,37 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} - commander@8.3.0: - resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} - engines: {node: '>= 12'} + commitlint@19.8.1: + resolution: {integrity: sha512-j7jojdmHrVOZ16gnjK2nbQuzdwA9TpxS9iNb9Q9QS3ytgt3JZVIGmsNbCuhmnsJWGspotlQ34yH8n1HvIKImiQ==} + engines: {node: '>=v18'} + hasBin: true - compressible@2.0.18: - resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} - engines: {node: '>= 0.6'} + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} - compression@1.8.1: - resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} - engines: {node: '>= 0.8.0'} + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - connect-history-api-fallback@2.0.0: - resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==} - engines: {node: '>=0.8'} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} @@ -3811,6 +5255,19 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + conventional-changelog-angular@7.0.0: + resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} + engines: {node: '>=16'} + + conventional-changelog-conventionalcommits@7.0.2: + resolution: {integrity: sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==} + engines: {node: '>=16'} + + conventional-commits-parser@5.0.0: + resolution: {integrity: sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==} + engines: {node: '>=16'} + hasBin: true + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -3818,56 +5275,66 @@ packages: resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-es@1.2.2: + resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} - cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} - cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} - cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - cookie@1.0.2: - resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} - engines: {node: '>=18'} - - core-js-compat@3.47.0: - resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} + cosmiconfig-typescript-loader@6.2.0: + resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' - css-loader@7.1.2: - resolution: {integrity: sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==} - engines: {node: '>= 18.12.0'} + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} peerDependencies: - '@rspack/core': 0.x || 1.x - webpack: ^5.27.0 + typescript: '>=4.9.5' peerDependenciesMeta: - '@rspack/core': + typescript: optional: true - webpack: + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: optional: true - css-select@4.3.0: - resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-declaration-sorter@7.3.0: + resolution: {integrity: sha512-LQF6N/3vkAMYF4xoHLJfG718HRJh34Z8BnNhd6bosOMIVjMlhuZK5++oZa3uYAgrI5+7x2o27gUqTR2U/KjUOQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.0.9 + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} css-tree@3.1.0: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} @@ -3882,12 +5349,34 @@ packages: engines: {node: '>=4'} hasBin: true - cssstyle@4.6.0: - resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} - engines: {node: '>=18'} + cssnano-preset-default@7.0.10: + resolution: {integrity: sha512-6ZBjW0Lf1K1Z+0OKUAUpEN62tSXmYChXWi2NAA0afxEVsj9a+MbcB1l5qel6BHJHmULai2fCGRthCeKSFbScpA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 - cssstyle@5.3.3: - resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==} + cssnano-utils@5.0.1: + resolution: {integrity: sha512-ZIP71eQgG9JwjVZsTPSqhc6GHgEr53uJ7tK5///VfyWj6Xp2DBmixWHqJgPno+PqATzn48pL42ww9x5SSGmhZg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + cssnano@7.1.2: + resolution: {integrity: sha512-HYOPBsNvoiFeR1eghKD5C3ASm64v9YVyJB4Ivnl2gqKoQYvjjN/G0rztvKQq8OxocUtC6sjqY8jwYngIB4AByA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + cssstyle@5.3.5: + resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==} engines: {node: '>=20'} csstype@3.2.3: @@ -3902,6 +5391,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + dargs@8.1.0: + resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} + engines: {node: '>=12'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -3922,19 +5415,23 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dataloader@1.4.0: + resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: supports-color: '*' peerDependenciesMeta: supports-color: optional: true - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -3950,12 +5447,24 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + decamelize@6.0.1: + resolution: {integrity: sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + decode-uri-component@0.4.1: + resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} + engines: {node: '>=14.16'} + dedent@1.7.0: resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} peerDependencies: @@ -3964,6 +5473,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -3991,33 +5504,32 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - depd@1.1.2: - resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} - engines: {node: '>= 0.6'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + deprecation@2.3.1: + resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - detect-node@2.1.0: - resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} - devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -4032,9 +5544,9 @@ packages: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} - dns-packet@5.6.1: - resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} - engines: {node: '>=6'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} @@ -4043,24 +5555,22 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - dom-converter@0.2.0: - resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} - - dom-serializer@1.4.1: - resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - domhandler@4.3.1: - resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - domutils@2.8.0: - resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - dot-case@3.0.4: - resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} dotenv@16.4.5: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} @@ -4070,10 +5580,14 @@ packages: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} + dotenv@8.6.0: + resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} + engines: {node: '>=10'} + dts-resolver@2.1.3: resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} engines: {node: '>=20.19.0'} @@ -4093,6 +5607,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.19.13: + resolution: {integrity: sha512-8MZ783YuHRwHZX2Mmm+bpGxq+7XPd88sWwYAz2Ysry80sEKpftDZXs2Hg9ZyjESi1IBTNHF0oDKe0zJRkUlyew==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -4104,6 +5621,9 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@7.0.3: + resolution: {integrity: sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -4114,10 +5634,6 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} - encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -4125,31 +5641,39 @@ packages: encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} - enhanced-resolve@5.18.3: - resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + enhanced-resolve@5.18.4: + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} - entities@2.2.0: - resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - envinfo@7.21.0: - resolution: {integrity: sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==} - engines: {node: '>=4'} - hasBin: true + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} - err-code@2.0.3: - resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - es-abstract@1.24.0: - resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} es-define-property@1.0.1: @@ -4160,8 +5684,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.2.1: - resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} + es-iterator-helpers@1.2.2: + resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} es-module-lexer@1.7.0: @@ -4286,10 +5810,6 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4312,6 +5832,10 @@ packages: jiti: optional: true + esm@3.2.25: + resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} + engines: {node: '>=6'} + espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4329,10 +5853,6 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -4349,6 +5869,9 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -4364,9 +5887,6 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -4382,12 +5902,12 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - exit-hook@2.2.1: - resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} - engines: {node: '>=6'} + execa@9.6.0: + resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==} + engines: {node: ^18.19.0 || >=20.5.0} - expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} express-rate-limit@7.5.1: @@ -4396,14 +5916,13 @@ packages: peerDependencies: express: '>= 4.11' - express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} - engines: {node: '>= 0.10.0'} - express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -4411,10 +5930,17 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + external-editor@3.1.0: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-content-type-parse@2.0.1: resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} @@ -4435,27 +5961,31 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@5.2.5: + resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} + hasBin: true + fast-xml-parser@5.3.2: resolution: {integrity: sha512-n8v8b6p4Z1sMgqRmqLJm3awW4NX7NkaKPfb3uJIBTSH7Pdvufi3PQ3/lJLQrvxcMYl7JI2jnDO90siPEpD8JBA==} hasBin: true - fastest-levenshtein@1.0.16: - resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} - engines: {node: '>= 4.9.1'} + fast-xml-parser@5.3.3: + resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==} + hasBin: true - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} - faye-websocket@0.11.4: - resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} - engines: {node: '>=0.8.0'} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4465,6 +5995,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figlet@1.9.4: resolution: {integrity: sha512-uN6QE+TrzTAHC1IWTyrc4FfGo2KH/82J8Jl1tyKB7+z5DBit/m3D++Iu5lg91qJMnQQ3vpJrj5gxcK/pk4R9tQ==} engines: {node: '>= 17.0.0'} @@ -4485,13 +6018,17 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} - engines: {node: '>= 0.8'} + filter-obj@5.1.0: + resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} + engines: {node: '>=14.16'} - finalhandler@2.1.0: - resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} - engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} @@ -4501,14 +6038,17 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flat@5.0.2: - resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} - hasBin: true - flat@6.0.1: resolution: {integrity: sha512-/3FfIa8mbrg3xE7+wAhWeV+bd7L2Mof+xtZb5dRDKZ+wDvYJK4WDYeIOuOhre5Yv5aQObZrlbRmk3RTSiuQBtw==} engines: {node: '>=18'} @@ -4549,14 +6089,22 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4585,6 +6133,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.4.0: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} @@ -4593,14 +6145,18 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} - get-port@5.1.1: - resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} - engines: {node: '>=8'} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -4608,10 +6164,18 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + getopts@2.3.0: + resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==} + gettext-parser@8.0.0: resolution: {integrity: sha512-eFmhDi2xQ+2reMRY2AbJ2oa10uFOl1oyGbAKdCZiNOk94NJHi7aN0OBELSC9v35ZAPQdr+uRBi93/Gu4SlBdrA==} engines: {node: '>=18'} + git-raw-commits@4.0.0: + resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} + engines: {node: '>=16'} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -4620,27 +6184,14 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob-to-regex.js@1.2.0: - resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - hasBin: true - glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} hasBin: true - glob@13.0.0: - resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} - engines: {node: 20 || >=22} + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -4654,6 +6205,14 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globby@14.1.0: + resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} + engines: {node: '>=18'} + goober@2.1.18: resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==} peerDependencies: @@ -4677,9 +6236,6 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - handle-thing@2.0.1: - resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} - has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -4713,10 +6269,6 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - he@1.2.0: - resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} - hasBin: true - hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -4726,84 +6278,48 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} - hosted-git-info@6.1.3: - resolution: {integrity: sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hookable@6.0.1: + resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} - hpack.js@2.1.6: - resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} + hosted-git-info@8.1.0: + resolution: {integrity: sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==} + engines: {node: ^18.17.0 || >=20.5.0} html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} - html-minifier-terser@6.1.0: - resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==} - engines: {node: '>=12'} - hasBin: true - html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - html-webpack-plugin@5.6.5: - resolution: {integrity: sha512-4xynFbKNNk+WlzXeQQ+6YYsH2g7mpfPszQZUi3ovKlj+pDmngQ7vRXjrrmGROabmKwyQkcgcX5hqfOwHbFmK5g==} - engines: {node: '>=10.13.0'} - peerDependencies: - '@rspack/core': 0.x || 1.x - webpack: ^5.20.0 - peerDependenciesMeta: - '@rspack/core': - optional: true - webpack: - optional: true - - htmlparser2@6.1.0: - resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} - - http-deceiver@1.2.7: - resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} - - http-errors@1.6.3: - resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==} - engines: {node: '>= 0.6'} + htmlparser2@10.0.0: + resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} - http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - http-parser-js@0.5.10: - resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} - http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} - http-proxy-middleware@2.0.9: - resolution: {integrity: sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==} - engines: {node: '>=12.0.0'} - peerDependencies: - '@types/express': ^4.17.13 - peerDependenciesMeta: - '@types/express': - optional: true - - http-proxy@1.18.1: - resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} - engines: {node: '>=8.0.0'} - https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-id@4.1.3: + resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} + hasBin: true + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} hasBin: true - hyperdyperid@1.2.0: - resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} - engines: {node: '>=10.18'} - iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -4812,8 +6328,8 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - iconv-lite@0.7.0: - resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + iconv-lite@0.7.1: + resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} engines: {node: '>=0.10.0'} icss-utils@5.1.0: @@ -4837,10 +6353,12 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + import-without-cache@0.2.5: + resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} + engines: {node: '>=20.19.0'} imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} @@ -4850,12 +6368,13 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} - inherits@2.0.3: - resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ini@5.0.0: resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==} engines: {node: ^18.17.0 || >=20.5.0} @@ -4883,6 +6402,15 @@ packages: react-devtools-core: optional: true + inquirer@12.11.0: + resolution: {integrity: sha512-E5oT7r+NxIxTuZsl/2Hg76kdT57DGc5mn5pCEz0LqZjR8hN7prgMXhUZ6A7rj/qL3X4P5lToIWNkO10uZJSzdA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + inquirer@12.6.0: resolution: {integrity: sha512-3zmmccQd/8o65nPOZJZ+2wqt76Ghw3+LaMrmc6JE/IzcvQhJ1st+QLCOo/iLS85/tILU0myG31a2TAZX0ysAvg==} engines: {node: '>=18'} @@ -4900,21 +6428,17 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - interpret@3.1.1: - resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} - engines: {node: '>=10.13.0'} + interpret@2.2.0: + resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} + engines: {node: '>= 0.10'} - intl-messageformat@10.7.18: - resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} + intl-messageformat@11.0.6: + resolution: {integrity: sha512-zymRESVTAbsuhXPe1HuvMNgw1jixIDB+tSQ95AoihjB9w9LmuGoH42siLo7lqB13FSPcLweRUY7Mk4foR+ZBnA==} ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - ipaddr.js@2.3.0: - resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} - engines: {node: '>= 10'} - is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -4925,6 +6449,9 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -4984,6 +6511,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@2.0.0: + resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} + engines: {node: '>=4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -5023,14 +6554,13 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} - is-network-error@1.3.0: - resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==} - engines: {node: '>=16'} - is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -5039,18 +6569,14 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-plain-obj@3.0.0: - resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} - engines: {node: '>=10'} + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-plain-object@2.0.4: - resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} - engines: {node: '>=0.10.0'} - is-plain-object@3.0.1: resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==} engines: {node: '>=0.10.0'} @@ -5061,6 +6587,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -5073,14 +6602,26 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + is-symbol@1.1.1: resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} engines: {node: '>= 0.4'} + is-text-path@2.0.0: + resolution: {integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==} + engines: {node: '>=8'} + is-typed-array@1.1.15: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} @@ -5111,13 +6652,14 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + is-wsl@3.1.0: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -5131,17 +6673,10 @@ packages: iso-639-3@3.0.1: resolution: {integrity: sha512-SdljCYXOexv/JmbQ0tvigHN43yECoscVpe2y2hlEqy/CStXQlroPhZLj7zKLRiGqLJfw8k7B973UAMDoQczVgQ==} - isobject@3.0.1: - resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} - engines: {node: '>=0.10.0'} - iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jackspeak@4.1.1: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} @@ -5151,14 +6686,22 @@ packages: engines: {node: '>=10'} hasBin: true - jest-worker@27.5.1: - resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} - engines: {node: '>= 10.13.0'} + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + joi@18.0.1: + resolution: {integrity: sha512-IiQpRyypSnLisQf3PwuN2eIHAsAIGZIrLZkd4zdvIar2bDyhM91ubRjy8a3eYablXsh9BeI/c7dmPYHca5qtoA==} + engines: {node: '>= 20'} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-cookie@3.0.5: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} @@ -5166,6 +6709,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -5183,19 +6729,23 @@ packages: canvas: optional: true - jsdom@27.2.0: - resolution: {integrity: sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + jsdom@27.0.0: + resolution: {integrity: sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==} + engines: {node: '>=20'} peerDependencies: canvas: ^3.0.0 peerDependenciesMeta: canvas: optional: true - jsesc@3.0.2: - resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} - engines: {node: '>=6'} - hasBin: true + jsdom@27.3.0: + resolution: {integrity: sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} @@ -5208,10 +6758,6 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-parse-even-better-errors@3.0.2: - resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -5241,6 +6787,16 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + jsonrepair@3.13.1: resolution: {integrity: sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==} hasBin: true @@ -5256,6 +6812,41 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + knex@3.1.0: + resolution: {integrity: sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + better-sqlite3: '*' + mysql: '*' + mysql2: '*' + pg: '*' + pg-native: '*' + sqlite3: '*' + tedious: '*' + peerDependenciesMeta: + better-sqlite3: + optional: true + mysql: + optional: true + mysql2: + optional: true + pg: + optional: true + pg-native: + optional: true + sqlite3: + optional: true + tedious: + optional: true + + knitwork@1.3.0: + resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -5340,8 +6931,15 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} - lingo.dev@0.117.0: - resolution: {integrity: sha512-N979No6J00VnElVZ9DQe7v4P3u54wCIiMouFK4G1Fln3FR2S/Z4cTRajuIB4C3I1HM6y+lbYI8+zACbzuextMg==} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lingo.dev@0.117.21: + resolution: {integrity: sha512-vN60EkTlQArtN6keqdxJkjtAR2V1Ce+fnP+hBQcFBM6AgY49d+KBf1cJP2q8phHS8Cxo9jH20HTj6kUjIr9HFw==} engines: {node: '>=18'} hasBin: true @@ -5349,9 +6947,13 @@ packages: resolution: {integrity: sha512-vsBzcU4oE+v0lj4FhVLzr9dBTv4/fHIa57l+GCwovP8MoFNZJTOhGU8PXd4v2VJCbECAaijBiHntiekFMLvo0g==} engines: {node: '>=18.0.0'} - loader-runner@4.3.1: - resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} - engines: {node: '>=6.11.5'} + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} @@ -5361,12 +6963,49 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -5385,23 +7024,19 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lower-case@2.0.2: - resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.2: - resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - lucide-react@0.545.0: resolution: {integrity: sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==} peerDependencies: @@ -5468,8 +7103,8 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - mdast-util-to-hast@13.2.0: - resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -5477,38 +7112,28 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - memfs@4.51.1: - resolution: {integrity: sha512-Eyt3XrufitN2ZL9c/uIRMyDwXanLI88h/L3MoWqNY747ha3dMR9dWqp8cRT5ntjZ0U1TNuq4U91ZXK0sMBjYOQ==} - - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + meow@12.1.1: + resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} + engines: {node: '>=16.10'} merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -5621,6 +7246,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + micromustache@8.0.3: + resolution: {integrity: sha512-SXjrEPuYNtWq0reR9LR2nHdzdQx/3re9HPcDGjm00L7hi2RsH5KMRBhYEBvPdyQC51RW/2TznjwX/sQLPPyHNw==} + engines: {node: '>=8'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -5637,11 +7266,6 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -5650,9 +7274,6 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimalistic-assert@1.0.1: - resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -5675,34 +7296,65 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - morgan@1.10.1: - resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} - engines: {node: '>= 0.8.0'} - - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - multicast-dns@7.2.5: - resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} + mkdist@2.4.1: + resolution: {integrity: sha512-Ezk0gi04GJBkqMfsksICU5Rjoemc4biIekwgrONWVPor2EO/N9nBgN6MZXAf7Yw4mDDhrNyKbdETaHNevfumKg==} hasBin: true - + peerDependencies: + sass: ^1.92.1 + typescript: '>=5.9.2' + vue: ^3.5.21 + vue-sfc-transformer: ^0.1.1 + vue-tsc: ^1.8.27 || ^2.0.21 || ^3.0.0 + peerDependenciesMeta: + sass: + optional: true + typescript: + optional: true + vue: + optional: true + vue-sfc-transformer: + optional: true + vue-tsc: + optional: true + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} - nano-staged@0.9.0: - resolution: {integrity: sha512-0JfyX4i0Vp5HhC9RDtJ1kp7psz8CFuS3Gya3Z6WZv//QCwA9dPzi1S803VdR0c0P6R7sSvweZ5mSJmYQ/N+loQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -5711,25 +7363,35 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - - negotiator@0.6.4: - resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} - engines: {node: '>= 0.6'} - negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + next@15.3.8: + resolution: {integrity: sha512-L+4c5Hlr84fuaNADZbB9+ceRX9/CzwxJ+obXIGHupboB/Q1OLbSUapFs4bO8hnS/E6zV/JDX7sG1QpKVR2bguA==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true - next@16.0.7: - resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==} + next@16.0.4: + resolution: {integrity: sha512-vICcxKusY8qW7QFOzTvnRL1ejz2ClTqDKtm1AcUjm2mPv/lVAdgpGNsftsPRIDJOXOjRQO68i1dM8Lp8GZnqoA==} engines: {node: '>=20.9.0'} - deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -5769,8 +7431,26 @@ packages: sass: optional: true - no-case@3.0.4: - resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + next@16.1.1: + resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} @@ -5781,10 +7461,6 @@ packages: encoding: optional: true - node-forge@1.3.3: - resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} - engines: {node: '>= 6.13.0'} - node-machine-id@1.1.12: resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==} @@ -5796,39 +7472,23 @@ packages: engines: {node: '>= 8.16.0'} hasBin: true - normalize-package-data@5.0.0: - resolution: {integrity: sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} - - npm-install-checks@6.3.0: - resolution: {integrity: sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - npm-normalize-package-bin@3.0.1: - resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - npm-package-arg@10.1.0: - resolution: {integrity: sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + npm-package-arg@12.0.2: + resolution: {integrity: sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==} + engines: {node: ^18.17.0 || >=20.5.0} - npm-pick-manifest@8.0.2: - resolution: {integrity: sha512-1dKY+86/AIiq1tkKVD3l0WI+Gd3vkknVGAggsFeBkTvbhMQ1OND/LKkYv4JtXPKUJ8bOTCyLiqEg2P6QNdK+Gg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - nwsapi@2.2.22: - resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -5866,12 +7526,6 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - obuf@1.1.2: - resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} - - obug@2.1.0: - resolution: {integrity: sha512-uu/tgLPoa75CFA7UDkmqspKbefvZh1WMPwkU3bNr0PY746a/+xwXVgbw5co5C3GvJj3h5u8g/pbxXzI0gd1QFg==} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -5879,6 +7533,13 @@ packages: resolution: {integrity: sha512-wbqF4uc1YbcldtiBFfkSnquHtECEIpYD78YUXI6ri1Im5OO2NLo6ZVpRdbJpdnpZ05zMrVPssNiEo6JQtea+Qg==} engines: {node: '>= 18'} + ollama-ai-provider-v2@2.0.0: + resolution: {integrity: sha512-vbLG/xsW0kgGlInrTIa3W8niy294dw7n8nkLFVwjPHlX9EjtBH4bNPgHQEbmQUy60LjV3NF3Y1HtWPxORSb3bQ==} + engines: {node: '>=18'} + peerDependencies: + ai: ^5.0.0 || ^6.0.0 + zod: ^4.0.16 + ollama-ai-provider@1.2.0: resolution: {integrity: sha512-jTNFruwe3O/ruJeppI/quoOUxG7NA6blG3ZyQj3lei4+NnJo7bi3eIRWqlVpRlu/mbzbFXeJSBuYQWF6pzGKww==} engines: {node: '>=18'} @@ -5888,18 +7549,17 @@ packages: zod: optional: true - on-finished@2.3.0: - resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} - engines: {node: '>= 0.8'} + ollama@0.6.3: + resolution: {integrity: sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.1.0: - resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} - engines: {node: '>= 0.8'} - once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -5923,27 +7583,24 @@ packages: resolution: {integrity: sha512-YWielGi1XzG1UTvOaCFaNgEnuhZVMSHYkW/FQ7UX8O26PtlpdM84c0f7wLPlkvx2RfiQmnzd61d/MGxmpQeJPw==} engines: {node: '>=18'} + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + os-tmpdir@1.0.2: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxlint-tsgolint@0.8.0: - resolution: {integrity: sha512-uKMeYxrKOyFUOYGSZ7tf7DZ/+RT/BEiuIT9saQHJ3bTPPSutoaxZfHPEyl09IZrGq5PVVMHupKVuzwTiz2KsgA==} - hasBin: true - - oxlint@1.29.0: - resolution: {integrity: sha512-YqUVUhTYDqazV2qu3QSQn/H4Z1OP+fTnedgZWDk1/lDZxGfR0b1MqRVaEm3rRjBMLHP0zXlriIWUx+DD6UMaPA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - oxlint-tsgolint: '>=0.7.1' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} @@ -5953,10 +7610,18 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-limit@6.2.0: resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} engines: {node: '>=18'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -5965,13 +7630,21 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-queue@8.1.1: + resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} engines: {node: '>=18'} - p-retry@6.2.1: - resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==} - engines: {node: '>=16.17'} + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} @@ -5980,8 +7653,11 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - param-case@3.0.4: - resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + packrup@0.1.2: + resolution: {integrity: sha512-ZcKU7zrr5GlonoS9cxxrb5HVswGnyj6jQvwFBa6p5VFw7G71VAHcUKL5wyZSU/ECtPM/9gacWxy2KFQKt1gMNA==} parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} @@ -5990,6 +7666,14 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse-my-command@0.3.31: resolution: {integrity: sha512-B8voraH5aWFpEveAFoLGH055sLtoSs8/kJ03Zi8zvDxYHGsAJHvz7QEqtiMNbCN7fvC8i9erGfoquDzWP9Dvng==} engines: {node: '>=18'} @@ -6007,44 +7691,58 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} - pascal-case@3.1.2: - resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} - patch-console@2.0.0: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.1: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} - path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + path-type@6.0.0: + resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} + engines: {node: '>=18'} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pg-connection-string@2.6.2: + resolution: {integrity: sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==} + php-array-reader@2.1.2: resolution: {integrity: sha512-bev2MQhNmxlGiAQwi41z7J242jAEopCs5bT515Er/d4uiFl1z09SWQcr1VnycRJ0EA9ARlm8zDBvM40/y+l9/Q==} @@ -6066,24 +7764,63 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pkce-challenge@5.0.0: - resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@9.6.0: + resolution: {integrity: sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==} + hasBin: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} - pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} playwright-core@1.56.1: resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} engines: {node: '>=18'} hasBin: true + playwright-core@1.57.0: + resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + engines: {node: '>=18'} + hasBin: true + playwright@1.56.1: resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} engines: {node: '>=18'} hasBin: true + playwright@1.57.0: + resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -6092,6 +7829,102 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-calc@10.1.1: + resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==} + engines: {node: ^18.12 || ^20.9 || >=22.0} + peerDependencies: + postcss: ^8.4.38 + + postcss-colormin@7.0.5: + resolution: {integrity: sha512-ekIBP/nwzRWhEMmIxHHbXHcMdzd1HIUzBECaj5KEdLz9DVP2HzT065sEhvOx1dkLjYW7jyD0CngThx6bpFi2fA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-convert-values@7.0.8: + resolution: {integrity: sha512-+XNKuPfkHTCEo499VzLMYn94TiL3r9YqRE3Ty+jP7UX4qjewUONey1t7CG21lrlTLN07GtGM8MqFVp86D4uKJg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-comments@7.0.5: + resolution: {integrity: sha512-IR2Eja8WfYgN5n32vEGSctVQ1+JARfu4UH8M7bgGh1bC+xI/obsPJXaBpQF7MAByvgwZinhpHpdrmXtvVVlKcQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-duplicates@7.0.2: + resolution: {integrity: sha512-eTonaQvPZ/3i1ASDHOKkYwAybiM45zFIc7KXils4mQmHLqIswXD9XNOKEVxtTFnsmwYzF66u4LMgSr0abDlh5w==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-empty@7.0.1: + resolution: {integrity: sha512-cFrJKZvcg/uxB6Ijr4l6qmn3pXQBna9zyrPC+sK0zjbkDUZew+6xDltSF7OeB7rAtzaaMVYSdbod+sZOCWnMOg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-overridden@7.0.1: + resolution: {integrity: sha512-7c3MMjjSZ/qYrx3uc1940GSOzN1Iqjtlqe8uoSg+qdVPYyRb0TILSqqmtlSFuE4mTDECwsm397Ya7iXGzfF7lg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-merge-longhand@7.0.5: + resolution: {integrity: sha512-Kpu5v4Ys6QI59FxmxtNB/iHUVDn9Y9sYw66D6+SZoIk4QTz1prC4aYkhIESu+ieG1iylod1f8MILMs1Em3mmIw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-merge-rules@7.0.7: + resolution: {integrity: sha512-njWJrd/Ms6XViwowaaCc+/vqhPG3SmXn725AGrnl+BgTuRPEacjiLEaGq16J6XirMJbtKkTwnt67SS+e2WGoew==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-font-values@7.0.1: + resolution: {integrity: sha512-2m1uiuJeTplll+tq4ENOQSzB8LRnSUChBv7oSyFLsJRtUgAAJGP6LLz0/8lkinTgxrmJSPOEhgY1bMXOQ4ZXhQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-gradients@7.0.1: + resolution: {integrity: sha512-X9JjaysZJwlqNkJbUDgOclyG3jZEpAMOfof6PUZjPnPrePnPG62pS17CjdM32uT1Uq1jFvNSff9l7kNbmMSL2A==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-params@7.0.5: + resolution: {integrity: sha512-FGK9ky02h6Ighn3UihsyeAH5XmLEE2MSGH5Tc4tXMFtEDx7B+zTG6hD/+/cT+fbF7PbYojsmmWjyTwFwW1JKQQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-selectors@7.0.5: + resolution: {integrity: sha512-x2/IvofHcdIrAm9Q+p06ZD1h6FPcQ32WtCRVodJLDR+WMn8EVHI1kvLxZuGKz/9EY5nAmI6lIQIrpo4tBy5+ug==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + postcss-modules-extract-imports@3.1.0: resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} engines: {node: ^10 || ^12 || >= 14} @@ -6116,10 +7949,100 @@ packages: peerDependencies: postcss: ^8.1.0 + postcss-nested@7.0.2: + resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-normalize-charset@7.0.1: + resolution: {integrity: sha512-sn413ofhSQHlZFae//m9FTOfkmiZ+YQXsbosqOWRiVQncU2BA3daX3n0VF3cG6rGLSFVc5Di/yns0dFfh8NFgQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-display-values@7.0.1: + resolution: {integrity: sha512-E5nnB26XjSYz/mGITm6JgiDpAbVuAkzXwLzRZtts19jHDUBFxZ0BkXAehy0uimrOjYJbocby4FVswA/5noOxrQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-positions@7.0.1: + resolution: {integrity: sha512-pB/SzrIP2l50ZIYu+yQZyMNmnAcwyYb9R1fVWPRxm4zcUFCY2ign7rcntGFuMXDdd9L2pPNUgoODDk91PzRZuQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-repeat-style@7.0.1: + resolution: {integrity: sha512-NsSQJ8zj8TIDiF0ig44Byo3Jk9e4gNt9x2VIlJudnQQ5DhWAHJPF4Tr1ITwyHio2BUi/I6Iv0HRO7beHYOloYQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-string@7.0.1: + resolution: {integrity: sha512-QByrI7hAhsoze992kpbMlJSbZ8FuCEc1OT9EFbZ6HldXNpsdpZr+YXC5di3UEv0+jeZlHbZcoCADgb7a+lPmmQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-timing-functions@7.0.1: + resolution: {integrity: sha512-bHifyuuSNdKKsnNJ0s8fmfLMlvsQwYVxIoUBnowIVl2ZAdrkYQNGVB4RxjfpvkMjipqvbz0u7feBZybkl/6NJg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-unicode@7.0.5: + resolution: {integrity: sha512-X6BBwiRxVaFHrb2WyBMddIeB5HBjJcAaUHyhLrM2FsxSq5TFqcHSsK7Zu1otag+o0ZphQGJewGH1tAyrD0zX1Q==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-url@7.0.1: + resolution: {integrity: sha512-sUcD2cWtyK1AOL/82Fwy1aIVm/wwj5SdZkgZ3QiUzSzQQofrbq15jWJ3BA7Z+yVRwamCjJgZJN0I9IS7c6tgeQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-whitespace@7.0.1: + resolution: {integrity: sha512-vsbgFHMFQrJBJKrUFJNZ2pgBeBkC2IvvoHjz1to0/0Xk7sII24T0qFOiJzG6Fu3zJoq/0yI4rKWi7WhApW+EFA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-ordered-values@7.0.2: + resolution: {integrity: sha512-AMJjt1ECBffF7CEON/Y0rekRLS6KsePU6PRP08UqYW4UGFRnTXNrByUzYK1h8AC7UWTZdQ9O3Oq9kFIhm0SFEw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-reduce-initial@7.0.5: + resolution: {integrity: sha512-RHagHLidG8hTZcnr4FpyMB2jtgd/OcyAazjMhoy5qmWJOx1uxKh4ntk0Pb46ajKM0rkf32lRH4C8c9qQiPR6IA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-reduce-transforms@7.0.1: + resolution: {integrity: sha512-MhyEbfrm+Mlp/36hvZ9mT9DaO7dbncU0CvWI8V93LRkY6IYlu38OPg3FObnuKTUxJ4qA8HpurdQOo5CyqqO76g==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} + postcss-svgo@7.1.0: + resolution: {integrity: sha512-KnAlfmhtoLz6IuU3Sij2ycusNs4jPW+QoFE5kuuUOK8awR6tMxZQrs5Ey3BUz7nFCzT3eqyFgqkyrHiaU2xx3w==} + engines: {node: ^18.12.0 || ^20.9.0 || >= 18} + peerDependencies: + postcss: ^8.4.32 + + postcss-unique-selectors@7.0.4: + resolution: {integrity: sha512-pmlZjsmEAG7cHd7uK3ZiNSW6otSZ13RHuZ/4cDN/bVglS5EpF2r2oxY99SuOHa8m7AWoBCelTS3JPpzsIs8skQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -6139,40 +8062,42 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + prettier@3.6.2: resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} hasBin: true - pretty-error@4.0.0: - resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} + pretty-bytes@7.1.0: + resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} + engines: {node: '>=20'} pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - proc-log@3.0.0: - resolution: {integrity: sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + proc-log@5.0.0: + resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} + engines: {node: ^18.17.0 || >=20.5.0} - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} - promise-inflight@1.0.1: - resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} - peerDependencies: - bluebird: '*' - peerDependenciesMeta: - bluebird: - optional: true - - promise-retry@2.0.1: - resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} - engines: {node: '>=10'} + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -6187,13 +8112,15 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} @@ -6202,9 +8129,19 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + query-string@9.3.1: + resolution: {integrity: sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==} + engines: {node: '>=18'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -6215,12 +8152,8 @@ packages: rate-limiter-flexible@4.0.1: resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} - engines: {node: '>= 0.8'} - - raw-body@3.0.1: - resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} react-dom@19.2.0: @@ -6228,10 +8161,10 @@ packages: peerDependencies: react: ^19.2.0 - react-dom@19.2.1: - resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: - react: ^19.2.1 + react: ^19.2.3 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6245,55 +8178,25 @@ packages: peerDependencies: react: ^18.3.1 - react-refresh@0.14.2: - resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} - engines: {node: '>=0.10.0'} - - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react-router-dom@7.10.1: - resolution: {integrity: sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - - react-router@7.10.1: - resolution: {integrity: sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - peerDependenciesMeta: - react-dom: - optional: true - - react-router@7.9.6: - resolution: {integrity: sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - peerDependenciesMeta: - react-dom: - optional: true - react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} - react@19.2.1: - resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} + read-yaml-file@2.1.0: + resolution: {integrity: sha512-UkRNRIwnhG+y7hpqnycCL/xbTk7+ia9VuVTC0S+zVbwd65DI9eUpRMfsWIGrCWxTU/mi+JW8cHQCrv+zfCbEPQ==} + engines: {node: '>=10.13'} readable-stream@4.7.0: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} @@ -6307,6 +8210,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -6319,35 +8226,13 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} - regenerate-unicode-properties@10.2.2: - resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} - engines: {node: '>=4'} - - regenerate@1.4.2: - resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} - regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} - regexpu-core@6.4.0: - resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} - engines: {node: '>=4'} - - regjsgen@0.8.0: - resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} - - regjsparser@0.13.0: - resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} - hasBin: true - rehype-stringify@10.0.1: resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} - relateurl@0.2.7: - resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} - engines: {node: '>= 0.10'} - remark-disable-tokenizers@1.1.1: resolution: {integrity: sha512-KhxfswvMKNTicVaprWc21i8zbBLIf6wwCbn3cvnCP1400Sgd2eCSm4maKUkj3uNkVyCKp3u5BNRaXPxJ9gM99A==} @@ -6372,19 +8257,16 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - renderkid@3.0.0: - resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - - resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} @@ -6397,6 +8279,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -6418,10 +8304,6 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} - retry@0.13.1: - resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} - engines: {node: '>= 4'} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -6429,20 +8311,15 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rimraf@6.1.2: - resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==} - engines: {node: 20 || >=22} - hasBin: true - - rolldown-plugin-dts@0.18.0: - resolution: {integrity: sha512-2CJtKYa9WPClZxkJeCt4bGUegQvQKQ1VJp9jFJzG0h8I/80XI6qDgoWfVJUOEhT2swbsRQh/42N1RIWvbXT4rA==} + rolldown-plugin-dts@0.19.2: + resolution: {integrity: sha512-KbP0cnnjD1ubnyklqy6GCahvUsOrPFH4i+RTX6bNpyvh+jUsaxY01e9mLOU2NsGzQkJS/q4hbCbdcQoAmSWIYg==} engines: {node: '>=20.19.0'} peerDependencies: '@ts-macro/tsc': ^0.3.6 '@typescript/native-preview': '>=7.0.0-dev.20250601.1' - rolldown: ^1.0.0-beta.51 + rolldown: ^1.0.0-beta.55 typescript: ^5.0.0 - vue-tsc: ~3.1.0 + vue-tsc: ~3.2.0 peerDependenciesMeta: '@ts-macro/tsc': optional: true @@ -6453,16 +8330,45 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-beta.51: - resolution: {integrity: sha512-ZRLgPlS91l4JztLYEZnmMcd3Umcla1hkXJgiEiR4HloRJBBoeaX8qogTu5Jfu36rRMVLndzqYv0h+M5gJAkUfg==} + rolldown@1.0.0-beta.55: + resolution: {integrity: sha512-r8Ws43aYCnfO07ao0SvQRz4TBAtZJjGWNvScRBOHuiNHvjfECOJBIqJv0nUkL1GYcltjvvHswRilDF1ocsC0+g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.53.3: - resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} + rollup-plugin-dts@6.3.0: + resolution: {integrity: sha512-d0UrqxYd8KyZ6i3M2Nx7WOMy708qsV/7fTHMHxCMCBOAe3V/U7OMPu5GkX8hC+cmkHhzGnfeYongl1IgiooddA==} + engines: {node: '>=16'} + peerDependencies: + rollup: ^3.29.4 || ^4 + typescript: ^4.5 || ^5.0 + + rollup-plugin-esbuild@6.2.1: + resolution: {integrity: sha512-jTNOMGoMRhs0JuueJrJqbW8tOwxumaWYq+V5i+PD+8ecSCVkuX27tGW7BXqDgoULQ55rO7IdNxPcnsWtshz3AA==} + engines: {node: '>=14.18.0'} + peerDependencies: + esbuild: '>=0.18.0' + rollup: ^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 + + rollup-plugin-styler@2.0.0: + resolution: {integrity: sha512-u96KK3hfA5RDeZFuE1kW0mu7FKS6sDu0RlGx9vijqQbzlmrzkhkBtge5gXZ6wF0CnTgcn7CfkwKOwIcWVZU/VQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + rollup: ^2.63.0 || ^3.0.0 || ^4.0.0 + + rollup@4.52.5: + resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rollup@4.54.0: + resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rotating-file-stream@3.2.7: + resolution: {integrity: sha512-SVquhBEVvRFY+nWLUc791Y0MIlyZrEClRZwZFLLRgJKldHyV1z4e2e/dp9LPqCS3AM//uq/c3PnOFgjqnm5P+A==} + engines: {node: '>=14.0'} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -6481,6 +8387,10 @@ packages: resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} engines: {node: '>=0.12.0'} + run-async@4.0.6: + resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} + engines: {node: '>=0.12.0'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -6491,9 +8401,6 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -6505,6 +8412,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -6521,9 +8432,8 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - schema-utils@4.3.3: - resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} - engines: {node: '>= 10.13.0'} + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} @@ -6532,13 +8442,6 @@ packages: secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} - select-hose@2.0.0: - resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} - - selfsigned@2.4.1: - resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} - engines: {node: '>=10'} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -6548,12 +8451,8 @@ packages: engines: {node: '>=10'} hasBin: true - send@0.19.0: - resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} - engines: {node: '>= 0.8.0'} - - send@1.2.0: - resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} serialize-javascript@6.0.2: @@ -6575,24 +8474,16 @@ packages: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} - seroval@1.4.0: - resolution: {integrity: sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==} + seroval@1.4.1: + resolution: {integrity: sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg==} engines: {node: '>=10'} - serve-index@1.9.1: - resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==} - engines: {node: '>= 0.8.0'} - - serve-static@1.16.2: - resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} - engines: {node: '>= 0.8.0'} - - serve-static@2.2.0: - resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -6606,16 +8497,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - setprototypeof@1.1.0: - resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} - setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shallow-clone@3.0.1: - resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} - engines: {node: '>=8'} - sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -6658,6 +8542,21 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + slice-ansi@5.0.0: resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} engines: {node: '>=12'} @@ -6670,12 +8569,15 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} - sockjs@0.3.24: - resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} + smob@1.5.0: + resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} solid-js@1.9.10: resolution: {integrity: sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==} + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -6694,24 +8596,16 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - spdx-correct@3.2.0: - resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} - - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} - spdx-license-ids@3.0.22: - resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} - - spdy-transport@3.0.0: - resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==} + split-on-first@3.0.0: + resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} + engines: {node: '>=12'} - spdy@4.0.2: - resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==} - engines: {node: '>=6.0.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -6730,14 +8624,6 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - statuses@1.5.0: - resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} - engines: {node: '>= 0.6'} - - statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} - engines: {node: '>= 0.8'} - statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -6753,6 +8639,14 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-width@3.1.0: + resolution: {integrity: sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==} + engines: {node: '>=6'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -6788,15 +8682,16 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -6813,18 +8708,23 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strnum@2.1.1: - resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - style-loader@4.0.0: - resolution: {integrity: sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==} - engines: {node: '>= 18.12.0'} - peerDependencies: - webpack: ^5.27.0 + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} @@ -6839,66 +8739,98 @@ packages: babel-plugin-macros: optional: true + stylehacks@7.0.7: + resolution: {integrity: sha512-bJkD0JkEtbRrMFtwgpJyBbFIwfDDONQ1Ov3sDLZQP8HuJ73kBOyx66H4bOcAbVWmnfLdvQ0AJwXxOMkpujcO6g==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - swr@2.3.6: - resolution: {integrity: sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==} + svgo@4.0.0: + resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==} + engines: {node: '>=16'} + hasBin: true + + swr@2.3.8: + resolution: {integrity: sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==} peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.11: + resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} + engines: {node: ^14.18.0 || >=16.0.0} + + syncpack@13.0.4: + resolution: {integrity: sha512-kJ9VlRxNCsBD5pJAE29oXeBYbPLhEySQmK4HdpsLv81I6fcDDW17xeJqMwiU3H7/woAVsbgq25DJNS8BeiN5+w==} + engines: {node: '>=18.18.0'} + hasBin: true + tailwindcss@4.1.17: resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==} + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - terser-webpack-plugin@5.3.15: - resolution: {integrity: sha512-PGkOdpRFK+rb1TzVz+msVhw4YMRT9txLF4kRqvJhGhCM324xuR3REBSHALN+l+sAhKUmz0aotnjp5D+P83mLhQ==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@swc/core': '*' - esbuild: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@swc/core': - optional: true - esbuild: - optional: true - uglify-js: - optional: true + tarn@3.0.2: + resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} + engines: {node: '>=8.0.0'} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} terser@5.44.1: resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} engines: {node: '>=10'} hasBin: true - thingies@2.5.0: - resolution: {integrity: sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==} - engines: {node: '>=10.18'} - peerDependencies: - tslib: ^2 + text-extensions@2.4.0: + resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} + engines: {node: '>=8'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} throttleit@2.1.0: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} - thunky@1.1.0: - resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tightrope@0.2.0: + resolution: {integrity: sha512-Kw36UHxJEELq2VUqdaSGR2/8cAsPgMtvX8uGVU6Jk26O66PhXec0A5ZnRYs47btbtwPDpXXF66+Fo3vimCM9aQ==} + engines: {node: '>=16'} + + tildify@2.0.0: + resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==} + engines: {node: '>=8'} tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -6926,10 +8858,26 @@ packages: tinygradient@1.1.5: resolution: {integrity: sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} @@ -6970,6 +8918,10 @@ packages: toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -6989,12 +8941,6 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} - tree-dump@1.1.0: - resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -7011,16 +8957,22 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + ts-toolbelt@9.6.0: + resolution: {integrity: sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==} + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - tsdown@0.16.6: - resolution: {integrity: sha512-g3xHEnGdfwJTlXhEkqww3Q/KlCfyNFw4rnzuQ9Gqw8T2xjDYrw94qmSw5wYYTAW5zV1sEfWDlfgxZo5mmtu0NQ==} + tsdown@0.18.2: + resolution: {integrity: sha512-2o6p/9WjcQrgKnz5/VppOstsqXdTER6G6gPe5yhuP57AueIr2y/NQFKdFPHuqMqZpxRLVjm7MP/dXWG7EJpehg==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: '@arethetypeswrong/core': ^0.18.1 - '@vitejs/devtools': ^0.0.0-alpha.17 + '@vitejs/devtools': '*' publint: ^0.3.0 typescript: ^5.0.0 unplugin-lightningcss: ^0.4.0 @@ -7042,11 +8994,35 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + tsx@4.20.6: resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} engines: {node: '>=18.0.0'} hasBin: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + turbo-darwin-64@2.6.1: resolution: {integrity: sha512-Dm0HwhyZF4J0uLqkhUyCVJvKM9Rw7M03v3J9A7drHDQW0qAbIGBrUijQ8g4Q9Cciw/BXRRd8Uzkc3oue+qn+ZQ==} cpu: [x64] @@ -7093,10 +9069,6 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -7117,43 +9089,64 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.47.0: - resolution: {integrity: sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==} + typescript-eslint@8.48.0: + resolution: {integrity: sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + typescript@5.7.2: + resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - unconfig-core@7.4.1: - resolution: {integrity: sha512-Bp/bPZjV2Vl/fofoA2OYLSnw1Z0MOhCX7zHnVCYrazpfZvseBbGhwcNQMxsg185Mqh7VZQqK3C8hFG/Dyng+yA==} + unbuild@3.6.1: + resolution: {integrity: sha512-+U5CdtrdjfWkZhuO4N9l5UhyiccoeMEXIc2Lbs30Haxb+tRwB3VwB8AoZRxlAzORXunenSo+j6lh45jx+xkKgg==} + hasBin: true + peerDependencies: + typescript: ^5.9.2 + peerDependenciesMeta: + typescript: + optional: true + + unconfig-core@7.4.2: + resolution: {integrity: sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg==} + + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - unicode-canonical-property-names-ecmascript@2.0.1: - resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} - engines: {node: '>=4'} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - unicode-match-property-ecmascript@2.0.0: - resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} - engines: {node: '>=4'} + undici-types@7.8.0: + resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} - unicode-match-property-value-ecmascript@2.2.1: - resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} - engines: {node: '>=4'} + unhead@1.11.20: + resolution: {integrity: sha512-3AsNQC0pjwlLqEYHLjtichGWankK8yqmocReITecmpB1H0aOabeESueyy+8X1gyJx4ftZVwo9hqQ4O3fPWffCA==} - unicode-property-aliases-ecmascript@2.2.0: - resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} - engines: {node: '>=4'} + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -7182,13 +9175,28 @@ packages: universal-github-app-jwt@2.2.2: resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==} + universal-user-agent@6.0.1: + resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin-utils@0.2.5: + resolution: {integrity: sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==} + engines: {node: '>=18.12.0'} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -7196,8 +9204,8 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - unrun@0.2.11: - resolution: {integrity: sha512-HjUuNLRGfRxMvxkwOuO/CpkSzdizTPPApbarLplsTzUm8Kex+nS9eomKU1qgVus6WGWkDYhtf/mgNxGEpyTR6A==} + unrun@0.2.20: + resolution: {integrity: sha512-YhobStTk93HYRN/4iBs3q3/sd7knvju1XrzwwrVVfRujyTG1K88hGONIxCoJN0PWBuO+BX7fFiHH0sVDfE3MWw==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: @@ -7206,6 +9214,10 @@ packages: synckit: optional: true + untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} + hasBin: true + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -7226,35 +9238,13 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utila@0.4.0: - resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==} - - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - - uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - hasBin: true - uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true - valibot@1.2.0: - resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} - peerDependencies: - typescript: '>=5' - peerDependenciesMeta: - typescript: - optional: true - - validate-npm-package-license@3.0.4: - resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - - validate-npm-package-name@5.0.1: - resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + validate-npm-package-name@6.0.2: + resolution: {integrity: sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==} + engines: {node: ^18.17.0 || >=20.5.0} vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} @@ -7266,13 +9256,63 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.1.1: + resolution: {integrity: sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite-node@3.1.2: + resolution: {integrity: sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@7.2.4: - resolution: {integrity: sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==} + vite@6.3.5: + resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@7.1.12: + resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -7351,32 +9391,26 @@ packages: yaml: optional: true - vitest@4.0.14: - resolution: {integrity: sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + vitest@3.1.1: + resolution: {integrity: sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.14 - '@vitest/browser-preview': 4.0.14 - '@vitest/browser-webdriverio': 4.0.14 - '@vitest/ui': 4.0.14 + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.1.1 + '@vitest/ui': 3.1.1 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@opentelemetry/api': + '@types/debug': optional: true '@types/node': optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': + '@vitest/browser': optional: true '@vitest/ui': optional: true @@ -7385,100 +9419,180 @@ packages: jsdom: optional: true - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - watchpack@2.4.4: - resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} - engines: {node: '>=10.13.0'} - - wbuf@1.7.3: - resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==} - - web-vitals@5.1.0: - resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} - - webidl-conversions@8.0.0: - resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} - engines: {node: '>=20'} - - webpack-cli@6.0.1: - resolution: {integrity: sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==} - engines: {node: '>=18.12.0'} + vitest@3.1.2: + resolution: {integrity: sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - webpack: ^5.82.0 - webpack-bundle-analyzer: '*' - webpack-dev-server: '*' - peerDependenciesMeta: - webpack-bundle-analyzer: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.1.2 + '@vitest/ui': 3.1.2 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': optional: true - webpack-dev-server: + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: optional: true - webpack-dev-middleware@7.4.5: - resolution: {integrity: sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==} - engines: {node: '>= 18.12.0'} + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true peerDependencies: - webpack: ^5.0.0 + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' peerDependenciesMeta: - webpack: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: optional: true - webpack-dev-server@5.2.2: - resolution: {integrity: sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==} - engines: {node: '>= 18.12.0'} + vitest@4.0.13: + resolution: {integrity: sha512-QSD4I0fN6uZQfftryIXuqvqgBxTvJ3ZNkF6RWECd82YGAYAfhcppBLFXzXJHQAAhVFyYEuFTrq6h0hQqjB7jIQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: - webpack: ^5.0.0 - webpack-cli: '*' + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/debug': ^4.1.12 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.13 + '@vitest/browser-preview': 4.0.13 + '@vitest/browser-webdriverio': 4.0.13 + '@vitest/ui': 4.0.13 + happy-dom: '*' + jsdom: '*' peerDependenciesMeta: - webpack: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': optional: true - webpack-cli: + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: optional: true - webpack-merge@6.0.1: - resolution: {integrity: sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==} - engines: {node: '>=18.0.0'} - - webpack-sources@3.3.3: - resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} - engines: {node: '>=10.13.0'} - - webpack-virtual-modules@0.6.2: - resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + vitest@4.0.16: + resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.16 + '@vitest/browser-preview': 4.0.16 + '@vitest/browser-webdriverio': 4.0.16 + '@vitest/ui': 4.0.16 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true - webpack@5.103.0: - resolution: {integrity: sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==} - engines: {node: '>=10.13.0'} + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} hasBin: true peerDependencies: - webpack-cli: '*' + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue@3.5.24: + resolution: {integrity: sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==} + peerDependencies: + typescript: '*' peerDependenciesMeta: - webpack-cli: + typescript: optional: true - websocket-driver@0.7.4: - resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} - engines: {node: '>=0.8.0'} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + web-vitals@5.1.0: + resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webidl-conversions@8.0.0: + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} + engines: {node: '>=20'} - websocket-extensions@0.1.4: - resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} - engines: {node: '>=0.8.0'} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -7506,6 +9620,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -7515,11 +9632,6 @@ packages: engines: {node: '>= 8'} hasBin: true - which@3.0.1: - resolution: {integrity: sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -7529,13 +9641,14 @@ packages: resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} engines: {node: '>=12'} - wildcard@2.0.1: - resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@5.1.0: + resolution: {integrity: sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==} + engines: {node: '>=6'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -7604,6 +9717,13 @@ packages: resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==} engines: {node: '>=0.6.0'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -7612,6 +9732,25 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@13.1.2: + resolution: {integrity: sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@13.3.2: + resolution: {integrity: sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -7624,9 +9763,16 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + yoga-wasm-web@0.3.3: resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} + zhead@2.2.4: + resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==} + zod-to-json-schema@3.25.0: resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} peerDependencies: @@ -7641,12 +9787,16 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@acemir/cssom@0.9.24': {} + '@acemir/cssom@0.9.30': + optional: true '@ai-sdk/anthropic@1.2.11(zod@3.25.76)': dependencies: @@ -7654,17 +9804,43 @@ snapshots: '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/anthropic@3.0.9(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.2 + '@ai-sdk/provider-utils': 4.0.4(zod@4.1.12) + zod: 4.1.12 + + '@ai-sdk/gateway@3.0.10(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.2 + '@ai-sdk/provider-utils': 4.0.4(zod@4.1.12) + '@vercel/oidc': 3.1.0 + zod: 4.1.12 + + '@ai-sdk/gateway@3.0.2(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.0 + '@ai-sdk/provider-utils': 4.0.1(zod@4.1.12) + '@vercel/oidc': 3.0.5 + zod: 4.1.12 + '@ai-sdk/google@1.2.19(zod@3.25.76)': dependencies: '@ai-sdk/provider': 1.1.3 '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/google@1.2.22(zod@3.25.76)': + '@ai-sdk/google@3.0.1(zod@4.1.12)': dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - zod: 3.25.76 + '@ai-sdk/provider': 3.0.0 + '@ai-sdk/provider-utils': 4.0.1(zod@4.1.12) + zod: 4.1.12 + + '@ai-sdk/google@3.0.6(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.2 + '@ai-sdk/provider-utils': 4.0.4(zod@4.1.12) + zod: 4.1.12 '@ai-sdk/groq@1.2.3(zod@3.25.76)': dependencies: @@ -7672,11 +9848,17 @@ snapshots: '@ai-sdk/provider-utils': 2.2.3(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/groq@1.2.9(zod@3.25.76)': + '@ai-sdk/groq@3.0.1(zod@4.1.12)': dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - zod: 3.25.76 + '@ai-sdk/provider': 3.0.0 + '@ai-sdk/provider-utils': 4.0.1(zod@4.1.12) + zod: 4.1.12 + + '@ai-sdk/groq@3.0.4(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.2 + '@ai-sdk/provider-utils': 4.0.4(zod@4.1.12) + zod: 4.1.12 '@ai-sdk/mistral@1.2.8(zod@3.25.76)': dependencies: @@ -7684,12 +9866,30 @@ snapshots: '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/mistral@3.0.1(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.0 + '@ai-sdk/provider-utils': 4.0.1(zod@4.1.12) + zod: 4.1.12 + + '@ai-sdk/mistral@3.0.5(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.2 + '@ai-sdk/provider-utils': 4.0.4(zod@4.1.12) + zod: 4.1.12 + '@ai-sdk/openai@1.3.22(zod@3.25.76)': dependencies: '@ai-sdk/provider': 1.1.3 '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/openai@3.0.7(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.2 + '@ai-sdk/provider-utils': 4.0.4(zod@4.1.12) + zod: 4.1.12 + '@ai-sdk/provider-utils@2.2.3(zod@3.25.76)': dependencies: '@ai-sdk/provider': 1.1.0 @@ -7704,6 +9904,20 @@ snapshots: secure-json-parse: 2.7.0 zod: 3.25.76 + '@ai-sdk/provider-utils@4.0.1(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.0 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.1.12 + + '@ai-sdk/provider-utils@4.0.4(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.2 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.1.12 + '@ai-sdk/provider@1.1.0': dependencies: json-schema: 0.4.0 @@ -7712,32 +9926,30 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.2.12(react@19.2.0)(zod@3.25.76)': + '@ai-sdk/provider@3.0.0': dependencies: - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) - react: 19.2.0 - swr: 2.3.6(react@19.2.0) - throttleit: 2.1.0 - optionalDependencies: - zod: 3.25.76 + json-schema: 0.4.0 + + '@ai-sdk/provider@3.0.2': + dependencies: + json-schema: 0.4.0 - '@ai-sdk/react@1.2.12(react@19.2.1)(zod@3.25.76)': + '@ai-sdk/react@1.2.12(react@19.2.3)(zod@3.25.76)': dependencies: '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) - react: 19.2.1 - swr: 2.3.6(react@19.2.1) + react: 19.2.3 + swr: 2.3.8(react@19.2.3) throttleit: 2.1.0 optionalDependencies: zod: 3.25.76 - '@ai-sdk/react@1.2.5(react@19.2.0)(zod@3.25.76)': + '@ai-sdk/react@1.2.5(react@19.2.3)(zod@3.25.76)': dependencies: '@ai-sdk/provider-utils': 2.2.3(zod@3.25.76) '@ai-sdk/ui-utils': 1.2.4(zod@3.25.76) - react: 19.2.0 - swr: 2.3.6(react@19.2.0) + react: 19.2.3 + swr: 2.3.8(react@19.2.3) throttleit: 2.1.0 optionalDependencies: zod: 3.25.76 @@ -7758,6 +9970,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -7766,100 +9983,513 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 - '@asamuzakjp/css-color@4.1.0': + '@asamuzakjp/css-color@4.1.1': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - lru-cache: 11.2.2 + lru-cache: 11.2.4 - '@asamuzakjp/dom-selector@6.7.5': + '@asamuzakjp/dom-selector@6.7.6': dependencies: '@asamuzakjp/nwsapi': 2.3.9 bidi-js: 1.0.3 css-tree: 3.1.0 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.2 + lru-cache: 11.2.4 '@asamuzakjp/nwsapi@2.3.9': {} - '@babel/code-frame@7.27.1': + '@aws-crypto/sha256-browser@5.2.0': dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.28.5': {} + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-locate-window': 3.957.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 - '@babel/core@7.28.5': + '@aws-crypto/sha256-js@5.2.0': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.957.0 + tslib: 2.8.1 - '@babel/generator@7.28.5': + '@aws-crypto/supports-web-crypto@5.2.0': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 + tslib: 2.8.1 - '@babel/helper-annotate-as-pure@7.27.3': + '@aws-crypto/util@5.2.0': dependencies: - '@babel/types': 7.28.5 + '@aws-sdk/types': 3.957.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 - '@babel/helper-compilation-targets@7.27.2': + '@aws-sdk/client-sesv2@3.958.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.957.0 + '@aws-sdk/credential-provider-node': 3.958.0 + '@aws-sdk/middleware-host-header': 3.957.0 + '@aws-sdk/middleware-logger': 3.957.0 + '@aws-sdk/middleware-recursion-detection': 3.957.0 + '@aws-sdk/middleware-user-agent': 3.957.0 + '@aws-sdk/region-config-resolver': 3.957.0 + '@aws-sdk/signature-v4-multi-region': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-endpoints': 3.957.0 + '@aws-sdk/util-user-agent-browser': 3.957.0 + '@aws-sdk/util-user-agent-node': 3.957.0 + '@smithy/config-resolver': 4.4.5 + '@smithy/core': 3.20.0 + '@smithy/fetch-http-handler': 5.3.8 + '@smithy/hash-node': 4.2.7 + '@smithy/invalid-dependency': 4.2.7 + '@smithy/middleware-content-length': 4.2.7 + '@smithy/middleware-endpoint': 4.4.1 + '@smithy/middleware-retry': 4.4.17 + '@smithy/middleware-serde': 4.2.8 + '@smithy/middleware-stack': 4.2.7 + '@smithy/node-config-provider': 4.3.7 + '@smithy/node-http-handler': 4.4.7 + '@smithy/protocol-http': 5.3.7 + '@smithy/smithy-client': 4.10.2 + '@smithy/types': 4.11.0 + '@smithy/url-parser': 4.2.7 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.16 + '@smithy/util-defaults-mode-node': 4.2.19 + '@smithy/util-endpoints': 3.2.7 + '@smithy/util-middleware': 4.2.7 + '@smithy/util-retry': 4.2.7 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.958.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.957.0 + '@aws-sdk/middleware-host-header': 3.957.0 + '@aws-sdk/middleware-logger': 3.957.0 + '@aws-sdk/middleware-recursion-detection': 3.957.0 + '@aws-sdk/middleware-user-agent': 3.957.0 + '@aws-sdk/region-config-resolver': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-endpoints': 3.957.0 + '@aws-sdk/util-user-agent-browser': 3.957.0 + '@aws-sdk/util-user-agent-node': 3.957.0 + '@smithy/config-resolver': 4.4.5 + '@smithy/core': 3.20.0 + '@smithy/fetch-http-handler': 5.3.8 + '@smithy/hash-node': 4.2.7 + '@smithy/invalid-dependency': 4.2.7 + '@smithy/middleware-content-length': 4.2.7 + '@smithy/middleware-endpoint': 4.4.1 + '@smithy/middleware-retry': 4.4.17 + '@smithy/middleware-serde': 4.2.8 + '@smithy/middleware-stack': 4.2.7 + '@smithy/node-config-provider': 4.3.7 + '@smithy/node-http-handler': 4.4.7 + '@smithy/protocol-http': 5.3.7 + '@smithy/smithy-client': 4.10.2 + '@smithy/types': 4.11.0 + '@smithy/url-parser': 4.2.7 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.16 + '@smithy/util-defaults-mode-node': 4.2.19 + '@smithy/util-endpoints': 3.2.7 + '@smithy/util-middleware': 4.2.7 + '@smithy/util-retry': 4.2.7 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.957.0': + dependencies: + '@aws-sdk/types': 3.957.0 + '@aws-sdk/xml-builder': 3.957.0 + '@smithy/core': 3.20.0 + '@smithy/node-config-provider': 4.3.7 + '@smithy/property-provider': 4.2.7 + '@smithy/protocol-http': 5.3.7 + '@smithy/signature-v4': 5.3.7 + '@smithy/smithy-client': 4.10.2 + '@smithy/types': 4.11.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.7 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.957.0': dependencies: - '@babel/compat-data': 7.28.5 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 + '@aws-sdk/core': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@smithy/property-provider': 4.2.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.28.5)': + '@aws-sdk/credential-provider-http@3.957.0': + dependencies: + '@aws-sdk/core': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@smithy/fetch-http-handler': 5.3.8 + '@smithy/node-http-handler': 4.4.7 + '@smithy/property-provider': 4.2.7 + '@smithy/protocol-http': 5.3.7 + '@smithy/smithy-client': 4.10.2 + '@smithy/types': 4.11.0 + '@smithy/util-stream': 4.5.8 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.958.0': + dependencies: + '@aws-sdk/core': 3.957.0 + '@aws-sdk/credential-provider-env': 3.957.0 + '@aws-sdk/credential-provider-http': 3.957.0 + '@aws-sdk/credential-provider-login': 3.958.0 + '@aws-sdk/credential-provider-process': 3.957.0 + '@aws-sdk/credential-provider-sso': 3.958.0 + '@aws-sdk/credential-provider-web-identity': 3.958.0 + '@aws-sdk/nested-clients': 3.958.0 + '@aws-sdk/types': 3.957.0 + '@smithy/credential-provider-imds': 4.2.7 + '@smithy/property-provider': 4.2.7 + '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.958.0': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.28.5 - semver: 6.3.1 + '@aws-sdk/core': 3.957.0 + '@aws-sdk/nested-clients': 3.958.0 + '@aws-sdk/types': 3.957.0 + '@smithy/property-provider': 4.2.7 + '@smithy/protocol-http': 5.3.7 + '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/types': 4.11.0 + tslib: 2.8.1 transitivePeerDependencies: - - supports-color + - aws-crt + + '@aws-sdk/credential-provider-node@3.958.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.957.0 + '@aws-sdk/credential-provider-http': 3.957.0 + '@aws-sdk/credential-provider-ini': 3.958.0 + '@aws-sdk/credential-provider-process': 3.957.0 + '@aws-sdk/credential-provider-sso': 3.958.0 + '@aws-sdk/credential-provider-web-identity': 3.958.0 + '@aws-sdk/types': 3.957.0 + '@smithy/credential-provider-imds': 4.2.7 + '@smithy/property-provider': 4.2.7 + '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt - '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.28.5)': + '@aws-sdk/credential-provider-process@3.957.0': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.27.3 - regexpu-core: 6.4.0 - semver: 6.3.1 + '@aws-sdk/core': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@smithy/property-provider': 4.2.7 + '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.28.5)': + '@aws-sdk/credential-provider-sso@3.958.0': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 + '@aws-sdk/client-sso': 3.958.0 + '@aws-sdk/core': 3.957.0 + '@aws-sdk/token-providers': 3.958.0 + '@aws-sdk/types': 3.957.0 + '@smithy/property-provider': 4.2.7 + '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.958.0': + dependencies: + '@aws-sdk/core': 3.957.0 + '@aws-sdk/nested-clients': 3.958.0 + '@aws-sdk/types': 3.957.0 + '@smithy/property-provider': 4.2.7 + '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-host-header@3.957.0': + dependencies: + '@aws-sdk/types': 3.957.0 + '@smithy/protocol-http': 5.3.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.957.0': + dependencies: + '@aws-sdk/types': 3.957.0 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.957.0': + dependencies: + '@aws-sdk/types': 3.957.0 + '@aws/lambda-invoke-store': 0.2.2 + '@smithy/protocol-http': 5.3.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.957.0': + dependencies: + '@aws-sdk/core': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-arn-parser': 3.957.0 + '@smithy/core': 3.20.0 + '@smithy/node-config-provider': 4.3.7 + '@smithy/protocol-http': 5.3.7 + '@smithy/signature-v4': 5.3.7 + '@smithy/smithy-client': 4.10.2 + '@smithy/types': 4.11.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.7 + '@smithy/util-stream': 4.5.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.957.0': + dependencies: + '@aws-sdk/core': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-endpoints': 3.957.0 + '@smithy/core': 3.20.0 + '@smithy/protocol-http': 5.3.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.958.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.957.0 + '@aws-sdk/middleware-host-header': 3.957.0 + '@aws-sdk/middleware-logger': 3.957.0 + '@aws-sdk/middleware-recursion-detection': 3.957.0 + '@aws-sdk/middleware-user-agent': 3.957.0 + '@aws-sdk/region-config-resolver': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-endpoints': 3.957.0 + '@aws-sdk/util-user-agent-browser': 3.957.0 + '@aws-sdk/util-user-agent-node': 3.957.0 + '@smithy/config-resolver': 4.4.5 + '@smithy/core': 3.20.0 + '@smithy/fetch-http-handler': 5.3.8 + '@smithy/hash-node': 4.2.7 + '@smithy/invalid-dependency': 4.2.7 + '@smithy/middleware-content-length': 4.2.7 + '@smithy/middleware-endpoint': 4.4.1 + '@smithy/middleware-retry': 4.4.17 + '@smithy/middleware-serde': 4.2.8 + '@smithy/middleware-stack': 4.2.7 + '@smithy/node-config-provider': 4.3.7 + '@smithy/node-http-handler': 4.4.7 + '@smithy/protocol-http': 5.3.7 + '@smithy/smithy-client': 4.10.2 + '@smithy/types': 4.11.0 + '@smithy/url-parser': 4.2.7 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.16 + '@smithy/util-defaults-mode-node': 4.2.19 + '@smithy/util-endpoints': 3.2.7 + '@smithy/util-middleware': 4.2.7 + '@smithy/util-retry': 4.2.7 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.957.0': + dependencies: + '@aws-sdk/types': 3.957.0 + '@smithy/config-resolver': 4.4.5 + '@smithy/node-config-provider': 4.3.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.957.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@smithy/protocol-http': 5.3.7 + '@smithy/signature-v4': 5.3.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.958.0': + dependencies: + '@aws-sdk/core': 3.957.0 + '@aws-sdk/nested-clients': 3.958.0 + '@aws-sdk/types': 3.957.0 + '@smithy/property-provider': 4.2.7 + '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.957.0': + dependencies: + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.957.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.957.0': + dependencies: + '@aws-sdk/types': 3.957.0 + '@smithy/types': 4.11.0 + '@smithy/url-parser': 4.2.7 + '@smithy/util-endpoints': 3.2.7 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.957.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.957.0': + dependencies: + '@aws-sdk/types': 3.957.0 + '@smithy/types': 4.11.0 + bowser: 2.13.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.957.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@smithy/node-config-provider': 4.3.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.957.0': + dependencies: + '@smithy/types': 4.11.0 + fast-xml-parser: 5.2.5 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.2': {} + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.26.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.26.0) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + convert-source-map: 2.0.0 debug: 4.4.3 - lodash.debounce: 4.0.8 - resolve: 1.22.11 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.28.5 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.26.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.28.5 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.28.5 + semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -7879,6 +10509,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.28.3(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -7894,11 +10533,11 @@ snapshots: '@babel/helper-plugin-utils@7.27.1': {} - '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.28.5)': + '@babel/helper-replace-supers@7.27.1(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-wrap-function': 7.28.3 + '@babel/core': 7.26.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -7925,14 +10564,6 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} - '@babel/helper-wrap-function@7.28.3': - dependencies: - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/helpers@7.28.4': dependencies: '@babel/template': 7.27.2 @@ -7942,633 +10573,647 @@ snapshots: dependencies: '@babel/types': 7.28.5 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.28.5)': + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-transform-optional-chaining': 7.28.5(@babel/core@7.28.5) - transitivePeerDependencies: - - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.3(@babel/core@7.28.5)': + '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.0 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - - '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.28.5)': + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.26.0 + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.26.0) + transitivePeerDependencies: + - supports-color - '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-async-generator-functions@7.28.0(@babel/core@7.28.5)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.28.5) - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-async-to-generator@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-module-imports': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.26.0) + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-block-scoping@7.28.5(@babel/core@7.28.5)': + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.26.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.28.3(@babel/core@7.28.5)': + '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.28.4(@babel/core@7.28.5)': + '@babel/preset-react@7.26.3(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-globals': 7.28.0 + '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5) - '@babel/traverse': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.26.0) + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.26.0) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.26.0) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.26.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-computed-properties@7.27.1(@babel/core@7.28.5)': + '@babel/preset-typescript@7.26.0(@babel/core@7.26.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/template': 7.27.2 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.26.0) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.26.0) + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.26.0) + transitivePeerDependencies: + - supports-color - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.28.5)': + '@babel/preset-typescript@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-dotall-regex@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.4': {} - '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.28.5)': + '@babel/template@7.27.2': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1(@babel/core@7.28.5)': + '@babel/traverse@7.28.5': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color - '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.28.5)': + '@babel/types@7.28.5': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 - '@babel/plugin-transform-explicit-resource-management@7.28.0(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.28.5) - transitivePeerDependencies: - - supports-color + '@biomejs/js-api@3.0.0(@biomejs/wasm-nodejs@2.3.7)': + optionalDependencies: + '@biomejs/wasm-nodejs': 2.3.7 - '@babel/plugin-transform-exponentiation-operator@7.28.5(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@biomejs/wasm-nodejs@2.3.7': {} + + '@changesets/apply-release-plan@7.0.14': + dependencies: + '@changesets/config': 3.1.2 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.7.3 - '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.28.5)': + '@changesets/assemble-release-plan@6.0.9': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.7.3 - '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.28.5)': + '@changesets/changelog-git@0.2.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - transitivePeerDependencies: - - supports-color + '@changesets/types': 6.1.0 - '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.28.5)': + '@changesets/changelog-github@0.5.1(encoding@0.1.13)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.5 + '@changesets/get-github-info': 0.6.0(encoding@0.1.13) + '@changesets/types': 6.1.0 + dotenv: 8.6.0 transitivePeerDependencies: - - supports-color + - encoding - '@babel/plugin-transform-json-strings@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@changesets/cli@2.29.7(@types/node@25.0.3)': + dependencies: + '@changesets/apply-release-plan': 7.0.14 + '@changesets/assemble-release-plan': 6.0.9 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.2 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-release-plan': 4.0.14 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.6 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3(@types/node@25.0.3) + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + ci-info: 3.9.0 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + p-limit: 2.3.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.7.3 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' - '@babel/plugin-transform-literals@7.27.1(@babel/core@7.28.5)': + '@changesets/config@3.1.2': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/logger': 0.1.1 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 - '@babel/plugin-transform-logical-assignment-operators@7.28.5(@babel/core@7.28.5)': + '@changesets/errors@0.2.0': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + extendable-error: 0.1.7 - '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.28.5)': + '@changesets/get-dependents-graph@2.1.3': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.7.3 - '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.28.5)': + '@changesets/get-github-info@0.6.0(encoding@0.1.13)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 + dataloader: 1.4.0 + node-fetch: 2.7.0(encoding@0.1.13) transitivePeerDependencies: - - supports-color + - encoding - '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.5)': + '@changesets/get-release-plan@4.0.14': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color + '@changesets/assemble-release-plan': 6.0.9 + '@changesets/config': 3.1.2 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.6 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 - '@babel/plugin-transform-modules-systemjs@7.28.5(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color + '@changesets/get-version-range-type@0.4.0': {} - '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.28.5)': + '@changesets/git@3.0.4': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 - '@babel/plugin-transform-named-capturing-groups-regex@7.27.1(@babel/core@7.28.5)': + '@changesets/logger@0.1.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 + picocolors: 1.1.1 - '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.28.5)': + '@changesets/parse@0.4.2': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@changesets/types': 6.1.0 + js-yaml: 4.1.1 - '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.28.5)': + '@changesets/pre@2.0.2': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 - '@babel/plugin-transform-numeric-separator@7.27.1(@babel/core@7.28.5)': + '@changesets/read@0.6.6': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.2 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 - '@babel/plugin-transform-object-rest-spread@7.28.4(@babel/core@7.28.5)': + '@changesets/should-skip-package@0.1.2': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.5) - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 - '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5) - transitivePeerDependencies: - - supports-color + '@changesets/types@4.1.0': {} - '@babel/plugin-transform-optional-catch-binding@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@changesets/types@6.1.0': {} - '@babel/plugin-transform-optional-chaining@7.28.5(@babel/core@7.28.5)': + '@changesets/write@0.4.0': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - transitivePeerDependencies: - - supports-color + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.1.3 + prettier: 2.8.8 - '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@colors/colors@1.5.0': + optional: true - '@babel/plugin-transform-private-methods@7.27.1(@babel/core@7.28.5)': + '@commitlint/cli@19.8.1(@types/node@25.0.3)(typescript@5.9.3)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 + '@commitlint/format': 19.8.1 + '@commitlint/lint': 19.8.1 + '@commitlint/load': 19.8.1(@types/node@25.0.3)(typescript@5.9.3) + '@commitlint/read': 19.8.1 + '@commitlint/types': 19.8.1 + tinyexec: 1.0.2 + yargs: 17.7.2 transitivePeerDependencies: - - supports-color + - '@types/node' + - typescript - '@babel/plugin-transform-private-property-in-object@7.27.1(@babel/core@7.28.5)': + '@commitlint/config-conventional@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color + '@commitlint/types': 19.8.1 + conventional-changelog-conventionalcommits: 7.0.2 - '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.28.5)': + '@commitlint/config-validator@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@commitlint/types': 19.8.1 + ajv: 8.17.1 - '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.28.5)': + '@commitlint/ensure@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@commitlint/types': 19.8.1 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.upperfirst: 4.3.1 + + '@commitlint/execute-rule@19.8.1': {} - '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.28.5)': + '@commitlint/format@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.5) - transitivePeerDependencies: - - supports-color + '@commitlint/types': 19.8.1 + chalk: 5.6.2 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + '@commitlint/is-ignored@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@commitlint/types': 19.8.1 + semver: 7.7.3 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + '@commitlint/lint@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@commitlint/is-ignored': 19.8.1 + '@commitlint/parse': 19.8.1 + '@commitlint/rules': 19.8.1 + '@commitlint/types': 19.8.1 - '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.5)': + '@commitlint/load@19.8.1(@types/node@25.0.3)(typescript@5.9.3)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/types': 7.28.5 + '@commitlint/config-validator': 19.8.1 + '@commitlint/execute-rule': 19.8.1 + '@commitlint/resolve-extends': 19.8.1 + '@commitlint/types': 19.8.1 + chalk: 5.6.2 + cosmiconfig: 9.0.0(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.2.0(@types/node@25.0.3)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + lodash.uniq: 4.5.0 transitivePeerDependencies: - - supports-color + - '@types/node' + - typescript - '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-plugin-utils': 7.27.1 + '@commitlint/message@19.8.1': {} - '@babel/plugin-transform-regenerator@7.28.4(@babel/core@7.28.5)': + '@commitlint/parse@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@commitlint/types': 19.8.1 + conventional-changelog-angular: 7.0.0 + conventional-commits-parser: 5.0.0 - '@babel/plugin-transform-regexp-modifiers@7.27.1(@babel/core@7.28.5)': + '@commitlint/read@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 + '@commitlint/top-level': 19.8.1 + '@commitlint/types': 19.8.1 + git-raw-commits: 4.0.0 + minimist: 1.2.8 + tinyexec: 1.0.2 - '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.28.5)': + '@commitlint/resolve-extends@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@commitlint/config-validator': 19.8.1 + '@commitlint/types': 19.8.1 + global-directory: 4.0.1 + import-meta-resolve: 4.2.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 - '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.28.5)': + '@commitlint/rules@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@commitlint/ensure': 19.8.1 + '@commitlint/message': 19.8.1 + '@commitlint/to-lines': 19.8.1 + '@commitlint/types': 19.8.1 - '@babel/plugin-transform-spread@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - transitivePeerDependencies: - - supports-color + '@commitlint/to-lines@19.8.1': {} - '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.28.5)': + '@commitlint/top-level@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + find-up: 7.0.0 - '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.28.5)': + '@commitlint/types@19.8.1': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@types/conventional-commits-parser': 5.0.2 + chalk: 5.6.2 - '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@csstools/color-helpers@5.1.0': {} - '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.28.5)': + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) - transitivePeerDependencies: - - supports-color + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 - '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.28.5)': + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 - '@babel/plugin-transform-unicode-property-regex@7.27.1(@babel/core@7.28.5)': + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 + '@csstools/css-tokenizer': 3.0.4 - '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 + '@csstools/css-syntax-patches-for-csstree@1.0.22': {} - '@babel/plugin-transform-unicode-sets-regex@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 + '@csstools/css-tokenizer@3.0.4': {} - '@babel/preset-env@7.28.5(@babel/core@7.28.5)': + '@datocms/cma-client-node@4.0.1': dependencies: - '@babel/compat-data': 7.28.5 - '@babel/core': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.3(@babel/core@7.28.5) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.28.5) - '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.28.5) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-async-generator-functions': 7.28.0(@babel/core@7.28.5) - '@babel/plugin-transform-async-to-generator': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-block-scoping': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-class-static-block': 7.28.3(@babel/core@7.28.5) - '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.28.5) - '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-dotall-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-explicit-resource-management': 7.28.0(@babel/core@7.28.5) - '@babel/plugin-transform-exponentiation-operator': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-json-strings': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-logical-assignment-operators': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-modules-systemjs': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-named-capturing-groups-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-numeric-separator': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-object-rest-spread': 7.28.4(@babel/core@7.28.5) - '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-optional-catch-binding': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-optional-chaining': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.5) - '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-regenerator': 7.28.4(@babel/core@7.28.5) - '@babel/plugin-transform-regexp-modifiers': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-spread': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-unicode-property-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-unicode-sets-regex': 7.27.1(@babel/core@7.28.5) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.28.5) - babel-plugin-polyfill-corejs2: 0.4.14(@babel/core@7.28.5) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.28.5) - babel-plugin-polyfill-regenerator: 0.6.5(@babel/core@7.28.5) - core-js-compat: 3.47.0 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + '@datocms/cma-client': 4.0.2 + '@datocms/rest-client-utils': 4.0.2 + mime-types: 2.1.35 + tmp-promise: 3.0.3 - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.28.5)': + '@datocms/cma-client@4.0.2': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/types': 7.28.5 - esutils: 2.0.3 + '@datocms/rest-client-utils': 4.0.2 + uuid: 9.0.1 - '@babel/preset-react@7.28.5(@babel/core@7.28.5)': + '@datocms/rest-client-utils@4.0.2': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.28.5) - '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.28.5) - transitivePeerDependencies: - - supports-color + async-scheduler: 1.4.4 - '@babel/preset-typescript@7.28.5(@babel/core@7.28.5)': + '@directus/composables@11.2.7(vue@3.5.24(typescript@5.9.3))': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) + '@directus/constants': 14.0.0 + '@directus/utils': 13.0.13(vue@3.5.24(typescript@5.9.3)) + axios: 1.12.2 + lodash-es: 4.17.21 + nanoid: 5.1.6 + vue: 3.5.24(typescript@5.9.3) transitivePeerDependencies: - - supports-color - - '@babel/runtime@7.28.4': {} + - debug - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - - '@babel/traverse@7.28.5': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - debug: 4.4.3 + '@directus/constants@14.0.0': {} + + '@directus/extensions-sdk@17.0.3(@types/node@25.0.3)(@unhead/vue@1.11.20(vue@3.5.24(typescript@5.9.3)))(jiti@2.6.1)(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)))(sharp@0.34.5)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(ws@8.18.3)(yaml@2.8.2)': + dependencies: + '@directus/composables': 11.2.7(vue@3.5.24(typescript@5.9.3)) + '@directus/constants': 14.0.0 + '@directus/extensions': 3.0.14(knex@3.1.0)(sharp@0.34.5)(vue@3.5.24(typescript@5.9.3))(ws@8.18.3) + '@directus/themes': 1.1.8(@unhead/vue@1.11.20(vue@3.5.24(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)))(vue@3.5.24(typescript@5.9.3)) + '@directus/types': 13.4.0(knex@3.1.0)(sharp@0.34.5)(vue@3.5.24(typescript@5.9.3))(ws@8.18.3) + '@directus/utils': 13.0.13(vue@3.5.24(typescript@5.9.3)) + '@rollup/plugin-commonjs': 28.0.9(rollup@4.52.5) + '@rollup/plugin-json': 6.1.0(rollup@4.52.5) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.52.5) + '@rollup/plugin-replace': 6.0.3(rollup@4.52.5) + '@rollup/plugin-terser': 0.4.4(rollup@4.52.5) + '@rollup/plugin-virtual': 3.0.2(rollup@4.52.5) + '@vitejs/plugin-vue': 6.0.1(vite@7.1.12(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.24(typescript@5.9.3)) + chalk: 5.6.2 + commander: 14.0.2 + esbuild: 0.25.12 + execa: 9.6.0 + fs-extra: 11.3.2 + inquirer: 12.11.0(@types/node@25.0.3) + ora: 8.2.0 + rollup: 4.52.5 + rollup-plugin-esbuild: 6.2.1(esbuild@0.25.12)(rollup@4.52.5) + rollup-plugin-styler: 2.0.0(rollup@4.52.5)(typescript@5.9.3) + semver: 7.7.3 + vite: 7.1.12(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vue: 3.5.24(typescript@5.9.3) transitivePeerDependencies: + - '@types/node' + - '@unhead/vue' + - aws-crt + - better-sqlite3 + - debug + - deep-diff + - express + - graphql + - jiti + - knex + - less + - lightningcss + - mysql + - mysql2 + - nodemailer + - openapi3-ts + - pg + - pg-native + - pinia + - pino + - sass + - sass-embedded + - sharp + - sqlite3 + - stylus + - sugarss - supports-color + - tedious + - terser + - tsx + - typescript + - vue-router + - ws + - yaml - '@babel/types@7.28.5': + '@directus/extensions@3.0.14(knex@3.1.0)(sharp@0.34.5)(vue@3.5.24(typescript@5.9.3))(ws@8.18.3)': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@biomejs/js-api@3.0.0(@biomejs/wasm-nodejs@2.3.7)': + '@directus/constants': 14.0.0 + '@directus/types': 13.4.0(knex@3.1.0)(sharp@0.34.5)(vue@3.5.24(typescript@5.9.3))(ws@8.18.3) + '@directus/utils': 13.0.13(vue@3.5.24(typescript@5.9.3)) + '@types/express': 4.17.21 + fs-extra: 11.3.2 + lodash-es: 4.17.21 + zod: 4.1.12 optionalDependencies: - '@biomejs/wasm-nodejs': 2.3.7 - - '@biomejs/wasm-nodejs@2.3.7': {} - - '@colors/colors@1.5.0': - optional: true - - '@csstools/color-helpers@5.1.0': {} - - '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/color-helpers': 5.1.0 - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 + knex: 3.1.0 + vue: 3.5.24(typescript@5.9.3) + transitivePeerDependencies: + - aws-crt + - better-sqlite3 + - deep-diff + - express + - graphql + - mysql + - mysql2 + - nodemailer + - openapi3-ts + - pg + - pg-native + - sharp + - sqlite3 + - supports-color + - tedious + - ws - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + '@directus/schema@13.0.4': dependencies: - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-syntax-patches-for-csstree@1.0.20': {} - - '@csstools/css-tokenizer@3.0.4': {} + knex: 3.1.0 + transitivePeerDependencies: + - better-sqlite3 + - mysql + - mysql2 + - pg + - pg-native + - sqlite3 + - supports-color + - tedious - '@datocms/cma-client-node@4.0.1': - dependencies: - '@datocms/cma-client': 4.0.2 - '@datocms/rest-client-utils': 4.0.2 - mime-types: 2.1.35 - tmp-promise: 3.0.3 + '@directus/system-data@3.4.2': {} - '@datocms/cma-client@4.0.2': + '@directus/themes@1.1.8(@unhead/vue@1.11.20(vue@3.5.24(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)))(vue@3.5.24(typescript@5.9.3))': dependencies: - '@datocms/rest-client-utils': 4.0.2 - uuid: 9.0.1 + '@directus/utils': 13.0.13(vue@3.5.24(typescript@5.9.3)) + '@unhead/vue': 1.11.20(vue@3.5.24(typescript@5.9.3)) + decamelize: 6.0.1 + flat: 6.0.1 + lodash-es: 4.17.21 + pinia: 2.3.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)) + vue: 3.5.24(typescript@5.9.3) + + '@directus/types@13.4.0(knex@3.1.0)(sharp@0.34.5)(vue@3.5.24(typescript@5.9.3))(ws@8.18.3)': + dependencies: + '@directus/constants': 14.0.0 + '@directus/schema': 13.0.4 + '@sinclair/typebox': 0.34.41 + '@types/express': 4.17.21 + '@types/geojson': 7946.0.16 + '@types/nodemailer': 7.0.3 + '@types/ws': 8.18.1 + optionalDependencies: + knex: 3.1.0 + sharp: 0.34.5 + vue: 3.5.24(typescript@5.9.3) + ws: 8.18.3 + transitivePeerDependencies: + - aws-crt + - better-sqlite3 + - mysql + - mysql2 + - pg + - pg-native + - sqlite3 + - supports-color + - tedious - '@datocms/rest-client-utils@4.0.2': + '@directus/utils@13.0.13(vue@3.5.24(typescript@5.9.3))': dependencies: - async-scheduler: 1.4.4 - - '@discoveryjs/json-ext@0.6.3': {} + '@directus/constants': 14.0.0 + '@directus/system-data': 3.4.2 + date-fns: 4.1.0 + fs-extra: 11.3.2 + joi: 18.0.1 + js-yaml: 4.1.1 + lodash-es: 4.17.21 + micromustache: 8.0.3 + optionalDependencies: + vue: 3.5.24(typescript@5.9.3) '@emnapi/core@1.7.1': dependencies: @@ -8765,7 +11410,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': + '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 debug: 4.4.3 @@ -8788,30 +11433,31 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@formatjs/ecma402-abstract@2.3.6': + '@formatjs/ecma402-abstract@3.0.5': dependencies: - '@formatjs/fast-memoize': 2.2.7 - '@formatjs/intl-localematcher': 0.6.2 + '@formatjs/fast-memoize': 3.0.1 + '@formatjs/intl-localematcher': 0.7.3 decimal.js: 10.6.0 tslib: 2.8.1 - '@formatjs/fast-memoize@2.2.7': + '@formatjs/fast-memoize@3.0.1': dependencies: tslib: 2.8.1 - '@formatjs/icu-messageformat-parser@2.11.4': + '@formatjs/icu-messageformat-parser@3.1.1': dependencies: - '@formatjs/ecma402-abstract': 2.3.6 - '@formatjs/icu-skeleton-parser': 1.8.16 + '@formatjs/ecma402-abstract': 3.0.5 + '@formatjs/icu-skeleton-parser': 2.0.5 tslib: 2.8.1 - '@formatjs/icu-skeleton-parser@1.8.16': + '@formatjs/icu-skeleton-parser@2.0.5': dependencies: - '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/ecma402-abstract': 3.0.5 tslib: 2.8.1 - '@formatjs/intl-localematcher@0.6.2': + '@formatjs/intl-localematcher@0.7.3': dependencies: + '@formatjs/fast-memoize': 3.0.1 tslib: 2.8.1 '@gitbeaker/core@39.34.3': @@ -8832,6 +11478,22 @@ snapshots: '@gitbeaker/core': 39.34.3 '@gitbeaker/requester-utils': 39.34.3 + '@hapi/address@5.1.1': + dependencies: + '@hapi/hoek': 11.0.7 + + '@hapi/formula@3.0.2': {} + + '@hapi/hoek@11.0.7': {} + + '@hapi/pinpoint@2.0.1': {} + + '@hapi/tlds@1.1.4': {} + + '@hapi/topo@6.0.2': + dependencies: + '@hapi/hoek': 11.0.7 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -8940,153 +11602,274 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inkjs/ui@2.0.0(ink@4.2.0(@types/react@18.3.27)(react@19.2.0))': + '@inkjs/ui@2.0.0(ink@4.2.0(@types/react@19.2.7)(react@19.2.3))': dependencies: chalk: 5.6.2 cli-spinners: 3.3.0 deepmerge: 4.3.1 figures: 6.1.0 - ink: 4.2.0(@types/react@18.3.27)(react@19.2.0) + ink: 4.2.0(@types/react@19.2.7)(react@19.2.3) '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@22.19.1)': + '@inquirer/checkbox@4.3.2(@types/node@22.10.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.10.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.10.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/checkbox@4.3.2(@types/node@25.0.3)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@25.0.3) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/type': 3.0.10(@types/node@25.0.3) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 + + '@inquirer/confirm@5.1.21(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.10.2) + '@inquirer/type': 3.0.10(@types/node@22.10.2) + optionalDependencies: + '@types/node': 22.10.2 - '@inquirer/confirm@5.1.21(@types/node@22.19.1)': + '@inquirer/confirm@5.1.21(@types/node@25.0.3)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) + optionalDependencies: + '@types/node': 25.0.3 + + '@inquirer/core@10.3.2(@types/node@22.10.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.10.2) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 22.10.2 - '@inquirer/core@10.3.2(@types/node@22.19.1)': + '@inquirer/core@10.3.2(@types/node@25.0.3)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/type': 3.0.10(@types/node@25.0.3) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 + + '@inquirer/editor@4.2.23(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.10.2) + '@inquirer/external-editor': 1.0.3(@types/node@22.10.2) + '@inquirer/type': 3.0.10(@types/node@22.10.2) + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/editor@4.2.23(@types/node@25.0.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/external-editor': 1.0.3(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) + optionalDependencies: + '@types/node': 25.0.3 - '@inquirer/editor@4.2.23(@types/node@22.19.1)': + '@inquirer/expand@4.0.23(@types/node@22.10.2)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/external-editor': 1.0.3(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@22.10.2) + '@inquirer/type': 3.0.10(@types/node@22.10.2) + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 22.10.2 - '@inquirer/expand@4.0.23(@types/node@22.19.1)': + '@inquirer/expand@4.0.23(@types/node@25.0.3)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 + + '@inquirer/external-editor@1.0.3(@types/node@22.10.2)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.1 + optionalDependencies: + '@types/node': 22.10.2 - '@inquirer/external-editor@1.0.3(@types/node@22.19.1)': + '@inquirer/external-editor@1.0.3(@types/node@25.0.3)': dependencies: chardet: 2.1.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.1 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@22.19.1)': + '@inquirer/input@4.3.1(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.10.2) + '@inquirer/type': 3.0.10(@types/node@22.10.2) + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/input@4.3.1(@types/node@25.0.3)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 - '@inquirer/number@3.0.23(@types/node@22.19.1)': + '@inquirer/number@3.0.23(@types/node@22.10.2)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@22.10.2) + '@inquirer/type': 3.0.10(@types/node@22.10.2) optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 22.10.2 - '@inquirer/password@4.0.23(@types/node@22.19.1)': + '@inquirer/number@3.0.23(@types/node@25.0.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) + optionalDependencies: + '@types/node': 25.0.3 + + '@inquirer/password@4.0.23(@types/node@22.10.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.10.2) + '@inquirer/type': 3.0.10(@types/node@22.10.2) + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/password@4.0.23(@types/node@25.0.3)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) + optionalDependencies: + '@types/node': 25.0.3 + + '@inquirer/prompts@7.10.1(@types/node@25.0.3)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@25.0.3) + '@inquirer/confirm': 5.1.21(@types/node@25.0.3) + '@inquirer/editor': 4.2.23(@types/node@25.0.3) + '@inquirer/expand': 4.0.23(@types/node@25.0.3) + '@inquirer/input': 4.3.1(@types/node@25.0.3) + '@inquirer/number': 3.0.23(@types/node@25.0.3) + '@inquirer/password': 4.0.23(@types/node@25.0.3) + '@inquirer/rawlist': 4.1.11(@types/node@25.0.3) + '@inquirer/search': 3.2.2(@types/node@25.0.3) + '@inquirer/select': 4.4.2(@types/node@25.0.3) optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 + + '@inquirer/prompts@7.8.0(@types/node@22.10.2)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.10.2) + '@inquirer/confirm': 5.1.21(@types/node@22.10.2) + '@inquirer/editor': 4.2.23(@types/node@22.10.2) + '@inquirer/expand': 4.0.23(@types/node@22.10.2) + '@inquirer/input': 4.3.1(@types/node@22.10.2) + '@inquirer/number': 3.0.23(@types/node@22.10.2) + '@inquirer/password': 4.0.23(@types/node@22.10.2) + '@inquirer/rawlist': 4.1.11(@types/node@22.10.2) + '@inquirer/search': 3.2.2(@types/node@22.10.2) + '@inquirer/select': 4.4.2(@types/node@22.10.2) + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/prompts@7.8.0(@types/node@25.0.3)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@25.0.3) + '@inquirer/confirm': 5.1.21(@types/node@25.0.3) + '@inquirer/editor': 4.2.23(@types/node@25.0.3) + '@inquirer/expand': 4.0.23(@types/node@25.0.3) + '@inquirer/input': 4.3.1(@types/node@25.0.3) + '@inquirer/number': 3.0.23(@types/node@25.0.3) + '@inquirer/password': 4.0.23(@types/node@25.0.3) + '@inquirer/rawlist': 4.1.11(@types/node@25.0.3) + '@inquirer/search': 3.2.2(@types/node@25.0.3) + '@inquirer/select': 4.4.2(@types/node@25.0.3) + optionalDependencies: + '@types/node': 25.0.3 + + '@inquirer/rawlist@4.1.11(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.10.2) + '@inquirer/type': 3.0.10(@types/node@22.10.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.10.2 - '@inquirer/prompts@7.10.1(@types/node@22.19.1)': + '@inquirer/rawlist@4.1.11(@types/node@25.0.3)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.1) - '@inquirer/confirm': 5.1.21(@types/node@22.19.1) - '@inquirer/editor': 4.2.23(@types/node@22.19.1) - '@inquirer/expand': 4.0.23(@types/node@22.19.1) - '@inquirer/input': 4.3.1(@types/node@22.19.1) - '@inquirer/number': 3.0.23(@types/node@22.19.1) - '@inquirer/password': 4.0.23(@types/node@22.19.1) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.1) - '@inquirer/search': 3.2.2(@types/node@22.19.1) - '@inquirer/select': 4.4.2(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 - '@inquirer/prompts@7.8.0(@types/node@22.19.1)': + '@inquirer/search@3.2.2(@types/node@22.10.2)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.1) - '@inquirer/confirm': 5.1.21(@types/node@22.19.1) - '@inquirer/editor': 4.2.23(@types/node@22.19.1) - '@inquirer/expand': 4.0.23(@types/node@22.19.1) - '@inquirer/input': 4.3.1(@types/node@22.19.1) - '@inquirer/number': 3.0.23(@types/node@22.19.1) - '@inquirer/password': 4.0.23(@types/node@22.19.1) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.1) - '@inquirer/search': 3.2.2(@types/node@22.19.1) - '@inquirer/select': 4.4.2(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@22.10.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.10.2) + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 22.10.2 - '@inquirer/rawlist@4.1.11(@types/node@22.19.1)': + '@inquirer/search@3.2.2(@types/node@25.0.3)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.0.3) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 - '@inquirer/search@3.2.2(@types/node@22.19.1)': + '@inquirer/select@4.4.2(@types/node@22.10.2)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.10.2) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/type': 3.0.10(@types/node@22.10.2) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 22.10.2 - '@inquirer/select@4.4.2(@types/node@22.19.1)': + '@inquirer/select@4.4.2(@types/node@25.0.3)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@25.0.3) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/type': 3.0.10(@types/node@25.0.3) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 + + '@inquirer/type@3.0.10(@types/node@22.10.2)': + optionalDependencies: + '@types/node': 22.10.2 - '@inquirer/type@3.0.10(@types/node@22.19.1)': + '@inquirer/type@3.0.10(@types/node@25.0.3)': optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 '@isaacs/balanced-match@4.0.1': {} @@ -9127,45 +11910,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) - '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) - '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) - '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) - '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) - hyperdyperid: 1.2.0 - thingies: 2.5.0(tslib@2.8.1) - tree-dump: 1.1.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) - '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/util@1.9.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) - '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) - tslib: 2.8.1 - - '@leichtgewicht/ip-codec@2.0.5': {} - - '@lingo.dev/_compiler@0.8.0(react@19.2.0)': + '@lingo.dev/_compiler@0.8.8(react@19.2.3)': dependencies: '@ai-sdk/anthropic': 1.2.11(zod@3.25.76) '@ai-sdk/google': 1.2.19(zod@3.25.76) @@ -9176,10 +11921,10 @@ snapshots: '@babel/parser': 7.28.5 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 - '@lingo.dev/_sdk': 0.13.0 - '@lingo.dev/_spec': 0.44.0 - '@openrouter/ai-sdk-provider': 0.7.1(ai@4.2.10(react@19.2.0)(zod@3.25.76))(zod@3.25.76) - ai: 4.2.10(react@19.2.0)(zod@3.25.76) + '@lingo.dev/_sdk': 0.13.4 + '@lingo.dev/_spec': 0.44.4 + '@openrouter/ai-sdk-provider': 0.7.1(ai@4.2.10(react@19.2.3)(zod@3.25.76))(zod@3.25.76) + ai: 4.2.10(react@19.2.3)(zod@3.25.76) dedent: 1.7.0 dotenv: 16.4.5 fast-xml-parser: 5.3.2 @@ -9199,19 +11944,19 @@ snapshots: - supports-color - utf-8-validate - '@lingo.dev/_locales@0.3.0': + '@lingo.dev/_locales@0.3.1': dependencies: iso-639-3: 3.0.1 - '@lingo.dev/_react@0.7.0(next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))': + '@lingo.dev/_react@0.7.5(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))': dependencies: js-cookie: 3.0.5 lodash: 4.17.21 - next: 16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@lingo.dev/_sdk@0.13.0': + '@lingo.dev/_sdk@0.13.4': dependencies: - '@lingo.dev/_spec': 0.44.0 + '@lingo.dev/_spec': 0.44.4 '@paralleldrive/cuid2': 2.2.2 jsdom: 25.0.1 zod: 3.25.76 @@ -9221,20 +11966,34 @@ snapshots: - supports-color - utf-8-validate - '@lingo.dev/_spec@0.44.0': + '@lingo.dev/_spec@0.44.4': dependencies: - '@lingo.dev/_locales': 0.3.0 + '@lingo.dev/_locales': 0.3.1 zod: 3.25.76 zod-to-json-schema: 3.25.0(zod@3.25.76) - '@markdoc/markdoc@0.5.4(@types/react@18.3.27)(react@19.2.0)': + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.28.4 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.28.4 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@markdoc/markdoc@0.5.4(@types/react@19.2.7)(react@19.2.3)': optionalDependencies: '@types/linkify-it': 3.0.5 '@types/markdown-it': 12.2.3 - '@types/react': 18.3.27 - react: 19.2.0 - - '@mjackson/node-fetch-server@0.2.0': {} + '@types/react': 19.2.7 + react: 19.2.3 '@modelcontextprotocol/sdk@1.22.0': dependencies: @@ -9247,8 +12006,8 @@ snapshots: eventsource-parser: 3.0.6 express: 5.1.0 express-rate-limit: 7.5.1(express@5.1.0) - pkce-challenge: 5.0.0 - raw-body: 3.0.1 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 zod: 3.25.76 zod-to-json-schema: 3.25.0(zod@3.25.76) transitivePeerDependencies: @@ -9261,69 +12020,121 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@napi-rs/wasm-runtime@1.0.7': + '@napi-rs/wasm-runtime@1.1.0': dependencies: '@emnapi/core': 1.7.1 '@emnapi/runtime': 1.7.1 '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.0.7': {} + '@next/env@15.3.8': {} + + '@next/env@16.0.4': {} '@next/env@16.1.0': {} + '@next/env@16.1.1': {} + '@next/eslint-plugin-next@16.0.3': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.0.7': + '@next/swc-darwin-arm64@15.3.5': + optional: true + + '@next/swc-darwin-arm64@16.0.4': optional: true '@next/swc-darwin-arm64@16.1.0': optional: true - '@next/swc-darwin-x64@16.0.7': + '@next/swc-darwin-arm64@16.1.1': + optional: true + + '@next/swc-darwin-x64@15.3.5': + optional: true + + '@next/swc-darwin-x64@16.0.4': optional: true '@next/swc-darwin-x64@16.1.0': optional: true - '@next/swc-linux-arm64-gnu@16.0.7': + '@next/swc-darwin-x64@16.1.1': + optional: true + + '@next/swc-linux-arm64-gnu@15.3.5': + optional: true + + '@next/swc-linux-arm64-gnu@16.0.4': optional: true '@next/swc-linux-arm64-gnu@16.1.0': optional: true - '@next/swc-linux-arm64-musl@16.0.7': + '@next/swc-linux-arm64-gnu@16.1.1': + optional: true + + '@next/swc-linux-arm64-musl@15.3.5': + optional: true + + '@next/swc-linux-arm64-musl@16.0.4': optional: true '@next/swc-linux-arm64-musl@16.1.0': optional: true - '@next/swc-linux-x64-gnu@16.0.7': + '@next/swc-linux-arm64-musl@16.1.1': + optional: true + + '@next/swc-linux-x64-gnu@15.3.5': + optional: true + + '@next/swc-linux-x64-gnu@16.0.4': optional: true '@next/swc-linux-x64-gnu@16.1.0': optional: true - '@next/swc-linux-x64-musl@16.0.7': + '@next/swc-linux-x64-gnu@16.1.1': + optional: true + + '@next/swc-linux-x64-musl@15.3.5': + optional: true + + '@next/swc-linux-x64-musl@16.0.4': optional: true '@next/swc-linux-x64-musl@16.1.0': optional: true - '@next/swc-win32-arm64-msvc@16.0.7': + '@next/swc-linux-x64-musl@16.1.1': + optional: true + + '@next/swc-win32-arm64-msvc@15.3.5': + optional: true + + '@next/swc-win32-arm64-msvc@16.0.4': optional: true '@next/swc-win32-arm64-msvc@16.1.0': optional: true - '@next/swc-win32-x64-msvc@16.0.7': + '@next/swc-win32-arm64-msvc@16.1.1': + optional: true + + '@next/swc-win32-x64-msvc@15.3.5': + optional: true + + '@next/swc-win32-x64-msvc@16.0.4': optional: true '@next/swc-win32-x64-msvc@16.1.0': optional: true + '@next/swc-win32-x64-msvc@16.1.1': + optional: true + '@noble/hashes@1.8.0': {} '@nodelib/fs.scandir@2.1.5': @@ -9336,39 +12147,10 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 + fastq: 1.20.1 '@nolyfill/is-core-module@1.0.39': {} - '@npmcli/git@4.1.0': - dependencies: - '@npmcli/promise-spawn': 6.0.2 - lru-cache: 7.18.3 - npm-pick-manifest: 8.0.2 - proc-log: 3.0.0 - promise-inflight: 1.0.1 - promise-retry: 2.0.1 - semver: 7.7.3 - which: 3.0.1 - transitivePeerDependencies: - - bluebird - - '@npmcli/package-json@4.0.1': - dependencies: - '@npmcli/git': 4.1.0 - glob: 10.5.0 - hosted-git-info: 6.1.3 - json-parse-even-better-errors: 3.0.2 - normalize-package-data: 5.0.0 - proc-log: 3.0.0 - semver: 7.7.3 - transitivePeerDependencies: - - bluebird - - '@npmcli/promise-spawn@6.0.2': - dependencies: - which: 3.0.1 - '@octokit/app@15.1.6': dependencies: '@octokit/auth-app': 7.2.2 @@ -9413,6 +12195,8 @@ snapshots: '@octokit/types': 14.1.0 universal-user-agent: 7.0.3 + '@octokit/auth-token@4.0.0': {} + '@octokit/auth-token@5.1.2': {} '@octokit/auth-unauthenticated@6.1.3': @@ -9420,6 +12204,16 @@ snapshots: '@octokit/request-error': 6.1.8 '@octokit/types': 14.1.0 + '@octokit/core@5.2.2': + dependencies: + '@octokit/auth-token': 4.0.0 + '@octokit/graphql': 7.1.1 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + before-after-hook: 2.2.3 + universal-user-agent: 6.0.1 + '@octokit/core@6.1.6': dependencies: '@octokit/auth-token': 5.1.2 @@ -9435,6 +12229,17 @@ snapshots: '@octokit/types': 14.1.0 universal-user-agent: 7.0.3 + '@octokit/endpoint@9.0.6': + dependencies: + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/graphql@7.1.1': + dependencies: + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + '@octokit/graphql@8.2.2': dependencies: '@octokit/request': 9.2.4 @@ -9471,6 +12276,11 @@ snapshots: dependencies: '@octokit/core': 6.1.6 + '@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 + '@octokit/plugin-paginate-rest@11.6.0(@octokit/core@6.1.6)': dependencies: '@octokit/core': 6.1.6 @@ -9481,6 +12291,15 @@ snapshots: '@octokit/core': 6.1.6 '@octokit/types': 14.1.0 + '@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 + '@octokit/plugin-rest-endpoint-methods@13.5.0(@octokit/core@6.1.6)': dependencies: '@octokit/core': 6.1.6 @@ -9499,10 +12318,23 @@ snapshots: '@octokit/types': 13.10.0 bottleneck: 2.19.5 + '@octokit/request-error@5.1.1': + dependencies: + '@octokit/types': 13.10.0 + deprecation: 2.3.1 + once: 1.4.0 + '@octokit/request-error@6.1.8': dependencies: '@octokit/types': 14.1.0 + '@octokit/request@8.4.1': + dependencies: + '@octokit/endpoint': 9.0.6 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + '@octokit/request@9.2.4': dependencies: '@octokit/endpoint': 10.1.4 @@ -9511,6 +12343,13 @@ snapshots: fast-content-type-parse: 2.0.1 universal-user-agent: 7.0.3 + '@octokit/rest@20.1.2': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/plugin-paginate-rest': 11.4.4-cjs.2(@octokit/core@5.2.2) + '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.2) + '@octokit/plugin-rest-endpoint-methods': 13.3.2-cjs.1(@octokit/core@5.2.2) + '@octokit/types@13.10.0': dependencies: '@octokit/openapi-types': 24.2.0 @@ -9527,425 +12366,884 @@ snapshots: '@octokit/request-error': 6.1.8 '@octokit/webhooks-methods': 5.1.1 - '@openrouter/ai-sdk-provider@0.7.1(ai@4.2.10(react@19.2.0)(zod@3.25.76))(zod@3.25.76)': + '@openrouter/ai-sdk-provider@0.7.1(ai@4.2.10(react@19.2.3)(zod@3.25.76))(zod@3.25.76)': dependencies: '@ai-sdk/provider': 1.1.3 '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - ai: 4.2.10(react@19.2.0)(zod@3.25.76) + ai: 4.2.10(react@19.2.3)(zod@3.25.76) zod: 3.25.76 - '@openrouter/ai-sdk-provider@0.7.1(ai@4.3.15(react@19.2.0)(zod@3.25.76))(zod@3.25.76)': + '@openrouter/ai-sdk-provider@0.7.1(ai@4.3.15(react@19.2.3)(zod@3.25.76))(zod@3.25.76)': dependencies: '@ai-sdk/provider': 1.1.3 '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - ai: 4.3.15(react@19.2.0)(zod@3.25.76) + ai: 4.3.15(react@19.2.3)(zod@3.25.76) zod: 3.25.76 - '@openrouter/ai-sdk-provider@0.7.5(ai@4.3.19(react@19.2.1)(zod@3.25.76))(zod@3.25.76)': + '@openrouter/ai-sdk-provider@1.5.4(ai@6.0.3(zod@4.1.12))(zod@4.1.12)': dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - ai: 4.3.19(react@19.2.1)(zod@3.25.76) - zod: 3.25.76 + '@openrouter/sdk': 0.1.27 + ai: 6.0.3(zod@4.1.12) + zod: 4.1.12 - '@opentelemetry/api@1.9.0': {} + '@openrouter/ai-sdk-provider@6.0.0-alpha.1(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.0 + '@ai-sdk/provider-utils': 4.0.1(zod@4.1.12) + '@openrouter/sdk': 0.3.15 + zod: 4.1.12 - '@oxc-project/runtime@0.96.0': {} + '@openrouter/sdk@0.1.27': + dependencies: + zod: 4.1.12 - '@oxc-project/types@0.98.0': {} + '@openrouter/sdk@0.3.15': + dependencies: + zod: 4.1.12 + + '@opentelemetry/api@1.9.0': {} + + '@oxc-project/types@0.103.0': {} + + '@paralleldrive/cuid2@2.2.2': + dependencies: + '@noble/hashes': 1.8.0 - '@oxlint-tsgolint/darwin-arm64@0.8.0': + '@pkgr/core@0.2.9': optional: true - '@oxlint-tsgolint/darwin-x64@0.8.0': + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + + '@playwright/test@1.57.0': + dependencies: + playwright: 1.57.0 + optional: true + + '@polka/url@1.0.0-next.29': {} + + '@posthog/core@1.6.0': + dependencies: + cross-spawn: 7.0.6 + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@replexica/sdk@0.7.12(@types/node@25.0.3)(encoding@0.1.13)': + dependencies: + lingo.dev: 0.117.21(@types/node@25.0.3)(@types/react@19.2.7)(encoding@0.1.13)(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + transitivePeerDependencies: + - '@biomejs/wasm-bundler' + - '@biomejs/wasm-web' + - '@cfworker/json-schema' + - '@types/node' + - '@types/react' + - babel-plugin-macros + - bufferutil + - canvas + - encoding + - next + - react-devtools-core + - supports-color + - utf-8-validate + + '@rolldown/binding-android-arm64@1.0.0-beta.55': optional: true - '@oxlint-tsgolint/linux-arm64@0.8.0': + '@rolldown/binding-darwin-arm64@1.0.0-beta.55': optional: true - '@oxlint-tsgolint/linux-x64@0.8.0': + '@rolldown/binding-darwin-x64@1.0.0-beta.55': optional: true - '@oxlint-tsgolint/win32-arm64@0.8.0': + '@rolldown/binding-freebsd-x64@1.0.0-beta.55': optional: true - '@oxlint-tsgolint/win32-x64@0.8.0': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.55': optional: true - '@oxlint/darwin-arm64@1.29.0': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.55': optional: true - '@oxlint/darwin-x64@1.29.0': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.55': optional: true - '@oxlint/linux-arm64-gnu@1.29.0': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.55': optional: true - '@oxlint/linux-arm64-musl@1.29.0': + '@rolldown/binding-linux-x64-musl@1.0.0-beta.55': optional: true - '@oxlint/linux-x64-gnu@1.29.0': + '@rolldown/binding-openharmony-arm64@1.0.0-beta.55': optional: true - '@oxlint/linux-x64-musl@1.29.0': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.55': + dependencies: + '@napi-rs/wasm-runtime': 1.1.0 optional: true - '@oxlint/win32-arm64@1.29.0': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.55': optional: true - '@oxlint/win32-x64@1.29.0': + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.55': optional: true - '@paralleldrive/cuid2@2.2.2': + '@rolldown/pluginutils@1.0.0-beta.29': {} + + '@rolldown/pluginutils@1.0.0-beta.38': {} + + '@rolldown/pluginutils@1.0.0-beta.55': {} + + '@rollup/plugin-alias@5.1.1(rollup@4.54.0)': + optionalDependencies: + rollup: 4.54.0 + + '@rollup/plugin-commonjs@28.0.9(rollup@4.52.5)': dependencies: - '@noble/hashes': 1.8.0 + '@rollup/pluginutils': 5.3.0(rollup@4.52.5) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.5.0(picomatch@4.0.3) + is-reference: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.52.5 - '@pkgjs/parseargs@0.11.0': - optional: true + '@rollup/plugin-commonjs@28.0.9(rollup@4.54.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.54.0) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.5.0(picomatch@4.0.3) + is-reference: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.54.0 - '@playwright/test@1.56.1': + '@rollup/plugin-json@6.1.0(rollup@4.52.5)': dependencies: - playwright: 1.56.1 + '@rollup/pluginutils': 5.3.0(rollup@4.52.5) + optionalDependencies: + rollup: 4.52.5 - '@posthog/core@1.6.0': + '@rollup/plugin-json@6.1.0(rollup@4.54.0)': dependencies: - cross-spawn: 7.0.6 + '@rollup/pluginutils': 5.3.0(rollup@4.54.0) + optionalDependencies: + rollup: 4.54.0 - '@quansync/fs@0.1.5': + '@rollup/plugin-node-resolve@16.0.3(rollup@4.52.5)': dependencies: - quansync: 0.2.11 + '@rollup/pluginutils': 5.3.0(rollup@4.52.5) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.11 + optionalDependencies: + rollup: 4.52.5 - '@react-router/dev@7.9.6(@react-router/serve@7.9.6(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3))(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1)': + '@rollup/plugin-node-resolve@16.0.3(rollup@4.54.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.5 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - '@npmcli/package-json': 4.0.1 - '@react-router/node': 7.9.6(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3) - '@remix-run/node-fetch-server': 0.9.0 - arg: 5.0.2 - babel-dead-code-elimination: 1.0.10 - chokidar: 4.0.3 - dedent: 1.7.0 - es-module-lexer: 1.7.0 - exit-hook: 2.2.1 - isbot: 5.1.32 - jsesc: 3.0.2 - lodash: 4.17.21 - p-map: 7.0.4 - pathe: 1.1.2 - picocolors: 1.1.1 - prettier: 3.6.2 - react-refresh: 0.14.2 - react-router: 7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - semver: 7.7.3 - tinyglobby: 0.2.15 - valibot: 1.2.0(typescript@5.9.3) - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + '@rollup/pluginutils': 5.3.0(rollup@4.54.0) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.11 optionalDependencies: - '@react-router/serve': 7.9.6(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - bluebird - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml + rollup: 4.54.0 - '@react-router/express@7.9.6(express@4.21.2)(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3)': + '@rollup/plugin-replace@6.0.3(rollup@4.52.5)': dependencies: - '@react-router/node': 7.9.6(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3) - express: 4.21.2 - react-router: 7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@rollup/pluginutils': 5.3.0(rollup@4.52.5) + magic-string: 0.30.21 optionalDependencies: - typescript: 5.9.3 + rollup: 4.52.5 - '@react-router/node@7.9.6(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3)': + '@rollup/plugin-replace@6.0.3(rollup@4.54.0)': dependencies: - '@mjackson/node-fetch-server': 0.2.0 - react-router: 7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@rollup/pluginutils': 5.3.0(rollup@4.54.0) + magic-string: 0.30.21 optionalDependencies: - typescript: 5.9.3 + rollup: 4.54.0 - '@react-router/serve@7.9.6(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3)': + '@rollup/plugin-terser@0.4.4(rollup@4.52.5)': dependencies: - '@mjackson/node-fetch-server': 0.2.0 - '@react-router/express': 7.9.6(express@4.21.2)(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3) - '@react-router/node': 7.9.6(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3) - compression: 1.8.1 - express: 4.21.2 - get-port: 5.1.1 - morgan: 1.10.1 - react-router: 7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - source-map-support: 0.5.21 - transitivePeerDependencies: - - supports-color - - typescript + serialize-javascript: 6.0.2 + smob: 1.5.0 + terser: 5.44.1 + optionalDependencies: + rollup: 4.52.5 - '@remix-run/node-fetch-server@0.9.0': {} + '@rollup/plugin-virtual@3.0.2(rollup@4.52.5)': + optionalDependencies: + rollup: 4.52.5 + + '@rollup/pluginutils@5.3.0(rollup@4.52.5)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.52.5 + + '@rollup/pluginutils@5.3.0(rollup@4.54.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.54.0 - '@rolldown/binding-android-arm64@1.0.0-beta.51': + '@rollup/rollup-android-arm-eabi@4.52.5': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-beta.51': + '@rollup/rollup-android-arm-eabi@4.54.0': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.51': + '@rollup/rollup-android-arm64@4.52.5': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.51': + '@rollup/rollup-android-arm64@4.54.0': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.51': + '@rollup/rollup-darwin-arm64@4.52.5': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.51': + '@rollup/rollup-darwin-arm64@4.54.0': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.51': + '@rollup/rollup-darwin-x64@4.52.5': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.51': + '@rollup/rollup-darwin-x64@4.54.0': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.51': + '@rollup/rollup-freebsd-arm64@4.52.5': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.51': + '@rollup/rollup-freebsd-arm64@4.54.0': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.51': - dependencies: - '@napi-rs/wasm-runtime': 1.0.7 + '@rollup/rollup-freebsd-x64@4.52.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.54.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.52.5': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.51': + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.51': + '@rollup/rollup-linux-arm-musleabihf@4.52.5': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.51': + '@rollup/rollup-linux-arm-musleabihf@4.54.0': optional: true - '@rolldown/pluginutils@1.0.0-beta.47': {} + '@rollup/rollup-linux-arm64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.52.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.54.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.52.5': + optional: true - '@rolldown/pluginutils@1.0.0-beta.51': {} + '@rollup/rollup-linux-loong64-gnu@4.54.0': + optional: true - '@rollup/rollup-android-arm-eabi@4.53.3': + '@rollup/rollup-linux-ppc64-gnu@4.52.5': optional: true - '@rollup/rollup-android-arm64@4.53.3': + '@rollup/rollup-linux-ppc64-gnu@4.54.0': optional: true - '@rollup/rollup-darwin-arm64@4.53.3': + '@rollup/rollup-linux-riscv64-gnu@4.52.5': optional: true - '@rollup/rollup-darwin-x64@4.53.3': + '@rollup/rollup-linux-riscv64-gnu@4.54.0': optional: true - '@rollup/rollup-freebsd-arm64@4.53.3': + '@rollup/rollup-linux-riscv64-musl@4.52.5': optional: true - '@rollup/rollup-freebsd-x64@4.53.3': + '@rollup/rollup-linux-riscv64-musl@4.54.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + '@rollup/rollup-linux-s390x-gnu@4.52.5': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.53.3': + '@rollup/rollup-linux-s390x-gnu@4.54.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.53.3': + '@rollup/rollup-linux-x64-gnu@4.52.5': optional: true - '@rollup/rollup-linux-arm64-musl@4.53.3': + '@rollup/rollup-linux-x64-gnu@4.54.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.53.3': + '@rollup/rollup-linux-x64-musl@4.52.5': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.53.3': + '@rollup/rollup-linux-x64-musl@4.54.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.53.3': + '@rollup/rollup-openharmony-arm64@4.52.5': optional: true - '@rollup/rollup-linux-riscv64-musl@4.53.3': + '@rollup/rollup-openharmony-arm64@4.54.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.53.3': + '@rollup/rollup-win32-arm64-msvc@4.52.5': optional: true - '@rollup/rollup-linux-x64-gnu@4.53.3': + '@rollup/rollup-win32-arm64-msvc@4.54.0': optional: true - '@rollup/rollup-linux-x64-musl@4.53.3': + '@rollup/rollup-win32-ia32-msvc@4.52.5': optional: true - '@rollup/rollup-openharmony-arm64@4.53.3': + '@rollup/rollup-win32-ia32-msvc@4.54.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.53.3': + '@rollup/rollup-win32-x64-gnu@4.52.5': optional: true - '@rollup/rollup-win32-ia32-msvc@4.53.3': + '@rollup/rollup-win32-x64-gnu@4.54.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.53.3': + '@rollup/rollup-win32-x64-msvc@4.52.5': optional: true - '@rollup/rollup-win32-x64-msvc@4.53.3': + '@rollup/rollup-win32-x64-msvc@4.54.0': optional: true '@rtsao/scc@1.1.0': {} - '@solid-primitives/event-listener@2.4.3(solid-js@1.9.10)': - dependencies: - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) - solid-js: 1.9.10 + '@sec-ant/readable-stream@0.4.1': {} - '@solid-primitives/keyboard@1.3.3(solid-js@1.9.10)': - dependencies: - '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10) - '@solid-primitives/rootless': 1.5.2(solid-js@1.9.10) - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) - solid-js: 1.9.10 + '@sinclair/typebox@0.34.41': {} - '@solid-primitives/resize-observer@2.1.3(solid-js@1.9.10)': - dependencies: - '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10) - '@solid-primitives/rootless': 1.5.2(solid-js@1.9.10) - '@solid-primitives/static-store': 0.1.2(solid-js@1.9.10) - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) - solid-js: 1.9.10 + '@sindresorhus/merge-streams@2.3.0': {} - '@solid-primitives/rootless@1.5.2(solid-js@1.9.10)': - dependencies: - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) - solid-js: 1.9.10 + '@sindresorhus/merge-streams@4.0.0': {} - '@solid-primitives/static-store@0.1.2(solid-js@1.9.10)': + '@smithy/abort-controller@4.2.7': dependencies: - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) - solid-js: 1.9.10 + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': + '@smithy/config-resolver@4.4.5': dependencies: - solid-js: 1.9.10 + '@smithy/node-config-provider': 4.3.7 + '@smithy/types': 4.11.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-endpoints': 3.2.7 + '@smithy/util-middleware': 4.2.7 + tslib: 2.8.1 - '@standard-schema/spec@1.0.0': {} + '@smithy/core@3.20.0': + dependencies: + '@smithy/middleware-serde': 4.2.8 + '@smithy/protocol-http': 5.3.7 + '@smithy/types': 4.11.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.7 + '@smithy/util-stream': 4.5.8 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 - '@swc/helpers@0.5.15': + '@smithy/credential-provider-imds@4.2.7': dependencies: + '@smithy/node-config-provider': 4.3.7 + '@smithy/property-provider': 4.2.7 + '@smithy/types': 4.11.0 + '@smithy/url-parser': 4.2.7 tslib: 2.8.1 - '@tailwindcss/node@4.1.17': + '@smithy/fetch-http-handler@5.3.8': dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.18.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.1.17 - - '@tailwindcss/oxide-android-arm64@4.1.17': - optional: true + '@smithy/protocol-http': 5.3.7 + '@smithy/querystring-builder': 4.2.7 + '@smithy/types': 4.11.0 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 - '@tailwindcss/oxide-darwin-arm64@4.1.17': - optional: true + '@smithy/hash-node@4.2.7': + dependencies: + '@smithy/types': 4.11.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 - '@tailwindcss/oxide-darwin-x64@4.1.17': - optional: true + '@smithy/invalid-dependency@4.2.7': + dependencies: + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@tailwindcss/oxide-freebsd-x64@4.1.17': - optional: true + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': - optional: true + '@smithy/is-array-buffer@4.2.0': + dependencies: + tslib: 2.8.1 - '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': - optional: true + '@smithy/middleware-content-length@4.2.7': + dependencies: + '@smithy/protocol-http': 5.3.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@tailwindcss/oxide-linux-arm64-musl@4.1.17': - optional: true + '@smithy/middleware-endpoint@4.4.1': + dependencies: + '@smithy/core': 3.20.0 + '@smithy/middleware-serde': 4.2.8 + '@smithy/node-config-provider': 4.3.7 + '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/types': 4.11.0 + '@smithy/url-parser': 4.2.7 + '@smithy/util-middleware': 4.2.7 + tslib: 2.8.1 - '@tailwindcss/oxide-linux-x64-gnu@4.1.17': - optional: true + '@smithy/middleware-retry@4.4.17': + dependencies: + '@smithy/node-config-provider': 4.3.7 + '@smithy/protocol-http': 5.3.7 + '@smithy/service-error-classification': 4.2.7 + '@smithy/smithy-client': 4.10.2 + '@smithy/types': 4.11.0 + '@smithy/util-middleware': 4.2.7 + '@smithy/util-retry': 4.2.7 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 - '@tailwindcss/oxide-linux-x64-musl@4.1.17': - optional: true + '@smithy/middleware-serde@4.2.8': + dependencies: + '@smithy/protocol-http': 5.3.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@tailwindcss/oxide-wasm32-wasi@4.1.17': - optional: true + '@smithy/middleware-stack@4.2.7': + dependencies: + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': - optional: true + '@smithy/node-config-provider@4.3.7': + dependencies: + '@smithy/property-provider': 4.2.7 + '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@tailwindcss/oxide-win32-x64-msvc@4.1.17': - optional: true + '@smithy/node-http-handler@4.4.7': + dependencies: + '@smithy/abort-controller': 4.2.7 + '@smithy/protocol-http': 5.3.7 + '@smithy/querystring-builder': 4.2.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@tailwindcss/oxide@4.1.17': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.17 - '@tailwindcss/oxide-darwin-arm64': 4.1.17 - '@tailwindcss/oxide-darwin-x64': 4.1.17 - '@tailwindcss/oxide-freebsd-x64': 4.1.17 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.17 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.17 - '@tailwindcss/oxide-linux-x64-musl': 4.1.17 - '@tailwindcss/oxide-wasm32-wasi': 4.1.17 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.17 + '@smithy/property-provider@4.2.7': + dependencies: + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@tailwindcss/postcss@4.1.17': + '@smithy/protocol-http@5.3.7': dependencies: - '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.1.17 - '@tailwindcss/oxide': 4.1.17 - postcss: 8.5.6 - tailwindcss: 4.1.17 + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@tailwindcss/vite@4.1.17(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + '@smithy/querystring-builder@4.2.7': dependencies: - '@tailwindcss/node': 4.1.17 - '@tailwindcss/oxide': 4.1.17 - tailwindcss: 4.1.17 - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + '@smithy/types': 4.11.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 - '@tailwindcss/vite@4.1.17(vite@7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + '@smithy/querystring-parser@4.2.7': dependencies: - '@tailwindcss/node': 4.1.17 - '@tailwindcss/oxide': 4.1.17 - tailwindcss: 4.1.17 - vite: 7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@tanstack/devtools-client@0.0.3': + '@smithy/service-error-classification@4.2.7': dependencies: - '@tanstack/devtools-event-client': 0.3.5 + '@smithy/types': 4.11.0 - '@tanstack/devtools-client@0.0.4': + '@smithy/shared-ini-file-loader@4.4.2': dependencies: - '@tanstack/devtools-event-client': 0.3.5 + '@smithy/types': 4.11.0 + tslib: 2.8.1 - '@tanstack/devtools-event-bus@0.3.3': + '@smithy/signature-v4@5.3.7': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.7 + '@smithy/types': 4.11.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.7 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.10.2': + dependencies: + '@smithy/core': 3.20.0 + '@smithy/middleware-endpoint': 4.4.1 + '@smithy/middleware-stack': 4.2.7 + '@smithy/protocol-http': 5.3.7 + '@smithy/types': 4.11.0 + '@smithy/util-stream': 4.5.8 + tslib: 2.8.1 + + '@smithy/types@4.11.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.7': + dependencies: + '@smithy/querystring-parser': 4.2.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.16': + dependencies: + '@smithy/property-provider': 4.2.7 + '@smithy/smithy-client': 4.10.2 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.19': + dependencies: + '@smithy/config-resolver': 4.4.5 + '@smithy/credential-provider-imds': 4.2.7 + '@smithy/node-config-provider': 4.3.7 + '@smithy/property-provider': 4.2.7 + '@smithy/smithy-client': 4.10.2 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.2.7': + dependencies: + '@smithy/node-config-provider': 4.3.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.7': + dependencies: + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.7': + dependencies: + '@smithy/service-error-classification': 4.2.7 + '@smithy/types': 4.11.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.8': + dependencies: + '@smithy/fetch-http-handler': 5.3.8 + '@smithy/node-http-handler': 4.4.7 + '@smithy/types': 4.11.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + tslib: 2.8.1 + + '@smithy/uuid@1.1.0': + dependencies: + tslib: 2.8.1 + + '@solid-primitives/event-listener@2.4.3(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/keyboard@1.3.3(solid-js@1.9.10)': + dependencies: + '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10) + '@solid-primitives/rootless': 1.5.2(solid-js@1.9.10) + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/rootless@1.5.2(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + + '@standard-schema/spec@1.1.0': {} + + '@swc/core-darwin-arm64@1.15.3': + optional: true + + '@swc/core-darwin-x64@1.15.3': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.3': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.3': + optional: true + + '@swc/core-linux-arm64-musl@1.15.3': + optional: true + + '@swc/core-linux-x64-gnu@1.15.3': + optional: true + + '@swc/core-linux-x64-musl@1.15.3': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.3': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.3': + optional: true + + '@swc/core-win32-x64-msvc@1.15.3': + optional: true + + '@swc/core@1.15.3': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.3 + '@swc/core-darwin-x64': 1.15.3 + '@swc/core-linux-arm-gnueabihf': 1.15.3 + '@swc/core-linux-arm64-gnu': 1.15.3 + '@swc/core-linux-arm64-musl': 1.15.3 + '@swc/core-linux-x64-gnu': 1.15.3 + '@swc/core-linux-x64-musl': 1.15.3 + '@swc/core-win32-arm64-msvc': 1.15.3 + '@swc/core-win32-ia32-msvc': 1.15.3 + '@swc/core-win32-x64-msvc': 1.15.3 + optional: true + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + optional: true + + '@tailwindcss/node@4.1.17': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.4 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.17 + + '@tailwindcss/node@4.1.18': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.4 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.18 + + '@tailwindcss/oxide-android-arm64@4.1.17': + optional: true + + '@tailwindcss/oxide-android-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.17': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.17': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.17': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.17': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.17': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide@4.1.17': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.17 + '@tailwindcss/oxide-darwin-arm64': 4.1.17 + '@tailwindcss/oxide-darwin-x64': 4.1.17 + '@tailwindcss/oxide-freebsd-x64': 4.1.17 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.17 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.17 + '@tailwindcss/oxide-linux-x64-musl': 4.1.17 + '@tailwindcss/oxide-wasm32-wasi': 4.1.17 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.17 + + '@tailwindcss/oxide@4.1.18': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + + '@tailwindcss/postcss@4.1.17': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.1.17 + '@tailwindcss/oxide': 4.1.17 + postcss: 8.5.6 + tailwindcss: 4.1.17 + + '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + tailwindcss: 4.1.18 + vite: 7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + '@tanstack/devtools-client@0.0.4': + dependencies: + '@tanstack/devtools-event-client': 0.3.5 + + '@tanstack/devtools-event-bus@0.3.2': + dependencies: + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@tanstack/devtools-event-bus@0.3.3': dependencies: ws: 8.18.3 transitivePeerDependencies: @@ -9954,7 +13252,7 @@ snapshots: '@tanstack/devtools-event-client@0.3.5': {} - '@tanstack/devtools-ui@0.4.4(csstype@3.2.3)(solid-js@1.9.10)': + '@tanstack/devtools-ui@0.3.5(csstype@3.2.3)(solid-js@1.9.10)': dependencies: clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) @@ -9962,7 +13260,7 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.3.11(vite@7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + '@tanstack/devtools-vite@0.3.11(vite@7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/generator': 7.28.5 @@ -9974,20 +13272,17 @@ snapshots: chalk: 5.6.2 launch-editor: 2.12.0 picomatch: 4.0.3 - vite: 7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@tanstack/devtools@0.7.0(csstype@3.2.3)(solid-js@1.9.10)': + '@tanstack/devtools@0.6.14(csstype@3.2.3)(solid-js@1.9.10)': dependencies: - '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10) '@solid-primitives/keyboard': 1.3.3(solid-js@1.9.10) - '@solid-primitives/resize-observer': 2.1.3(solid-js@1.9.10) - '@tanstack/devtools-client': 0.0.3 - '@tanstack/devtools-event-bus': 0.3.3 - '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.10) + '@tanstack/devtools-event-bus': 0.3.2 + '@tanstack/devtools-ui': 0.3.5(csstype@3.2.3)(solid-js@1.9.10) clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) solid-js: 1.9.10 @@ -9996,13 +13291,15 @@ snapshots: - csstype - utf-8-validate - '@tanstack/history@1.139.0': {} + '@tanstack/history@1.132.0': {} + + '@tanstack/history@1.141.0': {} - '@tanstack/react-devtools@0.7.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.10)': + '@tanstack/react-devtools@0.7.0(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(csstype@3.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.10)': dependencies: - '@tanstack/devtools': 0.7.0(csstype@3.2.3)(solid-js@1.9.10) - '@types/react': 19.2.7 - '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@tanstack/devtools': 0.6.14(csstype@3.2.3)(solid-js@1.9.10) + '@types/react': 19.2.0 + '@types/react-dom': 19.2.0(@types/react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) transitivePeerDependencies: @@ -10011,16 +13308,15 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-router-devtools@1.139.14(@tanstack/react-router@1.139.14(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@tanstack/router-core@1.139.14)(@types/node@22.19.1)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.10)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)': + '@tanstack/react-router-devtools@1.132.0(@tanstack/react-router@1.132.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@tanstack/router-core@1.143.3)(@types/node@22.10.2)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.10)(terser@5.44.1)(tiny-invariant@1.3.3)(tsx@4.21.0)(yaml@2.8.2)': dependencies: - '@tanstack/react-router': 1.139.14(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@tanstack/router-devtools-core': 1.139.14(@tanstack/router-core@1.139.14)(@types/node@22.19.1)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(solid-js@1.9.10)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + '@tanstack/react-router': 1.132.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/router-devtools-core': 1.132.0(@tanstack/router-core@1.143.3)(@types/node@22.10.2)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(solid-js@1.9.10)(terser@5.44.1)(tiny-invariant@1.3.3)(tsx@4.21.0)(yaml@2.8.2) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - vite: 7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) - optionalDependencies: - '@tanstack/router-core': 1.139.14 + vite: 7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: + - '@tanstack/router-core' - '@types/node' - csstype - jiti @@ -10032,45 +13328,56 @@ snapshots: - stylus - sugarss - terser + - tiny-invariant - tsx - yaml - '@tanstack/react-router@1.139.14(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@tanstack/react-router@1.132.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@tanstack/history': 1.139.0 - '@tanstack/react-store': 0.8.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@tanstack/router-core': 1.139.14 + '@tanstack/history': 1.132.0 + '@tanstack/react-store': 0.7.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/router-core': 1.132.0 isbot: 5.1.32 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-store@0.8.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@tanstack/react-store@0.7.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@tanstack/store': 0.8.0 + '@tanstack/store': 0.7.7 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) use-sync-external-store: 1.6.0(react@19.2.0) - '@tanstack/router-core@1.139.14': + '@tanstack/router-core@1.132.0': + dependencies: + '@tanstack/history': 1.132.0 + '@tanstack/store': 0.7.7 + cookie-es: 1.2.2 + seroval: 1.4.1 + seroval-plugins: 1.4.0(seroval@1.4.1) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/router-core@1.143.3': dependencies: - '@tanstack/history': 1.139.0 + '@tanstack/history': 1.141.0 '@tanstack/store': 0.8.0 cookie-es: 2.0.0 - seroval: 1.4.0 - seroval-plugins: 1.4.0(seroval@1.4.0) + seroval: 1.4.1 + seroval-plugins: 1.4.0(seroval@1.4.1) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.139.14(@tanstack/router-core@1.139.14)(@types/node@22.19.1)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(solid-js@1.9.10)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)': + '@tanstack/router-devtools-core@1.132.0(@tanstack/router-core@1.143.3)(@types/node@22.10.2)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(solid-js@1.9.10)(terser@5.44.1)(tiny-invariant@1.3.3)(tsx@4.21.0)(yaml@2.8.2)': dependencies: - '@tanstack/router-core': 1.139.14 + '@tanstack/router-core': 1.143.3 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) solid-js: 1.9.10 tiny-invariant: 1.3.3 - vite: 7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: csstype: 3.2.3 transitivePeerDependencies: @@ -10086,20 +13393,20 @@ snapshots: - tsx - yaml - '@tanstack/router-generator@1.139.14': + '@tanstack/router-generator@1.132.0': dependencies: - '@tanstack/router-core': 1.139.14 - '@tanstack/router-utils': 1.139.0 - '@tanstack/virtual-file-routes': 1.139.0 + '@tanstack/router-core': 1.132.0 + '@tanstack/router-utils': 1.132.0 + '@tanstack/virtual-file-routes': 1.132.0 prettier: 3.6.2 recast: 0.23.11 source-map: 0.7.6 - tsx: 4.20.6 + tsx: 4.21.0 zod: 3.25.76 transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.139.14(@tanstack/react-router@1.139.14(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))(webpack@5.103.0)': + '@tanstack/router-plugin@1.132.0(@tanstack/react-router@1.132.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -10107,22 +13414,21 @@ snapshots: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 - '@tanstack/router-core': 1.139.14 - '@tanstack/router-generator': 1.139.14 - '@tanstack/router-utils': 1.139.0 - '@tanstack/virtual-file-routes': 1.139.0 - babel-dead-code-elimination: 1.0.10 + '@tanstack/router-core': 1.132.0 + '@tanstack/router-generator': 1.132.0 + '@tanstack/router-utils': 1.132.0 + '@tanstack/virtual-file-routes': 1.132.0 + babel-dead-code-elimination: 1.0.11 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.139.14(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - vite: 7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) - webpack: 5.103.0(webpack-cli@6.0.1) + '@tanstack/react-router': 1.132.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + vite: 7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@tanstack/router-utils@1.139.0': + '@tanstack/router-utils@1.132.0': dependencies: '@babel/core': 7.28.5 '@babel/generator': 7.28.5 @@ -10130,14 +13436,27 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) ansis: 4.2.0 diff: 8.0.2 + fast-glob: 3.3.3 pathe: 2.0.3 - tinyglobby: 0.2.15 transitivePeerDependencies: - supports-color + '@tanstack/store@0.7.7': {} + '@tanstack/store@0.8.0': {} - '@tanstack/virtual-file-routes@1.139.0': {} + '@tanstack/virtual-file-routes@1.132.0': {} + + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 '@testing-library/dom@10.4.1': dependencies: @@ -10150,12 +13469,22 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@testing-library/react@16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@testing-library/dom': 10.4.1 + '@testing-library/dom': 10.4.0 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.0 + '@types/react-dom': 19.2.0(@types/react@19.2.0) + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) @@ -10193,25 +13522,32 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 22.19.1 - - '@types/bonjour@3.5.13': - dependencies: - '@types/node': 22.19.1 + '@types/node': 20.19.25 '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/connect-history-api-fallback@1.5.4': + '@types/chokidar@2.1.7': dependencies: - '@types/express-serve-static-core': 4.19.7 - '@types/node': 22.19.1 + chokidar: 4.0.3 + + '@types/cli-progress@3.11.6': + dependencies: + '@types/node': 20.19.25 '@types/connect@3.4.38': dependencies: - '@types/node': 22.19.1 + '@types/node': 20.19.25 + + '@types/conventional-commits-parser@5.0.2': + dependencies: + '@types/node': 20.19.25 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 20.19.25 '@types/debug@4.1.12': dependencies: @@ -10221,17 +13557,9 @@ snapshots: '@types/diff-match-patch@1.0.36': {} - '@types/ejs@3.1.5': {} - - '@types/eslint-scope@3.7.7': - dependencies: - '@types/eslint': 9.6.1 - '@types/estree': 1.0.8 + '@types/diff@7.0.0': {} - '@types/eslint@9.6.1': - dependencies: - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 + '@types/ejs@3.1.5': {} '@types/estree-jsx@1.0.5': dependencies: @@ -10241,29 +13569,62 @@ snapshots: '@types/express-serve-static-core@4.19.7': dependencies: - '@types/node': 22.19.1 + '@types/node': 20.19.25 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 - '@types/express@4.17.25': + '@types/express-serve-static-core@5.1.0': + dependencies: + '@types/node': 20.19.25 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.21': dependencies: '@types/body-parser': 1.19.6 '@types/express-serve-static-core': 4.19.7 '@types/qs': 6.14.0 + '@types/serve-static': 2.2.0 + + '@types/express@5.0.5': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.0 '@types/serve-static': 1.15.10 + '@types/figlet@1.7.0': {} + + '@types/geojson@7946.0.16': {} + + '@types/gettext-parser@4.0.4': + dependencies: + '@types/node': 20.19.25 + '@types/readable-stream': 4.0.23 + + '@types/glob@8.1.0': + dependencies: + '@types/minimatch': 5.1.2 + '@types/node': 20.19.25 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 - '@types/html-minifier-terser@6.1.0': {} - '@types/http-errors@2.0.5': {} - '@types/http-proxy@1.17.17': + '@types/ini@4.1.1': {} + + '@types/is-url@1.2.32': {} + + '@types/js-cookie@3.0.6': {} + + '@types/jsdom@21.1.7': dependencies: - '@types/node': 22.19.1 + '@types/node': 20.19.25 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 '@types/json-schema@7.0.15': {} @@ -10272,6 +13633,8 @@ snapshots: '@types/linkify-it@3.0.5': optional: true + '@types/lodash@4.17.21': {} + '@types/markdown-it@12.2.3': dependencies: '@types/linkify-it': 3.0.5 @@ -10287,21 +13650,47 @@ snapshots: '@types/mime@1.3.5': {} + '@types/minimatch@5.1.2': {} + '@types/ms@2.1.0': {} - '@types/node-forge@1.3.14': - dependencies: - '@types/node': 22.19.1 + '@types/node-gettext@3.0.6': {} + + '@types/node@12.20.55': {} '@types/node@20.19.25': dependencies: undici-types: 6.21.0 - '@types/node@22.19.1': + '@types/node@22.10.2': dependencies: - undici-types: 6.21.0 + undici-types: 6.20.0 - '@types/prop-types@15.7.15': {} + '@types/node@22.13.5': + dependencies: + undici-types: 6.20.0 + + '@types/node@24.0.10': + dependencies: + undici-types: 7.8.0 + + '@types/node@25.0.3': + dependencies: + undici-types: 7.16.0 + + '@types/nodemailer@7.0.3': + dependencies: + '@aws-sdk/client-sesv2': 3.958.0 + '@types/node': 20.19.25 + transitivePeerDependencies: + - aws-crt + + '@types/object-hash@3.0.6': {} + + '@types/plist@3.0.5': + dependencies: + '@types/node': 20.19.25 + xmlbuilder: 15.1.1 '@types/proper-lockfile@4.1.4': dependencies: @@ -10311,76 +13700,74 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/react-dom@19.2.3(@types/react@18.3.27)': + '@types/react-dom@19.2.0(@types/react@19.2.0)': dependencies: - '@types/react': 18.3.27 - - '@types/react-dom@19.2.3(@types/react@19.2.6)': - dependencies: - '@types/react': 19.2.6 + '@types/react': 19.2.0 '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 - '@types/react@18.3.27': + '@types/react@19.2.0': dependencies: - '@types/prop-types': 15.7.15 csstype: 3.2.3 - '@types/react@19.2.6': + '@types/react@19.2.7': dependencies: csstype: 3.2.3 - '@types/react@19.2.7': + '@types/readable-stream@4.0.23': dependencies: - csstype: 3.2.3 + '@types/node': 20.19.25 - '@types/retry@0.12.2': {} + '@types/resolve@1.20.2': {} '@types/retry@0.12.5': {} '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 22.19.1 + '@types/node': 20.19.25 '@types/send@1.2.1': dependencies: - '@types/node': 22.19.1 - - '@types/serve-index@1.9.4': - dependencies: - '@types/express': 4.17.25 + '@types/node': 20.19.25 '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 22.19.1 + '@types/node': 20.19.25 '@types/send': 0.17.6 - '@types/sockjs@0.3.36': + '@types/serve-static@2.2.0': dependencies: - '@types/node': 22.19.1 + '@types/http-errors': 2.0.5 + '@types/node': 20.19.25 '@types/tinycolor2@1.4.6': {} + '@types/tough-cookie@4.0.5': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} '@types/ws@8.18.1': dependencies: - '@types/node': 22.19.1 + '@types/node': 20.19.25 + + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 20.19.25 - '@typescript-eslint/eslint-plugin@8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.47.0 - '@typescript-eslint/type-utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.47.0 + '@typescript-eslint/parser': 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.48.0 + '@typescript-eslint/type-utils': 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.48.0 eslint: 9.39.1(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 @@ -10390,41 +13777,45 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.47.0 - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.47.0 + '@typescript-eslint/scope-manager': 8.48.0 + '@typescript-eslint/types': 8.48.0 + '@typescript-eslint/typescript-estree': 8.48.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.48.0 debug: 4.4.3 eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.47.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.48.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3) - '@typescript-eslint/types': 8.47.0 + '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) + '@typescript-eslint/types': 8.50.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.47.0': + '@typescript-eslint/scope-manager@8.48.0': + dependencies: + '@typescript-eslint/types': 8.48.0 + '@typescript-eslint/visitor-keys': 8.48.0 + + '@typescript-eslint/tsconfig-utils@8.48.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/visitor-keys': 8.47.0 + typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.47.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.48.0 + '@typescript-eslint/typescript-estree': 8.48.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) @@ -10432,42 +13823,66 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.47.0': {} + '@typescript-eslint/types@8.48.0': {} - '@typescript-eslint/typescript-estree@8.47.0(typescript@5.9.3)': + '@typescript-eslint/types@8.50.1': {} + + '@typescript-eslint/typescript-estree@8.48.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.47.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3) - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/visitor-keys': 8.47.0 + '@typescript-eslint/project-service': 8.48.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.9.3) + '@typescript-eslint/types': 8.48.0 + '@typescript-eslint/visitor-keys': 8.48.0 debug: 4.4.3 - fast-glob: 3.3.3 - is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.7.3 + tinyglobby: 0.2.15 ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.47.0 - '@typescript-eslint/types': 8.47.0 - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.48.0 + '@typescript-eslint/types': 8.48.0 + '@typescript-eslint/typescript-estree': 8.48.0(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.47.0': + '@typescript-eslint/visitor-keys@8.48.0': dependencies: - '@typescript-eslint/types': 8.47.0 + '@typescript-eslint/types': 8.48.0 eslint-visitor-keys: 4.2.1 '@ungap/structured-clone@1.3.0': {} + '@unhead/dom@1.11.20': + dependencies: + '@unhead/schema': 1.11.20 + '@unhead/shared': 1.11.20 + + '@unhead/schema@1.11.20': + dependencies: + hookable: 5.5.3 + zhead: 2.2.4 + + '@unhead/shared@1.11.20': + dependencies: + '@unhead/schema': 1.11.20 + packrup: 0.1.2 + + '@unhead/vue@1.11.20(vue@3.5.24(typescript@5.9.3))': + dependencies: + '@unhead/schema': 1.11.20 + '@unhead/shared': 1.11.20 + hookable: 5.5.3 + unhead: 1.11.20 + vue: 3.5.24(typescript@5.9.3) + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -10527,186 +13942,366 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@5.1.1(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + '@vercel/oidc@3.0.5': {} + + '@vercel/oidc@3.1.0': {} + + '@vitejs/plugin-react@4.4.1(vite@6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) - '@rolldown/pluginutils': 1.0.0-beta.47 '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + react-refresh: 0.17.0 + vite: 6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.1.1(vite@7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + '@vitejs/plugin-react@5.0.4(vite@7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) - '@rolldown/pluginutils': 1.0.0-beta.47 + '@rolldown/pluginutils': 1.0.0-beta.38 '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + react-refresh: 0.17.0 + vite: 7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/expect@4.0.14': + '@vitejs/plugin-vue@6.0.1(vite@7.1.12(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.24(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.29 + vite: 7.1.12(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vue: 3.5.24(typescript@5.9.3) + + '@vitest/expect@3.1.1': + dependencies: + '@vitest/spy': 3.1.1 + '@vitest/utils': 3.1.1 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/expect@3.1.2': + dependencies: + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/expect@3.2.4': dependencies: - '@standard-schema/spec': 1.0.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 - chai: 6.2.1 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/expect@4.0.13': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.13 + '@vitest/utils': 4.0.13 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/expect@4.0.16': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/mocker@3.1.1(vite@6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.1.1 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/mocker@3.1.2(vite@6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 3.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + + '@vitest/mocker@3.1.2(vite@6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/mocker@3.1.2(vite@6.3.5(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.3.5(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/mocker@3.1.2(vite@6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vitest/spy': 4.0.14 + '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.3.0(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/pretty-format@4.0.14': + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@24.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + + '@vitest/mocker@4.0.13(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.13 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.16 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/pretty-format@3.1.1': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/pretty-format@3.1.2': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/pretty-format@4.0.13': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/pretty-format@4.0.16': dependencies: tinyrainbow: 3.0.3 - '@vitest/runner@4.0.14': + '@vitest/runner@3.1.1': + dependencies: + '@vitest/utils': 3.1.1 + pathe: 2.0.3 + + '@vitest/runner@3.1.2': + dependencies: + '@vitest/utils': 3.1.2 + pathe: 2.0.3 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/runner@4.0.13': + dependencies: + '@vitest/utils': 4.0.13 + pathe: 2.0.3 + + '@vitest/runner@4.0.16': dependencies: - '@vitest/utils': 4.0.14 + '@vitest/utils': 4.0.16 pathe: 2.0.3 - '@vitest/snapshot@4.0.14': + '@vitest/snapshot@3.1.1': dependencies: - '@vitest/pretty-format': 4.0.14 + '@vitest/pretty-format': 3.1.1 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.14': {} + '@vitest/snapshot@3.1.2': + dependencies: + '@vitest/pretty-format': 3.1.2 + magic-string: 0.30.21 + pathe: 2.0.3 - '@vitest/utils@4.0.14': + '@vitest/snapshot@3.2.4': dependencies: - '@vitest/pretty-format': 4.0.14 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.13': + dependencies: + '@vitest/pretty-format': 4.0.13 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.1.1': + dependencies: + tinyspy: 3.0.2 - '@webassemblyjs/ast@1.14.1': + '@vitest/spy@3.1.2': dependencies: - '@webassemblyjs/helper-numbers': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + tinyspy: 3.0.2 - '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/spy@4.0.13': {} - '@webassemblyjs/helper-api-error@1.13.2': {} + '@vitest/spy@4.0.16': {} - '@webassemblyjs/helper-buffer@1.14.1': {} + '@vitest/ui@3.2.4(vitest@3.2.4)': + dependencies: + '@vitest/utils': 3.2.4 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.13.5)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@webassemblyjs/helper-numbers@1.13.2': + '@vitest/ui@4.0.16(vitest@4.0.16)': dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.13.2 - '@webassemblyjs/helper-api-error': 1.13.2 - '@xtuc/long': 4.2.2 + '@vitest/utils': 4.0.16 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + optional: true - '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + '@vitest/utils@3.1.1': + dependencies: + '@vitest/pretty-format': 3.1.1 + loupe: 3.2.1 + tinyrainbow: 2.0.0 - '@webassemblyjs/helper-wasm-section@1.14.1': + '@vitest/utils@3.1.2': dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/wasm-gen': 1.14.1 + '@vitest/pretty-format': 3.1.2 + loupe: 3.2.1 + tinyrainbow: 2.0.0 - '@webassemblyjs/ieee754@1.13.2': + '@vitest/utils@3.2.4': dependencies: - '@xtuc/ieee754': 1.2.0 + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 - '@webassemblyjs/leb128@1.13.2': + '@vitest/utils@4.0.13': dependencies: - '@xtuc/long': 4.2.2 + '@vitest/pretty-format': 4.0.13 + tinyrainbow: 3.0.3 - '@webassemblyjs/utf8@1.13.2': {} + '@vitest/utils@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + tinyrainbow: 3.0.3 - '@webassemblyjs/wasm-edit@1.14.1': + '@vue/compiler-core@3.5.24': dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/helper-wasm-section': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-opt': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - '@webassemblyjs/wast-printer': 1.14.1 + '@babel/parser': 7.28.5 + '@vue/shared': 3.5.24 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 - '@webassemblyjs/wasm-gen@1.14.1': + '@vue/compiler-dom@3.5.24': dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 + '@vue/compiler-core': 3.5.24 + '@vue/shared': 3.5.24 - '@webassemblyjs/wasm-opt@1.14.1': + '@vue/compiler-sfc@3.5.24': dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 + '@babel/parser': 7.28.5 + '@vue/compiler-core': 3.5.24 + '@vue/compiler-dom': 3.5.24 + '@vue/compiler-ssr': 3.5.24 + '@vue/shared': 3.5.24 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 - '@webassemblyjs/wasm-parser@1.14.1': + '@vue/compiler-ssr@3.5.24': dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-api-error': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 + '@vue/compiler-dom': 3.5.24 + '@vue/shared': 3.5.24 + + '@vue/devtools-api@6.6.4': {} - '@webassemblyjs/wast-printer@1.14.1': + '@vue/reactivity@3.5.24': dependencies: - '@webassemblyjs/ast': 1.14.1 - '@xtuc/long': 4.2.2 + '@vue/shared': 3.5.24 - '@webpack-cli/configtest@3.0.1(webpack-cli@6.0.1)(webpack@5.103.0)': + '@vue/runtime-core@3.5.24': dependencies: - webpack: 5.103.0(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.103.0) + '@vue/reactivity': 3.5.24 + '@vue/shared': 3.5.24 - '@webpack-cli/info@3.0.1(webpack-cli@6.0.1)(webpack@5.103.0)': + '@vue/runtime-dom@3.5.24': dependencies: - webpack: 5.103.0(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.103.0) + '@vue/reactivity': 3.5.24 + '@vue/runtime-core': 3.5.24 + '@vue/shared': 3.5.24 + csstype: 3.2.3 - '@webpack-cli/serve@3.0.1(webpack-cli@6.0.1)(webpack-dev-server@5.2.2)(webpack@5.103.0)': + '@vue/server-renderer@3.5.24(vue@3.5.24(typescript@5.9.3))': dependencies: - webpack: 5.103.0(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.103.0) - optionalDependencies: - webpack-dev-server: 5.2.2(webpack-cli@6.0.1)(webpack@5.103.0) + '@vue/compiler-ssr': 3.5.24 + '@vue/shared': 3.5.24 + vue: 3.5.24(typescript@5.9.3) - '@xmldom/xmldom@0.8.11': {} + '@vue/shared@3.5.24': {} - '@xtuc/ieee754@1.2.0': {} + '@xmldom/xmldom@0.8.11': {} - '@xtuc/long@4.2.2': {} + JSONStream@1.3.5: + dependencies: + jsonparse: 1.3.1 + through: 2.3.8 abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - accepts@2.0.0: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-import-phases@1.0.4(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -10715,55 +14310,59 @@ snapshots: agent-base@7.1.4: {} - ai@4.2.10(react@19.2.0)(zod@3.25.76): + ai-sdk-ollama@3.0.0(ai@6.0.3(zod@4.1.12))(zod@4.1.12): + dependencies: + '@ai-sdk/provider': 3.0.0 + '@ai-sdk/provider-utils': 4.0.1(zod@4.1.12) + ai: 6.0.3(zod@4.1.12) + ollama: 0.6.3 + transitivePeerDependencies: + - zod + + ai@4.2.10(react@19.2.3)(zod@3.25.76): dependencies: '@ai-sdk/provider': 1.1.0 '@ai-sdk/provider-utils': 2.2.3(zod@3.25.76) - '@ai-sdk/react': 1.2.5(react@19.2.0)(zod@3.25.76) + '@ai-sdk/react': 1.2.5(react@19.2.3)(zod@3.25.76) '@ai-sdk/ui-utils': 1.2.4(zod@3.25.76) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 zod: 3.25.76 optionalDependencies: - react: 19.2.0 + react: 19.2.3 - ai@4.3.15(react@19.2.0)(zod@3.25.76): + ai@4.3.15(react@19.2.3)(zod@3.25.76): dependencies: '@ai-sdk/provider': 1.1.3 '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - '@ai-sdk/react': 1.2.12(react@19.2.0)(zod@3.25.76) + '@ai-sdk/react': 1.2.12(react@19.2.3)(zod@3.25.76) '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 zod: 3.25.76 optionalDependencies: - react: 19.2.0 + react: 19.2.3 - ai@4.3.19(react@19.2.1)(zod@3.25.76): + ai@6.0.25(zod@4.1.12): dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - '@ai-sdk/react': 1.2.12(react@19.2.1)(zod@3.25.76) - '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) + '@ai-sdk/gateway': 3.0.10(zod@4.1.12) + '@ai-sdk/provider': 3.0.2 + '@ai-sdk/provider-utils': 4.0.4(zod@4.1.12) '@opentelemetry/api': 1.9.0 - jsondiffpatch: 0.6.0 - zod: 3.25.76 - optionalDependencies: - react: 19.2.1 + zod: 4.1.12 - ajv-formats@2.1.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 + ai@6.0.3(zod@4.1.12): + dependencies: + '@ai-sdk/gateway': 3.0.2(zod@4.1.12) + '@ai-sdk/provider': 3.0.0 + '@ai-sdk/provider-utils': 4.0.1(zod@4.1.12) + '@opentelemetry/api': 1.9.0 + zod: 4.1.12 ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 - ajv-keywords@5.1.0(ajv@8.17.1): - dependencies: - ajv: 8.17.1 - fast-deep-equal: 3.1.3 - ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -10778,6 +14377,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-colors@4.1.3: {} + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -10788,12 +14389,16 @@ snapshots: dependencies: environment: 1.1.0 - ansi-html-community@0.0.8: {} + ansi-regex@4.1.1: {} ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -10804,13 +14409,13 @@ snapshots: ansis@4.2.0: {} + any-promise@1.3.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 - arg@5.0.2: {} - argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -10828,24 +14433,26 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - array-flatten@1.1.1: {} + array-ify@1.0.0: {} array-includes@3.1.9: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 is-string: 1.1.1 math-intrinsics: 1.1.0 + array-union@2.1.0: {} + array.prototype.findlast@1.2.5: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-object-atoms: 1.1.1 es-shim-unscopables: 1.1.0 @@ -10855,7 +14462,7 @@ snapshots: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-object-atoms: 1.1.1 es-shim-unscopables: 1.1.0 @@ -10864,21 +14471,21 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-shim-unscopables: 1.1.0 array.prototype.flatmap@1.3.3: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-shim-unscopables: 1.1.0 array.prototype.tosorted@1.1.4: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-shim-unscopables: 1.1.0 @@ -10887,7 +14494,7 @@ snapshots: array-buffer-byte-length: 1.0.2 call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 @@ -10913,14 +14520,15 @@ snapshots: asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} + auto-bind@5.0.1: {} - autoprefixer@10.4.22(postcss@8.5.6): + autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001756 + caniuse-lite: 1.0.30001761 fraction.js: 5.3.4 - normalize-range: 0.1.2 picocolors: 1.1.1 postcss: 8.5.6 postcss-value-parser: 4.2.0 @@ -10931,72 +14539,48 @@ snapshots: axe-core@4.11.0: {} + axios@1.12.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} - babel-dead-code-elimination@1.0.10: + babel-dead-code-elimination@1.0.11: dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.0 '@babel/parser': 7.28.5 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color - babel-loader@10.0.0(@babel/core@7.28.5)(webpack@5.103.0): - dependencies: - '@babel/core': 7.28.5 - find-up: 5.0.0 - webpack: 5.103.0(webpack-cli@6.0.1) - - babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.5): - dependencies: - '@babel/compat-data': 7.28.5 - '@babel/core': 7.28.5 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.5) - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.28.5): - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.5) - core-js-compat: 3.47.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-polyfill-regenerator@0.6.5(@babel/core@7.28.5): - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.5) - transitivePeerDependencies: - - supports-color - - bail@2.0.2: {} + bail@2.0.2: {} balanced-match@1.0.2: {} base64-js@1.5.1: {} - baseline-browser-mapping@2.9.8: {} - - basic-auth@2.0.1: - dependencies: - safe-buffer: 5.1.2 - - batch@0.6.1: {} + baseline-browser-mapping@2.9.11: {} before-after-hook@2.2.3: {} before-after-hook@3.0.2: {} + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 binary-extensions@2.3.0: {} - birpc@2.8.0: {} + birpc@4.0.0: {} bitbucket@2.12.0(encoding@0.1.13): dependencies: @@ -11010,46 +14594,26 @@ snapshots: blacklist@1.1.4: {} - body-parser@1.20.3: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - body-parser@2.2.0: + body-parser@2.2.1: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 4.4.3 - http-errors: 2.0.0 - iconv-lite: 0.6.3 + http-errors: 2.0.1 + iconv-lite: 0.7.1 on-finished: 2.4.1 qs: 6.14.0 - raw-body: 3.0.1 + raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: - supports-color - bonjour-service@1.3.0: - dependencies: - fast-deep-equal: 3.1.3 - multicast-dns: 7.2.5 - boolbase@1.0.0: {} bottleneck@2.19.5: {} + bowser@2.13.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -11065,8 +14629,8 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.8 - caniuse-lite: 1.0.30001760 + baseline-browser-mapping: 2.9.11 + caniuse-lite: 1.0.30001761 electron-to-chromium: 1.5.267 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -11082,6 +14646,15 @@ snapshots: dependencies: run-applescript: 7.1.0 + bundle-require@5.1.0(esbuild@0.27.2): + dependencies: + esbuild: 0.27.2 + load-tsconfig: 0.2.5 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + bytes@3.1.2: {} cac@6.7.14: {} @@ -11105,20 +14678,34 @@ snapshots: callsites@3.1.0: {} - camel-case@4.1.2: - dependencies: - pascal-case: 3.1.2 - tslib: 2.8.1 + camelcase@5.3.1: {} camelcase@8.0.0: {} - caniuse-lite@1.0.30001756: {} + caniuse-api@3.0.0: + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001761 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001760: {} + caniuse-lite@1.0.30001761: {} ccount@2.0.1: {} - chai@6.2.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chai@6.2.2: {} + + chalk-template@1.1.2: + dependencies: + chalk: 5.6.2 chalk@4.1.2: dependencies: @@ -11139,6 +14726,15 @@ snapshots: chardet@2.1.1: {} + check-error@2.1.1: {} + + chokidar-cli@3.0.0: + dependencies: + chokidar: 3.6.0 + lodash.debounce: 4.0.8 + lodash.throttle: 4.1.1 + yargs: 13.3.2 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -11155,13 +14751,11 @@ snapshots: dependencies: readdirp: 4.1.2 - chrome-trace-event@1.0.4: {} - ci-info@3.9.0: {} - clean-css@5.3.3: + citty@0.1.6: dependencies: - source-map: 0.6.1 + consola: 3.4.2 cli-boxes@3.0.0: {} @@ -11201,11 +14795,17 @@ snapshots: client-only@0.0.1: {} - clone-deep@4.0.1: + cliui@5.0.0: dependencies: - is-plain-object: 2.0.4 - kind-of: 6.0.3 - shallow-clone: 3.0.1 + string-width: 3.1.0 + strip-ansi: 5.2.0 + wrap-ansi: 5.1.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 clone@2.1.2: {} @@ -11215,12 +14815,22 @@ snapshots: dependencies: convert-to-spaces: 2.0.1 + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-name@1.1.3: {} + color-name@1.1.4: {} + colord@2.9.3: {} + + colorette@2.0.19: {} + colorette@2.0.20: {} combined-stream@1.0.8: @@ -11229,70 +14839,107 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + + commander@11.1.0: {} + + commander@12.0.0: {} + commander@12.1.0: {} + commander@13.1.0: {} + commander@14.0.2: {} commander@2.20.3: {} - commander@7.2.0: {} + commander@4.1.1: {} - commander@8.3.0: {} + commander@7.2.0: {} - compressible@2.0.18: + commitlint@19.8.1(@types/node@25.0.3)(typescript@5.9.3): dependencies: - mime-db: 1.54.0 + '@commitlint/cli': 19.8.1(@types/node@25.0.3)(typescript@5.9.3) + '@commitlint/types': 19.8.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + commondir@1.0.1: {} - compression@1.8.1: + compare-func@2.0.0: dependencies: - bytes: 3.1.2 - compressible: 2.0.18 - debug: 2.6.9 - negotiator: 0.6.4 - on-headers: 1.1.0 - safe-buffer: 5.2.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color + array-ify: 1.0.0 + dot-prop: 5.3.0 concat-map@0.0.1: {} - connect-history-api-fallback@2.0.0: {} + confbox@0.1.8: {} - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 + confbox@0.2.2: {} + + consola@3.4.2: {} content-disposition@1.0.1: {} content-type@1.0.5: {} + conventional-changelog-angular@7.0.0: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@7.0.2: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@5.0.0: + dependencies: + JSONStream: 1.3.5 + is-text-path: 2.0.0 + meow: 12.1.1 + split2: 4.2.0 + convert-source-map@2.0.0: {} convert-to-spaces@2.0.1: {} - cookie-es@2.0.0: {} + cookie-es@1.2.2: {} - cookie-signature@1.0.6: {} + cookie-es@2.0.0: {} cookie-signature@1.2.2: {} - cookie@0.7.1: {} - cookie@0.7.2: {} - cookie@1.0.2: {} + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 - core-js-compat@3.47.0: + cosmiconfig-typescript-loader@6.2.0(@types/node@25.0.3)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): dependencies: - browserslist: 4.28.1 + '@types/node': 25.0.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 - core-util-is@1.0.3: {} + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 - cors@2.8.5: + cosmiconfig@9.0.0(typescript@5.9.3): dependencies: - object-assign: 4.1.1 - vary: 1.1.2 + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 cross-spawn@7.0.6: dependencies: @@ -11300,27 +14947,23 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-loader@7.1.2(webpack@5.103.0): + css-declaration-sorter@7.3.0(postcss@8.5.6): dependencies: - icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) - postcss-modules-scope: 3.2.1(postcss@8.5.6) - postcss-modules-values: 4.0.0(postcss@8.5.6) - postcss-value-parser: 4.2.0 - semver: 7.7.3 - optionalDependencies: - webpack: 5.103.0(webpack-cli@6.0.1) - css-select@4.3.0: + css-select@5.2.2: dependencies: boolbase: 1.0.0 css-what: 6.2.2 - domhandler: 4.3.1 - domutils: 2.8.0 + domhandler: 5.0.3 + domutils: 3.2.2 nth-check: 2.1.1 + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + css-tree@3.1.0: dependencies: mdn-data: 2.12.2 @@ -11330,15 +14973,63 @@ snapshots: cssesc@3.0.0: {} + cssnano-preset-default@7.0.10(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + css-declaration-sorter: 7.3.0(postcss@8.5.6) + cssnano-utils: 5.0.1(postcss@8.5.6) + postcss: 8.5.6 + postcss-calc: 10.1.1(postcss@8.5.6) + postcss-colormin: 7.0.5(postcss@8.5.6) + postcss-convert-values: 7.0.8(postcss@8.5.6) + postcss-discard-comments: 7.0.5(postcss@8.5.6) + postcss-discard-duplicates: 7.0.2(postcss@8.5.6) + postcss-discard-empty: 7.0.1(postcss@8.5.6) + postcss-discard-overridden: 7.0.1(postcss@8.5.6) + postcss-merge-longhand: 7.0.5(postcss@8.5.6) + postcss-merge-rules: 7.0.7(postcss@8.5.6) + postcss-minify-font-values: 7.0.1(postcss@8.5.6) + postcss-minify-gradients: 7.0.1(postcss@8.5.6) + postcss-minify-params: 7.0.5(postcss@8.5.6) + postcss-minify-selectors: 7.0.5(postcss@8.5.6) + postcss-normalize-charset: 7.0.1(postcss@8.5.6) + postcss-normalize-display-values: 7.0.1(postcss@8.5.6) + postcss-normalize-positions: 7.0.1(postcss@8.5.6) + postcss-normalize-repeat-style: 7.0.1(postcss@8.5.6) + postcss-normalize-string: 7.0.1(postcss@8.5.6) + postcss-normalize-timing-functions: 7.0.1(postcss@8.5.6) + postcss-normalize-unicode: 7.0.5(postcss@8.5.6) + postcss-normalize-url: 7.0.1(postcss@8.5.6) + postcss-normalize-whitespace: 7.0.1(postcss@8.5.6) + postcss-ordered-values: 7.0.2(postcss@8.5.6) + postcss-reduce-initial: 7.0.5(postcss@8.5.6) + postcss-reduce-transforms: 7.0.1(postcss@8.5.6) + postcss-svgo: 7.1.0(postcss@8.5.6) + postcss-unique-selectors: 7.0.4(postcss@8.5.6) + + cssnano-utils@5.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + cssnano@7.1.2(postcss@8.5.6): + dependencies: + cssnano-preset-default: 7.0.10(postcss@8.5.6) + lilconfig: 3.1.3 + postcss: 8.5.6 + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 rrweb-cssom: 0.8.0 - cssstyle@5.3.3: + cssstyle@5.3.5: dependencies: - '@asamuzakjp/css-color': 4.1.0 - '@csstools/css-syntax-patches-for-csstree': 1.0.20 + '@asamuzakjp/css-color': 4.1.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.22 css-tree: 3.1.0 csstype@3.2.3: {} @@ -11349,6 +15040,8 @@ snapshots: damerau-levenshtein@1.0.8: {} + dargs@8.1.0: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -11377,28 +15070,38 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - date-fns@4.1.0: {} + dataloader@1.4.0: {} - debug@2.6.9: - dependencies: - ms: 2.0.0 + date-fns@4.1.0: {} debug@3.2.7: dependencies: ms: 2.1.3 + debug@4.3.4: + dependencies: + ms: 2.1.2 + debug@4.4.3: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + + decamelize@6.0.1: {} + decimal.js@10.6.0: {} decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 + decode-uri-component@0.4.1: {} + dedent@1.7.0: {} + deep-eql@5.0.2: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -11424,20 +15127,20 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - delayed-stream@1.0.0: {} + defu@6.1.4: {} - depd@1.1.2: {} + delayed-stream@1.0.0: {} depd@2.0.0: {} + deprecation@2.3.1: {} + dequal@2.0.3: {} - destroy@1.2.0: {} + detect-indent@6.1.0: {} detect-libc@2.1.2: {} - detect-node@2.1.0: {} - devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -11448,9 +15151,9 @@ snapshots: diff@8.0.2: {} - dns-packet@5.6.1: + dir-glob@3.0.1: dependencies: - '@leichtgewicht/ip-codec': 2.0.5 + path-type: 4.0.0 doctrine@2.1.0: dependencies: @@ -11458,38 +15161,35 @@ snapshots: dom-accessibility-api@0.5.16: {} - dom-converter@0.2.0: - dependencies: - utila: 0.4.0 - - dom-serializer@1.4.1: + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 - domhandler: 4.3.1 - entities: 2.2.0 + domhandler: 5.0.3 + entities: 4.5.0 domelementtype@2.3.0: {} - domhandler@4.3.1: + domhandler@5.0.3: dependencies: domelementtype: 2.3.0 - domutils@2.8.0: + domutils@3.2.2: dependencies: - dom-serializer: 1.4.1 + dom-serializer: 2.0.0 domelementtype: 2.3.0 - domhandler: 4.3.1 + domhandler: 5.0.3 - dot-case@3.0.4: + dot-prop@5.3.0: dependencies: - no-case: 3.0.4 - tslib: 2.8.1 + is-obj: 2.0.0 dotenv@16.4.5: {} dotenv@16.4.7: {} - dotenv@16.6.1: {} + dotenv@17.2.3: {} + + dotenv@8.6.0: {} dts-resolver@2.1.3: {} @@ -11503,6 +15203,11 @@ snapshots: ee-first@1.1.1: {} + effect@3.19.13: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -11511,36 +15216,45 @@ snapshots: emoji-regex@10.6.0: {} + emoji-regex@7.0.3: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} empathic@2.0.0: {} - encodeurl@1.0.2: {} - encodeurl@2.0.0: {} encoding@0.1.13: dependencies: iconv-lite: 0.6.3 - enhanced-resolve@5.18.3: + enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 - entities@2.2.0: {} + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@4.5.0: {} entities@6.0.1: {} - envinfo@7.21.0: {} + env-paths@2.2.1: {} + + env-paths@3.0.0: {} environment@1.1.0: {} - err-code@2.0.3: {} + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 - es-abstract@1.24.0: + es-abstract@1.24.1: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 @@ -11601,12 +15315,12 @@ snapshots: es-errors@1.3.0: {} - es-iterator-helpers@1.2.1: + es-iterator-helpers@1.2.2: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-set-tostringtag: 2.1.0 function-bind: 1.1.2 @@ -11711,18 +15425,18 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.0.3(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.0.3 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.1(jiti@2.6.1)) globals: 16.4.0 - typescript-eslint: 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -11739,7 +15453,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -11750,22 +15464,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -11776,7 +15490,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -11788,7 +15502,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -11815,12 +15529,12 @@ snapshots: eslint-plugin-react-hooks@7.0.1(eslint@9.39.1(jiti@2.6.1)): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.0 '@babel/parser': 7.28.5 eslint: 9.39.1(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 3.25.76 - zod-validation-error: 4.0.2(zod@3.25.76) + zod: 4.1.12 + zod-validation-error: 4.0.2(zod@4.1.12) transitivePeerDependencies: - supports-color @@ -11831,7 +15545,7 @@ snapshots: array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.2.1 + es-iterator-helpers: 1.2.2 eslint: 9.39.1(jiti@2.6.1) estraverse: 5.3.0 hasown: 2.0.2 @@ -11846,11 +15560,6 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -11867,7 +15576,7 @@ snapshots: '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.1 + '@eslint/eslintrc': 3.3.3 '@eslint/js': 9.39.1 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -11901,6 +15610,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm@3.2.25: {} + espree@10.4.0: dependencies: acorn: 8.15.0 @@ -11917,8 +15628,6 @@ snapshots: dependencies: estraverse: 5.3.0 - estraverse@4.3.0: {} - estraverse@5.3.0: {} estree-util-is-identifier-name@3.0.0: {} @@ -11937,6 +15646,8 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -11947,8 +15658,6 @@ snapshots: event-target-shim@5.0.1: {} - eventemitter3@4.0.7: {} - eventemitter3@5.0.1: {} events@3.3.0: {} @@ -11959,54 +15668,31 @@ snapshots: dependencies: eventsource-parser: 3.0.6 - exit-hook@2.2.1: {} + execa@9.6.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 - expect-type@1.2.2: {} + expect-type@1.3.0: {} express-rate-limit@7.5.1(express@5.1.0): dependencies: express: 5.1.0 - express@4.21.2: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.3 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.1 - cookie-signature: 1.0.6 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.1 - fresh: 0.5.2 - http-errors: 2.0.0 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.12 - proxy-addr: 2.0.7 - qs: 6.13.0 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.0 - serve-static: 1.16.2 - setprototypeof: 1.2.0 - statuses: 2.0.1 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - express@5.1.0: dependencies: accepts: 2.0.0 - body-parser: 2.2.0 + body-parser: 2.2.1 content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 @@ -12015,9 +15701,9 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.0 + finalhandler: 2.1.1 fresh: 2.0.0 - http-errors: 2.0.0 + http-errors: 2.0.1 merge-descriptors: 2.0.0 mime-types: 3.0.2 on-finished: 2.4.1 @@ -12027,26 +15713,34 @@ snapshots: qs: 6.14.0 range-parser: 1.2.1 router: 2.2.0 - send: 1.2.0 - serve-static: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 statuses: 2.0.2 type-is: 2.0.1 vary: 1.1.2 transitivePeerDependencies: - supports-color + exsolve@1.0.8: {} + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 extend@3.0.2: {} + extendable-error@0.1.7: {} + external-editor@3.1.0: dependencies: chardet: 0.7.0 iconv-lite: 0.4.24 tmp: 0.0.33 + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-content-type-parse@2.0.1: {} fast-deep-equal@3.1.3: {} @@ -12071,15 +15765,23 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-redact@3.5.0: {} + fast-uri@3.1.0: {} + fast-xml-parser@5.2.5: + dependencies: + strnum: 2.1.2 + fast-xml-parser@5.3.2: dependencies: - strnum: 2.1.1 + strnum: 2.1.2 - fastest-levenshtein@1.0.16: {} + fast-xml-parser@5.3.3: + dependencies: + strnum: 2.1.2 - fastq@1.19.1: + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -12087,14 +15789,12 @@ snapshots: dependencies: format: 0.2.2 - faye-websocket@0.11.4: - dependencies: - websocket-driver: 0.7.4 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + figlet@1.9.4: dependencies: commander: 14.0.2 @@ -12115,19 +15815,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.1: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.1 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color + filter-obj@5.1.0: {} - finalhandler@2.1.0: + finalhandler@2.1.1: dependencies: debug: 4.4.3 encodeurl: 2.0.0 @@ -12138,6 +15828,10 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -12148,13 +15842,23 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.0 + rollup: 4.54.0 + flat-cache@4.0.1: dependencies: flatted: 3.3.3 keyv: 4.5.4 - flat@5.0.2: {} - flat@6.0.1: {} flatted@3.3.3: {} @@ -12184,10 +15888,26 @@ snapshots: fraction.js@5.3.4: {} - fresh@0.5.2: {} - fresh@2.0.0: {} + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + fsevents@2.3.2: optional: true @@ -12211,6 +15931,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-east-asian-width@1.4.0: {} get-intrinsic@1.3.0: @@ -12226,13 +15948,18 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 - get-port@5.1.1: {} + get-package-type@0.1.0: {} get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -12243,6 +15970,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + getopts@2.3.0: {} + gettext-parser@8.0.0: dependencies: content-type: 1.0.5 @@ -12250,6 +15979,12 @@ snapshots: readable-stream: 4.7.0 safe-buffer: 5.2.1 + git-raw-commits@4.0.0: + dependencies: + dargs: 8.1.0 + meow: 12.1.1 + split2: 4.2.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -12258,21 +15993,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regex.js@1.2.0(tslib@2.8.1): - dependencies: - tslib: 2.8.1 - - glob-to-regexp@0.4.1: {} - - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -12282,11 +16002,9 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.1 - glob@13.0.0: + global-directory@4.0.1: dependencies: - minimatch: 10.1.1 - minipass: 7.1.2 - path-scurry: 2.0.1 + ini: 4.1.1 globals@14.0.0: {} @@ -12297,6 +16015,24 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globby@14.1.0: + dependencies: + '@sindresorhus/merge-streams': 2.3.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + path-type: 6.0.0 + slash: 5.1.0 + unicorn-magic: 0.3.0 + goober@2.1.18(csstype@3.2.3): dependencies: csstype: 3.2.3 @@ -12319,8 +16055,6 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - handle-thing@2.0.1: {} - has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -12351,7 +16085,7 @@ snapshots: comma-separated-tokens: 2.0.3 hast-util-whitespace: 3.0.0 html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 stringify-entities: 4.0.4 @@ -12361,8 +16095,6 @@ snapshots: dependencies: '@types/hast': 3.0.4 - he@1.2.0: {} - hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -12371,69 +16103,33 @@ snapshots: hookable@5.5.3: {} - hosted-git-info@6.1.3: - dependencies: - lru-cache: 7.18.3 + hookable@6.0.1: {} - hpack.js@2.1.6: + hosted-git-info@8.1.0: dependencies: - inherits: 2.0.4 - obuf: 1.1.2 - readable-stream: 2.3.8 - wbuf: 1.7.3 + lru-cache: 10.4.3 html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 - html-minifier-terser@6.1.0: - dependencies: - camel-case: 4.1.2 - clean-css: 5.3.3 - commander: 8.3.0 - he: 1.2.0 - param-case: 3.0.4 - relateurl: 0.2.7 - terser: 5.44.1 - html-void-elements@3.0.0: {} - html-webpack-plugin@5.6.5(webpack@5.103.0): - dependencies: - '@types/html-minifier-terser': 6.1.0 - html-minifier-terser: 6.1.0 - lodash: 4.17.21 - pretty-error: 4.0.0 - tapable: 2.3.0 - optionalDependencies: - webpack: 5.103.0(webpack-cli@6.0.1) - - htmlparser2@6.1.0: + htmlparser2@10.0.0: dependencies: domelementtype: 2.3.0 - domhandler: 4.3.1 - domutils: 2.8.0 - entities: 2.2.0 - - http-deceiver@1.2.7: {} - - http-errors@1.6.3: - dependencies: - depd: 1.1.2 - inherits: 2.0.3 - setprototypeof: 1.1.0 - statuses: 1.5.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 6.0.1 - http-errors@2.0.0: + http-errors@2.0.1: dependencies: depd: 2.0.0 inherits: 2.0.4 setprototypeof: 1.2.0 - statuses: 2.0.1 + statuses: 2.0.2 toidentifier: 1.0.1 - http-parser-js@0.5.10: {} - http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -12441,26 +16137,6 @@ snapshots: transitivePeerDependencies: - supports-color - http-proxy-middleware@2.0.9(@types/express@4.17.25): - dependencies: - '@types/http-proxy': 1.17.17 - http-proxy: 1.18.1 - is-glob: 4.0.3 - is-plain-obj: 3.0.0 - micromatch: 4.0.8 - optionalDependencies: - '@types/express': 4.17.25 - transitivePeerDependencies: - - debug - - http-proxy@1.18.1: - dependencies: - eventemitter3: 4.0.7 - follow-redirects: 1.15.11 - requires-port: 1.0.0 - transitivePeerDependencies: - - debug - https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -12468,9 +16144,11 @@ snapshots: transitivePeerDependencies: - supports-color - husky@9.1.7: {} + human-id@4.1.3: {} - hyperdyperid@1.2.0: {} + human-signals@8.0.1: {} + + husky@9.1.7: {} iconv-lite@0.4.24: dependencies: @@ -12480,7 +16158,7 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.0: + iconv-lite@0.7.1: dependencies: safer-buffer: 2.1.2 @@ -12499,19 +16177,18 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-local@3.2.0: - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 + import-meta-resolve@4.2.0: {} + + import-without-cache@0.2.5: {} imurmurhash@0.1.4: {} indent-string@5.0.0: {} - inherits@2.0.3: {} - inherits@2.0.4: {} + ini@4.1.1: {} + ini@5.0.0: {} ink-progress-bar@3.0.0: @@ -12519,13 +16196,13 @@ snapshots: blacklist: 1.1.4 prop-types: 15.8.1 - ink-spinner@5.0.0(ink@4.2.0(@types/react@18.3.27)(react@19.2.0))(react@19.2.0): + ink-spinner@5.0.0(ink@4.2.0(@types/react@19.2.7)(react@19.2.3))(react@19.2.3): dependencies: cli-spinners: 2.9.2 - ink: 4.2.0(@types/react@18.3.27)(react@19.2.0) - react: 19.2.0 + ink: 4.2.0(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 - ink@4.2.0(@types/react@18.3.27)(react@19.2.0): + ink@4.2.0(@types/react@19.2.7)(react@19.2.3): dependencies: ansi-escapes: 6.2.1 auto-bind: 5.0.1 @@ -12540,8 +16217,8 @@ snapshots: is-upper-case: 2.0.2 lodash: 4.17.21 patch-console: 2.0.0 - react: 19.2.0 - react-reconciler: 0.29.2(react@19.2.0) + react: 19.2.3 + react-reconciler: 0.29.2(react@19.2.3) scheduler: 0.23.2 signal-exit: 3.0.7 slice-ansi: 6.0.0 @@ -12553,26 +16230,58 @@ snapshots: ws: 8.18.3 yoga-wasm-web: 0.3.3 optionalDependencies: - '@types/react': 18.3.27 + '@types/react': 19.2.7 transitivePeerDependencies: - bufferutil - utf-8-validate - inquirer@12.6.0(@types/node@22.19.1): + inquirer@12.11.0(@types/node@25.0.3): + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/prompts': 7.10.1(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) + mute-stream: 3.0.0 + run-async: 4.0.6 + rxjs: 7.8.2 + optionalDependencies: + '@types/node': 25.0.3 + + inquirer@12.6.0(@types/node@22.10.2): + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.10.2) + '@inquirer/prompts': 7.8.0(@types/node@22.10.2) + '@inquirer/type': 3.0.10(@types/node@22.10.2) + ansi-escapes: 4.3.2 + mute-stream: 2.0.0 + run-async: 3.0.0 + rxjs: 7.8.2 + optionalDependencies: + '@types/node': 22.10.2 + + inquirer@12.6.0(@types/node@25.0.3): dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/prompts': 7.10.1(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/prompts': 7.8.0(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 rxjs: 7.8.2 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 + + interactive-commander@0.5.194(@types/node@22.10.2): + dependencies: + '@inquirer/prompts': 7.8.0(@types/node@22.10.2) + commander: 12.1.0 + parse-my-command: 0.3.31 + transitivePeerDependencies: + - '@types/node' - interactive-commander@0.5.194(@types/node@22.19.1): + interactive-commander@0.5.194(@types/node@25.0.3): dependencies: - '@inquirer/prompts': 7.10.1(@types/node@22.19.1) + '@inquirer/prompts': 7.8.0(@types/node@25.0.3) commander: 12.1.0 parse-my-command: 0.3.31 transitivePeerDependencies: @@ -12584,19 +16293,17 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 - interpret@3.1.1: {} + interpret@2.2.0: {} - intl-messageformat@10.7.18: + intl-messageformat@11.0.6: dependencies: - '@formatjs/ecma402-abstract': 2.3.6 - '@formatjs/fast-memoize': 2.2.7 - '@formatjs/icu-messageformat-parser': 2.11.4 + '@formatjs/ecma402-abstract': 3.0.5 + '@formatjs/fast-memoize': 3.0.1 + '@formatjs/icu-messageformat-parser': 3.1.1 tslib: 2.8.1 ipaddr.js@1.9.1: {} - ipaddr.js@2.3.0: {} - is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -12610,6 +16317,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} + is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -12668,6 +16377,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@2.0.0: {} + is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@4.0.0: {} @@ -12702,9 +16413,9 @@ snapshots: is-map@2.0.3: {} - is-negative-zero@2.0.3: {} + is-module@1.0.0: {} - is-network-error@1.3.0: {} + is-negative-zero@2.0.3: {} is-number-object@1.1.1: dependencies: @@ -12713,20 +16424,20 @@ snapshots: is-number@7.0.0: {} - is-plain-obj@3.0.0: {} + is-obj@2.0.0: {} is-plain-obj@4.1.0: {} - is-plain-object@2.0.4: - dependencies: - isobject: 3.0.1 - is-plain-object@3.0.1: {} is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.8 + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -12740,17 +16451,27 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@4.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + is-symbol@1.1.1: dependencies: call-bound: 1.0.4 has-symbols: 1.1.0 safe-regex-test: 1.1.0 + is-text-path@2.0.0: + dependencies: + text-extensions: 2.4.0 + is-typed-array@1.1.15: dependencies: which-typed-array: 1.1.19 @@ -12776,12 +16497,12 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-windows@1.0.2: {} + is-wsl@3.1.0: dependencies: is-inside-container: 1.0.0 - isarray@1.0.0: {} - isarray@2.0.5: {} isbot@5.1.32: {} @@ -12790,8 +16511,6 @@ snapshots: iso-639-3@3.0.1: {} - isobject@3.0.1: {} - iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -12801,12 +16520,6 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jackspeak@4.1.1: dependencies: '@isaacs/cliui': 8.0.2 @@ -12817,18 +16530,28 @@ snapshots: filelist: 1.0.4 picocolors: 1.1.1 - jest-worker@27.5.1: - dependencies: - '@types/node': 22.19.1 - merge-stream: 2.0.0 - supports-color: 8.1.1 + jiti@1.21.7: {} jiti@2.6.1: {} + joi@18.0.1: + dependencies: + '@hapi/address': 5.1.1 + '@hapi/formula': 3.0.2 + '@hapi/hoek': 11.0.7 + '@hapi/pinpoint': 2.0.1 + '@hapi/tlds': 1.1.4 + '@hapi/topo': 6.0.2 + '@standard-schema/spec': 1.1.0 + + joycon@3.1.1: {} + js-cookie@3.0.5: {} js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -12848,7 +16571,7 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.22 + nwsapi: 2.2.23 parse5: 7.3.0 rrweb-cssom: 0.7.1 saxes: 6.0.0 @@ -12866,18 +16589,18 @@ snapshots: - supports-color - utf-8-validate - jsdom@27.2.0: + jsdom@27.0.0: dependencies: - '@acemir/cssom': 0.9.24 - '@asamuzakjp/dom-selector': 6.7.5 - cssstyle: 5.3.3 + '@asamuzakjp/dom-selector': 6.7.6 + cssstyle: 5.3.5 data-urls: 6.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - parse5: 8.0.0 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.0 @@ -12893,7 +16616,33 @@ snapshots: - supports-color - utf-8-validate - jsesc@3.0.2: {} + jsdom@27.3.0: + dependencies: + '@acemir/cssom': 0.9.30 + '@asamuzakjp/dom-selector': 6.7.6 + cssstyle: 5.3.5 + data-urls: 6.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true jsesc@3.1.0: {} @@ -12901,8 +16650,6 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-parse-even-better-errors@3.0.2: {} - json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -12925,6 +16672,18 @@ snapshots: chalk: 5.6.2 diff-match-patch: 1.0.5 + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonparse@1.3.1: {} + jsonrepair@3.13.1: {} jsx-ast-utils@3.3.5: @@ -12940,6 +16699,29 @@ snapshots: kind-of@6.0.3: {} + kleur@3.0.3: {} + + knex@3.1.0: + dependencies: + colorette: 2.0.19 + commander: 10.0.1 + debug: 4.3.4 + escalade: 3.2.0 + esm: 3.2.25 + get-package-type: 0.1.0 + getopts: 2.3.0 + interpret: 2.2.0 + lodash: 4.17.21 + pg-connection-string: 2.6.2 + rechoir: 0.8.0 + resolve-from: 5.0.0 + tarn: 3.0.2 + tildify: 2.0.0 + transitivePeerDependencies: + - supports-color + + knitwork@1.3.0: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -13005,7 +16787,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 - lingo.dev@0.117.0(@types/node@22.19.1)(@types/react@18.3.27)(encoding@0.1.13)(next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)): + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lingo.dev@0.117.21(@types/node@25.0.3)(@types/react@19.2.7)(encoding@0.1.13)(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): dependencies: '@ai-sdk/anthropic': 1.2.11(zod@3.25.76) '@ai-sdk/google': 1.2.19(zod@3.25.76) @@ -13019,19 +16805,19 @@ snapshots: '@biomejs/wasm-nodejs': 2.3.7 '@datocms/cma-client-node': 4.0.1 '@gitbeaker/rest': 39.34.3 - '@inkjs/ui': 2.0.0(ink@4.2.0(@types/react@18.3.27)(react@19.2.0)) - '@inquirer/prompts': 7.8.0(@types/node@22.19.1) - '@lingo.dev/_compiler': 0.8.0(react@19.2.0) - '@lingo.dev/_locales': 0.3.0 - '@lingo.dev/_react': 0.7.0(next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) - '@lingo.dev/_sdk': 0.13.0 - '@lingo.dev/_spec': 0.44.0 - '@markdoc/markdoc': 0.5.4(@types/react@18.3.27)(react@19.2.0) + '@inkjs/ui': 2.0.0(ink@4.2.0(@types/react@19.2.7)(react@19.2.3)) + '@inquirer/prompts': 7.8.0(@types/node@25.0.3) + '@lingo.dev/_compiler': 0.8.8(react@19.2.3) + '@lingo.dev/_locales': 0.3.1 + '@lingo.dev/_react': 0.7.5(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + '@lingo.dev/_sdk': 0.13.4 + '@lingo.dev/_spec': 0.44.4 + '@markdoc/markdoc': 0.5.4(@types/react@19.2.7)(react@19.2.3) '@modelcontextprotocol/sdk': 1.22.0 - '@openrouter/ai-sdk-provider': 0.7.1(ai@4.3.15(react@19.2.0)(zod@3.25.76))(zod@3.25.76) + '@openrouter/ai-sdk-provider': 0.7.1(ai@4.3.15(react@19.2.3)(zod@3.25.76))(zod@3.25.76) '@paralleldrive/cuid2': 2.2.2 '@types/ejs': 3.1.5 - ai: 4.3.15(react@19.2.0)(zod@3.25.76) + ai: 4.3.15(react@19.2.3)(zod@3.25.76) bitbucket: 2.12.0(encoding@0.1.13) chalk: 5.6.2 chokidar: 4.0.3 @@ -13043,6 +16829,9 @@ snapshots: date-fns: 4.1.0 dedent: 1.7.0 diff: 7.0.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 dotenv: 16.4.7 ejs: 3.1.10 express: 5.1.0 @@ -13053,12 +16842,13 @@ snapshots: glob: 11.1.0 gradient-string: 3.0.0 gray-matter: 4.0.3 + htmlparser2: 10.0.0 ini: 5.0.0 - ink: 4.2.0(@types/react@18.3.27)(react@19.2.0) + ink: 4.2.0(@types/react@19.2.7)(react@19.2.3) ink-progress-bar: 3.0.0 - ink-spinner: 5.0.0(ink@4.2.0(@types/react@18.3.27)(react@19.2.0))(react@19.2.0) - inquirer: 12.6.0(@types/node@22.19.1) - interactive-commander: 0.5.194(@types/node@22.19.1) + ink-spinner: 5.0.0(ink@4.2.0(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) + inquirer: 12.6.0(@types/node@25.0.3) + interactive-commander: 0.5.194(@types/node@25.0.3) is-url: 1.2.4 jsdom: 25.0.1 json5: 2.2.3 @@ -13082,7 +16872,7 @@ snapshots: plist: 3.1.0 posthog-node: 5.14.0 prettier: 3.6.2 - react: 19.2.0 + react: 19.2.3 rehype-stringify: 10.0.1 remark-disable-tokenizers: 1.1.1 remark-frontmatter: 5.0.0 @@ -13126,7 +16916,12 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 - loader-runner@4.3.1: {} + load-tsconfig@0.2.5: {} + + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 locate-path@5.0.0: dependencies: @@ -13136,10 +16931,36 @@ snapshots: dependencies: p-locate: 5.0.0 + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash-es@4.17.21: {} + + lodash.camelcase@4.3.0: {} + lodash.debounce@4.0.8: {} + lodash.isplainobject@4.0.6: {} + + lodash.kebabcase@4.1.1: {} + + lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} + lodash.mergewith@4.6.2: {} + + lodash.snakecase@4.1.1: {} + + lodash.startcase@4.4.0: {} + + lodash.throttle@4.1.1: {} + + lodash.uniq@4.5.0: {} + + lodash.upperfirst@4.3.1: {} + lodash@4.17.21: {} log-symbols@6.0.0: @@ -13161,20 +16982,16 @@ snapshots: dependencies: js-tokens: 4.0.0 - lower-case@2.0.2: - dependencies: - tslib: 2.8.1 + loupe@3.2.1: {} lru-cache@10.4.3: {} - lru-cache@11.2.2: {} + lru-cache@11.2.4: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lru-cache@7.18.3: {} - lucide-react@0.545.0(react@19.2.0): dependencies: react: 19.2.0 @@ -13337,7 +17154,7 @@ snapshots: '@types/mdast': 4.0.4 unist-util-is: 6.0.1 - mdast-util-to-hast@13.2.0: + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 @@ -13365,31 +17182,18 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - mdn-data@2.12.2: {} + mdn-data@2.0.28: {} - media-typer@0.3.0: {} + mdn-data@2.12.2: {} media-typer@1.1.0: {} - memfs@4.51.1: - dependencies: - '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) - '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) - glob-to-regex.js: 1.2.0(tslib@2.8.1) - thingies: 2.5.0(tslib@2.8.1) - tree-dump: 1.1.0(tslib@2.8.1) - tslib: 2.8.1 - - merge-descriptors@1.0.3: {} + meow@12.1.1: {} merge-descriptors@2.0.0: {} - merge-stream@2.0.0: {} - merge2@1.4.1: {} - methods@1.1.2: {} - micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.2.0 @@ -13666,6 +17470,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + micromustache@8.0.3: {} + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -13678,14 +17484,10 @@ snapshots: dependencies: mime-db: 1.54.0 - mime@1.6.0: {} - mimic-fn@2.1.0: {} mimic-function@5.0.1: {} - minimalistic-assert@1.0.1: {} - minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -13706,80 +17508,122 @@ snapshots: minipass@7.1.2: {} - morgan@1.10.1: + mkdist@2.4.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)): dependencies: - basic-auth: 2.0.1 - debug: 2.6.9 - depd: 2.0.0 - on-finished: 2.3.0 - on-headers: 1.1.0 - transitivePeerDependencies: - - supports-color + autoprefixer: 10.4.23(postcss@8.5.6) + citty: 0.1.6 + cssnano: 7.1.2(postcss@8.5.6) + defu: 6.1.4 + esbuild: 0.25.12 + jiti: 1.21.7 + mlly: 1.8.0 + pathe: 2.0.3 + pkg-types: 2.3.0 + postcss: 8.5.6 + postcss-nested: 7.0.2(postcss@8.5.6) + semver: 7.7.3 + tinyglobby: 0.2.15 + optionalDependencies: + typescript: 5.9.3 + vue: 3.5.24(typescript@5.9.3) - ms@2.0.0: {} + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 - ms@2.1.3: {} + mri@1.2.0: {} - multicast-dns@7.2.5: - dependencies: - dns-packet: 5.6.1 - thunky: 1.1.0 + mrmime@2.0.1: {} + + ms@2.1.2: {} + + ms@2.1.3: {} mute-stream@2.0.0: {} - nano-staged@0.9.0: + mute-stream@3.0.0: {} + + mz@2.7.0: dependencies: - picocolors: 1.1.1 + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 nanoid@3.3.11: {} + nanoid@5.1.6: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} - negotiator@0.6.3: {} - - negotiator@0.6.4: {} - negotiator@1.0.0: {} - neo-async@2.6.2: {} + next@15.3.8(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@next/env': 15.3.8 + '@swc/counter': 0.1.3 + '@swc/helpers': 0.5.15 + busboy: 1.6.0 + caniuse-lite: 1.0.30001761 + postcss: 8.4.31 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3) + optionalDependencies: + '@next/swc-darwin-arm64': 15.3.5 + '@next/swc-darwin-x64': 15.3.5 + '@next/swc-linux-arm64-gnu': 15.3.5 + '@next/swc-linux-arm64-musl': 15.3.5 + '@next/swc-linux-x64-gnu': 15.3.5 + '@next/swc-linux-x64-musl': 15.3.5 + '@next/swc-win32-arm64-msvc': 15.3.5 + '@next/swc-win32-x64-msvc': 15.3.5 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.57.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros - next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.0.4(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: - '@next/env': 16.0.7 + '@next/env': 16.0.4 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001756 + caniuse-lite: 1.0.30001761 postcss: 8.4.31 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) styled-jsx: 5.1.6(react@19.2.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.0.7 - '@next/swc-darwin-x64': 16.0.7 - '@next/swc-linux-arm64-gnu': 16.0.7 - '@next/swc-linux-arm64-musl': 16.0.7 - '@next/swc-linux-x64-gnu': 16.0.7 - '@next/swc-linux-x64-musl': 16.0.7 - '@next/swc-win32-arm64-msvc': 16.0.7 - '@next/swc-win32-x64-msvc': 16.0.7 + '@next/swc-darwin-arm64': 16.0.4 + '@next/swc-darwin-x64': 16.0.4 + '@next/swc-linux-arm64-gnu': 16.0.4 + '@next/swc-linux-arm64-musl': 16.0.4 + '@next/swc-linux-x64-gnu': 16.0.4 + '@next/swc-linux-x64-musl': 16.0.4 + '@next/swc-win32-arm64-msvc': 16.0.4 + '@next/swc-win32-x64-msvc': 16.0.4 '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.56.1 + '@playwright/test': 1.57.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next@16.1.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.0 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.8 - caniuse-lite: 1.0.30001760 + baseline-browser-mapping: 2.9.11 + caniuse-lite: 1.0.30001761 postcss: 8.4.31 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.1) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.0 '@next/swc-darwin-x64': 16.1.0 @@ -13796,10 +17640,31 @@ snapshots: - '@babel/core' - babel-plugin-macros - no-case@3.0.4: + next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - lower-case: 2.0.2 - tslib: 2.8.1 + '@next/env': 16.1.1 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.9.11 + caniuse-lite: 1.0.30001761 + postcss: 8.4.31 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.1 + '@next/swc-darwin-x64': 16.1.1 + '@next/swc-linux-arm64-gnu': 16.1.1 + '@next/swc-linux-arm64-musl': 16.1.1 + '@next/swc-linux-x64-gnu': 16.1.1 + '@next/swc-linux-x64-musl': 16.1.1 + '@next/swc-win32-arm64-msvc': 16.1.1 + '@next/swc-win32-x64-msvc': 16.1.1 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.57.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros node-fetch@2.7.0(encoding@0.1.13): dependencies: @@ -13807,8 +17672,6 @@ snapshots: optionalDependencies: encoding: 0.1.13 - node-forge@1.3.3: {} - node-machine-id@1.1.12: {} node-releases@2.0.27: {} @@ -13817,42 +17680,25 @@ snapshots: dependencies: commander: 7.2.0 - normalize-package-data@5.0.0: - dependencies: - hosted-git-info: 6.1.3 - is-core-module: 2.16.1 - semver: 7.7.3 - validate-npm-package-license: 3.0.4 - normalize-path@3.0.0: {} - normalize-range@0.1.2: {} - - npm-install-checks@6.3.0: - dependencies: - semver: 7.7.3 - - npm-normalize-package-bin@3.0.1: {} - - npm-package-arg@10.1.0: + npm-package-arg@12.0.2: dependencies: - hosted-git-info: 6.1.3 - proc-log: 3.0.0 + hosted-git-info: 8.1.0 + proc-log: 5.0.0 semver: 7.7.3 - validate-npm-package-name: 5.0.1 + validate-npm-package-name: 6.0.2 - npm-pick-manifest@8.0.2: + npm-run-path@6.0.0: dependencies: - npm-install-checks: 6.3.0 - npm-normalize-package-bin: 3.0.1 - npm-package-arg: 10.1.0 - semver: 7.7.3 + path-key: 4.0.0 + unicorn-magic: 0.3.0 nth-check@2.1.1: dependencies: boolbase: 1.0.0 - nwsapi@2.2.22: {} + nwsapi@2.2.23: {} object-assign@4.1.1: {} @@ -13882,14 +17728,14 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-object-atoms: 1.1.1 object.groupby@1.0.3: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 object.values@1.2.1: dependencies: @@ -13898,10 +17744,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - obuf@1.1.2: {} - - obug@2.1.0: {} - obug@2.1.1: {} octokit@4.0.2: @@ -13917,6 +17759,13 @@ snapshots: '@octokit/request-error': 6.1.8 '@octokit/types': 13.10.0 + ollama-ai-provider-v2@2.0.0(ai@6.0.25(zod@4.1.12))(zod@4.1.12): + dependencies: + '@ai-sdk/provider': 3.0.0 + '@ai-sdk/provider-utils': 4.0.1(zod@4.1.12) + ai: 6.0.25(zod@4.1.12) + zod: 4.1.12 + ollama-ai-provider@1.2.0(zod@3.25.76): dependencies: '@ai-sdk/provider': 1.1.3 @@ -13925,16 +17774,16 @@ snapshots: optionalDependencies: zod: 3.25.76 - on-finished@2.3.0: + ollama@0.6.3: dependencies: - ee-first: 1.1.1 + whatwg-fetch: 3.6.20 + + on-exit-leak-free@2.1.2: {} on-finished@2.4.1: dependencies: ee-first: 1.1.1 - on-headers@1.1.0: {} - once@1.4.0: dependencies: wrappy: 1.0.2 @@ -13975,34 +17824,31 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.2 + os-tmpdir@1.0.2: {} + outdent@0.5.0: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxlint-tsgolint@0.8.0: - optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.8.0 - '@oxlint-tsgolint/darwin-x64': 0.8.0 - '@oxlint-tsgolint/linux-arm64': 0.8.0 - '@oxlint-tsgolint/linux-x64': 0.8.0 - '@oxlint-tsgolint/win32-arm64': 0.8.0 - '@oxlint-tsgolint/win32-x64': 0.8.0 - - oxlint@1.29.0(oxlint-tsgolint@0.8.0): - optionalDependencies: - '@oxlint/darwin-arm64': 1.29.0 - '@oxlint/darwin-x64': 1.29.0 - '@oxlint/linux-arm64-gnu': 1.29.0 - '@oxlint/linux-arm64-musl': 1.29.0 - '@oxlint/linux-x64-gnu': 1.29.0 - '@oxlint/linux-x64-musl': 1.29.0 - '@oxlint/win32-arm64': 1.29.0 - '@oxlint/win32-x64': 1.29.0 - oxlint-tsgolint: 0.8.0 + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 p-limit@2.3.0: dependencies: @@ -14012,10 +17858,18 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + p-limit@6.2.0: dependencies: yocto-queue: 1.2.2 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -14024,22 +17878,28 @@ snapshots: dependencies: p-limit: 3.1.0 - p-map@7.0.4: {} + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + p-map@2.1.0: {} - p-retry@6.2.1: + p-queue@8.1.1: dependencies: - '@types/retry': 0.12.2 - is-network-error: 1.3.0 - retry: 0.13.1 + eventemitter3: 5.0.1 + p-timeout: 6.1.4 + + p-timeout@6.1.4: {} p-try@2.2.0: {} package-json-from-dist@1.0.1: {} - param-case@3.0.4: + package-manager-detector@0.2.11: dependencies: - dot-case: 3.0.4 - tslib: 2.8.1 + quansync: 0.2.11 + + packrup@0.1.2: {} parent-module@1.0.1: dependencies: @@ -14055,11 +17915,20 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 - parse-my-command@0.3.31: + parse-json@5.2.0: dependencies: - camelcase: 8.0.0 - commander: 12.1.0 - + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-ms@4.0.0: {} + + parse-my-command@0.3.31: + dependencies: + camelcase: 8.0.0 + commander: 12.1.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -14067,42 +17936,43 @@ snapshots: parse5@8.0.0: dependencies: entities: 6.0.1 + optional: true parseurl@1.3.3: {} partial-json@0.1.7: {} - pascal-case@3.1.2: - dependencies: - no-case: 3.0.4 - tslib: 2.8.1 - patch-console@2.0.0: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} + path-exists@5.0.0: {} + path-key@3.1.1: {} - path-parse@1.0.7: {} + path-key@4.0.0: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 + path-parse@1.0.7: {} path-scurry@2.0.1: dependencies: - lru-cache: 11.2.2 + lru-cache: 11.2.4 minipass: 7.1.2 - path-to-regexp@0.1.12: {} - path-to-regexp@8.3.0: {} - pathe@1.1.2: {} + path-type@4.0.0: {} + + path-type@6.0.0: {} pathe@2.0.3: {} + pathval@2.0.1: {} + + pg-connection-string@2.6.2: {} + php-array-reader@2.1.2: dependencies: php-parser: 3.2.5 @@ -14117,20 +17987,72 @@ snapshots: picomatch@4.0.3: {} - pkce-challenge@5.0.0: {} + pify@4.0.1: {} - pkg-dir@4.2.0: + pinia@2.3.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)): dependencies: - find-up: 4.1.0 + '@vue/devtools-api': 6.6.4 + vue: 3.5.24(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.24(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.0.0: {} + + pino@9.6.0: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 4.0.1 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + + pirates@4.0.7: {} + + pkce-challenge@5.0.1: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 playwright-core@1.56.1: {} + playwright-core@1.57.0: + optional: true + playwright@1.56.1: dependencies: playwright-core: 1.56.1 optionalDependencies: fsevents: 2.3.2 + playwright@1.57.0: + dependencies: + playwright-core: 1.57.0 + optionalDependencies: + fsevents: 2.3.2 + optional: true + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11 @@ -14139,6 +18061,100 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss-calc@10.1.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + + postcss-colormin@7.0.5(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-api: 3.0.0 + colord: 2.9.3 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-convert-values@7.0.8(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-discard-comments@7.0.5(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-discard-duplicates@7.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-empty@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-overridden@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.6.1 + postcss: 8.5.6 + tsx: 4.21.0 + yaml: 2.8.1 + + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.6.1 + postcss: 8.5.6 + tsx: 4.21.0 + yaml: 2.8.2 + + postcss-merge-longhand@7.0.5(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + stylehacks: 7.0.7(postcss@8.5.6) + + postcss-merge-rules@7.0.7(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-api: 3.0.0 + cssnano-utils: 5.0.1(postcss@8.5.6) + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-minify-font-values@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-gradients@7.0.1(postcss@8.5.6): + dependencies: + colord: 2.9.3 + cssnano-utils: 5.0.1(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-params@7.0.5(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + cssnano-utils: 5.0.1(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-selectors@7.0.5(postcss@8.5.6): + dependencies: + cssesc: 3.0.0 + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + postcss-modules-extract-imports@3.1.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -14160,11 +18176,89 @@ snapshots: icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 + postcss-nested@7.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-normalize-charset@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-normalize-display-values@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-positions@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-repeat-style@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-string@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-timing-functions@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-unicode@7.0.5(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-url@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-whitespace@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-ordered-values@7.0.2(postcss@8.5.6): + dependencies: + cssnano-utils: 5.0.1(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-reduce-initial@7.0.5(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-api: 3.0.0 + postcss: 8.5.6 + + postcss-reduce-transforms@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 + postcss-svgo@7.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + svgo: 4.0.0 + + postcss-unique-selectors@7.0.4(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser@4.2.0: {} postcss@8.4.31: @@ -14185,12 +18279,11 @@ snapshots: prelude-ls@1.2.1: {} + prettier@2.8.8: {} + prettier@3.6.2: {} - pretty-error@4.0.0: - dependencies: - lodash: 4.17.21 - renderkid: 3.0.0 + pretty-bytes@7.1.0: {} pretty-format@27.5.1: dependencies: @@ -14198,18 +18291,20 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 - proc-log@3.0.0: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + proc-log@5.0.0: {} - process-nextick-args@2.0.1: {} + process-warning@4.0.1: {} process@0.11.10: {} - promise-inflight@1.0.1: {} - - promise-retry@2.0.1: + prompts@2.4.2: dependencies: - err-code: 2.0.3 - retry: 0.12.0 + kleur: 3.0.3 + sisteransi: 1.0.5 prop-types@15.8.1: dependencies: @@ -14230,11 +18325,11 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} - qs@6.13.0: - dependencies: - side-channel: 1.1.0 + pure-rand@6.1.0: {} qs@6.14.0: dependencies: @@ -14242,8 +18337,18 @@ snapshots: quansync@0.2.11: {} + quansync@1.0.0: {} + + query-string@9.3.1: + dependencies: + decode-uri-component: 0.4.1 + filter-obj: 5.1.0 + split-on-first: 3.0.0 + queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -14252,18 +18357,11 @@ snapshots: rate-limiter-flexible@4.0.1: {} - raw-body@2.5.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - - raw-body@3.0.1: + raw-body@3.0.2: dependencies: bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.7.0 + http-errors: 2.0.1 + iconv-lite: 0.7.1 unpipe: 1.0.0 react-dom@19.2.0(react@19.2.0): @@ -14271,66 +18369,38 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 - react-dom@19.2.1(react@19.2.1): + react-dom@19.2.3(react@19.2.3): dependencies: - react: 19.2.1 + react: 19.2.3 scheduler: 0.27.0 react-is@16.13.1: {} react-is@17.0.2: {} - react-reconciler@0.29.2(react@19.2.0): + react-reconciler@0.29.2(react@19.2.3): dependencies: loose-envify: 1.4.0 - react: 19.2.0 + react: 19.2.3 scheduler: 0.23.2 - react-refresh@0.14.2: {} - - react-refresh@0.18.0: {} - - react-router-dom@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): - dependencies: - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - react-router: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - - react-router@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): - dependencies: - cookie: 1.0.2 - react: 19.2.1 - set-cookie-parser: 2.7.2 - optionalDependencies: - react-dom: 19.2.1(react@19.2.1) - - react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): - dependencies: - cookie: 1.0.2 - react: 19.2.0 - set-cookie-parser: 2.7.2 - optionalDependencies: - react-dom: 19.2.0(react@19.2.0) + react-refresh@0.17.0: {} react@19.2.0: {} - react@19.2.1: {} + react@19.2.3: {} - readable-stream@2.3.8: + read-yaml-file@1.1.0: dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 + graceful-fs: 4.2.11 + js-yaml: 3.14.2 + pify: 4.0.1 + strip-bom: 3.0.0 - readable-stream@3.6.2: + read-yaml-file@2.1.0: dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 + js-yaml: 4.1.1 + strip-bom: 4.0.0 readable-stream@4.7.0: dependencies: @@ -14346,6 +18416,8 @@ snapshots: readdirp@4.1.2: {} + real-require@0.2.0: {} + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -14362,19 +18434,13 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 get-proto: 1.0.1 which-builtin-type: 1.2.1 - regenerate-unicode-properties@10.2.2: - dependencies: - regenerate: 1.4.2 - - regenerate@1.4.2: {} - regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -14384,29 +18450,12 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 - regexpu-core@6.4.0: - dependencies: - regenerate: 1.4.2 - regenerate-unicode-properties: 10.2.2 - regjsgen: 0.8.0 - regjsparser: 0.13.0 - unicode-match-property-ecmascript: 2.0.0 - unicode-match-property-value-ecmascript: 2.2.1 - - regjsgen@0.8.0: {} - - regjsparser@0.13.0: - dependencies: - jsesc: 3.1.0 - rehype-stringify@10.0.1: dependencies: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 unified: 11.0.5 - relateurl@0.2.7: {} - remark-disable-tokenizers@1.1.1: dependencies: clone: 2.1.2 @@ -14460,7 +18509,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 unified: 11.0.5 vfile: 6.0.3 @@ -14470,21 +18519,11 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - renderkid@3.0.0: - dependencies: - css-select: 4.3.0 - dom-converter: 0.2.0 - htmlparser2: 6.1.0 - lodash: 4.17.21 - strip-ansi: 6.0.1 + require-directory@2.1.1: {} require-from-string@2.0.2: {} - requires-port@1.0.0: {} - - resolve-cwd@3.0.0: - dependencies: - resolve-from: 5.0.0 + require-main-filename@2.0.0: {} resolve-from@4.0.0: {} @@ -14492,6 +18531,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve.exports@2.0.3: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -14516,100 +18557,166 @@ snapshots: retry@0.12.0: {} - retry@0.13.1: {} - reusify@1.1.0: {} rfdc@1.4.1: {} - rimraf@6.1.2: - dependencies: - glob: 13.0.0 - package-json-from-dist: 1.0.1 - - rolldown-plugin-dts@0.18.0(rolldown@1.0.0-beta.51)(typescript@5.9.3): + rolldown-plugin-dts@0.19.2(rolldown@1.0.0-beta.55)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 '@babel/types': 7.28.5 ast-kit: 2.2.0 - birpc: 2.8.0 + birpc: 4.0.0 dts-resolver: 2.1.3 get-tsconfig: 4.13.0 - magic-string: 0.30.21 - obug: 2.1.0 - rolldown: 1.0.0-beta.51 + obug: 2.1.1 + rolldown: 1.0.0-beta.55 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver - rolldown@1.0.0-beta.51: + rolldown@1.0.0-beta.55: dependencies: - '@oxc-project/types': 0.98.0 - '@rolldown/pluginutils': 1.0.0-beta.51 + '@oxc-project/types': 0.103.0 + '@rolldown/pluginutils': 1.0.0-beta.55 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.51 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.51 - '@rolldown/binding-darwin-x64': 1.0.0-beta.51 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.51 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.51 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.51 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.51 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.51 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.51 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.51 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.51 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.51 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.51 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.51 - - rollup@4.53.3: + '@rolldown/binding-android-arm64': 1.0.0-beta.55 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.55 + '@rolldown/binding-darwin-x64': 1.0.0-beta.55 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.55 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.55 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.55 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.55 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.55 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.55 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.55 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.55 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.55 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.55 + + rollup-plugin-dts@6.3.0(rollup@4.54.0)(typescript@5.9.3): dependencies: - '@types/estree': 1.0.8 + magic-string: 0.30.21 + rollup: 4.54.0 + typescript: 5.9.3 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.53.3 - '@rollup/rollup-android-arm64': 4.53.3 - '@rollup/rollup-darwin-arm64': 4.53.3 - '@rollup/rollup-darwin-x64': 4.53.3 - '@rollup/rollup-freebsd-arm64': 4.53.3 - '@rollup/rollup-freebsd-x64': 4.53.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 - '@rollup/rollup-linux-arm-musleabihf': 4.53.3 - '@rollup/rollup-linux-arm64-gnu': 4.53.3 - '@rollup/rollup-linux-arm64-musl': 4.53.3 - '@rollup/rollup-linux-loong64-gnu': 4.53.3 - '@rollup/rollup-linux-ppc64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-musl': 4.53.3 - '@rollup/rollup-linux-s390x-gnu': 4.53.3 - '@rollup/rollup-linux-x64-gnu': 4.53.3 - '@rollup/rollup-linux-x64-musl': 4.53.3 - '@rollup/rollup-openharmony-arm64': 4.53.3 - '@rollup/rollup-win32-arm64-msvc': 4.53.3 - '@rollup/rollup-win32-ia32-msvc': 4.53.3 - '@rollup/rollup-win32-x64-gnu': 4.53.3 - '@rollup/rollup-win32-x64-msvc': 4.53.3 - fsevents: 2.3.3 + '@babel/code-frame': 7.27.1 - router@2.2.0: + rollup-plugin-esbuild@6.2.1(esbuild@0.25.12)(rollup@4.52.5): dependencies: debug: 4.4.3 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.3.0 + es-module-lexer: 1.7.0 + esbuild: 0.25.12 + get-tsconfig: 4.13.0 + rollup: 4.52.5 + unplugin-utils: 0.2.5 transitivePeerDependencies: - supports-color - rrweb-cssom@0.7.1: {} - - rrweb-cssom@0.8.0: {} - + rollup-plugin-styler@2.0.0(rollup@4.52.5)(typescript@5.9.3): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.52.5) + cosmiconfig: 8.3.6(typescript@5.9.3) + cssnano: 7.1.2(postcss@8.5.6) + fs-extra: 11.3.2 + icss-utils: 5.1.0(postcss@8.5.6) + mime-types: 2.1.35 + p-queue: 8.1.1 + postcss: 8.5.6 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) + postcss-modules-scope: 3.2.1(postcss@8.5.6) + postcss-modules-values: 4.0.0(postcss@8.5.6) + postcss-value-parser: 4.2.0 + query-string: 9.3.1 + resolve: 1.22.11 + resolve.exports: 2.0.3 + rollup: 4.52.5 + source-map-js: 1.2.1 + tslib: 2.8.1 + transitivePeerDependencies: + - typescript + + rollup@4.52.5: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.52.5 + '@rollup/rollup-android-arm64': 4.52.5 + '@rollup/rollup-darwin-arm64': 4.52.5 + '@rollup/rollup-darwin-x64': 4.52.5 + '@rollup/rollup-freebsd-arm64': 4.52.5 + '@rollup/rollup-freebsd-x64': 4.52.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.52.5 + '@rollup/rollup-linux-arm-musleabihf': 4.52.5 + '@rollup/rollup-linux-arm64-gnu': 4.52.5 + '@rollup/rollup-linux-arm64-musl': 4.52.5 + '@rollup/rollup-linux-loong64-gnu': 4.52.5 + '@rollup/rollup-linux-ppc64-gnu': 4.52.5 + '@rollup/rollup-linux-riscv64-gnu': 4.52.5 + '@rollup/rollup-linux-riscv64-musl': 4.52.5 + '@rollup/rollup-linux-s390x-gnu': 4.52.5 + '@rollup/rollup-linux-x64-gnu': 4.52.5 + '@rollup/rollup-linux-x64-musl': 4.52.5 + '@rollup/rollup-openharmony-arm64': 4.52.5 + '@rollup/rollup-win32-arm64-msvc': 4.52.5 + '@rollup/rollup-win32-ia32-msvc': 4.52.5 + '@rollup/rollup-win32-x64-gnu': 4.52.5 + '@rollup/rollup-win32-x64-msvc': 4.52.5 + fsevents: 2.3.3 + + rollup@4.54.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.54.0 + '@rollup/rollup-android-arm64': 4.54.0 + '@rollup/rollup-darwin-arm64': 4.54.0 + '@rollup/rollup-darwin-x64': 4.54.0 + '@rollup/rollup-freebsd-arm64': 4.54.0 + '@rollup/rollup-freebsd-x64': 4.54.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.54.0 + '@rollup/rollup-linux-arm-musleabihf': 4.54.0 + '@rollup/rollup-linux-arm64-gnu': 4.54.0 + '@rollup/rollup-linux-arm64-musl': 4.54.0 + '@rollup/rollup-linux-loong64-gnu': 4.54.0 + '@rollup/rollup-linux-ppc64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-musl': 4.54.0 + '@rollup/rollup-linux-s390x-gnu': 4.54.0 + '@rollup/rollup-linux-x64-gnu': 4.54.0 + '@rollup/rollup-linux-x64-musl': 4.54.0 + '@rollup/rollup-openharmony-arm64': 4.54.0 + '@rollup/rollup-win32-arm64-msvc': 4.54.0 + '@rollup/rollup-win32-ia32-msvc': 4.54.0 + '@rollup/rollup-win32-x64-gnu': 4.54.0 + '@rollup/rollup-win32-x64-msvc': 4.54.0 + fsevents: 2.3.3 + + rotating-file-stream@3.2.7: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + run-applescript@7.1.0: {} run-async@3.0.0: {} + run-async@4.0.6: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -14626,8 +18733,6 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 - safe-buffer@5.1.2: {} - safe-buffer@5.2.1: {} safe-push-apply@1.0.0: @@ -14641,6 +18746,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sax@1.4.3: {} @@ -14655,12 +18762,7 @@ snapshots: scheduler@0.27.0: {} - schema-utils@4.3.3: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - ajv-keywords: 5.1.0(ajv@8.17.1) + scule@1.3.0: {} section-matter@1.0.0: dependencies: @@ -14669,43 +18771,18 @@ snapshots: secure-json-parse@2.7.0: {} - select-hose@2.0.0: {} - - selfsigned@2.4.1: - dependencies: - '@types/node-forge': 1.3.14 - node-forge: 1.3.3 - semver@6.3.1: {} semver@7.7.3: {} - send@0.19.0: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 1.0.2 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.0 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.1 - transitivePeerDependencies: - - supports-color - - send@1.2.0: + send@1.2.1: dependencies: debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 fresh: 2.0.0 - http-errors: 2.0.0 + http-errors: 2.0.1 mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 @@ -14722,45 +18799,24 @@ snapshots: dependencies: seroval: 1.3.2 - seroval-plugins@1.4.0(seroval@1.4.0): + seroval-plugins@1.4.0(seroval@1.4.1): dependencies: - seroval: 1.4.0 + seroval: 1.4.1 seroval@1.3.2: {} - seroval@1.4.0: {} - - serve-index@1.9.1: - dependencies: - accepts: 1.3.8 - batch: 0.6.1 - debug: 2.6.9 - escape-html: 1.0.3 - http-errors: 1.6.3 - mime-types: 2.1.35 - parseurl: 1.3.3 - transitivePeerDependencies: - - supports-color - - serve-static@1.16.2: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.0 - transitivePeerDependencies: - - supports-color + seroval@1.4.1: {} - serve-static@2.2.0: + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.0 + send: 1.2.1 transitivePeerDependencies: - supports-color - set-cookie-parser@2.7.2: {} + set-blocking@2.0.0: {} set-function-length@1.2.2: dependencies: @@ -14784,14 +18840,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 - setprototypeof@1.1.0: {} - setprototypeof@1.2.0: {} - shallow-clone@3.0.1: - dependencies: - kind-of: 6.0.3 - sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -14866,6 +18916,18 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + slash@5.1.0: {} + slice-ansi@5.0.0: dependencies: ansi-styles: 6.2.3 @@ -14881,11 +18943,7 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - sockjs@0.3.24: - dependencies: - faye-websocket: 0.11.4 - uuid: 8.3.2 - websocket-driver: 0.7.4 + smob@1.5.0: {} solid-js@1.9.10: dependencies: @@ -14893,6 +18951,10 @@ snapshots: seroval: 1.3.2 seroval-plugins: 1.3.3(seroval@1.3.2) + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -14906,40 +18968,14 @@ snapshots: space-separated-tokens@2.0.2: {} - spdx-correct@3.2.0: - dependencies: - spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.22 - - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@3.0.1: + spawndamnit@3.0.1: dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.22 - - spdx-license-ids@3.0.22: {} + cross-spawn: 7.0.6 + signal-exit: 4.1.0 - spdy-transport@3.0.0: - dependencies: - debug: 4.4.3 - detect-node: 2.1.0 - hpack.js: 2.1.6 - obuf: 1.1.2 - readable-stream: 3.6.2 - wbuf: 1.7.3 - transitivePeerDependencies: - - supports-color + split-on-first@3.0.0: {} - spdy@4.0.2: - dependencies: - debug: 4.4.3 - handle-thing: 2.0.1 - http-deceiver: 1.2.7 - select-hose: 2.0.0 - spdy-transport: 3.0.0 - transitivePeerDependencies: - - supports-color + split2@4.2.0: {} sprintf-js@1.0.3: {} @@ -14953,10 +18989,6 @@ snapshots: stackback@0.0.2: {} - statuses@1.5.0: {} - - statuses@2.0.1: {} - statuses@2.0.2: {} std-env@3.10.0: {} @@ -14968,6 +19000,14 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + streamsearch@1.1.0: {} + + string-width@3.1.0: + dependencies: + emoji-regex: 7.0.3 + is-fullwidth-code-point: 2.0.0 + strip-ansi: 5.2.0 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -14990,14 +19030,14 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-errors: 1.3.0 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 @@ -15011,7 +19051,7 @@ snapshots: string.prototype.repeat@1.0.0: dependencies: define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 string.prototype.trim@1.2.10: dependencies: @@ -15019,7 +19059,7 @@ snapshots: call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.1 es-object-atoms: 1.1.1 has-property-descriptors: 1.0.2 @@ -15036,10 +19076,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -15049,6 +19085,10 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -15061,18 +19101,29 @@ snapshots: strip-bom@3.0.0: {} + strip-bom@4.0.0: {} + + strip-final-newline@4.0.0: {} + strip-json-comments@3.1.1: {} - strnum@2.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + strnum@2.1.2: {} - style-loader@4.0.0(webpack@5.103.0): + styled-jsx@5.1.6(@babel/core@7.26.0)(react@19.2.3): dependencies: - webpack: 5.103.0(webpack-cli@6.0.1) + client-only: 0.0.1 + react: 19.2.3 + optionalDependencies: + '@babel/core': 7.26.0 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.1): + styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3): dependencies: client-only: 0.0.1 - react: 19.2.1 + react: 19.2.3 optionalDependencies: '@babel/core': 7.28.5 @@ -15081,42 +19132,82 @@ snapshots: client-only: 0.0.1 react: 19.2.0 - supports-color@7.2.0: + stylehacks@7.0.7(postcss@8.5.6): dependencies: - has-flag: 4.0.0 + browserslist: 4.28.1 + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 - supports-color@8.1.1: + supports-color@7.2.0: dependencies: has-flag: 4.0.0 supports-preserve-symlinks-flag@1.0.0: {} - swr@2.3.6(react@19.2.0): + svgo@4.0.0: dependencies: - dequal: 2.0.3 - react: 19.2.0 - use-sync-external-store: 1.6.0(react@19.2.0) + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.1.0 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.4.3 - swr@2.3.6(react@19.2.1): + swr@2.3.8(react@19.2.3): dependencies: dequal: 2.0.3 - react: 19.2.1 - use-sync-external-store: 1.6.0(react@19.2.1) + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) symbol-tree@3.2.4: {} + synckit@0.11.11: + dependencies: + '@pkgr/core': 0.2.9 + optional: true + + syncpack@13.0.4(typescript@5.9.3): + dependencies: + chalk: 5.6.2 + chalk-template: 1.1.2 + commander: 13.1.0 + cosmiconfig: 9.0.0(typescript@5.9.3) + effect: 3.19.13 + enquirer: 2.4.1 + fast-check: 3.23.2 + globby: 14.1.0 + jsonc-parser: 3.3.1 + minimatch: 9.0.5 + npm-package-arg: 12.0.2 + ora: 8.2.0 + prompts: 2.4.2 + read-yaml-file: 2.1.0 + semver: 7.7.3 + tightrope: 0.2.0 + ts-toolbelt: 9.6.0 + transitivePeerDependencies: + - typescript + tailwindcss@4.1.17: {} + tailwindcss@4.1.18: {} + tapable@2.3.0: {} - terser-webpack-plugin@5.3.15(webpack@5.103.0): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - serialize-javascript: 6.0.2 - terser: 5.44.1 - webpack: 5.103.0(webpack-cli@6.0.1) + tarn@3.0.2: {} + + term-size@2.2.1: {} terser@5.44.1: dependencies: @@ -15125,13 +19216,27 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - thingies@2.5.0(tslib@2.8.1): + text-extensions@2.4.0: {} + + thenify-all@1.6.0: dependencies: - tslib: 2.8.1 + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 throttleit@2.1.0: {} - thunky@1.1.0: {} + through@2.3.8: {} + + tightrope@0.2.0: {} + + tildify@2.0.0: {} tiny-invariant@1.3.3: {} @@ -15155,8 +19260,16 @@ snapshots: '@types/tinycolor2': 1.4.6 tinycolor2: 1.6.0 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@3.0.2: {} + + tinyspy@4.0.4: {} + tldts-core@6.1.86: {} tldts-core@7.0.19: {} @@ -15189,6 +19302,8 @@ snapshots: toml@3.0.0: {} + totalist@3.0.1: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -15207,10 +19322,6 @@ snapshots: dependencies: punycode: 2.3.1 - tree-dump@1.1.0(tslib@2.8.1): - dependencies: - tslib: 2.8.1 - tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -15221,6 +19332,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-interface-checker@0.1.13: {} + + ts-toolbelt@9.6.0: {} + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -15228,23 +19343,24 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.16.6(typescript@5.9.3): + tsdown@0.18.2(synckit@0.11.11)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 - chokidar: 4.0.3 - diff: 8.0.2 + defu: 6.1.4 empathic: 2.0.0 - hookable: 5.5.3 - obug: 2.1.0 - rolldown: 1.0.0-beta.51 - rolldown-plugin-dts: 0.18.0(rolldown@1.0.0-beta.51)(typescript@5.9.3) + hookable: 6.0.1 + import-without-cache: 0.2.5 + obug: 2.1.1 + picomatch: 4.0.3 + rolldown: 1.0.0-beta.55 + rolldown-plugin-dts: 0.19.2(rolldown@1.0.0-beta.55)(typescript@5.9.3) semver: 7.7.3 tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 - unconfig-core: 7.4.1 - unrun: 0.2.11 + unconfig-core: 7.4.2 + unrun: 0.2.20(synckit@0.11.11) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -15256,6 +19372,64 @@ snapshots: tslib@2.8.1: {} + tsup@8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.2 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1) + resolve-from: 5.0.0 + rollup: 4.54.0 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.15.3 + postcss: 8.5.6 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsup@8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.2 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + resolve-from: 5.0.0 + rollup: 4.54.0 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.15.3 + postcss: 8.5.6 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsx@4.20.6: dependencies: esbuild: 0.25.12 @@ -15263,6 +19437,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + turbo-darwin-64@2.6.1: optional: true @@ -15298,11 +19479,6 @@ snapshots: type-fest@0.21.3: {} - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -15342,19 +19518,23 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.48.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color + typescript@5.7.2: {} + typescript@5.9.3: {} + ufo@1.6.1: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -15362,23 +19542,63 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - unconfig-core@7.4.1: + unbuild@3.6.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)): + dependencies: + '@rollup/plugin-alias': 5.1.1(rollup@4.54.0) + '@rollup/plugin-commonjs': 28.0.9(rollup@4.54.0) + '@rollup/plugin-json': 6.1.0(rollup@4.54.0) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.54.0) + '@rollup/plugin-replace': 6.0.3(rollup@4.54.0) + '@rollup/pluginutils': 5.3.0(rollup@4.54.0) + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + esbuild: 0.25.12 + fix-dts-default-cjs-exports: 1.0.1 + hookable: 5.5.3 + jiti: 2.6.1 + magic-string: 0.30.21 + mkdist: 2.4.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)) + mlly: 1.8.0 + pathe: 2.0.3 + pkg-types: 2.3.0 + pretty-bytes: 7.1.0 + rollup: 4.54.0 + rollup-plugin-dts: 6.3.0(rollup@4.54.0)(typescript@5.9.3) + scule: 1.3.0 + tinyglobby: 0.2.15 + untyped: 2.0.0 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - sass + - vue + - vue-sfc-transformer + - vue-tsc + + unconfig-core@7.4.2: dependencies: - '@quansync/fs': 0.1.5 - quansync: 0.2.11 + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + undici-types@6.20.0: {} undici-types@6.21.0: {} - unicode-canonical-property-names-ecmascript@2.0.1: {} + undici-types@7.16.0: {} - unicode-match-property-ecmascript@2.0.0: + undici-types@7.8.0: {} + + unhead@1.11.20: dependencies: - unicode-canonical-property-names-ecmascript: 2.0.1 - unicode-property-aliases-ecmascript: 2.2.0 + '@unhead/dom': 1.11.20 + '@unhead/schema': 1.11.20 + '@unhead/shared': 1.11.20 + hookable: 5.5.3 - unicode-match-property-value-ecmascript@2.2.1: {} + unicorn-magic@0.1.0: {} - unicode-property-aliases-ecmascript@2.2.0: {} + unicorn-magic@0.3.0: {} unified@11.0.5: dependencies: @@ -15429,10 +19649,21 @@ snapshots: universal-github-app-jwt@2.2.2: {} + universal-user-agent@6.0.1: {} + universal-user-agent@7.0.3: {} + universalify@0.1.2: {} + + universalify@2.0.1: {} + unpipe@1.0.0: {} + unplugin-utils@0.2.5: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.3 + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -15464,10 +19695,19 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - unrun@0.2.11: + unrun@0.2.20(synckit@0.11.11): + dependencies: + rolldown: 1.0.0-beta.55 + optionalDependencies: + synckit: 0.11.11 + + untyped@2.0.0: dependencies: - '@oxc-project/runtime': 0.96.0 - rolldown: 1.0.0-beta.51 + citty: 0.1.6 + defu: 6.1.4 + jiti: 2.6.1 + knitwork: 1.3.0 + scule: 1.3.0 update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: @@ -15485,55 +19725,567 @@ snapshots: dependencies: react: 19.2.0 - use-sync-external-store@1.6.0(react@19.2.1): + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + + util-deprecate@1.0.2: {} + + uuid@9.0.1: {} + + validate-npm-package-name@6.0.2: {} + + vary@1.1.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-node@3.1.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-node@3.1.2(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-node@3.1.2(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-node@3.1.2(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.5(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-node@3.1.2(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-node@3.2.4(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.0(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-node@3.2.4(@types/node@24.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.0(@types/node@24.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.10.2 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.1 + + vite@6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.10.2 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.2 + + vite@6.3.5(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.13.5 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.2 + + vite@6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.3 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.2 + + vite@7.1.12(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.3 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.2 + + vite@7.3.0(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.10.2 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.2 + + vite@7.3.0(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.13.5 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.2 + + vite@7.3.0(@types/node@24.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.0.10 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.20.6 + yaml: 2.8.2 + + vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.3 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.2 + + vitest@3.1.1(@types/debug@4.1.12)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 3.1.1 + '@vitest/mocker': 3.1.1(vite@6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.1.1 + '@vitest/snapshot': 3.1.1 + '@vitest/spy': 3.1.1 + '@vitest/utils': 3.1.1 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.1.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 25.0.3 + jsdom: 27.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): + dependencies: + '@vitest/expect': 3.1.2 + '@vitest/mocker': 3.1.2(vite@6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.1.2 + '@vitest/snapshot': 3.1.2 + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + vite-node: 3.1.2(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.10.2 + jsdom: 25.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - react: 19.2.1 - - util-deprecate@1.0.2: {} - - utila@0.4.0: {} - - utils-merge@1.0.1: {} - - uuid@8.3.2: {} - - uuid@9.0.1: {} - - valibot@1.2.0(typescript@5.9.3): + '@vitest/expect': 3.1.2 + '@vitest/mocker': 3.1.2(vite@6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.1.2 + '@vitest/snapshot': 3.1.2 + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.3.5(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.1.2(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 optionalDependencies: - typescript: 5.9.3 - - validate-npm-package-license@3.0.4: - dependencies: - spdx-correct: 3.2.0 - spdx-expression-parse: 3.0.1 - - validate-npm-package-name@5.0.1: {} - - vary@1.1.2: {} + '@types/debug': 4.1.12 + '@types/node': 22.10.2 + jsdom: 27.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml - vfile-message@4.0.3: + vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@types/unist': 3.0.3 - unist-util-stringify-position: 4.0.0 + '@vitest/expect': 3.1.2 + '@vitest/mocker': 3.1.2(vite@6.3.5(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.1.2 + '@vitest/snapshot': 3.1.2 + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.3.5(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.1.2(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.13.5 + jsdom: 27.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml - vfile@6.0.3: + vitest@3.1.2(@types/debug@4.1.12)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@types/unist': 3.0.3 - vfile-message: 4.0.3 + '@vitest/expect': 3.1.2 + '@vitest/mocker': 3.1.2(vite@6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.1.2 + '@vitest/snapshot': 3.1.2 + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.3.5(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.1.2(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 25.0.3 + jsdom: 25.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml - vite-node@3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.13.5)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - cac: 6.7.14 + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 debug: 4.4.3 - es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 pathe: 2.0.3 - vite: 7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.0(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@22.13.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.13.5 + '@vitest/ui': 3.2.4(vitest@3.2.4) + jsdom: 27.3.0 transitivePeerDependencies: - - '@types/node' - jiti - less - lightningcss + - msw - sass - sass-embedded - stylus @@ -15543,66 +20295,118 @@ snapshots: - tsx - yaml - vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2): dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.0(@types/node@24.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2) + why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.19.1 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - terser: 5.44.1 - tsx: 4.20.6 - yaml: 2.8.1 + '@types/debug': 4.1.12 + '@types/node': 24.0.10 + '@vitest/ui': 3.2.4(vitest@3.2.4) + jsdom: 27.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml - vite@7.3.0(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + vitest@4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.3) + '@vitest/expect': 4.0.13 + '@vitest/mocker': 4.0.13(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.13 + '@vitest/runner': 4.0.13 + '@vitest/snapshot': 4.0.13 + '@vitest/spy': 4.0.13 + '@vitest/utils': 4.0.13 + debug: 4.4.3 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.19.1 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - terser: 5.44.1 - tsx: 4.20.6 - yaml: 2.8.1 + '@opentelemetry/api': 1.9.0 + '@types/debug': 4.1.12 + '@types/node': 25.0.3 + jsdom: 27.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml - vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@vitest/expect': 4.0.14 - '@vitest/mocker': 4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) - '@vitest/pretty-format': 4.0.14 - '@vitest/runner': 4.0.14 - '@vitest/snapshot': 4.0.14 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 + '@vitest/expect': 4.0.16 + '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.16 + '@vitest/runner': 4.0.16 + '@vitest/snapshot': 4.0.16 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 es-module-lexer: 1.7.0 - expect-type: 1.2.2 + expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 22.19.1 - jsdom: 27.2.0 + '@types/node': 25.0.3 + '@vitest/ui': 4.0.16(vitest@4.0.16) + jsdom: 27.3.0 transitivePeerDependencies: - jiti - less @@ -15616,18 +20420,23 @@ snapshots: - tsx - yaml - w3c-xmlserializer@5.0.0: + vue-demi@0.14.10(vue@3.5.24(typescript@5.9.3)): dependencies: - xml-name-validator: 5.0.0 + vue: 3.5.24(typescript@5.9.3) - watchpack@2.4.4: + vue@3.5.24(typescript@5.9.3): dependencies: - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 + '@vue/compiler-dom': 3.5.24 + '@vue/compiler-sfc': 3.5.24 + '@vue/runtime-dom': 3.5.24 + '@vue/server-renderer': 3.5.24(vue@3.5.24(typescript@5.9.3)) + '@vue/shared': 3.5.24 + optionalDependencies: + typescript: 5.9.3 - wbuf@1.7.3: + w3c-xmlserializer@5.0.0: dependencies: - minimalistic-assert: 1.0.1 + xml-name-validator: 5.0.0 web-vitals@5.1.0: {} @@ -15637,131 +20446,14 @@ snapshots: webidl-conversions@8.0.0: {} - webpack-cli@6.0.1(webpack-dev-server@5.2.2)(webpack@5.103.0): - dependencies: - '@discoveryjs/json-ext': 0.6.3 - '@webpack-cli/configtest': 3.0.1(webpack-cli@6.0.1)(webpack@5.103.0) - '@webpack-cli/info': 3.0.1(webpack-cli@6.0.1)(webpack@5.103.0) - '@webpack-cli/serve': 3.0.1(webpack-cli@6.0.1)(webpack-dev-server@5.2.2)(webpack@5.103.0) - colorette: 2.0.20 - commander: 12.1.0 - cross-spawn: 7.0.6 - envinfo: 7.21.0 - fastest-levenshtein: 1.0.16 - import-local: 3.2.0 - interpret: 3.1.1 - rechoir: 0.8.0 - webpack: 5.103.0(webpack-cli@6.0.1) - webpack-merge: 6.0.1 - optionalDependencies: - webpack-dev-server: 5.2.2(webpack-cli@6.0.1)(webpack@5.103.0) - - webpack-dev-middleware@7.4.5(webpack@5.103.0): - dependencies: - colorette: 2.0.20 - memfs: 4.51.1 - mime-types: 3.0.2 - on-finished: 2.4.1 - range-parser: 1.2.1 - schema-utils: 4.3.3 - optionalDependencies: - webpack: 5.103.0(webpack-cli@6.0.1) - - webpack-dev-server@5.2.2(webpack-cli@6.0.1)(webpack@5.103.0): - dependencies: - '@types/bonjour': 3.5.13 - '@types/connect-history-api-fallback': 1.5.4 - '@types/express': 4.17.25 - '@types/express-serve-static-core': 4.19.7 - '@types/serve-index': 1.9.4 - '@types/serve-static': 1.15.10 - '@types/sockjs': 0.3.36 - '@types/ws': 8.18.1 - ansi-html-community: 0.0.8 - bonjour-service: 1.3.0 - chokidar: 3.6.0 - colorette: 2.0.20 - compression: 1.8.1 - connect-history-api-fallback: 2.0.0 - express: 4.21.2 - graceful-fs: 4.2.11 - http-proxy-middleware: 2.0.9(@types/express@4.17.25) - ipaddr.js: 2.3.0 - launch-editor: 2.12.0 - open: 10.2.0 - p-retry: 6.2.1 - schema-utils: 4.3.3 - selfsigned: 2.4.1 - serve-index: 1.9.1 - sockjs: 0.3.24 - spdy: 4.0.2 - webpack-dev-middleware: 7.4.5(webpack@5.103.0) - ws: 8.18.3 - optionalDependencies: - webpack: 5.103.0(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.103.0) - transitivePeerDependencies: - - bufferutil - - debug - - supports-color - - utf-8-validate - - webpack-merge@6.0.1: - dependencies: - clone-deep: 4.0.1 - flat: 5.0.2 - wildcard: 2.0.1 - - webpack-sources@3.3.3: {} - webpack-virtual-modules@0.6.2: {} - webpack@5.103.0(webpack-cli@6.0.1): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.28.1 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.3 - es-module-lexer: 1.7.0 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.1 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 4.3.3 - tapable: 2.3.0 - terser-webpack-plugin: 5.3.15(webpack@5.103.0) - watchpack: 2.4.4 - webpack-sources: 3.3.3 - optionalDependencies: - webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.103.0) - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - websocket-driver@0.7.4: - dependencies: - http-parser-js: 0.5.10 - safe-buffer: 5.2.1 - websocket-extensions: 0.1.4 - - websocket-extensions@0.1.4: {} - whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@4.0.0: {} whatwg-url@14.2.0: @@ -15810,6 +20502,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -15824,10 +20518,6 @@ snapshots: dependencies: isexe: 2.0.0 - which@3.0.1: - dependencies: - isexe: 2.0.0 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -15837,10 +20527,14 @@ snapshots: dependencies: string-width: 5.1.2 - wildcard@2.0.1: {} - word-wrap@1.2.5: {} + wrap-ansi@5.1.0: + dependencies: + ansi-styles: 3.2.1 + string-width: 3.1.0 + strip-ansi: 5.2.0 + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -15898,26 +20592,69 @@ snapshots: xpath@0.0.34: {} + y18n@4.0.3: {} + + y18n@5.0.8: {} + yallist@3.1.1: {} yaml@2.8.1: {} + yaml@2.8.2: + optional: true + + yargs-parser@13.1.2: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs-parser@21.1.1: {} + + yargs@13.3.2: + dependencies: + cliui: 5.0.0 + find-up: 3.0.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 3.1.0 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 13.1.2 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} yoctocolors-cjs@2.1.3: {} + yoctocolors@2.1.2: {} + yoga-wasm-web@0.3.3: {} + zhead@2.2.4: {} + zod-to-json-schema@3.25.0(zod@3.25.76): dependencies: zod: 3.25.76 - zod-validation-error@4.0.2(zod@3.25.76): + zod-validation-error@4.0.2(zod@4.1.12): dependencies: - zod: 3.25.76 + zod: 4.1.12 zod@3.25.76: {} + zod@4.1.12: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..5e654f5f5 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +packages: + - "./packages/*" + - "./demo/*" + - "./integrations/*" + - "./legacy/*" + - "./action" + - "./php/*" + - "./scripts/*" diff --git a/readme.md b/readme.md new file mode 120000 index 000000000..284a18f47 --- /dev/null +++ b/readme.md @@ -0,0 +1 @@ +packages/cli/README.md \ No newline at end of file diff --git a/readme/ar.md b/readme/ar.md new file mode 100644 index 000000000..a3856b7a6 --- /dev/null +++ b/readme/ar.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - مجموعة أدوات الترجمة مفتوحة المصدر مدعومة بالذكاء الاصطناعي + للترجمة الفورية باستخدام نماذج اللغة الكبيرة. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## تعرّف على المُجمّع 🆕 + +**Lingo.dev Compiler** هو برنامج وسيط مجاني ومفتوح المصدر، مصمم لجعل أي تطبيق React متعدد اللغات في وقت البناء دون الحاجة إلى أي تغييرات على مكونات React الموجودة. + +ثبّته مرة واحدة: + +```bash +npm install @lingo.dev/compiler +``` + +فعّله في إعدادات البناء الخاصة بك: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +شغّل `next build` وشاهد حزم الإسبانية والفرنسية تظهر ✨ + +[اقرأ الوثائق ←](https://lingo.dev/compiler) للحصول على الدليل الكامل، و[انضم إلى Discord الخاص بنا](https://lingo.dev/go/discord) للحصول على المساعدة في إعدادك. + +--- + +### ما الموجود داخل هذا المستودع؟ + +| الأداة | الملخص | الوثائق | +| ------------ | ----------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | ترجمة React في وقت البناء | [/compiler](https://lingo.dev/compiler) | +| **CLI** | ترجمة بأمر واحد لتطبيقات الويب والموبايل، JSON، YAML، markdown، والمزيد | [/cli](https://lingo.dev/cli) | +| **CI/CD** | إرسال الترجمات تلقائيًا عند كل دفع + إنشاء طلبات سحب عند الحاجة | [/ci](https://lingo.dev/ci) | +| **SDK** | ترجمة فورية للمحتوى الذي ينشئه المستخدم | [/sdk](https://lingo.dev/sdk) | + +فيما يلي النقاط السريعة لكل منها 👇 + +--- + +### ⚡️ Lingo.dev CLI + +ترجم الكود والمحتوى مباشرة من الطرفية الخاصة بك. + +```bash +npx lingo.dev@latest run +``` + +يقوم ببصمة كل سلسلة نصية، ويخزن النتائج مؤقتاً، ويعيد ترجمة ما تغير فقط. + +[اتبع الوثائق ←](https://lingo.dev/cli) لتتعلم كيفية إعداده. + +--- + +### 🔄 Lingo.dev CI/CD + +قم بشحن ترجمات مثالية تلقائياً. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +يحافظ على مستودعك نظيفاً ومنتجك متعدد اللغات دون خطوات يدوية. + +[اقرأ الوثائق ←](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +ترجمة فورية لكل طلب للمحتوى الديناميكي. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +مثالي للدردشة وتعليقات المستخدمين وتدفقات الوقت الفعلي الأخرى. + +[اقرأ الوثائق ←](https://lingo.dev/sdk) + +--- + +## 🤝 المجتمع + +نحن مدفوعون بالمجتمع ونحب المساهمات! + +- لديك فكرة؟ [افتح مشكلة](https://github.com/lingodotdev/lingo.dev/issues) +- تريد إصلاح شيء ما؟ [أرسل طلب سحب](https://github.com/lingodotdev/lingo.dev/pulls) +- تحتاج مساعدة؟ [انضم إلى Discord الخاص بنا](https://lingo.dev/go/discord) + +## ⭐ تاريخ النجوم + +إذا أعجبك ما نقوم به، امنحنا ⭐ وساعدنا في الوصول إلى 6,000 نجمة! 🌟 + +[ + +![مخطط تاريخ النجوم](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 اقرأني بلغات أخرى + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +لا ترى لغتك؟ أضفها إلى [`i18n.json`](./i18n.json) وافتح طلب سحب! + +**تنسيق اللغة المحلية:** استخدم رموز [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale): `language[-Script][-REGION]` + +- اللغة: ISO 639-1/2/3 أحرف صغيرة (`en`، `zh`، `bho`) +- الكتابة: ISO 15924 حالة العنوان (`Hans`، `Hant`، `Latn`) +- المنطقة: ISO 3166-1 alpha-2 أحرف كبيرة (`US`، `CN`، `IN`) +- أمثلة: `en`، `pt-BR`، `zh-Hans`، `sr-Cyrl-RS` diff --git a/readme/as-IN.md b/readme/as-IN.md new file mode 100644 index 000000000..f8ae5266e --- /dev/null +++ b/readme/as-IN.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - মুক্ত উৎস, AI-চালিত i18n টুলকিট LLM ৰ সৈতে তাৎক্ষণিক + স্থানীয়কৰণৰ বাবে। + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compiler ৰ সৈতে পৰিচয় হওক 🆕 + +**Lingo.dev Compiler** এটা বিনামূলীয়া, মুক্ত উৎস কম্পাইলাৰ মিডলৱেৰ, যিকোনো React এপক বিল্ড সময়ত বহুভাষিক কৰিবলৈ ডিজাইন কৰা হৈছে বৰ্তমান React কম্পোনেণ্টসমূহত কোনো পৰিৱৰ্তনৰ প্ৰয়োজন নোহোৱাকৈ। + +এবাৰ ইনষ্টল কৰক: + +```bash +npm install @lingo.dev/compiler +``` + +আপোনাৰ বিল্ড কনফিগত সক্ষম কৰক: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` চলাওক আৰু স্পেনিছ আৰু ফ্ৰেঞ্চ বাণ্ডল ওলাই অহা চাওক ✨ + +[দস্তাবেজ পঢ়ক →](https://lingo.dev/compiler) সম্পূৰ্ণ গাইডৰ বাবে, আৰু [আমাৰ Discord ত যোগদান কৰক](https://lingo.dev/go/discord) আপোনাৰ ছেটআপত সহায়ৰ বাবে। + +--- + +### এই ৰিপ'ৰ ভিতৰত কি আছে? + +| টুল | TL;DR | দস্তাবেজ | +| ------------ | ----------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | বিল্ড-সময় React স্থানীয়কৰণ | [/compiler](https://lingo.dev/compiler) | +| **CLI** | ৱেব আৰু মোবাইল এপ, JSON, YAML, markdown, + অধিকৰ বাবে এক-আদেশ স্থানীয়কৰণ | [/cli](https://lingo.dev/cli) | +| **CI/CD** | প্ৰতিটো পুছত স্বয়ংক্ৰিয়-কমিট অনুবাদ + প্ৰয়োজন হ'লে pull request সৃষ্টি কৰক | [/ci](https://lingo.dev/ci) | +| **SDK** | ব্যৱহাৰকাৰী-সৃষ্ট সমলৰ বাবে ৰিয়েলটাইম অনুবাদ | [/sdk](https://lingo.dev/sdk) | + +তলত প্ৰতিটোৰ বাবে দ্ৰুত তথ্য দিয়া হৈছে 👇 + +--- + +### ⚡️ Lingo.dev CLI + +আপোনাৰ টাৰ্মিনেলৰ পৰা পোনপটীয়াকৈ ক'ড আৰু সমল অনুবাদ কৰক। + +```bash +npx lingo.dev@latest run +``` + +ই প্ৰতিটো ষ্ট্ৰিং ফিংগাৰপ্ৰিণ্ট কৰে, ফলাফল কেশ্ব কৰে, আৰু কেৱল সলনি হোৱা অংশহে পুনৰ অনুবাদ কৰে। + +[দস্তাবেজ অনুসৰণ কৰক →](https://lingo.dev/cli) ইয়াক কেনেকৈ ছেটআপ কৰিব লাগে জানিবলৈ। + +--- + +### 🔄 Lingo.dev CI/CD + +স্বয়ংক্ৰিয়ভাৱে নিখুঁত অনুবাদ প্ৰদান কৰক। + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +আপোনাৰ ৰিপ' সেউজীয়া আৰু আপোনাৰ প্ৰডাক্ট বহুভাষিক ৰাখে হস্তচালিত পদক্ষেপ অবিহনে। + +[দস্তাবেজ পঢ়ক →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +গতিশীল সমলৰ বাবে তাৎক্ষণিক প্ৰতি-অনুৰোধ অনুবাদ। + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +চেট, ব্যৱহাৰকাৰীৰ মন্তব্য, আৰু অন্যান্য ৰিয়েল-টাইম প্ৰবাহৰ বাবে নিখুঁত। + +[দস্তাবেজ পঢ়ক →](https://lingo.dev/sdk) + +--- + +## 🤝 সম্প্ৰদায় + +আমি সম্প্ৰদায়-চালিত আৰু অৱদান ভাল পাওঁ! + +- কিবা ধাৰণা আছে? [এটা ইছ্যু খোলক](https://github.com/lingodotdev/lingo.dev/issues) +- কিবা ঠিক কৰিব বিচাৰে? [এটা PR পঠিয়াওক](https://github.com/lingodotdev/lingo.dev/pulls) +- সহায়ৰ প্ৰয়োজন? [আমাৰ Discord-ত যোগদান কৰক](https://lingo.dev/go/discord) + +## ⭐ তৰা ইতিহাস + +যদি আপুনি আমি কৰি থকা কামটো ভাল পায়, আমাক এটা ⭐ দিয়ক আৰু আমাক 6,000 তৰা লাভ কৰাত সহায় কৰক! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 অন্যান্য ভাষাত Readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +আপোনাৰ ভাষা দেখা নাই? ইয়াক `i18n.json`ত যোগ কৰক আৰু এটা PR খোলক! + +**Locale ফৰ্মেট:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) ক'ড ব্যৱহাৰ কৰক: `language[-Script][-REGION]` + +- ভাষা: ISO 639-1/2/3 সৰু আখৰ (`en`, `zh`, `bho`) +- লিপি: ISO 15924 শিৰোনাম কেছ (`Hans`, `Hant`, `Latn`) +- অঞ্চল: ISO 3166-1 alpha-2 ডাঙৰ আখৰ (`US`, `CN`, `IN`) +- উদাহৰণ: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/bho.md b/readme/bho.md new file mode 100644 index 000000000..b4fa458b8 --- /dev/null +++ b/readme/bho.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - ओपन-सोर्स, AI-संचालित i18n टूलकिट जवन LLMs के साथ तुरंत + स्थानीयकरण खातिर बा। + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compiler से मिलीं 🆕 + +**Lingo.dev Compiler** एगो मुफ्त, ओपन-सोर्स कंपाइलर मिडलवेयर बा, जवन कवनो भी React ऐप के बिल्ड टाइम पर बहुभाषी बनावे खातिर डिजाइन कइल गइल बा बिना मौजूदा React कंपोनेंट्स में कवनो बदलाव के जरूरत के। + +एक बेर इंस्टॉल करीं: + +```bash +npm install @lingo.dev/compiler +``` + +अपना बिल्ड कॉन्फिग में सक्षम करीं: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` चलाईं आ स्पेनिश आ फ्रेंच बंडल्स के निकलत देखीं ✨ + +[डॉक्स पढ़ीं →](https://lingo.dev/compiler) पूरा गाइड खातिर, आ [हमनी के Discord में शामिल होईं](https://lingo.dev/go/discord) अपना सेटअप में मदद पावे खातिर। + +--- + +### ए रेपो में का बा? + +| टूल | TL;DR | डॉक्स | +| ------------ | ------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | बिल्ड-टाइम React स्थानीयकरण | [/compiler](https://lingo.dev/compiler) | +| **CLI** | वेब आ मोबाइल ऐप्स, JSON, YAML, markdown, + अउरी खातिर एक-कमांड स्थानीयकरण | [/cli](https://lingo.dev/cli) | +| **CI/CD** | हर पुश पर ऑटो-कमिट अनुवाद + जरूरत पड़ला पर पुल रिक्वेस्ट बनाईं | [/ci](https://lingo.dev/ci) | +| **SDK** | यूजर-जेनरेटेड कंटेंट खातिर रियलटाइम अनुवाद | [/sdk](https://lingo.dev/sdk) | + +नीचे हर एक के खातिर त्वरित हिट बा 👇 + +--- + +### ⚡️ Lingo.dev CLI + +अपना टर्मिनल से सीधे कोड आ सामग्री के अनुवाद करीं। + +```bash +npx lingo.dev@latest run +``` + +ई हर स्ट्रिंग के फिंगरप्रिंट करेला, परिणाम के कैश करेला, आ सिर्फ ओही के दोबारा अनुवाद करेला जवन बदल गइल बा। + +[दस्तावेज़ के फॉलो करीं →](https://lingo.dev/cli) एकरा के सेट अप करे के तरीका जाने खातिर। + +--- + +### 🔄 Lingo.dev CI/CD + +परफेक्ट अनुवाद के अपने आप भेजीं। + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +राउर रेपो के हरियर आ राउर प्रोडक्ट के बहुभाषी बनवले रहेला बिना मैनुअल स्टेप के। + +[दस्तावेज़ पढ़ीं →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +डायनामिक सामग्री खातिर तुरंत प्रति-अनुरोध अनुवाद। + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +चैट, यूजर कमेंट, आ अउरी रियल-टाइम फ्लो खातिर परफेक्ट बा। + +[दस्तावेज़ पढ़ीं →](https://lingo.dev/sdk) + +--- + +## 🤝 समुदाय + +हमनी के समुदाय-संचालित बानी आ योगदान के प्यार करीला! + +- कवनो विचार बा? [एगो इश्यू खोलीं](https://github.com/lingodotdev/lingo.dev/issues) +- कुछ ठीक करे के चाहत बानी? [एगो PR भेजीं](https://github.com/lingodotdev/lingo.dev/pulls) +- मदद चाहीं? [हमनी के Discord में शामिल होईं](https://lingo.dev/go/discord) + +## ⭐ स्टार इतिहास + +अगर राउर का हमनी के करत बानी ओकरा पसंद बा, त हमनी के एगो ⭐ दीं आ 6,000 स्टार तक पहुंचे में मदद करीं! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 दोसर भाषा में रीडमी + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +अपना भाषा ना देख रहल बानी? एकरा [`i18n.json`](./i18n.json) में जोड़ के PR खोल दीं! + +**लोकेल फॉर्मेट:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) कोड इस्तेमाल करीं: `language[-Script][-REGION]` + +- भाषा: ISO 639-1/2/3 छोट अक्षर (`en`, `zh`, `bho`) +- लिपि: ISO 15924 टाइटल केस (`Hans`, `Hant`, `Latn`) +- क्षेत्र: ISO 3166-1 alpha-2 बड़ अक्षर (`US`, `CN`, `IN`) +- उदाहरण: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/bn.md b/readme/bn.md new file mode 100644 index 000000000..f5dd3382e --- /dev/null +++ b/readme/bn.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - ওপেন-সোর্স, AI-চালিত i18n টুলকিট যা LLM-এর মাধ্যমে তাৎক্ষণিক + স্থানীয়করণ প্রদান করে। + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## কম্পাইলারের সাথে পরিচিত হন 🆕 + +**Lingo.dev Compiler** হলো একটি বিনামূল্যের, ওপেন-সোর্স কম্পাইলার মিডলওয়্যার, যা বিদ্যমান React কম্পোনেন্টে কোনো পরিবর্তন ছাড়াই বিল্ড টাইমে যেকোনো React অ্যাপকে বহুভাষিক করার জন্য ডিজাইন করা হয়েছে। + +একবার ইনস্টল করুন: + +```bash +npm install @lingo.dev/compiler +``` + +আপনার বিল্ড কনফিগে সক্রিয় করুন: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` চালান এবং স্প্যানিশ ও ফরাসি বান্ডেল বের হতে দেখুন ✨ + +সম্পূর্ণ গাইডের জন্য [ডকুমেন্টেশন পড়ুন →](https://lingo.dev/compiler), এবং আপনার সেটআপে সাহায্য পেতে [আমাদের Discord-এ যোগ দিন](https://lingo.dev/go/discord)। + +--- + +### এই রিপোজিটরিতে কী আছে? + +| টুল | সংক্ষিপ্ত বিবরণ | ডকুমেন্টেশন | +| ------------ | ------------------------------------------------------------------------------------------ | --------------------------------------- | +| **Compiler** | বিল্ড-টাইম React স্থানীয়করণ | [/compiler](https://lingo.dev/compiler) | +| **CLI** | ওয়েব এবং মোবাইল অ্যাপ, JSON, YAML, markdown এবং আরও অনেক কিছুর জন্য এক-কমান্ড স্থানীয়করণ | [/cli](https://lingo.dev/cli) | +| **CI/CD** | প্রতিটি পুশে স্বয়ংক্রিয়ভাবে অনুবাদ কমিট করুন + প্রয়োজনে পুল রিকোয়েস্ট তৈরি করুন | [/ci](https://lingo.dev/ci) | +| **SDK** | ইউজার-জেনারেটেড কন্টেন্টের জন্য রিয়েলটাইম অনুবাদ | [/sdk](https://lingo.dev/sdk) | + +নিচে প্রতিটির জন্য দ্রুত তথ্য রয়েছে 👇 + +--- + +### ⚡️ Lingo.dev CLI + +আপনার টার্মিনাল থেকে সরাসরি কোড এবং কন্টেন্ট অনুবাদ করুন। + +```bash +npx lingo.dev@latest run +``` + +এটি প্রতিটি স্ট্রিং ফিঙ্গারপ্রিন্ট করে, ফলাফল ক্যাশ করে এবং শুধুমাত্র পরিবর্তিত অংশ পুনরায় অনুবাদ করে। + +এটি কীভাবে সেটআপ করবেন তা জানতে [ডকুমেন্টেশন অনুসরণ করুন →](https://lingo.dev/cli)। + +--- + +### 🔄 Lingo.dev CI/CD + +স্বয়ংক্রিয়ভাবে নিখুঁত অনুবাদ ডেলিভার করুন। + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +ম্যানুয়াল ধাপ ছাড়াই আপনার রিপোজিটরি সচল এবং আপনার প্রোডাক্ট বহুভাষিক রাখে। + +[ডকুমেন্টেশন পড়ুন →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +ডায়নামিক কন্টেন্টের জন্য তাৎক্ষণিক প্রতি-রিকোয়েস্ট অনুবাদ। + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +চ্যাট, ইউজার কমেন্ট এবং অন্যান্য রিয়েল-টাইম ফ্লোর জন্য পারফেক্ট। + +[ডকুমেন্টেশন পড়ুন →](https://lingo.dev/sdk) + +--- + +## 🤝 কমিউনিটি + +আমরা কমিউনিটি-চালিত এবং অবদান পছন্দ করি! + +- কোনো আইডিয়া আছে? [একটি ইস্যু খুলুন](https://github.com/lingodotdev/lingo.dev/issues) +- কিছু ঠিক করতে চান? [একটি PR পাঠান](https://github.com/lingodotdev/lingo.dev/pulls) +- সাহায্য প্রয়োজন? [আমাদের ডিসকর্ডে যোগ দিন](https://lingo.dev/go/discord) + +## ⭐ স্টার হিস্ট্রি + +আমরা যা করছি তা যদি আপনার পছন্দ হয়, আমাদের একটি ⭐ দিন এবং ৬,০০০ স্টারে পৌঁছাতে সাহায্য করুন! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 অন্যান্য ভাষায় রিডমি + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +আপনার ভাষা দেখছেন না? এটি [`i18n.json`](./i18n.json)-এ যোগ করুন এবং একটি PR খুলুন! + +**লোকেল ফরম্যাট:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) কোড ব্যবহার করুন: `language[-Script][-REGION]` + +- ভাষা: ISO 639-1/2/3 ছোট হাতের অক্ষর (`en`, `zh`, `bho`) +- লিপি: ISO 15924 টাইটেল কেস (`Hans`, `Hant`, `Latn`) +- অঞ্চল: ISO 3166-1 alpha-2 বড় হাতের অক্ষর (`US`, `CN`, `IN`) +- উদাহরণ: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/de.md b/readme/de.md new file mode 100644 index 000000000..4d04b0b22 --- /dev/null +++ b/readme/de.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - Open-Source, KI-gestütztes i18n-Toolkit für sofortige + Lokalisierung mit LLMs. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + Lizenz + + + Letzter Commit + + + Product Hunt #1 DevTool des Monats + + + Product Hunt #1 DevTool der Woche + + + Product Hunt #2 Produkt des Tages + + + Github trending + +

    + +--- + +## Lernen Sie den Compiler kennen 🆕 + +**Lingo.dev Compiler** ist eine kostenlose Open-Source-Compiler-Middleware, die entwickelt wurde, um jede React-App zur Build-Zeit mehrsprachig zu machen, ohne dass Änderungen an den bestehenden React-Komponenten erforderlich sind. + +Einmalig installieren: + +```bash +npm install @lingo.dev/compiler +``` + +In Ihrer Build-Konfiguration aktivieren: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +Führen Sie `next build` aus und beobachten Sie, wie spanische und französische Bundles erscheinen ✨ + +[Lesen Sie die Dokumentation →](https://lingo.dev/compiler) für die vollständige Anleitung und [treten Sie unserem Discord bei](https://lingo.dev/go/discord), um Hilfe bei Ihrem Setup zu erhalten. + +--- + +### Was befindet sich in diesem Repository? + +| Tool | TL;DR | Dokumentation | +| ------------ | -------------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | React-Lokalisierung zur Build-Zeit | [/compiler](https://lingo.dev/compiler) | +| **CLI** | Ein-Befehl-Lokalisierung für Web- und Mobile-Apps, JSON, YAML, Markdown und mehr | [/cli](https://lingo.dev/cli) | +| **CI/CD** | Auto-Commit von Übersetzungen bei jedem Push + Erstellung von Pull Requests bei Bedarf | [/ci](https://lingo.dev/ci) | +| **SDK** | Echtzeit-Übersetzung für nutzergenerierte Inhalte | [/sdk](https://lingo.dev/sdk) | + +Hier sind die wichtigsten Punkte im Überblick 👇 + +--- + +### ⚡️ Lingo.dev CLI + +Übersetze Code & Inhalte direkt aus deinem Terminal. + +```bash +npx lingo.dev@latest run +``` + +Es erstellt einen Fingerabdruck für jeden String, speichert Ergebnisse im Cache und übersetzt nur das, was sich geändert hat. + +[Folge der Dokumentation →](https://lingo.dev/cli), um zu erfahren, wie du es einrichtest. + +--- + +### 🔄 Lingo.dev CI/CD + +Liefere perfekte Übersetzungen automatisch aus. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +Hält dein Repository sauber und dein Produkt mehrsprachig ohne manuelle Schritte. + +[Dokumentation lesen →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +Sofortige Übersetzung pro Anfrage für dynamische Inhalte. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +Perfekt für Chat, Benutzerkommentare und andere Echtzeit-Abläufe. + +[Dokumentation lesen →](https://lingo.dev/sdk) + +--- + +## 🤝 Community + +Wir sind community-getrieben und lieben Beiträge! + +- Hast du eine Idee? [Öffne ein Issue](https://github.com/lingodotdev/lingo.dev/issues) +- Möchtest du etwas beheben? [Sende einen PR](https://github.com/lingodotdev/lingo.dev/pulls) +- Brauchst du Hilfe? [Tritt unserem Discord bei](https://lingo.dev/go/discord) + +## ⭐ Star-Verlauf + +Wenn dir gefällt, was wir tun, gib uns einen ⭐ und hilf uns, 6.000 Sterne zu erreichen! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 Readme in anderen Sprachen + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +Sehen Sie Ihre Sprache nicht? Fügen Sie sie zu [`i18n.json`](./i18n.json) hinzu und öffnen Sie einen PR! + +**Locale-Format:** Verwenden Sie [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale)-Codes: `language[-Script][-REGION]` + +- Sprache: ISO 639-1/2/3 Kleinbuchstaben (`en`, `zh`, `bho`) +- Schrift: ISO 15924 Großschreibung am Wortanfang (`Hans`, `Hant`, `Latn`) +- Region: ISO 3166-1 alpha-2 Großbuchstaben (`US`, `CN`, `IN`) +- Beispiele: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/en.md b/readme/en.md new file mode 120000 index 000000000..a645183ed --- /dev/null +++ b/readme/en.md @@ -0,0 +1 @@ +../packages/cli/README.md \ No newline at end of file diff --git a/readme/es.md b/readme/es.md new file mode 100644 index 000000000..8ac32601f --- /dev/null +++ b/readme/es.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - kit de herramientas i18n de código abierto impulsado por IA + para localización instantánea con LLMs. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Conoce el Compiler 🆕 + +**Lingo.dev Compiler** es un middleware de compilación gratuito y de código abierto, diseñado para hacer que cualquier aplicación React sea multilingüe en tiempo de compilación sin requerir cambios en los componentes React existentes. + +Instala una vez: + +```bash +npm install @lingo.dev/compiler +``` + +Habilita en tu configuración de compilación: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +Ejecuta `next build` y observa cómo aparecen los bundles en español y francés ✨ + +[Lee la documentación →](https://lingo.dev/compiler) para la guía completa, y [únete a nuestro Discord](https://lingo.dev/go/discord) para obtener ayuda con tu configuración. + +--- + +### ¿Qué hay dentro de este repositorio? + +| Herramienta | Resumen | Documentación | +| ------------ | -------------------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | Localización de React en tiempo de compilación | [/compiler](https://lingo.dev/compiler) | +| **CLI** | Localización con un solo comando para aplicaciones web y móviles, JSON, YAML, markdown y más | [/cli](https://lingo.dev/cli) | +| **CI/CD** | Auto-commit de traducciones en cada push + creación de pull requests si es necesario | [/ci](https://lingo.dev/ci) | +| **SDK** | Traducción en tiempo real para contenido generado por usuarios | [/sdk](https://lingo.dev/sdk) | + +A continuación, los puntos clave de cada uno 👇 + +--- + +### ⚡️ CLI de Lingo.dev + +Traduce código y contenido directamente desde tu terminal. + +```bash +npx lingo.dev@latest run +``` + +Genera una huella digital de cada cadena, almacena los resultados en caché y solo vuelve a traducir lo que ha cambiado. + +[Sigue la documentación →](https://lingo.dev/cli) para aprender cómo configurarlo. + +--- + +### 🔄 CI/CD de Lingo.dev + +Entrega traducciones perfectas automáticamente. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +Mantiene tu repositorio en verde y tu producto multilingüe sin pasos manuales. + +[Lee la documentación →](https://lingo.dev/ci) + +--- + +### 🧩 SDK de Lingo.dev + +Traducción instantánea por solicitud para contenido dinámico. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +Perfecto para chat, comentarios de usuarios y otros flujos en tiempo real. + +[Lee la documentación →](https://lingo.dev/sdk) + +--- + +## 🤝 Comunidad + +Somos una comunidad impulsada por sus miembros y nos encantan las contribuciones. + +- ¿Tienes una idea? [Abre un issue](https://github.com/lingodotdev/lingo.dev/issues) +- ¿Quieres arreglar algo? [Envía un PR](https://github.com/lingodotdev/lingo.dev/pulls) +- ¿Necesitas ayuda? [Únete a nuestro Discord](https://lingo.dev/go/discord) + +## ⭐ Historial de estrellas + +Si te gusta lo que hacemos, danos una ⭐ y ayúdanos a alcanzar las 6000 estrellas. 🌟 + +[ + +![Gráfico de historial de estrellas](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 Léeme en otros idiomas + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +¿No ves tu idioma? Añádelo a [`i18n.json`](./i18n.json) y abre un PR. + +**Formato de configuración regional:** usa códigos [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale): `language[-Script][-REGION]` + +- Idioma: ISO 639-1/2/3 en minúsculas (`en`, `zh`, `bho`) +- Escritura: ISO 15924 en mayúscula inicial (`Hans`, `Hant`, `Latn`) +- Región: ISO 3166-1 alpha-2 en mayúsculas (`US`, `CN`, `IN`) +- Ejemplos: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/fa.md b/readme/fa.md new file mode 100644 index 000000000..bccd8692a --- /dev/null +++ b/readme/fa.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - ابزار i18n متن‌باز و مبتنی بر هوش مصنوعی برای بومی‌سازی فوری + با LLM‌ها. + +

    + +
    + +

    + کامپایلر Lingo.dev • + MCP Lingo.dev • + CLI Lingo.dev • + CI/CD Lingo.dev • + SDK Lingo.dev +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## با کامپایلر آشنا شوید 🆕 + +**کامپایلر Lingo.dev** یک میان‌افزار کامپایلر رایگان و متن‌باز است که برای چندزبانه کردن هر برنامه React در زمان ساخت، بدون نیاز به تغییر در کامپوننت‌های موجود React طراحی شده است. + +یک‌بار نصب کنید: + +```bash +npm install @lingo.dev/compiler +``` + +در تنظیمات ساخت خود فعال کنید: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +دستور `next build` را اجرا کنید و بسته‌های اسپانیایی و فرانسوی را ببینید که ظاهر می‌شوند ✨ + +[مستندات را بخوانید →](https://lingo.dev/compiler) برای راهنمای کامل، و [به Discord ما بپیوندید](https://lingo.dev/go/discord) تا در راه‌اندازی کمک بگیرید. + +--- + +### داخل این مخزن چه چیزی است؟ + +| ابزار | خلاصه | مستندات | +| ------------ | ------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | بومی‌سازی React در زمان ساخت | [/compiler](https://lingo.dev/compiler) | +| **CLI** | بومی‌سازی با یک دستور برای برنامه‌های وب و موبایل، JSON، YAML، markdown و بیشتر | [/cli](https://lingo.dev/cli) | +| **CI/CD** | کامیت خودکار ترجمه‌ها در هر push و ایجاد pull request در صورت نیاز | [/ci](https://lingo.dev/ci) | +| **SDK** | ترجمه لحظه‌ای برای محتوای تولید شده توسط کاربر | [/sdk](https://lingo.dev/sdk) | + +در زیر نکات کلیدی برای هر کدام آمده است 👇 + +--- + +### ⚡️ رابط خط فرمان Lingo.dev + +کد و محتوا را مستقیماً از ترمینال خود ترجمه کنید. + +```bash +npx lingo.dev@latest run +``` + +این ابزار اثر انگشت هر رشته را ثبت می‌کند، نتایج را کش می‌کند و فقط آنچه را که تغییر کرده دوباره ترجمه می‌کند. + +برای یادگیری نحوه راه‌اندازی [مستندات را دنبال کنید →](https://lingo.dev/cli). + +--- + +### 🔄 یکپارچه‌سازی مداوم Lingo.dev + +ترجمه‌های بی‌نقص را به‌طور خودکار ارسال کنید. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +مخزن شما را سبز و محصولتان را چندزبانه نگه می‌دارد بدون نیاز به مراحل دستی. + +[مستندات را بخوانید →](https://lingo.dev/ci) + +--- + +### 🧩 کیت توسعه Lingo.dev + +ترجمه آنی برای هر درخواست برای محتوای پویا. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +مناسب برای چت، نظرات کاربران و سایر جریان‌های بلادرنگ. + +[مستندات را بخوانید →](https://lingo.dev/sdk) + +--- + +## 🤝 انجمن + +ما جامعه‌محور هستیم و مشارکت‌ها را دوست داریم! + +- ایده‌ای دارید؟ [یک مسئله باز کنید](https://github.com/lingodotdev/lingo.dev/issues) +- می‌خواهید چیزی را اصلاح کنید؟ [یک درخواست ارسال کنید](https://github.com/lingodotdev/lingo.dev/pulls) +- به کمک نیاز دارید؟ [به دیسکورد ما بپیوندید](https://lingo.dev/go/discord) + +## ⭐ تاریخچه ستاره‌ها + +اگر کاری که انجام می‌دهیم را دوست دارید، به ما یک ستاره ⭐ بدهید و به ما کمک کنید به 6000 ستاره برسیم! 🌟 + +[ + +![نمودار تاریخچه ستاره](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 راهنما به زبان‌های دیگر + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +زبان خود را نمی‌بینید؟ آن را به `i18n.json` اضافه کنید و یک PR باز کنید! + +**قالب محلی:** از کدهای [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) استفاده کنید: `language[-Script][-REGION]` + +- زبان: ISO 639-1/2/3 حروف کوچک (`en`، `zh`، `bho`) +- خط: ISO 15924 حروف بزرگ و کوچک (`Hans`، `Hant`، `Latn`) +- منطقه: ISO 3166-1 alpha-2 حروف بزرگ (`US`، `CN`، `IN`) +- نمونه‌ها: `en`، `pt-BR`، `zh-Hans`، `sr-Cyrl-RS` diff --git a/readme/fr.md b/readme/fr.md new file mode 100644 index 000000000..8d8f4669b --- /dev/null +++ b/readme/fr.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - boîte à outils i18n open-source et alimentée par l'IA pour + une localisation instantanée avec les LLM. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + Licence + + + Dernier commit + + + Product Hunt #1 DevTool du mois + + + Product Hunt #1 DevTool de la semaine + + + Product Hunt #2 produit du jour + + + Tendances GitHub + +

    + +--- + +## Découvrez le Compiler 🆕 + +**Lingo.dev Compiler** est un middleware de compilation gratuit et open-source, conçu pour rendre n'importe quelle application React multilingue au moment de la compilation sans nécessiter de modifications des composants React existants. + +Installez une seule fois : + +```bash +npm install @lingo.dev/compiler +``` + +Activez dans votre configuration de build : + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +Lancez `next build` et regardez les bundles espagnol et français apparaître ✨ + +[Consultez la documentation →](https://lingo.dev/compiler) pour le guide complet, et [rejoignez notre Discord](https://lingo.dev/go/discord) pour obtenir de l'aide avec votre configuration. + +--- + +### Que contient ce dépôt ? + +| Outil | En bref | Documentation | +| ------------ | -------------------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | Localisation React au moment de la compilation | [/compiler](https://lingo.dev/compiler) | +| **CLI** | Localisation en une commande pour applications web et mobiles, JSON, YAML, markdown, et plus | [/cli](https://lingo.dev/cli) | +| **CI/CD** | Commit automatique des traductions à chaque push + création de pull requests si nécessaire | [/ci](https://lingo.dev/ci) | +| **SDK** | Traduction en temps réel pour le contenu généré par les utilisateurs | [/sdk](https://lingo.dev/sdk) | + +Voici les points essentiels pour chacun 👇 + +--- + +### ⚡️ CLI Lingo.dev + +Traduisez le code et le contenu directement depuis votre terminal. + +```bash +npx lingo.dev@latest run +``` + +Il empreinte chaque chaîne, met en cache les résultats et ne retraduit que ce qui a changé. + +[Consultez la documentation →](https://lingo.dev/cli) pour apprendre à le configurer. + +--- + +### 🔄 CI/CD Lingo.dev + +Livrez des traductions parfaites automatiquement. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +Garde votre dépôt au vert et votre produit multilingue sans les étapes manuelles. + +[Consultez la documentation →](https://lingo.dev/ci) + +--- + +### 🧩 SDK Lingo.dev + +Traduction instantanée par requête pour le contenu dynamique. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +Parfait pour le chat, les commentaires des utilisateurs et autres flux en temps réel. + +[Consultez la documentation →](https://lingo.dev/sdk) + +--- + +## 🤝 Communauté + +Nous sommes portés par la communauté et adorons les contributions ! + +- Vous avez une idée ? [Ouvrez une issue](https://github.com/lingodotdev/lingo.dev/issues) +- Vous voulez corriger quelque chose ? [Envoyez une PR](https://github.com/lingodotdev/lingo.dev/pulls) +- Besoin d'aide ? [Rejoignez notre Discord](https://lingo.dev/go/discord) + +## ⭐ Historique des étoiles + +Si vous aimez ce que nous faisons, donnez-nous une ⭐ et aidez-nous à atteindre 6 000 étoiles ! 🌟 + +[ + +![Graphique de l'historique des étoiles](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 Readme dans d'autres langues + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +Vous ne voyez pas votre langue ? Ajoutez-la à [`i18n.json`](./i18n.json) et ouvrez une PR ! + +**Format de locale :** utilisez les codes [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) : `language[-Script][-REGION]` + +- Langue : ISO 639-1/2/3 en minuscules (`en`, `zh`, `bho`) +- Écriture : ISO 15924 en casse de titre (`Hans`, `Hant`, `Latn`) +- Région : ISO 3166-1 alpha-2 en majuscules (`US`, `CN`, `IN`) +- Exemples : `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/gu-IN.md b/readme/gu-IN.md new file mode 100644 index 000000000..ce7a0c484 --- /dev/null +++ b/readme/gu-IN.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - ઓપન-સોર્સ, AI-સંચાલિત i18n ટૂલકિટ LLMs સાથે તાત્કાલિક + સ્થાનિકીકરણ માટે. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compiler સાથે મળો 🆕 + +**Lingo.dev Compiler** એક મફત, ઓપન-સોર્સ કમ્પાઇલર મિડલવેર છે, જે કોઈપણ React એપ્લિકેશનને બિલ્ડ સમયે બહુભાષી બનાવવા માટે ડિઝાઇન કરવામાં આવ્યું છે, જેમાં હાલના React કમ્પોનન્ટ્સમાં કોઈ ફેરફારની જરૂર નથી. + +એકવાર ઇન્સ્ટોલ કરો: + +```bash +npm install @lingo.dev/compiler +``` + +તમારા બિલ્ડ કોન્ફિગમાં સક્ષમ કરો: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` ચલાવો અને સ્પેનિશ અને ફ્રેન્ચ બંડલ્સ બહાર આવતા જુઓ ✨ + +સંપૂર્ણ માર્ગદર્શિકા માટે [દસ્તાવેજો વાંચો →](https://lingo.dev/compiler), અને તમારા સેટઅપમાં મદદ મેળવવા માટે [અમારા Discord માં જોડાઓ](https://lingo.dev/go/discord). + +--- + +### આ રેપોમાં શું છે? + +| ટૂલ | TL;DR | દસ્તાવેજો | +| ------------ | ---------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | બિલ્ડ-ટાઇમ React સ્થાનિકીકરણ | [/compiler](https://lingo.dev/compiler) | +| **CLI** | વેબ અને મોબાઇલ એપ્સ, JSON, YAML, markdown અને વધુ માટે એક-કમાન્ડ સ્થાનિકીકરણ | [/cli](https://lingo.dev/cli) | +| **CI/CD** | દરેક પુશ પર ઓટો-કમિટ અનુવાદો + જરૂર હોય તો પુલ રિક્વેસ્ટ બનાવો | [/ci](https://lingo.dev/ci) | +| **SDK** | યુઝર-જનરેટેડ કન્ટેન્ટ માટે રિયલટાઇમ અનુવાદ | [/sdk](https://lingo.dev/sdk) | + +નીચે દરેક માટે ઝડપી માહિતી છે 👇 + +--- + +### ⚡️ Lingo.dev CLI + +તમારા ટર્મિનલમાંથી સીધા કોડ અને સામગ્રીનું ભાષાંતર કરો. + +```bash +npx lingo.dev@latest run +``` + +તે દરેક સ્ટ્રિંગને ફિંગરપ્રિન્ટ કરે છે, પરિણામોને કેશ કરે છે, અને ફક્ત જે બદલાયું છે તેનું જ ફરીથી ભાષાંતર કરે છે. + +તેને કેવી રીતે સેટઅપ કરવું તે જાણવા માટે [દસ્તાવેજો અનુસરો →](https://lingo.dev/cli). + +--- + +### 🔄 Lingo.dev CI/CD + +સંપૂર્ણ ભાષાંતરો આપમેળે મોકલો. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +તમારા રેપોને ગ્રીન અને તમારા પ્રોડક્ટને મેન્યુઅલ સ્ટેપ્સ વિના બહુભાષી રાખે છે. + +[દસ્તાવેજો વાંચો →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +ડાયનેમિક સામગ્રી માટે તાત્કાલિક પ્રતિ-વિનંતી ભાષાંતર. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +ચેટ, યુઝર કોમેન્ટ્સ અને અન્ય રીઅલ-ટાઇમ ફ્લો માટે સંપૂર્ણ. + +[દસ્તાવેજો વાંચો →](https://lingo.dev/sdk) + +--- + +## 🤝 સમુદાય + +અમે સમુદાય-સંચાલિત છીએ અને યોગદાનને પ્રેમ કરીએ છીએ! + +- કોઈ વિચાર છે? [ઇશ્યૂ ખોલો](https://github.com/lingodotdev/lingo.dev/issues) +- કંઈક ઠીક કરવા માંગો છો? [PR મોકલો](https://github.com/lingodotdev/lingo.dev/pulls) +- મદદની જરૂર છે? [અમારા Discord જોડાઓ](https://lingo.dev/go/discord) + +## ⭐ સ્ટાર હિસ્ટ્રી + +જો તમને અમે જે કરી રહ્યા છીએ તે ગમે, તો અમને ⭐ આપો અને 6,000 સ્ટાર્સ સુધી પહોંચવામાં અમારી મદદ કરો! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 અન્ય ભાષાઓમાં Readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +તમારી ભાષા દેખાતી નથી? તેને [`i18n.json`](./i18n.json) માં ઉમેરો અને PR ખોલો! + +**લોકેલ ફોર્મેટ:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) કોડ્સનો ઉપયોગ કરો: `language[-Script][-REGION]` + +- ભાષા: ISO 639-1/2/3 લોઅરકેસ (`en`, `zh`, `bho`) +- સ્ક્રિપ્ટ: ISO 15924 ટાઇટલ કેસ (`Hans`, `Hant`, `Latn`) +- પ્રદેશ: ISO 3166-1 alpha-2 અપરકેસ (`US`, `CN`, `IN`) +- ઉદાહરણો: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/he.md b/readme/he.md new file mode 100644 index 000000000..c8ccd3a08 --- /dev/null +++ b/readme/he.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - ערכת כלים בקוד פתוח מבוססת AI לתרגום מיידי עם מודלי שפה + גדולים. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## הכירו את ה-Compiler 🆕 + +**Lingo.dev Compiler** הוא תוכנת ביניים חינמית בקוד פתוח, שתוכננה להפוך כל אפליקציית React לרב-לשונית בזמן הבנייה ללא צורך בשינויים בקומפוננטות ה-React הקיימות. + +התקנה חד-פעמית: + +```bash +npm install @lingo.dev/compiler +``` + +הפעלה בקובץ תצורת הבנייה: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +הריצו `next build` וצפו בחבילות בספרדית ובצרפתית מופיעות ✨ + +[קראו את התיעוד →](https://lingo.dev/compiler) למדריך המלא, ו[הצטרפו ל-Discord שלנו](https://lingo.dev/go/discord) כדי לקבל עזרה בהגדרה. + +--- + +### מה נמצא בתוך הריפו הזה? + +| כלי | תקציר | תיעוד | +| ------------ | ------------------------------------------------------------------ | --------------------------------------- | +| **Compiler** | תרגום React בזמן בנייה | [/compiler](https://lingo.dev/compiler) | +| **CLI** | תרגום בפקודה אחת לאפליקציות ווב ומובייל, JSON, YAML, markdown ועוד | [/cli](https://lingo.dev/cli) | +| **CI/CD** | תרגומים אוטומטיים בכל push + יצירת pull requests במידת הצורך | [/ci](https://lingo.dev/ci) | +| **SDK** | תרגום בזמן אמת לתוכן שנוצר על ידי משתמשים | [/sdk](https://lingo.dev/sdk) | + +להלן הנקודות המרכזיות עבור כל אחד 👇 + +--- + +### ⚡️ Lingo.dev CLI + +תרגם קוד ותוכן ישירות מהטרמינל שלך. + +```bash +npx lingo.dev@latest run +``` + +הוא יוצר טביעת אצבע לכל מחרוזת, שומר תוצאות במטמון, ומתרגם מחדש רק את מה שהשתנה. + +[עקוב אחר התיעוד ←](https://lingo.dev/cli) כדי ללמוד כיצד להגדיר אותו. + +--- + +### 🔄 Lingo.dev CI/CD + +שלח תרגומים מושלמים באופן אוטומטי. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +שומר על המאגר שלך ירוק ועל המוצר שלך רב-לשוני ללא שלבים ידניים. + +[קרא את התיעוד ←](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +תרגום מיידי לכל בקשה עבור תוכן דינמי. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +מושלם עבור צ'אט, תגובות משתמשים ותהליכים אחרים בזמן אמת. + +[קרא את התיעוד ←](https://lingo.dev/sdk) + +--- + +## 🤝 קהילה + +אנחנו מונעים על ידי הקהילה ואוהבים תרומות! + +- יש לך רעיון? [פתח issue](https://github.com/lingodotdev/lingo.dev/issues) +- רוצה לתקן משהו? [שלח PR](https://github.com/lingodotdev/lingo.dev/pulls) +- צריך עזרה? [הצטרף לדיסקורד שלנו](https://lingo.dev/go/discord) + +## ⭐ היסטוריית כוכבים + +אם אתה אוהב את מה שאנחנו עושים, תן לנו ⭐ ועזור לנו להגיע ל-6,000 כוכבים! 🌟 + +[ + +![תרשים היסטוריית כוכבים](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 קובץ readme בשפות אחרות + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +לא רואה את השפה שלך? הוסף אותה ל-`i18n.json` ופתח PR! + +**פורמט לוקייל:** יש להשתמש בקודים של [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale): `language[-Script][-REGION]` + +- שפה: ISO 639-1/2/3 באותיות קטנות (`en`, `zh`, `bho`) +- כתב: ISO 15924 באותיות רישיות (`Hans`, `Hant`, `Latn`) +- אזור: ISO 3166-1 alpha-2 באותיות גדולות (`US`, `CN`, `IN`) +- דוגמאות: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/hi.md b/readme/hi.md new file mode 100644 index 000000000..42f8023de --- /dev/null +++ b/readme/hi.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - ओपन-सोर्स, AI-संचालित i18n टूलकिट जो LLMs के साथ तत्काल + स्थानीयकरण के लिए है। + +

    + +
    + +

    + Lingo.dev कंपाइलर • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + रिलीज़ + + + लाइसेंस + + + अंतिम कमिट + + + Product Hunt #1 महीने का DevTool + + + Product Hunt #1 सप्ताह का प्रोडक्ट + + + Product Hunt #2 दिन का प्रोडक्ट + + + Github ट्रेंडिंग + +

    + +--- + +## कंपाइलर से मिलें 🆕 + +**Lingo.dev कंपाइलर** एक मुफ्त, ओपन-सोर्स कंपाइलर मिडलवेयर है, जो किसी भी React ऐप को बिल्ड टाइम पर बहुभाषी बनाने के लिए डिज़ाइन किया गया है, बिना मौजूदा React कंपोनेंट्स में कोई बदलाव किए। + +एक बार इंस्टॉल करें: + +```bash +npm install @lingo.dev/compiler +``` + +अपने बिल्ड कॉन्फ़िग में सक्षम करें: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` चलाएं और स्पेनिश और फ्रेंच बंडल्स को बाहर आते देखें ✨ + +[दस्तावेज़ पढ़ें →](https://lingo.dev/compiler) पूरी गाइड के लिए, और [हमारे Discord में शामिल हों](https://lingo.dev/go/discord) अपने सेटअप में मदद पाने के लिए। + +--- + +### इस रेपो में क्या है? + +| टूल | TL;DR | दस्तावेज़ | +| ----------- | --------------------------------------------------------------------------- | --------------------------------------- | +| **कंपाइलर** | बिल्ड-टाइम React स्थानीयकरण | [/compiler](https://lingo.dev/compiler) | +| **CLI** | वेब और मोबाइल ऐप्स, JSON, YAML, markdown, + अधिक के लिए वन-कमांड स्थानीयकरण | [/cli](https://lingo.dev/cli) | +| **CI/CD** | हर पुश पर ऑटो-कमिट अनुवाद + ज़रूरत पड़ने पर pull requests बनाएं | [/ci](https://lingo.dev/ci) | +| **SDK** | यूज़र-जनरेटेड कंटेंट के लिए रियलटाइम अनुवाद | [/sdk](https://lingo.dev/sdk) | + +नीचे प्रत्येक के लिए त्वरित जानकारी दी गई है 👇 + +--- + +### ⚡️ Lingo.dev CLI + +अपने टर्मिनल से सीधे कोड और कंटेंट का अनुवाद करें। + +```bash +npx lingo.dev@latest run +``` + +यह प्रत्येक स्ट्रिंग को फिंगरप्रिंट करता है, परिणामों को कैश करता है, और केवल बदली हुई चीज़ों का पुनः अनुवाद करता है। + +इसे सेट अप करने का तरीका जानने के लिए [दस्तावेज़ देखें →](https://lingo.dev/cli)। + +--- + +### 🔄 Lingo.dev CI/CD + +स्वचालित रूप से परफेक्ट अनुवाद डिलीवर करें। + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +आपके रेपो को ग्रीन रखता है और आपके प्रोडक्ट को मैनुअल स्टेप्स के बिना बहुभाषी बनाता है। + +[दस्तावेज़ पढ़ें →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +डायनामिक कंटेंट के लिए प्रति-रिक्वेस्ट तत्काल अनुवाद। + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +चैट, यूज़र कमेंट्स और अन्य रियल-टाइम फ्लो के लिए परफेक्ट। + +[दस्तावेज़ पढ़ें →](https://lingo.dev/sdk) + +--- + +## 🤝 कम्युनिटी + +हम कम्युनिटी-ड्रिवन हैं और योगदान को पसंद करते हैं! + +- कोई आइडिया है? [इश्यू ओपन करें](https://github.com/lingodotdev/lingo.dev/issues) +- कुछ ठीक करना चाहते हैं? [PR भेजें](https://github.com/lingodotdev/lingo.dev/pulls) +- मदद चाहिए? [हमारे Discord से जुड़ें](https://lingo.dev/go/discord) + +## ⭐ स्टार हिस्ट्री + +अगर आपको हमारा काम पसंद है, तो हमें ⭐ दें और 6,000 स्टार तक पहुंचने में हमारी मदद करें! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 अन्य भाषाओं में Readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +अपनी भाषा नहीं दिख रही? इसे [`i18n.json`](./i18n.json) में जोड़ें और PR खोलें! + +**लोकेल प्रारूप:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) कोड का उपयोग करें: `language[-Script][-REGION]` + +- भाषा: ISO 639-1/2/3 लोअरकेस (`en`, `zh`, `bho`) +- लिपि: ISO 15924 टाइटल केस (`Hans`, `Hant`, `Latn`) +- क्षेत्र: ISO 3166-1 alpha-2 अपरकेस (`US`, `CN`, `IN`) +- उदाहरण: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/it.md b/readme/it.md new file mode 100644 index 000000000..dc839b816 --- /dev/null +++ b/readme/it.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - toolkit i18n open-source e basato su AI per la localizzazione + istantanea con LLM. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + Licenza + + + Ultimo commit + + + Product Hunt #1 DevTool del mese + + + Product Hunt #1 DevTool della settimana + + + Product Hunt #2 prodotto del giorno + + + Github trending + +

    + +--- + +## Scopri il Compiler 🆕 + +**Lingo.dev Compiler** è un middleware di compilazione gratuito e open-source, progettato per rendere multilingue qualsiasi app React in fase di build senza richiedere modifiche ai componenti React esistenti. + +Installa una volta: + +```bash +npm install @lingo.dev/compiler +``` + +Abilita nella tua configurazione di build: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +Esegui `next build` e guarda apparire i bundle in spagnolo e francese ✨ + +[Leggi la documentazione →](https://lingo.dev/compiler) per la guida completa, e [unisciti al nostro Discord](https://lingo.dev/go/discord) per ricevere aiuto con la tua configurazione. + +--- + +### Cosa c'è in questa repo? + +| Tool | TL;DR | Documentazione | +| ------------ | ----------------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | Localizzazione React in fase di build | [/compiler](https://lingo.dev/compiler) | +| **CLI** | Localizzazione con un solo comando per app web e mobile, JSON, YAML, markdown e altro | [/cli](https://lingo.dev/cli) | +| **CI/CD** | Commit automatico delle traduzioni ad ogni push + creazione di pull request se necessario | [/ci](https://lingo.dev/ci) | +| **SDK** | Traduzione in tempo reale per contenuti generati dagli utenti | [/sdk](https://lingo.dev/sdk) | + +Di seguito trovi i punti salienti per ciascuno 👇 + +--- + +### ⚡️ Lingo.dev CLI + +Traduci codice e contenuti direttamente dal tuo terminale. + +```bash +npx lingo.dev@latest run +``` + +Crea un'impronta digitale di ogni stringa, memorizza i risultati nella cache e ritraduce solo ciò che è cambiato. + +[Segui la documentazione →](https://lingo.dev/cli) per scoprire come configurarlo. + +--- + +### 🔄 Lingo.dev CI/CD + +Distribuisci traduzioni perfette automaticamente. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +Mantiene il tuo repository pulito e il tuo prodotto multilingue senza passaggi manuali. + +[Leggi la documentazione →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +Traduzione istantanea per richiesta per contenuti dinamici. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +Perfetto per chat, commenti degli utenti e altri flussi in tempo reale. + +[Leggi la documentazione →](https://lingo.dev/sdk) + +--- + +## 🤝 Community + +Siamo guidati dalla community e amiamo i contributi! + +- Hai un'idea? [Apri una issue](https://github.com/lingodotdev/lingo.dev/issues) +- Vuoi correggere qualcosa? [Invia una PR](https://github.com/lingodotdev/lingo.dev/pulls) +- Hai bisogno di aiuto? [Unisciti al nostro Discord](https://lingo.dev/go/discord) + +## ⭐ Cronologia delle stelle + +Se ti piace quello che facciamo, dacci una ⭐ e aiutaci a raggiungere le 6.000 stelle! 🌟 + +[ + +![Grafico cronologia stelle](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 Readme in altre lingue + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +Non vedi la tua lingua? Aggiungila a [`i18n.json`](./i18n.json) e apri una PR! + +**Formato locale:** usa i codici [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale): `language[-Script][-REGION]` + +- Lingua: ISO 639-1/2/3 minuscolo (`en`, `zh`, `bho`) +- Script: ISO 15924 maiuscolo iniziale (`Hans`, `Hant`, `Latn`) +- Regione: ISO 3166-1 alpha-2 maiuscolo (`US`, `CN`, `IN`) +- Esempi: `en`, `pt-BR`, `zh-Hans`, {/_ INLINE_CODE_PLACEHOLDER_6e553bb40a655db7be211ded60744c98 _/ diff --git a/readme/ja.md b/readme/ja.md new file mode 100644 index 000000000..da2b9f167 --- /dev/null +++ b/readme/ja.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - + オープンソースのAI搭載i18nツールキットで、LLMによる即座のローカライゼーションを実現 + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compilerのご紹介 🆕 + +**Lingo.dev Compiler**は、無料のオープンソースコンパイラミドルウェアで、既存のReactコンポーネントに変更を加えることなく、ビルド時にあらゆるReactアプリを多言語対応にすることができます。 + +一度インストール: + +```bash +npm install @lingo.dev/compiler +``` + +ビルド設定で有効化: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build`を実行すると、スペイン語とフランス語のバンドルが生成されます✨ + +[ドキュメントを読む →](https://lingo.dev/compiler)で完全なガイドを確認し、[Discordに参加](https://lingo.dev/go/discord)してセットアップのサポートを受けましょう。 + +--- + +### このリポジトリには何が含まれていますか? + +| ツール | 概要 | ドキュメント | +| ------------ | ----------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | ビルド時のReactローカライゼーション | [/compiler](https://lingo.dev/compiler) | +| **CLI** | Webおよびモバイルアプリ、JSON、YAML、markdownなどのワンコマンドローカライゼーション | [/cli](https://lingo.dev/cli) | +| **CI/CD** | プッシュごとに翻訳を自動コミット + 必要に応じてプルリクエストを作成 | [/ci](https://lingo.dev/ci) | +| **SDK** | ユーザー生成コンテンツのリアルタイム翻訳 | [/sdk](https://lingo.dev/sdk) | + +以下は各機能の概要です 👇 + +--- + +### ⚡️ Lingo.dev CLI + +ターミナルから直接コードとコンテンツを翻訳できます。 + +```bash +npx lingo.dev@latest run +``` + +すべての文字列をフィンガープリント化し、結果をキャッシュし、変更された部分のみを再翻訳します。 + +セットアップ方法については[ドキュメントを参照 →](https://lingo.dev/cli)してください。 + +--- + +### 🔄 Lingo.dev CI/CD + +完璧な翻訳を自動的にデプロイできます。 + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +手動作業なしで、リポジトリを正常に保ち、製品を多言語対応にします。 + +[ドキュメントを読む →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +動的コンテンツのリクエストごとの即時翻訳。 + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +チャット、ユーザーコメント、その他のリアルタイムフローに最適です。 + +[ドキュメントを読む →](https://lingo.dev/sdk) + +--- + +## 🤝 コミュニティ + +私たちはコミュニティ主導で、貢献を歓迎しています! + +- アイデアがありますか?[issueを開く](https://github.com/lingodotdev/lingo.dev/issues) +- 何か修正したいですか?[PRを送る](https://github.com/lingodotdev/lingo.dev/pulls) +- サポートが必要ですか?[Discordに参加](https://lingo.dev/go/discord) + +## ⭐ スター履歴 + +私たちの取り組みが気に入ったら、⭐をつけて6,000スター達成にご協力ください!🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 他の言語のReadme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +お使いの言語が見つかりませんか?[`i18n.json`](./i18n.json)に追加してPRを開いてください! + +**ロケール形式:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale)コードを使用してください: `language[-Script][-REGION]` + +- 言語: ISO 639-1/2/3 小文字 (`en`、`zh`、`bho`) +- 文字体系: ISO 15924 タイトルケース (`Hans`、`Hant`、`Latn`) +- 地域: ISO 3166-1 alpha-2 大文字 (`US`、`CN`、`IN`) +- 例: `en`、`pt-BR`、`zh-Hans`、`sr-Cyrl-RS` diff --git a/readme/ko.md b/readme/ko.md new file mode 100644 index 000000000..cb6c35c18 --- /dev/null +++ b/readme/ko.md @@ -0,0 +1,218 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - LLM 기반 즉시 현지화를 위한 오픈소스 AI 기반 i18n 툴킷 + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compiler를 만나보세요 🆕 + +**Lingo.dev Compiler**는 기존 React 컴포넌트를 변경하지 않고도 빌드 시점에 모든 React 앱을 다국어로 만들 수 있도록 설계된 무료 오픈소스 컴파일러 미들웨어입니다. + +한 번만 설치하세요: + +```bash +npm install @lingo.dev/compiler +``` + +빌드 설정에서 활성화하세요: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build`를 실행하고 스페인어와 프랑스어 번들이 생성되는 것을 확인하세요 ✨ + +전체 가이드는 [문서 읽기 →](https://lingo.dev/compiler)를 참고하시고, 설정에 도움이 필요하시면 [Discord에 참여하세요](https://lingo.dev/go/discord). + +--- + +### 이 저장소에는 무엇이 있나요? + +| 도구 | 요약 | 문서 | +| ------------ | ------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | 빌드 시점 React 현지화 | [/compiler](https://lingo.dev/compiler) | +| **CLI** | 웹 및 모바일 앱, JSON, YAML, 마크다운 등을 위한 원클릭 현지화 | [/cli](https://lingo.dev/cli) | +| **CI/CD** | 푸시할 때마다 번역 자동 커밋 + 필요 시 풀 리퀘스트 생성 | [/ci](https://lingo.dev/ci) | +| **SDK** | 사용자 생성 콘텐츠를 위한 실시간 번역 | [/sdk](https://lingo.dev/sdk) | + +각 항목에 대한 핵심 내용은 다음과 같습니다 👇 + +--- + +### ⚡️ Lingo.dev CLI + +터미널에서 바로 코드와 콘텐츠를 번역하세요. + +```bash +npx lingo.dev@latest run +``` + +모든 문자열을 지문화하고, 결과를 캐시하며, 변경된 내용만 다시 번역합니다. + +설정 방법을 알아보려면 [문서 보기 →](https://lingo.dev/cli)를 참조하세요. + +--- + +### 🔄 Lingo.dev CI/CD + +완벽한 번역을 자동으로 배포하세요. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +수동 작업 없이 저장소를 안정적으로 유지하고 제품을 다국어로 만듭니다. + +[문서 읽기 →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +동적 콘텐츠를 위한 즉각적인 요청별 번역. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +채팅, 사용자 댓글 및 기타 실시간 흐름에 완벽합니다. + +[문서 읽기 →](https://lingo.dev/sdk) + +--- + +## 🤝 커뮤니티 + +우리는 커뮤니티 중심이며 기여를 환영합니다! + +- 아이디어가 있으신가요? [이슈 열기](https://github.com/lingodotdev/lingo.dev/issues) +- 무언가를 수정하고 싶으신가요? [PR 보내기](https://github.com/lingodotdev/lingo.dev/pulls) +- 도움이 필요하신가요? [Discord에 참여하기](https://lingo.dev/go/discord) + +## ⭐ 스타 히스토리 + +저희가 하는 일이 마음에 드신다면 ⭐를 주시고 6,000개의 스타를 달성할 수 있도록 도와주세요! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 다른 언어로 된 Readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +사용하시는 언어가 보이지 않나요? [`i18n.json`](./i18n.json)에 추가하고 PR을 열어주세요! + +**로케일 형식:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) 코드 사용: `language[-Script][-REGION]` + +- 언어: ISO 639-1/2/3 소문자 (`en`, `zh`, `bho`) +- 문자 체계: ISO 15924 타이틀 케이스 (`Hans`, `Hant`, `Latn`) +- 지역: ISO 3166-1 alpha-2 대문자 (`US`, `CN`, `IN`) +- 예시: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/ml-IN.md b/readme/ml-IN.md new file mode 100644 index 000000000..224bdd881 --- /dev/null +++ b/readme/ml-IN.md @@ -0,0 +1,215 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - ഓപ്പൺ സോഴ്‌സ്, AI-യാൽ പ്രവർത്തിക്കുന്ന i18n ടൂൾകിറ്റ് — + LLM-കളിലൂടെ തൽക്ഷണ ലോക്കലൈസേഷനായി. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## കമ്പൈലറെ പരിചയപ്പെടൂ 🆕 + +**Lingo.dev Compiler** എന്നത് ഒരു സൗജന്യ, ഓപ്പൺ സോഴ്‌സ് കമ്പൈലർ മിഡിൽവെയറാണ് — നിലവിലുള്ള React ഘടകങ്ങളിൽ മാറ്റമൊന്നുമില്ലാതെ ഏത് React ആപ്പിനെയും ബിൽഡ് സമയത്ത് ബഹുഭാഷയാക്കാൻ ഇതു സഹായിക്കുന്നു. + +ഇൻസ്റ്റാൾ ചെയ്യുക: + +```bash +npm install lingo.dev +``` + +നിങ്ങളുടെ ബിൽഡ് കോൺഫിഗറേഷനിൽ സജീവമാക്കുക: + +```js +import lingoCompiler from "lingo.dev/compiler"; + +const existingNextConfig = {}; + +export default lingoCompiler.next({ + sourceLocale: "en", + targetLocales: ["es", "fr"], +})(existingNextConfig); +``` + +`next build` പ്രവർത്തിപ്പിക്കുക — സ്പാനിഷ്, ഫ്രഞ്ച് ബണ്ടിലുകൾ സ്വയമേവ ലഭിക്കും ✨ + +പൂർണ്ണമായ മാർഗ്ഗനിർദേശത്തിനായി [ഡോക്യുമെന്റേഷൻ വായിക്കുക →](https://lingo.dev/compiler) +അല്ലെങ്കിൽ സഹായത്തിനായി [ഞങ്ങളുടെ Discord-ിൽ ചേരുക](https://lingo.dev/go/discord) + +--- + +### ഈ റീപ്പോയിൽ എന്തൊക്കെ ഉണ്ട്? + +| ടൂൾ | ചുരുക്കം | ഡോക്യുമെന്റ് | +| ------------ | ---------------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | ബിൽഡ് സമയത്ത് React ആപ്പുകൾക്ക് ബഹുഭാഷാ പിന്തുണ | [/compiler](https://lingo.dev/compiler) | +| **CLI** | വെബ്, മൊബൈൽ ആപ്പുകൾ, JSON, YAML, Markdown എന്നിവയ്ക്ക് ഒരൊറ്റ കമാൻഡ് കൊണ്ട് ഭാഷാന്തരം | [/cli](https://lingo.dev/cli) | +| **CI/CD** | ഓരോ push-നും ട്രാൻസ്ലേഷൻസ് ഓട്ടോ-കമിറ്റ് ചെയ്യുകയും ആവശ്യമായാൽ PR സൃഷ്ടിക്കുകയും ചെയ്യും | [/ci](https://lingo.dev/ci) | +| **SDK** | ഉപയോക്താക്കളുടെ സജീവ ഉള്ളടക്കത്തിന് റിയൽടൈം ഭാഷാന്തരം | [/sdk](https://lingo.dev/sdk) | + +താഴെ ഓരോന്നിന്റെ ചുരുക്കം കാണാം 👇 + +--- + +### ⚡️ Lingo.dev CLI + +നിങ്ങളുടെ ടർമിനലിൽ നിന്നുതന്നെ കോഡും ഉള്ളടക്കവും ഭാഷാന്തരം ചെയ്യുക. + +```bash +npx lingo.dev@latest run +``` + +ഇത് ഓരോ സ്ട്രിംഗിനും ഫിംഗർപ്രിന്റ് സൃഷ്ടിക്കും, ഫലങ്ങൾ കാഷ് ചെയ്യും, മാറ്റം വന്നവ മാത്രം പുനർഭാഷാന്തരം ചെയ്യും. + +[പൂർണ്ണ ഡോക്‌സ് വായിക്കുക →](https://lingo.dev/cli) + +--- + +### 🔄 Lingo.dev CI/CD + +ട്രാൻസ്ലേഷൻസ് സ്വയം പ്രയോഗിക്കാം. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +ഇതിലൂടെ നിങ്ങളുടെ റീപ്പോ പച്ചയായിരിക്കും 🌱, ഉൽപ്പന്നം ബഹുഭാഷയിലായിരിക്കും 🌍. + +[ഡോക്യുമെന്റേഷൻ വായിക്കുക →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +ഡൈനാമിക് ഉള്ളടക്കത്തിന് തൽക്ഷണ ഭാഷാന്തരം ലഭിക്കുക. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// ഫലം: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +ചാറ്റുകൾക്കും, ഉപയോക്തൃ കമന്റുകൾക്കും, തൽക്ഷണ അപ്ഡേറ്റുകൾക്കുമായി മികച്ചതാണ്. + +[ഡോക്യുമെന്റേഷൻ വായിക്കുക →](https://lingo.dev/sdk) + +--- + +## 🤝 സമൂഹം + +ഞങ്ങൾ സമൂഹാധിഷ്ഠിതരാണ് — നിങ്ങളുടെ സംഭാവനകളെ ഞങ്ങൾ സ്നേഹിക്കുന്നു! + +- ആശയമുണ്ടോ? [Issue തുറക്കൂ](https://github.com/lingodotdev/lingo.dev/issues) +- എന്തെങ്കിലും പരിഹരിക്കാനോ? [PR അയക്കൂ](https://github.com/lingodotdev/lingo.dev/pulls) +- സഹായം വേണോ? [ഞങ്ങളുടെ Discord-ൽ ചേരൂ](https://lingo.dev/go/discord) + +## ⭐ നക്ഷത്ര ചരിത്രം + +നിങ്ങൾക്ക് ഞങ്ങൾ ചെയ്യുന്ന പ്രവർത്തനം ഇഷ്ടമാണെങ്കിൽ ⭐ കൊടുക്കൂ — 4,000 സ്റ്റാറുകൾക്ക് സഹായിക്കൂ! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 മറ്റ് ഭാഷകളിലുള്ള README + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +നിങ്ങളുടെ ഭാഷ കാണുന്നില്ലേ? [`i18n.json`](./i18n.json) ഫയലിൽ ചേർക്കുക, ശേഷം PR തുറക്കൂ! + +**ലോക്കേൽ ഫോർമാറ്റ്:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) കോഡുകൾ ഉപയോഗിക്കുക: `language[-Script][-REGION]` + +- ഭാഷ: ISO 639-1/2/3 ചെറുകക്ഷരത്തിൽ (`en`, `zh`, `bho`) +- ലിപി: ISO 15924 ടൈറ്റിൽ കേസ് (`Hans`, `Hant`, `Latn`) +- പ്രദേശം: ISO 3166-1 alpha-2 വലിയക്ഷരത്തിൽ (`US`, `CN`, `IN`) +- ഉദാഹരണങ്ങൾ: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/mr-IN.md b/readme/mr-IN.md new file mode 100644 index 000000000..729fda07b --- /dev/null +++ b/readme/mr-IN.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - ओपन-सोर्स, AI-संचालित i18n टूलकिट जे LLMs सह त्वरित + स्थानिकीकरण करते. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compiler ला भेटा 🆕 + +**Lingo.dev Compiler** हे एक मोफत, ओपन-सोर्स कंपायलर मिडलवेअर आहे, जे कोणत्याही React अॅपला विद्यमान React कॉम्पोनेंट्समध्ये कोणतेही बदल न करता बिल्ड टाइमवर बहुभाषिक बनवण्यासाठी डिझाइन केले आहे. + +एकदा इन्स्टॉल करा: + +```bash +npm install @lingo.dev/compiler +``` + +तुमच्या बिल्ड कॉन्फिगमध्ये सक्षम करा: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` रन करा आणि स्पॅनिश आणि फ्रेंच बंडल्स बाहेर येताना पहा ✨ + +[डॉक्स वाचा →](https://lingo.dev/compiler) संपूर्ण मार्गदर्शनासाठी, आणि तुमच्या सेटअपमध्ये मदत मिळवण्यासाठी [आमच्या Discord मध्ये सामील व्हा](https://lingo.dev/go/discord). + +--- + +### या रेपोमध्ये काय आहे? + +| टूल | TL;DR | डॉक्स | +| ------------ | --------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | बिल्ड-टाइम React स्थानिकीकरण | [/compiler](https://lingo.dev/compiler) | +| **CLI** | वेब आणि मोबाइल अॅप्स, JSON, YAML, markdown, + अधिकसाठी एक-कमांड स्थानिकीकरण | [/cli](https://lingo.dev/cli) | +| **CI/CD** | प्रत्येक पुशवर ऑटो-कमिट भाषांतरे + आवश्यक असल्यास पुल रिक्वेस्ट तयार करा | [/ci](https://lingo.dev/ci) | +| **SDK** | वापरकर्त्याने तयार केलेल्या सामग्रीसाठी रिअलटाइम भाषांतर | [/sdk](https://lingo.dev/sdk) | + +खाली प्रत्येकासाठी द्रुत माहिती आहे 👇 + +--- + +### ⚡️ Lingo.dev CLI + +तुमच्या टर्मिनलमधून थेट कोड आणि सामग्रीचे भाषांतर करा. + +```bash +npx lingo.dev@latest run +``` + +हे प्रत्येक स्ट्रिंगचे फिंगरप्रिंट करते, परिणाम कॅश करते आणि फक्त बदललेल्या गोष्टींचे पुन्हा भाषांतर करते. + +ते कसे सेट करायचे हे जाणून घेण्यासाठी [डॉक्स फॉलो करा →](https://lingo.dev/cli). + +--- + +### 🔄 Lingo.dev CI/CD + +परफेक्ट भाषांतरे आपोआप पाठवा. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +तुमचा रेपो ग्रीन आणि तुमचे उत्पादन मॅन्युअल स्टेप्सशिवाय बहुभाषिक ठेवते. + +[डॉक्स वाचा →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +डायनॅमिक सामग्रीसाठी तात्काळ प्रति-रिक्वेस्ट भाषांतर. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +चॅट, यूझर कमेंट्स आणि इतर रिअल-टाइम फ्लोसाठी परफेक्ट. + +[डॉक्स वाचा →](https://lingo.dev/sdk) + +--- + +## 🤝 कम्युनिटी + +आम्ही कम्युनिटी-ड्रिव्हन आहोत आणि योगदानांचे स्वागत करतो! + +- काही कल्पना आहे? [इश्यू उघडा](https://github.com/lingodotdev/lingo.dev/issues) +- काहीतरी ठीक करायचे आहे? [PR पाठवा](https://github.com/lingodotdev/lingo.dev/pulls) +- मदत हवी आहे? [आमच्या Discord मध्ये सामील व्हा](https://lingo.dev/go/discord) + +## ⭐ स्टार हिस्ट्री + +आम्ही जे करत आहोत ते तुम्हाला आवडत असल्यास, आम्हाला ⭐ द्या आणि 6,000 स्टार्सपर्यंत पोहोचण्यात मदत करा! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 इतर भाषांमध्ये Readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +तुमची भाषा दिसत नाही? ती [`i18n.json`](./i18n.json) मध्ये जोडा आणि PR उघडा! + +**Locale स्वरूप:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) कोड वापरा: `language[-Script][-REGION]` + +- भाषा: ISO 639-1/2/3 लोअरकेस (`en`, `zh`, `bho`) +- लिपी: ISO 15924 टायटल केस (`Hans`, `Hant`, `Latn`) +- प्रदेश: ISO 3166-1 alpha-2 अप्परकेस (`US`, `CN`, `IN`) +- उदाहरणे: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/or-IN.md b/readme/or-IN.md new file mode 100644 index 000000000..e43952040 --- /dev/null +++ b/readme/or-IN.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - ଓପନ-ସୋର୍ସ, AI-ପାୱାର୍ଡ i18n ଟୁଲକିଟ୍ LLMs ସହିତ ତୁରନ୍ତ + ଲୋକାଲାଇଜେସନ୍ ପାଇଁ। + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compiler ସହିତ ପରିଚିତ ହୁଅନ୍ତୁ 🆕 + +**Lingo.dev Compiler** ହେଉଛି ଏକ ମାଗଣା, ଓପନ-ସୋର୍ସ କମ୍ପାଇଲର୍ ମିଡଲୱେର୍, ଯାହା ବିଲ୍ଡ ସମୟରେ ଯେକୌଣସି React ଆପ୍‌କୁ ବହୁଭାଷୀ କରିବା ପାଇଁ ଡିଜାଇନ୍ କରାଯାଇଛି, ବିଦ୍ୟମାନ React କମ୍ପୋନେଣ୍ଟଗୁଡ଼ିକରେ କୌଣସି ପରିବର୍ତ୍ତନ ଆବଶ୍ୟକ ନକରି। + +ଥରେ ଇନଷ୍ଟଲ୍ କରନ୍ତୁ: + +```bash +npm install @lingo.dev/compiler +``` + +ଆପଣଙ୍କ ବିଲ୍ଡ କନଫିଗରେ ସକ୍ଷମ କରନ୍ତୁ: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` ଚଲାନ୍ତୁ ଏବଂ ସ୍ପାନିସ୍ ଓ ଫ୍ରେଞ୍ଚ ବଣ୍ଡଲ୍ ବାହାରୁଥିବା ଦେଖନ୍ତୁ ✨ + +[ଡକ୍ସ ପଢନ୍ତୁ →](https://lingo.dev/compiler) ସମ୍ପୂର୍ଣ୍ଣ ଗାଇଡ୍ ପାଇଁ, ଏବଂ [ଆମର Discord ରେ ଯୋଗ ଦିଅନ୍ତୁ](https://lingo.dev/go/discord) ଆପଣଙ୍କ ସେଟଅପ୍ ସହିତ ସାହାଯ୍ୟ ପାଇବାକୁ। + +--- + +### ଏହି ରେପୋ ଭିତରେ କ'ଣ ଅଛି? + +| ଟୁଲ୍ | TL;DR | ଡକ୍ସ | +| ------------ | ------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | ବିଲ୍ଡ-ଟାଇମ୍ React ଲୋକାଲାଇଜେସନ୍ | [/compiler](https://lingo.dev/compiler) | +| **CLI** | ୱେବ୍ ଏବଂ ମୋବାଇଲ୍ ଆପ୍ସ, JSON, YAML, markdown, + ଅଧିକ ପାଇଁ ଏକ-କମାଣ୍ଡ ଲୋକାଲାଇଜେସନ୍ | [/cli](https://lingo.dev/cli) | +| **CI/CD** | ପ୍ରତ୍ୟେକ push ରେ ଅଟୋ-କମିଟ୍ ଅନୁବାଦ + ଆବଶ୍ୟକ ହେଲେ pull request ସୃଷ୍ଟି କରନ୍ତୁ | [/ci](https://lingo.dev/ci) | +| **SDK** | ୟୁଜର-ଜେନେରେଟେଡ୍ କଣ୍ଟେଣ୍ଟ ପାଇଁ ରିଅଲଟାଇମ୍ ଅନୁବାଦ | [/sdk](https://lingo.dev/sdk) | + +ପ୍ରତ୍ୟେକ ପାଇଁ ଦ୍ରୁତ ସୂଚନା ନିମ୍ନରେ ଅଛି 👇 + +--- + +### ⚡️ Lingo.dev CLI + +ଆପଣଙ୍କ ଟର୍ମିନାଲରୁ ସିଧାସଳଖ କୋଡ୍ ଓ ବିଷୟବସ୍ତୁ ଅନୁବାଦ କରନ୍ତୁ। + +```bash +npx lingo.dev@latest run +``` + +ଏହା ପ୍ରତ୍ୟେକ ଷ୍ଟ୍ରିଙ୍ଗକୁ ଫିଙ୍ଗରପ୍ରିଣ୍ଟ କରେ, ଫଳାଫଳ କ୍ୟାଶ୍ କରେ, ଏବଂ କେବଳ ପରିବର୍ତ୍ତିତ ଅଂଶକୁ ପୁନଃ ଅନୁବାଦ କରେ। + +[ଡକ୍ସ ଅନୁସରଣ କରନ୍ତୁ →](https://lingo.dev/cli) ଏହାକୁ କିପରି ସେଟଅପ୍ କରିବେ ଜାଣିବାକୁ। + +--- + +### 🔄 Lingo.dev CI/CD + +ସ୍ୱୟଂଚାଳିତ ଭାବରେ ସମ୍ପୂର୍ଣ୍ଣ ଅନୁବାଦ ପ୍ରଦାନ କରନ୍ତୁ। + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +ମାନୁଆଲ ପଦକ୍ଷେପ ବିନା ଆପଣଙ୍କ ରେପୋକୁ ସବୁଜ ଏବଂ ଆପଣଙ୍କ ଉତ୍ପାଦକୁ ବହୁଭାଷୀ ରଖେ। + +[ଡକ୍ସ ପଢନ୍ତୁ →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +ଗତିଶୀଳ ବିଷୟବସ୍ତୁ ପାଇଁ ତୁରନ୍ତ ପ୍ରତି-ଅନୁରୋଧ ଅନୁବାଦ। + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +ଚାଟ୍, ବ୍ୟବହାରକାରୀ ମନ୍ତବ୍ୟ ଏବଂ ଅନ୍ୟାନ୍ୟ ରିଅଲ-ଟାଇମ୍ ଫ୍ଲୋ ପାଇଁ ଉପଯୁକ୍ତ। + +[ଡକ୍ସ ପଢନ୍ତୁ →](https://lingo.dev/sdk) + +--- + +## 🤝 ସମ୍ପ୍ରଦାୟ + +ଆମେ ସମ୍ପ୍ରଦାୟ-ଚାଳିତ ଏବଂ ଅବଦାନକୁ ଭଲପାଉ! + +- କୌଣସି ଧାରଣା ଅଛି? [ଏକ ଇସ୍ୟୁ ଖୋଲନ୍ତୁ](https://github.com/lingodotdev/lingo.dev/issues) +- କିଛି ଠିକ୍ କରିବାକୁ ଚାହୁଁଛନ୍ତି? [ଏକ PR ପଠାନ୍ତୁ](https://github.com/lingodotdev/lingo.dev/pulls) +- ସହାୟତା ଦରକାର? [ଆମ Discord ଯୋଗଦିଅନ୍ତୁ](https://lingo.dev/go/discord) + +## ⭐ ଷ୍ଟାର ଇତିହାସ + +ଯଦି ଆପଣ ଆମର କାର୍ଯ୍ୟ ପସନ୍ଦ କରନ୍ତି, ଆମକୁ ଏକ ⭐ ଦିଅନ୍ତୁ ଏବଂ 6,000 ଷ୍ଟାର ପହଞ୍ଚିବାରେ ସାହାଯ୍ୟ କରନ୍ତୁ! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 ଅନ୍ୟ ଭାଷାରେ Readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +ଆପଣଙ୍କ ଭାଷା ଦେଖୁନାହାଁନ୍ତି? ଏହାକୁ [`i18n.json`](./i18n.json) ରେ ଯୋଡ଼ନ୍ତୁ ଏବଂ ଏକ PR ଖୋଲନ୍ତୁ! + +**Locale ଫର୍ମାଟ୍:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) କୋଡ୍ ବ୍ୟବହାର କରନ୍ତୁ: `language[-Script][-REGION]` + +- ଭାଷା: ISO 639-1/2/3 ଛୋଟ ଅକ୍ଷର (`en`, `zh`, `bho`) +- ଲିପି: ISO 15924 ଟାଇଟଲ୍ କେସ୍ (`Hans`, `Hant`, `Latn`) +- ଅଞ୍ଚଳ: ISO 3166-1 alpha-2 ବଡ଼ ଅକ୍ଷର (`US`, `CN`, `IN`) +- ଉଦାହରଣ: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/pa-IN.md b/readme/pa-IN.md new file mode 100644 index 000000000..0b38f3146 --- /dev/null +++ b/readme/pa-IN.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - ਓਪਨ-ਸੋਰਸ, AI-ਸੰਚਾਲਿਤ i18n ਟੂਲਕਿੱਟ ਜੋ LLMs ਨਾਲ ਤੁਰੰਤ + ਲੋਕਲਾਈਜ਼ੇਸ਼ਨ ਲਈ ਹੈ। + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compiler ਨਾਲ ਮਿਲੋ 🆕 + +**Lingo.dev Compiler** ਇੱਕ ਮੁਫ਼ਤ, ਓਪਨ-ਸੋਰਸ ਕੰਪਾਈਲਰ ਮਿਡਲਵੇਅਰ ਹੈ, ਜੋ ਕਿਸੇ ਵੀ React ਐਪ ਨੂੰ ਮੌਜੂਦਾ React ਕੰਪੋਨੈਂਟਸ ਵਿੱਚ ਕੋਈ ਬਦਲਾਅ ਕੀਤੇ ਬਿਨਾਂ ਬਿਲਡ ਟਾਈਮ 'ਤੇ ਬਹੁਭਾਸ਼ਾਈ ਬਣਾਉਣ ਲਈ ਤਿਆਰ ਕੀਤਾ ਗਿਆ ਹੈ। + +ਇੱਕ ਵਾਰ ਇੰਸਟਾਲ ਕਰੋ: + +```bash +npm install @lingo.dev/compiler +``` + +ਆਪਣੀ ਬਿਲਡ ਕੌਂਫਿਗ ਵਿੱਚ ਸਮਰੱਥ ਕਰੋ: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` ਚਲਾਓ ਅਤੇ ਸਪੈਨਿਸ਼ ਅਤੇ ਫ੍ਰੈਂਚ ਬੰਡਲ ਬਾਹਰ ਆਉਂਦੇ ਦੇਖੋ ✨ + +ਪੂਰੀ ਗਾਈਡ ਲਈ [ਡੌਕਸ ਪੜ੍ਹੋ →](https://lingo.dev/compiler), ਅਤੇ ਆਪਣੇ ਸੈਟਅੱਪ ਵਿੱਚ ਮਦਦ ਲੈਣ ਲਈ [ਸਾਡੇ Discord 'ਤੇ ਸ਼ਾਮਲ ਹੋਵੋ](https://lingo.dev/go/discord)। + +--- + +### ਇਸ ਰਿਪੋ ਵਿੱਚ ਕੀ ਹੈ? + +| ਟੂਲ | TL;DR | ਡੌਕਸ | +| ------------ | -------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | ਬਿਲਡ-ਟਾਈਮ React ਲੋਕਲਾਈਜ਼ੇਸ਼ਨ | [/compiler](https://lingo.dev/compiler) | +| **CLI** | ਵੈੱਬ ਅਤੇ ਮੋਬਾਈਲ ਐਪਸ, JSON, YAML, markdown, + ਹੋਰ ਲਈ ਇੱਕ-ਕਮਾਂਡ ਲੋਕਲਾਈਜ਼ੇਸ਼ਨ | [/cli](https://lingo.dev/cli) | +| **CI/CD** | ਹਰ ਪੁਸ਼ 'ਤੇ ਅਨੁਵਾਦ ਆਟੋ-ਕਮਿਟ ਕਰੋ + ਲੋੜ ਪੈਣ 'ਤੇ ਪੁੱਲ ਰਿਕੁਐਸਟ ਬਣਾਓ | [/ci](https://lingo.dev/ci) | +| **SDK** | ਯੂਜ਼ਰ-ਜਨਰੇਟਿਡ ਸਮੱਗਰੀ ਲਈ ਰੀਅਲਟਾਈਮ ਅਨੁਵਾਦ | [/sdk](https://lingo.dev/sdk) | + +ਹੇਠਾਂ ਹਰੇਕ ਲਈ ਤੇਜ਼ ਜਾਣਕਾਰੀ ਹੈ 👇 + +--- + +### ⚡️ Lingo.dev CLI + +ਆਪਣੇ ਟਰਮੀਨਲ ਤੋਂ ਸਿੱਧੇ ਕੋਡ ਅਤੇ ਸਮੱਗਰੀ ਦਾ ਅਨੁਵਾਦ ਕਰੋ। + +```bash +npx lingo.dev@latest run +``` + +ਇਹ ਹਰ ਸਟ੍ਰਿੰਗ ਨੂੰ ਫਿੰਗਰਪ੍ਰਿੰਟ ਕਰਦਾ ਹੈ, ਨਤੀਜਿਆਂ ਨੂੰ ਕੈਸ਼ ਕਰਦਾ ਹੈ, ਅਤੇ ਸਿਰਫ਼ ਬਦਲੀਆਂ ਚੀਜ਼ਾਂ ਦਾ ਦੁਬਾਰਾ ਅਨੁਵਾਦ ਕਰਦਾ ਹੈ। + +ਇਸਨੂੰ ਸੈੱਟਅੱਪ ਕਰਨ ਬਾਰੇ ਜਾਣਨ ਲਈ [ਡੌਕੁਮੈਂਟ ਦੇਖੋ →](https://lingo.dev/cli)। + +--- + +### 🔄 Lingo.dev CI/CD + +ਸੰਪੂਰਨ ਅਨੁਵਾਦ ਆਪਣੇ-ਆਪ ਭੇਜੋ। + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +ਤੁਹਾਡੇ ਰਿਪੋ ਨੂੰ ਹਰਾ ਅਤੇ ਤੁਹਾਡੇ ਉਤਪਾਦ ਨੂੰ ਬਿਨਾਂ ਮੈਨੁਅਲ ਕਦਮਾਂ ਦੇ ਬਹੁਭਾਸ਼ੀ ਰੱਖਦਾ ਹੈ। + +[ਡੌਕੁਮੈਂਟ ਪੜ੍ਹੋ →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +ਗਤੀਸ਼ੀਲ ਸਮੱਗਰੀ ਲਈ ਤੁਰੰਤ ਪ੍ਰਤੀ-ਬੇਨਤੀ ਅਨੁਵਾਦ। + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +ਚੈਟ, ਉਪਭੋਗਤਾ ਟਿੱਪਣੀਆਂ, ਅਤੇ ਹੋਰ ਰੀਅਲ-ਟਾਈਮ ਫਲੋਅ ਲਈ ਸੰਪੂਰਨ। + +[ਡੌਕੁਮੈਂਟ ਪੜ੍ਹੋ →](https://lingo.dev/sdk) + +--- + +## 🤝 ਕਮਿਊਨਿਟੀ + +ਅਸੀਂ ਕਮਿਊਨਿਟੀ-ਸੰਚਾਲਿਤ ਹਾਂ ਅਤੇ ਯੋਗਦਾਨਾਂ ਨੂੰ ਪਸੰਦ ਕਰਦੇ ਹਾਂ! + +- ਕੋਈ ਵਿਚਾਰ ਹੈ? [ਇੱਕ ਇਸ਼ੂ ਖੋਲ੍ਹੋ](https://github.com/lingodotdev/lingo.dev/issues) +- ਕੁਝ ਠੀਕ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ? [PR ਭੇਜੋ](https://github.com/lingodotdev/lingo.dev/pulls) +- ਮਦਦ ਚਾਹੀਦੀ ਹੈ? [ਸਾਡੇ Discord ਨਾਲ ਜੁੜੋ](https://lingo.dev/go/discord) + +## ⭐ ਸਟਾਰ ਇਤਿਹਾਸ + +ਜੇ ਤੁਹਾਨੂੰ ਸਾਡਾ ਕੰਮ ਪਸੰਦ ਹੈ, ਤਾਂ ਸਾਨੂੰ ⭐ ਦਿਓ ਅਤੇ 6,000 ਸਿਤਾਰਿਆਂ ਤੱਕ ਪਹੁੰਚਣ ਵਿੱਚ ਸਾਡੀ ਮਦਦ ਕਰੋ! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 ਹੋਰ ਭਾਸ਼ਾਵਾਂ ਵਿੱਚ Readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +ਤੁਹਾਡੀ ਭਾਸ਼ਾ ਨਹੀਂ ਦਿਖਾਈ ਦੇ ਰਹੀ? ਇਸਨੂੰ [`i18n.json`](./i18n.json) ਵਿੱਚ ਸ਼ਾਮਲ ਕਰੋ ਅਤੇ PR ਖੋਲ੍ਹੋ! + +**ਲੋਕੇਲ ਫਾਰਮੈਟ:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) ਕੋਡ ਵਰਤੋ: `language[-Script][-REGION]` + +- ਭਾਸ਼ਾ: ISO 639-1/2/3 ਛੋਟੇ ਅੱਖਰ (`en`, `zh`, `bho`) +- ਲਿਪੀ: ISO 15924 ਟਾਈਟਲ ਕੇਸ (`Hans`, `Hant`, `Latn`) +- ਖੇਤਰ: ISO 3166-1 alpha-2 ਵੱਡੇ ਅੱਖਰ (`US`, `CN`, `IN`) +- ਉਦਾਹਰਨਾਂ: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/pl.md b/readme/pl.md new file mode 100644 index 000000000..b8706d3ca --- /dev/null +++ b/readme/pl.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev – otwartoźródłowy, oparty na AI zestaw narzędzi i18n do + natychmiastowej lokalizacji z użyciem LLM. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Ostatni commit + + + Product Hunt #1 DevTool miesiąca + + + Product Hunt #1 Produkt tygodnia + + + Product Hunt #2 Produkt dnia + + + Github trending + +

    + +--- + +## Poznaj Compiler 🆕 + +**Lingo.dev Compiler** to darmowe, otwartoźródłowe oprogramowanie pośredniczące (middleware), które umożliwia każdej aplikacji React obsługę wielu języków już na etapie budowania, bez konieczności modyfikowania istniejących komponentów React. + +Zainstaluj raz: + +```bash +npm install @lingo.dev/compiler +``` + +Włącz w swojej konfiguracji builda: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +Uruchom `next build` i zobacz, jak pojawiają się paczki hiszpańskie i francuskie ✨ + +[Przeczytaj dokumentację →](https://lingo.dev/compiler), aby poznać pełny przewodnik, oraz [dołącz do naszego Discorda](https://lingo.dev/go/discord), by uzyskać pomoc przy konfiguracji. + +--- + +### Co znajdziesz w tym repozytorium? + +| Narzędzie | TL;DR | Dokumentacja | +| ------------ | ------------------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | Lokalizacja Reacta na etapie budowania | [/compiler](https://lingo.dev/compiler) | +| **CLI** | Lokalizacja webowych i mobilnych aplikacji, JSON, YAML, markdown i więcej jednym poleceniem | [/cli](https://lingo.dev/cli) | +| **CI/CD** | Auto-commit tłumaczeń przy każdym pushu + tworzenie pull requestów w razie potrzeby | [/ci](https://lingo.dev/ci) | +| **SDK** | Tłumaczenie w czasie rzeczywistym treści generowanych przez użytkowników | [/sdk](https://lingo.dev/sdk) | + +Poniżej znajdziesz szybkie podsumowanie dla każdego z nich 👇 + +--- + +### ⚡️ Lingo.dev CLI + +Tłumacz kod i treści bezpośrednio z terminala. + +```bash +npx lingo.dev@latest run +``` + +Tworzy odcisk palca każdej frazy, zapisuje wyniki w pamięci podręcznej i tłumaczy ponownie tylko to, co się zmieniło. + +[Przejdź do dokumentacji →](https://lingo.dev/cli), aby dowiedzieć się, jak to skonfigurować. + +--- + +### 🔄 Lingo.dev CI/CD + +Wysyłaj idealne tłumaczenia automatycznie. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +Utrzymuje Twój repozytorium w dobrej kondycji i sprawia, że Twój produkt jest wielojęzyczny bez ręcznych kroków. + +[Przeczytaj dokumentację →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +Błyskawiczne tłumaczenie na żądanie dla dynamicznych treści. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +Idealnie do czatów, komentarzy użytkowników i innych przepływów w czasie rzeczywistym. + +[Przeczytaj dokumentację →](https://lingo.dev/sdk) + +--- + +## 🤝 Społeczność + +Jesteśmy napędzani przez społeczność i uwielbiamy Wasze wkłady! + +- Masz pomysł? [Otwórz zgłoszenie](https://github.com/lingodotdev/lingo.dev/issues) +- Chcesz coś poprawić? [Wyślij PR](https://github.com/lingodotdev/lingo.dev/pulls) +- Potrzebujesz pomocy? [Dołącz do naszego Discorda](https://lingo.dev/go/discord) + +## ⭐ Historia gwiazdek + +Jeśli podoba Ci się to, co robimy, daj nam ⭐ i pomóż nam osiągnąć 6 000 gwiazdek! 🌟 + +[ + +![Wykres historii gwiazdek](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 Readme w innych językach + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +Nie widzisz swojego języka? Dodaj go do [`i18n.json`](./i18n.json) i otwórz PR! + +**Format lokalizacji:** Używaj kodów [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale): `language[-Script][-REGION]` + +- Język: ISO 639-1/2/3 małymi literami (`en`, `zh`, `bho`) +- Skrypt: ISO 15924 z wielką literą na początku (`Hans`, `Hant`, `Latn`) +- Region: ISO 3166-1 alpha-2 wielkimi literami (`US`, `CN`, `IN`) +- Przykłady: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/pt-BR.md b/readme/pt-BR.md new file mode 100644 index 000000000..690b5d149 --- /dev/null +++ b/readme/pt-BR.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - kit de ferramentas i18n de código aberto, com IA, para + localização instantânea com LLMs. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + Licença + + + Último Commit + + + Product Hunt #1 DevTool do Mês + + + Product Hunt #1 DevTool da Semana + + + Product Hunt #2 Produto do Dia + + + Trending no Github + +

    + +--- + +## Conheça o Compiler 🆕 + +**Lingo.dev Compiler** é um middleware de compilação gratuito e de código aberto, projetado para tornar qualquer aplicativo React multilíngue em tempo de build sem exigir alterações nos componentes React existentes. + +Instale uma vez: + +```bash +npm install @lingo.dev/compiler +``` + +Habilite na sua configuração de build: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +Execute `next build` e veja os bundles em espanhol e francês aparecerem ✨ + +[Leia a documentação →](https://lingo.dev/compiler) para o guia completo, e [Entre no nosso Discord](https://lingo.dev/go/discord) para obter ajuda com sua configuração. + +--- + +### O que tem dentro deste repositório? + +| Ferramenta | Resumo | Documentação | +| ------------ | ------------------------------------------------------------------------------------ | --------------------------------------- | +| **Compiler** | Localização React em tempo de build | [/compiler](https://lingo.dev/compiler) | +| **CLI** | Localização com um comando para apps web e mobile, JSON, YAML, markdown e muito mais | [/cli](https://lingo.dev/cli) | +| **CI/CD** | Auto-commit de traduções a cada push + criação de pull requests quando necessário | [/ci](https://lingo.dev/ci) | +| **SDK** | Tradução em tempo real para conteúdo gerado por usuários | [/sdk](https://lingo.dev/sdk) | + +Abaixo estão os destaques rápidos para cada um 👇 + +--- + +### ⚡️ Lingo.dev CLI + +Traduza código e conteúdo diretamente do seu terminal. + +```bash +npx lingo.dev@latest run +``` + +Ele cria uma impressão digital de cada string, armazena resultados em cache e só retraduz o que mudou. + +[Siga a documentação →](https://lingo.dev/cli) para aprender como configurar. + +--- + +### 🔄 Lingo.dev CI/CD + +Entregue traduções perfeitas automaticamente. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +Mantém seu repositório funcionando e seu produto multilíngue sem etapas manuais. + +[Leia a documentação →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +Tradução instantânea por requisição para conteúdo dinâmico. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +Perfeito para chat, comentários de usuários e outros fluxos em tempo real. + +[Leia a documentação →](https://lingo.dev/sdk) + +--- + +## 🤝 Comunidade + +Somos orientados pela comunidade e adoramos contribuições! + +- Tem uma ideia? [Abra uma issue](https://github.com/lingodotdev/lingo.dev/issues) +- Quer corrigir algo? [Envie um PR](https://github.com/lingodotdev/lingo.dev/pulls) +- Precisa de ajuda? [Entre no nosso Discord](https://lingo.dev/go/discord) + +## ⭐ Histórico de estrelas + +Se você gosta do que estamos fazendo, nos dê uma ⭐ e nos ajude a alcançar 6.000 estrelas! 🌟 + +[ + +![Gráfico de histórico de estrelas](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 Leia-me em outros idiomas + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +Não vê o seu idioma? Adicione-o em [`i18n.json`](./i18n.json) e abra um PR! + +**Formato de locale:** Use códigos [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale): `language[-Script][-REGION]` + +- Idioma: ISO 639-1/2/3 em minúsculas (`en`, `zh`, `bho`) +- Script: ISO 15924 em title case (`Hans`, `Hant`, `Latn`) +- Região: ISO 3166-1 alpha-2 em maiúsculas (`US`, `CN`, `IN`) +- Exemplos: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/ru.md b/readme/ru.md new file mode 100644 index 000000000..f7494c39e --- /dev/null +++ b/readme/ru.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev — open-source AI-инструмент для мгновенной локализации с + помощью LLM. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Встречайте Compiler 🆕 + +**Lingo.dev Compiler** — бесплатный open-source middleware-компилятор, который делает любой React-приложение мультиязычным на этапе сборки, без изменений в существующих React-компонентах. + +Установите один раз: + +```bash +npm install @lingo.dev/compiler +``` + +Включите в вашем build config: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +Запустите `next build` и смотрите, как появляются бандлы на испанском и французском ✨ + +[Читать документацию →](https://lingo.dev/compiler) для полного гайда, а также [залетайте в наш Discord](https://lingo.dev/go/discord), чтобы получить помощь с настройкой. + +--- + +### Что внутри этого репозитория? + +| Инструмент | TL;DR | Документация | +| ------------ | ---------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | Локализация React на этапе сборки | [/compiler](https://lingo.dev/compiler) | +| **CLI** | Локализация для web и mobile, JSON, YAML, markdown и др. одной командой | [/cli](https://lingo.dev/cli) | +| **CI/CD** | Авто-коммит переводов при каждом пуше + создание pull request'ов при необходимости | [/ci](https://lingo.dev/ci) | +| **SDK** | Перевод в реальном времени для пользовательского контента | [/sdk](https://lingo.dev/sdk) | + +Ниже — краткие фишки для каждого варианта 👇 + +--- + +### ⚡️ Lingo.dev CLI + +Переводите код и контент прямо из терминала. + +```bash +npx lingo.dev@latest run +``` + +Каждая строка получает свой отпечаток, результаты кэшируются, и переводятся только изменённые части. + +[Следуйте документации →](https://lingo.dev/cli), чтобы узнать, как всё настроить. + +--- + +### 🔄 Lingo.dev CI/CD + +Отправляйте идеальные переводы автоматически. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +Ваш репозиторий всегда зелёный, а продукт — мультиязычный, без ручных шагов. + +[Читать документацию →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +Мгновенный перевод по запросу для динамического контента. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +Идеально для чатов, комментариев пользователей и других real-time сценариев. + +[Читать документацию →](https://lingo.dev/sdk) + +--- + +## 🤝 Сообщество + +Мы развиваемся вместе с комьюнити и любим вклад каждого! + +- Есть идея? [Откройте issue](https://github.com/lingodotdev/lingo.dev/issues) +- Хотите что-то исправить? [Присылайте PR](https://github.com/lingodotdev/lingo.dev/pulls) +- Нужна помощь? [Присоединяйтесь к нашему Discord](https://lingo.dev/go/discord) + +## ⭐ История звёзд + +Если вам нравится, что мы делаем, поставьте ⭐ и помогите нам добраться до 6 000 звёзд! 🌟 + +[ + +![График истории звёзд](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 Readme на других языках + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +Не нашли свой язык? Добавьте его в [`i18n.json`](./i18n.json) и откройте PR! + +**Формат локали:** используйте коды по [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale): `language[-Script][-REGION]` + +- Язык: строчные буквы по ISO 639-1/2/3 (`en`, `zh`, `bho`) +- Письмо: с заглавной буквы по ISO 15924 (`Hans`, `Hant`, `Latn`) +- Регион: прописные буквы по ISO 3166-1 alpha-2 (`US`, `CN`, `IN`) +- Примеры: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/si-LK.md b/readme/si-LK.md new file mode 100644 index 000000000..e9b3d7415 --- /dev/null +++ b/readme/si-LK.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - LLMs සමඟ ක්ෂණික ප්‍රාදේශීයකරණය සඳහා විවෘත-මූලාශ්‍ර, + AI-බලගන්වන i18n මෙවලම් කට්ටලය. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compiler හමුවන්න 🆕 + +**Lingo.dev Compiler** යනු නොමිලේ, විවෘත-මූලාශ්‍ර compiler middleware එකක්, පවතින React components වලට කිසිදු වෙනසක් අවශ්‍ය නොවී build කාලයේදී ඕනෑම React යෙදුමක් බහුභාෂා බවට පත් කිරීම සඳහා නිර්මාණය කර ඇත. + +එක් වරක් ස්ථාපනය කරන්න: + +```bash +npm install @lingo.dev/compiler +``` + +ඔබේ build config එකේ සක්‍රීය කරන්න: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` ධාවනය කර ස්පාඤ්ඤ සහ ප්‍රංශ bundles එළියට එනවා බලන්න ✨ + +සම්පූර්ණ මාර්ගෝපදේශය සඳහා [ලේඛන කියවන්න →](https://lingo.dev/compiler), සහ ඔබේ setup එක සඳහා උදව් ලබා ගැනීමට [අපගේ Discord එකට එක්වන්න](https://lingo.dev/go/discord). + +--- + +### මෙම repo එක තුළ මොනවාද තියෙන්නේ? + +| මෙවලම | TL;DR | ලේඛන | +| ------------ | ------------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | Build-time React ප්‍රාදේශීයකරණය | [/compiler](https://lingo.dev/compiler) | +| **CLI** | වෙබ් සහ ජංගම යෙදුම්, JSON, YAML, markdown, + තවත් දේ සඳහා එක-විධානයෙන් ප්‍රාදේශීයකරණය | [/cli](https://lingo.dev/cli) | +| **CI/CD** | සෑම push එකකම ස්වයංක්‍රීයව පරිවර්තන commit කරන්න + අවශ්‍ය නම් pull requests සාදන්න | [/ci](https://lingo.dev/ci) | +| **SDK** | පරිශීලක-ජනනය කළ අන්තර්ගතය සඳහා තත්‍ය කාලීන පරිවර්තනය | [/sdk](https://lingo.dev/sdk) | + +පහත දැක්වෙන්නේ එක් එක් සඳහා ඉක්මන් විස්තර 👇 + +--- + +### ⚡️ Lingo.dev CLI + +ඔබේ ටර්මිනලයෙන් කෙලින්ම කේතය සහ අන්තර්ගතය පරිවර්තනය කරන්න. + +```bash +npx lingo.dev@latest run +``` + +එය සෑම තන්තුවක්ම ඇඟිලි සලකුණු කරයි, ප්‍රතිඵල හැඹිලිගත කරයි, සහ වෙනස් වූ දේ පමණක් නැවත පරිවර්තනය කරයි. + +එය සකසන ආකාරය ඉගෙන ගැනීමට [ලේඛන අනුගමනය කරන්න →](https://lingo.dev/cli). + +--- + +### 🔄 Lingo.dev CI/CD + +පරිපූර්ණ පරිවර්තන ස්වයංක්‍රීයව යවන්න. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +අතින් කළ යුතු පියවර නොමැතිව ඔබේ ගබඩාව හරිත සහ ඔබේ නිෂ්පාදනය බහුභාෂා තබා ගනී. + +[ලේඛන කියවන්න →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +ගතික අන්තර්ගතය සඳහා ක්ෂණික ඉල්ලීම් අනුව පරිවර්තනය. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +චැට්, පරිශීලක අදහස් සහ අනෙකුත් තත්‍ය කාලීන ප්‍රවාහ සඳහා පරිපූර්ණයි. + +[ලේඛන කියවන්න →](https://lingo.dev/sdk) + +--- + +## 🤝 ප්‍රජාව + +අපි ප්‍රජාව මත පදනම් වූ අතර දායකත්වයට ආදරය කරමු! + +- අදහසක් තිබේද? [ගැටළුවක් විවෘත කරන්න](https://github.com/lingodotdev/lingo.dev/issues) +- යමක් නිවැරදි කිරීමට අවශ්‍යද? [PR එකක් යවන්න](https://github.com/lingodotdev/lingo.dev/pulls) +- උදව් අවශ්‍යද? [අපගේ Discord එකට එක්වන්න](https://lingo.dev/go/discord) + +## ⭐ තරු ඉතිහාසය + +අපි කරන දේ ඔබට කැමති නම්, අපට ⭐ එකක් දී තරු 6,000 කට ළඟා වීමට අපට උදව් කරන්න! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 වෙනත් භාෂාවලින් Readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +ඔබේ භාෂාව නොපෙනේද? එය [`i18n.json`](./i18n.json) වෙත එක් කර PR එකක් විවෘත කරන්න! + +**භාෂා කේත ආකෘතිය:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) කේත භාවිතා කරන්න: `language[-Script][-REGION]` + +- භාෂාව: ISO 639-1/2/3 කුඩා අකුරු (`en`, `zh`, `bho`) +- අක්ෂර පද්ධතිය: ISO 15924 මාතෘකා අකුරු (`Hans`, `Hant`, `Latn`) +- කලාපය: ISO 3166-1 alpha-2 ලොකු අකුරු (`US`, `CN`, `IN`) +- උදාහරණ: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/ta-IN.md b/readme/ta-IN.md new file mode 100644 index 000000000..fda58e657 --- /dev/null +++ b/readme/ta-IN.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - திறந்த மூலக் குறியீடு, AI-இயக்கப்படும் i18n கருவித்தொகுப்பு, + LLM-களுடன் உடனடி உள்ளூர்மயமாக்கலுக்கு. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compiler-ஐ சந்திக்கவும் 🆕 + +**Lingo.dev Compiler** என்பது இலவச, திறந்த மூலக் குறியீடு கம்பைலர் மிடில்வேர் ஆகும், இது எந்த React பயன்பாட்டையும் தற்போதுள்ள React கூறுகளில் எந்த மாற்றமும் தேவையில்லாமல் பில்ட் நேரத்தில் பன்மொழியாக மாற்ற வடிவமைக்கப்பட்டுள்ளது. + +ஒருமுறை நிறுவவும்: + +```bash +npm install @lingo.dev/compiler +``` + +உங்கள் பில்ட் கட்டமைப்பில் இயக்கவும்: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` இயக்கி ஸ்பானிஷ் மற்றும் பிரெஞ்சு பண்டில்கள் வெளிவருவதைப் பாருங்கள் ✨ + +முழு வழிகாட்டிக்கு [ஆவணங்களைப் படிக்கவும் →](https://lingo.dev/compiler), மற்றும் உங்கள் அமைப்பில் உதவி பெற [எங்கள் Discord-இல் சேரவும்](https://lingo.dev/go/discord). + +--- + +### இந்த repo-வில் என்ன உள்ளது? + +| கருவி | சுருக்கம் | ஆவணங்கள் | +| ------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | பில்ட்-நேர React உள்ளூர்மயமாக்கல் | [/compiler](https://lingo.dev/compiler) | +| **CLI** | வலை மற்றும் மொபைல் பயன்பாடுகள், JSON, YAML, markdown மற்றும் பலவற்றிற்கான ஒரு-கட்டளை உள்ளூர்மயமாக்கல் | [/cli](https://lingo.dev/cli) | +| **CI/CD** | ஒவ்வொரு push-இலும் தானாக மொழிபெயர்ப்புகளை commit செய்யவும் + தேவைப்பட்டால் pull request-களை உருவாக்கவும் | [/ci](https://lingo.dev/ci) | +| **SDK** | பயனர் உருவாக்கிய உள்ளடக்கத்திற்கான நேரடி மொழிபெயர்ப்பு | [/sdk](https://lingo.dev/sdk) | + +ஒவ்வொன்றுக்கும் விரைவான முக்கிய அம்சங்கள் கீழே உள்ளன 👇 + +--- + +### ⚡️ Lingo.dev CLI + +உங்கள் டெர்மினலில் இருந்தே குறியீடு மற்றும் உள்ளடக்கத்தை மொழிபெயர்க்கவும். + +```bash +npx lingo.dev@latest run +``` + +இது ஒவ்வொரு சரத்தையும் கைரேகையிடுகிறது, முடிவுகளை தற்காலிகமாக சேமிக்கிறது, மற்றும் மாற்றப்பட்டவற்றை மட்டும் மீண்டும் மொழிபெயர்க்கிறது. + +அமைப்பு முறையை அறிய [ஆவணங்களைப் பின்பற்றவும் →](https://lingo.dev/cli). + +--- + +### 🔄 Lingo.dev CI/CD + +சரியான மொழிபெயர்ப்புகளை தானாக வெளியிடவும். + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +உங்கள் களஞ்சியத்தை பசுமையாகவும், உங்கள் தயாரிப்பை பன்மொழியாகவும் கைமுறை படிகள் இல்லாமல் வைத்திருக்கிறது. + +[ஆவணங்களைப் படிக்கவும் →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +மாறும் உள்ளடக்கத்திற்கு உடனடி கோரிக்கை அடிப்படையிலான மொழிபெயர்ப்பு. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +அரட்டை, பயனர் கருத்துகள் மற்றும் பிற நேரடி ஓட்டங்களுக்கு சிறந்தது. + +[ஆவணங்களைப் படிக்கவும் →](https://lingo.dev/sdk) + +--- + +## 🤝 சமூகம் + +நாங்கள் சமூக உந்துதலுடன் செயல்படுகிறோம், பங்களிப்புகளை விரும்புகிறோம்! + +- ஒரு யோசனை உள்ளதா? [சிக்கலைத் திறக்கவும்](https://github.com/lingodotdev/lingo.dev/issues) +- ஏதாவது சரிசெய்ய விரும்புகிறீர்களா? [PR அனுப்பவும்](https://github.com/lingodotdev/lingo.dev/pulls) +- உதவி தேவையா? [எங்கள் Discord-இல் சேரவும்](https://lingo.dev/go/discord) + +## ⭐ நட்சத்திர வரலாறு + +நாங்கள் செய்வது உங்களுக்குப் பிடித்திருந்தால், எங்களுக்கு ஒரு ⭐ கொடுத்து 6,000 நட்சத்திரங்களை அடைய உதவவும்! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 மற்ற மொழிகளில் Readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +உங்கள் மொழி இல்லையா? அதை [`i18n.json`](./i18n.json)-ல் சேர்த்து PR திறக்கவும்! + +**மொழிக் குறியீட்டு வடிவம்:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) குறியீடுகளைப் பயன்படுத்தவும்: `language[-Script][-REGION]` + +- மொழி: ISO 639-1/2/3 சிறிய எழுத்துகள் (`en`, `zh`, `bho`) +- எழுத்துமுறை: ISO 15924 தலைப்பெழுத்து வடிவம் (`Hans`, `Hant`, `Latn`) +- பிராந்தியம்: ISO 3166-1 alpha-2 பெரிய எழுத்துகள் (`US`, `CN`, `IN`) +- எடுத்துக்காட்டுகள்: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` \ No newline at end of file diff --git a/readme/te-IN.md b/readme/te-IN.md new file mode 100644 index 000000000..a16c29676 --- /dev/null +++ b/readme/te-IN.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - LLMలతో తక్షణ స్థానికీకరణ కోసం ఓపెన్-సోర్స్, AI-ఆధారిత i18n + టూల్‌కిట్. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## కంపైలర్‌ను కలవండి 🆕 + +**Lingo.dev Compiler** అనేది ఉచిత, ఓపెన్-సోర్స్ కంపైలర్ మిడిల్‌వేర్, ఇది ఇప్పటికే ఉన్న React కాంపోనెంట్‌లకు ఎలాంటి మార్పులు అవసరం లేకుండా బిల్డ్ టైమ్‌లో ఏ React యాప్‌ను అయినా బహుభాషాగా మార్చడానికి రూపొందించబడింది. + +ఒకసారి ఇన్‌స్టాల్ చేయండి: + +```bash +npm install @lingo.dev/compiler +``` + +మీ బిల్డ్ కాన్ఫిగ్‌లో ఎనేబుల్ చేయండి: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` రన్ చేయండి మరియు స్పానిష్ మరియు ఫ్రెంచ్ బండిల్స్ బయటకు రావడం చూడండి ✨ + +పూర్తి గైడ్ కోసం [డాక్స్ చదవండి →](https://lingo.dev/compiler), మరియు మీ సెటప్‌తో సహాయం పొందడానికి [మా Discordలో చేరండి](https://lingo.dev/go/discord). + +--- + +### ఈ రిపోలో ఏముంది? + +| టూల్ | TL;DR | డాక్స్ | +| ------------ | ---------------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | బిల్డ్-టైమ్ React స్థానికీకరణ | [/compiler](https://lingo.dev/compiler) | +| **CLI** | వెబ్ మరియు మొబైల్ యాప్‌లు, JSON, YAML, markdown, + మరిన్నింటి కోసం ఒక-కమాండ్ స్థానికీకరణ | [/cli](https://lingo.dev/cli) | +| **CI/CD** | ప్రతి పుష్‌లో అనువాదాలను ఆటో-కమిట్ చేయండి + అవసరమైతే పుల్ రిక్వెస్ట్‌లు సృష్టించండి | [/ci](https://lingo.dev/ci) | +| **SDK** | యూజర్-జనరేటెడ్ కంటెంట్ కోసం రియల్‌టైమ్ అనువాదం | [/sdk](https://lingo.dev/sdk) | + +ప్రతి ఒక్కదానికి సంబంధించిన ముఖ్య విషయాలు క్రింద ఉన్నాయి 👇 + +--- + +### ⚡️ Lingo.dev CLI + +మీ టెర్మినల్ నుండి నేరుగా కోడ్ & కంటెంట్‌ను అనువదించండి. + +```bash +npx lingo.dev@latest run +``` + +ఇది ప్రతి స్ట్రింగ్‌కు ఫింగర్‌ప్రింట్ చేస్తుంది, ఫలితాలను క్యాష్ చేస్తుంది మరియు మార్చబడిన వాటిని మాత్రమే తిరిగి అనువదిస్తుంది. + +[డాక్యుమెంటేషన్ చూడండి →](https://lingo.dev/cli) సెటప్ ఎలా చేయాలో తెలుసుకోవడానికి. + +--- + +### 🔄 Lingo.dev CI/CD + +పరిపూర్ణ అనువాదాలను ఆటోమేటిక్‌గా డెలివర్ చేయండి. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +మాన్యువల్ స్టెప్స్ లేకుండా మీ రిపోను గ్రీన్‌గా మరియు మీ ప్రోడక్ట్‌ను బహుభాషాగా ఉంచుతుంది. + +[డాక్యుమెంటేషన్ చదవండి →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +డైనమిక్ కంటెంట్ కోసం తక్షణ పర్-రిక్వెస్ట్ అనువాదం. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +చాట్, యూజర్ కామెంట్స్ మరియు ఇతర రియల్-టైం ఫ్లోస్ కోసం పర్ఫెక్ట్. + +[డాక్యుమెంటేషన్ చదవండి →](https://lingo.dev/sdk) + +--- + +## 🤝 కమ్యూనిటీ + +మేము కమ్యూనిటీ-డ్రివెన్ మరియు కంట్రిబ్యూషన్స్‌ను ఇష్టపడతాము! + +- ఐడియా ఉందా? [ఇష్యూ ఓపెన్ చేయండి](https://github.com/lingodotdev/lingo.dev/issues) +- ఏదైనా ఫిక్స్ చేయాలనుకుంటున్నారా? [PR పంపండి](https://github.com/lingodotdev/lingo.dev/pulls) +- సహాయం కావాలా? [మా డిస్కార్డ్‌లో చేరండి](https://lingo.dev/go/discord) + +## ⭐ స్టార్ హిస్టరీ + +మేము చేస్తున్నది మీకు నచ్చితే, మాకు ⭐ ఇవ్వండి మరియు 6,000 స్టార్స్ చేరుకోవడానికి సహాయం చేయండి! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 ఇతర భాషలలో రీడ్‌మీ + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +మీ భాష కనిపించడం లేదా? దీన్ని [`i18n.json`](./i18n.json)కి జోడించి PR ఓపెన్ చేయండి! + +**లొకేల్ ఫార్మాట్:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) కోడ్‌లను ఉపయోగించండి: `language[-Script][-REGION]` + +- భాష: ISO 639-1/2/3 చిన్నబడి (`en`, `zh`, `bho`) +- లిపి: ISO 15924 టైటిల్ కేస్ (`Hans`, `Hant`, `Latn`) +- ప్రాంతం: ISO 3166-1 alpha-2 పెద్దబడి (`US`, `CN`, `IN`) +- ఉదాహరణలు: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/tr.md b/readme/tr.md new file mode 100644 index 000000000..f859d3c72 --- /dev/null +++ b/readme/tr.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - LLM'ler ile anında yerelleştirme için açık kaynaklı, yapay + zeka destekli i18n araç seti. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compiler ile tanışın 🆕 + +**Lingo.dev Compiler**, mevcut React bileşenlerinde herhangi bir değişiklik gerektirmeden, herhangi bir React uygulamasını derleme zamanında çok dilli hale getirmek için tasarlanmış ücretsiz, açık kaynaklı bir derleyici ara yazılımıdır. + +Bir kez kurun: + +```bash +npm install @lingo.dev/compiler +``` + +Derleme yapılandırmanızda etkinleştirin: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` komutunu çalıştırın ve İspanyolca ve Fransızca paketlerin ortaya çıkışını izleyin ✨ + +Tam kılavuz için [belgeleri okuyun →](https://lingo.dev/compiler) ve kurulumunuzla ilgili yardım almak için [Discord'umuza katılın](https://lingo.dev/go/discord). + +--- + +### Bu repo'nun içinde neler var? + +| Araç | Kısaca | Belgeler | +| ------------ | --------------------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | Derleme zamanında React yerelleştirme | [/compiler](https://lingo.dev/compiler) | +| **CLI** | Web ve mobil uygulamalar, JSON, YAML, markdown ve daha fazlası için tek komutla yerelleştirme | [/cli](https://lingo.dev/cli) | +| **CI/CD** | Her push'ta otomatik çeviri commit'i + gerekirse pull request oluşturma | [/ci](https://lingo.dev/ci) | +| **SDK** | Kullanıcı tarafından oluşturulan içerik için gerçek zamanlı çeviri | [/sdk](https://lingo.dev/sdk) | + +Aşağıda her biri için hızlı özetler bulunuyor 👇 + +--- + +### ⚡️ Lingo.dev CLI + +Kodu ve içeriği doğrudan terminalinizden çevirin. + +```bash +npx lingo.dev@latest run +``` + +Her dizeyi parmak iziyle tanımlar, sonuçları önbelleğe alır ve yalnızca değişenleri yeniden çevirir. + +Nasıl kurulacağını öğrenmek için [belgeleri takip edin →](https://lingo.dev/cli). + +--- + +### 🔄 Lingo.dev CI/CD + +Mükemmel çevirileri otomatik olarak yayınlayın. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +Deponuzu yeşil tutar ve ürününüzü manuel adımlar olmadan çok dilli hale getirir. + +[Belgeleri okuyun →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +Dinamik içerik için istek başına anında çeviri. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +Sohbet, kullanıcı yorumları ve diğer gerçek zamanlı akışlar için mükemmel. + +[Belgeleri okuyun →](https://lingo.dev/sdk) + +--- + +## 🤝 Topluluk + +Topluluk odaklıyız ve katkıları seviyoruz! + +- Bir fikriniz mi var? [Bir sorun açın](https://github.com/lingodotdev/lingo.dev/issues) +- Bir şeyi düzeltmek mi istiyorsunuz? [Bir PR gönderin](https://github.com/lingodotdev/lingo.dev/pulls) +- Yardıma mı ihtiyacınız var? [Discord'umuza katılın](https://lingo.dev/go/discord) + +## ⭐ Yıldız geçmişi + +Yaptıklarımızı beğeniyorsanız, bize bir ⭐ verin ve 6.000 yıldıza ulaşmamıza yardımcı olun! 🌟 + +[ + +![Yıldız Geçmişi Grafiği](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 Diğer dillerde readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +Dilinizi görmüyor musunuz? [`i18n.json`](./i18n.json) dosyasına ekleyin ve bir PR açın! + +**Yerel ayar formatı:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) kodlarını kullanın: `language[-Script][-REGION]` + +- Dil: ISO 639-1/2/3 küçük harf (`en`, `zh`, `bho`) +- Alfabe: ISO 15924 baş harfi büyük (`Hans`, `Hant`, `Latn`) +- Bölge: ISO 3166-1 alpha-2 büyük harf (`US`, `CN`, `IN`) +- Örnekler: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/uk-UA.md b/readme/uk-UA.md new file mode 100644 index 000000000..17a90501f --- /dev/null +++ b/readme/uk-UA.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - інструментарій для миттєвої локалізації з відкритим кодом на + основі штучного інтелекту з використанням LLM. + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool місяця + + + Product Hunt #1 DevTool тижня + + + Product Hunt #2 продукт дня + + + Github trending + +

    + +--- + +## Знайомтеся з Compiler 🆕 + +**Lingo.dev Compiler** — це безкоштовний компілятор з відкритим кодом, розроблений для того, щоб зробити будь-який React-додаток багатомовним під час збірки без необхідності внесення змін до існуючих React-компонентів. + +Встановіть один раз: + +```bash +npm install @lingo.dev/compiler +``` + +Увімкніть у конфігурації збірки: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +Запустіть `next build` і спостерігайте, як з'являються іспанські та французькі збірки ✨ + +[Читати документацію →](https://lingo.dev/compiler) для повного посібника та [приєднуйтесь до нашого Discord](https://lingo.dev/go/discord), щоб отримати допомогу з налаштуванням. + +--- + +### Що міститься в цьому репозиторії? + +| Інструмент | Коротко | Документація | +| ------------ | --------------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | Локалізація React під час збірки | [/compiler](https://lingo.dev/compiler) | +| **CLI** | Локалізація однією командою для веб- і мобільних додатків, JSON, YAML, markdown та інше | [/cli](https://lingo.dev/cli) | +| **CI/CD** | Автоматичний коміт перекладів при кожному push + створення pull request за потреби | [/ci](https://lingo.dev/ci) | +| **SDK** | Переклад у реальному часі для контенту, створеного користувачами | [/sdk](https://lingo.dev/sdk) | + +Нижче наведено короткий огляд кожного з них 👇 + +--- + +### ⚡️ Lingo.dev CLI + +Перекладайте код і контент прямо з вашого терміналу. + +```bash +npx lingo.dev@latest run +``` + +Він створює відбиток кожного рядка, кешує результати та перекладає лише те, що змінилося. + +[Читайте документацію →](https://lingo.dev/cli), щоб дізнатися, як його налаштувати. + +--- + +### 🔄 Lingo.dev CI/CD + +Випускайте ідеальні переклади автоматично. + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +Підтримує ваш репозиторій у робочому стані, а ваш продукт багатомовним без ручних кроків. + +[Читайте документацію →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +Миттєвий переклад для динамічного контенту за запитом. + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +Ідеально підходить для чатів, коментарів користувачів та інших процесів у реальному часі. + +[Читайте документацію →](https://lingo.dev/sdk) + +--- + +## 🤝 Спільнота + +Ми орієнтовані на спільноту та любимо внески! + +- Є ідея? [Створіть issue](https://github.com/lingodotdev/lingo.dev/issues) +- Хочете щось виправити? [Надішліть PR](https://github.com/lingodotdev/lingo.dev/pulls) +- Потрібна допомога? [Приєднуйтесь до нашого Discord](https://lingo.dev/go/discord) + +## ⭐ Історія зірок + +Якщо вам подобається те, що ми робимо, поставте нам ⭐ та допоможіть досягти 6 000 зірок! 🌟 + +[ + +![Графік історії зірок](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 Readme іншими мовами + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +Не бачите свою мову? Додайте її до [`i18n.json`](./i18n.json) та відкрийте PR! + +**Формат локалі:** використовуйте коди [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale): `language[-Script][-REGION]` + +- Мова: ISO 639-1/2/3 у нижньому регістрі (`en`, `zh`, `bho`) +- Письмо: ISO 15924 з великої літери (`Hans`, `Hant`, `Latn`) +- Регіон: ISO 3166-1 alpha-2 у верхньому регістрі (`US`, `CN`, `IN`) +- Приклади: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/ur.md b/readme/ur.md new file mode 100644 index 000000000..b2e54f195 --- /dev/null +++ b/readme/ur.md @@ -0,0 +1,219 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - اوپن سورس، AI سے چلنے والا i18n ٹول کٹ جو LLMs کے ساتھ فوری + لوکلائزیشن فراہم کرتا ہے۔ + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt #1 DevTool of the Month + + + Product Hunt #1 DevTool of the Week + + + Product Hunt #2 Product of the Day + + + Github trending + +

    + +--- + +## Compiler سے ملیں 🆕 + +**Lingo.dev Compiler** ایک مفت، اوپن سورس کمپائلر middleware ہے، جو کسی بھی React ایپ کو build کے وقت کثیر لسانی بنانے کے لیے ڈیزائن کیا گیا ہے بغیر موجودہ React components میں کوئی تبدیلی کیے۔ + +ایک بار انسٹال کریں: + +```bash +npm install @lingo.dev/compiler +``` + +اپنی build config میں فعال کریں: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +`next build` چلائیں اور دیکھیں کہ ہسپانوی اور فرانسیسی bundles کیسے سامنے آتے ہیں ✨ + +مکمل گائیڈ کے لیے [دستاویزات پڑھیں →](https://lingo.dev/compiler)، اور اپنے سیٹ اپ میں مدد کے لیے [ہماری Discord میں شامل ہوں](https://lingo.dev/go/discord)۔ + +--- + +### اس repo میں کیا ہے؟ + +| Tool | خلاصہ | دستاویزات | +| ------------ | --------------------------------------------------------------------------------- | --------------------------------------- | +| **Compiler** | Build کے وقت React کی لوکلائزیشن | [/compiler](https://lingo.dev/compiler) | +| **CLI** | ویب اور موبائل ایپس، JSON، YAML، markdown وغیرہ کے لیے ایک کمانڈ لوکلائزیشن | [/cli](https://lingo.dev/cli) | +| **CI/CD** | ہر push پر خودکار طور پر تراجم commit کریں اور ضرورت پڑنے پر pull requests بنائیں | [/ci](https://lingo.dev/ci) | +| **SDK** | یوزر-جنریٹڈ مواد کے لیے ریئل ٹائم ترجمہ | [/sdk](https://lingo.dev/sdk) | + +ذیل میں ہر ایک کے لیے فوری نکات ہیں 👇 + +--- + +### ⚡️ Lingo.dev CLI + +اپنے ٹرمینل سے براہ راست کوڈ اور مواد کا ترجمہ کریں۔ + +```bash +npx lingo.dev@latest run +``` + +یہ ہر سٹرنگ کو فنگر پرنٹ کرتا ہے، نتائج کو کیش کرتا ہے، اور صرف تبدیل شدہ چیزوں کا دوبارہ ترجمہ کرتا ہے۔ + +[دستاویزات دیکھیں →](https://lingo.dev/cli) تاکہ جان سکیں کہ اسے کیسے سیٹ اپ کریں۔ + +--- + +### 🔄 Lingo.dev CI/CD + +خودکار طریقے سے بہترین ترجمے فراہم کریں۔ + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +آپ کے repo کو سبز اور آپ کی پروڈکٹ کو کسی دستی مرحلے کے بغیر کثیر لسانی رکھتا ہے۔ + +[دستاویزات پڑھیں →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +متحرک مواد کے لیے فی درخواست فوری ترجمہ۔ + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +چیٹ، صارف کے تبصروں، اور دیگر real-time flows کے لیے بہترین۔ + +[دستاویزات پڑھیں →](https://lingo.dev/sdk) + +--- + +## 🤝 کمیونٹی + +ہم کمیونٹی پر مبنی ہیں اور شراکتوں کو پسند کرتے ہیں! + +- کوئی خیال ہے؟ [ایک issue کھولیں](https://github.com/lingodotdev/lingo.dev/issues) +- کچھ ٹھیک کرنا چاہتے ہیں؟ [PR بھیجیں](https://github.com/lingodotdev/lingo.dev/pulls) +- مدد چاہیے؟ [ہماری Discord میں شامل ہوں](https://lingo.dev/go/discord) + +## ⭐ Star History + +اگر آپ کو ہمارا کام پسند ہے، تو ہمیں ⭐ دیں اور 6,000 ستاروں تک پہنچنے میں ہماری مدد کریں! 🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 دیگر زبانوں میں Readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [Español](/readme/es.md) • [Français](/readme/fr.md) • [Русский](/readme/ru.md) • [Українська](/readme/uk-UA.md) • [Deutsch](/readme/de.md) • [Italiano](/readme/it.md) • [العربية](/readme/ar.md) • [עברית](/readme/he.md) • [हिन्दी](/readme/hi.md) • [Português (Brasil)](/readme/pt-BR.md) • [বাংলা](/readme/bn.md) • [فارسی](/readme/fa.md) • [Polski](/readme/pl.md) • [Türkçe](/readme/tr.md) • [اردو](/readme/ur.md) • [भोजपुरी](/readme/bho.md) • [অসমীয়া](/readme/as-IN.md) • [ગુજરાતી](/readme/gu-IN.md) • [മലയാളം (IN)](/readme/ml-IN.md) • [मराठी](/readme/mr-IN.md) • [ଓଡ଼ିଆ](/readme/or-IN.md) • [ਪੰਜਾਬੀ](/readme/pa-IN.md) • [සිංහල](/readme/si-LK.md) • [தமிழ்](/readme/ta-IN.md) • [తెలుగు](/readme/te-IN.md) + +اپنی زبان نظر نہیں آ رہی؟ اسے [`i18n.json`](./i18n.json) میں شامل کریں اور PR کھولیں! + +**لوکیل فارمیٹ:** [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) کوڈز استعمال کریں: `language[-Script][-REGION]` + +- زبان: ISO 639-1/2/3 چھوٹے حروف (`en`, `zh`, `bho`) +- رسم الخط: ISO 15924 ٹائٹل کیس (`Hans`, `Hant`, `Latn`) +- خطہ: ISO 3166-1 alpha-2 بڑے حروف (`US`, `CN`, `IN`) +- مثالیں: `en`, `pt-BR`, `zh-Hans`, `sr-Cyrl-RS` diff --git a/readme/zh-Hans.md b/readme/zh-Hans.md new file mode 100644 index 000000000..cdafc5d86 --- /dev/null +++ b/readme/zh-Hans.md @@ -0,0 +1,218 @@ +

    + + Lingo.dev + +

    + +

    + + ⚡ Lingo.dev - 开源、AI 驱动的 i18n 工具包,借助 LLM 实现即时本地化。 + +

    + +
    + +

    + Lingo.dev Compiler • + Lingo.dev MCP • + Lingo.dev CLI • + Lingo.dev CI/CD • + Lingo.dev SDK +

    + +

    + + Release + + + License + + + Last Commit + + + Product Hunt 月度第一开发工具 + + + Product Hunt 本周第一产品 + + + Product Hunt 今日第二产品 + + + Github 趋势 + +

    + +--- + +## 认识 Compiler 🆕 + +**Lingo.dev Compiler** 是一款免费开源的编译中间件,旨在让任何 React 应用在构建时实现多语言支持,无需更改现有 React 组件。 + +只需安装一次: + +```bash +npm install @lingo.dev/compiler +``` + +在构建配置中启用: + +```ts +import type { NextConfig } from "next"; +import { withLingo } from "@lingo.dev/compiler/next"; + +const nextConfig: NextConfig = {}; + +export default async function (): Promise { + return await withLingo(nextConfig, { + sourceLocale: "en", + targetLocales: ["es", "fr"], + models: "lingo.dev", + }); +} +``` + +运行 `next build`,即可看到西班牙语和法语包自动生成 ✨ + +[阅读文档 →](https://lingo.dev/compiler) 获取完整指南,或 [加入我们的 Discord](https://lingo.dev/go/discord) 获取设置帮助。 + +--- + +### 本仓库包含哪些内容? + +| 工具 | 简要说明 | 文档 | +| ------------ | ------------------------------------------------- | --------------------------------------- | +| **Compiler** | 构建时 React 本地化 | [/compiler](https://lingo.dev/compiler) | +| **CLI** | 一键本地化网页和移动应用、JSON、YAML、Markdown 等 | [/cli](https://lingo.dev/cli) | +| **CI/CD** | 每次推送自动提交翻译,如有需要自动创建拉取请求 | [/ci](https://lingo.dev/ci) | +| **SDK** | 用户生成内容的实时翻译 | [/sdk](https://lingo.dev/sdk) | + +下面是每个功能的快速介绍👇 + +--- + +### ⚡️ Lingo.dev CLI + +直接在终端中翻译代码和内容。 + +```bash +npx lingo.dev@latest run +``` + +它会为每个字符串生成指纹,缓存结果,只重新翻译有变动的内容。 + +[查看文档 →](https://lingo.dev/cli) 了解如何设置。 + +--- + +### 🔄 Lingo.dev CI/CD + +自动交付完美翻译。 + +```yaml +# .github/workflows/i18n.yml +name: Lingo.dev i18n +on: [push] + +jobs: + i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + with: + api-key: ${{ secrets.LINGODOTDEV_API_KEY }} +``` + +让你的仓库持续通过检查,让产品无需手动操作即可多语言化。 + +[阅读文档 →](https://lingo.dev/ci) + +--- + +### 🧩 Lingo.dev SDK + +为动态内容提供即时按需翻译。 + +```ts +import { LingoDotDevEngine } from "lingo.dev/sdk"; + +const lingoDotDev = new LingoDotDevEngine({ + apiKey: "your-api-key-here", +}); + +const content = { + greeting: "Hello", + farewell: "Goodbye", + message: "Welcome to our platform", +}; + +const translated = await lingoDotDev.localizeObject(content, { + sourceLocale: "en", + targetLocale: "es", +}); +// Returns: { greeting: "Hola", farewell: "Adiós", message: "Bienvenido a nuestra plataforma" } +``` + +非常适合聊天、用户评论和其他实时场景。 + +[阅读文档 →](https://lingo.dev/sdk) + +--- + +## 🤝 社区 + +我们以社区为驱动力,欢迎大家贡献! + +- 有想法?[提交 issue](https://github.com/lingodotdev/lingo.dev/issues) +- 想修复问题?[发送 PR](https://github.com/lingodotdev/lingo.dev/pulls) +- 需要帮助?[加入我们的 Discord](https://lingo.dev/go/discord) + +## ⭐ Star 历史 + +如果你喜欢我们的项目,欢迎给我们一个 ⭐,帮助我们达到 6,000 颗星!🌟 + +[ + +![Star History Chart](https://api.star-history.com/svg?repos=lingodotdev/lingo.dev&type=Date) + +](https://www.star-history.com/#lingodotdev/lingo.dev&Date) + +## 🌐 其他语言版本的 Readme + +[English](https://github.com/lingodotdev/lingo.dev) • [中文](/readme/zh-Hans.md) • [日本語](/readme/ja.md) • [한국어](/readme/ko.md) • [西班牙语](/readme/es.md) • [法语](/readme/fr.md) • [俄语](/readme/ru.md) • [乌克兰语](/readme/uk-UA.md) • [德语](/readme/de.md) • [意大利语](/readme/it.md) • [阿拉伯语](/readme/ar.md) • [希伯来语](/readme/he.md) • [印地语](/readme/hi.md) • [葡萄牙语(巴西)](/readme/pt-BR.md) • [孟加拉语](/readme/bn.md) • [波斯语](/readme/fa.md) • [波兰语](/readme/pl.md) • [土耳其语](/readme/tr.md) • [乌尔都语](/readme/ur.md) • [博杰普尔语](/readme/bho.md) • [阿萨姆语](/readme/as-IN.md) • [古吉拉特语](/readme/gu-IN.md) • [马拉雅拉姆语(印度)](/readme/ml-IN.md) • [马拉地语](/readme/mr-IN.md) • [奥里亚语](/readme/or-IN.md) • [旁遮普语](/readme/pa-IN.md) • [僧伽罗语](/readme/si-LK.md) • [泰米尔语](/readme/ta-IN.md) • [泰卢固语](/readme/te-IN.md) + +没有找到你的语言?请将其添加到 [`i18n.json`](./i18n.json) 并提交 PR! + +**区域格式:** 使用 [BCP-47](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) 代码:`language[-Script][-REGION]` + +- 语言:ISO 639-1/2/3 小写(`en`、`zh`、`bho`) +- 字母书写系统:ISO 15924 首字母大写(`Hans`、`Hant`、`Latn`) +- 地区:ISO 3166-1 alpha-2 大写(`US`、`CN`、`IN`) +- 示例:`en`、`pt-BR`、`zh-Hans`、`sr-Cyrl-RS` diff --git a/scripts/docs/CHANGELOG.md b/scripts/docs/CHANGELOG.md new file mode 100644 index 000000000..207d4904d --- /dev/null +++ b/scripts/docs/CHANGELOG.md @@ -0,0 +1,7 @@ +# docs + +## 1.0.1 + +### Patch Changes + +- [#1749](https://github.com/lingodotdev/lingo.dev/pull/1749) [`5bc0c89`](https://github.com/lingodotdev/lingo.dev/commit/5bc0c8952d1bc01be7a2e7b49506f6a5f8f05a59) Thanks [@sumitsaurabh927](https://github.com/sumitsaurabh927)! - create a new space for community contributions like demo apps etc diff --git a/scripts/docs/README.md b/scripts/docs/README.md new file mode 100644 index 000000000..93f0f856f --- /dev/null +++ b/scripts/docs/README.md @@ -0,0 +1,42 @@ +# scripts/docs + +## Introduction + +This directory contains scripts for generating documentation from the Lingo.dev source code. + +## generate-cli-docs + +This script generates reference documentation for **Lingo.dev CLI**. + +### Usage + +```bash +pnpm --filter docs run generate-cli-docs [output_directory] +``` + +### How it works + +1. Loads the CLI program from the `cli` package. +2. Walks through top-level commands and their subcommands. +3. Generates an `.mdx` file for each top-level command with structured reference content. + +### Notes + +- When running inside a GitHub Action, this script comments on the PR with the Markdown content. +- When running outside of a GitHub action, the script writes one `.mdx` file per top-level command to the provided directory. + +## generate-config-docs + +This script generates reference documentation for the `i18n.json` file. + +### Usage + +```bash +pnpm --filter docs run generate-config-docs [output_file_path] +``` + +### How it works + +1. Converts the Zod schema into a JSON Schema. +2. Walks through all properties on the schema. +3. Generates a Markdown file with the complete property reference. diff --git a/scripts/docs/package.json b/scripts/docs/package.json new file mode 100644 index 000000000..f9ec44e55 --- /dev/null +++ b/scripts/docs/package.json @@ -0,0 +1,31 @@ +{ + "name": "docs", + "version": "1.0.1", + "private": true, + "type": "module", + "scripts": { + "generate-cli-docs": "tsx ./src/generate-cli-docs.ts", + "generate-config-docs": "tsx ./src/generate-config-docs.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "@lingo.dev/_spec": "workspace:*", + "@octokit/rest": "20.1.2", + "@types/mdast": "4.0.4", + "@types/node": "24.0.10", + "@vitest/ui": "3.2.4", + "commander": "12.0.0", + "remark-stringify": "11.0.0", + "tsx": "4.20.6", + "typescript": "5.9.3", + "unified": "11.0.5", + "vitest": "3.2.4" + }, + "dependencies": { + "zod": "3.25.76", + "zod-to-json-schema": "3.25.0" + } +} diff --git a/scripts/docs/src/generate-cli-docs.ts b/scripts/docs/src/generate-cli-docs.ts new file mode 100644 index 000000000..9d3cce512 --- /dev/null +++ b/scripts/docs/src/generate-cli-docs.ts @@ -0,0 +1,652 @@ +#!/usr/bin/env node + +import type { Argument, Command, Option } from "commander"; +import { existsSync } from "fs"; +import { mkdir, writeFile } from "fs/promises"; +import type { + Content, + Heading, + List, + ListItem, + Paragraph, + PhrasingContent, + Root, +} from "mdast"; +import { dirname, join, resolve } from "path"; +import remarkStringify from "remark-stringify"; +import { unified } from "unified"; +import { pathToFileURL } from "url"; +import { createOrUpdateGitHubComment, getRepoRoot } from "./utils"; +import { format as prettierFormat, resolveConfig } from "prettier"; + +type CommandWithInternals = Command & { + _hidden?: boolean; + _helpCommand?: Command; +}; + +const FRONTMATTER_DELIMITER = "---"; + +async function getProgram(repoRoot: string): Promise { + const filePath = resolve( + repoRoot, + "packages", + "cli", + "src", + "cli", + "index.ts", + ); + + if (!existsSync(filePath)) { + throw new Error(`CLI source file not found at ${filePath}`); + } + + const cliModule = (await import(pathToFileURL(filePath).href)) as { + default: Command; + }; + + return cliModule.default; +} + +function slugifyCommandName(name: string): string { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return slug.length > 0 ? slug : "command"; +} + +function formatYamlValue(value: string): string { + const escaped = value.replace(/"/g, '\\"'); + return `"${escaped}"`; +} + +function createHeading( + depth: number, + content: string | PhrasingContent[], +): Heading { + const children = Array.isArray(content) + ? content + : [{ type: "text", value: content }]; + + return { + type: "heading", + depth: Math.min(Math.max(depth, 1), 6), + children, + }; +} + +function createInlineCode(value: string): PhrasingContent { + return { type: "inlineCode", value }; +} + +function createParagraph(text: string): Paragraph { + return { + type: "paragraph", + children: createTextNodes(text), + }; +} + +function createTextNodes(text: string): PhrasingContent[] { + if (!text) { + return []; + } + + const nodes: PhrasingContent[] = []; + const parts = text.split(/(`[^`]*`)/g); + + parts.forEach((part) => { + if (!part) { + return; + } + + if (part.startsWith("`") && part.endsWith("`")) { + nodes.push(createInlineCode(part.slice(1, -1))); + } else { + nodes.push(...createBracketAwareTextNodes(part)); + } + }); + + return nodes; +} + +function createBracketAwareTextNodes(text: string): PhrasingContent[] { + const nodes: PhrasingContent[] = []; + const bracketPattern = /\[[^\]]+\]/g; + let lastIndex = 0; + + for (const match of text.matchAll(bracketPattern)) { + const [value] = match; + const start = match.index ?? 0; + + if (start > lastIndex) { + nodes.push({ type: "text", value: text.slice(lastIndex, start) }); + } + + nodes.push(createInlineCode(value)); + lastIndex = start + value.length; + } + + if (lastIndex < text.length) { + nodes.push({ type: "text", value: text.slice(lastIndex) }); + } + + if (nodes.length === 0) { + nodes.push({ type: "text", value: text }); + } + + return nodes; +} + +function createList(items: ListItem[]): List { + return { + type: "list", + ordered: false, + spread: false, + children: items, + }; +} + +function createListItem(children: PhrasingContent[]): ListItem { + return { + type: "listItem", + spread: false, + children: [ + { + type: "paragraph", + children, + }, + ], + }; +} + +function formatArgumentLabel(arg: Argument): string { + const name = arg.name(); + const suffix = arg.variadic ? "..." : ""; + return arg.required ? `<${name}${suffix}>` : `[${name}${suffix}]`; +} + +function formatValue(value: unknown): string { + if (value === undefined) { + return ""; + } + + if (value === null) { + return "null"; + } + + if (typeof value === "string") { + return value; + } + + if (typeof value === "number" || typeof value === "bigint") { + return value.toString(); + } + + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return "[]"; + } + return value.map((item) => formatValue(item)).join(", "); + } + + return JSON.stringify(value); +} + +function getCommandPath( + rootName: string, + ancestors: string[], + command: Command, +): string { + return [rootName, ...ancestors, command.name()].filter(Boolean).join(" "); +} + +function isHiddenCommand(command: Command): boolean { + return Boolean((command as CommandWithInternals)._hidden); +} + +function isHelpCommand(parent: Command, command: Command): boolean { + const helpCmd = (parent as CommandWithInternals)._helpCommand; + return helpCmd === command; +} + +function partitionOptions(options: Option[]): { + flags: Option[]; + valueOptions: Option[]; +} { + const flags: Option[] = []; + const valueOptions: Option[] = []; + + options.forEach((option) => { + if (option.hidden) { + return; + } + + if (option.required || option.optional) { + valueOptions.push(option); + } else { + flags.push(option); + } + }); + + return { flags, valueOptions }; +} + +function buildUsage(command: Command): string { + return command.createHelp().commandUsage(command).trim(); +} + +function formatOptionSignature(option: Option): string { + return option.flags.replace(/\s+/g, " ").trim(); +} + +function extractOptionPlaceholder(option: Option): string { + const match = option.flags.match(/(<[^>]+>|\[[^\]]+\])/); + return match ? match[0] : ""; +} + +function buildOptionUsage(commandPath: string, option: Option): string { + const preferred = + option.long || option.short || formatOptionSignature(option); + const placeholder = extractOptionPlaceholder(option); + const usage = [commandPath, preferred, placeholder] + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + + return usage; +} + +function buildOptionDetails(option: Option): string[] { + const details: string[] = []; + + if (option.mandatory) { + details.push("Must be specified."); + } + + if (option.required) { + details.push("Requires a value."); + } else if (option.optional) { + details.push("Accepts an optional value."); + } + + if (option.defaultValueDescription) { + details.push(`Default: ${option.defaultValueDescription}.`); + } else if (option.defaultValue !== undefined) { + details.push(`Default: ${formatValue(option.defaultValue)}.`); + } + + if (option.argChoices && option.argChoices.length > 0) { + details.push(`Allowed values: ${option.argChoices.join(", ")}.`); + } + + if (option.envVar) { + details.push(`Environment variable: ${option.envVar}.`); + } + + if (option.presetArg !== undefined) { + details.push(`Preset value: ${formatValue(option.presetArg)}.`); + } + + return details; +} + +type BuildOptionEntriesArgs = { + options: Option[]; + commandPath: string; + depth: number; +}; + +function buildOptionEntries({ + options, + commandPath, + depth, +}: BuildOptionEntriesArgs): Content[] { + const nodes: Content[] = []; + const headingDepth = Math.min(depth + 1, 6); + + options.forEach((option) => { + const signature = formatOptionSignature(option); + nodes.push(createHeading(headingDepth, [createInlineCode(signature)])); + + nodes.push({ + type: "code", + lang: "bash", + value: buildOptionUsage(commandPath, option), + }); + + if (option.description) { + nodes.push(createParagraph(option.description)); + } + + const details = buildOptionDetails(option); + if (details.length > 0) { + nodes.push(createParagraph(details.join(" "))); + } + }); + + return nodes; +} + +function buildArgumentListItems(args: readonly Argument[]): ListItem[] { + return args.map((arg) => { + const children: PhrasingContent[] = [ + createInlineCode(formatArgumentLabel(arg)), + ]; + + if (arg.description) { + children.push({ type: "text", value: ` — ${arg.description}` }); + } + + const details: string[] = []; + + if (arg.defaultValueDescription) { + details.push(`default: ${arg.defaultValueDescription}`); + } else if (arg.defaultValue !== undefined) { + details.push(`default: ${formatValue(arg.defaultValue)}`); + } + + if (arg.argChoices && arg.argChoices.length > 0) { + details.push(`choices: ${arg.argChoices.join(", ")}`); + } + + if (!arg.required) { + details.push("optional"); + } + + if (details.length > 0) { + children.push({ + type: "text", + value: ` (${details.join("; ")})`, + }); + } + + return createListItem(children); + }); +} + +type BuildCommandSectionOptions = { + command: Command; + rootName: string; + ancestors: string[]; + depth: number; + useRootIntro: boolean; +}; + +function buildCommandSection({ + command, + rootName, + ancestors, + depth, + useRootIntro, +}: BuildCommandSectionOptions): Content[] { + const nodes: Content[] = []; + const commandPath = getCommandPath(rootName, ancestors, command); + const isRootCommand = ancestors.length === 0; + const shouldUseIntro = isRootCommand && useRootIntro; + const headingContent = shouldUseIntro + ? "Introduction" + : [createInlineCode(commandPath)]; + + nodes.push(createHeading(depth, headingContent)); + + const description = command.description(); + if (description) { + nodes.push(createParagraph(description)); + } + + const usage = buildUsage(command); + if (usage) { + const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6); + nodes.push(createHeading(sectionDepth, "Usage")); + nodes.push({ + type: "paragraph", + children: [createInlineCode(usage)], + }); + } + + const aliases = command.aliases(); + if (aliases.length > 0) { + const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6); + nodes.push(createHeading(sectionDepth, "Aliases")); + nodes.push( + createList( + aliases.map((alias) => createListItem([createInlineCode(alias)])), + ), + ); + } + + const args = command.registeredArguments ?? []; + if (args.length > 0) { + const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6); + nodes.push(createHeading(sectionDepth, "Arguments")); + nodes.push(createList(buildArgumentListItems(args))); + } + + const visibleOptions = command.options.filter((option) => !option.hidden); + if (visibleOptions.length > 0) { + const { flags, valueOptions } = partitionOptions(visibleOptions); + const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6); + + if (valueOptions.length > 0) { + nodes.push(createHeading(sectionDepth, "Options")); + nodes.push( + ...buildOptionEntries({ + options: valueOptions, + commandPath, + depth: sectionDepth, + }), + ); + } + + if (flags.length > 0) { + nodes.push(createHeading(sectionDepth, "Flags")); + nodes.push( + ...buildOptionEntries({ + options: flags, + commandPath, + depth: sectionDepth, + }), + ); + } + } + + const subcommands = command.commands.filter( + (sub) => + !isHiddenCommand(sub) && + !isHelpCommand(command, sub) && + sub.parent === command, + ); + + if (subcommands.length > 0) { + const sectionDepth = shouldUseIntro ? depth : Math.min(depth + 1, 6); + nodes.push(createHeading(sectionDepth, "Subcommands")); + + subcommands.forEach((sub) => { + nodes.push( + ...buildCommandSection({ + command: sub, + rootName, + ancestors: [...ancestors, command.name()], + depth: Math.min(sectionDepth + 1, 6), + useRootIntro, + }), + ); + }); + } + + return nodes; +} + +function toMarkdown(root: Root): string { + return unified().use(remarkStringify).stringify(root).trimEnd(); +} + +async function formatWithPrettier( + content: string, + filePath: string, +): Promise { + const config = await resolveConfig(filePath); + return prettierFormat(content, { + ...(config ?? {}), + filepath: filePath, + }); +} + +type CommandDoc = { + fileName: string; + markdown: string; + mdx: string; + commandPath: string; +}; + +type BuildCommandDocOptions = { + useRootIntro?: boolean; +}; + +function buildCommandDoc( + command: Command, + rootName: string, + options?: BuildCommandDocOptions, +): CommandDoc { + const useRootIntro = options?.useRootIntro ?? true; + const commandPath = getCommandPath(rootName, [], command); + const title = commandPath; + const subtitle = `CLI reference docs for ${command.name()} command`; + const root: Root = { + type: "root", + children: buildCommandSection({ + command, + rootName, + ancestors: [], + depth: 2, + useRootIntro, + }), + }; + + const markdown = toMarkdown(root); + const frontmatter = [ + FRONTMATTER_DELIMITER, + `title: ${formatYamlValue(title)}`, + `subtitle: ${formatYamlValue(subtitle)}`, + FRONTMATTER_DELIMITER, + "", + ].join("\n"); + + const mdx = `${frontmatter}${markdown}\n`; + const fileName = `${slugifyCommandName(command.name())}.mdx`; + + return { fileName, markdown, mdx, commandPath }; +} + +function buildIndexDoc(commands: Command[], rootName: string): CommandDoc { + const root: Root = { + type: "root", + children: [ + createHeading(2, "Introduction"), + createParagraph( + `This page aggregates CLI reference docs for ${rootName} commands.`, + ), + ], + }; + + commands.forEach((command) => { + root.children.push( + ...buildCommandSection({ + command, + rootName, + ancestors: [], + depth: 2, + useRootIntro: false, + }), + ); + }); + + const markdown = toMarkdown(root); + const frontmatter = [ + FRONTMATTER_DELIMITER, + `title: ${formatYamlValue(`${rootName} CLI reference`)}`, + "seo:", + " noindex: true", + FRONTMATTER_DELIMITER, + "", + ].join("\n"); + + const mdx = `${frontmatter}${markdown}\n`; + + return { + fileName: "index.mdx", + markdown, + mdx, + commandPath: `${rootName} (index)`, + }; +} + +async function main(): Promise { + const repoRoot = getRepoRoot(); + const cli = await getProgram(repoRoot); + + const outputArg = process.argv[2]; + + if (!outputArg) { + throw new Error( + "Output directory is required. Usage: generate-cli-docs ", + ); + } + + const outputDir = resolve(process.cwd(), outputArg); + await mkdir(outputDir, { recursive: true }); + + const topLevelCommands = cli.commands.filter( + (command) => command.parent === cli && !isHiddenCommand(command), + ); + + if (topLevelCommands.length === 0) { + console.warn("No top-level commands found. Nothing to document."); + return; + } + + const docs = topLevelCommands.map((command) => + buildCommandDoc(command, cli.name()), + ); + const indexDoc = buildIndexDoc(topLevelCommands, cli.name()); + + for (const doc of [...docs, indexDoc]) { + const filePath = join(outputDir, doc.fileName); + await mkdir(dirname(filePath), { recursive: true }); + const formatted = await formatWithPrettier(doc.mdx, filePath); + await writeFile(filePath, formatted, "utf8"); + console.log(`✅ Saved ${doc.commandPath} docs to ${filePath}`); + } + + if (process.env.GITHUB_ACTIONS) { + const commentMarker = ""; + const combinedMarkdown = docs + .map((doc) => doc.markdown) + .join("\n\n---\n\n"); + + const commentBody = [ + commentMarker, + "", + "Your PR updates Lingo.dev CLI behavior. Please review the regenerated reference docs below.", + "", + combinedMarkdown, + ].join("\n"); + + await createOrUpdateGitHubComment({ + commentMarker, + body: commentBody, + }); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/docs/src/generate-config-docs.ts b/scripts/docs/src/generate-config-docs.ts new file mode 100644 index 000000000..463d9d8f4 --- /dev/null +++ b/scripts/docs/src/generate-config-docs.ts @@ -0,0 +1,125 @@ +#!/usr/bin/env node + +import { LATEST_CONFIG_DEFINITION } from "@lingo.dev/_spec/src/config"; +import type { Root } from "mdast"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import remarkStringify from "remark-stringify"; +import { unified } from "unified"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { renderMarkdown } from "./json-schema/markdown-renderer"; +import { parseSchema } from "./json-schema/parser"; +import type { JSONSchemaObject } from "./json-schema/types"; +import { createOrUpdateGitHubComment, formatMarkdown } from "./utils"; + +const ROOT_PROPERTY_ORDER = ["$schema", "version", "locale", "buckets"]; + +function generateMarkdown(schema: unknown): string { + if (!schema || typeof schema !== "object") { + throw new Error("Invalid schema provided"); + } + + // Ensure the `version` property reflects the latest schema version in docs + const schemaObj = schema as JSONSchemaObject; + const rootRef = schemaObj.$ref as string | undefined; + const rootName: string = rootRef + ? (rootRef.split("/").pop() ?? "I18nConfig") + : "I18nConfig"; + + let rootSchema: unknown; + if ( + rootRef && + schemaObj.definitions && + typeof schemaObj.definitions === "object" + ) { + const definitions = schemaObj.definitions as Record; + rootSchema = definitions[rootName]; + } else { + rootSchema = schema; + } + + if (rootSchema && typeof rootSchema === "object") { + const rootSchemaObj = rootSchema as JSONSchemaObject; + if ( + rootSchemaObj.properties && + typeof rootSchemaObj.properties === "object" + ) { + const properties = rootSchemaObj.properties as Record; + if (properties.version && typeof properties.version === "object") { + (properties.version as Record).default = + LATEST_CONFIG_DEFINITION.defaultValue.version; + } + } + } + + const properties = parseSchema(schema, { customOrder: ROOT_PROPERTY_ORDER }); + return renderMarkdown(properties); +} + +async function main() { + const commentMarker = ""; + const isGitHubAction = Boolean(process.env.GITHUB_ACTIONS); + + const outputArg = process.argv[2]; + + const schema = zodToJsonSchema(LATEST_CONFIG_DEFINITION.schema, { + name: "I18nConfig", + markdownDescription: true, + }); + + console.log("🔄 Generating i18n.json reference docs..."); + const markdown = generateMarkdown(schema); + const formattedMarkdown = await formatMarkdown(markdown); + + if (isGitHubAction) { + const mdast: Root = { + type: "root", + children: [ + { type: "html", value: commentMarker }, + { + type: "paragraph", + children: [ + { + type: "text", + value: + "Your PR affects the Lingo.dev i18n.json configuration schema and may affect the auto-generated reference documentation. Please review the output below to ensure that the changes are correct.", + }, + ], + }, + { type: "html", value: "
    " }, + { + type: "html", + value: "i18n.json reference docs", + }, + { type: "code", lang: "markdown", value: formattedMarkdown }, + { type: "html", value: "
    " }, + ], + }; + const body = unified() + .use([[remarkStringify, { fence: "~" }]]) + .stringify(mdast) + .toString(); + await createOrUpdateGitHubComment({ + commentMarker, + body, + }); + return; + } + + if (!outputArg) { + throw new Error( + "Output file path is required. Usage: generate-config-docs ", + ); + } + + const outputFilePath = resolve(process.cwd(), outputArg); + console.log(`💾 Saving to ${outputFilePath}...`); + mkdirSync(dirname(outputFilePath), { recursive: true }); + writeFileSync(outputFilePath, formattedMarkdown); + console.log(`✅ Saved to ${outputFilePath}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/docs/src/json-schema/markdown-renderer.test.ts b/scripts/docs/src/json-schema/markdown-renderer.test.ts new file mode 100644 index 000000000..eed90fe52 --- /dev/null +++ b/scripts/docs/src/json-schema/markdown-renderer.test.ts @@ -0,0 +1,439 @@ +import { describe, expect, it } from "vitest"; +import type { RootContent } from "mdast"; +import { + makeHeadingNode, + makeDescriptionNode, + makeTypeBulletNode, + makeRequiredBulletNode, + makeDefaultBulletNode, + makeEnumBulletNode, + makeAllowedKeysBulletNode, + makeBullets, + renderPropertyToMarkdown, + renderPropertiesToMarkdown, + renderMarkdown, +} from "./markdown-renderer"; +import type { PropertyInfo } from "./types"; + +describe("makeHeadingNode", () => { + it("should create heading with correct depth for top-level property", () => { + const node = makeHeadingNode("version"); + expect(node).toEqual({ + type: "heading", + depth: 2, + children: [{ type: "inlineCode", value: "version" }], + }); + }); + + it("should create deeper heading for nested property", () => { + const node = makeHeadingNode("config.debug.level"); + expect(node).toEqual({ + type: "heading", + depth: 4, + children: [{ type: "inlineCode", value: "config.debug.level" }], + }); + }); + + it("should cap heading depth at 6", () => { + const node = makeHeadingNode("a.b.c.d.e.f.g.h"); + expect(node).toEqual({ + type: "heading", + depth: 6, + children: [{ type: "inlineCode", value: "a.b.c.d.e.f.g.h" }], + }); + }); +}); + +describe("makeDescriptionNode", () => { + it("should create paragraph node for description", () => { + const node = makeDescriptionNode("This is a description"); + expect(node).toEqual({ + type: "paragraph", + children: [{ type: "text", value: "This is a description" }], + }); + }); + + it("should return null for empty description", () => { + expect(makeDescriptionNode("")).toBeNull(); + expect(makeDescriptionNode(undefined)).toBeNull(); + }); +}); + +describe("makeTypeBulletNode", () => { + it("should create list item with type information", () => { + const node = makeTypeBulletNode("string"); + expect(node).toEqual({ + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: "Type: " }, + { type: "inlineCode", value: "string" }, + ], + }, + ], + }); + }); +}); + +describe("makeRequiredBulletNode", () => { + it("should create list item for required property", () => { + const node = makeRequiredBulletNode(true); + expect(node).toEqual({ + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: "Required: " }, + { type: "inlineCode", value: "yes" }, + ], + }, + ], + }); + }); + + it("should create list item for optional property", () => { + const node = makeRequiredBulletNode(false); + expect(node).toEqual({ + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: "Required: " }, + { type: "inlineCode", value: "no" }, + ], + }, + ], + }); + }); +}); + +describe("makeDefaultBulletNode", () => { + it("should create list item for default value", () => { + const node = makeDefaultBulletNode("default value"); + expect(node).toEqual({ + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: "Default: " }, + { type: "inlineCode", value: '"default value"' }, + ], + }, + ], + }); + }); + + it("should handle numeric default", () => { + const node = makeDefaultBulletNode(42); + expect(node).toBeDefined(); + if ( + node && + "children" in node && + node.children[0] && + "children" in node.children[0] + ) { + expect(node.children[0].children[1]).toEqual({ + type: "inlineCode", + value: "42", + }); + } + }); + + it("should return null for undefined default", () => { + expect(makeDefaultBulletNode(undefined)).toBeNull(); + }); +}); + +describe("makeEnumBulletNode", () => { + it("should create list item with enum values", () => { + const node = makeEnumBulletNode(["red", "green", "blue"]); + expect(node).toEqual({ + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "Allowed values:" }], + }, + { + type: "list", + ordered: false, + spread: false, + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "inlineCode", value: "red" }], + }, + ], + }, + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "inlineCode", value: "green" }], + }, + ], + }, + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "inlineCode", value: "blue" }], + }, + ], + }, + ], + }, + ], + }); + }); + + it("should return null for empty array", () => { + expect(makeEnumBulletNode([])).toBeNull(); + expect(makeEnumBulletNode(undefined)).toBeNull(); + }); +}); + +describe("makeAllowedKeysBulletNode", () => { + it("should create list item with allowed keys", () => { + const node = makeAllowedKeysBulletNode(["key1", "key2"]); + expect(node).toEqual({ + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "Allowed keys:" }], + }, + { + type: "list", + ordered: false, + spread: false, + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "inlineCode", value: "key1" }], + }, + ], + }, + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "inlineCode", value: "key2" }], + }, + ], + }, + ], + }, + ], + }); + }); + + it("should return null for empty/undefined array", () => { + expect(makeAllowedKeysBulletNode([])).toBeNull(); + expect(makeAllowedKeysBulletNode(undefined)).toBeNull(); + }); +}); + +describe("makeBullets", () => { + it("should create all relevant bullets for a property", () => { + const property: PropertyInfo = { + name: "test", + fullPath: "test", + type: "string", + required: true, + defaultValue: "default", + allowedValues: ["a", "b"], + allowedKeys: ["key1"], + }; + + const bullets = makeBullets(property); + expect(bullets).toHaveLength(5); // type, required, default, enum, allowedKeys + }); + + it("should only create necessary bullets", () => { + const property: PropertyInfo = { + name: "test", + fullPath: "test", + type: "string", + required: false, + }; + + const bullets = makeBullets(property); + expect(bullets).toHaveLength(2); // only type and required + }); +}); + +describe("renderPropertyToMarkdown", () => { + it("should render simple property", () => { + const property: PropertyInfo = { + name: "version", + fullPath: "version", + type: "string", + required: true, + description: "The version number", + }; + + const nodes = renderPropertyToMarkdown(property); + expect(nodes).toHaveLength(3); // heading, description, bullets list + expect(nodes[0].type).toBe("heading"); + expect(nodes[1].type).toBe("paragraph"); + expect(nodes[2].type).toBe("list"); + }); + + it("should render property without description", () => { + const property: PropertyInfo = { + name: "test", + fullPath: "test", + type: "string", + required: false, + }; + + const nodes = renderPropertyToMarkdown(property); + expect(nodes).toHaveLength(2); // heading, bullets list (no description) + }); + + it("should render property with children", () => { + const property: PropertyInfo = { + name: "config", + fullPath: "config", + type: "object", + required: true, + children: [ + { + name: "debug", + fullPath: "config.debug", + type: "boolean", + required: false, + }, + ], + }; + + const nodes = renderPropertyToMarkdown(property); + expect(nodes.length).toBeGreaterThan(2); // includes child nodes + + // Find child heading node + const childHeading = nodes.find( + (node: RootContent) => + node.type === "heading" && + node.type === "heading" && + "children" in node && + node.children[0] && + "value" in node.children[0] && + node.children[0].value === "config.debug", + ); + expect(childHeading).toBeDefined(); + }); +}); + +describe("renderPropertiesToMarkdown", () => { + it("should render complete document with header", () => { + const properties: PropertyInfo[] = [ + { + name: "version", + fullPath: "version", + type: "string", + required: true, + }, + ]; + + const nodes = renderPropertiesToMarkdown(properties); + expect(nodes[0]).toEqual({ + type: "paragraph", + children: [ + { + type: "text", + value: + "This page describes the complete list of properties that are available within the ", + }, + { type: "inlineCode", value: "i18n.json" }, + { + type: "text", + value: " configuration file. This file is used by ", + }, + { + type: "strong", + children: [{ type: "text", value: "Lingo.dev CLI" }], + }, + { + type: "text", + value: " to configure the behavior of the translation pipeline.", + }, + ], + }); + expect(nodes[1].type).toBe("heading"); // version heading + }); + + it("should add spacing between top-level properties", () => { + const properties: PropertyInfo[] = [ + { + name: "prop1", + fullPath: "prop1", + type: "string", + required: true, + }, + { + name: "prop2", + fullPath: "prop2", + type: "string", + required: false, + }, + ]; + + const nodes = renderPropertiesToMarkdown(properties); + // Should have spacing paragraphs between properties + const spacingNodes = nodes.filter( + (node: RootContent) => + node.type === "paragraph" && + "children" in node && + node.children[0] && + "value" in node.children[0] && + node.children[0].value === "", + ); + expect(spacingNodes).toHaveLength(2); // One after each property + }); +}); + +describe("renderMarkdown", () => { + it("should generate valid markdown string", () => { + const properties: PropertyInfo[] = [ + { + name: "version", + fullPath: "version", + type: "string", + required: true, + description: "The version", + }, + ]; + + const markdown = renderMarkdown(properties); + expect(typeof markdown).toBe("string"); + expect(markdown).toContain("---\ntitle: i18n.json properties\n---"); + expect(markdown).toContain( + "This page describes the complete list of properties", + ); + expect(markdown).toContain("## `version`"); + expect(markdown).toContain("The version"); + expect(markdown).toContain("* Type: `string`"); + expect(markdown).toContain("* Required: `yes`"); + }); + + it("should handle empty properties array", () => { + const markdown = renderMarkdown([]); + expect(markdown).toContain("---\ntitle: i18n.json properties\n---"); + expect(markdown).toContain("This page describes the complete list"); + }); +}); diff --git a/scripts/docs/src/json-schema/markdown-renderer.ts b/scripts/docs/src/json-schema/markdown-renderer.ts new file mode 100644 index 000000000..106764a5f --- /dev/null +++ b/scripts/docs/src/json-schema/markdown-renderer.ts @@ -0,0 +1,228 @@ +import type { ListItem, Root, RootContent } from "mdast"; +import { unified } from "unified"; +import remarkStringify from "remark-stringify"; +import type { PropertyInfo } from "./types"; + +export function makeHeadingNode(fullName: string): RootContent { + const headingDepth = Math.min(6, 2 + (fullName.split(".").length - 1)); + return { + type: "heading", + depth: headingDepth as 1 | 2 | 3 | 4 | 5 | 6, + children: [{ type: "inlineCode", value: fullName }], + }; +} + +export function makeDescriptionNode(description?: string): RootContent | null { + if (!description) return null; + return { + type: "paragraph", + children: [{ type: "text", value: description }], + }; +} + +export function makeTypeBulletNode(type: string): ListItem { + return { + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: "Type: " }, + { type: "inlineCode", value: type }, + ], + }, + ], + }; +} + +export function makeRequiredBulletNode(required: boolean): ListItem { + return { + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: "Required: " }, + { type: "inlineCode", value: required ? "yes" : "no" }, + ], + }, + ], + }; +} + +export function makeDefaultBulletNode(defaultValue?: unknown): ListItem | null { + if (defaultValue === undefined) return null; + return { + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: "Default: " }, + { type: "inlineCode", value: JSON.stringify(defaultValue) }, + ], + }, + ], + }; +} + +export function makeEnumBulletNode(allowedValues?: unknown[]): ListItem | null { + if (!allowedValues || allowedValues.length === 0) return null; + return { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "Allowed values:" }], + }, + { + type: "list", + ordered: false, + spread: false, + children: allowedValues.map((v) => ({ + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "inlineCode", value: String(v) }], + }, + ], + })), + }, + ], + }; +} + +export function makeAllowedKeysBulletNode( + allowedKeys?: string[], +): ListItem | null { + if (!allowedKeys || allowedKeys.length === 0) return null; + return { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "Allowed keys:" }], + }, + { + type: "list", + ordered: false, + spread: false, + children: allowedKeys.map((v) => ({ + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "inlineCode", value: v }], + }, + ], + })), + }, + ], + }; +} + +export function makeBullets(property: PropertyInfo): ListItem[] { + const bullets: ListItem[] = [ + makeTypeBulletNode(property.type), + makeRequiredBulletNode(property.required), + ]; + + const defaultNode = makeDefaultBulletNode(property.defaultValue); + if (defaultNode) bullets.push(defaultNode); + + const enumNode = makeEnumBulletNode(property.allowedValues); + if (enumNode) bullets.push(enumNode); + + const allowedKeysNode = makeAllowedKeysBulletNode(property.allowedKeys); + if (allowedKeysNode) bullets.push(allowedKeysNode); + + return bullets; +} + +export function renderPropertyToMarkdown( + property: PropertyInfo, +): RootContent[] { + const nodes: RootContent[] = [makeHeadingNode(property.fullPath)]; + + // Description node + const descNode = makeDescriptionNode(property.description); + if (descNode) nodes.push(descNode); + + // Bullet list node (with all bullets) + const bulletItems = makeBullets(property); + nodes.push({ + type: "list", + ordered: false, + spread: false, + children: bulletItems, + }); + + // Recurse for nested properties + if (property.children) { + for (const child of property.children) { + nodes.push(...renderPropertyToMarkdown(child)); + } + } + + return nodes; +} + +export function renderPropertiesToMarkdown( + properties: PropertyInfo[], +): RootContent[] { + const children: RootContent[] = [ + { + type: "paragraph", + children: [ + { + type: "text", + value: + "This page describes the complete list of properties that are available within the ", + }, + { type: "inlineCode", value: "i18n.json" }, + { + type: "text", + value: " configuration file. This file is used by ", + }, + { + type: "strong", + children: [{ type: "text", value: "Lingo.dev CLI" }], + }, + { + type: "text", + value: " to configure the behavior of the translation pipeline.", + }, + ], + }, + ]; + + for (const property of properties) { + children.push(...renderPropertyToMarkdown(property)); + + // Add spacing between top-level sections + children.push({ + type: "paragraph", + children: [{ type: "text", value: "" }], + }); + } + + return children; +} + +export function renderMarkdown(properties: PropertyInfo[]): string { + const children = renderPropertiesToMarkdown(properties); + const root: Root = { type: "root", children }; + const markdownContent = unified() + .use(remarkStringify, { fences: true, listItemIndent: "one" }) + .stringify(root); + + // Add YAML frontmatter + const frontmatter = `--- +title: i18n.json properties +--- + +`; + + return frontmatter + markdownContent; +} diff --git a/scripts/docs/src/json-schema/parser.test.ts b/scripts/docs/src/json-schema/parser.test.ts new file mode 100644 index 000000000..95a44d95e --- /dev/null +++ b/scripts/docs/src/json-schema/parser.test.ts @@ -0,0 +1,356 @@ +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([]); + }); +}); diff --git a/scripts/docs/src/json-schema/parser.ts b/scripts/docs/src/json-schema/parser.ts new file mode 100644 index 000000000..dc42e79e4 --- /dev/null +++ b/scripts/docs/src/json-schema/parser.ts @@ -0,0 +1,399 @@ +import type { + JSONSchemaObject, + PropertyInfo, + SchemaParsingOptions, +} from "./types"; + +export function resolveRef(ref: string, root: unknown): unknown { + if (!ref.startsWith("#/")) return undefined; + const pathSegments = ref + .slice(2) // remove "#/" + .split("/") + .map((seg) => decodeURIComponent(seg)); + + let current = root; + for (const segment of pathSegments) { + if (current && typeof current === "object" && segment in current) { + current = (current as Record)[segment]; + } else { + return undefined; + } + } + return current; +} + +export function sortPropertyKeys( + keys: string[], + requiredKeys: string[] = [], + customOrder: string[] = [], +): string[] { + const keySet = new Set(keys); + const requiredSet = new Set(requiredKeys); + + // Start with custom ordered keys that exist in the properties + const orderedKeys: string[] = []; + for (const key of customOrder) { + if (keySet.has(key)) { + orderedKeys.push(key); + keySet.delete(key); + } + } + + // Handle remaining keys - separate into required and optional + const remainingKeys = Array.from(keySet); + const remainingRequired: string[] = []; + const remainingOptional: string[] = []; + + for (const key of remainingKeys) { + if (requiredSet.has(key)) { + remainingRequired.push(key); + } else { + remainingOptional.push(key); + } + } + + // Sort alphabetically within each group + remainingRequired.sort((a, b) => a.localeCompare(b)); + remainingOptional.sort((a, b) => a.localeCompare(b)); + + return [...orderedKeys, ...remainingRequired, ...remainingOptional]; +} + +export function inferType(schema: unknown, root: unknown): string { + if (!schema || typeof schema !== "object") return "unknown"; + + const schemaObj = schema as JSONSchemaObject; + + // Handle $ref at the root level + if (schemaObj.$ref) { + return inferTypeFromRef(schemaObj.$ref, root); + } + + // Handle type property + if (schemaObj.type) { + return inferTypeFromType(schemaObj, root); + } + + // Handle union types (anyOf) at the top level + if (Array.isArray(schemaObj.anyOf)) { + return inferTypeFromAnyOf(schemaObj.anyOf, root); + } + + return "unknown"; +} + +function inferTypeFromRef(ref: string, root: unknown): string { + const resolved = resolveRef(ref, root); + if (resolved) { + return inferType(resolved, root); + } + return String(ref).split("/").pop() || "unknown"; +} + +function inferTypeFromType(schemaObj: JSONSchemaObject, root: unknown): string { + // Handle array of types + if (Array.isArray(schemaObj.type)) { + return schemaObj.type.join(" | "); + } + + if (schemaObj.type === "array") { + return inferTypeFromArray(schemaObj, root); + } + + return String(schemaObj.type); +} + +function inferTypeFromArray( + schemaObj: JSONSchemaObject, + root: unknown, +): string { + const items = schemaObj.items; + if (!items || typeof items !== "object") { + return "array"; + } + + const itemsObj = items as JSONSchemaObject; + + // Array with $ref items + if (itemsObj.$ref) { + return `array of ${inferTypeFromRef(itemsObj.$ref, root)}`; + } + + // Array with anyOf union types + if (Array.isArray(itemsObj.anyOf)) { + const types = itemsObj.anyOf.map((item) => inferType(item, root)); + return `array of ${types.join(" | ")}`; + } + + // Array with direct type(s) + if (itemsObj.type) { + if (Array.isArray(itemsObj.type)) { + return `array of ${itemsObj.type.join(" | ")}`; + } + return `array of ${itemsObj.type}`; + } + + // Array of object or unknown + return `array of ${inferType(items, root)}`; +} + +function inferTypeFromAnyOf(anyOfArr: unknown[], root: unknown): string { + const types = anyOfArr.map((item) => inferType(item, root)); + return types.join(" | "); +} + +function extractAllowedValues(schema: JSONSchemaObject): unknown[] | undefined { + if (!Array.isArray(schema.enum)) return undefined; + return Array.from(new Set(schema.enum)).sort((a, b) => + String(a).localeCompare(String(b)), + ); +} + +function extractAllowedKeys(schema: JSONSchemaObject): string[] | undefined { + if ( + !schema.propertyNames || + typeof schema.propertyNames !== "object" || + !Array.isArray(schema.propertyNames.enum) + ) { + return undefined; + } + const allowedKeys = schema.propertyNames.enum as string[]; + if (allowedKeys.length === 0) return undefined; + return Array.from(new Set(allowedKeys)).sort((a, b) => a.localeCompare(b)); +} + +export function parseProperty( + name: string, + schema: unknown, + required: boolean, + options: SchemaParsingOptions = {}, +): PropertyInfo[] { + if (!schema || typeof schema !== "object") return []; + + const { parentPath = "", rootSchema = schema } = options; + const schemaObj = schema as JSONSchemaObject; + const fullPath = parentPath ? `${parentPath}.${name}` : name; + + const description = schemaObj.markdownDescription ?? schemaObj.description; + + const property: PropertyInfo = { + name, + fullPath, + type: inferType(schema, rootSchema), + required, + description, + defaultValue: schemaObj.default, + allowedValues: extractAllowedValues(schemaObj), + allowedKeys: extractAllowedKeys(schemaObj), + }; + + const result: PropertyInfo[] = [property]; + + // Add children for nested properties + const children = parseNestedProperties(schema, fullPath, rootSchema); + if (children.length > 0) { + property.children = children; + } + + return result; +} + +function parseNestedProperties( + schema: unknown, + fullPath: string, + rootSchema: unknown, +): PropertyInfo[] { + if (!schema || typeof schema !== "object") return []; + + const schemaObj = schema as JSONSchemaObject; + const children: PropertyInfo[] = []; + + // Recurse into nested properties for objects + if (schemaObj.type === "object") { + if (schemaObj.properties && typeof schemaObj.properties === "object") { + const properties = schemaObj.properties; + const nestedRequired = Array.isArray(schemaObj.required) + ? schemaObj.required + : []; + const sortedKeys = sortPropertyKeys( + Object.keys(properties), + nestedRequired, + ); + for (const key of sortedKeys) { + children.push( + ...parseProperty(key, properties[key], nestedRequired.includes(key), { + parentPath: fullPath, + rootSchema, + }), + ); + } + } + + // Handle schemas that use `additionalProperties` + if ( + schemaObj.additionalProperties && + typeof schemaObj.additionalProperties === "object" + ) { + children.push( + ...parseProperty("*", schemaObj.additionalProperties, false, { + parentPath: fullPath, + rootSchema, + }), + ); + } + } + + // Recurse into items for arrays of objects + if (schemaObj.type === "array" && schemaObj.items) { + const items = schemaObj.items as JSONSchemaObject; + const itemSchema = items.$ref + ? resolveRef(items.$ref, rootSchema) || items + : items; + + // Handle union types in array items (anyOf) + if (Array.isArray(items.anyOf)) { + items.anyOf.forEach((unionItem) => { + let resolvedItem = unionItem; + if (unionItem && typeof unionItem === "object") { + const unionItemObj = unionItem as JSONSchemaObject; + if (unionItemObj.$ref) { + resolvedItem = + resolveRef(unionItemObj.$ref, rootSchema) || unionItem; + } + } + + if ( + resolvedItem && + typeof resolvedItem === "object" && + ((resolvedItem as JSONSchemaObject).type === "object" || + (resolvedItem as JSONSchemaObject).properties) + ) { + const resolvedItemObj = resolvedItem as JSONSchemaObject; + const nestedRequired = Array.isArray(resolvedItemObj.required) + ? resolvedItemObj.required + : []; + const properties = resolvedItemObj.properties || {}; + const sortedKeys = sortPropertyKeys( + Object.keys(properties), + nestedRequired, + ); + for (const key of sortedKeys) { + children.push( + ...parseProperty( + key, + properties[key], + nestedRequired.includes(key), + { + parentPath: `${fullPath}.*`, + rootSchema, + }, + ), + ); + } + } + }); + } else if ( + itemSchema && + typeof itemSchema === "object" && + ((itemSchema as JSONSchemaObject).type === "object" || + (itemSchema as JSONSchemaObject).properties) + ) { + // Handle regular object items (non-union) + const itemSchemaObj = itemSchema as JSONSchemaObject; + const nestedRequired = Array.isArray(itemSchemaObj.required) + ? itemSchemaObj.required + : []; + const properties = itemSchemaObj.properties || {}; + const sortedKeys = sortPropertyKeys( + Object.keys(properties), + nestedRequired, + ); + for (const key of sortedKeys) { + children.push( + ...parseProperty(key, properties[key], nestedRequired.includes(key), { + parentPath: `${fullPath}.*`, + rootSchema, + }), + ); + } + + // Handle additionalProperties inside array items if present + if ( + itemSchemaObj.additionalProperties && + typeof itemSchemaObj.additionalProperties === "object" + ) { + children.push( + ...parseProperty("*", itemSchemaObj.additionalProperties, false, { + parentPath: `${fullPath}.*`, + rootSchema, + }), + ); + } + } + } + + return children; +} + +export function parseSchema( + schema: unknown, + options: SchemaParsingOptions = {}, +): PropertyInfo[] { + if (!schema || typeof schema !== "object") { + return []; + } + + const schemaObj = schema as JSONSchemaObject; + const { customOrder = [] } = options; + const rootRef = schemaObj.$ref as string | undefined; + const rootName: string = rootRef + ? (rootRef.split("/").pop() ?? "I18nConfig") + : "I18nConfig"; + + let rootSchema: unknown; + if ( + rootRef && + schemaObj.definitions && + typeof schemaObj.definitions === "object" + ) { + const definitions = schemaObj.definitions as Record; + rootSchema = definitions[rootName]; + } else { + rootSchema = schema; + } + + if (!rootSchema || typeof rootSchema !== "object") { + console.log(`Could not find root schema: ${rootName}`); + return []; + } + + const rootSchemaObj = rootSchema as JSONSchemaObject; + const required = Array.isArray(rootSchemaObj.required) + ? rootSchemaObj.required + : []; + + if ( + !rootSchemaObj.properties || + typeof rootSchemaObj.properties !== "object" + ) { + return []; + } + + const properties = rootSchemaObj.properties; + const sortedKeys = sortPropertyKeys( + Object.keys(properties), + required, + customOrder, + ); + const result: PropertyInfo[] = []; + + for (const key of sortedKeys) { + result.push( + ...parseProperty(key, properties[key], required.includes(key), { + rootSchema: schema, + }), + ); + } + + return result; +} diff --git a/scripts/docs/src/json-schema/types.ts b/scripts/docs/src/json-schema/types.ts new file mode 100644 index 000000000..3fb099a44 --- /dev/null +++ b/scripts/docs/src/json-schema/types.ts @@ -0,0 +1,35 @@ +export type PropertyInfo = { + name: string; + fullPath: string; + type: string; + required: boolean; + description?: string; + defaultValue?: unknown; + allowedValues?: unknown[]; + allowedKeys?: string[]; + children?: PropertyInfo[]; +}; + +export type JSONSchemaObject = { + type?: string | string[]; + properties?: Record; + required?: string[]; + items?: unknown; + anyOf?: unknown[]; + $ref?: string; + description?: string; + markdownDescription?: string; + default?: unknown; + enum?: unknown[]; + propertyNames?: { + enum?: string[]; + }; + additionalProperties?: unknown; + definitions?: Record; +}; + +export type SchemaParsingOptions = { + customOrder?: string[]; + parentPath?: string; + rootSchema?: unknown; +}; diff --git a/scripts/docs/src/utils.test.ts b/scripts/docs/src/utils.test.ts new file mode 100644 index 000000000..55ce942ba --- /dev/null +++ b/scripts/docs/src/utils.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + getRepoRoot, + getGitHubToken, + getGitHubRepo, + getGitHubOwner, + getGitHubPRNumber, +} from "./utils"; + +describe("utils", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("getRepoRoot", () => { + it("should find the repository root directory", () => { + const result = getRepoRoot(); + expect(result).toBeDefined(); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe("getGitHubToken", () => { + it("should return token when GITHUB_TOKEN is set", () => { + vi.stubEnv("GITHUB_TOKEN", "test-token"); + + const result = getGitHubToken(); + expect(result).toBe("test-token"); + }); + + it("should throw error when GITHUB_TOKEN is not set", () => { + vi.stubEnv("GITHUB_TOKEN", ""); + + expect(() => getGitHubToken()).toThrow( + "GITHUB_TOKEN environment variable is required.", + ); + }); + }); + + describe("getGitHubRepo", () => { + it("should extract repo name from GITHUB_REPOSITORY", () => { + vi.stubEnv("GITHUB_REPOSITORY", "owner/repo-name"); + + const result = getGitHubRepo(); + expect(result).toBe("repo-name"); + }); + + it("should throw error when GITHUB_REPOSITORY is not set", () => { + vi.stubEnv("GITHUB_REPOSITORY", ""); + + expect(() => getGitHubRepo()).toThrow( + "GITHUB_REPOSITORY environment variable is missing.", + ); + }); + }); + + describe("getGitHubOwner", () => { + it("should extract owner from GITHUB_REPOSITORY", () => { + vi.stubEnv("GITHUB_REPOSITORY", "test-owner/repo-name"); + + const result = getGitHubOwner(); + expect(result).toBe("test-owner"); + }); + + it("should throw error when GITHUB_REPOSITORY is not set", () => { + vi.stubEnv("GITHUB_REPOSITORY", ""); + + expect(() => getGitHubOwner()).toThrow( + "GITHUB_REPOSITORY environment variable is missing.", + ); + }); + }); + + describe("getGitHubPRNumber", () => { + it("should return PR_NUMBER when set", () => { + vi.stubEnv("PR_NUMBER", "123"); + + const result = getGitHubPRNumber(); + expect(result).toBe(123); + }); + + it("should throw error when no PR number can be determined", () => { + vi.stubEnv("PR_NUMBER", ""); + vi.stubEnv("GITHUB_EVENT_PATH", ""); + + expect(() => getGitHubPRNumber()).toThrow( + "Could not determine pull request number.", + ); + }); + }); +}); diff --git a/scripts/docs/src/utils.ts b/scripts/docs/src/utils.ts new file mode 100644 index 000000000..cdcb27552 --- /dev/null +++ b/scripts/docs/src/utils.ts @@ -0,0 +1,136 @@ +import { existsSync } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { readFileSync } from "fs"; +import { Octokit } from "@octokit/rest"; +import * as prettier from "prettier"; + +export function getRepoRoot(): string { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + let currentDir = __dirname; + + while (currentDir !== path.parse(currentDir).root) { + if (existsSync(path.join(currentDir, ".git"))) { + return currentDir; + } + currentDir = path.dirname(currentDir); + } + + throw new Error("Could not find project root"); +} + +export function getGitHubToken() { + const token = process.env.GITHUB_TOKEN; + + if (!token) { + throw new Error("GITHUB_TOKEN environment variable is required."); + } + + return token; +} + +export function getGitHubRepo() { + const repository = process.env.GITHUB_REPOSITORY; + + if (!repository) { + throw new Error("GITHUB_REPOSITORY environment variable is missing."); + } + + const [_, repo] = repository.split("/"); + + return repo; +} + +export function getGitHubOwner() { + const repository = process.env.GITHUB_REPOSITORY; + + if (!repository) { + throw new Error("GITHUB_REPOSITORY environment variable is missing."); + } + + const [owner] = repository.split("/"); + + return owner; +} + +export function getGitHubPRNumber() { + const prNumber = process.env.PR_NUMBER; + + if (prNumber) { + return Number(prNumber); + } + + const eventPath = process.env.GITHUB_EVENT_PATH; + + if (eventPath && existsSync(eventPath)) { + try { + const eventData = JSON.parse(readFileSync(eventPath, "utf8")); + return Number(eventData.pull_request?.number); + } catch (err) { + console.warn("Failed to parse GITHUB_EVENT_PATH JSON:", err); + } + } + + throw new Error("Could not determine pull request number."); +} + +export type GitHubCommentOptions = { + commentMarker: string; + body: string; +}; + +export async function createOrUpdateGitHubComment( + options: GitHubCommentOptions, +): Promise { + const token = getGitHubToken(); + const owner = getGitHubOwner(); + const repo = getGitHubRepo(); + const prNumber = getGitHubPRNumber(); + + const octokit = new Octokit({ auth: token }); + + const commentsResponse = await octokit.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + + const comments = commentsResponse.data; + + const existing = comments.find((c) => { + if (!c.body) { + return false; + } + return c.body.startsWith(options.commentMarker); + }); + + if (existing) { + console.log(`Updating existing comment (id: ${existing.id}).`); + await octokit.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body: options.body, + }); + return; + } + + console.log("Creating new comment."); + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: options.body, + }); +} + +export async function formatMarkdown(markdown: string): Promise { + const repoRoot = getRepoRoot(); + const prettierConfig = await prettier.resolveConfig(repoRoot); + return await prettier.format(markdown, { + ...prettierConfig, + parser: "markdown", + }); +} diff --git a/scripts/docs/tsconfig.json b/scripts/docs/tsconfig.json new file mode 100644 index 000000000..74027fbc6 --- /dev/null +++ b/scripts/docs/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "allowUnreachableCode": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "vitest.config.ts"], + "exclude": ["build/**/*"] +} diff --git a/scripts/docs/vitest.config.ts b/scripts/docs/vitest.config.ts new file mode 100644 index 000000000..d3ff17d0e --- /dev/null +++ b/scripts/docs/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + exclude: ["node_modules", "build"], + coverage: { + reporter: ["text", "html", "lcov"], + exclude: ["node_modules/", "build/", "**/*.d.ts", "**/*.config.*"], + }, + }, +}); diff --git a/scripts/packagist-publish.php b/scripts/packagist-publish.php new file mode 100644 index 000000000..2c7a6c359 --- /dev/null +++ b/scripts/packagist-publish.php @@ -0,0 +1,90 @@ + [ + 'url' => $repoUrl + ] +]; + +$ch = curl_init($apiUrl); + +curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); +curl_setopt($ch, CURLOPT_POST, true); +curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); +curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Accept: application/json' +]); + +echo "Sending request to Packagist API ($apiUrl)...\n"; +$response = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + +if (curl_errno($ch)) { + echo "Error: " . curl_error($ch) . "\n"; + curl_close($ch); + exit(1); +} + +curl_close($ch); + +$responseData = json_decode($response, true); + +echo "HTTP Response Code: $httpCode\n"; +echo "Response: " . print_r($responseData, true) . "\n"; + +if ($httpCode >= 200 && $httpCode < 300) { + echo "Package $packageName successfully " . ($packageExists ? "updated" : "submitted") . " to Packagist!\n"; + exit(0); +} else { + echo "Failed to " . ($packageExists ? "update" : "submit") . " package $packageName to Packagist.\n"; + exit(1); +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 000000000..d51528c77 --- /dev/null +++ b/turbo.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": [ + "typecheck", + "^build" + ] + }, + "typecheck": { + "dependsOn": [ + "^build" + ] + }, + "test": { + "dependsOn": [ + "^build" + ] + }, + "test:e2e:prepare": { + "dependsOn": [ + "build" + ] + }, + "test:e2e": { + "dependsOn": [ + "test:e2e:prepare" + ] + }, + "deploy": { + "dependsOn": [ + "build", + "test", + "^deploy" + ] + } + } +}