update at 2025-09-22 14:58:45
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
---
|
||||
(实现过程中该文档可增补)
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user