diff --git a/docs/plugins/AliasRedirects.md b/docs/plugins/AliasRedirects.md
index 8c0365377..94ba43bcf 100644
--- a/docs/plugins/AliasRedirects.md
+++ b/docs/plugins/AliasRedirects.md
@@ -6,19 +6,39 @@ tags:
This plugin emits HTML redirect pages for aliases and permalinks defined in the frontmatter of content files.
-For example, A `foo.md` has the following frontmatter
+## Aliases
+
+Aliases create redirects from alternative names to the actual page URL.
+
+For example, a `foo.md` has the following frontmatter:
```md title="foo.md"
---
title: "Foo"
alias:
- "bar"
+ - "old-name"
---
```
-The target `host.me/bar` will be redirected to `host.me/foo`
+The URLs `host.me/bar` and `host.me/old-name` will be redirected to `host.me/foo`
-Note that these are permanent redirect.
+## Permalinks
+
+Permalinks set the actual URL where the page will be accessed. When you set a permalink, the page is rendered at that URL instead of the default file path.
+
+For example, a `my-note.md` has the following frontmatter:
+
+```md title="my-note.md"
+---
+title: "My Note"
+permalink: "/custom-path"
+---
+```
+
+The page will be accessible at `host.me/custom-path` (not at `host.me/my-note`). A redirect will automatically be created from the original file path (`host.me/my-note`) to the permalink (`host.me/custom-path`).
+
+Note that these are permanent redirects.
The emitter supports the following aliases:
diff --git a/quartz/build.ts b/quartz/build.ts
index f3adfe250..81abdf24f 100644
--- a/quartz/build.ts
+++ b/quartz/build.ts
@@ -49,6 +49,7 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
cfg,
allSlugs: [],
allFiles: [],
+ slugToPermalink: {},
incremental: false,
}
@@ -84,6 +85,13 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const parsedFiles = await parseMarkdown(ctx, filePaths)
const filteredContent = filterContent(ctx, parsedFiles)
+ // Build slugToPermalink map after parsing
+ for (const [_tree, file] of filteredContent) {
+ if (file.data.permalinkSlug && file.data.slug) {
+ ctx.slugToPermalink[file.data.slug] = file.data.permalinkSlug
+ }
+ }
+
await emitContent(ctx, filteredContent)
console.log(
styleText("green", `Done processing ${markdownPaths.length} files in ${perf.timeSince()}`),
@@ -261,6 +269,14 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD
.map((file) => file.content),
)
+ // Rebuild slugToPermalink map
+ ctx.slugToPermalink = {}
+ for (const [_tree, file] of processedFiles) {
+ if (file.data.permalinkSlug && file.data.slug) {
+ ctx.slugToPermalink[file.data.slug] = file.data.permalinkSlug
+ }
+ }
+
let emittedFiles = 0
for (const emitter of cfg.plugins.emitters) {
// Try to use partialEmit if available, otherwise assume the output is static
diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx
index 0d34457f3..0d783310a 100644
--- a/quartz/components/Backlinks.tsx
+++ b/quartz/components/Backlinks.tsx
@@ -1,6 +1,6 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/backlinks.scss"
-import { resolveRelative, simplifySlug } from "../util/path"
+import { resolveRelative, simplifySlug, getDisplaySlug } from "../util/path"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
import OverflowListFactory from "./OverflowList"
@@ -35,7 +35,10 @@ export default ((opts?: Partial) => {
{backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => (
-
+
{f.frontmatter?.title}
diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx
index 5144a314d..43690589f 100644
--- a/quartz/components/Breadcrumbs.tsx
+++ b/quartz/components/Breadcrumbs.tsx
@@ -1,6 +1,6 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
-import { FullSlug, SimpleSlug, resolveRelative, simplifySlug } from "../util/path"
+import { FullSlug, SimpleSlug, resolveRelative, simplifySlug, getDisplaySlug } from "../util/path"
import { classNames } from "../util/lang"
import { trieFromAllFiles } from "../util/ctx"
@@ -59,7 +59,7 @@ export default ((opts?: Partial) => {
}
const crumbs: CrumbData[] = pathNodes.map((node, idx) => {
- const crumb = formatCrumb(node.displayName, fileData.slug!, simplifySlug(node.slug))
+ const crumb = formatCrumb(node.displayName, getDisplaySlug(fileData), simplifySlug(node.slug))
if (idx === 0) {
crumb.displayName = options.rootName
}
diff --git a/quartz/components/PageList.tsx b/quartz/components/PageList.tsx
index 7bf23829c..e58a8fda4 100644
--- a/quartz/components/PageList.tsx
+++ b/quartz/components/PageList.tsx
@@ -1,4 +1,4 @@
-import { FullSlug, isFolderPath, resolveRelative } from "../util/path"
+import { FullSlug, isFolderPath, resolveRelative, getDisplaySlug } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile"
import { Date, getDate } from "./Date"
import { QuartzComponent, QuartzComponentProps } from "./types"
@@ -78,7 +78,10 @@ export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort
@@ -88,7 +91,7 @@ export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort
{tag}
diff --git a/quartz/components/RecentNotes.tsx b/quartz/components/RecentNotes.tsx
index 2c32feadf..06b2448ad 100644
--- a/quartz/components/RecentNotes.tsx
+++ b/quartz/components/RecentNotes.tsx
@@ -1,5 +1,5 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
-import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
+import { FullSlug, SimpleSlug, resolveRelative, getDisplaySlug } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile"
import { byDateAndAlphabetical } from "./PageList"
import style from "./styles/recentNotes.scss"
@@ -48,7 +48,10 @@ export default ((userOpts?: Partial) => {
@@ -64,7 +67,10 @@ export default ((userOpts?: Partial
) => {
{tag}
@@ -79,7 +85,7 @@ export default ((userOpts?: Partial) => {
{opts.linkToMore && remaining > 0 && (
-
+
{i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })}
diff --git a/quartz/components/TagList.tsx b/quartz/components/TagList.tsx
index c73ed392a..ad5a48a23 100644
--- a/quartz/components/TagList.tsx
+++ b/quartz/components/TagList.tsx
@@ -1,4 +1,4 @@
-import { FullSlug, resolveRelative } from "../util/path"
+import { FullSlug, resolveRelative, getDisplaySlug } from "../util/path"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
@@ -8,7 +8,7 @@ const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentPro
return (
{tags.map((tag) => {
- const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)
+ const linkDest = resolveRelative(getDisplaySlug(fileData), `tags/${tag}` as FullSlug)
return (
-
diff --git a/quartz/components/pages/TagContent.tsx b/quartz/components/pages/TagContent.tsx
index a1df6e140..f602fcb47 100644
--- a/quartz/components/pages/TagContent.tsx
+++ b/quartz/components/pages/TagContent.tsx
@@ -1,7 +1,13 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import style from "../styles/listPage.scss"
import { PageList, SortFn } from "../PageList"
-import { FullSlug, getAllSegmentPrefixes, resolveRelative, simplifySlug } from "../../util/path"
+import {
+ FullSlug,
+ getAllSegmentPrefixes,
+ resolveRelative,
+ simplifySlug,
+ getDisplaySlug,
+} from "../../util/path"
import { QuartzPluginData } from "../../plugins/vfile"
import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx"
@@ -75,7 +81,7 @@ export default ((opts?: Partial) => {
: htmlToJsx(contentPage.filePath!, root)
const tagListingPage = `/tags/${tag}` as FullSlug
- const href = resolveRelative(fileData.slug!, tagListingPage)
+ const href = resolveRelative(getDisplaySlug(fileData), tagListingPage)
return (
diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts
index 9c8341169..dac58dd4f 100644
--- a/quartz/components/scripts/explorer.inline.ts
+++ b/quartz/components/scripts/explorer.inline.ts
@@ -84,11 +84,13 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen
const clone = template.content.cloneNode(true) as DocumentFragment
const li = clone.querySelector("li") as HTMLLIElement
const a = li.querySelector("a") as HTMLAnchorElement
- a.href = resolveRelative(currentSlug, node.slug)
+ // Use displaySlug if available (for permalink support), otherwise use slug
+ const linkSlug = (node.data as any)?.displaySlug ?? node.slug
+ a.href = resolveRelative(currentSlug, linkSlug)
a.dataset.for = node.slug
a.textContent = node.displayName
- if (currentSlug === node.slug) {
+ if (currentSlug === linkSlug || currentSlug === node.slug) {
a.classList.add("active")
}
@@ -115,7 +117,9 @@ function createFolderNode(
// Replace button with link for link behavior
const button = titleContainer.querySelector(".folder-button") as HTMLElement
const a = document.createElement("a")
- a.href = resolveRelative(currentSlug, folderPath)
+ // Use displaySlug if available (for permalink support), otherwise use folderPath
+ const linkSlug = (node.data as any)?.displaySlug ?? folderPath
+ a.href = resolveRelative(currentSlug, linkSlug)
a.dataset.for = folderPath
a.className = "folder-title"
a.textContent = node.displayName
diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts
index 9cb9bd576..cda2fbdb8 100644
--- a/quartz/plugins/emitters/aliases.ts
+++ b/quartz/plugins/emitters/aliases.ts
@@ -6,7 +6,9 @@ import { VFile } from "vfile"
import path from "path"
async function* processFile(ctx: BuildCtx, file: VFile) {
- const ogSlug = simplifySlug(file.data.slug!)
+ // Use permalinkSlug if set, otherwise use the regular slug
+ const targetSlug = file.data.permalinkSlug ?? file.data.slug!
+ const ogSlug = simplifySlug(targetSlug)
for (const aliasTarget of file.data.aliases ?? []) {
const aliasTargetSlug = (
diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx
index 56392b358..3d5a63252 100644
--- a/quartz/plugins/emitters/contentIndex.tsx
+++ b/quartz/plugins/emitters/contentIndex.tsx
@@ -11,6 +11,7 @@ import { i18n } from "../../i18n"
export type ContentIndexMap = Map
export type ContentDetails = {
slug: FullSlug
+ displaySlug?: FullSlug
filePath: FilePath
title: string
links: SimpleSlug[]
@@ -46,7 +47,9 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string
${content.date && `${content.date.toISOString()}`}
`
const urls = Array.from(idx)
- .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
+ .map(([_, content]) =>
+ createURLEntry(simplifySlug(content.displaySlug ?? content.slug), content),
+ )
.join("")
return `${urls}`
}
@@ -74,7 +77,9 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?:
return f1.title.localeCompare(f2.title)
})
- .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
+ .map(([_, content]) =>
+ createURLEntry(simplifySlug(content.displaySlug ?? content.slug), content),
+ )
.slice(0, limit ?? idx.size)
.join("")
@@ -100,11 +105,14 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => {
const cfg = ctx.cfg.configuration
const linkIndex: ContentIndexMap = new Map()
for (const [tree, file] of content) {
+ // Always use the original slug for tree structure
const slug = file.data.slug!
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
linkIndex.set(slug, {
slug,
+ // Add displaySlug if permalink is set
+ displaySlug: file.data.permalinkSlug,
filePath: file.data.relativePath!,
title: file.data.frontmatter?.title!,
links: file.data.links ?? [],
diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx
index c3410ecc3..37fbdcdb0 100644
--- a/quartz/plugins/emitters/contentPage.tsx
+++ b/quartz/plugins/emitters/contentPage.tsx
@@ -24,8 +24,10 @@ async function processContent(
resources: StaticResources,
) {
const slug = fileData.slug!
+ // Use permalinkSlug if set, otherwise use the regular slug
+ const renderSlug = fileData.permalinkSlug ?? slug
const cfg = ctx.cfg.configuration
- const externalResources = pageResources(pathToRoot(slug), resources)
+ const externalResources = pageResources(pathToRoot(renderSlug), resources)
const componentData: QuartzComponentProps = {
ctx,
fileData,
@@ -36,11 +38,11 @@ async function processContent(
allFiles,
}
- const content = renderPage(cfg, slug, componentData, opts, externalResources)
+ const content = renderPage(cfg, renderSlug, componentData, opts, externalResources)
return write({
ctx,
content,
- slug,
+ slug: renderSlug,
ext: ".html",
})
}
diff --git a/quartz/plugins/emitters/ogImage.tsx b/quartz/plugins/emitters/ogImage.tsx
index 813d9348c..6d3658aab 100644
--- a/quartz/plugins/emitters/ogImage.tsx
+++ b/quartz/plugins/emitters/ogImage.tsx
@@ -72,7 +72,7 @@ async function processOgImage(
fullOptions: SocialImageOptions,
) {
const cfg = ctx.cfg.configuration
- const slug = fileData.slug!
+ const slug = fileData.permalinkSlug ?? fileData.slug!
const titleSuffix = cfg.pageTitleSuffix ?? ""
const title =
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
@@ -154,7 +154,7 @@ export const CustomOgImages: QuartzEmitterPlugin> =
}
const generatedOgImagePath = isRealFile
- ? `https://${baseUrl}/${pageData.slug!}-og-image.webp`
+ ? `https://${baseUrl}/${pageData.permalinkSlug ?? pageData.slug!}-og-image.webp`
: undefined
const defaultOgImagePath = `https://${baseUrl}/static/og-image.png`
const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath
diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts
index db1cf4213..213fd015a 100644
--- a/quartz/plugins/transformers/frontmatter.ts
+++ b/quartz/plugins/transformers/frontmatter.ts
@@ -87,12 +87,21 @@ export const FrontMatter: QuartzTransformerPlugin> = (userOpts)
allSlugs.push(...file.data.aliases)
}
+ // Handle permalink: it should be the actual URL where the page is rendered
+ // Keep the original slug for sidebar structure, use permalinkSlug for rendering
if (data.permalink != null && data.permalink.toString() !== "") {
data.permalink = data.permalink.toString() as FullSlug
- const aliases = file.data.aliases ?? []
- aliases.push(data.permalink)
- file.data.aliases = aliases
+ const originalSlug = file.data.slug
+
+ // Set the permalinkSlug - this is where the page will actually be rendered
+ file.data.permalinkSlug = data.permalink
allSlugs.push(data.permalink)
+
+ // Create a redirect from the original file path to the permalink
+ if (originalSlug && originalSlug !== data.permalink) {
+ file.data.aliases = file.data.aliases ?? []
+ file.data.aliases.push(originalSlug)
+ }
}
const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
@@ -135,6 +144,7 @@ export const FrontMatter: QuartzTransformerPlugin> = (userOpts)
declare module "vfile" {
interface DataMap {
aliases: FullSlug[]
+ permalinkSlug?: FullSlug
frontmatter: { [key: string]: unknown } & {
title: string
} & Partial<{
diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts
index f4451d927..7090baa74 100644
--- a/quartz/plugins/transformers/links.ts
+++ b/quartz/plugins/transformers/links.ts
@@ -8,6 +8,7 @@ import {
simplifySlug,
splitAnchor,
transformLink,
+ resolveRelative,
} from "../../util/path"
import path from "path"
import { visit } from "unist-util-visit"
@@ -113,7 +114,7 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts)
// WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
const url = new URL(dest, "https://base.com/" + stripSlashes(curSlug, true))
const canonicalDest = url.pathname
- let [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
+ let [destCanonical, destAnchor] = splitAnchor(canonicalDest)
if (destCanonical.endsWith("/")) {
destCanonical += "index"
}
@@ -123,6 +124,14 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts)
const simple = simplifySlug(full)
outgoing.add(simple)
node.properties["data-slug"] = full
+
+ // If the target file has a permalink, rewrite the href to use it
+ if (ctx.slugToPermalink[full]) {
+ const permalinkSlug = ctx.slugToPermalink[full]
+ const currentSlug = file.data.permalinkSlug ?? file.data.slug!
+ const newHref = resolveRelative(currentSlug, permalinkSlug) + destAnchor
+ node.properties.href = newHref
+ }
}
// rewrite link internals if prettylinks is on
diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts
index 1099cd99b..bea7a184a 100644
--- a/quartz/processors/parse.ts
+++ b/quartz/processors/parse.ts
@@ -181,6 +181,7 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise
trie?: FileTrieNode
incremental: boolean
}
diff --git a/quartz/util/path.ts b/quartz/util/path.ts
index b95770159..09af046ce 100644
--- a/quartz/util/path.ts
+++ b/quartz/util/path.ts
@@ -173,6 +173,11 @@ export function resolveRelative(current: FullSlug, target: FullSlug | SimpleSlug
return res
}
+/** Get the slug to use for linking to a page (uses permalink if available) */
+export function getDisplaySlug(data: { slug?: FullSlug; permalinkSlug?: FullSlug }): FullSlug {
+ return data.permalinkSlug ?? data.slug ?? ("" as FullSlug)
+}
+
export function splitAnchor(link: string): [string, string] {
let [fp, anchor] = link.split("#", 2)
if (fp.endsWith(".pdf")) {