base path refactor to better support subpath hosting
| | |
| | | |
| | | 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. |
| | | |
| | |
| | | |
| | | ```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. |
| | |
| | | |
| | | ```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`. |
| | |
| | | |
| | | // dom custom event |
| | | interface CustomEventMap { |
| | | nav: CustomEvent<{ url: CanonicalSlug }> |
| | | nav: CustomEvent<{ url: FullSlug }> |
| | | } |
| | | |
| | | declare const fetchData: Promise<ContentIndex> |
| | |
| | | "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", |
| | |
| | | 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"> |
| | |
| | | {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> |
| | |
| | | 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` |
| | | |
| | |
| | | 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" |
| | |
| | | } & 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) |
| | |
| | | <ul class="section-ul"> |
| | | {list.map((page) => { |
| | | const title = page.frontmatter?.title |
| | | const pageSlug = canonicalizeServer(page.slug!) |
| | | const tags = page.frontmatter?.tags ?? [] |
| | | |
| | | return ( |
| | |
| | | )} |
| | | <div class="desc"> |
| | | <h3> |
| | | <a href={resolveRelative(slug, pageSlug)} class="internal"> |
| | | <a href={resolveRelative(fileData.slug!, page.slug!)} class="internal"> |
| | | {title} |
| | | </a> |
| | | </h3> |
| | |
| | | <li> |
| | | <a |
| | | class="internal tag-link" |
| | | href={resolveRelative(slug, `tags/${tag}` as CanonicalSlug)} |
| | | href={resolveRelative(fileData.slug!, `tags/${tag}/index` as FullSlug)} |
| | | > |
| | | #{tag} |
| | | </a> |
| | |
| | | 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> |
| | |
| | | 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"> |
| | |
| | | |
| | | 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) |
| | |
| | | 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 |
| | |
| | | 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), |
| | |
| | | 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 |
| | |
| | | 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", |
| | |
| | | } |
| | | |
| | | export function renderPage( |
| | | slug: CanonicalSlug, |
| | | slug: FullSlug, |
| | | componentData: QuartzComponentProps, |
| | | components: RenderComponents, |
| | | pageResources: StaticResources, |
| | |
| | | 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 |
| | |
| | | |
| | | 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 |
| | |
| | | } |
| | | } |
| | | } 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[] } = { |
| | |
| | | .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)) |
| | |
| | | } |
| | | |
| | | 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") |
| | |
| | | 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 |
| | | } |
| | |
| | | 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") |
| | |
| | | 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 |
| | | } |
| | |
| | | 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, |
| | | }) |
| | |
| | | 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 |
| | |
| | | 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) |
| | | } |
| | |
| | | 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 |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | createRouter() |
| | | notifyNav(getCanonicalSlug(window)) |
| | | notifyNav(getFullSlug(window)) |
| | | |
| | | if (!customElements.get("route-announcer")) { |
| | | const attrs = { |
| | |
| | | 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" |
| | | |
| | |
| | | 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) { |
| | |
| | | } |
| | | |
| | | 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> |
| | |
| | | import { FilePath, ServerSlug } from "../../util/path" |
| | | import { FilePath, FullSlug } from "../../util/path" |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | |
| | | // @ts-ignore |
| | |
| | | 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", |
| | |
| | | }).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, |
| | | }), |
| | |
| | | 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 |
| | |
| | | |
| | | 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> |
| | |
| | | </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> |
| | |
| | | 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, { |
| | |
| | | emitted.push( |
| | | await emit({ |
| | | content: generateSiteMap(cfg, linkIndex), |
| | | slug: "sitemap" as ServerSlug, |
| | | slug: "sitemap" as FullSlug, |
| | | ext: ".xml", |
| | | }), |
| | | ) |
| | |
| | | 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 |
| | |
| | | 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" |
| | | |
| | |
| | | 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, |
| | |
| | | const content = renderPage(slug, componentData, opts, externalResources) |
| | | const fp = await emit({ |
| | | content, |
| | | slug: file.data.slug!, |
| | | slug, |
| | | ext: ".html", |
| | | }) |
| | | |
| | |
| | | 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" |
| | | |
| | |
| | | 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] |
| | | } |
| | |
| | | [...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 = { |
| | |
| | | const content = renderPage(slug, componentData, opts, externalResources) |
| | | const fp = await emit({ |
| | | content, |
| | | slug: file.data.slug!, |
| | | slug, |
| | | ext: ".html", |
| | | }) |
| | | |
| | |
| | | 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" |
| | | |
| | |
| | | return [ |
| | | tag, |
| | | defaultProcessedContent({ |
| | | slug: joinSegments("tags", tag) as ServerSlug, |
| | | slug: joinSegments("tags", tag) as FullSlug, |
| | | frontmatter: { title, 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 [tree, file] = tagDescriptions[tag] |
| | | const componentData: QuartzComponentProps = { |
| | |
| | | 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) { |
| | |
| | | declare module "vfile" { |
| | | // inserted in processors.ts |
| | | interface DataMap { |
| | | slug: ServerSlug |
| | | slug: FullSlug |
| | | filePath: FilePath |
| | | } |
| | | } |
| | |
| | | import { QuartzTransformerPlugin } from "../types" |
| | | import { |
| | | CanonicalSlug, |
| | | FullSlug, |
| | | RelativeURL, |
| | | SimpleSlug, |
| | | TransformOptions, |
| | | _stripSlashes, |
| | | canonicalizeServer, |
| | | joinSegments, |
| | | simplifySlug, |
| | | splitAnchor, |
| | | transformLink, |
| | | } from "../../util/path" |
| | |
| | | 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, |
| | |
| | | |
| | | // 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 |
| | |
| | | ) { |
| | | 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 |
| | | } |
| | | } |
| | |
| | | |
| | | declare module "vfile" { |
| | | interface DataMap { |
| | | links: CanonicalSlug[] |
| | | links: SimpleSlug[] |
| | | } |
| | | } |
| | |
| | | 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" |
| | |
| | | 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) |
| | |
| | | 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 { |
| | |
| | | } |
| | | |
| | | export interface EmitOptions { |
| | | slug: ServerSlug |
| | | slug: FullSlug |
| | | ext: `.${string}` | "" |
| | | content: string |
| | | } |
| | |
| | | import { QuartzConfig } from "../cfg" |
| | | import { ServerSlug } from "./path" |
| | | import { FullSlug } from "./path" |
| | | |
| | | export interface Argv { |
| | | directory: string |
| | |
| | | export interface BuildCtx { |
| | | argv: Argv |
| | | cfg: QuartzConfig |
| | | allSlugs: ServerSlug[] |
| | | allSlugs: FullSlug[] |
| | | } |
| | |
| | | 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", () => { |
| | |
| | | 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", () => { |
| | |
| | | } |
| | | } |
| | | |
| | | 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, |
| | | ) |
| | | }) |
| | | |
| | |
| | | ], |
| | | path.slugifyFilePath, |
| | | path.isFilePath, |
| | | path.isServerSlug, |
| | | path.isFullSlug, |
| | | ) |
| | | }) |
| | | |
| | |
| | | test("pathToRoot", () => { |
| | | asserts( |
| | | [ |
| | | ["", "."], |
| | | ["abc", ".."], |
| | | ["abc/def", "../.."], |
| | | ["index", "."], |
| | | ["abc", "."], |
| | | ["abc/def", ".."], |
| | | ["abc/def/ghi", "../.."], |
| | | ["abc/def/index", "../.."], |
| | | ], |
| | | path.pathToRoot, |
| | | path.isCanonicalSlug, |
| | | path.isFullSlug, |
| | | path.isRelativeURL, |
| | | ) |
| | | }) |
| | |
| | | "e/g/h", |
| | | "index", |
| | | "a/test.png", |
| | | ] as ServerSlug[] |
| | | ] as FullSlug[] |
| | | |
| | | describe("absolute", () => { |
| | | const opts: TransformOptions = { |
| | |
| | | } |
| | | |
| | | 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/") |
| | |
| | | } |
| | | |
| | | 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/") |
| | |
| | | }) |
| | | |
| | | 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/") |
| | |
| | | } |
| | | |
| | | 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), "../../../") |
| | |
| | | }) |
| | | |
| | | 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") |
| | |
| | | }) |
| | | |
| | | 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/") |
| | | }) |
| | |
| | | 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) |
| | |
| | | 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 + "$"), "") |
| | |
| | | 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 = "." |
| | | } |
| | | |
| | | export function resolveRelative(current: CanonicalSlug, target: CanonicalSlug): RelativeURL { |
| | | const res = joinSegments(pathToRoot(current), target) as RelativeURL |
| | | return rootPath as RelativeURL |
| | | } |
| | | |
| | | export function resolveRelative(current: FullSlug, target: FullSlug | SimpleSlug): RelativeURL { |
| | | const res = joinSegments(pathToRoot(current), simplifySlug(target as FullSlug)) as RelativeURL |
| | | return res |
| | | } |
| | | |
| | |
| | | |
| | | 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) |
| | | |
| | |
| | | |
| | | // 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 |
| | | } |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | 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 { |
| | |
| | | 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) |
| | | } |
| | | |
| | |
| | | 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, |