mirror of
https://github.com/quartz-community/utils.git
synced 2026-07-22 02:50:27 +00:00
feat(path): support folder note convention
This commit is contained in:
parent
cf1450b6f7
commit
e17fc8d5f0
6 changed files with 139 additions and 3 deletions
5
dist/index.js
vendored
5
dist/index.js
vendored
|
|
@ -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
2
dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
5
dist/path.js
vendored
5
dist/path.js
vendored
|
|
@ -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
2
dist/path.js.map
vendored
File diff suppressed because one or more lines are too long
13
src/path.ts
13
src/path.ts
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue