@docusaurus/plugin-content-blog 0.0.0-5994 → 0.0.0-6000
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/authors.js +23 -7
- package/lib/authorsSocials.d.ts +10 -0
- package/lib/authorsSocials.js +48 -0
- package/lib/client/contexts.d.ts +33 -0
- package/lib/client/contexts.js +54 -0
- package/lib/client/index.d.ts +3 -2
- package/lib/client/index.js +3 -9
- package/lib/client/sidebarUtils.d.ts +21 -0
- package/lib/client/sidebarUtils.js +49 -0
- package/lib/client/sidebarUtils.test.d.ts +7 -0
- package/lib/client/sidebarUtils.test.js +43 -0
- package/lib/client/structuredDataUtils.d.ts +10 -0
- package/lib/client/structuredDataUtils.js +122 -0
- package/lib/frontMatter.js +2 -0
- package/package.json +10 -9
- package/src/authors.ts +24 -7
- package/src/authorsSocials.ts +64 -0
- package/src/client/contexts.tsx +95 -0
- package/src/client/index.tsx +24 -0
- package/src/client/sidebarUtils.test.ts +52 -0
- package/src/client/sidebarUtils.tsx +85 -0
- package/src/client/structuredDataUtils.ts +178 -0
- package/src/frontMatter.ts +2 -0
- package/src/plugin-content-blog.d.ts +29 -2
- package/src/client/index.ts +0 -20
package/lib/authors.js
CHANGED
|
@@ -9,8 +9,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
9
9
|
exports.validateAuthorsMap = validateAuthorsMap;
|
|
10
10
|
exports.getAuthorsMap = getAuthorsMap;
|
|
11
11
|
exports.getBlogPostAuthors = getBlogPostAuthors;
|
|
12
|
+
const tslib_1 = require("tslib");
|
|
13
|
+
const _ = tslib_1.__importStar(require("lodash"));
|
|
12
14
|
const utils_1 = require("@docusaurus/utils");
|
|
13
15
|
const utils_validation_1 = require("@docusaurus/utils-validation");
|
|
16
|
+
const authorsSocials_1 = require("./authorsSocials");
|
|
14
17
|
const AuthorsMapSchema = utils_validation_1.Joi.object()
|
|
15
18
|
.pattern(utils_validation_1.Joi.string(), utils_validation_1.Joi.object({
|
|
16
19
|
name: utils_validation_1.Joi.string(),
|
|
@@ -18,6 +21,7 @@ const AuthorsMapSchema = utils_validation_1.Joi.object()
|
|
|
18
21
|
imageURL: utils_validation_1.URISchema,
|
|
19
22
|
title: utils_validation_1.Joi.string(),
|
|
20
23
|
email: utils_validation_1.Joi.string(),
|
|
24
|
+
socials: authorsSocials_1.AuthorSocialsSchema,
|
|
21
25
|
})
|
|
22
26
|
.rename('image_url', 'imageURL')
|
|
23
27
|
.or('name', 'imageURL')
|
|
@@ -37,12 +41,24 @@ function validateAuthorsMap(content) {
|
|
|
37
41
|
}
|
|
38
42
|
return value;
|
|
39
43
|
}
|
|
44
|
+
function normalizeAuthor(author) {
|
|
45
|
+
return {
|
|
46
|
+
...author,
|
|
47
|
+
socials: author.socials ? (0, authorsSocials_1.normalizeSocials)(author.socials) : undefined,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function normalizeAuthorsMap(authorsMap) {
|
|
51
|
+
return _.mapValues(authorsMap, normalizeAuthor);
|
|
52
|
+
}
|
|
40
53
|
async function getAuthorsMap(params) {
|
|
41
|
-
|
|
54
|
+
const authorsMap = await (0, utils_1.getDataFileData)({
|
|
42
55
|
filePath: params.authorsMapPath,
|
|
43
56
|
contentPaths: params.contentPaths,
|
|
44
57
|
fileType: 'authors map',
|
|
45
|
-
},
|
|
58
|
+
},
|
|
59
|
+
// TODO annoying to test: tightly coupled FS reads + validation...
|
|
60
|
+
validateAuthorsMap);
|
|
61
|
+
return authorsMap ? normalizeAuthorsMap(authorsMap) : undefined;
|
|
46
62
|
}
|
|
47
63
|
function normalizeImageUrl({ imageURL, baseUrl, }) {
|
|
48
64
|
return imageURL?.startsWith('/')
|
|
@@ -70,7 +86,7 @@ function getFrontMatterAuthorLegacy({ baseUrl, frontMatter, }) {
|
|
|
70
86
|
return undefined;
|
|
71
87
|
}
|
|
72
88
|
function normalizeFrontMatterAuthors(frontMatterAuthors = []) {
|
|
73
|
-
function
|
|
89
|
+
function normalizeFrontMatterAuthor(authorInput) {
|
|
74
90
|
if (typeof authorInput === 'string') {
|
|
75
91
|
// Technically, we could allow users to provide an author's name here, but
|
|
76
92
|
// we only support keys, otherwise, a typo in a key would fallback to
|
|
@@ -80,8 +96,8 @@ function normalizeFrontMatterAuthors(frontMatterAuthors = []) {
|
|
|
80
96
|
return authorInput;
|
|
81
97
|
}
|
|
82
98
|
return Array.isArray(frontMatterAuthors)
|
|
83
|
-
? frontMatterAuthors.map(
|
|
84
|
-
: [
|
|
99
|
+
? frontMatterAuthors.map(normalizeFrontMatterAuthor)
|
|
100
|
+
: [normalizeFrontMatterAuthor(frontMatterAuthors)];
|
|
85
101
|
}
|
|
86
102
|
function getFrontMatterAuthors(params) {
|
|
87
103
|
const { authorsMap } = params;
|
|
@@ -105,11 +121,11 @@ ${Object.keys(authorsMap)
|
|
|
105
121
|
return undefined;
|
|
106
122
|
}
|
|
107
123
|
function toAuthor(frontMatterAuthor) {
|
|
108
|
-
return {
|
|
124
|
+
return normalizeAuthor({
|
|
109
125
|
// Author def from authorsMap can be locally overridden by front matter
|
|
110
126
|
...getAuthorsMapAuthor(frontMatterAuthor.key),
|
|
111
127
|
...frontMatterAuthor,
|
|
112
|
-
};
|
|
128
|
+
});
|
|
113
129
|
}
|
|
114
130
|
return frontMatterAuthors.map(toAuthor);
|
|
115
131
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
import { Joi } from '@docusaurus/utils-validation';
|
|
8
|
+
import type { AuthorSocials } from '@docusaurus/plugin-content-blog';
|
|
9
|
+
export declare const AuthorSocialsSchema: Joi.ObjectSchema<AuthorSocials>;
|
|
10
|
+
export declare const normalizeSocials: (socials: AuthorSocials) => AuthorSocials;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.normalizeSocials = exports.AuthorSocialsSchema = void 0;
|
|
10
|
+
const utils_validation_1 = require("@docusaurus/utils-validation");
|
|
11
|
+
exports.AuthorSocialsSchema = utils_validation_1.Joi.object({
|
|
12
|
+
twitter: utils_validation_1.Joi.string(),
|
|
13
|
+
github: utils_validation_1.Joi.string(),
|
|
14
|
+
linkedin: utils_validation_1.Joi.string(),
|
|
15
|
+
// StackOverflow userIds like '82609' are parsed as numbers by Yaml
|
|
16
|
+
stackoverflow: utils_validation_1.Joi.alternatives()
|
|
17
|
+
.try(utils_validation_1.Joi.number(), utils_validation_1.Joi.string())
|
|
18
|
+
.custom((val) => String(val)),
|
|
19
|
+
x: utils_validation_1.Joi.string(),
|
|
20
|
+
}).unknown();
|
|
21
|
+
const PredefinedPlatformNormalizers = {
|
|
22
|
+
x: (handle) => `https://x.com/${handle}`,
|
|
23
|
+
twitter: (handle) => `https://twitter.com/${handle}`,
|
|
24
|
+
github: (handle) => `https://github.com/${handle}`,
|
|
25
|
+
linkedin: (handle) => `https://www.linkedin.com/in/${handle}/`,
|
|
26
|
+
stackoverflow: (userId) => `https://stackoverflow.com/users/${userId}`,
|
|
27
|
+
};
|
|
28
|
+
function normalizeSocialEntry([platform, value]) {
|
|
29
|
+
const normalizer = PredefinedPlatformNormalizers[platform.toLowerCase()];
|
|
30
|
+
const isAbsoluteUrl = value.startsWith('http://') || value.startsWith('https://');
|
|
31
|
+
if (isAbsoluteUrl) {
|
|
32
|
+
return [platform, value];
|
|
33
|
+
}
|
|
34
|
+
else if (value.includes('/')) {
|
|
35
|
+
throw new Error(`Author socials should be usernames/userIds/handles, or fully qualified HTTP(s) absolute URLs.
|
|
36
|
+
Social platform '${platform}' has illegal value '${value}'`);
|
|
37
|
+
}
|
|
38
|
+
if (normalizer && !isAbsoluteUrl) {
|
|
39
|
+
const normalizedPlatform = platform.toLowerCase();
|
|
40
|
+
const normalizedValue = normalizer(value);
|
|
41
|
+
return [normalizedPlatform, normalizedValue];
|
|
42
|
+
}
|
|
43
|
+
return [platform, value];
|
|
44
|
+
}
|
|
45
|
+
const normalizeSocials = (socials) => {
|
|
46
|
+
return Object.fromEntries(Object.entries(socials).map(normalizeSocialEntry));
|
|
47
|
+
};
|
|
48
|
+
exports.normalizeSocials = normalizeSocials;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
import { type ReactNode } from 'react';
|
|
8
|
+
import type { PropBlogPostContent, BlogMetadata } from '@docusaurus/plugin-content-blog';
|
|
9
|
+
export declare function useBlogMetadata(): BlogMetadata;
|
|
10
|
+
/**
|
|
11
|
+
* The React context value returned by the `useBlogPost()` hook.
|
|
12
|
+
* It contains useful data related to the currently browsed blog post.
|
|
13
|
+
*/
|
|
14
|
+
export type BlogPostContextValue = Pick<PropBlogPostContent, 'metadata' | 'frontMatter' | 'assets' | 'toc'> & {
|
|
15
|
+
readonly isBlogPostPage: boolean;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* This is a very thin layer around the `content` received from the MDX loader.
|
|
19
|
+
* It provides metadata about the blog post to the children tree.
|
|
20
|
+
*/
|
|
21
|
+
export declare function BlogPostProvider({ children, content, isBlogPostPage, }: {
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
content: PropBlogPostContent;
|
|
24
|
+
isBlogPostPage?: boolean;
|
|
25
|
+
}): JSX.Element;
|
|
26
|
+
/**
|
|
27
|
+
* Returns the data of the currently browsed blog post. Gives access to
|
|
28
|
+
* front matter, metadata, TOC, etc.
|
|
29
|
+
* When swizzling a low-level component (e.g. the "Edit this page" link)
|
|
30
|
+
* and you need some extra metadata, you don't have to drill the props
|
|
31
|
+
* all the way through the component tree: simply use this hook instead.
|
|
32
|
+
*/
|
|
33
|
+
export declare function useBlogPost(): BlogPostContextValue;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
import React, { useMemo, useContext } from 'react';
|
|
8
|
+
import { ReactContextError } from '@docusaurus/theme-common/internal';
|
|
9
|
+
import useRouteContext from '@docusaurus/useRouteContext';
|
|
10
|
+
export function useBlogMetadata() {
|
|
11
|
+
const routeContext = useRouteContext();
|
|
12
|
+
const blogMetadata = routeContext?.data?.blogMetadata;
|
|
13
|
+
if (!blogMetadata) {
|
|
14
|
+
throw new Error("useBlogMetadata() can't be called on the current route because the blog metadata could not be found in route context");
|
|
15
|
+
}
|
|
16
|
+
return blogMetadata;
|
|
17
|
+
}
|
|
18
|
+
const Context = React.createContext(null);
|
|
19
|
+
/**
|
|
20
|
+
* Note: we don't use `PropBlogPostContent` as context value on purpose.
|
|
21
|
+
* Metadata is currently stored inside the MDX component, but we may want to
|
|
22
|
+
* change that in the future.
|
|
23
|
+
*/
|
|
24
|
+
function useContextValue({ content, isBlogPostPage, }) {
|
|
25
|
+
return useMemo(() => ({
|
|
26
|
+
metadata: content.metadata,
|
|
27
|
+
frontMatter: content.frontMatter,
|
|
28
|
+
assets: content.assets,
|
|
29
|
+
toc: content.toc,
|
|
30
|
+
isBlogPostPage,
|
|
31
|
+
}), [content, isBlogPostPage]);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* This is a very thin layer around the `content` received from the MDX loader.
|
|
35
|
+
* It provides metadata about the blog post to the children tree.
|
|
36
|
+
*/
|
|
37
|
+
export function BlogPostProvider({ children, content, isBlogPostPage = false, }) {
|
|
38
|
+
const contextValue = useContextValue({ content, isBlogPostPage });
|
|
39
|
+
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns the data of the currently browsed blog post. Gives access to
|
|
43
|
+
* front matter, metadata, TOC, etc.
|
|
44
|
+
* When swizzling a low-level component (e.g. the "Edit this page" link)
|
|
45
|
+
* and you need some extra metadata, you don't have to drill the props
|
|
46
|
+
* all the way through the component tree: simply use this hook instead.
|
|
47
|
+
*/
|
|
48
|
+
export function useBlogPost() {
|
|
49
|
+
const blogPost = useContext(Context);
|
|
50
|
+
if (blogPost === null) {
|
|
51
|
+
throw new ReactContextError('BlogPostProvider');
|
|
52
|
+
}
|
|
53
|
+
return blogPost;
|
|
54
|
+
}
|
package/lib/client/index.d.ts
CHANGED
|
@@ -4,5 +4,6 @@
|
|
|
4
4
|
* This source code is licensed under the MIT license found in the
|
|
5
5
|
* LICENSE file in the root directory of this source tree.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
export
|
|
7
|
+
export { BlogPostProvider, type BlogPostContextValue, useBlogPost, useBlogMetadata, } from './contexts';
|
|
8
|
+
export { useBlogListPageStructuredData, useBlogPostStructuredData, } from './structuredDataUtils';
|
|
9
|
+
export { BlogSidebarItemList, groupBlogSidebarItemsByYear, useVisibleBlogSidebarItems, } from './sidebarUtils';
|
package/lib/client/index.js
CHANGED
|
@@ -4,12 +4,6 @@
|
|
|
4
4
|
* This source code is licensed under the MIT license found in the
|
|
5
5
|
* LICENSE file in the root directory of this source tree.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
const blogMetadata = routeContext?.data?.blogMetadata;
|
|
11
|
-
if (!blogMetadata) {
|
|
12
|
-
throw new Error("useBlogMetadata() can't be called on the current route because the blog metadata could not be found in route context");
|
|
13
|
-
}
|
|
14
|
-
return blogMetadata;
|
|
15
|
-
}
|
|
7
|
+
export { BlogPostProvider, useBlogPost, useBlogMetadata, } from './contexts';
|
|
8
|
+
export { useBlogListPageStructuredData, useBlogPostStructuredData, } from './structuredDataUtils';
|
|
9
|
+
export { BlogSidebarItemList, groupBlogSidebarItemsByYear, useVisibleBlogSidebarItems, } from './sidebarUtils';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
import { type ReactNode } from 'react';
|
|
8
|
+
import type { BlogSidebarItem } from '@docusaurus/plugin-content-blog';
|
|
9
|
+
/**
|
|
10
|
+
* Return the visible blog sidebar items to display.
|
|
11
|
+
* Unlisted items are filtered.
|
|
12
|
+
*/
|
|
13
|
+
export declare function useVisibleBlogSidebarItems(items: BlogSidebarItem[]): BlogSidebarItem[];
|
|
14
|
+
export declare function groupBlogSidebarItemsByYear(items: BlogSidebarItem[]): [string, BlogSidebarItem[]][];
|
|
15
|
+
export declare function BlogSidebarItemList({ items, ulClassName, liClassName, linkClassName, linkActiveClassName, }: {
|
|
16
|
+
items: BlogSidebarItem[];
|
|
17
|
+
ulClassName?: string;
|
|
18
|
+
liClassName?: string;
|
|
19
|
+
linkClassName?: string;
|
|
20
|
+
linkActiveClassName?: string;
|
|
21
|
+
}): ReactNode;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
import React, { useMemo } from 'react';
|
|
8
|
+
import { useLocation } from '@docusaurus/router';
|
|
9
|
+
import Link from '@docusaurus/Link';
|
|
10
|
+
import { groupBy } from '@docusaurus/theme-common';
|
|
11
|
+
import { isSamePath } from '@docusaurus/theme-common/internal';
|
|
12
|
+
function isVisible(item, pathname) {
|
|
13
|
+
if (item.unlisted && !isSamePath(item.permalink, pathname)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Return the visible blog sidebar items to display.
|
|
20
|
+
* Unlisted items are filtered.
|
|
21
|
+
*/
|
|
22
|
+
export function useVisibleBlogSidebarItems(items) {
|
|
23
|
+
const { pathname } = useLocation();
|
|
24
|
+
return useMemo(() => items.filter((item) => isVisible(item, pathname)), [items, pathname]);
|
|
25
|
+
}
|
|
26
|
+
export function groupBlogSidebarItemsByYear(items) {
|
|
27
|
+
const groupedByYear = groupBy(items, (item) => {
|
|
28
|
+
return `${new Date(item.date).getFullYear()}`;
|
|
29
|
+
});
|
|
30
|
+
// "as" is safe here
|
|
31
|
+
// see https://github.com/microsoft/TypeScript/pull/56805#issuecomment-2196526425
|
|
32
|
+
const entries = Object.entries(groupedByYear);
|
|
33
|
+
// We have to use entries because of https://x.com/sebastienlorber/status/1806371668614369486
|
|
34
|
+
// Objects with string/number keys are automatically sorted asc...
|
|
35
|
+
// Even if keys are strings like "2024"
|
|
36
|
+
// We want descending order for years
|
|
37
|
+
// Alternative: using Map.groupBy (not affected by this "reordering")
|
|
38
|
+
entries.reverse();
|
|
39
|
+
return entries;
|
|
40
|
+
}
|
|
41
|
+
export function BlogSidebarItemList({ items, ulClassName, liClassName, linkClassName, linkActiveClassName, }) {
|
|
42
|
+
return (<ul className={ulClassName}>
|
|
43
|
+
{items.map((item) => (<li key={item.permalink} className={liClassName}>
|
|
44
|
+
<Link isNavLink to={item.permalink} className={linkClassName} activeClassName={linkActiveClassName}>
|
|
45
|
+
{item.title}
|
|
46
|
+
</Link>
|
|
47
|
+
</li>))}
|
|
48
|
+
</ul>);
|
|
49
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
import { groupBlogSidebarItemsByYear } from './sidebarUtils';
|
|
8
|
+
describe('groupBlogSidebarItemsByYear', () => {
|
|
9
|
+
const post1 = {
|
|
10
|
+
title: 'post1',
|
|
11
|
+
permalink: '/post1',
|
|
12
|
+
date: '2024-10-03',
|
|
13
|
+
unlisted: false,
|
|
14
|
+
};
|
|
15
|
+
const post2 = {
|
|
16
|
+
title: 'post2',
|
|
17
|
+
permalink: '/post2',
|
|
18
|
+
date: '2024-05-02',
|
|
19
|
+
unlisted: false,
|
|
20
|
+
};
|
|
21
|
+
const post3 = {
|
|
22
|
+
title: 'post3',
|
|
23
|
+
permalink: '/post3',
|
|
24
|
+
date: '2022-11-18',
|
|
25
|
+
unlisted: false,
|
|
26
|
+
};
|
|
27
|
+
it('can group items by year', () => {
|
|
28
|
+
const items = [post1, post2, post3];
|
|
29
|
+
const entries = groupBlogSidebarItemsByYear(items);
|
|
30
|
+
expect(entries).toEqual([
|
|
31
|
+
['2024', [post1, post2]],
|
|
32
|
+
['2022', [post3]],
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
it('always returns result in descending chronological order', () => {
|
|
36
|
+
const items = [post3, post1, post2];
|
|
37
|
+
const entries = groupBlogSidebarItemsByYear(items);
|
|
38
|
+
expect(entries).toEqual([
|
|
39
|
+
['2024', [post1, post2]],
|
|
40
|
+
['2022', [post3]],
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
import type { Props as BlogListPageStructuredDataProps } from '@theme/BlogListPage/StructuredData';
|
|
8
|
+
import type { Blog, BlogPosting, WithContext } from 'schema-dts';
|
|
9
|
+
export declare function useBlogListPageStructuredData(props: BlogListPageStructuredDataProps): WithContext<Blog>;
|
|
10
|
+
export declare function useBlogPostStructuredData(): WithContext<BlogPosting>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
import { useBaseUrlUtils } from '@docusaurus/useBaseUrl';
|
|
8
|
+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
9
|
+
import { useBlogMetadata } from '@docusaurus/plugin-content-blog/client';
|
|
10
|
+
import { useBlogPost } from './contexts';
|
|
11
|
+
const convertDate = (dateMs) => new Date(dateMs).toISOString();
|
|
12
|
+
function getBlogPost(blogPostContent, siteConfig, withBaseUrl) {
|
|
13
|
+
const { assets, frontMatter, metadata } = blogPostContent;
|
|
14
|
+
const { date, title, description, lastUpdatedAt } = metadata;
|
|
15
|
+
const image = assets.image ?? frontMatter.image;
|
|
16
|
+
const keywords = frontMatter.keywords ?? [];
|
|
17
|
+
const blogUrl = `${siteConfig.url}${metadata.permalink}`;
|
|
18
|
+
const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;
|
|
19
|
+
return {
|
|
20
|
+
'@type': 'BlogPosting',
|
|
21
|
+
'@id': blogUrl,
|
|
22
|
+
mainEntityOfPage: blogUrl,
|
|
23
|
+
url: blogUrl,
|
|
24
|
+
headline: title,
|
|
25
|
+
name: title,
|
|
26
|
+
description,
|
|
27
|
+
datePublished: date,
|
|
28
|
+
...(dateModified ? { dateModified } : {}),
|
|
29
|
+
...getAuthor(metadata.authors),
|
|
30
|
+
...getImage(image, withBaseUrl, title),
|
|
31
|
+
...(keywords ? { keywords } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function getAuthor(authors) {
|
|
35
|
+
const authorsStructuredData = authors.map(createPersonStructuredData);
|
|
36
|
+
return {
|
|
37
|
+
author: authorsStructuredData.length === 1
|
|
38
|
+
? authorsStructuredData[0]
|
|
39
|
+
: authorsStructuredData,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function getImage(image, withBaseUrl, title) {
|
|
43
|
+
return image
|
|
44
|
+
? {
|
|
45
|
+
image: createImageStructuredData({
|
|
46
|
+
imageUrl: withBaseUrl(image, { absolute: true }),
|
|
47
|
+
caption: `title image for the blog post: ${title}`,
|
|
48
|
+
}),
|
|
49
|
+
}
|
|
50
|
+
: {};
|
|
51
|
+
}
|
|
52
|
+
export function useBlogListPageStructuredData(props) {
|
|
53
|
+
const { siteConfig } = useDocusaurusContext();
|
|
54
|
+
const { withBaseUrl } = useBaseUrlUtils();
|
|
55
|
+
const { metadata: { blogDescription, blogTitle, permalink }, } = props;
|
|
56
|
+
const url = `${siteConfig.url}${permalink}`;
|
|
57
|
+
// details on structured data support: https://schema.org/Blog
|
|
58
|
+
return {
|
|
59
|
+
'@context': 'https://schema.org',
|
|
60
|
+
'@type': 'Blog',
|
|
61
|
+
'@id': url,
|
|
62
|
+
mainEntityOfPage: url,
|
|
63
|
+
headline: blogTitle,
|
|
64
|
+
description: blogDescription,
|
|
65
|
+
blogPost: props.items.map((blogItem) => getBlogPost(blogItem.content, siteConfig, withBaseUrl)),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function useBlogPostStructuredData() {
|
|
69
|
+
const blogMetadata = useBlogMetadata();
|
|
70
|
+
const { assets, metadata } = useBlogPost();
|
|
71
|
+
const { siteConfig } = useDocusaurusContext();
|
|
72
|
+
const { withBaseUrl } = useBaseUrlUtils();
|
|
73
|
+
const { date, title, description, frontMatter, lastUpdatedAt } = metadata;
|
|
74
|
+
const image = assets.image ?? frontMatter.image;
|
|
75
|
+
const keywords = frontMatter.keywords ?? [];
|
|
76
|
+
const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;
|
|
77
|
+
const url = `${siteConfig.url}${metadata.permalink}`;
|
|
78
|
+
// details on structured data support: https://schema.org/BlogPosting
|
|
79
|
+
// BlogPosting is one of the structured data types that Google explicitly
|
|
80
|
+
// supports: https://developers.google.com/search/docs/appearance/structured-data/article#structured-data-type-definitions
|
|
81
|
+
return {
|
|
82
|
+
'@context': 'https://schema.org',
|
|
83
|
+
'@type': 'BlogPosting',
|
|
84
|
+
'@id': url,
|
|
85
|
+
mainEntityOfPage: url,
|
|
86
|
+
url,
|
|
87
|
+
headline: title,
|
|
88
|
+
name: title,
|
|
89
|
+
description,
|
|
90
|
+
datePublished: date,
|
|
91
|
+
...(dateModified ? { dateModified } : {}),
|
|
92
|
+
...getAuthor(metadata.authors),
|
|
93
|
+
...getImage(image, withBaseUrl, title),
|
|
94
|
+
...(keywords ? { keywords } : {}),
|
|
95
|
+
isPartOf: {
|
|
96
|
+
'@type': 'Blog',
|
|
97
|
+
'@id': `${siteConfig.url}${blogMetadata.blogBasePath}`,
|
|
98
|
+
name: blogMetadata.blogTitle,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** @returns A {@link https://schema.org/Person} constructed from the {@link Author} */
|
|
103
|
+
function createPersonStructuredData(author) {
|
|
104
|
+
return {
|
|
105
|
+
'@type': 'Person',
|
|
106
|
+
...(author.name ? { name: author.name } : {}),
|
|
107
|
+
...(author.title ? { description: author.title } : {}),
|
|
108
|
+
...(author.url ? { url: author.url } : {}),
|
|
109
|
+
...(author.email ? { email: author.email } : {}),
|
|
110
|
+
...(author.imageURL ? { image: author.imageURL } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** @returns A {@link https://schema.org/ImageObject} */
|
|
114
|
+
function createImageStructuredData({ imageUrl, caption, }) {
|
|
115
|
+
return {
|
|
116
|
+
'@type': 'ImageObject',
|
|
117
|
+
'@id': imageUrl,
|
|
118
|
+
url: imageUrl,
|
|
119
|
+
contentUrl: imageUrl,
|
|
120
|
+
caption,
|
|
121
|
+
};
|
|
122
|
+
}
|
package/lib/frontMatter.js
CHANGED
|
@@ -8,12 +8,14 @@ exports.validateBlogPostFrontMatter = validateBlogPostFrontMatter;
|
|
|
8
8
|
* LICENSE file in the root directory of this source tree.
|
|
9
9
|
*/
|
|
10
10
|
const utils_validation_1 = require("@docusaurus/utils-validation");
|
|
11
|
+
const authorsSocials_1 = require("./authorsSocials");
|
|
11
12
|
const BlogPostFrontMatterAuthorSchema = utils_validation_1.JoiFrontMatter.object({
|
|
12
13
|
key: utils_validation_1.JoiFrontMatter.string(),
|
|
13
14
|
name: utils_validation_1.JoiFrontMatter.string(),
|
|
14
15
|
title: utils_validation_1.JoiFrontMatter.string(),
|
|
15
16
|
url: utils_validation_1.URISchema,
|
|
16
17
|
imageURL: utils_validation_1.JoiFrontMatter.string(),
|
|
18
|
+
socials: authorsSocials_1.AuthorSocialsSchema,
|
|
17
19
|
})
|
|
18
20
|
.or('key', 'name', 'imageURL')
|
|
19
21
|
.rename('image_url', 'imageURL', { alias: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@docusaurus/plugin-content-blog",
|
|
3
|
-
"version": "0.0.0-
|
|
3
|
+
"version": "0.0.0-6000",
|
|
4
4
|
"description": "Blog plugin for Docusaurus.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "src/plugin-content-blog.d.ts",
|
|
@@ -31,13 +31,14 @@
|
|
|
31
31
|
},
|
|
32
32
|
"license": "MIT",
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@docusaurus/core": "0.0.0-
|
|
35
|
-
"@docusaurus/logger": "0.0.0-
|
|
36
|
-
"@docusaurus/mdx-loader": "0.0.0-
|
|
37
|
-
"@docusaurus/
|
|
38
|
-
"@docusaurus/
|
|
39
|
-
"@docusaurus/utils
|
|
40
|
-
"@docusaurus/utils-
|
|
34
|
+
"@docusaurus/core": "0.0.0-6000",
|
|
35
|
+
"@docusaurus/logger": "0.0.0-6000",
|
|
36
|
+
"@docusaurus/mdx-loader": "0.0.0-6000",
|
|
37
|
+
"@docusaurus/theme-common": "0.0.0-6000",
|
|
38
|
+
"@docusaurus/types": "0.0.0-6000",
|
|
39
|
+
"@docusaurus/utils": "0.0.0-6000",
|
|
40
|
+
"@docusaurus/utils-common": "0.0.0-6000",
|
|
41
|
+
"@docusaurus/utils-validation": "0.0.0-6000",
|
|
41
42
|
"cheerio": "^1.0.0-rc.12",
|
|
42
43
|
"feed": "^4.2.2",
|
|
43
44
|
"fs-extra": "^11.1.1",
|
|
@@ -59,5 +60,5 @@
|
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@total-typescript/shoehorn": "^0.1.2"
|
|
61
62
|
},
|
|
62
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "10f44ae468ae6751fa53eb8af6441265f0e5075c"
|
|
63
64
|
}
|
package/src/authors.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* LICENSE file in the root directory of this source tree.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import * as _ from 'lodash';
|
|
8
9
|
import {getDataFileData, normalizeUrl} from '@docusaurus/utils';
|
|
9
10
|
import {Joi, URISchema} from '@docusaurus/utils-validation';
|
|
11
|
+
import {AuthorSocialsSchema, normalizeSocials} from './authorsSocials';
|
|
10
12
|
import type {BlogContentPaths} from './types';
|
|
11
13
|
import type {
|
|
12
14
|
Author,
|
|
@@ -20,12 +22,13 @@ export type AuthorsMap = {[authorKey: string]: Author};
|
|
|
20
22
|
const AuthorsMapSchema = Joi.object<AuthorsMap>()
|
|
21
23
|
.pattern(
|
|
22
24
|
Joi.string(),
|
|
23
|
-
Joi.object({
|
|
25
|
+
Joi.object<Author>({
|
|
24
26
|
name: Joi.string(),
|
|
25
27
|
url: URISchema,
|
|
26
28
|
imageURL: URISchema,
|
|
27
29
|
title: Joi.string(),
|
|
28
30
|
email: Joi.string(),
|
|
31
|
+
socials: AuthorSocialsSchema,
|
|
29
32
|
})
|
|
30
33
|
.rename('image_url', 'imageURL')
|
|
31
34
|
.or('name', 'imageURL')
|
|
@@ -51,18 +54,32 @@ export function validateAuthorsMap(content: unknown): AuthorsMap {
|
|
|
51
54
|
return value;
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
function normalizeAuthor(author: Author): Author {
|
|
58
|
+
return {
|
|
59
|
+
...author,
|
|
60
|
+
socials: author.socials ? normalizeSocials(author.socials) : undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeAuthorsMap(authorsMap: AuthorsMap): AuthorsMap {
|
|
65
|
+
return _.mapValues(authorsMap, normalizeAuthor);
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
export async function getAuthorsMap(params: {
|
|
55
69
|
authorsMapPath: string;
|
|
56
70
|
contentPaths: BlogContentPaths;
|
|
57
71
|
}): Promise<AuthorsMap | undefined> {
|
|
58
|
-
|
|
72
|
+
const authorsMap = await getDataFileData(
|
|
59
73
|
{
|
|
60
74
|
filePath: params.authorsMapPath,
|
|
61
75
|
contentPaths: params.contentPaths,
|
|
62
76
|
fileType: 'authors map',
|
|
63
77
|
},
|
|
78
|
+
// TODO annoying to test: tightly coupled FS reads + validation...
|
|
64
79
|
validateAuthorsMap,
|
|
65
80
|
);
|
|
81
|
+
|
|
82
|
+
return authorsMap ? normalizeAuthorsMap(authorsMap) : undefined;
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
type AuthorsParam = {
|
|
@@ -115,7 +132,7 @@ function getFrontMatterAuthorLegacy({
|
|
|
115
132
|
function normalizeFrontMatterAuthors(
|
|
116
133
|
frontMatterAuthors: BlogPostFrontMatterAuthors = [],
|
|
117
134
|
): BlogPostFrontMatterAuthor[] {
|
|
118
|
-
function
|
|
135
|
+
function normalizeFrontMatterAuthor(
|
|
119
136
|
authorInput: string | Author,
|
|
120
137
|
): BlogPostFrontMatterAuthor {
|
|
121
138
|
if (typeof authorInput === 'string') {
|
|
@@ -128,8 +145,8 @@ function normalizeFrontMatterAuthors(
|
|
|
128
145
|
}
|
|
129
146
|
|
|
130
147
|
return Array.isArray(frontMatterAuthors)
|
|
131
|
-
? frontMatterAuthors.map(
|
|
132
|
-
: [
|
|
148
|
+
? frontMatterAuthors.map(normalizeFrontMatterAuthor)
|
|
149
|
+
: [normalizeFrontMatterAuthor(frontMatterAuthors)];
|
|
133
150
|
}
|
|
134
151
|
|
|
135
152
|
function getFrontMatterAuthors(params: AuthorsParam): Author[] {
|
|
@@ -158,11 +175,11 @@ ${Object.keys(authorsMap)
|
|
|
158
175
|
}
|
|
159
176
|
|
|
160
177
|
function toAuthor(frontMatterAuthor: BlogPostFrontMatterAuthor): Author {
|
|
161
|
-
return {
|
|
178
|
+
return normalizeAuthor({
|
|
162
179
|
// Author def from authorsMap can be locally overridden by front matter
|
|
163
180
|
...getAuthorsMapAuthor(frontMatterAuthor.key),
|
|
164
181
|
...frontMatterAuthor,
|
|
165
|
-
};
|
|
182
|
+
});
|
|
166
183
|
}
|
|
167
184
|
|
|
168
185
|
return frontMatterAuthors.map(toAuthor);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {Joi} from '@docusaurus/utils-validation';
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
AuthorSocials,
|
|
12
|
+
SocialPlatformKey,
|
|
13
|
+
} from '@docusaurus/plugin-content-blog';
|
|
14
|
+
|
|
15
|
+
export const AuthorSocialsSchema = Joi.object<AuthorSocials>({
|
|
16
|
+
twitter: Joi.string(),
|
|
17
|
+
github: Joi.string(),
|
|
18
|
+
linkedin: Joi.string(),
|
|
19
|
+
// StackOverflow userIds like '82609' are parsed as numbers by Yaml
|
|
20
|
+
stackoverflow: Joi.alternatives()
|
|
21
|
+
.try(Joi.number(), Joi.string())
|
|
22
|
+
.custom((val) => String(val)),
|
|
23
|
+
x: Joi.string(),
|
|
24
|
+
}).unknown();
|
|
25
|
+
|
|
26
|
+
type PredefinedPlatformNormalizer = (value: string) => string;
|
|
27
|
+
|
|
28
|
+
const PredefinedPlatformNormalizers: Record<
|
|
29
|
+
SocialPlatformKey | string,
|
|
30
|
+
PredefinedPlatformNormalizer
|
|
31
|
+
> = {
|
|
32
|
+
x: (handle: string) => `https://x.com/${handle}`,
|
|
33
|
+
twitter: (handle: string) => `https://twitter.com/${handle}`,
|
|
34
|
+
github: (handle: string) => `https://github.com/${handle}`,
|
|
35
|
+
linkedin: (handle: string) => `https://www.linkedin.com/in/${handle}/`,
|
|
36
|
+
stackoverflow: (userId: string) =>
|
|
37
|
+
`https://stackoverflow.com/users/${userId}`,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type SocialEntry = [string, string];
|
|
41
|
+
|
|
42
|
+
function normalizeSocialEntry([platform, value]: SocialEntry): SocialEntry {
|
|
43
|
+
const normalizer = PredefinedPlatformNormalizers[platform.toLowerCase()];
|
|
44
|
+
const isAbsoluteUrl =
|
|
45
|
+
value.startsWith('http://') || value.startsWith('https://');
|
|
46
|
+
if (isAbsoluteUrl) {
|
|
47
|
+
return [platform, value];
|
|
48
|
+
} else if (value.includes('/')) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Author socials should be usernames/userIds/handles, or fully qualified HTTP(s) absolute URLs.
|
|
51
|
+
Social platform '${platform}' has illegal value '${value}'`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (normalizer && !isAbsoluteUrl) {
|
|
55
|
+
const normalizedPlatform = platform.toLowerCase();
|
|
56
|
+
const normalizedValue = normalizer(value);
|
|
57
|
+
return [normalizedPlatform as SocialPlatformKey, normalizedValue];
|
|
58
|
+
}
|
|
59
|
+
return [platform, value];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const normalizeSocials = (socials: AuthorSocials): AuthorSocials => {
|
|
63
|
+
return Object.fromEntries(Object.entries(socials).map(normalizeSocialEntry));
|
|
64
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, {useMemo, type ReactNode, useContext} from 'react';
|
|
9
|
+
import {ReactContextError} from '@docusaurus/theme-common/internal';
|
|
10
|
+
import useRouteContext from '@docusaurus/useRouteContext';
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
PropBlogPostContent,
|
|
14
|
+
BlogMetadata,
|
|
15
|
+
} from '@docusaurus/plugin-content-blog';
|
|
16
|
+
|
|
17
|
+
export function useBlogMetadata(): BlogMetadata {
|
|
18
|
+
const routeContext = useRouteContext();
|
|
19
|
+
const blogMetadata = routeContext?.data?.blogMetadata;
|
|
20
|
+
if (!blogMetadata) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
"useBlogMetadata() can't be called on the current route because the blog metadata could not be found in route context",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return blogMetadata as BlogMetadata;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The React context value returned by the `useBlogPost()` hook.
|
|
30
|
+
* It contains useful data related to the currently browsed blog post.
|
|
31
|
+
*/
|
|
32
|
+
export type BlogPostContextValue = Pick<
|
|
33
|
+
PropBlogPostContent,
|
|
34
|
+
'metadata' | 'frontMatter' | 'assets' | 'toc'
|
|
35
|
+
> & {
|
|
36
|
+
readonly isBlogPostPage: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const Context = React.createContext<BlogPostContextValue | null>(null);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Note: we don't use `PropBlogPostContent` as context value on purpose.
|
|
43
|
+
* Metadata is currently stored inside the MDX component, but we may want to
|
|
44
|
+
* change that in the future.
|
|
45
|
+
*/
|
|
46
|
+
function useContextValue({
|
|
47
|
+
content,
|
|
48
|
+
isBlogPostPage,
|
|
49
|
+
}: {
|
|
50
|
+
content: PropBlogPostContent;
|
|
51
|
+
isBlogPostPage: boolean;
|
|
52
|
+
}): BlogPostContextValue {
|
|
53
|
+
return useMemo(
|
|
54
|
+
() => ({
|
|
55
|
+
metadata: content.metadata,
|
|
56
|
+
frontMatter: content.frontMatter,
|
|
57
|
+
assets: content.assets,
|
|
58
|
+
toc: content.toc,
|
|
59
|
+
isBlogPostPage,
|
|
60
|
+
}),
|
|
61
|
+
[content, isBlogPostPage],
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* This is a very thin layer around the `content` received from the MDX loader.
|
|
67
|
+
* It provides metadata about the blog post to the children tree.
|
|
68
|
+
*/
|
|
69
|
+
export function BlogPostProvider({
|
|
70
|
+
children,
|
|
71
|
+
content,
|
|
72
|
+
isBlogPostPage = false,
|
|
73
|
+
}: {
|
|
74
|
+
children: ReactNode;
|
|
75
|
+
content: PropBlogPostContent;
|
|
76
|
+
isBlogPostPage?: boolean;
|
|
77
|
+
}): JSX.Element {
|
|
78
|
+
const contextValue = useContextValue({content, isBlogPostPage});
|
|
79
|
+
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns the data of the currently browsed blog post. Gives access to
|
|
84
|
+
* front matter, metadata, TOC, etc.
|
|
85
|
+
* When swizzling a low-level component (e.g. the "Edit this page" link)
|
|
86
|
+
* and you need some extra metadata, you don't have to drill the props
|
|
87
|
+
* all the way through the component tree: simply use this hook instead.
|
|
88
|
+
*/
|
|
89
|
+
export function useBlogPost(): BlogPostContextValue {
|
|
90
|
+
const blogPost = useContext(Context);
|
|
91
|
+
if (blogPost === null) {
|
|
92
|
+
throw new ReactContextError('BlogPostProvider');
|
|
93
|
+
}
|
|
94
|
+
return blogPost;
|
|
95
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
BlogPostProvider,
|
|
10
|
+
type BlogPostContextValue,
|
|
11
|
+
useBlogPost,
|
|
12
|
+
useBlogMetadata,
|
|
13
|
+
} from './contexts';
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
useBlogListPageStructuredData,
|
|
17
|
+
useBlogPostStructuredData,
|
|
18
|
+
} from './structuredDataUtils';
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
BlogSidebarItemList,
|
|
22
|
+
groupBlogSidebarItemsByYear,
|
|
23
|
+
useVisibleBlogSidebarItems,
|
|
24
|
+
} from './sidebarUtils';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {groupBlogSidebarItemsByYear} from './sidebarUtils';
|
|
9
|
+
import type {BlogSidebarItem} from '@docusaurus/plugin-content-blog';
|
|
10
|
+
|
|
11
|
+
describe('groupBlogSidebarItemsByYear', () => {
|
|
12
|
+
const post1: BlogSidebarItem = {
|
|
13
|
+
title: 'post1',
|
|
14
|
+
permalink: '/post1',
|
|
15
|
+
date: '2024-10-03',
|
|
16
|
+
unlisted: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const post2: BlogSidebarItem = {
|
|
20
|
+
title: 'post2',
|
|
21
|
+
permalink: '/post2',
|
|
22
|
+
date: '2024-05-02',
|
|
23
|
+
unlisted: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const post3: BlogSidebarItem = {
|
|
27
|
+
title: 'post3',
|
|
28
|
+
permalink: '/post3',
|
|
29
|
+
date: '2022-11-18',
|
|
30
|
+
unlisted: false,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
it('can group items by year', () => {
|
|
34
|
+
const items: BlogSidebarItem[] = [post1, post2, post3];
|
|
35
|
+
const entries = groupBlogSidebarItemsByYear(items);
|
|
36
|
+
|
|
37
|
+
expect(entries).toEqual([
|
|
38
|
+
['2024', [post1, post2]],
|
|
39
|
+
['2022', [post3]],
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('always returns result in descending chronological order', () => {
|
|
44
|
+
const items: BlogSidebarItem[] = [post3, post1, post2];
|
|
45
|
+
const entries = groupBlogSidebarItemsByYear(items);
|
|
46
|
+
|
|
47
|
+
expect(entries).toEqual([
|
|
48
|
+
['2024', [post1, post2]],
|
|
49
|
+
['2022', [post3]],
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, {type ReactNode, useMemo} from 'react';
|
|
9
|
+
import {useLocation} from '@docusaurus/router';
|
|
10
|
+
import Link from '@docusaurus/Link';
|
|
11
|
+
import {groupBy} from '@docusaurus/theme-common';
|
|
12
|
+
import {isSamePath} from '@docusaurus/theme-common/internal';
|
|
13
|
+
import type {BlogSidebarItem} from '@docusaurus/plugin-content-blog';
|
|
14
|
+
|
|
15
|
+
function isVisible(item: BlogSidebarItem, pathname: string): boolean {
|
|
16
|
+
if (item.unlisted && !isSamePath(item.permalink, pathname)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Return the visible blog sidebar items to display.
|
|
24
|
+
* Unlisted items are filtered.
|
|
25
|
+
*/
|
|
26
|
+
export function useVisibleBlogSidebarItems(
|
|
27
|
+
items: BlogSidebarItem[],
|
|
28
|
+
): BlogSidebarItem[] {
|
|
29
|
+
const {pathname} = useLocation();
|
|
30
|
+
return useMemo(
|
|
31
|
+
() => items.filter((item) => isVisible(item, pathname)),
|
|
32
|
+
[items, pathname],
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function groupBlogSidebarItemsByYear(
|
|
37
|
+
items: BlogSidebarItem[],
|
|
38
|
+
): [string, BlogSidebarItem[]][] {
|
|
39
|
+
const groupedByYear = groupBy(items, (item) => {
|
|
40
|
+
return `${new Date(item.date).getFullYear()}`;
|
|
41
|
+
});
|
|
42
|
+
// "as" is safe here
|
|
43
|
+
// see https://github.com/microsoft/TypeScript/pull/56805#issuecomment-2196526425
|
|
44
|
+
const entries = Object.entries(groupedByYear) as [
|
|
45
|
+
string,
|
|
46
|
+
BlogSidebarItem[],
|
|
47
|
+
][];
|
|
48
|
+
// We have to use entries because of https://x.com/sebastienlorber/status/1806371668614369486
|
|
49
|
+
// Objects with string/number keys are automatically sorted asc...
|
|
50
|
+
// Even if keys are strings like "2024"
|
|
51
|
+
// We want descending order for years
|
|
52
|
+
// Alternative: using Map.groupBy (not affected by this "reordering")
|
|
53
|
+
entries.reverse();
|
|
54
|
+
return entries;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function BlogSidebarItemList({
|
|
58
|
+
items,
|
|
59
|
+
ulClassName,
|
|
60
|
+
liClassName,
|
|
61
|
+
linkClassName,
|
|
62
|
+
linkActiveClassName,
|
|
63
|
+
}: {
|
|
64
|
+
items: BlogSidebarItem[];
|
|
65
|
+
ulClassName?: string;
|
|
66
|
+
liClassName?: string;
|
|
67
|
+
linkClassName?: string;
|
|
68
|
+
linkActiveClassName?: string;
|
|
69
|
+
}): ReactNode {
|
|
70
|
+
return (
|
|
71
|
+
<ul className={ulClassName}>
|
|
72
|
+
{items.map((item) => (
|
|
73
|
+
<li key={item.permalink} className={liClassName}>
|
|
74
|
+
<Link
|
|
75
|
+
isNavLink
|
|
76
|
+
to={item.permalink}
|
|
77
|
+
className={linkClassName}
|
|
78
|
+
activeClassName={linkActiveClassName}>
|
|
79
|
+
{item.title}
|
|
80
|
+
</Link>
|
|
81
|
+
</li>
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {useBaseUrlUtils, type BaseUrlUtils} from '@docusaurus/useBaseUrl';
|
|
9
|
+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
10
|
+
import {useBlogMetadata} from '@docusaurus/plugin-content-blog/client';
|
|
11
|
+
import type {Props as BlogListPageStructuredDataProps} from '@theme/BlogListPage/StructuredData';
|
|
12
|
+
import {useBlogPost} from './contexts';
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
Blog,
|
|
16
|
+
BlogPosting,
|
|
17
|
+
WithContext,
|
|
18
|
+
Person,
|
|
19
|
+
ImageObject,
|
|
20
|
+
} from 'schema-dts';
|
|
21
|
+
import type {
|
|
22
|
+
Author,
|
|
23
|
+
PropBlogPostContent,
|
|
24
|
+
} from '@docusaurus/plugin-content-blog';
|
|
25
|
+
import type {DocusaurusConfig} from '@docusaurus/types';
|
|
26
|
+
|
|
27
|
+
const convertDate = (dateMs: number) => new Date(dateMs).toISOString();
|
|
28
|
+
|
|
29
|
+
function getBlogPost(
|
|
30
|
+
blogPostContent: PropBlogPostContent,
|
|
31
|
+
siteConfig: DocusaurusConfig,
|
|
32
|
+
withBaseUrl: BaseUrlUtils['withBaseUrl'],
|
|
33
|
+
): BlogPosting {
|
|
34
|
+
const {assets, frontMatter, metadata} = blogPostContent;
|
|
35
|
+
const {date, title, description, lastUpdatedAt} = metadata;
|
|
36
|
+
|
|
37
|
+
const image = assets.image ?? frontMatter.image;
|
|
38
|
+
const keywords = frontMatter.keywords ?? [];
|
|
39
|
+
|
|
40
|
+
const blogUrl = `${siteConfig.url}${metadata.permalink}`;
|
|
41
|
+
|
|
42
|
+
const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
'@type': 'BlogPosting',
|
|
46
|
+
'@id': blogUrl,
|
|
47
|
+
mainEntityOfPage: blogUrl,
|
|
48
|
+
url: blogUrl,
|
|
49
|
+
headline: title,
|
|
50
|
+
name: title,
|
|
51
|
+
description,
|
|
52
|
+
datePublished: date,
|
|
53
|
+
...(dateModified ? {dateModified} : {}),
|
|
54
|
+
...getAuthor(metadata.authors),
|
|
55
|
+
...getImage(image, withBaseUrl, title),
|
|
56
|
+
...(keywords ? {keywords} : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getAuthor(authors: Author[]) {
|
|
61
|
+
const authorsStructuredData = authors.map(createPersonStructuredData);
|
|
62
|
+
return {
|
|
63
|
+
author:
|
|
64
|
+
authorsStructuredData.length === 1
|
|
65
|
+
? authorsStructuredData[0]
|
|
66
|
+
: authorsStructuredData,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getImage(
|
|
71
|
+
image: string | undefined,
|
|
72
|
+
withBaseUrl: BaseUrlUtils['withBaseUrl'],
|
|
73
|
+
title: string,
|
|
74
|
+
) {
|
|
75
|
+
return image
|
|
76
|
+
? {
|
|
77
|
+
image: createImageStructuredData({
|
|
78
|
+
imageUrl: withBaseUrl(image, {absolute: true}),
|
|
79
|
+
caption: `title image for the blog post: ${title}`,
|
|
80
|
+
}),
|
|
81
|
+
}
|
|
82
|
+
: {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useBlogListPageStructuredData(
|
|
86
|
+
props: BlogListPageStructuredDataProps,
|
|
87
|
+
): WithContext<Blog> {
|
|
88
|
+
const {siteConfig} = useDocusaurusContext();
|
|
89
|
+
const {withBaseUrl} = useBaseUrlUtils();
|
|
90
|
+
|
|
91
|
+
const {
|
|
92
|
+
metadata: {blogDescription, blogTitle, permalink},
|
|
93
|
+
} = props;
|
|
94
|
+
|
|
95
|
+
const url = `${siteConfig.url}${permalink}`;
|
|
96
|
+
|
|
97
|
+
// details on structured data support: https://schema.org/Blog
|
|
98
|
+
return {
|
|
99
|
+
'@context': 'https://schema.org',
|
|
100
|
+
'@type': 'Blog',
|
|
101
|
+
'@id': url,
|
|
102
|
+
mainEntityOfPage: url,
|
|
103
|
+
headline: blogTitle,
|
|
104
|
+
description: blogDescription,
|
|
105
|
+
blogPost: props.items.map((blogItem) =>
|
|
106
|
+
getBlogPost(blogItem.content, siteConfig, withBaseUrl),
|
|
107
|
+
),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function useBlogPostStructuredData(): WithContext<BlogPosting> {
|
|
112
|
+
const blogMetadata = useBlogMetadata();
|
|
113
|
+
const {assets, metadata} = useBlogPost();
|
|
114
|
+
const {siteConfig} = useDocusaurusContext();
|
|
115
|
+
const {withBaseUrl} = useBaseUrlUtils();
|
|
116
|
+
|
|
117
|
+
const {date, title, description, frontMatter, lastUpdatedAt} = metadata;
|
|
118
|
+
|
|
119
|
+
const image = assets.image ?? frontMatter.image;
|
|
120
|
+
const keywords = frontMatter.keywords ?? [];
|
|
121
|
+
|
|
122
|
+
const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;
|
|
123
|
+
|
|
124
|
+
const url = `${siteConfig.url}${metadata.permalink}`;
|
|
125
|
+
|
|
126
|
+
// details on structured data support: https://schema.org/BlogPosting
|
|
127
|
+
// BlogPosting is one of the structured data types that Google explicitly
|
|
128
|
+
// supports: https://developers.google.com/search/docs/appearance/structured-data/article#structured-data-type-definitions
|
|
129
|
+
return {
|
|
130
|
+
'@context': 'https://schema.org',
|
|
131
|
+
'@type': 'BlogPosting',
|
|
132
|
+
'@id': url,
|
|
133
|
+
mainEntityOfPage: url,
|
|
134
|
+
url,
|
|
135
|
+
headline: title,
|
|
136
|
+
name: title,
|
|
137
|
+
description,
|
|
138
|
+
datePublished: date,
|
|
139
|
+
...(dateModified ? {dateModified} : {}),
|
|
140
|
+
...getAuthor(metadata.authors),
|
|
141
|
+
...getImage(image, withBaseUrl, title),
|
|
142
|
+
...(keywords ? {keywords} : {}),
|
|
143
|
+
isPartOf: {
|
|
144
|
+
'@type': 'Blog',
|
|
145
|
+
'@id': `${siteConfig.url}${blogMetadata.blogBasePath}`,
|
|
146
|
+
name: blogMetadata.blogTitle,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @returns A {@link https://schema.org/Person} constructed from the {@link Author} */
|
|
152
|
+
function createPersonStructuredData(author: Author): Person {
|
|
153
|
+
return {
|
|
154
|
+
'@type': 'Person',
|
|
155
|
+
...(author.name ? {name: author.name} : {}),
|
|
156
|
+
...(author.title ? {description: author.title} : {}),
|
|
157
|
+
...(author.url ? {url: author.url} : {}),
|
|
158
|
+
...(author.email ? {email: author.email} : {}),
|
|
159
|
+
...(author.imageURL ? {image: author.imageURL} : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** @returns A {@link https://schema.org/ImageObject} */
|
|
164
|
+
function createImageStructuredData({
|
|
165
|
+
imageUrl,
|
|
166
|
+
caption,
|
|
167
|
+
}: {
|
|
168
|
+
imageUrl: string;
|
|
169
|
+
caption: string;
|
|
170
|
+
}): ImageObject {
|
|
171
|
+
return {
|
|
172
|
+
'@type': 'ImageObject',
|
|
173
|
+
'@id': imageUrl,
|
|
174
|
+
url: imageUrl,
|
|
175
|
+
contentUrl: imageUrl,
|
|
176
|
+
caption,
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/frontMatter.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
URISchema,
|
|
14
14
|
validateFrontMatter,
|
|
15
15
|
} from '@docusaurus/utils-validation';
|
|
16
|
+
import {AuthorSocialsSchema} from './authorsSocials';
|
|
16
17
|
import type {BlogPostFrontMatter} from '@docusaurus/plugin-content-blog';
|
|
17
18
|
|
|
18
19
|
const BlogPostFrontMatterAuthorSchema = Joi.object({
|
|
@@ -21,6 +22,7 @@ const BlogPostFrontMatterAuthorSchema = Joi.object({
|
|
|
21
22
|
title: Joi.string(),
|
|
22
23
|
url: URISchema,
|
|
23
24
|
imageURL: Joi.string(),
|
|
25
|
+
socials: AuthorSocialsSchema,
|
|
24
26
|
})
|
|
25
27
|
.or('key', 'name', 'imageURL')
|
|
26
28
|
.rename('image_url', 'imageURL', {alias: true});
|
|
@@ -43,6 +43,29 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
|
|
|
43
43
|
authorsImageUrls: (string | undefined)[];
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Note we don't pre-define all possible platforms
|
|
48
|
+
* Users can add their own custom platforms if needed
|
|
49
|
+
*/
|
|
50
|
+
export type SocialPlatformKey =
|
|
51
|
+
| 'twitter'
|
|
52
|
+
| 'github'
|
|
53
|
+
| 'linkedin'
|
|
54
|
+
| 'stackoverflow'
|
|
55
|
+
| 'x';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Social platforms of the author.
|
|
59
|
+
* The record value is usually the fully qualified link of the social profile.
|
|
60
|
+
* For pre-defined platforms, it's possible to pass a handle instead
|
|
61
|
+
*/
|
|
62
|
+
export type AuthorSocials = Partial<Record<SocialPlatformKey, string>> & {
|
|
63
|
+
/**
|
|
64
|
+
* Unknown keys are allowed: users can pass additional social platforms
|
|
65
|
+
*/
|
|
66
|
+
[customAuthorSocialPlatform: string]: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
46
69
|
export type Author = {
|
|
47
70
|
key?: string; // TODO temporary, need refactor
|
|
48
71
|
|
|
@@ -69,11 +92,15 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
|
|
|
69
92
|
* to generate a fallback `mailto:` URL.
|
|
70
93
|
*/
|
|
71
94
|
email?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Social platforms of the author
|
|
97
|
+
* Usually displayed as a list of social icon links.
|
|
98
|
+
*/
|
|
99
|
+
socials?: AuthorSocials;
|
|
72
100
|
/**
|
|
73
101
|
* Unknown keys are allowed, so that we can pass custom fields to authors,
|
|
74
|
-
* e.g., `twitter`.
|
|
75
102
|
*/
|
|
76
|
-
[
|
|
103
|
+
[customAuthorAttribute: string]: unknown;
|
|
77
104
|
};
|
|
78
105
|
|
|
79
106
|
/**
|
package/src/client/index.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
3
|
-
*
|
|
4
|
-
* This source code is licensed under the MIT license found in the
|
|
5
|
-
* LICENSE file in the root directory of this source tree.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import useRouteContext from '@docusaurus/useRouteContext';
|
|
9
|
-
import type {BlogMetadata} from '@docusaurus/plugin-content-blog';
|
|
10
|
-
|
|
11
|
-
export function useBlogMetadata(): BlogMetadata {
|
|
12
|
-
const routeContext = useRouteContext();
|
|
13
|
-
const blogMetadata = routeContext?.data?.blogMetadata;
|
|
14
|
-
if (!blogMetadata) {
|
|
15
|
-
throw new Error(
|
|
16
|
-
"useBlogMetadata() can't be called on the current route because the blog metadata could not be found in route context",
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
return blogMetadata as BlogMetadata;
|
|
20
|
-
}
|