Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
51c58e0
Add FileValidator-Function for composer.json
Crizz0 Sep 25, 2025
4b49c5b
Add preg_match limiter /
Crizz0 Sep 25, 2025
42d3e74
Repair language-iso and name check of composer.json
Crizz0 Sep 25, 2025
c8e6b9a
Remove ValidateIsoTest.php from tests/
Crizz0 Sep 25, 2025
8449fc3
Change plural-rule guessing to composer.json
Crizz0 Sep 26, 2025
40177ad
Use a separate function for recaptcha and turnstile lang-key check
Crizz0 Sep 26, 2025
06cc5ae
Use a dedi function to open the composer.json
Crizz0 Sep 26, 2025
1451e78
Fix tests for Validate Captchas
Crizz0 Sep 26, 2025
de46382
Add check for valid language iso in composer.json
Crizz0 Sep 26, 2025
a107d46
Change required license to GPL-2.0-only
Crizz0 Sep 26, 2025
46a206b
Add 4.0/ to ignored directories
Crizz0 Sep 26, 2025
48177df
Repair version format check
Crizz0 Sep 26, 2025
8b4bbad
Check for correct type
Crizz0 Sep 26, 2025
616a8a7
Add URL check for homepage
Crizz0 Sep 26, 2025
9d21818
Check text direction has 1 of 2 valid values
Crizz0 Sep 26, 2025
a57fd86
Change some JSON-check error levels and messages
Crizz0 Sep 26, 2025
2f5d75e
Repair tests for Recaptcha
Crizz0 Sep 26, 2025
44453b7
Update Readme.md for better looks and recent status
Crizz0 Sep 26, 2025
c4d3d6c
Formatting and typo fixes
Crizz0 Sep 26, 2025
ca6c80c
Add safe-mode functionality again to guessPluralRule()
Crizz0 Sep 26, 2025
b1e4668
Correct some idents
Crizz0 Sep 26, 2025
abf36b6
Fix direction for FileListValidator.php
Crizz0 Sep 26, 2025
a78a007
Add a ! to preg_match of english-name value check
Crizz0 Sep 26, 2025
9111275
Repair tests for 4.0 in FileListValidator
Crizz0 Sep 27, 2025
45f3e8e
Add a composer.json to fixtures for direction
Crizz0 Sep 27, 2025
fe6e927
Add composer.json to source and remove common.php
Crizz0 Sep 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/vendor/
/.idea/
/bin/
/3.2/
/3.3/
/4.0/
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ For the easiest results, create a directory called `4.0` in the root of the Tran
phpbb-translation-validator/4.0/de/
phpbb-translation-validator/translation.php

The simplest way to validate is to then run this command (the final argument is the language you wish to test and that has already been uploaded to the `3.2` directory; eg. `fr` for French):
The simplest way to validate is to then run this command (the final argument is the language you wish to test and that has already been uploaded to the `4.0` directory; eg. `fr` for French):

php translation.php validate fr

There are more arguments that can be supplied. For example, suppose you wanted to have your `3.2` directory in a different location, you wanted to explicitly specify phpBB version 3.2 (default validation is against 3.3), you wanted to run in safe mode and you wanted to see all notices displayed - you would run this command:
There are more arguments that can be supplied. For example, suppose you wanted to have your `4.x` directory in a different location, you wanted to explicitly specify phpBB version 4.x (default validation is against 4.0), you wanted to run in safe mode and you wanted to see all notices displayed - you would run this command:

php translation.php validate fr
--package-dir=/path/to/your/4.0
Expand Down
6 changes: 3 additions & 3 deletions src/Phpbb/TranslationValidator/Command/ValidateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ protected function configure()
->setName('validate')
->setDescription('Run the validator on your language pack.')
->addArgument('origin-iso', InputArgument::REQUIRED, 'The ISO of the language to validate')
->addOption('phpbb-version', null, InputOption::VALUE_OPTIONAL, 'The phpBB Version to validate against', '3.3')
->addOption('phpbb-version', null, InputOption::VALUE_OPTIONAL, 'The phpBB Version to validate against', '4.0')
->addOption('source-iso', null, InputOption::VALUE_OPTIONAL, 'The ISO of the language to validate against', 'en')
->addOption('package-dir', null, InputOption::VALUE_OPTIONAL, 'The path to the directory with the language packages', null)
->addOption('language-dir', null, InputOption::VALUE_OPTIONAL, 'The path to the directory with the language folders', null)
Expand All @@ -52,9 +52,9 @@ protected function execute(InputInterface $input, OutputInterface $output)
$displayNotices = $input->getOption('display-notices');
$safeMode = $input->getOption('safe-mode');

