diff --git a/docs/nav.json b/docs/nav.json index 183e8286f..c59af0b8a 100644 --- a/docs/nav.json +++ b/docs/nav.json @@ -117,6 +117,10 @@ "title": "useBlocksTheme", "route": "/docs/reference/use-blocks-theme" }, + { + "title": "useBodyClass", + "route": "/docs/reference/use-body-class" + }, { "title": "useLogin", "route": "/docs/reference/use-login" diff --git a/docs/reference/use-body-class/index.md b/docs/reference/use-body-class/index.md new file mode 100644 index 000000000..a8a788969 --- /dev/null +++ b/docs/reference/use-body-class/index.md @@ -0,0 +1,51 @@ +--- +title: "useBodyClass" +description: "Reference for adding WordPress-style body classes like single-post or category-news to Faust.js pages." +--- + +WordPress themes add classes like `home`, `single-post`, `postid-123` and `category-news` to ``. Faust.js can add the same classes, so CSS ported from a theme keeps working. + +## Add body classes to every WordPress page + +Pass `bodyClass` to `WordPressTemplate` on every page that renders it. In the getting-started example, that's `pages/index.js`, `pages/[...wordpressNode].js` and `pages/preview.js`: + +```js title="pages/[...wordpressNode].js" +export default function Page(props) { + return ; +} +``` + +The classes are worked out from the seed node Faust.js already loads, and they also work in previews. They're off unless you set `bodyClass`. + +| Page | Classes | +| --- | --- | +| Static front page | `home`, plus its page classes | +| Posts page | `blog` | +| Single post or custom post type | `single`, `single-{post type}`, `postid-{id}` | +| Attachment | `single`, `single-attachment`, `postid-{id}`, `attachment`, `attachmentid-{id}` | +| Page | `page`, `page-id-{id}`, `page-template-default` or `page-template-{template name}` | +| Category | `archive`, `category`, `category-{slug}`, `category-{id}` | +| Tag | `archive`, `tag`, `tag-{slug}`, `tag-{id}` | +| Other taxonomy term | `archive`, `tax-{taxonomy}`, `term-{slug}`, `term-{id}` | +| Author | `archive`, `author`, `author-{id}` | +| Post type archive | `archive`, `post-type-archive`, `post-type-archive-{post type}` | + +Previews also get `logged-in`, since you have to be logged in to see them. When the Faust toolbar is showing, it adds `admin-bar`, even without `bodyClass`. + +These are a subset of what WordPress's `body_class()` adds. Page template classes use the template's name, such as `page-template-full-width` for "Full Width", rather than its file name, and filters on `body_class` don't apply. As in WordPress, characters that can't go in a class name are removed from slugs. + +## useBodyClass + +To add your own classes, call the `useBodyClass` hook from any component. The classes are added while the component is mounted and removed when it unmounts or the list changes. + +```js +import { useBodyClass } from "@faustwp/core"; + +export default function Landing() { + useBodyClass(["landing", "dark"]); + + return
…
; +} +``` + +To get the WordPress classes yourself, for example to add them to an element other than ``, call `getBodyClasses(seedNode)`. It returns them as an array. diff --git a/examples/next/faustwp-getting-started/pages/[...wordpressNode].js b/examples/next/faustwp-getting-started/pages/[...wordpressNode].js index fe51bbcc9..4e684216d 100644 --- a/examples/next/faustwp-getting-started/pages/[...wordpressNode].js +++ b/examples/next/faustwp-getting-started/pages/[...wordpressNode].js @@ -1,7 +1,7 @@ import { getWordPressProps, WordPressTemplate } from '@faustwp/core'; export default function Page(props) { - return ; + return ; } export function getStaticProps(ctx) { diff --git a/examples/next/faustwp-getting-started/pages/index.js b/examples/next/faustwp-getting-started/pages/index.js index 37681a6cd..aa3cf6cfe 100644 --- a/examples/next/faustwp-getting-started/pages/index.js +++ b/examples/next/faustwp-getting-started/pages/index.js @@ -1,7 +1,7 @@ import { getWordPressProps, WordPressTemplate } from '@faustwp/core'; export default function Page(props) { - return ; + return ; } export function getStaticProps(ctx) { diff --git a/examples/next/faustwp-getting-started/pages/preview.js b/examples/next/faustwp-getting-started/pages/preview.js index a4b886e8e..bed201a14 100644 --- a/examples/next/faustwp-getting-started/pages/preview.js +++ b/examples/next/faustwp-getting-started/pages/preview.js @@ -1,5 +1,5 @@ import { WordPressTemplate } from '@faustwp/core'; export default function Preview(props) { - return ; + return ; } diff --git a/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx b/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx index 4a310f53a..b56a028bd 100644 --- a/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx +++ b/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx @@ -2,6 +2,7 @@ import { gql, useQuery } from '@apollo/client'; import React, { useEffect, useMemo, useState } from 'react'; import { getApolloAuthClient } from '../../client.js'; import { useAuth } from '../../hooks/useAuth.js'; +import { useBodyClass } from '../../hooks/useBodyClass.js'; import { SeedNode } from '../../queries/seedQuery.js'; import { hooks } from '../../wpHooks/index.js'; import { Edit } from './nodes/Edit.js'; @@ -114,21 +115,8 @@ export function AuthenticatedToolbar({ seedNode }: ToolbarProps) { setToolbarNodes(filteredNodes); }, [coreToolbarNodes, seedNode]); - /** - * Handle adding `admin-bar` body class on render. - * - * This could eventually be handled with a Faust bodyClass hook - * @link https://developer.wordpress.org/reference/hooks/body_class/ - */ - useEffect(() => { - document?.body.classList.add('admin-bar'); - - // Cleanup body class when this component unmounts. - // eslint-disable-next-line consistent-return - return () => { - document?.body.classList.remove('admin-bar'); - }; - }, []); + // Makes room for the toolbar. See styles/_body-classes.scss. + useBodyClass(['admin-bar']); const primaryNodes = toolbarNodes.filter( ({ location }) => location === 'primary', diff --git a/packages/faustwp-core/src/components/WordPressTemplate.tsx b/packages/faustwp-core/src/components/WordPressTemplate.tsx index 10f6feb20..ad3ec5d00 100644 --- a/packages/faustwp-core/src/components/WordPressTemplate.tsx +++ b/packages/faustwp-core/src/components/WordPressTemplate.tsx @@ -17,6 +17,7 @@ import { loadDynamicComponent, } from '../getTemplate.js'; import { useAuth } from '../hooks/useAuth.js'; +import { useBodyClass } from '../hooks/useBodyClass.js'; import { SEED_QUERY, SeedNode, @@ -24,10 +25,16 @@ import { } from '../queries/seedQuery.js'; import { FaustContext, FaustQueries } from '../store/FaustContext.js'; import { getQueryParam } from '../utils/convert.js'; +import { getBodyClasses } from '../utils/getBodyClasses.js'; import { isWordPressPreview } from '../utils/isWordPressPreview.js'; import type { WordPressTemplate as WordPressTemplateType } from '../getWordPressProps.js'; export type FaustProps = { + /** + * Add WordPress-style classes like `single-post` or `category-news` to + * ``. See getBodyClasses(). + */ + bodyClass?: boolean; __SEED_NODE__?: SeedNode | null; __FAUST_QUERIES__?: FaustQueries | null; __TEMPLATE_QUERY_DATA__?: any | null; @@ -56,6 +63,29 @@ function checkDuplicateQueryQueries(template: WordPressTemplateType): void { } } +/** + * Body classes for the template's page. + * + * A preview's seed node is the autosave revision, so take the ID of the post + * being previewed from the URL instead. Users are only known to be logged in + * during previews. + */ +function getTemplateBodyClasses( + seedNode: SeedNode, + isPreview: boolean, + isAuthenticated: boolean | null, +): string[] { + const previewId = + isPreview && typeof window !== 'undefined' + ? getQueryParam(window.location.href, 'p') + : ''; + const classes = getBodyClasses( + previewId ? { ...seedNode, databaseId: previewId } : seedNode, + ); + + return isAuthenticated ? [...classes, 'logged-in'] : classes; +} + export function WordPressTemplateInternal( props: WordPressTemplateProps & { seedNode: SeedNode; @@ -72,6 +102,7 @@ export function WordPressTemplateInternal( } const { + bodyClass, seedNode, isAuthenticated, isPreview, @@ -80,6 +111,13 @@ export function WordPressTemplateInternal( setLoading, ...wordpressTemplateProps } = props; + + useBodyClass( + bodyClass + ? getTemplateBodyClasses(seedNode, isPreview, isAuthenticated) + : [], + ); + const unknownTemplate = getTemplate(seedNode, templates); const isDynamic = isDynamicComponent(unknownTemplate); const [data, setData] = useState(templateQueryDataProp); diff --git a/packages/faustwp-core/src/hooks/useBodyClass.tsx b/packages/faustwp-core/src/hooks/useBodyClass.tsx new file mode 100644 index 000000000..46981dca6 --- /dev/null +++ b/packages/faustwp-core/src/hooks/useBodyClass.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; + +/** + * Add classes to the page's `` while the component is mounted. + * + * The classes are removed when the component unmounts or the list changes. + * If two components add the same class, the first to remove it removes it for + * both. + * + * @param classes The classes to add. + */ +export function useBodyClass(classes: string[]): void { + // Compare by value, so a new array with the same classes doesn't re-run the effect. + const classNames = classes.filter(Boolean).join(' '); + + useEffect(() => { + const list = classNames.split(' ').filter(Boolean); + + if (list.length === 0) { + return undefined; + } + + document.body.classList.add(...list); + + return () => { + document.body.classList.remove(...list); + }; + }, [classNames]); +} diff --git a/packages/faustwp-core/src/index.ts b/packages/faustwp-core/src/index.ts index e3389dab3..90f3d992d 100644 --- a/packages/faustwp-core/src/index.ts +++ b/packages/faustwp-core/src/index.ts @@ -30,6 +30,8 @@ import { } from './server/sitemaps/getSitemapProps.js'; import { useAuth } from './hooks/useAuth.js'; import { useLogin } from './hooks/useLogin.js'; +import { useBodyClass } from './hooks/useBodyClass.js'; +import { getBodyClasses } from './utils/getBodyClasses.js'; import { useLogout } from './hooks/useLogout.js'; import { useFaustQuery } from './hooks/useFaustContext.js'; import { FaustContext } from './store/FaustContext.js'; @@ -74,6 +76,8 @@ export { GetSitemapPropsConfig, useAuth, useLogin, + useBodyClass, + getBodyClasses, useLogout, FaustToolbarNodes, FaustToolbarContext, diff --git a/packages/faustwp-core/src/utils/getBodyClasses.ts b/packages/faustwp-core/src/utils/getBodyClasses.ts new file mode 100644 index 000000000..e731d9195 --- /dev/null +++ b/packages/faustwp-core/src/utils/getBodyClasses.ts @@ -0,0 +1,121 @@ +import type { SeedNode } from '../queries/seedQuery.js'; + +/** + * Make a slug safe to use in a class name, like WordPress's + * sanitize_html_class(): percent-encoded characters and anything other than + * letters, numbers, underscores and hyphens are removed. + */ +function sanitizeClass(value?: string): string { + return (value ?? '') + .replace(/%[a-fA-F0-9]{2}/g, '') + .replace(/[^A-Za-z0-9_-]/g, ''); +} + +/** + * Turn a page template name like "Full Width" into "full-width". + */ +function toClassName(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Get WordPress-style body classes for a page from its seed node. + * + * The classes follow the names WordPress's `body_class()` uses, such as + * `home`, `single-post`, `postid-123` and `category-news`, so CSS written for a + * WordPress theme keeps working. They're worked out from the seed node alone, + * so they're a subset of what WordPress adds, and page template classes use + * the template's name rather than its file name. + * + * @param seedNode The seed node for the current page. + * + * @returns The body classes. + */ +export function getBodyClasses(seedNode?: SeedNode | null): string[] { + if (!seedNode) { + return []; + } + + const classes: string[] = []; + const id = seedNode.databaseId; + // eslint-disable-next-line no-underscore-dangle + const typeName = seedNode.__typename; + + if (seedNode.isFrontPage) { + classes.push('home'); + } + + // The posts page lists posts, so it gets no page or archive classes, even + // when a preview resolves it to a Page. + if (seedNode.isPostsPage) { + classes.push('blog'); + + return classes; + } + + if (seedNode.isContentNode) { + const postType = seedNode.contentType?.node?.name; + const templateName = seedNode.template?.templateName; + + if (postType === 'page') { + classes.push('page', `page-id-${id}`); + + if (templateName) { + classes.push( + templateName === 'Default' + ? 'page-template-default' + : `page-template-${toClassName(templateName)}`, + ); + } + } else if (postType) { + classes.push('single', `single-${postType}`, `postid-${id}`); + + if (postType === 'attachment') { + classes.push('attachment', `attachmentid-${id}`); + } + } + + return classes; + } + + if (seedNode.isTermNode) { + const { taxonomyName } = seedNode; + const slug = sanitizeClass(seedNode.slug); + // Like WordPress, skip the slug class if nothing usable is left. + const termClasses = (prefix: string) => + slug ? [`${prefix}-${slug}`, `${prefix}-${id}`] : [`${prefix}-${id}`]; + + if (taxonomyName === 'category') { + classes.push('archive', 'category', ...termClasses('category')); + } else if (taxonomyName === 'post_tag') { + classes.push('archive', 'tag', ...termClasses('tag')); + } else if (taxonomyName) { + classes.push( + 'archive', + `tax-${sanitizeClass(taxonomyName)}`, + ...termClasses('term'), + ); + } + + return classes; + } + + if (typeName === 'User') { + classes.push('archive', 'author', `author-${id}`); + + return classes; + } + + if (typeName === 'ContentType' && seedNode.name) { + classes.push( + 'archive', + 'post-type-archive', + `post-type-archive-${seedNode.name}`, + ); + } + + return classes; +} diff --git a/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx b/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx index b6406465e..8193eb704 100644 --- a/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx +++ b/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx @@ -349,6 +349,7 @@ describe(' client-side query variables', () => { afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); + window.history.pushState({}, '', '/'); }); const props = { @@ -431,4 +432,63 @@ describe(' client-side query variables', () => { variables: { postId: '1' }, }); }); + + test('adds WordPress body classes only when bodyClass is set', async () => { + const MyTemplate = () => <>My component; + jest.spyOn(getTemplate, 'getTemplate').mockReturnValue(MyTemplate); + const seedNode = { + databaseId: '7', + isContentNode: true, + contentType: { node: { name: 'post' } }, + }; + + let view = render( + , + ); + expect(document.body.classList.contains('single-post')).toBe(false); + view.unmount(); + + view = render( + , + ); + expect(document.body.classList.contains('single-post')).toBe(true); + expect(document.body.classList.contains('postid-7')).toBe(true); + + view.unmount(); + expect(document.body.classList.contains('single-post')).toBe(false); + }); + + test("uses the previewed post's ID, not the autosave revision's, and adds logged-in", () => { + const MyTemplate = () => <>My component; + jest.spyOn(getTemplate, 'getTemplate').mockReturnValue(MyTemplate); + window.history.pushState({}, '', '/preview?p=2&preview=true'); + const revision = { + databaseId: '72', + isContentNode: true, + contentType: { node: { name: 'page' } }, + }; + + const view = render( + , + ); + + expect(document.body.classList.contains('page-id-2')).toBe(true); + expect(document.body.classList.contains('page-id-72')).toBe(false); + expect(document.body.classList.contains('logged-in')).toBe(true); + + view.unmount(); + }); }); diff --git a/packages/faustwp-core/tests/hooks/useBodyClass.test.ts b/packages/faustwp-core/tests/hooks/useBodyClass.test.ts new file mode 100644 index 000000000..e52f87f67 --- /dev/null +++ b/packages/faustwp-core/tests/hooks/useBodyClass.test.ts @@ -0,0 +1,40 @@ +/** + * @jest-environment jsdom + */ + +import { renderHook } from '@testing-library/react'; +import { useBodyClass } from '../../src/hooks/useBodyClass'; + +describe('useBodyClass()', () => { + afterEach(() => { + document.body.className = ''; + }); + + test('adds the classes and removes them on unmount', () => { + document.body.classList.add('theirs'); + + const { unmount } = renderHook(() => useBodyClass(['single', 'postid-7'])); + + expect(document.body.className).toBe('theirs single postid-7'); + + unmount(); + + expect(document.body.className).toBe('theirs'); + }); + + test('swaps the classes when they change', () => { + const { rerender } = renderHook(({ classes }) => useBodyClass(classes), { + initialProps: { classes: ['single', 'postid-7'] }, + }); + + rerender({ classes: ['page', 'page-id-9'] }); + + expect(document.body.className).toBe('page page-id-9'); + }); + + test('does nothing for an empty list', () => { + renderHook(() => useBodyClass([])); + + expect(document.body.className).toBe(''); + }); +}); diff --git a/packages/faustwp-core/tests/utils/getBodyClasses.test.ts b/packages/faustwp-core/tests/utils/getBodyClasses.test.ts new file mode 100644 index 000000000..16aea49d7 --- /dev/null +++ b/packages/faustwp-core/tests/utils/getBodyClasses.test.ts @@ -0,0 +1,143 @@ +import { getBodyClasses } from '../../src/utils/getBodyClasses'; + +describe('getBodyClasses()', () => { + const post = (name: string, extra = {}) => ({ + __typename: 'Post', + databaseId: '7', + isContentNode: true, + contentType: { node: { name } }, + ...extra, + }); + + test('returns no classes without a seed node', () => { + expect(getBodyClasses(null)).toEqual([]); + expect(getBodyClasses(undefined)).toEqual([]); + }); + + test('single posts and custom post types', () => { + expect(getBodyClasses(post('post'))).toEqual([ + 'single', + 'single-post', + 'postid-7', + ]); + expect(getBodyClasses(post('movie'))).toEqual([ + 'single', + 'single-movie', + 'postid-7', + ]); + }); + + test('attachments', () => { + expect(getBodyClasses(post('attachment'))).toEqual([ + 'single', + 'single-attachment', + 'postid-7', + 'attachment', + 'attachmentid-7', + ]); + }); + + test('pages with the default and a custom template', () => { + expect( + getBodyClasses(post('page', { template: { templateName: 'Default' } })), + ).toEqual(['page', 'page-id-7', 'page-template-default']); + expect( + getBodyClasses( + post('page', { template: { templateName: 'Full Width' } }), + ), + ).toEqual(['page', 'page-id-7', 'page-template-full-width']); + }); + + test('a static front page', () => { + expect(getBodyClasses(post('page', { isFrontPage: true }))).toEqual([ + 'home', + 'page', + 'page-id-7', + ]); + }); + + test('the posts page, also when it is the front page', () => { + const postsPage = { + __typename: 'ContentType', + name: 'post', + isPostsPage: true, + }; + + expect(getBodyClasses(postsPage)).toEqual(['blog']); + expect(getBodyClasses({ ...postsPage, isFrontPage: true })).toEqual([ + 'home', + 'blog', + ]); + }); + + test('a posts page that a preview resolves to a Page gets only blog', () => { + expect(getBodyClasses(post('page', { isPostsPage: true }))).toEqual([ + 'blog', + ]); + }); + + test('categories, tags and other taxonomies', () => { + const term = (taxonomyName: string) => ({ + __typename: 'Term', + databaseId: '3', + isTermNode: true, + slug: 'news', + taxonomyName, + }); + + expect(getBodyClasses(term('category'))).toEqual([ + 'archive', + 'category', + 'category-news', + 'category-3', + ]); + expect(getBodyClasses(term('post_tag'))).toEqual([ + 'archive', + 'tag', + 'tag-news', + 'tag-3', + ]); + expect(getBodyClasses(term('genre'))).toEqual([ + 'archive', + 'tax-genre', + 'term-news', + 'term-3', + ]); + }); + + test('author archives', () => { + expect(getBodyClasses({ __typename: 'User', databaseId: '2' })).toEqual([ + 'archive', + 'author', + 'author-2', + ]); + }); + + test('post type archives', () => { + expect( + getBodyClasses({ __typename: 'ContentType', name: 'movie' }), + ).toEqual(['archive', 'post-type-archive', 'post-type-archive-movie']); + }); + + test('removes percent-encoded characters from slugs, like WordPress', () => { + const term = (slug: string) => ({ + __typename: 'Term', + databaseId: '3', + isTermNode: true, + slug, + taxonomyName: 'category', + }); + + expect(getBodyClasses(term('caf%c3%a9-news'))).toEqual([ + 'archive', + 'category', + 'category-caf-news', + 'category-3', + ]); + expect(getBodyClasses(term('%e6%97%a5%e6%9c%ac'))).toEqual([ + 'archive', + 'category', + 'category-3', + ]); + }); +});