Skip to content

Markdown editor#2770

Open
abose wants to merge 111 commits intomainfrom
md
Open

Markdown editor#2770
abose wants to merge 111 commits intomainfrom
md

Conversation

@abose
Copy link
Copy Markdown
Member

@abose abose commented Mar 29, 2026

This PR introduces a full-featured markdown live preview editor (src-mdviewer/) that replaces the basic markdown rendering with a rich WYSIWYG editing experience in the live preview panel.

Screenshots

edit mode

image

Reader mode

image

Major Features

  • Rich markdown editor — contenteditable-based WYSIWYG editing with toolbar, slash menu (/ commands), and format bar
  • Bidirectional sync — real-time sync between CodeMirror source and the visual editor, including cursor/scroll position sync
  • Document cache — persistent iframe with DOM caching for instant file switching (working set + LRU)
  • Image paste-to-upload — paste/upload images from clipboard or file picker, auto-compress, cloud upload via assets.phcode.dev
  • In-document search — Ctrl+F search with mark.js highlighting, per-document index preservation
  • Print support — print button with light theme forcing and proper page layout
  • Table editing — row/column handles, context menus, add/delete row/column/table, cell navigation
  • List editing — Enter splits bullets, Tab/Shift+Tab indent/outdent, UL/OL type switching
  • Heading editing — Enter splits at cursor, Backspace at start converts to paragraph, context-aware behavior
  • Code block editing — ArrowDown/Shift+Enter exits to paragraph below
  • Entitlement gating — edit mode gated behind pro/trial entitlement with upsell dialog

