update at 2025-09-22 14:58:45

This commit is contained in:
douboer
2025-09-22 14:58:45 +08:00
parent 0090ce9b93
commit 9b8ec73c83
10 changed files with 394 additions and 377 deletions

300
detaildesign.md Normal file
View File

@@ -0,0 +1,300 @@
# note-to-mp 设计文档 (Detail Design)
## 1. 背景
为满足从 Obsidian 笔记到微信公众号文章的高质量发布需求,插件需要:
- 支持多种图片书写形式Wikilink 与标准 Markdown
- 统一图片处理与上传(包括 WebP 转换、水印、封面选择)。
- 自动抽取文章元数据(标题、作者、封面图)。
- 支持自定义短代码(`gallery`)与行级语法扩展(`||` 样式块、`[fig .../]` 等)。
- 提供灵活的封面回退逻辑frontmatter 指定优先,缺省取正文第一图)。
## 2. 目标
| 目标 | 说明 |
|------|------|
| 图片语法统一 | `![[file.png]]``![alt](path/file.png)` 最终统一进入 LocalImage 管线 |
| 元数据抽取 | 自动获取标题、作者、封面图(可回退)供后续上传逻辑使用 |
| 封面回退 | 未显式指定封面时,自动决策第一张图片 |
| Gallery 支持 | 将 `{{<gallery .../>}}{{<load-photoswipe>}}` 转成图片 wikilinks 列表 |
| 预处理 | 在 Markdown 渲染前执行自定义语法转 HTML |
| 易扩展 | 提供独立函数/接口减少耦合,如 `selectGalleryImages``extractWeChatMeta` |
## 3. 术语与定义
- **Wikilink 图片语法**`![[xxx.png]]`
- **标准 Markdown 图片**`![描述](path/to/xxx.png)`
- **Frontmatter**:位于首部 `---` 包裹的元数据区域。
- **Cover封面**:用于公众号首图上传的图片。
- **Gallery Shortcode**`{{<gallery dir="/img/foo" figcaption="说明"/>}}{{<load-photoswipe>}}`
## 4. 系统现状概览
主要处理链路:
```
Raw Markdown
↓ extractWeChatMeta (保留 frontmatter 内容供分析)
↓ 去 frontmatter
↓ transformGalleryShortcodes (gallery → ![[...]] 列表)
↓ marked.parse() (图片扩展 -> LocalImage token
↓ 生成 HTML + 样式注入
↓ setArticle()
↓ getArticleContent() -> preprocessContent(line regex 替换) -> 最终 HTML
```
## 5. 架构模块划分
| 模块 | 关键函数/变量 | 作用 |
|------|---------------|------|
| 内容预处理 | `preprocessContent()` | 行级 Regex 转 HTML图片路径修正、`||` 块、`[fig .../]` |
| 图片统一解析 | `LocalFileRegex`、MarkdownImage tokenizer | 标准化所有图片为 LocalImage token |
| 图片资源管理 | `LocalImageManager` | 记录本地图片、上传、替换 URL、Base64 嵌入 |
| Gallery | `_listGalleryImages` / `selectGalleryImages` / `transformGalleryShortcodes` | 短代码 → wikilink 列表(可扩展 figcaption |
| 元数据抽取 | `extractWeChatMeta` / `getWeChatArticleMeta` | 标题 / 作者 / 封面图计算 |
| 封面自动补全 | `getMetadata()` 尾部逻辑 | 无 frontmatter cover 时回填 |
| 图片上传 | `uploadLocalImage` / `uploadCover` | WebP→JPG、加水印、水印依赖 wasm |
| WebP 支持 | `PrepareImageLib` + wasm | 转换后再上传 |
| 渲染管线 | `renderMarkdown` | 串联以上逻辑 |
## 6. 数据流示意
参见第 4 节图。每个阶段保证产物单向流入下一层,避免循环依赖。
## 7. 关键算法与实现细节
### 7.1 图片统一转换
- Regex`LocalFileRegex = /^(?:!\[\[(.*?)\]\]|!\[[^\]]*\]\(([^\n\r\)]+)\))/`
- Markdown 标准图片 tokenizer
1. 匹配 `![alt](path)``matches[0]`
2. 取 basename → 构造 `![[basename]]` 语义(内部直接建 LocalImage token不再二次正则回匹配
3. 避免原先多余 `-2.png)` 残留问题。
### 7.2 元数据抽取(`extractWeChatMeta`
- 捕获 frontmatter 简易块(首个 `---` 区间)。
- 解析 `title / author / image` 单行 KV。
- `image` → 取 basename → `![[basename]]`
- 回退封面:同时匹配 wikilink + markdown 图片,比较 index 取出现最早的一种。
- 返回:`{ title, author, coverLink, rawImage }`
-`getMetadata()` 融合以补齐空缺字段。
### 7.3 前置处理(`preprocessContent`
- `[fig .../]``<span>`(题注样式)。
- 行级命令:`||r / ||g / ||b / ||y / ||` → 不同背景色 `<p>`
- `<img src="img/...">` → 前缀补全 `/img/`
### 7.4 Gallery 功能
- 短代码 Regex`{{<gallery dir="..."( figcaption="...")?/ >}}{{<load-photoswipe>}}`
- `_listGalleryImages`:读目录 + 过滤扩展 + 排序 + 截断。
- `selectGalleryImages`:对外通用(支持未来 random / prefix / includeDirInLink
- 输出:多行 `![[file]]`,并追加可选 `figcaption` div。
#### 7.4.1 块级 Gallery 语法(新增)
支持:
```
{{<gallery>}}
{{<figure src="/img/foo-1.png" caption="说明" >}}
{{<figure src="/img/foo-2.jpeg" caption="说明2" >}}
{{</gallery>}}
```
转换:
```
![[foo-1.png]]
![[foo-2.jpeg]]
```
规则:
- 仅取 src 的 basename忽略 caption后续可扩展为题注输出
- 若块内未匹配到任何 figure保留原文本。
- 正则:`/{{<gallery>}}([\s\S]*?){{<\/gallery>}}/g` 与内部 `figureRegex = /{{<figure\s+src="([^"]+)"[^>]*>}}/g`
- 输出顺序按出现顺序。
## 8. 正则清单
| 场景 | 正则 | 说明 |
|------|------|------|
| frontmatter | `^---[\s\S]*?\n---` | 仅首段 |
| Wikilink 图片 | `!\[\[(.+?)\]\]` | 非贪婪 |
| Markdown 图片 | `!\[[^\]]*\]\(([^\n\r\)]+)\)` | 不跨行 |
| Gallery | `{{<gallery\s+dir=\"([^\"]+)\"(?:\s+figcaption=\"([^\"]*)\")?\s*\/>}}{{<load-photoswipe>}}` | 捕获 dir/caption |
| fig | `\[fig([^>]*?)\/]` | 题注 |
| 行块 | `\|\|r (.*)` 等 | 行级匹配 |
## 9. 错误与边界
| 情况 | 行为 |
|------|------|
| frontmatter 缺尾部 | 不解析,当普通正文 |
| 无 image 且正文无图 | `coverLink` 为空 |
| Gallery 目录缺失 | 原样保留短代码 |
| WebP 转换失败 | 记录日志,使用原文件 |
| 非支持图片扩展 | 忽略该文件 |
## 10. 性能
- 正则线性扫描 O(n)。
- Gallery 目录排序 O(m log m)。
- 可后续对 `_listGalleryImages` 结果加缓存。
## 11. 配置 & 常量
| 常量 | 说明 | 后续计划 |
|------|------|----------|
| `GALLERY_PRE_PATH` | 画廊根目录 | 移入设置面板 |
| `GALLERY_NUM_PIC` | 默认选图数量 | 支持短代码参数覆盖 |
| 行级样式内联 | 直接 embed style | 可改 class + CSS |
## 12. 对外接口
| 方法 | 描述 |
|------|------|
| `getWeChatArticleMeta()` | 获取最近一次渲染抽取的 meta |
| `getMetadata()` | 微信上传所需聚合元数据,含封面补回 |
| `uploadCover()` | 上传封面,含 webp 处理 |
| `uploadLocalImage()` | 上传正文图片 |
| `renderMarkdown()` | 触发整个渲染链路 |
## 13. 测试建议
| 测试项 | 用例 |
|--------|------|
| frontmatter | 正常/缺尾部/缺字段/中文标题 |
| 首图回退 | wikilink 与 markdown 顺序交错 |
| Gallery | 有/无目录;含 caption空目录 |
| 图片文件名 | 中文/空格/连字符/数字/大小写扩展 |
| 行级语法 | 多种颜色并存/与普通段落混排 |
| WebP | 可转换/未准备 wasm |
| 覆盖逻辑 | frontmatter 不同组合(仅 author、仅 title 等) |
## 14. 可扩展点
| 方向 | 说明 |
|------|------|
| 更完整 YAML | 使用 `js-yaml` 支持多行、列表、复杂类型 |
| tags/categories | 抽取为数组并暴露接口 |
| Gallery 参数 | 支持 `count=``random=``includeDir=` 等 |
| 封面策略 | 配置“frontmatter 优先 / 正文优先 / 首图随机” |
| 图廊 HTML 模式 | 直接生成 `<figure>` 集合而非 wikilink 列表 |
| 样式外置 | 行级块样式改为统一 CSS class |
| 目录缓存 | 减少频繁 IO |
## 15. 风险与规避
| 风险 | 缓解 |
|------|------|
| 简化 frontmatter 误判 | 提示限制 + 计划引入 YAML 解析 |
| 正则误伤 | 增加单元测试覆盖边界字符 |
| Gallery IO 阻塞 | 后续异步 + loading 占位 |
| 移动端缺 fs | try/catch + 环境判断 |
| 样式散落行内 | 后续集中到主题 CSS |
## 16. 示例复盘
示例:
```
---
title: 6月特种兵式观展
author: 大童
image: "/img/shufa/a.jpg"
---
前言
![首图](img/b-first.png)
![[c-second.png]]
```
结果:
- 封面:`![[a.jpg]]`frontmatter 优先)
- 若删去 image 行 → 封面:`![[b-first.png]]`(首图)
## 17. 迭代优先级建议
| 优先级 | 项目 |
|--------|------|
| 高 | 封面 UI 选择确认 |
| 中 | YAML 解析器集成 |
| 中 | Gallery 参数化count/random |
| 中 | tags/categories 抽取 |
| 低 | 图廊 HTML figure 模式 |
## 18. 关键函数索引
| 函数 | 作用 |
|------|------|
| `extractWeChatMeta` | 抽取标题/作者/封面回退 |
| `transformGalleryShortcodes` | gallery 短代码 → wikilinks |
| `selectGalleryImages` | 画廊图片选择封装 |
| `preprocessContent` | 行级语法 HTML 化 |
| `getWeChatArticleMeta` | 获取抽取的 meta |
| `getMetadata` | 最终上传元数据(含封面回填) |
| `MarkdownImage.tokenizer` | 标准图片转 LocalImage token |
| `LocalFileRegex` | 统一匹配图片语法 |
## 19. 总结
通过“标准化 → 抽取 → 预处理 → 渲染 → 上传”分层设计,确保各功能模块低耦合并可独立演进。当前设计已满足基础运营发布需求,后续可按优先级增强 YAML 解析、封面配置、多图策略与 gallery 表现力。
---
*若需我继续实现 tags/categories 抽取或 gallery 参数扩展,请直接提出。*
## 附录 A. 草稿箱清空功能
### A.1 背景
运营过程中测试/多次上传会堆积大量“草稿”,需要一键清理能力,并具备安全保护与预览模式。
### A.2 接口
| 方法 | 说明 |
|------|------|
| `clearAllDrafts(appid, { confirm, batchSize=20, retainLatest=0, dryRun=false })` | 批量列出并删除草稿;需 `confirm:true` 才执行实际删除 |
### A.3 选项说明
| 选项 | 类型 | 说明 |
|------|------|------|
| confirm | boolean | 必须显式 true否则抛错中止 |
| batchSize | number | 分页拉取条数(默认 20受微信接口限制 |
| retainLatest | number | 保留最新 N 条(按接口返回顺序) |
| dryRun | boolean | 仅统计将删除的数量,不执行删除 |
### A.4 返回结构
```
{
total: number, // 收集到的全部 media_id 数
skip: number, // 被保留的数量(= retainLatest 实际保留)
success: number, // 实际删除成功数dryRun= true 时恒 0
fail: number, // 删除失败数
fails: Array<{ media_id, status? , errcode?, errmsg?, text? }>,
dryRun: boolean
}
```
### A.5 安全措施
1. `confirm` 必须为 true。
2. 可设置 `retainLatest` 防止误删全部。
3. `dryRun` 先预览再正式执行。
4. 删除逐条执行,可在失败时保留失败列表审计。
### A.6 未来增强
| 方向 | 说明 |
|------|------|
| 并发删除 | Promise pool 控制并发提升速度 |
| 过滤条件 | 按标题关键词/日期范围选择性删除 |
| 进度通知 | 分批实时进度 Notice / 状态栏 |
| UI 集成 | 命令面板 + 二次确认弹窗 |
| 时间排序校验 | 根据返回 `update_time` 明确排序而非假设 |
### A.7 命令面板入口
已添加命令:`清空微信草稿箱 (危险)` (id: `note-to-mp-clear-drafts`)
流程:
1. 首次 confirm提示风险。
2. 询问是否 dryRun输入 y 仅预览)。
3. 若非 dryRun再询问保留最近 N 条。
4. 二次 confirm 再次确认删除范围。
5. 调用 `clearAllDrafts(null, { confirm:true, dryRun, retainLatest })`
失败处理:捕获异常并 Notice 显示;控制台输出详细错误。
### A.8 可视化操作面板 (Modal)
新增 `ClearDraftsModal`:提供表单而非多级 confirm/prompt。
表单字段:
- appid (可留空自动从当前文章 frontmatter 获取)
- 保留最近 N 条number默认 0
- DryRun 复选框(默认勾选)
交互流程:
1. 打开命令 → 弹出 Modal。
2. 用户填写/确认参数,首次点“执行”→ 若为真实删除且非 dryRun会再弹出 confirm。
3. 结果以 JSON 形式写入下方 <pre> 区域,便于复制。
4. Notice 简要提示DryRun 或 完成)。
错误处理:
- try/catch 包裹,失败写入 resultPre 文本 + Notice。
- run 按钮在执行期间 disabled防止重复触发。
后续增强设想:
| 项目 | 说明 |
|------|------|
| 进度条 | 删除大批量时显示当前进度/总数 |
| 失败重试 | 针对 fails 列表单独重试按钮 |
| 过滤条件 | 增加标题关键词 / 日期起止输入 |
| 多账号选择 | 下拉列出已配置的 appid 列表 |
| 日志导出 | 一键复制 JSON 结果 |

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "note-to-mp",
"version": "1.3.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "note-to-mp",
"version": "1.3.0",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@zip.js/zip.js": "^2.7.43",

View File

@@ -1,47 +0,0 @@
// [note-to-mp 重构] Gallery 模块
import { App } from 'obsidian';
export interface GalleryTransformResult {
content: string;
replaced: boolean;
}
// 单行 self-closing 形式: {{<gallery dir="/img/foo" figcaption="说明"/>}}{{<load-photoswipe>}}
const GALLERY_INLINE_RE = /{{<gallery\s+dir="([^"]+)"(?:\s+figcaption="([^"]*)")?\s*\/?>}}\s*{{<load-photoswipe>}}/g;
// 块级形式
const GALLERY_BLOCK_RE = /{{<gallery>}}([\s\S]*?){{<\/gallery>}}/g;
const FIGURE_RE = /{{<figure\s+src="([^"]+)"[^>]*>}}/g;
export function transformGalleryShortcodes(raw: string): GalleryTransformResult {
let replaced = false;
// 处理块级
raw = raw.replace(GALLERY_BLOCK_RE, (_m, inner) => {
const imgs: string[] = [];
let fm: RegExpExecArray | null;
while ((fm = FIGURE_RE.exec(inner)) !== null) {
const src = fm[1];
const base = src.split(/[?#]/)[0].split('/').pop();
if (base) imgs.push(`![[${base}]]`);
}
if (imgs.length === 0) return _m; // 保留原文本
replaced = true;
return imgs.join('\n') + '\n';
});
// 处理单行自闭合形式
raw = raw.replace(GALLERY_INLINE_RE, (_m, dir, figcaption) => {
replaced = true;
const comment = figcaption ? `<!-- gallery: ${figcaption} -->\n` : '';
// 暂不实际列目录;由后续 selectGalleryImages 扩展
return comment + `<!-- gallery dir=${dir} -->`;
});
return { content: raw, replaced };
}
// 占位:真实实现可遍历 vault 目录
export async function selectGalleryImages(app: App, dir: string, options?: { limit?: number }): Promise<string[]> {
// TODO: 遍历 app.vault.getAbstractFileByPath(dir)
// 返回文件名数组(不含路径)
return [];
}

View File

@@ -1,64 +0,0 @@
// [note-to-mp 重构] 图片处理模块
// 负责统一解析 wikilink 与 markdown 图片,并提供集中管理
export interface LocalImage {
original: string; // 原始匹配串(包括语法标记)
basename: string; // 文件基本名(不含路径)
alt?: string; // alt 描述(若来自 markdown 语法)
sourceType: 'wikilink' | 'markdown';
index: number; // 在原文中的出现顺序
}
export const LocalFileRegex = /^(?:!\[\[(.*?)\]\]|!\[[^\]]*\]\(([^\n\r\)]+)\))/;
export class LocalImageManager {
private images: LocalImage[] = [];
private byBasename: Map<string, LocalImage[]> = new Map();
add(image: LocalImage) {
this.images.push(image);
const list = this.byBasename.get(image.basename) || [];
list.push(image);
this.byBasename.set(image.basename, list);
}
all(): LocalImage[] { return this.images.slice(); }
first(): LocalImage | undefined { return this.images[0]; }
findByBasename(name: string): LocalImage | undefined {
const list = this.byBasename.get(name);
return list && list[0];
}
clear() { this.images = []; this.byBasename.clear(); }
}
export function parseImagesFromMarkdown(markdown: string): LocalImage[] {
// 扫描整篇,统一抽取,不做替换
const result: LocalImage[] = [];
const wikilinkRe = /!\[\[(.+?)\]\]/g; // 非贪婪
const mdImgRe = /!\[([^\]]*)\]\(([^\n\r\)]+)\)/g;
let index = 0;
let m: RegExpExecArray | null;
while ((m = wikilinkRe.exec(markdown)) !== null) {
const full = m[0];
const inner = m[1].trim();
const basename = inner.split('/').pop() || inner;
result.push({ original: full, basename, sourceType: 'wikilink', index: index++ });
}
while ((m = mdImgRe.exec(markdown)) !== null) {
const full = m[0];
const alt = m[1].trim();
const link = m[2].trim();
const basename = link.split(/[?#]/)[0].split('/').pop() || link;
result.push({ original: full, basename, alt, sourceType: 'markdown', index: index++ });
}
// 按出现顺序(两个正则独立扫描会破坏顺序,重新排序 by 原始位置)
result.sort((a, b) => markdown.indexOf(a.original) - markdown.indexOf(b.original));
// 重排 index
result.forEach((r, i) => r.index = i);
return result;
}

View File

@@ -1,76 +0,0 @@
// [note-to-mp 重构] 元数据与封面模块
import { LocalImage } from '../image';
export interface WeChatMetaRaw {
title?: string;
author?: string;
coverLink?: string; // frontmatter 或行内指定的图片 basename 形式
rawImage?: string; // 原 frontmatter 中的 image 字段原始值(可包含路径)
hasFrontmatter: boolean;
}
export interface FinalMeta {
title: string;
author?: string;
coverImage?: LocalImage; // 解析到的封面图片对象
coverLink?: string; // 决策后的封面 basename
}
const FRONTMATTER_RE = /^---[\s\S]*?\n---/;
export function extractWeChatMeta(raw: string): { meta: WeChatMetaRaw; body: string } {
const fmMatch = raw.match(FRONTMATTER_RE);
if (!fmMatch) {
return { meta: { hasFrontmatter: false }, body: raw };
}
const block = fmMatch[0];
const lines = block.split(/\r?\n/).slice(1, -1); // 去除首尾 ---
let title: string | undefined;
let author: string | undefined;
let image: string | undefined;
for (const line of lines) {
const m = line.match(/^([a-zA-Z0-9_-]+)\s*:\s*(.*)$/);
if (!m) continue;
const key = m[1].toLowerCase();
let val = m[2].trim();
// 去除包裹引号
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (key === 'title') title = val;
else if (key === 'author') author = val;
else if (key === 'image' || key === 'cover') image = val;
}
let coverLink: string | undefined;
if (image) {
const basename = image.split(/[?#]/)[0].split('/').pop() || image;
coverLink = basename;
}
const body = raw.slice(block.length).replace(/^\s+/, '');
return { meta: { title, author, coverLink, rawImage: image, hasFrontmatter: true }, body };
}
export function getMetadata(images: LocalImage[], rawMeta: WeChatMetaRaw): FinalMeta {
// 标题回退策略:若无 frontmatter title尝试第一行一级标题
let title = rawMeta.title;
if (!title) {
// 简单取第一行 markdown 一级/二级标题
// 实际调用方可传入 body 再做改进;这里保持接口简单
title = '未命名文章';
}
let coverLink = rawMeta.coverLink;
let coverImage: LocalImage | undefined;
if (coverLink) {
coverImage = images.find(img => img.basename === coverLink);
}
if (!coverImage) {
coverImage = images[0];
coverLink = coverImage?.basename;
}
return { title, author: rawMeta.author, coverImage, coverLink };
}

View File

@@ -22,8 +22,6 @@
import { EventRef, ItemView, Workspace, WorkspaceLeaf, Notice, Platform, TFile, TFolder, TAbstractFile, Plugin } from 'obsidian';
import { uevent, debounce, waitForLayoutReady } from './utils';
// [note-to-mp 重构] 引入新渲染管线
import { RenderService, RenderedArticle } from './render';
import { NMPSettings } from './settings';
import AssetsManager from './assets';
import { MarkedParser } from './markdown/parser';
@@ -64,9 +62,6 @@ export class NotePreview extends ItemView {
_articleRender: ArticleRender | null = null;
isCancelUpload: boolean = false;
isBatchRuning: boolean = false;
// [note-to-mp 重构] 新渲染服务实例与最近一次渲染结果
newRenderService: RenderService | null = null;
lastArticle?: RenderedArticle;
constructor(leaf: WorkspaceLeaf, plugin: Plugin) {
@@ -118,8 +113,6 @@ export class NotePreview extends ItemView {
}
this.buildUI();
// [note-to-mp 重构] 初始化新渲染服务
this.newRenderService = new RenderService(this.app);
this.listeners = [
this.workspace.on('file-open', () => {
this.update();
@@ -447,33 +440,31 @@ export class NotePreview extends ItemView {
return;
}
this.currentFile = af;
// [note-to-mp 重构] 使用新渲染服务进行渲染
if (this.newRenderService) {
try {
const article = await this.newRenderService.renderFile(af);
this.lastArticle = article;
if (this.articleDiv) {
this.articleDiv.empty();
const wrap = this.articleDiv.createDiv();
wrap.innerHTML = article.html;
await this.render.renderMarkdown(af);
const metadata = this.render.getMetadata();
if (metadata.appid) {
this.wechatSelect.value = metadata.appid;
}
else {
this.wechatSelect.value = this.currentAppId;
}
if (metadata.theme) {
this.assetsManager.themes.forEach(theme => {
if (theme.name === metadata.theme) {
this.themeSelect.value = theme.className;
}
// 元数据适配(当前新 meta 不含 appid/theme/highlight保持现有选择状态
if (this.wechatSelect) {
this.wechatSelect.value = this.currentAppId || '';
}
if (this.themeSelect) {
this.themeSelect.value = this.currentTheme;
}
if (this.highlightSelect) {
this.highlightSelect.value = this.currentHighlight;
}
} catch (e) {
console.error('[note-to-mp 重构] 渲染失败', e);
new Notice('渲染失败: ' + e.message);
}
} else {
// 兜底:仍使用旧渲染
await this.render.renderMarkdown(af);
});
}
else {
this.themeSelect.value = this.currentTheme;
}
if (metadata.highlight) {
this.highlightSelect.value = this.render.currentHighlight;
}
else {
this.highlightSelect.value = this.currentHighlight;
}
}

View File

@@ -1,36 +0,0 @@
// [note-to-mp 重构] 内容预处理模块
// 行级颜色块语法:||r text / ||g text / ||b text / ||y text / || text
const LINE_COLOR_RE = /^\|\|(r|g|b|y)?\s+(.*)$/;
const FIG_RE = /\[fig([^\n]*?)\/_?]/g; // 简单题注
function wrapColorLine(code: string | undefined, text: string): string {
const colorMap: Record<string, string> = {
r: '#ffe5e5',
g: '#e5ffe9',
b: '#e5f1ff',
y: '#fff7d6',
'': '#f2f2f2'
};
const c = (code && colorMap[code]) || colorMap[''];
return `<p style="background:${c};padding:4px 8px;border-radius:4px;">${text}</p>`;
}
export function preprocessContent(markdown: string): string {
const lines = markdown.split(/\r?\n/);
const out: string[] = [];
for (const line of lines) {
const m = line.match(LINE_COLOR_RE);
if (m) {
out.push(wrapColorLine(m[1], m[2]));
} else {
out.push(line);
}
}
let joined = out.join('\n');
joined = joined.replace(FIG_RE, (_m, g1) => {
const text = g1.trim();
return `<span class="nmp-fig" style="display:block;text-align:center;color:#666;font-size:12px;margin:4px 0;">${text}</span>`;
});
return joined;
}

View File

@@ -1,69 +0,0 @@
# note-to-mp 重构规划 (模块与接口草案)
> 标记格式: // [note-to-mp 重构]
## 目标概要
- 模块化图片处理、元数据、Gallery、内容预处理、渲染管线分离。
- 清晰接口:对外暴露统一渲染与数据提取 API。
- 可测试:核心逻辑函数纯函数化,最小化对 Obsidian 运行时依赖。
## 模块划分
1. 图片处理模块 (image/)
- 统一识别 wikilink 与 markdown 图片语法
- LocalImage 结构: { original: string; basename: string; alt?: string; sourceType: 'wikilink'|'markdown'; index: number; }
- LocalImageManager: 收集、查询、封面候选、上传占位接口
- 正则常量: LocalFileRegex
2. 元数据与封面 (meta/)
- extractWeChatMeta(raw: string): WeChatMetaRaw
- getWeChatArticleMeta(): 返回最近一次渲染缓存的 meta
- getMetadata(images: LocalImage[], metaRaw: WeChatMetaRaw): FinalMeta
- 回退策略: frontmatter cover > metaRaw.coverLink > images[0]
3. Gallery 支持 (gallery/)
- transformGalleryShortcodes(content: string): { content: string; extracted?: GalleryInfo }
- selectGalleryImages(dir: string, options): Promise<string[]>
- 语法: 单行 self-closing 与 块级形式
4. 内容预处理 (preprocess/)
- preprocessContent(markdown: string): string
- 行级语法: ||r / ||g / ||b / ||y / || (默认灰)
- figure 语法: [fig text/]
5. 渲染管线 (render/)
- renderMarkdown(file: TFile): Promise<RenderedArticle>
- 内部阶段:
Raw -> extractWeChatMeta -> strip frontmatter -> transformGalleryShortcodes -> preprocessContent -> markdown parse (自定义 tokenizer) -> HTML + 样式注入 -> metadata 汇总
6. 上传/微信接口 (weixin/)
- 包装现有 weixin-api.ts 函数 + 错误封装
## 数据结构
```ts
interface LocalImage { original: string; basename: string; alt?: string; sourceType: 'wikilink'|'markdown'; index: number; }
interface WeChatMetaRaw { title?: string; author?: string; coverLink?: string; rawImage?: string; hasFrontmatter: boolean; }
interface FinalMeta { title: string; author?: string; coverImage?: LocalImage; coverLink?: string; }
interface RenderedArticle { html: string; css?: string; meta: FinalMeta; images: LocalImage[]; raw: string; }
```
## 关键正则
- frontmatter: ^---[\s\S]*?\n---
- wikilink image: !\[\[(.+?)\]\]
- markdown image: !\[[^\]]*\]\(([^\n\r\)]+)\)
- gallery block: /{{<gallery>}}([\s\S]*?){{<\/gallery>}}/g
- gallery figure: /{{<figure\s+src="([^"]+)"[^>]*>}}/g
## 风险点
- 正则误判 frontmatter
- 图片在预处理阶段被破坏索引
- 多次渲染缓存污染
## 缓解
- 提取后不修改原文本副本
- 维护渲染上下文对象 (RenderContext)
## 后续实现顺序
图片处理 -> 元数据 -> Gallery -> 预处理 -> 渲染组装 -> 接口对接现有 NotePreview
---
(实现过程中该文档可增补)

View File

@@ -1,50 +0,0 @@
// [note-to-mp 重构] 渲染管线模块
import { App, TFile, MarkdownRenderer } from 'obsidian';
import { parseImagesFromMarkdown, LocalImage, LocalImageManager } from '../image';
import { extractWeChatMeta, getMetadata, FinalMeta } from '../meta';
import { transformGalleryShortcodes } from '../gallery';
import { preprocessContent } from '../preprocess';
export interface RenderedArticle {
html: string;
meta: FinalMeta;
images: LocalImage[];
raw: string;
}
export class RenderService {
private app: App;
private imageManager = new LocalImageManager();
constructor(app: App) {
this.app = app;
}
async renderFile(file: TFile): Promise<RenderedArticle> {
const raw = await this.app.vault.read(file);
return this.renderRaw(raw, file.path);
}
async renderRaw(raw: string, path?: string): Promise<RenderedArticle> {
this.imageManager.clear();
// 1. frontmatter + 基础元数据
const { meta: rawMeta, body } = extractWeChatMeta(raw);
// 2. gallery 转换
const galleryRes = transformGalleryShortcodes(body);
// 3. 预处理行级语法
const preprocessed = preprocessContent(galleryRes.content);
// 4. 图片解析
const images = parseImagesFromMarkdown(preprocessed);
images.forEach(i => this.imageManager.add(i));
// 5. 获取最终 meta封面回退
const finalMeta = getMetadata(images, rawMeta);
// 6. markdown -> HTML (使用 Obsidian 内部渲染管线)
const el = document.createElement('div');
// NOTE: 这里简化,实际应考虑自定义 tokenizer后续可补充
await MarkdownRenderer.renderMarkdown(preprocessed, el, path || '', this.app as any);
// 7. 注入简单样式 (可外置)
const style = `<style>.nmp-fig{font-style:italic}</style>`;
return { html: style + el.innerHTML, meta: finalMeta, images, raw };
}
}

68
todo.list Normal file
View File

@@ -0,0 +1,68 @@
1. 预处理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。
- 这个/Users/gavin/myweb/static/img/guanzhan/1路径下图片<5张取出所有图片; >n张任意取出n张。n=NUM_PIC作为全局定义。
- 比如n=1取出的图片为xx.jpg那么把{{<gallery dir="/img/guanzhan/1" figcaption="毕业展"/>}}{{<load-photoswipe>}}替换为![[xx.jpg]]
如n=2取出的图片为xx.jpg,yy.png那么把{{<gallery dir="/img/guanzhan/1" figcaption="毕业展"/>}}{{<load-photoswipe>}}替换为:
![[xx.jpg]]
![[yy.png]]
在main.js单独函数中处理在预处理内容时调用。
2.
对如下:
{{<gallery>}}
{{<figure src="/img/晋中晋北行程.jpeg" caption="晋中晋北行程" >}}
{{<figure src="/img/晋中晋北行程-2.png" caption="晋中晋北行程" >}}
{{<figure src="/img/晋中晋北行程-3.png" caption="晋中晋北行程" >}}
{{</gallery>}}
替换为
![[晋中晋北行程.jpeg]]
![[晋中晋北行程-2.jpeg]]
![[晋中晋北行程-3.jpeg]]
2. 需求:没有成功❌❓
|| content1
content2
content3
修改代码,连续多行只渲染第一行,举例:
<p style="font-family:'Microsoft YaHei',sans-serif;background-color:#FFFFC2;padding:20px;border-radius:20px;line-height:35px;">content1</p>
而不是:
<p style="font-family:'Microsoft YaHei',sans-serif;background-color:#FFFFC2;padding:20px;border-radius:20px;line-height:35px;">content1\ncontent2\ncontent3</p>
3.
读取markdown属性
---
layout: post
title: 6月特种兵式观展
subtitle:
description:
date: 2025-06-11 11:00:00
author: 大童
image: "/img/shufa/a.jpg"
showtoc: true
tags:
- 旅行
URL:
categories:
- live
slug: guanzhan
---
提取以下信息(忽略两端的“”):
- 公众号文章title: 6月特种兵式观展
- 文章作者: 大童
- 文章封面图片GALLERY_PRE_PATH+"/img/shufa/a.jpg",转化为![[a.jpg]]; 如image为空封面图片取文章中第一张图片
4.
2025ZK1.md 没有正确解析公众号标题2025ZK1封面图片解析也不对。
正确的应该是:
公众号标题“2025篆刻记录-0426”
封面图片:![[2025ZK1-7.jpg]]
注意如果我把2025ZK1.md 内容img改成![[2025ZK1-7.jpg]],以上解析没有问题。
5. 文章没有图片,封面使用一张默认图片(设计一张)。