update at 2025-09-22 13:55:41

This commit is contained in:
douboer
2025-09-22 13:55:41 +08:00
parent f33b2ee535
commit 0090ce9b93
85 changed files with 20418 additions and 0 deletions

630
src/article-render.ts Normal file
View File

@@ -0,0 +1,630 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { App, ItemView, Workspace, Notice, sanitizeHTMLToDom, apiVersion, TFile, MarkdownRenderer, FrontMatterCache } from 'obsidian';
import { applyCSS } from './utils';
import { UploadImageToWx } from './imagelib';
import { NMPSettings } from './settings';
import AssetsManager from './assets';
import InlineCSS from './inline-css';
import { wxGetToken, wxAddDraft, wxBatchGetMaterial, DraftArticle, DraftImageMediaId, DraftImages, wxAddDraftImages } from './weixin-api';
import { MDRendererCallback } from './markdown/extension';
import { MarkedParser } from './markdown/parser';
import { LocalImageManager, LocalFile } from './markdown/local-file';
import { CardDataManager } from './markdown/code';
import { debounce } from './utils';
import { PrepareImageLib, IsImageLibReady, WebpToJPG } from './imagelib';
import { toPng } from 'html-to-image';
const FRONT_MATTER_REGEX = /^(---)$.+?^(---)$.+?/ims;
export class ArticleRender implements MDRendererCallback {
app: App;
itemView: ItemView;
workspace: Workspace;
styleEl: HTMLElement;
articleDiv: HTMLDivElement;
settings: NMPSettings;
assetsManager: AssetsManager;
articleHTML: string;
title: string;
_currentTheme: string;
_currentHighlight: string;
_currentAppId: string;
markedParser: MarkedParser;
cachedElements: Map<string, string> = new Map();
debouncedRenderMarkdown: (...args: any[]) => void;
constructor(app: App, itemView: ItemView, styleEl: HTMLElement, articleDiv: HTMLDivElement) {
this.app = app;
this.itemView = itemView;
this.styleEl = styleEl;
this.articleDiv = articleDiv;
this.settings = NMPSettings.getInstance();
this.assetsManager = AssetsManager.getInstance();
this.articleHTML = '';
this.title = '';
this._currentTheme = 'default';
this._currentHighlight = 'default';
this.markedParser = new MarkedParser(app, this);
this.debouncedRenderMarkdown = debounce(this.renderMarkdown.bind(this), 1000);
}
set currentTheme(value: string) {
this._currentTheme = value;
}
get currentTheme() {
const { theme } = this.getMetadata();
if (theme) {
return theme;
}
return this._currentTheme;
}
set currentHighlight(value: string) {
this._currentHighlight = value;
}
get currentHighlight() {
const { highlight } = this.getMetadata();
if (highlight) {
return highlight;
}
return this._currentHighlight;
}
isOldTheme() {
const theme = this.assetsManager.getTheme(this.currentTheme);
if (theme) {
return theme.css.indexOf('.note-to-mp') < 0;
}
return false;
}
setArticle(article: string) {
this.articleDiv.empty();
let className = 'note-to-mp';
// 兼容旧版本样式
if (this.isOldTheme()) {
className = this.currentTheme;
}
const html = `<section class="${className}" id="article-section">${article}</section>`;
const doc = sanitizeHTMLToDom(html);
if (doc.firstChild) {
this.articleDiv.appendChild(doc.firstChild);
}
}
setStyle(css: string) {
this.styleEl.empty();
this.styleEl.appendChild(document.createTextNode(css));
}
reloadStyle() {
this.setStyle(this.getCSS());
}
getArticleSection() {
return this.articleDiv.querySelector('#article-section') as HTMLElement;
}
getArticleContent() {
const content = this.articleDiv.innerHTML;
let html = applyCSS(content, this.getCSS());
// 处理话题多余内容
html = html.replace(/rel="noopener nofollow"/g, '');
html = html.replace(/target="_blank"/g, '');
html = html.replace(/data-leaf=""/g, 'leaf=""');
return CardDataManager.getInstance().restoreCard(html);
}
getArticleText() {
return this.articleDiv.innerText.trimStart();
}
errorContent(error: any) {
return '<h1>渲染失败!</h1><br/>'
+ '如需帮助请前往&nbsp;&nbsp;<a href="https://github.com/sunbooshi/note-to-mp/issues">https://github.com/sunbooshi/note-to-mp/issues</a>&nbsp;&nbsp;反馈<br/><br/>'
+ '如果方便请提供引发错误的完整Markdown内容。<br/><br/>'
+ '<br/>Obsidian版本' + apiVersion
+ '<br/>错误信息:<br/>'
+ `${error}`;
}
async renderMarkdown(af: TFile | null = null) {
try {
let md = '';
if (af && af.extension.toLocaleLowerCase() === 'md') {
md = await this.app.vault.adapter.read(af.path);
this.title = af.basename;
}
else {
md = '没有可渲染的笔记或文件不支持渲染';
}
if (md.startsWith('---')) {
md = md.replace(FRONT_MATTER_REGEX, '');
}
this.articleHTML = await this.markedParser.parse(md);
this.setStyle(this.getCSS());
this.setArticle(this.articleHTML);
await this.processCachedElements();
}
catch (e) {
console.error(e);
this.setArticle(this.errorContent(e));
}
}
getCSS() {
try {
const theme = this.assetsManager.getTheme(this.currentTheme);
const highlight = this.assetsManager.getHighlight(this.currentHighlight);
const customCSS = this.settings.customCSSNote.length > 0 || this.settings.useCustomCss ? this.assetsManager.customCSS : '';
const baseCSS = this.settings.baseCSS ? `.note-to-mp {${this.settings.baseCSS}}` : '';
return `${InlineCSS}\n\n${highlight!.css}\n\n${theme!.css}\n\n${baseCSS}\n\n${customCSS}`;
} catch (error) {
console.error(error);
new Notice(`获取样式失败${this.currentTheme}|${this.currentHighlight},请检查主题是否正确安装。`);
}
return '';
}
updateStyle(styleName: string) {
this.currentTheme = styleName;
this.setStyle(this.getCSS());
}
updateHighLight(styleName: string) {
this.currentHighlight = styleName;
this.setStyle(this.getCSS());
}
getFrontmatterValue(frontmatter: FrontMatterCache, key: string) {
const value = frontmatter[key];
if (value instanceof Array) {
return value[0];
}
return value;
}
getMetadata() {
let res: DraftArticle = {
title: '',
author: undefined,
digest: undefined,
content: '',
content_source_url: undefined,
cover: undefined,
thumb_media_id: '',
need_open_comment: undefined,
only_fans_can_comment: undefined,
pic_crop_235_1: undefined,
pic_crop_1_1: undefined,
appid: undefined,
theme: undefined,
highlight: undefined,
}
const file = this.app.workspace.getActiveFile();
if (!file) return res;
const metadata = this.app.metadataCache.getFileCache(file);
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);
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.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;
res.appid = this.getFrontmatterValue(frontmatter, keys.appid);
if (res.appid && !res.appid.startsWith('wx')) {
res.appid = this.settings.wxInfo.find(wx => wx.name === res.appid)?.appid;
}
res.theme = this.getFrontmatterValue(frontmatter, keys.theme);
res.highlight = this.getFrontmatterValue(frontmatter, keys.highlight);
if (frontmatter[keys.crop]) {
res.pic_crop_235_1 = '0_0_1_0.5';
res.pic_crop_1_1 = '0_0.525_0.404_1';
}
}
return res;
}
async uploadVaultCover(name: string, token: string) {
const LocalFileRegex = /^!\[\[(.*?)\]\]/;
const matches = name.match(LocalFileRegex);
let fileName = '';
if (matches && matches.length > 1) {
fileName = matches[1];
}
else {
fileName = name;
}
const vault = this.app.vault;
const file = this.assetsManager.searchFile(fileName) as TFile;
if (!file) {
throw new Error('找不到封面文件: ' + fileName);
}
const fileData = await vault.readBinary(file);
return await this.uploadCover(new Blob([fileData]), file.name, token);
}
async uploadCover(data: Blob, filename: string, token: string) {
if (filename.toLowerCase().endsWith('.webp')) {
await PrepareImageLib();
if (IsImageLibReady()) {
data = new Blob([WebpToJPG(await data.arrayBuffer())]);
filename = filename.toLowerCase().replace('.webp', '.jpg');
}
}
const res = await UploadImageToWx(data, filename, token, 'image');
if (res.media_id) {
return res.media_id;
}
console.error('upload cover fail: ' + res.errmsg);
throw new Error('上传封面失败: ' + res.errmsg);
}
async getDefaultCover(token: string) {
const res = await wxBatchGetMaterial(token, 'image');
if (res.item_count > 0) {
return res.item[0].media_id;
}
return '';
}
async getToken(appid: string) {
const secret = this.getSecret(appid);
const res = await wxGetToken(this.settings.authKey, appid, secret);
if (res.status != 200) {
const data = res.json;
throw new Error('获取token失败: ' + data.message);
}
const token = res.json.token;
if (token === '') {
throw new Error('获取token失败: ' + res.json.message);
}
return token;
}
async uploadImages(appid: string) {
if (!this.settings.authKey) {
throw new Error('请先设置注册码AuthKey');
}
let metadata = this.getMetadata();
if (metadata.appid) {
appid = metadata.appid;
}
if (!appid || appid.length == 0) {
throw new Error('请先选择公众号');
}
// 获取token
const token = await this.getToken(appid);
if (token === '') {
return;
}
await this.cachedElementsToImages();
const lm = LocalImageManager.getInstance();
// 上传图片
await lm.uploadLocalImage(token, this.app.vault);
// 上传图床图片
await lm.uploadRemoteImage(this.articleDiv, token);
// 替换图片链接
lm.replaceImages(this.articleDiv);
await this.copyArticle();
}
async copyArticle() {
const content = this.getArticleContent();
await navigator.clipboard.write([new ClipboardItem({
'text/html': new Blob([content], { type: 'text/html' })
})])
}
getSecret(appid: string) {
for (const wx of this.settings.wxInfo) {
if (wx.appid === appid) {
return wx.secret.replace('SECRET', '');
}
}
return '';
}
async postArticle(appid:string, localCover: File | null = null) {
if (!this.settings.authKey) {
throw new Error('请先设置注册码AuthKey');
}
let metadata = this.getMetadata();
if (metadata.appid) {
appid = metadata.appid;
}
if (!appid || appid.length == 0) {
throw new Error('请先选择公众号');
}
// 获取token
const token = await this.getToken(appid);
if (token === '') {
throw new Error('获取token失败,请检查网络链接!');
}
await this.cachedElementsToImages();
const lm = LocalImageManager.getInstance();
// 上传图片
await lm.uploadLocalImage(token, this.app.vault);
// 上传图床图片
await lm.uploadRemoteImage(this.articleDiv, token);
// 替换图片链接
lm.replaceImages(this.articleDiv);
// 上传封面
let mediaId = metadata.thumb_media_id;
if (!mediaId) {
if (metadata.cover) {
// 上传仓库里的图片
if (metadata.cover.startsWith('http')) {
const res = await LocalImageManager.getInstance().uploadImageFromUrl(metadata.cover, token, 'image');
if (res.media_id) {
mediaId = res.media_id;
}
else {
throw new Error('上传封面失败:' + res.errmsg);
}
}
else {
mediaId = await this.uploadVaultCover(metadata.cover, token);
}
}
else if (localCover) {
mediaId = await this.uploadCover(localCover, localCover.name, token);
}
else {
mediaId = await this.getDefaultCover(token);
}
}
if (mediaId === '') {
throw new Error('请先上传图片或者设置默认封面');
}
metadata.title = metadata.title || this.title;
metadata.content = this.getArticleContent();
metadata.thumb_media_id = mediaId;
// 创建草稿
const res = await wxAddDraft(token, metadata);
if (res.status != 200) {
console.error(res.text);
throw new Error(`创建草稿失败, https状态码: ${res.status} 可能是文章包含异常内容,请尝试手动复制到公众号编辑器!`);
}
const draft = res.json;
if (draft.media_id) {
return draft.media_id;
}
else {
console.error(JSON.stringify(draft));
throw new Error('发布失败!' + draft.errmsg);
}
}
async postImages(appid: string) {
if (!this.settings.authKey) {
throw new Error('请先设置注册码AuthKey');
}
let metadata = this.getMetadata();
if (metadata.appid) {
appid = metadata.appid;
}
if (!appid || appid.length == 0) {
throw new Error('请先选择公众号');
}
// 获取token
const token = await this.getToken(appid);
if (token === '') {
throw new Error('获取token失败,请检查网络链接!');
}
const imageList: DraftImageMediaId[] = [];
const lm = LocalImageManager.getInstance();
// 上传图片
await lm.uploadLocalImage(token, this.app.vault, 'image');
// 上传图床图片
await lm.uploadRemoteImage(this.articleDiv, token, 'image');
const images = lm.getImageInfos(this.articleDiv);
for (const image of images) {
if (!image.media_id) {
console.warn('miss media id:', image.resUrl);
continue;
}
imageList.push({
image_media_id: image.media_id,
});
}
if (imageList.length === 0) {
throw new Error('没有图片需要发布!');
}
const content = this.getArticleText();
const imagesData: DraftImages = {
article_type: 'newspic',
title: metadata.title || this.title,
content: content,
need_open_commnet: metadata.need_open_comment || 0,
only_fans_can_comment: metadata.only_fans_can_comment || 0,
image_info: {
image_list: imageList,
}
}
// 创建草稿
const res = await wxAddDraftImages(token, imagesData);
if (res.status != 200) {
console.error(res.text);
throw new Error(`创建图片/文字失败, https状态码: ${res.status} ${res.text}`);
}
const draft = res.json;
if (draft.media_id) {
return draft.media_id;
}
else {
console.error(JSON.stringify(draft));
throw new Error('发布失败!' + draft.errmsg);
}
}
async exportHTML() {
await this.cachedElementsToImages();
const lm = LocalImageManager.getInstance();
const content = await lm.embleImages(this.articleDiv, this.app.vault);
const globalStyle = await this.assetsManager.getStyle();
const html = applyCSS(content, this.getCSS() + globalStyle);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = this.title + '.html';
a.click();
URL.revokeObjectURL(url);
a.remove();
}
async processCachedElements() {
const af = this.app.workspace.getActiveFile();
if (!af) {
console.error('当前没有打开文件,无法处理缓存元素');
return;
}
for (const [key, value] of this.cachedElements) {
const [category, id] = key.split(':');
if (category === 'mermaid' || category === 'excalidraw') {
const container = this.articleDiv.querySelector('#' + id) as HTMLElement;
if (container) {
await MarkdownRenderer.render(this.app, value, container, af.path, this.itemView);
}
}
}
}
async cachedElementsToImages() {
for (const [key, cached] of this.cachedElements) {
const [category, elementId] = key.split(':');
const container = this.articleDiv.querySelector(`#${elementId}`) as HTMLElement;
if (!container) continue;
if (category === 'mermaid') {
await this.replaceMermaidWithImage(container, elementId);
} else if (category === 'excalidraw') {
await this.replaceExcalidrawWithImage(container, elementId);
}
}
}
private async replaceMermaidWithImage(container: HTMLElement, id: string) {
const mermaidContainer = container.querySelector('.mermaid') as HTMLElement;
if (!mermaidContainer || !mermaidContainer.children.length) return;
const svg = mermaidContainer.querySelector('svg');
if (!svg) return;
try {
const pngDataUrl = await toPng(mermaidContainer.firstElementChild as HTMLElement, { pixelRatio: 2 });
const img = document.createElement('img');
img.id = `img-${id}`;
img.src = pngDataUrl;
img.style.width = `${svg.clientWidth}px`;
img.style.height = 'auto';
container.replaceChild(img, mermaidContainer);
} catch (error) {
console.warn(`Failed to render Mermaid diagram: ${id}`, error);
}
}
private async replaceExcalidrawWithImage(container: HTMLElement, id: string) {
const innerDiv = container.querySelector('div') as HTMLElement;
if (!innerDiv) return;
if (NMPSettings.getInstance().excalidrawToPNG) {
const originalImg = container.querySelector('img') as HTMLImageElement;
if (!originalImg) return;
const style = originalImg.getAttribute('style') || '';
try {
const pngDataUrl = await toPng(originalImg, { pixelRatio: 2 });
const img = document.createElement('img');
img.id = `img-${id}`;
img.src = pngDataUrl;
img.setAttribute('style', style);
container.replaceChild(img, container.firstChild!);
} catch (error) {
console.warn(`Failed to render Excalidraw image: ${id}`, error);
}
} else {
const svg = await LocalFile.renderExcalidraw(innerDiv.innerHTML);
this.updateElementByID(id, svg);
}
}
updateElementByID(id: string, html: string): void {
const item = this.articleDiv.querySelector('#' + id) as HTMLElement;
if (!item) return;
const doc = sanitizeHTMLToDom(html);
item.empty();
if (doc.childElementCount > 0) {
for (const child of doc.children) {
item.appendChild(child.cloneNode(true)); // 使用 cloneNode 复制节点以避免移动它
}
}
else {
item.innerText = '渲染失败';
}
}
cacheElement(category: string, id: string, data: string): void {
const key = category + ':' + id;
this.cachedElements.set(key, data);
}
}

461
src/assets.ts Normal file
View File

@@ -0,0 +1,461 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { App, PluginManifest, Notice, requestUrl, FileSystemAdapter, TAbstractFile, TFile, TFolder } from "obsidian";
import * as zip from "@zip.js/zip.js";
import DefaultTheme from "./default-theme";
import DefaultHighlight from "./default-highlight";
import { NMPSettings } from "./settings";
import { ExpertSettings, defaultExpertSettings, expertSettingsFromString } from "./expert-settings";
export interface Theme {
name: string
className: string
desc: string
author: string
css: string
}
export interface Highlight {
name: string
url: string
css: string
}
export default class AssetsManager {
app: App;
defaultTheme: Theme = DefaultTheme;
manifest: PluginManifest;
themes: Theme[];
highlights: Highlight[];
assetsPath: string;
themesPath: string;
hilightPath: string;
customCSS: string = '';
themeCfg: string;
hilightCfg: string;
customCSSPath: string;
iconsPath: string;
wasmPath: string;
expertSettings: ExpertSettings;
isLoaded: boolean = false;
private static instance: AssetsManager;
// 静态方法,用于获取实例
public static getInstance(): AssetsManager {
if (!AssetsManager.instance) {
AssetsManager.instance = new AssetsManager();
}
return AssetsManager.instance;
}
public static setup(app: App, manifest: PluginManifest) {
AssetsManager.getInstance()._setup(app, manifest);
}
private _setup(app: App, manifest: PluginManifest) {
this.app = app;
this.manifest = manifest;
this.assetsPath = this.app.vault.configDir + '/plugins/' + this.manifest.id + '/assets/';
this.themesPath = this.assetsPath + 'themes/';
this.hilightPath = this.assetsPath + 'highlights/';
this.themeCfg = this.assetsPath + 'themes.json';
this.hilightCfg = this.assetsPath + 'highlights.json';
this.customCSSPath = this.assetsPath + 'custom.css';
this.iconsPath = this.assetsPath + 'icons/';
this.wasmPath = this.assetsPath + 'lib.wasm';
}
private constructor() {
}
async loadAssets() {
await this.loadThemes();
await this.loadHighlights();
await this.loadCustomCSS();
await this.loadExpertSettings();
this.isLoaded = true;
}
async loadThemes() {
try {
if (!await this.app.vault.adapter.exists(this.themeCfg)) {
new Notice('主题资源未下载,请前往设置下载!');
this.themes = [this.defaultTheme];
return;
}
const data = await this.app.vault.adapter.read(this.themeCfg);
if (data) {
const themes = JSON.parse(data);
await this.loadCSS(themes);
this.themes = [this.defaultTheme, ... themes];
}
} catch (error) {
console.error(error);
new Notice('themes.json解析失败');
}
}
async loadCSS(themes: Theme[]) {
try {
for (const theme of themes) {
const cssFile = this.themesPath + theme.className + '.css';
const cssContent = await this.app.vault.adapter.read(cssFile);
if (cssContent) {
theme.css = cssContent;
}
}
} catch (error) {
console.error(error);
new Notice('读取CSS失败');
}
}
async loadCustomCSS() {
try {
const customCSSNote = NMPSettings.getInstance().customCSSNote;
if (customCSSNote != '') {
const file = this.searchFile(customCSSNote);
if (file) {
const cssContent = await this.app.vault.adapter.read(file.path);
if (cssContent) {
this.customCSS = cssContent.replace(/```css/gi, '').replace(/```/g, '');
}
}
else {
new Notice(customCSSNote + '自定义CSS文件不存在');
}
return;
}
if (!await this.app.vault.adapter.exists(this.customCSSPath)) {
return;
}
const cssContent = await this.app.vault.adapter.read(this.customCSSPath);
if (cssContent) {
this.customCSS = cssContent;
}
} catch (error) {
console.error(error);
new Notice('读取CSS失败');
}
}
async loadExpertSettings() {
try {
const note = NMPSettings.getInstance().expertSettingsNote;
if (note != '') {
const file = this.searchFile(note);
if (file) {
let content = await this.app.vault.adapter.read(file.path);
if (content) {
this.expertSettings = expertSettingsFromString(content);
}
else {
this.expertSettings = defaultExpertSettings;
new Notice(note + '专家设置文件内容为空!');
}
}
else {
this.expertSettings = defaultExpertSettings;
new Notice(note + '专家设置不存在!');
}
}
else {
this.expertSettings = defaultExpertSettings;
}
} catch (error) {
console.error(error);
new Notice('读取专家设置失败!');
}
}
async loadHighlights() {
try {
const defaultHighlight = {name: '默认', url: '', css: DefaultHighlight};
this.highlights = [defaultHighlight];
if (!await this.app.vault.adapter.exists(this.hilightCfg)) {
new Notice('高亮资源未下载,请前往设置下载!');
return;
}
const data = await this.app.vault.adapter.read(this.hilightCfg);
if (data) {
const items = JSON.parse(data);
for (const item of items) {
const cssFile = this.hilightPath + item.name + '.css';
const cssContent = await this.app.vault.adapter.read(cssFile);
this.highlights.push({name: item.name, url: item.url, css: cssContent});
}
}
}
catch (error) {
console.error(error);
new Notice('highlights.json解析失败');
}
}
async loadIcon(name: string) {
const icon = this.iconsPath + name + '.svg';
if (!await this.app.vault.adapter.exists(icon)) {
return '';
}
const iconContent = await this.app.vault.adapter.read(icon);
if (iconContent) {
return iconContent;
}
return '';
}
async loadWasm() {
if (!await this.app.vault.adapter.exists(this.wasmPath)) {
return null;
}
const wasmContent = await this.app.vault.adapter.readBinary(this.wasmPath);
if (wasmContent) {
return wasmContent;
}
return null;
}
getTheme(themeName: string) {
if (themeName === '') {
return this.themes[0];
}
for (const theme of this.themes) {
if (theme.name.toLowerCase() === themeName.toLowerCase() || theme.className.toLowerCase() === themeName.toLowerCase()) {
return theme;
}
}
}
getHighlight(highlightName: string) {
if (highlightName === '') {
return this.highlights[0];
}
for (const highlight of this.highlights) {
if (highlight.name.toLowerCase() === highlightName.toLowerCase()) {
return highlight;
}
}
}
getThemeURL() {
const version = this.manifest.version;
return `https://github.com/sunbooshi/note-to-mp/releases/download/${version}/assets.zip`;
}
async getStyle() {
const file = this.app.vault.configDir + '/plugins/' + this.manifest.id + '/styles.css';
if (!await this.app.vault.adapter.exists(file)) {
return '';
}
const data = await this.app.vault.adapter.read(file);
if (data) {
return data;
}
return '';
}
async downloadThemes() {
try {
if (await this.app.vault.adapter.exists(this.themeCfg)) {
new Notice('主题资源已存在!')
return;
}
const res = await requestUrl(this.getThemeURL());
const data = res.arrayBuffer;
await this.unzip(new Blob([data]));
await this.loadAssets();
new Notice('主题下载完成!');
} catch (error) {
console.error(error);
await this.removeThemes();
new Notice('主题下载失败, 请检查网络!');
}
}
async unzip(data:Blob) {
const zipFileReader = new zip.BlobReader(data);
const zipReader = new zip.ZipReader(zipFileReader);
const entries = await zipReader.getEntries();
if (!await this.app.vault.adapter.exists(this.assetsPath)) {
await this.app.vault.adapter.mkdir(this.assetsPath);
}
for (const entry of entries) {
if (entry.directory) {
const dirPath = this.assetsPath + entry.filename;
await this.app.vault.adapter.mkdir(dirPath);
}
else {
const filePath = this.assetsPath + entry.filename;
const blobWriter = new zip.Uint8ArrayWriter();
if (entry.getData) {
const data = await entry.getData(blobWriter);
await this.app.vault.adapter.writeBinary(filePath, data.buffer as ArrayBuffer);
}
}
}
await zipReader.close();
}
async removeThemes() {
try {
const adapter = this.app.vault.adapter;
if (await adapter.exists(this.themeCfg)) {
await adapter.remove(this.themeCfg);
}
if (await adapter.exists(this.hilightCfg)) {
await adapter.remove(this.hilightCfg);
}
if (await adapter.exists(this.themesPath)) {
await adapter.rmdir(this.themesPath, true);
}
if (await adapter.exists(this.hilightPath)) {
await adapter.rmdir(this.hilightPath, true);
}
await this.loadAssets();
new Notice('清空完成!');
} catch (error) {
console.error(error);
new Notice('清空主题失败!');
}
}
async openAssets() {
const path = require('path');
const adapter = this.app.vault.adapter as FileSystemAdapter;
const vaultRoot = adapter.getBasePath();
const assets = this.assetsPath;
if (!await adapter.exists(assets)) {
await adapter.mkdir(assets);
}
const dst = path.join(vaultRoot, assets);
const { shell } = require('electron');
shell.openPath(dst);
}
searchFile(nameOrPath: string): TAbstractFile | null {
const resolvedPath = this.resolvePath(nameOrPath);
const vault= this.app.vault;
const attachmentFolderPath = vault.config.attachmentFolderPath || '';
let localPath = resolvedPath;
let file = null;
// 先按路径查找
file = vault.getFileByPath(resolvedPath);
if (file) {
return file;
}
// 在根目录查找
file = vault.getFileByPath(nameOrPath);
if (file) {
return file;
}
// 从附件文件夹查找
if (attachmentFolderPath != '') {
localPath = attachmentFolderPath + '/' + nameOrPath;
file = vault.getFileByPath(localPath)
if (file) {
return file;
}
localPath = attachmentFolderPath + '/' + resolvedPath;
file = vault.getFileByPath(localPath)
if (file) {
return file;
}
}
// 最后查找所有文件,这里只需要判断文件名
const files = vault.getAllLoadedFiles();
for (let f of files) {
if (f instanceof TFolder) continue
file = f as TFile;
if (file.basename === nameOrPath || file.name === nameOrPath) {
return f;
}
}
return null;
}
getResourcePath(path: string): {resUrl:string, filePath:string}|null {
const file = this.searchFile(path) as TFile;
if (file == null) {
return null;
}
const resUrl = this.app.vault.getResourcePath(file);
return {resUrl, filePath: file.path};
}
resolvePath(relativePath: string): string {
const basePath = this.getActiveFileDir();
if (!relativePath.includes('/')) {
return relativePath;
}
const stack = basePath.split("/");
const parts = relativePath.split("/");
stack.pop(); // Remove the current file name (or empty string)
for (const part of parts) {
if (part === ".") continue;
if (part === "..") stack.pop();
else stack.push(part);
}
return stack.join("/");
}
getActiveFileDir() {
const af = this.app.workspace.getActiveFile();
if (af == null) {
return '';
}
const parts = af.path.split('/');
parts.pop();
if (parts.length == 0) {
return '';
}
return parts.join('/');
}
async readFileBinary(path: string) {
const vault= this.app.vault;
const file = this.searchFile(path) as TFile;
if (file == null) {
return null;
}
return await vault.readBinary(file);
}
}

109
src/default-highlight.ts Normal file
View File

