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>
This commit is contained in:
Jim Liu 宝玉
2026-05-27 23:06:04 -05:00
parent 84cefc2784
commit 639e0b4193
9 changed files with 514 additions and 184 deletions
@@ -54,3 +54,76 @@ test("CLI forwards wrapper title and package render options", async () => {
/<body[^>]*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, /<img src="a\.png" data-local-path="[^"]+a\.png" alt=""/);
assert.match(
html,
/<img src="b\.webp" data-local-path="[^"]+Attachments[^"]+b\.webp" alt="B alt"/,
);
});
+15 -1
View File
@@ -27,6 +27,7 @@ interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
alt?: string;
}
interface MermaidImageInfo {
@@ -58,6 +59,14 @@ type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
mermaid?: MermaidCliOptions;
};
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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 = `<img src="${image.originalPath}" data-local-path="${image.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
const altAttr = image.alt !== undefined
? ` alt="${escapeHtmlAttribute(image.alt)}"`
: "";
const imgTag = `<img src="${escapeHtmlAttribute(image.originalPath)}" `
+ `data-local-path="${escapeHtmlAttribute(image.localPath)}"${altAttr} `
+ `style="display: block; width: 100%; margin: 1.5em auto;">`;
finalContent = finalContent.replace(image.placeholder, imgTag);
}
fs.writeFileSync(finalHtmlPath, finalContent, "utf-8");
@@ -22,6 +22,7 @@ interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
alt?: string;
}
interface ParsedResult {
@@ -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<string> {
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'));
});
+27 -118
View File
@@ -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<void> {
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<string> {
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, '&amp;')
@@ -215,6 +121,10 @@ function escapeHtml(text: string): string {
.replace(/'/g, '&#39;');
}
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<string, number>();
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;
}