- {pages.length} items with this tag.{" "}
+ {pluralize(pages.length, "item")} with this tag.{" "}
{pages.length > numPages && `Showing first ${numPages}.`}
@@ -80,7 +83,7 @@ function TagContent(props: QuartzComponentProps) {
return (
{content}
-
{pages.length} items with this tag.
+
{pluralize(pages.length, "item")} with this tag.
diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx
index 7ea2a89bf8d14..1fba7302f8bd5 100644
--- a/quartz/components/renderPage.tsx
+++ b/quartz/components/renderPage.tsx
@@ -3,7 +3,10 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header"
import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
-import { FullSlug, joinSegments, pathToRoot } from "../util/path"
+import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
+import { visit } from "unist-util-visit"
+import { Root, Element, ElementContent } from "hast"
+import { QuartzPluginData } from "../plugins/vfile"
interface RenderComponents {
head: QuartzComponent
@@ -15,11 +18,12 @@ interface RenderComponents {
footer: QuartzComponent
}
-export function pageResources(slug: FullSlug, staticResources: StaticResources): StaticResources {
- const baseDir = pathToRoot(slug)
-
+export function pageResources(
+ baseDir: FullSlug | RelativeURL,
+ staticResources: StaticResources,
+): StaticResources {
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 {
css: [joinSegments(baseDir, "index.css"), ...staticResources.css],
@@ -46,12 +50,121 @@ export function pageResources(slug: FullSlug, staticResources: StaticResources):
}
}
+let pageIndex: Map
| undefined = undefined
+function getOrComputeFileIndex(allFiles: QuartzPluginData[]): Map {
+ if (!pageIndex) {
+ pageIndex = new Map()
+ for (const file of allFiles) {
+ pageIndex.set(file.slug!, file)
+ }
+ }
+
+ return pageIndex
+}
+
export function renderPage(
slug: FullSlug,
componentData: QuartzComponentProps,
components: RenderComponents,
pageResources: StaticResources,
): string {
+ // process transcludes in componentData
+ visit(componentData.tree as Root, "element", (node, _index, _parent) => {
+ if (node.tagName === "blockquote") {
+ const classNames = (node.properties?.className ?? []) as string[]
+ if (classNames.includes("transclude")) {
+ const inner = node.children[0] as Element
+ const transcludeTarget = inner.properties["data-slug"] as FullSlug
+ const page = getOrComputeFileIndex(componentData.allFiles).get(transcludeTarget)
+ if (!page) {
+ return
+ }
+
+ let blockRef = node.properties.dataBlock as string | undefined
+ if (blockRef?.startsWith("#^")) {
+ // block transclude
+ blockRef = blockRef.slice("#^".length)
+ let blockNode = page.blocks?.[blockRef]
+ if (blockNode) {
+ if (blockNode.tagName === "li") {
+ blockNode = {
+ type: "element",
+ tagName: "ul",
+ properties: {},
+ children: [blockNode],
+ }
+ }
+
+ node.children = [
+ normalizeHastElement(blockNode, slug, transcludeTarget),
+ {
+ type: "element",
+ tagName: "a",
+ properties: { href: inner.properties?.href, class: ["internal"] },
+ children: [{ type: "text", value: `Link to original` }],
+ },
+ ]
+ }
+ } else if (blockRef?.startsWith("#") && page.htmlAst) {
+ // header transclude
+ blockRef = blockRef.slice(1)
+ let startIdx = undefined
+ let endIdx = undefined
+ for (const [i, el] of page.htmlAst.children.entries()) {
+ if (el.type === "element" && el.tagName.match(/h[1-6]/)) {
+ if (endIdx) {
+ break
+ }
+
+ if (startIdx !== undefined) {
+ endIdx = i
+ } else if (el.properties?.id === blockRef) {
+ startIdx = i
+ }
+ }
+ }
+
+ if (startIdx === undefined) {
+ return
+ }
+
+ node.children = [
+ ...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((child) =>
+ normalizeHastElement(child as Element, slug, transcludeTarget),
+ ),
+ {
+ type: "element",
+ tagName: "a",
+ properties: { href: inner.properties?.href, class: ["internal"] },
+ children: [{ type: "text", value: `Link to original` }],
+ },
+ ]
+ } else if (page.htmlAst) {
+ // page transclude
+ node.children = [
+ {
+ type: "element",
+ tagName: "h1",
+ properties: {},
+ children: [
+ { type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
+ ],
+ },
+ ...(page.htmlAst.children as ElementContent[]).map((child) =>
+ normalizeHastElement(child as Element, slug, transcludeTarget),
+ ),
+ {
+ type: "element",
+ tagName: "a",
+ properties: { href: inner.properties?.href, class: ["internal"] },
+ children: [{ type: "text", value: `Link to original` }],
+ },
+ ]
+ }
+ }
+ }
+ })
+
const {
head: Head,
header,
diff --git a/quartz/components/scripts/darkmode.inline.ts b/quartz/components/scripts/darkmode.inline.ts
index e16f4f845d4b4..c42a367c9103f 100644
--- a/quartz/components/scripts/darkmode.inline.ts
+++ b/quartz/components/scripts/darkmode.inline.ts
@@ -20,4 +20,13 @@ document.addEventListener("nav", () => {
if (currentTheme === "dark") {
toggleSwitch.checked = true
}
+
+ // Listen for changes in prefers-color-scheme
+ const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
+ colorSchemeMediaQuery.addEventListener("change", (e) => {
+ const newTheme = e.matches ? "dark" : "light"
+ document.documentElement.setAttribute("saved-theme", newTheme)
+ localStorage.setItem("theme", newTheme)
+ toggleSwitch.checked = e.matches
+ })
})
diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts
new file mode 100644
index 0000000000000..8e79d200ebd49
--- /dev/null
+++ b/quartz/components/scripts/explorer.inline.ts
@@ -0,0 +1,162 @@
+import { FolderState } from "../ExplorerNode"
+
+// Current state of folders
+let explorerState: FolderState[]
+
+const observer = new IntersectionObserver((entries) => {
+ // If last element is observed, remove gradient of "overflow" class so element is visible
+ const explorer = document.getElementById("explorer-ul")
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ explorer?.classList.add("no-background")
+ } else {
+ explorer?.classList.remove("no-background")
+ }
+ }
+})
+
+function toggleExplorer(this: HTMLElement) {
+ // Toggle collapsed state of entire explorer
+ this.classList.toggle("collapsed")
+ const content = this.nextElementSibling as HTMLElement
+ content.classList.toggle("collapsed")
+ content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
+}
+
+function toggleFolder(evt: MouseEvent) {
+ evt.stopPropagation()
+
+ // Element that was clicked
+ const target = evt.target as HTMLElement
+
+ // Check if target was svg icon or button
+ const isSvg = target.nodeName === "svg"
+
+ // corresponding element relative to clicked button/folder
+ let childFolderContainer: HTMLElement
+
+ // - element of folder (stores folder-path dataset)
+ let currentFolderParent: HTMLElement
+
+ // Get correct relative container and toggle collapsed class
+ if (isSvg) {
+ childFolderContainer = target.parentElement?.nextSibling as HTMLElement
+ currentFolderParent = target.nextElementSibling as HTMLElement
+
+ childFolderContainer.classList.toggle("open")
+ } else {
+ childFolderContainer = target.parentElement?.parentElement?.nextElementSibling as HTMLElement
+ currentFolderParent = target.parentElement as HTMLElement
+
+ childFolderContainer.classList.toggle("open")
+ }
+ if (!childFolderContainer) return
+
+ // Collapse folder container
+ const isCollapsed = childFolderContainer.classList.contains("open")
+ setFolderState(childFolderContainer, !isCollapsed)
+
+ // Save folder state to localStorage
+ const clickFolderPath = currentFolderParent.dataset.folderpath as string
+
+ const fullFolderPath = clickFolderPath
+ toggleCollapsedByPath(explorerState, fullFolderPath)
+
+ const stringifiedFileTree = JSON.stringify(explorerState)
+ localStorage.setItem("fileTree", stringifiedFileTree)
+}
+
+function setupExplorer() {
+ // Set click handler for collapsing entire explorer
+ const explorer = document.getElementById("explorer")
+
+ // Get folder state from local storage
+ const storageTree = localStorage.getItem("fileTree")
+
+ // Convert to bool
+ const useSavedFolderState = explorer?.dataset.savestate === "true"
+
+ if (explorer) {
+ // Get config
+ const collapseBehavior = explorer.dataset.behavior
+
+ // Add click handlers for all folders (click handler on folder "label")
+ if (collapseBehavior === "collapse") {
+ Array.prototype.forEach.call(
+ document.getElementsByClassName("folder-button"),
+ function (item) {
+ item.removeEventListener("click", toggleFolder)
+ item.addEventListener("click", toggleFolder)
+ },
+ )
+ }
+
+ // Add click handler to main explorer
+ explorer.removeEventListener("click", toggleExplorer)
+ explorer.addEventListener("click", toggleExplorer)
+ }
+
+ // Set up click handlers for each folder (click handler on folder "icon")
+ Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) {
+ item.removeEventListener("click", toggleFolder)
+ item.addEventListener("click", toggleFolder)
+ })
+
+ if (storageTree && useSavedFolderState) {
+ // Get state from localStorage and set folder state
+ explorerState = JSON.parse(storageTree)
+ explorerState.map((folderUl) => {
+ // grab
- element for matching folder path
+ const folderLi = document.querySelector(`[data-folderpath='${folderUl.path}']`) as HTMLElement
+
+ // Get corresponding content
tag and set state
+ if (folderLi) {
+ const folderUL = folderLi.parentElement?.nextElementSibling
+ if (folderUL) {
+ setFolderState(folderUL as HTMLElement, folderUl.collapsed)
+ }
+ }
+ })
+ } else if (explorer?.dataset.tree) {
+ // If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset
+ explorerState = JSON.parse(explorer.dataset.tree)
+ }
+}
+
+window.addEventListener("resize", setupExplorer)
+document.addEventListener("nav", () => {
+ setupExplorer()
+
+ observer.disconnect()
+
+ // select pseudo element at end of list
+ const lastItem = document.getElementById("explorer-end")
+ if (lastItem) {
+ observer.observe(lastItem)
+ }
+})
+
+/**
+ * Toggles the state of a given folder
+ * @param folderElement Element of folder (parent)
+ * @param collapsed if folder should be set to collapsed or not
+ */
+function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
+ if (collapsed) {
+ folderElement?.classList.remove("open")
+ } else {
+ folderElement?.classList.add("open")
+ }
+}
+
+/**
+ * Toggles visibility of a folder
+ * @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
+ * @param path path to folder (e.g. 'advanced/more/more2')
+ */
+function toggleCollapsedByPath(array: FolderState[], path: string) {
+ const entry = array.find((item) => item.path === path)
+ if (entry) {
+ entry.collapsed = !entry.collapsed
+ }
+}
diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts
index e589217f22816..bddcfa4c618d2 100644
--- a/quartz/components/scripts/graph.inline.ts
+++ b/quartz/components/scripts/graph.inline.ts
@@ -1,4 +1,4 @@
-import type { ContentDetails } from "../../plugins/emitters/contentIndex"
+import type { ContentDetails, ContentIndex } from "../../plugins/emitters/contentIndex"
import * as d3 from "d3"
import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
@@ -42,17 +42,38 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
linkDistance,
fontSize,
opacityScale,
+ removeTags,
+ showTags,
} = JSON.parse(graph.dataset["cfg"]!)
- const data = await fetchData
-
+ const data: Map
= new Map(
+ Object.entries(await fetchData).map(([k, v]) => [
+ simplifySlug(k as FullSlug),
+ v,
+ ]),
+ )
const links: LinkData[] = []
- for (const [src, details] of Object.entries(data)) {
- const source = simplifySlug(src as FullSlug)
+ const tags: SimpleSlug[] = []
+
+ const validLinks = new Set(data.keys())
+ for (const [source, details] of data.entries()) {
const outgoing = details.links ?? []
+
for (const dest of outgoing) {
- if (dest in data) {
- links.push({ source, target: dest })
+ if (validLinks.has(dest)) {
+ links.push({ source: source, target: dest })
+ }
+ }
+
+ if (showTags) {
+ const localTags = details.tags
+ .filter((tag) => !removeTags.includes(tag))
+ .map((tag) => simplifySlug(("tags/" + tag) as FullSlug))
+
+ tags.push(...localTags.filter((tag) => !tags.includes(tag)))
+
+ for (const tag of localTags) {
+ links.push({ source: source, target: tag })
}
}
}
@@ -74,15 +95,19 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
}
}
} else {
- Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug)))
+ validLinks.forEach((id) => neighbourhood.add(id))
+ if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
}
const graphData: { nodes: NodeData[]; links: LinkData[] } = {
- nodes: [...neighbourhood].map((url) => ({
- id: url,
- text: data[url]?.title ?? url,
- tags: data[url]?.tags ?? [],
- })),
+ nodes: [...neighbourhood].map((url) => {
+ const text = url.startsWith("tags/") ? "#" + url.substring(5) : data.get(url)?.title ?? url
+ return {
+ id: url,
+ text: text,
+ tags: data.get(url)?.tags ?? [],
+ }
+ }),
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
}
@@ -126,7 +151,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
const isCurrent = d.id === slug
if (isCurrent) {
return "var(--secondary)"
- } else if (visited.has(d.id)) {
+ } else if (visited.has(d.id) || d.id.startsWith("tags/")) {
return "var(--tertiary)"
} else {
return "var(--gray)"
@@ -177,7 +202,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
window.spaNavigate(new URL(targ, window.location.toString()))
})
.on("mouseover", function (_, d) {
- const neighbours: SimpleSlug[] = data[fullSlug].links ?? []
+ const neighbours: SimpleSlug[] = data.get(slug)?.links ?? []
const neighbourNodes = d3
.selectAll(".node")
.filter((d) => neighbours.includes(d.id))
@@ -230,9 +255,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
.attr("dx", 0)
.attr("dy", (d) => -nodeRadius(d) + "px")
.attr("text-anchor", "middle")
- .text(
- (d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "),
- )
+ .text((d) => d.text)
.style("opacity", (opacityScale - 1) / 3.75)
.style("pointer-events", "none")
.style("font-size", fontSize + "em")
diff --git a/quartz/components/scripts/plausible.inline.ts b/quartz/components/scripts/plausible.inline.ts
deleted file mode 100644
index 704f5d5fee3c8..0000000000000
--- a/quartz/components/scripts/plausible.inline.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import Plausible from "plausible-tracker"
-const { trackPageview } = Plausible()
-document.addEventListener("nav", () => trackPageview())
diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts
index ed3c925ce85c3..4d51e2a6f6df1 100644
--- a/quartz/components/scripts/popover.inline.ts
+++ b/quartz/components/scripts/popover.inline.ts
@@ -1,16 +1,5 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
-
-// from micromorph/src/utils.ts
-// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
-export function normalizeRelativeURLs(el: Element | Document, base: string | URL) {
- const update = (el: Element, attr: string, base: string | URL) => {
- el.setAttribute(attr, new URL(el.getAttribute(attr)!, base).pathname)
- }
-
- el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) => update(item, "href", base))
-
- el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) => update(item, "src", base))
-}
+import { normalizeRelativeURLs } from "../../util/path"
const p = new DOMParser()
async function mouseEnterHandler(
@@ -18,6 +7,10 @@ async function mouseEnterHandler(
{ clientX, clientY }: { clientX: number; clientY: number },
) {
const link = this
+ if (link.dataset.noPopover === "true") {
+ return
+ }
+
async function setPosition(popoverElement: HTMLElement) {
const { x, y } = await computePosition(link, popoverElement, {
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
@@ -28,8 +21,11 @@ async function mouseEnterHandler(
})
}
+ const hasAlreadyBeenFetched = () =>
+ [...link.children].some((child) => child.classList.contains("popover"))
+
// dont refetch if there's already a popover
- if ([...link.children].some((child) => child.classList.contains("popover"))) {
+ if (hasAlreadyBeenFetched()) {
return setPosition(link.lastChild as HTMLElement)
}
@@ -40,8 +36,6 @@ async function mouseEnterHandler(
const hash = targetUrl.hash
targetUrl.hash = ""
targetUrl.search = ""
- // prevent hover of the same page
- if (thisUrl.toString() === targetUrl.toString()) return
const contents = await fetch(`${targetUrl}`)
.then((res) => res.text())
@@ -49,6 +43,11 @@ async function mouseEnterHandler(
console.error(err)
})
+ // bailout if another popover exists
+ if (hasAlreadyBeenFetched()) {
+ return
+ }
+
if (!contents) return
const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, targetUrl)
diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts
index adcd06abce4de..eff4eb1b9afd7 100644
--- a/quartz/components/scripts/search.inline.ts
+++ b/quartz/components/scripts/search.inline.ts
@@ -1,4 +1,4 @@
-import { Document } from "flexsearch"
+import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, resolveRelative } from "../../util/path"
@@ -8,12 +8,20 @@ interface Item {
slug: FullSlug
title: string
content: string
+ tags: string[]
}
let index: Document- | undefined = undefined
+// Can be expanded with things like "term" in the future
+type SearchType = "basic" | "tags"
+
+// Current searchType
+let searchType: SearchType = "basic"
+
const contextWindowWords = 30
const numSearchResults = 5
+const numTagResults = 3
function highlight(searchTerm: string, text: string, trim?: boolean) {
// try to highlight longest tokens first
const tokenizedTerms = searchTerm
@@ -74,6 +82,7 @@ document.addEventListener("nav", async (e: unknown) => {
const searchIcon = document.getElementById("search-icon")
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
const results = document.getElementById("results-container")
+ const resultCards = document.getElementsByClassName("result-card")
const idDataMap = Object.keys(data) as FullSlug[]
function hideSearch() {
@@ -87,9 +96,12 @@ document.addEventListener("nav", async (e: unknown) => {
if (results) {
removeAllChildren(results)
}
+
+ searchType = "basic" // reset search type after closing
}
- function showSearch() {
+ function showSearch(searchTypeNew: SearchType) {
+ searchType = searchTypeNew
if (sidebar) {
sidebar.style.zIndex = "1"
}
@@ -98,16 +110,69 @@ document.addEventListener("nav", async (e: unknown) => {
}
function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
- if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
+ if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault()
const searchBarOpen = container?.classList.contains("active")
- searchBarOpen ? hideSearch() : showSearch()
+ searchBarOpen ? hideSearch() : showSearch("basic")
+ } else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
+ // Hotkey to open tag search
+ e.preventDefault()
+ const searchBarOpen = container?.classList.contains("active")
+ searchBarOpen ? hideSearch() : showSearch("tags")
+
+ // add "#" prefix for tag search
+ if (searchBar) searchBar.value = "#"
} else if (e.key === "Enter") {
- const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
- if (anchor) {
- anchor.click()
+ // If result has focus, navigate to that one, otherwise pick first result
+ if (results?.contains(document.activeElement)) {
+ const active = document.activeElement as HTMLInputElement
+ active.click()
+ } else {
+ const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
+ anchor?.click()
}
+ } else if (e.key === "ArrowDown") {
+ e.preventDefault()
+ // When first pressing ArrowDown, results wont contain the active element, so focus first element
+ if (!results?.contains(document.activeElement)) {
+ const firstResult = resultCards[0] as HTMLInputElement | null
+ firstResult?.focus()
+ } else {
+ // If an element in results-container already has focus, focus next one
+ const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null
+ nextResult?.focus()
+ }
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault()
+ if (results?.contains(document.activeElement)) {
+ // If an element in results-container already has focus, focus previous one
+ const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null
+ prevResult?.focus()
+ }
+ }
+ }
+
+ function trimContent(content: string) {
+ // works without escaping html like in `description.ts`
+ const sentences = content.replace(/\s+/g, " ").split(".")
+ let finalDesc = ""
+ let sentenceIdx = 0
+
+ // Roughly estimate characters by (words * 5). Matches description length in `description.ts`.
+ const len = contextWindowWords * 5
+ while (finalDesc.length < len) {
+ const sentence = sentences[sentenceIdx]
+ if (!sentence) break
+ finalDesc += sentence + "."
+ sentenceIdx++
}
+
+ // If more content would be available, indicate it by finishing with "..."
+ if (finalDesc.length < content.length) {
+ finalDesc += ".."
+ }
+
+ return finalDesc
}
const formatForDisplay = (term: string, id: number) => {
@@ -115,19 +180,53 @@ document.addEventListener("nav", async (e: unknown) => {
return {
id,
slug,
- title: highlight(term, data[slug].title ?? ""),
- content: highlight(term, data[slug].content ?? "", true),
+ title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
+ // if searchType is tag, display context from start of file and trim, otherwise use regular highlight
+ content:
+ searchType === "tags"
+ ? trimContent(data[slug].content)
+ : highlight(term, data[slug].content ?? "", true),
+ tags: highlightTags(term, data[slug].tags),
+ }
+ }
+
+ function highlightTags(term: string, tags: string[]) {
+ if (tags && searchType === "tags") {
+ // Find matching tags
+ const termLower = term.toLowerCase()
+ let matching = tags.filter((str) => str.includes(termLower))
+
+ // Substract matching from original tags, then push difference
+ if (matching.length > 0) {
+ let difference = tags.filter((x) => !matching.includes(x))
+
+ // Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`)
+ matching = matching.map((tag) => `
#${tag}
`)
+ difference = difference.map((tag) => `#${tag}
`)
+ matching.push(...difference)
+ }
+
+ // Only allow max of `numTagResults` in preview
+ if (tags.length > numTagResults) {
+ matching.splice(numTagResults)
+ }
+
+ return matching
+ } else {
+ return []
}
}
- const resultToHTML = ({ slug, title, content }: Item) => {
+ const resultToHTML = ({ slug, title, content, tags }: Item) => {
+ const htmlTags = tags.length > 0 ? `` : ``
const button = document.createElement("button")
button.classList.add("result-card")
button.id = slug
- button.innerHTML = `${title}
${content}
`
+ button.innerHTML = `${title}
${htmlTags}${content}
`
button.addEventListener("click", () => {
const targ = resolveRelative(currentSlug, slug)
window.spaNavigate(new URL(targ, window.location.toString()))
+ hideSearch()
})
return button
}
@@ -147,15 +246,45 @@ document.addEventListener("nav", async (e: unknown) => {
}
async function onType(e: HTMLElementEventMap["input"]) {
- const term = (e.target as HTMLInputElement).value
- const searchResults = (await index?.searchAsync(term, numSearchResults)) ?? []
+ let term = (e.target as HTMLInputElement).value
+ let searchResults: SimpleDocumentSearchResultSetUnit[]
+
+ if (term.toLowerCase().startsWith("#")) {
+ searchType = "tags"
+ } else {
+ searchType = "basic"
+ }
+
+ switch (searchType) {
+ case "tags": {
+ term = term.substring(1)
+ searchResults =
+ (await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ??
+ []
+ break
+ }
+ case "basic":
+ default: {
+ searchResults =
+ (await index?.searchAsync({
+ query: term,
+ limit: numSearchResults,
+ index: ["title", "content"],
+ })) ?? []
+ }
+ }
+
const getByField = (field: string): number[] => {
const results = searchResults.filter((x) => x.field === field)
return results.length === 0 ? [] : ([...results[0].result] as number[])
}
// order titles ahead of content
- const allIds: Set = new Set([...getByField("title"), ...getByField("content")])
+ const allIds: Set = new Set([
+ ...getByField("title"),
+ ...getByField("content"),
+ ...getByField("tags"),
+ ])
const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
displayResults(finalResults)
}
@@ -166,15 +295,14 @@ document.addEventListener("nav", async (e: unknown) => {
document.addEventListener("keydown", shortcutHandler)
prevShortcutHandler = shortcutHandler
- searchIcon?.removeEventListener("click", showSearch)
- searchIcon?.addEventListener("click", showSearch)
+ searchIcon?.removeEventListener("click", () => showSearch("basic"))
+ searchIcon?.addEventListener("click", () => showSearch("basic"))
searchBar?.removeEventListener("input", onType)
searchBar?.addEventListener("input", onType)
// setup index if it hasn't been already
if (!index) {
index = new Document({
- cache: true,
charset: "latin:extra",
optimize: true,
encode: encoder,
@@ -189,22 +317,36 @@ document.addEventListener("nav", async (e: unknown) => {
field: "content",
tokenize: "reverse",
},
+ {
+ field: "tags",
+ tokenize: "reverse",
+ },
],
},
})
- let id = 0
- for (const [slug, fileData] of Object.entries(data)) {
- await index.addAsync(id, {
- id,
- slug: slug as FullSlug,
- title: fileData.title,
- content: fileData.content,
- })
- id++
- }
+ fillDocument(index, data)
}
// register handlers
registerEscapeHandler(container, hideSearch)
})
+
+/**
+ * Fills flexsearch document with data
+ * @param index index to fill
+ * @param data data to fill index with
+ */
+async function fillDocument(index: Document- , data: any) {
+ let id = 0
+ for (const [slug, fileData] of Object.entries(data)) {
+ await index.addAsync(id, {
+ id,
+ slug: slug as FullSlug,
+ title: fileData.title,
+ content: fileData.content,
+ tags: fileData.tags,
+ })
+ id++
+ }
+}
diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts
index bd2260831c7be..c2a44c9a8bb62 100644
--- a/quartz/components/scripts/spa.inline.ts
+++ b/quartz/components/scripts/spa.inline.ts
@@ -1,9 +1,8 @@
import micromorph from "micromorph"
-import { FullSlug, RelativeURL, getFullSlug } from "../../util/path"
+import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
// adapted from `micromorph`
// https://github.com/natemoo-re/micromorph
-
const NODE_TYPE_ELEMENT = 1
let announcer = document.createElement("route-announcer")
const isElement = (target: EventTarget | null): target is Element =>
@@ -18,8 +17,15 @@ const isLocalUrl = (href: string) => {
return false
}
+const isSamePage = (url: URL): boolean => {
+ const sameOrigin = url.origin === window.location.origin
+ const samePath = url.pathname === window.location.pathname
+ return sameOrigin && samePath
+}
+
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
if (!isElement(target)) return
+ if (target.attributes.getNamedItem("target")?.value === "_blank") return
const a = target.closest("a")
if (!a) return
if ("routerIgnore" in a.dataset) return
@@ -37,7 +43,14 @@ let p: DOMParser
async function navigate(url: URL, isBack: boolean = false) {
p = p || new DOMParser()
const contents = await fetch(`${url}`)
- .then((res) => res.text())
+ .then((res) => {
+ const contentType = res.headers.get("content-type")
+ if (contentType?.startsWith("text/html")) {
+ return res.text()
+ } else {
+ window.location.assign(url)
+ }
+ })
.catch(() => {
window.location.assign(url)
})
@@ -45,6 +58,8 @@ async function navigate(url: URL, isBack: boolean = false) {
if (!contents) return
const html = p.parseFromString(contents, "text/html")
+ normalizeRelativeURLs(html, url)
+
let title = html.querySelector("title")?.textContent
if (title) {
document.title = title
@@ -92,8 +107,17 @@ function createRouter() {
if (typeof window !== "undefined") {
window.addEventListener("click", async (event) => {
const { url } = getOpts(event) ?? {}
- if (!url) return
+ // dont hijack behaviour, just let browser act normally
+ if (!url || event.ctrlKey || event.metaKey) return
event.preventDefault()
+
+ if (isSamePage(url) && url.hash) {
+ const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
+ el?.scrollIntoView()
+ history.pushState({}, "", url)
+ return
+ }
+
try {
navigate(url, false)
} catch (e) {
@@ -139,6 +163,7 @@ if (!customElements.get("route-announcer")) {
style:
"position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px",
}
+
customElements.define(
"route-announcer",
class RouteAnnouncer extends HTMLElement {
diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts
index f33d8f50458ab..f3da52cd50c6b 100644
--- a/quartz/components/scripts/toc.inline.ts
+++ b/quartz/components/scripts/toc.inline.ts
@@ -24,8 +24,9 @@ function toggleToc(this: HTMLElement) {
function setupToc() {
const toc = document.getElementById("toc")
if (toc) {
+ const collapsed = toc.classList.contains("collapsed")
const content = toc.nextElementSibling as HTMLElement
- content.style.maxHeight = content.scrollHeight + "px"
+ content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
toc.removeEventListener("click", toggleToc)
toc.addEventListener("click", toggleToc)
}
diff --git a/quartz/components/styles/breadcrumbs.scss b/quartz/components/styles/breadcrumbs.scss
new file mode 100644
index 0000000000000..789808baf68a4
--- /dev/null
+++ b/quartz/components/styles/breadcrumbs.scss
@@ -0,0 +1,22 @@
+.breadcrumb-container {
+ margin: 0;
+ margin-top: 0.75rem;
+ padding: 0;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.breadcrumb-element {
+ p {
+ margin: 0;
+ margin-left: 0.5rem;
+ padding: 0;
+ line-height: normal;
+ }
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/quartz/components/styles/clipboard.scss b/quartz/components/styles/clipboard.scss
index 1702a7bb4bddc..196b8945c7005 100644
--- a/quartz/components/styles/clipboard.scss
+++ b/quartz/components/styles/clipboard.scss
@@ -4,13 +4,12 @@
float: right;
right: 0;
padding: 0.4rem;
- margin: -0.2rem 0.3rem;
+ margin: 0.3rem;
color: var(--gray);
border-color: var(--dark);
background-color: var(--light);
border: 1px solid;
border-radius: 5px;
- z-index: 1;
opacity: 0;
transition: 0.2s;
diff --git a/quartz/components/styles/darkmode.scss b/quartz/components/styles/darkmode.scss
index 10cbc72a5ed1c..348c6f79373f0 100644
--- a/quartz/components/styles/darkmode.scss
+++ b/quartz/components/styles/darkmode.scss
@@ -21,6 +21,14 @@
}
}
+:root[saved-theme="dark"] {
+ color-scheme: dark;
+}
+
+:root[saved-theme="light"] {
+ color-scheme: light;
+}
+
:root[saved-theme="dark"] .toggle ~ label {
& > #dayIcon {
opacity: 0;
diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss
new file mode 100644
index 0000000000000..ff046a665c4cb
--- /dev/null
+++ b/quartz/components/styles/explorer.scss
@@ -0,0 +1,146 @@
+button#explorer {
+ all: unset;
+ background-color: transparent;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ padding: 0;
+ color: var(--dark);
+ display: flex;
+ align-items: center;
+
+ & h1 {
+ font-size: 1rem;
+ display: inline-block;
+ margin: 0;
+ }
+
+ & .fold {
+ margin-left: 0.5rem;
+ transition: transform 0.3s ease;
+ opacity: 0.8;
+ }
+
+ &.collapsed .fold {
+ transform: rotateZ(-90deg);
+ }
+}
+
+.folder-outer {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 0.3s ease-in-out;
+}
+
+.folder-outer.open {
+ grid-template-rows: 1fr;
+}
+
+.folder-outer > ul {
+ overflow: hidden;
+}
+
+#explorer-content {
+ list-style: none;
+ overflow: hidden;
+ max-height: none;
+ transition: max-height 0.35s ease;
+ margin-top: 0.5rem;
+
+ &.collapsed > .overflow::after {
+ opacity: 0;
+ }
+
+ & ul {
+ list-style: none;
+ margin: 0.08rem 0;
+ padding: 0;
+ transition:
+ max-height 0.35s ease,
+ transform 0.35s ease,
+ opacity 0.2s ease;
+ & li > a {
+ color: var(--dark);
+ opacity: 0.75;
+ pointer-events: all;
+ }
+ }
+}
+
+svg {
+ pointer-events: all;
+
+ & > polyline {
+ pointer-events: none;
+ }
+}
+
+.folder-container {
+ flex-direction: row;
+ display: flex;
+ align-items: center;
+ user-select: none;
+
+ & div > a {
+ color: var(--secondary);
+ font-family: var(--headerFont);
+ font-size: 0.95rem;
+ font-weight: 600;
+ line-height: 1.5rem;
+ display: inline-block;
+ }
+
+ & div > a:hover {
+ color: var(--tertiary);
+ }
+
+ & div > button {
+ color: var(--dark);
+ background-color: transparent;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ padding-left: 0;
+ padding-right: 0;
+ display: flex;
+ align-items: center;
+ font-family: var(--headerFont);
+
+ & span {
+ font-size: 0.95rem;
+ display: inline-block;
+ color: var(--secondary);
+ font-weight: 600;
+ margin: 0;
+ line-height: 1.5rem;
+ pointer-events: none;
+ }
+ }
+}
+
+.folder-icon {
+ margin-right: 5px;
+ color: var(--secondary);
+ cursor: pointer;
+ transition: transform 0.3s ease;
+ backface-visibility: visible;
+}
+
+div:has(> .folder-outer:not(.open)) > .folder-container > svg {
+ transform: rotate(-90deg);
+}
+
+.folder-icon:hover {
+ color: var(--tertiary);
+}
+
+.no-background::after {
+ background: none !important;
+}
+
+#explorer-end {
+ // needs height so IntersectionObserver gets triggered
+ height: 4px;
+ // remove default margin from li
+ margin: 0;
+}
diff --git a/quartz/components/styles/listPage.scss b/quartz/components/styles/listPage.scss
index 7105a1e86043f..c8fc9e95790cf 100644
--- a/quartz/components/styles/listPage.scss
+++ b/quartz/components/styles/listPage.scss
@@ -19,11 +19,6 @@ li.section-li {
}
}
- & > .tags {
- justify-self: end;
- margin-left: 1rem;
- }
-
& > .desc > h3 > a {
background-color: transparent;
}
diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss
index 4d5ad95cd6121..66f809f97f747 100644
--- a/quartz/components/styles/search.scss
+++ b/quartz/components/styles/search.scss
@@ -130,6 +130,44 @@
margin: 0;
}
+ & > ul > li {
+ margin: 0;
+ display: inline-block;
+ white-space: nowrap;
+ margin: 0;
+ overflow-wrap: normal;
+ }
+
+ & > ul {
+ list-style: none;
+ display: flex;
+ padding-left: 0;
+ gap: 0.4rem;
+ margin: 0;
+ margin-top: 0.45rem;
+ // Offset border radius
+ margin-left: -2px;
+ overflow: hidden;
+ background-clip: border-box;
+ }
+
+ & > ul > li > p {
+ border-radius: 8px;
+ background-color: var(--highlight);
+ overflow: hidden;
+ background-clip: border-box;
+ padding: 0.03rem 0.4rem;
+ margin: 0;
+ color: var(--secondary);
+ opacity: 0.85;
+ }
+
+ & > ul > li > .match-tag {
+ color: var(--tertiary);
+ font-weight: bold;
+ opacity: 1;
+ }
+
& > p {
margin-bottom: 0;
}
diff --git a/quartz/components/styles/toc.scss b/quartz/components/styles/toc.scss
index 3fac4432afd35..27ff62a4028cd 100644
--- a/quartz/components/styles/toc.scss
+++ b/quartz/components/styles/toc.scss
@@ -30,6 +30,7 @@ button#toc {
overflow: hidden;
max-height: none;
transition: max-height 0.5s ease;
+ position: relative;
&.collapsed > .overflow::after {
opacity: 0;
diff --git a/quartz/components/types.ts b/quartz/components/types.ts
index fd9574f560884..d322ea92672ce 100644
--- a/quartz/components/types.ts
+++ b/quartz/components/types.ts
@@ -9,7 +9,7 @@ export type QuartzComponentProps = {
fileData: QuartzPluginData
cfg: GlobalConfiguration
children: (QuartzComponent | JSX.Element)[]
- tree: Node
+ tree: Node
allFiles: QuartzPluginData[]
displayClass?: "mobile-only" | "desktop-only"
} & JSX.IntrinsicAttributes & {
diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx
new file mode 100644
index 0000000000000..cd079a06559a4
--- /dev/null
+++ b/quartz/plugins/emitters/404.tsx
@@ -0,0 +1,59 @@
+import { QuartzEmitterPlugin } from "../types"
+import { QuartzComponentProps } from "../../components/types"
+import BodyConstructor from "../../components/Body"
+import { pageResources, renderPage } from "../../components/renderPage"
+import { FullPageLayout } from "../../cfg"
+import { FilePath, FullSlug } from "../../util/path"
+import { sharedPageComponents } from "../../../quartz.layout"
+import { NotFound } from "../../components"
+import { defaultProcessedContent } from "../vfile"
+
+export const NotFoundPage: QuartzEmitterPlugin = () => {
+ const opts: FullPageLayout = {
+ ...sharedPageComponents,
+ pageBody: NotFound(),
+ beforeBody: [],
+ left: [],
+ right: [],
+ }
+
+ const { head: Head, pageBody, footer: Footer } = opts
+ const Body = BodyConstructor()
+
+ return {
+ name: "404Page",
+ getQuartzComponents() {
+ return [Head, Body, pageBody, Footer]
+ },
+ async emit(ctx, _content, resources, emit): Promise {
+ const cfg = ctx.cfg.configuration
+ const slug = "404" as FullSlug
+
+ const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
+ const path = url.pathname as FullSlug
+ const externalResources = pageResources(path, resources)
+ const [tree, vfile] = defaultProcessedContent({
+ slug,
+ text: "Not Found",
+ description: "Not Found",
+ frontmatter: { title: "Not Found", tags: [] },
+ })
+ const componentData: QuartzComponentProps = {
+ fileData: vfile.data,
+ externalResources,
+ cfg,
+ children: [],
+ tree,
+ allFiles: [],
+ }
+
+ return [
+ await emit({
+ content: renderPage(slug, componentData, opts, externalResources),
+ slug,
+ ext: ".html",
+ }),
+ ]
+ },
+ }
+}
diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts
index c7294a34390b3..210715eb4fb46 100644
--- a/quartz/plugins/emitters/aliases.ts
+++ b/quartz/plugins/emitters/aliases.ts
@@ -1,4 +1,4 @@
-import { FilePath, FullSlug, resolveRelative, simplifySlug } from "../../util/path"
+import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import path from "path"
@@ -12,15 +12,25 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
for (const [_tree, file] of content) {
const ogSlug = simplifySlug(file.data.slug!)
- const dir = path.posix.relative(argv.directory, file.dirname ?? argv.directory)
+ const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
if (typeof aliases === "string") {
aliases = [aliases]
}
- for (const alias of aliases) {
- const slug = path.posix.join(dir, alias) as FullSlug
+ const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
+ const permalink = file.data.frontmatter?.permalink
+ if (typeof permalink === "string") {
+ slugs.push(permalink as FullSlug)
+ }
+
+ for (let slug of slugs) {
+ // fix any slugs that have trailing slash
+ if (slug.endsWith("/")) {
+ slug = joinSegments(slug, "index") as FullSlug
+ }
+
const redirUrl = resolveRelative(slug, file.data.slug!)
const fp = await emit({
content: `
diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts
new file mode 100644
index 0000000000000..ffe2c6d12dc7e
--- /dev/null
+++ b/quartz/plugins/emitters/cname.ts
@@ -0,0 +1,29 @@
+import { FilePath, joinSegments } from "../../util/path"
+import { QuartzEmitterPlugin } from "../types"
+import fs from "fs"
+import chalk from "chalk"
+
+export function extractDomainFromBaseUrl(baseUrl: string) {
+ const url = new URL(`https://${baseUrl}`)
+ return url.hostname
+}
+
+export const CNAME: QuartzEmitterPlugin = () => ({
+ name: "CNAME",
+ getQuartzComponents() {
+ return []
+ },
+ async emit({ argv, cfg }, _content, _resources, _emit): Promise {
+ if (!cfg.configuration.baseUrl) {
+ console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
+ return []
+ }
+ const path = joinSegments(argv.output, "CNAME")
+ const content = extractDomainFromBaseUrl(cfg.configuration.baseUrl)
+ if (!content) {
+ return []
+ }
+ fs.writeFileSync(path, content)
+ return [path] as FilePath[]
+ },
+})
diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts
index a62bc382bb04d..e8a81bc0bba33 100644
--- a/quartz/plugins/emitters/componentResources.ts
+++ b/quartz/plugins/emitters/componentResources.ts
@@ -4,16 +4,15 @@ import { QuartzEmitterPlugin } from "../types"
// @ts-ignore
import spaRouterScript from "../../components/scripts/spa.inline"
// @ts-ignore
-import plausibleScript from "../../components/scripts/plausible.inline"
-// @ts-ignore
import popoverScript from "../../components/scripts/popover.inline"
-import styles from "../../styles/base.scss"
+import styles from "../../styles/custom.scss"
import popoverStyle from "../../components/styles/popover.scss"
import { BuildCtx } from "../../util/ctx"
import { StaticResources } from "../../util/resources"
import { QuartzComponent } from "../../components/types"
import { googleFontHref, joinStyles } from "../../util/theme"
import { Features, transform } from "lightningcss"
+import { transform as transpile } from "esbuild"
type ComponentResources = {
css: string[]
@@ -56,9 +55,16 @@ function getComponentResources(ctx: BuildCtx): ComponentResources {
}
}
-function joinScripts(scripts: string[]): string {
+async function joinScripts(scripts: string[]): Promise {
// wrap with iife to prevent scope collision
- return scripts.map((script) => `(function () {${script}})();`).join("\n")
+ const script = scripts.map((script) => `(function () {${script}})();`).join("\n")
+
+ // minify with esbuild
+ const res = await transpile(script, {
+ minify: true,
+ })
+
+ return res.code
}
function addGlobalPageResources(
@@ -85,17 +91,39 @@ function addGlobalPageResources(
componentResources.afterDOMLoaded.push(`
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
- gtag(\`js\`, new Date());
- gtag(\`config\`, \`${tagId}\`, { send_page_view: false });
+ gtag("js", new Date());
+ gtag("config", "${tagId}", { send_page_view: false });
- document.addEventListener(\`nav\`, () => {
- gtag(\`event\`, \`page_view\`, {
+ document.addEventListener("nav", () => {
+ gtag("event", "page_view", {
page_title: document.title,
page_location: location.href,
});
});`)
} else if (cfg.analytics?.provider === "plausible") {
- componentResources.afterDOMLoaded.push(plausibleScript)
+ const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
+ componentResources.afterDOMLoaded.push(`
+ const plausibleScript = document.createElement("script")
+ plausibleScript.src = "${plausibleHost}/js/script.manual.js"
+ plausibleScript.setAttribute("data-domain", location.hostname)
+ plausibleScript.defer = true
+ document.head.appendChild(plausibleScript)
+
+ window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
+
+ document.addEventListener("nav", () => {
+ plausible("pageview")
+ })
+ `)
+ } else if (cfg.analytics?.provider === "umami") {
+ componentResources.afterDOMLoaded.push(`
+ const umamiScript = document.createElement("script")
+ umamiScript.src = "https://analytics.umami.is/script.js"
+ umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
+ umamiScript.async = true
+
+ document.head.appendChild(umamiScript)
+ `)
}
if (cfg.enableSPA) {
@@ -107,12 +135,18 @@ function addGlobalPageResources(
document.dispatchEvent(event)`)
}
+ let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
+
+ if (ctx.argv.remoteDevHost) {
+ wsUrl = `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}`
+ }
+
if (reloadScript) {
staticResources.js.push({
loadTime: "afterDOMReady",
contentType: "inline",
script: `
- const socket = new WebSocket('ws://localhost:3001')
+ const socket = new WebSocket('${wsUrl}')
socket.addEventListener('message', () => document.location.reload())
`,
})
@@ -149,9 +183,12 @@ export const ComponentResources: QuartzEmitterPlugin = (opts?: Partial<
addGlobalPageResources(ctx, resources, componentResources)
- const stylesheet = joinStyles(ctx.cfg.configuration.theme, styles, ...componentResources.css)
- const prescript = joinScripts(componentResources.beforeDOMLoaded)
- const postscript = joinScripts(componentResources.afterDOMLoaded)
+ const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
+ const [prescript, postscript] = await Promise.all([
+ joinScripts(componentResources.beforeDOMLoaded),
+ joinScripts(componentResources.afterDOMLoaded),
+ ])
+
const fps = await Promise.all([
emit({
slug: "index" as FullSlug,
diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts
index 7de78beca8e02..10be0bb3c2deb 100644
--- a/quartz/plugins/emitters/contentIndex.ts
+++ b/quartz/plugins/emitters/contentIndex.ts
@@ -1,8 +1,10 @@
+import { Root } from "hast"
import { GlobalConfiguration } from "../../cfg"
import { getDate } from "../../components/Date"
import { escapeHTML } from "../../util/escape"
-import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
+import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
+import { toHtml } from "hast-util-to-html"
import path from "path"
export type ContentIndex = Map
@@ -11,6 +13,7 @@ export type ContentDetails = {
links: SimpleSlug[]
tags: string[]
content: string
+ richContent?: string
date?: Date
description?: string
}
@@ -18,19 +21,23 @@ export type ContentDetails = {
interface Options {
enableSiteMap: boolean
enableRSS: boolean
+ rssLimit?: number
+ rssFullHtml: boolean
includeEmptyFiles: boolean
}
const defaultOptions: Options = {
enableSiteMap: true,
enableRSS: true,
+ rssLimit: 10,
+ rssFullHtml: false,
includeEmptyFiles: true,
}
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
const base = cfg.baseUrl ?? ""
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `
- https://${base}/${encodeURI(slug)}
+ https://${joinSegments(base, encodeURI(slug))}
${content.date?.toISOString()}
`
const urls = Array.from(idx)
@@ -39,15 +46,15 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
return `${urls}`
}
-function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, includedSections: string[]): string {
+function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, includedSections: string[], limit?: number): string {
const base = cfg.baseUrl ?? "";
const root = `https://${base}`;
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `
-
${escapeHTML(content.title)}
- ${root}/${encodeURI(slug)}
- ${root}/${encodeURI(slug)}
- ${content.description}
+ ${joinSegments(root, encodeURI(slug))}
+ ${joinSegments(root, encodeURI(slug))}
+ ${content.richContent ?? content.description}
${content.date?.toUTCString()}
`;
@@ -75,9 +82,12 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, includedSe
return `
+ ${escapeHTML(cfg.pageTitle)}
${escapeHTML(cfg.pageTitle)}
${root}
- Recent content on ${cfg.pageTitle}
+ ${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
+ cfg.pageTitle,
+ )}
Quartz -- quartz.jzhao.xyz
${items.join('')}
@@ -96,7 +106,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => {
const cfg = ctx.cfg.configuration
const emitted: FilePath[] = []
const linkIndex: ContentIndex = new Map()
- for (const [_tree, file] of content) {
+ for (const [tree, file] of content) {
const slug = file.data.slug!
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
@@ -105,6 +115,9 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => {
links: file.data.links ?? [],
tags: file.data.frontmatter?.tags ?? [],
content: file.data.text ?? "",
+ richContent: opts?.rssFullHtml
+ ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
+ : undefined,
date: date,
description: file.data.description ?? "",
})
@@ -126,7 +139,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => {
if (opts?.enableRSS) {
emitted.push(
await emit({
- content: generateRSSFeed(cfg, linkIndex, includedSections), // Pass includedSections
+ content: generateRSSFeed(cfg, linkIndex, includedSections, opts.rssLimit), // Pass includedSections
slug: "index" as FullSlug,
ext: ".xml",
}),
diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx
index 0e510db894aff..338bfae44e1e2 100644
--- a/quartz/plugins/emitters/contentPage.tsx
+++ b/quartz/plugins/emitters/contentPage.tsx
@@ -4,9 +4,10 @@ import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg"
-import { FilePath } from "../../util/path"
+import { FilePath, pathToRoot } from "../../util/path"
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { Content } from "../../components"
+import chalk from "chalk"
export const ContentPage: QuartzEmitterPlugin> = (userOpts) => {
const opts: FullPageLayout = {
@@ -29,9 +30,15 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp
const cfg = ctx.cfg.configuration
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
+
+ let containsIndex = false
for (const [tree, file] of content) {
const slug = file.data.slug!
- const externalResources = pageResources(slug, resources)
+ if (slug === "index") {
+ containsIndex = true
+ }
+
+ const externalResources = pageResources(pathToRoot(slug), resources)
const componentData: QuartzComponentProps = {
fileData: file.data,
externalResources,
@@ -50,6 +57,15 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp
fps.push(fp)
}
+
+ if (!containsIndex) {
+ console.log(
+ chalk.yellow(
+ `\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder. This may cause errors when deploying.`,
+ ),
+ )
+ }
+
return fps
},
}
diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx
index 8d62f7bb4e4a5..8632eceb46b70 100644
--- a/quartz/plugins/emitters/folderPage.tsx
+++ b/quartz/plugins/emitters/folderPage.tsx
@@ -12,6 +12,7 @@ import {
SimpleSlug,
_stripSlashes,
joinSegments,
+ pathToRoot,
simplifySlug,
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
@@ -69,7 +70,7 @@ export const FolderPage: QuartzEmitterPlugin = (userOpts) => {
for (const folder of folders) {
const slug = joinSegments(folder, "index") as FullSlug
- const externalResources = pageResources(slug, resources)
+ const externalResources = pageResources(pathToRoot(slug), resources)
const [tree, file] = folderDescriptions[folder]
const componentData: QuartzComponentProps = {
fileData: file.data,
diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts
index da95d4901cdbf..bc378c47bedec 100644
--- a/quartz/plugins/emitters/index.ts
+++ b/quartz/plugins/emitters/index.ts
@@ -6,3 +6,5 @@ export { AliasRedirects } from "./aliases"
export { Assets } from "./assets"
export { Static } from "./static"
export { ComponentResources } from "./componentResources"
+export { NotFoundPage } from "./404"
+export { CNAME } from "./cname"
diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts
index 6f5d19d4190bd..f0118e2e83c23 100644
--- a/quartz/plugins/emitters/static.ts
+++ b/quartz/plugins/emitters/static.ts
@@ -11,7 +11,10 @@ export const Static: QuartzEmitterPlugin = () => ({
async emit({ argv, cfg }, _content, _resources, _emit): Promise {
const staticPath = joinSegments(QUARTZ, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
- await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), { recursive: true })
+ await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {
+ recursive: true,
+ dereference: true,
+ })
return fps.map((fp) => joinSegments(argv.output, "static", fp)) as FilePath[]
},
})
diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx
index 54ad934f69fa0..566911983d346 100644
--- a/quartz/plugins/emitters/tagPage.tsx
+++ b/quartz/plugins/emitters/tagPage.tsx
@@ -5,7 +5,13 @@ import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage"
import { ProcessedContent, defaultProcessedContent } from "../vfile"
import { FullPageLayout } from "../../cfg"
-import { FilePath, FullSlug, getAllSegmentPrefixes, joinSegments } from "../../util/path"
+import {
+ FilePath,
+ FullSlug,
+ getAllSegmentPrefixes,
+ joinSegments,
+ pathToRoot,
+} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components"
@@ -34,12 +40,13 @@ export const TagPage: QuartzEmitterPlugin = (userOpts) => {
const tags: Set = new Set(
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
)
+
// add base tag
tags.add("index")
const tagDescriptions: Record = Object.fromEntries(
[...tags].map((tag) => {
- const title = tag === "" ? "Tag Index" : `Tag: #${tag}`
+ const title = tag === "index" ? "Tag Index" : `Tag: #${tag}`
return [
tag,
defaultProcessedContent({
@@ -62,7 +69,7 @@ export const TagPage: QuartzEmitterPlugin = (userOpts) => {
for (const tag of tags) {
const slug = joinSegments("tags", tag) as FullSlug
- const externalResources = pageResources(slug, resources)
+ const externalResources = pageResources(pathToRoot(slug), resources)
const [tree, file] = tagDescriptions[tag]
const componentData: QuartzComponentProps = {
fileData: file.data,
diff --git a/quartz/plugins/filters/explicit.ts b/quartz/plugins/filters/explicit.ts
index 30f0b37fab0f1..48f92bdf25c1d 100644
--- a/quartz/plugins/filters/explicit.ts
+++ b/quartz/plugins/filters/explicit.ts
@@ -3,7 +3,11 @@ import { QuartzFilterPlugin } from "../types"
export const ExplicitPublish: QuartzFilterPlugin = () => ({
name: "ExplicitPublish",
shouldPublish(_ctx, [_tree, vfile]) {
- const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
+ const publishProperty = vfile.data?.frontmatter?.publish ?? false
+ const publishFlag =
+ typeof publishProperty === "string"
+ ? publishProperty.toLowerCase() === "true"
+ : Boolean(publishProperty)
return publishFlag
},
})
diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts
index 9753d2ea9081b..f35d05353a5da 100644
--- a/quartz/plugins/index.ts
+++ b/quartz/plugins/index.ts
@@ -30,5 +30,6 @@ declare module "vfile" {
interface DataMap {
slug: FullSlug
filePath: FilePath
+ relativePath: FilePath
}
}
diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts
index 08af5c7887591..884d5b1893041 100644
--- a/quartz/plugins/transformers/description.ts
+++ b/quartz/plugins/transformers/description.ts
@@ -1,6 +1,7 @@
import { Root as HTMLRoot } from "hast"
import { toString } from "hast-util-to-string"
import { QuartzTransformerPlugin } from "../types"
+import { escapeHTML } from "../../util/escape"
export interface Options {
descriptionLength: number
@@ -10,15 +11,6 @@ const defaultOptions: Options = {
descriptionLength: 150,
}
-const escapeHTML = (unsafe: string) => {
- return unsafe
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'")
-}
-
export const Description: QuartzTransformerPlugin | undefined> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
return {
diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts
index 3f55b9cb6bd4f..26a665d8f637c 100644
--- a/quartz/plugins/transformers/frontmatter.ts
+++ b/quartz/plugins/transformers/frontmatter.ts
@@ -2,14 +2,20 @@ import matter from "gray-matter"
import remarkFrontmatter from "remark-frontmatter"
import { QuartzTransformerPlugin } from "../types"
import yaml from "js-yaml"
+import toml from "toml"
import { slugTag } from "../../util/path"
+import { QuartzPluginData } from "../vfile"
export interface Options {
delims: string | string[]
+ language: "yaml" | "toml"
+ oneLineTagDelim: string
}
const defaultOptions: Options = {
delims: "---",
+ language: "yaml",
+ oneLineTagDelim: ",",
}
export const FrontMatter: QuartzTransformerPlugin | undefined> = (userOpts) => {
@@ -17,14 +23,17 @@ export const FrontMatter: QuartzTransformerPlugin | undefined>
return {
name: "FrontMatter",
markdownPlugins() {
+ const { oneLineTagDelim } = opts
+
return [
- remarkFrontmatter,
+ [remarkFrontmatter, ["yaml", "toml"]],
() => {
return (_, file) => {
- const { data } = matter(file.value, {
+ const { data } = matter(Buffer.from(file.value), {
...opts,
engines: {
yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
+ toml: (s) => toml.parse(s) as object,
},
})
@@ -33,22 +42,33 @@ export const FrontMatter: QuartzTransformerPlugin | undefined>
data.tags = data.tag
}
- if (data.tags && !Array.isArray(data.tags)) {
+ // coerce title to string
+ if (data.title) {
+ data.title = data.title.toString()
+ } else if (data.title === null || data.title === undefined) {
+ data.title = file.stem ?? "Untitled"
+ }
+
+ if (data.tags) {
+ // coerce to array
+ if (!Array.isArray(data.tags)) {
+ data.tags = data.tags
+ .toString()
+ .split(oneLineTagDelim)
+ .map((tag: string) => tag.trim())
+ }
+
+ // remove all non-string tags
data.tags = data.tags
- .toString()
- .split(",")
- .map((tag: string) => tag.trim())
+ .filter((tag: unknown) => typeof tag === "string" || typeof tag === "number")
+ .map((tag: string | number) => tag.toString())
}
// slug them all!!
- data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))] ?? []
+ data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))]
// fill in frontmatter
- file.data.frontmatter = {
- title: file.stem ?? "Untitled",
- tags: [],
- ...data,
- }
+ file.data.frontmatter = data as QuartzPluginData["frontmatter"]
}
},
]
diff --git a/quartz/plugins/transformers/gfm.ts b/quartz/plugins/transformers/gfm.ts
index 62624aac07da0..40c2205d38805 100644
--- a/quartz/plugins/transformers/gfm.ts
+++ b/quartz/plugins/transformers/gfm.ts
@@ -31,6 +31,11 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin |
rehypeAutolinkHeadings,
{
behavior: "append",
+ properties: {
+ ariaHidden: true,
+ tabIndex: -1,
+ "data-no-popover": true,
+ },
content: {
type: "text",
value: " §",
diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts
index 8013ab7ccdd2a..e340f10e799fe 100644
--- a/quartz/plugins/transformers/index.ts
+++ b/quartz/plugins/transformers/index.ts
@@ -5,5 +5,7 @@ export { Latex } from "./latex"
export { Description } from "./description"
export { CrawlLinks } from "./links"
export { ObsidianFlavoredMarkdown } from "./ofm"
+export { OxHugoFlavouredMarkdown } from "./oxhugofm"
export { SyntaxHighlighting } from "./syntax"
export { TableOfContents } from "./toc"
+export { HardLineBreaks } from "./linebreaks"
diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts
index 507b585223d3e..6e12616f77d74 100644
--- a/quartz/plugins/transformers/lastmod.ts
+++ b/quartz/plugins/transformers/lastmod.ts
@@ -2,6 +2,7 @@ import fs from "fs"
import path from "path"
import { Repository } from "@napi-rs/simple-git"
import { QuartzTransformerPlugin } from "../types"
+import chalk from "chalk"
export interface Options {
priority: ("frontmatter" | "git" | "filesystem")[]
@@ -11,6 +12,20 @@ const defaultOptions: Options = {
priority: ["frontmatter", "git", "filesystem"],
}
+function coerceDate(fp: string, d: any): Date {
+ const dt = new Date(d)
+ const invalidDate = isNaN(dt.getTime()) || dt.getTime() === 0
+ if (invalidDate && d !== undefined) {
+ console.log(
+ chalk.yellow(
+ `\nWarning: found invalid date "${d}" in \`${fp}\`. Supported formats: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format`,
+ ),
+ )
+ }
+
+ return invalidDate ? new Date() : dt
+}
+
type MaybeDate = undefined | string | number
export const CreatedModifiedDate: QuartzTransformerPlugin | undefined> = (
userOpts,
@@ -27,10 +42,11 @@ export const CreatedModifiedDate: QuartzTransformerPlugin | und
let modified: MaybeDate = undefined
let published: MaybeDate = undefined
- const fp = path.posix.join(file.cwd, file.data.filePath as string)
+ const fp = file.data.filePath!
+ const fullFp = path.posix.join(file.cwd, fp)
for (const source of opts.priority) {
if (source === "filesystem") {
- const st = await fs.promises.stat(fp)
+ const st = await fs.promises.stat(fullFp)
created ||= st.birthtimeMs
modified ||= st.mtimeMs
} else if (source === "frontmatter" && file.data.frontmatter) {
@@ -41,17 +57,29 @@ export const CreatedModifiedDate: QuartzTransformerPlugin | und
published ||= file.data.frontmatter.publishDate
} else if (source === "git") {
if (!repo) {
- repo = new Repository(file.cwd)
+ // Get a reference to the main git repo.
+ // It's either the same as the workdir,
+ // or 1+ level higher in case of a submodule/subtree setup
+ repo = Repository.discover(file.cwd)
}
- modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
+ try {
+ modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
+ } catch {
+ console.log(
+ chalk.yellow(
+ `\nWarning: ${file.data
+ .filePath!} isn't yet tracked by git, last modification date is not available for this file`,
+ ),
+ )
+ }
}
}
file.data.dates = {
- created: created ? new Date(created) : new Date(),
- modified: modified ? new Date(modified) : new Date(),
- published: published ? new Date(published) : new Date(),
+ created: coerceDate(fp, created),
+ modified: coerceDate(fp, modified),
+ published: coerceDate(fp, published),
}
}
},
diff --git a/quartz/plugins/transformers/latex.ts b/quartz/plugins/transformers/latex.ts
index 5c6f76787ed94..cb459f7438cff 100644
--- a/quartz/plugins/transformers/latex.ts
+++ b/quartz/plugins/transformers/latex.ts
@@ -1,6 +1,6 @@
import remarkMath from "remark-math"
import rehypeKatex from "rehype-katex"
-import rehypeMathjax from "rehype-mathjax/svg.js"
+import rehypeMathjax from "rehype-mathjax/svg"
import { QuartzTransformerPlugin } from "../types"
interface Options {
diff --git a/quartz/plugins/transformers/linebreaks.ts b/quartz/plugins/transformers/linebreaks.ts
new file mode 100644
index 0000000000000..a8a066fc19529
--- /dev/null
+++ b/quartz/plugins/transformers/linebreaks.ts
@@ -0,0 +1,11 @@
+import { QuartzTransformerPlugin } from "../types"
+import remarkBreaks from "remark-breaks"
+
+export const HardLineBreaks: QuartzTransformerPlugin = () => {
+ return {
+ name: "HardLineBreaks",
+ markdownPlugins() {
+ return [remarkBreaks]
+ },
+ }
+}
diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts
index 26c4a32282910..50d2d1a0a681a 100644
--- a/quartz/plugins/transformers/links.ts
+++ b/quartz/plugins/transformers/links.ts
@@ -5,7 +5,6 @@ import {
SimpleSlug,
TransformOptions,
_stripSlashes,
- joinSegments,
simplifySlug,
splitAnchor,
transformLink,
@@ -13,17 +12,22 @@ import {
import path from "path"
import { visit } from "unist-util-visit"
import isAbsoluteUrl from "is-absolute-url"
+import { Root } from "hast"
interface Options {
/** How to resolve Markdown paths */
markdownLinkResolution: TransformOptions["strategy"]
/** Strips folders from a link so that it looks nice */
prettyLinks: boolean
+ openLinksInNewTab: boolean
+ lazyLoad: boolean
}
const defaultOptions: Options = {
markdownLinkResolution: "absolute",
prettyLinks: true,
+ openLinksInNewTab: false,
+ lazyLoad: false,
}
export const CrawlLinks: QuartzTransformerPlugin | undefined> = (userOpts) => {
@@ -33,7 +37,7 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> =
htmlPlugins(ctx) {
return [
() => {
- return (tree, file) => {
+ return (tree: Root, file) => {
const curSlug = simplifySlug(file.data.slug!)
const outgoing: Set = new Set()
@@ -50,11 +54,27 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> =
typeof node.properties.href === "string"
) {
let dest = node.properties.href as RelativeURL
- node.properties.className ??= []
- node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
+ const classes = (node.properties.className ?? []) as string[]
+ classes.push(isAbsoluteUrl(dest) ? "external" : "internal")
+
+ // Check if the link has alias text
+ if (
+ node.children.length === 1 &&
+ node.children[0].type === "text" &&
+ node.children[0].value !== dest
+ ) {
+ // Add the 'alias' class if the text content is not the same as the href
+ classes.push("alias")
+ }
+ node.properties.className = classes
+
+ if (opts.openLinksInNewTab) {
+ node.properties.target = "_blank"
+ }
// don't process external links or intra-document anchors
- if (!(isAbsoluteUrl(dest) || dest.startsWith("#"))) {
+ const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#"))
+ if (isInternal) {
dest = node.properties.href = transformLink(
file.data.slug!,
dest,
@@ -65,18 +85,22 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> =
// WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
const url = new URL(dest, `https://base.com/${curSlug}`)
const canonicalDest = url.pathname
- const [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
+ let [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
+ if (destCanonical.endsWith("/")) {
+ destCanonical += "index"
+ }
// need to decodeURIComponent here as WHATWG URL percent-encodes everything
- const simple = decodeURIComponent(
- simplifySlug(destCanonical as FullSlug),
- ) as SimpleSlug
+ const full = decodeURIComponent(_stripSlashes(destCanonical, true)) as FullSlug
+ const simple = simplifySlug(full)
outgoing.add(simple)
+ node.properties["data-slug"] = full
}
// rewrite link internals if prettylinks is on
if (
opts.prettyLinks &&
+ isInternal &&
node.children.length === 1 &&
node.children[0].type === "text" &&
!node.children[0].value.startsWith("#")
@@ -91,6 +115,10 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> =
node.properties &&
typeof node.properties.src === "string"
) {
+ if (opts.lazyLoad) {
+ node.properties.loading = "lazy"
+ }
+
if (!isAbsoluteUrl(node.properties.src)) {
let dest = node.properties.src as RelativeURL
dest = node.properties.src = transformLink(
diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts
index bed6f622c7ca2..be3344aa439bf 100644
--- a/quartz/plugins/transformers/ofm.ts
+++ b/quartz/plugins/transformers/ofm.ts
@@ -1,7 +1,7 @@
-import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types"
-import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
-import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
+import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
+import { Element, Literal, Root as HtmlRoot } from "hast"
+import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
import { slug as slugAnchor } from "github-slugger"
import rehypeRaw from "rehype-raw"
import { visit } from "unist-util-visit"
@@ -13,6 +13,8 @@ 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"
+import { capitalize } from "../../util/lang"
+import { PluggableList } from "unified"
export interface Options {
comments: boolean
@@ -21,7 +23,9 @@ export interface Options {
callouts: boolean
mermaid: boolean
parseTags: boolean
+ parseBlockReferences: boolean
enableInHtmlEmbed: boolean
+ enableYouTubeEmbed: boolean
}
const defaultOptions: Options = {
@@ -31,7 +35,9 @@ const defaultOptions: Options = {
callouts: true,
mermaid: true,
parseTags: true,
+ parseBlockReferences: true,
enableInHtmlEmbed: false,
+ enableYouTubeEmbed: false,
}
const icons = {
@@ -69,6 +75,8 @@ const callouts = {
const calloutMapping: Record = {
note: "note",
abstract: "abstract",
+ summary: "abstract",
+ tldr: "abstract",
info: "info",
todo: "todo",
tip: "tip",
@@ -96,27 +104,32 @@ const calloutMapping: Record = {
function canonicalizeCallout(calloutName: string): keyof typeof callouts {
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
- return calloutMapping[callout] ?? calloutName
+ return calloutMapping[callout] ?? "note"
}
-const capitalize = (s: string): string => {
- return s.substring(0, 1).toUpperCase() + s.substring(1)
-}
+export const externalLinkRegex = /^https?:\/\//i
// !? -> optional embedding
// \[\[ -> open brace
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
-const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
-const highlightRegex = new RegExp(/==(.+)==/, "g")
+export const wikilinkRegex = new RegExp(
+ /!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/,
+ "g",
+)
+const highlightRegex = new RegExp(/==([^=]+)==/, "g")
const commentRegex = new RegExp(/%%(.+)%%/, "g")
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
-// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
-// #(\w+) -> tag itself is # followed by a string of alpha-numeric characters
-const tagRegex = new RegExp(/(?:^| )#(\p{L}+)/, "gu")
+// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
+// #(...) -> capturing group, tag itself must start with #
+// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
+// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
+const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\p{Emoji}\d])+(?:\/[-_\p{L}\p{Emoji}\d]+)*)/, "gu")
+const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g")
+const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin | undefined> = (
userOpts,
@@ -127,39 +140,16 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
const hast = toHast(ast, { allowDangerousHtml: true })!
return toHtml(hast, { allowDangerousHtml: true })
}
- const findAndReplace = opts.enableInHtmlEmbed
- ? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => {
- if (replace) {
- visit(tree, "html", (node: HTML) => {
- if (typeof replace === "string") {
- node.value = node.value.replace(regex, replace)
- } else {
- node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
- const replaceValue = replace(substring, ...args)
- if (typeof replaceValue === "string") {
- return replaceValue
- } else if (Array.isArray(replaceValue)) {
- return replaceValue.map(mdastToHtml).join("")
- } else if (typeof replaceValue === "object" && replaceValue !== null) {
- return mdastToHtml(replaceValue)
- } else {
- return substring
- }
- })
- }
- })
- }
-
- mdastFindReplace(tree, regex, replace)
- }
- : mdastFindReplace
return {
name: "ObsidianFlavoredMarkdown",
textTransform(_ctx, src) {
// pre-transform blockquotes
if (opts.callouts) {
- src = src.toString()
+ if (src instanceof Buffer) {
+ src = src.toString()
+ }
+
src = src.replaceAll(calloutLineRegex, (value) => {
// force newline after title of callout
return value + "\n> "
@@ -168,14 +158,24 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
// pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
if (opts.wikilinks) {
- src = src.toString()
+ if (src instanceof Buffer) {
+ src = src.toString()
+ }
+
src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
- const [rawFp, rawHeader, rawAlias] = capture
+ const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
+
const fp = rawFp ?? ""
- const anchor = rawHeader?.trim().slice(1)
- const displayAnchor = anchor ? `#${slugAnchor(anchor)}` : ""
+ const anchor = rawHeader?.trim().replace(/^#+/, "")
+ const blockRef = Boolean(anchor?.startsWith("^")) ? "^" : ""
+ const displayAnchor = anchor ? `#${blockRef}${slugAnchor(anchor)}` : ""
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
const embedDisplay = value.startsWith("!") ? "!" : ""
+
+ if (rawFp?.match(externalLinkRegex)) {
+ return `${embedDisplay}[${displayAlias.replace(/^\|/, "")}](${rawFp})`
+ }
+
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
})
}
@@ -184,100 +184,172 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
},
markdownPlugins() {
const plugins: PluggableList = []
- if (opts.wikilinks) {
- plugins.push(() => {
- return (tree: Root, _file) => {
- findAndReplace(tree, wikilinkRegex, (value: string, ...capture: string[]) => {
- let [rawFp, rawHeader, rawAlias] = capture
- const fp = rawFp?.trim() ?? ""
- const anchor = rawHeader?.trim() ?? ""
- const alias = rawAlias?.slice(1).trim()
-
- // embed cases
- if (value.startsWith("!")) {
- const ext: string = path.extname(fp).toLowerCase()
- const url = slugifyFilePath(fp as FilePath)
- if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
- const dims = alias ?? ""
- let [width, height] = dims.split("x", 2)
- width ||= "auto"
- height ||= "auto"
- return {
- type: "image",
- url,
- data: {
- hProperties: {
- width,
- height,
+
+ // regex replacements
+ plugins.push(() => {
+ return (tree: Root, file) => {
+ const replacements: [RegExp, string | ReplaceFunction][] = []
+ const base = pathToRoot(file.data.slug!)
+
+ if (opts.wikilinks) {
+ replacements.push([
+ wikilinkRegex,
+ (value: string, ...capture: string[]) => {
+ let [rawFp, rawHeader, rawAlias] = capture
+ const fp = rawFp?.trim() ?? ""
+ const anchor = rawHeader?.trim() ?? ""
+ const alias = rawAlias?.slice(1).trim()
+
+ // embed cases
+ if (value.startsWith("!")) {
+ const ext: string = path.extname(fp).toLowerCase()
+ const url = slugifyFilePath(fp as FilePath)
+ if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) {
+ const dims = alias ?? ""
+ let [width, height] = dims.split("x", 2)
+ width ||= "auto"
+ height ||= "auto"
+ return {
+ type: "image",
+ url,
+ data: {
+ hProperties: {
+ width,
+ height,
+ },
},
- },
- }
- } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
- return {
- type: "html",
- value: ``,
+ }
+ } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
+ return {
+ type: "html",
+ value: ``,
+ }
+ } else if (
+ [".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
+ ) {
+ return {
+ type: "html",
+ value: ``,
+ }
+ } else if ([".pdf"].includes(ext)) {
+ return {
+ type: "html",
+ value: ``,
+ }
+ } else if (ext === "") {
+ const block = anchor
+ return {
+ type: "html",
+ data: { hProperties: { transclude: true } },
+ value: `Transclude of ${url}${block}
`,
+ }
}
- } else if (
- [".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
- ) {
- return {
- type: "html",
- value: ``,
- }
- } else if ([".pdf"].includes(ext)) {
- return {
- type: "html",
- value: ``,
- }
- } else if (ext === "") {
- // TODO: note embed
+
+ // otherwise, fall through to regular link
}
- // otherwise, fall through to regular link
- }
- // internal link
- const url = fp + anchor
- return {
- type: "link",
- url,
- children: [
- {
- type: "text",
- value: alias ?? fp,
- },
- ],
- }
- })
+ // internal link
+ const url = fp + anchor
+ return {
+ type: "link",
+ url,
+ children: [
+ {
+ type: "text",
+ value: alias ?? fp,
+ },
+ ],
+ }
+ },
+ ])
}
- })
- }
- if (opts.highlight) {
- plugins.push(() => {
- return (tree: Root, _file) => {
- findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => {
- const [inner] = capture
- return {
- type: "html",
- value: `${inner}`,
- }
- })
+ if (opts.highlight) {
+ replacements.push([
+ highlightRegex,
+ (_value: string, ...capture: string[]) => {
+ const [inner] = capture
+ return {
+ type: "html",
+ value: `${inner}`,
+ }
+ },
+ ])
}
- })
- }
- if (opts.comments) {
- plugins.push(() => {
- return (tree: Root, _file) => {
- findAndReplace(tree, commentRegex, (_value: string, ..._capture: string[]) => {
- return {
- type: "text",
- value: "",
+ if (opts.comments) {
+ replacements.push([
+ commentRegex,
+ (_value: string, ..._capture: string[]) => {
+ return {
+ type: "text",
+ value: "",
+ }
+ },
+ ])
+ }
+
+ if (opts.parseTags) {
+ replacements.push([
+ tagRegex,
+ (_value: string, tag: string) => {
+ // Check if the tag only includes numbers
+ if (/^\d+$/.test(tag)) {
+ return false
+ }
+
+ tag = slugTag(tag)
+ if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
+ file.data.frontmatter.tags.push(tag)
+ }
+
+ return {
+ type: "link",
+ url: base + `/tags/${tag}`,
+ data: {
+ hProperties: {
+ className: ["tag-link"],
+ },
+ },
+ children: [
+ {
+ type: "text",
+ value: `#${tag}`,
+ },
+ ],
+ }
+ },
+ ])
+ }
+
+ if (opts.enableInHtmlEmbed) {
+ visit(tree, "html", (node: Html) => {
+ for (const [regex, replace] of replacements) {
+ if (typeof replace === "string") {
+ node.value = node.value.replace(regex, replace)
+ } else {
+ node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
+ const replaceValue = replace(substring, ...args)
+ if (typeof replaceValue === "string") {
+ return replaceValue
+ } else if (Array.isArray(replaceValue)) {
+ return replaceValue.map(mdastToHtml).join("")
+ } else if (typeof replaceValue === "object" && replaceValue !== null) {
+ return mdastToHtml(replaceValue)
+ } else {
+ return substring
+ }
+ })
+ }
}
})
}
- })
- }
+
+ mdastFindReplace(tree, replacements)
+ }
+ })
if (opts.callouts) {
plugins.push(() => {
@@ -318,9 +390,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
`
- const titleHtml: HTML = {
+ const titleHtml: Html = {
type: "html",
- value: `
${callouts[calloutType]}
@@ -378,29 +450,82 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
})
}
- if (opts.parseTags) {
+ return plugins
+ },
+ htmlPlugins() {
+ const plugins: PluggableList = [rehypeRaw]
+
+ if (opts.parseBlockReferences) {
plugins.push(() => {
- return (tree: Root, file) => {
- const base = pathToRoot(file.data.slug!)
- findAndReplace(tree, tagRegex, (_value: string, tag: string) => {
- if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
- file.data.frontmatter.tags.push(tag)
+ const inlineTagTypes = new Set(["p", "li"])
+ const blockTagTypes = new Set(["blockquote"])
+ return (tree: HtmlRoot, file) => {
+ file.data.blocks = {}
+
+ visit(tree, "element", (node, index, parent) => {
+ if (blockTagTypes.has(node.tagName)) {
+ const nextChild = parent?.children.at(index! + 2) as Element
+ if (nextChild && nextChild.tagName === "p") {
+ const text = nextChild.children.at(0) as Literal
+ if (text && text.value && text.type === "text") {
+ const matches = text.value.match(blockReferenceRegex)
+ if (matches && matches.length >= 1) {
+ parent!.children.splice(index! + 2, 1)
+ const block = matches[0].slice(1)
+
+ if (!Object.keys(file.data.blocks!).includes(block)) {
+ node.properties = {
+ ...node.properties,
+ id: block,
+ }
+ file.data.blocks![block] = node
+ }
+ }
+ }
+ }
+ } else if (inlineTagTypes.has(node.tagName)) {
+ const last = node.children.at(-1) as Literal
+ if (last && last.value && typeof last.value === "string") {
+ const matches = last.value.match(blockReferenceRegex)
+ if (matches && matches.length >= 1) {
+ last.value = last.value.slice(0, -matches[0].length)
+ const block = matches[0].slice(1)
+
+ if (!Object.keys(file.data.blocks!).includes(block)) {
+ node.properties = {
+ ...node.properties,
+ id: block,
+ }
+ file.data.blocks![block] = node
+ }
+ }
+ }
}
+ })
- return {
- type: "link",
- url: base + `/tags/${slugTag(tag)}`,
- data: {
- hProperties: {
- className: ["tag-link"],
- },
- },
- children: [
- {
- type: "text",
- value: `#${tag}`,
- },
- ],
+ file.data.htmlAst = tree
+ }
+ })
+ }
+
+ if (opts.enableYouTubeEmbed) {
+ plugins.push(() => {
+ return (tree: HtmlRoot) => {
+ visit(tree, "element", (node) => {
+ if (node.tagName === "img" && typeof node.properties.src === "string") {
+ const match = node.properties.src.match(ytLinkRegex)
+ const videoId = match && match[2].length == 11 ? match[2] : null
+ if (videoId) {
+ node.tagName = "iframe"
+ node.properties = {
+ class: "external-embed",
+ allow: "fullscreen",
+ frameborder: 0,
+ width: "600px",
+ height: "350px",
+ src: `https://www.youtube.com/embed/${videoId}`,
+ }
+ }
}
})
}
@@ -409,9 +534,6 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
return plugins
},
- htmlPlugins() {
- return [rehypeRaw]
- },
externalResources() {
const js: JSResource[] = []
@@ -428,7 +550,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
script: `
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark'
- mermaid.initialize({
+ mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: darkMode ? 'dark' : 'default'
@@ -449,3 +571,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
},
}
}
+
+declare module "vfile" {
+ interface DataMap {
+ blocks: Record
+ htmlAst: HtmlRoot
+ }
+}
diff --git a/quartz/plugins/transformers/oxhugofm.ts b/quartz/plugins/transformers/oxhugofm.ts
new file mode 100644
index 0000000000000..6e70bb1908fdd
--- /dev/null
+++ b/quartz/plugins/transformers/oxhugofm.ts
@@ -0,0 +1,108 @@
+import { QuartzTransformerPlugin } from "../types"
+
+export interface Options {
+ /** Replace {{ relref }} with quartz wikilinks []() */
+ wikilinks: boolean
+ /** Remove pre-defined anchor (see https://ox-hugo.scripter.co/doc/anchors/) */
+ removePredefinedAnchor: boolean
+ /** Remove hugo shortcode syntax */
+ removeHugoShortcode: boolean
+ /** Replace with ![]() */
+ replaceFigureWithMdImg: boolean
+
+ /** Replace org latex fragments with $ and $$ */
+ replaceOrgLatex: boolean
+}
+
+const defaultOptions: Options = {
+ wikilinks: true,
+ removePredefinedAnchor: true,
+ removeHugoShortcode: true,
+ replaceFigureWithMdImg: true,
+ replaceOrgLatex: true,
+}
+
+const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g")
+const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g")
+const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g")
+const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "g")
+// \\\\\( -> matches \\(
+// (.+?) -> Lazy match for capturing the equation
+// \\\\\) -> matches \\)
+const inlineLatexRegex = new RegExp(/\\\\\((.+?)\\\\\)/, "g")
+// (?:\\begin{equation}|\\\\\(|\\\\\[) -> start of equation
+// ([\s\S]*?) -> Matches the block equation
+// (?:\\\\\]|\\\\\)|\\end{equation}) -> end of equation
+const blockLatexRegex = new RegExp(
+ /(?:\\begin{equation}|\\\\\(|\\\\\[)([\s\S]*?)(?:\\\\\]|\\\\\)|\\end{equation})/,
+ "g",
+)
+// \$\$[\s\S]*?\$\$ -> Matches block equations
+// \$.*?\$ -> Matches inline equations
+const quartzLatexRegex = new RegExp(/\$\$[\s\S]*?\$\$|\$.*?\$/, "g")
+
+/**
+ * ox-hugo is an org exporter backend that exports org files to hugo-compatible
+ * markdown in an opinionated way. This plugin adds some tweaks to the generated
+ * markdown to make it compatible with quartz but the list of changes applied it
+ * is not exhaustive.
+ * */
+export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin | undefined> = (
+ userOpts,
+) => {
+ const opts = { ...defaultOptions, ...userOpts }
+ return {
+ name: "OxHugoFlavouredMarkdown",
+ textTransform(_ctx, src) {
+ if (opts.wikilinks) {
+ src = src.toString()
+ src = src.replaceAll(relrefRegex, (value, ...capture) => {
+ const [text, link] = capture
+ return `[${text}](${link})`
+ })
+ }
+
+ if (opts.removePredefinedAnchor) {
+ src = src.toString()
+ src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => {
+ const [headingText] = capture
+ return headingText
+ })
+ }
+
+ if (opts.removeHugoShortcode) {
+ src = src.toString()
+ src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => {
+ const [scContent] = capture
+ return scContent
+ })
+ }
+
+ if (opts.replaceFigureWithMdImg) {
+ src = src.toString()
+ src = src.replaceAll(figureTagRegex, (value, ...capture) => {
+ const [src] = capture
+ return ``
+ })
+ }
+
+ if (opts.replaceOrgLatex) {
+ src = src.toString()
+ src = src.replaceAll(inlineLatexRegex, (value, ...capture) => {
+ const [eqn] = capture
+ return `$${eqn}$`
+ })
+ src = src.replaceAll(blockLatexRegex, (value, ...capture) => {
+ const [eqn] = capture
+ return `$$${eqn}$$`
+ })
+
+ // ox-hugo escapes _ as \_
+ src = src.replaceAll(quartzLatexRegex, (value) => {
+ return value.replaceAll("\\_", "_")
+ })
+ }
+ return src
+ },
+ }
+}
diff --git a/quartz/plugins/transformers/syntax.ts b/quartz/plugins/transformers/syntax.ts
index 176681748db3d..e84772962b3f2 100644
--- a/quartz/plugins/transformers/syntax.ts
+++ b/quartz/plugins/transformers/syntax.ts
@@ -8,7 +8,11 @@ export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
[
rehypePrettyCode,
{
- theme: "css-variables",
+ keepBackground: false,
+ theme: {
+ dark: "github-dark",
+ light: "github-light",
+ },
} satisfies Partial,
],
]
diff --git a/quartz/plugins/transformers/toc.ts b/quartz/plugins/transformers/toc.ts
index be006f61a5f2d..d0781ec202ad0 100644
--- a/quartz/plugins/transformers/toc.ts
+++ b/quartz/plugins/transformers/toc.ts
@@ -3,17 +3,20 @@ import { Root } from "mdast"
import { visit } from "unist-util-visit"
import { toString } from "mdast-util-to-string"
import Slugger from "github-slugger"
+import { wikilinkRegex } from "./ofm"
export interface Options {
maxDepth: 1 | 2 | 3 | 4 | 5 | 6
minEntries: 1
showByDefault: boolean
+ collapseByDefault: boolean
}
const defaultOptions: Options = {
maxDepth: 3,
minEntries: 1,
showByDefault: true,
+ collapseByDefault: false,
}
interface TocEntry {
@@ -22,6 +25,7 @@ interface TocEntry {
slug: string // this is just the anchor (#some-slug), not the canonical slug
}
+const regexMdLinks = new RegExp(/\[([^\[]+)\](\(.*\))/, "g")
export const TableOfContents: QuartzTransformerPlugin | undefined> = (
userOpts,
) => {
@@ -39,7 +43,16 @@ export const TableOfContents: QuartzTransformerPlugin | undefin
let highestDepth: number = opts.maxDepth
visit(tree, "heading", (node) => {
if (node.depth <= opts.maxDepth) {
- const text = toString(node)
+ let text = toString(node)
+
+ // strip link formatting from toc entries
+ text = text.replace(wikilinkRegex, (_, rawFp, __, rawAlias) => {
+ const fp = rawFp?.trim() ?? ""
+ const alias = rawAlias?.slice(1).trim()
+ return alias ?? fp
+ })
+ text = text.replace(regexMdLinks, "$1")
+
highestDepth = Math.min(highestDepth, node.depth)
toc.push({
depth: node.depth,
@@ -54,6 +67,7 @@ export const TableOfContents: QuartzTransformerPlugin | undefin
...entry,
depth: entry.depth - highestDepth,
}))
+ file.data.collapseToc = opts.collapseByDefault
}
}
}
@@ -66,5 +80,6 @@ export const TableOfContents: QuartzTransformerPlugin | undefin
declare module "vfile" {
interface DataMap {
toc: TocEntry[]
+ collapseToc: boolean
}
}
diff --git a/quartz/plugins/vfile.ts b/quartz/plugins/vfile.ts
index 068981af82332..5be21058471ab 100644
--- a/quartz/plugins/vfile.ts
+++ b/quartz/plugins/vfile.ts
@@ -2,7 +2,7 @@ import { Node, Parent } from "hast"
import { Data, VFile } from "vfile"
export type QuartzPluginData = Data
-export type ProcessedContent = [Node, VFile]
+export type ProcessedContent = [Node, VFile]
export function defaultProcessedContent(vfileData: Partial): ProcessedContent {
const root: Parent = { type: "root", children: [] }
diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts
index 29f92fc45df0e..3950fee09146d 100644
--- a/quartz/processors/parse.ts
+++ b/quartz/processors/parse.ts
@@ -14,27 +14,25 @@ import { QuartzLogger } from "../util/log"
import { trace } from "../util/trace"
import { BuildCtx } from "../util/ctx"
-export type QuartzProcessor = Processor
+export type QuartzProcessor = Processor
export function createProcessor(ctx: BuildCtx): QuartzProcessor {
const transformers = ctx.cfg.plugins.transformers
- // base Markdown -> MD AST
- let processor = unified().use(remarkParse)
-
- // MD AST -> MD AST transforms
- for (const plugin of transformers.filter((p) => p.markdownPlugins)) {
- processor = processor.use(plugin.markdownPlugins!(ctx))
- }
-
- // MD AST -> HTML AST
- processor = processor.use(remarkRehype, { allowDangerousHtml: true })
-
- // HTML AST -> HTML AST transforms
- for (const plugin of transformers.filter((p) => p.htmlPlugins)) {
- processor = processor.use(plugin.htmlPlugins!(ctx))
- }
-
- return processor
+ return (
+ unified()
+ // base Markdown -> MD AST
+ .use(remarkParse)
+ // MD AST -> MD AST transforms
+ .use(
+ transformers
+ .filter((p) => p.markdownPlugins)
+ .flatMap((plugin) => plugin.markdownPlugins!(ctx)),
+ )
+ // MD AST -> HTML AST
+ .use(remarkRehype, { allowDangerousHtml: true })
+ // HTML AST -> HTML AST transforms
+ .use(transformers.filter((p) => p.htmlPlugins).flatMap((plugin) => plugin.htmlPlugins!(ctx)))
+ )
}
function* chunks(arr: T[], n: number) {
@@ -89,12 +87,13 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
// Text -> Text transforms
for (const plugin of cfg.plugins.transformers.filter((p) => p.textTransform)) {
- file.value = plugin.textTransform!(ctx, file.value)
+ file.value = plugin.textTransform!(ctx, file.value.toString())
}
// base data properties that plugins may use
- file.data.slug = slugifyFilePath(path.posix.relative(argv.directory, file.path) as FilePath)
- file.data.filePath = fp
+ file.data.filePath = file.path as FilePath
+ file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath
+ file.data.slug = slugifyFilePath(file.data.relativePath)
const ast = processor.parse(file)
const newAst = await processor.run(ast, file)
diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss
index 173f9bd70248c..28875403d6c3c 100644
--- a/quartz/styles/base.scss
+++ b/quartz/styles/base.scss
@@ -1,7 +1,6 @@
-@use "./custom.scss";
+@use "./variables.scss" as *;
@use "./syntax.scss";
@use "./callouts.scss";
-@use "./variables.scss" as *;
html {
scroll-behavior: smooth;
@@ -70,6 +69,12 @@ a {
background-color: var(--highlight);
padding: 0 0.1rem;
border-radius: 5px;
+
+ &:has(> img) {
+ background-color: none;
+ border-radius: 0;
+ padding: 0;
+ }
}
}
@@ -299,11 +304,13 @@ h6 {
margin-bottom: 1rem;
}
-div[data-rehype-pretty-code-fragment] {
+figure[data-rehype-pretty-code-figure] {
+ margin: 0;
+ position: relative;
line-height: 1.6rem;
position: relative;
- & > div[data-rehype-pretty-code-title] {
+ & > [data-rehype-pretty-code-title] {
font-family: var(--codeFont);
font-size: 0.9rem;
padding: 0.1rem 0.5rem;
@@ -315,16 +322,17 @@ div[data-rehype-pretty-code-fragment] {
}
& > pre {
- padding: 0.5rem 0;
+ padding: 0;
}
}
pre {
font-family: var(--codeFont);
- padding: 0.5rem;
+ padding: 0 0.5rem;
border-radius: 5px;
overflow-x: auto;
border: 1px solid var(--lightgray);
+ position: relative;
&:has(> code.mermaid) {
border: none;
@@ -337,6 +345,7 @@ pre {
counter-reset: line;
counter-increment: line 0;
display: grid;
+ padding: 0.5rem 0;
& [data-highlighted-chars] {
background-color: var(--highlight);
@@ -389,23 +398,33 @@ p {
line-height: 1.6rem;
}
-table {
- margin: 1rem;
- padding: 1.5rem;
- border-collapse: collapse;
- & > * {
- line-height: 2rem;
+.table-container {
+ overflow-x: auto;
+
+ & > table {
+ margin: 1rem;
+ padding: 1.5rem;
+ border-collapse: collapse;
+
+ th,
+ td {
+ min-width: 75px;
+ }
+
+ & > * {
+ line-height: 2rem;
+ }
}
}
th {
text-align: left;
- padding: 0.4rem 1rem;
+ padding: 0.4rem 0.7rem;
border-bottom: 2px solid var(--gray);
}
td {
- padding: 0.2rem 1rem;
+ padding: 0.2rem 0.7rem;
}
tr {
@@ -446,7 +465,7 @@ video {
ul.overflow,
ol.overflow {
- height: 300px;
+ max-height: 400;
overflow-y: auto;
// clearfix
@@ -454,7 +473,7 @@ ol.overflow {
clear: both;
& > li:last-of-type {
- margin-bottom: 50px;
+ margin-bottom: 30px;
}
&:after {
@@ -470,3 +489,9 @@ ol.overflow {
background: linear-gradient(transparent 0px, var(--light));
}
}
+
+.transclude {
+ ul {
+ padding-left: 1rem;
+ }
+}
diff --git a/quartz/styles/callouts.scss b/quartz/styles/callouts.scss
index ad991658d9aa7..703bd67f69da0 100644
--- a/quartz/styles/callouts.scss
+++ b/quartz/styles/callouts.scss
@@ -82,7 +82,6 @@
.callout-title {
display: flex;
- align-items: center;
gap: 5px;
padding: 1rem 0;
color: var(--color);
@@ -103,6 +102,8 @@
.callout-icon {
width: 18px;
height: 18px;
+ flex: 0 0 18px;
+ padding-top: 4px;
}
.callout-title-inner {
diff --git a/quartz/styles/custom.scss b/quartz/styles/custom.scss
index b908314b11187..b0c09dcb9d606 100644
--- a/quartz/styles/custom.scss
+++ b/quartz/styles/custom.scss
@@ -1 +1,3 @@
+@use "./base.scss";
+
// put your custom CSS here!
diff --git a/quartz/styles/syntax.scss b/quartz/styles/syntax.scss
index 623ee6f46386e..ba205632ad7e4 100644
--- a/quartz/styles/syntax.scss
+++ b/quartz/styles/syntax.scss
@@ -1,29 +1,17 @@
-// npx convert-sh-theme https://raw.githubusercontent.com/shikijs/shiki/main/packages/shiki/themes/github-light.json
-:root {
- --shiki-color-text: #24292e;
- --shiki-color-background: #f8f8f8;
- --shiki-token-constant: #005cc5;
- --shiki-token-string: #032f62;
- --shiki-token-comment: #6a737d;
- --shiki-token-keyword: #d73a49;
- --shiki-token-parameter: #24292e;
- --shiki-token-function: #24292e;
- --shiki-token-string-expression: #22863a;
- --shiki-token-punctuation: #24292e;
- --shiki-token-link: #24292e;
+code[data-theme*=" "] {
+ color: var(--shiki-light);
+ background-color: var(--shiki-light-bg);
}
-// npx convert-sh-theme https://raw.githubusercontent.com/shikijs/shiki/main/packages/shiki/themes/github-dark.json
-[saved-theme="dark"] {
- --shiki-color-text: #e1e4e8 !important;
- --shiki-color-background: #24292e !important;
- --shiki-token-constant: #79b8ff !important;
- --shiki-token-string: #9ecbff !important;
- --shiki-token-comment: #6a737d !important;
- --shiki-token-keyword: #f97583 !important;
- --shiki-token-parameter: #e1e4e8 !important;
- --shiki-token-function: #e1e4e8 !important;
- --shiki-token-string-expression: #85e89d !important;
- --shiki-token-punctuation: #e1e4e8 !important;
- --shiki-token-link: #e1e4e8 !important;
+code[data-theme*=" "] span {
+ color: var(--shiki-light);
+}
+
+[saved-theme="dark"] code[data-theme*=" "] {
+ color: var(--shiki-dark);
+ background-color: var(--shiki-dark-bg);
+}
+
+[saved-theme="dark"] code[data-theme*=" "] span {
+ color: var(--shiki-dark);
}
diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts
index d3033919012b6..13e0bf8644591 100644
--- a/quartz/util/ctx.ts
+++ b/quartz/util/ctx.ts
@@ -7,6 +7,8 @@ export interface Argv {
output: string
serve: boolean
port: number
+ wsPort: number
+ remoteDevHost?: string
concurrency?: number
}
diff --git a/quartz/util/escape.ts b/quartz/util/escape.ts
index 55144eecaa785..197558c7dec75 100644
--- a/quartz/util/escape.ts
+++ b/quartz/util/escape.ts
@@ -1,12 +1,8 @@
-export function escapeHTML(input: string): string {
- const entityMap: Record = {
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- "'": ''',
- };
-
- return input.replace(/[&<>"']/g, (char) => entityMap[char]);
- }
-
\ No newline at end of file
+export const escapeHTML = (unsafe: string) => {
+ return unsafe
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'")
+}
diff --git a/quartz/util/jsx.tsx b/quartz/util/jsx.tsx
new file mode 100644
index 0000000000000..b525423488cbd
--- /dev/null
+++ b/quartz/util/jsx.tsx
@@ -0,0 +1,27 @@
+import { Components, Jsx, toJsxRuntime } from "hast-util-to-jsx-runtime"
+import { Node, Root } from "hast"
+import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
+import { trace } from "./trace"
+import { type FilePath } from "./path"
+
+const customComponents: Components = {
+ table: (props) => (
+
+ ),
+}
+
+export function htmlToJsx(fp: FilePath, tree: Node) {
+ try {
+ return toJsxRuntime(tree as Root, {
+ Fragment,
+ jsx: jsx as Jsx,
+ jsxs: jsxs as Jsx,
+ elementAttributeNameCase: "html",
+ components: customComponents,
+ })
+ } catch (e) {
+ trace(`Failed to parse Markdown in \`${fp}\` into JSX`, e as Error)
+ }
+}
diff --git a/quartz/util/lang.ts b/quartz/util/lang.ts
new file mode 100644
index 0000000000000..5211b5d9a4218
--- /dev/null
+++ b/quartz/util/lang.ts
@@ -0,0 +1,11 @@
+export function pluralize(count: number, s: string): string {
+ if (count === 1) {
+ return `1 ${s}`
+ } else {
+ return `${count} ${s}s`
+ }
+}
+
+export function capitalize(s: string): string {
+ return s.substring(0, 1).toUpperCase() + s.substring(1)
+}
diff --git a/quartz/util/path.test.ts b/quartz/util/path.test.ts
index 8bbb58dc3071f..18edc9407cb0b 100644
--- a/quartz/util/path.test.ts
+++ b/quartz/util/path.test.ts
@@ -83,7 +83,7 @@ describe("transforms", () => {
test("simplifySlug", () => {
asserts(
[
- ["index", ""],
+ ["index", "/"],
["abc", "abc"],
["abc/index", "abc/"],
["abc/def", "abc/def"],
diff --git a/quartz/util/path.ts b/quartz/util/path.ts
index 1557c1bd561df..6cedffdb62b1f 100644
--- a/quartz/util/path.ts
+++ b/quartz/util/path.ts
@@ -1,4 +1,9 @@
-import { slug } from "github-slugger"
+import { slug as slugAnchor } from "github-slugger"
+import type { Element as HastElement } from "hast"
+import rfdc from "rfdc"
+
+export const clone = rfdc()
+
// this file must be isomorphic so it can't use node libs (e.g. path)
export const QUARTZ = "quartz"
@@ -24,7 +29,7 @@ export function isFullSlug(s: string): s is FullSlug {
/** 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 validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/")))
const validEnding = !(s.endsWith("/index") || s === "index")
return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s)
}
@@ -42,6 +47,14 @@ export function getFullSlug(window: Window): FullSlug {
return res
}
+function sluggify(s: string): string {
+ return s
+ .split("/")
+ .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments
+ .join("/") // always use / as sep
+ .replace(/\/$/, "")
+}
+
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
fp = _stripSlashes(fp) as FilePath
let ext = _getFileExtension(fp)
@@ -50,11 +63,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
ext = ""
}
- let slug = withoutFileExt
- .split("/")
- .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent")) // slugify all segments
- .join("/") // always use / as sep
- .replace(/\/$/, "") // remove trailing slash
+ let slug = sluggify(withoutFileExt)
// treat _index as index
if (_endsWith(slug, "_index")) {
@@ -65,7 +74,8 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
}
export function simplifySlug(fp: FullSlug): SimpleSlug {
- return _stripSlashes(_trimSuffix(fp, "index"), true) as SimpleSlug
+ const res = _stripSlashes(_trimSuffix(fp, "index"), true)
+ return (res.length === 0 ? "/" : res) as SimpleSlug
}
export function transformInternalLink(link: string): RelativeURL {
@@ -84,6 +94,50 @@ export function transformInternalLink(link: string): RelativeURL {
return res
}
+// from micromorph/src/utils.ts
+// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
+const _rebaseHtmlElement = (el: Element, attr: string, newBase: string | URL) => {
+ const rebased = new URL(el.getAttribute(attr)!, newBase)
+ el.setAttribute(attr, rebased.pathname + rebased.hash)
+}
+export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) {
+ el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) =>
+ _rebaseHtmlElement(item, "href", destination),
+ )
+ el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) =>
+ _rebaseHtmlElement(item, "src", destination),
+ )
+}
+
+const _rebaseHastElement = (
+ el: HastElement,
+ attr: string,
+ curBase: FullSlug,
+ newBase: FullSlug,
+) => {
+ if (el.properties?.[attr]) {
+ if (!isRelativeURL(String(el.properties[attr]))) {
+ return
+ }
+
+ const rel = joinSegments(resolveRelative(curBase, newBase), "..", el.properties[attr] as string)
+ el.properties[attr] = rel
+ }
+}
+
+export function normalizeHastElement(rawEl: HastElement, curBase: FullSlug, newBase: FullSlug) {
+ const el = clone(rawEl) // clone so we dont modify the original page
+ _rebaseHastElement(el, "src", curBase, newBase)
+ _rebaseHastElement(el, "href", curBase, newBase)
+ if (el.children) {
+ el.children = el.children.map((child) =>
+ normalizeHastElement(child as HastElement, curBase, newBase),
+ )
+ }
+
+ return el
+}
+
// resolve /a/b/c to ../..
export function pathToRoot(slug: FullSlug): RelativeURL {
let rootPath = slug
@@ -111,19 +165,18 @@ export function splitAnchor(link: string): [string, string] {
return [fp, anchor]
}
-export function slugAnchor(anchor: string) {
- return slug(anchor)
-}
-
export function slugTag(tag: string) {
return tag
.split("/")
- .map((tagSegment) => slug(tagSegment))
+ .map((tagSegment) => sluggify(tagSegment))
.join("/")
}
export function joinSegments(...args: string[]): string {
- return args.filter((segment) => segment !== "").join("/")
+ return args
+ .filter((segment) => segment !== "")
+ .join("/")
+ .replace(/\/\/+/g, "/")
}
export function getAllSegmentPrefixes(tags: string): string[] {
diff --git a/quartz/util/resources.tsx b/quartz/util/resources.tsx
index a185733e237dc..a572d891f7655 100644
--- a/quartz/util/resources.tsx
+++ b/quartz/util/resources.tsx
@@ -26,9 +26,12 @@ export function JSResourceToScriptElement(resource: JSResource, preserve?: boole
} else {
const content = resource.script
return (
-
+
)
}
}
diff --git a/quartz/util/trace.ts b/quartz/util/trace.ts
index c7f3cc339caa2..a33135d644c77 100644
--- a/quartz/util/trace.ts
+++ b/quartz/util/trace.ts
@@ -4,7 +4,7 @@ import { isMainThread } from "workerpool"
const rootFile = /.*at file:/
export function trace(msg: string, err: Error) {
- const stack = err.stack
+ let stack = err.stack ?? ""
const lines: string[] = []
@@ -12,15 +12,11 @@ export function trace(msg: string, err: Error) {
lines.push(
"\n" +
chalk.bgRed.black.bold(" ERROR ") +
- "\n" +
+ "\n\n" +
chalk.red(` ${msg}`) +
(err.message.length > 0 ? `: ${err.message}` : ""),
)
- if (!stack) {
- return
- }
-
let reachedEndOfLegibleTrace = false
for (const line of stack.split("\n").slice(1)) {
if (reachedEndOfLegibleTrace) {