Core Integration

Rendering and Preview Workflow

Understand the rendering pipeline for page, form, and carousel variants and how preview differs.

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 components from the payload before posting. Some render branches inspect components to 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:

  1. walks every node in the rendered tree
  2. extracts any fontFamily value that resolves to a web font (rather than a system stack)
  3. 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;700 query parameter
  4. 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
  5. 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, call MailRenderer::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)
  • header and footer → 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 _preview and signature pass through untouched. On Octane, preview responses carry Cache-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_at changes)
  • 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 — StyleGenerator and PageRenderer are designed to be stateless, but any custom extension you add must follow the same rule. Use the service container bound as scoped or 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 calling renderHtml().

Form Preview Path

When context=form, FormPreviewRenderer handles the request. It:

  • resolves form module wrappers (field groups, submit button, validation containers) that PageRenderer does 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:

  1. Renders the full page structure to baseline HTML (same as PageRenderer)
  2. Extracts root-level sections as individual slides
  3. Reads slider configuration from the page's layout_variant_config field: transition type, autoplay delay, navigation arrows, pagination dots, loop behaviour
  4. Wraps the slide HTML in the carousel shell view (tagixo::slider.preview)
  5. 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:

  1. Verify the branch: check that context and layout_variant in the POST body match what you expect. Log BuilderApiService::render() inputs. Confirm page_type on the Page model if using the consumer controller path.
  2. Module registration: confirm the module type exists in availableComponents returned by GET /tagixo/builder/bootstrap. An unregistered type renders as an empty <div> with no error.
  3. View resolution: run php artisan view:clear and check whether the module view resolves to the package path or a consumer override. Stale compiled views cause silent mismatches after a plugin update.
  4. Prop shape: dump $props inside the Blade partial. A prop key that changed name in a plugin update produces null in the view rather than an error. Check the stored JSON against the current PropType schema.
  5. Generated CSS: inspect $result['css'] for the expected class names and property values. A missing style almost always means the _styles key was not written by the editor (check the stored JSON) or the PropType is not registered with StyleGenerator.
  6. Global variables: if colours or fonts look wrong globally, check that GlobalVariablesSeeder has run and that tgx_global_variables has entries for the expected keys. Missing keys produce empty CSS custom properties, not errors.
  7. 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.
  8. Octane opcache: after plugin updates (vendor:publish --tag=tagixo-assets), run php artisan octane:reload to 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.

See Also