Architecture

Layout System Architecture

The layout system in SIA Publishing connects three layers: a hard-coded sitemap inventory (the expected page structure), a layout repository (named template definitions), and WordPress posts (the live content instances). These layers are assembled at runtime and cached in transients.

The three layers

Sitemap inventory

The sitemap inventory is a nested PHP array defined in SIA_Publishing_Sitemap::inventory(). It lists every page slug that should exist on a given site, organised hierarchically by navigation tier. The inventory is keyed at the top level by placement type (license_id or blog_id) and then by placement value — an organisation status constant or a specific blog ID.

The inventory is flattened at runtime into a single-level $flat array via flatten(), which annotates each node with its tier depth (0 = home, increasing with depth). Tier 3 nodes exist in the inventory for block-lookup purposes but are not included in navigation.

Layout repository

SIA_Publishing_Layouts_Repository::get_layouts() is a ~3,700-line PHP array containing every layout definition in the system. Each layout is keyed by its slug and carries:

  • title — page title
  • permalink — URL segment
  • placementType / placement — which org status or blog this layout belongs to
  • version — incremented when the layout structure changes
  • blocks — ordered array of block slug → config pairs
  • parent (optional) — parent layout slug, used for breadcrumb hierarchy
  • navigationTier (optional) — menu depth; defaults to the tier derived from the sitemap
  • excerpt (optional) — post excerpt
  • coreBlocks (optional) — WordPress core block types permitted in the editor
  • bypassAllowedBlocks (optional) — disables all block restrictions (e.g. Impact Blueprint)
  • postPassword (optional) — password-protects the page

Summit and Retreat microsite layouts are defined separately in summit_microsite_layouts() and merged in.

WordPress posts

Each layout maps to at most one WordPress page post per site. The post is identified by the _template_layout post meta field (constant SIA_Publishing::ACF_LAYOUT_FIELD_NAME), which stores the layout slug. Two companion fields are also stored: _template_layout_set (Unix timestamp of last application) and _template_layout_version (the layout version at time of application).

Stencil

Stencil (STENCIL_BLOG_ID) is the canonical source blog. For most placements, page content is cloned from Stencil when a layout is applied: the target post receives the title, URL slug, block markup, and excerpt from the corresponding Stencil post. Blog-specific layouts (those with placementType = 'blog_id') and layouts applied directly on Stencil itself instead assemble their markup from scratch by iterating the layout’s block definitions.

The sitemap lifecycle

SIA_Publishing_Sitemap is instantiated with a $placementType and $placement. On construction:

  1. inventory() loads the hard-coded structure for that placement.
  2. A transient is checked (keyed by class, blog ID, and anonymous/authenticated context). On a hit, $flat and $blockInventory are restored from cache.
  3. On a miss, three sequential steps run:
    • flatten() — collapses the nested tree into $this->flat, annotating each node with its tier.
    • associate_layouts() — instantiates a SIA_Publishing_Layout for each slug and stores it on the node.
    • associate_posts() — calls $layout->getPostIds() for each layout, stores the post object on the node if exactly one post is found, parses post blocks into $this->blockInventory, and applies the two sitemap filters.
  4. The assembled data is written to a transient.

Sitemap filters

Two WordPress filters modify the inventory during associate_posts():

  • sitemap/associate_posts — controls whether a node is visible at all. The default implementation in SIA_Publishing_Public::maybe_showPageInSitemap() hides certain pages (e.g. articles, jury listings, registration pages) based on the current programme phase.
  • sitemap/highlight_posts — marks a node as highlighted in navigation. The default implementation highlights about-application-host and community-voting-landing-page-host during their active phases.

Both filters receive the layout slug and the associated post, and must return a boolean or WP_Error.

Cache busting

SIA_Publishing_Sitemap::__bust_cache($blogId) deletes all transient variants for __construct and get_breadcrumbs. It is called automatically on wp_after_insert_post and can be triggered manually:

wp publishing bust-sitemap-cache [--site=<ID>] [--network]

Applying a layout: applyTo()

SIA_Publishing_Layout::applyTo($postId, $updateField, $force, $preserveContent) is the core operation.

  1. Validates the post exists and is of a supported type (pages only).
  2. Guards against conflicts: refuses if a different layout is already assigned, or if the same version is already applied — unless $force = TRUE.
  3. Writes the three post meta fields.
  4. If $preserveContent = TRUE, stops here — used to register an existing page into the repository without overwriting its content.
  5. Generates content: calls buildPostContent() on Stencil or blog-specific layouts, clonePostContent() on all others.
  6. Updates the wp_posts row directly via $wpdb->update: sets post_content, post_excerpt, and (on first assignment) post_name and post_title.

