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 <noreply@anthropic.com>
pull/5335/head
Divyansh Singh 2 weeks ago
parent e235dbeb8a
commit b8d9c8f9a9

@ -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('<div class="language-js">')
expect(enabled).toContain('class="copy"')
const disabled = await render(src, { preWrapper: false })
expect(disabled).not.toContain('<div class="language-js">')
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('&lt;&lt;&lt; ./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<MyComponent/>\nmore'
const enabled = await render(src)
expect(enabled).toContain('</p>\n<MyComponent/><p>')
const disabled = await render(src, { component: false })
expect(disabled).toContain('<p>text\n<MyComponent/>\nmore</p>')
})
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'
)
})
})
})

@ -121,10 +121,17 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions {
*/
languageLabel?: Record<string, string>
/**
* 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<string, string>
enabled?: string[]
shortcuts?: Record<string, string | string[]>
}
emoji?:
| {
defs?: Record<string, string>
enabled?: string[]
shortcuts?: Record<string, string | string[]>
}
| 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: '&#8203;',
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: '&#8203;',
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('&amp;', '&') // 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('&amp;', '&') // encoded twice because of restoreEntities
return tocOptions?.format?.(title) ?? title
}
})
}
if (options.math) {
try {

Loading…
Cancel
Save