HEX
Server: Apache/2.4.68 (Debian)
System: Linux as-cs-widget-demo-us-central1 6.1.0-44-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.164-1 (2026-03-09) x86_64
User: root (0)
PHP: 8.2.32
Disabled: NONE
Upload Files
File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/search-page.php
<?php
// includes/search-page.php
//
// Standalone Advanced Search page: a normal WordPress page (default slug /advanced-search/)
// whose [allspice_search] shortcode renders a mount div; the page bundle boots the full
// widget embedded into it (site header above, footer below), default tab = regular search,
// query seeded from ?q=.
//
// Activation model: the existing per-device "Search buttons open" dropdown gains an
// 'advanced_search' destination. The Portal's search-replacement policy opt-in remains the
// master switch - without it no site search interaction is touched, regardless of dropdown.
// When the dropdown selects the page but the page is missing/unpublished, the page bundle
// falls back to the full-page widget (never a 404 navigation).

if (!defined('ABSPATH')) exit;

const ALLSPICE_SEARCH_PAGE_OPTION = 'allspice_search_page_id';
const ALLSPICE_CREATE_SEARCH_PAGE_ACTION = 'allspice_create_search_page';
const ALLSPICE_REGENERATE_SEARCH_PAGE_ACTION = 'allspice_regenerate_search_page';

function allspice_search_page_slug(): string {
    $slug = sanitize_title((string)apply_filters('allspice_search_page_slug', 'advanced-search'));
    return $slug !== '' ? $slug : 'advanced-search';
}

/*
 * "Recipe Index" rather than "Search": it is the term food-blog readers actually search for
 * (and type into Google alongside a site name), where a bare "Search" heading indexes for
 * nothing. Filterable, and only ever applied to NEWLY generated pages - an existing page's
 * title is the publisher's, and is never rewritten underneath them.
 */
function allspice_search_page_title(): string {
    $title = trim((string)apply_filters('allspice_search_page_title', 'Recipe Index'));
    return $title !== '' ? $title : 'Recipe Index';
}

/* The saved search page when it is still a valid, non-trashed page - else null. */
function allspice_search_page_current() {
    $saved = (int)get_option(ALLSPICE_SEARCH_PAGE_OPTION, 0);
    if ($saved <= 0) return null;
    $post = get_post($saved);
    if (!$post || $post->post_type !== 'page' || $post->post_status === 'trash') return null;
    return $post;
}

/* Published page URL, '' otherwise, the only state in which navigation may target it. */
function allspice_search_page_url(): string {
    $page = allspice_search_page_current();
    if ($page === null || $page->post_status !== 'publish') return '';
    $url = get_permalink((int)$page->ID);
    return is_string($url) ? $url : '';
}

/* One-sentence lead-in. Gives the page real indexable text (a bare heading over a JS mount
   has almost none) and tells a first-time visitor what the AI button is for. */
function allspice_search_page_intro_text(): string {
    $site = function_exists('get_bloginfo') ? trim((string)get_bloginfo('name')) : '';
    $where = $site !== '' ? ' on ' . $site : '';
    return (string)apply_filters(
        'allspice_search_page_intro',
        'Browse every recipe' . $where . '. Search by ingredient, dish or diet, or describe what '
        . 'you feel like and let the assistant find it.'
    );
}

/*
 * Header blocks above the mount. Plain core blocks with plain classes - no block-level style
 * attributes, which are what trip Gutenberg's "unexpected or invalid content" validation - so
 * every line is editable, restyleable and deletable in the editor like any other content.
 *
 * The breadcrumb is a normal paragraph rather than server-rendered chrome on purpose: plenty of
 * themes (and Yoast/RankMath) already print their own, and a publisher who has one just deletes
 * this line.
 */
function allspice_search_page_header_blocks(): string {
    $home = function_exists('home_url') ? (string)home_url('/') : '/';
    $title = allspice_search_page_title();
    $crumb = esc_html($title);
    $intro = esc_html(allspice_search_page_intro_text());
    $home_attr = esc_url($home);
    return <<<HTML
<!-- wp:paragraph {"className":"allspice-search-page__crumbs"} -->
<p class="allspice-search-page__crumbs"><a href="{$home_attr}">Home</a> › {$crumb}</p>
<!-- /wp:paragraph -->

<!-- wp:group {"className":"allspice-search-page__head"} -->
<div class="wp-block-group allspice-search-page__head"><!-- wp:heading {"level":1,"className":"allspice-search-page__title"} -->
<h1 class="wp-block-heading allspice-search-page__title">{$crumb}</h1>
<!-- /wp:heading -->

<!-- wp:paragraph {"className":"allspice-search-page__intro"} -->
<p class="allspice-search-page__intro">{$intro}</p>
<!-- /wp:paragraph --></div>
<!-- /wp:group -->
HTML;
}

