diff --git a/docs/.vuepress/notes/zh/theme-guide.ts b/docs/.vuepress/notes/zh/theme-guide.ts index 09a96462..0f35a093 100644 --- a/docs/.vuepress/notes/zh/theme-guide.ts +++ b/docs/.vuepress/notes/zh/theme-guide.ts @@ -48,6 +48,7 @@ export const themeGuide = defineNoteConfig({ 'collapse', 'npm-to', 'caniuse', + 'chat', 'include', ], }, diff --git a/docs/.vuepress/theme.ts b/docs/.vuepress/theme.ts index aea313f1..1d98e043 100644 --- a/docs/.vuepress/theme.ts +++ b/docs/.vuepress/theme.ts @@ -30,6 +30,7 @@ export const theme: Theme = plumeTheme({ abbr: true, timeline: true, collapse: true, + chat: true, imageSize: 'all', pdf: true, caniuse: true, diff --git a/docs/notes/theme/config/markdown.md b/docs/notes/theme/config/markdown.md index 45babe5e..82446eec 100644 --- a/docs/notes/theme/config/markdown.md +++ b/docs/notes/theme/config/markdown.md @@ -167,6 +167,12 @@ export default defineUserConfig({ - **默认值**: `false` - **详情**: 是否启用折叠面板容器语法 +### chat + +- **类型**: `boolean` +- **默认值**: `false` +- **详情**: 是否启用对话记录容器 + ### demo - **类型**: `boolean` diff --git a/docs/notes/theme/guide/markdown/chat.md b/docs/notes/theme/guide/markdown/chat.md new file mode 100644 index 00000000..c8a5b25a --- /dev/null +++ b/docs/notes/theme/guide/markdown/chat.md @@ -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} + +{用户一} +用户一的消息 + +{.} +本人的消息 + +{用户二} +用户二的消息 + +{.} +本人的消息 +::: diff --git a/plugins/plugin-md-power/__test__/__snapshots__/chatPlugin.spec.ts.snap b/plugins/plugin-md-power/__test__/__snapshots__/chatPlugin.spec.ts.snap new file mode 100644 index 00000000..d79f075c --- /dev/null +++ b/plugins/plugin-md-power/__test__/__snapshots__/chatPlugin.spec.ts.snap @@ -0,0 +1,147 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`chatPlugin > should not work 1`] = ` +"

::: char +not work +:::

+" +`; + +exports[`chatPlugin > should work 1`] = ` +"
+
+

用户聊天

+
+
+
2025-06-01 12:00:00
+
+
+

用户一

+
+

这是用户一的消息

+
+
+
+
+
+
+

这是本人的消息

+
+
+
+
+
" +`; + +exports[`chatPlugin > should work multiple date and user 1`] = ` +"
+
+

Chat

+
+
+
2025-06-01 12:00:00
+
+
+

用户一

+
+

这是用户一的消息

+
+
+
+
+
+
+

这是本人的消息

+
+
+
+
+
+

用户二

+
+

这是用户二的消息

+
+
+
+
+
+
+

这是本人的消息

+
+
+
+
2025-06-05 12:00:00
+
+
+

用户一

+
+

这是用户一的消息

+
+
+
+
+
+
+

这是本人的消息

+
+
+
+
+
+

用户二

+
+

这是用户二的消息

+
+
+
+
+
+
+

这是本人的消息

+
+
+
+
+
" +`; + +exports[`chatPlugin > should work multiple users 1`] = ` +"
+
+

Chat

+
+
+
2025-06-01 12:00:00
+
+
+

用户一

+
+

这是用户一的消息

+
+
+
+
+
+
+

这是本人的消息

+
+
+
+
+
+

用户二

+
+

这是用户二的消息

+
+
+
+
+
+
+

这是本人的消息

+
+
+
+
+
" +`; diff --git a/plugins/plugin-md-power/__test__/chatPlugin.spec.ts b/plugins/plugin-md-power/__test__/chatPlugin.spec.ts new file mode 100644 index 00000000..48532644 --- /dev/null +++ b/plugins/plugin-md-power/__test__/chatPlugin.spec.ts @@ -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() + }) +}) diff --git a/plugins/plugin-md-power/src/client/styles/chat.css b/plugins/plugin-md-power/src/client/styles/chat.css new file mode 100644 index 00000000..5300eece --- /dev/null +++ b/plugins/plugin-md-power/src/client/styles/chat.css @@ -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; +} diff --git a/plugins/plugin-md-power/src/node/container/chat.ts b/plugins/plugin-md-power/src/node/container/chat.ts new file mode 100644 index 00000000..d66506af --- /dev/null +++ b/plugins/plugin-md-power/src/node/container/chat.ts @@ -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 `
+
+

${title || 'Chat'}

+
+
+ ${chatMessagesRender(md, env, messages)} +
+
` + } +} + +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(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 += `
${currentDate}
\n` + } + messageContent += `
+
\ + ${sender === 'user' ? `\n

${username}

` : ''} +
+ ${md.render(content.join('\n'), cleanMarkdownEnv(env)).trim()} +
+
+
` + + 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 +} diff --git a/plugins/plugin-md-power/src/node/container/index.ts b/plugins/plugin-md-power/src/node/container/index.ts index fb9b8af3..4f6fdc0b 100644 --- a/plugins/plugin-md-power/src/node/container/index.ts +++ b/plugins/plugin-md-power/src/node/container/index.ts @@ -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) } diff --git a/plugins/plugin-md-power/src/node/prepareConfigFile.ts b/plugins/plugin-md-power/src/node/prepareConfigFile.ts index fa8c0987..621770e9 100644 --- a/plugins/plugin-md-power/src/node/prepareConfigFile.ts +++ b/plugins/plugin-md-power/src/node/prepareConfigFile.ts @@ -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', `\ diff --git a/plugins/plugin-md-power/src/shared/plugin.ts b/plugins/plugin-md-power/src/shared/plugin.ts index ed0cda20..79a783d4 100644 --- a/plugins/plugin-md-power/src/shared/plugin.ts +++ b/plugins/plugin-md-power/src/shared/plugin.ts @@ -91,6 +91,24 @@ export interface MarkdownPowerPluginOptions { */ collapse?: boolean + /** + * 是否启用 chat 容器 语法 + * + * ```md + * ::: chat + * {:date} + * + * {user} + * message + * + * {.} + * message + * ::: + * ``` + * @default false + */ + chat?: boolean + // video embed /** * 是否启用 bilibili 视频嵌入 diff --git a/theme/src/node/detector/fields.ts b/theme/src/node/detector/fields.ts index ac33c761..9ddbe947 100644 --- a/theme/src/node/detector/fields.ts +++ b/theme/src/node/detector/fields.ts @@ -59,6 +59,7 @@ export const MARKDOWN_POWER_FIELDS: (keyof MarkdownPowerPluginOptions)[] = [ 'replit', 'timeline', 'collapse', + 'chat', 'youtube', ]