Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: set cookie path when entering preview mode #552

Merged
merged 4 commits into from
Jul 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/lovely-chicken-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@headstartwp/next": minor
"@headstartwp/headstartwp": patch
"@headstartwp/core": patch
---

Improves the Next.js preview cookie handling and fixes a bug where the locale was not properly being passed from WP when previewing.

First of all, it sets the preview cookie to expire within 5 minutes which aligns with the JWT token expiration.

Secondly, it will narrow the cookie to the post path being previewed so that `context.preview` is not true for other paths and thus avoiding bypassing getStaticProps until the cookies are cleared (either expires or the browser closes).
64 changes: 43 additions & 21 deletions docs/documentation/06-WordPress Integration/previews.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@ Below is a summary of the preview workflow.
- The token is sent alongside the post_type, post_id and a boolean indicating whether the post being previewed is a revision or not.
- The token is verified against the parameters and the token is used to fetch the post's draft/revision content.



## Usage

The Next.js project **must** expose a `api/preview` endpoint that uses the [previewHandler](/api/modules/headstartwp_next/#previewhandler).
Expand Down Expand Up @@ -85,38 +83,62 @@ export default async function handler(req, res) {

`name` would now be available in the context object of `getServerSideProps` and `getStaticProps` (`ctx.previewData`);

#### `onRedirect`
#### `getRedirectPath`

The `onRedirect` option allows you to customize the redirected URL that should handle the preview request. This can be useful if you have implemented a non-standard URL structure. For instance, if the permalink for your posts are `/%category%/%postname%/` you could create a `src/pages/[category]/[...path.js]` route to handle single post. However once you do that the `previewHandler` doesn't know how to redirect to that URL and as such you will have to provide your own redirect handling.
:::info
This option was added in `@headstartwp/next@1.1.0`.
:::info

:::caution
When handling redirects yourself, make sure to always append `-preview=true` to the end of the redirected URL.
:::caution
The `getRedirectPath` option allows you to customize the redirected URL that should handle the preview request. This can be useful if you have implemented a non-standard URL structure. For instance, if the permalink for your posts are `/%category%/%postname%/` you could create a `/src/pages/[category]/[...path.js]` route to handle single post. However, once you do that the `previewHandler` doesn't know how to redirect to that URL and as such you will have to provide your own redirect handling.

The framework will also use this value to restrict the preview cookie to the post being previewed to avoid bypassing `getStaticProps` until the cookie expires or the browser is closed. See the [Next.js docs](https://nextjs.org/docs/pages/building-your-application/configuring/preview-mode#specify-the-preview-mode-duration) for more info.

```ts
import { getPostTerms } from '@headstartwp/core';
import { previewHandler } from '@headstartwp/next';

export default async function handler(req, res) {
return previewHandler(req, res, {
// add categorySlug and post slug to preview data
preparePreviewData(req, res, post, previewData) {
const terms = getPostTerms(post);
if (Array.isArray(terms?.category) && terms.category.length > 0) {
const [category] = terms.category;
getRedirectPath(defaultRedirectPath, post) {
const { type, id, slug } = post;

return { ...previewData, categorySlug: category.slug, slug: post.slug };
if (type === 'post') {
const terms = getPostTerms(post);

if (Array.isArray(terms?.category) && terms.category.length > 0) {
const [category] = terms.category;

return `/${categorySlug}/${id}/${slug || id}`;
}
}
return { ...previewData };

return defaultRedirectPath
},
onRedirect(req, res, previewData, defaultRedirect) {
const { postType, id, slug, categorySlug } = previewData;
});
}
```

if (postType === 'post' && typeof categorySlug === 'string') {
return res.redirect(`/${categorySlug}/${id}/${slug || id}-preview=true`);
}
#### `onRedirect`

return defaultRedirect(req, res, previewData);
:::caution
Instead of implementing `onRedirect` we recommend implementing `getRedirectPath` instead as that will only enable the preview cookie for
the post being previewed.
:::caution

The `onRedirect` gives you full access to the `req` and `res` objects. If you do need implement this function we recommend also implementing `getRedirectPath`.

:::caution
When handling redirects yourself, make sure to always append `-preview=true` to the end of the redirected URL.
:::caution

```ts
import { getPostTerms } from '@headstartwp/core';
import { previewHandler } from '@headstartwp/next';

export default async function handler(req, res) {
return previewHandler(req, res, {
onRedirect(req, res, previewData) {
return res.redirect('/custom-path-preview-true');
},
});
}
Expand All @@ -126,7 +148,7 @@ export default async function handler(req, res) {

**After a while, the preview URL stops working**

The JWT token expires after 5 min by default, after this period, open another preview window from WordPress to preview the post.
The JWT token expires after 5 min by default, after this period, open another preview window from WordPress to preview the post. The Next.js preview cookie also last for only 5 minutes.

**I'm unable to preview a custom post type**

Expand Down
14 changes: 10 additions & 4 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
export type CustomPostTypes = Array<{
export type CustomPostType = {
slug: string;
endpoint: string;
single?: string;
archive?: string;
}>;
};

export type CustomPostTypes = Array<CustomPostType>;

export type RedirectStrategy = '404' | 'none' | 'always';
export type CustomTaxonomies = Array<{

export type CustomTaxonomy = {
slug: string;
endpoint: string;
rewrite?: string;
restParam?: string;
}>;
};

export type CustomTaxonomies = Array<CustomTaxonomy>;

export interface Integration {
enable: boolean;
Expand Down
74 changes: 74 additions & 0 deletions packages/next/src/handlers/__tests__/previewHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,78 @@ describe('previewHandler', () => {
expect(res.setPreviewData).toHaveBeenCalled();
expect(res._getStatusCode()).toBe(302);
});

it('sets preview cookie path', async () => {
const { req, res } = createMocks({
method: 'GET',
query: { post_id: DRAFT_POST_ID, token: VALID_AUTH_TOKEN, post_type: 'post' },
});

res.setPreviewData = jest.fn();
await previewHandler(req, res);

expect(res.setPreviewData).toHaveBeenCalledWith(
{
authToken: 'this is a valid auth',
id: 57,
postType: 'post',
revision: false,
},
{ maxAge: 300, path: '/modi-qui-dignissimos-sed-assumenda-sint-iusto-preview=true' },
);
expect(res._getStatusCode()).toBe(302);
});

it('set preview cookie path to all paths if onRedirect is passed without getRedirectPath', async () => {
const { req, res } = createMocks({
method: 'GET',
query: { post_id: DRAFT_POST_ID, token: VALID_AUTH_TOKEN, post_type: 'post' },
});

res.setPreviewData = jest.fn();
await previewHandler(req, res, {
onRedirect(req, res) {
return res.redirect('/');
},
});

expect(res.setPreviewData).toHaveBeenCalledWith(
{
authToken: 'this is a valid auth',
id: 57,
postType: 'post',
revision: false,
},
{ maxAge: 300, path: '/' },
);
expect(res._getStatusCode()).toBe(302);
});

it('set preview cookie path redirectPath if getRedirectPath is passed', async () => {
const { req, res } = createMocks({
method: 'GET',
query: { post_id: DRAFT_POST_ID, token: VALID_AUTH_TOKEN, post_type: 'post' },
});

res.setPreviewData = jest.fn();
await previewHandler(req, res, {
getRedirectPath() {
return '/custom-redirect-path/';
},
onRedirect(req, res) {
return res.redirect('/');
},
});

expect(res.setPreviewData).toHaveBeenCalledWith(
{
authToken: 'this is a valid auth',
id: 57,
postType: 'post',
revision: false,
},
{ maxAge: 300, path: '/custom-redirect-path-preview=true' },
);
expect(res._getStatusCode()).toBe(302);
});
});
81 changes: 73 additions & 8 deletions packages/next/src/handlers/previewHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getSiteByHost, PostEntity } from '@headstartwp/core';
import { CustomPostType, getSiteByHost, PostEntity } from '@headstartwp/core';
import { getCustomPostType, getHeadlessConfig } from '@headstartwp/core/utils';
import type { NextApiRequest, NextApiResponse } from 'next';
import { fetchHookData, usePost } from '../data';
Expand All @@ -9,7 +9,9 @@ import { PreviewData } from './types';
*/
export type PreviewHandlerOptions = {
/**
* If passed will override the behavior of redirecting to the previewed post.
* If passed will override the behavior of redirecting to the previewed post. We recommend implementing `getRedirectPath` instead. If you
* absolutely need to implement a custom redirect handler, we also suggest you implement `getRedirectPath` so that the preview cookie only
* applies to the specific path.
*
* If set you should handle the redirect yourself by calling `res.redirect`.
*
Expand All @@ -29,14 +31,38 @@ export type PreviewHandlerOptions = {
* });
* }
* ```
*
* @param req The NextApiRequest object
* @param res The NextApiResponse object
* @param previewData The previewData object
* @param defaultRedirect The default redirect function
* @param redirectPath The default redirect path or the one implemented in {@link PreviewHandlerOptions['getRedirectPath']}
*/
onRedirect?: (
req: NextApiRequest,
res: NextApiResponse,
previewData: PreviewData,
defaultRedirect?: PreviewHandlerOptions['onRedirect'],
redirectpath?: string,
) => NextApiResponse;

/**
* If passed will override the default redirect path
*
* **Important**: You should not need to override this but if you do, uou must append `-preview=true` to the end of the redirecte path.
*
* @param defaultRedirectPath the default redirect path
* @param post PostEntity
* @param postTypeDef The object describing a post type
*
* @returns the new redirect path
*/
getRedirectPath?: (
defaultRedirectPath: string,
post: any,
postTypeDef: CustomPostType,
) => string;

/**
* If passed, this function will be called when the preview data is fetched and allows
* for additional preview data to be set.
Expand All @@ -52,6 +78,18 @@ export type PreviewHandlerOptions = {
) => PreviewData;
};

function withPreviewSuffix(path: string) {
const suffix = '-preview=true';
// remove trailing slash
const normalizePath = path.replace(/\/+$/, '');

if (normalizePath.endsWith(suffix)) {
return normalizePath;
}

return `${[normalizePath]}${suffix}`;
}

/**
* The PreviewHandler is responsible for handling preview requests.
*
Expand Down Expand Up @@ -141,28 +179,55 @@ export async function previewHandler(
previewData = options.preparePreviewData(req, res, result, previewData);
}

res.setPreviewData(previewData);

const postTypeDef = getCustomPostType(post_type as string, sourceUrl);

if (!postTypeDef) {
return res.end('Cannot preview an unknown post type');
}

const defaultRedirect: PreviewHandlerOptions['onRedirect'] = (req, res) => {
/**
* Builds the default redirect path
*
* @returns the default redirec tpath
*/
const getDefaultRedirectPath = () => {
const singleRoute = postTypeDef.single || '/';
const prefixRoute = singleRoute === '/' ? '' : singleRoute;
const slugOrId = revision ? post_id : slug || post_id;

if (locale) {
return res.redirect(`/${locale}/${prefixRoute}/${slugOrId}-preview=true`);
return `/${locale}/${prefixRoute}/${slugOrId}`;
}

return res.redirect(`${prefixRoute}/${slugOrId}-preview=true`);
return `${prefixRoute}/${slugOrId}`;
};

const redirectPath =
typeof options.getRedirectPath === 'function'
? withPreviewSuffix(
options.getRedirectPath(getDefaultRedirectPath(), result, postTypeDef),
)
: withPreviewSuffix(getDefaultRedirectPath());

// we should set the path cookie if onRedirect is undefined (i.e we're just using default behasvior)
// or if user has supplied getRedirectPath from which we can get the actual path
const shouldSetPathInCookie =
typeof options.onRedirect === 'undefined' ||
typeof options.getRedirectPath === 'function';

res.setPreviewData(previewData, {
maxAge: 5 * 60,
// we can only safely narrow the cookei to a path if getRedirectPath is implemented or
// it's using the default behavior without a custom onRedirect
path: shouldSetPathInCookie ? redirectPath : '/',
});

const defaultRedirect: PreviewHandlerOptions['onRedirect'] = (req, res) => {
return res.redirect(redirectPath);
};

if (options?.onRedirect) {
return options.onRedirect(req, res, previewData, defaultRedirect);
return options.onRedirect(req, res, previewData, defaultRedirect, redirectPath);
}

return defaultRedirect(req, res, previewData);
Expand Down
2 changes: 1 addition & 1 deletion wp/headless-wp/includes/classes/Preview/preview.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
$post_type,
$is_revision ? '1' : '0',
$token,
Plugin::get_site_locale()
$locale
);

wp_redirect( $preview_url );
Expand Down
Loading