From a38eb4d12fd6bcb2a7359262360ad079e22fa3e1 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 5 Aug 2026 08:41:13 -0400 Subject: [PATCH 01/14] Get Tiger page + Bengal skin + CMS builder improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /get-tiger: shipped marketing page (IndexController::getTigerAction + view + routes.ini alias) — the four ways to run Tiger, vibe-stack comparison, portability, in the /vibe family. - Bengal skin: warm near-black ground + burnt tiger-orange, big bold headings; registered in the skin-switcher catalog. - default.css: named content-width classes (.container-narrow/-menu/-wide/ -full-bleed) for all skins; .container-menu matches the navbar width. CMS visual builder (design.phtml + tiger.page-builder.js): - Load the real front-end stylesheets into the GrapesJS canvas (was unstyled). - Canvas light/dark toggle; body background now tracks the theme token. - Non-editable, locked header/footer chrome preview (Elementor-style), stripped from the saved body. - Partials/composition primitive: [partial name="x"] shortcode (recursive, cycle/depth-guarded) + a GrapesJS Partial widget (live preview, exports the shortcode); designAction feeds the named partials. - Fix: Menu/Partial widgets are hover-selectable (pointer-events moved off the component root onto an inner preview wrapper). Co-Authored-By: Claude Opus 4.8 (1M context) --- configs/routes.ini | 6 + core/controllers/IndexController.php | 11 + core/views/scripts/index/get-tiger.phtml | 278 ++++++++++++++++++ library/Tiger/Application/Bootstrap.php | 34 +++ modules/cms/controllers/PageController.php | 21 ++ modules/cms/views/scripts/page/design.phtml | 31 +- themes/puma/assets/default.css | 23 ++ themes/puma/assets/js/tiger.page-builder.js | 111 ++++++- themes/puma/assets/skins/bengal.css | 65 ++++ .../scripts/_partials/skin-switcher.phtml | 1 + 10 files changed, 575 insertions(+), 6 deletions(-) create mode 100644 core/views/scripts/index/get-tiger.phtml create mode 100644 themes/puma/assets/skins/bengal.css diff --git a/configs/routes.ini b/configs/routes.ini index 413341f..c7cd5bb 100644 --- a/configs/routes.ini +++ b/configs/routes.ini @@ -44,6 +44,12 @@ resources.router.routes.hosting.defaults.module = "default" resources.router.routes.hosting.defaults.controller = "index" resources.router.routes.hosting.defaults.action = "hosting" +resources.router.routes.get_tiger.type = "Zend_Controller_Router_Route_Static" +resources.router.routes.get_tiger.route = "get-tiger" +resources.router.routes.get_tiger.defaults.module = "default" +resources.router.routes.get_tiger.defaults.controller = "index" +resources.router.routes.get_tiger.defaults.action = "get-tiger" + ; Self-service profile pretty alias: /user/profile -> the profile module's index. The canonical ; /profile path keeps working via the default MVC route; this is just the nicer URL (config, not code). resources.router.routes.user_profile.type = "Zend_Controller_Router_Route_Static" diff --git a/core/controllers/IndexController.php b/core/controllers/IndexController.php index bb67c89..04e36dd 100644 --- a/core/controllers/IndexController.php +++ b/core/controllers/IndexController.php @@ -104,6 +104,17 @@ public function featuresAction() // view: index/features.phtml } + /** + * `/get-tiger` — the "Get Tiger" page: the four ways to run Tiger, the vibe-stack comparison, and + * portability. A shipped marketing page; the view owns its content. + * + * @return void + */ + public function getTigerAction() + { + // view: index/get-tiger.phtml + } + /** The configured home-page id (tiger.site.home_page), or '' for the built-in landing. */ protected function _homePageId() { diff --git a/core/views/scripts/index/get-tiger.phtml b/core/views/scripts/index/get-tiger.phtml new file mode 100644 index 0000000..1e13d24 --- /dev/null +++ b/core/views/scripts/index/get-tiger.phtml @@ -0,0 +1,278 @@ + orange under the Bengal skin). LEFT-aligned to match the design. + * Shipped marketing; the view owns its content. + */ +$this->title = 'Get Tiger — Real SaaS apps under $20/mo, no vendor lock-in | Tiger'; + +$features = [ + ['fa-layer-group', 'Multi-tenant architecture', 'Baked into the model, not bolted on.'], + ['fa-user-shield', 'Multi-user with full ACL', 'Deny-by-default security at the framework level.'], + ['fa-users-gear', 'User & tenant admin', 'Orgs, users, and memberships, managed out of the box.'], + ['fa-database', 'DB-backed sessions', 'Load-balancer ready from day one.'], + ['fa-language', 'Translation / i18n', 'Live string overrides, no redeploy.'], + ['fa-shield-halved', 'TigerShield security', 'A WAF at the PHP layer — ~50µs bootstrap.'], + ['fa-lock', 'TigerSSL', 'Automatic Let\'s Encrypt certificates.'], + ['fa-box-archive', 'TigerBackup', 'Zip, move, unzip — done.'], + ['fa-code', 'No-build JS/CSS', 'React, Vue, vanilla — bring whatever you want.'], + ['fa-palette', 'Themes & skins', 'Runtime restyle, no rebuild.'], +]; + +$compare = [ + ['Monthly cost', '$80+ — Vercel + Supabase + Auth + more', 'Under $20/mo on TigerDen'], + ['Page load', '2–3s across multiple network hops', '~10ms — single process, no external calls'], + ['Portability', 'Locked to 4+ vendors; migration = rewrite', 'Zip, move, unzip — runs anywhere PHP + MariaDB runs'], + ['Auth / ACL', 'Scattered across vendor SDKs and JWT hacks', 'Built-in, framework-level, battle-tested'], + ['Multi-tenancy', 'Bolt-on afterthought', 'Baked into the architecture'], + ['Ownership', 'A thin client for someone else\'s platform', 'You own everything on the filesystem'], + ['Offline / air-gap','Dead without internet — an empty shell', 'Fully functional, zero external dependencies'], + ['Frontend', 'Opinionated — Vite / Webpack required', 'No-build; bring whatever you want'], +]; +?> + + +
+
+
The AI-native SaaS platform
+

Real SaaS apps. Under $20/mo.
No vendor lock-in.

+

+ Stop overpaying for a vibe platform. Other stacks charge $80+/mo and you still don't own + anything — you're renting four vendors and hoping none of them change their pricing. Tiger gives + you everything you need to build and deploy real, portable, enterprise-grade SaaS apps, and you + own your entire stack. +

+
+ Get started → + + $ composer create-project webtigers/tiger + copy + +
+
+ One command. A functional SaaS app in seconds. + BSD-3 open source. + Runs anywhere PHP + MariaDB runs. +
+
+
+ + +
+
+
+
+
+
~10ms
+
Page loads — single process, no external hops
+
+
+
+
+
50µs
+
TigerShield security bootstrap
+
+
+
+
+
$0 / vendor
+
No four-vendor bill, no phone-home to run
+
+
+
+
+
+ + +
+
+
Out of the box
+

Everything a real SaaS needs — already built in

+

Not a starter kit you assemble from a dozen + vendors. The boring-but-essential substrate is shipped, tested, and yours.

+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+ + +
+
+
The vibe-coding stack problem
+

Four vendors, $80+/mo, and you still own nothing

+

The typical vibe stack is a thin client for + someone else's platform. Tiger is the whole thing, on your filesystem.

+
+ + + + + + + + + + + + + + + + + +
Typical vibe stackTiger
+
+
+
+ + +
+
+
+
+
Bring your own Claude Code
+

You control the AI. You control the code.

+

Tiger is purpose-built for + AI-assisted development. Bring your own Claude Code subscription and start building — no + proprietary AI middleman taking a cut or adding latency. You control the infrastructure, + top to bottom.

+
+
+
+
+
No middleman
+
    +
  • No per-seat AI markup
  • +
  • No added latency hop
  • +
  • Your keys, your rules
  • +
  • The code stays yours
  • +
+
+
+
+
+
+
+ + +
+
+
Four ways to get started
+

Pick how you want to run it

+

From a one-click server to a Composer + command — same Tiger, your choice of on-ramp.

+
+ +
+
+ Fastest +
+
01
+

TigerDen

+
Instant hosting — you own the box
+
$19 / mo
+

Your own Tiger server, launched on demand and live in + minutes — TigerSSL, TigerShield, and TigerPASS included, your AI agent ready to + go. We launch it and hand you the keys; you own and drive the box. Zero setup to + go live, no build step, no server to stand up.

+
+
+
+ +
+
+
+
02
+

TigerInstall

+
Shared hosting
+

Already have shared hosting? TigerInstall gets you + running with a one-click setup on any standard LAMP host — no shell, no Composer, + no DevOps.

+
+
+
+ +
+
+
+
03
+

Tiger + Composer

+
Full control, for developers
+
+ $ composer create-project webtigers/tiger +
+

A functional SaaS app in seconds. Own it from the first commit.

+
+
+
+ +
+
+
+
04
+

Tiger + Docker

+
Local dev environment
+

Spin up a full local environment in seconds. Build + locally, deploy anywhere PHP + MariaDB runs.

+
+
+
+ +
+
+
+ + +
+
+
True portability
+

Your app. Where you put it.

+

No export APIs. No proprietary formats. No + six-month replatforming projects. No consultant budgets. TigerBackup is the whole migration story:

+
+ $s): ?> + + + +
+

Tiger runs on any box with PHP and MariaDB — + a $5 VPS, bare metal, even a Raspberry Pi. Your app doesn't phone home to four vendors to figure + out whether it's allowed to run. It's yours. It runs where you put it.

+
+
+ + +
+
+
Open source
+

BSD-3 licensed. No gotchas.

+

Tiger is free, open-source software under the + BSD-3 license. No relicensing surprises. Build what you want, deploy where you want, keep + everything you build.

+ +

TigerDen is an instant-launch hosting + service: we provision your dedicated Tiger server and hand you the keys — you own and operate the + box. It is not a fully-managed service. Pricing and on-ramps shown reflect the launch lineup; some + options roll out through launch.

