update at 2025-09-22 16:49:33

This commit is contained in:
douboer
2025-09-22 16:49:33 +08:00
parent 9b8ec73c83
commit 81c6f52b69
8 changed files with 742 additions and 7 deletions

131
architecture.md Normal file
View File

@@ -0,0 +1,131 @@
# Architecture Overview
> 本文档从整体视角拆分自 `detaildesign.md`,聚焦架构分层、核心模块职责与交互关系。补充细粒度时序与图片处理细节请参见:
> - `image-pipeline.md`
> - `render-service-blueprint.md`
> - `diagrams.md`
## 1. 分层结构
```
UI / Interaction (NotePreview)
├─ Toolbar (复制/上传/发草稿/图片集/导出/批量)
└─ 状态维护 (当前笔记、主题、代码高亮、公众号 AppId)
Rendering & Composition (ArticleRender + MarkedParser + Extensions)
├─ Markdown 预处理 (frontmatter 剥离 / 可选图片语法转换)
├─ 语法扩展 (LocalFile: wikilink 图片、文件嵌入、Excalidraw、SVG)
├─ 延迟元素缓存 (Mermaid / Excalidraw)
└─ 样式注入 (主题 + 代码高亮 + 自定义 CSS)
Assets & Settings Layer
├─ AssetsManager (themes, highlights, customCSS)
└─ NMPSettings (frontmatter key 映射 / 功能开关 / 微信配置)
Resource & Media Layer
├─ LocalImageManager (图片登记 / 上传 / 替换 / base64 嵌入)
├─ Image Conversion (WebP -> JPG wasm)
└─ html-to-image (Mermaid/Excalidraw 转 PNG)
WeChat Integration Layer
├─ Token 代理 (wxGetToken)
├─ 草稿创建 (wxAddDraft / wxAddDraftImages)
├─ 素材批量获取 (wxBatchGetMaterial)
└─ 上传图片 (wxUploadImage)
Utilities & Helpers
├─ debounce, applyCSS, waitForLayoutReady
└─ CardDataManager (代码卡片序列化/恢复)
```
## 2. 核心模块职责矩阵
| 模块 | 关键职责 | 输入 | 输出 | 失败模式 |
|------|----------|------|------|----------|
| NotePreview | 交互、调度、批处理 | 用户操作 / 活动文件 | 渲染触发、发布调用 | 文件为空 / 未配置公众号 |
| ArticleRender | 渲染 + 元数据 + 发布协调 | Markdown + Settings | HTML + DraftArticle | 解析错误 / token 失败 |
| MarkedParser + Extensions | Markdown Token 化扩展 | 预处理文本 | Token 树 -> HTML 片段 | 不支持语法 / 路径缺失 |
| LocalFile | Wikilink/嵌入/Excalidraw/SVG | Token.raw | 标准 HTML / 占位 | 文件不存在 / AuthKey 缺失 |
| LocalImageManager | 图片生命周期 | 图片引用集合 | 上传后的 URL 替换结果 | 上传失败 / 转码失败 |
| AssetsManager | 主题 & 高亮加载 | 文件系统 | CSS 文本 | 主题缺失 |
| NMPSettings | 全局配置 | 存储数据 | 配置实例 | AuthKey 无效 |
| WeChat API | 外部接口封装 | 请求结构 | 响应 JSON | HTTP 非 200 / errcode |
| CardDataManager | 代码块序列化 | HTML fragment | 可复制安全内容 | 序列化不匹配 |
## 3. 关键交互流程简述
### 3.1 渲染触发
1. 用户打开/切换文件 → `file-open` 事件。
2. `NotePreview.update()` → 清理旧图片缓存 → `ArticleRender.renderMarkdown()`
3. 读取 Markdown → (frontmatter 剥离 + 可选图片语法转换) → Marked 解析扩展 → HTML section → 延迟元素二次渲染。
4. 更新 UI 下拉:主题 / 高亮 / 公众号。
### 3.2 上传图片
1. 用户点击“上传图片” → `ArticleRender.uploadImages()`
2. 获取 token → 缓存元素转图片 → 本地/远程图片逐步上传 → DOM src 替换 → 复制 HTML 到剪贴板。
### 3.3 发布草稿
1. “发草稿” → token → 缓存元素转图片。
2. 上传本地 & 远程图片 → 封面决策thumb_media_id/frontmatter/正文首图/默认素材)→ 构造 `DraftArticle`
3. 调用 `wxAddDraft` → 返回 media_id。
### 3.4 发布图片集 (newspic)
与草稿类似,但构造 `DraftImages`content 使用纯文本,图片列表为 `media_id` 数组。
## 4. 元数据 & 封面策略抽象
逻辑函数(伪):
```ts
function resolveCover(originalMarkdown: string, fmCover?: string, thumbMediaId?: string): string | null {
if (thumbMediaId) return null; // 已有 media_id 不再上传本地封面
if (fmCover) return fmCover; // frontmatter 指定
const body = stripFrontmatter(originalMarkdown);
const candidates = collectImageOrder(body); // wikilink + markdown
return candidates.length ? `![[${candidates[0]}]]` : null;
}
```
## 5. 图片收集策略统一要点
- Wikilink解析时即登记路径 → vault file
- Markdown 图片:可配置预处理转 Wikilink以复用同一逻辑避免重复正则后处理
- DOM 补偿策略(如后续新渲染管线未登记图片):可在上传前扫描 `<img>` 回填缺失项。
## 6. 错误处理分层
| 层 | 代表错误 | 处理策略 |
|----|----------|----------|
| 输入层 | 没有活动文件 | 静默返回 / UI 提示 |
| 渲染层 | Marked 解析异常 | try/catch 显示“渲染失败” HTML |
| 媒体层 | 图片上传失败 | Notice + 继续其他图片 |
| 转码层 | wasm 未加载 | 降级:保持 webp 原样尝试上传 |
| 发布层 | token 获取失败 | 抛异常终止流程 |
| API 层 | errcode != 0 | 聚合错误信息 + 建议手动复制 |
## 7. 性能关注点
| 项目 | 当前 | 潜在优化 |
|------|------|----------|
| 图片上传 | 串行 | 并发队列 + 重试退避 |
| Mermaid/Excalidraw | 前台转 PNG | 结果缓存 / worker 线程 |
| 主题加载 | 每次刷新全部组合 | 按需拼接 + 缓存 hash |
| Markdown 解析 | 单线程 | 增量渲染(监听 diff |
## 8. 可扩展挂载点 (未来)
| Hook | 说明 | 预期参数 |
|------|------|----------|
| beforeRender | Markdown 转 HTML 前 | { markdown, file } |
| afterRender | HTML 完成但未上传 | { html, metadata } |
| beforeUploadImages | 上传前 | { images[] } |
| afterUploadImages | 上传后 | { mapping } |
| beforePublish | 调用 wxAddDraft 前 | { draft } |
| afterPublish | 成功返回 | { media_id } |
## 9. 风险摘录
- 多管线并存(旧 ArticleRender vs 新 RenderService 占位)易导致图片未登记 → 需统一抽象。
- 自动封面选取对远程图片/非图片扩展尚无过滤完备策略。
- 正则预处理与 Marked tokenizer 逻辑耦合度高,重构时需单测保护。
## 10. 索引 / 交叉引用
| 文档 | 内容概述 |
|------|----------|
| detaildesign.md | 全量深度设计描述 |
| image-pipeline.md | 图片解析、上传、封面、正则清单 |
| render-service-blueprint.md | 新一代渲染服务设计计划 |
| diagrams.md | 架构 / 时序 / 类关系图 (Mermaid) |
---
> 若修改架构(新增层或跨层依赖),需同步更新本文件与 `diagrams.md`。

View File

@@ -1,5 +1,12 @@
# note-to-mp 设计文档 (Detail Design)
> 拆分文档索引:
> - 架构总览:`architecture.md`
> - 图片管线:`image-pipeline.md`
> - 渲染服务蓝图:`render-service-blueprint.md`
> - 图示 (Mermaid)`diagrams.md`
> 本文件保留全量细节,增量演进请同步上述子文档。
## 1. 背景
为满足从 Obsidian 笔记到微信公众号文章的高质量发布需求,插件需要:
- 支持多种图片书写形式Wikilink 与标准 Markdown

155
diagrams.md Normal file
View File

@@ -0,0 +1,155 @@
# Diagrams (Mermaid)
> 动态架构与主要交互可视化。与文字说明对应:`architecture.md` / `image-pipeline.md` / `render-service-blueprint.md`。
## 1. 模块类图 (当前实现概览)
```mermaid
classDiagram
class NotePreview {
+renderMarkdown()
+uploadImages()
+postArticle()
+postImages()
+exportHTML()
}
class ArticleRender {
+renderMarkdown(file)
+getMetadata()
+uploadImages(appid)
+postArticle(appid,cover?)
+postImages(appid)
+exportHTML()
}
class LocalImageManager {
+setImage(path,info)
+uploadLocalImage(token,vault)
+uploadRemoteImage(root,token)
+replaceImages(root)
}
class LocalFile {
+markedExtension()
}
class AssetsManager {
+loadAssets()
+getTheme(name)
+getHighlight(name)
}
class NMPSettings {
+wxInfo
+authKey
+enableMarkdownImageToWikilink
}
class WeChatAPI {
+wxGetToken()
+wxAddDraft()
+wxUploadImage()
}
NotePreview --> ArticleRender
ArticleRender --> LocalImageManager
ArticleRender --> AssetsManager
ArticleRender --> NMPSettings
ArticleRender --> WeChatAPI
ArticleRender --> LocalFile
LocalFile --> LocalImageManager
NotePreview --> NMPSettings
NotePreview --> AssetsManager
```
## 2. 发布草稿时序图
```mermaid
sequenceDiagram
participant U as User
participant NP as NotePreview
participant AR as ArticleRender
participant WX as WeChatAPI
participant LIM as LocalImageManager
U->>NP: 点击 发草稿
NP->>AR: postArticle(appid)
AR->>WX: wxGetToken(authKey, appid)
WX-->>AR: token
AR->>AR: cachedElementsToImages()
AR->>LIM: uploadLocalImage(token)
LIM-->>AR: local media_id(s)
AR->>LIM: uploadRemoteImage(token)
LIM-->>AR: remote media_id(s)
AR->>LIM: replaceImages()
AR->>AR: resolve cover (frontmatter / first image / default)
AR->>WX: wxAddDraft(draft JSON)
WX-->>AR: media_id | err
AR-->>NP: 结果
NP-->>U: 成功 / 失败提示
```
## 3. 图片上传流程图
```mermaid
graph TD
A[Start UploadImages] --> B{AuthKey/AppId?}
B -- No --> Z[Throw Error]
B -- Yes --> C[Get Token]
C --> D[cachedElementsToImages]
D --> E[uploadLocalImage]
E --> F[uploadRemoteImage]
F --> G[replaceImages]
G --> H[Copy HTML to Clipboard]
H --> I[End]
```
## 4. 自动封面推断逻辑
```mermaid
graph TD
A[Need Cover?] -->|No| Z[Skip]
A -->|Yes| B[Strip Frontmatter]
B --> C[Scan Markdown Images]
B --> D[Scan Wikilink Images]
C --> E[Merge Candidates]
D --> E[Merge Candidates]
E --> F{Any Candidate?}
F -- No --> Z[Cover stays empty]
F -- Yes --> G[Sort by index]
G --> H[Select first -> cover wikilink]
```
## 5. 未来 RenderService Pipeline 图
```mermaid
graph LR
L[Loader] --> FM[Frontmatter]
FM --> PP[Preprocessors]
PP --> P[Parser]
P --> TR[Transformers]
TR --> RI[ResourceIndex]
RI --> R[Renderer]
R --> PO[Postprocessors]
PO --> EX[Exporters]
```
## 6. 并发上传示意 (未来优化)
```mermaid
graph TD
A[images[]] --> B[Partition into tasks]
B --> C[Promise Pool (N=4)]
C --> D[Upload Task]
D --> E{Success?}
E -- No --> R[Retry with backoff]
E -- Yes --> F[Collect media_id]
R --> C
F --> G[All Done]
```
## 7. 状态机概览 (发布按钮)
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Uploading : 点击 上传/发布
Uploading --> Publishing : 草稿模式
Uploading --> Completed : 仅上传
Publishing --> Completed : 响应成功
Publishing --> Failed : 接口错误
Uploading --> Failed : 资源错误
Failed --> Idle : 用户重试
Completed --> Idle : 新文件切换
```
---
需要我将这些图嵌入到 README 的一个“开发者”章节吗?可以继续提出。

158
image-pipeline.md Normal file
View File

@@ -0,0 +1,158 @@
# Image Pipeline & Cover Strategy
> 对应代码核心:`src/markdown/local-file.ts`, `src/article-render.ts` 以及相关预处理逻辑。
> 若需总体结构参见 `architecture.md`,更广设计参见 `detaildesign.md`。
## 1. 目标
- 统一处理所有图片来源Wikilink、标准 Markdown、远程 URL、Base64、生成图 (Mermaid/Excalidraw/SVG)。
- 保证上传后 HTML 使用微信可访问的正式 URL。
- 自动封面选取:未指定时从正文首图推断。
- WebP → JPG 转换保障兼容性。
## 2. 图片来源与归一化
| 来源 | 输入示例 | 归一化策略 | 备注 |
|------|----------|-----------|------|
| Wikilink | `![[foo.png]]` / `![[foo.png|120x80]]` | Marked 扩展直接生成 `<img src="vault://...">` 并登记 | 支持尺寸 / 位置扩展语法 |
| Markdown | `![alt](assets/img/foo.png)` | (可选) 预处理转换成 `![[foo.png]]` | 依赖设置:`enableMarkdownImageToWikilink` |
| 远程 URL | `<img src="https://...">` | 解析为远程上传任务 | 跳过已是微信域名 |
| Base64 | `<img src="data:image/png;base64,...">` | 解析成 Blob 上传 | 生成虚拟 id 构造文件名 |
| Mermaid | 源码块 → SVG/DOM | 转 PNG (html-to-image) / 留 SVG | 替换占位容器 |
| Excalidraw | `![[diagram.excalidraw]]` | 远程接口取 SVG 或转 PNG | 需 AuthKey |
| SVG 文件 | `![[icon.svg]]` | 内联 SVG 内容 | 直接嵌入,不上传 |
## 3. 关键数据结构
```ts
interface ImageInfo {
resUrl: string; // 初始占位或本地资源 URL (vault path 映射)
filePath: string; // Vault 内实际文件路径
url: string | null; // 上传后微信返回 URL
media_id: string | null; // 上传后素材 IDtype=image 时)
}
```
管理容器:`Map<string, ImageInfo>`,键值为 `resUrl`(初始唯一标识)。
## 4. 收集阶段
1. Marked 扩展 (`LocalFile.markedExtension`) 在 token 遍历时识别 LocalImage。
2. 根据 wikilink / 尺寸参数解析出真实 vault 文件路径:`assetsManager.getResourcePath()`
3. 调用 `LocalImageManager.setImage(res.resUrl, info)` 建档。
4. Markdown 图片(标准语法)若转换启用,提前转成 wikilink 进入同一逻辑;否则需未来扩展 tokenizer 直接登记。
## 5. 上传阶段
顺序:
```mermaid
graph TD
A[准备 token] --> B[cachedElementsToImages]
B --> C[uploadLocalImage]
C --> D[uploadRemoteImage]
D --> E[replaceImages]
E --> F[输出 HTML / 复制 / 发布]
```
### 5.1 本地图片 `uploadLocalImage`
流程:
- 遍历登记表,读取 vault 文件二进制。
- 若扩展名 `.webp` → wasm (`PrepareImageLib`) 转 JPG。
- 调用 `wxUploadImage(blob, filename, token, type)`
- 更新 `ImageInfo.url` / `media_id`
- 失败:`Notice + console.error`,继续后续项。
### 5.2 远程图片 `uploadRemoteImage`
匹配 `<img src="http...">`
- 跳过已在微信域名(`mmbiz.qpic.cn`)。
- 下载 → Blob → (webp 转换) → 上传。
- 更新 Map键为原始 src。
Base64
- 解析 data URI → 生成 Blob/扩展名 → 上传。
### 5.3 替换阶段 `replaceImages`
- 遍历 DOM `<img>` → 查询 Map → 使用上传后 `url` 覆盖 `src`
- 未登记:输出警告(潜在渲染管线遗漏)。
## 6. 自动封面策略
触发点:`getMetadata()` 中,若 frontmatter 未给出封面且无 `thumb_media_id`。步骤:
1. 使用 `originalMarkdown`(含 frontmatter 原始文本拷贝)。
2. 去除 frontmatter 块。
3. 分别用正则匹配:
- Markdown`/!\[[^\]]*\]\(([^)\s]+)\)/g`
- Wikilink`/!\[\[(.+?)\]\]/g`
4. 过滤远程 URL仅保留本地引用或无协议路径
5. 生成候选 `{ idx, basename }` 列表,按出现位置排序。
6. 第一项 → `![[basename]]` 写入 `res.cover`
Edge Cases
| 情况 | 处理 |
|------|------|
| 所有图片都是 http(s) | 不作为封面候选 |
| 文件名带 query/hash | 使用前切分去除 `?` `#` |
| markdown 图片后缀非图片 | 跳过 |
| 重复图片 | 以最先出现的为准 |
## 7. 正则索引
| 目的 | 正则 | 描述 |
|------|------|------|
| Frontmatter 删除 | `/^(---)$.+?^(---)$.+?/ims` | 首段 YAML |
| Markdown 图片 | `/!\[[^\]]*\]\(([^)\s]+)\)/g` | 捕获路径 group1 |
| Wikilink 图片 | `/!\[\[(.+?)\]\]/g` | 捕获内部资源 |
| WebP 检测 | `/\.webp$/i` | 扩展判断 |
| Base64 前缀 | `^data:image/` | 判断内嵌图 |
## 8. 失败与恢复策略
| 环节 | 失败示例 | 策略 |
|------|----------|------|
| wasm 初始化 | 网络慢 / 未加载 | 继续使用原 webp 上传(失败概率增加) |
| 本地文件找不到 | 路径错误 / 移动 | 在控制台警告,图片跳过 |
| 上传 403/401 | token 失效 | 抛异常终止流程(需重新获取 token |
| 单图 errcode !=0 | 大小/格式不合规 | Notice 提示 + 继续其他 |
| 替换未命中 | DOM 没登记 | 记录警告,建议加入补偿扫描 |
## 9. 性能优化方向
| 问题 | 现状 | 改进 |
|------|------|------|
| 上传串行 | 顺序 await | 并发池 (N=3~5) + 重试退避 |
| WebP 转换阻塞 | 单线程 | 预热 wasm + 批量并行 |
| 远程下载串行 | 每图 await | 统一收集 Promise.all 控制并发 |
| 首图扫描重复 | 重新正则两次 | 合并一次统一匹配 pipeline |
## 10. 与发布流程的接口点
| 函数 | 上下游 | 说明 |
|------|--------|------|
| `cachedElementsToImages()` | 延迟图形 → 图片 | 在上传前确保所有图形成为 <img> 可被收集 |
| `uploadLocalImage()` | 发布前 | 更新本地图片 URL/media_id |
| `uploadRemoteImage()` | 发布前 | 远程/内嵌资源统一化 |
| `replaceImages()` | 发布前 | DOM HTML 成为最终状态 |
| `getArticleContent()` | 发布 / 复制 / 导出 | 输出含最终图片 URL 的 HTML |
## 11. 未来扩展
| 需求 | 设想 |
|------|------|
| CDN 直传 | 微信前置 → 自建或 OSS 中转 |
| 图片压缩 | 上传前可选压缩 (canvas/wasm) |
| 失效重传 | 保存上传映射 + 校验缺失再补传 |
| 分块上传 | 大图分片(若接口支持) |
| 图片校验 | MD5 去重避免重复上传 |
## 12. 快速检查清单 (Debug Checklist)
- 图片是否在 `LocalImageManager.images` Map 中?
- 是否执行了 `cachedElementsToImages()`Mermaid/Excalidraw
- 上传后 `media_id` 是否为空?(格式/大小不合规)
- DOM 替换后 `<img src>` 是否为微信域?
- 自动封面未生效检查frontmatter cover / 有无本地首图 / 正则是否截获远程 URL。
## 13. 示例
输入 Markdown
```
---
title: 示例
---
![首图](assets/img/a.png)
正文...
![[b-second.jpg]]
```
处理:
1. frontmatter 去除后扫描:首个匹配为 Markdown 图片 a.png → 自动封面 `![[a.png]]`
2. a.png 转 wikilink (开启转换时) → 登记b-second.jpg 登记。
3. 上传顺序a.png → b-second.jpg。
4. 最终 HTML `<img src="https://mmbiz.qpic.cn/...">`
---
如需补充“并发上传样例代码”或“封面过滤策略扩展”,请提出。

208
render-service-blueprint.md Normal file
View File

@@ -0,0 +1,208 @@
# RenderService Blueprint
> 目的:规划下一代渲染服务以替换(或包装)现有 `ArticleRender` + `MarkedParser` 组合,降低耦合,支持插件化与增量演进。
> 关联文档:`architecture.md`, `image-pipeline.md`, `detaildesign.md`。
## 1. 现状问题 (Current Pain Points)
| 问题 | 说明 | 影响 |
|------|------|------|
| 逻辑集中 | `ArticleRender` 同时负责读取、解析、样式拼接、延迟元素、上传协调 | 难测试 / 难替换子环节 |
| 隐式状态 | `originalMarkdown`, `cachedElements` 内部字段耦合流程顺序 | 外部无法复用或二次解析 |
| 图片收集耦合 Marked 扩展 | 依赖前置 wikilink 转换 | 新语法添加需改内核 |
| 缺少中间 IR | 无法对 Token/AST 做多阶段转换 | 功能扩展如统计、Lint困难 |
| 发布耦合渲染 | 上传/发布 API 与渲染混合 | 交互模式无法独立测试 |
| 单线程串行 | 图形生成、图片上传均串行 | 性能受限 |
## 2. 设计目标 (Design Goals)
| 目标 | 描述 | 衡量指标 |
|------|------|----------|
| 分层解耦 | 渲染、转换、资源收集、发布分离 | 新增图片语法无需改核心类 |
| 插件式中间件 | 可在 parse → transform → render 各阶段注入 | 插件注册 API 清晰 |
| 可测试 | 纯函数/无副作用阶段隔离 | 单元测试覆盖率提升 |
| IR 标准化 | 生成统一 AST / 节点类型 | 后续可序列化/缓存 |
| 并发能力 | 图形/上传并发控制 | 大图文耗时下降 |
| 可观测性 | 事件 & 钩子 | before/after metrics 输出 |
## 3. 拟议分层 (Proposed Layers)
```
RenderPipeline
├─ Loader (读取源 Markdown / 资源定位)
├─ Frontmatter (解析 + Meta Store)
├─ Preprocessors[] (文本级转换:图片语法、短代码、宏)
├─ Parser (Markdown -> AST 统一节点树)
├─ Transformers[] (AST 级:节点重写、图片规范化、封面推断)
├─ ResourceIndex (图片/图形/嵌入引用索引构建)
├─ Renderer (AST -> HTML Fragment)
├─ Postprocessors[] (HTML 级:内联样式、链接修复、统计)
└─ Exporters (HTML/Text/JSON/WeChatDraft)
```
## 4. 关键接口 (Core Interfaces - Draft)
```ts
interface RenderContext {
file: TFile;
raw: string; // 原始 Markdown
frontmatter?: Record<string, any>;
meta: WeChatArticleMeta; // 标题/作者/封面等
ast?: MdAstRoot; // 标准化 AST
resources: ResourceIndex; // 图片/图形等
html?: string; // 渲染产物
diagnostics: Diagnostic[];
flags: Record<string, any>;
}
interface PipelineStage {
name: string;
run(ctx: RenderContext): Promise<void> | void;
order?: number; // 可选执行排序
}
interface ResourceIndex {
images: ImageCandidate[];
diagrams: DiagramCandidate[];
embeds: EmbedRef[];
}
interface ImageCandidate {
id: string; // 稳定标识
kind: 'local'|'remote'|'base64'|'generated';
original: string; // 原始文本或路径
basename?: string;
vaultPath?: string;
position: number; // 在 raw 中的位置,用于封面决策
meta?: Record<string, any>; // 尺寸/对齐等
}
```
## 5. 执行模型 (Execution Model)
```ts
class RenderService {
private stages: PipelineStage[] = [];
use(stage: PipelineStage) { this.stages.push(stage); }
async render(file: TFile): Promise<RenderContext> {
const ctx = createInitialContext(file);
for (const s of sortByOrder(this.stages)) {
try { await s.run(ctx); } catch (e) { ctx.diagnostics.push({stage: s.name, error: e}); if (isFatal(e)) break; }
}
return ctx;
}
}
```
## 6. 示例阶段实现草稿
| Stage | 说明 | 是否必须 |
|-------|------|----------|
| loader | 读取文件与 raw | 是 |
| frontmatter | 解析 YAML -> ctx.frontmatter / ctx.meta 预填 | 是 |
| markdownImageCompat | Markdown 图片 -> 统一节点形式 | 可选 |
| shortcodeGallery | 解析 gallery → 多个 image 节点 | 可选 |
| parser | Marked/Remark → AST | 是 |
| imageCollect | AST 遍历收集图片,写入 ctx.resources.images | 是 |
| coverInfer | 若无封面,从 images 按 position 选首图 | 可选 |
| renderHTML | AST -> HTML 字符串 | 是 |
| styleInline | 合成主题/高亮,自定义 CSS 注入 | 可选 |
| finalize | 产物整理 / hash / 缓存存储 | 可选 |
## 7. AST 规范 (Simplified)
```ts
type MdNode = Paragraph | Heading | Code | Image | Link | List | Blockquote | Html | ThematicBreak | Container;
interface Image { type:'image'; id:string; alt:string; url:string; title?:string; meta?:{width?:number;height?:number;align?:string;} }
```
可基于 remark/unified 生态替换当前 marked或构造最小抽象后桥接。
## 8. 事件 & Hook 设计
```ts
interface RenderHooks {
on(stage: string, fn: (ctx: RenderContext) => void): void;
emit(stage: string, ctx: RenderContext): void;
}
```
预定义事件:`before:stageName` / `after:stageName` / `error:stageName`
## 9. 并发策略
- 图片上传与图形生成移出核心渲染,进入 `PublisherService`
- `PublisherService.publish(ctx, opts)`:接受 RenderContext内部
1.`ctx.resources.images` 并发池(PromisePool)上传。
2. 替换 `ctx.html` 中引用。
3. 构建 `DraftArticle` / `DraftImages`
## 10. 迁移计划 (Phased Migration)
| 阶段 | 内容 | 验证点 |
|------|------|--------|
| P0 | 搭建 RenderService 空管线 + 复用旧 ArticleRender 结果 | 构建无回归 |
| P1 | 拆出 loader/frontmatter/parser/coverInfer | 旧行为对比 snapshot |
| P2 | 新 AST + imageCollect弃用 wikilink 转换 hack | 图片计数稳定 |
| P3 | 发布逻辑重构到 PublisherService | 草稿发布一致 |
| P4 | Hook/插件系统开放 | 外部扩展示例 |
| P5 | 并发上传 + 缓存 | 性能基线下降 |
## 11. 回滚策略
- 每阶段保留配置开关:`useLegacyRenderer`
- 出现渲染差异:比较 ctx.html 与 legacy.html diff 提示。
- 发布失败回退:走旧 `ArticleRender.postArticle`
## 12. 测试策略
| 测试 | 说明 |
|------|------|
| Snapshot 渲染 | 同一 Markdown 输入旧 vs 新 HTML 对比 |
| AST 结构 | 断言图片/标题节点数量与顺序 |
| 封面选择 | 多组合frontmatter + 混合图片顺序) |
| Hook 调用 | 注册 mock 钩子计次 |
| 并发上传 | 人工注入延迟 → 顺序与最终替换正确 |
## 13. 指标与可观测
- ctx.diagnostics 数量与类型统计。
- 阶段耗时:`performance.now()` 差值注入 ctx.flags。
- 上传耗时 / 失败率。
## 14. 安全考量
- Stage 插件沙箱:限制访问仅上下文公开字段。
- 阶段超时(可选):超过阈值标记 warning。
- HTML 输出再次 sanitize。
## 15. API 草案
```ts
const rs = new RenderService();
rs.use(loader());
rs.use(frontmatter());
rs.use(markdownImageCompat({ enable: settings.enableMarkdownImageToWikilink }));
rs.use(parser({ engine:'remark' }));
rs.use(imageCollect());
rs.use(coverInfer());
rs.use(renderHTML({ themeProvider, highlightProvider }));
rs.use(styleInline());
const ctx = await rs.render(activeFile);
const draft = await publisher.publish(ctx, { type:'article', coverMode:'auto' });
```
## 16. 依赖与选型比较
| 方向 | 方案 A | 方案 B | 选择建议 |
|------|-------|-------|----------|
| Markdown AST | remark/unified | 继续 marked + 自建 AST 映射 | remark 更标准;初期可混合 |
| Hook 实现 | 事件总线 (mitt) | 简单数组回调 | 先内建数组回调,后期引入库 |
| 并发控制 | p-limit | 自写 PromisePool | p-limit 简洁可靠 |
## 17. 风险 & 缓解
| 风险 | 缓解 |
|------|------|
| AST 转换差异导致样式变化 | Snapshot + 逐阶段灰度 |
| 性能倒退 | 阶段耗时基线监控;必要时跳过冗余阶段 |
| 插件滥用 Hook | 权限白名单 / 文档规范 |
| 并发上传触发限流 | 设置最大并发 + 429 重试 |
## 18. 成功判定 (Success Criteria)
- 同一输入 50 篇示例笔记,新旧 HTML 差异行 <= 2%(忽略动态 id
- 图片收集数量一致,封面判定一致率 100%。
- 20+ 图片大文档总发布耗时下降 ≥25%。
- 可插拔 demo增加一个统计字数的 Stage 无需改核心。
## 19. 下一步行动 (Action Items)
1. 建立 `RenderService` 目录与最小类骨架。
2. 搬迁读取/frontmatter/封面逻辑并保留旧 API 外壳。
3. 引入 AST先轻量仅 Image/Heading/Paragraph
4. Snapshot 测试脚手架。
5. 性能基线脚本:统计渲染 + 上传耗时。
---
后续若需要,我可以直接生成骨架代码与测试样例,请指示。

View File

@@ -54,6 +54,7 @@ export class ArticleRender implements MDRendererCallback {
markedParser: MarkedParser;
cachedElements: Map<string, string> = new Map();
debouncedRenderMarkdown: (...args: any[]) => void;
originalMarkdown: string | null = null; // 保存去除前处理前的原始 Markdown
constructor(app: App, itemView: ItemView, styleEl: HTMLElement, articleDiv: HTMLDivElement) {
this.app = app;
@@ -162,10 +163,28 @@ export class ArticleRender implements MDRendererCallback {
else {
md = '没有可渲染的笔记或文件不支持渲染';
}
this.originalMarkdown = md; // 保存原始内容(含 frontmatter供封面/摘要自动提取
if (md.startsWith('---')) {
md = md.replace(FRONT_MATTER_REGEX, '');
}
// 将标准 markdown 图片语法转为 wikilink 语法,便于现有 LocalImageManager 识别
if (this.settings.enableMarkdownImageToWikilink) {
// 匹配 ![alt](path/to/name.ext);不跨行;忽略包含空格的 URL 末尾注释
// 捕获路径和文件名,文件名取最后一段
md = md.replace(/!\[[^\]]*\]\(([^)\s]+)\)/g, (full, p1) => {
try {
// 去掉可能的 query/hash
const clean = p1.split('#')[0].split('?')[0];
const filename = clean.split('/').pop();
if (!filename) return full; // 无法解析
// 仅当是常见图片扩展时才替换
if (!filename.match(/\.(png|jpe?g|gif|bmp|webp|svg|tiff)$/i)) return full;
return `![[${filename}]]`;
} catch { return full; }
});
}
this.articleHTML = await this.markedParser.parse(md);
this.setStyle(this.getCSS());
this.setArticle(this.articleHTML);
@@ -233,11 +252,14 @@ export class ArticleRender implements MDRendererCallback {
if (metadata?.frontmatter) {
const keys = this.assetsManager.expertSettings.frontmatter;
const frontmatter = metadata.frontmatter;
res.title = this.getFrontmatterValue(frontmatter, keys.title);
res.author = this.getFrontmatterValue(frontmatter, keys.author);
// frontmatter 优先:如果存在 title/author 则直接取之
const fmTitle = this.getFrontmatterValue(frontmatter, keys.title) || frontmatter['title'];
const fmAuthor = this.getFrontmatterValue(frontmatter, keys.author) || frontmatter['author'];
if (fmTitle) res.title = fmTitle;
if (fmAuthor) res.author = fmAuthor;
res.digest = this.getFrontmatterValue(frontmatter, keys.digest);
res.content_source_url = this.getFrontmatterValue(frontmatter, keys.content_source_url);
res.cover = this.getFrontmatterValue(frontmatter, keys.cover);
res.cover = this.getFrontmatterValue(frontmatter, keys.cover) || frontmatter['cover'] || frontmatter['image'];
res.thumb_media_id = this.getFrontmatterValue(frontmatter, keys.thumb_media_id);
res.need_open_comment = frontmatter[keys.need_open_comment] ? 1 : undefined;
res.only_fans_can_comment = frontmatter[keys.only_fans_can_comment] ? 1 : undefined;
@@ -252,6 +274,41 @@ export class ArticleRender implements MDRendererCallback {
res.pic_crop_1_1 = '0_0.525_0.404_1';
}
}
// 如果未显式指定封面,尝试从正文首图( markdown 或 wikilink ) 提取,按出现顺序优先
if (!res.cover && this.originalMarkdown) {
let body = this.originalMarkdown;
if (body.startsWith('---')) body = body.replace(FRONT_MATTER_REGEX, '');
// 同时匹配两种形式并比较 index
const mdImgPattern = /!\[[^\]]*\]\(([^)\s]+)\)/g; // group1 为路径
const wikilinkPattern = /!\[\[(.+?)\]\]/g; // group1 为文件名或 path
interface Candidate { idx:number; basename:string; }
const candidates: Candidate[] = [];
let m: RegExpExecArray | null;
while ((m = mdImgPattern.exec(body)) !== null) {
const rawPath = m[1];
if (/^https?:\/\//i.test(rawPath)) continue; // 跳过远程
const clean = rawPath.split('#')[0].split('?')[0];
const basename = clean.split('/').pop();
if (basename && /\.(png|jpe?g|gif|bmp|webp|svg|tiff)$/i.test(basename)) {
candidates.push({ idx: m.index, basename });
}
}
while ((m = wikilinkPattern.exec(body)) !== null) {
const inner = m[1].trim();
const clean = inner.split('#')[0].split('?')[0];
const basename = clean.split('/').pop();
if (basename && /\.(png|jpe?g|gif|bmp|webp|svg|tiff)$/i.test(basename)) {
candidates.push({ idx: m.index, basename });
}
}
if (candidates.length > 0) {
candidates.sort((a,b)=> a.idx - b.idx);
res.cover = `![[${candidates[0].basename}]]`;
}
}
return res;
}

View File

@@ -43,6 +43,7 @@ export class NMPSettings {
excalidrawToPNG: boolean;
isLoaded: boolean = false;
enableEmptyLine: boolean = false;
enableMarkdownImageToWikilink: boolean = true; // 自动将 ![alt](path/file.ext) 转为 ![[file.ext]]
private static instance: NMPSettings;
@@ -72,6 +73,7 @@ export class NMPSettings {
this.excalidrawToPNG = false;
this.expertSettingsNote = '';
this.enableEmptyLine = false;
this.enableMarkdownImageToWikilink = true;
}
resetStyelAndHighlight() {
@@ -101,6 +103,7 @@ export class NMPSettings {
excalidrawToPNG,
expertSettingsNote,
ignoreEmptyLine,
enableMarkdownImageToWikilink,
} = data;
const settings = NMPSettings.getInstance();
@@ -155,6 +158,9 @@ export class NMPSettings {
if (ignoreEmptyLine !== undefined) {
settings.enableEmptyLine = ignoreEmptyLine;
}
if (enableMarkdownImageToWikilink !== undefined) {
settings.enableMarkdownImageToWikilink = enableMarkdownImageToWikilink;
}
settings.getExpiredDate();
settings.isLoaded = true;
}
@@ -179,6 +185,7 @@ export class NMPSettings {
'excalidrawToPNG': settings.excalidrawToPNG,
'expertSettingsNote': settings.expertSettingsNote,
'ignoreEmptyLine': settings.enableEmptyLine,
'enableMarkdownImageToWikilink': settings.enableMarkdownImageToWikilink,
}
}

View File

@@ -1,4 +1,17 @@
1. 预处理markdown文件
1. 目前图片只能识别![[imagefile]],无法识别![img](path/imagefile)把markdownown文件中![img](path/imagefile)转化为[[imagefile]]。
如:![img](img/2025ZK1-7.jpg)转为![[2025ZK1-7.jpg]]
2. 读取markdown的frontmatter属性文章标题取title和作者取author内容。
以附件2025ZK1.md为例
提取以下信息(忽略两端的“”):
- 如tiltle不为空文章title不使用文件名使用: 6月特种兵式观展
- 如果author不为空公众号文章作者: 大童。
- ![alt](path/to/name.ext), ![[name.ext]]不分优先级,看哪个在文章的最前面,取最前面这个作为封面图片
3. 预处理markdown文件
对{{<gallery dir="/img/guanzhan/1" figcaption="毕业展"/>}}{{<load-photoswipe>}}或{{<gallery dir="/img/guanzhan/1"/>}}{{<load-photoswipe>}}
- 获取dir中的内容如"/img/guanzhan/1"与PREPATH拼接全局定义PRE_PATH=/Users/gavin/myweb/static
图片所在路径PREPATH+"/img/guanzhan/1",即/Users/gavin/myweb/static/img/guanzhan/1。
@@ -7,8 +20,6 @@
如n=2取出的图片为xx.jpg,yy.png那么把{{<gallery dir="/img/guanzhan/1" figcaption="毕业展"/>}}{{<load-photoswipe>}}替换为:
![[xx.jpg]]
![[yy.png]]
在main.js单独函数中处理在预处理内容时调用。
2.
对如下:
@@ -54,7 +65,7 @@ slug: guanzhan
提取以下信息(忽略两端的“”):
- 公众号文章title: 6月特种兵式观展
- 文章作者: 大童
- 公众号文章作者: 大童
- 文章封面图片GALLERY_PRE_PATH+"/img/shufa/a.jpg",转化为![[a.jpg]]; 如image为空封面图片取文章中第一张图片
4.
@@ -66,3 +77,4 @@ slug: guanzhan
注意如果我把2025ZK1.md 内容img改成![[2025ZK1-7.jpg]],以上解析没有问题。
5. 文章没有图片,封面使用一张默认图片(设计一张)。