From f6c5d1a4a9ea6a61c8b0fbf212db4390f76a31cb Mon Sep 17 00:00:00 2001 From: rotioki <2431311634@qq.com> Date: Sun, 12 Jul 2026 19:26:01 +0800 Subject: [PATCH 1/8] fix(live-preview): resolve image ranges from the AST, not a regex scan The live preview located images with a document-wide regex, blind to parse context. Three user-visible bugs follow from that root cause: - [![alt](img)](url) (e.g. README CI badges): the regex image range overlapped the link decoration, and the link marker arithmetic split at the first ](, garbling the label and the navigation URL. Links whose label contains an image now render as a clickable image widget. - Image syntax inside fenced code blocks / inline code was rendered as a real image instead of staying literal text. - URLs with balanced parentheses (.../Foo_(bar)) were truncated at the first ), breaking the image and leaving a stray paren. Images are now emitted by the mdast walk like every other inline node. The Lezer adapter preserves the image title so tooltip-dependent renderers keep working, and reference-style images without an inline URL stay raw text instead of becoming an empty-src widget. Co-Authored-By: Claude Fable 5 --- packages/core/src/lezer-mdast-adapter.ts | 8 +- packages/core/src/live-preview-ranges.ts | 39 +++------- packages/core/src/live-preview.ts | 41 ++++++++++- .../core/test/live-preview-ranges.test.ts | 57 +++++++++++++++ .../test/live-preview-regressions.test.ts | 73 +++++++++++++++++++ 5 files changed, 187 insertions(+), 31 deletions(-) diff --git a/packages/core/src/lezer-mdast-adapter.ts b/packages/core/src/lezer-mdast-adapter.ts index fbd6edad..fbee4f28 100644 --- a/packages/core/src/lezer-mdast-adapter.ts +++ b/packages/core/src/lezer-mdast-adapter.ts @@ -361,6 +361,12 @@ function adaptInlineNode(source: Source, node: SyntaxNode): PhrasingContent | nu case "Image": { const url = findChildByName(node, "URL"); const urlText = url ? readSlice(source, url.from, url.to) : ""; + // Title is delimited by "…", '…' or (…) per CommonMark — strip one + // matching pair so consumers get the bare text (e.g. for tooltips). + const titleNode = findChildByName(node, "LinkTitle"); + const titleText = titleNode + ? readSlice(source, titleNode.from, titleNode.to).replace(/^(["'(])([\s\S]*)(["')])$/, "$2") + : null; // Image label sits between `![` and `]` — same structure as Link. let alt = ""; const first = node.firstChild; @@ -380,7 +386,7 @@ function adaptInlineNode(source: Source, node: SyntaxNode): PhrasingContent | nu type: "image", url: urlText, alt, - title: null, + title: titleText, position: position(node.from, node.to), }; return out; diff --git a/packages/core/src/live-preview-ranges.ts b/packages/core/src/live-preview-ranges.ts index 7c6736db..78f29440 100644 --- a/packages/core/src/live-preview-ranges.ts +++ b/packages/core/src/live-preview-ranges.ts @@ -23,6 +23,7 @@ function isLivePreviewNode(node: Content): node is LivePreviewNode { node.type === "emphasis" || node.type === "heading" || node.type === "html" || + node.type === "image" || node.type === "inlineCode" || node.type === "link" || node.type === "strong" || @@ -67,34 +68,6 @@ export function selectionOnSameLine( }); } -function collectImageRanges( - doc: string, - selection: readonly SelectionRange[] -): LivePreviewRange[] { - const ranges: LivePreviewRange[] = []; - const pattern = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g; - - for (const match of doc.matchAll(pattern)) { - const from = match.index ?? 0; - const source = match[0]; - const to = from + source.length; - - ranges.push({ - from, - to, - source, - node: { - type: "image", - alt: match[1] || null, - url: match[2], - title: match[3] || null - } - }); - } - - return ranges; -} - function isInsideWikiLink(from: number, to: number, wikiLinkSpans: readonly [number, number][]): boolean { return wikiLinkSpans.some(([wikiFrom, wikiTo]) => from >= wikiFrom && to <= wikiTo); } @@ -123,6 +96,10 @@ function visit( if (typeof from === "number" && typeof to === "number" && isLivePreviewNode(child)) { if (shouldSkipInsideWikiLink(child, from, to, wikiLinkSpans)) continue; + // Reference-style images (![alt][label]) carry no inline URL — leave + // them as raw text rather than emitting a widget with an empty src. + if (child.type === "image" && !child.url) continue; + if (child.type === "table") { ranges.push({ from, to, node: child, source: doc.slice(from, to) }); continue; @@ -164,8 +141,12 @@ export function collectLivePreviewRanges( const ranges: LivePreviewRange[] = []; const wikiLinkSpans = scanWikiLinks(doc).map((link) => [link.from, link.to] as [number, number]); + // Images are emitted by the AST walk like every other inline node. They must + // NOT come from a document-wide regex scan: only the AST knows whether + // `![alt](url)` sits in prose (render it) or inside a code span / fenced + // block (literal text), and only the parser resolves CommonMark URL edge + // cases such as balanced parentheses in the destination. visit(ast, doc, selection, ranges, wikiLinkSpans); - ranges.push(...collectImageRanges(doc, selection)); return ranges.sort((left, right) => left.from - right.from); } diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index bbca2d71..687d168c 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -1,7 +1,7 @@ import { type EditorState, RangeSet, RangeSetBuilder, StateEffect, StateField, type Extension, type Range, type SelectionRange, type Transaction } from "@codemirror/state"; import { Decoration, type DecorationSet, EditorView, ViewPlugin, WidgetType } from "@codemirror/view"; import { ensureSyntaxTree } from "@codemirror/language"; -import type { Code, FootnoteDefinition, FootnoteReference, Heading, Html, List, Root, Table } from "mdast"; +import type { Code, FootnoteDefinition, FootnoteReference, Heading, Html, Link, List, Root, Table } from "mdast"; import type { CodeHighlightToken } from "./types"; import { lezerStringToMdast, lezerTreeToMdast } from "./lezer-mdast-adapter"; @@ -1143,6 +1143,45 @@ function buildDecorations( ); } } else if (range.node.type === "link" && !config.renderers.link) { + const linkNode = range.node as Link; + const hasImageLabel = linkNode.children.some((child) => child.type === "image"); + if (hasImageLabel) { + // Linked image `[![alt](img)](url)` — the label is not text, so the + // marker-length arithmetic below (indexOf "](" ) would split inside + // the nested image. Render the label's images as a clickable widget + // instead, and claim the whole span so the child image ranges don't + // emit a second, overlapping replace decoration. + const cursorOnLink = selectionIntersects(range.from, range.to, selection, true); + if (cursorOnLink) continue; + parentSpans.push([range.from, range.to]); + const linkedImageKey = `linkimg:${range.from}-${range.to}:${range.source}`; + const buildLinkedImage = (): HTMLElement => { + const wrapper = document.createElement("span"); + wrapper.setAttribute("data-link-url", linkNode.url); + wrapper.style.cursor = "pointer"; + wrapper.title = linkNode.url; + for (const child of linkNode.children) { + const childFrom = child.position?.start.offset; + const childTo = child.position?.end.offset; + if (typeof childFrom !== "number" || typeof childTo !== "number") continue; + const childSource = doc.slice(childFrom, childTo); + if (child.type === "image" && child.url) { + wrapper.appendChild( + renderLivePreviewNode(child, childSource, config.renderers, childFrom, childTo) + ); + } else { + wrapper.appendChild(document.createTextNode(childSource)); + } + } + return wrapper; + }; + decos.push( + Decoration.replace({ + widget: createWidget(buildLinkedImage, true, undefined, linkedImageKey), + }).range(range.from, range.to) + ); + continue; + } const inlineStyle = getInlineMarkerStyle("link", range.source); if (inlineStyle) { const { openLen, closeLen, style, attrs } = inlineStyle; diff --git a/packages/core/test/live-preview-ranges.test.ts b/packages/core/test/live-preview-ranges.test.ts index 7b1066da..df316b7c 100644 --- a/packages/core/test/live-preview-ranges.test.ts +++ b/packages/core/test/live-preview-ranges.test.ts @@ -59,4 +59,61 @@ describe("live preview ranges", () => { expect(ranges.map((range) => range.node.type)).toEqual(["table", "heading"]); }); + + it("does not emit image ranges for image syntax inside code contexts", () => { + const doc = "```\n![alt](https://example.com/a.png)\n```\n\nUse `![x](https://example.com/b.png)` inline"; + const ranges = collectLivePreviewRanges( + lezerStringToMdast(doc), + doc, + [EditorSelection.cursor(doc.length)] + ); + + expect(ranges.some((range) => range.node.type === "image")).toBe(false); + }); + + it("keeps balanced parentheses in image URLs and preserves the title", () => { + const doc = '![Wiki](https://en.wikipedia.org/wiki/Foo_(bar) "Disambiguation")'; + const ranges = collectLivePreviewRanges( + lezerStringToMdast(doc), + doc, + [EditorSelection.cursor(0)] + ); + + const image = ranges.find((range) => range.node.type === "image"); + expect(image).toBeDefined(); + expect(image?.node).toMatchObject({ + url: "https://en.wikipedia.org/wiki/Foo_(bar)", + title: "Disambiguation" + }); + expect(image?.to).toBe(doc.length); + }); + + it("emits the link and its nested image for a linked image", () => { + const doc = "[![logo](https://example.com/l.png)](https://example.com)"; + const ranges = collectLivePreviewRanges( + lezerStringToMdast(doc), + doc, + [EditorSelection.cursor(0)] + ); + + expect(ranges.map((range) => range.node.type)).toEqual(["link", "image"]); + const [link, image] = ranges; + expect(link.from).toBe(0); + expect(link.to).toBe(doc.length); + expect((link.node as { url: string }).url).toBe("https://example.com"); + expect((image.node as { url: string }).url).toBe("https://example.com/l.png"); + expect(image.from).toBeGreaterThan(link.from); + expect(image.to).toBeLessThan(link.to); + }); + + it("leaves reference-style images without an inline URL as raw text", () => { + const doc = "![alt][logo]\n\n[logo]: https://example.com/l.png"; + const ranges = collectLivePreviewRanges( + lezerStringToMdast(doc), + doc, + [EditorSelection.cursor(0)] + ); + + expect(ranges.some((range) => range.node.type === "image")).toBe(false); + }); }); diff --git a/packages/core/test/live-preview-regressions.test.ts b/packages/core/test/live-preview-regressions.test.ts index 8a3cbfc9..f0a94e55 100644 --- a/packages/core/test/live-preview-regressions.test.ts +++ b/packages/core/test/live-preview-regressions.test.ts @@ -243,4 +243,77 @@ describe("live preview regressions", () => { expect(container.textContent).not.toContain("![alt]"); editor.destroy(); }); + + // Linked images `[![alt](img)](url)` — e.g. README CI badges. The old + // regex-driven image scan emitted a replace decoration nested inside the + // link's replace decoration, and the link's marker arithmetic split at the + // FIRST `](` (inside the nested image), so the label rendered as garbage + // like `![logo` with a fragment URL. + it("renders a linked image as a clickable image widget", () => { + const container = document.createElement("div"); + const doc = "Intro\n\n[![logo](https://example.com/l.png)](https://example.com)\n\nend"; + const editor = createEditor({ + container, + initialValue: doc, + livePreview: true + }); + + editor.setSelection(0); + const linkEl = container.querySelector("[data-link-url]"); + expect(linkEl?.getAttribute("data-link-url")).toBe("https://example.com"); + const img = linkEl?.querySelector("[data-live-preview-image]"); + expect(img?.getAttribute("data-live-preview-image")).toBe("https://example.com/l.png"); + // No half-parsed label text may leak through. + expect(container.textContent).not.toContain("![logo"); + + // Cursor inside the link → raw source visible for editing. The nested + // image's own cursor-in behavior may interleave a preview widget, so + // assert the raw pieces rather than one contiguous string. + editor.setSelection(doc.indexOf("logo")); + expect(container.textContent).toContain("[![logo](https://example.com/l.png)"); + expect(container.textContent).toContain("](https://example.com)"); + + // Cursor back out → widget again. + editor.setSelection(0); + expect(container.querySelector("[data-link-url]")).not.toBeNull(); + expect(container.textContent).not.toContain("![logo"); + editor.destroy(); + }); + + // Code contexts are literal text: the document-wide image regex used to + // inject real image widgets into fenced code blocks and inline code. + it("keeps image syntax literal inside fenced code and inline code", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "```\n![alt](https://example.com/a.png)\n```\n\nUse `![x](https://example.com/b.png)` inline\n\nend", + livePreview: true + }); + + editor.setSelection(editor.getDocument().length); + expect(container.querySelector("[data-live-preview-image]")).toBeNull(); + const text = container.textContent ?? ""; + expect(text).toContain("![alt](https://example.com/a.png)"); + expect(text).toContain("![x](https://example.com/b.png)"); + editor.destroy(); + }); + + // CommonMark allows balanced parentheses in destinations; the old regex + // stopped at the first `)`, truncating the URL and leaving a stray `)`. + it("renders image URLs containing balanced parentheses in full", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "Intro\n\n![Wiki](https://en.wikipedia.org/wiki/Foo_(bar))\n\nend", + livePreview: true + }); + + editor.setSelection(0); + const widget = container.querySelector("[data-live-preview-image]"); + expect(widget?.getAttribute("data-live-preview-image")).toBe( + "https://en.wikipedia.org/wiki/Foo_(bar)" + ); + expect(container.textContent).not.toContain(")"); + editor.destroy(); + }); }); From fae079266ece35f852b735deaa7dd030b8096b41 Mon Sep 17 00:00:00 2001 From: rotioki <2431311634@qq.com> Date: Sun, 12 Jul 2026 19:46:31 +0800 Subject: [PATCH 2/8] fix(live-preview): keep linked-image preview visible while editing Standalone images show source + a preview widget below when the cursor is inside their range. Linked images rendered raw source only, so pasting a badge line (cursor lands at its end, which counts as on the link) looked broken until the cursor moved away. Mirror the standalone image contract: cursor out - replace widget; cursor in - editable source plus a block preview widget right below. Co-Authored-By: Claude Fable 5 --- packages/core/src/live-preview.ts | 30 ++++++++++++++----- .../test/live-preview-regressions.test.ts | 16 ++++++---- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index 687d168c..01551c2e 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -1151,10 +1151,14 @@ function buildDecorations( // the nested image. Render the label's images as a clickable widget // instead, and claim the whole span so the child image ranges don't // emit a second, overlapping replace decoration. - const cursorOnLink = selectionIntersects(range.from, range.to, selection, true); - if (cursorOnLink) continue; + // + // Cursor-aware like standalone images: cursor out → replace widget; + // cursor in → raw source stays editable AND the preview renders as a + // block widget right below, so pasting a badge line (cursor lands at + // its end) shows the result immediately. parentSpans.push([range.from, range.to]); - const linkedImageKey = `linkimg:${range.from}-${range.to}:${range.source}`; + const cursorOnLink = selectionIntersects(range.from, range.to, selection, true); + const linkedImageKey = `linkimg:${range.from}-${range.to}:${cursorOnLink ? "in" : "out"}:${range.source}`; const buildLinkedImage = (): HTMLElement => { const wrapper = document.createElement("span"); wrapper.setAttribute("data-link-url", linkNode.url); @@ -1175,11 +1179,21 @@ function buildDecorations( } return wrapper; }; - decos.push( - Decoration.replace({ - widget: createWidget(buildLinkedImage, true, undefined, linkedImageKey), - }).range(range.from, range.to) - ); + if (!cursorOnLink) { + decos.push( + Decoration.replace({ + widget: createWidget(buildLinkedImage, true, undefined, linkedImageKey), + }).range(range.from, range.to) + ); + } else { + decos.push( + Decoration.widget({ + widget: createWidget(buildLinkedImage, true, undefined, linkedImageKey), + block: true, + side: 1, + }).range(range.to) + ); + } continue; } const inlineStyle = getInlineMarkerStyle("link", range.source); diff --git a/packages/core/test/live-preview-regressions.test.ts b/packages/core/test/live-preview-regressions.test.ts index f0a94e55..7e4d748b 100644 --- a/packages/core/test/live-preview-regressions.test.ts +++ b/packages/core/test/live-preview-regressions.test.ts @@ -266,12 +266,18 @@ describe("live preview regressions", () => { // No half-parsed label text may leak through. expect(container.textContent).not.toContain("![logo"); - // Cursor inside the link → raw source visible for editing. The nested - // image's own cursor-in behavior may interleave a preview widget, so - // assert the raw pieces rather than one contiguous string. + // Cursor inside the link → raw source visible for editing AND the + // preview rendered alongside (same contract as standalone images), so + // pasting a badge line shows the result without moving the cursor. editor.setSelection(doc.indexOf("logo")); - expect(container.textContent).toContain("[![logo](https://example.com/l.png)"); - expect(container.textContent).toContain("](https://example.com)"); + expect(container.textContent).toContain("[![logo](https://example.com/l.png)](https://example.com)"); + const editModePreview = container.querySelector("[data-link-url]"); + expect(editModePreview?.querySelector("[data-live-preview-image]")).not.toBeNull(); + + // Cursor at the very end of the link (where it lands right after a + // paste) also counts as "on the link" — preview must still be visible. + editor.setSelection(doc.indexOf("\n\nend")); + expect(container.querySelector("[data-live-preview-image]")).not.toBeNull(); // Cursor back out → widget again. editor.setSelection(0); From ce3e5d2400d818deb7a6cba3930959be119bd17a Mon Sep 17 00:00:00 2001 From: rotioki <2431311634@qq.com> Date: Sun, 12 Jul 2026 20:00:09 +0800 Subject: [PATCH 3/8] fix(live-preview): let clicks reach the linked-image navigation handler CM6 skips every registered domEventHandler - including the [data-link-url] mousedown handler that opens links - for events inside widgets whose ignoreEvent() returns true, so the linked-image widget rendered but never navigated. Match the plain-link widget contract (ignoreEvent false); interactive chrome inside custom image renderers already protects itself by stopping propagation. Also surface the destination on hover: browsers resolve tooltips from the innermost titled element, so an image-level tooltip set by a renderer would mask the wrapper title. Inside a linked image the destination outranks it. Co-Authored-By: Claude Fable 5 --- packages/core/src/live-preview.ts | 15 ++++++++++++--- .../core/test/live-preview-regressions.test.ts | 3 +++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index 01551c2e..897dc64c 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -1163,7 +1163,6 @@ function buildDecorations( const wrapper = document.createElement("span"); wrapper.setAttribute("data-link-url", linkNode.url); wrapper.style.cursor = "pointer"; - wrapper.title = linkNode.url; for (const child of linkNode.children) { const childFrom = child.position?.start.offset; const childTo = child.position?.end.offset; @@ -1177,18 +1176,28 @@ function buildDecorations( wrapper.appendChild(document.createTextNode(childSource)); } } + // The widget navigates on click, so the destination outranks any + // image-level tooltip a renderer set inside the label (browsers + // resolve hover from the innermost titled element). + wrapper.title = linkNode.url; + wrapper.querySelectorAll("img").forEach((img) => { img.title = linkNode.url; }); return wrapper; }; + // swallowEvents must stay false: CM6 skips ALL domEventHandlers — + // including the [data-link-url] navigation handler — for events + // inside widgets whose ignoreEvent() returns true. Interactive + // chrome inside custom image renderers protects itself by stopping + // propagation (same contract as the code-copy button). if (!cursorOnLink) { decos.push( Decoration.replace({ - widget: createWidget(buildLinkedImage, true, undefined, linkedImageKey), + widget: createWidget(buildLinkedImage, false, undefined, linkedImageKey), }).range(range.from, range.to) ); } else { decos.push( Decoration.widget({ - widget: createWidget(buildLinkedImage, true, undefined, linkedImageKey), + widget: createWidget(buildLinkedImage, false, undefined, linkedImageKey), block: true, side: 1, }).range(range.to) diff --git a/packages/core/test/live-preview-regressions.test.ts b/packages/core/test/live-preview-regressions.test.ts index 7e4d748b..be70f1d1 100644 --- a/packages/core/test/live-preview-regressions.test.ts +++ b/packages/core/test/live-preview-regressions.test.ts @@ -263,6 +263,9 @@ describe("live preview regressions", () => { expect(linkEl?.getAttribute("data-link-url")).toBe("https://example.com"); const img = linkEl?.querySelector("[data-live-preview-image]"); expect(img?.getAttribute("data-live-preview-image")).toBe("https://example.com/l.png"); + // Hovering must reveal the destination, including on the itself. + expect(linkEl?.getAttribute("title")).toBe("https://example.com"); + expect(linkEl?.querySelector("img")?.title).toBe("https://example.com"); // No half-parsed label text may leak through. expect(container.textContent).not.toContain("![logo"); From 6487023d08925d2a02312946eda7a8ce85da3ea5 Mon Sep 17 00:00:00 2001 From: rotioki <2431311634@qq.com> Date: Sun, 12 Jul 2026 20:04:30 +0800 Subject: [PATCH 4/8] fix(electron): open editor links in the system browser The demo never installed a window-open handler, so every link click in the live preview (window.open from the renderer) spawned a bare child BrowserWindow instead of the user browser. Route http(s) targets through shell.openExternal and deny all other window opens; apply the same policy to will-navigate so stray navigations cannot replace the editor page (current-URL reloads stay allowed). Co-Authored-By: Claude Fable 5 --- apps/electron-demo/electron/main.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/electron-demo/electron/main.ts b/apps/electron-demo/electron/main.ts index 98fb5c93..222e6688 100644 --- a/apps/electron-demo/electron/main.ts +++ b/apps/electron-demo/electron/main.ts @@ -55,6 +55,28 @@ function createWindow(): void { mainWindow?.show(); }); + // Editor links open via window.open — without a handler Electron spawns a + // bare child BrowserWindow (no nav bar, full node-less renderer) instead of + // the user's browser. Route web links to the system browser and deny + // everything else (file:, custom schemes) from opening windows. + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (/^https?:/i.test(url)) { + void shell.openExternal(url); + } + return { action: "deny" }; + }); + + // Same policy for in-place navigation (e.g. without target): + // reloads of the current page (vite full-reload in dev, Ctrl+R in prod) + // stay allowed, anything else must not navigate the editor window away. + mainWindow.webContents.on("will-navigate", (event, url) => { + if (url === mainWindow?.webContents.getURL()) return; + event.preventDefault(); + if (/^https?:/i.test(url)) { + void shell.openExternal(url); + } + }); + const devUrl = process.env.VITE_DEV_SERVER_URL; if (devUrl) { mainWindow.loadURL(devUrl); From 1146603a32dbf324c2031f66355abd5a92d21916 Mon Sep 17 00:00:00 2001 From: rotioki <2431311634@qq.com> Date: Sun, 12 Jul 2026 20:46:13 +0800 Subject: [PATCH 5/8] fix(live-preview): handle nested and reference-style link-label images Two gaps in the linked-image branch, both found by adversarial review: - The image check only looked at the label top level, so [*![a](i)*](u) (image wrapped in emphasis) still fell into the marker-arithmetic path and rendered the garbage label the branch exists to prevent. Detect images at any depth and flatten wrapper nodes when rendering the label. - A Link node with no URL child (reference-style [x][ref], empty [x]()) fell back to the whole node source as its url, leaking raw markdown into data-link-url and the hover tooltip. Only autolinks keep the node-text fallback; such labels now render their image without pretending to navigate. Co-Authored-By: Claude Fable 5 --- packages/core/src/lezer-mdast-adapter.ts | 17 ++++- packages/core/src/live-preview-ranges.ts | 7 +- packages/core/src/live-preview.ts | 75 +++++++++++++------ .../test/live-preview-regressions.test.ts | 36 +++++++++ 4 files changed, 105 insertions(+), 30 deletions(-) diff --git a/packages/core/src/lezer-mdast-adapter.ts b/packages/core/src/lezer-mdast-adapter.ts index fbee4f28..ebb51586 100644 --- a/packages/core/src/lezer-mdast-adapter.ts +++ b/packages/core/src/lezer-mdast-adapter.ts @@ -313,7 +313,15 @@ function adaptInlineNode(source: Source, node: SyntaxNode): PhrasingContent | nu // Lezer Link: contains LinkMark `[`, label content, LinkMark `]`, // optional `(URL "title")`. We pull URL via child name. const url = findChildByName(node, "URL"); - const urlText = url ? readSlice(source, url.from, url.to) : readSlice(source, node.from, node.to).replace(/^<|>$/g, ""); + // Autolinks ARE their URL, so the node text is the right fallback. A + // full Link with no URL child (reference-style `[x][ref]`, empty + // `[x]()`) has no inline destination — falling back to the node text + // would leak raw markdown into hrefs and tooltips downstream. + const urlText = url + ? readSlice(source, url.from, url.to) + : node.name === "Link" + ? "" + : readSlice(source, node.from, node.to).replace(/^<|>$/g, ""); // Walk children between the first `[` and the matching `]` and // recursively adapt nested inline nodes (Image, StrongEmphasis, // Emphasis, InlineCode, etc.) so things like @@ -361,11 +369,12 @@ function adaptInlineNode(source: Source, node: SyntaxNode): PhrasingContent | nu case "Image": { const url = findChildByName(node, "URL"); const urlText = url ? readSlice(source, url.from, url.to) : ""; - // Title is delimited by "…", '…' or (…) per CommonMark — strip one - // matching pair so consumers get the bare text (e.g. for tooltips). + // LinkTitle spans its delimiters — lezer only emits well-formed + // "…", '…' or (…) pairs — so strip the outer pair to give consumers + // the bare text (e.g. for tooltips). const titleNode = findChildByName(node, "LinkTitle"); const titleText = titleNode - ? readSlice(source, titleNode.from, titleNode.to).replace(/^(["'(])([\s\S]*)(["')])$/, "$2") + ? readSlice(source, titleNode.from, titleNode.to).replace(/^["'(]([\s\S]*)["')]$/, "$1") : null; // Image label sits between `![` and `]` — same structure as Link. let alt = ""; diff --git a/packages/core/src/live-preview-ranges.ts b/packages/core/src/live-preview-ranges.ts index 78f29440..ddb079d1 100644 --- a/packages/core/src/live-preview-ranges.ts +++ b/packages/core/src/live-preview-ranges.ts @@ -23,6 +23,8 @@ function isLivePreviewNode(node: Content): node is LivePreviewNode { node.type === "emphasis" || node.type === "heading" || node.type === "html" || + // images come from this AST walk (never a text scan) so that image + // syntax inside code spans and fenced blocks stays literal node.type === "image" || node.type === "inlineCode" || node.type === "link" || @@ -141,11 +143,6 @@ export function collectLivePreviewRanges( const ranges: LivePreviewRange[] = []; const wikiLinkSpans = scanWikiLinks(doc).map((link) => [link.from, link.to] as [number, number]); - // Images are emitted by the AST walk like every other inline node. They must - // NOT come from a document-wide regex scan: only the AST knows whether - // `![alt](url)` sits in prose (render it) or inside a code span / fenced - // block (literal text), and only the parser resolves CommonMark URL edge - // cases such as balanced parentheses in the destination. visit(ast, doc, selection, ranges, wikiLinkSpans); return ranges.sort((left, right) => left.from - right.from); diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index 897dc64c..2b5e1768 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -1028,6 +1028,48 @@ function getInlineMarkerStyle(nodeType: string, source: string): InlineMarkerSty } } +/** + * Whether a link label contains an image at any depth — `[![a](i)](u)` but + * also `[*![a](i)*](u)`, where the image sits inside emphasis/strong/delete. + * Depth matters: the marker-arithmetic path splits at the FIRST `](`, which + * lands inside the nested image regardless of how deeply it is wrapped. + */ +function linkLabelContainsImage(children: Link["children"]): boolean { + return children.some( + (child) => + child.type === "image" || + ("children" in child && + Array.isArray(child.children) && + linkLabelContainsImage(child.children as Link["children"])) + ); +} + +/** + * Render a link label's nodes into `parent`: images become widgets, wrapper + * nodes (emphasis/strong/delete) are flattened so a nested image still + * renders, and anything else falls back to its raw source text. + */ +function appendLinkLabel( + parent: HTMLElement, + children: Link["children"], + doc: string, + renderers: NormalizedLivePreviewConfig["renderers"] +): void { + for (const child of children) { + const childFrom = child.position?.start.offset; + const childTo = child.position?.end.offset; + if (child.type === "image" && child.url && typeof childFrom === "number" && typeof childTo === "number") { + parent.appendChild( + renderLivePreviewNode(child, doc.slice(childFrom, childTo), renderers, childFrom, childTo) + ); + } else if ("children" in child && Array.isArray(child.children)) { + appendLinkLabel(parent, child.children as Link["children"], doc, renderers); + } else if (typeof childFrom === "number" && typeof childTo === "number") { + parent.appendChild(document.createTextNode(doc.slice(childFrom, childTo))); + } + } +} + interface BuildContext { /** Optional pre-computed Lezer mdast snapshot (avoids re-parsing on cursor-only updates). */ ast?: Root; @@ -1144,8 +1186,7 @@ function buildDecorations( } } else if (range.node.type === "link" && !config.renderers.link) { const linkNode = range.node as Link; - const hasImageLabel = linkNode.children.some((child) => child.type === "image"); - if (hasImageLabel) { + if (linkLabelContainsImage(linkNode.children)) { // Linked image `[![alt](img)](url)` — the label is not text, so the // marker-length arithmetic below (indexOf "](" ) would split inside // the nested image. Render the label's images as a clickable widget @@ -1161,26 +1202,18 @@ function buildDecorations( const linkedImageKey = `linkimg:${range.from}-${range.to}:${cursorOnLink ? "in" : "out"}:${range.source}`; const buildLinkedImage = (): HTMLElement => { const wrapper = document.createElement("span"); - wrapper.setAttribute("data-link-url", linkNode.url); - wrapper.style.cursor = "pointer"; - for (const child of linkNode.children) { - const childFrom = child.position?.start.offset; - const childTo = child.position?.end.offset; - if (typeof childFrom !== "number" || typeof childTo !== "number") continue; - const childSource = doc.slice(childFrom, childTo); - if (child.type === "image" && child.url) { - wrapper.appendChild( - renderLivePreviewNode(child, childSource, config.renderers, childFrom, childTo) - ); - } else { - wrapper.appendChild(document.createTextNode(childSource)); - } + appendLinkLabel(wrapper, linkNode.children, doc, config.renderers); + // Reference-style labels (`[![a](i)][ref]`) have no inline + // destination — render the image but don't pretend to navigate. + if (linkNode.url) { + wrapper.setAttribute("data-link-url", linkNode.url); + wrapper.style.cursor = "pointer"; + // The widget navigates on click, so the destination outranks any + // image-level tooltip a renderer set inside the label (browsers + // resolve hover from the innermost titled element). + wrapper.title = linkNode.url; + wrapper.querySelectorAll("img").forEach((img) => { img.title = linkNode.url; }); } - // The widget navigates on click, so the destination outranks any - // image-level tooltip a renderer set inside the label (browsers - // resolve hover from the innermost titled element). - wrapper.title = linkNode.url; - wrapper.querySelectorAll("img").forEach((img) => { img.title = linkNode.url; }); return wrapper; }; // swallowEvents must stay false: CM6 skips ALL domEventHandlers — diff --git a/packages/core/test/live-preview-regressions.test.ts b/packages/core/test/live-preview-regressions.test.ts index be70f1d1..a4d7a1ed 100644 --- a/packages/core/test/live-preview-regressions.test.ts +++ b/packages/core/test/live-preview-regressions.test.ts @@ -289,6 +289,42 @@ describe("live preview regressions", () => { editor.destroy(); }); + // The image may sit deeper than the label's top level — `[*![a](i)*](u)` + // wraps it in emphasis. A direct-children-only check fell back to the + // marker-arithmetic path, which splits at the FIRST "](" and produced the + // same garbage label/URL the widget branch exists to prevent. + it("renders a linked image whose label nests the image inside emphasis", () => { + const container = document.createElement("div"); + const doc = "Intro\n\n[*![logo](https://example.com/l.png)* extra](https://example.com)\n\nend"; + const editor = createEditor({ container, initialValue: doc, livePreview: true }); + + editor.setSelection(0); + const linkEl = container.querySelector("[data-link-url]"); + expect(linkEl?.getAttribute("data-link-url")).toBe("https://example.com"); + expect(linkEl?.querySelector("[data-live-preview-image]")?.getAttribute("data-live-preview-image")).toBe( + "https://example.com/l.png" + ); + expect(linkEl?.textContent).toContain("extra"); + expect(container.textContent).not.toContain("![logo"); + editor.destroy(); + }); + + // Reference-style links have no inline destination. The adapter used to + // fall back to the whole node source, so `[![a](i)][ref]` rendered a + // "clickable" image whose data-link-url and hover tooltip were the raw + // markdown itself. + it("renders a reference-style linked image without a fake destination", () => { + const container = document.createElement("div"); + const doc = "Intro\n\n[![logo](https://example.com/l.png)][badge]\n\n[badge]: https://example.com\n\nend"; + const editor = createEditor({ container, initialValue: doc, livePreview: true }); + + editor.setSelection(0); + expect(container.querySelector("[data-live-preview-image]")).not.toBeNull(); + expect(container.querySelector("[data-link-url]")).toBeNull(); + expect(container.textContent).not.toContain("[![logo"); + editor.destroy(); + }); + // Code contexts are literal text: the document-wide image regex used to // inject real image widgets into fenced code blocks and inline code. it("keeps image syntax literal inside fenced code and inline code", () => { From 8122e2d043c73a456f6814b3ea488ad3298868cb Mon Sep 17 00:00:00 2001 From: rotioki <2431311634@qq.com> Date: Sun, 12 Jul 2026 20:48:48 +0800 Subject: [PATCH 6/8] fix(live-preview): only open web and mail schemes from link clicks data-link-url is authored document content, so clicking a link whose destination is javascript:, file: or a custom scheme must not reach window.open. Scheme-less GFM autolink literals (www.example.com) used to be opened as relative paths and now get an https prefix instead. Co-Authored-By: Claude Fable 5 --- packages/core/src/live-preview.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index 2b5e1768..505823a2 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -1558,8 +1558,16 @@ export function createLivePreviewExtension( return true; } - // External links: open in new tab - window.open(url, "_blank", "noopener"); + // External links: open in new tab. data-link-url is authored document + // content, so only web and mail schemes may reach window.open — + // javascript:/file:/custom schemes are consumed without navigating. + // GFM autolink literals (www.example.com) carry no scheme; give them + // one instead of letting window.open treat them as relative paths. + if (/^(https?:|mailto:)/i.test(url)) { + window.open(url, "_blank", "noopener"); + } else if (/^www\./i.test(url)) { + window.open(`https://${url}`, "_blank", "noopener"); + } return true; } }); From cc25f60f6ef85f8be316753fb1c56f5b0e306004 Mon Sep 17 00:00:00 2001 From: rotioki <2431311634@qq.com> Date: Sun, 12 Jul 2026 20:48:49 +0800 Subject: [PATCH 7/8] test(live-preview): lock navigation contract and widen linked-image coverage The click-to-navigate behavior hinges on the widget NOT swallowing events: CM6 skips every domEventHandler for events inside widgets whose ignoreEvent() returns true, and the rendered DOM is identical either way, so only a dispatched mousedown can catch that regression. Also cover: scheme filtering, single-preview suppression in edit mode, mixed text-and-images labels, all three CommonMark title delimiters, images inside raw HTML blocks, and scope the stray-paren assertion to the image line so unrelated renderer chrome cannot break it. Co-Authored-By: Claude Fable 5 --- .../core/test/live-preview-ranges.test.ts | 43 +++++++++- .../test/live-preview-regressions.test.ts | 78 ++++++++++++++++++- 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/packages/core/test/live-preview-ranges.test.ts b/packages/core/test/live-preview-ranges.test.ts index df316b7c..b0fa1a20 100644 --- a/packages/core/test/live-preview-ranges.test.ts +++ b/packages/core/test/live-preview-ranges.test.ts @@ -88,6 +88,31 @@ describe("live preview ranges", () => { expect(image?.to).toBe(doc.length); }); + it("strips every CommonMark title delimiter form", () => { + const cases: Array<[string, string | null]> = [ + ['![a](https://example.com/i.png "double")', "double"], + ["![a](https://example.com/i.png 'single')", "single"], + ["![a](https://example.com/i.png (paren))", "paren"], + ["![a](https://example.com/i.png)", null] + ]; + for (const [doc, title] of cases) { + const ranges = collectLivePreviewRanges(lezerStringToMdast(doc), doc, [EditorSelection.cursor(0)]); + const image = ranges.find((range) => range.node.type === "image"); + expect(image?.node).toMatchObject({ title }); + } + }); + + it("does not emit image ranges for image syntax inside raw HTML blocks", () => { + const doc = "
\n![alt](https://example.com/a.png)\n
"; + const ranges = collectLivePreviewRanges( + lezerStringToMdast(doc), + doc, + [EditorSelection.cursor(doc.length)] + ); + + expect(ranges.some((range) => range.node.type === "image")).toBe(false); + }); + it("emits the link and its nested image for a linked image", () => { const doc = "[![logo](https://example.com/l.png)](https://example.com)"; const ranges = collectLivePreviewRanges( @@ -96,16 +121,30 @@ describe("live preview ranges", () => { [EditorSelection.cursor(0)] ); + // The child image range is intentionally still emitted — buildDecorations + // suppresses it via parentSpans when the link renders as one widget. expect(ranges.map((range) => range.node.type)).toEqual(["link", "image"]); const [link, image] = ranges; expect(link.from).toBe(0); expect(link.to).toBe(doc.length); - expect((link.node as { url: string }).url).toBe("https://example.com"); - expect((image.node as { url: string }).url).toBe("https://example.com/l.png"); + expect(link.node).toMatchObject({ url: "https://example.com" }); + expect(image.node).toMatchObject({ url: "https://example.com/l.png" }); expect(image.from).toBeGreaterThan(link.from); expect(image.to).toBeLessThan(link.to); }); + it("emits an empty url for reference-style links instead of raw source", () => { + const doc = "[![logo](https://example.com/l.png)][badge]\n\n[badge]: https://example.com"; + const ranges = collectLivePreviewRanges( + lezerStringToMdast(doc), + doc, + [EditorSelection.cursor(0)] + ); + + const link = ranges.find((range) => range.node.type === "link"); + expect(link?.node).toMatchObject({ url: "" }); + }); + it("leaves reference-style images without an inline URL as raw text", () => { const doc = "![alt][logo]\n\n[logo]: https://example.com/l.png"; const ranges = collectLivePreviewRanges( diff --git a/packages/core/test/live-preview-regressions.test.ts b/packages/core/test/live-preview-regressions.test.ts index a4d7a1ed..f05bcc98 100644 --- a/packages/core/test/live-preview-regressions.test.ts +++ b/packages/core/test/live-preview-regressions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createHistoryPlugin } from "../../plugin-history/src/index"; import { createEditor } from "../src/index"; @@ -278,9 +278,11 @@ describe("live preview regressions", () => { expect(editModePreview?.querySelector("[data-live-preview-image]")).not.toBeNull(); // Cursor at the very end of the link (where it lands right after a - // paste) also counts as "on the link" — preview must still be visible. + // paste) also counts as "on the link" — preview must still be visible, + // and exactly ONE: the child image range must not add its own preview + // on top of the link's (parentSpans suppression). editor.setSelection(doc.indexOf("\n\nend")); - expect(container.querySelector("[data-live-preview-image]")).not.toBeNull(); + expect(container.querySelectorAll("[data-live-preview-image]").length).toBe(1); // Cursor back out → widget again. editor.setSelection(0); @@ -289,6 +291,72 @@ describe("live preview regressions", () => { editor.destroy(); }); + // Navigation runs through a CM6 domEventHandler, and CM6 skips ALL of + // those for events inside widgets whose ignoreEvent() returns true. The + // rendered DOM is byte-identical either way, so only a dispatched event + // catches a regression back to a swallowing widget. + it("mousedown on a linked-image widget opens the destination", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const doc = "Intro\n\n[![logo](https://example.com/l.png)](https://example.com)\n\nend"; + const editor = createEditor({ container, initialValue: doc, livePreview: true }); + const open = vi.spyOn(window, "open").mockReturnValue(null); + + editor.setSelection(0); + const img = container.querySelector("[data-link-url] img"); + expect(img).not.toBeNull(); + img?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); + expect(open).toHaveBeenCalledWith("https://example.com", "_blank", "noopener"); + + open.mockRestore(); + editor.destroy(); + container.remove(); + }); + + // data-link-url is authored document content — non-web schemes must be + // consumed without reaching window.open, and scheme-less GFM autolink + // literals must not be opened as relative paths. + it("filters link destinations by scheme before opening", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const doc = "Intro\n\n[run](javascript:alert(1)) and www.example.com\n\nend"; + const editor = createEditor({ container, initialValue: doc, livePreview: true }); + const open = vi.spyOn(window, "open").mockReturnValue(null); + + editor.setSelection(0); + const links = Array.from(container.querySelectorAll("[data-link-url]")); + const jsLink = links.find((el) => el.getAttribute("data-link-url")?.startsWith("javascript:")); + expect(jsLink).toBeDefined(); + jsLink?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); + expect(open).not.toHaveBeenCalled(); + + const wwwLink = links.find((el) => el.getAttribute("data-link-url") === "www.example.com"); + expect(wwwLink).toBeDefined(); + wwwLink?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); + expect(open).toHaveBeenCalledWith("https://www.example.com", "_blank", "noopener"); + + open.mockRestore(); + editor.destroy(); + container.remove(); + }); + + // Labels mixing text and several images must render every image inside a + // single navigable widget (the untested else-branch of the label walk). + it("renders a mixed text-and-images label as one clickable widget", () => { + const container = document.createElement("div"); + const doc = + "Intro\n\n[pre ![a](https://example.com/1.png) mid ![b](https://example.com/2.png)](https://example.com)\n\nend"; + const editor = createEditor({ container, initialValue: doc, livePreview: true }); + + editor.setSelection(0); + const linkEl = container.querySelector("[data-link-url]"); + expect(linkEl?.getAttribute("data-link-url")).toBe("https://example.com"); + expect(linkEl?.querySelectorAll("[data-live-preview-image]").length).toBe(2); + expect(linkEl?.textContent).toContain("pre"); + expect(linkEl?.textContent).toContain("mid"); + editor.destroy(); + }); + // The image may sit deeper than the label's top level — `[*![a](i)*](u)` // wraps it in emphasis. A direct-children-only check fell back to the // marker-arithmetic path, which splits at the FIRST "](" and produced the @@ -358,7 +426,9 @@ describe("live preview regressions", () => { expect(widget?.getAttribute("data-live-preview-image")).toBe( "https://en.wikipedia.org/wiki/Foo_(bar)" ); - expect(container.textContent).not.toContain(")"); + // The truncated match used to leave the final ")" behind as stray + // document text on the image's line. + expect(widget?.closest(".cm-line")?.textContent).not.toContain(")"); editor.destroy(); }); }); From 262adb3f82cefe3af0633c12fff66f7e82bde553 Mon Sep 17 00:00:00 2001 From: rotioki <2431311634@qq.com> Date: Sun, 12 Jul 2026 20:51:13 +0800 Subject: [PATCH 8/8] refactor(electron): extract testable link policy and allow mailto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window-open / will-navigate rules were inline closures over mainWindow, so nothing could exercise them. Pull the pure decisions into link-policy.ts with unit tests, and let mailto: reach the system handler alongside http(s) — it was silently dead before. Co-Authored-By: Claude Fable 5 --- apps/electron-demo/electron/link-policy.ts | 19 +++++++++++++++ apps/electron-demo/electron/main.ts | 21 ++++++++-------- apps/electron-demo/test/link-policy.test.ts | 27 +++++++++++++++++++++ 3 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 apps/electron-demo/electron/link-policy.ts create mode 100644 apps/electron-demo/test/link-policy.test.ts diff --git a/apps/electron-demo/electron/link-policy.ts b/apps/electron-demo/electron/link-policy.ts new file mode 100644 index 00000000..e595117c --- /dev/null +++ b/apps/electron-demo/electron/link-policy.ts @@ -0,0 +1,19 @@ +/** + * Navigation policy for editor windows, kept free of electron imports so it + * can be unit-tested: web and mail links leave the app via the system + * browser, nothing else may open windows or navigate the editor away. + */ + +/** The URL to hand to shell.openExternal, or null when nothing may open. */ +export function externalTarget(url: string): string | null { + return /^(https?:|mailto:)/i.test(url) ? url : null; +} + +/** + * Whether an in-place navigation may proceed. Only renderer-initiated + * reloads of the current page qualify (location.reload(), vite full-reload + * in dev) — webContents.reload() never emits will-navigate at all. + */ +export function isSamePageReload(url: string, currentUrl: string): boolean { + return url === currentUrl; +} diff --git a/apps/electron-demo/electron/main.ts b/apps/electron-demo/electron/main.ts index 222e6688..80f7d916 100644 --- a/apps/electron-demo/electron/main.ts +++ b/apps/electron-demo/electron/main.ts @@ -1,4 +1,6 @@ import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell } from "electron"; + +import { externalTarget, isSamePageReload } from "./link-policy"; import { readFile, writeFile, readdir, mkdir, rename, stat } from "node:fs/promises"; import { existsSync, watch, type FSWatcher } from "node:fs"; import path from "node:path"; @@ -57,24 +59,21 @@ function createWindow(): void { // Editor links open via window.open — without a handler Electron spawns a // bare child BrowserWindow (no nav bar, full node-less renderer) instead of - // the user's browser. Route web links to the system browser and deny + // the user's browser. Route web/mail links to the system browser and deny // everything else (file:, custom schemes) from opening windows. mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (/^https?:/i.test(url)) { - void shell.openExternal(url); - } + const target = externalTarget(url); + if (target) void shell.openExternal(target); return { action: "deny" }; }); - // Same policy for in-place navigation (e.g.
without target): - // reloads of the current page (vite full-reload in dev, Ctrl+R in prod) - // stay allowed, anything else must not navigate the editor window away. + // Same policy for in-place navigation (e.g. without target), + // so stray links cannot replace the editor page. mainWindow.webContents.on("will-navigate", (event, url) => { - if (url === mainWindow?.webContents.getURL()) return; + if (isSamePageReload(url, mainWindow?.webContents.getURL() ?? "")) return; event.preventDefault(); - if (/^https?:/i.test(url)) { - void shell.openExternal(url); - } + const target = externalTarget(url); + if (target) void shell.openExternal(target); }); const devUrl = process.env.VITE_DEV_SERVER_URL; diff --git a/apps/electron-demo/test/link-policy.test.ts b/apps/electron-demo/test/link-policy.test.ts new file mode 100644 index 00000000..00db9575 --- /dev/null +++ b/apps/electron-demo/test/link-policy.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { externalTarget, isSamePageReload } from "../electron/link-policy"; + +describe("link policy", () => { + it("routes web and mail links to the system browser", () => { + expect(externalTarget("https://example.com")).toBe("https://example.com"); + expect(externalTarget("http://example.com/a?b=c")).toBe("http://example.com/a?b=c"); + expect(externalTarget("HTTPS://EXAMPLE.COM")).toBe("HTTPS://EXAMPLE.COM"); + expect(externalTarget("mailto:a@example.com")).toBe("mailto:a@example.com"); + }); + + it("denies everything that is not a web or mail link", () => { + expect(externalTarget("file:///C:/Windows/system32")).toBeNull(); + expect(externalTarget("javascript:alert(1)")).toBeNull(); + expect(externalTarget("app://internal")).toBeNull(); + expect(externalTarget("")).toBeNull(); + // Not a scheme at all — a relative path must not leave the app either. + expect(externalTarget("relative/path.md")).toBeNull(); + }); + + it("allows only same-page reloads to navigate in place", () => { + expect(isSamePageReload("http://localhost:5173/", "http://localhost:5173/")).toBe(true); + expect(isSamePageReload("http://localhost:5173/other", "http://localhost:5173/")).toBe(false); + expect(isSamePageReload("https://example.com", "file:///app/index.html")).toBe(false); + }); +});