···77 * - peek://extensions/{path} - Shared extension infrastructure
88 * - peek://theme/{path} - Current theme files (vars.css, manifest.json)
99 * - peek://theme/{themeId}/{path} - Specific theme files
1010+ * - peek://local-file/{path} - Local filesystem files (images, documents, media, etc.)
1011 */
11121213import { protocol, net } from 'electron';
···4344 'lit-element': 'node_modules/lit-element/lit-element.js',
4445 'lit-element/': 'node_modules/lit-element/'
4546};
4747+4848+// Allowed file extensions for peek://local-file/ protocol.
4949+// Maps extension -> MIME type. Only these types are served; everything else is blocked.
5050+const ALLOWED_LOCAL_FILE_EXTS = new Map<string, string>([
5151+ // Images
5252+ ['.png', 'image/png'],
5353+ ['.jpg', 'image/jpeg'],
5454+ ['.jpeg', 'image/jpeg'],
5555+ ['.gif', 'image/gif'],
5656+ ['.svg', 'image/svg+xml'],
5757+ ['.webp', 'image/webp'],
5858+ ['.bmp', 'image/bmp'],
5959+ ['.ico', 'image/x-icon'],
6060+ ['.avif', 'image/avif'],
6161+ // Documents
6262+ ['.pdf', 'application/pdf'],
6363+ // Fonts
6464+ ['.woff', 'font/woff'],
6565+ ['.woff2', 'font/woff2'],
6666+ ['.ttf', 'font/ttf'],
6767+ ['.otf', 'font/otf'],
6868+ // Styles
6969+ ['.css', 'text/css'],
7070+ // Media
7171+ ['.mp4', 'video/mp4'],
7272+ ['.webm', 'video/webm'],
7373+ ['.mp3', 'audio/mpeg'],
7474+ ['.ogg', 'audio/ogg'],
7575+ ['.wav', 'audio/wav'],
7676+]);
7777+7878+// Explicitly blocked executable/script extensions (for clear error messages)
7979+const BLOCKED_EXTS = new Set([
8080+ '.js', '.mjs', '.cjs', '.ts', '.mts', '.cts',
8181+ '.html', '.htm', '.xhtml',
8282+ '.sh', '.bash', '.zsh', '.fish',
8383+ '.exe', '.bat', '.cmd', '.com', '.msi',
8484+ '.py', '.rb', '.pl', '.php',
8585+ '.jar', '.class',
8686+]);
46874788/**
4889 * Fetch a local file, logging a warning if it doesn't exist.
···379420 }
380421381422 return fetchFile(absolutePath, req.url);
423423+ }
424424+425425+ // Handle local file access: peek://local-file/{absolute-path}
426426+ // Used by editor preview and other features to serve local files.
427427+ // Only serves files with explicitly allowed MIME types for security.
428428+ if (host === 'local-file') {
429429+ // Decode percent-encoded path segments
430430+ const decodedPathname = decodeURIComponent(pathname);
431431+ const absolutePath = '/' + decodedPathname;
432432+433433+ // Security: reject paths containing null bytes
434434+ if (absolutePath.includes('\0')) {
435435+ console.error(`[protocol] local-file: blocked null byte in path`);
436436+ return new Response('Forbidden: invalid path', { status: 403 });
437437+ }
438438+439439+ // Security: only allow known-safe file extensions
440440+ const ext = path.extname(absolutePath).toLowerCase();
441441+ if (!ALLOWED_LOCAL_FILE_EXTS.has(ext)) {
442442+ const isBlocked = BLOCKED_EXTS.has(ext);
443443+ DEBUG && console.log(`[protocol] local-file: blocked ${isBlocked ? 'executable/script' : 'unknown'} extension: ${ext}`);
444444+ return new Response('Forbidden: file type not allowed', { status: 403 });
445445+ }
446446+447447+ // Security: resolve symlinks to get the real path
448448+ let realPath: string;
449449+ try {
450450+ realPath = fs.realpathSync(absolutePath);
451451+ } catch {
452452+ DEBUG && console.log(`[protocol] local-file: file not found: ${absolutePath}`);
453453+ return new Response('Not Found', { status: 404 });
454454+ }
455455+456456+ // Re-check extension after symlink resolution
457457+ const realExt = path.extname(realPath).toLowerCase();
458458+ if (!ALLOWED_LOCAL_FILE_EXTS.has(realExt)) {
459459+ DEBUG && console.log(`[protocol] local-file: blocked extension after symlink resolution: ${realExt}`);
460460+ return new Response('Forbidden: file type not allowed', { status: 403 });
461461+ }
462462+463463+ DEBUG && console.log(`[protocol] local-file: serving ${realPath}`);
464464+465465+ // Serve with proper MIME type from our allowlist
466466+ const mimeType = ALLOWED_LOCAL_FILE_EXTS.get(realExt) || 'application/octet-stream';
467467+ const fileURL = pathToFileURL(realPath).toString();
468468+ const response = await net.fetch(fileURL);
469469+ const body = await response.arrayBuffer();
470470+ return new Response(body, {
471471+ status: response.status,
472472+ headers: {
473473+ 'Content-Type': mimeType,
474474+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
475475+ 'Pragma': 'no-cache',
476476+ 'Expires': '0'
477477+ }
478478+ });
382479 }
383480384481 // Handle per-extension hosts: peek://{ext-id}/{path}
+13
features/editor/editor-layout.js
···11121112 }
11131113 }
1114111411151115+ /**
11161116+ * Set the base path for resolving relative image paths in the preview.
11171117+ * Should be the directory containing the file being edited.
11181118+ * @param {string|null} basePath - Directory path (e.g., '/Users/me/docs')
11191119+ */
11201120+ setBasePath(basePath) {
11211121+ if (this.previewSidebar) {
11221122+ this.previewSidebar.setBasePath(basePath);
11231123+ // Re-render preview with the new base path
11241124+ this.updateSidebars();
11251125+ }
11261126+ }
11271127+11151128 destroy() {
11161129 this.isDestroyed = true;
11171130 this.stopWatching();
+15
features/editor/home.js
···196196 currentFilePath = result.data.path;
197197 hasExplicitSource = true;
198198 debug && console.log('[editor] Loaded file from path:', currentFilePath);
199199+ // basePath will be set after editorLayout is created
199200 }
200201 } catch (err) {
201202 debug && console.log('[editor] Failed to load file:', err);
···246247 }
247248 if (currentFilePath) {
248249 updateWindowTitle();
250250+ // Set base path for resolving relative image paths in preview
251251+ editorLayout.setBasePath(extractDirname(currentFilePath));
249252 }
250253251254 // Set up Cmd+O (open file) and Cmd+S (save file) keyboard shortcuts
···469472};
470473471474/**
475475+ * Extract directory path from a full file path.
476476+ */
477477+const extractDirname = (filePath) => {
478478+ if (!filePath) return '';
479479+ const parts = filePath.split('/');
480480+ parts.pop();
481481+ return parts.join('/') || '/';
482482+};
483483+484484+/**
472485 * Update the window title and filename display to show the current filename.
473486 */
474487const updateWindowTitle = () => {
···513526 if (editorLayout) {
514527 editorLayout.setContent(content);
515528 editorLayout.setSaveStatusVisible(true);
529529+ // Set base path for resolving relative image paths in preview
530530+ editorLayout.setBasePath(extractDirname(filePath));
516531 }
517532518533 saveStatus = 'saved';
+147-9
features/editor/preview-sidebar.js
···1515}
16161717/**
1818+ * Resolve a potentially relative image src to an absolute path.
1919+ * Returns null if the src should be blocked (unsafe protocol).
2020+ * @param {string} src - The image src attribute value
2121+ * @param {string} [basePath] - Base directory for resolving relative paths
2222+ * @returns {string|null} - Resolved src or null if blocked
2323+ */
2424+function resolveImgSrc(src, basePath) {
2525+ // Allow https:// and data:image/ always
2626+ if (/^(https:\/\/|data:image\/)/i.test(src)) return src;
2727+2828+ // Allow file:// protocol for local files
2929+ if (/^file:\/\//i.test(src)) return src;
3030+3131+ // Block dangerous protocols explicitly
3232+ if (/^javascript:/i.test(src.replace(/\s/g, ''))) return null;
3333+3434+ // Block http:// (non-secure)
3535+ if (/^http:\/\//i.test(src)) return null;
3636+3737+ // Block any other explicit protocol (e.g., ftp://, blob:, vbscript:, etc.)
3838+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(src)) return null;
3939+4040+ // Relative path — resolve against basePath if available
4141+ if (basePath) {
4242+ // Normalize: remove trailing slash from basePath
4343+ const base = basePath.replace(/\/+$/, '');
4444+ let resolved;
4545+ if (src.startsWith('/')) {
4646+ // Absolute path on filesystem
4747+ resolved = src;
4848+ } else {
4949+ resolved = base + '/' + src;
5050+ }
5151+ // Normalize path segments (resolve . and ..)
5252+ const parts = resolved.split('/');
5353+ const normalized = [];
5454+ for (const part of parts) {
5555+ if (part === '.' || part === '') continue;
5656+ if (part === '..') {
5757+ normalized.pop();
5858+ } else {
5959+ normalized.push(part);
6060+ }
6161+ }
6262+ // Reconstruct as absolute path (starts with /)
6363+ const normalizedPath = '/' + normalized.join('/');
6464+ // Use peek://local-file/ instead of file:// so images load from the
6565+ // peek:// origin without cross-origin restrictions (webSecurity: true).
6666+ return 'peek://local-file' + normalizedPath;
6767+ }
6868+6969+ // No basePath — can't resolve relative paths, block them
7070+ return null;
7171+}
7272+7373+/**
7474+ * Restore safe HTML img tags that were escaped by escapeHtml().
7575+ * Allows src with https://, data:image/, and file:// protocols.
7676+ * Resolves relative paths against basePath when provided.
7777+ * Permits alt, width, height, title, loading, and style attributes.
7878+ * @param {string} html - HTML-escaped string
7979+ * @param {string} [basePath] - Base directory for resolving relative image paths
8080+ * @returns {string}
8181+ */
8282+function restoreImgTags(html, basePath) {
8383+ // Match escaped <img ... > or <img ... />
8484+ return html.replace(
8585+ /<img\s+((?:(?!>).)+?)\/?\s*>/gi,
8686+ (match, attrs) => {
8787+ // Unescape the attributes portion so we can parse them
8888+ const unescaped = attrs
8989+ .replace(/&/g, '&')
9090+ .replace(/</g, '<')
9191+ .replace(/>/g, '>');
9292+ const allowed = ['src', 'alt', 'width', 'height', 'title', 'loading', 'style'];
9393+ const result = [];
9494+ // Match attribute="value", attribute='value', or attribute=value
9595+ const attrRegex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
9696+ let m;
9797+ while ((m = attrRegex.exec(unescaped)) !== null) {
9898+ const name = m[1].toLowerCase();
9999+ const value = m[2] ?? m[3] ?? m[4];
100100+ if (!allowed.includes(name)) continue;
101101+ if (name === 'src') {
102102+ const resolved = resolveImgSrc(value, basePath);
103103+ if (!resolved) continue;
104104+ const safeValue = resolved.replace(/"/g, '"');
105105+ result.push(`${name}="${safeValue}"`);
106106+ continue;
107107+ }
108108+ // Sanitize value — escape quotes to prevent attribute injection
109109+ const safeValue = value.replace(/"/g, '"');
110110+ result.push(`${name}="${safeValue}"`);
111111+ }
112112+ // Must have a src to render
113113+ if (!result.find(a => a.startsWith('src='))) return match;
114114+ // Add max-width for responsive images
115115+ if (!result.find(a => a.startsWith('style='))) {
116116+ result.push('style="max-width:100%"');
117117+ }
118118+ return `<img ${result.join(' ')}>`;
119119+ }
120120+ );
121121+}
122122+123123+/**
124124+ * Resolve a markdown image src, applying the same protocol/path rules as HTML img tags.
125125+ * @param {string} src - The image src from markdown 
126126+ * @param {string} [basePath] - Base directory for resolving relative paths
127127+ * @returns {string} - Resolved src (original if allowed, resolved if relative, empty if blocked)
128128+ */
129129+function resolveMarkdownImgSrc(src, basePath) {
130130+ const resolved = resolveImgSrc(src, basePath);
131131+ return resolved || '';
132132+}
133133+134134+/**
18135 * Simple markdown renderer.
1919- * Supports: headers, bold, italic, code, links, lists, blockquotes, hr.
136136+ * Supports: headers, bold, italic, code, links, lists, blockquotes, hr, HTML img tags.
20137 * @param {string} text - Markdown text
138138+ * @param {object} [options] - Render options
139139+ * @param {string} [options.basePath] - Base directory for resolving relative image paths
21140 * @returns {string} - HTML string
22141 */
2323-export function renderMarkdown(text) {
142142+export function renderMarkdown(text, options) {
143143+ const basePath = options?.basePath;
24144 let html = escapeHtml(text);
145145+146146+ // Restore safe HTML img tags (before other transformations)
147147+ html = restoreImgTags(html, basePath);
2514826149 // Code blocks (``` ... ```)
27150 html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
···47170 html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
48171 html = html.replace(/_(.+?)_/g, '<em>$1</em>');
49172173173+ // Images — resolve src against basePath
174174+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
175175+ const resolved = resolveMarkdownImgSrc(src, basePath);
176176+ if (!resolved) return ``; // leave as text if blocked
177177+ return `<img src="${resolved}" alt="${alt}" style="max-width:100%">`;
178178+ });
179179+50180 // Links
51181 html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
5252-5353- // Images
5454- html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">');
5518256183 // Horizontal rule
57184 html = html.replace(/^(---|\*\*\*|___)$/gm, '<hr>');
···7320074201 for (let i = 0; i < lines.length; i++) {
75202 const line = lines[i];
7676- const isBlock = /^<(h[1-6]|pre|ul|ol|li|blockquote|hr)/.test(line);
203203+ const isBlock = /^<(h[1-6]|pre|ul|ol|li|blockquote|hr|img)/.test(line);
77204 const isEmpty = line.trim() === '';
7820579206 if (isBlock) {
···108235 this.container = options.container;
109236 this.onToggle = options.onToggle; // callback: () => void — layout handles state
110237 this.collapsed = false;
238238+ this.basePath = null; // Base directory for resolving relative image paths
111239112240 // Create sidebar element
113241 this.element = document.createElement('div');
···125253 // Collapsed pane icon (visible only when collapsed)
126254 this.collapsedIcon = document.createElement('span');
127255 this.collapsedIcon.className = 'pane-collapsed-icon';
128128- this.collapsedIcon.textContent = '\u25CE'; // ◎ (eye-like icon)
256256+ this.collapsedIcon.textContent = '\u25CE'; // eye-like icon
129257 this.collapsedIcon.title = 'Show Preview';
130258 this.collapsedIcon.addEventListener('click', () => this.toggle());
131259 this.header.appendChild(this.collapsedIcon);
132260133261 this.toggleBtn = document.createElement('button');
134262 this.toggleBtn.className = 'sidebar-toggle';
135135- this.toggleBtn.innerHTML = '\u25B6'; // ▶ (collapse icon)
263263+ this.toggleBtn.innerHTML = '\u25B6'; // collapse icon
136264 this.toggleBtn.title = 'Hide Preview';
137265 this.toggleBtn.tabIndex = -1;
138266 this.toggleBtn.addEventListener('mousedown', (e) => e.preventDefault());
···150278 }
151279152280 /**
281281+ * Set the base path for resolving relative image paths.
282282+ * Should be the directory containing the file being edited.
283283+ * @param {string|null} basePath
284284+ */
285285+ setBasePath(basePath) {
286286+ this.basePath = basePath;
287287+ }
288288+289289+ /**
153290 * Update the preview with new markdown content.
154291 * @param {string} markdown
155292 */
156293 update(markdown) {
157157- this.content.innerHTML = renderMarkdown(markdown);
294294+ const options = this.basePath ? { basePath: this.basePath } : undefined;
295295+ this.content.innerHTML = renderMarkdown(markdown, options);
158296 }
159297160298 /**
+275-13
tests/unit/editor-outline-preview.test.js
···5656 .replace(/>/g, '>');
5757}
58585959-function renderMarkdown(text) {
5959+function resolveImgSrc(src, basePath) {
6060+ if (/^(https:\/\/|data:image\/)/i.test(src)) return src;
6161+ if (/^file:\/\//i.test(src)) return src;
6262+ if (/^javascript:/i.test(src.replace(/\s/g, ''))) return null;
6363+ if (/^http:\/\//i.test(src)) return null;
6464+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(src)) return null;
6565+ if (basePath) {
6666+ const base = basePath.replace(/\/+$/, '');
6767+ let resolved;
6868+ if (src.startsWith('/')) {
6969+ resolved = src;
7070+ } else {
7171+ resolved = base + '/' + src;
7272+ }
7373+ const parts = resolved.split('/');
7474+ const normalized = [];
7575+ for (const part of parts) {
7676+ if (part === '.' || part === '') continue;
7777+ if (part === '..') {
7878+ normalized.pop();
7979+ } else {
8080+ normalized.push(part);
8181+ }
8282+ }
8383+ const normalizedPath = '/' + normalized.join('/');
8484+ return 'peek://local-file' + normalizedPath;
8585+ }
8686+ return null;
8787+}
8888+8989+function restoreImgTags(html, basePath) {
9090+ return html.replace(
9191+ /<img\s+((?:(?!>).)+?)\/?\s*>/gi,
9292+ (match, attrs) => {
9393+ const unescaped = attrs
9494+ .replace(/&/g, '&')
9595+ .replace(/</g, '<')
9696+ .replace(/>/g, '>');
9797+ const allowed = ['src', 'alt', 'width', 'height', 'title', 'loading', 'style'];
9898+ const result = [];
9999+ const attrRegex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
100100+ let m;
101101+ while ((m = attrRegex.exec(unescaped)) !== null) {
102102+ const name = m[1].toLowerCase();
103103+ const value = m[2] ?? m[3] ?? m[4];
104104+ if (!allowed.includes(name)) continue;
105105+ if (name === 'src') {
106106+ const resolved = resolveImgSrc(value, basePath);
107107+ if (!resolved) continue;
108108+ const safeValue = resolved.replace(/"/g, '"');
109109+ result.push(`${name}="${safeValue}"`);
110110+ continue;
111111+ }
112112+ const safeValue = value.replace(/"/g, '"');
113113+ result.push(`${name}="${safeValue}"`);
114114+ }
115115+ if (!result.find(a => a.startsWith('src='))) return match;
116116+ if (!result.find(a => a.startsWith('style='))) {
117117+ result.push('style="max-width:100%"');
118118+ }
119119+ return `<img ${result.join(' ')}>`;
120120+ }
121121+ );
122122+}
123123+124124+function resolveMarkdownImgSrc(src, basePath) {
125125+ const resolved = resolveImgSrc(src, basePath);
126126+ return resolved || '';
127127+}
128128+129129+function renderMarkdown(text, options) {
130130+ const basePath = options?.basePath;
60131 let html = escapeHtml(text);
132132+133133+ // Restore safe HTML img tags
134134+ html = restoreImgTags(html, basePath);
6113562136 // Code blocks (``` ... ```)
63137 html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
···83157 html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
84158 html = html.replace(/_(.+?)_/g, '<em>$1</em>');
85159160160+ // Images — resolve src against basePath
161161+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
162162+ const resolved = resolveMarkdownImgSrc(src, basePath);
163163+ if (!resolved) return ``; // leave as text if blocked
164164+ return `<img src="${resolved}" alt="${alt}" style="max-width:100%">`;
165165+ });
166166+86167 // Links
87168 html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
8888-8989- // Images
9090- html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">');
9116992170 // Horizontal rule
93171 html = html.replace(/^(---|\*\*\*|___)$/gm, '<hr>');
···109187110188 for (let i = 0; i < lines.length; i++) {
111189 const line = lines[i];
112112- const isBlock = /^<(h[1-6]|pre|ul|ol|li|blockquote|hr)/.test(line);
190190+ const isBlock = /^<(h[1-6]|pre|ul|ol|li|blockquote|hr|img)/.test(line);
113191 const isEmpty = line.trim() === '';
114192115193 if (isBlock) {
···308386 assert.ok(html.includes('<a href="https://example.com" target="_blank">Click me</a>'));
309387 });
310388311311- it('renders images (known limitation: link regex matches first)', () => {
312312- // Note: In the current renderMarkdown implementation, the link regex runs
313313- // before the image regex, so  is partially consumed by the link
314314- // pattern. This test documents the current behavior.
315315- const html = renderMarkdown('');
316316- // The link regex captures [Alt text](image.png) first, leaving "!" prefix
317317- assert.ok(html.includes('Alt text'));
318318- assert.ok(html.includes('image.png'));
389389+ it('renders markdown images', () => {
390390+ // Image regex runs before link regex so  is handled correctly
391391+ const html = renderMarkdown('');
392392+ assert.ok(html.includes('<img src="https://example.com/image.png"'));
393393+ assert.ok(html.includes('alt="Alt text"'));
319394 });
320395321396 it('renders horizontal rules', () => {
···386461 const html = renderMarkdown(md);
387462 assert.ok(html.includes('<pre><code'));
388463 assert.ok(html.includes('plain code'));
464464+ });
465465+466466+ // HTML img tag tests
467467+ it('renders HTML img tags with https src', () => {
468468+ const html = renderMarkdown('<img src="https://example.com/photo.png" alt="photo">');
469469+ assert.ok(html.includes('<img src="https://example.com/photo.png" alt="photo"'));
470470+ assert.ok(!html.includes('<img'));
471471+ });
472472+473473+ it('renders HTML img tags with data:image src', () => {
474474+ const html = renderMarkdown('<img src="data:image/png;base64,abc123" alt="inline">');
475475+ assert.ok(html.includes('<img src="data:image/png;base64,abc123"'));
476476+ });
477477+478478+ it('blocks HTML img tags with javascript: src', () => {
479479+ const html = renderMarkdown('<img src="javascript:alert(1)">');
480480+ assert.ok(!html.includes('<img src='));
481481+ assert.ok(html.includes('<img'));
482482+ });
483483+484484+ it('blocks HTML img tags with http:// (non-https) src', () => {
485485+ const html = renderMarkdown('<img src="http://example.com/photo.png">');
486486+ assert.ok(!html.includes('<img src='));
487487+ assert.ok(html.includes('<img'));
488488+ });
489489+490490+ it('strips disallowed attributes like onerror from img tags', () => {
491491+ const html = renderMarkdown('<img src="https://example.com/x.png" onerror="alert(1)">');
492492+ assert.ok(html.includes('<img src="https://example.com/x.png"'));
493493+ assert.ok(!html.includes('onerror'));
494494+ });
495495+496496+ it('renders self-closing HTML img tags', () => {
497497+ const html = renderMarkdown('<img src="https://example.com/x.png" />');
498498+ assert.ok(html.includes('<img src="https://example.com/x.png"'));
499499+ });
500500+501501+ it('preserves width and height attributes on img tags', () => {
502502+ const html = renderMarkdown('<img src="https://example.com/x.png" width="200" height="100">');
503503+ assert.ok(html.includes('width="200"'));
504504+ assert.ok(html.includes('height="100"'));
505505+ });
506506+507507+ it('adds max-width style to img tags without explicit style', () => {
508508+ const html = renderMarkdown('<img src="https://example.com/x.png">');
509509+ assert.ok(html.includes('style="max-width:100%"'));
510510+ });
511511+512512+ it('preserves explicit style on img tags', () => {
513513+ const html = renderMarkdown('<img src="https://example.com/x.png" style="border:1px solid red">');
514514+ assert.ok(html.includes('style="border:1px solid red"'));
515515+ // Should NOT add default max-width since style was provided
516516+ assert.ok(!html.includes('max-width:100%'));
517517+ });
518518+519519+ // file:// protocol tests
520520+ it('renders HTML img tags with file:// src', () => {
521521+ const html = renderMarkdown('<img src="file:///Users/me/photo.png" alt="local">');
522522+ assert.ok(html.includes('<img src="file:///Users/me/photo.png"'));
523523+ assert.ok(!html.includes('<img'));
524524+ });
525525+526526+ it('blocks HTML img tags with ftp:// src', () => {
527527+ const html = renderMarkdown('<img src="ftp://example.com/photo.png">');
528528+ assert.ok(!html.includes('<img src='));
529529+ assert.ok(html.includes('<img'));
530530+ });
531531+532532+ it('blocks HTML img tags with vbscript: src', () => {
533533+ const html = renderMarkdown('<img src="vbscript:alert(1)">');
534534+ assert.ok(!html.includes('<img src='));
535535+ });
536536+537537+ // Relative path resolution tests
538538+ it('resolves relative image paths with basePath', () => {
539539+ const html = renderMarkdown('<img src="./screenshot.png">', { basePath: '/Users/me/docs' });
540540+ assert.ok(html.includes('<img src="peek://local-file/Users/me/docs/screenshot.png"'));
541541+ });
542542+543543+ it('resolves parent-relative image paths with basePath', () => {
544544+ const html = renderMarkdown('<img src="../images/photo.jpg">', { basePath: '/Users/me/docs' });
545545+ assert.ok(html.includes('<img src="peek://local-file/Users/me/images/photo.jpg"'));
546546+ });
547547+548548+ it('resolves bare filename image paths with basePath', () => {
549549+ const html = renderMarkdown('<img src="image.png">', { basePath: '/Users/me/docs' });
550550+ assert.ok(html.includes('<img src="peek://local-file/Users/me/docs/image.png"'));
551551+ });
552552+553553+ it('resolves absolute filesystem paths with basePath', () => {
554554+ const html = renderMarkdown('<img src="/tmp/image.png">', { basePath: '/Users/me/docs' });
555555+ assert.ok(html.includes('<img src="peek://local-file/tmp/image.png"'));
556556+ });
557557+558558+ it('blocks relative image paths without basePath', () => {
559559+ const html = renderMarkdown('<img src="./screenshot.png">');
560560+ assert.ok(!html.includes('<img src='));
561561+ assert.ok(html.includes('<img'));
562562+ });
563563+564564+ it('still allows https with basePath set', () => {
565565+ const html = renderMarkdown('<img src="https://example.com/photo.png">', { basePath: '/Users/me/docs' });
566566+ assert.ok(html.includes('<img src="https://example.com/photo.png"'));
567567+ });
568568+569569+ it('still blocks javascript: with basePath set', () => {
570570+ const html = renderMarkdown('<img src="javascript:alert(1)">', { basePath: '/Users/me/docs' });
571571+ assert.ok(!html.includes('<img src='));
572572+ });
573573+574574+ // Markdown image syntax with basePath
575575+ it('resolves relative markdown image paths with basePath', () => {
576576+ const html = renderMarkdown('', { basePath: '/Users/me/docs' });
577577+ assert.ok(html.includes('src="peek://local-file/Users/me/docs/image.png"'));
578578+ assert.ok(html.includes('alt="Alt text"'));
579579+ });
580580+581581+ it('resolves parent-relative markdown image paths with basePath', () => {
582582+ const html = renderMarkdown('', { basePath: '/Users/me/docs' });
583583+ assert.ok(html.includes('src="peek://local-file/Users/me/images/photo.jpg"'));
584584+ });
585585+});
586586+587587+// ===========================================================================
588588+// resolveImgSrc tests
589589+// ===========================================================================
590590+591591+describe('resolveImgSrc', () => {
592592+ it('passes through https:// URLs', () => {
593593+ assert.equal(resolveImgSrc('https://example.com/img.png'), 'https://example.com/img.png');
594594+ });
595595+596596+ it('passes through data:image/ URLs', () => {
597597+ const src = 'data:image/png;base64,abc123';
598598+ assert.equal(resolveImgSrc(src), src);
599599+ });
600600+601601+ it('passes through file:// URLs', () => {
602602+ assert.equal(resolveImgSrc('file:///Users/me/photo.png'), 'file:///Users/me/photo.png');
603603+ });
604604+605605+ it('blocks javascript: protocol', () => {
606606+ assert.equal(resolveImgSrc('javascript:alert(1)'), null);
607607+ });
608608+609609+ it('blocks http:// protocol', () => {
610610+ assert.equal(resolveImgSrc('http://example.com/img.png'), null);
611611+ });
612612+613613+ it('blocks ftp:// protocol', () => {
614614+ assert.equal(resolveImgSrc('ftp://example.com/img.png'), null);
615615+ });
616616+617617+ it('blocks vbscript: protocol', () => {
618618+ assert.equal(resolveImgSrc('vbscript:code'), null);
619619+ });
620620+621621+ it('blocks blob: protocol', () => {
622622+ assert.equal(resolveImgSrc('blob:http://example.com/uuid'), null);
623623+ });
624624+625625+ it('resolves relative path with basePath', () => {
626626+ assert.equal(resolveImgSrc('image.png', '/Users/me/docs'), 'peek://local-file/Users/me/docs/image.png');
627627+ });
628628+629629+ it('resolves ./ relative path', () => {
630630+ assert.equal(resolveImgSrc('./image.png', '/Users/me/docs'), 'peek://local-file/Users/me/docs/image.png');
631631+ });
632632+633633+ it('resolves ../ relative path', () => {
634634+ assert.equal(resolveImgSrc('../image.png', '/Users/me/docs'), 'peek://local-file/Users/me/image.png');
635635+ });
636636+637637+ it('resolves deeply nested relative path', () => {
638638+ assert.equal(resolveImgSrc('sub/dir/image.png', '/Users/me/docs'), 'peek://local-file/Users/me/docs/sub/dir/image.png');
639639+ });
640640+641641+ it('resolves absolute path with basePath', () => {
642642+ assert.equal(resolveImgSrc('/tmp/image.png', '/Users/me/docs'), 'peek://local-file/tmp/image.png');
643643+ });
644644+645645+ it('returns null for relative path without basePath', () => {
646646+ assert.equal(resolveImgSrc('image.png'), null);
647647+ });
648648+649649+ it('handles basePath with trailing slash', () => {
650650+ assert.equal(resolveImgSrc('image.png', '/Users/me/docs/'), 'peek://local-file/Users/me/docs/image.png');
389651 });
390652});
391653