Centralize supported content languages in a ContentLanguage enum

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 -> "中文".
This commit is contained in:
Paulo Castellano 2026-07-03 14:12:25 -03:00
parent eb0ec6fb24
commit f7ef13c116
15 changed files with 236 additions and 66 deletions

View file

@ -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()

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
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 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.
*/
enum ContentLanguage: string
{
case English = 'en';
case PortugueseBrazil = 'pt-BR';
case Spanish = 'es';
case French = 'fr';
case German = 'de';
case Italian = 'it';
case Dutch = 'nl';
case Polish = 'pl';
case Greek = 'el';
case Japanese = 'ja';
case Korean = 'ko';
case Chinese = 'zh';
case Russian = 'ru';
case Turkish = 'tr';
case Arabic = 'ar';
public const DEFAULT = self::English;
/**
* The language's own name, shown in the content-language picker.
*/
public function label(): string
{
return match ($this) {
self::English => '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 `<html lang>` 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<int, string>
*/
public static function values(): array
{
return array_map(fn (self $language) => $language->value, self::cases());
}
/**
* @return array<int, array{value: string, label: string}>
*/
public static function options(): array
{
return array_map(
fn (self $language) => ['value' => $language->value, 'label' => $language->label()],
self::cases(),
);
}
}

View file

@ -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(),
]);
}

View file

@ -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'],
];
}

View file

@ -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())],
];
}

View file

@ -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();
}
/**

View file

@ -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

View file

@ -61,6 +61,7 @@ const props = withDefaults(
availableFonts: string[];
availableImageStyles: string[];
availableVoiceTraits: Record<string, string[]>;
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<string, string> = {
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 () => {
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
<SelectItem value="es">Español</SelectItem>
<SelectItem value="fr">Français</SelectItem>
<SelectItem value="de">Deutsch</SelectItem>
<SelectItem value="it">Italiano</SelectItem>
<SelectItem value="nl">Nederlandse</SelectItem>
<SelectItem value="pl">Polski</SelectItem>
<SelectItem value="el">Ελληνικά</SelectItem>
<SelectItem value="ja">日本語</SelectItem>
<SelectItem value="ko">한국어</SelectItem>
<SelectItem value="zh">现代标准汉语</SelectItem>
<SelectItem value="ru">Русский</SelectItem>
<SelectItem value="tr">Türkçe</SelectItem>
<SelectItem value="ar">العربية</SelectItem>
<SelectItem v-for="language in availableContentLanguages" :key="language.value" :value="language.value">
{{ language.label }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-xs font-medium text-foreground/60">

View file

@ -25,6 +25,7 @@ const props = defineProps<{
availableFonts: string[];
availableImageStyles: string[];
availableVoiceTraits: Record<string, string[]>;
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"
/>

View file

@ -30,6 +30,7 @@ defineProps<{
availableFonts: string[];
availableImageStyles: string[];
availableVoiceTraits: Record<string, string[]>;
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"
/>
</div>
</AppLayout>

View file

@ -10,6 +10,7 @@ defineProps<{
availableFonts: string[];
availableImageStyles: string[];
availableVoiceTraits: Record<string, string[]>;
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"
/>

View file

@ -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 () {

View file

@ -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' => '',

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Enums\Workspace\ContentLanguage;
test('values exposes every supported content-language code', function () {
expect(ContentLanguage::values())->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],
]);

View file

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