diff --git a/__tests__/unit/node/markdown/plugins/containers.test.ts b/__tests__/unit/node/markdown/plugins/containers.test.ts new file mode 100644 index 00000000..ca2ec1ec --- /dev/null +++ b/__tests__/unit/node/markdown/plugins/containers.test.ts @@ -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(` + "

TIP

+

content of tip

+
+

INFO

+

content of info

+
+

WARNING

+

content of warning

+
+

DANGER

+

content of danger

+
+

NOTE

+

content of note

+
+

IMPORTANT

+

content of important

+
+

CAUTION

+

content of caution

+
+ " + `) + }) + + test('renders details as a disclosure with summary', async () => { + expect(await render('::: details\nhidden content\n:::')) + .toMatchInlineSnapshot(` + "
Details +

hidden content

+
+ " + `) + }) + + 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(` + "

STOP

+

Danger zone, do not proceed

+
+

A bold title with code

+

content

+
+
Click me to toggle the code +
js
console.log('hi')
+      
+
+ " + `) + }) + + test('resolves reference links in titles', async () => { + const src = [ + '::: tip See [the guide][guide]', + 'content', + ':::', + '', + '[guide]: /guide/' + ].join('\n') + expect(await render(src)).toMatchInlineSnapshot(` + "

See the guide

+

content

+
+ " + `) + }) + + 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(` + "

提示

+

提示内容

+
+
详细信息 +

详情内容

+
+ " + `) + }) + + 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(` + "
Click me +

content

+
+

Custom

+

content

+
+ " + `) + }) + + 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(` + "

Custom

+

content

+
+ " + `) + }) + + test('keeps fence line braces verbatim when attrs are disabled', async () => { + expect( + await render('::: details Click me {open}\ncontent\n:::', { + attrs: false + }) + ).toMatchInlineSnapshot(` + "
Click me {open} +

content

+
+ " + `) + }) + + 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 `
`', + ':::' + ].join('\n') + expect(await render(src)).toMatchInlineSnapshot(` + "
+

{{ this will be displayed as-is }}

+
+
+

Wraps in a <div class="vp-raw">

+
+ " + `) + }) + + 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(` + "
+
js
const a = 1
+      
+
ts
const a: number = 1
+      
+
+ " + `) + }) + + test('supports nesting via longer fences', async () => { + const src = [ + ':::: info Outer', + 'outer content', + '', + '::: details Inner', + 'inner content', + ':::', + '::::' + ].join('\n') + expect(await render(src)).toMatchInlineSnapshot(` + "

Outer

+

outer content

+
Inner +

inner content

+
+
+ " + `) + }) + + test('auto-closes unclosed containers', async () => { + expect(await render('::: warning\nno closing fence')) + .toMatchInlineSnapshot(` + "

WARNING

+

no closing fence

+
+ " + `) + }) + + test('parses fences without a space before the name', async () => { + expect(await render(':::tip\ncontent\n:::')).toMatchInlineSnapshot(` + "

TIP

+

content

+
+ " + `) + }) + + test('leaves non-container fence lines alone', async () => { + expect(await render('::: unknown\ncontent\n:::')).toMatchInlineSnapshot(` + "

::: unknown + content + :::

+ " + `) + }) +}) + +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(` + "

NOTE

+

note content

+
+

TIP

+

tip content

+
+

IMPORTANT

+

important content

+
+

WARNING

+

warning content

+
+

CAUTION

+

caution content

+
+ " + `) + }) + + test('matches markers case-insensitively', async () => { + expect(await render('> [!tip]\n> content')).toMatchInlineSnapshot(` + "

TIP

+

content

+
+ " + `) + }) + + test('supports custom titles after the marker', async () => { + expect(await render('> [!WARNING] Custom Title\n> content')) + .toMatchInlineSnapshot(` + "

Custom Title

+

content

+
+ " + `) + }) + + test('respects custom labels from container options', async () => { + expect( + await render('> [!TIP]\n> content', { container: { tipLabel: '提示' } }) + ).toMatchInlineSnapshot(` + "

提示

+

content

+
+ " + `) + }) + + 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(` + "

NOTE

+

first paragraph + lazy continuation

+ +
js
const a = 1
+      
+
+ " + `) + }) + + test('converts markers without content', async () => { + expect(await render('> [!NOTE]')).toMatchInlineSnapshot(` + "

NOTE

+

+
+ " + `) + }) + + 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(` + "
+

just a quote

+
+
+

[!FOO] + not an alert

+
+

paragraph [!NOTE] not at blockquote start

+ " + `) + }) + + test('can be disabled via gfmAlerts: false', async () => { + expect(await render('> [!NOTE]\n> content', { gfmAlerts: false })) + .toMatchInlineSnapshot(` + "
+

[!NOTE] + content

+
+ " + `) + }) +}) diff --git a/package.json b/package.json index 8cf5a5ca..b12c36d7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c9da2e3..138dd9bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 72e3a40b..566d5f64 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -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) } diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts index 20a49167..00cb8880 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -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 ? `
\n` : `
\n` + .use(container, { + name: 'v-pre', + openRender: () => `
\n`, + closeRender: () => `
\n` + }) + .use(container, { + name: 'raw', + openRender: () => `
\n`, + closeRender: () => `
\n` + }) + .use(container, { + name: 'code-group', + openRender: createCodeGroupOpenRender(md), + closeRender: () => `
\n` }) - .use(container, 'raw', { - render: (tokens: Token[], idx: number) => - tokens[idx].nesting === 1 ? `
\n` : `
\n` + + for (const [name, defaultTitle] of Object.entries(resolveTitles(options))) { + md.use(container, { + name, + openRender: createOpenRender(md, name, defaultTitle, attrs), + closeRender: () => (name === 'details' ? `\n` : `\n`) }) - .use(...createCodeGroup(md)) + } } -type ContainerArgs = [typeof container, string, { render: RenderRule }] +function resolveTitles(options?: ContainerOptions): Record { + 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 `
${title}\n` - return `

${title}

\n` - } else return klass === 'details' ? `
\n` : `\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 `
${title}\n` + const titleClass = + 'custom-block-title' + (info ? '' : ' custom-block-title-default') + return `

${title}

\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 += `` - - if (checked && !isHtml) tokens[i].info += ' active' - checked = '' - } - } - } - - return `
${tabs}
\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 += `` + + if (checked && !isHtml) tokens[i].info += ' active' + checked = '' } - return `
\n` } } - ] + + return `
${tabs}
\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 `

${title}

\n` + } } diff --git a/src/node/markdown/plugins/githubAlerts.ts b/src/node/markdown/plugins/githubAlerts.ts deleted file mode 100644 index 7e065f7a..00000000 --- a/src/node/markdown/plugins/githubAlerts.ts +++ /dev/null @@ -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 - - 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 `

${title}

\n` - } -} - -function capitalize(str: string) { - return str.charAt(0).toUpperCase() + str.slice(1) -}