if (!in_array($phpbbVersion, array('3.2', '3.3')))
if ($phpbbVersion != '4.0')
{
throw new \RuntimeException('Invalid phpbb-version, allowed versions: 3.2 and 3.3');
throw new \RuntimeException('Invalid phpbb-version, allowed versions: 4.0');
}

$output = new Output($output, $debug);
Expand Down
234 changes: 195 additions & 39 deletions src/Phpbb/TranslationValidator/Validator/FileValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,47 @@ class FileValidator
'', // Allow empty strings
];

/** @var array List from https://developers.cloudflare.com/turnstile/reference/supported-languages/ */
private $reTurnstilesLanguages = [
'ar',
'bg',
'zh',
'hr',
'cs',
'da',
'nl',
'en',
'fa',
'fi',
'fr',
'de',
'el',
'he',
'hi',
'hu',
'id',
'it',
'ja',
'ko',
'lt',
'ms',
'nb',
'pl',
'pt',
'ro',
'ru',
'sr',
'sk',
'sl',
'es',
'sv',
'tl',
'th',
'tr',
'uk',
'vi',
'', // Allow empty strings
];
/**
* @param InputInterface $input
* @param OutputInterface $output
Expand Down Expand Up @@ -227,6 +268,16 @@ public function setSafeMode($safeMode)
return $this;
}

/**
* Open the composer.json of the language pack and
* save it to an array, accessible for the following functions
*/
public function openComposerJson($originFile)
{
$fileContents = (string) file_get_contents($this->originPath . '/' . $originFile);
return json_decode($fileContents, true);
}

/**
* Decides which validation function to use
*
Expand Down Expand Up @@ -260,9 +311,10 @@ public function validate($sourceFile, $originFile)
{
$this->validateLicenseFile($originFile);
}
else if ($originFile === $this->originLanguagePath . 'iso.txt')
else if ($originFile === $this->originLanguagePath . 'composer.json')
{
$this->validateIsoFile($originFile);
$this->validateJsonFile($originFile);
$this->validateCaptchaValues($originFile);
}
else if (substr($originFile, -4) === '.css')
{
Expand Down Expand Up @@ -361,32 +413,10 @@ public function validateLangFile($sourceFile, $originFile)
$this->output->addMessage(Output::FATAL, 'Must not contain key: ' . $validateLangKey, $originFile);
}
}

// Check reCaptcha file
if ($originFile === $this->originLanguagePath . 'captcha_recaptcha.php')
{
$this->validateReCaptchaValue($originFile, $validate);
}
}

/**
* Check that the reCaptcha key provided is allowed
* @param $originFile
* @param array $validate
*/
public function validateReCaptchaValue($originFile, $validate)
{
// The key 'RECAPTCHA_LANG' must match the list provided by Google, or be left empty
// If any other key is used, we will show an error
if (array_key_exists('RECAPTCHA_LANG', $validate) && !in_array($validate['RECAPTCHA_LANG'], $this->reCaptchaLanguages))
{
// The supplied value doesn't match the allowed values
$this->output->addMessage(Output::ERROR, 'reCaptcha must match a language/country code on https://developers.google.com/recaptcha/docs/language - if no code exists for your language you can use "en" or leave the string empty', $originFile, 'RECAPTCHA_LANG');
}
}

/**
* Validates a email .txt file
* Validates an email .txt file
*
* Emails must have a subject when the source file has one, otherwise must not have one.
* Emails must have a signature when the source file has one, otherwise must not have one.
Expand Down Expand Up @@ -521,26 +551,152 @@ public function validateIndexFile($originFile)
}

/**
* Validates the iso.txt file
* Validates the composer.json file
*
* Should only contain 3 lines:
* 1. English name of the language
* 2. Native name of the language
* 3. Line with information about the author
* Should be valid and contain the necessary information:
* Mandatory:
* name, description, type, version, homepage, license
* Authors: name (optional: email and homepage)
* Extra: language-iso, english-name, local-name,
* phpbb-version, direction, user-lang, plural-rule
* Optional:
* Support: urls to: forum, wiki, issues etc
*
* @param string $originFile File to validate
* @return null
*/
public function validateIsoFile($originFile)
public function validateJsonFile($originFile)
{
$fileContents = (string) file_get_contents($this->originPath . '/' . $originFile);
$isoFile = explode("\n", $fileContents);

if (sizeof($isoFile) != 3)
{
$this->output->addMessage(Output::FATAL, 'Must contain exactly 3 lines: 1. English name, 2. Native name, 3. Author information', $originFile);
}
}
$jsonContent = $this->openComposerJson($originFile);