+
+
diff --git a/library/Tiger/Application/Bootstrap.php b/library/Tiger/Application/Bootstrap.php index ec99f89..e115e86 100644 --- a/library/Tiger/Application/Bootstrap.php +++ b/library/Tiger/Application/Bootstrap.php @@ -204,6 +204,40 @@ protected function _initCms() if (!empty($attrs['id'])) { $options['id'] = $attrs['id']; } return Tiger_Menu::getHTML($key, $options); }); + + // [partial name="x"] -> render a published `type=partial` page row (DB, org-cascade: an org's own + // partial wins over the global one), RECURSIVELY — a partial can contain widgets ([menu], …) and + // nested [partial]s — with a cycle + depth guard. This is the composition primitive: everything + // (header, footer, hero, section, …) is just a labeled partial dropped into a page or layout. + Tiger_Cms_Renderer::registerShortcode('partial', static function ($attrs, $inner = null, array $context = []) { + $name = trim((string) ($attrs['name'] ?? ($attrs['key'] ?? ''))); + if ($name === '') { return ''; } + + $stack = (isset($context['_partialStack']) && is_array($context['_partialStack'])) ? $context['_partialStack'] : []; + if (in_array($name, $stack, true) || count($stack) >= 10) { + return ''; + } + try { + $model = new Tiger_Model_Page(); + $orgId = class_exists('Tiger_Model_Org') ? (string) Tiger_Model_Org::siteOrgId() : ''; + $row = $model->fetchRow( + $model->activeSelect() + ->where('type = ?', Tiger_Model_Page::TYPE_PARTIAL) + ->where('page_key = ?', $name) + ->where('status = ?', Tiger_Model_Page::STATUS_PUBLISHED) + ->where('org_id IN (?)', [$orgId, '']) + ->order('org_id DESC') // an org-specific partial wins over the global one + ->limit(1) + ); + if (!$row) { return ''; } + return (new Tiger_Cms_Renderer())->renderBody( + (string) $row->body, (string) $row->format, + $context + ['_partialStack' => array_merge($stack, [$name])] + ); + } catch (Throwable $e) { + return ''; + } + }); } /** diff --git a/modules/cms/controllers/PageController.php b/modules/cms/controllers/PageController.php index 6e2f8e1..10a4206 100644 --- a/modules/cms/controllers/PageController.php +++ b/modules/cms/controllers/PageController.php @@ -110,6 +110,26 @@ public function designAction() $menus[$key] = Tiger_Menu::getHTML($key); } + // Named partials for the builder's Partial widget — a {name: {label, html}} map: `label` for + // the picker, `html` a live-rendered preview. The widget exports [partial name="x"] (dynamic), + // never the preview markup, so editing the partial updates everywhere it's dropped. + $partials = []; + try { + $pm = new Tiger_Model_Page(); + foreach ($pm->fetchAll( + $pm->activeSelect() + ->where('type = ?', Tiger_Model_Page::TYPE_PARTIAL) + ->where('status = ?', Tiger_Model_Page::STATUS_PUBLISHED) + ->order('page_key ASC') + ) as $r) { + if (!$r->page_key) { continue; } + $partials[$r->page_key] = [ + 'label' => (string) (($r->title !== null && $r->title !== '') ? $r->title : $r->page_key), + 'html' => (new Tiger_Cms_Renderer())->renderBody((string) $r->body, (string) $r->format, []), + ]; + } + } catch (\Throwable $e) { $partials = []; } + // The ACTIVE theme's builder components (its components/*.phtml) + the CSS to load into // the GrapesJS canvas so those blocks preview in the theme's own style (THEMES.md Tier 2). $manifest = Tiger_Theme::manifest(); @@ -119,6 +139,7 @@ public function designAction() $this->view->page = $page; $this->view->projectData = !empty($meta['builder']) ? $meta['builder'] : null; $this->view->menus = $menus; + $this->view->partials = $partials; $this->view->themeBlocks = Tiger_Theme::components(); $this->view->canvasCss = isset($manifest['canvasCss']) ? (array) $manifest['canvasCss'] : []; } diff --git a/modules/cms/views/scripts/page/design.phtml b/modules/cms/views/scripts/page/design.phtml index 3e08c66..cc2e7ba 100644 --- a/modules/cms/views/scripts/page/design.phtml +++ b/modules/cms/views/scripts/page/design.phtml @@ -11,6 +11,28 @@ $va = $this->themeAssets; // Safe JSON embedding in a or quote breakouts). $jf = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP; + +// The base front-end stylesheets the LIVE page uses. GrapesJS renders the page inside an isolated +// iframe that does NOT inherit the outer document's s, so these must be injected into the +// canvas (below) — otherwise the page edits as unstyled HTML. Same set + order as the public layout. +$canvasStyles = [ + $this->asset($va . '/vendor/fonts/inter/inter.css'), + $this->asset($va . '/vendor/fontawesome/css/all.min.css'), + $this->asset($va . '/css/bootstrap.css'), + $this->asset($va . '/default.css'), +]; +if (!empty($this->skin)) { $canvasStyles[] = $this->asset($va . '/skins/' . $this->skin . '.css'); } +$canvasStyles[] = $this->asset($va . '/custom.css'); + +// Render the real public header + footer for a NON-EDITABLE preview inside the builder canvas +// (Elementor-style theme chrome). #is', '#]*>.*?#is']; +try { $headerHtml = trim((string) preg_replace($tbStrip, '', (string) $this->render('_partials/public-header.phtml'))); } catch (\Throwable $e) {} +try { $footerHtml = trim((string) preg_replace($tbStrip, '', (string) $this->render('_partials/public-footer.phtml'))); } catch (\Throwable $e) {} ?> @@ -38,6 +60,7 @@ $jf = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JS
+ Settings Exit @@ -53,8 +76,12 @@ $jf = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JS project: projectData ? json_encode($this->projectData, $jf) : 'null' ?>, seedHtml: page->body, $jf) ?>, menus: menus ?: (object) [], $jf) ?>, - themeBlocks: themeBlocks ?: [], $jf) ?>, - canvasCss: canvasCss ?: [], $jf) ?> + partials: partials ?: (object) [], $jf) ?>, + themeBlocks: themeBlocks ?: [], $jf) ?>, + headerHtml: , + footerHtml: , + canvasStyles: , + canvasCss: canvasCss ?: [], $jf) ?> }; diff --git a/themes/puma/assets/default.css b/themes/puma/assets/default.css index 4ff8231..c5cac2b 100644 --- a/themes/puma/assets/default.css +++ b/themes/puma/assets/default.css @@ -41,3 +41,26 @@ footer.tiger-foot { .post-body a{color:var(--bs-primary);} .post-footer{margin-top:2.5rem;padding-top:1.5rem;border-top:1px solid var(--bs-border-color);} .post-terms a{text-decoration:none;} + +/* ── Named content-width containers (theme base — apply under EVERY skin) ───── + Pick one per
instead of a hand-set max-width. Complete containers + (centered, standard Bootstrap gutters) — except -full-bleed, edge-to-edge with + NO padding or margin. Names don't collide with Bootstrap's .container-{sm..xxl}/-fluid. */ +.container-narrow,.container-menu,.container-wide{ + width:100%;margin-right:auto;margin-left:auto; + padding-right:calc(var(--bs-gutter-x,1.5rem)*.5); + padding-left:calc(var(--bs-gutter-x,1.5rem)*.5); +} +.container-narrow{max-width:760px;} /* a tight reading/prose column */ +.container-wide{max-width:1440px;} /* roomier than the menu bar */ + +/* matches the main menu bar (the public navbar + footer use Bootstrap .container) */ +.container-menu{max-width:100%;} +@media(min-width:576px){.container-menu{max-width:540px;}} +@media(min-width:768px){.container-menu{max-width:720px;}} +@media(min-width:992px){.container-menu{max-width:960px;}} +@media(min-width:1200px){.container-menu{max-width:1140px;}} +@media(min-width:1400px){.container-menu{max-width:1320px;}} + +/* edge-to-edge: no max-width, no padding, no margin */ +.container-full-bleed{width:100%;max-width:none;margin:0;padding:0;} diff --git a/themes/puma/assets/js/tiger.page-builder.js b/themes/puma/assets/js/tiger.page-builder.js index 3d5fff3..7c2edd5 100644 --- a/themes/puma/assets/js/tiger.page-builder.js +++ b/themes/puma/assets/js/tiger.page-builder.js @@ -20,7 +20,7 @@ height: '100%', fromElement: false, storageManager: false, // Tiger owns persistence, via /api - canvas: { styles: cfg.canvasCss || [] }, // load the ACTIVE theme's CSS so blocks preview in its style + canvas: { styles: (cfg.canvasStyles || []).concat(cfg.canvasCss || []) }, // base front-end CSS (bootstrap/default/skin/FA) + theme block CSS, injected into the canvas iframe // Choosing/uploading an image opens the shared Tiger Media Library (TigerMediaPicker // over Media_Service_Media) instead of GrapesJS's default uploader — so page media is // real TigerMedia (public URL / CDN), never a base64 blob inlined into the body. @@ -49,6 +49,7 @@ // Tiger additions: a live-rendering Menu component + a Bootstrap 5 block library. Base set — // enough to show where this goes (a Divi/Elementor-class kit); dress up later. registerMenuComponent(editor); + registerPartialComponent(editor); registerBootstrapBlocks(editor); registerVideoPicker(editor); registerThemeBlocks(editor); @@ -82,7 +83,7 @@ body.set('service', 'page'); body.set('method', 'saveDesign'); body.set('page_id', cfg.pageId || ''); - body.set('html', editor.getHtml()); + body.set('html', tbStripChrome(editor.getHtml())); // never persist the header/footer preview into the page body body.set('css', editor.getCss()); body.set('project', JSON.stringify(editor.getProjectData())); @@ -104,6 +105,67 @@ var saveBtn = document.getElementById('tb-save'); if (saveBtn) { saveBtn.addEventListener('click', save); } + // Canvas light/dark — mirror the site's data-bs-theme INSIDE the canvas iframe (Bootstrap reads it + // on the iframe's ; our skins define both modes), toggleable from the top-bar sun/moon. + var tbMode = 'light'; + // GrapesJS hardcodes a white "paper" bg on the canvas body, which ignores --bs-body-bg — so the body + // stayed white while token-driven sections went dark. Tie html/body to the Bootstrap body token so + // it follows the theme (background only forced; text-* utilities keep their own colors). + function tbApplyCanvasBg() { + try { + var doc = editor.Canvas.getDocument(); + if (!doc || doc.querySelector('style[data-tiger="canvas-bg"]')) { return; } + var st = doc.createElement('style'); + st.setAttribute('data-tiger', 'canvas-bg'); + st.textContent = 'html,body{background-color:var(--bs-body-bg)!important;color:var(--bs-body-color);}'; + (doc.head || doc.documentElement).appendChild(st); + } catch (e) {} + } + function tbCanvasTheme(mode) { + tbMode = mode; + try { + var doc = editor.Canvas.getDocument(); + if (doc && doc.documentElement) { doc.documentElement.setAttribute('data-bs-theme', mode); } + } catch (e) {} + var icon = document.querySelector('#tb-theme i'); + if (icon) { icon.className = 'fa-solid ' + (mode === 'dark' ? 'fa-moon' : 'fa-sun'); } + } + // Default the canvas to whatever the builder itself is in (theme-init.js set it from the cookie), + // and re-apply after a device switch (which reloads the canvas frame). + editor.on('load', function () { tbApplyCanvasBg(); tbCanvasTheme(document.documentElement.getAttribute('data-bs-theme') || 'light'); }); + editor.on('canvas:frame:load', function () { tbApplyCanvasBg(); tbCanvasTheme(tbMode); }); + var themeBtn = document.getElementById('tb-theme'); + if (themeBtn) { themeBtn.addEventListener('click', function () { tbCanvasTheme(tbMode === 'dark' ? 'light' : 'dark'); }); } + + // ---- Non-editable theme chrome: render the real header + footer in the canvas for context + // (Elementor-style), LOCKED so they can't be edited/moved/deleted, and STRIPPED from the saved + // body so they never bake into the page (the live layout provides the real header/footer). ---- + function tbLock(c) { + c.set({ selectable: false, editable: false, hoverable: false, highlightable: false, draggable: false, + droppable: false, removable: false, copyable: false, layerable: false, badgable: false }); + var kids = c.components && c.components(); + if (kids && kids.forEach) { kids.forEach(tbLock); } + } + function tbInjectChrome() { + var w = editor.getWrapper(); + if (!w) { return; } + try { w.find('[data-tiger-chrome]').forEach(function (c) { c.remove(); }); } catch (e) {} // drop any stale copy restored from the project + try { + if (cfg.footerHtml) { w.append('
' + cfg.footerHtml + '
'); } + if (cfg.headerHtml) { w.append('
' + cfg.headerHtml + '
', { at: 0 }); } + w.find('[data-tiger-chrome]').forEach(tbLock); + } catch (e) {} + } + function tbStripChrome(html) { + try { + var d = new DOMParser().parseFromString('' + (html || '') + '', 'text/html'); + var nodes = d.querySelectorAll('[data-tiger-chrome]'); + for (var i = 0; i < nodes.length; i++) { nodes[i].parentNode.removeChild(nodes[i]); } + return d.body.innerHTML; + } catch (e) { return html; } + } + editor.on('load', function () { tbInjectChrome(); }); + // Ctrl/Cmd+S saves without leaving the builder. document.addEventListener('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && String(e.key).toLowerCase() === 's') { e.preventDefault(); save(); } @@ -143,10 +205,50 @@ onRender: function () { var key = this.model.get('menuKey') || 'primary'; var m = (window.TIGER_BUILDER && window.TIGER_BUILDER.menus) || {}; - this.el.innerHTML = (m[key] != null && m[key] !== '') + var inner = (m[key] != null && m[key] !== '') ? '' : '
[menu name="' + key + '"] — no preview
'; - this.el.style.pointerEvents = 'none'; // canvas preview only + // pointer-events off on the PREVIEW only — so GrapesJS can still hover/select/move the component. + this.el.innerHTML = '
' + inner + '
'; + } + } + }); + } + + // The PARTIAL widget: drop it, pick a named partial (a type=partial page), it renders a LIVE preview + // and exports [partial name="x"] — dynamic at view time, so editing the partial updates everywhere. + // The composition primitive for "everything is a partial" (header/footer/hero/section/…). Twin of the + // Menu widget above. + function registerPartialComponent(editor) { + var partials = (window.TIGER_BUILDER && window.TIGER_BUILDER.partials) || {}; + var names = Object.keys(partials); + + editor.Components.addType('tiger-partial', { + isComponent: function (el) { return el && el.getAttribute && el.getAttribute('data-tiger-partial') !== null; }, + model: { + defaults: { + partialName: names[0] || '', + draggable: true, droppable: false, editable: false, highlightable: true, + attributes: { 'data-tiger-partial': '' }, + traits: [{ + type: names.length ? 'select' : 'text', name: 'partialName', label: 'Partial', changeProp: 1, + options: names.map(function (n) { return { id: n, name: (partials[n] && partials[n].label) || n }; }) + }] + }, + init: function () { this.on('change:partialName', function () { if (this.view) { this.view.render(); } }); }, + // Export the SHORTCODE, not the preview — the partial stays dynamic + editable in its own editor. + toHTML: function () { return '[partial name="' + (this.get('partialName') || '') + '"]'; } + }, + view: { + onRender: function () { + var name = this.model.get('partialName') || ''; + var p = (window.TIGER_BUILDER && window.TIGER_BUILDER.partials) || {}; + var html = (p[name] && p[name].html != null) ? p[name].html : ''; + var inner = (html !== '') + ? html + : '
[partial name="' + (name || '?') + '"] — pick a partial in the trait panel
'; + // pointer-events off on the PREVIEW only — GrapesJS keeps hover/select/move/delete on the widget. + this.el.innerHTML = '
' + inner + '
'; } } }); @@ -194,6 +296,7 @@ add('tb-accordion', 'Accordion', 'Components', accordionHtml(), 'fa-bars-staggered'); bm.add('tb-menu', { label: 'Menu', category: 'Components', media: '', content: { type: 'tiger-menu' } }); + bm.add('tb-partial', { label: 'Partial', category: 'Components', media: '', content: { type: 'tiger-partial' } }); } // ---- Video ↔ Media Library. The native video component takes its src from a trait, not the diff --git a/themes/puma/assets/skins/bengal.css b/themes/puma/assets/skins/bengal.css new file mode 100644 index 0000000..3fb6e58 --- /dev/null +++ b/themes/puma/assets/skins/bengal.css @@ -0,0 +1,65 @@ +/*! SPDX-License-Identifier: BSD-3-Clause · © 2026 WebTigers · Tiger™/WebTigers™ are trademarks */ +/* PUMA skin: bengal — Tiger-original. A warm near-black ground with a burnt tiger-orange + accent and big, bold, tight-tracked headings (the "Get Tiger" look). A variable-level + overlay on stock Bootstrap 5.3, loaded AFTER bootstrap.css — re-declares the --bs-* color + system, font, and radius for BOTH light and dark (data-bs-theme), so it keeps its identity + in either mode. Swapped live by the skin switcher (tiger.prefs.js). */ +@import url('../vendor/fonts/inter/inter.css'); + +:root,[data-bs-theme=light]{ + --bs-blue:#3a6ea5;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d6336c;--bs-red:#cf3a2e; + --bs-orange:#e8631a;--bs-yellow:#e8922b;--bs-green:#1f9d57;--bs-teal:#199d8b;--bs-cyan:#2f7fa8; + --bs-black:#000;--bs-white:#fff; + --bs-gray:#7c6f60;--bs-gray-dark:#39322a; + --bs-gray-100:#f7f3ee;--bs-gray-200:#efe7dc;--bs-gray-300:#e4d8c9;--bs-gray-400:#d3c3ae; + --bs-gray-500:#a89a89;--bs-gray-600:#7c6f60;--bs-gray-700:#5b5044;--bs-gray-800:#39322a;--bs-gray-900:#211b15; + --bs-primary:#c65312;--bs-secondary:#7c6f60;--bs-success:#1f9d57;--bs-info:#2f7fa8;--bs-warning:#e8922b;--bs-danger:#cf3a2e;--bs-light:#f7f3ee;--bs-dark:#211b15; + --bs-primary-rgb:198,83,18;--bs-secondary-rgb:124,111,96;--bs-success-rgb:31,157,87;--bs-info-rgb:47,127,168;--bs-warning-rgb:232,146,43;--bs-danger-rgb:207,58,46;--bs-light-rgb:247,243,238;--bs-dark-rgb:33,27,21; + --bs-primary-text-emphasis:#742f09;--bs-secondary-text-emphasis:#3a332b;--bs-success-text-emphasis:#0d3f23;--bs-info-text-emphasis:#12333f;--bs-warning-text-emphasis:#5c3a11;--bs-danger-text-emphasis:#531712;--bs-light-text-emphasis:#5b5044;--bs-dark-text-emphasis:#5b5044; + --bs-primary-bg-subtle:#fbe6d6;--bs-secondary-bg-subtle:#efe7dc;--bs-success-bg-subtle:#d6f0e0;--bs-info-bg-subtle:#d9eaf2;--bs-warning-bg-subtle:#fbebd5;--bs-danger-bg-subtle:#f7dad7;--bs-light-bg-subtle:#fcfbf8;--bs-dark-bg-subtle:#d3c3ae; + --bs-primary-border-subtle:#eeb488;--bs-secondary-border-subtle:#d3c3ae;--bs-success-border-subtle:#a6ddc0;--bs-info-border-subtle:#abcbe0;--bs-warning-border-subtle:#f6d5a6;--bs-danger-border-subtle:#eab0ab;--bs-light-border-subtle:#efe7dc;--bs-dark-border-subtle:#a89a89; + --bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0; + --bs-font-sans-serif:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + --bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; + --bs-gradient:linear-gradient(180deg, rgba(255,255,255,.15), rgba(255,255,255,0)); + --bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.6; + --bs-body-color:#2b241d;--bs-body-color-rgb:43,36,29;--bs-body-bg:#faf7f2;--bs-body-bg-rgb:250,247,242; + --bs-emphasis-color:#171310;--bs-emphasis-color-rgb:23,19,16; + --bs-secondary-color:rgba(43,36,29,.7);--bs-secondary-color-rgb:43,36,29;--bs-secondary-bg:#efe7dc;--bs-secondary-bg-rgb:239,231,220; + --bs-tertiary-color:rgba(43,36,29,.5);--bs-tertiary-color-rgb:43,36,29;--bs-tertiary-bg:#f5efe7;--bs-tertiary-bg-rgb:245,239,231; + --bs-heading-color:inherit; + --bs-link-color:#c65312;--bs-link-color-rgb:198,83,18;--bs-link-decoration:none;--bs-link-hover-color:#a5430c;--bs-link-hover-color-rgb:165,67,12; + --bs-code-color:#c0491a;--bs-highlight-color:#2b241d;--bs-highlight-bg:#fbebd5; + --bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#e4d8c9;--bs-border-color-translucent:rgba(40,25,10,.14); + --bs-border-radius:.55rem;--bs-border-radius-sm:.35rem;--bs-border-radius-lg:.85rem;--bs-border-radius-xl:1.1rem;--bs-border-radius-xxl:1.6rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem; + --bs-box-shadow:0 1px 2px rgba(40,25,10,.05),0 12px 30px -16px rgba(40,25,10,.2);--bs-box-shadow-sm:0 .125rem .25rem rgba(40,25,10,.07);--bs-box-shadow-lg:0 1.2rem 3rem rgba(40,25,10,.16);--bs-box-shadow-inset:inset 0 1px 2px rgba(40,25,10,.075); + --bs-focus-ring-width:.25rem;--bs-focus-ring-opacity:.22;--bs-focus-ring-color:rgba(198,83,18,.28); + --bs-form-valid-color:#1f9d57;--bs-form-valid-border-color:#1f9d57;--bs-form-invalid-color:#cf3a2e;--bs-form-invalid-border-color:#cf3a2e; +} + +[data-bs-theme=dark]{ + color-scheme:dark; + --bs-body-color:#f1e9df;--bs-body-color-rgb:241,233,223;--bs-body-bg:#100e0c;--bs-body-bg-rgb:16,14,12; + --bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255; + --bs-secondary-color:rgba(241,233,223,.72);--bs-secondary-color-rgb:241,233,223;--bs-secondary-bg:#1c1712;--bs-secondary-bg-rgb:28,23,18; + --bs-tertiary-color:rgba(241,233,223,.5);--bs-tertiary-color-rgb:241,233,223;--bs-tertiary-bg:#17120e;--bs-tertiary-bg-rgb:23,18,14; + --bs-primary:#f07c2a;--bs-primary-rgb:240,124,42; + --bs-primary-text-emphasis:#f7ab6f;--bs-secondary-text-emphasis:#c8bcac;--bs-success-text-emphasis:#7fd3a5;--bs-info-text-emphasis:#87bcd6;--bs-warning-text-emphasis:#f2c17e;--bs-danger-text-emphasis:#e79087;--bs-light-text-emphasis:#f7f3ee;--bs-dark-text-emphasis:#e4d8c9; + --bs-primary-bg-subtle:#2a1a0d;--bs-secondary-bg-subtle:#211a13;--bs-success-bg-subtle:#0a2417;--bs-info-bg-subtle:#0a1e28;--bs-warning-bg-subtle:#2c1e0a;--bs-danger-bg-subtle:#2c0f0c;--bs-light-bg-subtle:#1c1712;--bs-dark-bg-subtle:#141310; + --bs-primary-border-subtle:#6c3c17;--bs-secondary-border-subtle:#4a4034;--bs-success-border-subtle:#155f39;--bs-info-border-subtle:#1c516b;--bs-warning-border-subtle:#7a5216;--bs-danger-border-subtle:#7c2820;--bs-light-border-subtle:#39322a;--bs-dark-border-subtle:#2d2419; + --bs-heading-color:inherit; + --bs-link-color:#f07c2a;--bs-link-hover-color:#f7ab6f;--bs-link-color-rgb:240,124,42;--bs-link-hover-color-rgb:247,171,111; + --bs-code-color:#f39b5f;--bs-highlight-color:#f1e9df;--bs-highlight-bg:#5c3a11; + --bs-border-color:#2d2419;--bs-border-color-translucent:rgba(255,255,255,.13); + --bs-focus-ring-color:rgba(240,124,42,.35); + --bs-form-valid-color:#7fd3a5;--bs-form-valid-border-color:#7fd3a5;--bs-form-invalid-color:#e79087;--bs-form-invalid-border-color:#e79087; +} +[data-bs-theme=dark]{ + --bs-navbar-color:rgba(255,255,255,.6);--bs-navbar-hover-color:#f07c2a;--bs-navbar-disabled-color:rgba(255,255,255,.25);--bs-navbar-active-color:#f07c2a;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#f07c2a;--bs-navbar-toggler-border-color:rgba(255,255,255,.1); + --bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.6%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); +} + +/* Bengal identity: big, bold, tight-tracked headings (loaded after Bootstrap, so these win). */ +h1,h2,.h1,.h2{font-weight:800;letter-spacing:-.025em;} +h3,h4,h5,h6,.h3,.h4,.h5,.h6{font-weight:700;letter-spacing:-.015em;} +.display-1,.display-2,.display-3,.display-4,.display-5,.display-6{font-weight:800;letter-spacing:-.035em;} diff --git a/themes/puma/views/scripts/_partials/skin-switcher.phtml b/themes/puma/views/scripts/_partials/skin-switcher.phtml index be169db..5287220 100644 --- a/themes/puma/views/scripts/_partials/skin-switcher.phtml +++ b/themes/puma/views/scripts/_partials/skin-switcher.phtml @@ -23,6 +23,7 @@ $catalog = array( 'jaguar' => array('Jaguar', 'Flatly', '#2c3e50'), 'tabby' => array('Tabby', 'United', '#e95420'), 'cheetah' => array('Cheetah', 'Custom', '#f59e0b'), + 'bengal' => array('Bengal', 'Custom', '#e8631a'), ); $meta = function ($s) use ($catalog) { return $catalog[$s] ?? array(ucfirst($s), '', '#6c757d'); From 8f4fda297bc26bb7c07ada8e82dfe485b741979a Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 5 Aug 2026 09:53:04 -0400 Subject: [PATCH 02/14] Fix: harden CMS builder saveDesign meta encode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GrapesJS project can carry lone surrogates / invalid UTF-8 (JS strings) that make a strict json_encode return false — an empty string that fails the meta column's json_valid CHECK (SQLSTATE 23000 / err 4025). Encode with JSON_INVALID_UTF8_SUBSTITUTE (then '{}' as a last resort) so saving a builder page never writes invalid JSON. Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/cms/services/Page.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/modules/cms/services/Page.php b/modules/cms/services/Page.php index ee74d2e..58bc22b 100644 --- a/modules/cms/services/Page.php +++ b/modules/cms/services/Page.php @@ -245,11 +245,20 @@ public function saveDesign(array $params): void $meta['builder'] = is_array($decodedProject) ? $decodedProject : null; } + // A GrapesJS project can carry lone surrogates / invalid UTF-8 (JS strings), which make a strict + // json_encode return FALSE — an empty string that fails the meta column's json_valid CHECK + // (SQLSTATE 23000 / err 4025). Encode defensively (substitute bad bytes) so we never write invalid JSON. + $metaJson = json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($metaJson === false) { + $metaJson = json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); + } + if ($metaJson === false) { $metaJson = '{}'; } + try { $model->save([ 'body' => $body, 'format' => Tiger_Model_Page::FORMAT_BUILDER, - 'meta' => json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + 'meta' => $metaJson, ], $pageId); $this->_success(['page_id' => $pageId], 'cms.page.saved'); } catch (Throwable $e) { From 34ba4a8c3f266c2a932515dbda7d0246967ed6eb Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 5 Aug 2026 10:04:02 -0400 Subject: [PATCH 03/14] Fix: strip builder chrome from the saved project (MariaDB JSON depth-32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real cause of the saveDesign 4025 error: the injected header/footer chrome preview deeply nests the GrapesJS project blob, and MariaDB's json_valid (the page.meta CHECK) rejects JSON nested >= 32 levels — PHP encodes it fine, MariaDB refuses it. (The earlier UTF-8 hardening was defensive, not the cause.) The chrome is now removed from ALL saved data (body/css/project) on save and re-injected for continued editing; suppressUpdate keeps that churn from marking the doc dirty. Co-Authored-By: Claude Opus 4.8 (1M context) --- themes/puma/assets/js/tiger.page-builder.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/themes/puma/assets/js/tiger.page-builder.js b/themes/puma/assets/js/tiger.page-builder.js index 7c2edd5..9106cd8 100644 --- a/themes/puma/assets/js/tiger.page-builder.js +++ b/themes/puma/assets/js/tiger.page-builder.js @@ -72,20 +72,33 @@ var saving = false; var dirty = false; + var suppressUpdate = false; // set while save() pulls/re-adds the chrome, so that churn isn't "dirty" function save() { if (saving) { return; } saving = true; setStatus('Saving…', 'saving'); + // Remove the header/footer preview from ALL saved data (body, css, AND the project blob) — it's + // view-only chrome, and its deep nesting blows past MariaDB's 32-level JSON limit (the meta + // json_valid CHECK). Capture with it gone, then re-inject for continued editing. suppressUpdate + // keeps this churn from marking the doc dirty / flickering the status. + suppressUpdate = true; + editor.getWrapper().find('[data-tiger-chrome]').forEach(function (c) { c.remove(); }); + var outHtml = editor.getHtml(); + var outCss = editor.getCss(); + var outProject = JSON.stringify(editor.getProjectData()); + tbInjectChrome(); + suppressUpdate = false; + var body = new URLSearchParams(); body.set('module', 'cms'); body.set('service', 'page'); body.set('method', 'saveDesign'); body.set('page_id', cfg.pageId || ''); - body.set('html', tbStripChrome(editor.getHtml())); // never persist the header/footer preview into the page body - body.set('css', editor.getCss()); - body.set('project', JSON.stringify(editor.getProjectData())); + body.set('html', tbStripChrome(outHtml)); // belt-and-suspenders (chrome already removed above) + body.set('css', outCss); + body.set('project', outProject); fetch(cfg.api || '/api', { method: 'POST', @@ -174,7 +187,7 @@ // Track unsaved edits; warn before closing. GrapesJS fires 'update' after its initial // seed, so arm the flag only once the editor has settled. editor.on('load', function () { - setTimeout(function () { editor.on('update', function () { dirty = true; setStatus('Unsaved changes', ''); }); }, 400); + setTimeout(function () { editor.on('update', function () { if (suppressUpdate) { return; } dirty = true; setStatus('Unsaved changes', ''); }); }, 400); }); window.addEventListener('beforeunload', function (e) { if (dirty) { e.preventDefault(); e.returnValue = ''; } From 7479e9600d8d04306295cc7e216ae80d5da19d81 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 5 Aug 2026 10:52:06 -0400 Subject: [PATCH 04/14] =?UTF-8?q?Convert=20JSON=20columns=20to=20LONGTEXT?= =?UTF-8?q?=20=E2=80=94=20DB=20stores=20text,=20app=20owns=20JSON=20validi?= =?UTF-8?q?ty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0041: page.meta, page_version.meta, media.variants, media.scan_meta JSON -> LONGTEXT, plus a sweep that drops every json_valid CHECK in the schema. MariaDB's JSON type carries an implicit CHECK(json_valid) that rejects JSON nested >= 32 levels (PHP encodes it fine) — it broke the CMS builder storing its deep GrapesJS project in page.meta. LONGTEXT holds any depth; the app already json_encode/decodes these values. One-way (no down to JSON). AGENTS.md documents the convention: new tables use LONGTEXT, never the JSON type. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +++ migrations/0041_json_to_longtext.php | 42 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 migrations/0041_json_to_longtext.php diff --git a/AGENTS.md b/AGENTS.md index 6355a4c..d73207d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,6 +245,10 @@ geocode / reverse / IP behind adapters, normalized `Place` payload) and **`Tiger string-concatenated SQL in models or services. - Every domain table gets the **standard columns**: `status`, `deleted`, `created_by`, `updated_by`, `created_at`, `updated_at`. UUIDs are `CHAR(36)`; unique text is `VARCHAR(191)`. +- **JSON-shaped data → `LONGTEXT`, never the `JSON` type.** The DB stores text; validating JSON is the + app's job (you already `json_encode`/`json_decode` it). MariaDB's `JSON` is `LONGTEXT` + an implicit + `CHECK(json_valid())` that **rejects JSON nested ≥ 32 levels** (which PHP encodes fine) — it broke the + CMS builder's deep project blob (migration `0041` converted the existing columns). - Migrations are **additive-only** PHP files (`NNNN_name.php` returning `['up'=>[], 'down'=>[]]`) in `migrations/`. One logical DDL change per migration (MySQL auto-commits DDL). diff --git a/migrations/0041_json_to_longtext.php b/migrations/0041_json_to_longtext.php new file mode 100644 index 0000000..3aed06c --- /dev/null +++ b/migrations/0041_json_to_longtext.php @@ -0,0 +1,42 @@ + [ + 'ALTER TABLE `page` MODIFY `meta` LONGTEXT DEFAULT NULL', + 'ALTER TABLE `page_version` MODIFY `meta` LONGTEXT DEFAULT NULL', + 'ALTER TABLE `media` MODIFY `variants` LONGTEXT DEFAULT NULL', + 'ALTER TABLE `media` MODIFY `scan_meta` LONGTEXT DEFAULT NULL', + + // Drop EVERY json_valid CHECK still present in this schema (core leftovers + any module table). + function ($db) { + $rows = $db->fetchAll( + "SELECT TABLE_NAME AS t, CONSTRAINT_NAME AS c + FROM information_schema.CHECK_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND LOWER(CHECK_CLAUSE) LIKE '%json_valid%'" + ); + foreach ($rows as $r) { + $db->query('ALTER TABLE `' . $r['t'] . '` DROP CONSTRAINT `' . $r['c'] . '`'); + } + }, + ], + 'down' => [ + // Intentionally empty — never re-validate JSON in the DB. + ], +]; From ea8676f5828e547dc1b0bad531cccdd60df5cd85 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 5 Aug 2026 11:07:06 -0400 Subject: [PATCH 05/14] Fix: no dark-mode FOUC in the CMS builder canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GrapesJS canvas iframe painted light (data-bs-theme default) before the theme was applied on load, flashing to dark. Init tbMode from the builder's own data-bs-theme (not 'light') so the first frame:load applies the right mode, and hide the .gjs-frame until JS marks it ready (theme applied + chrome placed, via rAF), then fade it in — with a 2.5s safety reveal so it can't stay blank. Co-Authored-By: Claude Opus 4.8 (1M context) --- themes/puma/assets/builder.css | 6 ++++++ themes/puma/assets/js/tiger.page-builder.js | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/themes/puma/assets/builder.css b/themes/puma/assets/builder.css index 32be293..d539900 100644 --- a/themes/puma/assets/builder.css +++ b/themes/puma/assets/builder.css @@ -55,3 +55,9 @@ body { margin: 0; display: flex; flex-direction: column; overflow: hidden; } .gjs-block__media svg { width: 30px !important; height: 30px !important; } .gjs-block__media i { font-size: 28px; line-height: 1; } .gjs-block-label { font-size: 0.85rem; } + +/* --- No-FOUC: the canvas iframe paints LIGHT before its data-bs-theme is applied on load, flashing to + dark. Hide the frame until JS marks it ready (theme applied + chrome placed), then fade it in. A JS + safety timeout also adds .tb-canvas-ready so the frame can never stay hidden. --- */ +.gjs-frame { opacity: 0; } +body.tb-canvas-ready .gjs-frame { opacity: 1; transition: opacity .15s ease; } diff --git a/themes/puma/assets/js/tiger.page-builder.js b/themes/puma/assets/js/tiger.page-builder.js index 9106cd8..bf50420 100644 --- a/themes/puma/assets/js/tiger.page-builder.js +++ b/themes/puma/assets/js/tiger.page-builder.js @@ -120,7 +120,9 @@ // Canvas light/dark — mirror the site's data-bs-theme INSIDE the canvas iframe (Bootstrap reads it // on the iframe's ; our skins define both modes), toggleable from the top-bar sun/moon. - var tbMode = 'light'; + // Init to the builder's OWN theme (theme-init.js set it from the cookie), so the first frame:load + // applies the right mode immediately — no light default to flash from. + var tbMode = document.documentElement.getAttribute('data-bs-theme') || 'light'; // GrapesJS hardcodes a white "paper" bg on the canvas body, which ignores --bs-body-bg — so the body // stayed white while token-driven sections went dark. Tie html/body to the Bootstrap body token so // it follows the theme (background only forced; text-* utilities keep their own colors). @@ -149,6 +151,9 @@ editor.on('canvas:frame:load', function () { tbApplyCanvasBg(); tbCanvasTheme(tbMode); }); var themeBtn = document.getElementById('tb-theme'); if (themeBtn) { themeBtn.addEventListener('click', function () { tbCanvasTheme(tbMode === 'dark' ? 'light' : 'dark'); }); } + // Safety: the canvas frame is hidden by builder.css until .tb-canvas-ready — always reveal it, even if + // 'load' never fires, so it can't stay blank. + setTimeout(function () { document.body.classList.add('tb-canvas-ready'); }, 2500); // ---- Non-editable theme chrome: render the real header + footer in the canvas for context // (Elementor-style), LOCKED so they can't be edited/moved/deleted, and STRIPPED from the saved @@ -177,7 +182,12 @@ return d.body.innerHTML; } catch (e) { return html; } } - editor.on('load', function () { tbInjectChrome(); }); + editor.on('load', function () { + tbInjectChrome(); + // Theme is applied + chrome placed — reveal the canvas (hidden until now to kill the light→dark + // FOUC; see builder.css). rAF lets the themed repaint land before the fade-in. + requestAnimationFrame(function () { document.body.classList.add('tb-canvas-ready'); }); + }); // Ctrl/Cmd+S saves without leaving the builder. document.addEventListener('keydown', function (e) { From 5c4e2b481b90dc75d9720da28499ebb02aafe96c Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 5 Aug 2026 11:45:33 -0400 Subject: [PATCH 06/14] =?UTF-8?q?Add=20[content]=20shortcode=20=E2=80=94?= =?UTF-8?q?=20layout-as-partial=20composition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A layout is now 'just a partial': a visually-built layout body places the page inside itself via [content], and composes header/footer from [partial]. The renderer already passes the page body to a layout as $context['content']; the phtml path used , this gives html/builder layouts the same slot. Empty (harmless) outside a layout render. Completes the composition primitive. Co-Authored-By: Claude Opus 4.8 (1M context) --- library/Tiger/Application/Bootstrap.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/library/Tiger/Application/Bootstrap.php b/library/Tiger/Application/Bootstrap.php index e115e86..e10a741 100644 --- a/library/Tiger/Application/Bootstrap.php +++ b/library/Tiger/Application/Bootstrap.php @@ -238,6 +238,14 @@ protected function _initCms() return ''; } }); + + // [content] -> the page body, when a LAYOUT wraps a page (Tiger_Cms_Renderer::render passes it as + // $context['content']). This is the one reserved slot that lets a VISUALLY-BUILT layout be "just a + // partial": [partial name="site-header"] [content] [partial name="site-footer"]. Empty (harmless) + // when rendered outside a layout, so it never leaks a stray body elsewhere. + Tiger_Cms_Renderer::registerShortcode('content', static function ($attrs, $inner = null, array $context = []) { + return isset($context['content']) ? (string) $context['content'] : ''; + }); } /** From fd2553a86173f4ff53f0c1aa4250565f063862a8 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Thu, 6 Aug 2026 05:04:14 -0400 Subject: [PATCH 07/14] CMS: in-context partial editor + Blocks (copy-in fragments) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partial editor — a partial now edits INSIDE its layout's chrome (the page builder inverted: layout = locked context, partial = the one editable region). Reuses the whole locked-chrome / canvas-CSS / light-dark stack. - Association via the reused layout_key column (no migration) + a [Layout ▾] picker that persists the choice; default = the theme header/footer. - Auto slot-detection: a layout that NAMES the partial ([partial name=key]) edits it in its own slot (header/footer); any other partial edits in the [content] area (hero/CTA). _splitLayoutForPartial cuts the rendered layout on a marker into locked chrome-before / chrome-after. - Save writes back only the partial body (chrome stripped). Blocks — the copy-in twin of the reference-placed Partial widget. - New type=block fragment primitive (model/form/list/datatable filter). - 'My Blocks' builder palette: dropping a Block inlines its editable HTML into the page (a detached copy), reusing the theme-components insert path. - Save as Block: persist the selected element as a reusable Block + live-add it to the palette (Cms_Service_Page::saveBlock, create-from-selection). - Block masters are editable in the builder (fragment chrome, 'Block' badge). Partials and Blocks are reachable from the content list (Design button now covers page/partial/block; Type filter gains Block). Co-Authored-By: Claude Opus 4.8 (1M context) --- library/Tiger/Model/Page.php | 5 + modules/cms/controllers/PageController.php | 149 +++++++++++++++++++- modules/cms/forms/Page.php | 2 +- modules/cms/languages/en/cms.php | 4 + modules/cms/services/Page.php | 79 ++++++++++- modules/cms/views/scripts/page/design.phtml | 39 ++++- modules/cms/views/scripts/page/index.phtml | 7 +- themes/puma/assets/js/tiger.page-builder.js | 82 ++++++++++- 8 files changed, 341 insertions(+), 26 deletions(-) diff --git a/library/Tiger/Model/Page.php b/library/Tiger/Model/Page.php index ed472da..9150969 100644 --- a/library/Tiger/Model/Page.php +++ b/library/Tiger/Model/Page.php @@ -22,6 +22,11 @@ class Tiger_Model_Page extends Tiger_Model_Table const TYPE_PAGE = 'page'; const TYPE_LAYOUT = 'layout'; const TYPE_PARTIAL = 'partial'; + // A "block" is a reusable fragment placed by COPY: dropping it in the builder inlines its editable + // HTML into the page (detached from the source), vs a PARTIAL which is placed by REFERENCE + // ([partial name] — a live, immutable placeholder). A block is a builder library source only; it is + // never resolved at render time (there is no [block name] shortcode). See cms/AGENTS.md + THEMES.md. + const TYPE_BLOCK = 'block'; const STATUS_DRAFT = 'draft'; const STATUS_PUBLISHED = 'published'; diff --git a/modules/cms/controllers/PageController.php b/modules/cms/controllers/PageController.php index 10a4206..f664d69 100644 --- a/modules/cms/controllers/PageController.php +++ b/modules/cms/controllers/PageController.php @@ -134,14 +134,149 @@ public function designAction() // the GrapesJS canvas so those blocks preview in the theme's own style (THEMES.md Tier 2). $manifest = Tiger_Theme::manifest(); + // User-authored BLOCKS — reusable fragments placed by COPY. Passed to the builder's "My Blocks" + // palette; dropping one inlines its editable HTML into the page (detached from the source), the + // twin of the reference-placed Partial widget. Body is inserted raw so the author edits real + // markup (GrapesJS absorbs any #is', '#]*>.*?#is']; + $before = trim((string) preg_replace($strip, '', $parts[0] ?? '')); + $after = trim((string) preg_replace($strip, '', $parts[1] ?? '')); + return [$before, $after]; } /** Map a page row to editor form values. */ diff --git a/modules/cms/forms/Page.php b/modules/cms/forms/Page.php index 1fff2f9..ceb14aa 100644 --- a/modules/cms/forms/Page.php +++ b/modules/cms/forms/Page.php @@ -42,7 +42,7 @@ protected function elements(): array ]], ['select', 'type', [ - 'multiOptions' => ['page' => 'Page', 'layout' => 'Layout', 'partial' => 'Partial'], + 'multiOptions' => ['page' => 'Page', 'layout' => 'Layout', 'partial' => 'Partial', 'block' => 'Block'], 'value' => 'page', 'attribs' => array_merge($select, ['id' => 'cms-type']), ]], diff --git a/modules/cms/languages/en/cms.php b/modules/cms/languages/en/cms.php index b24037b..0e87a70 100644 --- a/modules/cms/languages/en/cms.php +++ b/modules/cms/languages/en/cms.php @@ -13,6 +13,10 @@ 'cms.page.forked' => 'Template customized — now editing your copy.', 'cms.page.exists' => 'That template is already customized — opening it.', + 'cms.block.saved' => 'Block saved.', + 'cms.block.name_required' => 'Give the block a name first.', + 'cms.block.empty' => 'There is nothing to save as a block.', + 'cms.settings.saved' => 'Settings saved.', 'cms.menu.item_saved' => 'Menu item saved.', diff --git a/modules/cms/services/Page.php b/modules/cms/services/Page.php index 58bc22b..9164955 100644 --- a/modules/cms/services/Page.php +++ b/modules/cms/services/Page.php @@ -39,7 +39,7 @@ public function datatable(array $params): void $data = (new Tiger_Model_Page())->datatable([ 'search' => $dt['search'], 'status' => in_array(($params['status'] ?? ''), [Tiger_Model_Page::STATUS_DRAFT, Tiger_Model_Page::STATUS_PUBLISHED, Tiger_Model_Page::STATUS_ARCHIVED], true) ? (string) $params['status'] : '', - 'type' => in_array(($params['type'] ?? ''), [Tiger_Model_Page::TYPE_PAGE, Tiger_Model_Page::TYPE_LAYOUT, Tiger_Model_Page::TYPE_PARTIAL], true) ? (string) $params['type'] : '', + 'type' => in_array(($params['type'] ?? ''), [Tiger_Model_Page::TYPE_PAGE, Tiger_Model_Page::TYPE_LAYOUT, Tiger_Model_Page::TYPE_PARTIAL, Tiger_Model_Page::TYPE_BLOCK], true) ? (string) $params['type'] : '', 'orderCol' => isset($dt['order'][0]) ? $dt['order'][0]['column'] : -1, 'orderDir' => isset($dt['order'][0]) ? $dt['order'][0]['dir'] : '', 'offset' => $dt['start'], @@ -254,18 +254,85 @@ public function saveDesign(array $params): void } if ($metaJson === false) { $metaJson = '{}'; } + $save = [ + 'body' => $body, + 'format' => Tiger_Model_Page::FORMAT_BUILDER, + 'meta' => $metaJson, + ]; + // Partial editing: the builder's [Layout ▾] picker persists which layout this partial previews + // against (its layout_key). Only touched when the param is present, so a page save never clears it. + if (array_key_exists('layout_key', $params)) { + $lk = trim((string) $params['layout_key']); + $save['layout_key'] = ($lk !== '') ? $lk : null; + } + try { - $model->save([ - 'body' => $body, - 'format' => Tiger_Model_Page::FORMAT_BUILDER, - 'meta' => $metaJson, - ], $pageId); + $model->save($save, $pageId); $this->_success(['page_id' => $pageId], 'cms.page.saved'); } catch (Throwable $e) { $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); } } + /** + * Save a builder selection as a reusable BLOCK (a copy-in fragment). The visual builder posts the + * selected component's HTML (+ its scoped CSS) and a name; we store a type=block page row that the + * builder then offers in its "My Blocks" palette. Unlike a partial, a block is placed by COPY — + * dropping it inlines this HTML into a page, detached — so it is a library source only, never + * resolved at render time. Returns the new block for a live palette add. + * + * @param array $params `name`, `html`, and optional `css` + * @return void + */ + public function saveBlock(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + + $name = trim((string) ($params['name'] ?? '')); + if ($name === '') { $this->_error('cms.block.name_required'); return; } + + // Strip #is', '', $html); + $html = preg_replace('#]*/?>#i', '', (string) $html); + if (trim((string) $html) === '') { $this->_error('cms.block.empty'); return; } + $css = trim((string) ($params['css'] ?? '')); + $body = ($css !== '' ? "\n" : '') . $html; + + // A stable, collision-proof handle (blocks aren't referenced by key, but page_key is UNIQUE-ish + // per the store's conventions, so uniquify against existing block/partial keys). + $pm = new Tiger_Model_Page(); + $key = $this->_uniqueKey($pm, $this->_slugify($name) ?: 'block'); + + try { + $id = $pm->save([ + 'type' => Tiger_Model_Page::TYPE_BLOCK, + 'page_key' => $key, + 'slug' => null, + 'locale' => 'en', + 'title' => $name, + 'body' => $body, + 'format' => Tiger_Model_Page::FORMAT_BUILDER, + 'status' => Tiger_Model_Page::STATUS_PUBLISHED, + ], null); + $this->_success(['page_id' => $id, 'page_key' => $key, 'label' => $name, 'html' => $body], 'cms.block.saved'); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); + } + } + + /** A page_key not already taken by an active row — appends -2, -3, … on collision. */ + protected function _uniqueKey(Tiger_Model_Page $pm, string $base): string + { + $key = $base; + for ($i = 2; $i <= 50; $i++) { + $hit = $pm->fetchRow($pm->activeSelect()->where('page_key = ?', $key)->limit(1)); + if (!$hit) { return $key; } + $key = $base . '-' . $i; + } + return $base . '-' . substr(bin2hex(random_bytes(3)), 0, 5); + } + /** * Soft-delete a page (recoverable — the row is flagged, not dropped). * diff --git a/modules/cms/views/scripts/page/design.phtml b/modules/cms/views/scripts/page/design.phtml index cc2e7ba..97ec7b4 100644 --- a/modules/cms/views/scripts/page/design.phtml +++ b/modules/cms/views/scripts/page/design.phtml @@ -24,15 +24,23 @@ $canvasStyles = [ if (!empty($this->skin)) { $canvasStyles[] = $this->asset($va . '/skins/' . $this->skin . '.css'); } $canvasStyles[] = $this->asset($va . '/custom.css'); -// Render the real public header + footer for a NON-EDITABLE preview inside the builder canvas -// (Elementor-style theme chrome). #is', '#]*>.*?#is']; $headerHtml = ''; $footerHtml = ''; -$tbStrip = ['#]*>.*?#is', '#]*>.*?#is']; -try { $headerHtml = trim((string) preg_replace($tbStrip, '', (string) $this->render('_partials/public-header.phtml'))); } catch (\Throwable $e) {} -try { $footerHtml = trim((string) preg_replace($tbStrip, '', (string) $this->render('_partials/public-footer.phtml'))); } catch (\Throwable $e) {} +if ($this->chromeBefore !== null || $this->chromeAfter !== null) { + $headerHtml = (string) $this->chromeBefore; // already style/script-stripped by the controller + $footerHtml = (string) $this->chromeAfter; +} else { + try { $headerHtml = trim((string) preg_replace($tbStrip, '', (string) $this->render('_partials/public-header.phtml'))); } catch (\Throwable $e) {} + try { $footerHtml = trim((string) preg_replace($tbStrip, '', (string) $this->render('_partials/public-footer.phtml'))); } catch (\Throwable $e) {} +} ?> @@ -56,10 +64,25 @@ try { $footerHtml = trim((string) preg_replace($tbStrip, '', (string) $this->ren
Tiger Builder +partialMode)): ?> + escape($this->fragmentLabel ?: 'Partial') ?> + escape($this->title ?: 'Untitled') ?>
+partialMode) && !empty($this->layoutOptions)): ?> + + + Settings Exit @@ -77,9 +100,11 @@ try { $footerHtml = trim((string) preg_replace($tbStrip, '', (string) $this->ren seedHtml: page->body, $jf) ?>, menus: menus ?: (object) [], $jf) ?>, partials: partials ?: (object) [], $jf) ?>, + userBlocks: userBlocks ?: [], $jf) ?>, themeBlocks: themeBlocks ?: [], $jf) ?>, headerHtml: , footerHtml: , + partialMode: partialMode) ? 'true' : 'false' ?>, canvasStyles: , canvasCss: canvasCss ?: [], $jf) ?> }; diff --git a/modules/cms/views/scripts/page/index.phtml b/modules/cms/views/scripts/page/index.phtml index d6ee340..8631512 100644 --- a/modules/cms/views/scripts/page/index.phtml +++ b/modules/cms/views/scripts/page/index.phtml @@ -53,6 +53,7 @@ +
@@ -115,7 +116,7 @@ document.addEventListener('DOMContentLoaded', function () { if (typeof tigerDataTable !== 'function') { return; } var $ = jQuery; var esc = tigerDataTable.esc; - var TYPE_ICON = { page: 'fa-file-lines', layout: 'fa-table-columns', partial: 'fa-puzzle-piece' }; + var TYPE_ICON = { page: 'fa-file-lines', layout: 'fa-table-columns', partial: 'fa-puzzle-piece', block: 'fa-cube' }; var BADGE = { published: 'success', draft: 'secondary', archived: 'warning' }; function cap(s) { s = String(s || ''); return s.charAt(0).toUpperCase() + s.slice(1); } @@ -156,7 +157,9 @@ document.addEventListener('DOMContentLoaded', function () { var html = ''; if (row.can_edit) { html += ' '; - if (row.type === 'page') { + // Pages, partials, AND blocks open in the visual builder (a fragment edits inside its + // layout's chrome — see Cms_PageController::designAction); layouts stay text-edited. + if (row.type === 'page' || row.type === 'partial' || row.type === 'block') { html += ' '; } } diff --git a/themes/puma/assets/js/tiger.page-builder.js b/themes/puma/assets/js/tiger.page-builder.js index bf50420..1ccc472 100644 --- a/themes/puma/assets/js/tiger.page-builder.js +++ b/themes/puma/assets/js/tiger.page-builder.js @@ -53,6 +53,7 @@ registerBootstrapBlocks(editor); registerVideoPicker(editor); registerThemeBlocks(editor); + registerUserBlocks(editor); // Seed the canvas: prefer the lossless project blob, else import the body HTML. try { @@ -73,8 +74,9 @@ var saving = false; var dirty = false; var suppressUpdate = false; // set while save() pulls/re-adds the chrome, so that churn isn't "dirty" + var pendingLayoutKey = null; // partial mode: a [Layout ▾] change to persist with the next save - function save() { + function save(done) { if (saving) { return; } saving = true; setStatus('Saving…', 'saving'); @@ -99,6 +101,8 @@ body.set('html', tbStripChrome(outHtml)); // belt-and-suspenders (chrome already removed above) body.set('css', outCss); body.set('project', outProject); + // Partial mode: persist a changed preview-layout association alongside the design. + if (pendingLayoutKey !== null) { body.set('layout_key', pendingLayoutKey); } fetch(cfg.api || '/api', { method: 'POST', @@ -108,7 +112,7 @@ }) .then(function (r) { return r.json(); }) .then(function (res) { - if (res && res.result) { dirty = false; setStatus('Saved ✓', 'ok'); } + if (res && res.result) { dirty = false; setStatus('Saved ✓', 'ok'); if (typeof done === 'function') { done(); } } else { setStatus('Save failed', 'err'); } }) .catch(function () { setStatus('Save failed', 'err'); }) @@ -116,7 +120,19 @@ } var saveBtn = document.getElementById('tb-save'); - if (saveBtn) { saveBtn.addEventListener('click', save); } + if (saveBtn) { saveBtn.addEventListener('click', function () { save(); }); } + + // Partial mode: the [Layout ▾] picker re-previews this partial inside a different layout's chrome. + // The server assembles the locked chrome, so we save the current design (persisting the new + // association) and reload to pick up the freshly-split chrome around the partial. + var layoutSel = document.getElementById('tb-layout'); + if (layoutSel) { + layoutSel.addEventListener('change', function () { + pendingLayoutKey = layoutSel.value; + setStatus('Switching layout…', 'saving'); + save(function () { window.location.reload(); }); + }); + } // Canvas light/dark — mirror the site's data-bs-theme INSIDE the canvas iframe (Bootstrap reads it // on the iframe's ; our skins define both modes), toggleable from the top-bar sun/moon. @@ -357,6 +373,66 @@ }); } + // ---- user-authored BLOCKS: reusable fragments placed by COPY (window.TIGER_BUILDER.userBlocks) ---- + // Each drops its saved HTML into the canvas as EDITABLE components, detached from the source — the twin + // of the reference-placed Partial widget (which drops a live [partial name] placeholder). Grouped under + // "My Blocks"; authored via the "Save as Block" button (addUserBlock adds one to the palette live). + function registerUserBlocks(editor) { + var blocks = (window.TIGER_BUILDER && window.TIGER_BUILDER.userBlocks) || []; + blocks.forEach(function (b) { addUserBlock({ page_id: b.id, label: b.label, html: b.content }); }); + } + function addUserBlock(b) { + if (!b || !b.page_id) { return; } + try { + editor.BlockManager.add('userblock-' + b.page_id, { + label: b.label || 'Block', + category: 'My Blocks', + media: '', + content: b.html || '' + }); + } catch (e) {} + } + + // "Save as Block": persist the SELECTED element as a reusable Block (a copy-in fragment) and add it to + // the "My Blocks" palette immediately. Copy semantics — the page keeps its element; the Block is a new, + // independent source. Chrome is stripped so locked header/footer can never be captured into a block. + var saveBlockBtn = document.getElementById('tb-save-block'); + if (saveBlockBtn) { + saveBlockBtn.addEventListener('click', function () { + var sel = editor.getSelected(); + if (!sel) { setStatus('Select an element first', 'err'); return; } + var name = window.prompt('Name this block:', ''); + if (name === null) { return; } + name = String(name).trim(); + if (!name) { setStatus('A name is required', 'err'); return; } + + var html = '', css = ''; + try { html = sel.toHTML(); } catch (e) {} + try { css = editor.getCss({ component: sel }) || ''; } catch (e) {} + html = tbStripChrome(html); + if (!html || !html.trim()) { setStatus('Nothing to save', 'err'); return; } + + var body = new URLSearchParams(); + body.set('module', 'cms'); body.set('service', 'page'); body.set('method', 'saveBlock'); + body.set('name', name); body.set('html', html); body.set('css', css); + + saveBlockBtn.disabled = true; + setStatus('Saving block…', 'saving'); + fetch(cfg.api || '/api', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' }, + body: body.toString(), credentials: 'same-origin' + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res && res.result && res.data) { addUserBlock(res.data); setStatus('Block saved ✓ — see “My Blocks”', 'ok'); } + else { setStatus('Block save failed', 'err'); } + }) + .catch(function () { setStatus('Block save failed', 'err'); }) + .finally(function () { saveBlockBtn.disabled = false; }); + }); + } + // ---- theme-provided blocks: the ACTIVE theme's components/*.phtml (Tiger_Theme::components) ---- // Passed on window.TIGER_BUILDER.themeBlocks by the CMS designAction; each drops the vendor's own // markup, and the canvas CSS (cfg.canvasCss) makes it preview in the theme's style. From 0e6a95f940400a07e9f5a5e3e8a447885ee378a0 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Thu, 6 Aug 2026 13:40:13 -0400 Subject: [PATCH 08/14] Fix: an inactive module's acl.ini must not load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activation gate strips a deactivated module from the dispatch map (so its controllers/services are unreachable), and the nav/usermenu/header/fields/ schedule registries all skip inactive modules — but Tiger_Acl_Acl::_aclIniPaths globbed EVERY module dir, so an inactive module's acl.ini still registered its roles/resources/rules into the ACL graph. Gate it on Tiger_Model_Module:: inactiveSlugs(), mirroring Tiger_Admin_Nav; fail-safe (DB not ready -> load all). Real leak: the inactive 'apidocs' module ships an acl.ini that was loading. Co-Authored-By: Claude Opus 4.8 (1M context) --- library/Tiger/Acl/Acl.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/library/Tiger/Acl/Acl.php b/library/Tiger/Acl/Acl.php index 3e94146..5d814a4 100644 --- a/library/Tiger/Acl/Acl.php +++ b/library/Tiger/Acl/Acl.php @@ -174,8 +174,24 @@ protected function _aclIniPaths() if (is_file($core)) { $paths[] = $core; } + + // A DEACTIVATED module contributes NO ACL rules: its controllers/services are stripped from the + // dispatch map (Tiger_Application_Resource_Modules), so loading its acl.ini would only register + // roles/resources/rules for unreachable code. Gate it here, mirroring Tiger_Admin_Nav. Fail-safe: + // DB not ready (install/CLI) -> empty inactive set -> load all, never fewer rules than stock. + $inactive = []; + try { + if (class_exists('Tiger_Model_Module')) { + $inactive = (new Tiger_Model_Module())->inactiveSlugs(); + } + } catch (Throwable $e) { + } + foreach ([APPLICATION_PATH . '/modules', TIGER_CORE_PATH . '/modules'] as $modsDir) { foreach (glob($modsDir . '/*', GLOB_ONLYDIR) ?: [] as $moduleDir) { + if (in_array(basename($moduleDir), $inactive, true)) { + continue; // deactivated module: no ACL rules + } $ini = $moduleDir . '/configs/acl.ini'; if (is_file($ini)) { $paths[] = $ini; From a24e406ccc1809c1671f085ca60c3d47341ad722 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Thu, 6 Aug 2026 13:40:25 -0400 Subject: [PATCH 09/14] CMS: fork theme layouts/partials + Theme-Templates DataTable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AUTHORING.md — the page-building workflow of record: every start is a page-store row, seeded from blank / a core BS5 starter / a theme; human + agent share the /api door; the shell-vs-body-skeleton seam. Theme fork generalized beyond pages — Tiger_Theme gains layouts()/partials()/ template($kind,$slug)/forkables() (fingerprint-cached) so a theme's tiger:layout and tiger:partial files fork into editable type=layout/partial rows; Cms_Service_Page::forkTheme takes a kind + bakes the origin theme's stylesheet links into a forked page's head (self-loads its CSS via the theme symlink). Only the ACTIVE theme surfaces (Tiger_Theme::active) — an installed-but-inactive theme has no asset symlink, so its content can't render and must not be forkable; forkTheme refuses a non-active theme. The 'Theme Templates' tab is now a server-side DataTable (Cms_Service_Page:: themeTemplates) with a Theme column, since a theme may ship hundreds (Porto ~830); 'customized' is matched by origin (theme|kind|slug) in the fork's meta. Co-Authored-By: Claude Opus 4.8 (1M context) --- AUTHORING.md | 164 +++++++++++++++ library/Tiger/Theme.php | 225 +++++++++++++++++++-- modules/cms/controllers/PageController.php | 24 +-- modules/cms/services/Page.php | 178 +++++++++++++--- modules/cms/views/scripts/page/index.phtml | 138 +++++++------ 5 files changed, 611 insertions(+), 118 deletions(-) create mode 100644 AUTHORING.md diff --git a/AUTHORING.md b/AUTHORING.md new file mode 100644 index 0000000..457f37e --- /dev/null +++ b/AUTHORING.md @@ -0,0 +1,164 @@ +# Tiger — Authoring: how a page gets built (human & agent) + +How someone — a person clicking around, or an AI agent calling `/api` — builds a page in Tiger, from +nothing or from a starting point, and where every piece is stored. Read this before touching the CMS +authoring surface, the theme-fork flow, or the starter-content library. For the theme-material +architecture read [THEMES.md](THEMES.md); for the composition primitives read the CMS section of +[FEATURES.md](FEATURES.md); for the `/api` contract read [WEBSERVICES.md](WEBSERVICES.md). + +> **Status: design-of-record (partly built).** Built today: the `page` store (page/layout/partial/block), +> the visual builder, the [partial]/[content] composition primitives, the in-context partial editor, +> Blocks (copy-in fragments), and `forkTheme` for theme **pages**. Proposed here: the same fork for theme +> **layouts/partials/blocks** (§4/§6 #1), a core Bootstrap-5 starter library (§3.2, #2), a blank base +> (§1, #3), and the agent authoring skill (§5, #4). Where a section says "proposed," it isn't built yet. + +--- + +## 0. The one principle + +**Whatever you start from, the artifact is always a row in the page store** — a `page`, `layout`, +`partial`, or `block`. The three ways to begin a page differ *only in how that first row is seeded*. + +The load-bearing consequence: **a human clicking "New" and an AI agent calling `/api` produce the +identical artifact through the identical door** (`Cms_Service_Page::save`). The agent is not a second +code path — it writes the same rows the UI writes, validated by the same form, gated by the same ACL. +That is what makes authoring both **UI-agnostic** (any front-end renders the rows) and **agent-native** +(the agent has nothing bespoke to learn beyond the row shape). You never build the workflow twice. + +So the real question is never "how does this scenario work?" — it's **"where does the starter row come +from?"** There are exactly three sources: **blank**, a **core Bootstrap-5 starter**, or a **theme**. + +--- + +## 1. The two layers (the UI-agnostic seam) + +Getting this boundary right is what keeps a theme swap from breaking pages, and lets a layout "contain +nothing in the head, or whatever the author desires": + +| Layer | Owns | Lives as | Swappable? | +|---|---|---|---| +| **The shell** | `…` + which CSS/JS load | a **theme file** (`layouts/scripts/*.phtml`) | yes — this is the theme axis (PUMA loads Bootstrap; a React theme loads a bundle) | +| **The body skeleton** | the page's structure — `[partial]` slots + the `[content]` slot | a **CMS `layout` row** (`type=layout`) | it's data — edit it, no deploy | + +- **Core emits no shell.** The shell is a theme concern; Core emits *data* (rows) + semantic default + views (ARCHITECTURE §9). A `type=layout` row **never** owns ``/`` — it's the *body* + skeleton, rendered inside the active theme's shell. +- **Per-page head control already exists.** The page's `head_html` / `body_scripts` fields fill the + shell's `pageHead` / `pageScripts` slots (THEMES §8a). "Nothing in the head" = leave them empty; + "whatever you desire" = fill them. No new mechanism. +- **From-nothing wants the leanest shell.** For a true blank start we ship a **blank / Bootstrap-only + base** — a shell that loads only Bootstrap and exposes the head/content slots, imposing no opinion + (proposed, #3). + +--- + +## 2. The store — four primitives + the composition seam + +One table (`page`), discriminated by `type`. Everything an author starts from or drops in is one of +these rows: + +| `type` | Is | Placed / reached by | Editable how | +|---|---|---|---| +| **page** | routed content (a URL) | its `slug`; wraps in its `layout_key` layout | visual builder or text | +| **layout** | a body skeleton (chrome composition) | a page's `layout_key`; body = `[partial]…[content]…[partial]` | text/shortcode (visual later) | +| **partial** | a synced **reference** fragment (header, footer, CTA) | `[partial name="x"]` — an *immutable placeholder* on the page; edited only in its own editor; every placement updates | in-context partial editor | +| **block** | a **copy-in** fragment (hero, pricing section) | dropped from the builder's *My Blocks* palette → its HTML is *inlined into the page*, detached | visual builder (its master) | + +The **two composition primitives** are shortcodes the renderer expands (`Tiger_Cms_Renderer`): + +- **`[content]`** — the page-body slot inside a layout (a layout is "a partial with a content hole"). +- **`[partial name="x"]`** — transclude a partial by reference (recursive, cycle/depth-guarded). + +Partial vs Block is the **synced-reference vs detached-copy** distinction — see the CMS-partials note + +THEMES.md. A block is a *builder library source only*; it is never resolved at render time (there is no +`[block name]` shortcode). + +--- + +## 3. Three ways to seed the first row + +### 3.1 From nothing + +New → **Layout** (blank): body = `[content]` plus any `[partial]` placeholders you want; New → **Page** +bound to it (`layout_key`). Both are rows in the store; the page's head lives in its `meta` +(`head_html`/`body_scripts`). Reach the leanest possible shell with the blank base (#3). The agent does +the identical thing over `/api` — write a `layout` row, then a `page` row that references it. + +### 3.2 From core Bootstrap-5 starters (proposed, #2) + +PUMA ships a small **library of stock Bootstrap-5 layouts/sections** as files — holy-grail, sidebar, +landing, hero+features — **pure Bootstrap, no custom CSS/JS**. **"New from starter"** forks one into an +editable `type=layout` (or `page`) row *with `[content]`/`[partial]` placeholders already placed*. This +is *literally the same mechanism as §3.3* — it is "fork the **default** theme's stock layouts." §3.2 and +§3.3 are one feature pointed at two sources (core vs installed theme). + +### 3.3 From a theme (fork templates) + +The **active** theme (e.g. Porto once activated) ships layouts / partials / components as files; the +content admin lists them as forkable **templates**; **Customize** forks one into the matching editable +row (`layout`/`partial`/`block`/`page`). Theme menus → forkable menu rows. **Only the active theme +surfaces** — an installed-but-inactive theme has no asset symlink (created on Activate, removed on +Deactivate), so its content can't render and never appears; activate a theme to fork its templates. A +forked **page** bakes the theme's stylesheet links into its head, so it self-loads its origin theme's +CSS (reachable via that theme's symlink) and renders correctly even if a *different* theme is later +activated — going dark only if the origin theme is deactivated. This is THEMES.md's Tier-1-files → +Tier-3-rows path (§4a), with **fork-on-edit provenance** (`source`/`source_key`/`source_slug`/`forked`, +§4b) so a theme *update* refreshes only the copies you have not touched — non-destructive, the thing +WordPress cannot do. + +--- + +## 4. The unifying primitive + +**One store · one "fork a file into an editable row" operation · three starter sources (blank / +core-BS5 / theme).** Everything downstream — the visual editor, the placeholders, the render pipeline — +already exists and is shared. We are not building three workflows; we are building **one fork-to-row +primitive** and pointing it at three sources. + +`forkTheme` (built, pages only) is that primitive. Generalizing it needs three parallel reads on +`Tiger_Theme` (it has `pages()`/`page()` today) and a `kind`/`type` on the fork service that maps to the +right `TYPE_*` and derives `slug` (pages) or `page_key` (layouts/partials/blocks). The provenance columns +(THEMES §4b) let a fork remember its origin so "revert to theme default" and non-destructive theme +updates work — no schema gymnastics. + +--- + +## 5. The agent path (same door, one skill) + +Because §3.1–§3.3 all reduce to "seed a row, then edit it," the agent's whole job is **"write that +row"** — through the same `Cms_Service_Page::save` the UI uses. What the agent needs is a focused +**authoring skill**: the four `type`s, the `[content]`/`[partial]` placeholder conventions, the format +choices (html/markdown/phtml/builder), and the save contract. That skill feeds straight off the existing +docblock **reference generator** + [AGENTS.md](AGENTS.md), and is the same surface the **TigerMCP** scope +exposes to external agents. "The agent builds my pages" is the *easy* half precisely because the human +workflow already normalized everything to rows. + +--- + +## 6. Build order (phasing) + +1. **Surface theme layouts (+ partials/blocks) as forkable templates** — generalize `forkTheme` + the + "Theme Templates" tab beyond pages. **Fixes the concrete gap (a theme layout you can't edit), and is + the foundation §3.2/§3.3 reuse.** ← *start here.* +2. **The PUMA Bootstrap-5 starter library + "New from starter"** — same fork primitive, core-provided + sources; the from-a-skeleton path. +3. **The blank / Bootstrap-only base** — the leanest shell for from-nothing. +4. **The agent authoring skill** — the row model + save contract as a skill/doc (ties to the reference + generator + TigerMCP). + +--- + +## 7. Rejected alternatives (so we don't relitigate) + +| Rejected | Why | Chosen instead | +|---|---|---| +| A `type=layout` row owns ``/`` | couples content to a shell → the theme-swap break | shell is a theme file; the layout row is the *body* skeleton (§1) | +| A separate agent authoring API | two code paths to keep in sync; drift | agent writes the *same rows* via the *same* `/api` (§0/§5) | +| Theme layouts stay locked in files | the author can't start from them (the gap) | fork a theme file into an editable row (§3.3/§4) | +| A distinct "starter" store/table | schema sprawl; a second thing to render | starters are just files forked into ordinary `page` rows (§3.2) | +| Theme update overwrites edited rows | destroys the author's work | fork-on-edit provenance — updates skip `forked=1` (THEMES §4c) | + +--- + +*This document records the authoring workflow and its rationale. If you change a decision, update the +"why" here in the same change — it's the most valuable and most perishable part.* diff --git a/library/Tiger/Theme.php b/library/Tiger/Theme.php index 885a8b4..64bf3a8 100644 --- a/library/Tiger/Theme.php +++ b/library/Tiger/Theme.php @@ -34,8 +34,29 @@ public static function dir() */ public static function manifest() { - $file = self::dir() . '/theme.json'; - if (!is_file($file)) { + return self::_manifestAt(self::dir()); + } + + /** + * A theme's public stylesheet URLs (its manifest `canvasCss` — the same sheets its shell loads, + * reachable via the theme's `public/_` symlink). Used to bake a forked page's head so it + * self-loads its origin theme's CSS regardless of the active theme. + * + * @param string|null $dir the theme dir (null = the active theme) + * @return array + */ + public static function stylesheets($dir = null) + { + $man = self::_manifestAt(($dir !== null && $dir !== '') ? $dir : self::dir()); + $css = $man['canvasCss'] ?? []; + return is_array($css) ? array_values(array_filter(array_map('strval', $css))) : []; + } + + /** Read a theme.json manifest at a specific theme dir (for enumerating non-active themes). */ + protected static function _manifestAt($dir) + { + $file = rtrim((string) $dir, '/') . '/theme.json'; + if ($dir === '' || !is_file($file)) { return []; } $data = json_decode((string) file_get_contents($file), true); @@ -82,12 +103,130 @@ public static function components() /** * The active theme's page templates — every `content/**‍/*.phtml` it serves from files - * (THEMES.md §8a), each with its `tiger:page` hint parsed. The CMS surfaces these so an author - * can CUSTOMIZE one — fork it into an editable page row that overrides the file (live-override). + * (THEMES.md §8a) that is NOT a layout/partial skeleton, each with its `tiger:page` hint parsed. + * The CMS surfaces these so an author can CUSTOMIZE one — fork it into an editable page row that + * overrides the file (live-override). * * @return array> [{slug,title,layout,skin}] sorted by title */ public static function pages() + { + // Default kind: anything in content/ that isn't explicitly hinted as a layout/partial skeleton + // is a page (preserves the historical "every content file is a page" behavior). + return self::_scan('tiger:page', ['tiger:layout', 'tiger:partial']); + } + + /** + * The active theme's forkable LAYOUT skeletons — `content/**‍/*.phtml` hinted `tiger:layout`. A + * layout is a body skeleton (`[partial]…[content]…[partial]`), NOT the outer shell (the shell stays + * a theme file — AUTHORING.md §1). Forking one yields an editable `type=layout` row. + * + * @return array> [{slug,title,layout,skin}] sorted by title + */ + public static function layouts() + { + return self::_scan('tiger:layout'); + } + + /** + * The active theme's forkable PARTIAL fragments — `content/**‍/*.phtml` hinted `tiger:partial`. + * Forking one yields an editable `type=partial` row (a synced reference: header, footer, …). + * + * @return array> [{slug,title,layout,skin}] sorted by title + */ + public static function partials() + { + return self::_scan('tiger:partial'); + } + + /** + * ALL forkable material in ONE pass — every `content/**‍/*.phtml`, bucketed by kind from its hint + * (tiger:layout → layout, tiger:partial → partial, else page). Cheaper than pages()+layouts()+ + * partials() (which each re-scan the tree); use this where the whole set is needed at once (the + * Theme Templates datatable, which a large theme — Porto ~830 — makes worth doing in one scan). + * + * @return array> [{kind,slug,title,layout,skin}] sorted by title + */ + public static function forkables($themeDir = null) + { + $root = ($themeDir !== null && $themeDir !== '') ? (string) $themeDir : self::dir(); + $base = $root . '/content'; + if ($root === '' || !is_dir($base)) { + return []; + } + + // A THEME may ship hundreds of forkables (Porto ~834), and the Theme Templates datatable scans + // EVERY installed theme per request. So: a cheap stat sweep builds a fingerprint; the parsed + // result (which needs the expensive file_get_contents per file to read hints) is cached against + // it — one cache file per theme, re-scanned only when a file changes. + try { + $files = []; + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS) + ); + foreach ($it as $file) { + if ($file->isFile() && strtolower($file->getExtension()) === 'phtml') { + $files[$file->getPathname()] = $file->getMTime() . ':' . $file->getSize(); + } + } + } catch (Throwable $e) { + return []; + } + ksort($files); + $fp = md5($root . '|' . serialize($files)); + $cached = self::_cacheGet($root, $fp); + if ($cached !== null) { return $cached; } + + $out = []; + foreach ($files as $path => $_) { + $raw = (string) file_get_contents($path); + $kind = self::_hasTag($raw, 'tiger:layout') ? 'layout' + : (self::_hasTag($raw, 'tiger:partial') ? 'partial' : 'page'); + $slug = str_replace('\\', '/', substr($path, strlen($base) + 1)); + $slug = preg_replace('/\.phtml$/i', '', $slug); + $meta = self::hint($raw, 'tiger:' . $kind); + $out[] = [ + 'kind' => $kind, + 'slug' => $slug, + 'title' => $meta['title'] ?? ucfirst(str_replace(['-', '/'], ' ', $slug)), + 'layout' => $meta['layout'] ?? '', + 'skin' => $meta['skin'] ?? '', + ]; + } + usort($out, static function ($a, $b) { return strcasecmp($a['title'], $b['title']); }); + self::_cacheSet($root, $fp, $out); + return $out; + } + + /** + * The ACTIVE theme — the one THIS request resolved (tiger.theme, org-scopable, + preview cookie). + * Only the active theme's content is forkable: an installed-but-INACTIVE theme has no asset symlink + * (it's created on Activate, removed on Deactivate), so its templates can't render and must not + * surface. Mirrors the Modules admin's rule — a theme is active iff tiger.theme === its key. + * + * @return array{key:string,name:string,dir:string} + */ + public static function active() + { + $dir = self::dir(); + $key = defined('THEME') ? (string) THEME : ($dir !== '' ? basename($dir) : ''); + $man = self::_manifestAt($dir); + return [ + 'key' => $key, + 'name' => (string) ($man['name'] ?? ($key !== '' ? ucfirst(str_replace('-', ' ', $key)) : '')), + 'dir' => $dir, + ]; + } + + /** + * Scan `content/**‍/*.phtml` for files carrying $hintTag (skipping any that carry an $exclude tag), + * returning the fork-list shape. The shared engine behind pages()/layouts()/partials(). + * + * @param string $hintTag the tiger:* tag whose hint drives title/layout/skin + * @param array $exclude tags that, if present, remove the file from THIS list + * @return array> + */ + protected static function _scan($hintTag, array $exclude = []) { $base = self::dir() . '/content'; if (self::dir() === '' || !is_dir($base)) { @@ -100,9 +239,17 @@ public static function pages() ); foreach ($it as $file) { if (!$file->isFile() || strtolower($file->getExtension()) !== 'phtml') { continue; } + $raw = (string) file_get_contents($file->getPathname()); + // Membership: this list requires its own tag EXCEPT the pages() list, which is the + // default kind (no tag required) and only excludes the layout/partial skeletons. + if ($exclude) { + foreach ($exclude as $x) { if (self::_hasTag($raw, $x)) { continue 2; } } + } elseif (!self::_hasTag($raw, $hintTag)) { + continue; + } $slug = str_replace('\\', '/', substr($file->getPathname(), strlen($base) + 1)); $slug = preg_replace('/\.phtml$/i', '', $slug); - $meta = self::hint((string) file_get_contents($file->getPathname()), 'tiger:page'); + $meta = self::hint($raw, $hintTag); $out[] = [ 'slug' => $slug, 'title' => $meta['title'] ?? ucfirst(str_replace(['-', '/'], ' ', $slug)), @@ -117,28 +264,42 @@ public static function pages() return $out; } + /** True if $raw carries a leading-comment hint tag ``. */ + protected static function _hasTag($raw, $tag) + { + return (bool) preg_match('/\s*/s', '', $raw, 1); + $meta = self::hint($raw, $tag); + $body = preg_replace('/^\s*\s*/s', '', $raw, 1); return [ + 'kind' => $kind, 'slug' => $slug, 'title' => $meta['title'] ?? ucfirst(str_replace(['-', '/'], ' ', $slug)), 'layout' => $meta['layout'] ?? '', @@ -147,6 +308,17 @@ public static function page($slug) ]; } + /** + * One PAGE template by slug — a thin back-compat wrapper over template('page', …). + * + * @param string $slug the content slug (may be nested) + * @return array|null [{slug,title,layout,skin,body}] + */ + public static function page($slug) + { + return self::template('page', $slug); + } + /** * 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). @@ -166,4 +338,29 @@ public static function hint($raw, $tag) } return $meta; } + + /** The forkables cache file for a theme dir (one file per theme, inside the app cache root). */ + protected static function _cacheFile($root) + { + if (!defined('APPLICATION_ROOT')) { return ''; } + return APPLICATION_ROOT . '/var/cache/theme/forkables-' . md5((string) $root) . '.json'; + } + + /** Return the cached forkables for $root iff the stored fingerprint still matches; else null. */ + protected static function _cacheGet($root, $fp) + { + $f = self::_cacheFile($root); + if ($f === '' || !is_file($f)) { return null; } + $d = json_decode((string) file_get_contents($f), true); + return (is_array($d) && ($d['fp'] ?? '') === $fp && isset($d['data']) && is_array($d['data'])) ? $d['data'] : null; + } + + /** Persist the scanned forkables + fingerprint for $root (best-effort; a read-only cache dir is fine). */ + protected static function _cacheSet($root, $fp, array $data) + { + $f = self::_cacheFile($root); + if ($f === '') { return; } + if (!is_dir(dirname($f))) { @mkdir(dirname($f), 0775, true); } + @file_put_contents($f, json_encode(['fp' => $fp, 'data' => $data]), LOCK_EX); + } } diff --git a/modules/cms/controllers/PageController.php b/modules/cms/controllers/PageController.php index f664d69..beef2c6 100644 --- a/modules/cms/controllers/PageController.php +++ b/modules/cms/controllers/PageController.php @@ -38,23 +38,13 @@ public function indexAction() $this->view->title = 'Content — Tiger Admin'; $this->view->useDataTables = true; // the layout loads jQuery + DataTables when set - // The ACTIVE theme's page templates (served from files) — surfaced so an author can - // CUSTOMIZE one: fork it into an editable page that overrides the file. Flag which are - // already customized (a published/draft page row claims the slug). - $templates = Tiger_Theme::pages(); - if ($templates) { - $bySlug = []; - foreach ($this->_pages->fetchAll( - $this->_pages->activeSelect()->where('type = ?', Tiger_Model_Page::TYPE_PAGE) - ) as $p) { - if ($p->slug !== null && $p->slug !== '') { $bySlug[$p->slug] = $p->page_id; } - } - foreach ($templates as &$t) { $t['page_id'] = $bySlug[$t['slug']] ?? ''; } - unset($t); - } - $man = Tiger_Theme::manifest(); - $this->view->themeName = (string) ($man['name'] ?? ''); - $this->view->themeTemplates = $templates; + // The ACTIVE theme's forkable material — pages/layouts/partials it serves from files + // (AUTHORING.md §3.3), surfaced in the "Theme Templates" tab so an author can CUSTOMIZE one (fork + // it into an editable row that overrides the file). An installed-but-inactive theme has no asset + // symlink, so its content can't render and never surfaces. The tab is a server-side DataTable + // (Cms_Service_Page::themeTemplates); here we only need the count for the tab badge (cheap — + // Tiger_Theme::forkables is fingerprint-cached). + $this->view->themeCount = count(Tiger_Theme::forkables()); } /** diff --git a/modules/cms/services/Page.php b/modules/cms/services/Page.php index 9164955..eea690d 100644 --- a/modules/cms/services/Page.php +++ b/modules/cms/services/Page.php @@ -72,56 +72,180 @@ public function datatable(array $params): void } /** - * Fork an ACTIVE-theme page template (served from a file) into an editable CMS page row at the - * same slug. That row then transparently OVERRIDES the file — ThemeContent serves the file only - * when no DB page claims the slug (the live-override tier). No theme file is ever modified. If a - * page already exists at the slug it's returned (already customized). Origin is tagged in `meta` - * (source/source_key) so we can later offer "revert to theme default" without a schema change. + * DataTables server-side source for the "Theme Templates" tab — the active theme's forkable + * pages/layouts/partials (served from files). A large theme (Porto ~830) makes this a real dataset, + * so it paginates/searches/sorts server-side like the content list. Rows are file-derived (not a SQL + * query): scan once (Tiger_Theme::forkables), flag each with its customization id, then filter/sort/ + * slice in PHP. Each row carries `can_edit` so the client renders ACL-correct controls. * - * @param array $params must carry `slug` (the theme content slug) + * @param array $params the DataTables request payload + * @return void + */ + public function themeTemplates(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + + $dt = $this->_dtParams($params); + + // Which theme templates are already forked — matched PRECISELY by origin (theme|kind|slug) + // recorded in the fork's meta, so a same-named template can't cross-flag. + $forked = $this->_forkedIndex(); + + // Only the ACTIVE theme's templates surface — an installed-but-INACTIVE theme has no asset + // symlink, so its content can't render and must not be forkable (AUTHORING.md §3.3). + $active = Tiger_Theme::active(); + $rows = []; + foreach (Tiger_Theme::forkables() as $t) { + $isPage = ($t['kind'] === 'page'); + $pageId = $forked[$active['key'] . '|' . $t['kind'] . '|' . $t['slug']] ?? ''; + $rows[] = [ + 'kind' => $t['kind'], + 'theme' => $active['name'], + 'theme_key' => $active['key'], + 'title' => ($t['title'] !== '' ? $t['title'] : $t['slug']), + 'slug' => $t['slug'], // raw handle for the fork call + 'handle' => $isPage ? '/' . $t['slug'] : '#' . $this->_slugify($t['slug']), + 'customized' => $pageId !== '', + 'page_id' => $pageId, + ]; + } + + $total = count($rows); + + // Search across title/slug/kind/theme. + $search = strtolower(trim((string) $dt['search'])); + if ($search !== '') { + $rows = array_values(array_filter($rows, static function ($r) use ($search) { + return strpos(strtolower($r['title'] . ' ' . $r['slug'] . ' ' . $r['kind'] . ' ' . $r['theme']), $search) !== false; + })); + } + $filtered = count($rows); + + // Sort by the ordered column, title as the tiebreak. + $cols = [0 => 'kind', 1 => 'theme', 2 => 'title', 3 => 'handle', 4 => 'customized']; + $field = $cols[(int) ($dt['order'][0]['column'] ?? 1)] ?? 'theme'; + $dir = (strtolower((string) ($dt['order'][0]['dir'] ?? 'asc')) === 'desc') ? -1 : 1; + $val = static function ($r) use ($field) { + return $field === 'customized' ? ($r['customized'] ? '1' : '0') : (string) $r[$field]; + }; + usort($rows, static function ($a, $b) use ($val, $dir) { + $c = strcmp($val($a), $val($b)); + if ($c === 0) { $c = strcasecmp((string) $a['title'], (string) $b['title']); } + return $c * $dir; + }); + + // Paginate. + $page = array_slice($rows, (int) $dt['start'], ((int) $dt['length'] > 0) ? (int) $dt['length'] : null); + + $canEdit = $this->_isAdmin(static::class, 'save'); + foreach ($page as &$r) { $r['can_edit'] = $canEdit; } + unset($r); + + $this->_dtResponse($dt['draw'], $total, $filtered, $page); + } + + /** [theme_key|kind|slug => page_id] for theme-forked rows, from each fork's origin recorded in meta. */ + protected function _forkedIndex(): array + { + $pm = new Tiger_Model_Page(); + $out = []; + foreach ($pm->fetchAll($pm->activeSelect()->where('type IN (?)', [ + Tiger_Model_Page::TYPE_PAGE, Tiger_Model_Page::TYPE_LAYOUT, Tiger_Model_Page::TYPE_PARTIAL, + ])) as $r) { + if (empty($r->meta)) { continue; } + $meta = is_array($r->meta) ? $r->meta : json_decode((string) $r->meta, true); + if (!is_array($meta) || ($meta['source'] ?? '') !== 'theme') { continue; } + $out[($meta['source_key'] ?? '') . '|' . ($meta['source_kind'] ?? '') . '|' . ($meta['source_slug'] ?? '')] = $r->page_id; + } + return $out; + } + + /** + * Fork an ACTIVE-theme template (served from a file) into an editable CMS row — a PAGE (by slug), + * a LAYOUT, or a PARTIAL (by page_key). That row then transparently OVERRIDES the file (the + * live-override tier); no theme file is ever modified. If a matching row already exists it's + * returned (already customized). Origin is tagged in `meta` (source/source_key/source_kind) so we + * can later offer "revert to theme default" + non-destructive theme updates (AUTHORING.md §4). + * + * @param array $params `slug` (the theme content slug/key) + optional `kind` (page|layout|partial) * @return void */ public function forkTheme(array $params): void { if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } - $slug = trim((string) ($params['slug'] ?? ''), '/'); - $tpl = Tiger_Theme::page($slug); + $kind = (string) ($params['kind'] ?? 'page'); + if (!in_array($kind, ['page', 'layout', 'partial'], true)) { $kind = 'page'; } + $key = trim((string) ($params['slug'] ?? ''), '/'); + + // Only the ACTIVE theme is forkable (its assets are symlinked/reachable). Refuse any other — a + // crafted request could otherwise name an installed-but-inactive theme whose content is dark. + $active = Tiger_Theme::active(); + $themeParam = (string) ($params['theme'] ?? ''); + if ($themeParam !== '' && $themeParam !== $active['key']) { + $this->_error('That theme is not active.'); return; + } + $dir = $active['dir']; + $themeKey = $active['key']; + + $tpl = Tiger_Theme::template($kind, $key, $dir ?: null); if (!$tpl) { $this->_error('That template is no longer available.'); return; } - $pm = new Tiger_Model_Page(); - $existing = $pm->fetchRow( - $pm->activeSelect()->where('slug = ?', $slug)->where('type = ?', Tiger_Model_Page::TYPE_PAGE) - ); - if ($existing) { - $url = '/cms/page/edit/id/' . $existing->page_id; - $this->_success(['page_id' => $existing->page_id, 'edit_url' => $url], 'cms.page.exists', $url); + $type = [ + 'page' => Tiger_Model_Page::TYPE_PAGE, + 'layout' => Tiger_Model_Page::TYPE_LAYOUT, + 'partial' => Tiger_Model_Page::TYPE_PARTIAL, + ][$kind]; + $isPage = ($kind === 'page'); + + // Re-customizing an already-forked template just reopens it — matched by ORIGIN (theme|kind|slug), + // so the same-named template from two themes forks independently. + $pm = new Tiger_Model_Page(); + $forked = $this->_forkedIndex(); + $origin = $themeKey . '|' . $kind . '|' . $key; + if (isset($forked[$origin])) { + $id = $forked[$origin]; + $url = '/cms/page/edit/id/' . $id; + $this->_success(['page_id' => $id, 'edit_url' => $url, 'kind' => $kind], 'cms.page.exists', $url); return; } - // A page body with PHP must stay phtml (trusted); pure markup forks as html so it opens - // cleanly in the visual builder. - $hasPhp = (strpos($tpl['body'], '_uniqueKey($pm, $this->_slugify($key) ?: $kind); + + // Provenance (source_*) drives "already customized" + a future "revert to theme default". For a + // PAGE, bake the origin theme's stylesheet links into the head so it SELF-LOADS its theme's CSS + // (reachable via that theme's own symlink) — so the fork renders correctly under ANY active theme, + // and goes dark only if that theme is deactivated and its symlink removed. + $meta = ['source' => 'theme', 'source_key' => $themeKey, 'source_kind' => $kind, 'source_slug' => $key]; + if ($isPage) { + $links = ''; + foreach (Tiger_Theme::stylesheets($dir ?: null) as $href) { + $links .= '' . "\n"; + } + if ($links !== '') { $meta['head_html'] = $links; } + } + $data = [ - 'type' => Tiger_Model_Page::TYPE_PAGE, - 'page_key' => $this->_slugify($slug), - 'slug' => $slug, + 'type' => $type, + 'page_key' => $pageKey, + 'slug' => $isPage ? $key : null, 'locale' => '', 'title' => $tpl['title'], 'body' => $tpl['body'], 'format' => $hasPhp ? Tiger_Model_Page::FORMAT_PHTML : Tiger_Model_Page::FORMAT_HTML, - 'layout_key' => $tpl['layout'] !== '' ? $tpl['layout'] : null, + 'layout_key' => ($isPage && $tpl['layout'] !== '') ? $tpl['layout'] : null, 'status' => Tiger_Model_Page::STATUS_PUBLISHED, 'published_at' => null, - 'meta' => json_encode( - ['source' => 'theme', 'source_key' => (string) (Tiger_Theme::manifest()['key'] ?? '')], - JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE - ), + 'meta' => json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), ]; try { $id = $pm->save($data, null); $url = '/cms/page/edit/id/' . $id; - $this->_success(['page_id' => $id, 'edit_url' => $url], 'cms.page.forked', $url); + $this->_success(['page_id' => $id, 'edit_url' => $url, 'kind' => $kind], 'cms.page.forked', $url); } catch (Throwable $e) { $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); } diff --git a/modules/cms/views/scripts/page/index.phtml b/modules/cms/views/scripts/page/index.phtml index 8631512..e8a376d 100644 --- a/modules/cms/views/scripts/page/index.phtml +++ b/modules/cms/views/scripts/page/index.phtml @@ -25,9 +25,9 @@ - themeTemplates)): ?> + themeCount)): ?> @@ -83,19 +83,28 @@
- themeTemplates)): ?> + themeCount)): ?>
-
-

- themeName): ?>escape($this->themeName) ?> - Pages the active theme serves from files — Customize makes an editable copy that overrides the file. The theme file is never changed. +

+

+ Pages, layouts, and partials your installed themes serve from files — Customize makes an editable copy that overrides the file. The theme file is never changed.

-
-
-
- - +
+
+
+ + + + + + + + + + + +
KindThemeTitleHandleStatusActions
@@ -103,14 +112,6 @@
-themeTemplates)): ?> - - - From c240a29b05f6bdd355a6f32bf11ada8b53d3e808 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Thu, 6 Aug 2026 20:56:51 -0400 Subject: [PATCH 11/14] CMS: layouts are content-region templates + PUMA starter set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 'layout' is now a content-region template that renders INSIDE the theme shell's
, not a whole page — so a CMS user composing a layout never touches the shell's plumbing (SEO/analytics/consent/assets/scripts), and a layout is structurally incapable of carrying injection code. - PageController::viewAction retires the self-contained 'layout_key owns the whole document' path: every CMS page renders through the theme shell, and its layout composes the content region within it (verified end-to-end: single document, no nested ). Safe — 0 live pages used the old behavior. Dropped two now-dead helpers (_injectBefore/_consentBanner). - Header/footer stay chrome the SHELL renders (they need the theme view scope — themeAssets, nav helper, auth placeholders — a CMS render can't supply); their content stays editable via the partial editor, just not re-placed per layout. - PUMA ships the starter set as forkable tiger:layout files: Full Width, Sidebar Left, Sidebar Right, Two Sidebars (+ a Sidebar partial) — asides/columns via [content] + [partial] slots, zero plumbing. - AUTHORING.md §1 updated to nail the shell-vs-content-region split. Co-Authored-By: Claude Opus 4.8 (1M context) --- AUTHORING.md | 23 +++++++--- core/controllers/PageController.php | 57 ++++++------------------- themes/puma/content/full-width.phtml | 4 ++ themes/puma/content/sidebar-left.phtml | 7 +++ themes/puma/content/sidebar-right.phtml | 7 +++ themes/puma/content/sidebar.phtml | 5 +++ themes/puma/content/two-sidebar.phtml | 8 ++++ 7 files changed, 60 insertions(+), 51 deletions(-) create mode 100644 themes/puma/content/full-width.phtml create mode 100644 themes/puma/content/sidebar-left.phtml create mode 100644 themes/puma/content/sidebar-right.phtml create mode 100644 themes/puma/content/sidebar.phtml create mode 100644 themes/puma/content/two-sidebar.phtml diff --git a/AUTHORING.md b/AUTHORING.md index 457f37e..0ad6ae3 100644 --- a/AUTHORING.md +++ b/AUTHORING.md @@ -35,14 +35,25 @@ from?"** There are exactly three sources: **blank**, a **core Bootstrap-5 starte Getting this boundary right is what keeps a theme swap from breaking pages, and lets a layout "contain nothing in the head, or whatever the author desires": -| Layer | Owns | Lives as | Swappable? | +| Layer | Owns | Lives as | Injection code? | |---|---|---|---| -| **The shell** | `…` + which CSS/JS load | a **theme file** (`layouts/scripts/*.phtml`) | yes — this is the theme axis (PUMA loads Bootstrap; a React theme loads a bundle) | -| **The body skeleton** | the page's structure — `[partial]` slots + the `[content]` slot | a **CMS `layout` row** (`type=layout`) | it's data — edit it, no deploy | - +| **The shell** | `…` + assets + **all injection points** (SEO/analytics/consent/code-inject), the header/footer *placement*, scripts | a **theme file** (`layouts/scripts/*.phtml`) | all of it | +| **The content-region layout** | what goes **inside `
`** — full-width, sidebars, columns: `[content]` + `[partial]` (aside) slots | a **CMS `layout` row** (`type=layout`) | **none — ever** | + +- **A `layout` is a *content-region* template, not a whole page.** It renders **inside** the shell's + `
` — `Tiger_Cms_Renderer` wraps the page body in it, and `PageController::viewAction` no longer + treats a `layout_key` as a self-contained document (that behavior is retired). So a CMS user composing + a layout (full-width, sidebar-left/right, two-sidebar) never sees the shell plumbing, and a layout is + structurally incapable of carrying injection code. +- **Header/footer are chrome the *shell* renders**, in the theme's view scope — they need `themeAssets`, + the nav helper, and the auth placeholders, which a CMS render can't supply. Their *content* is editable + via the partial editor; they simply aren't re-placed per layout. That keeps the dynamic chrome working + while the layout owns the content region. +- PUMA ships the starter set as forkable `tiger:layout` files: **Full Width · Sidebar Left · Sidebar + Right · Two Sidebars** (+ a `Sidebar` partial). - **Core emits no shell.** The shell is a theme concern; Core emits *data* (rows) + semantic default - views (ARCHITECTURE §9). A `type=layout` row **never** owns ``/`` — it's the *body* - skeleton, rendered inside the active theme's shell. + views (ARCHITECTURE §9). A `type=layout` row **never** owns ``/`` — it's the content + region, rendered inside the active theme's shell. - **Per-page head control already exists.** The page's `head_html` / `body_scripts` fields fill the shell's `pageHead` / `pageScripts` slots (THEMES §8a). "Nothing in the head" = leave them empty; "whatever you desire" = fill them. No new mechanism. diff --git a/core/controllers/PageController.php b/core/controllers/PageController.php index 23838c7..5c55abd 100644 --- a/core/controllers/PageController.php +++ b/core/controllers/PageController.php @@ -46,33 +46,18 @@ public function viewAction() // The meta description (and other SEO) is no longer synthesized here — it lives in meta.seo and is // rendered through the head registry by TigerSEO (Seo_Plugin_Head → headMeta/headLink). - if (!empty($page->layout_key)) { - // Self-contained CMS layout owns the whole document (it disables the theme layout, so nothing - // the theme layout normally injects reaches it). Splice the same head + body bits the layout - // would have added: the SEO head registry (headMeta/headLink + JSON-LD), the analytics/tracking - // tags (tigerTracking placeholder, consent-gated), the admin head_html — and, before , - // the admin body_scripts + the GDPR consent banner (self-checks; empty when not due). - $headInject = trim( - (string) $this->view->headMeta() - . (string) $this->view->headLink() - . (string) $this->view->placeholder('tigerJsonLd') - . (string) $this->view->placeholder('tigerTracking') - . "\n" . $head - ); - $bodyInject = trim($scripts . "\n" . self::_consentBanner($this->view)); - if ($headInject !== '') { $html = self::_injectBefore($html, '', $headInject); } - if ($bodyInject !== '') { $html = self::_injectBefore($html, '', $bodyInject); } - $this->_helper->layout()->disableLayout(); - $this->_helper->viewRenderer->setNoRender(true); - $this->getResponse()->setBody($html); - } else { - // Body only — wrap in the theme's public layout (see page/view.phtml). - $this->view->title = $page->title; - $this->view->cmsContent = $html; - $this->view->pageHead = $head; // the theme layout emits this in - $this->view->pageScripts = $scripts; // …and this before - $this->view->pageMeta = $meta; // whole meta -> the layout can read theme hints (e.g. skin) - } + // Every CMS page renders through the theme's public shell (layout.phtml). The SHELL owns the + // document + ALL injection points (SEO head registry, analytics/tracking, consent, assets, + // scripts, code-inject) — a layout never re-implements those. Tiger_Cms_Renderer has already + // wrapped the body in its layout_key CONTENT-REGION layout (a full-width / sidebar / column + // composition that renders INSIDE the shell's
), or returned the bare body. A layout is a + // content-region template, not a whole page — so a CMS user never touches the shell plumbing. + // (Formerly a layout_key was treated as a self-contained full document; retired — see AUTHORING.md.) + $this->view->title = $page->title; + $this->view->cmsContent = $html; + $this->view->pageHead = $head; // the shell emits this in + $this->view->pageScripts = $scripts; // …and this before + $this->view->pageMeta = $meta; // whole meta -> the shell can read theme hints (e.g. skin) } /** @@ -130,22 +115,4 @@ public function themeContentAction() $this->_helper->viewRenderer->setScriptAction('view'); // reuse core/views/scripts/page/view.phtml } - /** Splice a fragment immediately before a tag in an HTML string (append if the tag is absent). */ - protected static function _injectBefore($html, $tag, $fragment) - { - $pos = stripos($html, $tag); - return $pos !== false - ? substr($html, 0, $pos) . $fragment . "\n" . substr($html, $pos) - : $html . $fragment; - } - - /** Render the active theme's consent banner partial for splicing (empty if absent/not due). */ - protected static function _consentBanner($view) - { - try { - return (string) $view->render('_partials/consent-banner.phtml'); - } catch (Throwable $e) { - return ''; // a theme without the partial (or no consent) simply contributes nothing - } - } } diff --git a/themes/puma/content/full-width.phtml b/themes/puma/content/full-width.phtml new file mode 100644 index 0000000..9b19b56 --- /dev/null +++ b/themes/puma/content/full-width.phtml @@ -0,0 +1,4 @@ + +
+ [content] +
diff --git a/themes/puma/content/sidebar-left.phtml b/themes/puma/content/sidebar-left.phtml new file mode 100644 index 0000000..df7e6ed --- /dev/null +++ b/themes/puma/content/sidebar-left.phtml @@ -0,0 +1,7 @@ + +
+
+ +
[content]
+
+
diff --git a/themes/puma/content/sidebar-right.phtml b/themes/puma/content/sidebar-right.phtml new file mode 100644 index 0000000..0e51808 --- /dev/null +++ b/themes/puma/content/sidebar-right.phtml @@ -0,0 +1,7 @@ + +
+
+
[content]
+ +
+
diff --git a/themes/puma/content/sidebar.phtml b/themes/puma/content/sidebar.phtml new file mode 100644 index 0000000..93f1858 --- /dev/null +++ b/themes/puma/content/sidebar.phtml @@ -0,0 +1,5 @@ + +
+

Sidebar

+

A reusable aside — edit it in the partial editor, or drop widgets, menus, or a [partial] here.

+
diff --git a/themes/puma/content/two-sidebar.phtml b/themes/puma/content/two-sidebar.phtml new file mode 100644 index 0000000..7564bcd --- /dev/null +++ b/themes/puma/content/two-sidebar.phtml @@ -0,0 +1,8 @@ + +
+
+ +
[content]
+ +
+
From b53fc59a8dadf31b9d8e4e2e17caac9c9948015d Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 7 Aug 2026 04:32:51 -0400 Subject: [PATCH 12/14] Fix Tiger_Theme::active() runtime-override + update tests for new behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tiger_Theme::active() derived its key from the boot-time THEME constant, which ignores a runtime Tiger_ThemeDir override (an admin preview, or a test's temp theme) — so forkTheme recorded the wrong source_key. Derive it from the manifest (else the dir basename minus a theme- prefix) instead. (Caught by the forkTheme integration test.) - DiscoveryTest: a routed module's default type is now 'plugin', not 'module'. - CmsControllerTest: the Theme Templates tab is a DataTable now — assert themeCount, not the removed themeName / inline template list. - CoreControllerActionsTest: the self-contained-layout path is retired — rewrite to assert the content-region model (body wrapped in its layout, rendered through the shell; head_html/body_scripts flow to the shell slots). Co-Authored-By: Claude Opus 4.8 (1M context) --- library/Tiger/Theme.php | 5 ++- tests/Integration/Cms/CmsControllerTest.php | 12 +++---- .../Controller/CoreControllerActionsTest.php | 31 ++++++++++--------- tests/Unit/Module/DiscoveryTest.php | 4 +-- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/library/Tiger/Theme.php b/library/Tiger/Theme.php index 64bf3a8..ea5d418 100644 --- a/library/Tiger/Theme.php +++ b/library/Tiger/Theme.php @@ -209,8 +209,11 @@ public static function forkables($themeDir = null) public static function active() { $dir = self::dir(); - $key = defined('THEME') ? (string) THEME : ($dir !== '' ? basename($dir) : ''); $man = self::_manifestAt($dir); + // Derive the key from the manifest, else the dir basename (minus a `theme-` module prefix) — so a + // runtime Tiger_ThemeDir override (an admin preview, or a test's temp theme) is reflected, not the + // boot-time THEME constant (which can't change mid-request). + $key = (string) ($man['key'] ?? ($dir !== '' ? preg_replace('/^theme-/', '', basename($dir)) : '')); return [ 'key' => $key, 'name' => (string) ($man['name'] ?? ($key !== '' ? ucfirst(str_replace('-', ' ', $key)) : '')), diff --git a/tests/Integration/Cms/CmsControllerTest.php b/tests/Integration/Cms/CmsControllerTest.php index 1f68eb6..2f5d6d8 100644 --- a/tests/Integration/Cms/CmsControllerTest.php +++ b/tests/Integration/Cms/CmsControllerTest.php @@ -124,13 +124,11 @@ public function page_index_renders_the_content_list_shell_with_theme_templates() $view = $this->controller()->view; $this->assertSame('Content — Tiger Admin', $view->title); $this->assertTrue($view->useDataTables); - $this->assertSame('Test Theme', $view->themeName); - $this->assertNotEmpty($view->themeTemplates, 'the active theme templates are surfaced'); - - $welcome = null; - foreach ($view->themeTemplates as $t) { if ($t['slug'] === 'welcome') { $welcome = $t; } } - $this->assertNotNull($welcome, 'the welcome template appears'); - $this->assertSame($id, $welcome['page_id'], 'the template is flagged customized (a page claims its slug)'); + // The theme templates now load via a server-side DataTable (Cms_Service_Page::themeTemplates); + // the index shell only exposes the count for the tab badge. + $this->assertGreaterThan(0, (int) $view->themeCount, 'the active theme surfaces forkable templates'); + // (the seeded 'welcome' page is what a themeTemplates row flags "customized" — covered there.) + $this->assertNotNull($id); } #[Test] diff --git a/tests/Integration/Controller/CoreControllerActionsTest.php b/tests/Integration/Controller/CoreControllerActionsTest.php index 6d6552a..bb0a543 100644 --- a/tests/Integration/Controller/CoreControllerActionsTest.php +++ b/tests/Integration/Controller/CoreControllerActionsTest.php @@ -422,34 +422,37 @@ public function page_view_renders_a_published_page_in_the_theme_layout(): void } #[Test] - public function page_view_renders_a_self_contained_layout_page_into_the_body(): void + public function page_view_wraps_a_layout_page_in_its_content_region_layout(): void { - // A layout row + a page that references it → PageController emits a self-contained document - // (the theme layout is disabled and the HTML is set as the response body). + // A layout row + a page that references it → the page body is composed by its CONTENT-REGION + // layout and rendered THROUGH the theme shell (a layout is not a self-contained document; the + // shell owns / + all injection points). See PageController::viewAction / AUTHORING.md. (new Tiger_Model_Page())->insert([ 'type' => Tiger_Model_Page::TYPE_LAYOUT, - 'page_key' => 'full-doc', + 'page_key' => 'region', 'locale' => 'en', - 'title' => 'Full Doc Layout', - // A phtml layout self-injects the page body via $this->content (renderer context var). - 'body' => 'content ?>', - 'format' => Tiger_Model_Page::FORMAT_PHTML, + 'title' => 'Region Layout', + 'body' => '
[content]
', + 'format' => Tiger_Model_Page::FORMAT_HTML, 'status' => Tiger_Model_Page::STATUS_PUBLISHED, ]); $pageId = $this->seedPage([ 'title' => 'Landing', 'body' => '

Landing body

', - 'layout_key' => 'full-doc', + 'layout_key' => 'region', 'meta' => json_encode(['head_html' => '', 'body_scripts' => '']), ]); $res = $this->dispatchAction(PageController::class, 'view', ['cms_page_id' => $pageId], 'GET'); - $body = $res->getBody(); - $this->assertSame(200, $res->getHttpResponseCode()); - $this->assertStringContainsString('Landing body', $body, 'the layout-wrapped page is emitted as the response body'); - $this->assertStringContainsString(''); - $this->assertStringContainsString('void 0;', $body, 'the admin-authored body scripts are spliced before '); + + $view = $this->controller()->view; + $this->assertStringContainsString('Landing body', (string) $view->cmsContent, 'the page body is wrapped in its layout'); + $this->assertStringContainsString('layout-wrap', (string) $view->cmsContent, 'the content-region layout composed around the body'); + $this->assertStringNotContainsString('cmsContent, 'a layout is a content region, not a whole document — the shell owns that'); + // The admin-authored head/body escape hatches flow to the shell's slots (it emits them). + $this->assertStringContainsString('pageHead, 'head_html reaches the shell slot'); + $this->assertStringContainsString('void 0;', (string) $view->pageScripts, 'body scripts reach the shell body slot'); } #[Test] diff --git a/tests/Unit/Module/DiscoveryTest.php b/tests/Unit/Module/DiscoveryTest.php index e29f455..828d5d9 100644 --- a/tests/Unit/Module/DiscoveryTest.php +++ b/tests/Unit/Module/DiscoveryTest.php @@ -73,7 +73,7 @@ public function detectsARoutedModuleByItsBootstrap(): void $all = Tiger_Module_Discovery::all(); $this->assertArrayHasKey('fixrouted', $all); - $this->assertSame('module', $all['fixrouted']['type']); + $this->assertSame('plugin', $all['fixrouted']['type'], 'an untyped routed module defaults to plugin (WP model)'); $this->assertSame('app', $all['fixrouted']['area']); $this->assertSame('Fix Routed', $all['fixrouted']['name']); $this->assertSame('1.0.0', $all['fixrouted']['version']); @@ -90,7 +90,7 @@ public function detectsARoutedModuleWithControllersButNoManifest(): void $all = Tiger_Module_Discovery::all(); $this->assertArrayHasKey('fixctrl', $all); - $this->assertSame('module', $all['fixctrl']['type']); + $this->assertSame('plugin', $all['fixctrl']['type'], 'an untyped routed module defaults to plugin'); $this->assertFalse($all['fixctrl']['has_manifest'], 'no manifest was shipped'); $this->assertSame('Fixctrl', $all['fixctrl']['name'], 'name falls back to ucfirst(slug)'); } From 7f5004ffd3b5d098f76b1175c65ca1b528712abf Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 7 Aug 2026 04:36:45 -0400 Subject: [PATCH 13/14] Test: use a phtml layout ($this->content) in the content-region page-view test The [content] shortcode isn't registered in the reduced integration bootstrap, so the html layout left it literal. phtml pulls the body via the renderer context var, independent of shortcode registration (matches the prior test's approach). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Integration/Controller/CoreControllerActionsTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Controller/CoreControllerActionsTest.php b/tests/Integration/Controller/CoreControllerActionsTest.php index bb0a543..ab8da59 100644 --- a/tests/Integration/Controller/CoreControllerActionsTest.php +++ b/tests/Integration/Controller/CoreControllerActionsTest.php @@ -432,8 +432,10 @@ public function page_view_wraps_a_layout_page_in_its_content_region_layout(): vo 'page_key' => 'region', 'locale' => 'en', 'title' => 'Region Layout', - 'body' => '
[content]
', - 'format' => Tiger_Model_Page::FORMAT_HTML, + // phtml pulls the page body via $this->content (the renderer context var) — independent of + // whether the [content] shortcode is registered in this reduced test bootstrap. + 'body' => '
content ?>
', + 'format' => Tiger_Model_Page::FORMAT_PHTML, 'status' => Tiger_Model_Page::STATUS_PUBLISHED, ]); $pageId = $this->seedPage([ From 8020d1476629e7aaea64b5a03cf92f33523b269b Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 7 Aug 2026 04:43:20 -0400 Subject: [PATCH 14/14] Bump version to 0.51.0-beta Feature release: in-context partial editor + Blocks, content-region layouts + PUMA starter set, theme-fork workflow + Theme-Templates DataTable, Modules taxonomy retention + Type-column DataTable, and the acl.ini activation-gate fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- library/Tiger/Version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 7d56d52..99faf73 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 = '0.50.0-beta'; + const VERSION = '0.51.0-beta'; }