@@ -0,0 +1,109 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
export default `
pre code.hljs {
display: block;
}
/*
XCode style (c) Angel Garcia <angelgarcia.mail@gmail.com>
*/
.hljs {
background: #fff;
color: black
}
/* Gray DOCTYPE selectors like WebKit */
.xml .hljs-meta {
color: #c0c0c0
}
.hljs-comment,
.hljs-quote {
color: #007400
}
.hljs-tag,
.hljs-attribute,
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-name {
color: #aa0d91
}
.hljs-variable,
.hljs-template-variable {
color: #3F6E74
}
.hljs-code,
.hljs-string,
.hljs-meta .hljs-string {
color: #c41a16
}
.hljs-regexp,
.hljs-link {
color: #0E0EFF
}
.hljs-title,
.hljs-symbol,
.hljs-bullet,
.hljs-number {
color: #1c00cf
}
.hljs-section,
.hljs-meta {
color: #643820
}
.hljs-title.class_,
.hljs-class .hljs-title,
.hljs-type,
.hljs-built_in,
.hljs-params {
color: #5c2699
}
.hljs-attr {
color: #836C28
}
.hljs-subst {
color: #000
}
.hljs-formula {
background-color: #eee;
font-style: italic
}
.hljs-addition {
background-color: #baeeba
}
.hljs-deletion {
background-color: #ffc8bd
}
.hljs-selector-id,
.hljs-selector-class {
color: #9b703f
}
.hljs-doctag,
.hljs-strong {
font-weight: bold
}
.hljs-emphasis {
font-style: italic
}
`;

349
src/default-theme.ts Normal file
View File

@@ -0,0 +1,349 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const css = `
/* =========================================================== */
/* Obsidian的默认样式 */
/* =========================================================== */
.note-to-mp {
padding: 0;
user-select: text;
-webkit-user-select: text;
color: #222222;
font-size: 16px;
}
.note-to-mp:last-child {
margin-bottom: 0;
}
.note-to-mp .fancybox-img {
border: none;
}
.note-to-mp .fancybox-img:hover {
opacity: none;
border: none;
}
/*
=================================
Heading
==================================
*/
.note-to-mp h1 {
color: #222;
font-weight: 700;
font-size: 1.802em;
line-height: 1.2;
margin-block-start: 1em;
margin-block-end: 0;
}
.note-to-mp h2 {
color: #222;
font-weight: 600;
font-size: 1.602em;
line-height: 1.2;
margin-block-start: 1em;
margin-block-end: 0;
}
.note-to-mp h3 {
color: #222;
font-weight: 600;
font-size: 1.424em;
line-height: 1.3;
margin-block-start: 1em;
margin-block-end: 0;
}
.note-to-mp h4 {
color: #222;
font-weight: 600;
font-size: 1.266em;
line-height: 1.4;
margin-block-start: 1em;
margin-block-end: 0;
}
.note-to-mp h5 {
color: #222;
margin-block-start: 1em;
margin-block-end: 0;
}
.note-to-mp h6 {
color: #222;
margin-block-start: 1em;
margin-block-end: 0;
}
/*
=================================
Horizontal Rules
==================================
*/
.note-to-mp hr {
border-color: #e0e0e0;
margin-top: 3em;
margin-bottom: 3em;
}
/*
=================================
Paragraphs
==================================
*/
.note-to-mp p {
line-height: 1.6em;
margin: 1em 0;
}
/*
=================================
Emphasis
==================================
*/
.note-to-mp strong {
color: #222222;
font-weight: 600;
}
.note-to-mp em {
color: inherit;
font-style: italic;
}
.note-to-mp s {
color: inherit;
}
/*
=================================
Blockquotes
==================================
*/
.note-to-mp blockquote {
font-size: 1rem;
display: block;
margin: 2em 0;
padding: 0em 0.8em 0em 0.8em;
position: relative;
color: inherit;
border-left: 0.15rem solid #7852ee;
}
.note-to-mp blockquote blockquote {
margin: 0 0;
}
.note-to-mp blockquote p {
margin: 0;
}
.note-to-mp blockquote footer strong {
margin-right: 0.5em;
}
/*
=================================
List
==================================
*/
.note-to-mp ul {
margin: 0;
margin-top: 1.25em;
margin-bottom: 1.25em;
line-height: 1.6em;
}
.note-to-mp ul>li::marker {
color: #ababab;
/* font-size: 1.5em; */
}
.note-to-mp li>p {
margin: 0;
}
.note-to-mp ol {
margin: 0;
padding: 0;
margin-top: 1.25em;
margin-bottom: 0em;
list-style-type: decimal;
line-height: 1.6em;
}
.note-to-mp ol>li {
position: relative;
padding-left: 0.1em;
margin-left: 2em;
}
/*
=================================
Link
==================================
*/
.note-to-mp a {
color: #7852ee;
text-decoration: none;
font-weight: 500;
text-decoration: none;
border-bottom: 1px solid #7852ee;
transition: border 0.3s ease-in-out;
}
.note-to-mp a:hover {
color: #7952eebb;
border-bottom: 1px solid #7952eebb;
}
/*
=================================
Table
==================================
*/
.note-to-mp table {
width: 100%;
table-layout: auto;
text-align: left;
margin-top: 2em;
margin-bottom: 2em;
font-size: 0.875em;
line-height: 1.7142857;
border-collapse: collapse;
border-color: inherit;
text-indent: 0;
}
.note-to-mp table thead {
color: #000;
font-weight: 600;
border: #e0e0e0 1px solid;
}
.note-to-mp table thead th {
vertical-align: bottom;
padding-right: 0.5714286em;
padding-bottom: 0.5714286em;
padding-left: 0.5714286em;
border: #e0e0e0 1px solid;
}
.note-to-mp table thead th:first-child {
padding-left: 0.5em;
}
.note-to-mp table thead th:last-child {
padding-right: 0.5em;
}
.note-to-mp table tbody tr {
border-style: solid;
border: #e0e0e0 1px solid;
}
.note-to-mp table tbody tr:last-child {
border-bottom-width: 0;
}
.note-to-mp table tbody td {
vertical-align: top;
padding-top: 0.5714286em;
padding-right: 0.5714286em;
padding-bottom: 0.5714286em;
padding-left: 0.5714286em;
border: #e0e0e0 1px solid;
}
.note-to-mp table tbody td:first-child {
padding-left: 0;
}
.note-to-mp table tbody td:last-child {
padding-right: 0;
}
/*
=================================
Images
==================================
*/
.note-to-mp img {
margin: 2em auto;
}
.note-to-mp .footnotes hr {
margin-top: 4em;
margin-bottom: 0.5em;
}
/*
=================================
Code
==================================
*/
.note-to-mp .code-section {
display: flex;
border: rgb(240, 240, 240) 1px solid;
line-height: 26px;
font-size: 14px;
margin: 1em 0;
padding: 0.875em;
box-sizing: border-box;
}
.note-to-mp .code-section ul {
width: fit-content;
margin-block-start: 0;
margin-block-end: 0;
flex-shrink: 0;
height: 100%;
padding: 0;
line-height: 26px;
list-style-type: none;
backgroud: transparent !important;
}
.note-to-mp .code-section ul>li {
text-align: right;
}
.note-to-mp .code-section pre {
margin-block-start: 0;
margin-block-end: 0;
white-space: normal;
overflow: auto;
padding: 0 0 0 0.875em;
}
.note-to-mp .code-section code {
display: flex;
text-wrap: nowrap;
font-family: Consolas,Courier,monospace;
}
`
export default {name: '默认', className: 'obsidian-light', desc: '默认主题', author: 'SunBooshi', css:css};

66
src/doc-modal.ts Normal file
View File

@@ -0,0 +1,66 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { App, Modal, sanitizeHTMLToDom } from "obsidian";
export class DocModal extends Modal {
url: string = '';
title: string = '提示';
content: string = '';
constructor(app: App, title: string = "提示", content: string = "", url: string = "") {
super(app);
this.title = title;
this.content = content;
this.url = url;
}
onOpen() {
let { contentEl, modalEl } = this;
modalEl.style.width = '640px';
modalEl.style.height = '720px';
contentEl.style.display = 'flex';
contentEl.style.flexDirection = 'column';
const titleEl = contentEl.createEl('h2', { text: this.title });
titleEl.style.marginTop = '0.5em';
const content = contentEl.createEl('div');
content.setAttr('style', 'margin-bottom:1em;-webkit-user-select: text; user-select: text;');
content.appendChild(sanitizeHTMLToDom(this.content));
const iframe = contentEl.createEl('iframe', {
attr: {
src: this.url,
width: '100%',
allow: 'clipboard-read; clipboard-write',
},
});
iframe.style.flex = '1';
}
onClose() {
let { contentEl } = this;
contentEl.empty();
}
}

80
src/expert-settings.ts Normal file
View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { parseYaml } from "obsidian";
export interface ExpertSettings {
render?: {
h1?: string | number | object;
h2?: string | number | object;
h3?: string | number | object;
code?: number;
callout?: object | undefined;
},
frontmatter: {
title: string;
author: string;
digest: string;
content_source_url: string;
cover: string;
thumb_media_id: string
need_open_comment: string;
only_fans_can_comment: string;
appid: string;
theme: string;
highlight: string;
crop: string;
}
}
export const defaultExpertSettings: ExpertSettings = {
render: undefined,
frontmatter: {
title: '标题',
author: '作者',
digest: '摘要',
content_source_url: '原文地址',
cover: '封面',
thumb_media_id: '封面素材ID',
need_open_comment: '打开评论',
only_fans_can_comment: '仅粉丝可评论',
appid: '公众号',
theme: '样式',
highlight: '代码高亮',
crop: '封面裁剪',
}
};
export function expertSettingsFromString(content: string): ExpertSettings {
content = content.replace(/```yaml/gi, '').replace(/```/g, '');
let parsed = parseYaml(content) as Partial<ExpertSettings>;
if (!parsed || typeof parsed !== 'object') {
parsed = {};
}
return {
render: parsed.render,
frontmatter: {
...defaultExpertSettings.frontmatter,
...(parsed.frontmatter || {})
}
};
}

47
src/gallery/index.ts Normal file
View File

@@ -0,0 +1,47 @@
// [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 [];
}

64
src/image/index.ts Normal file
View File

@@ -0,0 +1,64 @@
// [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;
}

68
src/imagelib.ts Normal file
View File

@@ -0,0 +1,68 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { getBlobArrayBuffer } from "obsidian";
import { wxUploadImage } from "./weixin-api";
import { NMPSettings } from "./settings";
import { IsWasmReady, LoadWasm } from "./wasm/wasm";
import AssetsManager from "./assets";
declare function GoWebpToJPG(data: Uint8Array): Uint8Array;
declare function GoWebpToPNG(data: Uint8Array): Uint8Array;
declare function GoAddWatermark(img: Uint8Array, watermark: Uint8Array): Uint8Array;
export function IsImageLibReady() {
return IsWasmReady();
}
export async function PrepareImageLib() {
await LoadWasm();
}
export function WebpToJPG(data: ArrayBuffer): ArrayBuffer {
return GoWebpToJPG(new Uint8Array(data));
}
export function WebpToPNG(data: ArrayBuffer): ArrayBuffer {
return GoWebpToPNG(new Uint8Array(data));
}
export function AddWatermark(img: ArrayBuffer, watermark: ArrayBuffer): ArrayBuffer {
return GoAddWatermark(new Uint8Array(img), new Uint8Array(watermark));
}
export async function UploadImageToWx(data: Blob, filename: string, token: string, type?: string) {
if (!IsImageLibReady()) {
await PrepareImageLib();
}
const watermark = NMPSettings.getInstance().watermark;
if (watermark != null && watermark != '') {
const watermarkData = await AssetsManager.getInstance().readFileBinary(watermark);
if (watermarkData == null) {
throw new Error('水印图片不存在: ' + watermark);
}
const watermarkImg = AddWatermark(await data.arrayBuffer(), watermarkData);
data = new Blob([watermarkImg], { type: data.type });
}
return await wxUploadImage(data, filename, token, type);
}

207
src/inline-css.ts Normal file
View File

@@ -0,0 +1,207 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
// 需要渲染进inline style的css样式
export default `
/* --------------------------------------- */
/* callout */
/* --------------------------------------- */
section .note-callout {
border: none;
padding: 1em 1em 1em 1.5em;
display: flex;
flex-direction: column;
margin: 1em 0;
border-radius: 4px;
}
section .note-callout-title-wrap {
display: flex;
flex-direction: row;
align-items: center;
font-size: 1em;
font-weight: 600;
}
.note-callout-icon {
display: inline-block;
width: 18px;
height: 18px;
}
.note-callout-icon svg {
width: 100%;
height: 100%;
}
section .note-callout-title {
margin-left: 0.25em;
}
section .note-callout-content {
color: rgb(34,34,34);
}
/* note info todo */
section .note-callout-note {
color: rgb(8, 109, 221);
background-color: rgba(8, 109, 221, 0.1);
}
/* abstract tip hint */
section .note-callout-abstract {
color: rgb(0, 191, 188);
background-color: rgba(0, 191, 188, 0.1);
}
section .note-callout-success {
color: rgb(8, 185, 78);
background-color: rgba(8, 185, 78, 0.1);
}
/* question help, faq, warning, caution, attention */
section .note-callout-question {
color: rgb(236, 117, 0);
background-color: rgba(236, 117, 0, 0.1);
}
/* failure, fail, missing, danger, error, bug */
section .note-callout-failure {
color: rgb(233, 49, 71);
background-color: rgba(233, 49, 71, 0.1);
}
section .note-callout-example {
color: rgb(120, 82, 238);
background-color: rgba(120, 82, 238, 0.1);
}
section .note-callout-quote {
color: rgb(158, 158, 158);
background-color: rgba(158, 158, 158, 0.1);
}
/* custom icon callout */
section .note-callout-custom {
color: rgb(8, 109, 221);
background-color: rgba(8, 109, 221, 0.1);
}
/* --------------------------------------- */
/* math */
/* --------------------------------------- */
.block-math-svg {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-items: center;
margin:20px 0px;
max-width: 300% !important;
}
.block-math-section {
text-align: center;
overflow: auto;
}
/* --------------------------------------- */
/* 高亮 */
/* --------------------------------------- */
.note-highlight {
background-color: rgba(255,208,0, 0.4);
}
/* --------------------------------------- */
/* 列表需要强制设置样式*/
/* --------------------------------------- */
ul {
list-style-type: disc;
}
.note-svg-icon {
min-width: 24px;
height: 24px;
display: inline-block;
}
.note-svg-icon svg {
width: 100%;
height: 100%;
}
.note-embed-excalidraw-left {
display: flex;
flex-direction: row;
width: 100%;
}
.note-embed-excalidraw-center {
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
}
.note-embed-excalidraw-right {
display: flex;
flex-direction: row;
justify-content: flex-end;
width: 100%;
}
.note-embed-excalidraw {
display: inline-block;
}
.note-embed-excalidraw p {
line-height: 0 !important;
margin: 0 !important;
}
/*
.note-embed-excalidraw svg {
width: 100%;
height: 100%;
}
*/
.note-embed-svg-left {
display: flex;
flex-direction: row;
justify-content: flex-start;
width: 100%;
}
.note-embed-svg-center {
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
}
.note-embed-svg-right {
display: flex;
flex-direction: row;
justify-content: flex-end;
width: 100%;
}
.note-embed-svg svg {
width: 100%;
height: 100%;
}
`;

153
src/main.ts Normal file
View File

@@ -0,0 +1,153 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Plugin, WorkspaceLeaf, App, PluginManifest, Menu, Notice, TAbstractFile, TFile, TFolder } from 'obsidian';
import { NotePreview, VIEW_TYPE_NOTE_PREVIEW } from './note-preview';
import { NMPSettings } from './settings';
import { NoteToMpSettingTab } from './setting-tab';
import AssetsManager from './assets';
import { setVersion, uevent } from './utils';
import { WidgetsModal } from './widgets-modal';
export default class NoteToMpPlugin extends Plugin {
settings: NMPSettings;
assetsManager: AssetsManager;
constructor(app: App, manifest: PluginManifest) {
super(app, manifest);
AssetsManager.setup(app, manifest);
this.assetsManager = AssetsManager.getInstance();
}
async loadResource() {
await this.loadSettings();
await this.assetsManager.loadAssets();
}
async onload() {
console.log('Loading NoteToMP');
setVersion(this.manifest.version);
uevent('load');
this.app.workspace.onLayoutReady(()=>{
this.loadResource();
})
this.registerView(
VIEW_TYPE_NOTE_PREVIEW,
(leaf) => new NotePreview(leaf, this)
);
const ribbonIconEl = this.addRibbonIcon('clipboard-paste', '复制到公众号', (evt: MouseEvent) => {
this.activateView();
});
ribbonIconEl.addClass('note-to-mp-plugin-ribbon-class');
this.addCommand({
id: 'note-to-mp-preview',
name: '复制到公众号',
callback: () => {
this.activateView();
}
});
this.addSettingTab(new NoteToMpSettingTab(this.app, this));
this.addCommand({
id: 'note-to-mp-widget',
name: '插入样式小部件',
callback: () => {
new WidgetsModal(this.app).open();
}
});
this.addCommand({
id: 'note-to-mp-pub',
name: '发布公众号文章',
callback: async () => {
await this.activateView();
this.getNotePreview()?.postArticle();
}
});
// 监听右键菜单
this.registerEvent(
this.app.workspace.on('file-menu', (menu, file) => {
menu.addItem((item) => {
item
.setTitle('发布到公众号')
.setIcon('lucide-send')
.onClick(async () => {
if (file instanceof TFile) {
if (file.extension.toLowerCase() !== 'md') {
new Notice('只能发布 Markdown 文件');
return;
}
await this.activateView();
await this.getNotePreview()?.renderMarkdown(file);
await this.getNotePreview()?.postArticle();
} else if (file instanceof TFolder) {
await this.activateView();
await this.getNotePreview()?.batchPost(file);
}
});
});
})
);
}
onunload() {
}
async loadSettings() {
NMPSettings.loadSettings(await this.loadData());
}
async saveSettings() {
await this.saveData(NMPSettings.allSettings());
}
async activateView() {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_NOTE_PREVIEW);
if (leaves.length > 0) {
leaf = leaves[0];
} else {
leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: VIEW_TYPE_NOTE_PREVIEW, active: false });
}
if (leaf) workspace.revealLeaf(leaf);
}
getNotePreview(): NotePreview | null {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_NOTE_PREVIEW);
if (leaves.length > 0) {
const leaf = leaves[0];
return leaf.view as NotePreview;
}
return null;
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Tokens, MarkedExtension } from "marked";
import { Extension, MDRendererCallback } from "./extension";
import { NMPSettings } from "src/settings";
import { App, Vault } from "obsidian";
import AssetsManager from "../assets";
import { CalloutRenderer } from "./callouts";
import { WidgetBox } from "./widget-box";
export class Blockquote extends Extension {
callout: CalloutRenderer;
box: WidgetBox;
constructor(app: App, settings: NMPSettings, assetsManager: AssetsManager, callback: MDRendererCallback) {
super(app, settings, assetsManager, callback);
this.callout = new CalloutRenderer(app, settings, assetsManager, callback);
if (settings.isAuthKeyVaild()) {
this.box = new WidgetBox(app, settings, assetsManager, callback);
}
}
async prepare() {
if (!this.marked) {
console.error("marked is not ready");
return;
}
if (this.callout) this.callout.marked = this.marked;
if (this.box) this.box.marked = this.marked;
return;
}
async renderer(token: Tokens.Blockquote) {
if (this.callout.matched(token.text)) {
return await this.callout.renderer(token);
}
if (this.box && this.box.matched(token.text)) {
return await this.box.renderer(token);
}
const body = this.marked.parser(token.tokens);
return `<blockquote>${body}</blockquote>`;
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'blockquote') {
return;
}
token.html = await this.renderer(token as Tokens.Blockquote);
},
extensions: [{
name: 'blockquote',
level: 'block',
renderer: (token: Tokens.Generic) => {
return token.html;
},
}]
}
}
}

275
src/markdown/callouts.ts Normal file
View File

@@ -0,0 +1,275 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Tokens, MarkedExtension} from "marked";
import { Extension } from "./extension";
import AssetsManager from "src/assets";
import { wxWidget } from "src/weixin-api";
const icon_note = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-pencil"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path><path d="m15 5 4 4"></path></svg>`
const icon_abstract = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-clipboard-list"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>`
const icon_info = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-info"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg>`
const icon_todo = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-check-circle-2"><circle cx="12" cy="12" r="10"></circle><path d="m9 12 2 2 4-4"></path></svg>`
const icon_tip = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-flame"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg>`
const icon_success = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-check"><path d="M20 6 9 17l-5-5"></path></svg>`
const icon_question = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><path d="M12 17h.01"></path></svg>`
const icon_warning = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-alert-triangle"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>`
const icon_failure = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-x"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>`
const icon_danger = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-zap"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`
const icon_bug = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-bug"><path d="m8 2 1.88 1.88"></path><path d="M14.12 3.88 16 2"></path><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"></path><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"></path><path d="M12 20v-9"></path><path d="M6.53 9C4.6 8.8 3 7.1 3 5"></path><path d="M6 13H2"></path><path d="M3 21c0-2.1 1.7-3.9 3.8-4"></path><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"></path><path d="M22 13h-4"></path><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"></path></svg>`
const icon_example = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-list"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`
const icon_quote = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-quote"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>`
/*
note,
abstract, summary, tldr
info
todo
tip
hint, important
success, check, done
question, help, faq
warning, caution, attention
failure, fail, missing
danger, error
bug
example
quote, cite
*/
type CalloutInfo = {icon: string, style: string}
const CalloutTypes = new Map<string, CalloutInfo>(Object.entries({
note: {
icon: icon_note,
style: 'note-callout-note',
},
abstract: {
icon: icon_abstract,
style: 'note-callout-abstract',
},
summary: {
icon: icon_abstract,
style: 'note-callout-abstract',
},
tldr: {
icon: icon_abstract,
style: 'note-callout-abstract',
},
info: {
icon: icon_info,
style: 'note-callout-note',
},
todo: {
icon: icon_todo,
style: 'note-callout-note',
},
tip: {
icon: icon_tip,
style: 'note-callout-abstract',
},
hint: {
icon: icon_tip,
style: 'note-callout-abstract',
},
important: {
icon: icon_tip,
style: 'note-callout-abstract',
},
success: {
icon: icon_success,
style: 'note-callout-success',
},
check: {
icon: icon_success,
style: 'note-callout-success',
},
done: {
icon: icon_success,
style: 'note-callout-success',
},
question: {
icon: icon_question,
style: 'note-callout-question',
},
help: {
icon: icon_question,
style: 'note-callout-question',
},
faq: {
icon: icon_question,
style: 'note-callout-question',
},
warning: {
icon: icon_warning,
style: 'note-callout-question',
},
caution: {
icon: icon_warning,
style: 'note-callout-question',
},
attention: {
icon: icon_warning,
style: 'note-callout-question',
},
failure: {
icon: icon_failure,
style: 'note-callout-failure',
},
fail: {
icon: icon_failure,
style: 'note-callout-failure',
},
missing: {
icon: icon_failure,
style: 'note-callout-failure',
},
danger: {
icon: icon_danger,
style: 'note-callout-failure',
},
error: {
icon: icon_danger,
style: 'note-callout-failure',
},
bug: {
icon: icon_bug,
style: 'note-callout-failure',
},
example: {
icon: icon_example,
style: 'note-callout-example',
},
quote: {
icon: icon_quote,
style: 'note-callout-quote',
},
cite: {
icon: icon_quote,
style: 'note-callout-quote',
}
}));
function GetCallout(type: string) {
return CalloutTypes.get(type);
};
function matchCallouts(text:string) {
const regex = /\[\!(.*?)\]/g;
let m;
if( m = regex.exec(text)) {
return m[1];
}
return "";
}
function GetCalloutTitle(callout:string, text:string) {
let title = callout.charAt(0).toUpperCase() + callout.slice(1).toLowerCase();
let start = text.indexOf(']') + 1;
if (text.indexOf(']-') > 0 || text.indexOf(']+') > 0) {
start = start + 1;
}
let end = text.indexOf('\n');
if (end === -1) end = text.length;
if (start >= end) return title;
const customTitle = text.slice(start, end).trim();
if (customTitle !== '') {
title = customTitle;
}
return title;
}
export class CalloutRenderer extends Extension {
matched(text: string) {
return matchCallouts(text) != '';
}
async renderer(token: Tokens.Blockquote) {
let callout = matchCallouts(token.text);
if (callout == '') {
const body = this.marked.parser(token.tokens);
return `<blockquote>${body}</blockquote>`;;
}
const title = GetCalloutTitle(callout, token.text);
const index = token.text.indexOf('\n');
let body = '';
if (index > 0) {
token.text = token.text.slice(index+1)
body = await this.marked.parse(token.text);
}
const setting = AssetsManager.getInstance().expertSettings.render?.callout as { [key: string]: any };
if (setting && callout.toLocaleLowerCase() in setting) {
const authkey = this.settings.authKey;
const widget = setting[callout.toLocaleLowerCase()];
if (typeof widget === 'number') {
return await wxWidget(authkey, JSON.stringify({
id: `${widget}`,
title,
content: body,
}));
}
if (typeof widget === 'object') {
const {id, style} = widget;
return await wxWidget(authkey, JSON.stringify({
id: `${id}`,
title,
style: style || {},
content: body,
}));
}
}
let info = GetCallout(callout.toLowerCase());
if (info == null) {
const svg = await this.assetsManager.loadIcon(callout);
if (svg) {
info = {icon: svg, style: 'note-callout-custom'}
}
else {
info = GetCallout('note');
}
}
return `<section class="note-callout ${info?.style}"><section class="note-callout-title-wrap"><span class="note-callout-icon">${info?.icon}</span><span class="note-callout-title">${title}<span></section><section class="note-callout-content">${body}</section></section>`;
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'blockquote') {
return;
}
token.html = await this.renderer(token as Tokens.Blockquote);
},
extensions:[{
name: 'blockquote',
level: 'block',
renderer: (token: Tokens.Generic)=> {
return token.html;
},
}]
}
}
}

287
src/markdown/code.ts Normal file
View File

@@ -0,0 +1,287 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Notice } from "obsidian";
import { MarkedExtension, Tokens } from "marked";
import hljs from "highlight.js";
import { MathRendererQueue } from "./math";
import { Extension } from "./extension";
import { UploadImageToWx } from "../imagelib";
import AssetsManager from "src/assets";
import { wxWidget } from "src/weixin-api";
export class CardDataManager {
private cardData: Map<string, string>;
private static instance: CardDataManager;
private constructor() {
this.cardData = new Map<string, string>();
}
// 静态方法,用于获取实例
public static getInstance(): CardDataManager {
if (!CardDataManager.instance) {
CardDataManager.instance = new CardDataManager();
}
return CardDataManager.instance;
}
public setCardData(id: string, cardData: string) {
this.cardData.set(id, cardData);
}
public cleanup() {
this.cardData.clear();
}
public restoreCard(html: string) {
for (const [key, value] of this.cardData.entries()) {
const exp = `<section[^>]*\\sdata-id="${key}"[^>]*>(.*?)<\\/section>`;
const regex = new RegExp(exp, 'gs');
if (!regex.test(html)) {
console.warn('没有公众号信息:', key);
continue;
}
html = html.replace(regex, value);
}
return html;
}
}
const MermaidSectionClassName = 'note-mermaid';
const MermaidImgClassName = 'note-mermaid-img';
export class CodeRenderer extends Extension {
showLineNumber: boolean;
mermaidIndex: number;
async prepare() {
this.mermaidIndex = 0;
}
static srcToBlob(src: string) {
const base64 = src.split(',')[1];
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return new Blob([byteArray], { type: 'image/png' });
}
static async uploadMermaidImages(root: HTMLElement, token: string) {
const imgs = root.querySelectorAll('.' + MermaidImgClassName);
for (let img of imgs) {
const src = img.getAttribute('src');
if (!src) continue;
if (src.startsWith('http')) continue;
const blob = CodeRenderer.srcToBlob(img.getAttribute('src')!);
const name = img.id + '.png';
const res = await UploadImageToWx(blob, name, token);
if (res.errcode != 0) {
const msg = `上传图片失败: ${res.errcode} ${res.errmsg}`;
new Notice(msg);
console.error(msg);
continue;
}
const url = res.url;
img.setAttribute('src', url);
}
}
replaceSpaces(text: string) {
let result = '';
let inTag = false;
for (let char of text) {
if (char === '<') {
inTag = true;
result += char;
continue;
} else if (char === '>') {
inTag = false;
result += char;
continue;
}
if (inTag) {
result += char;
} else {
if (char === ' ') {
result += '&nbsp;';
} else if (char === '\t') {
result += '&nbsp;&nbsp;&nbsp;&nbsp;';
} else {
result += char;
}
}
}
return result;
}
async codeRenderer(code: string, infostring: string | undefined) {
const lang = (infostring || '').match(/^\S*/)?.[0];
code = code.replace(/\n$/, '');
try {
if (lang && hljs.getLanguage(lang)) {
code = hljs.highlight(code, { language: lang }).value;
}
else {
code = hljs.highlightAuto(code).value;
}
} catch (err) {
console.error(err);
}
code = this.replaceSpaces(code);
const lines = code.split('\n');
let body = '';
let liItems = '';
for (let line in lines) {
let text = lines[line];
if (text.length === 0) {
text = '<br>'
}
body = body + '<code>' + text + '</code>';
liItems = liItems + `<li>${parseInt(line)+1}</li>`;
}
let codeSection = '<section class="code-section code-snippet__fix hljs">';
if (this.settings.lineNumber) {
codeSection = codeSection + '<ul>'
+ liItems
+ '</ul>';
}
let html = '';
if (lang) {
html = codeSection + '<pre style="max-width:1000% !important;" class="hljs language-'
+ lang
+ '">'
+ body
+ '</pre></section>';
}
else {
html = codeSection + '<pre>'
+ body
+ '</pre></section>';
}
if (!this.settings.isAuthKeyVaild()) {
return html;
}
const settings = AssetsManager.getInstance().expertSettings;
const id = settings.render?.code;
if (id && typeof id === 'number') {
const params = JSON.stringify({
id: `${id}`,
content: html,
});
html = await wxWidget(this.settings.authKey, params);
}
return html;
}
static getMathType(lang: string | null) {
if (!lang) return null;
let l = lang.toLowerCase();
l = l.trim();
if (l === 'am' || l === 'asciimath') return 'asciimath';
if (l === 'latex' || l === 'tex') return 'latex';
return null;
}
parseCard(htmlString: string) {
const id = /data-id="([^"]+)"/;
const headimgRegex = /data-headimg="([^"]+)"/;
const nicknameRegex = /data-nickname="([^"]+)"/;
const signatureRegex = /data-signature="([^"]+)"/;
const idMatch = htmlString.match(id);
const headimgMatch = htmlString.match(headimgRegex);
const nicknameMatch = htmlString.match(nicknameRegex);
const signatureMatch = htmlString.match(signatureRegex);
return {
id: idMatch ? idMatch[1] : '',
headimg: headimgMatch ? headimgMatch[1] : '',
nickname: nicknameMatch ? nicknameMatch[1] : '公众号名称',
signature: signatureMatch ? signatureMatch[1] : '公众号介绍'
};
}
renderCard(token: Tokens.Code) {
const { id, headimg, nickname, signature } = this.parseCard(token.text);
if (id === '') {
return '<span>公众号卡片数据错误没有id</span>';
}
CardDataManager.getInstance().setCardData(id, token.text);
return `<section data-id="${id}" class="note-mpcard-wrapper"><div class="note-mpcard-content"><img class="note-mpcard-headimg" width="54" height="54" src="${headimg}"></img><div class="note-mpcard-info"><div class="note-mpcard-nickname">${nickname}</div><div class="note-mpcard-signature">${signature}</div></div></div><div class="note-mpcard-foot">公众号</div></section>`;
}
renderMermaid(token: Tokens.Code) {
try {
const meraidIndex = this.mermaidIndex;
const containerId = `mermaid-${meraidIndex}`;
this.callback.cacheElement('mermaid', containerId, token.raw);
this.mermaidIndex += 1;
return `<section id="${containerId}" class="${MermaidSectionClassName}"></section>`;
} catch (error) {
console.error(error.message);
return '<span>mermaid渲染失败</span>';
}
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'code') return;
if (this.settings.isAuthKeyVaild()) {
const type = CodeRenderer.getMathType(token.lang ?? '');
if (type) {
token.html = await MathRendererQueue.getInstance().render(token, false, type);
return;
}
if (token.lang && token.lang.trim().toLocaleLowerCase() == 'mermaid') {
token.html = this.renderMermaid(token as Tokens.Code);
return;
}
}
if (token.lang && token.lang.trim().toLocaleLowerCase() == 'mpcard') {
token.html = this.renderCard(token as Tokens.Code);
return;
}
token.html = await this.codeRenderer(token.text, token.lang);
},
extensions: [{
name: 'code',
level: 'block',
renderer: (token: Tokens.Generic) => {
return token.html;
},
}]
}
}
}

78
src/markdown/commnet.ts Normal file
View File

@@ -0,0 +1,78 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
const commentRegex = /^%%([\s\S]*?)%%/;
export class Comment extends Extension {
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'CommentInline',
level: 'inline',
start(src: string) {
let index;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('%%');
if (index === -1) return;
return index;
}
},
tokenizer(src: string) {
const match = src.match(commentRegex);
if (match) {
return {
type: 'CommentInline',
raw: match[0],
text: match[1],
};
}
},
renderer(token: Tokens.Generic) {
return '';
}
},
{
name: 'CommentBlock',
level: 'block',
tokenizer(src: string) {
const match = src.match(commentRegex);
if (match) {
return {
type: 'CommentBlock',
raw: match[0],
text: match[1],
};
}
},
renderer(token: Tokens.Generic) {
return '';
}
},
]
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
const BlockMarkRegex = /^\^[0-9A-Za-z-]+$/;
export class EmbedBlockMark extends Extension {
allLinks:string[] = [];
async prepare() {
this.allLinks = [];
}
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'EmbedBlockMark',
level: 'inline',
start(src: string) {
let index = src.indexOf('^');
if (index === -1) {
return;
}
return index;
},
tokenizer(src: string) {
const match = src.match(BlockMarkRegex);
if (match) {
return {
type: 'EmbedBlockMark',
raw: match[0],
text: match[0]
};
}
},
renderer: (token: Tokens.Generic) => {
return `<span data-txt="${token.text}"></span}`;
}
}]
}
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
export class EmptyLineRenderer extends Extension {
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'emptyline',
level: 'block',
tokenizer(src: string) {
const match = /^\n\n+/.exec(src);
if (match) {
console.log('mathced src: ', src)
return {
type: "emptyline",
raw: match[0],
};
}
},
renderer: (token: Tokens.Generic) => {
return '<p><br></p>'.repeat(token.raw.length - 1);
},
}]
}
}
}

55
src/markdown/extension.ts Normal file
View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { NMPSettings } from "src/settings";
import { Marked, MarkedExtension } from "marked";
import { App, Vault } from "obsidian";
import AssetsManager from "../assets";
export interface MDRendererCallback {
settings: NMPSettings;
updateElementByID(id:string, html:string):void; // 改为异步渲染后已废弃
cacheElement(category: string, id: string, data: string): void;
}
export abstract class Extension {
app: App;
vault: Vault;
assetsManager: AssetsManager
settings: NMPSettings;
callback: MDRendererCallback;
marked: Marked;
constructor(app: App, settings: NMPSettings, assetsManager: AssetsManager, callback: MDRendererCallback) {
this.app = app;
this.vault = app.vault;
this.settings = settings;
this.assetsManager = assetsManager;
this.callback = callback;
}
async prepare() { return; }
async postprocess(html:string) { return html; }
async beforePublish() { }
async cleanup() { return; }
abstract markedExtension(): MarkedExtension
}

104
src/markdown/footnote.ts Normal file
View File

@@ -0,0 +1,104 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
const refRule = /^\[\^([^\]]+)\]/; // 匹配 [^label]
const defRule = /^ *\[\^([^\]]+)\]:/; // 匹配 [^label]:
export class FootnoteRenderer extends Extension {
allDefs: any[] = [];
defCounter = 0;
async prepare() {
this.allDefs = [];
this.defCounter = 0;
}
async postprocess(html: string) {
if (this.allDefs.length == 0) {
return html;
}
let body = '';
for (const def of this.allDefs) {
const {label, content} = def;
const html = await this.marked.parse(content);
const id = `fn-${label}`;
body += `<li id="${id}">${html}</li>`;
}
return html + `<section class="footnotes"><hr><ol>${body}</ol></section>`;
}
markedExtension(): MarkedExtension {
return {
extensions: [
{
name: 'FootnoteRef',
level: 'inline',
start(src) {
const index = src.indexOf('[^');
return index > 0 ? index : -1;
},
tokenizer: (src) => {
const match = src.match(refRule);
if (match) {
return {
type: 'FootnoteRef',
raw: match[0],
text: match[1],
};
}
},
renderer: (token: Tokens.Generic) => {
this.defCounter += 1;
const id = `fnref-${this.defCounter}`;
return `<sup id="${id}">${this.defCounter}</sup>`;
}
},
{
name: 'FootnoteDef',
level: 'block',
tokenizer: (src) => {
const match = src.match(defRule);
if (match) {
const label = match[1].trim();
const end = src.indexOf('\n');
const raw = end === -1 ? src: src.substring(0, end + 1);
const content = raw.substring(match[0].length);
this.allDefs.push({label, content});
return {
type: 'FootnoteDef',
raw: raw,
text: content,
};
}
},
renderer: (token: Tokens.Generic) => {
return '';
}
}
]
}
}
}

131
src/markdown/heading.ts Normal file
View File

@@ -0,0 +1,131 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
import AssetsManager from "src/assets";
import { ExpertSettings } from "src/expert-settings";
import { wxWidget } from "src/weixin-api";
export class HeadingRenderer extends Extension {
index = [0, 0, 0, 0];
expertSettings: ExpertSettings;
headingSettings: any[]
async prepare() {
this.index = [0, 0, 0, 0];
this.expertSettings = AssetsManager.getInstance().expertSettings;
this.headingSettings = [undefined, undefined, undefined, undefined];
if (!this.expertSettings.render) {
return;
}
if (this.expertSettings.render.h1) {
this.headingSettings[1] = this.expertSettings.render.h1;
}
if (this.expertSettings.render.h2) {
this.headingSettings[2] = this.expertSettings.render.h2;
}
if (this.expertSettings.render.h3) {
this.headingSettings[3] = this.expertSettings.render.h3;
}
}
async renderWithTemplate(token: Tokens.Heading, template: string) {
const content = await this.marked.parseInline(token.text);
return template.replace('{content}', content);
}
async renderWithWidgetId(token: Tokens.Heading, widgetId: number) {
const authkey = this.settings.authKey;
const content = await this.marked.parseInline(token.text);
const params = JSON.stringify({
id: `${widgetId}`,
title: content,
});
return await wxWidget(authkey, params);
}
async renderWithWidget(token: Tokens.Heading, widgetId: number, counter: boolean|undefined, len: number|undefined, style: object|undefined = undefined) {
const authkey = this.settings.authKey;
let title = token.text;
if (counter === undefined) {
counter = false;
}
if (len === undefined) {
len = 1;
}
if (style === undefined) {
style = new Map<string, string>();
}
if (counter) {
title = `${this.index[token.depth]}`;
if (title.length < len) {
title = title.padStart(len, '0');
}
}
const content = await this.marked.parseInline(token.text);
const params = JSON.stringify({
id: `${widgetId}`,
title,
style,
content: '<p>' + content + '</p>',
});
return await wxWidget(authkey, params);
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'heading') {
return;
}
const setting = this.headingSettings[token.depth];
this.index[token.depth] += 1;
if (setting) {
if (typeof setting === 'string') {
token.html = await this.renderWithTemplate(token as Tokens.Heading, setting);
}
else if (typeof setting === 'number') {
token.html = await this.renderWithWidgetId(token as Tokens.Heading, setting);
}
else {
const { id, counter, len, style } = setting;
token.html = await this.renderWithWidget(token as Tokens.Heading, id, counter, len, style);
}
return;
}
const body = await this.marked.parseInline(token.text);
token.html = `<h${token.depth}>${body}</h${token.depth}>`;
},
extensions: [{
name: 'heading',
level: 'block',
renderer: (token: Tokens.Generic) => {
return token.html;
},
}]
}
}
}

122
src/markdown/icons.ts Normal file
View File

@@ -0,0 +1,122 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
const iconsRegex = /^\[:(.*?):\]/;
export class SVGIcon extends Extension {
isNumeric(str: string): boolean {
return !isNaN(Number(str)) && str.trim() !== '';
}
getSize(size: string) {
const items = size.split('x');
let width, height;
if (items.length == 2) {
width = items[0];
height = items[1];
}
else {
width = items[0];
height = items[0];
}
width = this.isNumeric(width) ? width+'px' : width;
height = this.isNumeric(height) ? height+'px' : height;
return {width, height};
}
renderStyle(items: string[]) {
let size = '';
let color = '';
if (items.length == 3) {
size = items[1];
color = items[2];
}
else if (items.length == 2) {
if (items[1].startsWith('#')) {
color = items[1];
}
else {
size = items[1];
}
}
let style = '';
if (size.length > 0) {
const {width, height} = this.getSize(size);
style += `width:${width};height:${height};`;
}
if (color.length > 0) {
style += `color:${color};`;
}
return style.length > 0 ? `style="${style}"` : '';
}
async render(text: string) {
const items = text.split('|');
const name = items[0];
const svg = await this.assetsManager.loadIcon(name);
const body = svg==='' ? '未找到图标' + name : svg;
const style = this.renderStyle(items);
return `<span class="note-svg-icon" ${style}>${body}</span>`
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'SVGIcon') {
return;
}
token.html = await this.render(token.text);
},
extensions: [{
name: 'SVGIcon',
level: 'inline',
start(src: string) {
let index;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('[:');
if (index === -1) return;
return index;
}
},
tokenizer(src: string) {
const match = src.match(iconsRegex);
if (match) {
return {
type: 'SVGIcon',
raw: match[0],
text: match[1],
};
}
},
renderer(token: Tokens.Generic) {
return token.html;
}
}]
}
}
}

69
src/markdown/link.ts Normal file
View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
export class LinkRenderer extends Extension {
allLinks:string[] = [];
async prepare() {
this.allLinks = [];
}
async postprocess(html: string) {
if (this.settings.linkStyle !== 'footnote'
|| this.allLinks.length == 0) {
return html;
}
const links = this.allLinks.map((href, i) => {
return `<li>${href}&nbsp;↩</li>`;
});
return `${html}<seciton class="footnotes"><hr><ol>${links.join('')}</ol></section>`;
}
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'link',
level: 'inline',
renderer: (token: Tokens.Link) => {
if (token.href.startsWith('mailto:')) {
return token.text;
}
if (token.text.indexOf(token.href) === 0
|| (token.href.indexOf('https://mp.weixin.qq.com/mp') === 0)
|| (token.href.indexOf('https://mp.weixin.qq.com/s') === 0)) {
return `<a href="${token.href}">${token.text}</a>`;
}
this.allLinks.push(token.href);
if (this.settings.linkStyle == 'footnote') {
return `<a>${token.text}<sup>[${this.allLinks.length}]</sup></a>`;
}
else {
return `<a>${token.text}[${token.href}]</a>`;
}
}
}]
}
}
}

831
src/markdown/local-file.ts Normal file
View File

@@ -0,0 +1,831 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Token, Tokens, MarkedExtension } from "marked";
import { Notice, TAbstractFile, TFile, Vault, MarkdownView, requestUrl, Platform } from "obsidian";
import { Extension } from "./extension";
import { NMPSettings } from "../settings";
import { IsImageLibReady, PrepareImageLib, WebpToJPG, UploadImageToWx } from "../imagelib";
declare module 'obsidian' {
interface Vault {
config: {
attachmentFolderPath: string;
newLinkFormat: string;
useMarkdownLinks: boolean;
};
}
}
const LocalFileRegex = /^!\[\[(.*?)\]\]/;
interface ImageInfo {
resUrl: string;
filePath: string;
url: string | null;
media_id: string | null;
}
export class LocalImageManager {
private images: Map<string, ImageInfo>;
private static instance: LocalImageManager;
private constructor() {
this.images = new Map<string, ImageInfo>();
}
// 静态方法,用于获取实例
public static getInstance(): LocalImageManager {
if (!LocalImageManager.instance) {
LocalImageManager.instance = new LocalImageManager();
}
return LocalImageManager.instance;
}
public setImage(path: string, info: ImageInfo): void {
if (!this.images.has(path)) {
this.images.set(path, info);
}
}
isWebp(file: TFile | string): boolean {
if (file instanceof TFile) {
return file.extension.toLowerCase() === 'webp';
}
const name = file.toLowerCase();
return name.endsWith('.webp');
}
async uploadLocalImage(token: string, vault: Vault, type: string = '') {
const keys = this.images.keys();
await PrepareImageLib();
const result = [];
for (let key of keys) {
const value = this.images.get(key);
if (value == null) continue;
if (value.url != null) continue;
const file = vault.getFileByPath(value.filePath);
if (file == null) continue;
let fileData = await vault.readBinary(file);
let name = file.name;
if (this.isWebp(file)) {
if (IsImageLibReady()) {
fileData = WebpToJPG(fileData);
name = name.toLowerCase().replace('.webp', '.jpg');
}
else {
console.error('wasm not ready for webp');
}
}
const res = await UploadImageToWx(new Blob([fileData]), name, token, type);
if (res.errcode != 0) {
const msg = `上传图片失败: ${res.errcode} ${res.errmsg}`;
new Notice(msg);
console.error(msg);
}
value.url = res.url;
value.media_id = res.media_id;
result.push(res);
}
return result;
}
checkImageExt(filename: string ): boolean {
const name = filename.toLowerCase();
if (name.endsWith('.jpg')
|| name.endsWith('.jpeg')
|| name.endsWith('.png')
|| name.endsWith('.gif')
|| name.endsWith('.bmp')
|| name.endsWith('.tiff')
|| name.endsWith('.svg')
|| name.endsWith('.webp')) {
return true;
}
return false;
}
getImageNameFromUrl(url: string, type: string): string {
try {
// 创建URL对象
const urlObj = new URL(url);
// 获取pathname部分
const pathname = urlObj.pathname;
// 获取最后一个/后的内容作为文件名
let filename = pathname.split('/').pop() || '';
filename = decodeURIComponent(filename);
if (!this.checkImageExt(filename)) {
filename = filename + this.getImageExt(type);
}
return filename;
} catch (e) {
// 如果URL解析失败尝试简单的字符串处理
const queryIndex = url.indexOf('?');
if (queryIndex !== -1) {
url = url.substring(0, queryIndex);
}
return url.split('/').pop() || '';
}
}
getImageExtFromBlob(blob: Blob): string {
// MIME类型到文件扩展名的映射
const mimeToExt: { [key: string]: string } = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/bmp': '.bmp',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/tiff': '.tiff'
};
// 获取MIME类型
const mimeType = blob.type.toLowerCase();
// 返回对应的扩展名,如果找不到则返回空字符串
return mimeToExt[mimeType] || '';
}
base64ToBlob(src: string) {
const items = src.split(',');
if (items.length != 2) {
throw new Error('base64格式错误');
}
const mineType = items[0].replace('data:', '');
const base64 = items[1];
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return {blob: new Blob([byteArray], { type: mineType }), ext: this.getImageExt(mineType)};
}
async uploadImageFromUrl(url: string, token: string, type: string = '') {
try {
const rep = await requestUrl(url);
await PrepareImageLib();
let data = rep.arrayBuffer;
let blob = new Blob([data]);
let filename = this.getImageNameFromUrl(url, rep.headers['content-type']);
if (filename == '' || filename == null) {
filename = 'remote_img' + this.getImageExtFromBlob(blob);
}
if (this.isWebp(filename)) {
if (IsImageLibReady()) {
data = WebpToJPG(data);
blob = new Blob([data]);
filename = filename.toLowerCase().replace('.webp', '.jpg');
}
else {
console.error('wasm not ready for webp');
}
}
return await UploadImageToWx(blob, filename, token, type);
}
catch (e) {
console.error(e);
throw new Error('上传图片失败:' + e.message + '|' + url);
}
}
getImageExt(type: string): string {
const mimeToExt: { [key: string]: string } = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/bmp': '.bmp',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/tiff': '.tiff'
};
return mimeToExt[type] || '.jpg';
}
getMimeType(ext: string): string {
const extToMime: { [key: string]: string } = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.tiff': 'image/tiff'
};
return extToMime[ext.toLowerCase()] || 'image/jpeg';
}
getImageInfos(root: HTMLElement) {
const images = root.getElementsByTagName('img');
const result = [];
for (let i = 0; i < images.length; i++) {
const img = images[i];
const res = this.images.get(img.src);
if (res) {
result.push(res);
}
}
return result;
}
async uploadRemoteImage(root: HTMLElement, token: string, type: string = '') {
const images = root.getElementsByTagName('img');
const result = [];
for (let i = 0; i < images.length; i++) {
const img = images[i];
if (img.src.includes('mmbiz.qpic.cn')) continue;
// 移动端本地图片不通过src上传
if (img.src.startsWith('http://localhost/') && Platform.isMobileApp) {
continue;
}
if (img.src.startsWith('http')) {
const res = await this.uploadImageFromUrl(img.src, token, type);
if (res.errcode != 0) {
const msg = `上传图片失败: ${img.src} ${res.errcode} ${res.errmsg}`;
new Notice(msg);
console.error(msg);
}
const info = {
resUrl: img.src,
filePath: "",
url: res.url,
media_id: res.media_id,
};
this.images.set(img.src, info);
result.push(res);
}
else if (img.src.startsWith('data:image/')) {
const {blob, ext} = this.base64ToBlob(img.src);
if (!img.id) {
img.id = `local-img-${i}`;
}
const name = img.id + ext;
const res = await UploadImageToWx(blob, name, token);
if (res.errcode != 0) {
const msg = `上传图片失败: ${res.errcode} ${res.errmsg}`;
new Notice(msg);
console.error(msg);
continue;
}
const info = {
resUrl: '#' + img.id,
filePath: "",
url: res.url,
media_id: res.media_id,
};
this.images.set('#' + img.id, info);
result.push(res);
}
}
return result;
}
replaceImages(root: HTMLElement) {
const images = root.getElementsByTagName('img');
for (let i = 0; i < images.length; i++) {
const img = images[i];
let value = this.images.get(img.src);
if (value == null) {
if (!img.id) {
console.error('miss image id, ' + img.src);
continue;
}
value = this.images.get('#' + img.id);
}
if (value == null) continue;
if (value.url == null) continue;
img.setAttribute('src', value.url);
}
}
arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
async localImagesToBase64(vault: Vault) {
const keys = this.images.keys();
const result = new Map<string, string>();
for (let key of keys) {
const value = this.images.get(key);
if (value == null) continue;
const file = vault.getFileByPath(value.filePath);
if (file == null) continue;
let fileData = await vault.readBinary(file);
const base64 = this.arrayBufferToBase64(fileData);
const mimeType = this.getMimeType(file.extension);
const data = `data:${mimeType};base64,${base64}`;
result.set(value.resUrl, data);
}
return result;
}
async downloadRemoteImage(url: string) {
try {
const rep = await requestUrl(url);
let data = rep.arrayBuffer;
let blob = new Blob([data]);
let ext = this.getImageExtFromBlob(blob);
if (ext == '' || ext == null) {
const filename = this.getImageNameFromUrl(url, rep.headers['content-type']);
ext = '.' + filename.split('.').pop() || 'jpg';
}
const base64 = this.arrayBufferToBase64(data);
const mimeType = this.getMimeType(ext);
return `data:${mimeType};base64,${base64}`;
}
catch (e) {
console.error(e);
return '';
}
}
async remoteImagesToBase64(root: HTMLElement) {
const images = root.getElementsByTagName('img');
const result = new Map<string, string>();
for (let i = 0; i < images.length; i++) {
const img = images[i];
if (!img.src.startsWith('http')) continue;
const base64 = await this.downloadRemoteImage(img.src);
if (base64 == '') continue;
result.set(img.src, base64);
}
return result;
}
async embleImages(root: HTMLElement, vault: Vault) {
const localImages = await this.localImagesToBase64(vault);
const remoteImages = await this.remoteImagesToBase64(root);
const result = root.cloneNode(true) as HTMLElement;
const images = result.getElementsByTagName('img');
for (let i = 0; i < images.length; i++) {
const img = images[i];
if (img.src.startsWith('http')) {
const base64 = remoteImages.get(img.src);
if (base64 != null) {
img.setAttribute('src', base64);
}
}
else {
const base64 = localImages.get(img.src);
if (base64 != null) {
img.setAttribute('src', base64);
}
}
}
return result.innerHTML;
}
async cleanup() {
this.images.clear();
}
}
export class LocalFile extends Extension{
index: number = 0;
public static fileCache: Map<string, string> = new Map<string, string>();
generateId() {
this.index += 1;
return `fid-${this.index}`;
}
getImagePath(path: string) {
const res = this.assetsManager.getResourcePath(path);
if (res == null) {
console.error('找不到文件:' + path);
return '';
}
const info = {
resUrl: res.resUrl,
filePath: res.filePath,
media_id: null,
url: null
};
LocalImageManager.getInstance().setImage(res.resUrl, info);
return res.resUrl;
}
isImage(file: string) {
file = file.toLowerCase();
return file.endsWith('.png')
|| file.endsWith('.jpg')
|| file.endsWith('.jpeg')
|| file.endsWith('.gif')
|| file.endsWith('.bmp')
|| file.endsWith('.webp');
}
parseImageLink(link: string) {
if (link.includes('|')) {
const parts = link.split('|');
const path = parts[0];
if (!this.isImage(path)) return null;
let width = null;
let height = null;
if (parts.length == 2) {
const size = parts[1].toLowerCase().split('x');
width = parseInt(size[0]);
if (size.length == 2 && size[1] != '') {
height = parseInt(size[1]);
}
}
return { path, width, height };
}
if (this.isImage(link)) {
return { path: link, width: null, height: null };
}
return null;
}
getHeaderLevel(line: string) {
const match = line.trimStart().match(/^#{1,6}/);
if (match) {
return match[0].length;
}
return 0;
}
async getFileContent(file: TAbstractFile, header: string | null, block: string | null) {
const content = await this.app.vault.adapter.read(file.path);
if (header == null && block == null) {
return content;
}
let result = '';
const lines = content.split('\n');
if (header) {
let level = 0;
let append = false;
for (let line of lines) {
if (append) {
if (level == this.getHeaderLevel(line)) {
break;
}
result += line + '\n';
continue;
}
if (!line.trim().startsWith('#')) continue;
const items = line.trim().split(' ');
if (items.length != 2) continue;
if (header.trim() != items[1].trim()) continue;
if (this.getHeaderLevel(line)) {
result += line + '\n';
level = this.getHeaderLevel(line);
append = true;
}
}
}
function isStructuredBlock(line: string) {
const trimmed = line.trim();
return trimmed.startsWith('-') || trimmed.startsWith('>') || trimmed.startsWith('|') || trimmed.match(/^\d+\./);
}
if (block) {
let stopAtEmpty = false;
let totalLen = 0;
let structured = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.indexOf(block) >= 0) {
result = line.replace(block, '').trim();
// 标记和结构化内容位于同一行的时候只返回当前的条目
if (isStructuredBlock(line)) {
break;
}
// 向上查找内容
for (let j = i - 1; j >= 0; j--) {
const l = lines[j];
if (l.startsWith('#')) {
break;
}
if (l.trim() == '') {
if (stopAtEmpty) break;
if (j < i - 1 && totalLen > 0) break;
stopAtEmpty = true;
result = l + '\n' + result;
continue;
}
else {
stopAtEmpty = true;
}
if (structured && !isStructuredBlock(l)) {
break;
}
if (totalLen === 0 && isStructuredBlock(l)) {
structured = true;
}
totalLen += result.length;
result = l + '\n' + result;
}
break;
}
}
}
return result;
}
parseFileLink(link: string) {
const info = link.split('|')[0];
const items = info.split('#');
let path = items[0];
let header = null;
let block = null;
if (items.length == 2) {
if (items[1].startsWith('^')) {
block = items[1];
} else {
header = items[1];
}
}
return { path, head: header, block };
}
async renderFile(link: string, id: string) {
let { path, head: header, block} = this.parseFileLink(link);
let file = null;
if (path === '') {
file = this.app.workspace.getActiveFile();
}
else {
if (!path.endsWith('.md')) {
path = path + '.md';
}
file = this.assetsManager.searchFile(path);
}
if (file == null) {
const msg = '找不到文件:' + path;
console.error(msg)
return msg;
}
let content = await this.getFileContent(file, header, block);
if (content.startsWith('---')) {
content = content.replace(/^(---)$.+?^(---)$.+?/ims, '');
}
const body = await this.marked.parse(content);
return body;
}
static async readBlob(src: string) {
return await fetch(src).then(response => response.blob())
}
static async getExcalidrawUrl(data: string) {
const url = 'https://obplugin.sunboshi.tech/math/excalidraw';
const req = await requestUrl({
url,
method: 'POST',
contentType: 'application/json',
headers: {
authkey: NMPSettings.getInstance().authKey
},
body: JSON.stringify({ data })
});
if (req.status != 200) {
console.error(req.status);
return null;
}
return req.json.url;
}
parseLinkStyle(link: string) {
let filename = '';
let style = 'style="width:100%;height:100%"';
let postion = 'left';
const postions = ['left', 'center', 'right'];
if (link.includes('|')) {
const items = link.split('|');
filename = items[0];
let size = '';
if (items.length == 2) {
if (postions.includes(items[1])) {
postion = items[1];
}
else {
size = items[1];
}
}
else if (items.length == 3) {
size = items[1];
if (postions.includes(items[1])) {
size = items[2];
postion = items[1];
}
else {
size = items[1];
postion = items[2];
}
}
if (size != '') {
const sizes = size.split('x');
if (sizes.length == 2) {
style = `style="width:${sizes[0]}px;height:${sizes[1]}px;"`
}
else {
style = `style="width:${sizes[0]}px;"`
}
}
}
else {
filename = link;
}
return { filename, style, postion };
}
parseExcalidrawLink(link: string) {
let classname = 'note-embed-excalidraw-left';
const postions = new Map<string, string>([
['left', 'note-embed-excalidraw-left'],
['center', 'note-embed-excalidraw-center'],
['right', 'note-embed-excalidraw-right']
])
let {filename, style, postion} = this.parseLinkStyle(link);
classname = postions.get(postion) || classname;
if(filename.endsWith('excalidraw') || filename.endsWith('excalidraw.md')) {
return { filename, style, classname };
}
return null;
}
static async renderExcalidraw(html: string) {
try {
const src = await this.getExcalidrawUrl(html);
let svg = '';
if (src === '') {
svg = '渲染失败';
console.log('Failed to get Excalidraw URL');
}
else {
const blob = await this.readBlob(src);
if (blob.type === 'image/svg+xml') {
svg = await blob.text();
}
else {
svg = '暂不支持' + blob.type;
}
}
return svg;
} catch (error) {
console.error(error.message);
return '渲染失败:' + error.message;
}
}
parseSVGLink(link: string) {
let classname = 'note-embed-svg-left';
const postions = new Map<string, string>([
['left', 'note-embed-svg-left'],
['center', 'note-embed-svg-center'],
['right', 'note-embed-svg-right']
])
let {filename, style, postion} = this.parseLinkStyle(link);
classname = postions.get(postion) || classname;
return { filename, style, classname };
}
async renderSVGFile(filename: string, id: string) {
const file = this.assetsManager.searchFile(filename);
if (file == null) {
const msg = '找不到文件:' + file;
console.error(msg)
return msg;
}
const content = await this.getFileContent(file, null, null);
LocalFile.fileCache.set(filename, content);
return content;
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'LocalImage') {
return;
}
// 渲染本地图片
let item = this.parseImageLink(token.href);
if (item) {
const src = this.getImagePath(item.path);
const width = item.width ? `width="${item.width}"` : '';
const height = item.height? `height="${item.height}"` : '';
token.html = `<img src="${src}" alt="${token.text}" ${width} ${height} />`;
return;
}
const info = this.parseExcalidrawLink(token.href);
if (info) {
if (!NMPSettings.getInstance().isAuthKeyVaild()) {
token.html = "<span>请设置注册码</span>";
return;
}
const id = this.generateId();
this.callback.cacheElement('excalidraw', id, token.raw);
token.html = `<span class="${info.classname}"><span class="note-embed-excalidraw" id="${id}" ${info.style}></span></span>`
return;
}
if (token.href.endsWith('.svg') || token.href.includes('.svg|')) {
const info = this.parseSVGLink(token.href);
const id = this.generateId();
let svg = '渲染中';
if (LocalFile.fileCache.has(info.filename)) {
svg = LocalFile.fileCache.get(info.filename) || '渲染失败';
}
else {
svg = await this.renderSVGFile(info.filename, id) || '渲染失败';
}
token.html = `<span class="${info.classname}"><span class="note-embed-svg" id="${id}" ${info.style}>${svg}</span></span>`
return;
}
const id = this.generateId();
const content = await this.renderFile(token.href, id);
const tag = this.callback.settings.embedStyle === 'quote' ? 'blockquote' : 'section';
token.html = `<${tag} class="note-embed-file" id="${id}">${content}</${tag}>`
},
extensions:[{
name: 'LocalImage',
level: 'block',
start: (src: string) => {
const index = src.indexOf('![[');
if (index === -1) return;
return index;
},
tokenizer: (src: string) => {
const matches = src.match(LocalFileRegex);
if (matches == null) return;
const token: Token = {
type: 'LocalImage',
raw: matches[0],
href: matches[1],
text: matches[1]
};
return token;
},
renderer: (token: Tokens.Generic) => {
return token.html;
}
}]};
}
}

