Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/folder-note-shortest-links.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 10 additions & 1 deletion src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions test/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down