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

refactor(theme): use JSON-LD instead of microdata for blog structured data #9669

Merged
merged 23 commits into from
Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e0da5cf
feat: JSON-LD structured data implementation for blog
johnnyreilly Dec 25, 2023
6521052
Merge branch 'main' of https://github.com/johnnyreilly/docusaurus int…
johnnyreilly Dec 26, 2023
2bc8ca6
fix: tests
johnnyreilly Dec 26, 2023
25afce0
Update packages/docusaurus-theme-classic/src/theme/BlogPostPage/Struc…
johnnyreilly Jan 29, 2024
1df8ce4
Update packages/docusaurus-theme-classic/src/theme/BlogListPage/Struc…
johnnyreilly Jan 29, 2024
7c88ae0
feat: migrate to blogMetadata bundle/prop as suggested by @slorber
johnnyreilly Feb 9, 2024
79224e1
feat: dedicated StructuredData component
johnnyreilly Feb 9, 2024
c21d57a
feat: add structuredDataUtils
johnnyreilly Feb 9, 2024
c5961ea
feat: add schema-dts
johnnyreilly Feb 9, 2024
4cc7a50
fix: single blogMetadata
johnnyreilly Feb 9, 2024
885dbaf
fix: split out getImage
johnnyreilly Feb 10, 2024
6663726
fix: getAuthor / getImage move
johnnyreilly Feb 10, 2024
8ffcb58
fix: getBlogPost
johnnyreilly Feb 10, 2024
ce1b664
fix: baseBlogPermalink -> blogBasePath
johnnyreilly Feb 10, 2024
3ce60a1
fix: migrate structuredData logic to theme-common
johnnyreilly Feb 10, 2024
356d8f2
fix: move StructuredData to theme-common
johnnyreilly Feb 10, 2024
55433eb
fix: remove unnecessary prop
johnnyreilly Feb 10, 2024
e59c882
fix: less prop drilling
johnnyreilly Feb 10, 2024
0be47bd
fix: address review feedback
johnnyreilly Feb 14, 2024
6d26f5c
Add blog plugin useBlogMetadata() client hook + refactor to use it
slorber Feb 14, 2024
84a13d1
fix blog tests???
slorber Feb 14, 2024
20256fa
revert usage of StructuredData comp in docs
slorber Feb 15, 2024
96073e8
remove StructuredData component
slorber Feb 15, 2024
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
14 changes: 14 additions & 0 deletions packages/docusaurus-plugin-content-blog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export default async function pluginContentBlog(
blogArchiveComponent,
routeBasePath,
archiveBasePath,
blogTitle,
} = options;

const {addRoute, createData} = actions;
Expand Down Expand Up @@ -255,6 +256,18 @@ export default async function pluginContentBlog(
),
);

const blogMetadataPath = await createData(
`blogMetadata-${pluginId}.json`,
JSON.stringify(
{
blogBasePath: normalizeUrl([baseUrl, routeBasePath]),
blogTitle,
},
null,
2,
),
);

