Skip to content

Commit

Permalink
feat: JSON-LD structured data implementation for blog
Browse files Browse the repository at this point in the history
  • Loading branch information
johnnyreilly committed Dec 25, 2023
1 parent 643a7fe commit e0da5cf
Show file tree
Hide file tree
Showing 13 changed files with 255 additions and 63 deletions.
5 changes: 5 additions & 0 deletions packages/docusaurus-plugin-content-blog/src/blogUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ async function processBlogSourceFile(
truncateMarker,
showReadingTime,
editUrl,
blogTitle: baseBlogTitle,
} = options;

// Lookup in localized folder in priority
Expand Down Expand Up @@ -314,6 +315,8 @@ async function processBlogSourceFile(
return undefined;
}

const baseBlogPermalink = normalizeUrl([baseUrl, routeBasePath]);

const tagsBasePath = normalizeUrl([
baseUrl,
routeBasePath,
Expand All @@ -325,6 +328,8 @@ async function processBlogSourceFile(
id: slug,
metadata: {
permalink,
baseBlogPermalink,
baseBlogTitle,
editUrl: getBlogEditUrl(),
source: aliasedSource,
title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
readonly formattedDate: string;
/** Full link including base URL. */
readonly permalink: string;
/** the path to the base of the blog */
readonly baseBlogPermalink: string;
/** title of the overall blog */
readonly baseBlogTitle: string;
/**
* Description used in the meta. Could be an empty string (empty content)
*/
Expand Down Expand Up @@ -552,6 +556,27 @@ declare module '@theme/BlogPostPage/Metadata' {
export default function BlogPostPageMetadata(): JSX.Element;
}

declare module '@theme/BlogPostPage/StructuredData' {
import type {
BlogPostFrontMatter,
PropBlogPostContent,
} 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 frontMatter: FrontMatter;
readonly metadata: Metadata;
}

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

declare module '@theme/BlogListPage' {
import type {Content} from '@theme/BlogPostPage';
import type {
Expand All @@ -574,6 +599,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,95 @@
/**
* 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 {useBaseUrlUtils} from '@docusaurus/useBaseUrl';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import type {Props} from '@theme/BlogListPage/StructuredData';

export default function BlogListPageStructuredData(props: Props): JSX.Element {
const {siteConfig} = useDocusaurusContext();
const {withBaseUrl} = useBaseUrlUtils();

const {
metadata: {blogDescription, blogTitle, permalink},
} = props;

const url = `${siteConfig.url}${permalink}`;

// details on structured data support: https://schema.org/Blog
const blogStructuredData = {
'@context': 'https://schema.org',
'@type': 'Blog',
'@id': url,
mainEntityOfPage: url,
headline: blogTitle,
description: blogDescription,
blogPost: props.items.map((blogItem) => {
const {
content: {assets, frontMatter, metadata},
} = blogItem;
const {date, title, description} = metadata;

const image = assets.image ?? frontMatter.image;
const keywords = frontMatter.keywords ?? [];

// an array of https://schema.org/Person
const authorsStructuredData = metadata.authors.map((author) => ({
'@type': 'Person',
...(author.name ? {name: author.name} : {}),
...(author.title ? {description: author.title} : {}),
...(author.url ? {url: author.url} : {}),
...(author.email ? {email: author.email} : {}),
...(author.imageURL ? {image: author.imageURL} : {}),
}));

const blogUrl = `${siteConfig.url}${metadata.permalink}`;
const imageUrl = image ? withBaseUrl(image, {absolute: true}) : undefined;

return {
'@type': 'BlogPosting',
'@id': blogUrl,
mainEntityOfPage: blogUrl,
url: blogUrl,
headline: title,
name: title,
description,
datePublished: date,
author:
authorsStructuredData.length === 1
? authorsStructuredData[0]
: authorsStructuredData,
...(image
? {
// details on structured data support: https://schema.org/ImageObject
image: {
'@type': 'ImageObject',
'@id': imageUrl,
url: imageUrl,
contentUrl: imageUrl,
caption: `title image for the blog post: ${title}`,
},
}
: {}),
...(keywords ? {keywords} : {}),
};
}),
};

return (
<script
type="application/ld+json"
// We're using dangerouslySetInnerHTML because we want to avoid React
// transforming 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(blogStructuredData),
}}
/>
);
}
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} />
<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>
);
}
Loading

0 comments on commit e0da5cf

Please sign in to comment.