/*
 * Default content: editable blocks around the shortcode. The shortcode's `tabs`
 * attribute is the publisher's configuration surface for whether the widget's other tabs
 * (chat, groceries, meal plans) are reachable: tabs="all" (default) or tabs="search".
 */
function allspice_search_page_default_content(): string {
    $header = allspice_search_page_header_blocks();
    return <<<HTML
{$header}

<!-- wp:shortcode -->
[allspice_search tabs="all"]
<!-- /wp:shortcode -->
HTML;
}

/* Same idempotent contract as the membership page: reuse saved id, adopt slug, else draft.
   $force skips reuse/adopt so a regenerate can't re-adopt the page it just trashed. */
function allspice_create_search_page(bool $force = false): array {
    $current = $force ? null : allspice_search_page_current();
    if ($current !== null) {
        return ['ok' => true, 'page_id' => (int)$current->ID, 'created' => false, 'reason' => 'reused_saved_id'];
    }
    $slug = allspice_search_page_slug();
    $by_slug = (!$force && function_exists('get_page_by_path')) ? get_page_by_path($slug, OBJECT, 'page') : null;
    if ($by_slug && $by_slug->post_status !== 'trash') {
        update_option(ALLSPICE_SEARCH_PAGE_OPTION, (int)$by_slug->ID, false);
        return ['ok' => true, 'page_id' => (int)$by_slug->ID, 'created' => false, 'reason' => 'adopted_slug_page'];
    }
    $page_id = wp_insert_post([
        'post_type' => 'page',
        'post_status' => 'draft',           // NEVER published automatically
        'post_title' => allspice_search_page_title(),
        'post_name' => $slug,
        'post_content' => allspice_search_page_default_content(),
    ], true);
    if (is_wp_error($page_id) || (int)$page_id <= 0) {
        return ['ok' => false, 'page_id' => 0, 'created' => false, 'reason' => 'insert_failed'];
    }
    update_option(ALLSPICE_SEARCH_PAGE_OPTION, (int)$page_id, false);
    return ['ok' => true, 'page_id' => (int)$page_id, 'created' => true, 'reason' => 'created_draft'];
}

add_action('admin_post_' . ALLSPICE_CREATE_SEARCH_PAGE_ACTION, 'allspice_admin_post_create_search_page');
function allspice_admin_post_create_search_page(): void {
    if (!current_user_can('manage_options')) wp_die('Forbidden', 403);
    check_admin_referer(ALLSPICE_CREATE_SEARCH_PAGE_ACTION);
    $r = allspice_create_search_page();
    if (!empty($r['ok']) && (int)$r['page_id'] > 0) {
        wp_safe_redirect(allspice_membership_page_editor_url((int)$r['page_id']));
        exit;
    }
    set_transient('allspice_admin_notice', ['msg' => 'Could not create the search page.', 'type' => 'error'], 30);
    wp_safe_redirect(admin_url('options-general.php?page=' . ALLSPICE_SETTINGS_PAGE));
    exit;
}

/*
 * Regenerate (same contract as the membership page): the current page goes to the TRASH,
 * recoverable, never permanently deleted and never silently overwritten - and a fresh draft is
 * built from the current default header. This is the only way an already-generated page picks
 * up a new default layout; page content is stamped at creation and never rewritten in place.
 */
function allspice_regenerate_search_page(): array {
    $current = allspice_search_page_current();
    if ($current !== null) {
        wp_delete_post((int)$current->ID, false); /* false = Trash, NEVER permanent delete */
    }
    delete_option(ALLSPICE_SEARCH_PAGE_OPTION);
    $r = allspice_create_search_page(true);
    $r['trashed_page_id'] = $current !== null ? (int)$current->ID : 0;
    return $r;
}

add_action('admin_post_' . ALLSPICE_REGENERATE_SEARCH_PAGE_ACTION, 'allspice_admin_post_regenerate_search_page');
function allspice_admin_post_regenerate_search_page(): void {
    if (!current_user_can('manage_options')) wp_die('Forbidden', 403);
    check_admin_referer(ALLSPICE_REGENERATE_SEARCH_PAGE_ACTION);
    $r = allspice_regenerate_search_page();
    if (!empty($r['ok']) && (int)$r['page_id'] > 0) {
        wp_safe_redirect(allspice_membership_page_editor_url((int)$r['page_id']));
        exit;
    }
    set_transient('allspice_admin_notice', ['msg' => 'Could not regenerate the search page.', 'type' => 'error'], 30);
    wp_safe_redirect(admin_url('options-general.php?page=' . ALLSPICE_SETTINGS_PAGE));
    exit;
}

/* ---------------------------------------------------------------------------- shortcode */

add_shortcode('allspice_search', 'allspice_search_shortcode');
function allspice_search_shortcode($atts = []): string {
    $atts = is_array($atts) ? $atts : [];
    $tabs = strtolower(trim((string)($atts['tabs'] ?? 'all')));
    if ($tabs !== 'search') $tabs = 'all';
    /* The page bundle detects this mount, suppresses the float launcher/search interception
       on this page, and boots the embedded widget with the ?q= query.
       CLS: the min-height mirrors the embedded widget root's height EXACTLY
       (#allspicewidgetroot.asx-embedded in the widget's app.scss - keep the two in sync), so
       the space is reserved from the first paint and the site footer never shifts when the
       widget mounts (field trace showed a 0.55 footer shift from the 0px mount growing). */
    return allspice_search_page_header_css()
        . '<div id="allspice-search-embed" class="allspice-search-page-mount"'
        . ' data-allspice-search-tabs="' . esc_attr($tabs) . '"'
        . ' style="min-height:clamp(560px, calc(100vh - var(--asx-embed-offset, 140px)), 1400px)"></div>'
        . allspice_search_page_fit_script();
}

/*
 * Size the mount to the space that is ACTUALLY left below the site header.
 *
 * The height is `100vh - --asx-embed-offset`, and nothing ever set that variable - so every site
 * got the same hardcoded 140px guess at its own header + page-title height. The guess is wrong in
 * both directions: a site with taller chrome (header + breadcrumb + H1 + intro) gets a box that
 * runs past the fold, and one with a compact header gets a box that stops short, leaving dead
 * white space with the results cut off above it.
 *
 * Measured INLINE, immediately after the mount, on purpose: at this point in parsing the header
 * above is laid out but the footer below does not exist yet, so correcting the height costs no
 * layout shift. Doing it from the deferred page bundle instead would move the footer after first
 * paint - the exact 0.55 CLS regression the reserved min-height was added to kill. Everything is
 * defensive: any failure leaves the server-rendered default in place.
 */
function allspice_search_page_fit_script(): string {
    static $printed = false;
    if ($printed) return '';
    $printed = true;
    $gap = (int)apply_filters('allspice_search_page_bottom_gap', 24);
    return '<script id="allspice-search-page-fit">(function(){'
        . 'var m=document.getElementById("allspice-search-embed");if(!m)return;'
        . 'var GAP=' . $gap . ',raf=null;'
        . 'function fit(){raf=null;try{'
        . 'var t=m.getBoundingClientRect().top+(window.pageYOffset||0);'
        . 'if(!isFinite(t))return;'
        . 'm.style.setProperty("--asx-embed-offset",(Math.max(0,Math.round(t))+GAP)+"px");'
        . '}catch(e){}}'
        . 'function q(){if(raf===null&&typeof requestAnimationFrame==="function")raf=requestAnimationFrame(fit);}'
        . 'fit();'
        . 'window.addEventListener("resize",q,{passive:true});'
        . 'window.addEventListener("orientationchange",q,{passive:true});'
        . '})();</script>';
}

/*
 * Type treatment for the generated header lines, printed once alongside the mount (so it exists
 * only on pages that actually carry the shortcode - no site-wide enqueue).
 *
 * Declares no colors, families or backgrounds: every rule is relative (em, opacity,
 * ch) so the lines inherit the publisher's palette and typography and look native on any theme.
 * Publishers who delete the header blocks are left with nothing but a few unmatched selectors.
 */
