feat(plugin-md-power): add chat container syntax support (#537)

This commit is contained in:
pengzhanbo 2025-03-30 00:10:45 +08:00 committed by GitHub
parent 497fe23001
commit 6237446482
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 660 additions and 0 deletions

View File

@ -48,6 +48,7 @@ export const themeGuide = defineNoteConfig({
'collapse',
'npm-to',
'caniuse',
'chat',
'include',
],
},

View File

@ -30,6 +30,7 @@ export const theme: Theme = plumeTheme({
abbr: true,
timeline: true,
collapse: true,
chat: true,
imageSize: 'all',
pdf: true,
caniuse: true,

View File

@ -167,6 +167,12 @@ export default defineUserConfig({
- **默认值**: `false`
- **详情**: 是否启用折叠面板容器语法
### chat
- **类型**: `boolean`
- **默认值**: `false`
- **详情**: 是否启用对话记录容器
### demo
- **类型**: `boolean`

View File

@ -0,0 +1,123 @@
---
title: 对话记录
icon: cil:chat-bubble
createTime: 2025/03/24 21:40:18
permalink: /guide/markdown/chat/
badge:
type: tip
text: 1.0.0-rc.138
---
## 前言
::: chat title="阿 B"
{:2025-03-24 10:15:00}
{阿 B}
在文档里放聊天记录截图还是太难看了,有没有更好的方法?\[doge\]
{.}
有的,兄弟,包有的
{.}
但是挂聊天记录真的没问题吗?
{阿 B}
祖安对线,战绩可查 \[doge\]
{:2025-03-24 15:32:00}
{.}
好消息:文档支持聊天记录了!
{.}
坏消息:我把你挂出来了 \[doge\]
{阿 B}
:::
## 概述
在 Markdown 中,使用 `:: chat` 容器包裹带有特定标记的文本内容,可以在文档中显示 ==聊天记录==。
::: warning 这是一个大多数时候都用不上的功能,需要使用时请斟酌是否要这么做,对于涉及隐私的内容请自行过滤。
:::
## 启用
该功能默认不启用,你需要在 `theme` 配置中启用。
```ts title=".vuepress/config.ts"
export default defineUserConfig({
theme: plumeTheme({
markdown: {
chat: true, // [!code ++]
}
})
})
```
## 使用
`::: chat` 容器中,使用特定的标记,标识消息的发送者和发送时间,然后在文档中显示聊天记录。
```md
::: chat title="标题"
{:date} <!-- 标记起始时间 -->
{username} <!-- 标记发送者 -->
xxx <!-- 发送者的消息内容 -->
{.} <!-- 标记为本人发送 -->
xxx <!-- 本人的消息内容 -->
:::
```
- `{:date}` 标记起始时间 (可选)。使用 `{:` + date + `}` 标记date 可以为常见的日期格式。
主题不对 `date` 做任何处理,只是简单的渲染。
- `{username}` 标记后续内容的发送者,使用 `{` + username + `}` 标记username 可以为任意字符串。
- `{.}` 标记为本人发送
## 示例
__输入__
``` md
::: chat title="标题"
{:2025-03-24 10:15:00}
{用户一}
用户一的消息
{.}
本人的消息
{用户二}
用户二的消息
{.}
本人的消息
:::
```
__输出__
::: chat title="标题"
{:2025-03-24 10:15:00}
{用户一}
用户一的消息
{.}
本人的消息
{用户二}
用户二的消息
{.}
本人的消息
:::

View File

@ -0,0 +1,147 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`chatPlugin > should not work 1`] = `
"<p>::: char
not work
:::</p>
"
`;
exports[`chatPlugin > should work 1`] = `
"<div class="vp-chat">
<div class="vp-chat-header">
<p class="vp-chat-title">用户聊天</p>
</div>
<div class="vp-chat-content">
<div class="vp-chat-date"><span>2025-06-01 12:00:00</span></div>
<div class="vp-chat-message user">
<div class="vp-chat-message-body">
<p class="vp-chat-username">用户一</p>
<div class="message-content">
<p>这是用户一的消息</p>
</div>
</div>
</div>
<div class="vp-chat-message self">
<div class="vp-chat-message-body">
<div class="message-content">
<p>这是本人的消息</p>
</div>
</div>
</div>
</div>
</div>"
`;
exports[`chatPlugin > should work multiple date and user 1`] = `
"<div class="vp-chat">
<div class="vp-chat-header">
<p class="vp-chat-title">Chat</p>
</div>
<div class="vp-chat-content">
<div class="vp-chat-date"><span>2025-06-01 12:00:00</span></div>
<div class="vp-chat-message user">
<div class="vp-chat-message-body">
<p class="vp-chat-username">用户一</p>
<div class="message-content">
<p>这是用户一的消息</p>
</div>
</div>
</div>
<div class="vp-chat-message self">
<div class="vp-chat-message-body">
<div class="message-content">
<p>这是本人的消息</p>
</div>
</div>
</div>
<div class="vp-chat-message user">
<div class="vp-chat-message-body">
<p class="vp-chat-username">用户二</p>
<div class="message-content">
<p>这是用户二的消息</p>
</div>
</div>
</div>
<div class="vp-chat-message self">
<div class="vp-chat-message-body">
<div class="message-content">
<p>这是本人的消息</p>
</div>
</div>
</div>
<div class="vp-chat-date"><span>2025-06-05 12:00:00</span></div>
<div class="vp-chat-message user">
<div class="vp-chat-message-body">
<p class="vp-chat-username">用户一</p>
<div class="message-content">
<p>这是用户一的消息</p>
</div>
</div>
</div>
<div class="vp-chat-message self">
<div class="vp-chat-message-body">
<div class="message-content">
<p>这是本人的消息</p>
</div>
</div>
</div>
<div class="vp-chat-message user">
<div class="vp-chat-message-body">
<p class="vp-chat-username">用户二</p>
<div class="message-content">
<p>这是用户二的消息</p>
</div>
</div>
</div>
<div class="vp-chat-message self">
<div class="vp-chat-message-body">
<div class="message-content">
<p>这是本人的消息</p>
</div>
</div>
</div>
</div>
</div>"
`;
exports[`chatPlugin > should work multiple users 1`] = `
"<div class="vp-chat">
<div class="vp-chat-header">
<p class="vp-chat-title">Chat</p>
</div>
<div class="vp-chat-content">
<div class="vp-chat-date"><span>2025-06-01 12:00:00</span></div>
<div class="vp-chat-message user">
<div class="vp-chat-message-body">
<p class="vp-chat-username">用户一</p>
<div class="message-content">
<p>这是用户一的消息</p>
</div>
</div>
</div>
<div class="vp-chat-message self">
<div class="vp-chat-message-body">
<div class="message-content">
<p>这是本人的消息</p>
</div>
</div>
</div>
<div class="vp-chat-message user">
<div class="vp-chat-message-body">
<p class="vp-chat-username">用户二</p>
<div class="message-content">
<p>这是用户二的消息</p>
</div>
</div>
</div>
<div class="vp-chat-message self">
<div class="vp-chat-message-body">
<div class="message-content">
<p>这是本人的消息</p>
</div>
</div>
</div>
</div>
</div>"
`;

View File

@ -0,0 +1,82 @@
import MarkdownIt from 'markdown-it'
import { describe, expect, it } from 'vitest'
import { chatPlugin } from '../src/node/container/chat.js'
describe('chatPlugin', () => {
const md = new MarkdownIt().use(chatPlugin)
it('should work', () => {
const code = `\
::: chat title="用户聊天"
{:2025-06-01 12:00:00}
{}
{.}
:::
`
expect(md.render(code)).toMatchSnapshot()
})
it('should work multiple users', () => {
const code = `\
::: chat
{:2025-06-01 12:00:00}
{}
{.}
{}
{.}
:::
`
expect(md.render(code)).toMatchSnapshot()
})
it('should work multiple date and user', () => {
const code = `\
::: chat
{:2025-06-01 12:00:00}
{}
{.}
{}
{.}
{:2025-06-05 12:00:00}
{}
{.}
{}
{.}
:::
`
expect(md.render(code)).toMatchSnapshot()
})
it('should not work', () => {
const code = `\
::: char
not work
:::
`
expect(md.render(code)).toMatchSnapshot()
})
})

View File

@ -0,0 +1,129 @@
:root {
--vp-chat-c-bg: var(--vp-c-bg-soft);
--vp-chat-c-bg-header: var(--vp-c-bg-soft);
--vp-chat-c-bg-content: var(--vp-c-bg-soft);
--vp-chat-c-bg-user: var(--vp-c-bg);
--vp-chat-c-bg-self: var(--vp-c-brand-soft);
--vp-chat-c-title: var(--vp-c-text-1);
--vp-chat-c-text: var(--vp-c-text-1);
--vp-chat-c-date: var(--vp-c-text-3);
--vp-chat-c-username: var(--vp-c-text-2);
}
.vp-chat {
width: 100%;
margin: 16px 0;
overflow: hidden;
background-color: var(--vp-chat-c-bg);
border-radius: 6px;
transition: background-color var(--vp-t-color);
}
@media (min-width: 640px) {
.vp-chat {
width: 320px;
margin: 16px auto;
}
}
@media (min-width: 768px) {
.vp-chat {
width: 360px;
}
}
@media (min-width: 960px) {
.vp-chat {
width: 480px;
}
}
.vp-chat .vp-chat-header {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
background-color: var(--vp-chat-c-bg-header);
transition: background-color var(--vp-t-color);
}
.vp-chat .vp-chat-title {
flex: 1 2;
font-weight: 600;
color: var(--vp-chat-c-title);
text-align: center;
transition: color var(--vp-t-color);
}
.vp-chat .vp-chat-content {
padding: 0 16px 24px;
background-color: var(--vp-chat-c-bg-content);
transition: background-color var(--vp-t-color);
}
.vp-chat .vp-chat-date {
display: flex;
align-items: center;
justify-content: center;
margin: 16px 0;
font-size: 12px;
color: var(--vp-chat-c-date);
transition: color var(--vp-t-color);
}
.vp-chat .vp-chat-message {
display: flex;
margin-bottom: 16px;
}
.vp-chat .vp-chat-message.self {
justify-content: flex-end;
}
.vp-chat .vp-chat-message:last-child {
margin-bottom: 0;
}
.vp-chat .vp-chat-message-body {
flex-shrink: 2;
padding-right: 32px;
}
.vp-chat .vp-chat-message.self .vp-chat-message-body {
padding-right: 0;
padding-left: 32px;
}
.vp-chat .vp-chat-username {
margin: 0;
font-size: 14px;
font-weight: 500;
color: var(--vp-chat-c-username);
}
.vp-chat .vp-chat-message-body .message-content {
max-width: 100%;
padding: 8px 16px;
font-size: 14px;
line-height: 22px;
color: var(--vp-chat-c-text);
background-color: var(--vp-chat-c-bg-user);
border-radius: 6px;
box-shadow: var(--vp-shadow-1);
}
.vp-chat .vp-chat-message.self .vp-chat-message-body .message-content {
background-color: var(--vp-chat-c-bg-self);
}
.vp-chat .vp-chat-message-body .message-content :where(p, ul, ol) {
margin: 8px 0;
}
.vp-chat .vp-chat-message-body .message-content :first-child {
margin-top: 0;
}
.vp-chat .vp-chat-message-body .message-content :last-child {
margin-bottom: 0;
}

View File

@ -0,0 +1,144 @@
/**
* ::: chat
* {:time}
*
* {user-1}
* xxxxxxxx
*
* {.}
* xxxxxx
* :::
*/
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
}
interface ChatMessage {
sender: 'user' | 'self'
date: string
username: string
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 }) => {
let messageContent = ''
if (!currentDate || currentDate !== date) {
currentDate = date
messageContent += `<div class="vp-chat-date"><span>${currentDate}</span></div>\n`
}
messageContent += `<div class="vp-chat-message ${sender}">
<div class="vp-chat-message-body">\
${sender === 'user' ? `\n<p class="vp-chat-username">${username}</p>` : ''}
<div class="message-content">
${md.render(content.join('\n'), cleanMarkdownEnv(env)).trim()}
</div>
</div>
</div>`
return messageContent
})
.join('\n')
}
// 解析聊天内容的核心逻辑
function parseChatContent(content: string): ChatMessage[] {
const lines = content.split('\n')
const messages: ChatMessage[] = []
let currentDate = ''
let message!: ChatMessage
for (const line of lines) {
const lineStr = line.trim()
// 解析时间标记
if (lineStr.startsWith('{:') && lineStr.endsWith('}')) {
currentDate = lineStr.slice(2, -1).trim()
continue
}
// 解析用户 / 本人标记
if (lineStr.startsWith('{') && lineStr.endsWith('}')) {
const username = lineStr.slice(1, -1).trim()
message = {
sender: username === '.' ? 'self' : 'user',
username,
date: currentDate,
content: [],
}
messages.push(message)
continue
}
// 收集消息内容
if (message?.sender) {
message.content.push(line)
}
}
return messages
}