208
src/markdown/math.ts Normal file
View File

@@ -0,0 +1,208 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { MarkedExtension, Token, Tokens } from "marked";
import { requestUrl } from "obsidian";
import { Extension } from "./extension";
import { NMPSettings } from "src/settings";
const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n\$]))\1/;
const blockRule = /^(\${1,2})\n((?:\\[^]|[^\\])+?)\n\1(?:\n|$)/;
const svgCache = new Map<string, string>();
export function cleanMathCache() {
svgCache.clear();
}
export class MathRendererQueue {
private host = 'https://obplugin.sunboshi.tech';
private static instance: MathRendererQueue;
private mathIndex: number = 0;
// 静态方法,用于获取实例
public static getInstance(): MathRendererQueue {
if (!MathRendererQueue.instance) {
MathRendererQueue.instance = new MathRendererQueue();
}
return MathRendererQueue.instance;
}
private constructor() {
}
async getMathSVG(expression: string, inline: boolean, type: string) {
try {
let success = false;
let path = '';
if (type === 'asciimath') {
path = '/math/am';
}
else {
path = '/math/tex';
}
const url = `${this.host}${path}`;
const res = await requestUrl({
url,
method: 'POST',
contentType: 'application/json',
headers: {
authkey: NMPSettings.getInstance().authKey
},
body: JSON.stringify({
expression,
inline
})
})
let svg = ''
if (res.status === 200) {
svg = res.text;
success = true;
}
else {
console.error('render error: ' + res.json.msg)
svg = '渲染失败: ' + res.json.msg;
}
return { svg, success };
}
catch (err) {
console.log(err.msg);
const svg = '渲染失败: ' + err.message;
return { svg, success: false };
}
}
generateId() {
this.mathIndex += 1;
return `math-id-${this.mathIndex}`;
}
async render(token: Tokens.Generic, inline: boolean, type: string) {
if (!NMPSettings.getInstance().isAuthKeyVaild()) {
return '<span>注册码无效或已过期</span>';
}
const id = this.generateId();
let svg = '渲染中';
const expression = token.text;
if (svgCache.has(token.text)) {
svg = svgCache.get(expression) as string;
}
else {
const res = await this.getMathSVG(expression, inline, type)
if (res.success) {
svgCache.set(expression, res.svg);
}
svg = res.svg;
}
const className = inline ? 'inline-math-svg' : 'block-math-svg';
const body = inline ? svg : `<section class="block-math-section">${svg}</section>`;
return `<span id="${id}" class="${className}">${body}</span>`;
}
}
export class MathRenderer extends Extension {
async renderer(token: Tokens.Generic, inline: boolean, type: string = '') {
if (type === '') {
type = this.settings.math;
}
return await MathRendererQueue.getInstance().render(token, inline, type);
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type === 'InlineMath' || token.type === 'BlockMath') {
token.html = await this.renderer(token, token.type === 'InlineMath', token.displayMode ? 'latex' : 'asciimath');
}
},
extensions: [
this.inlineMath(),
this.blockMath()
]
}
}
inlineMath() {
return {
name: 'InlineMath',
level: 'inline',
start(src: string) {
let index;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('$');
if (index === -1) {
return;
}
const possibleKatex = indexSrc.substring(index);
if (possibleKatex.match(inlineRule)) {
return index;
}
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, '');
}
},
tokenizer(src: string, tokens: Token[]) {
const match = src.match(inlineRule);
if (match) {
return {
type: 'InlineMath',
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2
};
}
},
renderer: (token: Tokens.Generic) => {
return token.html;
}
}
}
blockMath() {
return {
name: 'BlockMath',
level: 'block',
tokenizer(src: string) {
const match = src.match(blockRule);
if (match) {
return {
type: 'BlockMath',
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2
};
}
},
renderer: (token: Tokens.Generic) => {
return token.html;
}
};
}
}

167
src/markdown/parser.ts Normal file
View File

@@ -0,0 +1,167 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Marked } from "marked";
import { NMPSettings } from "src/settings";
import { App, Vault } from "obsidian";
import AssetsManager from "../assets";
import { Extension, MDRendererCallback } from "./extension";
import { Blockquote} from "./blockquote";
import { CodeRenderer } from "./code";
import { EmbedBlockMark } from "./embed-block-mark";
import { SVGIcon } from "./icons";
import { LinkRenderer } from "./link";
import { LocalFile, LocalImageManager } from "./local-file";
import { MathRenderer } from "./math";
import { TextHighlight } from "./text-highlight";
import { Comment } from "./commnet";
import { Topic } from "./topic";
import { HeadingRenderer } from "./heading";
import { FootnoteRenderer } from "./footnote";
import { EmptyLineRenderer } from "./empty-line";
import { cleanUrl } from "../utils";
const markedOptiones = {
gfm: true,
breaks: true,
};
const customRenderer = {
hr(): string {
return '<hr>';
},
list(body: string, ordered: boolean, start: number | ''): string {
const type = ordered ? 'ol' : 'ul';
const startatt = (ordered && start !== 1) ? (' start="' + start + '"') : '';
return '<' + type + startatt + ' class="list-paddingleft-1">' + body + '</' + type + '>';
},
listitem(text: string, task: boolean, checked: boolean): string {
return `<li><section><span data-leaf="">${text}<span></section></li>`;
},
image(href: string, title: string | null, text: string): string {
const cleanHref = cleanUrl(href);
if (cleanHref === null) {
return text;
}
href = cleanHref;
if (!href.startsWith('http')) {
const res = AssetsManager.getInstance().getResourcePath(decodeURI(href));
if (res) {
href = res.resUrl;
const info = {
resUrl: res.resUrl,
filePath: res.filePath,
media_id: null,
url: null
};
LocalImageManager.getInstance().setImage(res.resUrl, info);
}
}
let out = '';
if (NMPSettings.getInstance().useFigcaption) {
out = `<figure style="display: flex; flex-direction: column; align-items: center;"><img src="${href}" alt="${text}"`;
if (title) {
out += ` title="${title}"`;
}
if (text.length > 0) {
out += `><figcaption>${text}</figcaption></figure>`;
}
else {
out += '></figure>'
}
}
else {
out = `<img src="${href}" alt="${text}"`;
if (title) {
out += ` title="${title}"`;
}
out += '>';
}
return out;
}
};
export class MarkedParser {
extensions: Extension[] = [];
marked: Marked;
app: App;
vault: Vault;
constructor(app: App, callback: MDRendererCallback) {
this.app = app;
this.vault = app.vault;
const settings = NMPSettings.getInstance();
const assetsManager = AssetsManager.getInstance();
this.extensions.push(new LocalFile(app, settings, assetsManager, callback));
this.extensions.push(new Blockquote(app, settings, assetsManager, callback));
this.extensions.push(new EmbedBlockMark(app, settings, assetsManager, callback));
this.extensions.push(new SVGIcon(app, settings, assetsManager, callback));
this.extensions.push(new LinkRenderer(app, settings, assetsManager, callback));
this.extensions.push(new TextHighlight(app, settings, assetsManager, callback));
this.extensions.push(new CodeRenderer(app, settings, assetsManager, callback));
this.extensions.push(new Comment(app, settings, assetsManager, callback));
this.extensions.push(new Topic(app, settings, assetsManager, callback));
this.extensions.push(new HeadingRenderer(app, settings, assetsManager, callback));
this.extensions.push(new FootnoteRenderer(app, settings, assetsManager, callback));
if (settings.enableEmptyLine) {
this.extensions.push(new EmptyLineRenderer(app, settings, assetsManager, callback));
}
if (settings.isAuthKeyVaild()) {
this.extensions.push(new MathRenderer(app, settings, assetsManager, callback));
}
}
async buildMarked() {
this.marked = new Marked();
this.marked.use(markedOptiones);
for (const ext of this.extensions) {
this.marked.use(ext.markedExtension());
ext.marked = this.marked;
await ext.prepare();
}
this.marked.use({renderer: customRenderer});
}
async prepare() {
this.extensions.forEach(async ext => await ext.prepare());
}
async postprocess(html: string) {
let result = html;
for (let ext of this.extensions) {
result = await ext.postprocess(result);
}
return result;
}
async parse(content: string) {
if (!this.marked) await this.buildMarked();
await this.prepare();
let html = await this.marked.parse(content);
html = await this.postprocess(html);
return html;
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Token, Tokens, Lexer, MarkedExtension } from "marked";
import { Extension } from "./extension";
const highlightRegex = /^==(.*?)==/;
export class TextHighlight extends Extension {
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'InlineHighlight',
level: 'inline',
start(src: string) {
let index;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('==');
if (index === -1) return;
return index;
}
},
tokenizer(src: string, tokens: Token[]) {
const match = src.match(highlightRegex);
if (match) {
return {
type: 'InlineHighlight',
raw: match[0],
text: match[1],
};
}
},
renderer(token: Tokens.Generic) {
const lexer = new Lexer();
const tokens = lexer.lex(token.text);
// TODO: 优化一下
let body = this.parser.parse(tokens)
body = body.replace('<p>', '')
body = body.replace('</p>', '')
return `<span class="note-highlight">${body}</span>`;
}
}]
};
}
}

61
src/markdown/topic.ts Normal file
View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
const topicRegex = /^#([^\s#]+)/;
export class Topic extends Extension {
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'Topic',
level: 'inline',
start(src: string) {
let index;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('#');
if (index === -1) return;
return index;
}
},
tokenizer(src: string) {
const match = src.match(topicRegex);
if (match) {
return {
type: 'Topic',
raw: match[0],
text: match[1],
};
}
},
renderer(token: Tokens.Generic) {
return `<a class="wx_topic_link" style="color: #576B95 !important;" data-topic="1">${'#' + token.text.trim()}</a>`;
}
},
]
}
}
}

179
src/markdown/widget-box.ts Normal file
View File

@@ -0,0 +1,179 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
import { NMPSettings } from "src/settings";
import { uevent } from "src/utils";
import { wxWidget } from "src/weixin-api";
const widgetCache = new Map<string, string>();
export function cleanWidgetCache() {
widgetCache.clear();
}
export class WidgetBox extends Extension {
mapToString(map: Map<string, string>): string {
if (map.size === 0) return "";
return Array.from(map.entries())
.map(([key, value]) => `${key}=${value}`)
.join("&"); // 用 "&" 连接键值对,可换成其他分隔符
}
calcKey(id: string, title: string, style: Map<string, string>, content: string) {
const styleStr = this.mapToString(style);
const key = `${id}-${title}-${styleStr}-${content}`;
return key;
}
cacheWidget(id: string, title: string, style: Map<string, string>, content: string, result: string) {
const key = this.calcKey(id, title, style, content);
widgetCache.set(key, result);
}
getWidget(id: string, title: string, style: Map<string, string>, content: string) {
const key = this.calcKey(id, title, style, content);
if (!widgetCache.has(key)) {
return null;
}
return widgetCache.get(key);
}
getBoxTitle(text: string) {
let start = text.indexOf(']') + 1;
let end = text.indexOf('\n');
if (end === -1) end = text.length;
if (start >= end) return '';
return text.slice(start, end).trim();
}
getBoxId(text: string) {
const regex = /\[#(.*?)\]/g;
let m;
if( m = regex.exec(text)) {
return m[1];
}
return "";
}
matched(text: string) {
return this.getBoxId(text) != "";
}
parseStyle(text: string) {
const style = text.split(':').map((s) => s.trim());
if (style.length != 2) return null;
const key = style[0];
const value = style[1];
return {key, value};
}
parseBox(text: string) {
const lines = text.split('\n');
let style = new Map<string, string>();
let content = [];
let isStyle = false;
for (let line of lines) {
if (line === '===') {
isStyle = !isStyle;
continue;
}
if (isStyle) {
const s = this.parseStyle(line);
if (s) style.set(s.key, s.value);
} else {
content.push(line);
}
}
const contentStr = content.join('\n');
return { style, contentStr };
}
async reqContent(id: string, title: string, style: Map<string, string>, content: string) {
const params = JSON.stringify({
id,
title,
style: Object.fromEntries(style),
content
});
return wxWidget(NMPSettings.getInstance().authKey, params)
}
processColor(style: Map<string, string>) {
const keys = style.keys();
for (let key of keys) {
if (key.includes('color')) {
const value = style.get(key);
if (!value) continue;
if (value.startsWith('rgb') || value.startsWith('#')) {
continue;
}
style.set(key, '#' + value);
}
}
}
async renderer(token: Tokens.Blockquote) {
let boxId = this.getBoxId(token.text);
if (boxId == '') {
const body = this.marked.parser(token.tokens);
return `<blockquote>${body}</blockquote>`;;
}
const title = this.getBoxTitle(token.text);
let style = new Map<string, string>();
let content = '';
const index = token.text.indexOf('\n');
if (index > 0) {
const pared = this.parseBox(token.text.slice(index + 1))
style = pared.style;
content = await this.marked.parse(pared.contentStr);
}
this.processColor(style);
const cached = this.getWidget(boxId, title, style, content);
if (cached) {
uevent('render-widgets-cached');
return cached;
}
else {
const reqContent = await this.reqContent(boxId, title, style, content);
this.cacheWidget(boxId, title, style, content, reqContent);
uevent('render-widgets');
return reqContent;
}
}
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'blockquote',
level: 'block',
renderer: (token: Tokens.Generic) => {
return token.html;
},
}]
}
}
}

76
src/meta/index.ts Normal file
View File

@@ -0,0 +1,76 @@
// [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 };
}

569
src/note-preview.ts Normal file
View File

@@ -0,0 +1,569 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
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';
import { LocalImageManager, LocalFile } from './markdown/local-file';
import { CardDataManager } from './markdown/code';
import { ArticleRender } from './article-render';
export const VIEW_TYPE_NOTE_PREVIEW = 'note-preview';
export class NotePreview extends ItemView {
workspace: Workspace;
plugin: Plugin;
mainDiv: HTMLDivElement;
toolbar: HTMLDivElement;
renderDiv: HTMLDivElement;
articleDiv: HTMLDivElement;
styleEl: HTMLElement;
coverEl: HTMLInputElement;
useDefaultCover: HTMLInputElement;
useLocalCover: HTMLInputElement;
msgView: HTMLDivElement;
wechatSelect: HTMLSelectElement;
themeSelect: HTMLSelectElement;
highlightSelect: HTMLSelectElement;
listeners?: EventRef[];
container: Element;
settings: NMPSettings;
assetsManager: AssetsManager;
articleHTML: string;
title: string;
currentFile?: TFile;
currentTheme: string;
currentHighlight: string;
currentAppId: string;
markedParser: MarkedParser;
cachedElements: Map<string, string> = new Map();
_articleRender: ArticleRender | null = null;
isCancelUpload: boolean = false;
isBatchRuning: boolean = false;
// [note-to-mp 重构] 新渲染服务实例与最近一次渲染结果
newRenderService: RenderService | null = null;
lastArticle?: RenderedArticle;
constructor(leaf: WorkspaceLeaf, plugin: Plugin) {
super(leaf);
this.workspace = this.app.workspace;
this.plugin = plugin;
this.settings = NMPSettings.getInstance();
this.assetsManager = AssetsManager.getInstance();
this.currentTheme = this.settings.defaultStyle;
this.currentHighlight = this.settings.defaultHighlight;
}
getViewType() {
return VIEW_TYPE_NOTE_PREVIEW;
}
getIcon() {
return 'clipboard-paste';
}
getDisplayText() {
return '笔记预览';
}
get render() {
if (!this._articleRender) {
this._articleRender = new ArticleRender(this.app, this, this.styleEl, this.articleDiv);
this._articleRender.currentTheme = this.currentTheme;
this._articleRender.currentHighlight = this.currentHighlight;
}
return this._articleRender;
}
async onOpen() {
this.viewLoading();
this.setup();
uevent('open');
}
async setup() {
await waitForLayoutReady(this.app);
if (!this.settings.isLoaded) {
const data = await this.plugin.loadData();
NMPSettings.loadSettings(data);
}
if (!this.assetsManager.isLoaded) {
await this.assetsManager.loadAssets();
}
this.buildUI();
// [note-to-mp 重构] 初始化新渲染服务
this.newRenderService = new RenderService(this.app);
this.listeners = [
this.workspace.on('file-open', () => {
this.update();
}),
this.app.vault.on("modify", (file) => {
if (this.currentFile?.path == file.path) {
this.renderMarkdown();
}
} )
];
this.renderMarkdown();
}
async onClose() {
this.listeners?.forEach(listener => this.workspace.offref(listener));
LocalFile.fileCache.clear();
uevent('close');
}
onAppIdChanged() {
// 清理上传过的图片
this.cleanArticleData();
}
async update() {
if (this.isBatchRuning) {
return;
}
this.cleanArticleData();
this.renderMarkdown();
}
cleanArticleData() {
LocalImageManager.getInstance().cleanup();
CardDataManager.getInstance().cleanup();
}
buildMsgView(parent: HTMLDivElement) {
this.msgView = parent.createDiv({ cls: 'msg-view' });
const title = this.msgView.createDiv({ cls: 'msg-title' });
title.id = 'msg-title';
title.innerText = '加载中...';
const okBtn = this.msgView.createEl('button', { cls: 'msg-ok-btn' }, async (button) => {
});
okBtn.id = 'msg-ok-btn';
okBtn.innerText = '确定';
okBtn.onclick = async () => {
this.msgView.setAttr('style', 'display: none;');
}
const cancelBtn = this.msgView.createEl('button', { cls: 'msg-ok-btn' }, async (button) => {
});
cancelBtn.id = 'msg-cancel-btn';
cancelBtn.innerText = '取消';
cancelBtn.onclick = async () => {
this.isCancelUpload = true;
this.msgView.setAttr('style', 'display: none;');
}
}
showLoading(msg: string, cancelable: boolean = false) {
const title = this.msgView.querySelector('#msg-title') as HTMLElement;
title!.innerText = msg;
const btn = this.msgView.querySelector('#msg-ok-btn') as HTMLElement;
btn.setAttr('style', 'display: none;');
this.msgView.setAttr('style', 'display: flex;');
const cancelBtn = this.msgView.querySelector('#msg-cancel-btn') as HTMLElement;
cancelBtn.setAttr('style', cancelable ? 'display: block;': 'display: none;');
this.msgView.setAttr('style', 'display: flex;');
}
showMsg(msg: string) {
const title = this.msgView.querySelector('#msg-title') as HTMLElement;
title!.innerText = msg;
const btn = this.msgView.querySelector('#msg-ok-btn') as HTMLElement;
btn.setAttr('style', 'display: block;');
this.msgView.setAttr('style', 'display: flex;');
const cancelBtn = this.msgView.querySelector('#msg-cancel-btn') as HTMLElement;
cancelBtn.setAttr('style', 'display: none;');
this.msgView.setAttr('style', 'display: flex;');
}
buildToolbar(parent: HTMLDivElement) {
this.toolbar = parent.createDiv({ cls: 'preview-toolbar' });
let lineDiv;
// 公众号
if (this.settings.wxInfo.length > 1 || Platform.isDesktop) {
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line' });
lineDiv.createDiv({ cls: 'style-label' }).innerText = '公众号:';
const wxSelect = lineDiv.createEl('select', { cls: 'style-select' })
wxSelect.setAttr('style', 'width: 200px');
wxSelect.onchange = async () => {
this.currentAppId = wxSelect.value;
this.onAppIdChanged();
}
const defautlOp =wxSelect.createEl('option');
defautlOp.value = '';
defautlOp.text = '请在设置里配置公众号';
for (let i = 0; i < this.settings.wxInfo.length; i++) {
const op = wxSelect.createEl('option');
const wx = this.settings.wxInfo[i];
op.value = wx.appid;
op.text = wx.name;
if (i== 0) {
op.selected = true
this.currentAppId = wx.appid;
}
}
this.wechatSelect = wxSelect;
if (Platform.isDesktop) {
const openBtn = lineDiv.createEl('button', { cls: 'refresh-button' }, async (button) => {
button.setText('去公众号后台');
})
openBtn.onclick = async () => {
const { shell } = require('electron');
shell.openExternal('https://mp.weixin.qq.com')
uevent('open-mp');
}
}
}
else if (this.settings.wxInfo.length > 0) {
this.currentAppId = this.settings.wxInfo[0].appid;
}
// 复制,刷新,带图片复制,发草稿箱
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line' });
const refreshBtn = lineDiv.createEl('button', { cls: 'refresh-button' }, async (button) => {
button.setText('刷新');
})
refreshBtn.onclick = async () => {
await this.assetsManager.loadCustomCSS();
await this.assetsManager.loadExpertSettings();
this.render.reloadStyle();
await this.renderMarkdown();
uevent('refresh');
}
if (Platform.isDesktop) {
const copyBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => {
button.setText('复制');
})
copyBtn.onclick = async() => {
try {
await this.render.copyArticle();
new Notice('复制成功,请到公众号编辑器粘贴。');
uevent('copy');
} catch (error) {
console.error(error);
new Notice('复制失败: ' + error);
}
}
}
const uploadImgBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => {
button.setText('上传图片');
})
uploadImgBtn.onclick = async() => {
await this.uploadImages();
uevent('upload');
}
const postBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => {
button.setText('发草稿');
})
postBtn.onclick = async() => {
await this.postArticle();
uevent('pub');
}
const imagesBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => {
button.setText('图片/文字');
})
imagesBtn.onclick = async() => {
await this.postImages();
uevent('pub-images');
}
if (Platform.isDesktop && this.settings.isAuthKeyVaild()) {
const htmlBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => {
button.setText('导出HTML');
})
htmlBtn.onclick = async() => {
await this.exportHTML();
uevent('export-html');
}
}
// 封面
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line' });
const coverTitle = lineDiv.createDiv({ cls: 'style-label' });
coverTitle.innerText = '封面:';
this.useDefaultCover = lineDiv.createEl('input', { cls: 'input-style' });
this.useDefaultCover.setAttr('type', 'radio');
this.useDefaultCover.setAttr('name', 'cover');
this.useDefaultCover.setAttr('value', 'default');
this.useDefaultCover.setAttr('checked', true);
this.useDefaultCover.id = 'default-cover';
this.useDefaultCover.onchange = async () => {
if (this.useDefaultCover.checked) {
this.coverEl.setAttr('style', 'visibility:hidden;width:0px;');
}
else {
this.coverEl.setAttr('style', 'visibility:visible;width:180px;');
}
}
const defaultLable = lineDiv.createEl('label');
defaultLable.innerText = '默认';
defaultLable.setAttr('for', 'default-cover');
this.useLocalCover = lineDiv.createEl('input', { cls: 'input-style' });
this.useLocalCover.setAttr('type', 'radio');
this.useLocalCover.setAttr('name', 'cover');
this.useLocalCover.setAttr('value', 'local');
this.useLocalCover.id = 'local-cover';
this.useLocalCover.setAttr('style', 'margin-left:20px;');
this.useLocalCover.onchange = async () => {
if (this.useLocalCover.checked) {
this.coverEl.setAttr('style', 'visibility:visible;width:180px;');
}
else {
this.coverEl.setAttr('style', 'visibility:hidden;width:0px;');
}
}
const localLabel = lineDiv.createEl('label');
localLabel.setAttr('for', 'local-cover');
localLabel.innerText = '上传';
this.coverEl = lineDiv.createEl('input', { cls: 'upload-input' });
this.coverEl.setAttr('type', 'file');
this.coverEl.setAttr('placeholder', '封面图片');
this.coverEl.setAttr('accept', '.png, .jpg, .jpeg');
this.coverEl.setAttr('name', 'cover');
this.coverEl.id = 'cover-input';
// 样式
if (this.settings.showStyleUI) {
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line' });
const cssStyle = lineDiv.createDiv({ cls: 'style-label' });
cssStyle.innerText = '样式:';
const selectBtn = lineDiv.createEl('select', { cls: 'style-select' }, async (sel) => {
})
selectBtn.onchange = async () => {
this.currentTheme = selectBtn.value;
this.render.updateStyle(selectBtn.value);
}
for (let s of this.assetsManager.themes) {
const op = selectBtn.createEl('option');
op.value = s.className;
op.text = s.name;
op.selected = s.className == this.settings.defaultStyle;
}
this.themeSelect = selectBtn;
const highlightStyle = lineDiv.createDiv({ cls: 'style-label' });
highlightStyle.innerText = '代码高亮:';
const highlightStyleBtn = lineDiv.createEl('select', { cls: 'style-select' }, async (sel) => {
})
highlightStyleBtn.onchange = async () => {
this.currentHighlight = highlightStyleBtn.value;
this.render.updateHighLight(highlightStyleBtn.value);
}
for (let s of this.assetsManager.highlights) {
const op = highlightStyleBtn.createEl('option');
op.value = s.name;
op.text = s.name;
op.selected = s.name == this.settings.defaultHighlight;
}
this.highlightSelect = highlightStyleBtn;
}
this.buildMsgView(this.toolbar);
}
async buildUI() {
this.container = this.containerEl.children[1];
this.container.empty();
this.mainDiv = this.container.createDiv({ cls: 'note-preview' });
this.buildToolbar(this.mainDiv);
this.renderDiv = this.mainDiv.createDiv({cls: 'render-div'});
this.renderDiv.id = 'render-div';
this.renderDiv.setAttribute('style', '-webkit-user-select: text; user-select: text;');
this.styleEl = this.renderDiv.createEl('style');
this.styleEl.setAttr('title', 'note-to-mp-style');
this.articleDiv = this.renderDiv.createEl('div');
}
async viewLoading() {
const container = this.containerEl.children[1]
container.empty();
const loading = container.createDiv({cls: 'loading-wrapper'})
loading.createDiv({cls: 'loading-spinner'})
}
async renderMarkdown(af: TFile | null = null) {
if (!af) {
af = this.app.workspace.getActiveFile();
}
if (!af || af.extension.toLocaleLowerCase() !== 'md') {
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;
}
// 元数据适配(当前新 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);
}
}
async uploadImages() {
this.showLoading('图片上传中...');
try {
await this.render.uploadImages(this.currentAppId);
this.showMsg('图片上传成功,并且文章内容已复制,请到公众号编辑器粘贴。');
} catch (error) {
this.showMsg('图片上传失败: ' + error.message);
}
}
async postArticle() {
let localCover = null;
if (this.useLocalCover.checked) {
const fileInput = this.coverEl;
if (!fileInput.files || fileInput.files.length === 0) {
this.showMsg('请选择封面文件');
return;
}
localCover = fileInput.files[0];
if (!localCover) {
this.showMsg('请选择封面文件');
return;
}
}
this.showLoading('发布中...');
try {
await this.render.postArticle(this.currentAppId, localCover);
this.showMsg('发布成功');
}
catch (error) {
this.showMsg('发布失败: ' + error.message);
}
}
async postImages() {
this.showLoading('发布图片中...');
try {
await this.render.postImages(this.currentAppId);
this.showMsg('图片发布成功');
} catch (error) {
this.showMsg('图片发布失败: ' + error.message);
}
}
async exportHTML() {
this.showLoading('导出HTML中...');
try {
await this.render.exportHTML();
this.showMsg('HTML导出成功');
} catch (error) {
this.showMsg('HTML导出失败: ' + error.message);
}
}
async batchPost(folder: TFolder) {
const files = folder.children.filter((child: TAbstractFile) => child.path.toLocaleLowerCase().endsWith('.md'));
if (!files) {
new Notice('没有可渲染的笔记或文件不支持渲染');
return;
}
this.isCancelUpload = false;
this.isBatchRuning = true;
try {
for (let file of files) {
this.showLoading(`即将发布: ${file.name}`, true);
await sleep(5000);
if (this.isCancelUpload) {
break;
}
this.cleanArticleData();
await this.renderMarkdown(file as TFile);
await this.postArticle();
}
if (!this.isCancelUpload) {
this.showMsg(`批量发布完成:成功发布 ${files.length} 篇笔记`);
}
}
catch (e) {
console.error(e);
new Notice('批量发布失败: ' + e.message);
}
finally {
this.isBatchRuning = false;
this.isCancelUpload = false;
}
}
}

