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.

+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+ + +
+
+
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/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('#]*/?>#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 3e08c66..97ec7b4 100644 --- a/modules/cms/views/scripts/page/design.phtml +++ b/modules/cms/views/scripts/page/design.phtml @@ -11,6 +11,36 @@ $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'); + +// 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
Tiger Builder +partialMode)): ?> + escape($this->fragmentLabel ?: 'Partial') ?> + escape($this->title ?: 'Untitled') ?>
+partialMode) && !empty($this->layoutOptions)): ?> + + + + Settings Exit @@ -53,8 +99,14 @@ $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) ?>, + 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..e8a376d 100644 --- a/modules/cms/views/scripts/page/index.phtml +++ b/modules/cms/views/scripts/page/index.phtml @@ -25,9 +25,9 @@ - themeTemplates)): ?> + themeCount)): ?> @@ -53,6 +53,7 @@ +
@@ -82,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
@@ -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' => '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' => '
content ?>
', 'format' => Tiger_Model_Page::FORMAT_PHTML, '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)'); } 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 = '
' + inner + '
'; } } }); @@ -194,6 +335,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 @@ -231,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. 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/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]
+ +
+
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');