From 639e0b41938bc553403c71e740e7394e2f2fb6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Wed, 27 May 2026 23:06:04 -0500 Subject: [PATCH] feat: support Obsidian wikilink images Support Obsidian image wikilinks alongside standard markdown images, preserve mixed image order, and resolve Obsidian default Attachments/ paths. Co-authored-by: Chao Zheng <10296164+zcqqq@users.noreply.github.com> --- packages/baoyu-md/dist/index.cjs | 87 +++++++++-- packages/baoyu-md/dist/index.js | 87 +++++++++-- packages/baoyu-md/src/images.test.ts | 58 +++++++ packages/baoyu-md/src/images.ts | 128 ++++++++++++++-- .../scripts/main.test.ts | 73 +++++++++ skills/baoyu-markdown-to-html/scripts/main.ts | 16 +- .../scripts/md-to-wechat.ts | 1 + .../scripts/md-to-html.test.ts | 103 ++++++++++--- skills/baoyu-post-to-x/scripts/md-to-html.ts | 145 ++++-------------- 9 files changed, 514 insertions(+), 184 deletions(-) diff --git a/packages/baoyu-md/dist/index.cjs b/packages/baoyu-md/dist/index.cjs index cf3cb7b..31d4119 100644 --- a/packages/baoyu-md/dist/index.cjs +++ b/packages/baoyu-md/dist/index.cjs @@ -72694,15 +72694,32 @@ var import_node_path6 = __toESM(require("node:path")); function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) { const images = []; let imageCounter = 0; - const rewritten = markdown2.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => { + let lastIndex = 0; + let rewritten = ""; + const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g; + for (const match of markdown2.matchAll(imagePattern)) { + const fullMatch = match[0]; + const matchIndex = match.index ?? 0; + const markdownAlt = match[1]; + const markdownSrc = match[2]; + const wikilinkTarget = match[3]; + const wikilinkImage = wikilinkTarget ? parseObsidianImageWikilink(wikilinkTarget) : null; + if (wikilinkTarget && !wikilinkImage) { + continue; + } + const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? ""; + const alt = wikilinkImage?.alt ?? markdownAlt ?? ""; const placeholder = `${placeholderPrefix}${++imageCounter}`; + rewritten += markdown2.slice(lastIndex, matchIndex); images.push({ alt, - originalPath: src, + originalPath, placeholder }); - return placeholder; - }); + rewritten += placeholder; + lastIndex = matchIndex + fullMatch.length; + } + rewritten += markdown2.slice(lastIndex); return { images, markdown: rewritten }; } function getImageExtension(urlOrPath) { @@ -72757,13 +72774,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m } return localPath; } - const decoded = safeDecodeImagePath(imagePath); - const resolved = resolveAgainstBaseDir(decoded, baseDir); - const resolvedWithFallback = resolveLocalWithFallback(resolved, logLabel); - if (decoded === imagePath || import_node_fs5.default.existsSync(resolvedWithFallback)) { - return resolvedWithFallback; - } - return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel); + return resolveLocalImagePath(imagePath, baseDir, logLabel); } async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") { const resolved = []; @@ -72775,10 +72786,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu- } return resolved; } -function resolveLocalWithFallback(resolved, logLabel) { +function parseObsidianImageWikilink(target) { + const separatorIndex = target.indexOf("|"); + const originalPath = (separatorIndex === -1 ? target : target.slice(0, separatorIndex)).trim(); + const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim(); + if (!hasExplicitImageExtension(originalPath)) { + return null; + } + return { originalPath, alt }; +} +function hasExplicitImageExtension(value2) { + return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value2); +} +function resolveLocalImagePath(imagePath, baseDir, logLabel) { + const decoded = safeDecodeImagePath(imagePath); + const decodedResolved = resolveAgainstBaseDir(decoded, baseDir); + const decodedWithFallback = resolveLocalWithFallback(decodedResolved, logLabel, buildAttachmentFallbackPath(decoded, baseDir)); + if (decoded === imagePath || import_node_fs5.default.existsSync(decodedWithFallback)) { + return decodedWithFallback; + } + return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel, buildAttachmentFallbackPath(imagePath, baseDir)); +} +function resolveLocalWithFallback(resolved, logLabel, attachmentResolved) { if (import_node_fs5.default.existsSync(resolved)) { return resolved; } + if (attachmentResolved && import_node_fs5.default.existsSync(attachmentResolved)) { + logImageFallback(resolved, attachmentResolved, logLabel); + return attachmentResolved; + } + const originalAlternative = findExtensionFallback(resolved); + if (originalAlternative) { + logImageFallback(resolved, originalAlternative, logLabel); + return originalAlternative; + } + if (attachmentResolved) { + const attachmentAlternative = findExtensionFallback(attachmentResolved); + if (attachmentAlternative) { + logImageFallback(resolved, attachmentAlternative, logLabel); + return attachmentAlternative; + } + } + return resolved; +} +function findExtensionFallback(resolved) { const ext = import_node_path6.default.extname(resolved); const base = ext ? resolved.slice(0, -ext.length) : resolved; const alternatives = [ @@ -72793,10 +72844,12 @@ function resolveLocalWithFallback(resolved, logLabel) { for (const alternative of alternatives) { if (!import_node_fs5.default.existsSync(alternative)) continue; - console.error(`[${logLabel}] Image fallback: ${import_node_path6.default.basename(resolved)} -> ${import_node_path6.default.basename(alternative)}`); return alternative; } - return resolved; + return null; +} +function logImageFallback(fromPath, toPath, logLabel) { + console.error(`[${logLabel}] Image fallback: ${import_node_path6.default.basename(fromPath)} -> ${import_node_path6.default.basename(toPath)}`); } function safeDecodeImagePath(imagePath) { try { @@ -72808,6 +72861,12 @@ function safeDecodeImagePath(imagePath) { function resolveAgainstBaseDir(imagePath, baseDir) { return import_node_path6.default.isAbsolute(imagePath) ? imagePath : import_node_path6.default.resolve(baseDir, imagePath); } +function buildAttachmentFallbackPath(imagePath, baseDir) { + if (import_node_path6.default.isAbsolute(imagePath)) { + return; + } + return import_node_path6.default.resolve(baseDir, "Attachments", imagePath); +} // src/mermaid-preprocess.ts var import_node_fs6 = __toESM(require("node:fs")); var import_node_path7 = __toESM(require("node:path")); diff --git a/packages/baoyu-md/dist/index.js b/packages/baoyu-md/dist/index.js index 9ad278f..a8ea9d6 100644 --- a/packages/baoyu-md/dist/index.js +++ b/packages/baoyu-md/dist/index.js @@ -72620,15 +72620,32 @@ import path5 from "node:path"; function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) { const images = []; let imageCounter = 0; - const rewritten = markdown2.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => { + let lastIndex = 0; + let rewritten = ""; + const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g; + for (const match of markdown2.matchAll(imagePattern)) { + const fullMatch = match[0]; + const matchIndex = match.index ?? 0; + const markdownAlt = match[1]; + const markdownSrc = match[2]; + const wikilinkTarget = match[3]; + const wikilinkImage = wikilinkTarget ? parseObsidianImageWikilink(wikilinkTarget) : null; + if (wikilinkTarget && !wikilinkImage) { + continue; + } + const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? ""; + const alt = wikilinkImage?.alt ?? markdownAlt ?? ""; const placeholder = `${placeholderPrefix}${++imageCounter}`; + rewritten += markdown2.slice(lastIndex, matchIndex); images.push({ alt, - originalPath: src, + originalPath, placeholder }); - return placeholder; - }); + rewritten += placeholder; + lastIndex = matchIndex + fullMatch.length; + } + rewritten += markdown2.slice(lastIndex); return { images, markdown: rewritten }; } function getImageExtension(urlOrPath) { @@ -72683,13 +72700,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m } return localPath; } - const decoded = safeDecodeImagePath(imagePath); - const resolved = resolveAgainstBaseDir(decoded, baseDir); - const resolvedWithFallback = resolveLocalWithFallback(resolved, logLabel); - if (decoded === imagePath || fs5.existsSync(resolvedWithFallback)) { - return resolvedWithFallback; - } - return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel); + return resolveLocalImagePath(imagePath, baseDir, logLabel); } async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") { const resolved = []; @@ -72701,10 +72712,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu- } return resolved; } -function resolveLocalWithFallback(resolved, logLabel) { +function parseObsidianImageWikilink(target) { + const separatorIndex = target.indexOf("|"); + const originalPath = (separatorIndex === -1 ? target : target.slice(0, separatorIndex)).trim(); + const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim(); + if (!hasExplicitImageExtension(originalPath)) { + return null; + } + return { originalPath, alt }; +} +function hasExplicitImageExtension(value2) { + return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value2); +} +function resolveLocalImagePath(imagePath, baseDir, logLabel) { + const decoded = safeDecodeImagePath(imagePath); + const decodedResolved = resolveAgainstBaseDir(decoded, baseDir); + const decodedWithFallback = resolveLocalWithFallback(decodedResolved, logLabel, buildAttachmentFallbackPath(decoded, baseDir)); + if (decoded === imagePath || fs5.existsSync(decodedWithFallback)) { + return decodedWithFallback; + } + return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel, buildAttachmentFallbackPath(imagePath, baseDir)); +} +function resolveLocalWithFallback(resolved, logLabel, attachmentResolved) { if (fs5.existsSync(resolved)) { return resolved; } + if (attachmentResolved && fs5.existsSync(attachmentResolved)) { + logImageFallback(resolved, attachmentResolved, logLabel); + return attachmentResolved; + } + const originalAlternative = findExtensionFallback(resolved); + if (originalAlternative) { + logImageFallback(resolved, originalAlternative, logLabel); + return originalAlternative; + } + if (attachmentResolved) { + const attachmentAlternative = findExtensionFallback(attachmentResolved); + if (attachmentAlternative) { + logImageFallback(resolved, attachmentAlternative, logLabel); + return attachmentAlternative; + } + } + return resolved; +} +function findExtensionFallback(resolved) { const ext = path5.extname(resolved); const base = ext ? resolved.slice(0, -ext.length) : resolved; const alternatives = [ @@ -72719,10 +72770,12 @@ function resolveLocalWithFallback(resolved, logLabel) { for (const alternative of alternatives) { if (!fs5.existsSync(alternative)) continue; - console.error(`[${logLabel}] Image fallback: ${path5.basename(resolved)} -> ${path5.basename(alternative)}`); return alternative; } - return resolved; + return null; +} +function logImageFallback(fromPath, toPath, logLabel) { + console.error(`[${logLabel}] Image fallback: ${path5.basename(fromPath)} -> ${path5.basename(toPath)}`); } function safeDecodeImagePath(imagePath) { try { @@ -72734,6 +72787,12 @@ function safeDecodeImagePath(imagePath) { function resolveAgainstBaseDir(imagePath, baseDir) { return path5.isAbsolute(imagePath) ? imagePath : path5.resolve(baseDir, imagePath); } +function buildAttachmentFallbackPath(imagePath, baseDir) { + if (path5.isAbsolute(imagePath)) { + return; + } + return path5.resolve(baseDir, "Attachments", imagePath); +} // src/mermaid-preprocess.ts import fs6 from "node:fs"; import path6 from "node:path"; diff --git a/packages/baoyu-md/src/images.test.ts b/packages/baoyu-md/src/images.test.ts index 793cf4a..8a4f148 100644 --- a/packages/baoyu-md/src/images.test.ts +++ b/packages/baoyu-md/src/images.test.ts @@ -28,6 +28,32 @@ test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image m ]); }); +test("replaceMarkdownImagesWithPlaceholders supports Obsidian image wikilinks in document order", () => { + const result = replaceMarkdownImagesWithPlaceholders( + `Intro\n\n![[a.png]]\n\n![B](b.jpg)\n\n![[c.webp|C alt]]\n\n![[note]]`, + "IMG_", + ); + + assert.equal(result.markdown, `Intro\n\nIMG_1\n\nIMG_2\n\nIMG_3\n\n![[note]]`); + assert.deepEqual(result.images, [ + { alt: "", originalPath: "a.png", placeholder: "IMG_1" }, + { alt: "B", originalPath: "b.jpg", placeholder: "IMG_2" }, + { alt: "C alt", originalPath: "c.webp", placeholder: "IMG_3" }, + ]); +}); + +test("replaceMarkdownImagesWithPlaceholders supports Obsidian image wikilinks with paths", () => { + const result = replaceMarkdownImagesWithPlaceholders( + `![[Attachments/screenshot.png]]`, + "IMG_", + ); + + assert.equal(result.markdown, `IMG_1`); + assert.deepEqual(result.images, [ + { alt: "", originalPath: "Attachments/screenshot.png", placeholder: "IMG_1" }, + ]); +}); + test("image extension and local fallback resolution handle common path variants", async (t) => { assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg"); assert.equal(getImageExtension("/tmp/figure"), "png"); @@ -45,6 +71,38 @@ test("image extension and local fallback resolution handle common path variants" assert.equal(resolved, path.join(baseDir, "figure.webp")); }); +test("resolveImagePath falls back to Attachments subdirectory before extension variants", async (t) => { + const root = await makeTempDir("baoyu-md-attachments-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const baseDir = path.join(root, "article"); + const tempDir = path.join(root, "tmp"); + const attachmentsDir = path.join(baseDir, "Attachments"); + await fs.mkdir(attachmentsDir, { recursive: true }); + await fs.mkdir(tempDir, { recursive: true }); + await fs.writeFile(path.join(baseDir, "figure.webp"), "webp"); + await fs.writeFile(path.join(attachmentsDir, "figure.png"), "png"); + + const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test"); + assert.equal(resolved, path.join(attachmentsDir, "figure.png")); +}); + +test("resolveImagePath prefers original path before Attachments fallback", async (t) => { + const root = await makeTempDir("baoyu-md-attachments-original-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const baseDir = path.join(root, "article"); + const tempDir = path.join(root, "tmp"); + const attachmentsDir = path.join(baseDir, "Attachments"); + await fs.mkdir(attachmentsDir, { recursive: true }); + await fs.mkdir(tempDir, { recursive: true }); + await fs.writeFile(path.join(baseDir, "figure.png"), "png"); + await fs.writeFile(path.join(attachmentsDir, "figure.png"), "attachment png"); + + const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test"); + assert.equal(resolved, path.join(baseDir, "figure.png")); +}); + test("resolveImagePath decodes URL-encoded filenames with spaces", async (t) => { const root = await makeTempDir("baoyu-md-urlencoded-"); t.after(() => fs.rm(root, { recursive: true, force: true })); diff --git a/packages/baoyu-md/src/images.ts b/packages/baoyu-md/src/images.ts index 3bc43d0..a42da97 100644 --- a/packages/baoyu-md/src/images.ts +++ b/packages/baoyu-md/src/images.ts @@ -23,16 +23,39 @@ export function replaceMarkdownImagesWithPlaceholders( } { const images: ImagePlaceholder[] = []; let imageCounter = 0; + let lastIndex = 0; + let rewritten = ""; - const rewritten = markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => { + const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g; + for (const match of markdown.matchAll(imagePattern)) { + const fullMatch = match[0]; + const matchIndex = match.index ?? 0; + const markdownAlt = match[1]; + const markdownSrc = match[2]; + const wikilinkTarget = match[3]; + const wikilinkImage = wikilinkTarget + ? parseObsidianImageWikilink(wikilinkTarget) + : null; + + if (wikilinkTarget && !wikilinkImage) { + continue; + } + + const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? ""; + const alt = wikilinkImage?.alt ?? markdownAlt ?? ""; const placeholder = `${placeholderPrefix}${++imageCounter}`; + + rewritten += markdown.slice(lastIndex, matchIndex); images.push({ alt, - originalPath: src, + originalPath, placeholder, }); - return placeholder; - }); + rewritten += placeholder; + lastIndex = matchIndex + fullMatch.length; + } + + rewritten += markdown.slice(lastIndex); return { images, markdown: rewritten }; } @@ -103,14 +126,7 @@ export async function resolveImagePath( return localPath; } - const decoded = safeDecodeImagePath(imagePath); - const resolved = resolveAgainstBaseDir(decoded, baseDir); - const resolvedWithFallback = resolveLocalWithFallback(resolved, logLabel); - if (decoded === imagePath || fs.existsSync(resolvedWithFallback)) { - return resolvedWithFallback; - } - - return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel); + return resolveLocalImagePath(imagePath, baseDir, logLabel); } export async function resolveContentImages( @@ -131,11 +147,79 @@ export async function resolveContentImages( return resolved; } -function resolveLocalWithFallback(resolved: string, logLabel: string): string { +function parseObsidianImageWikilink(target: string): { + originalPath: string; + alt: string; +} | null { + const separatorIndex = target.indexOf("|"); + const originalPath = (separatorIndex === -1 + ? target + : target.slice(0, separatorIndex)).trim(); + const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim(); + + if (!hasExplicitImageExtension(originalPath)) { + return null; + } + + return { originalPath, alt }; +} + +function hasExplicitImageExtension(value: string): boolean { + return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value); +} + +function resolveLocalImagePath(imagePath: string, baseDir: string, logLabel: string): string { + const decoded = safeDecodeImagePath(imagePath); + const decodedResolved = resolveAgainstBaseDir(decoded, baseDir); + const decodedWithFallback = resolveLocalWithFallback( + decodedResolved, + logLabel, + buildAttachmentFallbackPath(decoded, baseDir), + ); + + if (decoded === imagePath || fs.existsSync(decodedWithFallback)) { + return decodedWithFallback; + } + + return resolveLocalWithFallback( + resolveAgainstBaseDir(imagePath, baseDir), + logLabel, + buildAttachmentFallbackPath(imagePath, baseDir), + ); +} + +function resolveLocalWithFallback( + resolved: string, + logLabel: string, + attachmentResolved?: string, +): string { if (fs.existsSync(resolved)) { return resolved; } + if (attachmentResolved && fs.existsSync(attachmentResolved)) { + logImageFallback(resolved, attachmentResolved, logLabel); + return attachmentResolved; + } + + const originalAlternative = findExtensionFallback(resolved); + if (originalAlternative) { + logImageFallback(resolved, originalAlternative, logLabel); + return originalAlternative; + } + + if (attachmentResolved) { + const attachmentAlternative = findExtensionFallback(attachmentResolved); + if (attachmentAlternative) { + logImageFallback(resolved, attachmentAlternative, logLabel); + return attachmentAlternative; + } + } + + return resolved; +} + +function findExtensionFallback(resolved: string): string | null { const ext = path.extname(resolved); const base = ext ? resolved.slice(0, -ext.length) : resolved; const alternatives = [ @@ -150,13 +234,16 @@ function resolveLocalWithFallback(resolved: string, logLabel: string): string { for (const alternative of alternatives) { if (!fs.existsSync(alternative)) continue; - console.error( - `[${logLabel}] Image fallback: ${path.basename(resolved)} -> ${path.basename(alternative)}`, - ); return alternative; } - return resolved; + return null; +} + +function logImageFallback(fromPath: string, toPath: string, logLabel: string): void { + console.error( + `[${logLabel}] Image fallback: ${path.basename(fromPath)} -> ${path.basename(toPath)}`, + ); } function safeDecodeImagePath(imagePath: string): string { @@ -170,3 +257,10 @@ function safeDecodeImagePath(imagePath: string): string { function resolveAgainstBaseDir(imagePath: string, baseDir: string): string { return path.isAbsolute(imagePath) ? imagePath : path.resolve(baseDir, imagePath); } + +function buildAttachmentFallbackPath(imagePath: string, baseDir: string): string | undefined { + if (path.isAbsolute(imagePath)) { + return undefined; + } + return path.resolve(baseDir, "Attachments", imagePath); +} diff --git a/skills/baoyu-markdown-to-html/scripts/main.test.ts b/skills/baoyu-markdown-to-html/scripts/main.test.ts index 2027585..a14ec7c 100644 --- a/skills/baoyu-markdown-to-html/scripts/main.test.ts +++ b/skills/baoyu-markdown-to-html/scripts/main.test.ts @@ -54,3 +54,76 @@ test("CLI forwards wrapper title and package render options", async () => { /]*style="[^"]*font-family: Menlo, Monaco, 'Courier New', monospace;[^"]*font-size: 18px/, ); }); + +test("CLI renders Obsidian wikilink images with alt text and Attachments fallback", async () => { + const root = await makeTempDir("baoyu-markdown-to-html-wikilink-cli-"); + const attachmentsDir = path.join(root, "Attachments"); + await fs.mkdir(attachmentsDir, { recursive: true }); + await fs.writeFile(path.join(root, "a.png"), "a", "utf-8"); + await fs.writeFile(path.join(attachmentsDir, "b.webp"), "b", "utf-8"); + + const markdownPath = path.join(root, "article.md"); + await fs.writeFile( + markdownPath, + [ + "## Section", + "", + "![[a.png]]", + "", + "![[b.webp|B alt]]", + ].join("\n"), + "utf-8", + ); + + const { stdout } = await execFileAsync( + process.execPath, + [ + "--import", + "tsx", + SCRIPT_PATH, + markdownPath, + "--keep-title", + ], + { cwd: SCRIPT_DIR }, + ); + + const result = JSON.parse(stdout.trim()) as { + contentImages: Array<{ + alt?: string; + localPath: string; + originalPath: string; + placeholder: string; + }>; + htmlPath: string; + }; + + assert.deepEqual( + result.contentImages.map(({ alt, localPath, originalPath, placeholder }) => ({ + alt, + localPath, + originalPath, + placeholder, + })), + [ + { + alt: "", + localPath: path.join(root, "a.png"), + originalPath: "a.png", + placeholder: "MDTOHTMLIMGPH_1", + }, + { + alt: "B alt", + localPath: path.join(attachmentsDir, "b.webp"), + originalPath: "b.webp", + placeholder: "MDTOHTMLIMGPH_2", + }, + ], + ); + + const html = await fs.readFile(result.htmlPath, "utf-8"); + assert.match(html, /> & { mermaid?: MermaidCliOptions; }; +function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + export async function convertMarkdown( markdownPath: string, options?: ConvertMarkdownOptions, @@ -160,7 +169,12 @@ export async function convertMarkdown( let finalContent = fs.readFileSync(finalHtmlPath, "utf-8"); for (const image of contentImages) { - const imgTag = ``; + const altAttr = image.alt !== undefined + ? ` alt="${escapeHtmlAttribute(image.alt)}"` + : ""; + const imgTag = ``; finalContent = finalContent.replace(image.placeholder, imgTag); } fs.writeFileSync(finalHtmlPath, finalContent, "utf-8"); diff --git a/skills/baoyu-post-to-wechat/scripts/md-to-wechat.ts b/skills/baoyu-post-to-wechat/scripts/md-to-wechat.ts index 7cf41e2..55a71c2 100644 --- a/skills/baoyu-post-to-wechat/scripts/md-to-wechat.ts +++ b/skills/baoyu-post-to-wechat/scripts/md-to-wechat.ts @@ -22,6 +22,7 @@ interface ImageInfo { placeholder: string; localPath: string; originalPath: string; + alt?: string; } interface ParsedResult { diff --git a/skills/baoyu-post-to-x/scripts/md-to-html.test.ts b/skills/baoyu-post-to-x/scripts/md-to-html.test.ts index cfad63d..848add1 100644 --- a/skills/baoyu-post-to-x/scripts/md-to-html.test.ts +++ b/skills/baoyu-post-to-x/scripts/md-to-html.test.ts @@ -1,37 +1,100 @@ -import assert from "node:assert/strict"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; -import { parseMarkdown } from "./md-to-html.ts"; +import { parseMarkdown } from './md-to-html.ts'; async function makeTempDir(prefix: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), prefix)); } -test("parseMarkdown resolves encoded spaces and literal percent image paths", async (t) => { - const root = await makeTempDir("baoyu-post-to-x-images-"); +test('parseMarkdown preserves mixed markdown and Obsidian wikilink image order', async (t) => { + const root = await makeTempDir('x-md-to-html-wikilinks-'); t.after(() => fs.rm(root, { recursive: true, force: true })); - const articlePath = path.join(root, "article.md"); - const tempDir = path.join(root, "tmp"); + const articleDir = path.join(root, 'article'); + const attachmentsDir = path.join(articleDir, 'Attachments'); + const tempDir = path.join(root, 'tmp'); + await fs.mkdir(attachmentsDir, { recursive: true }); await fs.mkdir(tempDir, { recursive: true }); - await fs.writeFile(path.join(root, "Pasted image.png"), "png"); - await fs.writeFile(path.join(root, "100%.png"), "png"); + await fs.writeFile(path.join(articleDir, 'a.png'), 'a'); + await fs.writeFile(path.join(articleDir, 'b.jpg'), 'b'); + await fs.writeFile(path.join(attachmentsDir, 'c.webp'), 'c'); + + const markdownPath = path.join(articleDir, 'post.md'); + await fs.writeFile( + markdownPath, + [ + '# Title', + '', + '![[a.png]]', + '', + '![B alt](b.jpg)', + '', + '![[c.webp|C alt]]', + '', + '![[note]]', + ].join('\n'), + ); + + const result = await parseMarkdown(markdownPath, { tempDir }); + + assert.deepEqual( + result.contentImages.map(({ placeholder, originalPath, alt, localPath }) => ({ + placeholder, + originalPath, + alt, + localPath, + })), + [ + { + placeholder: 'XIMGPH_1', + originalPath: 'a.png', + alt: '', + localPath: path.join(articleDir, 'a.png'), + }, + { + placeholder: 'XIMGPH_2', + originalPath: 'b.jpg', + alt: 'B alt', + localPath: path.join(articleDir, 'b.jpg'), + }, + { + placeholder: 'XIMGPH_3', + originalPath: 'c.webp', + alt: 'C alt', + localPath: path.join(attachmentsDir, 'c.webp'), + }, + ], + ); + assert.match(result.html, /XIMGPH_1[\s\S]*XIMGPH_2[\s\S]*XIMGPH_3/); + assert.match(result.html, /!\[\[note\]\]/); +}); + +test('parseMarkdown resolves encoded spaces and literal percent image paths', async (t) => { + const root = await makeTempDir('baoyu-post-to-x-images-'); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const articlePath = path.join(root, 'article.md'); + const tempDir = path.join(root, 'tmp'); + await fs.mkdir(tempDir, { recursive: true }); + await fs.writeFile(path.join(root, 'Pasted image.png'), 'png'); + await fs.writeFile(path.join(root, '100%.png'), 'png'); await fs.writeFile( articlePath, [ - "# Title", - "", - "![encoded](Pasted%20image.png)", - "", - "![literal](100%.png)", - ].join("\n"), + '# Title', + '', + '![encoded](Pasted%20image.png)', + '', + '![literal](100%.png)', + ].join('\n'), ); const result = await parseMarkdown(articlePath, { tempDir }); - assert.equal(result.contentImages[0]?.localPath, path.join(root, "Pasted image.png")); - assert.equal(result.contentImages[1]?.localPath, path.join(root, "100%.png")); + assert.equal(result.contentImages[0]?.localPath, path.join(root, 'Pasted image.png')); + assert.equal(result.contentImages[1]?.localPath, path.join(root, '100%.png')); }); diff --git a/skills/baoyu-post-to-x/scripts/md-to-html.ts b/skills/baoyu-post-to-x/scripts/md-to-html.ts index 545aaa7..5c3debd 100644 --- a/skills/baoyu-post-to-x/scripts/md-to-html.ts +++ b/skills/baoyu-post-to-x/scripts/md-to-html.ts @@ -1,10 +1,8 @@ import fs from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; -import https from 'node:https'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; -import { createHash } from 'node:crypto'; import { pathToFileURL } from 'node:url'; import frontMatter from 'front-matter'; @@ -15,7 +13,11 @@ import remarkCjkFriendly from 'remark-cjk-friendly'; import remarkParse from 'remark-parse'; import remarkStringify from 'remark-stringify'; -import { preprocessMermaidInMarkdown } from 'baoyu-md'; +import { + preprocessMermaidInMarkdown, + replaceMarkdownImagesWithPlaceholders, + resolveImagePath, +} from 'baoyu-md'; import { closeRenderer, renderMermaidToPng } from 'baoyu-chrome-cdp/mermaid'; interface ImageInfo { @@ -23,6 +25,7 @@ interface ImageInfo { localPath: string; originalPath: string; blockIndex: number; + alt?: string; } interface ParsedMarkdown { @@ -109,103 +112,6 @@ function extractTitleFromMarkdown(markdown: string): string { return ''; } -function downloadFile(url: string, destPath: string, maxRedirects = 5): Promise { - return new Promise((resolve, reject) => { - if (!url.startsWith('https://')) { - reject(new Error(`Refusing non-HTTPS download: ${url}`)); - return; - } - if (maxRedirects <= 0) { - reject(new Error('Too many redirects')); - return; - } - const file = fs.createWriteStream(destPath); - - const request = https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => { - if (response.statusCode === 301 || response.statusCode === 302) { - const redirectUrl = response.headers.location; - if (redirectUrl) { - file.close(); - fs.unlinkSync(destPath); - downloadFile(redirectUrl, destPath, maxRedirects - 1).then(resolve).catch(reject); - return; - } - } - - if (response.statusCode !== 200) { - file.close(); - fs.unlinkSync(destPath); - reject(new Error(`Failed to download: ${response.statusCode}`)); - return; - } - - response.pipe(file); - file.on('finish', () => { - file.close(); - resolve(); - }); - }); - - request.on('error', (err) => { - file.close(); - fs.unlink(destPath, () => {}); - reject(err); - }); - - request.setTimeout(30000, () => { - request.destroy(); - reject(new Error('Download timeout')); - }); - }); -} - -function getImageExtension(urlOrPath: string): string { - const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i); - return match ? match[1]!.toLowerCase() : 'png'; -} - -async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): Promise { - if (imagePath.startsWith('http://')) { - console.error(`[md-to-html] Skipping non-HTTPS image: ${imagePath}`); - return ''; - } - if (imagePath.startsWith('https://')) { - const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8); - const ext = getImageExtension(imagePath); - const localPath = path.join(tempDir, `remote_${hash}.${ext}`); - - if (!fs.existsSync(localPath)) { - console.error(`[md-to-html] Downloading: ${imagePath}`); - await downloadFile(imagePath, localPath); - } - return localPath; - } - - return resolveLocalImagePath(imagePath, baseDir); -} - -function resolveLocalImagePath(imagePath: string, baseDir: string): string { - const decoded = safeDecodeImagePath(imagePath); - const resolved = resolveAgainstBaseDir(decoded, baseDir); - if (decoded === imagePath || fs.existsSync(resolved)) { - return resolved; - } - - return resolveAgainstBaseDir(imagePath, baseDir); -} - -function safeDecodeImagePath(imagePath: string): string { - try { - return decodeURIComponent(imagePath); - } catch { - return imagePath; - } -} - -function resolveAgainstBaseDir(imagePath: string, baseDir: string): string { - return path.isAbsolute(imagePath) ? imagePath : path.resolve(baseDir, imagePath); -} - function escapeHtml(text: string): string { return text .replace(/&/g, '&') @@ -215,6 +121,10 @@ function escapeHtml(text: string): string { .replace(/'/g, '''); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function highlightCode(code: string, lang: string): string { try { if (lang && hljs.getLanguage(lang)) { @@ -240,7 +150,7 @@ function preprocessCjkMarkdown(markdown: string): string { } } -function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } { +function convertMarkdownToHtml(markdown: string): { html: string; totalBlocks: number } { const preprocessedMarkdown = preprocessCjkMarkdown(markdown); const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true }); @@ -272,7 +182,7 @@ function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, al image({ href, text }: Tokens.Image): string { if (!href) return ''; - return imageCallback(href, text ?? ''); + return escapeHtml(text ?? ''); }, link({ href, title, tokens, text }: Tokens.Link): string { @@ -358,22 +268,20 @@ export async function parseMarkdown( ); } - const images: Array<{ src: string; alt: string; blockIndex: number }> = []; - let imageCounter = 0; - - const { html, totalBlocks } = convertMarkdownToHtml(mermaidProcessedBody, (src, alt) => { - const placeholder = `XIMGPH_${++imageCounter}`; - images.push({ src, alt, blockIndex: -1 }); - return placeholder; - }); + const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders( + mermaidProcessedBody, + 'XIMGPH_', + ); + const { html, totalBlocks } = convertMarkdownToHtml(rewrittenBody); const htmlLines = html.split('\n'); + const imageBlockIndexes = new Map(); for (let i = 0; i < images.length; i++) { - const placeholder = `XIMGPH_${i + 1}`; + const placeholder = images[i]!.placeholder; for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) { - const regex = new RegExp(`\\b${placeholder}\\b`); + const regex = new RegExp(`\\b${escapeRegExp(placeholder)}\\b`); if (regex.test(htmlLines[lineIndex]!)) { - images[i]!.blockIndex = lineIndex; + imageBlockIndexes.set(placeholder, lineIndex); break; } } @@ -384,17 +292,18 @@ export async function parseMarkdown( for (let i = 0; i < images.length; i++) { const img = images[i]!; - const localPath = await resolveImagePath(img.src, baseDir, tempDir); + const localPath = await resolveImagePath(img.originalPath, baseDir, tempDir, 'md-to-html'); if (i === 0 && !coverImagePath) { firstImageAsCover = localPath; } contentImages.push({ - placeholder: `XIMGPH_${i + 1}`, + placeholder: img.placeholder, localPath, - originalPath: img.src, - blockIndex: img.blockIndex, + originalPath: img.originalPath, + alt: img.alt, + blockIndex: imageBlockIndexes.get(img.placeholder) ?? -1, }); } @@ -402,7 +311,7 @@ export async function parseMarkdown( let resolvedCoverImage: string | null = null; if (coverImagePath) { - resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir); + resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir, 'md-to-html'); } else if (firstImageAsCover) { resolvedCoverImage = firstImageAsCover; }