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',
+ ]);
+ });
+});