From c874e7e9378a5ba895870e9680484fb4af5c6e93 Mon Sep 17 00:00:00 2001 From: Jacky Zhao <j.zhao2k19@gmail.com> Date: Sat, 19 Aug 2023 15:52:25 -0700 Subject: [PATCH] base path refactor to better support subpath hosting --- content/advanced/paths.md | 32 ++-- content/hosting.md | 2 +- index.d.ts | 2 +- package.json | 2 +- quartz/components/Backlinks.tsx | 6 +- quartz/components/Head.tsx | 5 +- quartz/components/PageList.tsx | 8 +- quartz/components/PageTitle.tsx | 5 +- quartz/components/TagList.tsx | 5 +- quartz/components/pages/FolderContent.tsx | 6 +- quartz/components/pages/TagContent.tsx | 4 +- quartz/components/renderPage.tsx | 19 +- quartz/components/scripts/graph.inline.ts | 34 ++-- quartz/components/scripts/search.inline.ts | 10 +- quartz/components/scripts/spa.inline.ts | 8 +- quartz/plugins/emitters/aliases.ts | 17 +- quartz/plugins/emitters/componentResources.ts | 8 +- quartz/plugins/emitters/contentIndex.ts | 30 ++- quartz/plugins/emitters/contentPage.tsx | 6 +- quartz/plugins/emitters/folderPage.tsx | 20 +- quartz/plugins/emitters/tagPage.tsx | 12 +- quartz/plugins/index.ts | 4 +- quartz/plugins/transformers/links.ts | 26 ++- quartz/plugins/transformers/ofm.ts | 5 +- quartz/plugins/types.ts | 4 +- quartz/util/ctx.ts | 4 +- quartz/util/path.test.ts | 179 +++++++----------- quartz/util/path.ts | 179 ++++++------------ quartz/worker.ts | 4 +- 29 files changed, 257 insertions(+), 389 deletions(-) diff --git a/content/advanced/paths.md b/content/advanced/paths.md index 68fc181..9455b98 100644 --- a/content/advanced/paths.md +++ b/content/advanced/paths.md @@ -4,7 +4,7 @@ title: Paths in Quartz Paths are pretty complex to reason about because, especially for a static site generator, they can come from so many places. -The current browser URL? Technically a path. A full file path to a piece of content? Also a path. What about a slug for a piece of content? Yet another path. +A full file path to a piece of content? Also a path. What about a slug for a piece of content? Yet another path. It would be silly to type these all as `string` and call it a day as it's pretty common to accidentally mistake one type of path for another. Unfortunately, TypeScript does not have [nominal types](https://en.wikipedia.org/wiki/Nominal_type_system) for type aliases meaning even if you made custom types of a server-side slug or a client-slug slug, you can still accidentally assign one to another and TypeScript wouldn't catch it. @@ -12,13 +12,13 @@ Luckily, we can mimic nominal typing using [brands](https://www.typescriptlang.o ```typescript // instead of -type ClientSlug = string +type FullSlug = string // we do -type ClientSlug = string & { __brand: "client" } +type FullSlug = string & { __brand: "full" } // that way, the following will fail typechecking -const slug: ClientSlug = "some random slug" +const slug: FullSlug = "some random string" ``` While this prevents most typing mistakes _within_ our nominal typing system (e.g. mistaking a server slug for a client slug), it doesn't prevent us from _accidentally_ mistaking a string for a client slug when we forcibly cast it. @@ -29,27 +29,23 @@ The following diagram draws the relationships between all the path sources, nomi ```mermaid graph LR - Browser{{Browser}} --> Window{{Window}} & LinkElement{{Link Element}} - Window --"getCanonicalSlug()"--> Canonical[Canonical Slug] - Window --"getClientSlug()"--> Client[Client Slug] + Browser{{Browser}} --> Window{{Body}} & LinkElement{{Link Element}} + Window --"getFullSlug()"--> FullSlug[Full Slug] LinkElement --".href"--> Relative[Relative URL] - Client --"canonicalizeClient()"--> Canonical - Canonical --"pathToRoot()"--> Relative - Canonical --"resolveRelative()" --> Relative + FullSlug --"simplifySlug()" --> SimpleSlug[Simple Slug] + SimpleSlug --"pathToRoot()"--> Relative + SimpleSlug --"resolveRelative()" --> Relative MD{{Markdown File}} --> FilePath{{File Path}} & Links[Markdown links] Links --"transformLink()"--> Relative - FilePath --"slugifyFilePath()"--> Server[Server Slug] - Server --> HTML["HTML File"] - Server --"canonicalizeServer()"--> Canonical - style Canonical stroke-width:4px + FilePath --"slugifyFilePath()"--> FullSlug[Full Slug] + style FullSlug stroke-width:4px ``` Here are the main types of slugs with a rough description of each type of path: -- `ClientSlug`: client-side slug, usually obtained through `window.location`. Contains the protocol (i.e. starts with `https://`) -- `CanonicalSlug`: should be used whenever you need to refer to the location of a file/note. Shouldn't be a relative path and shouldn't have leading or trailing slashes `/` either. Also shouldn't have `/index` as an ending or a file extension. -- `RelativeURL`: must start with `.` or `..` to indicate it's a relative URL. Shouldn't have `/index` as an ending or a file extension. -- `ServerSlug`: cannot be relative and may not have leading or trailing slashes. - `FilePath`: a real file path to a file on disk. Cannot be relative and must have a file extension. +- `FullSlug`: cannot be relative and may not have leading or trailing slashes. It can have `index` as it's last segment. Use this wherever possible is it's the most 'general' interpretation of a slug. +- `SimpleSlug`: cannot be relative and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. +- `RelativeURL`: must start with `.` or `..` to indicate it's a relative URL. Shouldn't have `/index` as an ending or a file extension but can contain a trailing slash. To get a clearer picture of how these relate to each other, take a look at the path tests in `quartz/path.test.ts`. diff --git a/content/hosting.md b/content/hosting.md index b247627..d6ccd0b 100644 --- a/content/hosting.md +++ b/content/hosting.md @@ -17,7 +17,7 @@ However, if you'd like to publish your site to the world, you need a way to host | Configuration option | Value | | ---------------------- | ------------------ | -| Production branch | `v4` | +| Production branch | `v4` | | Framework preset | `None` | | Build command | `npx quartz build` | | Build output directory | `public` | diff --git a/index.d.ts b/index.d.ts index 4a93f16..aec536d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,7 +5,7 @@ declare module "*.scss" { // dom custom event interface CustomEventMap { - nav: CustomEvent<{ url: CanonicalSlug }> + nav: CustomEvent<{ url: FullSlug }> } declare const fetchData: Promise<ContentIndex> diff --git a/package.json b/package.json index 17e46b0..08ab2d6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@jackyzha0/quartz", "description": "🌱 publish your digital garden and notes as a website", "private": true, - "version": "4.0.7", + "version": "4.0.8", "type": "module", "author": "jackyzha0 <j.zhao2k19@gmail.com>", "license": "MIT", diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx index 8cf3afa..e88966b 100644 --- a/quartz/components/Backlinks.tsx +++ b/quartz/components/Backlinks.tsx @@ -1,9 +1,9 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import style from "./styles/backlinks.scss" -import { canonicalizeServer, resolveRelative } from "../util/path" +import { resolveRelative, simplifySlug } from "../util/path" function Backlinks({ fileData, allFiles }: QuartzComponentProps) { - const slug = canonicalizeServer(fileData.slug!) + const slug = simplifySlug(fileData.slug!) const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug)) return ( <div class="backlinks"> @@ -12,7 +12,7 @@ function Backlinks({ fileData, allFiles }: QuartzComponentProps) { {backlinkFiles.length > 0 ? ( backlinkFiles.map((f) => ( <li> - <a href={resolveRelative(slug, canonicalizeServer(f.slug!))} class="internal"> + <a href={resolveRelative(fileData.slug!, f.slug!)} class="internal"> {f.frontmatter?.title} </a> </li> diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index bfeb3f1..5221c29 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -1,14 +1,13 @@ -import { canonicalizeServer, pathToRoot } from "../util/path" +import { pathToRoot } from "../util/path" import { JSResourceToScriptElement } from "../util/resources" import { QuartzComponentConstructor, QuartzComponentProps } from "./types" export default (() => { function Head({ cfg, fileData, externalResources }: QuartzComponentProps) { - const slug = canonicalizeServer(fileData.slug!) const title = fileData.frontmatter?.title ?? "Untitled" const description = fileData.description?.trim() ?? "No description provided" const { css, js } = externalResources - const baseDir = pathToRoot(slug) + const baseDir = pathToRoot(fileData.slug!) const iconPath = baseDir + "/static/icon.png" const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png` diff --git a/quartz/components/PageList.tsx b/quartz/components/PageList.tsx index 83b1b07..63513f5 100644 --- a/quartz/components/PageList.tsx +++ b/quartz/components/PageList.tsx @@ -1,4 +1,4 @@ -import { CanonicalSlug, canonicalizeServer, resolveRelative } from "../util/path" +import { FullSlug, resolveRelative } from "../util/path" import { QuartzPluginData } from "../plugins/vfile" import { Date } from "./Date" import { QuartzComponentProps } from "./types" @@ -25,7 +25,6 @@ type Props = { } & QuartzComponentProps export function PageList({ fileData, allFiles, limit }: Props) { - const slug = canonicalizeServer(fileData.slug!) let list = allFiles.sort(byDateAndAlphabetical) if (limit) { list = list.slice(0, limit) @@ -35,7 +34,6 @@ export function PageList({ fileData, allFiles, limit }: Props) { <ul class="section-ul"> {list.map((page) => { const title = page.frontmatter?.title - const pageSlug = canonicalizeServer(page.slug!) const tags = page.frontmatter?.tags ?? [] return ( @@ -48,7 +46,7 @@ export function PageList({ fileData, allFiles, limit }: Props) { )} <div class="desc"> <h3> - <a href={resolveRelative(slug, pageSlug)} class="internal"> + <a href={resolveRelative(fileData.slug!, page.slug!)} class="internal"> {title} </a> </h3> @@ -58,7 +56,7 @@ export function PageList({ fileData, allFiles, limit }: Props) { <li> <a class="internal tag-link" - href={resolveRelative(slug, `tags/${tag}` as CanonicalSlug)} + href={resolveRelative(fileData.slug!, `tags/${tag}/index` as FullSlug)} > #{tag} </a> diff --git a/quartz/components/PageTitle.tsx b/quartz/components/PageTitle.tsx index c1e74f3..c327547 100644 --- a/quartz/components/PageTitle.tsx +++ b/quartz/components/PageTitle.tsx @@ -1,10 +1,9 @@ -import { canonicalizeServer, pathToRoot } from "../util/path" +import { pathToRoot } from "../util/path" import { QuartzComponentConstructor, QuartzComponentProps } from "./types" function PageTitle({ fileData, cfg }: QuartzComponentProps) { const title = cfg?.pageTitle ?? "Untitled Quartz" - const slug = canonicalizeServer(fileData.slug!) - const baseDir = pathToRoot(slug) + const baseDir = pathToRoot(fileData.slug!) return ( <h1 class="page-title"> <a href={baseDir}>{title}</a> diff --git a/quartz/components/TagList.tsx b/quartz/components/TagList.tsx index bf5badd..639f68c 100644 --- a/quartz/components/TagList.tsx +++ b/quartz/components/TagList.tsx @@ -1,10 +1,9 @@ -import { canonicalizeServer, pathToRoot, slugTag } from "../util/path" +import { pathToRoot, slugTag } from "../util/path" import { QuartzComponentConstructor, QuartzComponentProps } from "./types" function TagList({ fileData }: QuartzComponentProps) { const tags = fileData.frontmatter?.tags - const slug = canonicalizeServer(fileData.slug!) - const baseDir = pathToRoot(slug) + const baseDir = pathToRoot(fileData.slug!) if (tags && tags.length > 0) { return ( <ul class="tags"> diff --git a/quartz/components/pages/FolderContent.tsx b/quartz/components/pages/FolderContent.tsx index 6037930..478eaa7 100644 --- a/quartz/components/pages/FolderContent.tsx +++ b/quartz/components/pages/FolderContent.tsx @@ -5,13 +5,13 @@ import path from "path" import style from "../styles/listPage.scss" import { PageList } from "../PageList" -import { canonicalizeServer } from "../../util/path" +import { simplifySlug } from "../../util/path" function FolderContent(props: QuartzComponentProps) { const { tree, fileData, allFiles } = props - const folderSlug = canonicalizeServer(fileData.slug!) + const folderSlug = simplifySlug(fileData.slug!) const allPagesInFolder = allFiles.filter((file) => { - const fileSlug = canonicalizeServer(file.slug!) + const fileSlug = simplifySlug(file.slug!) const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug const folderParts = folderSlug.split(path.posix.sep) const fileParts = fileSlug.split(path.posix.sep) diff --git a/quartz/components/pages/TagContent.tsx b/quartz/components/pages/TagContent.tsx index 2cae40d..7b3a582 100644 --- a/quartz/components/pages/TagContent.tsx +++ b/quartz/components/pages/TagContent.tsx @@ -3,7 +3,7 @@ import { Fragment, jsx, jsxs } from "preact/jsx-runtime" import { toJsxRuntime } from "hast-util-to-jsx-runtime" import style from "../styles/listPage.scss" import { PageList } from "../PageList" -import { ServerSlug, canonicalizeServer, getAllSegmentPrefixes } from "../../util/path" +import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path" import { QuartzPluginData } from "../../plugins/vfile" const numPages = 10 @@ -15,7 +15,7 @@ function TagContent(props: QuartzComponentProps) { throw new Error(`Component "TagContent" tried to render a non-tag page: ${slug}`) } - const tag = canonicalizeServer(slug.slice("tags/".length) as ServerSlug) + const tag = simplifySlug(slug.slice("tags/".length) as FullSlug) const allPagesWithTag = (tag: string) => allFiles.filter((file) => (file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(tag), diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 171c414..a1d5fee 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -3,7 +3,7 @@ import { QuartzComponent, QuartzComponentProps } from "./types" import HeaderConstructor from "./Header" import BodyConstructor from "./Body" import { JSResourceToScriptElement, StaticResources } from "../util/resources" -import { CanonicalSlug, pathToRoot } from "../util/path" +import { FullSlug, joinSegments, pathToRoot } from "../util/path" interface RenderComponents { head: QuartzComponent @@ -15,19 +15,20 @@ interface RenderComponents { footer: QuartzComponent } -export function pageResources( - slug: CanonicalSlug, - staticResources: StaticResources, -): StaticResources { +export function pageResources(slug: FullSlug, staticResources: StaticResources): StaticResources { const baseDir = pathToRoot(slug) - const contentIndexPath = baseDir + "/static/contentIndex.json" + const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())` return { - css: [baseDir + "/index.css", ...staticResources.css], + css: [joinSegments(baseDir, "index.css"), ...staticResources.css], js: [ - { src: baseDir + "/prescript.js", loadTime: "beforeDOMReady", contentType: "external" }, + { + src: joinSegments(baseDir, "/prescript.js"), + loadTime: "beforeDOMReady", + contentType: "external", + }, { loadTime: "beforeDOMReady", contentType: "inline", @@ -46,7 +47,7 @@ export function pageResources( } export function renderPage( - slug: CanonicalSlug, + slug: FullSlug, componentData: QuartzComponentProps, components: RenderComponents, pageResources: StaticResources, diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index bea32eb..e84480e 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -1,31 +1,32 @@ import type { ContentDetails } from "../../plugins/emitters/contentIndex" import * as d3 from "d3" import { registerEscapeHandler, removeAllChildren } from "./util" -import { CanonicalSlug, getCanonicalSlug, getClientSlug, resolveRelative } from "../../util/path" +import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path" type NodeData = { - id: CanonicalSlug + id: SimpleSlug text: string tags: string[] } & d3.SimulationNodeDatum type LinkData = { - source: CanonicalSlug - target: CanonicalSlug + source: SimpleSlug + target: SimpleSlug } const localStorageKey = "graph-visited" -function getVisited(): Set<CanonicalSlug> { +function getVisited(): Set<SimpleSlug> { return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]")) } -function addToVisited(slug: CanonicalSlug) { +function addToVisited(slug: SimpleSlug) { const visited = getVisited() visited.add(slug) localStorage.setItem(localStorageKey, JSON.stringify([...visited])) } -async function renderGraph(container: string, slug: CanonicalSlug) { +async function renderGraph(container: string, fullSlug: FullSlug) { + const slug = simplifySlug(fullSlug) const visited = getVisited() const graph = document.getElementById(container) if (!graph) return @@ -47,16 +48,17 @@ async function renderGraph(container: string, slug: CanonicalSlug) { const links: LinkData[] = [] for (const [src, details] of Object.entries<ContentDetails>(data)) { + const source = simplifySlug(src as FullSlug) const outgoing = details.links ?? [] for (const dest of outgoing) { - if (src in data && dest in data) { - links.push({ source: src as CanonicalSlug, target: dest }) + if (dest in data) { + links.push({ source, target: dest }) } } } - const neighbourhood = new Set<CanonicalSlug>() - const wl: (CanonicalSlug | "__SENTINEL")[] = [slug, "__SENTINEL"] + const neighbourhood = new Set<SimpleSlug>() + const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"] if (depth >= 0) { while (depth >= 0 && wl.length > 0) { // compute neighbours @@ -72,7 +74,7 @@ async function renderGraph(container: string, slug: CanonicalSlug) { } } } else { - Object.keys(data).forEach((id) => neighbourhood.add(id as CanonicalSlug)) + Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug))) } const graphData: { nodes: NodeData[]; links: LinkData[] } = { @@ -171,11 +173,11 @@ async function renderGraph(container: string, slug: CanonicalSlug) { .attr("fill", color) .style("cursor", "pointer") .on("click", (_, d) => { - const targ = resolveRelative(slug, d.id) - window.spaNavigate(new URL(targ, getClientSlug(window))) + const targ = resolveRelative(fullSlug, d.id) + window.spaNavigate(new URL(targ, window.location.toString())) }) .on("mouseover", function (_, d) { - const neighbours: CanonicalSlug[] = data[slug].links ?? [] + const neighbours: SimpleSlug[] = data[slug].links ?? [] const neighbourNodes = d3 .selectAll<HTMLElement, NodeData>(".node") .filter((d) => neighbours.includes(d.id)) @@ -271,7 +273,7 @@ async function renderGraph(container: string, slug: CanonicalSlug) { } function renderGlobalGraph() { - const slug = getCanonicalSlug(window) + const slug = getFullSlug(window) const container = document.getElementById("global-graph-outer") const sidebar = container?.closest(".sidebar") as HTMLElement container?.classList.add("active") diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index b94cdfb..038566c 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -1,11 +1,11 @@ import { Document } from "flexsearch" import { ContentDetails } from "../../plugins/emitters/contentIndex" import { registerEscapeHandler, removeAllChildren } from "./util" -import { CanonicalSlug, getClientSlug, resolveRelative } from "../../util/path" +import { FullSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path" interface Item { id: number - slug: CanonicalSlug + slug: FullSlug title: string content: string } @@ -73,7 +73,7 @@ document.addEventListener("nav", async (e: unknown) => { const searchIcon = document.getElementById("search-icon") const searchBar = document.getElementById("search-bar") as HTMLInputElement | null const results = document.getElementById("results-container") - const idDataMap = Object.keys(data) as CanonicalSlug[] + const idDataMap = Object.keys(data) as FullSlug[] function hideSearch() { container?.classList.remove("active") @@ -126,7 +126,7 @@ document.addEventListener("nav", async (e: unknown) => { button.innerHTML = `<h3>${title}</h3><p>${content}</p>` button.addEventListener("click", () => { const targ = resolveRelative(currentSlug, slug) - window.spaNavigate(new URL(targ, getClientSlug(window))) + window.spaNavigate(new URL(targ, window.location.toString())) }) return button } @@ -192,7 +192,7 @@ document.addEventListener("nav", async (e: unknown) => { for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { await index.addAsync(id, { id, - slug: slug as CanonicalSlug, + slug: slug as FullSlug, title: fileData.title, content: fileData.content, }) diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index e0a8373..0a57a9d 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -1,5 +1,5 @@ import micromorph from "micromorph" -import { CanonicalSlug, RelativeURL, getCanonicalSlug } from "../../util/path" +import { FullSlug, RelativeURL, getFullSlug } from "../../util/path" // adapted from `micromorph` // https://github.com/natemoo-re/micromorph @@ -31,7 +31,7 @@ const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined } } -function notifyNav(url: CanonicalSlug) { +function notifyNav(url: FullSlug) { const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } }) document.dispatchEvent(event) } @@ -81,7 +81,7 @@ async function navigate(url: URL, isBack: boolean = false) { const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])") elementsToAdd.forEach((el) => document.head.appendChild(el)) - notifyNav(getCanonicalSlug(window)) + notifyNav(getFullSlug(window)) delete announcer.dataset.persist } @@ -129,7 +129,7 @@ function createRouter() { } createRouter() -notifyNav(getCanonicalSlug(window)) +notifyNav(getFullSlug(window)) if (!customElements.get("route-announcer")) { const attrs = { diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts index 1fbea76..62879f7 100644 --- a/quartz/plugins/emitters/aliases.ts +++ b/quartz/plugins/emitters/aliases.ts @@ -1,10 +1,4 @@ -import { - CanonicalSlug, - FilePath, - ServerSlug, - canonicalizeServer, - resolveRelative, -} from "../../util/path" +import { FilePath, FullSlug, resolveRelative, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import path from "path" @@ -17,10 +11,10 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({ const fps: FilePath[] = [] for (const [_tree, file] of content) { - const ogSlug = canonicalizeServer(file.data.slug!) + const ogSlug = simplifySlug(file.data.slug!) const dir = path.posix.relative(argv.directory, file.dirname ?? argv.directory) - let aliases: CanonicalSlug[] = [] + let aliases: FullSlug[] = [] if (file.data.frontmatter?.aliases) { aliases = file.data.frontmatter?.aliases } else if (file.data.frontmatter?.alias) { @@ -28,9 +22,8 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({ } for (const alias of aliases) { - const slug = path.posix.join(dir, alias) as ServerSlug - - const redirUrl = resolveRelative(canonicalizeServer(slug), ogSlug) + const slug = path.posix.join(dir, alias) as FullSlug + const redirUrl = resolveRelative(slug, file.data.slug!) const fp = await emit({ content: ` <!DOCTYPE html> diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 19eddc6..e0bc83d 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -1,4 +1,4 @@ -import { FilePath, ServerSlug } from "../../util/path" +import { FilePath, FullSlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" // @ts-ignore @@ -154,7 +154,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial< const postscript = joinScripts(componentResources.afterDOMLoaded) const fps = await Promise.all([ emit({ - slug: "index" as ServerSlug, + slug: "index" as FullSlug, ext: ".css", content: transform({ filename: "index.css", @@ -171,12 +171,12 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial< }).code.toString(), }), emit({ - slug: "prescript" as ServerSlug, + slug: "prescript" as FullSlug, ext: ".js", content: prescript, }), emit({ - slug: "postscript" as ServerSlug, + slug: "postscript" as FullSlug, ext: ".js", content: postscript, }), diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 85cdfe7..f4bf6db 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -1,18 +1,12 @@ import { GlobalConfiguration } from "../../cfg" -import { - CanonicalSlug, - ClientSlug, - FilePath, - ServerSlug, - canonicalizeServer, -} from "../../util/path" +import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import path from "path" -export type ContentIndex = Map<CanonicalSlug, ContentDetails> +export type ContentIndex = Map<FullSlug, ContentDetails> export type ContentDetails = { title: string - links: CanonicalSlug[] + links: SimpleSlug[] tags: string[] content: string date?: Date @@ -33,21 +27,21 @@ const defaultOptions: Options = { function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" - const createURLEntry = (slug: CanonicalSlug, content: ContentDetails): string => `<url> + const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url> <loc>https://${base}/${slug}</loc> <lastmod>${content.date?.toISOString()}</lastmod> </url>` const urls = Array.from(idx) - .map(([slug, content]) => createURLEntry(slug, content)) + .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .join("") return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>` } function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" - const root = `https://${base}` as ClientSlug + const root = `https://${base}` - const createURLEntry = (slug: CanonicalSlug, content: ContentDetails): string => `<items> + const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<items> <title>${content.title}</title> <link>${root}/${slug}</link> <guid>${root}/${slug}</guid> @@ -56,7 +50,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { </items>` const items = Array.from(idx) - .map(([slug, content]) => createURLEntry(slug, content)) + .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .join("") return `<rss xmlns:atom="http://www.w3.org/2005/atom" version="2.0"> <channel> @@ -79,7 +73,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => { const emitted: FilePath[] = [] const linkIndex: ContentIndex = new Map() for (const [_tree, file] of content) { - const slug = canonicalizeServer(file.data.slug!) + const slug = file.data.slug! const date = file.data.dates?.modified ?? new Date() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { linkIndex.set(slug, { @@ -97,7 +91,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => { emitted.push( await emit({ content: generateSiteMap(cfg, linkIndex), - slug: "sitemap" as ServerSlug, + slug: "sitemap" as FullSlug, ext: ".xml", }), ) @@ -107,13 +101,13 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => { emitted.push( await emit({ content: generateRSSFeed(cfg, linkIndex), - slug: "index" as ServerSlug, + slug: "index" as FullSlug, ext: ".xml", }), ) } - const fp = path.join("static", "contentIndex") as ServerSlug + const fp = path.join("static", "contentIndex") as FullSlug const simplifiedIndex = Object.fromEntries( Array.from(linkIndex).map(([slug, content]) => { // remove description and from content index as nothing downstream diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index b7e347a..0e510db 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -4,7 +4,7 @@ import HeaderConstructor from "../../components/Header" import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { FullPageLayout } from "../../cfg" -import { FilePath, canonicalizeServer } from "../../util/path" +import { FilePath } from "../../util/path" import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" import { Content } from "../../components" @@ -30,7 +30,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp const fps: FilePath[] = [] const allFiles = content.map((c) => c[1].data) for (const [tree, file] of content) { - const slug = canonicalizeServer(file.data.slug!) + const slug = file.data.slug! const externalResources = pageResources(slug, resources) const componentData: QuartzComponentProps = { fileData: file.data, @@ -44,7 +44,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp const content = renderPage(slug, componentData, opts, externalResources) const fp = await emit({ content, - slug: file.data.slug!, + slug, ext: ".html", }) diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx index 724717a..4d22556 100644 --- a/quartz/plugins/emitters/folderPage.tsx +++ b/quartz/plugins/emitters/folderPage.tsx @@ -6,13 +6,7 @@ import { pageResources, renderPage } from "../../components/renderPage" import { ProcessedContent, defaultProcessedContent } from "../vfile" import { FullPageLayout } from "../../cfg" import path from "path" -import { - CanonicalSlug, - FilePath, - ServerSlug, - canonicalizeServer, - joinSegments, -} from "../../util/path" +import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { FolderContent } from "../../components" @@ -38,10 +32,10 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => { const allFiles = content.map((c) => c[1].data) const cfg = ctx.cfg.configuration - const folders: Set<CanonicalSlug> = new Set( + const folders: Set<SimpleSlug> = new Set( allFiles.flatMap((data) => { const slug = data.slug - const folderName = path.dirname(slug ?? "") as CanonicalSlug + const folderName = path.dirname(slug ?? "") as SimpleSlug if (slug && folderName !== "." && folderName !== "tags") { return [folderName] } @@ -53,21 +47,21 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => { [...folders].map((folder) => [ folder, defaultProcessedContent({ - slug: joinSegments(folder, "index") as ServerSlug, + slug: joinSegments(folder, "index") as FullSlug, frontmatter: { title: `Folder: ${folder}`, tags: [] }, }), ]), ) for (const [tree, file] of content) { - const slug = canonicalizeServer(file.data.slug!) + const slug = simplifySlug(file.data.slug!) if (folders.has(slug)) { folderDescriptions[slug] = [tree, file] } } for (const folder of folders) { - const slug = folder + const slug = joinSegments(folder, "index") as FullSlug const externalResources = pageResources(slug, resources) const [tree, file] = folderDescriptions[folder] const componentData: QuartzComponentProps = { @@ -82,7 +76,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => { const content = renderPage(slug, componentData, opts, externalResources) const fp = await emit({ content, - slug: file.data.slug!, + slug, ext: ".html", }) diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx index de07623..54ad934 100644 --- a/quartz/plugins/emitters/tagPage.tsx +++ b/quartz/plugins/emitters/tagPage.tsx @@ -5,13 +5,7 @@ import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { ProcessedContent, defaultProcessedContent } from "../vfile" import { FullPageLayout } from "../../cfg" -import { - CanonicalSlug, - FilePath, - ServerSlug, - getAllSegmentPrefixes, - joinSegments, -} from "../../util/path" +import { FilePath, FullSlug, getAllSegmentPrefixes, joinSegments } from "../../util/path" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { TagContent } from "../../components" @@ -49,7 +43,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => { return [ tag, defaultProcessedContent({ - slug: joinSegments("tags", tag) as ServerSlug, + slug: joinSegments("tags", tag) as FullSlug, frontmatter: { title, tags: [] }, }), ] @@ -67,7 +61,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => { } for (const tag of tags) { - const slug = joinSegments("tags", tag) as CanonicalSlug + const slug = joinSegments("tags", tag) as FullSlug const externalResources = pageResources(slug, resources) const [tree, file] = tagDescriptions[tag] const componentData: QuartzComponentProps = { diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts index c83455e..9753d2e 100644 --- a/quartz/plugins/index.ts +++ b/quartz/plugins/index.ts @@ -1,5 +1,5 @@ import { StaticResources } from "../util/resources" -import { FilePath, ServerSlug } from "../util/path" +import { FilePath, FullSlug } from "../util/path" import { BuildCtx } from "../util/ctx" export function getStaticResourcesFromPlugins(ctx: BuildCtx) { @@ -28,7 +28,7 @@ export * from "./emitters" declare module "vfile" { // inserted in processors.ts interface DataMap { - slug: ServerSlug + slug: FullSlug filePath: FilePath } } diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index d867039..5d34fa3 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -1,11 +1,12 @@ import { QuartzTransformerPlugin } from "../types" import { - CanonicalSlug, + FullSlug, RelativeURL, + SimpleSlug, TransformOptions, _stripSlashes, - canonicalizeServer, joinSegments, + simplifySlug, splitAnchor, transformLink, } from "../../util/path" @@ -33,8 +34,8 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = return [ () => { return (tree, file) => { - const curSlug = canonicalizeServer(file.data.slug!) - const outgoing: Set<CanonicalSlug> = new Set() + const curSlug = simplifySlug(file.data.slug!) + const outgoing: Set<SimpleSlug> = new Set() const transformOptions: TransformOptions = { strategy: opts.markdownLinkResolution, @@ -54,10 +55,15 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = // don't process external links or intra-document anchors if (!(isAbsoluteUrl(dest) || dest.startsWith("#"))) { - dest = node.properties.href = transformLink(curSlug, dest, transformOptions) + dest = node.properties.href = transformLink( + file.data.slug!, + dest, + transformOptions, + ) const canonicalDest = path.posix.normalize(joinSegments(curSlug, dest)) const [destCanonical, _destAnchor] = splitAnchor(canonicalDest) - outgoing.add(destCanonical as CanonicalSlug) + const simple = simplifySlug(destCanonical as FullSlug) + outgoing.add(simple) } // rewrite link internals if prettylinks is on @@ -79,7 +85,11 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = ) { if (!isAbsoluteUrl(node.properties.src)) { let dest = node.properties.src as RelativeURL - dest = node.properties.src = transformLink(curSlug, dest, transformOptions) + dest = node.properties.src = transformLink( + file.data.slug!, + dest, + transformOptions, + ) node.properties.src = dest } } @@ -95,6 +105,6 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = declare module "vfile" { interface DataMap { - links: CanonicalSlug[] + links: SimpleSlug[] } } diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index b324dac..b66ba85 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -9,7 +9,7 @@ import path from "path" import { JSResource } from "../../util/resources" // @ts-ignore import calloutScript from "../../components/scripts/callout.inline.ts" -import { FilePath, canonicalizeServer, pathToRoot, slugTag, slugifyFilePath } from "../../util/path" +import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path" import { toHast } from "mdast-util-to-hast" import { toHtml } from "hast-util-to-html" import { PhrasingContent } from "mdast-util-find-and-replace/lib" @@ -381,8 +381,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> if (opts.parseTags) { plugins.push(() => { return (tree: Root, file) => { - const slug = canonicalizeServer(file.data.slug!) - const base = pathToRoot(slug) + const base = pathToRoot(file.data.slug!) findAndReplace(tree, tagRegex, (value: string, tag: string) => { if (file.data.frontmatter) { file.data.frontmatter.tags.push(tag) diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index ad1881b..eaeb12a 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -2,7 +2,7 @@ import { PluggableList } from "unified" import { StaticResources } from "../util/resources" import { ProcessedContent } from "./vfile" import { QuartzComponent } from "../components/types" -import { FilePath, ServerSlug } from "../util/path" +import { FilePath, FullSlug } from "../util/path" import { BuildCtx } from "../util/ctx" export interface PluginTypes { @@ -46,7 +46,7 @@ export type QuartzEmitterPluginInstance = { } export interface EmitOptions { - slug: ServerSlug + slug: FullSlug ext: `.${string}` | "" content: string } diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts index b127839..d303391 100644 --- a/quartz/util/ctx.ts +++ b/quartz/util/ctx.ts @@ -1,5 +1,5 @@ import { QuartzConfig } from "../cfg" -import { ServerSlug } from "./path" +import { FullSlug } from "./path" export interface Argv { directory: string @@ -13,5 +13,5 @@ export interface Argv { export interface BuildCtx { argv: Argv cfg: QuartzConfig - allSlugs: ServerSlug[] + allSlugs: FullSlug[] } diff --git a/quartz/util/path.test.ts b/quartz/util/path.test.ts index cbfd86b..8bbb58d 100644 --- a/quartz/util/path.test.ts +++ b/quartz/util/path.test.ts @@ -1,45 +1,25 @@ import test, { describe } from "node:test" import * as path from "./path" import assert from "node:assert" -import { CanonicalSlug, ServerSlug, TransformOptions } from "./path" +import { FullSlug, TransformOptions } from "./path" describe("typeguards", () => { - test("isClientSlug", () => { - assert(path.isClientSlug("http://example.com")) - assert(path.isClientSlug("http://example.com/index")) - assert(path.isClientSlug("http://example.com/index.html")) - assert(path.isClientSlug("http://example.com/")) - assert(path.isClientSlug("https://example.com")) - assert(path.isClientSlug("https://example.com/abc/def")) - assert(path.isClientSlug("https://example.com/abc/def/")) - assert(path.isClientSlug("https://example.com/abc/def#cool")) - assert(path.isClientSlug("https://example.com/abc/def?field=1&another=2")) - assert(path.isClientSlug("https://example.com/abc/def?field=1&another=2#cool")) - assert(path.isClientSlug("https://example.com/abc/def.html?field=1&another=2#cool")) + test("isSimpleSlug", () => { + assert(path.isSimpleSlug("")) + assert(path.isSimpleSlug("abc")) + assert(path.isSimpleSlug("abc/")) + assert(path.isSimpleSlug("notindex")) + assert(path.isSimpleSlug("notindex/def")) - assert(!path.isClientSlug("./")) - assert(!path.isClientSlug("")) - assert(!path.isClientSlug("ipfs://example.com")) - assert(!path.isClientSlug("http")) - assert(!path.isClientSlug("https")) - }) - - test("isCanonicalSlug", () => { - assert(path.isCanonicalSlug("")) - assert(path.isCanonicalSlug("abc")) - assert(path.isCanonicalSlug("notindex")) - assert(path.isCanonicalSlug("notindex/def")) - - assert(!path.isCanonicalSlug("//")) - assert(!path.isCanonicalSlug("index")) - assert(!path.isCanonicalSlug("https://example.com")) - assert(!path.isCanonicalSlug("/abc")) - assert(!path.isCanonicalSlug("abc/")) - assert(!path.isCanonicalSlug("abc/index")) - assert(!path.isCanonicalSlug("abc#anchor")) - assert(!path.isCanonicalSlug("abc?query=1")) - assert(!path.isCanonicalSlug("index.md")) - assert(!path.isCanonicalSlug("index.html")) + assert(!path.isSimpleSlug("//")) + assert(!path.isSimpleSlug("index")) + assert(!path.isSimpleSlug("https://example.com")) + assert(!path.isSimpleSlug("/abc")) + assert(!path.isSimpleSlug("abc/index")) + assert(!path.isSimpleSlug("abc#anchor")) + assert(!path.isSimpleSlug("abc?query=1")) + assert(!path.isSimpleSlug("index.md")) + assert(!path.isSimpleSlug("index.html")) }) test("isRelativeURL", () => { @@ -58,18 +38,18 @@ describe("typeguards", () => { assert(!path.isRelativeURL("./abc/def.md")) }) - test("isServerSlug", () => { - assert(path.isServerSlug("index")) - assert(path.isServerSlug("abc/def")) - assert(path.isServerSlug("html.energy")) - assert(path.isServerSlug("test.pdf")) + test("isFullSlug", () => { + assert(path.isFullSlug("index")) + assert(path.isFullSlug("abc/def")) + assert(path.isFullSlug("html.energy")) + assert(path.isFullSlug("test.pdf")) - assert(!path.isServerSlug(".")) - assert(!path.isServerSlug("./abc/def")) - assert(!path.isServerSlug("../abc/def")) - assert(!path.isServerSlug("abc/def#anchor")) - assert(!path.isServerSlug("abc/def?query=1")) - assert(!path.isServerSlug("note with spaces")) + assert(!path.isFullSlug(".")) + assert(!path.isFullSlug("./abc/def")) + assert(!path.isFullSlug("../abc/def")) + assert(!path.isFullSlug("abc/def#anchor")) + assert(!path.isFullSlug("abc/def?query=1")) + assert(!path.isFullSlug("note with spaces")) }) test("isFilePath", () => { @@ -100,40 +80,17 @@ describe("transforms", () => { } } - test("canonicalizeServer", () => { + test("simplifySlug", () => { asserts( [ ["index", ""], - ["abc/index", "abc"], + ["abc", "abc"], + ["abc/index", "abc/"], ["abc/def", "abc/def"], ], - path.canonicalizeServer, - path.isServerSlug, - path.isCanonicalSlug, - ) - }) - - test("canonicalizeClient", () => { - asserts( - [ - ["http://localhost:3000", ""], - ["http://localhost:3000/index", ""], - ["http://localhost:3000/test", "test"], - ["http://example.com", ""], - ["http://example.com/index", ""], - ["http://example.com/index.html", ""], - ["http://example.com/", ""], - ["https://example.com", ""], - ["https://example.com/abc/def", "abc/def"], - ["https://example.com/abc/def/", "abc/def"], - ["https://example.com/abc/def#cool", "abc/def"], - ["https://example.com/abc/def?field=1&another=2", "abc/def"], - ["https://example.com/abc/def?field=1&another=2#cool", "abc/def"], - ["https://example.com/abc/def.html?field=1&another=2#cool", "abc/def"], - ], - path.canonicalizeClient, - path.isClientSlug, - path.isCanonicalSlug, + path.simplifySlug, + path.isFullSlug, + path.isSimpleSlug, ) }) @@ -151,7 +108,7 @@ describe("transforms", () => { ], path.slugifyFilePath, path.isFilePath, - path.isServerSlug, + path.isFullSlug, ) }) @@ -186,12 +143,14 @@ describe("transforms", () => { test("pathToRoot", () => { asserts( [ - ["", "."], - ["abc", ".."], - ["abc/def", "../.."], + ["index", "."], + ["abc", "."], + ["abc/def", ".."], + ["abc/def/ghi", "../.."], + ["abc/def/index", "../.."], ], path.pathToRoot, - path.isCanonicalSlug, + path.isFullSlug, path.isRelativeURL, ) }) @@ -206,7 +165,7 @@ describe("link strategies", () => { "e/g/h", "index", "a/test.png", - ] as ServerSlug[] + ] as FullSlug[] describe("absolute", () => { const opts: TransformOptions = { @@ -215,28 +174,28 @@ describe("link strategies", () => { } test("from a/b/c", () => { - const cur = "a/b/c" as CanonicalSlug - assert.strictEqual(path.transformLink(cur, "a/b/d", opts), "../../../a/b/d") - assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../../a/b/") - assert.strictEqual(path.transformLink(cur, "e/f", opts), "../../../e/f") - assert.strictEqual(path.transformLink(cur, "e/g/h", opts), "../../../e/g/h") - assert.strictEqual(path.transformLink(cur, "index", opts), "../../../") - assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../../index.png") - assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../../#abc") - assert.strictEqual(path.transformLink(cur, "tag/test", opts), "../../../tag/test") - assert.strictEqual(path.transformLink(cur, "a/b/c#test", opts), "../../../a/b/c#test") - assert.strictEqual(path.transformLink(cur, "a/test.png", opts), "../../../a/test.png") + const cur = "a/b/c" as FullSlug + assert.strictEqual(path.transformLink(cur, "a/b/d", opts), "../../a/b/d") + assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/") + assert.strictEqual(path.transformLink(cur, "e/f", opts), "../../e/f") + assert.strictEqual(path.transformLink(cur, "e/g/h", opts), "../../e/g/h") + assert.strictEqual(path.transformLink(cur, "index", opts), "../../") + assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../index.png") + assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../#abc") + assert.strictEqual(path.transformLink(cur, "tag/test", opts), "../../tag/test") + assert.strictEqual(path.transformLink(cur, "a/b/c#test", opts), "../../a/b/c#test") + assert.strictEqual(path.transformLink(cur, "a/test.png", opts), "../../a/test.png") }) test("from a/b/index", () => { - const cur = "a/b" as CanonicalSlug + const cur = "a/b/index" as FullSlug assert.strictEqual(path.transformLink(cur, "a/b/d", opts), "../../a/b/d") assert.strictEqual(path.transformLink(cur, "a/b", opts), "../../a/b") assert.strictEqual(path.transformLink(cur, "index", opts), "../../") }) test("from index", () => { - const cur = "" as CanonicalSlug + const cur = "index" as FullSlug assert.strictEqual(path.transformLink(cur, "index", opts), "./") assert.strictEqual(path.transformLink(cur, "a/b/c", opts), "./a/b/c") assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/") @@ -250,20 +209,20 @@ describe("link strategies", () => { } test("from a/b/c", () => { - const cur = "a/b/c" as CanonicalSlug - assert.strictEqual(path.transformLink(cur, "d", opts), "../../../a/b/d") - assert.strictEqual(path.transformLink(cur, "h", opts), "../../../e/g/h") - assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../../a/b/") - assert.strictEqual(path.transformLink(cur, "a/b/index.png", opts), "../../../a/b/index.png") - assert.strictEqual(path.transformLink(cur, "a/b/index#abc", opts), "../../../a/b/#abc") - assert.strictEqual(path.transformLink(cur, "index", opts), "../../../") - assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../../index.png") - assert.strictEqual(path.transformLink(cur, "test.png", opts), "../../../a/test.png") - assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../../#abc") + const cur = "a/b/c" as FullSlug + assert.strictEqual(path.transformLink(cur, "d", opts), "../../a/b/d") + assert.strictEqual(path.transformLink(cur, "h", opts), "../../e/g/h") + assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/") + assert.strictEqual(path.transformLink(cur, "a/b/index.png", opts), "../../a/b/index.png") + assert.strictEqual(path.transformLink(cur, "a/b/index#abc", opts), "../../a/b/#abc") + assert.strictEqual(path.transformLink(cur, "index", opts), "../../") + assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../index.png") + assert.strictEqual(path.transformLink(cur, "test.png", opts), "../../a/test.png") + assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../#abc") }) test("from a/b/index", () => { - const cur = "a/b" as CanonicalSlug + const cur = "a/b/index" as FullSlug assert.strictEqual(path.transformLink(cur, "d", opts), "../../a/b/d") assert.strictEqual(path.transformLink(cur, "h", opts), "../../e/g/h") assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/") @@ -271,7 +230,7 @@ describe("link strategies", () => { }) test("from index", () => { - const cur = "" as CanonicalSlug + const cur = "index" as FullSlug assert.strictEqual(path.transformLink(cur, "d", opts), "./a/b/d") assert.strictEqual(path.transformLink(cur, "h", opts), "./e/g/h") assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/") @@ -286,7 +245,7 @@ describe("link strategies", () => { } test("from a/b/c", () => { - const cur = "a/b/c" as CanonicalSlug + const cur = "a/b/c" as FullSlug assert.strictEqual(path.transformLink(cur, "d", opts), "./d") assert.strictEqual(path.transformLink(cur, "index", opts), "./") assert.strictEqual(path.transformLink(cur, "../../../index", opts), "../../../") @@ -303,7 +262,7 @@ describe("link strategies", () => { }) test("from a/b/index", () => { - const cur = "a/b" as CanonicalSlug + const cur = "a/b/index" as FullSlug assert.strictEqual(path.transformLink(cur, "../../index", opts), "../../") assert.strictEqual(path.transformLink(cur, "../../", opts), "../../") assert.strictEqual(path.transformLink(cur, "../../e/g/h", opts), "../../e/g/h") @@ -311,7 +270,7 @@ describe("link strategies", () => { }) test("from index", () => { - const cur = "" as CanonicalSlug + const cur = "index" as FullSlug assert.strictEqual(path.transformLink(cur, "e/g/h", opts), "./e/g/h") assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/") }) diff --git a/quartz/util/path.ts b/quartz/util/path.ts index 7394a43..d14b827 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -1,73 +1,35 @@ import { slug } from "github-slugger" // this file must be isomorphic so it can't use node libs (e.g. path) -// Quartz Paths -// Things in boxes are not actual types but rather sources which these types can be acquired from -// -// ┌────────────┐ -// ┌───────────┤ Browser ├────────────┐ -// │ └────────────┘ │ -// │ │ -// ▼ ▼ -// ┌────────┐ ┌─────────────┐ -// ┌───────────────────┤ Window │ │ LinkElement │ -// │ └────┬───┘ └──────┬──────┘ -// │ │ │ -// │ getClientSlug() │ .href │ -// │ ▼ ▼ -// │ -// │ Client Slug ┌───► Relative URL -// getCanonicalSlug() │ https://test.ca/note/abc#anchor?query=123 │ ../note/def#anchor -// │ │ -// │ canonicalizeClient() │ │ ▲ ▲ -// │ ▼ │ │ │ -// │ pathToRoot() │ │ │ -// └───────────────► Canonical Slug ────────────────┘ │ │ -// note/abc │ │ -// ──────────────────────────┘ │ -// ▲ resolveRelative() │ -// canonicalizeServer() │ │ -// │ -// HTML File Server Slug │ -// note/abc/index.html ◄───────────── note/abc/index │ -// │ -// ▲ ┌────────┴────────┐ -// slugifyFilePath() │ transformLink() │ │ -// │ │ │ -// ┌─────────┴──────────┐ ┌─────┴─────┐ ┌────────┴──────┐ -// │ File Path │ │ Wikilinks │ │ Markdown Link │ -// │ note/abc/index.md │ └───────────┘ └───────────────┘ -// └────────────────────┘ ▲ ▲ -// ▲ │ │ -// │ ┌─────────┐ │ │ -// └────────────┤ MD File ├─────┴─────────────────┘ -// └─────────┘ - export const QUARTZ = "quartz" /// Utility type to simulate nominal types in TypeScript type SlugLike<T> = string & { __brand: T } -/** Client-side slug, usually obtained through `window.location` */ -export type ClientSlug = SlugLike<"client"> -export function isClientSlug(s: string): s is ClientSlug { - const res = /^https?:\/\/.+/.test(s) - return res +/** Cannot be relative and must have a file extension. */ +export type FilePath = SlugLike<"filepath"> +export function isFilePath(s: string): s is FilePath { + const validStart = !s.startsWith(".") + return validStart && _hasFileExtension(s) } -/** Canonical slug, should be used whenever you need to refer to the location of a file/note. - * On the client, this is normally stored in `document.body.dataset.slug` - */ -export type CanonicalSlug = SlugLike<"canonical"> -export function isCanonicalSlug(s: string): s is CanonicalSlug { +/** Cannot be relative and may not have leading or trailing slashes. It can have `index` as it's last segment. Use this wherever possible is it's the most 'general' interpretation of a slug. */ +export type FullSlug = SlugLike<"full"> +export function isFullSlug(s: string): s is FullSlug { const validStart = !(s.startsWith(".") || s.startsWith("/")) - const validEnding = !(s.endsWith("/") || s.endsWith("/index") || s === "index") + const validEnding = !s.endsWith("/") + return validStart && validEnding && !_containsForbiddenCharacters(s) +} + +/** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */ +export type SimpleSlug = SlugLike<"simple"> +export function isSimpleSlug(s: string): s is SimpleSlug { + const validStart = !(s.startsWith(".") || s.startsWith("/")) + const validEnding = !(s.endsWith("/index") || s === "index") return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s) } -/** A relative link, can be found on `href`s but can also be constructed for - * client-side navigation (e.g. search and graph) - */ +/** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */ export type RelativeURL = SlugLike<"relative"> export function isRelativeURL(s: string): s is RelativeURL { const validStart = /^\.{1,2}/.test(s) @@ -75,46 +37,12 @@ export function isRelativeURL(s: string): s is RelativeURL { return validStart && validEnding && ![".md", ".html"].includes(_getFileExtension(s) ?? "") } -/** A server side slug. This is what Quartz uses to emit files so uses index suffixes */ -export type ServerSlug = SlugLike<"server"> -export function isServerSlug(s: string): s is ServerSlug { - const validStart = !(s.startsWith(".") || s.startsWith("/")) - const validEnding = !s.endsWith("/") - return validStart && validEnding && !_containsForbiddenCharacters(s) -} - -/** The real file path to a file on disk */ -export type FilePath = SlugLike<"filepath"> -export function isFilePath(s: string): s is FilePath { - const validStart = !s.startsWith(".") - return validStart && _hasFileExtension(s) -} - -export function getClientSlug(window: Window): ClientSlug { - const res = window.location.href as ClientSlug +export function getFullSlug(window: Window): FullSlug { + const res = window.document.body.dataset.slug! as FullSlug return res } -export function getCanonicalSlug(window: Window): CanonicalSlug { - const res = window.document.body.dataset.slug! as CanonicalSlug - return res -} - -export function canonicalizeClient(slug: ClientSlug): CanonicalSlug { - const { pathname } = new URL(slug) - let fp = pathname.slice(1) - fp = fp.replace(new RegExp(_getFileExtension(fp) + "$"), "") - const res = _canonicalize(fp) as CanonicalSlug - return res -} - -export function canonicalizeServer(slug: ServerSlug): CanonicalSlug { - let fp = slug as string - const res = _canonicalize(fp) as CanonicalSlug - return res -} - -export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): ServerSlug { +export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { fp = _stripSlashes(fp) as FilePath let ext = _getFileExtension(fp) const withoutFileExt = fp.replace(new RegExp(ext + "$"), "") @@ -133,44 +61,47 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): ServerSlug slug = slug.replace(/_index$/, "index") } - return (slug + ext) as ServerSlug + return (slug + ext) as FullSlug +} + +export function simplifySlug(fp: FullSlug): SimpleSlug { + return _stripSlashes(_trimSuffix(fp, "index"), true) as SimpleSlug } export function transformInternalLink(link: string): RelativeURL { let [fplike, anchor] = splitAnchor(decodeURI(link)) - const folderPath = - fplike.endsWith("index") || - fplike.endsWith("index.md") || - fplike.endsWith("index.html") || - fplike.endsWith("/") - + const folderPath = _isFolderPath(fplike) let segments = fplike.split("/").filter((x) => x.length > 0) let prefix = segments.filter(_isRelativeSegment).join("/") - let fp = segments.filter((seg) => !_isRelativeSegment(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 - fp = canonicalizeServer(slugifyFilePath(fp as FilePath) as ServerSlug) - const joined = joinSegments(_stripSlashes(prefix), _stripSlashes(fp)) + const simpleSlug = simplifySlug(slugifyFilePath(fp as FilePath)) + const joined = joinSegments(_stripSlashes(prefix), _stripSlashes(simpleSlug)) const trail = folderPath ? "/" : "" const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL return res } -// resolve /a/b/c to ../../.. -export function pathToRoot(slug: CanonicalSlug): RelativeURL { +// resolve /a/b/c to ../.. +export function pathToRoot(slug: FullSlug): RelativeURL { let rootPath = slug .split("/") .filter((x) => x !== "") + .slice(0, -1) .map((_) => "..") .join("/") - const res = _addRelativeToStart(rootPath) as RelativeURL - return res + if (rootPath.length === 0) { + rootPath = "." + } + + return rootPath as RelativeURL } -export function resolveRelative(current: CanonicalSlug, target: CanonicalSlug): RelativeURL { - const res = joinSegments(pathToRoot(current), target) as RelativeURL +export function resolveRelative(current: FullSlug, target: FullSlug | SimpleSlug): RelativeURL { + const res = joinSegments(pathToRoot(current), simplifySlug(target as FullSlug)) as RelativeURL return res } @@ -206,20 +137,16 @@ export function getAllSegmentPrefixes(tags: string): string[] { export interface TransformOptions { strategy: "absolute" | "relative" | "shortest" - allSlugs: ServerSlug[] + allSlugs: FullSlug[] } -export function transformLink( - src: CanonicalSlug, - target: string, - opts: TransformOptions, -): RelativeURL { - let targetSlug: string = transformInternalLink(target) +export function transformLink(src: FullSlug, target: string, opts: TransformOptions): RelativeURL { + let targetSlug = transformInternalLink(target) if (opts.strategy === "relative") { - return _addRelativeToStart(targetSlug) as RelativeURL + return targetSlug as RelativeURL } else { - const folderTail = targetSlug.endsWith("/") ? "/" : "" + const folderTail = _isFolderPath(targetSlug) ? "/" : "" const canonicalSlug = _stripSlashes(targetSlug.slice(".".length)) let [targetCanonical, targetAnchor] = splitAnchor(canonicalSlug) @@ -233,7 +160,7 @@ export function transformLink( // only match, just use it if (matchingFileNames.length === 1) { - const targetSlug = canonicalizeServer(matchingFileNames[0]) + const targetSlug = matchingFileNames[0] return (resolveRelative(src, targetSlug) + targetAnchor) as RelativeURL } } @@ -243,9 +170,13 @@ export function transformLink( } } -function _canonicalize(fp: string): string { - fp = _trimSuffix(fp, "index") - return _stripSlashes(fp) +function _isFolderPath(fplike: string): boolean { + return ( + fplike.endsWith("/") || + _endsWith(fplike, "index") || + _endsWith(fplike, "index.md") || + _endsWith(fplike, "index.html") + ) } function _endsWith(s: string, suffix: string): boolean { @@ -275,12 +206,12 @@ function _isRelativeSegment(s: string): boolean { return /^\.{0,2}$/.test(s) } -export function _stripSlashes(s: string): string { +export function _stripSlashes(s: string, onlyStripPrefix?: boolean): string { if (s.startsWith("/")) { s = s.substring(1) } - if (s.endsWith("/")) { + if (!onlyStripPrefix && s.endsWith("/")) { s = s.slice(0, -1) } diff --git a/quartz/worker.ts b/quartz/worker.ts index 3bd7ea7..b92bdac 100644 --- a/quartz/worker.ts +++ b/quartz/worker.ts @@ -2,12 +2,12 @@ import sourceMapSupport from "source-map-support" sourceMapSupport.install(options) import cfg from "../quartz.config" import { Argv, BuildCtx } from "./util/ctx" -import { FilePath, ServerSlug } from "./util/path" +import { FilePath, FullSlug } from "./util/path" import { createFileParser, createProcessor } from "./processors/parse" import { options } from "./util/sourcemap" // only called from worker thread -export async function parseFiles(argv: Argv, fps: FilePath[], allSlugs: ServerSlug[]) { +export async function parseFiles(argv: Argv, fps: FilePath[], allSlugs: FullSlug[]) { const ctx: BuildCtx = { cfg, argv,