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

@ -13,3 +13,5 @@ packages/docusaurus-*/lib/*
packages/create-docusaurus/lib/*
packages/create-docusaurus/templates/
website/static/katex/katex.min.css
jest/vendor

14
jest.config.mjs vendored
View file

@ -80,6 +80,20 @@ export default {
'@docusaurus/plugin-content-docs/src/client/index.ts',
'@testing-utils/(.*)': '<rootDir>/jest/utils/$1.ts',
// MDX packages are ESM-only and it is a pain to use in Jest
// So we use them in Jest tests as CJS versions
// see https://mdxjs.com/docs/troubleshooting-mdx/#problems-integrating-mdx
'^unified$': '<rootDir>/jest/vendor/unified@10.1.2.js',
'^@mdx-js/mdx$': '<rootDir>/jest/vendor/@mdx-js__mdx@2.1.5.js',
'^remark$': '<rootDir>/jest/vendor/remark@14.0.2.js',
'^remark-mdx$': '<rootDir>/jest/vendor/remark-mdx@2.1.5.js',
'^remark-directive$': '<rootDir>/jest/vendor/remark-directive@2.0.1.js',
'^remark-gfm$': '<rootDir>/jest/vendor/remark-gfm@3.0.1.js',
'^estree-util-value-to-estree$':
'<rootDir>/jest/vendor/estree-util-value-to-estree@2.1.0.js',
'^mdast-util-to-string$':
'<rootDir>/jest/vendor/mdast-util-to-string@3.1.0.js',
},
snapshotSerializers: [
'<rootDir>/jest/snapshotPathNormalizer.ts',

7
jest/deps.d.ts vendored
View file

@ -13,13 +13,6 @@ declare module 'to-vfile' {
export function read(path: string, encoding?: string): Promise<VFile>;
}
declare module 'remark-mdx' {
import type {Plugin} from 'unified';
const mdx: Plugin;
export = mdx;
}
declare module 'remark-rehype';
declare module 'rehype-stringify';

View file

@ -55,12 +55,12 @@
"test": "jest",
"test:build:website": "./admin/scripts/test-release.sh",
"watch": "yarn lerna run --parallel watch",
"clear": "(yarn workspace website clear || echo 'Failure while running docusaurus clear') && yarn lerna exec --ignore docusaurus yarn rimraf lib",
"clear": "(yarn workspace website clear || echo 'Failure while running docusaurus clear') && yarn rimraf test-website && yarn rimraf test-website-in-workspace && yarn lerna exec --ignore docusaurus yarn rimraf lib",
"test:baseUrl": "yarn build:website:baseUrl && yarn serve:website:baseUrl",
"lock:update": "npx --yes yarn-deduplicate"
},
"dependencies": {
"unified": "^9.2.2"
"unified": "^10.1.2"
},
"devDependencies": {
"@crowdin/cli": "^3.10.0",

View file

@ -17,7 +17,7 @@
"dependencies": {
"@docusaurus/core": "^3.0.0-alpha.0",
"@docusaurus/preset-classic": "^3.0.0-alpha.0",
"@mdx-js/react": "^1.6.22",
"@mdx-js/react": "^2.1.5",
"clsx": "^1.2.1",
"prism-react-renderer": "^1.3.5",
"react": "^17.0.2",

View file

@ -16,7 +16,7 @@
"dependencies": {
"@docusaurus/core": "^3.0.0-alpha.0",
"@docusaurus/preset-classic": "^3.0.0-alpha.0",
"@mdx-js/react": "^1.6.22",
"@mdx-js/react": "^2.1.5",
"clsx": "^1.2.1",
"prism-react-renderer": "^1.3.5",
"react": "^17.0.2",

View file

@ -20,7 +20,7 @@
"dependencies": {
"@docusaurus/core": "^3.0.0-alpha.0",
"@docusaurus/preset-classic": "^3.0.0-alpha.0",
"@mdx-js/react": "^1.6.22",
"@mdx-js/react": "^2.1.5",
"clsx": "^1.2.1",
"react": "^17.0.2",
"react-dom": "^17.0.2"

View file

@ -22,16 +22,23 @@
"@babel/traverse": "^7.21.2",
"@docusaurus/logger": "^3.0.0-alpha.0",
"@docusaurus/utils": "^3.0.0-alpha.0",
"@mdx-js/mdx": "^1.6.22",
"@docusaurus/utils-validation": "^3.0.0-alpha.0",
"@mdx-js/mdx": "^2.1.5",
"escape-html": "^1.0.3",
"estree-util-value-to-estree": "^2.1.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.1.0",
"hastscript": "^7.1.0",
"image-size": "^1.0.2",
"mdast-util-to-string": "^2.0.0",
"mdast-util-to-string": "^3.0.0",
"mdast-util-mdx": "^2.0.0",
"remark-comment": "^1.0.0",
"remark-gfm": "^3.0.1",
"remark-directive": "^2.0.1",
"remark-emoji": "^2.2.0",
"stringify-object": "^3.3.0",
"tslib": "^2.5.0",
"unified": "^9.2.2",
"unified": "^10.1.2",
"unist-util-visit": "^2.0.3",
"url-loader": "^4.1.1",
"webpack": "^5.76.0"
@ -43,8 +50,8 @@
"@types/stringify-object": "^3.3.1",
"@types/unist": "^2.0.6",
"rehype-stringify": "^8.0.0",
"remark": "^12.0.1",
"remark-mdx": "^1.6.21",
"remark": "^14.0.2",
"remark-mdx": "^2.1.5",
"remark-rehype": "^8.1.0",
"to-vfile": "^6.1.0",
"unist-builder": "^2.0.3",

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: [],
},
},
};
}

View file

@ -38,7 +38,7 @@
"remark-stringify": "^8.1.0",
"semver": "^7.3.8",
"tslib": "^2.5.0",
"unified": "^9.2.2",
"unified": "^10.1.2",
"unist-util-visit": "^2.0.3"
},
"devDependencies": {

View file

@ -37,10 +37,10 @@ async function walk(dir: string): Promise<string[]> {
return results;
}
function sanitizedFileContent(
async function sanitizedFileContent(
content: string,
migrateMDFiles: boolean,
): string {
): Promise<string> {
const extractedData = extractMetadata(content);
const extractedMetaData = Object.entries(extractedData.metadata)
.map(
@ -55,7 +55,7 @@ ${extractedMetaData}
---
${
migrateMDFiles
? sanitizeMD(extractedData.rawContent)
? await sanitizeMD(extractedData.rawContent)
: extractedData.rawContent
}`;
return sanitizedData;
@ -427,7 +427,7 @@ async function migrateBlogFiles(context: MigrationContext) {
const content = await fs.readFile(file, 'utf-8');
await fs.outputFile(
file,
sanitizedFileContent(content, shouldMigrateMdFiles),
await sanitizedFileContent(content, shouldMigrateMdFiles),
);
}),
);
@ -515,7 +515,7 @@ async function migrateVersionedDocs(
const content = await fs.readFile(pathToFile, 'utf-8');
await fs.outputFile(
pathToFile,
sanitizedFileContent(
await sanitizedFileContent(
content.replace(versionRegex, ''),
shouldMigrateMdFiles,
),
@ -695,7 +695,7 @@ async function migrateLatestDocs(context: MigrationContext) {
const content = await fs.readFile(file, 'utf-8');
await fs.outputFile(
file,
sanitizedFileContent(content, shouldMigrateMdFiles),
await sanitizedFileContent(content, shouldMigrateMdFiles),
);
}
}),
@ -752,7 +752,10 @@ export async function migrateMDToMDX(
files.map(async (filePath) => {
if (path.extname(filePath) === '.md') {
const content = await fs.readFile(filePath, 'utf-8');
await fs.outputFile(filePath, sanitizedFileContent(content, true));
await fs.outputFile(
filePath,
await sanitizedFileContent(content, true),
);
}
}),
);

View file

@ -7,7 +7,6 @@
import markdown from 'remark-parse';
import toJsx from '@mapbox/hast-util-to-jsx';
import unified from 'unified';
import parse from 'rehype-parse';
import visit from 'unist-util-visit';
import remarkStringify from 'remark-stringify';
@ -21,7 +20,9 @@ const tags = htmlTags.reduce((acc: {[key: string]: boolean}, tag) => {
return acc;
}, {});
export default function sanitizeMD(code: string): string {
export default async function sanitizeMD(code: string): Promise<string> {
const {unified} = await import('unified');
const markdownTree = unified().use(markdown).parse(code);
visit(markdownTree, 'code', (node: Code) => {
node.value = `\n<!--${node.value}-->\n`;
@ -30,7 +31,8 @@ export default function sanitizeMD(code: string): string {
node.value = `<!--${node.value}-->`;
});
const markdownString = unified()
// @ts-expect-error: :/
const markdownString: string = await unified()
.use(remarkStringify, {fence: '`', fences: true})
.stringify(markdownTree);
@ -45,6 +47,7 @@ export default function sanitizeMD(code: string): string {
delete (node as Partial<Element>).tagName;
}
});
return toJsx(htmlTree)
.replace(/\{\/\*|\*\/\}/g, '')
.replace(/\{\/\*|\*\/\}/g, '')

View file

@ -26,7 +26,7 @@ export const DEFAULT_OPTIONS: PluginOptions = {
beforeDefaultRehypePlugins: [],
beforeDefaultRemarkPlugins: [],
admonitions: true,
truncateMarker: /<!--\s*truncate\s*-->/,
truncateMarker: /<!--\s*truncate\s*-->|\{\/\*\s*truncate\s*\*\/\}/,
rehypePlugins: [],
remarkPlugins: [],
showReadingTime: true,

View file

@ -1,65 +1,101 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`footnoteIDFixer remark plugin appends a hash to each footnote def/ref 1`] = `
"/* @jsxRuntime classic */
/* @jsx mdx */
const layoutProps = {
};
const MDXLayout = "wrapper"
export default function MDXContent({
components,
...props
}) {
return <MDXLayout {...layoutProps} {...props} components={components} mdxType="MDXLayout">
<p>{\`foo\`}<sup parentName="p" {...{
"id": "fnref-1-[HASH]"
}}><a parentName="sup" {...{
"href": "#fn-1-[HASH]",
"className": "footnote-ref"
}}>{\`1\`}</a></sup></p>
<p>{\`bar\`}<sup parentName="p" {...{
"id": "fnref-2-[HASH]"
}}><a parentName="sup" {...{
"href": "#fn-2-[HASH]",
"className": "footnote-ref"
}}>{\`2\`}</a></sup></p>
<p>{\`baz\`}<sup parentName="p" {...{
"id": "fnref-3-[HASH]"
}}><a parentName="sup" {...{
"href": "#fn-3-[HASH]",
"className": "footnote-ref"
}}>{\`3\`}</a></sup></p>
<div {...{
"className": "footnotes"
}}>
<hr parentName="div"></hr>
<ol parentName="div">
<li parentName="ol" {...{
"id": "fn-1-[HASH]"
}}>{\`foo\`}<a parentName="li" {...{
"href": "#fnref-1-[HASH]",
"className": "footnote-backref"
}}>{\`↩\`}</a></li>
<li parentName="ol" {...{
"id": "fn-2-[HASH]"
}}>{\`foo\`}<a parentName="li" {...{
"href": "#fnref-2-[HASH]",
"className": "footnote-backref"
}}>{\`↩\`}</a></li>
<li parentName="ol" {...{
"id": "fn-3-[HASH]"
}}>{\`foo\`}<a parentName="li" {...{
"href": "#fnref-3-[HASH]",
"className": "footnote-backref"
}}>{\`↩\`}</a></li>
</ol>
</div>
</MDXLayout>;
"/*@jsxRuntime automatic @jsxImportSource react*/
import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
function _createMdxContent(props) {
const _components = Object.assign({
p: "p",
sup: "sup",
a: "a",
section: "section",
h2: "h2",
ol: "ol",
li: "li"
}, props.components);
return _jsxs(_Fragment, {
children: [_jsxs(_components.p, {
children: ["foo", _jsx(_components.sup, {
children: _jsx(_components.a, {
href: "#user-content-fn-1-[HASH]",
id: "user-content-fnref-1-[HASH]",
"data-footnote-ref": true,
"aria-describedby": "footnote-label",
children: "1"
})
})]
}), "/n", _jsxs(_components.p, {
children: ["bar", _jsx(_components.sup, {
children: _jsx(_components.a, {
href: "#user-content-fn-2-[HASH]",
id: "user-content-fnref-2-[HASH]",
"data-footnote-ref": true,
"aria-describedby": "footnote-label",
children: "2"
})
})]
}), "/n", _jsxs(_components.p, {
children: ["baz", _jsx(_components.sup, {
children: _jsx(_components.a, {
href: "#user-content-fn-3-[HASH]",
id: "user-content-fnref-3-[HASH]",
"data-footnote-ref": true,
"aria-describedby": "footnote-label",
children: "3"
})
})]
}), "/n", _jsxs(_components.section, {
"data-footnotes": true,
className: "footnotes",
children: [_jsx(_components.h2, {
className: "sr-only",
id: "footnote-label",
children: "Footnotes"
}), "/n", _jsxs(_components.ol, {
children: ["/n", _jsxs(_components.li, {
id: "user-content-fn-1-[HASH]",
children: ["/n", _jsxs(_components.p, {
children: ["foo ", _jsx(_components.a, {
href: "#user-content-fnref-1-[HASH]",
"data-footnote-backref": true,
className: "data-footnote-backref",
"aria-label": "Back to content",
children: "↩"
})]
}), "/n"]
}), "/n", _jsxs(_components.li, {
id: "user-content-fn-2-[HASH]",
children: ["/n", _jsxs(_components.p, {
children: ["foo ", _jsx(_components.a, {
href: "#user-content-fnref-2-[HASH]",
"data-footnote-backref": true,
className: "data-footnote-backref",
"aria-label": "Back to content",
children: "↩"
})]
}), "/n"]
}), "/n", _jsxs(_components.li, {
id: "user-content-fn-3-[HASH]",
children: ["/n", _jsxs(_components.p, {
children: ["foo ", _jsx(_components.a, {
href: "#user-content-fnref-3-[HASH]",
"data-footnote-backref": true,
className: "data-footnote-backref",
"aria-label": "Back to content",
children: "↩"
})]
}), "/n"]
}), "/n"]
}), "/n"]
})]
});
}
;
MDXContent.isMDXComponent = true;"
function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, Object.assign({}, props, {
children: _jsx(_createMdxContent, props)
})) : _createMdxContent(props);
}
export default MDXContent;
"
`;

View file

@ -5,20 +5,24 @@
* LICENSE file in the root directory of this source tree.
*/
import fs from 'fs-extra';
import path from 'path';
import vfile from 'to-vfile';
import {simpleHash} from '@docusaurus/utils';
import mdx from '@mdx-js/mdx';
import footnoteIDFixer from '../footnoteIDFixer';
const processFixture = async (name: string) => {
const filepath = path.join(__dirname, `__fixtures__/${name}.md`);
const result = await mdx(await fs.readFile(filepath, 'utf8'), {
filepath,
remarkPlugins: [footnoteIDFixer],
const mdx = await import('@mdx-js/mdx');
const {default: gfm} = await import('remark-gfm');
const filePath = path.join(__dirname, `__fixtures__/${name}.md`);
const file = await vfile.read(filePath);
const result = await mdx.compile(file, {
remarkPlugins: [gfm, footnoteIDFixer],
});
return result.toString();
return result.value;
};
describe('footnoteIDFixer remark plugin', () => {

View file

@ -7,6 +7,7 @@
import visit from 'unist-util-visit';
import {simpleHash} from '@docusaurus/utils';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer} from 'unified';
import type {FootnoteReference, FootnoteDefinition} from 'mdast';

View file

@ -17,14 +17,16 @@
},
"license": "MIT",
"dependencies": {
"mdast-util-mdx": "^2.0.0",
"npm-to-yarn": "^2.0.0",
"tslib": "^2.5.0",
"unified": "^10.1.2",
"unist-util-visit": "^2.0.3"
},
"devDependencies": {
"@types/mdast": "^3.0.10",
"remark": "^12.0.1",
"remark-mdx": "^1.6.21",
"remark": "^14.0.2",
"remark-mdx": "^2.1.5",
"to-vfile": "^6.1.0"
},
"engines": {

View file

@ -0,0 +1,21 @@
# Title
```bash npm2yarn
$ npm install --global docusaurus
```
<div className="nested-npm2yarn">
```bash npm2yarn
npm install
```
</div>
```bash
echo "no npm2yarn here"
```
## Subtitle
```bash npm2yarn
yarn add @docusaurus/core
```

View file

@ -1,67 +1,127 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`npm2yarn plugin does not re-import tabs components real-world multiple npm2yarn usage 1`] = `
"import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
# Title
<Tabs>
<TabItem value="npm">
\`\`\`bash
$ npm install --global docusaurus
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
$ yarn global add docusaurus
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
$ pnpm add --global docusaurus
\`\`\`
</TabItem>
</Tabs>
<div className="nested-npm2yarn">
<Tabs>
<TabItem value="npm">
\`\`\`bash
npm install
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn install
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm install
\`\`\`
</TabItem>
</Tabs>
</div>
\`\`\`bash
echo "no npm2yarn here"
\`\`\`
## Subtitle
<Tabs>
<TabItem value="npm">
\`\`\`bash
yarn add @docusaurus/core
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn add @docusaurus/core
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
yarn add @docusaurus/core
\`\`\`
</TabItem>
</Tabs>
"
`;
exports[`npm2yarn plugin does not re-import tabs components when already imported above 1`] = `
"import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
<Tabs>
<TabItem value="npm">
\`\`\`bash
$ npm install --global docusaurus
\`\`\`
</TabItem>
<TabItem value="npm">
\`\`\`bash
$ npm install --global docusaurus
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
$ yarn global add docusaurus
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
$ pnpm add --global docusaurus
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
$ yarn global add docusaurus
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
$ pnpm add --global docusaurus
\`\`\`
</TabItem>
</Tabs>
"
`;
exports[`npm2yarn plugin does not re-import tabs components when already imported below 1`] = `
"<Tabs>
<TabItem value="npm">
\`\`\`bash
$ npm install --global docusaurus
\`\`\`
</TabItem>
<TabItem value="npm">
\`\`\`bash
$ npm install --global docusaurus
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
$ yarn global add docusaurus
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
$ pnpm add --global docusaurus
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
$ yarn global add docusaurus
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
$ pnpm add --global docusaurus
\`\`\`
</TabItem>
</Tabs>
import Tabs from '@theme/Tabs';
@ -86,322 +146,278 @@ npm install --save docusaurus-plugin-name
`;
exports[`npm2yarn plugin work with custom converter 1`] = `
"import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
"import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
## Installing a plugin
A plugin is usually a npm package, so you install them like other npm packages using npm.
<Tabs>
<TabItem value="npm">
\`\`\`bash
npm install --save docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="npm">
\`\`\`bash
npm install --save docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="Turbo">
\`\`\`bash
turbo install --save docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="Turbo">
\`\`\`bash
turbo install --save docusaurus-plugin-name
\`\`\`
</TabItem>
</Tabs>
"
`;
exports[`npm2yarn plugin work with pnpm converter 1`] = `
"import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
"import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
## Installing a plugin
A plugin is usually a npm package, so you install them like other npm packages using npm.
<Tabs>
<TabItem value="npm">
\`\`\`bash
npm install --save docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="npm">
\`\`\`bash
npm install --save docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm add docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm add docusaurus-plugin-name
\`\`\`
</TabItem>
</Tabs>
"
`;
exports[`npm2yarn plugin work with yarn converter 1`] = `
"import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
"import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
## Installing a plugin
A plugin is usually a npm package, so you install them like other npm packages using npm.
<Tabs>
<TabItem value="npm">
\`\`\`bash
npm install --save docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="npm">
\`\`\`bash
npm install --save docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn add docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn add docusaurus-plugin-name
\`\`\`
</TabItem>
</Tabs>
"
`;
exports[`npm2yarn plugin works on installation file 1`] = `
"import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
"import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
<Tabs>
<TabItem value="npm">
\`\`\`bash
$ npm install --global docusaurus
\`\`\`
</TabItem>
<TabItem value="npm">
\`\`\`bash
$ npm install --global docusaurus
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
$ yarn global add docusaurus
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
$ pnpm add --global docusaurus
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
$ yarn global add docusaurus
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
$ pnpm add --global docusaurus
\`\`\`
</TabItem>
</Tabs>
"
`;
exports[`npm2yarn plugin works on plugin file 1`] = `
"import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
"import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
## Installing a plugin
A plugin is usually a npm package, so you install them like other npm packages using npm.
<Tabs>
<TabItem value="npm">
\`\`\`bash
npm install --save docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="npm">
\`\`\`bash
npm install --save docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn add docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm add docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn add docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm add docusaurus-plugin-name
\`\`\`
</TabItem>
</Tabs>
"
`;
exports[`npm2yarn plugin works with common commands 1`] = `
"import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
"import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
<Tabs groupId="npm2yarn">
<TabItem value="npm">
\`\`\`bash
npm run xxx -- --arg
\`\`\`
</TabItem>
<TabItem value="npm">
\`\`\`bash
npm run xxx -- --arg
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn xxx --arg
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm run xxx -- --arg
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn xxx --arg
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm run xxx -- --arg
\`\`\`
</TabItem>
</Tabs>
<Tabs groupId="npm2yarn">
<TabItem value="npm">
\`\`\`bash
npm install package
\`\`\`
</TabItem>
<TabItem value="npm">
\`\`\`bash
npm install package
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn add package
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm add package
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn add package
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm add package
\`\`\`
</TabItem>
</Tabs>
<Tabs groupId="npm2yarn">
<TabItem value="npm">
\`\`\`bash
npm remove package-name
\`\`\`
</TabItem>
<TabItem value="npm">
\`\`\`bash
npm remove package-name
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn remove package-name
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm remove package-name
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn remove package-name
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm remove package-name
\`\`\`
</TabItem>
</Tabs>
<Tabs groupId="npm2yarn">
<TabItem value="npm">
\`\`\`bash
npm init docusaurus
npm init docusaurus@latest my-website classic
\`\`\`
</TabItem>
<TabItem value="npm">
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn create docusaurus
yarn create docusaurus@latest my-website classic
\`\`\`
</TabItem>
\`\`\`bash
npm init docusaurus
npm init docusaurus@latest my-website classic
\`\`\`
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm create docusaurus
pnpm create docusaurus@latest my-website classic
\`\`\`
</TabItem>
</Tabs>
"
`;
</TabItem>
exports[`npm2yarn plugin works with simplest md 1`] = `
"import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
<TabItem value="yarn" label="Yarn">
# Title
\`\`\`bash
yarn create docusaurus
yarn create docusaurus@latest my-website classic
\`\`\`
Hey
</TabItem>
<Tabs>
<TabItem value="npm">
\`\`\`bash
npm install test
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm create docusaurus
pnpm create docusaurus@latest my-website classic
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn add test
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm add test
\`\`\`
</TabItem>
</Tabs>
"
`;
exports[`npm2yarn plugin works with sync option 1`] = `
"import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
"import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
## Installing a plugin
A plugin is usually a npm package, so you install them like other npm packages using npm.
<Tabs groupId="npm2yarn">
<TabItem value="npm">
\`\`\`bash
npm install --save docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="npm">
\`\`\`bash
npm install --save docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn add docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm add docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="yarn" label="Yarn">
\`\`\`bash
yarn add docusaurus-plugin-name
\`\`\`
</TabItem>
<TabItem value="pnpm" label="pnpm">
\`\`\`bash
pnpm add docusaurus-plugin-name
\`\`\`
</TabItem>
</Tabs>
"
`;

View file

@ -7,22 +7,49 @@
import path from 'path';
import vfile from 'to-vfile';
import mdx from 'remark-mdx';
import remark from 'remark';
import dedent from 'dedent';
import npm2yarn from '../index';
const process = async (
content: any,
options?: Parameters<typeof npm2yarn>[0],
) => {
const {remark} = await import('remark');
const {default: mdx} = await import('remark-mdx');
const result = await remark()
.use(mdx)
.use(npm2yarn, options)
.process(content);
return result.value;
};
const processFixture = async (
name: string,
options?: Parameters<typeof npm2yarn>[0],
) => {
const filePath = path.join(__dirname, '__fixtures__', `${name}.md`);
const file = await vfile.read(filePath);
const result = await remark().use(mdx).use(npm2yarn, options).process(file);
return result.toString();
return process(file, options);
};
describe('npm2yarn plugin', () => {
it('works with simplest md', async () => {
const result = await process(dedent`
# Title
Hey
\`\`\`bash npm2yarn
npm install test
\`\`\`
`);
expect(result).toMatchSnapshot();
});
it('works on installation file', async () => {
const result = await processFixture('installation');
@ -65,6 +92,11 @@ describe('npm2yarn plugin', () => {
expect(result).toMatchSnapshot();
});
it('does not re-import tabs components real-world multiple npm2yarn usage', async () => {
const result = await processFixture('multiple');
expect(result).toMatchSnapshot();
});
it('work with yarn converter', async () => {
const result = await processFixture('plugin', {converters: ['yarn']});

View file

@ -7,95 +7,186 @@
import visit from 'unist-util-visit';
import npmToYarn from 'npm-to-yarn';
import type {Code, Content, Literal} from 'mdast';
import type {Plugin} from 'unified';
import type {Code, Literal} from 'mdast';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {MdxJsxFlowElement, MdxJsxAttribute} from 'mdast-util-mdx';
import type {Node, Parent} from 'unist';
// @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';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type Plugin<T> = any; // TODO fix this asap
type KnownConverter = 'yarn' | 'pnpm';
type CustomConverter = [name: string, cb: (npmCode: string) => string];
type Converter = CustomConverter | KnownConverter;
type PluginOptions = {
sync?: boolean;
converters?: (CustomConverter | 'yarn' | 'pnpm')[];
converters?: Converter[];
};
function createTabItem(
code: string,
node: Code,
value: string,
label?: string,
) {
return [
{
type: 'jsx',
value: `<TabItem value="${value}"${label ? ` label="${label}"` : ''}>`,
},
{
type: node.type,
lang: node.lang,
value: code,
},
{
type: 'jsx',
value: '</TabItem>',
},
] as Content[];
function createAttribute(
attributeName: string,
attributeValue: MdxJsxAttribute['value'],
): MdxJsxAttribute {
return {
type: 'mdxJsxAttribute',
name: attributeName,
value: attributeValue,
};
}
function createTabItem({
code,
node,
value,
label,
}: {
code: string;
node: Code;
value: string;
label?: string;
}): MdxJsxFlowElement {
return {
type: 'mdxJsxFlowElement',
name: 'TabItem',
attributes: [
createAttribute('value', value),
label && createAttribute('label', label),
].filter((attr): attr is MdxJsxAttribute => Boolean(attr)),
children: [
{
type: node.type,
lang: node.lang,
value: code,
},
],
};
}
const transformNode = (
node: Code,
isSync: boolean,
converters: (CustomConverter | 'yarn' | 'pnpm')[],
converters: Converter[],
) => {
const groupIdProp = isSync ? ' groupId="npm2yarn"' : '';
const groupIdProp = isSync
? {
type: 'mdxJsxAttribute',
name: 'groupId',
value: 'npm2yarn',
}
: undefined;
const npmCode = node.value;
function createConvertedTabItem(converter: Converter) {
if (typeof converter === 'string') {
return createTabItem({
code: npmToYarn(npmCode, converter),
node,
value: converter,
label: converter === 'yarn' ? 'Yarn' : converter,
});
}
const [converterName, converterFn] = converter;
return createTabItem({
code: converterFn(npmCode),
node,
value: converterName,
});
}
return [
{
type: 'jsx',
value: `<Tabs${groupIdProp}>`,
type: 'mdxJsxFlowElement',
name: 'Tabs',
attributes: [groupIdProp].filter(Boolean),
children: [
createTabItem({code: npmCode, node, value: 'npm'}),
...converters.flatMap(createConvertedTabItem),
],
},
...createTabItem(npmCode, node, 'npm'),
...converters.flatMap((converter) =>
typeof converter === 'string'
? createTabItem(
npmToYarn(npmCode, converter),
node,
converter,
converter === 'yarn' ? 'Yarn' : converter,
)
: createTabItem(converter[1](npmCode), node, converter[0]),
),
{
type: 'jsx',
value: '</Tabs>',
},
] as Content[];
] as any[];
};
const isImport = (node: Node): node is Literal => node.type === 'import';
const isMdxEsmLiteral = (node: Node): node is Literal =>
node.type === 'mdxjsEsm';
// TODO legacy approximation, good-enough for now but not 100% accurate
const isTabsImport = (node: Node): boolean =>
isMdxEsmLiteral(node) && node.value.includes('@theme/Tabs');
const isParent = (node: Node): node is Parent =>
Array.isArray((node as Parent).children);
const matchNode = (node: Node): node is Code =>
const isNpm2Yarn = (node: Node): node is Code =>
node.type === 'code' && (node as Code).meta === 'npm2yarn';
const nodeForImport: Literal = {
type: 'import',
value:
"import Tabs from '@theme/Tabs';\nimport TabItem from '@theme/TabItem';",
};
const plugin: Plugin<[PluginOptions?]> = (options = {}) => {
function createImportNode() {
return {
type: 'mdxjsEsm',
value:
"import Tabs from '@theme/Tabs'\nimport TabItem from '@theme/TabItem'",
data: {
estree: {
type: 'Program',
body: [
{
type: 'ImportDeclaration',
specifiers: [
{
type: 'ImportDefaultSpecifier',
local: {type: 'Identifier', name: 'Tabs'},
},
],
source: {
type: 'Literal',
value: '@theme/Tabs',
raw: "'@theme/Tabs'",
},
},
{
type: 'ImportDeclaration',
specifiers: [
{
type: 'ImportDefaultSpecifier',
local: {type: 'Identifier', name: 'TabItem'},
},
],
source: {
type: 'Literal',
value: '@theme/TabItem',
raw: "'@theme/TabItem'",
},
},
],
sourceType: 'module',
},
},
};
}
const plugin: Plugin<[PluginOptions?]> = (options = {}): Transformer => {
// @ts-expect-error: todo temporary
const {sync = false, converters = ['yarn', 'pnpm']} = options;
return (root) => {
let transformed = false as boolean;
let alreadyImported = false as boolean;
let transformed = false;
let alreadyImported = false;
visit(root, (node: Node) => {
if (isImport(node) && node.value.includes('@theme/Tabs')) {
if (isTabsImport(node)) {
alreadyImported = true;
}
if (isParent(node)) {
let index = 0;
while (index < node.children.length) {
const child = node.children[index]!;
if (matchNode(child)) {
if (isNpm2Yarn(child)) {
const result = transformNode(child, sync, converters);
node.children.splice(index, 1, ...result);
index += result.length;
@ -106,8 +197,9 @@ const plugin: Plugin<[PluginOptions?]> = (options = {}) => {
}
}
});
if (transformed && !alreadyImported) {
(root as Parent).children.unshift(nodeForImport);
(root as Parent).children.unshift(createImportNode());
}
};
};

View file

@ -32,7 +32,7 @@
"@docusaurus/utils": "^3.0.0-alpha.0",
"@docusaurus/utils-common": "^3.0.0-alpha.0",
"@docusaurus/utils-validation": "^3.0.0-alpha.0",
"@mdx-js/react": "^1.6.22",
"@mdx-js/react": "^2.1.5",
"clsx": "^1.2.1",
"copy-text-to-clipboard": "^3.0.1",
"infima": "0.2.0-alpha.43",

View file

@ -312,14 +312,6 @@ export default function getSwizzleConfig(): SwizzleConfig {
},
description: 'The component used to render <details> tags in MDX',
},
'MDXComponents/Head': {
actions: {
eject: 'forbidden',
wrap: 'forbidden',
},
description:
'Technical component used to assign metadata (generally for SEO purpose) to the current MDX document',
},
'MDXComponents/Heading': {
actions: {
eject: 'safe',

View file

@ -861,14 +861,6 @@ declare module '@theme/MDXComponents/Img' {
export default function MDXImg(props: Props): JSX.Element;
}
declare module '@theme/MDXComponents/Head' {
import type {ComponentProps} from 'react';
export interface Props extends ComponentProps<'head'> {}
export default function MDXHead(props: Props): JSX.Element;
}
declare module '@theme/MDXComponents/Heading' {
import type {ComponentProps} from 'react';
import type Heading from '@theme/Heading';
@ -889,7 +881,6 @@ declare module '@theme/MDXComponents/Pre' {
declare module '@theme/MDXComponents' {
import type {ComponentType, ComponentProps} from 'react';
import type MDXHead from '@theme/MDXComponents/Head';
import type MDXCode from '@theme/MDXComponents/Code';
import type MDXA from '@theme/MDXComponents/A';
import type MDXPre from '@theme/MDXComponents/Pre';
@ -898,13 +889,14 @@ declare module '@theme/MDXComponents' {
import type MDXImg from '@theme/MDXComponents/Img';
import type Admonition from '@theme/Admonition';
import type Mermaid from '@theme/Mermaid';
import type Head from '@docusaurus/Head';
export type MDXComponentsObject = {
readonly head: typeof MDXHead;
readonly Head: typeof Head;
readonly Details: typeof MDXDetails;
readonly code: typeof MDXCode;
readonly a: typeof MDXA;
readonly pre: typeof MDXPre;
readonly details: typeof MDXDetails;
readonly ul: typeof MDXUl;
readonly img: typeof MDXImg;
readonly h1: (props: ComponentProps<'h1'>) => JSX.Element;

View file

@ -6,50 +6,13 @@
*/
import type {ComponentProps} from 'react';
import React, {isValidElement} from 'react';
import React from 'react';
import CodeBlock from '@theme/CodeBlock';
import type {Props} from '@theme/MDXComponents/Code';
export default function MDXCode(props: Props): JSX.Element {
const inlineElements: (string | undefined)[] = [
'a',
'abbr',
'b',
'br',
'button',
'cite',
'code',
'del',
'dfn',
'em',
'i',
'img',
'input',
'ins',
'kbd',
'label',
'object',
'output',
'q',
'ruby',
's',
'small',
'span',
'strong',
'sub',
'sup',
'time',
'u',
'var',
'wbr',
];
const shouldBeInline = React.Children.toArray(props.children).every(
(el) =>
(typeof el === 'string' && !el.includes('\n')) ||
(isValidElement(el) &&
inlineElements.includes(
(el.props as {mdxType: string} | null)?.mdxType,
)),
(el) => typeof el === 'string' && !el.includes('\n'),
);
return shouldBeInline ? (

View file

@ -15,8 +15,7 @@ export default function MDXDetails(props: Props): JSX.Element {
// Details theme component
const summary = items.find(
(item): item is ReactElement<ComponentProps<'summary'>> =>
React.isValidElement(item) &&
(item.props as {mdxType: string} | null)?.mdxType === 'summary',
React.isValidElement(item) && item.type === 'summary',
);
const children = <>{items.filter((item) => item !== summary)}</>;

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.
*/
import React, {type ReactElement} from 'react';
import Head, {type Props as HeadProps} from '@docusaurus/Head';
import type {Props} from '@theme/MDXComponents/Head';
type MDXElement = ReactElement<
{mdxType?: string; originalType?: string} | undefined
>;
// MDX elements are wrapped through the MDX pragma. In some cases (notably usage
// with Head/Helmet) we need to unwrap those elements.
function unwrapMDXElement(element: MDXElement) {
if (element.props?.mdxType && element.props.originalType) {
const {mdxType, originalType, ...newProps} = element.props;
return React.createElement(element.props.originalType, newProps);
}
return element;
}
export default function MDXHead(props: Props): JSX.Element {
const unwrappedChildren = React.Children.map(props.children, (child) =>
React.isValidElement(child) ? unwrapMDXElement(child as MDXElement) : child,
);
return <Head {...(props as HeadProps)}>{unwrappedChildren}</Head>;
}

View file

@ -5,19 +5,11 @@
* LICENSE file in the root directory of this source tree.
*/
import React, {isValidElement} from 'react';
import CodeBlock from '@theme/CodeBlock';
import React, {type ReactNode} from 'react';
import type {Props} from '@theme/MDXComponents/Pre';
export default function MDXPre(props: Props): JSX.Element {
return (
<CodeBlock
// If this pre is created by a ``` fenced codeblock, unwrap the children
{...(isValidElement(props.children) &&
(props.children.props as {originalType: string} | null)?.originalType ===
'code'
? props.children.props
: {...props})}
/>
);
export default function MDXPre(props: Props): ReactNode | undefined {
// With MDX 2, this element is only used for fenced code blocks
// It always receives a MDXComponents/Code as children
return <>{props.children}</>;
}

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import MDXHead from '@theme/MDXComponents/Head';
import Head from '@docusaurus/Head';
import MDXCode from '@theme/MDXComponents/Code';
import MDXA from '@theme/MDXComponents/A';
import MDXPre from '@theme/MDXComponents/Pre';
@ -20,11 +20,11 @@ import Mermaid from '@theme/Mermaid';
import type {MDXComponentsObject} from '@theme/MDXComponents';
const MDXComponents: MDXComponentsObject = {
head: MDXHead,
Head,
Details: MDXDetails,
code: MDXCode,
a: MDXA,
pre: MDXPre,
details: MDXDetails,
ul: MDXUl,
img: MDXImg,
h1: (props) => <MDXHeading as="h1" {...props} />,

View file

@ -6,6 +6,7 @@
*/
import React from 'react';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import {MDXProvider} from '@mdx-js/react';
import MDXComponents from '@theme/MDXComponents';
import type {Props} from '@theme/MDXContent';

View file

@ -10,6 +10,7 @@ import clsx from 'clsx';
import {
useScrollPositionBlocker,
useTabs,
sanitizeTabsChildren,
type TabItemProps,
} from '@docusaurus/theme-common/internal';
import useIsBrowser from '@docusaurus/useIsBrowser';
@ -152,7 +153,8 @@ export default function Tabs(props: Props): JSX.Element {
// Remount tabs after hydration
// Temporary fix for https://github.com/facebook/docusaurus/issues/5653
key={String(isBrowser)}
{...props}
/>
{...props}>
{sanitizeTabsChildren(props.children)}
</TabsComponent>
);
}

View file

@ -57,6 +57,10 @@ CSS variables, meant to be overridden by final theme
padding-top: 1rem;
}
.collapsibleContent > *:last-child {
.collapsibleContent p:last-child {
margin-bottom: 0;
}
.details > summary > p:last-child {
margin-bottom: 0;
}

View file

@ -42,7 +42,7 @@ export {
useAnnouncementBar,
} from './contexts/announcementBar';
export {useTabs} from './utils/tabsUtils';
export {useTabs, sanitizeTabsChildren} from './utils/tabsUtils';
export type {TabValue, TabsProps, TabItemProps} from './utils/tabsUtils';
export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar';

View file

@ -14,15 +14,16 @@ function extractMDXAdmonitionTitle(children: ReactNode): {
rest: ReactNode;
} {
const items = React.Children.toArray(children);
const mdxAdmonitionTitle = items.find(
(item) =>
React.isValidElement(item) &&
(item.props as {mdxType: string} | null)?.mdxType ===
'mdxAdmonitionTitle',
const mdxAdmonitionTitleWrapper = items.find(
(item) => React.isValidElement(item) && item.type === 'mdxAdmonitionTitle',
) as JSX.Element | undefined;
const rest = items.filter((item) => item !== mdxAdmonitionTitle);
const rest = items.filter((item) => item !== mdxAdmonitionTitleWrapper);
const mdxAdmonitionTitle = mdxAdmonitionTitleWrapper?.props.children;
return {
mdxAdmonitionTitle: mdxAdmonitionTitle?.props.children,
mdxAdmonitionTitle,
rest: rest.length > 0 ? <>{rest}</> : null,
};
}

View file

@ -61,25 +61,27 @@ function isTabItem(
return !!props && typeof props === 'object' && 'value' in props;
}
function ensureValidChildren(children: TabsProps['children']) {
return (React.Children.map(children, (child) => {
// Pass falsy values through: allow conditionally not rendering a tab
if (!child || (isValidElement(child) && isTabItem(child))) {
return child;
}
// child.type.name will give non-sensical values in prod because of
// minification, but we assume it won't throw in prod.
throw new Error(
`Docusaurus error: Bad <Tabs> child <${
// @ts-expect-error: guarding against unexpected cases
typeof child.type === 'string' ? child.type : child.type.name
}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique "value" prop.`,
);
})?.filter(Boolean) ?? []) as ReactElement<TabItemProps>[];
export function sanitizeTabsChildren(children: TabsProps['children']) {
return (React.Children.toArray(children)
.filter((child) => child !== '\n')
.map((child) => {
if (!child || (isValidElement(child) && isTabItem(child))) {
return child;
}
// child.type.name will give non-sensical values in prod because of
// minification, but we assume it won't throw in prod.
throw new Error(
`Docusaurus error: Bad <Tabs> child <${
// @ts-expect-error: guarding against unexpected cases
typeof child.type === 'string' ? child.type : child.type.name
}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique "value" prop.`,
);
})
?.filter(Boolean) ?? []) as ReactElement<TabItemProps>[];
}
function extractChildrenTabValues(children: TabsProps['children']): TabValue[] {
return ensureValidChildren(children).map(
return sanitizeTabsChildren(children).map(
({props: {value, label, attributes, default: isDefault}}) => ({
value,
label,

View file

@ -38,7 +38,6 @@
"@docusaurus/theme-common": "^3.0.0-alpha.0",
"@docusaurus/types": "^3.0.0-alpha.0",
"@docusaurus/utils-validation": "^3.0.0-alpha.0",
"@mdx-js/react": "^1.6.22",
"mermaid": "^9.4.3",
"tslib": "^2.5.0"
},

View file

@ -27,7 +27,25 @@ export type MarkdownConfig = {
* @see https://docusaurus.io/docs/markdown-features/diagrams/
* @default false
*/
mermaid?: boolean;
mermaid: boolean;
/**
* Gives opportunity to preprocess the MDX string content before compiling.
* A good escape hatch that can be used to handle edge cases.
*
* @param args
*/
preprocessor?: (args: {filePath: string; fileContent: string}) => string;
/**
* Set of flags make it easier to upgrade from MDX 1 to MDX 2
* See also https://github.com/facebook/docusaurus/issues/4029
*/
mdx1Compat: {
comments: boolean;
admonitions: boolean;
headingIds: boolean;
};
};
/**

View file

@ -2,27 +2,19 @@
exports[`validation schemas admonitionsSchema: for value=[] 1`] = `""value" does not look like a valid admonitions config"`;
exports[`validation schemas admonitionsSchema: for value={"customTypes":{"myKeyword":{"keyword":"myKeyword","infima":true,"svg":"<svg width=\\"512px\\" height=\\"512px\\" viewBox=\\"0 0 512 512\\" xmlns=\\"http://www.w3.org/2000/svg\\"></svg>"}}} 1`] = `
"The Docusaurus admonitions system has changed, and the option "customTypes" does not exist anymore.
You now need to swizzle the admonitions component to provide UI customizations such as icons.
Please refer to https://github.com/facebook/docusaurus/pull/7152 for detailed upgrade instructions."
`;
exports[`validation schemas admonitionsSchema: for value={"customTypes":{"myKeyword":{"keyword":"myKeyword","infima":true,"svg":"<svg width=\\"512px\\" height=\\"512px\\" viewBox=\\"0 0 512 512\\" xmlns=\\"http://www.w3.org/2000/svg\\"></svg>"}}} 1`] = `""customTypes" is not allowed"`;
exports[`validation schemas admonitionsSchema: for value={"icons":"emoji"} 1`] = `
"The Docusaurus admonitions system has changed, and the option "icons" does not exist anymore.
You now need to swizzle the admonitions component to provide UI customizations such as icons.
Please refer to https://github.com/facebook/docusaurus/pull/7152 for detailed upgrade instructions."
`;
exports[`validation schemas admonitionsSchema: for value={"icons":"emoji"} 1`] = `""icons" is not allowed"`;
exports[`validation schemas admonitionsSchema: for value={"infima":true} 1`] = `
"The Docusaurus admonitions system has changed, and the option "infima" does not exist anymore.
You now need to swizzle the admonitions component to provide UI customizations such as icons.
Please refer to https://github.com/facebook/docusaurus/pull/7152 for detailed upgrade instructions."
`;
exports[`validation schemas admonitionsSchema: for value={"infima":true} 1`] = `""infima" is not allowed"`;
exports[`validation schemas admonitionsSchema: for value={"keywords":["custom-keyword"],"extendDefaults":42} 1`] = `""extendDefaults" must be a boolean"`;
exports[`validation schemas admonitionsSchema: for value={"tag":""} 1`] = `""tag" is not allowed to be empty"`;
exports[`validation schemas admonitionsSchema: for value={"tag":""} 1`] = `"It is not possible anymore to use a custom admonition tag. The only admonition tag supported is ':::' (Markdown Directive syntax)"`;
exports[`validation schemas admonitionsSchema: for value={"tag":"+++","keywords":["info","tip"]} 1`] = `"It is not possible anymore to use a custom admonition tag. The only admonition tag supported is ':::' (Markdown Directive syntax)"`;
exports[`validation schemas admonitionsSchema: for value={"tag":"+++"} 1`] = `"It is not possible anymore to use a custom admonition tag. The only admonition tag supported is ':::' (Markdown Directive syntax)"`;
exports[`validation schemas admonitionsSchema: for value={"unknownAttribute":"val"} 1`] = `""unknownAttribute" is not allowed"`;

View file

@ -97,22 +97,22 @@ describe('validation schemas', () => {
testOK(true);
testOK(false);
testOK({});
testOK({tag: '+++'});
testOK({keywords: ['info', 'tip']});
testOK({keywords: ['info', 'tip'], extendDefaults: true});
testOK({keywords: ['info', 'tip'], extendDefaults: false});
testOK({keywords: []});
testOK({keywords: [], extendDefaults: true}); // noop
testOK({keywords: [], extendDefaults: false}); // disable admonitions
testOK({tag: '+++', keywords: ['info', 'tip']});
testOK({tag: '+++', keywords: ['custom-keyword'], extendDefaults: true});
testOK({tag: '+++', keywords: ['custom-keyword'], extendDefaults: false});
testOK({keywords: ['custom-keyword'], extendDefaults: true});
testOK({keywords: ['custom-keyword'], extendDefaults: false});
testFail(3);
testFail([]);
testFail({unknownAttribute: 'val'});
testFail({tag: ''});
testFail({keywords: ['custom-keyword'], extendDefaults: 42});
testFail({tag: '+++'});
testFail({tag: '+++', keywords: ['info', 'tip']});
// Legacy types
testFail({

View file

@ -37,17 +37,10 @@ const MarkdownPluginsSchema = Joi.array()
export const RemarkPluginsSchema = MarkdownPluginsSchema;
export const RehypePluginsSchema = MarkdownPluginsSchema;
const LegacyAdmonitionConfigSchema = Joi.forbidden().messages({
'any.unknown': `The Docusaurus admonitions system has changed, and the option {#label} does not exist anymore.
You now need to swizzle the admonitions component to provide UI customizations such as icons.
Please refer to https://github.com/facebook/docusaurus/pull/7152 for detailed upgrade instructions.`,
});
export const AdmonitionsSchema = JoiFrontMatter.alternatives()
.try(
JoiFrontMatter.boolean().required(),
JoiFrontMatter.object({
tag: JoiFrontMatter.string(),
keywords: JoiFrontMatter.array().items(
JoiFrontMatter.string(),
// Apparently this is how we tell job to accept empty arrays...
@ -55,10 +48,10 @@ export const AdmonitionsSchema = JoiFrontMatter.alternatives()
),
extendDefaults: JoiFrontMatter.boolean(),
// TODO Remove before 2023
customTypes: LegacyAdmonitionConfigSchema,
icons: LegacyAdmonitionConfigSchema,
infima: LegacyAdmonitionConfigSchema,
// TODO Remove before 2024
tag: Joi.any().forbidden().messages({
'any.unknown': `It is not possible anymore to use a custom admonition tag. The only admonition tag supported is ':::' (Markdown Directive syntax)`,
}),
}).required(),
)
.default(true)

View file

@ -8,6 +8,7 @@
import logger from '@docusaurus/logger';
import Yaml from 'js-yaml';
import {PluginIdSchema} from './validationSchemas';
import type {ValidationOptions} from 'joi';
import type Joi from './Joi';
/** Print warnings returned from Joi validation. */
@ -77,13 +78,15 @@ export function normalizeThemeConfig<T>(
* Validate front matter with better error message
*/
export function validateFrontMatter<T>(
frontMatter: {[key: string]: unknown},
frontMatter: unknown,
schema: Joi.ObjectSchema<T>,
options?: ValidationOptions,
): T {
const {value, error, warning} = schema.validate(frontMatter, {
convert: true,
allowUnknown: true,
abortEarly: false,
...options,
});
printWarning(warning);

View file

@ -12,6 +12,9 @@ import {
parseMarkdownString,
parseMarkdownHeadingId,
writeMarkdownHeadingId,
escapeMarkdownHeadingIds,
unwrapMdxCodeBlocks,
admonitionTitleToDirectiveLabel,
} from '../markdownUtils';
describe('createExcerpt', () => {
@ -916,6 +919,556 @@ describe('parseMarkdownHeadingId', () => {
});
});
describe('escapeMarkdownHeadingIds', () => {
it('can escape simple heading id', () => {
expect(escapeMarkdownHeadingIds('# title 1 {#id-1}')).toBe(
'# title 1 \\{#id-1}',
);
expect(escapeMarkdownHeadingIds('# title 1 {#id-1}')).toBe(
'# title 1 \\{#id-1}',
);
expect(escapeMarkdownHeadingIds('# title 1{#id-1}')).toBe(
'# title 1\\{#id-1}',
);
expect(escapeMarkdownHeadingIds('# title 1 \\{#id-1}')).toBe(
'# title 1 \\{#id-1}',
);
expect(escapeMarkdownHeadingIds('# title 1\\{#id-1}')).toBe(
'# title 1\\{#id-1}',
);
});
it('can escape level 1-6 heading ids', () => {
expect(
escapeMarkdownHeadingIds(dedent`
# title 1 {#id-1}
## title 2 {#id-2}
### title 3 {#id-3}
#### title 4 {#id-4}
##### title 5 {#id-5}
###### title 6 {#id-6}
`),
).toEqual(dedent`
# title 1 \{#id-1}
## title 2 \{#id-2}
### title 3 \{#id-3}
#### title 4 \{#id-4}
##### title 5 \{#id-5}
###### title 6 \{#id-6}
`);
});
it('does not escape level 7 heading id', () => {
expect(
escapeMarkdownHeadingIds(dedent`
####### title 7 {#id-7}
`),
).toEqual(dedent`
####### title 7 {#id-7}
`);
});
it('does not escape non-heading', () => {
expect(
escapeMarkdownHeadingIds(dedent`
some text {#non-id}
`),
).toEqual(dedent`
some text {#non-id}
`);
});
it('works for realistic example', () => {
expect(
escapeMarkdownHeadingIds(dedent`
# Support
Docusaurus has a community of thousands of developers.
On this page we've listed some Docusaurus-related communities that you can be a part of; see the other pages in this section for additional online and in-person learning materials.
Before participating in Docusaurus' communities, [please read our Code of Conduct](https://engineering.fb.com/codeofconduct/). We have adopted the [Contributor Covenant](https://www.contributor-covenant.org/) and we expect that all community members adhere to the guidelines within.
## Stack Overflow {#stack-overflow}
Stack Overflow is a popular forum to ask code-level questions or if you're stuck with a specific error. Read through the [existing questions](https://stackoverflow.com/questions/tagged/docusaurus) tagged with **docusaurus** or [ask your own](https://stackoverflow.com/questions/ask?tags=docusaurus)!
## Discussion forums \{#discussion-forums}
There are many online forums for discussion about best practices and application architecture as well as the future of Docusaurus. If you have an answerable code-level question, Stack Overflow is usually a better fit.
- [Docusaurus online chat](https://discord.gg/docusaurus)
- [#help-and-questions](https://discord.gg/fwbcrQ3dHR) for user help
- [#contributors](https://discord.gg/6g6ASPA) for contributing help
- [Reddit's Docusaurus community](https://www.reddit.com/r/docusaurus/)
## Feature requests {#feature-requests}
For new feature requests, you can create a post on our [feature requests board (Canny)](/feature-requests), which is a handy tool for road-mapping and allows for sorting by upvotes, which gives the core team a better indicator of what features are in high demand, as compared to GitHub issues which are harder to triage. Refrain from making a Pull Request for new features (especially large ones) as someone might already be working on it or will be part of our roadmap. Talk to us first!
## News {#news}
For the latest news about Docusaurus, [follow **@docusaurus** on Twitter](https://twitter.com/docusaurus) and the [official Docusaurus blog](/blog) on this website.
`),
).toEqual(dedent`
# Support
Docusaurus has a community of thousands of developers.
On this page we've listed some Docusaurus-related communities that you can be a part of; see the other pages in this section for additional online and in-person learning materials.
Before participating in Docusaurus' communities, [please read our Code of Conduct](https://engineering.fb.com/codeofconduct/). We have adopted the [Contributor Covenant](https://www.contributor-covenant.org/) and we expect that all community members adhere to the guidelines within.
## Stack Overflow \{#stack-overflow}
Stack Overflow is a popular forum to ask code-level questions or if you're stuck with a specific error. Read through the [existing questions](https://stackoverflow.com/questions/tagged/docusaurus) tagged with **docusaurus** or [ask your own](https://stackoverflow.com/questions/ask?tags=docusaurus)!
## Discussion forums \{#discussion-forums}
There are many online forums for discussion about best practices and application architecture as well as the future of Docusaurus. If you have an answerable code-level question, Stack Overflow is usually a better fit.
- [Docusaurus online chat](https://discord.gg/docusaurus)
- [#help-and-questions](https://discord.gg/fwbcrQ3dHR) for user help
- [#contributors](https://discord.gg/6g6ASPA) for contributing help
- [Reddit's Docusaurus community](https://www.reddit.com/r/docusaurus/)
## Feature requests \{#feature-requests}
For new feature requests, you can create a post on our [feature requests board (Canny)](/feature-requests), which is a handy tool for road-mapping and allows for sorting by upvotes, which gives the core team a better indicator of what features are in high demand, as compared to GitHub issues which are harder to triage. Refrain from making a Pull Request for new features (especially large ones) as someone might already be working on it or will be part of our roadmap. Talk to us first!
## News \{#news}
For the latest news about Docusaurus, [follow **@docusaurus** on Twitter](https://twitter.com/docusaurus) and the [official Docusaurus blog](/blog) on this website.
`);
});
});
describe('unwrapMdxCodeBlocks', () => {
it('can unwrap a simple mdx code block', () => {
expect(
unwrapMdxCodeBlocks(dedent`
# Title
\`\`\`mdx-code-block
import Comp, {User} from "@site/components/comp"
<Comp prop="test">
<User user={{firstName: "Sébastien"}} />
</Comp>
export const age = 36
\`\`\`
text
`),
).toEqual(dedent`
# Title
import Comp, {User} from "@site/components/comp"
<Comp prop="test">
<User user={{firstName: "Sébastien"}} />
</Comp>
export const age = 36
text
`);
});
it('can unwrap a nested mdx code block', () => {
expect(
unwrapMdxCodeBlocks(dedent`
# Title
\`\`\`\`mdx-code-block
some content
\`\`\`js
export const age = 36
\`\`\`
\`\`\`\`
text
`),
).toEqual(dedent`
# Title
some content
\`\`\`js
export const age = 36
\`\`\`
text
`);
});
it('works for realistic example', () => {
expect(
unwrapMdxCodeBlocks(dedent`
# Canary releases
\`\`\`mdx-code-block
import {
VersionsProvider,
} from "@site/src/components/Versions";
<VersionsProvider prop={{attr: 42}} test="yes">
\`\`\`
Docusaurus has a canary releases system.
It permits you to **test new unreleased features** as soon as the pull requests are merged on the [next version](./5-release-process.md#next-version) of Docusaurus.
It is a good way to **give feedback to maintainers**, ensuring the newly implemented feature works as intended.
:::note
Using a canary release in production might seem risky, but in practice, it's not.
A canary release passes all automated tests and is used in production by the Docusaurus site itself.
\`\`\`mdx-code-block
</VersionsProvider>
\`\`\`
`),
).toEqual(dedent`
# Canary releases
import {
VersionsProvider,
} from "@site/src/components/Versions";
<VersionsProvider prop={{attr: 42}} test="yes">
Docusaurus has a canary releases system.
It permits you to **test new unreleased features** as soon as the pull requests are merged on the [next version](./5-release-process.md#next-version) of Docusaurus.
It is a good way to **give feedback to maintainers**, ensuring the newly implemented feature works as intended.
:::note
Using a canary release in production might seem risky, but in practice, it's not.
A canary release passes all automated tests and is used in production by the Docusaurus site itself.
</VersionsProvider>
`);
});
});
describe('admonitionTitleToDirectiveLabel', () => {
const directives = ['info', 'note', 'tip', 'caution'];
it('does not transform markdown without any admonition', () => {
expect(
admonitionTitleToDirectiveLabel(
dedent`
# Title
intro
## Sub Title
content
`,
directives,
),
).toEqual(dedent`
# Title
intro
## Sub Title
content
`);
});
it('transform simple admonition', () => {
expect(
admonitionTitleToDirectiveLabel(
dedent`
before
:::note Title
content
:::
after
`,
directives,
),
).toEqual(dedent`
before
:::note[Title]
content
:::
after
`);
});
it('does not transform already transformed admonition', () => {
expect(
admonitionTitleToDirectiveLabel(
dedent`
before
:::note[Title]
content
:::
after
`,
directives,
),
).toEqual(dedent`
before
:::note[Title]
content
:::
after
`);
});
it('does not transform non-container directives', () => {
expect(
admonitionTitleToDirectiveLabel(
dedent`
before
::note Title
content
:::
after
`,
directives,
),
).toEqual(dedent`
before
::note Title
content
:::
after
`);
});
it('does not transform left-padded directives', () => {
expect(
admonitionTitleToDirectiveLabel(
dedent`
before
:::note Title
content
:::
after
`,
directives,
),
).toEqual(dedent`
before
:::note Title
content
:::
after
`);
});
it('does not transform admonition without title', () => {
expect(
admonitionTitleToDirectiveLabel(
dedent`
before
:::note
content
:::
after
`,
directives,
),
).toEqual(dedent`
before
:::note
content
:::
after
`);
});
it('does not transform non-admonition directive', () => {
expect(
admonitionTitleToDirectiveLabel(
dedent`
before
:::whatever Title
content
:::
after
`,
directives,
),
).toEqual(dedent`
before
:::whatever Title
content
:::
after
`);
});
it('transform real-world nested messy admonitions', () => {
expect(
admonitionTitleToDirectiveLabel(
dedent`
---
title: "contains :::note"
---
# Title
intro
::::note note **title**
note content
::::tip tip <span>title</span>
tip content
:::whatever whatever title
whatever content
:::
::::
:::::
## Heading {#my-heading}
::::info weird spaced title
into content
:::tip[tip directiveLabel]
tip content
::::
## Conclusion
end
`,
directives,
),
).toEqual(dedent`
---
title: "contains :::note"
---
# Title
intro
::::note[note **title**]
note content
::::tip[tip <span>title</span>]
tip content
:::whatever whatever title
whatever content
:::
::::
:::::
## Heading {#my-heading}
::::info[weird spaced title]
into content
:::tip[tip directiveLabel]
tip content
::::
## Conclusion
end
`);
});
});
describe('writeMarkdownHeadingId', () => {
it('works for simple level-2 heading', () => {
expect(writeMarkdownHeadingId('## ABC')).toBe('## ABC {#abc}');

View file

@ -66,6 +66,9 @@ export {
} from './tags';
export {
parseMarkdownHeadingId,
escapeMarkdownHeadingIds,
unwrapMdxCodeBlocks,
admonitionTitleToDirectiveLabel,
createExcerpt,
parseFrontMatter,
parseMarkdownContentTitle,

View file

@ -39,6 +39,75 @@ export function parseMarkdownHeadingId(heading: string): {
return {text: heading, id: undefined};
}
/**
* MDX 2 requires escaping { with a \ so our anchor syntax need that now.
* See https://mdxjs.com/docs/troubleshooting-mdx/#could-not-parse-expression-with-acorn-error
*/
export function escapeMarkdownHeadingIds(content: string): string {
const markdownHeadingRegexp = /(?:^|\n)#{1,6}(?!#).*/g;
return content.replaceAll(markdownHeadingRegexp, (substring) =>
// TODO probably not the most efficient impl...
substring
.replace('{#', '\\{#')
// prevent duplicate escaping
.replace('\\\\{#', '\\{#'),
);
}
/**
* Hacky temporary escape hatch for Crowdin bad MDX support
* See https://docusaurus.io/docs/i18n/crowdin#mdx
*
* TODO Titus suggested a clean solution based on ```mdx eval and Remark
* See https://github.com/mdx-js/mdx/issues/701#issuecomment-947030041
*
* @param content
*/
export function unwrapMdxCodeBlocks(content: string): string {
// We only support 3/4 backticks on purpose, should be good enough
const regexp3 =
/(?<begin>^|\n)```mdx-code-block\n(?<children>.*?)\n```(?<end>\n|$)/gs;
const regexp4 =
/(?<begin>^|\n)````mdx-code-block\n(?<children>.*?)\n````(?<end>\n|$)/gs;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const replacer = (substring: string, ...args: any[]) => {
const groups = args.at(-1);
return `${groups.begin}${groups.children}${groups.end}`;
};
return content.replaceAll(regexp3, replacer).replaceAll(regexp4, replacer);
}
/**
* Add support for our legacy ":::note Title" admonition syntax
* Not supported by https://github.com/remarkjs/remark-directive
* Syntax is transformed to ":::note[Title]" (container directive label)
* See https://talk.commonmark.org/t/generic-directives-plugins-syntax/444
*
* @param content
* @param admonitionContainerDirectives
*/
export function admonitionTitleToDirectiveLabel(
content: string,
admonitionContainerDirectives: string[],
): string {
// this will also process ":::note Title" inside docs code blocks
// good enough: we fixed older versions docs to not be affected
const directiveNameGroup = `(${admonitionContainerDirectives.join('|')})`;
const regexp = new RegExp(
`^(?<directive>:{3,}${directiveNameGroup}) +(?<title>.*)$`,
'gm',
);
return content.replaceAll(regexp, (substring, ...args: any[]) => {
const groups = args.at(-1);
return `${groups.directive}[${groups.title}]`;
});
}
// TODO: Find a better way to do so, possibly by compiling the Markdown content,
// stripping out HTML tags and obtaining the first line.
/**

View file

@ -17,7 +17,13 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = `
"path": "i18n",
},
"markdown": {
"mdx1Compat": {
"admonitions": true,
"comments": true,
"headingIds": true,
},
"mermaid": false,
"preprocessor": undefined,
},
"noIndex": false,
"onBrokenLinks": "throw",
@ -58,7 +64,13 @@ exports[`loadSiteConfig website with valid async config 1`] = `
"path": "i18n",
},
"markdown": {
"mdx1Compat": {
"admonitions": true,
"comments": true,
"headingIds": true,
},
"mermaid": false,
"preprocessor": undefined,
},
"noIndex": false,
"onBrokenLinks": "throw",
@ -101,7 +113,13 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = `
"path": "i18n",
},
"markdown": {
"mdx1Compat": {
"admonitions": true,
"comments": true,
"headingIds": true,
},
"mermaid": false,
"preprocessor": undefined,
},
"noIndex": false,
"onBrokenLinks": "throw",
@ -144,7 +162,13 @@ exports[`loadSiteConfig website with valid config creator function 1`] = `
"path": "i18n",
},
"markdown": {
"mdx1Compat": {
"admonitions": true,
"comments": true,
"headingIds": true,
},
"mermaid": false,
"preprocessor": undefined,
},
"noIndex": false,
"onBrokenLinks": "throw",
@ -190,7 +214,13 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = `
"path": "i18n",
},
"markdown": {
"mdx1Compat": {
"admonitions": true,
"comments": true,
"headingIds": true,
},
"mermaid": false,
"preprocessor": undefined,
},
"noIndex": false,
"onBrokenLinks": "throw",

View file

@ -91,7 +91,13 @@ exports[`load loads props for site with custom i18n path 1`] = `
"path": "i18n",
},
"markdown": {
"mdx1Compat": {
"admonitions": true,
"comments": true,
"headingIds": true,
},
"mermaid": false,
"preprocessor": undefined,
},
"noIndex": false,
"onBrokenLinks": "throw",

View file

@ -32,7 +32,7 @@ describe('normalizeConfig', () => {
});
it('accepts correctly defined config options', () => {
const userConfig = {
const userConfig: Config = {
...DEFAULT_CONFIG,
...baseConfig,
tagline: 'my awesome site',
@ -60,6 +60,12 @@ describe('normalizeConfig', () => {
],
markdown: {
mermaid: true,
preprocessor: ({fileContent}) => fileContent,
mdx1Compat: {
comments: true,
admonitions: false,
headingIds: true,
},
},
};
const normalizedConfig = normalizeConfig(userConfig);
@ -496,6 +502,12 @@ describe('markdown', () => {
it('accepts valid markdown object', () => {
const markdown: DocusaurusConfig['markdown'] = {
mermaid: true,
preprocessor: ({fileContent}) => fileContent,
mdx1Compat: {
comments: false,
admonitions: true,
headingIds: false,
},
};
expect(
normalizeConfig({
@ -504,6 +516,51 @@ describe('markdown', () => {
).toEqual(expect.objectContaining({markdown}));
});
it('accepts partial markdown object', () => {
const markdown: DeepPartial<DocusaurusConfig['markdown']> = {
mdx1Compat: {
admonitions: true,
headingIds: false,
},
};
expect(
normalizeConfig({
markdown,
}),
).toEqual(
expect.objectContaining({
markdown: {
...DEFAULT_CONFIG.markdown,
...markdown,
mdx1Compat: {
...DEFAULT_CONFIG.markdown.mdx1Compat,
...markdown.mdx1Compat,
},
},
}),
);
});
it('throw for preprocessor bad arity', () => {
expect(() =>
normalizeConfig({
markdown: {preprocessor: () => 'content'},
}),
).toThrowErrorMatchingInlineSnapshot(`
""markdown.preprocessor" must have an arity of 1
"
`);
expect(() =>
normalizeConfig({
// @ts-expect-error: types forbid this
markdown: {preprocessor: (arg1, arg2) => String(arg1) + String(arg2)},
}),
).toThrowErrorMatchingInlineSnapshot(`
""markdown.preprocessor" must have an arity of 1
"
`);
});
it('throw for null object', () => {
expect(() => {
normalizeConfig({

View file

@ -66,6 +66,12 @@ export const DEFAULT_CONFIG: Pick<
staticDirectories: [DEFAULT_STATIC_DIR_NAME],
markdown: {
mermaid: false,
preprocessor: undefined,
mdx1Compat: {
comments: true,
admonitions: true,
headingIds: true,
},
},
};
@ -271,6 +277,21 @@ export const ConfigSchema = Joi.object<DocusaurusConfig>({
}).optional(),
markdown: Joi.object({
mermaid: Joi.boolean().default(DEFAULT_CONFIG.markdown.mermaid),
preprocessor: Joi.function()
.arity(1)
.optional()
.default(() => DEFAULT_CONFIG.markdown.preprocessor),
mdx1Compat: Joi.object({
comments: Joi.boolean().default(
DEFAULT_CONFIG.markdown.mdx1Compat.comments,
),
admonitions: Joi.boolean().default(
DEFAULT_CONFIG.markdown.mdx1Compat.admonitions,
),
headingIds: Joi.boolean().default(
DEFAULT_CONFIG.markdown.mdx1Compat.headingIds,
),
}).default(DEFAULT_CONFIG.markdown.mdx1Compat),
}).default(DEFAULT_CONFIG.markdown),
}).messages({
'docusaurus.configValidationWarning':

View file

@ -40,7 +40,9 @@ export function formatStatsErrorMessage(
// Also the error causal chain is lost here
// We log the stacktrace inside serverEntry.tsx for now (not ideal)
const {errors} = formatWebpackMessages(statsJson);
return errors.join('\n---\n');
return errors
.map((str) => logger.red(str))
.join(`\n\n${logger.yellow('--------------------------')}\n\n`);
}
return undefined;
}

View file

@ -137,6 +137,7 @@ immer
infima
inlines
intelli
intellij
interactiveness
interpolatable
investec
@ -179,6 +180,7 @@ mdast
mdxa
mdxast
mdxhast
mdxjs
metadatum
metastring
metrica
@ -190,6 +192,9 @@ mkcert
mkdir
mkdirs
mkdocs
mkdn
mdwn
mkdown
moesif
msapplication
nabors
@ -389,6 +394,7 @@ wcag
webfactory
webp
webpackbar
webstorm
wolcott
writeups
xclip
@ -400,3 +406,4 @@ yangshunz
zhou
zoomable
zpao
hastscript

View file

@ -7,7 +7,7 @@ toc_max_heading_level: 4
tags: [paginated-tag]
---
<!-- truncate -->
{/* truncate */}
import Content, {
toc as ContentToc,

View file

@ -16,7 +16,7 @@ hide_reading_time: true
Some MDX tests, mostly to test how the RSS feed render those
<!-- truncate -->
{/* truncate */}
## Imports

View file

@ -15,7 +15,7 @@ tags:
Some MDX tests, mostly to test how the RSS feed render those
<!-- truncate -->
{/* truncate */}
Test MDX with require calls

View file

@ -6,7 +6,7 @@ tags: [paginated-tag, blog, docusaurus]
Did you know you can use multiple instances of the same plugin?
<!--truncate-->
{/* truncate */}
:::tip

View file

@ -16,24 +16,22 @@
## Large font icon
import Admonition from '@theme/Admonition';
```mdx-code-block
<admonition
<Admonition
type="tip"
icon={<span style={{fontSize: 60}}>💡</span>}
title="Did you know...">
<p>
content
</p>
</admonition>
</Admonition>
<admonition
<Admonition
type="info"
icon={<span style={{fontSize: 40}}></span>}
title="Did you know...">
<p>
content
</p>
</admonition>
</Admonition>
```
## Large svg icon
@ -41,21 +39,17 @@
```mdx-code-block
import InfoIcon from "@theme/Admonition/Icon/Info"
<admonition
<Admonition
type="tip"
icon={<InfoIcon style={{width: 60, height: 60}}/>}
title="Did you know...">
<p>
content
</p>
</admonition>
</Admonition>
<admonition
<Admonition
type="info"
icon={<InfoIcon style={{width: 40, height: 40}}/>}
title="Did you know...">
<p>
content
</p>
</admonition>
</Admonition>
```

View file

@ -5,6 +5,14 @@ import TabItem from '@theme/TabItem';
# Code block tests
:::danger legacy test page - MDX v1
This test page is quite outdated: MDX v2 lowercase tags are not substituted anymore in the same way as they were in v1.
:::
---
```java
class HelloWorld {
public static void main(String args[]) {
@ -27,7 +35,7 @@ Multi-line text inside `pre` will turn into one-liner, but it's okay (https://gi
<pre>1 2 3</pre>
<!-- prettier-ignore -->
{/* prettier-ignore */}
<pre>
1
2

View file

@ -18,6 +18,11 @@ import Readme from "../README.mdx"
<Readme />
```
### Markdown tests
- [Markdown tests MDX](/tests/pages/markdown-tests-mdx)
- [Markdown tests MD](/tests/pages/markdown-tests-md)
### Other tests
- [Crash test](/tests/pages/crashTest)
@ -25,8 +30,6 @@ import Readme from "../README.mdx"
- [Link tests](/tests/pages/link-tests)
- [Error boundary tests](/tests/pages/error-boundary-tests)
- [Hydration tests](/tests/pages/hydration-tests)
- [Asset linking tests](/tests/pages/markdown-tests)
- [General Markdown tests](/tests/pages/markdownPageTests)
- [TOC tests](/tests/pages/page-toc-tests)
- [Diagram tests](/tests/pages/diagrams)
- [Tabs tests](/tests/pages/tabs-tests)

View file

@ -0,0 +1,44 @@
---
title: Markdown Page tests title
description: Markdown Page tests description
wrapperClassName: docusaurus-markdown-example
---
# Markdown .md tests
This file should be interpreted in a more CommonMark compliant way
## Comment
Html comment: <!-- comment -->
<!-- prettier-ignore -->
MDX comment: {/* comment */}
## JSX syntax
import BrowserWindow from '@site/src/components/BrowserWindow';
<BrowserWindow>
BrowserWindow content
<BrowserWindow/>
export const answer = 42;
Test {xyz}
## Admonition
Admonitions still work
:::note[title]
note
:::
## Heading Id {#custom-heading-id}
Custom heading syntax `{#custom-heading-id}` still works

View file

@ -4,7 +4,7 @@ description: Markdown Page tests description
wrapperClassName: docusaurus-markdown-example
---
# Markdown page tests
# Markdown .mdx tests
This is a page generated from Markdown to illustrate the Markdown page feature and test some edge cases.
@ -51,20 +51,12 @@ import TabItem from '@theme/TabItem';
MDX comments can be used with
```mdx
<!--
My comment
-->
{/* My comment */}
```
See, nothing is displayed:
<!--
My comment
-->
{/* My comment */}
## Import code block from source code file
@ -180,7 +172,7 @@ function Clock(props) {
### Weird heading {#a b}
### Weird heading {#a{b}
### Weird heading {#a\{b}
## Pipe
@ -247,9 +239,19 @@ Can be arbitrarily nested:
- [ ] Task
- [ ] Task
## Emojis
Emojis in this text will be replaced with [remark-emoji](https://www.npmjs.com/package/remark-emoji): :dog: :+1:
## Admonitions
:::caution Interpolated `title` with a <button style={{color: "red"}} onClick={() => alert("it works")}>button</button>
:::caution Interpolated `title` with a <button style={{color: "red"}} onClick={() => alert("it works")}>button</button> (old syntax)
Admonition body
:::
:::caution[Interpolated `title` with a <button style={{color: "red"}} onClick={() => alert("it works")}>button</button> (directive label syntax)]
Admonition body
@ -281,4 +283,60 @@ hey
:::::
after admonition
::::tip[Use tabs in admonitions]
:::info[Admonition nested]
test
:::
<Tabs>
<TabItem value="apple" label="Apple">
:::note[Admonition in tab]
test
:::
</TabItem>
<TabItem value="orange" label="Orange">This is an orange 🍊</TabItem>
<TabItem value="banana" label="Banana">This is a banana 🍌</TabItem>
</Tabs>
::::
## Linking
This is a test page to see if Docusaurus Markdown features are working properly
### Linking to assets
See [#3337](https://github.com/facebook/docusaurus/issues/3337)
- [/someFile.pdf](/someFile.pdf)
- [/someFile.xyz](/someFile.xyz)
- [@site/\_dogfooding/\_asset-tests/someFile.pdf](@site/_dogfooding/_asset-tests/someFile.pdf)
- [@site/\_dogfooding/\_asset-tests/someFile.xyz](@site/_dogfooding/_asset-tests/someFile.xyz)
### Linking to non-SPA page hosted within website
See [#3309](https://github.com/facebook/docusaurus/issues/3309)
- [pathname:///dogfooding/javadoc](pathname:///dogfooding/javadoc)
- [pathname:///dogfooding/javadoc/index.html](pathname:///dogfooding/javadoc/index.html)
- [pathname://../dogfooding/javadoc](pathname://../dogfooding/javadoc)
- [pathname://../dogfooding/javadoc/index.html](pathname://../dogfooding/javadoc/index.html)
### Linking to JSON
- [./script.js](./_script.js)
- [./data.json](./data.json)

View file

@ -1,33 +0,0 @@
# Markdown tests
This is a test page to see if Docusaurus Markdown features are working properly
## Linking to assets
See [#3337](https://github.com/facebook/docusaurus/issues/3337)
- [/someFile.pdf](/someFile.pdf)
- [/someFile.xyz](/someFile.xyz)
- [@site/\_dogfooding/\_asset-tests/someFile.pdf](@site/_dogfooding/_asset-tests/someFile.pdf)
- [@site/\_dogfooding/\_asset-tests/someFile.xyz](@site/_dogfooding/_asset-tests/someFile.xyz)
## Linking to non-SPA page hosted within website
See [#3309](https://github.com/facebook/docusaurus/issues/3309)
- [pathname:///dogfooding/javadoc](pathname:///dogfooding/javadoc)
- [pathname:///dogfooding/javadoc/index.html](pathname:///dogfooding/javadoc/index.html)
- [pathname://../dogfooding/javadoc](pathname://../dogfooding/javadoc)
- [pathname://../dogfooding/javadoc/index.html](pathname://../dogfooding/javadoc/index.html)
## Linking to JSON
- [./script.js](./_script.js)
- [./data.json](./data.json)

View file

@ -21,15 +21,12 @@ It all started with this [RFC issue](https://github.com/facebook/docusaurus/issu
<blockquote>
<h4>
<a href="https://github.com/facebook/docusaurus/issues/789">
[RFC] Docusaurus v2 · Issue #789 · facebook/docusaurus
{'[RFC] Docusaurus v2 · Issue #789 · facebook/docusaurus'}
</a>
</h4>
<p>
These are some of the problems I'm seeing in Docusaurus now and also how we
can address them in v2. A number of the ideas here were inspired by VuePress
and other static site generators. In the current static site generators
ecosystem, t...
</p>
These are some of the problems I'm seeing in Docusaurus now and also how we can
address them in v2. A number of the ideas here were inspired by VuePress and other
static site generators. In the current static site generators ecosystem, t...
</blockquote>
Most of the suggested improvements are mentioned in the issue; I will provide details on some of issues in Docusaurus 1 and how we are going to address them in Docusaurus 2.

View file

@ -32,7 +32,7 @@ After **4 years of work, [75 alphas](https://github.com/facebook/docusaurus/rele
![social-card image](./img/social-card.png)
<!--truncate-->
{/* truncate */}
:::info We are on [ProductHunt](https://www.producthunt.com/posts/docusaurus-2-0) and [Hacker News](https://news.ycombinator.com/item?id=32303052)!

View file

@ -12,7 +12,7 @@ The upgrade should be easy: as explained in our [release process documentation](
![Docusaurus 2.2 social card](./img/social-card.png)
<!--truncate-->
{/* truncate */}
## Highlights

View file

@ -123,7 +123,7 @@ The i18n configuration object to [localize your site](../i18n/i18n-introduction.
Example:
<!-- cSpell:ignore فارسی -->
{/* cSpell:ignore فارسی */}
```js title="docusaurus.config.js"
module.exports = {

View file

@ -61,7 +61,7 @@ Accepted fields:
| `rehypePlugins` | `any[]` | `[]` | Rehype plugins passed to MDX. |
| `beforeDefaultRemarkPlugins` | `any[]` | `[]` | Custom Remark plugins passed to MDX before the default Docusaurus Remark plugins. |
| `beforeDefaultRehypePlugins` | `any[]` | `[]` | Custom Rehype plugins passed to MDX before the default Docusaurus Rehype plugins. |
| `truncateMarker` | `RegExp` | `/<!--\s*(truncate)\s*-->/` | Truncate marker marking where the summary ends. |
| `truncateMarker` | `RegExp` | `/<!--\s*truncate\s*-->/` \| `\{\/\*\s*truncate\s*\*\/\}/` | Truncate marker marking where the summary ends. |
| `showReadingTime` | `boolean` | `true` | Show estimated reading time for the blog post. |
| `readingTime` | `ReadingTimeFn` | The default reading time | A callback to customize the reading time number displayed. |
| `authorsMapPath` | `string` | `'authors.yml'` | Path to the authors map file, relative to the blog content directory. |
@ -69,7 +69,7 @@ Accepted fields:
| `feedOptions.type` | <code><a href="#FeedType">FeedType</a> \| <a href="#FeedType">FeedType</a>[] \| 'all' \| null</code> | **Required** | Type of feed to be generated. Use `null` to disable generation. |
| `feedOptions.createFeedItems` | <code><a href="#CreateFeedItemsFn">CreateFeedItemsFn</a> \| undefined</code> | `undefined` | An optional function which can be used to transform and / or filter the items in the feed. |
| `feedOptions.title` | `string` | `siteConfig.title` | Title of the feed. |
| `feedOptions.description` | `string` | <code>\`${siteConfig.title} Blog\`</code> | Description of the feed. |
| `feedOptions.description` | `string` | <code>\`$\{siteConfig.title} Blog\`</code> | Description of the feed. |
| `feedOptions.copyright` | `string` | `undefined` | Copyright message. |
| `feedOptions.language` | `string` (See [documentation](http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes) for possible values) | `undefined` | Language metadata of the feed. |
| `sortPosts` | <code>'descending' \| 'ascending' </code> | `'descending'` | Governs the direction of blog post sorting. |

View file

@ -63,7 +63,7 @@ hide_table_of_contents: false
Welcome to this blog. This blog is created with [**Docusaurus 2**](https://docusaurus.io/).
<!--truncate-->
<!-- truncate -->
This is my first post on Docusaurus 2.
@ -78,22 +78,31 @@ The blog's index page (by default, it is at `/blog`) is the _blog list page_, wh
Use the `<!--truncate-->` marker in your blog post to represent what will be shown as the summary when viewing all published blog posts. Anything above `<!--truncate-->` will be part of the summary. For example:
```md
```md title="website/blog/my-post.md" {7}
---
title: Truncation Example
title: Markdown blog truncation example
---
All these will be part of the blog post summary.
Even this.
<!--truncate-->
<!-- truncate -->
But anything from here on down will not be.
```
Not this.
For files using the `.mdx` extension, use a [MDX](https://mdxjs.com/) comment `{/* truncate */}` instead:
Or this.
{/* prettier-ignore */}
```md title="website/blog/my-post.mdx" {7}
---
title: MDX blog truncation Example
---
All these will be part of the blog post summary.
{/* truncate */}
But anything from here on down will not be.
```
By default, 10 posts are shown on each blog list page, but you can control pagination with the `postsPerPage` option in the plugin configuration. If you set `postsPerPage: 'ALL'`, pagination will be disabled and all posts will be displayed on the first page. You can also add a meta description to the blog list page for better SEO:

Some files were not shown because too many files have changed in this diff Show more