mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-19 10:54:06 -06:00
fix: correct permalink and alias behavior to match Obsidian
This commit is contained in:
parent
bacd19c4ea
commit
84d6484350
@ -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:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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 ?? [],
|
||||
|
||||
@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ export interface BuildCtx {
|
||||
cfg: QuartzConfig
|
||||
allSlugs: FullSlug[]
|
||||
allFiles: FilePath[]
|
||||
slugToPermalink: Record<FullSlug, FullSlug>
|
||||
trie?: FileTrieNode<BuildTimeTrieData>
|
||||
incremental: boolean
|
||||
}
|
||||
|
||||
@ -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")) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user