fix(editor): follow local Markdown anchor links (#1328)

Go to Declaration and modifier-click on a fragment-only Markdown or HTML link such as [artist::order](#Artist-order) said there was nothing to go to: the text-reference fallback rejected fragment-only targets, and Markdown fragment lookup checked headings only, never HTML id / name anchors. Fragment-only href targets are now recognized and resolve in the same file (the current buffer, so unsaved anchors count), and Markdown files fall back to id / name anchors after headings.

Co-authored-by: pravdomil <2387356+pravdomil@users.noreply.github.com>
This commit is contained in:
pravdomil
2026-11-05 01:48:06 +08:00
committed by GitHub
parent 11932a87af
commit fd69664ca7
4 changed files with 81 additions and 12 deletions
+33 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { Text } from "@codemirror/state";
import { fragmentOffset, linkReference, looksLikePath, textReference } from "./links";
import { fragmentOffset, linkReference, looksLikePath, resolvePath, textReference } from "./links";
const at = (src: string, marker = "|") => {
const offset = src.indexOf(marker);
@@ -28,6 +28,20 @@ describe("text references", () => {
expect(textReference(text, offset)).toMatchObject({ kind: "path", value: "img/logo.svg" });
});
it("finds fragment-only link targets", () => {
for (const source of ["[artist::order](#Artist-or|der)", '<a href="#Artist-or|der">order</a>']) {
const { text, offset } = at(source);
expect(textReference(text, offset)).toMatchObject({ kind: "path", value: "#Artist-order" });
}
});
it("does not treat arbitrary fragment strings as links", () => {
for (const source of ['const color = "#ff|f"', '<a id="Artist-or|der">', "url(#ic|on)"]) {
const { text, offset } = at(source);
expect(textReference(text, offset)).toBeNull();
}
});
it("ignores prose and identifiers", () => {
expect(looksLikePath("hello world.txt")).toBe(false);
expect(looksLikePath("utf-8")).toBe(false);
@@ -41,6 +55,18 @@ describe("text references", () => {
});
describe("links and fragments", () => {
it("resolves local fragments without probing the filesystem", async () => {
const ref = { kind: "path" as const, from: 5, to: 20, value: "#Artist%2Dorder" };
expect(await resolvePath(ref, "/p/notes.md")).toEqual({
kind: "file",
from: 5,
to: 20,
path: "/p/notes.md",
fragment: "Artist-order",
});
expect(await resolvePath({ ...ref, value: "#bad%" }, "/p/notes.md")).toBeNull();
});
it("picks the server link around the offset", () => {
const text = Text.of(['<a href="b.html#top">']);
const range = { start: { line: 0, col: 9 }, end: { line: 0, col: 19 } };
@@ -55,6 +81,12 @@ describe("links and fragments", () => {
const html = '<p>\n<div class="x" id="main">';
expect(fragmentOffset(html, "/i.html", "main")).toBe(html.indexOf("id="));
expect(fragmentOffset("# Intro\n\n## Getting Started!\n", "/r.md", "getting-started")).toBe(9);
for (const path of ["/r.md", "/r.markdown", "/r.mdx"]) {
for (const attr of ["id", "name"]) {
const source = `[artist::order](#Artist-order)\n\n<a ${attr}="Artist-order"></a>`;
expect(fragmentOffset(source, path, "Artist-order")).toBe(source.indexOf(`${attr}=`));
}
}
expect(fragmentOffset("x", "/i.html", "nope")).toBeNull();
});
});
+16 -5
View File
@@ -87,14 +87,19 @@ export function textReference(text: Text, offset: number): Reference | null {
return { kind: "url", from: line.from + m.index, to: line.from + m.index + m[0].length, url: m[0] };
}
const strings = [
...[...line.text.matchAll(QUOTED_RE)].map((m) => ({ value: m[1] ?? m[2] ?? m[3] ?? "", from: m.index + 1 })),
...[...line.text.matchAll(QUOTED_RE)].map((m) => ({
value: m[1] ?? m[2] ?? m[3] ?? "",
from: m.index + 1,
fragment: /\bhref\s*=\s*$/i.test(line.text.slice(0, m.index)),
})),
...[...line.text.matchAll(BARE_RE)].map((m) => {
const value = m[1] ?? m[2] ?? "";
return { value, from: m.index + m[0].indexOf(value) };
return { value, from: m.index + m[0].indexOf(value), fragment: m[1] !== undefined };
}),
];
for (const { value, from } of strings) {
if (col < from || col > from + value.length || !looksLikePath(value)) continue;
for (const { value, from, fragment } of strings) {
if (col < from || col > from + value.length || (!looksLikePath(value) && !(fragment && /^#[^\s]+$/.test(value))))
continue;
return { kind: "path", from: line.from + from, to: line.from + from + value.length, value };
}
return null;
@@ -113,6 +118,13 @@ async function isFile(path: string): Promise<boolean> {
/** Resolves a `path` reference (in the backend, like IDEA's file references); null when nothing exists there. */
export async function resolvePath(ref: Reference, from: string): Promise<Reference | null> {
if (ref.kind !== "path") return ref;
if (ref.value.startsWith("#")) {
try {
return { kind: "file", from: ref.from, to: ref.to, path: from, fragment: decodeURIComponent(ref.value.slice(1)) };
} catch {
return null;
}
}
if (!isTauri()) return null;
const path = await call(commands.fsResolveReference(from, ref.value)).catch(() => null);
if (!path) return null;
@@ -142,7 +154,6 @@ export function fragmentOffset(text: string, path: string, fragment: string): nu
if (h && slug(h[1]!) === fragment.toLowerCase()) return offset;
offset += l.length + 1;
}
return null;
}
const m = new RegExp(`\\b(?:id|name)\\s*=\\s*["']?${esc}(?=["'\\s>])`).exec(text);
return m ? m.index : null;
+17 -1
View File
@@ -50,7 +50,15 @@ import { showHint, showPendingHint } from "./hint";
import { outsideProjectReason } from "./outsideProject";
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
import { readManyFileLines, targetLabel } from "./files";
import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links";
import {
documentLinks,
followReference,
fragmentOffset,
knownLinks,
linkReference,
referenceAt,
textReference,
} from "./links";
import { featureBackoff } from "./availability";
import { requestHover, showPhpConstructDocumentation } from "./hover";
import { declarationSummary, renderCode } from "./markdown";
@@ -277,6 +285,14 @@ async function followReferenceAt(view: EditorView, doc: EditorDocContext, offset
const ref = await referenceAt(view, doc, offset);
if (!ref) return false;
try {
if (ref.kind === "file" && ref.path === doc.path && ref.fragment) {
const target = fragmentOffset(view.state.doc.toString(), doc.path, ref.fragment);
if (target !== null) {
view.dispatch({ selection: EditorSelection.cursor(target), effects: EditorView.scrollIntoView(target) });
view.focus();
return true;
}
}
await followReference(ref);
} catch (e) {
showHint(view, e instanceof Error ? e.message : String(e), { tone: "error" });
+15 -5
View File
@@ -82,15 +82,12 @@ const local = (over: Partial<Extract<Locals, { status: "local" }>["symbol"]> = {
});
const views: EditorView[] = [];
function editor(doc: string, language = "typescript", anchor = 0) {
function editor(doc: string, language = "typescript", anchor = 0, path = "/p/a.ts") {
const view = new EditorView({
state: EditorState.create({
doc,
selection: { anchor },
extensions: [
lspDoc.of({ doc: 7, path: "/p/a.ts", language, uiMode: null, readOnly: false }),
navigationExtension(),
],
extensions: [lspDoc.of({ doc: 7, path, language, uiMode: null, readOnly: false }), navigationExtension()],
}),
parent: document.body,
});
@@ -670,3 +667,16 @@ describe("modifier-hover navigation recovery", () => {
expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("bar");
});
});
describe("same-document fragment navigation", () => {
it.each(["definition", "declaration"] as const)(
"follows Markdown HTML anchors through %s without a server",
async (kind) => {
const source = '[artist::order](#Artist-order)\n\n<a id="Artist-order"></a>';
const view = editor(source, "markdown", source.indexOf("#Artist"), "/p/notes.md");
await gotoTargets(view, kind);
expect(view.state.selection.main.head).toBe(source.indexOf("id="));
expect(openFile).not.toHaveBeenCalled();
},
);
});