// Create routes for blog entries.
await Promise.all(
blogPosts.map(async (blogPost) => {
Expand All @@ -273,6 +286,7 @@ export default async function pluginContentBlog(
modules: {
sidebar: aliasedSource(sidebarProp),
content: metadata.source,
blogMetadata: aliasedSource(blogMetadataPath),
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,13 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
blogTagsListPath: string;
};

export type BlogMetadata = {
/** the path to the base of the blog */
blogBasePath: string;
/** title of the overall blog */
blogTitle: string;
};

export type BlogTags = {
[permalink: string]: BlogTag;
};
Expand Down Expand Up @@ -532,6 +539,7 @@ declare module '@theme/BlogPostPage' {
BlogPostFrontMatter,
BlogSidebar,
PropBlogPostContent,
BlogMetadata,
} from '@docusaurus/plugin-content-blog';

export type FrontMatter = BlogPostFrontMatter;
Expand All @@ -543,6 +551,8 @@ declare module '@theme/BlogPostPage' {
readonly sidebar: BlogSidebar;
/** Content of this post as an MDX component, with useful metadata. */
readonly content: Content;
/** Metadata about the blog. */
readonly blogMetadata: BlogMetadata;
}

export default function BlogPostPage(props: Props): JSX.Element;
Expand All @@ -552,6 +562,28 @@ declare module '@theme/BlogPostPage/Metadata' {
export default function BlogPostPageMetadata(): JSX.Element;
}

declare module '@theme/BlogPostPage/StructuredData' {
import type {
BlogPostFrontMatter,
PropBlogPostContent,
BlogMetadata,
} from '@docusaurus/plugin-content-blog';

export type FrontMatter = BlogPostFrontMatter;

export type Assets = PropBlogPostContent['assets'];

export type Metadata = PropBlogPostContent['metadata'];

export interface Props {
readonly assets: Assets;
readonly metadata: Metadata;
readonly blogMetadata: BlogMetadata;
}

export default function BlogPostStructuredData(props: Props): JSX.Element;
}

declare module '@theme/BlogListPage' {
import type {Content} from '@theme/BlogPostPage';
import type {
Expand All @@ -574,6 +606,28 @@ declare module '@theme/BlogListPage' {
export default function BlogListPage(props: Props): JSX.Element;
}

declare module '@theme/BlogListPage/StructuredData' {
import type {Content} from '@theme/BlogPostPage';
import type {
BlogSidebar,
BlogPaginatedMetadata,
} from '@docusaurus/plugin-content-blog';

export interface Props {
/** Blog sidebar. */
readonly sidebar: BlogSidebar;
/** Metadata of the current listing page. */
readonly metadata: BlogPaginatedMetadata;
/**
* Array of blog posts included on this page. Every post's metadata is also
* available.
*/
readonly items: readonly {readonly content: Content}[];
}

export default function BlogListPageStructuredData(props: Props): JSX.Element;
}

declare module '@theme/BlogTagsListPage' {
import type {BlogSidebar} from '@docusaurus/plugin-content-blog';
import type {TagsListItem} from '@docusaurus/utils';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ export default function BlogLayout(props: Props): JSX.Element {
className={clsx('col', {
'col--7': hasSidebar,
'col--9 col--offset-1': !hasSidebar,
})}
itemScope
itemType="https://schema.org/Blog">
})}>
{children}
</main>
{toc && <div className="col col--2">{toc}</div>}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import React from 'react';
import {
useBlogListPageStructuredData,
StructuredData,
} from '@docusaurus/theme-common';
import type {Props} from '@theme/BlogListPage/StructuredData';

export default function BlogListPageStructuredData(props: Props): JSX.Element {
johnnyreilly marked this conversation as resolved.
Show resolved Hide resolved
const structuredData = useBlogListPageStructuredData(props);
return <StructuredData structuredData={structuredData} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import BlogListPaginator from '@theme/BlogListPaginator';
import SearchMetadata from '@theme/SearchMetadata';
import type {Props} from '@theme/BlogListPage';
import BlogPostItems from '@theme/BlogPostItems';
import BlogListPageStructuredData from '@theme/BlogListPage/StructuredData';

function BlogListPageMetadata(props: Props): JSX.Element {
const {metadata} = props;
Expand Down Expand Up @@ -54,6 +55,7 @@ export default function BlogListPage(props: Props): JSX.Element {
ThemeClassNames.page.blogListPage,
)}>
<BlogListPageMetadata {...props} />
<BlogListPageStructuredData {...props} />
johnnyreilly marked this conversation as resolved.
Show resolved Hide resolved
<BlogListPageContent {...props} />
</HtmlClassNameProvider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,11 @@
*/

import React from 'react';
import {useBaseUrlUtils} from '@docusaurus/useBaseUrl';
import {useBlogPost} from '@docusaurus/theme-common/internal';
import type {Props} from '@theme/BlogPostItem/Container';

export default function BlogPostItemContainer({
children,
className,
}: Props): JSX.Element {
const {
frontMatter,
assets,
metadata: {description},
} = useBlogPost();
const {withBaseUrl} = useBaseUrlUtils();
const image = assets.image ?? frontMatter.image;
const keywords = frontMatter.keywords ?? [];
return (
<article
className={className}
itemProp="blogPost"
itemScope
itemType="https://schema.org/BlogPosting">
{description && <meta itemProp="description" content={description} />}
{image && (
<link itemProp="image" href={withBaseUrl(image, {absolute: true})} />
)}
{keywords.length > 0 && (
<meta itemProp="keywords" content={keywords.join(',')} />
)}
{children}
</article>
);
return <article className={className}>{children}</article>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ export default function BlogPostItemContent({
<div
// This ID is used for the feed generation to locate the main content
id={isBlogPostPage ? blogPostContainerID : undefined}
className={clsx('markdown', className)}
itemProp="articleBody">
className={clsx('markdown', className)}>
<MDXContent>{children}</MDXContent>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,31 +28,18 @@ export default function BlogPostItemHeaderAuthor({
<div className={clsx('avatar margin-bottom--sm', className)}>
{imageURL && (
<MaybeLink href={link} className="avatar__photo-link">
<img
className="avatar__photo"
src={imageURL}
alt={name}
itemProp="image"
/>
<img className="avatar__photo" src={imageURL} alt={name} />
</MaybeLink>
)}

{name && (
<div
className="avatar__intro"
itemProp="author"
itemScope
itemType="https://schema.org/Person">
<div className="avatar__intro">
<div className="avatar__name">
<MaybeLink href={link} itemProp="url">
<span itemProp="name">{name}</span>
<MaybeLink href={link}>
<span>{name}</span>
</MaybeLink>
</div>
{title && (
<small className="avatar__subtitle" itemProp="description">
{title}
</small>
)}
{title && <small className="avatar__subtitle">{title}</small>}
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,7 @@ function ReadingTime({readingTime}: {readingTime: number}) {
}

function Date({date, formattedDate}: {date: string; formattedDate: string}) {
return (
<time dateTime={date} itemProp="datePublished">
{formattedDate}
</time>
);
return <time dateTime={date}>{formattedDate}</time>;
}

function Spacer() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,8 @@ export default function BlogPostItemHeaderTitle({
const {permalink, title} = metadata;
const TitleHeading = isBlogPostPage ? 'h1' : 'h2';
return (
<TitleHeading className={clsx(styles.title, className)} itemProp="headline">
{isBlogPostPage ? (
title
) : (
<Link itemProp="url" to={permalink}>
{title}
</Link>
)}
<TitleHeading className={clsx(styles.title, className)}>
{isBlogPostPage ? title : <Link to={permalink}>{title}</Link>}
</TitleHeading>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import React from 'react';
import {
useBlogPostStructuredData,
StructuredData,
} from '@docusaurus/theme-common';
import type {Props} from '@theme/BlogPostPage/StructuredData';

export default function BlogPostStructuredData(props: Props): JSX.Element {
johnnyreilly marked this conversation as resolved.
Show resolved Hide resolved
const structuredData = useBlogPostStructuredData(props);
return <StructuredData structuredData={structuredData} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import BlogLayout from '@theme/BlogLayout';
import BlogPostItem from '@theme/BlogPostItem';
import BlogPostPaginator from '@theme/BlogPostPaginator';
import BlogPostPageMetadata from '@theme/BlogPostPage/Metadata';
import BlogPostPageStructuredData from '@theme/BlogPostPage/StructuredData';
import TOC from '@theme/TOC';
import type {Props} from '@theme/BlogPostPage';
import Unlisted from '@theme/Unlisted';
Expand Down Expand Up @@ -45,6 +46,7 @@ function BlogPostPageContent({
) : undefined
}>
{unlisted && <Unlisted />}

<BlogPostItem>{children}</BlogPostItem>

{(nextItem || prevItem) && (
Expand All @@ -64,6 +66,11 @@ export default function BlogPostPage(props: Props): JSX.Element {
ThemeClassNames.page.blogPostPage,
)}>
<BlogPostPageMetadata />
<BlogPostPageStructuredData
assets={props.content.assets}
metadata={props.content.metadata}
blogMetadata={props.blogMetadata}
/>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer this component to be "prop-less" instead, similar to the <BlogPostPageMetadata /> component above

The reason is, prop-less components that users swizzle are less sensitive to internal theme refactors such as adding/removing/renaming props, and are less likely to break on Docusaurus version upgrades.

The structured data component could receive that data by other means:

const {assets, metadata} = useBlogPost();

For the blog metadata, you could use an undocumented "route context" feature that we haven't used much yet but I'd like to move internal usage to it progressively:

addRoute({
            path: metadata.permalink,
            component: blogPostComponent,
            exact: true,
            modules: {
              sidebar: aliasedSource(sidebarProp),
              content: metadata.source,
            },
            context: {
              blogMetadata: aliasedSource(blogMetadataPath),
            },
});
import useRouteContext from '@docusaurus/useRouteContext';

function useBlogMetadata() {
  return useRouteContext().data.blogMetadata as BlogMetadata;
}

Eventually we could put this hook in a blog client export, similarly to what we do for the docs plugin (see code importing @docusaurus/plugin-content-docs/client, aka docusaurus/packages/docusaurus-plugin-content-docs/src/client/index.ts

I'd prefer this approach because in the future I'd like to have a parent route for your whole blog that declares this context, and makes it accessible to all the blog pages (not just the blog post page but also tags etc)

Let me know if you need help doing this refactoring, that's a bit out of the scope of supporting structured data 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes please help - I don't quite follow!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, let me know when you finish your changes and I'll take over and do this refactor, and then ready to merge 👍

<BlogPostPageContent sidebar={props.sidebar}>
<BlogPostContent />
</BlogPostPageContent>
Expand Down
3 changes: 2 additions & 1 deletion packages/docusaurus-theme-common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
"@docusaurus/core": "3.0.0",
"@docusaurus/types": "3.0.0",
"fs-extra": "^11.1.1",
"lodash": "^4.17.21"
"lodash": "^4.17.21",
"schema-dts": "^1.1.2"
},
"peerDependencies": {
"react": "^18.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import React from 'react';

interface Props {
readonly structuredData: object;
slorber marked this conversation as resolved.
Show resolved Hide resolved
}

export default function StructuredData({structuredData}: Props): JSX.Element {
return (
<script
type="application/ld+json"
// We're using dangerouslySetInnerHTML because we want to avoid React
// transforming quotes into &quot; which upsets parsers.
// The entire contents is a stringified JSON object so it is safe
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: JSON.stringify(structuredData),
}}
/>
);
}
9 changes: 9 additions & 0 deletions packages/docusaurus-theme-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export {

export {default as ThemedComponent} from './components/ThemedComponent';

export {default as StructuredData} from './components/StructuredData';

export {
createStorageSlot,
useStorageSlot,
Expand All @@ -39,6 +41,13 @@ export {
filterDocCardListItems,
} from './utils/docsUtils';

export {
useBlogListPageStructuredData,
useBlogPostStructuredData,
makeImageStructuredData,
makePersonStructuredData,
johnnyreilly marked this conversation as resolved.
Show resolved Hide resolved
} from './utils/structuredDataUtils';

export {usePluralForm} from './utils/usePluralForm';

export {useCollapsible, Collapsible} from './components/Collapsible';
Expand Down
Loading
Loading