diff --git a/.github/workflows/monorepo-split.yml b/.github/workflows/monorepo-split.yml index 7a780147..0f38e847 100644 --- a/.github/workflows/monorepo-split.yml +++ b/.github/workflows/monorepo-split.yml @@ -15,6 +15,7 @@ jobs: - media - announcements - core + - laravel-translations steps: - uses: actions/checkout@v3 - id: previous-tag diff --git a/packages/laravel-translations/.editorconfig b/packages/laravel-translations/.editorconfig new file mode 100644 index 00000000..dd9a2b51 --- /dev/null +++ b/packages/laravel-translations/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/packages/laravel-translations/.gitattributes b/packages/laravel-translations/.gitattributes new file mode 100644 index 00000000..c09f81e5 --- /dev/null +++ b/packages/laravel-translations/.gitattributes @@ -0,0 +1,20 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/art export-ignore +/docs export-ignore +/tests export-ignore +/workbench export-ignore +/.editorconfig export-ignore +/.php_cs.dist.php export-ignore +/psalm.xml export-ignore +/psalm.xml.dist export-ignore +/testbench.yaml export-ignore +/UPGRADING.md export-ignore +/phpstan.neon.dist export-ignore +/phpstan-baseline.neon export-ignore diff --git a/packages/laravel-translations/.github/FUNDING.yml b/packages/laravel-translations/.github/FUNDING.yml new file mode 100644 index 00000000..36c22286 --- /dev/null +++ b/packages/laravel-translations/.github/FUNDING.yml @@ -0,0 +1 @@ +github: vormkracht10 diff --git a/packages/laravel-translations/.github/ISSUE_TEMPLATE/bug.yml b/packages/laravel-translations/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..fe4cfe6d --- /dev/null +++ b/packages/laravel-translations/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,66 @@ +name: Bug Report +description: Report an Issue or Bug with the Package +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + We're sorry to hear you have a problem. Can you help us solve it by providing the following details. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect to happen? + placeholder: I cannot currently do X thing because when I do, it breaks X thing. + validations: + required: true + - type: textarea + id: how-to-reproduce + attributes: + label: How to reproduce the bug + description: How did this occur, please add any config values used and provide a set of reliable steps if possible. + placeholder: When I do X I see Y. + validations: + required: true + - type: input + id: package-version + attributes: + label: Package Version + description: What version of our Package are you running? Please be as specific as possible + placeholder: 2.0.0 + validations: + required: true + - type: input + id: php-version + attributes: + label: PHP Version + description: What version of PHP are you running? Please be as specific as possible + placeholder: 8.2.0 + validations: + required: true + - type: input + id: laravel-version + attributes: + label: Laravel Version + description: What version of Laravel are you running? Please be as specific as possible + placeholder: 9.0.0 + validations: + required: true + - type: dropdown + id: operating-systems + attributes: + label: Which operating systems does with happen with? + description: You may select more than one. + multiple: true + options: + - macOS + - Windows + - Linux + - type: textarea + id: notes + attributes: + label: Notes + description: Use this field to provide any other notes that you feel might be relevant to the issue. + validations: + required: false diff --git a/packages/laravel-translations/.github/ISSUE_TEMPLATE/config.yml b/packages/laravel-translations/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..d9b73f27 --- /dev/null +++ b/packages/laravel-translations/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/vormkracht10/laravel-translations/discussions/new?category=q-a + about: Ask the community for help + - name: Request a feature + url: https://github.com/vormkracht10/laravel-translations/discussions/new?category=ideas + about: Share ideas for new features + - name: Report a security issue + url: https://github.com/vormkracht10/laravel-translations/security/policy + about: Learn how to notify us for sensitive bugs diff --git a/packages/laravel-translations/.github/dependabot.yml b/packages/laravel-translations/.github/dependabot.yml new file mode 100644 index 00000000..39b15807 --- /dev/null +++ b/packages/laravel-translations/.github/dependabot.yml @@ -0,0 +1,19 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/packages/laravel-translations/.github/workflows/dependabot-auto-merge.yml b/packages/laravel-translations/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 00000000..cc8c94cf --- /dev/null +++ b/packages/laravel-translations/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,33 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + timeout-minutes: 5 + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2.4.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/packages/laravel-translations/.github/workflows/fix-php-code-style-issues.yml b/packages/laravel-translations/.github/workflows/fix-php-code-style-issues.yml new file mode 100644 index 00000000..1eed95a3 --- /dev/null +++ b/packages/laravel-translations/.github/workflows/fix-php-code-style-issues.yml @@ -0,0 +1,28 @@ +name: Fix PHP code style issues + +on: + push: + paths: + - '**.php' + +permissions: + contents: write + +jobs: + php-code-styling: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@2.6 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: Fix styling diff --git a/packages/laravel-translations/.github/workflows/phpstan.yml b/packages/laravel-translations/.github/workflows/phpstan.yml new file mode 100644 index 00000000..d5db2f14 --- /dev/null +++ b/packages/laravel-translations/.github/workflows/phpstan.yml @@ -0,0 +1,28 @@ +name: PHPStan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + - '.github/workflows/phpstan.yml' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v3 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github diff --git a/packages/laravel-translations/.github/workflows/tests.yml b/packages/laravel-translations/.github/workflows/tests.yml new file mode 100644 index 00000000..ff979668 --- /dev/null +++ b/packages/laravel-translations/.github/workflows/tests.yml @@ -0,0 +1,54 @@ +name: tests + +on: + push: + paths: + - '**.php' + - '.github/workflows/run-tests.yml' + - 'phpunit.xml.dist' + - 'composer.json' + - 'composer.lock' + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] + php: [8.3] + laravel: [12.*] + stability: [prefer-lowest, prefer-stable] + include: + - laravel: 12.* + testbench: 10.* + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: List Installed Dependencies + run: composer show -D + + - name: Execute tests + run: vendor/bin/pest --ci diff --git a/packages/laravel-translations/.github/workflows/update-changelog.yml b/packages/laravel-translations/.github/workflows/update-changelog.yml new file mode 100644 index 00000000..39de30d6 --- /dev/null +++ b/packages/laravel-translations/.github/workflows/update-changelog.yml @@ -0,0 +1,32 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v5 + with: + branch: main + commit_message: Update CHANGELOG + file_pattern: CHANGELOG.md diff --git a/packages/laravel-translations/.gitignore b/packages/laravel-translations/.gitignore new file mode 100644 index 00000000..1a19bd03 --- /dev/null +++ b/packages/laravel-translations/.gitignore @@ -0,0 +1,31 @@ +# Composer Related +composer.lock +/vendor + +# Frontend Assets +/node_modules + +# Logs +npm-debug.log +yarn-error.log + +# Caches +.phpunit.cache +.phpunit.result.cache +/build + +# IDE Helper +_ide_helper.php +_ide_helper_models.php +.phpstorm.meta.php + +# Editors +/.idea +/.fleet +/.vscode + +# Misc +phpunit.xml +phpstan.neon +testbench.yaml +/coverage diff --git a/packages/laravel-translations/CHANGELOG.md b/packages/laravel-translations/CHANGELOG.md new file mode 100644 index 00000000..be194afa --- /dev/null +++ b/packages/laravel-translations/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to `laravel-translations` will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial release of Laravel Translations package +- Support for Google Translate, DeepL, and AI translation providers +- Automatic scanning of Laravel translation functions +- Model attribute translation with Eloquent integration +- Language management system +- Translation synchronization and caching +- Artisan commands for translation management +- Comprehensive documentation + +### Features +- **Multiple Translation Providers**: Google Translate (free), DeepL (premium), AI providers (OpenAI, Anthropic) +- **Automatic Scanning**: Detects `trans()`, `__()`, `@lang`, and other Laravel translation functions +- **Model Translation**: Automatically translate Eloquent model attributes +- **Language Management**: Add, remove, and manage multiple languages +- **Performance Optimization**: Optional permanent caching and queued operations +- **Laravel Integration**: Seamless integration with Laravel's translation system + +### Commands +- `translations:languages:add` - Add new languages +- `translations:scan` - Scan application for translation strings +- `translations:translate` - Translate scanned strings +- `translations:sync` - Synchronize translations and clean up + +### Requirements +- PHP 8.2+ +- Laravel 10.x, 11.x, or 12.x diff --git a/packages/laravel-translations/LICENSE.md b/packages/laravel-translations/LICENSE.md new file mode 100644 index 00000000..0c7eada8 --- /dev/null +++ b/packages/laravel-translations/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Vormkracht10 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/laravel-translations/README.md b/packages/laravel-translations/README.md new file mode 100644 index 00000000..049cb404 --- /dev/null +++ b/packages/laravel-translations/README.md @@ -0,0 +1,131 @@ +
+ +# Laravel Translations + + Nice to meet you, we're [Vormkracht10](https://vormrkacht10.nl) + +Break Language Barriers, Empower Global Success + +license +last-commit + + + Latest Version on Packagist + + + GitHub Tests Action Status + + + GitHub Code Style Action Status + + + Total Downloads + + + +
+ +## Quick Start + +Laravel Translations makes multilingual Laravel applications simple. Scan, translate, and manage your translations automatically. + +### 1. Install + +```bash +composer require backstage/laravel-translations + +php artisan vendor:publish --provider="Backstage\Translations\Laravel\TranslationServiceProvider" + +php artisan migrate +``` + +### 2. Add Languages + +```bash +php artisan translations:languages:add en English + +php artisan translations:languages:add es Spanish + +php artisan translations:languages:add fr French +``` + +### 3. Scan & Translate + +```bash +# Scan your app for translation strings +php artisan translations:scan + +# Translate them automatically +php artisan translations:translate +``` + +That's it! Your translations are now managed automatically. + +## Features + +- 🌐 **Multiple Providers**: Google Translate, DeepL, AI (OpenAI, etc.) +- 🔄 **Auto-Scanning**: Finds `trans()`, `__()`, `@lang` in your code +- 🏷️ **Model Attributes**: Translate Eloquent model attributes automatically +- 📊 **Language Management**: Add, remove, and manage languages easily +- ⚡ **Performance**: Optional caching and queued operations +- 🎯 **Laravel Integration**: Works seamlessly with Laravel's translation system + +## Model Translation + +Translate Eloquent model attributes automatically. See the [Model Attributes](docs/model-attributes.md) guide for detailed setup and usage. + +## Commands + +```bash +# Add languages +php artisan translations:languages:add {locale} {label} + +# Scan for translations +php artisan translations:scan + +# Translate strings +php artisan translations:translate + +php artisan translations:translate --code=es + +php artisan translations:translate --update + +# Sync translations +php artisan translations:sync +``` + +## Translation Providers + +Supports Google Translate, DeepL, and AI providers. See the [Translation Providers](docs/providers.md) guide for configuration details. + +## Documentation + +📚 **[Complete Documentation](docs/index.md)** - Detailed guides and API reference + +- [Installation & Setup](docs/installation.md) +- [Configuration](docs/configuration.md) +- [Basic Usage](docs/basic-usage.md) +- [Model Attributes](docs/model-attributes.md) +- [Translation Providers](docs/providers.md) +- [Commands Reference](docs/commands.md) +- [Advanced Usage](docs/advanced-usage.md) + +## Requirements + +- PHP 8.2+ +- Laravel 10.x, 11.x, or 12.x + +## License + +MIT License. See [LICENSE](LICENSE.md) for details. + +## Contributing + +- 🐛 [Report Issues](https://github.com/backstagephp/laravel-translations/issues) +- 💡 [Submit Pull Requests](https://github.com/backstagephp/laravel-translations/pulls) + +--- + +
+Made with ❤️ by [Vormkracht10](https://vormkracht10.nl) +
diff --git a/packages/laravel-translations/composer.json b/packages/laravel-translations/composer.json new file mode 100644 index 00000000..6fc1ad88 --- /dev/null +++ b/packages/laravel-translations/composer.json @@ -0,0 +1,98 @@ +{ + "name": "backstage/laravel-translations", + "description": "A Laravel translations package", + "keywords": [ + "backstage", + "laravel", + "translations" + ], + "homepage": "https://github.com/backstagephp/laravel-translations", + "license": "MIT", + "authors": [ + { + "name": "Manoj Hortulanus", + "email": "manoj@backstagephp.com", + "role": "Developer" + }, + { + "name": "Mark", + "email": "mark@backstagephp.com", + "role": "Developer" + } + ], + "require": { + "php": "^8.3", + "backstage/laravel-permanent-cache": "dev-main", + "deeplcom/deepl-php": ">=1.12", + "prism-php/prism": "^0.98", + "illuminate/contracts": "^10.0||^11.0||^12.0", + "lorisleiva/laravel-actions": ">=2.0", + "spatie/fork": "^1.2", + "spatie/laravel-package-tools": "^1.16", + "stichoza/google-translate-php": "^5.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "nunomaduro/collision": "^8.1.1||^7.10.0", + "larastan/larastan": "^3.7", + "orchestra/testbench": "^9.0.0||^8.22.0", + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-arch": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0" + }, + "autoload": { + "psr-4": { + "Backstage\\Translations\\Laravel\\": "src/" + }, + "files": [ + "src/helpers.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Backstage\\Translations\\Laravel\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/" + } + }, + "scripts": { + "post-autoload-dump": [ + "@composer run prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-laravel-translations --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": [ + "@composer run prepare", + "@php vendor/bin/testbench workbench:build --ansi" + ], + "start": [ + "Composer\\Config::disableProcessTimeout", + "@composer run build", + "@php vendor/bin/testbench serve" + ], + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "php-http/discovery": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "laravel": { + "providers": [ + "Backstage\\Translations\\Laravel\\TranslationServiceProvider", + "Backstage\\Translations\\Laravel\\TranslationLoaderServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} \ No newline at end of file diff --git a/packages/laravel-translations/config/translations.php b/packages/laravel-translations/config/translations.php new file mode 100644 index 00000000..1f2d25ca --- /dev/null +++ b/packages/laravel-translations/config/translations.php @@ -0,0 +1,60 @@ + [ + 'paths' => [ + base_path(), + ], + + 'extensions' => [ + '*.php', + '*.blade.php', + '*.json', + ], + + 'functions' => [ + 'trans', + 'trans_choice', + 'Lang::transChoice', + 'Lang::trans', + 'Lang::get', + 'Lang::choice', + '@lang', + '@choice', + '__', + ], + ], + + 'use_permanent_cache' => false, + + 'eloquent' => [ + 'translatable-models' => [ + // + ], + ], + + 'translators' => [ + 'default' => env('TRANSLATION_DRIVER', 'google-translate'), + + 'drivers' => [ + 'google-translate' => [ + // no options + ], + + 'ai' => [ + 'provider' => Provider::OpenAI, + 'model' => 'gpt-4.1', + 'system_prompt' => 'You translate Laravel translations strings to the language you have been asked.', + ], + + 'deep-l' => [ + 'options' => [ + TranslatorOptions::SERVER_URL => env('DEEPL_SERVER_URL', 'https://api.deepl.com/'), + ], + ], + ], + ], +]; diff --git a/packages/laravel-translations/database/migrations/create_languages_table.php.stub b/packages/laravel-translations/database/migrations/create_languages_table.php.stub new file mode 100644 index 00000000..347cf1b2 --- /dev/null +++ b/packages/laravel-translations/database/migrations/create_languages_table.php.stub @@ -0,0 +1,34 @@ +char('code', 5) + ->primary(); + + $table->string('name'); + $table->string('native') + ->nullable(); + + $table->boolean('active') + ->default(true); + + $table->boolean('default') + ->default(false); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down() + { + Schema::dropIfExists('translations'); + } +}; diff --git a/packages/laravel-translations/database/migrations/create_translated_attributes_table.php.stub b/packages/laravel-translations/database/migrations/create_translated_attributes_table.php.stub new file mode 100644 index 00000000..40b5a471 --- /dev/null +++ b/packages/laravel-translations/database/migrations/create_translated_attributes_table.php.stub @@ -0,0 +1,35 @@ +id(); + $table->string('code', 5); + + $table->foreign('code') + ->references('code') + ->on('languages') + ->onDelete('cascade'); + + $table->morphs('translatable'); + + $table->longText('attribute'); + $table->longText('translated_attribute')->nullable(); + $table->timestamp('translated_at')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down() + { + Schema::dropIfExists('translated_attributes'); + } +}; diff --git a/packages/laravel-translations/database/migrations/create_translations_table.php.stub b/packages/laravel-translations/database/migrations/create_translations_table.php.stub new file mode 100644 index 00000000..d3dec4d1 --- /dev/null +++ b/packages/laravel-translations/database/migrations/create_translations_table.php.stub @@ -0,0 +1,43 @@ +id(); + $table->string('code', 5); + + $table->string('group') + ->nullable() + ->index(); + + $table->text('key'); + + $table->longText('text') + ->nullable(); + + $table->longText('source_text') + ->nullable(); + + $table->string('namespace') + ->default('*') + ->index(); + + $table->timestamp('translated_at') + ->nullable(); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down() + { + Schema::dropIfExists('translations'); + } +}; diff --git a/packages/laravel-translations/docs/advanced-usage.md b/packages/laravel-translations/docs/advanced-usage.md new file mode 100644 index 00000000..2fc4b8a9 --- /dev/null +++ b/packages/laravel-translations/docs/advanced-usage.md @@ -0,0 +1,525 @@ +# Advanced Usage + +This guide covers advanced features and customization options for the Laravel Translations package. + +## Custom Translation Functions + +### Adding Custom Functions + +Add custom translation functions to scan: + +```php +// config/translations.php +'scan' => [ + 'functions' => [ + 'trans', + '__', + 'my_custom_trans', // Custom function + 'MyClass::translate', // Static method + 'MyClass::trans', // Another static method + ], +], +``` + +### Creating Custom Functions + +```php +// In a helper file or service +function my_custom_trans($key, $replace = [], $locale = null) +{ + return app('translator')->get($key, $replace, $locale); +} + +// Or as a static method +class TranslationHelper +{ + public static function translate($key, $replace = [], $locale = null) + { + return app('translator')->get($key, $replace, $locale); + } +} +``` + +## Custom Translation Prompts + +### AI Provider Customization + +Customize AI translation prompts: + +```php +// config/translations.php +'ai' => [ + 'provider' => Provider::OpenAI, + 'model' => 'gpt-4.1', + 'system_prompt' => 'You are a professional translator specializing in Laravel applications. Translate the following text accurately while maintaining the context and meaning.', + 'custom_prompts' => [ + 'title' => 'Translate this title for a blog post about technology. Keep it engaging and SEO-friendly.', + 'content' => 'Translate this blog post content. Maintain the original tone and structure, including any HTML formatting.', + 'meta_description' => 'Translate this meta description for SEO. Keep it under 160 characters and compelling.', + ], +], +``` + +### Context-Aware Translation + +Use model context for better translations: + +```php +class Post extends Model implements TranslatesAttributes +{ + use HasTranslatableAttributes; + + public function translateAttribute(mixed $attribute, string $targetLanguage, bool $overwrite = false, ?string $extraPrompt = null): mixed + { + // Build context-aware prompt + $context = $this->buildTranslationContext($attribute); + $prompt = $extraPrompt ?? $context; + + return TranslateAttribute::run( + model: $this, + attribute: $attribute, + targetLanguage: $targetLanguage, + overwrite: $overwrite, + extraPrompt: $prompt + ); + } + + private function buildTranslationContext(string $attribute): string + { + $context = "Translate this {$attribute} for a blog post"; + + if ($this->category) { + $context .= " in the {$this->category} category"; + } + + if ($this->tags) { + $context .= " with tags: " . $this->tags->pluck('name')->join(', '); + } + + return $context; + } +} +``` + +## Event Handling + +### Translation Events + +Listen to translation events: + +```php +use Backstage\Translations\Laravel\Events\LanguageAdded; +use Backstage\Translations\Laravel\Events\LanguageDeleted; +use Backstage\Translations\Laravel\Events\LanguageCodeChanged; + +// In a service provider +public function boot() +{ + Event::listen(LanguageAdded::class, function ($event) { + // Handle new language added + Log::info('New language added: ' . $event->language->code); + + // Auto-scan for new translations + dispatch(new ScanTranslationStrings($event->language)); + }); + + Event::listen(LanguageDeleted::class, function ($event) { + // Handle language deleted + Log::info('Language deleted: ' . $event->language->code); + + // Clean up related translations + Translation::where('code', $event->language->code)->delete(); + }); +} +``` + +### Custom Events + +Create custom translation events: + +```php + true, + +// Custom cache configuration +'cache' => [ + 'driver' => 'redis', + 'prefix' => 'translations', + 'ttl' => 3600, // 1 hour +], +``` + +### Queue Configuration + +Optimize queue processing: + +```php +// config/queue.php +'connections' => [ + 'translations' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'queue' => 'translations', + 'retry_after' => 90, + 'block_for' => null, + ], +], +``` + +### Batch Processing + +Process translations in batches: + +```php +use Backstage\Translations\Laravel\Models\Translation; +use Backstage\Translations\Laravel\Jobs\TranslateKeys; + +// Process translations in chunks +Translation::whereNull('translated_at') + ->chunk(100, function ($translations) { + TranslateKeys::dispatch($translations); + }); +``` + +## Custom Commands + +### Creating Custom Commands + +Create custom translation commands: + +```php +option('language'); + + if ($languageCode) { + $this->showLanguageStats($languageCode); + } else { + $this->showOverallStats(); + } + } + + private function showOverallStats() + { + $languages = Language::active()->count(); + $translations = Translation::count(); + $translated = Translation::whereNotNull('translated_at')->count(); + $pending = $translations - $translated; + + $this->info("Overall Statistics:"); + $this->table( + ['Metric', 'Count'], + [ + ['Active Languages', $languages], + ['Total Translations', $translations], + ['Translated', $translated], + ['Pending', $pending], + ['Completion Rate', round(($translated / $translations) * 100, 2) . '%'], + ] + ); + } + + private function showLanguageStats(string $languageCode) + { + $language = Language::where('code', $languageCode)->first(); + + if (!$language) { + $this->error("Language '{$languageCode}' not found."); + return; + } + + $total = Translation::where('code', $languageCode)->count(); + $translated = Translation::where('code', $languageCode) + ->whereNotNull('translated_at') + ->count(); + $pending = $total - $translated; + + $this->info("Statistics for {$language->name} ({$languageCode}):"); + $this->table( + ['Metric', 'Count'], + [ + ['Total Translations', $total], + ['Translated', $translated], + ['Pending', $pending], + ['Completion Rate', round(($translated / $total) * 100, 2) . '%'], + ] + ); + } +} +``` + +## Testing + +### Testing Translations + +Create tests for your translation functionality: + +```php +artisan('translations:languages:add', [ + 'locale' => 'es', + 'label' => 'Spanish' + ])->assertExitCode(0); + + $this->assertDatabaseHas('languages', [ + 'code' => 'es', + 'name' => 'Spanish' + ]); + } + + public function test_can_scan_translations() + { + // Create a language first + Language::create([ + 'code' => 'en', + 'name' => 'English', + 'native' => 'English', + 'active' => true, + ]); + + $this->artisan('translations:scan') + ->assertExitCode(0); + + // Check if translations were created + $this->assertTrue(Translation::count() > 0); + } + + public function test_model_translation() + { + $post = Post::create([ + 'title' => 'Hello World', + 'content' => 'This is a test post', + ]); + + // Add Spanish language + Language::create([ + 'code' => 'es', + 'name' => 'Spanish', + 'native' => 'Español', + 'active' => true, + ]); + + // Translate attributes + $post->translateAttributes('es'); + + // Check if translations were created + $this->assertTrue($post->translatableAttributes()->count() > 0); + } +} +``` + +## Monitoring and Analytics + +### Translation Analytics + +Track translation usage and performance: + +```php +trackLanguageAdded($event->language); + }); + } + + public function getTranslationStats(): array + { + return [ + 'total_translations' => Translation::count(), + 'translated_count' => Translation::whereNotNull('translated_at')->count(), + 'pending_count' => Translation::whereNull('translated_at')->count(), + 'languages_count' => Language::active()->count(), + 'completion_rate' => $this->getCompletionRate(), + ]; + } + + public function getLanguageStats(string $languageCode): array + { + $translations = Translation::where('code', $languageCode); + + return [ + 'total' => $translations->count(), + 'translated' => $translations->whereNotNull('translated_at')->count(), + 'pending' => $translations->whereNull('translated_at')->count(), + ]; + } + + private function getCompletionRate(): float + { + $total = Translation::count(); + $translated = Translation::whereNotNull('translated_at')->count(); + + return $total > 0 ? round(($translated / $total) * 100, 2) : 0; + } + + private function trackLanguageAdded(Language $language): void + { + Log::info('Language added', [ + 'code' => $language->code, + 'name' => $language->name, + 'timestamp' => now(), + ]); + } +} +``` + +## Integration Examples + +### Multi-tenant Applications + +Handle translations in multi-tenant applications: + +```php +tenant_id}"; + $prompt = $extraPrompt ? "{$tenantContext}. {$extraPrompt}" : $tenantContext; + + return parent::translateAttribute($attribute, $targetLanguage, $overwrite, $prompt); + } +} +``` + +## Translating Array Values + +When your translation payload contains arrays, you can declare rules to translate array values. Use `'*'` to indicate that all items under a given key should be translated, or scope it to a particular nested key. + +```php +// Translate all items under the "data" key +return [ + 'data' => ['*'], +]; +``` + +```php +// Translate all items under a specific nested key within "data" +return [ + 'data' => [ + 'special-key' => ['*'], + ], +]; +``` + +This rule is nested under the `data` key. Array rules are hierarchical and mirror your array structure, so `special-key` here is resolved as `data.special-key`. + +### Per-attribute rules and defaults + +For model attribute translation, you can define rules per attribute by adding a method on your model named `getTranslatableAttributeRulesFor{Attribute}`. If this method does not exist, the default rule is `'*'` (translate the entire attribute). + +```php +// Example on a model using content attribute: + +public function getTranslatableAttributeRulesForContent(): array|string +{ + return [ + 'data' => [ + 'special-key' => ['*'], + ], + ]; +} + +// If the method is absent, it behaves as if: +// return '*'; +``` + +#### Example use case + +```php +public function getTranslatableAttributeRulesForContent(): array|string +{ + return [ + 'metadata' => [ + 'tags' => ['*'], + ], + ]; +} + +// Effect: +// - Translates all items in content.metadata.tags [0..*] +// - Leaves content.metadata.{other} unchanged +``` + +## Next Steps + +- [Configuration](configuration.md) - Complete configuration options diff --git a/packages/laravel-translations/docs/basic-usage.md b/packages/laravel-translations/docs/basic-usage.md new file mode 100644 index 00000000..653e9794 --- /dev/null +++ b/packages/laravel-translations/docs/basic-usage.md @@ -0,0 +1,308 @@ +# Basic Usage + +This guide covers the core features of the Laravel Translations package for managing translations in your Laravel application. + +## Overview + +The Laravel Translations package extends Laravel's built-in translation system with: + +- Automatic scanning of translation strings +- Multiple translation providers (Google Translate, DeepL, AI) +- Language management +- Translation synchronization +- Performance optimization with caching + +## Language Management + +### Adding Languages + +Add new languages to your application: + +```bash +# Add a language with locale and display name +php artisan translations:languages:add en English +php artisan translations:languages:add es Spanish +php artisan translations:languages:add fr French + +# Add locale-specific variants +php artisan translations:languages:add en-US "English (US)" +php artisan translations:languages:add fr-BE "Français (Belgique)" +``` + +### Managing Languages + +Languages are stored in the `languages` table with these fields: + +- `code` - Language code (e.g., 'en', 'es', 'fr-BE') +- `name` - Display name in your app's locale +- `native` - Native name of the language +- `active` - Whether the language is active +- `default` - Whether it's the default language + +### Working with Languages in Code + +```php +use Backstage\Translations\Laravel\Models\Language; + +// Get all active languages +$languages = Language::active()->get(); + +// Get default language +$default = Language::default(); + +// Get specific language +$spanish = Language::where('code', 'es')->first(); + +// Check if language exists +if (Language::where('code', 'de')->exists()) { + // German language is available +} +``` + +## Scanning for Translations + +### Automatic Scanning + +The package automatically scans your application for translation strings: + +```bash +# Scan all active languages +php artisan translations:scan + +# Scan specific language +php artisan translations:scan --language=es +``` + +### What Gets Scanned + +The scanner looks for these Laravel translation functions: + +```php +// In your PHP files +trans('messages.welcome'); +__('messages.hello'); +trans_choice('messages.apples', $count); + +// In Blade templates +{{ __('messages.welcome') }} +{{ trans('messages.hello') }} +@lang('messages.goodbye') +@choice('messages.items', $count) + +// Using Lang facade +Lang::get('messages.welcome'); +Lang::trans('messages.hello'); +Lang::choice('messages.apples', $count); +``` + +### Translation Keys Structure + +Scanned translations are stored with this structure: + +- `group` - The translation group (e.g., 'messages', 'validation') +- `key` - The translation key (e.g., 'welcome', 'hello') +- `namespace` - The namespace (usually '*' for default) +- `code` - The language code +- `text` - The actual translation text + +## Translating Strings + +### Automatic Translation + +Translate all scanned strings: + +```bash +# Translate all languages +php artisan translations:translate + +# Translate specific language +php artisan translations:translate --code=es + +# Update existing translations +php artisan translations:translate --code=es --update +``` + +### Translation Process + +1. **Scan** - Find translation strings in your code +2. **Store** - Save them to the database +3. **Translate** - Use configured provider to translate +4. **Cache** - Store translations for performance + +### Translation Providers + +The package supports multiple translation providers. See the [Translation Providers](providers.md) guide for detailed configuration. + +## Using Translations + +### Standard Laravel Methods + +Use translations exactly like Laravel's built-in system: + +```php +// In controllers +$message = trans('messages.welcome'); +$message = __('messages.hello'); + +// In Blade templates +{{ __('messages.welcome') }} +{{ trans('messages.hello') }} + +// With parameters +{{ __('messages.welcome', ['name' => $user->name]) }} + +// Pluralization +{{ trans_choice('messages.apples', $count) }} +``` + +### Programmatic Access + +Access translations directly from the database: + +```php +use Backstage\Translations\Laravel\Models\Translation; + +// Get specific translation +$translation = Translation::where('key', 'welcome') + ->where('group', 'messages') + ->where('code', 'es') + ->first(); + +echo $translation->text; // "Bienvenido" + +// Get all translations for a language +$spanishTranslations = Translation::where('code', 'es')->get(); + +// Get all translations for a group +$messages = Translation::where('group', 'messages')->get(); +``` + +## Synchronization + +### Automatic Sync + +The package automatically syncs translations when: + +- New languages are added +- Translation strings are scanned +- Model attributes are updated + +### Manual Sync + +Force synchronization: + +```bash +php artisan translations:sync +``` + +This command: +- Removes orphaned translations +- Fills missing translations +- Updates translation status + +### Scheduled Sync + +The package automatically runs sync daily at midnight: + +```php +// In TranslationServiceProvider +Schedule::command(SyncTranslations::class) + ->dailyAt('00:00') + ->withoutOverlapping(); +``` + +## Caching + +### Enable Caching + +For better performance, enable permanent caching: + +```php +// In config/translations.php +'use_permanent_cache' => true, +``` + +### Cache Management + +The package automatically manages translation caches: + +- Translations are cached when saved +- Cache is updated when translations change +- Cache is cleared when needed + +## Best Practices + +### 1. Organize Translation Keys + +Use meaningful group and key names: + +```php +// Good +trans('auth.login.title') +trans('products.show.price') +trans('validation.required') + +// Avoid +trans('a') +trans('b') +trans('c') +``` + +### 2. Use Parameters + +Make translations flexible with parameters: + +```php +// Translation file +'welcome' => 'Welcome, :name!', + +// Usage +trans('messages.welcome', ['name' => $user->name]) +``` + +### 3. Handle Missing Translations + +The package handles missing translations gracefully: + +```php +// If translation doesn't exist, returns the key +echo trans('nonexistent.key'); // "nonexistent.key" + +// Check if translation exists +if (Translation::where('key', 'welcome')->exists()) { + // Translation exists +} +``` + +### 4. Regular Maintenance + +Run these commands regularly: + +```bash +# Scan for new translations +php artisan translations:scan + +# Translate new strings +php artisan translations:translate + +# Sync and clean up +php artisan translations:sync +``` + +## Troubleshooting + +### Common Issues + +**Translations not appearing**: Run `translations:scan` first + +**Translation errors**: Check your translation provider configuration + +**Performance issues**: Enable caching with `use_permanent_cache => true` + +**Missing languages**: Add languages with `translations:languages:add` + +## Next Steps + +- [Model Attributes](model-attributes.md) - Translate Eloquent model attributes +- [Commands](commands.md) - Complete command reference +- [Advanced Usage](advanced-usage.md) - Advanced features and customization diff --git a/packages/laravel-translations/docs/commands.md b/packages/laravel-translations/docs/commands.md new file mode 100644 index 00000000..98ac4df1 --- /dev/null +++ b/packages/laravel-translations/docs/commands.md @@ -0,0 +1,386 @@ +# Commands Reference + +The Laravel Translations package provides several Artisan commands to manage translations. This guide covers all available commands and their options. + +## Available Commands + +### `translations:languages:add` + +Add a new language to your application. + +```bash +php artisan translations:languages:add {locale} {label} +``` + +**Parameters:** +- `locale` - Language code (e.g., 'en', 'es', 'fr-BE') +- `label` - Display name for the language + +**Examples:** +```bash +# Add basic languages +php artisan translations:languages:add en English +php artisan translations:languages:add es Spanish +php artisan translations:languages:add fr French + +# Add locale-specific variants +php artisan translations:languages:add en-US "English (US)" +php artisan translations:languages:add fr-BE "Français (Belgique)" +php artisan translations:languages:add de-AT "Deutsch (Österreich)" +``` + +**What it does:** +- Creates a new language record in the `languages` table +- Sets the language as active by default +- Generates native language name automatically + +### `translations:scan` + +Scan your application for translation strings. + +```bash +php artisan translations:scan +``` + +**Options:** +- `--language={code}` - Scan for specific language only + +**Examples:** +```bash +# Scan all active languages +php artisan translations:scan + +# Scan for specific language +php artisan translations:scan --language=es +``` + +**What it does:** +- Scans configured paths for translation functions +- Finds `trans()`, `__()`, `@lang`, etc. +- Stores found translations in the database +- Creates language records if they don't exist + +**Configuration:** +The scan behavior is controlled by `config/translations.php`: + +```php +'scan' => [ + 'paths' => [ + base_path(), // Directories to scan + ], + 'extensions' => [ + '*.php', + '*.blade.php', + '*.json', + ], + 'functions' => [ + 'trans', + 'trans_choice', + '__', + '@lang', + '@choice', + // ... more functions + ], +], +``` + +### `translations:translate` + +Translate scanned translation strings. + +```bash +php artisan translations:translate + {--code= : Translate for specific language} + {--update : Update existing translations} +``` + +**Options:** +- `--code={language}` - Translate only for specific language +- `--update` - Overwrite existing translations + +**Examples:** +```bash +# Translate all languages +php artisan translations:translate + +# Translate specific language +php artisan translations:translate --code=es + +# Update existing translations +php artisan translations:translate --code=es --update + +# Update all translations +php artisan translations:translate --update +``` + +**What it does:** +- Uses configured translation provider +- Translates untranslated strings +- Updates `translated_at` timestamp +- Processes translations in background (queued) + +### `translations:sync` + +Synchronize translations and clean up orphaned data. + +```bash +php artisan translations:sync +``` + +**What it does:** +- Removes orphaned translations +- Fills missing translations +- Updates translation status +- Cleans up unused data + +**When to use:** +- After adding/removing languages +- After cleaning up translation files +- For general maintenance +- When translations seem out of sync + +## Command Workflows + +### Initial Setup + +Set up translations for a new application: + +```bash +# 1. Add your default language +php artisan translations:languages:add en English + +# 2. Add additional languages +php artisan translations:languages:add es Spanish +php artisan translations:languages:add fr French + +# 3. Scan for existing translations +php artisan translations:scan + +# 4. Translate all strings +php artisan translations:translate +``` + +### Adding New Language + +Add a new language to existing application: + +```bash +# 1. Add the language +php artisan translations:languages:add de German + +# 2. Scan for new translations +php artisan translations:scan + +# 3. Translate for new language +php artisan translations:translate --code=de + +# 4. Sync to ensure consistency +php artisan translations:sync +``` + +### Updating Translations + +Update existing translations: + +```bash +# Update specific language +php artisan translations:translate --code=es --update + +# Update all languages +php artisan translations:translate --update +``` + +### Maintenance + +Regular maintenance tasks: + +```bash +# Scan for new translations +php artisan translations:scan + +# Translate new strings +php artisan translations:translate + +# Sync and clean up +php artisan translations:sync +``` + +## Scheduled Commands + +The package automatically schedules the sync command: + +```php +// In TranslationServiceProvider +Schedule::command(SyncTranslations::class) + ->dailyAt('00:00') + ->withoutOverlapping(); +``` + +This runs daily at midnight to keep translations in sync. + +## Custom Commands + +You can create custom commands that use the translation system: + +```php +count(); + $translations = Translation::count(); + $translated = Translation::whereNotNull('translated_at')->count(); + + $this->info("Languages: {$languages}"); + $this->info("Total translations: {$translations}"); + $this->info("Translated: {$translated}"); + $this->info("Pending: " . ($translations - $translated)); + } +} +``` + +## Command Output + +### Success Messages + +Commands provide clear feedback: + +```bash +$ php artisan translations:languages:add es Spanish +✓ Language 'es' added successfully + +$ php artisan translations:scan +✓ Scanning translations... +✓ Found 150 translation strings +✓ Translations stored successfully + +$ php artisan translations:translate --code=es +✓ Translating for language: Spanish... +✓ 150 translations processed successfully +``` + +### Error Handling + +Commands handle errors gracefully: + +```bash +$ php artisan translations:languages:add es Spanish +✗ Language 'es' already exists + +$ php artisan translations:translate --code=xx +✗ Language 'xx' not found + +$ php artisan translations:translate --code=es +✗ Translation failed: API key not configured +``` + +## Integration with CI/CD + +### GitHub Actions + +Example workflow for automated translation updates: + +```yaml +name: Update Translations + +on: + schedule: + - cron: '0 2 * * *' # Daily at 2 AM + +jobs: + update-translations: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + + - name: Install dependencies + run: composer install --no-dev --optimize-autoloader + + - name: Run migrations + run: php artisan migrate --force + + - name: Scan translations + run: php artisan translations:scan + + - name: Translate new strings + run: php artisan translations:translate + env: + TRANSLATION_DRIVER: google-translate + + - name: Sync translations + run: php artisan translations:sync +``` + +### Local Development + +Set up local development workflow: + +```bash +# Add to your local development script +#!/bin/bash + +# Scan for new translations +php artisan translations:scan + +# Translate new strings +php artisan translations:translate + +# Sync translations +php artisan translations:sync + +echo "Translations updated successfully!" +``` + +## Troubleshooting Commands + +### Common Issues + +**Command not found**: Ensure the package is properly installed and registered + +**Permission errors**: Check file permissions for the application directory + +**Database errors**: Ensure migrations have been run + +**Translation failures**: Check your translation provider configuration + +### Debug Mode + +Enable debug mode for more verbose output: + +```bash +# Set debug mode +APP_DEBUG=true php artisan translations:scan + +# Or use verbose flag +php artisan translations:scan -v +``` + +### Logging + +Commands log their activities: + +```php +// Check logs +tail -f storage/logs/laravel.log | grep translation +``` + +## Next Steps + +- [Advanced Usage](advanced-usage.md) - Advanced features and customization diff --git a/packages/laravel-translations/docs/configuration.md b/packages/laravel-translations/docs/configuration.md new file mode 100644 index 00000000..8025c909 --- /dev/null +++ b/packages/laravel-translations/docs/configuration.md @@ -0,0 +1,289 @@ +# Configuration + +This document covers all configuration options available in the Laravel Translations package. + +## Configuration File + +The main configuration file is located at `config/translations.php`. Here's the complete configuration with explanations: + +```php + [ + 'paths' => [ + base_path(), // Directories to scan for translations + ], + 'extensions' => [ + '*.php', + '*.blade.php', + '*.json', + ], + 'functions' => [ + 'trans', + 'trans_choice', + 'Lang::transChoice', + 'Lang::trans', + 'Lang::get', + 'Lang::choice', + '@lang', + '@choice', + '__', + ], + ], + + // Enable permanent caching for better performance + 'use_permanent_cache' => false, + + // Eloquent model configuration + 'eloquent' => [ + 'translatable-models' => [ + // Add your models here + // App\Models\Post::class, + // App\Models\Product::class, + ], + ], + + // Translation providers configuration + 'translators' => [ + 'default' => env('TRANSLATION_DRIVER', 'google-translate'), + + 'drivers' => [ + 'google-translate' => [ + // No additional options needed + ], + + 'ai' => [ + 'provider' => Provider::OpenAI, + 'model' => 'gpt-4.1', + 'system_prompt' => 'You translate Laravel translations strings to the language you have been asked.', + ], + + 'deep-l' => [ + 'options' => [ + TranslatorOptions::SERVER_URL => env('DEEPL_SERVER_URL', 'https://api.deepl.com/'), + ], + ], + ], + ], +]; +``` + +## Configuration Sections + +### Scan Configuration + +Controls how the package scans your application for translation strings. + +#### `paths` +Array of directories to scan for translation strings. + +```php +'paths' => [ + base_path(), // Scan entire application + base_path('app'), // Scan only app directory + base_path('resources/views'), // Scan only views +], +``` + +#### `extensions` +File extensions to include in the scan. + +```php +'extensions' => [ + '*.php', // PHP files + '*.blade.php', // Blade templates + '*.json', // JSON files + '*.vue', // Vue components +], +``` + +#### `functions` +Laravel translation functions to detect. + +```php +'functions' => [ + 'trans', // trans() function + 'trans_choice', // trans_choice() function + '__', // __() helper + '@lang', // @lang directive + '@choice', // @choice directive + 'Lang::get', // Lang facade methods + 'Lang::trans', + 'Lang::choice', + 'Lang::transChoice', +], +``` + +### Caching Configuration + +#### `use_permanent_cache` +Enable permanent caching for better performance. Requires the `backstage/laravel-permanent-cache` package. + +```php +'use_permanent_cache' => true, // Enable caching +``` + +### Eloquent Configuration + +#### `translatable-models` +Register models that should have translatable attributes. + +```php +'eloquent' => [ + 'translatable-models' => [ + App\Models\Post::class, + App\Models\Product::class, + App\Models\Category::class, + ], +], +``` + +### Translation Providers + +#### Default Driver +Set the default translation provider. + +```php +'default' => env('TRANSLATION_DRIVER', 'google-translate'), +``` + +#### Google Translate Driver +No additional configuration needed. + +```php +'google-translate' => [ + // No options required +], +``` + +#### DeepL Driver +Configure DeepL API settings. + +```php +'deep-l' => [ + 'options' => [ + TranslatorOptions::SERVER_URL => env('DEEPL_SERVER_URL', 'https://api.deepl.com/'), + ], +], +``` + +**Environment Variables:** +```env +DEEPL_API_KEY=your_deepl_api_key +DEEPL_SERVER_URL=https://api.deepl.com/ # or https://api-free.deepl.com/ for free tier +``` + +#### AI Driver +Configure AI translation providers. + +```php +'ai' => [ + 'provider' => Provider::OpenAI, + 'model' => 'gpt-4.1', + 'system_prompt' => 'You translate Laravel translations strings to the language you have been asked.', +], +``` + +**Environment Variables:** +```env +OPENAI_API_KEY=your_openai_api_key +``` + +## Environment Variables + +Add these to your `.env` file: + +```env +# Translation driver +TRANSLATION_DRIVER=google-translate + +# DeepL configuration +DEEPL_API_KEY=your_deepl_api_key +DEEPL_SERVER_URL=https://api.deepl.com/ + +# AI provider configuration +OPENAI_API_KEY=your_openai_api_key +``` + +## Advanced Configuration + +### Custom Translation Functions + +Add custom translation functions to scan: + +```php +'functions' => [ + 'trans', + '__', + 'my_custom_trans', // Custom function + 'MyClass::translate', // Static method +], +``` + +### Excluding Paths + +To exclude certain paths from scanning, modify the paths array: + +```php +'paths' => [ + base_path('app'), + base_path('resources/views'), + // Exclude vendor and storage + // base_path('vendor'), + // base_path('storage'), +], +``` + +### Custom AI Prompts + +Customize AI translation prompts: + +```php +'ai' => [ + 'provider' => Provider::OpenAI, + 'model' => 'gpt-4.1', + 'system_prompt' => 'You are a professional translator specializing in Laravel applications. Translate the following text accurately while maintaining the context and meaning.', +], +``` + +## Configuration Validation + +The package validates your configuration on boot. Common validation errors: + +- **Invalid driver**: Ensure the driver exists in the `drivers` array +- **Missing API keys**: Check that required environment variables are set +- **Invalid paths**: Ensure scan paths exist and are readable + +## Performance Optimization + +### Enable Caching +For better performance, enable permanent caching: + +```php +'use_permanent_cache' => true, +``` + +### Optimize Scan Paths +Only scan necessary directories: + +```php +'paths' => [ + base_path('app'), + base_path('resources/views'), + // Don't scan vendor, storage, etc. +], +``` + +### Queue Heavy Operations +Translation operations are automatically queued for better performance. + +## Next Steps + +- [Basic Usage](basic-usage.md) - Learn how to use the configured package +- [Model Attributes](model-attributes.md) - Set up model translation +- [Commands](commands.md) - Use Artisan commands effectively diff --git a/packages/laravel-translations/docs/index.md b/packages/laravel-translations/docs/index.md new file mode 100644 index 00000000..5cdb6b0a --- /dev/null +++ b/packages/laravel-translations/docs/index.md @@ -0,0 +1,71 @@ +# Laravel Translations Documentation + +Welcome to the Laravel Translations package documentation. This package provides comprehensive translation management for Laravel applications with support for multiple translation providers and automatic model attribute translation. + +## Quick Navigation + +- [Installation & Setup](installation.md) - Get started quickly +- [Configuration](configuration.md) - Configure the package +- [Basic Usage](basic-usage.md) - Core translation features +- [Model Attributes](model-attributes.md) - Translate Eloquent model attributes +- [Translation Providers](providers.md) - Google Translate, DeepL, AI providers +- [Commands](commands.md) - Artisan commands reference +- [Advanced Usage](advanced-usage.md) - Advanced features and customization + +## What This Package Does + +Laravel Translations is a powerful package that: + +1. **Scans your Laravel application** for translation strings using `trans()`, `__()`, and other Laravel translation functions +2. **Manages multiple languages** with support for locale-specific variants (e.g., `en-US`, `fr-BE`) +3. **Translates automatically** using Google Translate, DeepL, or AI providers +4. **Handles model attributes** - automatically translate Eloquent model attributes +5. **Syncs translations** - keeps your translations in sync across languages +6. **Caches efficiently** - optional permanent caching for better performance + +## Key Features + +- 🌐 **Multiple Translation Providers**: Google Translate, DeepL, AI (OpenAI, etc.) +- 🔄 **Automatic Scanning**: Finds translation strings in your codebase +- 🏷️ **Model Attributes**: Translate Eloquent model attributes automatically +- 📊 **Language Management**: Add, remove, and manage languages easily +- ⚡ **Performance**: Optional caching and queued operations +- 🎯 **Laravel Integration**: Seamless integration with Laravel's translation system + +## Quick Start + +1. **Install the package**: + ```bash + composer require backstage/laravel-translations + ``` + +2. **Publish and run migrations**: + ```bash + php artisan vendor:publish --provider="Backstage\Translations\Laravel\TranslationServiceProvider" + php artisan migrate + ``` + +3. **Add a language**: + ```bash + php artisan translations:languages:add en English + ``` + +4. **Scan for translations**: + ```bash + php artisan translations:scan + ``` + +5. **Translate them**: + ```bash + php artisan translations:translate + ``` + +That's it! Your translations are now managed automatically. + +## Need Help? + +- Look at the [Advanced Usage](advanced-usage.md) for complex scenarios + +--- + +*This documentation covers Laravel Translations v1.x. For older versions, please refer to the appropriate version tag.* diff --git a/packages/laravel-translations/docs/installation.md b/packages/laravel-translations/docs/installation.md new file mode 100644 index 00000000..f8e0af79 --- /dev/null +++ b/packages/laravel-translations/docs/installation.md @@ -0,0 +1,182 @@ +# Installation & Setup + +This guide will walk you through installing and setting up the Laravel Translations package. + +## Requirements + +- PHP 8.2 or higher +- Laravel 10.x, 11.x, or 12.x +- Composer + +## Installation + +### 1. Install via Composer + +```bash +composer require backstage/laravel-translations +``` + +### 2. Publish Configuration and Migrations + +Publish the package configuration and migration files: + +```bash +php artisan vendor:publish --provider="Backstage\Translations\Laravel\TranslationServiceProvider" +``` + +This will create: +- `config/translations.php` - Package configuration +- Database migrations for `languages`, `translations`, and `translated_attributes` tables + +### 3. Run Migrations + +Create the necessary database tables: + +```bash +php artisan migrate +``` + +### 4. Configure Environment Variables + +Add these environment variables to your `.env` file: + +```env +# Translation driver (google-translate, deep-l, ai) +TRANSLATION_DRIVER=google-translate + +# DeepL configuration (if using DeepL) +DEEPL_API_KEY=your_deepl_api_key +DEEPL_SERVER_URL=https://api.deepl.com/ + +# AI provider configuration (if using AI) +OPENAI_API_KEY=your_openai_api_key +``` + +## Basic Configuration + +### Translation Driver + +The package supports three translation providers: + +1. **Google Translate** (default) - No API key required +2. **DeepL** - Requires API key +3. **AI** - Uses OpenAI or other AI providers + +Set your preferred driver in `config/translations.php`: + +```php +'translators' => [ + 'default' => env('TRANSLATION_DRIVER', 'google-translate'), + // ... +], +``` + +### Scan Configuration + +Configure which files and functions to scan for translations: + +```php +'scan' => [ + 'paths' => [ + base_path(), // Scan entire application + ], + 'extensions' => [ + '*.php', + '*.blade.php', + '*.json', + ], + 'functions' => [ + 'trans', + 'trans_choice', + '__', + '@lang', + '@choice', + // ... more functions + ], +], +``` + +## Initial Setup + +### 1. Add Your First Language + +Add your application's default language: + +```bash +php artisan translations:languages:add en English +``` + +### 2. Scan for Existing Translations + +Scan your application for translation strings: + +```bash +php artisan translations:scan +``` + +This will find all translation keys in your codebase and store them in the database. + +### 3. Add Additional Languages + +Add more languages as needed: + +```bash +php artisan translations:languages:add es Spanish +php artisan translations:languages:add fr French +php artisan translations:languages:add de German +``` + +### 4. Translate Your Strings + +Translate all scanned strings: + +```bash +php artisan translations:translate +``` + +Or translate specific languages: + +```bash +php artisan translations:translate --code=es +php artisan translations:translate --code=fr +``` + +## Verification + +Check that everything is working: + +1. **Verify languages were created**: + ```bash + php artisan tinker + >>> \Backstage\Translations\Laravel\Models\Language::all() + ``` + +2. **Check translations were scanned**: + ```bash + php artisan tinker + >>> \Backstage\Translations\Laravel\Models\Translation::count() + ``` + +3. **Test translation retrieval**: + ```bash + php artisan tinker + >>> app('translator')->get('your.translation.key', [], 'es') + ``` + +## Next Steps + +- [Basic Usage](basic-usage.md) - Learn how to use the core features +- [Model Attributes](model-attributes.md) - Set up automatic model translation +- [Configuration](configuration.md) - Detailed configuration options + +## Troubleshooting + +### Common Issues + +**Migration fails**: Ensure your database connection is working and you have the necessary permissions. + +**Translations not found**: Make sure you've run `translations:scan` after adding languages. + +**Translation driver errors**: Check your API keys and configuration for the selected driver. + +**Performance issues**: Consider enabling permanent caching in the configuration. diff --git a/packages/laravel-translations/docs/model-attributes.md b/packages/laravel-translations/docs/model-attributes.md new file mode 100644 index 00000000..0452edfc --- /dev/null +++ b/packages/laravel-translations/docs/model-attributes.md @@ -0,0 +1,364 @@ +# Model Attributes Translation + +The Laravel Translations package provides powerful functionality to automatically translate Eloquent model attributes. This feature allows you to store content in one language and automatically generate translations for other languages. + +## Overview + +Model attribute translation works by: + +1. **Detecting changes** to translatable attributes +2. **Queuing translation jobs** automatically +3. **Storing translations** in a separate table +4. **Retrieving translations** based on current locale + +## Setup + +### 1. Configure Translatable Models + +Add your models to the configuration: + +```php +// config/translations.php +'eloquent' => [ + 'translatable-models' => [ + App\Models\Post::class, + App\Models\Product::class, + App\Models\Category::class, + ], +], +``` + +### 2. Implement the Contract and Use the Trait + +Your model must implement the `TranslatesAttributes` contract AND use the `HasTranslatableAttributes` trait: + +```php + 'string', + 'content' => 'string', + 'excerpt' => 'string', + 'meta_description' => 'string', + ]; +} +``` + +### 3. Database Migration + +The package creates a `translated_attributes` table to store translations: + +```php +// Migration: create_translated_attributes_table.php +Schema::create('translated_attributes', function (Blueprint $table) { + $table->id(); + $table->string('code'); // Language code + $table->morphs('translatable'); // Polymorphic relationship + $table->string('attribute'); // Attribute name + $table->text('translated_attribute'); // Translated value + $table->timestamp('translated_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); +}); +``` + +## Basic Usage + +### Creating and Updating Models + +When you create or update a model with translatable attributes, translations are automatically queued: + +```php +// Create a new post +$post = Post::create([ + 'title' => 'Hello World', + 'content' => 'This is my first post', + 'excerpt' => 'A short description', +]); + +// Translations are automatically queued for all active languages +// No additional code needed! +``` + +### Retrieving Translations + +Get translated attributes for the current locale: + +```php +$post = Post::find(1); + +// Get translated title for current locale +$title = $post->getTranslatedAttribute('title'); + +// Get translated title for specific locale +$spanishTitle = $post->getTranslatedAttribute('title', 'es'); + +// Get all translated attributes for current locale +$translations = $post->getTranslatedAttributes(); + +// Get all translated attributes for specific locale +$spanishTranslations = $post->getTranslatedAttributes('es'); +``` + +### Manual Translation + +Translate attributes manually: + +```php +$post = Post::find(1); + +// Translate single attribute +$translatedTitle = $post->translateAttribute('title', 'es'); + +// Translate all attributes for specific language +$translations = $post->translateAttributes('es'); + +// Translate all attributes for all languages +$allTranslations = $post->translateAttributesForAllLanguages(); +``` + +## Advanced Features + +### Overwriting Existing Translations + +Force translation of existing content: + +```php +$post = Post::find(1); + +// Overwrite existing Spanish translation +$post->translateAttribute('title', 'es', overwrite: true); + +// Overwrite all translations for this attribute +$post->translateAttributeForAllLanguages('title', overwrite: true); +``` + +### Custom Translation Prompts + +Add custom context for better translations: + +```php +$post = Post::find(1); + +// Translate with custom prompt +$translatedTitle = $post->translateAttribute( + 'title', + 'es', + overwrite: false, + extraPrompt: 'This is a blog post title about technology' +); +``` + +### Pushing Manual Translations + +Store translations manually: + +```php +$post = Post::find(1); + +// Store a manual translation +$post->pushTranslateAttribute('title', 'Hola Mundo', 'es'); + +// This bypasses automatic translation +``` + +## Synchronization + +### Automatic Sync + +The package automatically syncs translations when: + +- A model is created +- Translatable attributes are updated +- Languages are added or removed + +### Manual Sync + +Force synchronization: + +```php +$post = Post::find(1); + +// Sync all translations for this model +$post->syncTranslations(); + +// Sync with progress output +$post->syncTranslations($output); +``` + +### Bulk Sync + +Sync all models at once: + +```bash +php artisan translations:sync +``` + +## Working with Relationships + +### Eager Loading Translations + +Load translations efficiently: + +```php +// Load posts with their translations +$posts = Post::with('translatableAttributes')->get(); + +// Load specific language translations +$posts = Post::with(['translatableAttributes' => function ($query) { + $query->where('code', 'es'); +}])->get(); +``` + +### Translation Relationships + +Access translation relationships: + +```php +$post = Post::find(1); + +// Get all translations for this post +$translations = $post->translatableAttributes; + +// Get translations for specific language +$spanishTranslations = $post->translatableAttributes() + ->where('code', 'es') + ->get(); + +// Get translations for specific attribute +$titleTranslations = $post->translatableAttributes() + ->where('attribute', 'title') + ->get(); +``` + +## Performance Optimization + +### Caching + +Enable caching for better performance: + +```php +// config/translations.php +'use_permanent_cache' => true, +``` + +### Queued Operations + +Translation operations are automatically queued: + +```php +// This happens in the background +$post->translateAttributes('es'); +``` + +### Batch Operations + +Process multiple models efficiently: + +```php +// Translate multiple posts at once +$posts = Post::where('created_at', '>', now()->subDay())->get(); + +foreach ($posts as $post) { + $post->translateAttributesForAllLanguages(); +} +``` + +## Best Practices + +### Choose Translatable Attributes Wisely + +Only translate attributes that need translation: + +```php +public function getTranslatableAttributes(): array +{ + return [ + 'title', // ✅ User-facing content + 'content', // ✅ User-facing content + 'excerpt', // ✅ User-facing content + // 'id', // ❌ System data + // 'created_at', // ❌ System data + // 'user_id', // ❌ System data + ]; +} +``` + +### Use Appropriate Casts + +Define proper casts for translatable attributes: + +```php +protected $casts = [ + 'title' => 'string', + 'content' => 'string', + 'excerpt' => 'string', + 'meta_data' => 'array', // If storing JSON +]; +``` + + +## Troubleshooting + +### Common Issues + +**Translations not created**: Check that the model is registered in config AND uses the `HasTranslatableAttributes` trait + +**Missing translations**: Run `translations:sync` to fill missing translations + +**Performance issues**: Enable caching and use queued operations + +**Translation errors**: Check your translation provider configuration + +**Model not implementing contract**: Ensure your model implements `TranslatesAttributes` and uses `HasTranslatableAttributes` trait + +### Debugging + +Check translation status: + +```php +$post = Post::find(1); + +// Check if attribute is translatable +$isTranslatable = $post->isTranslatableAttribute('title'); + +// Get translation status +$translations = $post->translatableAttributes; +foreach ($translations as $translation) { + echo "{$translation->attribute}: {$translation->code} - {$translation->translated_at}"; +} +``` + +## Next Steps + +- [Commands](commands.md) - Complete command reference +- [Advanced Usage](advanced-usage.md) - Advanced features and customization diff --git a/packages/laravel-translations/docs/providers.md b/packages/laravel-translations/docs/providers.md new file mode 100644 index 00000000..82399da7 --- /dev/null +++ b/packages/laravel-translations/docs/providers.md @@ -0,0 +1,461 @@ +# Translation Providers + +The Laravel Translations package supports multiple translation providers. This guide covers configuration and usage of each provider. + +## Supported Providers + +- **Google Translate** - Free, no API key required +- **DeepL** - High-quality translations, requires API key +- **AI Providers** - OpenAI, Anthropic, and other AI services + +## Google Translate (Default) + +### Configuration + +Google Translate is the default provider and requires no additional configuration: + +```php +// config/translations.php +'translators' => [ + 'default' => 'google-translate', + 'drivers' => [ + 'google-translate' => [ + // No configuration needed + ], + ], +], +``` + +### Usage + +```bash +# Set as default in .env +TRANSLATION_DRIVER=google-translate + +# Or use directly +php artisan translations:translate +``` + +### Features + +- ✅ No API key required +- ✅ Supports 100+ languages +- ✅ Fast translation +- ✅ Free to use +- ❌ Lower translation quality +- ❌ Rate limits may apply + +## DeepL + +### Configuration + +DeepL provides high-quality translations but requires an API key: + +```php +// config/translations.php +'translators' => [ + 'default' => 'deep-l', + 'drivers' => [ + 'deep-l' => [ + 'options' => [ + TranslatorOptions::SERVER_URL => env('DEEPL_SERVER_URL', 'https://api.deepl.com/'), + ], + ], + ], +], +``` + +### Environment Variables + +```env +# .env +TRANSLATION_DRIVER=deep-l +DEEPL_API_KEY=your_deepl_api_key +DEEPL_SERVER_URL=https://api.deepl.com/ # or https://api-free.deepl.com/ for free tier +``` + +### Usage + +```bash +# Set environment variables +export DEEPL_API_KEY=your_api_key +export DEEPL_SERVER_URL=https://api.deepl.com/ + +# Run translations +php artisan translations:translate +``` + +### Features + +- ✅ High-quality translations +- ✅ Supports 30+ languages +- ✅ Context-aware translation +- ✅ Professional results +- ❌ Requires API key +- ❌ Usage limits (free tier: 500k chars/month) + +### API Key Setup + +1. **Sign up** at [DeepL API](https://www.deepl.com/pro-api) +2. **Get API key** from your account +3. **Choose server**: + - `https://api.deepl.com/` - Pro account + - `https://api-free.deepl.com/` - Free account +4. **Set environment variables** + +## AI Providers + +### Configuration + +AI providers offer flexible, context-aware translations: + +```php +// config/translations.php +'translators' => [ + 'default' => 'ai', + 'drivers' => [ + 'ai' => [ + 'provider' => Provider::OpenAI, + 'model' => 'gpt-4.1', + 'system_prompt' => 'You translate Laravel translations strings to the language you have been asked.', + ], + ], +], +``` + +### Supported Providers + +#### OpenAI + +```env +# .env +TRANSLATION_DRIVER=ai +OPENAI_API_KEY=your_openai_api_key +``` + +```php +'ai' => [ + 'provider' => Provider::OpenAI, + 'model' => 'gpt-4.1', // or 'gpt-3.5-turbo' + 'system_prompt' => 'You are a professional translator...', +], +``` + +#### Anthropic (Claude) + +```env +# .env +ANTHROPIC_API_KEY=your_anthropic_api_key +``` + +```php +'ai' => [ + 'provider' => Provider::Anthropic, + 'model' => 'claude-3-sonnet-20240229', + 'system_prompt' => 'You are a professional translator...', +], +``` + +### Custom AI Providers + +Create custom AI providers: + +```php +// config/translations.php +'ai' => [ + 'provider' => 'custom', + 'model' => 'custom-model', + 'api_key' => env('CUSTOM_AI_API_KEY'), + 'endpoint' => env('CUSTOM_AI_ENDPOINT'), + 'system_prompt' => 'Custom translation prompt...', +], +``` + +### Features + +- ✅ Context-aware translations +- ✅ Customizable prompts +- ✅ High-quality results +- ✅ Supports any language +- ❌ Requires API key +- ❌ Higher cost +- ❌ Slower than other providers + +## Provider Comparison + +| Feature | Google Translate | DeepL | AI Providers | +|---------|------------------|-------|--------------| +| **Cost** | Free | Paid | Paid | +| **Quality** | Good | Excellent | Excellent | +| **Speed** | Fast | Fast | Slow | +| **Languages** | 100+ | 30+ | Any | +| **Context** | Basic | Good | Excellent | +| **Setup** | None | API Key | API Key | + +## Switching Providers + +### Runtime Switching + +Change provider at runtime: + +```php +// Using the facade +use Backstage\Translations\Laravel\Facades\Translator; + +Translator::with('deep-l')->translate('Hello', 'es'); + +// Or using the contract directly +use Backstage\Translations\Laravel\Contracts\TranslatorContract; + +app(TranslatorContract::class)->with('deep-l')->translate('Hello', 'es'); +``` + +### Configuration Switching + +Change default provider: + +```php +// config/translations.php +'translators' => [ + 'default' => 'deep-l', // Change this + // ... +], +``` + +### Environment Switching + +Use different providers for different environments: + +```env +# .env.local +TRANSLATION_DRIVER=google-translate + +# .env.production +TRANSLATION_DRIVER=deep-l +``` + +## Custom Providers + +### Creating Custom Providers + +Create a custom translation provider: + +```php +callCustomAPI($text, $targetLanguage, $sourceLanguage); + } + + private function callCustomAPI(string $text, string $targetLanguage, string $sourceLanguage): string + { + // Implementation + } +} +``` + +### Registering Custom Providers + +Register your custom provider: + +```php +// In a service provider +public function register() +{ + $this->app->bind(TranslatorContract::class, function ($app) { + return new CustomTranslator(); + }); +} +``` + +## Error Handling + +### Provider Errors + +Handle provider-specific errors: + +```php +use Backstage\Translations\Laravel\Facades\Translator; + +try { + $translation = Translator::translate('Hello', 'es'); +} catch (GoogleTranslateException $e) { + // Handle Google Translate errors + Log::error('Google Translate error: ' . $e->getMessage()); +} catch (DeepLException $e) { + // Handle DeepL errors + Log::error('DeepL error: ' . $e->getMessage()); +} catch (AIProviderException $e) { + // Handle AI provider errors + Log::error('AI provider error: ' . $e->getMessage()); +} +``` + +### Fallback Providers + +Set up fallback providers: + +```php +// config/translations.php +'translators' => [ + 'default' => 'deep-l', + 'fallback' => 'google-translate', // Fallback if primary fails + 'drivers' => [ + 'deep-l' => [...], + 'google-translate' => [...], + ], +], +``` + +## Performance Optimization + +### Caching + +Enable caching for better performance: + +```php +// config/translations.php +'use_permanent_cache' => true, +``` + +### Rate Limiting + +Handle rate limits gracefully: + +```php +// For DeepL +'deep-l' => [ + 'options' => [ + TranslatorOptions::SERVER_URL => env('DEEPL_SERVER_URL'), + 'rate_limit' => 500, // requests per minute + ], +], +``` + +### Batch Processing + +Process translations in batches: + +```php +use Backstage\Translations\Laravel\Facades\Translator; + +// Translate multiple strings at once +$translations = Translator::translateBatch([ + 'Hello' => 'es', + 'World' => 'es', + 'Laravel' => 'es', +]); +``` + +## Monitoring and Logging + +### Translation Logging + +Log translation activities: + +```php +// In your service provider +Event::listen(TranslationCompleted::class, function ($event) { + Log::info('Translation completed', [ + 'provider' => $event->provider, + 'source' => $event->sourceLanguage, + 'target' => $event->targetLanguage, + 'text' => $event->text, + 'translation' => $event->translation, + ]); +}); +``` + +### Performance Monitoring + +Monitor translation performance: + +```php +// Track translation times +$start = microtime(true); +$translation = app('translator')->translate('Hello', 'es'); +$time = microtime(true) - $start; + +Log::info('Translation performance', [ + 'provider' => 'deep-l', + 'time' => $time, + 'text_length' => strlen('Hello'), +]); +``` + +## Best Practices + +### 1. Choose the Right Provider + +- **Development**: Google Translate (free, fast) +- **Production**: DeepL (quality, reliability) +- **Specialized**: AI providers (context, customization) + +### 2. Handle Errors Gracefully + +```php +use Backstage\Translations\Laravel\Facades\Translator; + +try { + $translation = Translator::translate($text, $language); +} catch (Exception $e) { + // Fallback to original text + $translation = $text; + Log::warning('Translation failed, using original text'); +} +``` + +### 3. Cache Translations + +Enable caching to avoid re-translating the same text: + +```php +'use_permanent_cache' => true, +``` + +### 4. Monitor Usage + +Track API usage and costs: + +```php +// Log API usage +Log::info('Translation API usage', [ + 'provider' => 'deep-l', + 'characters' => strlen($text), + 'cost' => $this->calculateCost($text), +]); +``` + +## Troubleshooting + +### Common Issues + +**API key errors**: Check your API key configuration + +**Rate limit errors**: Implement rate limiting or switch providers + +**Translation quality**: Try different providers or adjust prompts + +**Performance issues**: Enable caching and optimize batch processing + +### Debug Mode + +Enable debug mode for detailed logging: + +```env +APP_DEBUG=true +LOG_LEVEL=debug +``` + +## Next Steps + +- [Configuration](configuration.md) - Complete configuration options +- [Advanced Usage](advanced-usage.md) - Advanced features and customization diff --git a/packages/laravel-translations/package-lock.json b/packages/laravel-translations/package-lock.json new file mode 100644 index 00000000..29589b11 --- /dev/null +++ b/packages/laravel-translations/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "laravel-translations", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/packages/laravel-translations/phpstan-baseline.neon b/packages/laravel-translations/phpstan-baseline.neon new file mode 100644 index 00000000..e69de29b diff --git a/packages/laravel-translations/phpstan.neon.dist b/packages/laravel-translations/phpstan.neon.dist new file mode 100644 index 00000000..ab1b4c30 --- /dev/null +++ b/packages/laravel-translations/phpstan.neon.dist @@ -0,0 +1,12 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 5 + paths: + - src + - config + - database + tmpDir: build/phpstan + checkOctaneCompatibility: true + checkModelProperties: true diff --git a/packages/laravel-translations/phpunit.xml.dist b/packages/laravel-translations/phpunit.xml.dist new file mode 100644 index 00000000..e396df83 --- /dev/null +++ b/packages/laravel-translations/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + tests + + + + + + + + ./src + + + diff --git a/packages/laravel-translations/src/Base/TranslationLoader.php b/packages/laravel-translations/src/Base/TranslationLoader.php new file mode 100644 index 00000000..53dbf199 --- /dev/null +++ b/packages/laravel-translations/src/Base/TranslationLoader.php @@ -0,0 +1,56 @@ +where('namespace', $namespace); + } + + if ($group !== '*') { + $translations->where('group', $group); + } + + return $translations->where(fn ($query) => $query->where('code', 'LIKE', $locale . '_%')->orWhere('code', $locale)) + ->pluck('text', 'key') + ->toArray(); + } + + protected static function checkTableExists(): bool + { + static $exists = null; + + if ($exists !== null) { + return $exists; + } + + $table = (new Translation)->getTable(); + + if (! app()->isProduction()) { + return $exists = Schema::hasTable($table); + } + + return $exists = Cache::remember('translations:table_exists', 3600, fn () => Schema::hasTable($table)); + } +} diff --git a/packages/laravel-translations/src/Caches/TranslationStringsCache.php b/packages/laravel-translations/src/Caches/TranslationStringsCache.php new file mode 100644 index 00000000..94c48460 --- /dev/null +++ b/packages/laravel-translations/src/Caches/TranslationStringsCache.php @@ -0,0 +1,45 @@ +select(['code', 'group', 'namespace', 'key', 'text']) + ->get(); + + return $translations + ->groupBy('code') + ->map(function ($byLocale) { + return $byLocale->groupBy(fn ($row) => $row->group ?? '*') + ->map(function ($byGroup) { + return $byGroup->groupBy(fn ($row) => $row->namespace) + ->map(function ($byNamespace) { + return $byNamespace->mapWithKeys(fn ($row) => [ + $row->key => $row->text, + ]); + }); + }); + }) + ->toArray(); + } + + public static function schedule($callback) + { + return $callback->everyTenMinutes(); // Adjust as needed + } +} diff --git a/packages/laravel-translations/src/Commands/SyncTranslations.php b/packages/laravel-translations/src/Commands/SyncTranslations.php new file mode 100644 index 00000000..b352a18f --- /dev/null +++ b/packages/laravel-translations/src/Commands/SyncTranslations.php @@ -0,0 +1,150 @@ +getTranslatableItems(); + + info("Found {$items->count()} translatable items to sync."); + + $this->newLine(); + + $this->syncItems($items); + + $this->newLine(); + + info('Translations synced successfully.'); + + note('Cleaning unused translations...'); + + $orphans = static::getOrphanedAttributes(); + + if ($orphans->isEmpty()) { + note('No unused translations found.'); + + return; + } else { + static::cleanOrphanedTranslations($orphans); + } + } + + protected function getTranslatableItems(): Collection + { + $models = collect(config('translations.eloquent.translatable-models', [])); + + return $models + ->flatMap(fn (string $model) => $model::all()) + ->filter(fn ($item) => $item instanceof TranslatesAttributes); + } + + protected function syncItems(Collection $items): void + { + if ($items->isEmpty()) { + info('No translatable items found to sync.'); + + return; + } + + try { + $itemsCount = $items + ->filter(function (TranslatesAttributes | Model $item) { + $translations = $item->translatableAttributes()->count(); + + if ($translations === count($item->getTranslatableAttributes())) { + return false; + } + + return true; + }) + ->map(fn (TranslatesAttributes | Model $item) => count($item->getTranslatableAttributes())) + ->sum(); + + $this->output->progressStart($itemsCount); + + $items->each(function (TranslatesAttributes | Model $item) { + try { + $item->syncTranslations($this->output); + } catch (\Throwable $e) { + info("Failed to sync translations for item: {$item->getKey()} - {$e->getMessage()}"); + } + }); + } catch (\Throwable $e) { + info('Payload is too large, syncing items one by one.'); + + progress('Syncing translatable items', $items, function (TranslatesAttributes | Model $item) { + try { + $item->syncTranslations($this->output); + } catch (\Throwable $e) { + info("Failed to sync translations for item: {$item->getKey()} - {$e->getMessage()}"); + } + }); + } + } + + protected static function cleanOrphanedTranslations($orphans): void + { + progress('Deleting unused translations', $orphans, function (TranslatedAttribute $attr) { + $attr->forceDelete(); + }); + } + + protected static function getOrphanedAttributes(): Collection + { + return TranslatedAttribute::query() + ->get() + ->filter(function (TranslatedAttribute $attr) { + /** + * @var \Illuminate\Database\Eloquent\Model $type + */ + $type = $attr->translatable_type; + + $id = $attr->translatable_id; + + if (! class_exists($type) && Relation::getMorphedModel($type) === null) { + return true; + } + + $model = $attr->translatable()->get()->first(); + + $attribute = $attr->attribute; + + if (! method_exists($model, 'translatableAttributes')) { + return true; + } + + if (! in_array($attribute, $model->getTranslatableAttributes())) { + return true; + } + + /** + * @var \Illuminate\Database\Eloquent\Builder $query + */ + $query = get_class($model)::query(); + + if (in_array(SoftDeletes::class, class_uses_recursive($model))) { + $query->withTrashed(); + } + + return ! $query->whereKey($id)->exists(); + }); + } +} diff --git a/packages/laravel-translations/src/Commands/TranslateTranslations.php b/packages/laravel-translations/src/Commands/TranslateTranslations.php new file mode 100644 index 00000000..bfb60954 --- /dev/null +++ b/packages/laravel-translations/src/Commands/TranslateTranslations.php @@ -0,0 +1,68 @@ +option('code'); + + if ($this->option('update')) { + $translations = Translation::all(); + + if ($languageCode) { + $translations = Translation::where('code', $languageCode)->get(); + + if ($translations->isEmpty()) { + $this->fail("No translations found with the code: {$languageCode}"); + + return; + } + } + + $translations->each(function ($translation) { + $translation->update([ + 'translated_at' => null, + ]); + }); + } + + $languageCode ? $this->handleLanguage($languageCode) : $this->handleAllLanguages(); + } + + protected function handleAllLanguages(): void + { + $this->info('Translating imports for all languages...'); + + TranslateKeys::dispatchSync(); + + $this->info('All languages processed successfully.'); + } + + protected function handleLanguage(?string $code): void + { + $language = Language::where('code', $code)->first(); + + if (! $language) { + $this->fail("Language {$code} not found."); + } + + $this->info("Translating imports for language: {$language->localizedLanguageName}..."); + + TranslateKeys::dispatchSync($language); + + $this->info("Language {$language->localizedLanguageName} processed successfully."); + } +} diff --git a/packages/laravel-translations/src/Commands/TranslationsAddLanguage.php b/packages/laravel-translations/src/Commands/TranslationsAddLanguage.php new file mode 100644 index 00000000..da474061 --- /dev/null +++ b/packages/laravel-translations/src/Commands/TranslationsAddLanguage.php @@ -0,0 +1,42 @@ +argument('code') ?? text('Enter language code'); + $name = $this->argument('name') ?? text('Enter name'); + + $this->createLanguage($code, $name); + } + + protected function createLanguage($code, $name) + { + $language = Language::where('code', $code)->first(); + + if ($language) { + $this->info("Language $code already exists"); + + return Command::INVALID; + } + + Language::create([ + 'code' => $code, + 'name' => $name, + 'native' => localized_language_name($name), + ]); + + $this->info("Language $name ($code) added."); + } +} diff --git a/packages/laravel-translations/src/Commands/TranslationsScan.php b/packages/laravel-translations/src/Commands/TranslationsScan.php new file mode 100644 index 00000000..6ceacf11 --- /dev/null +++ b/packages/laravel-translations/src/Commands/TranslationsScan.php @@ -0,0 +1,30 @@ + app()->getLocale(), + 'name' => localized_language_name(app()->getLocale()), + 'native' => localized_language_name(app()->getLocale()), + 'active' => true, + ]); + } + + Language::active()->get()->each(function (Language $language) { + ScanTranslationStrings::dispatchSync($language); + }); + } +} diff --git a/packages/laravel-translations/src/Contracts/TranslatesAttributes.php b/packages/laravel-translations/src/Contracts/TranslatesAttributes.php new file mode 100644 index 00000000..fd20d18a --- /dev/null +++ b/packages/laravel-translations/src/Contracts/TranslatesAttributes.php @@ -0,0 +1,147 @@ + Associative array of translated attributes. + * + * @see \Backstage\Translations\Laravel\Domain\Translatables\Actions\TranslateAttributes + */ + public function translateAttributes(?string $targetLanguage = null): array; + + /** + * Translate all translatable attributes to all available languages. + * + * @return array> Attribute name => [locale => translation]. + * + * @see \Backstage\Translations\Laravel\Domain\Translatables\Actions\TranslateAttributesForAllLanguages + */ + public function translateAttributesForAllLanguages(): array; + + /** + * Translate a specific attribute to all available languages. + * + * @param string $attribute The attribute to translate. + * @param bool $overwrite Whether to overwrite existing translations. + * @return array Translations keyed by locale. + * + * @see \Backstage\Translations\Laravel\Domain\Translatables\Actions\TranslateAttributeForAllLanguages + */ + public function translateAttributeForAllLanguages(string $attribute, bool $overwrite = false): array; + + /** + * Translate a specific attribute to a target language. + * + * @param string $attribute The attribute to translate. + * @param string $targetLanguage ISO 639-1 code. + * @param bool $overwrite Whether to overwrite existing translation. + * @return mixed The translated value. + * + * @see \Backstage\Translations\Laravel\Domain\Translatables\Actions\TranslateAttribute + */ + public function translateAttribute(mixed $attribute, string $targetLanguage, bool $overwrite = false): mixed; + + /** + * Persist a translation for a given attribute and locale. + * + * @param string $attribute The attribute name. + * @param string $translation The translated value. + * @param string $locale The locale code (ISO 639-1). + * + * @see \Backstage\Translations\Laravel\Domain\Translatables\Actions\PushTranslatedAttribute + */ + public function pushTranslateAttribute(string $attribute, string $translation, string $locale): void; + + /** + * Retrieve the translation of a specific attribute for a locale. + * + * @param string $attribute The attribute name. + * @param string|null $locale Locale to fetch translation for, or null for fallback/default. + * @return mixed The translated value or null if not found. + * + * @see \Backstage\Translations\Laravel\Domain\Translatables\Actions\GetTranslatedAttribute + */ + public function getTranslatedAttribute(string $attribute, ?string $locale = null): mixed; + + /** + * Get all translated attributes for the model in a specific locale. + * + * @param string|null $locale Locale to fetch translations for, or null for default. + * @return array Associative array of translated attributes. + * + * @see \Backstage\Translations\Laravel\Domain\Translatables\Actions\GetTranslatedAttributes + */ + public function getTranslatedAttributes(?string $locale = null): array; + + /** + * Return the list of attributes marked as translatable. + * + * @return array List of attribute names. + */ + public function getTranslatableAttributes(): array; + + /** + * Check if an attribute is translatable. + * + * @param string $attribute Attribute to check. + * @return bool True if translatable, false otherwise. + * + * @see \Backstage\Translations\Laravel\Domain\Translatables\Actions\IsTranslatableAttribute + */ + public function isTranslatableAttribute(string $attribute): bool; + + /** + * MorphMany relationship with the translated attributes table. + * + * @return MorphMany Eloquent relationship. + */ + public function translatableAttributes(): MorphMany; + + /** + * Sync translations, typically after creation or update. + * + * + * @see \Backstage\Translations\Laravel\Domain\Translatables\Actions\SyncTranslations + */ + public function syncTranslations(?OutputStyle $output = null): void; + + /** + * Update multiple translate attributes. + * + * @param array $attributes Attributes to update. + * + * @see \Backstage\Translations\Laravel\Domain\Translatables\Actions\UpdateTranslateAttributes + */ + public function updateTranslateAttributes(array $attributes): void; + + /** + * Update only attributes that are translatable. + * + * @param array $translatableAttributes Attribute names to update. + * + * @see \Backstage\Translations\Laravel\Domain\Translatables\Actions\UpdateAttributesIfTranslatable + */ + public function updateAttributesIfTranslatable(array $translatableAttributes): void; + + /** + * Get the translation rules for a specific attribute. + * + * @param string $attribute Attribute name. + * @return array|string Rules array or '*' string for all. + */ + public function getTranslatableAttributeRulesFor(string $attribute): array | string; +} diff --git a/packages/laravel-translations/src/Contracts/TranslatorContract.php b/packages/laravel-translations/src/Contracts/TranslatorContract.php new file mode 100644 index 00000000..6ac67b4c --- /dev/null +++ b/packages/laravel-translations/src/Contracts/TranslatorContract.php @@ -0,0 +1,8 @@ +translate($text); + + $detected = $tr->getLastDetectedSource(); + + if (! $detected) { + throw new \Exception('Language detection failed for text: ' . $text); + } + + return $detected; + } +} diff --git a/packages/laravel-translations/src/Domain/Scanner/Actions/FindTranslatables.php b/packages/laravel-translations/src/Domain/Scanner/Actions/FindTranslatables.php new file mode 100644 index 00000000..c35f2b64 --- /dev/null +++ b/packages/laravel-translations/src/Domain/Scanner/Actions/FindTranslatables.php @@ -0,0 +1,145 @@ +in(config('translations.scan.paths')) + ->name(config('translations.scan.extensions')) + ->files(); + + /** + * @var \Illuminate\Support\Collection $functions + */ + $functions = collect(config('translations.scan.functions')); + + /** + * @var string $pattern + */ + $pattern = + '[^\w]' . + '(?)' . // Ignore method chaining + '(?:' . implode('|', $functions->toArray()) . ')' . + '\(\s*' . + '(?:' . + "'((?:[^'\\\\]|\\\\.)+)'" . // Match single-quoted keys + '|' . + '`((?:[^`\\\\]|\\\\.)+)`' . // Match backtick-quoted keys + '|' . + '"((?:[^"\\\\]|\\\\.)+)"' . // Match double-quoted keys + '|' . + '(\$[a-zA-Z_][a-zA-Z0-9_]*)' . // Match variables + ')' . + '\s*' . + '(?:,([^)]*))?' . // Capture second argument (parameters) + '\s*' . + '[\),]'; + + foreach ($finder as $file) { + if (preg_match_all("/$pattern/siU", $file->getContents(), $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + /** + * @var string $key + */ + $key = $match[1] ?: $match[2] ?: $match[3] ?: $match[4]; + + /** + * @var string|null $params + */ + $params = $match[5] ?? null; + + static::addMatch($file, $key, $params); + } + } + } + + return collect(static::$allMatches) + ->collapse() + ->map(fn ($match) => [ + 'key' => $match['key'], + 'namespace' => $match['namespace'], + 'group' => $match['group'], + 'text' => __($match['key']), + 'params' => $match['params'], + ]) + ->when($mergeKeys, fn ($collection) => $this->mergeExistingKeys($collection)); + } + + protected static function addMatch($file, $key, $params = null): void + { + /** + * @var string $namespace + */ + $namespace = static::extractNamespace($key); + + /** + * @var string $group + */ + $group = static::extractGroup($key); + + static::$allMatches[$file->getRelativePathname()][] = [ + 'key' => $key, + 'namespace' => $namespace, + 'group' => $group, + 'params' => $params, + ]; + } + + protected static function extractNamespace(string $key): ?string + { + /** + * @var string|null $string + */ + $string = Str::contains($key, '::') ? explode('::', $key)[0] : null; + + return $string; + } + + protected static function extractGroup(string $key): ?string + { + /** + * @var string|null $string + */ + $string = Str::contains($key, '.') ? explode('.', $key)[0] : null; + + return $string; + } + + protected static function mergeExistingKeys(Collection $newKeys): Collection + { + /** + * @var \Illuminate\Support\Collection $existingKeys + */ + $existingKeys = collect(json_decode(File::get(static::$baseFilename), true) ?? []); + + /** + * @var \Illuminate\Support\Collection $unionAble + */ + $unionAble = $newKeys->filter(fn ($key) => ! $existingKeys->has($key)); + + /** + * @var \Illuminate\Support\Collection $union + */ + $union = $existingKeys->union($unionAble); + + return $union; + } +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/GetTranslatedAttribute.php b/packages/laravel-translations/src/Domain/Translatables/Actions/GetTranslatedAttribute.php new file mode 100644 index 00000000..f87abfa5 --- /dev/null +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/GetTranslatedAttribute.php @@ -0,0 +1,66 @@ +getTranslatableAttributes()) || $locale === env('APP_LOCALE')) { + return $model->getAttribute($attribute); + } + + /** + * @var string $locale + */ + $locale = $locale ?: env('APP_LOCALE'); + + /** + * @var mixed $value + */ + $value = $model->translatableAttributes() + ->where('attribute', $attribute) + ->where('code', $locale) + ->first() + ?->translated_attribute ?? null; + + if (is_null($value)) { + return $model->getAttribute($attribute); + } + + /** + * @var mixed $resultingAttribute + */ + $resultingAttribute = static::getMutatedAttribute($model, $attribute, $value); + + return $resultingAttribute; + } + + public static function getMutatedAttribute(Model $model, $resultingAttribute, $resultingAttributeValue): mixed + { + $model->setRawAttributes([$resultingAttribute => $resultingAttributeValue] + $model->getAttributes()); + + return $model->getAttribute($resultingAttribute); + } + + public static function getReversedMutatedAttribute(Model $model, string $key, mixed $value): mixed + { + /** + * @var Model $clonedModel + */ + $clonedModel = clone $model; + + $clonedModel->setAttribute($key, $value); + + return $clonedModel->getAttributes()[$key] ?? null; + } +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/GetTranslatedAttributes.php b/packages/laravel-translations/src/Domain/Translatables/Actions/GetTranslatedAttributes.php new file mode 100644 index 00000000..d18ba4ba --- /dev/null +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/GetTranslatedAttributes.php @@ -0,0 +1,38 @@ +getTranslatableAttributes(); + + /** + * @var array $translatedAttributes + */ + $translatedAttributes = collect($translatableAttributes) + ->values() + ->mapWithKeys(function ($attribute) use ($model, $locale) { + return $model->getTranslatedAttribute($attribute, $locale) !== null + ? [$attribute => $model->getTranslatedAttribute($attribute, $locale)] + : []; + })->toArray(); + + return array_merge( + $translatedAttributes + ); + } +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/IsTranslatableAttribute.php b/packages/laravel-translations/src/Domain/Translatables/Actions/IsTranslatableAttribute.php new file mode 100644 index 00000000..462b4aa5 --- /dev/null +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/IsTranslatableAttribute.php @@ -0,0 +1,30 @@ +getTranslatedAttributes(); + + /** + * @var bool $isTranslatableAttribute + */ + $isTranslatableAttribute = in_array($attribute, $translatableAttrbiutes); + + return $isTranslatableAttribute; + } +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/PushTranslatedAttribute.php b/packages/laravel-translations/src/Domain/Translatables/Actions/PushTranslatedAttribute.php new file mode 100644 index 00000000..7d815b19 --- /dev/null +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/PushTranslatedAttribute.php @@ -0,0 +1,71 @@ +where('code', $locale)->exists(); + + if (! $localExists) { + Language::query()->create([ + 'code' => $locale, + 'name' => Locale::getDisplayName($locale), + ]); + } + + /** + * @var mixed $reverseMutatedAttributeValue + */ + $reverseMutatedAttributeValue = GetTranslatedAttribute::getReversedMutatedAttribute( + $model, + $attribute, + $translation + ); + + if (is_array($reverseMutatedAttributeValue)) { + $reverseMutatedAttributeValue = json_encode($reverseMutatedAttributeValue, JSON_UNESCAPED_UNICODE); + } + + if (env('APP_LOCALE') === $locale) { + static::modifyOriginalAttributeValue($model, $attribute, $reverseMutatedAttributeValue); + } else { + static::modifyTranslatedAttributeValue($model, $attribute, $reverseMutatedAttributeValue, $locale); + } + } + + public static function modifyTranslatedAttributeValue(Model $model, string $attribute, mixed $reverseMutatedAttributeValue, string $locale): void + { + $model->translatableAttributes()->updateOrCreate([ + 'translatable_type' => get_class($model), + 'translatable_id' => $model->getKey(), + 'attribute' => $attribute, + 'code' => $locale, + ], [ + 'translated_attribute' => $reverseMutatedAttributeValue, + 'translated_at' => now(), + ]); + } + + public static function modifyOriginalAttributeValue(Model $model, string $attribute, mixed $value): void + { + $model->updateQuietly([ + $attribute => $value, + ]); + } +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/SyncTranslations.php b/packages/laravel-translations/src/Domain/Translatables/Actions/SyncTranslations.php new file mode 100644 index 00000000..339caab9 --- /dev/null +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/SyncTranslations.php @@ -0,0 +1,31 @@ +getTranslatableAttributes(); + + foreach ($designatedAttributes as $attribute) { + $model->translateAttributeForAllLanguages($attribute); + + if ($output) { + $output->progressAdvance(); + } + } + } +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttribute.php b/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttribute.php new file mode 100644 index 00000000..d8d1d26f --- /dev/null +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttribute.php @@ -0,0 +1,258 @@ +getAttribute($attribute); + + if ( + ! $overwrite && $model->translatableAttributes() + ->getQuery() + ->where('attribute', $attribute) + ->where('code', $targetLanguage) + ->exists() + ) { + return $model->getTranslatedAttribute($attribute, $targetLanguage); + } + + /** + * @var mixed $attributeValue + */ + $attributeValue = $model->getAttribute($attribute); + + $attributeValue = json_decode($attributeValue, true) ?? $attributeValue; + /** + * @var mixed $translated + */ + $translated = is_array($attributeValue) ? static::translateArray($model, $attributeValue, $attribute, $targetLanguage, $extraPrompt) : static::translate($attributeValue, $targetLanguage, $extraPrompt); + + if ($translated === null) { + $translated = $model->translatableAttributes() + ->where('attribute', $attribute) + ->where('code', $targetLanguage) + ->value('translated_attribute'); + } + + $model->pushTranslateAttribute($attribute, $translated, $targetLanguage); + + return $translated ?: $originalValue; + } + + protected static function translate(mixed $value, string $targetLanguage, ?string $extraPrompt = null): mixed + { + if (is_string($value) || is_numeric($value)) { + /** + * @var mixed $translated + */ + try { + $translated = Translator::with(config('translations.translators.default'))->translate($value, $targetLanguage, $extraPrompt); + } catch (\Throwable $e) { + $avaiableDrivers = collect(config('translations.translators.drivers', [])) + ->filter(function ($x, $driver) { + $textQuery = 'Test query'; + + try { + $translation = Translator::with($driver)->translate($textQuery, 'ru'); + + if ($translation === $textQuery) { + return false; + } + + return true; + } catch (\Throwable $e) { + return false; + } + }) + ->keys(); + + if ($avaiableDrivers->isEmpty()) { + info('No available translation drivers found.'); + + return $value; + } + + info('Translation failed, using default driver.'); + $translated = Translator::with($avaiableDrivers->first())->translate($value, $targetLanguage, $extraPrompt); + } + + return $translated; + } + + return $value; + } + + /** + * Translate an array of attributes. + * + * @param string|array $rules + */ + public static function translateArray(?TranslatesAttributes $model, array $data, ?string $attribute, string $targetLanguage, $rules = null, ?string $extraPrompt = null): array + { + $rules = $model?->getTranslatableAttributeRulesFor($attribute ?? throw new \InvalidArgumentException('Attribute is required')) ?? $rules; + + if (in_array('*', $rules, true)) { + return static::translateAllStringsInArray($data, $targetLanguage, $extraPrompt); + } + + collect($rules) + ->filter(fn ($rule) => str_starts_with($rule, '!')) + ->map(fn ($rule) => ltrim($rule, '!')) + ->each(fn ($key) => \Illuminate\Support\Arr::forget($data, $key)); + + collect($rules) + ->filter(fn ($rule) => str_starts_with($rule, '*')) + ->each(function ($rule) use (&$data, $targetLanguage, $extraPrompt) { + $key = ltrim($rule, '*'); + $data = static::translateAllKeysValuesFor($data, $key, $targetLanguage, $extraPrompt); + }); + + collect($rules) + ->reject(fn ($rule) => str_starts_with($rule, '!')) + ->reject(fn ($rule) => str_starts_with($rule, '*')) + ->each(function ($path) use (&$data, $targetLanguage, $extraPrompt) { + $segments = explode('.', $path); + if (count($segments) === 1) { + $data = static::translateAllByKey($data, $segments[0], $targetLanguage, $extraPrompt); + } else { + $data = static::translatePath($data, $segments, $targetLanguage, $extraPrompt); + } + }); + + return $data; + } + + protected static function translateAllKeysValuesFor(array $data, string $targetKey, string $targetLanguage, ?string $extraPrompt = null): array + { + foreach ($data as $key => $value) { + if ($key === $targetKey && is_array($value)) { + foreach ($value as $innerKey => $innerValue) { + if (is_string($innerValue) || is_numeric($innerValue)) { + $value[$innerKey] = static::translate($innerValue, $targetLanguage, $extraPrompt); + } elseif (is_array($innerValue)) { + $value[$innerKey] = static::translateAllStringsInArray($innerValue, $targetLanguage, $extraPrompt); + } + } + + $data[$key] = $value; + } elseif (is_array($value)) { + $data[$key] = static::translateAllKeysValuesFor($value, $targetKey, $targetLanguage, $extraPrompt); + } + } + + return $data; + } + + protected static function translateAllByKey(array $data, string $key, string $targetLanguage, ?string $extraPrompt = null): array + { + return collect($data)->map(function ($value, $k) use ($key, $targetLanguage, $extraPrompt) { + if (is_array($value)) { + return static::translateAllByKey($value, $key, $targetLanguage, $extraPrompt); + } + + if ($k === $key && (is_string($value) || is_numeric($value))) { + return static::translate($value, $targetLanguage, $extraPrompt); + } + + return $value; + })->toArray(); + } + + protected static function translateKeyAtRoot(array $data, string $key, string $targetLanguage, ?string $extraPrompt = null): array + { + if (! array_key_exists($key, $data)) { + return $data; + } + + /** + * @var mixed $value + */ + $value = $data[$key]; + + if (! is_string($value) && ! is_numeric($value)) { + return $data; + } + + $data[$key] = static::translate($value, $targetLanguage, $extraPrompt); + + return $data; + } + + protected static function translatePath(array $data, array $segments, string $targetLanguage, ?string $extraPrompt = null): array + { + if ($segments === []) { + return $data; + } + + $segment = array_shift($segments); + + if ($segment === '*') { + foreach ($data as $key => $item) { + if (is_array($item)) { + $data[$key] = static::translatePath($item, $segments, $targetLanguage, $extraPrompt); + } else { + if (is_string($item) || is_numeric($item)) { + $data[$key] = static::translate($item, $targetLanguage, $extraPrompt); + } else { + continue; + } + } + } + + return $data; + } + + if (! array_key_exists($segment, $data)) { + return $data; + } + + if ($segments === []) { + /** + * @var mixed $value + */ + $value = $data[$segment]; + + if (is_string($value) || is_numeric($value)) { + $data[$segment] = static::translate($value, $targetLanguage, $extraPrompt); + } + + return $data; + } + + if (is_array($data[$segment])) { + $data[$segment] = static::translatePath($data[$segment], $segments, $targetLanguage, $extraPrompt); + } + + return $data; + } + + protected static function translateAllStringsInArray(array $data, string $targetLanguage, ?string $extraPrompt = null): array + { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = static::translateAllStringsInArray($value, $targetLanguage, $extraPrompt); + + continue; + } + + if (! is_string($value) && ! is_numeric($value)) { + continue; + } + + $data[$key] = static::translate($value, $targetLanguage, $extraPrompt); + } + + return $data; + } +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttributeForAllLanguages.php b/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttributeForAllLanguages.php new file mode 100644 index 00000000..a7332eb8 --- /dev/null +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttributeForAllLanguages.php @@ -0,0 +1,39 @@ +isEmpty()) { + throw new \RuntimeException('No languages available for translation.'); + + return []; + } + + /** + * @var \Illuminate\Database\Eloquent\Collection $translations + */ + $translations = $languages->mapWithKeys(function (Language $language) use ($attribute, $overwrite, $model) { + return [$language->code => $model->translateAttribute($attribute, $language->code, $overwrite)]; + }); + + return $translations->toArray(); + } +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttributes.php b/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttributes.php new file mode 100644 index 00000000..2aaeafae --- /dev/null +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttributes.php @@ -0,0 +1,35 @@ +getTranslatableAttributes() as $attribute) { + $translatedAttributes[$attribute] = $model->translateAttribute($attribute, $targetLanguage); + } + + return $translatedAttributes; + } +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttributesForAllLanguages.php b/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttributesForAllLanguages.php new file mode 100644 index 00000000..dd3ed44b --- /dev/null +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/TranslateAttributesForAllLanguages.php @@ -0,0 +1,33 @@ +mapWithKeys(function (Language $language) use ($model) { + return [$language->code => $model->translateAttributes($language->code)]; + }); + + return $translations->toArray(); + } +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/UpdateAttributesIfTranslatable.php b/packages/laravel-translations/src/Domain/Translatables/Actions/UpdateAttributesIfTranslatable.php new file mode 100644 index 00000000..387531f1 --- /dev/null +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/UpdateAttributesIfTranslatable.php @@ -0,0 +1,22 @@ +translateAttributeForAllLanguages(attribute: $attribute, overwrite: true); + } + } +} diff --git a/packages/laravel-translations/src/Domain/Translatables/Actions/UpdateTranslateAttributes.php b/packages/laravel-translations/src/Domain/Translatables/Actions/UpdateTranslateAttributes.php new file mode 100644 index 00000000..adc5ef76 --- /dev/null +++ b/packages/laravel-translations/src/Domain/Translatables/Actions/UpdateTranslateAttributes.php @@ -0,0 +1,30 @@ +translateAttribute($attribute, $language->code, true); + } + } + } +} diff --git a/packages/laravel-translations/src/Drivers/AITranslator.php b/packages/laravel-translations/src/Drivers/AITranslator.php new file mode 100644 index 00000000..3c7064de --- /dev/null +++ b/packages/laravel-translations/src/Drivers/AITranslator.php @@ -0,0 +1,153 @@ +translateJson($text, $targetLanguage, $extraPrompt); + } + + $systemPromptLines = [ + config('translations.translators.drivers.ai.system_prompt'), + + 'You are a professional translation engine. Your sole task is to translate raw input text into the specified target language, accurately and precisely, without any form of commentary, confirmation, clarification, or explanation.', + '', + 'Your response must follow these strict rules at all times:', + '', + '1. Only output the translated text. No headings, introductions, explanations, or metadata. For example, do NOT say:', + ' - "Certainly!"', + ' - "The translation of..."', + ' - "Here is the translation:"', + ' - "(\'Inloggen\' is already in Dutch.)"', + ' - "The key %key in Dutch could be..."', + ' - "Translated to [language]:"', + ' - "This text is already in..."', + '', + '2. Never repeat the input text or key name. Just return the translated string itself.', + '', + '3. Do not guess or assume formatting intentions. Preserve:', + ' - All punctuation (.,!?: etc.)', + ' - Line breaks and spacing', + ' - HTML tags and structure', + ' - Markdown, quotes, dashes, and ellipses', + ' - Capitalization and tone (e.g., commands, questions, titles)', + '', + '4. Do NOT translate variables. These are words starting with a colon (:), such as:', + ' - :name', + ' - :count', + ' - :user_email', + '', + ' Leave them exactly as-is. For example:', + ' - Input: Hello, :name!', + ' - Output (to German): Hallo, :name!', + '', + '5. Do NOT alter or explain language behavior. Even if the input is already in the target language, do not say so—just return it unchanged.', + '', + "6. Do NOT interpret keys like %button_label unless they are part of the text itself. If you're translating `%manage_subscription_button_label: Abonnement beheren`, only translate the value (`Abonnement beheren`), unless instructed otherwise.", + '', + '7. Only respond with the translated string. No code blocks, no markdown, no quotes. Just the raw translated text, nothing else.', + '', + '8. Follow cultural and grammatical conventions of the target language. Ensure fluency, natural tone, and proper grammar.', + '', + 'You are NOT a chatbot. You are NOT a teacher. You are a silent, efficient translation tool.', + '"9. You may also receive an entire JSON object or array. When this happens:", + " - ONLY translate the **values**, not the **keys**.", + " - Preserve all JSON structure, indentation, and formatting.", + " - Do NOT reformat, wrap, or explain the JSON.", + " - Return only the modified JSON as raw text—no code blocks, no comments, no extra output.", + " - Keep all colon-prefixed variables like `:count` intact inside values.",', + ]; + + $instructions = []; + + if ($extraPrompt) { + $instructions[] = $extraPrompt; + } + + $instructions[] = "Translate the following text to {$targetLanguage}."; + $instructions[] = 'Preserve punctuation, tone, and special characters.'; + $instructions[] = 'Do NOT translate colon-prefixed variables (e.g., :name).'; + $instructions[] = 'Only return the translated sentence—no commentary.'; + $instructions[] = "Text: {$text}"; + + $systemPrompt = implode("\n", $systemPromptLines); + + $instructionsString = implode("\n", $instructions); + + $response = Prism::text() + ->withClientOptions([ + 'timeout' => 600, + 'text_output_only' => true, + ]) + ->withProviderOptions([ + 'reasoning' => ['effort' => 'minimal'], + ]) + ->withClientRetry(4, 100) + ->using(config('translations.translators.drivers.ai.provider'), config('translations.translators.drivers.ai.model')) + ->withSystemPrompt($systemPrompt) + ->withPrompt($instructionsString) + ->asText(); + + return trim($response->text); + } + + protected function translateJson(array $toBeJson, string $targetLanguage, ?string $extraPrompt = null): array + { + $systemPromptLines = [ + 'You are a professional translation engine. Your sole task is to translate raw input values of a JSON object into the specified target language.', + '', + 'Follow these rules:', + '', + '1. Only translate the values, NOT the keys.', + '2. Preserve variables prefixed with a colon (e.g., :name, :count).', + '3. Maintain formatting, HTML, punctuation, and spacing exactly.', + '4. Do NOT add comments, notes, explanations, or code blocks.', + '5. Respond with only the translated JSON object.', + '', + 'Example Input:', + '{ "welcome": "Welcome, :name!", "logout": "Log out" }', + '', + 'Example Output (in French):', + '{ "welcome": "Bienvenue, :name!", "logout": "Se déconnecter" }', + ]; + + $systemPrompt = implode("\n", $systemPromptLines); + + $json = json_encode($toBeJson, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + + $prompt = <<using(config('translations.translators.drivers.ai.provider'), config('translations.translators.drivers.ai.model')) + ->withSystemPrompt($systemPrompt) + ->withPrompt($prompt) + ->asText(); + + $translated = trim($response->text); + + json_decode($translated); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('Invalid JSON returned from AI translation.'); + } + + return json_decode($translated, true); + } +} diff --git a/packages/laravel-translations/src/Drivers/DeepLTranslator.php b/packages/laravel-translations/src/Drivers/DeepLTranslator.php new file mode 100644 index 00000000..0a8c1f1e --- /dev/null +++ b/packages/laravel-translations/src/Drivers/DeepLTranslator.php @@ -0,0 +1,57 @@ +isUlid()) { + return $text; + } + + $deeplClient = static::initializeDeepLClient(); + + $targetLanguage = static::normalizeLanguageCode($targetLanguage); + + $result = $deeplClient->translateText($text, null, $targetLanguage, [ + TranslateTextOptions::PRESERVE_FORMATTING => true, + ]); + + return static::parseText($result); + } + + protected static function normalizeLanguageCode(string $language): string + { + return match (strtolower($language)) { + 'en' => 'EN-GB', + 'pt' => 'PT-PT', + 'zh' => 'ZH-HANS', + 'es' => 'ES', + default => strtoupper($language), + }; + } + + protected static function initializeDeepLClient(): DeepLClient + { + $authkey = config('services.deepl.auth_key'); + + if (! $authkey) { + throw new \RuntimeException('DeepL auth key is not set in the configuration.'); + } + + $client = new DeepLClient($authkey, config('translations.translators.drivers.deep-l.options', [])); + + return $client; + } + + protected static function parseText(TextResult $textResult) + { + return $textResult->text; + } +} diff --git a/packages/laravel-translations/src/Drivers/GoogleTranslator.php b/packages/laravel-translations/src/Drivers/GoogleTranslator.php new file mode 100644 index 00000000..56c915fa --- /dev/null +++ b/packages/laravel-translations/src/Drivers/GoogleTranslator.php @@ -0,0 +1,20 @@ +setSource(); + + $tr->setTarget($targetLanguage); + + return retry(3, fn () => $tr->translate($text), 100); + } +} diff --git a/packages/laravel-translations/src/Events/LanguageAdded.php b/packages/laravel-translations/src/Events/LanguageAdded.php new file mode 100644 index 00000000..565d8665 --- /dev/null +++ b/packages/laravel-translations/src/Events/LanguageAdded.php @@ -0,0 +1,10 @@ +locale = $locale; + } + + public function handle() + { + $translations = collect(FindTranslatables::scan())->unique(); + + $locales = $this->locale ? collect([$this->locale->code]) : $this->getLocales(); + + $baseLocale = App::getLocale(); + + $localizedTranslations = $this->mapTranslations($translations, $locales); + + App::setLocale($baseLocale); + + $this->storeTranslations($localizedTranslations); + } + + protected function getLocales(): \Illuminate\Support\Collection + { + return Language::active()->pluck('code'); + } + + protected function mapTranslations($translations, $locales): \Illuminate\Support\Collection + { + return $translations->flatMap(function ($translation) use ($locales) { + return $locales->map(function ($locale) use ($translation) { + $baseLocale = $locale; + $locale = explode('-', $baseLocale)[0]; + + $data = [ + 'code' => $baseLocale, + 'group' => $translation['group'], + 'key' => $translation['key'], + 'namespace' => $translation['namespace'] ?? '*', + ]; + + if (! $this->redo) { + if ($data['namespace'] !== '*') { + $data['text'] = Lang::get(key: $translation['key'], locale: $locale); + } else { + $data['text'] = Lang::get(key: $translation['key'], locale: $locale); + } + + return $data; + } + + $oldTranslation = Translation::query() + ->where('key', $translation['key']) + ->where('group', $translation['group']) + ->where('namespace', $translation['namespace'] ?? '*') + ->where('code', $baseLocale) + ->first(); + + if ($oldTranslation) { + $data['text'] = $oldTranslation->text; + } else { + $data['text'] = Lang::get(key: $translation['key'], locale: $locale); + } + + return $data; + }); + }); + } + + protected function storeTranslations($translations): void + { + Event::fake(); + + $translations->each(function ($translation) { + if (! is_array($translation['text'])) { + Translation::firstOrCreate([ + 'group' => $translation['group'], + 'code' => $translation['code'], + 'key' => $translation['key'], + 'namespace' => $translation['namespace'], + ], [ + 'text' => $translation['text'] ?? $translation['key'], + 'source_text' => $translation['text'] !== $translation['key'] ? $translation['text'] : null, + 'translated_at' => static::translationIsTranslated($translation) ? now() : null, + ]); + } + }); + + TranslationStringsCache::update(); + } + + protected static function translationIsTranslated(array $translation): bool + { + if ($translation['namespace'] === '*') { + return false; + } + + if ($translation['key'] === $translation['text']) { + return false; + } + + return true; + } +} diff --git a/packages/laravel-translations/src/Jobs/TranslateKeys.php b/packages/laravel-translations/src/Jobs/TranslateKeys.php new file mode 100644 index 00000000..6516596c --- /dev/null +++ b/packages/laravel-translations/src/Jobs/TranslateKeys.php @@ -0,0 +1,75 @@ +addMinutes(1); + } + + public function __construct(public ?Language $lang = null) {} + + public function handle(): void + { + ScanTranslationStrings::dispatchSync(); + + $locales = $this->lang ? [$this->lang->code] : Language::active()->pluck('code')->toArray(); + + $translator = Translator::with(config('translations.translators.default')); + + /** + * @var \Illuminate\Database\Eloquent\Builder $query + */ + $query = Translation::query(); + + $results = $query->whereIn('code', $locales) + ->whereNull('translated_at') + ->get(); + + $results + ->each(function (Translation $translation) use ($translator) { + $newText = $translator->translate($translation->text, $translation->languageCode); + + $translation->update([ + 'text' => $newText, + 'translated_at' => now(), + ]); + }); + } +} diff --git a/packages/laravel-translations/src/Listners/DeleteTranslations.php b/packages/laravel-translations/src/Listners/DeleteTranslations.php new file mode 100644 index 00000000..f7f069fb --- /dev/null +++ b/packages/laravel-translations/src/Listners/DeleteTranslations.php @@ -0,0 +1,15 @@ +language->code)->delete(); + } +} diff --git a/packages/laravel-translations/src/Listners/HandleLanguageCodeChanges.php b/packages/laravel-translations/src/Listners/HandleLanguageCodeChanges.php new file mode 100644 index 00000000..728758ce --- /dev/null +++ b/packages/laravel-translations/src/Listners/HandleLanguageCodeChanges.php @@ -0,0 +1,23 @@ +language->getOriginal('code')) + ->get() + ->each(function (Translation $translation) use ($event) { + $translation->code = $event->language->getAttribute('code'); + + $translation->translated_at = null; + + $translation->save(); + }); + } +} diff --git a/packages/laravel-translations/src/Managers/TranslatorManager.php b/packages/laravel-translations/src/Managers/TranslatorManager.php new file mode 100644 index 00000000..c9f97fd8 --- /dev/null +++ b/packages/laravel-translations/src/Managers/TranslatorManager.php @@ -0,0 +1,40 @@ +driver = $driver; + + return $this->driver($driver); + } + + protected function createGoogleTranslateDriver() + { + return new GoogleTranslator; + } + + protected function createAiDriver() + { + return new AITranslator; + } + + protected function createDeepLDriver() + { + return new DeepLTranslator; + } + + public function getDefaultDriver(): string + { + return config('translations.translators.default'); + } +} diff --git a/packages/laravel-translations/src/Models/Concerns/HasTranslatableAttributes.php b/packages/laravel-translations/src/Models/Concerns/HasTranslatableAttributes.php new file mode 100644 index 00000000..e8068353 --- /dev/null +++ b/packages/laravel-translations/src/Models/Concerns/HasTranslatableAttributes.php @@ -0,0 +1,193 @@ + $model->syncTranslations()); + }); + + /** + * @param self $model + */ + static::updated(function (TranslatesAttributes $model) { + $dirtyAttributes = $model->getDirty(); + + $translatableAttributes = array_intersect( + array_keys($dirtyAttributes), + $model->getTranslatableAttributes() + ); + + dispatch(fn () => $model->updateAttributesIfTranslatable($translatableAttributes)); + }); + + /** + * @param self $model + */ + static::deleting(function (TranslatesAttributes $model) { + dispatch(fn () => $model->translatableAttributes->each->delete()); + }); + } + + /** + * Translate the given attributes. + */ + public function translateAttributes(?string $targetLanguage = null): array + { + return TranslateAttributes::run( + model: $this, + targetLanguage: $targetLanguage ?? app()->getLocale() + ); + } + + /** + * Translate attributes for all languages defined in the system. + */ + public function translateAttributesForAllLanguages(): array + { + return TranslateAttributesForAllLanguages::run( + model: $this + ); + } + + public function translateAttributeForAllLanguages(string $attribute, bool $overwrite = false): array + { + return TranslateAttributeForAllLanguages::run( + model: $this, + attribute: $attribute, + overwrite: $overwrite + ); + } + + /** + * Translate a single attribute. + */ + public function translateAttribute(mixed $attribute, string $targetLanguage, bool $overwrite = false, ?string $extraPrompt = null): mixed + { + return TranslateAttribute::run( + model: $this, + attribute: $attribute, + targetLanguage: $targetLanguage, + overwrite: $overwrite, + extraPrompt: $extraPrompt + ); + } + + /** + * Store or update a translated attribute. + */ + public function pushTranslateAttribute(string $attribute, mixed $translation, string $locale): void + { + PushTranslatedAttribute::run( + model: $this, + attribute: $attribute, + translation: $translation, + locale: $locale + ); + } + + /** + * Get the translated value of a given attribute. + */ + public function getTranslatedAttribute(string $attribute, ?string $locale = null): mixed + { + return GetTranslatedAttribute::run( + model: $this, + attribute: $attribute, + locale: $locale ?? app()->getLocale() + ); + } + + public function getTranslatedAttributes(?string $locale = null): array + { + $locale = $locale ?? app()->getLocale(); + + return GetTranslatedAttributes::run( + model: $this, + locale: $locale + ); + } + + /** + * Get the attributes that should be translated. + */ + public function getTranslatableAttributes(): array + { + return []; + } + + public function isTranslatableAttribute(string $attribute): bool + { + return IsTranslatableAttribute::run( + model: $this, + attribute: $attribute + ); + } + + /** + * Get the relationship for translated attributes. + */ + public function translatableAttributes(): MorphMany + { + return $this->morphMany(TranslatedAttribute::class, 'translatable'); + } + + public function syncTranslations(?\Illuminate\Console\OutputStyle $output = null): void + { + SyncTranslations::run( + model: $this, + output: $output + ); + } + + public function updateTranslateAttributes(array $attributes): void + { + UpdateTranslateAttributes::run( + model: $this, + attributes: $attributes + ); + } + + public function updateAttributesIfTranslatable(array $translatableAttributes): void + { + UpdateAttributesIfTranslatable::run( + model: $this, + translatableAttributes: $translatableAttributes + ); + } + + public function getTranslatableAttributeRulesFor(string $attribute): array | string + { + $methodName = 'getTranslatableAttributeRulesFor' . str($attribute)->studly(); + + if (! method_exists($this, $methodName)) { + return '*'; + } + + return $this->{$methodName}(); + } +} diff --git a/packages/laravel-translations/src/Models/Language.php b/packages/laravel-translations/src/Models/Language.php new file mode 100644 index 00000000..59ca074d --- /dev/null +++ b/packages/laravel-translations/src/Models/Language.php @@ -0,0 +1,88 @@ + 'string', + 'name' => 'string', + 'native' => 'string', + 'active' => 'boolean', + 'default' => 'boolean', + ]; + + public function scopeActive($query) + { + return $query->where('active', true); + } + + public static function default(): ?Language + { + return static::firstWhere('default', true); + } + + protected static function newFactory() + { + $package = Str::before(get_called_class(), 'Models\\'); + $modelName = Str::after(get_called_class(), 'Models\\'); + $path = $package . 'Database\\Factories\\' . $modelName . 'Factory'; + + return $path::new(); + } + + public function translatableAttributes(): HasMany + { + return $this->hasMany(TranslatedAttribute::class, 'locale', 'code'); + } + + public function translations(): HasMany + { + return $this->hasMany(Translation::class, 'code', 'code'); + } + + public function getLanguageCodeAttribute() + { + return explode('-', $this->attributes['code'])[0]; + } + + public function getCountryCodeAttribute() + { + return explode('-', $this->attributes['code'])[1]; + } + + public function getLocalizedCountryNameAttribute($locale = null) + { + $code = strtolower(explode('-', $this->attributes['code'])[1] ?? $this->attributes['code']); + + return Locale::getDisplayRegion('-' . $code, $locale ?? app()->getLocale()); + } + + public function getLocalizedLanguageNameAttribute($locale = null) + { + $code = strtolower(explode('-', $this->attributes['code'])[0]); + + return Locale::getDisplayLanguage($code, $locale ?? app()->getLocale()); + } +} diff --git a/packages/laravel-translations/src/Models/TranslatedAttribute.php b/packages/laravel-translations/src/Models/TranslatedAttribute.php new file mode 100644 index 00000000..e1a88cf4 --- /dev/null +++ b/packages/laravel-translations/src/Models/TranslatedAttribute.php @@ -0,0 +1,36 @@ + 'datetime', + ]; + + public function translatable(): MorphTo + { + return $this->morphTo(); + } + + public function language(): BelongsTo + { + return $this->belongsTo(Language::class, 'code', 'code'); + } +} diff --git a/packages/laravel-translations/src/Models/Translation.php b/packages/laravel-translations/src/Models/Translation.php new file mode 100644 index 00000000..c2ddc559 --- /dev/null +++ b/packages/laravel-translations/src/Models/Translation.php @@ -0,0 +1,55 @@ + 'string', + 'group' => 'string', + 'key' => 'string', + 'text' => 'string', + 'namespace' => 'string', + 'translated_at' => 'datetime', + ]; + + protected static function boot() + { + parent::boot(); + + static::saved(function (Translation $translation) { + dispatch(fn () => TranslationStringsCache::update()); + }); + } + + public function language(): BelongsTo + { + return $this->belongsTo(Language::class); + } + + public function getLanguageCodeAttribute() + { + return explode('-', $this->attributes['code'])[0]; + } + + public function getCountryCodeAttribute() + { + return explode('-', $this->attributes['code'])[1]; + } +} diff --git a/packages/laravel-translations/src/Observers/LanguageObserver.php b/packages/laravel-translations/src/Observers/LanguageObserver.php new file mode 100644 index 00000000..7eb4bd3c --- /dev/null +++ b/packages/laravel-translations/src/Observers/LanguageObserver.php @@ -0,0 +1,78 @@ +exists()) { + $language->default = true; + } + + if (! Language::where('active', true)->exists()) { + $language->active = true; + } + } + + public function created(Language $language) + { + event(new LanguageAdded($language)); + } + + public function updated(Language $language) + { + if ($language->wasChanged('active') && ! $language->active) { + if (Language::where('active', true)->count() == 0) { + Language::where('code', $language->code) + ->update([ + 'active' => true, + ]); + } + } + + if ($language->wasChanged('default') && ! $language->active) { + Language::where('code', $language->code) + ->update([ + 'default' => false, + ]); + + return; + } + + $defaultExists = Language::where('default', true)->exists(); + + if ($language->default) { + Language::where('code', '!=', $language->code)->update([ + 'default' => false, + ]); + } elseif (! $language->default && ! $defaultExists) { + Language::where('code', $language->code) + ->update([ + 'default' => true, + ]); + } + + if ($language->wasChanged('code')) { + event(new LanguageCodeChanged($language)); + } + } + + public function deleted(Language $language) + { + if ($language->default && Language::count() > 0) { + Language::where('code', '!=', $language->code) + ->first() + ->update([ + 'default' => true, + ]); + } + + event(new LanguageDeleted($language)); + } +} diff --git a/packages/laravel-translations/src/TranslationLoaderServiceProvider.php b/packages/laravel-translations/src/TranslationLoaderServiceProvider.php new file mode 100644 index 00000000..c51f65af --- /dev/null +++ b/packages/laravel-translations/src/TranslationLoaderServiceProvider.php @@ -0,0 +1,26 @@ +app->singleton('translation.loader', function (Application $app) { + return new TranslationLoader($app['files'], $app['path.lang']); + }); + } + + /** + * Get the services provided by the provider. + */ + public function provides(): array + { + return ['translation.loader']; + } +} diff --git a/packages/laravel-translations/src/TranslationServiceProvider.php b/packages/laravel-translations/src/TranslationServiceProvider.php new file mode 100644 index 00000000..05522257 --- /dev/null +++ b/packages/laravel-translations/src/TranslationServiceProvider.php @@ -0,0 +1,62 @@ +name('laravel-translations') + ->hasMigrations( + 'create_languages_table', + 'create_translations_table', + 'create_translated_attributes_table', + ) + ->hasConfigFile('translations') + ->hasCommands( + SyncTranslations::class, + TranslateTranslations::class, + TranslationsAddLanguage::class, + TranslationsScan::class, + ); + } + + public function registeringPackage() + { + $this->app->singleton(TranslatorContract::class, fn ($app) => new TranslatorManager($app)); + } + + public function bootingPackage() + { + Event::listen(LanguageDeleted::class, DeleteTranslations::class); + Event::listen(LanguageCodeChanged::class, HandleLanguageCodeChanges::class); + + if (config('translations.use_permanent_cache')) { + PermanentCache::caches([ + TranslationStringsCache::class, + ]); + } + + Schedule::command(SyncTranslations::class) + ->dailyAt('00:00') + ->withoutOverlapping(); + } +} diff --git a/packages/laravel-translations/src/helpers.php b/packages/laravel-translations/src/helpers.php new file mode 100644 index 00000000..1216bf11 --- /dev/null +++ b/packages/laravel-translations/src/helpers.php @@ -0,0 +1,15 @@ +getLocale()); +} + +function localized_language_name(string $code, ?string $locale = null): string +{ + $code = strtolower(explode('-', $code)[0]); + + return Locale::getDisplayLanguage($code, $locale ?? app()->getLocale()); +} diff --git a/packages/laravel-translations/tests/ArchTest.php b/packages/laravel-translations/tests/ArchTest.php new file mode 100644 index 00000000..87fb64cd --- /dev/null +++ b/packages/laravel-translations/tests/ArchTest.php @@ -0,0 +1,5 @@ +expect(['dd', 'dump', 'ray']) + ->each->not->toBeUsed(); diff --git a/packages/laravel-translations/tests/ExampleTest.php b/packages/laravel-translations/tests/ExampleTest.php new file mode 100644 index 00000000..5d363218 --- /dev/null +++ b/packages/laravel-translations/tests/ExampleTest.php @@ -0,0 +1,5 @@ +toBeTrue(); +}); diff --git a/packages/laravel-translations/tests/Pest.php b/packages/laravel-translations/tests/Pest.php new file mode 100644 index 00000000..ba373236 --- /dev/null +++ b/packages/laravel-translations/tests/Pest.php @@ -0,0 +1,5 @@ +in(__DIR__); diff --git a/packages/laravel-translations/tests/TestCase.php b/packages/laravel-translations/tests/TestCase.php new file mode 100644 index 00000000..1789330a --- /dev/null +++ b/packages/laravel-translations/tests/TestCase.php @@ -0,0 +1,36 @@ + 'Backstage\\Translations\\Laravel\\Database\\Factories\\' . class_basename($modelName) . 'Factory' + ); + } + + protected function getPackageProviders($app) + { + return [ + TranslationServiceProvider::class, + ]; + } + + public function getEnvironmentSetUp($app) + { + config()->set('database.default', 'testing'); + + /* + $migration = include __DIR__.'/../database/migrations/create_laravel-translations_table.php.stub'; + $migration->up(); + */ + } +}