| 1 |
<?php |
| 2 |
/** |
| 3 |
* Imported Chrome — canvas takeover reader for classic themes. |
| 4 |
* |
| 5 |
* Renders the imported header/footer (stored as `wp_template_part` posts — |
| 6 |
* the SAME storage the FSE hierarchy reads natively) on ANY classic theme by |
| 7 |
* serving a plugin canvas template via the `template_include` filter (the |
| 8 |
* Elementor-Canvas pattern). The theme's templates never run on takeover |
| 9 |
* pages; the theme is never modified — no theme carries any code. |
| 10 |
* |
| 11 |
* Two takeover scopes: |
| 12 |
* site — option `zipai_chrome_mode === 'takeover'` (written by the |
| 13 |
* importer after a successful site-wide chrome write): every core |
| 14 |
* content request renders through the canvas with the imported |
| 15 |
* header/footer around it. |
| 16 |
* page — post meta `zipai_chrome_takeover` (standalone imports, chrome |
| 17 |
* embedded in post_content): only that page renders through the |
| 18 |
* canvas, bare. |
| 19 |
* |
| 20 |
* Registering the option (show_in_rest) doubles as the backend's capability |
| 21 |
* probe: the key appearing in `GET /wp/v2/settings` is how the importer |
| 22 |
* knows this reader exists BEFORE writing anything. Rollback = empty the |
| 23 |
* option; the theme's own chrome returns untouched. |
| 24 |
* |
| 25 |
* @since 0.0.8 |
| 26 |
* @package zip-ai |
| 27 |
*/ |
| 28 |
|
| 29 |
namespace ZipAI\MCP\Classes\Core; |
| 30 |
|
| 31 |
if ( ! defined( 'ABSPATH' ) ) { |
| 32 |
exit; |
| 33 |
} |
| 34 |
|
| 35 |
/** |
| 36 |
* Canvas takeover reader — see the file header for the architecture. |
| 37 |
* |
| 38 |
* Lane structure: register_contract() is lane-agnostic (the option/meta are |
| 39 |
* the activation contract for ANY reader lane); takeover_scope() is the one |
| 40 |
* decision point a future lane extends. No theme conditionals live here — |
| 41 |
* the only theme-shaped checks are core's `wp_is_block_theme()` release |
| 42 |
* guard and the follow-the-theme re-home (maybe_follow_theme), which keys |
| 43 |
* on the active stylesheet, never on a theme's name. |
| 44 |
* |
| 45 |
* Follow-the-theme: chrome parts are written under the import-time theme's |
| 46 |
* `wp_theme` term, so a later theme switch would otherwise resolve stale or |
| 47 |
* missing parts (and kill the page-scope JS riding the header part). The |
| 48 |
* re-home lane promotes the site's zip-marked parts to the new theme's term |
| 49 |
* (term-append, marker-gated) and, for block themes on takeover sites, |
| 50 |
* ensures the bare zip `page` template — so switching themes keeps the |
| 51 |
* imported site intact in BOTH directions, classic and FSE. |
| 52 |
*/ |
| 53 |
class Imported_Chrome { |
| 54 |
|
| 55 |
const OPTION_KEY = 'zipai_chrome_mode'; |
| 56 |
const MODE_TAKEOVER = 'takeover'; |
| 57 |
const PAGE_META_KEY = 'zipai_chrome_takeover'; |
| 58 |
|
| 59 |
/** |
| 60 |
* The stylesheet the imported chrome is currently "homed" to (see the |
| 61 |
* class header). Autoloaded and compared against the active stylesheet |
| 62 |
* on every request, so ANY switch — admin UI, WP-CLI, or one made while |
| 63 |
* this plugin was deactivated — heals on the next request. |
| 64 |
*/ |
| 65 |
const THEME_HOME_OPTION = 'zipai_chrome_theme'; |
| 66 |
|
| 67 |
/** |
| 68 |
* Class the importer stamps on every imported part's root container — the |
| 69 |
* provenance marker separating zip-written chrome from user- or |
| 70 |
* theme-authored `wp_template_part` posts. Anything unmarked is treated |
| 71 |
* as user-owned: never promoted, never displaced. |
| 72 |
*/ |
| 73 |
const ZIP_PART_MARKER = 'zipai-part-root'; |
| 74 |
|
| 75 |
/** |
| 76 |
* Meta stamped on the `page` wp_template the re-home lane ensures — |
| 77 |
* ZIP_PART_MARKER's template-tier sibling. It is what lets |
| 78 |
* conceal_released_template() stand the template down on rollback |
| 79 |
* without ever touching a user- or build-authored template. |
| 80 |
*/ |
| 81 |
const TEMPLATE_META_KEY = '_zipai_chrome_template'; |
| 82 |
|
| 83 |
/** |
| 84 |
* Import marker the committer stamps on every imported page (registered |
| 85 |
* by spectra-blocks' AssetLoader). The canvas prints `the_title()` only |
| 86 |
* for singulars WITHOUT it — imported pages carry their H1 in content. |
| 87 |
*/ |
| 88 |
const IMPORT_MARKER_META_KEY = '_zipai_imported'; |
| 89 |
|
| 90 |
/** |
| 91 |
* The resolved scope for the current request — set by maybe_takeover() |
| 92 |
* and read by the canvas template ('site' | 'page'). |
| 93 |
* |
| 94 |
* @var string |
| 95 |
*/ |
| 96 |
public static $scope = ''; |
| 97 |
|
| 98 |
/** |
| 99 |
* Always-on core service (loader.php precedent: Snippet_Executor, |
| 100 |
* Plugin_Abilities_Toggler). Cheap no-op everywhere it doesn't apply: |
| 101 |
* the filter exits on one autoloaded get_option / block-theme check. |
| 102 |
* |
| 103 |
* @since 0.0.8 |
| 104 |
* @return void |
| 105 |
*/ |
| 106 |
public static function init(): void { |
| 107 |
add_action( 'init', array( __CLASS__, 'register_contract' ) ); |
| 108 |
// After register_contract, before any template resolution: the first |
| 109 |
// request after a theme switch re-homes the chrome, so even that very |
| 110 |
// first front-end view renders the right parts. |
| 111 |
add_action( 'init', array( __CLASS__, 'maybe_follow_theme' ), 20 ); |
| 112 |
add_action( 'after_setup_theme', array( __CLASS__, 'enable_part_editing' ) ); |
| 113 |
add_filter( 'template_include', array( __CLASS__, 'maybe_takeover' ), PHP_INT_MAX - 10 ); |
| 114 |
// FSE mirror of the canvas's per-request option check: the ensured |
| 115 |
// `page` template stands down while takeover is off (the rollback |
| 116 |
// contract in the file header). |
| 117 |
add_filter( 'get_block_templates', array( __CLASS__, 'conceal_released_template' ) ); |
| 118 |
} |
| 119 |
|
| 120 |
/** |
| 121 |
* Native edit surface for the imported chrome on CLASSIC themes: core's |
| 122 |
* `block-template-parts` support (WP 6.1+) adds Appearance → Template |
| 123 |
* Parts — a block-editor screen over the SAME `wp_template_part` posts |
| 124 |
* the canvas renders, so a user edit shows on the next front-end load |
| 125 |
* with no importer round-trip. Gated on the takeover option: the menu |
| 126 |
* appears exactly when the canvas owns the chrome (standalone-only sites |
| 127 |
* edit their chrome inside the page itself). Block themes already have |
| 128 |
* the full Site Editor — the flag is theirs to ignore. |
| 129 |
* |
| 130 |
* @since 0.0.8 |
| 131 |
* @return void |
| 132 |
*/ |
| 133 |
public static function enable_part_editing(): void { |
| 134 |
if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) { |
| 135 |
return; |
| 136 |
} |
| 137 |
if ( self::is_takeover_active() ) { |
| 138 |
add_theme_support( 'block-template-parts' ); |
| 139 |
} |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* Register the activation contract — both REST-visible because the |
| 144 |
* importer writes them over `/wp/v2/settings` and `/wp/v2/pages/{id}`, |
| 145 |
* and the OPTION key's presence in GET /wp/v2/settings is the backend's |
| 146 |
* capability probe (probeChromeReader). |
| 147 |
* |
| 148 |
* @since 0.0.8 |
| 149 |
* @return void |
| 150 |
*/ |
| 151 |
public static function register_contract(): void { |
| 152 |
register_setting( |
| 153 |
'general', |
| 154 |
self::OPTION_KEY, |
| 155 |
array( |
| 156 |
'type' => 'string', |
| 157 |
'show_in_rest' => true, |
| 158 |
'default' => '', |
| 159 |
'sanitize_callback' => 'sanitize_key', |
| 160 |
) |
| 161 |
); |
| 162 |
register_post_meta( |
| 163 |
'page', |
| 164 |
self::PAGE_META_KEY, |
| 165 |
array( |
| 166 |
'type' => 'string', |
| 167 |
'single' => true, |
| 168 |
'show_in_rest' => true, |
| 169 |
'sanitize_callback' => 'sanitize_key', |
| 170 |
'auth_callback' => function () { |
| 171 |
return current_user_can( 'edit_pages' ); |
| 172 |
}, |
| 173 |
) |
| 174 |
); |
| 175 |
} |
| 176 |
|
| 177 |
/** |
| 178 |
* The `template_include` gate — THIN, ordered guards; the first hit |
| 179 |
* returns the incoming template untouched. |
| 180 |
* |
| 181 |
* @since 0.0.8 |
| 182 |
* @param string $template The template WordPress resolved. |
| 183 |
* @return string |
| 184 |
*/ |
| 185 |
public static function maybe_takeover( $template ) { |
| 186 |
// 1. Never outside a normal frontend render. |
| 187 |
if ( is_admin() || is_feed() || is_embed() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { |
| 188 |
return $template; |
| 189 |
} |
| 190 |
// 2. Resolve the takeover scope (null = leave the theme alone). |
| 191 |
$scope = self::takeover_scope(); |
| 192 |
if ( null === $scope ) { |
| 193 |
return $template; |
| 194 |
} |
| 195 |
// 3. Block themes render SITE-wide chrome natively via the FSE |
| 196 |
// hierarchy — the canvas must never fight the native engine for |
| 197 |
// site scope (covers option-still-set after a classic→FSE theme |
| 198 |
// switch). Marker ('page') scope stays on the canvas even on block |
| 199 |
// themes: a standalone page's chrome is EMBEDDED in post_content, |
| 200 |
// and a native template would double-wrap it with theme chrome. |
| 201 |
if ( 'site' === $scope && function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) { |
| 202 |
return $template; |
| 203 |
} |
| 204 |
// 4. A missing canvas file (broken deploy) must degrade to the |
| 205 |
// theme, never white-screen the site — the documented |
| 206 |
// template_include pitfall. |
| 207 |
$canvas = ZIPAI_MCP_DIR . 'inc/templates/imported-chrome-canvas.php'; |
| 208 |
if ( ! is_readable( $canvas ) ) { |
| 209 |
return $template; |
| 210 |
} |
| 211 |
self::$scope = $scope; |
| 212 |
return $canvas; |
| 213 |
} |
| 214 |
|
| 215 |
/** |
| 216 |
* PURE scope decision — the one place a future reader lane extends. |
| 217 |
* |
| 218 |
* 'page' — singular carrying the standalone marker (checked FIRST: |
| 219 |
* the marker WINS over the site option, so a page with |
| 220 |
* embedded chrome is never double-wrapped by site parts). |
| 221 |
* 'site' — option active, core content only (never CPT territory), |
| 222 |
* no explicit page-template assignment, and at least one |
| 223 |
* stored part resolves (else RELEASE — e.g. after a |
| 224 |
* classic→classic theme switch the parts are keyed to the |
| 225 |
* old theme; a naked canvas is strictly worse than the |
| 226 |
* theme's own chrome). |
| 227 |
* null — no takeover. |
| 228 |
* |
| 229 |
* @since 0.0.8 |
| 230 |
* @return string|null |
| 231 |
*/ |
| 232 |
public static function takeover_scope() { |
| 233 |
$post_id = is_singular() ? get_the_ID() : false; |
| 234 |
// Marker wins over the site option (anti-double-chrome) — but a |
| 235 |
// LATER explicit template assignment on the page wins over the |
| 236 |
// marker: the user took ownership of that page's rendering. |
| 237 |
if ( |
| 238 |
false !== $post_id |
| 239 |
&& '' !== self::meta_string( $post_id, self::PAGE_META_KEY ) |
| 240 |
&& '' === (string) get_page_template_slug( $post_id ) |
| 241 |
) { |
| 242 |
return 'page'; |
| 243 |
} |
| 244 |
if ( ! self::is_takeover_active() ) { |
| 245 |
return null; |
| 246 |
} |
| 247 |
// Core content only: pages, posts, home, CORE archives (category/ |
| 248 |
// tag/date/author), search, 404. CPT singulars, post-type archives |
| 249 |
// and custom-taxonomy archives (Woo, LMS, …) keep their own |
| 250 |
// machinery — bare is_archive() would hijack /shop. |
| 251 |
$is_core_archive = is_archive() && ! is_post_type_archive() && ! is_tax(); |
| 252 |
$is_core_content = is_singular( array( 'page', 'post' ) ) || is_home() || $is_core_archive || is_search() || is_404(); |
| 253 |
if ( ! $is_core_content ) { |
| 254 |
return null; |
| 255 |
} |
| 256 |
// An explicitly assigned page template (Elementor Canvas, theme |
| 257 |
// full-width, …) always wins — structural respect, no name-lists. |
| 258 |
if ( false !== $post_id && '' !== (string) get_page_template_slug( $post_id ) ) { |
| 259 |
return null; |
| 260 |
} |
| 261 |
// Release when nothing resolves: parts are keyed to the import-time |
| 262 |
// theme term; render the theme's own chrome instead of an empty shell. |
| 263 |
if ( '' === self::imported_part( 'header' ) && '' === self::imported_part( 'footer' ) ) { |
| 264 |
return null; |
| 265 |
} |
| 266 |
return 'site'; |
| 267 |
} |
| 268 |
|
| 269 |
/** |
| 270 |
* Whether site-wide takeover is switched on — STRICT comparison, so the |
| 271 |
* legacy theme-prototype value 'astra' (and any other value) never |
| 272 |
* engages the plugin lane. Public seam: theme-independent, so tests can |
| 273 |
* assert the mutual-exclusion contract without a main query or a |
| 274 |
* classic theme active. |
| 275 |
* |
| 276 |
* @since 0.0.8 |
| 277 |
* @return bool |
| 278 |
*/ |
| 279 |
public static function is_takeover_active(): bool { |
| 280 |
return self::MODE_TAKEOVER === get_option( self::OPTION_KEY ); |
| 281 |
} |
| 282 |
|
| 283 |
/** |
| 284 |
* The imported template part's block markup for an area, or '' when |
| 285 |
* absent. Parts are namespaced by the ACTIVE stylesheet (child-theme |
| 286 |
* safe — the importer addresses writes the same way); parent slug is |
| 287 |
* the fallback. |
| 288 |
* |
| 289 |
* @since 0.0.8 |
| 290 |
* @param string $slug 'header' or 'footer'. |
| 291 |
* @return string Block markup. |
| 292 |
*/ |
| 293 |
public static function imported_part( $slug ): string { |
| 294 |
// ponytail: two keyed queries per takeover view; add a transient |
| 295 |
// cache keyed theme+slug (invalidate on wp_template_part save) only |
| 296 |
// if profiling ever shows it. |
| 297 |
// |
| 298 |
// `post_name__in`, NOT `name`: a `name` query is singular-shaped and |
| 299 |
// WP_Query silently DROPS tax_query on singular queries — the theme |
| 300 |
// prototype's lookup was never actually theme-scoped ("newest part |
| 301 |
// wins globally", masked because the active theme's parts were |
| 302 |
// always newest; measured live 2026-07-13 on a theme switch). |
| 303 |
foreach ( array_unique( array( get_stylesheet(), get_template() ) ) as $theme ) { |
| 304 |
$posts = get_posts( |
| 305 |
array( |
| 306 |
'post_type' => 'wp_template_part', |
| 307 |
'post_name__in' => array( $slug ), |
| 308 |
'post_status' => 'publish', |
| 309 |
'numberposts' => 1, |
| 310 |
'orderby' => 'date', |
| 311 |
'order' => 'DESC', |
| 312 |
'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query |
| 313 |
array( |
| 314 |
'taxonomy' => 'wp_theme', |
| 315 |
'field' => 'slug', |
| 316 |
'terms' => $theme, |
| 317 |
), |
| 318 |
), |
| 319 |
) |
| 320 |
); |
| 321 |
if ( ! empty( $posts ) && '' !== $posts[0]->post_content ) { |
| 322 |
return $posts[0]->post_content; |
| 323 |
} |
| 324 |
} |
| 325 |
return ''; |
| 326 |
} |
| 327 |
|
| 328 |
/** |
| 329 |
* Make the imported chrome FOLLOW a theme switch. |
| 330 |
* |
| 331 |
* Runs on every request; exits on one autoloaded-option compare in the |
| 332 |
* steady state. On a mismatch (the active stylesheet is not the one the |
| 333 |
* chrome is homed to) it re-homes once, then stamps the new home. Lazy |
| 334 |
* option-compare instead of the `switch_theme` action so switches that |
| 335 |
* fire where the hook can't see them — WP-CLI on another process model, |
| 336 |
* a switch while this plugin was deactivated — still heal on the next |
| 337 |
* request. |
| 338 |
* |
| 339 |
* Never during a theme PREVIEW: `get_stylesheet()` is filtered to the |
| 340 |
* previewed theme there, and re-homing on a preview would move the chrome |
| 341 |
* under a theme the admin is only looking at. |
| 342 |
* |
| 343 |
* Deliberately un-locked: the heal is idempotent (term-append and de-term |
| 344 |
* converge), so simultaneous first requests after a switch — or a |
| 345 |
* per-request `stylesheet` filter re-healing every load — cost redundant |
| 346 |
* reads at worst. A lock's stuck-state failure mode (the heal never runs |
| 347 |
* again) is the worse trade. |
| 348 |
* |
| 349 |
* @since 0.0.9 |
| 350 |
* @return void |
| 351 |
*/ |
| 352 |
public static function maybe_follow_theme(): void { |
| 353 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only presence check; no state derives from the value. |
| 354 |
if ( is_customize_preview() || isset( $_GET['wp_theme_preview'] ) ) { |
| 355 |
return; |
| 356 |
} |
| 357 |
$current = get_stylesheet(); |
| 358 |
$homed = get_option( self::THEME_HOME_OPTION, '' ); |
| 359 |
if ( $current === $homed ) { |
| 360 |
return; |
| 361 |
} |
| 362 |
self::rehome_chrome( is_string( $homed ) ? $homed : '', $current ); |
| 363 |
update_option( self::THEME_HOME_OPTION, $current ); |
| 364 |
} |
| 365 |
|
| 366 |
/** |
| 367 |
* Re-home the site's imported chrome from one theme term to another. |
| 368 |
* |
| 369 |
* PROMOTION IS TERM-APPEND, NEVER A CONTENT COPY. Appending the new |
| 370 |
* `wp_theme` term to the SAME `wp_template_part` post means: no content |
| 371 |
* write (so the `spectraCustomJS` riding the part — scroll reveals, nav |
| 372 |
* toggle — can never be stripped by the save-time JS gate, which only |
| 373 |
* lets snippets through for `unfiltered_html` users), no duplicate posts, |
| 374 |
* and edits made under one theme show under every other. A part |
| 375 |
* accumulates one term per theme it has served; switching back is a |
| 376 |
* no-op append. |
| 377 |
* |
| 378 |
* DISPLACEMENT IS MARKER-GATED. A zip-marked part already carrying the |
| 379 |
* new theme's term is a STALE SIBLING — chrome of whatever site was last |
| 380 |
* built under that theme (measured live 2026-08-17: without this, core's |
| 381 |
* newest-first part resolution rendered that previous site's header and |
| 382 |
* footer). It loses the term, nothing else — content and its other terms |
| 383 |
* survive. An UNMARKED part in the slot means the slot is user- or |
| 384 |
* theme-authored: the whole slug is left alone, promotion included. |
| 385 |
* |
| 386 |
* Public seam (like {@see self::is_takeover_active()}): tests drive it |
| 387 |
* with throwaway theme terms, so the live slots never move. |
| 388 |
* |
| 389 |
* @since 0.0.9 |
| 390 |
* @param string $from Stylesheet the chrome is homed to ('' = unknown, first run). |
| 391 |
* @param string $to Stylesheet to home it to (the active theme). |
| 392 |
* @return void |
| 393 |
*/ |
| 394 |
public static function rehome_chrome( string $from, string $to ): void { |
| 395 |
$promoted = 0; |
| 396 |
foreach ( array( 'header', 'footer' ) as $slug ) { |
| 397 |
$source = null; |
| 398 |
foreach ( self::part_posts( $slug, $from ) as $candidate ) { |
| 399 |
if ( self::is_zip_part( $candidate ) ) { |
| 400 |
$source = $candidate; |
| 401 |
break; |
| 402 |
} |
| 403 |
} |
| 404 |
if ( null === $source ) { |
| 405 |
// First run, or the home option predates the last build: |
| 406 |
// the newest zip-marked part IS the current site's chrome |
| 407 |
// (the importer updates the site-wide slot in place, so the |
| 408 |
// newest modified part is always the live site's). |
| 409 |
$source = self::newest_zip_part( $slug ); |
| 410 |
} |
| 411 |
if ( null === $source ) { |
| 412 |
continue; |
| 413 |
} |
| 414 |
$stale = array(); |
| 415 |
$foreign = false; |
| 416 |
foreach ( self::part_posts( $slug, $to ) as $occupant ) { |
| 417 |
if ( $occupant->ID === $source->ID ) { |
| 418 |
continue; |
| 419 |
} |
| 420 |
if ( self::is_zip_part( $occupant ) ) { |
| 421 |
$stale[] = $occupant->ID; |
| 422 |
} else { |
| 423 |
$foreign = true; |
| 424 |
} |
| 425 |
} |
| 426 |
if ( $foreign ) { |
| 427 |
continue; |
| 428 |
} |
| 429 |
wp_set_object_terms( $source->ID, $to, 'wp_theme', true ); |
| 430 |
foreach ( $stale as $stale_id ) { |
| 431 |
wp_remove_object_terms( $stale_id, $to, 'wp_theme' ); |
| 432 |
} |
| 433 |
++$promoted; |
| 434 |
} |
| 435 |
// Block themes render through the FSE hierarchy, so a site whose |
| 436 |
// chrome the canvas owned site-wide (takeover) also needs the bare |
| 437 |
// zip `page` template there — without it the theme's own page |
| 438 |
// template wraps every imported page in its banner/title/width. |
| 439 |
if ( $promoted > 0 && self::is_takeover_active() && wp_is_block_theme() ) { |
| 440 |
self::ensure_page_template( $to ); |
| 441 |
} |
| 442 |
} |
| 443 |
|
| 444 |
/** |
| 445 |
* All published parts in a theme's slot. |
| 446 |
* |
| 447 |
* `post_name__in`, NOT `name` — a `name` query is singular-shaped and |
| 448 |
* WP_Query silently DROPS tax_query on singular queries (measured live |
| 449 |
* 2026-07-13; same trap as {@see self::imported_part()}). |
| 450 |
* |
| 451 |
* @since 0.0.9 |
| 452 |
* @param string $slug 'header' or 'footer'. |
| 453 |
* @param string $theme Theme term slug ('' returns nothing). |
| 454 |
* @return \WP_Post[] |
| 455 |
*/ |
| 456 |
private static function part_posts( string $slug, string $theme ): array { |
| 457 |
if ( '' === $theme ) { |
| 458 |
return array(); |
| 459 |
} |
| 460 |
return get_posts( |
| 461 |
array( |
| 462 |
'post_type' => 'wp_template_part', |
| 463 |
'post_name__in' => array( $slug ), |
| 464 |
'post_status' => 'publish', |
| 465 |
'numberposts' => -1, |
| 466 |
// Modified DESC — the SAME invariant as newest_zip_part(): |
| 467 |
// the importer upserts the slot in place, so created dates |
| 468 |
// lie. rehome_chrome()'s source pick takes the FIRST zip part |
| 469 |
// returned; without this the created-date default could hand |
| 470 |
// it a stale sibling as the promotion source. |
| 471 |
'orderby' => 'modified', |
| 472 |
'order' => 'DESC', |
| 473 |
'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query |
| 474 |
array( |
| 475 |
'taxonomy' => 'wp_theme', |
| 476 |
'field' => 'slug', |
| 477 |
'terms' => $theme, |
| 478 |
), |
| 479 |
), |
| 480 |
) |
| 481 |
); |
| 482 |
} |
| 483 |
|
| 484 |
/** |
| 485 |
* The newest zip-marked part in a slot, across ALL theme terms. |
| 486 |
* |
| 487 |
* Ordered by MODIFIED: the importer upserts the site-wide slot in place, |
| 488 |
* so the most recently modified zip part is the live site's chrome even |
| 489 |
* when its created date is old. |
| 490 |
* |
| 491 |
* @since 0.0.9 |
| 492 |
* @param string $slug 'header' or 'footer'. |
| 493 |
* @return \WP_Post|null |
| 494 |
*/ |
| 495 |
private static function newest_zip_part( string $slug ): ?\WP_Post { |
| 496 |
$posts = get_posts( |
| 497 |
array( |
| 498 |
'post_type' => 'wp_template_part', |
| 499 |
'post_name__in' => array( $slug ), |
| 500 |
'post_status' => 'publish', |
| 501 |
'numberposts' => -1, |
| 502 |
'orderby' => 'modified', |
| 503 |
'order' => 'DESC', |
| 504 |
) |
| 505 |
); |
| 506 |
foreach ( $posts as $post ) { |
| 507 |
if ( self::is_zip_part( $post ) ) { |
| 508 |
return $post; |
| 509 |
} |
| 510 |
} |
| 511 |
return null; |
| 512 |
} |
| 513 |
|
| 514 |
/** |
| 515 |
* Whether a template part was written by the importer. |
| 516 |
* |
| 517 |
* @since 0.0.9 |
| 518 |
* @param \WP_Post $post Template-part post. |
| 519 |
* @return bool |
| 520 |
*/ |
| 521 |
private static function is_zip_part( \WP_Post $post ): bool { |
| 522 |
return false !== strpos( $post->post_content, self::ZIP_PART_MARKER ); |
| 523 |
} |
| 524 |
|
| 525 |
/** |
| 526 |
* Ensure the bare zip `page` wp_template exists for a block theme. |
| 527 |
* |
| 528 |
* Imported pages carry their own H1 and full-width sections in |
| 529 |
* post_content; a theme's page template (title banner + constrained |
| 530 |
* content) double-titles and squeezes them. The bare template is |
| 531 |
* header part + full-width post-content + footer part — the same shape |
| 532 |
* the importer writes on FSE-delivery builds. |
| 533 |
* |
| 534 |
* Resolution order: an existing `page` template for this theme wins |
| 535 |
* (zip-written or user-authored alike); else a template this lane |
| 536 |
* previously stamped ({@see self::TEMPLATE_META_KEY}) under ANOTHER term |
| 537 |
* is term-appended (its part refs keep resolving — the parts accumulate |
| 538 |
* terms the same way); else a |
| 539 |
* fresh insert. The fresh insert forces its exact slug past core's |
| 540 |
* collision suffixing (`pre_wp_unique_post_slug`) — a suffixed template |
| 541 |
* matches nothing in the hierarchy, and the guard above already proved |
| 542 |
* this theme's slot is empty. Every template this lane claims — reused |
| 543 |
* or fresh — is stamped {@see self::TEMPLATE_META_KEY} so |
| 544 |
* {@see self::conceal_released_template()} can stand it down on rollback. |
| 545 |
* |
| 546 |
* Public seam (like {@see self::rehome_chrome()}): tests exercise the |
| 547 |
* reuse branch with a throwaway term. |
| 548 |
* |
| 549 |
* @since 0.0.9 |
| 550 |
* @param string $theme Active (block) theme stylesheet. |
| 551 |
* @return void |
| 552 |
*/ |
| 553 |
public static function ensure_page_template( string $theme ): void { |
| 554 |
$slot = array( |
| 555 |
'post_type' => 'wp_template', |
| 556 |
'post_name__in' => array( 'page' ), |
| 557 |
'post_status' => 'publish', |
| 558 |
'numberposts' => -1, |
| 559 |
); |
| 560 |
$for_theme = get_posts( |
| 561 |
$slot + array( |
| 562 |
'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query |
| 563 |
array( |
| 564 |
'taxonomy' => 'wp_theme', |
| 565 |
'field' => 'slug', |
| 566 |
'terms' => $theme, |
| 567 |
), |
| 568 |
), |
| 569 |
) |
| 570 |
); |
| 571 |
if ( ! empty( $for_theme ) ) { |
| 572 |
return; |
| 573 |
} |
| 574 |
// Reuse is provenance-gated exactly like part promotion: only a |
| 575 |
// template THIS lane stamped is ever appropriated. A user-authored |
| 576 |
// bare template that merely LOOKS like ours (post-content + |
| 577 |
// template-part, no title) is someone's content — appropriating it |
| 578 |
// would stamp it, and rollback would then conceal it on its home |
| 579 |
// theme too. |
| 580 |
foreach ( get_posts( $slot ) as $candidate ) { |
| 581 |
if ( '1' === get_post_meta( $candidate->ID, self::TEMPLATE_META_KEY, true ) ) { |
| 582 |
wp_set_object_terms( $candidate->ID, $theme, 'wp_theme', true ); |
| 583 |
return; |
| 584 |
} |
| 585 |
} |
| 586 |
$force = static function () { |
| 587 |
return 'page'; |
| 588 |
}; |
| 589 |
add_filter( 'pre_wp_unique_post_slug', $force, 100 ); |
| 590 |
$template_id = wp_insert_post( |
| 591 |
array( |
| 592 |
'post_type' => 'wp_template', |
| 593 |
'post_name' => 'page', |
| 594 |
'post_title' => 'Pages', |
| 595 |
'post_status' => 'publish', |
| 596 |
'post_content' => implode( |
| 597 |
"\n", |
| 598 |
array( |
| 599 |
// No `"theme"` pin on the refs: the template follows |
| 600 |
// the active theme, so active-theme-relative refs |
| 601 |
// resolve the parts this same pass just promoted. |
| 602 |
'<!-- wp:template-part {"slug":"header","tagName":"header"} /-->', |
| 603 |
'<!-- wp:post-content {"align":"full","layout":{"type":"constrained"}} /-->', |
| 604 |
'<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->', |
| 605 |
) |
| 606 |
), |
| 607 |
), |
| 608 |
true |
| 609 |
); |
| 610 |
remove_filter( 'pre_wp_unique_post_slug', $force, 100 ); |
| 611 |
if ( is_wp_error( $template_id ) ) { |
| 612 |
return; |
| 613 |
} |
| 614 |
update_post_meta( $template_id, self::TEMPLATE_META_KEY, '1' ); |
| 615 |
wp_set_object_terms( $template_id, $theme, 'wp_theme' ); |
| 616 |
} |
| 617 |
|
| 618 |
/** |
| 619 |
* Stand the ensured `page` template down while takeover is OFF. |
| 620 |
* |
| 621 |
* The bare template is the FSE rendition of the classic canvas — and the |
| 622 |
* canvas checks the option on EVERY request, so its template must obey |
| 623 |
* the same switch, or rollback (empty the option) leaves every page on a |
| 624 |
* block theme rendering bare. Concealed, never deleted: re-enabling |
| 625 |
* takeover restores it instantly (a delete could not — nothing would |
| 626 |
* re-create the template until the next theme switch), and the hierarchy |
| 627 |
* falls back to the theme's own `page` template while it is hidden. |
| 628 |
* Meta-gated provenance ({@see self::TEMPLATE_META_KEY}): only a template |
| 629 |
* this lane ensured is ever touched — user- and build-authored templates |
| 630 |
* pass through untouched, always. |
| 631 |
* |
| 632 |
* @since 0.0.9 |
| 633 |
* @param \WP_Block_Template[] $templates `get_block_templates()` result. |
| 634 |
* @return \WP_Block_Template[] |
| 635 |
*/ |
| 636 |
public static function conceal_released_template( $templates ) { |
| 637 |
if ( self::is_takeover_active() ) { |
| 638 |
return $templates; |
| 639 |
} |
| 640 |
// Never re-homed → this lane never stamped a template. One autoloaded |
| 641 |
// option compare spares every un-imported install the per-template |
| 642 |
// meta walk below. |
| 643 |
if ( '' === get_option( self::THEME_HOME_OPTION, '' ) ) { |
| 644 |
return $templates; |
| 645 |
} |
| 646 |
$kept = array(); |
| 647 |
foreach ( $templates as $template ) { |
| 648 |
if ( |
| 649 |
empty( $template->wp_id ) |
| 650 |
|| '1' !== get_post_meta( (int) $template->wp_id, self::TEMPLATE_META_KEY, true ) |
| 651 |
) { |
| 652 |
$kept[] = $template; |
| 653 |
continue; |
| 654 |
} |
| 655 |
// Concealing the custom template alone leaves a HOLE, not a |
| 656 |
// fallback (measured live 2026-08-17, wp-playground): core drops |
| 657 |
// the theme's FILE template from this result set whenever a |
| 658 |
// custom one with the same slug exists, so with ours removed the |
| 659 |
// hierarchy fell through to `index`. Removal must put the |
| 660 |
// theme's own file template back in its place. |
| 661 |
$file = get_block_file_template( get_stylesheet() . '//' . $template->slug, 'wp_template' ); |
| 662 |
if ( $file instanceof \WP_Block_Template ) { |
| 663 |
$kept[] = $file; |
| 664 |
} |
| 665 |
} |
| 666 |
return $kept; |
| 667 |
} |
| 668 |
|
| 669 |
/** |
| 670 |
* Whether the current singular post was created by the importer — the |
| 671 |
* canvas prints `the_title()` only when it was NOT (imported pages |
| 672 |
* carry their H1 in post_content; non-imported posts/pages must not |
| 673 |
* lose their headline). |
| 674 |
* |
| 675 |
* @since 0.0.8 |
| 676 |
* @return bool |
| 677 |
*/ |
| 678 |
public static function is_imported_post(): bool { |
| 679 |
$post_id = is_singular() ? get_the_ID() : false; |
| 680 |
return false !== $post_id && '' !== self::meta_string( $post_id, self::IMPORT_MARKER_META_KEY ); |
| 681 |
} |
| 682 |
|
| 683 |
/** |
| 684 |
* Single post-meta value as a string ('' when unset or non-string). |
| 685 |
* |
| 686 |
* @param int $post_id Post ID. |
| 687 |
* @param string $key Meta key. |
| 688 |
* @return string |
| 689 |
*/ |
| 690 |
private static function meta_string( int $post_id, string $key ): string { |
| 691 |
$value = get_post_meta( $post_id, $key, true ); |
| 692 |
return is_string( $value ) ? $value : ''; |
| 693 |
} |
| 694 |
} |
| 695 |
|