mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
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:
Vendored
+73
-14
@@ -72694,15 +72694,32 @@ var import_node_path6 = __toESM(require("node:path"));
|
|||||||
function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) {
|
function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) {
|
||||||
const images = [];
|
const images = [];
|
||||||
let imageCounter = 0;
|
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}`;
|
const placeholder = `${placeholderPrefix}${++imageCounter}`;
|
||||||
|
rewritten += markdown2.slice(lastIndex, matchIndex);
|
||||||
images.push({
|
images.push({
|
||||||
alt,
|
alt,
|
||||||
originalPath: src,
|
originalPath,
|
||||||
placeholder
|
placeholder
|
||||||
});
|
});
|
||||||
return placeholder;
|
rewritten += placeholder;
|
||||||
});
|
lastIndex = matchIndex + fullMatch.length;
|
||||||
|
}
|
||||||
|
rewritten += markdown2.slice(lastIndex);
|
||||||
return { images, markdown: rewritten };
|
return { images, markdown: rewritten };
|
||||||
}
|
}
|
||||||
function getImageExtension(urlOrPath) {
|
function getImageExtension(urlOrPath) {
|
||||||
@@ -72757,13 +72774,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m
|
|||||||
}
|
}
|
||||||
return localPath;
|
return localPath;
|
||||||
}
|
}
|
||||||
const decoded = safeDecodeImagePath(imagePath);
|
return resolveLocalImagePath(imagePath, baseDir, logLabel);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
|
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
|
||||||
const resolved = [];
|
const resolved = [];
|
||||||
@@ -72775,10 +72786,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-
|
|||||||
}
|
}
|
||||||
return resolved;
|
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)) {
|
if (import_node_fs5.default.existsSync(resolved)) {
|
||||||
return 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 ext = import_node_path6.default.extname(resolved);
|
||||||
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
||||||
const alternatives = [
|
const alternatives = [
|
||||||
@@ -72793,10 +72844,12 @@ function resolveLocalWithFallback(resolved, logLabel) {
|
|||||||
for (const alternative of alternatives) {
|
for (const alternative of alternatives) {
|
||||||
if (!import_node_fs5.default.existsSync(alternative))
|
if (!import_node_fs5.default.existsSync(alternative))
|
||||||
continue;
|
continue;
|
||||||
console.error(`[${logLabel}] Image fallback: ${import_node_path6.default.basename(resolved)} -> ${import_node_path6.default.basename(alternative)}`);
|
|
||||||
return 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) {
|
function safeDecodeImagePath(imagePath) {
|
||||||
try {
|
try {
|
||||||
@@ -72808,6 +72861,12 @@ function safeDecodeImagePath(imagePath) {
|
|||||||
function resolveAgainstBaseDir(imagePath, baseDir) {
|
function resolveAgainstBaseDir(imagePath, baseDir) {
|
||||||
return import_node_path6.default.isAbsolute(imagePath) ? imagePath : import_node_path6.default.resolve(baseDir, imagePath);
|
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
|
// src/mermaid-preprocess.ts
|
||||||
var import_node_fs6 = __toESM(require("node:fs"));
|
var import_node_fs6 = __toESM(require("node:fs"));
|
||||||
var import_node_path7 = __toESM(require("node:path"));
|
var import_node_path7 = __toESM(require("node:path"));
|
||||||
|
|||||||
Vendored
+73
-14
@@ -72620,15 +72620,32 @@ import path5 from "node:path";
|
|||||||
function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) {
|
function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) {
|
||||||
const images = [];
|
const images = [];
|
||||||
let imageCounter = 0;
|
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}`;
|
const placeholder = `${placeholderPrefix}${++imageCounter}`;
|
||||||
|
rewritten += markdown2.slice(lastIndex, matchIndex);
|
||||||
images.push({
|
images.push({
|
||||||
alt,
|
alt,
|
||||||
originalPath: src,
|
originalPath,
|
||||||
placeholder
|
placeholder
|
||||||
});
|
});
|
||||||
return placeholder;
|
rewritten += placeholder;
|
||||||
});
|
lastIndex = matchIndex + fullMatch.length;
|
||||||
|
}
|
||||||
|
rewritten += markdown2.slice(lastIndex);
|
||||||
return { images, markdown: rewritten };
|
return { images, markdown: rewritten };
|
||||||
}
|
}
|
||||||
function getImageExtension(urlOrPath) {
|
function getImageExtension(urlOrPath) {
|
||||||
@@ -72683,13 +72700,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m
|
|||||||
}
|
}
|
||||||
return localPath;
|
return localPath;
|
||||||
}
|
}
|
||||||
const decoded = safeDecodeImagePath(imagePath);
|
return resolveLocalImagePath(imagePath, baseDir, logLabel);
|
||||||
const resolved = resolveAgainstBaseDir(decoded, baseDir);
|
|
||||||
const resolvedWithFallback = resolveLocalWithFallback(resolved, logLabel);
|
|
||||||
if (decoded === imagePath || fs5.existsSync(resolvedWithFallback)) {
|
|
||||||
return resolvedWithFallback;
|
|
||||||
}
|
|
||||||
return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel);
|
|
||||||
}
|
}
|
||||||
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
|
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
|
||||||
const resolved = [];
|
const resolved = [];
|
||||||
@@ -72701,10 +72712,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-
|
|||||||
}
|
}
|
||||||
return resolved;
|
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)) {
|
if (fs5.existsSync(resolved)) {
|
||||||
return 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 ext = path5.extname(resolved);
|
||||||
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
||||||
const alternatives = [
|
const alternatives = [
|
||||||
@@ -72719,10 +72770,12 @@ function resolveLocalWithFallback(resolved, logLabel) {
|
|||||||
for (const alternative of alternatives) {
|
for (const alternative of alternatives) {
|
||||||
if (!fs5.existsSync(alternative))
|
if (!fs5.existsSync(alternative))
|
||||||
continue;
|
continue;
|
||||||
console.error(`[${logLabel}] Image fallback: ${path5.basename(resolved)} -> ${path5.basename(alternative)}`);
|
|
||||||
return 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) {
|
function safeDecodeImagePath(imagePath) {
|
||||||
try {
|
try {
|
||||||
@@ -72734,6 +72787,12 @@ function safeDecodeImagePath(imagePath) {
|
|||||||
function resolveAgainstBaseDir(imagePath, baseDir) {
|
function resolveAgainstBaseDir(imagePath, baseDir) {
|
||||||
return path5.isAbsolute(imagePath) ? imagePath : path5.resolve(baseDir, imagePath);
|
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
|
// src/mermaid-preprocess.ts
|
||||||
import fs6 from "node:fs";
|
import fs6 from "node:fs";
|
||||||
import path6 from "node:path";
|
import path6 from "node:path";
|
||||||
|
|||||||
@@ -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) => {
|
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("https://example.com/a.jpeg?x=1"), "jpeg");
|
||||||
assert.equal(getImageExtension("/tmp/figure"), "png");
|
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"));
|
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) => {
|
test("resolveImagePath decodes URL-encoded filenames with spaces", async (t) => {
|
||||||
const root = await makeTempDir("baoyu-md-urlencoded-");
|
const root = await makeTempDir("baoyu-md-urlencoded-");
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|||||||
+111
-17
@@ -23,16 +23,39 @@ export function replaceMarkdownImagesWithPlaceholders(
|
|||||||
} {
|
} {
|
||||||
const images: ImagePlaceholder[] = [];
|
const images: ImagePlaceholder[] = [];
|
||||||
let imageCounter = 0;
|
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}`;
|
const placeholder = `${placeholderPrefix}${++imageCounter}`;
|
||||||
|
|
||||||
|
rewritten += markdown.slice(lastIndex, matchIndex);
|
||||||
images.push({
|
images.push({
|
||||||
alt,
|
alt,
|
||||||
originalPath: src,
|
originalPath,
|
||||||
placeholder,
|
placeholder,
|
||||||
});
|
});
|
||||||
return placeholder;
|
rewritten += placeholder;
|
||||||
});
|
lastIndex = matchIndex + fullMatch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
rewritten += markdown.slice(lastIndex);
|
||||||
|
|
||||||
return { images, markdown: rewritten };
|
return { images, markdown: rewritten };
|
||||||
}
|
}
|
||||||
@@ -103,14 +126,7 @@ export async function resolveImagePath(
|
|||||||
return localPath;
|
return localPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = safeDecodeImagePath(imagePath);
|
return resolveLocalImagePath(imagePath, baseDir, logLabel);
|
||||||
const resolved = resolveAgainstBaseDir(decoded, baseDir);
|
|
||||||
const resolvedWithFallback = resolveLocalWithFallback(resolved, logLabel);
|
|
||||||
if (decoded === imagePath || fs.existsSync(resolvedWithFallback)) {
|
|
||||||
return resolvedWithFallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveContentImages(
|
export async function resolveContentImages(
|
||||||
@@ -131,11 +147,79 @@ export async function resolveContentImages(
|
|||||||
return resolved;
|
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)) {
|
if (fs.existsSync(resolved)) {
|
||||||
return 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 ext = path.extname(resolved);
|
||||||
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
||||||
const alternatives = [
|
const alternatives = [
|
||||||
@@ -150,13 +234,16 @@ function resolveLocalWithFallback(resolved: string, logLabel: string): string {
|
|||||||
|
|
||||||
for (const alternative of alternatives) {
|
for (const alternative of alternatives) {
|
||||||
if (!fs.existsSync(alternative)) continue;
|
if (!fs.existsSync(alternative)) continue;
|
||||||
console.error(
|
|
||||||
`[${logLabel}] Image fallback: ${path.basename(resolved)} -> ${path.basename(alternative)}`,
|
|
||||||
);
|
|
||||||
return 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 {
|
function safeDecodeImagePath(imagePath: string): string {
|
||||||
@@ -170,3 +257,10 @@ function safeDecodeImagePath(imagePath: string): string {
|
|||||||
function resolveAgainstBaseDir(imagePath: string, baseDir: string): string {
|
function resolveAgainstBaseDir(imagePath: string, baseDir: string): string {
|
||||||
return path.isAbsolute(imagePath) ? imagePath : path.resolve(baseDir, imagePath);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/,
|
/<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"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface ImageInfo {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
originalPath: string;
|
originalPath: string;
|
||||||
|
alt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MermaidImageInfo {
|
interface MermaidImageInfo {
|
||||||
@@ -58,6 +59,14 @@ type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
|
|||||||
mermaid?: MermaidCliOptions;
|
mermaid?: MermaidCliOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function escapeHtmlAttribute(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
export async function convertMarkdown(
|
export async function convertMarkdown(
|
||||||
markdownPath: string,
|
markdownPath: string,
|
||||||
options?: ConvertMarkdownOptions,
|
options?: ConvertMarkdownOptions,
|
||||||
@@ -160,7 +169,12 @@ export async function convertMarkdown(
|
|||||||
|
|
||||||
let finalContent = fs.readFileSync(finalHtmlPath, "utf-8");
|
let finalContent = fs.readFileSync(finalHtmlPath, "utf-8");
|
||||||
for (const image of contentImages) {
|
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);
|
finalContent = finalContent.replace(image.placeholder, imgTag);
|
||||||
}
|
}
|
||||||
fs.writeFileSync(finalHtmlPath, finalContent, "utf-8");
|
fs.writeFileSync(finalHtmlPath, finalContent, "utf-8");
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface ImageInfo {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
originalPath: string;
|
originalPath: string;
|
||||||
|
alt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedResult {
|
interface ParsedResult {
|
||||||
|
|||||||
@@ -1,37 +1,100 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from 'node:assert/strict';
|
||||||
import fs from "node:fs/promises";
|
import fs from 'node:fs/promises';
|
||||||
import os from "node:os";
|
import os from 'node:os';
|
||||||
import path from "node:path";
|
import path from 'node:path';
|
||||||
import test from "node:test";
|
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> {
|
async function makeTempDir(prefix: string): Promise<string> {
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parseMarkdown resolves encoded spaces and literal percent image paths", async (t) => {
|
test('parseMarkdown preserves mixed markdown and Obsidian wikilink image order', async (t) => {
|
||||||
const root = await makeTempDir("baoyu-post-to-x-images-");
|
const root = await makeTempDir('x-md-to-html-wikilinks-');
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
const articlePath = path.join(root, "article.md");
|
const articleDir = path.join(root, 'article');
|
||||||
const tempDir = path.join(root, "tmp");
|
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.mkdir(tempDir, { recursive: true });
|
||||||
await fs.writeFile(path.join(root, "Pasted image.png"), "png");
|
await fs.writeFile(path.join(articleDir, 'a.png'), 'a');
|
||||||
await fs.writeFile(path.join(root, "100%.png"), "png");
|
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]]',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'![[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(
|
await fs.writeFile(
|
||||||
articlePath,
|
articlePath,
|
||||||
[
|
[
|
||||||
"# Title",
|
'# Title',
|
||||||
"",
|
'',
|
||||||
"",
|
'',
|
||||||
"",
|
'',
|
||||||
"",
|
'',
|
||||||
].join("\n"),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await parseMarkdown(articlePath, { tempDir });
|
const result = await parseMarkdown(articlePath, { tempDir });
|
||||||
|
|
||||||
assert.equal(result.contentImages[0]?.localPath, path.join(root, "Pasted image.png"));
|
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[1]?.localPath, path.join(root, '100%.png'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import https from 'node:https';
|
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
import frontMatter from 'front-matter';
|
import frontMatter from 'front-matter';
|
||||||
@@ -15,7 +13,11 @@ import remarkCjkFriendly from 'remark-cjk-friendly';
|
|||||||
import remarkParse from 'remark-parse';
|
import remarkParse from 'remark-parse';
|
||||||
import remarkStringify from 'remark-stringify';
|
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';
|
import { closeRenderer, renderMermaidToPng } from 'baoyu-chrome-cdp/mermaid';
|
||||||
|
|
||||||
interface ImageInfo {
|
interface ImageInfo {
|
||||||
@@ -23,6 +25,7 @@ interface ImageInfo {
|
|||||||
localPath: string;
|
localPath: string;
|
||||||
originalPath: string;
|
originalPath: string;
|
||||||
blockIndex: number;
|
blockIndex: number;
|
||||||
|
alt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedMarkdown {
|
interface ParsedMarkdown {
|
||||||
@@ -109,103 +112,6 @@ function extractTitleFromMarkdown(markdown: string): string {
|
|||||||
return '';
|
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 {
|
function escapeHtml(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -215,6 +121,10 @@ function escapeHtml(text: string): string {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
function highlightCode(code: string, lang: string): string {
|
function highlightCode(code: string, lang: string): string {
|
||||||
try {
|
try {
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
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 preprocessedMarkdown = preprocessCjkMarkdown(markdown);
|
||||||
const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });
|
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 {
|
image({ href, text }: Tokens.Image): string {
|
||||||
if (!href) return '';
|
if (!href) return '';
|
||||||
return imageCallback(href, text ?? '');
|
return escapeHtml(text ?? '');
|
||||||
},
|
},
|
||||||
|
|
||||||
link({ href, title, tokens, text }: Tokens.Link): string {
|
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 }> = [];
|
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
|
||||||
let imageCounter = 0;
|
mermaidProcessedBody,
|
||||||
|
'XIMGPH_',
|
||||||
const { html, totalBlocks } = convertMarkdownToHtml(mermaidProcessedBody, (src, alt) => {
|
);
|
||||||
const placeholder = `XIMGPH_${++imageCounter}`;
|
const { html, totalBlocks } = convertMarkdownToHtml(rewrittenBody);
|
||||||
images.push({ src, alt, blockIndex: -1 });
|
|
||||||
return placeholder;
|
|
||||||
});
|
|
||||||
|
|
||||||
const htmlLines = html.split('\n');
|
const htmlLines = html.split('\n');
|
||||||
|
const imageBlockIndexes = new Map<string, number>();
|
||||||
for (let i = 0; i < images.length; i++) {
|
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++) {
|
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]!)) {
|
if (regex.test(htmlLines[lineIndex]!)) {
|
||||||
images[i]!.blockIndex = lineIndex;
|
imageBlockIndexes.set(placeholder, lineIndex);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,17 +292,18 @@ export async function parseMarkdown(
|
|||||||
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
const img = images[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) {
|
if (i === 0 && !coverImagePath) {
|
||||||
firstImageAsCover = localPath;
|
firstImageAsCover = localPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
contentImages.push({
|
contentImages.push({
|
||||||
placeholder: `XIMGPH_${i + 1}`,
|
placeholder: img.placeholder,
|
||||||
localPath,
|
localPath,
|
||||||
originalPath: img.src,
|
originalPath: img.originalPath,
|
||||||
blockIndex: img.blockIndex,
|
alt: img.alt,
|
||||||
|
blockIndex: imageBlockIndexes.get(img.placeholder) ?? -1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +311,7 @@ export async function parseMarkdown(
|
|||||||
|
|
||||||
let resolvedCoverImage: string | null = null;
|
let resolvedCoverImage: string | null = null;
|
||||||
if (coverImagePath) {
|
if (coverImagePath) {
|
||||||
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
|
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir, 'md-to-html');
|
||||||
} else if (firstImageAsCover) {
|
} else if (firstImageAsCover) {
|
||||||
resolvedCoverImage = firstImageAsCover;
|
resolvedCoverImage = firstImageAsCover;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user