diff --git a/packages/core/src/data/strategies/SearchNativeFetchStrategy.ts b/packages/core/src/data/strategies/SearchNativeFetchStrategy.ts new file mode 100644 index 000000000..b96dc0d18 --- /dev/null +++ b/packages/core/src/data/strategies/SearchNativeFetchStrategy.ts @@ -0,0 +1,155 @@ +import { getSiteBySourceUrl, addQueryArgs, getWPUrl } from '../../utils'; +import { endpoints } from '../utils'; +import { apiGet } from '../api'; +import { PostEntity, QueriedObject } from '../types'; +import { searchMatchers } from '../utils/matchers'; +import { parsePath } from '../utils/parsePath'; +import { FetchOptions, AbstractFetchStrategy, EndpointParams } from './AbstractFetchStrategy'; + +/** + * The EndpointParams supported by the {@link SearchNativeFetchStrategy} + */ +export interface SearchParams extends EndpointParams { + /** + * Current page of the collection. + * + * @default 1 + */ + page?: number; + + /** + * Maximum number of items to be returned in result set. + * + * @default 10 + */ + per_page?: number; + + /** + * Limit results to those matching a string. + */ + search?: string; + + /** + * Limit results to items of an object type. + * + * @default 'post' + */ + type?: 'post' | 'term' | 'post-format'; + + /** + * Limit results to items of one or more object subtypes. + */ + subtype?: string; + + /** + * Ensure result set excludes specific IDs. + */ + exclude?: number[]; + + /** + * Limit result set to specific IDs. + */ + include?: number[]; +} + +/** + * The SearchNativeFetchStrategy is used to fetch search results for a given search query + * Uses the native WordPress search endpoint. + * + * Note that custom post types and custom taxonomies should be defined in `headless.config.js` + * + * This strategy supports extracting endpoint params from url E.g: + * - `/page/2/` maps to `{ page: 2 }` + * - `/searched-term/page/2` maps to `{ search: 'searched-term', page: 2 }` + * + * @see {@link getParamsFromURL} to learn about url param mapping + * + * @category Data Fetching + */ +export class SearchNativeFetchStrategy< + T extends PostEntity = PostEntity, + P extends SearchParams = SearchParams, +> extends AbstractFetchStrategy { + path: string = ''; + + locale: string = ''; + + getDefaultEndpoint() { + return endpoints.search; + } + + getDefaultParams(): Partial

{ + return { _embed: true, ...super.getDefaultParams() } as P; + } + + /** + * This strategy automatically extracts taxonomy filters, date filters and pagination params from the URL + * + * It also takes into account the custom taxonomies specified in `headless.config.js` + * + * @param path The URL path to extract params from + * @param params + */ + getParamsFromURL(path: string, params: Partial

= {}): Partial

{ + const config = getSiteBySourceUrl(this.baseURL); + + // Required for search lang url. + this.locale = config.integrations?.polylang?.enable && params.lang ? params.lang : ''; + + return parsePath(searchMatchers, path) as Partial

; + } + + /** + * The fetcher function is overridden to disable throwing if not found + * + * If a search request returns not found we do not want to redirect to a 404 page, + * instead the user should be informed that no posts were found + * + * @param url The url to parse + * @param params The params to build the endpoint with + * @param options FetchOptions + */ + async fetcher(url: string, params: Partial

, options: Partial = {}) { + const { burstCache = false } = options; + let seo_json: Record = {}; + let seo: string = ''; + + // Request SEO data. + try { + const wpUrl = getWPUrl().replace(/\/$/, ''); // Ensure no double slash in url param + const localeParam = this.locale ? `&lang=${this.locale}` : ''; + + const result = await apiGet( + addQueryArgs(`${wpUrl}${endpoints.yoast}`, { + url: `${wpUrl}/?s=${params.search}${localeParam}`, + }), + {}, + burstCache, + ); + + seo = result.json.html ?? null; + seo_json = { ...result.json.json }; + } catch (e) { + // do nothing + } + + const queriedObject: QueriedObject = { + search: { + searchedValue: params.search ?? '', + type: 'post', + subtype: (params.postType as string) ?? 'post', + yoast_head: seo, + yoast_head_json: { + ...seo_json, + }, + }, + }; + + const response = await super.fetcher(url, params, { ...options, throwIfNotFound: false }); + + return { + ...response, + queriedObject, + }; + } +} diff --git a/packages/core/src/data/strategies/index.ts b/packages/core/src/data/strategies/index.ts index 609b64650..f4cf8188b 100644 --- a/packages/core/src/data/strategies/index.ts +++ b/packages/core/src/data/strategies/index.ts @@ -2,6 +2,7 @@ export * from './AbstractFetchStrategy'; export * from './SinglePostFetchStrategy'; export * from './PostsArchiveFetchStrategy'; export * from './SearchFetchStrategy'; +export * from './SearchNativeFetchStrategy'; export * from './AppSettingsStrategy'; export * from './TaxonomyTermsStrategy'; export * from './AuthorArchiveFetchStrategy'; diff --git a/packages/core/src/data/utils/postHandling.ts b/packages/core/src/data/utils/postHandling.ts index e89688c22..f12a76d19 100644 --- a/packages/core/src/data/utils/postHandling.ts +++ b/packages/core/src/data/utils/postHandling.ts @@ -30,6 +30,10 @@ export function getPostTerms(post: PostEntity): Record { } post._embedded['wp:term'].forEach((taxonomy) => { + if (!Array.isArray(taxonomy)) { + return; + } + taxonomy.forEach((term) => { const taxonomySlug = term.taxonomy; diff --git a/packages/core/src/react/hooks/index.ts b/packages/core/src/react/hooks/index.ts index 1b42022bd..9e32f0ca6 100644 --- a/packages/core/src/react/hooks/index.ts +++ b/packages/core/src/react/hooks/index.ts @@ -2,6 +2,7 @@ export * from './useFetch'; export * from './useFetchPost'; export * from './useFetchPosts'; export * from './useFetchSearch'; +export * from './useFetchSearchNative'; export * from './types'; export * from './useFetchTerms'; export * from './useFetchAuthorArchive'; diff --git a/packages/core/src/react/hooks/useFetchSearchNative.ts b/packages/core/src/react/hooks/useFetchSearchNative.ts new file mode 100644 index 000000000..af9dade50 --- /dev/null +++ b/packages/core/src/react/hooks/useFetchSearchNative.ts @@ -0,0 +1,77 @@ +import { useFetch } from './useFetch'; + +import type { FetchHookOptions } from './types'; +import { + FetchResponse, + getPostAuthor, + getPostTerms, + PageInfo, + PostEntity, + SearchParams, + QueriedObject, + SearchNativeFetchStrategy, +} from '../../data'; +import { getWPUrl } from '../../utils'; +import { makeErrorCatchProxy } from './util'; +import { useSearchResponse } from './useFetchSearch'; + +/** + * The useFetchSearchNative hook. Returns a collection of search entities retrieved through the WP native search endpoint + * + * See {@link useSearchNative} for usage instructions. + * + * @param params The list of params to pass to the fetch strategy. It overrides the ones in the URL. + * @param options The options to pass to the swr hook. + * @param path The path of the url to get url params from. + * + * @category Data Fetching Hooks + */ +export function useFetchSearchNative< + T extends PostEntity = PostEntity, + P extends SearchParams = SearchParams, +>( + params: P | {} = {}, + options: FetchHookOptions> = {}, + path = '', +): useSearchResponse { + const { data, error, isMainQuery } = useFetch( + params, + useFetchSearchNative.fetcher(), + options, + path, + ); + + if (error || !data) { + const fakeData = { + posts: makeErrorCatchProxy('posts'), + pageInfo: makeErrorCatchProxy('pageInfo'), + queriedObject: makeErrorCatchProxy('queriedObject'), + }; + return { error, loading: !data, data: fakeData, isMainQuery }; + } + + const { result, pageInfo, queriedObject } = data; + + const posts = result.map((post) => { + post.author = getPostAuthor(post); + post.terms = getPostTerms(post); + + return post; + }); + + return { data: { posts, pageInfo, queriedObject }, loading: false, isMainQuery }; +} + +/** + * @internal + */ +// eslint-disable-next-line no-redeclare +export namespace useFetchSearchNative { + export const fetcher = < + T extends PostEntity = PostEntity, + P extends SearchParams = SearchParams, + >( + sourceUrl?: string, + defaultParams?: P, + ) => new SearchNativeFetchStrategy(sourceUrl ?? getWPUrl(), defaultParams); +} diff --git a/packages/core/src/utils/endpoints.ts b/packages/core/src/utils/endpoints.ts index 7d59afbc6..164a0923d 100644 --- a/packages/core/src/utils/endpoints.ts +++ b/packages/core/src/utils/endpoints.ts @@ -4,6 +4,7 @@ export const endpoints = { appSettings: '/wp-json/headless-wp/v1/app', category: '/wp-json/wp/v2/categories', tags: '/wp-json/wp/v2/tags', + search: '/wp-json/wp/v2/search', tokenVerify: '/wp-json/headless-wp/v1/token', yoast: '/wp-json/yoast/v1/get_head', }; diff --git a/packages/next/src/data/hooks/index.ts b/packages/next/src/data/hooks/index.ts index 5802a6734..a06f5f4cb 100644 --- a/packages/next/src/data/hooks/index.ts +++ b/packages/next/src/data/hooks/index.ts @@ -1,6 +1,7 @@ export * from './usePost'; export * from './usePosts'; export * from './useSearch'; +export * from './useSearchNative'; export * from './useAppSettings'; export * from './useMenu'; export * from './useTerms'; diff --git a/packages/next/src/data/hooks/useSearchNative.ts b/packages/next/src/data/hooks/useSearchNative.ts new file mode 100644 index 000000000..6325dd5ac --- /dev/null +++ b/packages/next/src/data/hooks/useSearchNative.ts @@ -0,0 +1,37 @@ +import { PostEntity, SearchParams, FetchResponse } from '@headstartwp/core'; +import { FetchHookOptions, useFetchSearchNative } from '@headstartwp/core/react'; +import { usePrepareFetch } from './usePrepareFetch'; + +/** + * The useSearchNative hook. Returns a collection of search entities retrieved through the WP native search endpoint. + * + * In order to automatically map URL params create a catch-all route named `[...path].js`. + * You can create the catch-all at any level e.g: `pages/search/[[...path]].js` + * + * The `pages/search/[[...path]].js` route for instance would yield a URL like this: `/search/[term]/page/[number]`, `/search/[term]` etc + * + * @param params The parameters accepted by the hook + * @param options Options for the SWR configuration + * + * @category Data Fetching Hooks + */ +export function useSearchNative< + T extends PostEntity = PostEntity, + P extends SearchParams = SearchParams, +>(params: Partial

= {}, options: FetchHookOptions> = {}) { + const useFetchArguments = usePrepareFetch(params, options); + + return useFetchSearchNative( + useFetchArguments.params, + useFetchArguments.options, + useFetchArguments.path, + ); +} + +/** + * @internal + */ +// eslint-disable-next-line no-redeclare +export namespace useSearchNative { + export const { fetcher } = useFetchSearchNative; +} diff --git a/projects/wp-nextjs/src/pages/search/[[...path]].js b/projects/wp-nextjs/src/pages/search/[[...path]].js index 389120c02..e239d8fb2 100644 --- a/projects/wp-nextjs/src/pages/search/[[...path]].js +++ b/projects/wp-nextjs/src/pages/search/[[...path]].js @@ -1,5 +1,5 @@ import { - useSearch, + useSearchNative, fetchHookData, addHookData, handleError, @@ -10,7 +10,7 @@ import { searchParams } from '../../params'; import { resolveBatch } from '../../utils/promises'; const SearchPage = () => { - const { data } = useSearch(searchParams); + const { data } = useSearchNative(searchParams); if (data.pageInfo.totalItems === 0) { return 'Nothing found'; @@ -22,8 +22,8 @@ const SearchPage = () => {