feat(i18n): add French translation support (#532)

Hello,

Here is my pull request for the official french translation.

I can change some traduction if you want

---------

Co-authored-by: Víctor Falcón <victoor89@gmail.com>
This commit is contained in:
elnuage 2026-06-15 19:15:43 +02:00 committed by GitHub
parent d27e1622b8
commit a38ed69d2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 2576 additions and 67 deletions

View File

@ -29,7 +29,7 @@ class SetLocale
protected function determineLocale(Request $request): string
{
// Priority 1: Check for lang query parameter (user override on welcome page)
if ($request->has('lang') && in_array($request->get('lang'), ['en', 'es'])) {
if ($request->has('lang') && in_array($request->get('lang'), ['en', 'es', 'fr'])) {
$locale = $request->get('lang');
// Store in session so subsequent requests remember this choice
$request->session()->put('locale', $locale);
@ -46,7 +46,7 @@ class SetLocale
if ($request->user()) {
$detected = $this->detectLocaleFromHeader($request);
if (in_array($request->session()->get('locale'), ['en', 'es'])) {
if (in_array($request->session()->get('locale'), ['en', 'es', 'fr'])) {
$detected = $request->session()->get('locale');
}
@ -81,6 +81,11 @@ class SetLocale
return 'es';
}
// Check if French is preferred
if (preg_match('/^fr(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'fr') {
return 'fr';
}
return 'en';
}
}

View File

@ -31,7 +31,7 @@ class ProfileUpdateRequest extends FormRequest
Rule::unique(User::class)->ignore($this->user()->id),
],
'currency_code' => ['required', 'string', 'max:3', Rule::in($currencyOptions->primaryCodes())],
'locale' => ['nullable', 'string', Rule::in(['en', 'es'])],
'locale' => ['nullable', 'string', Rule::in(['en', 'es', 'fr'])],
];
}
}

View File

@ -25,7 +25,7 @@ class StoreUserLeadRequest extends FormRequest
return [
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:user_leads,email'],
'referrer_code' => ['nullable', 'string', 'max:12'],
'locale' => ['nullable', 'string', 'in:en,es'],
'locale' => ['nullable', 'string', 'in:en,es,fr'],
];
}

2088
lang/fr.json Normal file

File diff suppressed because it is too large Load Diff

20
lang/fr/auth.php Normal file
View File

@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'Aucun compte enregistré avec les identifiants fournis.',
'password' => 'Le mot de passe est incorect.',
'throttle' => 'Trop de tentatives. Veuillez ressayer dans :seconds secondes.',
];

19
lang/fr/pagination.php Normal file
View File

@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; précèdent',
'next' => 'Suivant &raquo;',
];

22
lang/fr/passwords.php Normal file
View File

@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| outcome such as failure due to an invalid password / reset token.
|
*/
'reset' => 'Votre mot de passe a été réinitialisé.',
'sent' => 'Nous vous avons envoyé par e-mail le lien de réinitialisation de votre mot de passe.',
'throttled' => 'Veuillez patienter avant de réessayer.',
'token' => 'Ce jeton de réinitialisation de mot de passe n\'est pas valide.',
'user' => 'Aucun utilisateur ne correspond à cette adresse e-mail.',
];

199
lang/fr/validation.php Normal file
View File

