');
+ try {
+ selectText(editor, 'docs');
+ expect(pasteInto(editor, { 'text/plain': payload })).toBe(true);
+ expect(editor.state.doc.textContent).toBe('read the docs today');
+ expect(linkMarks(editor)[0]?.attrs.href).toBe(expectedHref);
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('paste of a non-allowlisted scheme over a selection never links — the payload lands as inert plain text', () => {
+ const editor = makeRealEditor('
read the docs today
');
+ try {
+ selectText(editor, 'docs');
+ pasteInto(editor, { 'text/plain': 'javascript:alert(1)' });
+ expect(linkMarks(editor)).toHaveLength(0);
+ // Fall-through = ordinary replace: the string is text, never an href.
+ expect(editor.state.doc.textContent).toBe('read the javascript:alert(1) today');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('paste of a URL over a code-marked selection falls through to a plain replace', () => {
+ const editor = makeRealEditor('
run bun install now
');
+ try {
+ selectText(editor, 'install');
+ pasteInto(editor, { 'text/plain': 'https://inkeep.com' });
+ expect(linkMarks(editor)).toHaveLength(0);
+ expect(editor.state.doc.textContent).toBe('run bun https://inkeep.com now');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('paste of a URL over a cross-block selection falls through (no link mark)', () => {
+ const editor = makeRealEditor('
one
two
');
+ try {
+ editor.commands.setTextSelection({ from: 2, to: 8 });
+ pasteInto(editor, { 'text/plain': 'https://inkeep.com' });
+ expect(linkMarks(editor)).toHaveLength(0);
+ // The fall-through is the normal paste path: the cross-block selection
+ // is replaced by the URL as plain text — content delivered, not dropped.
+ expect(editor.state.doc.textContent).toContain('https://inkeep.com');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('paste of a URL over already-linked text re-points the link, keeping the text', () => {
+ const editor = makeRealEditor('
',
+ });
+ try {
+ // Seeded links default to linkStyle 'inline'; a re-conversion would flip
+ // it to 'gfm-autolink'.
+ expect(firstLinkAttrs(editor)?.linkStyle).toBe('inline');
+ insertLocal(editor, ' ', 20);
+ await flushMicrotasksAndTimers();
+ expect(firstLinkAttrs(editor)?.linkStyle).toBe('inline');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('a wikilink atom next to a typed URL never converts and stays intact', async () => {
+ // The wikiLink node is an atom: `textBetween` renders it as leaf text, so
+ // the word scan can never see inside it. This pins that structural
+ // property — if WikiLink ever stops being an atom, this fails instead of
+ // the plugin silently starting to linkify wikilink internals.
+ const { WikiLink } = await import('@inkeep/open-knowledge-core');
+ const editor = mountLightEditor({
+ extensions: [WikiLink, GfmAutolink.configure({ isActiveEditor: () => true })],
+ });
+ try {
+ editor.commands.insertContent({ type: 'wikiLink', attrs: { target: 'Some Page' } });
+ // Type a URL + boundary right after the atom: only the URL converts.
+ const end = editor.state.doc.content.size - 1;
+ insertLocal(editor, ' https://after-atom.com ', end);
+ await flushMicrotasksAndTimers();
+ expect(linkHrefs(editor)).toEqual(['https://after-atom.com']);
+ // The atom's target never became link-marked text.
+ let wikiTargets = 0;
+ editor.state.doc.descendants((node) => {
+ if (node.type.name === 'wikiLink') wikiTargets++;
+ return true;
+ });
+ expect(wikiTargets).toBe(1);
+ } finally {
+ editor.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Deferred separate dispatch + position re-validation
+// ---------------------------------------------------------------------------
+
+describe('typed autolink — deferred dispatch', () => {
+ test('the conversion aborts silently when the range changed before the flush', async () => {
+ const editor = makeLightEditor();
+ try {
+ insertLocal(editor, 'https://example.com ', 1);
+ // Before the queued microtask runs, delete the text the candidate targeted.
+ editor.view.dispatch(editor.state.tr.delete(1, editor.state.doc.content.size));
+ await flushMicrotasksAndTimers();
+
+ expect(firstLinkAttrs(editor)).toBeNull();
+ expect(editor.state.doc.textContent).toBe('');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('the mark is added by a later dispatch, not merged into the typing tr', async () => {
+ const editor = makeLightEditor();
+ try {
+ insertLocal(editor, 'https://example.com ', 1);
+ // Synchronously — before the microtask — the mark is NOT present yet.
+ expect(firstLinkAttrs(editor)).toBeNull();
+ await flushMicrotasksAndTimers();
+ // It arrives on the deferred dispatch.
+ expect(firstLinkAttrs(editor)?.linkStyle).toBe('gfm-autolink');
+ } finally {
+ editor.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Undo isolation: the conversion is its own single-Cmd+Z step
+// ---------------------------------------------------------------------------
+
+describe('typed autolink — undo isolation (real y-undo binding)', () => {
+ test('one undo removes only the link mark, keeping the text and trailing space', async () => {
+ const ydoc = new Y.Doc();
+ seedFragmentParagraph(ydoc, 'seed');
+ const editor = makeCollabEditor(ydoc);
+ try {
+ await flushMicrotasksAndTimers();
+ // Close any binding-init capture so the typing below deterministically
+ // starts its own stack item (real typing happens long after doc open).
+ readUndoManager(editor)?.stopCapturing();
+
+ insertLocal(editor, ' https://example.com ', editor.state.doc.content.size - 1);
+ await flushMicrotasksAndTimers();
+ expect(firstLinkHref(editor)).toBe('https://example.com');
+
+ editor.commands.undo();
+ // The mark is gone but the typed text — trailing space included — stays.
+ expect(firstLinkAttrs(editor)).toBeNull();
+ expect(editor.state.doc.textContent).toBe('seed https://example.com ');
+
+ // The typing itself is the next distinct undo step.
+ editor.commands.undo();
+ expect(editor.state.doc.textContent).toBe('seed');
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+
+ test('redo after undoing a conversion re-applies the mark cleanly', async () => {
+ const ydoc = new Y.Doc();
+ seedFragmentParagraph(ydoc, 'seed');
+ const editor = makeCollabEditor(ydoc);
+ try {
+ await flushMicrotasksAndTimers();
+ readUndoManager(editor)?.stopCapturing();
+
+ insertLocal(editor, ' https://example.com ', editor.state.doc.content.size - 1);
+ await flushMicrotasksAndTimers();
+ expect(firstLinkHref(editor)).toBe('https://example.com');
+
+ editor.commands.undo();
+ expect(firstLinkAttrs(editor)).toBeNull();
+
+ editor.commands.redo();
+ const attrs = firstLinkAttrs(editor);
+ expect(attrs?.href).toBe('https://example.com');
+ expect(attrs?.linkStyle).toBe('gfm-autolink');
+ expect(editor.state.doc.textContent).toBe('seed https://example.com ');
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+
+ test("typing right after a conversion never merges into the mark's undo step", async () => {
+ const ydoc = new Y.Doc();
+ seedFragmentParagraph(ydoc, 'seed');
+ const editor = makeCollabEditor(ydoc);
+ try {
+ await flushMicrotasksAndTimers();
+ readUndoManager(editor)?.stopCapturing();
+
+ insertLocal(editor, ' https://example.com ', editor.state.doc.content.size - 1);
+ await flushMicrotasksAndTimers();
+ expect(firstLinkHref(editor)).toBe('https://example.com');
+
+ // Keep typing within the capture window of the mark dispatch.
+ insertLocal(editor, 'abc', editor.state.doc.content.size - 1);
+ await flushMicrotasksAndTimers();
+
+ editor.commands.undo();
+ // Only the new typing reverts; the conversion survives.
+ expect(editor.state.doc.textContent).toBe('seed https://example.com ');
+ expect(firstLinkHref(editor)).toBe('https://example.com');
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Real y-sync binding: origin guard against a genuine remote transaction
+// ---------------------------------------------------------------------------
+
+describe('typed autolink — real CRDT binding', () => {
+ test('a remote y-sync edit is not linkified, but a local edit in the same binding is', async () => {
+ const ydoc = new Y.Doc();
+ // Seed a paragraph with text so the remote append has an XmlText to grow.
+ seedFragmentParagraph(ydoc, 'seed');
+ const editor = makeCollabEditor(ydoc);
+
+ try {
+ await flushMicrotasksAndTimers();
+
+ // A peer appends a URL + space through the real y-sync path. The applied
+ // transaction carries ySyncPluginKey meta → origin guard skips it.
+ const remote = new Y.Doc();
+ Y.applyUpdate(remote, Y.encodeStateAsUpdate(ydoc));
+ remote.transact(() => {
+ appendToFirstParagraph(remote.getXmlFragment('default'), ' https://remote.example ');
+ });
+ Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(ydoc)), remote);
+ remote.destroy();
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toContain('https://remote.example');
+ expect(linkHrefs(editor)).not.toContain('https://remote.example');
+
+ // A local edit in the SAME binding still converts.
+ const end = editor.state.doc.content.size - 1;
+ insertLocal(editor, ' https://local.example ', end);
+ await flushMicrotasksAndTimers();
+
+ expect(linkHrefs(editor)).toContain('https://local.example');
+ // And the remote URL is still plain text.
+ expect(linkHrefs(editor)).not.toContain('https://remote.example');
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+});
diff --git a/packages/app/src/editor/gfm-autolink-plugin.ts b/packages/app/src/editor/gfm-autolink-plugin.ts
new file mode 100644
index 00000000..49eeb596
--- /dev/null
+++ b/packages/app/src/editor/gfm-autolink-plugin.ts
@@ -0,0 +1,290 @@
+/**
+ * Typed-URL autolink for the WYSIWYG editor.
+ *
+ * When a local user finishes a GFM-shape URL token and types a word boundary
+ * (space or Enter), the token becomes a link mark carrying
+ * `linkStyle: 'gfm-autolink'` so it serializes as a bare literal rather than
+ * `[url](url)`. Detection reuses stock TipTap's changed-range / last-word scan
+ * but swaps linkify's tokenizer for the GFM-parity recognizer, so only what the
+ * markdown pipeline itself would linkify ever converts.
+ *
+ * Two properties keep this safe in OK's multi-writer CRDT, and both are
+ * structural rather than conventional:
+ *
+ * - Origin guard. Peer, agent, disk-load, and observer-echo edits re-enter the
+ * editor as ProseMirror transactions tagged with `ySyncPluginKey` meta.
+ * Linkifying any of those would rewrite another writer's text across every
+ * connected client. The plugin skips the whole batch the moment any member
+ * carries that meta (fail-closed). A pooled, backgrounded editor also
+ * receives those updates and its provider is live even while hidden, so
+ * detection additionally requires the bound view to be the active/focused
+ * one — a stray local write into a hidden editor can never convert. This is
+ * the same `ySyncPluginKey` origin discipline as source-dirty-observer.ts.
+ *
+ * - Undo isolation. The mark is added as its OWN ProseMirror dispatch a
+ * microtask after the typing flush, never returned from `appendTransaction`.
+ * Returning it would weld the mark and the keystroke into a single Yjs
+ * transaction — inseparable by any undo-manager setting — and Y.UndoManager
+ * would still merge separate transactions landing within its default
+ * `captureTimeout` (500 ms — Y.js's default, not OK config) into one
+ * stack item. The separate dispatch undoes the first
+ * weld; `stopCapturing()` immediately before AND after the dispatch undoes
+ * the second, so one Cmd+Z removes just the link (typed text intact) and
+ * the user's next keystrokes never merge into the mark's step. The deferral
+ * also lets the flush read `view.composing` (unreachable inside
+ * appendTransaction) and re-validate the target range, which may have moved
+ * or vanished in the gap.
+ */
+
+import type { LinkStyle } from '@inkeep/open-knowledge-core';
+import {
+ combineTransactionSteps,
+ Extension,
+ findChildrenInRange,
+ getChangedRanges,
+ getMarksBetween,
+ type NodeWithPos,
+} from '@tiptap/core';
+import { type EditorState, Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
+import type { EditorView } from '@tiptap/pm/view';
+import { ySyncPluginKey } from '@tiptap/y-tiptap';
+import { detectGfmLinkToken } from './gfm-link-detector';
+import { dispatchAsOwnUndoStep } from './undo-isolation';
+
+// TipTap's UNICODE_WHITESPACE_PATTERN (@tiptap/extension-link), the class
+// stock autolink word-splits on, so the boundary notion here agrees with the
+// tokenizer this plugin descends from rather than JS `\s`, which omits some
+// of these code points.
+const WHITESPACE_CLASS = '\\u0000-\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u2029\\u205F\\u3000';
+const WHITESPACE_SPLIT = new RegExp(`[${WHITESPACE_CLASS}]`);
+const TRAILING_WHITESPACE = new RegExp(`[${WHITESPACE_CLASS}]$`);
+
+/** The mark this plugin creates and the fidelity attr that makes it serialize
+ * to a bare literal. Kept as constants so the flush and its tests agree. */
+const LINK_MARK = 'link';
+const GFM_AUTOLINK_STYLE: LinkStyle = 'gfm-autolink';
+
+/** Meta key for suppressing autolink on a transaction (same string value
+ * TipTap's Link extension uses internally, but defined and exported HERE) —
+ * set by the Link mark's setLink/unsetLink commands and by the clipboard
+ * dispatcher, both of which manage their own link bytes. */
+export const PREVENT_AUTOLINK_META = 'preventAutolink';
+
+const gfmAutolinkPluginKey = new PluginKey('gfmAutolink');
+
+interface LinkifyCandidate {
+ from: number;
+ to: number;
+ href: string;
+ /** The exact literal expected at [from, to] at dispatch time; a mismatch
+ * means the doc changed under us and the conversion is abandoned. */
+ text: string;
+}
+
+interface GfmAutolinkPluginOptions {
+ /**
+ * Whether the bound editor is the one that should linkify. Defaults to DOM
+ * focus, which is false for every pooled/background editor and true for the
+ * foreground editor a user is typing into. Overridable so headless tests can
+ * exercise the local-write path without driving real focus.
+ */
+ isActiveEditor?: (view: EditorView) => boolean;
+}
+
+function rangeHasCodeMark(view: EditorView, from: number, to: number): boolean {
+ const codeMark = view.state.schema.marks.code;
+ if (!codeMark) return false;
+ return view.state.doc.rangeHasMark(from, to, codeMark);
+}
+
+/**
+ * Scan the batch's changed ranges for a GFM-shape token that a just-typed word
+ * boundary completed. Pure with respect to the document — it only reads state
+ * and returns ranges; the caller performs the (deferred) mutation.
+ */
+function detectCandidates(
+ oldState: EditorState,
+ newState: EditorState,
+ transactions: readonly Transaction[],
+): LinkifyCandidate[] {
+ const results: LinkifyCandidate[] = [];
+ const transform = combineTransactionSteps(oldState.doc, [...transactions]);
+ const changes = getChangedRanges(transform);
+
+ for (const { newRange } of changes) {
+ const nodesInChangedRanges = findChildrenInRange(
+ newState.doc,
+ newRange,
+ (node) => node.isTextblock,
+ );
+
+ let textBlock: NodeWithPos | undefined;
+ let textBeforeWhitespace: string | undefined;
+
+ if (nodesInChangedRanges.length > 1) {
+ // Enter split a block: scan the first block's whole text (the trailing
+ // token now sits at its end). Stock takes this same ungated path.
+ textBlock = nodesInChangedRanges[0];
+ textBeforeWhitespace = newState.doc.textBetween(
+ textBlock.pos,
+ textBlock.pos + textBlock.node.nodeSize,
+ undefined,
+ ' ',
+ );
+ } else if (nodesInChangedRanges.length === 1) {
+ // Single block: only fire when the change ends in whitespace — the "a
+ // boundary key was just typed" signal.
+ const endText = newState.doc.textBetween(newRange.from, newRange.to, ' ', ' ');
+ if (!TRAILING_WHITESPACE.test(endText)) continue;
+ textBlock = nodesInChangedRanges[0];
+ textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, ' ');
+ }
+
+ if (!textBlock || !textBeforeWhitespace) continue;
+ // Code blocks are plain-text-only; never linkify inside one.
+ if (textBlock.node.type.spec.code) continue;
+
+ const words = textBeforeWhitespace.split(WHITESPACE_SPLIT).filter(Boolean);
+ const lastWord = words[words.length - 1];
+ if (!lastWord) continue;
+
+ const detected = detectGfmLinkToken(lastWord);
+ if (!detected) continue;
+
+ // The recognizer head-anchors the linkified literal to the start of the
+ // word, so the span is [wordStart, wordStart + text.length]. +1 crosses the
+ // textblock's opening boundary into its content (stock's `link.start + 1`).
+ const from = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWord) + 1;
+ const to = from + detected.text.length;
+ results.push({ from, to, href: detected.href, text: detected.text });
+ }
+
+ return results;
+}
+
+/**
+ * The ProseMirror plugin, as a factory so `isActiveEditor` can be injected.
+ * Tests drive it through the `GfmAutolink` extension (which threads
+ * `isActiveEditor` via its options), so the factory stays module-private.
+ */
+function gfmAutolinkPlugin(options: GfmAutolinkPluginOptions = {}): Plugin {
+ const isActiveEditor = options.isActiveEditor ?? ((view: EditorView) => view.hasFocus());
+
+ let boundView: EditorView | null = null;
+ let scheduled = false;
+ const pending: LinkifyCandidate[] = [];
+
+ const flush = (): void => {
+ scheduled = false;
+ const view = boundView;
+ const candidates = pending.splice(0, pending.length);
+ if (!view || view.isDestroyed) return;
+ // No conversion mid-IME-composition, and never in a view that lost focus
+ // between detection and this microtask.
+ if (view.composing) return;
+ if (!isActiveEditor(view)) return;
+
+ const markType = view.state.schema.marks[LINK_MARK];
+ if (!markType) return;
+
+ let tr = view.state.tr;
+ let changed = false;
+ const docSize = view.state.doc.content.size;
+
+ for (const candidate of candidates) {
+ const { from, to, href, text } = candidate;
+ // Re-validate: the doc may have shifted, shrunk, or been rewritten in the
+ // gap. A range that no longer holds the exact detected literal is stale.
+ if (from < 0 || to > docSize || from >= to) continue;
+ if (view.state.doc.textBetween(from, to) !== text) continue;
+ // Skip if the span is already linked or lives inside inline code.
+ if (getMarksBetween(from, to, view.state.doc).some((m) => m.mark.type === markType)) continue;
+ if (rangeHasCodeMark(view, from, to)) continue;
+
+ tr = tr.addMark(from, to, markType.create({ href, linkStyle: GFM_AUTOLINK_STYLE }));
+ changed = true;
+ }
+
+ if (!changed) return;
+ // Tag our own mark-add so re-entering appendTransaction skips it.
+ tr = tr.setMeta(PREVENT_AUTOLINK_META, true);
+
+ // The typing that triggered detection is < captureTimeout old, so the
+ // mark-add must land as its own undo stack item (see undo-isolation.ts
+ // for the split-before/close-after contract). flush() runs as a bare
+ // microtask — an escaping throw would be an uncaught async error and the
+ // conversion would just vanish, so degrade like the clipboard dispatcher:
+ // log and drop this batch (the typed text itself is untouched).
+ try {
+ dispatchAsOwnUndoStep(view, tr);
+ } catch (err) {
+ console.warn(
+ '[gfm-autolink] linkify dispatch failed',
+ { candidates: candidates.map((c) => ({ from: c.from, to: c.to, href: c.href })) },
+ err,
+ );
+ }
+ };
+
+ return new Plugin({
+ key: gfmAutolinkPluginKey,
+ view(editorView) {
+ boundView = editorView;
+ return {
+ destroy() {
+ boundView = null;
+ pending.length = 0;
+ scheduled = false;
+ },
+ };
+ },
+ appendTransaction(transactions, oldState, newState) {
+ // Fail-closed origin guard: any CRDT-sync-tagged transaction in the batch
+ // (remote peer, agent, disk load, observer echo) disqualifies the whole
+ // batch. Also honour explicit autolink suppression.
+ if (transactions.some((tr) => tr.getMeta(ySyncPluginKey))) return null;
+ if (transactions.some((tr) => tr.getMeta(PREVENT_AUTOLINK_META))) return null;
+
+ const docChanged = transactions.some((tr) => tr.docChanged) && !oldState.doc.eq(newState.doc);
+ if (!docChanged) return null;
+
+ // Only the active/focused editor linkifies — closes the pooled-hidden-
+ // editor local-write vector the origin guard alone can't see.
+ if (!boundView || !isActiveEditor(boundView)) return null;
+
+ const candidates = detectCandidates(oldState, newState, transactions);
+ if (candidates.length === 0) return null;
+
+ pending.push(...candidates);
+ if (!scheduled) {
+ scheduled = true;
+ queueMicrotask(flush);
+ }
+ // Never return the mark-add here — it must land as its own dispatch.
+ return null;
+ },
+ });
+}
+
+export interface GfmAutolinkOptions {
+ isActiveEditor?: (view: EditorView) => boolean;
+}
+
+/**
+ * App editor extension wrapper. Registered only in the app's editor extension
+ * list, never in the core/persistence set, so linkification stays a client-side
+ * behavior of the interactive editor.
+ */
+export const GfmAutolink = Extension.create({
+ name: 'gfmAutolink',
+
+ addOptions() {
+ return {
+ isActiveEditor: undefined,
+ };
+ },
+
+ addProseMirrorPlugins() {
+ return [gfmAutolinkPlugin({ isActiveEditor: this.options.isActiveEditor })];
+ },
+});
diff --git a/packages/app/src/editor/gfm-link-detector.test.ts b/packages/app/src/editor/gfm-link-detector.test.ts
new file mode 100644
index 00000000..f7da332e
--- /dev/null
+++ b/packages/app/src/editor/gfm-link-detector.test.ts
@@ -0,0 +1,190 @@
+import { describe, expect, test } from 'bun:test';
+import { MarkdownManager, SAFE_URL_SCHEMES, sharedExtensions } from '@inkeep/open-knowledge-core';
+import type { JSONContent } from '@tiptap/core';
+import { detectGfmLinkToken, type GfmLinkToken } from './gfm-link-detector';
+
+// Real pipeline, shared with production — the parity oracle. Constructing one
+// manager pulls the same remark-gfm autolink transform the editor uses.
+const mdManager = new MarkdownManager({ extensions: sharedExtensions });
+
+/** Walk a parsed doc for the first text run carrying a link mark and return
+ * the exact {href, text} the pipeline produced (or null if nothing linked). */
+function parseFirstLink(token: string): GfmLinkToken | null {
+ function walk(node: JSONContent): GfmLinkToken | null {
+ const mark = node.marks?.find((m) => m.type === 'link');
+ if (mark) {
+ return { href: String(mark.attrs?.href ?? ''), text: node.text ?? '' };
+ }
+ for (const child of node.content ?? []) {
+ const found = walk(child);
+ if (found) return found;
+ }
+ return null;
+ }
+ return walk(mdManager.parse(token));
+}
+
+// The locked acceptance matrix. `null` = stays plain text.
+const MATRIX: Array<{ token: string; expected: GfmLinkToken | null }> = [
+ {
+ token: 'https://example.com',
+ expected: { href: 'https://example.com', text: 'https://example.com' },
+ },
+ {
+ token: 'www.example.com',
+ expected: { href: 'http://www.example.com', text: 'www.example.com' },
+ },
+ { token: 'a@b.com', expected: { href: 'mailto:a@b.com', text: 'a@b.com' } },
+ { token: 'example.com', expected: null },
+ { token: 'AGENTS.md', expected: null },
+ { token: 'package.json', expected: null },
+ { token: 'localhost:5173', expected: null },
+ { token: 'foo.bar', expected: null },
+ { token: 'v1.2.3', expected: null },
+ { token: '192.168.1.1', expected: null },
+ // Explicit-scheme dotless hosts: micromark's http_autolink skips the
+ // dotted-domain requirement, so these linkify and round-trip as bare
+ // literals (the schemeless rows above stay rejected).
+ {
+ token: 'http://localhost:5174',
+ expected: { href: 'http://localhost:5174', text: 'http://localhost:5174' },
+ },
+ {
+ token: 'http://localhost:5174/#/doc',
+ expected: { href: 'http://localhost:5174/#/doc', text: 'http://localhost:5174/#/doc' },
+ },
+ { token: 'http://localhost', expected: { href: 'http://localhost', text: 'http://localhost' } },
+ {
+ token: 'http://127.0.0.1:8080/x',
+ expected: { href: 'http://127.0.0.1:8080/x', text: 'http://127.0.0.1:8080/x' },
+ },
+ { token: 'localhost:5174', expected: null },
+ { token: 'http://foo_bar', expected: null },
+ // Ported edge branches — splitUrl's trailing-punctuation / unbalanced-paren
+ // strips, isCorrectDomain's underscore reject, the email bad-tail reject,
+ // and their keep-side complements. In this array they get parity coverage
+ // against the real parse like every other row, which is what guards the
+ // hand-port against upstream drift.
+ {
+ token: 'https://example.com.',
+ expected: { href: 'https://example.com', text: 'https://example.com' },
+ },
+ {
+ token: 'https://example.com)',
+ expected: { href: 'https://example.com', text: 'https://example.com' },
+ },
+ { token: 'https://foo_bar.com', expected: null },
+ { token: 'a@b.c_', expected: null },
+ { token: 'user@host.com-', expected: null },
+ { token: 'a@b.co1', expected: null },
+ { token: 'a@b_c.com', expected: { href: 'mailto:a@b_c.com', text: 'a@b_c.com' } },
+ {
+ token: 'www.a_b.example.com',
+ expected: { href: 'http://www.a_b.example.com', text: 'www.a_b.example.com' },
+ },
+];
+
+describe('detectGfmLinkToken — acceptance matrix', () => {
+ for (const { token, expected } of MATRIX) {
+ test(`${token} → ${expected ? `link ${expected.href}` : 'plain text'}`, () => {
+ expect(detectGfmLinkToken(token)).toEqual(expected);
+ });
+ }
+});
+
+describe('detectGfmLinkToken — parity with MarkdownManager.parse', () => {
+ // The single-source-of-truth contract: for every matrix token the detector
+ // agrees with what the real pipeline linkifies (verdict AND href).
+ const parityTokens = [
+ ...MATRIX.map((row) => row.token),
+ 'http://example.com',
+ 'HTTPS://Example.com',
+ 'WWW.Example.com',
+ 'a.b@c.co.uk',
+ 'ftp://files.example.com',
+ 'https://en.wikipedia.org/wiki/Foo_(bar)',
+ ];
+ for (const token of parityTokens) {
+ test(`agrees with the pipeline for ${token}`, () => {
+ expect(detectGfmLinkToken(token)).toEqual(parseFirstLink(token));
+ });
+ }
+});
+
+describe('detectGfmLinkToken — bare domains are rejected structurally, not by TLD', () => {
+ // No TLD table ships. A bare domain never linkifies regardless of how
+ // real its suffix looks; only a scheme, `www.`, or `@` promotes it.
+ for (const token of [
+ 'example.com',
+ 'foo.bar',
+ 'a.io',
+ 'my-site.dev',
+ 'stripe.com',
+ '192.168.1.1',
+ ]) {
+ test(`${token} stays plain text`, () => {
+ expect(detectGfmLinkToken(token)).toBeNull();
+ });
+ }
+});
+
+describe('detectGfmLinkToken — href scheme stays inside the allowlist', () => {
+ test('every produced href uses a SAFE_URL_SCHEMES scheme', () => {
+ const allowed = SAFE_URL_SCHEMES.map((s) => `${s}:`);
+ for (const token of [
+ 'https://example.com',
+ 'http://example.com',
+ 'www.example.com',
+ 'a@b.com',
+ ]) {
+ const result = detectGfmLinkToken(token);
+ expect(result).not.toBeNull();
+ const href = result?.href.toLowerCase() ?? '';
+ expect(allowed.some((prefix) => href.startsWith(prefix))).toBe(true);
+ }
+ });
+
+ test('ftp:// is a safe scheme but not a GFM shape, so it does not convert', () => {
+ // Guards against widening the recognizer to the full scheme allowlist:
+ // the shape rules are GFM's (http/https/www/email), not SAFE_URL_SCHEMES.
+ expect(detectGfmLinkToken('ftp://files.example.com')).toBeNull();
+ });
+});
+
+describe('detectGfmLinkToken — href shape details', () => {
+ test('www. tokens get an http:// href, not https://', () => {
+ expect(detectGfmLinkToken('www.example.com')?.href).toBe('http://www.example.com');
+ });
+
+ test('scheme match is case-insensitive and preserves original casing', () => {
+ expect(detectGfmLinkToken('HTTPS://Example.com')).toEqual({
+ href: 'HTTPS://Example.com',
+ text: 'HTTPS://Example.com',
+ });
+ expect(detectGfmLinkToken('WWW.Example.com')?.href).toBe('http://WWW.Example.com');
+ });
+
+ test('email tokens become mailto: hrefs with the address as text', () => {
+ expect(detectGfmLinkToken('a.b@c.co.uk')).toEqual({
+ href: 'mailto:a.b@c.co.uk',
+ text: 'a.b@c.co.uk',
+ });
+ });
+
+ test('trailing sentence punctuation is split off the linkified span', () => {
+ expect(detectGfmLinkToken('https://example.com.')).toEqual({
+ href: 'https://example.com',
+ text: 'https://example.com',
+ });
+ });
+
+ test('a closing paren that balances an opening paren stays in the link', () => {
+ expect(detectGfmLinkToken('https://en.wikipedia.org/wiki/Foo_(bar)')?.text).toBe(
+ 'https://en.wikipedia.org/wiki/Foo_(bar)',
+ );
+ });
+
+ test('empty string is not a link', () => {
+ expect(detectGfmLinkToken('')).toBeNull();
+ });
+});
diff --git a/packages/app/src/editor/gfm-link-detector.ts b/packages/app/src/editor/gfm-link-detector.ts
new file mode 100644
index 00000000..b9f3d283
--- /dev/null
+++ b/packages/app/src/editor/gfm-link-detector.ts
@@ -0,0 +1,168 @@
+/**
+ * Pure, editor-independent recognizer for the three GFM autolink-literal
+ * token shapes — protocol URLs (`https?://…`), `www.` domains, and bare
+ * emails (`local@domain`). Given a single whitespace-free token it answers:
+ * would the markdown pipeline's own parse turn this into a link, and to
+ * what href?
+ *
+ * Linkification only fires when GFM itself would, so a converted token
+ * serializes to a bare literal that re-parses to the identical link. That
+ * is why bare domains (`example.com`), filenames (`AGENTS.md`), and
+ * `localhost:5173` must NOT match: GFM never linkifies them, so the
+ * recognizer must not either. The dotted-domain requirement applies to the
+ * schemeless (`www.`) arm only — micromark's `http_autolink` production
+ * skips it when an explicit scheme is present (a documented divergence from
+ * the GFM spec prose), so `http://localhost:5174` linkifies and round-trips
+ * as a bare literal. The logic is ported from
+ * `mdast-util-gfm-autolink-literal` (the pipeline's own transform) so the
+ * two agree by construction; the co-located parity test pins the agreement
+ * against the real `MarkdownManager.parse`.
+ *
+ * Href transforms mirror GFM exactly: `www.` prepends `http://` (NOT
+ * https — the pipeline's own default), email prepends `mailto:`, and
+ * protocol URLs keep their scheme verbatim with original casing. Trailing
+ * sentence punctuation and unbalanced closing parens are excluded from the
+ * linkified span, matching how GFM splits them off.
+ *
+ * No ProseMirror / editor imports: `clipboard/lone-url.ts` (a plain string
+ * module) and unit tests both consume this as a pure string function.
+ *
+ * Coupled artifact: this is a hand-port of `mdast-util-gfm-autolink-literal`
+ * (which exposes no synchronous string classifier) and must track it on every
+ * markdown dependency bump. The drift guards are the parity tests in
+ * `gfm-link-detector.test.ts` (token matrix incl. trailing punctuation,
+ * balanced parens, structural bad-domain rejection, all checked against the
+ * real `MarkdownManager.parse`) — they run in `bun run check`.
+ */
+
+import { SAFE_URL_SCHEMES } from '@inkeep/open-knowledge-core';
+
+export type GfmLinkToken = {
+ /** Resolved link target: scheme-prepended for www/email, verbatim otherwise. */
+ href: string;
+ /** The linkified literal — the display text and the bytes that serialize back. */
+ text: string;
+};
+
+// Head-anchored ports of the two `transformGfmAutolinkLiterals` regexes. The
+// token is already whitespace-delimited, so the upstream "previous character"
+// guard (start / whitespace / punctuation) is always satisfied at index 0 and
+// is omitted here.
+const URL_HEAD = /^(https?:\/\/|www(?=\.))([-.\w]+)([^ \t\r\n]*)/i;
+const EMAIL_HEAD = /^([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/;
+// GFM rejects an email whose domain's final label ends in `-`, a digit, or `_`.
+const EMAIL_LABEL_BAD_TAIL = /[-\d_]$/;
+
+const ALLOWED_SCHEME_PREFIXES = SAFE_URL_SCHEMES.map((scheme) => `${scheme}:`);
+
+function schemeAllowed(href: string): boolean {
+ const lower = href.toLowerCase();
+ return ALLOWED_SCHEME_PREFIXES.some((prefix) => lower.startsWith(prefix));
+}
+
+function countChar(haystack: string, char: string): number {
+ let n = 0;
+ for (const c of haystack) {
+ if (c === char) n++;
+ }
+ return n;
+}
+
+/** GFM's per-label check on the final two dot-separated labels: neither may
+ * contain `_` or lack an alphanumeric character. Applies to BOTH url arms —
+ * micromark keeps the underscore restriction even for scheme'd URLs. */
+function hasCorrectDomainLabels(domain: string): boolean {
+ const parts = domain.split('.');
+ const last = parts[parts.length - 1];
+ const penultimate = parts[parts.length - 2];
+ if (last && (/_/.test(last) || !/[a-zA-Z\d]/.test(last))) return false;
+ if (penultimate && (/_/.test(penultimate) || !/[a-zA-Z\d]/.test(penultimate))) return false;
+ return true;
+}
+
+/** The schemeless (`www.`) arm additionally requires a dotted host. An
+ * explicit `http(s)://` scheme skips this — micromark's `http_autolink`
+ * accepts dotless hosts (`http://localhost:5174`), and the detector must
+ * match the parse or typed conversions would diverge from round-trip. */
+function isCorrectSchemelessDomain(domain: string): boolean {
+ return domain.split('.').length >= 2 && hasCorrectDomainLabels(domain);
+}
+
+/** GFM's trailing-punctuation split: peel a run of sentence punctuation off
+ * the end, but keep closing parens that balance an opening paren inside the
+ * URL (Wikipedia-style `…/Foo_(disambiguation)`). Returns [linkified, trailing]. */
+function splitUrl(url: string): [string, string] {
+ const trailMatch = /[!"&'),.:;<>?\]}]+$/.exec(url);
+ if (!trailMatch) return [url, ''];
+
+ let head = url.slice(0, trailMatch.index);
+ let trail = trailMatch[0];
+ let closingParenIndex = trail.indexOf(')');
+ const openingParens = countChar(head, '(');
+ let closingParens = countChar(head, ')');
+
+ while (closingParenIndex !== -1 && openingParens > closingParens) {
+ head += trail.slice(0, closingParenIndex + 1);
+ trail = trail.slice(closingParenIndex + 1);
+ closingParenIndex = trail.indexOf(')');
+ closingParens++;
+ }
+
+ return [head, trail];
+}
+
+function detectUrl(token: string): GfmLinkToken | null {
+ const match = URL_HEAD.exec(token);
+ if (!match) return null;
+
+ let protocol = match[1];
+ let domain = match[2];
+ const path = match[3];
+ let prefix = '';
+
+ // A `www.` match carries no scheme in the source; fold it into the domain
+ // and let the pipeline's `http://` prefix supply one.
+ const schemeless = /^w/i.test(protocol);
+ if (schemeless) {
+ domain = protocol + domain;
+ protocol = '';
+ prefix = 'http://';
+ }
+
+ if (schemeless ? !isCorrectSchemelessDomain(domain) : !hasCorrectDomainLabels(domain)) {
+ return null;
+ }
+
+ const [core] = splitUrl(domain + path);
+ if (!core) return null;
+
+ const text = protocol + core;
+ const href = prefix + protocol + core;
+ if (!schemeAllowed(href)) return null;
+ return { href, text };
+}
+
+function detectEmail(token: string): GfmLinkToken | null {
+ const match = EMAIL_HEAD.exec(token);
+ if (!match) return null;
+
+ const local = match[1];
+ const label = match[2];
+ if (EMAIL_LABEL_BAD_TAIL.test(label)) return null;
+
+ const text = `${local}@${label}`;
+ const href = `mailto:${text}`;
+ if (!schemeAllowed(href)) return null;
+ return { href, text };
+}
+
+/**
+ * Classify a completed token. Returns the `{ href, text }` to link, or null
+ * when GFM would leave the token as plain text. `text` is the exact literal
+ * to keep in the document (which may be shorter than the input token when
+ * trailing punctuation is split off); `href` is its resolved target.
+ */
+export function detectGfmLinkToken(token: string): GfmLinkToken | null {
+ if (!token) return null;
+ return detectUrl(token) ?? detectEmail(token);
+}
diff --git a/packages/app/src/editor/inline-link-input-rule.test.ts b/packages/app/src/editor/inline-link-input-rule.test.ts
new file mode 100644
index 00000000..82006df3
--- /dev/null
+++ b/packages/app/src/editor/inline-link-input-rule.test.ts
@@ -0,0 +1,209 @@
+/**
+ * Typed `[text](url)` input rule — conversion + href policy, exclusion
+ * contexts, and the one-undo-restores-the-literal contract against a real
+ * y-undo binding.
+ *
+ * Input rules fire only from `handleTextInput`, so these rigs type through
+ * `view.someProp('handleTextInput', …)` with the same unhandled-fallback
+ * insertion the real DOM input path performs — a bare `insertText` dispatch
+ * never triggers a rule.
+ */
+
+import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
+import type { Editor } from '@tiptap/core';
+import * as Y from 'yjs';
+import {
+ firstLinkAttrs,
+ linkHrefs,
+ mountCollabEditor,
+ mountLightEditor,
+ readUndoManager,
+} from './editor-rig.test-helper';
+import { InlineLinkInputRule } from './inline-link-input-rule';
+import { flushMicrotasksAndTimers, installDomGlobals } from './walk-currency-test-harness';
+
+let restoreDomGlobals: (() => void) | null = null;
+
+beforeAll(() => {
+ restoreDomGlobals = installDomGlobals();
+});
+
+afterAll(() => {
+ restoreDomGlobals?.();
+ restoreDomGlobals = null;
+});
+
+function makeEditor(opts: { content?: string } = {}): Editor {
+ return mountLightEditor({ content: opts.content, extensions: [InlineLinkInputRule] });
+}
+
+/**
+ * Type text the way the DOM input path does: each character goes through
+ * `handleTextInput` (where input rules run) and falls back to a plain
+ * insertion when no rule claims it.
+ */
+function typeText(editor: Editor, text: string): void {
+ for (const char of text) {
+ const { from, to } = editor.state.selection;
+ const deflt = () => editor.state.tr.insertText(char, from, to);
+ const handled = editor.view.someProp('handleTextInput', (handleTextInput) =>
+ handleTextInput(editor.view, from, to, char, deflt),
+ );
+ if (!handled) {
+ editor.view.dispatch(deflt());
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Conversion
+// ---------------------------------------------------------------------------
+
+describe('inline-link input rule — conversion', () => {
+ test('typing [text](url) converts to linked display text on the closing paren', async () => {
+ const editor = makeEditor();
+ try {
+ typeText(editor, '[docs](https://example.com)');
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toBe('docs');
+ const attrs = firstLinkAttrs(editor);
+ expect(attrs?.href).toBe('https://example.com');
+ expect(attrs?.linkStyle).toBe('inline');
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('a relative target is allowed (internal-link contract)', async () => {
+ const editor = makeEditor();
+ try {
+ typeText(editor, '[guide](/docs/start)');
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toBe('guide');
+ expect(firstLinkAttrs(editor)?.href).toBe('/docs/start');
+ } finally {
+ editor.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Href policy
+// ---------------------------------------------------------------------------
+
+describe('inline-link input rule — href policy', () => {
+ test('an empty URL [text]() stays literal', async () => {
+ const editor = makeEditor();
+ try {
+ typeText(editor, '[x]()');
+ await flushMicrotasksAndTimers();
+ expect(editor.state.doc.textContent).toBe('[x]()');
+ expect(linkHrefs(editor)).toEqual([]);
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('a disallowed scheme leaves the literal untouched', async () => {
+ const editor = makeEditor();
+ try {
+ typeText(editor, '[x](javascript:alert)');
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toBe('[x](javascript:alert)');
+ expect(linkHrefs(editor)).toEqual([]);
+ } finally {
+ editor.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Exclusion contexts
+// ---------------------------------------------------------------------------
+
+describe('inline-link input rule — exclusions', () => {
+ test('does not fire inside a code block', async () => {
+ const editor = makeEditor({ content: '
x
' });
+ try {
+ // Caret at the end of the code block content.
+ editor.commands.setTextSelection(editor.state.doc.content.size - 1);
+ typeText(editor, '[a](https://b.com)');
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toBe('x[a](https://b.com)');
+ expect(linkHrefs(editor)).toEqual([]);
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ test('wikilink shorthand is excluded structurally', async () => {
+ const editor = makeEditor();
+ try {
+ typeText(editor, '[[Page]]');
+ await flushMicrotasksAndTimers();
+
+ expect(linkHrefs(editor)).toEqual([]);
+ } finally {
+ editor.destroy();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Undo isolation (real y-undo binding)
+// ---------------------------------------------------------------------------
+
+describe('inline-link input rule — one undo restores the literal', () => {
+ test('a single undo brings back [text](url) with the text intact', async () => {
+ const ydoc = new Y.Doc();
+ const editor = mountCollabEditor(ydoc, [InlineLinkInputRule]);
+ try {
+ typeText(editor, '[docs](https://example.com)');
+ await flushMicrotasksAndTimers();
+ expect(editor.state.doc.textContent).toBe('docs');
+ expect(linkHrefs(editor)).toEqual(['https://example.com']);
+
+ const undoManager = readUndoManager(editor);
+ expect(undoManager).not.toBeNull();
+ undoManager?.undo();
+ await flushMicrotasksAndTimers();
+
+ expect(editor.state.doc.textContent).toBe('[docs](https://example.com)');
+ expect(linkHrefs(editor)).toEqual([]);
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+
+ test('typing after a conversion stays its own undo step (no merge into the collapse)', async () => {
+ const ydoc = new Y.Doc();
+ const editor = mountCollabEditor(ydoc, [InlineLinkInputRule]);
+ try {
+ typeText(editor, '[docs](https://example.com)');
+ await flushMicrotasksAndTimers();
+ expect(editor.state.doc.textContent).toBe('docs');
+
+ // Keystrokes after the collapse land within captureTimeout of it; the
+ // closing stopCapturing must keep them OUT of the collapse's undo item.
+ typeText(editor, ' more');
+ await flushMicrotasksAndTimers();
+ expect(editor.state.doc.textContent).toBe('docs more');
+
+ const undoManager = readUndoManager(editor);
+ undoManager?.undo();
+ await flushMicrotasksAndTimers();
+
+ // Only the trailing typing is removed; the converted link survives.
+ expect(editor.state.doc.textContent).toBe('docs');
+ expect(linkHrefs(editor)).toEqual(['https://example.com']);
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+ });
+});
diff --git a/packages/app/src/editor/inline-link-input-rule.ts b/packages/app/src/editor/inline-link-input-rule.ts
new file mode 100644
index 00000000..62ffce6d
--- /dev/null
+++ b/packages/app/src/editor/inline-link-input-rule.ts
@@ -0,0 +1,120 @@
+/**
+ * Typed `[text](url)` input rule for the WYSIWYG editor.
+ *
+ * When a local user closes a well-formed inline-link literal by typing the
+ * final `)`, the `[text](url)` span collapses to its display `text` carrying a
+ * `link` mark. The mark keeps the schema-default `linkStyle: 'inline'`, so it
+ * serializes straight back to `[text](url)` (unlike the bare-literal
+ * `gfm-autolink` style the typed-URL plugin uses). Scope is deliberately narrow
+ * — the one universal Markdown link shorthand editors share (Outline, Plate).
+ *
+ * Safety and undo, both structural:
+ *
+ * - Origin safety by construction. Input rules fire only from
+ * `handleTextInput` — a local, focused DOM keystroke — never from a y-sync
+ * transaction (remote peer, agent, disk load, observer echo) or a
+ * programmatic write into a pooled/background editor. So the "never convert
+ * another writer's content" invariant holds without an explicit origin
+ * guard, unlike the appendTransaction-based typed-URL plugin.
+ * - Href policy. The URL must pass `isAllowedLinkUri` (the shared scheme
+ * allowlist); a `javascript:`-style payload leaves the literal untouched.
+ * Relative targets resolve against the placeholder base and are allowed,
+ * matching the internal-link contract.
+ * - Undo isolation, mark kept in step with the trigger. The rule does NOT
+ * consume the `)`; it lets the paren land through normal typing (so the
+ * literal is complete in the doc) and collapses in a deferred separate
+ * dispatch a microtask later, split from the typing by `stopCapturing()`
+ * before and after. One Cmd+Z then reverts just the collapse, restoring the
+ * full `[text](url)` literal — the same trigger-char-kept, one-undo contract
+ * the gfm-autolink path has. Absent a y-undo binding there is no capture
+ * stack and nothing to split.
+ */
+
+import { isAllowedLinkUri } from '@inkeep/open-knowledge-core';
+import { Extension, InputRule } from '@tiptap/core';
+import type { EditorView } from '@tiptap/pm/view';
+import { dispatchAsOwnUndoStep } from './undo-isolation';
+
+/** `[text](url)` ending at the caret. `text` = one+ non-`]`; `url` = one+
+ * non-`)`/non-whitespace (inline links whose URL needs spaces use `<>`, out
+ * of scope). The trailing `)` anchors the rule to the closing keystroke.
+ * `[[Page]]` wikilinks never match — they carry no `](` after the first `]`. */
+const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)$/;
+
+const LINK_MARK = 'link';
+
+/**
+ * Collapse `[text](url)` (already complete in the doc, including the just-typed
+ * `)`) at `[from, from+len]` down to `text` carrying a link mark. Runs a
+ * microtask after the paren landed, so it re-validates the span first — the doc
+ * may have shifted or been rewritten in the gap.
+ */
+function collapseToLink(
+ view: EditorView,
+ from: number,
+ fullMatch: string,
+ text: string,
+ href: string,
+): void {
+ if (view.isDestroyed || view.composing) return;
+ const { state } = view;
+ const markType = state.schema.marks[LINK_MARK];
+ if (!markType) return;
+
+ const to = from + fullMatch.length;
+ if (from < 0 || to > state.doc.content.size) return;
+ if (state.doc.textBetween(from, to) !== fullMatch) return;
+ // Don't nest inside an existing link.
+ if (state.doc.rangeHasMark(from, to, markType)) return;
+
+ // The collapse must be its own undo stack item so one undo restores the
+ // literal (see undo-isolation.ts for the split-before/close-after contract).
+ // Only the dispatch is guarded — it crosses into third-party plugin hooks;
+ // the guards above are internal-trusted and should fail loud.
+ const linked = state.schema.text(text, [markType.create({ href })]);
+ try {
+ dispatchAsOwnUndoStep(view, state.tr.replaceRangeWith(from, to, linked));
+ } catch (err) {
+ console.warn('[inline-link-rule] collapse dispatch failed', { from, text, href }, err);
+ }
+}
+
+export const InlineLinkInputRule = Extension.create({
+ name: 'inlineLinkInputRule',
+
+ addInputRules() {
+ const editor = this.editor;
+ return [
+ new InputRule({
+ find: INLINE_LINK_RE,
+ // Returning null (no steps) means TipTap does not consume the match:
+ // the `)` inserts through the normal path and the deferred collapse
+ // below does the linkification. Code blocks and inline-code contexts
+ // are already refused upstream by TipTap's input-rule runner.
+ handler: ({ state, range, match }) => {
+ const text = match[1];
+ const url = match[2];
+ if (!text || !url) return null;
+ if (!isAllowedLinkUri(url)) return null;
+
+ const markType = state.schema.marks[LINK_MARK];
+ if (!markType) return null;
+ if (state.doc.rangeHasMark(range.from, range.to, markType)) return null;
+
+ // range.from is the start of `[` in the pre-paren doc; positions
+ // before the caret are stable across the paren insertion, so the
+ // completed literal will span [range.from, range.from + match[0].length].
+ const from = range.from;
+ const fullMatch = match[0];
+ // Capture the view eagerly: input rules fire from handleTextInput
+ // (the view is mounted here), while `editor.view` re-read inside the
+ // microtask is a throwing proxy if a recycle starts in the gap.
+ // collapseToLink's isDestroyed guard covers teardown after capture.
+ const view = editor.view;
+ queueMicrotask(() => collapseToLink(view, from, fullMatch, text, url));
+ return null;
+ },
+ }),
+ ];
+ },
+});
diff --git a/packages/app/src/editor/link-path-suggestions.dom.test.tsx b/packages/app/src/editor/link-path-suggestions.dom.test.tsx
index be03b02a..e23ee6fb 100644
--- a/packages/app/src/editor/link-path-suggestions.dom.test.tsx
+++ b/packages/app/src/editor/link-path-suggestions.dom.test.tsx
@@ -220,6 +220,36 @@ describe('LinkPathSuggestionInput', () => {
expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined();
});
+ test('after Escape dismisses the panel, keys reach the parent onKeyDown', () => {
+ // Suggestions still EXIST for the value after dismissal — only the panel is
+ // hidden. Keys must not be swallowed against the invisible panel: the next
+ // Escape has to reach the parent popover/dialog (which closes on it) and
+ // Enter has to reach the parent's apply handler.
+ const seen: string[] = [];
+ render(
+ {}}
+ onKeyDown={(event) => seen.push(event.key)}
+ />,
+ );
+
+ const input = screen.getByRole('combobox', { name: 'Link target' });
+ fireEvent.focus(input);
+ expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined();
+
+ fireEvent.keyDown(input, { key: 'Escape' });
+ expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull();
+ expect(seen).toEqual([]);
+
+ fireEvent.keyDown(input, { key: 'Escape' });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(seen).toEqual(['Escape', 'Enter']);
+ });
+
test('ArrowDown and ArrowUp update the active option', () => {
render();
diff --git a/packages/app/src/editor/link-path-suggestions.tsx b/packages/app/src/editor/link-path-suggestions.tsx
index 3084ab01..b749281d 100644
--- a/packages/app/src/editor/link-path-suggestions.tsx
+++ b/packages/app/src/editor/link-path-suggestions.tsx
@@ -210,7 +210,12 @@ export function LinkPathSuggestionInput({
}
function handleKeyDown(event: KeyboardEvent) {
- if (showSuggestionOptions) {
+ // Gate on the options being VISIBLE (panel open), not merely existing:
+ // suggestions still exist for the current value after an Escape dismissal,
+ // and swallowing keys against an invisible panel strands the user — the
+ // next Escape must reach the parent popover/dialog (as the branch below
+ // documents) and Enter must reach the parent's apply handler.
+ if (showSuggestions && showSuggestionOptions) {
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightedIndex((current) => Math.min(current + 1, suggestions.length - 1));
diff --git a/packages/app/src/editor/slash-command/component-items.tsx b/packages/app/src/editor/slash-command/component-items.tsx
index 52a2b069..e7dbb97d 100644
--- a/packages/app/src/editor/slash-command/component-items.tsx
+++ b/packages/app/src/editor/slash-command/component-items.tsx
@@ -23,7 +23,7 @@ import type { Editor } from '@tiptap/react';
import { CopyPlus, ExternalLink, FileUp, Hash, Link2 } from 'lucide-react';
import type { ReactNode } from 'react';
import { setPendingLinkEdit } from '../extensions/link-edit-autoopen';
-import { markIdentityKey } from '../extensions/mark-identity';
+import { findMarkIdAt } from '../extensions/mark-identity';
import { uploadAndInsert } from '../image-upload/index.ts';
import { getInteractionLayer } from '../interaction-layer-host';
import { resolveIcon } from '../registry/icons.ts';
@@ -656,26 +656,6 @@ function openFilePickerAndUpload(editor: Editor): void {
input.click();
}
-/**
- * Resolve the stable mark id of a `link` mark covering `pos` from the
- * mark-identity plugin state. IDs are assigned synchronously during the
- * dispatch that inserts the mark, so this reads them off `editor.state`
- * immediately after the insert chain runs. Returns null when the plugin
- * isn't installed (non-app editors / tests) or no link mark covers `pos` —
- * callers degrade gracefully (the link is still inserted; only the
- * auto-open is skipped).
- */
-function findLinkMarkIdAt(editor: Editor, pos: number): string | null {
- const state = markIdentityKey.getState(editor.state);
- if (!state) return null;
- for (const info of state.byId.values()) {
- if (info.markType === 'link' && info.from <= pos && pos < info.to) {
- return info.id;
- }
- }
- return null;
-}
-
/**
* Slash-menu items for inline-only PM atoms. Block components flow
* through the descriptor registry (`getComponentItems()`); inline atoms
@@ -744,7 +724,7 @@ export function getInlineComponentItems(): SlashCommandItem[] {
})
.run();
- const markId = findLinkMarkIdAt(editor, insertPos);
+ const markId = findMarkIdAt(editor.state, insertPos, 'link');
if (!markId) return;
setPendingLinkEdit(markId);
requestAnimationFrame(() => {
diff --git a/packages/app/src/editor/undo-isolation.test.ts b/packages/app/src/editor/undo-isolation.test.ts
new file mode 100644
index 00000000..376fccf5
--- /dev/null
+++ b/packages/app/src/editor/undo-isolation.test.ts
@@ -0,0 +1,62 @@
+/**
+ * The exception-safety property of `dispatchAsOwnUndoStep`: the CLOSING
+ * `stopCapturing()` must run even when `view.dispatch` throws — a
+ * split-open-but-never-closed capture merges the user's next keystrokes into
+ * the failed item, and one undo then strips them together. The happy path is
+ * covered by the collab-rig undo suites in gfm-autolink-plugin.test.ts and
+ * inline-link-input-rule.test.ts; this file pins the throw path (the only
+ * path where the `finally` matters — a try/catch refactor would break it).
+ */
+
+import { afterAll, beforeAll, expect, test } from 'bun:test';
+import type { EditorView } from '@tiptap/pm/view';
+import * as Y from 'yjs';
+import { mountCollabEditor, readUndoManager } from './editor-rig.test-helper';
+import { dispatchAsOwnUndoStep } from './undo-isolation';
+import { installDomGlobals } from './walk-currency-test-harness';
+
+let restoreDomGlobals: (() => void) | null = null;
+
+beforeAll(() => {
+ restoreDomGlobals = installDomGlobals();
+});
+
+afterAll(() => {
+ restoreDomGlobals?.();
+ restoreDomGlobals = null;
+});
+
+test('a throwing dispatch still closes the capture (stopCapturing runs twice)', () => {
+ const ydoc = new Y.Doc();
+ const editor = mountCollabEditor(ydoc, []);
+ try {
+ const undoManager = readUndoManager(editor);
+ expect(undoManager).not.toBeNull();
+ if (!undoManager) return;
+
+ let stops = 0;
+ const originalStop = undoManager.stopCapturing.bind(undoManager);
+ undoManager.stopCapturing = () => {
+ stops++;
+ originalStop();
+ };
+
+ // dispatchAsOwnUndoStep reads only `state` and `dispatch` from the view;
+ // hand it the real editor state with a dispatch that throws mid-step.
+ const throwingView = {
+ state: editor.state,
+ dispatch: () => {
+ throw new Error('plugin hook exploded');
+ },
+ } as unknown as EditorView;
+
+ expect(() => dispatchAsOwnUndoStep(throwingView, editor.state.tr)).toThrow(
+ 'plugin hook exploded',
+ );
+ // Split before + close after — BOTH ran despite the throw.
+ expect(stops).toBe(2);
+ } finally {
+ editor.destroy();
+ ydoc.destroy();
+ }
+});
diff --git a/packages/app/src/editor/undo-isolation.ts b/packages/app/src/editor/undo-isolation.ts
new file mode 100644
index 00000000..4c6e5b6d
--- /dev/null
+++ b/packages/app/src/editor/undo-isolation.ts
@@ -0,0 +1,35 @@
+/**
+ * Dispatch a transaction as its OWN y-undo stack item.
+ *
+ * The WYSIWYG linkify surfaces add their mark within `captureTimeout` of the
+ * typing that triggered them (500 ms — Y.js's default, not OK config), so
+ * without a capture split Y.UndoManager
+ * merges the mark into the typing's stack item — one undo then deletes the
+ * typed text too. `stopCapturing()` before the dispatch splits the item off
+ * the preceding typing; `stopCapturing()` after closes it so the user's NEXT
+ * keystrokes don't merge in either (undoing them would strip the mark as a
+ * side effect). Both calls are synchronous — the correctness-critical sequence
+ * lives here, in one place, so every mark-producing surface shares the exact
+ * same contract. Absent a y-undo binding (non-collaborative editors) there is
+ * no capture stack and the dispatch happens plain.
+ */
+
+import type { Transaction } from '@tiptap/pm/state';
+import type { EditorView } from '@tiptap/pm/view';
+import { yUndoPluginKey } from '@tiptap/y-tiptap';
+import type { UndoManager } from 'yjs';
+
+export function dispatchAsOwnUndoStep(view: EditorView, tr: Transaction): void {
+ const undoState: { undoManager?: UndoManager } | undefined = yUndoPluginKey.getState(view.state);
+ const undoManager = undoState?.undoManager;
+ undoManager?.stopCapturing();
+ try {
+ view.dispatch(tr);
+ } finally {
+ // `dispatch` runs third-party plugin hooks and can throw. The closing
+ // stopCapturing must run regardless — a split-open-but-never-closed
+ // capture would merge the user's NEXT keystrokes into the failed item,
+ // and a later undo would strip them together (silent undo corruption).
+ undoManager?.stopCapturing();
+ }
+}
diff --git a/packages/app/src/lib/keyboard-shortcuts.test.ts b/packages/app/src/lib/keyboard-shortcuts.test.ts
index 75593fbd..2387dfc2 100644
--- a/packages/app/src/lib/keyboard-shortcuts.test.ts
+++ b/packages/app/src/lib/keyboard-shortcuts.test.ts
@@ -309,14 +309,37 @@ describe('keyboard shortcut registry', () => {
).toBe(true);
});
- test('matches command palette with allowed extra modifiers and rejects missing mod', () => {
+ test('matches command palette on exact Cmd/Ctrl+K only', () => {
expect(
matchesKeyboardShortcut(
- { metaKey: true, ctrlKey: false, altKey: false, shiftKey: true, key: 'k' },
+ { metaKey: true, ctrlKey: false, altKey: false, key: 'k' },
'command-palette',
'mac',
),
).toBe(true);
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: false, ctrlKey: true, altKey: false, key: 'k' },
+ 'command-palette',
+ 'windowsLinux',
+ ),
+ ).toBe(true);
+ // ⇧⌘K must NOT open the palette — that chord belongs to CodeMirror's
+ // delete-line in source mode.
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: true, ctrlKey: false, altKey: false, shiftKey: true, key: 'k' },
+ 'command-palette',
+ 'mac',
+ ),
+ ).toBe(false);
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: true, ctrlKey: false, altKey: true, key: 'k' },
+ 'command-palette',
+ 'mac',
+ ),
+ ).toBe(false);
expect(
matchesKeyboardShortcut(
{ metaKey: false, ctrlKey: false, altKey: false, key: 'k' },
@@ -326,6 +349,53 @@ describe('keyboard shortcut registry', () => {
).toBe(false);
});
+ test('add-link shares the exact ⌘K chord with the palette and excludes extra modifiers', () => {
+ expect(formatShortcut('add-link', 'mac')).toBe('⌘ K');
+ expect(formatShortcut('add-link', 'windowsLinux')).toBe('Ctrl K');
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: true, ctrlKey: false, altKey: false, key: 'k' },
+ 'add-link',
+ 'mac',
+ ),
+ ).toBe(true);
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: false, ctrlKey: true, altKey: false, key: 'k' },
+ 'add-link',
+ 'windowsLinux',
+ ),
+ ).toBe(true);
+ // Wrong platform modifier: Ctrl+K on macOS must NOT match (mod is exact).
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: false, ctrlKey: true, altKey: false, key: 'k' },
+ 'add-link',
+ 'mac',
+ ),
+ ).toBe(false);
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: true, ctrlKey: false, altKey: false, shiftKey: true, key: 'k' },
+ 'add-link',
+ 'mac',
+ ),
+ ).toBe(false);
+ expect(
+ matchesKeyboardShortcut(
+ { metaKey: true, ctrlKey: false, altKey: true, key: 'k' },
+ 'add-link',
+ 'mac',
+ ),
+ ).toBe(false);
+ // One exact chord, two consumers: matching is identical for both ids;
+ // routing between them is contextual (capture-phase claim in the editor
+ // vs. the palette's window-bubble fallthrough).
+ const exactCmdK = { metaKey: true, ctrlKey: false, altKey: false, key: 'k' };
+ expect(matchesKeyboardShortcut(exactCmdK, 'add-link', 'mac')).toBe(true);
+ expect(matchesKeyboardShortcut(exactCmdK, 'command-palette', 'mac')).toBe(true);
+ });
+
test('matches source-aware replace shortcuts per platform', () => {
expect(
matchesKeyboardShortcut(
diff --git a/packages/app/src/lib/keyboard-shortcuts.ts b/packages/app/src/lib/keyboard-shortcuts.ts
index 6f5881a1..d4e2682e 100644
--- a/packages/app/src/lib/keyboard-shortcuts.ts
+++ b/packages/app/src/lib/keyboard-shortcuts.ts
@@ -86,16 +86,27 @@ export const SHORTCUT_CATEGORY_ORDER = Object.keys(SHORTCUT_CATEGORY_LABELS) as
const KEYBOARD_SHORTCUT_DEFINITIONS = [
{
+ // Exact ⌘K only (no extra-modifier tolerance) — a deliberate narrowing
+ // that ships with the dual-role ⌘K: it keeps the add-link claim
+ // unambiguous and frees ⌘⇧K (which would otherwise shadow CodeMirror's
+ // delete-line in source mode). ⌘⇧K intentionally does NOT open the
+ // palette; a regression test pins that.
+ //
+ // Phase-ordering contract for the shared ⌘K chord: this palette listener
+ // is window BUBBLE phase; the add-link claim (LinkEditPopover) is window
+ // CAPTURE phase and stops propagation when it applies. Any future
+ // capture-phase ⌘K handler must slot into that ordering consciously —
+ // the registry itself has no phase concept.
id: 'command-palette',
category: 'general',
title: msg`Command palette`,
- description: msg`Search files, commands, projects, and AI handoff actions.`,
+ description: msg`Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead.`,
scope: msg`Global`,
bindings: [
{
mac: '⌘ K',
windowsLinux: 'Ctrl K',
- match: { key: 'k', mod: true, allowExtraModifiers: true },
+ match: { key: 'k', mod: true },
},
],
},
@@ -431,6 +442,25 @@ const KEYBOARD_SHORTCUT_DEFINITIONS = [
scope: msg`Visual editor`,
bindings: [{ mac: '⇧⌘ H', windowsLinux: 'Ctrl Shift H' }],
},
+ {
+ // Same chord as `command-palette` by design: a capture-phase window
+ // listener in the WYSIWYG bubble menu claims exact ⌘K first when a link
+ // affordance applies (focused editor + text selection, or caret inside a
+ // link); everywhere else the event falls through to the palette's
+ // window-bubble listener.
+ id: 'add-link',
+ category: 'wysiwyg',
+ title: msg`Add link`,
+ description: msg`Link the selected text, or edit the link under the caret.`,
+ scope: msg`Visual editor selection`,
+ bindings: [
+ {
+ mac: '⌘ K',
+ windowsLinux: 'Ctrl K',
+ match: { key: 'k', mod: true },
+ },
+ ],
+ },
{
id: 'edit-with-ai',
category: 'general',
diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json
index fd97de36..e8123301 100644
--- a/packages/app/src/locales/en/messages.json
+++ b/packages/app/src/locales/en/messages.json
@@ -1001,6 +1001,9 @@
"ODGGlj": ["Folder name: ", ["detail"]],
"OETysD": ["No hub pages yet. Hubs appear once pages accumulate inbound graph links."],
"OEu_0l": ["Failed to load skills: ", ["0"]],
+ "OFBmkg": [
+ "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead."
+ ],
"OJTOUj": ["Requires ", ["brand"], "."],
"OJeiSH": ["Failed to add property"],
"OLBz-_": ["Mermaid diagram failed to render."],
@@ -1053,7 +1056,6 @@
"PR9FSv": ["Move to the next visual-editor find result."],
"PRnH8G": ["of ", ["0"]],
"PVUn_c": ["Open folder"],
- "PZt1uN": ["Search files, commands, projects, and AI handoff actions."],
"P_bX4O": ["How to set up"],
"PdA9NZ": [["ahead"], " ahead"],
"PdJZ5p": ["No skills yet."],
@@ -1262,6 +1264,7 @@
"VuxVSP": ["Start source completion"],
"VvS_k_": ["Config sharing is now local-only."],
"VxO6wR": ["Delete selected files or folders"],
+ "VzGJRl": ["Add link"],
"W-ntoW": ["heading-slug"],
"W0i24j": ["Object"],
"W1qRuM": ["This document is already active in the editor. Use Open to collapse the graph."],
@@ -2228,6 +2231,7 @@
"u3wRF-": ["Published"],
"u6Hp4N": ["Markdown"],
"u7H-Wn": ["Show panel (", ["panelShortcutLabel"], ")"],
+ "u7U_Ij": ["Link the selected text, or edit the link under the caret."],
"uA_jXp": ["Switch and update branch"],
"uCaIdy": ["GitHub sign-in is missing or expired. Reconnect to resume syncing."],
"uDlyqd": [
@@ -2278,6 +2282,7 @@
{ "one": ["Uploaded one file"], "other": ["Uploaded ", ["uploadedCount"], " files"] }
]
],
+ "v77jrg": ["Visual editor selection"],
"v7hK0U": ["Use lowercase letters, digits, and hyphens only."],
"v7lbnC": ["Find next match"],
"vAYoCZ": ["Couldn't open the worktree. Try again."],
diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po
index bb3a1883..3d833f41 100644
--- a/packages/app/src/locales/en/messages.po
+++ b/packages/app/src/locales/en/messages.po
@@ -682,6 +682,10 @@ msgstr "Add item"
msgid "Add item to {keyName}"
msgstr "Add item to {keyName}"
+#: src/lib/keyboard-shortcuts.ts
+msgid "Add link"
+msgstr "Add link"
+
#: src/components/settings/SearchSection.tsx
msgid "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer."
msgstr "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer."
@@ -3867,6 +3871,10 @@ msgstr "Link copied."
msgid "Link target"
msgstr "Link target"
+#: src/lib/keyboard-shortcuts.ts
+msgid "Link the selected text, or edit the link under the caret."
+msgstr "Link the selected text, or edit the link under the caret."
+
#: src/editor/slash-command/component-items.tsx
msgid "Link to a page or external URL."
msgstr "Link to a page or external URL."
@@ -5959,8 +5967,8 @@ msgid "Search failed."
msgstr "Search failed."
#: src/lib/keyboard-shortcuts.ts
-msgid "Search files, commands, projects, and AI handoff actions."
-msgstr "Search files, commands, projects, and AI handoff actions."
+msgid "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead."
+msgstr "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead."
#: src/components/CommandPalette.tsx
msgid "Search files, folders, and commands for the current workspace."
@@ -7583,6 +7591,10 @@ msgstr "Visual editor list item"
msgid "Visual editor replace input focused"
msgstr "Visual editor replace input focused"
+#: src/lib/keyboard-shortcuts.ts
+msgid "Visual editor selection"
+msgstr "Visual editor selection"
+
#: src/lib/keyboard-shortcuts.ts
msgid "Visual editor table"
msgstr "Visual editor table"
diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json
index af02b67c..027f0dc2 100644
--- a/packages/app/src/locales/pseudo/messages.json
+++ b/packages/app/src/locales/pseudo/messages.json
@@ -1001,6 +1001,9 @@
"ODGGlj": ["Ƒōĺďēŕ ńàḿē: ", ["detail"]],
"OETysD": ["Ńō ĥũƀ ƥàĝēś ŷēţ. Ĥũƀś àƥƥēàŕ ōńćē ƥàĝēś àććũḿũĺàţē ĩńƀōũńď ĝŕàƥĥ ĺĩńķś."],
"OEu_0l": ["Ƒàĩĺēď ţō ĺōàď śķĩĺĺś: ", ["0"]],
+ "OFBmkg": [
+ "Śēàŕćĥ ƒĩĺēś, ćōḿḿàńďś, ƥŕōĴēćţś, àńď ÀĨ ĥàńďōƒƒ àćţĩōńś. Ŵĩţĥ ţēxţ śēĺēćţēď ĩń ţĥē vĩśũàĺ ēďĩţōŕ, ţĥĩś ćĥōŕď àďďś à ĺĩńķ ĩńśţēàď."
+ ],
"OJTOUj": ["Ŕēǫũĩŕēś ", ["brand"], "."],
"OJeiSH": ["Ƒàĩĺēď ţō àďď ƥŕōƥēŕţŷ"],
"OLBz-_": ["Ḿēŕḿàĩď ďĩàĝŕàḿ ƒàĩĺēď ţō ŕēńďēŕ."],
@@ -1053,7 +1056,6 @@
"PR9FSv": ["Ḿōvē ţō ţĥē ńēxţ vĩśũàĺ-ēďĩţōŕ ƒĩńď ŕēśũĺţ."],
"PRnH8G": ["ōƒ ", ["0"]],
"PVUn_c": ["Ōƥēń ƒōĺďēŕ"],
- "PZt1uN": ["Śēàŕćĥ ƒĩĺēś, ćōḿḿàńďś, ƥŕōĴēćţś, àńď ÀĨ ĥàńďōƒƒ àćţĩōńś."],
"P_bX4O": ["Ĥōŵ ţō śēţ ũƥ"],
"PdA9NZ": [["ahead"], " àĥēàď"],
"PdJZ5p": ["Ńō śķĩĺĺś ŷēţ."],
@@ -1262,6 +1264,7 @@
"VuxVSP": ["Śţàŕţ śōũŕćē ćōḿƥĺēţĩōń"],
"VvS_k_": ["Ćōńƒĩĝ śĥàŕĩńĝ ĩś ńōŵ ĺōćàĺ-ōńĺŷ."],
"VxO6wR": ["Ďēĺēţē śēĺēćţēď ƒĩĺēś ōŕ ƒōĺďēŕś"],
+ "VzGJRl": ["Àďď ĺĩńķ"],
"W-ntoW": ["ĥēàďĩńĝ-śĺũĝ"],
"W0i24j": ["ŌƀĴēćţ"],
"W1qRuM": ["Ţĥĩś ďōćũḿēńţ ĩś àĺŕēàďŷ àćţĩvē ĩń ţĥē ēďĩţōŕ. Ũśē Ōƥēń ţō ćōĺĺàƥśē ţĥē ĝŕàƥĥ."],
@@ -2228,6 +2231,7 @@
"u3wRF-": ["Ƥũƀĺĩśĥēď"],
"u6Hp4N": ["Ḿàŕķďōŵń"],
"u7H-Wn": ["Śĥōŵ ƥàńēĺ (", ["panelShortcutLabel"], ")"],
+ "u7U_Ij": ["Ĺĩńķ ţĥē śēĺēćţēď ţēxţ, ōŕ ēďĩţ ţĥē ĺĩńķ ũńďēŕ ţĥē ćàŕēţ."],
"uA_jXp": ["Śŵĩţćĥ àńď ũƥďàţē ƀŕàńćĥ"],
"uCaIdy": ["ĜĩţĤũƀ śĩĝń-ĩń ĩś ḿĩśśĩńĝ ōŕ ēxƥĩŕēď. Ŕēćōńńēćţ ţō ŕēśũḿē śŷńćĩńĝ."],
"uDlyqd": [
@@ -2278,6 +2282,7 @@
{ "one": ["Ũƥĺōàďēď ōńē ƒĩĺē"], "other": ["Ũƥĺōàďēď ", ["uploadedCount"], " ƒĩĺēś"] }
]
],
+ "v77jrg": ["Vĩśũàĺ ēďĩţōŕ śēĺēćţĩōń"],
"v7hK0U": ["Ũśē ĺōŵēŕćàśē ĺēţţēŕś, ďĩĝĩţś, àńď ĥŷƥĥēńś ōńĺŷ."],
"v7lbnC": ["Ƒĩńď ńēxţ ḿàţćĥ"],
"vAYoCZ": ["Ćōũĺďń'ţ ōƥēń ţĥē ŵōŕķţŕēē. Ţŕŷ àĝàĩń."],
diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po
index eb8558d8..1330e8b3 100644
--- a/packages/app/src/locales/pseudo/messages.po
+++ b/packages/app/src/locales/pseudo/messages.po
@@ -677,6 +677,10 @@ msgstr ""
msgid "Add item to {keyName}"
msgstr ""
+#: src/lib/keyboard-shortcuts.ts
+msgid "Add link"
+msgstr ""
+
#: src/components/settings/SearchSection.tsx
msgid "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer."
msgstr ""
@@ -3862,6 +3866,10 @@ msgstr ""
msgid "Link target"
msgstr ""
+#: src/lib/keyboard-shortcuts.ts
+msgid "Link the selected text, or edit the link under the caret."
+msgstr ""
+
#: src/editor/slash-command/component-items.tsx
msgid "Link to a page or external URL."
msgstr ""
@@ -5954,7 +5962,7 @@ msgid "Search failed."
msgstr ""
#: src/lib/keyboard-shortcuts.ts
-msgid "Search files, commands, projects, and AI handoff actions."
+msgid "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead."
msgstr ""
#: src/components/CommandPalette.tsx
@@ -7578,6 +7586,10 @@ msgstr ""
msgid "Visual editor replace input focused"
msgstr ""
+#: src/lib/keyboard-shortcuts.ts
+msgid "Visual editor selection"
+msgstr ""
+
#: src/lib/keyboard-shortcuts.ts
msgid "Visual editor table"
msgstr ""
diff --git a/packages/app/tests/stress/_helpers/editor-state.ts b/packages/app/tests/stress/_helpers/editor-state.ts
index 8a6084f3..11a8e6cd 100644
--- a/packages/app/tests/stress/_helpers/editor-state.ts
+++ b/packages/app/tests/stress/_helpers/editor-state.ts
@@ -132,6 +132,62 @@ export async function focusEditor(page: Page, timeoutMs = 5_000): Promise
);
}
+/**
+ * Select the first occurrence of `text` in the active PM editor and wait
+ * until `editor.state.selection` actually covers it.
+ *
+ * Routed through TipTap's `setTextSelection` command on
+ * `window.__activeEditor` rather than synthesized mouse drags or
+ * platform-specific word-selection chords — deterministic under CI worker
+ * contention. The search walks text nodes and matches within a single one,
+ * so `text` must not span a mark/node boundary (fine for word-level
+ * selections; extend when a caller needs a cross-node range — the helper
+ * throws loudly rather than selecting a partial match).
+ *
+ * The trailing `waitForFunction` is the "selection took" signal callers
+ * need before dispatching selection-dependent events (paste, keyboard
+ * chords): `setTextSelection` updates PM state synchronously, but polling
+ * the state from the test context is what proves the evaluate round-trip
+ * completed and the range maps to the expected characters.
+ */
+export async function selectText(page: Page, text: string): Promise {
+ await page.evaluate((target) => {
+ const editor = window.__activeEditor;
+ if (!editor) throw new Error('selectText: window.__activeEditor not set');
+ let from = -1;
+ editor.state.doc.descendants((node, pos) => {
+ if (from !== -1) return false;
+ const nodeText = node.isText ? node.text : undefined;
+ if (nodeText) {
+ const idx = nodeText.indexOf(target);
+ if (idx !== -1) {
+ from = pos + idx;
+ return false;
+ }
+ }
+ return true;
+ });
+ if (from === -1) {
+ throw new Error(`selectText: "${target}" not found within a single text node`);
+ }
+ editor
+ .chain()
+ .focus()
+ .setTextSelection({ from, to: from + target.length })
+ .run();
+ }, text);
+ await page.waitForFunction(
+ (target) => {
+ const editor = window.__activeEditor;
+ if (!editor) return false;
+ const { from, to } = editor.state.selection;
+ return to > from && editor.state.doc.textBetween(from, to) === target;
+ },
+ text,
+ { timeout: 5_000 },
+ );
+}
+
/**
* Wait until ProseMirror's `editor.state.selection` has an ancestor of the
* given `nodeType` name — i.e. the cursor is INSIDE that node type per PM's
diff --git a/packages/app/tests/stress/_helpers/index.ts b/packages/app/tests/stress/_helpers/index.ts
index f1be6b50..6826ec67 100644
--- a/packages/app/tests/stress/_helpers/index.ts
+++ b/packages/app/tests/stress/_helpers/index.ts
@@ -14,6 +14,7 @@ export { resetContentToFixtureBaseline } from './content-reset.ts';
export {
focusEditor,
selectAllAndWaitForSelection,
+ selectText,
waitForPmSelectionInNode,
} from './editor-state.ts';
export { filterCriticalErrors, type LogEntry } from './error-filters.ts';
diff --git a/packages/app/tests/stress/link-authoring-apex.e2e.ts b/packages/app/tests/stress/link-authoring-apex.e2e.ts
new file mode 100644
index 00000000..a892dd16
--- /dev/null
+++ b/packages/app/tests/stress/link-authoring-apex.e2e.ts
@@ -0,0 +1,370 @@
+/**
+ * Apex E2E for the link-authoring feature — the release gate for the
+ * cross-writer hazard and the ⌘K dual-role contract.
+ *
+ * Four concerns, each proven in the real app rather than at a unit rung:
+ *
+ * 1. Cross-writer safety across two live clients. A boundary-terminated URL
+ * that reaches a client via CRDT sync (another writer's content) is NEVER
+ * linkified; only a client's OWN locally-typed URL + boundary converts.
+ * This is the origin guard (ySyncPluginKey) observed end-to-end.
+ * 2. Cross-writer safety for a pooled/backgrounded editor. A hidden Activity's
+ * editor still has a live provider and receives remote writes; it must
+ * never linkify them (origin guard + active-editor gate).
+ * 3. ⌘K routing matrix: non-empty selection → link popover; collapsed caret →
+ * palette; caret inside a link → chip edit surface; source pane → palette;
+ * ⌘⇧K → NOT the palette (the exact-⌘K narrowing).
+ * 4. Clipboard pre-fill degrades silently: with clipboard-read withheld
+ * (real permission gate), the popover opens empty and stays functional.
+ *
+ * Byte-shape oracles live in link-authoring-bytes.e2e.ts; this file asserts
+ * link-mark presence/absence and which UI surface a shortcut routes to.
+ *
+ * Run:
+ * cd packages/app && bunx playwright test tests/stress/link-authoring-apex.e2e.ts
+ */
+
+import { randomUUID } from 'node:crypto';
+import type { Page } from '@playwright/test';
+import type { Node as PMNode } from '@tiptap/pm/model';
+import { expect, focusEditor, selectText, test, waitForActiveProviderSynced } from './_helpers';
+
+const EDITOR = '.ProseMirror:not(.composer-prosemirror)';
+const LINK_CHIP = `${EDITOR} span[data-link]`;
+const PALETTE = '[cmdk-root]';
+
+/** Does the active editor's doc carry any link mark? */
+async function pmHasLink(page: Page): Promise {
+ return page.evaluate(() =>
+ JSON.stringify(window.__activeEditor?.state.doc.toJSON() ?? {}).includes('"type":"link"'),
+ );
+}
+
+async function waitForYTextToContain(page: Page, needle: string): Promise {
+ await page.waitForFunction(
+ (n: string) =>
+ (window.__activeProvider?.document?.getText('source')?.toString() ?? '').includes(n),
+ needle,
+ { timeout: 10_000 },
+ );
+}
+
+// ───────────────────────────────────────────────────────────────────────────
+// Cross-writer: two live clients — a synced URL is never linkified by the receiver
+// ───────────────────────────────────────────────────────────────────────────
+
+test.describe('apex — cross-writer linkification never fires', () => {
+ test('a boundary-less URL typed by a peer stays plain on the receiver; only a client’s own boundary-typed URL converts', async ({
+ browser,
+ api,
+ baseURL,
+ }) => {
+ const docName = `test-link-apex-fr1-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+
+ const ctxA = await browser.newContext({ baseURL });
+ const ctxB = await browser.newContext({ baseURL });
+ const pageA = await ctxA.newPage();
+ const pageB = await ctxB.newPage();
+
+ try {
+ await Promise.all([pageA.goto(`/#/${docName}`), pageB.goto(`/#/${docName}`)]);
+ await Promise.all([
+ pageA.waitForFunction(() => Boolean(window.__activeProvider), null, { timeout: 15_000 }),
+ pageB.waitForFunction(() => Boolean(window.__activeProvider), null, { timeout: 15_000 }),
+ ]);
+ await Promise.all([pageA.waitForSelector(EDITOR), pageB.waitForSelector(EDITOR)]);
+
+ // Client A LIVE-TYPES a GFM-shaped URL with NO trailing boundary key.
+ // Live typing (not a markdown/agent write, which would parse the bare URL
+ // into a link) leaves it as plain text: A's own plugin needs a boundary
+ // to fire, and none was typed. The plain text syncs to B as another
+ // writer's content.
+ await pageA.locator(EDITOR).click();
+ await pageA.keyboard.type('https://a-side.com');
+ await waitForYTextToContain(pageB, 'a-side.com');
+
+ // Neither client linkified A's boundary-less URL — B must not convert
+ // content that arrived via CRDT sync, and A never typed a boundary.
+ expect(await pmHasLink(pageA)).toBe(false);
+ expect(await pmHasLink(pageB)).toBe(false);
+ await expect(pageA.locator(LINK_CHIP)).toHaveCount(0);
+ await expect(pageB.locator(LINK_CHIP)).toHaveCount(0);
+
+ // B types its OWN URL + space at the START of the doc — its boundary
+ // (the trailing space) lands after B's token, never adjacent to A's URL,
+ // so only B's token converts. (A boundary typed right after A's URL would
+ // legitimately convert it too: that is B's own local edit completing the
+ // token, not a cross-writer linkification.)
+ await pageB.locator(EDITOR).click();
+ await pageB.evaluate(() => window.__activeEditor?.commands.focus('start'));
+ await pageB.keyboard.type('https://b-own.com ');
+
+ await pageB.waitForFunction(
+ () =>
+ JSON.stringify(window.__activeEditor?.state.doc.toJSON() ?? {}).includes('"type":"link"'),
+ null,
+ { timeout: 5_000 },
+ );
+
+ // Exactly one link on B: its own URL. A's synced URL is still plain.
+ await expect(pageB.locator(`${LINK_CHIP}[aria-label="Link: https://b-own.com"]`)).toHaveCount(
+ 1,
+ );
+ await expect(pageB.locator(LINK_CHIP)).toHaveCount(1);
+
+ // The mark syncs back to A; A shows exactly the same one link (B's), and
+ // still nothing on its own boundary-less URL.
+ await waitForYTextToContain(pageA, 'b-own.com');
+ await expect(pageA.locator(`${LINK_CHIP}[aria-label="Link: https://b-own.com"]`)).toHaveCount(
+ 1,
+ );
+ await expect(pageA.locator(LINK_CHIP)).toHaveCount(1);
+ } finally {
+ await ctxA.close();
+ await ctxB.close();
+ }
+ });
+});
+
+// ───────────────────────────────────────────────────────────────────────────
+// A pooled/backgrounded editor never linkifies remote writes
+// ───────────────────────────────────────────────────────────────────────────
+
+test.describe('apex — backgrounded editor never linkifies', () => {
+ test('a peer’s boundary-less URL reaches a hidden Activity’s editor and stays plain', async ({
+ browser,
+ api,
+ baseURL,
+ }) => {
+ const docX = `test-link-apex-hidx-${randomUUID().slice(0, 8)}`;
+ const docY = `test-link-apex-hidy-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docX}.md`);
+ await api.createPage(`${docY}.md`);
+
+ // Context H holds X, then navigates to Y — X's editor flips to
+ // but keeps a live provider in the pool.
+ const ctxH = await browser.newContext({ baseURL });
+ const ctxM = await browser.newContext({ baseURL });
+ const pageH = await ctxH.newPage();
+ const pageM = await ctxM.newPage();
+
+ try {
+ await pageH.goto(`/#/${docX}`);
+ await pageH.waitForFunction(() => Boolean(window.__activeProvider), null, {
+ timeout: 15_000,
+ });
+ await pageH.waitForSelector(EDITOR);
+ await pageH.goto(`/#/${docY}`);
+ await pageH.waitForFunction(() => Boolean(window.__activeProvider), null, {
+ timeout: 15_000,
+ });
+ await pageH.waitForSelector(EDITOR);
+
+ // Context M opens X (foreground) and live-types a boundary-less URL. It
+ // stays plain (no boundary → M's own plugin doesn't fire) and syncs into
+ // X's Y.Doc, reaching H's HIDDEN X editor while it is backgrounded.
+ await pageM.goto(`/#/${docX}`);
+ await pageM.waitForFunction(() => Boolean(window.__activeProvider), null, {
+ timeout: 15_000,
+ });
+ await pageM.waitForSelector(EDITOR);
+ await pageM.locator(EDITOR).click();
+ await pageM.keyboard.type('https://while-hidden.com');
+ await waitForYTextToContain(pageM, 'while-hidden.com');
+
+ // Deterministic delivery signal: poll H's HIDDEN pooled doc (via the
+ // provider pool's read-only peek) until the peer's URL reaches it while
+ // X is still backgrounded — the exact window the active-editor gate
+ // must hold through. Only then return to X.
+ await pageH.waitForFunction(
+ (doc: string) =>
+ (
+ window.__providerPool?.peek(doc)?.provider?.document?.getText('source')?.toString() ??
+ ''
+ ).includes('while-hidden.com'),
+ docX,
+ { timeout: 10_000 },
+ );
+ await pageH.goto(`/#/${docX}`);
+ await pageH.waitForFunction(() => Boolean(window.__activeProvider), null, {
+ timeout: 15_000,
+ });
+ await waitForYTextToContain(pageH, 'while-hidden.com');
+
+ // The URL is present but the hidden editor never linkified it.
+ expect(await pmHasLink(pageH)).toBe(false);
+ await expect(pageH.locator(LINK_CHIP)).toHaveCount(0);
+ } finally {
+ await ctxH.close();
+ await ctxM.close();
+ }
+ });
+});
+
+// ───────────────────────────────────────────────────────────────────────────
+// ⌘K routing matrix
+// ───────────────────────────────────────────────────────────────────────────
+
+test.describe('apex — ⌘K dual-role routing', () => {
+ let docName: string;
+
+ test.beforeEach(async ({ page, api }) => {
+ docName = `test-link-apex-cmdk-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+ await page.goto(`/#/${docName}`);
+ await waitForActiveProviderSynced(page);
+ await page.waitForSelector(EDITOR);
+ // Seed plain text (for selection / collapsed-caret cases) plus an existing
+ // link (for the caret-inside-link case).
+ await api.replaceDoc(
+ docName,
+ 'edit this text and visit [the docs](https://example.com) often\n',
+ );
+ await waitForYTextToContain(page, 'the docs');
+ await expect(page.locator(LINK_CHIP)).toHaveCount(1);
+ });
+
+ test('non-empty selection routes ⌘K to the link popover, not the palette', async ({ page }) => {
+ await selectText(page, 'this text');
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+k');
+
+ const input = page.getByLabel('Link URL');
+ await expect(input).toBeVisible({ timeout: 2_000 });
+ await expect(page.locator(PALETTE)).toHaveCount(0);
+
+ // Keyboard contract: the input takes focus on open (the popover lives in
+ // the floating bubble menu, which is unfocusable until positioned — the
+ // app retries until focus lands). Then two-stage Escape per the combobox
+ // convention: the first dismisses the path-suggestion panel, the second
+ // closes the popover and returns focus to the editor.
+ await expect
+ .poll(() => page.evaluate(() => document.activeElement?.getAttribute('aria-label')), {
+ timeout: 2_000,
+ })
+ .toBe('Link URL');
+ await page.keyboard.press('Escape');
+ await expect(input).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(input).toBeHidden({ timeout: 2_000 });
+ await expect
+ .poll(() => page.evaluate(() => window.__activeEditor?.view.hasFocus() ?? false), {
+ timeout: 2_000,
+ })
+ .toBe(true);
+ });
+
+ test('collapsed caret in plain text routes ⌘K to the palette', async ({ page }) => {
+ // Caret inside "often" — plain text, not a link.
+ await page.evaluate(() => {
+ const ed = window.__activeEditor;
+ if (!ed) throw new Error('no active editor');
+ const idx = ed.state.doc.textContent.indexOf('often');
+ ed.chain()
+ .focus()
+ .setTextSelection(idx + 2)
+ .run();
+ });
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+k');
+
+ await expect(page.locator(PALETTE)).toBeVisible({ timeout: 2_000 });
+ });
+
+ test('caret inside a link routes ⌘K to the chip edit surface, not the palette', async ({
+ page,
+ }) => {
+ // Collapse the caret inside the link mark's range.
+ await page.evaluate(() => {
+ const ed = window.__activeEditor;
+ if (!ed) throw new Error('no active editor');
+ let pos = -1;
+ ed.state.doc.descendants((node: PMNode, at: number) => {
+ if (pos !== -1) return false;
+ if (node.isText && node.marks.some((m) => m.type.name === 'link')) {
+ pos = at + 1;
+ return false;
+ }
+ return true;
+ });
+ if (pos === -1) throw new Error('no link mark found to place caret in');
+ ed.chain().focus().setTextSelection(pos).run();
+ });
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+k');
+
+ await expect(page.getByRole('combobox', { name: 'Link target' })).toBeVisible({
+ timeout: 2_000,
+ });
+ await expect(page.locator(PALETTE)).toHaveCount(0);
+ });
+
+ test('⌘⇧K does NOT open the palette (exact-⌘K narrowing)', async ({ page }) => {
+ await page.locator(EDITOR).click();
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+Shift+k');
+ await expect(page.locator(PALETTE)).toHaveCount(0);
+
+ // Condition-based negative proof (no wall-clock wait): exact ⌘K on the
+ // same keystroke pipeline DOES open the palette. That positive signal
+ // confirms key handling processed events after the ⌘⇧K press — if ⌘⇧K
+ // had opened (or toggled) the palette, this visibility wait would fail.
+ await page.keyboard.press('ControlOrMeta+k');
+ await expect(page.locator(PALETTE)).toBeVisible({ timeout: 2_000 });
+ });
+
+ test('⌘K in the source pane routes to the palette (WYSIWYG lacks focus)', async ({ page }) => {
+ await page.getByRole('radio', { name: 'Markdown source' }).click();
+ const cm = page.locator('.cm-content');
+ await expect(cm).toBeVisible({ timeout: 5_000 });
+ await cm.click();
+ await page.keyboard.press('ControlOrMeta+k');
+
+ await expect(page.locator(PALETTE)).toBeVisible({ timeout: 2_000 });
+ });
+});
+
+// ───────────────────────────────────────────────────────────────────────────
+// Clipboard pre-fill degrades silently under permission denial
+// ───────────────────────────────────────────────────────────────────────────
+
+test.describe('apex — clipboard pre-fill under real permission denial', () => {
+ test('with clipboard-read withheld, the popover opens empty and stays functional', async ({
+ browser,
+ api,
+ baseURL,
+ }) => {
+ // A fresh context with NO permissions granted — navigator.clipboard.readText
+ // rejects, exercising the real gate rather than a stub.
+ const ctx = await browser.newContext({ baseURL });
+ const page = await ctx.newPage();
+ try {
+ const docName = `test-link-apex-clip-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+ await page.goto(`/#/${docName}`);
+ await waitForActiveProviderSynced(page);
+ await page.waitForSelector(EDITOR);
+ await api.replaceDoc(docName, 'select me and link\n');
+ await waitForYTextToContain(page, 'select me');
+
+ await selectText(page, 'select me');
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+k');
+
+ const input = page.getByLabel('Link URL');
+ await expect(input).toBeVisible({ timeout: 2_000 });
+ // Denial degraded to an empty input — no error surfaced, no pre-fill.
+ await expect(input).toHaveValue('');
+
+ // Still functional: typing a URL and applying it creates the link.
+ await input.fill('https://typed-by-hand.com');
+ await page.keyboard.press('Enter');
+ await expect(
+ page.locator(`${LINK_CHIP}[aria-label="Link: https://typed-by-hand.com"]`),
+ ).toHaveCount(1, { timeout: 5_000 });
+ } finally {
+ await ctx.close();
+ }
+ });
+});
diff --git a/packages/app/tests/stress/link-authoring-bytes.e2e.ts b/packages/app/tests/stress/link-authoring-bytes.e2e.ts
new file mode 100644
index 00000000..9b9c0e0a
--- /dev/null
+++ b/packages/app/tests/stress/link-authoring-bytes.e2e.ts
@@ -0,0 +1,242 @@
+/**
+ * Byte-pinning E2E for WYSIWYG linkification: exact Y.Text oracles (never
+ * substring matches) for the three link-creation paths — typed URL + space,
+ * lone-URL paste at cursor, lone-URL paste over a selection — plus the
+ * per-path single-undo contract.
+ *
+ * Exact bytes are the point. A plain-text URL serializes GFM-escaped
+ * (`https\://…`) so it stays prose on re-parse, while a linkified URL
+ * serializes as the bare literal; a `toContain('inkeep.com')` cannot tell
+ * those apart, whole-string equality on Y.Text can.
+ *
+ * Two serialization behaviors of the bridge shape the oracles below — both
+ * pre-existing, neither introduced by linkification:
+ *
+ * - Paragraph-trailing whitespace does not round-trip: `'AGENTS.md '`
+ * serializes as `'AGENTS.md\n'`. Expected bytes never carry a trailing
+ * space before the newline.
+ *
+ * - A mark-only change can be byte-invisible. Server Observer A settles
+ * without rewriting Y.Text when the new fragment serialization is within
+ * `normalizeBridge` tolerance of the settled bytes, and the CommonMark
+ * escape-collapse class makes `https\://x` and `https://x` compare equal
+ * (they are parse-equivalent: escaped autolink-shaped bytes re-parse to
+ * the same link). So a typed conversion — which only ADDS a mark over
+ * text that is already in Y.Text — leaves the escaped bytes at rest
+ * until the next content-bearing edit re-serializes the paragraph, at
+ * which point the bare literal lands. The typed-path tests pin exactly
+ * that lifecycle: mark first (fragment truth), bare bytes on the next
+ * edit (Y.Text truth). Paste paths insert or restructure content, so
+ * their bytes settle immediately.
+ *
+ * Link marks render as `` chips (InternalLink
+ * deliberately emits no `` — anchor navigation would race the
+ * InteractionLayer), so DOM assertions target the chip.
+ *
+ * Clipboard injection: DataTransfer + dispatchEvent (same pattern as
+ * paste-fidelity.e2e.ts) — bypasses the navigator.clipboard permission
+ * gate on headless Chromium.
+ *
+ * Run:
+ * cd packages/app && bunx playwright test tests/stress/link-authoring-bytes.e2e.ts
+ */
+
+import { randomUUID } from 'node:crypto';
+import type { Page } from '@playwright/test';
+import {
+ expect,
+ focusEditor,
+ selectText,
+ simulateCopyAndRead,
+ test,
+ waitForActiveProviderSynced as waitForProvider,
+} from './_helpers';
+
+const EDITOR = '.ProseMirror:not(.composer-prosemirror)';
+const LINK_CHIP = `${EDITOR} span[data-link]`;
+const URL_LITERAL = 'https://inkeep.com';
+
+async function getYText(page: Page): Promise {
+ return page.evaluate(() => {
+ const provider = window.__activeProvider;
+ return provider?.document?.getText('source')?.toString() ?? '';
+ });
+}
+
+/** Paste a text/plain-only payload into the WYSIWYG editor. */
+async function pasteText(page: Page, text: string) {
+ await page.evaluate((content) => {
+ const editor = document.querySelector('.ProseMirror:not(.composer-prosemirror)');
+ if (!editor) throw new Error('ProseMirror editor not found');
+ const dt = new DataTransfer();
+ dt.setData('text/plain', content);
+ const event = new ClipboardEvent('paste', {
+ clipboardData: dt,
+ bubbles: true,
+ cancelable: true,
+ });
+ editor.dispatchEvent(event);
+ }, text);
+}
+
+/** PM-layer truth: does the active editor's doc carry a link mark, and what
+ * is its plain text? Byte oracles cannot see a mark whose serialization is
+ * within bridge tolerance (file header), so mark-level assertions read the
+ * fragment side directly. */
+async function pmLinkSnapshot(page: Page): Promise<{ hasLink: boolean; text: string }> {
+ return page.evaluate(() => {
+ const ed = window.__activeEditor;
+ return {
+ hasLink: JSON.stringify(ed?.state.doc.toJSON() ?? {}).includes('"type":"link"'),
+ text: ed?.state.doc.textContent ?? '',
+ };
+ });
+}
+
+/** Wait until the active editor's doc gains (or is confirmed to hold) a link
+ * mark — the typed path lands it a microtask after the boundary keystroke. */
+async function waitForPmLink(page: Page): Promise {
+ await page.waitForFunction(
+ () => JSON.stringify(window.__activeEditor?.state.doc.toJSON() ?? {}).includes('"type":"link"'),
+ null,
+ { timeout: 5_000 },
+ );
+}
+
+// ─── lone-URL paste at cursor ───
+
+test.describe('lone-URL paste at cursor — bare-literal bytes', () => {
+ let docName: string;
+
+ test.beforeEach(async ({ page, api }) => {
+ docName = `test-linkbytes-cursor-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+ await page.goto(`/#/${docName}`);
+ await waitForProvider(page);
+ await page.waitForSelector(EDITOR);
+ await page.click(EDITOR);
+ });
+
+ test('pasted lone URL lands as a link with exactly the bare-literal bytes', async ({ page }) => {
+ await pasteText(page, URL_LITERAL);
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com\n');
+ await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${URL_LITERAL}"]`)).toHaveCount(1);
+ });
+
+ test('WYSIWYG copy of the pasted link round-trips clean text/plain', async ({ page }) => {
+ await pasteText(page, URL_LITERAL);
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com\n');
+ const out = await simulateCopyAndRead(page, 'wysiwyg');
+ expect(out.plain).toBe('https://inkeep.com\n');
+ });
+
+ test('one undo removes the pasted link entirely', async ({ page }) => {
+ await pasteText(page, URL_LITERAL);
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com\n');
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+z');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('');
+ await expect(page.locator(LINK_CHIP)).toHaveCount(0);
+ });
+
+ test('explicit-scheme dotless host (localhost) pastes as a link with bare-literal bytes', async ({
+ page,
+ }) => {
+ // The dotted-domain rule is schemeless-only: http://localhost is a GFM
+ // autolink literal, so it converts and its bytes stay the bare URL.
+ const localUrl = 'http://localhost:5174/#/some-doc';
+ await pasteText(page, localUrl);
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe(`${localUrl}\n`);
+ await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${localUrl}"]`)).toHaveCount(1);
+ });
+});
+
+// ─── lone-URL paste over a selection ───
+
+test.describe('lone-URL paste over a selection — [text](url) bytes', () => {
+ let docName: string;
+
+ test.beforeEach(async ({ page, api }) => {
+ docName = `test-linkbytes-sel-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+ await page.goto(`/#/${docName}`);
+ await waitForProvider(page);
+ await page.waitForSelector(EDITOR);
+ // Seed through the agent-write path (a remote, undo-untracked origin) so
+ // the linkify mark is the first locally-tracked undo item — mirrors real
+ // usage, where the selected text long predates the paste.
+ await api.replaceDoc(docName, 'inkeep docs\n');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('inkeep docs\n');
+ await expect(page.locator(`${EDITOR} p`).first()).toContainText('inkeep docs');
+ });
+
+ test('pasting a URL over a selected word keeps the text and links it', async ({ page }) => {
+ await selectText(page, 'docs');
+ await pasteText(page, URL_LITERAL);
+ await expect
+ .poll(() => getYText(page), { timeout: 5_000 })
+ .toBe('inkeep [docs](https://inkeep.com)\n');
+ await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${URL_LITERAL}"]`)).toHaveCount(1);
+ });
+
+ test('one undo restores the pre-paste unlinked text', async ({ page }) => {
+ await selectText(page, 'docs');
+ await pasteText(page, URL_LITERAL);
+ await expect
+ .poll(() => getYText(page), { timeout: 5_000 })
+ .toBe('inkeep [docs](https://inkeep.com)\n');
+ await focusEditor(page);
+ await page.keyboard.press('ControlOrMeta+z');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('inkeep docs\n');
+ await expect(page.locator(LINK_CHIP)).toHaveCount(0);
+ });
+});
+
+// ─── typed URL + space ───
+
+test.describe('typed URL + space — GFM autolink byte contract', () => {
+ let docName: string;
+
+ test.beforeEach(async ({ page, api }) => {
+ docName = `test-linkbytes-typed-${randomUUID().slice(0, 8)}`;
+ await api.createPage(`${docName}.md`);
+ await page.goto(`/#/${docName}`);
+ await waitForProvider(page);
+ await page.waitForSelector(EDITOR);
+ await page.click(EDITOR);
+ });
+
+ test('typed GFM URL converts on space; bytes settle bare on the next edit', async ({ page }) => {
+ await page.keyboard.type('https://inkeep.com ');
+ // Conversion truth lives in the fragment: the mark lands immediately, but
+ // the mark-only change is within bridge tolerance of the escaped bytes
+ // already at rest (file header), so Y.Text is asserted after the next
+ // content-bearing edit forces a re-serialization.
+ await waitForPmLink(page);
+ await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${URL_LITERAL}"]`)).toHaveCount(1);
+ await page.keyboard.type('done');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com done\n');
+ });
+
+ test('typed filename-shaped token stays plain and serializes unlinked', async ({ page }) => {
+ await page.keyboard.type('AGENTS.md ');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('AGENTS.md\n');
+ await expect(page.locator(LINK_CHIP)).toHaveCount(0);
+ });
+
+ test('one undo removes only the mark — text intact, bytes re-escape', async ({ page }) => {
+ await page.keyboard.type('https://inkeep.com ');
+ await waitForPmLink(page);
+ await page.keyboard.press('ControlOrMeta+z');
+ // Mark gone, typed text and trailing space intact at the fragment layer.
+ await expect
+ .poll(() => pmLinkSnapshot(page), { timeout: 5_000 })
+ .toEqual({ hasLink: false, text: 'https://inkeep.com ' });
+ await expect(page.locator(LINK_CHIP)).toHaveCount(0);
+ // The next edit re-serializes the now-unmarked paragraph: the URL escapes
+ // back to prose form — the sharp proof that the undone doc's canonical
+ // bytes are the escaped ones (not merely that they never changed).
+ await page.keyboard.type('x');
+ await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https\\://inkeep.com x\n');
+ });
+});
diff --git a/packages/core/src/extensions/link-fidelity.ts b/packages/core/src/extensions/link-fidelity.ts
index 0bfa8964..b93cd344 100644
--- a/packages/core/src/extensions/link-fidelity.ts
+++ b/packages/core/src/extensions/link-fidelity.ts
@@ -1,9 +1,12 @@
/**
* Link mark override for source-text fidelity.
*
- * Extends @tiptap/extension-link (preserving autolink, linkOnPaste,
- * and click handling plugins) and adds fidelity attributes for link
- * style (inline, full, collapsed, shortcut) and reference label.
+ * Extends @tiptap/extension-link and adds fidelity attributes for link
+ * style (inline, full, collapsed, shortcut) and reference label. LinkFidelity
+ * itself inherits the stock link plugins (autolink, linkOnPaste, click
+ * handling), but the app-side `InternalLink` subclass overrides
+ * `addProseMirrorPlugins` to drop them in favour of OK's own linkify/paste/
+ * click surfaces — so in the app those stock plugins are not active.
*
* Markdown parsing/serialization is handled by the unified pipeline (packages/core/src/markdown/).
*/
@@ -16,15 +19,25 @@ const ALLOWED_LINK_SCHEMES: ReadonlySet = new Set(SAFE_URL_SCHEMES.map((
const PLACEHOLDER_BASE = 'https://placeholder.invalid';
/**
- * Allowlist gate for TipTap's autolinker / linkOnPaste / setLink input
- * paths. Bare relative URLs (e.g. `/foo`, `./bar`, `#hash`) parse against
- * `PLACEHOLDER_BASE` and inherit `https:`, so they pass without a special
- * case. Storage-layer mdast→PM (`MarkdownManager`) intentionally bypasses
- * this hook — see AGENTS.md "Storage never sanitizes; render-time layers
- * do." Render-time defenses (`rehypeSanitizeUrls`, `isSafeNavigationUrl`,
- * `sanitizeComponentProps`) cover egress.
+ * Known `linkStyle` values. The producer side (e.g. the app's typed-autolink
+ * plugin) and the PM→mdast serializer branch on these string literals; typing
+ * both against this union turns a drifted value (rename/typo) into a compile
+ * error instead of a silently mis-serialized link.
*/
-function isAllowedLinkUri(url: string): boolean {
+export type LinkStyle = 'inline' | 'full' | 'collapsed' | 'shortcut' | 'autolink' | 'gfm-autolink';
+
+/**
+ * Allowlist gate for link-mark creation paths: TipTap's autolinker /
+ * linkOnPaste / setLink inputs and the app clipboard dispatcher's lone-URL
+ * paste classifier. Bare relative URLs (e.g. `/foo`, `./bar`, `#hash`)
+ * parse against `PLACEHOLDER_BASE` and inherit `https:`, so they pass
+ * without a special case. Storage-layer mdast→PM (`MarkdownManager`)
+ * intentionally bypasses this hook — see AGENTS.md "Storage never
+ * sanitizes; render-time layers do." Render-time defenses
+ * (`rehypeSanitizeUrls`, `isSafeNavigationUrl`, `sanitizeComponentProps`)
+ * cover egress.
+ */
+export function isAllowedLinkUri(url: string): boolean {
try {
const parsed = new URL(url, PLACEHOLDER_BASE);
return ALLOWED_LINK_SCHEMES.has(parsed.protocol.toLowerCase());
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 9dee6588..f0753aa3 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -282,7 +282,7 @@ export { HtmlBlockFidelity } from './extensions/html-block-fidelity.ts';
export { ImageSrcFidelity } from './extensions/image-src-fidelity.ts';
export { JsxComponent } from './extensions/jsx-component.ts';
export { JsxInline } from './extensions/jsx-inline.ts';
-export { LinkFidelity } from './extensions/link-fidelity.ts';
+export { isAllowedLinkUri, LinkFidelity, type LinkStyle } from './extensions/link-fidelity.ts';
export { LinkRefDefFidelity } from './extensions/link-ref-def-fidelity.ts';
export { List, ListItem, ListItemNode, ListNode } from './extensions/list.ts';
export { MathInline } from './extensions/math-inline.ts';
diff --git a/packages/core/src/markdown/index.ts b/packages/core/src/markdown/index.ts
index cdbc4c87..58be3530 100644
--- a/packages/core/src/markdown/index.ts
+++ b/packages/core/src/markdown/index.ts
@@ -53,6 +53,7 @@ import {
createStructuralFreshnessChecker,
type StructuralFreshnessChecker,
} from '../bridge/structural-freshness.ts';
+import type { LinkStyle } from '../extensions/link-fidelity.ts';
import { isValidSourceLiteralRaw } from '../extensions/source-literal-mark.ts';
import { createRegistry } from '../registry/index.ts';
import type { PropDef } from '../registry/types.ts';
@@ -1538,7 +1539,7 @@ function buildPmToMdastHandlers(
children: [{ type: 'text' as const, value: label }],
} as unknown as MdastNodes;
}
- const style = mark.attrs.linkStyle;
+ const style: LinkStyle | undefined = mark.attrs.linkStyle;
if (style === 'autolink') {
return {
type: 'link' as const,