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 98fb5c93..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"; @@ -55,6 +57,25 @@ 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/mail links to the system browser and deny + // everything else (file:, custom schemes) from opening windows. + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + const target = externalTarget(url); + if (target) void shell.openExternal(target); + return { action: "deny" }; + }); + + // 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 (isSamePageReload(url, mainWindow?.webContents.getURL() ?? "")) return; + event.preventDefault(); + const target = externalTarget(url); + if (target) void shell.openExternal(target); + }); + const devUrl = process.env.VITE_DEV_SERVER_URL; if (devUrl) { mainWindow.loadURL(devUrl); 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); + }); +}); diff --git a/packages/core/src/lezer-mdast-adapter.ts b/packages/core/src/lezer-mdast-adapter.ts index fbd6edad..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,6 +369,13 @@ function adaptInlineNode(source: Source, node: SyntaxNode): PhrasingContent | nu case "Image": { const url = findChildByName(node, "URL"); const urlText = url ? readSlice(source, url.from, url.to) : ""; + // 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]*)["')]$/, "$1") + : null; // Image label sits between `![` and `]` — same structure as Link. let alt = ""; const first = node.firstChild; @@ -380,7 +395,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..ddb079d1 100644 --- a/packages/core/src/live-preview-ranges.ts +++ b/packages/core/src/live-preview-ranges.ts @@ -23,6 +23,9 @@ 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" || node.type === "strong" || @@ -67,34 +70,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 +98,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; @@ -165,7 +144,6 @@ export function collectLivePreviewRanges( const wikiLinkSpans = scanWikiLinks(doc).map((link) => [link.from, link.to] as [number, number]); 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..505823a2 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"; @@ -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; @@ -1143,6 +1185,59 @@ function buildDecorations( ); } } else if (range.node.type === "link" && !config.renderers.link) { + const linkNode = range.node as Link; + 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 + // instead, and claim the whole span so the child image ranges don't + // emit a second, overlapping replace decoration. + // + // 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 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"); + 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; }); + } + 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, false, undefined, linkedImageKey), + }).range(range.from, range.to) + ); + } else { + decos.push( + Decoration.widget({ + widget: createWidget(buildLinkedImage, false, undefined, linkedImageKey), + block: true, + side: 1, + }).range(range.to) + ); + } + continue; + } const inlineStyle = getInlineMarkerStyle("link", range.source); if (inlineStyle) { const { openLen, closeLen, style, attrs } = inlineStyle; @@ -1463,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; } }); diff --git a/packages/core/test/live-preview-ranges.test.ts b/packages/core/test/live-preview-ranges.test.ts index 7b1066da..b0fa1a20 100644 --- a/packages/core/test/live-preview-ranges.test.ts +++ b/packages/core/test/live-preview-ranges.test.ts @@ -59,4 +59,100 @@ 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("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( + lezerStringToMdast(doc), + doc, + [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).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( + 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..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"; @@ -243,4 +243,192 @@ 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"); + // 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"); + + // 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)](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, + // 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.querySelectorAll("[data-live-preview-image]").length).toBe(1); + + // Cursor back out → widget again. + editor.setSelection(0); + expect(container.querySelector("[data-link-url]")).not.toBeNull(); + expect(container.textContent).not.toContain("![logo"); + 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 + // 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", () => { + 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)" + ); + // 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(); + }); });