Site Layout

Site Layout is a full-page layout system, stored in its own database table ({prefix}sanilwb_site_layouts) and built entirely with the Page Builder — the same drag-and-drop canvas used for regular Pages. It replaces the old folder-preset system for seven content types: single posts, pages, archives, 404, search results, and — as of the header/footer migration — headers and footers too.

This is the Elementor/Divi "Theme Builder" model applied to this plugin: every part of the site's chrome and content areas, header and footer included, is authored visually and populates itself from whatever post WordPress's current loop/query has in scope, rather than any part of the site still being a hand-written PHP preset.


Why This Exists

Before Site Layout, single/page/archive/404/search were rendered by hand-written PHP files under public/presets/{type}/{slug}/index.php. Editing one of those layouts meant editing PHP directly. Site Layout lets an editor build the same kind of layout visually, using Page Builder widgets, without touching code.

The folder-preset system for these five types was removed first; header and footer kept their own separate folder-preset system for a while afterward, then were migrated into Site Layout as two more layout_type values (header, footer) — see Header and Footer as Site Layout Types below. The entire public/presets/ folder-preset system — for every content type, header and footer included — is now gone from the plugin. When no Site Layout row matches a single/page/archive/404/search request, whichever theme is currently active renders its own normal template for that request, completely untouched by the plugin (header/footer have no such fallback — see below). This is theme-agnostic by design — see Rendering Bridge below — the plugin no longer requires any specific paired theme to be active.

The name "Site Layout" previously belonged to the header/footer admin page, back when header/footer had their own separate admin screen and folder-preset system. That old page and system are both gone now that header/footer are Site Layout types themselves, authored in the same list screen and editor as every other Site Layout row.


Storage

File: includes/class-sanilwb-site-layout-db.php, class SANILWB_Site_Layout_DB

Every Site Layout entry is one row in {prefix}sanilwb_site_layouts:

Column Purpose
id Primary key
name Admin-facing label only, never shown to visitors
layout_type One of SANILWB_Site_Layout_DB::LAYOUT_TYPESsingle, page, archive, 404, search, header, footer
condition_json Encoded condition narrowing which real post/term/post-type this row applies to, or NULL for "All"
schema_json The Page Builder document — same kind: 'div'\|'widget' tree shape as a Page's sanilwb_data, see Page Builder → Data Structure
include_header / include_footer Whether this row's own Header/Footer Site Layout content should render — see Include Header and Footer Toggle. Meaningless for header/footer-type rows themselves. Default 1 (on).
css_version Cache-busting version for this row's compiled CSS file, see CSS Caching
deleted_at Soft-delete (trash) timestamp, NULL for an active row

SANILWB_Site_Layout_DB is a thin model with no ORM — get_all()/get()/get_active_rows_by_type() for reads, insert()/update()/trash()/restore()/delete()/duplicate() for writes, unique_name() for de-duping the Name field, and decode_condition()/encode_condition() for the condition_json column.

LAYOUT_TYPES, CONDITION_KINDS_BY_TYPE, and CONDITION_SPECIFICITY (below) are the single source of truth read by both the admin UI (list screen, creation modal, Canvas Settings sidebar) and the rendering bridge — nothing hardcodes this list a second time anywhere else.


Admin List Screen

Menu: WordPress Admin → Sanil Builder → Site Layouts (admin.php?page=sanilwb-site-layouts)

Files: admin/class-sanilwb-admin-site-layout.php, includes/class-sanilwb-site-layouts-list-table.php (a WP_List_Table subclass), admin/templates/site-layout-builder/list-page.php

A standard WordPress list table — Name, Applies To, Condition (human-readable summary, e.g. "Category: News"), Updated — with the usual Active/Trash views and Trash/Restore/Delete bulk + row actions. There is no separate edit-in-place row action: clicking a row's name opens the full-screen Page Builder editor for it.

Creating a New Entry

