feat(core): add new site config option siteConfig.markdown.anchors.maintainCase (#10064)

Co-authored-by: Joshua Chen <sidachen2003@gmail.com>
Co-authored-by: Sébastien Lorber <slorber@users.noreply.github.com>
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
This commit is contained in:
Alexey Ivanov 2024-04-25 23:35:38 +09:00 committed by GitHub
parent 9418786b26
commit daba917e7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 122 additions and 13 deletions

View file

@ -47,11 +47,6 @@ type SimpleProcessor = {
}) => Promise<SimpleProcessorResult>; }) => Promise<SimpleProcessorResult>;
}; };
async function getDefaultRemarkPlugins(): Promise<MDXPlugin[]> {
const {default: emoji} = await import('remark-emoji');
return [headings, emoji, toc];
}
export type MDXPlugin = Pluggable; export type MDXPlugin = Pluggable;
export type MDXOptions = { export type MDXOptions = {
@ -86,8 +81,18 @@ async function createProcessorFactory() {
const {default: comment} = await import('@slorber/remark-comment'); const {default: comment} = await import('@slorber/remark-comment');
const {default: directive} = await import('remark-directive'); const {default: directive} = await import('remark-directive');
const {VFile} = await import('vfile'); const {VFile} = await import('vfile');
const {default: emoji} = await import('remark-emoji');
const defaultRemarkPlugins = await getDefaultRemarkPlugins(); function getDefaultRemarkPlugins({options}: {options: Options}): MDXPlugin[] {
return [
[
headings,
{anchorsMaintainCase: options.markdownConfig.anchors.maintainCase},
],
emoji,
toc,
];
}
// /!\ this method is synchronous on purpose // /!\ this method is synchronous on purpose
// Using async code here can create cache entry race conditions! // Using async code here can create cache entry race conditions!
@ -104,7 +109,7 @@ async function createProcessorFactory() {
directive, directive,
[contentTitle, {removeContentTitle: options.removeContentTitle}], [contentTitle, {removeContentTitle: options.removeContentTitle}],
...getAdmonitionsPlugins(options.admonitions ?? false), ...getAdmonitionsPlugins(options.admonitions ?? false),
...defaultRemarkPlugins, ...getDefaultRemarkPlugins({options}),
details, details,
head, head,
...(options.markdownConfig.mermaid ? [mermaid] : []), ...(options.markdownConfig.mermaid ? [mermaid] : []),

View file

@ -11,13 +11,20 @@ import u from 'unist-builder';
import {removePosition} from 'unist-util-remove-position'; 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 {visit} from 'unist-util-visit';
import slug from '../index'; import plugin from '../index';
import type {PluginOptions} from '../index';
import type {Plugin} from 'unified'; import type {Plugin} from 'unified';
import type {Parent} from 'unist'; import type {Parent} from 'unist';
async function process(doc: string, plugins: Plugin[] = []) { async function process(
doc: string,
plugins: Plugin[] = [],
options: PluginOptions = {anchorsMaintainCase: false},
) {
const {remark} = await import('remark'); const {remark} = await import('remark');
const processor = await remark().use({plugins: [...plugins, slug]}); const processor = await remark().use({
plugins: [...plugins, [plugin, options]],
});
const result = await processor.run(processor.parse(doc)); const result = await processor.run(processor.parse(doc));
removePosition(result, {force: true}); removePosition(result, {force: true});
return result; return result;
@ -312,4 +319,25 @@ describe('headings remark plugin', () => {
}, },
]); ]);
}); });
it('preserve anchors case then "anchorsMaintainCase" option is set', async () => {
const result = await process('# Case Sensitive Heading', [], {
anchorsMaintainCase: true,
});
const expected = u('root', [
u(
'heading',
{
depth: 1,
data: {
hProperties: {id: 'Case-Sensitive-Heading'},
id: 'Case-Sensitive-Heading',
},
},
[u('text', 'Case Sensitive Heading')],
),
]);
expect(result).toEqual(expected);
});
}); });

View file

