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
36 changes: 36 additions & 0 deletions library/Tiger/Model/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,42 @@ public function getSummaries($type = self::TYPE_PAGE)
);
}

/**
* PUBLISHED-page summaries for a pick-list (the home-page selector), bounded in the model — small
* columns only (never `body`/`meta`), an optional case-insensitive search over title/slug/key, and
* a hard row cap. Keeps query construction in the model (not the calling service) and keeps a
* per-keystroke search from ever loading full page rows.
*
* @param string $q optional substring filter on title / slug / page_key ('' = all)
* @param int $limit max rows (clamped 1..200)
* @param string $type the page type (default TYPE_PAGE)
* @return array<int,array<string,string>> [page_id, title, slug, page_key, locale], title then locale
*/
public function publishedSummaries($q = '', $limit = 50, $type = self::TYPE_PAGE)
{
$db = $this->getAdapter();
$limit = max(1, min(200, (int) $limit));
$select = $db->select()
->from($this->_name, ['page_id', 'title', 'slug', 'page_key', 'locale'])
->where('type = ?', (string) $type)
->where('deleted = ?', 0)
->where('status = ?', self::STATUS_PUBLISHED)
->where('published_at IS NULL OR published_at <= NOW()')
->order(['title ASC', 'locale ASC'])
->limit($limit);

$q = trim((string) $q);
if ($q !== '') {
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $q) . '%';
$select->where(
$db->quoteInto('title LIKE ?', $like)
. ' OR ' . $db->quoteInto('slug LIKE ?', $like)
. ' OR ' . $db->quoteInto('page_key LIKE ?', $like)
);
}
return $db->fetchAll($select);
}

/**
* Every active row of any of the given types.
*
Expand Down
82 changes: 60 additions & 22 deletions library/Tiger/Theme.php
Original file line number Diff line number Diff line change
Expand Up @@ -327,27 +327,72 @@ public static function scope()
public static function names()
{
$out = [];
foreach (self::_installedThemeDirs() as $dir) {
$man = self::_manifestAt($dir);
if (!empty($man['key'])) {
$out[(string) $man['key']] = (string) ($man['name'] ?? $man['key']);
}
foreach (self::inventory() as $key => $t) {
$out[$key] = (string) ($t['name'] ?? $key);
}
return $out;
}

/**
* The on-disk directories of every INSTALLED theme (app `modules/theme-*` + core `themes/*`),
* whether active or not. The scan behind `names()` / `dirForKey()`.
* The installed-theme INVENTORY, keyed by manifest key: `[key => ['dir','name','assetBase']]`.
* Built ONCE per request from a single filesystem scan (memoised) so `names()`/`dirForKey()`/
* `assetBaseForKey()`/`pagesForKey()` — often all called for the same discovery — don't each re-glob
* and re-read every manifest.
*
* It scans exactly the locations `Bootstrap::_initTheme` resolves an active theme from, in the SAME
* precedence (app wins the package), so a theme discoverable/activatable by the bootstrap is also
* resolvable here — a plain `themes/<name>` dir AND a `theme-<name>` MODULE, under both the app and
* the core package.
*
* @return array<string,array{dir:string,name:string,assetBase:string}>
*/
public static function inventory()
{
if (self::$_inventory !== null) { return self::$_inventory; }

$dirs = [];
if (defined('APPLICATION_PATH')) {
$dirs = array_merge($dirs,
(array) glob(APPLICATION_PATH . '/themes/*', GLOB_ONLYDIR),
(array) glob(APPLICATION_PATH . '/modules/theme-*', GLOB_ONLYDIR));
}
if (defined('TIGER_CORE_PATH')) {
$dirs = array_merge($dirs,
(array) glob(TIGER_CORE_PATH . '/modules/theme-*', GLOB_ONLYDIR),
(array) glob(TIGER_CORE_PATH . '/themes/*', GLOB_ONLYDIR));
}

$inv = [];
foreach ($dirs as $dir) { // in precedence order — first key seen wins (app over core)
$man = self::_manifestAt($dir);
$key = (string) ($man['key'] ?? '');
if ($key === '' || isset($inv[$key])) { continue; }
$inv[$key] = [
'dir' => $dir,
'name' => (string) ($man['name'] ?? $key),
'assetBase' => (isset($man['assetBase']) && $man['assetBase'] !== '') ? (string) $man['assetBase'] : '/_theme',
];
}
return self::$_inventory = $inv;
}

/** Drop the memoised inventory (tests that install/remove a theme dir mid-run). */
public static function resetInventory()
{
self::$_inventory = null;
}

/** @var array<string,array{dir:string,name:string,assetBase:string}>|null memoised installed-theme inventory */
protected static $_inventory = null;

