feat(plugin-md-power): add createContainerSyntaxPlugin for custom container rendering (#564)

This commit is contained in:
pengzhanbo 2025-04-26 15:59:08 +08:00 committed by GitHub
parent 58381ba294
commit 2e7f69bb62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 136 additions and 65 deletions

View File

@ -1,6 +1,6 @@
import MarkdownIt from 'markdown-it'
import { describe, expect, it } from 'vitest'
import { createContainerPlugin } from '../src/node/container/createContainer.js'
import { createContainerPlugin, createContainerSyntaxPlugin } from '../src/node/container/createContainer.js'
describe('createContainerPlugin', () => {
it('should work with default options', () => {
@ -20,3 +20,37 @@ describe('createContainerPlugin', () => {
expect(md.render(':::test\ncontent\n:::')).toContain('class="test"')
})
})
describe('createContainerSyntaxPlugin', () => {
it('should work with default options', () => {
const md = new MarkdownIt()
createContainerSyntaxPlugin(md, 'test')
const rendered = md.render(':::test\ncontent\n:::')
expect(rendered).toContain('class="custom-container test"')
expect(rendered).toContain('content')
})
it('should work with more than 3 markers', () => {
const md = new MarkdownIt()
createContainerSyntaxPlugin(md, 'test')
expect(md.render('::::test\ncontent\n::::')).toContain('class="custom-container test"')
expect(md.render(':::::test\ncontent\n:::::')).toContain('class="custom-container test"')
})
it('should work with custom render', () => {
const md = new MarkdownIt()
createContainerSyntaxPlugin(md, 'test', (tokens, index) => `<div class="test">${tokens[index].content} ${tokens[index].meta.title}</div>`)
const rendered = md.render(':::test title="title"\ncontent\n:::')
expect(rendered).toContain('class="test"')
expect(rendered).toContain('content\n title')
})
it('should not work', () => {
const md = new MarkdownIt()
createContainerSyntaxPlugin(md, 'test')
expect(md.render('::test\ncontent\n::')).not.toContain('class="custom-container test"')
expect(md.render(':::text\ncontent\n:::')).not.toContain('class="custom-container text"')
expect(md.render('::test\ncontent\n::')).not.toContain('class="custom-container test"')
})
})

View File

@ -10,15 +10,9 @@
* :::
*/
import type { PluginSimple } from 'markdown-it'
import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs'
import type Token from 'markdown-it/lib/token.mjs'
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
import { resolveAttrs } from '.././utils/resolveAttrs.js'
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
interface ChatMeta {
title?: string
}
import { createContainerSyntaxPlugin } from './createContainer.js'
interface ChatMessage {
sender: 'user' | 'self'
@ -27,63 +21,6 @@ interface ChatMessage {
content: string[]
}
export const chatPlugin: PluginSimple = (md) => {
md.block.ruler.before('fence', 'chat_def', chatDef)
md.renderer.rules.chat_container = (tokens: Token[], idx: number, _, env) => {
const { meta, content } = tokens[idx]
const { title } = meta as ChatMeta
const messages = parseChatContent(content)
return `<div class="vp-chat">
<div class="vp-chat-header">
<p class="vp-chat-title">${title || 'Chat'}</p>
</div>
<div class="vp-chat-content">
${chatMessagesRender(md, env, messages)}
</div>
</div>`
}
}
function chatDef(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
const start = state.bMarks[startLine] + state.tShift[startLine]
const max = state.eMarks[startLine]
let pos = start
if (state.src.slice(pos, pos + 3) !== ':::')
return false
pos += 3
const info = state.src.slice(start + 3, max).trim()
if (!info.startsWith('chat'))
return false
/* istanbul ignore if -- @preserve */
if (silent)
return true
let line = startLine
let content = ''
while (++line < endLine) {
if (state.src.slice(state.bMarks[line], state.eMarks[line]).trim() === ':::') {
break
}
content += `${state.src.slice(state.bMarks[line], state.eMarks[line])}\n`
}
const token = state.push('chat_container', '', 0)
token.meta = resolveAttrs<ChatMeta>(info).attrs
token.content = content
token.markup = '::: chat'
token.map = [startLine, line + 1]
state.line = line + 1
return true
}
function chatMessagesRender(md: Markdown, env: MarkdownEnv, messages: ChatMessage[]): string {
let currentDate = ''
return messages.map(({ sender, username, date, content }) => {
@ -142,3 +79,16 @@ function parseChatContent(content: string): ChatMessage[] {
}
return messages
}
export const chatPlugin: PluginSimple = md => createContainerSyntaxPlugin(
md,
'chat',
(tokens, idx, _, env) => `<div class="vp-chat">
<div class="vp-chat-header">
<p class="vp-chat-title">${tokens[idx].meta?.title || 'Chat'}</p>
</div>
<div class="vp-chat-content">
${chatMessagesRender(md, env, parseChatContent(tokens[idx].content))}
</div>
</div>`,
)

View File

@ -1,6 +1,8 @@
import type { RenderRule } from 'markdown-it/lib/renderer.mjs'
import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs'
import type { Markdown } from 'vuepress/markdown'
import container from 'markdown-it-container'
import { resolveAttrs } from '../utils/resolveAttrs.js'
type RenderRuleParams = Parameters<RenderRule> extends [...infer Args, infer _] ? Args : never
@ -27,3 +29,88 @@ export function createContainerPlugin(
md.use(container, type, { render })
}
/**
* markdown-it
* content
* ```md
* ::: type
* xxxx <-- content: 这部分的内容不会交给 markdown-it
* :::
* ```
*
* @example
* ```ts
* const example = createContainerSyntaxPlugin(md, 'example', (tokens, index, options, env) => {
* const { content, meta } = tokens[index]
* return `<div class="example">${meta.title} | ${content}</div>`
* })
* ```
*/
export function createContainerSyntaxPlugin(
md: Markdown,
type: string,
render?: RenderRule,
) {
const maker = ':'
const markerMinLen = 3
function defineContainer(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
const start = state.bMarks[startLine] + state.tShift[startLine]
const max = state.eMarks[startLine]
let pos = start
// check marker
if (state.src[pos] !== maker)
return false
pos += markerMinLen
for (pos = start + 1; pos <= max; pos++) {
if (state.src[pos] !== maker)
break
}
if (pos - start < markerMinLen)
return false
const markup = state.src.slice(start, pos)
const info = state.src.slice(pos, max).trim()
// ::: type
if (!info.startsWith(type))
return false
/* istanbul ignore if -- @preserve */
if (silent)
return true
let line = startLine
let content = ''
while (++line < endLine) {
if (state.src.slice(state.bMarks[line], state.eMarks[line]).trim() === markup) {
break
}
content += `${state.src.slice(state.bMarks[line], state.eMarks[line])}\n`
}
const token = state.push(`${type}_container`, '', 0)
token.meta = resolveAttrs(info.slice(type.length)).attrs
token.content = content
token.markup = `${markup} ${type}`
token.map = [startLine, line + 1]
state.line = line + 1
return true
}
const defaultRender: RenderRule = (tokens, index) => {
const { content } = tokens[index]
return `<div class="custom-container ${type}">${content}</div>`
}
md.block.ruler.before('fence', `${type}_definition`, defineContainer)
md.renderer.rules[`${type}_container`] = render ?? defaultRender
}