diff --git a/.changeset/lucky-buttons-smile.md b/.changeset/lucky-buttons-smile.md new file mode 100644 index 000000000..a1900187b --- /dev/null +++ b/.changeset/lucky-buttons-smile.md @@ -0,0 +1,9 @@ +--- +"@headstartwp/core": minor +"@headstartwp/next": minor +--- + +Add support for archive path matching `matchArchivePath`. +Add support for passing a function to `customPostTypes` and `customTaxonomies` option in `headstartwp.config.js`. +Rename `headless.config.js` to `headstartwp.config.js` but keep backward compatibility. +Automatically load `headstartwp.config.js` or `headless.config.js` in `next.config.js`. diff --git a/docs/documentation/01-Getting Started/headless-config.md b/docs/documentation/01-Getting Started/headless-config.md index a24a34e9b..cd4c75373 100644 --- a/docs/documentation/01-Getting Started/headless-config.md +++ b/docs/documentation/01-Getting Started/headless-config.md @@ -2,13 +2,33 @@ slug: /getting-started/headless-config sidebar_position: 3 --- -# Headless Config +# Configuring the Framework -The `headless.config.js` file contains several config options for HeadstartWP. This file should export an object of type [HeadlessConfig](/api/modules/headstartwp_core/#headlessconfig). +The `headstartwp.config.js` (previously, `headless.config.js`) file contains several config options for HeadstartWP. This file should export an object of type [HeadlessConfig](/api/modules/headstartwp_core/#headlessconfig). + +## Usage with Next.js + +The file **must be named** either `headstartwp.config.js` or `headless.config.js`. When `injectConfig` param of `withHeadstartWPConfig` (previously `withHeadlessConfig`) is set to true, the framework will look for these two files to be injected and loaded in the runtime bundle of the Next.js App. + +```js title=next.config.js +const { withHeadstartWPConfig } = require('@headstartwp/next/config'); + +/** + * Update whatever you need within the nextConfig object. + * + * @type {import('next').NextConfig} + */ +const nextConfig = {}; + +module.exports = withHeadstartWPConfig(nextConfig); +``` +:::caution +Since `@headstartwp/next@1.2.0` you do not need to import `headstartwp.config.js` in `next.config.js` anymore, the framework will dynamically load the config. +:::caution Here's a sample config file -```javascript title="headless.config.js" +```javascript title="headstartwp.config.js" module.exports = { sourceUrl: process.env.NEXT_PUBLIC_HEADLESS_WP_URL, hostUrl: process.env.HOST_URL, @@ -41,9 +61,9 @@ The `host` option is automatically inferred if `hostUrl` is set. You probably do ## customPostTypes -To add support for custom post types, add your custom post type to the `customPostTypes` setting in `headless.config.js`. +To add support for custom post types, add your custom post type to the `customPostTypes` setting in `headstartwp.config.js`. -```js title="headless.config.js" +```js title="headstartwp.config.js" module.exports = { sourceUrl: process.env.NEXT_PUBLIC_HEADLESS_WP_URL, hostUrl: process.env.HOST_URL, @@ -66,15 +86,28 @@ usePost({ postType: ['book'] }); usePosts({ postType:'book', perPage: 10 }); ``` -The `single` option is required for a number of things that includes: +The `single` option is required for several things including: - properly previewing custom post types when the "single" route is at a different prefix. E.g: `/book/da-vince-code` instead of `/da-vice-code`; In this case, the framework will use the `single` path to redirect the previewed post to the right path/route. - Matching post path permalinks with the current URL. E.g: when fetching a single custom post type the framework will filter the returned posts to the one that matches the existing URL. Therefore, the framework needs to know the single prefix url for custom post types. This is required to properly handle parent pages that share the same child slug. See [post path mapping](/learn/data-fetching/usepost/#post-path-matching) for more info. +It is also possible to pass a function, when doing so the default post types (post and pages) will be passed to the function. The code snipped below will disable [post path mapping](/learn/data-fetching/usepost/#post-path-matching) to the default post types. + +```js title="headstartwp.config.js" +module.exports = { + sourceUrl: process.env.NEXT_PUBLIC_HEADLESS_WP_URL, + hostUrl: process.env.HOST_URL, + customPostTypes: (defaultPostTypes) => { + // disable post path mapping for default post types + return defaultPostTypes.map((postType) => ({...postType, matchSinglePath: false})); + } +} +``` + ## customTaxonomies -To add support for custom taxonomies, add your custom taxonomy to the `customTaxonomies` setting in `headless.config.js`. +To add support for custom taxonomies, add your custom taxonomy to the `customTaxonomies` setting in `headstartwp.config.js`. -```js title="headless.config.js" +```js title="headstartwp.config.js" module.exports = { customPostTypes: [ { @@ -90,6 +123,8 @@ module.exports = { slug: 'genre', endpoint: '/wp-json/wp/v2/genre', postType: ['book'], + rewrite: 'genre', + restParam: 'genre' }, ], } @@ -140,6 +175,33 @@ This route would automatically handle the following URLs: The code snippet above does not implement pre-fetching, which you probably want to. Check out the [pre-fetching docs](/learn/data-fetching/prefetching) for instructions. :::caution +It is also possible to specify a function for 'customTaxonomies', when doing so the default taxonomies will be passed to the function. This can be used for instance to enable [archive path matching](/learn/data-fetching/useposts#archive-path-matching). + +```js title="headstartwp.config.js" +module.exports = { + customPostTypes: [ + { + slug: 'book', + endpoint: '/wp-json/wp/v2/book', + // these should match your file-system routing + single: '/book', + archive: '/books', + }, + ], + customTaxonomies: (defaultTaxonomies) => { + return defaultTaxonomies.map((taxonomy) => ({ ...taxonomy, matchArchivePath: true })), + }, +} +``` +### restParam + +This option shouldn't be necessary most of the time, but this is used to map a custom taxonomy to its REST API parameter. Most of the times the slug is equal to the restParam but in some cases it differs. For instance, the default post tag taxonomy has a slug of `post_tag` but a `restParam` of `tags` (i.e., to filter posts by `tags` in the REST API, we must use `tags=`). + + +### rewrite + +This option controls the expected prefix the taxonomy must use in front-end urls. This generally should match `rewrite.slug` of the [register_taxonomy](https://developer.wordpress.org/reference/functions/register_taxonomy/) function. + ## redirectStrategy This option control how redirects are handled. There are 2 supported methods of handling redirects. diff --git a/docs/documentation/01-Getting Started/quick-tutorial.md b/docs/documentation/01-Getting Started/quick-tutorial.md index bbde9d580..daedc3c94 100644 --- a/docs/documentation/01-Getting Started/quick-tutorial.md +++ b/docs/documentation/01-Getting Started/quick-tutorial.md @@ -107,7 +107,7 @@ The `resolveBatch` function is just a utility function that lets you run multipl Next, we have the `addHookData` function which expects an array of "hook data" (i.e pre-fetched data for the custom hooks returned by `fetchHookData`). The `addHookData` will simply put the results on the cache and hydrate the custom hook with pre-fetched data. The second param is an object that represents the Next.js props you can return from `getStaticProps` or `getServerSideProps`. -If anything fails, we call the `handleError` function which provides standard error handling such as rendering a 404 page if a page is not found and optionally handling redirects (if the redirect strategy is set to 404 in headless.config.js). +If anything fails, we call the `handleError` function which provides standard error handling such as rendering a 404 page if a page is not found and optionally handling redirects (if the redirect strategy is set to 404 in headstartwp.config.js). Lastly, the `getStaticPaths` will return an array of paths that should be pre-rendered at build time. This should only be used in conjunction with getStaticProps. Note that the framework doesn’t force getStaticProps you can use getServerSideProps (especially if your hosting doesn’t provide good support for ISR). diff --git a/docs/documentation/01-Getting Started/setting-up-manually.md b/docs/documentation/01-Getting Started/setting-up-manually.md index 02201f3ec..a4c08540b 100644 --- a/docs/documentation/01-Getting Started/setting-up-manually.md +++ b/docs/documentation/01-Getting Started/setting-up-manually.md @@ -22,11 +22,11 @@ and install the following packages npm install --save @headstartwp/core @headstartwp/next ``` -### headless.config.js +### headstartwp.config.js -Create a `headless.config.js` file at the root of your Next.js project. +Create a `headstartwp.config.js` file at the root of your Next.js project. -```js title="headless.config.js" +```js title="headstartwp.config.js" /** * Headless Config * @@ -47,7 +47,7 @@ Then create a `.env` (or `.env.local`) with the following contents: NEXT_PUBLIC_HEADLESS_WP_URL=https://my-wordpress.test ``` -You can call the env variable anything you want, just make sure to update `headless.config.js` accordingly. +You can call the env variable anything you want, just make sure to update `headstartwp.config.js` accordingly. If you're developing locally and your WordPress instance uses https but does not have a valid cert, add `NODE_TLS_REJECT_UNAUTHORIZED=0` to your env variables. diff --git a/docs/documentation/02 - Data Fetching/useAuthorArchive.md b/docs/documentation/02 - Data Fetching/useAuthorArchive.md index d7c25c1c9..343e47324 100644 --- a/docs/documentation/02 - Data Fetching/useAuthorArchive.md +++ b/docs/documentation/02 - Data Fetching/useAuthorArchive.md @@ -48,12 +48,12 @@ The route will automatically render the latest 10 posts from the current author. ## Author Archive for Custom Post Type -In order to fetch posts from a custom post type, first declare the custom post type in `headless.config.js` as explained in the [headless.config.js](/learn/getting-started/headless-config#custom-post-types) section. +In order to fetch posts from a custom post type, first declare the custom post type in `headstartwp.config.js` as explained in the [headstartwp.config.js](/learn/getting-started/headless-config#custom-post-types) section. ```js title="src/pages/author/[...path].js" import { useAuthorArchive } from '@headstartwp/next'; const ArchivePage = () => { - // book must be declared in headless.config.js + // book must be declared in headstartwp.config.js const { loading, error, data } = useAuthorArchive({ postType: ['book'] }); if (loading) { diff --git a/docs/documentation/02 - Data Fetching/usePost.md b/docs/documentation/02 - Data Fetching/usePost.md index 7351011ec..5c3297d63 100644 --- a/docs/documentation/02 - Data Fetching/usePost.md +++ b/docs/documentation/02 - Data Fetching/usePost.md @@ -67,7 +67,7 @@ Example where path does not match but is redirected to the right one: - User visits URL `/post-name` - The post with the `post-name` slug contains a `http://backend.com/2022/10/30/post-name` url - Since the URL and the path of `post.link` do not match, a NotFound error is thrown -- If prefetching is setup following [pre-fetching](/learn/data-fetching/prefetching) and `redirectStrategy` is set to "404" or "always" in `headless.config.js`, `handleError` will then look if there's a redirect available and since WordPress redirects `/post-name` to `/2022/10/30/post-name`, the framework will also perform the redirect. +- If prefetching is setup following [pre-fetching](/learn/data-fetching/prefetching) and `redirectStrategy` is set to "404" or "always" in `headstartwp.config.js`, `handleError` will then look if there's a redirect available and since WordPress redirects `/post-name` to `/2022/10/30/post-name`, the framework will also perform the redirect. ### Fetching from multiple post types @@ -103,7 +103,7 @@ const PostOrPage = () => { ## Fetching from a custom post type -To fetch a single from a custom post type, first declare the custom post type in `headless.config.js` as explained in the [headless.config.js](/learn/getting-started/headless-config#custom-post-types) section. +To fetch a single from a custom post type, first declare the custom post type in `headstartwp.config.js` as explained in the [headstartwp.config.js](/learn/getting-started/headless-config#custom-post-types) section. ```js title="src/pages/book/[...path].js" import { usePost } from '@headstartwp/next'; diff --git a/docs/documentation/02 - Data Fetching/usePosts.md b/docs/documentation/02 - Data Fetching/usePosts.md index d17f47492..0de59e348 100644 --- a/docs/documentation/02 - Data Fetching/usePosts.md +++ b/docs/documentation/02 - Data Fetching/usePosts.md @@ -52,7 +52,7 @@ The route will automatically render the latest 10 posts and you get pagination, The `usePosts` hook exposes a `queriedObject`. It's similar to WordPress [get_queried_object()](https://developer.wordpress.org/reference/functions/get_queried_object/) function. -It essentially returned the what's being queried for, e.g: author or category. If the current page is querying posts within a certain author, then that author object will be populated in `data.queriedObject.author`. Similarly, if the current page is querying posts from a given category `data.queriedObject.term` will be populated with that category. +It essentially returns what's being queried for, e.g., author or category. If the current page is querying posts within a certain author, then that author object will be populated in `data.queriedObject.author`. Similarly, if the current page is querying posts from a given category `data.queriedObject.term` will be populated with that category. Example: ```javascript @@ -97,8 +97,31 @@ const CategoryPage = () => { ); }; +``` + +## Archive Path Matching + +When using `usePosts` to create archive pages (e.g. a category archive) you can optionally enable "archive path matching" to ensure that your archive routes match the expected permalink dictated by WordPress. Without "archive path matching", your archive routes would match as long as the last segment of the url is a valid term slug. + +For instance, let's take the `/category/[...path].js` route above. It will match URLs like: +- /category/cat-name +- /category/parent-cat-name/cat-name +The framework, however, does not check if `parent-cat-name` is the de facto parent of cat-name, and even worse, it has no way to know (without additional rest api calls) if `parent-cat-name` is even a valid category. + +To address this, you can pass `matchArchivePath` to `usePosts` to tell the framework to check the `link` property of the `queriedObject`, i.e if the `link` property of `cat-name` returned by the [WordPress REST API](https://developer.wordpress.org/rest-api/reference/categories/#schema) matches the front-end path. + +This setting can also be enabled in `headstartwp.config.js` globally. + +```js title="headstartwp.config.js" +module.exports = { + // enable archive path mapping for all default taxonomies + customTaxonomies: (defaultTaxonomies) => { + return defaultTaxonomies.map((taxonomy) => ({ ...taxonomy, matchArchivePath: true })), + }, +} ``` + ## Known limitations - It is not possible to fetch posts from more than one post type. diff --git a/docs/documentation/06-WordPress Integration/multisite.md b/docs/documentation/06-WordPress Integration/multisite.md index 2d2001efe..689a7cb69 100644 --- a/docs/documentation/06-WordPress Integration/multisite.md +++ b/docs/documentation/06-WordPress Integration/multisite.md @@ -5,7 +5,7 @@ slug: /wordpress-integration/multisite # Multisite -HeadstartWP has built-in support for WordPress multisite via the `sites` property in the `headless.config.js` file. This transforms the Next.js app into a multi-tenant app. +HeadstartWP has built-in support for WordPress multisite via the `sites` property in the `headstartwp.config.js` file. This transforms the Next.js app into a multi-tenant app. The `sites` option allows specifying as many sites you want to connect to your app. Each site must have a `sourceUrl` and a `hostUrl`. The `hostUrl` will be used to match the current site and `sourceUrl` indicates where content should be sourced from. @@ -17,7 +17,7 @@ Take a look at the [multisite demo project](https://github.com/10up/headstartwp/ ### Config -The first step is to declare all of your sites in `headless.config.js`. In the example below we're declaring two sites. +The first step is to declare all of your sites in `headstartwp.config.js`. In the example below we're declaring two sites. ```javascript /** diff --git a/docs/documentation/06-WordPress Integration/polylang.md b/docs/documentation/06-WordPress Integration/polylang.md index c70e9ed13..643e8face 100644 --- a/docs/documentation/06-WordPress Integration/polylang.md +++ b/docs/documentation/06-WordPress Integration/polylang.md @@ -8,9 +8,9 @@ slug: /wordpress-integration/polylang Polylang Pro is required since only Polylang Pro offers the [REST API integration](https://polylang.pro/doc/rest-api/). :::caution -It is possible to integrate with Polylang by enabling the integration in `headless.config.js` and adding the supported locales to [Next.js config](https://nextjs.org/docs/advanced-features/i18n-routing). +It is possible to integrate with Polylang by enabling the integration in `headstartwp.config.js` and adding the supported locales to [Next.js config](https://nextjs.org/docs/advanced-features/i18n-routing). -```js title="headless.config.js" +```js title="headstartwp.config.js" module.exports = { // other settings integrations: { diff --git a/packages/core/src/data/strategies/PostsArchiveFetchStrategy.ts b/packages/core/src/data/strategies/PostsArchiveFetchStrategy.ts index 84a0bee27..a1cde0ea6 100644 --- a/packages/core/src/data/strategies/PostsArchiveFetchStrategy.ts +++ b/packages/core/src/data/strategies/PostsArchiveFetchStrategy.ts @@ -7,6 +7,7 @@ import { NotFoundError, addQueryArgs, getCustomTaxonomy, + removeSourceUrl, } from '../../utils'; import { endpoints, getPostAuthor, getPostTerms, removeFieldsFromPostRelatedData } from '../utils'; import { apiGet } from '../api'; @@ -180,6 +181,13 @@ export interface PostsArchiveParams extends EndpointParams { * Limit result set to items that are sticky. */ sticky?: boolean; + + /** + * Overrides the value set in {@link CustomTaxonomy#matchArchivePath} + * + * @default false + */ + matchArchivePath?: boolean; } /** @@ -199,6 +207,10 @@ export class PostsArchiveFetchStrategy< T extends PostEntity = PostEntity, P extends PostsArchiveParams = PostsArchiveParams, > extends AbstractFetchStrategy { + path: string = ''; + + locale: string = ''; + getDefaultEndpoint(): string { return endpoints.posts; } @@ -216,6 +228,12 @@ export class PostsArchiveFetchStrategy< * @param params */ getParamsFromURL(path: string, params: Partial