Clicking Add New Site Layout opens an in-page modal (not a new screen) capturing:

  1. Name — plain text.
  2. Applies to — one of the seven LAYOUT_TYPES (Single Post, Page, Archive, 404, Search Results, Header, Footer).
  3. Condition — appears once a type is picked, its options driven by CONDITION_KINDS_BY_TYPE for that type (see Condition System below); 404/search skip this section entirely and show a short "always applies everywhere" note instead.

Both Name and Applies To are required before Create submits. The button POSTs straight to sanilwb_site_layout_save (the same AJAX action the full-screen editor's Save button uses) with an empty canvas (schema: '[]'), then redirects to the Page Builder editor for the new row's real id — there is no intermediate "blank, unexplained" editor state.

The Condition Type=Term / Condition Type=Specific Post targets are live debounced search-selects (sanilwb_search_terms/sanilwb_search_posts), not static dropdowns — searching all terms or all posts up front wouldn't scale. See admin/assets/js/site-layout-list.js.


Condition System

Each row's condition narrows which real content of its broad layout_type it applies to. CONDITION_KINDS_BY_TYPE in SANILWB_Site_Layout_DB is the single source of truth for which kinds are offered per type:

Layout Type Available Condition Kinds
single All, Specific Post Type, Entire Taxonomy, Specific Term, Specific Post
page All, Specific Post
archive All, Specific Post Type, Entire Taxonomy, Specific Term
404 (none — always "All")
search (none — always "All")
header All, Specific Post Type, Entire Taxonomy, Specific Term, Specific Post
footer All, Specific Post Type, Entire Taxonomy, Specific Term, Specific Post

A condition is stored as {kind: 'all'|'post_type'|'taxonomy'|'term'|'post', ...}, with post_type/taxonomy/term_id/post_id present depending on kind. {kind:'all'} is stored as NULL in condition_jsondecode_condition() treats a missing or unrecognized value the same way, so a corrupt/legacy row fails safe to "applies to everything of this type" rather than matching nothing.

Specificity (CONDITION_SPECIFICITY) breaks ties when more than one active row of the same type could match the same request — highest wins:

post (40) > term (30) > taxonomy (20) > post_type (10) > all (0)

e.g. a row targeting one exact post beats a row targeting its whole post type, which beats a catch-all "All" row. An exact score tie keeps whichever row has the lowest id (was created first) — see get_active_rows_by_type()'s own docblock.


Editing an Entry

A Site Layout row is edited in the same full-screen Page Builder editor used for Pages and Templates, running in its own context: 'site_layout' (see usePageBuilderStore.js's siteLayoutType/siteLayoutCondition fields, and CanvasSettingsSidebar.jsx for the "Applies To" type + Condition fields in the editor's own settings panel). Every other part of the Page Builder — widget types, the canvas, save/undo, everything documented in Page Builder — works completely unmodified for Site Layout rows.

URL: /?sanilwb_site_layout_editor=1&id={layout_id} (id=0 for a brand-new, not-yet-saved row) — a frontend full-screen route, same pattern as the Template editor, not a wp-admin screen. See public/hooks.php.

Saving calls SANILWB_Ajax::save_site_layout() (action sanilwb_site_layout_save), which validates the name and layout type server-side against SANILWB_Site_Layout_DB::LAYOUT_TYPES, sanitizes the posted condition against CONDITION_KINDS_BY_TYPE for that type, and inserts or updates the row. A successful first save on a new row soft-navigates the editor to the new row's real id.

Editing against an entry's own ambient context

The AJAX canvas preview (ajax_render_widget()) sets post context to the document currently being edited — which, for a Site Layout row, is the row itself, not a real piece of content. This matters for dynamic widgets (Post Title, Excerpt, Post Content, Featured Image, Archive Title): a Site Layout row has a name (whatever it was called) but no real excerpt/content/featured image, so those widgets have nothing to show while you're authoring the template.

To avoid every dynamic widget looking like a blank, broken box while editing, empty dynamic-widget output shows an admin-only placeholder instead — see Page Builder → dynamic_widget_placeholder(). The placeholder is gated on is_admin() so it never appears on the real frontend (where an empty title/excerpt/content is a legitimate state, e.g. on a 404 page).

For an archive-type row, the Canvas Settings sidebar's Preview Term picker (PreviewTermPicker.jsx) lets you preview ambient widgets against a real term while authoring, instead of only ever seeing the placeholder.


Rendering Bridge

Files: includes/class-sanilwb-template-loader.php (SANILWB_Template_Loader), includes/class-sanilwb-site-layout-renderer.php (SANILWB_Site_Layout_Renderer), public/templates/canvas.php

Rendering is theme-agnostic — it works no matter which theme is active, not just the plugin's old paired theme (template-for-page-builder). No theme file needs to know this plugin exists. This is split across two independent mechanisms, since single/page/archive/404/search and header/footer behave differently (one has a fallback, the other doesn't):

