From 81c6f52b6902f1b812965ad2452cba8be57e1ed3 Mon Sep 17 00:00:00 2001 From: douboer Date: Mon, 22 Sep 2025 16:49:33 +0800 Subject: [PATCH] update at 2025-09-22 16:49:33 --- architecture.md | 131 +++++++++++++++++++++++ detaildesign.md | 7 ++ diagrams.md | 155 +++++++++++++++++++++++++++ image-pipeline.md | 158 +++++++++++++++++++++++++++ render-service-blueprint.md | 208 ++++++++++++++++++++++++++++++++++++ src/article-render.ts | 63 ++++++++++- src/settings.ts | 7 ++ todo.list | 20 +++- 8 files changed, 742 insertions(+), 7 deletions(-) create mode 100644 architecture.md create mode 100644 diagrams.md create mode 100644 image-pipeline.md create mode 100644 render-service-blueprint.md diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..6fea6f7 --- /dev/null +++ b/architecture.md @@ -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 补偿策略(如后续新渲染管线未登记图片):可在上传前扫描 `` 回填缺失项。 + +## 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`。 diff --git a/detaildesign.md b/detaildesign.md index a5fb823..a4ba6ef 100644 --- a/detaildesign.md +++ b/detaildesign.md @@ -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)。 diff --git a/diagrams.md b/diagrams.md new file mode 100644 index 0000000..84de5d6 --- /dev/null +++ b/diagrams.md @@ -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 的一个“开发者”章节吗?可以继续提出。 diff --git a/image-pipeline.md b/image-pipeline.md new file mode 100644 index 0000000..7a52ce0 --- /dev/null +++ b/image-pipeline.md @@ -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 扩展直接生成 `` 并登记 | 支持尺寸 / 位置扩展语法 | +| Markdown | `![alt](assets/img/foo.png)` | (可选) 预处理转换成 `![[foo.png]]` | 依赖设置:`enableMarkdownImageToWikilink` | +| 远程 URL | `` | 解析为远程上传任务 | 跳过已是微信域名 | +| 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; // 上传后素材 ID(type=image 时) +} +``` +管理容器:`Map`,键值为 `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` +匹配 ``: +- 跳过已在微信域名(`mmbiz.qpic.cn`)。 +- 下载 → Blob → (webp 转换) → 上传。 +- 更新 Map,键为原始 src。 +Base64: +- 解析 data URI → 生成 Blob/扩展名 → 上传。 + +### 5.3 替换阶段 `replaceImages` +- 遍历 DOM `` → 查询 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()` | 延迟图形 → 图片 | 在上传前确保所有图形成为 可被收集 | +| `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 替换后 `` 是否为微信域? +- 自动封面未生效?检查: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 ``。 + +--- +如需补充“并发上传样例代码”或“封面过滤策略扩展”,请提出。 diff --git a/render-service-blueprint.md b/render-service-blueprint.md new file mode 100644 index 0000000..5b65994 --- /dev/null +++ b/render-service-blueprint.md @@ -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; + meta: WeChatArticleMeta; // 标题/作者/封面等 + ast?: MdAstRoot; // 标准化 AST + resources: ResourceIndex; // 图片/图形等 + html?: string; // 渲染产物 + diagnostics: Diagnostic[]; + flags: Record; +} + +interface PipelineStage { + name: string; + run(ctx: RenderContext): Promise | 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; // 尺寸/对齐等 +} +``` + +## 5. 执行模型 (Execution Model) +```ts +class RenderService { + private stages: PipelineStage[] = []; + use(stage: PipelineStage) { this.stages.push(stage); } + async render(file: TFile): Promise { + 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. 性能基线脚本:统计渲染 + 上传耗时。 + +--- +后续若需要,我可以直接生成骨架代码与测试样例,请指示。 diff --git a/src/article-render.ts b/src/article-render.ts index b702fd2..1f75378 100644 --- a/src/article-render.ts +++ b/src/article-render.ts @@ -54,6 +54,7 @@ export class ArticleRender implements MDRendererCallback { markedParser: MarkedParser; cachedElements: Map = 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; } diff --git a/src/settings.ts b/src/settings.ts index b8f2c23..ea2f78c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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, } } diff --git a/todo.list b/todo.list index 8567523..0ca6c6a 100644 --- a/todo.list +++ b/todo.list @@ -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文件: 对{{}}{{}}或{{}}{{}} - 获取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,那么把{{}}{{}}替换为: ![[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. 文章没有图片,封面使用一张默认图片(设计一张)。 +