From b8d9c8f9a92ec4e8c877d6c28fee26b3c379876c Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:20:18 +0530 Subject: [PATCH] feat(markdown): support disabling built-in markdown plugins Allows custom themes to opt out of markup and behavior added on top of vanilla markdown rendering: - `anchor`, `emoji`, `toc`, `component`, `image` now also accept `false` - new `preWrapper` and `snippet` boolean options (default true) - `lineNumbers` is a no-op when `preWrapper` is disabled, as its markup depends on the wrapper close #4484 close #4556 Co-Authored-By: Claude Fable 5 --- __tests__/unit/node/markdown/markdown.test.ts | 105 +++++++++++ src/node/markdown/markdown.ts | 175 +++++++++++------- 2 files changed, 213 insertions(+), 67 deletions(-) create mode 100644 __tests__/unit/node/markdown/markdown.test.ts diff --git a/__tests__/unit/node/markdown/markdown.test.ts b/__tests__/unit/node/markdown/markdown.test.ts new file mode 100644 index 00000000..e8219a54 --- /dev/null +++ b/__tests__/unit/node/markdown/markdown.test.ts @@ -0,0 +1,105 @@ +import { + createMarkdownRenderer, + disposeMdItInstance, + type MarkdownOptions +} from 'node/markdown/markdown' + +async function render(src: string, options: MarkdownOptions = {}) { + disposeMdItInstance() + const md = await createMarkdownRenderer('.', { + highlight: (code) => code, + ...options + }) + return md.renderAsync(src) +} + +describe('node/markdown/markdown', () => { + describe('disabling built-in plugins', () => { + test('anchor', async () => { + const enabled = await render('# Hello World') + expect(enabled).toContain('id="hello-world"') + expect(enabled).toContain('header-anchor') + + const disabled = await render('# Hello World', { anchor: false }) + expect(disabled).not.toContain('id=') + expect(disabled).not.toContain('header-anchor') + }) + + test('attrs', async () => { + const enabled = await render('## Title {#custom-id}') + expect(enabled).toContain('id="custom-id"') + + const disabled = await render('## Title {#custom-id}', { attrs: false }) + expect(disabled).not.toContain('id="custom-id"') + expect(disabled).toContain('{#custom-id}') + }) + + test('emoji', async () => { + expect(await render(':tada:')).toContain('🎉') + expect(await render(':tada:', { emoji: false })).toContain(':tada:') + }) + + test('toc', async () => { + const src = '# Title\n\n[[toc]]' + expect(await render(src)).toContain('table-of-contents') + + const disabled = await render(src, { toc: false }) + expect(disabled).not.toContain('table-of-contents') + expect(disabled).toContain('[[toc]]') + }) + + test('preWrapper', async () => { + const src = '```js\nconst a = 1\n```' + const enabled = await render(src) + expect(enabled).toContain('
') + expect(enabled).toContain('class="copy"') + + const disabled = await render(src, { preWrapper: false }) + expect(disabled).not.toContain('
') + expect(disabled).not.toContain('class="copy"') + }) + + test('preWrapper disables line numbers with it', async () => { + const src = '```js\nconst a = 1\n```' + const enabled = await render(src, { lineNumbers: true }) + expect(enabled).toContain('line-numbers-wrapper') + + const disabled = await render(src, { + preWrapper: false, + lineNumbers: true + }) + expect(disabled).not.toContain('line-numbers-wrapper') + }) + + test('snippet', async () => { + const disabled = await render('<<< ./foo.js', { snippet: false }) + expect(disabled).toContain('<<< ./foo.js') + }) + + test('image', async () => { + const src = '![img](/foo.png)' + const enabled = await render(src, { image: { lazyLoad: true } }) + expect(enabled).toContain('loading="lazy"') + + const disabled = await render(src, { image: false }) + expect(disabled).not.toContain('loading="lazy"') + }) + + test('component', async () => { + const src = 'text\n\nmore' + const enabled = await render(src) + expect(enabled).toContain('

