Skip to content
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
4 changes: 4 additions & 0 deletions docs/nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
51 changes: 51 additions & 0 deletions docs/reference/use-body-class/index.md
Original file line number Diff line number Diff line change
@@ -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 `<body>`. 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 <WordPressTemplate {...props} bodyClass />;
}
```

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 <main>…</main>;
}
```

To get the WordPress classes yourself, for example to add them to an element other than `<body>`, call `getBodyClasses(seedNode)`. It returns them as an array.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getWordPressProps, WordPressTemplate } from '@faustwp/core';

export default function Page(props) {
return <WordPressTemplate {...props} />;
return <WordPressTemplate {...props} bodyClass />;
}

export function getStaticProps(ctx) {
Expand Down
2 changes: 1 addition & 1 deletion examples/next/faustwp-getting-started/pages/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getWordPressProps, WordPressTemplate } from '@faustwp/core';

export default function Page(props) {
return <WordPressTemplate {...props} />;
return <WordPressTemplate {...props} bodyClass />;
}

export function getStaticProps(ctx) {
Expand Down
2 changes: 1 addition & 1 deletion examples/next/faustwp-getting-started/pages/preview.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { WordPressTemplate } from '@faustwp/core';

export default function Preview(props) {
return <WordPressTemplate {...props} />;
return <WordPressTemplate {...props} bodyClass />;
}
18 changes: 3 additions & 15 deletions packages/faustwp-core/src/components/Toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
38 changes: 38 additions & 0 deletions packages/faustwp-core/src/components/WordPressTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,24 @@ import {
loadDynamicComponent,
} from '../getTemplate.js';
import { useAuth } from '../hooks/useAuth.js';
import { useBodyClass } from '../hooks/useBodyClass.js';
import {
SEED_QUERY,
SeedNode,
getPreviewSeedNode,
} 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
* `<body>`. See getBodyClasses().
*/
bodyClass?: boolean;
__SEED_NODE__?: SeedNode | null;
__FAUST_QUERIES__?: FaustQueries | null;
__TEMPLATE_QUERY_DATA__?: any | null;
Expand Down Expand Up @@ -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;
Expand All @@ -72,6 +102,7 @@ export function WordPressTemplateInternal(
}

const {
bodyClass,
seedNode,
isAuthenticated,
isPreview,
Expand All @@ -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<any | null>(templateQueryDataProp);
Expand Down
29 changes: 29 additions & 0 deletions packages/faustwp-core/src/hooks/useBodyClass.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useEffect } from 'react';

/**
* Add classes to the page's `<body>` 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]);
}
4 changes: 4 additions & 0 deletions packages/faustwp-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -74,6 +76,8 @@ export {
GetSitemapPropsConfig,
useAuth,
useLogin,
useBodyClass,
getBodyClasses,
useLogout,
FaustToolbarNodes,
FaustToolbarContext,
Expand Down
121 changes: 121 additions & 0 deletions packages/faustwp-core/src/utils/getBodyClasses.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
Copilot marked this conversation as resolved.
Resolved

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;
}
Loading
Loading