experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add img tag rendering with local file support in editor preview

+547 -22
+97
backend/electron/protocol.ts
··· 7 7 * - peek://extensions/{path} - Shared extension infrastructure 8 8 * - peek://theme/{path} - Current theme files (vars.css, manifest.json) 9 9 * - peek://theme/{themeId}/{path} - Specific theme files 10 + * - peek://local-file/{path} - Local filesystem files (images, documents, media, etc.) 10 11 */ 11 12 12 13 import { protocol, net } from 'electron'; ··· 43 44 'lit-element': 'node_modules/lit-element/lit-element.js', 44 45 'lit-element/': 'node_modules/lit-element/' 45 46 }; 47 + 48 + // Allowed file extensions for peek://local-file/ protocol. 49 + // Maps extension -> MIME type. Only these types are served; everything else is blocked. 50 + const ALLOWED_LOCAL_FILE_EXTS = new Map<string, string>([ 51 + // Images 52 + ['.png', 'image/png'], 53 + ['.jpg', 'image/jpeg'], 54 + ['.jpeg', 'image/jpeg'], 55 + ['.gif', 'image/gif'], 56 + ['.svg', 'image/svg+xml'], 57 + ['.webp', 'image/webp'], 58 + ['.bmp', 'image/bmp'], 59 + ['.ico', 'image/x-icon'], 60 + ['.avif', 'image/avif'], 61 + // Documents 62 + ['.pdf', 'application/pdf'], 63 + // Fonts 64 + ['.woff', 'font/woff'], 65 + ['.woff2', 'font/woff2'], 66 + ['.ttf', 'font/ttf'], 67 + ['.otf', 'font/otf'], 68 + // Styles 69 + ['.css', 'text/css'], 70 + // Media 71 + ['.mp4', 'video/mp4'], 72 + ['.webm', 'video/webm'], 73 + ['.mp3', 'audio/mpeg'], 74 + ['.ogg', 'audio/ogg'], 75 + ['.wav', 'audio/wav'], 76 + ]); 77 + 78 + // Explicitly blocked executable/script extensions (for clear error messages) 79 + const BLOCKED_EXTS = new Set([ 80 + '.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', 81 + '.html', '.htm', '.xhtml', 82 + '.sh', '.bash', '.zsh', '.fish', 83 + '.exe', '.bat', '.cmd', '.com', '.msi', 84 + '.py', '.rb', '.pl', '.php', 85 + '.jar', '.class', 86 + ]); 46 87 47 88 /** 48 89 * Fetch a local file, logging a warning if it doesn't exist. ··· 379 420 } 380 421 381 422 return fetchFile(absolutePath, req.url); 423 + } 424 + 425 + // Handle local file access: peek://local-file/{absolute-path} 426 + // Used by editor preview and other features to serve local files. 427 + // Only serves files with explicitly allowed MIME types for security. 428 + if (host === 'local-file') { 429 + // Decode percent-encoded path segments 430 + const decodedPathname = decodeURIComponent(pathname); 431 + const absolutePath = '/' + decodedPathname; 432 + 433 + // Security: reject paths containing null bytes 434 + if (absolutePath.includes('\0')) { 435 + console.error(`[protocol] local-file: blocked null byte in path`); 436 + return new Response('Forbidden: invalid path', { status: 403 }); 437 + } 438 + 439 + // Security: only allow known-safe file extensions 440 + const ext = path.extname(absolutePath).toLowerCase(); 441 + if (!ALLOWED_LOCAL_FILE_EXTS.has(ext)) { 442 + const isBlocked = BLOCKED_EXTS.has(ext); 443 + DEBUG && console.log(`[protocol] local-file: blocked ${isBlocked ? 'executable/script' : 'unknown'} extension: ${ext}`); 444 + return new Response('Forbidden: file type not allowed', { status: 403 }); 445 + } 446 + 447 + // Security: resolve symlinks to get the real path 448 + let realPath: string; 449 + try { 450 + realPath = fs.realpathSync(absolutePath); 451 + } catch { 452 + DEBUG && console.log(`[protocol] local-file: file not found: ${absolutePath}`); 453 + return new Response('Not Found', { status: 404 }); 454 + } 455 + 456 + // Re-check extension after symlink resolution 457 + const realExt = path.extname(realPath).toLowerCase(); 458 + if (!ALLOWED_LOCAL_FILE_EXTS.has(realExt)) { 459 + DEBUG && console.log(`[protocol] local-file: blocked extension after symlink resolution: ${realExt}`); 460 + return new Response('Forbidden: file type not allowed', { status: 403 }); 461 + } 462 + 463 + DEBUG && console.log(`[protocol] local-file: serving ${realPath}`); 464 + 465 + // Serve with proper MIME type from our allowlist 466 + const mimeType = ALLOWED_LOCAL_FILE_EXTS.get(realExt) || 'application/octet-stream'; 467 + const fileURL = pathToFileURL(realPath).toString(); 468 + const response = await net.fetch(fileURL); 469 + const body = await response.arrayBuffer(); 470 + return new Response(body, { 471 + status: response.status, 472 + headers: { 473 + 'Content-Type': mimeType, 474 + 'Cache-Control': 'no-store, no-cache, must-revalidate', 475 + 'Pragma': 'no-cache', 476 + 'Expires': '0' 477 + } 478 + }); 382 479 } 383 480 384 481 // Handle per-extension hosts: peek://{ext-id}/{path}
+13
features/editor/editor-layout.js
··· 1112 1112 } 1113 1113 } 1114 1114 1115 + /** 1116 + * Set the base path for resolving relative image paths in the preview. 1117 + * Should be the directory containing the file being edited. 1118 + * @param {string|null} basePath - Directory path (e.g., '/Users/me/docs') 1119 + */ 1120 + setBasePath(basePath) { 1121 + if (this.previewSidebar) { 1122 + this.previewSidebar.setBasePath(basePath); 1123 + // Re-render preview with the new base path 1124 + this.updateSidebars(); 1125 + } 1126 + } 1127 + 1115 1128 destroy() { 1116 1129 this.isDestroyed = true; 1117 1130 this.stopWatching();
+15
features/editor/home.js
··· 196 196 currentFilePath = result.data.path; 197 197 hasExplicitSource = true; 198 198 debug && console.log('[editor] Loaded file from path:', currentFilePath); 199 + // basePath will be set after editorLayout is created 199 200 } 200 201 } catch (err) { 201 202 debug && console.log('[editor] Failed to load file:', err); ··· 246 247 } 247 248 if (currentFilePath) { 248 249 updateWindowTitle(); 250 + // Set base path for resolving relative image paths in preview 251 + editorLayout.setBasePath(extractDirname(currentFilePath)); 249 252 } 250 253 251 254 // Set up Cmd+O (open file) and Cmd+S (save file) keyboard shortcuts ··· 469 472 }; 470 473 471 474 /** 475 + * Extract directory path from a full file path. 476 + */ 477 + const extractDirname = (filePath) => { 478 + if (!filePath) return ''; 479 + const parts = filePath.split('/'); 480 + parts.pop(); 481 + return parts.join('/') || '/'; 482 + }; 483 + 484 + /** 472 485 * Update the window title and filename display to show the current filename. 473 486 */ 474 487 const updateWindowTitle = () => { ··· 513 526 if (editorLayout) { 514 527 editorLayout.setContent(content); 515 528 editorLayout.setSaveStatusVisible(true); 529 + // Set base path for resolving relative image paths in preview 530 + editorLayout.setBasePath(extractDirname(filePath)); 516 531 } 517 532 518 533 saveStatus = 'saved';
+147 -9
features/editor/preview-sidebar.js
··· 15 15 } 16 16 17 17 /** 18 + * Resolve a potentially relative image src to an absolute path. 19 + * Returns null if the src should be blocked (unsafe protocol). 20 + * @param {string} src - The image src attribute value 21 + * @param {string} [basePath] - Base directory for resolving relative paths 22 + * @returns {string|null} - Resolved src or null if blocked 23 + */ 24 + function resolveImgSrc(src, basePath) { 25 + // Allow https:// and data:image/ always 26 + if (/^(https:\/\/|data:image\/)/i.test(src)) return src; 27 + 28 + // Allow file:// protocol for local files 29 + if (/^file:\/\//i.test(src)) return src; 30 + 31 + // Block dangerous protocols explicitly 32 + if (/^javascript:/i.test(src.replace(/\s/g, ''))) return null; 33 + 34 + // Block http:// (non-secure) 35 + if (/^http:\/\//i.test(src)) return null; 36 + 37 + // Block any other explicit protocol (e.g., ftp://, blob:, vbscript:, etc.) 38 + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(src)) return null; 39 + 40 + // Relative path — resolve against basePath if available 41 + if (basePath) { 42 + // Normalize: remove trailing slash from basePath 43 + const base = basePath.replace(/\/+$/, ''); 44 + let resolved; 45 + if (src.startsWith('/')) { 46 + // Absolute path on filesystem 47 + resolved = src; 48 + } else { 49 + resolved = base + '/' + src; 50 + } 51 + // Normalize path segments (resolve . and ..) 52 + const parts = resolved.split('/'); 53 + const normalized = []; 54 + for (const part of parts) { 55 + if (part === '.' || part === '') continue; 56 + if (part === '..') { 57 + normalized.pop(); 58 + } else { 59 + normalized.push(part); 60 + } 61 + } 62 + // Reconstruct as absolute path (starts with /) 63 + const normalizedPath = '/' + normalized.join('/'); 64 + // Use peek://local-file/ instead of file:// so images load from the 65 + // peek:// origin without cross-origin restrictions (webSecurity: true). 66 + return 'peek://local-file' + normalizedPath; 67 + } 68 + 69 + // No basePath — can't resolve relative paths, block them 70 + return null; 71 + } 72 + 73 + /** 74 + * Restore safe HTML img tags that were escaped by escapeHtml(). 75 + * Allows src with https://, data:image/, and file:// protocols. 76 + * Resolves relative paths against basePath when provided. 77 + * Permits alt, width, height, title, loading, and style attributes. 78 + * @param {string} html - HTML-escaped string 79 + * @param {string} [basePath] - Base directory for resolving relative image paths 80 + * @returns {string} 81 + */ 82 + function restoreImgTags(html, basePath) { 83 + // Match escaped <img ... > or <img ... /> 84 + return html.replace( 85 + /&lt;img\s+((?:(?!&gt;).)+?)\/?\s*&gt;/gi, 86 + (match, attrs) => { 87 + // Unescape the attributes portion so we can parse them 88 + const unescaped = attrs 89 + .replace(/&amp;/g, '&') 90 + .replace(/&lt;/g, '<') 91 + .replace(/&gt;/g, '>'); 92 + const allowed = ['src', 'alt', 'width', 'height', 'title', 'loading', 'style']; 93 + const result = []; 94 + // Match attribute="value", attribute='value', or attribute=value 95 + const attrRegex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g; 96 + let m; 97 + while ((m = attrRegex.exec(unescaped)) !== null) { 98 + const name = m[1].toLowerCase(); 99 + const value = m[2] ?? m[3] ?? m[4]; 100 + if (!allowed.includes(name)) continue; 101 + if (name === 'src') { 102 + const resolved = resolveImgSrc(value, basePath); 103 + if (!resolved) continue; 104 + const safeValue = resolved.replace(/"/g, '&quot;'); 105 + result.push(`${name}="${safeValue}"`); 106 + continue; 107 + } 108 + // Sanitize value — escape quotes to prevent attribute injection 109 + const safeValue = value.replace(/"/g, '&quot;'); 110 + result.push(`${name}="${safeValue}"`); 111 + } 112 + // Must have a src to render 113 + if (!result.find(a => a.startsWith('src='))) return match; 114 + // Add max-width for responsive images 115 + if (!result.find(a => a.startsWith('style='))) { 116 + result.push('style="max-width:100%"'); 117 + } 118 + return `<img ${result.join(' ')}>`; 119 + } 120 + ); 121 + } 122 + 123 + /** 124 + * Resolve a markdown image src, applying the same protocol/path rules as HTML img tags. 125 + * @param {string} src - The image src from markdown ![alt](src) 126 + * @param {string} [basePath] - Base directory for resolving relative paths 127 + * @returns {string} - Resolved src (original if allowed, resolved if relative, empty if blocked) 128 + */ 129 + function resolveMarkdownImgSrc(src, basePath) { 130 + const resolved = resolveImgSrc(src, basePath); 131 + return resolved || ''; 132 + } 133 + 134 + /** 18 135 * Simple markdown renderer. 19 - * Supports: headers, bold, italic, code, links, lists, blockquotes, hr. 136 + * Supports: headers, bold, italic, code, links, lists, blockquotes, hr, HTML img tags. 20 137 * @param {string} text - Markdown text 138 + * @param {object} [options] - Render options 139 + * @param {string} [options.basePath] - Base directory for resolving relative image paths 21 140 * @returns {string} - HTML string 22 141 */ 23 - export function renderMarkdown(text) { 142 + export function renderMarkdown(text, options) { 143 + const basePath = options?.basePath; 24 144 let html = escapeHtml(text); 145 + 146 + // Restore safe HTML img tags (before other transformations) 147 + html = restoreImgTags(html, basePath); 25 148 26 149 // Code blocks (``` ... ```) 27 150 html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { ··· 47 170 html = html.replace(/__(.+?)__/g, '<strong>$1</strong>'); 48 171 html = html.replace(/_(.+?)_/g, '<em>$1</em>'); 49 172 173 + // Images — resolve src against basePath 174 + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => { 175 + const resolved = resolveMarkdownImgSrc(src, basePath); 176 + if (!resolved) return `![${alt}](${src})`; // leave as text if blocked 177 + return `<img src="${resolved}" alt="${alt}" style="max-width:100%">`; 178 + }); 179 + 50 180 // Links 51 181 html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>'); 52 - 53 - // Images 54 - html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">'); 55 182 56 183 // Horizontal rule 57 184 html = html.replace(/^(---|\*\*\*|___)$/gm, '<hr>'); ··· 73 200 74 201 for (let i = 0; i < lines.length; i++) { 75 202 const line = lines[i]; 76 - const isBlock = /^<(h[1-6]|pre|ul|ol|li|blockquote|hr)/.test(line); 203 + const isBlock = /^<(h[1-6]|pre|ul|ol|li|blockquote|hr|img)/.test(line); 77 204 const isEmpty = line.trim() === ''; 78 205 79 206 if (isBlock) { ··· 108 235 this.container = options.container; 109 236 this.onToggle = options.onToggle; // callback: () => void — layout handles state 110 237 this.collapsed = false; 238 + this.basePath = null; // Base directory for resolving relative image paths 111 239 112 240 // Create sidebar element 113 241 this.element = document.createElement('div'); ··· 125 253 // Collapsed pane icon (visible only when collapsed) 126 254 this.collapsedIcon = document.createElement('span'); 127 255 this.collapsedIcon.className = 'pane-collapsed-icon'; 128 - this.collapsedIcon.textContent = '\u25CE'; // ◎ (eye-like icon) 256 + this.collapsedIcon.textContent = '\u25CE'; // eye-like icon 129 257 this.collapsedIcon.title = 'Show Preview'; 130 258 this.collapsedIcon.addEventListener('click', () => this.toggle()); 131 259 this.header.appendChild(this.collapsedIcon); 132 260 133 261 this.toggleBtn = document.createElement('button'); 134 262 this.toggleBtn.className = 'sidebar-toggle'; 135 - this.toggleBtn.innerHTML = '\u25B6'; // ▶ (collapse icon) 263 + this.toggleBtn.innerHTML = '\u25B6'; // collapse icon 136 264 this.toggleBtn.title = 'Hide Preview'; 137 265 this.toggleBtn.tabIndex = -1; 138 266 this.toggleBtn.addEventListener('mousedown', (e) => e.preventDefault()); ··· 150 278 } 151 279 152 280 /** 281 + * Set the base path for resolving relative image paths. 282 + * Should be the directory containing the file being edited. 283 + * @param {string|null} basePath 284 + */ 285 + setBasePath(basePath) { 286 + this.basePath = basePath; 287 + } 288 + 289 + /** 153 290 * Update the preview with new markdown content. 154 291 * @param {string} markdown 155 292 */ 156 293 update(markdown) { 157 - this.content.innerHTML = renderMarkdown(markdown); 294 + const options = this.basePath ? { basePath: this.basePath } : undefined; 295 + this.content.innerHTML = renderMarkdown(markdown, options); 158 296 } 159 297 160 298 /**
+275 -13
tests/unit/editor-outline-preview.test.js
··· 56 56 .replace(/>/g, '&gt;'); 57 57 } 58 58 59 - function renderMarkdown(text) { 59 + function resolveImgSrc(src, basePath) { 60 + if (/^(https:\/\/|data:image\/)/i.test(src)) return src; 61 + if (/^file:\/\//i.test(src)) return src; 62 + if (/^javascript:/i.test(src.replace(/\s/g, ''))) return null; 63 + if (/^http:\/\//i.test(src)) return null; 64 + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(src)) return null; 65 + if (basePath) { 66 + const base = basePath.replace(/\/+$/, ''); 67 + let resolved; 68 + if (src.startsWith('/')) { 69 + resolved = src; 70 + } else { 71 + resolved = base + '/' + src; 72 + } 73 + const parts = resolved.split('/'); 74 + const normalized = []; 75 + for (const part of parts) { 76 + if (part === '.' || part === '') continue; 77 + if (part === '..') { 78 + normalized.pop(); 79 + } else { 80 + normalized.push(part); 81 + } 82 + } 83 + const normalizedPath = '/' + normalized.join('/'); 84 + return 'peek://local-file' + normalizedPath; 85 + } 86 + return null; 87 + } 88 + 89 + function restoreImgTags(html, basePath) { 90 + return html.replace( 91 + /&lt;img\s+((?:(?!&gt;).)+?)\/?\s*&gt;/gi, 92 + (match, attrs) => { 93 + const unescaped = attrs 94 + .replace(/&amp;/g, '&') 95 + .replace(/&lt;/g, '<') 96 + .replace(/&gt;/g, '>'); 97 + const allowed = ['src', 'alt', 'width', 'height', 'title', 'loading', 'style']; 98 + const result = []; 99 + const attrRegex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g; 100 + let m; 101 + while ((m = attrRegex.exec(unescaped)) !== null) { 102 + const name = m[1].toLowerCase(); 103 + const value = m[2] ?? m[3] ?? m[4]; 104 + if (!allowed.includes(name)) continue; 105 + if (name === 'src') { 106 + const resolved = resolveImgSrc(value, basePath); 107 + if (!resolved) continue; 108 + const safeValue = resolved.replace(/"/g, '&quot;'); 109 + result.push(`${name}="${safeValue}"`); 110 + continue; 111 + } 112 + const safeValue = value.replace(/"/g, '&quot;'); 113 + result.push(`${name}="${safeValue}"`); 114 + } 115 + if (!result.find(a => a.startsWith('src='))) return match; 116 + if (!result.find(a => a.startsWith('style='))) { 117 + result.push('style="max-width:100%"'); 118 + } 119 + return `<img ${result.join(' ')}>`; 120 + } 121 + ); 122 + } 123 + 124 + function resolveMarkdownImgSrc(src, basePath) { 125 + const resolved = resolveImgSrc(src, basePath); 126 + return resolved || ''; 127 + } 128 + 129 + function renderMarkdown(text, options) { 130 + const basePath = options?.basePath; 60 131 let html = escapeHtml(text); 132 + 133 + // Restore safe HTML img tags 134 + html = restoreImgTags(html, basePath); 61 135 62 136 // Code blocks (``` ... ```) 63 137 html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { ··· 83 157 html = html.replace(/__(.+?)__/g, '<strong>$1</strong>'); 84 158 html = html.replace(/_(.+?)_/g, '<em>$1</em>'); 85 159 160 + // Images — resolve src against basePath 161 + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => { 162 + const resolved = resolveMarkdownImgSrc(src, basePath); 163 + if (!resolved) return `![${alt}](${src})`; // leave as text if blocked 164 + return `<img src="${resolved}" alt="${alt}" style="max-width:100%">`; 165 + }); 166 + 86 167 // Links 87 168 html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>'); 88 - 89 - // Images 90 - html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">'); 91 169 92 170 // Horizontal rule 93 171 html = html.replace(/^(---|\*\*\*|___)$/gm, '<hr>'); ··· 109 187 110 188 for (let i = 0; i < lines.length; i++) { 111 189 const line = lines[i]; 112 - const isBlock = /^<(h[1-6]|pre|ul|ol|li|blockquote|hr)/.test(line); 190 + const isBlock = /^<(h[1-6]|pre|ul|ol|li|blockquote|hr|img)/.test(line); 113 191 const isEmpty = line.trim() === ''; 114 192 115 193 if (isBlock) { ··· 308 386 assert.ok(html.includes('<a href="https://example.com" target="_blank">Click me</a>')); 309 387 }); 310 388 311 - it('renders images (known limitation: link regex matches first)', () => { 312 - // Note: In the current renderMarkdown implementation, the link regex runs 313 - // before the image regex, so ![alt](url) is partially consumed by the link 314 - // pattern. This test documents the current behavior. 315 - const html = renderMarkdown('![Alt text](image.png)'); 316 - // The link regex captures [Alt text](image.png) first, leaving "!" prefix 317 - assert.ok(html.includes('Alt text')); 318 - assert.ok(html.includes('image.png')); 389 + it('renders markdown images', () => { 390 + // Image regex runs before link regex so ![alt](url) is handled correctly 391 + const html = renderMarkdown('![Alt text](https://example.com/image.png)'); 392 + assert.ok(html.includes('<img src="https://example.com/image.png"')); 393 + assert.ok(html.includes('alt="Alt text"')); 319 394 }); 320 395 321 396 it('renders horizontal rules', () => { ··· 386 461 const html = renderMarkdown(md); 387 462 assert.ok(html.includes('<pre><code')); 388 463 assert.ok(html.includes('plain code')); 464 + }); 465 + 466 + // HTML img tag tests 467 + it('renders HTML img tags with https src', () => { 468 + const html = renderMarkdown('<img src="https://example.com/photo.png" alt="photo">'); 469 + assert.ok(html.includes('<img src="https://example.com/photo.png" alt="photo"')); 470 + assert.ok(!html.includes('&lt;img')); 471 + }); 472 + 473 + it('renders HTML img tags with data:image src', () => { 474 + const html = renderMarkdown('<img src="data:image/png;base64,abc123" alt="inline">'); 475 + assert.ok(html.includes('<img src="data:image/png;base64,abc123"')); 476 + }); 477 + 478 + it('blocks HTML img tags with javascript: src', () => { 479 + const html = renderMarkdown('<img src="javascript:alert(1)">'); 480 + assert.ok(!html.includes('<img src=')); 481 + assert.ok(html.includes('&lt;img')); 482 + }); 483 + 484 + it('blocks HTML img tags with http:// (non-https) src', () => { 485 + const html = renderMarkdown('<img src="http://example.com/photo.png">'); 486 + assert.ok(!html.includes('<img src=')); 487 + assert.ok(html.includes('&lt;img')); 488 + }); 489 + 490 + it('strips disallowed attributes like onerror from img tags', () => { 491 + const html = renderMarkdown('<img src="https://example.com/x.png" onerror="alert(1)">'); 492 + assert.ok(html.includes('<img src="https://example.com/x.png"')); 493 + assert.ok(!html.includes('onerror')); 494 + }); 495 + 496 + it('renders self-closing HTML img tags', () => { 497 + const html = renderMarkdown('<img src="https://example.com/x.png" />'); 498 + assert.ok(html.includes('<img src="https://example.com/x.png"')); 499 + }); 500 + 501 + it('preserves width and height attributes on img tags', () => { 502 + const html = renderMarkdown('<img src="https://example.com/x.png" width="200" height="100">'); 503 + assert.ok(html.includes('width="200"')); 504 + assert.ok(html.includes('height="100"')); 505 + }); 506 + 507 + it('adds max-width style to img tags without explicit style', () => { 508 + const html = renderMarkdown('<img src="https://example.com/x.png">'); 509 + assert.ok(html.includes('style="max-width:100%"')); 510 + }); 511 + 512 + it('preserves explicit style on img tags', () => { 513 + const html = renderMarkdown('<img src="https://example.com/x.png" style="border:1px solid red">'); 514 + assert.ok(html.includes('style="border:1px solid red"')); 515 + // Should NOT add default max-width since style was provided 516 + assert.ok(!html.includes('max-width:100%')); 517 + }); 518 + 519 + // file:// protocol tests 520 + it('renders HTML img tags with file:// src', () => { 521 + const html = renderMarkdown('<img src="file:///Users/me/photo.png" alt="local">'); 522 + assert.ok(html.includes('<img src="file:///Users/me/photo.png"')); 523 + assert.ok(!html.includes('&lt;img')); 524 + }); 525 + 526 + it('blocks HTML img tags with ftp:// src', () => { 527 + const html = renderMarkdown('<img src="ftp://example.com/photo.png">'); 528 + assert.ok(!html.includes('<img src=')); 529 + assert.ok(html.includes('&lt;img')); 530 + }); 531 + 532 + it('blocks HTML img tags with vbscript: src', () => { 533 + const html = renderMarkdown('<img src="vbscript:alert(1)">'); 534 + assert.ok(!html.includes('<img src=')); 535 + }); 536 + 537 + // Relative path resolution tests 538 + it('resolves relative image paths with basePath', () => { 539 + const html = renderMarkdown('<img src="./screenshot.png">', { basePath: '/Users/me/docs' }); 540 + assert.ok(html.includes('<img src="peek://local-file/Users/me/docs/screenshot.png"')); 541 + }); 542 + 543 + it('resolves parent-relative image paths with basePath', () => { 544 + const html = renderMarkdown('<img src="../images/photo.jpg">', { basePath: '/Users/me/docs' }); 545 + assert.ok(html.includes('<img src="peek://local-file/Users/me/images/photo.jpg"')); 546 + }); 547 + 548 + it('resolves bare filename image paths with basePath', () => { 549 + const html = renderMarkdown('<img src="image.png">', { basePath: '/Users/me/docs' }); 550 + assert.ok(html.includes('<img src="peek://local-file/Users/me/docs/image.png"')); 551 + }); 552 + 553 + it('resolves absolute filesystem paths with basePath', () => { 554 + const html = renderMarkdown('<img src="/tmp/image.png">', { basePath: '/Users/me/docs' }); 555 + assert.ok(html.includes('<img src="peek://local-file/tmp/image.png"')); 556 + }); 557 + 558 + it('blocks relative image paths without basePath', () => { 559 + const html = renderMarkdown('<img src="./screenshot.png">'); 560 + assert.ok(!html.includes('<img src=')); 561 + assert.ok(html.includes('&lt;img')); 562 + }); 563 + 564 + it('still allows https with basePath set', () => { 565 + const html = renderMarkdown('<img src="https://example.com/photo.png">', { basePath: '/Users/me/docs' }); 566 + assert.ok(html.includes('<img src="https://example.com/photo.png"')); 567 + }); 568 + 569 + it('still blocks javascript: with basePath set', () => { 570 + const html = renderMarkdown('<img src="javascript:alert(1)">', { basePath: '/Users/me/docs' }); 571 + assert.ok(!html.includes('<img src=')); 572 + }); 573 + 574 + // Markdown image syntax with basePath 575 + it('resolves relative markdown image paths with basePath', () => { 576 + const html = renderMarkdown('![Alt text](./image.png)', { basePath: '/Users/me/docs' }); 577 + assert.ok(html.includes('src="peek://local-file/Users/me/docs/image.png"')); 578 + assert.ok(html.includes('alt="Alt text"')); 579 + }); 580 + 581 + it('resolves parent-relative markdown image paths with basePath', () => { 582 + const html = renderMarkdown('![Photo](../images/photo.jpg)', { basePath: '/Users/me/docs' }); 583 + assert.ok(html.includes('src="peek://local-file/Users/me/images/photo.jpg"')); 584 + }); 585 + }); 586 + 587 + // =========================================================================== 588 + // resolveImgSrc tests 589 + // =========================================================================== 590 + 591 + describe('resolveImgSrc', () => { 592 + it('passes through https:// URLs', () => { 593 + assert.equal(resolveImgSrc('https://example.com/img.png'), 'https://example.com/img.png'); 594 + }); 595 + 596 + it('passes through data:image/ URLs', () => { 597 + const src = 'data:image/png;base64,abc123'; 598 + assert.equal(resolveImgSrc(src), src); 599 + }); 600 + 601 + it('passes through file:// URLs', () => { 602 + assert.equal(resolveImgSrc('file:///Users/me/photo.png'), 'file:///Users/me/photo.png'); 603 + }); 604 + 605 + it('blocks javascript: protocol', () => { 606 + assert.equal(resolveImgSrc('javascript:alert(1)'), null); 607 + }); 608 + 609 + it('blocks http:// protocol', () => { 610 + assert.equal(resolveImgSrc('http://example.com/img.png'), null); 611 + }); 612 + 613 + it('blocks ftp:// protocol', () => { 614 + assert.equal(resolveImgSrc('ftp://example.com/img.png'), null); 615 + }); 616 + 617 + it('blocks vbscript: protocol', () => { 618 + assert.equal(resolveImgSrc('vbscript:code'), null); 619 + }); 620 + 621 + it('blocks blob: protocol', () => { 622 + assert.equal(resolveImgSrc('blob:http://example.com/uuid'), null); 623 + }); 624 + 625 + it('resolves relative path with basePath', () => { 626 + assert.equal(resolveImgSrc('image.png', '/Users/me/docs'), 'peek://local-file/Users/me/docs/image.png'); 627 + }); 628 + 629 + it('resolves ./ relative path', () => { 630 + assert.equal(resolveImgSrc('./image.png', '/Users/me/docs'), 'peek://local-file/Users/me/docs/image.png'); 631 + }); 632 + 633 + it('resolves ../ relative path', () => { 634 + assert.equal(resolveImgSrc('../image.png', '/Users/me/docs'), 'peek://local-file/Users/me/image.png'); 635 + }); 636 + 637 + it('resolves deeply nested relative path', () => { 638 + assert.equal(resolveImgSrc('sub/dir/image.png', '/Users/me/docs'), 'peek://local-file/Users/me/docs/sub/dir/image.png'); 639 + }); 640 + 641 + it('resolves absolute path with basePath', () => { 642 + assert.equal(resolveImgSrc('/tmp/image.png', '/Users/me/docs'), 'peek://local-file/tmp/image.png'); 643 + }); 644 + 645 + it('returns null for relative path without basePath', () => { 646 + assert.equal(resolveImgSrc('image.png'), null); 647 + }); 648 + 649 + it('handles basePath with trailing slash', () => { 650 + assert.equal(resolveImgSrc('image.png', '/Users/me/docs/'), 'peek://local-file/Users/me/docs/image.png'); 389 651 }); 390 652 }); 391 653