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 onis_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.phpis guarded to'page' === get_post_type($post_id)only — the CPT has nopage-attributessupport, 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:
sanilwb_layout_typepostmeta on the entry itself — so the dropdown shows the right value next time you open it.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=1mechanism 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()andSANILWB_Blueprints_Preflight::check_content_presets()still only know about the preset-slug assignment path, notsanilwb_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 |