From 7dd91dd611c25e7f98d6a3abd61f53d7fac28b4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Fri, 27 Mar 2026 05:05:52 +0800 Subject: [PATCH 1/4] =?UTF-8?q?chore(dev):=20=E9=87=8D=E6=9E=84=20Vite=20?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E6=9C=8D=E5=8A=A1=E5=99=A8=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=BB=A5=E6=94=AF=E6=8C=81=E5=A4=9A=E9=A1=B5=E9=9D=A2=E5=BA=94?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + examples/ai_chat.html | 2 +- package.json | 2 +- packages/cherry-markdown/index.html | 181 ----------------------- packages/cherry-markdown/package.json | 1 + packages/cherry-markdown/vite.config.ts | 131 ++++++++-------- packages/cherry-markdown/vite.plugins.ts | 177 ++++++++++++++++++++++ 7 files changed, 253 insertions(+), 244 deletions(-) delete mode 100644 packages/cherry-markdown/index.html create mode 100644 packages/cherry-markdown/vite.plugins.ts diff --git a/.gitignore b/.gitignore index ec65c4fc6..daaf916ed 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ packages/**/yarn.lock # config examples/cherry-markdown-publish/src/common/config/*.yaml + +# CodeBuddy local data +.codebuddy/ diff --git a/examples/ai_chat.html b/examples/ai_chat.html index e42318c7b..427b8521f 100644 --- a/examples/ai_chat.html +++ b/examples/ai_chat.html @@ -5,7 +5,7 @@ 流式输出md内容 - + - - - - - - -
- - - - - diff --git a/packages/cherry-markdown/package.json b/packages/cherry-markdown/package.json index d2a4aa503..2e9a58108 100644 --- a/packages/cherry-markdown/package.json +++ b/packages/cherry-markdown/package.json @@ -25,6 +25,7 @@ ], "scripts": { "iconfont": "gulp", + "dev": "vite", "dev:vite": "vite", "build": "run-s clean build:all", "build:all": "run-p iconfont build:styles build:types build:addons build:full build:core build:engine build:stream", diff --git a/packages/cherry-markdown/vite.config.ts b/packages/cherry-markdown/vite.config.ts index 5166b6b27..6540cb7eb 100644 --- a/packages/cherry-markdown/vite.config.ts +++ b/packages/cherry-markdown/vite.config.ts @@ -1,95 +1,104 @@ +/** + * Vite 开发服务器配置 + * + * 该配置用于开发环境,支持实时预览 examples 中的所有 HTML 页面。 + * + * 架构特点: + * - MPA(多页面应用):以 examples/ 作为根目录 + * - 虚拟模块:dist 请求重定向到源码,支持热更新 + * - 中间件拦截:处理字体文件和资源路由 + * - HTML 转换:自动处理 link 和 script 标签 + * + * 注意:此配置仅用于开发,生产构建使用 Rollup(build/*.config.js) + */ + import { defineConfig } from 'vite'; import path from 'path'; +import { cherryDevPlugin, printLinksPlugin } from './vite.plugins'; + +// Cherry Markdown 源码目录 +const cherryMarkdownDir = path.resolve(__dirname); +const examplesDir = path.resolve(__dirname, '../../examples'); +const srcDir = path.resolve(cherryMarkdownDir, 'src'); -const paths = [ +// examples 目录下所有可访问的 HTML 页面 +// 用于启动时打印可访问链接,以及插件中的文件存在性检查 +const htmlPages = [ '/index.html', - // '/basic.html', + '/basic.html', '/h5.html', '/multiple.html', '/notoolbar.html', '/preview_only.html', '/xss.html', - // '/api.html', + '/api.html', '/img.html', '/table.html', '/head_num.html', '/ai_chat.html', - // '/vim.html', - // '/mermaid.html', + '/ai_chat_stream.html', + '/mermaid.html', + '/vim.html', + '/drawio_demo.html', + '/chart_toolbar_demo.html', + '/chatgpt.html', + '/suggester.html', + '/custom_codeblock_wrapper.html', ]; -function printLinks() { - return { - name: 'print-links', - configureServer(server: any) { - server.httpServer?.once('listening', () => { - const address = server.httpServer?.address(); - let port = 5173; - // 始终使用 localhost - const host = 'localhost'; - if (typeof address === 'object' && address) { - port = address.port || port; - } - console.log('\n可访问页面链接:'); - paths.forEach((p) => { - console.log(` http://${host}:${port}${p}`); - }); - console.log(''); - }); - }, - }; -} - -function spaFallback() { - return { - name: 'spa-fallback', - transformIndexHtml: (html: string, ctx: { path: string; filename?: string; server?: any }) => { - const path = ctx.path; - // 如果是配置的路径之一,注入脚本设置原始路径 - // 优化的路径匹配逻辑 - const isExactMatch = paths.includes(path); - const isPathWithoutHtmlMatch = path.endsWith('.html') && paths.includes(path.slice(0, -5)); - const matchedPath = isExactMatch || isPathWithoutHtmlMatch ? (isExactMatch ? path : path.slice(0, -5)) : null; - - if (matchedPath && matchedPath !== '/index.html') { - // 转义路径中的特殊字符以防止XSS注入 - const escapedPath = matchedPath.replace(/['"\\]/g, '\\$&'); - const script = ``; - return html.replace('', `${script}\n `); - } - return html; - }, - }; -} - export default defineConfig({ - root: process.cwd(), + // 以 examples 目录作为根目录,实现真正的多页面应用 + root: examplesDir, base: '/', - publicDir: 'dist', + resolve: { alias: [ - { find: '@', replacement: path.resolve(__dirname, 'src') }, - { find: '@examples', replacement: path.resolve(__dirname, '../../examples') }, + // 源码别名 + { find: '@', replacement: srcDir }, + // examples 别名 + { find: '@examples', replacement: examplesDir }, ], }, + server: { host: '0.0.0.0', port: 5173, - open: false, + open: '/index.html', + // 允许所有主机 allowedHosts: true, fs: { - allow: [path.resolve(__dirname), path.resolve(__dirname, 'dist'), path.resolve(__dirname, '..', '..')], + // 允许访问的目录 + allow: [ + examplesDir, + cherryMarkdownDir, + // 允许访问整个 monorepo + path.resolve(__dirname, '../..'), + ], }, }, - build: { - outDir: 'dist', - emptyOutDir: false, - }, + + // 定义全局常量 define: { 'process.env.BUILD_VERSION': JSON.stringify(process.env.BUILD_VERSION || ''), 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), BUILD_ENV: JSON.stringify(process.env.NODE_ENV || 'development'), - __EXAMPLES_PATH__: JSON.stringify(path.resolve(__dirname, '../../examples').replace(/\\/g, '/')), }, - plugins: [spaFallback(), printLinks()], + + // CSS 配置 + css: { + preprocessorOptions: { + scss: { + // SCSS 配置 + charset: false, + }, + }, + }, + + // 优化依赖预构建 + optimizeDeps: { + include: ['codemirror'], + exclude: [], + }, + + plugins: [cherryDevPlugin(srcDir, cherryMarkdownDir), printLinksPlugin(examplesDir, htmlPages)], }); diff --git a/packages/cherry-markdown/vite.plugins.ts b/packages/cherry-markdown/vite.plugins.ts new file mode 100644 index 000000000..d4eca22be --- /dev/null +++ b/packages/cherry-markdown/vite.plugins.ts @@ -0,0 +1,177 @@ +/** + * Vite 开发模式插件集合 + * + * 本文件包含为开发环境设计的 Vite 插件,用于: + * 1. 虚拟模块处理:将对 dist 的请求重定向到源码 + * 2. 资源中间件:处理字体文件和资源路由 + * 3. HTML 转换:适配开发模式的 script 和 link 标签 + * 4. 链接打印:启动时显示所有可访问的页面链接 + * + * 注意:这些插件仅在开发模式使用,生产环境不加载此文件 + */ + +import { Plugin } from 'vite'; +import path from 'path'; +import fs from 'fs'; + +/** + * 打印可访问链接的插件 + * 在开发服务器启动后,打印所有可访问的 HTML 页面链接 + */ +export function printLinksPlugin(examplesDir: string, htmlPages: string[]): Plugin { + return { + name: 'print-links', + configureServer(server) { + server.httpServer?.once('listening', () => { + const address = server.httpServer?.address(); + let port = 5173; + const host = 'localhost'; + if (typeof address === 'object' && address) { + port = address.port || port; + } + console.log('\n🍒 Cherry Markdown 开发服务器已启动'); + console.log('\n可访问页面链接:'); + htmlPages.forEach((p) => { + const filePath = path.join(examplesDir, p); + if (fs.existsSync(filePath)) { + console.log(` ✅ http://${host}:${port}${p}`); + } + }); + console.log(''); + }); + }, + }; +} + +/** + * Cherry Markdown 开发模式插件 + * + * 功能: + * 1. 拦截对 dist/cherry-markdown.js 的请求,重定向到虚拟模块(源码) + * 2. 拦截对 dist/cherry-markdown.css 的请求,重定向到虚拟模块(SCSS 源文件) + * 3. 拦截字体文件请求,代理到 dist/fonts/ 目录 + * 4. 转换 HTML,将 link 标签转换为 JS 模块导入,将普通 script 转换为 module 类型 + */ +export function cherryDevPlugin(srcDir: string, cherryMarkdownDir: string): Plugin { + // 虚拟模块 ID + const virtualCherryJsId = 'virtual:cherry-markdown-js'; + const virtualCherryCssId = 'virtual:cherry-markdown-css'; + const resolvedVirtualCherryJsId = `\0${virtualCherryJsId}`; + const resolvedVirtualCherryCssId = `\0${virtualCherryCssId}`; + + return { + name: 'cherry-dev-redirect', + enforce: 'pre', + + configureServer(server) { + // 中间件:拦截请求并进行转发或代理 + server.middlewares.use((req, res, next) => { + const url = req.url || ''; + + // 匹配模式:/packages/cherry-markdown/dist/cherry-markdown*.js + const jsPattern = /\/?\.{0,2}\/?packages\/cherry-markdown\/dist\/cherry-markdown[^/]*\.js/; + const cssPattern = /\/?\.{0,2}\/?packages\/cherry-markdown\/dist\/cherry-markdown[^/]*\.css/; + + // 拦截 cherry-markdown.js 请求 → 虚拟模块 + if (jsPattern.test(url)) { + req.url = `/@id/${virtualCherryJsId}`; + return next(); + } + + // 拦截 cherry-markdown.css 请求 → 虚拟模块 + if (cssPattern.test(url)) { + req.url = `/@id/${virtualCherryCssId}`; + return next(); + } + + // 拦截字体文件请求,代理到 dist/fonts/ + // 情况1: src/sass/fonts/ 路径(Vite 处理 SCSS 时生成的绝对路径) + // 情况2: /fonts/ 根路径(CSS 通过 JS 模块注入时,浏览器用页面 URL 解析相对路径 ./fonts/) + const fontPatterns = [/\/packages\/cherry-markdown\/src\/sass\/fonts\/(.+)/, /^\/fonts\/(ch-icon\.[^?]+)/]; + const mimeTypes: Record = { + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.ttf': 'font/ttf', + '.eot': 'application/vnd.ms-fontobject', + '.svg': 'image/svg+xml', + }; + + for (const pattern of fontPatterns) { + const fontMatch = url.match(pattern); + if (fontMatch) { + const fontFile = fontMatch[1].split('?')[0]; + const fontPath = path.join(cherryMarkdownDir, 'dist', 'fonts', fontFile); + if (fs.existsSync(fontPath)) { + const ext = path.extname(fontFile).toLowerCase(); + res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream'); + res.setHeader('Cache-Control', 'max-age=31536000'); + fs.createReadStream(fontPath).pipe(res); + return; + } + } + } + + next(); + }); + }, + + resolveId(id) { + if (id === virtualCherryJsId) { + return resolvedVirtualCherryJsId; + } + if (id === virtualCherryCssId) { + return resolvedVirtualCherryCssId; + } + }, + + load(id) { + // 加载虚拟 JS 模块 - 从源码导入并暴露到全局 + if (id === resolvedVirtualCherryJsId) { + return ` +import Cherry from '${srcDir.replace(/\\/g, '/')}/index.js'; + +// 暴露到全局,兼容 examples 中的用法 +window.Cherry = Cherry; + +export default Cherry; +export { Cherry }; +`; + } + + // 加载虚拟 CSS 模块 - 导入 SCSS 源文件 + if (id === resolvedVirtualCherryCssId) { + return `import '${srcDir.replace(/\\/g, '/')}/sass/index.scss';`; + } + }, + + // 转换 HTML,处理脚本类型和样式引入 + transformIndexHtml(html) { + let result = html; + + // 1. 将引用 dist/cherry-markdown.css 的 link 标签转换为 JS 模块导入 + // 因为 Vite 处理 SCSS 需要通过 JS 模块 + result = result.replace( + /]*href=["']([^"']*\/packages\/cherry-markdown\/dist\/cherry-markdown[^"']*\.css)["'][^>]*\/?>/gi, + (_match, href) => { + return ``; + }, + ); + + // 2. 将引用 dist/cherry-markdown.js 的普通 script 标签转换为 module 类型 + // 因为源码是 ES Module + result = result.replace( + /]*)\s+src=["']([^"']*\/packages\/cherry-markdown\/dist\/cherry-markdown[^"']*)["']([^>]*)><\/script>/gi, + (match, before, src, after) => { + // 如果已经是 module 类型,不做处理 + if (/type\s*=\s*["']module["']/i.test(before + after)) { + return match; + } + // 添加 type="module" + return ``; + }, + ); + + return result; + }, + }; +} From 51bc74a487c6b7bcfb35d82070be38bf046665ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=20Cai?= Date: Fri, 27 Mar 2026 10:35:46 +0800 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=E5=8D=87=E7=BA=A7=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=20echarts=20=E4=BE=9D=E8=B5=96=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E8=87=B3=205.4.0=EF=BC=8C=E4=BC=98=E5=8C=96=E7=A4=BA=E4=BE=8B?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/api.html | 2 +- examples/basic.html | 104 +++--- examples/chart_toolbar_demo.html | 73 ++-- examples/chatgpt.html | 2 +- examples/custom_codeblock_wrapper.html | 423 ++++++++++++----------- examples/h5.html | 2 +- examples/head_num.html | 2 +- examples/mermaid.html | 102 +++--- examples/multiple.html | 2 +- examples/notoolbar.html | 2 +- examples/preview_only.html | 2 +- examples/suggester.html | 2 +- examples/vim.html | 94 ++--- examples/xss.html | 2 +- packages/cherry-markdown/vite.plugins.ts | 2 +- 15 files changed, 413 insertions(+), 403 deletions(-) diff --git a/examples/api.html b/examples/api.html index 9f920fd15..75a2129fe 100644 --- a/examples/api.html +++ b/examples/api.html @@ -698,7 +698,7 @@

toolbar.toolbarHandlers.graph(type:string)

- + diff --git a/examples/basic.html b/examples/basic.html index b6044f2a4..e5a5cdc18 100644 --- a/examples/basic.html +++ b/examples/basic.html @@ -1,53 +1,55 @@ - - - - - - Cherry Editor - Markdown Editor - - - - + + + + + + Cherry Editor - Markdown Editor + + + + + + - -
- - - - + +
+ + + + diff --git a/examples/chart_toolbar_demo.html b/examples/chart_toolbar_demo.html index 125652d21..c4527aec6 100644 --- a/examples/chart_toolbar_demo.html +++ b/examples/chart_toolbar_demo.html @@ -1,20 +1,23 @@ - - - - - + + + + + Cherry Markdown - 图表工具栏测试 - - - - - -
+ + + + + - + + - + diff --git a/examples/chatgpt.html b/examples/chatgpt.html index 1d86571b2..793ec00f6 100644 --- a/examples/chatgpt.html +++ b/examples/chatgpt.html @@ -44,7 +44,7 @@
- + diff --git a/examples/custom_codeblock_wrapper.html b/examples/custom_codeblock_wrapper.html index 28747cc96..1df04edff 100644 --- a/examples/custom_codeblock_wrapper.html +++ b/examples/custom_codeblock_wrapper.html @@ -1,215 +1,216 @@ - - - - - - 自定义代码块外层容器 - Cherry Editor - - - - - - -
- - - - + + + } + }, + toolbars: { + toolbar: [ + 'bold', + 'italic', + 'size', + '|', + 'color', + 'header', + '|', + 'drawIo', + '|', + 'ol', + 'ul', + 'checklist', + 'panel', + 'align', + 'detail', + '|', + 'formula', + 'graph', + 'proTable', + 'codeTheme', + 'search', + 'shortcutKey', + ], + }, + previewer: { + enablePreviewerBubble: false, + } + }; + var sourceCode = document.documentElement.outerHTML; + var cherry = new Cherry(config); + cherry.setValue('\n## 自定义json代码块的特殊渲染\n\n```json\n{\n"--oc-white": "#ffffff",\n"--oc-black": "#000000",\n"--oc-gray-0": "#f8f9fa",\n"--oc-gray-1": "#f1f3f5",\n"--oc-gray-2": "#e9ecef",\n"--oc-gray-3": "#dee2e6",\n"--oc-gray-4": "#ced4da",\n"--oc-gray-5": "#adb5bd",\n"--oc-gray-6": "#868e96",\n"--oc-gray-7": "#495057",\n"--oc-gray-8": "#343a40",\n"--oc-gray-9": "#212529"\n}\n```\n\n'); + // 绑定 body的点击事件,判断是否为 .j-switch 元素 + document.body.addEventListener('click', function(e) { + const target = e.target.closest('.j-switch'); + if (target) { + const switcher = target.querySelector('.my-code-wrapper-switcher'); + const preview = target.parentElement.parentElement.querySelector('.my-code-wrapper-preview-content'); + const code = target.parentElement.parentElement.querySelector('.my-code-wrapper-content'); + if (switcher.classList.contains('active')) { + switcher.classList.remove('active'); + preview.classList.add('hidden'); + code.classList.remove('hidden'); + } else { + switcher.classList.add('active'); + preview.classList.remove('hidden'); + code.classList.add('hidden'); + const codeStr = code.innerText.replace(/\n/g, ''); + const codeJson = JSON.parse(codeStr); + const thead = '变量名颜色值'; + let tbody = ''; + for (let key in codeJson) { + tbody += `${key}`; + } + preview.innerHTML = `${thead}${tbody}
`; + } + } + }); + + diff --git a/examples/h5.html b/examples/h5.html index b1d572cb1..59cb68ee9 100644 --- a/examples/h5.html +++ b/examples/h5.html @@ -28,7 +28,7 @@
- + + @@ -86,26 +88,26 @@ Sit down: 3: Me ``` - - - - - + + + + + diff --git a/examples/multiple.html b/examples/multiple.html index aaa9321c1..7f8f7795f 100644 --- a/examples/multiple.html +++ b/examples/multiple.html @@ -37,7 +37,7 @@

Cherry Markdown Editor

Cherry Markdown Editor

- + + + diff --git a/examples/suggester.html b/examples/suggester.html index 153dd1847..516b37d2a 100644 --- a/examples/suggester.html +++ b/examples/suggester.html @@ -37,7 +37,7 @@
- + diff --git a/examples/vim.html b/examples/vim.html index f0820046b..098ab495c 100644 --- a/examples/vim.html +++ b/examples/vim.html @@ -1,48 +1,50 @@ - - - - - - VIM demo - Cherry Editor - - - - + + + + + + VIM demo - Cherry Editor + + + + + + - -
- - - + +
+ + + diff --git a/examples/xss.html b/examples/xss.html index 34c809acf..3064dd2b2 100644 --- a/examples/xss.html +++ b/examples/xss.html @@ -40,7 +40,7 @@
- + - + diff --git a/examples/assets/scripts/api-demo.js b/examples/assets/scripts/api-demo.js index 6cd10227e..e030dcecc 100644 --- a/examples/assets/scripts/api-demo.js +++ b/examples/assets/scripts/api-demo.js @@ -1,6 +1,8 @@ -function dealClick(obj, e) { +export function dealClick(obj, e) { eval(obj.parentNode.firstElementChild.value); } +// 挂载到全局,HTML 中的 onclick 需要 +window.dealClick = dealClick; var CustomHookA = Cherry.createSyntaxHook('codeBlock', Cherry.constants.HOOKS_TYPE_LIST.PAR, { makeHtml(str) { @@ -18,7 +20,7 @@ var CustomHookA = Cherry.createSyntaxHook('codeBlock', Cherry.constants.HOOKS_TY }, }); -var cherryConfig = { +export var cherryConfig = { id: 'markdown', externals: { echarts: window.echarts, @@ -87,8 +89,3 @@ var cherryConfig = { keydown: [], //extensions: [], }; - -fetch('./assets/markdown/api.md').then((response) => response.text()).then((value) => { - var config = Object.assign({}, cherryConfig, { value: value }); - window.cherryObj = new Cherry(config); -}); diff --git a/examples/assets/scripts/chatgpt-demo.js b/examples/assets/scripts/chatgpt-demo.js deleted file mode 100644 index 1b54b022a..000000000 --- a/examples/assets/scripts/chatgpt-demo.js +++ /dev/null @@ -1,253 +0,0 @@ -/** - * 自定义一个语法 - */ -var CustomHookA = Cherry.createSyntaxHook('codeBlock', Cherry.constants.HOOKS_TYPE_LIST.PAR, { - makeHtml(str) { - console.warn('custom hook', 'hello'); - return str; - }, - rule(str) { - const regex = { - begin: '', - content: '', - end: '', - }; - regex.reg = new RegExp(regex.begin + regex.content + regex.end, 'g'); - return regex; - }, -}); -/** - * 自定义一个自定义菜单 - * 点第一次时,把选中的文字变成同时加粗和斜体 - * 保持光标选区不变,点第二次时,把加粗斜体的文字变成普通文本 - */ -var customMenuA = Cherry.createMenuHook('加粗斜体', { - iconName: 'font', - onClick: function (selection) { - // 获取用户选中的文字,调用getSelection方法后,如果用户没有选中任何文字,会尝试获取光标所在位置的单词或句子 - let $selection = this.getSelection(selection) || '同时加粗斜体'; - // 如果是单选,并且选中内容的开始结束内没有加粗语法,则扩大选中范围 - if (!this.isSelections && !/^\s*(\*\*\*)[\s\S]+(\1)/.test($selection)) { - this.getMoreSelection('***', '***', () => { - const newSelection = this.editor.editor.getSelection(); - const isBoldItalic = /^\s*(\*\*\*)[\s\S]+(\1)/.test(newSelection); - if (isBoldItalic) { - $selection = newSelection; - } - return isBoldItalic; - }); - } - // 如果选中的文本中已经有加粗语法了,则去掉加粗语法 - if (/^\s*(\*\*\*)[\s\S]+(\1)/.test($selection)) { - return $selection.replace(/(^)(\s*)(\*\*\*)([^\n]+)(\3)(\s*)($)/gm, '$1$4$7'); - } - /** - * 注册缩小选区的规则 - * 注册后,插入“***TEXT***”,选中状态会变成“***【TEXT】***” - * 如果不注册,插入后效果为:“【***TEXT***】” - */ - this.registerAfterClickCb(() => { - this.setLessSelection('***', '***'); - }); - return $selection.replace(/(^)([^\n]+)($)/gm, '$1***$2***$3'); - }, -}); -/** - * 定义一个空壳,用于自行规划cherry已有工具栏的层级结构 - */ -var customMenuB = Cherry.createMenuHook('实验室', { - iconName: '', -}); -/** - * 定义一个自带二级菜单的工具栏 - */ -var customMenuC = Cherry.createMenuHook('帮助中心', { - iconName: 'question', - onClick: (selection, type) => { - switch (type) { - case 'shortKey': - return `${selection}快捷键看这里:https://codemirror.net/5/demo/sublime.html`; - case 'github': - return `${selection}我们在这里:https://github.com/Tencent/cherry-markdown`; - case 'release': - return `${selection}我们在这里:https://github.com/Tencent/cherry-markdown/releases`; - default: - return selection; - } - }, - subMenuConfig: [ - { - noIcon: true, - name: '快捷键', - onclick: (event) => { - cherry.toolbar.menus.hooks.customMenuCName.fire(null, 'shortKey'); - }, - }, - { - noIcon: true, - name: '联系我们', - onclick: (event) => { - cherry.toolbar.menus.hooks.customMenuCName.fire(null, 'github'); - }, - }, - { - noIcon: true, - name: '更新日志', - onclick: (event) => { - cherry.toolbar.menus.hooks.customMenuCName.fire(null, 'release'); - }, - }, - ], -}); - -var basicConfig = { - openai: { - apiKey: '', // apiKey - proxy: { - host: '127.0.0.1', - port: '7890', - }, // http & https代理配置 - ignoreError: false, // 是否忽略请求失败,默认忽略 - }, - id: 'markdown', - externals: { - echarts: window.echarts, - katex: window.katex, - MathJax: window.MathJax, - }, - isPreviewOnly: false, - engine: { - global: { - urlProcessor(url, srcType) { - console.log(`url-processor`, url, srcType); - return url; - }, - }, - syntax: { - codeBlock: { - theme: 'twilight', - }, - table: { - enableChart: false, - // chartEngine: Engine Class - }, - fontEmphasis: { - allowWhitespace: false, // 是否允许首尾空格 - }, - strikethrough: { - needWhitespace: false, // 是否必须有前后空格 - }, - mathBlock: { - engine: 'MathJax', // katex或MathJax - src: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js', // 如果使用MathJax plugins,则需要使用该url通过script标签引入 - }, - inlineMath: { - engine: 'MathJax', // katex或MathJax - }, - emoji: { - useUnicode: false, - customResourceURL: 'https://github.githubassets.com/images/icons/emoji/unicode/${code}.png?v8', - upperCase: true, - }, - // toc: { - // tocStyle: 'nested' - // } - // 'header': { - // strict: false - // } - }, - customSyntax: { - // SyntaxHookClass - CustomHook: { - syntaxClass: CustomHookA, - force: false, - after: 'br', - }, - }, - }, - toolbars: { - toolbar: [ - 'bold', - 'italic', - { - strikethrough: ['strikethrough', 'underline', 'sub', 'sup', 'ruby', 'customMenuAName'], - }, - 'size', - '|', - 'color', - 'header', - '|', - 'drawIo', - '|', - 'ol', - 'ul', - 'checklist', - 'panel', - 'justify', - 'detail', - '|', - 'formula', - { - insert: [ - 'image', - 'audio', - 'video', - 'link', - 'hr', - 'br', - 'code', - 'formula', - 'toc', - 'table', - 'pdf', - 'word', - 'ruby', - ], - }, - 'graph', - 'togglePreview', - 'settings', - 'codeTheme', - 'export', - { - customMenuBName: ['ruby', 'audio', 'video', 'customMenuAName'], - }, - 'customMenuCName', - 'theme', - 'chatgpt', - ], - toolbarRight: ['fullScreen', '|'], - bubble: ['bold', 'italic', 'underline', 'strikethrough', 'sub', 'sup', 'quote', 'ruby', '|', 'size', 'color'], // array or false - sidebar: ['mobilePreview', 'copy', 'theme'], - customMenu: { - customMenuAName: customMenuA, - customMenuBName: customMenuB, - customMenuCName: customMenuC, - }, - }, - drawioIframeUrl: './drawio_demo.html', - editor: { - defaultModel: 'edit&preview', - }, - previewer: { - // 自定义markdown预览区域class - // className: 'markdown' - }, - keydown: [], - //extensions: [], - callback: { - changeString2Pinyin: pinyin, - }, - editor: { - id: 'cherry-text', - name: 'cherry-text', - autoSave2Textarea: true, - }, -}; - -fetch('./assets/markdown/index.md') - .then((response) => response.text()) - .then((value) => { - var config = Object.assign({}, basicConfig, { value: value }); - window.cherry = new Cherry(config); - }); diff --git a/examples/assets/scripts/index-demo.js b/examples/assets/scripts/index-demo.js index 51f09d113..2c28f2963 100644 --- a/examples/assets/scripts/index-demo.js +++ b/examples/assets/scripts/index-demo.js @@ -85,21 +85,21 @@ var customMenuC = Cherry.createMenuHook('自定义菜单+子菜单', { noIcon: true, name: '快捷键', onclick: (event) => { - cherry.toolbar.menus.hooks.customMenuCName.fire(null, 'shortKey'); + window.cherry.toolbar.menus.hooks.customMenuCName.fire(null, 'shortKey'); }, }, { noIcon: true, name: '联系我们', onclick: (event) => { - cherry.toolbar.menus.hooks.customMenuCName.fire(null, 'github'); + window.cherry.toolbar.menus.hooks.customMenuCName.fire(null, 'github'); }, }, { noIcon: true, name: '更新日志', onclick: (event) => { - cherry.toolbar.menus.hooks.customMenuCName.fire(null, 'release'); + window.cherry.toolbar.menus.hooks.customMenuCName.fire(null, 'release'); }, }, ], @@ -115,7 +115,7 @@ var customMenuTable = Cherry.createMenuHook('图表', { noIcon: true, name: '折线图', onclick: (event) => { - cherry.insert( + window.cherry.insert( '\n| :line:{"title": "折线图"} | Header1 | Header2 | Header3 | Header4 |\n| ------ | ------ | ------ | ------ | ------ |\n| Sample1 | 11 | 11 | 4 | 33 |\n| Sample2 | 112 | 111 | 22 | 222 |\n| Sample3 | 333 | 142 | 311 | 11 |\n', ); }, @@ -124,7 +124,7 @@ var customMenuTable = Cherry.createMenuHook('图表', { noIcon: true, name: '柱状图', onclick: (event) => { - cherry.insert( + window.cherry.insert( '\n| :bar:{"title": "柱状图"} | Header1 | Header2 | Header3 | Header4 |\n| ------ | ------ | ------ | ------ | ------ |\n| Sample1 | 11 | 11 | 4 | 33 |\n| Sample2 | 112 | 111 | 22 | 222 |\n| Sample3 | 333 | 142 | 311 | 11 |\n', ); }, @@ -133,7 +133,7 @@ var customMenuTable = Cherry.createMenuHook('图表', { noIcon: true, name: '雷达图', onclick: (event) => { - cherry.insert( + window.cherry.insert( '\n| :radar:{"title": "雷达图"} | 技能1 | 技能2 | 技能3 | 技能4 | 技能5 |\n| ------ | ------ | ------ | ------ | ------ | ------ |\n| 用户A | 90 | 85 | 75 | 80 | 88 |\n| 用户B | 75 | 90 | 88 | 85 | 78 |\n| 用户C | 85 | 78 | 90 | 88 | 85 |\n', ); }, @@ -142,7 +142,7 @@ var customMenuTable = Cherry.createMenuHook('图表', { noIcon: true, name: '热力图', onclick: (event) => { - cherry.insert( + window.cherry.insert( '\n| :heatmap:{"title": "热力图"} | 周一 | 周二 | 周三 | 周四 | 周五 |\n| ------ | ------ | ------ | ------ | ------ | ------ |\n| 上午 | 10 | 20 | 30 | 40 | 50 |\n| 下午 | 15 | 25 | 35 | 45 | 55 |\n| 晚上 | 5 | 15 | 25 | 35 | 45 |\n', ); }, @@ -151,7 +151,7 @@ var customMenuTable = Cherry.createMenuHook('图表', { noIcon: true, name: '饼图', onclick: (event) => { - cherry.insert( + window.cherry.insert( '\n| :pie:{"title": "饼图"} | 数值 |\n| ------ | ------ |\n| 苹果 | 40 |\n| 香蕉 | 30 |\n| 橙子 | 20 |\n| 葡萄 | 10 |\n', ); }, @@ -160,7 +160,7 @@ var customMenuTable = Cherry.createMenuHook('图表', { noIcon: true, name: '散点图', onclick: (event) => { - cherry.insert( + window.cherry.insert( '\n| :scatter:{"title": "散点图", "cherry:mapping": {"x": "X", "y": "Y", "size": "Size", "series": "Series"}} | X | Y | Size | Series |\n| ------ | ------ | ------ | ------ | ------ |\n| A1 | 10 | 20 | 5 | S1 |\n| A2 | 15 | 35 | 8 | S1 |\n| B1 | 30 | 12 | 3 | S2 |\n| B2 | 25 | 28 | 6 | S2 |\n| C1 | 50 | 40 | 9 | S3 |\n| C2 | 60 | 55 | 7 | S3 |\n', ); }, @@ -169,7 +169,7 @@ var customMenuTable = Cherry.createMenuHook('图表', { noIcon: true, name: '地图', onclick: (event) => { - cherry.insert( + window.cherry.insert( '\n| :map:{"title": "地图", "mapDataSource": "https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json"} | 数值 |\n| :-: | :-: |\n| 北京 | 100 |\n| 上海 | 200 |\n| 广东 | 300 |\n| 四川 | 150 |\n| 江苏 | 250 |\n| 浙江 | 180 |\n\n**说明:** 修改mapDataSource的URL来自定义地图数据源\n', ); }, @@ -471,7 +471,7 @@ const basicConfig = { keydown: [], //extensions: [], callback: { - changeString2Pinyin: pinyin, + changeString2Pinyin: window.pinyin, onClickPreview: (event) => { console.log('onClickPreview', event); }, diff --git a/examples/assets/scripts/suggester-demo.js b/examples/assets/scripts/suggester-demo.js index 5e3fbb799..1cdf0168c 100644 --- a/examples/assets/scripts/suggester-demo.js +++ b/examples/assets/scripts/suggester-demo.js @@ -1,3 +1,6 @@ +/** + * 自定义一个语法 + */ var CustomHookA = Cherry.createSyntaxHook('codeBlock', Cherry.constants.HOOKS_TYPE_LIST.PAR, { makeHtml(str) { console.warn('custom hook', 'hello'); @@ -15,7 +18,7 @@ var CustomHookA = Cherry.createSyntaxHook('codeBlock', Cherry.constants.HOOKS_TY }); var suggest = []; var list = ['barryhu', 'ivorwei', 'sunsunliu', 'jiaweicui', 'other', 'new']; -var basicConfig = { +export var basicConfig = { id: 'markdown', externals: { echarts: window.echarts, @@ -50,31 +53,6 @@ var basicConfig = { customResourceURL: 'https://github.githubassets.com/images/icons/emoji/unicode/${code}.png?v8', upperCase: true, }, - suggester: { - suggester: [ - { - // 获取 列表 - suggestList(word, callback) { - suggest.push(list[Math.floor(Math.random() * 6)]); - if (suggest.length >= 6) { - suggest.shift(); - } - callback(suggest); - }, - // 唤醒关键字 - // default '@' - keyword: '@', - // 建议模板 - suggestListRender(valueArray) { - return ''; - }, - // 回填回调 - echo(value) { - return ''; - }, - }, - ], - }, }, customSyntax: { // SyntaxHookClass @@ -109,6 +87,31 @@ var basicConfig = { }, editor: { defaultModel: 'edit&preview', + suggester: { + suggester: [ + { + // 获取 列表 + suggestList(word, callback) { + suggest.push(list[Math.floor(Math.random() * 6)]); + if (suggest.length >= 6) { + suggest.shift(); + } + callback(suggest); + }, + // 唤醒关键字 + // default '@' + keyword: '@', + // 建议模板 + suggestListRender(valueArray) { + return ''; + }, + // 回填回调 + echo(value) { + return ''; + }, + }, + ], + }, }, previewer: { // 自定义markdown预览区域class @@ -117,10 +120,3 @@ var basicConfig = { keydown: [], //extensions: [], }; - -fetch('./assets/markdown/index.md') - .then((response) => response.text()) - .then((value) => { - var config = Object.assign({}, basicConfig, { value }); - window.cherry = new Cherry(config); - }); diff --git a/examples/assets/scripts/xss-demo.js b/examples/assets/scripts/xss-demo.js new file mode 100644 index 000000000..49f721bc7 --- /dev/null +++ b/examples/assets/scripts/xss-demo.js @@ -0,0 +1,141 @@ +/** + * XSS 白名单对比演示配置 + * + * 用两个 Cherry 实例展示 htmlWhiteList 配置的效果差异: + * - 实例1(默认模式):不配置 htmlWhiteList,HTML 标签被 DOMPurify 过滤 + * - 实例2(白名单模式):配置 htmlWhiteList: 'iframe|style',允许渲染指定 HTML 标签 + * + * 安全说明: + * - 演示内容仅使用 + +> ✅ 如果 style 生效,这段引用块左边框变为**红色**,背景变为**浅红**;上方标题也会变红。 +> ❌ 如果 style 被过滤,引用块和标题保持默认颜色。 + +--- + +## 2. \` + +> ✅ 如果 iframe 生效,上方出现**绿色虚线边框**区域。 +> ❌ 如果 iframe 被过滤,上方什么都不会显示。 + +--- + +## 3. 默认白名单标签(始终生效) + +以下标签属于默认安全白名单,两边都能正常渲染: + +
+ ✅ <div> 标签始终正常渲染,不需要额外配置白名单。 +
+ +粗体 · 斜体 · 下划线 · 删除线 +`; +} + +/** + * 实例1配置:默认模式(不启用白名单) + * - htmlWhiteList 为空,DOMPurify 会过滤 style、iframe、script 等标签 + * - 这是 Cherry 的默认安全行为 + */ +const xssConfig1 = { + id: 'markdown-default', + engine: { + global: { + // 不配置 htmlWhiteList,使用默认安全策略 + htmlWhiteList: '', + }, + }, + toolbars: { + toolbar: [ + 'bold', + 'italic', + 'strikethrough', + '|', + 'color', + 'header', + '|', + 'list', + 'code', + 'table', + 'hr', + ], + toolbarRight: [], + sidebar: [], + }, + editor: { + height: '100%', + }, + value: createXssDemoMarkdown('markdown-default'), +}; + +/** + * 实例2配置:白名单模式(启用 iframe + style) + * - htmlWhiteList 配置为 'iframe|style' + * - DOMPurify 不会过滤这两个标签,允许渲染 + * + * 安全提示:不在演示中启用 script 白名单,避免实际 XSS 风险 + */ +const xssConfig2 = { + id: 'markdown-whitelist', + engine: { + global: { + // 允许 iframe 和 style 标签通过 DOMPurify 过滤 + htmlWhiteList: 'iframe|style', + }, + }, + toolbars: { + toolbar: [ + 'bold', + 'italic', + 'strikethrough', + '|', + 'color', + 'header', + '|', + 'list', + 'code', + 'table', + 'hr', + ], + toolbarRight: [], + sidebar: [], + }, + editor: { + height: '100%', + }, + value: createXssDemoMarkdown('markdown-whitelist'), +}; + +export { xssConfig1, xssConfig2 }; diff --git a/examples/chart_toolbar_demo.html b/examples/chart_toolbar_demo.html index c4527aec6..9f4447f97 100644 --- a/examples/chart_toolbar_demo.html +++ b/examples/chart_toolbar_demo.html @@ -8,6 +8,15 @@ + @@ -81,9 +90,7 @@ 'togglePreview', 'fullScreen' ] }, - editor: { - height: '600px' - }, + editor: {}, previewer: { dom: false, className: 'cherry-editor', diff --git a/examples/chatgpt.html b/examples/chatgpt.html deleted file mode 100644 index 793ec00f6..000000000 --- a/examples/chatgpt.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - Cherry Editor - Markdown Editor - - - - - - - - - -
-
- - - - - - - diff --git a/examples/mermaid.html b/examples/mermaid.html index 616ddd570..56224e2db 100644 --- a/examples/mermaid.html +++ b/examples/mermaid.html @@ -92,10 +92,14 @@ - + diff --git a/examples/table.html b/examples/table.html index 5e294b6db..320984523 100644 --- a/examples/table.html +++ b/examples/table.html @@ -12,12 +12,6 @@ height: 100%; overflow: hidden; } - #markdown { - margin-top: 5%; - width: 70%; - margin-left: auto; - margin-right: auto; - } @@ -32,14 +26,11 @@ diff --git a/examples/xss.html b/examples/xss.html index 3064dd2b2..b19428812 100644 --- a/examples/xss.html +++ b/examples/xss.html @@ -3,60 +3,129 @@ - Cherry Editor - Markdown Editor + XSS 白名单对比演示 - Cherry Editor + + + + - - - - - -
-
- - + + +
+ +
+
+ 🔒 默认模式 — htmlWhiteList: ''(style / iframe 被过滤) +
+
+
+
+
+ + +
+
+ 🔓 白名单模式 — htmlWhiteList: 'iframe|style'(style / iframe 正常渲染) +
+
+
+
+
+
+ diff --git a/packages/cherry-markdown/vite.config.ts b/packages/cherry-markdown/vite.config.ts index 6a0b289ae..60251e0a0 100644 --- a/packages/cherry-markdown/vite.config.ts +++ b/packages/cherry-markdown/vite.config.ts @@ -41,7 +41,6 @@ const htmlPages = [ '/vim.html', '/drawio_demo.html', '/chart_toolbar_demo.html', - '/chatgpt.html', '/suggester.html', '/custom_codeblock_wrapper.html', ]; diff --git a/packages/cherry-markdown/vite.plugins.ts b/packages/cherry-markdown/vite.plugins.ts index 6da887b3d..0e38f8502 100644 --- a/packages/cherry-markdown/vite.plugins.ts +++ b/packages/cherry-markdown/vite.plugins.ts @@ -47,18 +47,57 @@ export function printLinksPlugin(examplesDir: string, htmlPages: string[]): Plug * Cherry Markdown 开发模式插件 * * 功能: - * 1. 拦截对 dist/cherry-markdown.js 的请求,重定向到虚拟模块(源码) - * 2. 拦截对 dist/cherry-markdown.css 的请求,重定向到虚拟模块(SCSS 源文件) - * 3. 拦截字体文件请求,代理到 dist/fonts/ 目录 - * 4. 转换 HTML,将 link 标签转换为 JS 模块导入,将引用 dist 的 script 转换为 module 类型 + * 1. 拦截对 dist/cherry-markdown.js 的请求,重定向到虚拟模块(源码入口 index.js) + * 2. 拦截对 dist/cherry-markdown.core.js 的请求,重定向到虚拟模块(源码入口 index.core.js) + * 3. 拦截对 dist/cherry-markdown.css 的请求,重定向到虚拟模块(SCSS 源文件) + * 4. 拦截对 dist/addons/*.js 的请求,重定向到虚拟模块(src/addons/ 源码) + * 5. 拦截字体文件请求,代理到 dist/fonts/ 目录 + * 6. 转换 HTML,将 link 标签转换为 JS 模块导入,将引用 dist 的 script 转换为 module 类型 */ export function cherryDevPlugin(srcDir: string, cherryMarkdownDir: string): Plugin { - // 虚拟模块 ID - const virtualCherryJsId = 'virtual:cherry-markdown-js'; - const virtualCherryCssId = 'virtual:cherry-markdown-css'; + // 虚拟模块 ID 前缀 + const VIRTUAL_PREFIX = 'virtual:cherry-'; + const RESOLVED_PREFIX = `\0${VIRTUAL_PREFIX}`; + + // 固定虚拟模块 + const virtualCherryJsId = `${VIRTUAL_PREFIX}full-js`; + const virtualCherryCoreJsId = `${VIRTUAL_PREFIX}core-js`; + const virtualCherryCssId = `${VIRTUAL_PREFIX}css`; const resolvedVirtualCherryJsId = `\0${virtualCherryJsId}`; + const resolvedVirtualCherryCoreJsId = `\0${virtualCherryCoreJsId}`; const resolvedVirtualCherryCssId = `\0${virtualCherryCssId}`; + /** + * 将 addon 文件名转为 UMD 全局变量名(camelCase) + * 例如:cherry-code-block-mermaid-plugin → CherryCodeBlockMermaidPlugin + * + * 这与 addons.build.js 中的命名逻辑保持一致, + * 确保 HTML 中使用 window.CherryCodeBlockMermaidPlugin 能正确访问 + */ + function addonFileNameToGlobalName(fileName: string): string { + const nameWithoutExt = fileName.replace(/\.js$/, ''); + return nameWithoutExt + .split('-') + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(''); + } + + /** + * 从请求 URL 中提取 addon 文件名 + * 匹配模式:.../dist/addons/.js + */ + function extractAddonFileName(url: string): string | null { + const match = url.match(/\/packages\/cherry-markdown\/dist\/addons\/([^/?]+\.js)/); + return match ? match[1] : null; + } + + /** + * 为 addon 生成虚拟模块 ID + */ + function getAddonVirtualId(fileName: string): string { + return `${VIRTUAL_PREFIX}addon-${fileName}`; + } + return { name: 'cherry-dev-redirect', enforce: 'pre', @@ -68,23 +107,42 @@ export function cherryDevPlugin(srcDir: string, cherryMarkdownDir: string): Plug server.middlewares.use((req, res, next) => { const url = req.url || ''; - // 匹配模式:/packages/cherry-markdown/dist/cherry-markdown*.js + // 1. 拦截 addon JS 请求 → 虚拟模块 + // 必须在主包匹配之前,避免被 cherry-markdown*.js 的正则吃掉 + const addonFileName = extractAddonFileName(url); + if (addonFileName) { + // 检查对应源文件是否存在 + const srcPath = path.join(srcDir, 'addons', addonFileName); + if (fs.existsSync(srcPath)) { + req.url = `/@id/${getAddonVirtualId(addonFileName)}`; + return next(); + } + } + + // 2. 拦截 cherry-markdown.core.js 请求 → core 虚拟模块 + // 必须在通用的 cherry-markdown*.js 匹配之前 + const coreJsPattern = /\/?\.{0,2}\/?packages\/cherry-markdown\/dist\/cherry-markdown\.core[^/]*\.js/; + if (coreJsPattern.test(url)) { + req.url = `/@id/${virtualCherryCoreJsId}`; + return next(); + } + + // 3. 拦截 cherry-markdown.js(非 core)请求 → full 虚拟模块 const jsPattern = /\/?\.{0,2}\/?packages\/cherry-markdown\/dist\/cherry-markdown[^/]*\.js/; const cssPattern = /\/?\.{0,2}\/?packages\/cherry-markdown\/dist\/cherry-markdown[^/]*\.css/; - // 拦截 cherry-markdown.js 请求 → 虚拟模块 if (jsPattern.test(url)) { req.url = `/@id/${virtualCherryJsId}`; return next(); } - // 拦截 cherry-markdown.css 请求 → 虚拟模块 + // 4. 拦截 cherry-markdown.css 请求 → 虚拟模块 if (cssPattern.test(url)) { req.url = `/@id/${virtualCherryCssId}`; return next(); } - // 拦截字体文件请求,代理到 dist/fonts/ + // 5. 拦截字体文件请求,代理到 dist/fonts/ // 情况1: src/sass/fonts/ 路径(Vite 处理 SCSS 时生成的绝对路径) // 情况2: /fonts/ 根路径(CSS 通过 JS 模块注入时,浏览器用页面 URL 解析相对路径 ./fonts/) const fontPatterns = [/\/packages\/cherry-markdown\/src\/sass\/fonts\/(.+)/, /^\/fonts\/(ch-icon\.[^?]+)/]; @@ -116,19 +174,36 @@ export function cherryDevPlugin(srcDir: string, cherryMarkdownDir: string): Plug }, resolveId(id) { - if (id === virtualCherryJsId) { - return resolvedVirtualCherryJsId; - } - if (id === virtualCherryCssId) { - return resolvedVirtualCherryCssId; + if (id === virtualCherryJsId) return resolvedVirtualCherryJsId; + if (id === virtualCherryCoreJsId) return resolvedVirtualCherryCoreJsId; + if (id === virtualCherryCssId) return resolvedVirtualCherryCssId; + + // 动态 addon 虚拟模块 + if (id.startsWith(VIRTUAL_PREFIX + 'addon-')) { + return `\0${id}`; } }, load(id) { - // 加载虚拟 JS 模块 - 从源码导入并暴露到全局 + const srcDirNormalized = srcDir.replace(/\\/g, '/'); + + // 加载 full bundle 虚拟模块 - 从 index.js 导入(包含 mermaid 等所有 addon) if (id === resolvedVirtualCherryJsId) { return ` -import Cherry from '${srcDir.replace(/\\/g, '/')}/index.js'; +import Cherry from '${srcDirNormalized}/index.js'; + +// 暴露到全局,兼容 examples 中的用法 +window.Cherry = Cherry; + +export default Cherry; +export { Cherry }; +`; + } + + // 加载 core 虚拟模块 - 从 index.core.js 导入(不含 addon,按需加载) + if (id === resolvedVirtualCherryCoreJsId) { + return ` +import Cherry from '${srcDirNormalized}/index.core.js'; // 暴露到全局,兼容 examples 中的用法 window.Cherry = Cherry; @@ -140,16 +215,38 @@ export { Cherry }; // 加载虚拟 CSS 模块 - 导入 SCSS 源文件 if (id === resolvedVirtualCherryCssId) { - return `import '${srcDir.replace(/\\/g, '/')}/sass/index.scss';`; + return `import '${srcDirNormalized}/sass/index.scss';`; + } + + // 加载 addon 虚拟模块 - 从 src/addons/ 导入并暴露为 UMD 风格的全局变量 + if (id.startsWith(RESOLVED_PREFIX + 'addon-')) { + const fileName = id.replace(RESOLVED_PREFIX + 'addon-', ''); + const globalName = addonFileNameToGlobalName(fileName); + return ` +import AddonModule from '${srcDirNormalized}/addons/${fileName}'; + +// 暴露到全局,兼容 dist/addons/ UMD 构建中的全局变量命名 +window.${globalName} = AddonModule; + +export default AddonModule; +`; } }, // 转换 HTML,处理脚本类型和样式引入 + // + // 虚拟模块返回 ES module 代码(import/export),所以需要: + // 1. CSS link → module script(Vite 处理 SCSS 需通过 JS 模块) + // 2. cherry-markdown*.js → type="module"(虚拟模块是 ES module) + // 3. dist/addons/*.js → type="module"(addon 虚拟模块也是 ES module) + // + // 注意:由于 module 脚本延迟执行,后续依赖 Cherry 的脚本 + // 必须也是 module 脚本或写在 `; + }, + ); + + // 3. dist/addons/*.js → type="module" + result = result.replace( + /]*)\s+src=["']([^"']*\/packages\/cherry-markdown\/dist\/addons\/[^"']*\.js)["']([^>]*)><\/script>/gi, + (match, before, src, after) => { + if (/type\s*=\s*["']module["']/i.test(before + after)) { + return match; + } return ``; }, );