diff --git a/packages/docusaurus-mdx-loader/src/processor.ts b/packages/docusaurus-mdx-loader/src/processor.ts index 6865f721a999..778bfce1ba47 100644 --- a/packages/docusaurus-mdx-loader/src/processor.ts +++ b/packages/docusaurus-mdx-loader/src/processor.ts @@ -47,11 +47,6 @@ type SimpleProcessor = { }) => Promise; }; -async function getDefaultRemarkPlugins(): Promise { - const {default: emoji} = await import('remark-emoji'); - return [headings, emoji, toc]; -} - export type MDXPlugin = Pluggable; export type MDXOptions = { @@ -86,8 +81,18 @@ async function createProcessorFactory() { const {default: comment} = await import('@slorber/remark-comment'); const {default: directive} = await import('remark-directive'); 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 // Using async code here can create cache entry race conditions! @@ -104,7 +109,7 @@ async function createProcessorFactory() { directive, [contentTitle, {removeContentTitle: options.removeContentTitle}], ...getAdmonitionsPlugins(options.admonitions ?? false), - ...defaultRemarkPlugins, + ...getDefaultRemarkPlugins({options}), details, head, ...(options.markdownConfig.mermaid ? [mermaid] : []), diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.ts index f9385ca71fba..3e5aa89636b2 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.ts @@ -11,13 +11,20 @@ import u from 'unist-builder'; import {removePosition} from 'unist-util-remove-position'; import {toString} from 'mdast-util-to-string'; 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 {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 processor = await remark().use({plugins: [...plugins, slug]}); + const processor = await remark().use({ + plugins: [...plugins, [plugin, options]], + }); const result = await processor.run(processor.parse(doc)); removePosition(result, {force: true}); 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); + }); }); diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts index 90f90e75a5ad..5f2250a9c17e 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -12,7 +12,13 @@ import {parseMarkdownHeadingId, createSlugger} from '@docusaurus/utils'; import type {Transformer} from 'unified'; 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) => { const {toString} = await import('mdast-util-to-string'); const {visit} = await import('unist-util-visit'); @@ -38,7 +44,9 @@ export default function plugin(): Transformer { // Support explicit heading IDs const parsedHeading = parseMarkdownHeadingId(heading); - id = parsedHeading.id ?? slugs.slug(heading); + id = + parsedHeading.id ?? + slugs.slug(heading, {maintainCase: anchorsMaintainCase}); if (parsedHeading.id) { // When there's an id, it is always in the last child node diff --git a/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.ts index 87bc5c6793dd..c246a7bcfedd 100644 --- a/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.ts +++ b/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.ts @@ -25,7 +25,7 @@ const processFixture = async (name: string) => { const result = await compile(file, { format: 'mdx', - remarkPlugins: [headings, gfm, plugin], + remarkPlugins: [[headings, {anchorsMaintainCase: false}], gfm, plugin], rehypePlugins: [], }); diff --git a/packages/docusaurus-types/src/config.d.ts b/packages/docusaurus-types/src/config.d.ts index 47bfde898d44..62627281cc66 100644 --- a/packages/docusaurus-types/src/config.d.ts +++ b/packages/docusaurus-types/src/config.d.ts @@ -45,6 +45,13 @@ export type ParseFrontMatter = ( }, ) => Promise; +export type MarkdownAnchorsConfig = { + /** + * Preserves the case of the heading text when generating anchor ids. + */ + maintainCase: boolean; +}; + export type MarkdownConfig = { /** * The Markdown format to use by default. @@ -101,6 +108,11 @@ export type MarkdownConfig = { * See also https://github.com/remarkjs/remark-rehype#options */ remarkRehypeOptions: RemarkRehypeOptions; + + /** + * Options to control the behavior of anchors generated from Markdown headings + */ + anchors: MarkdownAnchorsConfig; }; /** diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap index c10c4833901e..72d6a58aa5a8 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap @@ -17,6 +17,9 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = ` "path": "i18n", }, "markdown": { + "anchors": { + "maintainCase": false, + }, "format": "mdx", "mdx1Compat": { "admonitions": true, @@ -68,6 +71,9 @@ exports[`loadSiteConfig website with ts + js config 1`] = ` "path": "i18n", }, "markdown": { + "anchors": { + "maintainCase": false, + }, "format": "mdx", "mdx1Compat": { "admonitions": true, @@ -119,6 +125,9 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = ` "path": "i18n", }, "markdown": { + "anchors": { + "maintainCase": false, + }, "format": "mdx", "mdx1Compat": { "admonitions": true, @@ -170,6 +179,9 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = ` "path": "i18n", }, "markdown": { + "anchors": { + "maintainCase": false, + }, "format": "mdx", "mdx1Compat": { "admonitions": true, @@ -221,6 +233,9 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = ` "path": "i18n", }, "markdown": { + "anchors": { + "maintainCase": false, + }, "format": "mdx", "mdx1Compat": { "admonitions": true, @@ -272,6 +287,9 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = ` "path": "i18n", }, "markdown": { + "anchors": { + "maintainCase": false, + }, "format": "mdx", "mdx1Compat": { "admonitions": true, @@ -323,6 +341,9 @@ exports[`loadSiteConfig website with valid async config 1`] = ` "path": "i18n", }, "markdown": { + "anchors": { + "maintainCase": false, + }, "format": "mdx", "mdx1Compat": { "admonitions": true, @@ -376,6 +397,9 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = ` "path": "i18n", }, "markdown": { + "anchors": { + "maintainCase": false, + }, "format": "mdx", "mdx1Compat": { "admonitions": true, @@ -429,6 +453,9 @@ exports[`loadSiteConfig website with valid config creator function 1`] = ` "path": "i18n", }, "markdown": { + "anchors": { + "maintainCase": false, + }, "format": "mdx", "mdx1Compat": { "admonitions": true, @@ -485,6 +512,9 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = ` "path": "i18n", }, "markdown": { + "anchors": { + "maintainCase": false, + }, "format": "mdx", "mdx1Compat": { "admonitions": true, diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap index 8a48af985e45..c2562812b70f 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap @@ -97,6 +97,9 @@ exports[`load loads props for site with custom i18n path 1`] = ` "path": "i18n", }, "markdown": { + "anchors": { + "maintainCase": false, + }, "format": "mdx", "mdx1Compat": { "admonitions": true, diff --git a/packages/docusaurus/src/server/__tests__/configValidation.test.ts b/packages/docusaurus/src/server/__tests__/configValidation.test.ts index ea76af81408b..c20ff6431493 100644 --- a/packages/docusaurus/src/server/__tests__/configValidation.test.ts +++ b/packages/docusaurus/src/server/__tests__/configValidation.test.ts @@ -69,6 +69,9 @@ describe('normalizeConfig', () => { admonitions: false, headingIds: true, }, + anchors: { + maintainCase: true, + }, remarkRehypeOptions: { footnoteLabel: 'Pied de page', }, @@ -517,6 +520,9 @@ describe('markdown', () => { admonitions: true, headingIds: false, }, + anchors: { + maintainCase: true, + }, remarkRehypeOptions: { footnoteLabel: 'Notes de bas de page', // @ts-expect-error: we don't validate it on purpose diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index cf2d16aa59f5..3f6fac5fb174 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -41,6 +41,9 @@ export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = { admonitions: true, headingIds: true, }, + anchors: { + maintainCase: false, + }, remarkRehypeOptions: undefined, }; @@ -320,6 +323,11 @@ export const ConfigSchema = Joi.object({ // Not sure if it's a good idea, validation is likely to become stale // See https://github.com/remarkjs/remark-rehype#options Joi.object().unknown(), + anchors: Joi.object({ + maintainCase: Joi.boolean().default( + DEFAULT_CONFIG.markdown.anchors.maintainCase, + ), + }).default(DEFAULT_CONFIG.markdown.anchors), }).default(DEFAULT_CONFIG.markdown), }).messages({ 'docusaurus.configValidationWarning': diff --git a/website/docs/api/docusaurus.config.js.mdx b/website/docs/api/docusaurus.config.js.mdx index 5a187dfcaaff..2a346d2e2eff 100644 --- a/website/docs/api/docusaurus.config.js.mdx +++ b/website/docs/api/docusaurus.config.js.mdx @@ -438,6 +438,10 @@ export type ParseFrontMatter = (params: { content: string; }>; +type MarkdownAnchorsConfig = { + maintainCase: boolean; +}; + type MarkdownConfig = { format: 'mdx' | 'md' | 'detect'; mermaid: boolean; @@ -445,6 +449,7 @@ type MarkdownConfig = { parseFrontMatter?: ParseFrontMatter; mdx1Compat: MDX1CompatOptions; remarkRehypeOptions: object; // see https://github.com/remarkjs/remark-rehype#options + anchors: MarkdownAnchorsConfig; }; ``` @@ -469,6 +474,9 @@ export default { admonitions: 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. | | `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+. | +| `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). | ```mdx-code-block