diff --git a/core/controllers/PageController.php b/core/controllers/PageController.php index 64a07eb2..c9285db9 100644 --- a/core/controllers/PageController.php +++ b/core/controllers/PageController.php @@ -107,7 +107,10 @@ public function themeContentAction() } $dir = Tiger_Theme::dir(); - $file = ($dir !== '' && $slug !== '') ? $dir . '/content/' . $slug . '.phtml' : ''; + // Locale-aware: content/..phtml when the theme ships that translation, else the + // base content/.phtml. The hint (title etc.) is read from whichever file is chosen, so a + // translated page renders fully in-locale. + $file = $slug !== '' ? Tiger_Theme::contentFile($slug, $dir) : ''; if ($file === '' || !is_file($file)) { throw new Zend_Controller_Action_Exception('Page not found', 404); diff --git a/library/Tiger/Theme.php b/library/Tiger/Theme.php index a3c982f6..53041eab 100644 --- a/library/Tiger/Theme.php +++ b/library/Tiger/Theme.php @@ -539,6 +539,43 @@ public static function page($slug) return self::template('page', $slug); } + /** + * The locale-aware content file for a slug — the active request's locale variant + * (`content/..phtml`) when the theme ships one, else the base `content/.phtml`. + * + * The theme sibling of {@see Tiger_View_Helper_LocaleView}: a theme's long-form `content/` pages + * are translated as whole sibling views (`index.es.phtml`, `index.tlh.phtml`), not string keys + * (THEMES.md §8a / I18N.md). This resolves the right one off the single locale already decided for + * the request (`LANG`, set by Tiger_Controller_Plugin_LocalePrefix), so a translated marketing page + * renders in-locale and any untranslated page falls through to English. Same strict, dot-free slug + * guard as {@see template()} / Tiger_Controller_Plugin_ThemeContent — no traversal out of `content/`. + * + * @param string $slug the content slug (may be nested) + * @param string|null $dir the theme dir to read from (null = the active theme) + * @param string|null $lang the locale to resolve (null = the request locale, `LANG`) + * @return string absolute path to the chosen file, or '' if the base is missing + */ + public static function contentFile($slug, $dir = null, $lang = null) + { + $root = ($dir !== null && $dir !== '') ? (string) $dir : self::dir(); + $slug = trim((string) $slug, '/'); + if ($slug === '' || $root === '' || !preg_match('#^[A-Za-z0-9][A-Za-z0-9/_-]*$#', $slug)) { + return ''; + } + $base = $root . '/content/' . $slug . '.phtml'; + if (!is_file($base)) { + return ''; // the base English page is the canonical one; a lone locale file is an orphan + } + $lang = $lang !== null ? (string) $lang : (defined('LANG') ? (string) LANG : ''); + if ($lang !== '' && preg_match('/^[a-z]{2,3}$/', $lang)) { + $variant = $root . '/content/' . $slug . '.' . $lang . '.phtml'; + if (is_file($variant)) { + return $variant; // a translated sibling ships for this locale → use it + } + } + return $base; + } + /** * Parse a leading `` hint comment into an assoc array (empty if none). * The shared parser behind `tiger:page` (theme static pages) and `tiger:block` (components). diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 210126ef..ca09aa41 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.22.0'; + const VERSION = '1.23.0'; } diff --git a/tests/Unit/Theme/ThemeContentFileTest.php b/tests/Unit/Theme/ThemeContentFileTest.php new file mode 100644 index 00000000..2d167a8d --- /dev/null +++ b/tests/Unit/Theme/ThemeContentFileTest.php @@ -0,0 +1,125 @@ +.phtml page: + * the request locale's sibling (content/..phtml) when it ships, else the base English + * file. This is what lets a translated marketing page render in-locale (TIGER-325); regression of + * the per-locale marketing pages lost in the TIGER-230 extraction. + */ +#[CoversClass(Tiger_Theme::class)] +final class ThemeContentFileTest extends UnitTestCase +{ + private string $dir = ''; + + protected function setUp(): void + { + parent::setUp(); + $this->dir = sys_get_temp_dir() . '/tigcontent-' . uniqid('', true); + mkdir($this->dir . '/content', 0777, true); + Zend_Registry::set('Tiger_ThemeDir', $this->dir); + } + + protected function tearDown(): void + { + foreach (glob($this->dir . '/content/*') ?: [] as $f) { @unlink($f); } + @rmdir($this->dir . '/content'); + @rmdir($this->dir); + parent::tearDown(); + } + + private function page(string $name): void + { + file_put_contents($this->dir . '/content/' . $name, '

