Site Layout

Site Layout is a custom post type (sanilwb_site_layout) whose entries are visual, dynamic templates built entirely with the Page Builder — the same drag-and-drop canvas used for regular Pages. It replaces the folder-preset system for five content types: single posts, pages, archives, 404, and search results.

This is the Elementor/Divi "Theme Builder" model applied to this plugin: presets stay for global chrome (Header & Footer), while dynamic content areas get authored visually and populate themselves from whatever post WordPress's current loop/query has in scope.


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 — the same folder-preset mechanism header/footer still use. 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 has since been removed from the plugin entirely — there is no admin-selectable preset slug for single/page/archive/404/search anymore. When no Site Layout entry is assigned, the theme itself renders a plain, hard-coded default template (migrated in from the old preset files) instead of the plugin resolving and requiring one. See Rendering Bridge below.

The name "Site Layout" previously belonged to the header/footer admin page — that page was renamed to Header & Footer (label only; see Header & Footer → Relationship to the Site Layout CPT) when this feature reclaimed the term.


Post Type Registration

File: includes/class-sanilwb-cpt-site-layout.php, class SANILWB_CPT_Site_Layout

Registered on init (both admin and frontend, since the rendering bridge needs get_post() to work against it on the frontend too):

Arg Value Why
public false Not directly browsable/permalink-routable as content
show_ui true Has a normal admin list/edit screen
show_in_menu 'sanilwb-website-builder' Nests as a submenu under "Sanil Builder", not its own top-level menu
show_in_rest false Classic-editor-only — matches how the Page Builder metabox already excludes itself from the block editor
supports ['title'] No 'editor' (content is 100% Page-Builder-authored via sanilwb_data postmeta — a WYSIWYG editor would be redundant); no 'page-attributes' (no template dropdown or parent/child hierarchy needed)
capability_type 'page' Reuses edit_pages/edit_page — the same capability every other admin surface in this plugin gates on

SANILWB_CPT_Site_Layout::ASSIGNABLE_TYPES is ['single', 'page', 'archive', '404', 'search'] — deliberately excludes 'header'/'footer', which stay on the preset system permanently.


Editing a Site Layout Entry

A Site Layout entry's edit screen is the same Page Builder metabox used on regular Pages — extended to also activate on this post type. Two admin-side gates make this work (admin/class-sanilwb-admin-page-builder.php):

  • add_metabox() registers the metabox on ['page', SANILWB_CPT_Site_Layout::POST_TYPE] instead of just 'page'.
  • enqueue_styles() / enqueue_scripts() gate on is_our_page_template_selected() || is_our_cpt_edit_screen(). is_our_cpt_edit_screen() is a separate, simpler check (is_admin() && SANILWB_CPT_Site_Layout::POST_TYPE === get_post_type()) — unlike Pages, this CPT has no "page template" selection step, so the builder is just always active on its edit screen.
  • ajax_pb_save()'s auto-assignment of _wp_page_template = page-pagebuilder.php is guarded to 'page' === get_post_type($post_id) only — the CPT has no page-attributes support, so that postmeta would be meaningless for it.

Every other part of the Page Builder — widget types, the canvas, save/undo, everything documented in Page Builder — works completely unmodified for Site Layout entries. build_window_data() needed zero CPT-specific changes.

Editing against a Site Layout entry's own ambient context

Site Layout entries are edited the same way any Page Builder document is: the AJAX canvas preview (ajax_render_widget()) sets post context to the document currently being edited — which, for a Site Layout entry, is the entry itself, not a real piece of content. This matters for dynamic widgets (Post Title, Excerpt, Post Content, Featured Image, Archive Title): a Site Layout entry has a title (whatever you named it) 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).


Assigning an Entry to a Content Type

Each Site Layout entry has an "Applies To" meta box (side panel, classic meta box — no React/AJAX needed for something this simple) with a plain <select> of the five assignable types plus "— Not assigned —".

File: SANILWB_CPT_Site_Layout::render_assignment_metabox() / save_assignment()

Saving persists two things:

  1. sanilwb_layout_type postmeta on the entry itself — so the dropdown shows the right value next time you open it.
  2. sanilwb_layout_assignment_{type} option — the single source of truth the rendering bridge reads. Saving a type here always makes this entry the active one for that type, overwriting whatever entry was active before.

This is intentionally a single global assignment per type (no per-category/per-post-type scoping yet) — the same simplicity the preset system it replaces already had. If you create three different "Archive" entries, only one is ever active; the others are effectively drafts. If an entry's type changes (or is cleared), and it was the active entry for its old type, that option is cleared too — so it never keeps pointing at an entry that no longer claims that type.


Rendering Bridge

File: includes/class-sanilwb-site-layout-renderer.php, class SANILWB_Site_Layout_Renderer

Kept as its own class, separate from the header/footer folder-preset system (SANILWB_SL_Renderer / SANILWB_SL_Presets) — this is an architecturally different rendering mechanism (CPT + the Page Builder JSON engine) from folder-of-PHP-files presets, and mixing the two would blur that distinction.

