From 119e71d06e608844036262246d982d4e0e3a38cf Mon Sep 17 00:00:00 2001 From: Amarjeet Singh Rai Date: Wed, 5 Nov 2025 08:03:16 +0000 Subject: [PATCH] feat: add support for lowercase paths --- docs/configuration.md | 1 + quartz.config.ts | 1 + quartz/build.ts | 8 +++- quartz/cfg.ts | 2 + quartz/plugins/emitters/assets.ts | 14 ++++--- quartz/plugins/transformers/frontmatter.ts | 7 +++- quartz/plugins/transformers/links.ts | 1 + quartz/plugins/transformers/ofm.ts | 14 +++++-- quartz/processors/parse.ts | 6 ++- quartz/util/path.test.ts | 44 ++++++++++++++++++++++ quartz/util/path.ts | 32 +++++++++++----- 11 files changed, 108 insertions(+), 22 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index c12a8a562..bccef0b08 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,6 +41,7 @@ This part of the configuration concerns anything that can affect the whole site. - This should also include the subpath if you are [[hosting]] on GitHub pages without a custom domain. For example, if my repository is `jackyzha0/quartz`, GitHub pages would deploy to `https://jackyzha0.github.io/quartz` and the `baseUrl` would be `jackyzha0.github.io/quartz`. - Note that Quartz 4 will avoid using this as much as possible and use relative URLs whenever it can to make sure your site works no matter _where_ you end up actually deploying it. - `ignorePatterns`: a list of [glob]() patterns that Quartz should ignore and not search through when looking for files inside the `content` folder. See [[private pages]] for more details. +- `lowercasePaths`: whether to convert all generated paths (URLs) to lowercase. When set to `true`, a file named `MyFile.md` will be available at `/myfile` instead of `/MyFile`. This also applies to tags but not aliases. Default is `false`. - `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings. - `theme`: configure how the site looks. - `cdnCaching`: if `true` (default), use Google CDN to cache the fonts. This will generally be faster. Disable (`false`) this if you want Quartz to download the fonts to be self-contained. diff --git a/quartz.config.ts b/quartz.config.ts index b3db3d60d..a77770e2d 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -18,6 +18,7 @@ const config: QuartzConfig = { locale: "en-US", baseUrl: "quartz.jzhao.xyz", ignorePatterns: ["private", "templates", ".obsidian"], + lowercasePaths: false, defaultDateType: "modified", theme: { fontOrigin: "googleFonts", diff --git a/quartz/build.ts b/quartz/build.ts index f3adfe250..2ca9ba572 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -79,7 +79,9 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { const filePaths = markdownPaths.map((fp) => joinSegments(argv.directory, fp) as FilePath) ctx.allFiles = allFiles - ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath)) + ctx.allSlugs = allFiles.map((fp) => + slugifyFilePath(fp as FilePath, undefined, ctx.cfg.configuration.lowercasePaths), + ) const parsedFiles = await parseMarkdown(ctx, filePaths) const filteredContent = filterContent(ctx, parsedFiles) @@ -253,7 +255,9 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD // update allFiles and then allSlugs with the consistent view of content map ctx.allFiles = Array.from(contentMap.keys()) - ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath)) + ctx.allSlugs = ctx.allFiles.map((fp) => + slugifyFilePath(fp as FilePath, undefined, ctx.cfg.configuration.lowercasePaths), + ) let processedFiles = filterContent( ctx, Array.from(contentMap.values()) diff --git a/quartz/cfg.ts b/quartz/cfg.ts index 57dff5c75..9984e4664 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -62,6 +62,8 @@ export interface GlobalConfiguration { analytics: Analytics /** Glob patterns to not search */ ignorePatterns: string[] + /** Whether to convert all generated paths to lowercase (default: false) */ + lowercasePaths?: boolean /** Whether to use created, modified, or published as the default type of date */ defaultDateType: ValidDateType /** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL. diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index d0da66ace..2cb5a017a 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -11,10 +11,10 @@ const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => { return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]) } -const copyFile = async (argv: Argv, fp: FilePath) => { +const copyFile = async (argv: Argv, fp: FilePath, cfg: QuartzConfig) => { const src = joinSegments(argv.directory, fp) as FilePath - const name = slugifyFilePath(fp) + const name = slugifyFilePath(fp, undefined, cfg.configuration.lowercasePaths) const dest = joinSegments(argv.output, name) as FilePath // ensure dir exists @@ -31,7 +31,7 @@ export const Assets: QuartzEmitterPlugin = () => { async *emit({ argv, cfg }) { const fps = await filesToCopy(argv, cfg) for (const fp of fps) { - yield copyFile(argv, fp) + yield copyFile(argv, fp, cfg) } }, async *partialEmit(ctx, _content, _resources, changeEvents) { @@ -40,9 +40,13 @@ export const Assets: QuartzEmitterPlugin = () => { if (ext === ".md") continue if (changeEvent.type === "add" || changeEvent.type === "change") { - yield copyFile(ctx.argv, changeEvent.path) + yield copyFile(ctx.argv, changeEvent.path, ctx.cfg) } else if (changeEvent.type === "delete") { - const name = slugifyFilePath(changeEvent.path) + const name = slugifyFilePath( + changeEvent.path, + undefined, + ctx.cfg.configuration.lowercasePaths, + ) const dest = joinSegments(ctx.argv.output, name) as FilePath await fs.promises.unlink(dest) } diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index db1cf4213..2a2b48e28 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -78,7 +78,12 @@ export const FrontMatter: QuartzTransformerPlugin> = (userOpts) } const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"])) - if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))] + if (tags) + data.tags = [ + ...new Set( + tags.map((tag: string) => slugTag(tag, cfg.configuration.lowercasePaths)), + ), + ] const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"])) if (aliases) { diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index f4451d927..acea3903e 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -46,6 +46,7 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) const transformOptions: TransformOptions = { strategy: opts.markdownLinkResolution, allSlugs: ctx.allSlugs, + lowercasePaths: ctx.cfg.configuration.lowercasePaths, } visit(tree, "element", (node, _index, _parent) => { diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 7a523aa59..06b64136a 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -229,7 +229,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> // embed cases if (value.startsWith("!")) { const ext: string = path.extname(fp).toLowerCase() - const url = slugifyFilePath(fp as FilePath) + const url = slugifyFilePath( + fp as FilePath, + undefined, + ctx.cfg.configuration.lowercasePaths, + ) if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) { const match = wikilinkImageEmbedRegex.exec(alias ?? "") const alt = match?.groups?.alt ?? "" @@ -279,7 +283,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> // treat as broken link if slug not in ctx.allSlugs if (opts.disableBrokenWikilinks) { - const slug = slugifyFilePath(fp as FilePath) + const slug = slugifyFilePath( + fp as FilePath, + undefined, + ctx.cfg.configuration.lowercasePaths, + ) const exists = ctx.allSlugs && ctx.allSlugs.includes(slug) if (!exists) { return { @@ -342,7 +350,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> return false } - tag = slugTag(tag) + tag = slugTag(tag, ctx.cfg.configuration.lowercasePaths) if (file.data.frontmatter) { const noteTags = file.data.frontmatter.tags ?? [] file.data.frontmatter.tags = [...new Set([...noteTags, tag])] diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts index 1099cd99b..cc9810cde 100644 --- a/quartz/processors/parse.ts +++ b/quartz/processors/parse.ts @@ -102,7 +102,11 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) { // base data properties that plugins may use file.data.filePath = file.path as FilePath file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath - file.data.slug = slugifyFilePath(file.data.relativePath) + file.data.slug = slugifyFilePath( + file.data.relativePath, + undefined, + cfg.configuration.lowercasePaths, + ) const ast = processor.parse(file) const newAst = await processor.run(ast, file) diff --git a/quartz/util/path.test.ts b/quartz/util/path.test.ts index e85f1d0ad..2c62e9a9d 100644 --- a/quartz/util/path.test.ts +++ b/quartz/util/path.test.ts @@ -127,6 +127,29 @@ describe("transforms", () => { ) }) + test("slugifyFilePath with lowercase", () => { + asserts( + [ + ["content/Index.md", "content/index"], + ["content/INDEX.html", "content/index"], + ["content/_Index.md", "content/index"], + ["/content/Index.md", "content/index"], + ["content/Cool.png", "content/cool.png"], + ["Index.md", "index"], + ["Test.mp4", "test.mp4"], + ["Note With Spaces.md", "note-with-spaces"], + ["Notes.With.Dots.md", "notes.with.dots"], + ["Test/Special Chars?.md", "test/special-chars"], + ["Test/Special Chars #3.md", "test/special-chars-3"], + ["Cool/What About R&D?.md", "cool/what-about-r-and-d"], + ["MixedCase/File.md", "mixedcase/file"], + ], + (fp: path.FilePath) => path.slugifyFilePath(fp, undefined, true), + path.isFilePath, + path.isFullSlug, + ) + }) + test("transformInternalLink", () => { asserts( [ @@ -155,6 +178,27 @@ describe("transforms", () => { ) }) + test("transformInternalLink with lowercase", () => { + asserts( + [ + ["Content", "./content"], + ["Content/Test.md", "./content/test"], + ["Content/Test.pdf", "./content/test.pdf"], + ["./Content/Test.md", "./content/test"], + ["../Content/Test.md", "../content/test"], + ["Tags/", "./tags/"], + ["/Tags/", "./tags/"], + ["Content/With Spaces", "./content/with-spaces"], + ["Content/With Spaces/Index", "./content/with-spaces/"], + ["Content/With Spaces#And Anchor!", "./content/with-spaces#and-anchor"], + ["MixedCase/File.md", "./mixedcase/file"], + ], + (link: string) => path.transformInternalLink(link, true), + (_x: string): _x is string => true, + path.isRelativeURL, + ) + }) + test("pathToRoot", () => { asserts( [ diff --git a/quartz/util/path.ts b/quartz/util/path.ts index b95770159..b62b5a282 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -54,8 +54,8 @@ export function getFullSlug(window: Window): FullSlug { return res } -function sluggify(s: string): string { - return s +function sluggify(s: string, lowercase?: boolean): string { + const result = s .split("/") .map((segment) => segment @@ -67,9 +67,11 @@ function sluggify(s: string): string { ) .join("/") // always use / as sep .replace(/\/$/, "") + + return lowercase ? result.toLowerCase() : result } -export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { +export function slugifyFilePath(fp: FilePath, excludeExt?: boolean, lowercase?: boolean): FullSlug { fp = stripSlashes(fp) as FilePath let ext = getFileExtension(fp) const withoutFileExt = fp.replace(new RegExp(ext + "$"), "") @@ -77,7 +79,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { ext = "" } - let slug = sluggify(withoutFileExt) + let slug = sluggify(withoutFileExt, lowercase) // treat _index as index if (endsWith(slug, "_index")) { @@ -92,16 +94,25 @@ export function simplifySlug(fp: FullSlug): SimpleSlug { return (res.length === 0 ? "/" : res) as SimpleSlug } -export function transformInternalLink(link: string): RelativeURL { +export function transformInternalLink(link: string, lowercase?: boolean): RelativeURL { let [fplike, anchor] = splitAnchor(decodeURI(link)) - const folderPath = isFolderPath(fplike) + // If lowercase is enabled, check for folder path in a case-insensitive way + const folderPath = lowercase + ? fplike.endsWith("/") || + fplike.toLowerCase().endsWith("/index") || + fplike.toLowerCase().endsWith("/index.md") || + fplike.toLowerCase().endsWith("/index.html") || + fplike.toLowerCase() === "index" || + fplike.toLowerCase() === "index.md" || + fplike.toLowerCase() === "index.html" + : isFolderPath(fplike) let segments = fplike.split("/").filter((x) => x.length > 0) let prefix = segments.filter(isRelativeSegment).join("/") let fp = segments.filter((seg) => !isRelativeSegment(seg) && seg !== "").join("/") // manually add ext here as we want to not strip 'index' if it has an extension - const simpleSlug = simplifySlug(slugifyFilePath(fp as FilePath)) + const simpleSlug = simplifySlug(slugifyFilePath(fp as FilePath, undefined, lowercase)) const joined = joinSegments(stripSlashes(prefix), stripSlashes(simpleSlug)) const trail = folderPath ? "/" : "" const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL @@ -182,10 +193,10 @@ export function splitAnchor(link: string): [string, string] { return [fp, anchor] } -export function slugTag(tag: string) { +export function slugTag(tag: string, lowercase?: boolean) { return tag .split("/") - .map((tagSegment) => sluggify(tagSegment)) + .map((tagSegment) => sluggify(tagSegment, lowercase)) .join("/") } @@ -224,10 +235,11 @@ export function getAllSegmentPrefixes(tags: string): string[] { export interface TransformOptions { strategy: "absolute" | "relative" | "shortest" allSlugs: FullSlug[] + lowercasePaths?: boolean } export function transformLink(src: FullSlug, target: string, opts: TransformOptions): RelativeURL { - let targetSlug = transformInternalLink(target) + let targetSlug = transformInternalLink(target, opts.lowercasePaths) if (opts.strategy === "relative") { return targetSlug as RelativeURL