diff --git a/src/core/markdown.ts b/src/core/markdown.ts index 156d652..e5bce5e 100644 --- a/src/core/markdown.ts +++ b/src/core/markdown.ts @@ -52,11 +52,18 @@ export async function renderMarkdownToHtml( markdown: string, ): Promise { const renderedMarkdown = autolinkBareUrls(stripWikilinks(markdown.trim())); + const defaultProtocols = defaultSchema.protocols as + | Record | null | undefined> + | undefined; + const defaultSrcProtocolsValue = defaultProtocols?.["src"]; + const defaultSrcProtocols = Array.isArray(defaultSrcProtocolsValue) + ? defaultSrcProtocolsValue + : []; const sanitizeSchema = { ...defaultSchema, protocols: { ...defaultSchema.protocols, - src: [...(defaultSchema.protocols?.["src"] ?? []), "data"], + src: [...defaultSrcProtocols, "data"], }, }; const rawHtml = String( diff --git a/src/core/publish-markdown.ts b/src/core/publish-markdown.ts index 6f06b19..a1dbdb7 100644 --- a/src/core/publish-markdown.ts +++ b/src/core/publish-markdown.ts @@ -14,6 +14,8 @@ const LOCAL_IMAGE_EXTENSIONS = new Set([ ".svg", ".avif", ]); +const EXCALIDRAW_MARKDOWN_SUFFIX = ".excalidraw.md"; +const EXCALIDRAW_EXPORT_EXTENSIONS = [".svg", ".png", ".webp", ".jpg", ".jpeg"]; const OBSIDIAN_IMAGE_EMBED_RE = /!\[\[([^\]\n]+)\]\]/g; const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\n]+)\)/g; @@ -161,7 +163,11 @@ async function resolveLocalImageAsset( baseDir: string, rawTarget: string, ): Promise<{ alt: string; dataUrl: string } | null> { - const resolvedPath = path.resolve(baseDir, rawTarget); + const excalidrawExportPath = await resolveExcalidrawExportPath( + baseDir, + rawTarget, + ); + const resolvedPath = excalidrawExportPath ?? path.resolve(baseDir, rawTarget); const extension = path.extname(resolvedPath).toLowerCase(); if (!LOCAL_IMAGE_EXTENSIONS.has(extension)) { @@ -171,7 +177,13 @@ async function resolveLocalImageAsset( try { const content = await readFile(resolvedPath); return { - alt: escapeMarkdownLabel(path.basename(rawTarget)), + alt: escapeMarkdownLabel( + path.basename( + excalidrawExportPath === null + ? rawTarget + : rawTarget.replace(/\.excalidraw\.md$/i, extension), + ), + ), dataUrl: buildDataUrl(content, extension), }; } catch { @@ -179,6 +191,34 @@ async function resolveLocalImageAsset( } } +async function resolveExcalidrawExportPath( + baseDir: string, + rawTarget: string, +): Promise { + if (!rawTarget.toLowerCase().endsWith(EXCALIDRAW_MARKDOWN_SUFFIX)) { + return null; + } + + const absoluteTarget = path.resolve(baseDir, rawTarget); + const exportBasePath = absoluteTarget.slice( + 0, + -EXCALIDRAW_MARKDOWN_SUFFIX.length, + ); + + for (const extension of EXCALIDRAW_EXPORT_EXTENSIONS) { + const candidatePath = `${exportBasePath}${extension}`; + + try { + await readFile(candidatePath); + return candidatePath; + } catch { + // Try the next export format. + } + } + + return null; +} + function buildDataUrl(content: Buffer, extension: string): string { return `data:${mimeTypeForExtension(extension)};base64,${content.toString("base64")}`; } diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index c6c534d..27210a6 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -166,6 +166,72 @@ Look: const rawMarkdown = await rawResponse.text(); expect(rawMarkdown).toContain("![[diagram.svg|320x200]]"); }); + + it("renders Excalidraw embeds from sibling exported images while preserving the raw note", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "publish-it-cli-excal-")); + const configDir = path.join(root, "config"); + const mappingPath = path.join(root, ".pub"); + const cwd = path.join(root, "workspace"); + const notePath = path.join(cwd, "note.md"); + const drawingPath = path.join(cwd, "landscape.excalidraw.md"); + const exportPath = path.join(cwd, "landscape.svg"); + + server = await startTestServer(root); + await mkdir(cwd, { recursive: true }); + await writeFile( + drawingPath, + `--- +excalidraw-plugin: parsed +--- +`, + "utf8", + ); + await writeFile( + exportPath, + '', + "utf8", + ); + await writeFile( + notePath, + `--- +title: Excalidraw Embed +--- + +Diagram: + +![[landscape.excalidraw.md]] +`, + "utf8", + ); + + await runCli(["claim", "restuta", "--api-base", server.origin], { + cwd, + env: { + PUB_CONFIG_DIR: configDir, + PUB_MAPPING_PATH: mappingPath, + }, + }); + + const publishResult = await runCli( + ["publish", notePath, "--api-base", server.origin], + { + cwd, + env: { + PUB_CONFIG_DIR: configDir, + PUB_MAPPING_PATH: mappingPath, + }, + }, + ); + const pageUrl = publishResult.stdout.trim(); + + const htmlResponse = await fetch(pageUrl); + const html = await htmlResponse.text(); + expect(html).toContain("data:image/svg+xml;base64,"); + + const rawResponse = await fetch(`${pageUrl}?raw=1`); + const rawMarkdown = await rawResponse.text(); + expect(rawMarkdown).toContain("![[landscape.excalidraw.md]]"); + }); }); async function runCli( diff --git a/tests/unit/publish-markdown.test.ts b/tests/unit/publish-markdown.test.ts index df2b6db..cbfedd8 100644 --- a/tests/unit/publish-markdown.test.ts +++ b/tests/unit/publish-markdown.test.ts @@ -50,4 +50,35 @@ describe("prepareMarkdownBodyForPublish", () => { expect(prepared).toContain("![Team photo](data:image/svg+xml;base64,"); }); + + it("resolves Excalidraw embeds to sibling exported images", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pubmd-excalidraw-")); + const notePath = path.join(root, "note.md"); + const drawingPath = path.join(root, "diagram.excalidraw.md"); + const exportPath = path.join(root, "diagram.svg"); + + await writeFile( + drawingPath, + `--- +excalidraw-plugin: parsed +--- +`, + "utf8", + ); + await writeFile( + exportPath, + '', + "utf8", + ); + + const prepared = await prepareMarkdownBodyForPublish( + "Here:\n\n![[diagram.excalidraw.md]]", + { + sourcePath: notePath, + }, + ); + + expect(prepared).toContain("data:image/svg+xml;base64,"); + expect(prepared).not.toContain("![[diagram.excalidraw.md]]"); + }); });