{tags.map((tag) => {
const pages = tagItemMap.get(tag)!
@@ -54,15 +58,24 @@ 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 })}
+
+ >
+ )}
+
+
+
)
})}
@@ -77,11 +90,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 305f511fd..0c1854448 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
@@ -18,12 +19,13 @@ interface RenderComponents {
footer: QuartzComponent
}
+const headerRegex = new RegExp(/h[1-6]/)
export function pageResources(
baseDir: FullSlug | RelativeURL,
staticResources: StaticResources,
): StaticResources {
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
- const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())`
+ const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
return {
css: [joinSegments(baseDir, "index.css"), ...staticResources.css],
@@ -50,37 +52,30 @@ 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 transcludeTarget = inner.properties["data-slug"] as FullSlug
+ const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
if (!page) {
return
}
- let blockRef = node.properties?.dataBlock as string | undefined
+ let blockRef = node.properties.dataBlock as string | undefined
if (blockRef?.startsWith("#^")) {
// block transclude
blockRef = blockRef.slice("#^".length)
@@ -90,6 +85,7 @@ export function renderPage(
blockNode = {
type: "element",
tagName: "ul",
+ properties: {},
children: [blockNode],
}
}
@@ -99,8 +95,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 },
+ ],
},
]
}
@@ -108,18 +106,23 @@ export function renderPage(
// header transclude
blockRef = blockRef.slice(1)
let startIdx = undefined
+ let startDepth = undefined
let endIdx = undefined
for (const [i, el] of page.htmlAst.children.entries()) {
- if (el.type === "element" && el.tagName.match(/h[1-6]/)) {
- if (endIdx) {
- break
- }
+ // skip non-headers
+ if (!(el.type === "element" && el.tagName.match(headerRegex))) continue
+ const depth = Number(el.tagName.substring(1))
- if (startIdx !== undefined) {
- endIdx = i
- } else if (el.properties?.id === blockRef) {
+ // lookin for our blockref
+ if (startIdx === undefined || startDepth === undefined) {
+ // skip until we find the blockref that matches
+ if (el.properties?.id === blockRef) {
startIdx = i
+ startDepth = Number(el.tagName.substring(1))
}
+ } else if (depth <= startDepth) {
+ // looking for new header that is same level or higher
+ endIdx = i
}
}
@@ -134,8 +137,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) {
@@ -144,8 +149,16 @@ export function renderPage(
{
type: "element",
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) =>
@@ -154,8 +167,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 },
+ ],
},
]
}
@@ -163,6 +178,9 @@ export function renderPage(
}
})
+ // set componentData.tree to the edited html that has transclusions rendered
+ componentData.tree = root
+
const {
head: Head,
header,
@@ -191,8 +209,9 @@ export function renderPage(
)
+ const lang = componentData.fileData.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 d8cf5180a..8f63df36f 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 000000000..50ab0425a
--- /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 c604c9bc5..87182a154 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 c42a367c9..48e0aa1f5 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 72404ed23..3eb25ead4 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"
+ 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
- // 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
+ 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")
+ 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))
+ }
+ }
+
+ explorer.addEventListener("click", toggleExplorer)
+ window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
+
+ // Set up click handlers for each folder (click handler on folder "icon")
+ for (const item of document.getElementsByClassName(
+ "folder-icon",
+ ) as HTMLCollectionOf) {
+ item.addEventListener("click", toggleFolder)
+ window.addCleanup(() => item.removeEventListener("click", toggleFolder))
+ }
// Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
-
- // Convert to bool
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 })
+ }
- 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)
- },
- )
+ 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)
}
-
- // Add click handler to main explorer
- explorer.removeEventListener("click", toggleExplorer)
- explorer.addEventListener("click", toggleExplorer)
- }
-
- // Set up click handlers for each folder (click handler on folder "icon")
- Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) {
- item.removeEventListener("click", toggleFolder)
- item.addEventListener("click", toggleFolder)
})
-
- if (storageTree && useSavedFolderState) {
- // Get state from localStorage and set folder state
- explorerState = JSON.parse(storageTree)
- explorerState.map((folderUl) => {
- // grab element for matching folder path
- const folderLi = document.querySelector(
- `[data-folderpath='/${folderUl.path}']`,
- ) as HTMLElement
-
- // Get corresponding content tag and set state
- if (folderLi) {
- const folderUL = folderLi.parentElement?.nextElementSibling
- if (folderUL) {
- setFolderState(folderUL as HTMLElement, folderUl.collapsed)
- }
- }
- })
- } else if (explorer?.dataset.tree) {
- // If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset
- explorerState = JSON.parse(explorer.dataset.tree)
- }
}
window.addEventListener("resize", setupExplorer)
document.addEventListener("nav", () => {
setupExplorer()
-
observer.disconnect()
// select pseudo element at end of list
@@ -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 bddcfa4c6..1c9bb5d64 100644
--- a/quartz/components/scripts/graph.inline.ts
+++ b/quartz/components/scripts/graph.inline.ts
@@ -44,6 +44,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
opacityScale,
removeTags,
showTags,
+ focusOnHover,
} = JSON.parse(graph.dataset["cfg"]!)
const data: Map = new Map(
@@ -189,6 +190,8 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
return 2 + Math.sqrt(numLinks)
}
+ let connectedNodes: SimpleSlug[] = []
+
// draw individual nodes
const node = graphNode
.append("circle")
@@ -202,17 +205,25 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
window.spaNavigate(new URL(targ, window.location.toString()))
})
.on("mouseover", function (_, d) {
- const neighbours: SimpleSlug[] = data.get(slug)?.links ?? []
- const neighbourNodes = d3
- .selectAll(".node")
- .filter((d) => neighbours.includes(d.id))
const currentId = d.id
const linkNodes = d3
.selectAll(".link")
.filter((d: any) => d.source.id === currentId || d.target.id === currentId)
- // highlight neighbour nodes
- neighbourNodes.transition().duration(200).attr("fill", color)
+ if (focusOnHover) {
+ // fade out non-neighbour nodes
+ connectedNodes = linkNodes.data().flatMap((d: any) => [d.source.id, d.target.id])
+
+ d3.selectAll(".link")
+ .transition()
+ .duration(200)
+ .style("opacity", 0.2)
+ d3.selectAll(".node")
+ .filter((d) => !connectedNodes.includes(d.id))
+ .transition()
+ .duration(200)
+ .style("opacity", 0.2)
+ }
// highlight links
linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1)
@@ -231,6 +242,10 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
.style("font-size", bigFont + "em")
})
.on("mouseleave", function (_, d) {
+ if (focusOnHover) {
+ d3.selectAll(".link").transition().duration(200).style("opacity", 1)
+ d3.selectAll(".node").transition().duration(200).style("opacity", 1)
+ }
const currentId = d.id
const linkNodes = d3
.selectAll(".link")
@@ -319,12 +334,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 704f5d5fe..000000000
--- 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 4d51e2a6f..972d3c638 100644
--- a/quartz/components/scripts/popover.inline.ts
+++ b/quartz/components/scripts/popover.inline.ts
@@ -37,29 +37,55 @@ 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")!.split(";")
+ const [contentTypeCategory, typeInfo] = contentType.split("/")
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 = contentType ?? undefined
+
+ switch (contentTypeCategory) {
+ case "image":
+ const img = document.createElement("img")
+ img.src = targetUrl.toString()
+ img.alt = targetUrl.pathname
+
+ popoverInner.appendChild(img)
+ break
+ case "application":
+ switch (typeInfo) {
+ case "pdf":
+ const pdf = document.createElement("iframe")
+ pdf.src = targetUrl.toString()
+ popoverInner.appendChild(pdf)
+ break
+ default:
+ break
+ }
+ 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 +102,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 eff4eb1b9..a75f4ff46 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()
+ if (!anchor || anchor?.classList.contains("no-match")) return
+ await displayPreview(anchor)
+ anchor.click()
}
- } else if (e.key === "ArrowDown") {
- e.preventDefault()
- // When first pressing ArrowDown, results wont contain the active element, so focus first element
- if (!results?.contains(document.activeElement)) {
- const firstResult = resultCards[0] as HTMLInputElement | null
- firstResult?.focus()
- } else {
- // If an element in results-container already has focus, focus next one
- const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null
- nextResult?.focus()
- }
- } else if (e.key === "ArrowUp") {
+ } 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 = `
- No results.
- Try another search term?
- `
+ results.innerHTML = `
+ No results.
+ Try another search term?
+ `
} else {
results.append(...finalResults.map(resultToHTML))
}
+
+ if (finalResults.length === 0 && preview) {
+ // no results, clear previous preview
+ removeAllChildren(preview)
+ } else {
+ // 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)
+ }
+ }
+
+ 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"]) {
- let term = (e.target as HTMLInputElement).value
- let searchResults: SimpleDocumentSearchResultSetUnit[]
+ if (!searchLayout || !index) return
+ currentSearchTerm = (e.target as HTMLInputElement).value
+ searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
+ searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
- if (term.toLowerCase().startsWith("#")) {
- searchType = "tags"
- } else {
- searchType = "basic"
- }
-
- switch (searchType) {
- case "tags": {
- term = term.substring(1)
- searchResults =
- (await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ??
- []
- break
- }
- case "basic":
- default: {
- searchResults =
- (await index?.searchAsync({
- query: term,
- limit: numSearchResults,
- index: ["title", "content"],
- })) ?? []
- }
+ 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 c2a44c9a8..1790bcabc 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 f3da52cd5..546859ed3 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 5fcabadc1..4ffff29e2 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/clipboard.scss b/quartz/components/styles/clipboard.scss
index a585c7b52..196b8945c 100644
--- a/quartz/components/styles/clipboard.scss
+++ b/quartz/components/styles/clipboard.scss
@@ -4,7 +4,7 @@
float: right;
right: 0;
padding: 0.4rem;
- margin: -0.2rem 0.3rem;
+ margin: 0.3rem;
color: var(--gray);
border-color: var(--dark);
background-color: var(--light);
diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss
index 28e9f9bb2..55ea8aa88 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 fae0e121b..b1694f97c 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,28 @@
white-space: normal;
}
+ & > .popover-inner[data-content-type] {
+ &[data-content-type*="pdf"],
+ &[data-content-type*="image"] {
+ padding: 0;
+ max-height: 100%;
+ }
+
+ &[data-content-type*="image"] {
+ img {
+ margin: 0;
+ border-radius: 0;
+ display: block;
+ }
+ }
+
+ &[data-content-type*="pdf"] {
+ iframe {
+ width: 100%;
+ }
+ }
+ }
+
h1 {
font-size: 1.5rem;
}
diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss
index 66f809f97..8a9ec6714 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
+ &.display-results {
+ display: flex;
+ }
+
+ &[data-preview] > #results-container {
+ flex: 0 0 min(30%, 450px);
+ }
+
+ @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;
+ }
+ }
+ }
+ }
+
+ & > div {
+ height: calc(75vh - 12vh);
+ border-radius: 5px;
+ }
+
+ @media all and (max-width: $tabletBreakpoint) {
+ & > #preview-container {
+ display: none !important;
+ }
+
+ &[data-preview] > #results-container {
+ width: 100%;
+ height: auto;
+ flex: 0 0 100%;
+ }
+ }
+
+ & .highlight {
+ background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
+ border-radius: 5px;
+ scroll-margin-top: 2rem;
+ }
+
+ & > #preview-container {
+ display: block;
+ overflow: hidden;
font-family: inherit;
- font-size: 100%;
- line-height: 1.15;
- margin: 0;
- text-transform: none;
- text-align: left;
- background: var(--light);
- outline: none;
+ color: var(--dark);
+ line-height: 1.5em;
+ font-weight: $normalWeight;
+ overflow-y: auto;
+ padding: 0 2rem;
- & .highlight {
- color: var(--secondary);
- font-weight: 700;
+ & .preview-inner {
+ margin: 0 auto;
+ width: min($pageWidth, 100%);
}
- &:hover,
- &:focus {
- background: var(--lightgray);
+ a[role="anchor"] {
+ background-color: transparent;
}
+ }
- &:first-of-type {
- border-top-left-radius: 5px;
- border-top-right-radius: 5px;
- }
+ & > #results-container {
+ overflow-y: auto;
- &:last-of-type {
- border-bottom-left-radius: 5px;
- border-bottom-right-radius: 5px;
+ & .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;
- & > h3 {
+ // 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;
- & > ul > li {
- margin: 0;
- display: inline-block;
- white-space: nowrap;
- margin: 0;
- overflow-wrap: normal;
- }
+ &:hover,
+ &:focus,
+ &.focus {
+ background: var(--lightgray);
+ }
- & > 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;
- }
+ & > h3 {
+ margin: 0;
+ }
- & > ul > li > p {
- border-radius: 8px;
- background-color: var(--highlight);
- overflow: hidden;
- background-clip: border-box;
- padding: 0.03rem 0.4rem;
- margin: 0;
- color: var(--secondary);
- opacity: 0.85;
- }
+ & > ul.tags {
+ margin-top: 0.45rem;
+ margin-bottom: 0;
+ }
- & > ul > li > .match-tag {
- color: var(--tertiary);
- font-weight: bold;
- opacity: 1;
- }
+ & > 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);
- & > p {
- margin-bottom: 0;
+ &.match-tag {
+ color: var(--tertiary);
+ }
+ }
+
+ & > p {
+ margin-bottom: 0;
+ }
}
}
}
diff --git a/quartz/components/types.ts b/quartz/components/types.ts
index fd9574f56..a6b90d3b2 100644
--- a/quartz/components/types.ts
+++ b/quartz/components/types.ts
@@ -3,13 +3,15 @@ import { StaticResources } from "../util/resources"
import { QuartzPluginData } from "../plugins/vfile"
import { GlobalConfiguration } from "../cfg"
import { Node } from "hast"
+import { BuildCtx } from "../util/ctx"
export type QuartzComponentProps = {
+ ctx: BuildCtx
externalResources: StaticResources
fileData: QuartzPluginData
cfg: GlobalConfiguration
children: (QuartzComponent | JSX.Element)[]
- tree: Node
+ tree: Node
allFiles: QuartzPluginData[]
displayClass?: "mobile-only" | "desktop-only"
} & JSX.IntrinsicAttributes & {
diff --git a/quartz/depgraph.test.ts b/quartz/depgraph.test.ts
new file mode 100644
index 000000000..062f13e35
--- /dev/null
+++ b/quartz/depgraph.test.ts
@@ -0,0 +1,118 @@
+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("mergeGraph", () => {
+ test("merges two graphs", () => {
+ const graph = new DepGraph()
+ graph.addEdge("A.md", "A.html")
+
+ const other = new DepGraph()
+ other.addEdge("B.md", "B.html")
+
+ graph.mergeGraph(other)
+
+ const expected = {
+ nodes: ["A.md", "A.html", "B.md", "B.html"],
+ edges: [
+ ["A.md", "A.html"],
+ ["B.md", "B.html"],
+ ],
+ }
+
+ assert.deepStrictEqual(graph.export(), expected)
+ })
+ })
+
+ 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 000000000..3d048cd83
--- /dev/null
+++ b/quartz/depgraph.ts
@@ -0,0 +1,228 @@
+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() })
+ }
+ }
+
+ // Remove node and all edges connected to it
+ removeNode(node: T): void {
+ if (this._graph.has(node)) {
+ // first remove all edges so other nodes don't have references to this node
+ for (const target of this._graph.get(node)!.outgoing) {
+ this.removeEdge(node, target)
+ }
+ for (const source of this._graph.get(node)!.incoming) {
+ this.removeEdge(source, node)
+ }
+ this._graph.delete(node)
+ }
+ }
+
+ forEachNode(callback: (node: T) => void): void {
+ for (const node of this._graph.keys()) {
+ callback(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
+
+ // Add all nodes and edges from other graph to this graph
+ mergeGraph(other: DepGraph): void {
+ other.forEachEdge(([source, target]) => {
+ this.addNode(source)
+ this.addNode(target)
+ this.addEdge(source, target)
+ })
+ }
+
+ // 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)
+ }
+ })
+ }
+
+ // Remove all nodes that do not have any incoming or outgoing edges
+ // A node may be orphaned if the only node pointing to it was removed
+ removeOrphanNodes(): Set {
+ let orphanNodes = new Set()
+
+ this.forEachNode((node) => {
+ if (this.inDegree(node) === 0 && this.outDegree(node) === 0) {
+ orphanNodes.add(node)
+ }
+ })
+
+ orphanNodes.forEach((node) => {
+ this.removeNode(node)
+ })
+
+ return orphanNodes
+ }
+
+ // 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 000000000..b97368d96
--- /dev/null
+++ b/quartz/i18n/index.ts
@@ -0,0 +1,58 @@
+import { Translation, CalloutTranslation } from "./locales/definition"
+import en from "./locales/en-US"
+import fr from "./locales/fr-FR"
+import it from "./locales/it-IT"
+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"
+import zh from "./locales/zh-CN"
+import vi from "./locales/vi-VN"
+
+export const TRANSLATIONS = {
+ "en-US": en,
+ "fr-FR": fr,
+ "it-IT": it,
+ "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,
+ "zh-CN": zh,
+ "vi-VN": vi,
+} 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 000000000..f7048103f
--- /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 000000000..64c9ba9df
--- /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 000000000..1d5d3dda6
--- /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 000000000..ac283fdaf
--- /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 000000000..37a2a79c7
--- /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 000000000..b485d2b6e
--- /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/it-IT.ts b/quartz/i18n/locales/it-IT.ts
new file mode 100644
index 000000000..ca8818a65
--- /dev/null
+++ b/quartz/i18n/locales/it-IT.ts
@@ -0,0 +1,83 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "Senza titolo",
+ description: "Nessuna descrizione",
+ },
+ components: {
+ callout: {
+ note: "Nota",
+ abstract: "Astratto",
+ info: "Info",
+ todo: "Da fare",
+ tip: "Consiglio",
+ success: "Completato",
+ question: "Domanda",
+ warning: "Attenzione",
+ failure: "Errore",
+ danger: "Pericolo",
+ bug: "Bug",
+ example: "Esempio",
+ quote: "Citazione",
+ },
+ backlinks: {
+ title: "Link entranti",
+ noBacklinksFound: "Nessun link entrante",
+ },
+ themeToggle: {
+ lightMode: "Tema chiaro",
+ darkMode: "Tema scuro",
+ },
+ explorer: {
+ title: "Esplora",
+ },
+ footer: {
+ createdWith: "Creato con",
+ },
+ graph: {
+ title: "Vista grafico",
+ },
+ recentNotes: {
+ title: "Note recenti",
+ seeRemainingMore: ({ remaining }) => `Vedi ${remaining} altro →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `Transclusione di ${targetSlug}`,
+ linkToOriginal: "Link all'originale",
+ },
+ search: {
+ title: "Cerca",
+ searchBarPlaceholder: "Cerca qualcosa",
+ },
+ tableOfContents: {
+ title: "Tabella dei contenuti",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) => `${minutes} minuti`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "Note recenti",
+ lastFewNotes: ({ count }) => `Ultime ${count} note`,
+ },
+ error: {
+ title: "Non trovato",
+ notFound: "Questa pagina è privata o non esiste.",
+ },
+ folderContent: {
+ folder: "Cartella",
+ itemsUnderFolder: ({ count }) =>
+ count === 1 ? "1 oggetto in questa cartella." : `${count} oggetti in questa cartella.`,
+ },
+ tagContent: {
+ tag: "Etichetta",
+ tagIndex: "Indice etichette",
+ itemsUnderTag: ({ count }) =>
+ count === 1 ? "1 oggetto con questa etichetta." : `${count} oggetti con questa etichetta.`,
+ showingFirst: ({ count }) => `Prime ${count} etichette.`,
+ totalTags: ({ count }) => `Trovate ${count} etichette totali.`,
+ },
+ },
+} 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 000000000..d429db411
--- /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 000000000..ea735b00c
--- /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 000000000..d075d584a
--- /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 000000000..556b18995
--- /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 000000000..8ead3cabe
--- /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 000000000..b63693837
--- /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/i18n/locales/vi-VN.ts b/quartz/i18n/locales/vi-VN.ts
new file mode 100644
index 000000000..b72ced4ac
--- /dev/null
+++ b/quartz/i18n/locales/vi-VN.ts
@@ -0,0 +1,83 @@
+import { Translation } from "./definition"
+
+export default {
+ propertyDefaults: {
+ title: "Không có tiêu đề",
+ description: "Không có mô tả được cung cấp",
+ },
+ components: {
+ callout: {
+ note: "Ghi Chú",
+ abstract: "Tóm Tắt",
+ info: "Thông tin",
+ todo: "Cần Làm",
+ tip: "Gợi Ý",
+ success: "Thành Công",
+ question: "Nghi Vấn",
+ warning: "Cảnh Báo",
+ failure: "Thất Bại",
+ danger: "Nguy Hiểm",
+ bug: "Lỗi",
+ example: "Ví Dụ",
+ quote: "Trích Dẫn",
+ },
+ backlinks: {
+ title: "Liên Kết Ngược",
+ noBacklinksFound: "Không có liên kết ngược được tìm thấy",
+ },
+ themeToggle: {
+ lightMode: "Sáng",
+ darkMode: "Tối",
+ },
+ explorer: {
+ title: "Trong bài này",
+ },
+ footer: {
+ createdWith: "Được tạo bởi",
+ },
+ graph: {
+ title: "Biểu Đồ",
+ },
+ recentNotes: {
+ title: "Bài viết gần đây",
+ seeRemainingMore: ({ remaining }) => `Xem ${remaining} thêm →`,
+ },
+ transcludes: {
+ transcludeOf: ({ targetSlug }) => `Bao gồm ${targetSlug}`,
+ linkToOriginal: "Liên Kết Gốc",
+ },
+ search: {
+ title: "Tìm Kiếm",
+ searchBarPlaceholder: "Tìm kiếm thông tin",
+ },
+ tableOfContents: {
+ title: "Bảng Nội Dung",
+ },
+ contentMeta: {
+ readingTime: ({ minutes }) => `đọc ${minutes} phút`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "Những bài gần đây",
+ lastFewNotes: ({ count }) => `${count} Bài gần đây`,
+ },
+ error: {
+ title: "Không Tìm Thấy",
+ notFound: "Trang này được bảo mật hoặc không tồn tại.",
+ },
+ folderContent: {
+ folder: "Thư Mục",
+ itemsUnderFolder: ({ count }) =>
+ count === 1 ? "1 mục trong thư mục này." : `${count} mục trong thư mục này.`,
+ },
+ tagContent: {
+ tag: "Thẻ",
+ tagIndex: "Thẻ Mục Lục",
+ itemsUnderTag: ({ count }) =>
+ count === 1 ? "1 mục gắn thẻ này." : `${count} mục gắn thẻ này.`,
+ showingFirst: ({ count }) => `Hiển thị trước ${count} thẻ.`,
+ totalTags: ({ count }) => `Tìm thấy ${count} thẻ tổng cộng.`,
+ },
+ },
+} as const satisfies Translation
diff --git a/quartz/i18n/locales/zh-CN.ts b/quartz/i18n/locales/zh-CN.ts
new file mode 100644
index 000000000..43d011197
--- /dev/null
+++ b/quartz/i18n/locales/zh-CN.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}分钟阅读`,
+ },
+ },
+ pages: {
+ rss: {
+ recentNotes: "最近的笔记",
+ lastFewNotes: ({ count }) => `最近的${count}条笔记`,
+ },
+ error: {
+ title: "无法找到",
+ 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/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx
index cd079a065..e4605cfcd 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,20 +28,25 @@ 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 = {
+ ctx,
fileData: vfile.data,
externalResources,
cfg,
@@ -48,8 +56,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 210715eb4..af3578ebe 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 {
+ async getDependencyGraph(ctx, content, _resources) {
+ const graph = new DepGraph()
+
+ const { argv } = ctx
+ for (const [_tree, file] of content) {
+ 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)
+ }
+
+ 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!))
-
- let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
- if (typeof aliases === "string") {
- aliases = [aliases]
- }
-
+ 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 edc22d9e9..036b27da4 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 ffe2c6d12..cbed2a8b4 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 a27f1f977..3c3b44f6a 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(
@@ -85,24 +93,37 @@ function addGlobalPageResources(
componentResources.afterDOMLoaded.push(`
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
- gtag(\`js\`, new Date());
- gtag(\`config\`, \`${tagId}\`, { send_page_view: false });
-
- document.addEventListener(\`nav\`, () => {
- gtag(\`event\`, \`page_view\`, {
+ gtag("js", new Date());
+ gtag("config", "${tagId}", { send_page_view: false });
+
+ document.addEventListener("nav", () => {
+ gtag("event", "page_view", {
page_title: document.title,
page_location: location.href,
});
});`)
} else if (cfg.analytics?.provider === "plausible") {
- componentResources.afterDOMLoaded.push(plausibleScript)
+ const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
+ componentResources.afterDOMLoaded.push(`
+ const plausibleScript = document.createElement("script")
+ plausibleScript.src = "${plausibleHost}/js/script.manual.js"
+ plausibleScript.setAttribute("data-domain", location.hostname)
+ plausibleScript.defer = true
+ document.head.appendChild(plausibleScript)
+
+ window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
+
+ document.addEventListener("nav", () => {
+ plausible("pageview")
+ })
+ `)
} else if (cfg.analytics?.provider === "umami") {
componentResources.afterDOMLoaded.push(`
const umamiScript = document.createElement("script")
- umamiScript.src = "https://analytics.umami.is/script.js"
+ umamiScript.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)
`)
} else if (cfg.analytics?.provider === "postHog") {
@@ -112,15 +133,26 @@ function addGlobalPageResources(
posthog.init('phc_gQ0zjg2pQPUEqhpVagoE84JhGIQSkttyv9ki1unG3Mh',{api_host:'https://app.posthog.com'})
`
)
+ } else if (cfg.analytics?.provider === "goatcounter") {
+ componentResources.afterDOMLoaded.push(`
+ const goatcounterScript = document.createElement("script")
+ goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}"
+ goatcounterScript.async = true
+ goatcounterScript.setAttribute("data-goatcounter",
+ "https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count")
+ document.head.appendChild(goatcounterScript)
+ `)
}
if (cfg.enableSPA) {
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}`
@@ -135,7 +167,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))
`,
})
}
@@ -156,26 +189,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)
+ 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
-
- if (fontOrigin === "googleFonts") {
- resources.css.push(googleFontHref(ctx.cfg.configuration.theme))
- } else if (fontOrigin === "local") {
- // let the user do it themselves in css
- }
-
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({
@@ -192,18 +294,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 c5170c64a..c0fef86d2 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