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

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 };
}
}