From c7be1af12491203e6f4190234ee19ecb914fc205 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 18:52:45 -0300 Subject: [PATCH] Detect all 15 content languages on autofill, harden RTL/i18n, and cover the gaps with tests - Brand-analyzer prompt now lists every supported language instead of only en/pt-BR/es, so onboarding autofill can detect the 12 added languages. The backtick-formatted list is built in BrandAnalyzer::instructions(), keeping the Blade clean and the enum free of prompt presentation. - Translate the delete-confirmation keyword for el/ja/zh/ar (the four locales that still shipped the English "delete"). - Make the language and font comboboxes RTL-correct (logical ms-* instead of physical ml-*), and let the language combobox be searched by English name via a visually-hidden label (ContentLanguage::options() now exposes englishName). - Correct the ContentLanguage class docblock: the enum is also the source of truth for the UI locales' text direction. Tests: SetLocale middleware dir/RTL, isRtl and the full 15-language englishName/label match arms, LLM language detection beyond en/es/pt-BR, and store-path persistence of a non-default content language plus rejection of an unsupported one. --- app/Ai/Agents/BrandAnalyzer.php | 3 + app/Enums/Workspace/ContentLanguage.php | 20 +++--- lang/ar/common.php | 2 +- lang/el/common.php | 2 +- lang/ja/common.php | 2 +- lang/zh/common.php | 2 +- resources/js/components/FontPicker.vue | 4 +- resources/js/components/LanguagePicker.vue | 11 ++-- .../views/prompts/brand_analyzer.blade.php | 2 +- tests/Feature/Ai/AutofillBrandTest.php | 31 +++++++++ tests/Feature/Middleware/SetLocaleTest.php | 44 +++++++++++++ tests/Feature/WorkspaceControllerTest.php | 27 +++++++- .../Enums/Workspace/ContentLanguageTest.php | 66 ++++++++++++++++--- 13 files changed, 187 insertions(+), 29 deletions(-) create mode 100644 tests/Feature/Middleware/SetLocaleTest.php diff --git a/app/Ai/Agents/BrandAnalyzer.php b/app/Ai/Agents/BrandAnalyzer.php index 7e34c5a0..c1df86cd 100644 --- a/app/Ai/Agents/BrandAnalyzer.php +++ b/app/Ai/Agents/BrandAnalyzer.php @@ -21,6 +21,9 @@ public function instructions(): string return view('prompts.brand_analyzer', [ 'voice_groups' => BrandVoiceTrait::grouped(), 'single_select_groups' => BrandVoiceTrait::singleSelectGroups(), + 'content_languages' => collect(ContentLanguage::values()) + ->map(fn (string $code) => "`{$code}`") + ->implode(', '), ])->render(); } diff --git a/app/Enums/Workspace/ContentLanguage.php b/app/Enums/Workspace/ContentLanguage.php index a41fab09..9448dc34 100644 --- a/app/Enums/Workspace/ContentLanguage.php +++ b/app/Enums/Workspace/ContentLanguage.php @@ -5,14 +5,14 @@ namespace App\Enums\Workspace; /** - * Languages a workspace can pick for AI-generated content. This is the single - * source of truth for the supported set: request validation, the brand - * analyzer's structured-output enum, homepage language detection, the AI image - * prompt's language name, and the picker options in the UI all derive from it. + * The set of languages the app supports, and the single source of truth for it: + * request validation, the brand analyzer's structured-output enum, homepage + * language detection, the AI image prompt's language name, the content-language + * picker options, and the right-to-left direction of the UI all derive from it. * * The string value is the language code stored on the workspace and passed - * straight to the content prompts (`content_language`). This is unrelated to - * the application's UI locale — we are not translating the interface here. + * straight to the content prompts (`content_language`); the same codes also back + * the application's UI locales, so `isRtl()` drives the document `dir` attribute. */ enum ContentLanguage: string { @@ -130,12 +130,16 @@ public static function values(): array } /** - * @return array + * @return array */ public static function options(): array { return array_map( - fn (self $language) => ['value' => $language->value, 'label' => $language->label()], + fn (self $language) => [ + 'value' => $language->value, + 'label' => $language->label(), + 'englishName' => $language->englishName(), + ], self::cases(), ); } diff --git a/lang/ar/common.php b/lang/ar/common.php index 02d969c9..59fd7803 100644 --- a/lang/ar/common.php +++ b/lang/ar/common.php @@ -13,7 +13,7 @@ 'type' => 'اكتب', 'to_confirm' => 'للتأكيد.', 'copy_to_clipboard' => 'نسخ إلى الحافظة', - 'delete_keyword' => 'delete', + 'delete_keyword' => 'حذف', ], 'photo_upload' => [ diff --git a/lang/el/common.php b/lang/el/common.php index ec186956..0bb7db23 100644 --- a/lang/el/common.php +++ b/lang/el/common.php @@ -13,7 +13,7 @@ 'type' => 'Πληκτρολογήστε', 'to_confirm' => 'για επιβεβαίωση.', 'copy_to_clipboard' => 'Αντιγραφή στο πρόχειρο', - 'delete_keyword' => 'delete', + 'delete_keyword' => 'διαγραφή', ], 'photo_upload' => [ diff --git a/lang/ja/common.php b/lang/ja/common.php index ab2a26e8..96e89257 100644 --- a/lang/ja/common.php +++ b/lang/ja/common.php @@ -13,7 +13,7 @@ 'type' => '入力', 'to_confirm' => 'で確認してください。', 'copy_to_clipboard' => 'クリップボードにコピー', - 'delete_keyword' => 'delete', + 'delete_keyword' => '削除', ], 'photo_upload' => [ diff --git a/lang/zh/common.php b/lang/zh/common.php index f4e517b1..b16eb072 100644 --- a/lang/zh/common.php +++ b/lang/zh/common.php @@ -13,7 +13,7 @@ 'type' => '输入', 'to_confirm' => '以确认。', 'copy_to_clipboard' => '复制到剪贴板', - 'delete_keyword' => 'delete', + 'delete_keyword' => '删除', ], 'photo_upload' => [ diff --git a/resources/js/components/FontPicker.vue b/resources/js/components/FontPicker.vue index d886ddf4..ff1359b6 100644 --- a/resources/js/components/FontPicker.vue +++ b/resources/js/components/FontPicker.vue @@ -87,7 +87,7 @@ const select = (font: string) => { {{ value || placeholder }} - + @@ -105,7 +105,7 @@ const select = (font: string) => { > {{ font }} diff --git a/resources/js/components/LanguagePicker.vue b/resources/js/components/LanguagePicker.vue index 54922a7c..9826d597 100644 --- a/resources/js/components/LanguagePicker.vue +++ b/resources/js/components/LanguagePicker.vue @@ -8,8 +8,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { cn } from '@/lib/utils'; interface Props { - /** Selectable languages as { value: locale code, label: native name }. */ - options: { value: string; label: string }[]; + /** Selectable languages as { value: locale code, label: native name, englishName: name in English }. */ + options: { value: string; label: string; englishName?: string }[]; placeholder?: string; searchPlaceholder?: string; emptyText?: string; @@ -49,7 +49,7 @@ const select = (code: string) => { {{ selectedLabel || placeholder }} - + @@ -62,11 +62,12 @@ const select = (code: string) => { {{ option.label }} - + {{ option.englishName }} + diff --git a/resources/views/prompts/brand_analyzer.blade.php b/resources/views/prompts/brand_analyzer.blade.php index 30f7de33..eb31b155 100644 --- a/resources/views/prompts/brand_analyzer.blade.php +++ b/resources/views/prompts/brand_analyzer.blade.php @@ -10,7 +10,7 @@ 2. **description** — a concise 2-3 sentence brand description explaining what the company does, who they serve, and what makes them unique. Write it in the detected content language. Avoid marketing fluff; be specific. -3. **language** — detect the primary content language of the site. Pick exactly one of: `en`, `pt-BR`, `es`. If the site is in a different language entirely, pick the closest match (prefer `en`). +3. **language** — detect the primary content language of the site and return its code. Pick exactly one of: {{ $content_languages }}. If the site is in a language not in that list, pick the closest supported match (prefer `en`). 4. **voice_traits** — infer the brand's voice as a set of traits, returned as an array of values chosen ONLY from the allowed list below. For each single-select dimension pick AT MOST ONE value (or none if unclear); for the `style` group pick any number that fit. Aim for a coherent set of roughly 5-9 traits. @foreach($voice_groups as $group => $values) diff --git a/tests/Feature/Ai/AutofillBrandTest.php b/tests/Feature/Ai/AutofillBrandTest.php index fd5469c0..5f43f750 100644 --- a/tests/Feature/Ai/AutofillBrandTest.php +++ b/tests/Feature/Ai/AutofillBrandTest.php @@ -390,6 +390,37 @@ expect($result->toArray()['brand_voice_traits'])->toBe(['third_person', 'direct', 'no_hype']); }); +test('LLM language detection carries any supported language, not just en/es/pt-BR', function () { + config()->set('services.gemini.api_key', 'fake-key'); + config()->set('ai.default', 'gemini'); + + Http::fake([ + 'example.com' => Http::response(<<<'HTML' + + + Beispiel GmbH + + +

Wir bauen Widgets.

+ + HTML, 200), + ]); + + BrandAnalyzer::fake([ + [ + 'description' => 'Beispiel GmbH baut Widgets für kleine Teams.', + 'language' => 'de', + 'voice_traits' => ['third_person'], + ], + ]); + + $result = ($this->autofill)('https://example.com'); + + // The LLM's detected language wins over the deterministic one and must be + // able to be one of the 12 languages added beyond the original en/es/pt-BR. + expect($result->language)->toBe('de'); +}); + test('when llm is not configured, falls back to meta tags only', function () { // beforeEach already cleared api keys. Http::fake([ diff --git a/tests/Feature/Middleware/SetLocaleTest.php b/tests/Feature/Middleware/SetLocaleTest.php new file mode 100644 index 00000000..8096c453 --- /dev/null +++ b/tests/Feature/Middleware/SetLocaleTest.php @@ -0,0 +1,44 @@ +`. + */ +function runSetLocale(?string $locale): string +{ + $request = Request::create('/', 'GET'); + + if ($locale !== null) { + $request->cookies->set('locale', $locale); + } + + (new SetLocale)->handle($request, fn () => response('ok')); + + return View::shared('htmlDir'); +} + +test('shares rtl direction and sets the locale for Arabic', function () { + expect(runSetLocale('ar'))->toBe('rtl'); + expect(app()->getLocale())->toBe('ar'); +}); + +test('shares ltr direction for a left-to-right locale', function (string $locale) { + expect(runSetLocale($locale))->toBe('ltr'); + expect(app()->getLocale())->toBe($locale); +})->with(['en', 'ja', 'pt-BR', 'de']); + +test('falls back to the default locale and ltr for an unknown cookie', function () { + expect(runSetLocale('sv'))->toBe('ltr'); + expect(app()->getLocale())->toBe(config('languages.default')); +}); + +test('falls back to the default locale and ltr when no cookie is set', function () { + expect(runSetLocale(null))->toBe('ltr'); + expect(app()->getLocale())->toBe(config('languages.default')); +}); diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php index 3bb04ced..5f1970a1 100644 --- a/tests/Feature/WorkspaceControllerTest.php +++ b/tests/Feature/WorkspaceControllerTest.php @@ -61,7 +61,7 @@ $response->assertInertia(fn ($page) => $page ->component('workspaces/Create', false) ->has('availableContentLanguages', count(ContentLanguage::cases())) - ->where('availableContentLanguages.0', ['value' => 'en', 'label' => 'English']) + ->where('availableContentLanguages.0', ['value' => 'en', 'label' => 'English', 'englishName' => 'English']) ); }); @@ -628,6 +628,31 @@ expect($workspace->brand_description)->toBe('We sell rockets.'); }); +test('store persists a newly supported non-default content language', function () { + $account = Account::factory()->create(); + $user = User::factory()->create(['account_id' => $account->id]); + $account->update(['owner_id' => $user->id]); + + $this->actingAs($user)->post(route('app.workspaces.store'), [ + 'name' => 'Beispiel GmbH', + 'content_language' => 'de', + ])->assertSessionHasNoErrors(); + + // 'de' is not the DB default ('en'), so this proves the field is written. + expect(Workspace::where('name', 'Beispiel GmbH')->sole()->content_language)->toBe('de'); +}); + +test('store rejects an unsupported content language', function () { + $account = Account::factory()->create(); + $user = User::factory()->create(['account_id' => $account->id]); + $account->update(['owner_id' => $user->id]); + + $this->actingAs($user)->post(route('app.workspaces.store'), [ + 'name' => 'Bad Lang', + 'content_language' => 'sv', + ])->assertSessionHasErrors(['content_language']); +}); + test('store redirects additional workspace to /accounts', function () { $account = Account::factory()->create(); $user = User::factory()->create(['account_id' => $account->id]); diff --git a/tests/Unit/Enums/Workspace/ContentLanguageTest.php b/tests/Unit/Enums/Workspace/ContentLanguageTest.php index 58db5692..88f8aa2e 100644 --- a/tests/Unit/Enums/Workspace/ContentLanguageTest.php +++ b/tests/Unit/Enums/Workspace/ContentLanguageTest.php @@ -16,19 +16,69 @@ expect(ContentLanguage::DEFAULT->value)->toBe('en'); }); -test('options pairs each code with its native label', function () { +test('options pairs each code with its native and English label', function () { $options = ContentLanguage::options(); expect($options)->toHaveCount(count(ContentLanguage::cases())); - expect($options[0])->toBe(['value' => 'en', 'label' => 'English']); - expect($options)->toContain(['value' => 'pt-BR', 'label' => 'Português (Brasil)']); - expect($options)->toContain(['value' => 'ja', 'label' => '日本語']); + expect($options[0])->toBe(['value' => 'en', 'label' => 'English', 'englishName' => 'English']); + expect($options)->toContain(['value' => 'pt-BR', 'label' => 'Português (Brasil)', 'englishName' => 'Brazilian Portuguese']); + expect($options)->toContain(['value' => 'ja', 'label' => '日本語', 'englishName' => 'Japanese']); }); -test('english name is the language name in English for the AI image prompt', function () { - expect(ContentLanguage::PortugueseBrazil->englishName())->toBe('Brazilian Portuguese'); - expect(ContentLanguage::French->englishName())->toBe('French'); - expect(ContentLanguage::Chinese->englishName())->toBe('Chinese'); +test('englishName returns a distinct English name for every language', function (ContentLanguage $language, string $expected) { + expect($language->englishName())->toBe($expected); +})->with([ + [ContentLanguage::English, 'English'], + [ContentLanguage::PortugueseBrazil, 'Brazilian Portuguese'], + [ContentLanguage::Spanish, 'Spanish'], + [ContentLanguage::French, 'French'], + [ContentLanguage::German, 'German'], + [ContentLanguage::Italian, 'Italian'], + [ContentLanguage::Dutch, 'Dutch'], + [ContentLanguage::Polish, 'Polish'], + [ContentLanguage::Greek, 'Greek'], + [ContentLanguage::Japanese, 'Japanese'], + [ContentLanguage::Korean, 'Korean'], + [ContentLanguage::Chinese, 'Chinese'], + [ContentLanguage::Russian, 'Russian'], + [ContentLanguage::Turkish, 'Turkish'], + [ContentLanguage::Arabic, 'Arabic'], +]); + +test('only English resolves to the English name', function () { + foreach (ContentLanguage::cases() as $language) { + expect($language->englishName() === 'English')->toBe($language === ContentLanguage::English); + } +}); + +test('label returns the native name for every language', function (ContentLanguage $language, string $expected) { + expect($language->label())->toBe($expected); +})->with([ + [ContentLanguage::English, 'English'], + [ContentLanguage::PortugueseBrazil, 'Português (Brasil)'], + [ContentLanguage::Spanish, 'Español'], + [ContentLanguage::French, 'Français'], + [ContentLanguage::German, 'Deutsch'], + [ContentLanguage::Italian, 'Italiano'], + [ContentLanguage::Dutch, 'Nederlands'], + [ContentLanguage::Polish, 'Polski'], + [ContentLanguage::Greek, 'Ελληνικά'], + [ContentLanguage::Japanese, '日本語'], + [ContentLanguage::Korean, '한국어'], + [ContentLanguage::Chinese, '中文'], + [ContentLanguage::Russian, 'Русский'], + [ContentLanguage::Turkish, 'Türkçe'], + [ContentLanguage::Arabic, 'العربية'], +]); + +test('isRtl is true only for Arabic', function () { + expect(ContentLanguage::Arabic->isRtl())->toBeTrue(); + + foreach (ContentLanguage::cases() as $language) { + if ($language !== ContentLanguage::Arabic) { + expect($language->isRtl())->toBeFalse(); + } + } }); test('fromHtmlLang resolves the two-letter primary subtag', function (string $lang, ?ContentLanguage $expected) {