feat: upgrade to MDX v2 (#8288)

Co-authored-by: Armano <armano2@users.noreply.github.com>
This commit is contained in:
Sébastien Lorber 2023-04-21 19:48:57 +02:00 committed by GitHub
parent 10f161d578
commit bf913aea2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
161 changed files with 4028 additions and 2821 deletions

View file

@ -0,0 +1,93 @@
/**
* 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 escapeStringRegexp from 'escape-string-regexp';
import {
validateMDXFrontMatter,
DefaultMDXFrontMatter,
type MDXFrontMatter,
} from '../frontMatter';
function testField(params: {
prefix: string;
validFrontMatters: MDXFrontMatter[];
convertibleFrontMatter?: [
ConvertibleFrontMatter: {[key: string]: unknown},
ConvertedFrontMatter: MDXFrontMatter,
][];
invalidFrontMatters?: [
InvalidFrontMatter: {[key: string]: unknown},
ErrorMessage: string,
][];
}) {
// eslint-disable-next-line jest/require-top-level-describe
test(`[${params.prefix}] accept valid values`, () => {
params.validFrontMatters.forEach((frontMatter) => {
expect(validateMDXFrontMatter(frontMatter)).toEqual(frontMatter);
});
});
// eslint-disable-next-line jest/require-top-level-describe
test(`[${params.prefix}] convert valid values`, () => {
params.convertibleFrontMatter?.forEach(
([convertibleFrontMatter, convertedFrontMatter]) => {
expect(validateMDXFrontMatter(convertibleFrontMatter)).toEqual(
convertedFrontMatter,
);
},
);
});
// eslint-disable-next-line jest/require-top-level-describe
test(`[${params.prefix}] throw error for values`, () => {
params.invalidFrontMatters?.forEach(([frontMatter, message]) => {
try {
validateMDXFrontMatter(frontMatter);
throw new Error(
`MDX front matter is expected to be rejected, but was accepted successfully:\n ${JSON.stringify(
frontMatter,
null,
2,
)}`,
);
} catch (err) {
// eslint-disable-next-line jest/no-conditional-expect
expect((err as Error).message).toMatch(
new RegExp(escapeStringRegexp(message)),
);
}
});
});
}
describe('MDX front matter schema', () => {
it('accepts empty object', () => {
const frontMatter: Partial<MDXFrontMatter> = {};
expect(validateMDXFrontMatter(frontMatter)).toEqual(DefaultMDXFrontMatter);
});
it('accepts undefined object', () => {
expect(validateMDXFrontMatter(undefined)).toEqual(DefaultMDXFrontMatter);
});
it('rejects unknown field', () => {
const frontMatter = {abc: '1'};
expect(() =>
validateMDXFrontMatter(frontMatter),
).toThrowErrorMatchingInlineSnapshot(`""abc" is not allowed"`);
});
});
describe('validateDocFrontMatter format', () => {
testField({
prefix: 'format',
validFrontMatters: [{format: 'md'}, {format: 'mdx'}],
invalidFrontMatters: [
[{format: 'xdm'}, '"format" must be one of [md, mdx, detect]'],
],
});
});

View file

@ -1,31 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
// TODO Types provided by MDX 2.0 https://github.com/mdx-js/mdx/blob/main/packages/mdx/types/index.d.ts
declare module '@mdx-js/mdx' {
import type {Processor, Plugin} from 'unified';
type MDXPlugin =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[Plugin<any[]>, any] | Plugin<any[]>;
type Options = {
filepath?: string;
skipExport?: boolean;
wrapExport?: string;
remarkPlugins?: MDXPlugin[];
rehypePlugins?: MDXPlugin[];
};
export function sync(content: string, options?: Options): string;
export function createMdxAstCompiler(options?: Options): Processor;
export function createCompiler(options?: Options): Processor;
export default function mdx(
content: string,
options?: Options,
): Promise<string>;
}

View file

@ -0,0 +1,31 @@
/**
* 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 {
JoiFrontMatter as Joi,
validateFrontMatter,
} from '@docusaurus/utils-validation';
export type MDXFrontMatter = {
format: 'md' | 'mdx' | 'detect';
};
export const DefaultMDXFrontMatter: MDXFrontMatter = {
format: 'detect',
};
const MDXFrontMatterSchema = Joi.object<MDXFrontMatter>({
format: Joi.string()
.equal('md', 'mdx', 'detect')
.default(DefaultMDXFrontMatter.format),
}).default(DefaultMDXFrontMatter);
export function validateMDXFrontMatter(frontMatter: unknown): MDXFrontMatter {
return validateFrontMatter(frontMatter, MDXFrontMatterSchema, {
allowUnknown: false,
});
}

View file

@ -7,13 +7,11 @@
import {mdxLoader} from './loader';
import type {TOCItem as TOCItemImported} from './remark/toc';
export default mdxLoader;
export type TOCItem = {
readonly value: string;
readonly id: string;
readonly level: number;
};
export type TOCItem = TOCItemImported;
export type LoadedMDXContent<FrontMatter, Metadata, Assets = undefined> = {
/** As verbatim declared in the MDX document. */

View file

@ -6,6 +6,7 @@
*/
import fs from 'fs-extra';
import path from 'path';
import logger from '@docusaurus/logger';
import {
parseFrontMatter,
@ -13,46 +14,73 @@ import {
escapePath,
getFileLoaderUtils,
} from '@docusaurus/utils';
import {createCompiler} from '@mdx-js/mdx';
import emoji from 'remark-emoji';
import stringifyObject from 'stringify-object';
import preprocessor from './preprocessor';
import headings from './remark/headings';
import toc from './remark/toc';
import unwrapMdxCodeBlocks from './remark/unwrapMdxCodeBlocks';
import transformImage from './remark/transformImage';
import transformLinks from './remark/transformLinks';
import details from './remark/details';
import head from './remark/head';
import mermaid from './remark/mermaid';
import transformAdmonitions from './remark/admonitions';
import codeCompatPlugin from './remark/mdx1Compat/codeCompatPlugin';
import {validateMDXFrontMatter} from './frontMatter';
import type {MarkdownConfig} from '@docusaurus/types';
import type {LoaderContext} from 'webpack';
import type {Processor, Plugin} from 'unified';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Processor} from 'unified';
import type {AdmonitionOptions} from './remark/admonitions';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {ProcessorOptions} from '@mdx-js/mdx';
// TODO as of April 2023, no way to import/re-export this ESM type easily :/
// This might change soon, likely after TS 5.2
// See https://github.com/microsoft/TypeScript/issues/49721#issuecomment-1517839391
type Pluggable = any; // TODO fix this asap
// Copied from https://mdxjs.com/packages/mdx/#optionsmdextensions
// Although we are likely to only use .md / .mdx anyway...
const mdFormatExtensions = [
'.md',
'.markdown',
'.mdown',
'.mkdn',
'.mkd',
'.mdwn',
'.mkdown',
'.ron',
];
function isMDFormat(filepath: string) {
return mdFormatExtensions.includes(path.extname(filepath));
}
const {
loaders: {inlineMarkdownImageFileLoader},
} = getFileLoaderUtils();
const pragma = `
/* @jsxRuntime classic */
/* @jsx mdx */
/* @jsxFrag React.Fragment */
`;
const DEFAULT_OPTIONS: MDXOptions = {
admonitions: true,
rehypePlugins: [],
remarkPlugins: [unwrapMdxCodeBlocks, emoji, headings, toc],
remarkPlugins: [emoji, headings, toc],
beforeDefaultRemarkPlugins: [],
beforeDefaultRehypePlugins: [],
};
const compilerCache = new Map<string | Options, [Processor, Options]>();
type CompilerCacheEntry = {
mdCompiler: Processor;
mdxCompiler: Processor;
options: Options;
};
export type MDXPlugin =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[Plugin<any[]>, any] | Plugin<any[]>;
const compilerCache = new Map<string | Options, CompilerCacheEntry>();
export type MDXPlugin = Pluggable;
export type MDXOptions = {
admonitions: boolean | Partial<AdmonitionOptions>;
@ -149,9 +177,21 @@ function getAdmonitionsPlugins(
: [transformAdmonitions, admonitionsOption];
return [plugin];
}
return [];
}
// TODO temporary, remove this after v3.1?
// Some plugin authors use our mdx-loader, despite it not being public API
// see https://github.com/facebook/docusaurus/issues/8298
function ensureMarkdownConfig(reqOptions: Options) {
if (!reqOptions.markdownConfig) {
throw new Error(
'Docusaurus v3+ requires MDX loader options.markdownConfig - plugin authors using the MDX loader should make sure to provide that option',
);
}
}
export async function mdxLoader(
this: LoaderContext<Options>,
fileString: string,
@ -159,21 +199,47 @@ export async function mdxLoader(
const callback = this.async();
const filePath = this.resourcePath;
const reqOptions = this.getOptions();
ensureMarkdownConfig(reqOptions);
const {createProcessor} = await import('@mdx-js/mdx');
const {default: gfm} = await import('remark-gfm');
const {default: comment} = await import('remark-comment');
const {default: directives} = await import('remark-directive');
const {frontMatter, content: contentWithTitle} = parseFrontMatter(fileString);
const mdxFrontMatter = validateMDXFrontMatter(frontMatter.mdx);
const {content, contentTitle} = parseMarkdownContentTitle(contentWithTitle, {
removeContentTitle: reqOptions.removeContentTitle,
const {content: contentUnprocessed, contentTitle} = parseMarkdownContentTitle(
contentWithTitle,
{
removeContentTitle: reqOptions.removeContentTitle,
},
);
const content = preprocessor({
fileContent: contentUnprocessed,
filePath,
admonitions: reqOptions.admonitions,
markdownConfig: reqOptions.markdownConfig,
});
const hasFrontMatter = Object.keys(frontMatter).length > 0;
if (!compilerCache.has(this.query)) {
/*
/!\ DO NOT PUT ANY ASYNC / AWAIT / DYNAMIC IMPORTS HERE
This creates cache creation race conditions
TODO extract this in a synchronous method
*/
const remarkPlugins: MDXPlugin[] = [
...(reqOptions.beforeDefaultRemarkPlugins ?? []),
directives,
...getAdmonitionsPlugins(reqOptions.admonitions ?? false),
...DEFAULT_OPTIONS.remarkPlugins,
...(reqOptions.markdownConfig?.mermaid ? [mermaid] : []),
details,
head,
...(reqOptions.markdownConfig.mermaid ? [mermaid] : []),
[
transformImage,
{
@ -188,8 +254,14 @@ export async function mdxLoader(
siteDir: reqOptions.siteDir,
},
],
gfm,
reqOptions.markdownConfig.mdx1Compat.comments ? comment : null,
...(reqOptions.remarkPlugins ?? []),
];
].filter((plugin): plugin is MDXPlugin => Boolean(plugin));
// codeCompatPlugin needs to be applied last after user-provided plugins
// (after npm2yarn for example)
remarkPlugins.push(codeCompatPlugin);
const rehypePlugins: MDXPlugin[] = [
...(reqOptions.beforeDefaultRehypePlugins ?? []),
@ -197,26 +269,60 @@ export async function mdxLoader(
...(reqOptions.rehypePlugins ?? []),
];
const options: Options = {
const options: ProcessorOptions & Options = {
...reqOptions,
remarkPlugins,
rehypePlugins,
providerImportSource: '@mdx-js/react',
};
compilerCache.set(this.query, [createCompiler(options), options]);
const compilerCacheEntry: CompilerCacheEntry = {
mdCompiler: createProcessor({
...options,
format: 'md',
}),
mdxCompiler: createProcessor({
...options,
format: 'mdx',
}),
options,
};
compilerCache.set(this.query, compilerCacheEntry);
}
const [compiler, options] = compilerCache.get(this.query)!;
const {mdCompiler, mdxCompiler, options} = compilerCache.get(this.query)!;
function getCompiler() {
const format =
mdxFrontMatter.format === 'detect'
? isMDFormat(filePath)
? 'md'
: 'mdx'
: mdxFrontMatter.format;
return format === 'md' ? mdCompiler : mdxCompiler;
}
let result: string;
try {
result = await compiler
result = await getCompiler()
.process({
contents: content,
path: this.resourcePath,
value: content,
path: filePath,
})
.then((res) => res.toString());
} catch (err) {
return callback(err as Error);
} catch (errorUnknown) {
const error = errorUnknown as Error;
return callback(
new Error(
`MDX compilation failed for file ${logger.path(filePath)}\nCause: ${
error.message
}\nDetails:\n${JSON.stringify(error, null, 2)}`,
// TODO error cause doesn't seem to be used by Webpack stats.errors :s
{cause: error},
),
);
}
// MDX partials are MDX files starting with _ or in a folder starting with _
@ -265,6 +371,8 @@ ${JSON.stringify(frontMatter, null, 2)}`;
? reqOptions.createAssets({frontMatter, metadata})
: undefined;
// TODO use remark plugins to insert extra exports instead of string concat?
// cf how the toc is exported
const exportsCode = `
export const frontMatter = ${stringifyObject(frontMatter)};
export const contentTitle = ${stringifyObject(contentTitle)};
@ -273,10 +381,6 @@ ${assets ? `export const assets = ${createAssetsExportCode(assets)};` : ''}
`;
const code = `
${pragma}
import React from 'react';
import { mdx } from '@mdx-js/react';
${exportsCode}
${result}
`;

View file

@ -0,0 +1,47 @@
/**
* 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 {
escapeMarkdownHeadingIds,
unwrapMdxCodeBlocks,
admonitionTitleToDirectiveLabel,
} from '@docusaurus/utils';
import {normalizeAdmonitionOptions} from './remark/admonitions';
import type {Options} from './loader';
/**
* Preprocess the string before passing it to MDX
* This is not particularly recommended but makes it easier to upgrade to MDX 2
*/
export default function preprocessContent({
fileContent: initialFileContent,
filePath,
markdownConfig,
admonitions,
}: {
fileContent: string;
filePath: string;
markdownConfig: Options['markdownConfig'];
admonitions: Options['admonitions'] | undefined;
}): string {
let fileContent = initialFileContent;
if (markdownConfig.preprocessor) {
fileContent = markdownConfig.preprocessor({
fileContent,
filePath,
});
}
fileContent = unwrapMdxCodeBlocks(fileContent);
if (markdownConfig.mdx1Compat.headingIds) {
fileContent = escapeMarkdownHeadingIds(fileContent);
}
if (markdownConfig.mdx1Compat.admonitions && admonitions) {
const {keywords} = normalizeAdmonitionOptions(admonitions);
fileContent = admonitionTitleToDirectiveLabel(fileContent, keywords);
}
return fileContent;
}

View file

@ -22,25 +22,9 @@ exports[`admonitions remark plugin base 1`] = `
<p>++++</p>"
`;
exports[`admonitions remark plugin custom tag 1`] = `
"<p>The blog feature enables you to deploy in no time a full-featured blog.</p>
<p>:::info Sample Title</p>
<p>Check the <a href="./api/plugins/plugin-content-blog.md">Blog Plugin API Reference documentation</a> for an exhaustive list of options.</p>
<p>:::</p>
<h2>Initial setup {#initial-setup}</h2>
<p>To set up your site's blog, start by creating a <code>blog</code> directory.</p>
<p>:::tip</p>
<p>Use the <strong><a href="introduction.md#fast-track">Fast Track</a></strong> to understand Docusaurus in <strong>5 minutes ⏱</strong>!</p>
<p>Use <strong><a href="https://docusaurus.new">docusaurus.new</a></strong> to test Docusaurus immediately in your browser!</p>
<p>:::</p>
<admonition type="tip"><p>Admonition with different syntax</p></admonition>"
`;
exports[`admonitions remark plugin default behavior for custom keyword 1`] = `
"<p>The blog feature enables you to deploy in no time a full-featured blog.</p>
<p>:::info Sample Title</p>
<p>Check the <a href="./api/plugins/plugin-content-blog.md">Blog Plugin API Reference documentation</a> for an exhaustive list of options.</p>
<p>:::</p>
<div><p>Sample Title</p><p>Check the <a href="./api/plugins/plugin-content-blog.md">Blog Plugin API Reference documentation</a> for an exhaustive list of options.</p></div>
<h2>Initial setup {#initial-setup}</h2>
<p>To set up your site's blog, start by creating a <code>blog</code> directory.</p>
<admonition type="tip"><p>Use the <strong><a href="introduction.md#fast-track">Fast Track</a></strong> to understand Docusaurus in <strong>5 minutes ⏱</strong>!</p><p>Use <strong><a href="https://docusaurus.new">docusaurus.new</a></strong> to test Docusaurus immediately in your browser!</p></admonition>
@ -61,9 +45,7 @@ exports[`admonitions remark plugin nesting 1`] = `
exports[`admonitions remark plugin replace custom keyword 1`] = `
"<p>The blog feature enables you to deploy in no time a full-featured blog.</p>
<p>:::info Sample Title</p>
<p>Check the <a href="./api/plugins/plugin-content-blog.md">Blog Plugin API Reference documentation</a> for an exhaustive list of options.</p>
<p>:::</p>
<div><p>Sample Title</p><p>Check the <a href="./api/plugins/plugin-content-blog.md">Blog Plugin API Reference documentation</a> for an exhaustive list of options.</p></div>
<h2>Initial setup {#initial-setup}</h2>
<p>To set up your site's blog, start by creating a <code>blog</code> directory.</p>
<admonition type="tip"><p>Use the <strong><a href="introduction.md#fast-track">Fast Track</a></strong> to understand Docusaurus in <strong>5 minutes ⏱</strong>!</p><p>Use <strong><a href="https://docusaurus.new">docusaurus.new</a></strong> to test Docusaurus immediately in your browser!</p></admonition>

View file

@ -6,40 +6,68 @@
*/
import path from 'path';
import remark from 'remark';
import remark2rehype from 'remark-rehype';
import stringify from 'rehype-stringify';
import vfile from 'to-vfile';
import plugin from '../index';
import preprocessor from '../../../preprocessor';
import plugin, {DefaultAdmonitionOptions} from '../index';
import type {AdmonitionOptions} from '../index';
const processFixture = async (
name: string,
options?: Partial<AdmonitionOptions>,
) => {
const {remark} = await import('remark');
const {default: directives} = await import('remark-directive');
const filePath = path.join(__dirname, '__fixtures__', `${name}.md`);
const file = await vfile.read(filePath);
const fileContentPreprocessed = preprocessor({
fileContent: file.toString(),
filePath,
admonitions: DefaultAdmonitionOptions,
markdownConfig: {
mermaid: false,
mdx1Compat: {
admonitions: true,
comments: false,
headingIds: false,
},
},
});
/*
// TODO we shouldn't use rehype in these tests
// this requires to re-implement admonitions with mdxJsxFlowElement
const {default: mdx} = await import('remark-mdx');
const result = await remark()
.use(directives)
.use(plugin)
.use(mdx)
.process(fileContentPreprocessed);
return result.value;
*/
const result = await remark()
.use(directives)
.use(plugin, options)
.use(remark2rehype)
.use(stringify)
.process(file);
.process(fileContentPreprocessed);
return result.toString();
return result.value;
};
describe('admonitions remark plugin', () => {
it('base', async () => {
const result = await processFixture('base');
expect(result).toMatchSnapshot();
await expect(result).toMatchSnapshot();
});
it('default behavior for custom keyword', async () => {
const result = await processFixture('base', {
keywords: ['tip'],
// extendDefaults: false, // By default we don't extend
extendDefaults: undefined, // By default we extend
});
expect(result).toMatchSnapshot();
});
@ -60,13 +88,6 @@ describe('admonitions remark plugin', () => {
expect(result).toMatchSnapshot();
});
it('custom tag', async () => {
const result = await processFixture('base', {
tag: '++++',
});
expect(result).toMatchSnapshot();
});
it('interpolation', async () => {
const result = await processFixture('interpolation');
expect(result).toMatchSnapshot();

View file

@ -4,25 +4,26 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import visit from 'unist-util-visit';
import type {Transformer, Processor, Plugin} from 'unified';
import type {Literal} from 'mdast';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer, Processor} from 'unified';
const NEWLINE = '\n';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {ContainerDirective} from 'mdast-util-directive';
import type {Parent} from 'mdast';
// TODO as of April 2023, no way to import/re-export this ESM type easily :/
// This might change soon, likely after TS 5.2
// See https://github.com/microsoft/TypeScript/issues/49721#issuecomment-1517839391
// import type {Plugin} from 'unified';
type Plugin = any; // TODO fix this asap
// TODO not ideal option shape
// First let upgrade to MDX 2.0
// Maybe we'll want to provide different tags for different admonition types?
// Also maybe rename "keywords" to "types"?
export type AdmonitionOptions = {
tag: string;
keywords: string[];
extendDefaults: boolean;
};
export const DefaultAdmonitionOptions: AdmonitionOptions = {
tag: ':::',
keywords: [
'secondary',
'info',
@ -34,16 +35,16 @@ export const DefaultAdmonitionOptions: AdmonitionOptions = {
'important',
'caution',
],
extendDefaults: false, // TODO make it true by default: breaking change
extendDefaults: true,
};
function escapeRegExp(s: string): string {
return s.replace(/[-[\]{}()*+?.\\^$|/]/g, '\\$&');
}
function normalizeOptions(
providedOptions: Partial<AdmonitionOptions>,
export function normalizeAdmonitionOptions(
providedOptions: Partial<AdmonitionOptions> | true,
): AdmonitionOptions {
if (providedOptions === true) {
return DefaultAdmonitionOptions;
}
const options = {...DefaultAdmonitionOptions, ...providedOptions};
// By default it makes more sense to append keywords to the default ones
@ -58,181 +59,84 @@ function normalizeOptions(
return options;
}
// This string value does not matter much
// It is ignored because nodes are using hName/hProperties coming from HAST
const admonitionNodeType = 'admonitionHTML';
type DirectiveLabel = Parent;
type DirectiveContent = ContainerDirective['children'];
function parseDirective(directive: ContainerDirective): {
directiveLabel: DirectiveLabel | undefined;
contentNodes: DirectiveContent;
} {
const hasDirectiveLabel =
directive.children?.[0]?.data?.directiveLabel === true;
if (hasDirectiveLabel) {
const [directiveLabel, ...contentNodes] = directive.children;
return {directiveLabel: directiveLabel as DirectiveLabel, contentNodes};
}
return {directiveLabel: undefined, contentNodes: directive.children};
}
function getTextOnlyTitle(directiveLabel: DirectiveLabel): string | undefined {
const isTextOnlyTitle =
directiveLabel?.children?.length === 1 &&
directiveLabel?.children?.[0]?.type === 'text';
return isTextOnlyTitle
? // @ts-expect-error: todo type
(directiveLabel?.children?.[0].value as string)
: undefined;
}
const plugin: Plugin = function plugin(
this: Processor,
optionsInput: Partial<AdmonitionOptions> = {},
): Transformer {
const options = normalizeOptions(optionsInput);
const {keywords} = normalizeAdmonitionOptions(optionsInput);
const keywords = Object.values(options.keywords).map(escapeRegExp).join('|');
const nestingChar = escapeRegExp(options.tag.slice(0, 1));
const tag = escapeRegExp(options.tag);
return async (root) => {
visit(root, (node) => {
if (node.type === 'containerDirective') {
const directive = node as ContainerDirective;
const isAdmonition = keywords.includes(directive.name);
// resolve th nesting level of an opening tag
// ::: -> 0, :::: -> 1, ::::: -> 2 ...
const nestingLevelRegex = new RegExp(
`^${tag}(?<nestingLevel>${nestingChar}*)`,
);
const regex = new RegExp(`${tag}${nestingChar}*(${keywords})(?: *(.*))?\n`);
const escapeTag = new RegExp(
escapeRegExp(`\\${options.tag}${options.tag.slice(0, 1)}*`),
'g',
);
// The tokenizer is called on blocks to determine if there is an admonition
// present and create tags for it
function blockTokenizer(this: any, eat: any, value: string, silent: boolean) {
// Stop if no match or match does not start at beginning of line
const match = regex.exec(value);
if (!match || match.index !== 0) {
return false;
}
// If silent return the match
if (silent) {
return true;
}
const now = eat.now();
const [opening, keyword, title] = match as string[] as [
string,
string,
string,
];
const food = [];
const content = [];
// get the nesting level of the opening tag
const openingLevel =
nestingLevelRegex.exec(opening)!.groups!.nestingLevel!.length;
// used as a stack to keep track of nested admonitions
const nestingLevels: number[] = [openingLevel];
let newValue = value;
// consume lines until a closing tag
let idx = newValue.indexOf(NEWLINE);
while (idx !== -1) {
// grab this line and eat it
const next = newValue.indexOf(NEWLINE, idx + 1);
const line =
next !== -1 ? newValue.slice(idx + 1, next) : newValue.slice(idx + 1);
food.push(line);
newValue = newValue.slice(idx + 1);
const nesting = nestingLevelRegex.exec(line);
idx = newValue.indexOf(NEWLINE);
if (!nesting) {
content.push(line);
continue;
}
const tagLevel = nesting.groups!.nestingLevel!.length;
// first level
if (nestingLevels.length === 0) {
nestingLevels.push(tagLevel);
content.push(line);
continue;
}
const currentLevel = nestingLevels[nestingLevels.length - 1]!;
if (tagLevel < currentLevel) {
// entering a nested admonition block
nestingLevels.push(tagLevel);
} else if (tagLevel === currentLevel) {
// closing a nested admonition block
nestingLevels.pop();
// the closing tag is NOT part of the content
if (nestingLevels.length === 0) {
break;
if (!isAdmonition) {
return;
}
}
content.push(line);
}
// consume the processed tag and replace escape sequences
const contentString = content.join(NEWLINE).replace(escapeTag, options.tag);
const add = eat(opening + food.join(NEWLINE));
const {directiveLabel, contentNodes} = parseDirective(directive);
// parse the content in block mode
const exit = this.enterBlock();
const contentNodes = this.tokenizeBlock(contentString, now);
exit();
const textOnlyTitle =
directive.attributes?.title ??
(directiveLabel ? getTextOnlyTitle(directiveLabel) : undefined);
const titleNodes = this.tokenizeInline(title, now);
const isSimpleTextTitle =
titleNodes.length === 1 && titleNodes[0].type === 'text';
const element = {
type: admonitionNodeType,
data: {
// hName/hProperties come from HAST
// Transform the mdast directive node to a hast admonition node
// See https://github.com/syntax-tree/mdast-util-to-hast#fields-on-nodes
hName: 'admonition',
hProperties: {
...(title && isSimpleTextTitle && {title}),
type: keyword,
},
},
children: [
// For titles containing MDX syntax: create a custom element. The theme
// component will extract it and render it nicely.
//
// Temporary workaround, because it's complex in MDX v1 to emit
// interpolated JSX prop syntax (title={<>my <code>title</code></>}).
// For this reason, we use children instead of the title prop.
title &&
!isSimpleTextTitle && {
type: admonitionNodeType,
// TODO in MDX v2 we should transform the whole directive to
// mdxJsxFlowElement instead of using hast
directive.data = {
hName: 'admonition',
hProperties: {
...(textOnlyTitle && {title: textOnlyTitle}),
type: directive.name,
},
};
directive.children = contentNodes;
// TODO legacy MDX v1 <mdxAdmonitionTitle> workaround
// v1: not possible to inject complex JSX elements as props
// v2: now possible: use a mdxJsxFlowElement element
if (directiveLabel && !textOnlyTitle) {
const complexTitleNode = {
type: 'mdxAdmonitionTitle',
data: {
hName: 'mdxAdmonitionTitle',
hProperties: {},
},
children: titleNodes,
},
...contentNodes,
].filter(Boolean),
};
return add(element);
}
// add tokenizer to parser after fenced code blocks
const Parser = this.Parser.prototype;
Parser.blockTokenizers.admonition = blockTokenizer;
Parser.blockMethods.splice(
Parser.blockMethods.indexOf('fencedCode') + 1,
0,
'admonition',
);
Parser.interruptParagraph.splice(
Parser.interruptParagraph.indexOf('fencedCode') + 1,
0,
['admonition'],
);
Parser.interruptList.splice(
Parser.interruptList.indexOf('fencedCode') + 1,
0,
['admonition'],
);
Parser.interruptBlockquote.splice(
Parser.interruptBlockquote.indexOf('fencedCode') + 1,
0,
['admonition'],
);
return (root) => {
// escape everything except admonitionHTML nodes
visit(
root,
(node: unknown): node is Literal =>
(node as Literal | undefined)?.type !== admonitionNodeType,
(node: Literal) => {
if (node.value) {
node.value = node.value.replace(escapeTag, options.tag);
children: directiveLabel.children,
};
// @ts-expect-error: invented node type
directive.children.unshift(complexTitleNode);
}
},
);
}
});
};
};

View file

@ -0,0 +1,76 @@
/**
* 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 details from '..';
async function process(content: string) {
const {remark} = await import('remark');
const {default: mdx} = await import('remark-mdx');
const result = await remark().use(mdx).use(details).process(content);
return result.value;
}
describe('details remark plugin', () => {
it("does nothing if there's no details", async () => {
const input = `# Heading 1
Some content
`;
const result = await process(input);
expect(result).toEqual(result);
});
it('can convert details', async () => {
const input = `# Details element example
<details>
<summary>Toggle me!</summary>
<div>
<div>This is the detailed content</div>
<br/>
<details>
<summary>
Nested toggle! Some surprise inside...
</summary>
<div>
😲😲😲😲😲
</div>
</details>
</div>
</details>`;
const result = await process(input);
expect(result).toMatchInlineSnapshot(`
"# Details element example
<Details>
<summary>Toggle me!</summary>
<div>
<div>This is the detailed content</div>
<br />
<Details>
<summary>
Nested toggle! Some surprise inside...
</summary>
<div>
😲😲😲😲😲
</div>
</Details>
</div>
</Details>
"
`);
});
});

View file

@ -0,0 +1,25 @@
/**
* 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 from 'unist-util-visit';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer} from 'unified';
// @ts-expect-error: ES support...
import type {MdxJsxFlowElement} from 'mdast-util-mdx';
// Transform <details> to <Details>
// MDX 2 doesn't allow to substitute html elements with the provider anymore
export default function plugin(): Transformer {
return (root) => {
visit(root, 'mdxJsxFlowElement', (node: MdxJsxFlowElement) => {
if (node.name === 'details') {
node.name = 'Details';
}
});
};
}

View file

@ -0,0 +1,76 @@
/**
* 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 head from '..';
async function process(content: string) {
const {remark} = await import('remark');
const {default: mdx} = await import('remark-mdx');
const result = await remark().use(mdx).use(head).process(content);
return result.value;
}
describe('head remark plugin', () => {
it("does nothing if there's no details", async () => {
const input = `# Heading
<head>
<html className="some-extra-html-class" />
<body className="other-extra-body-class" />
<title>Head Metadata customized title!</title>
<meta charSet="utf-8" />
<meta name="twitter:card" content="summary" />
<link rel="canonical" href="https://docusaurus.io/docs/markdown-features/head-metadata" />
</head>
Some content
`;
const result = await process(input);
expect(result).toEqual(result);
});
it('can convert head', async () => {
const input = `# Heading
<head>
<html className="some-extra-html-class" />
<body className="other-extra-body-class" />
<title>Head Metadata customized title!</title>
<meta charSet="utf-8" />
<meta name="twitter:card" content="summary" />
<link rel="canonical" href="https://docusaurus.io/docs/markdown-features/head-metadata" />
</head>
Some content;`;
const result = await process(input);
expect(result).toMatchInlineSnapshot(`
"# Heading
<Head>
<html className="some-extra-html-class" />
<body className="other-extra-body-class" />
<title>Head Metadata customized title!</title>
<meta charSet="utf-8" />
<meta name="twitter:card" content="summary" />
<link rel="canonical" href="https://docusaurus.io/docs/markdown-features/head-metadata" />
</Head>
Some content;
"
`);
});
});

View file

@ -0,0 +1,25 @@
/**
* 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 from 'unist-util-visit';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer} from 'unified';
// @ts-expect-error: ES support...
import type {MdxJsxFlowElement} from 'mdast-util-mdx';
// Transform <head> to <Head>
// MDX 2 doesn't allow to substitute html elements with the provider anymore
export default function plugin(): Transformer {
return (root) => {
visit(root, 'mdxJsxFlowElement', (node: MdxJsxFlowElement) => {
if (node.name === 'head') {
node.name = 'Head';
}
});
};
}

View file

@ -7,18 +7,18 @@
/* Based on remark-slug (https://github.com/remarkjs/remark-slug) and gatsby-remark-autolink-headers (https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-remark-autolink-headers) */
import remark from 'remark';
import u from 'unist-builder';
import removePosition from 'unist-util-remove-position';
import toString from 'mdast-util-to-string';
import {toString} from 'mdast-util-to-string';
import visit from 'unist-util-visit';
import slug from '../index';
import type {Plugin} from 'unified';
import type {Parent} from 'unist';
function process(doc: string, plugins: Plugin[] = []) {
const processor = remark().use({plugins: [...plugins, slug]});
return removePosition(processor.runSync(processor.parse(doc)), true);
async function process(doc: string, plugins: Plugin[] = []) {
const {remark} = await import('remark');
const processor = await remark().use({plugins: [...plugins, slug]});
return removePosition(await processor.run(processor.parse(doc)), true);
}
function heading(label: string | null, id: string) {
@ -30,8 +30,8 @@ function heading(label: string | null, id: string) {
}
describe('headings remark plugin', () => {
it('patches `id`s and `data.hProperties.id', () => {
const result = process('# Normal\n\n## Table of Contents\n\n# Baz\n');
it('patches `id`s and `data.hProperties.id', async () => {
const result = await process('# Normal\n\n## Table of Contents\n\n# Baz\n');
const expected = u('root', [
u(
'heading',
@ -57,8 +57,8 @@ describe('headings remark plugin', () => {
expect(result).toEqual(expected);
});
it('does not overwrite `data` on headings', () => {
const result = process('# Normal\n', [
it('does not overwrite `data` on headings', async () => {
const result = await process('# Normal\n', [
() => (root) => {
(root as Parent).children[0]!.data = {foo: 'bar'};
},
@ -77,8 +77,8 @@ describe('headings remark plugin', () => {
expect(result).toEqual(expected);
});
it('does not overwrite `data.hProperties` on headings', () => {
const result = process('# Normal\n', [
it('does not overwrite `data.hProperties` on headings', async () => {
const result = await process('# Normal\n', [
() => (root) => {
(root as Parent).children[0]!.data = {
hProperties: {className: ['foo']},
@ -99,8 +99,8 @@ describe('headings remark plugin', () => {
expect(result).toEqual(expected);
});
it('generates `id`s and `hProperties.id`s, based on `hProperties.id` if they exist', () => {
const result = process(
it('generates `id`s and `hProperties.id`s, based on `hProperties.id` if they exist', async () => {
const result = await process(
[
'## Something',
'## Something here',
@ -152,8 +152,8 @@ describe('headings remark plugin', () => {
expect(result).toEqual(expected);
});
it('creates GitHub-style headings ids', () => {
const result = process(
it('creates GitHub-style headings ids', async () => {
const result = await process(
[
'## I ♥ unicode',
'',
@ -223,8 +223,10 @@ describe('headings remark plugin', () => {
expect(result).toEqual(expected);
});
it('generates id from only text contents of headings if they contains HTML tags', () => {
const result = process('# <span class="normal-header">Normal</span>\n');
it('generates id from only text contents of headings if they contains HTML tags', async () => {
const result = await process(
'# <span class="normal-header">Normal</span>\n',
);
const expected = u('root', [
u(
'heading',
@ -243,8 +245,8 @@ describe('headings remark plugin', () => {
expect(result).toEqual(expected);
});
it('creates custom headings ids', () => {
const result = process(`
it('creates custom headings ids', async () => {
const result = await process(`
# Heading One {#custom_h1}
## Heading Two {#custom-heading-two}

View file

@ -9,12 +9,14 @@
import {parseMarkdownHeadingId, createSlugger} from '@docusaurus/utils';
import visit from 'unist-util-visit';
import mdastToString from 'mdast-util-to-string';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer} from 'unified';
import type {Heading, Text} from 'mdast';
export default function plugin(): Transformer {
return (root) => {
return async (root) => {
const {toString} = await import('mdast-util-to-string');
const slugs = createSlugger();
visit(root, 'heading', (headingNode: Heading) => {
const data = headingNode.data ?? (headingNode.data = {});
@ -29,7 +31,7 @@ export default function plugin(): Transformer {
const headingTextNodes = headingNode.children.filter(
({type}) => !['html', 'jsx'].includes(type),
);
const heading = mdastToString(
const heading = toString(
headingTextNodes.length > 0 ? headingTextNodes : headingNode,
);

View file

@ -0,0 +1,36 @@
/**
* 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 from 'unist-util-visit';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer, Processor} from 'unified';
import type {Code} from 'mdast';
// Solution inspired by https://github.com/pomber/docusaurus-mdx-2/blob/main/packages/mdx-loader/src/remark/codeCompat/index.ts
// TODO after MDX 2 we probably don't need this - remove soon?
// Only fenced code blocks are swapped by pre/code MDX components
// Using <pre><code> in JSX shouldn't use our MDX components anymore
// To make theme-classic/src/theme/MDXComponents/Pre work
// we need to fill two properties that mdx v2 doesn't provide anymore
export default function codeCompatPlugin(this: Processor): Transformer {
return (root) => {
visit(root, 'code', (node: Code) => {
node.data = node.data || {};
node.data.hProperties = node.data.hProperties || {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(node.data.hProperties as any).metastring = node.meta;
// Retrocompatible support for live codeblock metastring
// Not really the appropriate place to handle that :s
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(node.data.hProperties as any).live = node.meta
?.split(' ')
.includes('live');
});
};
}

View file

@ -1,49 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`mermaid remark plugin does nothing if there's no mermaid code block 1`] = `
"
const layoutProps = {
};
const MDXLayout = "wrapper"
export default function MDXContent({
components,
...props
}) {
return <MDXLayout {...layoutProps} {...props} components={components} mdxType="MDXLayout">
<h1>{\`Heading 1\`}</h1>
<p>{\`No Mermaid diagram :(\`}</p>
<pre><code parentName="pre" {...{
"className": "language-js"
}}>{\`this is not mermaid
\`}</code></pre>
</MDXLayout>;
}
;
MDXContent.isMDXComponent = true;"
`;
exports[`mermaid remark plugin works for basic mermaid code blocks 1`] = `
"
const layoutProps = {
};
const MDXLayout = "wrapper"
export default function MDXContent({
components,
...props
}) {
return <MDXLayout {...layoutProps} {...props} components={components} mdxType="MDXLayout">
<h1>{\`Heading 1\`}</h1>
<mermaid {...{
"value": "graph TD;/n A-->B;/n A-->C;/n B-->D;/n C-->D;"
}}></mermaid>
</MDXLayout>;
}
;
MDXContent.isMDXComponent = true;"
`;

View file

@ -5,19 +5,28 @@
* LICENSE file in the root directory of this source tree.
*/
import {createCompiler} from '@mdx-js/mdx';
import remark2rehype from 'remark-rehype';
import stringify from 'rehype-stringify';
import mermaid from '..';
describe('mermaid remark plugin', () => {
function createTestCompiler() {
return createCompiler({
remarkPlugins: [mermaid],
});
}
async function process(content: string) {
const {remark} = await import('remark');
// const {default: mdx} = await import('remark-mdx');
// const result = await remark().use(mermaid).use(mdx).process(content);
const result = await remark()
.use(mermaid)
.use(remark2rehype)
.use(stringify)
.process(content);
return result.value;
}
describe('mermaid remark plugin', () => {
it("does nothing if there's no mermaid code block", async () => {
const mdxCompiler = createTestCompiler();
const result = await mdxCompiler.process(
const result = await process(
`# Heading 1
No Mermaid diagram :(
@ -27,12 +36,17 @@ this is not mermaid
\`\`\`
`,
);
expect(result.contents).toMatchSnapshot();
expect(result).toMatchInlineSnapshot(`
"<h1>Heading 1</h1>
<p>No Mermaid diagram :(</p>
<pre><code class="language-js">this is not mermaid
</code></pre>"
`);
});
it('works for basic mermaid code blocks', async () => {
const mdxCompiler = createTestCompiler();
const result = await mdxCompiler.process(`# Heading 1
const result = await process(`# Heading 1
\`\`\`mermaid
graph TD;
@ -41,6 +55,13 @@ graph TD;
B-->D;
C-->D;
\`\`\``);
expect(result.contents).toMatchSnapshot();
expect(result).toMatchInlineSnapshot(`
"<h1>Heading 1</h1>
<mermaid value="graph TD;
A-->B;
A-->C;
B-->D;
C-->D;"></mermaid>"
`);
});
});

View file

@ -6,6 +6,7 @@
*/
import visit from 'unist-util-visit';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer} from 'unified';
import type {Code} from 'mdast';
@ -17,6 +18,7 @@ export default function plugin(): Transformer {
return (root) => {
visit(root, 'code', (node: Code, index, parent) => {
if (node.lang === 'mermaid') {
// TODO migrate to mdxJsxFlowElement? cf admonitions
parent!.children.splice(index, 1, {
type: 'mermaidCodeBlock',
data: {

View file

@ -45,7 +45,7 @@ exports[`toc remark plugin escapes inline code 1`] = `
id: 'divitestidiv-1',
level: 2
}
];
]
## \`<Head />\`
@ -78,7 +78,7 @@ exports[`toc remark plugin exports even with existing name 1`] = `
id: 'avengers',
level: 3
}
];
]
## Thanos
@ -89,11 +89,11 @@ exports[`toc remark plugin exports even with existing name 1`] = `
`;
exports[`toc remark plugin handles empty headings 1`] = `
"export const toc = [];
"export const toc = []
# Ignore this
##
##
## ![](an-image.svg)
"
@ -120,7 +120,7 @@ export const toc = [
id: 'again',
level: 3
}
];
]
## Title
@ -133,7 +133,7 @@ Content.
`;
exports[`toc remark plugin outputs empty array for no TOC 1`] = `
"export const toc = [];
"export const toc = []
foo
@ -172,9 +172,9 @@ exports[`toc remark plugin works on non text phrasing content 1`] = `
id: 'inlinecode',
level: 2
}
];
]
## _Emphasis_
## *Emphasis*
### **Importance**
@ -208,7 +208,7 @@ exports[`toc remark plugin works on text content 1`] = `
id: 'i--unicode',
level: 2
}
];
]
### Endi

View file

@ -6,22 +6,25 @@
*/
import path from 'path';
import remark from 'remark';
import mdx from 'remark-mdx';
import vfile from 'to-vfile';
import plugin from '../index';
import headings from '../../headings/index';
const processFixture = async (name: string) => {
const {remark} = await import('remark');
const {default: gfm} = await import('remark-gfm');
const {default: mdx} = await import('remark-mdx');
const filePath = path.join(__dirname, '__fixtures__', `${name}.md`);
const file = await vfile.read(filePath);
const result = await remark()
.use(headings)
.use(gfm)
.use(mdx)
.use(plugin)
.process(file);
return result.toString();
return result.value;
};
describe('toc remark plugin', () => {

View file

@ -8,28 +8,42 @@
import {parse, type ParserOptions} from '@babel/parser';
import traverse from '@babel/traverse';
import stringifyObject from 'stringify-object';
import toString from 'mdast-util-to-string';
import visit from 'unist-util-visit';
import {toValue} from '../utils';
import type {Identifier} from '@babel/types';
import type {TOCItem} from '../..';
import type {Node, Parent} from 'unist';
import type {Heading, Literal} from 'mdast';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
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
// See https://github.com/microsoft/TypeScript/issues/49721#issuecomment-1517839391
// import type {Plugin} from 'unified';
type Plugin = any; // TODO fix this asap
export type TOCItem = {
readonly value: string;
readonly id: string;
readonly level: number;
};
const parseOptions: ParserOptions = {
plugins: ['jsx'],
sourceType: 'module',
};
const name = 'toc';
const isImport = (child: Node): child is Literal => child.type === 'import';
const isImport = (child: any): child is Literal =>
child.type === 'mdxjsEsm' && child.value.startsWith('import');
const hasImports = (index: number) => index > -1;
const isExport = (child: Node): child is Literal => child.type === 'export';
const isExport = (child: any): child is Literal =>
child.type === 'mdxjsEsm' && child.value.startsWith('export');
const isTarget = (child: Literal) => {
interface PluginOptions {
name?: string;
}
const isTarget = (child: Literal, name: string) => {
let found = false;
const ast = parse(child.value, parseOptions);
traverse(ast, {
@ -42,24 +56,23 @@ const isTarget = (child: Literal) => {
return found;
};
const getOrCreateExistingTargetIndex = (children: Node[]) => {
const getOrCreateExistingTargetIndex = async (
children: Node[],
name: string,
) => {
let importsIndex = -1;
let targetIndex = -1;
children.forEach((child, index) => {
if (isImport(child)) {
importsIndex = index;
} else if (isExport(child) && isTarget(child)) {
} else if (isExport(child) && isTarget(child, name)) {
targetIndex = index;
}
});
if (targetIndex === -1) {
const target = {
default: false,
type: 'export',
value: `export const ${name} = [];`,
};
const target = await createExportNode(name, []);
targetIndex = hasImports(importsIndex) ? importsIndex + 1 : 0;
children.splice(targetIndex, 0, target);
@ -68,31 +81,72 @@ const getOrCreateExistingTargetIndex = (children: Node[]) => {
return targetIndex;
};
export default function plugin(): Transformer {
return (root) => {
const plugin: Plugin = function plugin(
options: PluginOptions = {},
): Transformer {
const name = options.name || 'toc';
return async (root) => {
const {toString} = await import('mdast-util-to-string');
const headings: TOCItem[] = [];
visit(root, 'heading', (child: Heading, index, parent) => {
visit(root, 'heading', (child: Heading) => {
const value = toString(child);
// depth: 1 headings are titles and not included in the TOC
if (parent !== root || !value || child.depth < 2) {
// depth:1 headings are titles and not included in the TOC
if (!value || child.depth < 2) {
return;
}
headings.push({
value: toValue(child),
value: toValue(child, toString),
id: child.data!.id as string,
level: child.depth,
});
});
const {children} = root as Parent<Literal>;
const targetIndex = getOrCreateExistingTargetIndex(children);
const targetIndex = await getOrCreateExistingTargetIndex(children, name);
if (headings.length) {
children[targetIndex]!.value = `export const ${name} = ${stringifyObject(
headings,
)};`;
if (headings?.length) {
children[targetIndex] = await createExportNode(name, headings);
}
};
};
export default plugin;
async function createExportNode(name: string, object: any) {
const {valueToEstree} = await import('estree-util-value-to-estree');
return {
type: 'mdxjsEsm',
value: `export const ${name} = ${stringifyObject(object)}`,
data: {
estree: {
type: 'Program',
body: [
{
type: 'ExportNamedDeclaration',
declaration: {
type: 'VariableDeclaration',
declarations: [
{
type: 'VariableDeclarator',
id: {
type: 'Identifier',
name,
},
init: valueToEstree(object),
},
],
kind: 'const',
},
specifiers: [],
source: null,
},
],
sourceType: 'module',
},
},
};
}

View file

@ -4,6 +4,8 @@
![img](static/img.png)
in paragraph ![img](static/img.png)
![img from second static folder](/img2.png)
![img from second static folder](./static2/img2.png)
@ -16,11 +18,9 @@
![site alias](@site/static/img.png)
![img with hash](/img.png#light)
![img with hash](/img.png#dark)
![img with hash](/img.png#light) ![img with hash](/img.png#dark)
![img with query](/img.png?w=10)
![img with query](/img.png?w=10&h=10)
![img with query](/img.png?w=10) ![img with query](/img.png?w=10&h=10)
![img with both](/img.png?w=10&h=10#light)

View file

@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`transformImage plugin does not choke on invalid image 1`] = `
"<img alt={"invalid image"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/invalid.png").default} />
"<img alt="invalid image" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/invalid.png").default} />
"
`;
@ -21,27 +21,27 @@ exports[`transformImage plugin transform md images to <img /> 1`] = `
<img src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default} width="200" height="200" />
<img alt={"img"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default} width="200" height="200" />
<img alt="img" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default} width="200" height="200" />
<img alt={"img from second static folder"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static2/img2.png").default} width="256" height="82" />
in paragraph <img alt="img" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default} width="200" height="200" />
<img alt={"img from second static folder"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static2/img2.png").default} width="256" height="82" />
<img alt="img from second static folder" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static2/img2.png").default} width="256" height="82" />
<img alt={"img with URL encoded chars"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static2/img2 copy.png").default} width="256" height="82" />
<img alt="img from second static folder" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static2/img2.png").default} width="256" height="82" />
<img alt={"img"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default} title="Title" width="200" height="200" /> <img alt={"img"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default} width="200" height="200" />
<img alt="img with URL encoded chars" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static2/img2 copy.png").default} width="256" height="82" />
<img alt={"img with &quot;quotes&quot;"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default} title="&#39;Quoted&#39; title" width="200" height="200" />
<img alt="img" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default} title="Title" width="200" height="200" /> <img alt="img" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default} width="200" height="200" />
<img alt={"site alias"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default} width="200" height="200" />
!/[img with "quotes"]/(./static/img.png ''Quoted' title')
<img alt={"img with hash"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default + '#light'} width="200" height="200" />
<img alt={"img with hash"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default + '#dark'} width="200" height="200" />
<img alt="site alias" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default} width="200" height="200" />
<img alt={"img with query"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png?w=10").default} width="200" height="200" />
<img alt={"img with query"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png?w=10&h=10").default} width="200" height="200" />
<img alt="img with hash" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default + '#light'} width="200" height="200" /> <img alt="img with hash" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png").default + '#dark'} width="200" height="200" />
<img alt={"img with both"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png?w=10&h=10").default + '#light'} width="200" height="200" />
<img alt="img with query" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png?w=10").default} width="200" height="200" /> <img alt="img with query" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png?w=10&h=10").default} width="200" height="200" />
<img alt="img with both" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/img.png?w=10&h=10").default + '#light'} width="200" height="200" />
## Heading

View file

@ -7,25 +7,24 @@
import {jest} from '@jest/globals';
import path from 'path';
import remark from 'remark';
import mdx from 'remark-mdx';
import vfile from 'to-vfile';
import plugin, {type PluginOptions} from '../index';
import headings from '../../headings/index';
const processFixture = async (
name: string,
options: Partial<PluginOptions>,
) => {
const {remark} = await import('remark');
const {default: mdx} = await import('remark-mdx');
const filePath = path.join(__dirname, `__fixtures__/${name}.md`);
const file = await vfile.read(filePath);
const result = await remark()
.use(headings)
.use(mdx)
.use(plugin, {siteDir: __dirname, staticDirs: [], ...options})
.process(file);
return result.toString();
return result.value;
};
const staticDirs = [

View file

@ -9,7 +9,6 @@ import path from 'path';
import url from 'url';
import fs from 'fs-extra';
import {promisify} from 'util';
import logger from '@docusaurus/logger';
import {
toMessageRelativeFilePath,
posixPath,
@ -20,15 +19,20 @@ import {
import visit from 'unist-util-visit';
import escapeHtml from 'escape-html';
import sizeOf from 'image-size';
import logger from '@docusaurus/logger';
import {assetRequireAttributeValue} from '../utils';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer} from 'unified';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {MdxJsxTextElement} from 'mdast-util-mdx';
import type {Image} from 'mdast';
import type {Parent} from 'unist';
import type {Image, Literal} from 'mdast';
const {
loaders: {inlineMarkdownImageFileLoader},
} = getFileLoaderUtils();
export type PluginOptions = {
type PluginOptions = {
staticDirs: string[];
siteDir: string;
};
@ -40,10 +44,14 @@ type Context = PluginOptions & {
type Target = [node: Image, index: number, parent: Parent];
async function toImageRequireNode(
[node, index, parent]: Target,
[node]: Target,
imagePath: string,
filePath: string,
) {
// MdxJsxTextElement => see https://github.com/facebook/docusaurus/pull/8288#discussion_r1125871405
const jsxNode = node as unknown as MdxJsxTextElement;
const attributes: MdxJsxTextElement['attributes'] = [];
let relativeImagePath = posixPath(
path.relative(path.dirname(filePath), imagePath),
);
@ -52,21 +60,46 @@ async function toImageRequireNode(
const parsedUrl = url.parse(node.url);
const hash = parsedUrl.hash ?? '';
const search = parsedUrl.search ?? '';
const alt = node.alt ? `alt={"${escapeHtml(node.alt)}"} ` : '';
const src = `require("${inlineMarkdownImageFileLoader}${
const requireString = `${inlineMarkdownImageFileLoader}${
escapePath(relativeImagePath) + search
}").default${hash ? ` + '${hash}'` : ''}`;
const title = node.title ? ` title="${escapeHtml(node.title)}"` : '';
let width = '';
let height = '';
}`;
if (node.alt) {
attributes.push({
type: 'mdxJsxAttribute',
name: 'alt',
value: escapeHtml(node.alt),
});
}
attributes.push({
type: 'mdxJsxAttribute',
name: 'src',
value: assetRequireAttributeValue(requireString, hash),
});
if (node.title) {
attributes.push({
type: 'mdxJsxAttribute',
name: 'title',
value: escapeHtml(node.title),
});
}
try {
const size = (await promisify(sizeOf)(imagePath))!;
if (size.width) {
width = ` width="${size.width}"`;
attributes.push({
type: 'mdxJsxAttribute',
name: 'width',
value: String(size.width),
});
}
if (size.height) {
height = ` height="${size.height}"`;
attributes.push({
type: 'mdxJsxAttribute',
name: 'height',
value: String(size.height),
});
}
} catch (err) {
// Workaround for https://github.com/yarnpkg/berry/pull/3889#issuecomment-1034469784
@ -77,12 +110,14 @@ ${(err as Error).message}`;
}
}
const jsxNode: Literal = {
type: 'jsx',
value: `<img ${alt}src={${src}}${title}${width}${height} />`,
};
Object.keys(jsxNode).forEach(
(key) => delete jsxNode[key as keyof typeof jsxNode],
);
parent.children.splice(index, 1, jsxNode);
jsxNode.type = 'mdxJsxTextElement';
jsxNode.name = 'img';
jsxNode.attributes = attributes;
jsxNode.children = [];
}
async function ensureImageFileExist(imagePath: string, sourceFilePath: string) {
@ -122,7 +157,7 @@ async function getImageAbsolutePath(
}
return imageFilePath;
}
// Relative paths are resolved against the source file's folder.
// relative paths are resolved against the source file's folder
const imageFilePath = path.join(
path.dirname(filePath),
decodeURIComponent(imagePath),

View file

@ -4,6 +4,8 @@
[asset](./asset.pdf)
in paragraph [asset](./asset.pdf)
[asset with URL encoded chars](./asset%20%282%29.pdf)
[asset with hash](./asset.pdf#page=2)

View file

@ -12,15 +12,17 @@ exports[`transformAsset plugin pathname protocol 1`] = `
exports[`transformAsset plugin transform md links to <a /> 1`] = `
"[asset](https://example.com/asset.pdf)
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf').default}></a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} />
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf').default}>asset</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset (2).pdf').default}>asset with URL encoded chars</a>
in paragraph <a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf').default + '#page=2'}>asset with hash</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset (2).pdf").default}>asset with URL encoded chars</a>
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf').default} title="Title">asset</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default + '#page=2'}>asset with hash</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} title="Title">asset</a>
[page](noUrl.md)
@ -34,24 +36,24 @@ exports[`transformAsset plugin transform md links to <a /> 1`] = `
[assets](/github/!file-loader!/assets.pdf)
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf').default}>asset</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static2/asset2.pdf').default}>asset2</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static2/asset2.pdf").default}>asset2</a>
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf').default}>staticAsset.pdf</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>staticAsset.pdf</a>
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf').default}>@site/static/staticAsset.pdf</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>@site/static/staticAsset.pdf</a>
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf').default + '#page=2'} title="Title">@site/static/staticAsset.pdf</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default + '#page=2'} title="Title">@site/static/staticAsset.pdf</a>
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf').default}>Just staticAsset.pdf</a>, and <a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf').default}><strong>awesome</strong> staticAsset 2.pdf &#39;It is really &quot;AWESOME&quot;&#39;</a>, but also <a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf').default}>coded <code>staticAsset 3.pdf</code></a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>Just staticAsset.pdf</a>, and <a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>**awesome** staticAsset 2.pdf 'It is really "AWESOME"'</a>, but also <a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>coded \`staticAsset 3.pdf\`</a>
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAssetImage.png').default}><img alt={"Clickable Docusaurus logo"} src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/staticAssetImage.png").default} width="200" height="200" /></a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAssetImage.png").default}><img alt="Clickable Docusaurus logo" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/staticAssetImage.png").default} width="200" height="200" /></a>
<a target="_blank" href={require('!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf').default}><span style={{color: "red"}}>Stylized link to asset file</span></a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}><span style={{color: "red"}}>Stylized link to asset file</span></a>
<a target="_blank" href={require('./data.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./data.json').default}>JSON</a>
<a target="_blank" href={require("./data.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./data.json").default}>JSON</a>
<a target="_blank" href={require('./static/static-json.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/static-json.json').default}>static JSON</a>
<a target="_blank" href={require("./static/static-json.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/static-json.json").default}>static JSON</a>
"
`;

View file

@ -6,13 +6,13 @@
*/
import path from 'path';
import remark from 'remark';
import mdx from 'remark-mdx';
import vfile from 'to-vfile';
import plugin from '..';
import transformImage, {type PluginOptions} from '../../transformImage';
const processFixture = async (name: string, options?: PluginOptions) => {
const {remark} = await import('remark');
const {default: mdx} = await import('remark-mdx');
const siteDir = path.join(__dirname, `__fixtures__`);
const staticDirs = [
path.join(siteDir, 'static'),
@ -29,7 +29,7 @@ const processFixture = async (name: string, options?: PluginOptions) => {
})
.process(file);
return result.toString();
return result.value;
};
describe('transformAsset plugin', () => {

View file

@ -17,8 +17,11 @@ import {
} from '@docusaurus/utils';
import visit from 'unist-util-visit';
import escapeHtml from 'escape-html';
import {stringifyContent} from '../utils';
import {assetRequireAttributeValue} from '../utils';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer} from 'unified';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {MdxJsxTextElement} from 'mdast-util-mdx';
import type {Parent} from 'unist';
import type {Link, Literal} from 'mdast';
@ -26,7 +29,7 @@ const {
loaders: {inlineMarkdownLinkFileLoader},
} = getFileLoaderUtils();
export type PluginOptions = {
type PluginOptions = {
staticDirs: string[];
siteDir: string;
};
@ -40,11 +43,15 @@ type Target = [node: Link, index: number, parent: Parent];
/**
* Transforms the link node to a JSX `<a>` element with a `require()` call.
*/
function toAssetRequireNode(
[node, index, parent]: Target,
async function toAssetRequireNode(
[node]: Target,
assetPath: string,
filePath: string,
) {
// MdxJsxTextElement => see https://github.com/facebook/docusaurus/pull/8288#discussion_r1125871405
const jsxNode = node as unknown as MdxJsxTextElement;
const attributes: MdxJsxTextElement['attributes'] = [];
// require("assets/file.pdf") means requiring from a package called assets
const relativeAssetPath = `./${posixPath(
path.relative(path.dirname(filePath), assetPath),
@ -54,23 +61,43 @@ function toAssetRequireNode(
const hash = parsedUrl.hash ?? '';
const search = parsedUrl.search ?? '';
const href = `require('${
const requireString = `${
// A hack to stop Webpack from using its built-in loader to parse JSON
path.extname(relativeAssetPath) === '.json'
? `${relativeAssetPath.replace('.json', '.raw')}!=`
: ''
}${inlineMarkdownLinkFileLoader}${
escapePath(relativeAssetPath) + search
}').default${hash ? ` + '${hash}'` : ''}`;
const children = stringifyContent(node);
const title = node.title ? ` title="${escapeHtml(node.title)}"` : '';
}${inlineMarkdownLinkFileLoader}${escapePath(relativeAssetPath) + search}`;
const jsxNode: Literal = {
type: 'jsx',
value: `<a target="_blank" href={${href}}${title}>${children}</a>`,
};
attributes.push({
type: 'mdxJsxAttribute',
name: 'target',
value: '_blank',
});
parent.children.splice(index, 1, jsxNode);
attributes.push({
type: 'mdxJsxAttribute',
name: 'href',
value: assetRequireAttributeValue(requireString, hash),
});
if (node.title) {
attributes.push({
type: 'mdxJsxAttribute',
name: 'title',
value: escapeHtml(node.title),
});
}
const {children} = node;
Object.keys(jsxNode).forEach(
(key) => delete jsxNode[key as keyof typeof jsxNode],
);
jsxNode.type = 'mdxJsxTextElement';
jsxNode.name = 'a';
jsxNode.attributes = attributes;
jsxNode.children = children;
}
async function ensureAssetFileExist(assetPath: string, sourceFilePath: string) {
@ -144,7 +171,7 @@ async function processLinkNode(target: Target, context: Context) {
context,
);
if (assetPath) {
toAssetRequireNode(target, assetPath, context.filePath);
await toAssetRequireNode(target, assetPath, context.filePath);
}
}

View file

@ -1,95 +0,0 @@
# MDX code blocks test document
## Some basic markdown
text
[link](https://facebook.com)
**bold**
![image](https://facebook.com/favicon.ico)
## Some basic MDX
import XYZ from 'xyz';
<XYZ abc="1" def={[1, '42', {hello: 'world'}]} style={{color: 'red'}}>
<span>Test</span>
</XYZ>
## Some basic MDX code block
```mdx-code-block
import Avatar from 'avatar';
<Avatar style={{color: 'red'}}>
<div>Sebastien Lorber</div>
</Avatar>
```
## Some complex MDX with nested code blocks
<Tabs
defaultValue="bash"
values={[
{ label: 'Bash', value: 'bash' },
{ label: 'Windows', value: 'windows' },
{ label: 'PowerShell', value: 'powershell' }
]}>
<TabItem value="bash">
```bash
GIT_USER=<GITHUB_USERNAME> yarn deploy
```
</TabItem>
<TabItem value="windows">
```batch
cmd /C "set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy"
```
</TabItem>
<TabItem value="powershell">
```powershell
cmd /C 'set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy'
```
</TabItem>
</Tabs>
## Some complex MDX code block with nested code blocks
````mdx-code-block
<Tabs
defaultValue="bash"
values={[
{ label: 'Bash', value: 'bash' },
{ label: 'Windows', value: 'windows' },
{ label: 'PowerShell', value: 'powershell' }
]}>
<TabItem value="bash">
```bash
GIT_USER=<GITHUB_USERNAME> yarn deploy
```
</TabItem>
<TabItem value="windows">
```batch
cmd /C "set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy"
```
</TabItem>
<TabItem value="powershell">
```powershell
cmd /C 'set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy'
```
</TabItem>
</Tabs>
````

View file

@ -1,819 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`unwrapMdxCodeBlocks remark plugin unwraps the mdx code blocks 1`] = `
"# MDX code blocks test document
## Some basic markdown
text
[link](https://facebook.com)
**bold**
![image](https://facebook.com/favicon.ico)
## Some basic MDX
import XYZ from 'xyz';
<XYZ abc="1" def={[1, '42', {hello: 'world'}]} style={{color: 'red'}}>
<span>Test</span>
</XYZ>
## Some basic MDX code block
import Avatar from 'avatar';
<Avatar style={{color: 'red'}}>
<div>Sebastien Lorber</div>
</Avatar>
## Some complex MDX with nested code blocks
<Tabs
defaultValue="bash"
values={[
{ label: 'Bash', value: 'bash' },
{ label: 'Windows', value: 'windows' },
{ label: 'PowerShell', value: 'powershell' }
]}>
<TabItem value="bash">
\`\`\`bash
GIT_USER=<GITHUB_USERNAME> yarn deploy
\`\`\`
</TabItem>
<TabItem value="windows">
\`\`\`batch
cmd /C "set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy"
\`\`\`
</TabItem>
<TabItem value="powershell">
\`\`\`powershell
cmd /C 'set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy'
\`\`\`
</TabItem>
</Tabs>
## Some complex MDX code block with nested code blocks
<Tabs
defaultValue="bash"
values={[
{ label: 'Bash', value: 'bash' },
{ label: 'Windows', value: 'windows' },
{ label: 'PowerShell', value: 'powershell' }
]}>
<TabItem value="bash">
\`\`\`bash
GIT_USER=<GITHUB_USERNAME> yarn deploy
\`\`\`
</TabItem>
<TabItem value="windows">
\`\`\`batch
cmd /C "set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy"
\`\`\`
</TabItem>
<TabItem value="powershell">
\`\`\`powershell
cmd /C 'set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy'
\`\`\`
</TabItem>
</Tabs>
"
`;
exports[`unwrapMdxCodeBlocks remark plugin unwraps the mdx code blocks AST 1`] = `
{
"children": [
{
"children": [
{
"position": Position {
"end": {
"column": 32,
"line": 1,
"offset": 31,
},
"indent": [],
"start": {
"column": 3,
"line": 1,
"offset": 2,
},
},
"type": "text",
"value": "MDX code blocks test document",
},
],
"depth": 1,
"position": Position {
"end": {
"column": 32,
"line": 1,
"offset": 31,
},
"indent": [],
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "heading",
},
{
"children": [
{
"position": Position {
"end": {
"column": 23,
"line": 3,
"offset": 55,
},
"indent": [],
"start": {
"column": 4,
"line": 3,
"offset": 36,
},
},
"type": "text",
"value": "Some basic markdown",
},
],
"depth": 2,
"position": Position {
"end": {
"column": 23,
"line": 3,
"offset": 55,
},
"indent": [],
"start": {
"column": 1,
"line": 3,
"offset": 33,
},
},
"type": "heading",
},
{
"children": [
{
"position": Position {
"end": {
"column": 5,
"line": 5,
"offset": 61,
},
"indent": [],
"start": {
"column": 1,
"line": 5,
"offset": 57,
},
},
"type": "text",
"value": "text",
},
],
"position": Position {
"end": {
"column": 5,
"line": 5,
"offset": 61,
},
"indent": [],
"start": {
"column": 1,
"line": 5,
"offset": 57,
},
},
"type": "paragraph",
},
{
"children": [
{
"children": [
{
"position": Position {
"end": {
"column": 6,
"line": 7,
"offset": 68,
},
"indent": [],
"start": {
"column": 2,
"line": 7,
"offset": 64,
},
},
"type": "text",
"value": "link",
},
],
"position": Position {
"end": {
"column": 29,
"line": 7,
"offset": 91,
},
"indent": [],
"start": {
"column": 1,
"line": 7,
"offset": 63,
},
},
"title": null,
"type": "link",
"url": "https://facebook.com",
},
],
"position": Position {
"end": {
"column": 29,
"line": 7,
"offset": 91,
},
"indent": [],
"start": {
"column": 1,
"line": 7,
"offset": 63,
},
},
"type": "paragraph",
},
{
"children": [
{
"children": [
{
"position": Position {
"end": {
"column": 7,
"line": 9,
"offset": 99,
},
"indent": [],
"start": {
"column": 3,
"line": 9,
"offset": 95,
},
},
"type": "text",
"value": "bold",
},
],
"position": Position {
"end": {
"column": 9,
"line": 9,
"offset": 101,
},
"indent": [],
"start": {
"column": 1,
"line": 9,
"offset": 93,
},
},
"type": "strong",
},
],
"position": Position {
"end": {
"column": 9,
"line": 9,
"offset": 101,
},
"indent": [],
"start": {
"column": 1,
"line": 9,
"offset": 93,
},
},
"type": "paragraph",
},
{
"children": [
{
"alt": "image",
"position": Position {
"end": {
"column": 43,
"line": 11,
"offset": 145,
},
"indent": [],
"start": {
"column": 1,
"line": 11,
"offset": 103,
},
},
"title": null,
"type": "image",
"url": "https://facebook.com/favicon.ico",
},
],
"position": Position {
"end": {
"column": 43,
"line": 11,
"offset": 145,
},
"indent": [],
"start": {
"column": 1,
"line": 11,
"offset": 103,
},
},
"type": "paragraph",
},
{
"children": [
{
"position": Position {
"end": {
"column": 18,
"line": 13,
"offset": 164,
},
"indent": [],
"start": {
"column": 4,
"line": 13,
"offset": 150,
},
},
"type": "text",
"value": "Some basic MDX",
},
],
"depth": 2,
"position": Position {
"end": {
"column": 18,
"line": 13,
"offset": 164,
},
"indent": [],
"start": {
"column": 1,
"line": 13,
"offset": 147,
},
},
"type": "heading",
},
{
"position": Position {
"end": {
"column": 23,
"line": 15,
"offset": 188,
},
"indent": [],
"start": {
"column": 1,
"line": 15,
"offset": 166,
},
},
"type": "import",
"value": "import XYZ from 'xyz';",
},
{
"position": Position {
"end": {
"column": 7,
"line": 19,
"offset": 287,
},
"indent": [
1,
1,
],
"start": {
"column": 1,
"line": 17,
"offset": 190,
},
},
"type": "jsx",
"value": "<XYZ abc="1" def={[1, '42', {hello: 'world'}]} style={{color: 'red'}}>
<span>Test</span>
</XYZ>",
},
{
"children": [
{
"position": Position {
"end": {
"column": 29,
"line": 21,
"offset": 317,
},
"indent": [],
"start": {
"column": 4,
"line": 21,
"offset": 292,
},
},
"type": "text",
"value": "Some basic MDX code block",
},
],
"depth": 2,
"position": Position {
"end": {
"column": 29,
"line": 21,
"offset": 317,
},
"indent": [],
"start": {
"column": 1,
"line": 21,
"offset": 289,
},
},
"type": "heading",
},
{
"lang": "mdx-code-block",
"meta": null,
"position": Position {
"end": {
"column": 4,
"line": 29,
"offset": 442,
},
"indent": [
1,
1,
1,
1,
1,
1,
],
"start": {
"column": 1,
"line": 23,
"offset": 319,
},
},
"type": "code",
"value": "import Avatar from 'avatar';
<Avatar style={{color: 'red'}}>
<div>Sebastien Lorber</div>
</Avatar>",
},
{
"children": [
{
"position": Position {
"end": {
"column": 44,
"line": 31,
"offset": 487,
},
"indent": [],
"start": {
"column": 4,
"line": 31,
"offset": 447,
},
},
"type": "text",
"value": "Some complex MDX with nested code blocks",
},
],
"depth": 2,
"position": Position {
"end": {
"column": 44,
"line": 31,
"offset": 487,
},
"indent": [],
"start": {
"column": 1,
"line": 31,
"offset": 444,
},
},
"type": "heading",
},
{
"position": Position {
"end": {
"column": 25,
"line": 40,
"offset": 688,
},
"indent": [
1,
1,
1,
1,
1,
1,
1,
],
"start": {
"column": 1,
"line": 33,
"offset": 489,
},
},
"type": "jsx",
"value": "<Tabs
defaultValue="bash"
values={[
{ label: 'Bash', value: 'bash' },
{ label: 'Windows', value: 'windows' },
{ label: 'PowerShell', value: 'powershell' }
]}>
<TabItem value="bash">",
},
{
"lang": "bash",
"meta": null,
"position": Position {
"end": {
"column": 4,
"line": 44,
"offset": 740,
},
"indent": [
1,
1,
],
"start": {
"column": 1,
"line": 42,
"offset": 690,
},
},
"type": "code",
"value": "GIT_USER=<GITHUB_USERNAME> yarn deploy",
},
{
"position": Position {
"end": {
"column": 28,
"line": 47,
"offset": 782,
},
"indent": [
1,
],
"start": {
"column": 1,
"line": 46,
"offset": 742,
},
},
"type": "jsx",
"value": " </TabItem>
<TabItem value="windows">",
},
{
"lang": null,
"meta": null,
"position": Position {
"end": {
"column": 8,
"line": 51,
"offset": 865,
},
"indent": [
1,
1,
],
"start": {
"column": 1,
"line": 49,
"offset": 784,
},
},
"type": "code",
"value": "\`\`\`batch
cmd /C "set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy"
\`\`\`",
},
{
"position": Position {
"end": {
"column": 31,
"line": 54,
"offset": 910,
},
"indent": [
1,
],
"start": {
"column": 1,
"line": 53,
"offset": 867,
},
},
"type": "jsx",
"value": " </TabItem>
<TabItem value="powershell">",
},
{
"lang": "powershell",
"meta": null,
"position": Position {
"end": {
"column": 4,
"line": 58,
"offset": 986,
},
"indent": [
1,
1,
],
"start": {
"column": 1,
"line": 56,
"offset": 912,
},
},
"type": "code",
"value": "cmd /C 'set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy'",
},
{
"position": Position {
"end": {
"column": 8,
"line": 61,
"offset": 1008,
},
"indent": [
1,
],
"start": {
"column": 1,
"line": 60,
"offset": 988,
},
},
"type": "jsx",
"value": " </TabItem>
</Tabs>",
},
{
"children": [
{
"position": Position {
"end": {
"column": 55,
"line": 63,
"offset": 1064,
},
"indent": [],
"start": {
"column": 4,
"line": 63,
"offset": 1013,
},
},
"type": "text",
"value": "Some complex MDX code block with nested code blocks",
},
],
"depth": 2,
"position": Position {
"end": {
"column": 55,
"line": 63,
"offset": 1064,
},
"indent": [],
"start": {
"column": 1,
"line": 63,
"offset": 1010,
},
},
"type": "heading",
},
{
"lang": "mdx-code-block",
"meta": null,
"position": Position {
"end": {
"column": 5,
"line": 95,
"offset": 1585,
},
"indent": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
],
"start": {
"column": 1,
"line": 65,
"offset": 1066,
},
},
"type": "code",
"value": "<Tabs
defaultValue="bash"
values={[
{ label: 'Bash', value: 'bash' },
{ label: 'Windows', value: 'windows' },
{ label: 'PowerShell', value: 'powershell' }
]}>
<TabItem value="bash">
\`\`\`bash
GIT_USER=<GITHUB_USERNAME> yarn deploy
\`\`\`
</TabItem>
<TabItem value="windows">
\`\`\`batch
cmd /C "set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy"
\`\`\`
</TabItem>
<TabItem value="powershell">
\`\`\`powershell
cmd /C 'set "GIT_USER=<GITHUB_USERNAME>" && yarn deploy'
\`\`\`
</TabItem>
</Tabs>",
},
],
"position": {
"end": {
"column": 1,
"line": 96,
"offset": 1586,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "root",
}
`;

View file

@ -1,36 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import path from 'path';
import remark from 'remark';
import mdx from 'remark-mdx';
import vfile from 'to-vfile';
import plugin from '..';
const processFixture = async (name: string) => {
const file = await vfile.read(path.join(__dirname, '__fixtures__', name));
const result = await remark().use(mdx).use(plugin).process(file);
return result.toString();
};
const processFixtureAST = async (name: string) => {
const file = await vfile.read(path.join(__dirname, '__fixtures__', name));
return remark().use(mdx).use(plugin).parse(file);
};
describe('unwrapMdxCodeBlocks remark plugin', () => {
it('unwraps the mdx code blocks', async () => {
const result = await processFixture('has-mdx-code-blocks.mdx');
expect(result).toMatchSnapshot();
});
// The AST output should be parsed correctly or the MDX loader won't work!
it('unwraps the mdx code blocks AST', async () => {
const result = await processFixtureAST('has-mdx-code-blocks.mdx');
expect(result).toMatchSnapshot();
});
});

View file

@ -1,33 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import visit from 'unist-util-visit';
import type {Transformer, Processor} from 'unified';
import type {Code, Parent} from 'mdast';
// This plugin is mostly to help integrating Docusaurus with translation systems
// that do not support well MDX embedded JSX syntax (like Crowdin).
// We wrap the JSX syntax in code blocks so that translation tools don't mess up
// with the markup, but the JSX inside such code blocks should still be
// evaluated as JSX
// See https://github.com/facebook/docusaurus/pull/4278
export default function plugin(this: Processor): Transformer {
return (root) => {
visit(root, 'code', (node: Code, index, parent) => {
if (node.lang === 'mdx-code-block') {
const newChildren = (this.parse(node.value) as Parent).children;
// Replace the mdx code block by its content, parsed
parent!.children.splice(
parent!.children.indexOf(node),
1,
...newChildren,
);
}
});
};
}

View file

@ -6,31 +6,99 @@
*/
import escapeHtml from 'escape-html';
import toString from 'mdast-util-to-string';
import type {Parent} from 'unist';
import type {PhrasingContent, Heading} from 'mdast';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {MdxJsxAttributeValueExpression} from 'mdast-util-mdx';
export function stringifyContent(node: Parent): string {
return (node.children as PhrasingContent[]).map(toValue).join('');
export function stringifyContent(
node: Parent,
toString: (param: unknown) => string, // TODO weird but works): string {
): string {
return (node.children as PhrasingContent[])
.map((item) => toValue(item, toString))
.join('');
}
export function toValue(node: PhrasingContent | Heading): string {
export function toValue(
node: PhrasingContent | Heading,
toString: (param: unknown) => string, // TODO weird but works
): string {
switch (node.type) {
case 'mdxJsxTextElement': {
const tag = node.name;
return `<${tag}>${stringifyContent(node, toString)}</${tag}>`;
}
case 'text':
return escapeHtml(node.value);
case 'heading':
return stringifyContent(node);
return stringifyContent(node, toString);
case 'inlineCode':
return `<code>${escapeHtml(node.value)}</code>`;
case 'emphasis':
return `<em>${stringifyContent(node)}</em>`;
return `<em>${stringifyContent(node, toString)}</em>`;
case 'strong':
return `<strong>${stringifyContent(node)}</strong>`;
return `<strong>${stringifyContent(node, toString)}</strong>`;
case 'delete':
return `<del>${stringifyContent(node)}</del>`;
return `<del>${stringifyContent(node, toString)}</del>`;
case 'link':
return stringifyContent(node);
return stringifyContent(node, toString);
default:
return toString(node);
}
}
export function assetRequireAttributeValue(
requireString: string,
hash: string,
): MdxJsxAttributeValueExpression {
return {
type: 'mdxJsxAttributeValueExpression',
value: `require("${requireString}").default${hash && ` + '${hash}'`}`,
data: {
estree: {
type: 'Program',
body: [
{
type: 'ExpressionStatement',
expression: {
type: 'BinaryExpression',
left: {
type: 'MemberExpression',
object: {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: 'require',
},
arguments: [
{
type: 'Literal',
value: requireString,
raw: `"${requireString}"`,
},
],
optional: false,
},
property: {
type: 'Identifier',
name: 'default',
},
computed: false,
optional: false,
},
operator: '+',
right: {
type: 'Literal',
value: hash,
raw: `"${hash}"`,
},
},
},
],
sourceType: 'module',
comments: [],
},
},
};
}