PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.12
ZIP AI – AI Website Builder & AI Agent v0.0.12
0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / core / imported-chrome.php

imported-chrome.php in ZIP AI – AI Website Builder & AI Agent 0.0.12, at inc/core/imported-chrome.php

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