Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion core/controllers/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ public function themeContentAction()
}

$dir = Tiger_Theme::dir();
$file = ($dir !== '' && $slug !== '') ? $dir . '/content/' . $slug . '.phtml' : '';
// Locale-aware: content/<slug>.<LANG>.phtml when the theme ships that translation, else the
// base content/<slug>.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);
Expand Down
37 changes: 37 additions & 0 deletions library/Tiger/Theme.php
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>.<LANG>.phtml`) when the theme ships one, else the base `content/<slug>.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 `<!-- <tag> key="value" … -->` hint comment into an assoc array (empty if none).
* The shared parser behind `tiger:page` (theme static pages) and `tiger:block` (components).
Expand Down
2 changes: 1 addition & 1 deletion library/Tiger/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
125 changes: 125 additions & 0 deletions tests/Unit/Theme/ThemeContentFileTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.

namespace Tiger\Tests\Unit\Theme;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Tiger\Tests\Support\UnitTestCase;
use Tiger_Theme;
use Zend_Registry;

/**
* Tiger_Theme::contentFile() — locale-aware resolution of a theme's content/<slug>.phtml page:
* the request locale's sibling (content/<slug>.<lang>.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, '<p>' . $name . '</p>');
}

#[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);
}
}
Loading