/**
* The on-disk directories of every INSTALLED theme, whether active or not.
*
* @return array<int,string>
*/
protected static function _installedThemeDirs()
{
$dirs = [];
if (defined('APPLICATION_PATH')) { $dirs = array_merge($dirs, (array) glob(APPLICATION_PATH . '/modules/theme-*', GLOB_ONLYDIR)); }
if (defined('TIGER_CORE_PATH')) { $dirs = array_merge($dirs, (array) glob(TIGER_CORE_PATH . '/themes/*', GLOB_ONLYDIR)); }
return $dirs;
return array_values(array_map(static function ($t) { return $t['dir']; }, self::inventory()));
}

/**
Expand All @@ -360,15 +405,8 @@ protected static function _installedThemeDirs()
*/
public static function dirForKey($key)
{
$key = (string) $key;
if ($key === '') { return ''; }
foreach (self::_installedThemeDirs() as $dir) {
$man = self::_manifestAt($dir);
if (!empty($man['key']) && (string) $man['key'] === $key) {
return $dir;
}
}
return '';
$inv = self::inventory();
return isset($inv[(string) $key]) ? $inv[(string) $key]['dir'] : '';
}

/**
Expand All @@ -380,8 +418,8 @@ public static function dirForKey($key)
*/
public static function assetBaseForKey($key)
{
$man = self::_manifestAt(self::dirForKey($key));
return (isset($man['assetBase']) && $man['assetBase'] !== '') ? (string) $man['assetBase'] : '/_theme';
$inv = self::inventory();
return isset($inv[(string) $key]) ? $inv[(string) $key]['assetBase'] : '/_theme';
}

/**
Expand Down
79 changes: 44 additions & 35 deletions modules/cms/services/Paths.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,14 @@ public function search(array $params): void
{
if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; }

$q = strtolower(trim((string) ($params['q'] ?? '')));
$q = trim((string) ($params['q'] ?? ''));
$advanced = !empty($params['advanced']) && (string) $params['advanced'] !== '0';

// Groups come back already q-filtered (CMS pages filtered + bounded in the model; the small
// in-memory lists filtered here). Cap each group so one huge theme can't flood the list.
$out = [];
foreach (self::_discover($advanced) as $group) {
$opts = [];
foreach ($group['options'] as $o) {
if ($q === '' || strpos(strtolower($o['label'] . ' ' . $o['value']), $q) !== false) {
$opts[] = $o;
if (count($opts) >= self::LIMIT) { break; }
}
}
foreach (self::_discover($advanced, $q) as $group) {
$opts = array_slice($group['options'], 0, self::LIMIT);
if ($opts) { $out[] = ['label' => $group['label'], 'options' => $opts]; }
}

Expand Down Expand Up @@ -86,45 +82,57 @@ public static function labelFor($value)
}

/**
* Build the grouped option set (unfiltered). BASIC unless $advanced adds every theme page.
* Build the grouped option set, q-filtered. BASIC unless $advanced adds every theme page.
*
* CMS pages are filtered + bounded IN THE MODEL (Tiger_Model_Page::publishedSummaries — no query
* builder here, no page bodies loaded). Theme HOMES are emitted for ALL themes first, then (advanced)
* every theme's sub-pages, so the caller's per-group cap can never crowd a later theme's home out.
* Theme + module lists are small and in-memory, so they're substring-filtered here.
*
* @param bool $advanced
* @param bool $advanced
* @param string $q
* @return array<int,array{label:string,options:array<int,array{value:string,label:string}>}>
*/
protected static function _discover(bool $advanced): array
protected static function _discover(bool $advanced, string $q): array
{
$ql = strtolower($q);
$match = static function ($label, $value) use ($ql) {
return $ql === '' || strpos(strtolower($label . ' ' . $value), $ql) !== false;
};
$groups = [];

// General — the built-in landing (value '').
$groups[] = ['label' => self::_t('cms.settings.optgroup_general'), 'options' => [
['value' => '', 'label' => self::_t('cms.settings.opt_builtin_landing')],
]];
$landing = self::_t('cms.settings.opt_builtin_landing');
if ($match($landing, '')) {
$groups[] = ['label' => self::_t('cms.settings.optgroup_general'), 'options' => [['value' => '', 'label' => $landing]]];
}

// CMS pages — stored as a page_id.
// CMS pages — filtered + bounded in the model; stored as a page_id.
$pages = [];
$pm = new Tiger_Model_Page();
foreach ($pm->fetchAll(
$pm->activeSelect()
->where('type = ?', Tiger_Model_Page::TYPE_PAGE)
->where('status = ?', Tiger_Model_Page::STATUS_PUBLISHED)
->order(['title ASC', 'locale ASC'])
) as $p) {
$pages[] = ['value' => (string) $p->page_id, 'label' => ($p->title ?: $p->slug ?: $p->page_key) . ' (' . $p->locale . ')'];
foreach ((new Tiger_Model_Page())->publishedSummaries($q, self::LIMIT) as $p) {
$label = (($p['title'] ?? '') ?: ($p['slug'] ?? '') ?: ($p['page_key'] ?? '')) . ' (' . ($p['locale'] ?? '') . ')';
$pages[] = ['value' => (string) $p['page_id'], 'label' => $label];
}
if ($pages) { $groups[] = ['label' => self::_t('cms.settings.optgroup_pages'), 'options' => $pages]; }

// Themes — each installed theme's HOME, plus (advanced) its every content page.
$themes = [];
foreach (Tiger_Theme::names() as $key => $name) {
$dir = Tiger_Theme::dirForKey($key);
if ($dir === '') { continue; }
if (is_file($dir . '/content/index.phtml')) {
$themes[] = ['value' => '@theme:' . $key, 'label' => $name . ' — ' . self::_t('cms.settings.theme_home')];
// Themes — ALL homes first, then (advanced) every theme's sub-pages. One inventory scan.
$themes = [];
$themeHome = self::_t('cms.settings.theme_home');
$inv = Tiger_Theme::inventory();
foreach ($inv as $key => $t) {
if (is_file($t['dir'] . '/content/index.phtml')) {
$label = $t['name'] . ' — ' . $themeHome;
$value = '@theme:' . $key;
if ($match($label, $value)) { $themes[] = ['value' => $value, 'label' => $label]; }
}
if ($advanced) {
}
if ($advanced) {
foreach ($inv as $key => $t) {
foreach (Tiger_Theme::pagesForKey($key) as $pg) {
if ($pg['slug'] === 'index') { continue; } // the home is already listed
$themes[] = ['value' => '@theme:' . $key . ':' . $pg['slug'], 'label' => $name . ' — ' . $pg['title']];
if ($pg['slug'] === 'index') { continue; } // the home is already listed above
$label = $t['name'] . ' — ' . $pg['title'];
$value = '@theme:' . $key . ':' . $pg['slug'];
if ($match($label, $value)) { $themes[] = ['value' => $value, 'label' => $label]; }
}
}
}
Expand All @@ -136,7 +144,8 @@ protected static function _discover(bool $advanced): array
foreach (Tiger_Routing_Overrides::all() as $o) {
$prefix = trim((string) ($o['prefix'] ?? ''), '/');
if ($prefix === '' || strpos($prefix, '.') !== false) { continue; } // robots.txt / sitemap.xml / llms.txt
$modules['/' . $prefix] = ['value' => '/' . $prefix, 'label' => '/' . $prefix];
$value = '/' . $prefix;
if ($match($value, $value)) { $modules[$value] = ['value' => $value, 'label' => $value]; }
}
ksort($modules);
}
Expand Down
10 changes: 5 additions & 5 deletions modules/cms/views/scripts/settings/index.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* CMS Settings — the site name + the home page served at "/". ViewHelper-only form (the view owns
* markup). The home page is ANY valid path: a TigerCombobox over Cms_Service_Paths supplies the
* markup). The home page is ANY valid path: a TigerPathbox over Cms_Service_Paths supplies the
* convenience list (with an "Advanced Search" toggle for the full litterbox), and a dev can type any
* route freely — the hidden `home_page` field holds whatever is committed. Saving is an /api call to
* Cms_Service_Settings; behaviour lives in cms.settings.js (no inline script).
Expand Down Expand Up @@ -41,10 +41,10 @@ $el = function ($name) use ($form) { return $form->getElement($name); };
<?php // The committed value the form submits — hidden; the combobox writes into it. ?>
<div class="d-none"><?= $el('home_page') ?></div>

<?php // The searchable "pick or type" control (TigerCombobox → Cms_Service_Paths). ?>
<div data-tg-combobox data-service="cms/paths/search"
<?php // The searchable "pick or type" control (TigerPathbox → Cms_Service_Paths). ?>
<div data-tg-pathbox data-service="cms/paths/search"
data-value-field="set-home-page" data-advanced-field="set-home-advanced">
<input type="text" class="form-control" id="set-home-page-search" data-tg-combobox-search
<input type="text" class="form-control" id="set-home-page-search" data-tg-pathbox-search
autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list"
value="<?= $this->escape($this->homeLabel) ?>"
placeholder="<?= $this->escape($this->t('cms.settings.home_placeholder')) ?>"
Expand All @@ -71,6 +71,6 @@ $this->i18n([
'networkError' => 'cms.js.network_error',
'settingsSaved' => 'cms.js.settings_saved',
]);
$this->pageScript($this->themeAssets . '/js/tiger.combobox.js');
$this->pageScript($this->themeAssets . '/js/tiger.pathbox.js');
$this->pageScript($this->themeAssets . '/js/cms.settings.js');
?>
37 changes: 37 additions & 0 deletions tests/Integration/Model/PageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,41 @@ public function an_exact_locale_still_beats_the_default_fallback(): void

$this->assertSame($de, $this->page->resolveBySlug('hallo', 'de', $org)->page_id, 'exact de wins over the en default');
}

// ----- publishedSummaries (the home-page selector's bounded finder) ---------------------------

#[Test]
public function published_summaries_returns_small_columns_for_published_pages_only(): void
{
$live = $this->insertPage(['slug' => 'zz-live-' . substr(Tiger_Uuid::v7(), 0, 8), 'title' => 'ZZ Live Page', 'page_key' => 'zz-live']);
$draft = $this->insertPage(['slug' => 'zz-draft', 'title' => 'ZZ Draft Page', 'status' => Tiger_Model_Page::STATUS_DRAFT]);
$future = $this->insertPage(['slug' => 'zz-future', 'title' => 'ZZ Future Page', 'published_at' => $this->at('+2 days')]);
$gone = $this->insertPage(['slug' => 'zz-gone', 'title' => 'ZZ Gone Page']);
$this->page->softDelete(['page_id = ?' => $gone]); // softDelete takes a WHERE, not a bare id

$rows = $this->page->publishedSummaries('ZZ ', 50);
$ids = array_map(static function ($r) { return $r['page_id']; }, $rows);

$this->assertContains($live, $ids, 'a published, past/NULL-scheduled page is listed');
$this->assertNotContains($draft, $ids, 'a draft is not');
$this->assertNotContains($future, $ids, 'a future-scheduled page is not');
$this->assertNotContains($gone, $ids, 'a soft-deleted page is not');

$row = null;
foreach ($rows as $r) { if ($r['page_id'] === $live) { $row = $r; } }
$this->assertNotNull($row);
$this->assertSame(['page_id', 'title', 'slug', 'page_key', 'locale'], array_keys($row), 'small columns only — never body/meta');
}

#[Test]
public function published_summaries_filters_by_query_and_honors_the_limit(): void
{
$wanted = $this->insertPage(['slug' => 'find-me-widget', 'title' => 'Find Me Widget', 'page_key' => 'find-me-widget']);
$this->insertPage(['slug' => 'unrelated-thing', 'title' => 'Unrelated Thing', 'page_key' => 'unrelated']);

$hits = array_map(static function ($r) { return $r['page_id']; }, $this->page->publishedSummaries('widget', 50));
$this->assertContains($wanted, $hits, 'matches on title/slug/page_key');

$this->assertLessThanOrEqual(1, count($this->page->publishedSummaries('', 1)), 'the row cap is enforced');
}
}
13 changes: 10 additions & 3 deletions themes/puma/assets/js/cms.settings.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/* SPDX-License-Identifier: BSD-3-Clause
* Copyright (c) 2026 WebTigers. Tiger and WebTigers are trademarks of WebTigers.
*
* CMS Settings — save handler. The home-page control is a TigerCombobox (auto-initialised by
* tiger.combobox.js over Cms_Service_Paths); this only wires the Save button, which posts the form to
* CMS Settings — save handler. The home-page control is a TigerPathbox (auto-initialised by
* tiger.pathbox.js over Cms_Service_Paths); this only wires the Save button, which posts the form to
* /api (Cms_Service_Settings::save) and drives the feedback via the house primitives. No page-POST.
*/
(function (document) {
Expand All @@ -19,6 +19,10 @@
fb.innerHTML = '';
form.querySelectorAll('.is-invalid').forEach(function (e) { e.classList.remove('is-invalid'); });

// Commit any text typed into the pathbox but not yet blurred, SYNCHRONOUSLY, so the value
// typed a moment before clicking Save is in the hidden field before FormData reads it.
if (window.TigerPathbox) { TigerPathbox.flushAll(form); }

var fd = new URLSearchParams(new FormData(form));
fd.set('module', 'cms'); fd.set('service', 'settings'); fd.set('method', 'save');

Expand All @@ -30,7 +34,10 @@
if (res && res.result === 1) { TigerDOM.notify(fb, Tiger.t('settingsSaved'), { type: 'success' }); return; }
if (res && res.form) {
Object.keys(res.form).forEach(function (field) {
var input = form.querySelector('[name="' + field + '"]');
// home_page is a hidden backing field; show the error on the VISIBLE search input.
var input = field === 'home_page'
? document.getElementById('set-home-page-search')
: form.querySelector('[name="' + field + '"]');
if (input) { input.classList.add('is-invalid'); }
});
}
Expand Down
Loading
Loading