There is no plugin class mediating single/page/archive/404/search rendering anymore (the old SANILWB_Page_Renderer has been removed). Instead, the theme's own template files call render_for_type() directly:

// single.php / page.php / archive.php / 404.php / search.php
if ( ! SANILWB_Site_Layout_Renderer::render_for_type( 'single' ) ) {
    require get_stylesheet_directory() . '/partials/site-layout-defaults/single.php';
}

render_for_type( string $type ): bool

Returns true if it rendered a Site Layout entry, in which case the theme template does nothing further. Returns false if nothing valid is assigned, in which case the theme template includes its own default template from partials/site-layout-defaults/{type}.php — a plain, hard-coded fallback with no plugin involvement.

public static function render_for_type( string $type ): bool {
    $layout_post_id = self::get_active_layout_id( $type ); // validates published status
    if ( ! $layout_post_id ) {
        return false;
    }
    // ...renders via render_page_builder_content(), directly or in a loop
    return true;
}

Assignment validation

get_active_layout_id( $type ) reads sanilwb_layout_assignment_{type} and validates the referenced post is a real, published Site Layout entry. A stale reference (trashed/deleted entry) is treated exactly like "nothing assigned" — this is what makes deleting or unpublishing a Site Layout entry fail safe back to the theme's default template instead of rendering nothing.

Single / page / 404 — one-shot render

Calls Sanil_Website_Builder_Public::render_page_builder_content( $layout_post_id ) 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), matching how a hand-written 404 preset has no post context either.

Archive / search — real main-query loop

Search results behave like archive (a real loop of possibly-mixed-post-type results), not like a single item, despite the type name looking closer to "single"/"page". Both wrap WordPress's real main-query loop, matching the theme's own partials/site-layout-defaults/post-loop.php's pattern exactly:

while ( have_posts() ) {
    the_post();
    $renderer->render_page_builder_content( $layout_post_id );
}
wp_reset_postdata();
wp_pagenavi();

This is deliberately not a custom WP_Query — unlike the Posts widget (which owns its own separate query and is meant to be embedded inside a page), this is a true archive/search-page replacement, so it must respect the actual queried object, pagination, and any pre_get_posts filters already applied to the main query.

Calling render_page_builder_content() once per iteration, after the_post() has updated global $post, is what makes dynamic widgets (Post Title, Featured Image, Excerpt, …) resolve per-card instead of all showing the first post's data — and what makes the existing per-post widget cache key (get_widget_cache_key(), get_the_ID()-based, in Sanil_Website_Builder_Public) work correctly with zero changes to that method.

Pagination reuses wp_pagenavi() — the same call the theme's default post-loop partial makes — so archive-type Site Layout templates get the same next/prev controls the theme's default templates do, until a Page-Builder-authored pagination widget exists.


A Subtle Gotcha This Feature Surfaced: global $post in AJAX Preview

While building the dynamic widgets that make Site Layout useful, ajax_render_widget()'s post-context setup turned out to have a real bug: setup_postdata( $post ) alone does not set $GLOBALS['post'] in modern WordPress — only WP_Query::the_post() does that, via its own explicit global $post; assignment. The fix (global $post; before the assignment) is documented in full in Page Builder → Ambient post context in the AJAX canvas preview, since it affects every dynamic widget, not just Site Layout specifically.


Known Limitations (not yet built)

  • No "preview against real content" mode for an unsaved/unassigned entry. A Site Layout entry's own full-screen editor already has AJAX canvas preview for authoring, but there's no equivalent of "preview this entry against a real post before assigning it." (The old preset system's ?sanilwb_content_preview=1 mechanism was removed along with the content-tab admin UI; header/footer preview uses a separate ?sanilwb_sl_preview=... endpoint and never depended on this param — see Header & Footer → Preview System.)
  • Single active entry per type only. No per-category, per-post-type, or per-post override — matches the old preset system's own simplicity, but is a real limitation if you need, say, a different archive template per category.
  • Blueprints integration is not updated. SANILWB_Blueprints_Applier::apply_content_presets() and SANILWB_Blueprints_Preflight::check_content_presets() still only know about the preset-slug assignment path, not sanilwb_layout_assignment_{type} + CPT content. Deferred as a separate follow-up.

Key Files

File Purpose
includes/class-sanilwb-cpt-site-layout.php Post type registration, "Applies To" assignment meta box, save logic
includes/class-sanilwb-site-layout-renderer.php Rendering bridge — resolves the active entry per type and runs it through the Page Builder render pipeline
admin/class-sanilwb-admin-page-builder.php Extended metabox/enqueue gates so the Page Builder activates on this CPT's edit screen
public/class-sanil-website-builder-public.php render_page_builder_content() (the actual render pipeline, shared with regular Pages), dynamic widget shortcode handlers, dynamic_widget_placeholder() / post_content_placeholder()
(theme) single.php / page.php / archive.php / 404.php / search.php Call render_for_type() directly; include the matching default template when nothing is assigned
(theme) partials/site-layout-defaults/{type}.php The theme's own hard-coded fallback templates — migrated from the old public/presets/{type}/default/ preset files, no longer plugin-managed