Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 | 7x 121x 7x 7x 7x 7x 7x 7x 7x 97x 73x 24x 3x 21x 4x 17x 63x 36x 27x 27x 27x 1x 26x 26x 9x 17x 50x 35x 15x 15x 15x 14x 14x 1x 55x 22x 8x 2x 6x 2x 4x 47x 42x 5x 134x 8x 126x 10x 2x 14x 14x 12x 12x 1x 225x 147x 78x 78x 91x 91x 78x 96x 96x 75x 21x 21x 21x 21x 22x 14x 22x 22x 21x 7x 43x 215x 86x 86x 43x 43x 43x 43x 43x 43x 43x 43x 43x 37x 6x 3x 3x 1x 2x 17x 1x 16x 1x 15x 15x 13x 4x 11x 8x 3x 8x 43x 43x 43x 43x 43x 43x 43x 43x 43x 43x 43x 43x 6x 2x 4x 5x | /**
* Pure helpers for shaping v1.3 Jetpack Search results into the flat form the
* Interactivity API templates consume. Extracted from store/index.js so they
* can be unit-tested without bootstrapping the IAPI runtime.
*
* Note: this module is loaded inside the Interactivity API view bundle, where
* `@wordpress/i18n` is not available — the IAPI runtime rejects WP-script
* imports. Strings here are deliberately untranslated; the editor preview
* (edit.js) composes its own localized versions via wp.i18n. Localizing the
* frontend strings is tracked separately so it lands once the IAPI build
* pipeline gains wp.i18n support.
*/
import { formatWpDate } from './wp-date-format';
// Module-scoped: the site's WP `date_format` setting is seeded once at store
// hydration via `setSeededDateFormat()` and read implicitly by `formatDate()`.
// Threading it through every `normalizeResult` call would be noise — it never
// changes for the lifetime of the page, and the Interactivity API store is a
// singleton, so module scope matches the data's actual lifetime.
let seededDateFormat = '';
/**
* Capture the site's WP `date_format` Settings option for use by subsequent
* `formatDate()` calls. Called once during store init from the
* `state.dateFormat` seed (see `Search_Blocks::seed_interactivity_state()`).
*
* @param {string} format - WP `date_format` token string, or an empty string
* when the seed is missing (falls back to the legacy
* `toLocaleDateString` shape).
*/
export function setSeededDateFormat( format ) {
seededDateFormat = typeof format === 'string' ? format : '';
}
const HTTP_SCHEME_PATTERN = /^https?:\/\//i;
const ANY_SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i;
const STRIP_TAGS_PATTERN = /<[^>]*>/g;
const NUMERIC_ENTITY_PATTERN = /&#(\d+);/g;
const HEX_ENTITY_PATTERN = /&#x([0-9a-f]+);/gi;
const NAMED_ENTITY_PATTERN = /&([a-z][a-z0-9]*);/gi;
// Minimum entity coverage needed to render API-supplied prices/titles as plain
// text — WPCOM hands back HTML-formatted prices like `<span>$</span>11.05`
// where `$` arrives as a numeric entity and ` ` is common between currency
// and amount. Anything outside this map (e.g. `©`) is left intact so it
// stays visible as a question for whoever sees it, rather than silently
// disappearing.
const NAMED_ENTITY_MAP = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
};
/**
* Ensure a URL is a browser-safe http(s)/protocol-relative reference. The
* v1.3 API returns hostless URLs (e.g. `example.com/foo/`) which we promote
* to a protocol-relative form (`//example.com/foo/`) so links inherit the
* page's scheme — matches the page protocol on http sites and avoids
* mixed-content downgrades on https sites. URLs with any other scheme
* (javascript:, data:, ftp:, …) are rejected so a compromised API response
* can't smuggle a non-http URL into an href.
*
* @param {string} raw - Raw URL from the API.
* @return {string} Safe URL or ''.
*/
export function toSafeUrl( raw ) {
if ( typeof raw !== 'string' || raw === '' ) {
return '';
}
if ( HTTP_SCHEME_PATTERN.test( raw ) ) {
return raw;
}
if ( ANY_SCHEME_PATTERN.test( raw ) ) {
return '';
}
return `//${ raw.replace( /^\/+/, '' ) }`;
}
/**
* Format an ISO date string for display on a search result card.
*
* When the module-scoped `seededDateFormat` is non-empty (the site's WP
* `date_format` option, captured once at store init via
* `setSeededDateFormat()`), the date is rendered through the PHP `date()`-style
* token parser in `wp-date-format.js` so result cards match the rest of the
* site (`F j, Y`, `Y-m-d`, `d/m/Y`, etc.) instead of `toLocaleDateString`'s
* fixed `{ year, month, day }` shape. The legacy short-form output is kept as
* the fallback so call sites that run before the seed (older tests, edge
* paths) keep working unchanged.
*
* The `dateFormat` override exists for test ergonomics — call sites pass
* nothing in production. Tests reach for the explicit override or for
* `setSeededDateFormat()` plus a reset; both are documented in
* `result-utils.test.js`.
*
* @param {string} iso - ISO-ish date string.
* @param {string} [locale] - BCP47 locale (e.g. `en-US`).
* @param {string} [dateFormat] - Override; defaults to the module-scoped seeded value.
* @return {string} Formatted date or ''.
*/
export function formatDate( iso, locale = 'en-US', dateFormat = seededDateFormat ) {
if ( ! iso ) {
return '';
}
const fixed = String( iso ).replace( /\.\d+/, '' ).replace( ' ', 'T' );
const d = new Date( fixed );
if ( isNaN( d.getTime() ) ) {
return '';
}
const resolvedLocale = locale || 'en-US';
if ( dateFormat ) {
return formatWpDate( d, dateFormat, resolvedLocale );
}
return d.toLocaleDateString( resolvedLocale, {
year: 'numeric',
month: 'short',
day: 'numeric',
} );
}
/**
* Derive a breadcrumb-style path from a permalink ("2023 › 01 › 13 › slug").
*
* @param {string} permalink - Full URL.
* @return {string} Breadcrumb string or ''.
*/
export function formatPath( permalink ) {
if ( ! permalink ) {
return '';
}
try {
// `toSafeUrl` promotes hostless API URLs to protocol-relative form
// (`//example.com/…`), but `new URL()` requires an explicit scheme and
// would throw otherwise. Pin a scheme for parsing only — it never
// reaches the DOM.
const resolved = permalink.startsWith( '//' ) ? `https:${ permalink }` : permalink;
const url = new URL( resolved );
const parts = url.pathname.split( '/' ).filter( Boolean ).map( decodeURIComponent );
return parts.join( ' › ' );
} catch {
return '';
}
}
/**
* Format the v1.3 `author` field for display on a result card. The API hands
* back a single author as a string and co-authored posts as an array; in the
* array case, mirror instant-search's behavior — comma-join up to three
* entries, and for >3 keep the first three and append an ellipsis so the
* meta row doesn't run away on heavily co-authored posts.
*
* Each name is run through `decodeEntities` because the API HTML-encodes
* punctuation in display names (`O’Brien`, `Jane & John`). The meta
* row binds via `data-wp-text` (textContent, not innerHTML), so without
* decoding the raw entity would render literally on the card.
*
* @param {*} value - `fields.author` from the v1.3 API response.
* @return {string} Display string, or '' when no author is present.
*/
export function formatAuthor( value ) {
if ( Array.isArray( value ) ) {
const names = value.map( v => decodeEntities( String( v ?? '' ).trim() ) ).filter( Boolean );
if ( names.length === 0 ) {
return '';
}
if ( names.length > 3 ) {
return names.slice( 0, 3 ).join( ', ' ) + '...';
}
return names.join( ', ' );
}
if ( typeof value !== 'string' ) {
return '';
}
return decodeEntities( value.trim() );
}
/**
* Decode the small set of HTML entities the v1.3 API can place in
* text-rendered fields. WPCOM hands back WC-formatted prices and post titles
* with numeric entities (e.g. `$` for `$`) and a handful of named ones
* (`&`, ` `); everything else is left untouched.
*
* @param {string} s - Input string.
* @return {string} Input with the supported entities replaced.
*/
export function decodeEntities( s ) {
if ( typeof s !== 'string' || s === '' ) {
return s;
}
return s
.replace( NUMERIC_ENTITY_PATTERN, ( _, n ) => safeFromCodePoint( Number( n ) ) )
.replace( HEX_ENTITY_PATTERN, ( _, h ) => safeFromCodePoint( parseInt( h, 16 ) ) )
.replace( NAMED_ENTITY_PATTERN, ( m, name ) => {
const value = NAMED_ENTITY_MAP[ name.toLowerCase() ];
return value === undefined ? m : value;
} );
}
/**
* `String.fromCodePoint` throws on out-of-range integers; swallow the throw so
* a malformed numeric entity drops the bad bytes instead of crashing the whole
* sanitization pass.
*
* @param {number} n - Code point.
* @return {string} The character, or '' if the code point is invalid.
*/
function safeFromCodePoint( n ) {
try {
return String.fromCodePoint( n );
} catch {
return '';
}
}
/**
* Strip HTML tags from a string and decode any HTML entities the API may have
* encoded around them. Runs the strip+decode pair in a loop until the output
* is stable so nested tag constructions (e.g. `<<script>script>`, which a
* single strip pass would leave as `<script>`) and entity-encoded tags
* (`<script>`, which would survive a single strip pass) can't smuggle
* a tag through.
*
* @param {string} s - Input string.
* @return {string} Input with all tags removed and supported entities decoded.
*/
export function stripTags( s ) {
if ( typeof s !== 'string' || s === '' ) {
return s;
}
let prev;
let out = s;
do {
prev = out;
// The strip regex on its own is "incomplete multi-character
// sanitization" — `<<script>script>` collapses to `<script>` after a
// single pass, which CodeQL flags. The loop runs the strip+decode
// pair until the output stabilizes, so the security guarantee holds
// across nested or entity-encoded tags. The `keeps stripping until
// the output is free of tag-like markup` test in result-utils.test.js
// pins this behavior.
out = decodeEntities( out ).replace( STRIP_TAGS_PATTERN, '' );
} while ( out !== prev );
return out;
}
/**
* Tokenize a v1.3 `highlight` field into an array of pieces suitable for
* rendering with Interactivity `data-wp-each` / `data-wp-text`. Each piece
* is `{ text, isHighlight }`; the template wraps highlighted pieces in a
* styled element so the match still stands out visually. Splitting into
* text pieces (vs. binding innerHTML) keeps the XSS surface at zero — we
* never render API-supplied HTML, only textContent.
*
* Returns an empty array when the highlight field is missing/invalid so
* the template falls back to the plain `title` field.
*
* @param {*} highlight - Highlight value (array of snippet strings or a single string).
* @return {Array<{index: number, text: string, isHighlight: boolean}>} Pieces to render.
*/
export function tokenizeHighlight( highlight ) {
const raw = Array.isArray( highlight ) ? highlight.join( ' ' ) : highlight;
if ( typeof raw !== 'string' || raw === '' ) {
return [];
}
// Kept local so `exec()`'s stateful `lastIndex` cursor can't leak between
// calls — the regex is cheap to construct.
const markPattern = /<mark[^>]*>([\s\S]*?)<\/mark>/gi;
const pieces = [];
let lastIndex = 0;
let match;
while ( ( match = markPattern.exec( raw ) ) !== null ) {
if ( match.index > lastIndex ) {
pieces.push( {
text: stripTags( raw.slice( lastIndex, match.index ) ),
isHighlight: false,
} );
}
pieces.push( {
text: stripTags( match[ 1 ] ),
isHighlight: true,
} );
lastIndex = markPattern.lastIndex;
}
if ( lastIndex < raw.length ) {
pieces.push( {
text: stripTags( raw.slice( lastIndex ) ),
isHighlight: false,
} );
}
// data-wp-each needs a stable key per piece — index works because the
// pieces array is recomputed whenever the parent result changes.
return pieces.filter( p => p.text !== '' ).map( ( p, index ) => ( { ...p, index } ) );
}
/**
* First non-empty scalar from a possibly-array field. The v1.3 API hands
* back single values as bare strings and multi-valued meta fields as arrays;
* call sites only ever need the first entry.
*
* @param {*} value - Scalar or array.
* @return {*} Scalar or undefined.
*/
function firstScalar( value ) {
return Array.isArray( value ) ? value[ 0 ] : value;
}
/**
* Coerce a possibly-array numeric field into a finite number. Returns 0 when
* the value is missing or unparseable so downstream `hasRating` / `>= 0`
* checks stay simple.
*
* @param {*} value - Scalar or array.
* @return {number} Finite number, or 0.
*/
function toNumber( value ) {
const n = Number( firstScalar( value ) );
return Number.isFinite( n ) ? n : 0;
}
/**
* Build product-layout fields from a raw result. Returns empty/zero values
* when the result isn't a WooCommerce product so the template's
* `data-wp-bind--hidden` checks still hide the price/rating row.
*
* @param {object} fields - `raw.fields` from the v1.3 API response.
* @return {object} Product fields.
*/
function normalizeProductFields( fields ) {
// WPCOM returns WC prices as HTML fragments (e.g.
// `<span class="woocommerce-Price-amount"><span class="…-currencySymbol">$</span>11.05</span>`)
// because the legacy instant-search overlay renders them via
// `dangerouslySetInnerHTML`. Search Blocks bind these via `data-wp-text`,
// so the markup has to be flattened to plain text up front or the result
// card prints the raw `<span>` tags and `$` entity to the page.
const formattedPrice = stripTags( String( firstScalar( fields[ 'wc.formatted_price' ] ) ?? '' ) );
const formattedRegularPrice = stripTags(
String( firstScalar( fields[ 'wc.formatted_regular_price' ] ) ?? '' )
);
const formattedSalePrice = stripTags(
String( firstScalar( fields[ 'wc.formatted_sale_price' ] ) ?? '' )
);
const hasSalePrice =
formattedSalePrice !== '' &&
formattedRegularPrice !== '' &&
formattedSalePrice !== formattedRegularPrice;
const rating = Math.max(
0,
Math.min( 5, toNumber( fields[ 'meta._wc_average_rating.double' ] ) )
);
const reviewCount = Math.max(
0,
Math.trunc( toNumber( fields[ 'meta._wc_review_count.long' ] ) )
);
const ratingPercent = `${ Math.round( ( rating / 5 ) * 200 ) / 2 }%`;
return {
formattedPrice,
formattedRegularPrice,
formattedSalePrice,
hasSalePrice,
hasPrice: formattedPrice !== '' || formattedSalePrice !== '',
rating,
// Drives a CSS-only star bar via `data-wp-style--width`. Rounded to a
// half-star to match WC's display convention.
ratingPercent,
reviewCount,
reviewCountLabel: reviewCount > 0 ? `(${ reviewCount })` : '',
// Combined SR string for the rating row. The visible star bar and
// `(N)` count are aria-hidden, so this is the only signal screen
// readers get — needs both the rating and the review count to match
// instant-search's "Average rating … from N reviews" announcement.
ratingAriaLabel: buildRatingAriaLabel( rating, reviewCount ),
hasRating: rating > 0,
};
}
/**
* Compose the screen-reader announcement for the rating row.
*
* Strings are intentionally untranslated — see the file-level comment.
* Localization is tracked as a follow-up that needs IAPI build support
* for `@wordpress/i18n`.
*
* @param {number} rating - 0–5 average rating.
* @param {number} reviewCount - Number of reviews backing the rating.
* @return {string} Aria-label, or '' when the row should be hidden.
*/
function buildRatingAriaLabel( rating, reviewCount ) {
if ( rating <= 0 ) {
return '';
}
if ( reviewCount <= 0 ) {
return `${ rating } out of 5 stars`;
}
if ( reviewCount === 1 ) {
return `${ rating } out of 5 stars based on 1 review`;
}
return `${ rating } out of 5 stars based on ${ reviewCount } reviews`;
}
/**
* Derive the match hint from the highlight object.
*
* Returns '' when the title itself carries a highlighted fragment (no badge
* needed), 'comments' when a comment field matched but the title didn't, or
* 'content' when another non-title field matched but the title didn't.
*
* Mirrors the badge logic in the instant-search `SearchResultProduct`
* component. The v1.3 API uses 'comment' (singular) as the comment-field key.
*
* @param {object} highlight - `raw.highlight` from the API response.
* @param {Array} titlePieces - Pre-computed title pieces from tokenizeHighlight.
* @return {'content'|'comments'|''} Match hint value.
*/
export function deriveMatchHint( highlight, titlePieces ) {
// If the title itself has a highlighted fragment, no badge is needed.
if ( titlePieces.some( p => p.isHighlight ) ) {
return '';
}
if ( typeof highlight !== 'object' || highlight === null ) {
return '';
}
const entries = Object.entries( highlight );
if (
entries.some(
// The v1.3 API uses 'comment' (singular), not 'comments'.
( [ key, value ] ) => key === 'comment' && Array.isArray( value ) && value[ 0 ]?.length > 0
)
) {
return 'comments';
}
if (
entries.some(
( [ key, value ] ) =>
key !== 'title' && key !== 'comment' && Array.isArray( value ) && value[ 0 ]?.length > 0
)
) {
return 'content';
}
return '';
}
/**
* Normalize a v1.3 Jetpack Search result into the flat shape expected by the
* Interactivity API templates.
*
* `formatDate()` reads the site's WP `date_format` from module scope (set
* once at store init via `setSeededDateFormat()`), so this signature stays
* focused on per-result inputs rather than threading site-wide formatting
* context through every call.
*
* @param {object} raw - Raw result from the API.
* @param {string} [locale] - BCP47 locale for date formatting.
* @param {string} [searchQuery] - The query string the user actually typed. When
* empty (filter-only browse), the match-hint
* badge is suppressed — "Matches content" only
* makes sense in response to a typed query, and
* reads as misleading when every visible result
* was returned by a category/tag/price filter.
* @return {object} Flat result.
*/
export function normalizeResult( raw, locale = 'en-US', searchQuery = '' ) {
const fields = raw?.fields ?? {};
const highlight = raw?.highlight ?? {};
const permalink = toSafeUrl( fields[ 'permalink.url.raw' ] );
const rawImage = fields[ 'image.url.raw' ];
const imageSrc = Array.isArray( rawImage ) ? rawImage[ 0 ] : rawImage;
const imageUrl = toSafeUrl( imageSrc );
// The fallback title is rendered via `data-wp-text`, so any HTML or HTML
// entities the API returns (post titles can contain `&`, `’`,
// etc.) need to be flattened the same way the highlight pieces are —
// otherwise the raw entity markup leaks onto the result card.
const plainTitle = stripTags( String( fields[ 'title.default' ] ?? fields.title ?? '' ) );
const titlePieces = tokenizeHighlight( highlight.title );
const contentPieces = tokenizeHighlight( highlight.content );
const hasQuery = typeof searchQuery === 'string' && searchQuery.trim() !== '';
const matchHint = hasQuery ? deriveMatchHint( highlight, titlePieces ) : '';
return {
id: String( raw?.result_id ?? fields.post_id ?? permalink ),
title: plainTitle,
// Rendered when the API returns a highlighted title; template
// falls back to `title` when this is empty.
titlePieces,
hasTitlePieces: titlePieces.length > 0,
// Rendered when the API returns a highlighted content snippet;
// hidden when empty so the layout does not gain an empty gap.
contentPieces,
hasContentPieces: contentPieces.length > 0,
permalink,
path: formatPath( permalink ),
dateLabel: formatDate( fields.date, locale ),
authorLabel: formatAuthor( fields.author ),
imageUrl,
// Pre-built `url(...)` value so the product layout's CSS background
// image binds via `data-wp-style--background-image` without the
// template having to wrap a string at render time.
imageBackgroundImage: imageUrl ? `url(${ imageUrl })` : '',
// 'content' | 'comments' | '' — drives the "Matches content / Matches
// comments" hint badge shown on product cards when the title has no
// highlight but another field does.
matchHint,
matchHintIsComments: matchHint === 'comments',
...normalizeProductFields( fields ),
};
}
/**
* Count the total number of selected filter values across all filter keys.
*
* @param {object} activeFilters - Map of filterKey → array of selected values.
* @return {number} Total selected values; 0 if input is not a plain object.
*/
export function countActiveFilters( activeFilters ) {
if ( ! activeFilters || typeof activeFilters !== 'object' ) {
return 0;
}
return Object.values( activeFilters ).reduce(
( sum, v ) => sum + ( Array.isArray( v ) ? v.length : 0 ),
0
);
}
|