if (!str_starts_with($jsonContent['name'], 'phpbb/phpbb-language-'))
{
$this->output->addMessage(Output::FATAL, 'Name should start with phpbb/phpbb-language- followed by the language iso code', $originFile);
}
// Check for an existing description
if (!array_key_exists('description', $jsonContent) || $jsonContent['description'] == '')
{
$this->output->addMessage(Output::FATAL, 'Description is missing', $originFile);
}
// Check if the description contains only words and punctuation, not URLs.
elseif (preg_match('/\b(?:www|https)\b|(?:\.[a-z]{2,})/i', $jsonContent['description']))
{
$this->output->addMessage(Output::ERROR, 'The description should only contain words - no URLs.', $originFile);
}
// Check if the type is correctly defined
if ($jsonContent['type'] != 'phpbb-language')
{
$this->output->addMessage(Output::FATAL, 'Type must be exactly: "phpbb-language"', $originFile);
}
// Check if there is a valid version definition
if (!array_key_exists('version', $jsonContent))
{
$this->output->addMessage(Output::FATAL, 'Language pack needs a version definition.', $originFile);
}
elseif ($jsonContent['version'] == '')
{
$this->output->addMessage(Output::FATAL, 'The defined version should not be empty.', $originFile);
}
elseif (!preg_match('/^(\d+\.)?(\d+\.)?(\*|\d+)$/', $jsonContent['version']))
{
$this->output->addMessage(Output::ERROR, 'The defined version is in the wrong format.', $originFile);
}
// Homepage should be at least an empty string
if (!preg_match('/(?:https?:\/\/|www\.)[^\s]+|(?:\b[a-z0-9-]+\.(?:com|net|org|info|io|co|biz|me|xyz|ai|app|dev|tech|tv|us|uk|de|fr|ru|jp|cn|in)\b)/i', $jsonContent['homepage']) && $jsonContent['homepage'] != '')
{
$this->output->addMessage(Output::ERROR, 'The homepage value allows only URLs or can be left empty.', $originFile);
}
// Check for the correct license value
if ($jsonContent['license'] != 'GPL-2.0-only')
{
$this->output->addMessage(Output::FATAL, 'The license value has to be "GPL-2.0-only"', $originFile);
}
// Check for the authors
if (!array_key_exists('authors', $jsonContent))
{
$this->output->addMessage(Output::ERROR, 'The authors value is missing.', $originFile);
}
// Check for support, authors should at least give one contact option!
if (!array_key_exists('support', $jsonContent))
{
$this->output->addMessage(Output::ERROR, 'The support value is missing.', $originFile);
}
elseif (count ($jsonContent['support']) < 1)
{
$this->output->addMessage(Output::ERROR, 'The support value has not sub values. Please provide at least one contact option e.g. forum, email.', $originFile);
}
// Check for the extra-section
if (!array_key_exists('extra', $jsonContent))
{
$this->output->addMessage(Output::FATAL, 'The extra section is missing.', $originFile);
}
// language-iso must be valid
if (!preg_match('/^(?:[a-z]*_?){0,2}[a-z]*$/', $jsonContent['extra']['language-iso']))
{
$this->output->addMessage(Output::FATAL, 'The language-iso should only contain small letters from a to z and maximum two underscores.', $originFile);
}
elseif ($jsonContent['extra']['language-iso'] != $this->originIso)
{
$this->output->addMessage(Output::FATAL, 'Language iso is not valid', $originFile);
}
// Check for english name
if ($jsonContent['extra']['english-name'] == '' || preg_match('/^[a-zA-Z\s]+$/', $jsonContent['extra']['english-name']))
{
$this->output->addMessage(Output::ERROR, 'The english-name value should only contain letters aA-zZ and spaces.', $originFile);
}
// Check for local name
if ($jsonContent['extra']['local-name'] == '')
{
$this->output->addMessage(Output::ERROR, 'The local-name value should not be empty.', $originFile);
}
// Check for valid phpBB-Version, we accept: 4.0.0, 4.0.0-a1 or 4.0.0-b1 or 4.0.0-RC1
if (!preg_match('/^\d+\.\d+\.\d+(-(?:a|b|RC)\d+)?$/', $jsonContent['extra']['phpbb-version']) || $jsonContent['extra']['phpbb-version'] == '' )
{
$this->output->addMessage(Output::FATAL, 'The phpbb-version value should not be empty and contain a valid version number.', $originFile);
}
// Check for valid direction
$textDirection = $jsonContent['extra']['direction'];
if (!in_array($textDirection, array('ltr', 'rtl')))
{
$this->output->addMessage(Output::FATAL, 'The direction can only be rtl or ltr.', $originFile);
}
// Check for user-lang: en-gb
if (!isset($jsonContent['extra']['user-lang']) || $jsonContent['extra']['user-lang'] == '')
{
$this->output->addMessage(Output::FATAL, 'The user-lang must be defined.', $originFile);
}
// Check for plural-rule
if (!preg_match('/^(?:[0-9]|1[0-5])$/', $jsonContent['extra']['plural-rule']))
{
$this->output->addMessage(Output::FATAL, 'Plural rules does not have a valid value.', $originFile);
}
}

