mirror of https://github.com/vuejs/vitepress
Replaces markdown-it-container and merges the GitHub alerts plugin into
the containers module so both share one title map. Rendered output is
byte-compatible with the previous implementation.
This also restores fence-line attributes on containers ({open}, {.class},
etc.), which broke when markdown-it-attrs was replaced with
@mdit/plugin-attrs - its fence rule handled containers too, but had to be
disabled to keep code block meta intact. The container plugin now parses
the trailing braces itself, so the old markdown-it-attrs patch is not
needed in any form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5335/head
parent
f1ee91335e
commit
420565236b
@ -0,0 +1,404 @@
|
|||||||
|
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/plugins/containers', () => {
|
||||||
|
test('renders built-in containers with default titles', async () => {
|
||||||
|
const src = [
|
||||||
|
'tip',
|
||||||
|
'info',
|
||||||
|
'warning',
|
||||||
|
'danger',
|
||||||
|
'note',
|
||||||
|
'important',
|
||||||
|
'caution'
|
||||||
|
]
|
||||||
|
.map((t) => `::: ${t}\ncontent of ${t}\n:::`)
|
||||||
|
.join('\n\n')
|
||||||
|
expect(await render(src)).toMatchInlineSnapshot(`
|
||||||
|
"<div class="tip custom-block"><p class="custom-block-title custom-block-title-default">TIP</p>
|
||||||
|
<p>content of tip</p>
|
||||||
|
</div>
|
||||||
|
<div class="info custom-block"><p class="custom-block-title custom-block-title-default">INFO</p>
|
||||||
|
<p>content of info</p>
|
||||||
|
</div>
|
||||||
|
<div class="warning custom-block"><p class="custom-block-title custom-block-title-default">WARNING</p>
|
||||||
|
<p>content of warning</p>
|
||||||
|
</div>
|
||||||
|
<div class="danger custom-block"><p class="custom-block-title custom-block-title-default">DANGER</p>
|
||||||
|
<p>content of danger</p>
|
||||||
|
</div>
|
||||||
|
<div class="note custom-block"><p class="custom-block-title custom-block-title-default">NOTE</p>
|
||||||
|
<p>content of note</p>
|
||||||
|
</div>
|
||||||
|
<div class="important custom-block"><p class="custom-block-title custom-block-title-default">IMPORTANT</p>
|
||||||
|
<p>content of important</p>
|
||||||
|
</div>
|
||||||
|
<div class="caution custom-block"><p class="custom-block-title custom-block-title-default">CAUTION</p>
|
||||||
|
<p>content of caution</p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders details as a disclosure with summary', async () => {
|
||||||
|
expect(await render('::: details\nhidden content\n:::'))
|
||||||
|
.toMatchInlineSnapshot(`
|
||||||
|
"<details class="details custom-block"><summary>Details</summary>
|
||||||
|
<p>hidden content</p>
|
||||||
|
</details>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders custom titles, including inline markdown', async () => {
|
||||||
|
const src = [
|
||||||
|
'::: danger STOP',
|
||||||
|
'Danger zone, do not proceed',
|
||||||
|
':::',
|
||||||
|
'',
|
||||||
|
'::: tip A **bold** _title_ with `code`',
|
||||||
|
'content',
|
||||||
|
':::',
|
||||||
|
'',
|
||||||
|
'::: details Click me to toggle the code',
|
||||||
|
'```js',
|
||||||
|
"console.log('hi')",
|
||||||
|
'```',
|
||||||
|
':::'
|
||||||
|
].join('\n')
|
||||||
|
expect(await render(src)).toMatchInlineSnapshot(`
|
||||||
|
"<div class="danger custom-block"><p class="custom-block-title">STOP</p>
|
||||||
|
<p>Danger zone, do not proceed</p>
|
||||||
|
</div>
|
||||||
|
<div class="tip custom-block"><p class="custom-block-title">A <strong>bold</strong> <em>title</em> with <code>code</code></p>
|
||||||
|
<p>content</p>
|
||||||
|
</div>
|
||||||
|
<details class="details custom-block"><summary>Click me to toggle the code</summary>
|
||||||
|
<div class="language-js"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre><code class="language-js">console.log('hi')
|
||||||
|
</code></pre>
|
||||||
|
</div></details>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolves reference links in titles', async () => {
|
||||||
|
const src = [
|
||||||
|
'::: tip See [the guide][guide]',
|
||||||
|
'content',
|
||||||
|
':::',
|
||||||
|
'',
|
||||||
|
'[guide]: /guide/'
|
||||||
|
].join('\n')
|
||||||
|
expect(await render(src)).toMatchInlineSnapshot(`
|
||||||
|
"<div class="tip custom-block"><p class="custom-block-title">See <a href="/guide/">the guide</a></p>
|
||||||
|
<p>content</p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('respects custom labels from container options', async () => {
|
||||||
|
const src = '::: tip\n提示内容\n:::\n\n::: details\n详情内容\n:::'
|
||||||
|
expect(
|
||||||
|
await render(src, {
|
||||||
|
container: { tipLabel: '提示', detailsLabel: '详细信息' }
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
"<div class="tip custom-block"><p class="custom-block-title custom-block-title-default">提示</p>
|
||||||
|
<p>提示内容</p>
|
||||||
|
</div>
|
||||||
|
<details class="details custom-block"><summary>详细信息</summary>
|
||||||
|
<p>详情内容</p>
|
||||||
|
</details>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports attrs on the fence line', async () => {
|
||||||
|
const src = [
|
||||||
|
'::: details Click me {open}',
|
||||||
|
'content',
|
||||||
|
':::',
|
||||||
|
'',
|
||||||
|
'::: tip Custom {.extra-class #custom-id}',
|
||||||
|
'content',
|
||||||
|
':::'
|
||||||
|
].join('\n')
|
||||||
|
expect(await render(src)).toMatchInlineSnapshot(`
|
||||||
|
"<details open="" class="details custom-block"><summary>Click me</summary>
|
||||||
|
<p>content</p>
|
||||||
|
</details>
|
||||||
|
<div class="extra-class tip custom-block" id="custom-id"><p class="custom-block-title">Custom</p>
|
||||||
|
<p>content</p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports quoted and bare attr values on the fence line', async () => {
|
||||||
|
expect(await render('::: tip Custom {data-a="b c" data-d=e}\ncontent\n:::'))
|
||||||
|
.toMatchInlineSnapshot(`
|
||||||
|
"<div data-a="b c" data-d="e" class="tip custom-block"><p class="custom-block-title">Custom</p>
|
||||||
|
<p>content</p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps fence line braces verbatim when attrs are disabled', async () => {
|
||||||
|
expect(
|
||||||
|
await render('::: details Click me {open}\ncontent\n:::', {
|
||||||
|
attrs: false
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
"<details class="details custom-block"><summary>Click me {open}</summary>
|
||||||
|
<p>content</p>
|
||||||
|
</details>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders v-pre and raw containers as plain wrappers', async () => {
|
||||||
|
const src = [
|
||||||
|
'::: v-pre',
|
||||||
|
'{{ this will be displayed as-is }}',
|
||||||
|
':::',
|
||||||
|
'',
|
||||||
|
'::: raw',
|
||||||
|
'Wraps in a `<div class="vp-raw">`',
|
||||||
|
':::'
|
||||||
|
].join('\n')
|
||||||
|
expect(await render(src)).toMatchInlineSnapshot(`
|
||||||
|
"<div v-pre>
|
||||||
|
<p>{{ this will be displayed as-is }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="vp-raw">
|
||||||
|
<p>Wraps in a <code><div class="vp-raw"></code></p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders code groups with tabs', async () => {
|
||||||
|
const src = [
|
||||||
|
'::: code-group',
|
||||||
|
'',
|
||||||
|
'```js [config.js]',
|
||||||
|
'const a = 1',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'```ts [config.ts]',
|
||||||
|
'const a: number = 1',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
':::'
|
||||||
|
].join('\n')
|
||||||
|
expect(await render(src)).toMatchInlineSnapshot(`
|
||||||
|
"<div class="vp-code-group"><div class="tabs"><input type="radio" name="group-0" id="tab-1" checked><label data-title="config.js" for="tab-1">config.js</label><input type="radio" name="group-0" id="tab-2" ><label data-title="config.ts" for="tab-2">config.ts</label></div><div class="blocks">
|
||||||
|
<div class="language-js active"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre><code class="language-js">const a = 1
|
||||||
|
</code></pre>
|
||||||
|
</div><div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre><code class="language-ts">const a: number = 1
|
||||||
|
</code></pre>
|
||||||
|
</div></div></div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports nesting via longer fences', async () => {
|
||||||
|
const src = [
|
||||||
|
':::: info Outer',
|
||||||
|
'outer content',
|
||||||
|
'',
|
||||||
|
'::: details Inner',
|
||||||
|
'inner content',
|
||||||
|
':::',
|
||||||
|
'::::'
|
||||||
|
].join('\n')
|
||||||
|
expect(await render(src)).toMatchInlineSnapshot(`
|
||||||
|
"<div class="info custom-block"><p class="custom-block-title">Outer</p>
|
||||||
|
<p>outer content</p>
|
||||||
|
<details class="details custom-block"><summary>Inner</summary>
|
||||||
|
<p>inner content</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('auto-closes unclosed containers', async () => {
|
||||||
|
expect(await render('::: warning\nno closing fence'))
|
||||||
|
.toMatchInlineSnapshot(`
|
||||||
|
"<div class="warning custom-block"><p class="custom-block-title custom-block-title-default">WARNING</p>
|
||||||
|
<p>no closing fence</p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parses fences without a space before the name', async () => {
|
||||||
|
expect(await render(':::tip\ncontent\n:::')).toMatchInlineSnapshot(`
|
||||||
|
"<div class="tip custom-block"><p class="custom-block-title custom-block-title-default">TIP</p>
|
||||||
|
<p>content</p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves non-container fence lines alone', async () => {
|
||||||
|
expect(await render('::: unknown\ncontent\n:::')).toMatchInlineSnapshot(`
|
||||||
|
"<p>::: unknown
|
||||||
|
content
|
||||||
|
:::</p>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('node/markdown/plugins/containers (github alerts)', () => {
|
||||||
|
test('renders github alerts like containers', async () => {
|
||||||
|
const src = [
|
||||||
|
'> [!NOTE]',
|
||||||
|
'> note content',
|
||||||
|
'',
|
||||||
|
'> [!TIP]',
|
||||||
|
'> tip content',
|
||||||
|
'',
|
||||||
|
'> [!IMPORTANT]',
|
||||||
|
'> important content',
|
||||||
|
'',
|
||||||
|
'> [!WARNING]',
|
||||||
|
'> warning content',
|
||||||
|
'',
|
||||||
|
'> [!CAUTION]',
|
||||||
|
'> caution content'
|
||||||
|
].join('\n')
|
||||||
|
expect(await render(src)).toMatchInlineSnapshot(`
|
||||||
|
"<div class="note custom-block github-alert"><p class="custom-block-title">NOTE</p>
|
||||||
|
<p>note content</p>
|
||||||
|
</div>
|
||||||
|
<div class="tip custom-block github-alert"><p class="custom-block-title">TIP</p>
|
||||||
|
<p>tip content</p>
|
||||||
|
</div>
|
||||||
|
<div class="important custom-block github-alert"><p class="custom-block-title">IMPORTANT</p>
|
||||||
|
<p>important content</p>
|
||||||
|
</div>
|
||||||
|
<div class="warning custom-block github-alert"><p class="custom-block-title">WARNING</p>
|
||||||
|
<p>warning content</p>
|
||||||
|
</div>
|
||||||
|
<div class="caution custom-block github-alert"><p class="custom-block-title">CAUTION</p>
|
||||||
|
<p>caution content</p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('matches markers case-insensitively', async () => {
|
||||||
|
expect(await render('> [!tip]\n> content')).toMatchInlineSnapshot(`
|
||||||
|
"<div class="tip custom-block github-alert"><p class="custom-block-title">TIP</p>
|
||||||
|
<p>content</p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports custom titles after the marker', async () => {
|
||||||
|
expect(await render('> [!WARNING] Custom Title\n> content'))
|
||||||
|
.toMatchInlineSnapshot(`
|
||||||
|
"<div class="warning custom-block github-alert"><p class="custom-block-title">Custom Title</p>
|
||||||
|
<p>content</p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('respects custom labels from container options', async () => {
|
||||||
|
expect(
|
||||||
|
await render('> [!TIP]\n> content', { container: { tipLabel: '提示' } })
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
"<div class="tip custom-block github-alert"><p class="custom-block-title">提示</p>
|
||||||
|
<p>content</p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports block content and lazy continuation', async () => {
|
||||||
|
const src = [
|
||||||
|
'> [!NOTE]',
|
||||||
|
'> first paragraph',
|
||||||
|
'lazy continuation',
|
||||||
|
'>',
|
||||||
|
'> - list item',
|
||||||
|
'>',
|
||||||
|
'> ```js',
|
||||||
|
'> const a = 1',
|
||||||
|
'> ```'
|
||||||
|
].join('\n')
|
||||||
|
expect(await render(src)).toMatchInlineSnapshot(`
|
||||||
|
"<div class="note custom-block github-alert"><p class="custom-block-title">NOTE</p>
|
||||||
|
<p>first paragraph
|
||||||
|
lazy continuation</p>
|
||||||
|
<ul>
|
||||||
|
<li>list item</li>
|
||||||
|
</ul>
|
||||||
|
<div class="language-js"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre><code class="language-js">const a = 1
|
||||||
|
</code></pre>
|
||||||
|
</div></div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('converts markers without content', async () => {
|
||||||
|
expect(await render('> [!NOTE]')).toMatchInlineSnapshot(`
|
||||||
|
"<div class="note custom-block github-alert"><p class="custom-block-title">NOTE</p>
|
||||||
|
<p></p>
|
||||||
|
</div>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves regular blockquotes and unknown markers alone', async () => {
|
||||||
|
const src = [
|
||||||
|
'> just a quote',
|
||||||
|
'',
|
||||||
|
'> [!FOO]',
|
||||||
|
'> not an alert',
|
||||||
|
'',
|
||||||
|
'paragraph [!NOTE] not at blockquote start'
|
||||||
|
].join('\n')
|
||||||
|
expect(await render(src)).toMatchInlineSnapshot(`
|
||||||
|
"<blockquote>
|
||||||
|
<p>just a quote</p>
|
||||||
|
</blockquote>
|
||||||
|
<blockquote>
|
||||||
|
<p>[!FOO]
|
||||||
|
not an alert</p>
|
||||||
|
</blockquote>
|
||||||
|
<p>paragraph [!NOTE] not at blockquote start</p>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('can be disabled via gfmAlerts: false', async () => {
|
||||||
|
expect(await render('> [!NOTE]\n> content', { gfmAlerts: false }))
|
||||||
|
.toMatchInlineSnapshot(`
|
||||||
|
"<blockquote>
|
||||||
|
<p>[!NOTE]
|
||||||
|
content</p>
|
||||||
|
</blockquote>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import type { MarkdownItAsync } from 'markdown-it-async'
|
|
||||||
import type { ContainerOptions } from './containers'
|
|
||||||
|
|
||||||
const markerRE =
|
|
||||||
/^\[!(TIP|NOTE|INFO|IMPORTANT|WARNING|CAUTION|DANGER)\]([^\n\r]*)/i
|
|
||||||
|
|
||||||
export const gitHubAlertsPlugin = (
|
|
||||||
md: MarkdownItAsync,
|
|
||||||
options?: ContainerOptions
|
|
||||||
) => {
|
|
||||||
const titleMark = {
|
|
||||||
tip: options?.tipLabel || 'TIP',
|
|
||||||
note: options?.noteLabel || 'NOTE',
|
|
||||||
info: options?.infoLabel || 'INFO',
|
|
||||||
important: options?.importantLabel || 'IMPORTANT',
|
|
||||||
warning: options?.warningLabel || 'WARNING',
|
|
||||||
caution: options?.cautionLabel || 'CAUTION',
|
|
||||||
danger: options?.dangerLabel || 'DANGER'
|
|
||||||
} as Record<string, string>
|
|
||||||
|
|
||||||
md.core.ruler.after('block', 'github-alerts', (state) => {
|
|
||||||
const tokens = state.tokens
|
|
||||||
for (let i = 0; i < tokens.length; i++) {
|
|
||||||
if (tokens[i].type === 'blockquote_open') {
|
|
||||||
const startIndex = i
|
|
||||||
const open = tokens[startIndex]
|
|
||||||
let endIndex = i + 1
|
|
||||||
while (
|
|
||||||
endIndex < tokens.length &&
|
|
||||||
(tokens[endIndex].type !== 'blockquote_close' ||
|
|
||||||
tokens[endIndex].level !== open.level)
|
|
||||||
)
|
|
||||||
endIndex++
|
|
||||||
if (endIndex === tokens.length) continue
|
|
||||||
const close = tokens[endIndex]
|
|
||||||
const firstContent = tokens
|
|
||||||
.slice(startIndex, endIndex + 1)
|
|
||||||
.find((token) => token.type === 'inline')
|
|
||||||
if (!firstContent) continue
|
|
||||||
const match = firstContent.content.match(markerRE)
|
|
||||||
if (!match) continue
|
|
||||||
const type = match[1].toLowerCase()
|
|
||||||
const title = match[2].trim() || titleMark[type] || capitalize(type)
|
|
||||||
firstContent.content = firstContent.content
|
|
||||||
.slice(match[0].length)
|
|
||||||
.trimStart()
|
|
||||||
open.type = 'github_alert_open'
|
|
||||||
open.tag = 'div'
|
|
||||||
open.meta = {
|
|
||||||
title,
|
|
||||||
type
|
|
||||||
}
|
|
||||||
close.type = 'github_alert_close'
|
|
||||||
close.tag = 'div'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
md.renderer.rules.github_alert_open = function (tokens, idx) {
|
|
||||||
const { title, type } = tokens[idx].meta
|
|
||||||
const attrs = ''
|
|
||||||
return `<div class="${type} custom-block github-alert"${attrs}><p class="custom-block-title">${title}</p>\n`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function capitalize(str: string) {
|
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
||||||
}
|
|
||||||
Loading…
Reference in new issue