single/page/archive/404/searchSANILWB_Template_Loader hooks WordPress's own template_include filter (priority 20, so it runs after core/theme template resolution, including a block theme's own template lookup). For the current request it resolves either "is this a real page using the Page Builder template" (SANILWB_CSS_Cache::resolve_own_page_id()) or "does an active Site Layout row exist for this request's type" (SANILWB_CSS_Cache::resolve_site_layout_type_for_request() + SANILWB_Site_Layout_Renderer::get_active_layout_id()). If either matches, it serves the plugin's own bare document shell, public/templates/canvas.php — its own minimal <!DOCTYPE html>/<head>/<body>, still firing wp_head()/wp_body_open()/wp_footer() so other plugins/theme assets keep working, but never calling the active theme's own get_header()/get_footer(). This is deliberate ("Canvas" rendering — see Include Header and Footer Toggle below for why): it guarantees a foreign theme's own container/sidebar/typography CSS never bleeds into Page Builder's layout. If neither matches, template_include is left completely untouched — the active theme's own normal template renders exactly as it would on any other WordPress site, with no plugin involvement at all (this replaces the old theme-specific partials/site-layout-defaults/ fallback files, which only existed in template-for-page-builder).

SANILWB_Template_Loader also hooks theme_page_templates to add "Sanil Website Builder" to the Page Attributes dropdown unconditionally — the template is selectable (and, once assigned, still renders) regardless of whether the active theme physically contains a page-pagebuilder.php file; the stored _wp_page_template postmeta value is just checked directly.

header/footerSANILWB_Site_Layout_Renderer::register() hooks the generic wp_body_open (header) and wp_footer (footer, priority 1) actions directly — independent of template_include/Canvas, and independent of whichever theme happens to be active, as long as that theme calls both functions (effectively universal across modern themes; public/templates/canvas.php calls both itself for Canvas-rendered requests, so header/footer always work there too).

render_for_type( string $type ): bool

Returns true if it rendered a Site Layout row, in which case the caller does nothing further. Returns false if nothing matches.

For single/page/archive/404/search, SANILWB_Template_Loader is the only caller, from inside canvas.php — a false return there simply means Canvas wasn't served for this request in the first place (see above), so there's no separate fallback branch to speak of. Header and footer have no fallback at all — see Header and Footer as Site Layout Types below.

Condition resolution

Every layout type — including 404/search — is resolved the same way, by get_active_layout_id( $type ): every active row of that type is fetched (SANILWB_Site_Layout_DB::get_active_rows_by_type()), checked against get_queried_object() for the current request, and the matching row with the highest specificity score wins. 404/search rows always carry {kind:'all'}, so in practice only one such row is ever expected to exist per type — but nothing assumes that; a second 404/search row would simply tie and lose to whichever was inserted first. Header/footer use the full condition system, so more than one active row of either type is an expected, normal case, not an edge case.

get_active_layout_id() is public so SANILWB_CSS_Cache can resolve the exact same row id for CSS-cache enqueue purposes without re-implementing this resolution a second time.

Single / page / 404 — one-shot render

Renders once, directly, against the already-current queried object. No loop: there's exactly one implicit post/queried-object already in scope. On 404 there is no post at all — dynamic widgets like Post Title simply render empty (or the admin placeholder, in the editor).

