diff --git a/docs/features/Obsidian compatibility.md b/docs/features/Obsidian compatibility.md index d519e1779..1254370e0 100644 --- a/docs/features/Obsidian compatibility.md +++ b/docs/features/Obsidian compatibility.md @@ -25,6 +25,7 @@ Finally, Quartz also provides `Plugin.CrawlLinks` which allows you to customize - `callouts`: whether to enable [[callouts]]. Defaults to `true` - `mermaid`: whether to enable [[Mermaid diagrams]]. Defaults to `true` - `parseTags`: whether to try and parse tags in the content body. Defaults to `true` + - `parseArrows`: whether to try and parse arrows in the content body. Defaults to `true`. - `enableInHtmlEmbed`: whether to try and parse Obsidian flavoured markdown in raw HTML. Defaults to `false` - `enableYouTubeEmbed`: whether to enable embedded YouTube videos using external image Markdown syntax. Defaults to `false` - Link resolution behaviour: diff --git a/docs/features/darkmode.md b/docs/features/darkmode.md index dfa231409..dff75b44d 100644 --- a/docs/features/darkmode.md +++ b/docs/features/darkmode.md @@ -12,3 +12,12 @@ Quartz supports darkmode out of the box that respects the user's theme preferenc - Component: `quartz/components/Darkmode.tsx` - Style: `quartz/components/styles/darkmode.scss` - Script: `quartz/components/scripts/darkmode.inline.ts` + +You can also listen to the `themechange` event to perform any custom logic when the theme changes. + +```js +document.addEventListener("themechange", (e) => { + console.log("Theme changed to " + e.detail.theme) // either "light" or "dark" + // your logic here +}) +``` diff --git a/docs/index.md b/docs/index.md index 655f5c39b..7804ac6ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,9 +25,6 @@ This will guide you through initializing your Quartz with content. Once you've d 4. [[build|Build and preview]] Quartz 5. [[hosting|Host]] Quartz online -> [!info] -> Coming from Quartz 3? See the [[migrating from Quartz 3|migration guide]] for the differences between Quartz 3 and Quartz 4 and how to migrate. - ## 🔧 Features - [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], and [many more](./features) right out of the box diff --git a/index.d.ts b/index.d.ts index aec536d25..a6c594fff 100644 --- a/index.d.ts +++ b/index.d.ts @@ -6,6 +6,7 @@ declare module "*.scss" { // dom custom event interface CustomEventMap { nav: CustomEvent<{ url: FullSlug }> + themechange: CustomEvent<{ theme: "light" | "dark" }> } declare const fetchData: Promise diff --git a/package-lock.json b/package-lock.json index 73531b4b9..7e135f1a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackyzha0/quartz", - "version": "4.1.4", + "version": "4.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackyzha0/quartz", - "version": "4.1.4", + "version": "4.1.5", "license": "MIT", "dependencies": { "@clack/prompts": "^0.7.0", diff --git a/package.json b/package.json index bb79280d8..ea7629241 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@jackyzha0/quartz", "description": "🌱 publish your digital garden and notes as a website", "private": true, - "version": "4.1.4", + "version": "4.1.5", "type": "module", "author": "jackyzha0 ", "license": "MIT", diff --git a/quartz/build.ts b/quartz/build.ts index 24f049882..b78ff2bc6 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -3,13 +3,13 @@ sourceMapSupport.install(options) import path from "path" import { PerfTimer } from "./util/perf" import { rimraf } from "rimraf" -import { isGitIgnored } from "globby" +import { GlobbyFilterFunction, isGitIgnored } from "globby" import chalk from "chalk" import { parseMarkdown } from "./processors/parse" import { filterContent } from "./processors/filter" import { emitContent } from "./processors/emit" import cfg from "../quartz.config" -import { FilePath, joinSegments, slugifyFilePath } from "./util/path" +import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path" import chokidar from "chokidar" import { ProcessedContent } from "./plugins/vfile" import { Argv, BuildCtx } from "./util/ctx" @@ -18,6 +18,19 @@ import { trace } from "./util/trace" import { options } from "./util/sourcemap" import { Mutex } from "async-mutex" +type BuildData = { + ctx: BuildCtx + ignored: GlobbyFilterFunction + mut: Mutex + initialSlugs: FullSlug[] + // TODO merge contentMap and trackedAssets + contentMap: Map + trackedAssets: Set + toRebuild: Set + toRemove: Set + lastBuildMs: number +} + async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { const ctx: BuildCtx = { argv, @@ -73,92 +86,22 @@ async function startServing( ) { const { argv } = ctx - const ignored = await isGitIgnored() const contentMap = new Map() for (const content of initialContent) { const [_tree, vfile] = content contentMap.set(vfile.data.filePath!, content) } - const initialSlugs = ctx.allSlugs - let lastBuildMs = 0 - const toRebuild: Set = new Set() - const toRemove: Set = new Set() - const trackedAssets: Set = new Set() - async function rebuild(fp: string, action: "add" | "change" | "delete") { - // don't do anything for gitignored files - if (ignored(fp)) { - return - } - - // dont bother rebuilding for non-content files, just track and refresh - fp = toPosixPath(fp) - const filePath = joinSegments(argv.directory, fp) as FilePath - if (path.extname(fp) !== ".md") { - if (action === "add" || action === "change") { - trackedAssets.add(filePath) - } else if (action === "delete") { - trackedAssets.delete(filePath) - } - clientRefresh() - return - } - - if (action === "add" || action === "change") { - toRebuild.add(filePath) - } else if (action === "delete") { - toRemove.add(filePath) - } - - // debounce rebuilds every 250ms - - const buildStart = new Date().getTime() - lastBuildMs = buildStart - const release = await mut.acquire() - if (lastBuildMs > buildStart) { - release() - return - } - - const perf = new PerfTimer() - console.log(chalk.yellow("Detected change, rebuilding...")) - try { - const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp)) - - const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])] - .filter((fp) => !toRemove.has(fp)) - .map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath)) - - ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])] - const parsedContent = await parseMarkdown(ctx, filesToRebuild) - for (const content of parsedContent) { - const [_tree, vfile] = content - contentMap.set(vfile.data.filePath!, content) - } - - for (const fp of toRemove) { - contentMap.delete(fp) - } - - const parsedFiles = [...contentMap.values()] - const filteredContent = filterContent(ctx, parsedFiles) - - // TODO: we can probably traverse the link graph to figure out what's safe to delete here - // instead of just deleting everything - await rimraf(argv.output) - await emitContent(ctx, filteredContent) - console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`)) - } catch (err) { - console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`)) - if (argv.verbose) { - console.log(chalk.red(err)) - } - } - - release() - clientRefresh() - toRebuild.clear() - toRemove.clear() + const buildData: BuildData = { + ctx, + mut, + contentMap, + ignored: await isGitIgnored(), + initialSlugs: ctx.allSlugs, + toRebuild: new Set(), + toRemove: new Set(), + trackedAssets: new Set(), + lastBuildMs: 0, } const watcher = chokidar.watch(".", { @@ -168,15 +111,110 @@ async function startServing( }) watcher - .on("add", (fp) => rebuild(fp, "add")) - .on("change", (fp) => rebuild(fp, "change")) - .on("unlink", (fp) => rebuild(fp, "delete")) + .on("add", (fp) => rebuildFromEntrypoint(fp, "add", clientRefresh, buildData)) + .on("change", (fp) => rebuildFromEntrypoint(fp, "change", clientRefresh, buildData)) + .on("unlink", (fp) => rebuildFromEntrypoint(fp, "delete", clientRefresh, buildData)) return async () => { await watcher.close() } } +async function rebuildFromEntrypoint( + fp: string, + action: "add" | "change" | "delete", + clientRefresh: () => void, + buildData: BuildData, // note: this function mutates buildData +) { + const { + ctx, + ignored, + mut, + initialSlugs, + contentMap, + toRebuild, + toRemove, + trackedAssets, + lastBuildMs, + } = buildData + + const { argv } = ctx + + // don't do anything for gitignored files + if (ignored(fp)) { + return + } + + // dont bother rebuilding for non-content files, just track and refresh + fp = toPosixPath(fp) + const filePath = joinSegments(argv.directory, fp) as FilePath + if (path.extname(fp) !== ".md") { + if (action === "add" || action === "change") { + trackedAssets.add(filePath) + } else if (action === "delete") { + trackedAssets.delete(filePath) + } + clientRefresh() + return + } + + if (action === "add" || action === "change") { + toRebuild.add(filePath) + } else if (action === "delete") { + toRemove.add(filePath) + } + + // debounce rebuilds every 250ms + + const buildStart = new Date().getTime() + buildData.lastBuildMs = buildStart + const release = await mut.acquire() + if (lastBuildMs > buildStart) { + release() + return + } + + const perf = new PerfTimer() + console.log(chalk.yellow("Detected change, rebuilding...")) + try { + const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp)) + + const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])] + .filter((fp) => !toRemove.has(fp)) + .map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath)) + + ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])] + const parsedContent = await parseMarkdown(ctx, filesToRebuild) + for (const content of parsedContent) { + const [_tree, vfile] = content + contentMap.set(vfile.data.filePath!, content) + } + + for (const fp of toRemove) { + contentMap.delete(fp) + } + + const parsedFiles = [...contentMap.values()] + const filteredContent = filterContent(ctx, parsedFiles) + + // TODO: we can probably traverse the link graph to figure out what's safe to delete here + // instead of just deleting everything + await rimraf(argv.output) + await emitContent(ctx, filteredContent) + console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`)) + } catch (err) { + console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`)) + if (argv.verbose) { + console.log(chalk.red(err)) + } + } + + release() + clientRefresh() + toRebuild.clear() + toRemove.clear() +} + export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => { try { return await buildQuartz(argv, mut, clientRefresh) diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx index 0497b6458..182d9d62c 100644 --- a/quartz/components/Breadcrumbs.tsx +++ b/quartz/components/Breadcrumbs.tsx @@ -69,9 +69,9 @@ export default ((opts?: Partial) => { for (const file of allFiles) { if (file.slug?.endsWith("index")) { const folderParts = file.slug?.split("/") - if (folderParts) { - // 2nd last to exclude the /index - const folderName = folderParts[folderParts?.length - 2] + // 2nd last to exclude the /index + const folderName = folderParts?.at(-2) + if (folderName) { folderIndex.set(folderName, file) } } @@ -104,13 +104,14 @@ export default ((opts?: Partial) => { } // Add current file to crumb (can directly use frontmatter title) - if (options.showCurrentPage && slugParts.at(-1) === "") { + if (options.showCurrentPage && slugParts.at(-1) !== "index") { crumbs.push({ displayName: fileData.frontmatter!.title, path: "", }) } } + return (