@docusaurus/plugin-content-blog 3.3.2 → 3.5.0
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/assets/atom.css +75 -0
- package/assets/atom.xsl +92 -0
- package/assets/rss.css +75 -0
- package/assets/rss.xsl +86 -0
- package/lib/authors.d.ts +9 -11
- package/lib/authors.js +42 -64
- package/lib/authorsMap.d.ts +23 -0
- package/lib/authorsMap.js +116 -0
- package/lib/authorsProblems.d.ts +21 -0
- package/lib/authorsProblems.js +51 -0
- package/lib/authorsSocials.d.ts +10 -0
- package/lib/authorsSocials.js +48 -0
- package/lib/blogUtils.d.ts +7 -12
- package/lib/blogUtils.js +44 -34
- package/lib/client/contexts.d.ts +33 -0
- package/lib/client/contexts.js +54 -0
- package/lib/client/index.d.ts +3 -3
- 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/feed.d.ts +8 -3
- package/lib/feed.js +111 -20
- package/lib/frontMatter.d.ts +0 -1
- package/lib/frontMatter.js +3 -2
- package/lib/index.d.ts +0 -1
- package/lib/index.js +132 -105
- package/lib/markdownLoader.js +3 -7
- package/lib/options.d.ts +4 -1
- package/lib/options.js +107 -26
- package/lib/props.d.ts +9 -2
- package/lib/props.js +23 -3
- package/lib/remark/footnoteIDFixer.js +1 -1
- package/lib/routes.d.ts +0 -1
- package/lib/routes.js +82 -14
- package/lib/translations.d.ts +0 -1
- package/lib/translations.js +2 -3
- package/lib/types.d.ts +1 -8
- package/package.json +13 -10
- package/src/authors.ts +56 -93
- package/src/authorsMap.ts +171 -0
- package/src/authorsProblems.ts +72 -0
- package/src/authorsSocials.ts +64 -0
- package/src/blogUtils.ts +51 -46
- 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/feed.ts +197 -18
- package/src/frontMatter.ts +2 -0
- package/src/index.ts +182 -137
- package/src/markdownLoader.ts +3 -7
- package/src/options.ts +132 -32
- package/src/plugin-content-blog.d.ts +252 -113
- package/src/props.ts +41 -1
- package/src/routes.ts +102 -12
- package/src/types.ts +1 -6
- package/src/client/index.ts +0 -20
|
@@ -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
|
+
};
|
package/src/blogUtils.ts
CHANGED
|
@@ -17,9 +17,7 @@ import {
|
|
|
17
17
|
getEditUrl,
|
|
18
18
|
getFolderContainingFile,
|
|
19
19
|
posixPath,
|
|
20
|
-
replaceMarkdownLinks,
|
|
21
20
|
Globby,
|
|
22
|
-
normalizeFrontMatterTags,
|
|
23
21
|
groupTaggedItems,
|
|
24
22
|
getTagVisibility,
|
|
25
23
|
getFileCommitDate,
|
|
@@ -27,29 +25,49 @@ import {
|
|
|
27
25
|
isUnlisted,
|
|
28
26
|
isDraft,
|
|
29
27
|
readLastUpdateData,
|
|
28
|
+
normalizeTags,
|
|
29
|
+
aliasedSitePathToRelativePath,
|
|
30
30
|
} from '@docusaurus/utils';
|
|
31
|
+
import {getTagsFile} from '@docusaurus/utils-validation';
|
|
31
32
|
import {validateBlogPostFrontMatter} from './frontMatter';
|
|
32
|
-
import {
|
|
33
|
+
import {getBlogPostAuthors} from './authors';
|
|
34
|
+
import {reportAuthorsProblems} from './authorsProblems';
|
|
35
|
+
import type {TagsFile} from '@docusaurus/utils';
|
|
33
36
|
import type {LoadContext, ParseFrontMatter} from '@docusaurus/types';
|
|
34
37
|
import type {
|
|
38
|
+
AuthorsMap,
|
|
35
39
|
PluginOptions,
|
|
36
40
|
ReadingTimeFunction,
|
|
37
41
|
BlogPost,
|
|
38
42
|
BlogTags,
|
|
39
43
|
BlogPaginated,
|
|
40
44
|
} from '@docusaurus/plugin-content-blog';
|
|
41
|
-
import type {BlogContentPaths
|
|
45
|
+
import type {BlogContentPaths} from './types';
|
|
42
46
|
|
|
43
47
|
export function truncate(fileString: string, truncateMarker: RegExp): string {
|
|
44
48
|
return fileString.split(truncateMarker, 1).shift()!;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
export function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
export function reportUntruncatedBlogPosts({
|
|
52
|
+
blogPosts,
|
|
53
|
+
onUntruncatedBlogPosts,
|
|
54
|
+
}: {
|
|
55
|
+
blogPosts: BlogPost[];
|
|
56
|
+
onUntruncatedBlogPosts: PluginOptions['onUntruncatedBlogPosts'];
|
|
57
|
+
}): void {
|
|
58
|
+
const untruncatedBlogPosts = blogPosts.filter(
|
|
59
|
+
(p) => !p.metadata.hasTruncateMarker,
|
|
52
60
|
);
|
|
61
|
+
if (onUntruncatedBlogPosts !== 'ignore' && untruncatedBlogPosts.length > 0) {
|
|
62
|
+
const message = logger.interpolate`Docusaurus found blog posts without truncation markers:
|
|
63
|
+
- ${untruncatedBlogPosts
|
|
64
|
+
.map((p) => logger.path(aliasedSitePathToRelativePath(p.metadata.source)))
|
|
65
|
+
.join('\n- ')}
|
|
66
|
+
|
|
67
|
+
We recommend using truncation markers (code=${`<!-- truncate -->`} or code=${`{/* truncate */}`}) in blog posts to create shorter previews on blog paginated lists.
|
|
68
|
+
Tip: turn this security off with the code=${`onUntruncatedBlogPosts: 'ignore'`} blog plugin option.`;
|
|
69
|
+
logger.report(onUntruncatedBlogPosts)(message);
|
|
70
|
+
}
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
export function paginateBlogPosts({
|
|
@@ -70,7 +88,7 @@ export function paginateBlogPosts({
|
|
|
70
88
|
const totalCount = blogPosts.length;
|
|
71
89
|
const postsPerPage =
|
|
72
90
|
postsPerPageOption === 'ALL' ? totalCount : postsPerPageOption;
|
|
73
|
-
const numberOfPages = Math.ceil(totalCount / postsPerPage);
|
|
91
|
+
const numberOfPages = Math.max(1, Math.ceil(totalCount / postsPerPage));
|
|
74
92
|
|
|
75
93
|
const pages: BlogPaginated[] = [];
|
|
76
94
|
|
|
@@ -126,9 +144,11 @@ export function getBlogTags({
|
|
|
126
144
|
isUnlisted: (item) => item.metadata.unlisted,
|
|
127
145
|
});
|
|
128
146
|
return {
|
|
147
|
+
inline: tag.inline,
|
|
129
148
|
label: tag.label,
|
|
130
|
-
items: tagVisibility.listedItems.map((item) => item.id),
|
|
131
149
|
permalink: tag.permalink,
|
|
150
|
+
description: tag.description,
|
|
151
|
+
items: tagVisibility.listedItems.map((item) => item.id),
|
|
132
152
|
pages: paginateBlogPosts({
|
|
133
153
|
blogPosts: tagVisibility.listedItems,
|
|
134
154
|
basePageUrl: tag.permalink,
|
|
@@ -198,6 +218,7 @@ async function processBlogSourceFile(
|
|
|
198
218
|
contentPaths: BlogContentPaths,
|
|
199
219
|
context: LoadContext,
|
|
200
220
|
options: PluginOptions,
|
|
221
|
+
tagsFile: TagsFile | null,
|
|
201
222
|
authorsMap?: AuthorsMap,
|
|
202
223
|
): Promise<BlogPost | undefined> {
|
|
203
224
|
const {
|
|
@@ -316,12 +337,26 @@ async function processBlogSourceFile(
|
|
|
316
337
|
return undefined;
|
|
317
338
|
}
|
|
318
339
|
|
|
319
|
-
const
|
|
340
|
+
const tagsBaseRoutePath = normalizeUrl([
|
|
320
341
|
baseUrl,
|
|
321
342
|
routeBasePath,
|
|
322
343
|
tagsRouteBasePath,
|
|
323
344
|
]);
|
|
345
|
+
|
|
324
346
|
const authors = getBlogPostAuthors({authorsMap, frontMatter, baseUrl});
|
|
347
|
+
reportAuthorsProblems({
|
|
348
|
+
authors,
|
|
349
|
+
blogSourceRelative,
|
|
350
|
+
options,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const tags = normalizeTags({
|
|
354
|
+
options,
|
|
355
|
+
source: blogSourceRelative,
|
|
356
|
+
frontMatterTags: frontMatter.tags,
|
|
357
|
+
tagsBaseRoutePath,
|
|
358
|
+
tagsFile,
|
|
359
|
+
});
|
|
325
360
|
|
|
326
361
|
return {
|
|
327
362
|
id: slug,
|
|
@@ -332,7 +367,7 @@ async function processBlogSourceFile(
|
|
|
332
367
|
title,
|
|
333
368
|
description,
|
|
334
369
|
date,
|
|
335
|
-
tags
|
|
370
|
+
tags,
|
|
336
371
|
readingTime: showReadingTime
|
|
337
372
|
? options.readingTime({
|
|
338
373
|
content,
|
|
@@ -355,6 +390,7 @@ export async function generateBlogPosts(
|
|
|
355
390
|
contentPaths: BlogContentPaths,
|
|
356
391
|
context: LoadContext,
|
|
357
392
|
options: PluginOptions,
|
|
393
|
+
authorsMap?: AuthorsMap,
|
|
358
394
|
): Promise<BlogPost[]> {
|
|
359
395
|
const {include, exclude} = options;
|
|
360
396
|
|
|
@@ -367,10 +403,7 @@ export async function generateBlogPosts(
|
|
|
367
403
|
ignore: exclude,
|
|
368
404
|
});
|
|
369
405
|
|
|
370
|
-
const
|
|
371
|
-
contentPaths,
|
|
372
|
-
authorsMapPath: options.authorsMapPath,
|
|
373
|
-
});
|
|
406
|
+
const tagsFile = await getTagsFile({contentPaths, tags: options.tags});
|
|
374
407
|
|
|
375
408
|
async function doProcessBlogSourceFile(blogSourceFile: string) {
|
|
376
409
|
try {
|
|
@@ -379,6 +412,7 @@ export async function generateBlogPosts(
|
|
|
379
412
|
contentPaths,
|
|
380
413
|
context,
|
|
381
414
|
options,
|
|
415
|
+
tagsFile,
|
|
382
416
|
authorsMap,
|
|
383
417
|
);
|
|
384
418
|
} catch (err) {
|
|
@@ -403,35 +437,6 @@ export async function generateBlogPosts(
|
|
|
403
437
|
return blogPosts;
|
|
404
438
|
}
|
|
405
439
|
|
|
406
|
-
export type LinkifyParams = {
|
|
407
|
-
filePath: string;
|
|
408
|
-
fileString: string;
|
|
409
|
-
} & Pick<
|
|
410
|
-
BlogMarkdownLoaderOptions,
|
|
411
|
-
'sourceToPermalink' | 'siteDir' | 'contentPaths' | 'onBrokenMarkdownLink'
|
|
412
|
-
>;
|
|
413
|
-
|
|
414
|
-
export function linkify({
|
|
415
|
-
filePath,
|
|
416
|
-
contentPaths,
|
|
417
|
-
fileString,
|
|
418
|
-
siteDir,
|
|
419
|
-
sourceToPermalink,
|
|
420
|
-
onBrokenMarkdownLink,
|
|
421
|
-
}: LinkifyParams): string {
|
|
422
|
-
const {newContent, brokenMarkdownLinks} = replaceMarkdownLinks({
|
|
423
|
-
siteDir,
|
|
424
|
-
fileString,
|
|
425
|
-
filePath,
|
|
426
|
-
contentPaths,
|
|
427
|
-
sourceToPermalink,
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
brokenMarkdownLinks.forEach((l) => onBrokenMarkdownLink(l));
|
|
431
|
-
|
|
432
|
-
return newContent;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
440
|
export async function applyProcessBlogPosts({
|
|
436
441
|
blogPosts,
|
|
437
442
|
processBlogPosts,
|
|
@@ -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
|
+
}
|