140
src/postcss/at-rule.d.ts vendored Normal file
View File

@@ -0,0 +1,140 @@
import Container, {
ContainerProps,
ContainerWithChildren
} from './container.js'
declare namespace AtRule {
export interface AtRuleRaws extends Record<string, unknown> {
/**
* The space symbols after the last child of the node to the end of the node.
*/
after?: string
/**
* The space between the at-rule name and its parameters.
*/
afterName?: string
/**
* The space symbols before the node. It also stores `*`
* and `_` symbols before the declaration (IE hack).
*/
before?: string
/**
* The symbols between the last parameter and `{` for rules.
*/
between?: string
/**
* The rules selector with comments.
*/
params?: {
raw: string
value: string
}
/**
* Contains `true` if the last child has an (optional) semicolon.
*/
semicolon?: boolean
}
export interface AtRuleProps extends ContainerProps {
/** Name of the at-rule. */
name: string
/** Parameters following the name of the at-rule. */
params?: number | string
/** Information used to generate byte-to-byte equal node string as it was in the origin input. */
raws?: AtRuleRaws
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { AtRule_ as default }
}
/**
* Represents an at-rule.
*
* ```js
* Once (root, { AtRule }) {
* let media = new AtRule({ name: 'media', params: 'print' })
* media.append(…)
* root.append(media)
* }
* ```
*
* If its followed in the CSS by a `{}` block, this node will have
* a nodes property representing its children.
*
* ```js
* const root = postcss.parse('@charset "UTF-8"; @media print {}')
*
* const charset = root.first
* charset.type //=> 'atrule'
* charset.nodes //=> undefined
*
* const media = root.last
* media.nodes //=> []
* ```
*/
declare class AtRule_ extends Container {
/**
* The at-rules name immediately follows the `@`.
*
* ```js
* const root = postcss.parse('@media print {}')
* const media = root.first
* media.name //=> 'media'
* ```
*/
get name(): string
set name(value: string)
/**
* An array containing the layers children.
*
* ```js
* const root = postcss.parse('@layer example { a { color: black } }')
* const layer = root.first
* layer.nodes.length //=> 1
* layer.nodes[0].selector //=> 'a'
* ```
*
* Can be `undefinded` if the at-rule has no body.
*
* ```js
* const root = postcss.parse('@layer a, b, c;')
* const layer = root.first
* layer.nodes //=> undefined
* ```
*/
nodes: Container['nodes']
/**
* The at-rules parameters, the values that follow the at-rules name
* but precede any `{}` block.
*
* ```js
* const root = postcss.parse('@media print, screen {}')
* const media = root.first
* media.params //=> 'print, screen'
* ```
*/
get params(): string
set params(value: string)
parent: ContainerWithChildren | undefined
raws: AtRule.AtRuleRaws
type: 'atrule'
constructor(defaults?: AtRule.AtRuleProps)
assign(overrides: AtRule.AtRuleProps | object): this
clone(overrides?: Partial<AtRule.AtRuleProps>): AtRule
cloneAfter(overrides?: Partial<AtRule.AtRuleProps>): AtRule
cloneBefore(overrides?: Partial<AtRule.AtRuleProps>): AtRule
}
declare class AtRule extends AtRule_ {}
export = AtRule

68
src/postcss/comment.d.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
import Container from './container.js'
import Node, { NodeProps } from './node.js'
declare namespace Comment {
export interface CommentRaws extends Record<string, unknown> {
/**
* The space symbols before the node.
*/
before?: string
/**
* The space symbols between `/*` and the comments text.
*/
left?: string
/**
* The space symbols between the comments text.
*/
right?: string
}
export interface CommentProps extends NodeProps {
/** Information used to generate byte-to-byte equal node string as it was in the origin input. */
raws?: CommentRaws
/** Content of the comment. */
text: string
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { Comment_ as default }
}
/**
* It represents a class that handles
* [CSS comments](https://developer.mozilla.org/en-US/docs/Web/CSS/Comments)
*
* ```js
* Once (root, { Comment }) {
* const note = new Comment({ text: 'Note: …' })
* root.append(note)
* }
* ```
*
* Remember that CSS comments inside selectors, at-rule parameters,
* or declaration values will be stored in the `raws` properties
* explained above.
*/
declare class Comment_ extends Node {
parent: Container | undefined
raws: Comment.CommentRaws
/**
* The comment's text.
*/
get text(): string
set text(value: string)
type: 'comment'
constructor(defaults?: Comment.CommentProps)
assign(overrides: Comment.CommentProps | object): this
clone(overrides?: Partial<Comment.CommentProps>): Comment
cloneAfter(overrides?: Partial<Comment.CommentProps>): Comment
cloneBefore(overrides?: Partial<Comment.CommentProps>): Comment
}
declare class Comment extends Comment_ {}
export = Comment

490
src/postcss/container.d.ts vendored Normal file
View File

@@ -0,0 +1,490 @@
import AtRule from './at-rule.js'
import Comment from './comment.js'
import Declaration from './declaration.js'
import Node, { ChildNode, ChildProps, NodeProps } from './node.js'
import Rule from './rule.js'
declare namespace Container {
export class ContainerWithChildren<
Child extends Node = ChildNode
> extends Container_<Child> {
nodes: Child[]
}
export interface ValueOptions {
/**
* String thats used to narrow down values and speed up the regexp search.
*/
fast?: string
/**
* An array of property names.
*/
props?: string[]
}
export interface ContainerProps extends NodeProps {
nodes?: (ChildNode | ChildProps)[]
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { Container_ as default }
}
/**
* The `Root`, `AtRule`, and `Rule` container nodes
* inherit some common methods to help work with their children.
*
* Note that all containers can store any content. If you write a rule inside
* a rule, PostCSS will parse it.
*/
declare abstract class Container_<Child extends Node = ChildNode> extends Node {
/**
* An array containing the containers children.
*
* ```js
* const root = postcss.parse('a { color: black }')
* root.nodes.length //=> 1
* root.nodes[0].selector //=> 'a'
* root.nodes[0].nodes[0].prop //=> 'color'
* ```
*/
nodes: Child[] | undefined
/**
* Inserts new nodes to the end of the container.
*
* ```js
* const decl1 = new Declaration({ prop: 'color', value: 'black' })
* const decl2 = new Declaration({ prop: 'background-color', value: 'white' })
* rule.append(decl1, decl2)
*
* root.append({ name: 'charset', params: '"UTF-8"' }) // at-rule
* root.append({ selector: 'a' }) // rule
* rule.append({ prop: 'color', value: 'black' }) // declaration
* rule.append({ text: 'Comment' }) // comment
*
* root.append('a {}')
* root.first.append('color: black; z-index: 1')
* ```
*
* @param nodes New nodes.
* @return This node for methods chain.
*/
append(
...nodes: (
| ChildProps
| ChildProps[]
| Node
| Node[]
| string
| string[]
| undefined
)[]
): this
assign(overrides: Container.ContainerProps | object): this
clone(overrides?: Partial<Container.ContainerProps>): Container<Child>
cloneAfter(overrides?: Partial<Container.ContainerProps>): Container<Child>
cloneBefore(overrides?: Partial<Container.ContainerProps>): Container<Child>
/**
* Iterates through the containers immediate children,
* calling `callback` for each child.
*
* Returning `false` in the callback will break iteration.
*
* This method only iterates through the containers immediate children.
* If you need to recursively iterate through all the containers descendant
* nodes, use `Container#walk`.
*
* Unlike the for `{}`-cycle or `Array#forEach` this iterator is safe
* if you are mutating the array of child nodes during iteration.
* PostCSS will adjust the current index to match the mutations.
*
* ```js
* const root = postcss.parse('a { color: black; z-index: 1 }')
* const rule = root.first
*
* for (const decl of rule.nodes) {
* decl.cloneBefore({ prop: '-webkit-' + decl.prop })
* // Cycle will be infinite, because cloneBefore moves the current node
* // to the next index
* }
*
* rule.each(decl => {
* decl.cloneBefore({ prop: '-webkit-' + decl.prop })
* // Will be executed only for color and z-index
* })
* ```
*
* @param callback Iterator receives each node and index.
* @return Returns `false` if iteration was broke.
*/
each(
callback: (node: Child, index: number) => false | void
): false | undefined
/**
* Returns `true` if callback returns `true`
* for all of the containers children.
*
* ```js
* const noPrefixes = rule.every(i => i.prop[0] !== '-')
* ```
*
* @param condition Iterator returns true or false.
* @return Is every child pass condition.
*/
every(
condition: (node: Child, index: number, nodes: Child[]) => boolean
): boolean
/**
* Returns a `child`s index within the `Container#nodes` array.
*
* ```js
* rule.index( rule.nodes[2] ) //=> 2
* ```
*
* @param child Child of the current container.
* @return Child index.
*/
index(child: Child | number): number
/**
* Insert new node after old node within the container.
*
* @param oldNode Child or childs index.
* @param newNode New node.
* @return This node for methods chain.
*/
insertAfter(
oldNode: Child | number,
newNode:
| Child
| Child[]
| ChildProps
| ChildProps[]
| string
| string[]
| undefined
): this
/**
* Insert new node before old node within the container.
*
* ```js
* rule.insertBefore(decl, decl.clone({ prop: '-webkit-' + decl.prop }))
* ```
*
* @param oldNode Child or childs index.
* @param newNode New node.
* @return This node for methods chain.
*/
insertBefore(
oldNode: Child | number,
newNode:
| Child
| Child[]
| ChildProps
| ChildProps[]
| string
| string[]
| undefined
): this
/**
* Traverses the containers descendant nodes, calling callback
* for each comment node.
*
* Like `Container#each`, this method is safe
* to use if you are mutating arrays during iteration.
*
* ```js
* root.walkComments(comment => {
* comment.remove()
* })
* ```
*
* @param callback Iterator receives each node and index.
* @return Returns `false` if iteration was broke.
*/
/**
* Inserts new nodes to the start of the container.
*
* ```js
* const decl1 = new Declaration({ prop: 'color', value: 'black' })
* const decl2 = new Declaration({ prop: 'background-color', value: 'white' })
* rule.prepend(decl1, decl2)
*
* root.append({ name: 'charset', params: '"UTF-8"' }) // at-rule
* root.append({ selector: 'a' }) // rule
* rule.append({ prop: 'color', value: 'black' }) // declaration
* rule.append({ text: 'Comment' }) // comment
*
* root.append('a {}')
* root.first.append('color: black; z-index: 1')
* ```
*
* @param nodes New nodes.
* @return This node for methods chain.
*/
prepend(
...nodes: (
| ChildProps
| ChildProps[]
| Node
| Node[]
| string
| string[]
| undefined
)[]
): this
/**
* Add child to the end of the node.
*
* ```js
* rule.push(new Declaration({ prop: 'color', value: 'black' }))
* ```
*
* @param child New node.
* @return This node for methods chain.
*/
push(child: Child): this
/**
* Removes all children from the container
* and cleans their parent properties.
*
* ```js
* rule.removeAll()
* rule.nodes.length //=> 0
* ```
*
* @return This node for methods chain.
*/
removeAll(): this
/**
* Removes node from the container and cleans the parent properties
* from the node and its children.
*
* ```js
* rule.nodes.length //=> 5
* rule.removeChild(decl)
* rule.nodes.length //=> 4
* decl.parent //=> undefined
* ```
*
* @param child Child or childs index.
* @return This node for methods chain.
*/
removeChild(child: Child | number): this
replaceValues(
pattern: RegExp | string,
replaced: { (substring: string, ...args: any[]): string } | string
): this
/**
* Passes all declaration values within the container that match pattern
* through callback, replacing those values with the returned result
* of callback.
*
* This method is useful if you are using a custom unit or function
* and need to iterate through all values.
*
* ```js
* root.replaceValues(/\d+rem/, { fast: 'rem' }, string => {
* return 15 * parseInt(string) + 'px'
* })
* ```
*
* @param pattern Replace pattern.
* @param {object} opts Options to speed up the search.
* @param callback String to replace pattern or callback
* that returns a new value. The callback
* will receive the same arguments
* as those passed to a function parameter
* of `String#replace`.
* @return This node for methods chain.
*/
replaceValues(
pattern: RegExp | string,
options: Container.ValueOptions,
replaced: { (substring: string, ...args: any[]): string } | string
): this
/**
* Returns `true` if callback returns `true` for (at least) one
* of the containers children.
*
* ```js
* const hasPrefix = rule.some(i => i.prop[0] === '-')
* ```
*
* @param condition Iterator returns true or false.
* @return Is some child pass condition.
*/
some(
condition: (node: Child, index: number, nodes: Child[]) => boolean
): boolean
/**
* Traverses the containers descendant nodes, calling callback
* for each node.
*
* Like container.each(), this method is safe to use
* if you are mutating arrays during iteration.
*
* If you only need to iterate through the containers immediate children,
* use `Container#each`.
*
* ```js
* root.walk(node => {
* // Traverses all descendant nodes.
* })
* ```
*
* @param callback Iterator receives each node and index.
* @return Returns `false` if iteration was broke.
*/
walk(
callback: (node: ChildNode, index: number) => false | void
): false | undefined
/**
* Traverses the containers descendant nodes, calling callback
* for each at-rule node.
*
* If you pass a filter, iteration will only happen over at-rules
* that have matching names.
*
* Like `Container#each`, this method is safe
* to use if you are mutating arrays during iteration.
*
* ```js
* root.walkAtRules(rule => {
* if (isOld(rule.name)) rule.remove()
* })
*
* let first = false
* root.walkAtRules('charset', rule => {
* if (!first) {
* first = true
* } else {
* rule.remove()
* }
* })
* ```
*
* @param name String or regular expression to filter at-rules by name.
* @param callback Iterator receives each node and index.
* @return Returns `false` if iteration was broke.
*/
walkAtRules(
nameFilter: RegExp | string,
callback: (atRule: AtRule, index: number) => false | void
): false | undefined
walkAtRules(
callback: (atRule: AtRule, index: number) => false | void
): false | undefined
walkComments(
callback: (comment: Comment, indexed: number) => false | void
): false | undefined
walkComments(
callback: (comment: Comment, indexed: number) => false | void
): false | undefined
/**
* Traverses the containers descendant nodes, calling callback
* for each declaration node.
*
* If you pass a filter, iteration will only happen over declarations
* with matching properties.
*
* ```js
* root.walkDecls(decl => {
* checkPropertySupport(decl.prop)
* })
*
* root.walkDecls('border-radius', decl => {
* decl.remove()
* })
*
* root.walkDecls(/^background/, decl => {
* decl.value = takeFirstColorFromGradient(decl.value)
* })
* ```
*
* Like `Container#each`, this method is safe
* to use if you are mutating arrays during iteration.
*
* @param prop String or regular expression to filter declarations
* by property name.
* @param callback Iterator receives each node and index.
* @return Returns `false` if iteration was broke.
*/
walkDecls(
propFilter: RegExp | string,
callback: (decl: Declaration, index: number) => false | void
): false | undefined
walkDecls(
callback: (decl: Declaration, index: number) => false | void
): false | undefined
/**
* Traverses the containers descendant nodes, calling callback
* for each rule node.
*
* If you pass a filter, iteration will only happen over rules
* with matching selectors.
*
* Like `Container#each`, this method is safe
* to use if you are mutating arrays during iteration.
*
* ```js
* const selectors = []
* root.walkRules(rule => {
* selectors.push(rule.selector)
* })
* console.log(`Your CSS uses ${ selectors.length } selectors`)
* ```
*
* @param selector String or regular expression to filter rules by selector.
* @param callback Iterator receives each node and index.
* @return Returns `false` if iteration was broke.
*/
walkRules(
selectorFilter: RegExp | string,
callback: (rule: Rule, index: number) => false | void
): false | undefined
walkRules(
callback: (rule: Rule, index: number) => false | void
): false | undefined
/**
* The containers first child.
*
* ```js
* rule.first === rules.nodes[0]
* ```
*/
get first(): Child | undefined
/**
* The containers last child.
*
* ```js
* rule.last === rule.nodes[rule.nodes.length - 1]
* ```
*/
get last(): Child | undefined
}
declare class Container<
Child extends Node = ChildNode
> extends Container_<Child> {}
export = Container

248
src/postcss/css-syntax-error.d.ts vendored Normal file
View File

@@ -0,0 +1,248 @@
import { FilePosition } from './input.js'
declare namespace CssSyntaxError {
/**
* A position that is part of a range.
*/
export interface RangePosition {
/**
* The column number in the input.
*/
column: number
/**
* The line number in the input.
*/
line: number
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { CssSyntaxError_ as default }
}
/**
* The CSS parser throws this error for broken CSS.
*
* Custom parsers can throw this error for broken custom syntax using
* the `Node#error` method.
*
* PostCSS will use the input source map to detect the original error location.
* If you wrote a Sass file, compiled it to CSS and then parsed it with PostCSS,
* PostCSS will show the original position in the Sass file.
*
* If you need the position in the PostCSS input
* (e.g., to debug the previous compiler), use `error.input.file`.
*
* ```js
* // Raising error from plugin
* throw node.error('Unknown variable', { plugin: 'postcss-vars' })
* ```
*
* ```js
* // Catching and checking syntax error
* try {
* postcss.parse('a{')
* } catch (error) {
* if (error.name === 'CssSyntaxError') {
* error //=> CssSyntaxError
* }
* }
* ```
*/
declare class CssSyntaxError_ extends Error {
/**
* Source column of the error.
*
* ```js
* error.column //=> 1
* error.input.column //=> 4
* ```
*
* PostCSS will use the input source map to detect the original location.
* If you need the position in the PostCSS input, use `error.input.column`.
*/
column?: number
/**
* Source column of the error's end, exclusive. Provided if the error pertains
* to a range.
*
* ```js
* error.endColumn //=> 1
* error.input.endColumn //=> 4
* ```
*
* PostCSS will use the input source map to detect the original location.
* If you need the position in the PostCSS input, use `error.input.endColumn`.
*/
endColumn?: number
/**
* Source line of the error's end, exclusive. Provided if the error pertains
* to a range.
*
* ```js
* error.endLine //=> 3
* error.input.endLine //=> 4
* ```
*
* PostCSS will use the input source map to detect the original location.
* If you need the position in the PostCSS input, use `error.input.endLine`.
*/
endLine?: number
/**
* Absolute path to the broken file.
*
* ```js
* error.file //=> 'a.sass'
* error.input.file //=> 'a.css'
* ```
*
* PostCSS will use the input source map to detect the original location.
* If you need the position in the PostCSS input, use `error.input.file`.
*/
file?: string
/**
* Input object with PostCSS internal information
* about input file. If input has source map
* from previous tool, PostCSS will use origin
* (for example, Sass) source. You can use this
* object to get PostCSS input source.
*
* ```js
* error.input.file //=> 'a.css'
* error.file //=> 'a.sass'
* ```
*/
input?: FilePosition
/**
* Source line of the error.
*
* ```js
* error.line //=> 2
* error.input.line //=> 4
* ```
*
* PostCSS will use the input source map to detect the original location.
* If you need the position in the PostCSS input, use `error.input.line`.
*/
line?: number
/**
* Full error text in the GNU error format
* with plugin, file, line and column.
*
* ```js
* error.message //=> 'a.css:1:1: Unclosed block'
* ```
*/
message: string
/**
* Always equal to `'CssSyntaxError'`. You should always check error type
* by `error.name === 'CssSyntaxError'`
* instead of `error instanceof CssSyntaxError`,
* because npm could have several PostCSS versions.
*
* ```js
* if (error.name === 'CssSyntaxError') {
* error //=> CssSyntaxError
* }
* ```
*/
name: 'CssSyntaxError'
/**
* Plugin name, if error came from plugin.
*
* ```js
* error.plugin //=> 'postcss-vars'
* ```
*/
plugin?: string
/**
* Error message.
*
* ```js
* error.message //=> 'Unclosed block'
* ```
*/
reason: string
/**
* Source code of the broken file.
*
* ```js
* error.source //=> 'a { b {} }'
* error.input.source //=> 'a b { }'
* ```
*/
source?: string
stack: string
/**
* Instantiates a CSS syntax error. Can be instantiated for a single position
* or for a range.
* @param message Error message.
* @param lineOrStartPos If for a single position, the line number, or if for
* a range, the inclusive start position of the error.
* @param columnOrEndPos If for a single position, the column number, or if for
* a range, the exclusive end position of the error.
* @param source Source code of the broken file.
* @param file Absolute path to the broken file.
* @param plugin PostCSS plugin name, if error came from plugin.
*/
constructor(
message: string,
lineOrStartPos?: CssSyntaxError.RangePosition | number,
columnOrEndPos?: CssSyntaxError.RangePosition | number,
source?: string,
file?: string,
plugin?: string
)
/**
* Returns a few lines of CSS source that caused the error.
*
* If the CSS has an input source map without `sourceContent`,
* this method will return an empty string.
*
* ```js
* error.showSourceCode() //=> " 4 | }
* // 5 | a {
* // > 6 | bad
* // | ^
* // 7 | }
* // 8 | b {"
* ```
*
* @param color Whether arrow will be colored red by terminal
* color codes. By default, PostCSS will detect
* color support by `process.stdout.isTTY`
* and `process.env.NODE_DISABLE_COLORS`.
* @return Few lines of CSS source that caused the error.
*/
showSourceCode(color?: boolean): string
/**
* Returns error position, message and source code of the broken part.
*
* ```js
* error.toString() //=> "CssSyntaxError: app.css:1:1: Unclosed block
* // > 1 | a {
* // | ^"
* ```
*
* @return Error position, message and source code.
*/
toString(): string
}
declare class CssSyntaxError extends CssSyntaxError_ {}
export = CssSyntaxError

152
src/postcss/declaration.d.ts vendored Normal file
View File

@@ -0,0 +1,152 @@
import { ContainerWithChildren } from './container.js'
import Node from './node.js'
declare namespace Declaration {
export interface DeclarationRaws extends Record<string, unknown> {
/**
* The space symbols before the node. It also stores `*`
* and `_` symbols before the declaration (IE hack).
*/
before?: string
/**
* The symbols between the property and value for declarations.
*/
between?: string
/**
* The content of the important statement, if it is not just `!important`.
*/
important?: string
/**
* Declaration value with comments.
*/
value?: {
raw: string
value: string
}
}
export interface DeclarationProps {
/** Whether the declaration has an `!important` annotation. */
important?: boolean
/** Name of the declaration. */
prop: string
/** Information used to generate byte-to-byte equal node string as it was in the origin input. */
raws?: DeclarationRaws
/** Value of the declaration. */
value: string
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { Declaration_ as default }
}
/**
* It represents a class that handles
* [CSS declarations](https://developer.mozilla.org/en-US/docs/Web/CSS/Syntax#css_declarations)
*
* ```js
* Once (root, { Declaration }) {
* const color = new Declaration({ prop: 'color', value: 'black' })
* root.append(color)
* }
* ```
*
* ```js
* const root = postcss.parse('a { color: black }')
* const decl = root.first?.first
*
* decl.type //=> 'decl'
* decl.toString() //=> ' color: black'
* ```
*/
declare class Declaration_ extends Node {
/**
* It represents a specificity of the declaration.
*
* If true, the CSS declaration will have an
* [important](https://developer.mozilla.org/en-US/docs/Web/CSS/important)
* specifier.
*
* ```js
* const root = postcss.parse('a { color: black !important; color: red }')
*
* root.first.first.important //=> true
* root.first.last.important //=> undefined
* ```
*/
get important(): boolean
set important(value: boolean)
parent: ContainerWithChildren | undefined
/**
* The property name for a CSS declaration.
*
* ```js
* const root = postcss.parse('a { color: black }')
* const decl = root.first.first
*
* decl.prop //=> 'color'
* ```
*/
get prop(): string
set prop(value: string)
raws: Declaration.DeclarationRaws
type: 'decl'
/**
* The property value for a CSS declaration.
*
* Any CSS comments inside the value string will be filtered out.
* CSS comments present in the source value will be available in
* the `raws` property.
*
* Assigning new `value` would ignore the comments in `raws`
* property while compiling node to string.
*
* ```js
* const root = postcss.parse('a { color: black }')
* const decl = root.first.first
*
* decl.value //=> 'black'
* ```
*/
get value(): string
set value(value: string)
/**
* It represents a getter that returns `true` if a declaration starts with
* `--` or `$`, which are used to declare variables in CSS and SASS/SCSS.
*
* ```js
* const root = postcss.parse(':root { --one: 1 }')
* const one = root.first.first
*
* one.variable //=> true
* ```
*
* ```js
* const root = postcss.parse('$one: 1')
* const one = root.first
*
* one.variable //=> true
* ```
*/
get variable(): boolean
set varaible(value: string)
constructor(defaults?: Declaration.DeclarationProps)
assign(overrides: Declaration.DeclarationProps | object): this
clone(overrides?: Partial<Declaration.DeclarationProps>): Declaration
cloneAfter(overrides?: Partial<Declaration.DeclarationProps>): Declaration
cloneBefore(overrides?: Partial<Declaration.DeclarationProps>): Declaration
}
declare class Declaration extends Declaration_ {}
export = Declaration

69
src/postcss/document.d.ts vendored Normal file
View File

@@ -0,0 +1,69 @@
import Container, { ContainerProps } from './container.js'
import { ProcessOptions } from './postcss.js'
import Result from './result.js'
import Root from './root.js'
declare namespace Document {
export interface DocumentProps extends ContainerProps {
nodes?: Root[]
/**
* Information to generate byte-to-byte equal node string as it was
* in the origin input.
*
* Every parser saves its own properties.
*/
raws?: Record<string, any>
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { Document_ as default }
}
/**
* Represents a file and contains all its parsed nodes.
*
* **Experimental:** some aspects of this node could change within minor
* or patch version releases.
*
* ```js
* const document = htmlParser(
* '<html><style>a{color:black}</style><style>b{z-index:2}</style>'
* )
* document.type //=> 'document'
* document.nodes.length //=> 2
* ```
*/
declare class Document_ extends Container<Root> {
nodes: Root[]
parent: undefined
type: 'document'
constructor(defaults?: Document.DocumentProps)
assign(overrides: Document.DocumentProps | object): this
clone(overrides?: Partial<Document.DocumentProps>): Document
cloneAfter(overrides?: Partial<Document.DocumentProps>): Document
cloneBefore(overrides?: Partial<Document.DocumentProps>): Document
/**
* Returns a `Result` instance representing the documents CSS roots.
*
* ```js
* const root1 = postcss.parse(css1, { from: 'a.css' })
* const root2 = postcss.parse(css2, { from: 'b.css' })
* const document = postcss.document()
* document.append(root1)
* document.append(root2)
* const result = document.toResult({ to: 'all.css', map: true })
* ```
*
* @param opts Options.
* @return Result with current documents CSS.
*/
toResult(options?: ProcessOptions): Result
}
declare class Document extends Document_ {}
export = Document

9
src/postcss/fromJSON.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import { JSONHydrator } from './postcss.js'
interface FromJSON extends JSONHydrator {
default: FromJSON
}
declare const fromJSON: FromJSON
export = fromJSON

194
src/postcss/input.d.ts vendored Normal file
View File

@@ -0,0 +1,194 @@
import { CssSyntaxError, ProcessOptions } from './postcss.js'
import PreviousMap from './previous-map.js'
declare namespace Input {
export interface FilePosition {
/**
* Column of inclusive start position in source file.
*/
column: number
/**
* Column of exclusive end position in source file.
*/
endColumn?: number
/**
* Line of exclusive end position in source file.
*/
endLine?: number
/**
* Absolute path to the source file.
*/
file?: string
/**
* Line of inclusive start position in source file.
*/
line: number
/**
* Source code.
*/
source?: string
/**
* URL for the source file.
*/
url: string
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { Input_ as default }
}
/**
* Represents the source CSS.
*
* ```js
* const root = postcss.parse(css, { from: file })
* const input = root.source.input
* ```
*/
declare class Input_ {
/**
* Input CSS source.
*
* ```js
* const input = postcss.parse('a{}', { from: file }).input
* input.css //=> "a{}"
* ```
*/
css: string
/**
* The absolute path to the CSS source file defined
* with the `from` option.
*
* ```js
* const root = postcss.parse(css, { from: 'a.css' })
* root.source.input.file //=> '/home/ai/a.css'
* ```
*/
file?: string
/**
* The flag to indicate whether or not the source code has Unicode BOM.
*/
hasBOM: boolean
/**
* The unique ID of the CSS source. It will be created if `from` option
* is not provided (because PostCSS does not know the file path).
*
* ```js
* const root = postcss.parse(css)
* root.source.input.file //=> undefined
* root.source.input.id //=> "<input css 8LZeVF>"
* ```
*/
id?: string
/**
* The input source map passed from a compilation step before PostCSS
* (for example, from Sass compiler).
*
* ```js
* root.source.input.map.consumer().sources //=> ['a.sass']
* ```
*/
map: PreviousMap
/**
* @param css Input CSS source.
* @param opts Process options.
*/
constructor(css: string, opts?: ProcessOptions)
error(
message: string,
start:
| {
column: number
line: number
}
| {
offset: number
},
end:
| {
column: number
line: number
}
| {
offset: number
},
opts?: { plugin?: CssSyntaxError['plugin'] }
): CssSyntaxError
/**
* Returns `CssSyntaxError` with information about the error and its position.
*/
error(
message: string,
line: number,
column: number,
opts?: { plugin?: CssSyntaxError['plugin'] }
): CssSyntaxError
error(
message: string,
offset: number,
opts?: { plugin?: CssSyntaxError['plugin'] }
): CssSyntaxError
/**
* Converts source offset to line and column.
*
* @param offset Source offset.
*/
fromOffset(offset: number): { col: number; line: number } | null
/**
* Reads the input source map and returns a symbol position
* in the input source (e.g., in a Sass file that was compiled
* to CSS before being passed to PostCSS). Optionally takes an
* end position, exclusive.
*
* ```js
* root.source.input.origin(1, 1) //=> { file: 'a.css', line: 3, column: 1 }
* root.source.input.origin(1, 1, 1, 4)
* //=> { file: 'a.css', line: 3, column: 1, endLine: 3, endColumn: 4 }
* ```
*
* @param line Line for inclusive start position in input CSS.
* @param column Column for inclusive start position in input CSS.
* @param endLine Line for exclusive end position in input CSS.
* @param endColumn Column for exclusive end position in input CSS.
*
* @return Position in input source.
*/
origin(
line: number,
column: number,
endLine?: number,
endColumn?: number
): false | Input.FilePosition
/**
* The CSS source identifier. Contains `Input#file` if the user
* set the `from` option, or `Input#id` if they did not.
*
* ```js
* const root = postcss.parse(css, { from: 'a.css' })
* root.source.input.from //=> "/home/ai/a.css"
*
* const root = postcss.parse(css)
* root.source.input.from //=> "<input css 1>"
* ```
*/
get from(): string
}
declare class Input extends Input_ {}
export = Input

190
src/postcss/lazy-result.d.ts vendored Normal file
View File

@@ -0,0 +1,190 @@
import Document from './document.js'
import { SourceMap } from './postcss.js'
import Processor from './processor.js'
import Result, { Message, ResultOptions } from './result.js'
import Root from './root.js'
import Warning from './warning.js'
declare namespace LazyResult {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { LazyResult_ as default }
}
/**
* A Promise proxy for the result of PostCSS transformations.
*
* A `LazyResult` instance is returned by `Processor#process`.
*
* ```js
* const lazy = postcss([autoprefixer]).process(css)
* ```
*/
declare class LazyResult_<RootNode = Document | Root>
implements PromiseLike<Result<RootNode>>
{
/**
* Processes input CSS through synchronous and asynchronous plugins
* and calls onRejected for each error thrown in any plugin.
*
* It implements standard Promise API.
*
* ```js
* postcss([autoprefixer]).process(css).then(result => {
* console.log(result.css)
* }).catch(error => {
* console.error(error)
* })
* ```
*/
catch: Promise<Result<RootNode>>['catch']
/**
* Processes input CSS through synchronous and asynchronous plugins
* and calls onFinally on any error or when all plugins will finish work.
*
* It implements standard Promise API.
*
* ```js
* postcss([autoprefixer]).process(css).finally(() => {
* console.log('processing ended')
* })
* ```
*/
finally: Promise<Result<RootNode>>['finally']
/**
* Processes input CSS through synchronous and asynchronous plugins
* and calls `onFulfilled` with a Result instance. If a plugin throws
* an error, the `onRejected` callback will be executed.
*
* It implements standard Promise API.
*
* ```js
* postcss([autoprefixer]).process(css, { from: cssPath }).then(result => {
* console.log(result.css)
* })
* ```
*/
then: Promise<Result<RootNode>>['then']
/**
* @param processor Processor used for this transformation.
* @param css CSS to parse and transform.
* @param opts Options from the `Processor#process` or `Root#toResult`.
*/
constructor(processor: Processor, css: string, opts: ResultOptions)
/**
* Run plugin in async way and return `Result`.
*
* @return Result with output content.
*/
async(): Promise<Result<RootNode>>
/**
* Run plugin in sync way and return `Result`.
*
* @return Result with output content.
*/
sync(): Result<RootNode>
/**
* Alias for the `LazyResult#css` property.
*
* ```js
* lazy + '' === lazy.css
* ```
*
* @return Output CSS.
*/
toString(): string
/**
* Processes input CSS through synchronous plugins
* and calls `Result#warnings`.
*
* @return Warnings from plugins.
*/
warnings(): Warning[]
/**
* An alias for the `css` property. Use it with syntaxes
* that generate non-CSS output.
*
* This property will only work with synchronous plugins.
* If the processor contains any asynchronous plugins
* it will throw an error.
*
* PostCSS runners should always use `LazyResult#then`.
*/
get content(): string
/**
* Processes input CSS through synchronous plugins, converts `Root`
* to a CSS string and returns `Result#css`.
*
* This property will only work with synchronous plugins.
* If the processor contains any asynchronous plugins
* it will throw an error.
*
* PostCSS runners should always use `LazyResult#then`.
*/
get css(): string
/**
* Processes input CSS through synchronous plugins
* and returns `Result#map`.
*
* This property will only work with synchronous plugins.
* If the processor contains any asynchronous plugins
* it will throw an error.
*
* PostCSS runners should always use `LazyResult#then`.
*/
get map(): SourceMap
/**
* Processes input CSS through synchronous plugins
* and returns `Result#messages`.
*
* This property will only work with synchronous plugins. If the processor
* contains any asynchronous plugins it will throw an error.
*
* PostCSS runners should always use `LazyResult#then`.
*/
get messages(): Message[]
/**
* Options from the `Processor#process` call.
*/
get opts(): ResultOptions
/**
* Returns a `Processor` instance, which will be used
* for CSS transformations.
*/
get processor(): Processor
/**
* Processes input CSS through synchronous plugins
* and returns `Result#root`.
*
* This property will only work with synchronous plugins. If the processor
* contains any asynchronous plugins it will throw an error.
*
* PostCSS runners should always use `LazyResult#then`.
*/
get root(): RootNode
/**
* Returns the default string description of an object.
* Required to implement the Promise interface.
*/
get [Symbol.toStringTag](): string
}
declare class LazyResult<
RootNode = Document | Root
> extends LazyResult_<RootNode> {}
export = LazyResult

57
src/postcss/list.d.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
declare namespace list {
type List = {
/**
* Safely splits comma-separated values (such as those for `transition-*`
* and `background` properties).
*
* ```js
* Once (root, { list }) {
* list.comma('black, linear-gradient(white, black)')
* //=> ['black', 'linear-gradient(white, black)']
* }
* ```
*
* @param str Comma-separated values.
* @return Split values.
*/
comma(str: string): string[]
default: List
/**
* Safely splits space-separated values (such as those for `background`,
* `border-radius`, and other shorthand properties).
*
* ```js
* Once (root, { list }) {
* list.space('1px calc(10% + 1px)') //=> ['1px', 'calc(10% + 1px)']
* }
* ```
*
* @param str Space-separated values.
* @return Split values.
*/
space(str: string): string[]
/**
* Safely splits values.
*
* ```js
* Once (root, { list }) {
* list.split('1px calc(10% + 1px)', [' ', '\n', '\t']) //=> ['1px', 'calc(10% + 1px)']
* }
* ```
*
* @param string separated values.
* @param separators array of separators.
* @param last boolean indicator.
* @return Split values.
*/
split(string: string, separators: string[], last: boolean): string[]
}
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
declare const list: list.List
export = list

46
src/postcss/no-work-result.d.ts vendored Normal file
View File

@@ -0,0 +1,46 @@
import LazyResult from './lazy-result.js'
import { SourceMap } from './postcss.js'
import Processor from './processor.js'
import Result, { Message, ResultOptions } from './result.js'
import Root from './root.js'
import Warning from './warning.js'
declare namespace NoWorkResult {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { NoWorkResult_ as default }
}
/**
* A Promise proxy for the result of PostCSS transformations.
* This lazy result instance doesn't parse css unless `NoWorkResult#root` or `Result#root`
* are accessed. See the example below for details.
* A `NoWork` instance is returned by `Processor#process` ONLY when no plugins defined.
*
* ```js
* const noWorkResult = postcss().process(css) // No plugins are defined.
* // CSS is not parsed
* let root = noWorkResult.root // now css is parsed because we accessed the root
* ```
*/
declare class NoWorkResult_ implements LazyResult<Root> {
catch: Promise<Result<Root>>['catch']
finally: Promise<Result<Root>>['finally']
then: Promise<Result<Root>>['then']
constructor(processor: Processor, css: string, opts: ResultOptions)
async(): Promise<Result<Root>>
sync(): Result<Root>
toString(): string
warnings(): Warning[]
get content(): string
get css(): string
get map(): SourceMap
get messages(): Message[]
get opts(): ResultOptions
get processor(): Processor
get root(): Root
get [Symbol.toStringTag](): string
}
declare class NoWorkResult extends NoWorkResult_ {}
export = NoWorkResult

536
src/postcss/node.d.ts vendored Normal file
View File

@@ -0,0 +1,536 @@
import AtRule = require('./at-rule.js')
import { AtRuleProps } from './at-rule.js'
import Comment, { CommentProps } from './comment.js'
import Container from './container.js'
import CssSyntaxError from './css-syntax-error.js'
import Declaration, { DeclarationProps } from './declaration.js'
import Document from './document.js'
import Input from './input.js'
import { Stringifier, Syntax } from './postcss.js'
import Result from './result.js'
import Root from './root.js'
import Rule, { RuleProps } from './rule.js'
import Warning, { WarningOptions } from './warning.js'
declare namespace Node {
export type ChildNode = AtRule.default | Comment | Declaration | Rule
export type AnyNode =
| AtRule.default
| Comment
| Declaration
| Document
| Root
| Rule
export type ChildProps =
| AtRuleProps
| CommentProps
| DeclarationProps
| RuleProps
export interface Position {
/**
* Source line in file. In contrast to `offset` it starts from 1.
*/
column: number
/**
* Source column in file.
*/
line: number
/**
* Source offset in file. It starts from 0.
*/
offset: number
}
export interface Range {
/**
* End position, exclusive.
*/
end: Position
/**
* Start position, inclusive.
*/
start: Position
}
/**
* Source represents an interface for the {@link Node.source} property.
*/
export interface Source {
/**
* The inclusive ending position for the source
* code of a node.
*/
end?: Position
/**
* The source file from where a node has originated.
*/
input: Input
/**
* The inclusive starting position for the source
* code of a node.
*/
start?: Position
}
/**
* Interface represents an interface for an object received
* as parameter by Node class constructor.
*/
export interface NodeProps {
source?: Source
}
export interface NodeErrorOptions {
/**
* An ending index inside a node's string that should be highlighted as
* source of error.
*/
endIndex?: number
/**
* An index inside a node's string that should be highlighted as source
* of error.
*/
index?: number
/**
* Plugin name that created this error. PostCSS will set it automatically.
*/
plugin?: string
/**
* A word inside a node's string, that should be highlighted as source
* of error.
*/
word?: string
}
// eslint-disable-next-line @typescript-eslint/no-shadow
class Node extends Node_ {}
export { Node as default }
}
/**
* It represents an abstract class that handles common
* methods for other CSS abstract syntax tree nodes.
*
* Any node that represents CSS selector or value should
* not extend the `Node` class.
*/
declare abstract class Node_ {
/**
* It represents parent of the current node.
*
* ```js
* root.nodes[0].parent === root //=> true
* ```
*/
parent: Container | Document | undefined
/**
* It represents unnecessary whitespace and characters present
* in the css source code.
*
* Information to generate byte-to-byte equal node string as it was
* in the origin input.
*
* The properties of the raws object are decided by parser,
* the default parser uses the following properties:
*
* * `before`: the space symbols before the node. It also stores `*`
* and `_` symbols before the declaration (IE hack).
* * `after`: the space symbols after the last child of the node
* to the end of the node.
* * `between`: the symbols between the property and value
* for declarations, selector and `{` for rules, or last parameter
* and `{` for at-rules.
* * `semicolon`: contains true if the last child has
* an (optional) semicolon.
* * `afterName`: the space between the at-rule name and its parameters.
* * `left`: the space symbols between `/*` and the comments text.
* * `right`: the space symbols between the comments text
* and <code>*&#47;</code>.
* - `important`: the content of the important statement,
* if it is not just `!important`.
*
* PostCSS filters out the comments inside selectors, declaration values
* and at-rule parameters but it stores the origin content in raws.
*
* ```js
* const root = postcss.parse('a {\n color:black\n}')
* root.first.first.raws //=> { before: '\n ', between: ':' }
* ```
*/
raws: any
/**
* It represents information related to origin of a node and is required
* for generating source maps.
*
* The nodes that are created manually using the public APIs
* provided by PostCSS will have `source` undefined and
* will be absent in the source map.
*
* For this reason, the plugin developer should consider
* duplicating nodes as the duplicate node will have the
* same source as the original node by default or assign
* source to a node created manually.
*
* ```js
* decl.source.input.from //=> '/home/ai/source.css'
* decl.source.start //=> { line: 10, column: 2 }
* decl.source.end //=> { line: 10, column: 12 }
* ```
*
* ```js
* // Incorrect method, source not specified!
* const prefixed = postcss.decl({
* prop: '-moz-' + decl.prop,
* value: decl.value
* })
*
* // Correct method, source is inherited when duplicating.
* const prefixed = decl.clone({
* prop: '-moz-' + decl.prop
* })
* ```
*
* ```js
* if (atrule.name === 'add-link') {
* const rule = postcss.rule({
* selector: 'a',
* source: atrule.source
* })
*
* atrule.parent.insertBefore(atrule, rule)
* }
* ```
*/
source?: Node.Source
/**
* It represents type of a node in
* an abstract syntax tree.
*
* A type of node helps in identification of a node
* and perform operation based on it's type.
*
* ```js
* const declaration = new Declaration({
* prop: 'color',
* value: 'black'
* })
*
* declaration.type //=> 'decl'
* ```
*/
type: string
constructor(defaults?: object)
/**
* Insert new node after current node to current nodes parent.
*
* Just alias for `node.parent.insertAfter(node, add)`.
*
* ```js
* decl.after('color: black')
* ```
*
* @param newNode New node.
* @return This node for methods chain.
*/
after(newNode: Node | Node.ChildProps | Node[] | string | undefined): this
/**
* It assigns properties to an existing node instance.
*
* ```js
* decl.assign({ prop: 'word-wrap', value: 'break-word' })
* ```
*
* @param overrides New properties to override the node.
*
* @return `this` for method chaining.
*/
assign(overrides: object): this
/**
* Insert new node before current node to current nodes parent.
*
* Just alias for `node.parent.insertBefore(node, add)`.
*
* ```js
* decl.before('content: ""')
* ```
*
* @param newNode New node.
* @return This node for methods chain.
*/
before(newNode: Node | Node.ChildProps | Node[] | string | undefined): this
/**
* Clear the code style properties for the node and its children.
*
* ```js
* node.raws.before //=> ' '
* node.cleanRaws()
* node.raws.before //=> undefined
* ```
*
* @param keepBetween Keep the `raws.between` symbols.
*/
cleanRaws(keepBetween?: boolean): void
/**
* It creates clone of an existing node, which includes all the properties
* and their values, that includes `raws` but not `type`.
*
* ```js
* decl.raws.before //=> "\n "
* const cloned = decl.clone({ prop: '-moz-' + decl.prop })
* cloned.raws.before //=> "\n "
* cloned.toString() //=> -moz-transform: scale(0)
* ```
*
* @param overrides New properties to override in the clone.
*
* @return Duplicate of the node instance.
*/
clone(overrides?: object): Node
/**
* Shortcut to clone the node and insert the resulting cloned node
* after the current node.
*
* @param overrides New properties to override in the clone.
* @return New node.
*/
cloneAfter(overrides?: object): Node
/**
* Shortcut to clone the node and insert the resulting cloned node
* before the current node.
*
* ```js
* decl.cloneBefore({ prop: '-moz-' + decl.prop })
* ```
*
* @param overrides Mew properties to override in the clone.
*
* @return New node
*/
cloneBefore(overrides?: object): Node
/**
* It creates an instance of the class `CssSyntaxError` and parameters passed
* to this method are assigned to the error instance.
*
* The error instance will have description for the
* error, original position of the node in the
* source, showing line and column number.
*
* If any previous map is present, it would be used
* to get original position of the source.
*
* The Previous Map here is referred to the source map
* generated by previous compilation, example: Less,
* Stylus and Sass.
*
* This method returns the error instance instead of
* throwing it.
*
* ```js
* if (!variables[name]) {
* throw decl.error(`Unknown variable ${name}`, { word: name })
* // CssSyntaxError: postcss-vars:a.sass:4:3: Unknown variable $black
* // color: $black
* // a
* // ^
* // background: white
* }
* ```
*
* @param message Description for the error instance.
* @param options Options for the error instance.
*
* @return Error instance is returned.
*/
error(message: string, options?: Node.NodeErrorOptions): CssSyntaxError
/**
* Returns the next child of the nodes parent.
* Returns `undefined` if the current node is the last child.
*
* ```js
* if (comment.text === 'delete next') {
* const next = comment.next()
* if (next) {
* next.remove()
* }
* }
* ```
*
* @return Next node.
*/
next(): Node.ChildNode | undefined
/**
* Get the position for a word or an index inside the node.
*
* @param opts Options.
* @return Position.
*/
positionBy(opts?: Pick<WarningOptions, 'index' | 'word'>): Node.Position
/**
* Convert string index to line/column.
*
* @param index The symbol number in the nodes string.
* @return Symbol position in file.
*/
positionInside(index: number): Node.Position
/**
* Returns the previous child of the nodes parent.
* Returns `undefined` if the current node is the first child.
*
* ```js
* const annotation = decl.prev()
* if (annotation.type === 'comment') {
* readAnnotation(annotation.text)
* }
* ```
*
* @return Previous node.
*/
prev(): Node.ChildNode | undefined
/**
* Get the range for a word or start and end index inside the node.
* The start index is inclusive; the end index is exclusive.
*
* @param opts Options.
* @return Range.
*/
rangeBy(
opts?: Pick<WarningOptions, 'endIndex' | 'index' | 'word'>
): Node.Range
/**
* Returns a `raws` value. If the node is missing
* the code style property (because the node was manually built or cloned),
* PostCSS will try to autodetect the code style property by looking
* at other nodes in the tree.
*
* ```js
* const root = postcss.parse('a { background: white }')
* root.nodes[0].append({ prop: 'color', value: 'black' })
* root.nodes[0].nodes[1].raws.before //=> undefined
* root.nodes[0].nodes[1].raw('before') //=> ' '
* ```
*
* @param prop Name of code style property.
* @param defaultType Name of default value, it can be missed
* if the value is the same as prop.
* @return {string} Code style value.
*/
raw(prop: string, defaultType?: string): string
/**
* It removes the node from its parent and deletes its parent property.
*
* ```js
* if (decl.prop.match(/^-webkit-/)) {
* decl.remove()
* }
* ```
*
* @return `this` for method chaining.
*/
remove(): this
/**
* Inserts node(s) before the current node and removes the current node.
*
* ```js
* AtRule: {
* mixin: atrule => {
* atrule.replaceWith(mixinRules[atrule.params])
* }
* }
* ```
*
* @param nodes Mode(s) to replace current one.
* @return Current node to methods chain.
*/
replaceWith(
...nodes: (
| Node.ChildNode
| Node.ChildNode[]
| Node.ChildProps
| Node.ChildProps[]
)[]
): this
/**
* Finds the Root instance of the nodes tree.
*
* ```js
* root.nodes[0].nodes[0].root() === root
* ```
*
* @return Root parent.
*/
root(): Root
/**
* Fix circular links on `JSON.stringify()`.
*
* @return Cleaned object.
*/
toJSON(): object
/**
* It compiles the node to browser readable cascading style sheets string
* depending on it's type.
*
* ```js
* new Rule({ selector: 'a' }).toString() //=> "a {}"
* ```
*
* @param stringifier A syntax to use in string generation.
* @return CSS string of this node.
*/
toString(stringifier?: Stringifier | Syntax): string
/**
* It is a wrapper for {@link Result#warn}, providing convenient
* way of generating warnings.
*
* ```js
* Declaration: {
* bad: (decl, { result }) => {
* decl.warn(result, 'Deprecated property: bad')
* }
* }
* ```
*
* @param result The `Result` instance that will receive the warning.
* @param message Description for the warning.
* @param options Options for the warning.
*
* @return `Warning` instance is returned
*/
warn(result: Result, message: string, options?: WarningOptions): Warning
}
declare class Node extends Node_ {}
export = Node

9
src/postcss/parse.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import { Parser } from './postcss.js'
interface Parse extends Parser {
default: Parse
}
declare const parse: Parse
export = parse

436
src/postcss/postcss.d.ts vendored Normal file
View File

@@ -0,0 +1,436 @@
import AtRule, { AtRuleProps } from './at-rule.js'
import Comment, { CommentProps } from './comment.js'
import Container, { ContainerProps } from './container.js'
import CssSyntaxError from './css-syntax-error.js'
import Declaration, { DeclarationProps } from './declaration.js'
import Document, { DocumentProps } from './document.js'
import Input, { FilePosition } from './input.js'
import LazyResult from './lazy-result.js'
import list from './list.js'
import Node, {
AnyNode,
ChildNode,
ChildProps,
NodeErrorOptions,
NodeProps,
Position,
Source
} from './node.js'
import Processor from './processor.js'
import Result, { Message } from './result.js'
import Root, { RootProps } from './root.js'
import Rule, { RuleProps } from './rule.js'
import Warning, { WarningOptions } from './warning.js'
type DocumentProcessor = (
document: Document,
helper: postcss.Helpers
) => Promise<void> | void
type RootProcessor = (root: Root, helper: postcss.Helpers) => Promise<void> | void
type DeclarationProcessor = (
decl: Declaration,
helper: postcss.Helpers
) => Promise<void> | void
type RuleProcessor = (rule: Rule, helper: postcss.Helpers) => Promise<void> | void
type AtRuleProcessor = (atRule: AtRule, helper: postcss.Helpers) => Promise<void> | void
type CommentProcessor = (
comment: Comment,
helper: postcss.Helpers
) => Promise<void> | void
interface Processors {
/**
* Will be called on all`AtRule` nodes.
*
* Will be called again on node or children changes.
*/
AtRule?: { [name: string]: AtRuleProcessor } | AtRuleProcessor
/**
* Will be called on all `AtRule` nodes, when all children will be processed.
*
* Will be called again on node or children changes.
*/
AtRuleExit?: { [name: string]: AtRuleProcessor } | AtRuleProcessor
/**
* Will be called on all `Comment` nodes.
*
* Will be called again on node or children changes.
*/
Comment?: CommentProcessor
/**
* Will be called on all `Comment` nodes after listeners
* for `Comment` event.
*
* Will be called again on node or children changes.
*/
CommentExit?: CommentProcessor
/**
* Will be called on all `Declaration` nodes after listeners
* for `Declaration` event.
*
* Will be called again on node or children changes.
*/
Declaration?: { [prop: string]: DeclarationProcessor } | DeclarationProcessor
/**
* Will be called on all `Declaration` nodes.
*
* Will be called again on node or children changes.
*/
DeclarationExit?:
| { [prop: string]: DeclarationProcessor }
| DeclarationProcessor
/**
* Will be called on `Document` node.
*
* Will be called again on children changes.
*/
Document?: DocumentProcessor
/**
* Will be called on `Document` node, when all children will be processed.
*
* Will be called again on children changes.
*/
DocumentExit?: DocumentProcessor
/**
* Will be called on `Root` node once.
*/
Once?: RootProcessor
/**
* Will be called on `Root` node once, when all children will be processed.
*/
OnceExit?: RootProcessor
/**
* Will be called on `Root` node.
*
* Will be called again on children changes.
*/
Root?: RootProcessor
/**
* Will be called on `Root` node, when all children will be processed.
*
* Will be called again on children changes.
*/
RootExit?: RootProcessor
/**
* Will be called on all `Rule` nodes.
*
* Will be called again on node or children changes.
*/
Rule?: RuleProcessor
/**
* Will be called on all `Rule` nodes, when all children will be processed.
*
* Will be called again on node or children changes.
*/
RuleExit?: RuleProcessor
}
declare namespace postcss {
export {
AnyNode,
AtRule,
AtRuleProps,
ChildNode,
ChildProps,
Comment,
CommentProps,
Container,
ContainerProps,
CssSyntaxError,
Declaration,
DeclarationProps,
Document,
DocumentProps,
FilePosition,
Input,
LazyResult,
list,
Message,
Node,
NodeErrorOptions,
NodeProps,
Position,
Processor,
Result,
Root,
RootProps,
Rule,
RuleProps,
Source,
Warning,
WarningOptions
}
export type Helpers = { postcss: Postcss; result: Result } & Postcss
export interface Plugin extends Processors {
postcssPlugin: string
prepare?: (result: Result) => Processors
}
export interface PluginCreator<PluginOptions> {
(opts?: PluginOptions): Plugin | Processor
postcss: true
}
export interface Transformer extends TransformCallback {
postcssPlugin: string
postcssVersion: string
}
export interface TransformCallback {
(root: Root, result: Result): Promise<void> | void
}
export interface OldPlugin<T> extends Transformer {
(opts?: T): Transformer
postcss: Transformer
}
export type AcceptedPlugin =
| {
postcss: Processor | TransformCallback
}
| OldPlugin<any>
| Plugin
| PluginCreator<any>
| Processor
| TransformCallback
export interface Parser<RootNode = Document | Root> {
(
css: { toString(): string } | string,
opts?: Pick<ProcessOptions, 'from' | 'map'>
): RootNode
}
export interface Builder {
(part: string, node?: AnyNode, type?: 'end' | 'start'): void
}
export interface Stringifier {
(node: AnyNode, builder: Builder): void
}
export interface JSONHydrator {
(data: object): Node
(data: object[]): Node[]
}
export interface Syntax<RootNode = Document | Root> {
/**
* Function to generate AST by string.
*/
parse?: Parser<RootNode>
/**
* Class to generate string by AST.
*/
stringify?: Stringifier
}
export interface SourceMapOptions {
/**
* Use absolute path in generated source map.
*/
absolute?: boolean
/**
* Indicates that PostCSS should add annotation comments to the CSS.
* By default, PostCSS will always add a comment with a path
* to the source map. PostCSS will not add annotations to CSS files
* that do not contain any comments.
*
* By default, PostCSS presumes that you want to save the source map as
* `opts.to + '.map'` and will use this path in the annotation comment.
* A different path can be set by providing a string value for annotation.
*
* If you have set `inline: true`, annotation cannot be disabled.
*/
annotation?: ((file: string, root: Root) => string) | boolean | string
/**
* Override `from` in maps sources.
*/
from?: string
/**
* Indicates that the source map should be embedded in the output CSS
* as a Base64-encoded comment. By default, it is `true`.
* But if all previous maps are external, not inline, PostCSS will not embed
* the map even if you do not set this option.
*
* If you have an inline source map, the result.map property will be empty,
* as the source map will be contained within the text of `result.css`.
*/
inline?: boolean
/**
* Source map content from a previous processing step (e.g., Sass).
*
* PostCSS will try to read the previous source map
* automatically (based on comments within the source CSS), but you can use
* this option to identify it manually.
*
* If desired, you can omit the previous map with prev: `false`.
*/
prev?: ((file: string) => string) | boolean | object | string
/**
* Indicates that PostCSS should set the origin content (e.g., Sass source)
* of the source map. By default, it is true. But if all previous maps do not
* contain sources content, PostCSS will also leave it out even if you
* do not set this option.
*/
sourcesContent?: boolean
}
export interface ProcessOptions<RootNode = Document | Root> {
/**
* The path of the CSS source file. You should always set `from`,
* because it is used in source map generation and syntax error messages.
*/
from?: string | undefined
/**
* Source map options
*/
map?: boolean | SourceMapOptions
/**
* Function to generate AST by string.
*/
parser?: Parser<RootNode> | Syntax<RootNode>
/**
* Class to generate string by AST.
*/
stringifier?: Stringifier | Syntax<RootNode>
/**
* Object with parse and stringify.
*/
syntax?: Syntax<RootNode>
/**
* The path where you'll put the output CSS file. You should always set `to`
* to generate correct source maps.
*/
to?: string
}
export type Postcss = typeof postcss
/**
* Default function to convert a node tree into a CSS string.
*/
export let stringify: Stringifier
/**
* Parses source css and returns a new `Root` or `Document` node,
* which contains the source CSS nodes.
*
* ```js
* // Simple CSS concatenation with source map support
* const root1 = postcss.parse(css1, { from: file1 })
* const root2 = postcss.parse(css2, { from: file2 })
* root1.append(root2).toResult().css
* ```
*/
export let parse: Parser<Root>
/**
* Rehydrate a JSON AST (from `Node#toJSON`) back into the AST classes.
*
* ```js
* const json = root.toJSON()
* // save to file, send by network, etc
* const root2 = postcss.fromJSON(json)
* ```
*/
export let fromJSON: JSONHydrator
/**
* Creates a new `Comment` node.
*
* @param defaults Properties for the new node.
* @return New comment node
*/
export function comment(defaults?: CommentProps): Comment
/**
* Creates a new `AtRule` node.
*
* @param defaults Properties for the new node.
* @return New at-rule node.
*/
export function atRule(defaults?: AtRuleProps): AtRule
/**
* Creates a new `Declaration` node.
*
* @param defaults Properties for the new node.
* @return New declaration node.
*/
export function decl(defaults?: DeclarationProps): Declaration
/**
* Creates a new `Rule` node.
*
* @param default Properties for the new node.
* @return New rule node.
*/
export function rule(defaults?: RuleProps): Rule
/**
* Creates a new `Root` node.
*
* @param defaults Properties for the new node.
* @return New root node.
*/
export function root(defaults?: RootProps): Root
/**
* Creates a new `Document` node.
*
* @param defaults Properties for the new node.
* @return New document node.
*/
export function document(defaults?: DocumentProps): Document
export { postcss as default }
}
/**
* Create a new `Processor` instance that will apply `plugins`
* as CSS processors.
*
* ```js
* let postcss = require('postcss')
*
* postcss(plugins).process(css, { from, to }).then(result => {
* console.log(result.css)
* })
* ```
*
* @param plugins PostCSS plugins.
* @return Processor to process multiple CSS.
*/
declare function postcss(plugins?: postcss.AcceptedPlugin[]): Processor
declare function postcss(...plugins: postcss.AcceptedPlugin[]): Processor
export = postcss

6520
src/postcss/postcss.js Normal file

File diff suppressed because it is too large Load Diff

81
src/postcss/previous-map.d.ts vendored Normal file
View File

@@ -0,0 +1,81 @@
import { SourceMapConsumer } from 'source-map-js'
import { ProcessOptions } from './postcss.js'
declare namespace PreviousMap {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { PreviousMap_ as default }
}
/**
* Source map information from input CSS.
* For example, source map after Sass compiler.
*
* This class will automatically find source map in input CSS or in file system
* near input file (according `from` option).
*
* ```js
* const root = parse(css, { from: 'a.sass.css' })
* root.input.map //=> PreviousMap
* ```
*/
declare class PreviousMap_ {
/**
* `sourceMappingURL` content.
*/
annotation?: string
/**
* The CSS source identifier. Contains `Input#file` if the user
* set the `from` option, or `Input#id` if they did not.
*/
file?: string
/**
* Was source map inlined by data-uri to input CSS.
*/
inline: boolean
/**
* Path to source map file.
*/
mapFile?: string
/**
* The directory with source map file, if source map is in separated file.
*/
root?: string
/**
* Source map file content.
*/
text?: string
/**
* @param css Input CSS source.
* @param opts Process options.
*/
constructor(css: string, opts?: ProcessOptions)
/**
* Create a instance of `SourceMapGenerator` class
* from the `source-map` library to work with source map information.
*
* It is lazy method, so it will create object only on first call
* and then it will use cache.
*
* @return Object with source map information.
*/
consumer(): SourceMapConsumer
/**
* Does source map contains `sourcesContent` with input source text.
*
* @return Is `sourcesContent` present.
*/
withContent(): boolean
}
declare class PreviousMap extends PreviousMap_ {}
export = PreviousMap

115
src/postcss/processor.d.ts vendored Normal file
View File

@@ -0,0 +1,115 @@
import Document from './document.js'
import LazyResult from './lazy-result.js'
import NoWorkResult from './no-work-result.js'
import {
AcceptedPlugin,
Plugin,
ProcessOptions,
TransformCallback,
Transformer
} from './postcss.js'
import Result from './result.js'
import Root from './root.js'
declare namespace Processor {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { Processor_ as default }
}
/**
* Contains plugins to process CSS. Create one `Processor` instance,
* initialize its plugins, and then use that instance on numerous CSS files.
*
* ```js
* const processor = postcss([autoprefixer, postcssNested])
* processor.process(css1).then(result => console.log(result.css))
* processor.process(css2).then(result => console.log(result.css))
* ```
*/
declare class Processor_ {
/**
* Plugins added to this processor.
*
* ```js
* const processor = postcss([autoprefixer, postcssNested])
* processor.plugins.length //=> 2
* ```
*/
plugins: (Plugin | TransformCallback | Transformer)[]
/**
* Current PostCSS version.
*
* ```js
* if (result.processor.version.split('.')[0] !== '6') {
* throw new Error('This plugin works only with PostCSS 6')
* }
* ```
*/
version: string
/**
* @param plugins PostCSS plugins
*/
constructor(plugins?: AcceptedPlugin[])
/**
* Parses source CSS and returns a `LazyResult` Promise proxy.
* Because some plugins can be asynchronous it doesnt make
* any transformations. Transformations will be applied
* in the `LazyResult` methods.
*
* ```js
* processor.process(css, { from: 'a.css', to: 'a.out.css' })
* .then(result => {
* console.log(result.css)
* })
* ```
*
* @param css String with input CSS or any object with a `toString()` method,
* like a Buffer. Optionally, send a `Result` instance
* and the processor will take the `Root` from it.
* @param opts Options.
* @return Promise proxy.
*/
process(
css: { toString(): string } | LazyResult | Result | Root | string
): LazyResult | NoWorkResult
process<RootNode extends Document | Root = Root>(
css: { toString(): string } | LazyResult | Result | Root | string,
options: ProcessOptions<RootNode>
): LazyResult<RootNode>
/**
* Adds a plugin to be used as a CSS processor.
*
* PostCSS plugin can be in 4 formats:
* * A plugin in `Plugin` format.
* * A plugin creator function with `pluginCreator.postcss = true`.
* PostCSS will call this function without argument to get plugin.
* * A function. PostCSS will pass the function a {@link Root}
* as the first argument and current `Result` instance
* as the second.
* * Another `Processor` instance. PostCSS will copy plugins
* from that instance into this one.
*
* Plugins can also be added by passing them as arguments when creating
* a `postcss` instance (see [`postcss(plugins)`]).
*
* Asynchronous plugins should return a `Promise` instance.
*
* ```js
* const processor = postcss()
* .use(autoprefixer)
* .use(postcssNested)
* ```
*
* @param plugin PostCSS plugin or `Processor` with plugins.
* @return Current processor to make methods chain.
*/
use(plugin: AcceptedPlugin): this
}
declare class Processor extends Processor_ {}
export = Processor

206
src/postcss/result.d.ts vendored Normal file
View File

@@ -0,0 +1,206 @@
import {
Document,
Node,
Plugin,
ProcessOptions,
Root,
SourceMap,
TransformCallback,
Warning,
WarningOptions
} from './postcss.js'
import Processor from './processor.js'
declare namespace Result {
export interface Message {
[others: string]: any
/**
* Source PostCSS plugin name.
*/
plugin?: string
/**
* Message type.
*/
type: string
}
export interface ResultOptions extends ProcessOptions {
/**
* The CSS node that was the source of the warning.
*/
node?: Node
/**
* Name of plugin that created this warning. `Result#warn` will fill it
* automatically with `Plugin#postcssPlugin` value.
*/
plugin?: string
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { Result_ as default }
}
/**
* Provides the result of the PostCSS transformations.
*
* A Result instance is returned by `LazyResult#then`
* or `Root#toResult` methods.
*
* ```js
* postcss([autoprefixer]).process(css).then(result => {
* console.log(result.css)
* })
* ```
*
* ```js
* const result2 = postcss.parse(css).toResult()
* ```
*/
declare class Result_<RootNode = Document | Root> {
/**
* A CSS string representing of `Result#root`.
*
* ```js
* postcss.parse('a{}').toResult().css //=> "a{}"
* ```
*/
css: string
/**
* Last runned PostCSS plugin.
*/
lastPlugin: Plugin | TransformCallback
/**
* An instance of `SourceMapGenerator` class from the `source-map` library,
* representing changes to the `Result#root` instance.
*
* ```js
* result.map.toJSON() //=> { version: 3, file: 'a.css', … }
* ```
*
* ```js
* if (result.map) {
* fs.writeFileSync(result.opts.to + '.map', result.map.toString())
* }
* ```
*/
map: SourceMap
/**
* Contains messages from plugins (e.g., warnings or custom messages).
* Each message should have type and plugin properties.
*
* ```js
* AtRule: {
* import: (atRule, { result }) {
* const importedFile = parseImport(atRule)
* result.messages.push({
* type: 'dependency',
* plugin: 'postcss-import',
* file: importedFile,
* parent: result.opts.from
* })
* }
* }
* ```
*/
messages: Result.Message[]
/**
* Options from the `Processor#process` or `Root#toResult` call
* that produced this Result instance.]
*
* ```js
* root.toResult(opts).opts === opts
* ```
*/
opts: Result.ResultOptions
/**
* The Processor instance used for this transformation.
*
* ```js
* for (const plugin of result.processor.plugins) {
* if (plugin.postcssPlugin === 'postcss-bad') {
* throw 'postcss-good is incompatible with postcss-bad'
* }
* })
* ```
*/
processor: Processor
/**
* Root node after all transformations.
*
* ```js
* root.toResult().root === root
* ```
*/
root: RootNode
/**
* @param processor Processor used for this transformation.
* @param root Root node after all transformations.
* @param opts Options from the `Processor#process` or `Root#toResult`.
*/
constructor(processor: Processor, root: RootNode, opts: Result.ResultOptions)
/**
* Returns for `Result#css` content.
*
* ```js
* result + '' === result.css
* ```
*
* @return String representing of `Result#root`.
*/
toString(): string
/**
* Creates an instance of `Warning` and adds it to `Result#messages`.
*
* ```js
* if (decl.important) {
* result.warn('Avoid !important', { node: decl, word: '!important' })
* }
* ```
*
* @param text Warning message.
* @param opts Warning options.
* @return Created warning.
*/
warn(message: string, options?: WarningOptions): Warning
/**
* Returns warnings from plugins. Filters `Warning` instances
* from `Result#messages`.
*
* ```js
* result.warnings().forEach(warn => {
* console.warn(warn.toString())
* })
* ```
*
* @return Warnings from plugins.
*/
warnings(): Warning[]
/**
* An alias for the `Result#css` property.
* Use it with syntaxes that generate non-CSS output.
*
* ```js
* result.css === result.content
* ```
*/
get content(): string
}
declare class Result<RootNode = Document | Root> extends Result_<RootNode> {}
export = Result

87
src/postcss/root.d.ts vendored Normal file
View File

@@ -0,0 +1,87 @@
import Container, { ContainerProps } from './container.js'
import Document from './document.js'
import { ProcessOptions } from './postcss.js'
import Result from './result.js'
declare namespace Root {
export interface RootRaws extends Record<string, any> {
/**
* The space symbols after the last child to the end of file.
*/
after?: string
/**
* Non-CSS code after `Root`, when `Root` is inside `Document`.
*
* **Experimental:** some aspects of this node could change within minor
* or patch version releases.
*/
codeAfter?: string
/**
* Non-CSS code before `Root`, when `Root` is inside `Document`.
*
* **Experimental:** some aspects of this node could change within minor
* or patch version releases.
*/
codeBefore?: string
/**
* Is the last child has an (optional) semicolon.
*/
semicolon?: boolean
}
export interface RootProps extends ContainerProps {
/**
* Information used to generate byte-to-byte equal node string
* as it was in the origin input.
* */
raws?: RootRaws
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { Root_ as default }
}
/**
* Represents a CSS file and contains all its parsed nodes.
*
* ```js
* const root = postcss.parse('a{color:black} b{z-index:2}')
* root.type //=> 'root'
* root.nodes.length //=> 2
* ```
*/
declare class Root_ extends Container {
nodes: NonNullable<Container['nodes']>
parent: Document | undefined
raws: Root.RootRaws
type: 'root'
constructor(defaults?: Root.RootProps)
assign(overrides: object | Root.RootProps): this
clone(overrides?: Partial<Root.RootProps>): Root
cloneAfter(overrides?: Partial<Root.RootProps>): Root
cloneBefore(overrides?: Partial<Root.RootProps>): Root
/**
* Returns a `Result` instance representing the roots CSS.
*
* ```js
* const root1 = postcss.parse(css1, { from: 'a.css' })
* const root2 = postcss.parse(css2, { from: 'b.css' })
* root1.append(root2)
* const result = root1.toResult({ to: 'all.css', map: true })
* ```
*
* @param opts Options.
* @return Result with current roots CSS.
*/
toResult(options?: ProcessOptions): Result
}
declare class Root extends Root_ {}
export = Root

119
src/postcss/rule.d.ts vendored Normal file
View File

@@ -0,0 +1,119 @@
import Container, {
ContainerProps,
ContainerWithChildren
} from './container.js'
declare namespace Rule {
export interface RuleRaws extends Record<string, unknown> {
/**
* The space symbols after the last child of the node to the end of the node.
*/
after?: string
/**
* The space symbols before the node. It also stores `*`
* and `_` symbols before the declaration (IE hack).
*/
before?: string
/**
* The symbols between the selector and `{` for rules.
*/
between?: string
/**
* Contains `true` if there is semicolon after rule.
*/
ownSemicolon?: string
/**
* The rules selector with comments.
*/
selector?: {
raw: string
value: string
}
/**
* Contains `true` if the last child has an (optional) semicolon.
*/
semicolon?: boolean
}
export interface RuleProps extends ContainerProps {
/** Information used to generate byte-to-byte equal node string as it was in the origin input. */
raws?: RuleRaws
/** Selector or selectors of the rule. */
selector?: string
/** Selectors of the rule represented as an array of strings. */
selectors?: string[]
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { Rule_ as default }
}
/**
* Represents a CSS rule: a selector followed by a declaration block.
*
* ```js
* Once (root, { Rule }) {
* let a = new Rule({ selector: 'a' })
* a.append(…)
* root.append(a)
* }
* ```
*
* ```js
* const root = postcss.parse('a{}')
* const rule = root.first
* rule.type //=> 'rule'
* rule.toString() //=> 'a{}'
* ```
*/
declare class Rule_ extends Container {
nodes: NonNullable<Container['nodes']>
parent: ContainerWithChildren | undefined
raws: Rule.RuleRaws
/**
* The rules full selector represented as a string.
*
* ```js
* const root = postcss.parse('a, b { }')
* const rule = root.first
* rule.selector //=> 'a, b'
* ```
*/
get selector(): string
set selector(value: string);
/**
* An array containing the rules individual selectors.
* Groups of selectors are split at commas.
*
* ```js
* const root = postcss.parse('a, b { }')
* const rule = root.first
*
* rule.selector //=> 'a, b'
* rule.selectors //=> ['a', 'b']
*
* rule.selectors = ['a', 'strong']
* rule.selector //=> 'a, strong'
* ```
*/
get selectors(): string[]
set selectors(values: string[]);
type: 'rule'
constructor(defaults?: Rule.RuleProps)
assign(overrides: object | Rule.RuleProps): this
clone(overrides?: Partial<Rule.RuleProps>): Rule
cloneAfter(overrides?: Partial<Rule.RuleProps>): Rule
cloneBefore(overrides?: Partial<Rule.RuleProps>): Rule
}
declare class Rule extends Rule_ {}
export = Rule

46
src/postcss/stringifier.d.ts vendored Normal file
View File

@@ -0,0 +1,46 @@
import {
AnyNode,
AtRule,
Builder,
Comment,
Container,
Declaration,
Document,
Root,
Rule
} from './postcss.js'
declare namespace Stringifier {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { Stringifier_ as default }
}
declare class Stringifier_ {
builder: Builder
constructor(builder: Builder)
atrule(node: AtRule, semicolon?: boolean): void
beforeAfter(node: AnyNode, detect: 'after' | 'before'): string
block(node: AnyNode, start: string): void
body(node: Container): void
comment(node: Comment): void
decl(node: Declaration, semicolon?: boolean): void
document(node: Document): void
raw(node: AnyNode, own: null | string, detect?: string): string
rawBeforeClose(root: Root): string | undefined
rawBeforeComment(root: Root, node: Comment): string | undefined
rawBeforeDecl(root: Root, node: Declaration): string | undefined
rawBeforeOpen(root: Root): string | undefined
rawBeforeRule(root: Root): string | undefined
rawColon(root: Root): string | undefined
rawEmptyBody(root: Root): string | undefined
rawIndent(root: Root): string | undefined
rawSemicolon(root: Root): boolean | undefined
rawValue(node: AnyNode, prop: string): string
root(node: Root): void
rule(node: Rule): void
stringify(node: AnyNode, semicolon?: boolean): void
}
declare class Stringifier extends Stringifier_ {}
export = Stringifier

9
src/postcss/stringify.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import { Stringifier } from './postcss.js'
interface Stringify extends Stringifier {
default: Stringify
}
declare const stringify: Stringify
export = stringify

147
src/postcss/warning.d.ts vendored Normal file
View File

@@ -0,0 +1,147 @@
import { RangePosition } from './css-syntax-error.js'
import Node from './node.js'
declare namespace Warning {
export interface WarningOptions {
/**
* End position, exclusive, in CSS node string that caused the warning.
*/
end?: RangePosition
/**
* End index, exclusive, in CSS node string that caused the warning.
*/
endIndex?: number
/**
* Start index, inclusive, in CSS node string that caused the warning.
*/
index?: number
/**
* CSS node that caused the warning.
*/
node?: Node
/**
* Name of the plugin that created this warning. `Result#warn` fills
* this property automatically.
*/
plugin?: string
/**
* Start position, inclusive, in CSS node string that caused the warning.
*/
start?: RangePosition
/**
* Word in CSS source that caused the warning.
*/
word?: string
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export { Warning_ as default }
}
/**
* Represents a plugins warning. It can be created using `Node#warn`.
*
* ```js
* if (decl.important) {
* decl.warn(result, 'Avoid !important', { word: '!important' })
* }
* ```
*/
declare class Warning_ {
/**
* Column for inclusive start position in the input file with this warnings source.
*
* ```js
* warning.column //=> 6
* ```
*/
column: number
/**
* Column for exclusive end position in the input file with this warnings source.
*
* ```js
* warning.endColumn //=> 4
* ```
*/
endColumn?: number
/**
* Line for exclusive end position in the input file with this warnings source.
*
* ```js
* warning.endLine //=> 6
* ```
*/
endLine?: number
/**
* Line for inclusive start position in the input file with this warnings source.
*
* ```js
* warning.line //=> 5
* ```
*/
line: number
/**
* Contains the CSS node that caused the warning.
*
* ```js
* warning.node.toString() //=> 'color: white !important'
* ```
*/
node: Node
/**
* The name of the plugin that created this warning.
* When you call `Node#warn` it will fill this property automatically.
*
* ```js
* warning.plugin //=> 'postcss-important'
* ```
*/
plugin: string
/**
* The warning message.
*
* ```js
* warning.text //=> 'Try to avoid !important'
* ```
*/
text: string
/**
* Type to filter warnings from `Result#messages`.
* Always equal to `"warning"`.
*/
type: 'warning'
/**
* @param text Warning message.
* @param opts Warning options.
*/
constructor(text: string, opts?: Warning.WarningOptions)
/**
* Returns a warning position and message.
*
* ```js
* warning.toString() //=> 'postcss-lint:a.css:10:14: Avoid !important'
* ```
*
* @return Warning position and message.
*/
toString(): string
}
declare class Warning extends Warning_ {}
export = Warning

36
src/preprocess/index.ts Normal file
View File

@@ -0,0 +1,36 @@
// [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;
}

69
src/refactor-plan.md Normal file
View File

@@ -0,0 +1,69 @@
# 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
---
(实现过程中该文档可增补)

50
src/render/index.ts Normal file
View File

@@ -0,0 +1,50 @@
// [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 };
}
}

459
src/setting-tab.ts Normal file
View File

@@ -0,0 +1,459 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { App, TextAreaComponent, PluginSettingTab, Setting, Notice, sanitizeHTMLToDom } from 'obsidian';
import NoteToMpPlugin from './main';
import { wxGetToken,wxEncrypt } from './weixin-api';
import { cleanMathCache } from './markdown/math';
import { NMPSettings } from './settings';
import { DocModal } from './doc-modal';
export class NoteToMpSettingTab extends PluginSettingTab {
plugin: NoteToMpPlugin;
wxInfo: string;
wxTextArea: TextAreaComponent|null;
settings: NMPSettings;
constructor(app: App, plugin: NoteToMpPlugin) {
super(app, plugin);
this.plugin = plugin;
this.settings = NMPSettings.getInstance();
this.wxInfo = this.parseWXInfo();
}
displayWXInfo(txt:string) {
this.wxTextArea?.setValue(txt);
}
parseWXInfo() {
const wxInfo = this.settings.wxInfo;
if (wxInfo.length == 0) {
return '';
}
let res = '';
for (let wx of wxInfo) {
res += `${wx.name}|${wx.appid}|********\n`;
}
return res;
}
async testWXInfo() {
const authKey = this.settings.authKey;
if (authKey.length == 0) {
new Notice('请先设置authKey');
return;
}
const wxInfo = this.settings.wxInfo;
if (wxInfo.length == 0) {
new Notice('请先设置公众号信息');
return;
}
try {
const docUrl = 'https://mp.weixin.qq.com/s/rk5CTPGr5ftly8PtYgSjCQ';
for (let wx of wxInfo) {
const res = await wxGetToken(authKey, wx.appid, wx.secret.replace('SECRET', ''));
if (res.status != 200) {
const data = res.json;
const { code, message } = data;
let content = message;
if (code === 50002) {
content = '用户受限,可能是您的公众号被冻结或注销,请联系微信客服处理';
}
else if (code === 40125) {
content = 'AppSecret错误请检查或者重置详细操作步骤请参考下方文档';
}
else if (code === 40164) {
content = 'IP地址不在白名单中请将如下地址添加到白名单<br>59.110.112.211<br>154.8.198.218<br>详细步骤请参考下方文档';
}
const modal = new DocModal(this.app, `${wx.name} 测试失败`, content, docUrl);
modal.open();
break
}
const data = res.json;
if (data.token.length == 0) {
new Notice(`${wx.name}|${wx.appid} 测试失败`);
break
}
new Notice(`${wx.name} 测试通过`);
}
} catch (error) {
new Notice(`测试失败:${error}`);
}
}
async encrypt() {
if (this.wxInfo.length == 0) {
new Notice('请输入内容');
return false;
}
if (this.settings.wxInfo.length > 0) {
new Notice('已经保存过了,请先清除!');
return false;
}
const wechat = [];
const lines = this.wxInfo.split('\n');
for (let line of lines) {
line = line.trim();
if (line.length == 0) {
continue;
}
const items = line.split('|');
if (items.length != 3) {
new Notice('格式错误,请检查');
return false;
}
const name = items[0];
const appid = items[1].trim();
const secret = items[2].trim();
wechat.push({name, appid, secret});
}
if (wechat.length == 0) {
return false;
}
try {
const res = await wxEncrypt(this.settings.authKey, wechat);
if (res.status != 200) {
const data = res.json;
new Notice(`${data.message}`);
return false;
}
const data = res.json;
for (let wx of wechat) {
wx.secret = data[wx.appid];
}
this.settings.wxInfo = wechat;
await this.plugin.saveSettings();
this.wxInfo = this.parseWXInfo();
this.displayWXInfo(this.wxInfo);
new Notice('保存成功');
return true;
} catch (error) {
new Notice(`保存失败:${error}`);
console.error(error);
}
return false;
}
async clear() {
this.settings.wxInfo = [];
await this.plugin.saveSettings();
this.wxInfo = '';
this.displayWXInfo('')
}
display() {
const {containerEl} = this;
containerEl.empty();
this.wxInfo = this.parseWXInfo();
const helpEl = containerEl.createEl('div');
helpEl.style.cssText = 'display: flex;flex-direction: row;align-items: center;';
helpEl.createEl('h2', {text: '帮助文档'}).style.cssText = 'margin-right: 10px;';
helpEl.createEl('a', {text: 'https://sunboshi.tech/doc', attr: {href: 'https://sunboshi.tech/doc'}});
containerEl.createEl('h2', {text: '插件设置'});
new Setting(containerEl)
.setName('默认样式')
.addDropdown(dropdown => {
const styles = this.plugin.assetsManager.themes;
for (let s of styles) {
dropdown.addOption(s.className, s.name);
}
dropdown.setValue(this.settings.defaultStyle);
dropdown.onChange(async (value) => {
this.settings.defaultStyle = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('代码高亮')
.addDropdown(dropdown => {
const styles = this.plugin.assetsManager.highlights;
for (let s of styles) {
dropdown.addOption(s.name, s.name);
}
dropdown.setValue(this.settings.defaultHighlight);
dropdown.onChange(async (value) => {
this.settings.defaultHighlight = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('在工具栏展示样式选择')
.setDesc('建议在移动端关闭,可以增大文章预览区域')
.addToggle(toggle => {
toggle.setValue(this.settings.showStyleUI);
toggle.onChange(async (value) => {
this.settings.showStyleUI = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('链接展示样式')
.addDropdown(dropdown => {
dropdown.addOption('inline', '内嵌');
dropdown.addOption('footnote', '脚注');
dropdown.setValue(this.settings.linkStyle);
dropdown.onChange(async (value) => {
this.settings.linkStyle = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('文件嵌入展示样式')
.addDropdown(dropdown => {
dropdown.addOption('quote', '引用');
dropdown.addOption('content', '正文');
dropdown.setValue(this.settings.embedStyle);
dropdown.onChange(async (value) => {
this.settings.embedStyle = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('数学公式语法')
.addDropdown(dropdown => {
dropdown.addOption('latex', 'latex');
dropdown.addOption('asciimath', 'asciimath');
dropdown.setValue(this.settings.math);
dropdown.onChange(async (value) => {
this.settings.math = value;
cleanMathCache();
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('显示代码行号')
.addToggle(toggle => {
toggle.setValue(this.settings.lineNumber);
toggle.onChange(async (value) => {
this.settings.lineNumber = value;
await this.plugin.saveSettings();
});
})
new Setting(containerEl)
.setName('启用空行渲染')
.addToggle(toggle => {
toggle.setValue(this.settings.enableEmptyLine);
toggle.onChange(async (value) => {
this.settings.enableEmptyLine = value;
await this.plugin.saveSettings();
});
})
new Setting(containerEl)
.setName('渲染图片标题')
.addToggle(toggle => {
toggle.setValue(this.settings.useFigcaption);
toggle.onChange(async (value) => {
this.settings.useFigcaption = value;
await this.plugin.saveSettings();
});
})
new Setting(containerEl)
.setName('Excalidraw 渲染为 PNG 图片')
.addToggle(toggle => {
toggle.setValue(this.settings.excalidrawToPNG);
toggle.onChange(async (value) => {
this.settings.excalidrawToPNG = value;
await this.plugin.saveSettings();
});
})
new Setting(containerEl)
.setName('水印图片')
.addText(text => {
text.setPlaceholder('请输入图片名称')
.setValue(this.settings.watermark)
.onChange(async (value) => {
this.settings.watermark = value.trim();
await this.plugin.saveSettings();
})
.inputEl.setAttr('style', 'width: 320px;')
})
new Setting(containerEl)
.setName('获取更多主题')
.addButton(button => {
button.setButtonText('下载');
button.onClick(async () => {
button.setButtonText('下载中...');
await this.plugin.assetsManager.downloadThemes();
button.setButtonText('下载完成');
});
})
.addButton(button => {
button.setIcon('folder-open');
button.onClick(async () => {
await this.plugin.assetsManager.openAssets();
});
});
new Setting(containerEl)
.setName('清空主题')
.addButton(button => {
button.setButtonText('清空');
button.onClick(async () => {
await this.plugin.assetsManager.removeThemes();
this.settings.resetStyelAndHighlight();
await this.plugin.saveSettings();
});
})
new Setting(containerEl)
.setName('全局CSS属性')
.setDesc('只能填写CSS属性不能写选择器')
.addTextArea(text => {
this.wxTextArea = text;
text.setPlaceholder('请输入CSS属性background: #fff;padding: 10px;')
.setValue(this.settings.baseCSS)
.onChange(async (value) => {
this.settings.baseCSS = value;
await this.plugin.saveSettings();
})
.inputEl.setAttr('style', 'width: 520px; height: 60px;');
})
const customCSSDoc = '使用指南:<a href="https://sunboshi.tech/customcss">https://sunboshi.tech/customcss</a>';
new Setting(containerEl)
.setName('自定义CSS笔记')
.setDesc(sanitizeHTMLToDom(customCSSDoc))
.addText(text => {
text.setPlaceholder('请输入自定义CSS笔记标题')
.setValue(this.settings.customCSSNote)
.onChange(async (value) => {
this.settings.customCSSNote = value.trim();
await this.plugin.saveSettings();
await this.plugin.assetsManager.loadCustomCSS();
})
.inputEl.setAttr('style', 'width: 320px;')
});
const expertDoc = '使用指南:<a href="https://sunboshi.tech/expert">https://sunboshi.tech/expert</a>';
new Setting(containerEl)
.setName('专家设置笔记')
.setDesc(sanitizeHTMLToDom(expertDoc))
.addText(text => {
text.setPlaceholder('请输入专家设置笔记标题')
.setValue(this.settings.expertSettingsNote)
.onChange(async (value) => {
this.settings.expertSettingsNote = value.trim();
await this.plugin.saveSettings();
await this.plugin.assetsManager.loadExpertSettings();
})
.inputEl.setAttr('style', 'width: 320px;')
});
let descHtml = '详情说明:<a href="https://sunboshi.tech/subscribe">https://sunboshi.tech/subscribe</a>';
if (this.settings.isVip) {
descHtml = '<span style="color:rgb(245, 70, 85);font-weight: bold;">👑永久会员</span><br/>' + descHtml;
}
else if (this.settings.expireat) {
const timestr = this.settings.expireat.toLocaleString();
descHtml = `有效期至:${timestr} <br/>${descHtml}`
}
new Setting(containerEl)
.setName('注册码AuthKey')
.setDesc(sanitizeHTMLToDom(descHtml))
.addText(text => {
text.setPlaceholder('请输入注册码')
.setValue(this.settings.authKey)
.onChange(async (value) => {
this.settings.authKey = value.trim();
this.settings.getExpiredDate();
await this.plugin.saveSettings();
})
.inputEl.setAttr('style', 'width: 320px;')
}).descEl.setAttr('style', '-webkit-user-select: text; user-select: text;')
let isClear = this.settings.wxInfo.length > 0;
let isRealClear = false;
const buttonText = isClear ? '清空公众号信息' : '保存公众号信息';
new Setting(containerEl)
.setName('公众号信息')
.addTextArea(text => {
this.wxTextArea = text;
text.setPlaceholder('请输入公众号信息\n格式公众号名称|公众号AppID|公众号AppSecret\n多个公众号请换行输入\n输入完成后点击加密按钮')
.setValue(this.wxInfo)
.onChange(value => {
this.wxInfo = value;
})
.inputEl.setAttr('style', 'width: 520px; height: 120px;');
})
new Setting(containerEl).addButton(button => {
button.setButtonText(buttonText);
button.onClick(async () => {
if (isClear) {
isRealClear = true;
isClear = false;
button.setButtonText('确认清空?');
}
else if (isRealClear) {
isRealClear = false;
isClear = false;
this.clear();
button.setButtonText('保存公众号信息');
}
else {
button.setButtonText('保存中...');
if (await this.encrypt()) {
isClear = true;
isRealClear = false;
button.setButtonText('清空公众号信息');
}
else {
button.setButtonText('保存公众号信息');
}
}
});
})
.addButton(button => {
button.setButtonText('测试公众号');
button.onClick(async () => {
button.setButtonText('测试中...');
await this.testWXInfo();
button.setButtonText('测试公众号');
})
})
}
}

203
src/settings.ts Normal file
View File

@@ -0,0 +1,203 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { wxKeyInfo } from './weixin-api';
export class NMPSettings {
defaultStyle: string;
defaultHighlight: string;
showStyleUI: boolean;
linkStyle: string;
embedStyle: string;
lineNumber: boolean;
authKey: string;
useCustomCss: boolean;
customCSSNote: string;
expertSettingsNote: string;
wxInfo: {name:string, appid:string, secret:string}[];
math: string;
expireat: Date | null = null;
isVip: boolean = false;
baseCSS: string;
watermark: string;
useFigcaption: boolean;
excalidrawToPNG: boolean;
isLoaded: boolean = false;
enableEmptyLine: boolean = false;
private static instance: NMPSettings;
// 静态方法,用于获取实例
public static getInstance(): NMPSettings {
if (!NMPSettings.instance) {
NMPSettings.instance = new NMPSettings();
}
return NMPSettings.instance;
}
private constructor() {
this.defaultStyle = 'obsidian-light';
this.defaultHighlight = '默认';
this.showStyleUI = true;
this.linkStyle = 'inline';
this.embedStyle = 'content';
this.lineNumber = true;
this.useCustomCss = false;
this.authKey = '';
this.wxInfo = [];
this.math = 'latex';
this.baseCSS = '';
this.watermark = '';
this.useFigcaption = false;
this.customCSSNote = '';
this.excalidrawToPNG = false;
this.expertSettingsNote = '';
this.enableEmptyLine = false;
}
resetStyelAndHighlight() {
this.defaultStyle = 'obsidian-light';
this.defaultHighlight = '默认';
}
public static loadSettings(data: any) {
if (!data) {
return
}
const {
defaultStyle,
linkStyle,
embedStyle,
showStyleUI,
lineNumber,
defaultHighlight,
authKey,
wxInfo,
math,
useCustomCss,
baseCSS,
watermark,
useFigcaption,
customCSSNote,
excalidrawToPNG,
expertSettingsNote,
ignoreEmptyLine,
} = data;
const settings = NMPSettings.getInstance();
if (defaultStyle) {
settings.defaultStyle = defaultStyle;
}
if (defaultHighlight) {
settings.defaultHighlight = defaultHighlight;
}
if (showStyleUI !== undefined) {
settings.showStyleUI = showStyleUI;
}
if (linkStyle) {
settings.linkStyle = linkStyle;
}
if (embedStyle) {
settings.embedStyle = embedStyle;
}
if (lineNumber !== undefined) {
settings.lineNumber = lineNumber;
}
if (authKey) {
settings.authKey = authKey;
}
if (wxInfo) {
settings.wxInfo = wxInfo;
}
if (math) {
settings.math = math;
}
if (useCustomCss !== undefined) {
settings.useCustomCss = useCustomCss;
}
if (baseCSS) {
settings.baseCSS = baseCSS;
}
if (watermark) {
settings.watermark = watermark;
}
if (useFigcaption !== undefined) {
settings.useFigcaption = useFigcaption;
}
if (customCSSNote) {
settings.customCSSNote = customCSSNote;
}
if (excalidrawToPNG !== undefined) {
settings.excalidrawToPNG = excalidrawToPNG;
}
if (expertSettingsNote) {
settings.expertSettingsNote = expertSettingsNote;
}
if (ignoreEmptyLine !== undefined) {
settings.enableEmptyLine = ignoreEmptyLine;
}
settings.getExpiredDate();
settings.isLoaded = true;
}
public static allSettings() {
const settings = NMPSettings.getInstance();
return {
'defaultStyle': settings.defaultStyle,
'defaultHighlight': settings.defaultHighlight,
'showStyleUI': settings.showStyleUI,
'linkStyle': settings.linkStyle,
'embedStyle': settings.embedStyle,
'lineNumber': settings.lineNumber,
'authKey': settings.authKey,
'wxInfo': settings.wxInfo,
'math': settings.math,
'useCustomCss': settings.useCustomCss,
'baseCSS': settings.baseCSS,
'watermark': settings.watermark,
'useFigcaption': settings.useFigcaption,
'customCSSNote': settings.customCSSNote,
'excalidrawToPNG': settings.excalidrawToPNG,
'expertSettingsNote': settings.expertSettingsNote,
'ignoreEmptyLine': settings.enableEmptyLine,
}
}
getExpiredDate() {
if (this.authKey.length == 0) return;
wxKeyInfo(this.authKey).then((res) => {
if (res.status == 200) {
if (res.json.vip) {
this.isVip = true;
}
this.expireat = new Date(res.json.expireat);
}
})
}
isAuthKeyVaild() {
if (this.authKey.length == 0) return false;
if (this.isVip) return true;
if (this.expireat == null) return false;
return this.expireat > new Date();
}
}

251
src/utils.ts Normal file
View File

@@ -0,0 +1,251 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { App, sanitizeHTMLToDom, requestUrl, Platform } from "obsidian";
import * as postcss from "./postcss/postcss";
let PluginVersion = "0.0.0";
let PlugPlatform = "obsidian";
export function setVersion(version: string) {
PluginVersion = version;
if (Platform.isWin) {
PlugPlatform = "win";
}
else if (Platform.isMacOS) {
PlugPlatform = "mac";
}
else if (Platform.isLinux) {
PlugPlatform = "linux";
}
else if (Platform.isIosApp) {
PlugPlatform = "ios";
}
else if (Platform.isAndroidApp) {
PlugPlatform = "android";
}
}
function getStyleSheet() {
for (var i = 0; i < document.styleSheets.length; i++) {
var sheet = document.styleSheets[i];
if (sheet.title == 'note-to-mp-style') {
return sheet;
}
}
}
function applyStyles(element: HTMLElement, styles: CSSStyleDeclaration, computedStyle: CSSStyleDeclaration) {
for (let i = 0; i < styles.length; i++) {
const propertyName = styles[i];
let propertyValue = computedStyle.getPropertyValue(propertyName);
if (propertyName == 'width' && styles.getPropertyValue(propertyName) == 'fit-content') {
propertyValue = 'fit-content';
}
if (propertyName.indexOf('margin') >= 0 && styles.getPropertyValue(propertyName).indexOf('auto') >= 0) {
propertyValue = styles.getPropertyValue(propertyName);
}
element.style.setProperty(propertyName, propertyValue);
}
}
function parseAndApplyStyles(element: HTMLElement, sheet:CSSStyleSheet) {
try {
const computedStyle = getComputedStyle(element);
for (let i = 0; i < sheet.cssRules.length; i++) {
const rule = sheet.cssRules[i];
if (rule instanceof CSSStyleRule && element.matches(rule.selectorText)) {
applyStyles(element, rule.style, computedStyle);
}
}
} catch (e) {
console.warn("Unable to access stylesheet: " + sheet.href, e);
}
}
function traverse(root: HTMLElement, sheet:CSSStyleSheet) {
let element = root.firstElementChild;
while (element) {
if (element.tagName === 'svg') {
// pass
}
else {
traverse(element as HTMLElement, sheet);
}
element = element.nextElementSibling;
}
parseAndApplyStyles(root, sheet);
}
export async function CSSProcess(content: HTMLElement) {
// 获取样式表
const style = getStyleSheet();
if (style) {
traverse(content, style);
}
}
export function parseCSS(css: string) {
return postcss.parse(css);
}
export function ruleToStyle(rule: postcss.Rule) {
let style = '';
rule.walkDecls(decl => {
style += decl.prop + ':' + decl.value + ';';
})
return style;
}
function processPseudoSelector(selector: string) {
if (selector.includes('::before') || selector.includes('::after')) {
selector = selector.replace(/::before/g, '').replace(/::after/g, '');
}
return selector;
}
function getPseudoType(selector: string) {
if (selector.includes('::before')) {
return 'before';
}
else if (selector.includes('::after')) {
return 'after';
}
return undefined;
}
function applyStyle(root: HTMLElement, cssRoot: postcss.Root) {
if (root.tagName.toLowerCase() === 'a' && root.classList.contains('wx_topic_link')) {
return;
}
const cssText = root.style.cssText;
cssRoot.walkRules(rule => {
const selector = processPseudoSelector(rule.selector);
try {
if (root.matches(selector)) {
let item = root;
const pseudoType = getPseudoType(rule.selector);
if (pseudoType) {
let content = '';
rule.walkDecls('content', decl => {
content = decl.value || '';
})
item = createSpan();
item.textContent = content.replace(/(^")|("$)/g, '');
if (pseudoType === 'before') {
root.prepend(item);
}
else if (pseudoType === 'after') {
root.appendChild(item);
}
}
rule.walkDecls(decl => {
// 如果已经设置了,则不覆盖
const setted = cssText.includes(decl.prop);
if (!setted || decl.important) {
item.style.setProperty(decl.prop, decl.value);
}
})
}
}
catch (err) {
if (err.message && err.message.includes('is not a valid selector')) {
return;
}
else {
throw err;
}
}
});
if (root.tagName === 'svg') {
return;
}
let element = root.firstElementChild;
while (element) {
applyStyle(element as HTMLElement, cssRoot);
element = element.nextElementSibling;
}
}
export function applyCSS(html: string, css: string) {
const doc = sanitizeHTMLToDom(html);
const root = doc.firstChild as HTMLElement;
const cssRoot = postcss.parse(css);
applyStyle(root, cssRoot);
return root.outerHTML;
}
export function uevent(name: string) {
const url = `https://u.sunboshi.tech/event?name=${name}&platform=${PlugPlatform}&v=${PluginVersion}`;
requestUrl(url).then().catch(error => {
console.error("Failed to send event: " + url, error);
});
}
/**
* 创建一个防抖函数
* @param func 要执行的函数
* @param wait 等待时间(毫秒)
* @returns 防抖处理后的函数
*/
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function(this: any, ...args: Parameters<T>) {
const context = this;
const later = () => {
timeout = null;
func.apply(context, args);
};
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}
export function cleanUrl(href: string) {
try {
href = encodeURI(href).replace(/%25/g, '%');
} catch (e) {
return null;
}
return href;
}
export async function waitForLayoutReady(app: App): Promise<void> {
if (app.workspace.layoutReady) {
return;
}
return new Promise((resolve) => {
app.workspace.onLayoutReady(() => resolve());
});
}

58
src/wasm/wasm.ts Normal file
View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import AssetsManager from "../assets";
require('./wasm_exec.js');
declare class Go {
argv: string[];
env: { [envKey: string]: string };
exit: (code: number) => void;
importObject: WebAssembly.Imports;
exited: boolean;
mem: DataView;
run(instance: WebAssembly.Instance): Promise<void>;
}
let WasmLoaded = false;
export function IsWasmReady() {
return WasmLoaded;
}
export async function LoadWasm() {
if (WasmLoaded) {
return;
}
const assets = AssetsManager.getInstance();
const wasmContent = await assets.loadWasm();
if (!wasmContent) {
console.error('WASM content not found');
// throw new Error('WASM content not found');
return;
}
const go = new Go();
const ret = await WebAssembly.instantiate(wasmContent, go.importObject);
go.run(ret.instance);
WasmLoaded = true;
}

561
src/wasm/wasm_exec.js Normal file
View File

@@ -0,0 +1,561 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substring(0, nl));
outputBuf = outputBuf.substring(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const setInt32 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
_gotest: {
add: (a, b) => a + b,
},
gojs: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8),
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();

228
src/weixin-api.ts Normal file
View File

@@ -0,0 +1,228 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { requestUrl, RequestUrlParam, getBlobArrayBuffer } from "obsidian";
const PluginHost = 'https://obplugin.sunboshi.tech';
// 获取token
export async function wxGetToken(authkey:string, appid:string, secret:string) {
const url = PluginHost + '/v1/wx/token';
const body = {
authkey,
appid,
secret
}
const res = await requestUrl({
url,
method: 'POST',
throw: false,
contentType: 'application/json',
body: JSON.stringify(body)
});
return res;
}
export async function wxEncrypt(authkey:string, wechat:any[]) {
const url = PluginHost + '/v1/wx/encrypt';
const body = JSON.stringify({
authkey,
wechat
});
const res = await requestUrl({
url: url,
method: 'POST',
throw: false,
contentType: 'application/json',
body: body
});
return res
}
export async function wxKeyInfo(authkey:string) {
const url = PluginHost + '/v1/wx/info/' + authkey;
const res = await requestUrl({
url: url,
method: 'GET',
throw: false,
contentType: 'application/json',
});
return res
}
export async function wxWidget(authkey: string, params: string) {
const host = 'https://obplugin.sunboshi.tech';
const path = '/math/widget';
const url = `${host}${path}`;
try {
const res = await requestUrl({
url,
throw: false,
method: 'POST',
contentType: 'application/json',
headers: {
authkey
},
body: params
})
if (res.status === 200) {
return res.json.content;
}
return res.json.msg;
} catch (error) {
console.log(error);
return error.message;
}
}
// 上传图片
export async function wxUploadImage(data: Blob, filename: string, token: string, type?: string) {
let url = '';
if (type == null || type === '') {
url = 'https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=' + token;
} else {
url = `https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${token}&type=${type}`
}
const N = 16 // The length of our random boundry string
const randomBoundryString = "djmangoBoundry" + Array(N+1).join((Math.random().toString(36)+'00000000000000000').slice(2, 18)).slice(0, N)
// Construct the form data payload as a string
const pre_string = `------${randomBoundryString}\r\nContent-Disposition: form-data; name="media"; filename="${filename}"\r\nContent-Type: "application/octet-stream"\r\n\r\n`;
const post_string = `\r\n------${randomBoundryString}--`
// Convert the form data payload to a blob by concatenating the pre_string, the file data, and the post_string, and then return the blob as an array buffer
const pre_string_encoded = new TextEncoder().encode(pre_string);
// const data = file;
const post_string_encoded = new TextEncoder().encode(post_string);
const concatenated = await new Blob([pre_string_encoded, await getBlobArrayBuffer(data), post_string_encoded]).arrayBuffer()
// Now that we have the form data payload as an array buffer, we can pass it to requestURL
// We also need to set the content type to multipart/form-data and pass in the boundry string
const options: RequestUrlParam = {
method: 'POST',
url: url,
contentType: `multipart/form-data; boundary=----${randomBoundryString}`,
body: concatenated
};
const res = await requestUrl(options);
const resData = await res.json;
return {
url: resData.url || '',
media_id: resData.media_id || '',
errcode: resData.errcode || 0,
errmsg: resData.errmsg || '',
}
}
// 新建草稿
export interface DraftArticle {
title: string;
author?: string;
digest?: string;
cover?: string;
content: string;
content_source_url?: string;
thumb_media_id: string;
need_open_comment?: number;
only_fans_can_comment?: number;
pic_crop_235_1?: string;
pic_crop_1_1?: string;
appid?: string;
theme?: string;
highlight?: string;
}
export async function wxAddDraft(token: string, data: DraftArticle) {
const url = 'https://api.weixin.qq.com/cgi-bin/draft/add?access_token=' + token;
const body = {articles:[{
title: data.title,
content: data.content,
digest: data.digest,
thumb_media_id: data.thumb_media_id,
... data.pic_crop_235_1 && {pic_crop_235_1: data.pic_crop_235_1},
... data.pic_crop_1_1 && {pic_crop_1_1: data.pic_crop_1_1},
... data.content_source_url && {content_source_url: data.content_source_url},
... data.need_open_comment !== undefined && {need_open_comment: data.need_open_comment},
... data.only_fans_can_comment !== undefined && {only_fans_can_comment: data.only_fans_can_comment},
... data.author && {author: data.author},
}]};
const res = await requestUrl({
method: 'POST',
url: url,
throw: false,
body: JSON.stringify(body)
});
return res;
}
export interface DraftImageMediaId {
image_media_id: string;
}
export interface DraftImageInfo {
image_list: DraftImageMediaId[];
}
export interface DraftImages {
article_type: string;
title: string;
content: string;
need_open_commnet: number;
only_fans_can_comment: number;
image_info: DraftImageInfo;
}
export async function wxAddDraftImages(token: string, data: DraftImages) {
const url = 'https://api.weixin.qq.com/cgi-bin/draft/add?access_token=' + token;
const body = {articles:[data]};
const res = await requestUrl({
method: 'POST',
url: url,
throw: false,
body: JSON.stringify(body)
});
return res;
}
export async function wxBatchGetMaterial(token: string, type: string, offset: number = 0, count: number = 10) {
const url = 'https://api.weixin.qq.com/cgi-bin/material/batchget_material?access_token=' + token;
const body = {
type,
offset,
count
};
const res = await requestUrl({
method: 'POST',
url: url,
throw: false,
body: JSON.stringify(body)
});
return await res.json;
}

77
src/widgets-modal.ts Normal file
View File

@@ -0,0 +1,77 @@
/*
* Copyright (c) 2024-2025 Sun Booshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { App, Modal, MarkdownView } from "obsidian";
import { uevent } from "./utils";
export class WidgetsModal extends Modal {
listener: any = null;
url: string = 'https://widgets.sunboshi.tech';
constructor(app: App) {
super(app);
}
insertMarkdown(markdown: string) {
const editor = this.app.workspace.getActiveViewOfType(MarkdownView)?.editor;
if (!editor) return;
editor.replaceSelection(markdown);
editor.exec("goRight");
uevent('insert-widgets');
}
onOpen() {
let { contentEl, modalEl } = this;
modalEl.style.width = '640px';
modalEl.style.height = '500px';
const iframe = contentEl.createEl('iframe', {
attr: {
src: this.url,
width: '100%',
height: '100%',
allow: 'clipboard-read; clipboard-write',
},
});
iframe.style.border = 'none';
this.listener = this.handleMessage.bind(this);
window.addEventListener('message', this.listener);
uevent('open-widgets');
}
handleMessage(event: MessageEvent) {
if (event.origin === this.url) {
const { type, data } = event.data;
if (type === 'cmd') {
this.insertMarkdown(data);
}
}
}
onClose() {
if (this.listener) {
window.removeEventListener('message', this.listener);
}
let { contentEl } = this;
contentEl.empty();
}
}