Skip to content
Open
19 changes: 19 additions & 0 deletions apps/electron-demo/electron/link-policy.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions apps/electron-demo/electron/main.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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. <a href> 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);
Expand Down
27 changes: 27 additions & 0 deletions apps/electron-demo/test/link-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 17 additions & 2 deletions packages/core/src/lezer-mdast-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
36 changes: 7 additions & 29 deletions packages/core/src/live-preview-ranges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" ||
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
109 changes: 106 additions & 3 deletions packages/core/src/live-preview.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
});
Expand Down
Loading