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