Content is generated in one of two ways:

  • buildPostContent() — iterates $this->blocks, wraps core blocks in Gutenberg comment syntax, calls $block->getGutenbergMarkup() for custom blocks.
  • clonePostContent() — switches to STENCIL_BLOG_ID, retrieves the Stencil post via SIA_Publishing_Sitemap, copies post_content, post_title, and post_excerpt.

Block restrictions in the editor

SIA_Publishing_Admin::allowed_block_types() limits the block inserter for layout-managed pages:

  • Default: only heading blocks are permitted — editors fill fields within blocks, they don’t add new ones.
  • If the layout declares coreBlocks: those specific WordPress core block types are also permitted.
  • If bypassAllowedBlocks = TRUE: no restrictions apply.
  • Users with REVIEW_PERMISSION and DEBUG_LAYOUTS set in the environment bypass all restrictions.

Content inventory visualisation

An interactive D3.js sitemap overview is maintained at public/sitemap-overview.html. It renders all five site types (Candidate, Host, International, Retreat, Summit) as zoomable horizontal trees and exposes per-page detail in a click-to-open modal.

Layout repository fields surfaced in the overview

In addition to the core fields listed under the layout repository above, the following optional fields are read by the overview:

  • audience — primary intended audience for the page (e.g. alumni, partner)
  • pageUsage — functional role of the page independent of its position in the hierarchy (e.g. info)

Both fields are optional and absent from most layouts.

JSON data file

public/sitemap-data.json supplies live page titles and excerpts from WordPress to the overview. It is generated by:

wp publishing sitemap-prototype-data [--output=<path>]

The command switches to STENCIL_BLOG_ID to collect Candidate and Host content, then to INTERNATIONAL_BLOG_ID for International. For each site type it instantiates SIA_Publishing_Sitemap, iterates $sitemap->flat, and — for every node that has an associated post — writes a keyed entry containing title, excerpt, audience, pageUsage, and version. The last three come from SIA_Publishing_Layouts_Repository::get_layouts() keyed by slug. Retreat and Summit content is not included; the overview falls back to its hardcoded values for those site types.

The JSON is loaded at runtime via fetch('sitemap-data.json') relative to the HTML file. If the file is absent or the fetch fails the overview renders from its inventory silently.

Workflow

When the inventory or layout repository changes (new page added, slug renamed, hierarchy changed): update the inventory in public/sitemap-overview.html to match. The inventory only defines the tree shape — slugs, inNav flags, and parent–child relationships. Field values (title, excerpt, audience, pageUsage, version) are overwritten at runtime from the JSON and do not need to be kept in sync manually.

When Stencil or International page content changes (title or excerpt edited): run the command on production using --output to write into the gitignored _data/ folder at the repo root, keeping the production working tree clean:

wp publishing sitemap-prototype-data --output=_data

Then copy _data/sitemap-data.json to public/sitemap-data.json locally and commit from there.

To publish changes to the design system: run .bin/mirror-docs.sh from the main repo root. The script copies both sitemap-overview.html and sitemap-data.json to the submodule, rebuilds the library, and commits and pushes the submodule. Run this after any change to either file.

Design system integration

sitemap-overview.html and sitemap-data.json are mirrored into the design system submodule by .bin/mirror-docs.sh using mirror_asset, which copies both files verbatim to assets/src/. The build pipeline copies assets/src/ to assets/dist/, making them accessible as static assets served from the library root.

A documentation page at docs/publishing-and-play/publishing/05-sitemap-overview.md embeds the overview in an <iframe src="../../../sitemap-overview.html">. The three-level relative path resolves to the static asset root in both the Fractal dev server and the compiled library/ output.

WP-CLI commands

wp publishing set-layout <slug> --page-id=<id> [--site=<id>] [--force] [--preserve-content]
wp publishing unset-layout --page-id=<id> [--site=<id>]
wp publishing create-post-with-layout <slug> [--site=<id>]
wp publishing list-layouts [--site=<id> | --license=<license>]
wp publishing validate-sitemap
wp publishing sitemap [--license_id=<id> | --blog_id=<id>]
wp publishing navigation --site=<id>
wp publishing bust-sitemap-cache [--site=<id>] [--network]
wp publishing sitemap-prototype-data [--output=<path>]