@ -0,0 +1,199 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'Le champ :attribute doit être accepté.',
'accepted_if' => 'Le champ :attribute doit être accepté lorsque :other vaut :value.',
'active_url' => 'Le champ :attribute doit être une URL valide.',
'after' => 'Le champ :attribute doit être une date postérieure à :date.',
'after_or_equal' => 'Le champ :attribute doit être une date postérieure ou égale à :date.',
'alpha' => 'Le champ :attribute ne doit contenir que des lettres.',
'alpha_dash' => 'Le champ :attribute ne doit contenir que des lettres, des chiffres, des tirets et des traits de soulignement.',
'alpha_num' => 'Le champ :attribute ne doit contenir que des lettres et des chiffres.',
'any_of' => 'Le champ :attribute n\'est pas valide.',
'array' => 'Le champ :attribute doit être un tableau.',
'ascii' => 'Le champ :attribute ne doit contenir que des caractères alphanumériques et des symboles sur un seul octet.',
'before' => 'Le champ :attribute doit être une date antérieure à :date.',
'before_or_equal' => 'Le champ :attribute doit être une date antérieure ou égale à :date.',
'between' => [
'array' => 'Le champ :attribute doit contenir entre :min et :max éléments.',
'file' => 'Le champ :attribute doit faire entre :min et :max kilo-octets.',
'numeric' => 'Le champ :attribute doit être compris entre :min et :max.',
'string' => 'Le champ :attribute doit contenir entre :min et :max caractères.',
],
'boolean' => 'Le champ :attribute doit être vrai ou faux.',
'can' => 'Le champ :attribute contient une valeur non autorisée.',
'confirmed' => 'La confirmation du champ :attribute ne correspond pas.',
'contains' => 'Le champ :attribute n\'inclut pas une valeur requise.',
'current_password' => 'Le mot de passe est incorrect.',
'date' => 'Le champ :attribute doit être une date valide.',
'date_equals' => 'Le champ :attribute doit être une date égale à :date.',
'date_format' => 'Le champ :attribute doit correspondre au format :format.',
'decimal' => 'Le champ :attribute doit comporter :decimal décimales.',
'declined' => 'Le champ :attribute doit être refusé.',
'declined_if' => 'Le champ :attribute doit être refusé lorsque :other vaut :value.',
'different' => 'Les champs :attribute et :other doivent être différents.',
'digits' => 'Le champ :attribute doit comporter :digits chiffres.',
'digits_between' => 'Le champ :attribute doit comporter entre :min et :max chiffres.',
'dimensions' => 'Le champ :attribute a des dimensions d\'image non valides.',
'distinct' => 'Le champ :attribute a une valeur en double.',
'doesnt_contain' => 'Le champ :attribute ne doit contenir aucune des valeurs suivantes : :values.',
'doesnt_end_with' => 'Le champ :attribute ne doit pas se terminer par l\'une des valeurs suivantes : :values.',
'doesnt_start_with' => 'Le champ :attribute ne doit pas commencer par l\'une des valeurs suivantes : :values.',
'email' => 'Le champ :attribute doit être une adresse e-mail valide.',
'encoding' => 'Le champ :attribute doit être encodé en :encoding.',
'ends_with' => 'Le champ :attribute doit se terminer par l\'une des valeurs suivantes : :values.',
'enum' => 'La valeur sélectionnée pour :attribute n\'est pas valide.',
'exists' => 'La valeur sélectionnée pour :attribute n\'est pas valide.',
'extensions' => 'Le champ :attribute doit avoir l\'une des extensions suivantes : :values.',
'file' => 'Le champ :attribute doit être un fichier.',
'filled' => 'Le champ :attribute doit avoir une valeur.',
'gt' => [
'array' => 'Le champ :attribute doit contenir plus de :value éléments.',
'file' => 'Le champ :attribute doit être supérieur à :value kilo-octets.',
'numeric' => 'Le champ :attribute doit être supérieur à :value.',
'string' => 'Le champ :attribute doit contenir plus de :value caractères.',
],
'gte' => [
'array' => 'Le champ :attribute doit contenir :value éléments ou plus.',
'file' => 'Le champ :attribute doit être supérieur ou égal à :value kilo-octets.',
'numeric' => 'Le champ :attribute doit être supérieur ou égal à :value.',
'string' => 'Le champ :attribute doit contenir au moins :value caractères.',
],
'hex_color' => 'Le champ :attribute doit être une couleur hexadécimale valide.',
'image' => 'Le champ :attribute doit être une image.',
'in' => 'La valeur sélectionnée pour :attribute n\'est pas valide.',
'in_array' => 'Le champ :attribute doit exister dans :other.',
'in_array_keys' => 'Le champ :attribute doit contenir au moins l\'une des clés suivantes : :values.',
'integer' => 'Le champ :attribute doit être un entier.',
'ip' => 'Le champ :attribute doit être une adresse IP valide.',
'ipv4' => 'Le champ :attribute doit être une adresse IPv4 valide.',
'ipv6' => 'Le champ :attribute doit être une adresse IPv6 valide.',
'json' => 'Le champ :attribute doit être une chaîne JSON valide.',
'list' => 'Le champ :attribute doit être une liste.',
'lowercase' => 'Le champ :attribute doit être en minuscules.',
'lt' => [
'array' => 'Le champ :attribute doit contenir moins de :value éléments.',
'file' => 'Le champ :attribute doit être inférieur à :value kilo-octets.',
'numeric' => 'Le champ :attribute doit être inférieur à :value.',
'string' => 'Le champ :attribute doit contenir moins de :value caractères.',
],
'lte' => [
'array' => 'Le champ :attribute ne doit pas contenir plus de :value éléments.',
'file' => 'Le champ :attribute doit être inférieur ou égal à :value kilo-octets.',
'numeric' => 'Le champ :attribute doit être inférieur ou égal à :value.',
'string' => 'Le champ :attribute doit contenir au plus :value caractères.',
],
'mac_address' => 'Le champ :attribute doit être une adresse MAC valide.',
'max' => [
'array' => 'Le champ :attribute ne doit pas contenir plus de :max éléments.',
'file' => 'Le champ :attribute ne doit pas dépasser :max kilo-octets.',
'numeric' => 'Le champ :attribute ne doit pas être supérieur à :max.',
'string' => 'Le champ :attribute ne doit pas dépasser :max caractères.',
],
'max_digits' => 'Le champ :attribute ne doit pas comporter plus de :max chiffres.',
'mimes' => 'Le champ :attribute doit être un fichier de type : :values.',
'mimetypes' => 'Le champ :attribute doit être un fichier de type : :values.',
'min' => [
'array' => 'Le champ :attribute doit contenir au moins :min éléments.',
'file' => 'Le champ :attribute doit faire au moins :min kilo-octets.',
'numeric' => 'Le champ :attribute doit être au moins :min.',
'string' => 'Le champ :attribute doit contenir au moins :min caractères.',
],
'min_digits' => 'Le champ :attribute doit comporter au moins :min chiffres.',
'missing' => 'Le champ :attribute doit être absent.',
'missing_if' => 'Le champ :attribute doit être absent lorsque :other vaut :value.',
'missing_unless' => 'Le champ :attribute doit être absent sauf si :other vaut :value.',
'missing_with' => 'Le champ :attribute doit être absent lorsque :values est présent.',
'missing_with_all' => 'Le champ :attribute doit être absent lorsque :values sont présents.',
'multiple_of' => 'Le champ :attribute doit être un multiple de :value.',
'not_in' => 'La valeur sélectionnée pour :attribute n\'est pas valide.',
'not_regex' => 'Le format du champ :attribute n\'est pas valide.',
'numeric' => 'Le champ :attribute doit être un nombre.',
'password' => [
'letters' => 'Le champ :attribute doit contenir au moins une lettre.',
'mixed' => 'Le champ :attribute doit contenir au moins une majuscule et une minuscule.',
'numbers' => 'Le champ :attribute doit contenir au moins un chiffre.',
'symbols' => 'Le champ :attribute doit contenir au moins un symbole.',
'uncompromised' => 'Le :attribute fourni est apparu dans une fuite de données. Veuillez choisir un autre :attribute.',
],
'present' => 'Le champ :attribute doit être présent.',
'present_if' => 'Le champ :attribute doit être présent lorsque :other vaut :value.',
'present_unless' => 'Le champ :attribute doit être présent sauf si :other vaut :value.',
'present_with' => 'Le champ :attribute doit être présent lorsque :values est présent.',
'present_with_all' => 'Le champ :attribute doit être présent lorsque :values sont présents.',
'prohibited' => 'Le champ :attribute est interdit.',
'prohibited_if' => 'Le champ :attribute est interdit lorsque :other vaut :value.',
'prohibited_if_accepted' => 'Le champ :attribute est interdit lorsque :other est accepté.',
'prohibited_if_declined' => 'Le champ :attribute est interdit lorsque :other est refusé.',
'prohibited_unless' => 'Le champ :attribute est interdit sauf si :other fait partie de :values.',
'prohibits' => 'Le champ :attribute empêche :other d\'être présent.',
'regex' => 'Le format du champ :attribute n\'est pas valide.',
'required' => 'Le champ :attribute est obligatoire.',
'required_array_keys' => 'Le champ :attribute doit contenir des entrées pour : :values.',
'required_if' => 'Le champ :attribute est obligatoire lorsque :other vaut :value.',
'required_if_accepted' => 'Le champ :attribute est obligatoire lorsque :other est accepté.',
'required_if_declined' => 'Le champ :attribute est obligatoire lorsque :other est refusé.',
'required_unless' => 'Le champ :attribute est obligatoire sauf si :other fait partie de :values.',
'required_with' => 'Le champ :attribute est obligatoire lorsque :values est présent.',
'required_with_all' => 'Le champ :attribute est obligatoire lorsque :values sont présents.',
'required_without' => 'Le champ :attribute est obligatoire lorsque :values n\'est pas présent.',
'required_without_all' => 'Le champ :attribute est obligatoire lorsque aucun de :values n\'est présent.',
'same' => 'Le champ :attribute doit correspondre à :other.',
'size' => [
'array' => 'Le champ :attribute doit contenir :size éléments.',
'file' => 'Le champ :attribute doit faire :size kilo-octets.',
'numeric' => 'Le champ :attribute doit être :size.',
'string' => 'Le champ :attribute doit contenir :size caractères.',
],
'starts_with' => 'Le champ :attribute doit commencer par l\'une des valeurs suivantes : :values.',
'string' => 'Le champ :attribute doit être une chaîne de caractères.',
'timezone' => 'Le champ :attribute doit être un fuseau horaire valide.',
'unique' => 'La valeur du champ :attribute est déjà utilisée.',
'uploaded' => 'Le téléversement du champ :attribute a échoué.',
'uppercase' => 'Le champ :attribute doit être en majuscules.',
'url' => 'Le champ :attribute doit être une URL valide.',
'ulid' => 'Le champ :attribute doit être un ULID valide.',
'uuid' => 'Le champ :attribute doit être un UUID valide.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap our attribute placeholder
| with something more reader friendly such as "E-Mail Address" instead
| of "email". This simply helps us make our message more expressive.
|
*/
'attributes' => [],
];

