From b53c3d7db79fc2b2fecefe2f4c1cdb7d14259cd1 Mon Sep 17 00:00:00 2001 From: Alastair Scheuermann Date: Tue, 22 Sep 2026 14:43:55 -0600 Subject: [PATCH] fix(path): resolve short wikilinks to folder notes --- .changeset/folder-note-shortest-links.md | 9 +++++++++ src/path.ts | 11 ++++++++++- test/path.test.ts | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 .changeset/folder-note-shortest-links.md diff --git a/.changeset/folder-note-shortest-links.md b/.changeset/folder-note-shortest-links.md new file mode 100644 index 0000000..a538ba0 --- /dev/null +++ b/.changeset/folder-note-shortest-links.md @@ -0,0 +1,9 @@ +--- +"@quartz-community/utils": patch +--- + +Resolve short wikilinks to Obsidian folder notes. + +`slugifyFilePath` rewrites `Foo/Foo.md` to the slug `Foo/index`. The last path segment is then `index`, so the `shortest` strategy in `transformLink` never matched a short `[[Foo]]` link. The link resolved to a root-relative "guess" that does not exist, and no error occurred. + +`transformLink` now retries against the folder name. The retry runs only when no real file claims that name. Links that already resolve do not change. diff --git a/src/path.ts b/src/path.ts index 68550c9..a1ebd6a 100644 --- a/src/path.ts +++ b/src/path.ts @@ -258,7 +258,7 @@ export function transformLink(src: FullSlug, target: string, opts: TransformOpti if (opts.strategy === "shortest") { const isMultiSegment = targetCanonical.includes("/"); const isFolderTarget = isFolderPath(targetSlug); - const matchingFileNames = opts.allSlugs.filter((slug) => { + let matchingFileNames = opts.allSlugs.filter((slug) => { if (isMultiSegment) { // Multi-segment partial path: match by suffix if (slug === targetCanonical || slug.endsWith("/" + targetCanonical)) { @@ -276,6 +276,15 @@ export function transformLink(src: FullSlug, target: string, opts: TransformOpti return targetCanonical === fileName; }); + // Folder note Foo/Foo.md slugs to Foo/index, so its last segment never matches [[Foo]] + // Retry by folder name, only when no real file claims it + if (matchingFileNames.length === 0 && !isMultiSegment) { + matchingFileNames = opts.allSlugs.filter((slug) => { + const parts = slug.split("/"); + return parts.at(-1) === "index" && parts.at(-2) === targetCanonical; + }); + } + if (matchingFileNames.length === 1) { const matchedSlug = matchingFileNames[0]!; return (resolveRelative(effectiveSrc, matchedSlug) + targetAnchor) as RelativeURL; diff --git a/test/path.test.ts b/test/path.test.ts index 94ee669..38bfed8 100644 --- a/test/path.test.ts +++ b/test/path.test.ts @@ -442,6 +442,22 @@ describe("transformLink", () => { expect(transformLink(cur, "index", opts)).toBe("../../"); }); + it("resolves a folder note by its folder name", () => { + // slugifyFilePath gives a/b/b.md the slug a/b/index, per Obsidian folder-note convention + const cur = "a/b/c" as FullSlug; + expect(transformLink(cur, "b", opts)).toBe("../../a/b/"); + }); + + it("prefers a real file over a folder note of the same name", () => { + const withCollision = [...allSlugs, "archive/b"] as FullSlug[]; + const collisionOpts: TransformOptions = { + strategy: "shortest", + allSlugs: withCollision, + }; + const cur = "a/b/c" as FullSlug; + expect(transformLink(cur, "b", collisionOpts)).toBe("../../archive/b"); + }); + it("resolves from a/b/index (explicit index slug)", () => { const cur = "a/b/index" as FullSlug; expect(transformLink(cur, "d", opts)).toBe("../../a/b/d");