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
+hidden content
+STOP
+Danger zone, do not proceed
+A bold title with code
content
+console.log('hi')
+
+ See the guide
+content
+提示
+提示内容
+详情内容
+content
+Custom
+content
+Custom
+content
+content
+{{ this will be displayed as-is }}
+Wraps in a <div class="vp-raw">
const a = 1
+
+ const a: number = 1
+
+ Outer
+outer content
+inner content
+WARNING
+no closing fence
+TIP
+content
+::: 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
+TIP
+content
+Custom Title
+content
+提示
+content
+NOTE
+first paragraph + lazy continuation
+const a = 1
+
+ NOTE
+ +++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(` + "++ " + `) + }) +}) 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 ? `[!NOTE] + content
+
${title}
\n` - } else return klass === 'details' ? `\n` : `${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 `${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${title}
\n` - } -} - -function capitalize(str: string) { - return str.charAt(0).toUpperCase() + str.slice(1) -}