Architecture

  • src-mdviewer/ — Vite-built iframe app with its own i18n, themes, and component system
  • Communicates with Phoenix via postMessage bridge (bridge.js ↔ MarkdownSync.js)
  • Separate iframe ID (panel-md-preview-frame) to avoid duplicate DOM IDs with HTML live preview
  • Sandboxed without allow-same-origin for security (scripts in rendered markdown can't access Phoenix context)
  • morphdom for incremental DOM updates with image node preservation (no reload/flicker)
  • ImageUploadManager service provider pattern — core registry, pro extension implements upload

Security

  • mdviewer iframe sandbox: allow-scripts allow-popups allow-popups-to-escape-sandbox allow-modals (no allow-same-origin)
  • EventManager silently ignores null origin messages from sandboxed iframes
  • No sandbox in test windows for integration test compatibility

Testing

  • Updated LiveDevelopmentCustomServer-test.js and LiveDevelopmentMultiBrowser-test.js for new iframe architecture
  • Added test for play button/mode dropdown hidden on markdown files
  • 62/62 MultiBrowser LP tests passing, 35/35 Custom Server LP tests passing
  • Comprehensive test plan in src-mdviewer/to-create-tests.md

Other Changes

  • Assets proxy route in serve-proxy.js for browser dev builds
  • H4/H5 support in block type selector and slash menu
  • End/Home key fix near images in contenteditable
  • Edit mode preserved across project switches

Test plan

  • Open markdown file — rich preview with toolbar appears
  • Edit in CM — preview updates without image reload
  • Edit in preview — syncs back to CM
  • Paste image — uploads to cloud, embed URL inserted
  • Ctrl+F — in-document search works
  • Print button — prints with light theme, proper margins
  • Table editing — add/delete rows/columns, navigation
  • File switching — instant with cached DOM
  • Project switch — cache clears, edit mode preserved
  • All LP integration tests pass (62 + 35 = 97 tests)

abose and others added 30 commits March 29, 2026 22:36
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…position sync

- Add data-source-line attributes to rendered markdown HTML via custom
  marked renderer using lexer/parser pipeline with token annotation
- Bidirectional click-to-scroll: clicking in preview moves CM5 cursor
  to corresponding source line (centred); clicking in CM5 scrolls
  preview to matching element — only when target is off-screen
- Route undo/redo from iframe through CM5's undo stack via capture-phase
  keyboard interception (Cmd/Ctrl+Z, Shift+Z, Y)
- Use diff-based cm.replaceRange() instead of doc.setText() to preserve
  CM5 undo history when syncing WYSIWYG edits back
- Escape key in iframe refocuses CM5 editor
- Read mode clicks without selection refocus CM5; edit mode clicks sync
  cursor without stealing focus
- Exclude src/mdViewer build output from ESLint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tton styles

Reduce toolbar height from 44px to 30px, shrink buttons/icons proportionally,
and switch edit-toggle-btn to btn-alt-quiet style (transparent bg, border on hover).
…tip delay

- Add underline formatting to embedded toolbar, floating format bar, and editor
- Responsive toolbar: collapses into dropdown groups (text, lists, blocks) on narrow widths
- Fix min-width issue preventing toolbar from shrinking below content width
- Format bar buttons now single-line (no wrapping)
- Increase tooltip delay from 300ms to 800ms
Collapse groups into dropdowns progressively as width shrinks:
- Level 1 (<480px): block elements (quote, hr, table, codeblock)
- Level 2 (<390px): + lists (bullet, ordered, task)
- Level 3 (<300px): + text formatting (bold, italic, etc.)
Most-used tools stay visible longest.
…ewer

CM selections highlight corresponding blocks in the md viewer, and
md viewer selections set the matching text selection in CM. Clicking
without selecting clears both sides. Uses data-source-line mapping
with text-matching refinement, debounced at 200ms.
Enable checkboxes in edit mode (marked renders them disabled), handle
click events to trigger content change, sync checked property to HTML
attribute before cloning for Turndown conversion, and relax Turndown
filter to match checkboxes inside any <li> (not just .task-list-item).
… switching

Implement a two-tier document cache inside the mdviewer iframe:
- Working set files: always cached (unlimited, mirrors Phoenix)
- Non-working-set files: LRU cache (max 20 entries)

Each cached file gets its own DOM element. File switching is now a
hide/show operation with zero re-rendering or layout reflow. The md
iframe persists across MD-to-HTML preview switches (hidden, not destroyed).

Key changes:
- New doc-cache.js module manages DOM pool with id="viewer-content" swap trick
- bridge.js handles SWITCH_FILE, CLEAR_CACHE, WORKING_SET_CHANGED messages
- MarkdownSync.js sends SWITCH_FILE on iframe reuse, includes filePath
- main.js keeps $mdviewrIframe persistent, syncs working set changes
- viewer.js and editor.js use dynamic element lookup
Phoenix sends full locale like "en-US" but locale files are named "en.json".
Strip to base language to avoid "Failed to load locale" warnings.
- Reload button clears file cache and re-renders, restoring scroll
  position and edit mode
- Scroll save/restore now uses data-source-line elements instead of
  pixel positions, making it immune to image loading layout shifts
Parameterize translateStrings.js to support both Phoenix NLS (define-wrapped JS)
and mdviewer (flat JSON) formats. translateMdviewer() flattens en.json to
root/strings.json, translates all locales, then unflattens back to locales/.
i18n.js updated to handle region-code locales with fallback.
Stage md-nls-autogenerated and locales folders in translation PR,
check both error files, and gitignore mdviewer errors.txt.
…ortcut tooltip

Edit mode is now preserved globally when switching files instead of
per-document. Added ({mod}+U) shortcut to underline tooltip string.
…lapse thresholds

- Rename Done button to Reader with book-open icon and title
- Add Switch to edit mode title on Edit button
- Remove unused toolbar.done string
- Add toolbar.reader, toolbar.switch_to_reader, toolbar.switch_to_edit strings
- Raise collapse thresholds by +100px (640/520/420) to prevent clipping
When blanking the iframe on panel hide, deactivate MarkdownSync and
reset _isMdviewrActive so the next panel show properly reuses the
persistent md iframe instead of activating on the blank iframe.
abose added 18 commits March 29, 2026 22:36
…mode on file switch

Replaced inline markdown in MDVIEWR_SET_EDIT_MODE with a dedicated
MDVIEWR_RERENDER_CONTENT message sent only when user switches to reader.
Prevents edit mode from being reset during file switches.
- Add printer icon button to both reader and edit mode toolbars
- Force light theme CSS variables in @media print for readable output
- Hide toolbar and all UI chrome during print via CSS
- Override height/overflow constraints so full content prints
- Add allow-modals to mdviewer sandbox for window.print()
- Standard page margins (20mm top/bottom, 25mm left/right)
- Add ImageUploadManager service provider registry in core
- Add paste interceptor on editor-holder that detects image clipboard
  content in markdown/gfm files and triggers upload flow
- Insert uploading placeholder with spinner SVG, replace with embed URL
- Show login dialog with image preview if not logged in
- Show upsell dialog if upload quota exceeded
- Add i18n strings for upload dialogs and messages
- Remove image border from embed/login preview dialogs (dark theme fix)
- Use DOM paste listener instead of modifying ChangeHelper interceptor
- Add IMAGE_UPLOAD_UNSUPPORTED_TYPE and IMAGE_UPLOAD_LIMIT_TITLE strings
- Use uploading.svg placeholder during upload
- Fix language check to include gfm
…roll jump

Replace full innerHTML replacement with morphdom diffing so unchanged
elements (images, code blocks) stay in the DOM during CM→preview sync.
Prevents image reloads and layout shifts when editing text.
…d paste

- Add image dropdown in toolbar with "Image URL" and "Upload from Computer"
- Add two image items to slash menu (image URL and upload)
- Add image URL dialog with URL and alt text inputs
- Add file picker for image upload from computer
- Intercept image paste in mdviewer editor
- Forward image blobs to Phoenix via bridge for cloud upload
- Handle upload results (replace placeholder with embed URL)
- Add menu-style dropdown CSS with left-aligned icon + text items
- Collapse image section with blocks at narrow widths
…e DOM ids

The mdviewer iframe now uses id="panel-md-preview-frame" instead of
sharing "panel-live-preview-frame" with the HTML preview iframe.
Fixes duplicate ID causing test failures in Device Size & Resize Ruler.
…itecture

- Check panel-md-preview-frame and query __getActiveFilePath() for md files
- Add null guards to _waitForIframeURL and _forSVGLivePreview for iframe
  transitions between md and HTML preview
- Expose __getActiveFilePath on mdviewer window for test access
- Add /proxy/assets/* route to serve-proxy.js proxying to assets.phcode.dev
- Tag proxy requests with _proxyTarget so Host header matches the target
- Declare ASSETS_SERVER constant for the assets base URL
handleClearCache no longer resets editMode to false. The cache is
cleared on project switch but the user's edit/reader mode persists.
…vent reload

Save existing <img> elements by src before innerHTML replacement, then
swap them back in after. The browser keeps the same nodes with cached
image data, preventing re-fetches and flicker when editing text in CM.
Replaces morphdom with simpler innerHTML + image node preservation.
Replace images with placeholder spans before morphdom runs so the diff
engine never touches them. After morphdom inserts new img nodes, swap
the saved originals back in. Prevents GIF restart and image flicker.
Contenteditable jumps to container end/start instead of block end/start
when cursor is near an <img>. Intercept End/Home (and Cmd+Right/Left
on Mac) to manually position cursor at block boundary.
- Add helpers to find md/lp iframes by their distinct IDs
- Check md iframe visibility instead of src for markdown file detection
- Add _ensureMdReaderMode to switch md viewer to reader mode in tests
- Add _TEST_FOCUS_CLICK and _TEST_SELECT_TEXT_AND_CLICK handlers to
  mdviewer bridge for integration test support
Copy link
Copy Markdown

@github-advanced-security github-advanced-security bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SonarCloud found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

if (!placeholder) return;

if (embedURL) {
placeholder.src = embedURL;

Check failure

Code scanning / CodeQL

Client-side cross-site scripting High

Cross-site scripting vulnerability due to
user-provided value
.

Copilot Autofix

AI 27 minutes ago

In general, to fix this kind of issue you must not assign untrusted strings directly to dangerous DOM properties (src, href, innerHTML, etc.) without validation. Either (a) validate and constrain the value to a safe subset (e.g., only allow HTTPS URLs from trusted hosts) or (b) encode/escape it and insert it only into safe contexts.

The best minimal fix here is to validate embedURL before assigning it to placeholder.src. Since we cannot modify other files, we add a small helper in bridge.js that checks the URL using the built-in URL constructor and only allows safe protocols such as http: and https: (and optionally same-origin relative URLs). _handleImageUploadResult will call this helper; if validation fails, we treat it like an error (remove the placeholder instead of embedding a potentially dangerous URL). This preserves existing behavior for legitimate URLs while blocking attacker-controlled schemes like javascript: or data:.

Concretely:

  • In src-mdviewer/src/bridge.js, above _handleImageUploadResult, define a function sanitizeEmbedUrl(rawUrl) that:
    • Returns null if rawUrl is falsy.
    • Attempts to construct a URL object, using window.location.href as base for relative URLs.
    • Only returns the URL’s href if its protocol is in an allowed list (http: and https:). Otherwise returns null.
  • In _handleImageUploadResult, replace use of embedURL with the sanitized result:
    • Compute const safeEmbedURL = sanitizeEmbedUrl(embedURL);
    • Change the condition from if (embedURL) to if (safeEmbedURL) and assign placeholder.src = safeEmbedURL;.
  • No new external dependencies or imports are required; we rely on the built-in URL class.
Suggested changeset 1
src-mdviewer/src/bridge.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js
--- a/src-mdviewer/src/bridge.js
+++ b/src-mdviewer/src/bridge.js
@@ -656,6 +656,27 @@
     emit("file:rendered", parseResult);
 }
 
+/**
+ * Sanitize an embed URL coming from postMessage data.
+ * Only allow http/https URLs (including relative URLs that resolve to those).
+ * Returns a safe URL string, or null if the URL is invalid or uses a disallowed scheme.
+ */
+function sanitizeEmbedUrl(rawUrl) {
+    if (!rawUrl || typeof rawUrl !== "string") {
+        return null;
+    }
+    try {
+        const url = new URL(rawUrl, window.location.href);
+        const allowedProtocols = new Set(["http:", "https:"]);
+        if (!allowedProtocols.has(url.protocol)) {
+            return null;
+        }
+        return url.href;
+    } catch (e) {
+        return null;
+    }
+}
+
 function _handleImageUploadResult(data) {
     const { uploadId, embedURL, error } = data;
     const content = document.getElementById("viewer-content");
@@ -663,8 +684,10 @@
     const placeholder = content.querySelector(`img[data-upload-id="${uploadId}"]`);
     if (!placeholder) return;
 
-    if (embedURL) {
-        placeholder.src = embedURL;
+    const safeEmbedURL = sanitizeEmbedUrl(embedURL);
+
+    if (safeEmbedURL) {
+        placeholder.src = safeEmbedURL;
         placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt;
         placeholder.removeAttribute("data-upload-id");
     } else {
EOF
@@ -656,6 +656,27 @@
emit("file:rendered", parseResult);
}

/**
* Sanitize an embed URL coming from postMessage data.
* Only allow http/https URLs (including relative URLs that resolve to those).
* Returns a safe URL string, or null if the URL is invalid or uses a disallowed scheme.
*/
function sanitizeEmbedUrl(rawUrl) {
if (!rawUrl || typeof rawUrl !== "string") {
return null;
}
try {
const url = new URL(rawUrl, window.location.href);
const allowedProtocols = new Set(["http:", "https:"]);
if (!allowedProtocols.has(url.protocol)) {
return null;
}
return url.href;
} catch (e) {
return null;
}
}

function _handleImageUploadResult(data) {
const { uploadId, embedURL, error } = data;
const content = document.getElementById("viewer-content");
@@ -663,8 +684,10 @@
const placeholder = content.querySelector(`img[data-upload-id="${uploadId}"]`);
if (!placeholder) return;

if (embedURL) {
placeholder.src = embedURL;
const safeEmbedURL = sanitizeEmbedUrl(embedURL);

if (safeEmbedURL) {
placeholder.src = safeEmbedURL;
placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt;
placeholder.removeAttribute("data-upload-id");
} else {
Copilot is powered by AI and may make mistakes. Always verify output.
]);

function sanitizePastedHTML(html) {
const doc = new DOMParser().parseFromString(html, "text/html");

Check failure

Code scanning / CodeQL

Client-side cross-site scripting High

Cross-site scripting vulnerability due to
user-provided value
.

Copilot Autofix

AI 27 minutes ago

To fix the problem, keep sanitizing the pasted HTML but additionally validate and normalize any URL-bearing attributes (href, src) before allowing them, and remove or neutralize any values with dangerous schemes (like javascript:, vbscript:, data: for non-images, etc.). This keeps existing functionality (pasting formatted content with links and images) while blocking XSS vectors.

Concretely, in src-mdviewer/src/components/editor.js, inside sanitizePastedHTML, we should:

  1. Add a small helper to determine whether a URL is safe:
    • Allow only certain schemes: empty/relative URLs, http:, https:, mailto: (and optionally others you deem safe).
    • Reject anything starting with javascript:, vbscript:, or other non-HTTP(S) schemes.
  2. When iterating over attributes:
    • Still preserve alt as harmless.
    • For href and src, only keep them if they pass the new URL validation; otherwise remove those attributes.
    • We can still limit attributes to the allowlist as currently done.
  3. Optionally, we can further constrain IMG src to http/https and safe data:image/... URLs, but at minimum we must remove javascript: and similar.

Implementation details:

  • Add a small function isSafeUrl(url) above or inside sanitizePastedHTML. Since we must only edit the provided snippet, define it just above sanitizePastedHTML in editor.js. Use standard string checks and URL constructor where possible, but fall back gracefully on exceptions.
  • Modify the block that currently keeps href, src, alt unconditionally:
if (attr.name === "href" || attr.name === "src" || attr.name === "alt") continue;

to:

  • Always keep alt.
  • For href / src, call isSafeUrl(attr.value) and only keep them if that returns true; otherwise remove the attribute.

No new external dependencies are necessary; we can use built-in URL and basic string operations.

Suggested changeset 1
src-mdviewer/src/components/editor.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src-mdviewer/src/components/editor.js b/src-mdviewer/src/components/editor.js
--- a/src-mdviewer/src/components/editor.js
+++ b/src-mdviewer/src/components/editor.js
@@ -1123,6 +1123,28 @@
     "BR", "HR", "IMG", "DEL", "S", "INPUT", "DIV"
 ]);
 
+function isSafeUrl(url) {
+    if (!url) return false;
+    const trimmed = url.trim();
+    // Disallow obvious javascript/vbscript/data/code-like URLs
+    const lower = trimmed.toLowerCase();
+    if (lower.startsWith("javascript:") || lower.startsWith("vbscript:")) {
+        return false;
+    }
+    // Allow fragment-only, query-only, and relative URLs
+    if (trimmed.startsWith("#") || trimmed.startsWith("?") || trimmed.startsWith("/")) {
+        return true;
+    }
+    try {
+        const parsed = new URL(trimmed, window.location.origin);
+        const protocol = parsed.protocol;
+        return protocol === "http:" || protocol === "https:" || protocol === "mailto:";
+    } catch {
+        // If URL constructor fails, treat as unsafe
+        return false;
+    }
+}
+
 function sanitizePastedHTML(html) {
     const doc = new DOMParser().parseFromString(html, "text/html");
     doc.querySelectorAll("o\\:p, style, script, meta, link, xml").forEach((el) => el.remove());
@@ -1142,7 +1164,8 @@
             for (const attr of attrs) {
                 if (tag === "INPUT" && (attr.name === "type" || attr.name === "checked")) continue;
                 if (tag === "PRE" && attr.name === "data-language") continue;
-                if (attr.name === "href" || attr.name === "src" || attr.name === "alt") continue;
+                if (attr.name === "alt") continue;
+                if ((attr.name === "href" || attr.name === "src") && isSafeUrl(attr.value)) continue;
                 node.removeAttribute(attr.name);
             }
         }
EOF
@@ -1123,6 +1123,28 @@
"BR", "HR", "IMG", "DEL", "S", "INPUT", "DIV"
]);

function isSafeUrl(url) {
if (!url) return false;
const trimmed = url.trim();
// Disallow obvious javascript/vbscript/data/code-like URLs
const lower = trimmed.toLowerCase();
if (lower.startsWith("javascript:") || lower.startsWith("vbscript:")) {
return false;
}
// Allow fragment-only, query-only, and relative URLs
if (trimmed.startsWith("#") || trimmed.startsWith("?") || trimmed.startsWith("/")) {
return true;
}
try {
const parsed = new URL(trimmed, window.location.origin);
const protocol = parsed.protocol;
return protocol === "http:" || protocol === "https:" || protocol === "mailto:";
} catch {
// If URL constructor fails, treat as unsafe
return false;
}
}

function sanitizePastedHTML(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll("o\\:p, style, script, meta, link, xml").forEach((el) => el.remove());
@@ -1142,7 +1164,8 @@
for (const attr of attrs) {
if (tag === "INPUT" && (attr.name === "type" || attr.name === "checked")) continue;
if (tag === "PRE" && attr.name === "data-language") continue;
if (attr.name === "href" || attr.name === "src" || attr.name === "alt") continue;
if (attr.name === "alt") continue;
if ((attr.name === "href" || attr.name === "src") && isSafeUrl(attr.value)) continue;
node.removeAttribute(attr.name);
}
}
Copilot is powered by AI and may make mistakes. Always verify output.
const { svg } = await mermaid.render(id, trimmed);

const temp = document.createElement("div");
temp.innerHTML = svg;

Check failure

Code scanning / CodeQL

DOM text reinterpreted as HTML High

DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.

Copilot Autofix

AI about 12 hours ago

In general, to fix “DOM text reinterpreted as HTML” issues, you should avoid feeding untrusted strings directly into innerHTML or similar DOM sinks. Either (1) sanitize the HTML using a robust library before assigning it, or (2) use DOM APIs that treat the data as text (like textContent) or as data within a constrained context (e.g., SVG parsing) rather than as free-form HTML.

For this specific file, the vulnerable line is:

const temp = document.createElement("div");
temp.innerHTML = svg;
const svgEl = temp.querySelector("svg");

The rest of the code then only uses svgEl. A minimal, behavior-preserving hardening is to sanitize svg before putting it into innerHTML. The most standard way in front‑end JS is to use DOMPurify, which is well-known and specifically designed to sanitize HTML/SVG. Since we are allowed to import well-known external libraries, we can:

  1. Add import DOMPurify from "dompurify"; at the top of src-mdviewer/src/components/mermaid-editor.js.
  2. Replace temp.innerHTML = svg; with temp.innerHTML = DOMPurify.sanitize(svg, {USE_PROFILES: {svg: true}});. This keeps only safe SVG constructs and strips scripts/unsafe URLs/etc., addressing all variants where the tainted source/textarea.value ultimately flows into this innerHTML.
  3. No other functionality changes are needed; the code still parses the sanitized SVG into a temporary container, selects the <svg> element, applies accessibility attributes, and injects it into the preview.

This modification is entirely local to the shown region and doesn’t change semantics except for dropping potentially unsafe content that Mermaid might emit under attacker control.

Suggested changeset 2
src-mdviewer/src/components/mermaid-editor.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src-mdviewer/src/components/mermaid-editor.js b/src-mdviewer/src/components/mermaid-editor.js
--- a/src-mdviewer/src/components/mermaid-editor.js
+++ b/src-mdviewer/src/components/mermaid-editor.js
@@ -10,6 +10,7 @@
 import { on, off } from "../core/events.js";
 import { t } from "../core/i18n.js";
 import { flushSnapshot } from "./editor.js";
+import DOMPurify from "dompurify";
 
 const devLog = import.meta.env.DEV ? console.log.bind(console, "[mermaid-editor]") : () => {};
 
@@ -221,7 +222,8 @@
         const { svg } = await mermaid.render(id, trimmed);
 
         const temp = document.createElement("div");
-        temp.innerHTML = svg;
+        const safeSvg = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
+        temp.innerHTML = safeSvg;
         const svgEl = temp.querySelector("svg");
 
         if (svgEl) {
EOF
@@ -10,6 +10,7 @@
import { on, off } from "../core/events.js";
import { t } from "../core/i18n.js";
import { flushSnapshot } from "./editor.js";
import DOMPurify from "dompurify";

const devLog = import.meta.env.DEV ? console.log.bind(console, "[mermaid-editor]") : () => {};

@@ -221,7 +222,8 @@
const { svg } = await mermaid.render(id, trimmed);

const temp = document.createElement("div");
temp.innerHTML = svg;
const safeSvg = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
temp.innerHTML = safeSvg;
const svgEl = temp.querySelector("svg");

if (svgEl) {
src-mdviewer/package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src-mdviewer/package.json b/src-mdviewer/package.json
--- a/src-mdviewer/package.json
+++ b/src-mdviewer/package.json
@@ -23,6 +23,7 @@
         "morphdom": "^2.7.8",
         "prismjs": "^1.29.0",
         "turndown": "^7.2.0",
-        "turndown-plugin-gfm": "^1.0.2"
+        "turndown-plugin-gfm": "^1.0.2",
+        "dompurify": "^3.3.3"
     }
 }
EOF
@@ -23,6 +23,7 @@
"morphdom": "^2.7.8",
"prismjs": "^1.29.0",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2"
"turndown-plugin-gfm": "^1.0.2",
"dompurify": "^3.3.3"
}
}
This fix introduces these dependencies
Package Version Security advisories
dompurify (npm) 3.3.3 None
Copilot is powered by AI and may make mistakes. Always verify output.
const { svg } = await mermaid.render(id, source);

