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;
}