From 7c944fd50a79731f61852380843a76497dcf7f11 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 21 Oct 2026 11:25:05 -0400 Subject: [PATCH 1/7] feat(core): add WordPress-style body classes with WordPressTemplate's bodyClass prop and useBodyClass --- docs/nav.json | 4 + docs/reference/use-body-class/index.md | 51 ++++++++ .../src/components/Toolbar/Toolbar.tsx | 18 +-- .../src/components/WordPressTemplate.tsx | 11 ++ .../faustwp-core/src/hooks/useBodyClass.tsx | 29 +++++ packages/faustwp-core/src/index.ts | 4 + .../faustwp-core/src/utils/getBodyClasses.ts | 104 ++++++++++++++++ .../components/WordPressTemplate.test.tsx | 32 +++++ .../tests/hooks/useBodyClass.test.ts | 40 ++++++ .../tests/utils/getBodyClasses.test.ts | 115 ++++++++++++++++++ 10 files changed, 393 insertions(+), 15 deletions(-) create mode 100644 docs/reference/use-body-class/index.md create mode 100644 packages/faustwp-core/src/hooks/useBodyClass.tsx create mode 100644 packages/faustwp-core/src/utils/getBodyClasses.ts create mode 100644 packages/faustwp-core/tests/hooks/useBodyClass.test.ts create mode 100644 packages/faustwp-core/tests/utils/getBodyClasses.test.ts 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..ac321b2ba --- /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`: + +```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}` | + +When the Faust toolbar is showing, it adds `admin-bar` and `logged-in`. + +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. + +## 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/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx b/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx index 4a310f53a..150fbc061 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'); - }; - }, []); + // The toolbar only shows for logged-in users, like WordPress's admin bar. + useBodyClass(['admin-bar', 'logged-in']); 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..0158df81b 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; @@ -72,6 +79,7 @@ export function WordPressTemplateInternal( } const { + bodyClass, seedNode, isAuthenticated, isPreview, @@ -80,6 +88,9 @@ export function WordPressTemplateInternal( setLoading, ...wordpressTemplateProps } = props; + + useBodyClass(bodyClass ? getBodyClasses(seedNode) : []); + 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..255c50d99 --- /dev/null +++ b/packages/faustwp-core/src/utils/getBodyClasses.ts @@ -0,0 +1,104 @@ +import type { SeedNode } from '../queries/seedQuery.js'; + +/** + * 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'); + } + + if (seedNode.isPostsPage) { + classes.push('blog'); + } + + 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 { slug, taxonomyName } = seedNode; + + if (taxonomyName === 'category') { + classes.push('archive', 'category', `category-${slug}`, `category-${id}`); + } else if (taxonomyName === 'post_tag') { + classes.push('archive', 'tag', `tag-${slug}`, `tag-${id}`); + } else if (taxonomyName) { + classes.push( + 'archive', + `tax-${taxonomyName}`, + `term-${slug}`, + `term-${id}`, + ); + } + + return classes; + } + + if (typeName === 'User') { + classes.push('archive', 'author', `author-${id}`); + + return classes; + } + + // The posts page is also a post type archive, but WordPress only calls it "blog". + if (typeName === 'ContentType' && seedNode.name && !seedNode.isPostsPage) { + 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..bbe4c7004 100644 --- a/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx +++ b/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx @@ -431,4 +431,36 @@ 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); + }); }); 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..2c4a93723 --- /dev/null +++ b/packages/faustwp-core/tests/utils/getBodyClasses.test.ts @@ -0,0 +1,115 @@ +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('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']); + }); +}); From 28834ce7513e4608e2729b17924419e45de3a2c6 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 21 Oct 2026 11:30:19 -0400 Subject: [PATCH 2/7] docs(core): add bodyClass to every page that renders WordPressTemplate --- docs/reference/use-body-class/index.md | 2 +- .../next/faustwp-getting-started/pages/[...wordpressNode].js | 2 +- examples/next/faustwp-getting-started/pages/index.js | 2 +- examples/next/faustwp-getting-started/pages/preview.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/reference/use-body-class/index.md b/docs/reference/use-body-class/index.md index ac321b2ba..493319fd1 100644 --- a/docs/reference/use-body-class/index.md +++ b/docs/reference/use-body-class/index.md @@ -7,7 +7,7 @@ WordPress themes add classes like `home`, `single-post`, `postid-123` and `categ ## Add body classes to every WordPress page -Pass `bodyClass` to `WordPressTemplate`: +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) { 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 ; } From 826374f064453aff71b0ae774fd5f843c897e58d Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 21 Oct 2026 11:31:49 -0400 Subject: [PATCH 3/7] fix(core): sanitize term slugs in body classes like WordPress does --- .../faustwp-core/src/utils/getBodyClasses.ts | 26 ++++++++++++++----- .../tests/utils/getBodyClasses.test.ts | 22 ++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/faustwp-core/src/utils/getBodyClasses.ts b/packages/faustwp-core/src/utils/getBodyClasses.ts index 255c50d99..e148d388f 100644 --- a/packages/faustwp-core/src/utils/getBodyClasses.ts +++ b/packages/faustwp-core/src/utils/getBodyClasses.ts @@ -1,5 +1,16 @@ 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". */ @@ -67,18 +78,21 @@ export function getBodyClasses(seedNode?: SeedNode | null): string[] { } if (seedNode.isTermNode) { - const { slug, taxonomyName } = seedNode; + 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', `category-${slug}`, `category-${id}`); + classes.push('archive', 'category', ...termClasses('category')); } else if (taxonomyName === 'post_tag') { - classes.push('archive', 'tag', `tag-${slug}`, `tag-${id}`); + classes.push('archive', 'tag', ...termClasses('tag')); } else if (taxonomyName) { classes.push( 'archive', - `tax-${taxonomyName}`, - `term-${slug}`, - `term-${id}`, + `tax-${sanitizeClass(taxonomyName)}`, + ...termClasses('term'), ); } diff --git a/packages/faustwp-core/tests/utils/getBodyClasses.test.ts b/packages/faustwp-core/tests/utils/getBodyClasses.test.ts index 2c4a93723..e61505343 100644 --- a/packages/faustwp-core/tests/utils/getBodyClasses.test.ts +++ b/packages/faustwp-core/tests/utils/getBodyClasses.test.ts @@ -112,4 +112,26 @@ describe('getBodyClasses()', () => { 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', + ]); + }); }); From 382a302868629ad9099fccd97e47a8a1d3ce7352 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 21 Oct 2026 11:32:35 -0400 Subject: [PATCH 4/7] docs(core): note that body class slugs are sanitized like WordPress --- docs/reference/use-body-class/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/use-body-class/index.md b/docs/reference/use-body-class/index.md index 493319fd1..106761417 100644 --- a/docs/reference/use-body-class/index.md +++ b/docs/reference/use-body-class/index.md @@ -32,7 +32,7 @@ The classes are worked out from the seed node Faust.js already loads, and they a When the Faust toolbar is showing, it adds `admin-bar` and `logged-in`. -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. +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 From 46ee2dcd9e282b580bcb927f56722e160bc48c2f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 21 Oct 2026 18:58:04 -0400 Subject: [PATCH 5/7] fix(core): give a previewed posts page only the blog body class --- packages/faustwp-core/src/utils/getBodyClasses.ts | 7 +++++-- packages/faustwp-core/tests/utils/getBodyClasses.test.ts | 6 ++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/faustwp-core/src/utils/getBodyClasses.ts b/packages/faustwp-core/src/utils/getBodyClasses.ts index e148d388f..e731d9195 100644 --- a/packages/faustwp-core/src/utils/getBodyClasses.ts +++ b/packages/faustwp-core/src/utils/getBodyClasses.ts @@ -48,8 +48,12 @@ export function getBodyClasses(seedNode?: SeedNode | null): string[] { 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) { @@ -105,8 +109,7 @@ export function getBodyClasses(seedNode?: SeedNode | null): string[] { return classes; } - // The posts page is also a post type archive, but WordPress only calls it "blog". - if (typeName === 'ContentType' && seedNode.name && !seedNode.isPostsPage) { + if (typeName === 'ContentType' && seedNode.name) { classes.push( 'archive', 'post-type-archive', diff --git a/packages/faustwp-core/tests/utils/getBodyClasses.test.ts b/packages/faustwp-core/tests/utils/getBodyClasses.test.ts index e61505343..16aea49d7 100644 --- a/packages/faustwp-core/tests/utils/getBodyClasses.test.ts +++ b/packages/faustwp-core/tests/utils/getBodyClasses.test.ts @@ -70,6 +70,12 @@ describe('getBodyClasses()', () => { ]); }); + 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', From 66af6b9385f11abda4cf1a9a8326072a2a790c64 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 21 Oct 2026 18:58:05 -0400 Subject: [PATCH 6/7] fix(core): use the previewed post's ID in body classes and add logged-in only with bodyClass --- docs/reference/use-body-class/index.md | 2 +- .../src/components/Toolbar/Toolbar.tsx | 4 +-- .../src/components/WordPressTemplate.tsx | 29 ++++++++++++++++++- .../components/WordPressTemplate.test.tsx | 28 ++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/docs/reference/use-body-class/index.md b/docs/reference/use-body-class/index.md index 106761417..a8a788969 100644 --- a/docs/reference/use-body-class/index.md +++ b/docs/reference/use-body-class/index.md @@ -30,7 +30,7 @@ The classes are worked out from the seed node Faust.js already loads, and they a | Author | `archive`, `author`, `author-{id}` | | Post type archive | `archive`, `post-type-archive`, `post-type-archive-{post type}` | -When the Faust toolbar is showing, it adds `admin-bar` and `logged-in`. +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. diff --git a/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx b/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx index 150fbc061..b56a028bd 100644 --- a/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx +++ b/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx @@ -115,8 +115,8 @@ export function AuthenticatedToolbar({ seedNode }: ToolbarProps) { setToolbarNodes(filteredNodes); }, [coreToolbarNodes, seedNode]); - // The toolbar only shows for logged-in users, like WordPress's admin bar. - useBodyClass(['admin-bar', 'logged-in']); + // 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 0158df81b..ad3ec5d00 100644 --- a/packages/faustwp-core/src/components/WordPressTemplate.tsx +++ b/packages/faustwp-core/src/components/WordPressTemplate.tsx @@ -63,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; @@ -89,7 +112,11 @@ export function WordPressTemplateInternal( ...wordpressTemplateProps } = props; - useBodyClass(bodyClass ? getBodyClasses(seedNode) : []); + useBodyClass( + bodyClass + ? getTemplateBodyClasses(seedNode, isPreview, isAuthenticated) + : [], + ); const unknownTemplate = getTemplate(seedNode, templates); const isDynamic = isDynamicComponent(unknownTemplate); diff --git a/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx b/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx index bbe4c7004..149cc0bdf 100644 --- a/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx +++ b/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx @@ -463,4 +463,32 @@ describe(' client-side query variables', () => { 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(); + window.history.pushState({}, '', '/'); + }); }); From de339c32abd18240100e4ea8f4297d5a29655fa1 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 22 Oct 2026 10:18:14 -0400 Subject: [PATCH 7/7] test(core): reset the preview URL even when a body class test fails --- .../faustwp-core/tests/components/WordPressTemplate.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx b/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx index 149cc0bdf..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 = { @@ -489,6 +490,5 @@ describe(' client-side query variables', () => { expect(document.body.classList.contains('logged-in')).toBe(true); view.unmount(); - window.history.pushState({}, '', '/'); }); });