Archive / search — real main-query loop

If an archive/search layout needs to show a list of posts, that's built with the Page Builder's own Loop container (Enable Loop, Context: Current Query, Query Type: Post) inside the layout — it reuses the real main query and pagination itself (SANILWB_Frontend_Renderer::render_div_or_loop_clones()). The rendering bridge itself still only renders the layout row once per request; the Loop container is what makes it repeat per post in the real query.


Header and footer used to be a completely separate system: their own admin screen, their own folder-of-PHP-files presets under public/presets/header/{slug}/ and public/presets/footer/{slug}/, and their own renderer class. That system has been fully removed. Header and footer are now just two more layout_type values on Site Layout, authored the same way as every other type.

Authoring. A header or footer row is built in the exact same frontend full-screen Page Builder editor used for every other Site Layout type — real widgets (Menu, Icon, Social Icons, etc.), not hand-written PHP. It's created from the same "Create Site Layout" modal on the Site Layouts list screen, picking "Header" or "Footer" under Applies To.

Multiple rows and conditions. Unlike 404/Search (always {kind:'all'}, effectively one row), header and footer get the full condition/specificity engine — the same one Single Post uses. Several Header (or Footer) rows can coexist, each scoped to a different post type/taxonomy/term/post, and the most specific match wins per request, using the same specificity scoring (post=40 > term=30 > taxonomy=20 > post_type=10 > all=0) described in Condition System.

No fallback. SANILWB_Site_Layout_Renderer::register() hooks wp_body_open/wp_footer directly (see Rendering Bridge above) — no theme file needs to call anything. But unlike single/page/archive/404/search, there is no fallback of any kind: if no row is active for the current request (or the active row's own Include Header and Footer Toggle is off), nothing renders at all in that region.

Rendered alongside content, not instead of it. Header/footer are chrome that appears on every request regardless of which content type is being rendered — SANILWB_CSS_Cache::enqueue_page_builder_css() enqueues the active header row's CSS and the active footer row's CSS unconditionally, in addition to whatever the content region (single/page/archive/etc.) resolves to.

Known gap — no WP Widget Area widget. The old folder-based footer presets supported real dynamic_sidebar() widget areas (footer column sidebars, a copyright-bar widget area). There is no Page Builder widget yet that embeds a real WP widget area, so a header/footer built this way cannot reproduce that. Content that used to live in a WP widget area needs to be manually rebuilt as static Page Builder content until a "WP Widget Area" widget exists.


Files: admin/assets/js/src/page-builder/components/CanvasSettingsSidebar.jsx (UI), includes/class-sanilwb-site-layout-renderer.php (should_render_region(), the real frontend effect)

A per-item, persisted "Show header" / "Show footer" toggle, in the full-screen editor's Canvas Settings panel — the same panel as the "Applies To"/Condition fields. It controls whether the active Header/Footer Site Layout row (if any exists at all — see "No fallback" above) renders for this specific page or Site Layout row, not globally. It has real effect on the published frontend, and its stored value is what the editor's own live canvas preview shows too — a single source of truth, no separate "preview only" state.

Where it applies: a real Page using the Page Builder template, and a single/page/archive/404/search-type Site Layout row. Where it's hidden: a Header or Footer-type Site Layout row itself (doesn't make sense to hide the header while authoring the header), and a Template (never independently renders as a document on the real frontend, so the setting has nowhere to apply).

Storage: sanilwb_include_header / sanilwb_include_footer postmeta on a real Page; include_header / include_footer columns (added in SANILWB_DB_VERSION 4.6.0) on a Site Layout row. Both default to "on" — missing/unset always means visible, so nothing changes for existing content until someone explicitly turns a region off.

The actual gate: SANILWB_Site_Layout_Renderer::should_render_region( 'header' | 'footer' ), checked by render_header_hook()/render_footer_hook() before calling render_for_type(). It resolves "what is this request" using the exact same two calls SANILWB_Template_Loader already uses (resolve_own_page_id() / resolve_site_layout_type_for_request() + get_active_layout_id()), then reads that specific page's/row's own stored value. An ordinary page/post that isn't Page-Builder-owned at all has no per-item value to check — header/footer always render normally there, unaffected by any toggle.