/**
* Check that the reCaptcha and Turnstile key provided is allowed
* @param $originFile
*/
public function validateCaptchaValues($originFile, $optParams = '')
{
$jsonContent = $this->openComposerJson($originFile);

if ($optParams != '')
{
$jsonContent['extra']['recaptcha-lang'] = $optParams;
}
// The key 'RECAPTCHA_LANG' must match the list provided by Google, or be left empty
// Check for valid recaptcha-lang: en-GB
if (!in_array($jsonContent['extra']['recaptcha-lang'], $this->reCaptchaLanguages))
{
$this->output->addMessage(Output::ERROR, 'reCaptcha must match a language/country code on https://developers.google.com/recaptcha/docs/language - if no code exists for your language you can use "en".', $originFile);
}
// Check for valid turnstile-lang: en
// (should be in: https://developers.cloudflare.com/turnstile/reference/supported-languages/ )
if (!in_array($jsonContent['extra']['turnstile-lang'], $this->reTurnstilesLanguages))
{
$this->output->addMessage(Output::ERROR, 'Turnstile must match a 2-digit-language code from https://developers.cloudflare.com/turnstile/reference/supported-languages/ - if no code exists for your language you can use "en".', $originFile);
}
}

/**
* Validates whether a file checks for the IN_PHPBB constant
Expand Down
41 changes: 18 additions & 23 deletions src/Phpbb/TranslationValidator/Validator/ValidatorRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,36 +228,31 @@ protected function printErrorLevel(Output $output)
}

/**
* Try to find the plural rule for the language
* Try to find the plural rule for the language in composer.json
* @return int
*/
protected function guessPluralRule()
protected function guessPluralRule(): int
{
$filePath = $this->originPath . '/' . $this->originLanguagePath . 'common.php';
// TODO: Check for safeMode and langParser integration here in that function
$filePath = $this->originPath . '/' . $this->originLanguagePath . 'composer.json';

if (file_exists($filePath))
{
if ($this->safeMode)
{
$lang = self::langParser($filePath);
}

else
{
include($filePath);
}

if (!isset($lang['PLURAL_RULE']))
{
$this->output->writelnIfDebug("<info>No plural rule set, falling back to plural rule #1</info>");
}
}
if (file_exists($filePath))
{

$fileContents = (string) file_get_contents($filePath);
$jsonContent = json_decode($fileContents, true);

if (!isset($jsonContent['extra']['plural-rule']))
{
$this->output->writelnIfDebug("<info>No plural rule set, falling back to plural rule #1</info>");
}
}
else
{
$this->output->writelnIfDebug("<info>Could not find common.php, falling back to plural rule #1</info>");
$this->output->writelnIfDebug("<info>Could not find composer.json, falling back to plural rule #1</info>");
}

return isset($lang['PLURAL_RULE']) ? $lang['PLURAL_RULE'] : 1;
return $jsonContent['extra']['plural-rule'] ?? 1;
}

/**
Expand All @@ -278,7 +273,7 @@ public static function arrayParser($file)
* @param string $relativePath
* @return array
*/
public static function langParser($filePath, $relativePath = '')
public static function langParser($filePath, string $relativePath = '')
{
$lang = [];
$parsed = self::arrayParser($relativePath . $filePath);
Expand Down
Loading