Install the plugin
In WordPress admin, open Plugins → Add New Plugin, search for "SearchCue", install it, and activate it.
The SearchCue plugin adds fast site search and cited answers to WordPress. Install it from the WordPress plugin directory, and inspect every line of source code below.
In WordPress admin, open Plugins → Add New Plugin, search for "SearchCue", install it, and activate it.
Sign in to SearchCue, copy your site settings, and let the hosted crawler index your public content.
SearchCue runs search and answers from our hosted index instead of adding a heavy search engine to your database.
The plugin includes every SearchCue feature. If your site uses WooCommerce, it also adds rich product search.
Instant, typo-resistant results across every page, with matching terms highlighted.
Direct answers grounded in your content, with citations visitors can inspect.
See popular queries, clicks, dead ends, content gaps, and changes over time.
Place a search bar or button anywhere, then match its colours and style to your site.
Exclude landing pages, SEO filler, or anything else you do not want to surface.
Index supported PDFs and Office documents alongside your pages.
Make images searchable with OCR and image understanding.
Search published products and variations with prices, sale and stock status, images, attributes, and faceted filters.
The browser below is generated from the plugin files during the site build. Pick a file to read it without downloading anything.
@font-face {
font-family: "Schibsted Grotesk";
src: url("../fonts/searchcue/SchibstedGrotesk-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Schibsted Grotesk";
src: url("../fonts/searchcue/SchibstedGrotesk-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Schibsted Grotesk";
src: url("../fonts/searchcue/SchibstedGrotesk-SemiBold.woff2") format("woff2");
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Schibsted Grotesk";
src: url("../fonts/searchcue/SchibstedGrotesk-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Spline Sans Mono";
src: url("../fonts/searchcue/SplineSansMono-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Spline Sans Mono";
src: url("../fonts/searchcue/SplineSansMono-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
.searchcue-admin {
--searchcue-ink-900: #1b1b19;
--searchcue-ink-700: #44443f;
--searchcue-ink-500: #6e6e66;
--searchcue-ink-300: #b0b0a7;
--searchcue-paper: #fafaf6;
--searchcue-paper-shade: #f2f2ec;
--searchcue-surface: #ffffff;
--searchcue-line-strong: #cfcfc6;
--searchcue-line: #e2e2da;
--searchcue-line-soft: #edede6;
--searchcue-cue: #1e6b50;
--searchcue-cue-deep: #14503b;
--searchcue-cue-wash: #e9f2ec;
--searchcue-cue-line: #bfd9cb;
--searchcue-ok: #1e6b50;
--searchcue-ok-wash: #e9f2ec;
--searchcue-warn: #8a6018;
--searchcue-warn-wash: #f7f0df;
--searchcue-err: #b23b2e;
--searchcue-err-wash: #f9ecea;
--searchcue-font-mono: "Spline Sans Mono", ui-monospace, "SF Mono", Menlo, monospace;
color: var(--searchcue-ink-900);
font-family: "Schibsted Grotesk", "Helvetica Neue", Helvetica, Arial, sans-serif;
max-width: 1120px;
}
.searchcue-admin * {
box-sizing: border-box;
}
.searchcue-mono {
font-family: var(--searchcue-font-mono);
}
.searchcue-hero {
align-items: stretch;
background: var(--searchcue-paper);
border: 1px solid var(--searchcue-line);
border-radius: 6px;
display: grid;
gap: 0;
grid-template-columns: minmax(0, 1fr);
margin: 22px 0;
overflow: hidden;
}
.searchcue-hero-copy {
padding: 34px;
}
.searchcue-admin h1,
.searchcue-admin h2 {
color: var(--searchcue-ink-900);
font-weight: 600;
letter-spacing: 0;
}
.searchcue-admin h1 {
font-size: 42px;
line-height: 1.08;
margin: 0;
max-width: 720px;
}
.searchcue-admin .searchcue-wordmark {
align-items: baseline;
display: inline-flex;
font-weight: 700;
gap: 3px;
letter-spacing: -0.022em;
line-height: 1;
white-space: nowrap;
}
.searchcue-wordmark-caret {
background: var(--searchcue-cue);
border-radius: 1px;
display: inline-block;
height: 0.78em;
transform: translateY(0.04em);
width: 0.09em;
}
.searchcue-connected-status {
align-items: center;
color: var(--searchcue-ink-700);
display: flex;
font-size: 15px;
font-weight: 600;
gap: 8px;
margin: 16px 0 0;
}
.searchcue-connected-check {
align-items: center;
background: var(--searchcue-ok-wash);
border-radius: 9999px;
color: var(--searchcue-ok);
display: inline-flex;
height: 20px;
justify-content: center;
width: 20px;
}
.searchcue-connected-check svg {
display: block;
height: 12px;
width: 12px;
}
.searchcue-intro {
color: var(--searchcue-ink-500);
font-size: 17px;
line-height: 1.6;
margin: 18px 0 0;
max-width: 680px;
}
.searchcue-status-badge {
border-radius: 2px;
display: inline-flex;
font-size: 12px;
font-weight: 700;
line-height: 1;
margin: 0 0 18px;
padding: 6px 8px;
}
.searchcue-status-badge.is-needed {
background: var(--searchcue-warn-wash);
color: var(--searchcue-warn);
}
.searchcue-settings {
background: var(--searchcue-surface);
border: 1px solid var(--searchcue-line);
border-radius: 6px;
margin: 0 0 18px;
padding: 30px 34px 18px;
}
.searchcue-field {
max-width: 720px;
}
.searchcue-label {
color: var(--searchcue-ink-900);
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.searchcue-input {
border-color: var(--searchcue-line-strong);
border-radius: 4px;
font-size: 18px;
max-width: 440px;
min-height: 46px;
padding: 8px 12px;
width: 100%;
}
.searchcue-input:focus {
border-color: var(--searchcue-cue);
box-shadow: 0 0 0 3px var(--searchcue-cue-wash);
outline: none;
}
.searchcue-help {
color: var(--searchcue-ink-500);
font-size: 14px;
line-height: 1.55;
margin: 8px 0 0;
}
.searchcue-toggle {
align-items: flex-start;
cursor: pointer;
display: flex;
gap: 14px;
margin-top: 0;
max-width: 720px;
padding-top: 0;
position: relative;
}
.searchcue-toggle .searchcue-switch-input {
height: 1px;
left: 0;
margin: 0;
min-height: 1px;
min-width: 1px;
opacity: 0;
position: absolute;
top: 0;
width: 1px;
}
.searchcue-switch {
background: var(--searchcue-line-strong);
border-radius: 999px;
flex: 0 0 auto;
height: 20px;
margin-top: 1px;
position: relative;
transition: background-color 120ms ease-out;
width: 34px;
}
.searchcue-switch::after {
background: var(--searchcue-surface);
border-radius: 999px;
content: "";
height: 16px;
left: 2px;
position: absolute;
top: 2px;
transition: transform 120ms ease-out;
width: 16px;
}
.searchcue-switch-input:checked + .searchcue-switch {
background: var(--searchcue-ink-900);
}
.searchcue-switch-input:checked + .searchcue-switch::after {
transform: translateX(14px);
}
.searchcue-switch-input:focus-visible + .searchcue-switch {
outline: 2px solid var(--searchcue-cue);
outline-offset: 2px;
}
.searchcue-toggle-title {
display: block;
font-size: 15px;
font-weight: 700;
margin-bottom: 4px;
}
.searchcue-actions {
margin-top: 26px;
}
.searchcue-onboarding {
background: var(--searchcue-surface);
border: 1px solid var(--searchcue-line);
border-radius: 6px;
margin: 0 0 18px;
padding: 30px 34px;
}
.searchcue-onboarding h2 {
font-size: 22px;
margin: 0;
}
.searchcue-setup-intro {
margin: 8px 0 24px;
max-width: 720px;
}
.searchcue-setup-toggle {
margin-top: 24px;
}
.searchcue-setup-consent {
font-size: 12px;
margin-top: 22px;
max-width: 720px;
}
.searchcue-alert {
background: var(--searchcue-cue-wash);
border: 1px solid var(--searchcue-cue-line);
border-radius: 6px;
color: var(--searchcue-cue-deep);
font-size: 14px;
line-height: 1.5;
margin: 0 0 18px;
padding: 14px 16px;
width: 100%;
}
.searchcue-alert.is-error {
background: var(--searchcue-warn-wash);
border-color: var(--searchcue-line-strong);
color: var(--searchcue-warn);
}
.searchcue-setup-consent a,
.searchcue-advanced a,
.searchcue-activation a {
color: var(--searchcue-cue);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.searchcue-setup-consent a:hover,
.searchcue-advanced a:hover,
.searchcue-activation a:hover {
color: var(--searchcue-cue-deep);
}
.searchcue-setup-consent a::after,
.searchcue-activation a::after {
background-color: currentColor;
content: "";
display: inline-block;
height: 0.78em;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 3.5h3.5V7'/%3E%3Cpath d='M12.5 3.5 7 9'/%3E%3Cpath d='M3.5 7v5.5H9'/%3E%3C/svg%3E") no-repeat center / contain;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 3.5h3.5V7'/%3E%3Cpath d='M12.5 3.5 7 9'/%3E%3Cpath d='M3.5 7v5.5H9'/%3E%3C/svg%3E") no-repeat center / contain;
margin-left: 0.18em;
transform: translateY(0.05em);
width: 0.78em;
}
.searchcue-actions .button-primary,
.searchcue-actions button[type="submit"] {
background: var(--searchcue-ink-900);
border-color: var(--searchcue-ink-900);
border-radius: 4px;
box-shadow: none;
color: var(--searchcue-paper);
font-weight: 600;
min-height: 40px;
padding-left: 18px;
padding-right: 18px;
}
.searchcue-actions .button-primary:hover,
.searchcue-actions button[type="submit"]:hover {
background: #000000;
border-color: #000000;
color: var(--searchcue-paper);
}
.searchcue-actions .button-primary:focus,
.searchcue-actions button[type="submit"]:focus {
border-color: var(--searchcue-cue);
box-shadow: none;
color: var(--searchcue-paper);
outline: 2px solid var(--searchcue-cue);
outline-offset: 2px;
}
.searchcue-actions .button-primary:disabled,
.searchcue-actions button[type="submit"]:disabled {
background: var(--searchcue-paper-shade) !important;
border-color: var(--searchcue-line-soft) !important;
box-shadow: none !important;
color: var(--searchcue-ink-300) !important;
cursor: not-allowed;
opacity: 1;
}
.searchcue-analytics {
background: var(--searchcue-surface);
border: 1px solid var(--searchcue-line);
border-radius: 6px;
margin: 0 0 18px;
padding: 30px 34px;
}
.searchcue-analytics.is-in-hero {
background: var(--searchcue-surface);
border: 0;
border-radius: 0;
border-top: 1px solid var(--searchcue-line);
margin: 0;
padding: 30px 34px;
}
.searchcue-analytics-header {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
gap: 18px;
justify-content: space-between;
}
.searchcue-analytics-header > div:first-child {
flex: 1 1 360px;
min-width: 0;
}
.searchcue-analytics h2,
.searchcue-analytics h3 {
color: var(--searchcue-ink-900);
margin: 0;
}
.searchcue-analytics h2 {
font-size: 24px;
font-weight: 500;
}
.searchcue-analytics h3 {
font-size: 15px;
font-weight: 700;
}
.searchcue-analytics-kicker,
.searchcue-analytics-state {
color: var(--searchcue-ink-500);
font-size: 14px;
line-height: 1.55;
}
.searchcue-analytics-kicker {
margin: 12px 0 0;
}
.searchcue-analytics-state {
margin: 8px 0 0;
}
.searchcue-analytics-range {
color: var(--searchcue-cue);
font-size: 13px;
font-weight: 700;
}
.searchcue-analytics-link {
color: var(--searchcue-cue);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
}
.searchcue-analytics-meta {
align-items: flex-end;
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 8px;
margin-left: auto;
margin-top: 4px;
text-align: right;
}
.searchcue-analytics-metrics {
border-top: 1px solid var(--searchcue-line-soft);
display: grid;
gap: 24px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 24px;
padding-top: 24px;
}
.searchcue-analytics-metric {
min-width: 0;
}
.searchcue-analytics-metric span {
color: var(--searchcue-ink-500);
display: block;
font-size: 13px;
margin-bottom: 6px;
}
.searchcue-analytics-metric strong {
color: var(--searchcue-ink-900);
display: block;
font-size: 28px;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 1.1;
}
.searchcue-analytics-detail {
display: grid;
gap: 26px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
margin-top: 26px;
}
.searchcue-query-table {
margin-top: 14px;
overflow-x: auto;
}
.searchcue-query-table table {
border-collapse: collapse;
width: 100%;
}
.searchcue-query-table thead th {
border-bottom: 1px solid var(--searchcue-line);
color: var(--searchcue-ink-500);
font-size: 13px;
font-weight: 500;
padding: 0 0 10px;
text-align: left;
}
.searchcue-query-table thead th.searchcue-query-num {
text-align: right;
}
.searchcue-query-table tbody td {
border-bottom: 1px solid var(--searchcue-line-soft);
color: var(--searchcue-ink-700);
font-size: 14px;
padding: 11px 0;
}
.searchcue-query-table tbody tr:last-child td {
border-bottom: 0;
}
.searchcue-query-table tbody tr {
transition: background-color 140ms ease;
}
.searchcue-query-table tbody tr:hover {
background: var(--searchcue-paper-shade);
}
.searchcue-query-term {
color: var(--searchcue-ink-900);
font-weight: 600;
max-width: 0;
overflow: hidden;
padding-right: 16px;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.searchcue-query-num {
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
width: 1%;
}
.searchcue-volume-chart {
margin-top: 12px;
min-height: 180px;
}
.searchcue-volume-chart svg {
display: block;
height: 180px;
overflow: visible;
width: 100%;
}
.searchcue-volume-chart text {
fill: var(--searchcue-ink-500);
font-family: inherit;
font-size: 11px;
}
.searchcue-volume-grid {
stroke: var(--searchcue-line);
stroke-width: 1;
}
.searchcue-volume-axis {
stroke: var(--searchcue-ink-500);
stroke-width: 1;
}
.searchcue-volume-line {
fill: none;
stroke: var(--searchcue-cue);
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 3;
}
.searchcue-volume-point {
fill: var(--searchcue-surface);
stroke: var(--searchcue-cue);
stroke-width: 2.5;
}
.searchcue-empty {
color: var(--searchcue-ink-500);
font-size: 14px;
line-height: 1.55;
margin: 12px 0 0;
}
.searchcue-hidden {
display: none;
}
.searchcue-advanced {
margin-top: 28px;
max-width: 760px;
padding-top: 0;
}
.searchcue-advanced summary {
color: var(--searchcue-ink-900);
cursor: pointer;
font-size: 15px;
font-weight: 700;
width: fit-content;
}
.searchcue-advanced summary:focus-visible {
border-radius: 2px;
outline: 2px solid var(--searchcue-cue);
outline-offset: 4px;
}
.searchcue-advanced-body {
padding-top: 18px;
}
.searchcue-embed-notice,
.searchcue-activation {
background: var(--searchcue-surface);
border: 1px solid var(--searchcue-line);
border-radius: 6px;
margin: 0 0 18px;
padding: 30px 34px;
}
.searchcue-embed-notice {
background: var(--searchcue-err-wash);
border-color: var(--searchcue-err);
}
.searchcue-embed-notice h2,
.searchcue-activation h2 {
font-size: 22px;
margin: 0;
}
.searchcue-embed-reason {
color: var(--searchcue-err);
font-size: 15px;
font-weight: 600;
line-height: 1.6;
margin: 20px 0 8px;
max-width: 720px;
}
.searchcue-embed-notice .searchcue-help {
max-width: 720px;
}
.searchcue-diagnostics {
display: block;
font-size: 13px;
line-height: 1.6;
max-width: 720px;
min-height: 220px;
overflow-wrap: anywhere;
resize: vertical;
white-space: pre-wrap;
}
.searchcue-activation-intro {
color: var(--searchcue-ink-500);
font-size: 15px;
line-height: 1.6;
margin: 8px 0 0;
max-width: 720px;
}
.searchcue-activation-list {
list-style: none;
margin: 26px 0 0;
padding: 0;
}
.searchcue-activation-item {
padding: 22px 0;
}
.searchcue-activation-item:first-child {
padding-top: 0;
}
.searchcue-activation-item + .searchcue-activation-item {
border-top: 1px solid var(--searchcue-line);
}
.searchcue-activation-item h3 {
align-items: center;
color: var(--searchcue-ink-900);
display: flex;
flex-wrap: wrap;
font-size: 15px;
font-weight: 600;
gap: 10px;
margin: 0 0 6px;
}
.searchcue-embed-message,
.searchcue-activation-body {
color: var(--searchcue-ink-700);
font-size: 15px;
line-height: 1.6;
margin: 0;
max-width: 720px;
}
.searchcue-activation-body code {
background: var(--searchcue-paper-shade);
border-radius: 3px;
font-size: 0.92em;
padding: 1px 5px;
}
.searchcue-code {
background: var(--searchcue-paper-shade);
border: 1px solid var(--searchcue-line);
border-radius: 4px;
color: var(--searchcue-ink-900);
font-family: var(--searchcue-font-mono);
font-size: 13px;
line-height: 1.6;
margin: 12px 0 0;
max-width: 720px;
overflow-x: auto;
padding: 12px 14px;
}
.searchcue-activation-docs {
color: var(--searchcue-ink-500);
font-size: 14px;
line-height: 1.55;
margin: 30px 0 0;
}
.searchcue-protection h2 {
font-size: 22px;
margin: 0;
}
.searchcue-protection > p,
.searchcue-protection-status {
font-size: 14px;
line-height: 1.6;
max-width: 720px;
}
.searchcue-protection-warning {
color: var(--searchcue-warn);
}
.searchcue-protection form,
.searchcue-protection-status,
.searchcue-protection .searchcue-label {
margin-top: 24px;
}
.searchcue-protection-ips {
display: block;
font-size: 13px;
line-height: 1.6;
resize: vertical;
}
.searchcue-protection a {
color: var(--searchcue-cue);
}
@media (prefers-reduced-motion: reduce) {
.searchcue-switch,
.searchcue-switch::after,
.searchcue-query-table tbody tr {
transition: none;
}
}
@media (max-width: 900px) {
.searchcue-hero {
grid-template-columns: 1fr;
}
.searchcue-analytics-detail,
.searchcue-analytics-metrics {
grid-template-columns: 1fr;
}
.searchcue-admin h1 {
font-size: 34px;
}
.searchcue-hero-copy,
.searchcue-analytics.is-in-hero,
.searchcue-analytics,
.searchcue-embed-notice,
.searchcue-onboarding,
.searchcue-activation,
.searchcue-settings {
padding-left: 22px;
padding-right: 22px;
}
} Copyright 2023 The Schibsted-Grotesk Project Authors (https://github.com/schibsted/schibsted-grotesk)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE. Copyright 2022 The Spline Sans Mono Project Authors (https://github.com/SorkinType/SplineSansMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE. (function renderSettingsFormDirtyState() {
var form = document.querySelector("[data-searchcue-settings]");
if (!form) {
return;
}
var saveButton = form.querySelector("[data-searchcue-save]");
if (!saveButton) {
return;
}
var fields = Array.prototype.slice.call(
form.querySelectorAll("input[name^='donoharm_searchcue_options']")
);
function currentValue(field) {
return field.type === "checkbox" ? (field.checked ? "1" : "0") : field.value;
}
fields.forEach(function (field) {
field.dataset.searchcueInitialValue = currentValue(field);
});
function hasChanges() {
return fields.some(function (field) {
return currentValue(field) !== field.dataset.searchcueInitialValue;
});
}
function syncSaveButton() {
saveButton.disabled = !hasChanges();
}
fields.forEach(function (field) {
field.addEventListener("change", syncSaveButton);
field.addEventListener("input", syncSaveButton);
});
syncSaveButton();
})();
(function () {
var button = document.querySelector("[data-searchcue-copy-diagnostics]");
var details = button && button.closest("details");
var report = details && details.querySelector("[data-searchcue-diagnostics]");
var feedback = details && details.querySelector("[data-searchcue-copy-feedback]");
if (!button || !report || !feedback) {
return;
}
var messages = window.donoharmSearchCueAdmin || {};
function finishCopy(copied) {
button.disabled = false;
feedback.textContent = copied ? messages.diagnosticsCopied : messages.diagnosticsCopyFailed;
if (copied) {
button.focus();
} else {
report.focus();
report.select();
}
}
function copySelection() {
var copied = false;
try {
report.focus();
report.select();
copied = document.execCommand("copy");
} catch (error) {}
finishCopy(copied);
}
button.addEventListener("click", function () {
if (button.disabled) {
return;
}
button.disabled = true;
feedback.textContent = "";
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(report.value).then(function () {
finishCopy(true);
}, copySelection);
return;
}
} catch (error) {}
copySelection();
});
})();
(function () {
var setup = document.querySelector("[data-searchcue-setup]");
if (!setup) {
return;
}
var endpoint = setup.getAttribute("data-endpoint");
var nonce = setup.getAttribute("data-nonce");
function checkSetup() {
var url = endpoint + "?action=donoharm_searchcue_setup_status&_ajax_nonce=" + encodeURIComponent(nonce);
fetch(url, { credentials: "same-origin" })
.then(function (response) { return response.json(); })
.then(function (payload) {
if (payload && payload.success && payload.data && payload.data.connected) {
window.location.reload();
}
})
.catch(function () {});
}
window.setInterval(checkSetup, 5000);
})();
(function () {
var card = document.querySelector("[data-searchcue-analytics]");
if (!card || card.getAttribute("data-enabled") !== "1") {
return;
}
var endpoint = card.getAttribute("data-endpoint");
var nonce = card.getAttribute("data-nonce");
var messages = window.donoharmSearchCueAdmin || {};
var state = card.querySelector("[data-searchcue-analytics-state]");
var content = card.querySelector("[data-searchcue-analytics-content]");
function setText(selector, value) {
var node = card.querySelector(selector);
if (node) {
node.textContent = formatCount(value);
}
}
function formatCount(value) {
var number = parseInt(value, 10);
if (isNaN(number)) {
number = 0;
}
return number.toLocaleString();
}
function showEmpty(selector, show) {
var node = card.querySelector(selector);
if (node) {
node.classList.toggle("searchcue-hidden", !show);
}
}
function formatSearchCount(value) {
return formatCount(value) + " " +
(value === 1 ? messages.searchSingular : messages.searchPlural);
}
function renderQueries(queries) {
var body = card.querySelector("[data-searchcue-analytics-queries]");
var table = card.querySelector("[data-searchcue-analytics-query-table]");
if (!body) {
return;
}
body.innerHTML = "";
if (!queries || !queries.length) {
if (table) {
table.classList.add("searchcue-hidden");
}
showEmpty("[data-searchcue-analytics-empty-queries]", true);
return;
}
if (table) {
table.classList.remove("searchcue-hidden");
}
showEmpty("[data-searchcue-analytics-empty-queries]", false);
queries.forEach(function (query) {
var row = document.createElement("tr");
var term = document.createElement("td");
var count = document.createElement("td");
term.className = "searchcue-query-term";
term.textContent = query.term || "";
count.className = "searchcue-query-num";
count.textContent = formatCount(query.searches);
row.appendChild(term);
row.appendChild(count);
body.appendChild(row);
});
}
function renderVolume(points) {
var chart = card.querySelector("[data-searchcue-analytics-volume]");
if (!chart) {
return;
}
chart.innerHTML = "";
if (!points || !points.length) {
chart.classList.add("searchcue-hidden");
showEmpty("[data-searchcue-analytics-empty-volume]", true);
return;
}
chart.classList.remove("searchcue-hidden");
showEmpty("[data-searchcue-analytics-empty-volume]", false);
var max = points.reduce(function (current, point) {
return Math.max(current, parseInt(point.count, 10) || 0);
}, 0);
var width = 360;
var height = 160;
var plot = {
left: 32,
right: 354,
top: 8,
bottom: 132
};
var plotWidth = plot.right - plot.left;
var plotHeight = plot.bottom - plot.top;
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
var chartPoints = points.map(function (point, index) {
var value = parseInt(point.count, 10) || 0;
return {
label: point.label || "",
value: value,
x: points.length === 1
? plot.left + plotWidth / 2
: plot.left + index / (points.length - 1) * plotWidth,
y: max > 0
? plot.top + (1 - value / max) * plotHeight
: plot.bottom
};
});
svg.setAttribute("viewBox", "0 0 " + width + " " + height);
svg.setAttribute("role", "img");
svg.setAttribute(
"aria-label",
messages.searchVolumeByDay + " " + chartPoints.map(function (point) {
return point.label + ": " + formatSearchCount(point.value);
}).join(", ")
);
var tickValues = max > 0 ? [max, max / 2, 0] : [0];
tickValues.forEach(function (tickValue) {
var tickY = max > 0
? plot.top + (1 - tickValue / max) * plotHeight
: plot.bottom;
var grid = document.createElementNS("http://www.w3.org/2000/svg", "line");
var label = document.createElementNS("http://www.w3.org/2000/svg", "text");
var roundedTick = Math.round(tickValue * 10) / 10;
grid.setAttribute("x1", plot.left);
grid.setAttribute("y1", tickY);
grid.setAttribute("x2", plot.right);
grid.setAttribute("y2", tickY);
grid.setAttribute(
"class",
tickValue === 0 ? "searchcue-volume-axis" : "searchcue-volume-grid"
);
label.setAttribute("x", plot.left - 8);
label.setAttribute("y", tickY);
label.setAttribute("text-anchor", "end");
label.setAttribute("dominant-baseline", "middle");
label.textContent = roundedTick.toLocaleString();
svg.appendChild(grid);
svg.appendChild(label);
});
chartPoints.forEach(function (point) {
var xLabel = document.createElementNS("http://www.w3.org/2000/svg", "text");
xLabel.setAttribute("x", point.x);
xLabel.setAttribute("y", plot.bottom + 20);
xLabel.setAttribute("text-anchor", "middle");
xLabel.textContent = point.label;
svg.appendChild(xLabel);
});
var line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
line.setAttribute("class", "searchcue-volume-line");
line.setAttribute("points", chartPoints.map(function (point) {
return point.x + "," + point.y;
}).join(" "));
svg.appendChild(line);
chartPoints.forEach(function (point) {
var circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
var title = document.createElementNS("http://www.w3.org/2000/svg", "title");
circle.setAttribute("class", "searchcue-volume-point");
circle.setAttribute("cx", point.x);
circle.setAttribute("cy", point.y);
circle.setAttribute("r", 4);
title.textContent = point.label + ": " + formatSearchCount(point.value);
circle.appendChild(title);
svg.appendChild(circle);
});
chart.appendChild(svg);
}
fetch(endpoint + "?action=donoharm_searchcue_analytics&_ajax_nonce=" + encodeURIComponent(nonce), {
credentials: "same-origin"
})
.then(function (response) { return response.json(); })
.then(function (payload) {
if (!payload || !payload.success || !payload.data) {
throw new Error("analytics unavailable");
}
var data = payload.data;
var range = card.querySelector("[data-searchcue-analytics-range]");
if (range && data.range && data.range.label) {
range.textContent = data.range.label;
}
setText("[data-searchcue-analytics-searches]", data.totals && data.totals.searches);
setText("[data-searchcue-analytics-clicks]", data.totals && data.totals.clicks);
setText("[data-searchcue-analytics-zero-results]", data.totals && data.totals.zeroResults);
renderQueries(data.topQueries || []);
renderVolume(data.dailyVolume || []);
if (state) {
state.classList.add("searchcue-hidden");
}
if (content) {
content.classList.remove("searchcue-hidden");
}
})
.catch(function () {
if (state) {
state.textContent = messages.analyticsUnavailable;
}
});
})(); (function loadEmbed(options) {
const anchor = document.currentScript;
if (!anchor) return;
const collection = Boolean(options.collectionId);
const method = collection ? "mount" : "showSearch";
let active = false;
let statusTimer;
let panel;
function setFailureVisible(visible) {
if (!panel) return;
panel.hidden = !visible;
panel.style.setProperty("display", visible ? "" : "none", "important");
}
function showFailure(error) {
setFailureVisible(true);
if (error) console.error("Embed failed to load", error);
}
try {
if (collection && anchor.parentElement && !anchor.parentElement.matches("head, html")) {
let placeholder = anchor.previousElementSibling;
if (placeholder?.getAttribute("data-collection-placeholder") !== options.collectionId) {
placeholder = null;
}
const preceding = placeholder ? placeholder.previousElementSibling : anchor.previousElementSibling;
if (preceding?.getAttribute("data-collection-load-failure") === options.collectionId) panel = preceding;
if (!panel) {
panel = document.createElement("div");
panel.setAttribute("data-collection-load-failure", options.collectionId);
panel.setAttribute("role", "status");
const message = document.createElement("p");
message.setAttribute("data-collection-load-message", "");
const retry = document.createElement("a");
retry.setAttribute("data-collection-load-retry", "");
panel.append(message, retry);
anchor.after(panel);
}
const failure = options.failure || {
language: "en", message: "We're having trouble loading this content.", retry: "Retry",
};
panel.lang = failure.language;
panel.dir = failure.language === "ar" ? "rtl" : "ltr";
panel.querySelector("[data-collection-load-message]").textContent = failure.message;
const retry = panel.querySelector("[data-collection-load-retry]");
retry.textContent = failure.retry;
retry.href = location.href;
retry.addEventListener("click", (event) => {
event.preventDefault();
// Reload preserves hash routing without duplicating an in-flight native script or settings request.
if (active) {
try { location.reload(); } catch (error) { showFailure(error); }
} else {
start();
}
});
}
const url = new URL(options.src, location.href);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
throw new Error("Invalid embed bundle URL");
}
if (!["Monocle", "SearchCue", "MonocleCollections", "SearchCueCollections"].includes(options.globalName)) {
throw new Error("Invalid embed product");
}
const integrity = options.integrity || "";
const referrerPolicy = anchor.referrerPolicy || "";
const attachTo = options.attachTo ?? anchor.getAttribute("attachto");
const registryKey = Symbol.for("donoharm.embed.loads");
const loads = window[registryKey] ||= new Map();
function api() {
const value = window[options.globalName];
return typeof value?.[method] === "function" ? value : null;
}
function execute(src, native) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.async = true;
script.fetchPriority = "high";
script.referrerPolicy = referrerPolicy;
script.setAttribute("siteid", options.siteId);
if (attachTo) script.setAttribute("attachto", attachTo);
if (native) {
script.crossOrigin = "anonymous";
// Preserve authorisation for the original URL, never for Blob code.
if (anchor.nonce) script.nonce = anchor.nonce;
if (integrity) script.integrity = integrity;
}
script.onload = () => {
script.onload = script.onerror = null;
if (!native) URL.revokeObjectURL(src);
const value = api();
if (value) resolve(value);
else reject(new Error("Embed bundle did not initialise"));
};
script.onerror = () => {
script.onload = script.onerror = null;
script.remove();
if (!native) URL.revokeObjectURL(src);
reject(Object.assign(new Error("Embed script was blocked"), { local: !native }));
};
try {
script.src = src;
if (script.src !== src) throw new Error("Embed script URL was changed by policy");
document.head.appendChild(script);
} catch (error) {
script.remove();
if (!native) URL.revokeObjectURL(src);
reject(Object.assign(error, { local: !native }));
}
});
}
async function nativeAfterLoad() {
await new Promise((resolve) => {
const ready = () => setTimeout(resolve, 0);
if (document.readyState === "complete") ready();
else window.addEventListener("load", ready, { once: true });
});
return execute(url.href, true);
}
async function download() {
const deadline = performance.now() + 40000;
const limit = 8 * 1024 * 1024;
let policyBlocked = false;
const policy = (event) => {
if (event.isTrusted && event.disposition === "enforce" && event.effectiveDirective === "connect-src" &&
[url.href, url.origin, url.origin + url.pathname].includes(event.blockedURI)) {
policyBlocked = true;
}
};
document.addEventListener("securitypolicyviolation", policy);
try {
for (let attempt = 0; attempt < 2; attempt++) {
if (performance.now() >= deadline) throw new Error("Embed download timed out");
let delay = 250 + Math.random() * 250;
const controller = new AbortController();
let timer;
const expired = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new DOMException("Download timed out", "AbortError"));
controller.abort();
}, Math.min(20000, deadline - performance.now()));
});
try {
return await Promise.race([
(async () => {
const response = await fetch(url.href, {
mode: "cors", credentials: "omit", redirect: "error", priority: "high",
integrity, referrerPolicy, signal: controller.signal,
cache: attempt ? "no-cache" : "default",
});
if (controller.signal.aborted) throw new DOMException("Download timed out", "AbortError");
if (!response.ok) {
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
const seconds = Number(retryAfter);
const wait = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(retryAfter) - Date.now();
if (Number.isFinite(wait)) delay = Math.max(delay, wait);
}
throw Object.assign(new Error("Embed download failed: " + response.status), {
retryable: response.status === 408 || response.status === 429 ||
(response.status >= 500 && response.status < 600),
});
}
const type = (response.headers.get("Content-Type") || "").split(";")[0].trim().toLowerCase();
if (!["text/javascript", "application/javascript", "application/x-javascript"].includes(type)) {
throw new Error("Embed response is not JavaScript");
}
if (Number(response.headers.get("Content-Length")) > limit || !response.body) {
throw new Error("Invalid embed response size");
}
const reader = response.body.getReader();
const chunks = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (controller.signal.aborted) throw new DOMException("Download timed out", "AbortError");
if (done) break;
size += value.byteLength;
if (size > limit) throw new Error("Embed response is too large");
chunks.push(value);
}
if (!size) throw new Error("Embed response is empty");
return new Blob(chunks, { type: "text/javascript;charset=utf-8" });
})(),
expired,
]);
} catch (error) {
// CSP violations are dispatched after the rejected fetch promise.
await new Promise((resolve) => setTimeout(resolve, 0));
if (policyBlocked) throw Object.assign(error, { policy: true });
if (attempt || performance.now() + delay >= deadline ||
!(error.retryable || error.name === "TypeError" || error.name === "AbortError")) {
throw error;
}
} finally {
clearTimeout(timer);
controller.abort();
}
await new Promise((resolve) => setTimeout(resolve, delay));
}
} finally {
document.removeEventListener("securitypolicyviolation", policy);
}
}
function bundle() {
const existing = loads.get(options.globalName);
if (existing) {
if (integrity && integrity !== existing.integrity) {
return Promise.reject(new Error("Conflicting embed integrity requirements"));
}
return existing.promise;
}
const loaded = api();
if (loaded) {
return integrity
? Promise.reject(new Error("Existing embed integrity cannot be verified"))
: Promise.resolve(loaded);
}
const entry = { integrity, promise: null };
loads.set(options.globalName, entry);
entry.promise = (async () => {
if (!window.fetch || !window.AbortController || !URL.createObjectURL) {
return nativeAfterLoad();
}
let blob;
try {
blob = await download();
} catch (error) {
if (error.policy) return nativeAfterLoad();
throw error;
}
try {
return await execute(URL.createObjectURL(blob), false);
} catch (error) {
if (error.local) return nativeAfterLoad();
throw error;
}
})().catch((error) => {
if (loads.get(options.globalName) === entry) loads.delete(options.globalName);
throw error;
});
return entry.promise;
}
async function start() {
if (active) return;
active = true;
setFailureVisible(false);
if (panel) statusTimer = setTimeout(() => showFailure(), 15000);
try {
const value = await bundle();
if (!anchor.isConnected) return;
if (collection && !await value.mount({ siteId: options.siteId, collectionId: options.collectionId, script: anchor })) {
throw new Error("Collection did not initialise");
}
setFailureVisible(false);
return value;
} catch (error) {
showFailure(error);
} finally {
active = false;
clearTimeout(statusTimer);
}
}
return start();
} catch (error) {
showFailure(error);
}
}) <?php
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
function register_admin_page(): void
{
add_menu_page(
__("SearchCue", "searchcue"),
__("SearchCue", "searchcue"),
"manage_options",
SETTINGS_PAGE,
__NAMESPACE__ . '\\render_settings_page',
"dashicons-search",
);
$options = get_searchcue_options();
if ($options["api_key"] === "") {
return;
}
$links = [
["documentation", __("Documentation", "searchcue")],
["design", __("Design", "searchcue")],
["analytics", __("Analytics", "searchcue")],
["more", __("SearchCue Dashboard", "searchcue")],
];
foreach ($links as [$destination, $label]) {
add_submenu_page(
SETTINGS_PAGE,
$label,
$label,
"manage_options",
searchcue_admin_url($options, $destination),
);
}
}
function register_settings(): void
{
// Registering generated defaults makes WordPress skip saving identical first-run settings.
register_setting(
SETTINGS_GROUP,
OPTION_NAME,
array(
"type" => "array",
"sanitize_callback" => "DoNoHarm\\SearchCue\\sanitize_options",
),
);
add_settings_section(
SETTINGS_SECTION,
__("Settings", "searchcue"),
"__return_empty_string",
SETTINGS_PAGE,
);
add_settings_field(
"donoharm_searchcue_site_id",
__("SearchCue Site ID", "searchcue"),
__NAMESPACE__ . '\\render_site_id_field',
SETTINGS_PAGE,
SETTINGS_SECTION,
);
add_settings_field(
"donoharm_searchcue_push_updates",
__("Automatic updates", "searchcue"),
__NAMESPACE__ . '\\render_push_updates_field',
SETTINGS_PAGE,
SETTINGS_SECTION,
);
}
function render_site_id_field(): void
{
$options = get_searchcue_options(); ?>
<input
id="donoharm_searchcue_site_id"
class="regular-text"
name="<?php echo esc_attr(OPTION_NAME); ?>[site_id]"
type="text"
value="<?php echo esc_attr($options["site_id"]); ?>"
placeholder="site_..."
autocomplete="off"
>
<p class="description">
<?php echo esc_html__(
"Only change this if you want to use a different search index than the default SearchCue assigned.",
"searchcue",
); ?>
</p>
<?php
}
function render_push_updates_field(): void
{
$options = get_searchcue_options(); ?>
<label for="donoharm_searchcue_push_updates">
<input
id="donoharm_searchcue_push_updates"
name="<?php echo esc_attr(OPTION_NAME); ?>[push_updates]"
type="checkbox"
value="1"
<?php checked($options["push_updates"], "1"); ?>
>
<?php echo esc_html__(
"Keep the SearchCue search index current as public pages and files change.",
"searchcue",
); ?>
</label>
<?php
}
function render_settings_page(): void
{
if (!current_user_can("manage_options")) {
return;
}
settings_errors();
$options = get_searchcue_options();
$effective_site_id = effective_site_id($options);
if ($effective_site_id === "") {
render_first_run_settings_page($options);
return;
}
render_connected_settings_page($options);
}
function render_embed_output_status(): void
{
if (!current_user_can("manage_options")) {
return;
}
$status = embed_render_status();
switch ($status["reason"]) {
case "removed_tag":
$message = __("A plugin or your theme removed SearchCue from the page. Check your cookie-consent and performance settings for a rule that blocks SearchCue.", "searchcue");
break;
case "invalid_markup":
$message = __("A plugin or your theme changed the code that adds SearchCue to the page. Check your performance plugin's JavaScript settings and exclude SearchCue from changes that combine or rewrite files.", "searchcue");
break;
case "duplicate_attributes":
$message = __("SearchCue received conflicting loading instructions. Check whether more than one performance or cookie-consent plugin is changing SearchCue.", "searchcue");
break;
case "blocked_type":
$message = __("Another plugin changed how SearchCue is allowed to run. Check your cookie-consent and performance settings. SearchCue must still wait for any consent those settings require.", "searchcue");
break;
case "missing_src":
$message = __("The address of the SearchCue file is missing. Check your performance and cookie-consent settings for rules that change how SearchCue loads.", "searchcue");
break;
case "unexpected_src":
$message = __("The SearchCue file now points to a different address. Check settings that move or combine JavaScript files, and exclude SearchCue from those changes.", "searchcue");
break;
case "loader_unreadable":
$message = __("A SearchCue file is missing or cannot be read. Install a fresh copy over the existing plugin. If this continues, ask your hosting provider to check the plugin files.", "searchcue");
break;
default:
return;
}
$scope = __("This checks what the WordPress plugin added to a page. It does not test cached pages, later HTML changes, or whether search works in the browser.", "searchcue");
$candidates = __("The plugins below are diagnostic candidates, not proof of a conflict or fault. Inactive plugins are included for completeness. An installed drop-in is not necessarily active.", "searchcue");
$site_plugins = get_option("active_plugins", []);
$network_plugins = is_multisite() ? get_site_option("active_sitewide_plugins", []) : [];
$searchcue_plugin = plugin_basename(PLUGIN_FILE);
$inventory = [];
foreach (["ordinary" => get_plugins(), "mu" => get_mu_plugins(), "dropin" => get_dropins()] as $kind => $plugins) {
foreach ($plugins as $file => $plugin) {
if ($file === $searchcue_plugin) {
continue;
}
if ($kind === "mu") {
$state = __("Must-use", "searchcue");
} elseif ($kind === "dropin") {
$state = __("Drop-in installed; activation not checked", "searchcue");
} elseif (is_array($network_plugins) && array_key_exists($file, $network_plugins)) {
$state = __("Network active", "searchcue");
} else {
$state = is_array($site_plugins) && in_array($file, $site_plugins, true)
? __("Active", "searchcue")
: __("Inactive", "searchcue");
}
$name = sanitize_text_field(html_entity_decode((string) ($plugin["Name"] ?? ""), ENT_QUOTES, "UTF-8"));
$version = sanitize_text_field(html_entity_decode((string) ($plugin["Version"] ?? ""), ENT_QUOTES, "UTF-8"));
$inventory[] = sprintf(
/* translators: 1: plugin name, 2: plugin version, 3: installation or activation state. */
__('%1$s (%2$s) [%3$s]', "searchcue"),
$name !== "" ? $name : sanitize_text_field(basename($file)),
$version !== "" ? $version : __("Version unavailable", "searchcue"),
$state,
);
}
}
$home = normalize_source_url(get_option("home", ""));
$site_address = source_origin($home);
if ($site_address !== "") {
$site_address .= (string) wp_parse_url($home, PHP_URL_PATH);
}
$report = implode("\n", [
/* translators: %s: installed SearchCue version. */
sprintf(__("SearchCue version: %s", "searchcue"), VERSION),
/* translators: %s: public SearchCue Site ID. */
sprintf(__("Site ID: %s", "searchcue"), effective_site_id(get_searchcue_options())),
/* translators: %s: canonical public site address, without credentials or query parameters. */
sprintf(__("Site address: %s", "searchcue"), $site_address),
"",
/* translators: %s: fixed local rendering reason code. */
sprintf(__("Check result: %s", "searchcue"), $status["reason"]),
$message,
$scope,
"",
__("Other installed plugins:", "searchcue"),
$candidates,
implode("\n", $inventory) ?: __("No other installed plugins were found.", "searchcue"),
]);
?>
<section
class="searchcue-embed-notice"
role="alert"
aria-labelledby="searchcue-script-output-title"
>
<h2 id="searchcue-script-output-title"><?php echo esc_html__("Search on your website", "searchcue"); ?></h2>
<p class="searchcue-embed-reason"><?php echo esc_html__("SearchCue could not be added to the page", "searchcue"); ?></p>
<p class="searchcue-embed-message"><?php echo esc_html($message); ?></p>
<p class="searchcue-help"><?php echo esc_html__("After changing a setting, clear your website's cache. Then open your website and refresh this page.", "searchcue"); ?></p>
<div class="searchcue-actions">
<a class="button button-primary" href="<?php echo esc_url(home_url("/")); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html__("Open your website", "searchcue"); ?></a>
</div>
<details class="searchcue-advanced">
<summary><?php echo esc_html__("Details for support", "searchcue"); ?></summary>
<div class="searchcue-advanced-body">
<label class="searchcue-label" for="searchcue-diagnostics"><?php echo esc_html__("Diagnostic report", "searchcue"); ?></label>
<textarea id="searchcue-diagnostics" class="searchcue-input searchcue-mono searchcue-diagnostics" data-searchcue-diagnostics readonly rows="18"><?php echo esc_textarea($report); ?></textarea>
<div class="searchcue-actions">
<button type="button" class="button button-primary" data-searchcue-copy-diagnostics><?php echo esc_html__("Copy diagnostics", "searchcue"); ?></button>
</div>
<p class="searchcue-help" data-searchcue-copy-feedback role="status" aria-live="polite"></p>
</div>
</details>
</section>
<?php
}
function render_search_activation_card(array $options): void
{
?>
<section class="searchcue-activation">
<h2><?php echo esc_html__("Activate the search interface", "searchcue"); ?></h2>
<p class="searchcue-activation-intro">
<?php echo esc_html__(
"Choose how visitors open search on your website. Use one of these options, or combine them.",
"searchcue",
); ?>
</p>
<ul class="searchcue-activation-list">
<li class="searchcue-activation-item">
<h3>
<?php echo esc_html__("Use the floating search icon", "searchcue"); ?>
</h3>
<p class="searchcue-activation-body">
<?php
printf(
/* translators: %s: link to the SearchCue design section. */
esc_html__(
"Choose whether to show a floating search icon, and customise its appearance in the %s.",
"searchcue",
),
'<a href="' .
esc_url(searchcue_admin_url($options, "design") . "#search-launcher") .
'" target="_blank" rel="noopener noreferrer">' .
esc_html__("design section", "searchcue") .
"</a>",
);
?>
</p>
</li>
<li class="searchcue-activation-item">
<h3><?php echo esc_html__("Add a search link", "searchcue"); ?></h3>
<p class="searchcue-activation-body">
<?php
printf(
/* translators: %s: the /search path shown in monospace. */
esc_html__(
"Link to %s anywhere on your site, for example as a search button in your header or navigation. SearchCue opens when visitors follow the link.",
"searchcue",
),
'<code class="searchcue-mono">/search</code>',
);
?>
</p>
</li>
<li class="searchcue-activation-item">
<h3><?php echo esc_html__("Add a search box", "searchcue"); ?></h3>
<p class="searchcue-activation-body">
<?php echo esc_html__(
"Add a search box to your theme with the code below. SearchCue opens when a visitor selects it.",
"searchcue",
); ?>
</p>
<pre class="searchcue-code"><code><?php echo esc_html('<input type="search" placeholder="Search..." />'); ?></code></pre>
</li>
</ul>
<p class="searchcue-activation-docs">
<?php
printf(
/* translators: %s: link to the WordPress guide. */
esc_html__("For the full walkthrough, read the %s.", "searchcue"),
'<a href="' .
esc_url("https://searchcue.com/docs/tutorial-wordpress") .
'" target="_blank" rel="noopener noreferrer">' .
esc_html__("WordPress guide", "searchcue") .
"</a>",
);
?>
</p>
</section>
<?php
}
function render_connected_settings_page(array $options): void
{
?>
<div class="wrap searchcue-admin">
<section class="searchcue-hero is-connected">
<div class="searchcue-hero-copy">
<h1 class="searchcue-wordmark">
<span><?php echo esc_html__("SearchCue", "searchcue"); ?></span>
<span class="searchcue-wordmark-caret" aria-hidden="true"></span>
</h1>
<p class="searchcue-connected-status">
<span class="searchcue-connected-check" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none">
<path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</span>
<span><?php echo esc_html__("SearchCue settings saved", "searchcue"); ?></span>
</p>
</div>
<?php render_search_analytics_card($options); ?>
</section>
<?php render_embed_output_status(); ?>
<?php render_search_activation_card($options); ?>
<?php render_protection_panel(); ?>
<form class="searchcue-settings" action="options.php" method="post" data-searchcue-settings>
<?php settings_fields(SETTINGS_GROUP); ?>
<label class="searchcue-toggle" for="donoharm_searchcue_push_updates">
<input
class="searchcue-switch-input"
id="donoharm_searchcue_push_updates"
name="<?php echo esc_attr(OPTION_NAME); ?>[push_updates]"
type="checkbox"
value="1"
<?php checked($options["push_updates"], "1"); ?>
>
<span class="searchcue-switch" aria-hidden="true"></span>
<span>
<span class="searchcue-toggle-title">
<?php echo esc_html__("Automatic updates", "searchcue"); ?>
</span>
<span class="searchcue-help">
<?php echo esc_html__(
"Keep the search index current as you update public pages, posts, custom content, and files.",
"searchcue",
); ?>
</span>
</span>
</label>
<details class="searchcue-advanced">
<summary><?php echo esc_html__("Advanced settings", "searchcue"); ?></summary>
<div class="searchcue-advanced-body">
<div class="searchcue-field">
<label class="searchcue-label" for="donoharm_searchcue_site_id">
<?php echo esc_html__("SearchCue Site ID", "searchcue"); ?>
</label>
<input
id="donoharm_searchcue_site_id"
class="searchcue-input regular-text searchcue-mono"
name="<?php echo esc_attr(OPTION_NAME); ?>[site_id]"
type="text"
value="<?php echo esc_attr($options["site_id"]); ?>"
placeholder="<?php echo esc_attr($options["assigned_site_id"] !== "" ? $options["assigned_site_id"] : "site_..."); ?>"
autocomplete="off"
>
<p class="searchcue-help">
<?php echo esc_html__(
"Only change this if you want to use a different search index than the default SearchCue assigned.",
"searchcue",
); ?>
</p>
</div>
</div>
</details>
<div class="searchcue-actions">
<button
class="button button-primary searchcue-save"
type="submit"
name="submit"
data-searchcue-save
>
<?php echo esc_html__("Save SearchCue settings", "searchcue"); ?>
</button>
</div>
</form>
</div>
<?php
} <?php
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
function render_search_analytics_card(array $options): void
{
$can_load_analytics = $options["api_key"] !== "";
?>
<div
class="searchcue-analytics is-in-hero"
data-searchcue-analytics
data-endpoint="<?php echo esc_url(admin_url("admin-ajax.php")); ?>"
data-nonce="<?php echo esc_attr(wp_create_nonce("donoharm_searchcue_analytics")); ?>"
data-enabled="<?php echo esc_attr($can_load_analytics ? "1" : "0"); ?>"
>
<div class="searchcue-analytics-header">
<div>
<h2><?php echo esc_html__("Search activity", "searchcue"); ?></h2>
<p class="searchcue-analytics-kicker">
<?php echo esc_html__(
"A quick view of how visitors use SearchCue on this site.",
"searchcue",
); ?>
</p>
</div>
<div class="searchcue-analytics-meta">
<span class="searchcue-analytics-range" data-searchcue-analytics-range>
<?php echo esc_html__("Last 30 days", "searchcue"); ?>
</span>
</div>
</div>
<p
class="searchcue-analytics-state"
role="status" aria-live="polite"
data-searchcue-analytics-state
>
<?php
echo $can_load_analytics
? esc_html__("Loading search activity...", "searchcue")
: esc_html__(
"Search activity will appear here once this site is connected with a SearchCue API key.",
"searchcue",
);
?>
</p>
<div class="searchcue-hidden" data-searchcue-analytics-content>
<div class="searchcue-analytics-metrics">
<div class="searchcue-analytics-metric">
<span><?php echo esc_html__("Searches", "searchcue"); ?></span>
<strong data-searchcue-analytics-searches>0</strong>
</div>
<div class="searchcue-analytics-metric">
<span><?php echo esc_html__("Clicks", "searchcue"); ?></span>
<strong data-searchcue-analytics-clicks>0</strong>
</div>
<div class="searchcue-analytics-metric">
<span><?php echo esc_html__("No-result searches", "searchcue"); ?></span>
<strong data-searchcue-analytics-zero-results>0</strong>
</div>
</div>
<div class="searchcue-analytics-detail">
<div>
<h3><?php echo esc_html__("Recent search terms", "searchcue"); ?></h3>
<div class="searchcue-query-table" data-searchcue-analytics-query-table>
<table>
<caption class="screen-reader-text">
<?php echo esc_html__("Recent search terms", "searchcue"); ?>
</caption>
<thead>
<tr>
<th scope="col">
<?php echo esc_html__("Search term", "searchcue"); ?>
</th>
<th scope="col" class="searchcue-query-num">
<?php echo esc_html__("Searches", "searchcue"); ?>
</th>
</tr>
</thead>
<tbody data-searchcue-analytics-queries></tbody>
</table>
</div>
<p class="searchcue-empty searchcue-hidden" data-searchcue-analytics-empty-queries>
<?php echo esc_html__(
"Search terms will appear once visitors start searching.",
"searchcue",
); ?>
</p>
</div>
<div>
<h3><?php echo esc_html__("Search volume", "searchcue"); ?></h3>
<div class="searchcue-volume-chart" data-searchcue-analytics-volume></div>
<p class="searchcue-empty searchcue-hidden" data-searchcue-analytics-empty-volume>
<?php echo esc_html__(
"Search volume will appear once there is activity in the current period.",
"searchcue",
); ?>
</p>
</div>
</div>
</div>
</div>
<?php
}
function ajax_search_analytics(): void
{
if (!current_user_can("manage_options")) {
wp_send_json_error(["message" => "forbidden"]);
return;
}
check_ajax_referer("donoharm_searchcue_analytics");
$options = get_searchcue_options();
if ($options["api_key"] === "") {
wp_send_json_error(["message" => "missing_api_key"]);
return;
}
$analytics = fetch_site_analytics($options["api_key"]);
if ($analytics === null) {
wp_send_json_error(["message" => "analytics_unavailable"]);
return;
}
wp_send_json_success($analytics);
}
/**
* @return array{range: array{label: string, days: int}, totals: array{searches: int, zeroResults: int, clicks: int}, topQueries: array<int, array{term: string, searches: int}>, dailyVolume: array<int, array{label: string, count: int}>}|null
*/
function fetch_site_analytics(string $api_key): ?array
{
$response = wp_remote_get(wordpress_integration_url("/analytics.json"), [
"user-agent" => CRAWLER_USER_AGENT,
"timeout" => 2,
"redirection" => 0,
"limit_response_size" => 262145,
"headers" => [
"Accept" => "application/json",
"Authorization" => "Bearer " . $api_key,
],
]);
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
return null;
}
$body = wp_remote_retrieve_body($response);
if (strlen($body) > 262144) {
return null;
}
$payload = json_decode($body, true);
return sanitize_site_analytics_payload($payload);
}
/**
* @param mixed $payload
*
* @return array{range: array{label: string, days: int}, totals: array{searches: int, zeroResults: int, clicks: int}, topQueries: array<int, array{term: string, searches: int}>, dailyVolume: array<int, array{label: string, count: int}>}|null
*/
function sanitize_site_analytics_payload($payload): ?array
{
if (!is_array($payload)) {
return null;
}
$range = is_array($payload["range"] ?? null) ? $payload["range"] : [];
$totals = is_array($payload["totals"] ?? null) ? $payload["totals"] : [];
$top_queries = is_array($payload["topQueries"] ?? null) ? $payload["topQueries"] : [];
$daily_volume = is_array($payload["dailyVolume"] ?? null) ? $payload["dailyVolume"] : [];
return [
"range" => [
"label" => sanitize_text_field($range["label"] ?? "30 days"),
"days" => max(0, (int) ($range["days"] ?? 30)),
],
"totals" => [
"searches" => max(0, (int) ($totals["searches"] ?? 0)),
"zeroResults" => max(0, (int) ($totals["zeroResults"] ?? 0)),
"clicks" => max(0, (int) ($totals["clicks"] ?? 0)),
],
"topQueries" => sanitize_site_analytics_queries($top_queries),
"dailyVolume" => sanitize_site_analytics_volume($daily_volume),
];
}
/**
* @param array<int, mixed> $queries
*
* @return array<int, array{term: string, searches: int}>
*/
function sanitize_site_analytics_queries(array $queries): array
{
$sanitized = [];
foreach (array_slice($queries, 0, 5) as $query) {
if (!is_array($query)) {
continue;
}
$term = sanitize_text_field($query["term"] ?? "");
if ($term === "") {
continue;
}
$sanitized[] = [
"term" => $term,
"searches" => max(0, (int) ($query["searches"] ?? 0)),
];
}
return $sanitized;
}
/**
* @param array<int, mixed> $points
*
* @return array<int, array{label: string, count: int}>
*/
function sanitize_site_analytics_volume(array $points): array
{
$sanitized = [];
foreach (array_slice($points, -7) as $point) {
if (!is_array($point)) {
continue;
}
$label = sanitize_text_field($point["label"] ?? "");
if ($label === "") {
continue;
}
$sanitized[] = [
"label" => $label,
"count" => max(0, (int) ($point["count"] ?? 0)),
];
}
return $sanitized;
} <?php
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
function embed_render_status(): array
{
$site_id = effective_site_id(get_searchcue_options());
if ($site_id === "") {
return ["reason" => "missing_site_id"];
}
$status = get_option("donoharm_searchcue_embed_status");
if (
!is_array($status) ||
($status["site_id"] ?? null) !== $site_id ||
($status["version"] ?? null) !== VERSION ||
($status["home"] ?? null) !== (string) get_option("home") ||
!in_array($status["reason"] ?? null, [
"emitted", "missing_site_id", "removed_tag", "invalid_markup", "duplicate_attributes",
"blocked_type", "missing_src", "unexpected_src", "loader_unreadable",
], true)
) {
return ["reason" => "not_observed"];
}
return ["reason" => $status["reason"]];
}
function record_embed_render_status(string $reason, string $site_id): void
{
$status = [
"reason" => $reason,
"site_id" => $site_id,
"version" => VERSION,
"home" => (string) get_option("home"),
];
if (get_option("donoharm_searchcue_embed_status") !== $status) {
update_option("donoharm_searchcue_embed_status", $status, false);
}
}
function reject_embed_script(string $reason, string $site_id): string
{
record_embed_render_status($reason, $site_id);
return "\n<!-- SearchCue embed not emitted: " . $reason . ". See SearchCue in the WordPress admin. -->\n";
}
function is_expected_embed_src(string $src, string $canonical): bool
{
// Never interpret backslashes, whitespace, or control characters as URL separators.
if (preg_match('~[\x00-\x20\x7f\\\\]~', $src)) {
return false;
}
$expected = wp_parse_url($canonical);
if (strpos($src, "//") === 0) {
$src = $expected["scheme"] . ":" . $src;
}
$actual = wp_parse_url($src);
if (
!is_array($actual) ||
isset($actual["user"]) ||
isset($actual["pass"]) ||
strcasecmp($actual["host"] ?? "", $expected["host"]) !== 0 ||
($actual["path"] ?? "") !== $expected["path"]
) {
return false;
}
$scheme = strtolower($actual["scheme"] ?? "");
if (!in_array($scheme, ["http", "https"], true)) {
return false;
}
$port = $actual["port"] ?? ($scheme === "https" ? 443 : 80);
$expected_port = $expected["port"] ?? ($expected["scheme"] === "https" ? 443 : 80);
if ($scheme === $expected["scheme"]) {
return $port === $expected_port;
}
// Repair only the ordinary HTTP form of our HTTPS endpoint, never a different port.
return $scheme === "http" && $expected["scheme"] === "https" && $port === 80 && $expected_port === 443;
}
function enqueue_embed(): void
{
if (effective_site_id(get_searchcue_options()) === "") {
record_embed_render_status("missing_site_id", "");
return;
}
wp_enqueue_script(
EMBED_SCRIPT_HANDLE,
searchcue_service_url(EMBED_SCRIPT_URL),
[],
VERSION,
false,
);
}
function render_embed_script(string $tag, string $handle): string
{
if ($handle !== EMBED_SCRIPT_HANDLE) {
return $tag;
}
$site_id = effective_site_id(get_searchcue_options());
if ($site_id === "") {
return reject_embed_script("missing_site_id", $site_id);
}
if (trim($tag) === "") {
return reject_embed_script("removed_tag", $site_id);
}
// Parse only a single empty script tag. Ambiguous markup stays non-executable.
if (!preg_match('~\A\s*<script\b((?:[^>"\']|"[^"]*"|\'[^\']*\')*)>\s*</script>\s*\z~i', $tag, $script)) {
return reject_embed_script("invalid_markup", $site_id);
}
preg_match_all(
'~\s+([a-z_:][a-z0-9_.:-]*)(?:\s*=\s*("[^"]*"|\'[^\']*\'|[^\s"\'`=<>]+))?~i',
$script[1],
$attributes,
PREG_SET_ORDER,
);
if (implode("", array_column($attributes, 0)) !== rtrim($script[1])) {
return reject_embed_script("invalid_markup", $site_id);
}
$options = [
"src" => add_query_arg(["ver" => VERSION], searchcue_service_url(EMBED_SCRIPT_URL)),
"globalName" => "SearchCue",
"siteId" => $site_id,
];
$inline_attributes = [];
$seen = [];
foreach ($attributes as $attribute) {
$name = strtolower($attribute[1]);
if (isset($seen[$name])) {
return reject_embed_script("duplicate_attributes", $site_id);
}
$value = $attribute[2] ?? "";
if ($value !== "" && ($value[0] === '"' || $value[0] === "'")) {
$value = substr($value, 1, -1);
}
$value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, "UTF-8");
$seen[$name] = $value;
if ($name === "integrity") {
$options["integrity"] = $value;
} elseif ($name !== "src" && $name !== "siteid" && $name !== "data-no-minify") {
$inline_attributes[$name] = isset($attribute[2]) ? $value : true;
}
}
$type = strtolower(trim($seen["type"] ?? ""));
$src = $seen["src"] ?? "";
if ($type === "text/rocketlazyloadscript") {
// Rocket must release this inert inline loader itself; never remove its delayed type.
$type = strtolower(trim($seen["data-rocket-type"] ?? ""));
if (isset($seen["src"], $seen["data-rocket-src"]) && $seen["src"] !== $seen["data-rocket-src"]) {
return reject_embed_script("duplicate_attributes", $site_id);
}
$src = $seen["data-rocket-src"] ?? $src;
unset($inline_attributes["data-rocket-src"]);
}
if (!in_array($type, ["", "text/javascript", "application/javascript", "text/ecmascript", "application/ecmascript"], true)) {
return reject_embed_script("blocked_type", $site_id);
}
if ($src === "") {
return reject_embed_script("missing_src", $site_id);
}
if (!is_expected_embed_src($src, $options["src"])) {
return reject_embed_script("unexpected_src", $site_id);
}
$loader = dirname(__DIR__) . "/assets/js/embed-loader.js";
if (!is_file($loader) || !is_readable($loader)) {
return reject_embed_script("loader_unreadable", $site_id);
}
$source = file_get_contents($loader);
if ($source === false || trim($source) === "") {
return reject_embed_script("loader_unreadable", $site_id);
}
$javascript = trim($source) . "(" . wp_json_encode(
$options,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES,
) . ");";
$inline_attributes["data-no-minify"] = "1";
$inline_attributes["siteid"] = $site_id;
record_embed_render_status("emitted", $site_id);
return wp_get_inline_script_tag($javascript, $inline_attributes);
}
function enqueue_admin_assets(string $hook_suffix): void
{
if ($hook_suffix !== "toplevel_page_" . SETTINGS_PAGE) {
return;
}
$asset_url = plugin_dir_url(PLUGIN_FILE) . "assets/";
wp_enqueue_style(ADMIN_ASSET_HANDLE, $asset_url . "css/admin.css", [], VERSION);
wp_enqueue_script(ADMIN_ASSET_HANDLE, $asset_url . "js/admin.js", [], VERSION, true);
wp_localize_script(ADMIN_ASSET_HANDLE, "donoharmSearchCueAdmin", [
"analyticsUnavailable" => __("Search activity is not available right now.", "searchcue"),
"searchVolumeByDay" => __("Search volume by day.", "searchcue"),
"searchSingular" => __("search", "searchcue"),
"searchPlural" => __("searches", "searchcue"),
"diagnosticsCopied" => __("Diagnostics copied.", "searchcue"),
"diagnosticsCopyFailed" => __("Could not copy automatically. Select and copy the diagnostics.", "searchcue"),
]);
} <?php
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
const BLOG_TAXONOMY_MAX_REFERENCES = 100;
const BLOG_TAXONOMY_MAX_BODY_BYTES = 1048576;
function register_blog_taxonomy_lookup_route(): void
{
register_rest_route("searchcue/v1", "/blog-taxonomy/lookup", [
"methods" => "POST",
"callback" => __NAMESPACE__ . "\\lookup_blog_taxonomy",
"permission_callback" => __NAMESPACE__ . "\\authorize_blog_taxonomy_export",
]);
}
function blog_taxonomy_source_url(string $url): bool
{
if (!taxonomy_export_valid_url($url)) {
return false;
}
$source = wp_parse_url(home_url("/"));
$target = wp_parse_url($url);
$source_scheme = strtolower($source["scheme"] ?? "");
$target_scheme = strtolower($target["scheme"] ?? "");
return $target_scheme === $source_scheme &&
strtolower($target["host"]) === strtolower($source["host"] ?? "") &&
($target["port"] ?? ($target_scheme === "https" ? 443 : 80)) ===
($source["port"] ?? ($source_scheme === "https" ? 443 : 80));
}
function lookup_blog_taxonomy(\WP_REST_Request $request): \WP_REST_Response
{
$body = $request->get_body();
if (strlen($body) > BLOG_TAXONOMY_MAX_BODY_BYTES) {
return blog_taxonomy_error_response(
"searchcue_blog_taxonomy_invalid_scope",
"The lookup body must not exceed 1 MiB.",
413
);
}
$scope = json_decode($body);
$references = is_object($scope) ? ($scope->references ?? null) : null;
$valid = json_last_error() === JSON_ERROR_NONE &&
is_object($scope) && count(get_object_vars($scope)) === 1 &&
is_array($references) &&
count($references) >= 1 && count($references) <= BLOG_TAXONOMY_MAX_REFERENCES;
$seen = [];
if ($valid) {
foreach ($references as $reference) {
if (
!is_object($reference) ||
!isset($reference->url) || !is_string($reference->url) ||
!blog_taxonomy_source_url($reference->url) ||
isset($seen[$reference->url]) ||
array_diff(array_keys(get_object_vars($reference)), ["url", "post_id"]) !== [] ||
(property_exists($reference, "post_id") &&
(!is_string($reference->post_id) ||
preg_match('/\A[1-9][0-9]*\z/', $reference->post_id) !== 1))
) {
$valid = false;
break;
}
$seen[$reference->url] = true;
}
}
if (!$valid) {
return blog_taxonomy_error_response(
"searchcue_blog_taxonomy_invalid_scope",
"Supply 1 to 100 unique public source URLs with optional positive decimal post IDs.",
400
);
}
$posts = [];
$unavailable_urls = [];
foreach ($references as $reference) {
// Resolve locally before considering the crawled body's post ID. Never enumerate or fetch URLs.
$post_id = url_to_postid($reference->url);
if ($post_id === 0 && isset($reference->post_id)) {
$post_id = filter_var($reference->post_id, FILTER_VALIDATE_INT, [
"options" => ["min_range" => 1],
]) ?: 0;
}
$post = $post_id > 0 ? get_post($post_id) : null;
$url = $post_id > 0 ? get_permalink($post_id) : false;
if (
!is_object($post) ||
!isset($post->post_type, $post->post_status, $post->post_password) ||
$post->post_type !== "post" ||
$post->post_status !== "publish" ||
(string) $post->post_password !== "" ||
!is_post_publicly_viewable($post) ||
!is_string($url) ||
!blog_taxonomy_source_url($url)
) {
$unavailable_urls[] = $reference->url;
continue;
}
$posts[] = [
"requested_url" => $reference->url,
"id" => (string) $post_id,
"url" => $url,
"source_taxonomy" => taxonomy_export_source_taxonomy_for_object(
$post_id,
"category",
"post_tag"
),
];
}
$response = new \WP_REST_Response([
"posts" => $posts,
"unavailable_urls" => $unavailable_urls,
]);
$response->header("Cache-Control", "private, no-store");
return $response;
}
function blog_taxonomy_error_response(
string $code,
string $message,
int $status
): \WP_REST_Response {
$response = new \WP_REST_Response([
"code" => $code,
"message" => $message,
"data" => ["status" => $status],
], $status);
$response->header("Cache-Control", "private, no-store");
return $response;
} <?php
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
const INDEXNOW_QUEUE_PREFIX = "donoharm_searchcue_indexnow_batch_";
const INDEXNOW_IMAGE_PREFIX = "donoharm_searchcue_image_batch_";
const INDEXNOW_IMAGE_PROGRESS = "donoharm_searchcue_image_progress";
const INDEXNOW_IMAGE_POSTS = 50;
const INDEXNOW_CRON_HOOK = "donoharm_searchcue_deliver_indexnow";
const INDEXNOW_BATCH_URLS = 1000;
const INDEXNOW_BATCH_BYTES = 1048576;
const INDEXNOW_MAX_URL_BYTES = 4194304;
// WordPress can include the plugin inside its activation sandbox rather than global scope.
global $donoharm_searchcue_pending_indexnow_urls, $donoharm_searchcue_pending_images,
$donoharm_searchcue_updates_deactivated, $donoharm_searchcue_saved_posts,
$donoharm_searchcue_changed_meta_posts, $donoharm_searchcue_term_posts;
$donoharm_searchcue_pending_indexnow_urls = [];
$donoharm_searchcue_pending_images = [];
$donoharm_searchcue_updates_deactivated = false;
$donoharm_searchcue_saved_posts = [];
$donoharm_searchcue_changed_meta_posts = [];
$donoharm_searchcue_term_posts = [];
function register_update_hooks(): void
{
add_action("wp_after_insert_post", __NAMESPACE__ . "\\queue_saved_post", 10, 4);
add_action("pre_post_update", __NAMESPACE__ . "\\queue_before_post_update", 10, 2);
add_action("before_delete_post", __NAMESPACE__ . "\\queue_current_post_urls");
add_action("add_attachment", __NAMESPACE__ . "\\queue_attachment_update");
add_action("attachment_updated", __NAMESPACE__ . "\\queue_updated_attachment", 10, 3);
add_action("delete_attachment", __NAMESPACE__ . "\\queue_current_post_urls");
foreach (["update_post_meta", "delete_post_meta"] as $hook) {
add_action($hook, __NAMESPACE__ . "\\queue_before_media_meta_update", 10, 3);
}
foreach (["added_post_meta", "updated_post_meta", "deleted_post_meta"] as $hook) {
add_action($hook, __NAMESPACE__ . "\\queue_post_meta_update", 10, 3);
}
add_action("woocommerce_update_product", __NAMESPACE__ . "\\queue_post_update", 10, 1);
add_action("woocommerce_update_product_variation", __NAMESPACE__ . "\\queue_post_update", 10, 1);
foreach (["woocommerce_product_set_stock", "woocommerce_variation_set_stock"] as $hook) {
add_action($hook, __NAMESPACE__ . "\\queue_product_stock_update", 10, 1);
}
add_action("set_object_terms", __NAMESPACE__ . "\\queue_object_terms_update", 10, 6);
foreach (["add_term_relationship", "delete_term_relationships", "deleted_term_relationships"] as $hook) {
add_action($hook, __NAMESPACE__ . "\\queue_term_relationship_update", 10, 3);
}
foreach (["edit_terms", "pre_delete_term"] as $hook) {
add_action($hook, __NAMESPACE__ . "\\queue_before_term_change_update", 10, 2);
}
add_action("edited_term", __NAMESPACE__ . "\\queue_term_change_update", 10, 3);
add_action("delete_term", __NAMESPACE__ . "\\queue_term_change_update", 10, 3);
add_action("shutdown", __NAMESPACE__ . "\\flush_indexnow_updates");
add_filter("cron_schedules", __NAMESPACE__ . "\\indexnow_cron_schedules");
add_action("init", __NAMESPACE__ . "\\schedule_indexnow_delivery");
foreach (["add_option_", "update_option_", "delete_option_"] as $hook) {
add_action($hook . OPTION_NAME, __NAMESPACE__ . "\\schedule_indexnow_delivery", 10, 0);
}
add_action(INDEXNOW_CRON_HOOK, __NAMESPACE__ . "\\deliver_indexnow_updates");
}
function indexnow_cron_schedules(array $schedules): array
{
$schedules[INDEXNOW_CRON_HOOK] = [
"interval" => 60,
"display" => __("Every minute (SearchCue)", "searchcue"),
];
return $schedules;
}
function schedule_indexnow_delivery(): void
{
global $donoharm_searchcue_updates_deactivated;
if ($donoharm_searchcue_updates_deactivated || !updates_enabled()) {
wp_clear_scheduled_hook(INDEXNOW_CRON_HOOK);
} elseif (!wp_next_scheduled(INDEXNOW_CRON_HOOK)) {
wp_schedule_event(time() + 60, INDEXNOW_CRON_HOOK, INDEXNOW_CRON_HOOK);
}
}
function deactivate_indexnow(bool $network_wide = false): void
{
global $donoharm_searchcue_updates_deactivated;
// Shutdown may still persist this request's changes, but must not restart cron.
$donoharm_searchcue_updates_deactivated = true;
if ($network_wide && is_multisite()) {
foreach (get_sites(["fields" => "ids", "number" => 0]) as $site_id) {
switch_to_blog((int) $site_id);
wp_clear_scheduled_hook(INDEXNOW_CRON_HOOK);
restore_current_blog();
}
} else {
wp_clear_scheduled_hook(INDEXNOW_CRON_HOOK);
}
}
function serve_indexnow_keyfile(): void
{
$request_uri = isset($_SERVER["REQUEST_URI"])
? sanitize_text_field(wp_unslash($_SERVER["REQUEST_URI"]))
: "";
$path = wp_parse_url($request_uri, PHP_URL_PATH);
$path = is_string($path) ? $path : "";
$verification_state = setup_verification_content_for_path($path);
if ($verification_state !== null) {
status_header(200);
header("Content-Type: text/plain; charset=utf-8");
header("Cache-Control: no-store");
echo esc_html(setup_verification_body($verification_state));
if (!defined("DONOHARM_SEARCHCUE_TESTING") || DONOHARM_SEARCHCUE_TESTING !== true) {
exit();
}
return;
}
$content = keyfile_content_for_path($path);
if ($content === null) {
return;
}
status_header(200);
header("Content-Type: text/plain; charset=utf-8");
header("X-Robots-Tag: noindex");
echo esc_html($content);
if (!defined("DONOHARM_SEARCHCUE_TESTING") || DONOHARM_SEARCHCUE_TESTING !== true) {
exit();
}
}
function setup_verification_content_for_path(string $path): ?string
{
$state = get_searchcue_options()["setup_state"];
if ($state === "") {
return null;
}
return rawurldecode($path) === "/" . $state . ".txt" ? $state : null;
}
function setup_verification_body(string $state): string
{
return rtrim(
strtr(
base64_encode(hash("sha256", "searchcue_wordpress_setup_verification:" . $state, true)),
"+/",
"-_",
),
"=",
);
}
function keyfile_content_for_path(string $path): ?string
{
$options = get_searchcue_options();
if ($options["push_updates"] !== "1" || $options["indexnow_key"] === "") {
return null;
}
$expected_path = "/" . $options["indexnow_key"] . ".txt";
return rawurldecode($path) === $expected_path ? $options["indexnow_key"] : null;
}
function post_update_changes_content(array $data, $before): bool
{
foreach ([
"post_title", "post_content", "post_excerpt", "post_content_filtered",
"post_status", "post_password", "post_name", "post_type", "post_parent",
"post_author", "post_date", "post_date_gmt", "post_mime_type", "menu_order",
"comment_status", "ping_status",
] as $field) {
if (array_key_exists($field, $data) && (string) $data[$field] !== (string) ($before->$field ?? "")) {
return true;
}
}
return false;
}
function queue_before_post_update(int $post_id, array $data): void
{
if (!updates_enabled() || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
return;
}
// Core has already loaded this post into its cache before supplying the new data.
$before = get_post($post_id);
if ($before && post_update_changes_content($data, $before)) {
queue_current_post_urls($post_id);
}
}
function queue_saved_post(int $post_id, $post, bool $update, $before): void
{
global $donoharm_searchcue_saved_posts, $donoharm_searchcue_changed_meta_posts;
if (!updates_enabled() || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
return;
}
$post_key = get_current_blog_id() . ":" . $post_id;
$donoharm_searchcue_saved_posts[$post_key] = true;
if (!$update || !$before || post_update_changes_content((array) $post, $before) ||
isset($donoharm_searchcue_changed_meta_posts[$post_key])) {
queue_post_url($post);
}
}
function queue_updated_attachment(int $post_id, $post, $before): void
{
queue_saved_post($post_id, $post, true, $before);
}
function queue_post_update(int $post_id): void
{
if (!updates_enabled() || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
return;
}
$post = get_post($post_id);
if ($post) {
queue_post_url($post);
}
}
function queue_product_stock_update(\WC_Product $product): void
{
queue_post_update($product->get_id());
}
function queue_term_relationship_update($object_id, $term_taxonomy_ids, $taxonomy): void
{
// Real writes capture old permalinks. Direct removals need their own after-hook:
// wp_remove_object_terms() does not fire set_object_terms.
if (in_array($taxonomy, ["category", "post_tag", "product_cat", "product_tag"], true)) {
queue_post_update((int) $object_id);
}
}
/**
* Queues the public URL of an object whose category or tag assignments changed so the
* next partial crawl refreshes its taxonomy.
*
* @param mixed $object_id
* @param mixed $terms
* @param mixed $term_taxonomy_ids
* @param mixed $taxonomy
*/
function queue_object_terms_update($object_id, $terms, $term_taxonomy_ids, $taxonomy, $append = false, $old_term_taxonomy_ids = null): void
{
if (!is_string($taxonomy)) {
return;
}
if (!in_array($taxonomy, ["category", "post_tag", "product_cat", "product_tag"], true)) {
return;
}
// Core also sets the same categories/tags during an unchanged post save.
if (is_array($old_term_taxonomy_ids) &&
!array_diff((array) $term_taxonomy_ids, $old_term_taxonomy_ids) &&
($append || !array_diff($old_term_taxonomy_ids, (array) $term_taxonomy_ids))) {
return;
}
queue_post_update((int) $object_id);
}
function term_change_ids(int $term_id, string $taxonomy): ?array
{
global $wpdb;
$term_ids = [$term_id];
if (!is_taxonomy_hierarchical($taxonomy)) {
return $term_ids;
}
// parent/child_of load the whole hierarchy and bypass SQL LIMIT, even with hierarchical:false.
// ponytail: taxonomies over 100 terms reconcile broadly; a durable term worker can narrow that later.
$parents = get_terms([
"taxonomy" => $taxonomy,
"get" => "all",
"fields" => "id=>parent",
"number" => BLOG_TAXONOMY_MAX_REFERENCES + 1,
"orderby" => "none",
"update_term_meta_cache" => false,
]);
if (!is_array($parents) || $wpdb->last_error !== "" || count($parents) > BLOG_TAXONOMY_MAX_REFERENCES) {
return null;
}
$children = [];
foreach ($parents as $child_id => $parent_id) {
if ((int) $child_id <= 0 || (int) $parent_id < 0) {
return null;
}
$children[(int) $parent_id][] = (int) $child_id;
}
$seen = [$term_id => true];
for ($index = 0; $index < count($term_ids); ++$index) {
foreach ($children[$term_ids[$index]] ?? [] as $child_id) {
if (isset($seen[$child_id])) {
return null;
}
$seen[$child_id] = true;
$term_ids[] = $child_id;
}
}
return $term_ids;
}
function queue_before_term_change_update($term_id, $taxonomy): void
{
global $wpdb, $donoharm_searchcue_term_posts, $donoharm_searchcue_pending_indexnow_urls;
if (!updates_enabled() ||
!in_array($taxonomy, ["category", "post_tag", "product_cat", "product_tag"], true)) {
return;
}
$term_ids = term_change_ids((int) $term_id, $taxonomy);
if ($term_ids === null) {
queue_indexnow_url(home_url("/"));
$donoharm_searchcue_term_posts[get_current_blog_id() . ":" . $taxonomy . ":" . $term_id] = false;
return;
}
// Descendant posts carry this ancestor's label and may include its slug in their permalink.
$query = new \WP_Query([
"post_type" => ["post", "product"],
"post_status" => "publish",
"has_password" => false,
"posts_per_page" => BLOG_TAXONOMY_MAX_REFERENCES + 1,
"fields" => "ids",
"orderby" => "none",
"no_found_rows" => true,
"ignore_sticky_posts" => true,
"update_post_meta_cache" => false,
"update_post_term_cache" => false,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Indexed term selection is capped at 101 IDs, without counts or object hydration.
"tax_query" => [[
"taxonomy" => $taxonomy,
"field" => "term_id",
"terms" => $term_ids,
"include_children" => false,
]],
]);
$post_ids = $query->posts;
if ($wpdb->last_error !== "" || count($post_ids) > BLOG_TAXONOMY_MAX_REFERENCES) {
// ponytail: bound save-time hydration to 100 posts; a durable term worker can replace broad reconciliation.
queue_indexnow_url(home_url("/"));
$donoharm_searchcue_term_posts[get_current_blog_id() . ":" . $taxonomy . ":" . $term_id] = false;
return;
}
_prime_post_caches($post_ids, true, false);
$pending_count = count($donoharm_searchcue_pending_indexnow_urls);
foreach ($post_ids as $post_id) {
queue_post_update((int) $post_id);
}
$key = get_current_blog_id() . ":" . $taxonomy . ":" . $term_id;
$donoharm_searchcue_term_posts[$key] = [
"post_ids" => $post_ids,
"urls" => array_slice($donoharm_searchcue_pending_indexnow_urls, $pending_count),
];
}
function queue_term_change_update($term_id, $term_taxonomy_id, $taxonomy): void
{
global $donoharm_searchcue_term_posts;
$key = get_current_blog_id() . ":" . $taxonomy . ":" . $term_id;
$snapshot = $donoharm_searchcue_term_posts[$key] ?? [];
unset($donoharm_searchcue_term_posts[$key]);
if ($snapshot === false) {
queue_indexnow_url(home_url("/"));
return;
}
// Requeue old URLs even if an earlier flush was acknowledged before the mutation completed.
foreach ($snapshot["urls"] ?? [] as $url) {
queue_indexnow_url($url);
}
// Keep the pre-mutation set: deletion has already removed assignments and reparented children.
foreach ($snapshot["post_ids"] ?? [] as $post_id) {
queue_post_update((int) $post_id);
}
}
function queue_current_post_urls(int $post_id): void
{
global $donoharm_searchcue_pending_images;
if (!updates_enabled()) {
return;
}
$post = get_post($post_id);
if ($post) {
queue_post_url($post);
if (get_post_type($post) !== "attachment" && is_post_publicly_viewable($post)) {
queue_attached_media_urls($post_id);
}
if (isset($donoharm_searchcue_pending_images[$post_id])) {
// Journal old evidence, but retain it and pending URLs for the post-mutation flush.
persist_indexnow_images([$post_id => $donoharm_searchcue_pending_images[$post_id]]);
}
}
}
function queue_post_url($post): void
{
$post_id = is_object($post) && isset($post->ID) ? (int) $post->ID : 0;
if ($post_id > 0 && get_post_type($post) === "attachment") {
queue_attachment_update($post_id);
return;
}
if ($post_id > 0 && get_post_type($post) === "product_variation") {
$post_id = wp_get_post_parent_id($post_id);
$post = $post_id > 0 ? get_post($post_id) : null;
}
if (!$post || $post_id <= 0 || !is_post_publicly_viewable($post) || !empty($post->post_password)) {
return;
}
$url = get_permalink($post);
if (is_string($url) && $url !== "") {
queue_indexnow_url($url);
}
}
function queue_attachment_update(int $post_id): void
{
global $donoharm_searchcue_pending_images;
if (!updates_enabled() || get_post_type($post_id) !== "attachment") {
return;
}
$post = get_post($post_id);
$parent_id = wp_get_post_parent_id($post_id);
$parent = $parent_id > 0 ? get_post($parent_id) : null;
$url = wp_get_attachment_url($post_id);
$path = is_string($url) ? wp_parse_url($url, PHP_URL_PATH) : null;
// The option did not exist before WordPress 6.4, when attachment pages were enabled.
if (get_option("wp_attachment_pages_enabled", true) &&
is_post_publicly_viewable($post) && empty($post->post_password) && empty($parent->post_password)) {
$permalink = get_permalink($post);
$permalink_path = is_string($permalink) ? wp_parse_url($permalink, PHP_URL_PATH) : null;
if (is_string($permalink) && $permalink !== "" &&
(!is_string($path) || !is_string($permalink_path) || rawurldecode($permalink_path) !== rawurldecode($path))) {
queue_indexnow_url($permalink);
}
}
// Core's image helper returns false once deletion removes the attached-file metadata.
if (!wp_attachment_is_image($post_id) && strpos((string) ($post->post_mime_type ?? ""), "image/") !== 0) {
if (is_post_publicly_viewable($post) && empty($post->post_password) && empty($parent->post_password)) {
queue_attachment_url($post_id);
}
return;
}
// Upload provenance does not decide visibility: an image can be reused on public pages.
if ($parent && get_post_type($parent) !== "attachment") {
queue_post_url($parent);
}
$image = $donoharm_searchcue_pending_images[$post_id] ?? ["paths" => [], "parents" => []];
$image["parents"][(int) ($post->post_parent ?? 0)] = true;
$image["version"] = ($image["version"] ?? 0) + 1;
if (is_string($path) && $path !== "") {
$image["paths"][$path] = true;
$metadata = wp_get_attachment_metadata($post_id);
$files = array_merge(
is_array($metadata) ? ($metadata["sizes"] ?? []) : [],
(array) get_post_meta($post_id, "_wp_attachment_backup_sizes", true),
);
if (!empty($metadata["original_image"])) {
$files[] = ["file" => $metadata["original_image"]];
}
foreach ($files as $file) {
if (is_array($file) && !empty($file["file"])) {
$image["paths"][dirname($path) . "/" . $file["file"]] = true;
}
}
}
// Keep old paths too: metadata can change, or the attachment can disappear before shutdown.
$donoharm_searchcue_pending_images[$post_id] = $image;
}
/**
* @param int|string|array $meta_ids
*/
function queue_before_media_meta_update($meta_ids, int $post_id, string $meta_key): void
{
global $donoharm_searchcue_pending_images;
if (!updates_enabled() || in_array($meta_key, ["_edit_lock", "_edit_last"], true)) {
return;
}
if ($post_id <= 0) {
// Bulk metadata deletions supply row IDs only; capture their owners before rows disappear.
foreach ((array) $meta_ids as $meta_id) {
$meta = get_metadata_by_mid("post", (int) $meta_id);
if ($meta) {
queue_post_meta_update($meta_ids, (int) $meta->post_id, $meta_key);
}
}
} elseif (in_array($meta_key, ["_thumbnail_id", "_product_image_gallery"], true)) {
queue_post_update($post_id);
} elseif (in_array($meta_key, [
"_wp_attachment_image_alt",
"_wp_attachment_metadata",
"_wp_attached_file",
"_wp_attachment_backup_sizes",
], true)) {
queue_attachment_update($post_id);
} else {
return;
}
// Journal only this image, not the accumulated bulk request. Shutdown retains old/new
// history and owner URLs even if another cron acknowledges this early snapshot.
if (isset($donoharm_searchcue_pending_images[$post_id])) {
persist_indexnow_images([$post_id => $donoharm_searchcue_pending_images[$post_id]]);
}
}
function queue_post_meta_update($meta_ids, int $post_id, string $meta_key): void
{
global $donoharm_searchcue_saved_posts, $donoharm_searchcue_changed_meta_posts;
if (!updates_enabled() || $post_id <= 0 || in_array($meta_key, ["_edit_lock", "_edit_last"], true)) {
return;
}
if (in_array($meta_key, [
"_thumbnail_id", "_product_image_gallery", "_wp_attachment_image_alt",
"_wp_attachment_metadata", "_wp_attached_file", "_wp_attachment_backup_sizes",
], true)) {
queue_post_update($post_id);
return;
}
// Unknown custom fields count only alongside a core save, in either hook order.
// Frontend counters/cache writes must not turn every crawl into another notification.
$post_key = get_current_blog_id() . ":" . $post_id;
$donoharm_searchcue_changed_meta_posts[$post_key] = true;
if (isset($donoharm_searchcue_saved_posts[$post_key])) {
queue_post_update($post_id);
}
}
function resolve_image_updates(float $deadline): bool
{
global $wpdb, $donoharm_searchcue_updates_deactivated;
$rows_left = 5000;
$pending = null;
// ponytail: bounded full passes, not a reverse-reference index; add one if large-site latency demands it.
for ($pass = 0; $pass < 5 && $rows_left > 0 && microtime(true) < $deadline - 1; ++$pass) {
if (indexnow_delivery_options() === null) {
return false;
}
if ($pending === null) {
// Freeze this request's generations before reading posts. Later arrivals need fresh post caches.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read bounded membership and byte counts, not image payloads.
$candidates = $wpdb->get_results($wpdb->prepare(
"SELECT option_name, OCTET_LENGTH(option_value) AS bytes FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id LIMIT 500",
$wpdb->esc_like(INDEXNOW_IMAGE_PREFIX) . "%",
));
if ($wpdb->last_error !== "") {
return false;
}
$pending = array_column($candidates, "bytes", "option_name");
unset($candidates);
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Only the delivery-lock owner reads or advances this cursor.
$saved = $wpdb->get_var($wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
INDEXNOW_IMAGE_PROGRESS,
));
if ($wpdb->last_error !== "") {
return false;
}
$progress = maybe_unserialize($saved);
if ($saved === null) {
if (!$pending) {
return true;
}
$batches = [];
$bytes = 0;
foreach ($pending as $name => $size) {
if (count($batches) >= 100 || ($batches && $bytes + (int) $size > INDEXNOW_MAX_URL_BYTES)) {
break;
}
// ponytail: one oversized legacy row may exceed 4 MiB; split the journal format if this becomes material.
$batches[] = $name;
$bytes += (int) $size;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The primary-key endpoint bounds a pass despite concurrent inserts.
$last_id = $wpdb->get_var("SELECT ID FROM {$wpdb->posts} ORDER BY ID DESC LIMIT 1");
if ($wpdb->last_error !== "" || microtime(true) >= $deadline - 1) {
return false;
}
$progress = ["batches" => $batches, "cursor" => 0, "last_id" => (int) $last_id];
wp_cache_delete(INDEXNOW_IMAGE_PROGRESS, "options");
wp_cache_delete("notoptions", "options");
if (!add_option(INDEXNOW_IMAGE_PROGRESS, $progress, "", false)) {
return false;
}
$saved = maybe_serialize($progress);
}
foreach ($progress["batches"] as $name) {
unset($pending[$name]);
}
$images = [];
$paths = [];
if ($progress["cursor"] < $progress["last_id"]) {
// Materialise once per pass, not again for each post chunk.
foreach ($progress["batches"] as $name) {
if (microtime(true) >= $deadline - 1 || $donoharm_searchcue_updates_deactivated) {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Evidence remains immutable until every URL and cursor write succeeds.
$batch = maybe_unserialize($wpdb->get_var($wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
$name,
)));
if (!is_array($batch) || $wpdb->last_error !== "") {
return false;
}
foreach ($batch as $id => $image) {
if (microtime(true) >= $deadline - 1) {
return false;
}
if (!isset($images[$id])) {
$images[$id] = ["parents" => $image["parents"]];
} else {
$images[$id]["parents"] += $image["parents"];
}
foreach ($image["paths"] as $path => $unused) {
if (microtime(true) >= $deadline - 1) {
return false;
}
$path = rawurldecode($path);
if ($path !== "") {
$paths[$path] = strlen($path);
}
}
}
unset($batch, $image);
}
}
while ($progress["cursor"] < $progress["last_id"] && $rows_left > 0 && microtime(true) < $deadline - 1) {
if (indexnow_delivery_options() === null) {
return false;
}
// Select by primary key BEFORE inspecting content or metadata, never LIMIT after REGEXP.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bound examined post rows, not merely the number of matching consumers.
$post_ids = $wpdb->get_col($wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND ID <= %d ORDER BY ID LIMIT %d",
$progress["cursor"],
$progress["last_id"],
min(INDEXNOW_IMAGE_POSTS, $rows_left),
));
if ($wpdb->last_error !== "") {
return false;
}
$rows_left -= count($post_ids);
$cursor = $progress["cursor"];
foreach ($post_ids as $post_id) {
if (microtime(true) >= $deadline - 1 || $donoharm_searchcue_updates_deactivated) {
break;
}
$post_id = (int) $post_id;
$post = get_post($post_id);
if ($wpdb->last_error !== "") {
return false;
}
if ($post && !in_array(get_post_type($post), ["attachment", "revision"], true)) {
$references = isset($images[(int) get_post_meta($post_id, "_thumbnail_id", true)]);
if ($wpdb->last_error !== "") {
return false;
}
$gallery = get_post_meta($post_id, "_product_image_gallery", true);
foreach (wp_parse_id_list($gallery) as $image_id) {
$references = $references || isset($images[$image_id]);
}
if ($wpdb->last_error !== "") {
return false;
}
if ($references || (
!in_array($post->post_status, ["trash", "auto-draft"], true) &&
empty($post->post_password) &&
image_content_references(
(string) ($post->post_content ?? "") . "\n" . (string) ($post->post_excerpt ?? ""),
$post_id,
$images,
$paths,
)
)) {
queue_post_url($post);
}
if ($wpdb->last_error !== "") {
return false;
}
}
$cursor = $post_id;
}
if (!$post_ids) {
$cursor = $progress["last_id"];
}
if ($cursor === $progress["cursor"]) {
break;
}
if (indexnow_delivery_options() === null || !flush_indexnow_updates()) {
return false;
}
$progress["cursor"] = $cursor;
$next = maybe_serialize($progress);
// URL durability precedes cursor durability; failure may replay URLs, never skip them.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Advance under the same connection-scoped delivery lock, bypassing option caches.
if ($wpdb->update(
$wpdb->options,
["option_value" => $next],
["option_name" => INDEXNOW_IMAGE_PROGRESS, "option_value" => $saved],
) !== 1) {
return false;
}
$saved = $next;
wp_cache_delete(INDEXNOW_IMAGE_PROGRESS, "options");
}
if ($progress["cursor"] < $progress["last_id"]) {
return true;
}
// A completed cursor survives partial cleanup, so an interrupted delete is retryable.
foreach (array_merge($progress["batches"], [INDEXNOW_IMAGE_PROGRESS]) as $name) {
if (microtime(true) >= $deadline || $donoharm_searchcue_updates_deactivated) {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Acknowledge only this immutable generation after all of its URLs are durable.
if ($wpdb->delete($wpdb->options, ["option_name" => $name], ["%s"]) === false) {
return false;
}
wp_cache_delete($name, "options");
}
wp_cache_delete("notoptions", "options");
}
return true;
}
function image_content_references(
string $content,
int $post_id,
array $images,
array $paths,
array &$seen_blocks = []
): bool {
global $wpdb;
$content = html_entity_decode(str_replace("\\/", "/", $content), ENT_QUOTES | ENT_HTML5, "UTF-8");
$decoded = rawurldecode($content);
preg_match_all("~\\bwp-image-([0-9]+)~", $decoded, $matches);
foreach ($matches[1] as $id) {
if (isset($images[$id])) {
return true;
}
}
foreach ($paths as $path => $length) {
$offset = 0;
while (($offset = strpos($decoded, $path, $offset)) !== false) {
$end = $offset + $length;
if (!isset($decoded[$end]) || strpos(" \t\n\r\v\f\"'<>?#),", $decoded[$end]) !== false) {
return true;
}
++$offset;
}
}
$blocks = parse_blocks($content);
while ($blocks) {
$block = array_pop($blocks);
$name = $block["blockName"] ?? "";
$attrs = $block["attrs"] ?? [];
$ids = [];
if (in_array($name, ["core/image", "core/cover"], true)) {
$ids = [(int) ($attrs["id"] ?? 0)];
} elseif ($name === "core/media-text") {
$ids = [(int) ($attrs["mediaId"] ?? 0)];
} elseif ($name === "core/gallery") {
$ids = wp_parse_id_list($attrs["ids"] ?? []);
} elseif ($name === "core/block") {
$ref = (int) ($attrs["ref"] ?? 0);
$reusable = $ref > 0 && !isset($seen_blocks[$ref]) ? get_post($ref) : null;
if ($wpdb->last_error !== "") {
return false;
}
if ($reusable && get_post_type($reusable) === "wp_block" && $reusable->post_status === "publish" && empty($reusable->post_password)) {
$seen_blocks[$ref] = true;
if (image_content_references((string) $reusable->post_content, $post_id, $images, $paths, $seen_blocks)) {
return true;
}
if ($wpdb->last_error !== "") {
return false;
}
}
}
foreach ($ids as $id) {
if (isset($images[$id])) {
return true;
}
}
foreach ($block["innerBlocks"] ?? [] as $inner) {
$blocks[] = $inner;
}
}
preg_match_all("/" . get_shortcode_regex(["gallery"]) . "/s", $content, $galleries, PREG_SET_ORDER);
foreach ($galleries as $gallery) {
if ($gallery[1] === "[" && $gallery[6] === "]") {
continue;
}
$attrs = shortcode_parse_atts($gallery[3]);
$listed = !empty($attrs["ids"]) ? $attrs["ids"] : ($attrs["include"] ?? []);
$included = $listed ? wp_parse_id_list($listed) : [];
if ($included) {
foreach ($included as $id) {
if (isset($images[$id])) {
return true;
}
}
} else {
$parent = (int) ($attrs["id"] ?? $post_id);
$excluded = wp_parse_id_list($attrs["exclude"] ?? []);
foreach ($images as $id => $image) {
if (isset($image["parents"][$parent]) && !in_array((int) $id, $excluded, true)) {
return true;
}
}
}
}
return false;
}
function queue_attachment_url(int $post_id): void
{
$url = wp_get_attachment_url($post_id);
$parts = is_string($url) ? wp_parse_url($url) : false;
if (
!is_string($url) ||
$url === "" ||
!is_array($parts) ||
!isset($parts["scheme"], $parts["host"]) ||
!in_array(strtolower($parts["scheme"]), ["http", "https"], true) ||
isset($parts["user"]) ||
isset($parts["pass"]) ||
isset($parts["query"]) ||
isset($parts["fragment"])
) {
return;
}
queue_indexnow_url($url);
}
function queue_attached_media_urls(int $post_id): void
{
foreach (get_attached_media("", $post_id) as $attachment) {
// A parent save already queues its page. Only standalone media URLs need a separate purge.
if (is_object($attachment) && isset($attachment->ID) && !wp_attachment_is_image((int) $attachment->ID)) {
queue_attachment_update((int) $attachment->ID);
}
}
}
function queue_indexnow_url(string $url): void
{
global $donoharm_searchcue_pending_indexnow_urls;
if (!updates_enabled() || !same_host_url($url)) {
return;
}
$donoharm_searchcue_pending_indexnow_urls[] = $url;
}
function flush_indexnow_updates(): bool
{
global $donoharm_searchcue_pending_indexnow_urls, $donoharm_searchcue_pending_images;
if (!updates_enabled()) {
wp_clear_scheduled_hook(INDEXNOW_CRON_HOOK);
$donoharm_searchcue_pending_indexnow_urls = [];
$donoharm_searchcue_pending_images = [];
return false;
}
schedule_indexnow_delivery();
$images = $donoharm_searchcue_pending_images;
if (!persist_indexnow_images($images)) {
return false;
}
$pending_count = count($donoharm_searchcue_pending_indexnow_urls);
$batch = [];
$bytes = 0;
foreach (array_unique($donoharm_searchcue_pending_indexnow_urls) as $url) {
$length = strlen($url);
if ($length > INDEXNOW_MAX_URL_BYTES) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Report a permanent admission failure without disclosing URL contents.
error_log("SearchCue rejected an IndexNow URL of " . $length . " bytes: the service limit is 4194304 bytes.");
continue;
}
if ($batch && (count($batch) >= INDEXNOW_BATCH_URLS || $bytes + $length > INDEXNOW_BATCH_BYTES)) {
if (!persist_indexnow_batch($batch)) {
return false;
}
$batch = [];
$bytes = 0;
}
// A URL above the normal byte target travels alone, up to the service's hard limit.
$batch[] = $url;
$bytes += $length;
}
if ($batch && !persist_indexnow_batch($batch)) {
return false;
}
// Clear only after every batch is durable; a partial write may retry duplicates, never lose URLs.
$donoharm_searchcue_pending_indexnow_urls = array_slice($donoharm_searchcue_pending_indexnow_urls, $pending_count);
foreach ($images as $id => $image) {
if (($donoharm_searchcue_pending_images[$id] ?? null) === $image) {
unset($donoharm_searchcue_pending_images[$id]);
}
}
return true;
}
function persist_indexnow_images(array $images): bool
{
if (!$images || add_option(INDEXNOW_IMAGE_PREFIX . wp_generate_uuid4(), $images, "", false)) {
return true;
}
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Retain old image evidence for a retry if the database rejects a snapshot.
error_log("SearchCue could not persist image changes; pending updates were retained for this request.");
return false;
}
function persist_indexnow_batch(array $urls): bool
{
if (add_option(INDEXNOW_QUEUE_PREFIX . wp_generate_uuid4(), $urls, "", false)) {
return true;
}
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- A database write failure must not silently discard notifications.
error_log("SearchCue could not persist an IndexNow batch; pending updates were retained for this request.");
return false;
}
function indexnow_delivery_options(): ?array
{
global $wpdb, $donoharm_searchcue_updates_deactivated;
if ($donoharm_searchcue_updates_deactivated) {
return null;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Recheck persisted consent and keys, not this worker's stale options cache.
$stored = maybe_unserialize($wpdb->get_var($wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
OPTION_NAME,
)));
$options = normalize_options(is_array($stored) ? $stored : []);
return $wpdb->last_error === "" && updates_enabled($options) ? $options : null;
}
function deliver_indexnow_batches(float $deadline, string $host, int &$requests, array &$attempted): bool
{
global $wpdb, $donoharm_searchcue_updates_deactivated;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bound immutable row membership before loading payloads, bypassing stale option caches.
$batches = $wpdb->get_col($wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id LIMIT 100",
$wpdb->esc_like(INDEXNOW_QUEUE_PREFIX) . "%",
));
if ($wpdb->last_error !== "") {
return false;
}
$index = 0;
$next_urls = null;
while ($index < count($batches) && $requests < 20) {
$names = [];
$urls = [];
$bytes = 0;
while ($index < count($batches)) {
if ($donoharm_searchcue_updates_deactivated) {
return false;
}
// Send the complete prefix while a consent check and bounded HTTP request still fit.
if (microtime(true) >= $deadline - ($names ? 5 : 1)) {
break;
}
$name = $batches[$index];
if (isset($attempted[$name])) {
++$index;
continue;
}
if ($next_urls === null) {
// Load one bounded producer payload at a time, including the single-URL 4 MiB allowance.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Do not cache immutable evidence between invocations.
$value = $wpdb->get_var($wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
$name,
));
if ($wpdb->last_error !== "") {
return false;
}
if ($value === null) {
++$index;
continue;
}
$next_urls = array_fill_keys(maybe_unserialize($value), true);
unset($value);
}
$additional = array_diff_key($next_urls, $urls);
$additional_bytes = array_sum(array_map("strlen", array_keys($additional)));
if ($names && $additional && (
count($urls) + count($additional) > INDEXNOW_BATCH_URLS ||
$bytes + $additional_bytes > INDEXNOW_BATCH_BYTES
)) {
break;
}
// Never take only part of a row: its acknowledgement must cover every original URL.
$urls += $additional;
$bytes += $additional_bytes;
$names[] = $name;
$next_urls = null;
++$index;
}
if (!$names) {
break;
}
$options = indexnow_delivery_options();
if ($options === null) {
return false;
}
$headers = ["Content-Type" => "application/json"];
if ($options["api_key"] !== "") {
$headers["Authorization"] = "Bearer " . $options["api_key"];
}
$body = wp_json_encode([
"host" => $host,
"key" => $options["indexnow_key"],
"keyLocation" => indexnow_key_location($options["indexnow_key"]),
"urlList" => array_keys($urls),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$remaining = $deadline - microtime(true);
if ($body === false || $remaining < 1) {
return false;
}
++$requests;
$response = wp_remote_post(searchcue_service_url(INDEXNOW_ENDPOINT), [
"user-agent" => CRAWLER_USER_AGENT,
"timeout" => min(5, $remaining),
"blocking" => true,
"redirection" => 0,
"headers" => $headers,
"body" => $body,
]);
foreach ($names as $name) {
$attempted[$name] = true;
}
$status = is_wp_error($response) ? 0 : wp_remote_retrieve_response_code($response);
if ($status === 0 || in_array($status, [401, 403, 408, 425, 429], true) || $status >= 500) {
return false;
}
if ($status === 200) {
// At most 100 deletes, including after a slow acknowledgement. A failed cleanup remains retryable.
foreach ($names as $name) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Delete whole acknowledged rows only, never newer arrivals with matching URLs.
if ($wpdb->delete($wpdb->options, ["option_name" => $name], ["%s"]) === false) {
return false;
}
wp_cache_delete($name, "options");
}
wp_cache_delete("notoptions", "options");
}
}
return true;
}
function deliver_indexnow_updates(): void
{
global $wpdb, $donoharm_searchcue_updates_deactivated;
static $running = false;
if ($running || $donoharm_searchcue_updates_deactivated) {
return;
}
if (!updates_enabled()) {
wp_clear_scheduled_hook(INDEXNOW_CRON_HOOK);
return;
}
$host = home_host();
if ($host === null) {
return;
}
// MySQL owns this connection-scoped lock; no expired lease can delete another worker's lock.
$lock = "searchcue_indexnow_" . md5(DB_NAME . ":" . $wpdb->prefix);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Connection-scoped worker exclusion must bypass option caches.
if ((string) $wpdb->get_var($wpdb->prepare("SELECT GET_LOCK(%s, 0)", $lock)) !== "1") {
return;
}
$running = true;
try {
$deadline = microtime(true) + 20;
// Ordinary delivery goes first; reserve time for scanning and same-run delivery of its output.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Check whether this invocation must reserve time for deferred references.
$images_pending = $wpdb->get_var($wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name = %s LIMIT 1",
$wpdb->esc_like(INDEXNOW_IMAGE_PREFIX) . "%",
INDEXNOW_IMAGE_PROGRESS,
));
$requests = 0;
$attempted = [];
if (!deliver_indexnow_batches($deadline - ($images_pending === null ? 0 : 10), $host, $requests, $attempted)) {
return;
}
if ($images_pending !== null && resolve_image_updates($deadline - 5)) {
deliver_indexnow_batches($deadline, $host, $requests, $attempted);
}
} finally {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release only this connection's own lock.
$wpdb->get_var($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock));
$running = false;
}
}
function updates_enabled(?array $options = null): bool
{
$options = $options ?? get_searchcue_options();
return effective_site_id($options) !== "" &&
$options["push_updates"] === "1" &&
$options["indexnow_key"] !== "";
}
function same_host_url(string $url): bool
{
$home_host = home_host();
$url_host = wp_parse_url($url, PHP_URL_HOST);
return is_string($home_host) &&
is_string($url_host) &&
strcasecmp($home_host, $url_host) === 0;
}
function home_host(): ?string
{
$host = wp_parse_url(home_url("/"), PHP_URL_HOST);
return is_string($host) && $host !== "" ? $host : null;
}
function indexnow_key_location(string $key): string
{
return (string) preg_replace("/^http:/i", "https:", home_url("/" . $key . ".txt"));
} <?php
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
function activate(bool $network_wide = false): void
{
add_option(OPTION_NAME, default_options(), "", false);
if (!$network_wide && effective_site_id(get_searchcue_options()) === "") {
add_option(ACTIVATION_REDIRECT_OPTION, "1", "", false);
} else {
delete_option(ACTIVATION_REDIRECT_OPTION);
}
}
function maybe_redirect_after_activation(): void
{
if (
get_option(ACTIVATION_REDIRECT_OPTION) === false ||
(defined("WP_CLI") && WP_CLI === true) ||
wp_doing_ajax() ||
is_network_admin() ||
!current_user_can("manage_options") ||
(isset($_SERVER["REQUEST_METHOD"]) &&
sanitize_key(wp_unslash($_SERVER["REQUEST_METHOD"])) !== "get")
) {
return;
}
delete_option(ACTIVATION_REDIRECT_OPTION);
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- These read-only markers only suppress a redirect.
$page = isset($_GET["page"])
? sanitize_key(sanitize_text_field(wp_unslash($_GET["page"])))
: "";
if (isset($_GET["activate-multi"]) || $page === SETTINGS_PAGE) {
return;
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
redirect_to_settings([]);
}
/**
* @return array{site_id: string, assigned_site_id: string, api_key: string, connected_source_url: string, setup_state: string, reported_source_features: array<int, string>, push_updates: string, indexnow_key: string}
*/
function default_options(): array
{
return [
"site_id" => "",
"assigned_site_id" => "",
"api_key" => "",
"connected_source_url" => "",
"setup_state" => generate_setup_state(),
"reported_source_features" => [],
"push_updates" => "1",
"indexnow_key" => generate_indexnow_key(),
];
}
function supported_source_features(): array
{
return ["product_search", "product_export_scoped", "blog_taxonomy", "blog_taxonomy_scoped"];
}
function source_features(): array
{
$woocommerce = "woocommerce/woocommerce.php";
$site_plugins = get_option("active_plugins", []);
$network_plugins = is_multisite()
? get_site_option("active_sitewide_plugins", [])
: [];
$site_plugins = is_array($site_plugins) ? $site_plugins : [];
$network_plugins = is_array($network_plugins) ? $network_plugins : [];
$woocommerce_active = in_array($woocommerce, $site_plugins, true) ||
array_key_exists($woocommerce, $network_plugins);
return $woocommerce_active
? supported_source_features()
: ["blog_taxonomy", "blog_taxonomy_scoped"];
}
/**
* @param mixed $features
*
* @return array<int, string>
*/
function normalize_source_features($features): array
{
if (!is_array($features)) {
return [];
}
$normalized = [];
foreach (supported_source_features() as $feature) {
if (in_array($feature, $features, true)) {
$normalized[] = $feature;
}
}
return $normalized;
}
/**
* @param mixed $url
*/
function normalize_source_url($url): string
{
return is_string($url) ? rtrim(esc_url_raw($url), "/") : "";
}
/**
* @return array{site_id: string, assigned_site_id: string, api_key: string, connected_source_url: string, setup_state: string, reported_source_features: array<int, string>, push_updates: string, indexnow_key: string}
*/
function get_searchcue_options(): array
{
$stored_options = get_option(OPTION_NAME);
return is_array($stored_options) ? normalize_options($stored_options) : default_options();
}
/**
* @return array{site_id: string, assigned_site_id: string, api_key: string, connected_source_url: string, setup_state: string, reported_source_features: array<int, string>, push_updates: string, indexnow_key: string}
*/
function normalize_options(array $input): array
{
return [
"site_id" => sanitize_identifier($input["site_id"] ?? ""),
"assigned_site_id" => sanitize_identifier($input["assigned_site_id"] ?? ""),
"api_key" => sanitize_identifier($input["api_key"] ?? ""),
"connected_source_url" => normalize_source_url($input["connected_source_url"] ?? ""),
"setup_state" => sanitize_identifier($input["setup_state"] ?? ""),
"reported_source_features" => normalize_source_features(
$input["reported_source_features"] ?? [],
),
"push_updates" => isset($input["push_updates"]) && $input["push_updates"] === "1" ? "1" : "0",
"indexnow_key" => sanitize_identifier($input["indexnow_key"] ?? ""),
];
}
/**
* Sanitizes the complete option array registered with WordPress.
*
* @param mixed $input
*
* @return array{site_id: string, assigned_site_id: string, api_key: string, connected_source_url: string, setup_state: string, reported_source_features: array<int, string>, push_updates: string, indexnow_key: string}
*/
function sanitize_options($input): array
{
if (!is_array($input)) {
$input = get_searchcue_options();
}
$stored_options = get_option(OPTION_NAME, []);
$stored_options = normalize_options(is_array($stored_options) ? $stored_options : []);
$input["reported_source_features"] = $stored_options["reported_source_features"];
$input["connected_source_url"] =
isset($input["connected_source_url"]) && is_string($input["connected_source_url"])
? normalize_source_url($input["connected_source_url"])
: $stored_options["connected_source_url"];
$input["assigned_site_id"] = sanitize_hidden_option($input, "assigned_site_id");
$input["api_key"] = sanitize_hidden_option($input, "api_key");
$input["setup_state"] = sanitize_hidden_option($input, "setup_state");
$input["indexnow_key"] = sanitize_hidden_option($input, "indexnow_key");
$options = normalize_options($input);
if (
$options["api_key"] !== $stored_options["api_key"] ||
$options["assigned_site_id"] !== $stored_options["assigned_site_id"] ||
$options["connected_source_url"] !== $stored_options["connected_source_url"]
) {
$options["reported_source_features"] = [];
}
if ($options["indexnow_key"] === "") {
$options["indexnow_key"] = generate_indexnow_key();
}
if ($options["setup_state"] === "") {
$options["setup_state"] = generate_setup_state();
}
return $options;
}
function sanitize_hidden_option(array $input, string $key): string
{
if (isset($input[$key]) && is_string($input[$key])) {
return sanitize_identifier($input[$key]);
}
$stored_options = get_option(OPTION_NAME, []);
$stored_value = is_array($stored_options) ? $stored_options[$key] ?? "" : "";
return sanitize_identifier($stored_value);
}
/**
* @param mixed $value
*/
function sanitize_identifier($value): string
{
if (!is_string($value)) {
return "";
}
$value = sanitize_text_field($value);
return preg_replace("/[^A-Za-z0-9_-]/", "", $value) ?? "";
}
function effective_site_id(array $options): string
{
return $options["site_id"] !== "" ? $options["site_id"] : $options["assigned_site_id"];
}
function generate_indexnow_key(): string
{
return wp_generate_password(48, false, false);
}
function generate_setup_state(): string
{
return wp_generate_password(32, false, false);
} <?php
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
const PRODUCT_TRANSLATION_MAX_IDS = 20;
const PRODUCT_TRANSLATION_MAX_BYTES = 200000;
function product_export_translations(): ?array
{
$plugin = "gtranslate/gtranslate.php";
$site_plugins = get_option("active_plugins", []);
$network_plugins = is_multisite() ? get_site_option("active_sitewide_plugins", []) : [];
if (
!(is_array($site_plugins) && in_array($plugin, $site_plugins, true)) &&
!(is_array($network_plugins) && array_key_exists($plugin, $network_plugins))
) {
return null;
}
$settings = get_option("GTranslate");
if (
!is_array($settings) ||
!in_array($settings["pro_version"] ?? null, [1, "1", true], true) ||
!in_array($settings["enterprise_version"] ?? "", ["", 0, "0", false], true) ||
!in_array($settings["custom_domains"] ?? "", ["", 0, "0", false], true) ||
!class_exists("GTranslate", false)
) {
return null;
}
// Use the active provider's language inventory, never its default selected lists.
$languages = get_class_vars("GTranslate")["lang_array"] ?? null;
$source = $settings["default_language"] ?? null;
$layout = $settings["widget_look"] ?? "float";
$flag_layouts = [
"flags", "float", "dropdown_with_flags", "flags_name", "flags_code", "popup", "popup_search",
];
if (
!is_array($languages) ||
!is_string($source) ||
!isset($languages[$source]) ||
preg_match('/\A[a-z]{2,3}(?:-[A-Z]{2})?\z/', $source) !== 1 ||
!in_array($layout, array_merge($flag_layouts, [
"flags_dropdown", "dropdown", "globe", "lang_names", "lang_codes",
]), true)
) {
return null;
}
$keys = $layout === "flags_dropdown"
? ["fincl_langs", "incl_langs"]
: [in_array($layout, $flag_layouts, true) ? "fincl_langs" : "incl_langs"];
$selected = [];
foreach ($keys as $key) {
if (!isset($settings[$key]) || !is_array($settings[$key])) {
return null;
}
foreach ($settings[$key] as $language) {
if (
!is_string($language) ||
!isset($languages[$language]) ||
preg_match('/\A[a-z]{2,3}(?:-[A-Z]{2})?\z/', $language) !== 1
) {
return null;
}
if ($language !== $source) {
$selected[$language] = true;
}
}
}
$home = home_url("/");
$path = wp_parse_url($home, PHP_URL_PATH);
if (
$selected === [] ||
!taxonomy_export_valid_url($home) ||
wp_parse_url($home, PHP_URL_QUERY) !== null ||
wp_parse_url($home, PHP_URL_FRAGMENT) !== null ||
!is_string($path) ||
preg_match('/[\x00-\x20\x7f\\\\]/', $home) === 1 ||
preg_match('~(?:\A|/)\.{1,2}(?:/|\z)|//|[\\\\\x00-\x20\x7f]~', rawurldecode($path)) === 1 ||
preg_match('/%(?:2f|5c|25)/i', $path) === 1
) {
return null;
}
ksort($selected, SORT_STRING);
$targets = [];
foreach (array_keys($selected) as $language) {
$targets[$language] = rtrim($home, "/") . "/" . $language . "/";
}
return ["provider" => "gtranslate", "source_language" => $source, "targets" => $targets];
}
function product_export_secret(string $api_key): string
{
return taxonomy_export_secret(TAXONOMY_EXPORT_PRODUCT_CONTEXT, $api_key);
}
function register_product_export_route(): void
{
register_rest_route("searchcue/v1", "/products", [
"methods" => "GET",
"callback" => __NAMESPACE__ . "\\export_products",
"permission_callback" => __NAMESPACE__ . "\\authorize_product_export",
]);
}
function authorize_product_export(\WP_REST_Request $request): bool
{
return authorize_taxonomy_export($request, TAXONOMY_EXPORT_PRODUCT_CONTEXT);
}
function export_products(\WP_REST_Request $request): \WP_REST_Response
{
global $wpdb;
$translations = product_export_translations();
$translate = $request->has_param("translate");
$include = null;
$selection = null;
if ($request->has_param("include")) {
$include = product_export_include($request->get_param("include"));
if ($include === null) {
return product_export_error("invalid_include", __("Include must contain 1 to 20 unique positive product IDs.", "searchcue"));
}
if (!$translate) {
$selection = ["include" => $request->get_param("include")];
}
}
if ($request->has_param("url")) {
$url = $request->get_param("url");
if (
$translate || $include !== null || !is_string($url) || strlen($url) > 8192 ||
!blog_taxonomy_source_url($url)
) {
return product_export_error("invalid_product_url", __("Supply one source URL without include or translate.", "searchcue"));
}
$selection = ["url" => $url];
$include = [];
if (function_exists("wc_get_product")) {
// Resolve explicit WooCommerce selectors before WordPress can match a static homepage.
parse_str((string) wp_parse_url($url, PHP_URL_QUERY), $query);
$slug = is_string($query["product"] ?? null) ? sanitize_title_for_query($query["product"]) : "";
$wpdb->last_error = "";
if ($slug !== "") {
$lookup = new \WP_Query([
"name" => $slug,
"post_type" => "product",
"post_status" => "publish",
"has_password" => false,
"posts_per_page" => 1,
"fields" => "ids",
"no_found_rows" => true,
"ignore_sticky_posts" => true,
"update_post_meta_cache" => false,
"update_post_term_cache" => false,
]);
$id = (int) ($lookup->posts[0] ?? 0);
} else {
$id = url_to_postid($url);
}
if ($wpdb->last_error !== "") {
return product_export_error("product_export_failed", __("The product query could not be completed.", "searchcue"), 503);
}
$product = $id > 0 ? wc_get_product($id) : false;
if ($wpdb->last_error !== "") {
return product_export_error("product_export_failed", __("The product query could not be completed.", "searchcue"), 503);
}
if ($product instanceof \WC_Product) {
$include = [$product->get_parent_id() ?: $product->get_id()];
}
}
}
if (
($translate && (!in_array($request->get_param("translate"), [1, "1"], true) || $include === null)) ||
(($translate || $include !== null) && $request->has_param("page") &&
!in_array($request->get_param("page"), [1, "1"], true))
) {
return product_export_error("invalid_translation_request", __("Bounded product exports require explicit IDs and page 1.", "searchcue"));
}
if ($translate && $translations === null) {
return product_export_error("translation_unavailable", __("Server-side subdirectory translation is not configured.", "searchcue"));
}
$max_bytes = null;
if ($translate) {
$value = $request->get_param("max_bytes");
$max_bytes = (is_int($value) || is_string($value))
? filter_var($value, FILTER_VALIDATE_INT, [
"options" => ["min_range" => 1, "max_range" => PRODUCT_TRANSLATION_MAX_BYTES],
])
: false;
if ($max_bytes === false) {
return product_export_error("invalid_translation_budget", __("Translation requests require a byte allowance between 1 and 200000.", "searchcue"));
}
}
$page = min(max(1, (int) $request->get_param("page")), PRODUCT_EXPORT_MAX_PAGES);
$page_size = $include === null ? PRODUCT_EXPORT_PAGE_SIZE : null;
if (!function_exists("wc_get_products") || $include === []) {
return product_export_response([], $page, 0, $translations, $max_bytes, $page_size, $selection);
}
$args = [
"status" => "publish",
"limit" => $include === null ? PRODUCT_EXPORT_PAGE_SIZE : count($include),
"page" => $page,
"paginate" => true,
"orderby" => "ID",
"order" => "ASC",
];
if ($include !== null) {
$args["include"] = $include;
}
$wpdb->last_error = "";
$query = wc_get_products($args);
if (
$wpdb->last_error !== "" ||
!is_object($query) ||
!isset($query->products, $query->max_num_pages) ||
!is_array($query->products) ||
filter_var($query->max_num_pages, FILTER_VALIDATE_INT) === false ||
(int) $query->max_num_pages < 0 ||
(!$translate && $include !== null && (int) $query->max_num_pages > 1)
) {
return product_export_error("product_export_failed", __("The product query could not be completed.", "searchcue"), 503);
}
$products = [];
$seen = [];
foreach ($query->products as $product) {
if (
!($product instanceof \WC_Product) ||
(!$translate && $include !== null && !in_array($product->get_id(), $include, true))
) {
return product_export_error("product_export_failed", __("The product query returned an invalid record.", "searchcue"), 503);
}
if (
$product->get_parent_id() !== 0 ||
!product_export_public($product) ||
($include !== null && !in_array($product->get_id(), $include, true)) ||
isset($seen[$product->get_id()])
) {
continue;
}
$seen[$product->get_id()] = true;
$records = product_export_records($product, $translations !== null, $translate);
if ($records === null) {
return $translate
? product_export_error("translation_too_large", __("The selected product translation exceeds the response limit.", "searchcue"), 413)
: product_export_error("product_export_failed", __("The selected product could not be exported completely.", "searchcue"), 503);
}
$products = array_merge($products, $records);
}
$total_pages = $include === null ? max(0, (int) $query->max_num_pages) : ($products === [] ? 0 : 1);
return product_export_response($products, $page, $total_pages, $translations, $max_bytes, $page_size, $selection);
}
/**
* @param mixed $value
*/
function product_export_include($value): ?array
{
if (
!is_string($value) ||
strlen($value) > PRODUCT_TRANSLATION_MAX_IDS * (strlen((string) PHP_INT_MAX) + 1) - 1 ||
preg_match('/\A[1-9][0-9]*(?:,[1-9][0-9]*)*\z/', $value) !== 1
) {
return null;
}
$parts = explode(",", $value);
if (count($parts) > PRODUCT_TRANSLATION_MAX_IDS) {
return null;
}
$ids = [];
foreach ($parts as $part) {
$id = (int) $part;
if ((string) $id !== $part || in_array($id, $ids, true)) {
return null;
}
$ids[] = $id;
}
return $ids;
}
function product_export_public(\WC_Product $product): bool
{
return $product->get_status() === "publish" &&
$product->is_visible() &&
(string) get_post_field("post_password", $product->get_id()) === "" &&
!post_password_required($product->get_id());
}
function product_export_error(string $code, string $message, int $status = 400): \WP_REST_Response
{
$response = new \WP_REST_Response(["code" => $code, "message" => $message], $status);
$response->header("Cache-Control", "private, no-store");
return $response;
}
function product_export_response(
array $products,
int $page,
int $total_pages,
?array $translations,
?int $max_bytes = null,
?int $page_size = null,
?array $selection = null
): \WP_REST_Response {
$data = [
"products" => $products,
"page" => $page,
"total_pages" => $total_pages,
"translations" => $translations,
];
if ($page_size !== null) {
$data["page_size"] = $page_size;
}
if ($selection !== null) {
$data["selection"] = $selection;
}
if ($max_bytes !== null) {
$json = wp_json_encode($data);
if (!is_string($json) || strlen($json) > $max_bytes) {
return product_export_error("translation_too_large", __("The selected product translation exceeds the response limit.", "searchcue"), 413);
}
}
$response = new \WP_REST_Response($data);
$response->header("Cache-Control", "private, no-store");
return $response;
}
function product_export_records(
\WC_Product $parent,
bool $with_translations,
bool $translate
): ?array {
$url = get_permalink($parent->get_id());
if (!is_string($url) || $url === "") {
return null;
}
$direct_terms = taxonomy_export_direct_terms($parent->get_id(), "product_cat", "product_tag");
$name = product_export_text($parent->get_name());
$tags = product_export_tag_names($direct_terms);
$source_taxonomy = taxonomy_export_source_taxonomy($direct_terms, "product_cat", "product_tag");
$records = [];
$bytes = 0;
if ($parent instanceof \WC_Product_Variable) {
$child_ids = array_values(array_unique($parent->get_visible_children()));
// ponytail: fail safely above this ceiling; add cursor pagination if real catalogs exceed it.
if (count($child_ids) > PRODUCT_EXPORT_MAX_VARIATIONS) {
return null;
}
_prime_post_caches($child_ids, true, true);
foreach ($child_ids as $child_id) {
$variation = wc_get_product((int) $child_id);
if (
$variation instanceof \WC_Product &&
$variation->get_parent_id() === $parent->get_id() &&
product_export_public($variation)
) {
$record = product_export_record(
$parent, $variation, $url, $name, $tags, $source_taxonomy, $with_translations, $translate
);
if ($translate) {
$bytes += strlen((string) wp_json_encode($record)) + 1;
if ($bytes > PRODUCT_TRANSLATION_MAX_BYTES) {
return null;
}
}
$records[] = $record;
}
}
}
return $records === []
? [product_export_record($parent, $parent, $url, $name, $tags, $source_taxonomy, $with_translations, $translate)]
: $records;
}
function product_export_record(
\WC_Product $parent,
\WC_Product $product,
string $url,
string $name,
array $tags,
array $source_taxonomy,
bool $with_translations,
bool $translate
): array {
$descriptions = product_export_descriptions($product, $parent);
$description = product_export_text($descriptions["description"]);
$short_description = product_export_text($descriptions["short_description"]);
$attribute_content = product_export_attribute_content($product, $parent);
$attributes = [];
foreach ($attribute_content as $attribute) {
$attributes[$attribute["name"]][] = $attribute["value"];
}
foreach ($attributes as $label => $values) {
$attributes[$label] = implode(", ", array_unique($values));
}
$image = product_export_image($product, $parent);
$blocks = $short_description === "" ? [] : [["text" => $short_description, "role" => "paragraph"]];
if (isset($image["alt"]) && !in_array($image["alt"], [$description, $short_description], true)) {
$blocks[] = ["text" => $image["alt"], "role" => "paragraph"];
}
$translation = [];
if ($with_translations) {
$content = product_export_translation_content(
$name, $descriptions, $tags, $attribute_content, $source_taxonomy, $image, $url
);
$translation = [
"id" => (string) $product->get_id(),
"product_id" => (string) $parent->get_id(),
"translation_hash" => hash("sha256", (string) wp_json_encode($content)),
"translation_content" => $content,
];
if ($translate) {
return $translation;
}
}
$price = product_export_price($product, $parent);
$sku = product_export_text($product->get_sku());
if ($sku === "" && $product !== $parent) {
$sku = product_export_text($parent->get_sku());
}
return [
"id" => (string) $product->get_id(),
"product_id" => (string) $parent->get_id(),
"sku" => $sku,
"name" => $name,
"description" => $description,
"blocks" => $blocks,
"store" => "",
"url" => $url,
"tags" => $tags,
"source_taxonomy" => $source_taxonomy,
"attributes" => $attributes,
"on_sale" => $price["onSale"],
"in_stock" => $product->is_in_stock(),
"stock" => product_export_stock($product),
"current_price" => $price[$price["onSale"] ? "salePrice" : "basePrice"],
"image" => $image,
"price" => $price,
"created_at" => product_export_date($product, $parent),
] + $translation;
}
function product_export_stock(\WC_Product $product): array
{
// WooCommerce returns "parent" for inherited stock and false when global management is off.
$managed = in_array($product->managing_stock(), [true, "parent"], true);
$quantity = $managed ? $product->get_stock_quantity() : null;
return [
"manage_stock" => $managed,
"stock_quantity" => is_int($quantity) || (is_float($quantity) && is_finite($quantity)) ? $quantity : null,
"stock_status" => $product->get_stock_status(),
"backorders" => $product->get_backorders(),
"stock_managed_by_id" => $managed ? (string) $product->get_stock_managed_by_id() : null,
];
}
function product_export_translation_content(
string $name,
array $descriptions,
array $tags,
array $attributes,
array $source_taxonomy,
?array $image,
string $url
): array {
sort($tags, SORT_STRING);
usort($attributes, static function (array $left, array $right): int {
return strcmp($left["name"], $right["name"]) ?: strcmp($left["value"], $right["value"]);
});
$terms = [];
foreach (["categories", "tags"] as $section) {
$terms[$section] = [];
foreach ($source_taxonomy[$section]["terms"] as $term) {
$terms[$section][] = ["id" => $term["id"], "label" => $term["label"], "url" => $term["url"]];
}
}
return [
"name" => $name,
"description" => $descriptions["description"],
"short_description" => $descriptions["short_description"],
"tags" => $tags,
"attributes" => $attributes,
"categories" => $terms["categories"],
"tag_terms" => $terms["tags"],
"image_alt" => $image["alt"] ?? "",
"url" => $url,
];
}
function product_export_price(\WC_Product $product, \WC_Product $parent): array
{
$regular_price = (string) $product->get_regular_price();
$current_price = (string) $product->get_price();
$price_source = $product;
if ($product !== $parent && $regular_price === "" && $current_price === "") {
$regular_price = (string) $parent->get_regular_price();
$current_price = (string) $parent->get_price();
$price_source = $parent;
}
$base_price = $regular_price !== "" ? $regular_price : $current_price;
$on_sale = $price_source->is_on_sale();
$sale_price = $on_sale ? $current_price : $base_price;
$currency = (string) get_woocommerce_currency();
return [
"basePrice" => ["currency" => $currency, "value" => $base_price],
"salePrice" => ["currency" => $currency, "value" => $sale_price],
"onSale" => $on_sale,
];
}
function product_export_attribute_content(\WC_Product $product, \WC_Product $parent): array
{
$parent_attributes = $parent->get_attributes();
$visible_names = [];
$hidden_names = [];
foreach ($parent_attributes as $attribute_name => $attribute) {
$attribute_name = (string) $attribute_name;
if ($attribute instanceof \WC_Product_Attribute && !$attribute->get_visible()) {
$hidden_names[] = $attribute_name;
} else {
$visible_names[] = $attribute_name;
}
}
$attribute_names = $visible_names;
if ($product !== $parent) {
foreach (array_keys($product->get_attributes()) as $attribute_name) {
$attribute_name = (string) $attribute_name;
if (
!in_array($attribute_name, $hidden_names, true) &&
!in_array($attribute_name, $attribute_names, true)
) {
$attribute_names[] = $attribute_name;
}
}
}
$result = [];
foreach ($attribute_names as $attribute_name) {
$value = product_export_text($product->get_attribute($attribute_name));
if ($value === "" && $product !== $parent) {
$value = product_export_text($parent->get_attribute($attribute_name));
}
$label = product_export_text(wc_attribute_label($attribute_name, $parent));
if ($label !== "" && $value !== "") {
$result[] = ["name" => $label, "value" => $value];
}
}
return $result;
}
function product_export_tag_names(array $direct_terms): array
{
$names = [];
foreach (["product_cat", "product_tag"] as $taxonomy) {
$assignment = $direct_terms[$taxonomy] ?? null;
if (!is_array($assignment)) {
continue;
}
foreach ($assignment["terms"] ?? [] as $term) {
$name = product_export_text((string) $term->name);
if ($name !== "" && !in_array($name, $names, true)) {
$names[] = $name;
}
}
}
return $names;
}
function product_export_image(\WC_Product $product, \WC_Product $parent): ?array
{
$image_id = $product->get_image_id();
if ($image_id <= 0 && $product !== $parent) {
$image_id = $parent->get_image_id();
}
$image = $image_id > 0 ? wp_get_attachment_image_src($image_id, "full") : false;
if (
!is_array($image) ||
!isset($image[0], $image[1], $image[2]) ||
!is_string($image[0]) ||
$image[0] === ""
) {
return null;
}
$result = [
"url" => $image[0],
"width" => (int) $image[1],
"height" => (int) $image[2],
"focal_point_x" => 50,
"focal_point_y" => 50,
];
$alt = get_post_meta($image_id, "_wp_attachment_image_alt", true);
$alt = is_string($alt) ? product_export_text($alt) : "";
if ($alt !== "") {
$result["alt"] = $alt;
}
return $result;
}
function product_export_descriptions(\WC_Product $product, \WC_Product $parent): array
{
$short_descriptions = [product_export_html($parent->get_short_description())];
$long_descriptions = [product_export_html($parent->get_description())];
if (product_export_text($long_descriptions[0]) === "") {
$long_descriptions[0] = $short_descriptions[0];
}
if ($product !== $parent) {
$long_descriptions[] = product_export_html($product->get_description());
$short_descriptions[] = product_export_html($product->get_short_description());
}
$descriptions = [];
$seen = [];
foreach (["description" => $long_descriptions, "short_description" => $short_descriptions] as $field => $values) {
$parts = [];
foreach ($values as $html) {
$text = product_export_text($html);
if ($text !== "" && !in_array($text, $seen, true)) {
$parts[] = $html;
$seen[] = $text;
}
}
$descriptions[$field] = implode("\n\n", $parts);
$seen[] = product_export_text($descriptions[$field]);
}
if ($descriptions["description"] === "") {
$descriptions["description"] = $descriptions["short_description"];
$descriptions["short_description"] = "";
}
return $descriptions;
}
function product_export_html(string $html): string
{
$html = strip_shortcodes($html);
$html = preg_replace('@<(script|style)\b[^>]*>.*?</\1>@is', "", $html) ?? $html;
return trim(wp_kses_post($html));
}
function product_export_date(\WC_Product $product, \WC_Product $parent): ?string
{
$date = $product->get_date_created();
if (!($date instanceof \DateTimeInterface) && $product !== $parent) {
$date = $parent->get_date_created();
}
return $date instanceof \DateTimeInterface ? $date->format(DATE_ATOM) : null;
}
function product_export_text(string $text): string
{
$text = strip_shortcodes($text);
$text =
preg_replace(
'/<\/?(?:p|div|br|li|h[1-6]|tr|td|th|blockquote)\b[^>]*>/i',
" ",
$text,
) ?? $text;
$text = wp_strip_all_tags($text);
return trim(preg_replace('/\s+/', " ", $text) ?? $text);
} <?php
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
const PROTECTION_SETTINGS_OPTION = "donoharm_searchcue_protection_settings";
const PROTECTION_CACHE_OPTION = "donoharm_searchcue_protection_cache";
const PROTECTION_CRON_HOOK = "donoharm_searchcue_refresh_protection_ips";
const PROTECTION_METADATA_CRON_HOOK = "donoharm_searchcue_refresh_protection_metadata";
const PROTECTION_REFRESH_SECONDS = 3600;
const PROTECTION_EXPIRY_SECONDS = 604800;
function protection_binding(): array
{
return ["binding" => hash("sha256",
normalize_source_url(get_option("home", "")) . "\n" . wordpress_integration_url("allowed-ips.json")
)];
}
function protection_binding_matches(array $value): bool
{
return ($value["binding"] ?? null) === protection_binding()["binding"];
}
function protection_enabled(): bool
{
$settings = get_option(PROTECTION_SETTINGS_OPTION, []);
return is_array($settings) && ($settings["enabled"] ?? false) === true &&
protection_binding_matches($settings);
}
function protection_cache(): array
{
$cache = get_option(PROTECTION_CACHE_OPTION, []);
$empty = protection_binding() + [
"ips" => [],
"revision" => "",
"checked_at" => 0,
"attempted_at" => 0,
"error" => "",
];
if (
!is_array($cache) || !protection_binding_matches($cache) ||
!isset($cache["ips"], $cache["revision"], $cache["checked_at"], $cache["attempted_at"], $cache["error"]) ||
!is_array($cache["ips"]) || count($cache["ips"]) > 128 ||
!is_string($cache["revision"]) || !is_string($cache["error"]) ||
!is_int($cache["checked_at"]) || !is_int($cache["attempted_at"])
) {
return $empty;
}
return $cache;
}
function protection_cache_fresh(array $cache): bool
{
$now = time();
return $cache["ips"] !== [] && $cache["checked_at"] > 0 &&
$cache["checked_at"] <= $now &&
$now - $cache["checked_at"] < PROTECTION_EXPIRY_SECONDS;
}
/** @param mixed $ip */
function canonical_protection_ip($ip): string
{
if (!is_string($ip) || filter_var($ip, FILTER_VALIDATE_IP) === false) {
return "";
}
$packed = inet_pton($ip);
// Some servers expose IPv4 peers as IPv4-mapped IPv6 addresses.
if (strlen($packed) === 16 && substr($packed, 0, 12) === str_repeat("\0", 10) . "\xff\xff") {
$packed = substr($packed, 12);
}
return inet_ntop($packed);
}
function public_protection_ip(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return false;
}
$bytes = unpack("C*", inet_pton($ip));
if (count($bytes) === 4) {
return $bytes[1] !== 0 && $bytes[1] < 224 &&
!($bytes[1] === 100 && $bytes[2] >= 64 && $bytes[2] <= 127) &&
!($bytes[1] === 192 && $bytes[2] === 0 && in_array($bytes[3], [0, 2], true)) &&
!($bytes[1] === 192 && $bytes[2] === 88 && $bytes[3] === 99) &&
!($bytes[1] === 198 && in_array($bytes[2], [18, 19], true)) &&
!($bytes[1] === 198 && $bytes[2] === 51 && $bytes[3] === 100) &&
!($bytes[1] === 203 && $bytes[2] === 0 && $bytes[3] === 113);
}
// Global unicast only, excluding special-use, transition and documentation ranges.
return ($bytes[1] & 0xe0) === 0x20 &&
!($bytes[1] === 0x20 && $bytes[2] === 0x01 && $bytes[3] < 2) &&
!($bytes[1] === 0x20 && $bytes[2] === 0x01 && $bytes[3] === 0x0d && $bytes[4] === 0xb8) &&
!($bytes[1] === 0x20 && $bytes[2] === 0x02) &&
!($bytes[1] === 0x3f && $bytes[2] === 0xff && ($bytes[3] & 0xf0) === 0);
}
function protection_payload(string $body): ?array
{
if (strlen($body) > 16384) {
return null;
}
$decoded = json_decode($body, false, 4);
$payload = is_object($decoded) ? get_object_vars($decoded) : null;
if (
!is_array($payload) || count($payload) !== 2 ||
!isset($payload["ips"], $payload["revision"]) ||
!is_array($payload["ips"]) || count($payload["ips"]) < 1 || count($payload["ips"]) > 128 ||
array_keys($payload["ips"]) !== range(0, count($payload["ips"]) - 1) ||
!is_string($payload["revision"]) || preg_match('/\A[a-f0-9]{64}\z/', $payload["revision"]) !== 1
) {
return null;
}
foreach ($payload["ips"] as $ip) {
if (!is_string($ip) || canonical_protection_ip($ip) !== $ip || !public_protection_ip($ip)) {
return null;
}
}
if (count(array_unique($payload["ips"])) !== count($payload["ips"])) {
return null;
}
sort($payload["ips"], SORT_STRING);
return hash_equals(hash("sha256", implode("\n", $payload["ips"])), $payload["revision"])
? $payload
: null;
}
function refresh_protection_ips(): void
{
wp_clear_scheduled_hook(PROTECTION_METADATA_CRON_HOOK);
$binding = protection_binding();
// Snapshot before HTTP so even a same-second refresh cannot replace a concurrent write.
wp_cache_delete(PROTECTION_CACHE_OPTION, "options");
wp_cache_delete("alloptions", "options");
wp_cache_delete("notoptions", "options");
$stored = get_option(PROTECTION_CACHE_OPTION, null);
$cache = protection_cache();
$attempted_at = time();
$response = wp_safe_remote_get(wordpress_integration_url("allowed-ips.json"), [
"user-agent" => CRAWLER_USER_AGENT,
"timeout" => 2,
"redirection" => 0,
"limit_response_size" => 16385,
"headers" => ["Accept" => "application/json"],
]);
$payload = null;
$error = "unavailable";
if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
$payload = protection_payload(wp_remote_retrieve_body($response));
$error = $payload === null ? "invalid_response" : "";
}
if (!protection_binding_matches($binding)) {
return;
}
if ($payload !== null) {
$cache["ips"] = $payload["ips"];
$cache["revision"] = $payload["revision"];
$cache["checked_at"] = time();
}
$cache["attempted_at"] = $attempted_at;
$cache["error"] = $error;
// Commit only if the row still matches the pre-request snapshot. The option API has no CAS.
global $wpdb;
if ($stored === null) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic creation must not overwrite another refresh.
$wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
PROTECTION_CACHE_OPTION,
maybe_serialize($cache)
));
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Compare-and-swap preserves a concurrent verified list.
$wpdb->update(
$wpdb->options,
["option_value" => maybe_serialize($cache)],
["option_name" => PROTECTION_CACHE_OPTION, "option_value" => maybe_serialize($stored)]
);
}
wp_cache_delete(PROTECTION_CACHE_OPTION, "options");
wp_cache_delete("alloptions", "options");
wp_cache_delete("notoptions", "options");
}
function clear_protection_permission(): void
{
delete_option(PROTECTION_SETTINGS_OPTION);
delete_option(PROTECTION_CACHE_OPTION);
wp_clear_scheduled_hook(PROTECTION_CRON_HOOK);
wp_clear_scheduled_hook(PROTECTION_METADATA_CRON_HOOK);
}
function reconcile_protection_binding(): void
{
$settings = get_option(PROTECTION_SETTINGS_OPTION, []);
if (is_array($settings) && $settings !== [] && !protection_binding_matches($settings)) {
clear_protection_permission();
}
}
function cron_refresh_protection_ips(): void
{
if (!protection_enabled()) {
wp_clear_scheduled_hook(PROTECTION_CRON_HOOK);
return;
}
refresh_protection_ips();
}
function schedule_protection_refresh(): void
{
if (!wp_next_scheduled(PROTECTION_CRON_HOOK)) {
wp_schedule_event(time() + PROTECTION_REFRESH_SECONDS, "hourly", PROTECTION_CRON_HOOK);
}
}
function deactivate_protection(bool $network_wide = false): void
{
if ($network_wide && is_multisite()) {
foreach (get_sites(["fields" => "ids", "number" => 0]) as $site_id) {
switch_to_blog((int) $site_id);
clear_protection_permission();
restore_current_blog();
}
} else {
clear_protection_permission();
}
}
function save_protection_permission(): void
{
if (!current_user_can("manage_options")) {
status_header(403);
return;
}
if (sanitize_text_field(wp_unslash($_SERVER["REQUEST_METHOD"] ?? "")) !== "POST") {
status_header(405);
return;
}
check_admin_referer("donoharm_searchcue_protection", "donoharm_searchcue_protection_nonce");
$enabled = isset($_POST["donoharm_searchcue_protection_enabled"]) &&
sanitize_text_field(wp_unslash($_POST["donoharm_searchcue_protection_enabled"])) === "1";
update_option(PROTECTION_SETTINGS_OPTION, protection_binding() + ["enabled" => $enabled], false);
if ($enabled) {
schedule_protection_refresh();
refresh_protection_ips();
} else {
wp_clear_scheduled_hook(PROTECTION_CRON_HOOK);
}
redirect_to_settings([]);
}
function manually_refresh_protection_ips(): void
{
if (!current_user_can("manage_options")) {
status_header(403);
return;
}
if (sanitize_text_field(wp_unslash($_SERVER["REQUEST_METHOD"] ?? "")) !== "POST") {
status_header(405);
return;
}
check_admin_referer("donoharm_searchcue_protection", "donoharm_searchcue_protection_nonce");
refresh_protection_ips();
redirect_to_settings([]);
}
/** Preserve the upstream decision and every manually allowed address. */
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- The upstream hook's other arguments must not replace the actual peer or change manual entries.
function allow_protection_ip($protected, $manual_ips, $remote_addr)
{
if (!$protected || !protection_enabled()) {
return $protected;
}
$cache = protection_cache();
if (!protection_cache_fresh($cache)) {
return $protected;
}
// Never use forwarded headers or a caller-supplied address for this trust decision.
$peer = isset($_SERVER["REMOTE_ADDR"])
? canonical_protection_ip(sanitize_text_field(wp_unslash($_SERVER["REMOTE_ADDR"])))
: "";
return $peer !== "" && in_array($peer, $cache["ips"], true) ? false : $protected;
}
/** Identify native protection denials without granting REST access. */
function protection_rest_error($result)
{
global $wp, $Password_Protected;
$route = $wp->query_vars["rest_route"] ?? "";
if (
!is_wp_error($result) || $result->get_error_code() !== "rest_cannot_access" ||
!is_string($route) ||
!in_array(rtrim($route, "/"), ["/searchcue/v1/products", "/searchcue/v1/blog-taxonomy/lookup"], true) ||
!is_object($Password_Protected) ||
!is_callable([$Password_Protected, "only_allow_logged_in_rest_access"])
) {
return $result;
}
$denial = $Password_Protected->only_allow_logged_in_rest_access(null);
if (!is_wp_error($denial) || $denial->get_error_code() !== "rest_cannot_access") {
return $result;
}
return new \WP_Error(
"searchcue_password_protected",
$denial->get_error_message(),
$denial->get_error_data()
);
}
function password_protected_state(): string
{
$plugin = "password-protected/password-protected.php";
$site_plugins = get_option("active_plugins", []);
$network_plugins = is_multisite() ? get_site_option("active_sitewide_plugins", []) : [];
$active = (is_array($site_plugins) && in_array($plugin, $site_plugins, true)) ||
(is_array($network_plugins) && array_key_exists($plugin, $network_plugins));
if ($active) {
return get_option("password_protected_status") ? "enabled" : "disabled";
}
return array_key_exists($plugin, get_plugins()) ? "inactive" : "absent";
}
function protection_settings_cache(): array
{
reconcile_protection_binding();
$cache = protection_cache();
$now = time();
if (protection_enabled()) {
schedule_protection_refresh();
}
if (($cache["attempted_at"] <= $now - PROTECTION_REFRESH_SECONDS || $cache["attempted_at"] > $now) &&
!wp_next_scheduled(PROTECTION_METADATA_CRON_HOOK)) {
wp_schedule_single_event(time() + 60, PROTECTION_METADATA_CRON_HOOK);
}
return $cache;
}
function render_protection_panel(): void
{
if (!current_user_can("manage_options")) {
return;
}
$state = password_protected_state();
if ($state === "absent" && get_option(PROTECTION_SETTINGS_OPTION, []) === []) {
return;
}
$cache = protection_settings_cache();
$enabled = protection_enabled();
$fresh = protection_cache_fresh($cache);
$states = [
"enabled" => __("SearchCue has to access your website in order to index its content. Currently the <em>Password Protected</em> plugin is likely to get in the way of this.", "searchcue"),
"disabled" => __("Password Protected is active, but its password protection is disabled.", "searchcue"),
"inactive" => __("Password Protected is installed but inactive. It is not protecting this site.", "searchcue"),
"absent" => __("Password Protected is not installed. It is not protecting this site.", "searchcue"),
];
?>
<section class="searchcue-settings searchcue-protection" aria-labelledby="searchcue-protection-title">
<h2 id="searchcue-protection-title"><?php echo esc_html__("Password Protected access", "searchcue"); ?></h2>
<p><?php echo wp_kses_post($states[$state]); ?></p>
<p><?php echo esc_html__("We recommend that you whitelist the IP addresses we use for crawling and to access your site. You can either do it manually, or let us augment your list of approved IPs for you. Without this access, setup and crawling will fail.", "searchcue"); ?></p>
<p class="searchcue-protection-warning"><strong><?php echo esc_html__("Search privacy:", "searchcue"); ?></strong> <?php echo wp_kses_post(__("Please beware that once whitelisted, SearchCue will do its best to crawl and index <em>all</em> content. If some pages should be kept out of search, you can use the exclusion features of SearchCue to restrict what is searchable. However, while your site is password protected, our search interface, including AI answers, is publicly accessible on the internet. Anyone who knows your site's ID can search its indexed content. The site ID is not a secret or an access control.", "searchcue")); ?></p>
<form action="<?php echo esc_url(admin_url("admin-post.php")); ?>" method="post">
<input type="hidden" name="action" value="donoharm_searchcue_save_protection">
<?php wp_nonce_field("donoharm_searchcue_protection", "donoharm_searchcue_protection_nonce"); ?>
<label class="searchcue-toggle" for="donoharm_searchcue_protection_enabled">
<input class="searchcue-switch-input" id="donoharm_searchcue_protection_enabled" name="donoharm_searchcue_protection_enabled" type="checkbox" value="1" <?php checked($enabled); ?> aria-describedby="searchcue-protection-help">
<span class="searchcue-switch" aria-hidden="true"></span>
<span>
<span class="searchcue-toggle-title"><?php echo esc_html__("Automatically allow SearchCue through Password Protected", "searchcue"); ?></span>
<span class="searchcue-help" id="searchcue-protection-help"><?php echo esc_html__("Off by default. This is separate from automatic content updates. When enabled, SearchCue keeps the list of SearchCue IPs up to date for you over time. We do not alter the list of other IPs you might already have added to Password Protected.", "searchcue"); ?></span>
</span>
</label>
<div class="searchcue-actions">
<button class="button button-primary" type="submit"><?php echo esc_html__("Save access permission", "searchcue"); ?></button>
</div>
</form>
<div class="searchcue-protection-status" role="status">
<p><strong><?php echo esc_html($enabled
? ($fresh ? __("Automatic permission is on. The managed IP list is current.", "searchcue") : __("Automatic permission is on, but managed access is not active: no current IP list is available.", "searchcue"))
: __("Automatic permission is off. No managed access is granted.", "searchcue")); ?></strong></p>
<p class="searchcue-help">
<?php
echo esc_html($cache["checked_at"] > 0
? sprintf(
/* translators: %s: date and time of successful IP refresh. */
__("Last successful check: %s.", "searchcue"),
wp_date("Y-m-d H:i T", $cache["checked_at"]),
)
: __("The IP list has not been successfully checked yet.", "searchcue"));
?>
</p>
<?php if ($cache["error"] !== ""): ?>
<p class="searchcue-protection-warning"><?php echo esc_html($cache["error"] === "invalid_response"
? __("The last refresh returned an invalid IP list. No new addresses were accepted. Try refreshing again.", "searchcue")
: __("The last refresh could not reach the SearchCue IP service. Try refreshing again.", "searchcue")); ?> <?php echo esc_html($fresh ? __("The last verified list is retained until its seven-day expiry.", "searchcue") : __("Managed access is unavailable until a valid list can be fetched.", "searchcue")); ?></p>
<?php endif; ?>
<?php if ($cache["ips"] !== [] && !$fresh): ?>
<p class="searchcue-protection-warning"><?php echo esc_html__("This IP list is stale. Managed permission has expired. Refresh before using the list for manual access.", "searchcue"); ?></p>
<?php endif; ?>
</div>
<h3><?php echo esc_html__("Manual IP access", "searchcue"); ?></h3>
<p class="searchcue-help"><?php echo esc_html__("Add these addresses to Allowed IP Addresses in Password Protected settings, one per line, keeping any existing entries. Note that manual entries are not maintained by SearchCue and might become stale over time.", "searchcue"); ?></p>
<?php if ($cache["ips"] !== []): ?>
<label class="searchcue-label" for="searchcue-protection-ips"><?php echo esc_html__("SearchCue IP addresses", "searchcue"); ?></label>
<textarea class="searchcue-input searchcue-mono searchcue-protection-ips" id="searchcue-protection-ips" rows="<?php echo esc_attr((string) min(10, count($cache["ips"]))); ?>" readonly spellcheck="false"><?php echo esc_html(implode("\n", $cache["ips"])); ?></textarea>
<p class="searchcue-help"><?php echo esc_html__("Select and copy the list above.", "searchcue"); ?></p>
<?php else: ?>
<p><?php echo esc_html__("No verified IP list is available. Refresh to retrieve the current list; no account or automatic permission is required.", "searchcue"); ?></p>
<?php endif; ?>
<p>
<?php if ($state === "enabled" || $state === "disabled"): ?>
<a href="<?php echo esc_url(admin_url("admin.php?page=password-protected")); ?>"><?php echo esc_html__("Open Password Protected settings", "searchcue"); ?></a>
<?php else: ?>
<a href="<?php echo esc_url(admin_url("plugins.php")); ?>"><?php echo esc_html__("Manage plugins", "searchcue"); ?></a>
<?php endif; ?>
</p>
<form action="<?php echo esc_url(admin_url("admin-post.php")); ?>" method="post" class="searchcue-actions">
<input type="hidden" name="action" value="donoharm_searchcue_refresh_protection">
<?php wp_nonce_field("donoharm_searchcue_protection", "donoharm_searchcue_protection_nonce"); ?>
<button class="button" type="submit"><?php echo esc_html__("Refresh IP list", "searchcue"); ?></button>
</form>
<h3><?php echo esc_html__("Troubleshooting", "searchcue"); ?></h3>
<p><?php echo esc_html__("If setting up the SearchCue plugin or crawling fails, please investigate whether there might be other proxies or firewalls between your site and SearchCue that could interfere.", "searchcue"); ?></p>
<p><?php echo wp_kses_post(__("If you are unable to find a solution, please contact us at <a href=\"mailto:support@searchcue.com\">support@searchcue.com</a>, and we will do our best to help you troubleshoot.", "searchcue")); ?></p>
</section>
<?php
} <?php
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
function searchcue_admin_url(array $options, string $destination): string
{
$site_id = rawurlencode(effective_site_id($options));
switch ($destination) {
case "documentation":
return searchcue_app_url("/docs");
case "design":
return searchcue_app_url("/site-search/app/site/" . $site_id . "/design");
case "analytics":
return searchcue_app_url("/site-search/app/site/" . $site_id . "/analytics");
case "more":
default:
return searchcue_app_url("/site-search/app/site/" . $site_id);
}
}
function searchcue_service_url(string $production_url): string
{
if (
!defined("DONOHARM_SEARCHCUE_STAGING") ||
constant("DONOHARM_SEARCHCUE_STAGING") !== true
) {
return $production_url;
}
$path = wp_parse_url($production_url, PHP_URL_PATH);
return STAGING_SERVICE_ENDPOINT . (is_string($path) ? $path : "");
}
function searchcue_app_url(string $path): string
{
return rtrim(searchcue_service_url(SEARCHCUE_APP_ENDPOINT), "/") . "/" . ltrim($path, "/");
}
function wordpress_integration_url(string $path): string
{
return rtrim(searchcue_service_url(WORDPRESS_INTEGRATION_ENDPOINT), "/") . "/" . ltrim($path, "/");
}
function settings_page_url(): string
{
return admin_url("admin.php?page=" . SETTINGS_PAGE);
}
function redirect_to_settings(array $params): void
{
wp_safe_redirect(add_query_arg($params, settings_page_url()));
if (!defined("DONOHARM_SEARCHCUE_TESTING") || DONOHARM_SEARCHCUE_TESTING !== true) {
exit();
}
} <?php
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
const SOURCE_FEATURES_CRON_HOOK = "donoharm_searchcue_sync_source_features";
const SOURCE_FEATURES_CHECK_OPTION = "donoharm_searchcue_source_features_checked_at";
function render_first_run_settings_page(array $options): void
{
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- This read-only status only controls a confirmation message.
$setup_status =
isset($_GET["donoharm_searchcue_setup"]) || isset($_GET["searchcue_setup"])
? sanitize_text_field(
wp_unslash($_GET["donoharm_searchcue_setup"] ?? $_GET["searchcue_setup"] ?? ""),
)
: "";
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$setup_notices = [
"sent" => [
"message" => __("Check your email for the SearchCue sign-in link. This page will update when setup finishes in another tab.", "searchcue"),
"error" => false,
],
"invalid_email" => [
"message" => __("Enter a valid email address and try again.", "searchcue"),
"error" => true,
],
"send_failed" => [
"message" => __("SearchCue could not send the sign-in link. Please try again.", "searchcue"),
"error" => true,
],
"state_mismatch" => [
"message" => __("The setup link could not be verified. Request a new link and try again.", "searchcue"),
"error" => true,
],
"claim_failed" => [
"message" => __("SearchCue could not finish connecting this site. Please try again.", "searchcue"),
"error" => true,
],
"site_already_connected" => [
"message" => __("This site is already connected to another SearchCue account. Check your email for instructions to reconnect using that account.", "searchcue"),
"error" => true,
],
"setup_failed" => [
"message" => __("SearchCue could not finish connecting this site. Please try again.", "searchcue"),
"error" => true,
],
"setup_verification_failed" => [
"message" => __("SearchCue could not verify control of this site. Check that the site address is correct and reachable, then request a new link.", "searchcue"),
"error" => true,
],
];
$setup_notice = $setup_notices[$setup_status] ?? null;
$protection_first = password_protected_state() === "enabled";
if ($protection_first) {
$cache = protection_settings_cache();
$manual_ips = explode("\n", (string) get_option("password_protected_allowed_ip_addresses", ""));
$protection_first = !protection_cache_fresh($cache) ||
(!protection_enabled() && array_diff($cache["ips"], $manual_ips) !== []);
}
?>
<div class="wrap searchcue-admin">
<section class="searchcue-hero">
<div class="searchcue-hero-copy">
<h1>
<?php
printf(
/* translators: %s: SearchCue brand wordmark. */
esc_html__("Welcome to %s", "searchcue"),
'<span class="searchcue-wordmark"><span>' .
esc_html__("SearchCue", "searchcue") .
'</span><span class="searchcue-wordmark-caret" aria-hidden="true"></span></span>',
);
?>
</h1>
<p class="searchcue-intro">
<?php echo esc_html__(
"You can try SearchCue for free on your site. Once connected, SearchCue indexes your public content and keeps your search index managed as you publish.",
"searchcue",
); ?>
</p>
</div>
</section>
<?php if ($protection_first) {
render_protection_panel();
} ?>
<section
class="searchcue-onboarding"
data-searchcue-setup
data-endpoint="<?php echo esc_url(admin_url("admin-ajax.php")); ?>"
data-nonce="<?php echo esc_attr(wp_create_nonce("donoharm_searchcue_setup_status")); ?>"
>
<span class="searchcue-status-badge is-needed">
<?php echo esc_html__("Setup needed", "searchcue"); ?>
</span>
<?php if (is_array($setup_notice)): ?>
<p
class="searchcue-alert<?php echo esc_attr($setup_notice["error"] ? " is-error" : ""); ?>"
role="<?php echo esc_attr($setup_notice["error"] ? "alert" : "status"); ?>"
>
<?php echo esc_html($setup_notice["message"]); ?>
</p>
<?php endif; ?>
<h2><?php echo esc_html__("Connect to SearchCue", "searchcue"); ?></h2>
<p class="searchcue-help searchcue-setup-intro">
<?php echo esc_html__(
"You need an account to use SearchCue. Enter your e-mail address and we will send you a link that can be used to sign up and connect your account to this site. If you already have a SearchCue account (welcome back), then please use the e-mail address associated with your existing account.",
"searchcue",
); ?>
</p>
<form action="<?php echo esc_url(admin_url("admin-post.php")); ?>" method="post">
<input type="hidden" name="action" value="donoharm_searchcue_send_setup_link">
<?php wp_nonce_field("donoharm_searchcue_setup", "donoharm_searchcue_setup_nonce"); ?>
<div class="searchcue-field">
<label class="searchcue-label" for="donoharm_searchcue_setup_email">
<?php echo esc_html__("E-mail", "searchcue"); ?>
</label>
<input
id="donoharm_searchcue_setup_email"
class="searchcue-input regular-text"
name="donoharm_searchcue_setup_email"
type="email"
autocomplete="email"
required
placeholder="my@email.com"
>
</div>
<label class="searchcue-toggle searchcue-setup-toggle" for="donoharm_searchcue_setup_push_updates">
<input
class="searchcue-switch-input"
id="donoharm_searchcue_setup_push_updates"
name="donoharm_searchcue_setup_push_updates"
type="checkbox"
value="1"
<?php checked($options["push_updates"], "1"); ?>
>
<span class="searchcue-switch" aria-hidden="true"></span>
<span>
<span class="searchcue-toggle-title">
<?php echo esc_html__("Automatic updates", "searchcue"); ?>
</span>
<span class="searchcue-help">
<?php echo esc_html__(
"Keep the search index current as you update public pages, posts, custom content, and files.",
"searchcue",
); ?>
</span>
</span>
</label>
<p class="searchcue-help searchcue-setup-consent">
<?php
echo wp_kses_post(
sprintf(
/* translators: 1: SearchCue Terms and Conditions link, 2: SearchCue Privacy Policy link. */
__(
'By submitting this form, you agree to the SearchCue %1$s and acknowledge the %2$s. Your email address and site URL will be sent to SearchCue to connect this site.',
"searchcue",
),
'<a href="' . esc_url("https://searchcue.com/terms/") . '" target="_blank" rel="noopener noreferrer">' . esc_html__("Terms and Conditions", "searchcue") . "</a>",
'<a href="' . esc_url("https://donoharm.world/privacy-policy/searchcue/") . '" target="_blank" rel="noopener noreferrer">' . esc_html__("Privacy Policy", "searchcue") . "</a>",
),
);
?>
</p>
<div class="searchcue-actions">
<button class="button button-primary" type="submit">
<?php echo esc_html__("Sign up or in", "searchcue"); ?>
</button>
</div>
</form>
</section>
<?php if (!$protection_first) {
render_protection_panel();
} ?>
</div>
<?php
}
function send_setup_link(): void
{
if (!current_user_can("manage_options")) {
status_header(403);
return;
}
check_admin_referer("donoharm_searchcue_setup", "donoharm_searchcue_setup_nonce");
$email = isset($_POST["donoharm_searchcue_setup_email"])
? sanitize_email(
sanitize_text_field(wp_unslash($_POST["donoharm_searchcue_setup_email"])),
)
: "";
if (!is_email($email)) {
redirect_to_settings(["donoharm_searchcue_setup" => "invalid_email"]);
return;
}
$options = ensure_setup_state(get_searchcue_options());
$push_updates = isset($_POST["donoharm_searchcue_setup_push_updates"])
? sanitize_text_field(wp_unslash($_POST["donoharm_searchcue_setup_push_updates"]))
: "0";
$options["push_updates"] = $push_updates === "1" ? "1" : "0";
update_option(OPTION_NAME, sanitize_options($options));
$site_url = normalize_source_url(get_option("home", ""));
$settings_path = wp_parse_url(settings_page_url(), PHP_URL_PATH);
$response = wp_safe_remote_post(wordpress_integration_url("/setup-link"), [
"user-agent" => CRAWLER_USER_AGENT,
"timeout" => 10,
"headers" => [
"Content-Type" => "application/json",
],
"body" => wp_json_encode([
"email" => $email,
"site_url" => $site_url . "/",
"return_url" => source_origin($site_url) . $settings_path . "?page=" . SETTINGS_PAGE,
"state" => $options["setup_state"],
]),
]);
if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
redirect_to_settings(["donoharm_searchcue_setup" => "sent"]);
return;
}
redirect_to_settings(["donoharm_searchcue_setup" => "send_failed"]);
}
function maybe_complete_setup_from_handoff(): void
{
if (!current_user_can("manage_options")) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- The SearchCue setup state is verified below.
$page = isset($_GET["page"])
? sanitize_key(sanitize_text_field(wp_unslash($_GET["page"])))
: "";
if ($page !== SETTINGS_PAGE || !isset($_GET["state"], $_GET["handoff"])) {
return;
}
$state = sanitize_identifier(sanitize_text_field(wp_unslash($_GET["state"])));
$handoff = sanitize_identifier(sanitize_text_field(wp_unslash($_GET["handoff"])));
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$options = get_searchcue_options();
$stored_state = $options["setup_state"] ?? "";
if (
$state === "" ||
$handoff === "" ||
!is_string($stored_state) ||
!hash_equals($stored_state, $state)
) {
redirect_to_settings(["donoharm_searchcue_setup" => "state_mismatch"]);
return;
}
$claim = claim_handoff($handoff);
if ($claim === null) {
redirect_to_settings(["donoharm_searchcue_setup" => "claim_failed"]);
return;
}
$options["api_key"] = $claim["api_key"];
$options["assigned_site_id"] = $claim["site_id"];
$options["connected_source_url"] = source_origin(get_option("home", ""));
$options["setup_state"] = generate_setup_state();
update_option(OPTION_NAME, sanitize_options($options));
sync_source_features();
redirect_to_settings(["donoharm_searchcue_setup" => "connected"]);
}
function reset_source_connection(array $options): void
{
$stored = get_option(OPTION_NAME);
if (!is_array($stored)) {
return;
}
$current = normalize_options($stored);
foreach (["api_key", "site_id", "assigned_site_id", "connected_source_url"] as $field) {
if ($current[$field] !== $options[$field]) {
return;
}
}
$options = $current;
$options["site_id"] = "";
$options["assigned_site_id"] = "";
$options["api_key"] = "";
$options["connected_source_url"] = "";
$options["setup_state"] = generate_setup_state();
$options["reported_source_features"] = [];
if (replace_source_options($stored, $options)) {
wp_clear_scheduled_hook(SOURCE_FEATURES_CRON_HOOK);
delete_option(SOURCE_FEATURES_CHECK_OPTION);
update_option(ACTIVATION_REDIRECT_OPTION, "1");
}
}
function replace_source_options(array $stored, array $options): bool
{
global $wpdb;
// A delayed response must not overwrite a concurrent disconnect, new connection, or consent change.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The option API has no compare-and-swap.
$updated = $wpdb->update(
$wpdb->options,
["option_value" => maybe_serialize(normalize_options($options))],
["option_name" => OPTION_NAME, "option_value" => maybe_serialize($stored)]
);
wp_cache_delete(OPTION_NAME, "options");
wp_cache_delete("alloptions", "options");
wp_cache_delete("notoptions", "options");
return $updated === 1;
}
function source_origin($url): string
{
$parts = wp_parse_url(normalize_source_url($url));
if (!is_array($parts) || empty($parts["host"]) ||
!in_array(strtolower($parts["scheme"] ?? ""), ["http", "https"], true)) {
return "";
}
$scheme = strtolower($parts["scheme"]);
$port = $parts["port"] ?? ($scheme === "https" ? 443 : 80);
$suffix = $port === ($scheme === "https" ? 443 : 80) ? "" : ":" . $port;
return $scheme . "://" . strtolower($parts["host"]) . $suffix;
}
/**
* Return the stored snapshot after migrating a legacy, language-dependent binding.
*/
function reconcile_source_connection(): ?array
{
$stored = get_option(OPTION_NAME);
if (!is_array($stored)) {
return null;
}
$options = normalize_options($stored);
$current = source_origin(get_option("home", ""));
if ($current === "") {
return null;
}
if ($options["api_key"] === "" || $options["connected_source_url"] === $current) {
return $stored;
}
$previous = source_origin($options["connected_source_url"]);
$same_site = $options["connected_source_url"] === "" || $previous === $current;
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's public settings API uses its own hook prefix.
if (!$same_site && (int) apply_filters("wpml_setting", 0, "language_negotiation_type") === 2) {
$domains = apply_filters("wpml_setting", [], "language_domains");
$default_language = apply_filters("wpml_setting", "", "default_language");
// phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
$domains = is_array($domains) ? $domains : [];
// WPML may store only secondary hostnames; the configured home is its implicit default.
if (is_string($default_language) && $default_language !== "" &&
!array_key_exists($default_language, $domains)) {
$domains[$default_language] = $current;
}
$home = wp_parse_url($current);
$origins = array_map(static function ($domain) use ($home): string {
if (!is_string($domain) || trim($domain) === "") {
return "";
}
$domain = trim($domain);
if (strpos($domain, "://") === false) {
$parts = wp_parse_url($home["scheme"] . "://" . ltrim($domain, "/"));
if (!is_array($parts) || empty($parts["host"])) {
return "";
}
$port = $parts["port"] ?? $home["port"] ?? null;
$domain = $home["scheme"] . "://" . $parts["host"] . ($port === null ? "" : ":" . $port);
}
return source_origin($domain);
}, $domains);
$same_site = $previous !== "" &&
in_array($previous, $origins, true) && in_array($current, $origins, true);
}
if (!$same_site) {
reset_source_connection($options);
return null;
}
$options["connected_source_url"] = $current;
return replace_source_options($stored, $options) ? $options : null;
}
function maybe_sync_source_features(): void
{
if (!current_user_can("manage_options")) {
return;
}
$stored = reconcile_source_connection();
if ($stored === null) {
return;
}
$options = normalize_options($stored);
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- This read-only marker only validates an existing connection.
$settings_page_request =
isset($_GET["page"]) &&
sanitize_key(sanitize_text_field(wp_unslash($_GET["page"]))) === SETTINGS_PAGE;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$features = source_features();
if (
$options["api_key"] === "" ||
($features === $options["reported_source_features"] &&
(!$settings_page_request || time() - (int) get_option(SOURCE_FEATURES_CHECK_OPTION, 0) < 300))
) {
return;
}
schedule_source_features_sync();
}
function schedule_source_features_sync(int $delay = 60): void
{
global $donoharm_searchcue_features_deactivated;
if ($donoharm_searchcue_features_deactivated) {
return;
}
if (!wp_next_scheduled(SOURCE_FEATURES_CRON_HOOK)) {
wp_schedule_single_event(time() + $delay, SOURCE_FEATURES_CRON_HOOK);
}
}
function sync_source_features(): void
{
global $donoharm_searchcue_features_deactivated;
if ($donoharm_searchcue_features_deactivated) {
return;
}
$stored = reconcile_source_connection();
if ($stored === null) {
return;
}
$options = normalize_options($stored);
if ($options["api_key"] === "") {
return;
}
$features = source_features();
update_option(SOURCE_FEATURES_CHECK_OPTION, time(), false);
$response = wp_safe_remote_post(wordpress_integration_url("/features"), [
"user-agent" => CRAWLER_USER_AGENT,
"timeout" => 2,
"redirection" => 0,
"limit_response_size" => 1024,
"headers" => [
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "Bearer " . $options["api_key"],
],
"body" => wp_json_encode(["features" => $features]),
]);
if (is_wp_error($response)) {
schedule_source_features_sync(300);
return;
}
$response_code = wp_remote_retrieve_response_code($response);
if ($response_code === 410) {
reset_source_connection($options);
return;
}
if ($response_code !== 204) {
schedule_source_features_sync(300);
return;
}
$options["reported_source_features"] = $features;
if (($options !== normalize_options($stored) && !replace_source_options($stored, $options)) ||
source_features() !== $features) {
schedule_source_features_sync(300);
}
}
function deactivate_source_features(bool $network_wide = false): void
{
global $donoharm_searchcue_features_deactivated;
$donoharm_searchcue_features_deactivated = true;
if ($network_wide && is_multisite()) {
foreach (get_sites(["fields" => "ids", "number" => 0]) as $site_id) {
switch_to_blog((int) $site_id);
wp_clear_scheduled_hook(SOURCE_FEATURES_CRON_HOOK);
restore_current_blog();
}
} else {
wp_clear_scheduled_hook(SOURCE_FEATURES_CRON_HOOK);
}
}
function ajax_setup_status(): void
{
if (!current_user_can("manage_options")) {
wp_send_json_error(["connected" => false]);
return;
}
check_ajax_referer("donoharm_searchcue_setup_status");
$options = get_searchcue_options();
$site_id = effective_site_id($options);
wp_send_json_success([
"connected" => $site_id !== "",
"siteId" => $site_id,
"assignedSiteId" => $options["assigned_site_id"],
]);
}
/**
* @return array{api_key: string, site_id: string}|null
*/
function claim_handoff(string $handoff): ?array
{
$response = wp_safe_remote_post(wordpress_integration_url("/claim"), [
"user-agent" => CRAWLER_USER_AGENT,
"timeout" => 10,
"headers" => [
"Content-Type" => "application/json",
],
"body" => wp_json_encode([
"handoff" => $handoff,
"state" => get_searchcue_options()["setup_state"],
]),
]);
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
return null;
}
$payload = json_decode(wp_remote_retrieve_body($response), true);
if (
!is_array($payload) ||
!isset($payload["apiKey"], $payload["siteId"]) ||
!is_string($payload["apiKey"]) ||
!is_string($payload["siteId"])
) {
return null;
}
$api_key = sanitize_identifier($payload["apiKey"]);
$site_id = sanitize_identifier($payload["siteId"]);
return $api_key !== "" && $site_id !== ""
? ["api_key" => $api_key, "site_id" => $site_id]
: null;
}
function ensure_setup_state(array $options): array
{
if ($options["setup_state"] === "") {
$options["setup_state"] = generate_setup_state();
update_option(OPTION_NAME, sanitize_options($options));
}
return $options;
} <?php
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
const TAXONOMY_EXPORT_PRODUCT_CONTEXT = "searchcue_wordpress_products_v1";
const TAXONOMY_EXPORT_BLOG_CONTEXT = "searchcue_wordpress_blog_taxonomy_v1";
function taxonomy_export_secret(string $context, string $api_key): string
{
$key = hash("sha256", $api_key, true);
$signature = hash_hmac("sha256", $context, $key, true);
return rtrim(strtr(base64_encode($signature), "+/", "-_"), "=");
}
function blog_taxonomy_secret(string $api_key): string
{
return taxonomy_export_secret(TAXONOMY_EXPORT_BLOG_CONTEXT, $api_key);
}
function taxonomy_export_bearer_token(\WP_REST_Request $request): ?string
{
$authorization = $request->get_headers()["authorization"] ?? [];
if (
!is_array($authorization) ||
count($authorization) !== 1 ||
!is_string($authorization[0]) ||
preg_match('/\ABearer ([A-Za-z0-9_-]+)\z/', $authorization[0], $matches) !== 1
) {
return null;
}
return $matches[1];
}
function authorize_taxonomy_export(\WP_REST_Request $request, string $context): bool
{
$api_key = get_searchcue_options()["api_key"];
if ($api_key === "") {
return false;
}
$token = taxonomy_export_bearer_token($request);
return $token !== null && hash_equals(taxonomy_export_secret($context, $api_key), $token);
}
function authorize_blog_taxonomy_export(\WP_REST_Request $request): bool
{
return authorize_taxonomy_export($request, TAXONOMY_EXPORT_BLOG_CONTEXT);
}
/**
* Fetches the directly assigned terms of both taxonomies with one query each.
*
* @return array<string, array{terms: array<int, \WP_Term>, degraded: bool}|null> taxonomy name => assignment result, or null when that taxonomy yielded no usable assignment information
*/
function taxonomy_export_direct_terms(
int $object_id,
string $category_taxonomy,
string $tag_taxonomy
): array {
return [
$category_taxonomy => taxonomy_export_assigned_terms($object_id, $category_taxonomy),
$tag_taxonomy => taxonomy_export_assigned_terms($object_id, $tag_taxonomy),
];
}
/**
* @return array{terms: array<int, \WP_Term>, degraded: bool}|null null when the taxonomy is unregistered, its query failed, or no entry was usable; degraded marks results that mixed usable terms with unusable entries
*/
function taxonomy_export_assigned_terms(int $object_id, string $taxonomy): ?array
{
if (!taxonomy_exists($taxonomy)) {
return null;
}
$terms = wp_get_post_terms($object_id, $taxonomy, ["fields" => "all"]);
if (!is_array($terms)) {
return null;
}
$assigned = [];
$degraded = false;
foreach ($terms as $term) {
if (taxonomy_export_valid_term($term, $taxonomy)) {
$assigned[] = $term;
} else {
$degraded = true;
}
}
if ($assigned === [] && $degraded) {
return null;
}
return ["terms" => $assigned, "degraded" => $degraded];
}
/**
* @param mixed $term
*/
function taxonomy_export_valid_term($term, string $taxonomy): bool
{
return $term instanceof \WP_Term
&& is_int($term->term_id)
&& $term->term_id > 0
&& is_string($term->name)
&& is_int($term->parent)
&& $term->parent >= 0
&& $term->taxonomy === $taxonomy;
}
/**
* @param array<string, array{terms: array<int, \WP_Term>, degraded: bool}|null> $direct_terms taxonomy => assignment result from taxonomy_export_direct_terms()
*
* @return array<string, mixed> the complete source taxonomy payload
*/
function taxonomy_export_source_taxonomy(
array $direct_terms,
string $category_taxonomy,
string $tag_taxonomy
): array {
return [
"version" => 1,
"provider" => "wordpress",
"scope" => [
"kind" => "site",
"id" => "site",
"url" => home_url("/"),
],
"categories" => taxonomy_export_section_from_terms(
$direct_terms[$category_taxonomy] ?? null,
$category_taxonomy
),
"tags" => taxonomy_export_section_from_terms(
$direct_terms[$tag_taxonomy] ?? null,
$tag_taxonomy
),
];
}
/**
* @return array<string, mixed>
*/
function taxonomy_export_source_taxonomy_for_object(
int $object_id,
string $category_taxonomy,
string $tag_taxonomy
): array {
return taxonomy_export_source_taxonomy(
taxonomy_export_direct_terms($object_id, $category_taxonomy, $tag_taxonomy),
$category_taxonomy,
$tag_taxonomy
);
}
/**
* @param array{terms: array<int, \WP_Term>, degraded: bool}|null $result assignment result, null when unavailable
*
* @return array<string, mixed> one taxonomy section of the source taxonomy payload
*/
function taxonomy_export_section_from_terms(?array $result, string $taxonomy): array
{
if (
$result === null ||
!isset($result["terms"], $result["degraded"]) ||
!is_array($result["terms"]) ||
!is_bool($result["degraded"]) ||
!taxonomy_exists($taxonomy) ||
!is_taxonomy_viewable($taxonomy)
) {
return taxonomy_export_unavailable_section($taxonomy);
}
$hierarchical = taxonomy_export_hierarchical($taxonomy);
$nodes = [];
$assigned_ids = [];
foreach ($result["terms"] as $term) {
taxonomy_export_register_node($nodes, $term, $taxonomy);
$assigned_ids[(string) $term->term_id] = true;
}
if ($hierarchical) {
foreach ($result["terms"] as $term) {
taxonomy_export_walk_ancestors($term, $taxonomy, $nodes);
}
} else {
// flat taxonomies always have a known null parent, so no ancestry is walked
foreach ($nodes as $id => $node) {
$node["parent_id"] = null;
$node["parent_known"] = true;
$node["walked"] = true;
$nodes[$id] = $node;
}
}
$assigned = [];
foreach (array_keys($assigned_ids) as $id) {
$assigned[] = (string) $id;
}
usort($assigned, "strcmp");
// unresolved_ids only ever contains assigned ids, re-derived against the
// final node observations so a later conflict also catches earlier chains
$unresolved = [];
$resolved = true;
foreach ($assigned as $id) {
if (!taxonomy_export_assigned_id_resolved($id, $nodes)) {
$resolved = false;
$unresolved[] = $id;
}
}
$conflicted = false;
foreach ($nodes as $node) {
if ($node["label_conflict"]) {
$conflicted = true;
break;
}
}
$ordered_terms = [];
foreach ($nodes as $node) {
$ordered_terms[] = [
"id" => $node["id"],
"label" => $node["label"],
"parent_id" => $node["parent_id"],
"parent_known" => $node["parent_known"],
"url" => $node["url"],
];
}
usort($ordered_terms, static function (array $left, array $right): int {
return strcmp($left["id"], $right["id"]);
});
return [
"taxonomy" => $taxonomy,
"hierarchical" => $hierarchical,
"status" => $resolved && !$result["degraded"] && !$conflicted
? "complete"
: "partial",
"terms" => $ordered_terms,
"assigned_ids" => $assigned,
"unresolved_ids" => $unresolved,
];
}
/**
* @return array<string, mixed>
*/
function taxonomy_export_unavailable_section(string $taxonomy): array
{
return [
"taxonomy" => $taxonomy,
"hierarchical" => taxonomy_export_hierarchical($taxonomy),
"status" => "unavailable",
"terms" => [],
"assigned_ids" => [],
"unresolved_ids" => [],
];
}
/**
* The four supported taxonomies keep their fixed shape regardless of any
* re-registration; anything else follows the live taxonomy object.
*/
function taxonomy_export_hierarchical(string $taxonomy): bool
{
if ($taxonomy === "category" || $taxonomy === "product_cat") {
return true;
}
if ($taxonomy === "post_tag" || $taxonomy === "product_tag") {
return false;
}
return taxonomy_exists($taxonomy) && is_taxonomy_hierarchical($taxonomy);
}
/**
* Registers one term observation. A duplicate observation with a different
* label keeps the first label but stops the node's ancestry from being
* trusted.
*
* @param array<string, array<string, mixed>> $nodes
*/
function taxonomy_export_register_node(array &$nodes, \WP_Term $term, string $taxonomy): void
{
$id = (string) $term->term_id;
$label = product_export_text($term->name);
if (isset($nodes[$id])) {
$node = $nodes[$id];
if (!$node["label_conflict"] && $node["label"] !== $label) {
$node["label_conflict"] = true;
$node["parent_known"] = false;
$nodes[$id] = $node;
}
return;
}
$nodes[$id] = [
"id" => $id,
"label" => $label,
"parent_id" => null,
"parent_known" => false,
"url" => taxonomy_export_term_url($term, $taxonomy),
"walked" => false,
"label_conflict" => false,
];
}
function taxonomy_export_valid_url(string $url): bool
{
$scheme = wp_parse_url($url, PHP_URL_SCHEME);
$host = wp_parse_url($url, PHP_URL_HOST);
return is_string($scheme)
&& in_array(strtolower($scheme), ["http", "https"], true)
&& is_string($host)
&& $host !== ""
&& wp_parse_url($url, PHP_URL_USER) === null
&& wp_parse_url($url, PHP_URL_PASS) === null;
}
function taxonomy_export_term_url(\WP_Term $term, string $taxonomy): ?string
{
$link = get_term_link($term, $taxonomy);
return is_string($link) && $link !== "" && taxonomy_export_valid_url($link)
? $link
: null;
}
/**
* Request-local term cache around get_term(), so parent walks do not repeat
* queries. The fetched term must belong to the requested taxonomy and carry
* the requested id to count as resolved.
*
* @return \WP_Term|null the term, or null when it is missing, foreign, or mismatched
*/
function taxonomy_export_term_lookup(
int $term_id,
string $taxonomy
): ?\WP_Term {
$GLOBALS["donoharm_searchcue_taxonomy_terms"] ??= [];
$cache = &$GLOBALS["donoharm_searchcue_taxonomy_terms"];
$key = $taxonomy . ":" . $term_id;
if (!array_key_exists($key, $cache)) {
$term = get_term($term_id, $taxonomy);
$cache[$key] =
taxonomy_export_valid_term($term, $taxonomy) && $term->term_id === $term_id
? $term
: null;
}
return $cache[$key];
}
/**
* Walks one assigned term's ancestor chain, registering nodes and observing
* parent edges. Parent zero is a known root. Missing parents, terms from
* another taxonomy, and cycles leave the supplied parent id in place with
* unknown ancestry; conflicting observations never overwrite the first one.
*
* @param array<string, array<string, mixed>> $nodes
*/
function taxonomy_export_walk_ancestors(
\WP_Term $term,
string $taxonomy,
array &$nodes
): void {
$visited = [(string) $term->term_id => true];
$current = $term;
while (true) {
$current_id = (string) $current->term_id;
$parent_id = $current->parent;
if ($parent_id === 0) {
taxonomy_export_apply_parent_edge($nodes, $current_id, null, true);
break;
}
$parent_key = (string) $parent_id;
if (isset($visited[$parent_key])) {
// cycle: the supplied parent id is retained but its ancestry is unknown
taxonomy_export_apply_parent_edge($nodes, $current_id, $parent_key, false);
break;
}
$parent_term = taxonomy_export_term_lookup($parent_id, $taxonomy);
if ($parent_term === null) {
taxonomy_export_apply_parent_edge($nodes, $current_id, $parent_key, false);
break;
}
if (
!taxonomy_export_apply_parent_edge($nodes, $current_id, $parent_key, true)
) {
// conflicting edge observation; the first definition stays in place
break;
}
taxonomy_export_register_node($nodes, $parent_term, $taxonomy);
$visited[$parent_key] = true;
$current = $parent_term;
}
}
/**
* Records one parent-edge observation. Returns true when the observation
* leaves the node's parent fully known, false when it repeats an unresolved
* edge or conflicts with a previous observation. The first definition is
* retained and a later observation never restores a downgraded or distrusted
* edge.
*
* @param array<string, array<string, mixed>> $nodes
*/
function taxonomy_export_apply_parent_edge(
array &$nodes,
string $term_id,
?string $parent_id,
bool $parent_known
): bool {
$node = $nodes[$term_id];
if (!$node["walked"]) {
$node["parent_id"] = $parent_id;
$node["parent_known"] = $parent_known && !$node["label_conflict"];
$node["walked"] = true;
$nodes[$term_id] = $node;
return $node["parent_known"];
}
if ($node["parent_id"] === $parent_id && $node["parent_known"] === $parent_known) {
return $node["parent_known"];
}
$node["parent_known"] = false;
$nodes[$term_id] = $node;
return false;
}
/**
* Re-derives whether one assigned id has a fully resolved, acyclic ancestor
* chain against the final node observations.
*
* @param array<string, array<string, mixed>> $nodes
*/
function taxonomy_export_assigned_id_resolved(string $assigned_id, array $nodes): bool
{
$visited = [$assigned_id => true];
$current_id = $assigned_id;
while (true) {
$node = $nodes[$current_id] ?? null;
if (!is_array($node) || !$node["walked"] || !$node["parent_known"]) {
return false;
}
$parent_id = $node["parent_id"];
if ($parent_id === null) {
return true;
}
if (isset($visited[$parent_id])) {
return false;
}
$visited[$parent_id] = true;
$current_id = $parent_id;
}
} GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print such an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
SearchCue WordPress Plugin
Copyright (C) 2026 DoNoHarm - Bauer & Eide GbR
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>. # SearchCue - WordPress Plugin
SearchCue is a hosted site search service with results that appear as visitors type, typo-tolerant matching, semantic search, and optional cited AI answers grounded in indexed content from your site.
The SearchCue WordPress plugin connects supported public pages, WooCommerce products, documents, and image content to that hosted search service. It can also notify SearchCue when public content changes, helping the hosted index stay current without building a search index in the WordPress database.
Please note that SearchCue is a paid service. You can try it for free for 15 days, and there are no bad feelings if you decide it isn't for you. We won't try to upsell you or trick you to become a customer. If you want more information about what the service costs before getting started, please check our [website here](https://searchcue.com).
## Plugin features
- Adds the shared inline SearchCue loader into the `<head>` of every public page and excludes it from WP Rocket minification
- Adds a **SearchCue** admin menu page to configure SearchCue
- Optional automatic pings to SearchCue when content changes in order to keep the search index up to date
- Exposes the published, visible WooCommerce catalog to the connected SearchCue backend through an authenticated endpoint
- Exposes published posts' categories and tags, with identities and parent relationships, to the connected SearchCue backend through an authenticated endpoint
- No third-party PHP dependencies
- The search index is hosted by SearchCue rather than built or queried in the WordPress database
## SearchCue features
- Typeahead keyword search
- AI answers and conversational search
- Design editor making it easy to adapt the search interface to the design of your site
- Search analytics
- Advanced tuning controls
- The ability to upload additional custom data you want to make searchable
- Rich WooCommerce product and variation results with prices, images, attributes, and faceted filters
## Installation
### From ZIP (for production use)
1. Download the plugin ZIP
2. In WordPress admin, go to **Plugins > Add New > Upload Plugin**
3. Upload the ZIP and activate
## Configuration
1. Open **SearchCue** from the WordPress admin menu
2. Enter your email and follow the sign-in link to connect the site
3. Keep **Automatic updates** checked if you want SearchCue to re-index changed pages automatically
4. Optionally set a different Site ID under **Advanced settings**
5. Save changes
Connections use WordPress's configured site address, not the current language's URL. Language changes retain the Site ID and API key. Existing language-path bindings and recognised WPML language-domain bindings migrate automatically, including hostname-only language settings. Setup returns to the configured origin and preserves the WordPress admin path. A canonically bound connection requires reconnection after moving to an unrelated origin.
If the site is already connected through another SearchCue account, SearchCue verifies site control before emailing you a masked account hint and recovery instructions. Sign out of your current SearchCue account, return to WordPress **SearchCue**, and request a fresh sign-in link using the original email address. Confirm the connection to reuse the existing site. Reply to the recovery email if you cannot identify or access that account.
The embed script will appear in the `<head>` of all public pages that call `wp_head()`.
The loader downloads the embed without delaying the page's load event. If the site's Content Security Policy prevents fetch or local execution, a native script request starts only after the page has loaded. It preserves nonce, integrity, and referrer-policy restrictions. Harmless URL transformations, such as changed cache-busting parameters, protocol-relative URLs, hostname case, and default ports, are normalised to the configured SearchCue bundle URL. Ordinary HTTP forms of the HTTPS endpoint are upgraded, not fetched over HTTP.
WP Rocket must load the embed from SearchCue's CDN rather than retain its own minified copy. After updating an existing installation, purge any previously generated WP Rocket minified scripts and affected page caches.
Recognised WP Rocket delayed script tags are rebuilt as inert inline loaders. WP Rocket still decides when they execute. Deliberately removed tags, consent-blocked types, unexpected hosts or paths, and ambiguous markup are not forced to run. Rejected output contains a fixed diagnostic comment, not the filtered markup or credentials.
The WordPress settings page shows **Search on your website** only for a current rendering failure after setup. The section uses SearchCue's semantic error colours with the existing typography, actions, and disclosure styles. It explains the problem in plain language and offers a link to open the site. First-run setup uses the existing connection form, without a separate error card.
**Details for support** contains a selectable, copyable report with the SearchCue version, effective public Site ID, credential-free canonical site address, fixed reason, and check limitations. It lists other installed plugins with versions and active, inactive, network-active, or must-use status. Installed drop-ins are listed without claiming they are active. These are diagnostic candidates, not proof of fault. The inventory is collected when the settings page is loaded, not stored with the rendering result. **Copy diagnostics** uses the browser clipboard API, falls back to native text selection and copying, and leaves the text selected for manual copying if clipboard access fails. The report excludes API keys, connection tokens, private configuration, and absolute file paths.
The local rendering record is bound to the Site ID, plugin version, and configured site address; unchanged results do not trigger repeated database writes. Successful rendering clears the warning without adding a success notice. Unobserved or stale results stay silent. This records PHP output only, not cached pages, later HTML transformations, or browser execution.
After correcting a problem, clear page caches, open a public page, and refresh SearchCue settings to check the new result.
Automatic updates send affected public page and product URLs when an image changes, rather than the image file URL. The plugin resolves upload parents, featured images, WooCommerce galleries, saved content, gallery shortcodes, and synced blocks, including encoded image filenames and bulk changes. Parentless images trigger updates when referenced or when they have an enabled public HTML attachment page. Public document attachments retain their direct URLs. Draft, private, and password-protected pages are excluded. References generated exclusively by themes, CSS, options, or plugin-specific fields cannot be inferred from WordPress's standard image metadata.
Changed URLs are stored in a local delivery queue, separately from the hosted search index. WP-Cron combines and deduplicates complete queued batches every minute while Automatic updates is enabled, authenticates delivery using the current stored API key, and retains them until SearchCue acknowledges receipt. Normal deliveries contain at most 1,000 URLs and 1 MiB of URL text. Authentication failures, throttling, and temporary failures pause delivery rather than attempting every remaining batch. Ordinary post saves do not wait for remote HTTP. Delivery depends on WordPress cron running; configure an external cron runner if WP-Cron is disabled or site traffic is infrequent.
Image edits persist their old and new reference information instead of scanning the site during a save. The same cron job combines up to 100 immutable change batches per pass and processes multiple 50-post chunks within a shared 20-second scanning and delivery budget, resuming where it stopped. Each invocation examines at most 5,000 stored post IDs across at most five passes. Generated URLs are deduplicated and delivered in the same run when time permits. New changes arriving during a scan receive a complete pass in a later cron request, preserving updates behind the current cursor without reusing stale post caches. Large sites may still need several cron runs; existing URL notifications continue during that work.
Unchanged core post saves ignore modification timestamps and editor locks. Custom-field changes made alongside a save, changed categories or tags, WooCommerce product update events, and media changes still notify SearchCue. Unrelated frontend metadata writes, such as view counters, do not trigger crawling.
Ordinary WooCommerce exports contain 10 parent products per page, ordered by ID, with each parent's visible variations kept together. Incremental exports select a known parent or resolve a public product URL locally, returning an explicit selection acknowledgement so a removal cannot be confused with an unsupported selector. The existing 1,000-variation safety ceiling per parent remains. GTranslate fetches one parent family per request. Stock and price changes update every cached language while reusing valid translated text.
Stock metadata includes the source quantity only when WooCommerce stock management is enabled globally and for the product or its parent. Unknown quantities remain unknown, zero and negative quantities are retained, and backorder policy is separate from physical stock. Variation records retain the stock-owning product ID, so shared parent quantities are not added together or presented as a quantity for every variant. SearchCue records when each source response was fetched; partial refreshes retain the previous observation time for untouched products. Indexed stock is a snapshot, not a live checkout availability or reservation check.
Direct WooCommerce stock-change hooks use the same queued automatic updates as product saves. Freshness still depends on Automatic updates, WP-Cron, successful notification delivery, and the hosted crawl completing; the one-minute delivery schedule is not a one-minute inventory guarantee. Existing installations need a plugin release containing the export change and a subsequent product recrawl before quantities become available. Rebuilding an old index alone cannot recover quantities that were never exported.
Plain `?product=...` URLs also resolve when WordPress uses a static homepage. Database query failures return HTTP 503 without acknowledging a removal, so the previously indexed product family can be retained.
Blog taxonomy lookups accept only selected public posts, not a whole-blog enumeration. Term edits capture old and new affected permalinks for up to 100 posts in a hierarchy containing at most 100 terms; larger taxonomies or affected post sets request broad reconciliation without loading the full hierarchy. Partial crawls skip screenshots.
Normal visitor and post-save hooks do not make synchronous SearchCue HTTP calls. Passive capability and Password Protected metadata checks run through coalesced cron jobs, not while rendering settings. Analytics, capability reporting, and IP-list requests have two-second transport timeouts; initial connection and explicit setup actions still wait for their results. WordPress cron normally uses a separate loopback request, but still consumes a PHP worker, CPU, and database capacity. Older WordPress versions can also pay the spawn cost before sending the page; alternate cron can continue on the originating worker after flushing output. Output buffering and a saturated worker pool can still delay visitors. A low request rate is not a guarantee of low server load. See the [shared-hosting development requirements](development.md#shared-hosting-request-cost).
## External Service and Privacy
The plugin requires the hosted SearchCue service; it has no third-party PHP dependencies. A SearchCue account is required, and continued service after the free trial requires a paid subscription.
Once connected, the plugin loads SearchCue's executable browser embed, sends public content updates when enabled, and exchanges setup and analytics data with SearchCue. SearchCue also pulls the WooCommerce catalog through an authenticated endpoint, including product names, descriptions, public URLs, product and variation IDs, SKUs, prices and currency, sale and stock status, managed stock quantities and their owning product IDs, backorder policy, categories and tags with their identities and parent relationships, attributes, images, and timestamps. Only published, visible catalog data is shared. The plugin does not share drafts, hidden or password-protected products, orders, customers, or download files. SearchCue also pulls blog taxonomy through an authenticated endpoint, including public post URLs, post IDs, and the identities, names, and parent relationships of the categories and tags assigned to published posts, including ancestor categories. Only published, public posts are shared, never post content, drafts, private posts, password-protected posts, or user data. Automatic updates sends the parent product URL after product or variation changes, the public URL of a post or product after its category or tag assignments change, and the home page URL after a category or tag is edited or deleted. The embed processes visitor searches and interaction metrics. The settings page uses locally bundled SearchCue font files under the SIL Open Font License 1.1.
See the complete data-flow disclosure in [`readme.txt`](readme.txt), the [SearchCue terms and conditions](https://searchcue.com/terms/), and the [SearchCue privacy policy](https://donoharm.world/privacy-policy/searchcue/).
## Uninstalling
Disabling Automatic updates or deactivating the plugin pauses delivery and preserves pending notifications. Deleting the plugin through WordPress runs `uninstall.php`, which removes stored options, queued notifications, and scheduled plugin jobs.
## License
This plugin is licensed under the [GNU General Public License v2.0 or later](https://www.gnu.org/licenses/gpl-2.0.html) (GPLv2+), which is the same license as WordPress itself. This is required for plugins published in the [WordPress.org Plugin Directory](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/#1-plugins-must-be-compatible-with-the-gnu-general-public-license). === SearchCue: AI Site Search & Answers ===
Contributors: donoharm
Tags: search, site search, ai search, chatbot, agent
Requires at least: 6.0
Tested up to: 7.1
Stable tag: 0.5.7
Requires PHP: 7.4
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Fast, typo-tolerant site search with cited AI answers for your pages, products, and documents. Fast setup, no code, and no local search index.
== Description ==
Give your visitors instant, typo-tolerant search, combined with AI answers that are based on only your content. SearchCue takes minutes to install, and since you don't have to hold the local index in your database, it keeps your backend clean.
[Live demo](https://searchcue.com/) | [Pricing](https://searchcue.com/#pricing) | [Docs](https://searchcue.com/docs/) | [Privacy policy](https://donoharm.world/privacy-policy/searchcue/)
**What you get**
Exceptional search:
* Real-time, typo-tolerant results that appear as visitors type
* Search across pages, PDFs, Word documents, PowerPoint presentations, and other supported documents
* OCR and image understanding that can make image content discoverable when useful text is missing
* Rich WooCommerce product and variation results with prices, sale and stock status, images, tags, and more
Complete answers and not just links:
* Optional cited AI answers generated from your indexed site content, with links to the sources used
* Semantic matching that can find relevant pages even when a query uses different wording
Full control:
* Search lenses for managing what content is searchable in different parts of your website or based on the users language
* Built-in search analytics showing popular terms, clicks, and searches that returned no results
* A hosted design editor for adapting the search interface without changing the plugin or your theme
* Optional automatic reindexing of public pages, posts, or products as you make changes
**Works beyond WordPress**
The same SearchCue account can also provide search for Webflow, Shopify, Squarespace, and hand-built sites.
**Pricing**
The plugin is free and GPL-licensed. The hosted service starts with a 15-day free trial, no credit card required. After that, plans start at €9 per month and include an AI answer allowance. You can set a hard monthly AI budget; once it is reached, SearchCue falls back to keyword results at no extra cost. Paying for AI answers beyond the allowance is always optional. See the [current SearchCue pricing and plan limits](https://searchcue.com/#pricing).
== Screenshots ==
1. One search window for keyword results and cited AI answers
2. Easily change colours, layout, position and more in the design editor
== Installation ==
1. Install and activate SearchCue through the WordPress Plugins screen.
2. Open SearchCue in WordPress admin and enter your email address.
3. Follow the magic link to sign up or sign in and connect the site.
4. SearchCue crawls the site and enables its floating search launcher. It can also use your existing search input or any link to `/search`.
If Password Protected is enabled, first allow the SearchCue IP addresses shown in the plugin, either automatically or manually. Without that access, connection verification and crawling will fail.
== Frequently Asked Questions ==
= What are cited AI answers? =
SearchCue can answer a visitor's question using indexed content from your site and link to the sources used. AI answers can be incomplete or inaccurate, so visitors should use the cited sources to verify important information.
= What does it cost? =
The plugin is free. The hosted service has a 15-day free trial with no credit card. Paid plans currently start at €9 per month. Current prices and limits are listed on the [SearchCue pricing page](https://searchcue.com/#pricing).
= Does it slow down my site or database? =
The browser embed downloads without delaying the page's load event. If the site's Content Security Policy prevents this loading route, a native script request starts only after the page has loaded. SearchCue does not build or query a local search index. The plugin stores settings and pending updates in WordPress. Ordinary visitor and post-save hooks do not wait for SearchCue HTTP requests. Background updates and authenticated content exports still use PHP workers, CPU, and the database. WordPress cron normally runs in a separate loopback request, but spawning it, output buffering, and competing for a small worker pool can still delay visitors. Use an external cron runner if WP-Cron is disabled or if your host needs scheduled background work separated from page requests.
= Does it answer unrelated general-knowledge questions? =
SearchCue is designed to answer questions from the content of your site. If your website does not support an answer, our system says as much rather than inventing an answer.
= Does it search images? =
SearchCue extract text content from images and also does its best to to derive an understanding of what your images contain. This can make image content discoverable even when captions or alt texts are missing.
= Do I need to change my theme or add code? =
No. SearchCue includes a floating search launcher and can take over a standard search input or any link to `/search`. As with other frontend plugins, the theme must call the standard WordPress `wp_head()` function.
= Why is the plugin configured but search is missing? =
If WordPress could not add SearchCue, a **Search on your website** section appears in WordPress admin. It explains what happened and what to check next. **Details for support** includes the check result and a list of other plugins, their versions, and whether they are active. Use **Copy diagnostics** to share the report with support. The list provides candidates to check, not proof that a plugin caused the problem. The section disappears when the problem clears. There is no extra message during normal operation or first-run setup.
SearchCue handles common changes made by performance plugins, including supported WP Rocket delayed loading. It respects cookie-consent rules and does not force blocked scripts to run.
After changing a setting, clear your website's cache. Open your website and try a search. Then refresh the SearchCue settings page. A saved connection does not prove that search works for visitors, so always check the live website too.
= Does it search PDFs and office documents? =
Yes. Supported PDFs, Word documents, PowerPoint presentations, and spreadsheets can be indexed alongside your pages, so manuals, specifications, and price lists can appear in results.
= Does it provide faceted filters or refinement menus? =
Yes. When WooCommerce is active, SearchCue indexes published, catalog-visible products and visible variations as rich product results. Their prices, sale and stock status, categories, tags, and attributes can power faceted filters and refinement menus. Ordinary page crawling remains available if the catalog cannot be retrieved.
= Does it index translated WooCommerce products from GTranslate? =
Yes, for active GTranslate Translation Delivery Network (TDN) installations that translate on the server and use language subdirectories on the same origin, such as `/en/`. Browser-only translation, language subdomains, and separate translation domains are not supported.
SearchCue detects the configured source and enabled target languages. Product names, long and short descriptions, visible attribute names and values, category and tag labels, image alternative text, and public URLs are translated. Product and variation IDs, SKUs, prices, currency, and stock status stay tied to the current WooCommerce catalogue. Category hierarchies remain separate for each language. Proven product-page URL aliases are replaced by the corresponding rich product result without merging different languages or losing variations. Retained aliases are reconciled before applying partial-crawl page limits, so they do not displace ordinary pages.
Editorial pages remain pages when they merely embed a product. Previously replaced product pages can fall back to retained page content when only the crawl request budget prevented a fresh fetch. Confirmed updates and removals still take precedence.
The hosted crawler requests only bounded product selections through GTranslate, using a per-request translation header rather than changing global JSON translation settings. Cached text is reused when its source content is unchanged, including during price and stock updates, which apply to every cached language. Adding or removing another target language does not refresh valid cached translations for unchanged targets. On full crawls, cached translations become eligible for refresh after seven days to pick up manual translation edits; missing translations take priority. Incremental updates do not refresh otherwise valid text solely because it is old.
The hosted crawler requests one parent product with its visible variations at a time, with at most 200,000 bytes of untranslated JSON per request. There is no cumulative byte allowance, product-count cap, or catalogue-wide translation deadline. WordPress enforces each request's size allowance, and the crawler retains individual network timeouts, rate limiting, and cancellation. Terminal page or document storage failures stop further product work rather than leaving translation requests running for a crawl that cannot be saved. Translation requests never follow redirects.
Tags are translated as a list, and older cached translations that omitted them are refreshed. Local language detection checks substantial translated prose against the requested language, including whole paragraphs made up of short sentences, and checks for copied source passages. Short labels, proper-name lists, inconclusive detection, and languages outside the detector's coverage are handled conservatively. The same checks apply to cached text. Transient request failures and wrong-language prose get up to three attempts per parent-language pair. Individual failures, including families too large for one translation request, do not stop the remaining requests. Original products and successful translations are retained. A failed refresh can retain a complete, valid cached translation; otherwise eligible crawled or retained HTML provides a page fallback for the missing translation. Translation access, safety, and cancellation failures still stop product fetching. These checks cannot guarantee idiomatic machine translation or correct wording in every short label. Translation requests may still incur charges under your GTranslate plan.
Crawl progress reports the language and batch being fetched, for example `Fetching products (EN)`, distinguishes cached text from translation refreshes, and reports the number of products with translation failures. Completed crawl results show the distinct parent-product count, including failed refreshes that retained cached text. Failed product IDs, languages, and reasons are saved with the crawl's product payload.
= Can I limit search to one section or language? =
Yes. Search lenses can include or exclude URL paths and select different content for sections or language paths on your site.
= Can I disable automatic content updates? =
Yes. Disable Automatic updates in the SearchCue settings page to stop the plugin from sending changed public URLs. Our content indexer will still crawl your website once per day.
= Can I use a password-protected staging site? =
The plugin detects Password Protected and lists SearchCue's public IP addresses. You can add them manually in Password Protected settings or explicitly enable automatic permission. Automatic permission checks the list hourly through WordPress cron, on settings-page visits when a check is due, or when you click Refresh IP list. WordPress cron needs traffic or a server-side scheduler. Managed access expires seven days after the last successful check. Turning it off, deactivating SearchCue, or uninstalling SearchCue removes managed access without changing IPs you entered manually.
Allowing access lets SearchCue crawl and index content behind Password Protected. The WordPress password does not protect SearchCue results or AI answers. Do not grant access to content that must stay private. Use SearchCue's Exclude and Search Lens controls to select content, not to authenticate visitors. Excluding content from one lens does not remove it from other lenses. Other password plugins, HTTP authentication, firewalls, proxies, or CDNs may need separate configuration.
= Does SearchCue work without an account? =
No. The plugin is an interface to the hosted SearchCue service. An account and an active trial or paid subscription are required for hosted search.
= Is this compatible with my WordPress version? =
The clean-install matrix covers WordPress 6.0 through 7.1 and PHP 7.4 through 8.5. It tests the PHP 7.4 floor on WordPress 6.0, every PHP 8 minor, and the available PHP 8.2 and 8.5 boundaries on WordPress 7.1 with debugging enabled.
== External services ==
SearchCue is a hosted site search and optional cited AI answer service. The plugin makes these external requests:
* Setup and admin analytics: When an administrator submits the connection form, the plugin sends the email address, site URL, WordPress admin return URL, and a random setup state to `https://central.searchcue.com`. It later exchanges a one-time handoff token there and uses the assigned API key to retrieve site search analytics. The plugin also reports supported integration capabilities. Initial setup reports them immediately; later changes and settings-page connection checks are coalesced through WordPress cron. Failed reports retry in the background, not on every admin request. Analytics and capability requests have two-second transport timeouts.
* Password Protected access: When an administrator opens SearchCue settings with Password Protected installed, the plugin schedules a public IP-list refresh through WordPress cron if the metadata is at least an hour old. Explicit permission and refresh actions fetch the list immediately, with a two-second transport timeout. The endpoint is `https://central.searchcue.com/site-search/integrations/wordpress/allowed-ips.json`. With automatic permission enabled, hourly WordPress cron also refreshes the list. This request needs no account and sends no content or account credentials. Fetching the list does not grant access without permission. The list includes the public addresses used by SearchCue's central and crawler nodes, not internal network addresses.
* Automatic content updates: While Automatic updates is enabled, the plugin sends the site hostname, an IndexNow verification key, and changed public URLs to `https://central.searchcue.com` so SearchCue can refresh the index. The assigned API key authenticates these deliveries to SearchCue. It sends the parent product URL after product or variation changes and the affected post or product URL after taxonomy assignments change. Category and tag edits or deletions capture old and new affected public URLs for up to 100 posts; hierarchical discovery is also limited to taxonomies containing at most 100 terms. Larger sets request broad reconciliation through the home page URL. Unchanged core saves ignore modification timestamps and editor locks; custom-field changes made alongside a save remain eligible. Image changes notify affected public pages and products instead of image files, using upload parents, featured images, WooCommerce galleries, saved content, gallery shortcodes, and synced blocks. Public HTML attachment pages are notified when enabled; unreferenced parentless images without these pages do not trigger updates. Public document attachments keep their direct URLs. Theme-generated references, CSS, options, and plugin-specific fields are outside this standard reference mapping. Changed URLs and image-reference snapshots are stored in non-autoloaded WordPress options. A once-per-minute cron job combines and deduplicates complete URL batches and resolves image references in resumable 50-post chunks, preserving old and new paths. It can process multiple chunks and change batches within a shared 20-second scanning and delivery budget, with at most 5,000 stored post IDs examined per run. New changes arriving during a scan receive a complete pass in a later cron request, without reusing stale post caches. Generated URLs are delivered in the same run when time permits; unacknowledged batches remain queued for retry. Disabling Automatic updates or deactivating the plugin pauses delivery and scanning; deleting the plugin removes queued work and scheduled jobs. Sites with WP-Cron disabled or infrequent traffic need an external cron runner.
* WooCommerce catalogue indexing: SearchCue requests WooCommerce products and visible variations from an authenticated plugin endpoint, with 10 parent products per ordinary page and each parent's variations kept together. Incremental requests can select a known product family or resolve a public product URL without enumerating the catalogue. The plugin explicitly acknowledges the selection, including confirmed removals. Product, metadata, and term caches are primed in batches to reduce repeated database lookups. The plugin returns names, descriptions, public URLs, product and variation IDs, SKUs, prices and currency, sale and stock status, managed stock quantities and their owning product IDs, backorder policy, categories and tags with their identities and parent relationships, attributes, images, and timestamps. Quantities are exported only when WooCommerce stock management is enabled; unknown, zero, negative, and shared parent quantities remain distinct. This indexed snapshot is not a live checkout inventory or reservation check. Only published, visible catalogue data is returned. The plugin excludes drafts, hidden or password-protected products, orders, customers, and download files.
* GTranslate product translations: For supported server-side, same-origin subdirectory installations, SearchCue requests selected public product text through the site's existing GTranslate TDN. The authenticated endpoint exposes the active translation configuration and a bounded text-only projection; orders, customers, hidden attributes, and private products are excluded. This uses the site's existing GTranslate service and does not change global JSON translation settings.
* Blog taxonomy indexing: SearchCue requests the categories and tags of selected published posts from an authenticated plugin endpoint, rather than enumerating every post. The plugin returns public post URLs, post IDs, and the identities, names, and parent relationships of the categories and tags assigned to each selected post, including ancestor categories. Only published, public posts are returned. The plugin excludes post content, drafts, private posts, password-protected posts, and user data.
* Visitor search: After the site is connected, public pages load executable code from `https://searchcue.com/search-embed.js`. That embed contacts `https://central.searchcue.com` and `https://edge.searchcue.com` to load settings, perform searches, and record visitor search terms, originating page paths, whether searches returned results, clicked result paths, and search performance and outcome metrics. If cited answers are enabled, visitor questions and relevant indexed excerpts are sent to SearchCue for cited answer generation.
Admin interface fonts are bundled with the plugin under the SIL Open Font License 1.1 and do not make external requests. The font license texts are included with the font files.
Like other web requests, these connections disclose network information such as the requester IP address and browser user agent. SearchCue uses the data to provide, secure, maintain, troubleshoot, and improve the service and to show search analytics to the site administrator. Site administrators are responsible for providing any visitor privacy notices and obtaining any consent required for their site.
Use of the service is governed by the [SearchCue Terms and Conditions](https://searchcue.com/terms/). Data handling is described in the [SearchCue Privacy Policy](https://donoharm.world/privacy-policy/searchcue/).
== Hosted service disclosure ==
SearchCue is a software-as-a-service (SaaS) product operated by DoNoHarm GmbH. This plugin connects your site to SearchCue, which crawls or receives public content, hosts the search index, and serves visitor searches and optional AI answers. A SearchCue account is required. The plugin does not provide search locally without the hosted service.
By connecting a site, an administrator agrees to the SearchCue terms and acknowledges the privacy policy:
* Service: [https://searchcue.com/](https://searchcue.com/)
* Terms and Conditions: [https://searchcue.com/terms/](https://searchcue.com/terms/)
* Privacy Policy: [https://donoharm.world/privacy-policy/searchcue/](https://donoharm.world/privacy-policy/searchcue/)
== Changelog ==
= 0.5.7 =
* Share WooCommerce stock quantities and backorder information with SearchCue, including stock shared by product variations.
* Trigger automatic reindexing when WooCommerce changes product or variation stock levels.
= 0.5.6 =
* Improve speed at which re-indexing takes place
= 0.5.5 =
* Improve compatibility with performance plugins, including WP Rocket, so harmless optimisations do not prevent SearchCue from loading.
* Add clear warnings and suggested next steps when the plugin cannot add SearchCue to a page.
= 0.5.4 =
* Fix unexpected disconnections on multilingual sites
* Improve setup guidance when a site is already connected under another email address.
= 0.5.3 =
* Lower system load during crawling to better support WordPress installations on resource-constrained hardware.
* Performance tuning for the WooCommerce integration.
= 0.5.2 =
* Fix a site-verification error that could prevent WordPress from connecting to SearchCue.
= 0.5.1 =
* Reduce server load during WooCommerce indexing
* Process image changes in smaller, resumable background batches
* Avoid unnecessary reindexing when posts are saved without content, custom-field, or taxonomy changes.
* Limit blog category and tag lookups to posts included in the crawl instead of scanning every post.
= 0.5.0 =
* Add support for indexing GTranslate-translated WooCommerce products on sites using server-side translation and same-site language subdirectories.
* Improve automatic reindexing when images change or are deleted, including images reused across pages, featured images, galleries, and synced blocks. Notify affected pages and products rather than image-only URLs.
* Make automatic content updates more reliable with a persistent queue, batched delivery, and retries through WordPress cron. Saving content no longer waits for a remote request.
* Exclude the SearchCue embed from WP Rocket minification so it continues to load and update directly from SearchCue.
= 0.4.0 =
* Add support for the Password Protected plugin, with optional automatic whitelisting of IP addresses needed by SearchCue to crawl and access your site
= 0.3.0 =
* Add authenticated exports of category and tag IDs, names, and category hierarchies for public blog posts.
* Include category and tag IDs and category hierarchies in WooCommerce product exports.
* Notify SearchCue when categories, tags, or their assignments change, respecting the Automatic updates setting.
= 0.2.2 =
* Reorganize plugin internals for readability without changing behavior.
= 0.2.1 =
* Keep WooCommerce product search availability synchronized with SearchCue.
* Reset stale connections when the WordPress site URL changes or the SearchCue connection is revoked.
= 0.2 =
* Add support for WooCommerce products.
= 0.1.2 =
* Fixes redirect to the SearchCue administrative interface after activation to help users complete the required setup.
= 0.1.1 =
* Declare compatibility with WordPress 7.1.
= 0.1.0 =
* Initial WordPress.org release. <?php
/**
* Plugin Name: SearchCue: AI Site Search & Answers
* Plugin URI: https://searchcue.com
* Description: Fast, typo-tolerant site search with cited AI answers for your pages, products, and documents. Fast setup, no code, and no local search index.
* Version: 0.5.7
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: DoNoHarm
* Author URI: https://donoharm.world
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: searchcue
*
* @package DoNoHarm_SearchCue
*/
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined("ABSPATH")) {
exit();
}
const VERSION = "0.5.7";
const PLUGIN_FILE = __FILE__;
const OPTION_NAME = "donoharm_searchcue_options";
const ACTIVATION_REDIRECT_OPTION = "donoharm_searchcue_activation_redirect";
const SETTINGS_GROUP = "donoharm_searchcue_settings";
const SETTINGS_PAGE = "searchcue";
const SETTINGS_SECTION = "donoharm_searchcue_main";
const EMBED_SCRIPT_HANDLE = "donoharm-searchcue-embed";
const ADMIN_ASSET_HANDLE = "donoharm-searchcue-admin";
const EMBED_SCRIPT_URL = "https://searchcue.com/search-embed.js";
const SEARCHCUE_APP_ENDPOINT = "https://searchcue.com";
const INDEXNOW_ENDPOINT = "https://central.searchcue.com/indexnow";
const WORDPRESS_INTEGRATION_ENDPOINT = "https://central.searchcue.com/site-search/integrations/wordpress";
const CRAWLER_USER_AGENT = "DoNoHarm Crawler/1.0 (+https://crawler.donoharm.world)";
const STAGING_SERVICE_ENDPOINT = "https://staging.searchcue.com";
const PRODUCT_EXPORT_PAGE_SIZE = 10;
const PRODUCT_EXPORT_MAX_VARIATIONS = 1000;
const PRODUCT_EXPORT_MAX_PAGES = 100000;
require_once __DIR__ . "/includes/options.php";
require_once __DIR__ . "/includes/service.php";
require_once __DIR__ . "/includes/taxonomy-export.php";
require_once __DIR__ . "/includes/product-export.php";
require_once __DIR__ . "/includes/blog-taxonomy-export.php";
require_once __DIR__ . "/includes/assets.php";
require_once __DIR__ . "/includes/setup.php";
require_once __DIR__ . "/includes/analytics.php";
require_once __DIR__ . "/includes/admin.php";
require_once __DIR__ . "/includes/protection.php";
require_once __DIR__ . "/includes/indexnow.php";
function bootstrap(): void
{
add_action("wp_enqueue_scripts", __NAMESPACE__ . '\\enqueue_embed');
add_filter("script_loader_tag", __NAMESPACE__ . '\\render_embed_script', PHP_INT_MAX, 2);
add_action("admin_enqueue_scripts", __NAMESPACE__ . '\\enqueue_admin_assets');
add_action("template_redirect", __NAMESPACE__ . '\\serve_indexnow_keyfile');
add_action("admin_menu", __NAMESPACE__ . '\\register_admin_page');
add_action("admin_init", __NAMESPACE__ . '\\register_settings');
add_action("admin_init", __NAMESPACE__ . '\\maybe_complete_setup_from_handoff');
add_action("admin_init", __NAMESPACE__ . '\\maybe_sync_source_features');
add_action(SOURCE_FEATURES_CRON_HOOK, __NAMESPACE__ . '\\sync_source_features');
add_action("admin_init", __NAMESPACE__ . '\\maybe_redirect_after_activation');
add_action("init", __NAMESPACE__ . '\\reconcile_protection_binding', 0);
add_action("update_option_home", __NAMESPACE__ . '\\clear_protection_permission');
add_action(PROTECTION_CRON_HOOK, __NAMESPACE__ . '\\cron_refresh_protection_ips');
add_action(PROTECTION_METADATA_CRON_HOOK, __NAMESPACE__ . '\\refresh_protection_ips');
add_action("admin_post_donoharm_searchcue_save_protection", __NAMESPACE__ . '\\save_protection_permission');
add_action("admin_post_donoharm_searchcue_refresh_protection", __NAMESPACE__ . '\\manually_refresh_protection_ips');
add_filter("password_protected__allowed_ip_ranges", __NAMESPACE__ . '\\allow_protection_ip', 10, 3);
add_filter("rest_authentication_errors", __NAMESPACE__ . '\\protection_rest_error', PHP_INT_MAX);
add_action("admin_post_donoharm_searchcue_send_setup_link", __NAMESPACE__ . "\\send_setup_link");
add_action("wp_ajax_donoharm_searchcue_setup_status", __NAMESPACE__ . "\\ajax_setup_status");
add_action("wp_ajax_donoharm_searchcue_analytics", __NAMESPACE__ . "\\ajax_search_analytics");
add_action("rest_api_init", __NAMESPACE__ . "\\register_product_export_route");
add_action("rest_api_init", __NAMESPACE__ . "\\register_blog_taxonomy_lookup_route");
register_update_hooks();
register_activation_hook(PLUGIN_FILE, __NAMESPACE__ . "\\activate");
register_deactivation_hook(PLUGIN_FILE, __NAMESPACE__ . "\\deactivate_protection");
register_deactivation_hook(PLUGIN_FILE, __NAMESPACE__ . "\\deactivate_indexnow");
register_deactivation_hook(PLUGIN_FILE, __NAMESPACE__ . "\\deactivate_source_features");
}
bootstrap(); <?php
/**
* SearchCue uninstall handler.
*
* Runs when the plugin is deleted through the WordPress admin.
* Removes all plugin options from the database.
*
* @package DoNoHarm_SearchCue
*/
declare(strict_types=1);
namespace DoNoHarm\SearchCue;
if (!defined('WP_UNINSTALL_PLUGIN')) {
exit;
}
$donoharm_searchcue_uninstall_site = static function (): void {
global $wpdb;
delete_option('donoharm_searchcue_options');
delete_option('donoharm_searchcue_activation_redirect');
delete_option('donoharm_searchcue_embed_status');
delete_option('donoharm_searchcue_protection_settings');
delete_option('donoharm_searchcue_protection_cache');
wp_clear_scheduled_hook('donoharm_searchcue_refresh_protection_ips');
wp_clear_scheduled_hook('donoharm_searchcue_refresh_protection_metadata');
wp_clear_scheduled_hook('donoharm_searchcue_deliver_indexnow');
delete_option('donoharm_searchcue_source_features_checked_at');
wp_clear_scheduled_hook('donoharm_searchcue_sync_source_features');
// The plugin is not loaded during uninstall; use its fixed durable-work names directly.
foreach (['donoharm_searchcue_indexnow_batch_', 'donoharm_searchcue_image_batch_'] as $prefix) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerate immutable rows, including those hidden by stale option caches.
$batches = $wpdb->get_results($wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like($prefix) . '%'
));
foreach ($batches as $batch) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Remove rows independently of the option cache.
$wpdb->delete($wpdb->options, ['option_name' => $batch->option_name], ['%s']);
wp_cache_delete($batch->option_name, 'options');
}
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Progress must be removed even with a stale negative option cache.
$wpdb->delete($wpdb->options, ['option_name' => 'donoharm_searchcue_image_progress'], ['%s']);
wp_cache_delete('donoharm_searchcue_image_progress', 'options');
wp_cache_delete('notoptions', 'options');
};
if (is_multisite()) {
$donoharm_searchcue_site_ids = get_sites(array('fields' => 'ids', 'number' => 0));
foreach ($donoharm_searchcue_site_ids as $donoharm_searchcue_site_id) {
switch_to_blog((int) $donoharm_searchcue_site_id);
$donoharm_searchcue_uninstall_site();
restore_current_blog();
}
} else {
$donoharm_searchcue_uninstall_site();
}