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\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 = `