refactor(markdown): migrate containers to @mdit/plugin-container

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
Divyansh Singh 2 months ago
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>&lt;div class=&quot;vp-raw&quot;&gt;</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>
"
`)
})
})

@ -131,6 +131,7 @@
"@mdit-vue/shared": "^3.0.2",
"@mdit/plugin-anchor": "^1.1.1",
"@mdit/plugin-attrs": "^1.0.2",
"@mdit/plugin-container": "^1.0.1",
"@mdit/plugin-emoji": "^1.1.0",
"@mdit/plugin-tasklist": "^1.0.1",
"@polka/compression": "^1.0.0-next.28",
@ -143,7 +144,6 @@
"@types/cross-spawn": "^6.0.6",
"@types/lodash.template": "^4.5.3",
"@types/mark.js": "^8.11.12",
"@types/markdown-it-container": "^4.0.0",
"@types/minimist": "^1.2.5",
"@types/node": "^25.9.4",
"@types/picomatch": "^4.0.3",
@ -162,7 +162,6 @@
"markdown-it": "^14.2.0",
"markdown-it-async": "^2.2.0",
"markdown-it-cjk-friendly": "^2.0.2",
"markdown-it-container": "^4.0.0",
"markdown-it-mathjax3": "^4.3.2",
"minimist": "^1.2.8",
"nanoid": "^5.1.16",

@ -113,6 +113,9 @@ importers:
'@mdit/plugin-attrs':
specifier: ^1.0.2
version: 1.0.2(markdown-it@14.2.0)
'@mdit/plugin-container':
specifier: ^1.0.1
version: 1.0.1(markdown-it@14.2.0)
'@mdit/plugin-emoji':
specifier: ^1.1.0
version: 1.1.0(markdown-it@14.2.0)
@ -149,9 +152,6 @@ importers:
'@types/mark.js':
specifier: ^8.11.12
version: 8.11.12
'@types/markdown-it-container':
specifier: ^4.0.0
version: 4.0.0
'@types/minimist':
specifier: ^1.2.5
version: 1.2.5
@ -206,9 +206,6 @@ importers:
markdown-it-cjk-friendly:
specifier: ^2.0.2
version: 2.0.2(@types/markdown-it@14.1.2)(markdown-it@14.2.0)
markdown-it-container:
specifier: ^4.0.0
version: 4.0.0
markdown-it-mathjax3:
specifier: ^4.3.2
version: 4.3.2
@ -657,6 +654,15 @@ packages:
markdown-it:
optional: true
'@mdit/plugin-container@1.0.1':
resolution: {integrity: sha512-dT6eoqKxpWcsIHYPTNjIOq/pIzhv6LJTNXjfLSk847goQtlvMZ/Y+kRKH+wQGqDKmNwxDbEQfDbcoqhNXEjuhA==}
engines: {node: '>=22'}
peerDependencies:
markdown-it: ^14.2.0
peerDependenciesMeta:
markdown-it:
optional: true
'@mdit/plugin-emoji@1.1.0':
resolution: {integrity: sha512-rdGhZ0OVhK0EhiVpw8v22BdTq7XZ6Adrcbq3hR2Cx/YwGnL+kSXFtcGOeJXphiSics7oNgpIKdNyl974z7Cj1A==}
engines: {node: '>=22'}
@ -1088,9 +1094,6 @@ packages:
'@types/mark.js@8.11.12':
resolution: {integrity: sha512-244ZnaIBpz4c6xutliAnYVZp6xJlmC569jZqnR3ElO1Y01ooYASSVQEqpd2x0A2UfrgVMs5V9/9tUAdZaDMytQ==}
'@types/markdown-it-container@4.0.0':
resolution: {integrity: sha512-GmD8OECLfzPHv8VyvFRzslqdwXoDBJ2H40fxXFjrarbqvJZSB/BJKZXN5e3k7Mx7GQanSNzTYhzeS3H9o0gAOw==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
@ -2074,9 +2077,6 @@ packages:
'@types/markdown-it':
optional: true
markdown-it-container@4.0.0:
resolution: {integrity: sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==}
markdown-it-mathjax3@4.3.2:
resolution: {integrity: sha512-TX3GW5NjmupgFtMJGRauioMbbkGsOXAAt1DZ/rzzYmTHqzkO1rNAdiMD4NiruurToPApn2kYy76x02QN26qr2w==}
@ -3268,6 +3268,12 @@ snapshots:
optionalDependencies:
markdown-it: 14.2.0
'@mdit/plugin-container@1.0.1(markdown-it@14.2.0)':
dependencies:
'@types/markdown-it': 14.1.2
optionalDependencies:
markdown-it: 14.2.0
'@mdit/plugin-emoji@1.1.0(markdown-it@14.2.0)':
dependencies:
'@types/markdown-it': 14.1.2
@ -3581,10 +3587,6 @@ snapshots:
dependencies:
'@types/jquery': 4.0.1
'@types/markdown-it-container@4.0.0':
dependencies:
'@types/markdown-it': 14.1.2
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
@ -4547,8 +4549,6 @@ snapshots:
optionalDependencies:
'@types/markdown-it': 14.1.2
markdown-it-container@4.0.0: {}
markdown-it-mathjax3@4.3.2:
dependencies:
juice: 8.1.0

@ -36,8 +36,11 @@ import path from 'node:path'
import type { BuiltinLanguage, BuiltinTheme, Highlighter } from 'shiki'
import type { Logger } from 'vite'
import type { Awaitable } from '../shared'
import { containerPlugin, type ContainerOptions } from './plugins/containers'
import { gitHubAlertsPlugin } from './plugins/githubAlerts'
import {
containerPlugin,
gitHubAlertsPlugin,
type ContainerOptions
} from './plugins/containers'
import { highlight as createHighlighter } from './plugins/highlight'
import { imagePlugin, type Options as ImageOptions } from './plugins/image'
import { lineNumberPlugin } from './plugins/lineNumbers'
@ -354,7 +357,7 @@ export async function createMarkdownRenderer(
if (options.snippet !== false) {
snippetPlugin(md, srcDir)
}
containerPlugin(md, options.container)
containerPlugin(md, options.container, options.attrs !== false)
if (options.gfmAlerts !== false) {
gitHubAlertsPlugin(md, options.container)
}

@ -1,124 +1,191 @@
import { container } from '@mdit/plugin-container'
import type { MarkdownItAsync } from 'markdown-it-async'
import container from 'markdown-it-container'
import type { RenderRule } from 'markdown-it/lib/renderer.mjs'
import type Token from 'markdown-it/lib/token.mjs'
import type { MarkdownEnv } from '../../shared'
import { extractTitle } from './preWrapper'
export interface ContainerOptions {
infoLabel?: string
noteLabel?: string
tipLabel?: string
warningLabel?: string
dangerLabel?: string
detailsLabel?: string
importantLabel?: string
cautionLabel?: string
}
export const containerPlugin = (
md: MarkdownItAsync,
options?: ContainerOptions
options?: ContainerOptions,
attrs = true
) => {
md.use(...createContainer('tip', options?.tipLabel || 'TIP', md))
.use(...createContainer('info', options?.infoLabel || 'INFO', md))
.use(...createContainer('warning', options?.warningLabel || 'WARNING', md))
.use(...createContainer('danger', options?.dangerLabel || 'DANGER', md))
.use(...createContainer('details', options?.detailsLabel || 'Details', md))
.use(...createContainer('note', options?.noteLabel || 'NOTE', md))
.use(
...createContainer(
'important',
options?.importantLabel || 'IMPORTANT',
md
)
)
.use(...createContainer('caution', options?.cautionLabel || 'CAUTION', md))
md
// explicitly escape Vue syntax
.use(container, 'v-pre', {
render: (tokens: Token[], idx: number) =>
tokens[idx].nesting === 1 ? `<div v-pre>\n` : `</div>\n`
.use(container, {
name: 'v-pre',
openRender: () => `<div v-pre>\n`,
closeRender: () => `</div>\n`
})
.use(container, {
name: 'raw',
openRender: () => `<div class="vp-raw">\n`,
closeRender: () => `</div>\n`
})
.use(container, {
name: 'code-group',
openRender: createCodeGroupOpenRender(md),
closeRender: () => `</div></div>\n`
})
.use(container, 'raw', {
render: (tokens: Token[], idx: number) =>
tokens[idx].nesting === 1 ? `<div class="vp-raw">\n` : `</div>\n`
for (const [name, defaultTitle] of Object.entries(resolveTitles(options))) {
md.use(container, {
name,
openRender: createOpenRender(md, name, defaultTitle, attrs),
closeRender: () => (name === 'details' ? `</details>\n` : `</div>\n`)
})
.use(...createCodeGroup(md))
}
}
type ContainerArgs = [typeof container, string, { render: RenderRule }]
function resolveTitles(options?: ContainerOptions): Record<string, string> {
return {
tip: options?.tipLabel || 'TIP',
info: options?.infoLabel || 'INFO',
warning: options?.warningLabel || 'WARNING',
danger: options?.dangerLabel || 'DANGER',
details: options?.detailsLabel || 'Details',
note: options?.noteLabel || 'NOTE',
important: options?.importantLabel || 'IMPORTANT',
caution: options?.cautionLabel || 'CAUTION'
}
}
function createContainer(
klass: string,
function createOpenRender(
md: MarkdownItAsync,
name: string,
defaultTitle: string,
md: MarkdownItAsync
): ContainerArgs {
return [
container,
klass,
{
render(tokens, idx, _options, env: MarkdownEnv & { references?: any }) {
const token = tokens[idx]
if (token.nesting === 1) {
token.attrJoin('class', `${klass} custom-block`)
const attrs = md.renderer.renderAttrs(token)
const info = token.info.trim().slice(klass.length).trim()
const title = md.renderInline(info || defaultTitle, {
references: env.references
})
const titleClass =
'custom-block-title' + (info ? '' : ' custom-block-title-default')
if (klass === 'details')
return `<details ${attrs}><summary>${title}</summary>\n`
return `<div ${attrs}><p class="${titleClass}">${title}</p>\n`
} else return klass === 'details' ? `</details>\n` : `</div>\n`
}
attrs: boolean
): RenderRule {
return (tokens, idx, _options, env: MarkdownEnv & { references?: any }) => {
const token = tokens[idx]
let info = token.info.trim().slice(name.length).trim()
if (attrs) info = applyFenceAttrs(token, info)
token.attrJoin('class', `${name} custom-block`)
const renderedAttrs = md.renderer.renderAttrs(token)
const title = md.renderInline(info || defaultTitle, {
references: env.references
})
if (name === 'details')
return `<details ${renderedAttrs}><summary>${title}</summary>\n`
const titleClass =
'custom-block-title' + (info ? '' : ' custom-block-title-default')
return `<div ${renderedAttrs}><p class="${titleClass}">${title}</p>\n`
}
}
// `::: tip Title {#id .class key="value" bare}` - the attrs plugin only
// handles fence lines through its `fence` rule, which is disabled to keep
// code block meta intact, so its trailing-braces syntax is parsed here
const fenceAttrsRE = /(?:^|\s)\{\s*([^{}]+?)\s*\}$/
const attrRE = /([^\s=]+)(?:=("[^"]*"|\S*))?/g
function applyFenceAttrs(token: Token, info: string): string {
const match = fenceAttrsRE.exec(info)
if (!match) return info
for (const [, name, value = ''] of match[1].matchAll(attrRE)) {
if (name.startsWith('.')) {
if (name.length > 1) token.attrJoin('class', name.slice(1))
} else if (name.startsWith('#')) {
if (name.length > 1) token.attrPush(['id', name.slice(1)])
} else {
token.attrPush([name, value.replace(/^"(.*)"$/, '$1')])
}
]
}
return info.slice(0, match.index)
}
function createCodeGroup(md: MarkdownItAsync): ContainerArgs {
return [
container,
'code-group',
{
render(tokens, idx) {
if (tokens[idx].nesting === 1) {
let tabs = ''
let checked = 'checked'
for (
let i = idx + 1;
!(
tokens[i].nesting === -1 &&
tokens[i].type === 'container_code-group_close'
);
++i
) {
const isHtml = tokens[i].type === 'html_block'
if (
(tokens[i].type === 'fence' && tokens[i].tag === 'code') ||
isHtml
) {
const title = extractTitle(
isHtml ? tokens[i].content : tokens[i].info,
isHtml
)
if (title) {
tabs += `<input type="radio" name="group-${idx}" id="tab-${i}" ${checked}><label data-title="${md.utils.escapeHtml(title)}" for="tab-${i}">${title}</label>`
if (checked && !isHtml) tokens[i].info += ' active'
checked = ''
}
}
}
return `<div class="vp-code-group"><div class="tabs">${tabs}</div><div class="blocks">\n`
function createCodeGroupOpenRender(md: MarkdownItAsync): RenderRule {
return (tokens, idx) => {
let tabs = ''
let checked = 'checked'
for (
let i = idx + 1;
!(
tokens[i].nesting === -1 &&
tokens[i].type === 'container_code-group_close'
);
++i
) {
const isHtml = tokens[i].type === 'html_block'
if ((tokens[i].type === 'fence' && tokens[i].tag === 'code') || isHtml) {
const title = extractTitle(
isHtml ? tokens[i].content : tokens[i].info,
isHtml
)
if (title) {
tabs += `<input type="radio" name="group-${idx}" id="tab-${i}" ${checked}><label data-title="${md.utils.escapeHtml(title)}" for="tab-${i}">${title}</label>`
if (checked && !isHtml) tokens[i].info += ' active'
checked = ''
}
return `</div></div>\n`
}
}
]
return `<div class="vp-code-group"><div class="tabs">${tabs}</div><div class="blocks">\n`
}
}
export interface ContainerOptions {
infoLabel?: string
noteLabel?: string
tipLabel?: string
warningLabel?: string
dangerLabel?: string
detailsLabel?: string
importantLabel?: string
cautionLabel?: string
const alertMarkerRE = /^\[!([\w-]+)\]([^\n\r]*)/
export const gitHubAlertsPlugin = (
md: MarkdownItAsync,
options?: ContainerOptions
) => {
const titles = resolveTitles(options)
// details makes no sense as a blockquote-style alert
delete titles.details
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(alertMarkerRE)
if (!match) continue
const type = match[1].toLowerCase()
if (!Object.hasOwn(titles, type)) continue
const title = match[2].trim() || titles[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
return `<div class="${type} custom-block github-alert"><p class="custom-block-title">${title}</p>\n`
}
}

@ -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…
Cancel
Save