diff --git a/index.html b/index.html
index 1a78fbc..76df38c 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,7 @@
-
+
Open Markdown
diff --git a/src/main/index.ts b/src/main/index.ts
index e859db1..b012860 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -5,6 +5,10 @@ import { app, BrowserWindow } from 'electron';
import path from 'node:path';
import { registerAllHandlers } from './ipc/handlers';
+import {
+ registerAssetProtocolScheme,
+ registerAssetProtocolHandler,
+} from './services/AssetProtocolService';
import { setupApplicationMenu } from './menu/applicationMenu';
import { getPreferencesService } from './services/PreferencesService';
import { getRecentFilesService } from './services/RecentFilesService';
@@ -21,6 +25,10 @@ interface PendingWindow {
const pendingWindows = new Map();
let preReadyFilePath: string | null = null;
+// Custom protocol used to serve local image assets must be registered as a
+// privileged scheme before the app `ready` event fires.
+registerAssetProtocolScheme();
+
/**
* Check command-line arguments for markdown file
*/
@@ -108,6 +116,9 @@ async function initialize(): Promise {
await getPreferencesService().initialize();
await getRecentFilesService().initialize();
+ // Serve local image assets referenced by markdown documents
+ registerAssetProtocolHandler();
+
// Register IPC handlers before creating windows
registerAllHandlers();
diff --git a/src/main/services/AssetProtocolService.ts b/src/main/services/AssetProtocolService.ts
new file mode 100644
index 0000000..b1bd2f2
--- /dev/null
+++ b/src/main/services/AssetProtocolService.ts
@@ -0,0 +1,86 @@
+/**
+ * AssetProtocolService - Serves local image assets referenced by markdown
+ * documents through a dedicated custom protocol.
+ *
+ * Markdown files commonly reference images with relative or absolute
+ * filesystem paths (e.g. `docs/images/logo.svg`). Those cannot be loaded
+ * directly from the renderer: the document's origin is the app bundle (or the
+ * dev server), and the Content-Security-Policy forbids `file:` images. The
+ * renderer rewrites such references to `om-asset:` URLs which this handler
+ * resolves back to files on disk.
+ */
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { protocol } from 'electron';
+
+import { ASSET_PROTOCOL_SCHEME } from '@shared/constants';
+
+/**
+ * Image extensions the protocol is allowed to serve. Restricting to images
+ * keeps the handler from turning into an arbitrary local-file reader.
+ */
+const IMAGE_CONTENT_TYPES: Record = {
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.bmp': 'image/bmp',
+ '.ico': 'image/x-icon',
+ '.avif': 'image/avif',
+ '.apng': 'image/apng',
+};
+
+/**
+ * Privileged scheme registration. Must run before the app `ready` event.
+ */
+export function registerAssetProtocolScheme(): void {
+ protocol.registerSchemesAsPrivileged([
+ {
+ scheme: ASSET_PROTOCOL_SCHEME,
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: true,
+ stream: true,
+ },
+ },
+ ]);
+}
+
+/**
+ * Register the protocol handler. Must run after the app `ready` event.
+ */
+export function registerAssetProtocolHandler(): void {
+ protocol.handle(ASSET_PROTOCOL_SCHEME, async (request) => {
+ let filePath: string;
+ try {
+ // The renderer encodes the file path as the URL path under a fixed
+ // host; reattaching it to a `file:` URL yields the original path.
+ const { pathname } = new URL(request.url);
+ filePath = fileURLToPath(`file://${pathname}`);
+ } catch {
+ return new Response('Invalid asset URL', { status: 400 });
+ }
+
+ const contentType = IMAGE_CONTENT_TYPES[path.extname(filePath).toLowerCase()];
+ if (!contentType) {
+ return new Response('Unsupported asset type', { status: 415 });
+ }
+
+ try {
+ const data = await readFile(filePath);
+ return new Response(data, {
+ headers: {
+ 'content-type': contentType,
+ 'cache-control': 'no-cache',
+ },
+ });
+ } catch {
+ return new Response('Asset not found', { status: 404 });
+ }
+ });
+}
diff --git a/src/preload/assetResolver.ts b/src/preload/assetResolver.ts
new file mode 100644
index 0000000..0f39000
--- /dev/null
+++ b/src/preload/assetResolver.ts
@@ -0,0 +1,65 @@
+/**
+ * Resolves image references found in markdown documents to `om-asset:` URLs
+ * that the main process can serve from disk.
+ *
+ * Runs in the preload context, where Node's `path` module is available to
+ * resolve relative references against the document's location.
+ */
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+import { ASSET_PROTOCOL_SCHEME, ASSET_PROTOCOL_HOST } from '@shared/constants';
+
+/**
+ * Resolve an image reference against the markdown file that contains it.
+ *
+ * @param baseFilePath - Absolute path to the markdown file being rendered.
+ * @param ref - The raw `src`/`srcset` reference from the document.
+ * @returns An `om-asset:` URL for local files, or `null` when the reference
+ * should be left untouched (already a URL, protocol-relative, or empty).
+ */
+export function resolveAssetUrl(baseFilePath: string, ref: string): string | null {
+ if (!baseFilePath || !ref) {
+ return null;
+ }
+
+ const trimmed = ref.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ // Already an absolute URL (http:, https:, data:, blob:, file:, mailto:, ...).
+ // A single-character "scheme" is actually a Windows drive letter, so the
+ // scheme is required to be at least two characters long.
+ if (/^[a-z][a-z0-9+.-]+:/i.test(trimmed) || trimmed.startsWith('//')) {
+ return null;
+ }
+
+ // Pure in-document anchors are not assets.
+ if (trimmed.startsWith('#')) {
+ return null;
+ }
+
+ // Filesystem paths carry neither query strings nor fragments.
+ const cleaned = trimmed.replace(/[?#].*$/, '');
+ if (!cleaned) {
+ return null;
+ }
+
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(cleaned);
+ } catch {
+ decoded = cleaned;
+ }
+
+ const resolved = path.isAbsolute(decoded)
+ ? decoded
+ : path.resolve(path.dirname(baseFilePath), decoded);
+
+ // Embed the (already percent-encoded) file path under a fixed host. A bare
+ // `om-asset:///path` URL is mangled by Chromium's standard-scheme parser,
+ // which treats the first path segment as the host; a fixed host keeps the
+ // full path intact.
+ return `${ASSET_PROTOCOL_SCHEME}://${ASSET_PROTOCOL_HOST}${pathToFileURL(resolved).pathname}`;
+}
diff --git a/src/preload/preload.ts b/src/preload/preload.ts
index c9905bf..f4a575d 100644
--- a/src/preload/preload.ts
+++ b/src/preload/preload.ts
@@ -5,6 +5,7 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron';
import { IPC_CHANNELS } from '@shared/types/api';
+import { resolveAssetUrl } from './assetResolver';
import type {
ElectronAPI,
@@ -334,6 +335,12 @@ const electronAPI: ElectronAPI = {
},
},
+ assets: {
+ resolve: (baseFilePath: string, ref: string): string | null => {
+ return resolveAssetUrl(baseFilePath, ref);
+ },
+ },
+
};
// Expose the API to the renderer process
diff --git a/src/renderer/components/MarkdownViewer.ts b/src/renderer/components/MarkdownViewer.ts
index c4daf03..5ed32d6 100644
--- a/src/renderer/components/MarkdownViewer.ts
+++ b/src/renderer/components/MarkdownViewer.ts
@@ -15,6 +15,8 @@ import { Toast } from './Toast';
import { EditModeController, createEditModeController } from './EditModeController';
import type { EditModeCallbacks } from './EditModeController';
+import { rewriteAssetPaths } from '../utils/assetPaths';
+
import type {
MarkdownPlugin,
ContextMenuData,
@@ -108,6 +110,21 @@ export class MarkdownViewer {
}
}
+ /**
+ * Resolve relative and absolute local image paths in the rendered content
+ * to the app's asset protocol so they load correctly. No-op until a file
+ * path is known (e.g. unsaved content), since paths cannot be resolved
+ * without a document location.
+ */
+ private rewriteAssetPaths(): void {
+ const filePath = this.state.filePath;
+ if (!filePath) return;
+
+ rewriteAssetPaths(this.container, (ref) =>
+ window.electronAPI.assets.resolve(filePath, ref)
+ );
+ }
+
/**
* Render markdown content
*/
@@ -127,6 +144,9 @@ export class MarkdownViewer {
const html = this.pluginManager.render(markdown);
this.container.innerHTML = html;
+ // Resolve relative/local image paths against the document's location
+ this.rewriteAssetPaths();
+
// Run post-render hooks (for Mermaid diagrams, etc.)
await this.pluginManager.postRender(this.container);
} catch (error) {
diff --git a/src/renderer/utils/assetPaths.ts b/src/renderer/utils/assetPaths.ts
new file mode 100644
index 0000000..d047968
--- /dev/null
+++ b/src/renderer/utils/assetPaths.ts
@@ -0,0 +1,75 @@
+/**
+ * Rewrites relative/absolute image references in rendered markdown so they
+ * point at the app's local asset protocol instead of failing to load.
+ *
+ * markdown-it emits `
`/`` elements with the `src`/`srcset` values
+ * exactly as written in the document. References like `docs/images/logo.svg`
+ * would otherwise resolve against the app origin (or dev server) and never
+ * reach the file the author intended.
+ */
+
+/**
+ * Resolves a single document reference to a loadable URL, or returns `null`
+ * to leave the reference untouched (e.g. it is already an absolute URL).
+ */
+export type AssetResolver = (ref: string) => string | null;
+
+/**
+ * Rewrite a `srcset` attribute value, resolving each candidate's URL while
+ * preserving its descriptor (e.g. `2x`, `640w`).
+ */
+export function rewriteSrcset(srcset: string, resolve: AssetResolver): string {
+ return srcset
+ .split(',')
+ .map((candidate) => {
+ const trimmed = candidate.trim();
+ if (!trimmed) {
+ return '';
+ }
+ const parts = trimmed.split(/\s+/);
+ const url = parts[0];
+ if (url) {
+ const resolved = resolve(url);
+ if (resolved) {
+ parts[0] = resolved;
+ }
+ }
+ return parts.join(' ');
+ })
+ .filter((candidate) => candidate.length > 0)
+ .join(', ');
+}
+
+/**
+ * Rewrite every image reference within a rendered markdown container.
+ *
+ * Handles markdown image syntax and raw HTML alike, including ``
+ * elements which use `` plus an `
` fallback.
+ */
+export function rewriteAssetPaths(
+ container: HTMLElement,
+ resolve: AssetResolver
+): void {
+ const srcElements = container.querySelectorAll(
+ 'img[src], source[src]'
+ );
+ srcElements.forEach((element) => {
+ const src = element.getAttribute('src');
+ if (src) {
+ const resolved = resolve(src);
+ if (resolved) {
+ element.setAttribute('src', resolved);
+ }
+ }
+ });
+
+ const srcsetElements = container.querySelectorAll(
+ 'img[srcset], source[srcset]'
+ );
+ srcsetElements.forEach((element) => {
+ const srcset = element.getAttribute('srcset');
+ if (srcset) {
+ element.setAttribute('srcset', rewriteSrcset(srcset, resolve));
+ }
+ });
+}
diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts
index 92d4052..7c8d7e4 100644
--- a/src/shared/constants/index.ts
+++ b/src/shared/constants/index.ts
@@ -46,6 +46,19 @@ export const BUILTIN_PLUGINS = {
GITHUB_FLAVORED: 'github-flavored',
} as const;
+/**
+ * Custom protocol scheme used to serve local image assets referenced by
+ * relative or absolute filesystem paths in markdown documents.
+ */
+export const ASSET_PROTOCOL_SCHEME = 'om-asset';
+
+/**
+ * Fixed host used in `om-asset:` URLs. The file path is carried in the URL
+ * path; a fixed host prevents Chromium's standard-scheme parser from
+ * consuming the first path segment as the host.
+ */
+export const ASSET_PROTOCOL_HOST = 'local';
+
/**
* Theme IDs
*/
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index 1f8e777..a15dbe4 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -258,6 +258,18 @@ export interface ShellAPI {
openExternal: (url: string) => Promise;
}
+/**
+ * Local asset resolution API exposed to renderer
+ */
+export interface AssetsAPI {
+ /**
+ * Resolve an image reference from a markdown document to an `om-asset:` URL
+ * the app can load. Returns `null` when the reference is already a URL or
+ * otherwise should be left untouched.
+ */
+ resolve: (baseFilePath: string, ref: string) => string | null;
+}
+
/**
* Complete Electron API exposed via contextBridge
*/
@@ -273,6 +285,7 @@ export interface ElectronAPI {
recentFiles: RecentFilesAPI;
menu: MenuAPI;
shell: ShellAPI;
+ assets: AssetsAPI;
}
/**
diff --git a/tests/unit/main/services/AssetProtocolService.test.ts b/tests/unit/main/services/AssetProtocolService.test.ts
new file mode 100644
index 0000000..1dbce17
--- /dev/null
+++ b/tests/unit/main/services/AssetProtocolService.test.ts
@@ -0,0 +1,107 @@
+import { readFile } from 'node:fs/promises';
+import { pathToFileURL } from 'node:url';
+
+import { protocol } from 'electron';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+import {
+ registerAssetProtocolScheme,
+ registerAssetProtocolHandler,
+} from '../../../../src/main/services/AssetProtocolService';
+
+vi.mock('electron', () => ({
+ protocol: {
+ registerSchemesAsPrivileged: vi.fn(),
+ handle: vi.fn(),
+ },
+}));
+
+vi.mock('node:fs/promises', () => ({
+ readFile: vi.fn(),
+}));
+
+type ProtocolHandler = (request: Request) => Promise;
+
+function getRegisteredHandler(): ProtocolHandler {
+ registerAssetProtocolHandler();
+ const calls = vi.mocked(protocol.handle).mock.calls;
+ const lastCall = calls[calls.length - 1];
+ expect(lastCall?.[0]).toBe('om-asset');
+ return lastCall?.[1] as ProtocolHandler;
+}
+
+function assetUrl(absolutePath: string): string {
+ return `om-asset://local${pathToFileURL(absolutePath).pathname}`;
+}
+
+describe('AssetProtocolService', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('registerAssetProtocolScheme', () => {
+ it('registers the om-asset scheme as privileged', () => {
+ registerAssetProtocolScheme();
+ expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([
+ {
+ scheme: 'om-asset',
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: true,
+ stream: true,
+ },
+ },
+ ]);
+ });
+ });
+
+ describe('protocol handler', () => {
+ it('serves an existing image with the correct content type', async () => {
+ vi.mocked(readFile).mockResolvedValue(Buffer.from('PNGDATA'));
+ const handler = getRegisteredHandler();
+
+ const response = await handler(
+ new Request(assetUrl('/assets/logo.png'))
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toBe('image/png');
+ expect(await response.text()).toBe('PNGDATA');
+ });
+
+ it('serves SVG files as image/svg+xml', async () => {
+ vi.mocked(readFile).mockResolvedValue(Buffer.from(''));
+ const handler = getRegisteredHandler();
+
+ const response = await handler(
+ new Request(assetUrl('/assets/logo.svg'))
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toBe('image/svg+xml');
+ });
+
+ it('rejects non-image extensions with 415', async () => {
+ const handler = getRegisteredHandler();
+
+ const response = await handler(
+ new Request(assetUrl('/assets/notes.txt'))
+ );
+
+ expect(response.status).toBe(415);
+ expect(readFile).not.toHaveBeenCalled();
+ });
+
+ it('returns 404 when the file cannot be read', async () => {
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
+ const handler = getRegisteredHandler();
+
+ const response = await handler(
+ new Request(assetUrl('/assets/missing.png'))
+ );
+
+ expect(response.status).toBe(404);
+ });
+ });
+});
diff --git a/tests/unit/preload/assetResolver.test.ts b/tests/unit/preload/assetResolver.test.ts
new file mode 100644
index 0000000..a628930
--- /dev/null
+++ b/tests/unit/preload/assetResolver.test.ts
@@ -0,0 +1,71 @@
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+import { describe, it, expect } from 'vitest';
+
+import { resolveAssetUrl } from '../../../src/preload/assetResolver';
+
+const BASE = path.resolve('/docs/project/README.md');
+
+function expectedUrl(absolutePath: string): string {
+ return `om-asset://local${pathToFileURL(absolutePath).pathname}`;
+}
+
+describe('resolveAssetUrl', () => {
+ it('resolves a relative path against the document directory', () => {
+ const result = resolveAssetUrl(BASE, 'docs/images/logo.svg');
+ expect(result).toBe(
+ expectedUrl(path.resolve('/docs/project/docs/images/logo.svg'))
+ );
+ });
+
+ it('resolves parent-relative paths', () => {
+ const result = resolveAssetUrl(BASE, '../shared/pic.png');
+ expect(result).toBe(
+ expectedUrl(path.resolve('/docs/shared/pic.png'))
+ );
+ });
+
+ it('resolves absolute filesystem paths', () => {
+ const absolute = path.resolve('/var/assets/diagram.png');
+ expect(resolveAssetUrl(BASE, absolute)).toBe(expectedUrl(absolute));
+ });
+
+ it('leaves http(s) URLs untouched', () => {
+ expect(resolveAssetUrl(BASE, 'https://example.com/a.png')).toBeNull();
+ expect(resolveAssetUrl(BASE, 'http://example.com/a.png')).toBeNull();
+ });
+
+ it('leaves data and blob URLs untouched', () => {
+ expect(resolveAssetUrl(BASE, 'data:image/png;base64,AAAA')).toBeNull();
+ expect(resolveAssetUrl(BASE, 'blob:abc-123')).toBeNull();
+ });
+
+ it('leaves protocol-relative URLs untouched', () => {
+ expect(resolveAssetUrl(BASE, '//cdn.example.com/a.png')).toBeNull();
+ });
+
+ it('leaves in-document anchors untouched', () => {
+ expect(resolveAssetUrl(BASE, '#section')).toBeNull();
+ });
+
+ it('returns null for empty references', () => {
+ expect(resolveAssetUrl(BASE, '')).toBeNull();
+ expect(resolveAssetUrl(BASE, ' ')).toBeNull();
+ expect(resolveAssetUrl('', 'docs/logo.svg')).toBeNull();
+ });
+
+ it('strips query strings and fragments from references', () => {
+ const result = resolveAssetUrl(BASE, 'images/logo.svg?v=2#frag');
+ expect(result).toBe(
+ expectedUrl(path.resolve('/docs/project/images/logo.svg'))
+ );
+ });
+
+ it('decodes percent-encoded references', () => {
+ const result = resolveAssetUrl(BASE, 'images/my%20logo.svg');
+ expect(result).toBe(
+ expectedUrl(path.resolve('/docs/project/images/my logo.svg'))
+ );
+ });
+});
diff --git a/tests/unit/renderer/utils/assetPaths.test.ts b/tests/unit/renderer/utils/assetPaths.test.ts
new file mode 100644
index 0000000..dce8b80
--- /dev/null
+++ b/tests/unit/renderer/utils/assetPaths.test.ts
@@ -0,0 +1,97 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect } from 'vitest';
+
+import {
+ rewriteSrcset,
+ rewriteAssetPaths,
+ type AssetResolver,
+} from '../../../../src/renderer/utils/assetPaths';
+
+// Resolver that mimics the preload behaviour: relative paths become
+// `om-asset:` URLs, absolute URLs are left untouched.
+const resolver: AssetResolver = (ref) => {
+ if (/^[a-z][a-z0-9+.-]+:/i.test(ref) || ref.startsWith('//')) {
+ return null;
+ }
+ return `om-asset:///docs/${ref}`;
+};
+
+describe('rewriteSrcset', () => {
+ it('rewrites a single-candidate srcset', () => {
+ expect(rewriteSrcset('images/logo.svg', resolver)).toBe(
+ 'om-asset:///docs/images/logo.svg'
+ );
+ });
+
+ it('preserves descriptors for each candidate', () => {
+ const result = rewriteSrcset('a.png 1x, b.png 2x', resolver);
+ expect(result).toBe('om-asset:///docs/a.png 1x, om-asset:///docs/b.png 2x');
+ });
+
+ it('leaves absolute URLs untouched', () => {
+ expect(rewriteSrcset('https://example.com/a.png 2x', resolver)).toBe(
+ 'https://example.com/a.png 2x'
+ );
+ });
+
+ it('ignores empty candidates', () => {
+ expect(rewriteSrcset('a.png 1x, , b.png 2x', resolver)).toBe(
+ 'om-asset:///docs/a.png 1x, om-asset:///docs/b.png 2x'
+ );
+ });
+});
+
+describe('rewriteAssetPaths', () => {
+ function render(html: string): HTMLElement {
+ const container = document.createElement('div');
+ container.innerHTML = html;
+ return container;
+ }
+
+ it('rewrites a relative
src', () => {
+ const container = render('
');
+ rewriteAssetPaths(container, resolver);
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(
+ 'om-asset:///docs/images/logo.svg'
+ );
+ });
+
+ it('rewrites with and
fallback', () => {
+ const container = render(`
+
+
+
+
+
+ `);
+ rewriteAssetPaths(container, resolver);
+
+ const sources = container.querySelectorAll('source');
+ expect(sources[0]?.getAttribute('srcset')).toBe(
+ 'om-asset:///docs/images/light.svg'
+ );
+ expect(sources[1]?.getAttribute('srcset')).toBe(
+ 'om-asset:///docs/images/dark.svg'
+ );
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(
+ 'om-asset:///docs/images/light.svg'
+ );
+ });
+
+ it('leaves remote images untouched', () => {
+ const container = render('
');
+ rewriteAssetPaths(container, resolver);
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(
+ 'https://example.com/a.png'
+ );
+ });
+
+ it('leaves data URIs untouched', () => {
+ const dataUri = 'data:image/png;base64,AAAA';
+ const container = render(`
`);
+ rewriteAssetPaths(container, resolver);
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(dataUri);
+ });
+});