This does not bring back the active theme's own header/footer. Turning this toggle off just means the Site Layout Header/Footer row doesn't render — Canvas mode still never calls the active theme's get_header()/get_footer() (see Rendering Bridge above). If no Site Layout Header/Footer row exists at all, this toggle has nothing to show or hide either way; build one first.


CSS Caching

Same static, cached-CSS-file model as a regular Page or Template — one compiled CSS file per Site Layout row, enqueued as an ordinary <link rel="stylesheet">, regenerated on save (SANILWB_CSS_Cache::generate_site_layout_css_file()), and resolved at enqueue time via resolve_active_render_post_ids() calling SANILWB_Site_Layout_Renderer::get_active_layout_id() rather than re-deriving the condition/specificity resolution a second time. Header/footer CSS is resolved and enqueued the same way, unconditionally on every front-end request (see Header and Footer as Site Layout Types above). See Page Builder → CSS Caching for the full mechanism.


Known Limitations (not yet built)

  • No "preview against real content" mode for single/page-type rows. Archive-type rows have the Preview Term picker (above); single/page-type rows have no equivalent "preview this row against a real post before it's assigned."
  • Blueprints integration is not updated. SANILWB_Blueprints_Applier::apply_content_presets() and SANILWB_Blueprints_Preflight::check_content_presets() still only know about the old preset-slug assignment path, not Site Layout rows. Deferred as a separate follow-up.
  • Layout Assignment Conditions are single-target only. Each condition targets exactly one post type, one taxonomy, one term, or one post — there's no "match several categories" or AND/OR combination yet.
  • No WP Widget Area widget. See Header and Footer as Site Layout Types above — a header/footer built with Site Layout cannot embed a real dynamic_sidebar() widget area.

Key Files

File Purpose
includes/class-sanilwb-site-layout-db.php DB model — wp_sanilwb_site_layouts reads/writes, LAYOUT_TYPES/CONDITION_KINDS_BY_TYPE/CONDITION_SPECIFICITY
includes/class-sanilwb-site-layout-renderer.php Rendering bridge for header/footer (register()'s wp_body_open/wp_footer hooks, should_render_region()), plus render_for_type()/get_active_layout_id() shared by both mechanisms
includes/class-sanilwb-template-loader.php Theme-agnostic template_include takeover for single/page/archive/404/search — see Rendering Bridge
public/templates/canvas.php The plugin's own bare document shell, served in place of the active theme's template for Page-Builder-owned requests
includes/class-sanilwb-site-layouts-list-table.php Admin list table (WP_List_Table subclass)
admin/class-sanilwb-admin-site-layout.php List page render + asset enqueue
admin/templates/site-layout-builder/list-page.php List page markup + the "Create Site Layout" modal
admin/assets/js/site-layout-list.js List page interactivity — modal open/close, condition-field visibility, live post/term search, create submit
admin/class-sanilwb-admin-page-builder.php build_site_layout_window_data() — injects window.PageBuilderData for the site_layout editor context, including includeHeader/includeFooter
admin/assets/js/src/page-builder/components/CanvasSettingsSidebar.jsx Include Header and Footer Toggle UI
public/class-sanil-website-builder-public.php render_site_layout_content() — the render entry point SANILWB_Site_Layout_Renderer calls
includes/class-sanilwb-frontend-renderer.php The actual render pipeline, shared with regular Pages
includes/class-sanilwb-css-cache.php Generates/enqueues each row's own cached CSS file; resolve_own_page_id()/resolve_site_layout_type_for_request() are the shared request-resolution logic SANILWB_Template_Loader and SANILWB_Site_Layout_Renderer::should_render_region() both reuse
includes/class-sanilwb-shortcode-handler.php + includes/shortcodes/ Dynamic widget shortcode handlers and shared helpers dynamic_widget_placeholder() / post_content_placeholder() (SANILWB_Shortcode_Helpers)