mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-19 10:54:06 -06:00
feat: add support for lowercase paths
This commit is contained in:
parent
0ecb859d2d
commit
119e71d06e
@ -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`.
|
- 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. Default is `false`.
|
||||||
- `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.
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -62,6 +62,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.
|
||||||
|
|||||||
@ -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, cfg: QuartzConfig) => {
|
||||||
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, cfg.configuration.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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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)
|
||||||
} 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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])]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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(
|
||||||
[
|
[
|
||||||
|
|||||||
@ -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,16 +94,25 @@ 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)
|
// 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 segments = fplike.split("/").filter((x) => x.length > 0)
|
||||||
let prefix = segments.filter(isRelativeSegment).join("/")
|
let prefix = segments.filter(isRelativeSegment).join("/")
|
||||||
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 +193,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 +235,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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user