Rendering Pipeline — Deep Dive
This document covers the complete server-side rendering pipeline used by Tagixo to turn a stored page tree into final HTML+CSS. It is intended for developers who need to understand, extend, or debug the renderer itself.
1. Full Call Chain
PublicPageController::show($slug)
└─ PageRenderer::renderWithLayout($page)
├─ resolveLayout($page) # picks page layout or global default
├─ reconstructTree($layout) # rebuilds Section/Row/Column/Module graph
│ └─ NodeFactory::fromArray() # hydrates each stored payload node
├─ [HTML pass]
│ ├─ renderSection($section)
│ │ └─ renderRow($row)
│ │ └─ renderColumn($col)
│ │ └─ renderModule($module) # dispatches to type registry
│ └─ renderSpecialSection() # header / footer / sliders
├─ [CSS pass]
│ └─ StyleGenerator::generate($tree)
│ ├─ walkNodes($tree) # depth-first, emits per-node rule blocks
│ └─ injectDynamicStyles() # appends after host stylesheet
└─ generateFontLinks() # Google Fonts <link> tags
Two-pass design. HTML and CSS are generated in separate passes. The HTML pass builds the DOM string with node IDs as data-vb-id attributes. The CSS pass walks the same tree and emits scoped rules keyed to those IDs. The two strings are assembled in the public.page Blade view.
2. StyleGenerator
Walk order
StyleGenerator performs a depth-first pre-order walk: Section → Row → Column → Module → sub-elements. Each node generates its own rule block before its children, so parent selectors always appear earlier in the stylesheet (relevant for cascade debugging).
Class naming strategy
Each node receives a deterministic, content-addressed class name:
tgx-{base36(crc32("{pageSlug}::{nodeId}"))}
pageSlugscopes the hash to the page, preventing cross-page collisions.nodeIdis the UUID stored in the builder payload.crc32is fast and produces a 32-bit integer;base36encoding keeps the class short (≤7 chars).- The same page+node always produces the same class, so the CSS cache key matches the HTML cache key.
Per-state rule emission
For each node, StyleGenerator emits up to four rule blocks in this order:
| State | CSS selector suffix | Notes |
|---|---|---|
default |
(none) | Always emitted |
hover |
:hover |
Skipped if no hover props set |
mobile |
Wrapped in @media (max-width: 768px) |
Skipped if identical to default |
sticky |
.is-sticky & |
Only for Section nodes with sticky enabled |
PropType integration
Each PropType implements:
toCss(value, prefix): string— returns a flat CSS string for simple properties.toCssWithSelectors(value, prefix): array— returns[selector => cssString]for props that need child selectors (e.g.,TypographyPropTypetargetingp,h1–h6).
StyleGenerator calls both methods and merges results into the rule block for the current node.
3. Font Collection
FontEmitterInterface
Any PropType that references a font implements FontEmitterInterface:
interface FontEmitterInterface
{
public function collectFonts(mixed $value): array; // returns FontDescriptor[]
}
FontDescriptor carries family, weights, and subsets.
Collection and deduplication
PageRenderer::generateFontLinks() walks the full node tree, calling collectFonts() on every FontEmitterInterface PropType. Results are merged by family name; weights and subsets are unioned. A single <link> tag per family is emitted.
Opting out
Set TAGIXO_GOOGLE_FONTS=false in .env. The renderer still collects font data internally (for CSS font-family declarations) but suppresses the <link> injection. Use this when self-hosting fonts or serving them via a CDN.
4. Module Render Dispatch
Registry lookup
Each module type registers under a string type ID (e.g., "text", "hero", "slider"). At render time:
$module = ModuleRegistry::resolve($node->type); // returns AbstractModule instance
$html = $module->render($node, $context);
AbstractModule::render() signature
abstract public function render(ModuleNode $node, RenderContext $context): string;
RenderContext carries:
$context->page— the currentPagemodel$context->layout— the resolvedLayoutmodel$context->globalVars— hydratedGlobalVariablecollection$context->renderMode—'public' | 'preview' | 'mail' | 'pdf'
Injected template variables
Inside module Blade templates the following variables are always available:
| Variable | Source |
|---|---|
$props |
Hydrated PropType values for this node |
$styles |
Generated CSS class name for this node |
$context |
Full RenderContext |
$globalVars |
Shorthand for $context->globalVars |
Graceful miss in production
If the registry has no handler for $node->type, the renderer logs a warning and returns an empty string in production. In APP_DEBUG=true mode it throws UnknownModuleTypeException to surface misconfiguration early.
5. CSS Specificity Strategy
Single-class baseline
All generated selectors use exactly one class: (0,1,0). This keeps specificity low and predictable, avoiding the cascade wars that arise from ID selectors or long class chains.
Child selectors
Selectors that target children (e.g., typography rules for p, a) add one element type qualifier: (0,1,1). Example:
.tgx-3k9f2a p { color: #333; }
Injection order
Generated CSS is injected after the host stylesheet (app.css) in the <head>. This means a single class from the generated sheet reliably wins over the host's reset/base styles without needing !important.
scopePrefix instead of !important
Some PropTypes (e.g., BackgroundPropType) accept a scopePrefix option that prepends an additional class to increase specificity by one level ((0,2,0)). Use this only for props that must win over third-party embeds. Never use !important in generated CSS — it makes overrides in custom_css impossible.
6. Global CSS Variables
Naming convention
All global variables are exposed as CSS custom properties on :root:
:root {
--tgx-primary: #0a6cf5;
--tgx-font-body: 'Inter', sans-serif;
}
The prefix is always --tgx-. Keys map directly to the key column in tgx_global_variables.
Usage in generated CSS
PropTypes that reference a global variable emit var() references instead of literal values:
.tgx-3k9f2a { color: var(--tgx-primary); }
This means changing a global variable takes effect across the entire site without regenerating any page HTML or CSS — only the :root block changes.
Cache invalidation gotcha
If you edit tgx_global_variables directly in the database (bypassing the admin panel), the render cache is not automatically invalidated. Run:
php artisan tagixo:cache:clear --global-vars
or flush the full render cache:
php artisan tagixo:cache:clear
The admin panel's GlobalVariableObserver handles invalidation automatically on saved and deleted events.
7. Render Cache
Cache key format
tagixo:render:{slug}:{SHA-256(serialized page tree + global vars snapshot)}
The SHA-256 component is computed from:
- The full serialized page payload (all nodes, props, and styles)
- A snapshot of all
tgx_global_variablesvalues
This means the cache key changes automatically whenever the page content or any global variable changes.
Automatic invalidation
TagixoPageSaved (fired by PageObserver) calls RenderCache::invalidate($page->slug).
GlobalVariableObserver::saved/deleted calls RenderCache::invalidateAll() — a global variable change potentially affects every page.
Manual invalidation
# Invalidate one page
php artisan tagixo:cache:clear --slug=home
# Invalidate all pages
php artisan tagixo:cache:clear
# Invalidate only the global-variable portion (re-primes :root block, keeps HTML)
php artisan tagixo:cache:clear --global-vars
Global-variable edge case
Because global variables are baked into the cache key hash, updating a variable will naturally produce a cache miss on the next request. However, if the cache store has a very high TTL and traffic is low, stale HTML might be served for a brief window. Set TAGIXO_RENDER_CACHE_TTL=0 to disable TTL-based expiry and rely entirely on event-driven invalidation.
8. Mail and PDF Rendering Differences
| Step | Public web | Mail (renderMode=mail) |
PDF (renderMode=pdf) |
|---|---|---|---|
| Layout resolution | Full layout (header + footer) | No layout; scaffold only | No layout; A4/letter scaffold |
| CSS delivery | <link> + <style> in <head> |
Inlined via Emogrifier | Inlined via Emogrifier |
| Width constraint | Fluid / container max-width | 600 px hard clamp | Page width from config |
| JS output | Full (<script> tags allowed) |
Stripped entirely | Stripped entirely |
| Google Fonts | <link> injected |
@import converted to <style> |
Omitted; PDF engine uses system fonts |
CSS inlining (Emogrifier)
For mail and PDF contexts, StyleGenerator passes its output through Pelago\Emogrifier\CssInliner. This converts rule blocks to inline style="" attributes. Pseudo-class rules (:hover, :focus) are discarded since email clients do not support them.
Gating module script output
Modules that emit JavaScript must check render mode before outputting <script> tags:
if ($context->renderMode === 'public') {
echo '<script>/* module JS */</script>';
}
The RenderContext::isInteractive() helper is a convenience alias for this check.
9. Custom Render Hooks
Events
| Event class | Fired | Payload |
|---|---|---|
TagixoRenderStarted |
Before HTML pass | $page, $layout, $context |
TagixoRenderCompleted |
After both passes | $page, $html, $css, $context |
CssGenerated |
After CSS pass | $css (mutable string), $tree |
Listen in a service provider:
Event::listen(TagixoRenderCompleted::class, function ($event) {
// post-process $event->html or $event->css
});
ModuleRenderOverride chain
To override a single module's output without replacing the registry entry, register a handler on ModuleRenderOverride:
Event::listen(ModuleRenderOverride::class, function ($event) {
if ($event->node->type !== 'my-module') return;
$event->setHtml('<div class="my-override">...</div>');
$event->stopPropagation();
});
Multiple listeners are called in priority order. The first listener to call setHtml() wins; subsequent listeners are skipped if stopPropagation() was also called.
CssGenerated CSS post-processing
Use the CssGenerated event to append or transform the generated stylesheet:
Event::listen(CssGenerated::class, function ($event) {
$event->css .= "\n/* custom appended rule */\n.my-class { color: red; }";
});
This runs after StyleGenerator finishes but before the CSS is written to the render cache, so the mutation is cached along with the rest of the output.