$value) {
$wp_query->set($key, $value);
}
}
/**
* Set 404 status
*/
protected function set404(): void
{
global $wp_query;
$wp_query->set_404();
status_header(404);
}
/**
* Prevent 404 handling and set 200 status
*/
protected function prevent404(): void
{
global $wp_query;
$wp_query->is_404 = false;
status_header(200);
}
/**
* Configure $wp_query and globals so the current request looks like a real WP page to
* the theme — required for FSE/block themes to resolve the correct block template
* (singular/archive/index) instead of falling back to 404.html.
*
* @param string $context 'singular' for single-trip / booking-confirmation / account, 'archive' for listings.
* @param array $args {
* @type string $title Page title used in
and queried-object stub.
* @type int $object_id Optional ID for the virtual queried object (defaults to 0).
* @type string $post_type Post type for the faux WP_Post (default 'page').
* @type string $post_name Slug for the faux WP_Post.
* @type string|null $content Optional post content placeholder.
* }
*/
protected function setupPageEnvironment(string $context = 'singular', array $args = []): void
{
global $wp_query, $post;
$title = (string) ($args['title'] ?? '');
$object_id = (int) ($args['object_id'] ?? 0);
$post_type = (string) ($args['post_type'] ?? 'page');
$post_name = (string) ($args['post_name'] ?? '');
$content = (string) ($args['content'] ?? '');
// Clear the conditional-tag state — WP_Query keeps stale flags from the main query.
$wp_query->init_query_flags();
$wp_query->is_404 = false;
$wp_query->is_home = false;
$wp_query->is_front_page = false;
if ($context === 'archive') {
$wp_query->is_archive = true;
// Do NOT set is_post_type_archive — we are a virtual page, not a
// real WP post-type archive. Setting it triggers WP code paths
// (feed_links_extra, post_type_archive_title, …) that call
// get_post_type_object() and crash on null when no CPT is set.
} else {
$wp_query->is_singular = true;
$wp_query->is_page = ($post_type === 'page');
$wp_query->is_single = ($post_type !== 'page');
}
// Build a virtual WP_Post so the_post(), get_the_ID(), body_class(), and FSE
// block-template selection have something coherent to read.
$virtual = new \WP_Post((object) [
'ID' => $object_id,
'post_author' => 0,
'post_date' => current_time('mysql'),
'post_date_gmt' => current_time('mysql', true),
'post_content' => $content,
'post_title' => $title,
'post_excerpt' => '',
'post_status' => 'publish',
'comment_status' => 'closed',
'ping_status' => 'closed',
'post_password' => '',
'post_name' => $post_name,
'to_ping' => '',
'pinged' => '',
'post_modified' => current_time('mysql'),
'post_modified_gmt' => current_time('mysql', true),
'post_content_filtered' => '',
'post_parent' => 0,
'guid' => home_url($post_name ? '/' . $post_name : '/'),
'menu_order' => 0,
'post_type' => $post_type,
'post_mime_type' => '',
'comment_count' => 0,
'filter' => 'raw',
]);
$wp_query->queried_object = $virtual;
$wp_query->queried_object_id = $object_id;
$wp_query->post = $virtual;
$wp_query->posts = [$virtual];
$wp_query->post_count = 1;
$wp_query->found_posts = 1;
$wp_query->max_num_pages = 1;
$wp_query->current_post = -1;
$post = $virtual;
// ── Strip WP core hooks that don't make sense for virtual pages ─────────────
//
// Our queried-object ID is a Yatra trips-table row id, NOT a wp_posts row id.
// Several WP core hooks call `get_post( get_queried_object_id() )` expecting a
// real WP_Post — they receive null and crash with
// "Attempt to read property 'post_type' on null".
//
// wp_shortlink_wp_head → calls wp_get_shortlink('query') → get_post(id) → null
// wp_shortlink_header → same code path, called on template_redirect
// adjacent_posts_rel_link_wp_head → walks $post->post_type for prev/next links
//
// None of these emit anything meaningful for a virtual Yatra page, so we strip
// them at the start of the render. Re-checking they exist (rather than calling
// remove_action blindly) keeps us forward-compatible if WP core retires a hook.
if (function_exists('wp_shortlink_wp_head')) {
remove_action('wp_head', 'wp_shortlink_wp_head', 10);
}
if (function_exists('wp_shortlink_header')) {
remove_action('template_redirect', 'wp_shortlink_header', 11);
}
if (function_exists('adjacent_posts_rel_link_wp_head')) {
remove_action('wp_head', 'adjacent_posts_rel_link_wp_head', 10);
}
// WP admin-bar "Edit Page" link — wp_admin_bar_edit_menu reads our virtual
// $post->post_type ('page') and adds an Edit link pointing at
// wp-admin/post.php?post=&action=edit. That ID is a Yatra trip
// row id, not a wp_posts row, so the link 404s (or worse, edits an unrelated
// post that happens to share the ID). Yatra registers its own "Edit Trip"
// admin-bar item via AdminBarProvider, which is the correct link for trips.
if (function_exists('wp_admin_bar_edit_menu')) {
remove_action('admin_bar_menu', 'wp_admin_bar_edit_menu', 80);
}
// Belt-and-braces: short-circuit `pre_get_shortlink` for THIS request so any
// other plugin/theme code that calls `wp_get_shortlink( 0, 'query' )` directly
// also bails out cleanly instead of feeding our virtual ID to `get_post()`.
if (!has_filter('pre_get_shortlink', [self::class, 'suppressVirtualShortlink'])) {
add_filter('pre_get_shortlink', [self::class, 'suppressVirtualShortlink'], 1);
}
status_header(200);
nocache_headers();
}
/**
* Returning an empty string from `pre_get_shortlink` short-circuits
* {@see wp_get_shortlink()} BEFORE it calls `get_post()` on our virtual ID. Used
* to silence "Attempt to read property 'post_type' on null" warnings on Yatra
* trip / listing / account pages where the queried-object ID is a Yatra row id,
* not a wp_posts row id.
*/
public static function suppressVirtualShortlink($shortlink): string
{
return '';
}
/**
* Set global variable
*
* @param string $name Variable name
* @param mixed $value Variable value
*/
protected function setGlobal(string $name, $value): void
{
$GLOBALS[$name] = $value;
}
/**
* Log error message
*
* @param string $message Error message
*/
protected function logError(string $message): void
{
if (defined('WP_DEBUG') && WP_DEBUG) {
}
}
/**
* Hand off a Yatra template to the `template_include` filter and return
* a routing result. Replaces the legacy `include + exit` pattern.
*
* The handler should call this as the last step in handle(). It does NOT
* render the template — that happens later via WordPress's normal
* template-loader pipeline, so wp_head/wp_footer fire, other plugins can
* hook in, and child themes can override.
*
* @param string $templateName Template filename relative to /templates/, with or without .php.
* @param BaseAssetManager|null $assetManager Optional asset manager whose assets to enqueue.
* @param string $pageType Logical page type for body_class hints + FSE template slug.
* @return bool true if the template was queued, false if missing.
*/
protected function selectTemplate(string $templateName, ?BaseAssetManager $assetManager = null, string $pageType = ''): bool
{
$name = ltrim($templateName, '/');
if (substr($name, -4) !== '.php') {
$name .= '.php';
}
$absolute = YATRA_PLUGIN_PATH . 'templates/' . $name;
// Allow themes/child-themes to override via templates/yatra/.
$located = locate_template(['yatra/' . $name], false, false);
if (is_string($located) && $located !== '') {
$absolute = $located;
}
$absolute = (string) apply_filters('yatra_template_path', $absolute, $name, $pageType);
if (!is_readable($absolute)) {
$this->logError("Template not readable: {$absolute}");
return false;
}
if ($assetManager !== null) {
$assetManager->enqueueAssets();
}
$ctx = PageContext::instance();
$ok = $ctx->select($absolute, $pageType);
if ($ok && $pageType !== '') {
$ctx->addBodyClass('yatra-page-' . sanitize_html_class($pageType));
}
return $ok;
}
/**
* @deprecated Kept only for backwards-compat with handlers not yet migrated.
* New handlers should call selectTemplate() and return.
*
* @param string|null $message Optional exit message
*/
protected function exit(?string $message = null): void
{
if ($message && defined('WP_DEBUG') && WP_DEBUG) {
echo '';
}
exit;
}
}