= {}): Partial

{ + const config = getSiteBySourceUrl(this.baseURL); + + // this is required for post path mapping + this.locale = config.integrations?.polylang?.enable && params.lang ? params.lang : ''; + this.path = path; + const matchers = [...postsMatchers]; if (typeof params.taxonomy === 'string') { @@ -306,6 +324,62 @@ export class PostsArchiveFetchStrategy< return super.buildEndpointURL(endpointParams as P); } + prepareResponse(response: FetchResponse, params: Partial

): FetchResponse { + const queriedObject = this.getQueriedObject(response, params); + + const currentPath = decodeURIComponent(this.path).replace(/\/?$/, '/'); + let queriedObjectPath = ''; + let taxonomySlug = ''; + + if (queriedObject?.term?.link) { + // if term is set then it should match the url + queriedObjectPath = decodeURIComponent( + removeSourceUrl({ + link: queriedObject.term.link, + backendUrl: this.baseURL, + }), + )?.replace(/\/?$/, '/'); + taxonomySlug = queriedObject.term.taxonomy; + } + + if (queriedObjectPath && taxonomySlug) { + const taxonomyObj = getCustomTaxonomy(taxonomySlug, this.baseURL); + const shouldMatchArchivePath = + taxonomyObj?.matchArchivePath || params.matchArchivePath || false; + + const prefixes = [ + '', + taxonomyObj?.rewrite ? `/${taxonomyObj?.rewrite}` : null, + taxonomyObj?.restParam ? `/${taxonomyObj?.restParam}` : null, + taxonomyObj?.slug ? `/${taxonomyObj?.slug}` : null, + ].filter((p) => p !== null); + + if (shouldMatchArchivePath) { + let matched = false; + for (const prefix of prefixes) { + if ( + queriedObjectPath === `${prefix}${currentPath}` || + queriedObjectPath === `/${this.locale}${prefix}${currentPath}` + ) { + matched = true; + break; + } + } + if (!matched) { + throw new NotFoundError( + `Posts were found but did not match current path: "${this.path}"`, + ); + } + } + } + + return { + ...response, + queriedObject, + result: response.result as unknown as T[], + }; + } + /** * Before fetching posts, we need handle taxonomy and authors. * @@ -417,7 +491,7 @@ export class PostsArchiveFetchStrategy< } } - const taxonomies = getCustomTaxonomies(); + const taxonomies = getCustomTaxonomies(this.baseURL); taxonomies.forEach((taxonomy) => { const termSlug = taxonomy.slug; diff --git a/packages/core/src/dom/__tests__/index.ts b/packages/core/src/dom/__tests__/index.ts index 79a1162c4..94bd09d38 100644 --- a/packages/core/src/dom/__tests__/index.ts +++ b/packages/core/src/dom/__tests__/index.ts @@ -1,7 +1,7 @@ import { Element } from 'html-react-parser'; import { isAnchorTag, isImageTag, isTwitterEmbed, isYoutubeEmbed } from '..'; -jest.mock('../../utils/getHeadlessConfig', () => { +jest.mock('../../utils/config', () => { return { getWPUrl: () => 'https://backendurl.com', }; diff --git a/packages/core/src/react/hooks/__tests__/useFetchPosts.tsx b/packages/core/src/react/hooks/__tests__/useFetchPosts.tsx index 6a92b4768..7456e49bd 100644 --- a/packages/core/src/react/hooks/__tests__/useFetchPosts.tsx +++ b/packages/core/src/react/hooks/__tests__/useFetchPosts.tsx @@ -12,8 +12,10 @@ describe('useFetchPosts', () => { return {children}; }; - setHeadlessConfig({ - useWordPressPlugin: true, + beforeEach(() => { + setHeadlessConfig({ + useWordPressPlugin: true, + }); }); it('throws errors if accessing data before fetch', async () => { @@ -235,24 +237,114 @@ describe('useFetchPosts', () => { }); }); - describe('useFetchPosts types', () => { - it('allows overriding types', () => { - interface Book extends PostEntity { - isbn: string; - } + it('throws params.matchArchivepath is true and path does not match', async () => { + const { result } = renderHook( + () => + useFetchPosts( + { + category: 'uncategorized', + per_page: 1, + matchArchivePath: true, + }, + {}, + 'https://js1.10up.com/category/asdasd/uncategorized', + ), + { + wrapper, + }, + ); - interface BookParams extends PostsArchiveParams { - isbn: string; - } + await waitFor(() => { + expect(result.current.error?.toString()).toBe( + `NotFoundError: Posts were found but did not match current path: "https://js1.10up.com/category/asdasd/uncategorized"`, + ); + }); + }); - const { result } = renderHook(() => useFetchPosts({ isbn: 'sdasd' })); + it('throws matchArchivepath config option is true and path does not match', async () => { + setHeadlessConfig({ + useWordPressPlugin: true, + customTaxonomies: (defaultTaxonomies) => { + return defaultTaxonomies.map((taxonomy) => ({ + ...taxonomy, + matchArchivePath: true, + })); + }, + }); + + const { result } = renderHook( + () => + useFetchPosts( + { + category: 'uncategorized', + per_page: 2, + }, + {}, + 'https://js1.10up.com/category/asdasd/uncategorized', + ), + { + wrapper, + }, + ); - expectTypeOf(result.current.data?.posts).toMatchTypeOf< - | Array<{ - isbn: string; - }> - | undefined - >(); + await waitFor(() => { + expect(result.current.error?.toString()).toBe( + `NotFoundError: Posts were found but did not match current path: "https://js1.10up.com/category/asdasd/uncategorized"`, + ); + }); + }); + + it('does not throws when matchArchivepath config option is true and path matches', async () => { + setHeadlessConfig({ + useWordPressPlugin: true, + customTaxonomies: (defaultTaxonomies) => { + return defaultTaxonomies.map((taxonomy) => ({ + ...taxonomy, + matchArchivePath: true, + })); + }, + }); + + const { result } = renderHook( + () => + useFetchPosts( + { + randomArgument: 10, // bypass swr cache + category: 'uncategorized', + per_page: 1, + }, + {}, + // Need this bc source url removal is not working in the tests + 'https://js1.10up.com/category/uncategorized', + ), + { + wrapper, + }, + ); + + await waitFor(() => { + expect(result.current.data?.queriedObject.term?.slug).toBe('uncategorized'); }); }); }); + +describe('useFetchPosts types', () => { + it('allows overriding types', () => { + interface Book extends PostEntity { + isbn: string; + } + + interface BookParams extends PostsArchiveParams { + isbn: string; + } + + const { result } = renderHook(() => useFetchPosts({ isbn: 'sdasd' })); + + expectTypeOf(result.current.data?.posts).toMatchTypeOf< + | Array<{ + isbn: string; + }> + | undefined + >(); + }); +}); diff --git a/packages/core/src/react/hooks/useFetchPosts.ts b/packages/core/src/react/hooks/useFetchPosts.ts index dd28a5c1b..9cd92419a 100644 --- a/packages/core/src/react/hooks/useFetchPosts.ts +++ b/packages/core/src/react/hooks/useFetchPosts.ts @@ -11,7 +11,7 @@ import { PostsArchiveParams, QueriedObject, } from '../../data'; -import { getCustomTaxonomies } from '../../utils/getHeadlessConfig'; +import { getCustomTaxonomies } from '../../utils/config'; import { getWPUrl } from '../../utils'; import { makeErrorCatchProxy } from './util'; import { useSettings } from '../provider'; diff --git a/packages/core/src/react/provider/Provider.tsx b/packages/core/src/react/provider/Provider.tsx index b50f271c6..4a8594767 100644 --- a/packages/core/src/react/provider/Provider.tsx +++ b/packages/core/src/react/provider/Provider.tsx @@ -1,5 +1,5 @@ import { FC, createContext, useMemo } from 'react'; -import { getHeadlessConfig } from '../../utils/getHeadlessConfig'; +import { getHeadlessConfig } from '../../utils/config'; import { SettingsContextProps } from './types'; export const SettingsContext = createContext>({}); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c5be9d8cb..b06677532 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3,6 +3,19 @@ export type CustomPostType = { endpoint: string; single?: string; archive?: string; + + /** + * Whether this custom post type should match the archive path + * + * If set to true, then when querying a post type archive page such as `/[post-type]/[post-name]` the + * `post.link` property should match the current path. This will avoid matching nested posts/pages that doesn't exist for instance: + * `/[post-type]/fake-parent-page/[post-name]` will not match if this option is set to true even though `post-name` exists. + * + * It is strongly recommended to set this option to true, otherwise hierarchical post types (such as pages) will not work properly. + * + * @default true + */ + matchSinglePath?: boolean; }; export type CustomPostTypes = Array; @@ -14,6 +27,17 @@ export type CustomTaxonomy = { endpoint: string; rewrite?: string; restParam?: string; + + /** + * Whether this custom taxonomy should match the archive path + * + * If set to true, then when querying a taxonomy archive page such as `/[taxonmy-slug]/[term-slug]` the + * `term.link` property should match the current path. This will avoid matching nested categories that doesn't exist for instance: + * `/[taxonmy-slug]/fake-parent-term/[term-slug]` will not match if this option is set to true even though `term-slug` exists. + * + * @default false + */ + matchArchivePath?: boolean; }; export type CustomTaxonomies = Array; @@ -36,8 +60,10 @@ export type HeadlessConfig = { locale?: string; sourceUrl?: string; hostUrl?: string; - customPostTypes?: CustomPostTypes; - customTaxonomies?: CustomTaxonomies; + customPostTypes?: CustomPostTypes | ((defaultPostTypes: CustomPostTypes) => CustomPostTypes); + customTaxonomies?: + | CustomTaxonomies + | ((defaultTaxonomies: CustomTaxonomies) => CustomTaxonomies); redirectStrategy?: RedirectStrategy; useWordPressPlugin?: boolean; integrations?: Integrations; diff --git a/packages/core/src/utils/__tests__/config.ts b/packages/core/src/utils/__tests__/config.ts new file mode 100644 index 000000000..b61922e61 --- /dev/null +++ b/packages/core/src/utils/__tests__/config.ts @@ -0,0 +1,182 @@ +import type { HeadlessConfig } from '../../types'; +import { + getCustomPostTypes, + getCustomTaxonomies, + getHeadstartWPConfig, + getSiteByHost, + setHeadstartWPConfig, +} from '../config'; + +describe('getHeadstartWPConfig', () => { + const headlessConfig: HeadlessConfig = { + sourceUrl: 'https://sourceurl.com', + hostUrl: 'https://publicurl.com', + sites: [ + { + sourceUrl: 'https://sourceurl.com/site1', + hostUrl: 'https://site1.com', + }, + ], + }; + + beforeAll(() => { + setHeadstartWPConfig(headlessConfig); + }); + + it('returns the headless config', () => { + expect(getHeadstartWPConfig()).toMatchObject(headlessConfig); + }); + + it('sets host for sites if host is not set and hostUrl is', () => { + expect(getHeadstartWPConfig()?.sites?.[0]?.host).toBe('site1.com'); + }); + + it('returns the default customTaxonomies', () => { + expect(getCustomTaxonomies()).toStrictEqual([ + { endpoint: '/wp-json/wp/v2/categories', restParam: 'categories', slug: 'category' }, + { + endpoint: '/wp-json/wp/v2/tags', + restParam: 'tags', + rewrite: 'tag', + slug: 'post_tag', + }, + ]); + }); + + it('returns the default customPostTypes', () => { + expect(getCustomPostTypes()).toStrictEqual([ + { endpoint: '/wp-json/wp/v2/pages', single: '/', slug: 'page' }, + { archive: '/blog', endpoint: '/wp-json/wp/v2/posts', single: '/', slug: 'post' }, + ]); + }); + + it('accepts an array for customTaxonomies', () => { + setHeadstartWPConfig({ + ...headlessConfig, + customTaxonomies: [ + { + slug: 'genre', + endpoint: '/wp-json/wp/v2/genre', + }, + ], + }); + + expect(getCustomTaxonomies()).toStrictEqual([ + { + slug: 'genre', + endpoint: '/wp-json/wp/v2/genre', + }, + { endpoint: '/wp-json/wp/v2/categories', restParam: 'categories', slug: 'category' }, + { + endpoint: '/wp-json/wp/v2/tags', + restParam: 'tags', + rewrite: 'tag', + slug: 'post_tag', + }, + ]); + }); + + it('accepts an array for customPostTypes', () => { + setHeadstartWPConfig({ + ...headlessConfig, + customPostTypes: [ + { + slug: 'book', + endpoint: '/wp-json/wp/v2/book', + single: '/book', + archive: '/books', + }, + ], + }); + + expect(getCustomPostTypes()).toStrictEqual([ + { + slug: 'book', + endpoint: '/wp-json/wp/v2/book', + single: '/book', + archive: '/books', + }, + { endpoint: '/wp-json/wp/v2/pages', single: '/', slug: 'page' }, + { archive: '/blog', endpoint: '/wp-json/wp/v2/posts', single: '/', slug: 'post' }, + ]); + }); + + it('accepts a function for customTaxonomies', () => { + setHeadstartWPConfig({ + ...headlessConfig, + customTaxonomies: (defaultTaxonomies) => { + return [ + ...defaultTaxonomies.map((taxonomy) => ({ + ...taxonomy, + matchArchivePath: true, + })), + ]; + }, + }); + + expect(getHeadstartWPConfig().customTaxonomies.at(0)?.matchArchivePath).toBe(true); + expect(getHeadstartWPConfig().customTaxonomies.at(1)?.matchArchivePath).toBe(true); + }); + + it('accepts a function for customPostTypes', () => { + setHeadstartWPConfig({ + ...headlessConfig, + customPostTypes: (defaultPostTypes) => { + return [ + ...defaultPostTypes.map((postType) => ({ + ...postType, + matchSinglePath: false, + })), + ]; + }, + }); + + expect(getHeadstartWPConfig().customPostTypes.at(0)?.matchSinglePath).toBe(false); + expect(getHeadstartWPConfig().customPostTypes.at(1)?.matchSinglePath).toBe(false); + }); +}); + +describe('getSiteByHost', () => { + const headlessConfig: HeadlessConfig = { + sourceUrl: 'https://sourceurl.com', + hostUrl: 'https://publicurl.com', + sites: [ + { + sourceUrl: 'https://sourceurl.com/site1', + hostUrl: 'https://site1.com', + locale: 'en', + }, + { + sourceUrl: 'https://sourceurl.com/site2', + host: 'site2.com', + hostUrl: 'https://site2.com', + locale: 'es', + }, + ], + }; + + beforeAll(() => { + setHeadstartWPConfig(headlessConfig); + }); + + it('finds sites by host even if host is not set but hostUrl is', () => { + expect(getSiteByHost('site1.com')?.sourceUrl).toBe('https://sourceurl.com/site1'); + + expect(getSiteByHost('site2.com')?.sourceUrl).toBe('https://sourceurl.com/site2'); + }); + + it('also accepts URLs', () => { + expect(getSiteByHost('https://site1.com')?.sourceUrl).toBe('https://sourceurl.com/site1'); + }); + + it('takes into account the locale', () => { + expect(getSiteByHost('https://site1.com', 'en')?.sourceUrl).toBe( + 'https://sourceurl.com/site1', + ); + + expect(getSiteByHost('site2.com', 'es')?.sourceUrl).toBe('https://sourceurl.com/site2'); + + expect(getSiteByHost('site2.com', 'en')).toBeNull(); + expect(getSiteByHost('site1.com', 'es')).toBeNull(); + }); +}); diff --git a/packages/core/src/utils/__tests__/getHeadlessConfig.ts b/packages/core/src/utils/__tests__/getHeadlessConfig.ts deleted file mode 100644 index b7cdfb466..000000000 --- a/packages/core/src/utils/__tests__/getHeadlessConfig.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { HeadlessConfig } from '../../types'; -import { getHeadlessConfig, getSiteByHost, setHeadlessConfig } from '../getHeadlessConfig'; - -describe('getHeadlessConfig', () => { - const headlessConfig: HeadlessConfig = { - sourceUrl: 'https://sourceurl.com', - hostUrl: 'https://publicurl.com', - sites: [ - { - sourceUrl: 'https://sourceurl.com/site1', - hostUrl: 'https://site1.com', - }, - ], - }; - - beforeAll(() => { - setHeadlessConfig(headlessConfig); - }); - - it('returns the headless config', () => { - expect(getHeadlessConfig()).toMatchObject(headlessConfig); - }); - - it('sets host for sites if host is not set and hostUrl is', () => { - expect(getHeadlessConfig()?.sites?.[0]?.host).toBe('site1.com'); - }); -}); - -describe('getSiteByHost', () => { - const headlessConfig: HeadlessConfig = { - sourceUrl: 'https://sourceurl.com', - hostUrl: 'https://publicurl.com', - sites: [ - { - sourceUrl: 'https://sourceurl.com/site1', - hostUrl: 'https://site1.com', - locale: 'en', - }, - { - sourceUrl: 'https://sourceurl.com/site2', - host: 'site2.com', - hostUrl: 'https://site2.com', - locale: 'es', - }, - ], - }; - - beforeAll(() => { - setHeadlessConfig(headlessConfig); - }); - - it('finds sites by host even if host is not set but hostUrl is', () => { - expect(getSiteByHost('site1.com')?.sourceUrl).toBe('https://sourceurl.com/site1'); - - expect(getSiteByHost('site2.com')?.sourceUrl).toBe('https://sourceurl.com/site2'); - }); - - it('also accepts URLs', () => { - expect(getSiteByHost('https://site1.com')?.sourceUrl).toBe('https://sourceurl.com/site1'); - }); - - it('takes into account the locale', () => { - expect(getSiteByHost('https://site1.com', 'en')?.sourceUrl).toBe( - 'https://sourceurl.com/site1', - ); - - expect(getSiteByHost('site2.com', 'es')?.sourceUrl).toBe('https://sourceurl.com/site2'); - - expect(getSiteByHost('site2.com', 'en')).toBeNull(); - expect(getSiteByHost('site1.com', 'es')).toBeNull(); - }); -}); diff --git a/packages/core/src/utils/__tests__/isInternalLink.ts b/packages/core/src/utils/__tests__/isInternalLink.ts index 6d76b635c..f229ad558 100644 --- a/packages/core/src/utils/__tests__/isInternalLink.ts +++ b/packages/core/src/utils/__tests__/isInternalLink.ts @@ -1,7 +1,7 @@ import { isInternalLink } from '..'; import { HeadlessConfig } from '../../types'; -jest.mock('../getHeadlessConfig', () => { +jest.mock('../config', () => { return { getWPUrl: () => 'https://backendurl.com', }; diff --git a/packages/core/src/utils/getHeadlessConfig.ts b/packages/core/src/utils/config.ts similarity index 83% rename from packages/core/src/utils/getHeadlessConfig.ts rename to packages/core/src/utils/config.ts index d53c4368f..c534f8985 100644 --- a/packages/core/src/utils/getHeadlessConfig.ts +++ b/packages/core/src/utils/config.ts @@ -1,5 +1,5 @@ import { endpoints } from './endpoints'; -import type { HeadlessConfig } from '../types'; +import type { CustomPostTypes, CustomTaxonomies, HeadlessConfig } from '../types'; let __10up__HEADLESS_CONFIG: HeadlessConfig = {}; @@ -32,11 +32,49 @@ export function getHeadstartWPConfig() { debug, } = __10up__HEADLESS_CONFIG; - const headlessConfig: HeadlessConfig = { + const defaultTaxonomies: CustomTaxonomies = [ + { + slug: 'category', + endpoint: endpoints.category, + restParam: 'categories', + }, + { + slug: 'post_tag', + endpoint: endpoints.tags, + rewrite: 'tag', + restParam: 'tags', + }, + ]; + + const taxonomies = + typeof customTaxonomies === 'function' + ? customTaxonomies(defaultTaxonomies) + : [...(customTaxonomies || []), ...defaultTaxonomies]; + + const defaultPostTypes: CustomPostTypes = [ + { + slug: 'page', + endpoint: '/wp-json/wp/v2/pages', + single: '/', + }, + { + slug: 'post', + endpoint: '/wp-json/wp/v2/posts', + single: '/', + archive: '/blog', + }, + ]; + + const postTypes = + typeof customPostTypes === 'function' + ? customPostTypes(defaultTaxonomies) + : [...(customPostTypes || []), ...defaultPostTypes]; + + const headlessConfig = { sourceUrl, hostUrl: hostUrl || '', - customPostTypes, - customTaxonomies, + customPostTypes: postTypes, + customTaxonomies: taxonomies, redirectStrategy: redirectStrategy || 'none', useWordPressPlugin: useWordPressPlugin || false, integrations, @@ -140,51 +178,30 @@ export function getSiteBySourceUrl(sourceUrl: string) { } /** - * Returns the available taxonomy slugs + * Returns the available taxonomies * * @param sourceUrl */ -export function getCustomTaxonomySlugs(sourceUrl?: string) { +export function getCustomTaxonomies(sourceUrl?: string) { const { customTaxonomies } = sourceUrl ? getSiteBySourceUrl(sourceUrl) : getHeadlessConfig(); - if (!customTaxonomies) { - return []; - } - - return customTaxonomies.map(({ slug }) => slug); + // at this point this is always an array + return customTaxonomies as CustomTaxonomies; } /** - * Returns the available taxonomies + * Returns the available taxonomy slugs * * @param sourceUrl */ -export function getCustomTaxonomies(sourceUrl?: string) { - const { customTaxonomies } = sourceUrl ? getSiteBySourceUrl(sourceUrl) : getHeadlessConfig(); - - const taxonomies = customTaxonomies || []; - - const hasCategory = taxonomies.find(({ slug }) => slug === 'category'); - const hasTag = taxonomies.find(({ slug }) => slug === 'post_tag'); - - if (!hasCategory) { - taxonomies.push({ - slug: 'category', - endpoint: endpoints.category, - restParam: 'categories', - }); - } +export function getCustomTaxonomySlugs(sourceUrl?: string) { + const customTaxonomies = getCustomTaxonomies(sourceUrl); - if (!hasTag) { - taxonomies.push({ - slug: 'post_tag', - endpoint: endpoints.tags, - rewrite: 'tag', - restParam: 'tags', - }); + if (!customTaxonomies) { + return []; } - return taxonomies; + return customTaxonomies.map(({ slug }) => slug); } /** @@ -201,51 +218,25 @@ export function getCustomTaxonomy(slug: string, sourceUrl?: string) { } /** - * Returns the available post type slugs + * Returns the available post types * * @param sourceUrl */ -export function getCustomPostTypesSlugs(sourceUrl?: string) { +export function getCustomPostTypes(sourceUrl?: string) { const { customPostTypes } = sourceUrl ? getSiteBySourceUrl(sourceUrl) : getHeadlessConfig(); - if (!customPostTypes) { - return []; - } - - return customPostTypes.map(({ slug }) => slug); + return customPostTypes as CustomPostTypes; } /** - * Returns the available post types + * Returns the available post type slugs * * @param sourceUrl */ -export function getCustomPostTypes(sourceUrl?: string) { - const { customPostTypes } = sourceUrl ? getSiteBySourceUrl(sourceUrl) : getHeadlessConfig(); - - const postTypes = customPostTypes || []; - - const hasPost = postTypes.find(({ slug }) => slug === 'post'); - const hasPage = postTypes.find(({ slug }) => slug === 'page'); - - if (!hasPage) { - postTypes.push({ - slug: 'page', - endpoint: '/wp-json/wp/v2/pages', - single: '/', - }); - } - - if (!hasPost) { - postTypes.push({ - slug: 'post', - endpoint: '/wp-json/wp/v2/posts', - single: '/', - archive: '/blog', - }); - } +export function getCustomPostTypesSlugs(sourceUrl?: string) { + const customPostTypes = getCustomPostTypes(sourceUrl); - return postTypes; + return customPostTypes.map(({ slug }) => slug); } /** @@ -264,7 +255,7 @@ export function getCustomPostType(slug: string, sourceUrl?: string) { * Returns the WP URL based on the headless config */ export function getWPUrl() { - const { sourceUrl } = getHeadlessConfig(); + const { sourceUrl } = getHeadstartWPConfig(); return sourceUrl || ''; } @@ -272,6 +263,6 @@ export function getWPUrl() { * Returns the WP URL based on the headless config */ export function getHostUrl() { - const { hostUrl } = getHeadlessConfig(); + const { hostUrl } = getHeadstartWPConfig(); return hostUrl || ''; } diff --git a/packages/core/src/utils/fetchRedirect.ts b/packages/core/src/utils/fetchRedirect.ts index 2b8a13162..60cd0cfe0 100644 --- a/packages/core/src/utils/fetchRedirect.ts +++ b/packages/core/src/utils/fetchRedirect.ts @@ -1,4 +1,4 @@ -import { getHeadlessConfig } from './getHeadlessConfig'; +import { getHeadlessConfig } from './config'; import { LOGTYPE, log } from './log'; import { removeSourceUrl } from './removeSourceUrl'; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 0446a8d50..fce380533 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,7 +1,7 @@ export * from './fetchRedirect'; export * from './isExternalUrl'; export * from './removeSourceUrl'; -export * from './getHeadlessConfig'; +export * from './config'; export * from './asyncForEach'; export * from './errors'; export * from './isInternalLink'; diff --git a/packages/core/src/utils/isInternalLink.ts b/packages/core/src/utils/isInternalLink.ts index f0c6a7907..2b1d331de 100644 --- a/packages/core/src/utils/isInternalLink.ts +++ b/packages/core/src/utils/isInternalLink.ts @@ -1,5 +1,5 @@ import { isExternalUrl } from './isExternalUrl'; -import { getWPUrl } from './getHeadlessConfig'; +import { getWPUrl } from './config'; import { removeSourceUrl } from './removeSourceUrl'; import { HeadlessConfig } from '../types'; diff --git a/packages/next/src/config/index.ts b/packages/next/src/config/index.ts index 56a106a7f..39e41d9dd 100644 --- a/packages/next/src/config/index.ts +++ b/packages/next/src/config/index.ts @@ -1 +1 @@ -export * from './withHeadlessConfig'; +export * from './withHeadstartWPConfig'; diff --git a/packages/next/src/config/withHeadlessConfig.ts b/packages/next/src/config/withHeadstartWPConfig.ts similarity index 88% rename from packages/next/src/config/withHeadlessConfig.ts rename to packages/next/src/config/withHeadstartWPConfig.ts index 44a84a667..414f8ed24 100644 --- a/packages/next/src/config/withHeadlessConfig.ts +++ b/packages/next/src/config/withHeadstartWPConfig.ts @@ -52,17 +52,29 @@ function traverse(rules) { * * @param {object} nextConfig The nextjs config object * @param {object} headlessConfig The headless config - * @param withHeadlessConfigOptions + * @param withHeadstarWPConfigOptions * @returns */ -export function withHeadlessConfig( +export function withHeadstarWPConfig( nextConfig: NextConfig = {}, headlessConfig: HeadlessConfig = {}, - withHeadlessConfigOptions: { injectConfig: boolean } = { injectConfig: true }, + withHeadstarWPConfigOptions: { injectConfig: boolean } = { injectConfig: true }, ): NextConfig { + const headlessConfigPath = `${process.cwd()}/headless.config.js`; + const headstartWpConfigPath = `${process.cwd()}/headstartwp.config.js`; + + const configPath = fs.existsSync(headstartWpConfigPath) + ? headstartWpConfigPath + : headlessConfigPath; + + if (Object.keys(headlessConfig).length === 0) { + // eslint-disable-next-line + headlessConfig = require(configPath); + } + if (!headlessConfig.sourceUrl && !headlessConfig.sites) { throw new ConfigError( - 'Missing sourceUrl in headless.config.js. Please add it to your headless.config.js file.', + 'Missing sourceUrl in headstartwp.config.js (or headless.config.js). Please add it to your config file.', ); } @@ -153,13 +165,6 @@ export function withHeadlessConfig( }, webpack: (config, options) => { - const headlessConfigPath = `${process.cwd()}/headless.config.js`; - const headstartWpConfigPath = `${process.cwd()}/headstartwp.config.js`; - - const configPath = fs.existsSync(headstartWpConfigPath) - ? headstartWpConfigPath - : headlessConfigPath; - const importSetHeadlessConfig = ` import { setHeadstartWPConfig as __setHeadstartWPConfig } from '@headstartwp/core/utils'; import __headlessConfig from '${configPath}'; @@ -197,7 +202,7 @@ export function withHeadlessConfig( rules: [ { test: (normalModule) => { - if (!withHeadlessConfigOptions.injectConfig) { + if (!withHeadstarWPConfigOptions.injectConfig) { return false; } @@ -259,3 +264,11 @@ export function withHeadlessConfig( }, }; } + +export function withHeadlessConfig( + nextConfig: NextConfig = {}, + headlessConfig: HeadlessConfig = {}, + withHeadstarWPConfigOptions: { injectConfig: boolean } = { injectConfig: true }, +) { + return withHeadstarWPConfig(nextConfig, headlessConfig, withHeadstarWPConfigOptions); +} diff --git a/projects/wp-nextjs/headless.config.js b/projects/wp-nextjs/headstartwp.config.js similarity index 75% rename from projects/wp-nextjs/headless.config.js rename to projects/wp-nextjs/headstartwp.config.js index 24aaec4db..ab0c99146 100644 --- a/projects/wp-nextjs/headless.config.js +++ b/projects/wp-nextjs/headstartwp.config.js @@ -19,14 +19,20 @@ module.exports = { archive: '/books', }, ], - customTaxonomies: [ - // this is just an example - { - slug: 'genre', - endpoint: '/wp-json/wp/v2/genre', - }, - ], + customTaxonomies: (defaultTaxonomies) => { + return [ + // turn on matchArchivePath for default taxonomies + ...defaultTaxonomies.map((taxonomy) => ({ ...taxonomy, matchArchivePath: true })), + // this is just an example + { + slug: 'genre', + endpoint: '/wp-json/wp/v2/genre', + }, + ]; + }, + redirectStrategy: '404', + /** * Using 10up's headless plugin is recommended */ diff --git a/projects/wp-nextjs/next.config.js b/projects/wp-nextjs/next.config.js index 3a86848d0..562bb8a4d 100644 --- a/projects/wp-nextjs/next.config.js +++ b/projects/wp-nextjs/next.config.js @@ -1,11 +1,9 @@ -const { withHeadlessConfig } = require('@headstartwp/next/config'); +const { withHeadstarWPConfig } = require('@headstartwp/next/config'); const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); -const headlessConfig = require('./headless.config'); - /** * Update whatever you need within the nextConfig object. * @@ -38,4 +36,4 @@ if (process.env.NEXT_REDIS_URL || process.env.VIP_REDIS_PRIMARY) { incrementalCacheHandlerPath: require.resolve('@10up/next-redis-cache-provider'), }; } -module.exports = withBundleAnalyzer(withHeadlessConfig(nextConfig, headlessConfig)); +module.exports = withBundleAnalyzer(withHeadstarWPConfig(nextConfig));