diff --git a/docs/changelog.270.txt b/docs/changelog.270.txt index a91835498..c0b3ca46e 100644 --- a/docs/changelog.270.txt +++ b/docs/changelog.270.txt @@ -24,6 +24,7 @@ Tests: Forms and templates: - fix TinyMCE 7 language handling to preserve case-sensitive locale codes such as zh_TW +- fix TinyMCE 7 fallback language handling to emit pack filename codes such as zh_TW/fr_FR/pt_BR and drop the invalid legacy _utf8 suffix (issue #76) System: - fix cloning a module block: the save handler now hydrates the module fields (mid, func_num, func_file, show_func, edit_func, template, dirname, name) from the clone form when bid=0, so the clone keeps its module binding and passes name validation; the path/function fields are validated (no traversal, identifier-only) before persisting (issue #73) diff --git a/htdocs/class/xoopseditor/tinymce7/formtinymce.php b/htdocs/class/xoopseditor/tinymce7/formtinymce.php index 47b12e987..55317acce 100644 --- a/htdocs/class/xoopseditor/tinymce7/formtinymce.php +++ b/htdocs/class/xoopseditor/tinymce7/formtinymce.php @@ -27,6 +27,80 @@ */ class XoopsFormTinymce7 extends XoopsEditor { + private const TINYMCE7_LANGUAGE_MAP = [ + 'ar' => 'ar', + 'bg' => 'bg_BG', + 'bg-bg' => 'bg_BG', + 'ca' => 'ca', + 'cs' => 'cs', + 'da' => 'da', + 'de' => 'de', + 'el' => 'el', + 'en' => 'en', + 'en-us' => 'en', + 'es' => 'es', + 'eu' => 'eu', + 'fa' => 'fa', + 'fi' => 'fi', + 'fr' => 'fr_FR', + 'fr-fr' => 'fr_FR', + 'he' => 'he_IL', + 'he-il' => 'he_IL', + 'hi' => 'hi', + 'hr' => 'hr', + 'hu' => 'hu_HU', + 'hu-hu' => 'hu_HU', + 'id' => 'id', + 'in' => 'id', + 'iw' => 'he_IL', + 'it' => 'it', + 'ja' => 'ja', + 'kk' => 'kk', + 'ko' => 'ko_KR', + 'ko-kr' => 'ko_KR', + 'ms' => 'ms', + 'nb' => 'nb_NO', + 'nb-no' => 'nb_NO', + 'nl' => 'nl', + 'no' => 'nb_NO', + 'pl' => 'pl', + 'pt' => 'pt_PT', + 'pt-br' => 'pt_BR', + 'pt-pt' => 'pt_PT', + 'ro' => 'ro', + 'ru' => 'ru', + 'sk' => 'sk', + 'sl' => 'sl_SI', + 'sl-si' => 'sl_SI', + 'sv' => 'sv_SE', + 'sv-se' => 'sv_SE', + 'th' => 'th_TH', + 'th-th' => 'th_TH', + 'tr' => 'tr', + 'uk' => 'uk', + 'vi' => 'vi', + 'zh' => 'zh_CN', + 'zh-cn' => 'zh_CN', + 'zh-hans' => 'zh_CN', + 'zh-hant' => 'zh_TW', + 'zh-hk' => 'zh_TW', + 'zh-tw' => 'zh_TW', + // Collapse the common country variants of languages whose only + // TinyMCE 7 pack is the bare code (no de_DE.js/es_ES.js/...). A + // genuine regional pack (e.g. es_MX) is intentionally NOT aliased + // here so it still resolves via the generic path. + 'de-de' => 'de', + 'es-es' => 'es', + 'it-it' => 'it', + 'ja-jp' => 'ja', + 'nl-nl' => 'nl', + 'pl-pl' => 'pl', + 'ru-ru' => 'ru', + 'tr-tr' => 'tr', + 'uk-ua' => 'uk', + 'vi-vn' => 'vi', + ]; + public $language; public $width = '100%'; public $height = '500px'; @@ -99,15 +173,51 @@ public function getLanguage() if (defined('_XOOPS_EDITOR_TINYMCE7_LANGUAGE')) { $this->language = constant('_XOOPS_EDITOR_TINYMCE7_LANGUAGE'); } else { - $this->language = str_replace('_', '-', strtolower(_LANGCODE)); - if (strtolower(_CHARSET) === 'utf-8') { - $this->language .= '_utf8'; - } + $langcode = defined('_LANGCODE') ? (string) constant('_LANGCODE') : 'en'; + $this->language = self::normalizeLanguageCode($langcode); } return $this->language; } + /** + * Convert XOOPS language codes to TinyMCE 7 language-pack filenames. + * + * TinyMCE 7 dropped the legacy TinyMCE 3 "_utf8" suffix and requires + * case-sensitive language codes matching the pack filename, e.g. zh_TW. + */ + protected static function normalizeLanguageCode(string $languageCode): string + { + $languageCode = trim($languageCode); + if ($languageCode === '') { + return 'en'; + } + + $key = strtolower(str_replace('_', '-', $languageCode)); + $key = preg_replace('/[-.]utf-?8$/', '', $key) ?? $key; + + if (isset(self::TINYMCE7_LANGUAGE_MAP[$key])) { + return self::TINYMCE7_LANGUAGE_MAP[$key]; + } + + [$language, $region] = array_pad(explode('-', $key, 2), 2, ''); + + // Reject malformed tokens: a TinyMCE 7 locale is a 2-3 letter + // language, optionally with a 2-letter region. Anything else + // (symbols, digits, junk) degrades to the built-in English. + if (!preg_match('/^[a-z]{2,3}$/', $language)) { + return 'en'; + } + if ($region === '') { + return $language; + } + if (!preg_match('/^[a-z]{2}$/', $region)) { + return 'en'; + } + + return $language . '_' . strtoupper($region); + } + /** * prepare HTML for output * diff --git a/htdocs/class/xoopseditor/tinymce7/language/english.php b/htdocs/class/xoopseditor/tinymce7/language/english.php index d6648adfd..f95bec0d2 100644 --- a/htdocs/class/xoopseditor/tinymce7/language/english.php +++ b/htdocs/class/xoopseditor/tinymce7/language/english.php @@ -21,6 +21,5 @@ */ // Name of the editor define('_XOOPS_EDITOR_TINYMCE7', 'TinyMCE7'); -// The value must be the same as /tinymce/jscripts/langs/your_language_code, for example, "en" for English, "fr" for French -// For details, check http://tinymce.moxiecode.com/download_i18n.php +// The value must match the TinyMCE 7 language-pack filename without ".js", for example "en", "fr_FR", or "zh_TW". define('_XOOPS_EDITOR_TINYMCE7_LANGUAGE', 'en'); diff --git a/htdocs/class/xoopseditor/tinymce7/language/french.php b/htdocs/class/xoopseditor/tinymce7/language/french.php index e56ec3c16..520ac7307 100644 --- a/htdocs/class/xoopseditor/tinymce7/language/french.php +++ b/htdocs/class/xoopseditor/tinymce7/language/french.php @@ -21,6 +21,5 @@ */ // Name of the editor define('_XOOPS_EDITOR_TINYMCE7', 'TinyMCE7'); -// The value must be the same as /tinymce/jscripts/langs/your_language_code, for example, "en" for English, "fr" for French -// For details, check http://tinymce.moxiecode.com/download_i18n.php -define('_XOOPS_EDITOR_TINYMCE7_LANGUAGE', 'fr'); +// The value must match the TinyMCE 7 language-pack filename without ".js", for example "en", "fr_FR", or "zh_TW". +define('_XOOPS_EDITOR_TINYMCE7_LANGUAGE', 'fr_FR'); diff --git a/tests/unit/htdocs/class/xoopseditor/tinymce7/XoopsFormTinymce7Test.php b/tests/unit/htdocs/class/xoopseditor/tinymce7/XoopsFormTinymce7Test.php index 5a23fb18d..ea4a7e136 100644 --- a/tests/unit/htdocs/class/xoopseditor/tinymce7/XoopsFormTinymce7Test.php +++ b/tests/unit/htdocs/class/xoopseditor/tinymce7/XoopsFormTinymce7Test.php @@ -4,6 +4,7 @@ namespace xoopseditor\tinymce7; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; @@ -49,4 +50,76 @@ public function __construct() self::assertSame('zh_TW', $editor->getLanguage()); } + + /** + * @return array + */ + public static function fallbackLanguageCodeProvider(): array + { + return [ + 'english remains default TinyMCE code' => ['en', 'en'], + 'french maps to TinyMCE 7 filename' => ['fr', 'fr_FR'], + 'traditional chinese underscore' => ['zh_TW', 'zh_TW'], + 'traditional chinese hyphen' => ['zh-tw', 'zh_TW'], + 'legacy TinyMCE 3 utf8 suffix is stripped' => ['zh-tw_utf8', 'zh_TW'], + 'brazilian portuguese preserves region case' => ['pt_BR', 'pt_BR'], + 'swedish maps to TinyMCE 7 filename' => ['sv', 'sv_SE'], + 'custom regional packs keep TinyMCE 7 separator style' => ['es-mx', 'es_MX'], + 'germany country variant collapses to bare pack' => ['de_DE', 'de'], + 'spain country variant collapses to bare pack' => ['es_ES', 'es'], + 'malformed token falls back to english' => ['@@', 'en'], + 'invalid region falls back to english' => ['es-123', 'en'], + 'empty language falls back to english' => ['', 'en'], + ]; + } + + #[DataProvider('fallbackLanguageCodeProvider')] + public function testFallbackLanguageCodesUseTinymce7LocaleFormat(string $langcode, string $expected): void + { + $editor = new class extends \XoopsFormTinymce7 { + public function __construct() + { + // Intentionally empty: this test only exercises the language + // formatter used by the getLanguage() fallback branch. + } + + public function normalizeForTest(string $langcode): string + { + return self::normalizeLanguageCode($langcode); + } + }; + + self::assertSame($expected, $editor->normalizeForTest($langcode)); + } + + /** + * End-to-end guard: getLanguage() must actually run _LANGCODE through + * the normalizer when _XOOPS_EDITOR_TINYMCE7_LANGUAGE is absent — i.e. + * proves the wiring of the #76 fix, not just the helper in isolation. + * + * Separate process because it has to define() the _LANGCODE global + * constant (a PHP constant cannot be unset). Local Windows PHPUnit + * process isolation has been flaky in this repo, but CI runs the + * existing isolated test reliably. + */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testGetLanguageFallbackUsesLangcodeNormalizer(): void + { + if (defined('_XOOPS_EDITOR_TINYMCE7_LANGUAGE') || defined('_LANGCODE')) { + self::markTestSkipped('Locale constants already defined; cannot exercise the fallback branch in isolation.'); + } + + define('_LANGCODE', 'zh-tw_utf8'); + + $editor = new class extends \XoopsFormTinymce7 { + public function __construct() + { + // Intentionally empty: bypass the heavy parent constructor; + // only the getLanguage() fallback branch is under test. + } + }; + + self::assertSame('zh_TW', $editor->getLanguage()); + } }