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.
This commit is contained in:
parent
519175f9fd
commit
c7be1af124
13 changed files with 187 additions and 29 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<int, array{value: string, label: string}>
|
||||
* @return array<int, array{value: string, label: string, englishName: string}>
|
||||
*/
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
'type' => 'اكتب',
|
||||
'to_confirm' => 'للتأكيد.',
|
||||
'copy_to_clipboard' => 'نسخ إلى الحافظة',
|
||||
'delete_keyword' => 'delete',
|
||||
'delete_keyword' => 'حذف',
|
||||
],
|
||||
|
||||
'photo_upload' => [
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
'type' => 'Πληκτρολογήστε',
|
||||
'to_confirm' => 'για επιβεβαίωση.',
|
||||
'copy_to_clipboard' => 'Αντιγραφή στο πρόχειρο',
|
||||
'delete_keyword' => 'delete',
|
||||
'delete_keyword' => 'διαγραφή',
|
||||
],
|
||||
|
||||
'photo_upload' => [
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
'type' => '入力',
|
||||
'to_confirm' => 'で確認してください。',
|
||||
'copy_to_clipboard' => 'クリップボードにコピー',
|
||||
'delete_keyword' => 'delete',
|
||||
'delete_keyword' => '削除',
|
||||
],
|
||||
|
||||
'photo_upload' => [
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
'type' => '输入',
|
||||
'to_confirm' => '以确认。',
|
||||
'copy_to_clipboard' => '复制到剪贴板',
|
||||
'delete_keyword' => 'delete',
|
||||
'delete_keyword' => '删除',
|
||||
],
|
||||
|
||||
'photo_upload' => [
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ const select = (font: string) => {
|
|||
<span :style="{ fontFamily: `'${value}', sans-serif` }">
|
||||
{{ value || placeholder }}
|
||||
</span>
|
||||
<IconChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
<IconChevronDown class="ms-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ const select = (font: string) => {
|
|||
>
|
||||
<span :style="{ fontFamily: `'${font}', sans-serif` }">{{ font }}</span>
|
||||
<IconCheck
|
||||
:class="cn('ml-auto size-4', value === font ? 'opacity-100' : 'opacity-0')"
|
||||
:class="cn('ms-auto size-4', value === font ? 'opacity-100' : 'opacity-0')"
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
<span :class="selectedLabel ? '' : 'text-muted-foreground'">
|
||||
{{ selectedLabel || placeholder }}
|
||||
</span>
|
||||
<IconChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
<IconChevronDown class="ms-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
|
|
@ -62,11 +62,12 @@ const select = (code: string) => {
|
|||
<CommandItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.label"
|
||||
:value="option.value"
|
||||
@select="select(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<IconCheck :class="cn('ml-auto size-4', value === option.value ? 'opacity-100' : 'opacity-0')" />
|
||||
<span v-if="option.englishName" class="sr-only">{{ option.englishName }}</span>
|
||||
<IconCheck :class="cn('ms-auto size-4', value === option.value ? 'opacity-100' : 'opacity-0')" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
<html lang="de">
|
||||
<head>
|
||||
<title>Beispiel GmbH</title>
|
||||
<meta name="description" content="Kurze Beschreibung.">
|
||||
</head>
|
||||
<body><main><p>Wir bauen Widgets.</p></main></body>
|
||||
</html>
|
||||
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([
|
||||
|
|
|
|||
44
tests/Feature/Middleware/SetLocaleTest.php
Normal file
44
tests/Feature/Middleware/SetLocaleTest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Middleware\App\SetLocale;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
/**
|
||||
* Run the middleware with the given `locale` cookie (null = no cookie) and
|
||||
* return the shared `htmlDir` the Blade root view renders into `<html dir>`.
|
||||
*/
|
||||
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'));
|
||||
});
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue