This commit is contained in:
Amarjeet Singh Rai 2025-12-14 10:31:14 +00:00 committed by GitHub
commit 9030bbf81f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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. 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" ```md title="foo.md"
--- ---
title: "Foo" title: "Foo"
alias: alias:
- "bar" - "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: The emitter supports the following aliases:

View File

@ -49,6 +49,7 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
cfg, cfg,
allSlugs: [], allSlugs: [],
allFiles: [], allFiles: [],
slugToPermalink: {},
incremental: false, incremental: false,
} }
@ -84,6 +85,13 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const parsedFiles = await parseMarkdown(ctx, filePaths) const parsedFiles = await parseMarkdown(ctx, filePaths)
const filteredContent = filterContent(ctx, parsedFiles) 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) await emitContent(ctx, filteredContent)
console.log( console.log(
styleText("green", `Done processing ${markdownPaths.length} files in ${perf.timeSince()}`), 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), .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 let emittedFiles = 0
for (const emitter of cfg.plugins.emitters) { for (const emitter of cfg.plugins.emitters) {
// Try to use partialEmit if available, otherwise assume the output is static // 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 { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/backlinks.scss" import style from "./styles/backlinks.scss"
import { resolveRelative, simplifySlug } from "../util/path" import { resolveRelative, simplifySlug, getDisplaySlug } from "../util/path"
import { i18n } from "../i18n" import { i18n } from "../i18n"
import { classNames } from "../util/lang" import { classNames } from "../util/lang"
import OverflowListFactory from "./OverflowList" import OverflowListFactory from "./OverflowList"
@ -35,7 +35,10 @@ export default ((opts?: Partial<BacklinksOptions>) => {
{backlinkFiles.length > 0 ? ( {backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => ( backlinkFiles.map((f) => (
<li> <li>
<a href={resolveRelative(fileData.slug!, f.slug!)} class="internal"> <a
href={resolveRelative(getDisplaySlug(fileData), getDisplaySlug(f))}
class="internal"
>
{f.frontmatter?.title} {f.frontmatter?.title}
</a> </a>
</li> </li>

View File

@ -1,6 +1,6 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import breadcrumbsStyle from "./styles/breadcrumbs.scss" 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 { classNames } from "../util/lang"
import { trieFromAllFiles } from "../util/ctx" import { trieFromAllFiles } from "../util/ctx"
@ -59,7 +59,7 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
} }
const crumbs: CrumbData[] = pathNodes.map((node, idx) => { 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) { if (idx === 0) {
crumb.displayName = options.rootName 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 { QuartzPluginData } from "../plugins/vfile"
import { Date, getDate } from "./Date" import { Date, getDate } from "./Date"
import { QuartzComponent, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentProps } from "./types"
@ -78,7 +78,10 @@ export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort
</p> </p>
<div class="desc"> <div class="desc">
<h3> <h3>
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal"> <a
href={resolveRelative(getDisplaySlug(fileData), getDisplaySlug(page))}
class="internal"
>
{title} {title}
</a> </a>
</h3> </h3>
@ -88,7 +91,7 @@ export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort
<li> <li>
<a <a
class="internal tag-link" class="internal tag-link"
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)} href={resolveRelative(getDisplaySlug(fileData), `tags/${tag}` as FullSlug)}
> >
{tag} {tag}
</a> </a>

View File

@ -1,5 +1,5 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" 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 { QuartzPluginData } from "../plugins/vfile"
import { byDateAndAlphabetical } from "./PageList" import { byDateAndAlphabetical } from "./PageList"
import style from "./styles/recentNotes.scss" import style from "./styles/recentNotes.scss"
@ -48,7 +48,10 @@ export default ((userOpts?: Partial<Options>) => {
<div class="section"> <div class="section">
<div class="desc"> <div class="desc">
<h3> <h3>
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal"> <a
href={resolveRelative(getDisplaySlug(fileData), getDisplaySlug(page))}
class="internal"
>
{title} {title}
</a> </a>
</h3> </h3>
@ -64,7 +67,10 @@ export default ((userOpts?: Partial<Options>) => {
<li> <li>
<a <a
class="internal tag-link" class="internal tag-link"
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)} href={resolveRelative(
getDisplaySlug(fileData),
`tags/${tag}` as FullSlug,
)}
> >
{tag} {tag}
</a> </a>
@ -79,7 +85,7 @@ export default ((userOpts?: Partial<Options>) => {
</ul> </ul>
{opts.linkToMore && remaining > 0 && ( {opts.linkToMore && remaining > 0 && (
<p> <p>
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}> <a href={resolveRelative(getDisplaySlug(fileData), opts.linkToMore)}>
{i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })} {i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })}
</a> </a>
</p> </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 { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang" import { classNames } from "../util/lang"
@ -8,7 +8,7 @@ const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentPro
return ( return (
<ul class={classNames(displayClass, "tags")}> <ul class={classNames(displayClass, "tags")}>
{tags.map((tag) => { {tags.map((tag) => {
const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug) const linkDest = resolveRelative(getDisplaySlug(fileData), `tags/${tag}` as FullSlug)
return ( return (
<li> <li>
<a href={linkDest} class="internal tag-link"> <a href={linkDest} class="internal tag-link">

View File

@ -1,7 +1,13 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import style from "../styles/listPage.scss" import style from "../styles/listPage.scss"
import { PageList, SortFn } from "../PageList" 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 { QuartzPluginData } from "../../plugins/vfile"
import { Root } from "hast" import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx" import { htmlToJsx } from "../../util/jsx"
@ -75,7 +81,7 @@ export default ((opts?: Partial<TagContentOptions>) => {
: htmlToJsx(contentPage.filePath!, root) : htmlToJsx(contentPage.filePath!, root)
const tagListingPage = `/tags/${tag}` as FullSlug const tagListingPage = `/tags/${tag}` as FullSlug
const href = resolveRelative(fileData.slug!, tagListingPage) const href = resolveRelative(getDisplaySlug(fileData), tagListingPage)
return ( return (
<div> <div>

View File

@ -84,11 +84,13 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen
const clone = template.content.cloneNode(true) as DocumentFragment const clone = template.content.cloneNode(true) as DocumentFragment
const li = clone.querySelector("li") as HTMLLIElement const li = clone.querySelector("li") as HTMLLIElement
const a = li.querySelector("a") as HTMLAnchorElement 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.dataset.for = node.slug
a.textContent = node.displayName a.textContent = node.displayName
if (currentSlug === node.slug) { if (currentSlug === linkSlug || currentSlug === node.slug) {
a.classList.add("active") a.classList.add("active")
} }
@ -115,7 +117,9 @@ function createFolderNode(
// Replace button with link for link behavior // Replace button with link for link behavior
const button = titleContainer.querySelector(".folder-button") as HTMLElement const button = titleContainer.querySelector(".folder-button") as HTMLElement
const a = document.createElement("a") 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.dataset.for = folderPath
a.className = "folder-title" a.className = "folder-title"
a.textContent = node.displayName a.textContent = node.displayName

View File

@ -6,7 +6,9 @@ import { VFile } from "vfile"
import path from "path" import path from "path"
async function* processFile(ctx: BuildCtx, file: VFile) { 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 ?? []) { for (const aliasTarget of file.data.aliases ?? []) {
const aliasTargetSlug = ( const aliasTargetSlug = (

View File

@ -11,6 +11,7 @@ import { i18n } from "../../i18n"
export type ContentIndexMap = Map<FullSlug, ContentDetails> export type ContentIndexMap = Map<FullSlug, ContentDetails>
export type ContentDetails = { export type ContentDetails = {
slug: FullSlug slug: FullSlug
displaySlug?: FullSlug
filePath: FilePath filePath: FilePath
title: string title: string
links: SimpleSlug[] links: SimpleSlug[]
@ -46,7 +47,9 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string
${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`} ${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
</url>` </url>`
const urls = Array.from(idx) const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .map(([_, content]) =>
createURLEntry(simplifySlug(content.displaySlug ?? content.slug), content),
)
.join("") .join("")
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>` 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) 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) .slice(0, limit ?? idx.size)
.join("") .join("")
@ -100,11 +105,14 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const linkIndex: ContentIndexMap = new Map() const linkIndex: ContentIndexMap = new Map()
for (const [tree, file] of content) { for (const [tree, file] of content) {
// Always use the original slug for tree structure
const slug = file.data.slug! const slug = file.data.slug!
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
linkIndex.set(slug, { linkIndex.set(slug, {
slug, slug,
// Add displaySlug if permalink is set
displaySlug: file.data.permalinkSlug,
filePath: file.data.relativePath!, filePath: file.data.relativePath!,
title: file.data.frontmatter?.title!, title: file.data.frontmatter?.title!,
links: file.data.links ?? [], links: file.data.links ?? [],

View File

@ -24,8 +24,10 @@ async function processContent(
resources: StaticResources, resources: StaticResources,
) { ) {
const slug = fileData.slug! const slug = fileData.slug!
// Use permalinkSlug if set, otherwise use the regular slug
const renderSlug = fileData.permalinkSlug ?? slug
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const externalResources = pageResources(pathToRoot(slug), resources) const externalResources = pageResources(pathToRoot(renderSlug), resources)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
ctx, ctx,
fileData, fileData,
@ -36,11 +38,11 @@ async function processContent(
allFiles, allFiles,
} }
const content = renderPage(cfg, slug, componentData, opts, externalResources) const content = renderPage(cfg, renderSlug, componentData, opts, externalResources)
return write({ return write({
ctx, ctx,
content, content,
slug, slug: renderSlug,
ext: ".html", ext: ".html",
}) })
} }

View File

@ -72,7 +72,7 @@ async function processOgImage(
fullOptions: SocialImageOptions, fullOptions: SocialImageOptions,
) { ) {
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const slug = fileData.slug! const slug = fileData.permalinkSlug ?? fileData.slug!
const titleSuffix = cfg.pageTitleSuffix ?? "" const titleSuffix = cfg.pageTitleSuffix ?? ""
const title = const title =
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix (fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
@ -154,7 +154,7 @@ export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> =
} }
const generatedOgImagePath = isRealFile const generatedOgImagePath = isRealFile
? `https://${baseUrl}/${pageData.slug!}-og-image.webp` ? `https://${baseUrl}/${pageData.permalinkSlug ?? pageData.slug!}-og-image.webp`
: undefined : undefined
const defaultOgImagePath = `https://${baseUrl}/static/og-image.png` const defaultOgImagePath = `https://${baseUrl}/static/og-image.png`
const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath

View File

@ -87,12 +87,21 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
allSlugs.push(...file.data.aliases) 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() !== "") { if (data.permalink != null && data.permalink.toString() !== "") {
data.permalink = data.permalink.toString() as FullSlug data.permalink = data.permalink.toString() as FullSlug
const aliases = file.data.aliases ?? [] const originalSlug = file.data.slug
aliases.push(data.permalink)
file.data.aliases = aliases // Set the permalinkSlug - this is where the page will actually be rendered
file.data.permalinkSlug = data.permalink
allSlugs.push(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"])) const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
@ -135,6 +144,7 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
declare module "vfile" { declare module "vfile" {
interface DataMap { interface DataMap {
aliases: FullSlug[] aliases: FullSlug[]
permalinkSlug?: FullSlug
frontmatter: { [key: string]: unknown } & { frontmatter: { [key: string]: unknown } & {
title: string title: string
} & Partial<{ } & Partial<{

View File

@ -8,6 +8,7 @@ import {
simplifySlug, simplifySlug,
splitAnchor, splitAnchor,
transformLink, transformLink,
resolveRelative,
} from "../../util/path" } from "../../util/path"
import path from "path" import path from "path"
import { visit } from "unist-util-visit" 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 // WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
const url = new URL(dest, "https://base.com/" + stripSlashes(curSlug, true)) const url = new URL(dest, "https://base.com/" + stripSlashes(curSlug, true))
const canonicalDest = url.pathname const canonicalDest = url.pathname
let [destCanonical, _destAnchor] = splitAnchor(canonicalDest) let [destCanonical, destAnchor] = splitAnchor(canonicalDest)
if (destCanonical.endsWith("/")) { if (destCanonical.endsWith("/")) {
destCanonical += "index" destCanonical += "index"
} }
@ -123,6 +124,14 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
const simple = simplifySlug(full) const simple = simplifySlug(full)
outgoing.add(simple) outgoing.add(simple)
node.properties["data-slug"] = full 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 // 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, argv: ctx.argv,
allSlugs: ctx.allSlugs, allSlugs: ctx.allSlugs,
allFiles: ctx.allFiles, allFiles: ctx.allFiles,
slugToPermalink: ctx.slugToPermalink,
incremental: ctx.incremental, incremental: ctx.incremental,
} }

View File

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

View File

@ -173,6 +173,11 @@ export function resolveRelative(current: FullSlug, target: FullSlug | SimpleSlug
return res 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] { export function splitAnchor(link: string): [string, string] {
let [fp, anchor] = link.split("#", 2) let [fp, anchor] = link.split("#", 2)
if (fp.endsWith(".pdf")) { if (fp.endsWith(".pdf")) {