PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.10
ZIP AI – AI Website Builder & AI Agent v0.0.10
0.0.14 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.10, at inc/core/imported-chrome.php

695 lines 25.6 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).
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