base path refactor to better support subpath hosting

This commit is contained in:
Jacky Zhao 2023-08-19 15:52:25 -07:00
parent 3201f83b70
commit c874e7e937
29 changed files with 257 additions and 389 deletions

View file

@ -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. 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. 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 ```typescript
// instead of // instead of
type ClientSlug = string type FullSlug = string
// we do // we do
type ClientSlug = string & { __brand: "client" } type FullSlug = string & { __brand: "full" }
// that way, the following will fail typechecking // 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. 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 ```mermaid
graph LR graph LR
Browser{{Browser}} --> Window{{Window}} & LinkElement{{Link Element}} Browser{{Browser}} --> Window{{Body}} & LinkElement{{Link Element}}
Window --"getCanonicalSlug()"--> Canonical[Canonical Slug] Window --"getFullSlug()"--> FullSlug[Full Slug]
Window --"getClientSlug()"--> Client[Client Slug]
LinkElement --".href"--> Relative[Relative URL] LinkElement --".href"--> Relative[Relative URL]
Client --"canonicalizeClient()"--> Canonical FullSlug --"simplifySlug()" --> SimpleSlug[Simple Slug]
Canonical --"pathToRoot()"--> Relative SimpleSlug --"pathToRoot()"--> Relative
Canonical --"resolveRelative()" --> Relative SimpleSlug --"resolveRelative()" --> Relative
MD{{Markdown File}} --> FilePath{{File Path}} & Links[Markdown links] MD{{Markdown File}} --> FilePath{{File Path}} & Links[Markdown links]
Links --"transformLink()"--> Relative Links --"transformLink()"--> Relative
FilePath --"slugifyFilePath()"--> Server[Server Slug] FilePath --"slugifyFilePath()"--> FullSlug[Full Slug]
Server --> HTML["HTML File"] style FullSlug stroke-width:4px
Server --"canonicalizeServer()"--> Canonical
style Canonical stroke-width:4px
``` ```
Here are the main types of slugs with a rough description of each type of path: 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. - `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`. To get a clearer picture of how these relate to each other, take a look at the path tests in `quartz/path.test.ts`.

View file

@ -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 | | Configuration option | Value |
| ---------------------- | ------------------ | | ---------------------- | ------------------ |
| Production branch | `v4` | | Production branch | `v4` |
| Framework preset | `None` | | Framework preset | `None` |
| Build command | `npx quartz build` | | Build command | `npx quartz build` |
| Build output directory | `public` | | Build output directory | `public` |

2
index.d.ts vendored
View file

