diff --git a/packages/docusaurus-mdx-loader/src/loader.ts b/packages/docusaurus-mdx-loader/src/loader.ts index cb71687b0053..797d17d974a6 100644 --- a/packages/docusaurus-mdx-loader/src/loader.ts +++ b/packages/docusaurus-mdx-loader/src/loader.ts @@ -121,6 +121,15 @@ function ensureMarkdownConfig(reqOptions: Options) { } } +/** + * data.contentTitle is set by the remark contentTitle plugin + */ +function extractContentTitleData(data: { + [key: string]: unknown; +}): string | undefined { + return data.contentTitle as string | undefined; +} + export async function mdxLoader( this: LoaderContext, fileString: string, @@ -150,7 +159,7 @@ export async function mdxLoader( mdxFrontMatter, }); - let result: {content: string; data: { [key: string]: unknown }}; + let result: {content: string; data: {[key: string]: unknown}}; try { result = await processor.process({content: preprocessedContent, filePath}); } catch (errorUnknown) { @@ -176,7 +185,7 @@ export async function mdxLoader( ); } - const contentTitle = 'xyz'; // TODO ! + const contentTitle = extractContentTitleData(result.data); // MDX partials are MDX files starting with _ or in a folder starting with _ // Partial are not expected to have associated metadata files or front matter @@ -238,7 +247,7 @@ ${assets ? `export const assets = ${createAssetsExportCode(assets)};` : ''} const code = ` ${exportsCode} -${result} +${result.content} `; return callback(null, code); diff --git a/packages/docusaurus-mdx-loader/src/processor.ts b/packages/docusaurus-mdx-loader/src/processor.ts index a10f197b22d0..5b22ef235a01 100644 --- a/packages/docusaurus-mdx-loader/src/processor.ts +++ b/packages/docusaurus-mdx-loader/src/processor.ts @@ -7,6 +7,7 @@ import emoji from 'remark-emoji'; import headings from './remark/headings'; +import contentTitle from './remark/contentTitle'; import toc from './remark/toc'; import transformImage from './remark/transformImage'; import transformLinks from './remark/transformLinks'; @@ -28,6 +29,8 @@ import type {ProcessorOptions} from '@mdx-js/mdx'; // See https://github.com/microsoft/TypeScript/issues/49721#issuecomment-1517839391 type Pluggable = any; // TODO fix this asap +type SimpleProcessorResult = {content: string; data: {[key: string]: unknown}}; + // TODO alt interface because impossible to import type Processor (ESM + TS :/) type SimpleProcessor = { process: ({ @@ -36,7 +39,7 @@ type SimpleProcessor = { }: { content: string; filePath: string; - }) => Promise<{content: string; data: { [key: string]: unknown }}>; + }) => Promise; }; const DEFAULT_OPTIONS: MDXOptions = { @@ -94,6 +97,7 @@ async function createProcessorFactory() { ...(options.beforeDefaultRemarkPlugins ?? []), frontmatter, directive, + [contentTitle, {removeContentTitle: options.removeContentTitle}], ...getAdmonitionsPlugins(options.admonitions ?? false), ...DEFAULT_OPTIONS.remarkPlugins, details, @@ -167,9 +171,9 @@ async function createProcessorFactory() { path: filePath, }) .then((vfile) => ({ - content: vfile.toString(), - data: vfile.data, - })), + content: vfile.toString(), + data: vfile.data, + })), }; } diff --git a/packages/docusaurus-mdx-loader/src/remark/contentTitle/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/contentTitle/__tests__/index.test.ts new file mode 100644 index 000000000000..439b9d46634e --- /dev/null +++ b/packages/docusaurus-mdx-loader/src/remark/contentTitle/__tests__/index.test.ts @@ -0,0 +1,151 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import plugin from '../index'; + +async function process( + content: string, + options: {removeContentTitle?: boolean} = {}, +) { + const {remark} = await import('remark'); + const processor = await remark().use({plugins: [[plugin, options]]}); + return processor.process(content); +} + +describe('contentTitle remark plugin', () => { + describe('extracts data.contentTitle', () => { + it('extracts h1 heading', async () => { + const result = await process(` +# contentTitle 1 + +## Heading Two {#custom-heading-two} + +# contentTitle 2 + +some **markdown** *content* + `); + + expect(result.data.contentTitle).toBe('contentTitle 1'); + }); + + it('extracts h1 heading alt syntax', async () => { + const result = await process(` +contentTitle alt +=== + +# contentTitle 1 + +## Heading Two {#custom-heading-two} + +# contentTitle 2 + +some **markdown** *content* + `); + + expect(result.data.contentTitle).toBe('contentTitle alt'); + }); + + it('works with no contentTitle', async () => { + const result = await process(` +## Heading Two {#custom-heading-two} + +some **markdown** *content* + `); + + expect(result.data.contentTitle).toBeUndefined(); + }); + + it('ignore contentTitle if not in first position', async () => { + const result = await process(` +## Heading Two {#custom-heading-two} + +# contentTitle 1 + +some **markdown** *content* + `); + + expect(result.data.contentTitle).toBeUndefined(); + }); + + it('is able to decently serialize Markdown syntax', async () => { + const result = await process(` +# some **markdown** \`content\` _italic_ + +some **markdown** *content* + `); + + expect(result.data.contentTitle).toBe('some markdown content italic'); + }); + }); + + describe('returns appropriate content', () => { + it('returns content unmodified', async () => { + const content = ` +# contentTitle 1 + +## Heading Two {#custom-heading-two} + +# contentTitle 2 + +some **markdown** *content* +`.trim(); + + const result = await process(content); + + expect(result.toString().trim()).toEqual(content); + }); + + it('can strip contentTitle', async () => { + const content = ` +# contentTitle 1 + +## Heading Two {#custom-heading-two} + +# contentTitle 2 + +some **markdown** *content* +`.trim(); + + const result = await process(content, {removeContentTitle: true}); + + expect(result.toString().trim()).toEqual( + ` +## Heading Two {#custom-heading-two} + +# contentTitle 2 + +some **markdown** *content* +`.trim(), + ); + }); + + it('can strip contentTitle alt', async () => { + const content = ` +contentTitle alt +=== + +## Heading Two {#custom-heading-two} + +# contentTitle 2 + +some **markdown** *content* +`.trim(); + + const result = await process(content, {removeContentTitle: true}); + + expect(result.toString().trim()).toEqual( + ` +## Heading Two {#custom-heading-two} + +# contentTitle 2 + +some **markdown** *content* +`.trim(), + ); + }); + }); +}); diff --git a/packages/docusaurus-mdx-loader/src/remark/contentTitle/index.ts b/packages/docusaurus-mdx-loader/src/remark/contentTitle/index.ts new file mode 100644 index 000000000000..e2e9977dad92 --- /dev/null +++ b/packages/docusaurus-mdx-loader/src/remark/contentTitle/index.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import visit, {EXIT} from 'unist-util-visit'; +// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721 +import type {Transformer} from 'unified'; +import type {Heading} from 'mdast'; + +// TODO as of April 2023, no way to import/re-export this ESM type easily :/ +// TODO upgrade to TS 5.3 +// See https://github.com/microsoft/TypeScript/issues/49721#issuecomment-1517839391 +// import type {Plugin} from 'unified'; +type Plugin = any; // TODO fix this asap + +interface PluginOptions { + removeContentTitle?: boolean; +} + +/** + * A remark plugin to extract the h1 heading found in Markdown files + * This is exposed as "data.contentTitle" to the processed vfile + * Also gives the ability to strip that content title (used for the blog plugin) + */ +const plugin: Plugin = function plugin( + options: PluginOptions = {}, +): Transformer { + // content title is + const removeContentTitle = options.removeContentTitle ?? false; + + return async (root, vfile) => { + const {toString} = await import('mdast-util-to-string'); + visit(root, 'heading', (headingNode: Heading, index, parent) => { + if (headingNode.depth === 1) { + vfile.data.contentTitle = toString(headingNode); + if (removeContentTitle) { + parent!.children.splice(index, 1); + } + return EXIT; // We only handle the very first heading + } + // We only handle contentTitle if it's the very first heading found + if (headingNode.depth >= 1) { + return EXIT; + } + return undefined; + }); + }; +}; + +export default plugin; diff --git a/packages/docusaurus-mdx-loader/src/remark/toc/index.ts b/packages/docusaurus-mdx-loader/src/remark/toc/index.ts index fea1585db935..707d689435d3 100644 --- a/packages/docusaurus-mdx-loader/src/remark/toc/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/toc/index.ts @@ -17,7 +17,7 @@ import type {Heading, Literal} from 'mdast'; import type {Transformer} from 'unified'; // TODO as of April 2023, no way to import/re-export this ESM type easily :/ -// This might change soon, likely after TS 5.2 +// TODO upgrade to TS 5.3 // See https://github.com/microsoft/TypeScript/issues/49721#issuecomment-1517839391 // import type {Plugin} from 'unified'; type Plugin = any; // TODO fix this asap