View File

@ -4,6 +4,7 @@ import type { MarkdownPowerPluginOptions } from '../../shared/index.js'
import { isPlainObject } from '@vuepress/helper'
import { alignPlugin } from './align.js'
import { cardPlugin } from './card.js'
import { chatPlugin } from './chat.js'
import { codeTabs } from './codeTabs.js'
import { collapsePlugin } from './collapse.js'
import { demoWrapperPlugin } from './demoWrapper.js'
@ -54,4 +55,7 @@ export async function containerPlugin(
if (options.collapse)
collapsePlugin(md)
if (options.chat)
chatPlugin(md)
}

View File

@ -116,6 +116,10 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
enhances.add(`app.component('VPCollapseItem', VPCollapseItem)`)
}
if (options.chat) {
imports.add(`import '${CLIENT_FOLDER}styles/chat.css'`)
}
return app.writeTemp(
'md-power/config.js',
`\

View File

@ -91,6 +91,24 @@ export interface MarkdownPowerPluginOptions {
*/
collapse?: boolean
/**
* chat
*
* ```md
* ::: chat
* {:date}
*
* {user}
* message
*
* {.}
* message
* :::
* ```
* @default false
*/
chat?: boolean
// video embed
/**
* bilibili

View File

@ -59,6 +59,7 @@ export const MARKDOWN_POWER_FIELDS: (keyof MarkdownPowerPluginOptions)[] = [
'replit',
'timeline',
'collapse',
'chat',
'youtube',
]