@ -12,7 +12,13 @@ import {parseMarkdownHeadingId, createSlugger} from '@docusaurus/utils';
import type {Transformer} from 'unified'; import type {Transformer} from 'unified';
import type {Heading, Text} from 'mdast'; import type {Heading, Text} from 'mdast';
export default function plugin(): Transformer { export interface PluginOptions {
anchorsMaintainCase: boolean;
}
export default function plugin({
anchorsMaintainCase,
}: PluginOptions): Transformer {
return async (root) => { return async (root) => {
const {toString} = await import('mdast-util-to-string'); const {toString} = await import('mdast-util-to-string');
const {visit} = await import('unist-util-visit'); const {visit} = await import('unist-util-visit');
@ -38,7 +44,9 @@ export default function plugin(): Transformer {
// Support explicit heading IDs // Support explicit heading IDs
const parsedHeading = parseMarkdownHeadingId(heading); const parsedHeading = parseMarkdownHeadingId(heading);
id = parsedHeading.id ?? slugs.slug(heading); id =
parsedHeading.id ??
slugs.slug(heading, {maintainCase: anchorsMaintainCase});
if (parsedHeading.id) { if (parsedHeading.id) {
// When there's an id, it is always in the last child node // When there's an id, it is always in the last child node

View file

@ -25,7 +25,7 @@ const processFixture = async (name: string) => {
const result = await compile(file, { const result = await compile(file, {
format: 'mdx', format: 'mdx',
remarkPlugins: [headings, gfm, plugin], remarkPlugins: [[headings, {anchorsMaintainCase: false}], gfm, plugin],
rehypePlugins: [], rehypePlugins: [],
}); });

View file

@ -45,6 +45,13 @@ export type ParseFrontMatter = (
}, },
) => Promise<ParseFrontMatterResult>; ) => Promise<ParseFrontMatterResult>;
export type MarkdownAnchorsConfig = {
/**
* Preserves the case of the heading text when generating anchor ids.
*/
maintainCase: boolean;
};
export type MarkdownConfig = { export type MarkdownConfig = {
/** /**
* The Markdown format to use by default. * The Markdown format to use by default.
@ -101,6 +108,11 @@ export type MarkdownConfig = {
* See also https://github.com/remarkjs/remark-rehype#options * See also https://github.com/remarkjs/remark-rehype#options
*/ */
remarkRehypeOptions: RemarkRehypeOptions; remarkRehypeOptions: RemarkRehypeOptions;
/**
* Options to control the behavior of anchors generated from Markdown headings
*/
anchors: MarkdownAnchorsConfig;
}; };
/** /**

View file

@ -17,6 +17,9 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = `
"path": "i18n", "path": "i18n",
}, },
"markdown": { "markdown": {
"anchors": {
"maintainCase": false,
},
"format": "mdx", "format": "mdx",
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
@ -68,6 +71,9 @@ exports[`loadSiteConfig website with ts + js config 1`] = `
"path": "i18n", "path": "i18n",
}, },
"markdown": { "markdown": {
"anchors": {
"maintainCase": false,
},
"format": "mdx", "format": "mdx",
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
@ -119,6 +125,9 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = `
"path": "i18n", "path": "i18n",
}, },
"markdown": { "markdown": {
"anchors": {
"maintainCase": false,
},
"format": "mdx", "format": "mdx",
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
@ -170,6 +179,9 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = `
"path": "i18n", "path": "i18n",
}, },
"markdown": { "markdown": {
"anchors": {
"maintainCase": false,
},
"format": "mdx", "format": "mdx",
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
@ -221,6 +233,9 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = `
"path": "i18n", "path": "i18n",
}, },
"markdown": { "markdown": {
"anchors": {
"maintainCase": false,
},
"format": "mdx", "format": "mdx",
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
@ -272,6 +287,9 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = `
"path": "i18n", "path": "i18n",
}, },
"markdown": { "markdown": {
"anchors": {
"maintainCase": false,
},
"format": "mdx", "format": "mdx",
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
@ -323,6 +341,9 @@ exports[`loadSiteConfig website with valid async config 1`] = `
"path": "i18n", "path": "i18n",
}, },
"markdown": { "markdown": {
"anchors": {
"maintainCase": false,
},
"format": "mdx", "format": "mdx",
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
@ -376,6 +397,9 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = `
"path": "i18n", "path": "i18n",
}, },
"markdown": { "markdown": {
"anchors": {
"maintainCase": false,
},
"format": "mdx", "format": "mdx",
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
@ -429,6 +453,9 @@ exports[`loadSiteConfig website with valid config creator function 1`] = `
"path": "i18n", "path": "i18n",
}, },
"markdown": { "markdown": {
"anchors": {
"maintainCase": false,
},
"format": "mdx", "format": "mdx",
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
@ -485,6 +512,9 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = `
"path": "i18n", "path": "i18n",
}, },
"markdown": { "markdown": {
"anchors": {
"maintainCase": false,
},
"format": "mdx", "format": "mdx",
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,

View file

@ -97,6 +97,9 @@ exports[`load loads props for site with custom i18n path 1`] = `
"path": "i18n", "path": "i18n",
}, },
"markdown": { "markdown": {
"anchors": {
"maintainCase": false,
},
"format": "mdx", "format": "mdx",
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,

View file

@ -69,6 +69,9 @@ describe('normalizeConfig', () => {
admonitions: false, admonitions: false,
headingIds: true, headingIds: true,
}, },
anchors: {
maintainCase: true,
},
remarkRehypeOptions: { remarkRehypeOptions: {
footnoteLabel: 'Pied de page', footnoteLabel: 'Pied de page',
}, },
@ -517,6 +520,9 @@ describe('markdown', () => {
admonitions: true, admonitions: true,
headingIds: false, headingIds: false,
}, },
anchors: {
maintainCase: true,
},
remarkRehypeOptions: { remarkRehypeOptions: {
footnoteLabel: 'Notes de bas de page', footnoteLabel: 'Notes de bas de page',
// @ts-expect-error: we don't validate it on purpose // @ts-expect-error: we don't validate it on purpose

View file

@ -41,6 +41,9 @@ export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = {
admonitions: true, admonitions: true,
headingIds: true, headingIds: true,
}, },
anchors: {
maintainCase: false,
},
remarkRehypeOptions: undefined, remarkRehypeOptions: undefined,
}; };
@ -320,6 +323,11 @@ export const ConfigSchema = Joi.object<DocusaurusConfig>({
// Not sure if it's a good idea, validation is likely to become stale // Not sure if it's a good idea, validation is likely to become stale
// See https://github.com/remarkjs/remark-rehype#options // See https://github.com/remarkjs/remark-rehype#options
Joi.object().unknown(), Joi.object().unknown(),
anchors: Joi.object({
maintainCase: Joi.boolean().default(
DEFAULT_CONFIG.markdown.anchors.maintainCase,
),
}).default(DEFAULT_CONFIG.markdown.anchors),
}).default(DEFAULT_CONFIG.markdown), }).default(DEFAULT_CONFIG.markdown),
}).messages({ }).messages({
'docusaurus.configValidationWarning': 'docusaurus.configValidationWarning':

View file

@ -438,6 +438,10 @@ export type ParseFrontMatter = (params: {
content: string; content: string;
}>; }>;
type MarkdownAnchorsConfig = {
maintainCase: boolean;
};
type MarkdownConfig = { type MarkdownConfig = {
format: 'mdx' | 'md' | 'detect'; format: 'mdx' | 'md' | 'detect';
mermaid: boolean; mermaid: boolean;
@ -445,6 +449,7 @@ type MarkdownConfig = {
parseFrontMatter?: ParseFrontMatter; parseFrontMatter?: ParseFrontMatter;
mdx1Compat: MDX1CompatOptions; mdx1Compat: MDX1CompatOptions;
remarkRehypeOptions: object; // see https://github.com/remarkjs/remark-rehype#options remarkRehypeOptions: object; // see https://github.com/remarkjs/remark-rehype#options
anchors: MarkdownAnchorsConfig;
}; };
``` ```
@ -469,6 +474,9 @@ export default {
admonitions: true, admonitions: true,
headingIds: true, headingIds: true,
}, },
anchors: {
maintainCase: true,
},
}, },
}; };
``` ```
@ -484,6 +492,7 @@ export default {
| `preprocessor` | `MarkdownPreprocessor` | `undefined` | Gives you the ability to alter the Markdown content string before parsing. Use it as a last-resort escape hatch or workaround: it is almost always better to implement a Remark/Rehype plugin. | | `preprocessor` | `MarkdownPreprocessor` | `undefined` | Gives you the ability to alter the Markdown content string before parsing. Use it as a last-resort escape hatch or workaround: it is almost always better to implement a Remark/Rehype plugin. |
| `parseFrontMatter` | `ParseFrontMatter` | `undefined` | Gives you the ability to provide your own front matter parser, or to enhance the default parser. Read our [front matter guide](../guides/markdown-features/markdown-features-intro.mdx#front-matter) for details. | | `parseFrontMatter` | `ParseFrontMatter` | `undefined` | Gives you the ability to provide your own front matter parser, or to enhance the default parser. Read our [front matter guide](../guides/markdown-features/markdown-features-intro.mdx#front-matter) for details. |
| `mdx1Compat` | `MDX1CompatOptions` | `{comments: true, admonitions: true, headingIds: true}` | Compatibility options to make it easier to upgrade to Docusaurus v3+. | | `mdx1Compat` | `MDX1CompatOptions` | `{comments: true, admonitions: true, headingIds: true}` | Compatibility options to make it easier to upgrade to Docusaurus v3+. |
| `anchors` | `MarkdownAnchorsConfig` | `{maintainCase: false}` | Options to control the behavior of anchors generated from Markdown headings |
| `remarkRehypeOptions` | `object` | `undefined` | Makes it possible to pass custom [`remark-rehype` options](https://github.com/remarkjs/remark-rehype#options). | | `remarkRehypeOptions` | `object` | `undefined` | Makes it possible to pass custom [`remark-rehype` options](https://github.com/remarkjs/remark-rehype#options). |
```mdx-code-block ```mdx-code-block