Files
odox/notes/architecture.md
nan 41db190fee
Some checks failed
Node.js build / build (20.x) (push) Failing after 18m32s
Node.js build / build (22.x) (push) Has been cancelled
feat: Implement Canvas rendering system and VitePress exporter plugin
- Add Canvas rendering implementation in `notes/render-canvas.md`
- Create VitePress exporter plugin in `src/main.ts` with commands for exporting notes
- Implement settings management for the plugin in `src/settings.ts`
- Add CSS file for potential styling needs
- Create version bump script in `version-bump.mjs` for managing versioning
- Initialize version tracking in `versions.json`
2026-04-20 22:23:23 +08:00

11 KiB
Raw Blame History

VitePress Exporter 架构设计

概述

VitePress Exporter 是一个 Obsidian 插件,用于将笔记和资源导出到 VitePress 格式。核心功能包括:

  • 导出 Markdown 文件到 docs/ 目录
  • 复制图片等资源到 docs/assets/ 目录
  • 将 Obsidian Canvas 渲染为图片

技术栈

  • 语言: TypeScript
  • 构建工具: esbuild
  • 包管理器: pnpm
  • Obsidian API: Plugin, Workspace, TFile, Vault, MetadataCache

核心架构

1. 导出流程

用户触发导出
    ↓
exportCurrentNote()
    ↓
exportNote(file)
    ↓
processAssets(content, file)
    ├─→ 扫描嵌入和链接(使用 MetadataCache)
    ├─→ 处理 Canvas 文件 (captureCanvas)
    │   ├─→ 打开 Canvas 文件
    │   ├─→ 获取 Canvas 对象
    │   └─→ exportCanvasWithInternalAPI
    │       ├─→ 设置 zoom = 0 (100%)
    │       ├─→ 调用 takeScreenshot
    │       └─→ 保存图片
    └─→ 复制资源文件
        ├─→ 使用 binary 模式读取
        └─→ 写入到 docs/assets/
    ↓
updateLinksInContent() / updateCanvasLinksInContent()
    ↓
写入 docs/ 目录(使用 binary 模式)

2. 文件结构

vitepress-exporter/
├── src/
│   ├── main.ts              # 主插件源代码
│   └── settings.ts          # 设置相关代码
├── manifest.json            # 插件清单
├── package.json             # 项目配置
├── esbuild.config.mjs       # 构建配置
├── tsconfig.json            # TypeScript 配置
└── notes/                   # 开发文档
    ├── architecture.md      # 本文档
    ├── canvas-rendering.md  # Canvas 渲染详细说明
    └── flowchart.md         # 流程图

3. 关键类和方法

VitePressExporterPlugin

主插件类,继承自 Plugin:

class VitePressExporterPlugin extends Plugin {
    settings: VitePressExporterSettings;

    // 生命周期
    async onload()                    // 插件加载,注册命令和菜单
    onunload()                        // 插件卸载

    // 设置管理
    async loadSettings()              // 加载设置
    async saveSettings()              // 保存设置

    // 导出功能
    async exportCurrentNote()         // 导出当前笔记
    async exportNote(file)            // 导出单个笔记

    // 资源处理
    async processAssets(content, file)  // 处理资源文件和 Canvas

    // Canvas 渲染
    async captureCanvas(canvasFile)      // 捕获 Canvas 为图片
    async exportCanvasWithInternalAPI(canvas, canvasFile)  // 使用内部 API

    // 链接更新
    updateLinksInContent(content, sourceFile, assetPath, newFileName)  // 更新资源链接
    updateCanvasLinksInContent(content, canvasFile, canvasPath, imageFileName)  // 更新 Canvas 链接
}

Canvas 渲染架构

核心决策

使用 Obsidian Canvas 内部 API,而不是自己实现渲染逻辑:

  • ✅ canvas.takeScreenshot() - 直接截图
  • ✅ canvas.getViewportBBox() - 获取边界框
  • ✅ canvas.zoom - 控制缩放(对数刻度)
  • ✅ canvas.requestFrame() - 触发重绘

为什么不使用第三方库:

  • ❌ html-to-image - 需要滑动窗口,代码复杂
  • ❌ dom-to-image-more - 维护不活跃
  • ✅ Obsidian 内部 API - 兼容性好,代码简洁

Zoom 对数刻度

关键发现:canvas.zoom 使用对数刻度(base 2)

zoom = 0    → scale = 2^0 = 1.0   (100%)
zoom = 1    → scale = 2^1 = 2.0   (200%)
zoom = -1   → scale = 2^-1 = 0.5  (50%)

重要:设置 zoom = 0 获得准确的 100% 缩放,不要设置 zoom = 1.0!

文件路径处理

takeScreenshot 需要绝对文件系统路径:

// 1. 获取 vault 的完整路径
const vaultBasePath = vault.adapter.basePath;

// 2. 构建临时文件的绝对路径
const tempFullPath = `${vaultBasePath}/temp_canvas_${Date.now()}.png`;

// 3. 调用 takeScreenshot(需要绝对路径)
await canvas.takeScreenshot(tempFullPath, false);

// 4. 使用相对路径读取(vault API)
const imageBuffer = await vault.adapter.readBinary(tempFileName);

// 5. 删除临时文件
await vault.adapter.remove(tempFileName);

// 6. 写入到正确位置(相对路径)
await vault.adapter.writeBinary(finalPath, imageBuffer);

资源处理

资源类型

  1. 嵌入资源 (![[image.png]])

    • 图片、PDF 等非 Markdown 文件
    • 自动复制到 docs/assets/
    • 使用 binary 模式保留原始内容
  2. Canvas 文件 (![[diagram.canvas]])

    • 使用内部 API 渲染为 PNG 图片
    • 替换为 <Viewer src="assets/diagram.png" />
  3. 普通链接 ([[other-file]])

    • Markdown 文件不复制
    • 其他资源文件会被复制

