Found {tags.length} total tags.
{tags.map((tag) => {
const pages = tagItemMap.get(tag)!
@@ -62,11 +62,17 @@ function TagContent(props: QuartzComponentProps) {
{content &&
{content}
}
-
- {pluralize(pages.length, "item")} with this tag.{" "}
- {pages.length > numPages && `Showing first ${numPages}.`}
-
-
+
+
+ {i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
+ {pages.length > numPages && (
+
+ {i18n(cfg.locale).pages.tagContent.showingFirst({ count: numPages })}
+
+ )}
+
+
+
)
})}
@@ -81,11 +87,13 @@ function TagContent(props: QuartzComponentProps) {
}
return (
-
+
{content}
-
{pluralize(pages.length, "item")} with this tag.
-
-
+
+
{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
+
)
diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx
index b3fe06ba..5394d6f4 100644
--- a/quartz/components/renderPage.tsx
+++ b/quartz/components/renderPage.tsx
@@ -3,10 +3,11 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header"
import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
-import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
+import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast"
-import { QuartzPluginData } from "../plugins/vfile"
+import { GlobalConfiguration } from "../cfg"
+import { i18n } from "../i18n"
interface RenderComponents {
head: QuartzComponent
@@ -50,32 +51,25 @@ export function pageResources(
}
}
-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(
+ cfg: GlobalConfiguration,
slug: FullSlug,
componentData: QuartzComponentProps,
components: RenderComponents,
pageResources: StaticResources,
): string {
+ // make a deep copy of the tree so we don't remove the transclusion references
+ // for the file cached in contentMap in build.ts
+ const root = clone(componentData.tree) as Root
+
// process transcludes in componentData
- visit(componentData.tree as Root, "element", (node, _index, _parent) => {
+ visit(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)
+ const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
if (!page) {
return
}
@@ -100,8 +94,10 @@ export function renderPage(
{
type: "element",
tagName: "a",
- properties: { href: inner.properties?.href, class: ["internal"] },
- children: [{ type: "text", value: `Link to original` }],
+ properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
+ children: [
+ { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
+ ],
},
]
}
@@ -135,8 +131,10 @@ export function renderPage(
{
type: "element",
tagName: "a",
- properties: { href: inner.properties?.href, class: ["internal"] },
- children: [{ type: "text", value: `Link to original` }],
+ properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
+ children: [
+ { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
+ ],
},
]
} else if (page.htmlAst) {
@@ -147,7 +145,14 @@ export function renderPage(
tagName: "h1",
properties: {},
children: [
- { type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
+ {
+ type: "text",
+ value:
+ page.frontmatter?.title ??
+ i18n(cfg.locale).components.transcludes.transcludeOf({
+ targetSlug: page.slug!,
+ }),
+ },
],
},
...(page.htmlAst.children as ElementContent[]).map((child) =>
@@ -156,8 +161,10 @@ export function renderPage(
{
type: "element",
tagName: "a",
- properties: { href: inner.properties?.href, class: ["internal"] },
- children: [{ type: "text", value: `Link to original` }],
+ properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
+ children: [
+ { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
+ ],
},
]
}
@@ -165,6 +172,9 @@ export function renderPage(
}
})
+ // set componentData.tree to the edited html that has transclusions rendered
+ componentData.tree = root
+
const {
head: Head,
header,
@@ -193,8 +203,9 @@ export function renderPage(
)
+ const lang = componentData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
const doc = (
-
+
diff --git a/quartz/components/scripts/callout.inline.ts b/quartz/components/scripts/callout.inline.ts
index d8cf5180..8f63df36 100644
--- a/quartz/components/scripts/callout.inline.ts
+++ b/quartz/components/scripts/callout.inline.ts
@@ -1,21 +1,21 @@
function toggleCallout(this: HTMLElement) {
const outerBlock = this.parentElement!
- outerBlock.classList.toggle(`is-collapsed`)
- const collapsed = outerBlock.classList.contains(`is-collapsed`)
+ outerBlock.classList.toggle("is-collapsed")
+ const collapsed = outerBlock.classList.contains("is-collapsed")
const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight
- outerBlock.style.maxHeight = height + `px`
+ outerBlock.style.maxHeight = height + "px"
// walk and adjust height of all parents
let current = outerBlock
let parent = outerBlock.parentElement
while (parent) {
- if (!parent.classList.contains(`callout`)) {
+ if (!parent.classList.contains("callout")) {
return
}
- const collapsed = parent.classList.contains(`is-collapsed`)
+ const collapsed = parent.classList.contains("is-collapsed")
const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
- parent.style.maxHeight = height + `px`
+ parent.style.maxHeight = height + "px"
current = parent
parent = parent.parentElement
@@ -30,15 +30,15 @@ function setupCallout() {
const title = div.firstElementChild
if (title) {
- title.removeEventListener(`click`, toggleCallout)
- title.addEventListener(`click`, toggleCallout)
+ title.addEventListener("click", toggleCallout)
+ window.addCleanup(() => title.removeEventListener("click", toggleCallout))
- const collapsed = div.classList.contains(`is-collapsed`)
+ const collapsed = div.classList.contains("is-collapsed")
const height = collapsed ? title.scrollHeight : div.scrollHeight
- div.style.maxHeight = height + `px`
+ div.style.maxHeight = height + "px"
}
}
}
-document.addEventListener(`nav`, setupCallout)
-window.addEventListener(`resize`, setupCallout)
+document.addEventListener("nav", setupCallout)
+window.addEventListener("resize", setupCallout)
diff --git a/quartz/components/scripts/checkbox.inline.ts b/quartz/components/scripts/checkbox.inline.ts
new file mode 100644
index 00000000..50ab0425
--- /dev/null
+++ b/quartz/components/scripts/checkbox.inline.ts
@@ -0,0 +1,23 @@
+import { getFullSlug } from "../../util/path"
+
+const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
+
+document.addEventListener("nav", () => {
+ const checkboxes = document.querySelectorAll(
+ "input.checkbox-toggle",
+ ) as NodeListOf
+ checkboxes.forEach((el, index) => {
+ const elId = checkboxId(index)
+
+ const switchState = (e: Event) => {
+ const newCheckboxState = (e.target as HTMLInputElement)?.checked ? "true" : "false"
+ localStorage.setItem(elId, newCheckboxState)
+ }
+
+ el.addEventListener("change", switchState)
+ window.addCleanup(() => el.removeEventListener("change", switchState))
+ if (localStorage.getItem(elId) === "true") {
+ el.checked = true
+ }
+ })
+})
diff --git a/quartz/components/scripts/clipboard.inline.ts b/quartz/components/scripts/clipboard.inline.ts
index c604c9bc..87182a15 100644
--- a/quartz/components/scripts/clipboard.inline.ts
+++ b/quartz/components/scripts/clipboard.inline.ts
@@ -14,7 +14,7 @@ document.addEventListener("nav", () => {
button.type = "button"
button.innerHTML = svgCopy
button.ariaLabel = "Copy source"
- button.addEventListener("click", () => {
+ function onClick() {
navigator.clipboard.writeText(source).then(
() => {
button.blur()
@@ -26,7 +26,9 @@ document.addEventListener("nav", () => {
},
(error) => console.error(error),
)
- })
+ }
+ button.addEventListener("click", onClick)
+ window.addCleanup(() => button.removeEventListener("click", onClick))
els[i].prepend(button)
}
}
diff --git a/quartz/components/scripts/darkmode.inline.ts b/quartz/components/scripts/darkmode.inline.ts
index c42a367c..48e0aa1f 100644
--- a/quartz/components/scripts/darkmode.inline.ts
+++ b/quartz/components/scripts/darkmode.inline.ts
@@ -2,31 +2,39 @@ const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "l
const currentTheme = localStorage.getItem("theme") ?? userPref
document.documentElement.setAttribute("saved-theme", currentTheme)
+const emitThemeChangeEvent = (theme: "light" | "dark") => {
+ const event: CustomEventMap["themechange"] = new CustomEvent("themechange", {
+ detail: { theme },
+ })
+ document.dispatchEvent(event)
+}
+
document.addEventListener("nav", () => {
- const switchTheme = (e: any) => {
- if (e.target.checked) {
- document.documentElement.setAttribute("saved-theme", "dark")
- localStorage.setItem("theme", "dark")
- } else {
- document.documentElement.setAttribute("saved-theme", "light")
- localStorage.setItem("theme", "light")
- }
+ const switchTheme = (e: Event) => {
+ const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light"
+ document.documentElement.setAttribute("saved-theme", newTheme)
+ localStorage.setItem("theme", newTheme)
+ emitThemeChangeEvent(newTheme)
+ }
+
+ const themeChange = (e: MediaQueryListEvent) => {
+ const newTheme = e.matches ? "dark" : "light"
+ document.documentElement.setAttribute("saved-theme", newTheme)
+ localStorage.setItem("theme", newTheme)
+ toggleSwitch.checked = e.matches
+ emitThemeChangeEvent(newTheme)
}
// Darkmode toggle
const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement
- toggleSwitch.removeEventListener("change", switchTheme)
toggleSwitch.addEventListener("change", switchTheme)
+ window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
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
- })
+ colorSchemeMediaQuery.addEventListener("change", themeChange)
+ window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange))
})
diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts
index 72404ed2..3eb25ead 100644
--- a/quartz/components/scripts/explorer.inline.ts
+++ b/quartz/components/scripts/explorer.inline.ts
@@ -1,135 +1,106 @@
import { FolderState } from "../ExplorerNode"
-// Current state of folders
-let explorerState: FolderState[]
-
+type MaybeHTMLElement = HTMLElement | undefined
+let currentExplorerState: 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")
+ const explorerUl = document.getElementById("explorer-ul")
+ if (!explorerUl) return
for (const entry of entries) {
if (entry.isIntersecting) {
- explorer?.classList.add("no-background")
+ explorerUl.classList.add("no-background")
} else {
- explorer?.classList.remove("no-background")
+ explorerUl.classList.remove("no-background")
}
}
})
function toggleExplorer(this: HTMLElement) {
- // Toggle collapsed state of entire explorer
this.classList.toggle("collapsed")
- const content = this.nextElementSibling as HTMLElement
+ const content = this.nextElementSibling as MaybeHTMLElement
+ if (!content) return
+
content.classList.toggle("collapsed")
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
}
function toggleFolder(evt: MouseEvent) {
evt.stopPropagation()
+ const target = evt.target as MaybeHTMLElement
+ if (!target) return
- // 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 childFolderContainer = (
+ isSvg
+ ? target.parentElement?.nextSibling
+ : target.parentElement?.parentElement?.nextElementSibling
+ ) as MaybeHTMLElement
+ const currentFolderParent = (
+ isSvg ? target.nextElementSibling : target.parentElement
+ ) as MaybeHTMLElement
+ if (!(childFolderContainer && currentFolderParent)) return
+
+ childFolderContainer.classList.toggle("open")
const isCollapsed = childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, !isCollapsed)
-
- // Save folder state to localStorage
- const clickFolderPath = currentFolderParent.dataset.folderpath as string
-
- // Remove leading "/"
- const fullFolderPath = clickFolderPath.substring(1)
- toggleCollapsedByPath(explorerState, fullFolderPath)
-
- const stringifiedFileTree = JSON.stringify(explorerState)
+ const fullFolderPath = currentFolderParent.dataset.folderpath as string
+ toggleCollapsedByPath(currentExplorerState, fullFolderPath)
+ const stringifiedFileTree = JSON.stringify(currentExplorerState)
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)
- },
- )
+ if (!explorer) return
+
+ if (explorer.dataset.behavior === "collapse") {
+ for (const item of document.getElementsByClassName(
+ "folder-button",
+ ) as HTMLCollectionOf) {
+ item.addEventListener("click", toggleFolder)
+ window.addCleanup(() => item.removeEventListener("click", toggleFolder))
}
-
- // Add click handler to main explorer
- explorer.removeEventListener("click", toggleExplorer)
- explorer.addEventListener("click", toggleExplorer)
}
+ explorer.addEventListener("click", toggleExplorer)
+ window.addCleanup(() => explorer.removeEventListener("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)
+ for (const item of document.getElementsByClassName(
+ "folder-icon",
+ ) as HTMLCollectionOf) {
item.addEventListener("click", toggleFolder)
- })
+ window.addCleanup(() => item.removeEventListener("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)
+ // Get folder state from local storage
+ const storageTree = localStorage.getItem("fileTree")
+ const useSavedFolderState = explorer?.dataset.savestate === "true"
+ const oldExplorerState: FolderState[] =
+ storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
+ const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
+ const newExplorerState: FolderState[] = explorer.dataset.tree
+ ? JSON.parse(explorer.dataset.tree)
+ : []
+ currentExplorerState = []
+ for (const { path, collapsed } of newExplorerState) {
+ currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed })
}
+
+ currentExplorerState.map((folderState) => {
+ const folderLi = document.querySelector(
+ `[data-folderpath='${folderState.path}']`,
+ ) as MaybeHTMLElement
+ const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
+ if (folderUl) {
+ setFolderState(folderUl, folderState.collapsed)
+ }
+ })
}
window.addEventListener("resize", setupExplorer)
document.addEventListener("nav", () => {
setupExplorer()
-
observer.disconnect()
// select pseudo element at end of list
@@ -145,11 +116,7 @@ document.addEventListener("nav", () => {
* @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")
- }
+ return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
}
/**
diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts
index bddcfa4c..c991e163 100644
--- a/quartz/components/scripts/graph.inline.ts
+++ b/quartz/components/scripts/graph.inline.ts
@@ -319,12 +319,12 @@ function renderGlobalGraph() {
registerEscapeHandler(container, hideGlobalGraph)
}
-document.addEventListener("nav", async (e: unknown) => {
- const slug = (e as CustomEventMap["nav"]).detail.url
+document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
+ const slug = e.detail.url
addToVisited(slug)
await renderGraph("graph-container", slug)
const containerIcon = document.getElementById("global-graph-icon")
- containerIcon?.removeEventListener("click", renderGlobalGraph)
containerIcon?.addEventListener("click", renderGlobalGraph)
+ window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
})
diff --git a/quartz/components/scripts/plausible.inline.ts b/quartz/components/scripts/plausible.inline.ts
deleted file mode 100644
index 704f5d5f..00000000
--- 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 4d51e2a6..d0346b05 100644
--- a/quartz/components/scripts/popover.inline.ts
+++ b/quartz/components/scripts/popover.inline.ts
@@ -37,29 +37,47 @@ async function mouseEnterHandler(
targetUrl.hash = ""
targetUrl.search = ""
- const contents = await fetch(`${targetUrl}`)
- .then((res) => res.text())
- .catch((err) => {
- console.error(err)
- })
+ const response = await fetch(`${targetUrl}`).catch((err) => {
+ console.error(err)
+ })
// bailout if another popover exists
if (hasAlreadyBeenFetched()) {
return
}
- if (!contents) return
- const html = p.parseFromString(contents, "text/html")
- normalizeRelativeURLs(html, targetUrl)
- const elts = [...html.getElementsByClassName("popover-hint")]
- if (elts.length === 0) return
+ if (!response) return
+ const contentType = response.headers.get("Content-Type")
+ const contentTypeCategory = contentType?.split("/")[0] ?? "text"
const popoverElement = document.createElement("div")
popoverElement.classList.add("popover")
const popoverInner = document.createElement("div")
popoverInner.classList.add("popover-inner")
popoverElement.appendChild(popoverInner)
- elts.forEach((elt) => popoverInner.appendChild(elt))
+
+ popoverInner.dataset.contentType = contentTypeCategory
+
+ switch (contentTypeCategory) {
+ case "image":
+ const img = document.createElement("img")
+
+ response.blob().then((blob) => {
+ img.src = URL.createObjectURL(blob)
+ })
+ img.alt = targetUrl.pathname
+
+ popoverInner.appendChild(img)
+ break
+ default:
+ const contents = await response.text()
+ const html = p.parseFromString(contents, "text/html")
+ normalizeRelativeURLs(html, targetUrl)
+ const elts = [...html.getElementsByClassName("popover-hint")]
+ if (elts.length === 0) return
+
+ elts.forEach((elt) => popoverInner.appendChild(elt))
+ }
setPosition(popoverElement)
link.appendChild(popoverElement)
@@ -76,7 +94,7 @@ async function mouseEnterHandler(
document.addEventListener("nav", () => {
const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]
for (const link of links) {
- link.removeEventListener("mouseenter", mouseEnterHandler)
link.addEventListener("mouseenter", mouseEnterHandler)
+ window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler))
}
})
diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts
index eff4eb1b..a75f4ff4 100644
--- a/quartz/components/scripts/search.inline.ts
+++ b/quartz/components/scripts/search.inline.ts
@@ -1,7 +1,7 @@
-import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch"
+import FlexSearch from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { registerEscapeHandler, removeAllChildren } from "./util"
-import { FullSlug, resolveRelative } from "../../util/path"
+import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
interface Item {
id: number
@@ -11,23 +11,53 @@ interface Item {
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"
+let currentSearchTerm: string = ""
+const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
+let index = new FlexSearch.Document
- ({
+ charset: "latin:extra",
+ encode: encoder,
+ document: {
+ id: "id",
+ index: [
+ {
+ field: "title",
+ tokenize: "forward",
+ },
+ {
+ field: "content",
+ tokenize: "forward",
+ },
+ {
+ field: "tags",
+ tokenize: "forward",
+ },
+ ],
+ },
+})
+const p = new DOMParser()
+const fetchContentCache: Map = new Map()
const contextWindowWords = 30
-const numSearchResults = 5
-const numTagResults = 3
+const numSearchResults = 8
+const numTagResults = 5
+
+const tokenizeTerm = (term: string) => {
+ const tokens = term.split(/\s+/).filter((t) => t.trim() !== "")
+ const tokenLen = tokens.length
+ if (tokenLen > 1) {
+ for (let i = 1; i < tokenLen; i++) {
+ tokens.push(tokens.slice(0, i + 1).join(" "))
+ }
+ }
+
+ return tokens.sort((a, b) => b.length - a.length) // always highlight longest terms first
+}
+
function highlight(searchTerm: string, text: string, trim?: boolean) {
- // try to highlight longest tokens first
- const tokenizedTerms = searchTerm
- .split(/\s+/)
- .filter((t) => t !== "")
- .sort((a, b) => b.length - a.length)
+ const tokenizedTerms = tokenizeTerm(searchTerm)
let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
let startIndex = 0
@@ -35,12 +65,12 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
if (trim) {
const includesCheck = (tok: string) =>
tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
- const occurencesIndices = tokenizedText.map(includesCheck)
+ const occurrencesIndices = tokenizedText.map(includesCheck)
let bestSum = 0
let bestIndex = 0
for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) {
- const window = occurencesIndices.slice(i, i + contextWindowWords)
+ const window = occurrencesIndices.slice(i, i + contextWindowWords)
const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0)
if (windowSum >= bestSum) {
bestSum = windowSum
@@ -71,20 +101,76 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
}`
}
-const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
-let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined
-document.addEventListener("nav", async (e: unknown) => {
- const currentSlug = (e as CustomEventMap["nav"]).detail.url
+function highlightHTML(searchTerm: string, el: HTMLElement) {
+ const p = new DOMParser()
+ const tokenizedTerms = tokenizeTerm(searchTerm)
+ const html = p.parseFromString(el.innerHTML, "text/html")
+
+ const createHighlightSpan = (text: string) => {
+ const span = document.createElement("span")
+ span.className = "highlight"
+ span.textContent = text
+ return span
+ }
+
+ const highlightTextNodes = (node: Node, term: string) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const nodeText = node.nodeValue ?? ""
+ const regex = new RegExp(term.toLowerCase(), "gi")
+ const matches = nodeText.match(regex)
+ if (!matches || matches.length === 0) return
+ const spanContainer = document.createElement("span")
+ let lastIndex = 0
+ for (const match of matches) {
+ const matchIndex = nodeText.indexOf(match, lastIndex)
+ spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex)))
+ spanContainer.appendChild(createHighlightSpan(match))
+ lastIndex = matchIndex + match.length
+ }
+ spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex)))
+ node.parentNode?.replaceChild(spanContainer, node)
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
+ if ((node as HTMLElement).classList.contains("highlight")) return
+ Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term))
+ }
+ }
+
+ for (const term of tokenizedTerms) {
+ highlightTextNodes(html.body, term)
+ }
+
+ return html.body
+}
+document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
+ const currentSlug = e.detail.url
const data = await fetchData
const container = document.getElementById("search-container")
const sidebar = container?.closest(".sidebar") as HTMLElement
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 searchLayout = document.getElementById("search-layout")
const idDataMap = Object.keys(data) as FullSlug[]
+ const appendLayout = (el: HTMLElement) => {
+ if (searchLayout?.querySelector(`#${el.id}`) === null) {
+ searchLayout?.appendChild(el)
+ }
+ }
+
+ const enablePreview = searchLayout?.dataset?.preview === "true"
+ let preview: HTMLDivElement | undefined = undefined
+ let previewInner: HTMLDivElement | undefined = undefined
+ const results = document.createElement("div")
+ results.id = "results-container"
+ appendLayout(results)
+
+ if (enablePreview) {
+ preview = document.createElement("div")
+ preview.id = "preview-container"
+ appendLayout(preview)
+ }
+
function hideSearch() {
container?.classList.remove("active")
if (searchBar) {
@@ -96,6 +182,12 @@ document.addEventListener("nav", async (e: unknown) => {
if (results) {
removeAllChildren(results)
}
+ if (preview) {
+ removeAllChildren(preview)
+ }
+ if (searchLayout) {
+ searchLayout.classList.remove("display-results")
+ }
searchType = "basic" // reset search type after closing
}
@@ -109,11 +201,14 @@ document.addEventListener("nav", async (e: unknown) => {
searchBar?.focus()
}
- function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
+ let currentHover: HTMLInputElement | null = null
+
+ async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault()
const searchBarOpen = container?.classList.contains("active")
searchBarOpen ? hideSearch() : showSearch("basic")
+ return
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
// Hotkey to open tag search
e.preventDefault()
@@ -122,156 +217,205 @@ document.addEventListener("nav", async (e: unknown) => {
// add "#" prefix for tag search
if (searchBar) searchBar.value = "#"
- } else if (e.key === "Enter") {
+ return
+ }
+
+ if (currentHover) {
+ currentHover.classList.remove("focus")
+ }
+
+ // If search is active, then we will render the first result and display accordingly
+ if (!container?.classList.contains("active")) return
+ if (e.key === "Enter") {
// If result has focus, navigate to that one, otherwise pick first result
if (results?.contains(document.activeElement)) {
const active = document.activeElement as HTMLInputElement
+ if (active.classList.contains("no-match")) return
+ await displayPreview(active)
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()
+ if (!anchor || anchor?.classList.contains("no-match")) return
+ await displayPreview(anchor)
+ anchor.click()
}
- } else if (e.key === "ArrowUp") {
+ } else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
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
+ const currentResult = currentHover
+ ? currentHover
+ : (document.activeElement as HTMLInputElement | null)
+ const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null
+ currentResult?.classList.remove("focus")
prevResult?.focus()
+ if (prevResult) currentHover = prevResult
+ await displayPreview(prevResult)
+ }
+ } else if (e.key === "ArrowDown" || e.key === "Tab") {
+ e.preventDefault()
+ // The results should already been focused, so we need to find the next one.
+ // The activeElement is the search bar, so we need to find the first result and focus it.
+ if (document.activeElement === searchBar || currentHover !== null) {
+ const firstResult = currentHover
+ ? currentHover
+ : (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null)
+ const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null
+ firstResult?.classList.remove("focus")
+ secondResult?.focus()
+ if (secondResult) currentHover = secondResult
+ await displayPreview(secondResult)
}
}
}
- 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) => {
const slug = idDataMap[id]
return {
id,
slug,
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),
+ content: highlight(term, data[slug].content ?? "", true),
+ tags: highlightTags(term.substring(1), 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 {
+ if (!tags || searchType !== "tags") {
return []
}
+
+ return tags
+ .map((tag) => {
+ if (tag.toLowerCase().includes(term.toLowerCase())) {
+ return `#${tag}
`
+ } else {
+ return `#${tag}
`
+ }
+ })
+ .slice(0, numTagResults)
+ }
+
+ function resolveUrl(slug: FullSlug): URL {
+ return new URL(resolveRelative(currentSlug, slug), location.toString())
}
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}
${htmlTags}${content}
`
- button.addEventListener("click", () => {
- const targ = resolveRelative(currentSlug, slug)
- window.spaNavigate(new URL(targ, window.location.toString()))
+ const htmlTags = tags.length > 0 ? `` : ``
+ const itemTile = document.createElement("a")
+ itemTile.classList.add("result-card")
+ itemTile.id = slug
+ itemTile.href = resolveUrl(slug).toString()
+ itemTile.innerHTML = `${title}
${htmlTags}${
+ enablePreview && window.innerWidth > 600 ? "" : `${content}
`
+ }`
+ itemTile.addEventListener("click", (event) => {
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
hideSearch()
})
- return button
+
+ const handler = (event: MouseEvent) => {
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
+ hideSearch()
+ }
+
+ async function onMouseEnter(ev: MouseEvent) {
+ if (!ev.target) return
+ const target = ev.target as HTMLInputElement
+ await displayPreview(target)
+ }
+
+ itemTile.addEventListener("mouseenter", onMouseEnter)
+ window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter))
+ itemTile.addEventListener("click", handler)
+ window.addCleanup(() => itemTile.removeEventListener("click", handler))
+
+ return itemTile
}
- function displayResults(finalResults: Item[]) {
+ async function displayResults(finalResults: Item[]) {
if (!results) return
removeAllChildren(results)
if (finalResults.length === 0) {
- results.innerHTML = ``
+ results.innerHTML = `
+ No results.
+ Try another search term?
+ `
} else {
results.append(...finalResults.map(resultToHTML))
}
- }
- async function onType(e: HTMLElementEventMap["input"]) {
- let term = (e.target as HTMLInputElement).value
- let searchResults: SimpleDocumentSearchResultSetUnit[]
-
- if (term.toLowerCase().startsWith("#")) {
- searchType = "tags"
+ if (finalResults.length === 0 && preview) {
+ // no results, clear previous preview
+ removeAllChildren(preview)
} else {
- searchType = "basic"
+ // focus on first result, then also dispatch preview immediately
+ const firstChild = results.firstElementChild as HTMLElement
+ firstChild.classList.add("focus")
+ currentHover = firstChild as HTMLInputElement
+ await displayPreview(firstChild)
}
+ }
- 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"],
- })) ?? []
- }
+ async function fetchContent(slug: FullSlug): Promise {
+ if (fetchContentCache.has(slug)) {
+ return fetchContentCache.get(slug) as Element[]
+ }
+
+ const targetUrl = resolveUrl(slug).toString()
+ const contents = await fetch(targetUrl)
+ .then((res) => res.text())
+ .then((contents) => {
+ if (contents === undefined) {
+ throw new Error(`Could not fetch ${targetUrl}`)
+ }
+ const html = p.parseFromString(contents ?? "", "text/html")
+ normalizeRelativeURLs(html, targetUrl)
+ return [...html.getElementsByClassName("popover-hint")]
+ })
+
+ fetchContentCache.set(slug, contents)
+ return contents
+ }
+
+ async function displayPreview(el: HTMLElement | null) {
+ if (!searchLayout || !enablePreview || !el || !preview) return
+ const slug = el.id as FullSlug
+ const innerDiv = await fetchContent(slug).then((contents) =>
+ contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]),
+ )
+ previewInner = document.createElement("div")
+ previewInner.classList.add("preview-inner")
+ previewInner.append(...innerDiv)
+ preview.replaceChildren(previewInner)
+
+ // scroll to longest
+ const highlights = [...preview.querySelectorAll(".highlight")].sort(
+ (a, b) => b.innerHTML.length - a.innerHTML.length,
+ )
+ highlights[0]?.scrollIntoView({ block: "start" })
+ }
+
+ async function onType(e: HTMLElementEventMap["input"]) {
+ if (!searchLayout || !index) return
+ currentSearchTerm = (e.target as HTMLInputElement).value
+ searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
+ searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
+
+ let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]
+ if (searchType === "tags") {
+ searchResults = await index.searchAsync({
+ query: currentSearchTerm.substring(1),
+ limit: numSearchResults,
+ index: ["tags"],
+ })
+ } else if (searchType === "basic") {
+ searchResults = await index.searchAsync({
+ query: currentSearchTerm,
+ limit: numSearchResults,
+ index: ["title", "content"],
+ })
}
const getByField = (field: string): number[] => {
@@ -285,51 +429,19 @@ document.addEventListener("nav", async (e: unknown) => {
...getByField("content"),
...getByField("tags"),
])
- const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
- displayResults(finalResults)
- }
-
- if (prevShortcutHandler) {
- document.removeEventListener("keydown", prevShortcutHandler)
+ const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id))
+ await displayResults(finalResults)
}
document.addEventListener("keydown", shortcutHandler)
- prevShortcutHandler = shortcutHandler
- searchIcon?.removeEventListener("click", () => showSearch("basic"))
+ window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
searchIcon?.addEventListener("click", () => showSearch("basic"))
- searchBar?.removeEventListener("input", onType)
+ window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic")))
searchBar?.addEventListener("input", onType)
+ window.addCleanup(() => searchBar?.removeEventListener("input", onType))
- // setup index if it hasn't been already
- if (!index) {
- index = new Document({
- charset: "latin:extra",
- optimize: true,
- encode: encoder,
- document: {
- id: "id",
- index: [
- {
- field: "title",
- tokenize: "reverse",
- },
- {
- field: "content",
- tokenize: "reverse",
- },
- {
- field: "tags",
- tokenize: "reverse",
- },
- ],
- },
- })
-
- fillDocument(index, data)
- }
-
- // register handlers
registerEscapeHandler(container, hideSearch)
+ await fillDocument(data)
})
/**
@@ -337,16 +449,20 @@ document.addEventListener("nav", async (e: unknown) => {
* @param index index to fill
* @param data data to fill index with
*/
-async function fillDocument(index: Document- , data: any) {
+async function fillDocument(data: { [key: FullSlug]: ContentDetails }) {
let id = 0
+ const promises: Array> = []
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++
+ promises.push(
+ index.addAsync(id++, {
+ id,
+ slug: slug as FullSlug,
+ title: fileData.title,
+ content: fileData.content,
+ tags: fileData.tags,
+ }),
+ )
}
+
+ return await Promise.all(promises)
}
diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts
index c2a44c9a..1790bcab 100644
--- a/quartz/components/scripts/spa.inline.ts
+++ b/quartz/components/scripts/spa.inline.ts
@@ -39,6 +39,9 @@ function notifyNav(url: FullSlug) {
document.dispatchEvent(event)
}
+const cleanupFns: Set<(...args: any[]) => void> = new Set()
+window.addCleanup = (fn) => cleanupFns.add(fn)
+
let p: DOMParser
async function navigate(url: URL, isBack: boolean = false) {
p = p || new DOMParser()
@@ -57,6 +60,10 @@ async function navigate(url: URL, isBack: boolean = false) {
if (!contents) return
+ // cleanup old
+ cleanupFns.forEach((fn) => fn())
+ cleanupFns.clear()
+
const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, url)
diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts
index f3da52cd..546859ed 100644
--- a/quartz/components/scripts/toc.inline.ts
+++ b/quartz/components/scripts/toc.inline.ts
@@ -16,7 +16,8 @@ const observer = new IntersectionObserver((entries) => {
function toggleToc(this: HTMLElement) {
this.classList.toggle("collapsed")
- const content = this.nextElementSibling as HTMLElement
+ const content = this.nextElementSibling as HTMLElement | undefined
+ if (!content) return
content.classList.toggle("collapsed")
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
}
@@ -25,10 +26,11 @@ function setupToc() {
const toc = document.getElementById("toc")
if (toc) {
const collapsed = toc.classList.contains("collapsed")
- const content = toc.nextElementSibling as HTMLElement
+ const content = toc.nextElementSibling as HTMLElement | undefined
+ if (!content) return
content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
- toc.removeEventListener("click", toggleToc)
toc.addEventListener("click", toggleToc)
+ window.addCleanup(() => toc.removeEventListener("click", toggleToc))
}
}
diff --git a/quartz/components/scripts/util.ts b/quartz/components/scripts/util.ts
index 5fcabadc..4ffff29e 100644
--- a/quartz/components/scripts/util.ts
+++ b/quartz/components/scripts/util.ts
@@ -12,10 +12,10 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb:
cb()
}
- outsideContainer?.removeEventListener("click", click)
outsideContainer?.addEventListener("click", click)
- document.removeEventListener("keydown", esc)
+ window.addCleanup(() => outsideContainer?.removeEventListener("click", click))
document.addEventListener("keydown", esc)
+ window.addCleanup(() => document.removeEventListener("keydown", esc))
}
export function removeAllChildren(node: HTMLElement) {
diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss
index 28e9f9bb..55ea8aa8 100644
--- a/quartz/components/styles/explorer.scss
+++ b/quartz/components/styles/explorer.scss
@@ -1,3 +1,5 @@
+@use "../../styles/variables.scss" as *;
+
button#explorer {
all: unset;
background-color: transparent;
@@ -85,7 +87,7 @@ svg {
color: var(--secondary);
font-family: var(--headerFont);
font-size: 0.95rem;
- font-weight: 600;
+ font-weight: $semiBoldWeight;
line-height: 1.5rem;
display: inline-block;
}
@@ -106,11 +108,11 @@ svg {
align-items: center;
font-family: var(--headerFont);
- & p {
+ & span {
font-size: 0.95rem;
display: inline-block;
color: var(--secondary);
- font-weight: 600;
+ font-weight: $semiBoldWeight;
margin: 0;
line-height: 1.5rem;
pointer-events: none;
@@ -126,7 +128,7 @@ svg {
backface-visibility: visible;
}
-div:has(> .folder-outer:not(.open)) > .folder-container > svg {
+li:has(> .folder-outer:not(.open)) > .folder-container > svg {
transform: rotate(-90deg);
}
diff --git a/quartz/components/styles/popover.scss b/quartz/components/styles/popover.scss
index fae0e121..141b89dd 100644
--- a/quartz/components/styles/popover.scss
+++ b/quartz/components/styles/popover.scss
@@ -26,6 +26,7 @@
max-height: 20rem;
padding: 0 1rem 1rem 1rem;
font-weight: initial;
+ font-style: initial;
line-height: normal;
font-size: initial;
font-family: var(--bodyFont);
@@ -37,6 +38,17 @@
white-space: normal;
}
+ & > .popover-inner[data-content-type="image"] {
+ padding: 0;
+ max-height: 100%;
+
+ img {
+ margin: 0;
+ border-radius: 0;
+ display: block;
+ }
+ }
+
h1 {
font-size: 1.5rem;
}
diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss
index 66f809f9..8a9ec671 100644
--- a/quartz/components/styles/search.scss
+++ b/quartz/components/styles/search.scss
@@ -54,8 +54,8 @@
}
& > #search-space {
- width: 50%;
- margin-top: 15vh;
+ width: 65%;
+ margin-top: 12vh;
margin-left: auto;
margin-right: auto;
@@ -65,7 +65,7 @@
& > * {
width: 100%;
- border-radius: 5px;
+ border-radius: 7px;
background: var(--light);
box-shadow:
0 14px 50px rgba(27, 33, 48, 0.12),
@@ -86,90 +86,140 @@
}
}
- & > #results-container {
- & .result-card {
- padding: 1em;
- cursor: pointer;
- transition: background 0.2s ease;
- border: 1px solid var(--lightgray);
- border-bottom: none;
- width: 100%;
+ & > #search-layout {
+ display: none;
+ flex-direction: row;
+ border: 1px solid var(--lightgray);
+ flex: 0 0 100%;
+ box-sizing: border-box;
- // normalize button props
- font-family: inherit;
- font-size: 100%;
- line-height: 1.15;
- margin: 0;
- text-transform: none;
- text-align: left;
- background: var(--light);
- outline: none;
+ &.display-results {
+ display: flex;
+ }
- & .highlight {
- color: var(--secondary);
- font-weight: 700;
- }
+ &[data-preview] > #results-container {
+ flex: 0 0 min(30%, 450px);
+ }
- &:hover,
- &:focus {
- background: var(--lightgray);
+ @media all and (min-width: $tabletBreakpoint) {
+ &[data-preview] {
+ & .result-card > p.preview {
+ display: none;
+ }
+
+ & > div {
+ &:first-child {
+ border-right: 1px solid var(--lightgray);
+ border-top-right-radius: unset;
+ border-bottom-right-radius: unset;
+ }
+
+ &:last-child {
+ border-top-left-radius: unset;
+ border-bottom-left-radius: unset;
+ }
+ }
}
+ }
- &:first-of-type {
- border-top-left-radius: 5px;
- border-top-right-radius: 5px;
- }
+ & > div {
+ height: calc(75vh - 12vh);
+ border-radius: 5px;
+ }
- &:last-of-type {
- border-bottom-left-radius: 5px;
- border-bottom-right-radius: 5px;
- border-bottom: 1px solid var(--lightgray);
+ @media all and (max-width: $tabletBreakpoint) {
+ & > #preview-container {
+ display: none !important;
}
- & > h3 {
- margin: 0;
+ &[data-preview] > #results-container {
+ width: 100%;
+ height: auto;
+ flex: 0 0 100%;
}
+ }
- & > ul > li {
- margin: 0;
- display: inline-block;
- white-space: nowrap;
- margin: 0;
- overflow-wrap: normal;
- }
+ & .highlight {
+ background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
+ border-radius: 5px;
+ scroll-margin-top: 2rem;
+ }
- & > 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;
+ & > #preview-container {
+ display: block;
+ overflow: hidden;
+ font-family: inherit;
+ color: var(--dark);
+ line-height: 1.5em;
+ font-weight: $normalWeight;
+ overflow-y: auto;
+ padding: 0 2rem;
+
+ & .preview-inner {
+ margin: 0 auto;
+ width: min($pageWidth, 100%);
}
- & > 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;
+ a[role="anchor"] {
+ background-color: transparent;
}
+ }
- & > ul > li > .match-tag {
- color: var(--tertiary);
- font-weight: bold;
- opacity: 1;
- }
+ & > #results-container {
+ overflow-y: auto;
- & > p {
- margin-bottom: 0;
+ & .result-card {
+ overflow: hidden;
+ padding: 1em;
+ cursor: pointer;
+ transition: background 0.2s ease;
+ border-bottom: 1px solid var(--lightgray);
+ width: 100%;
+ display: block;
+ box-sizing: border-box;
+
+ // normalize card props
+ font-family: inherit;
+ font-size: 100%;
+ line-height: 1.15;
+ margin: 0;
+ text-transform: none;
+ text-align: left;
+ outline: none;
+ font-weight: inherit;
+
+ &:hover,
+ &:focus,
+ &.focus {
+ background: var(--lightgray);
+ }
+
+ & > h3 {
+ margin: 0;
+ }
+
+ & > ul.tags {
+ margin-top: 0.45rem;
+ margin-bottom: 0;
+ }
+
+ & > ul > li > p {
+ border-radius: 8px;
+ background-color: var(--highlight);
+ padding: 0.2rem 0.4rem;
+ margin: 0 0.1rem;
+ line-height: 1.4rem;
+ font-weight: $boldWeight;
+ color: var(--secondary);
+
+ &.match-tag {
+ color: var(--tertiary);
+ }
+ }
+
+ & > p {
+ margin-bottom: 0;
+ }
}
}
}
diff --git a/quartz/depgraph.test.ts b/quartz/depgraph.test.ts
new file mode 100644
index 00000000..43eb4024
--- /dev/null
+++ b/quartz/depgraph.test.ts
@@ -0,0 +1,96 @@
+import test, { describe } from "node:test"
+import DepGraph from "./depgraph"
+import assert from "node:assert"
+
+describe("DepGraph", () => {
+ test("getLeafNodes", () => {
+ const graph = new DepGraph()
+ graph.addEdge("A", "B")
+ graph.addEdge("B", "C")
+ graph.addEdge("D", "C")
+ assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"]))
+ assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"]))
+ assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"]))
+ assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"]))
+ })
+
+ describe("getLeafNodeAncestors", () => {
+ test("gets correct ancestors in a graph without cycles", () => {
+ const graph = new DepGraph()
+ graph.addEdge("A", "B")
+ graph.addEdge("B", "C")
+ graph.addEdge("D", "B")
+ assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"]))
+ assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"]))
+ assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"]))
+ assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"]))
+ })
+
+ test("gets correct ancestors in a graph with cycles", () => {
+ const graph = new DepGraph()
+ graph.addEdge("A", "B")
+ graph.addEdge("B", "C")
+ graph.addEdge("C", "A")
+ graph.addEdge("C", "D")
+ assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"]))
+ assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"]))
+ assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"]))
+ assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"]))
+ })
+ })
+
+ describe("updateIncomingEdgesForNode", () => {
+ test("merges when node exists", () => {
+ // A.md -> B.md -> B.html
+ const graph = new DepGraph()
+ graph.addEdge("A.md", "B.md")
+ graph.addEdge("B.md", "B.html")
+
+ // B.md is edited so it removes the A.md transclusion
+ // and adds C.md transclusion
+ // C.md -> B.md
+ const other = new DepGraph()
+ other.addEdge("C.md", "B.md")
+ other.addEdge("B.md", "B.html")
+
+ // A.md -> B.md removed, C.md -> B.md added
+ // C.md -> B.md -> B.html
+ graph.updateIncomingEdgesForNode(other, "B.md")
+
+ const expected = {
+ nodes: ["A.md", "B.md", "B.html", "C.md"],
+ edges: [
+ ["B.md", "B.html"],
+ ["C.md", "B.md"],
+ ],
+ }
+
+ assert.deepStrictEqual(graph.export(), expected)
+ })
+
+ test("adds node if it does not exist", () => {
+ // A.md -> B.md
+ const graph = new DepGraph()
+ graph.addEdge("A.md", "B.md")
+
+ // Add a new file C.md that transcludes B.md
+ // B.md -> C.md
+ const other = new DepGraph()
+ other.addEdge("B.md", "C.md")
+
+ // B.md -> C.md added
+ // A.md -> B.md -> C.md
+ graph.updateIncomingEdgesForNode(other, "C.md")
+
+ const expected = {
+ nodes: ["A.md", "B.md", "C.md"],
+ edges: [
+ ["A.md", "B.md"],
+ ["B.md", "C.md"],
+ ],
+ }
+
+ assert.deepStrictEqual(graph.export(), expected)
+ })
+ })
+})
diff --git a/quartz/depgraph.ts b/quartz/depgraph.ts
new file mode 100644
index 00000000..1efad077
--- /dev/null
+++ b/quartz/depgraph.ts
@@ -0,0 +1,187 @@
+export default class DepGraph {
+ // node: incoming and outgoing edges
+ _graph = new Map; outgoing: Set }>()
+
+ constructor() {
+ this._graph = new Map()
+ }
+
+ export(): Object {
+ return {
+ nodes: this.nodes,
+ edges: this.edges,
+ }
+ }
+
+ toString(): string {
+ return JSON.stringify(this.export(), null, 2)
+ }
+
+ // BASIC GRAPH OPERATIONS
+
+ get nodes(): T[] {
+ return Array.from(this._graph.keys())
+ }
+
+ get edges(): [T, T][] {
+ let edges: [T, T][] = []
+ this.forEachEdge((edge) => edges.push(edge))
+ return edges
+ }
+
+ hasNode(node: T): boolean {
+ return this._graph.has(node)
+ }
+
+ addNode(node: T): void {
+ if (!this._graph.has(node)) {
+ this._graph.set(node, { incoming: new Set(), outgoing: new Set() })
+ }
+ }
+
+ removeNode(node: T): void {
+ if (this._graph.has(node)) {
+ this._graph.delete(node)
+ }
+ }
+
+ hasEdge(from: T, to: T): boolean {
+ return Boolean(this._graph.get(from)?.outgoing.has(to))
+ }
+
+ addEdge(from: T, to: T): void {
+ this.addNode(from)
+ this.addNode(to)
+
+ this._graph.get(from)!.outgoing.add(to)
+ this._graph.get(to)!.incoming.add(from)
+ }
+
+ removeEdge(from: T, to: T): void {
+ if (this._graph.has(from) && this._graph.has(to)) {
+ this._graph.get(from)!.outgoing.delete(to)
+ this._graph.get(to)!.incoming.delete(from)
+ }
+ }
+
+ // returns -1 if node does not exist
+ outDegree(node: T): number {
+ return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1
+ }
+
+ // returns -1 if node does not exist
+ inDegree(node: T): number {
+ return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1
+ }
+
+ forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void {
+ this._graph.get(node)?.outgoing.forEach(callback)
+ }
+
+ forEachInNeighbor(node: T, callback: (neighbor: T) => void): void {
+ this._graph.get(node)?.incoming.forEach(callback)
+ }
+
+ forEachEdge(callback: (edge: [T, T]) => void): void {
+ for (const [source, { outgoing }] of this._graph.entries()) {
+ for (const target of outgoing) {
+ callback([source, target])
+ }
+ }
+ }
+
+ // DEPENDENCY ALGORITHMS
+
+ // For the node provided:
+ // If node does not exist, add it
+ // If an incoming edge was added in other, it is added in this graph
+ // If an incoming edge was deleted in other, it is deleted in this graph
+ updateIncomingEdgesForNode(other: DepGraph, node: T): void {
+ this.addNode(node)
+
+ // Add edge if it is present in other
+ other.forEachInNeighbor(node, (neighbor) => {
+ this.addEdge(neighbor, node)
+ })
+
+ // For node provided, remove incoming edge if it is absent in other
+ this.forEachEdge(([source, target]) => {
+ if (target === node && !other.hasEdge(source, target)) {
+ this.removeEdge(source, target)
+ }
+ })
+ }
+
+ // Get all leaf nodes (i.e. destination paths) reachable from the node provided
+ // Eg. if the graph is A -> B -> C
+ // D ---^
+ // and the node is B, this function returns [C]
+ getLeafNodes(node: T): Set {
+ let stack: T[] = [node]
+ let visited = new Set()
+ let leafNodes = new Set()
+
+ // DFS
+ while (stack.length > 0) {
+ let node = stack.pop()!
+
+ // If the node is already visited, skip it
+ if (visited.has(node)) {
+ continue
+ }
+ visited.add(node)
+
+ // Check if the node is a leaf node (i.e. destination path)
+ if (this.outDegree(node) === 0) {
+ leafNodes.add(node)
+ }
+
+ // Add all unvisited neighbors to the stack
+ this.forEachOutNeighbor(node, (neighbor) => {
+ if (!visited.has(neighbor)) {
+ stack.push(neighbor)
+ }
+ })
+ }
+
+ return leafNodes
+ }
+
+ // Get all ancestors of the leaf nodes reachable from the node provided
+ // Eg. if the graph is A -> B -> C
+ // D ---^
+ // and the node is B, this function returns [A, B, D]
+ getLeafNodeAncestors(node: T): Set {
+ const leafNodes = this.getLeafNodes(node)
+ let visited = new Set()
+ let upstreamNodes = new Set()
+
+ // Backwards DFS for each leaf node
+ leafNodes.forEach((leafNode) => {
+ let stack: T[] = [leafNode]
+
+ while (stack.length > 0) {
+ let node = stack.pop()!
+
+ if (visited.has(node)) {
+ continue
+ }
+ visited.add(node)
+ // Add node if it's not a leaf node (i.e. destination path)
+ // Assumes destination file cannot depend on another destination file
+ if (this.outDegree(node) !== 0) {
+ upstreamNodes.add(node)
+ }
+
+ // Add all unvisited parents to the stack
+ this.forEachInNeighbor(node, (parentNode) => {
+ if (!visited.has(parentNode)) {
+ stack.push(parentNode)
+ }
+ })
+ }
+ })
+
+ return upstreamNodes
+ }
+}
diff --git a/quartz/i18n/index.ts b/quartz/i18n/index.ts
new file mode 100644
index 00000000..5224f667
--- /dev/null
+++ b/quartz/i18n/index.ts
@@ -0,0 +1,52 @@
+import { Translation, CalloutTranslation } from "./locales/definition"
+import en from "./locales/en-US"
+import fr from "./locales/fr-FR"
+import ja from "./locales/ja-JP"
+import de from "./locales/de-DE"
+import nl from "./locales/nl-NL"
+import ro from "./locales/ro-RO"
+import es from "./locales/es-ES"
+import ar from "./locales/ar-SA"
+import uk from "./locales/uk-UA"
+import ru from "./locales/ru-RU"
+import ko from "./locales/ko-KR"
+
+export const TRANSLATIONS = {
+ "en-US": en,
+ "fr-FR": fr,
+ "ja-JP": ja,
+ "de-DE": de,
+ "nl-NL": nl,
+ "nl-BE": nl,
+ "ro-RO": ro,
+ "ro-MD": ro,
+ "es-ES": es,
+ "ar-SA": ar,
+ "ar-AE": ar,
+ "ar-QA": ar,
+ "ar-BH": ar,
+ "ar-KW": ar,
+ "ar-OM": ar,
+ "ar-YE": ar,
+ "ar-IR": ar,
+ "ar-SY": ar,
+ "ar-IQ": ar,
+ "ar-JO": ar,
+ "ar-PL": ar,
+ "ar-LB": ar,
+ "ar-EG": ar,
+ "ar-SD": ar,
+ "ar-LY": ar,
+ "ar-MA": ar,
+ "ar-TN": ar,
+ "ar-DZ": ar,
+ "ar-MR": ar,
+ "uk-UA": uk,
+ "ru-RU": ru,
+ "ko-KR": ko,
+} as const
+
+export const defaultTranslation = "en-US"
+export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? defaultTranslation]
+export type ValidLocale = keyof typeof TRANSLATIONS
+export type ValidCallout = keyof CalloutTranslation
diff --git a/quartz/i18n/locales/ar-SA.ts b/quartz/i18n/locales/ar-SA.ts
new file mode 100644
index 00000000..f7048103
--- /dev/null
+++ b/quartz/i18n/locales/ar-SA.ts
@@ -0,0 +1,88 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "غير معنون",
+ description: "لم يتم تقديم أي وصف",
+ },
+ components: {
+ callout: {
+ note: "ملاحظة",
+ abstract: "ملخص",
+ info: "معلومات",
+ todo: "للقيام",
+ tip: "نصيحة",
+ success: "نجاح",
+ question: "سؤال",
+ warning: "تحذير",
+ failure: "فشل",
+ danger: "خطر",
+ bug: "خلل",
+ example: "مثال",
+ quote: "اقتباس",
+ },
+ backlinks: {
+ title: "وصلات العودة",
+ noBacklinksFound: "لا يوجد وصلات عودة",
+ },
+ themeToggle: {
+ lightMode: "الوضع النهاري",
+ darkMode: "الوضع الليلي",
+ },
+ explorer: {
+ title: "المستعرض",
+ },
+ footer: {
+ createdWith: "أُنشئ باستخدام",
+ },
+ graph: {
+ title: "التمثيل التفاعلي",
+ },
+ recentNotes: {
+ title: "آخر الملاحظات",
+ seeRemainingMore: ({ remaining }) => `تصفح ${remaining} أكثر →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `مقتبس من ${targetSlug}`,
+ linkToOriginal: "وصلة للملاحظة الرئيسة",
+ },
+ search: {
+ title: "بحث",
+ searchBarPlaceholder: "ابحث عن شيء ما",
+ },
+ tableOfContents: {
+ title: "فهرس المحتويات",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) =>
+ minutes == 1
+ ? `دقيقة أو أقل للقراءة`
+ : minutes == 2
+ ? `دقيقتان للقراءة`
+ : `${minutes} دقائق للقراءة`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "آخر الملاحظات",
+ lastFewNotes: ({ count }) => `آخر ${count} ملاحظة`,
+ },
+ error: {
+ title: "غير موجود",
+ notFound: "إما أن هذه الصفحة خاصة أو غير موجودة.",
+ },
+ folderContent: {
+ folder: "مجلد",
+ itemsUnderFolder: ({ count }) =>
+ count === 1 ? "يوجد عنصر واحد فقط تحت هذا المجلد" : `يوجد ${count} عناصر تحت هذا المجلد.`,
+ },
+ tagContent: {
+ tag: "الوسم",
+ tagIndex: "مؤشر الوسم",
+ itemsUnderTag: ({ count }) =>
+ count === 1 ? "يوجد عنصر واحد فقط تحت هذا الوسم" : `يوجد ${count} عناصر تحت هذا الوسم.`,
+ showingFirst: ({ count }) => `إظهار أول ${count} أوسمة.`,
+ totalTags: ({ count }) => `يوجد ${count} أوسمة.`,
+ },
+ },
+} as const satisfies Translation
diff --git a/quartz/i18n/locales/de-DE.ts b/quartz/i18n/locales/de-DE.ts
new file mode 100644
index 00000000..e3821944
--- /dev/null
+++ b/quartz/i18n/locales/de-DE.ts
@@ -0,0 +1,83 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "Unbenannt",
+ description: "Keine Beschreibung angegeben",
+ },
+ components: {
+ callout: {
+ note: "Hinweis",
+ abstract: "Zusammenfassung",
+ info: "Info",
+ todo: "Zu erledigen",
+ tip: "Tipp",
+ success: "Erfolg",
+ question: "Frage",
+ warning: "Warnung",
+ failure: "Misserfolg",
+ danger: "Gefahr",
+ bug: "Fehler",
+ example: "Beispiel",
+ quote: "Zitat",
+ },
+ backlinks: {
+ title: "Backlinks",
+ noBacklinksFound: "Keine Backlinks gefunden",
+ },
+ themeToggle: {
+ lightMode: "Light Mode",
+ darkMode: "Dark Mode",
+ },
+ explorer: {
+ title: "Explorer",
+ },
+ footer: {
+ createdWith: "Erstellt mit",
+ },
+ graph: {
+ title: "Graphansicht",
+ },
+ recentNotes: {
+ title: "Zuletzt bearbeitete Seiten",
+ seeRemainingMore: ({ remaining }) => `${remaining} weitere ansehen →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `Transklusion von ${targetSlug}`,
+ linkToOriginal: "Link zum Original",
+ },
+ search: {
+ title: "Suche",
+ searchBarPlaceholder: "Suche nach etwas",
+ },
+ tableOfContents: {
+ title: "Inhaltsverzeichnis",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) => `${minutes} min read`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "Zuletzt bearbeitete Seiten",
+ lastFewNotes: ({ count }) => `Letzte ${count} Seiten`,
+ },
+ error: {
+ title: "Nicht gefunden",
+ notFound: "Diese Seite ist entweder nicht öffentlich oder existiert nicht.",
+ },
+ folderContent: {
+ folder: "Ordner",
+ itemsUnderFolder: ({ count }) =>
+ count === 1 ? "1 Datei in diesem Ordner" : `${count} Dateien in diesem Ordner.`,
+ },
+ tagContent: {
+ tag: "Tag",
+ tagIndex: "Tag-Übersicht",
+ itemsUnderTag: ({ count }) =>
+ count === 1 ? "1 Datei mit diesem Tag" : `${count} Dateien mit diesem Tag.`,
+ showingFirst: ({ count }) => `Die ersten ${count} Tags werden angezeigt.`,
+ totalTags: ({ count }) => `${count} Tags insgesamt.`,
+ },
+ },
+} as const satisfies Translation
diff --git a/quartz/i18n/locales/definition.ts b/quartz/i18n/locales/definition.ts
new file mode 100644
index 00000000..1d5d3dda
--- /dev/null
+++ b/quartz/i18n/locales/definition.ts
@@ -0,0 +1,83 @@
+import { FullSlug } from "../../util/path"
+
+export interface CalloutTranslation {
+ note: string
+ abstract: string
+ info: string
+ todo: string
+ tip: string
+ success: string
+ question: string
+ warning: string
+ failure: string
+ danger: string
+ bug: string
+ example: string
+ quote: string
+}
+
+export interface Translation {
+ propertyDefaults: {
+ title: string
+ description: string
+ }
+ components: {
+ callout: CalloutTranslation
+ backlinks: {
+ title: string
+ noBacklinksFound: string
+ }
+ themeToggle: {
+ lightMode: string
+ darkMode: string
+ }
+ explorer: {
+ title: string
+ }
+ footer: {
+ createdWith: string
+ }
+ graph: {
+ title: string
+ }
+ recentNotes: {
+ title: string
+ seeRemainingMore: (variables: { remaining: number }) => string
+ }
+ transcludes: {
+ transcludeOf: (variables: { targetSlug: FullSlug }) => string
+ linkToOriginal: string
+ }
+ search: {
+ title: string
+ searchBarPlaceholder: string
+ }
+ tableOfContents: {
+ title: string
+ }
+ contentMeta: {
+ readingTime: (variables: { minutes: number }) => string
+ }
+ }
+ pages: {
+ rss: {
+ recentNotes: string
+ lastFewNotes: (variables: { count: number }) => string
+ }
+ error: {
+ title: string
+ notFound: string
+ }
+ folderContent: {
+ folder: string
+ itemsUnderFolder: (variables: { count: number }) => string
+ }
+ tagContent: {
+ tag: string
+ tagIndex: string
+ itemsUnderTag: (variables: { count: number }) => string
+ showingFirst: (variables: { count: number }) => string
+ totalTags: (variables: { count: number }) => string
+ }
+ }
+}
diff --git a/quartz/i18n/locales/en-US.ts b/quartz/i18n/locales/en-US.ts
new file mode 100644
index 00000000..4a308d79
--- /dev/null
+++ b/quartz/i18n/locales/en-US.ts
@@ -0,0 +1,83 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "Untitled",
+ description: "No description provided",
+ },
+ components: {
+ callout: {
+ note: "Note",
+ abstract: "Abstract",
+ info: "Info",
+ todo: "Todo",
+ tip: "Tip",
+ success: "Success",
+ question: "Question",
+ warning: "Warning",
+ failure: "Failure",
+ danger: "Danger",
+ bug: "Bug",
+ example: "Example",
+ quote: "Quote",
+ },
+ backlinks: {
+ title: "Backlinks",
+ noBacklinksFound: "No backlinks found",
+ },
+ themeToggle: {
+ lightMode: "Light mode",
+ darkMode: "Dark mode",
+ },
+ explorer: {
+ title: "Explorer",
+ },
+ footer: {
+ createdWith: "Created with",
+ },
+ graph: {
+ title: "Graph View",
+ },
+ recentNotes: {
+ title: "Recent Notes",
+ seeRemainingMore: ({ remaining }) => `See ${remaining} more →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`,
+ linkToOriginal: "Link to original",
+ },
+ search: {
+ title: "Search",
+ searchBarPlaceholder: "Search for something",
+ },
+ tableOfContents: {
+ title: "Table of Contents",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) => `${minutes} min read`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "Recent notes",
+ lastFewNotes: ({ count }) => `Last ${count} notes`,
+ },
+ error: {
+ title: "Not Found",
+ notFound: "Either this page is private or doesn't exist.",
+ },
+ folderContent: {
+ folder: "Folder",
+ itemsUnderFolder: ({ count }) =>
+ count === 1 ? "1 item under this folder" : `${count} items under this folder.`,
+ },
+ tagContent: {
+ tag: "Tag",
+ tagIndex: "Tag Index",
+ itemsUnderTag: ({ count }) =>
+ count === 1 ? "1 item with this tag" : `${count} items with this tag.`,
+ showingFirst: ({ count }) => `Showing first ${count} tags.`,
+ totalTags: ({ count }) => `Found ${count} total tags.`,
+ },
+ },
+} as const satisfies Translation
diff --git a/quartz/i18n/locales/es-ES.ts b/quartz/i18n/locales/es-ES.ts
new file mode 100644
index 00000000..f59d201a
--- /dev/null
+++ b/quartz/i18n/locales/es-ES.ts
@@ -0,0 +1,83 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "Sin título",
+ description: "Sin descripción",
+ },
+ components: {
+ callout: {
+ note: "Nota",
+ abstract: "Resumen",
+ info: "Información",
+ todo: "Por hacer",
+ tip: "Consejo",
+ success: "Éxito",
+ question: "Pregunta",
+ warning: "Advertencia",
+ failure: "Fallo",
+ danger: "Peligro",
+ bug: "Error",
+ example: "Ejemplo",
+ quote: "Cita",
+ },
+ backlinks: {
+ title: "Enlaces de Retroceso",
+ noBacklinksFound: "No se han encontrado enlaces traseros",
+ },
+ themeToggle: {
+ lightMode: "Modo claro",
+ darkMode: "Modo oscuro",
+ },
+ explorer: {
+ title: "Explorador",
+ },
+ footer: {
+ createdWith: "Creado con",
+ },
+ graph: {
+ title: "Vista Gráfica",
+ },
+ recentNotes: {
+ title: "Notas Recientes",
+ seeRemainingMore: ({ remaining }) => `Vea ${remaining} más →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `Transcluido de ${targetSlug}`,
+ linkToOriginal: "Enlace al original",
+ },
+ search: {
+ title: "Buscar",
+ searchBarPlaceholder: "Busca algo",
+ },
+ tableOfContents: {
+ title: "Tabla de Contenidos",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) => `${minutes} min read`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "Notas recientes",
+ lastFewNotes: ({ count }) => `Últimás ${count} notas`,
+ },
+ error: {
+ title: "No se encontró.",
+ notFound: "Esta página es privada o no existe.",
+ },
+ folderContent: {
+ folder: "Carpeta",
+ itemsUnderFolder: ({ count }) =>
+ count === 1 ? "1 artículo en esta carpeta" : `${count} artículos en esta carpeta.`,
+ },
+ tagContent: {
+ tag: "Etiqueta",
+ tagIndex: "Índice de Etiquetas",
+ itemsUnderTag: ({ count }) =>
+ count === 1 ? "1 artículo con esta etiqueta" : `${count} artículos con esta etiqueta.`,
+ showingFirst: ({ count }) => `Mostrando las primeras ${count} etiquetas.`,
+ totalTags: ({ count }) => `Se encontraron ${count} etiquetas en total.`,
+ },
+ },
+} as const satisfies Translation
diff --git a/quartz/i18n/locales/fr-FR.ts b/quartz/i18n/locales/fr-FR.ts
new file mode 100644
index 00000000..8b722920
--- /dev/null
+++ b/quartz/i18n/locales/fr-FR.ts
@@ -0,0 +1,83 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "Sans titre",
+ description: "Aucune description fournie",
+ },
+ components: {
+ callout: {
+ note: "Note",
+ abstract: "Résumé",
+ info: "Info",
+ todo: "À faire",
+ tip: "Conseil",
+ success: "Succès",
+ question: "Question",
+ warning: "Avertissement",
+ failure: "Échec",
+ danger: "Danger",
+ bug: "Bogue",
+ example: "Exemple",
+ quote: "Citation",
+ },
+ backlinks: {
+ title: "Liens retour",
+ noBacklinksFound: "Aucun lien retour trouvé",
+ },
+ themeToggle: {
+ lightMode: "Mode clair",
+ darkMode: "Mode sombre",
+ },
+ explorer: {
+ title: "Explorateur",
+ },
+ footer: {
+ createdWith: "Créé avec",
+ },
+ graph: {
+ title: "Vue Graphique",
+ },
+ recentNotes: {
+ title: "Notes Récentes",
+ seeRemainingMore: ({ remaining }) => `Voir ${remaining} de plus →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `Transclusion de ${targetSlug}`,
+ linkToOriginal: "Lien vers l'original",
+ },
+ search: {
+ title: "Recherche",
+ searchBarPlaceholder: "Rechercher quelque chose",
+ },
+ tableOfContents: {
+ title: "Table des Matières",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) => `${minutes} min read`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "Notes récentes",
+ lastFewNotes: ({ count }) => `Les dernières ${count} notes`,
+ },
+ error: {
+ title: "Pas trouvé",
+ notFound: "Cette page est soit privée, soit elle n'existe pas.",
+ },
+ folderContent: {
+ folder: "Dossier",
+ itemsUnderFolder: ({ count }) =>
+ count === 1 ? "1 élément sous ce dossier" : `${count} éléments sous ce dossier.`,
+ },
+ tagContent: {
+ tag: "Étiquette",
+ tagIndex: "Index des étiquettes",
+ itemsUnderTag: ({ count }) =>
+ count === 1 ? "1 élément avec cette étiquette" : `${count} éléments avec cette étiquette.`,
+ showingFirst: ({ count }) => `Affichage des premières ${count} étiquettes.`,
+ totalTags: ({ count }) => `Trouvé ${count} étiquettes au total.`,
+ },
+ },
+} as const satisfies Translation
diff --git a/quartz/i18n/locales/ja-JP.ts b/quartz/i18n/locales/ja-JP.ts
new file mode 100644
index 00000000..d429db41
--- /dev/null
+++ b/quartz/i18n/locales/ja-JP.ts
@@ -0,0 +1,81 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "無題",
+ description: "説明なし",
+ },
+ components: {
+ callout: {
+ note: "ノート",
+ abstract: "抄録",
+ info: "情報",
+ todo: "やるべきこと",
+ tip: "ヒント",
+ success: "成功",
+ question: "質問",
+ warning: "警告",
+ failure: "失敗",
+ danger: "危険",
+ bug: "バグ",
+ example: "例",
+ quote: "引用",
+ },
+ backlinks: {
+ title: "バックリンク",
+ noBacklinksFound: "バックリンクはありません",
+ },
+ themeToggle: {
+ lightMode: "ライトモード",
+ darkMode: "ダークモード",
+ },
+ explorer: {
+ title: "エクスプローラー",
+ },
+ footer: {
+ createdWith: "作成",
+ },
+ graph: {
+ title: "グラフビュー",
+ },
+ recentNotes: {
+ title: "最近の記事",
+ seeRemainingMore: ({ remaining }) => `さらに${remaining}件 →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `${targetSlug}のまとめ`,
+ linkToOriginal: "元記事へのリンク",
+ },
+ search: {
+ title: "検索",
+ searchBarPlaceholder: "検索ワードを入力",
+ },
+ tableOfContents: {
+ title: "目次",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) => `${minutes} min read`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "最近の記事",
+ lastFewNotes: ({ count }) => `最新の${count}件`,
+ },
+ error: {
+ title: "Not Found",
+ notFound: "ページが存在しないか、非公開設定になっています。",
+ },
+ folderContent: {
+ folder: "フォルダ",
+ itemsUnderFolder: ({ count }) => `${count}件のページ`,
+ },
+ tagContent: {
+ tag: "タグ",
+ tagIndex: "タグ一覧",
+ itemsUnderTag: ({ count }) => `${count}件のページ`,
+ showingFirst: ({ count }) => `のうち最初の${count}件を表示しています`,
+ totalTags: ({ count }) => `全${count}個のタグを表示中`,
+ },
+ },
+} as const satisfies Translation
diff --git a/quartz/i18n/locales/ko-KR.ts b/quartz/i18n/locales/ko-KR.ts
new file mode 100644
index 00000000..ed859a90
--- /dev/null
+++ b/quartz/i18n/locales/ko-KR.ts
@@ -0,0 +1,81 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "제목 없음",
+ description: "설명 없음",
+ },
+ components: {
+ callout: {
+ note: "노트",
+ abstract: "개요",
+ info: "정보",
+ todo: "할일",
+ tip: "팁",
+ success: "성공",
+ question: "질문",
+ warning: "주의",
+ failure: "실패",
+ danger: "위험",
+ bug: "버그",
+ example: "예시",
+ quote: "인용",
+ },
+ backlinks: {
+ title: "백링크",
+ noBacklinksFound: "백링크가 없습니다.",
+ },
+ themeToggle: {
+ lightMode: "라이트 모드",
+ darkMode: "다크 모드",
+ },
+ explorer: {
+ title: "탐색기",
+ },
+ footer: {
+ createdWith: "Created with",
+ },
+ graph: {
+ title: "그래프 뷰",
+ },
+ recentNotes: {
+ title: "최근 게시글",
+ seeRemainingMore: ({ remaining }) => `${remaining}건 더보기 →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `${targetSlug}의 포함`,
+ linkToOriginal: "원본 링크",
+ },
+ search: {
+ title: "검색",
+ searchBarPlaceholder: "검색어를 입력하세요",
+ },
+ tableOfContents: {
+ title: "목차",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) => `${minutes} min read`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "최근 게시글",
+ lastFewNotes: ({ count }) => `최근 ${count} 건`,
+ },
+ error: {
+ title: "Not Found",
+ notFound: "페이지가 존재하지 않거나 비공개 설정이 되어 있습니다.",
+ },
+ folderContent: {
+ folder: "폴더",
+ itemsUnderFolder: ({ count }) => `${count}건의 페이지`,
+ },
+ tagContent: {
+ tag: "태그",
+ tagIndex: "태그 목록",
+ itemsUnderTag: ({ count }) => `${count}건의 페이지`,
+ showingFirst: ({ count }) => `처음 ${count}개의 태그`,
+ totalTags: ({ count }) => `총 ${count}개의 태그를 찾았습니다.`,
+ },
+ },
+} as const satisfies Translation
diff --git a/quartz/i18n/locales/nl-NL.ts b/quartz/i18n/locales/nl-NL.ts
new file mode 100644
index 00000000..e239be0e
--- /dev/null
+++ b/quartz/i18n/locales/nl-NL.ts
@@ -0,0 +1,85 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "Naamloos",
+ description: "Geen beschrijving gegeven.",
+ },
+ components: {
+ callout: {
+ note: "Notitie",
+ abstract: "Samenvatting",
+ info: "Info",
+ todo: "Te doen",
+ tip: "Tip",
+ success: "Succes",
+ question: "Vraag",
+ warning: "Waarschuwing",
+ failure: "Mislukking",
+ danger: "Gevaar",
+ bug: "Bug",
+ example: "Voorbeeld",
+ quote: "Citaat",
+ },
+ backlinks: {
+ title: "Backlinks",
+ noBacklinksFound: "Geen backlinks gevonden",
+ },
+ themeToggle: {
+ lightMode: "Lichte modus",
+ darkMode: "Donkere modus",
+ },
+ explorer: {
+ title: "Verkenner",
+ },
+ footer: {
+ createdWith: "Gemaakt met",
+ },
+ graph: {
+ title: "Grafiekweergave",
+ },
+ recentNotes: {
+ title: "Recente notities",
+ seeRemainingMore: ({ remaining }) => `Zie ${remaining} meer →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `Invoeging van ${targetSlug}`,
+ linkToOriginal: "Link naar origineel",
+ },
+ search: {
+ title: "Zoeken",
+ searchBarPlaceholder: "Doorzoek de website",
+ },
+ tableOfContents: {
+ title: "Inhoudsopgave",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) =>
+ minutes === 1 ? "1 minuut leestijd" : `${minutes} minuten leestijd`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "Recente notities",
+ lastFewNotes: ({ count }) => `Laatste ${count} notities`,
+ },
+ error: {
+ title: "Niet gevonden",
+ notFound: "Deze pagina is niet zichtbaar of bestaat niet.",
+ },
+ folderContent: {
+ folder: "Map",
+ itemsUnderFolder: ({ count }) =>
+ count === 1 ? "1 item in deze map" : `${count} items in deze map.`,
+ },
+ tagContent: {
+ tag: "Label",
+ tagIndex: "Label-index",
+ itemsUnderTag: ({ count }) =>
+ count === 1 ? "1 item met dit label." : `${count} items met dit label.`,
+ showingFirst: ({ count }) =>
+ count === 1 ? "Eerste label tonen." : `Eerste ${count} labels tonen.`,
+ totalTags: ({ count }) => `${count} labels gevonden.`,
+ },
+ },
+} as const satisfies Translation
diff --git a/quartz/i18n/locales/ro-RO.ts b/quartz/i18n/locales/ro-RO.ts
new file mode 100644
index 00000000..556b1899
--- /dev/null
+++ b/quartz/i18n/locales/ro-RO.ts
@@ -0,0 +1,84 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "Fără titlu",
+ description: "Nici o descriere furnizată",
+ },
+ components: {
+ callout: {
+ note: "Notă",
+ abstract: "Rezumat",
+ info: "Informație",
+ todo: "De făcut",
+ tip: "Sfat",
+ success: "Succes",
+ question: "Întrebare",
+ warning: "Avertisment",
+ failure: "Eșec",
+ danger: "Pericol",
+ bug: "Bug",
+ example: "Exemplu",
+ quote: "Citat",
+ },
+ backlinks: {
+ title: "Legături înapoi",
+ noBacklinksFound: "Nu s-au găsit legături înapoi",
+ },
+ themeToggle: {
+ lightMode: "Modul luminos",
+ darkMode: "Modul întunecat",
+ },
+ explorer: {
+ title: "Explorator",
+ },
+ footer: {
+ createdWith: "Creat cu",
+ },
+ graph: {
+ title: "Graf",
+ },
+ recentNotes: {
+ title: "Notițe recente",
+ seeRemainingMore: ({ remaining }) => `Vezi încă ${remaining} →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `Extras din ${targetSlug}`,
+ linkToOriginal: "Legătură către original",
+ },
+ search: {
+ title: "Căutare",
+ searchBarPlaceholder: "Introduceți termenul de căutare...",
+ },
+ tableOfContents: {
+ title: "Cuprins",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) =>
+ minutes == 1 ? `lectură de 1 minut` : `lectură de ${minutes} minute`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "Notițe recente",
+ lastFewNotes: ({ count }) => `Ultimele ${count} notițe`,
+ },
+ error: {
+ title: "Pagina nu a fost găsită",
+ notFound: "Fie această pagină este privată, fie nu există.",
+ },
+ folderContent: {
+ folder: "Dosar",
+ itemsUnderFolder: ({ count }) =>
+ count === 1 ? "1 articol în acest dosar." : `${count} elemente în acest dosar.`,
+ },
+ tagContent: {
+ tag: "Etichetă",
+ tagIndex: "Indexul etichetelor",
+ itemsUnderTag: ({ count }) =>
+ count === 1 ? "1 articol cu această etichetă." : `${count} articole cu această etichetă.`,
+ showingFirst: ({ count }) => `Se afișează primele ${count} etichete.`,
+ totalTags: ({ count }) => `Au fost găsite ${count} etichete în total.`,
+ },
+ },
+} as const satisfies Translation
diff --git a/quartz/i18n/locales/ru-RU.ts b/quartz/i18n/locales/ru-RU.ts
new file mode 100644
index 00000000..8ead3cab
--- /dev/null
+++ b/quartz/i18n/locales/ru-RU.ts
@@ -0,0 +1,95 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "Без названия",
+ description: "Описание отсутствует",
+ },
+ components: {
+ callout: {
+ note: "Заметка",
+ abstract: "Резюме",
+ info: "Инфо",
+ todo: "Сделать",
+ tip: "Подсказка",
+ success: "Успех",
+ question: "Вопрос",
+ warning: "Предупреждение",
+ failure: "Неудача",
+ danger: "Опасность",
+ bug: "Баг",
+ example: "Пример",
+ quote: "Цитата",
+ },
+ backlinks: {
+ title: "Обратные ссылки",
+ noBacklinksFound: "Обратные ссылки отсутствуют",
+ },
+ themeToggle: {
+ lightMode: "Светлый режим",
+ darkMode: "Тёмный режим",
+ },
+ explorer: {
+ title: "Проводник",
+ },
+ footer: {
+ createdWith: "Создано с помощью",
+ },
+ graph: {
+ title: "Вид графа",
+ },
+ recentNotes: {
+ title: "Недавние заметки",
+ seeRemainingMore: ({ remaining }) =>
+ `Посмотреть оставш${getForm(remaining, "уюся", "иеся", "иеся")} ${remaining} →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `Переход из ${targetSlug}`,
+ linkToOriginal: "Ссылка на оригинал",
+ },
+ search: {
+ title: "Поиск",
+ searchBarPlaceholder: "Найти что-нибудь",
+ },
+ tableOfContents: {
+ title: "Оглавление",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) => `время чтения ~${minutes} мин.`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "Недавние заметки",
+ lastFewNotes: ({ count }) =>
+ `Последн${getForm(count, "яя", "ие", "ие")} ${count} замет${getForm(count, "ка", "ки", "ок")}`,
+ },
+ error: {
+ title: "Страница не найдена",
+ notFound: "Эта страница приватная или не существует",
+ },
+ folderContent: {
+ folder: "Папка",
+ itemsUnderFolder: ({ count }) =>
+ `в этой папке ${count} элемент${getForm(count, "", "а", "ов")}`,
+ },
+ tagContent: {
+ tag: "Тег",
+ tagIndex: "Индекс тегов",
+ itemsUnderTag: ({ count }) => `с этим тегом ${count} элемент${getForm(count, "", "а", "ов")}`,
+ showingFirst: ({ count }) =>
+ `Показыва${getForm(count, "ется", "ются", "ются")} ${count} тег${getForm(count, "", "а", "ов")}`,
+ totalTags: ({ count }) => `Всего ${count} тег${getForm(count, "", "а", "ов")}`,
+ },
+ },
+} as const satisfies Translation
+
+function getForm(number: number, form1: string, form2: string, form5: string): string {
+ const remainder100 = number % 100
+ const remainder10 = remainder100 % 10
+
+ if (remainder100 >= 10 && remainder100 <= 20) return form5
+ if (remainder10 > 1 && remainder10 < 5) return form2
+ if (remainder10 == 1) return form1
+ return form5
+}
diff --git a/quartz/i18n/locales/uk-UA.ts b/quartz/i18n/locales/uk-UA.ts
new file mode 100644
index 00000000..c997a697
--- /dev/null
+++ b/quartz/i18n/locales/uk-UA.ts
@@ -0,0 +1,83 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "Без назви",
+ description: "Опис не надано",
+ },
+ components: {
+ callout: {
+ note: "Примітка",
+ abstract: "Абстракт",
+ info: "Інформація",
+ todo: "Завдання",
+ tip: "Порада",
+ success: "Успіх",
+ question: "Питання",
+ warning: "Попередження",
+ failure: "Невдача",
+ danger: "Небезпека",
+ bug: "Баг",
+ example: "Приклад",
+ quote: "Цитата",
+ },
+ backlinks: {
+ title: "Зворотні посилання",
+ noBacklinksFound: "Зворотних посилань не знайдено",
+ },
+ themeToggle: {
+ lightMode: "Світлий режим",
+ darkMode: "Темний режим",
+ },
+ explorer: {
+ title: "Провідник",
+ },
+ footer: {
+ createdWith: "Створено за допомогою",
+ },
+ graph: {
+ title: "Вигляд графа",
+ },
+ recentNotes: {
+ title: "Останні нотатки",
+ seeRemainingMore: ({ remaining }) => `Переглянути ще ${remaining} →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `Видобуто з ${targetSlug}`,
+ linkToOriginal: "Посилання на оригінал",
+ },
+ search: {
+ title: "Пошук",
+ searchBarPlaceholder: "Шукати щось",
+ },
+ tableOfContents: {
+ title: "Зміст",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) => `${minutes} min read`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "Останні нотатки",
+ lastFewNotes: ({ count }) => `Останні нотатки: ${count}`,
+ },
+ error: {
+ title: "Не знайдено",
+ notFound: "Ця сторінка або приватна, або не існує.",
+ },
+ folderContent: {
+ folder: "Папка",
+ itemsUnderFolder: ({ count }) =>
+ count === 1 ? "У цій папці 1 елемент" : `Елементів у цій папці: ${count}.`,
+ },
+ tagContent: {
+ tag: "Тег",
+ tagIndex: "Індекс тегу",
+ itemsUnderTag: ({ count }) =>
+ count === 1 ? "1 елемент з цим тегом" : `Елементів з цим тегом: ${count}.`,
+ showingFirst: ({ count }) => `Показ перших ${count} тегів.`,
+ totalTags: ({ count }) => `Всього знайдено тегів: ${count}.`,
+ },
+ },
+} as const satisfies Translation
diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx
index cd079a06..f9d7a862 100644
--- a/quartz/plugins/emitters/404.tsx
+++ b/quartz/plugins/emitters/404.tsx
@@ -7,6 +7,9 @@ import { FilePath, FullSlug } from "../../util/path"
import { sharedPageComponents } from "../../../quartz.layout"
import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile"
+import { write } from "./helpers"
+import { i18n } from "../../i18n"
+import DepGraph from "../../depgraph"
export const NotFoundPage: QuartzEmitterPlugin = () => {
const opts: FullPageLayout = {
@@ -25,18 +28,22 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
getQuartzComponents() {
return [Head, Body, pageBody, Footer]
},
- async emit(ctx, _content, resources, emit): Promise {
+ async getDependencyGraph(_ctx, _content, _resources) {
+ return new DepGraph()
+ },
+ async emit(ctx, _content, resources): 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 notFound = i18n(cfg.locale).pages.error.title
const [tree, vfile] = defaultProcessedContent({
slug,
- text: "Not Found",
- description: "Not Found",
- frontmatter: { title: "Not Found", tags: [] },
+ text: notFound,
+ description: notFound,
+ frontmatter: { title: notFound, tags: [] },
})
const componentData: QuartzComponentProps = {
fileData: vfile.data,
@@ -48,8 +55,9 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
}
return [
- await emit({
- content: renderPage(slug, componentData, opts, externalResources),
+ await write({
+ ctx,
+ content: renderPage(cfg, slug, componentData, opts, externalResources),
slug,
ext: ".html",
}),
diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts
index 210715eb..af3578eb 100644
--- a/quartz/plugins/emitters/aliases.ts
+++ b/quartz/plugins/emitters/aliases.ts
@@ -1,24 +1,47 @@
import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import path from "path"
+import { write } from "./helpers"
+import DepGraph from "../../depgraph"
export const AliasRedirects: QuartzEmitterPlugin = () => ({
name: "AliasRedirects",
getQuartzComponents() {
return []
},
- async emit({ argv }, content, _resources, emit): Promise {
- const fps: FilePath[] = []
+ async getDependencyGraph(ctx, content, _resources) {
+ const graph = new DepGraph()
+ const { argv } = ctx
for (const [_tree, file] of content) {
- const ogSlug = simplifySlug(file.data.slug!)
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
+ const aliases = file.data.frontmatter?.aliases ?? []
+ const slugs = 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)
+ }
- let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
- if (typeof aliases === "string") {
- aliases = [aliases]
+ for (let slug of slugs) {
+ // fix any slugs that have trailing slash
+ if (slug.endsWith("/")) {
+ slug = joinSegments(slug, "index") as FullSlug
+ }
+
+ graph.addEdge(file.data.filePath!, joinSegments(argv.output, slug + ".html") as FilePath)
}
+ }
+ return graph
+ },
+ async emit(ctx, content, _resources): Promise {
+ const { argv } = ctx
+ const fps: FilePath[] = []
+
+ for (const [_tree, file] of content) {
+ const ogSlug = simplifySlug(file.data.slug!)
+ const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
+ const aliases = file.data.frontmatter?.aliases ?? []
const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
const permalink = file.data.frontmatter?.permalink
if (typeof permalink === "string") {
@@ -32,7 +55,8 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
}
const redirUrl = resolveRelative(slug, file.data.slug!)
- const fp = await emit({
+ const fp = await write({
+ ctx,
content: `
diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts
index edc22d9e..036b27da 100644
--- a/quartz/plugins/emitters/assets.ts
+++ b/quartz/plugins/emitters/assets.ts
@@ -3,6 +3,14 @@ import { QuartzEmitterPlugin } from "../types"
import path from "path"
import fs from "fs"
import { glob } from "../../util/glob"
+import DepGraph from "../../depgraph"
+import { Argv } from "../../util/ctx"
+import { QuartzConfig } from "../../cfg"
+
+const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => {
+ // glob all non MD files in content folder and copy it over
+ return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
+}
export const Assets: QuartzEmitterPlugin = () => {
return {
@@ -10,10 +18,27 @@ export const Assets: QuartzEmitterPlugin = () => {
getQuartzComponents() {
return []
},
- async emit({ argv, cfg }, _content, _resources, _emit): Promise {
- // glob all non MD/MDX/HTML files in content folder and copy it over
+ async getDependencyGraph(ctx, _content, _resources) {
+ const { argv, cfg } = ctx
+ const graph = new DepGraph()
+
+ const fps = await filesToCopy(argv, cfg)
+
+ for (const fp of fps) {
+ const ext = path.extname(fp)
+ const src = joinSegments(argv.directory, fp) as FilePath
+ const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath
+
+ const dest = joinSegments(argv.output, name) as FilePath
+
+ graph.addEdge(src, dest)
+ }
+
+ return graph
+ },
+ async emit({ argv, cfg }, _content, _resources): Promise {
const assetsPath = argv.output
- const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
+ const fps = await filesToCopy(argv, cfg)
const res: FilePath[] = []
for (const fp of fps) {
const ext = path.extname(fp)
diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts
index ffe2c6d1..cbed2a8b 100644
--- a/quartz/plugins/emitters/cname.ts
+++ b/quartz/plugins/emitters/cname.ts
@@ -2,6 +2,7 @@ import { FilePath, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import fs from "fs"
import chalk from "chalk"
+import DepGraph from "../../depgraph"
export function extractDomainFromBaseUrl(baseUrl: string) {
const url = new URL(`https://${baseUrl}`)
@@ -13,7 +14,10 @@ export const CNAME: QuartzEmitterPlugin = () => ({
getQuartzComponents() {
return []
},
- async emit({ argv, cfg }, _content, _resources, _emit): Promise {
+ async getDependencyGraph(_ctx, _content, _resources) {
+ return new DepGraph()
+ },
+ async emit({ argv, cfg }, _content, _resources): Promise {
if (!cfg.configuration.baseUrl) {
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
return []
diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts
index 884db4dd..04684168 100644
--- a/quartz/plugins/emitters/componentResources.ts
+++ b/quartz/plugins/emitters/componentResources.ts
@@ -1,11 +1,9 @@
-import { FilePath, FullSlug } from "../../util/path"
+import { FilePath, FullSlug, joinSegments } from "../../util/path"
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/custom.scss"
import popoverStyle from "../../components/styles/popover.scss"
@@ -14,6 +12,9 @@ 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"
+import { write } from "./helpers"
+import DepGraph from "../../depgraph"
type ComponentResources = {
css: string[]
@@ -56,9 +57,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(
@@ -87,7 +95,7 @@ function addGlobalPageResources(
function gtag() { dataLayer.push(arguments); }
gtag("js", new Date());
gtag("config", "${tagId}", { send_page_view: false });
-
+
document.addEventListener("nav", () => {
gtag("event", "page_view", {
page_title: document.title,
@@ -95,14 +103,27 @@ function addGlobalPageResources(
});
});`)
} 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.src = "${cfg.analytics.host}" ?? "https://analytics.umami.is/script.js"
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
umamiScript.async = true
-
+
document.head.appendChild(umamiScript)
`)
}
@@ -111,9 +132,11 @@ function addGlobalPageResources(
componentResources.afterDOMLoaded.push(spaRouterScript)
} else {
componentResources.afterDOMLoaded.push(`
- window.spaNavigate = (url, _) => window.location.assign(url)
- const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
- document.dispatchEvent(event)`)
+ window.spaNavigate = (url, _) => window.location.assign(url)
+ window.addCleanup = () => {}
+ const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
+ document.dispatchEvent(event)
+ `)
}
let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
@@ -128,7 +151,8 @@ function addGlobalPageResources(
contentType: "inline",
script: `
const socket = new WebSocket('${wsUrl}')
- socket.addEventListener('message', () => document.location.reload())
+ // reload(true) ensures resources like images and scripts are fetched again in firefox
+ socket.addEventListener('message', () => document.location.reload(true))
`,
})
}
@@ -149,26 +173,95 @@ export const ComponentResources: QuartzEmitterPlugin = (opts?: Partial<
getQuartzComponents() {
return []
},
- async emit(ctx, _content, resources, emit): Promise {
+ async getDependencyGraph(ctx, content, _resources) {
+ // This emitter adds static resources to the `resources` parameter. One
+ // important resource this emitter adds is the code to start a websocket
+ // connection and listen to rebuild messages, which triggers a page reload.
+ // The resources parameter with the reload logic is later used by the
+ // ContentPage emitter while creating the final html page. In order for
+ // the reload logic to be included, and so for partial rebuilds to work,
+ // we need to run this emitter for all markdown files.
+ const graph = new DepGraph()
+
+ for (const [_tree, file] of content) {
+ const sourcePath = file.data.filePath!
+ const slug = file.data.slug!
+ graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath)
+ }
+
+ return graph
+ },
+ async emit(ctx, _content, resources): Promise {
+ const promises: Promise[] = []
+ const cfg = ctx.cfg.configuration
// component specific scripts and styles
const componentResources = getComponentResources(ctx)
- // important that this goes *after* component scripts
- // as the "nav" event gets triggered here and we should make sure
- // that everyone else had the chance to register a listener for it
-
- if (fontOrigin === "googleFonts") {
- resources.css.push(googleFontHref(ctx.cfg.configuration.theme))
- } else if (fontOrigin === "local") {
+ let googleFontsStyleSheet = ""
+ if (fontOrigin === "local") {
// let the user do it themselves in css
+ } else if (fontOrigin === "googleFonts") {
+ if (cfg.theme.cdnCaching) {
+ resources.css.push(googleFontHref(cfg.theme))
+ } else {
+ let match
+
+ const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g
+
+ googleFontsStyleSheet = await (
+ await fetch(googleFontHref(ctx.cfg.configuration.theme))
+ ).text()
+
+ while ((match = fontSourceRegex.exec(googleFontsStyleSheet)) !== null) {
+ // match[0] is the `url(path)`, match[1] is the `path`
+ const url = match[1]
+ // the static name of this file.
+ const [filename, ext] = url.split("/").pop()!.split(".")
+
+ googleFontsStyleSheet = googleFontsStyleSheet.replace(
+ url,
+ `/static/fonts/${filename}.ttf`,
+ )
+
+ promises.push(
+ fetch(url)
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(`Failed to fetch font`)
+ }
+ return res.arrayBuffer()
+ })
+ .then((buf) =>
+ write({
+ ctx,
+ slug: joinSegments("static", "fonts", filename) as FullSlug,
+ ext: `.${ext}`,
+ content: Buffer.from(buf),
+ }),
+ ),
+ )
+ }
+ }
}
+ // important that this goes *after* component scripts
+ // as the "nav" event gets triggered here and we should make sure
+ // that everyone else had the chance to register a listener for it
addGlobalPageResources(ctx, resources, componentResources)
- const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
- const prescript = joinScripts(componentResources.beforeDOMLoaded)
- const postscript = joinScripts(componentResources.afterDOMLoaded)
- const fps = await Promise.all([
- emit({
+ const stylesheet = joinStyles(
+ ctx.cfg.configuration.theme,
+ googleFontsStyleSheet,
+ ...componentResources.css,
+ styles,
+ )
+ const [prescript, postscript] = await Promise.all([
+ joinScripts(componentResources.beforeDOMLoaded),
+ joinScripts(componentResources.afterDOMLoaded),
+ ])
+
+ promises.push(
+ write({
+ ctx,
slug: "index" as FullSlug,
ext: ".css",
content: transform({
@@ -185,18 +278,21 @@ export const ComponentResources: QuartzEmitterPlugin = (opts?: Partial<
include: Features.MediaQueries,
}).code.toString(),
}),
- emit({
+ write({
+ ctx,
slug: "prescript" as FullSlug,
ext: ".js",
content: prescript,
}),
- emit({
+ write({
+ ctx,
slug: "postscript" as FullSlug,
ext: ".js",
content: postscript,
}),
- ])
- return fps
+ )
+
+ return await Promise.all(promises)
},
}
}
diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts
index c5170c64..c0fef86d 100644
--- a/quartz/plugins/emitters/contentIndex.ts
+++ b/quartz/plugins/emitters/contentIndex.ts
@@ -2,10 +2,12 @@ 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"
+import { write } from "./helpers"
+import { i18n } from "../../i18n"
+import DepGraph from "../../depgraph"
export type ContentIndex = Map
export type ContentDetails = {
@@ -37,8 +39,8 @@ const defaultOptions: Options = {
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
const base = cfg.baseUrl ?? ""
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `
- https://${base}/${encodeURI(slug)}
- ${content.date?.toISOString()}
+ https://${joinSegments(base, encodeURI(slug))}
+ ${content.date && `${content.date.toISOString()}`}
`
const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
@@ -48,12 +50,11 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, 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)}
+ https://${joinSegments(base, encodeURI(slug))}
+ https://${joinSegments(base, encodeURI(slug))}
${content.richContent ?? content.description}
${content.date?.toUTCString()}
`
@@ -78,8 +79,8 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
${escapeHTML(cfg.pageTitle)}
- ${root}
- ${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
+ https://${base}
+ ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML(
cfg.pageTitle,
)}
Quartz -- quartz.jzhao.xyz
@@ -92,7 +93,27 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => {
opts = { ...defaultOptions, ...opts }
return {
name: "ContentIndex",
- async emit(ctx, content, _resources, emit) {
+ async getDependencyGraph(ctx, content, _resources) {
+ const graph = new DepGraph()
+
+ for (const [_tree, file] of content) {
+ const sourcePath = file.data.filePath!
+
+ graph.addEdge(
+ sourcePath,
+ joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath,
+ )
+ if (opts?.enableSiteMap) {
+ graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath)
+ }
+ if (opts?.enableRSS) {
+ graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath)
+ }
+ }
+
+ return graph
+ },
+ async emit(ctx, content, _resources) {
const cfg = ctx.cfg.configuration
const emitted: FilePath[] = []
const linkIndex: ContentIndex = new Map()
@@ -116,7 +137,8 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => {
if (opts?.enableSiteMap) {
emitted.push(
- await emit({
+ await write({
+ ctx,
content: generateSiteMap(cfg, linkIndex),
slug: "sitemap" as FullSlug,
ext: ".xml",
@@ -126,7 +148,8 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => {
if (opts?.enableRSS) {
emitted.push(
- await emit({
+ await write({
+ ctx,
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
slug: "index" as FullSlug,
ext: ".xml",
@@ -134,7 +157,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => {
)
}
- const fp = path.join("static", "contentIndex") as FullSlug
+ const fp = joinSegments("static", "contentIndex") as FullSlug
const simplifiedIndex = Object.fromEntries(
Array.from(linkIndex).map(([slug, content]) => {
// remove description and from content index as nothing downstream
@@ -147,7 +170,8 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => {
)
emitted.push(
- await emit({
+ await write({
+ ctx,
content: JSON.stringify(simplifiedIndex),
slug: fp,
ext: ".json",
diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx
index 338bfae4..904a8a8c 100644
--- a/quartz/plugins/emitters/contentPage.tsx
+++ b/quartz/plugins/emitters/contentPage.tsx
@@ -1,13 +1,55 @@
+import path from "path"
+import { visit } from "unist-util-visit"
+import { Root } from "hast"
+import { VFile } from "vfile"
import { QuartzEmitterPlugin } from "../types"
import { QuartzComponentProps } from "../../components/types"
import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg"
-import { FilePath, pathToRoot } from "../../util/path"
+import { Argv } from "../../util/ctx"
+import { FilePath, isRelativeURL, joinSegments, pathToRoot } from "../../util/path"
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { Content } from "../../components"
import chalk from "chalk"
+import { write } from "./helpers"
+import DepGraph from "../../depgraph"
+
+// get all the dependencies for the markdown file
+// eg. images, scripts, stylesheets, transclusions
+const parseDependencies = (argv: Argv, hast: Root, file: VFile): string[] => {
+ const dependencies: string[] = []
+
+ visit(hast, "element", (elem): void => {
+ let ref: string | null = null
+
+ if (
+ ["script", "img", "audio", "video", "source", "iframe"].includes(elem.tagName) &&
+ elem?.properties?.src
+ ) {
+ ref = elem.properties.src.toString()
+ } else if (["a", "link"].includes(elem.tagName) && elem?.properties?.href) {
+ // transclusions will create a tags with relative hrefs
+ ref = elem.properties.href.toString()
+ }
+
+ // if it is a relative url, its a local file and we need to add
+ // it to the dependency graph. otherwise, ignore
+ if (ref === null || !isRelativeURL(ref)) {
+ return
+ }
+
+ let fp = path.join(file.data.filePath!, path.relative(argv.directory, ref)).replace(/\\/g, "/")
+ // markdown files have the .md extension stripped in hrefs, add it back here
+ if (!fp.split("/").pop()?.includes(".")) {
+ fp += ".md"
+ }
+ dependencies.push(fp)
+ })
+
+ return dependencies
+}
export const ContentPage: QuartzEmitterPlugin> = (userOpts) => {
const opts: FullPageLayout = {
@@ -26,7 +68,22 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
- async emit(ctx, content, resources, emit): Promise {
+ async getDependencyGraph(ctx, content, _resources) {
+ const graph = new DepGraph()
+
+ for (const [tree, file] of content) {
+ const sourcePath = file.data.filePath!
+ const slug = file.data.slug!
+ graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath)
+
+ parseDependencies(ctx.argv, tree as Root, file).forEach((dep) => {
+ graph.addEdge(dep as FilePath, sourcePath)
+ })
+ }
+
+ return graph
+ },
+ async emit(ctx, content, resources): Promise {
const cfg = ctx.cfg.configuration
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
@@ -48,8 +105,9 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp
allFiles,
}
- const content = renderPage(slug, componentData, opts, externalResources)
- const fp = await emit({
+ const content = renderPage(cfg, slug, componentData, opts, externalResources)
+ const fp = await write({
+ ctx,
content,
slug,
ext: ".html",
@@ -58,7 +116,7 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp
fps.push(fp)
}
- if (!containsIndex) {
+ if (!containsIndex && !ctx.argv.fastRebuild) {
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.`,
diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx
index 8632eceb..bf69d298 100644
--- a/quartz/plugins/emitters/folderPage.tsx
+++ b/quartz/plugins/emitters/folderPage.tsx
@@ -10,15 +10,18 @@ import {
FilePath,
FullSlug,
SimpleSlug,
- _stripSlashes,
+ stripSlashes,
joinSegments,
pathToRoot,
simplifySlug,
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { FolderContent } from "../../components"
+import { write } from "./helpers"
+import { i18n } from "../../i18n"
+import DepGraph from "../../depgraph"
-export const FolderPage: QuartzEmitterPlugin = (userOpts) => {
+export const FolderPage: QuartzEmitterPlugin> = (userOpts) => {
const opts: FullPageLayout = {
...sharedPageComponents,
...defaultListPageLayout,
@@ -35,7 +38,23 @@ export const FolderPage: QuartzEmitterPlugin = (userOpts) => {
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
- async emit(ctx, content, resources, emit): Promise {
+ async getDependencyGraph(_ctx, content, _resources) {
+ // Example graph:
+ // nested/file.md --> nested/index.html
+ // nested/file2.md ------^
+ const graph = new DepGraph()
+
+ content.map(([_tree, vfile]) => {
+ const slug = vfile.data.slug
+ const folderName = path.dirname(slug ?? "") as SimpleSlug
+ if (slug && folderName !== "." && folderName !== "tags") {
+ graph.addEdge(vfile.data.filePath!, joinSegments(folderName, "index.html") as FilePath)
+ }
+ })
+
+ return graph
+ },
+ async emit(ctx, content, resources): Promise {
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
@@ -56,13 +75,16 @@ export const FolderPage: QuartzEmitterPlugin = (userOpts) => {
folder,
defaultProcessedContent({
slug: joinSegments(folder, "index") as FullSlug,
- frontmatter: { title: `Folder: ${folder}`, tags: [] },
+ frontmatter: {
+ title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folder}`,
+ tags: [],
+ },
}),
]),
)
for (const [tree, file] of content) {
- const slug = _stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
+ const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
if (folders.has(slug)) {
folderDescriptions[slug] = [tree, file]
}
@@ -81,8 +103,9 @@ export const FolderPage: QuartzEmitterPlugin = (userOpts) => {
allFiles,
}
- const content = renderPage(slug, componentData, opts, externalResources)
- const fp = await emit({
+ const content = renderPage(cfg, slug, componentData, opts, externalResources)
+ const fp = await write({
+ ctx,
content,
slug,
ext: ".html",
diff --git a/quartz/plugins/emitters/helpers.ts b/quartz/plugins/emitters/helpers.ts
new file mode 100644
index 00000000..523151c2
--- /dev/null
+++ b/quartz/plugins/emitters/helpers.ts
@@ -0,0 +1,19 @@
+import path from "path"
+import fs from "fs"
+import { BuildCtx } from "../../util/ctx"
+import { FilePath, FullSlug, joinSegments } from "../../util/path"
+
+type WriteOptions = {
+ ctx: BuildCtx
+ slug: FullSlug
+ ext: `.${string}` | ""
+ content: string | Buffer
+}
+
+export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise => {
+ const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath
+ const dir = path.dirname(pathToPage)
+ await fs.promises.mkdir(dir, { recursive: true })
+ await fs.promises.writeFile(pathToPage, content)
+ return pathToPage
+}
diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts
index f0118e2e..c52c6287 100644
--- a/quartz/plugins/emitters/static.ts
+++ b/quartz/plugins/emitters/static.ts
@@ -2,13 +2,28 @@ import { FilePath, QUARTZ, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import fs from "fs"
import { glob } from "../../util/glob"
+import DepGraph from "../../depgraph"
export const Static: QuartzEmitterPlugin = () => ({
name: "Static",
getQuartzComponents() {
return []
},
- async emit({ argv, cfg }, _content, _resources, _emit): Promise {
+ async getDependencyGraph({ argv, cfg }, _content, _resources) {
+ const graph = new DepGraph()
+
+ const staticPath = joinSegments(QUARTZ, "static")
+ const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
+ for (const fp of fps) {
+ graph.addEdge(
+ joinSegments("static", fp) as FilePath,
+ joinSegments(argv.output, "static", fp) as FilePath,
+ )
+ }
+
+ return graph
+ },
+ async emit({ argv, cfg }, _content, _resources): Promise {
const staticPath = joinSegments(QUARTZ, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {
diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx
index 56691198..332c758d 100644
--- a/quartz/plugins/emitters/tagPage.tsx
+++ b/quartz/plugins/emitters/tagPage.tsx
@@ -14,8 +14,11 @@ import {
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components"
+import { write } from "./helpers"
+import { i18n } from "../../i18n"
+import DepGraph from "../../depgraph"
-export const TagPage: QuartzEmitterPlugin = (userOpts) => {
+export const TagPage: QuartzEmitterPlugin> = (userOpts) => {
const opts: FullPageLayout = {
...sharedPageComponents,
...defaultListPageLayout,
@@ -32,7 +35,11 @@ export const TagPage: QuartzEmitterPlugin = (userOpts) => {
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
- async emit(ctx, content, resources, emit): Promise {
+ async getDependencyGraph(ctx, _content, _resources) {
+ // TODO implement
+ return new DepGraph()
+ },
+ async emit(ctx, content, resources): Promise {
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
@@ -46,7 +53,10 @@ export const TagPage: QuartzEmitterPlugin = (userOpts) => {
const tagDescriptions: Record = Object.fromEntries(
[...tags].map((tag) => {
- const title = tag === "index" ? "Tag Index" : `Tag: #${tag}`
+ const title =
+ tag === "index"
+ ? i18n(cfg.locale).pages.tagContent.tagIndex
+ : `${i18n(cfg.locale).pages.tagContent.tag}: #${tag}`
return [
tag,
defaultProcessedContent({
@@ -80,8 +90,9 @@ export const TagPage: QuartzEmitterPlugin = (userOpts) => {
allFiles,
}
- const content = renderPage(slug, componentData, opts, externalResources)
- const fp = await emit({
+ const content = renderPage(cfg, slug, componentData, opts, externalResources)
+ const fp = await write({
+ ctx,
content,
slug: file.data.slug!,
ext: ".html",
diff --git a/quartz/plugins/filters/explicit.ts b/quartz/plugins/filters/explicit.ts
index 30f0b37f..79a46a81 100644
--- a/quartz/plugins/filters/explicit.ts
+++ b/quartz/plugins/filters/explicit.ts
@@ -3,7 +3,6 @@ import { QuartzFilterPlugin } from "../types"
export const ExplicitPublish: QuartzFilterPlugin = () => ({
name: "ExplicitPublish",
shouldPublish(_ctx, [_tree, vfile]) {
- const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
- return publishFlag
+ return vfile.data?.frontmatter?.publish ?? false
},
})
diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts
index 9753d2ea..f35d0535 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/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts
index d50217ba..7073d43b 100644
--- a/quartz/plugins/transformers/frontmatter.ts
+++ b/quartz/plugins/transformers/frontmatter.ts
@@ -5,26 +5,46 @@ import yaml from "js-yaml"
import toml from "toml"
import { slugTag } from "../../util/path"
import { QuartzPluginData } from "../vfile"
+import { i18n } from "../../i18n"
export interface Options {
- delims: string | string[]
+ delimiters: string | [string, string]
language: "yaml" | "toml"
- oneLineTagDelim: string
}
const defaultOptions: Options = {
- delims: "---",
+ delimiters: "---",
language: "yaml",
- oneLineTagDelim: ",",
+}
+
+function coalesceAliases(data: { [key: string]: any }, aliases: string[]) {
+ for (const alias of aliases) {
+ if (data[alias] !== undefined && data[alias] !== null) return data[alias]
+ }
+}
+
+function coerceToArray(input: string | string[]): string[] | undefined {
+ if (input === undefined || input === null) return undefined
+
+ // coerce to array
+ if (!Array.isArray(input)) {
+ input = input
+ .toString()
+ .split(",")
+ .map((tag: string) => tag.trim())
+ }
+
+ // remove all non-strings
+ return input
+ .filter((tag: unknown) => typeof tag === "string" || typeof tag === "number")
+ .map((tag: string | number) => tag.toString())
}
export const FrontMatter: QuartzTransformerPlugin | undefined> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "FrontMatter",
- markdownPlugins() {
- const { oneLineTagDelim } = opts
-
+ markdownPlugins({ cfg }) {
return [
[remarkFrontmatter, ["yaml", "toml"]],
() => {
@@ -37,27 +57,19 @@ export const FrontMatter: QuartzTransformerPlugin | undefined>
},
})
- // tag is an alias for tags
- if (data.tag) {
- data.tags = data.tag
- }
-
- // 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"
+ data.title = file.stem ?? i18n(cfg.configuration.locale).propertyDefaults.title
}
- if (data.tags && !Array.isArray(data.tags)) {
- data.tags = data.tags
- .toString()
- .split(oneLineTagDelim)
- .map((tag: string) => tag.trim())
- }
+ const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"]))
+ if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))]
- // slug them all!!
- data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))]
+ const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"]))
+ if (aliases) data.aliases = aliases
+ const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
+ if (cssclasses) data.cssclasses = cssclasses
// fill in frontmatter
file.data.frontmatter = data as QuartzPluginData["frontmatter"]
@@ -70,9 +82,16 @@ export const FrontMatter: QuartzTransformerPlugin | undefined>
declare module "vfile" {
interface DataMap {
- frontmatter: { [key: string]: any } & {
+ frontmatter: { [key: string]: unknown } & {
title: string
- tags: string[]
- }
+ } & Partial<{
+ tags: string[]
+ aliases: string[]
+ description: string
+ publish: boolean
+ draft: boolean
+ enableToc: string
+ cssclasses: string[]
+ }>
}
}
diff --git a/quartz/plugins/transformers/gfm.ts b/quartz/plugins/transformers/gfm.ts
index 40c2205d..48681ff7 100644
--- a/quartz/plugins/transformers/gfm.ts
+++ b/quartz/plugins/transformers/gfm.ts
@@ -32,13 +32,42 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin |
{
behavior: "append",
properties: {
+ role: "anchor",
ariaHidden: true,
tabIndex: -1,
"data-no-popover": true,
},
content: {
- type: "text",
- value: " §",
+ type: "element",
+ tagName: "svg",
+ properties: {
+ width: 18,
+ height: 18,
+ viewBox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ "stroke-width": "2",
+ "stroke-linecap": "round",
+ "stroke-linejoin": "round",
+ },
+ children: [
+ {
+ type: "element",
+ tagName: "path",
+ properties: {
+ d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71",
+ },
+ children: [],
+ },
+ {
+ type: "element",
+ tagName: "path",
+ properties: {
+ d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
+ },
+ children: [],
+ },
+ ],
},
},
],
diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts
index feca4b52..2c7b9ce4 100644
--- a/quartz/plugins/transformers/lastmod.ts
+++ b/quartz/plugins/transformers/lastmod.ts
@@ -43,24 +43,36 @@ export const CreatedModifiedDate: QuartzTransformerPlugin | und
let published: MaybeDate = undefined
const fp = file.data.filePath!
- const fullFp = path.posix.join(file.cwd, fp)
+ const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp)
for (const source of opts.priority) {
if (source === "filesystem") {
const st = await fs.promises.stat(fullFp)
created ||= st.birthtimeMs
modified ||= st.mtimeMs
} else if (source === "frontmatter" && file.data.frontmatter) {
- created ||= file.data.frontmatter.date
- modified ||= file.data.frontmatter.lastmod
- modified ||= file.data.frontmatter.updated
- modified ||= file.data.frontmatter["last-modified"]
- published ||= file.data.frontmatter.publishDate
+ created ||= file.data.frontmatter.date as MaybeDate
+ modified ||= file.data.frontmatter.lastmod as MaybeDate
+ modified ||= file.data.frontmatter.updated as MaybeDate
+ modified ||= file.data.frontmatter["last-modified"] as MaybeDate
+ published ||= file.data.frontmatter.publishDate as MaybeDate
} 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`,
+ ),
+ )
+ }
}
}
diff --git a/quartz/plugins/transformers/latex.ts b/quartz/plugins/transformers/latex.ts
index cb459f74..c9f6bff0 100644
--- a/quartz/plugins/transformers/latex.ts
+++ b/quartz/plugins/transformers/latex.ts
@@ -26,12 +26,12 @@ export const Latex: QuartzTransformerPlugin = (opts?: Options) => {
return {
css: [
// base css
- "https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
+ "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css",
],
js: [
{
// fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
- src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js",
+ src: "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/copy-tex.min.js",
loadTime: "afterDOMReady",
contentType: "external",
},
diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts
index 3072959d..f89d367d 100644
--- a/quartz/plugins/transformers/links.ts
+++ b/quartz/plugins/transformers/links.ts
@@ -4,14 +4,16 @@ import {
RelativeURL,
SimpleSlug,
TransformOptions,
- _stripSlashes,
+ stripSlashes,
simplifySlug,
splitAnchor,
transformLink,
+ joinSegments,
} from "../../util/path"
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 */
@@ -19,12 +21,16 @@ interface Options {
/** Strips folders from a link so that it looks nice */
prettyLinks: boolean
openLinksInNewTab: boolean
+ lazyLoad: boolean
+ externalLinkIcon: boolean
}
const defaultOptions: Options = {
markdownLinkResolution: "absolute",
prettyLinks: true,
openLinksInNewTab: false,
+ lazyLoad: false,
+ externalLinkIcon: true,
}
export const CrawlLinks: QuartzTransformerPlugin | undefined> = (userOpts) => {
@@ -34,7 +40,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()
@@ -51,8 +57,30 @@ 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[]
+ const isExternal = isAbsoluteUrl(dest)
+ classes.push(isExternal ? "external" : "internal")
+
+ if (isExternal && opts.externalLinkIcon) {
+ node.children.push({
+ type: "element",
+ tagName: "svg",
+ properties: {
+ class: "external-icon",
+ viewBox: "0 0 512 512",
+ },
+ children: [
+ {
+ type: "element",
+ tagName: "path",
+ properties: {
+ d: "M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z",
+ },
+ children: [],
+ },
+ ],
+ })
+ }
// Check if the link has alias text
if (
@@ -61,8 +89,9 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> =
node.children[0].value !== dest
) {
// Add the 'alias' class if the text content is not the same as the href
- node.properties.className.push("alias")
+ classes.push("alias")
}
+ node.properties.className = classes
if (opts.openLinksInNewTab) {
node.properties.target = "_blank"
@@ -79,7 +108,7 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> =
// url.resolve is considered legacy
// WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
- const url = new URL(dest, `https://base.com/${curSlug}`)
+ const url = new URL(dest, "https://base.com/" + stripSlashes(curSlug, true))
const canonicalDest = url.pathname
let [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
if (destCanonical.endsWith("/")) {
@@ -87,7 +116,7 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> =
}
// need to decodeURIComponent here as WHATWG URL percent-encodes everything
- const full = decodeURIComponent(_stripSlashes(destCanonical, true)) as FullSlug
+ const full = decodeURIComponent(stripSlashes(destCanonical, true)) as FullSlug
const simple = simplifySlug(full)
outgoing.add(simple)
node.properties["data-slug"] = full
@@ -111,6 +140,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 4622ef6f..0ee2861c 100644
--- a/quartz/plugins/transformers/ofm.ts
+++ b/quartz/plugins/transformers/ofm.ts
@@ -1,20 +1,23 @@
import { QuartzTransformerPlugin } from "../types"
-import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
+import { Blockquote, 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"
+import { SKIP, visit } from "unist-util-visit"
import path from "path"
import { JSResource } from "../../util/resources"
// @ts-ignore
import calloutScript from "../../components/scripts/callout.inline.ts"
+// @ts-ignore
+import checkboxScript from "../../components/scripts/checkbox.inline.ts"
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"
+import { ValidCallout, i18n } from "../../i18n"
export interface Options {
comments: boolean
@@ -23,8 +26,12 @@ export interface Options {
callouts: boolean
mermaid: boolean
parseTags: boolean
+ parseArrows: boolean
parseBlockReferences: boolean
enableInHtmlEmbed: boolean
+ enableYouTubeEmbed: boolean
+ enableVideoEmbed: boolean
+ enableCheckbox: boolean
}
const defaultOptions: Options = {
@@ -34,47 +41,15 @@ const defaultOptions: Options = {
callouts: true,
mermaid: true,
parseTags: true,
+ parseArrows: true,
parseBlockReferences: true,
enableInHtmlEmbed: false,
+ enableYouTubeEmbed: true,
+ enableVideoEmbed: true,
+ enableCheckbox: false,
}
-const icons = {
- infoIcon: ``,
- pencilIcon: ``,
- clipboardListIcon: ``,
- checkCircleIcon: ``,
- flameIcon: ``,
- checkIcon: ``,
- helpCircleIcon: ``,
- alertTriangleIcon: ``,
- xIcon: ``,
- zapIcon: ``,
- bugIcon: ``,
- listIcon: ``,
- quoteIcon: ``,
- bookIcon: ``,
- wrenchIcon:``,
-}
-
-const callouts = {
- note: icons.pencilIcon,
- abstract: icons.clipboardListIcon,
- info: icons.infoIcon,
- todo: icons.checkCircleIcon,
- tip: icons.flameIcon,
- success: icons.checkIcon,
- question: icons.helpCircleIcon,
- warning: icons.alertTriangleIcon,
- failure: icons.xIcon,
- danger: icons.zapIcon,
- bug: icons.bugIcon,
- example: icons.listIcon,
- quote: icons.quoteIcon,
- def: icons.bookIcon,
- application:icons.wrenchIcon,
-}
-
-const calloutMapping: Record = {
+const calloutMapping = {
note: "note",
abstract: "abstract",
summary: "abstract",
@@ -105,26 +80,40 @@ const calloutMapping: Record = {
def: "def",
definition: "def",
application:"application",
+} as const
+
+const arrowMapping: Record = {
+ "->": "→",
+ "-->": "⇒",
+ "=>": "⇒",
+ "==>": "⇒",
+ "<-": "←",
+ "<--": "⇐",
+ "<=": "⇐",
+ "<==": "⇐",
}
-function canonicalizeCallout(calloutName: string): keyof typeof callouts {
- let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
- return calloutMapping[callout] ?? "note"
+function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping {
+ const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping
+ // if callout is not recognized, make it a custom one
+ return calloutMapping[normalizedCallout] ?? calloutName
}
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)
+export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/, "g")
+
+// !? -> 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)
export const wikilinkRegex = new RegExp(
- /!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/,
+ /!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\#]+)?\]\]/,
"g",
)
const highlightRegex = new RegExp(/==([^=]+)==/, "g")
-const commentRegex = new RegExp(/%%(.+)%%/, "g")
+const commentRegex = new RegExp(/%%[\s\S]*?%%/, "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")
@@ -132,8 +121,16 @@ const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
// #(...) -> 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}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu")
-const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g")
+const tagRegex = new RegExp(
+ /(?:^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/,
+ "gu",
+)
+const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/, "g")
+const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
+const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/)
+const wikilinkImageEmbedRegex = new RegExp(
+ /^(?(?!^\d*x?\d*$).*?)?(\|?\s*?(?\d+)(x(?\d+))?)?$/,
+)
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin | undefined> = (
userOpts,
@@ -148,13 +145,22 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
return {
name: "ObsidianFlavoredMarkdown",
textTransform(_ctx, src) {
+ // do comments at text level
+ if (opts.comments) {
+ if (src instanceof Buffer) {
+ src = src.toString()
+ }
+
+ src = src.replace(commentRegex, "")
+ }
+
// pre-transform blockquotes
if (opts.callouts) {
if (src instanceof Buffer) {
src = src.toString()
}
- src = src.replaceAll(calloutLineRegex, (value) => {
+ src = src.replace(calloutLineRegex, (value) => {
// force newline after title of callout
return value + "\n> "
})
@@ -166,7 +172,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
src = src.toString()
}
- src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
+ src = src.replace(wikilinkRegex, (value, ...capture) => {
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
const fp = rawFp ?? ""
@@ -186,8 +192,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
return src
},
- markdownPlugins() {
+ markdownPlugins(ctx) {
const plugins: PluggableList = []
+ const cfg = ctx.cfg.configuration
// regex replacements
plugins.push(() => {
@@ -208,11 +215,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
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"
+ if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) {
+ const match = wikilinkImageEmbedRegex.exec(alias ?? "")
+ const alt = match?.groups?.alt ?? ""
+ const width = match?.groups?.width ?? "auto"
+ const height = match?.groups?.height ?? "auto"
return {
type: "image",
url,
@@ -220,6 +227,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
hProperties: {
width,
height,
+ alt,
},
},
}
@@ -240,7 +248,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
type: "html",
value: ``,
}
- } else if (ext === "") {
+ } else {
const block = anchor
return {
type: "html",
@@ -283,13 +291,15 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
])
}
- if (opts.comments) {
+ if (opts.parseArrows) {
replacements.push([
- commentRegex,
- (_value: string, ..._capture: string[]) => {
+ arrowRegex,
+ (value: string, ..._capture: string[]) => {
+ const maybeArrow = arrowMapping[value]
+ if (maybeArrow === undefined) return SKIP
return {
- type: "text",
- value: "",
+ type: "html",
+ value: `${maybeArrow}`,
}
},
])
@@ -305,8 +315,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
}
tag = slugTag(tag)
- if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
- file.data.frontmatter.tags.push(tag)
+ if (file.data.frontmatter) {
+ const noteTags = file.data.frontmatter.tags ?? []
+ file.data.frontmatter.tags = [...new Set([...noteTags, tag])]
}
return {
@@ -334,7 +345,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
if (typeof replace === "string") {
node.value = node.value.replace(regex, replace)
} else {
- node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
+ node.value = node.value.replace(regex, (substring: string, ...args) => {
const replaceValue = replace(substring, ...args)
if (typeof replaceValue === "string") {
return replaceValue
@@ -350,11 +361,28 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
}
})
}
-
mdastFindReplace(tree, replacements)
}
})
+ if (opts.enableVideoEmbed) {
+ plugins.push(() => {
+ return (tree: Root, _file) => {
+ visit(tree, "image", (node, index, parent) => {
+ if (parent && index != undefined && videoExtensionRegex.test(node.url)) {
+ const newNode: Html = {
+ type: "html",
+ value: ``,
+ }
+
+ parent.children.splice(index, 1, newNode)
+ return SKIP
+ }
+ })
+ }
+ })
+ }
+
if (opts.callouts) {
plugins.push(() => {
return (tree: Root, _file) => {
@@ -370,36 +398,43 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
}
const text = firstChild.children[0].value
- const restChildren = firstChild.children.slice(1)
+ const restOfTitle = firstChild.children.slice(1)
const [firstLine, ...remainingLines] = text.split("\n")
const remainingText = remainingLines.join("\n")
const match = firstLine.match(calloutRegex)
if (match && match.input) {
const [calloutDirective, typeString, collapseChar] = match
- const calloutType = canonicalizeCallout(
- typeString.toLowerCase() as keyof typeof calloutMapping,
- )
+ const calloutType = canonicalizeCallout(typeString.toLowerCase())
const collapse = collapseChar === "+" || collapseChar === "-"
const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
- const titleContent =
- match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
+ const titleContent = match.input.slice(calloutDirective.length).trim()
+ const useDefaultTitle = titleContent === "" && restOfTitle.length === 0
const titleNode: Paragraph = {
type: "paragraph",
- children: [{ type: "text", value: titleContent + " " }, ...restChildren],
+ children: [
+ {
+ type: "text",
+ value: useDefaultTitle
+ ? capitalize(
+ i18n(cfg.locale).components.callout[calloutType as ValidCallout] ??
+ calloutType,
+ )
+ : titleContent + " ",
+ },
+ ...restOfTitle,
+ ],
}
const title = mdastToHtml(titleNode)
- const toggleIcon = ``
+ const toggleIcon = ``
const titleHtml: Html = {
type: "html",
value: `
-
${callouts[calloutType]}
+
${title}
${collapse ? toggleIcon : ""}
`,
@@ -421,13 +456,19 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
// replace first line of blockquote with title and rest of the paragraph text
node.children.splice(0, 1, ...blockquoteContent)
+ const classNames = ["callout", calloutType]
+ if (collapse) {
+ classNames.push("is-collapsible")
+ }
+ if (defaultState === "collapsed") {
+ classNames.push("is-collapsed")
+ }
+
// add properties to base blockquote
node.data = {
hProperties: {
...(node.data?.hProperties ?? {}),
- className: `callout ${collapse ? "is-collapsible" : ""} ${
- defaultState === "collapsed" ? "is-collapsed" : ""
- }`,
+ className: classNames.join(" "),
"data-callout": calloutType,
"data-callout-fold": collapse,
},
@@ -512,11 +553,61 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
})
}
+ 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}`,
+ }
+ }
+ }
+ })
+ }
+ })
+ }
+
+ if (opts.enableCheckbox) {
+ plugins.push(() => {
+ return (tree: HtmlRoot, _file) => {
+ visit(tree, "element", (node) => {
+ if (node.tagName === "input" && node.properties.type === "checkbox") {
+ const isChecked = node.properties?.checked ?? false
+ node.properties = {
+ type: "checkbox",
+ disabled: false,
+ checked: isChecked,
+ class: "checkbox-toggle",
+ }
+ }
+ })
+ }
+ })
+ }
+
return plugins
},
externalResources() {
const js: JSResource[] = []
+ if (opts.enableCheckbox) {
+ js.push({
+ script: checkboxScript,
+ loadTime: "afterDOMReady",
+ contentType: "inline",
+ })
+ }
+
if (opts.callouts) {
js.push({
script: calloutScript,
@@ -528,17 +619,22 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin
if (opts.mermaid) {
js.push({
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({
- startOnLoad: false,
- securityLevel: 'loose',
- theme: darkMode ? 'dark' : 'default'
- });
+ let mermaidImport = undefined
document.addEventListener('nav', async () => {
- await mermaid.run({
- querySelector: '.mermaid'
- })
+ if (document.querySelector("code.mermaid")) {
+ mermaidImport ||= await import('https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.7.0/mermaid.esm.min.mjs')
+ const mermaid = mermaidImport.default
+ const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark'
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: 'loose',
+ theme: darkMode ? 'dark' : 'default'
+ })
+
+ await mermaid.run({
+ querySelector: '.mermaid'
+ })
+ }
});
`,
loadTime: "afterDOMReady",
diff --git a/quartz/plugins/transformers/syntax.ts b/quartz/plugins/transformers/syntax.ts
index e8477296..f11734e5 100644
--- a/quartz/plugins/transformers/syntax.ts
+++ b/quartz/plugins/transformers/syntax.ts
@@ -1,20 +1,33 @@
import { QuartzTransformerPlugin } from "../types"
-import rehypePrettyCode, { Options as CodeOptions } from "rehype-pretty-code"
+import rehypePrettyCode, { Options as CodeOptions, Theme as CodeTheme } from "rehype-pretty-code"
-export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
- name: "SyntaxHighlighting",
- htmlPlugins() {
- return [
- [
- rehypePrettyCode,
- {
- keepBackground: false,
- theme: {
- dark: "github-dark",
- light: "github-light",
- },
- } satisfies Partial,
- ],
- ]
+interface Theme extends Record {
+ light: CodeTheme
+ dark: CodeTheme
+}
+
+interface Options {
+ theme?: Theme
+ keepBackground?: boolean
+}
+
+const defaultOptions: Options = {
+ theme: {
+ light: "github-light",
+ dark: "github-dark",
},
-})
+ keepBackground: false,
+}
+
+export const SyntaxHighlighting: QuartzTransformerPlugin = (
+ userOpts?: Partial,
+) => {
+ const opts: Partial = { ...defaultOptions, ...userOpts }
+
+ return {
+ name: "SyntaxHighlighting",
+ htmlPlugins() {
+ return [[rehypePrettyCode, opts]]
+ },
+ }
+}
diff --git a/quartz/plugins/transformers/toc.ts b/quartz/plugins/transformers/toc.ts
index d0781ec2..4c31d206 100644
--- a/quartz/plugins/transformers/toc.ts
+++ b/quartz/plugins/transformers/toc.ts
@@ -3,7 +3,6 @@ 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
@@ -25,7 +24,7 @@ interface TocEntry {
slug: string // this is just the anchor (#some-slug), not the canonical slug
}
-const regexMdLinks = new RegExp(/\[([^\[]+)\](\(.*\))/, "g")
+const slugAnchor = new Slugger()
export const TableOfContents: QuartzTransformerPlugin | undefined> = (
userOpts,
) => {
@@ -38,21 +37,12 @@ export const TableOfContents: QuartzTransformerPlugin | undefin
return async (tree: Root, file) => {
const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
if (display) {
- const slugAnchor = new Slugger()
+ slugAnchor.reset()
const toc: TocEntry[] = []
let highestDepth: number = opts.maxDepth
visit(tree, "heading", (node) => {
if (node.depth <= opts.maxDepth) {
- 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")
-
+ const text = toString(node)
highestDepth = Math.min(highestDepth, node.depth)
toc.push({
depth: node.depth,
diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts
index eaeb12ae..a23f5d6f 100644
--- a/quartz/plugins/types.ts
+++ b/quartz/plugins/types.ts
@@ -2,8 +2,9 @@ import { PluggableList } from "unified"
import { StaticResources } from "../util/resources"
import { ProcessedContent } from "./vfile"
import { QuartzComponent } from "../components/types"
-import { FilePath, FullSlug } from "../util/path"
+import { FilePath } from "../util/path"
import { BuildCtx } from "../util/ctx"
+import DepGraph from "../depgraph"
export interface PluginTypes {
transformers: QuartzTransformerPluginInstance[]
@@ -36,19 +37,11 @@ export type QuartzEmitterPlugin = (
) => QuartzEmitterPluginInstance
export type QuartzEmitterPluginInstance = {
name: string
- emit(
+ emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise
+ getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
+ getDependencyGraph?(
ctx: BuildCtx,
content: ProcessedContent[],
resources: StaticResources,
- emitCallback: EmitCallback,
- ): Promise
- getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
+ ): Promise>
}
-
-export interface EmitOptions {
- slug: FullSlug
- ext: `.${string}` | ""
- content: string
-}
-
-export type EmitCallback = (data: EmitOptions) => Promise
diff --git a/quartz/processors/emit.ts b/quartz/processors/emit.ts
index 3b357aa9..c68e0ede 100644
--- a/quartz/processors/emit.ts
+++ b/quartz/processors/emit.ts
@@ -1,10 +1,6 @@
-import path from "path"
-import fs from "fs"
import { PerfTimer } from "../util/perf"
import { getStaticResourcesFromPlugins } from "../plugins"
-import { EmitCallback } from "../plugins/types"
import { ProcessedContent } from "../plugins/vfile"
-import { FilePath, joinSegments } from "../util/path"
import { QuartzLogger } from "../util/log"
import { trace } from "../util/trace"
import { BuildCtx } from "../util/ctx"
@@ -15,19 +11,12 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
const log = new QuartzLogger(ctx.argv.verbose)
log.start(`Emitting output files`)
- const emit: EmitCallback = async ({ slug, ext, content }) => {
- const pathToPage = joinSegments(argv.output, slug + ext) as FilePath
- const dir = path.dirname(pathToPage)
- await fs.promises.mkdir(dir, { recursive: true })
- await fs.promises.writeFile(pathToPage, content)
- return pathToPage
- }
let emittedFiles = 0
const staticResources = getStaticResourcesFromPlugins(ctx)
for (const emitter of cfg.plugins.emitters) {
try {
- const emitted = await emitter.emit(ctx, content, staticResources, emit)
+ const emitted = await emitter.emit(ctx, content, staticResources)
emittedFiles += emitted.length
if (ctx.argv.verbose) {
diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts
index fab17954..3950fee0 100644
--- a/quartz/processors/parse.ts
+++ b/quartz/processors/parse.ts
@@ -91,8 +91,9 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
}
// 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 af9c6f7d..92798a2d 100644
--- a/quartz/styles/base.scss
+++ b/quartz/styles/base.scss
@@ -4,7 +4,6 @@
html {
scroll-behavior: smooth;
- -webkit-text-size-adjust: none;
text-size-adjust: none;
overflow-x: hidden;
width: 100vw;
@@ -27,7 +26,7 @@ section {
}
::selection {
- background: color-mix(in srgb, var(--tertiary) 75%, transparent);
+ background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
color: var(--darkgray);
}
@@ -54,8 +53,12 @@ ul,
}
}
+strong {
+ font-weight: $semiBoldWeight;
+}
+
a {
- font-weight: 600;
+ font-weight: $semiBoldWeight;
text-decoration: none;
transition: color 0.2s ease;
color: var(--secondary);
@@ -69,6 +72,7 @@ a {
background-color: var(--highlight);
padding: 0 0.1rem;
border-radius: 5px;
+ line-height: 1.4rem;
&:has(> img) {
background-color: none;
@@ -76,6 +80,15 @@ a {
padding: 0;
}
}
+
+ &.external .external-icon {
+ height: 1ex;
+ margin: 0 0.15em;
+
+ > path {
+ fill: var(--dark);
+ }
+ }
}
.desktop-only {
@@ -162,9 +175,11 @@ a {
& .sidebar.right {
right: calc(calc(100vw - $pageWidth) / 2 - $sidePanelWidth);
+ flex-wrap: wrap;
& > * {
@media all and (max-width: $fullPageWidth) {
flex: 1;
+ min-width: 140px;
}
}
}
@@ -248,11 +263,9 @@ thead {
font-weight: revert;
margin-bottom: 0;
- article > & > a {
+ article > & > a[role="anchor"] {
color: var(--dark);
- &.internal {
- background-color: transparent;
- }
+ background-color: transparent;
}
}
@@ -267,7 +280,6 @@ h6 {
opacity: 0;
transition: opacity 0.2s ease;
transform: translateY(-0.1rem);
- display: inline-block;
font-family: var(--codeFont);
user-select: none;
}
@@ -332,6 +344,7 @@ pre {
border-radius: 5px;
overflow-x: auto;
border: 1px solid var(--lightgray);
+ position: relative;
&:has(> code.mermaid) {
border: none;
@@ -345,6 +358,7 @@ pre {
counter-increment: line 0;
display: grid;
padding: 0.5rem 0;
+ overflow-x: scroll;
& [data-highlighted-chars] {
background-color: var(--highlight);
diff --git a/quartz/styles/callouts.scss b/quartz/styles/callouts.scss
index 703bd67f..b1fd180c 100644
--- a/quartz/styles/callouts.scss
+++ b/quartz/styles/callouts.scss
@@ -1,3 +1,4 @@
+@use "./variables.scss" as *;
@use "sass:color";
.callout {
@@ -13,16 +14,33 @@
margin-top: 0;
}
- &[data-callout="note"] {
+ --callout-icon-note: url('data:image/svg+xml; utf8, ');
+ --callout-icon-abstract: url('data:image/svg+xml; utf8, ');
+ --callout-icon-info: url('data:image/svg+xml; utf8, ');
+ --callout-icon-todo: url('data:image/svg+xml; utf8, ');
+ --callout-icon-tip: url('data:image/svg+xml; utf8, ');
+ --callout-icon-success: url('data:image/svg+xml; utf8, ');
+ --callout-icon-question: url('data:image/svg+xml; utf8, ');
+ --callout-icon-warning: url('data:image/svg+xml; utf8, ');
+ --callout-icon-failure: url('data:image/svg+xml; utf8, ');
+ --callout-icon-danger: url('data:image/svg+xml; utf8, ');
+ --callout-icon-bug: url('data:image/svg+xml; utf8, ');
+ --callout-icon-example: url('data:image/svg+xml; utf8, ');
+ --callout-icon-quote: url('data:image/svg+xml; utf8, ');
+ --callout-icon-fold: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpolyline points="6 9 12 15 18 9"%3E%3C/polyline%3E%3C/svg%3E');
+
+ &[data-callout] {
--color: #448aff;
--border: #448aff44;
--bg: #448aff10;
+ --callout-icon: var(--callout-icon-note);
}
&[data-callout="abstract"] {
--color: #00b0ff;
--border: #00b0ff44;
--bg: #00b0ff10;
+ --callout-icon: var(--callout-icon-abstract);
}
&[data-callout="info"],
@@ -30,30 +48,39 @@
--color: #00b8d4;
--border: #00b8d444;
--bg: #00b8d410;
+ --callout-icon: var(--callout-icon-info);
+ }
+
+ &[data-callout="todo"] {
+ --callout-icon: var(--callout-icon-todo);
}
&[data-callout="tip"] {
--color: #00bfa5;
--border: #00bfa544;
--bg: #00bfa510;
+ --callout-icon: var(--callout-icon-tip);
}
&[data-callout="success"] {
--color: #09ad7a;
--border: #09ad7144;
--bg: #09ad7110;
+ --callout-icon: var(--callout-icon-success);
}
&[data-callout="question"] {
--color: #dba642;
--border: #dba64244;
--bg: #dba64210;
+ --callout-icon: var(--callout-icon-question);
}
&[data-callout="warning"] {
--color: #db8942;
--border: #db894244;
--bg: #db894210;
+ --callout-icon: var(--callout-icon-warning);
}
&[data-callout="failure"],
@@ -62,50 +89,74 @@
--color: #db4242;
--border: #db424244;
--bg: #db424210;
+ --callout-icon: var(--callout-icon-failure);
+ }
+
+ &[data-callout="bug"] {
+ --callout-icon: var(--callout-icon-bug);
+ }
+
+ &[data-callout="danger"] {
+ --callout-icon: var(--callout-icon-danger);
}
&[data-callout="example"] {
--color: #7a43b5;
--border: #7a43b544;
--bg: #7a43b510;
+ --callout-icon: var(--callout-icon-example);
}
&[data-callout="quote"] {
--color: var(--secondary);
--border: var(--lightgray);
+ --callout-icon: var(--callout-icon-quote);
}
- &.is-collapsed > .callout-title > .fold {
+ &.is-collapsed > .callout-title > .fold-callout-icon {
transform: rotateZ(-90deg);
}
}
.callout-title {
display: flex;
+ align-items: flex-start;
gap: 5px;
padding: 1rem 0;
color: var(--color);
- & .fold {
- margin-left: 0.5rem;
- transition: transform 0.3s ease;
+ --icon-size: 18px;
+
+ & .fold-callout-icon {
+ transition: transform 0.15s ease;
opacity: 0.8;
cursor: pointer;
+ --callout-icon: var(--callout-icon-fold);
}
& > .callout-title-inner > p {
color: var(--color);
margin: 0;
}
-}
-.callout-icon {
- width: 18px;
- height: 18px;
- flex: 0 0 18px;
- padding-top: 4px;
-}
+ .callout-icon,
+ & .fold-callout-icon {
+ width: var(--icon-size);
+ height: var(--icon-size);
+ flex: 0 0 var(--icon-size);
+
+ // icon support
+ background-size: var(--icon-size) var(--icon-size);
+ background-position: center;
+ background-color: var(--color);
+ mask-image: var(--callout-icon);
+ mask-size: var(--icon-size) var(--icon-size);
+ mask-position: center;
+ mask-repeat: no-repeat;
+ padding: 0.2rem 0;
+ }
-.callout-title-inner {
- font-weight: 700;
+ .callout-title-inner {
+ font-weight: $semiBoldWeight;
+ }
}
diff --git a/quartz/styles/variables.scss b/quartz/styles/variables.scss
index 30004aa7..e45cc915 100644
--- a/quartz/styles/variables.scss
+++ b/quartz/styles/variables.scss
@@ -1,6 +1,9 @@
$pageWidth: 750px;
$mobileBreakpoint: 600px;
-$tabletBreakpoint: 1200px;
+$tabletBreakpoint: 1000px;
$sidePanelWidth: 380px;
$topSpacing: 6rem;
$fullPageWidth: $pageWidth + 2 * $sidePanelWidth;
+$boldWeight: 700;
+$semiBoldWeight: 600;
+$normalWeight: 400;
diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts
index 13e0bf86..e0561141 100644
--- a/quartz/util/ctx.ts
+++ b/quartz/util/ctx.ts
@@ -6,6 +6,7 @@ export interface Argv {
verbose: boolean
output: string
serve: boolean
+ fastRebuild: boolean
port: number
wsPort: number
remoteDevHost?: string
diff --git a/quartz/util/lang.ts b/quartz/util/lang.ts
index 5211b5d9..6fb04699 100644
--- a/quartz/util/lang.ts
+++ b/quartz/util/lang.ts
@@ -1,11 +1,13 @@
-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)
}
+
+export function classNames(
+ displayClass?: "mobile-only" | "desktop-only",
+ ...classes: string[]
+): string {
+ if (displayClass) {
+ classes.push(displayClass)
+ }
+ return classes.join(" ")
+}
diff --git a/quartz/util/path.test.ts b/quartz/util/path.test.ts
index 18edc940..7e9c4c84 100644
--- a/quartz/util/path.test.ts
+++ b/quartz/util/path.test.ts
@@ -105,6 +105,10 @@ describe("transforms", () => {
["index.md", "index"],
["test.mp4", "test.mp4"],
["note with spaces.md", "note-with-spaces"],
+ ["notes.with.dots.md", "notes.with.dots"],
+ ["test/special chars?.md", "test/special-chars"],
+ ["test/special chars #3.md", "test/special-chars-3"],
+ ["cool/what about r&d?.md", "cool/what-about-r-and-d"],
],
path.slugifyFilePath,
path.isFilePath,
diff --git a/quartz/util/path.ts b/quartz/util/path.ts
index d3997069..dceb89bf 100644
--- a/quartz/util/path.ts
+++ b/quartz/util/path.ts
@@ -2,7 +2,7 @@ import { slug as slugAnchor } from "github-slugger"
import type { Element as HastElement } from "hast"
import rfdc from "rfdc"
-const clone = rfdc()
+export const clone = rfdc()
// this file must be isomorphic so it can't use node libs (e.g. path)
@@ -23,22 +23,22 @@ export type FullSlug = SlugLike<"full">
export function isFullSlug(s: string): s is FullSlug {
const validStart = !(s.startsWith(".") || s.startsWith("/"))
const validEnding = !s.endsWith("/")
- return validStart && validEnding && !_containsForbiddenCharacters(s)
+ return validStart && validEnding && !containsForbiddenCharacters(s)
}
/** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */
export type SimpleSlug = SlugLike<"simple">
export function isSimpleSlug(s: string): s is SimpleSlug {
const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/")))
- const validEnding = !(s.endsWith("/index") || s === "index")
- return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s)
+ const validEnding = !endsWith(s, "index")
+ return validStart && !containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s)
}
/** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */
export type RelativeURL = SlugLike<"relative">
export function isRelativeURL(s: string): s is RelativeURL {
const validStart = /^\.{1,2}/.test(s)
- const validEnding = !(s.endsWith("/index") || s === "index")
+ const validEnding = !endsWith(s, "index")
return validStart && validEnding && ![".md", ".html"].includes(_getFileExtension(s) ?? "")
}
@@ -50,13 +50,20 @@ export function getFullSlug(window: Window): FullSlug {
function sluggify(s: string): string {
return s
.split("/")
- .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments
+ .map((segment) =>
+ segment
+ .replace(/\s/g, "-")
+ .replace(/&/g, "-and-")
+ .replace(/%/g, "-percent")
+ .replace(/\?/g, "")
+ .replace(/#/g, ""),
+ )
.join("/") // always use / as sep
.replace(/\/$/, "")
}
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
- fp = _stripSlashes(fp) as FilePath
+ fp = stripSlashes(fp) as FilePath
let ext = _getFileExtension(fp)
const withoutFileExt = fp.replace(new RegExp(ext + "$"), "")
if (excludeExt || [".md", ".html", undefined].includes(ext)) {
@@ -66,7 +73,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
let slug = sluggify(withoutFileExt)
// treat _index as index
- if (_endsWith(slug, "_index")) {
+ if (endsWith(slug, "_index")) {
slug = slug.replace(/_index$/, "index")
}
@@ -74,21 +81,21 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
}
export function simplifySlug(fp: FullSlug): SimpleSlug {
- const res = _stripSlashes(_trimSuffix(fp, "index"), true)
+ const res = stripSlashes(trimSuffix(fp, "index"), true)
return (res.length === 0 ? "/" : res) as SimpleSlug
}
export function transformInternalLink(link: string): RelativeURL {
let [fplike, anchor] = splitAnchor(decodeURI(link))
- const folderPath = _isFolderPath(fplike)
+ const folderPath = isFolderPath(fplike)
let segments = fplike.split("/").filter((x) => x.length > 0)
- let prefix = segments.filter(_isRelativeSegment).join("/")
- let fp = segments.filter((seg) => !_isRelativeSegment(seg) && seg !== "").join("/")
+ let prefix = segments.filter(isRelativeSegment).join("/")
+ let fp = segments.filter((seg) => !isRelativeSegment(seg) && seg !== "").join("/")
// manually add ext here as we want to not strip 'index' if it has an extension
const simpleSlug = simplifySlug(slugifyFilePath(fp as FilePath))
- const joined = joinSegments(_stripSlashes(prefix), _stripSlashes(simpleSlug))
+ const joined = joinSegments(stripSlashes(prefix), stripSlashes(simpleSlug))
const trail = folderPath ? "/" : ""
const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL
return res
@@ -199,8 +206,8 @@ export function transformLink(src: FullSlug, target: string, opts: TransformOpti
if (opts.strategy === "relative") {
return targetSlug as RelativeURL
} else {
- const folderTail = _isFolderPath(targetSlug) ? "/" : ""
- const canonicalSlug = _stripSlashes(targetSlug.slice(".".length))
+ const folderTail = isFolderPath(targetSlug) ? "/" : ""
+ const canonicalSlug = stripSlashes(targetSlug.slice(".".length))
let [targetCanonical, targetAnchor] = splitAnchor(canonicalSlug)
if (opts.strategy === "shortest") {
@@ -223,28 +230,29 @@ export function transformLink(src: FullSlug, target: string, opts: TransformOpti
}
}
-function _isFolderPath(fplike: string): boolean {
+// path helpers
+function isFolderPath(fplike: string): boolean {
return (
fplike.endsWith("/") ||
- _endsWith(fplike, "index") ||
- _endsWith(fplike, "index.md") ||
- _endsWith(fplike, "index.html")
+ endsWith(fplike, "index") ||
+ endsWith(fplike, "index.md") ||
+ endsWith(fplike, "index.html")
)
}
-function _endsWith(s: string, suffix: string): boolean {
+export function endsWith(s: string, suffix: string): boolean {
return s === suffix || s.endsWith("/" + suffix)
}
-function _trimSuffix(s: string, suffix: string): string {
- if (_endsWith(s, suffix)) {
+function trimSuffix(s: string, suffix: string): string {
+ if (endsWith(s, suffix)) {
s = s.slice(0, -suffix.length)
}
return s
}
-function _containsForbiddenCharacters(s: string): boolean {
- return s.includes(" ") || s.includes("#") || s.includes("?")
+function containsForbiddenCharacters(s: string): boolean {
+ return s.includes(" ") || s.includes("#") || s.includes("?") || s.includes("&")
}
function _hasFileExtension(s: string): boolean {
@@ -255,11 +263,11 @@ function _getFileExtension(s: string): string | undefined {
return s.match(/\.[A-Za-z0-9]+$/)?.[0]
}
-function _isRelativeSegment(s: string): boolean {
+function isRelativeSegment(s: string): boolean {
return /^\.{0,2}$/.test(s)
}
-export function _stripSlashes(s: string, onlyStripPrefix?: boolean): string {
+export function stripSlashes(s: string, onlyStripPrefix?: boolean): string {
if (s.startsWith("/")) {
s = s.substring(1)
}
diff --git a/quartz/util/theme.ts b/quartz/util/theme.ts
index 47951c4f..bd0da5fb 100644
--- a/quartz/util/theme.ts
+++ b/quartz/util/theme.ts
@@ -15,6 +15,7 @@ export interface Theme {
body: string
code: string
}
+ cdnCaching: boolean
colors: {
lightMode: ColorScheme
darkMode: ColorScheme
diff --git a/tsconfig.json b/tsconfig.json
index 784ab231..306204b5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -13,8 +13,8 @@
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"jsx": "react-jsx",
- "jsxImportSource": "preact"
+ "jsxImportSource": "preact",
},
"include": ["**/*.ts", "**/*.tsx", "./package.json"],
- "exclude": ["build/**/*.d.ts"]
+ "exclude": ["build/**/*.d.ts"],
}