From b781cc5bfed9778b907f4f5a16989d738a35f83b Mon Sep 17 00:00:00 2001 From: ThinkWithPbody <51525460+ThinkWithPbody@users.noreply.github.com> Date: Wed, 15 Jan 2025 06:20:57 -0500 Subject: [PATCH] quartz --- globals.d.ts | 4 + quartz.config.ts | 4 +- quartz.layout.ts | 49 +- quartz/bootstrap-worker.mjs | 5 +- quartz/build.ts | 694 +++++------ quartz/cfg.ts | 23 +- quartz/cli/args.js | 202 ++-- quartz/cli/constants.js | 22 +- quartz/cli/handlers.js | 1026 +++++++++-------- quartz/cli/helpers.js | 79 +- quartz/components/ArticleTitle.tsx | 47 +- quartz/components/Backlinks.tsx | 72 +- quartz/components/Comments.tsx | 18 +- quartz/components/ContentMeta.tsx | 10 +- quartz/components/Date.tsx | 2 +- quartz/components/ExplorerNode.tsx | 89 +- quartz/components/Head.tsx | 257 ++++- quartz/components/PageList.tsx | 43 +- quartz/components/PageTitle.tsx | 8 +- quartz/components/TagList.tsx | 1 - quartz/components/index.ts | 2 - quartz/components/pages/Content.tsx | 16 +- quartz/components/pages/FolderContent.tsx | 93 +- quartz/components/pages/TagContent.tsx | 12 +- quartz/components/renderPage.tsx | 42 +- quartz/components/scripts/callout.inline.ts | 66 +- quartz/components/scripts/checkbox.inline.ts | 36 +- quartz/components/scripts/clipboard.inline.ts | 64 +- quartz/components/scripts/comments.inline.ts | 28 +- quartz/components/scripts/darkmode.inline.ts | 33 +- quartz/components/scripts/explorer.inline.ts | 187 ++- quartz/components/scripts/graph.inline.ts | 15 +- quartz/components/scripts/mermaid.inline.ts | 248 ++++ quartz/components/scripts/popover.inline.ts | 172 +-- quartz/components/scripts/search.inline.ts | 861 +++++++------- quartz/components/scripts/spa.inline.ts | 341 +++--- quartz/components/scripts/toc.inline.ts | 60 +- quartz/components/scripts/util.ts | 57 +- quartz/components/styles/backlinks.scss | 26 +- quartz/components/styles/breadcrumbs.scss | 8 +- quartz/components/styles/contentMeta.scss | 2 +- quartz/components/styles/darkmode.scss | 2 - quartz/components/styles/explorer.scss | 107 +- quartz/components/styles/graph.scss | 2 +- quartz/components/styles/listPage.scss | 15 +- quartz/components/styles/mermaid.inline.scss | 163 +++ quartz/components/styles/popover.scss | 15 +- quartz/components/styles/search.scss | 12 +- quartz/components/styles/toc.scss | 31 +- quartz/components/types.ts | 18 +- quartz/depgraph.test.ts | 233 ++-- quartz/depgraph.ts | 452 ++++---- quartz/i18n/index.ts | 10 +- quartz/i18n/locales/ar-SA.ts | 74 +- quartz/i18n/locales/de-DE.ts | 74 +- quartz/i18n/locales/definition.ts | 2 +- quartz/i18n/locales/en-US.ts | 72 +- quartz/i18n/locales/es-ES.ts | 74 +- quartz/i18n/locales/fr-FR.ts | 68 +- quartz/i18n/locales/it-IT.ts | 72 +- quartz/i18n/locales/ja-JP.ts | 70 +- quartz/i18n/locales/ko-KR.ts | 70 +- quartz/i18n/locales/nl-NL.ts | 78 +- quartz/i18n/locales/ro-RO.ts | 74 +- quartz/i18n/locales/ru-RU.ts | 92 +- quartz/i18n/locales/tr-TR.ts | 84 ++ quartz/i18n/locales/uk-UA.ts | 74 +- quartz/i18n/locales/zh-CN.ts | 70 +- quartz/i18n/locales/zh-TW.ts | 82 ++ quartz/plugins/emitters/404.tsx | 2 +- quartz/plugins/emitters/aliases.ts | 121 +- quartz/plugins/emitters/assets.ts | 109 +- quartz/plugins/emitters/cname.ts | 57 +- quartz/plugins/emitters/componentResources.ts | 361 +++--- quartz/plugins/emitters/contentIndex.ts | 277 +++-- quartz/plugins/emitters/contentPage.tsx | 2 +- quartz/plugins/emitters/folderPage.tsx | 24 +- quartz/plugins/emitters/helpers.ts | 21 +- quartz/plugins/emitters/index.ts | 20 +- quartz/plugins/emitters/static.ts | 59 +- quartz/plugins/emitters/tagPage.tsx | 5 +- quartz/plugins/filters/draft.ts | 7 +- quartz/plugins/filters/explicit.ts | 12 +- quartz/plugins/filters/index.ts | 4 +- quartz/plugins/index.ts | 50 +- quartz/plugins/transformers/citations.ts | 4 +- quartz/plugins/transformers/description.ts | 103 +- quartz/plugins/transformers/frontmatter.ts | 117 +- quartz/plugins/transformers/gfm.ts | 17 +- quartz/plugins/transformers/index.ts | 25 +- quartz/plugins/transformers/lastmod.ts | 119 +- quartz/plugins/transformers/latex.ts | 69 +- quartz/plugins/transformers/linebreaks.ts | 19 +- quartz/plugins/transformers/links.ts | 70 +- quartz/plugins/transformers/ofm.ts | 202 +++- quartz/plugins/transformers/oxhugofm.ts | 112 +- quartz/plugins/transformers/syntax.ts | 31 +- quartz/plugins/transformers/toc.ts | 49 +- quartz/plugins/types.ts | 15 +- quartz/plugins/vfile.ts | 16 +- quartz/processors/emit.ts | 52 +- quartz/processors/filter.ts | 36 +- quartz/processors/parse.ts | 325 +++--- quartz/static/giscus/dark.css | 99 ++ quartz/static/giscus/light.css | 99 ++ quartz/static/icons/boxicons/BxsGame.svg | 1 - quartz/static/icons/file.svg | 1 - quartz/static/icons/folder-open.svg | 1 - quartz/static/icons/owl.svg | 10 - .../static/icons/remix-icons/GamepadLine.svg | 1 - quartz/static/icons/rpg-awesome/Knife.svg | 1 - quartz/static/icons/rpg-awesome/WolfHowl.svg | 1 - .../static/icons/tabler-icons/Radioactive.svg | 8 - quartz/static/og-image.png | Bin 5089 -> 39281 bytes quartz/styles/base.scss | 189 +-- quartz/styles/custom/callouts.scss | 147 --- quartz/styles/custom/fileIcons.scss | 11 - quartz/styles/custom/file_icon.scss | 15 - quartz/styles/custom/grid_callouts.scss | 440 ------- quartz/styles/custom/image_grid_float.scss | 235 ---- quartz/styles/variables.scss | 63 +- quartz/util/ctx.ts | 4 +- quartz/util/escape.ts | 23 +- quartz/util/glob.ts | 31 +- quartz/util/lang.ts | 14 +- quartz/util/log.ts | 46 +- quartz/util/og.tsx | 202 ++++ quartz/util/path.test.ts | 532 +++++---- quartz/util/path.ts | 356 +++--- quartz/util/perf.ts | 26 +- quartz/util/resources.tsx | 25 +- quartz/util/sourcemap.ts | 32 +- quartz/util/theme.ts | 12 +- quartz/util/trace.ts | 62 +- quartz/worker.ts | 49 +- 135 files changed, 6597 insertions(+), 5865 deletions(-) create mode 100644 quartz/components/scripts/mermaid.inline.ts create mode 100644 quartz/components/styles/mermaid.inline.scss create mode 100644 quartz/i18n/locales/tr-TR.ts create mode 100644 quartz/i18n/locales/zh-TW.ts create mode 100644 quartz/static/giscus/dark.css create mode 100644 quartz/static/giscus/light.css delete mode 100644 quartz/static/icons/boxicons/BxsGame.svg delete mode 100644 quartz/static/icons/file.svg delete mode 100644 quartz/static/icons/folder-open.svg delete mode 100644 quartz/static/icons/owl.svg delete mode 100644 quartz/static/icons/remix-icons/GamepadLine.svg delete mode 100644 quartz/static/icons/rpg-awesome/Knife.svg delete mode 100644 quartz/static/icons/rpg-awesome/WolfHowl.svg delete mode 100644 quartz/static/icons/tabler-icons/Radioactive.svg delete mode 100644 quartz/styles/custom/callouts.scss delete mode 100644 quartz/styles/custom/fileIcons.scss delete mode 100644 quartz/styles/custom/file_icon.scss delete mode 100644 quartz/styles/custom/grid_callouts.scss delete mode 100644 quartz/styles/custom/image_grid_float.scss create mode 100644 quartz/util/og.tsx diff --git a/globals.d.ts b/globals.d.ts index ee13005c..6cf30f8a 100644 --- a/globals.d.ts +++ b/globals.d.ts @@ -4,6 +4,10 @@ export declare global { type: K, listener: (this: Document, ev: CustomEventMap[K]) => void, ): void + removeEventListener( + type: K, + listener: (this: Document, ev: CustomEventMap[K]) => void, + ): void dispatchEvent(ev: CustomEventMap[K] | UIEvent): void } interface Window { diff --git a/quartz.config.ts b/quartz.config.ts index 285dd1e9..5a3dc530 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -87,7 +87,6 @@ const config: QuartzConfig = { Plugin.CreatedModifiedDate({ priority: ["frontmatter", "filesystem"], }), - Plugin.Latex({ renderEngine: "katex" }), Plugin.SyntaxHighlighting({ theme: { light: "github-light", @@ -95,11 +94,12 @@ const config: QuartzConfig = { }, keepBackground: false, }), - Plugin.ObsidianFlavoredMarkdown({ comments: true }), + Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }), Plugin.GitHubFlavoredMarkdown(), Plugin.TableOfContents(), Plugin.CrawlLinks({ markdownLinkResolution: "relative" }),// relative, absolute, shortest Plugin.Description(), + Plugin.Latex({ renderEngine: "katex" }), Plugin.HardLineBreaks(), ], filters: [Plugin.RemoveDrafts()], diff --git a/quartz.layout.ts b/quartz.layout.ts index 69610fc2..e4143f27 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -11,22 +11,10 @@ const iconsOptions: IconFolderOptions = { }, }; - export const sharedPageComponents: SharedLayout = { head: Component.Head(), - header: [ - Component.MobileOnly( - Component.ExplorerBurger({ - folderDefaultState: "open", - folderClickBehavior: "collapse",// link, collapse - iconSettings: iconsOptions, - }), - ), - Component.MobileOnly(Component.PageTitle()), - Component.MobileOnly(Component.Spacer()), - Component.Search(), - Component.Darkmode(), - ], + header: [], + afterBody: [], footer: Component.Footer({ links: { "Email": "mailto:yifuding.twp@gmail.com", @@ -45,29 +33,30 @@ export const defaultContentPageLayout: PageLayout = { Component.TagList(), ], left: [ - Component.DesktopOnly(Component.PageTitle()), - Component.DesktopOnly( - Component.ExplorerBurger({ - folderClickBehavior: "collapse",// link, collapse - folderDefaultState: "collapsed", - useSavedState: true, - title: "", - iconSettings: iconsOptions, - }), - ), + Component.PageTitle(), + Component.MobileOnly(Component.Spacer()), + Component.Search(), + Component.Darkmode(), + Component.DesktopOnly(Component.Explorer()), ], right: [ - Component.DesktopOnly(Component.Graph()), - Component.TableOfContents(), - Component.DesktopOnly(Component.Backlinks()), + Component.Graph(), + Component.DesktopOnly(Component.TableOfContents()), + Component.Backlinks(), ], } // components for pages that display lists of pages (e.g. tags or folders) export const defaultListPageLayout: PageLayout = { - beforeBody: defaultContentPageLayout.beforeBody, - left: defaultContentPageLayout.left, - right: [], + beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()], + left: [ + Component.PageTitle(), + Component.MobileOnly(Component.Spacer()), + Component.Search(), + Component.Darkmode(), + Component.DesktopOnly(Component.Explorer()), + ], + right: [], } // Graph diff --git a/quartz/bootstrap-worker.mjs b/quartz/bootstrap-worker.mjs index b08689c3..c4c4949b 100644 --- a/quartz/bootstrap-worker.mjs +++ b/quartz/bootstrap-worker.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import workerpool from "workerpool" const cacheFile = "./.quartz-cache/transpiled-worker.mjs" -const { parseFiles } = await import(cacheFile) +const { parseMarkdown, processHtml } = await import(cacheFile) workerpool.worker({ - parseFiles, + parseMarkdown, + processHtml, }) diff --git a/quartz/build.ts b/quartz/build.ts index 970bc929..64c462b1 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -1,26 +1,24 @@ -import { Mutex } from "async-mutex"; -import chalk from "chalk"; -import chokidar from "chokidar"; -import { GlobbyFilterFunction, isGitIgnored } from "globby"; -import path from "path"; -import { rimraf } from "rimraf"; -import sourceMapSupport from "source-map-support"; - -import cfg from "../quartz.config"; -import DepGraph from "./depgraph"; -import { getStaticResourcesFromPlugins } from "./plugins"; -import { ProcessedContent } from "./plugins/vfile"; -import { emitContent } from "./processors/emit"; -import { filterContent } from "./processors/filter"; -import { parseMarkdown } from "./processors/parse"; -import { Argv, BuildCtx } from "./util/ctx"; -import { glob, toPosixPath } from "./util/glob"; -import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path"; -import { PerfTimer } from "./util/perf"; -import { options } from "./util/sourcemap"; -import { trace } from "./util/trace"; - -sourceMapSupport.install(options); +import sourceMapSupport from "source-map-support" +sourceMapSupport.install(options) +import path from "path" +import { PerfTimer } from "./util/perf" +import { rimraf } from "rimraf" +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, FullSlug, joinSegments, slugifyFilePath } from "./util/path" +import chokidar from "chokidar" +import { ProcessedContent } from "./plugins/vfile" +import { Argv, BuildCtx } from "./util/ctx" +import { glob, toPosixPath } from "./util/glob" +import { trace } from "./util/trace" +import { options } from "./util/sourcemap" +import { Mutex } from "async-mutex" +import DepGraph from "./depgraph" +import { getStaticResourcesFromPlugins } from "./plugins" type Dependencies = Record | null> @@ -41,337 +39,339 @@ type BuildData = { type FileEvent = "add" | "change" | "delete" function newBuildId() { - return new Date().toISOString() + return Math.random().toString(36).substring(2, 8) } async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { - const ctx: BuildCtx = { - buildId: newBuildId(), - argv, - cfg, - allSlugs: [], - }; - - const perf = new PerfTimer(); - const output = argv.output; - - const pluginCount = Object.values(cfg.plugins).flat().length; - const pluginNames = (key: "transformers" | "filters" | "emitters") => - cfg.plugins[key].map((plugin) => plugin.name); - if (argv.verbose) { - console.log(`Loaded ${pluginCount} plugins`); - console.log(` Transformers: ${pluginNames("transformers").join(", ")}`); - console.log(` Filters: ${pluginNames("filters").join(", ")}`); - console.log(` Emitters: ${pluginNames("emitters").join(", ")}`); - } - - const release = await mut.acquire(); - perf.addEvent("clean"); - await rimraf(path.join(output, "*"), { glob: true }); - console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`); - - perf.addEvent("glob"); - const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns); - const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort(); - console.log( - `Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, - ); - - const filePaths = fps.map((fp) => joinSegments(argv.directory, fp) as FilePath); - ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath)); - - const parsedFiles = await parseMarkdown(ctx, filePaths); - const filteredContent = filterContent(ctx, parsedFiles); - - const dependencies: Record | null> = {}; - - // Only build dependency graphs if we're doing a fast rebuild - if (argv.fastRebuild) { - const staticResources = getStaticResourcesFromPlugins(ctx); - for (const emitter of cfg.plugins.emitters) { - dependencies[emitter.name] = - (await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null; - } - } - - await emitContent(ctx, filteredContent); - console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`)); - release(); - - if (argv.serve) { - return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies); - } + const ctx: BuildCtx = { + buildId: newBuildId(), + argv, + cfg, + allSlugs: [], + } + + const perf = new PerfTimer() + const output = argv.output + + const pluginCount = Object.values(cfg.plugins).flat().length + const pluginNames = (key: "transformers" | "filters" | "emitters") => + cfg.plugins[key].map((plugin) => plugin.name) + if (argv.verbose) { + console.log(`Loaded ${pluginCount} plugins`) + console.log(` Transformers: ${pluginNames("transformers").join(", ")}`) + console.log(` Filters: ${pluginNames("filters").join(", ")}`) + console.log(` Emitters: ${pluginNames("emitters").join(", ")}`) + } + + const release = await mut.acquire() + perf.addEvent("clean") + await rimraf(path.join(output, "*"), { glob: true }) + console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`) + + perf.addEvent("glob") + const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns) + const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort() + console.log( + `Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, + ) + + const filePaths = fps.map((fp) => joinSegments(argv.directory, fp) as FilePath) + ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath)) + + const parsedFiles = await parseMarkdown(ctx, filePaths) + const filteredContent = filterContent(ctx, parsedFiles) + + const dependencies: Record | null> = {} + + // Only build dependency graphs if we're doing a fast rebuild + if (argv.fastRebuild) { + const staticResources = getStaticResourcesFromPlugins(ctx) + for (const emitter of cfg.plugins.emitters) { + dependencies[emitter.name] = + (await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null + } + } + + await emitContent(ctx, filteredContent) + console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`)) + release() + + if (argv.serve) { + return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies) + } } // setup watcher for rebuilds async function startServing( - ctx: BuildCtx, - mut: Mutex, - initialContent: ProcessedContent[], - clientRefresh: () => void, - dependencies: Dependencies, // emitter name: dep graph + ctx: BuildCtx, + mut: Mutex, + initialContent: ProcessedContent[], + clientRefresh: () => void, + dependencies: Dependencies, // emitter name: dep graph ) { - const { argv } = ctx; - - // cache file parse results - const contentMap = new Map(); - for (const content of initialContent) { - const [_tree, vfile] = content; - contentMap.set(vfile.data.filePath!, content); - } - - const buildData: BuildData = { - ctx, - mut, - dependencies, - contentMap, - ignored: await isGitIgnored(), - initialSlugs: ctx.allSlugs, - toRebuild: new Set(), - toRemove: new Set(), - trackedAssets: new Set(), - lastBuildMs: 0, - }; - - const watcher = chokidar.watch(".", { - persistent: true, - cwd: argv.directory, - ignoreInitial: true, - }); - - const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint; - watcher - .on("add", (fp) => buildFromEntry(fp, "add", clientRefresh, buildData)) - .on("change", (fp) => buildFromEntry(fp, "change", clientRefresh, buildData)) - .on("unlink", (fp) => buildFromEntry(fp, "delete", clientRefresh, buildData)); - - return async () => { - await watcher.close(); - }; + const { argv } = ctx + + // cache file parse results + const contentMap = new Map() + for (const content of initialContent) { + const [_tree, vfile] = content + contentMap.set(vfile.data.filePath!, content) + } + + const buildData: BuildData = { + ctx, + mut, + dependencies, + contentMap, + ignored: await isGitIgnored(), + initialSlugs: ctx.allSlugs, + toRebuild: new Set(), + toRemove: new Set(), + trackedAssets: new Set(), + lastBuildMs: 0, + } + + const watcher = chokidar.watch(".", { + persistent: true, + cwd: argv.directory, + ignoreInitial: true, + }) + + const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint + watcher + .on("add", (fp) => buildFromEntry(fp as string, "add", clientRefresh, buildData)) + .on("change", (fp) => buildFromEntry(fp as string, "change", clientRefresh, buildData)) + .on("unlink", (fp) => buildFromEntry(fp as string, "delete", clientRefresh, buildData)) + + return async () => { + await watcher.close() + } } async function partialRebuildFromEntrypoint( - filepath: string, - action: FileEvent, - clientRefresh: () => void, - buildData: BuildData, // note: this function mutates buildData + filepath: string, + action: FileEvent, + clientRefresh: () => void, + buildData: BuildData, // note: this function mutates buildData ) { - const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData; - const { argv, cfg } = ctx; - - // don't do anything for gitignored files - if (ignored(filepath)) { - return; - } - - const buildStart = new Date().getTime(); - buildData.lastBuildMs = buildStart; - const release = await mut.acquire(); - if (buildData.lastBuildMs > buildStart) { - release(); - return; - } + const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData + const { argv, cfg } = ctx + + // don't do anything for gitignored files + if (ignored(filepath)) { + return + } + + const buildId = newBuildId() + ctx.buildId = buildId + buildData.lastBuildMs = new Date().getTime() + const release = await mut.acquire() + + // if there's another build after us, release and let them do it + if (ctx.buildId !== buildId) { + release() + return + } const perf = new PerfTimer() console.log(chalk.yellow("Detected change, rebuilding...")) - ctx.buildId = newBuildId() - - // UPDATE DEP GRAPH - const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath; - - const staticResources = getStaticResourcesFromPlugins(ctx); - let processedFiles: ProcessedContent[] = []; - - switch (action) { - case "add": - // add to cache when new file is added - processedFiles = await parseMarkdown(ctx, [fp]); - processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])); - - // update the dep graph by asking all emitters whether they depend on this file - for (const emitter of cfg.plugins.emitters) { - const emitterGraph = - (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null; - - if (emitterGraph) { - const existingGraph = dependencies[emitter.name]; - if (existingGraph !== null) { - existingGraph.mergeGraph(emitterGraph); - } else { - // might be the first time we're adding a mardown file - dependencies[emitter.name] = emitterGraph; - } - } - } - break; - case "change": - // invalidate cache when file is changed - processedFiles = await parseMarkdown(ctx, [fp]); - processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])); - - // only content files can have added/removed dependencies because of transclusions - if (path.extname(fp) === ".md") { - for (const emitter of cfg.plugins.emitters) { - // get new dependencies from all emitters for this file - const emitterGraph = - (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null; - - // only update the graph if the emitter plugin uses the changed file - // eg. Assets plugin ignores md files, so we skip updating the graph - if (emitterGraph?.hasNode(fp)) { - // merge the new dependencies into the dep graph - dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp); - } - } - } - break; - case "delete": - toRemove.add(fp); - break; - } - - if (argv.verbose) { - console.log(`Updated dependency graphs in ${perf.timeSince()}`); - } - - // EMIT - perf.addEvent("rebuild"); - let emittedFiles = 0; - - for (const emitter of cfg.plugins.emitters) { - const depGraph = dependencies[emitter.name]; - - // emitter hasn't defined a dependency graph. call it with all processed files - if (depGraph === null) { - if (argv.verbose) { - console.log( - `Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`, - ); - } - - const files = [...contentMap.values()].filter( - ([_node, vfile]) => !toRemove.has(vfile.data.filePath!), - ); - - const emittedFps = await emitter.emit(ctx, files, staticResources); - - if (ctx.argv.verbose) { - for (const file of emittedFps) { - console.log(`[emit:${emitter.name}] ${file}`); - } - } - - emittedFiles += emittedFps.length; - continue; - } - - // only call the emitter if it uses this file - if (depGraph.hasNode(fp)) { - // re-emit using all files that are needed for the downstream of this file - // eg. for ContentIndex, the dep graph could be: - // a.md --> contentIndex.json - // b.md ------^ - // - // if a.md changes, we need to re-emit contentIndex.json, - // and supply [a.md, b.md] to the emitter - const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[]; - - const upstreamContent = upstreams - // filter out non-markdown files - .filter((file) => contentMap.has(file)) - // if file was deleted, don't give it to the emitter - .filter((file) => !toRemove.has(file)) - .map((file) => contentMap.get(file)!); - - const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources); - - if (ctx.argv.verbose) { - for (const file of emittedFps) { - console.log(`[emit:${emitter.name}] ${file}`); - } - } - - emittedFiles += emittedFps.length; - } - } - - console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`); - - // CLEANUP - const destinationsToDelete = new Set(); - for (const file of toRemove) { - // remove from cache - contentMap.delete(file); - Object.values(dependencies).forEach((depGraph) => { - // remove the node from dependency graphs - depGraph?.removeNode(file); - // remove any orphan nodes. eg if a.md is deleted, a.html is orphaned and should be removed - const orphanNodes = depGraph?.removeOrphanNodes(); - orphanNodes?.forEach((node) => { - // only delete files that are in the output directory - if (node.startsWith(argv.output)) { - destinationsToDelete.add(node); - } - }); - }); - } - await rimraf([...destinationsToDelete]); - - console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`)); - - toRemove.clear(); - release(); - clientRefresh(); + + // UPDATE DEP GRAPH + const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath + + const staticResources = getStaticResourcesFromPlugins(ctx) + let processedFiles: ProcessedContent[] = [] + + switch (action) { + case "add": + // add to cache when new file is added + processedFiles = await parseMarkdown(ctx, [fp]) + processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])) + + // update the dep graph by asking all emitters whether they depend on this file + for (const emitter of cfg.plugins.emitters) { + const emitterGraph = + (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null + + if (emitterGraph) { + const existingGraph = dependencies[emitter.name] + if (existingGraph !== null) { + existingGraph.mergeGraph(emitterGraph) + } else { + // might be the first time we're adding a mardown file + dependencies[emitter.name] = emitterGraph + } + } + } + break + case "change": + // invalidate cache when file is changed + processedFiles = await parseMarkdown(ctx, [fp]) + processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])) + + // only content files can have added/removed dependencies because of transclusions + if (path.extname(fp) === ".md") { + for (const emitter of cfg.plugins.emitters) { + // get new dependencies from all emitters for this file + const emitterGraph = + (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null + + // only update the graph if the emitter plugin uses the changed file + // eg. Assets plugin ignores md files, so we skip updating the graph + if (emitterGraph?.hasNode(fp)) { + // merge the new dependencies into the dep graph + dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp) + } + } + } + break + case "delete": + toRemove.add(fp) + break + } + + if (argv.verbose) { + console.log(`Updated dependency graphs in ${perf.timeSince()}`) + } + + // EMIT + perf.addEvent("rebuild") + let emittedFiles = 0 + + for (const emitter of cfg.plugins.emitters) { + const depGraph = dependencies[emitter.name] + + // emitter hasn't defined a dependency graph. call it with all processed files + if (depGraph === null) { + if (argv.verbose) { + console.log( + `Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`, + ) + } + + const files = [...contentMap.values()].filter( + ([_node, vfile]) => !toRemove.has(vfile.data.filePath!), + ) + + const emittedFps = await emitter.emit(ctx, files, staticResources) + + if (ctx.argv.verbose) { + for (const file of emittedFps) { + console.log(`[emit:${emitter.name}] ${file}`) + } + } + + emittedFiles += emittedFps.length + continue + } + + // only call the emitter if it uses this file + if (depGraph.hasNode(fp)) { + // re-emit using all files that are needed for the downstream of this file + // eg. for ContentIndex, the dep graph could be: + // a.md --> contentIndex.json + // b.md ------^ + // + // if a.md changes, we need to re-emit contentIndex.json, + // and supply [a.md, b.md] to the emitter + const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[] + + const upstreamContent = upstreams + // filter out non-markdown files + .filter((file) => contentMap.has(file)) + // if file was deleted, don't give it to the emitter + .filter((file) => !toRemove.has(file)) + .map((file) => contentMap.get(file)!) + + const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources) + + if (ctx.argv.verbose) { + for (const file of emittedFps) { + console.log(`[emit:${emitter.name}] ${file}`) + } + } + + emittedFiles += emittedFps.length + } + } + + console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`) + + // CLEANUP + const destinationsToDelete = new Set() + for (const file of toRemove) { + // remove from cache + contentMap.delete(file) + Object.values(dependencies).forEach((depGraph) => { + // remove the node from dependency graphs + depGraph?.removeNode(file) + // remove any orphan nodes. eg if a.md is deleted, a.html is orphaned and should be removed + const orphanNodes = depGraph?.removeOrphanNodes() + orphanNodes?.forEach((node) => { + // only delete files that are in the output directory + if (node.startsWith(argv.output)) { + destinationsToDelete.add(node) + } + }) + }) + } + await rimraf([...destinationsToDelete]) + + console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`)) + + toRemove.clear() + release() + clientRefresh() } async function rebuildFromEntrypoint( - fp: string, - action: FileEvent, - clientRefresh: () => void, - buildData: BuildData, // note: this function mutates buildData + fp: string, + action: FileEvent, + clientRefresh: () => void, + buildData: BuildData, // note: this function mutates buildData ) { - const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } = - 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); - } - - const buildStart = new Date().getTime(); - buildData.lastBuildMs = buildStart; - const release = await mut.acquire(); - - // there's another build after us, release and let them do it - if (buildData.lastBuildMs > buildStart) { - release(); - return; - } + const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } = + 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) + } + + const buildId = newBuildId() + ctx.buildId = buildId + buildData.lastBuildMs = new Date().getTime() + const release = await mut.acquire() + + // there's another build after us, release and let them do it + if (ctx.buildId !== buildId) { + release() + return + } const perf = new PerfTimer() console.log(chalk.yellow("Detected change, rebuilding...")) - ctx.buildId = newBuildId() try { const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp)) @@ -381,12 +381,12 @@ async function rebuildFromEntrypoint( contentMap.set(vfile.data.filePath!, content) } - for (const fp of toRemove) { - contentMap.delete(fp); - } + for (const fp of toRemove) { + contentMap.delete(fp) + } - const parsedFiles = [...contentMap.values()]; - const filteredContent = filterContent(ctx, parsedFiles); + const parsedFiles = [...contentMap.values()] + const filteredContent = filterContent(ctx, parsedFiles) // re-update slugs const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])] @@ -407,16 +407,16 @@ async function rebuildFromEntrypoint( } } - release(); - clientRefresh(); - toRebuild.clear(); - toRemove.clear(); + clientRefresh() + toRebuild.clear() + toRemove.clear() + release() } export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => { - try { - return await buildQuartz(argv, mut, clientRefresh); - } catch (err) { - trace("\nExiting Quartz due to a fatal error", err as Error); - } -}; + try { + return await buildQuartz(argv, mut, clientRefresh) + } catch (err) { + trace("\nExiting Quartz due to a fatal error", err as Error) + } +} diff --git a/quartz/cfg.ts b/quartz/cfg.ts index 134ecd76..135f5849 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -1,8 +1,9 @@ -import { ValidDateType } from "./components/Date"; -import { QuartzComponent } from "./components/types"; -import { ValidLocale } from "./i18n"; -import { PluginTypes } from "./plugins/types"; -import { Theme } from "./util/theme"; +import { ValidDateType } from "./components/Date" +import { QuartzComponent } from "./components/types" +import { ValidLocale } from "./i18n" +import { PluginTypes } from "./plugins/types" +import { SocialImageOptions } from "./util/og" +import { Theme } from "./util/theme" export type Analytics = | null @@ -38,9 +39,14 @@ export type Analytics = provider: "cabin" host?: string } + | { + provider: "clarity" + projectId?: string + } export interface GlobalConfiguration { pageTitle: string + pageTitleSuffix?: string /** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */ enableSPA: boolean /** Whether to display Wikipedia-style popovers when hovering over links */ @@ -56,15 +62,14 @@ export interface GlobalConfiguration { */ baseUrl?: string /** - * Folder where the og-image is located. If not set, Quartz will use the default og-image.png - * baseUrl will be happenend as follow: `https://${baseUrl}/${ogImageDir}/${image}` + * Whether to generate social images (Open Graph and Twitter standard) for link previews */ - ogImageDir?: string + generateSocialImages: boolean | Partial theme: Theme /** * Allow to translate the date in the language of your choice. * Also used for UI translation (default: en-US) - * Need to be formated following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag + * Need to be formatted following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag * The first part is the language (en) and the second part is the script/region (US) * Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes * Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 diff --git a/quartz/cli/args.js b/quartz/cli/args.js index 66ada517..123d0ac5 100644 --- a/quartz/cli/args.js +++ b/quartz/cli/args.js @@ -1,108 +1,108 @@ export const CommonArgv = { - directory: { - string: true, - alias: ["d"], - default: "content", - describe: "directory to look for content files", - }, - verbose: { - boolean: true, - alias: ["v"], - default: false, - describe: "print out extra logging information", - }, -}; + directory: { + string: true, + alias: ["d"], + default: "content", + describe: "directory to look for content files", + }, + verbose: { + boolean: true, + alias: ["v"], + default: false, + describe: "print out extra logging information", + }, +} export const CreateArgv = { - ...CommonArgv, - source: { - string: true, - alias: ["s"], - describe: "source directory to copy/create symlink from", - }, - strategy: { - string: true, - alias: ["X"], - choices: ["new", "copy", "symlink"], - describe: "strategy for content folder setup", - }, - links: { - string: true, - alias: ["l"], - choices: ["absolute", "shortest", "relative"], - describe: "strategy to resolve links", - }, -}; + ...CommonArgv, + source: { + string: true, + alias: ["s"], + describe: "source directory to copy/create symlink from", + }, + strategy: { + string: true, + alias: ["X"], + choices: ["new", "copy", "symlink"], + describe: "strategy for content folder setup", + }, + links: { + string: true, + alias: ["l"], + choices: ["absolute", "shortest", "relative"], + describe: "strategy to resolve links", + }, +} export const SyncArgv = { - ...CommonArgv, - commit: { - boolean: true, - default: true, - describe: "create a git commit for your unsaved changes", - }, - message: { - string: true, - alias: ["m"], - describe: "option to override the default Quartz commit message", - }, - push: { - boolean: true, - default: true, - describe: "push updates to your Quartz fork", - }, - pull: { - boolean: true, - default: true, - describe: "pull updates from your Quartz fork", - }, -}; + ...CommonArgv, + commit: { + boolean: true, + default: true, + describe: "create a git commit for your unsaved changes", + }, + message: { + string: true, + alias: ["m"], + describe: "option to override the default Quartz commit message", + }, + push: { + boolean: true, + default: true, + describe: "push updates to your Quartz fork", + }, + pull: { + boolean: true, + default: true, + describe: "pull updates from your Quartz fork", + }, +} export const BuildArgv = { - ...CommonArgv, - output: { - string: true, - alias: ["o"], - default: "public", - describe: "output folder for files", - }, - serve: { - boolean: true, - default: false, - describe: "run a local server to live-preview your Quartz", - }, - fastRebuild: { - boolean: true, - default: false, - describe: "[experimental] rebuild only the changed files", - }, - baseDir: { - string: true, - default: "", - describe: "base path to serve your local server on", - }, - port: { - number: true, - default: 8080, - describe: "port to serve Quartz on", - }, - wsPort: { - number: true, - default: 3001, - describe: "port to use for WebSocket-based hot-reload notifications", - }, - remoteDevHost: { - string: true, - default: "", - describe: "A URL override for the websocket connection if you are not developing on localhost", - }, - bundleInfo: { - boolean: true, - default: false, - describe: "show detailed bundle information", - }, - concurrency: { - number: true, - describe: "how many threads to use to parse notes", - }, -}; + ...CommonArgv, + output: { + string: true, + alias: ["o"], + default: "public", + describe: "output folder for files", + }, + serve: { + boolean: true, + default: false, + describe: "run a local server to live-preview your Quartz", + }, + fastRebuild: { + boolean: true, + default: false, + describe: "[experimental] rebuild only the changed files", + }, + baseDir: { + string: true, + default: "", + describe: "base path to serve your local server on", + }, + port: { + number: true, + default: 8080, + describe: "port to serve Quartz on", + }, + wsPort: { + number: true, + default: 3001, + describe: "port to use for WebSocket-based hot-reload notifications", + }, + remoteDevHost: { + string: true, + default: "", + describe: "A URL override for the websocket connection if you are not developing on localhost", + }, + bundleInfo: { + boolean: true, + default: false, + describe: "show detailed bundle information", + }, + concurrency: { + number: true, + describe: "how many threads to use to parse notes", + }, +} diff --git a/quartz/cli/constants.js b/quartz/cli/constants.js index 5786b635..f4a9ce52 100644 --- a/quartz/cli/constants.js +++ b/quartz/cli/constants.js @@ -1,15 +1,15 @@ -import { readFileSync } from "fs"; -import path from "path"; +import path from "path" +import { readFileSync } from "fs" /** * All constants relating to helpers or handlers */ -export const ORIGIN_NAME = "origin"; -export const UPSTREAM_NAME = "upstream"; -export const QUARTZ_SOURCE_BRANCH = "v4"; -export const cwd = process.cwd(); -export const cacheDir = path.join(cwd, ".quartz-cache"); -export const cacheFile = "./quartz/.quartz-cache/transpiled-build.mjs"; -export const fp = "./quartz/build.ts"; -export const { version } = JSON.parse(readFileSync("./package.json").toString()); -export const contentCacheFolder = path.join(cacheDir, "content-cache"); +export const ORIGIN_NAME = "origin" +export const UPSTREAM_NAME = "upstream" +export const QUARTZ_SOURCE_BRANCH = "v4" +export const cwd = process.cwd() +export const cacheDir = path.join(cwd, ".quartz-cache") +export const cacheFile = "./quartz/.quartz-cache/transpiled-build.mjs" +export const fp = "./quartz/build.ts" +export const { version } = JSON.parse(readFileSync("./package.json").toString()) +export const contentCacheFolder = path.join(cacheDir, "content-cache") diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js index 816a9497..6b23d801 100644 --- a/quartz/cli/handlers.js +++ b/quartz/cli/handlers.js @@ -1,213 +1,214 @@ -import { intro, outro, select, text } from "@clack/prompts"; -import { Mutex } from "async-mutex"; -import chalk from "chalk"; -import { execSync, spawnSync } from "child_process"; -import chokidar from "chokidar"; -import { randomUUID } from "crypto"; -import esbuild from "esbuild"; -import { sassPlugin } from "esbuild-sass-plugin"; -import fs, { promises } from "fs"; -import http from "http"; -import path from "path"; -import prettyBytes from "pretty-bytes"; -import { rimraf } from "rimraf"; -import serveHandler from "serve-handler"; -import { WebSocketServer } from "ws"; - -import { CreateArgv } from "./args.js"; +import { promises } from "fs" +import path from "path" +import esbuild from "esbuild" +import chalk from "chalk" +import { sassPlugin } from "esbuild-sass-plugin" +import fs from "fs" +import { intro, outro, select, text } from "@clack/prompts" +import { rimraf } from "rimraf" +import chokidar from "chokidar" +import prettyBytes from "pretty-bytes" +import { execSync, spawnSync } from "child_process" +import http from "http" +import serveHandler from "serve-handler" +import { WebSocketServer } from "ws" +import { randomUUID } from "crypto" +import { Mutex } from "async-mutex" +import { CreateArgv } from "./args.js" +import { globby } from "globby" import { - cacheFile, - cwd, - fp, - ORIGIN_NAME, - QUARTZ_SOURCE_BRANCH, - UPSTREAM_NAME, - version, -} from "./constants.js"; + exitIfCancel, + escapePath, + gitPull, + popContentFolder, + stashContentFolder, +} from "./helpers.js" import { - escapePath, - exitIfCancel, - gitPull, - popContentFolder, - stashContentFolder, -} from "./helpers.js"; + UPSTREAM_NAME, + QUARTZ_SOURCE_BRANCH, + ORIGIN_NAME, + version, + fp, + cacheFile, + cwd, +} from "./constants.js" /** * Handles `npx quartz create` * @param {*} argv arguments for `create` */ export async function handleCreate(argv) { - console.log(); - intro(chalk.bgGreen.black(` Quartz v${version} `)); - const contentFolder = path.join(cwd, argv.directory); - let setupStrategy = argv.strategy?.toLowerCase(); - let linkResolutionStrategy = argv.links?.toLowerCase(); - const sourceDirectory = argv.source; - - // If all cmd arguments were provided, check if theyre valid - if (setupStrategy && linkResolutionStrategy) { - // If setup isn't, "new", source argument is required - if (setupStrategy !== "new") { - // Error handling - if (!sourceDirectory) { - outro( - chalk.red( - `Setup strategies (arg '${chalk.yellow( - `-${CreateArgv.strategy.alias[0]}`, - )}') other than '${chalk.yellow( - "new", - )}' require content folder argument ('${chalk.yellow( - `-${CreateArgv.source.alias[0]}`, - )}') to be set`, - ), - ); - process.exit(1); - } else { - if (!fs.existsSync(sourceDirectory)) { - outro( - chalk.red( - `Input directory to copy/symlink 'content' from not found ('${chalk.yellow( - sourceDirectory, - )}', invalid argument "${chalk.yellow(`-${CreateArgv.source.alias[0]}`)})`, - ), - ); - process.exit(1); - } else if (!fs.lstatSync(sourceDirectory).isDirectory()) { - outro( - chalk.red( - `Source directory to copy/symlink 'content' from is not a directory (found file at '${chalk.yellow( - sourceDirectory, - )}', invalid argument ${chalk.yellow(`-${CreateArgv.source.alias[0]}`)}")`, - ), - ); - process.exit(1); - } - } - } - } - - // Use cli process if cmd args werent provided - if (!setupStrategy) { - setupStrategy = exitIfCancel( - await select({ - message: `Choose how to initialize the content in \`${contentFolder}\``, - options: [ - { value: "new", label: "Empty Quartz" }, - { value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" }, - { - value: "symlink", - label: "Symlink an existing folder", - hint: "don't select this unless you know what you are doing!", - }, - ], - }), - ); - } - - async function rmContentFolder() { - const contentStat = await fs.promises.lstat(contentFolder); - if (contentStat.isSymbolicLink()) { - await fs.promises.unlink(contentFolder); - } else { - await rimraf(contentFolder); - } - } - - const gitkeepPath = path.join(contentFolder, ".gitkeep"); - if (fs.existsSync(gitkeepPath)) { - await fs.promises.unlink(gitkeepPath); - } - if (setupStrategy === "copy" || setupStrategy === "symlink") { - let originalFolder = sourceDirectory; - - // If input directory was not passed, use cli - if (!sourceDirectory) { - originalFolder = escapePath( - exitIfCancel( - await text({ - message: "Enter the full path to existing content folder", - placeholder: + console.log() + intro(chalk.bgGreen.black(` Quartz v${version} `)) + const contentFolder = path.join(cwd, argv.directory) + let setupStrategy = argv.strategy?.toLowerCase() + let linkResolutionStrategy = argv.links?.toLowerCase() + const sourceDirectory = argv.source + + // If all cmd arguments were provided, check if they're valid + if (setupStrategy && linkResolutionStrategy) { + // If setup isn't, "new", source argument is required + if (setupStrategy !== "new") { + // Error handling + if (!sourceDirectory) { + outro( + chalk.red( + `Setup strategies (arg '${chalk.yellow( + `-${CreateArgv.strategy.alias[0]}`, + )}') other than '${chalk.yellow( + "new", + )}' require content folder argument ('${chalk.yellow( + `-${CreateArgv.source.alias[0]}`, + )}') to be set`, + ), + ) + process.exit(1) + } else { + if (!fs.existsSync(sourceDirectory)) { + outro( + chalk.red( + `Input directory to copy/symlink 'content' from not found ('${chalk.yellow( + sourceDirectory, + )}', invalid argument "${chalk.yellow(`-${CreateArgv.source.alias[0]}`)})`, + ), + ) + process.exit(1) + } else if (!fs.lstatSync(sourceDirectory).isDirectory()) { + outro( + chalk.red( + `Source directory to copy/symlink 'content' from is not a directory (found file at '${chalk.yellow( + sourceDirectory, + )}', invalid argument ${chalk.yellow(`-${CreateArgv.source.alias[0]}`)}")`, + ), + ) + process.exit(1) + } + } + } + } + + // Use cli process if cmd args werent provided + if (!setupStrategy) { + setupStrategy = exitIfCancel( + await select({ + message: `Choose how to initialize the content in \`${contentFolder}\``, + options: [ + { value: "new", label: "Empty Quartz" }, + { value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" }, + { + value: "symlink", + label: "Symlink an existing folder", + hint: "don't select this unless you know what you are doing!", + }, + ], + }), + ) + } + + async function rmContentFolder() { + const contentStat = await fs.promises.lstat(contentFolder) + if (contentStat.isSymbolicLink()) { + await fs.promises.unlink(contentFolder) + } else { + await rimraf(contentFolder) + } + } + + const gitkeepPath = path.join(contentFolder, ".gitkeep") + if (fs.existsSync(gitkeepPath)) { + await fs.promises.unlink(gitkeepPath) + } + if (setupStrategy === "copy" || setupStrategy === "symlink") { + let originalFolder = sourceDirectory + + // If input directory was not passed, use cli + if (!sourceDirectory) { + originalFolder = escapePath( + exitIfCancel( + await text({ + message: "Enter the full path to existing content folder", + placeholder: "On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path", - validate(fp) { - const fullPath = escapePath(fp); - if (!fs.existsSync(fullPath)) { - return "The given path doesn't exist"; - } else if (!fs.lstatSync(fullPath).isDirectory()) { - return "The given path is not a folder"; - } - }, - }), - ), - ); - } - - await rmContentFolder(); - if (setupStrategy === "copy") { - await fs.promises.cp(originalFolder, contentFolder, { - recursive: true, - preserveTimestamps: true, - }); - } else if (setupStrategy === "symlink") { - await fs.promises.symlink(originalFolder, contentFolder, "dir"); - } - } else if (setupStrategy === "new") { - await fs.promises.writeFile( - path.join(contentFolder, "index.md"), - `--- + validate(fp) { + const fullPath = escapePath(fp) + if (!fs.existsSync(fullPath)) { + return "The given path doesn't exist" + } else if (!fs.lstatSync(fullPath).isDirectory()) { + return "The given path is not a folder" + } + }, + }), + ), + ) + } + + await rmContentFolder() + if (setupStrategy === "copy") { + await fs.promises.cp(originalFolder, contentFolder, { + recursive: true, + preserveTimestamps: true, + }) + } else if (setupStrategy === "symlink") { + await fs.promises.symlink(originalFolder, contentFolder, "dir") + } + } else if (setupStrategy === "new") { + await fs.promises.writeFile( + path.join(contentFolder, "index.md"), + `--- title: Welcome to Quartz --- This is a blank Quartz installation. See the [documentation](https://quartz.jzhao.xyz) for how to get started. `, - ); - } - - // Use cli process if cmd args werent provided - if (!linkResolutionStrategy) { - // get a preferred link resolution strategy - linkResolutionStrategy = exitIfCancel( - await select({ - message: "Choose how Quartz should resolve links in your content. This should match Obsidian's link format. You can change this later in `quartz.config.ts`.", - options: [ - { - value: "shortest", - label: "Treat links as shortest path", - hint: "(default)", - }, - { - value: "absolute", - label: "Treat links as absolute path", - }, - { - value: "relative", - label: "Treat links as relative paths", - }, - ], - }), - ); - } - - // now, do config changes - const configFilePath = path.join(cwd, "quartz.config.ts"); - let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" }); - configContent = configContent.replace( - /markdownLinkResolution: '(.+)'/, - `markdownLinkResolution: '${linkResolutionStrategy}'`, - ); - await fs.promises.writeFile(configFilePath, configContent); - - // setup remote - execSync( - "git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git", - { stdio: "ignore" }, - ); - - outro(`You're all set! Not sure what to do next? Try: + ) + } + + // Use cli process if cmd args werent provided + if (!linkResolutionStrategy) { + // get a preferred link resolution strategy + linkResolutionStrategy = exitIfCancel( + await select({ + message: `Choose how Quartz should resolve links in your content. This should match Obsidian's link format. You can change this later in \`quartz.config.ts\`.`, + options: [ + { + value: "shortest", + label: "Treat links as shortest path", + hint: "(default)", + }, + { + value: "absolute", + label: "Treat links as absolute path", + }, + { + value: "relative", + label: "Treat links as relative paths", + }, + ], + }), + ) + } + + // now, do config changes + const configFilePath = path.join(cwd, "quartz.config.ts") + let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" }) + configContent = configContent.replace( + /markdownLinkResolution: '(.+)'/, + `markdownLinkResolution: '${linkResolutionStrategy}'`, + ) + await fs.promises.writeFile(configFilePath, configContent) + + // setup remote + execSync( + `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`, + { stdio: "ignore" }, + ) + + outro(`You're all set! Not sure what to do next? Try: • Customizing Quartz a bit more by editing \`quartz.config.ts\` • Running \`npx quartz build --serve\` to preview your Quartz locally • Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting) -`); +`) } /** @@ -215,220 +216,233 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started. * @param {*} argv arguments for `build` */ export async function handleBuild(argv) { - console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)); - const ctx = await esbuild.context({ - entryPoints: [fp], - outfile: cacheFile, - bundle: true, - keepNames: true, - minifyWhitespace: true, - minifySyntax: true, - platform: "node", - format: "esm", - jsx: "automatic", - jsxImportSource: "preact", - packages: "external", - metafile: true, - sourcemap: true, - sourcesContent: false, - plugins: [ - sassPlugin({ - type: "css-text", - cssImports: true, - }), - { - name: "inline-script-loader", - setup(build) { - build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => { - let text = await promises.readFile(args.path, "utf8"); - - // remove default exports that we manually inserted - text = text.replace("export default", ""); - text = text.replace("export", ""); - - const sourcefile = path.relative(path.resolve("."), args.path); - const resolveDir = path.dirname(sourcefile); - const transpiled = await esbuild.build({ - stdin: { - contents: text, - loader: "ts", - resolveDir, - sourcefile, - }, - write: false, - bundle: true, - minify: true, - platform: "browser", - format: "esm", - }); - const rawMod = transpiled.outputFiles[0].text; - return { - contents: rawMod, - loader: "text", - }; - }); - }, - }, - ], - }); - - const buildMutex = new Mutex(); - let lastBuildMs = 0; - let cleanupBuild = null; - const build = async (clientRefresh) => { - const buildStart = new Date().getTime(); - lastBuildMs = buildStart; - const release = await buildMutex.acquire(); - if (lastBuildMs > buildStart) { - release(); - return; - } - - if (cleanupBuild) { - await cleanupBuild(); - console.log(chalk.yellow("Detected a source code change, doing a hard rebuild...")); - } - - const result = await ctx.rebuild().catch((err) => { - console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`); - console.log(`Reason: ${chalk.grey(err)}`); - process.exit(1); - }); - release(); - - if (argv.bundleInfo) { - const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"; - const meta = result.metafile.outputs[outputFileName]; - console.log( - `Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes( - meta.bytes, - )})`, - ); - console.log(await esbuild.analyzeMetafile(result.metafile, { color: true })); - } - - // bypass module cache - // https://github.com/nodejs/modules/issues/307 - const { default: buildQuartz } = await import(`../../${cacheFile}?update=${randomUUID()}`); - // ^ this import is relative, so base "cacheFile" path can't be used - - cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh); - clientRefresh(); - }; - - if (argv.serve) { - const connections = []; - const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild")); - - if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) { - argv.baseDir = "/" + argv.baseDir; - } - - await build(clientRefresh); - const server = http.createServer(async (req, res) => { - if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) { - console.log( - chalk.red( - `[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`, - ), - ); - res.writeHead(404); - res.end(); - return; - } - - // strip baseDir prefix - req.url = req.url?.slice(argv.baseDir.length); - - const serve = async () => { - const release = await buildMutex.acquire(); - await serveHandler(req, res, { - public: argv.output, - directoryListing: false, - headers: [ - { - source: "**/*.*", - headers: [{ key: "Content-Disposition", value: "inline" }], - }, - ], - }); - const status = res.statusCode; - const statusString = - status >= 200 && status < 300 ? chalk.green(`[${status}]`) : chalk.red(`[${status}]`); - console.log(statusString + chalk.grey(` ${argv.baseDir}${req.url}`)); - release(); - }; - - const redirect = (newFp) => { - newFp = argv.baseDir + newFp; - res.writeHead(302, { - Location: newFp, - }); - console.log(chalk.yellow("[302]") + chalk.grey(` ${argv.baseDir}${req.url} -> ${newFp}`)); - res.end(); - }; - - let fp = req.url?.split("?")[0] ?? "/"; - - // handle redirects - if (fp.endsWith("/")) { - // /trailing/ - // does /trailing/index.html exist? if so, serve it - const indexFp = path.posix.join(fp, "index.html"); - if (fs.existsSync(path.posix.join(argv.output, indexFp))) { - req.url = fp; - return serve(); - } - - // does /trailing.html exist? if so, redirect to /trailing - let base = fp.slice(0, -1); - if (path.extname(base) === "") { - base += ".html"; - } - if (fs.existsSync(path.posix.join(argv.output, base))) { - return redirect(fp.slice(0, -1)); - } - } else { - // /regular - // does /regular.html exist? if so, serve it - let base = fp; - if (path.extname(base) === "") { - base += ".html"; - } - if (fs.existsSync(path.posix.join(argv.output, base))) { - req.url = fp; - return serve(); - } - - // does /regular/index.html exist? if so, redirect to /regular/ - let indexFp = path.posix.join(fp, "index.html"); - if (fs.existsSync(path.posix.join(argv.output, indexFp))) { - return redirect(fp + "/"); - } - } - - return serve(); - }); - server.listen(argv.port); - const wss = new WebSocketServer({ port: argv.wsPort }); - wss.on("connection", (ws) => connections.push(ws)); - console.log( - chalk.cyan( - `Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`, - ), - ); - console.log("hint: exit with ctrl+c"); - chokidar - .watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], { - ignoreInitial: true, - }) - .on("all", async () => { - build(clientRefresh); - }); - } else { - await build(() => {}); - ctx.dispose(); - } + console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) + const ctx = await esbuild.context({ + entryPoints: [fp], + outfile: cacheFile, + bundle: true, + keepNames: true, + minifyWhitespace: true, + minifySyntax: true, + platform: "node", + format: "esm", + jsx: "automatic", + jsxImportSource: "preact", + packages: "external", + metafile: true, + sourcemap: true, + sourcesContent: false, + plugins: [ + sassPlugin({ + type: "css-text", + cssImports: true, + }), + sassPlugin({ + filter: /\.inline\.scss$/, + type: "css", + cssImports: true, + }), + { + name: "inline-script-loader", + setup(build) { + build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => { + let text = await promises.readFile(args.path, "utf8") + + // remove default exports that we manually inserted + text = text.replace("export default", "") + text = text.replace("export", "") + + const sourcefile = path.relative(path.resolve("."), args.path) + const resolveDir = path.dirname(sourcefile) + const transpiled = await esbuild.build({ + stdin: { + contents: text, + loader: "ts", + resolveDir, + sourcefile, + }, + write: false, + bundle: true, + minify: true, + platform: "browser", + format: "esm", + }) + const rawMod = transpiled.outputFiles[0].text + return { + contents: rawMod, + loader: "text", + } + }) + }, + }, + ], + }) + + const buildMutex = new Mutex() + let lastBuildMs = 0 + let cleanupBuild = null + const build = async (clientRefresh) => { + const buildStart = new Date().getTime() + lastBuildMs = buildStart + const release = await buildMutex.acquire() + if (lastBuildMs > buildStart) { + release() + return + } + + if (cleanupBuild) { + console.log(chalk.yellow("Detected a source code change, doing a hard rebuild...")) + await cleanupBuild() + } + + const result = await ctx.rebuild().catch((err) => { + console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`) + console.log(`Reason: ${chalk.grey(err)}`) + process.exit(1) + }) + release() + + if (argv.bundleInfo) { + const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs" + const meta = result.metafile.outputs[outputFileName] + console.log( + `Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes( + meta.bytes, + )})`, + ) + console.log(await esbuild.analyzeMetafile(result.metafile, { color: true })) + } + + // bypass module cache + // https://github.com/nodejs/modules/issues/307 + const { default: buildQuartz } = await import(`../../${cacheFile}?update=${randomUUID()}`) + // ^ this import is relative, so base "cacheFile" path can't be used + + cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh) + clientRefresh() + } + + if (argv.serve) { + const connections = [] + const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild")) + + if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) { + argv.baseDir = "/" + argv.baseDir + } + + await build(clientRefresh) + const server = http.createServer(async (req, res) => { + if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) { + console.log( + chalk.red( + `[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`, + ), + ) + res.writeHead(404) + res.end() + return + } + + // strip baseDir prefix + req.url = req.url?.slice(argv.baseDir.length) + + const serve = async () => { + const release = await buildMutex.acquire() + await serveHandler(req, res, { + public: argv.output, + directoryListing: false, + headers: [ + { + source: "**/*.*", + headers: [{ key: "Content-Disposition", value: "inline" }], + }, + { + source: "**/*.webp", + headers: [{ key: "Content-Type", value: "image/webp" }], + }, + // fixes bug where avif images are displayed as text instead of images (future proof) + { + source: "**/*.avif", + headers: [{ key: "Content-Type", value: "image/avif" }], + }, + ], + }) + const status = res.statusCode + const statusString = + status >= 200 && status < 300 ? chalk.green(`[${status}]`) : chalk.red(`[${status}]`) + console.log(statusString + chalk.grey(` ${argv.baseDir}${req.url}`)) + release() + } + + const redirect = (newFp) => { + newFp = argv.baseDir + newFp + res.writeHead(302, { + Location: newFp, + }) + console.log(chalk.yellow("[302]") + chalk.grey(` ${argv.baseDir}${req.url} -> ${newFp}`)) + res.end() + } + + let fp = req.url?.split("?")[0] ?? "/" + + // handle redirects + if (fp.endsWith("/")) { + // /trailing/ + // does /trailing/index.html exist? if so, serve it + const indexFp = path.posix.join(fp, "index.html") + if (fs.existsSync(path.posix.join(argv.output, indexFp))) { + req.url = fp + return serve() + } + + // does /trailing.html exist? if so, redirect to /trailing + let base = fp.slice(0, -1) + if (path.extname(base) === "") { + base += ".html" + } + if (fs.existsSync(path.posix.join(argv.output, base))) { + return redirect(fp.slice(0, -1)) + } + } else { + // /regular + // does /regular.html exist? if so, serve it + let base = fp + if (path.extname(base) === "") { + base += ".html" + } + if (fs.existsSync(path.posix.join(argv.output, base))) { + req.url = fp + return serve() + } + + // does /regular/index.html exist? if so, redirect to /regular/ + let indexFp = path.posix.join(fp, "index.html") + if (fs.existsSync(path.posix.join(argv.output, indexFp))) { + return redirect(fp + "/") + } + } + + return serve() + }) + server.listen(argv.port) + const wss = new WebSocketServer({ port: argv.wsPort }) + wss.on("connection", (ws) => connections.push(ws)) + console.log( + chalk.cyan( + `Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`, + ), + ) + console.log("hint: exit with ctrl+c") + const paths = await globby(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"]) + chokidar + .watch(paths, { ignoreInitial: true }) + .on("add", () => build(clientRefresh)) + .on("change", () => build(clientRefresh)) + .on("unlink", () => build(clientRefresh)) + } else { + await build(() => {}) + ctx.dispose() + } } /** @@ -436,33 +450,51 @@ export async function handleBuild(argv) { * @param {*} argv arguments for `update` */ export async function handleUpdate(argv) { - const contentFolder = path.join(cwd, argv.directory); - console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)); - console.log("Backing up your content"); - execSync( - "git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git", - ); - await stashContentFolder(contentFolder); - console.log( - "Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.", - ); - - try { - gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH); - } catch { - console.log(chalk.red("An error occurred above while pulling updates.")); - await popContentFolder(contentFolder); - return; - } - - await popContentFolder(contentFolder); - console.log("Ensuring dependencies are up to date"); - const res = spawnSync("npm", ["i"], { stdio: "inherit" }); - if (res.status === 0) { - console.log(chalk.green("Done!")); - } else { - console.log(chalk.red("An error occurred above while installing dependencies.")); - } + const contentFolder = path.join(cwd, argv.directory) + console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) + console.log("Backing up your content") + execSync( + `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`, + ) + await stashContentFolder(contentFolder) + console.log( + "Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.", + ) + + try { + gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH) + } catch { + console.log(chalk.red("An error occurred above while pulling updates.")) + await popContentFolder(contentFolder) + return + } + + await popContentFolder(contentFolder) + console.log("Ensuring dependencies are up to date") + + /* + On Windows, if the command `npm` is really `npm.cmd', this call fails + as it will be unable to find `npm`. This is often the case on systems + where `npm` is installed via a package manager. + + This means `npx quartz update` will not actually update dependencies + on Windows, without a manual `npm i` from the caller. + + However, by spawning a shell, we are able to call `npm.cmd`. + See: https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows + */ + + const opts = { stdio: "inherit" } + if (process.platform === "win32") { + opts.shell = true + } + + const res = spawnSync("npm", ["i"], opts) + if (res.status === 0) { + console.log(chalk.green("Done!")) + } else { + console.log(chalk.red("An error occurred above while installing dependencies.")) + } } /** @@ -470,8 +502,8 @@ export async function handleUpdate(argv) { * @param {*} argv arguments for `restore` */ export async function handleRestore(argv) { - const contentFolder = path.join(cwd, argv.directory); - await popContentFolder(contentFolder); + const contentFolder = path.join(cwd, argv.directory) + await popContentFolder(contentFolder) } /** @@ -479,66 +511,66 @@ export async function handleRestore(argv) { * @param {*} argv arguments for `sync` */ export async function handleSync(argv) { - const contentFolder = path.join(cwd, argv.directory); - console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)); - console.log("Backing up your content"); - - if (argv.commit) { - const contentStat = await fs.promises.lstat(contentFolder); - if (contentStat.isSymbolicLink()) { - const linkTarg = await fs.promises.readlink(contentFolder); - console.log(chalk.yellow("Detected symlink, trying to dereference before committing")); - - // stash symlink file - await stashContentFolder(contentFolder); - - // follow symlink and copy content - await fs.promises.cp(linkTarg, contentFolder, { - recursive: true, - preserveTimestamps: true, - }); - } - - const currentTimestamp = new Date().toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }); - const commitMessage = argv.message ?? `Quartz sync: ${currentTimestamp}`; - spawnSync("git", ["add", "."], { stdio: "inherit" }); - spawnSync("git", ["commit", "-m", commitMessage], { stdio: "inherit" }); - - if (contentStat.isSymbolicLink()) { - // put symlink back - await popContentFolder(contentFolder); - } - } - - await stashContentFolder(contentFolder); - - if (argv.pull) { - console.log( - "Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.", - ); - try { - gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH); - } catch { - console.log(chalk.red("An error occurred above while pulling updates.")); - await popContentFolder(contentFolder); - return; - } - } - - await popContentFolder(contentFolder); - if (argv.push) { - console.log("Pushing your changes"); - const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { - stdio: "inherit", - }); - if (res.status !== 0) { - console.log(chalk.red(`An error occurred above while pushing to remote ${ORIGIN_NAME}.`)); - return; - } - } - - console.log(chalk.green("Done!")); + const contentFolder = path.join(cwd, argv.directory) + console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) + console.log("Backing up your content") + + if (argv.commit) { + const contentStat = await fs.promises.lstat(contentFolder) + if (contentStat.isSymbolicLink()) { + const linkTarg = await fs.promises.readlink(contentFolder) + console.log(chalk.yellow("Detected symlink, trying to dereference before committing")) + + // stash symlink file + await stashContentFolder(contentFolder) + + // follow symlink and copy content + await fs.promises.cp(linkTarg, contentFolder, { + recursive: true, + preserveTimestamps: true, + }) + } + + const currentTimestamp = new Date().toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + const commitMessage = argv.message ?? `Quartz sync: ${currentTimestamp}` + spawnSync("git", ["add", "."], { stdio: "inherit" }) + spawnSync("git", ["commit", "-m", commitMessage], { stdio: "inherit" }) + + if (contentStat.isSymbolicLink()) { + // put symlink back + await popContentFolder(contentFolder) + } + } + + await stashContentFolder(contentFolder) + + if (argv.pull) { + console.log( + "Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.", + ) + try { + gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH) + } catch { + console.log(chalk.red("An error occurred above while pulling updates.")) + await popContentFolder(contentFolder) + return + } + } + + await popContentFolder(contentFolder) + if (argv.push) { + console.log("Pushing your changes") + const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { + stdio: "inherit", + }) + if (res.status !== 0) { + console.log(chalk.red(`An error occurred above while pushing to remote ${ORIGIN_NAME}.`)) + return + } + } + + console.log(chalk.green("Done!")) } diff --git a/quartz/cli/helpers.js b/quartz/cli/helpers.js index 06c013aa..702a1b71 100644 --- a/quartz/cli/helpers.js +++ b/quartz/cli/helpers.js @@ -1,55 +1,54 @@ -import { isCancel, outro } from "@clack/prompts"; -import chalk from "chalk"; -import { spawnSync } from "child_process"; -import fs from "fs"; - -import { contentCacheFolder } from "./constants.js"; +import { isCancel, outro } from "@clack/prompts" +import chalk from "chalk" +import { contentCacheFolder } from "./constants.js" +import { spawnSync } from "child_process" +import fs from "fs" export function escapePath(fp) { - return fp - .replace(/\\ /g, " ") // unescape spaces - .replace(/^".*"$/, "$1") - .replace(/^'.*"$/, "$1") - .trim(); + return fp + .replace(/\\ /g, " ") // unescape spaces + .replace(/^".*"$/, "$1") + .replace(/^'.*"$/, "$1") + .trim() } export function exitIfCancel(val) { - if (isCancel(val)) { - outro(chalk.red("Exiting")); - process.exit(0); - } else { - return val; - } + if (isCancel(val)) { + outro(chalk.red("Exiting")) + process.exit(0) + } else { + return val + } } export async function stashContentFolder(contentFolder) { - await fs.promises.rm(contentCacheFolder, { force: true, recursive: true }); - await fs.promises.cp(contentFolder, contentCacheFolder, { - force: true, - recursive: true, - verbatimSymlinks: true, - preserveTimestamps: true, - }); - await fs.promises.rm(contentFolder, { force: true, recursive: true }); + await fs.promises.rm(contentCacheFolder, { force: true, recursive: true }) + await fs.promises.cp(contentFolder, contentCacheFolder, { + force: true, + recursive: true, + verbatimSymlinks: true, + preserveTimestamps: true, + }) + await fs.promises.rm(contentFolder, { force: true, recursive: true }) } export function gitPull(origin, branch) { - const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]; - const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" }); - if (out.stderr) { - throw new Error(chalk.red(`Error while pulling updates: ${out.stderr}`)); - } else if (out.status !== 0) { - throw new Error(chalk.red("Error while pulling updates")); - } + const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"] + const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" }) + if (out.stderr) { + throw new Error(chalk.red(`Error while pulling updates: ${out.stderr}`)) + } else if (out.status !== 0) { + throw new Error(chalk.red("Error while pulling updates")) + } } export async function popContentFolder(contentFolder) { - await fs.promises.rm(contentFolder, { force: true, recursive: true }); - await fs.promises.cp(contentCacheFolder, contentFolder, { - force: true, - recursive: true, - verbatimSymlinks: true, - preserveTimestamps: true, - }); - await fs.promises.rm(contentCacheFolder, { force: true, recursive: true }); + await fs.promises.rm(contentFolder, { force: true, recursive: true }) + await fs.promises.cp(contentCacheFolder, contentFolder, { + force: true, + recursive: true, + verbatimSymlinks: true, + preserveTimestamps: true, + }) + await fs.promises.rm(contentCacheFolder, { force: true, recursive: true }) } diff --git a/quartz/components/ArticleTitle.tsx b/quartz/components/ArticleTitle.tsx index 91d52f14..318aeb24 100644 --- a/quartz/components/ArticleTitle.tsx +++ b/quartz/components/ArticleTitle.tsx @@ -1,32 +1,19 @@ -import { FileTitleIcon,IconFolderOptions } from "../plugins/components/FileIcons"; -import { classNames } from "../util/lang"; -import { QuartzComponentConstructor, QuartzComponentProps } from "./types"; +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" +import { classNames } from "../util/lang" -export default ((opts?: Partial) => { - function ArticleTitle(props: QuartzComponentProps) { - const { displayClass, fileData } = props; - let title = fileData.frontmatter?.title ?? fileData.slug; - if (title === "index") { - const path = fileData.slug?.split("/"); - title = path?.[path.length - 2].replaceAll("-", " ") ?? "index"; - } - const iconType = (fileData.frontmatter?.icon as string) || opts?.default?.file; - if (title) { - if (!opts?.rootIconFolder || !iconType) { - return

{title}

; - } - return ( - - ); - } else { - return null; - } - } - - ArticleTitle.css = ` - .article-title { - margin: 2rem 0 0 0; +const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { + const title = fileData.frontmatter?.title + if (title) { + return

{title}

+ } else { + return null } - `; - return ArticleTitle; -}) satisfies QuartzComponentConstructor; +} + +ArticleTitle.css = ` +.article-title { + margin: 2rem 0 0 0; +} +` + +export default (() => ArticleTitle) satisfies QuartzComponentConstructor diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx index aa412a2e..e99055e3 100644 --- a/quartz/components/Backlinks.tsx +++ b/quartz/components/Backlinks.tsx @@ -4,33 +4,49 @@ import { resolveRelative, simplifySlug } from "../util/path" import { i18n } from "../i18n" import { classNames } from "../util/lang" -const Backlinks: QuartzComponent = ({ - fileData, - allFiles, - displayClass, - cfg, -}: QuartzComponentProps) => { - const slug = simplifySlug(fileData.slug!) - const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug)) - return ( -
-

{i18n(cfg.locale).components.backlinks.title}

-
    - {backlinkFiles.length > 0 ? ( - backlinkFiles.map((f) => ( -
  • - - {f.frontmatter?.title} - -
  • - )) - ) : ( -
  • {i18n(cfg.locale).components.backlinks.noBacklinksFound}
  • - )} -
-
- ) +interface BacklinksOptions { + hideWhenEmpty: boolean } -Backlinks.css = style -export default (() => Backlinks) satisfies QuartzComponentConstructor +const defaultOptions: BacklinksOptions = { + hideWhenEmpty: true, +} + +export default ((opts?: Partial) => { + const options: BacklinksOptions = { ...defaultOptions, ...opts } + + const Backlinks: QuartzComponent = ({ + fileData, + allFiles, + displayClass, + cfg, + }: QuartzComponentProps) => { + const slug = simplifySlug(fileData.slug!) + const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug)) + if (options.hideWhenEmpty && backlinkFiles.length == 0) { + return null + } + return ( +
+

{i18n(cfg.locale).components.backlinks.title}

+
    + {backlinkFiles.length > 0 ? ( + backlinkFiles.map((f) => ( +
  • + + {f.frontmatter?.title} + +
  • + )) + ) : ( +
  • {i18n(cfg.locale).components.backlinks.noBacklinksFound}
  • + )} +
+
+ ) + } + + Backlinks.css = style + + return Backlinks +}) satisfies QuartzComponentConstructor diff --git a/quartz/components/Comments.tsx b/quartz/components/Comments.tsx index 8e449402..0bfd82d2 100644 --- a/quartz/components/Comments.tsx +++ b/quartz/components/Comments.tsx @@ -10,6 +10,9 @@ type Options = { repoId: string category: string categoryId: string + themeUrl?: string + lightTheme?: string + darkTheme?: string mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname" strict?: boolean reactionsEnabled?: boolean @@ -22,7 +25,15 @@ function boolToStringBool(b: boolean): string { } export default ((opts: Options) => { - const Comments: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => { + const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => { + // check if comments should be displayed according to frontmatter + const disableComment: boolean = + typeof fileData.frontmatter?.comments !== "undefined" && + (!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false") + if (disableComment) { + return <> + } + return (
{ data-strict={boolToStringBool(opts.options.strict ?? true)} data-reactions-enabled={boolToStringBool(opts.options.reactionsEnabled ?? true)} data-input-position={opts.options.inputPosition ?? "bottom"} + data-light-theme={opts.options.lightTheme ?? "light"} + data-dark-theme={opts.options.darkTheme ?? "dark"} + data-theme-url={ + opts.options.themeUrl ?? `https://${cfg.baseUrl ?? "example.com"}/static/giscus` + } >
) } diff --git a/quartz/components/ContentMeta.tsx b/quartz/components/ContentMeta.tsx index 5dfec144..e378bcce 100644 --- a/quartz/components/ContentMeta.tsx +++ b/quartz/components/ContentMeta.tsx @@ -1,4 +1,4 @@ -import { formatDate, getDate } from "./Date" +import { Date, getDate } from "./Date" import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import readingTime from "reading-time" import { classNames } from "../util/lang" @@ -30,7 +30,7 @@ export default ((opts?: Partial) => { const segments: (string | JSX.Element)[] = [] if (fileData.dates) { - segments.push(formatDate(getDate(cfg, fileData)!, cfg.locale)) + segments.push() } // Display reading time if enabled @@ -39,14 +39,12 @@ export default ((opts?: Partial) => { const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({ minutes: Math.ceil(minutes), }) - segments.push(displayedTime) + segments.push({displayedTime}) } - const segmentsElements = segments.map((segment) => {segment}) - return (

- {segmentsElements} + {segments}

) } else { diff --git a/quartz/components/Date.tsx b/quartz/components/Date.tsx index 26b59647..0a92cc4c 100644 --- a/quartz/components/Date.tsx +++ b/quartz/components/Date.tsx @@ -27,5 +27,5 @@ export function formatDate(d: Date, locale: ValidLocale = "en-US"): string { } export function Date({ date, locale }: Props) { - return <>{formatDate(date, locale)} + return } diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index 703509f6..e57d6771 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -1,13 +1,12 @@ // @ts-ignore -import { getIconForNodes, IconFolderOptions, NodesIcons } from "../plugins/components/FileIcons" import { QuartzPluginData } from "../plugins/vfile" import { - clone, - FilePath, joinSegments, resolveRelative, - SimpleSlug, + clone, simplifySlug, + SimpleSlug, + FilePath, } from "../util/path" type OrderEntries = "sort" | "filter" | "map" @@ -17,7 +16,6 @@ export interface Options { folderDefaultState: "collapsed" | "open" folderClickBehavior: "collapse" | "link" useSavedState: boolean - iconSettings?: IconFolderOptions sortFn: (a: FileNode, b: FileNode) => number filterFn: (node: FileNode) => boolean mapFn: (node: FileNode) => void @@ -49,23 +47,13 @@ export class FileNode { displayName: string file: QuartzPluginData | null depth: number - icon?: string - order?: number - constructor( - slugSegment: string, - displayName?: string, - file?: QuartzPluginData, - depth?: number, - icon?: string, - ) { + constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) { this.children = [] this.name = slugSegment this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment this.file = file ? clone(file) : null this.depth = depth ?? 0 - this.icon = icon ?? (file?.frontmatter?.icon as string) ?? undefined - this.order = file?.frontmatter?.order } private insert(fileData: DataWrapper) { @@ -80,8 +68,6 @@ export class FileNode { if (nextSegment === "") { // index case (we are the root and we just found index.md), set our data appropriately const title = fileData.file.frontmatter?.title - this.icon = (fileData.file.frontmatter?.icon as string) ?? undefined - this.order = fileData.file.frontmatter?.order if (title && title !== "index") { this.displayName = title } @@ -176,10 +162,6 @@ type ExplorerNodeProps = { fullPath?: string } -function sanitizeText(text: string) { - return text.replace(/'/g, "-") -} - export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) { // Get options const folderBehavior = opts.folderClickBehavior @@ -187,24 +169,14 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro // Calculate current folderPath const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : "" - const { iconAsSVG, hasIcon, iconPath } = getIconForNodes(node, opts.iconSettings) - const dataForSanitized = sanitizeText(node.file?.slug ?? node.name) - const isRoot = folderPath.split("/").length === 1 - const padding = 1 + folderPath.split("/").length >= 3 ? 0.5 : 1 + folderPath.split("/").length / 2 const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/" + return ( <> {node.file ? ( // Single file node
  • - - + {node.displayName}
  • @@ -213,38 +185,25 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro {node.name !== "" && ( // Node with entire folder // Render svg button + folder name, then children - -
    0}> - {node.children.length > 0 ? ( - - - - ) : null} +
    + + + {/* render tag if folderBehavior is "link", otherwise render