mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-29 08:57:03 +02:00
refactor(blog): theme-common shouldn't depend on blog content plugins (#10313)
This commit is contained in:
parent
7544a2373d
commit
61d6858864
29 changed files with 111 additions and 76 deletions
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
24
packages/docusaurus-plugin-content-blog/src/client/index.tsx
Normal file
24
packages/docusaurus-plugin-content-blog/src/client/index.tsx
Normal 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';
|
|
@ -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]],
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue