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
3 changes: 2 additions & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
> before assuming something isn't built. `@api` = stable to build on; `@internal` = may change.
> Grouped by **capability** (across layers), not by directory.

**175 classes** across **31 capabilities** · **17 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md).
**176 classes** across **31 capabilities** · **17 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md).

## Capabilities (`library/Tiger`)

Expand Down Expand Up @@ -264,6 +264,7 @@
- **Tiger_Controller_Plugin_RouteOverride** `@api` — apply declared pretty-route overrides. · `library/Tiger/Controller/Plugin/RouteOverride.php`
- **Tiger_Controller_Plugin_ScheduleTick** `@internal` — the WordPress-style pseudo-cron for Tiger_Schedule. · `library/Tiger/Controller/Plugin/ScheduleTick.php`
- **Tiger_Controller_Plugin_ThemeContent** `@api` — serve a theme's BUNDLED STATIC pages. · `library/Tiger/Controller/Plugin/ThemeContent.php`
- **Tiger_Routing_ModuleRoutes** `@api` — ingests every ACTIVE module's `configs/routes.ini` into the router. · `library/Tiger/Routing/ModuleRoutes.php`
- **Tiger_Routing_Overrides** `@api` — the pretty-route registry (module hook + admin override tier). · `library/Tiger/Routing/Overrides.php`

### Data layer (base)
Expand Down
23 changes: 18 additions & 5 deletions ROUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,32 @@ what everything else points at. Trailing `key/value` pairs fold into `getParam()
A vanity URL is an **optional alias** — the canonical `<module>/<controller>/<action>` path always
works automagically via the default route, so you only add an alias for a nicer URL. **Never
`$router->addRoute()` in a Bootstrap** (`_init*`) — routes are config, and hardcoding them scatters
precedence across bootstraps and makes you fight Zend's route stack. Two homes, by layer:
precedence across bootstraps and makes you fight Zend's route stack. Three homes, by layer:

- **Core / default-namespace aliases → `configs/routes.ini`.** ZF1-native `resources.router.routes.*`,
folded into the config cascade by `Tiger_Application::buildConfig`; the standard Router resource
applies them — no route code anywhere. Example (`/vibe` → `IndexController::vibeAction`):
- **Core / default-namespace aliases → `TIGER_CORE_PATH/configs/routes.ini` (or the app's
`configs/routes.ini`).** ZF1-native `resources.router.routes.*`, folded into the config cascade by
`Tiger_Application::buildConfig`; the standard Router resource applies them — no route code anywhere.
Example (`/vibe` → `IndexController::vibeAction`):
```ini
[production]
resources.router.routes.vibe.type = "Zend_Controller_Router_Route_Static"
resources.router.routes.vibe.route = "vibe"
resources.router.routes.vibe.defaults.controller = "index"
resources.router.routes.vibe.defaults.action = "vibe"
```
- **Module aliases → a `Tiger_Routing_Overrides` declaration** (below), so one authority owns ordering.
- **Module routes → the module's own `configs/routes.ini`.** Same ZF1-native `resources.router.routes.*`
shape, declared *in the module*. `Tiger_Routing_ModuleRoutes` (called from
`Tiger_Application_Bootstrap::_initModuleRoutes`) discovers every **active** module's `routes.ini` and
registers them, **namespaced `<slug>__<name>`** so a module can't stomp a core or peer route (app-dir
modules override a same-slug core-dir one). This is the general mechanism — it handles param routes
(`blog/:slug`) and **works under the reserved `/admin` prefix** (a native router route is matched
before dispatch, so it wins over the default MVC resolution, which `Tiger_Routing_Overrides` cannot do).
`modules/blog/configs/routes.ini` is the reference; `modules/agent` serves `/admin/settings/agent/skills`
this way. Declaration order within the file is preserved (ZF1 matches newest-first), so declare a
more-specific route *after* the catch-all it must shadow.
- **Module prefix→target aliases → a `Tiger_Routing_Overrides` declaration** (below), for the
"prefix maps to a target, the remainder is a `slug`" shape (e.g. a docs tree). Admin-reconfigurable via
config, but it **refuses reserved prefixes** (`/api`, `/auth`, `/admin`) — use `routes.ini` for those.

A module declares its default alias from its Bootstrap:

Expand Down
33 changes: 33 additions & 0 deletions library/Tiger/Application/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,39 @@ protected function _initAuthAliases()
));
}

/**
* MODULE ROUTES: ingest every ACTIVE module's `configs/routes.ini` into the router — the missing
* per-type consumer for module route config (the mirror of how Tiger_Admin_Nav consumes
* navigation.ini, Tiger_Acl_Acl consumes acl.ini, etc.). A module declares pretty URLs declaratively
* as native `resources.router.routes.*`; no Bootstrap `addRoute` code. Routes are namespaced
* `<slug>__<name>` so a module can't stomp a core or peer route (Tiger_Routing_ModuleRoutes).
*
* Runs AFTER the kernel routes (/api, /auth aliases) so those keep priority, and reads the active
* set from the DB (graceful: no DB yet → every module active). Registered here means a module's
* pretty URL works the instant it's dropped in — including under the reserved /admin prefix, which
* the routeShutdown Tiger_Routing_Overrides can't claim (a native router route is matched before
* dispatch, so it wins over the default MVC resolution).
*/
protected function _initModuleRoutes()
{
$this->bootstrap('frontController');
try { $this->bootstrap('db'); } catch (Throwable $e) { /* fresh install / CLI — all active */ }

$inactive = [];
try {
if (class_exists('Tiger_Model_Module')) {
$inactive = (new Tiger_Model_Module())->inactiveSlugs();
}
} catch (Throwable $e) { /* no DB / no module table yet → everything active */ }

Tiger_Routing_ModuleRoutes::apply(
$this->getResource('frontController')->getRouter(),
[TIGER_CORE_PATH . '/modules', APPLICATION_PATH . '/modules'], // app-dir last → app overrides core
$inactive,
APPLICATION_ENV
);
}

/**
* AUTHORIZATION: build the ACL (Tiger_Acl_Acl loads roles/resources/rules from
* ini + DB) and register the unbypassable gate (Tiger_Controller_Plugin_
Expand Down
88 changes: 88 additions & 0 deletions library/Tiger/Routing/ModuleRoutes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Routing_ModuleRoutes — ingests every ACTIVE module's `configs/routes.ini` into the router.
*
* This is the missing per-type consumer for module route config — the exact mirror of how
* `Tiger_Admin_Nav` discovers `configs/navigation.ini`, `Tiger_Acl_Acl` discovers `configs/acl.ini`,
* and `Tiger_Schedule` discovers `configs/schedule.ini`. A module declares its pretty URLs as native
* ZF1 `resources.router.routes.*` in `configs/routes.ini` (declarative, no Bootstrap code), and the core
* bootstrap (`Tiger_Application_Bootstrap::_initModuleRoutes`) hands them to the rewrite router here.
*
* WHY module routes.ini didn't work before: `Tiger_Application::buildConfig()` only merges the CORE and
* APP `configs/routes.ini` into the global config — module route files had no consumer at all, so a
* module's pretty URL only worked if it called `$router->addRoute()` from its Bootstrap (config-as-code).
* This closes that gap: drop the file, get the route.
*
* Collision safety: every route is namespaced `<slug>__<name>`, so a module can never stomp a core route
* or another module's route by reusing a name. App-dir modules override a same-slug core-dir module
* (scanned last wins), matching the app-over-vendor precedence used everywhere else. Route *matching*
* still respects declaration order within a file (ZF1 checks routes newest-first), so a module orders its
* more-specific routes last to shadow its own catch-alls — exactly as `$router->addRoute()` did.
*
* @api
* @see Tiger_Admin_Nav the sibling discovery this mirrors (navigation.ini)
* @see Tiger_Application_Bootstrap::_initModuleRoutes the bootstrap seam that calls apply()
*/
class Tiger_Routing_ModuleRoutes
{
/**
* Collect namespaced route definitions from active modules' `configs/routes.ini`.
*
* @param array<int,string> $moduleDirs dirs to scan; each holds `<slug>/configs/routes.ini`
* @param array<int,string> $inactiveSlugs module slugs to skip (deactivated → no routes)
* @param string|null $env the ini env section (APPLICATION_ENV); null = flat file
* @return array<string,array> namespaced name (`<slug>__<name>`) => a ZF1 route definition array
*/
public static function collect(array $moduleDirs, array $inactiveSlugs = [], $env = null)
{
$out = [];
foreach ($moduleDirs as $modsDir) {
foreach (glob($modsDir . '/*', GLOB_ONLYDIR) ?: [] as $moduleDir) {
$slug = basename($moduleDir);
if (in_array($slug, $inactiveSlugs, true)) { continue; } // deactivated → skipped (mirrors Nav)
$ini = $moduleDir . '/configs/routes.ini';
if (!is_file($ini)) { continue; }
try {
$routes = self::_routesNode(new Zend_Config_Ini($ini, $env));
if ($routes === null) { continue; }
foreach ($routes as $name => $def) {
if ($def instanceof Zend_Config) { $out[$slug . '__' . $name] = $def->toArray(); }
}
} catch (Throwable $e) {
error_log('Tiger_Routing_ModuleRoutes: failed to load ' . $ini . ' — ' . $e->getMessage());
}
}
}
return $out;
}

/**
* Collect + apply active modules' routes to a rewrite router via ZF1's native route factory.
*
* @param Zend_Controller_Router_Rewrite $router the front controller's router
* @param array<int,string> $moduleDirs dirs to scan
* @param array<int,string> $inactiveSlugs slugs to skip
* @param string|null $env the ini env section
* @return int the number of routes registered
*/
public static function apply($router, array $moduleDirs, array $inactiveSlugs = [], $env = null)
{
$routes = self::collect($moduleDirs, $inactiveSlugs, $env);
if (!$routes) { return 0; }
// addConfig() runs each entry through ZF1's route factory (honors `type`, defaulting to
// Zend_Controller_Router_Route) and preserves order → newest-first matching is retained.
$router->addConfig(new Zend_Config($routes));
return count($routes);
}

/** Navigate to `resources.router.routes` in a parsed routes.ini, or null if absent/malformed. */
private static function _routesNode(Zend_Config $cfg)
{
$res = $cfg->get('resources');
$router = ($res instanceof Zend_Config) ? $res->get('router') : null;
$routes = ($router instanceof Zend_Config) ? $router->get('routes') : null;
return ($routes instanceof Zend_Config) ? $routes : null;
}
}
30 changes: 2 additions & 28 deletions modules/blog/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,11 @@
*
* Extending Zend_Application_Module_Bootstrap gives the module its resource autoloader,
* so Blog_Model_* (models/), Blog_Service_* (services/) and Blog_Form_* (forms/) load by
* convention; controllers load via the registered module dir; configs/acl.ini and
* languages/ are picked up by the core globs.
* convention; controllers load via the registered module dir; configs/acl.ini, configs/routes.ini
* (the /blog/* pretty URLs), and languages/ are picked up by the core globs.
*/
class Blog_Bootstrap extends Zend_Application_Module_Bootstrap
{
/**
* Public front-end routes under /blog. The rewrite router checks routes newest-first,
* so ORDER here matters: the admin route (/blog/post → the authoring controller) is added
* LAST so it shadows the /blog/:slug article route for that one path. Everything else —
* /blog (index, via the default module route), /blog/<slug>, /blog/category|tag/<slug>,
* /blog/feed — resolves to Blog_IndexController. The words post/category/tag/feed are
* therefore reserved article slugs (enforced in Blog_Service_Post::save).
*/
protected function _initBlogRoutes()
{
$router = Zend_Controller_Front::getInstance()->getRouter();

$router->addRoute('blog_single', new Zend_Controller_Router_Route(
'blog/:slug', ['module' => 'blog', 'controller' => 'index', 'action' => 'view']));
$router->addRoute('blog_category', new Zend_Controller_Router_Route(
'blog/category/:slug', ['module' => 'blog', 'controller' => 'index', 'action' => 'category']));
$router->addRoute('blog_tag', new Zend_Controller_Router_Route(
'blog/tag/:slug', ['module' => 'blog', 'controller' => 'index', 'action' => 'tag']));
$router->addRoute('blog_feed', new Zend_Controller_Router_Route(
'blog/feed', ['module' => 'blog', 'controller' => 'index', 'action' => 'feed']));

// Added last → checked first → /blog/post stays the admin list (not an article slug).
$router->addRoute('blog_admin', new Zend_Controller_Router_Route(
'blog/post', ['module' => 'blog', 'controller' => 'post', 'action' => 'index']));
}

/** Register the blog's "articles" search provider — the tap-in demo for Tiger_Search. */
protected function _initSearchProvider()
{
Expand Down
38 changes: 38 additions & 0 deletions modules/blog/configs/routes.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
; SPDX-License-Identifier: BSD-3-Clause
; Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
;
; Public front-end routes under /blog (ingested by Tiger_Routing_ModuleRoutes — declarative, no Bootstrap
; code). The rewrite router checks routes newest-first, so ORDER matters: blogAdmin (/blog/post → the
; authoring controller) is declared LAST so it shadows the /blog/:slug article route for that one path.
; Everything else — /blog (index, default module route), /blog/<slug>, /blog/category|tag/<slug>,
; /blog/feed — resolves to Blog_IndexController. The words post/category/tag/feed are therefore reserved
; article slugs (enforced in Blog_Service_Post::save). Type defaults to Zend_Controller_Router_Route.
[production]
resources.router.routes.blogSingle.route = "blog/:slug"
resources.router.routes.blogSingle.defaults.module = "blog"
resources.router.routes.blogSingle.defaults.controller = "index"
resources.router.routes.blogSingle.defaults.action = "view"

resources.router.routes.blogCategory.route = "blog/category/:slug"
resources.router.routes.blogCategory.defaults.module = "blog"
resources.router.routes.blogCategory.defaults.controller = "index"
resources.router.routes.blogCategory.defaults.action = "category"

resources.router.routes.blogTag.route = "blog/tag/:slug"
resources.router.routes.blogTag.defaults.module = "blog"
resources.router.routes.blogTag.defaults.controller = "index"
resources.router.routes.blogTag.defaults.action = "tag"

resources.router.routes.blogFeed.route = "blog/feed"
resources.router.routes.blogFeed.defaults.module = "blog"
resources.router.routes.blogFeed.defaults.controller = "index"
resources.router.routes.blogFeed.defaults.action = "feed"

; LAST → checked first → /blog/post stays the admin list (not an article slug).
resources.router.routes.blogAdmin.route = "blog/post"
resources.router.routes.blogAdmin.defaults.module = "blog"
resources.router.routes.blogAdmin.defaults.controller = "post"
resources.router.routes.blogAdmin.defaults.action = "index"
[staging : production]
[testing : production]
[development : production]
34 changes: 23 additions & 11 deletions tests/Integration/Blog/BlogBootstrapTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,19 @@
use ReflectionProperty;
use Tiger\Tests\Support\IntegrationTestCase;
use Tiger_Model_Page;
use Tiger_Routing_ModuleRoutes;
use Tiger_Search;
use Tiger_Sitemap;
use Zend_Controller_Front;

/**
* Blog_Bootstrap — the module's wiring: public /blog routes, the "articles" search-provider tap-in
* (the reference Tiger_Search demo), and the sitemap/llms provider.
* Blog_Bootstrap — the module's wiring: the "articles" search-provider tap-in (the reference
* Tiger_Search demo) and the sitemap/llms provider. (The public /blog routes are now declarative in
* configs/routes.ini, ingested by Tiger_Routing_ModuleRoutes — asserted here against that file.)
*
* Each _init* is invoked directly (the harness doesn't boot module bootstraps) and its effect is
* asserted against the real registries: the router has the blog routes, Tiger_Search has the
* articles provider whose closure resolves seeded articles, and Tiger_Sitemap::collect() runs the
* blog provider closure over published articles (excerpt/desc unpacked from page.meta).
* asserted against the real registries: Tiger_Search has the articles provider whose closure resolves
* seeded articles, and Tiger_Sitemap::collect() runs the blog provider closure over published articles
* (excerpt/desc unpacked from page.meta).
*/
#[CoversClass(\Blog_Bootstrap::class)]
final class BlogBootstrapTest extends IntegrationTestCase
Expand Down Expand Up @@ -63,14 +64,25 @@ private function seedArticle(array $overrides): string
}

#[Test]
public function it_registers_the_public_blog_routes(): void
public function it_ships_the_public_blog_routes_declaratively(): void
{
$this->invoke('_initBlogRoutes');
$router = Zend_Controller_Front::getInstance()->getRouter();
// Routes now live in modules/blog/configs/routes.ini, ingested + namespaced by the core bootstrap.
$routes = Tiger_Routing_ModuleRoutes::collect([TIGER_CORE_PATH . '/modules'], [], APPLICATION_ENV);

foreach (['blog_single', 'blog_category', 'blog_tag', 'blog_feed', 'blog_admin'] as $name) {
$this->assertTrue($router->hasRoute($name), "route $name registered");
foreach (['blogSingle', 'blogCategory', 'blogTag', 'blogFeed', 'blogAdmin'] as $name) {
$this->assertArrayHasKey('blog__' . $name, $routes, "blog route $name shipped in routes.ini");
}
$this->assertSame('blog/:slug', $routes['blog__blogSingle']['route']);
$this->assertSame('blog/post', $routes['blog__blogAdmin']['route'], 'the /blog/post admin list route');
$this->assertSame('post', $routes['blog__blogAdmin']['defaults']['controller']);

// blogAdmin is declared LAST so newest-first matching lets /blog/post shadow /blog/:slug.
$keys = array_keys($routes);
$this->assertGreaterThan(
array_search('blog__blogSingle', $keys, true),
array_search('blog__blogAdmin', $keys, true),
'blogAdmin is ordered after blogSingle so it is matched first'
);
}

#[Test]
Expand Down
Loading
Loading