@ -5,7 +5,7 @@ declare module "*.scss" {
// dom custom event // dom custom event
interface CustomEventMap { interface CustomEventMap {
nav: CustomEvent<{ url: CanonicalSlug }> nav: CustomEvent<{ url: FullSlug }>
} }
declare const fetchData: Promise<ContentIndex> declare const fetchData: Promise<ContentIndex>

View file

@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website", "description": "🌱 publish your digital garden and notes as a website",
"private": true, "private": true,
"version": "4.0.7", "version": "4.0.8",
"type": "module", "type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>", "author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT", "license": "MIT",

View file

@ -1,9 +1,9 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/backlinks.scss" import style from "./styles/backlinks.scss"
import { canonicalizeServer, resolveRelative } from "../util/path" import { resolveRelative, simplifySlug } from "../util/path"
function Backlinks({ fileData, allFiles }: QuartzComponentProps) { function Backlinks({ fileData, allFiles }: QuartzComponentProps) {
const slug = canonicalizeServer(fileData.slug!) const slug = simplifySlug(fileData.slug!)
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug)) const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
return ( return (
<div class="backlinks"> <div class="backlinks">
@ -12,7 +12,7 @@ function Backlinks({ fileData, allFiles }: QuartzComponentProps) {
{backlinkFiles.length > 0 ? ( {backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => ( backlinkFiles.map((f) => (
<li> <li>
<a href={resolveRelative(slug, canonicalizeServer(f.slug!))} class="internal"> <a href={resolveRelative(fileData.slug!, f.slug!)} class="internal">
{f.frontmatter?.title} {f.frontmatter?.title}
</a> </a>
</li> </li>

View file

@ -1,14 +1,13 @@
import { canonicalizeServer, pathToRoot } from "../util/path" import { pathToRoot } from "../util/path"
import { JSResourceToScriptElement } from "../util/resources" import { JSResourceToScriptElement } from "../util/resources"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default (() => { export default (() => {
function Head({ cfg, fileData, externalResources }: QuartzComponentProps) { function Head({ cfg, fileData, externalResources }: QuartzComponentProps) {
const slug = canonicalizeServer(fileData.slug!)
const title = fileData.frontmatter?.title ?? "Untitled" const title = fileData.frontmatter?.title ?? "Untitled"
const description = fileData.description?.trim() ?? "No description provided" const description = fileData.description?.trim() ?? "No description provided"
const { css, js } = externalResources const { css, js } = externalResources
const baseDir = pathToRoot(slug) const baseDir = pathToRoot(fileData.slug!)
const iconPath = baseDir + "/static/icon.png" const iconPath = baseDir + "/static/icon.png"
const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png` const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png`

View file

@ -1,4 +1,4 @@
import { CanonicalSlug, canonicalizeServer, resolveRelative } from "../util/path" import { FullSlug, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { Date } from "./Date" import { Date } from "./Date"
import { QuartzComponentProps } from "./types" import { QuartzComponentProps } from "./types"
@ -25,7 +25,6 @@ type Props = {
} & QuartzComponentProps } & QuartzComponentProps
export function PageList({ fileData, allFiles, limit }: Props) { export function PageList({ fileData, allFiles, limit }: Props) {
const slug = canonicalizeServer(fileData.slug!)
let list = allFiles.sort(byDateAndAlphabetical) let list = allFiles.sort(byDateAndAlphabetical)
if (limit) { if (limit) {
list = list.slice(0, limit) list = list.slice(0, limit)
@ -35,7 +34,6 @@ export function PageList({ fileData, allFiles, limit }: Props) {
<ul class="section-ul"> <ul class="section-ul">
{list.map((page) => { {list.map((page) => {
const title = page.frontmatter?.title const title = page.frontmatter?.title
const pageSlug = canonicalizeServer(page.slug!)
const tags = page.frontmatter?.tags ?? [] const tags = page.frontmatter?.tags ?? []
return ( return (
@ -48,7 +46,7 @@ export function PageList({ fileData, allFiles, limit }: Props) {
)} )}
<div class="desc"> <div class="desc">
<h3> <h3>
<a href={resolveRelative(slug, pageSlug)} class="internal"> <a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
{title} {title}
</a> </a>
</h3> </h3>
@ -58,7 +56,7 @@ export function PageList({ fileData, allFiles, limit }: Props) {
<li> <li>
<a <a
class="internal tag-link" class="internal tag-link"
href={resolveRelative(slug, `tags/${tag}` as CanonicalSlug)} href={resolveRelative(fileData.slug!, `tags/${tag}/index` as FullSlug)}
> >
#{tag} #{tag}
</a> </a>

View file

@ -1,10 +1,9 @@
import { canonicalizeServer, pathToRoot } from "../util/path" import { pathToRoot } from "../util/path"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function PageTitle({ fileData, cfg }: QuartzComponentProps) { function PageTitle({ fileData, cfg }: QuartzComponentProps) {
const title = cfg?.pageTitle ?? "Untitled Quartz" const title = cfg?.pageTitle ?? "Untitled Quartz"
const slug = canonicalizeServer(fileData.slug!) const baseDir = pathToRoot(fileData.slug!)
const baseDir = pathToRoot(slug)
return ( return (
<h1 class="page-title"> <h1 class="page-title">
<a href={baseDir}>{title}</a> <a href={baseDir}>{title}</a>

View file

@ -1,10 +1,9 @@
import { canonicalizeServer, pathToRoot, slugTag } from "../util/path" import { pathToRoot, slugTag } from "../util/path"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function TagList({ fileData }: QuartzComponentProps) { function TagList({ fileData }: QuartzComponentProps) {
const tags = fileData.frontmatter?.tags const tags = fileData.frontmatter?.tags
const slug = canonicalizeServer(fileData.slug!) const baseDir = pathToRoot(fileData.slug!)
const baseDir = pathToRoot(slug)
if (tags && tags.length > 0) { if (tags && tags.length > 0) {
return ( return (
<ul class="tags"> <ul class="tags">

View file

@ -5,13 +5,13 @@ import path from "path"
import style from "../styles/listPage.scss" import style from "../styles/listPage.scss"
import { PageList } from "../PageList" import { PageList } from "../PageList"
import { canonicalizeServer } from "../../util/path" import { simplifySlug } from "../../util/path"
function FolderContent(props: QuartzComponentProps) { function FolderContent(props: QuartzComponentProps) {
const { tree, fileData, allFiles } = props const { tree, fileData, allFiles } = props
const folderSlug = canonicalizeServer(fileData.slug!) const folderSlug = simplifySlug(fileData.slug!)
const allPagesInFolder = allFiles.filter((file) => { const allPagesInFolder = allFiles.filter((file) => {
const fileSlug = canonicalizeServer(file.slug!) const fileSlug = simplifySlug(file.slug!)
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
const folderParts = folderSlug.split(path.posix.sep) const folderParts = folderSlug.split(path.posix.sep)
const fileParts = fileSlug.split(path.posix.sep) const fileParts = fileSlug.split(path.posix.sep)

View file

@ -3,7 +3,7 @@ import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { toJsxRuntime } from "hast-util-to-jsx-runtime" import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import style from "../styles/listPage.scss" import style from "../styles/listPage.scss"
import { PageList } from "../PageList" import { PageList } from "../PageList"
import { ServerSlug, canonicalizeServer, getAllSegmentPrefixes } from "../../util/path" import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
import { QuartzPluginData } from "../../plugins/vfile" import { QuartzPluginData } from "../../plugins/vfile"
const numPages = 10 const numPages = 10
@ -15,7 +15,7 @@ function TagContent(props: QuartzComponentProps) {
throw new Error(`Component "TagContent" tried to render a non-tag page: ${slug}`) 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) => const allPagesWithTag = (tag: string) =>
allFiles.filter((file) => allFiles.filter((file) =>
(file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(tag), (file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(tag),

View file

@ -3,7 +3,7 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header" import HeaderConstructor from "./Header"
import BodyConstructor from "./Body" import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../util/resources" import { JSResourceToScriptElement, StaticResources } from "../util/resources"
import { CanonicalSlug, pathToRoot } from "../util/path" import { FullSlug, joinSegments, pathToRoot } from "../util/path"
interface RenderComponents { interface RenderComponents {
head: QuartzComponent head: QuartzComponent
@ -15,19 +15,20 @@ interface RenderComponents {
footer: QuartzComponent footer: QuartzComponent
} }
export function pageResources( export function pageResources(slug: FullSlug, staticResources: StaticResources): StaticResources {
slug: CanonicalSlug,
staticResources: StaticResources,
): StaticResources {
const baseDir = pathToRoot(slug) 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())` const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())`
return { return {
css: [baseDir + "/index.css", ...staticResources.css], css: [joinSegments(baseDir, "index.css"), ...staticResources.css],
js: [ js: [
{ src: baseDir + "/prescript.js", loadTime: "beforeDOMReady", contentType: "external" }, {
src: joinSegments(baseDir, "/prescript.js"),
loadTime: "beforeDOMReady",
contentType: "external",
},
{ {
loadTime: "beforeDOMReady", loadTime: "beforeDOMReady",
contentType: "inline", contentType: "inline",
@ -46,7 +47,7 @@ export function pageResources(
} }
export function renderPage( export function renderPage(
slug: CanonicalSlug, slug: FullSlug,
componentData: QuartzComponentProps, componentData: QuartzComponentProps,
components: RenderComponents, components: RenderComponents,
pageResources: StaticResources, pageResources: StaticResources,

View file

@ -1,31 +1,32 @@
import type { ContentDetails } from "../../plugins/emitters/contentIndex" import type { ContentDetails } from "../../plugins/emitters/contentIndex"
import * as d3 from "d3" import * as d3 from "d3"
import { registerEscapeHandler, removeAllChildren } from "./util" import { registerEscapeHandler, removeAllChildren } from "./util"
import { CanonicalSlug, getCanonicalSlug, getClientSlug, resolveRelative } from "../../util/path" import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
type NodeData = { type NodeData = {
id: CanonicalSlug id: SimpleSlug
text: string text: string
tags: string[] tags: string[]
} & d3.SimulationNodeDatum } & d3.SimulationNodeDatum
type LinkData = { type LinkData = {
source: CanonicalSlug source: SimpleSlug
target: CanonicalSlug target: SimpleSlug
} }
const localStorageKey = "graph-visited" const localStorageKey = "graph-visited"
function getVisited(): Set<CanonicalSlug> { function getVisited(): Set<SimpleSlug> {
return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]")) return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
} }
function addToVisited(slug: CanonicalSlug) { function addToVisited(slug: SimpleSlug) {
const visited = getVisited() const visited = getVisited()
visited.add(slug) visited.add(slug)
localStorage.setItem(localStorageKey, JSON.stringify([...visited])) 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 visited = getVisited()
const graph = document.getElementById(container) const graph = document.getElementById(container)
if (!graph) return if (!graph) return
@ -47,16 +48,17 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
const links: LinkData[] = [] const links: LinkData[] = []
for (const [src, details] of Object.entries<ContentDetails>(data)) { for (const [src, details] of Object.entries<ContentDetails>(data)) {
const source = simplifySlug(src as FullSlug)
const outgoing = details.links ?? [] const outgoing = details.links ?? []
for (const dest of outgoing) { for (const dest of outgoing) {
if (src in data && dest in data) { if (dest in data) {
links.push({ source: src as CanonicalSlug, target: dest }) links.push({ source, target: dest })
} }
} }
} }
const neighbourhood = new Set<CanonicalSlug>() const neighbourhood = new Set<SimpleSlug>()
const wl: (CanonicalSlug | "__SENTINEL")[] = [slug, "__SENTINEL"] const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"]
if (depth >= 0) { if (depth >= 0) {
while (depth >= 0 && wl.length > 0) { while (depth >= 0 && wl.length > 0) {
// compute neighbours // compute neighbours
@ -72,7 +74,7 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
} }
} }
} else { } 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[] } = { const graphData: { nodes: NodeData[]; links: LinkData[] } = {
@ -171,11 +173,11 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
.attr("fill", color) .attr("fill", color)
.style("cursor", "pointer") .style("cursor", "pointer")
.on("click", (_, d) => { .on("click", (_, d) => {
const targ = resolveRelative(slug, d.id) const targ = resolveRelative(fullSlug, d.id)
window.spaNavigate(new URL(targ, getClientSlug(window))) window.spaNavigate(new URL(targ, window.location.toString()))
}) })
.on("mouseover", function (_, d) { .on("mouseover", function (_, d) {
const neighbours: CanonicalSlug[] = data[slug].links ?? [] const neighbours: SimpleSlug[] = data[slug].links ?? []
const neighbourNodes = d3 const neighbourNodes = d3
.selectAll<HTMLElement, NodeData>(".node") .selectAll<HTMLElement, NodeData>(".node")
.filter((d) => neighbours.includes(d.id)) .filter((d) => neighbours.includes(d.id))
@ -271,7 +273,7 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
} }
function renderGlobalGraph() { function renderGlobalGraph() {
const slug = getCanonicalSlug(window) const slug = getFullSlug(window)
const container = document.getElementById("global-graph-outer") const container = document.getElementById("global-graph-outer")
const sidebar = container?.closest(".sidebar") as HTMLElement const sidebar = container?.closest(".sidebar") as HTMLElement
container?.classList.add("active") container?.classList.add("active")

View file

@ -1,11 +1,11 @@
import { Document } from "flexsearch" import { Document } from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex" import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { registerEscapeHandler, removeAllChildren } from "./util" import { registerEscapeHandler, removeAllChildren } from "./util"
import { CanonicalSlug, getClientSlug, resolveRelative } from "../../util/path" import { FullSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
interface Item { interface Item {
id: number id: number
slug: CanonicalSlug slug: FullSlug
title: string title: string
content: string content: string
} }
@ -73,7 +73,7 @@ document.addEventListener("nav", async (e: unknown) => {
const searchIcon = document.getElementById("search-icon") const searchIcon = document.getElementById("search-icon")
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
const results = document.getElementById("results-container") const results = document.getElementById("results-container")
const idDataMap = Object.keys(data) as CanonicalSlug[] const idDataMap = Object.keys(data) as FullSlug[]
function hideSearch() { function hideSearch() {
container?.classList.remove("active") container?.classList.remove("active")
@ -126,7 +126,7 @@ document.addEventListener("nav", async (e: unknown) => {
button.innerHTML = `<h3>${title}</h3><p>${content}</p>` button.innerHTML = `<h3>${title}</h3><p>${content}</p>`
button.addEventListener("click", () => { button.addEventListener("click", () => {
const targ = resolveRelative(currentSlug, slug) const targ = resolveRelative(currentSlug, slug)
window.spaNavigate(new URL(targ, getClientSlug(window))) window.spaNavigate(new URL(targ, window.location.toString()))
}) })
return button return button
} }
@ -192,7 +192,7 @@ document.addEventListener("nav", async (e: unknown) => {
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
await index.addAsync(id, { await index.addAsync(id, {
id, id,
slug: slug as CanonicalSlug, slug: slug as FullSlug,
title: fileData.title, title: fileData.title,
content: fileData.content, content: fileData.content,
}) })

View file

@ -1,5 +1,5 @@
import micromorph from "micromorph" import micromorph from "micromorph"
import { CanonicalSlug, RelativeURL, getCanonicalSlug } from "../../util/path" import { FullSlug, RelativeURL, getFullSlug } from "../../util/path"
// adapted from `micromorph` // adapted from `micromorph`
// https://github.com/natemoo-re/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 } 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 } }) const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
document.dispatchEvent(event) document.dispatchEvent(event)
} }
@ -81,7 +81,7 @@ async function navigate(url: URL, isBack: boolean = false) {
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])") const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
elementsToAdd.forEach((el) => document.head.appendChild(el)) elementsToAdd.forEach((el) => document.head.appendChild(el))
notifyNav(getCanonicalSlug(window)) notifyNav(getFullSlug(window))
delete announcer.dataset.persist delete announcer.dataset.persist
} }
@ -129,7 +129,7 @@ function createRouter() {
} }
createRouter() createRouter()
notifyNav(getCanonicalSlug(window)) notifyNav(getFullSlug(window))
if (!customElements.get("route-announcer")) { if (!customElements.get("route-announcer")) {
const attrs = { const attrs = {

View file

@ -1,10 +1,4 @@
import { import { FilePath, FullSlug, resolveRelative, simplifySlug } from "../../util/path"
CanonicalSlug,
FilePath,
ServerSlug,
canonicalizeServer,
resolveRelative,
} from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import path from "path" import path from "path"
@ -17,10 +11,10 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
const fps: FilePath[] = [] const fps: FilePath[] = []
for (const [_tree, file] of content) { 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) const dir = path.posix.relative(argv.directory, file.dirname ?? argv.directory)
let aliases: CanonicalSlug[] = [] let aliases: FullSlug[] = []
if (file.data.frontmatter?.aliases) { if (file.data.frontmatter?.aliases) {
aliases = file.data.frontmatter?.aliases aliases = file.data.frontmatter?.aliases
} else if (file.data.frontmatter?.alias) { } else if (file.data.frontmatter?.alias) {
@ -28,9 +22,8 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
} }
for (const alias of aliases) { for (const alias of aliases) {
const slug = path.posix.join(dir, alias) as ServerSlug const slug = path.posix.join(dir, alias) as FullSlug
const redirUrl = resolveRelative(slug, file.data.slug!)
const redirUrl = resolveRelative(canonicalizeServer(slug), ogSlug)
const fp = await emit({ const fp = await emit({
content: ` content: `
<!DOCTYPE html> <!DOCTYPE html>

View file

@ -1,4 +1,4 @@
import { FilePath, ServerSlug } from "../../util/path" import { FilePath, FullSlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
// @ts-ignore // @ts-ignore
@ -154,7 +154,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
const postscript = joinScripts(componentResources.afterDOMLoaded) const postscript = joinScripts(componentResources.afterDOMLoaded)
const fps = await Promise.all([ const fps = await Promise.all([
emit({ emit({
slug: "index" as ServerSlug, slug: "index" as FullSlug,
ext: ".css", ext: ".css",
content: transform({ content: transform({
filename: "index.css", filename: "index.css",
@ -171,12 +171,12 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
}).code.toString(), }).code.toString(),
}), }),
emit({ emit({
slug: "prescript" as ServerSlug, slug: "prescript" as FullSlug,
ext: ".js", ext: ".js",
content: prescript, content: prescript,
}), }),
emit({ emit({
slug: "postscript" as ServerSlug, slug: "postscript" as FullSlug,
ext: ".js", ext: ".js",
content: postscript, content: postscript,
}), }),

View file

@ -1,18 +1,12 @@
import { GlobalConfiguration } from "../../cfg" import { GlobalConfiguration } from "../../cfg"
import { import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
CanonicalSlug,
ClientSlug,
FilePath,
ServerSlug,
canonicalizeServer,
} from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import path from "path" import path from "path"
export type ContentIndex = Map<CanonicalSlug, ContentDetails> export type ContentIndex = Map<FullSlug, ContentDetails>
export type ContentDetails = { export type ContentDetails = {
title: string title: string
links: CanonicalSlug[] links: SimpleSlug[]
tags: string[] tags: string[]
content: string content: string
date?: Date date?: Date
@ -33,21 +27,21 @@ const defaultOptions: Options = {
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
const base = cfg.baseUrl ?? "" 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> <loc>https://${base}/${slug}</loc>
<lastmod>${content.date?.toISOString()}</lastmod> <lastmod>${content.date?.toISOString()}</lastmod>
</url>` </url>`
const urls = Array.from(idx) const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(slug, content)) .map(([slug, content]) => createURLEntry(simplifySlug(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>`
} }
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string {
const base = cfg.baseUrl ?? "" 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> <title>${content.title}</title>
<link>${root}/${slug}</link> <link>${root}/${slug}</link>
<guid>${root}/${slug}</guid> <guid>${root}/${slug}</guid>
@ -56,7 +50,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string {
</items>` </items>`
const items = Array.from(idx) const items = Array.from(idx)
.map(([slug, content]) => createURLEntry(slug, content)) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
.join("") .join("")
return `<rss xmlns:atom="http://www.w3.org/2005/atom" version="2.0"> return `<rss xmlns:atom="http://www.w3.org/2005/atom" version="2.0">
<channel> <channel>
@ -79,7 +73,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
const emitted: FilePath[] = [] const emitted: FilePath[] = []
const linkIndex: ContentIndex = new Map() const linkIndex: ContentIndex = new Map()
for (const [_tree, file] of content) { 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() const date = file.data.dates?.modified ?? 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, {
@ -97,7 +91,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
emitted.push( emitted.push(
await emit({ await emit({
content: generateSiteMap(cfg, linkIndex), content: generateSiteMap(cfg, linkIndex),
slug: "sitemap" as ServerSlug, slug: "sitemap" as FullSlug,
ext: ".xml", ext: ".xml",
}), }),
) )
@ -107,13 +101,13 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
emitted.push( emitted.push(
await emit({ await emit({
content: generateRSSFeed(cfg, linkIndex), content: generateRSSFeed(cfg, linkIndex),
slug: "index" as ServerSlug, slug: "index" as FullSlug,
ext: ".xml", ext: ".xml",
}), }),
) )
} }
const fp = path.join("static", "contentIndex") as ServerSlug const fp = path.join("static", "contentIndex") as FullSlug
const simplifiedIndex = Object.fromEntries( const simplifiedIndex = Object.fromEntries(
Array.from(linkIndex).map(([slug, content]) => { Array.from(linkIndex).map(([slug, content]) => {
// remove description and from content index as nothing downstream // remove description and from content index as nothing downstream

View file

@ -4,7 +4,7 @@ import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body" import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage" import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
import { FilePath, canonicalizeServer } from "../../util/path" import { FilePath } from "../../util/path"
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { Content } from "../../components" import { Content } from "../../components"
@ -30,7 +30,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
const fps: FilePath[] = [] const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data) const allFiles = content.map((c) => c[1].data)
for (const [tree, file] of content) { for (const [tree, file] of content) {
const slug = canonicalizeServer(file.data.slug!) const slug = file.data.slug!
const externalResources = pageResources(slug, resources) const externalResources = pageResources(slug, resources)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
fileData: file.data, fileData: file.data,
@ -44,7 +44,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
const content = renderPage(slug, componentData, opts, externalResources) const content = renderPage(slug, componentData, opts, externalResources)
const fp = await emit({ const fp = await emit({
content, content,
slug: file.data.slug!, slug,
ext: ".html", ext: ".html",
}) })

View file

@ -6,13 +6,7 @@ import { pageResources, renderPage } from "../../components/renderPage"
import { ProcessedContent, defaultProcessedContent } from "../vfile" import { ProcessedContent, defaultProcessedContent } from "../vfile"
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
import path from "path" import path from "path"
import { import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path"
CanonicalSlug,
FilePath,
ServerSlug,
canonicalizeServer,
joinSegments,
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { FolderContent } from "../../components" import { FolderContent } from "../../components"
@ -38,10 +32,10 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
const allFiles = content.map((c) => c[1].data) const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const folders: Set<CanonicalSlug> = new Set( const folders: Set<SimpleSlug> = new Set(
allFiles.flatMap((data) => { allFiles.flatMap((data) => {
const slug = data.slug const slug = data.slug
const folderName = path.dirname(slug ?? "") as CanonicalSlug const folderName = path.dirname(slug ?? "") as SimpleSlug
if (slug && folderName !== "." && folderName !== "tags") { if (slug && folderName !== "." && folderName !== "tags") {
return [folderName] return [folderName]
} }
@ -53,21 +47,21 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
[...folders].map((folder) => [ [...folders].map((folder) => [
folder, folder,
defaultProcessedContent({ defaultProcessedContent({
slug: joinSegments(folder, "index") as ServerSlug, slug: joinSegments(folder, "index") as FullSlug,
frontmatter: { title: `Folder: ${folder}`, tags: [] }, frontmatter: { title: `Folder: ${folder}`, tags: [] },
}), }),
]), ]),
) )
for (const [tree, file] of content) { for (const [tree, file] of content) {
const slug = canonicalizeServer(file.data.slug!) const slug = simplifySlug(file.data.slug!)
if (folders.has(slug)) { if (folders.has(slug)) {
folderDescriptions[slug] = [tree, file] folderDescriptions[slug] = [tree, file]
} }
} }
for (const folder of folders) { for (const folder of folders) {
const slug = folder const slug = joinSegments(folder, "index") as FullSlug
const externalResources = pageResources(slug, resources) const externalResources = pageResources(slug, resources)
const [tree, file] = folderDescriptions[folder] const [tree, file] = folderDescriptions[folder]
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
@ -82,7 +76,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
const content = renderPage(slug, componentData, opts, externalResources) const content = renderPage(slug, componentData, opts, externalResources)
const fp = await emit({ const fp = await emit({
content, content,
slug: file.data.slug!, slug,
ext: ".html", ext: ".html",
}) })

View file

@ -5,13 +5,7 @@ import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage" import { pageResources, renderPage } from "../../components/renderPage"
import { ProcessedContent, defaultProcessedContent } from "../vfile" import { ProcessedContent, defaultProcessedContent } from "../vfile"
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
import { import { FilePath, FullSlug, getAllSegmentPrefixes, joinSegments } from "../../util/path"
CanonicalSlug,
FilePath,
ServerSlug,
getAllSegmentPrefixes,
joinSegments,
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components" import { TagContent } from "../../components"
@ -49,7 +43,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
return [ return [
tag, tag,
defaultProcessedContent({ defaultProcessedContent({
slug: joinSegments("tags", tag) as ServerSlug, slug: joinSegments("tags", tag) as FullSlug,
frontmatter: { title, tags: [] }, frontmatter: { title, tags: [] },
}), }),
] ]
@ -67,7 +61,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
} }
for (const tag of tags) { 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 externalResources = pageResources(slug, resources)
const [tree, file] = tagDescriptions[tag] const [tree, file] = tagDescriptions[tag]
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {

View file

@ -1,5 +1,5 @@
import { StaticResources } from "../util/resources" import { StaticResources } from "../util/resources"
import { FilePath, ServerSlug } from "../util/path" import { FilePath, FullSlug } from "../util/path"
import { BuildCtx } from "../util/ctx" import { BuildCtx } from "../util/ctx"
export function getStaticResourcesFromPlugins(ctx: BuildCtx) { export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
@ -28,7 +28,7 @@ export * from "./emitters"
declare module "vfile" { declare module "vfile" {
// inserted in processors.ts // inserted in processors.ts
interface DataMap { interface DataMap {
slug: ServerSlug slug: FullSlug
filePath: FilePath filePath: FilePath
} }
} }

View file

@ -1,11 +1,12 @@
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import { import {
CanonicalSlug, FullSlug,
RelativeURL, RelativeURL,
SimpleSlug,
TransformOptions, TransformOptions,
_stripSlashes, _stripSlashes,
canonicalizeServer,
joinSegments, joinSegments,
simplifySlug,
splitAnchor, splitAnchor,
transformLink, transformLink,
} from "../../util/path" } from "../../util/path"
@ -33,8 +34,8 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
return [ return [
() => { () => {
return (tree, file) => { return (tree, file) => {
const curSlug = canonicalizeServer(file.data.slug!) const curSlug = simplifySlug(file.data.slug!)
const outgoing: Set<CanonicalSlug> = new Set() const outgoing: Set<SimpleSlug> = new Set()
const transformOptions: TransformOptions = { const transformOptions: TransformOptions = {
strategy: opts.markdownLinkResolution, strategy: opts.markdownLinkResolution,
@ -54,10 +55,15 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
// don't process external links or intra-document anchors // don't process external links or intra-document anchors
if (!(isAbsoluteUrl(dest) || dest.startsWith("#"))) { 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 canonicalDest = path.posix.normalize(joinSegments(curSlug, dest))
const [destCanonical, _destAnchor] = splitAnchor(canonicalDest) 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 // rewrite link internals if prettylinks is on
@ -79,7 +85,11 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
) { ) {
if (!isAbsoluteUrl(node.properties.src)) { if (!isAbsoluteUrl(node.properties.src)) {
let dest = node.properties.src as RelativeURL 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 node.properties.src = dest
} }
} }
@ -95,6 +105,6 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
declare module "vfile" { declare module "vfile" {
interface DataMap { interface DataMap {
links: CanonicalSlug[] links: SimpleSlug[]
} }
} }

View file

@ -9,7 +9,7 @@ import path from "path"
import { JSResource } from "../../util/resources" import { JSResource } from "../../util/resources"
// @ts-ignore // @ts-ignore
import calloutScript from "../../components/scripts/callout.inline.ts" 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 { toHast } from "mdast-util-to-hast"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import { PhrasingContent } from "mdast-util-find-and-replace/lib" import { PhrasingContent } from "mdast-util-find-and-replace/lib"
@ -381,8 +381,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (opts.parseTags) { if (opts.parseTags) {
plugins.push(() => { plugins.push(() => {
return (tree: Root, file) => { return (tree: Root, file) => {
const slug = canonicalizeServer(file.data.slug!) const base = pathToRoot(file.data.slug!)
const base = pathToRoot(slug)
findAndReplace(tree, tagRegex, (value: string, tag: string) => { findAndReplace(tree, tagRegex, (value: string, tag: string) => {
if (file.data.frontmatter) { if (file.data.frontmatter) {
file.data.frontmatter.tags.push(tag) file.data.frontmatter.tags.push(tag)

View file

@ -2,7 +2,7 @@ import { PluggableList } from "unified"
import { StaticResources } from "../util/resources" import { StaticResources } from "../util/resources"
import { ProcessedContent } from "./vfile" import { ProcessedContent } from "./vfile"
import { QuartzComponent } from "../components/types" import { QuartzComponent } from "../components/types"
import { FilePath, ServerSlug } from "../util/path" import { FilePath, FullSlug } from "../util/path"
import { BuildCtx } from "../util/ctx" import { BuildCtx } from "../util/ctx"
export interface PluginTypes { export interface PluginTypes {
@ -46,7 +46,7 @@ export type QuartzEmitterPluginInstance = {
} }
export interface EmitOptions { export interface EmitOptions {
slug: ServerSlug slug: FullSlug
ext: `.${string}` | "" ext: `.${string}` | ""
content: string content: string
} }

View file

@ -1,5 +1,5 @@
import { QuartzConfig } from "../cfg" import { QuartzConfig } from "../cfg"
import { ServerSlug } from "./path" import { FullSlug } from "./path"
export interface Argv { export interface Argv {
directory: string directory: string
@ -13,5 +13,5 @@ export interface Argv {
export interface BuildCtx { export interface BuildCtx {
argv: Argv argv: Argv
cfg: QuartzConfig cfg: QuartzConfig
allSlugs: ServerSlug[] allSlugs: FullSlug[]
} }

View file

@ -1,45 +1,25 @@
import test, { describe } from "node:test" import test, { describe } from "node:test"
import * as path from "./path" import * as path from "./path"
import assert from "node:assert" import assert from "node:assert"
import { CanonicalSlug, ServerSlug, TransformOptions } from "./path" import { FullSlug, TransformOptions } from "./path"
describe("typeguards", () => { describe("typeguards", () => {
test("isClientSlug", () => { test("isSimpleSlug", () => {
assert(path.isClientSlug("http://example.com")) assert(path.isSimpleSlug(""))
assert(path.isClientSlug("http://example.com/index")) assert(path.isSimpleSlug("abc"))
assert(path.isClientSlug("http://example.com/index.html")) assert(path.isSimpleSlug("abc/"))
assert(path.isClientSlug("http://example.com/")) assert(path.isSimpleSlug("notindex"))
assert(path.isClientSlug("https://example.com")) assert(path.isSimpleSlug("notindex/def"))
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"))
assert(!path.isClientSlug("./")) assert(!path.isSimpleSlug("//"))
assert(!path.isClientSlug("")) assert(!path.isSimpleSlug("index"))
assert(!path.isClientSlug("ipfs://example.com")) assert(!path.isSimpleSlug("https://example.com"))
assert(!path.isClientSlug("http")) assert(!path.isSimpleSlug("/abc"))
assert(!path.isClientSlug("https")) assert(!path.isSimpleSlug("abc/index"))
}) assert(!path.isSimpleSlug("abc#anchor"))
assert(!path.isSimpleSlug("abc?query=1"))
test("isCanonicalSlug", () => { assert(!path.isSimpleSlug("index.md"))
assert(path.isCanonicalSlug("")) assert(!path.isSimpleSlug("index.html"))
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"))
}) })
test("isRelativeURL", () => { test("isRelativeURL", () => {
@ -58,18 +38,18 @@ describe("typeguards", () => {
assert(!path.isRelativeURL("./abc/def.md")) assert(!path.isRelativeURL("./abc/def.md"))
}) })
test("isServerSlug", () => { test("isFullSlug", () => {
assert(path.isServerSlug("index")) assert(path.isFullSlug("index"))
assert(path.isServerSlug("abc/def")) assert(path.isFullSlug("abc/def"))
assert(path.isServerSlug("html.energy")) assert(path.isFullSlug("html.energy"))
assert(path.isServerSlug("test.pdf")) assert(path.isFullSlug("test.pdf"))
assert(!path.isServerSlug(".")) assert(!path.isFullSlug("."))
assert(!path.isServerSlug("./abc/def")) assert(!path.isFullSlug("./abc/def"))
assert(!path.isServerSlug("../abc/def")) assert(!path.isFullSlug("../abc/def"))
assert(!path.isServerSlug("abc/def#anchor")) assert(!path.isFullSlug("abc/def#anchor"))
assert(!path.isServerSlug("abc/def?query=1")) assert(!path.isFullSlug("abc/def?query=1"))
assert(!path.isServerSlug("note with spaces")) assert(!path.isFullSlug("note with spaces"))
}) })
test("isFilePath", () => { test("isFilePath", () => {
@ -100,40 +80,17 @@ describe("transforms", () => {
} }
} }
test("canonicalizeServer", () => { test("simplifySlug", () => {
asserts( asserts(
[ [
["index", ""], ["index", ""],
["abc/index", "abc"], ["abc", "abc"],
["abc/index", "abc/"],
["abc/def", "abc/def"], ["abc/def", "abc/def"],
], ],
path.canonicalizeServer, path.simplifySlug,
path.isServerSlug, path.isFullSlug,
path.isCanonicalSlug, path.isSimpleSlug,
)
})
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,
) )
}) })
@ -151,7 +108,7 @@ describe("transforms", () => {
], ],
path.slugifyFilePath, path.slugifyFilePath,
path.isFilePath, path.isFilePath,
path.isServerSlug, path.isFullSlug,
) )
}) })
@ -186,12 +143,14 @@ describe("transforms", () => {
test("pathToRoot", () => { test("pathToRoot", () => {
asserts( asserts(
[ [
["", "."], ["index", "."],
["abc", ".."], ["abc", "."],
["abc/def", "../.."], ["abc/def", ".."],
["abc/def/ghi", "../.."],
["abc/def/index", "../.."],
], ],
path.pathToRoot, path.pathToRoot,
path.isCanonicalSlug, path.isFullSlug,
path.isRelativeURL, path.isRelativeURL,
) )
}) })
@ -206,7 +165,7 @@ describe("link strategies", () => {
"e/g/h", "e/g/h",
"index", "index",
"a/test.png", "a/test.png",
] as ServerSlug[] ] as FullSlug[]
describe("absolute", () => { describe("absolute", () => {
const opts: TransformOptions = { const opts: TransformOptions = {
@ -215,28 +174,28 @@ describe("link strategies", () => {
} }
test("from a/b/c", () => { test("from a/b/c", () => {
const cur = "a/b/c" as CanonicalSlug 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/d", opts), "../../a/b/d")
assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../../a/b/") 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/f", opts), "../../e/f")
assert.strictEqual(path.transformLink(cur, "e/g/h", opts), "../../../e/g/h") 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", opts), "../../")
assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../../index.png") assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../index.png")
assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../../#abc") assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../#abc")
assert.strictEqual(path.transformLink(cur, "tag/test", opts), "../../../tag/test") 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/b/c#test", opts), "../../a/b/c#test")
assert.strictEqual(path.transformLink(cur, "a/test.png", opts), "../../../a/test.png") assert.strictEqual(path.transformLink(cur, "a/test.png", opts), "../../a/test.png")
}) })
test("from a/b/index", () => { 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/d", opts), "../../a/b/d")
assert.strictEqual(path.transformLink(cur, "a/b", opts), "../../a/b") assert.strictEqual(path.transformLink(cur, "a/b", opts), "../../a/b")
assert.strictEqual(path.transformLink(cur, "index", opts), "../../") assert.strictEqual(path.transformLink(cur, "index", opts), "../../")
}) })
test("from index", () => { test("from index", () => {
const cur = "" as CanonicalSlug const cur = "index" as FullSlug
assert.strictEqual(path.transformLink(cur, "index", opts), "./") 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/c", opts), "./a/b/c")
assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/") assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/")
@ -250,20 +209,20 @@ describe("link strategies", () => {
} }
test("from a/b/c", () => { 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), "../../../a/b/d") 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, "h", opts), "../../e/g/h")
assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../../a/b/") 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.png", opts), "../../a/b/index.png")
assert.strictEqual(path.transformLink(cur, "a/b/index#abc", opts), "../../../a/b/#abc") 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", opts), "../../")
assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../../index.png") 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, "test.png", opts), "../../a/test.png")
assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../../#abc") assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../#abc")
}) })
test("from a/b/index", () => { 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, "d", opts), "../../a/b/d")
assert.strictEqual(path.transformLink(cur, "h", opts), "../../e/g/h") 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", opts), "../../a/b/")
@ -271,7 +230,7 @@ describe("link strategies", () => {
}) })
test("from index", () => { 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, "d", opts), "./a/b/d")
assert.strictEqual(path.transformLink(cur, "h", opts), "./e/g/h") 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", opts), "./a/b/")
@ -286,7 +245,7 @@ describe("link strategies", () => {
} }
test("from a/b/c", () => { 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, "d", opts), "./d")
assert.strictEqual(path.transformLink(cur, "index", opts), "./") assert.strictEqual(path.transformLink(cur, "index", opts), "./")
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", () => { 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, "../../index", opts), "../../")
assert.strictEqual(path.transformLink(cur, "../../", opts), "../../") assert.strictEqual(path.transformLink(cur, "../../", opts), "../../")
assert.strictEqual(path.transformLink(cur, "../../e/g/h", opts), "../../e/g/h") assert.strictEqual(path.transformLink(cur, "../../e/g/h", opts), "../../e/g/h")
@ -311,7 +270,7 @@ describe("link strategies", () => {
}) })
test("from index", () => { 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, "e/g/h", opts), "./e/g/h")
assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/") assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/")
}) })

View file

@ -1,73 +1,35 @@
import { slug } from "github-slugger" import { slug } from "github-slugger"
// this file must be isomorphic so it can't use node libs (e.g. path) // 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" export const QUARTZ = "quartz"
/// Utility type to simulate nominal types in TypeScript /// Utility type to simulate nominal types in TypeScript
type SlugLike<T> = string & { __brand: T } type SlugLike<T> = string & { __brand: T }
/** Client-side slug, usually obtained through `window.location` */ /** Cannot be relative and must have a file extension. */
export type ClientSlug = SlugLike<"client"> export type FilePath = SlugLike<"filepath">
export function isClientSlug(s: string): s is ClientSlug { export function isFilePath(s: string): s is FilePath {
const res = /^https?:\/\/.+/.test(s) const validStart = !s.startsWith(".")
return res return validStart && _hasFileExtension(s)
} }
/** Canonical slug, should be used whenever you need to refer to the location of a file/note. /** 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. */
* On the client, this is normally stored in `document.body.dataset.slug` export type FullSlug = SlugLike<"full">
*/ export function isFullSlug(s: string): s is FullSlug {
export type CanonicalSlug = SlugLike<"canonical">
export function isCanonicalSlug(s: string): s is CanonicalSlug {
const validStart = !(s.startsWith(".") || s.startsWith("/")) 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) return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s)
} }
/** A relative link, can be found on `href`s but can also be constructed for /** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */
* client-side navigation (e.g. search and graph)
*/
export type RelativeURL = SlugLike<"relative"> export type RelativeURL = SlugLike<"relative">
export function isRelativeURL(s: string): s is RelativeURL { export function isRelativeURL(s: string): s is RelativeURL {
const validStart = /^\.{1,2}/.test(s) 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) ?? "") 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 function getFullSlug(window: Window): FullSlug {
export type ServerSlug = SlugLike<"server"> const res = window.document.body.dataset.slug! as FullSlug
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
return res return res
} }
export function getCanonicalSlug(window: Window): CanonicalSlug { export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
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 {
fp = _stripSlashes(fp) as FilePath fp = _stripSlashes(fp) as FilePath
let ext = _getFileExtension(fp) let ext = _getFileExtension(fp)
const withoutFileExt = fp.replace(new RegExp(ext + "$"), "") const withoutFileExt = fp.replace(new RegExp(ext + "$"), "")
@ -133,44 +61,47 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): ServerSlug
slug = slug.replace(/_index$/, "index") 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 { export function transformInternalLink(link: string): RelativeURL {
let [fplike, anchor] = splitAnchor(decodeURI(link)) let [fplike, anchor] = splitAnchor(decodeURI(link))
const folderPath = const folderPath = _isFolderPath(fplike)
fplike.endsWith("index") ||
fplike.endsWith("index.md") ||
fplike.endsWith("index.html") ||
fplike.endsWith("/")
let segments = fplike.split("/").filter((x) => x.length > 0) let segments = fplike.split("/").filter((x) => x.length > 0)
let prefix = segments.filter(_isRelativeSegment).join("/") 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 // 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 simpleSlug = simplifySlug(slugifyFilePath(fp as FilePath))
const joined = joinSegments(_stripSlashes(prefix), _stripSlashes(fp)) const joined = joinSegments(_stripSlashes(prefix), _stripSlashes(simpleSlug))
const trail = folderPath ? "/" : "" const trail = folderPath ? "/" : ""
const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL
return res return res
} }
// resolve /a/b/c to ../../.. // resolve /a/b/c to ../..
export function pathToRoot(slug: CanonicalSlug): RelativeURL { export function pathToRoot(slug: FullSlug): RelativeURL {
let rootPath = slug let rootPath = slug
.split("/") .split("/")
.filter((x) => x !== "") .filter((x) => x !== "")
.slice(0, -1)
.map((_) => "..") .map((_) => "..")
.join("/") .join("/")
const res = _addRelativeToStart(rootPath) as RelativeURL if (rootPath.length === 0) {
return res rootPath = "."
}
return rootPath as RelativeURL
} }
export function resolveRelative(current: CanonicalSlug, target: CanonicalSlug): RelativeURL { export function resolveRelative(current: FullSlug, target: FullSlug | SimpleSlug): RelativeURL {
const res = joinSegments(pathToRoot(current), target) as RelativeURL const res = joinSegments(pathToRoot(current), simplifySlug(target as FullSlug)) as RelativeURL
return res return res
} }
@ -206,20 +137,16 @@ export function getAllSegmentPrefixes(tags: string): string[] {
export interface TransformOptions { export interface TransformOptions {
strategy: "absolute" | "relative" | "shortest" strategy: "absolute" | "relative" | "shortest"
allSlugs: ServerSlug[] allSlugs: FullSlug[]
} }
export function transformLink( export function transformLink(src: FullSlug, target: string, opts: TransformOptions): RelativeURL {
src: CanonicalSlug, let targetSlug = transformInternalLink(target)
target: string,
opts: TransformOptions,
): RelativeURL {
let targetSlug: string = transformInternalLink(target)
if (opts.strategy === "relative") { if (opts.strategy === "relative") {
return _addRelativeToStart(targetSlug) as RelativeURL return targetSlug as RelativeURL
} else { } else {
const folderTail = targetSlug.endsWith("/") ? "/" : "" const folderTail = _isFolderPath(targetSlug) ? "/" : ""
const canonicalSlug = _stripSlashes(targetSlug.slice(".".length)) const canonicalSlug = _stripSlashes(targetSlug.slice(".".length))
let [targetCanonical, targetAnchor] = splitAnchor(canonicalSlug) let [targetCanonical, targetAnchor] = splitAnchor(canonicalSlug)
@ -233,7 +160,7 @@ export function transformLink(
// only match, just use it // only match, just use it
if (matchingFileNames.length === 1) { if (matchingFileNames.length === 1) {
const targetSlug = canonicalizeServer(matchingFileNames[0]) const targetSlug = matchingFileNames[0]
return (resolveRelative(src, targetSlug) + targetAnchor) as RelativeURL return (resolveRelative(src, targetSlug) + targetAnchor) as RelativeURL
} }
} }
@ -243,9 +170,13 @@ export function transformLink(
} }
} }
function _canonicalize(fp: string): string { function _isFolderPath(fplike: string): boolean {
fp = _trimSuffix(fp, "index") return (
return _stripSlashes(fp) fplike.endsWith("/") ||
_endsWith(fplike, "index") ||
_endsWith(fplike, "index.md") ||
_endsWith(fplike, "index.html")
)
} }
function _endsWith(s: string, suffix: string): boolean { function _endsWith(s: string, suffix: string): boolean {
@ -275,12 +206,12 @@ function _isRelativeSegment(s: string): boolean {
return /^\.{0,2}$/.test(s) return /^\.{0,2}$/.test(s)
} }
export function _stripSlashes(s: string): string { export function _stripSlashes(s: string, onlyStripPrefix?: boolean): string {
if (s.startsWith("/")) { if (s.startsWith("/")) {
s = s.substring(1) s = s.substring(1)
} }
if (s.endsWith("/")) { if (!onlyStripPrefix && s.endsWith("/")) {
s = s.slice(0, -1) s = s.slice(0, -1)
} }

View file

@ -2,12 +2,12 @@ import sourceMapSupport from "source-map-support"
sourceMapSupport.install(options) sourceMapSupport.install(options)
import cfg from "../quartz.config" import cfg from "../quartz.config"
import { Argv, BuildCtx } from "./util/ctx" 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 { createFileParser, createProcessor } from "./processors/parse"
import { options } from "./util/sourcemap" import { options } from "./util/sourcemap"
// only called from worker thread // 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 = { const ctx: BuildCtx = {
cfg, cfg,
argv, argv,