const temp = document.createElement("div");
temp.innerHTML = svg;

Check failure

Code scanning / CodeQL

DOM text reinterpreted as HTML High

DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.

Copilot Autofix

AI about 12 hours ago

General approach: Avoid using innerHTML to convert an SVG string into DOM nodes. Instead, parse the SVG string into a Document using DOMParser with the "image/svg+xml" MIME type, and extract the <svg> element from that parsed document. This way, the browser treats the string as SVG markup only, not as generic HTML that could include script elements or event handlers outside of SVG context. This also resolves CodeQL’s concern about DOM text being reinterpreted as HTML.

Concrete fix:

  1. In reRenderDiagram (around lines 296–299), replace:

    const temp = document.createElement("div");
    temp.innerHTML = svg;
    const svgEl = temp.querySelector("svg");

    with code that uses DOMParser:

    const parser = new DOMParser();
    const parsed = parser.parseFromString(svg, "image/svg+xml");
    const svgEl = parsed.documentElement && parsed.documentElement.tagName.toLowerCase() === "svg"
        ? parsed.documentElement
        : parsed.querySelector("svg");

    This preserves functionality (we still obtain an <svg> element) but avoids innerHTML.

  2. In closeEditor (lines 268–273), similar pattern: instead of using temp.innerHTML = lastValidSvg, parse the saved SVG string with DOMParser and append the resulting <svg> node:

    const parser = new DOMParser();
    const parsed = parser.parseFromString(lastValidSvg, "image/svg+xml");
    const svgEl = parsed.documentElement && parsed.documentElement.tagName.toLowerCase() === "svg"
        ? parsed.documentElement
        : parsed.querySelector("svg");

    Then append this svgEl to diagram.

