release: 发布 v0.5.0

新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与 Electron PDF;实现稳定前缀复用和修改位置后的增量分页,保留媒体块按文档顺序串行回填与单次重排。

完善跨端链接与桌面文档工作流:Web 受控处理锚点和 HTTP/HTTPS 外链;Desktop 支持本地路径、file URI、系统协议、多窗口、同文件单例、Markdown 当前或新窗口打开,以及聚焦时外部文件变化提示。

统一四套内置主题名称并默认使用 Typora Github;修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块 Typora DOM 与重复行内样式;桌面发行链强制完整重建内嵌 Web,避免安装包携带陈旧资源。

发布 Web/Compose 与 Windows NSIS/ZIP:镜像 yixiong/md-to-pdf:v0.5.0 已健康部署;NSIS SHA-256 为 60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92,ZIP SHA-256 为 D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8,本机安装版已升级至 v0.5.0。

验证:全项目 238 项测试通过,类型检查、生产构建和 git diff --check 通过;Web 快速/连续/精确预览、Compose、Desktop 多窗口、窗口状态、文件关联、链接与代码块均完成真实环境验收。
This commit is contained in:
SkyJourney
2026-07-28 18:01:22 +08:00
parent 925b0d1485
commit 58087d0c7e
86 changed files with 4564 additions and 1204 deletions
+13 -2
View File
@@ -9,10 +9,12 @@ import {
DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_OPEN_DOCUMENT_LINK,
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_MARKDOWN,
DESKTOP_SET_DOCUMENT_DIRTY,
DESKTOP_START_NEW_MARKDOWN,
DESKTOP_SAVE_PDF
} from "./channels.js";
@@ -23,14 +25,23 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN),
discardPendingMarkdown: () =>
ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN),
onMarkdownOpened: (listener: () => void) => {
const handleOpened = () => listener();
onMarkdownOpened: (
listener: (reason: "open" | "reload" | "replace") => void
) => {
const handleOpened = (
_event: Electron.IpcRendererEvent,
reason: "open" | "reload" | "replace"
) => listener(reason);
ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened);
return () =>
ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened);
},
startNewMarkdown: () =>
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
setDocumentDirty: (dirty: boolean) =>
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
openDocumentLink: (href: string) =>
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
saveMarkdown: (fileName: string, markdown: string) =>
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
openThemeDirectory: () =>
+4
View File
@@ -8,6 +8,10 @@ export const DESKTOP_DISCARD_PENDING_MARKDOWN =
"md-to-pdf:desktop:discard-pending-markdown";
export const DESKTOP_MARKDOWN_OPENED =
"md-to-pdf:desktop:markdown-opened";
export const DESKTOP_SET_DOCUMENT_DIRTY =
"md-to-pdf:desktop:set-document-dirty";
export const DESKTOP_OPEN_DOCUMENT_LINK =
"md-to-pdf:desktop:open-document-link";
export const DESKTOP_START_NEW_MARKDOWN =
"md-to-pdf:desktop:start-new-markdown";
export const DESKTOP_SAVE_MARKDOWN =
@@ -0,0 +1,925 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
screen,
shell,
type IpcMainInvokeEvent,
type Rectangle
} from "electron";
import {
mkdir,
realpath,
stat,
writeFile
} from "node:fs/promises";
import path from "node:path";
import {
MAXIMUM_MARKDOWN_LENGTH,
type ApplicationService
} from "@md-to-pdf/application";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GENERATE_PDF,
DESKTOP_GET_THEME_CSS,
DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
DESKTOP_OPEN_DOCUMENT_LINK,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_MARKDOWN,
DESKTOP_SAVE_PDF,
DESKTOP_SET_DOCUMENT_DIRTY,
DESKTOP_START_NEW_MARKDOWN
} from "./channels.js";
import {
parseMarkdownRenderRequest,
parseThemeId
} from "./application-contract.js";
import { ElectronPdfGenerator } from "./electron-pdf-generator.js";
import { resolveDesktopDocumentLinkAction } from "./desktop-document-link.js";
import {
isMarkdownFilePath,
createMarkdownDocumentKey,
readMarkdownFileSnapshot,
type MarkdownFileSnapshot
} from "./markdown-file.js";
import { parsePagedDocumentPayload } from "./pdf-contract.js";
import {
loadWindowState,
resolveWindowBounds,
saveWindowState,
type WindowState
} from "./window-state.js";
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
const CASCADE_OFFSET = 32;
const FOCUS_CHECK_SUPPRESSION_MS = 800;
type MarkdownOpenReason = "open" | "reload" | "replace";
interface PendingOpenedMarkdown {
snapshot: MarkdownFileSnapshot;
reason: MarkdownOpenReason;
}
interface WindowSession {
window: BrowserWindow;
dirty: boolean;
allowClose: boolean;
currentFilePath: string | undefined;
documentKey: string | undefined;
documentRoot: string | undefined;
baselineHash: string | undefined;
pendingOpenedMarkdown: PendingOpenedMarkdown | undefined;
focusCheckRunning: boolean;
suppressFocusCheckUntil: number;
}
export interface DesktopApplicationControllerOptions {
applicationService: ApplicationService;
applicationUrl: string;
renderUrl: string;
preloadPath: string;
pdfPreloadPath: string;
iconPath: string;
themeDirectory: string;
windowStatePath: string;
}
function focusWindow(window: BrowserWindow) {
if (window.isDestroyed()) {
return;
}
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
export class DesktopApplicationController {
readonly #applicationService: ApplicationService;
readonly #applicationUrl: string;
readonly #applicationOrigin: string;
readonly #preloadPath: string;
readonly #iconPath: string;
readonly #themeDirectory: string;
readonly #windowStatePath: string;
readonly #pdfGenerator: ElectronPdfGenerator;
readonly #sessions = new Map<number, WindowSession>();
readonly #documentSessions = new Map<string, WindowSession>();
#primarySession: WindowSession | undefined;
#savedWindowState: WindowState | undefined;
#saveWindowStateTimer: NodeJS.Timeout | undefined;
#pendingWindowStateWrite = Promise.resolve();
#latestWindowState: WindowState | undefined;
constructor(options: DesktopApplicationControllerOptions) {
this.#applicationService = options.applicationService;
this.#applicationUrl = options.applicationUrl;
this.#applicationOrigin = new URL(options.applicationUrl).origin;
this.#preloadPath = options.preloadPath;
this.#iconPath = options.iconPath;
this.#themeDirectory = options.themeDirectory;
this.#windowStatePath = options.windowStatePath;
this.#pdfGenerator = new ElectronPdfGenerator({
renderUrl: options.renderUrl,
preloadPath: options.pdfPreloadPath
});
}
async initialize() {
this.#savedWindowState = await loadWindowState(
this.#windowStatePath
);
this.#registerIpcHandlers();
}
async createWindow(filePath?: string) {
let pendingSnapshot: MarkdownFileSnapshot | undefined;
if (filePath) {
pendingSnapshot = await readMarkdownFileSnapshot(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
const existing = this.#findDocumentSession(
pendingSnapshot.documentKey
);
if (existing) {
focusWindow(existing.window);
return existing.window;
}
}
return this.#createWindow(pendingSnapshot);
}
async #createWindow(pendingSnapshot?: MarkdownFileSnapshot) {
const isPrimary = !this.#primarySession;
const restoredWindowState = isPrimary
? this.#latestWindowState ?? this.#savedWindowState
: undefined;
const initialBounds = this.#resolveInitialBounds(isPrimary);
const window = new BrowserWindow({
title: `Markdown PDF 导出器 v${app.getVersion()}`,
icon: this.#iconPath,
...initialBounds,
minWidth: Math.min(
MINIMUM_WINDOW_SIZE.width,
initialBounds.width
),
minHeight: Math.min(
MINIMUM_WINDOW_SIZE.height,
initialBounds.height
),
autoHideMenuBar: true,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: this.#preloadPath
}
});
const session: WindowSession = {
window,
dirty: false,
allowClose: false,
currentFilePath: undefined,
documentKey: undefined,
documentRoot: undefined,
baselineHash: undefined,
pendingOpenedMarkdown: pendingSnapshot
? { snapshot: pendingSnapshot, reason: "open" }
: undefined,
focusCheckRunning: false,
suppressFocusCheckUntil: 0
};
this.#sessions.set(window.webContents.id, session);
if (isPrimary) {
this.#primarySession = session;
this.#captureWindowState();
this.#bindPrimaryWindowState(session);
}
this.#lockDownWindow(window);
this.#bindWindowLifecycle(session);
window.once("ready-to-show", () => {
if (
isPrimary &&
restoredWindowState?.maximized &&
!window.isDestroyed()
) {
window.maximize();
}
window.show();
});
await window.loadURL(this.#applicationUrl);
return window;
}
async openExternalMarkdown(filePath: string) {
const snapshot = await readMarkdownFileSnapshot(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
const existing = this.#findDocumentSession(snapshot.documentKey);
if (existing) {
focusWindow(existing.window);
return;
}
await this.#createWindow(snapshot);
}
focusMostRecentWindow() {
const focused = BrowserWindow.getFocusedWindow();
const focusedSession = focused
? [...this.#sessions.values()].find(
(session) => session.window === focused
)
: undefined;
if (focusedSession) {
focusWindow(focusedSession.window);
return true;
}
const session = [...this.#sessions.values()].at(-1);
if (!session) {
return false;
}
focusWindow(session.window);
return true;
}
async flushWindowState() {
if (this.#saveWindowStateTimer) {
clearTimeout(this.#saveWindowStateTimer);
this.#saveWindowStateTimer = undefined;
}
this.#captureWindowState();
await this.#persistWindowState();
}
async closeResources() {
await this.#pdfGenerator.close();
}
#resolveInitialBounds(isPrimary: boolean): Rectangle {
const displays = screen
.getAllDisplays()
.map((display) => display.workArea);
const primaryWorkArea = screen.getPrimaryDisplay().workArea;
if (isPrimary) {
return resolveWindowBounds(
(this.#latestWindowState ?? this.#savedWindowState)?.bounds,
displays,
primaryWorkArea,
DEFAULT_WINDOW_SIZE,
MINIMUM_WINDOW_SIZE
);
}
const reference =
BrowserWindow.getFocusedWindow() ??
[...this.#sessions.values()].at(-1)?.window;
const referenceBounds =
reference && !reference.isDestroyed()
? reference.getNormalBounds()
: primaryWorkArea;
return resolveWindowBounds(
{
x: referenceBounds.x + CASCADE_OFFSET,
y: referenceBounds.y + CASCADE_OFFSET,
width: referenceBounds.width,
height: referenceBounds.height
},
displays,
primaryWorkArea,
DEFAULT_WINDOW_SIZE,
MINIMUM_WINDOW_SIZE
);
}
#lockDownWindow(window: BrowserWindow) {
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, targetUrl) => {
if (new URL(targetUrl).origin !== this.#applicationOrigin) {
event.preventDefault();
}
});
const applicationSession = window.webContents.session;
applicationSession.setPermissionCheckHandler(() => false);
applicationSession.setPermissionRequestHandler(
(_webContents, _permission, callback) => callback(false)
);
}
#bindWindowLifecycle(session: WindowSession) {
const { window } = session;
const webContentsId = window.webContents.id;
window.on("focus", () => {
void this.#checkForExternalChange(session);
});
window.on("close", (event) => {
if (!session.dirty || session.allowClose) {
return;
}
event.preventDefault();
void this.#confirmDirtyWindowClose(session);
});
window.on("closed", () => {
const wasPrimary = this.#primarySession === session;
this.#sessions.delete(webContentsId);
if (
session.documentKey &&
this.#documentSessions.get(session.documentKey) === session
) {
this.#documentSessions.delete(session.documentKey);
}
if (wasPrimary) {
this.#promotePrimarySession();
}
});
}
#promotePrimarySession() {
const nextPrimary = [...this.#sessions.values()]
.filter((session) => !session.window.isDestroyed())
.at(-1);
this.#primarySession = nextPrimary;
if (!nextPrimary) {
return;
}
this.#bindPrimaryWindowState(nextPrimary);
this.#captureWindowState();
}
async #confirmDirtyWindowClose(session: WindowSession) {
if (session.window.isDestroyed()) {
return;
}
const result = await dialog.showMessageBox(session.window, {
type: "warning",
title: "关闭未保存文档",
message: "当前文档有未保存修改,确定要关闭窗口吗?",
detail: "关闭后,尚未保存的修改将会丢失。",
buttons: ["取消", "仍然关闭"],
defaultId: 0,
cancelId: 0,
noLink: true
});
session.suppressFocusCheckUntil =
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
if (result.response === 1 && !session.window.isDestroyed()) {
session.allowClose = true;
session.window.close();
}
}
async #checkForExternalChange(session: WindowSession) {
if (
session.focusCheckRunning ||
session.window.isDestroyed() ||
!session.currentFilePath ||
!session.baselineHash ||
Date.now() < session.suppressFocusCheckUntil
) {
return;
}
session.focusCheckRunning = true;
try {
let snapshot: MarkdownFileSnapshot;
try {
snapshot = await readMarkdownFileSnapshot(
session.currentFilePath,
MAXIMUM_MARKDOWN_LENGTH
);
} catch (error) {
await dialog.showMessageBox(session.window, {
type: "error",
title: "无法检查文件",
message: "当前 Markdown 文件已被删除或无法读取。",
detail:
error instanceof Error ? error.message : "未知文件错误",
buttons: ["确定"],
defaultId: 0,
noLink: true
});
return;
}
if (snapshot.contentHash === session.baselineHash) {
return;
}
const result = await dialog.showMessageBox(session.window, {
type: "warning",
title: "文件已在外部修改",
message: session.dirty
? "文件已在外部修改,重新载入会丢弃当前未保存内容。"
: "文件已在外部修改,是否重新载入?",
detail: snapshot.filePath,
buttons: ["暂不载入", "重新载入"],
defaultId: 1,
cancelId: 0,
noLink: true
});
if (result.response === 1) {
session.baselineHash = snapshot.contentHash;
session.pendingOpenedMarkdown = {
snapshot,
reason: "reload"
};
session.window.webContents.send(
DESKTOP_MARKDOWN_OPENED,
"reload"
);
}
} finally {
session.focusCheckRunning = false;
session.suppressFocusCheckUntil =
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
}
}
#bindPrimaryWindowState(session: WindowSession) {
const schedule = () => {
this.#captureWindowState();
if (this.#saveWindowStateTimer) {
clearTimeout(this.#saveWindowStateTimer);
}
this.#saveWindowStateTimer = setTimeout(() => {
this.#saveWindowStateTimer = undefined;
void this.#persistWindowState();
}, 2_000);
};
session.window.on("move", schedule);
session.window.on("resize", schedule);
session.window.on("maximize", schedule);
session.window.on("unmaximize", schedule);
}
#captureWindowState() {
const window = this.#primarySession?.window;
if (!window || window.isDestroyed()) {
return;
}
this.#latestWindowState = {
version: 1,
bounds: window.getNormalBounds(),
maximized: window.isMaximized()
};
}
#persistWindowState() {
if (!this.#latestWindowState) {
return this.#pendingWindowStateWrite;
}
const state = structuredClone(this.#latestWindowState);
this.#pendingWindowStateWrite = this.#pendingWindowStateWrite
.catch(() => undefined)
.then(() => saveWindowState(this.#windowStatePath, state))
.catch((error: unknown) => {
console.warn("无法保存窗口状态", error);
});
return this.#pendingWindowStateWrite;
}
#findDocumentSession(documentKey: string) {
const committed = this.#documentSessions.get(documentKey);
if (committed) {
return committed;
}
return [...this.#sessions.values()].find(
(session) =>
session.pendingOpenedMarkdown?.snapshot.documentKey ===
documentKey
);
}
#getSession(event: IpcMainInvokeEvent) {
const session = this.#sessions.get(event.sender.id);
let origin: string | undefined;
try {
origin = event.senderFrame
? new URL(event.senderFrame.url).origin
: undefined;
} catch {
origin = undefined;
}
if (
!session ||
event.sender !== session.window.webContents ||
origin !== this.#applicationOrigin
) {
throw new Error("拒绝未授权的桌面请求");
}
return session;
}
#commitSnapshot(
session: WindowSession,
snapshot: MarkdownFileSnapshot
) {
if (
session.documentKey &&
this.#documentSessions.get(session.documentKey) === session
) {
this.#documentSessions.delete(session.documentKey);
}
session.currentFilePath = snapshot.filePath;
session.documentKey = snapshot.documentKey;
session.documentRoot = path.dirname(snapshot.filePath);
session.baselineHash = snapshot.contentHash;
session.dirty = false;
this.#documentSessions.set(snapshot.documentKey, session);
}
#clearDocument(session: WindowSession) {
if (
session.documentKey &&
this.#documentSessions.get(session.documentKey) === session
) {
this.#documentSessions.delete(session.documentKey);
}
session.currentFilePath = undefined;
session.documentKey = undefined;
session.documentRoot = undefined;
session.baselineHash = undefined;
session.pendingOpenedMarkdown = undefined;
session.dirty = false;
}
#registerIpcHandlers() {
ipcMain.handle(
DESKTOP_OPEN_DOCUMENT_LINK,
async (event, unsafeHref: unknown) => {
const session = this.#getSession(event);
if (
typeof unsafeHref !== "string" ||
unsafeHref.length === 0 ||
unsafeHref.length > 8_192
) {
throw new Error("文档链接无效");
}
try {
await this.#openDocumentLink(session, unsafeHref);
} catch (error) {
await dialog.showMessageBox(session.window, {
type: "error",
title: "无法打开链接",
message:
error instanceof Error
? error.message
: "无法打开文档链接",
detail: unsafeHref,
buttons: ["确定"],
defaultId: 0,
noLink: true
});
}
}
);
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
const session = this.#getSession(event);
const selection = await dialog.showOpenDialog(session.window, {
title: "打开 Markdown",
properties: ["openFile"],
filters: [
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
]
});
const filePath = selection.filePaths[0];
if (selection.canceled || !filePath) {
return undefined;
}
const snapshot = await readMarkdownFileSnapshot(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
const existing = this.#findDocumentSession(snapshot.documentKey);
if (existing && existing !== session) {
focusWindow(existing.window);
return undefined;
}
this.#commitSnapshot(session, snapshot);
return snapshot.document;
});
ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => {
const session = this.#getSession(event);
const pending = session.pendingOpenedMarkdown;
session.pendingOpenedMarkdown = undefined;
if (!pending) {
return undefined;
}
this.#commitSnapshot(session, pending.snapshot);
return pending.snapshot.document;
});
ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => {
const session = this.#getSession(event);
session.pendingOpenedMarkdown = undefined;
});
ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => {
this.#clearDocument(this.#getSession(event));
});
ipcMain.handle(
DESKTOP_SET_DOCUMENT_DIRTY,
async (event, unsafeDirty: unknown) => {
if (typeof unsafeDirty !== "boolean") {
throw new Error("文档修改状态无效");
}
this.#getSession(event).dirty = unsafeDirty;
}
);
ipcMain.handle(
DESKTOP_SAVE_MARKDOWN,
async (
event,
unsafeFileName: unknown,
unsafeMarkdown: unknown
) => {
const session = this.#getSession(event);
if (
typeof unsafeFileName !== "string" ||
unsafeFileName.length === 0 ||
unsafeFileName.length > 500 ||
typeof unsafeMarkdown !== "string" ||
unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH
) {
throw new Error("Markdown 保存参数无效");
}
let targetPath = session.currentFilePath;
if (!targetPath) {
const suggestedName = /\.(?:md|markdown)$/iu.test(
path.basename(unsafeFileName)
)
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.md`;
const selection = await dialog.showSaveDialog(session.window, {
title: "保存 Markdown",
defaultPath: path.join(
app.getPath("documents"),
suggestedName
),
filters: [
{
name: "Markdown 文件",
extensions: ["md", "markdown"]
}
]
});
if (selection.canceled || !selection.filePath) {
return undefined;
}
targetPath = /\.(?:md|markdown)$/iu.test(selection.filePath)
? selection.filePath
: `${selection.filePath}.md`;
}
let targetKey = createMarkdownDocumentKey(
path.resolve(targetPath)
);
try {
targetKey = createMarkdownDocumentKey(
await realpath(targetPath)
);
} catch (error) {
if (
!(
error instanceof Error &&
"code" in error &&
error.code === "ENOENT"
)
) {
throw error;
}
}
const existing = this.#findDocumentSession(targetKey);
if (existing && existing !== session) {
focusWindow(existing.window);
throw new Error("该 Markdown 已在另一个窗口中打开");
}
await writeFile(targetPath, unsafeMarkdown, "utf8");
const snapshot = await readMarkdownFileSnapshot(
targetPath,
MAXIMUM_MARKDOWN_LENGTH
);
const canonicalExisting = this.#findDocumentSession(
snapshot.documentKey
);
if (canonicalExisting && canonicalExisting !== session) {
focusWindow(canonicalExisting.window);
throw new Error("该 Markdown 已在另一个窗口中打开");
}
this.#commitSnapshot(session, snapshot);
return { fileName: snapshot.document.fileName };
}
);
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
this.#getSession(event);
await mkdir(this.#themeDirectory, { recursive: true });
const errorMessage = await shell.openPath(this.#themeDirectory);
if (errorMessage) {
throw new Error(
`无法打开自定义主题目录:${errorMessage}`
);
}
return this.#themeDirectory;
});
ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => {
this.#getSession(event);
this.#applicationService.invalidateThemes();
});
ipcMain.handle(
DESKTOP_RENDER_MARKDOWN,
async (event, unsafeRequest: unknown) => {
const session = this.#getSession(event);
return this.#applicationService.render(
parseMarkdownRenderRequest(unsafeRequest),
session.documentRoot
? {
localRoot: session.documentRoot,
allowUnrestrictedLocalFiles: true
}
: {}
);
}
);
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => {
this.#getSession(event);
return this.#applicationService.listThemes();
});
ipcMain.handle(
DESKTOP_GET_THEME_CSS,
async (event, unsafeThemeId: unknown) => {
this.#getSession(event);
const css = await this.#applicationService.getThemeCss(
parseThemeId(unsafeThemeId)
);
if (css === undefined) {
throw new Error("未找到指定主题");
}
return css;
}
);
ipcMain.handle(
DESKTOP_GENERATE_PDF,
async (event, unsafePayload: unknown) => {
this.#getSession(event);
const result = await this.#pdfGenerator.generate(
parsePagedDocumentPayload(unsafePayload)
);
return {
pdf: result.pdf,
pageCount: result.pageCount,
echartsErrors: result.echartsErrors,
mermaidErrors: result.mermaidErrors
};
}
);
ipcMain.handle(
DESKTOP_SAVE_PDF,
async (
event,
unsafeFileName: unknown,
unsafePdf: unknown
) => {
const session = this.#getSession(event);
if (
typeof unsafeFileName !== "string" ||
unsafeFileName.length === 0 ||
unsafeFileName.length > 500 ||
!(unsafePdf instanceof Uint8Array) ||
unsafePdf.byteLength === 0 ||
unsafePdf.byteLength > 200 * 1024 * 1024
) {
throw new Error("PDF 保存参数无效");
}
const suggestedName = path.basename(unsafeFileName).endsWith(
".pdf"
)
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.pdf`;
const selection = await dialog.showSaveDialog(session.window, {
title: "导出 PDF",
defaultPath: path.join(
app.getPath("documents"),
suggestedName
),
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
});
if (selection.canceled || !selection.filePath) {
return false;
}
await writeFile(selection.filePath, unsafePdf);
return true;
}
);
}
async #openDocumentLink(
session: WindowSession,
href: string
) {
const action = resolveDesktopDocumentLinkAction(
href,
session.currentFilePath
);
if (action.type === "ignore" || action.type === "anchor") {
return;
}
if (action.type === "external") {
await shell.openExternal(action.href);
return;
}
let targetStats;
try {
targetStats = await stat(action.filePath);
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
error.code === "ENOENT"
) {
await dialog.showMessageBox(session.window, {
type: "warning",
title: "找不到文件",
message: "链接指向的本地文件不存在。",
detail: action.filePath,
buttons: ["确定"],
defaultId: 0,
noLink: true
});
return;
}
throw error;
}
if (
targetStats.isFile() &&
isMarkdownFilePath(action.filePath)
) {
const snapshot = await readMarkdownFileSnapshot(
action.filePath,
MAXIMUM_MARKDOWN_LENGTH
);
const existing = this.#findDocumentSession(
snapshot.documentKey
);
if (existing) {
focusWindow(existing.window);
return;
}
const result = await dialog.showMessageBox(session.window, {
type: "question",
title: "打开 Markdown",
message: `如何打开 ${snapshot.document.fileName}`,
detail: session.dirty
? "当前窗口有未保存修改;选择当前窗口会放弃这些修改。"
: snapshot.filePath,
buttons: ["取消", "当前窗口打开", "新窗口打开"],
defaultId: 2,
cancelId: 0,
noLink: true
});
if (result.response === 1) {
session.pendingOpenedMarkdown = {
snapshot,
reason: "replace"
};
session.window.webContents.send(
DESKTOP_MARKDOWN_OPENED,
"replace"
);
} else if (result.response === 2) {
await this.#createWindow(snapshot);
}
return;
}
const errorMessage = await shell.openPath(action.filePath);
if (errorMessage) {
throw new Error(`系统无法打开该路径:${errorMessage}`);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { classifyDocumentLink } from "@md-to-pdf/core";
export type DesktopDocumentLinkAction =
| {
type: "anchor";
href: string;
}
| {
type: "external";
href: string;
}
| {
type: "local";
filePath: string;
}
| {
type: "ignore";
};
function decodeLocalPath(href: string) {
const pathOnly = href.split(/[?#]/u, 1)[0] ?? "";
try {
return decodeURIComponent(pathOnly);
} catch {
throw new Error("本地链接的 URL 编码无效");
}
}
export function resolveDesktopDocumentLinkAction(
href: string,
markdownFilePath?: string
): DesktopDocumentLinkAction {
const link = classifyDocumentLink(href);
if (link.kind === "anchor") {
return { type: "anchor", href: link.normalizedHref };
}
if (link.kind === "network" || link.kind === "protocol") {
return { type: "external", href: link.normalizedHref };
}
if (link.kind !== "local") {
return { type: "ignore" };
}
if (link.scheme === "file") {
try {
return {
type: "local",
filePath: path.normalize(fileURLToPath(new URL(link.href)))
};
} catch {
throw new Error("file: 链接格式无效");
}
}
const decoded = decodeLocalPath(link.href);
if (path.isAbsolute(decoded) || /^[a-z]:[\\/]/iu.test(decoded)) {
return { type: "local", filePath: path.normalize(decoded) };
}
if (!markdownFilePath) {
throw new Error("当前文档尚未保存,无法解析相对本地链接");
}
return {
type: "local",
filePath: path.resolve(path.dirname(markdownFilePath), decoded)
};
}
+53 -451
View File
@@ -1,79 +1,32 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
net,
protocol,
screen,
session,
shell,
type IpcMainInvokeEvent
session
} from "electron";
import {
access,
mkdir,
writeFile
} from "node:fs/promises";
import { access, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
createApplicationService,
MAXIMUM_MARKDOWN_LENGTH,
type ApplicationService
} from "@md-to-pdf/application";
import { parseThemeResourceUrl } from "./application-contract.js";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_MARKDOWN,
DESKTOP_SAVE_PDF,
DESKTOP_START_NEW_MARKDOWN
} from "./channels.js";
import {
parseMarkdownRenderRequest,
parseThemeId,
parseThemeResourceUrl
} from "./application-contract.js";
import {
DESKTOP_PDF_PARTITION,
ElectronPdfGenerator
} from "./electron-pdf-generator.js";
import { parsePagedDocumentPayload } from "./pdf-contract.js";
DesktopApplicationController
} from "./desktop-application-controller.js";
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
import {
findMarkdownFileArgument,
isMarkdownFilePath,
readMarkdownDocument,
type OpenedMarkdownDocument
isMarkdownFilePath
} from "./markdown-file.js";
import {
loadWindowState,
resolveWindowBounds,
saveWindowState,
type WindowState
} from "./window-state.js";
const APP_SCHEME = "mdpdf";
const APP_HOST = "bundle";
const THEME_HOST = "theme";
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
let applicationController:
| {
window: BrowserWindow;
openMarkdownFile(filePath: string): Promise<void>;
flushWindowState(): Promise<void>;
}
| undefined;
let applicationController: DesktopApplicationController | undefined;
let pendingExternalMarkdownPath = findMarkdownFileArgument(
process.argv,
process.cwd()
@@ -130,29 +83,16 @@ function encodeThemeAssetPath(assetPath: string) {
.join("/");
}
function focusApplicationWindow() {
const window = applicationController?.window;
if (!window || window.isDestroyed()) {
return;
}
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
function dispatchExternalMarkdown(filePath: string) {
if (!applicationController) {
pendingExternalMarkdownPath = filePath;
return;
}
void applicationController.openMarkdownFile(filePath).catch(
void applicationController.openExternalMarkdown(filePath).catch(
(error: unknown) => {
console.warn("无法打开系统传入的 Markdown 文件", error);
}
);
focusApplicationWindow();
}
function getDesktopThemeDirectory() {
@@ -226,12 +166,10 @@ async function registerApplicationProtocol(
} catch {
return new Response("Bad request", { status: 400 });
}
const target = path.resolve(webRoot, relativePath);
if (!isContainedPath(webRoot, target)) {
return new Response("Forbidden", { status: 403 });
}
try {
await access(target);
} catch {
@@ -248,376 +186,6 @@ async function registerApplicationProtocol(
]);
}
function lockDownWindow(window: BrowserWindow, allowedOrigin: string) {
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, targetUrl) => {
if (new URL(targetUrl).origin !== allowedOrigin) {
event.preventDefault();
}
});
const applicationSession = window.webContents.session;
applicationSession.setPermissionCheckHandler(() => false);
applicationSession.setPermissionRequestHandler(
(_webContents, _permission, callback) => callback(false)
);
}
async function createApplication(
applicationService: ApplicationService
) {
const developmentUrl = getCommandLineValue("web-url");
const applicationUrl =
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
const renderUrl = new URL(
"/preview-frame.html?target=pdf",
applicationUrl
).href;
const windowStatePath = path.join(
app.getPath("userData"),
"window-state.json"
);
const savedWindowState = await loadWindowState(windowStatePath);
const primaryWorkArea = screen.getPrimaryDisplay().workArea;
const initialBounds = resolveWindowBounds(
savedWindowState?.bounds,
screen.getAllDisplays().map((display) => display.workArea),
primaryWorkArea,
DEFAULT_WINDOW_SIZE,
MINIMUM_WINDOW_SIZE
);
const window = new BrowserWindow({
title: `Markdown PDF 导出器 v${app.getVersion()}`,
icon: getApplicationIcon(),
...initialBounds,
minWidth: Math.min(MINIMUM_WINDOW_SIZE.width, initialBounds.width),
minHeight: Math.min(MINIMUM_WINDOW_SIZE.height, initialBounds.height),
autoHideMenuBar: true,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: path.join(currentDirectory, "app-preload.cjs")
}
});
let saveWindowStateTimer: NodeJS.Timeout | undefined;
let pendingWindowStateWrite = Promise.resolve();
let latestWindowState: WindowState = {
version: 1,
bounds: window.getNormalBounds(),
maximized: window.isMaximized()
};
const captureWindowState = () => {
if (window.isDestroyed()) {
return;
}
latestWindowState = {
version: 1,
bounds: window.getNormalBounds(),
maximized: window.isMaximized()
};
};
const persistWindowState = () => {
const state = structuredClone(latestWindowState);
pendingWindowStateWrite = pendingWindowStateWrite
.catch(() => undefined)
.then(() => saveWindowState(windowStatePath, state))
.catch((error: unknown) => {
console.warn("无法保存窗口状态", error);
});
return pendingWindowStateWrite;
};
const scheduleWindowStateSave = () => {
captureWindowState();
if (saveWindowStateTimer) {
clearTimeout(saveWindowStateTimer);
}
saveWindowStateTimer = setTimeout(() => {
saveWindowStateTimer = undefined;
void persistWindowState();
}, 2_000);
};
const flushWindowState = async () => {
if (saveWindowStateTimer) {
clearTimeout(saveWindowStateTimer);
saveWindowStateTimer = undefined;
}
captureWindowState();
await persistWindowState();
};
window.on("move", scheduleWindowStateSave);
window.on("resize", scheduleWindowStateSave);
window.on("maximize", scheduleWindowStateSave);
window.on("unmaximize", scheduleWindowStateSave);
lockDownWindow(window, new URL(applicationUrl).origin);
const pdfGenerator = new ElectronPdfGenerator({
renderUrl,
preloadPath: path.join(currentDirectory, "pdf-preload.cjs")
});
const validateSender = (event: IpcMainInvokeEvent) =>
event.sender === window.webContents &&
event.senderFrame !== null &&
new URL(event.senderFrame.url).origin ===
new URL(applicationUrl).origin;
let documentRoot: string | undefined;
let currentMarkdownPath: string | undefined;
let pendingOpenedMarkdown:
| {
document: OpenedMarkdownDocument;
filePath: string;
}
| undefined;
const queueOpenedMarkdown = async (filePath: string) => {
const opened = await readMarkdownDocument(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
pendingOpenedMarkdown = {
document: opened,
filePath
};
window.webContents.send(DESKTOP_MARKDOWN_OPENED);
};
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
const selection = await dialog.showOpenDialog(window, {
title: "打开 Markdown",
properties: ["openFile"],
filters: [
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
]
});
const filePath = selection.filePaths[0];
if (selection.canceled || !filePath) {
return undefined;
}
const opened = await readMarkdownDocument(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
documentRoot = path.dirname(filePath);
currentMarkdownPath = filePath;
return opened;
});
ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
const pending = pendingOpenedMarkdown;
pendingOpenedMarkdown = undefined;
if (!pending) {
return undefined;
}
currentMarkdownPath = pending.filePath;
documentRoot = path.dirname(pending.filePath);
return pending.document;
});
ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
pendingOpenedMarkdown = undefined;
});
ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
currentMarkdownPath = undefined;
documentRoot = undefined;
pendingOpenedMarkdown = undefined;
});
ipcMain.handle(
DESKTOP_SAVE_MARKDOWN,
async (
event,
unsafeFileName: unknown,
unsafeMarkdown: unknown
) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
if (
typeof unsafeFileName !== "string" ||
unsafeFileName.length === 0 ||
unsafeFileName.length > 500 ||
typeof unsafeMarkdown !== "string" ||
unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH
) {
throw new Error("Markdown 保存参数无效");
}
let targetPath = currentMarkdownPath;
if (!targetPath) {
const suggestedName = /\.(?:md|markdown)$/iu.test(
path.basename(unsafeFileName)
)
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.md`;
const selection = await dialog.showSaveDialog(window, {
title: "保存 Markdown",
defaultPath: path.join(
app.getPath("documents"),
suggestedName
),
filters: [
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
]
});
if (selection.canceled || !selection.filePath) {
return undefined;
}
targetPath = /\.(?:md|markdown)$/iu.test(selection.filePath)
? selection.filePath
: `${selection.filePath}.md`;
}
await writeFile(targetPath, unsafeMarkdown, "utf8");
currentMarkdownPath = targetPath;
documentRoot = path.dirname(targetPath);
return { fileName: path.basename(targetPath) };
}
);
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
const themeDirectory = getDesktopThemeDirectory();
await mkdir(themeDirectory, { recursive: true });
const errorMessage = await shell.openPath(themeDirectory);
if (errorMessage) {
throw new Error(`无法打开自定义主题目录:${errorMessage}`);
}
return themeDirectory;
});
ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
applicationService.invalidateThemes();
});
ipcMain.handle(
DESKTOP_RENDER_MARKDOWN,
async (event, unsafeRequest: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面渲染请求");
}
return applicationService.render(
parseMarkdownRenderRequest(unsafeRequest),
documentRoot ? { localRoot: documentRoot } : {}
);
}
);
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
return applicationService.listThemes();
});
ipcMain.handle(
DESKTOP_GET_THEME_CSS,
async (event, unsafeThemeId: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
const css = await applicationService.getThemeCss(
parseThemeId(unsafeThemeId)
);
if (css === undefined) {
throw new Error("未找到指定主题");
}
return css;
}
);
ipcMain.handle(
DESKTOP_GENERATE_PDF,
async (event, unsafePayload: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面 PDF 请求");
}
const payload = parsePagedDocumentPayload(unsafePayload);
const result = await pdfGenerator.generate(payload);
return {
pdf: result.pdf,
pageCount: result.pageCount,
echartsErrors: result.echartsErrors,
mermaidErrors: result.mermaidErrors
};
}
);
ipcMain.handle(
DESKTOP_SAVE_PDF,
async (
event,
unsafeFileName: unknown,
unsafePdf: unknown
) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面 PDF 保存请求");
}
if (
typeof unsafeFileName !== "string" ||
unsafeFileName.length === 0 ||
unsafeFileName.length > 500 ||
!(unsafePdf instanceof Uint8Array) ||
unsafePdf.byteLength === 0 ||
unsafePdf.byteLength > 200 * 1024 * 1024
) {
throw new Error("PDF 保存参数无效");
}
const suggestedName = path.basename(unsafeFileName).endsWith(".pdf")
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.pdf`;
const selection = await dialog.showSaveDialog(window, {
title: "导出 PDF",
defaultPath: path.join(app.getPath("documents"), suggestedName),
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
});
if (selection.canceled || !selection.filePath) {
return false;
}
await writeFile(selection.filePath, unsafePdf);
return true;
}
);
window.once("ready-to-show", () => {
if (savedWindowState?.maximized) {
window.maximize();
}
window.show();
});
window.on("close", () => {
void flushWindowState();
});
window.on("closed", () => {
ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN);
ipcMain.removeHandler(DESKTOP_CONSUME_PENDING_MARKDOWN);
ipcMain.removeHandler(DESKTOP_DISCARD_PENDING_MARKDOWN);
ipcMain.removeHandler(DESKTOP_START_NEW_MARKDOWN);
ipcMain.removeHandler(DESKTOP_SAVE_MARKDOWN);
ipcMain.removeHandler(DESKTOP_OPEN_THEME_DIRECTORY);
ipcMain.removeHandler(DESKTOP_REFRESH_THEMES);
ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN);
ipcMain.removeHandler(DESKTOP_LIST_THEMES);
ipcMain.removeHandler(DESKTOP_GET_THEME_CSS);
ipcMain.removeHandler(DESKTOP_GENERATE_PDF);
ipcMain.removeHandler(DESKTOP_SAVE_PDF);
void pdfGenerator.close();
});
await window.loadURL(applicationUrl);
return {
window,
openMarkdownFile: queueOpenedMarkdown,
flushWindowState
};
}
const hasSingleInstanceLock = app.requestSingleInstanceLock();
let windowStateReadyToQuit = false;
@@ -631,8 +199,8 @@ if (!hasSingleInstanceLock) {
);
if (filePath) {
dispatchExternalMarkdown(filePath);
} else {
focusApplicationWindow();
} else if (!applicationController?.focusMostRecentWindow()) {
void applicationController?.createWindow();
}
});
app.on("open-file", (event, filePath) => {
@@ -641,6 +209,11 @@ if (!hasSingleInstanceLock) {
dispatchExternalMarkdown(filePath);
}
});
app.on("activate", () => {
if (!applicationController?.focusMostRecentWindow()) {
void applicationController?.createWindow();
}
});
app.on("before-quit", (event) => {
if (windowStateReadyToQuit || !applicationController) {
return;
@@ -649,8 +222,14 @@ if (!hasSingleInstanceLock) {
void applicationController.flushWindowState().finally(() => {
windowStateReadyToQuit = true;
app.quit();
setTimeout(() => {
windowStateReadyToQuit = false;
}, 1_000);
});
});
app.on("will-quit", () => {
void applicationController?.closeResources();
});
app
.whenReady()
.then(async () => {
@@ -658,18 +237,41 @@ if (!hasSingleInstanceLock) {
if (process.platform === "win32") {
app.setAppUserModelId("com.md-to-pdf.desktop");
}
await mkdir(getDesktopThemeDirectory(), { recursive: true });
const themeDirectory = getDesktopThemeDirectory();
await mkdir(themeDirectory, { recursive: true });
const applicationService = createDesktopApplicationService();
await registerApplicationProtocol(applicationService);
applicationController =
await createApplication(applicationService);
const developmentUrl = getCommandLineValue("web-url");
const applicationUrl =
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
applicationController = new DesktopApplicationController({
applicationService,
applicationUrl,
renderUrl: new URL(
"/preview-frame.html?target=pdf",
applicationUrl
).href,
preloadPath: path.join(
currentDirectory,
"app-preload.cjs"
),
pdfPreloadPath: path.join(
currentDirectory,
"pdf-preload.cjs"
),
iconPath: getApplicationIcon(),
themeDirectory,
windowStatePath: path.join(
app.getPath("userData"),
"window-state.json"
)
});
await applicationController.initialize();
const startupMarkdownPath = pendingExternalMarkdownPath;
pendingExternalMarkdownPath = undefined;
if (startupMarkdownPath) {
await applicationController.openMarkdownFile(
startupMarkdownPath
);
}
await applicationController.createWindow(startupMarkdownPath);
})
.catch((error: unknown) => {
console.error("桌面应用启动失败", error);
+37 -1
View File
@@ -1,4 +1,5 @@
import { readFile, stat } from "node:fs/promises";
import { createHash } from "node:crypto";
import { readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
const markdownExtensions = new Set([".md", ".markdown"]);
@@ -8,6 +9,24 @@ export interface OpenedMarkdownDocument {
fileName: string;
}
export interface MarkdownFileSnapshot {
document: OpenedMarkdownDocument;
filePath: string;
documentKey: string;
contentHash: string;
}
export function createMarkdownContentHash(markdown: string) {
return createHash("sha256").update(markdown, "utf8").digest("hex");
}
export function createMarkdownDocumentKey(filePath: string) {
const normalized = path.normalize(filePath);
return process.platform === "win32"
? normalized.toLocaleLowerCase("en-US")
: normalized;
}
export function isMarkdownFilePath(filePath: string) {
return markdownExtensions.has(path.extname(filePath).toLowerCase());
}
@@ -58,3 +77,20 @@ export async function readMarkdownDocument(
fileName: path.basename(filePath)
};
}
export async function readMarkdownFileSnapshot(
filePath: string,
maximumLength: number
): Promise<MarkdownFileSnapshot> {
const canonicalPath = await realpath(filePath);
const document = await readMarkdownDocument(
canonicalPath,
maximumLength
);
return {
document,
filePath: canonicalPath,
documentKey: createMarkdownDocumentKey(canonicalPath),
contentHash: createMarkdownContentHash(document.markdown)
};
}