Rendering and Preview Workflow
The rendering pipeline converts stored JSON structures into HTML+CSS payloads. It is deliberately multi-branch: form content, page content, and carousel content each take distinct code paths, and the output contract differs between live public requests, builder preview requests, and offline batch jobs. Understanding which path applies to your use case — and what each path produces — is essential before you start customising modules or wiring up any caching strategy.
Input Contract
Every render call requires three inputs:
| Field | Type | Description |
|---|---|---|
structure |
object | The { "body": {...}, "components": [...] } payload from tgx_pages.content or equivalent |
context |
string | page or form — selects the render branch |
layout_variant |
string | default, carousel, or any registered variant — refines the branch within a context |
entity_type |
string | Optional. page, mail, or pdf — required for preview-URL generation and model-aware helpers |
entity_id |
int | Optional. The primary key of the entity being rendered; used in preview-URL signing |
The structure field is always shaped as:
{
"body": {
"sections": [
{
"id": "s1",
"type": "section",
"props": {},
"_styles": {},
"children": []
}
]
},
"components": [
{ "type": "heading", "label": "Heading" }
]
}
body carries the actual node tree (sections → rows → columns → modules). components is a registry of available module types used by the builder UI; it is ignored by the server-side renderers.
Gotcha: never strip
componentsfrom the payload before posting. Some render branches inspectcomponentsto resolve module metadata even on the server side. Send the full payload as the builder produced it.
Main Render Entry Point
The HTTP entry point is POST /tagixo/builder/render, handled by BuilderApiController::render(). The controller validates the request, then delegates everything to:
BuilderApiService::render(
array $structure,
string $context,
string $layoutVariant,
string $entityType,
int $entityId
): array // { html: string, css: string, fonts: string[] }
The service selects a renderer based on context and layoutVariant and calls renderFromJson() on the chosen renderer. The return value is always the same envelope — html, css, and optionally fonts — regardless of which branch ran.
Render Branch Selection
Branch selection happens in BuilderApiService::render(). The same payload can produce structurally different output depending on context and variant:
POST /tagixo/builder/render
|
v
BuilderApiService::render($structure, $context, $layoutVariant, ...)
|
+-- $context === 'form' -----------------------> FormPreviewRenderer::renderFromJson()
|
+-- $context === 'page'
| + $layoutVariant === 'carousel' -------> SliderPreviewRenderer::renderFromJson()
|
+-- (default) ---------------------------------> PageRenderer::renderFromJson()
|
v
{ html, css, fonts }
This is not a cosmetic distinction. A form payload run through PageRenderer will produce broken output even if the JSON is structurally valid, because form modules rely on form-context wrappers that PageRenderer does not emit. Branches are non-interchangeable.
PageRenderer — Step-by-Step
PageRenderer::renderFromJson(array $structure): array is the default path for all page-like content. It executes in this sequence:
1. Structure Normalisation
The raw JSON is passed through a normaliser that fills in missing defaults — node IDs, empty _styles maps, and missing props keys. This is a defensive step: it means partially authored content (e.g., a module that was added but never styled) renders without fatal errors.
2. Node Hierarchy Rebuild
The flat body.sections array is converted into a tree of PHP value objects (SectionNode, RowNode, ColumnNode, ModuleNode). Each node carries its type, props, _styles, and a children collection. This tree is what Blade views receive.
3. Module Rendering via Blade Partials
Each ModuleNode is dispatched to its Blade partial using its type field. The renderer resolves the view path as:
tagixo::modules.{type}.render
For example, a heading module resolves to resources/views/vendor/tagixo/modules/heading/render.blade.php in the consumer app if overridden, or to the equivalent view inside the package otherwise.
Every module partial receives these variables:
| Variable | Type | Description |
|---|---|---|
$module |
ModuleNode |
The current module node |
$props |
array | The module's props — content fields (text, URL, toggle, etc.) |
$styles |
array | The module's _styles — raw style data before CSS generation |
$cssClass |
string | The generated CSS class name for this node |
$globalVariables |
array | Resolved global variables (colours, fonts, spacing tokens) |
$context |
string | page or form — the render context |
Sections and rows follow the same pattern using tagixo::layout.section.render and tagixo::layout.row.render. Columns are rendered inline by the row partial rather than as a separately dispatched view.
4. CSS Generation via StyleGenerator
After the HTML tree is rendered, StyleGenerator processes every node's _styles map and emits a single concatenated CSS string. This string is returned in the css field of the render response.
Class naming strategy: each node gets a unique deterministic class derived from its ID: .tgx-node-{nodeId}. Sections additionally receive a .tgx-section-{nodeId} class used by layout-level rules. This guarantees per-node specificity without inline styles and without collision between two nodes that use the same module type.
Specificity: all generated rules are single-class selectors (.tgx-node-abc123 { ... }). The exception is sub-element selectors, which add one level: .tgx-node-abc123 .tgx-node-abc123__button { ... }. This intentionally avoids high-specificity chains so that global CSS overrides remain feasible.
Responsive breakpoints via @container: Tagixo uses CSS Container Queries, not media queries, for responsive variants. Each section is a containment context. When a node has responsive overrides for tablet or mobile, StyleGenerator emits:
@container vb-canvas (max-width: 1024px) {
.tgx-node-abc123 { font-size: 1rem; }
}
@container vb-canvas (max-width: 768px) {
.tgx-node-abc123 { font-size: 0.875rem; }
}
The breakpoint thresholds are configurable via config/tagixo.php under responsive.breakpoints. The containing element receives container-type: inline-size; container-name: vb-canvas from tgx-layout.css.
Important: responsive overrides are additive — a mobile override only needs to specify the props that differ from the base. The base styles are always written first, followed by the container-query blocks. Do not replicate base props inside overrides; it inflates the CSS payload.
PropType-level CSS generation: individual PropTypes (e.g., SpacingPropType, BorderPropType, TypographyPropType) each implement a toCss(array $value, string $selector): string method. StyleGenerator calls each registered PropType for every node, concatenates the results, and deduplicates identical declarations. Custom PropTypes plugged in via TagixoServiceProvider participate in this loop automatically.
5. Return Envelope
renderFromJson() returns:
[
'html' => '<div class="tgx-page-body">...</div>',
'css' => '.tgx-node-abc123 { ... } .tgx-node-def456 { ... }',
'fonts' => ['https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap'],
]
The fonts array contains fully-qualified <link> URLs (not <link> tags) ready for injection.
Font Injection — PageRenderer::generateFontLinks()
Font links are generated by scanning the rendered node tree for components that carry a font prop (primarily TypographyPropType fields referencing Google Fonts or custom font families). The method:
- walks every node in the rendered tree
- extracts any
fontFamilyvalue that resolves to a web font (rather than a system stack) - normalises font variant lists — if the same family appears at weight 400 in one node and 700 in another, they are merged into a single
family=Inter:wght@400;700query parameter - deduplicates across header, body, and footer so that loading the same font twice does not happen when a page layout and page body both use the same typeface
- returns the deduplicated array of URL strings
In a Blade template, inject the links as:
@foreach ($fonts as $fontUrl)
<link rel="stylesheet" href="{{ $fontUrl }}">
@endforeach
PublicPageController passes the fonts array to the public.page view which handles this injection. If you bypass the controller (e.g., batch export), call generateFontLinks() yourself:
use Ccast\Tagixo\Services\PageRenderer;
$renderer = app(PageRenderer::class);
$result = $renderer->renderFromJson($structure);
$links = $result['fonts']; // array of URL strings
Mail merge-tag caveat:
generateFontLinks()does not filter for mail-safe fonts. In the mail render pipeline, callMailRenderer::safeGenerateFontLinks()instead — it strips web fonts not supported by major email clients and falls back to system-font stacks.
Public Page Layout Assembly
When serving a live public page, PublicPageController::show() calls PageRenderer::renderWithLayout(Page $page) rather than renderFromJson() directly. This method adds layout-aware header and footer assembly:
PageRenderer::renderWithLayout(Page $page)
|
1. Resolve the page layout
| page->layout_id → Layout::find()
| fallback: GlobalSettings::getDefaultLayout()
|
2. Render the page body
| renderFromJson(page->content)
|
3. Render the layout header section (if layout has one)
| renderFromJson(layout->header_content)
|
4. Render the layout footer section (if layout has one)
| renderFromJson(layout->footer_content)
|
5. Merge HTML: header_html + body_html + footer_html
|
6. Merge CSS: all three CSS strings concatenated in order
|
7. Merge fonts: union of all three font arrays (deduplicated)
|
v
returns { html, css, fonts }
Fallback is section-based, not whole-layout based. If the assigned layout exists but its header section is missing (e.g., the layout only defines a footer), the page falls back to the global layout's header rather than dropping the entire layout. This allows per-page layout overrides that only customise specific sections.
Ownership boundary:
body→ belongs to the page (tgx_pages.content)headerandfooter→ belong to the layout (tgx_layouts.header_content,tgx_layouts.footer_content)- Global variables are resolved once and passed into all three render calls
CSS Bundle Architecture
Three CSS files are relevant to understand before debugging rendering issues:
| File | When loaded | Contents | Runtime-generated? |
|---|---|---|---|
public/vendor/tagixo/builder.js |
Admin builder only | Builder UI JS + styles for the editor shell | No (pre-built in dist/) |
tgx-layout.css |
Always (public + builder canvas) | Layout primitives: container queries, section/row/column flex rules, responsive containment, cross-context rules (sticky, overflow guards) | No (static, published) |
tgx-modules.css |
Always (public + builder canvas) | Base module styles: typography resets, spacing primitives, module-specific structural CSS that is the same regardless of props | No (static, published) |
Generated css in render response |
Runtime, per-request | Per-node prop-derived CSS from StyleGenerator — all _styles fields |
Yes |
Do not put prop-derived rules in tgx-layout.css or tgx-modules.css. Those files are loaded once and cached by the browser; they cannot vary per node. Prop-derived rules belong exclusively in the runtime-generated css field. The canonical test: if a rule depends on any value authored in the builder UI, it must be generated at render time.
builder.js is pre-built in the package dist/ directory and published to public/vendor/tagixo/ via vendor:publish --tag=tagixo-assets. Consumer apps never run npm against the plugin source.
Preview Mode
Preview mode differs from a normal public page render in three ways:
1. No published gate. The published() query scope on Page normally filters out draft pages. Preview bypasses this by checking Page::isAuthorizedPreviewRequest($request), which validates the signed URL parameters:
// In your PublicPageController::show()
$page = Page::where('slug', $slug)
->when(
! Page::isAuthorizedPreviewRequest($request),
fn ($q) => $q->published()
)
->firstOrFail();
2. Signed URL, not cached payload. The preview URL is generated server-side on demand:
POST /tagixo/builder/preview-url
Body: { "context": "page", "entity_id": 42 }
Response: {
"preview_url": "https://yoursite.com/my-page?_preview=1&signature=...",
"expires_in": 900
}
For mail and PDF contexts the URL points at dedicated preview routes:
/tagixo/builder/preview-mail/{id}?_preview=1&signature=.../tagixo/builder/preview-pdf/{id}?_preview=1&signature=...
3. Auth required. Even with a valid signature, the preview route requires an authenticated session. The TTL is configurable:
// config/tagixo.php
'preview' => [
'url_ttl_seconds' => 900, // 15 minutes default
],
Octane / CDN gotcha: if you proxy the consumer app behind a CDN that strips query parameters or normalises URLs, preview links will break because the signature is embedded in the query string. Ensure
_previewandsignaturepass through untouched. On Octane, preview responses carryCache-Control: no-store— do not add preview routes to any Octane response cache, and disable asset caching for the/admin/preview/*path prefix in nginx-proxy-manager.
Live reload in the builder: the builder iframe does not cache render results. Every time an edit is applied, the editor posts to POST /tagixo/builder/render with the full current structure and replaces the iframe content. There is no diff or incremental patch — the entire HTML+CSS payload is replaced on each edit cycle. This is intentional for correctness; the builder-side debounce (default 300 ms) controls how frequently re-renders happen.
Cache Strategy
The render output is expensive to compute on high-traffic pages. The recommended strategy:
What to cache: the combined html + css + fonts output from renderWithLayout(), keyed by {page_id}:{page_updated_at_timestamp}.
What NOT to cache: the raw renderFromJson() output in isolation when the result depends on global variables or layout, because a global variable change or layout edit would not be reflected without a full cache invalidation.
Invalidation triggers:
- Page content saved (
tgx_pages.updated_atchanges) - Layout assigned to the page changes
- Global variables change (invalidates all pages that reference those variables)
Example using Laravel's cache with tags:
use Illuminate\Support\Facades\Cache;
use Ccast\Tagixo\Models\Page;
use Ccast\Tagixo\Services\PageRenderer;
function renderCached(Page $page): array
{
$cacheKey = "tagixo.render.{$page->id}.{$page->updated_at->timestamp}";
return Cache::tags(["tagixo.page.{$page->id}", 'tagixo.global-variables'])
->remember($cacheKey, now()->addHours(24), function () use ($page) {
return app(PageRenderer::class)->renderWithLayout($page);
});
}
// On global variable save:
Cache::tags('tagixo.global-variables')->flush();
// On layout save (invalidates only pages using this layout):
Cache::tags("tagixo.layout.{$layout->id}")->flush();
Octane statelessness requirement: if you run Swoole/Octane, renderer instances are reused across requests. Do not store per-request state in renderer properties —
StyleGeneratorandPageRendererare designed to be stateless, but any custom extension you add must follow the same rule. Use the service container bound asscopedor transient rather than static singletons.
Native Model Helpers
For batch/offline use cases (cron, exports, mail dispatch):
// Full HTML document — offline export only, not used by PublicPageController
$html = $page->renderFull(); // returns <!DOCTYPE html>...
// Email-ready HTML body
$html = $mailTemplate->renderHtml();
// No merge tags substituted — post-process yourself:
$html = str_replace('{{name}}', $recipientName, $html);
// Print-ready HTML for PDF engines
$html = $pdfTemplate->renderHtml();
$dompdf->loadHtml($html);
$dompdf->render();
$pdf = $dompdf->output();
These methods bypass the live builder pipeline and read directly from the persisted content JSON. They are safe to call from queued jobs.
If you use custom Eloquent models instead of the built-in ones:
use Ccast\Tagixo\Services\PageRenderer;
$renderer = app(PageRenderer::class);
$result = $renderer->renderFromJson(json_decode($myModel->content, true));
// $result = ['html' => '...', 'css' => '...', 'fonts' => [...]]
Mail merge-tag caveat: batch rendering resolves global variables from the database at render time. If a batch job runs while an admin is mid-save on global variables, a partial write may produce inconsistent CSS. Acquire a read lock on the global variables record before batch rendering if consistency is critical.
Mail and PDF Rendering Differences
Mail and PDF rendering share the same entry point pattern but differ from page rendering in several important ways:
| Concern | Page rendering | Mail rendering | PDF rendering |
|---|---|---|---|
| Layout width | Fluid (100vw) | Fixed 600 px max | Fixed page dimensions |
| JavaScript | Loaded client-side | None | None |
| CSS delivery | External <link> injected |
Partially inlined | Fully inlined (dompdf requirement) |
| Module set | Full catalog | Restricted (no JS-dependent modules: slider, popup) | Restricted (no interactive modules) |
| Container queries | Yes | No (email clients ignore @container) |
No |
| Font loading | <link> to Google Fonts |
Embedded base64 or web-safe fallback | Embedded or system fonts only |
| Responsive variants | Supported via @container |
Limited (only @media for a few clients) |
Not applicable |
MailRenderer inlines critical CSS using a CSS inliner before returning the HTML body. Background images and advanced CSS properties that lack broad email client support are stripped or replaced with inline equivalents. If a module registers mailUnsupported: true in its config, it is omitted entirely from mail renders.
No merge-tag substitution is built in.
MailTemplate::renderHtml()returns the literal placeholder strings (e.g.,{{subscriber.name}}). Substitute values in your queued mail job after callingrenderHtml().
Form Preview Path
When context=form, FormPreviewRenderer handles the request. It:
- resolves form module wrappers (field groups, submit button, validation containers) that
PageRendererdoes not know about - emits embeddable form markup — the output is designed to be dropped into a page, not to be a standalone document
- combines per-component CSS with global variable CSS into a single scoped block
- does not render layout header or footer — form preview is always body-only
Do not test form payloads through the page path. The output will be structurally incorrect, and debugging it will mislead you into thinking the form payload is malformed when it is not. Cross-context testing produces misleading results.
Carousel Preview Path
When context=page and layout_variant=carousel, SliderPreviewRenderer runs:
- Renders the full page structure to baseline HTML (same as
PageRenderer) - Extracts root-level sections as individual slides
- Reads slider configuration from the page's
layout_variant_configfield: transition type, autoplay delay, navigation arrows, pagination dots, loop behaviour - Wraps the slide HTML in the carousel shell view (
tagixo::slider.preview) - Injects WebGL transition code when a GL transition type is configured
The slider is a layout_variant, not a separate context. This means slider pages still go through the same module-rendering and CSS-generation machinery as regular pages — only the final assembly differs. Cross-context testing (e.g. rendering a carousel structure through the default page branch) will produce a flat list of sections rather than a slider, giving a misleading impression of the actual output.
Debug Checklist
When rendered output is wrong or missing, work through these items in order:
- Verify the branch: check that
contextandlayout_variantin the POST body match what you expect. LogBuilderApiService::render()inputs. Confirmpage_typeon the Page model if using the consumer controller path. - Module registration: confirm the module type exists in
availableComponentsreturned byGET /tagixo/builder/bootstrap. An unregistered type renders as an empty<div>with no error. - View resolution: run
php artisan view:clearand check whether the module view resolves to the package path or a consumer override. Stale compiled views cause silent mismatches after a plugin update. - Prop shape: dump
$propsinside the Blade partial. A prop key that changed name in a plugin update producesnullin the view rather than an error. Check the stored JSON against the current PropType schema. - Generated CSS: inspect
$result['css']for the expected class names and property values. A missing style almost always means the_styleskey was not written by the editor (check the stored JSON) or the PropType is not registered withStyleGenerator. - Global variables: if colours or fonts look wrong globally, check that
GlobalVariablesSeederhas run and thattgx_global_variableshas entries for the expected keys. Missing keys produce empty CSS custom properties, not errors. - Cache: if you cache render output, ensure the cache key includes
updated_at. A stale cached response is indistinguishable from a render bug. Flush the relevant cache tags and re-render to confirm. - Octane opcache: after plugin updates (
vendor:publish --tag=tagixo-assets), runphp artisan octane:reloadto clear the opcache. Swoole caches class definitions across requests; file changes are not picked up until reload.
Output Contract
Every render response — regardless of branch — returns:
{
"html": "<div class=\"tgx-page-body\">...</div>",
"css": ".tgx-node-abc123 { color: #1a1a1a; } ...",
"fonts": [
"https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"
]
}
| Field | Type | Notes |
|---|---|---|
html |
string | The rendered markup. Does not include <html>, <head>, or <body> wrappers unless you call Page::renderFull() |
css |
string | All per-node generated styles. Inject into a <style> tag in the document <head> before html |
fonts |
string[] | Google Fonts or self-hosted font URLs. Inject as <link rel="stylesheet"> tags before the CSS block |
Your host controller is responsible for injecting css and fonts into the document. The public.page Blade view in the consumer app handles this for normal page requests. For custom integrations, follow the same injection order: font links → generated CSS → page HTML.