\n

') + + const disabled = await render(src, { component: false }) + expect(disabled).toContain('

text\n\nmore

') + }) + + test('tableTabIndex', async () => { + const src = '| a |\n| --- |\n| b |' + expect(await render(src)).toContain('tabindex="0"') + expect(await render(src, { tableTabIndex: false })).not.toContain( + 'tabindex' + ) + }) + }) +}) diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 299350c6..6293b7bc 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -121,10 +121,17 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions { */ languageLabel?: Record /** - * Show line numbers in code blocks + * Show line numbers in code blocks. Requires the `preWrapper` plugin. * @default false */ lineNumbers?: boolean + /** + * Wrap code blocks in a container carrying the language label and the + * copy button. The default theme's code block styling relies on this + * markup. Disabling it also disables `lineNumbers`. + * @default true + */ + preWrapper?: boolean /** * Fallback language when the specified language is not available. */ @@ -153,24 +160,28 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions { /* ==================== Markdown It Plugins ==================== */ /** - * Options for `markdown-it-anchor` + * Options for `markdown-it-anchor`. Set to `false` to disable adding ids + * and anchor links to headings. Note that the default theme's outline and + * heading hash links rely on these ids. * @see https://github.com/valeriangalliat/markdown-it-anchor */ - anchor?: anchorPlugin.AnchorOptions + anchor?: anchorPlugin.AnchorOptions | false /** * Options for `markdown-it-attrs`. Set to `false` to disable. * @see https://github.com/arve0/markdown-it-attrs */ attrs?: MarkdownItAttrsOptions | false /** - * Options for `markdown-it-emoji` + * Options for `markdown-it-emoji`. Set to `false` to disable. * @see https://github.com/markdown-it/markdown-it-emoji */ - emoji?: { - defs?: Record - enabled?: string[] - shortcuts?: Record - } + emoji?: + | { + defs?: Record + enabled?: string[] + shortcuts?: Record + } + | false /** * Options for `@mdit-vue/plugin-frontmatter` * @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-frontmatter @@ -187,15 +198,22 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions { */ sfc?: SfcPluginOptions /** - * Options for `@mdit-vue/plugin-toc` + * Options for `@mdit-vue/plugin-toc`. Set to `false` to disable the + * `[[toc]]` syntax. * @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-toc */ - toc?: TocPluginOptions + toc?: TocPluginOptions | false /** - * Options for `@mdit-vue/plugin-component` + * Options for `@mdit-vue/plugin-component`. Set to `false` to disable. * @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-component */ - component?: ComponentPluginOptions + component?: ComponentPluginOptions | false + /** + * Enables importing code snippets from files with `<<<`. + * @default true + * @see https://vitepress.dev/guide/markdown#import-code-snippets + */ + snippet?: boolean /** * Options for `markdown-it-container` * @see https://github.com/markdown-it/markdown-it-container @@ -210,7 +228,13 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions { * @see https://vitepress.dev/guide/markdown#math-equations */ math?: boolean | any - image?: ImageOptions + /** + * Options for the image plugin (resolves image sources against the public + * directory, adds dimensions, and supports lazy loading). Set to `false` + * to disable. + * @see https://vitepress.dev/guide/markdown#image-lazy-loading + */ + image?: ImageOptions | false /** * Allows disabling the github alerts plugin * @default true @@ -278,24 +302,33 @@ export async function createMarkdownRenderer( await options.preConfig(md) } - const slugify = options.anchor?.slugify ?? defaultSlugify + const slugify = + (options.anchor ? options.anchor.slugify : undefined) ?? defaultSlugify // custom plugins - componentPlugin(md, options.component) - preWrapperPlugin(md, { - codeCopyButtonTitle, - languageLabel: options.languageLabel - }) - snippetPlugin(md, srcDir) + if (options.component !== false) { + componentPlugin(md, options.component) + } + if (options.preWrapper !== false) { + preWrapperPlugin(md, { + codeCopyButtonTitle, + languageLabel: options.languageLabel + }) + lineNumberPlugin(md, options.lineNumbers) + } + if (options.snippet !== false) { + snippetPlugin(md, srcDir) + } containerPlugin(md, options.container) - imagePlugin(md, publicDir, options.image) + if (options.image !== false) { + imagePlugin(md, publicDir, options.image) + } linkPlugin( md, { target: '_blank', rel: 'noreferrer', ...options.externalLinks }, base, slugify ) - lineNumberPlugin(md, options.lineNumbers) if (options.tableTabIndex !== false) { tablePlugin(md) @@ -309,44 +342,48 @@ export async function createMarkdownRenderer( if (options.attrs !== false) { attrsPlugin(md, options.attrs) } - emojiPlugin(md, options.emoji) + if (options.emoji !== false) { + emojiPlugin(md, options.emoji) + } // mdit-vue plugins - anchorPlugin(md, { - slugify, - getTokensText: (tokens) => { - return tokens - .filter((t) => !['html_inline', 'emoji'].includes(t.type)) - .map((t) => t.content) - .join('') - }, - permalink: (slug, _, state, idx) => { - const title = - state.tokens[idx + 1]?.children - ?.filter((token) => ['text', 'code_inline'].includes(token.type)) - .reduce((acc, t) => acc + t.content, '') - .trim() || '' - - const linkTokens = [ - Object.assign(new state.Token('text', '', 0), { content: ' ' }), - Object.assign(new state.Token('link_open', 'a', 1), { - attrs: [ - ['class', 'header-anchor'], - ['href', `#${slug}`], - ['aria-label', `Permalink to “${title}”`] - ] - }), - Object.assign(new state.Token('html_inline', '', 0), { - content: '​', - meta: { isPermalinkSymbol: true } - }), - new state.Token('link_close', 'a', -1) - ] - - state.tokens[idx + 1].children?.push(...linkTokens) - }, - ...options.anchor - }) + if (options.anchor !== false) { + anchorPlugin(md, { + slugify, + getTokensText: (tokens) => { + return tokens + .filter((t) => !['html_inline', 'emoji'].includes(t.type)) + .map((t) => t.content) + .join('') + }, + permalink: (slug, _, state, idx) => { + const title = + state.tokens[idx + 1]?.children + ?.filter((token) => ['text', 'code_inline'].includes(token.type)) + .reduce((acc, t) => acc + t.content, '') + .trim() || '' + + const linkTokens = [ + Object.assign(new state.Token('text', '', 0), { content: ' ' }), + Object.assign(new state.Token('link_open', 'a', 1), { + attrs: [ + ['class', 'header-anchor'], + ['href', `#${slug}`], + ['aria-label', `Permalink to “${title}”`] + ] + }), + Object.assign(new state.Token('html_inline', '', 0), { + content: '​', + meta: { isPermalinkSymbol: true } + }), + new state.Token('link_close', 'a', -1) + ] + + state.tokens[idx + 1].children?.push(...linkTokens) + }, + ...options.anchor + }) + } frontmatterPlugin(md, options.frontmatter) @@ -360,14 +397,18 @@ export async function createMarkdownRenderer( sfcPlugin(md, options.sfc) titlePlugin(md) - tocPlugin(md, { - slugify, - ...options.toc, - format: (s) => { - const title = s.replaceAll('&', '&') // encoded twice because of restoreEntities - return options.toc?.format?.(title) ?? title - } - }) + + const tocOptions = options.toc + if (tocOptions !== false) { + tocPlugin(md, { + slugify, + ...tocOptions, + format: (s) => { + const title = s.replaceAll('&', '&') // encoded twice because of restoreEntities + return tocOptions?.format?.(title) ?? title + } + }) + } if (options.math) { try {