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:
parent
d27e1622b8
commit
a38ed69d2e
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.',
|
||||
|
||||
];
|
||||
|
|
@ -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' => '« précèdent',
|
||||
'next' => 'Suivant »',
|
||||
|
||||
];
|
||||
|
|
@ -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.',
|
||||
|
||||
];
|
||||
|
|
@ -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' => [],
|
||||
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue