| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
namespace Yatra\Core\Template; |
| 6 |
|
| 7 |
use Yatra\Core\Routing\PageContext; |
| 8 |
|
| 9 |
/** |
| 10 |
* FseTemplates |
| 11 |
* |
| 12 |
* Make Yatra pages first-class citizens in Full Site Editing: |
| 13 |
* |
| 14 |
* - Registers virtual block templates (Single Trip, Trip Listing, Booking, |
| 15 |
* etc.) so admins can find and edit them in Appearance → Editor → Templates. |
| 16 |
* |
| 17 |
* - Registers a server-side `yatra/page-content` block that, when present in |
| 18 |
* a block template, defers to the PHP template selected by Yatra's router. |
| 19 |
* This lets an admin reorder/replace header/footer/sidebar around Yatra |
| 20 |
* content using normal FSE tools. |
| 21 |
* |
| 22 |
* - When an admin saves a customisation in the Site Editor, WordPress core |
| 23 |
* stores it as a `wp_template` post with `source = 'custom'`. We detect |
| 24 |
* that via `should_render_block_template()` and let WP render the block |
| 25 |
* template instead of our PHP template — so user edits actually take |
| 26 |
* effect, without us needing to maintain a separate FSE rendering path. |
| 27 |
* |
| 28 |
* The class is intentionally inert on classic (non-block) themes: every hook |
| 29 |
* short-circuits early via wp_is_block_theme() so there is zero cost when not |
| 30 |
* applicable. |
| 31 |
* |
| 32 |
* @package Yatra\Core\Template |
| 33 |
*/ |
| 34 |
final class FseTemplates |
| 35 |
{ |
| 36 |
/** |
| 37 |
* Plugin slug used as the namespace for block templates we register. |
| 38 |
* WordPress stores customisations against `<theme>//<slug>` for theme |
| 39 |
* templates and `<plugin>//<slug>` for plugin templates. |
| 40 |
*/ |
| 41 |
public const NAMESPACE_SLUG = 'yatra'; |
| 42 |
|
| 43 |
/** |
| 44 |
* True while a Yatra PHP template is being executed as the body of the |
| 45 |
* `yatra/page-content` server block (i.e. nested inside a block-template |
| 46 |
* canvas). yatra_get_header() / yatra_get_footer() consult this so they |
| 47 |
* don't re-emit <doctype>/<html>/<head>/<body>/header/footer chrome that |
| 48 |
* the canvas already owns. |
| 49 |
*/ |
| 50 |
private static bool $insideCanvas = false; |
| 51 |
|
| 52 |
public static function isRenderingInsideCanvas(): bool |
| 53 |
{ |
| 54 |
return self::$insideCanvas; |
| 55 |
} |
| 56 |
|
| 57 |
/** |
| 58 |
* Map of virtual template slug → metadata. |
| 59 |
* Adding a new entry here exposes a new editable layout in the Site Editor. |
| 60 |
*/ |
| 61 |
private const TEMPLATES = [ |
| 62 |
'yatra-single-trip' => [ |
| 63 |
'title' => 'Yatra: Single Trip', |
| 64 |
'description' => 'Layout used for individual trip detail pages.', |
| 65 |
'page_type' => 'trip', |
| 66 |
'php_template' => 'single-trip', |
| 67 |
'is_singular' => true, |
| 68 |
], |
| 69 |
'yatra-trip-listing' => [ |
| 70 |
'title' => 'Yatra: Trip Listing', |
| 71 |
'description' => 'Layout used for the main trip archive.', |
| 72 |
'page_type' => 'listing-trip', |
| 73 |
'php_template' => 'listing-trip', |
| 74 |
'is_singular' => false, |
| 75 |
], |
| 76 |
'yatra-destination' => [ |
| 77 |
'title' => 'Yatra: Destination', |
| 78 |
'description' => 'Layout used for destination taxonomy pages.', |
| 79 |
'page_type' => 'taxonomy-destination', |
| 80 |
'php_template' => 'single-taxonomy', |
| 81 |
'is_singular' => false, |
| 82 |
], |
| 83 |
'yatra-activity' => [ |
| 84 |
'title' => 'Yatra: Activity', |
| 85 |
'description' => 'Layout used for activity taxonomy pages.', |
| 86 |
'page_type' => 'taxonomy-activity', |
| 87 |
'php_template' => 'single-taxonomy', |
| 88 |
'is_singular' => false, |
| 89 |
], |
| 90 |
'yatra-booking' => [ |
| 91 |
'title' => 'Yatra: Booking', |
| 92 |
'description' => 'Layout used for the booking flow page.', |
| 93 |
'page_type' => 'booking', |
| 94 |
'php_template' => 'booking', |
| 95 |
'is_singular' => true, |
| 96 |
], |
| 97 |
'yatra-booking-confirmation' => [ |
| 98 |
'title' => 'Yatra: Booking Confirmation', |
| 99 |
'description' => 'Layout used for the booking confirmation page.', |
| 100 |
'page_type' => 'booking-confirmation', |
| 101 |
'php_template' => 'booking-confirmation', |
| 102 |
'is_singular' => true, |
| 103 |
], |
| 104 |
'yatra-account' => [ |
| 105 |
'title' => 'Yatra: My Account', |
| 106 |
'description' => 'Layout used for the customer account dashboard.', |
| 107 |
'page_type' => 'account', |
| 108 |
'php_template' => 'account-page', |
| 109 |
'is_singular' => true, |
| 110 |
], |
| 111 |
]; |
| 112 |
|
| 113 |
public static function init(): void |
| 114 |
{ |
| 115 |
add_action('init', [self::class, 'registerBlock']); |
| 116 |
add_action('enqueue_block_editor_assets', [self::class, 'enqueueEditorAssets']); |
| 117 |
add_filter('get_block_templates', [self::class, 'injectVirtualTemplates'], 10, 3); |
| 118 |
add_filter('get_block_template', [self::class, 'resolveVirtualTemplate'], 10, 3); |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Enqueue the JS that registers `yatra/page-content` in the block editor. |
| 123 |
* |
| 124 |
* Without this, the editor's JS-side block registry doesn't know about the |
| 125 |
* block, the deserializer fails on `<!-- wp:yatra/page-content /-->` markup |
| 126 |
* inside saved templates, and the Site Editor shows the "Your site doesn't |
| 127 |
* include support for the X block" warning. |
| 128 |
*/ |
| 129 |
public static function enqueueEditorAssets(): void |
| 130 |
{ |
| 131 |
$handle = 'yatra-block-page-content'; |
| 132 |
$relPath = 'assets/js/blocks/page-content.js'; |
| 133 |
$absPath = YATRA_PLUGIN_PATH . $relPath; |
| 134 |
if (!is_readable($absPath)) { |
| 135 |
return; |
| 136 |
} |
| 137 |
wp_enqueue_script( |
| 138 |
$handle, |
| 139 |
YATRA_PLUGIN_URL . $relPath, |
| 140 |
['wp-blocks', 'wp-element', 'wp-block-editor', 'wp-i18n'], |
| 141 |
(string) filemtime($absPath), |
| 142 |
true |
| 143 |
); |
| 144 |
if (function_exists('wp_set_script_translations')) { |
| 145 |
wp_set_script_translations($handle, 'yatra'); |
| 146 |
} |
| 147 |
} |
| 148 |
|
| 149 |
/** |
| 150 |
* Server-side render: emit the Yatra PHP template inline. |
| 151 |
* |
| 152 |
* The template path comes from PageContext (set by the router) when |
| 153 |
* available; otherwise the block's `template` attribute is used as a |
| 154 |
* fallback so the block can be previewed in the editor with explicit |
| 155 |
* configuration. |
| 156 |
* |
| 157 |
* Output buffering captures the included template safely even if it |
| 158 |
* echoes directly. |
| 159 |
* |
| 160 |
* @param array $attributes Block attributes (supports 'template'). |
| 161 |
* @return string Rendered HTML. |
| 162 |
*/ |
| 163 |
public static function renderPageContentBlock(array $attributes): string |
| 164 |
{ |
| 165 |
$ctx = PageContext::instance(); |
| 166 |
$path = $ctx->getTemplate(); |
| 167 |
|
| 168 |
if (($path === null || $path === '') && !empty($attributes['template'])) { |
| 169 |
$name = ltrim((string) $attributes['template'], '/'); |
| 170 |
if (substr($name, -4) !== '.php') { |
| 171 |
$name .= '.php'; |
| 172 |
} |
| 173 |
$candidate = YATRA_PLUGIN_PATH . 'templates/' . $name; |
| 174 |
if (is_readable($candidate)) { |
| 175 |
$path = $candidate; |
| 176 |
} |
| 177 |
} |
| 178 |
|
| 179 |
if ($path === null || $path === '' || !is_readable($path)) { |
| 180 |
return ''; |
| 181 |
} |
| 182 |
|
| 183 |
// Mark "inside canvas" for the duration of the include so that |
| 184 |
// yatra_get_header() / yatra_get_footer() inside the template don't |
| 185 |
// re-emit document chrome. try/finally guarantees the flag is reset |
| 186 |
// even if the template throws. |
| 187 |
$previous = self::$insideCanvas; |
| 188 |
self::$insideCanvas = true; |
| 189 |
ob_start(); |
| 190 |
try { |
| 191 |
include $path; |
| 192 |
} finally { |
| 193 |
self::$insideCanvas = $previous; |
| 194 |
} |
| 195 |
return (string) ob_get_clean(); |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* Register the dynamic block. Safe to call on every request — register_block_type |
| 200 |
* is idempotent against re-registration of the same name (it returns false). |
| 201 |
*/ |
| 202 |
public static function registerBlock(): void |
| 203 |
{ |
| 204 |
if (!function_exists('register_block_type')) { |
| 205 |
return; |
| 206 |
} |
| 207 |
if (\WP_Block_Type_Registry::get_instance()->is_registered('yatra/page-content')) { |
| 208 |
return; |
| 209 |
} |
| 210 |
register_block_type('yatra/page-content', [ |
| 211 |
'api_version' => 2, |
| 212 |
'title' => 'Yatra Page Content', |
| 213 |
'description' => 'Renders the current Yatra page (single trip, listing, booking, etc.) inside a block template.', |
| 214 |
'category' => 'theme', |
| 215 |
'supports' => [ |
| 216 |
'html' => false, |
| 217 |
'reusable' => false, |
| 218 |
], |
| 219 |
'attributes' => [ |
| 220 |
'template' => [ |
| 221 |
'type' => 'string', |
| 222 |
'default' => '', |
| 223 |
], |
| 224 |
], |
| 225 |
'render_callback' => [self::class, 'renderPageContentBlock'], |
| 226 |
]); |
| 227 |
} |
| 228 |
|
| 229 |
/** |
| 230 |
* Inject virtual templates into the Site Editor's template list. |
| 231 |
* Skipped for classic themes — they don't use block templates. |
| 232 |
* |
| 233 |
* @param \WP_Block_Template[] $query_result |
| 234 |
* @param array $query |
| 235 |
* @param string $template_type 'wp_template' or 'wp_template_part'. |
| 236 |
* @return \WP_Block_Template[] |
| 237 |
*/ |
| 238 |
public static function injectVirtualTemplates(array $query_result, array $query, string $template_type): array |
| 239 |
{ |
| 240 |
if ($template_type !== 'wp_template' || !function_exists('wp_is_block_theme') || !wp_is_block_theme()) { |
| 241 |
return $query_result; |
| 242 |
} |
| 243 |
|
| 244 |
$existing = []; |
| 245 |
foreach ($query_result as $tpl) { |
| 246 |
if (isset($tpl->slug)) { |
| 247 |
$existing[$tpl->slug] = true; |
| 248 |
} |
| 249 |
} |
| 250 |
|
| 251 |
foreach (self::TEMPLATES as $slug => $meta) { |
| 252 |
if (isset($existing[$slug])) { |
| 253 |
continue; // User has already customised this template. |
| 254 |
} |
| 255 |
// Respect query filters (slug__in, post_type, etc.) the editor passes. |
| 256 |
if (!empty($query['slug__in']) && !in_array($slug, (array) $query['slug__in'], true)) { |
| 257 |
continue; |
| 258 |
} |
| 259 |
$query_result[] = self::buildVirtualTemplate($slug, $meta); |
| 260 |
} |
| 261 |
|
| 262 |
return $query_result; |
| 263 |
} |
| 264 |
|
| 265 |
/** |
| 266 |
* Resolve a single template by id (`<namespace>//<slug>`) — used when an |
| 267 |
* admin opens a Yatra template directly in the Site Editor. |
| 268 |
* |
| 269 |
* @param \WP_Block_Template|null $template |
| 270 |
* @param string $id |
| 271 |
* @param string $template_type |
| 272 |
* @return \WP_Block_Template|null |
| 273 |
*/ |
| 274 |
public static function resolveVirtualTemplate($template, string $id, string $template_type) |
| 275 |
{ |
| 276 |
if ($template !== null || $template_type !== 'wp_template') { |
| 277 |
return $template; |
| 278 |
} |
| 279 |
if (!function_exists('wp_is_block_theme') || !wp_is_block_theme()) { |
| 280 |
return $template; |
| 281 |
} |
| 282 |
|
| 283 |
// id format: "<namespace>//<slug>" |
| 284 |
$parts = explode('//', $id, 2); |
| 285 |
if (count($parts) !== 2) { |
| 286 |
return $template; |
| 287 |
} |
| 288 |
[$ns, $slug] = $parts; |
| 289 |
if ($ns !== self::NAMESPACE_SLUG) { |
| 290 |
return $template; |
| 291 |
} |
| 292 |
if (!isset(self::TEMPLATES[$slug])) { |
| 293 |
return $template; |
| 294 |
} |
| 295 |
return self::buildVirtualTemplate($slug, self::TEMPLATES[$slug]); |
| 296 |
} |
| 297 |
|
| 298 |
/** |
| 299 |
* If the admin has saved a Site Editor customisation for the given Yatra |
| 300 |
* page type, prime the block-template canvas globals with that customised |
| 301 |
* content and return the absolute path to template-canvas.php so the |
| 302 |
* caller can return it from the `template_include` filter. |
| 303 |
* |
| 304 |
* Implementation note: we deliberately avoid get_block_templates() here. |
| 305 |
* Core silently restricts that query to wp_theme = get_stylesheet(), but |
| 306 |
* plugin-namespaced template customisations are saved under wp_theme = |
| 307 |
* '<plugin-namespace>' (e.g. 'yatra'), so the core query misses them. We |
| 308 |
* query the wp_template post type directly and accept matches against the |
| 309 |
* plugin namespace OR the active theme — whichever Gutenberg ended up |
| 310 |
* using when the admin saved their customisation. |
| 311 |
* |
| 312 |
* Returns null when: |
| 313 |
* - not on a block theme |
| 314 |
* - this page type has no virtual template |
| 315 |
* - no `wp_template` post exists for the slug under either namespace |
| 316 |
*/ |
| 317 |
public static function loadCustomisedCanvas(string $pageType): ?string |
| 318 |
{ |
| 319 |
if (!function_exists('wp_is_block_theme') || !wp_is_block_theme()) { |
| 320 |
return null; |
| 321 |
} |
| 322 |
$slug = self::slugForPageType($pageType); |
| 323 |
if ($slug === null) { |
| 324 |
return null; |
| 325 |
} |
| 326 |
|
| 327 |
$post = self::findCustomisedTemplatePost($slug); |
| 328 |
if ($post === null) { |
| 329 |
return null; |
| 330 |
} |
| 331 |
|
| 332 |
$content = (string) $post->post_content; |
| 333 |
if ($content === '') { |
| 334 |
return null; |
| 335 |
} |
| 336 |
|
| 337 |
// template-canvas.php reads these two globals and renders the blocks. |
| 338 |
global $_wp_current_template_content, $_wp_current_template_id; |
| 339 |
$_wp_current_template_id = self::NAMESPACE_SLUG . '//' . $slug; |
| 340 |
$_wp_current_template_content = $content; |
| 341 |
|
| 342 |
$canvas = ABSPATH . WPINC . '/template-canvas.php'; |
| 343 |
return is_readable($canvas) ? $canvas : null; |
| 344 |
} |
| 345 |
|
| 346 |
/** |
| 347 |
* Find a saved wp_template customisation for the given slug. |
| 348 |
* |
| 349 |
* Looks for a published `wp_template` post with matching post_name whose |
| 350 |
* wp_theme term is either the plugin namespace ('yatra') or the active |
| 351 |
* theme. The plugin namespace is checked first because that's where |
| 352 |
* Gutenberg saves customisations of plugin-registered templates; the |
| 353 |
* stylesheet fallback handles WP versions/configurations that map the |
| 354 |
* customisation onto the active theme instead. |
| 355 |
*/ |
| 356 |
private static function findCustomisedTemplatePost(string $slug): ?\WP_Post |
| 357 |
{ |
| 358 |
$namespaces = [self::NAMESPACE_SLUG]; |
| 359 |
if (function_exists('get_stylesheet')) { |
| 360 |
$stylesheet = (string) get_stylesheet(); |
| 361 |
if ($stylesheet !== '' && $stylesheet !== self::NAMESPACE_SLUG) { |
| 362 |
$namespaces[] = $stylesheet; |
| 363 |
} |
| 364 |
} |
| 365 |
|
| 366 |
$query = new \WP_Query([ |
| 367 |
'post_type' => 'wp_template', |
| 368 |
'name' => $slug, |
| 369 |
'post_status' => ['publish', 'auto-draft'], |
| 370 |
'posts_per_page' => -1, |
| 371 |
'no_found_rows' => true, |
| 372 |
'ignore_sticky_posts' => true, |
| 373 |
'suppress_filters' => true, |
| 374 |
'tax_query' => [ |
| 375 |
[ |
| 376 |
'taxonomy' => 'wp_theme', |
| 377 |
'field' => 'name', |
| 378 |
'terms' => $namespaces, |
| 379 |
], |
| 380 |
], |
| 381 |
]); |
| 382 |
|
| 383 |
if (empty($query->posts)) { |
| 384 |
return null; |
| 385 |
} |
| 386 |
|
| 387 |
// If multiple matches, prefer one whose wp_theme term equals the |
| 388 |
// plugin namespace (most precise) — that's where Gutenberg saves |
| 389 |
// plugin-registered template customisations. |
| 390 |
$preferred = null; |
| 391 |
foreach ($query->posts as $post) { |
| 392 |
$terms = wp_get_post_terms($post->ID, 'wp_theme', ['fields' => 'names']); |
| 393 |
if (is_wp_error($terms)) { |
| 394 |
continue; |
| 395 |
} |
| 396 |
if (in_array(self::NAMESPACE_SLUG, $terms, true)) { |
| 397 |
return $post; |
| 398 |
} |
| 399 |
if ($preferred === null) { |
| 400 |
$preferred = $post; |
| 401 |
} |
| 402 |
} |
| 403 |
|
| 404 |
return $preferred; |
| 405 |
} |
| 406 |
|
| 407 |
/** |
| 408 |
* @internal Public so TemplateLoader can map page-type → slug for logging. |
| 409 |
*/ |
| 410 |
public static function slugForPageType(string $pageType): ?string |
| 411 |
{ |
| 412 |
foreach (self::TEMPLATES as $slug => $meta) { |
| 413 |
if (($meta['page_type'] ?? '') === $pageType) { |
| 414 |
return $slug; |
| 415 |
} |
| 416 |
} |
| 417 |
return null; |
| 418 |
} |
| 419 |
|
| 420 |
/** |
| 421 |
* Build a WP_Block_Template object that wraps the theme's header/footer |
| 422 |
* template parts around the `yatra/page-content` server-side block. |
| 423 |
* |
| 424 |
* @param string $slug |
| 425 |
* @param array $meta |
| 426 |
*/ |
| 427 |
private static function buildVirtualTemplate(string $slug, array $meta): \WP_Block_Template |
| 428 |
{ |
| 429 |
$tpl = new \WP_Block_Template(); |
| 430 |
$tpl->type = 'wp_template'; |
| 431 |
$tpl->theme = self::NAMESPACE_SLUG; |
| 432 |
$tpl->slug = $slug; |
| 433 |
$tpl->id = self::NAMESPACE_SLUG . '//' . $slug; |
| 434 |
$tpl->title = $meta['title']; |
| 435 |
$tpl->description = $meta['description']; |
| 436 |
$tpl->source = 'plugin'; |
| 437 |
$tpl->origin = 'plugin'; |
| 438 |
$tpl->status = 'publish'; |
| 439 |
$tpl->has_theme_file = false; |
| 440 |
$tpl->is_custom = false; |
| 441 |
$tpl->author = null; |
| 442 |
$tpl->content = self::defaultBlockTemplateMarkup($meta); |
| 443 |
|
| 444 |
return $tpl; |
| 445 |
} |
| 446 |
|
| 447 |
/** |
| 448 |
* Default block-template body: header part → page-content → footer part. |
| 449 |
* Authors can rearrange in the editor; their version supersedes this. |
| 450 |
*/ |
| 451 |
private static function defaultBlockTemplateMarkup(array $meta): string |
| 452 |
{ |
| 453 |
$phpTemplate = (string) ($meta['php_template'] ?? ''); |
| 454 |
$alignment = !empty($meta['is_singular']) ? 'full' : 'wide'; |
| 455 |
|
| 456 |
return implode("\n", [ |
| 457 |
'<!-- wp:template-part {"slug":"header","theme":"' . esc_attr(get_stylesheet()) . '","tagName":"header"} /-->', |
| 458 |
'', |
| 459 |
'<!-- wp:group {"tagName":"main","align":"' . esc_attr($alignment) . '","layout":{"type":"constrained"}} -->', |
| 460 |
'<main class="wp-block-group align' . esc_attr($alignment) . ' yatra-page-content-wrap">', |
| 461 |
'<!-- wp:yatra/page-content {"template":"' . esc_attr($phpTemplate) . '"} /-->', |
| 462 |
'</main>', |
| 463 |
'<!-- /wp:group -->', |
| 464 |
'', |
| 465 |
'<!-- wp:template-part {"slug":"footer","theme":"' . esc_attr(get_stylesheet()) . '","tagName":"footer"} /-->', |
| 466 |
]); |
| 467 |
} |
| 468 |
} |
| 469 |
|