From f7ef13c11676dbb69ae5dca148921032a2971d8f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 14:12:25 -0300 Subject: [PATCH] Centralize supported content languages in a ContentLanguage enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new content-language options were hand-duplicated across request validation, the UI picker, and homepage detection, while the brand analyzer's structured-output enum and the AI image prompt's language name still only knew about en/pt-BR/es. That left autofill unable to detect the new languages and made image text fall back to English for them. Introduce App\Enums\Workspace\ContentLanguage as the single source of truth and derive every site from it: - Store/UpdateWorkspaceRequest validate against ContentLanguage::values() - BrandAnalyzer's language enum uses ContentLanguage::values() - AiImageClient::languageName() resolves via the enum's englishName() - HomepageMetaExtractor detects through ContentLanguage::fromHtmlLang() - BrandForm consumes availableContentLanguages from the backend, like availableFonts/availableImageStyles, instead of a hardcoded list Also fix two labels: nl "Nederlandse" -> "Nederlands", zh -> "中文". --- app/Ai/Agents/BrandAnalyzer.php | 3 +- app/Enums/Workspace/ContentLanguage.php | 125 ++++++++++++++++++ .../Controllers/App/WorkspaceController.php | 3 + .../App/Workspace/StoreWorkspaceRequest.php | 3 +- .../App/Workspace/UpdateWorkspaceRequest.php | 3 +- app/Services/Ai/AiImageClient.php | 7 +- app/Services/Brand/HomepageMetaExtractor.php | 22 +-- resources/js/components/BrandForm.vue | 42 +----- resources/js/components/settings/BrandTab.vue | 2 + .../js/pages/settings/workspace/Brand.vue | 2 + resources/js/pages/workspaces/Create.vue | 2 + tests/Feature/Ai/AutofillBrandTest.php | 6 +- tests/Feature/WorkspaceControllerTest.php | 24 ++++ .../Enums/Workspace/ContentLanguageTest.php | 47 +++++++ tests/Unit/Services/Ai/AiImageClientTest.php | 11 +- 15 files changed, 236 insertions(+), 66 deletions(-) create mode 100644 app/Enums/Workspace/ContentLanguage.php create mode 100644 tests/Unit/Enums/Workspace/ContentLanguageTest.php diff --git a/app/Ai/Agents/BrandAnalyzer.php b/app/Ai/Agents/BrandAnalyzer.php index 124b1f79..7e34c5a0 100644 --- a/app/Ai/Agents/BrandAnalyzer.php +++ b/app/Ai/Agents/BrandAnalyzer.php @@ -5,6 +5,7 @@ namespace App\Ai\Agents; use App\Enums\Workspace\BrandVoiceTrait; +use App\Enums\Workspace\ContentLanguage; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasStructuredOutput; @@ -47,7 +48,7 @@ public function schema(JsonSchema $schema): array ->description('A concise 2-3 sentence brand description summarizing what the company does, who they serve, and what makes them unique. Written in the detected content language.') ->required(), 'language' => $schema->string() - ->enum(['en', 'pt-BR', 'es']) + ->enum(ContentLanguage::values()) ->description('The primary language of the content.') ->required(), 'brand_color' => $schema->string() diff --git a/app/Enums/Workspace/ContentLanguage.php b/app/Enums/Workspace/ContentLanguage.php new file mode 100644 index 00000000..cffa22ec --- /dev/null +++ b/app/Enums/Workspace/ContentLanguage.php @@ -0,0 +1,125 @@ + 'English', + self::PortugueseBrazil => 'Português (Brasil)', + self::Spanish => 'Español', + self::French => 'Français', + self::German => 'Deutsch', + self::Italian => 'Italiano', + self::Dutch => 'Nederlands', + self::Polish => 'Polski', + self::Greek => 'Ελληνικά', + self::Japanese => '日本語', + self::Korean => '한국어', + self::Chinese => '中文', + self::Russian => 'Русский', + self::Turkish => 'Türkçe', + self::Arabic => 'العربية', + }; + } + + /** + * The English name of the language, injected into AI image prompts so the + * in-image text is rendered in the workspace's content language. + */ + public function englishName(): string + { + return match ($this) { + self::English => 'English', + self::PortugueseBrazil => 'Brazilian Portuguese', + self::Spanish => 'Spanish', + self::French => 'French', + self::German => 'German', + self::Italian => 'Italian', + self::Dutch => 'Dutch', + self::Polish => 'Polish', + self::Greek => 'Greek', + self::Japanese => 'Japanese', + self::Korean => 'Korean', + self::Chinese => 'Chinese', + self::Russian => 'Russian', + self::Turkish => 'Turkish', + self::Arabic => 'Arabic', + }; + } + + /** + * Resolve a raw `` value (e.g. "pt-PT", "en-US") to a supported + * language by matching its two-letter primary subtag, or null if none fits. + */ + public static function fromHtmlLang(string $lang): ?self + { + $prefix = strtolower(substr(trim($lang), 0, 2)); + + if (strlen($prefix) < 2) { + return null; + } + + foreach (self::cases() as $language) { + if (str_starts_with($language->value, $prefix)) { + return $language; + } + } + + return null; + } + + /** + * @return array + */ + public static function values(): array + { + return array_map(fn (self $language) => $language->value, self::cases()); + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + fn (self $language) => ['value' => $language->value, 'label' => $language->label()], + self::cases(), + ); + } +} diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index ee500be8..e26a22f7 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -9,6 +9,7 @@ use App\Actions\Workspace\DeleteWorkspace; use App\Enums\Workspace\BrandFont; use App\Enums\Workspace\BrandVoiceTrait; +use App\Enums\Workspace\ContentLanguage; use App\Enums\Workspace\ImageStyle; use App\Http\Requests\App\Workspace\AutofillBrandRequest; use App\Http\Requests\App\Workspace\StoreWorkspaceRequest; @@ -80,6 +81,7 @@ public function create(Request $request): Response|RedirectResponse 'availableFonts' => BrandFont::values(), 'availableImageStyles' => ImageStyle::values(), 'availableVoiceTraits' => BrandVoiceTrait::grouped(), + 'availableContentLanguages' => ContentLanguage::options(), ]); } @@ -163,6 +165,7 @@ public function brandSettings(Request $request): Response|RedirectResponse 'availableFonts' => BrandFont::values(), 'availableImageStyles' => ImageStyle::values(), 'availableVoiceTraits' => BrandVoiceTrait::grouped(), + 'availableContentLanguages' => ContentLanguage::options(), ]); } diff --git a/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php b/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php index 59826bab..4c571596 100644 --- a/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php +++ b/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php @@ -6,6 +6,7 @@ use App\Enums\Workspace\BrandFont; use App\Enums\Workspace\BrandVoiceTrait; +use App\Enums\Workspace\ContentLanguage; use App\Enums\Workspace\ImageStyle; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -32,7 +33,7 @@ public function rules(): array 'text_color' => $hex, 'brand_font' => ['sometimes', 'string', Rule::in(BrandFont::values())], 'image_style' => ['sometimes', 'string', Rule::in(ImageStyle::values())], - 'content_language' => ['nullable', 'string', 'in:en,pt-BR,es,fr,de,it,nl,pl,el,ja,ko,zh,ru,tr,ar'], + 'content_language' => ['nullable', 'string', Rule::in(ContentLanguage::values())], 'logo_url' => ['nullable', 'url', 'max:1024'], ]; } diff --git a/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php b/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php index 476e63ce..4a8f0dba 100644 --- a/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php +++ b/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php @@ -6,6 +6,7 @@ use App\Enums\Workspace\BrandFont; use App\Enums\Workspace\BrandVoiceTrait; +use App\Enums\Workspace\ContentLanguage; use App\Enums\Workspace\ImageStyle; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -32,7 +33,7 @@ public function rules(): array 'text_color' => $hex, 'brand_font' => ['sometimes', 'required', 'string', Rule::in(BrandFont::values())], 'image_style' => ['sometimes', 'required', 'string', Rule::in(ImageStyle::values())], - 'content_language' => ['sometimes', 'string', 'in:en,pt-BR,es,fr,de,it,nl,pl,el,ja,ko,zh,ru,tr,ar'], + 'content_language' => ['sometimes', 'string', Rule::in(ContentLanguage::values())], ]; } diff --git a/app/Services/Ai/AiImageClient.php b/app/Services/Ai/AiImageClient.php index bd7eb193..fe15e10e 100644 --- a/app/Services/Ai/AiImageClient.php +++ b/app/Services/Ai/AiImageClient.php @@ -4,6 +4,7 @@ namespace App\Services\Ai; +use App\Enums\Workspace\ContentLanguage; use App\Enums\Workspace\ImageStyle; use App\Support\HexColorName; use Illuminate\Support\Facades\Log; @@ -89,11 +90,7 @@ public function generate( private function languageName(string $code): string { - return match ($code) { - 'pt-BR' => 'Brazilian Portuguese', - 'es' => 'Spanish', - default => 'English', - }; + return (ContentLanguage::tryFrom($code) ?? ContentLanguage::DEFAULT)->englishName(); } /** diff --git a/app/Services/Brand/HomepageMetaExtractor.php b/app/Services/Brand/HomepageMetaExtractor.php index 5291330e..8d37b064 100644 --- a/app/Services/Brand/HomepageMetaExtractor.php +++ b/app/Services/Brand/HomepageMetaExtractor.php @@ -4,6 +4,7 @@ namespace App\Services\Brand; +use App\Enums\Workspace\ContentLanguage; use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\UriResolver; @@ -291,26 +292,7 @@ private function extractLanguage(Crawler $crawler): ?string return null; } - $lower = strtolower($lang); - - return match (true) { - str_starts_with($lower, 'pt') => 'pt-BR', - str_starts_with($lower, 'es') => 'es', - str_starts_with($lower, 'en') => 'en', - str_starts_with($lower, 'fr') => 'fr', - str_starts_with($lower, 'de') => 'de', - str_starts_with($lower, 'it') => 'it', - str_starts_with($lower, 'nl') => 'nl', - str_starts_with($lower, 'pl') => 'pl', - str_starts_with($lower, 'el') => 'el', - str_starts_with($lower, 'ja') => 'ja', - str_starts_with($lower, 'ko') => 'ko', - str_starts_with($lower, 'zh') => 'zh', - str_starts_with($lower, 'ru') => 'ru', - str_starts_with($lower, 'tr') => 'tr', - str_starts_with($lower, 'ar') => 'ar', - default => null, - }; + return ContentLanguage::fromHtmlLang($lang)?->value; } private function extractLogoUrl(Crawler $crawler, string $baseUrl): ?string diff --git a/resources/js/components/BrandForm.vue b/resources/js/components/BrandForm.vue index a46a06d9..11f432a8 100644 --- a/resources/js/components/BrandForm.vue +++ b/resources/js/components/BrandForm.vue @@ -61,6 +61,7 @@ const props = withDefaults( availableFonts: string[]; availableImageStyles: string[]; availableVoiceTraits: Record; + availableContentLanguages: { value: string; label: string }[]; autofill?: boolean; showName?: boolean; }>(), @@ -95,26 +96,9 @@ const toggleTrait = (group: string, value: string): void => { props.fields.brand_voice_traits = [...current.filter((v) => !groupValues.includes(v)), value]; }; -const languageLabel = computed(() => { - const map: Record = { - en: 'English', - 'pt-BR': 'Português (Brasil)', - es: 'Español', - fr: 'Français', - de: 'Deutsch', - it: 'Italiano', - nl: 'Nederlandse', - pl: 'Polski', - el: 'Ελληνικά', - ja: '日本語', - ko: '한국어', - zh: '现代标准汉语', - ru: 'Русский', - tr: 'Türkçe', - ar: 'العربية', - }; - return map[props.fields.content_language] ?? ''; -}); +const languageLabel = computed( + () => props.availableContentLanguages.find((language) => language.value === props.fields.content_language)?.label ?? '', +); const runAutofill = async () => { const url = props.fields.brand_website?.trim() ?? ''; @@ -209,21 +193,9 @@ const runAutofill = async () => { - English - Português (Brasil) - Español - Français - Deutsch - Italiano - Nederlandse - Polski - Ελληνικά - 日本語 - 한국어 - 现代标准汉语 - Русский - Türkçe - العربية + + {{ language.label }} +

diff --git a/resources/js/components/settings/BrandTab.vue b/resources/js/components/settings/BrandTab.vue index dfe2804d..276aa60e 100644 --- a/resources/js/components/settings/BrandTab.vue +++ b/resources/js/components/settings/BrandTab.vue @@ -25,6 +25,7 @@ const props = defineProps<{ availableFonts: string[]; availableImageStyles: string[]; availableVoiceTraits: Record; + availableContentLanguages: { value: string; label: string }[]; }>(); const form = useForm({ @@ -58,6 +59,7 @@ const submit = () => { :available-fonts="availableFonts" :available-image-styles="availableImageStyles" :available-voice-traits="availableVoiceTraits" + :available-content-languages="availableContentLanguages" :autofill="!workspace.brand_website" /> diff --git a/resources/js/pages/settings/workspace/Brand.vue b/resources/js/pages/settings/workspace/Brand.vue index 7a25c423..7e29d74b 100644 --- a/resources/js/pages/settings/workspace/Brand.vue +++ b/resources/js/pages/settings/workspace/Brand.vue @@ -30,6 +30,7 @@ defineProps<{ availableFonts: string[]; availableImageStyles: string[]; availableVoiceTraits: Record; + availableContentLanguages: { value: string; label: string }[]; }>(); const tabs = computed(() => [ @@ -57,6 +58,7 @@ const tabs = computed(() => [ :available-fonts="availableFonts" :available-image-styles="availableImageStyles" :available-voice-traits="availableVoiceTraits" + :available-content-languages="availableContentLanguages" /> diff --git a/resources/js/pages/workspaces/Create.vue b/resources/js/pages/workspaces/Create.vue index 6009a65f..bb96ce62 100644 --- a/resources/js/pages/workspaces/Create.vue +++ b/resources/js/pages/workspaces/Create.vue @@ -10,6 +10,7 @@ defineProps<{ availableFonts: string[]; availableImageStyles: string[]; availableVoiceTraits: Record; + availableContentLanguages: { value: string; label: string }[]; }>(); const form = useForm({ @@ -45,6 +46,7 @@ const submit = () => { :available-fonts="availableFonts" :available-image-styles="availableImageStyles" :available-voice-traits="availableVoiceTraits" + :available-content-languages="availableContentLanguages" :autofill="true" :show-name="true" /> diff --git a/tests/Feature/Ai/AutofillBrandTest.php b/tests/Feature/Ai/AutofillBrandTest.php index 8cea1673..e4be9ded 100644 --- a/tests/Feature/Ai/AutofillBrandTest.php +++ b/tests/Feature/Ai/AutofillBrandTest.php @@ -104,8 +104,10 @@ ['pt-PT', 'pt-BR'], ['en-US', 'en'], ['es-MX', 'es'], - ['fr', null], - ['ja-JP', null], + ['fr', 'fr'], + ['ja-JP', 'ja'], + ['zh-Hans', 'zh'], + ['sv', null], ]); test('rejects non-http schemes', function () { diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php index b1d0a743..3bb04ced 100644 --- a/tests/Feature/WorkspaceControllerTest.php +++ b/tests/Feature/WorkspaceControllerTest.php @@ -4,6 +4,7 @@ use App\Ai\Agents\BrandAnalyzer; use App\Enums\UserWorkspace\Role; +use App\Enums\Workspace\ContentLanguage; use App\Models\Account; use App\Models\AiUsageLog; use App\Models\User; @@ -59,6 +60,8 @@ $response->assertOk(); $response->assertInertia(fn ($page) => $page ->component('workspaces/Create', false) + ->has('availableContentLanguages', count(ContentLanguage::cases())) + ->where('availableContentLanguages.0', ['value' => 'en', 'label' => 'English']) ); }); @@ -187,6 +190,7 @@ $response->assertInertia(fn ($page) => $page ->component('settings/workspace/Brand', false) ->has('workspace') + ->has('availableContentLanguages', count(ContentLanguage::cases())) ); }); @@ -288,6 +292,26 @@ ])->assertSessionHasErrors(['image_style']); }); +test('update workspace settings persists a newly supported content language', function () { + $this->actingAs($this->user) + ->from(route('app.workspace.brand')) + ->put(route('app.workspace.settings.update'), [ + 'name' => $this->workspace->name, + 'content_language' => 'fr', + ])->assertRedirect(route('app.workspace.brand')) + ->assertSessionHasNoErrors(); + + expect($this->workspace->refresh()->content_language)->toBe('fr'); +}); + +test('update workspace settings rejects an unsupported content language', function () { + $this->actingAs($this->user) + ->put(route('app.workspace.settings.update'), [ + 'name' => $this->workspace->name, + 'content_language' => 'sv', + ])->assertSessionHasErrors(['content_language']); +}); + test('update workspace settings validates required fields', function () { $response = $this->actingAs($this->user)->put(route('app.workspace.settings.update'), [ 'name' => '', diff --git a/tests/Unit/Enums/Workspace/ContentLanguageTest.php b/tests/Unit/Enums/Workspace/ContentLanguageTest.php new file mode 100644 index 00000000..525d66d1 --- /dev/null +++ b/tests/Unit/Enums/Workspace/ContentLanguageTest.php @@ -0,0 +1,47 @@ +toBe([ + 'en', 'pt-BR', 'es', 'fr', 'de', 'it', 'nl', + 'pl', 'el', 'ja', 'ko', 'zh', 'ru', 'tr', 'ar', + ]); +}); + +test('default language is English', function () { + expect(ContentLanguage::DEFAULT)->toBe(ContentLanguage::English); + expect(ContentLanguage::DEFAULT->value)->toBe('en'); +}); + +test('options pairs each code with its native 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' => '日本語']); +}); + +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('fromHtmlLang resolves the two-letter primary subtag', function (string $lang, ?ContentLanguage $expected) { + expect(ContentLanguage::fromHtmlLang($lang))->toBe($expected); +})->with([ + ['pt', ContentLanguage::PortugueseBrazil], + ['pt-PT', ContentLanguage::PortugueseBrazil], + ['en-US', ContentLanguage::English], + ['es-MX', ContentLanguage::Spanish], + ['fr', ContentLanguage::French], + ['ja-JP', ContentLanguage::Japanese], + ['zh-Hans', ContentLanguage::Chinese], + ['sv', null], + ['e', null], + ['', null], +]); diff --git a/tests/Unit/Services/Ai/AiImageClientTest.php b/tests/Unit/Services/Ai/AiImageClientTest.php index db372faa..5b65f2bd 100644 --- a/tests/Unit/Services/Ai/AiImageClientTest.php +++ b/tests/Unit/Services/Ai/AiImageClientTest.php @@ -81,12 +81,21 @@ Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('Spanish')); }); -test('generate defaults to English instruction when language is unknown', function () { +test('generate appends French instruction when language is fr', function () { Image::fake(); $client = new AiImageClient; $client->generate(['x'], ImageStyle::Cinematic, language: 'fr'); + Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('French')); +}); + +test('generate defaults to English instruction when language is unsupported', function () { + Image::fake(); + + $client = new AiImageClient; + $client->generate(['x'], ImageStyle::Cinematic, language: 'sv'); + Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('English')); });