View File

@ -13,6 +13,7 @@ import { cn } from '@/lib/utils';
import { store as storeUserLead } from '@/routes/user-leads';
import { type SharedData } from '@/types';
import { type CategoryColor, getCategoryColorClasses } from '@/types/category';
import { LANGUAGE_OPTIONS } from '@/types/language';
import { Plan } from '@/types/pricing';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
@ -2067,7 +2068,11 @@ export default function Welcome({
const urlParams = new URLSearchParams(window.location.search);
const langParam = urlParams.get('lang');
if (langParam && ['en', 'es'].includes(langParam)) {
const supportedLocales: readonly string[] = LANGUAGE_OPTIONS.map(
(option) => option.code,
);
if (langParam && supportedLocales.includes(langParam)) {
// Store the preference in localStorage
localStorage.setItem('whisper_landing_locale', langParam);
} else {
@ -2077,7 +2082,7 @@ export default function Welcome({
if (
storedLocale &&
storedLocale !== locale &&
['en', 'es'].includes(storedLocale)
supportedLocales.includes(storedLocale)
) {
// Redirect to stored preference
window.location.href = `/?lang=${storedLocale}`;
@ -2858,12 +2863,17 @@ export default function Welcome({
>
{__('Terms of Service')}
</Link>
<a
href={`/?lang=${locale === 'es' ? 'en' : 'es'}`}
className="cursor-pointer hover:text-[#1b1b18] dark:hover:text-[#EDEDEC]"
>
{locale === 'es' ? 'English' : 'Español'}
</a>
{LANGUAGE_OPTIONS.filter(
(option) => option.code !== locale,
).map((option) => (
<a
key={option.code}
href={`/?lang=${option.code}`}
className="cursor-pointer hover:text-[#1b1b18] dark:hover:text-[#EDEDEC]"
>
{option.label}
</a>
))}
</div>
</div>
</footer>

View File

@ -1,6 +1,7 @@
export const LANGUAGE_OPTIONS = [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' },
{ code: 'fr', label: 'Français' },
] as const;
export type LocaleCode = 'en' | 'es';
export type LocaleCode = 'en' | 'es' | 'fr';

View File

@ -1,13 +1,33 @@
<?php
use PHPUnit\Framework\Assert;
/**
* Localization completeness tests.
*
* These tests ensure every translatable string used in the application
* has a corresponding Spanish translation, preventing untranslated content
* from reaching production.
* Spanish is the reference locale and is strictly enforced: any English
* string without a Spanish translation fails the suite, preventing
* untranslated content from reaching production.
*
* French is an optional, community-maintained locale. Its gaps only emit a
* warning an incomplete French translation never blocks CI, but the run
* still surfaces exactly which keys are missing so they can be filled in.
*/
/**
* Locales whose translation gaps must fail the suite.
*
* @var array<string>
*/
const ENFORCED_LOCALES = ['es'];
/**
* Locales whose translation gaps only emit a warning.
*
* @var array<string>
*/
const OPTIONAL_LOCALES = ['fr'];
/**
* Recursively flattens a nested PHP translation array using dot notation.
*
@ -69,68 +89,144 @@ function extractI18nKeysFromSource(): array
return array_unique($keys);
}
describe('Spanish PHP translation files', function () {
it('has every key from the English PHP files translated', function () {
$englishDir = lang_path('en');
$missingByFile = [];
/**
* Computes the PHP translation keys present in lang/en but missing from the
* given locale, grouped by filename. A wholly absent file is reported as the
* sentinel "__missing_file__".
*
* @return array<string, array<string>>
*/
function missingPhpTranslationKeys(string $locale): array
{
$englishDir = lang_path('en');
$missingByFile = [];
/** @var SplFileInfo $file */
foreach (new FilesystemIterator($englishDir, FilesystemIterator::SKIP_DOTS) as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$filename = $file->getFilename();
$spanishPath = lang_path("es/{$filename}");
if (! file_exists($spanishPath)) {
$missingByFile[$filename] = ['__missing_file__'];
continue;
}
$englishKeys = flattenTranslations(require $file->getPathname());
$spanishKeys = flattenTranslations(require $spanishPath);
$missingKeys = array_keys(array_diff_key($englishKeys, $spanishKeys));
if ($missingKeys !== []) {
$missingByFile[$filename] = $missingKeys;
}
/** @var SplFileInfo $file */
foreach (new FilesystemIterator($englishDir, FilesystemIterator::SKIP_DOTS) as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$report = implode("\n", array_map(
fn (string $f, array $keys) => "lang/es/{$f}:\n - ".implode("\n - ", $keys),
array_keys($missingByFile),
$missingByFile,
));
$filename = $file->getFilename();
$localePath = lang_path("{$locale}/{$filename}");
if (! file_exists($localePath)) {
$missingByFile[$filename] = ['__missing_file__'];
continue;
}
$englishKeys = flattenTranslations(require $file->getPathname());
$localeKeys = flattenTranslations(require $localePath);
$missingKeys = array_keys(array_diff_key($englishKeys, $localeKeys));
if ($missingKeys !== []) {
$missingByFile[$filename] = $missingKeys;
}
}
return $missingByFile;
}
/**
* Renders a human-readable report of missing PHP translation keys.
*
* @param array<string, array<string>> $missingByFile
*/
function formatPhpTranslationReport(string $locale, array $missingByFile): string
{
return implode("\n", array_map(
fn (string $f, array $keys) => "lang/{$locale}/{$f}:\n - ".implode("\n - ", $keys),
array_keys($missingByFile),
$missingByFile,
));
}
/**
* Computes the __() source keys missing from the given locale's JSON file.
* A missing or malformed file is reported via a sentinel entry.
*
* @return array<string>
*/
function missingJsonTranslationKeys(string $locale): array
{
$jsonPath = lang_path("{$locale}.json");
if (! file_exists($jsonPath)) {
return ['__missing_file__'];
}
$json = json_decode(file_get_contents($jsonPath), associative: true);
if (! is_array($json)) {
return ['__invalid_json__'];
}
$sourceKeys = extractI18nKeysFromSource();
$missingKeys = array_values(array_filter(
$sourceKeys,
fn (string $key) => ! array_key_exists($key, $json)
));
sort($missingKeys);
return $missingKeys;
}
describe('enforced PHP translations', function () {
it('has every English PHP key translated', function (string $locale) {
$missingByFile = missingPhpTranslationKeys($locale);
expect($missingByFile)->toBeEmpty(
"Spanish PHP translation files have missing keys:\n{$report}"
"{$locale} PHP translation files have missing keys:\n".formatPhpTranslationReport($locale, $missingByFile)
);
});
})->with(ENFORCED_LOCALES);
});
describe('Spanish JSON translations', function () {
it('has every __() key from TypeScript source files translated in es.json', function () {
$spanishJsonPath = lang_path('es.json');
describe('enforced JSON translations', function () {
it('has every __() source key translated', function (string $locale) {
expect(lang_path("{$locale}.json"))->toBeFile("Missing lang/{$locale}.json translation file");
expect($spanishJsonPath)->toBeFile('Missing lang/es.json translation file');
$missingKeys = missingJsonTranslationKeys($locale);
$spanishJson = json_decode(file_get_contents($spanishJsonPath), associative: true);
expect($missingKeys)->toBeEmpty(
count($missingKeys)." key(s) used in source via __() are missing from lang/{$locale}.json:\n - ".implode("\n - ", $missingKeys)
);
})->with(ENFORCED_LOCALES);
});
expect($spanishJson)->toBeArray('lang/es.json must contain a valid JSON object');
describe('optional PHP translations', function () {
it('warns about untranslated PHP keys', function (string $locale) {
$missingByFile = missingPhpTranslationKeys($locale);
$sourceKeys = extractI18nKeysFromSource();
$missingKeys = array_values(array_filter(
$sourceKeys,
fn (string $key) => ! array_key_exists($key, $spanishJson)
));
sort($missingKeys);
expect($missingKeys)
->toBeEmpty(
count($missingKeys)." key(s) used in source via __() are missing from lang/es.json:\n - ".implode("\n - ", $missingKeys)
if ($missingByFile !== []) {
// Optional locale: surface the gap as an incomplete test so it stays
// visible in the run summary without failing CI (no failOnIncomplete
// is configured).
Assert::markTestIncomplete(
"Optional locale '{$locale}' has missing PHP translation keys (not blocking):\n"
.formatPhpTranslationReport($locale, $missingByFile)
);
});
}
expect($missingByFile)->toBeEmpty();
})->with(OPTIONAL_LOCALES);
});
describe('optional JSON translations', function () {
it('warns about __() source keys missing from the locale JSON', function (string $locale) {
$missingKeys = missingJsonTranslationKeys($locale);
if ($missingKeys !== []) {
// Optional locale: surface the gap as an incomplete test so it stays
// visible in the run summary without failing CI (no failOnIncomplete
// is configured).
Assert::markTestIncomplete(
count($missingKeys)." key(s) used in source via __() are missing from lang/{$locale}.json (not blocking):\n - "
.implode("\n - ", $missingKeys)
);
}
expect($missingKeys)->toBeEmpty();
})->with(OPTIONAL_LOCALES);
});

View File

@ -0,0 +1,49 @@
<?php
use App\Http\Middleware\SetLocale;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\App;
/**
* Runs a request through the SetLocale middleware and returns the locale the
* application ended up using.
*/
function resolveLocaleFor(Request $request): string
{
$request->setLaravelSession(app('session.store'));
(new SetLocale)->handle($request, fn () => new Response);
return App::getLocale();
}
it('applies a supported locale from the lang query parameter', function (string $locale) {
$request = Request::create('/', 'GET', ['lang' => $locale]);
expect(resolveLocaleFor($request))->toBe($locale);
})->with(['en', 'es', 'fr']);
it('ignores an unsupported lang query parameter', function () {
$request = Request::create('/', 'GET', ['lang' => 'de']);
expect(resolveLocaleFor($request))->not->toBe('de');
});
it('detects French from the Accept-Language header', function () {
$request = Request::create('/', 'GET', server: ['HTTP_ACCEPT_LANGUAGE' => 'fr-FR,fr;q=0.9,en;q=0.8']);
expect(resolveLocaleFor($request))->toBe('fr');
});
it('detects Spanish from the Accept-Language header', function () {
$request = Request::create('/', 'GET', server: ['HTTP_ACCEPT_LANGUAGE' => 'es-ES,es;q=0.9']);
expect(resolveLocaleFor($request))->toBe('es');
});
it('falls back to English for an unrecognized Accept-Language header', function () {
$request = Request::create('/', 'GET', server: ['HTTP_ACCEPT_LANGUAGE' => 'de-DE,de;q=0.9']);
expect(resolveLocaleFor($request))->toBe('en');
});