feat(v2): core v2 i18n support + Docusaurus site Crowdin integration (#3325)

* docs i18n initial poc

* docs i18n initial poc

* docs i18n initial poc

* docs i18n initial poc

* crowdin-v2 attempt

* fix source

* use crowdin env variable

* try to install crowdin on netlify

* try to install crowdin on netlify

* try to use crowdin jar directly

* try to curl the crowdin jar

* add java version cmd

* try to run crowdin jar in netlify

* fix translatedDocsDirPath

* fix loadContext issue due to site baseUrl not being modified in generted config file

* real validateLocalesFile

* add locale option to deploy command

* better LocalizationFile type

* create util getPluginI18nPath

* better core localization context loading code

* More explicit VersionMetadata type for localized docs folders

* Ability to translate blog posts with Crowdin!

* blog: refactor markdown loader + report broken links + try to get linkify working better

* upgrade crowdin config to upload all docs folder files except source code related files

* try to support translated pages

* make markdown pages translation work

* add write-translations cli command template

* fix site not  reloaded with correct options

* refactor a bit the read/write of @generated/i18n.json file

* Add <Translate> + translate() API + use it on the docusaurus homepage

* watch locale translation dir

* early POC of adding babel parsing for translation extraction

* fs.stat => pathExists

* add install:fast script

* TSC: noUnusedLocals false as it's already checked  by eslint

* POC of extracting translations from source code

* minor typo

* fix extracted key to code

* initial docs extracted translations

* stable plugin translations POC

* add crowdin commands

* quickfix for i18n deployment

* POC  of themeConfig translation

* add ability to have localized site without path prefix

* sidebar typo

* refactor translation system to output multiple translation files

* translate properly  the docs plugin

* improve theme classic translation

* rework translation extractor to handle new Chrome I18n JSON format (include id/description)

* writeTranslations: allow to pass locales cli arg

* fix ThemeConfig TS issues

* fix localizePath errors

* temporary add write-translations to netlify deploy preview

* complete example of french translated folder

* update fr folder

* remove all translations from repo

* minor translation  refactors

* fix all docs-related tests

* fix blog feed tests

* fix last blog tests

* refactor i18n context a bit, extract codeTranslations in an extra generated file

* improve @generated/i18n type

* fix some i18n todos

* minor refactor

* fix logo typing issue after merge

* move i18n.json to siteConfig instead

* try to fix windows CI build

* fix config test

* attempt to fix windows non-posix path

* increase v1 minify css jest timeout due to flaky test

* proper support for localizePath on windows

* remove non-functional install:fast

* docs, fix docsDirPathLocalized

* fix Docs i18n / md linkify issues

* ensure theme-classic swizzling will use "nextjs" sources (transpiled less aggressively, to make them human readable)

* fix some snapshots

* improve themeConfig translation code

* refactor a bit getPluginI18nPath

* readTranslationFileContent => ensure files are valid, fail fast

* fix versions tests

* add extractSourceCodeAstTranslations comments/resource links

* ignore eslint: packages/docusaurus-theme-classic/lib-next/

* fix windows CI with cross-env

* crowdin ignore .DS_Store

* improve writeTranslations + add exhaustive tests for translations.ts

* remove typo

* Wire currentLocale to algolia search

* improve i18n locale error

* Add tests for translationsExtractor.ts

* better code translation extraction regarding statically evaluable code

* fix typo

* fix typo

* improve theme-classic transpilation

* refactor  +  add i18n tests

* typo

* test new utils

* add missing snapshots

* fix snapshot

* blog onBrokenMarkdownLink

* add sidebars tests

* theme-classic index should now use ES modules

* tests for theme-classic translations

* useless comment

* add more translation tests

* simplify/cleanup writeTranslations

* try to fix Netlify fr deployment

* blog: test translated md is used during feed generation

* blog: better i18n tests regarding editUrl + md translation application

* more i18n tests for docs plugin

* more i18n tests for docs plugin

* Add tests for pages i18n

* polish docusaurus build i18n logs
This commit is contained in:
Sébastien Lorber 2020-11-26 12:16:46 +01:00 committed by GitHub
parent 85fe96d112
commit 3166fab307
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 5447 additions and 649 deletions

View file

@ -0,0 +1,11 @@
---
title: This post links to another one!
---
[Good link 1](2018-12-14-Happy-First-Birthday-Slash.md)
[Good link 2](./2018-12-14-Happy-First-Birthday-Slash.md)
[Bad link 1](postNotExist1.md)
[Bad link 1](./postNotExist2.mdx)

View file

@ -2,4 +2,4 @@
title: Happy 1st Birthday Slash!
---
pattern name
Happy birthday!

View file

@ -0,0 +1,5 @@
---
title: Happy 1st Birthday Slash! (translated)
---
Happy birthday! (translated)

View file

@ -40,11 +40,11 @@ exports[`blogFeed atom shows feed item for each post 1`] = `
<summary type=\\"html\\"><![CDATA[date inside front matter]]></summary>
</entry>
<entry>
<title type=\\"html\\"><![CDATA[Happy 1st Birthday Slash!]]></title>
<id>Happy 1st Birthday Slash!</id>
<title type=\\"html\\"><![CDATA[Happy 1st Birthday Slash! (translated)]]></title>
<id>Happy 1st Birthday Slash! (translated)</id>
<link href=\\"https://docusaurus.io/blog/2018/12/14/Happy-First-Birthday-Slash\\"/>
<updated>2018-12-14T00:00:00.000Z</updated>
<summary type=\\"html\\"><![CDATA[pattern name]]></summary>
<summary type=\\"html\\"><![CDATA[Happy birthday! (translated)]]></summary>
</entry>
</feed>"
`;
@ -90,11 +90,11 @@ exports[`blogFeed rss shows feed item for each post 1`] = `
<description><![CDATA[date inside front matter]]></description>
</item>
<item>
<title><![CDATA[Happy 1st Birthday Slash!]]></title>
<title><![CDATA[Happy 1st Birthday Slash! (translated)]]></title>
<link>https://docusaurus.io/blog/2018/12/14/Happy-First-Birthday-Slash</link>
<guid>Happy 1st Birthday Slash!</guid>
<guid>Happy 1st Birthday Slash! (translated)</guid>
<pubDate>Fri, 14 Dec 2018 00:00:00 GMT</pubDate>
<description><![CDATA[pattern name]]></description>
<description><![CDATA[Happy birthday! (translated)]]></description>
</item>
</channel>
</rss>"

View file

@ -1,5 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`report broken markdown links 1`] = `
"---
title: This post links to another one!
---
[Good link 1](/blog/2018/12/14/Happy-First-Birthday-Slash)
[Good link 2](/blog/2018/12/14/Happy-First-Birthday-Slash)
[Bad link 1](postNotExist1.md)
[Bad link 1](./postNotExist2.mdx)
"
`;
exports[`transform to correct link 1`] = `
"---
title: This post links to another one!

View file

@ -8,12 +8,25 @@
import path from 'path';
import {generateBlogFeed} from '../blogUtils';
import {LoadContext} from '@docusaurus/types';
import {PluginOptions} from '../types';
import {PluginOptions, BlogContentPaths} from '../types';
function getBlogContentPaths(siteDir: string): BlogContentPaths {
return {
contentPath: path.resolve(siteDir, 'blog'),
contentPathLocalized: path.resolve(
siteDir,
'i18n',
'en',
'docusaurus-plugin-content-blog',
),
};
}
describe('blogFeed', () => {
['atom', 'rss'].forEach((feedType) => {
(['atom', 'rss'] as const).forEach((feedType) => {
describe(`${feedType}`, () => {
test('can show feed without posts', async () => {
const siteDir = __dirname;
const siteConfig = {
title: 'Hello',
baseUrl: '/',
@ -22,8 +35,9 @@ describe('blogFeed', () => {
};
const feed = await generateBlogFeed(
getBlogContentPaths(siteDir),
{
siteDir: __dirname,
siteDir,
siteConfig,
} as LoadContext,
{
@ -31,7 +45,7 @@ describe('blogFeed', () => {
routeBasePath: 'blog',
include: ['*.md', '*.mdx'],
feedOptions: {
type: feedType,
type: [feedType],
copyright: 'Copyright',
},
} as PluginOptions,
@ -52,6 +66,7 @@ describe('blogFeed', () => {
};
const feed = await generateBlogFeed(
getBlogContentPaths(siteDir),
{
siteDir,
siteConfig,
@ -62,7 +77,7 @@ describe('blogFeed', () => {
routeBasePath: 'blog',
include: ['*r*.md', '*.mdx'], // skip no-date.md - it won't play nice with snapshots
feedOptions: {
type: feedType,
type: [feedType],
copyright: 'Copyright',
},
} as PluginOptions,

View file

@ -8,9 +8,15 @@
import fs from 'fs-extra';
import path from 'path';
import pluginContentBlog from '../index';
import {DocusaurusConfig, LoadContext} from '@docusaurus/types';
import {DocusaurusConfig, LoadContext, I18n} from '@docusaurus/types';
import {PluginOptionSchema} from '../pluginOptionSchema';
const DefaultI18N: I18n = {
currentLocale: 'en',
locales: ['en'],
defaultLocale: 'en',
};
function validateAndNormalize(schema, options) {
const {value, error} = schema.validate(options);
if (error) {
@ -34,6 +40,7 @@ describe('loadBlog', () => {
siteDir,
siteConfig,
generatedFilesDir,
i18n: DefaultI18N,
} as LoadContext,
validateAndNormalize(PluginOptionSchema, {
path: pluginPath,
@ -66,26 +73,28 @@ describe('loadBlog', () => {
tags: [],
nextItem: {
permalink: '/blog/2018/12/14/Happy-First-Birthday-Slash',
title: 'Happy 1st Birthday Slash!',
title: 'Happy 1st Birthday Slash! (translated)',
},
truncated: false,
});
expect(
blogPosts.find((v) => v.metadata.title === 'Happy 1st Birthday Slash!')
.metadata,
blogPosts.find(
(v) => v.metadata.title === 'Happy 1st Birthday Slash! (translated)',
).metadata,
).toEqual({
editUrl:
'https://github.com/facebook/docusaurus/edit/master/website-1x/blog/2018-12-14-Happy-First-Birthday-Slash.md',
'https://github.com/facebook/docusaurus/edit/master/website-1x/i18n/en/docusaurus-plugin-content-blog/2018-12-14-Happy-First-Birthday-Slash.md',
permalink: '/blog/2018/12/14/Happy-First-Birthday-Slash',
readingTime: 0.01,
readingTime: 0.015,
source: path.join(
'@site',
pluginPath,
// pluginPath,
path.join('i18n', 'en', 'docusaurus-plugin-content-blog'),
'2018-12-14-Happy-First-Birthday-Slash.md',
),
title: 'Happy 1st Birthday Slash!',
description: `pattern name`,
title: 'Happy 1st Birthday Slash! (translated)',
description: `Happy birthday! (translated)`,
date: new Date('2018-12-14'),
tags: [],
prevItem: {

View file

@ -7,11 +7,14 @@
import fs from 'fs-extra';
import path from 'path';
import {linkify} from '../blogUtils';
import {BlogPost} from '../types';
import {linkify, LinkifyParams} from '../blogUtils';
import {BlogBrokenMarkdownLink, BlogContentPaths, BlogPost} from '../types';
const sitePath = path.join(__dirname, '__fixtures__', 'website');
const blogPath = path.join(sitePath, 'blog-with-ref');
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const contentPaths: BlogContentPaths = {
contentPath: path.join(siteDir, 'blog-with-ref'),
contentPathLocalized: path.join(siteDir, 'blog-with-ref-localized'),
};
const pluginDir = 'blog-with-ref';
const blogPosts: BlogPost[] = [
{
@ -36,14 +39,26 @@ const blogPosts: BlogPost[] = [
},
];
const transform = (filepath: string) => {
const content = fs.readFileSync(filepath, 'utf-8');
const transformedContent = linkify(content, sitePath, blogPath, blogPosts);
return [content, transformedContent];
const transform = (filePath: string, options?: Partial<LinkifyParams>) => {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const transformedContent = linkify({
filePath,
fileContent,
siteDir,
contentPaths,
blogPosts,
onBrokenMarkdownLink: (brokenMarkdownLink) => {
throw new Error(
`Broken markdown link found: ${JSON.stringify(brokenMarkdownLink)}`,
);
},
...options,
});
return [fileContent, transformedContent];
};
test('transform to correct link', () => {
const post = path.join(blogPath, 'post.md');
const post = path.join(contentPaths.contentPath, 'post.md');
const [content, transformedContent] = transform(post);
expect(transformedContent).toMatchSnapshot();
expect(transformedContent).toContain(
@ -54,3 +69,25 @@ test('transform to correct link', () => {
);
expect(content).not.toEqual(transformedContent);
});
test('report broken markdown links', () => {
const filePath = 'post-with-broken-links.md';
const folderPath = contentPaths.contentPath;
const postWithBrokenLinks = path.join(folderPath, filePath);
const onBrokenMarkdownLink = jest.fn();
const [, transformedContent] = transform(postWithBrokenLinks, {
onBrokenMarkdownLink,
});
expect(transformedContent).toMatchSnapshot();
expect(onBrokenMarkdownLink).toHaveBeenCalledTimes(2);
expect(onBrokenMarkdownLink).toHaveBeenNthCalledWith(1, {
filePath: path.resolve(folderPath, filePath),
folderPath,
link: 'postNotExist1.md',
} as BlogBrokenMarkdownLink);
expect(onBrokenMarkdownLink).toHaveBeenNthCalledWith(2, {
filePath: path.resolve(folderPath, filePath),
folderPath,
link: './postNotExist2.mdx',
} as BlogBrokenMarkdownLink);
});

View file

@ -11,14 +11,23 @@ import chalk from 'chalk';
import path from 'path';
import readingTime from 'reading-time';
import {Feed} from 'feed';
import {PluginOptions, BlogPost, DateLink} from './types';
import {
PluginOptions,
BlogPost,
DateLink,
BlogContentPaths,
BlogBrokenMarkdownLink,
BlogMarkdownLoaderOptions,
} from './types';
import {
parseMarkdownFile,
normalizeUrl,
aliasedSitePath,
getEditUrl,
getFolderContainingFile,
} from '@docusaurus/utils';
import {LoadContext} from '@docusaurus/types';
import {keyBy} from 'lodash';
export function truncate(fileString: string, truncateMarker: RegExp): string {
return fileString.split(truncateMarker, 1).shift()!;
@ -36,6 +45,7 @@ function toUrl({date, link}: DateLink) {
}
export async function generateBlogFeed(
contentPaths: BlogContentPaths,
context: LoadContext,
options: PluginOptions,
): Promise<Feed | null> {
@ -44,9 +54,8 @@ export async function generateBlogFeed(
'Invalid options - `feedOptions` is not expected to be null.',
);
}
const {siteDir, siteConfig} = context;
const contentPath = path.resolve(siteDir, options.path);
const blogPosts = await generateBlogPosts(contentPath, context, options);
const {siteConfig} = context;
const blogPosts = await generateBlogPosts(contentPaths, context, options);
if (blogPosts == null) {
return null;
}
@ -88,7 +97,7 @@ export async function generateBlogFeed(
}
export async function generateBlogPosts(
blogDir: string,
contentPaths: BlogContentPaths,
{siteConfig, siteDir}: LoadContext,
options: PluginOptions,
): Promise<BlogPost[]> {
@ -100,24 +109,30 @@ export async function generateBlogPosts(
editUrl,
} = options;
if (!fs.existsSync(blogDir)) {
if (!fs.existsSync(contentPaths.contentPath)) {
return [];
}
const {baseUrl = ''} = siteConfig;
const blogFiles = await globby(include, {
cwd: blogDir,
const blogSourceFiles = await globby(include, {
cwd: contentPaths.contentPath,
});
const blogPosts: BlogPost[] = [];
await Promise.all(
blogFiles.map(async (relativeSource: string) => {
const source = path.join(blogDir, relativeSource);
blogSourceFiles.map(async (blogSourceFile: string) => {
// Lookup in localized folder in priority
const contentPath = await getFolderContainingFile(
getContentPathList(contentPaths),
blogSourceFile,
);
const source = path.join(contentPath, blogSourceFile);
const aliasedSource = aliasedSitePath(source, siteDir);
const refDir = path.parse(blogDir).dir;
const relativePath = path.relative(refDir, source);
const blogFileName = path.basename(relativeSource);
const relativePath = path.relative(siteDir, source);
const blogFileName = path.basename(blogSourceFile);
const editBlogUrl = getEditUrl(relativePath, editUrl);
@ -184,12 +199,31 @@ export async function generateBlogPosts(
return blogPosts;
}
export function linkify(
fileContent: string,
siteDir: string,
blogPath: string,
blogPosts: BlogPost[],
): string {
export type LinkifyParams = {
filePath: string;
fileContent: string;
} & Pick<
BlogMarkdownLoaderOptions,
'blogPosts' | 'siteDir' | 'contentPaths' | 'onBrokenMarkdownLink'
>;
export function linkify({
filePath,
contentPaths,
fileContent,
siteDir,
blogPosts,
onBrokenMarkdownLink,
}: LinkifyParams): string {
// TODO temporary, should consider the file being in localized folder!
const folderPath = contentPaths.contentPath;
// TODO perf refactor: do this earlier (once for all md files, not per file)
const blogPostsBySource: Record<string, BlogPost> = keyBy(
blogPosts,
(item) => item.metadata.source,
);
let fencedBlock = false;
const lines = fileContent.split('\n').map((line) => {
if (line.trim().startsWith('```')) {
@ -208,18 +242,24 @@ export function linkify(
const mdLink = mdMatch[1];
const aliasedPostSource = `@site/${path.relative(
siteDir,
path.resolve(blogPath, mdLink),
path.resolve(folderPath, mdLink),
)}`;
let blogPostPermalink = null;
blogPosts.forEach((blogPost) => {
if (blogPost.metadata.source === aliasedPostSource) {
blogPostPermalink = blogPost.metadata.permalink;
}
});
const blogPost: BlogPost | undefined =
blogPostsBySource[aliasedPostSource];
if (blogPostPermalink) {
modifiedLine = modifiedLine.replace(mdLink, blogPostPermalink);
if (blogPost) {
modifiedLine = modifiedLine.replace(
mdLink,
blogPost.metadata.permalink,
);
} else {
const brokenMarkdownLink: BlogBrokenMarkdownLink = {
folderPath,
filePath,
link: mdLink,
};
onBrokenMarkdownLink(brokenMarkdownLink);
}
mdMatch = mdRegex.exec(modifiedLine);
@ -230,3 +270,8 @@ export function linkify(
return lines.join('\n');
}
// Order matters: we look in priority in localized folder
export function getContentPathList(contentPaths: BlogContentPaths) {
return [contentPaths.contentPathLocalized, contentPaths.contentPath];
}

View file

@ -8,13 +8,19 @@
import fs from 'fs-extra';
import path from 'path';
import admonitions from 'remark-admonitions';
import {normalizeUrl, docuHash, aliasedSitePath} from '@docusaurus/utils';
import {
normalizeUrl,
docuHash,
aliasedSitePath,
getPluginI18nPath,
reportMessage,
} from '@docusaurus/utils';
import {
STATIC_DIR_NAME,
DEFAULT_PLUGIN_ID,
} from '@docusaurus/core/lib/constants';
import {ValidationError} from 'joi';
import {take, kebabCase} from 'lodash';
import {flatten, take, kebabCase} from 'lodash';
import {
PluginOptions,
@ -24,6 +30,8 @@ import {
TagsModule,
BlogPaginated,
BlogPost,
BlogContentPaths,
BlogMarkdownLoaderOptions,
} from './types';
import {PluginOptionSchema} from './pluginOptionSchema';
import {
@ -36,7 +44,11 @@ import {
ValidationResult,
} from '@docusaurus/types';
import {Configuration, Loader} from 'webpack';
import {generateBlogFeed, generateBlogPosts} from './blogUtils';
import {
generateBlogFeed,
generateBlogPosts,
getContentPathList,
} from './blogUtils';
export default function pluginContentBlog(
context: LoadContext,
@ -48,8 +60,22 @@ export default function pluginContentBlog(
]);
}
const {siteDir, generatedFilesDir} = context;
const contentPath = path.resolve(siteDir, options.path);
const {
siteDir,
siteConfig: {onBrokenMarkdownLinks},
generatedFilesDir,
i18n: {currentLocale},
} = context;
const contentPaths: BlogContentPaths = {
contentPath: path.resolve(siteDir, options.path),
contentPathLocalized: getPluginI18nPath({
siteDir,
locale: currentLocale,
pluginName: 'docusaurus-plugin-content-blog',
pluginId: options.id,
}),
};
const pluginId = options.id ?? DEFAULT_PLUGIN_ID;
const pluginDataDirRoot = path.join(
@ -67,8 +93,11 @@ export default function pluginContentBlog(
getPathsToWatch() {
const {include = []} = options;
const globPattern = include.map((pattern) => `${contentPath}/${pattern}`);
return [...globPattern];
return flatten(
getContentPathList(contentPaths).map((contentPath) => {
return include.map((pattern) => `${contentPath}/${pattern}`);
}),
);
},
getClientModules() {
@ -85,7 +114,7 @@ export default function pluginContentBlog(
async loadContent() {
const {postsPerPage, routeBasePath} = options;
blogPosts = await generateBlogPosts(contentPath, context, options);
blogPosts = await generateBlogPosts(contentPaths, context, options);
if (!blogPosts.length) {
return null;
}
@ -379,6 +408,23 @@ export default function pluginContentBlog(
beforeDefaultRemarkPlugins,
beforeDefaultRehypePlugins,
} = options;
const markdownLoaderOptions: BlogMarkdownLoaderOptions = {
siteDir,
contentPaths,
truncateMarker,
blogPosts,
onBrokenMarkdownLink: (brokenMarkdownLink) => {
if (onBrokenMarkdownLinks === 'ignore') {
return;
}
reportMessage(
`Blog markdown link couldn't be resolved: (${brokenMarkdownLink.link}) in ${brokenMarkdownLink.filePath}`,
onBrokenMarkdownLinks,
);
},
};
return {
resolve: {
alias: {
@ -389,7 +435,7 @@ export default function pluginContentBlog(
rules: [
{
test: /(\.mdx?)$/,
include: [contentPath],
include: getContentPathList(contentPaths),
use: [
getCacheLoader(isServer),
getBabelLoader(isServer),
@ -414,12 +460,7 @@ export default function pluginContentBlog(
},
{
loader: path.resolve(__dirname, './markdownLoader.js'),
options: {
siteDir,
contentPath,
truncateMarker,
blogPosts,
},
options: markdownLoaderOptions,
},
].filter(Boolean) as Loader[],
},
@ -433,7 +474,7 @@ export default function pluginContentBlog(
return;
}
const feed = await generateBlogFeed(context, options);
const feed = await generateBlogFeed(contentPaths, context, options);
if (!feed) {
return;

View file

@ -8,19 +8,20 @@
import {loader} from 'webpack';
import {truncate, linkify} from './blogUtils';
import {parseQuery, getOptions} from 'loader-utils';
import {BlogMarkdownLoaderOptions} from './types';
const markdownLoader: loader.Loader = function (source) {
const fileString = source as string;
const filePath = this.resourcePath;
const fileContent = source as string;
const callback = this.async();
const {truncateMarker, siteDir, contentPath, blogPosts} = getOptions(this);
const markdownLoaderOptions = getOptions(this) as BlogMarkdownLoaderOptions;
// Linkify posts
let finalContent = linkify(
fileString as string,
siteDir,
contentPath,
blogPosts,
);
// Linkify blog posts
let finalContent = linkify({
fileContent,
filePath,
...markdownLoaderOptions,
});
// Truncate content if requested (e.g: file.md?truncated=true).
const truncated: string | undefined = this.resourceQuery
@ -28,7 +29,7 @@ const markdownLoader: loader.Loader = function (source) {
: undefined;
if (truncated) {
finalContent = truncate(finalContent, truncateMarker);
finalContent = truncate(finalContent, markdownLoaderOptions.truncateMarker);
}
return callback && callback(null, finalContent);

View file

@ -5,6 +5,11 @@
* LICENSE file in the root directory of this source tree.
*/
export type BlogContentPaths = {
contentPath: string;
contentPathLocalized: string;
};
export interface BlogContent {
blogPosts: BlogPost[];
blogListPaginated: BlogPaginated[];
@ -121,3 +126,16 @@ export interface TagModule {
count: number;
permalink: string;
}
export type BlogBrokenMarkdownLink = {
folderPath: string;
filePath: string;
link: string;
};
export type BlogMarkdownLoaderOptions = {
siteDir: string;
contentPaths: BlogContentPaths;
truncateMarker: RegExp;
blogPosts: BlogPost[];
onBrokenMarkdownLink: (brokenMarkdownLink: BlogBrokenMarkdownLink) => void;
};