' . $name . '

'); + } + + #[Test] + public function returns_the_locale_variant_when_it_ships(): void + { + $this->page('index.phtml'); + $this->page('index.es.phtml'); + $this->assertSame($this->dir . '/content/index.es.phtml', Tiger_Theme::contentFile('index', null, 'es')); + } + + #[Test] + public function tlh_klingon_variant_resolves(): void + { + $this->page('index.phtml'); + $this->page('index.tlh.phtml'); // 3-letter locale + $this->assertSame($this->dir . '/content/index.tlh.phtml', Tiger_Theme::contentFile('index', null, 'tlh')); + } + + #[Test] + public function falls_back_to_base_when_no_variant(): void + { + $this->page('vibe.phtml'); // only the English base ships + $this->assertSame($this->dir . '/content/vibe.phtml', Tiger_Theme::contentFile('vibe', null, 'fr')); + } + + #[Test] + public function base_used_when_no_locale(): void + { + $this->page('vibe.phtml'); + $this->assertSame($this->dir . '/content/vibe.phtml', Tiger_Theme::contentFile('vibe', null, '')); + } + + #[Test] + public function missing_base_returns_empty_even_if_a_variant_exists(): void + { + $this->page('ghost.es.phtml'); // a stray variant with no base + $this->assertSame('', Tiger_Theme::contentFile('ghost', null, 'es')); + } + + #[Test] + public function nested_slug_is_allowed(): void + { + mkdir($this->dir . '/content/solutions', 0777, true); + file_put_contents($this->dir . '/content/solutions/x.phtml', 'x'); + file_put_contents($this->dir . '/content/solutions/x.de.phtml', 'x-de'); + $this->assertSame($this->dir . '/content/solutions/x.de.phtml', Tiger_Theme::contentFile('solutions/x', null, 'de')); + @unlink($this->dir . '/content/solutions/x.phtml'); + @unlink($this->dir . '/content/solutions/x.de.phtml'); + @rmdir($this->dir . '/content/solutions'); + } + + #[Test] + public function traversal_and_bad_slugs_are_refused(): void + { + $this->page('index.phtml'); + $this->assertSame('', Tiger_Theme::contentFile('../secret', null, 'es')); + $this->assertSame('', Tiger_Theme::contentFile('a.b', null, 'es')); // a dot can never be a slug + $this->assertSame('', Tiger_Theme::contentFile('', null, 'es')); + } + + #[Test] + public function a_bogus_locale_is_ignored_and_base_served(): void + { + $this->page('index.phtml'); + $this->page('index.es.phtml'); + // not a 2–3 lowercase code → never used to build a variant path + $this->assertSame($this->dir . '/content/index.phtml', Tiger_Theme::contentFile('index', null, 'EN-us')); + } + + #[Test] + public function explicit_dir_overrides_the_active_theme(): void + { + $other = sys_get_temp_dir() . '/tigcontent-other-' . uniqid('', true); + mkdir($other . '/content', 0777, true); + file_put_contents($other . '/content/index.phtml', 'base'); + file_put_contents($other . '/content/index.pt.phtml', 'pt'); + $this->assertSame($other . '/content/index.pt.phtml', Tiger_Theme::contentFile('index', $other, 'pt')); + @unlink($other . '/content/index.phtml'); + @unlink($other . '/content/index.pt.phtml'); + @rmdir($other . '/content'); + @rmdir($other); + } +}