feat(path): support folder note convention

This commit is contained in:
saberzero1 2026-04-10 19:16:30 +02:00
parent cf1450b6f7
commit e17fc8d5f0
No known key found for this signature in database
6 changed files with 139 additions and 3 deletions

5
dist/index.js vendored
View file

@ -54,6 +54,11 @@ function slugifyFilePath(fp, excludeExt) {
if (endsWith(slug, "_index")) {
slug = slug.replace(/_index$/, "index");
}
const segments = slug.split("/");
if (segments.length >= 2 && segments[segments.length - 1] === segments[segments.length - 2]) {
segments[segments.length - 1] = "index";
slug = segments.join("/");
}
return slug + (finalExt ?? "");
}
function joinSegments(...args) {

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

5
dist/path.js vendored
View file

@ -51,6 +51,11 @@ function slugifyFilePath(fp, excludeExt) {
if (endsWith(slug, "_index")) {
slug = slug.replace(/_index$/, "index");
}
const segments = slug.split("/");
if (segments.length >= 2 && segments[segments.length - 1] === segments[segments.length - 2]) {
segments[segments.length - 1] = "index";
slug = segments.join("/");
}
return slug + (finalExt ?? "");
}
function joinSegments(...args) {

2
dist/path.js.map vendored

File diff suppressed because one or more lines are too long

View file

@ -75,6 +75,19 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
slug = slug.replace(/_index$/, "index");
}
// Obsidian "Folder Notes" convention (insideFolder storage): a file named the
// same as its parent folder is treated as the folder's landing page. Rewrite
// `folder/folder` → `folder/index` so every downstream consumer (trie, link
// resolver, simplifySlug, isFolderPath) sees the canonical Quartz slug shape
// and Just Works. A top-level single-segment slug is NOT rewritten — that
// represents Obsidian's `parentFolder` storage mode, which Quartz already
// routes as a regular top-level page.
const segments = slug.split("/");
if (segments.length >= 2 && segments[segments.length - 1] === segments[segments.length - 2]) {
segments[segments.length - 1] = "index";
slug = segments.join("/");
}
return (slug + (finalExt ?? "")) as FullSlug;
}

View file

@ -9,10 +9,11 @@ import {
stripSlashes,
isFolderPath,
getAllSegmentPrefixes,
slugifyFilePath,
slugifyPath,
transformLink,
} from "../src/path.js";
import type { FullSlug, TransformOptions } from "../src/path.js";
import type { FilePath, FullSlug, TransformOptions } from "../src/path.js";
describe("simplifySlug", () => {
it("removes /index suffix", () => {
@ -90,6 +91,118 @@ describe("stripSlashes", () => {
});
});
describe("slugifyFilePath", () => {
const slugify = (fp: string) => slugifyFilePath(fp as FilePath);
describe("basic path handling", () => {
it("strips .md extension from markdown files", () => {
expect(slugify("content/post.md")).toBe("content/post");
});
it("strips .html extension", () => {
expect(slugify("content/post.html")).toBe("content/post");
});
it("preserves non-markdown extensions", () => {
expect(slugify("content/image.png")).toBe("content/image.png");
});
it("strips leading slash", () => {
expect(slugify("/content/post.md")).toBe("content/post");
});
it("handles top-level index.md", () => {
expect(slugify("index.md")).toBe("index");
});
it("handles nested index.md", () => {
expect(slugify("content/index.md")).toBe("content/index");
});
});
describe("Hugo _index convention", () => {
it("rewrites trailing _index to index", () => {
expect(slugify("content/_index.md")).toBe("content/index");
});
it("rewrites _index inside nested path", () => {
expect(slugify("a/b/_index.md")).toBe("a/b/index");
});
});
describe("Obsidian Folder Notes convention", () => {
it("rewrites folder/folder.md to folder/index", () => {
expect(slugify("characters/characters.md")).toBe("characters/index");
});
it("rewrites nested folder/folder.md to folder/index", () => {
expect(slugify("fiction/books/books.md")).toBe("fiction/books/index");
});
it("rewrites deeply nested folder/folder.md", () => {
expect(slugify("a/b/c/d/d.md")).toBe("a/b/c/d/index");
});
it("does NOT rewrite single-segment slugs (parentFolder storage is out of scope)", () => {
expect(slugify("characters.md")).toBe("characters");
});
it("does NOT rewrite when last two segments differ", () => {
expect(slugify("characters/alice.md")).toBe("characters/alice");
});
it("does NOT rewrite when basename matches a deeper ancestor but not direct parent", () => {
expect(slugify("characters/sub/characters.md")).toBe("characters/sub/characters");
});
it("rewrites even when the inner folder is inside a same-named outer folder", () => {
expect(slugify("a/a/a.md")).toBe("a/a/index");
});
it("leaves index.md as the canonical form unchanged", () => {
expect(slugify("characters/index.md")).toBe("characters/index");
});
it("does not double-apply when folder literally named 'index' has index.md", () => {
expect(slugify("index/index.md")).toBe("index/index");
});
it("handles nested folder literally named 'index'", () => {
expect(slugify("docs/index/index.md")).toBe("docs/index/index");
});
it("rewrites slugified paths with special characters", () => {
expect(slugify("my folder/my folder.md")).toBe("my-folder/index");
});
});
describe("interaction with simplifySlug (end-to-end canonicalization)", () => {
it("folder/folder.md routes to canonical folder URL via simplifySlug", () => {
expect(simplifySlug(slugify("characters/characters.md"))).toBe("characters/");
});
it("folder/index.md and folder/folder.md produce identical simplified URLs", () => {
expect(simplifySlug(slugify("characters/characters.md"))).toBe(
simplifySlug(slugify("characters/index.md")),
);
});
it("top-level index.md simplifies to root", () => {
expect(simplifySlug(slugify("index.md"))).toBe("/");
});
});
describe("interaction with isFolderPath", () => {
it("classifies folder/folder.md output as a folder path", () => {
expect(isFolderPath(slugify("characters/characters.md"))).toBe(true);
});
it("classifies regular file as non-folder", () => {
expect(isFolderPath(slugify("characters/alice.md"))).toBe(false);
});
});
});
describe("isFolderPath", () => {
it("detects trailing slash", () => {
expect(isFolderPath("folder/")).toBe(true);