Architecture

Rendering Pipeline

Trace how a saved JSON structure becomes rendered HTML — layout resolution, CSS generation, font injection.

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}"))}
  • pageSlug scopes the hash to the page, preventing cross-page collisions.
  • nodeId is the UUID stored in the builder payload.
  • crc32 is fast and produces a 32-bit integer; base36 encoding 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., TypographyPropType targeting p, h1h6).

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 current Page model
  • $context->layout — the resolved Layout model
  • $context->globalVars — hydrated GlobalVariable collection
  • $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:

  1. The full serialized page payload (all nodes, props, and styles)
  2. A snapshot of all tgx_global_variables values

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.