No additional imports are needed; DOMParser is a standard Web API. We keep all other behavior intact: the editor still restores lastValidSvg, still applies accessibility settings, and still re-renders diagrams.

Suggested changeset 1
src-mdviewer/src/components/mermaid-editor.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src-mdviewer/src/components/mermaid-editor.js b/src-mdviewer/src/components/mermaid-editor.js
--- a/src-mdviewer/src/components/mermaid-editor.js
+++ b/src-mdviewer/src/components/mermaid-editor.js
@@ -265,9 +265,12 @@
     }
 
     if (lastValidSvg) {
-        const temp = document.createElement("div");
-        temp.innerHTML = lastValidSvg;
-        const svgEl = temp.querySelector("svg");
+        const parser = new DOMParser();
+        const parsed = parser.parseFromString(lastValidSvg, "image/svg+xml");
+        const svgEl =
+            parsed.documentElement && parsed.documentElement.tagName.toLowerCase() === "svg"
+                ? parsed.documentElement
+                : parsed.querySelector("svg");
         if (svgEl) {
             diagram.appendChild(svgEl);
         }
@@ -293,9 +296,12 @@
         const id = `mermaid-rerender-${Date.now()}-${idCounter++}`;
         const { svg } = await mermaid.render(id, source);
 
-        const temp = document.createElement("div");
-        temp.innerHTML = svg;
-        const svgEl = temp.querySelector("svg");
+        const parser = new DOMParser();
+        const parsed = parser.parseFromString(svg, "image/svg+xml");
+        const svgEl =
+            parsed.documentElement && parsed.documentElement.tagName.toLowerCase() === "svg"
+                ? parsed.documentElement
+                : parsed.querySelector("svg");
 
         if (svgEl) {
             const acc = parseAccessibility(source);
EOF
@@ -265,9 +265,12 @@
}

if (lastValidSvg) {
const temp = document.createElement("div");
temp.innerHTML = lastValidSvg;
const svgEl = temp.querySelector("svg");
const parser = new DOMParser();
const parsed = parser.parseFromString(lastValidSvg, "image/svg+xml");
const svgEl =
parsed.documentElement && parsed.documentElement.tagName.toLowerCase() === "svg"
? parsed.documentElement
: parsed.querySelector("svg");
if (svgEl) {
diagram.appendChild(svgEl);
}
@@ -293,9 +296,12 @@
const id = `mermaid-rerender-${Date.now()}-${idCounter++}`;
const { svg } = await mermaid.render(id, source);

const temp = document.createElement("div");
temp.innerHTML = svg;
const svgEl = temp.querySelector("svg");
const parser = new DOMParser();
const parsed = parser.parseFromString(svg, "image/svg+xml");
const svgEl =
parsed.documentElement && parsed.documentElement.tagName.toLowerCase() === "svg"
? parsed.documentElement
: parsed.querySelector("svg");

if (svgEl) {
const acc = parseAccessibility(source);
Copilot is powered by AI and may make mistakes. Always verify output.

// Parse SVG and apply accessibility
const temp = document.createElement("div");
temp.innerHTML = svg;

Check failure

Code scanning / CodeQL

DOM text reinterpreted as HTML High

DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.

Copilot Autofix

AI about 12 hours ago

General fix: avoid using innerHTML with data derived from untrusted content. Instead, parse and insert the SVG in a way that does not execute arbitrary HTML/JS. The best option in this context is to use DOMParser with the "image/svg+xml" MIME type and then extract the <svg> element from the resulting document. This avoids going through HTML parsing and prevents things like <script> tags or other non-SVG HTML from being interpreted in the HTML context.

Concrete change:

  • In both places where svg is currently handled:

    const temp = document.createElement("div");
    temp.innerHTML = svg;
    const svgEl = temp.querySelector("svg");

    replace this with code using DOMParser:

    const parser = new DOMParser();
    const doc = parser.parseFromString(svg, "image/svg+xml");
    const svgEl = doc.querySelector("svg");
  • This stays within the same file src-mdviewer/src/core/mermaid-render.js and does not alter any external behavior other than how the SVG string is turned into a DOM node. The rest of the logic (accessibility, animations, error handling) remains unchanged.

  • DOMParser is a standard Web API; no imports are needed. We also do not need to hold a shared parser instance; creating it where needed keeps changes minimal and clear.

This single change removes the use of .innerHTML for tainted content and therefore addresses all variants of the alert, since both rendering paths use the same pattern.

Suggested changeset 1
src-mdviewer/src/core/mermaid-render.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src-mdviewer/src/core/mermaid-render.js b/src-mdviewer/src/core/mermaid-render.js
--- a/src-mdviewer/src/core/mermaid-render.js
+++ b/src-mdviewer/src/core/mermaid-render.js
@@ -180,9 +180,9 @@
       devLog("render success:", id);
 
       // Parse SVG and apply accessibility
-      const temp = document.createElement("div");
-      temp.innerHTML = svg;
-      const svgEl = temp.querySelector("svg");
+      const parser = new DOMParser();
+      const doc = parser.parseFromString(svg, "image/svg+xml");
+      const svgEl = doc.querySelector("svg");
 
       if (svgEl) {
         const acc = parseAccessibility(source);
@@ -239,9 +239,9 @@
     try {
       const { svg } = await mermaid.render(id, source);
 
-      const temp = document.createElement("div");
-      temp.innerHTML = svg;
-      const svgEl = temp.querySelector("svg");
+      const parser = new DOMParser();
+      const doc = parser.parseFromString(svg, "image/svg+xml");
+      const svgEl = doc.querySelector("svg");
 
       if (svgEl) {
         const acc = parseAccessibility(source);
EOF
@@ -180,9 +180,9 @@
devLog("render success:", id);

// Parse SVG and apply accessibility
const temp = document.createElement("div");
temp.innerHTML = svg;
const svgEl = temp.querySelector("svg");
const parser = new DOMParser();
const doc = parser.parseFromString(svg, "image/svg+xml");
const svgEl = doc.querySelector("svg");

if (svgEl) {
const acc = parseAccessibility(source);
@@ -239,9 +239,9 @@
try {
const { svg } = await mermaid.render(id, source);

const temp = document.createElement("div");
temp.innerHTML = svg;
const svgEl = temp.querySelector("svg");
const parser = new DOMParser();
const doc = parser.parseFromString(svg, "image/svg+xml");
const svgEl = doc.querySelector("svg");

if (svgEl) {
const acc = parseAccessibility(source);
Copilot is powered by AI and may make mistakes. Always verify output.
const { svg } = await mermaid.render(id, source);

const temp = document.createElement("div");
temp.innerHTML = svg;

Check failure

Code scanning / CodeQL

DOM text reinterpreted as HTML High

DOM text
is reinterpreted as HTML without escaping meta-characters.

Copilot Autofix

AI about 12 hours ago

In general, the fix is to avoid assigning untrusted or tainted strings directly to innerHTML. Instead, create DOM nodes in a way that does not reinterpret the string as HTML, or use a parsing mechanism that is restricted to SVG/XML and does not execute scripts or event handlers.

In this specific code, we only need the <svg> element from the svg string returned by Mermaid. We can obtain that <svg> element without using innerHTML by using DOMParser with the "image/svg+xml" MIME type; this parses the string as SVG/XML rather than HTML and gives us a document whose root is an <svg> element. We can then import or clone that node into the current document and append it to wrapper. This preserves existing functionality (we still render the exact same SVG), while removing the innerHTML use that CodeQL flags.

Concretely, in src-mdviewer/src/core/mermaid-render.js, within reRenderMermaidBlocks:

  • Replace the creation of temp and temp.innerHTML = svg; / temp.querySelector("svg") with a DOMParser call:
    const parser = new DOMParser(); const svgDoc = parser.parseFromString(svg, "image/svg+xml"); const svgEl = svgDoc.documentElement;.
  • Optionally, guard that svgEl is indeed an SVGSVGElement (its tagName equals "svg" ignoring case) before using it.
  • Keep the rest of the logic (accessibility application, clearing wrapper.innerHTML, appending the SVG) unchanged.

No new external npm dependencies are required; DOMParser is a standard Web API available in browsers.

Suggested changeset 1
src-mdviewer/src/core/mermaid-render.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src-mdviewer/src/core/mermaid-render.js b/src-mdviewer/src/core/mermaid-render.js
--- a/src-mdviewer/src/core/mermaid-render.js
+++ b/src-mdviewer/src/core/mermaid-render.js
@@ -239,9 +239,11 @@
     try {
       const { svg } = await mermaid.render(id, source);
 
-      const temp = document.createElement("div");
-      temp.innerHTML = svg;
-      const svgEl = temp.querySelector("svg");
+      const parser = new DOMParser();
+      const svgDoc = parser.parseFromString(svg, "image/svg+xml");
+      const svgEl = svgDoc.documentElement && svgDoc.documentElement.tagName.toLowerCase() === "svg"
+        ? svgDoc.documentElement
+        : null;
 
       if (svgEl) {
         const acc = parseAccessibility(source);
EOF
@@ -239,9 +239,11 @@
try {
const { svg } = await mermaid.render(id, source);

const temp = document.createElement("div");
temp.innerHTML = svg;
const svgEl = temp.querySelector("svg");
const parser = new DOMParser();
const svgDoc = parser.parseFromString(svg, "image/svg+xml");
const svgEl = svgDoc.documentElement && svgDoc.documentElement.tagName.toLowerCase() === "svg"
? svgDoc.documentElement
: null;

if (svgEl) {
const acc = parseAccessibility(source);
Copilot is powered by AI and may make mistakes. Always verify output.
if (!placeholder) return;

if (embedURL) {
placeholder.src = embedURL;

Check warning

Code scanning / CodeQL

Client-side URL redirect Medium

Untrusted URL redirection depends on a
user-provided value
.

Copilot Autofix

AI 27 minutes ago

In general, to fix this issue, we need to ensure that the URL assigned to placeholder.src is not fully attacker-controlled. This can be done by: (1) authenticating the message source (checking event.origin and/or a shared secret in event.data); and/or (2) validating embedURL against a whitelist of allowed schemes or patterns before using it.

The least invasive, functionality-preserving fix here is to validate embedURL before assigning it to placeholder.src. A robust approach is to parse the URL via the built-in URL constructor and enforce a whitelist of safe protocols, e.g., allow only https:, http: (if you must), data: (for embedded images), and perhaps blob:. If parsing fails or the protocol is not allowed, treat it as an error and remove the placeholder instead of setting the src. This preserves the existing behavior for valid URLs while preventing redirection/loading of arbitrary, potentially dangerous schemes (like javascript:).

Concretely, in src-mdviewer/src/bridge.js, inside _handleImageUploadResult, modify the block that currently does:

if (embedURL) {
    placeholder.src = embedURL;
    placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt;
    placeholder.removeAttribute("data-upload-id");
} else {
    placeholder.remove();
}

Change it so that:

  1. If embedURL is falsy, keep removing the placeholder as before.
  2. If embedURL is truthy, attempt to construct a new URL(embedURL, window.location.origin):
    • On error (invalid URL), remove the placeholder and return.
  3. Check url.protocol against a small whitelist, e.g. ["https:", "http:", "data:", "blob:"].
    • If not allowed, remove the placeholder and return.
  4. Only then set placeholder.src = embedURL and proceed with updating alt and removing data-upload-id.

No new imports are needed because URL is a standard Web API. This keeps the protocol of the image under control while minimally impacting existing flows where embedURL is already a valid, safe URL.


Suggested changeset 1
src-mdviewer/src/bridge.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js
--- a/src-mdviewer/src/bridge.js
+++ b/src-mdviewer/src/bridge.js
@@ -664,9 +664,24 @@
     if (!placeholder) return;
 
     if (embedURL) {
-        placeholder.src = embedURL;
-        placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt;
-        placeholder.removeAttribute("data-upload-id");
+        let isSafe = false;
+        try {
+            const parsed = new URL(embedURL, window.location.origin);
+            const allowedProtocols = ["https:", "http:", "data:", "blob:"];
+            if (allowedProtocols.includes(parsed.protocol)) {
+                isSafe = true;
+            }
+        } catch (e) {
+            isSafe = false;
+        }
+
+        if (isSafe) {
+            placeholder.src = embedURL;
+            placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt;
+            placeholder.removeAttribute("data-upload-id");
+        } else {
+            placeholder.remove();
+        }
     } else {
         placeholder.remove();
     }
EOF
@@ -664,9 +664,24 @@
if (!placeholder) return;

if (embedURL) {
placeholder.src = embedURL;
placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt;
placeholder.removeAttribute("data-upload-id");
let isSafe = false;
try {
const parsed = new URL(embedURL, window.location.origin);
const allowedProtocols = ["https:", "http:", "data:", "blob:"];
if (allowedProtocols.includes(parsed.protocol)) {
isSafe = true;
}
} catch (e) {
isSafe = false;
}

if (isSafe) {
placeholder.src = embedURL;
placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt;
placeholder.removeAttribute("data-upload-id");
} else {
placeholder.remove();
}
} else {
placeholder.remove();
}
Copilot is powered by AI and may make mistakes. Always verify output.
abose added 8 commits March 29, 2026 23:55
…k popover

- Add trash icon remove link button in edit mode
- Move close (×) to top-right corner of edit popover
- Use trash icon for unlink in view mode
- Fix unlink by directly unwrapping <a> instead of execCommand("unlink")
- New test file md-editor-integ-test.js with 14 tests for keyboard shortcuts
- Tests: Ctrl+S (save), Ctrl+Shift+F (find), Ctrl+B/I/U (formatting),
  Ctrl+Shift+X (strikethrough), Ctrl+K (link), Ctrl+Z/Y/Shift+Z (undo/redo),
  Ctrl+A (select all), Escape (focus), F8 (edit+reader mode)
- Add Turndown rules for <u> and <s>/<strike>/<del> to preserve formatting
- Add test helpers: __setEditModeForTest, __isSuppressingContentChange,
  __triggerContentSync on mdviewer window for test access
- Add sample.md and test-shortcuts.md test fixture files
- Mark completed tests in to-create-tests.md
- Block bold command in headings (headings are inherently bold)
- Disable bold button in toolbar and format bar when cursor is in heading
- Add Turndown rules for <u> and <s>/<strike>/<del> to preserve formatting
- Add test helpers: __isSuppressingContentChange, __triggerContentSync
- Fix formatting tests to wait for content sync to settle before applying
- Revert undo/redo tests to message-forwarding approach (more reliable)
- 14/14 md editor integration tests passing
Replace direct require of phoenix-pro/pro-dialogs in MarkdownSync with
EventManager event dispatch to preserve extension isolation. Add
PRO_DIALOGS_EVENT_MANAGER constant in Dialogs.js as single source of truth.
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
D Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant