fix: correct permalink and alias behavior to match Obsidian

This commit is contained in:
Amarjeet Singh Rai 2025-11-05 09:42:47 +00:00
parent bacd19c4ea
commit 84d6484350
No known key found for this signature in database
18 changed files with 129 additions and 33 deletions

View File

@ -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:

View File

@ -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

View File

@ -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<BacklinksOptions>) => {
{backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => (
<li>
<a href={resolveRelative(fileData.slug!, f.slug!)} class="internal">
<a
href={resolveRelative(getDisplaySlug(fileData), getDisplaySlug(f))}
class="internal"
>
{f.frontmatter?.title}
</a>
</li>

View File

@ -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<BreadcrumbOptions>) => {
}
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
}

View File

@ -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
</p>
<div class="desc">
<h3>
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
<a
href={resolveRelative(getDisplaySlug(fileData), getDisplaySlug(page))}
class="internal"
>
{title}
</a>
</h3>
@ -88,7 +91,7 @@ export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort
<li>
<a
class="internal tag-link"
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
href={resolveRelative(getDisplaySlug(fileData), `tags/${tag}` as FullSlug)}
>
{tag}
</a>

View File

@ -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<Options>) => {
<div class="section">
<div class="desc">
<h3>
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
<a
href={resolveRelative(getDisplaySlug(fileData), getDisplaySlug(page))}
class="internal"
>
{title}
</a>
</h3>
@ -64,7 +67,10 @@ export default ((userOpts?: Partial<Options>) => {
<li>
<a
class="internal tag-link"
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
href={resolveRelative(
getDisplaySlug(fileData),
`tags/${tag}` as FullSlug,
)}
>
{tag}
</a>
@ -79,7 +85,7 @@ export default ((userOpts?: Partial<Options>) => {
</ul>
{opts.linkToMore && remaining > 0 && (
<p>
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>
<a href={resolveRelative(getDisplaySlug(fileData), opts.linkToMore)}>
{i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })}
</a>
</p>

View File

@ -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 (
<ul class={classNames(displayClass, "tags")}>
{tags.map((tag) => {
const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)
const linkDest = resolveRelative(getDisplaySlug(fileData), `tags/${tag}` as FullSlug)
return (
<li>
<a href={linkDest} class="internal tag-link">

View File

@ -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<TagContentOptions>) => {
: htmlToJsx(contentPage.filePath!, root)
const tagListingPage = `/tags/${tag}` as FullSlug
const href = resolveRelative(fileData.slug!, tagListingPage)
const href = resolveRelative(getDisplaySlug(fileData), tagListingPage)
return (
<div>

View File

@ -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

View File

@ -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 = (

View File

@ -11,6 +11,7 @@ import { i18n } from "../../i18n"
export type ContentIndexMap = Map<FullSlug, ContentDetails>
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 && `<lastmod>${content.date.toISOString()}</lastmod>`}
</url>`
const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
.map(([_, content]) =>
createURLEntry(simplifySlug(content.displaySlug ?? content.slug), content),
)
.join("")
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
}
@ -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<Partial<Options>> = (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 ?? [],

View File

@ -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",
})
}

View File

@ -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<Partial<SocialImageOptions>> =
}
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

View File

@ -87,12 +87,21 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (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<Partial<Options>> = (userOpts)
declare module "vfile" {
interface DataMap {
aliases: FullSlug[]
permalinkSlug?: FullSlug
frontmatter: { [key: string]: unknown } & {
title: string
} & Partial<{

View File

@ -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<Partial<Options>> = (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<Partial<Options>> = (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

View File

@ -181,6 +181,7 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
argv: ctx.argv,
allSlugs: ctx.allSlugs,
allFiles: ctx.allFiles,
slugToPermalink: ctx.slugToPermalink,
incremental: ctx.incremental,
}

View File

@ -27,6 +27,7 @@ export interface BuildCtx {
cfg: QuartzConfig
allSlugs: FullSlug[]
allFiles: FilePath[]
slugToPermalink: Record<FullSlug, FullSlug>
trie?: FileTrieNode<BuildTimeTrieData>
incremental: boolean
}

View File

@ -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")) {