diff --git a/core/controllers/IndexController.php b/core/controllers/IndexController.php index 3f2d229..240b68d 100644 --- a/core/controllers/IndexController.php +++ b/core/controllers/IndexController.php @@ -33,12 +33,22 @@ public function init() */ public function indexAction() { - // 1) An admin picked a CMS page as the home page (tiger.site.home_page)? Serve it. - $homeId = $this->_homePageId(); - if ($homeId !== '') { - $page = (new Tiger_Model_Page())->findById($homeId); + $home = $this->_homePageId(); + + // 1a) An admin picked a PATH — a public module page (`/marketplace`, `/docs`) or any ad-hoc + // route. Forwarded, not redirected: the content has to be served AT "/" or it isn't the + // home page, it's a signpost pointing away from it. + if ($home !== '' && $home[0] === '/') { + if ($this->_forwardToPath($home)) { return; } + // Unresolvable (the module was deactivated, the route retired) — fall through to the + // theme home / built-in landing rather than 404ing the site's front door. + } + + // 1b) An admin picked a CMS page as the home page (tiger.site.home_page)? Serve it. + if ($home !== '' && $home[0] !== '/') { + $page = (new Tiger_Model_Page())->findById($home); if ($page && $page->status === Tiger_Model_Page::STATUS_PUBLISHED) { - $this->_forward('view', 'page', null, ['cms_page_id' => $homeId]); + $this->_forward('view', 'page', null, ['cms_page_id' => $home]); return; } } @@ -164,7 +174,62 @@ public function techStackAction() $this->view->localeView(); } - /** The configured home-page id (tiger.site.home_page), or '' for the built-in landing. */ + /** + * Forward "/" to an internal path — a public module page (`/marketplace`, `/docs`) or any + * `module/controller/action`. + * + * Resolution mirrors `Tiger_Controller_Plugin_RouteOverride` on purpose: a module's PUBLIC page + * is usually a registered override (a pretty prefix → a canonical MVC target), so matching the + * override table first is what makes `/marketplace` work rather than only the long canonical + * path. Anything not in that table falls back to a plain `module/controller/action` parse. + * + * Segments are sanitized to the same `[a-zA-Z0-9_-]` shape the dispatcher accepts, so a stored + * value can't be used to reach outside normal dispatch. + * + * @param string $path the configured path, leading slash included + * @return bool true when the request was forwarded + */ + protected function _forwardToPath($path) + { + $clean = trim(parse_url($path, PHP_URL_PATH) ?: '', '/'); + if ($clean === '') { return false; } + + // A registered module override (the pretty public prefix). + if (class_exists('Tiger_Routing_Overrides')) { + foreach (Tiger_Routing_Overrides::all() as $o) { + $prefix = (string) $o['prefix']; + if ($clean !== $prefix && strpos($clean, $prefix . '/') !== 0) { continue; } + [$module, $controller, $action] = $o['mca']; + $slug = trim(substr($clean, strlen($prefix)), '/'); + $this->_forward($action, $controller, $module, $slug !== '' ? ['slug' => $slug] : []); + return true; + } + } + + // Else a canonical module/controller/action path. + $seg = array_values(array_filter(explode('/', $clean), 'strlen')); + $ok = static function ($s) { return (string) preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $s); }; + $module = $ok($seg[0] ?? ''); + $controller = $ok($seg[1] ?? 'index'); + $action = $ok($seg[2] ?? 'index'); + if ($module === '') { return false; } + + $front = Zend_Controller_Front::getInstance(); + $dirs = (array) $front->getControllerDirectory(); + if (!isset($dirs[$module])) { + // Not a module — treat the first segment as a default-namespace controller (/vibe). + $this->_forward($ok($seg[1] ?? 'index'), $module, null); + return true; + } + + $this->_forward($action, $controller, $module); + return true; + } + + /** + * The configured home page (`tiger.site.home_page`): a CMS `page_id`, a PATH beginning with "/", + * or '' for the built-in landing. + */ protected function _homePageId() { if (!Zend_Registry::isRegistered('Zend_Config')) { diff --git a/modules/cms/controllers/SettingsController.php b/modules/cms/controllers/SettingsController.php index 5e7ac3d..35b1aaa 100644 --- a/modules/cms/controllers/SettingsController.php +++ b/modules/cms/controllers/SettingsController.php @@ -30,10 +30,22 @@ public function indexAction() $tiger = $cfg->get('tiger'); $site = $tiger ? $tiger->get('site') : null; + $home = $site ? (string) $site->get('home_page') : ''; + + // A stored PATH that isn't one of the offered module pages (an ad-hoc route, or a module + // since deactivated) must still round-trip: show it in the custom field with "custom path" + // selected, rather than silently resetting the site's home page to the built-in landing. + $custom = ''; + if ($home !== '' && $home[0] === '/' && !array_key_exists($home, Cms_Form_Settings::modulePaths())) { + $custom = $home; + $home = Cms_Form_Settings::CUSTOM; + } + $form = new Cms_Form_Settings(); $form->populate([ - 'site_name' => ($site && (string) $site->get('name') !== '') ? (string) $site->name : 'Tiger', - 'home_page' => $site ? (string) $site->get('home_page') : '', + 'site_name' => ($site && (string) $site->get('name') !== '') ? (string) $site->name : 'Tiger', + 'home_page' => $home, + 'home_page_custom' => $custom, ]); $this->view->title = 'Settings — Tiger Admin'; diff --git a/modules/cms/forms/Settings.php b/modules/cms/forms/Settings.php index cfcacb1..e647237 100644 --- a/modules/cms/forms/Settings.php +++ b/modules/cms/forms/Settings.php @@ -13,13 +13,43 @@ */ class Cms_Form_Settings extends Tiger_Form { + /** Sentinel option value meaning "use the typed path instead". Never stored. */ + const CUSTOM = '__custom__'; + + /** + * Public module pages, as `path => label` — the active modules' pretty public prefixes. + * + * Read from `Tiger_Routing_Overrides`, which is where a module declares the public alias it wants + * (`/docs`, `/marketplace`). Non-page endpoints are skipped: an override whose prefix looks like a + * file (`robots.txt`, `sitemap.xml`, `llms.txt`) serves plain text, and offering it as a home page + * would only ever be a mistake. Reserved prefixes are already excluded by `all()`. + * + * @return array + */ + public static function modulePaths(): array + { + if (!class_exists('Tiger_Routing_Overrides')) { return []; } + + $out = []; + foreach (Tiger_Routing_Overrides::all() as $o) { + $prefix = trim((string) ($o['prefix'] ?? ''), '/'); + if ($prefix === '' || strpos($prefix, '.') !== false) { continue; } // robots.txt / sitemap.xml / llms.txt + $out['/' . $prefix] = '/' . $prefix; + } + ksort($out); + return $out; + } + protected function elements(): array { $control = ['class' => 'form-control']; $select = ['class' => 'form-select']; $home = ['' => $this->_t('cms.settings.opt_builtin_landing')]; - $pm = new Tiger_Model_Page(); + + // CMS pages — stored as a page_id. + $pages = []; + $pm = new Tiger_Model_Page(); foreach ($pm->fetchAll( $pm->activeSelect() ->where('type = ?', Tiger_Model_Page::TYPE_PAGE) @@ -27,8 +57,17 @@ protected function elements(): array ->order(['title ASC', 'locale ASC']) ) as $p) { $label = ($p->title ?: $p->slug ?: $p->page_key) . ' (' . $p->locale . ')'; - $home[$p->page_id] = $label; + $pages[$p->page_id] = $label; } + if ($pages) { $home[$this->_t('cms.settings.optgroup_pages')] = $pages; } + + // Public module pages — stored as a PATH. An active module's pretty public prefix is exactly + // what an admin thinks of as "the marketplace page" or "the docs page". + $modulePages = self::modulePaths(); + if ($modulePages) { $home[$this->_t('cms.settings.optgroup_modules')] = $modulePages; } + + // The escape hatch: any other route, typed in. + $home[self::CUSTOM] = $this->_t('cms.settings.opt_custom_path'); return [ ['text', 'site_name', [ @@ -40,6 +79,13 @@ protected function elements(): array 'multiOptions' => $home, 'attribs' => array_merge($select, ['id' => 'set-home-page']), ]], + // Revealed by the view when "custom path" is picked; its value replaces home_page on save. + ['text', 'home_page_custom', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'validators' => [['Regex', false, ['pattern' => '~^/[A-Za-z0-9/_\-.]*$~']]], + 'attribs' => array_merge($control, ['id' => 'set-home-page-custom', 'placeholder' => '/marketplace']), + ]], ]; } } diff --git a/modules/cms/languages/de/cms.php b/modules/cms/languages/de/cms.php index dc40826..0bac0cb 100644 --- a/modules/cms/languages/de/cms.php +++ b/modules/cms/languages/de/cms.php @@ -79,6 +79,11 @@ 'cms.settings.label_home_page' => 'Startseite', 'cms.settings.help_home_page' => 'Welche Seite unter %s ausgeliefert wird. „Integrierte Landingpage“ behält die Standard-Marketingseite bei.', 'cms.settings.opt_builtin_landing' => '— Integrierte Landingpage —', + 'cms.settings.optgroup_pages' => 'Inhaltsseiten', + 'cms.settings.optgroup_modules' => 'Modulseiten', + 'cms.settings.opt_custom_path' => '— Eigener Pfad… —', + 'cms.settings.label_home_page_custom' => 'Eigener Pfad', + 'cms.settings.help_home_page_custom' => 'Eine beliebige Route dieser Website, beginnend mit einem Schrägstrich — zum Beispiel /marketplace. Sie wird unter / ausgeliefert, nicht per Weiterleitung.', // ---- Content list ---- 'cms.page.list_heading' => 'Inhalt', diff --git a/modules/cms/languages/en/cms.php b/modules/cms/languages/en/cms.php index e440ea7..d4450c7 100644 --- a/modules/cms/languages/en/cms.php +++ b/modules/cms/languages/en/cms.php @@ -81,6 +81,11 @@ 'cms.settings.label_home_page' => 'Home page', 'cms.settings.help_home_page' => 'Which page to serve at %s. “Built-in landing page” keeps the default marketing page.', 'cms.settings.opt_builtin_landing' => '— Built-in landing page —', + 'cms.settings.optgroup_pages' => 'Content pages', + 'cms.settings.optgroup_modules' => 'Module pages', + 'cms.settings.opt_custom_path' => '— Custom path… —', + 'cms.settings.label_home_page_custom' => 'Custom path', + 'cms.settings.help_home_page_custom' => 'Any route on this site, starting with a slash — for example /marketplace. It is served at / rather than redirected to.', // ---- Content list ---- 'cms.page.list_heading' => 'Content', diff --git a/modules/cms/languages/es/cms.php b/modules/cms/languages/es/cms.php index bb556a7..5c350fc 100644 --- a/modules/cms/languages/es/cms.php +++ b/modules/cms/languages/es/cms.php @@ -79,6 +79,11 @@ 'cms.settings.label_home_page' => 'Página de inicio', 'cms.settings.help_home_page' => 'Qué página servir en %s. «Página de inicio integrada» mantiene la página de marketing predeterminada.', 'cms.settings.opt_builtin_landing' => '— Página de inicio integrada —', + 'cms.settings.optgroup_pages' => 'Páginas de contenido', + 'cms.settings.optgroup_modules' => 'Páginas de módulos', + 'cms.settings.opt_custom_path' => '— Ruta personalizada… —', + 'cms.settings.label_home_page_custom' => 'Ruta personalizada', + 'cms.settings.help_home_page_custom' => 'Cualquier ruta de este sitio, empezando por una barra: por ejemplo /marketplace. Se sirve en / en lugar de redirigir.', // ---- Content list ---- 'cms.page.list_heading' => 'Contenido', diff --git a/modules/cms/languages/fr/cms.php b/modules/cms/languages/fr/cms.php index 1a0611f..aefda49 100644 --- a/modules/cms/languages/fr/cms.php +++ b/modules/cms/languages/fr/cms.php @@ -79,6 +79,11 @@ 'cms.settings.label_home_page' => 'Page d\'accueil', 'cms.settings.help_home_page' => 'Quelle page servir sur %s. « Page d\'accueil intégrée » conserve la page marketing par défaut.', 'cms.settings.opt_builtin_landing' => '— Page d\'accueil intégrée —', + 'cms.settings.optgroup_pages' => 'Pages de contenu', + 'cms.settings.optgroup_modules' => 'Pages de modules', + 'cms.settings.opt_custom_path' => '— Chemin personnalisé… —', + 'cms.settings.label_home_page_custom' => 'Chemin personnalisé', + 'cms.settings.help_home_page_custom' => 'N\'importe quelle route de ce site, commençant par une barre oblique — par exemple /marketplace. Elle est servie sur / et non redirigée.', // ---- Content list ---- 'cms.page.list_heading' => 'Contenu', diff --git a/modules/cms/languages/hi/cms.php b/modules/cms/languages/hi/cms.php index f608354..e79bab0 100644 --- a/modules/cms/languages/hi/cms.php +++ b/modules/cms/languages/hi/cms.php @@ -79,6 +79,11 @@ 'cms.settings.label_home_page' => 'होम पेज', 'cms.settings.help_home_page' => '%s पर कौन सा पेज परोसना है। “बिल्ट-इन लैंडिंग पेज” डिफ़ॉल्ट मार्केटिंग पेज बनाए रखता है।', 'cms.settings.opt_builtin_landing' => '— बिल्ट-इन लैंडिंग पेज —', + 'cms.settings.optgroup_pages' => 'सामग्री पृष्ठ', + 'cms.settings.optgroup_modules' => 'मॉड्यूल पृष्ठ', + 'cms.settings.opt_custom_path' => '— कस्टम पथ… —', + 'cms.settings.label_home_page_custom' => 'कस्टम पथ', + 'cms.settings.help_home_page_custom' => 'इस साइट का कोई भी रूट, स्लैश से शुरू — उदाहरण के लिए /marketplace। इसे रीडायरेक्ट किए बिना / पर ही परोसा जाता है।', // ---- Content list ---- 'cms.page.list_heading' => 'सामग्री', diff --git a/modules/cms/languages/pt/cms.php b/modules/cms/languages/pt/cms.php index 2aed55f..e3e52dc 100644 --- a/modules/cms/languages/pt/cms.php +++ b/modules/cms/languages/pt/cms.php @@ -79,6 +79,11 @@ 'cms.settings.label_home_page' => 'Página inicial', 'cms.settings.help_home_page' => 'Qual página servir em %s. “Página inicial integrada” mantém a página de marketing padrão.', 'cms.settings.opt_builtin_landing' => '— Página inicial integrada —', + 'cms.settings.optgroup_pages' => 'Páginas de conteúdo', + 'cms.settings.optgroup_modules' => 'Páginas de módulos', + 'cms.settings.opt_custom_path' => '— Caminho personalizado… —', + 'cms.settings.label_home_page_custom' => 'Caminho personalizado', + 'cms.settings.help_home_page_custom' => 'Qualquer rota deste site, começando com uma barra — por exemplo /marketplace. É servida em / em vez de redirecionar.', // ---- Content list ---- 'cms.page.list_heading' => 'Conteúdo', diff --git a/modules/cms/services/Settings.php b/modules/cms/services/Settings.php index d8c1731..57c0d99 100644 --- a/modules/cms/services/Settings.php +++ b/modules/cms/services/Settings.php @@ -32,7 +32,16 @@ public function save(array $params): void $cfg = new Tiger_Model_Config(); $g = Tiger_Model_Config::SCOPE_GLOBAL; $cfg->set($g, '', 'tiger.site.name', trim((string) $v['site_name'])); - $cfg->set($g, '', 'tiger.site.home_page', (string) $v['home_page']); + + // The stored value is a CMS page_id, a PATH ("/marketplace"), or '' for the built-in + // landing. Picking "custom path" swaps in the typed value; a blank one falls back to the + // built-in rather than storing the sentinel, which would resolve to nothing. + $home = (string) $v['home_page']; + if ($home === Cms_Form_Settings::CUSTOM) { + $custom = trim((string) ($v['home_page_custom'] ?? '')); + $home = ($custom !== '' && $custom[0] === '/') ? $custom : ''; + } + $cfg->set($g, '', 'tiger.site.home_page', $home); $this->_success([], 'cms.settings.saved', '/cms/settings'); } catch (Throwable $e) { diff --git a/modules/cms/views/scripts/settings/index.phtml b/modules/cms/views/scripts/settings/index.phtml index d8fa5d3..94f760a 100644 --- a/modules/cms/views/scripts/settings/index.phtml +++ b/modules/cms/views/scripts/settings/index.phtml @@ -37,6 +37,13 @@ $el = function ($name) use ($form) { return $form->getElement($name); };
t('cms.settings.help_home_page', '/') ?>
+ + +
+ + +
t('cms.settings.help_home_page_custom') ?>
+
@@ -56,6 +63,18 @@ document.addEventListener('DOMContentLoaded', function () { var form = document.getElementById('cms-settings-form'); var fb = document.getElementById('cms-settings-feedback'); + // Home page: reveal the free-text path field only when "custom path" is chosen. + var homeSel = document.getElementById('set-home-page'); + var homeWrap = document.getElementById('home-page-custom-wrap'); + if (homeSel && homeWrap) { + var syncHome = function () { + var custom = homeSel.value === ; + homeWrap.classList.toggle('d-none', !custom); + if (custom) { document.getElementById('set-home-page-custom').focus(); } + }; + homeSel.addEventListener('change', syncHome); + } + document.getElementById('cms-settings-save').addEventListener('click', function () { var btn = this; fb.innerHTML = ''; diff --git a/tests/Integration/Cms/FormsTest.php b/tests/Integration/Cms/FormsTest.php index 02af2b7..d310c0b 100644 --- a/tests/Integration/Cms/FormsTest.php +++ b/tests/Integration/Cms/FormsTest.php @@ -118,7 +118,12 @@ public function settings_form_requires_a_site_name_and_lists_published_pages_as_ $this->assertTrue($form->getElement('site_name')->isRequired()); $home = $form->getElement('home_page')->getMultiOptions(); $this->assertArrayHasKey('', $home, 'the built-in landing is the empty option'); - $this->assertContains('Home (en)', $home, 'a published page is a home-page choice'); + + // The list is now GROUPED (content pages / module pages / custom path), so a page label sits + // one level down inside its optgroup rather than at the top level. Flatten before asserting. + $labels = []; + array_walk_recursive($home, static function ($v) use (&$labels) { $labels[] = $v; }); + $this->assertContains('Home (en)', $labels, 'a published page is a home-page choice'); $this->assertTrue($form->isValid(['site_name' => 'My Site', 'home_page' => ''])); $bad = new Cms_Form_Settings(); diff --git a/tests/Integration/Cms/HomePageSelectorTest.php b/tests/Integration/Cms/HomePageSelectorTest.php new file mode 100644 index 0000000..f70a653 --- /dev/null +++ b/tests/Integration/Cms/HomePageSelectorTest.php @@ -0,0 +1,101 @@ + 'marketplace', 'target' => 'marketplace/index/index']); + Tiger_Routing_Overrides::register('docs', ['pattern' => 'docs', 'target' => 'docs/index/docs']); + + $paths = Cms_Form_Settings::modulePaths(); + + $this->assertArrayHasKey('/marketplace', $paths, 'a registered module page is offered'); + $this->assertArrayHasKey('/docs', $paths, 'and so is every other one'); + $this->assertSame('/marketplace', $paths['/marketplace'], 'the value IS the path — that is what gets stored'); + } + + #[Test] + public function non_page_endpoints_are_never_offered(): void + { + // These serve text/xml, not a page. Offering one as a home page could only ever be a mistake. + Tiger_Routing_Overrides::register('robots', ['pattern' => 'robots.txt', 'target' => 'seo/robots/txt']); + Tiger_Routing_Overrides::register('sitemap', ['pattern' => 'sitemap.xml', 'target' => 'seo/sitemap/xml']); + Tiger_Routing_Overrides::register('llms', ['pattern' => 'llms.txt', 'target' => 'seo/llms/txt']); + Tiger_Routing_Overrides::register('marketplace', ['pattern' => 'marketplace', 'target' => 'marketplace/index/index']); + + $paths = Cms_Form_Settings::modulePaths(); + + $this->assertSame(['/marketplace'], array_keys($paths), + 'file-like prefixes are filtered out; only real pages are offered'); + } + + #[Test] + public function the_selector_offers_the_builtin_landing_and_a_custom_escape_hatch(): void + { + Tiger_Routing_Overrides::register('marketplace', ['pattern' => 'marketplace', 'target' => 'marketplace/index/index']); + + $options = (new Cms_Form_Settings())->getElement('home_page')->getMultiOptions(); + + $this->assertArrayHasKey('', $options, 'the built-in landing stays the default'); + $this->assertArrayHasKey(Cms_Form_Settings::CUSTOM, $options, 'an ad-hoc path can always be typed'); + } + + #[Test] + public function a_typed_path_must_be_a_rooted_path(): void + { + $el = (new Cms_Form_Settings())->getElement('home_page_custom'); + + $this->assertTrue($el->isValid('/marketplace'), 'a normal module path passes'); + $this->assertTrue($el->isValid('/shop/index/cart'), 'so does a deeper route'); + $this->assertTrue($el->isValid(''), 'and blank is fine — the field only applies to "custom"'); + + $this->assertFalse($el->isValid('marketplace'), 'a path must be rooted, or "/" resolution is ambiguous'); + $this->assertFalse($el->isValid('https://evil.test/x'), 'an absolute URL is refused — this forwards internally, it does not redirect offsite'); + $this->assertFalse($el->isValid('/x?y=1'), 'no query string: the home page takes no caller-supplied params'); + } + + #[Test] + public function the_custom_sentinel_is_never_itself_stored(): void + { + // The sentinel only means "read the other field". If it reached the config table it would + // resolve to nothing and the site would silently lose its home page. + $this->assertStringStartsNotWith('/', Cms_Form_Settings::CUSTOM, + 'the sentinel is not a path, so it can never be mistaken for one at dispatch'); + $this->assertNotSame('', Cms_Form_Settings::CUSTOM, + 'nor for the built-in landing'); + } +}