资源扫描机制

使用 Obsidian 的 MetadataCache API:

const cache = metadataCache.getCache(sourceFile.path);

// 嵌入文件
if (cache.embeds) {
    for (const embed of cache.embeds) {
        const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path);
        // 检查是否是 Canvas 或其他资源
    }
}

// 普通链接
if (cache.links) {
    for (const link of cache.links) {
        const resolvedLink = metadataCache.getFirstLinkpathDest(link.link, sourceFile.path);
        // 只复制非 Markdown 文件
    }
}

路径更新

资源链接会自动更新为相对于 docs/ 的路径:

Markdown 格式:

之前: ![](images/screenshot.png)
之后: ![](assets/screenshot.png)

Wikilink 格式:

之前: ![[images/screenshot.png]]
之后: ![[assets/screenshot.png]]

Canvas 特殊处理:

之前: ![[diagram.canvas]]
之后: <Viewer src="assets/diagram.png" />

路径解析策略

支持多种路径格式的智能匹配:

const possiblePaths = [
    assetPath,                                    // 完整路径
    assetPath.substring(sourceDir.length + 1),   // 相对路径
    encodeURI(assetPath),                        // URL 编码
    encodeURI(assetPath.substring(sourceDir.length + 1)),
    sourceFile.name,                             // 文件名
    encodeURI(sourceFile.name)
];

配置

插件设置

interface VitePressExporterSettings {
    docsPath: string;      // 默认: 'docs'
    assetsPath: string;    // 默认: 'docs/assets'
}

Obsidian 配置

插件通过 Obsidian 的设置界面配置:

  1. 设置 → 社区插件 → VitePress Exporter → 选项
  2. 配置 Docs path 和 Assets path
  3. 保存设置

文件读写

Binary 模式

所有文件操作都使用 binary 模式,以保留原始格式:

// 读取文件
const contentBuffer = await vault.readBinary(file);
const content = new TextDecoder().decode(contentBuffer);

// 写入文件
const updatedBuffer = new TextEncoder().encode(updatedContent);
await vault.adapter.writeBinary(docsPath, updatedBuffer.buffer);

优势:

  • 保留行尾空格
  • 保留特殊字符
  • 不修改原始格式

错误处理

Canvas 渲染失败

当 Canvas 渲染失败时,会在控制台输出错误信息,但不会中断整个导出流程:

try {
    const renderedImage = await this.captureCanvas(canvasFile);
    if (renderedImage) {
        // 成功处理
    }
} catch (error) {
    console.error(`Failed to render canvas ${canvasPath}:`, error);
}
// 继续处理其他文件

资源复制失败

单个资源文件复制失败不会影响其他文件:

try {
    // 复制资源
} catch (error) {
    console.error(`Failed to copy asset ${assetPath}:`, error);
}
// 继续处理下一个资源

性能考虑

优化策略

  1. 使用 MetadataCache

    • 快速获取文件的嵌入和链接信息
    • 避免手动解析 Markdown
  2. 批量处理

    • 一次性收集所有需要处理的文件
    • 批量复制和更新
  3. 异步操作

    • 所有文件操作都是异步的
    • 不阻塞 UI 线程
  4. Binary 模式

    • 减少字符串转换开销
    • 直接操作二进制数据

预期性能

  • 小笔记(< 10 个资源):< 1 秒
  • 中等笔记(10-50 个资源):< 3 秒
  • 大笔记(> 50 个资源或多个 Canvas):< 10 秒

触发方式

插件提供三种导出触发方式:

  1. 命令面板

    • Export current note to VitePress
  2. 文件菜单

    • 右键点击文件 → Export to VitePress
  3. 编辑器菜单

    • 右键点击编辑器 → Export to VitePress

未来扩展

可能的功能

  1. 批量导出

    • 导出整个文件夹
    • 导出所有笔记
  2. 高级配置

    • 自定义图片格式(PNG/JPEG/WebP)
    • 图片质量控制
    • 自定义链接替换规则
  3. VitePress 集成

    • 自动生成 frontmatter
    • 自动创建索引页面
    • 集成 VitePress 构建流程
  4. 更多资源类型

    • 支持视频嵌入
    • 支持音频文件
    • 支持其他特殊格式

维护指南

修改导出逻辑

主要导出逻辑在 exportNote() 方法中:

async exportNote(file: TFile) {
    // 1. 读取文件(binary 模式)
    const contentBuffer = await vault.readBinary(file);
    const content = new TextDecoder().decode(contentBuffer);

    // 2. 处理资源
    const updatedContent = await this.processAssets(content, file);

    // 3. 写入文件(binary 模式)
    const updatedBuffer = new TextEncoder().encode(updatedContent);
    await vault.adapter.writeBinary(docsPath, updatedBuffer.buffer);
}

添加新的资源类型

在 processAssets() 方法中添加:

if (cache.embeds) {
    for (const embed of cache.embeds) {
        const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path);
        if (resolvedLink) {
            if (resolvedLink.extension === 'canvas') {
                // Canvas 处理
            } else if (resolvedLink.extension === 'newtype') {
                // 处理新类型
            } else {
                // 普通资源
            }
        }
    }
}

调试技巧

  1. 启用详细日志

    console.log('Processing assets for file:', sourceFile.path);
    console.log('Cache:', cache);
    console.log('Assets to copy:', Array.from(assetsToCopy));
    
  2. 使用 Obsidian 开发者工具

    • Ctrl+Shift+I 打开控制台
    • 查看日志输出
  3. 测试单个功能

    • 创建最小测试用例
    • 逐步验证每个步骤

相关文档