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')); });