refactor(blog): theme-common shouldn't depend on blog content plugins (#10313)

This commit is contained in:
Sébastien Lorber 2024-07-19 15:55:35 +02:00 committed by GitHub
parent 7544a2373d
commit 61d6858864
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 111 additions and 76 deletions

View file

@ -34,6 +34,7 @@
"@docusaurus/core": "3.4.0",
"@docusaurus/logger": "3.4.0",
"@docusaurus/mdx-loader": "3.4.0",
"@docusaurus/theme-common": "3.4.0",
"@docusaurus/types": "3.4.0",
"@docusaurus/utils": "3.4.0",
"@docusaurus/utils-common": "3.4.0",

View file

@ -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, {useMemo, type ReactNode, useContext} from 'react';
import {ReactContextError} from '@docusaurus/theme-common/internal';
import useRouteContext from '@docusaurus/useRouteContext';
import type {
PropBlogPostContent,
BlogMetadata,
} from '@docusaurus/plugin-content-blog';
export function useBlogMetadata(): BlogMetadata {
const routeContext = useRouteContext();
const blogMetadata = routeContext?.data?.blogMetadata;
if (!blogMetadata) {
throw new Error(
"useBlogMetadata() can't be called on the current route because the blog metadata could not be found in route context",
);
}
return blogMetadata as BlogMetadata;
}
/**
* The React context value returned by the `useBlogPost()` hook.
* It contains useful data related to the currently browsed blog post.
*/
export type BlogPostContextValue = Pick<
PropBlogPostContent,
'metadata' | 'frontMatter' | 'assets' | 'toc'
> & {
readonly isBlogPostPage: boolean;
};
const Context = React.createContext<BlogPostContextValue | null>(null);
/**
* Note: we don't use `PropBlogPostContent` as context value on purpose.
* Metadata is currently stored inside the MDX component, but we may want to
* change that in the future.
*/
function useContextValue({
content,
isBlogPostPage,
}: {
content: PropBlogPostContent;
isBlogPostPage: boolean;
}): BlogPostContextValue {
return useMemo(
() => ({
metadata: content.metadata,
frontMatter: content.frontMatter,
assets: content.assets,
toc: content.toc,
isBlogPostPage,
}),
[content, isBlogPostPage],
);
}
/**
* This is a very thin layer around the `content` received from the MDX loader.
* It provides metadata about the blog post to the children tree.
*/
export function BlogPostProvider({
children,
content,
isBlogPostPage = false,
}: {
children: ReactNode;
content: PropBlogPostContent;
isBlogPostPage?: boolean;
}): JSX.Element {
const contextValue = useContextValue({content, isBlogPostPage});
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
/**
* Returns the data of the currently browsed blog post. Gives access to
* front matter, metadata, TOC, etc.
* When swizzling a low-level component (e.g. the "Edit this page" link)
* and you need some extra metadata, you don't have to drill the props
* all the way through the component tree: simply use this hook instead.
*/
export function useBlogPost(): BlogPostContextValue {
const blogPost = useContext(Context);
if (blogPost === null) {
throw new ReactContextError('BlogPostProvider');
}
return blogPost;
}

View file

@ -1,20 +0,0 @@
/**
* 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 useRouteContext from '@docusaurus/useRouteContext';
import type {BlogMetadata} from '@docusaurus/plugin-content-blog';
export function useBlogMetadata(): BlogMetadata {
const routeContext = useRouteContext();
const blogMetadata = routeContext?.data?.blogMetadata;
if (!blogMetadata) {
throw new Error(
"useBlogMetadata() can't be called on the current route because the blog metadata could not be found in route context",
);
}
return blogMetadata as BlogMetadata;
}

View file

@ -0,0 +1,24 @@
/**
* 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.
*/
export {
BlogPostProvider,
type BlogPostContextValue,
useBlogPost,
useBlogMetadata,
} from './contexts';
export {
useBlogListPageStructuredData,
useBlogPostStructuredData,
} from './structuredDataUtils';
export {
BlogSidebarItemList,
groupBlogSidebarItemsByYear,
useVisibleBlogSidebarItems,
} from './sidebarUtils';

View file

@ -0,0 +1,52 @@
/**
* 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 {groupBlogSidebarItemsByYear} from './sidebarUtils';
import type {BlogSidebarItem} from '@docusaurus/plugin-content-blog';
describe('groupBlogSidebarItemsByYear', () => {
const post1: BlogSidebarItem = {
title: 'post1',
permalink: '/post1',
date: '2024-10-03',
unlisted: false,
};
const post2: BlogSidebarItem = {
title: 'post2',
permalink: '/post2',
date: '2024-05-02',
unlisted: false,
};
const post3: BlogSidebarItem = {
title: 'post3',
permalink: '/post3',
date: '2022-11-18',
unlisted: false,
};
it('can group items by year', () => {
const items: BlogSidebarItem[] = [post1, post2, post3];
const entries = groupBlogSidebarItemsByYear(items);
expect(entries).toEqual([
['2024', [post1, post2]],
['2022', [post3]],
]);
});
it('always returns result in descending chronological order', () => {
const items: BlogSidebarItem[] = [post3, post1, post2];
const entries = groupBlogSidebarItemsByYear(items);
expect(entries).toEqual([
['2024', [post1, post2]],
['2022', [post3]],
]);
});
});

View file

@ -0,0 +1,85 @@
/**
* 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, {type ReactNode, useMemo} from 'react';
import {useLocation} from '@docusaurus/router';
import Link from '@docusaurus/Link';
import {groupBy} from '@docusaurus/theme-common';
import {isSamePath} from '@docusaurus/theme-common/internal';
import type {BlogSidebarItem} from '@docusaurus/plugin-content-blog';
function isVisible(item: BlogSidebarItem, pathname: string): boolean {
if (item.unlisted && !isSamePath(item.permalink, pathname)) {
return false;
}
return true;
}
/**
* Return the visible blog sidebar items to display.
* Unlisted items are filtered.
*/
export function useVisibleBlogSidebarItems(
items: BlogSidebarItem[],
): BlogSidebarItem[] {
const {pathname} = useLocation();
return useMemo(
() => items.filter((item) => isVisible(item, pathname)),
[items, pathname],
);
}
export function groupBlogSidebarItemsByYear(
items: BlogSidebarItem[],
): [string, BlogSidebarItem[]][] {
const groupedByYear = groupBy(items, (item) => {
return `${new Date(item.date).getFullYear()}`;
});
// "as" is safe here
// see https://github.com/microsoft/TypeScript/pull/56805#issuecomment-2196526425
const entries = Object.entries(groupedByYear) as [
string,
BlogSidebarItem[],
][];
// We have to use entries because of https://x.com/sebastienlorber/status/1806371668614369486
// Objects with string/number keys are automatically sorted asc...
// Even if keys are strings like "2024"
// We want descending order for years
// Alternative: using Map.groupBy (not affected by this "reordering")
entries.reverse();
return entries;
}
export function BlogSidebarItemList({
items,
ulClassName,
liClassName,
linkClassName,
linkActiveClassName,
}: {
items: BlogSidebarItem[];
ulClassName?: string;
liClassName?: string;
linkClassName?: string;
linkActiveClassName?: string;
}): ReactNode {
return (
<ul className={ulClassName}>
{items.map((item) => (
<li key={item.permalink} className={liClassName}>
<Link
isNavLink
to={item.permalink}
className={linkClassName}
activeClassName={linkActiveClassName}>
{item.title}
</Link>
</li>
))}
</ul>
);
}

View file

@ -0,0 +1,178 @@
/**
* 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 {useBaseUrlUtils, type BaseUrlUtils} from '@docusaurus/useBaseUrl';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {useBlogMetadata} from '@docusaurus/plugin-content-blog/client';
import type {Props as BlogListPageStructuredDataProps} from '@theme/BlogListPage/StructuredData';
import {useBlogPost} from './contexts';
import type {
Blog,
BlogPosting,
WithContext,
Person,
ImageObject,
} from 'schema-dts';
import type {
Author,
PropBlogPostContent,
} from '@docusaurus/plugin-content-blog';
import type {DocusaurusConfig} from '@docusaurus/types';
const convertDate = (dateMs: number) => new Date(dateMs).toISOString();
function getBlogPost(
blogPostContent: PropBlogPostContent,
siteConfig: DocusaurusConfig,
withBaseUrl: BaseUrlUtils['withBaseUrl'],
): BlogPosting {
const {assets, frontMatter, metadata} = blogPostContent;
const {date, title, description, lastUpdatedAt} = metadata;
const image = assets.image ?? frontMatter.image;
const keywords = frontMatter.keywords ?? [];
const blogUrl = `${siteConfig.url}${metadata.permalink}`;
const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;
return {
'@type': 'BlogPosting',
'@id': blogUrl,
mainEntityOfPage: blogUrl,
url: blogUrl,
headline: title,
name: title,
description,
datePublished: date,
...(dateModified ? {dateModified} : {}),
...getAuthor(metadata.authors),
...getImage(image, withBaseUrl, title),
...(keywords ? {keywords} : {}),
};
}
function getAuthor(authors: Author[]) {
const authorsStructuredData = authors.map(createPersonStructuredData);
return {
author:
authorsStructuredData.length === 1
? authorsStructuredData[0]
: authorsStructuredData,
};
}
function getImage(
image: string | undefined,
withBaseUrl: BaseUrlUtils['withBaseUrl'],
title: string,
) {
return image
? {
image: createImageStructuredData({
imageUrl: withBaseUrl(image, {absolute: true}),
caption: `title image for the blog post: ${title}`,
}),
}
: {};
}
export function useBlogListPageStructuredData(
props: BlogListPageStructuredDataProps,
): WithContext<Blog> {
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
return {
'@context': 'https://schema.org',
'@type': 'Blog',
'@id': url,
mainEntityOfPage: url,
headline: blogTitle,
description: blogDescription,
blogPost: props.items.map((blogItem) =>
getBlogPost(blogItem.content, siteConfig, withBaseUrl),
),
};
}
export function useBlogPostStructuredData(): WithContext<BlogPosting> {
const blogMetadata = useBlogMetadata();
const {assets, metadata} = useBlogPost();
const {siteConfig} = useDocusaurusContext();
const {withBaseUrl} = useBaseUrlUtils();
const {date, title, description, frontMatter, lastUpdatedAt} = metadata;
const image = assets.image ?? frontMatter.image;
const keywords = frontMatter.keywords ?? [];
const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;
const url = `${siteConfig.url}${metadata.permalink}`;
// details on structured data support: https://schema.org/BlogPosting
// BlogPosting is one of the structured data types that Google explicitly
// supports: https://developers.google.com/search/docs/appearance/structured-data/article#structured-data-type-definitions
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'@id': url,
mainEntityOfPage: url,
url,
headline: title,
name: title,
description,
datePublished: date,
...(dateModified ? {dateModified} : {}),
...getAuthor(metadata.authors),
...getImage(image, withBaseUrl, title),
...(keywords ? {keywords} : {}),
isPartOf: {
'@type': 'Blog',
'@id': `${siteConfig.url}${blogMetadata.blogBasePath}`,
name: blogMetadata.blogTitle,
},
};
}
/** @returns A {@link https://schema.org/Person} constructed from the {@link Author} */
function createPersonStructuredData(author: Author): Person {
return {
'@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} : {}),
};
}
/** @returns A {@link https://schema.org/ImageObject} */
function createImageStructuredData({
imageUrl,
caption,
}: {
imageUrl: string;
caption: string;
}): ImageObject {
return {
'@type': 'ImageObject',
'@id': imageUrl,
url: imageUrl,
contentUrl: imageUrl,
caption,
};
}