UNPKG

@wordpress/core-data

Version:
316 lines (293 loc) 7.69 kB
/** * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; import { decodeEntities } from '@wordpress/html-entities'; import { __ } from '@wordpress/i18n'; export type SearchOptions = { /** * Displays initial search suggestions, when true. */ isInitialSuggestions?: boolean; /** * Search options for initial suggestions. */ initialSuggestionsSearchOptions?: Omit< SearchOptions, 'isInitialSuggestions' | 'initialSuggestionsSearchOptions' >; /** * Filters by search type. */ type?: 'attachment' | 'post' | 'term' | 'post-format'; /** * Slug of the post-type or taxonomy. */ subtype?: string; /** * Which page of results to return. */ page?: number; /** * Search results per page. */ perPage?: number; }; export type EditorSettings = { /** * Disables post formats, when true. */ disablePostFormats?: boolean; }; type SearchAPIResult = { id: number; title: string; url: string; type: string; subtype: string; }; type MediaAPIResult = { id: number; title: { rendered: string }; source_url: string; type: string; }; export type SearchResult = { /** * Post or term id. */ id: number; /** * Link url. */ url: string; /** * Title of the link. */ title: string; /** * The taxonomy or post type slug or type URL. */ type: string; /** * Link kind of post-type or taxonomy */ kind?: string; }; /** * Fetches link suggestions from the WordPress API. * * WordPress does not support searching multiple tables at once, e.g. posts and terms, so we * perform multiple queries at the same time and then merge the results together. * * @param search * @param searchOptions * @param editorSettings * * @example * ```js * import { __experimentalFetchLinkSuggestions as fetchLinkSuggestions } from '@wordpress/core-data'; * * //... * * export function initialize( id, settings ) { * * settings.__experimentalFetchLinkSuggestions = ( * search, * searchOptions * ) => fetchLinkSuggestions( search, searchOptions, settings ); * ``` */ export default async function fetchLinkSuggestions( search: string, searchOptions: SearchOptions = {}, editorSettings: EditorSettings = {} ): Promise< SearchResult[] > { const searchOptionsToUse = searchOptions.isInitialSuggestions && searchOptions.initialSuggestionsSearchOptions ? { ...searchOptions, ...searchOptions.initialSuggestionsSearchOptions, } : searchOptions; const { type, subtype, page, perPage = searchOptions.isInitialSuggestions ? 3 : 20, } = searchOptionsToUse; const { disablePostFormats = false } = editorSettings; const queries: Promise< SearchResult[] >[] = []; if ( ! type || type === 'post' ) { queries.push( apiFetch< SearchAPIResult[] >( { path: addQueryArgs( '/wp/v2/search', { search, page, per_page: perPage, type: 'post', subtype, } ), } ) .then( ( results ) => { return results.map( ( result ) => { return { id: result.id, url: result.url, title: decodeEntities( result.title || '' ) || __( '(no title)' ), type: result.subtype || result.type, kind: 'post-type', }; } ); } ) .catch( () => [] ) // Fail by returning no results. ); } if ( ! type || type === 'term' ) { queries.push( apiFetch< SearchAPIResult[] >( { path: addQueryArgs( '/wp/v2/search', { search, page, per_page: perPage, type: 'term', subtype, } ), } ) .then( ( results ) => { return results.map( ( result ) => { return { id: result.id, url: result.url, title: decodeEntities( result.title || '' ) || __( '(no title)' ), type: result.subtype || result.type, kind: 'taxonomy', }; } ); } ) .catch( () => [] ) // Fail by returning no results. ); } if ( ! disablePostFormats && ( ! type || type === 'post-format' ) ) { queries.push( apiFetch< SearchAPIResult[] >( { path: addQueryArgs( '/wp/v2/search', { search, page, per_page: perPage, type: 'post-format', subtype, } ), } ) .then( ( results ) => { return results.map( ( result ) => { return { id: result.id, url: result.url, title: decodeEntities( result.title || '' ) || __( '(no title)' ), type: result.subtype || result.type, kind: 'taxonomy', }; } ); } ) .catch( () => [] ) // Fail by returning no results. ); } if ( ! type || type === 'attachment' ) { queries.push( apiFetch< MediaAPIResult[] >( { path: addQueryArgs( '/wp/v2/media', { search, page, per_page: perPage, } ), } ) .then( ( results ) => { return results.map( ( result ) => { return { id: result.id, url: result.source_url, title: decodeEntities( result.title.rendered || '' ) || __( '(no title)' ), type: result.type, kind: 'media', }; } ); } ) .catch( () => [] ) // Fail by returning no results. ); } const responses = await Promise.all( queries ); let results = responses.flat(); results = results.filter( ( result ) => !! result.id ); results = sortResults( results, search ); results = results.slice( 0, perPage ); return results; } /** * Sort search results by relevance to the given query. * * Sorting is necessary as we're querying multiple endpoints and merging the results. For example * a taxonomy title might be more relevant than a post title, but by default taxonomy results will * be ordered after all the (potentially irrelevant) post results. * * We sort by scoring each result, where the score is the number of tokens in the title that are * also in the search query, divided by the total number of tokens in the title. This gives us a * score between 0 and 1, where 1 is a perfect match. * * @param results * @param search */ export function sortResults( results: SearchResult[], search: string ) { const searchTokens = tokenize( search ); const scores = {}; for ( const result of results ) { if ( result.title ) { const titleTokens = tokenize( result.title ); const exactMatchingTokens = titleTokens.filter( ( titleToken ) => searchTokens.some( ( searchToken ) => titleToken === searchToken ) ); const subMatchingTokens = titleTokens.filter( ( titleToken ) => searchTokens.some( ( searchToken ) => titleToken !== searchToken && titleToken.includes( searchToken ) ) ); // The score is a combination of exact matches and sub-matches. // More weight is given to exact matches, as they are more relevant (e.g. "cat" vs "caterpillar"). // Diving by the total number of tokens in the title normalizes the score and skews // the results towards shorter titles. const exactMatchScore = ( exactMatchingTokens.length / titleTokens.length ) * 10; const subMatchScore = subMatchingTokens.length / titleTokens.length; scores[ result.id ] = exactMatchScore + subMatchScore; } else { scores[ result.id ] = 0; } } return results.sort( ( a, b ) => scores[ b.id ] - scores[ a.id ] ); } /** * Turns text into an array of tokens, with whitespace and punctuation removed. * * For example, `"I'm having a ball."` becomes `[ "im", "having", "a", "ball" ]`. * * @param text */ export function tokenize( text: string ): string[] { // \p{L} matches any kind of letter from any language. // \p{N} matches any kind of numeric character. return text.toLowerCase().match( /[\p{L}\p{N}]+/gu ) || []; }