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/AUTHORING.md b/AUTHORING.md
new file mode 100644
index 0000000..0ad6ae3
--- /dev/null
+++ b/AUTHORING.md
@@ -0,0 +1,175 @@
+# 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 | Injection code? |
+|---|---|---|---|
+| **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 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.
+- **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/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/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/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.
+
+
+
+
+
+
+
= $f[1] ?>
+
= $f[2] ?>
+
+
+
+
+
+
+
+
+
+
+
+
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 stack
+
Tiger
+
+
+
+
+
+
= $row[0] ?>
+
= $row[1] ?>
+
= $row[2] ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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): ?>
+ = $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/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;
diff --git a/library/Tiger/Application/Bootstrap.php b/library/Tiger/Application/Bootstrap.php
index ec99f89..e10a741 100644
--- a/library/Tiger/Application/Bootstrap.php
+++ b/library/Tiger/Application/Bootstrap.php
@@ -204,6 +204,48 @@ 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 '';
+ }
+ });
+
+ // [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'] : '';
+ });
}
/**
diff --git a/library/Tiger/Model/Module.php b/library/Tiger/Model/Module.php
index 28fddf7..2977d78 100644
--- a/library/Tiger/Model/Module.php
+++ b/library/Tiger/Model/Module.php
@@ -99,6 +99,10 @@ public function install($slug, array $meta)
'repository' => $meta['repository'] ?? null,
'ref' => $meta['ref'] ?? null,
'source' => $meta['source'] ?? self::SOURCE_URL,
+ // Taxonomy captured from the source (listing/manifest) so it's retained after install — the
+ // same type/category Add Module showed (migration 0042). NULL when the source declared none.
+ 'type' => $meta['type'] ?? null,
+ 'category' => $meta['category'] ?? null,
'active' => 1,
'status' => 'active',
];
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/library/Tiger/Module/Discovery.php b/library/Tiger/Module/Discovery.php
index 4939f90..01e41bf 100644
--- a/library/Tiger/Module/Discovery.php
+++ b/library/Tiger/Module/Discovery.php
@@ -43,12 +43,18 @@ public static function all()
// and many modules declare a `vendor` (e.g. WebTigers) rather than an `author`.
$author = $m['author'] ?? ($m['vendor'] ?? '');
if (is_array($author)) { $author = $author['name'] ?? ''; }
- $type = (string) ($m['type'] ?? ($isTheme ? 'theme' : ($isSnippets ? 'code' : 'module')));
+ // Taxonomy travels WITH the module (its manifest), so it's retained from wherever it was
+ // installed — the same type/category the Add Module registry showed. An untyped routed
+ // module defaults to `plugin` (the WordPress model), a themes to `theme`, a snippets pack
+ // to `code`. See AUTHORING.md / MARKETPLACE.md + the registry taxonomy.json vocabulary.
+ $type = (string) ($m['type'] ?? ($isTheme ? 'theme' : ($isSnippets ? 'code' : 'plugin')));
+ $cats = isset($m['category']) ? array_values(array_filter(array_map('strval', (array) $m['category']))) : [];
$modules[$slug] = [
'slug' => $slug,
'area' => $root['area'],
- 'type' => $type, // module | theme
+ 'type' => $type, // app | plugin | theme | code | developer
+ 'category' => $cats, // taxonomy categories (registry vocabulary)
// The theme KEY (what tiger.theme stores → _initTheme resolves modules/theme-).
// From the manifest, else the slug minus its `theme-` prefix.
'key' => (string) ($m['key'] ?? preg_replace('/^theme-/', '', $slug)),
diff --git a/library/Tiger/Module/Installer.php b/library/Tiger/Module/Installer.php
index 6623c4c..47f3836 100644
--- a/library/Tiger/Module/Installer.php
+++ b/library/Tiger/Module/Installer.php
@@ -224,6 +224,12 @@ public static function installFromTarball($tarPath, array $provenance = [], arra
'repository' => $provenance['repository'] ?? null,
'ref' => $provenance['ref'] ?? null,
'source' => $provenance['source'] ?? Tiger_Model_Module::SOURCE_URL,
+ // Retain the source taxonomy — prefer the listing (provenance, when a caller has it),
+ // else the module's own manifest. NULL if neither declared it (Discovery then defaults).
+ 'type' => $provenance['type'] ?? ($manifest['type'] ?? null),
+ 'category' => isset($provenance['category'])
+ ? (is_array($provenance['category']) ? implode(',', $provenance['category']) : (string) $provenance['category'])
+ : (isset($manifest['category']) ? implode(',', (array) $manifest['category']) : null),
]);
return ['slug' => $slug, 'name' => $manifest['name'] ?? $slug, 'version' => $manifest['version'] ?? null,
diff --git a/library/Tiger/Theme.php b/library/Tiger/Theme.php
index 885a8b4..ea5d418 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,133 @@ 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();
+ $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)) : '')),
+ '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 +242,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 +267,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 +311,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 +341,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/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';
}
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.
+ ],
+];
diff --git a/migrations/0042_module_taxonomy.php b/migrations/0042_module_taxonomy.php
new file mode 100644
index 0000000..885a6d9
--- /dev/null
+++ b/migrations/0042_module_taxonomy.php
@@ -0,0 +1,21 @@
+ [
+ "ALTER TABLE `module`
+ ADD COLUMN `type` VARCHAR(32) NULL DEFAULT NULL AFTER `ref`,
+ ADD COLUMN `category` VARCHAR(191) NULL DEFAULT NULL AFTER `type`",
+ ],
+ 'down' => [
+ "ALTER TABLE `module` DROP COLUMN `category`, DROP COLUMN `type`",
+ ],
+];
diff --git a/modules/access/module.json b/modules/access/module.json
new file mode 100644
index 0000000..d7a826e
--- /dev/null
+++ b/modules/access/module.json
@@ -0,0 +1,4 @@
+{
+ "name": "Access",
+ "type": "plugin"
+}
diff --git a/modules/agent/module.json b/modules/agent/module.json
new file mode 100644
index 0000000..6626389
--- /dev/null
+++ b/modules/agent/module.json
@@ -0,0 +1,5 @@
+{
+ "name": "Agent",
+ "type": "app",
+ "category": ["ai"]
+}
diff --git a/modules/ally/module.json b/modules/ally/module.json
new file mode 100644
index 0000000..5897982
--- /dev/null
+++ b/modules/ally/module.json
@@ -0,0 +1,4 @@
+{
+ "name": "Ally",
+ "type": "plugin"
+}
diff --git a/modules/analytics/module.json b/modules/analytics/module.json
new file mode 100644
index 0000000..5a935ff
--- /dev/null
+++ b/modules/analytics/module.json
@@ -0,0 +1,5 @@
+{
+ "name": "Analytics",
+ "type": "app",
+ "category": ["marketing"]
+}
diff --git a/modules/backup/module.json b/modules/backup/module.json
new file mode 100644
index 0000000..d32d62d
--- /dev/null
+++ b/modules/backup/module.json
@@ -0,0 +1,5 @@
+{
+ "name": "Backup",
+ "type": "app",
+ "category": ["operations"]
+}
diff --git a/modules/blog/module.json b/modules/blog/module.json
new file mode 100644
index 0000000..74ab6df
--- /dev/null
+++ b/modules/blog/module.json
@@ -0,0 +1,5 @@
+{
+ "name": "Blog",
+ "type": "app",
+ "category": ["content"]
+}
diff --git a/modules/cms/controllers/PageController.php b/modules/cms/controllers/PageController.php
index 6e2f8e1..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());
}
/**
@@ -110,17 +100,173 @@ 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();
+ // 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/module.json b/modules/cms/module.json
new file mode 100644
index 0000000..a3e2867
--- /dev/null
+++ b/modules/cms/module.json
@@ -0,0 +1,5 @@
+{
+ "name": "CMS",
+ "type": "app",
+ "category": ["content"]
+}
diff --git a/modules/cms/services/Page.php b/modules/cms/services/Page.php
index ee74d2e..eea690d 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'],
@@ -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');
}
@@ -245,18 +369,94 @@ 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 = '{}'; }
+
+ $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' => json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
- ], $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('# 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');
+
+// The LOCKED chrome around the editable region (Elementor-style theme context). Two sources:
+// • a PARTIAL edited against a CMS layout supplies its own split chrome (controller: chromeBefore/
+// chromeAfter, cut at the partial's slot) — so a header partial edits at the top with the footer
+// below as context, a hero edits in the content area between real header + footer;
+// • everything else (a page, or a partial with no CMS layout) uses the theme's real public
+// header + footer.
+// #is', '##is'];
+$headerHtml = '';
+$footerHtml = '';
+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) {}
+}
?>
@@ -34,10 +64,26 @@ $jf = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JS
- themeName): ?>= $this->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.
-
-
-
-
-
No templates match.
+
+
+
+
+
+
+
Kind
+
Theme
+
Title
+
Handle
+
Status
+
Actions
+
+
+
@@ -102,20 +112,12 @@
-themeTemplates)): ?>
-
-
-
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..ab8da59 100644
--- a/tests/Integration/Controller/CoreControllerActionsTest.php
+++ b/tests/Integration/Controller/CoreControllerActionsTest.php
@@ -422,34 +422,39 @@ 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' => '= $this->content ?>',
+ 'title' => 'Region Layout',
+ // 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' => '
',
- '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)');
}
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/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..1ccc472 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,9 +49,11 @@
// 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);
+ registerUserBlocks(editor);
// Seed the canvas: prefer the lossless project blob, else import the body HTML.
try {
@@ -71,20 +73,36 @@
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');
+ // 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', editor.getHtml());
- 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);
+ // 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',
@@ -94,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'); })
@@ -102,7 +120,90 @@
}
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.
+ // 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).
+ 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'); }); }
+ // 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
+ // 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();
+ // 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) {
@@ -112,7 +213,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 = ''; }
@@ -143,10 +244,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 = '