function allspice_search_page_header_css(): string {
    static $printed = false;
    if ($printed) return '';
    $printed = true;
    return '<style id="allspice-search-page-header-css">'
        /* Single knob for the page's horizontal breathing room. The generated page is usually a
           full-bleed template (its content runs to the window edge while the theme's own header
           stays inset), so the inset is supplied here. A theme that already constrains its
           content can zero this out - `:root{--asx-search-page-inset:0}` - without touching any
           of the rules below. */
        . ':root{--asx-search-page-inset:clamp(16px,3vw,40px)}'
        /* PADDING, not margin, for the space under the site header: the breadcrumb is the first
           child of the theme's <main>, so a margin-top would collapse straight through it and
           land outside the element instead of pushing it down. Padding cannot collapse. */
        . '.allspice-search-page__crumbs{font-size:.8em;letter-spacing:.02em;opacity:.7;'
        . 'padding-top:clamp(18px,2.4vw,34px);margin-top:0;margin-bottom:.35em;'
        . 'padding-inline:var(--asx-search-page-inset)}'
        /* WordPress renders the group block with an EXTRA wrapper the saved markup does not
           contain: `<div class="wp-block-group"><div class="wp-block-group__inner-container">`.
           That made the grid container a one-child box with the title and description as
           GRANDchildren, so every grid placement below was inert (confirmed against the live
           page HTML). `display:contents` dissolves the wrapper so the two lines become real grid
           items - and it is a no-op on themes where WordPress omits the wrapper entirely, so one
           rule covers both renderings. */
        . '.allspice-search-page__head > .wp-block-group__inner-container{display:contents !important}'
        /* Title left, description right, sharing one baseline. Explicit grid placement rather
           than a nested columns block: two flat children stay individually editable and
           deletable in the editor, and there is no nested block markup to drift out of sync
           with what Gutenberg would regenerate.
           THEME ARMOR: the class is doubled (and the layout properties marked) because a single
           class loses to the ordinary theme rules that style post content - `.entry-content
           .wp-block-group` is two classes and silently beat `display:grid`, which left the
           description sitting under the title as a left-hand block with right-aligned text
           (`text-align` survived, the grid did not). */
        . '.allspice-search-page__head.allspice-search-page__head{display:grid !important;'
        . 'grid-template-columns:minmax(0,1fr) minmax(0,1fr) !important;align-items:end !important;'
        . 'column-gap:clamp(16px,4vw,64px);padding-inline:var(--asx-search-page-inset);'
        /* The header must not crowd the widget box directly beneath it - with the title and
           description both zeroed out, nothing else supplies this gap. */
        . 'margin-bottom:clamp(20px,2.4vw,36px) !important}'
        . '.allspice-search-page__head .allspice-search-page__title{grid-column:1 !important;'
        . 'grid-row:1 !important;margin:0 !important}'
        . '.allspice-search-page__head .allspice-search-page__intro{grid-column:2 !important;'
        . 'grid-row:1 !important;justify-self:end;text-align:right;max-width:52ch;margin:0 !important;'
        . 'opacity:.8}'
        /* Narrow screens: one column, everything left-aligned - a right-aligned paragraph under
           a left-aligned title reads as a mistake once they stack. */
        . '@media(max-width:781px){'
        . '.allspice-search-page__head.allspice-search-page__head{grid-template-columns:minmax(0,1fr) !important}'
        . '.allspice-search-page__head .allspice-search-page__intro{grid-column:1 !important;'
        . 'grid-row:2 !important;justify-self:start;text-align:left;margin-top:.6em !important}'
        . '}'
        . '.allspice-search-page-mount{box-sizing:border-box;padding-inline:var(--asx-search-page-inset)}'
        . '</style>';
}

/* --------------------------------------------------------------- native ?s= redirection */

/* True when ANY device's "Search buttons open" dropdown targets the Advanced Search page. */
function allspice_search_page_mode_selected(): bool {
    if (!function_exists('allspice_opt_get')) return false;
    $s = allspice_opt_get();
    foreach (['search_open_target_desktop', 'search_open_target_mobile'] as $k) {
        if (($s[$k] ?? '') === 'advanced_search') return true;
    }
    return false;
}

/* Mirror of the page bundle's policy gate: the Portal search-replacement opt-in. */
function allspice_search_replacement_policy_enabled(): bool {
    if (!function_exists('allspice_theme_policies_cache_get')) return false;
    $tp = allspice_theme_policies_cache_get();
    $policy = isset($tp['policy']) && is_array($tp['policy']) ? $tp['policy'] : [];
    $sr = $policy['search_replacement'] ?? ($policy['searchReplacement'] ?? null);
    if (!is_array($sr)) return false;
    if (($sr['enabled'] ?? null) === true) return true;
    foreach (['trigger_selectors', 'triggerSelectors', 'form_selectors', 'formSelectors', 'input_selectors', 'inputSelectors'] as $k) {
        if (isset($sr[$k]) && is_array($sr[$k]) && $sr[$k] !== []) return true;
    }
    return false;
}

/*
 * WordPress-native search (?s=term - theme forms we never intercept, direct links) lands on
 * the Advanced Search page too, but only when the whole feature is live: policy opt-in AND
 * dropdown selection AND a published page. 302 (mode is a setting, not permanent).
 */
add_action('template_redirect', 'allspice_search_page_native_redirect', 2);
function allspice_search_page_native_redirect(): void {
    if (is_admin() || !is_search()) return;
    if (!allspice_search_page_mode_selected()) return;
    if (!allspice_search_replacement_policy_enabled()) return;
    $url = allspice_search_page_url();
    if ($url === '') return; /* unpublished/missing page: native search keeps working */
    $q = trim((string)get_search_query());
    if ($q !== '') $url = add_query_arg('q', rawurlencode($q), $url);
    wp_safe_redirect($url, 302);
    exit;
}

/* ------------------------------------------------------------------------- integrations */

/* The search page is never content-gated (it holds no protected content of its own). */
add_filter('allspice_content_gate_excluded', 'allspice_search_page_gate_exclusion', 10, 3);
function allspice_search_page_gate_exclusion($excluded, $post_id = 0, $url_key = '') {
    if ($excluded) return $excluded;
    $page = allspice_search_page_current();
    return $page !== null && (int)$post_id === (int)$page->ID;
}