glueful / thallo-render
Rendered delivery for Thallo: server-rendered pages from published content via filesystem Twig themes, as a removable capability pack.
Requires
- php: ^8.3
- glueful/framework: ^1.65.2
- glueful/thallo-contracts: v1.0.0-beta.22
- glueful/thallo-tenancy: v1.0.0-beta.22
- twig/twig: ^3.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-12 23:39:26 UTC
README
Rendered delivery for Thallo — the CMS serves real HTML pages
from published content through filesystem Twig themes — packaged as a removable
capability pack (V2 rendered-delivery sub-project 2; see docs/internal/V2_DESIGN.md). With the
pack absent or thallo.render disabled, the install is exactly the headless product:
unmatched public paths return the router's standard JSON 404.
How a page renders
One lowest-priority catch-all (GET /{path} in the router's * bucket — every
static route and literal-prefix bucket wins first) hands raw paths to the
PublicRouteResolver contract, which core implements over the existing routing/
addressability layer:
- Normalization first: trailing/duplicate slashes 301 to the canonical path before any lookup.
- Parse against the route template —
/{locale}/{type}/{slug}when the first segment is an active locale,/{type}/{slug}in the default locale (/blog/hellois always typeblog, never localeblog). - Visibility: render is anonymous — non-public-delivery types resolve
not_foundeven with a live route. - Resolve: redirects honored as real 30x, broken redirect targets are 410,
published entries return the read-only public delivery shape (
seoincluded — byte-identical to the headless API) plus the content-type slug for template selection.
Reserved paths (render.reserved_prefixes — segment semantics — and
reserved_exact) return the framework's standard JSON 404 so API clients never receive
themed HTML.
Themes
A theme is themes/{name}/ with theme.json, templates/, assets/. The pack embeds
the default reference theme; an app theme overrides it by name
(render.theme, env RENDER_THEME) with per-template fallback — omit
404.twig and the default theme's serves. Ladder: missing app theme → default; present
but invalid theme.json → loud 500; broken pack default → hard 500; template missing in
both → error.twig → plain-text 500 (never a loop).
The default theme is a modern-SaaS reference: token-driven CSS (colors,
spacing, radius, fluid type) with automatic dark mode, a sticky translucent
header, full-width flow with per-block containers, and NAMESPACED shell
classes (.site-header/.site-nav/.site-footer) so block templates'
own header/nav/footer elements never inherit shell styling. Presentation is a
LAYERED contract: templates consume one composed presentation context
(show_title, layout) resolved as per-page override → theme.json
per-type setting → theme.json default → built-ins (show_title: true,
layout: 'centered'). Themes declare defaults in a strict settings
block ({"settings": {"layout": "full", "types": {"pages": {"show_title": false}}}} — unknown keys are rejected loudly); editors override per page
from the canvas inspector's Page tab. The override lives under the
reserved _presentation key in the draft's fields — it versions,
previews, and publishes WITH the page but is never public content:
the delivery API strips it from every payload, and schema field names may
never start with _ (reserved for system keys). layout maps to a
layout--* class on <main>; band blocks render edge-to-edge under
full and as contained cards under centered.
Hierarchy: entry/{type-slug}.twig → entry.twig; index.twig (homepage);
404.twig; error.twig; layout.twig. Context: entry (treat as read-only), site
(name/locale), and functions:
menu('main')— via theMenuReadercontract;[]when thallo-navigation is absent or disabled (no hard dependency).path(entryUuid)— live public path, null unless published (no dead links, ever).asset('css/site.css')—/theme-assets/...; rejects absolute URLs,.., leading/, backslashes. Only the active theme'sassets/is served — never templates.
Escaping: the reference theme escapes everything — it cannot know that a field named
body is sanitized rich text. |raw is a deliberate opt-in for theme authors who know
their schema's rich-text fields.
Twig compiles to storage/cache/twig/{theme} with auto_reload (recompiles on template
change). The active theme is resolved at boot (v1): changing render.theme
requires an app restart / extension-cache rebuild.
Theme runtime
Behavioral JS is package-owned, not theme-owned: one runtime.js
(packages/thallo-render/runtime/) carries the ThalloRuntime module registry
(color-mode, forms, carousel, navigation, tabs) and is served at
/_thallo/runtime/runtime.js — a stable, uncached logical alias that 302s to the
current content-fingerprinted runtime-<fp>.js, served with
Cache-Control: public, max-age=31536000, immutable (unknown or stale fingerprints
404, so one immutable URL always identifies one byte sequence). The default
layout.twig loads it via the runtime_script() Twig function; themes own
presentation (CSS) only. The default theme's assets/blocks.js is a temporary
behavior-free compatibility loader for already-cached HTML that still references
asset('blocks.js'); ThemeCloner deliberately never seeds it into clones of the
pack default.
Custom-theme migration (theme-runtime spec §2.4): a theme that copied the whole
assets directory keeps working unchanged — a copied layout.twig keeps loading the
copied blocks.js (old behavior, frozen at copy time), and a theme that does NOT
override layout.twig picks up the package runtime automatically through the
default layout fallback. To adopt the runtime in a copied theme, delete the copied
blocks.js and drop its <script> tag from the copied layout.twig (load
{{ runtime_script() }} there instead).
Per-block runtime assets. block_script(name) is a second, block-scoped lazy-load
channel alongside runtime_script(): a block template calls it to emit
<script defer src="/_thallo/runtime/block-{name}.js"></script> for exactly the two
names in the closed catalog (RenderContextExtension::BLOCK_SCRIPT_ASSETS —
animated-text, gallery); any other name — including path-traversal shapes or an
empty string — resolves to empty markup, so block_script() can never be coerced into
pointing at an arbitrary path. Emission dedupes once per render per name, but that
dedupe is a bandwidth optimization only, not the correctness mechanism: the real
guard is each asset's own IIFE (a window.__thalloBlock* flag) that registers with
the runtime exactly once and tolerates re-execution. That distinction matters because
fragment renders — e.g. EntryBlocksRenderer::renderPublishedBlocks(), the route-less
seam that lets a pack embed one entry's blocks region inside its OWN page render (the
shop product-detail page's enrichment is the first caller) — reset block_script()'s
per-render state independently before rendering the fragment, and the fragment's HTML
is then spliced into the enclosing page. So a single delivered page CAN legitimately
end up with the same <script> tag more than once in its final HTML — each execution
after the first is a cheap guard check, never a duplicate registration.
Theme runtime elements
Four custom elements — thallo-carousel, thallo-tabs, thallo-navigation,
thallo-color-mode-toggle — wrap the same runtime modules the starter blocks
use. Contract:
- Elements are light-DOM adapters — the inner skeleton is the same one the starter blocks use and IS the no-JS fallback.
- Custom themes that copied
blocks.css/navigation.cssmust re-copy (or port) the alias rules for element support. - Asynchronously-populated elements must be fully built before insertion.
- The runtime must be loaded with
defer(or otherwise after parse) — a synchronous head-script<script>connects elements before their children exist in the DOM, the element's structural check (e.g. the navigation element's drawer lookup) fails, and the element stays un-enhanced.
Attribute sugar (e.g. arrows/dots/autoplay on thallo-carousel,
reveal-hover on thallo-navigation) maps to the existing data-* options
and never overrides an explicit data-* already present in the markup. The
color-mode toggle is hidden (display: none) whenever the color-mode
feature is off. As elsewhere in this section: behavior JS is package-owned
(runtime.js), themes own CSS only.
thallo-carousel — viewport/track/slides is the real no-JS floor
(scroll-snap); arrows/dots/autoplay sugar to data-arrows/data-dots/
data-autoplay:
<thallo-carousel arrows dots> <div class="thallo-block-carousel__viewport"> <div class="thallo-block-carousel__track"> <div>Slide one</div> <div>Slide two</div> <div>Slide three</div> </div> </div> </thallo-carousel>
thallo-tabs — radios + labels + panels is the real no-JS floor
(checked-sibling CSS); ids follow the tabs-{id}-N pattern the starter
block emits (tabs-{{ block.id }}-{{ loop.index }}) and name scopes each
instance so multiple tab groups on one page stay independent — reuse any
stable string for {id}:
<thallo-tabs> <input class="thallo-block-tabs__radio" type="radio" name="tabs-demo" id="tabs-demo-1" checked> <input class="thallo-block-tabs__radio" type="radio" name="tabs-demo" id="tabs-demo-2"> <div class="thallo-block-tabs__list"> <label class="thallo-block-tabs__label" for="tabs-demo-1">One</label> <label class="thallo-block-tabs__label" for="tabs-demo-2">Two</label> </div> <div class="thallo-block-tabs__panels"> <div class="thallo-block-tabs__panel">Panel one content.</div> <div class="thallo-block-tabs__panel">Panel two content.</div> </div> </thallo-tabs>
thallo-navigation — the drawer <details data-thallo-enhance="navigation">
is the real no-JS floor (native disclosure); the module enhances that inner
details, not the element root:
<thallo-navigation> <nav class="thallo-block-navigation__nav" aria-label="Navigation"> <details class="thallo-block-navigation__mobile" data-thallo-enhance="navigation"> <summary class="thallo-block-navigation__hamburger"> <span class="thallo-block-navigation__hamburger-icon" aria-hidden="true"></span> <span class="thallo-block-navigation__hamburger-label">Menu</span> </summary> <ul class="thallo-block-navigation__list"> <li class="thallo-block-navigation__item"> <a class="thallo-block-navigation__link" href="/">Home</a> </li> <li class="thallo-block-navigation__item"> <a class="thallo-block-navigation__link" href="/about">About</a> </li> </ul> </details> </nav> </thallo-navigation>
thallo-color-mode-toggle — server-rendered [data-color-mode-set]
buttons are the real no-JS floor (they render, but do nothing until JS wires
the click, so JS-off leaves inert buttons rather than broken markup); this
element is the one pipeline exception (no registerElement entry) — clicks
already ride the page-level delegated handler, so it only re-syncs
late-inserted toggles' aria-checked on connect:
<thallo-color-mode-toggle role="radiogroup" aria-label="Color mode"> <button type="button" class="thallo-block-color_mode__option" data-color-mode-set="light" role="radio" aria-checked="false" aria-label="Light">☀</button> <button type="button" class="thallo-block-color_mode__option" data-color-mode-set="system" role="radio" aria-checked="false" aria-label="System">◻</button> <button type="button" class="thallo-block-color_mode__option" data-color-mode-set="dark" role="radio" aria-checked="false" aria-label="Dark">☾</button> </thallo-color-mode-toggle>
A real-Chromium smoke gate for these four elements (upgrade, option
projection with native .dataset reflection, marker ownership,
disconnect/reconnect, boot ordering, and computed no-JS display) lives in
tools/runtime-browser — run cd tools/runtime-browser && npm install && npx playwright install chromium && npm test; see that package's README.md.
Homepage
GET / always renders index.twig. Set render.homepage_entry (env
RENDER_HOMEPAGE_ENTRY) to put that entry in the context; unset renders the standalone
welcome. A set-but-unresolvable value (missing/unpublished/routeless/deleted) is a
500 config error — logged always, message in the body only under debug mode.
Listing & archive pages
Allowlisted types get a rendered listing at /{type} and term archives at
/{type}/{field}/{term} (the field must be a filterable reference; membership
comes from the same published-reference projection as the delivery archive
endpoint). Pagination is path-based — /{type}/page/2 — because the render
cache is keyed by path; /page/1 301s to the bare path. page is a reserved
word as an archive field segment. Templates: listing/{type}.twig →
listing.twig, archive/{type}.twig → archive.twig; context ships items
(each with a ready href; null = routeless), pagination
(prev_path/next_path precomputed), and for archives term + field.
Cached pages carry the broad thallo:type:{type} surrogate tag, so ANY publish
of the type purges every listing page immediately.
Term INDEX pages live at /{type}/terms/{field} — every term of the field with its
count, each linking to its archive page (500-term cap, no pagination). terms is a
reserved word alongside page: an archive field literally named terms cannot have
rendered archive pages (entries slugged terms are unaffected — the reservation only
applies at three segments). A valid field with zero terms renders an empty index;
unknown/non-filterable fields render the themed 404.
| Key (env) | Default |
|---|---|
render.listing_types (RENDER_LISTING_TYPES, comma-separated) |
'' — feature dormant |
render.listing_per_page (RENDER_LISTING_PER_PAGE) |
10 |
Preview in the theme
GET /_preview/{token} renders a draft (or pinned version) through the active theme —
the signed token from POST /v1/admin/entries/{uuid}/preview/{locale} is the only
credential (its response's theme_url carries this path; null = capability off).
Responses are Cache-Control: no-store + X-Robots-Tag: noindex, never enter the
page cache, and carry a preview flag templates can read (the default theme shows a
banner). Preview content has NO entry.seo object. All token failures render the
themed 404.
Opening a preview also starts a short-lived preview session (a signed cookie that
expires with the token): navigation stays in preview chrome (banner with an Exit
link, no-store, noindex, never cached), your draft — or, when the canvas has
applied unsaved work, its working copy — appears at its own URL and at / when
the entry is the configured homepage, and every other page shows published
content. GET /_preview/exit ends the session.
Minting accepts an optional theme (validated against installed themes, signed into
the token): the whole session renders through that theme, with assets served from the
token-scoped /_preview-assets/{token}/… route. Sessions work with the page cache
disabled; junk cookies never bypass the cache.
DB-edited templates
Admins with templates.manage can override any template the active theme resolves —
or create new hierarchy templates (entry/interview.twig) that don't exist on disk —
from the admin Templates screen. Overrides are stored per theme with append-only
version history (restore any version; deleting an override falls back to the
filesystem and keeps history). Saves go live immediately: every save is
statically policy-checked (allowlisted tags/filters/functions/tests, constant
include/extends targets, no method calls, no raw) with line-numbered errors, and
checked again at compile time, so rows written around the API never execute. There is
no runtime sandbox — enforcement is the AST scan plus the arrays-only render context.
RENDER_DB_TEMPLATES=false is the ops kill-switch (pure filesystem rendering, admin
routes off). Active-theme saves purge the page cache; per-preview themed sessions see
that theme's overrides, so you can author against an inactive theme and preview it.
Contributed pack templates (thallo-account, thallo-commerce) appear in the admin
Templates screen alongside theme templates, with origin package, and override
exactly the same way — the resolution order is db → theme → package → default.
Disabling a pack removes its baselines from the catalog while any existing DB
overrides of those paths stay listed and live; deleting such an override then falls
back to nothing (no filesystem baseline exists once the pack is disabled). Two
templates — blocks/html.twig and blocks/shortcode.twig — are permanently
disk-only: the admin renders them read-only with an explanatory reason, per the
closed allowlist in TemplatePolicy::DISK_ONLY_TEMPLATES.
Blocks in templates
blocks(entry.fields.body) renders an ordered blocks-field value through the template
hierarchy blocks/{type}.twig (theme file or DB-edited template — both work). Each
block template receives { block, data, entry, index }. Missing templates render an
HTML comment in production and a visible placeholder in debug, logged once per type.
Containers nest via {{ blocks(data.region) }} up to 3 levels; deeper data renders
nothing. Reference values inside data arrive expanded (published item:
data.post.fields.title, path(data.post.entry_uuid); null when the target is
unpublished or gated; raw uuid only at the expansion-depth cap). Asset values stay
raw blob uuids for media(). Pages embedding expanded targets carry the target's
thallo:entry:{uuid} cache tag, so they purge when the target republishes.
php glueful thallo:blocks:seed (alias blocks:seed) seeds the starter block types
(Layout/Content/Media) with matching default-theme templates — idempotent, skips any
existing slug, never overwrites admin edits. Media blocks render through media(uuid)
(public, anonymously retrievable blobs only — set UPLOADS_ACCESS=upload_only or
public; private/gated blobs render nothing). Link fields render through the
safe_url filter (relative, https, http, mailto only). Style enums map to
thallo-block-{slug}--{value} modifier classes — restyle by targeting them. The
starter styling ships standalone as assets/blocks.css: block TEMPLATES fall back
to the pack default per-template, but assets don't — a custom theme adopts the
starter blocks by copying (or rewriting) that one file.
Hero slider. A carousel block with style: hero and one or more hero blocks
as its slides is a recipe, not an enforced pairing — the carousel's slides field
places no type restriction on its children. Each slide's heading level comes from that
slide's own hero.heading_level (h1/h2/h3) exactly as it would standalone; author
every slide as h2 — a page should carry exactly one <h1> — unless the slider is the
page's sole hero (nothing else on the page renders or contains an <h1>), in which case
the FIRST slide alone may use h1. The thallo-block-carousel--hero modifier is
presentation only: one full-bleed slide per view, the slide's __wrapper/__media
stacked into the same grid cell with a scrim between them, on-media text tokens, and a
background fallback on any slide with no image; mechanics (drag, dots, arrows,
autoplay, the scroll-snap no-JS floor) are unchanged, inherited from thallo-carousel.
animated_text authoring. rotate_words is newline-delimited — one alternative per
line, blank lines dropped, CRLF/CR normalized to LF (the exact contract implemented by
FieldValidator::normalizeRotateWords() and consumed identically by the template) —
and capped at 5 normalized alternatives, enforced at save: a 6th alternative is
rejected with an error on {path}.rotate_words, never silently truncated. Once
revealed, block-animated-text.js runs ONE finite rotation cycle (1s per step) and
settles on the LAST alternative — it never loops. The static floor — server output
with no JS, prefers-reduced-motion: reduce, or an environment lacking
IntersectionObserver — renders prefix + the FIRST alternative + suffix as plain
visible text; CSS reserves width for the widest alternative via a same-cell grid
stack, but only the active one is ever visible.
Gallery. items is a blocks field hard-restricted to image children
(enforce_block_types: true — anything else is rejected at save, never silently
dropped). At render, each item resolves through media_image() before an anchor is
built, so unresolved or non-image assets are omitted from the grid entirely — no dead
anchors. lightbox defaults to on (data-lightbox="1"); set it to false to opt out
(data-lightbox="0"). The no-JS floor — and the fallback under any lightbox failure
(unsupported <dialog>, or a construction/showModal throw) — is real anchors
pointing straight at the full-size image blob: block-gallery.js only ever intercepts
a click to show that same image in a <dialog>, and lets the click fall through to
normal navigation whenever it can't.
Canvas annotation (preview only): every preview-session render wraps each
blocks() instance in a layout-inert <div class="thallo-preview-block" data-thallo-block="{id}"> carrier (display: contents from the static
/_preview.css) and injects the token-free /_preview-bridge.js — the visual
canvas maps DOM to block ids through it. Live renders carry neither. Shape
limit: block templates that must be literal children of semantic containers
(ul > li, table > tr) are not compatible with canvas annotation; Thallo blocks
are page/layout fragments, so no starter block is affected.
In a canvas session the bridge also renders a small toolbar on the selected
block (drag to reorder, move up/down, duplicate, delete, add block after). The toolbar posts
intents to the admin canvas; the block tree is mutated there, and the canvas
answers with mirror commands that update the preview DOM optimistically until
the next Save & refresh re-renders the truth. All toolbar styling lives in the
static /_preview.css (never inline styles); the toolbar is positioned by DOM
placement inside the selected block's first element, so blocks whose templates
render no element (text-only output) get selection but no toolbar.
While dragging, a compact ghost of the block follows the cursor and the
page auto-scrolls when the pointer nears the viewport edges.
The selected block also answers the keyboard: Alt/Option+Arrow moves it,
Backspace/Delete asks the admin's delete confirm, Cmd/Ctrl+D duplicates,
Enter opens in-place editing when the block has exactly one editable region
of its own (a container's child-block regions don't count — the same rule
the wrapper-level double-click uses), and Escape deselects. Shortcuts stay
inert while editing in-place, while dragging, and while focus sits in the
toolbar or the theme's own form fields.
With the admin's Apply action, the preview session can also render the
editor's unsaved working tree: the app validates and stashes it (cache-only,
TTL-bounded) and the whole session overlays it over the draft — the
/_preview/{token} stage, the entry's canonical URL, and the homepage when
the entry is the configured homepage entry. Version-pinned previews are never
overlaid.
Prose blocks (the exactly-one-rich-text convention) are also editable
in-place: annotated renders wrap the sanitized rich-field output in a
.thallo-edit-region marker (emitted by safe_html itself, only for prose
blocks), and double-clicking one in the canvas turns it into a plain
contenteditable whose text flows back to the admin's block tree. Typed HTML
is sanitized at save and re-sanitized by safe_html at render.
While a rich session is active, selecting text shows a small formatting
bubble over the selection (bold, italic, underline, strikethrough,
link/unlink — buttons light up when the caret already carries the mark);
the bridge normalizes ALL rich-region output (bar actions,
native Cmd+B/Cmd+I, paste) into the sanitizer's allowlist shape
(strong/em/u/s, no styled spans) before it
flows back, so formatting survives save and re-render. Links are added
through an inline panel in the bubble (TipTap-style, no browser prompt);
URLs are validated against the safe_url posture before they're applied,
and the edit session survives focus moving into the panel.
Applies are automatic by default: the admin re-applies the working tree on a short debounce after edits (suppressed while typing in-place) and restores the stage's scroll position across reloads. An Auto toggle beside Apply turns this off per browser; failures pause it until a manual Apply succeeds. Successful applies update the stage in place when the change is provably confined to block wrappers (a real re-render is fetched and compared — never a client-side guess); anything else, including added or removed blocks and theme-shell changes, falls back to a full reload.
Plain string/text fields join in via the opt-in |editable_text filter:
{{ data.heading|editable_text('heading') }} marks the value's rendered
location (annotated renders only; live output is byte-identical to the plain
emission). Apply it ONLY to whole-element text emissions — never inside HTML
attributes (alt, href), where it would emit broken markup in preview —
and keep existing {% if %} guards: a conditionally omitted field stays
inspector-first. The admin validates every edit against the block schema, so
a mistyped field name simply never becomes editable.
Changing block schemas: additive edits (new fields, retypes) are free via
PATCH /block-types/{slug}; renaming or deleting a field is a declared migration
(POST /block-types/{slug}/migrations with {ops:[{op:"rename",from,to}|{op:"delete",name}]})
— the schema flips immediately and a queued backfill rewrites every current draft
and publication (saves/publishes of entries containing the type 409 until it
completes; re-drive a failed run with php glueful thallo:blocks:migration:backfill {uuid}). Version rollback re-projects block data through migrations that postdate
the version. An UNUSED type can be hard-deleted (DELETE /block-types/{slug};
usage via GET /block-types/{slug}/usage) — deactivation remains the everyday
path.
Rich HTML in templates
format: rich text fields are sanitized SERVER-SIDE on save (TipTap-scoped
allowlist — no scripts, event handlers, unsafe schemes, images, or tables) and
templates render them with {{ value|safe_html }}, which re-sanitizes at output
(defense-in-depth) and falls back to escaped text if no sanitizer is bound. Never
use |raw on content fields.
Facet counts in templates
facets('post', 'category', limit = 100) returns [{uuid, slug, count}, …] (count
DESC) for filterable reference fields — [] on any gate failure, so templates never
break. Pages using it are automatically tagged with both type tags, so counts purge
event-driven like everything else.
Config
| Key (env) | Default |
|---|---|
render.theme (RENDER_THEME) |
default |
render.homepage_entry (RENDER_HOMEPAGE_ENTRY) |
'' |
render.site_name (RENDER_SITE_NAME) |
Thallo |
render.reserved_prefixes |
v1, admin, extensions, theme-assets |
render.reserved_exact |
sitemap.xml, robots.txt |
render.cache_enabled (RENDER_CACHE_ENABLED) |
true |
render.cache_ttl (RENDER_CACHE_TTL) |
3600 |
Page views are deliberately not rate-limited (this is the whole-site surface, not an API); the abuse posture is the page cache below — bogus paths can neither fill the cache nor re-render templates.
Page caching
Rendered pages are cached full-page in the framework CacheStore, keyed
render:{theme}:{normalizedPath}. Only 200 responses with Content-Type: text/html are cached per path. The themed 404/410 bodies are stored once per
theme (render:{theme}:404 / render:{theme}:410) and checked BEFORE the
template renders, so unique bogus URLs cost only the resolver's indexed queries —
they can neither fill the cache nor re-render 404.twig. Redirects, JSON
responses, and errors are never cached.
| Env | Default | Meaning |
|---|---|---|
RENDER_CACHE_ENABLED |
true |
false = uncached SSR (set in dev while theming) |
RENDER_CACHE_TTL |
3600 |
safety-net TTL per entry; tags do the real invalidation |
Invalidation. Every cached page is tagged with the same surrogate keys the
delivery API uses (thallo:entry:{uuid}, thallo:type:{slug}) plus
thallo:render:page. Publishing, unpublishing, or deleting content purges the
affected pages through the engine's existing cache listener — no render-specific
wiring. Menu changes (MenuUpdated) purge every cached page. Theme FILE edits
are not event-visible: run php glueful render:cache:clear (or wait out the
TTL). A theme NAME switch needs no purge — the theme is part of the key.
Non-tag cache drivers. If the configured cache driver does not support tags
(e.g. the file driver), targeted purges become no-ops and the page cache
degrades to TTL-only freshness: entries still store and expire by
RENDER_CACHE_TTL, and php glueful render:cache:clear remains the manual
escape hatch. A tag-capable driver (Redis) is recommended for production render
caching.
Install / remove
Bundled by default in the Thallo create-project template. Existing app:
composer require glueful/thallo-render, ./thallo extensions:enable thallo-render.
Disable via the switchboard ('capabilities' => ['thallo.render' => false]) or remove —
the headless product is untouched.
Out of scope (v1 — see V2_DESIGN §6)
Taxonomy term INDEX pages (/{type}/{field} enumerating all terms), DB-edited
templates, page/block builder, admin theme/homepage switching UI, full-site preview
navigation (links on a preview page lead to published pages). Per-page TTL overrides
and stale-while-revalidate are deferred with them (render caching spec §8).