This commit is contained in:
Amarjeet Singh Rai 2025-12-12 12:09:45 -07:00 committed by GitHub
commit a6b91a0f99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 98 additions and 21 deletions

View File

@ -42,6 +42,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`. - 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. - 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](<https://en.wikipedia.org/wiki/Glob_(programming)>) patterns that Quartz should ignore and not search through when looking for files inside the `content` folder. See [[private pages]] for more details. - `ignorePatterns`: a list of [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) 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.
- `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings. - `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. - `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. - `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.

View File

@ -18,6 +18,7 @@ const config: QuartzConfig = {
locale: "en-US", locale: "en-US",
baseUrl: "quartz.jzhao.xyz", baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"], ignorePatterns: ["private", "templates", ".obsidian"],
lowercasePaths: false,
defaultDateType: "modified", defaultDateType: "modified",
theme: { theme: {
fontOrigin: "googleFonts", fontOrigin: "googleFonts",

View File

@ -79,7 +79,9 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const filePaths = markdownPaths.map((fp) => joinSegments(argv.directory, fp) as FilePath) const filePaths = markdownPaths.map((fp) => joinSegments(argv.directory, fp) as FilePath)
ctx.allFiles = allFiles 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 parsedFiles = await parseMarkdown(ctx, filePaths)
const filteredContent = filterContent(ctx, parsedFiles) 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 // update allFiles and then allSlugs with the consistent view of content map
ctx.allFiles = Array.from(contentMap.keys()) 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( let processedFiles = filterContent(
ctx, ctx,
Array.from(contentMap.values()) Array.from(contentMap.values())

View File

@ -67,6 +67,8 @@ export interface GlobalConfiguration {
analytics: Analytics analytics: Analytics
/** Glob patterns to not search */ /** Glob patterns to not search */
ignorePatterns: string[] 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 */ /** Whether to use created, modified, or published as the default type of date */
defaultDateType: ValidDateType defaultDateType: ValidDateType
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL. /** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.

View File

@ -11,10 +11,10 @@ const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => {
return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]) return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
} }
const copyFile = async (argv: Argv, fp: FilePath) => { const copyFile = async (argv: Argv, fp: FilePath, lowercasePaths: boolean) => {
const src = joinSegments(argv.directory, fp) as FilePath const src = joinSegments(argv.directory, fp) as FilePath
const name = slugifyFilePath(fp) const name = slugifyFilePath(fp, undefined, lowercasePaths)
const dest = joinSegments(argv.output, name) as FilePath const dest = joinSegments(argv.output, name) as FilePath
// ensure dir exists // ensure dir exists
@ -31,7 +31,7 @@ export const Assets: QuartzEmitterPlugin = () => {
async *emit({ argv, cfg }) { async *emit({ argv, cfg }) {
const fps = await filesToCopy(argv, cfg) const fps = await filesToCopy(argv, cfg)
for (const fp of fps) { for (const fp of fps) {
yield copyFile(argv, fp) yield copyFile(argv, fp, cfg.configuration.lowercasePaths)
} }
}, },
async *partialEmit(ctx, _content, _resources, changeEvents) { async *partialEmit(ctx, _content, _resources, changeEvents) {
@ -40,9 +40,13 @@ export const Assets: QuartzEmitterPlugin = () => {
if (ext === ".md") continue if (ext === ".md") continue
if (changeEvent.type === "add" || changeEvent.type === "change") { if (changeEvent.type === "add" || changeEvent.type === "change") {
yield copyFile(ctx.argv, changeEvent.path) yield copyFile(ctx.argv, changeEvent.path, ctx.cfg.configuration.lowercasePaths)
} else if (changeEvent.type === "delete") { } 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 const dest = joinSegments(ctx.argv.output, name) as FilePath
await fs.promises.unlink(dest) await fs.promises.unlink(dest)
} }

View File

@ -78,7 +78,12 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
} }
const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"])) 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"])) const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"]))
if (aliases) { if (aliases) {

View File

@ -46,6 +46,7 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
const transformOptions: TransformOptions = { const transformOptions: TransformOptions = {
strategy: opts.markdownLinkResolution, strategy: opts.markdownLinkResolution,
allSlugs: ctx.allSlugs, allSlugs: ctx.allSlugs,
lowercasePaths: ctx.cfg.configuration.lowercasePaths,
} }
visit(tree, "element", (node, _index, _parent) => { visit(tree, "element", (node, _index, _parent) => {

View File

@ -229,7 +229,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
// embed cases // embed cases
if (value.startsWith("!")) { if (value.startsWith("!")) {
const ext: string = path.extname(fp).toLowerCase() 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)) { if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) {
const match = wikilinkImageEmbedRegex.exec(alias ?? "") const match = wikilinkImageEmbedRegex.exec(alias ?? "")
const alt = match?.groups?.alt ?? "" const alt = match?.groups?.alt ?? ""
@ -279,7 +283,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
// treat as broken link if slug not in ctx.allSlugs // treat as broken link if slug not in ctx.allSlugs
if (opts.disableBrokenWikilinks) { 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) const exists = ctx.allSlugs && ctx.allSlugs.includes(slug)
if (!exists) { if (!exists) {
return { return {
@ -342,7 +350,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
return false return false
} }
tag = slugTag(tag) tag = slugTag(tag, ctx.cfg.configuration.lowercasePaths)
if (file.data.frontmatter) { if (file.data.frontmatter) {
const noteTags = file.data.frontmatter.tags ?? [] const noteTags = file.data.frontmatter.tags ?? []
file.data.frontmatter.tags = [...new Set([...noteTags, tag])] file.data.frontmatter.tags = [...new Set([...noteTags, tag])]

View File

@ -102,7 +102,11 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
// base data properties that plugins may use // base data properties that plugins may use
file.data.filePath = file.path as FilePath file.data.filePath = file.path as FilePath
file.data.relativePath = path.posix.relative(argv.directory, 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 ast = processor.parse(file)
const newAst = await processor.run(ast, file) const newAst = await processor.run(ast, file)

View File

@ -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", () => { test("transformInternalLink", () => {
asserts( 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", () => { test("pathToRoot", () => {
asserts( asserts(
[ [

View File

@ -54,8 +54,8 @@ export function getFullSlug(window: Window): FullSlug {
return res return res
} }
function sluggify(s: string): string { function sluggify(s: string, lowercase?: boolean): string {
return s const result = s
.split("/") .split("/")
.map((segment) => .map((segment) =>
segment segment
@ -67,9 +67,11 @@ function sluggify(s: string): string {
) )
.join("/") // always use / as sep .join("/") // always use / as sep
.replace(/\/$/, "") .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 fp = stripSlashes(fp) as FilePath
let ext = getFileExtension(fp) let ext = getFileExtension(fp)
const withoutFileExt = fp.replace(new RegExp(ext + "$"), "") const withoutFileExt = fp.replace(new RegExp(ext + "$"), "")
@ -77,7 +79,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
ext = "" ext = ""
} }
let slug = sluggify(withoutFileExt) let slug = sluggify(withoutFileExt, lowercase)
// treat _index as index // treat _index as index
if (endsWith(slug, "_index")) { if (endsWith(slug, "_index")) {
@ -92,7 +94,7 @@ export function simplifySlug(fp: FullSlug): SimpleSlug {
return (res.length === 0 ? "/" : res) as 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)) let [fplike, anchor] = splitAnchor(decodeURI(link))
const folderPath = isFolderPath(fplike) const folderPath = isFolderPath(fplike)
@ -101,7 +103,7 @@ export function transformInternalLink(link: string): RelativeURL {
let fp = segments.filter((seg) => !isRelativeSegment(seg) && seg !== "").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 // 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 joined = joinSegments(stripSlashes(prefix), stripSlashes(simpleSlug))
const trail = folderPath ? "/" : "" const trail = folderPath ? "/" : ""
const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL
@ -182,10 +184,10 @@ export function splitAnchor(link: string): [string, string] {
return [fp, anchor] return [fp, anchor]
} }
export function slugTag(tag: string) { export function slugTag(tag: string, lowercase?: boolean) {
return tag return tag
.split("/") .split("/")
.map((tagSegment) => sluggify(tagSegment)) .map((tagSegment) => sluggify(tagSegment, lowercase))
.join("/") .join("/")
} }
@ -224,10 +226,11 @@ export function getAllSegmentPrefixes(tags: string): string[] {
export interface TransformOptions { export interface TransformOptions {
strategy: "absolute" | "relative" | "shortest" strategy: "absolute" | "relative" | "shortest"
allSlugs: FullSlug[] allSlugs: FullSlug[]
lowercasePaths?: boolean
} }
export function transformLink(src: FullSlug, target: string, opts: TransformOptions): RelativeURL { export function transformLink(src: FullSlug, target: string, opts: TransformOptions): RelativeURL {
let targetSlug = transformInternalLink(target) let targetSlug = transformInternalLink(target, opts.lowercasePaths)
if (opts.strategy === "relative") { if (opts.strategy === "relative") {
return targetSlug as RelativeURL return targetSlug as RelativeURL