md-preview-cli-plus 1.0.6 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -17
- package/bin/cli.js +2 -3
- package/lib/preview.js +4 -4
- package/lib/renderMarkdown.js +169 -21
- package/lib/{loader.js → utils.js} +21 -4
- package/package.json +14 -2
- package/plugins/copyright-plugin.js +7 -1
- package/public/hmrServer.js +53 -5
- package/public/useSocket.js +73 -6
- package/.github/workflows/main.yml +0 -31
- package/lib/loadConfig.js +0 -20
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Markdown 实时预览 + 导出工具
|
|
|
8
8
|
|
|
9
9
|
CLI + Web + Markdown 三位一体
|
|
10
10
|
|
|
11
|
-
由于CLI
|
|
11
|
+
由于 CLI 对系统兼容性要求高,服务端选择 CommonJS 模块化方式,浏览器采用 ESM
|
|
12
12
|
|
|
13
13
|
# Getting Started
|
|
14
14
|
|
|
@@ -36,7 +36,7 @@ npx md-preview-cli-plus <file_path> [options]
|
|
|
36
36
|
**Hot Module Replacement (HMR)** Similar to Vite's HMR: wraps `socket.io` for both client and server.
|
|
37
37
|
|
|
38
38
|
- The browser uses `useSocket()` to listen for server messages.
|
|
39
|
-
-
|
|
39
|
+
- Performance optimization for large files: The page partially updates upon receiving a notification.+ debounce
|
|
40
40
|
- The server watches file changes and pushes updates to the browser.
|
|
41
41
|
|
|
42
42
|
**HTML Export Feature** Supports `--export` flag to save rendered content as an HTML file.
|
|
@@ -47,14 +47,20 @@ npx md-preview-cli-plus <file_path> [options]
|
|
|
47
47
|
|
|
48
48
|
- Supports custom plugins
|
|
49
49
|
- Configuration via `.previewrc` file and CLI arguments
|
|
50
|
+
```json
|
|
51
|
+
//.previewrc
|
|
52
|
+
{
|
|
53
|
+
"theme": "default",
|
|
54
|
+
"pluginsFolder": [//loading all plugins under these folders
|
|
55
|
+
"./plugins"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
```
|
|
50
59
|
- CLI arguments take precedence over config file
|
|
60
|
+
- **Fault Tolerance** If a plugin throws an error, it will be ignored to prevent breaking the core functionality.
|
|
51
61
|
|
|
52
62
|
**Packaging & Distribution** Published as an npm package.
|
|
53
63
|
|
|
54
|
-
- Supports direct usage via `npx md-preview-cli ...` without installation.
|
|
55
|
-
|
|
56
|
-
**Fault Tolerance** If a plugin throws an error, it will be ignored to prevent breaking the core functionality.
|
|
57
|
-
|
|
58
64
|
# 🔌 Plugin System
|
|
59
65
|
|
|
60
66
|
1. **Multiple Integration Options:**
|
|
@@ -65,20 +71,22 @@ npx md-preview-cli-plus <file_path> [options]
|
|
|
65
71
|
- `init`
|
|
66
72
|
- `beforeRender`
|
|
67
73
|
- `afterRender`
|
|
68
|
-
|
|
74
|
+
<hr>
|
|
69
75
|
# 功能细化
|
|
70
76
|
|
|
71
77
|
1. 构建CLI 采用commander 解析参数、增加帮助信息--help、版本控制-V --version、错误处理
|
|
72
78
|
2. 文件监听变化 chokidar 跨平台兼容性好 低耗能
|
|
73
79
|
3. 打开open 打开各种文件程序 跨平台兼容性好
|
|
74
80
|
4. 打造彩色终端 🌈chalk,引入代码高亮 highlight.js
|
|
75
|
-
5. 热刷新HMR:(类似 Vite)封装socket.io客户端和服务端,浏览器使用useSocket()
|
|
81
|
+
5. 热刷新HMR:(类似 Vite)封装socket.io客户端和服务端,浏览器使用useSocket()监听服务端消息,有通知则局部更新页面 ~~(借助morphdom做DOM diff算法)~~ ,服务端监听文件变化并通知浏览器页面更新
|
|
82
|
+
性能优化:
|
|
83
|
+
(1)拆分为逻辑块,手写逻辑块 diff算法对比(按照key比较),将修改块发送给客户端,客户端通过增删逻辑对比替换
|
|
84
|
+
(2)更新逻辑的防抖处理:100ms等待时间
|
|
76
85
|
6. 导出 HTML 功能:增加 --export 参数保存 HTML 文件
|
|
77
86
|
7. 多主题支持:通过 --theme=dark 选择不同样式、支持自定义主题(放在styles文件夹下,代码参考default.css)
|
|
78
|
-
8. 插件机制:支持自定义插件及其生命周期钩子、高灵活度配置,支持插件配置文件.previewrc
|
|
79
|
-
9.
|
|
80
|
-
10.
|
|
81
|
-
11. 后续:结合图床、富文本编辑、插件沙箱机制、vitest单元测试
|
|
87
|
+
8. 插件机制:支持自定义插件及其生命周期钩子、高灵活度配置,支持插件配置文件.previewrc(一键配置加载文件夹下所有插件)+CLI参数优先级控制;容错机制:如果某插件有错误,无视该插件;
|
|
88
|
+
9. 打包发布:已发布为 NPM 包
|
|
89
|
+
10. 后续:微前端结合文档管理器(传入文件夹)、图床、富文本编辑、插件沙箱机制、vitest单元测试
|
|
82
90
|
|
|
83
91
|
# 插件机制
|
|
84
92
|
|
|
@@ -95,8 +103,7 @@ md-preview/
|
|
|
95
103
|
│ ├── pluginManager.js # 插件加载器
|
|
96
104
|
│ ├── preview.js # 启动本地服务
|
|
97
105
|
│ └── renderMarkdown.js # Markdown 渲染函数
|
|
98
|
-
│ └──
|
|
99
|
-
│ └── loader.js # 读取某文件夹下所有插件
|
|
106
|
+
│ └── utils.js # 多个工具函数,读取配置文件 .previewrc、读取某文件夹下所有插件
|
|
100
107
|
├── public/
|
|
101
108
|
│ └── hmrServer.js #hmr socket服务器
|
|
102
109
|
│ └── useSocket.js #hmr socket客户端
|
|
@@ -105,7 +112,6 @@ md-preview/
|
|
|
105
112
|
│ └── dark.css # 暗黑主题样式
|
|
106
113
|
├── README.md
|
|
107
114
|
├── package.json
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
├── copyright-plugin.js # 示例插件 影响最底部文字 可直接删除
|
|
115
|
+
├── plugins/
|
|
116
|
+
│ ├── copyright-plugin.js # 示例插件 影响最底部文字 可直接删除
|
|
111
117
|
```
|
package/bin/cli.js
CHANGED
|
@@ -4,8 +4,7 @@ const { program } = require('commander');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const startPreview = require('../lib/preview');
|
|
6
6
|
const pluginManager = require('../lib/pluginManager');
|
|
7
|
-
const loadConfig = require('../lib/
|
|
8
|
-
const loadPlugins = require('../lib/loader');
|
|
7
|
+
const { loadConfig, loadPlugins } = require('../lib/utils');
|
|
9
8
|
|
|
10
9
|
//帮助信息自动生成,不需要手动写
|
|
11
10
|
//默认命令触发条件就是和基本信息分开
|
|
@@ -23,7 +22,7 @@ program
|
|
|
23
22
|
.option('--theme <theme>', `${theme_d}`, 'default')
|
|
24
23
|
.option('--export <path>', `${export_d}`, null)
|
|
25
24
|
//修改CLI支持--plugin参数 可多个
|
|
26
|
-
.option('--plugin
|
|
25
|
+
.option('--plugin [plugin_path1,plugin_path2,...]', `${plugin_d}`) //...是commander里的rest参数
|
|
27
26
|
.action((file, options) => {
|
|
28
27
|
//console.log(options)
|
|
29
28
|
|
package/lib/preview.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const fs = require('fs');
|
|
3
|
-
const markdownToHtml = require('./renderMarkdown');
|
|
3
|
+
const { markdownToHtml } = require('./renderMarkdown');
|
|
4
4
|
const hmrServer = require('../public/hmrServer');
|
|
5
5
|
const open = require('open');
|
|
6
6
|
const chalk = require('chalk');
|
|
@@ -10,14 +10,14 @@ function startPreview(filePath, options, pluginManager) {
|
|
|
10
10
|
const app = express();
|
|
11
11
|
const port = options.port || 3000;
|
|
12
12
|
|
|
13
|
+
let { htmlBlocks, html } = markdownToHtml(filePath, options.theme, options.port, pluginManager);
|
|
13
14
|
app.get('/', (req, res) => {
|
|
14
|
-
let html = markdownToHtml(filePath, options.theme, options.port, pluginManager);
|
|
15
|
-
html = pluginManager.allAfterRender(html, options.theme) ?? html;
|
|
16
15
|
if (options.export) {
|
|
17
16
|
const exportPath = path.resolve(process.cwd(), options.export);
|
|
18
17
|
fs.writeFileSync(exportPath, html);
|
|
19
18
|
console.log(chalk.green(`✅ HTML exported to: ${exportPath}`));
|
|
20
19
|
}
|
|
20
|
+
html = pluginManager.allAfterRender(html, options.theme) ?? html;
|
|
21
21
|
res.send(html);
|
|
22
22
|
});
|
|
23
23
|
|
|
@@ -29,7 +29,7 @@ function startPreview(filePath, options, pluginManager) {
|
|
|
29
29
|
open(`http://localhost:${port}`);
|
|
30
30
|
}); //返回http.Server对象
|
|
31
31
|
|
|
32
|
-
hmrServer(server, filePath);
|
|
32
|
+
hmrServer(server, filePath, options.theme, pluginManager, htmlBlocks);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
module.exports = startPreview;
|
package/lib/renderMarkdown.js
CHANGED
|
@@ -1,19 +1,173 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
1
|
const markdownIt = require('markdown-it');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
function generateHash(content) {
|
|
5
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
6
|
+
}
|
|
7
|
+
function splitBlocks(mdText) {
|
|
8
|
+
//按逻辑块分组 正确渲染多行结构
|
|
9
|
+
/*逻辑块汇总
|
|
10
|
+
段落(连续非空文本行)
|
|
11
|
+
标题(# 开头)
|
|
12
|
+
代码块(``` 包裹)
|
|
13
|
+
表格(包含 | 且有 |---| 分隔行)
|
|
14
|
+
引用块(> 开头)
|
|
15
|
+
列表项(- / * / 1. 开头)
|
|
16
|
+
水平线(---、*** 等)
|
|
17
|
+
空行(作为块的分隔)
|
|
18
|
+
*/
|
|
19
|
+
const lines = mdText.split('\n');
|
|
20
|
+
const blocks = [];
|
|
21
|
+
let buffer = [];
|
|
22
|
+
let blockType = null;
|
|
23
|
+
let inCodeBlock = false; //是否在代码块中
|
|
24
|
+
let codeBlockFence = '';
|
|
25
|
+
function pushBuffer(index) {
|
|
26
|
+
if (buffer.length) {
|
|
27
|
+
let content = buffer.join('\n');
|
|
28
|
+
blocks.push({
|
|
29
|
+
type: blockType || 'paragraph',
|
|
30
|
+
content,
|
|
31
|
+
index, //逻辑块所在的结束行号
|
|
32
|
+
key: generateHash(`${blockType}${content}`), //生成唯一标识符
|
|
33
|
+
});
|
|
34
|
+
mapKey = [];
|
|
35
|
+
buffer = [];
|
|
36
|
+
blockType = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < lines.length; i++) {
|
|
41
|
+
const line = lines[i];
|
|
42
|
+
|
|
43
|
+
// --- 处理代码块 ---
|
|
44
|
+
if (/^```/.test(line)) {
|
|
45
|
+
if (!inCodeBlock) {
|
|
46
|
+
pushBuffer(i - 1); // 之前的结束
|
|
47
|
+
inCodeBlock = true;
|
|
48
|
+
codeBlockFence = line.trim();
|
|
49
|
+
blockType = 'code';
|
|
50
|
+
buffer.push(line);
|
|
51
|
+
} else {
|
|
52
|
+
buffer.push(line);
|
|
53
|
+
|
|
54
|
+
pushBuffer(i); // 整个代码块结束
|
|
55
|
+
inCodeBlock = false;
|
|
56
|
+
codeBlockFence = '';
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (inCodeBlock) {
|
|
62
|
+
buffer.push(line);
|
|
63
|
+
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- 空行:结束当前块 ---
|
|
68
|
+
if (line.trim() === '') {
|
|
69
|
+
pushBuffer(i);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- 处理标题 ---
|
|
74
|
+
if (/^#{1,6} /.test(line)) {
|
|
75
|
+
pushBuffer(i - 1); // 之前的结束
|
|
76
|
+
blockType = 'heading';
|
|
77
|
+
buffer.push(line);
|
|
78
|
+
|
|
79
|
+
pushBuffer(i);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// --- 处理引用 ---
|
|
83
|
+
if (/^\s*>/.test(line)) {
|
|
84
|
+
//引用符号前面可以只有空白符
|
|
85
|
+
if (blockType !== 'blockquote') {
|
|
86
|
+
pushBuffer(i - 1);
|
|
87
|
+
blockType = 'blockquote';
|
|
88
|
+
}
|
|
89
|
+
buffer.push(line);
|
|
90
|
+
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// --- 处理列表 ---
|
|
94
|
+
if (/^\s*[-*+] /.test(line) || /^\s*\d+\. /.test(line)) {
|
|
95
|
+
if (blockType !== 'list') {
|
|
96
|
+
pushBuffer(i - 1);
|
|
97
|
+
blockType = 'list';
|
|
98
|
+
}
|
|
99
|
+
buffer.push(line);
|
|
100
|
+
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// --- 处理表格 --- \s* 表示可有可无空格
|
|
104
|
+
if (/^\s*\|.*\|\s*$/.test(line)) {
|
|
105
|
+
if (blockType !== 'table') {
|
|
106
|
+
pushBuffer(i - 1);
|
|
107
|
+
blockType = 'table';
|
|
108
|
+
}
|
|
109
|
+
buffer.push(line);
|
|
3
110
|
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// --- 处理分割线 ---
|
|
114
|
+
if (/^---+$/.test(line) || /^\*\*\*+$/.test(line)) {
|
|
115
|
+
pushBuffer(i - 1);
|
|
116
|
+
blockType = 'hr';
|
|
117
|
+
buffer.push(line);
|
|
118
|
+
|
|
119
|
+
pushBuffer(i);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- 默认处理为段落 ---
|
|
124
|
+
if (blockType !== 'paragraph') {
|
|
125
|
+
pushBuffer(i - 1);
|
|
126
|
+
blockType = 'paragraph';
|
|
127
|
+
}
|
|
128
|
+
buffer.push(line);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
pushBuffer(lines.length); // 最后剩下的也加入
|
|
132
|
+
return blocks;
|
|
133
|
+
}
|
|
134
|
+
function mergeHtml(htmlBlocks, theme, pluginManager) {
|
|
135
|
+
let html = htmlBlocks.map((block) => block.html).join('');
|
|
136
|
+
// renderHtml = pluginManager.allAfterRender(html,theme) ?? html;
|
|
137
|
+
// return renderHtml;
|
|
138
|
+
return html;
|
|
139
|
+
}
|
|
140
|
+
function preProcessing(filePath, theme, pluginManager) {
|
|
141
|
+
let markdownContent = fs.readFileSync(filePath, 'utf-8');
|
|
142
|
+
markdownContent = pluginManager.allBeforeRender(markdownContent, theme) ?? markdownContent;
|
|
143
|
+
let splitedMd = splitBlocks(markdownContent);
|
|
144
|
+
let htmlBlocks = [];
|
|
145
|
+
for (const idx in splitedMd) {
|
|
146
|
+
const block = splitedMd[idx];
|
|
147
|
+
block.html = md.render(block.content);
|
|
148
|
+
htmlNode = {
|
|
149
|
+
...block,
|
|
150
|
+
idx,
|
|
151
|
+
html: `<div
|
|
152
|
+
data-key="${block.key}"
|
|
153
|
+
data-idx="${idx}"
|
|
154
|
+
data-index="${block.index}" data-type="${block.type}">${block.html}</div>`,
|
|
155
|
+
};
|
|
156
|
+
htmlBlocks.push(htmlNode); //传入辅助信息
|
|
157
|
+
}
|
|
158
|
+
return htmlBlocks;
|
|
159
|
+
}
|
|
4
160
|
function markdownToHtml(filePath, theme = 'default', port, pluginManager) {
|
|
5
|
-
|
|
161
|
+
md = new markdownIt({
|
|
6
162
|
html: true,
|
|
7
163
|
linkify: true, //自动识别链接文本
|
|
8
164
|
typographer: true, //排版符号美化
|
|
9
165
|
});
|
|
10
|
-
|
|
11
|
-
let
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return `
|
|
166
|
+
let htmlBlocks = preProcessing(filePath, theme, pluginManager);
|
|
167
|
+
let renderHtml = mergeHtml(htmlBlocks, theme, pluginManager);
|
|
168
|
+
return {
|
|
169
|
+
htmlBlocks,
|
|
170
|
+
html: `
|
|
17
171
|
<!DOCTYPE html>
|
|
18
172
|
<html lang="en">
|
|
19
173
|
<head>
|
|
@@ -23,8 +177,8 @@ function markdownToHtml(filePath, theme = 'default', port, pluginManager) {
|
|
|
23
177
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/github-dark.min.css">
|
|
24
178
|
</head>
|
|
25
179
|
<body>
|
|
26
|
-
<div class="content">
|
|
27
|
-
${
|
|
180
|
+
<div class="content" id="oldHtml">
|
|
181
|
+
${renderHtml}
|
|
28
182
|
</div>
|
|
29
183
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/highlight.min.js"></script>
|
|
30
184
|
<script>
|
|
@@ -32,17 +186,11 @@ function markdownToHtml(filePath, theme = 'default', port, pluginManager) {
|
|
|
32
186
|
</script>
|
|
33
187
|
<script type="module">
|
|
34
188
|
import { useSocket } from '/useSocket.js'
|
|
35
|
-
|
|
36
|
-
useSocket({
|
|
37
|
-
serverUrl: 'http://localhost:${port}',
|
|
38
|
-
onUpdate: (html) => {
|
|
39
|
-
//document.querySelector('.content').innerHTML = html
|
|
40
|
-
location.reload()
|
|
41
|
-
}
|
|
42
|
-
})
|
|
189
|
+
useSocket('http://localhost:${port}')
|
|
43
190
|
</script>
|
|
44
191
|
</body>
|
|
45
|
-
</html
|
|
192
|
+
</html>`,
|
|
193
|
+
};
|
|
46
194
|
}
|
|
47
195
|
|
|
48
|
-
module.exports = markdownToHtml;
|
|
196
|
+
module.exports = { preProcessing, mergeHtml, markdownToHtml };
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
//loader.js
|
|
2
|
-
//把配置的插件都加载放入并返回
|
|
3
|
-
const path = require('path');
|
|
4
1
|
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
5
3
|
|
|
6
4
|
function loadPlugins(folder_path) {
|
|
5
|
+
//加载文件夹下所有插件
|
|
7
6
|
let plugins = [];
|
|
8
7
|
//把folder_path目录下的插件注册完
|
|
9
8
|
if (Array.isArray(folder_path)) {
|
|
@@ -27,4 +26,22 @@ function loadPlugins(folder_path) {
|
|
|
27
26
|
return plugins;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
function loadConfig() {
|
|
30
|
+
//配置读取模块
|
|
31
|
+
const configPath = path.resolve(process.cwd(), '.previewrc');
|
|
32
|
+
if (!fs.existsSync(configPath)) return {};
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
36
|
+
const config = JSON.parse(raw);
|
|
37
|
+
return config;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.warn('⚠️ Failed to load .previewrc :', e.message);
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
loadConfig,
|
|
46
|
+
loadPlugins,
|
|
47
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "md-preview-cli-plus",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "md real-time preview + export",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"chokidar": "^4.0.3",
|
|
39
39
|
"commander": "^14.0.0",
|
|
40
40
|
"express": "^5.1.0",
|
|
41
|
+
"lodash.debounce": "^4.0.8",
|
|
41
42
|
"markdown-it": "^14.1.0",
|
|
42
43
|
"open": "^8.4.0",
|
|
43
44
|
"socket.io": "^4.8.1"
|
|
@@ -49,5 +50,16 @@
|
|
|
49
50
|
"husky": "^9.1.7",
|
|
50
51
|
"lint-staged": "^16.1.2",
|
|
51
52
|
"prettier": "^3.6.2"
|
|
52
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"bin/",
|
|
56
|
+
"lib/",
|
|
57
|
+
"plugins/",
|
|
58
|
+
"public/",
|
|
59
|
+
"styles/",
|
|
60
|
+
".editorconfig",
|
|
61
|
+
".previewrc",
|
|
62
|
+
"README.md",
|
|
63
|
+
"LICENSE"
|
|
64
|
+
]
|
|
53
65
|
}
|
|
@@ -9,6 +9,12 @@ module.exports = {
|
|
|
9
9
|
|
|
10
10
|
// 渲染为 HTML 后调用,可修改 HTML 字符串
|
|
11
11
|
afterRender(html, options) {
|
|
12
|
-
|
|
12
|
+
if (html.includes('<footer>')) {
|
|
13
|
+
return html; // 如果已经有 footer,则不添加
|
|
14
|
+
}
|
|
15
|
+
return html.replace(
|
|
16
|
+
'</body>',
|
|
17
|
+
`<footer><p style="text-align:center;color:#aaa;">© 2025 </p></footer></body>`
|
|
18
|
+
);
|
|
13
19
|
},
|
|
14
20
|
};
|
package/public/hmrServer.js
CHANGED
|
@@ -1,8 +1,41 @@
|
|
|
1
1
|
const { Server } = require('socket.io');
|
|
2
2
|
const chokidar = require('chokidar');
|
|
3
3
|
const chalk = require('chalk');
|
|
4
|
+
const { preProcessing } = require('../lib/renderMarkdown');
|
|
5
|
+
const debounce = require('lodash.debounce'); // 防抖函数
|
|
6
|
+
function NodeDiff(oldBlocks, newBlocks) {
|
|
7
|
+
//按key比较
|
|
8
|
+
//console.log(JSON.stringify(newBlocks));
|
|
9
|
+
//console.log(Array.isArray(oldBlocks),Array.isArray(newBlocks));
|
|
10
|
+
const oldMap = new Map(oldBlocks.map((b) => [b.key, b]));
|
|
11
|
+
const newMap = new Map(newBlocks.map((b) => [b.key, b]));
|
|
4
12
|
|
|
5
|
-
|
|
13
|
+
const added = [];
|
|
14
|
+
const updated = [];
|
|
15
|
+
const deleted = [];
|
|
16
|
+
|
|
17
|
+
// 找出新增和更新的
|
|
18
|
+
for (const [key, newBlock] of newMap) {
|
|
19
|
+
//console.log(key,'666',oldMap.get(key))
|
|
20
|
+
const oldBlock = oldMap.get(key);
|
|
21
|
+
if (!oldBlock) {
|
|
22
|
+
added.push(newBlock);
|
|
23
|
+
}
|
|
24
|
+
// else if (JSON.stringify(oldBlock.content) !== JSON.stringify(newBlock.content)) {
|
|
25
|
+
// updated.push(newBlock);
|
|
26
|
+
// }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 找出被删除的
|
|
30
|
+
for (const [key, oldBlock] of oldMap) {
|
|
31
|
+
if (!newMap.has(key)) {
|
|
32
|
+
deleted.push(oldBlock);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { added, updated, deleted };
|
|
36
|
+
}
|
|
37
|
+
function hmrServer(server, filePath, theme, pluginManager, oldBlocks) {
|
|
38
|
+
//旧html块
|
|
6
39
|
const watcher = chokidar.watch(filePath);
|
|
7
40
|
const io = new Server(server, {
|
|
8
41
|
cors: { origin: '*' },
|
|
@@ -10,9 +43,24 @@ function hmrServer(server, filePath) {
|
|
|
10
43
|
io.on('connection', (socket) => {
|
|
11
44
|
//console.log('connect successfully')
|
|
12
45
|
});
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
46
|
+
let oldHtmlBlocks = oldBlocks;
|
|
47
|
+
watcher
|
|
48
|
+
.on('ready', () => {
|
|
49
|
+
// console.log('ready!!!!')
|
|
50
|
+
// setTimeout(() => {
|
|
51
|
+
// io.emit('init', pluginManager, theme);
|
|
52
|
+
// }, 1000); // 等客户端连接稳定后再发
|
|
53
|
+
})
|
|
54
|
+
.on(
|
|
55
|
+
'change',
|
|
56
|
+
debounce(() => {
|
|
57
|
+
console.log(chalk.cyan('📄 Markdown file updated and this page will update partially.'));
|
|
58
|
+
newHtmlBlocks = preProcessing(filePath, theme, pluginManager);
|
|
59
|
+
changedBlocks = NodeDiff(oldHtmlBlocks, newHtmlBlocks);
|
|
60
|
+
//console.log(JSON.stringify(changedBlocks));
|
|
61
|
+
io.emit('update', changedBlocks, pluginManager, theme);
|
|
62
|
+
oldHtmlBlocks = newHtmlBlocks; // 更新旧的逻辑块
|
|
63
|
+
}, 100)
|
|
64
|
+
); //防抖处理:如果有变化 则需要等待100ms后再执行一次更新逻辑
|
|
17
65
|
}
|
|
18
66
|
module.exports = hmrServer;
|
package/public/useSocket.js
CHANGED
|
@@ -1,7 +1,67 @@
|
|
|
1
|
+
//ESM模块化
|
|
1
2
|
// useSocket.js(浏览器端模块,支持原生或 Vite 项目)
|
|
2
3
|
import { io } from 'https://cdn.socket.io/4.7.5/socket.io.esm.min.js';
|
|
4
|
+
//import morphdom from 'https://cdn.jsdelivr.net/npm/morphdom@2.6.1/dist/morphdom-esm.js';
|
|
5
|
+
function htmlStringToNode(htmlStr) {
|
|
6
|
+
const template = document.createElement('template'); //安全解析DOM结构 支持所有浏览器
|
|
7
|
+
template.innerHTML = htmlStr.trim(); // 去除空格,避免空文本节点
|
|
8
|
+
return template.content.firstChild;
|
|
9
|
+
}
|
|
10
|
+
function applyDiff({ added = [], updated = [], deleted = [] }, container) {
|
|
11
|
+
const keyToElement = new Map();
|
|
12
|
+
// 先缓存当前 DOM 中的所有 block 节点
|
|
13
|
+
container.querySelectorAll('[data-key]').forEach((el) => {
|
|
14
|
+
keyToElement.set(el.dataset.key, el);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// 1. 删除
|
|
18
|
+
deleted.forEach((block) => {
|
|
19
|
+
const el = keyToElement.get(block.key);
|
|
20
|
+
if (el) el.remove();
|
|
21
|
+
});
|
|
3
22
|
|
|
4
|
-
|
|
23
|
+
// 2. 更新 不可能有完全一样的key,因为key的组成是type+content 如果有变化则content不可能相同
|
|
24
|
+
// updated.forEach(block => {
|
|
25
|
+
// const oldEl = keyToElement.get(block.key);
|
|
26
|
+
// if (oldEl) {
|
|
27
|
+
// const newEl = htmlStringToNode(block.html);
|
|
28
|
+
// morphdom(oldEl, newEl, {//按照key来找到旧节点 无论顺序如何改变
|
|
29
|
+
// getNodeKey: node => node.dataset?.key ?? null
|
|
30
|
+
// });
|
|
31
|
+
// }
|
|
32
|
+
// });
|
|
33
|
+
|
|
34
|
+
// 3. 新增
|
|
35
|
+
added.forEach((block) => {
|
|
36
|
+
const newEl = htmlStringToNode(block.html);
|
|
37
|
+
|
|
38
|
+
// 插入位置:用 idx 升序插入
|
|
39
|
+
const all = Array.from(container.querySelectorAll('[data-key]'));
|
|
40
|
+
const idx = block.idx;
|
|
41
|
+
|
|
42
|
+
let inserted = false;
|
|
43
|
+
for (let i = 0; i < all.length; i++) {
|
|
44
|
+
const elIdx = parseInt(all[i].dataset.idx);
|
|
45
|
+
if (elIdx > idx) {
|
|
46
|
+
container.insertBefore(newEl, all[i]);
|
|
47
|
+
inserted = true;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!inserted) {
|
|
53
|
+
container.appendChild(newEl);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// function OneParentNode(html) {//morphdom需要一个父节点
|
|
59
|
+
// const div = document.createElement('div');
|
|
60
|
+
// div.id = 'oldHtml';
|
|
61
|
+
// div.innerHTML = html;
|
|
62
|
+
// return div;
|
|
63
|
+
// }
|
|
64
|
+
export function useSocket(serverUrl) {
|
|
5
65
|
const socket = io(serverUrl);
|
|
6
66
|
|
|
7
67
|
socket.on('connect', () => {
|
|
@@ -13,11 +73,18 @@ export function useSocket({ serverUrl, onUpdate }) {
|
|
|
13
73
|
});
|
|
14
74
|
|
|
15
75
|
// 接收后端推送的 HTML 内容
|
|
16
|
-
socket.on('
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
76
|
+
// socket.on('init',(pluginManager,theme)=>{
|
|
77
|
+
// console.log(pluginManager,theme)
|
|
78
|
+
// let node=document.getElementById('oldHtml');
|
|
79
|
+
// let html=node.innerHTML;
|
|
80
|
+
// node.innerHTML=pluginManager.allAfterRender(html,theme) ?? html;
|
|
81
|
+
// })
|
|
82
|
+
socket.on('update', (changedBlocks, pluginManager, theme) => {
|
|
83
|
+
console.log('📥 收到 update 事件', changedBlocks);
|
|
84
|
+
applyDiff(changedBlocks, document.getElementById('oldHtml'));
|
|
85
|
+
|
|
86
|
+
//morphdom(document.getElementById('oldHtml'), OneParentNode(newHtml));
|
|
87
|
+
// 自动把 old 中的 DOM 节点,变更为 new 的内容,只更新必要的差异部分。
|
|
21
88
|
});
|
|
22
89
|
|
|
23
90
|
return socket;
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
name: Publish to npm
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
tags:
|
|
6
|
-
- 'v*' # 仅当发布 tag,如 v1.0.0 时触发
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
publish:
|
|
10
|
-
runs-on: ubuntu-latest
|
|
11
|
-
|
|
12
|
-
steps:
|
|
13
|
-
- name: Checkout repo
|
|
14
|
-
uses: actions/checkout@v3
|
|
15
|
-
|
|
16
|
-
- name: Setup Node.js
|
|
17
|
-
uses: actions/setup-node@v3
|
|
18
|
-
with:
|
|
19
|
-
node-version: 18
|
|
20
|
-
registry-url: 'https://registry.npmjs.org/'
|
|
21
|
-
|
|
22
|
-
- name: Install dependencies
|
|
23
|
-
run: npm ci
|
|
24
|
-
|
|
25
|
-
- name: Run
|
|
26
|
-
run: npm run # 如果你没有 build 步骤可以删掉
|
|
27
|
-
|
|
28
|
-
- name: Publish to npm
|
|
29
|
-
run: npm publish
|
|
30
|
-
env:
|
|
31
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/lib/loadConfig.js
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
//配置读取模块 lib/loadConfig.js
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
|
|
6
|
-
function loadConfig() {
|
|
7
|
-
const configPath = path.resolve(process.cwd(), '.previewrc');
|
|
8
|
-
if (!fs.existsSync(configPath)) return {};
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
12
|
-
const config = JSON.parse(raw);
|
|
13
|
-
return config;
|
|
14
|
-
} catch (e) {
|
|
15
|
-
console.warn('⚠️ Failed to load .previewrc :', e.message);
|
|
16
|
-
return {};
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
module.exports = loadConfig;
|