Merge commit '76f2664277e07a7d1b011fac236840c6e8e69fdd' into v4
This commit is contained in:
		
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -7,3 +7,5 @@ tsconfig.tsbuildinfo | ||||
| content/.obsidian/workspace.json | ||||
| .quartz-cache | ||||
| private/ | ||||
| .replit | ||||
| replit.nix | ||||
|   | ||||
							
								
								
									
										11
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| FROM node:20-slim as builder | ||||
| WORKDIR /usr/src/app | ||||
| COPY package.json . | ||||
| COPY package-lock.json* . | ||||
| RUN npm ci | ||||
|  | ||||
| FROM node:20-slim | ||||
| WORKDIR /usr/src/app | ||||
| COPY --from=builder /usr/src/app/ /usr/src/app/ | ||||
| COPY . . | ||||
| CMD ["npx", "quartz", "build", "--serve"] | ||||
							
								
								
									
										38
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										38
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -1,12 +1,12 @@ | ||||
| { | ||||
|   "name": "@jackyzha0/quartz", | ||||
|   "version": "4.0.10", | ||||
|   "version": "4.0.11", | ||||
|   "lockfileVersion": 3, | ||||
|   "requires": true, | ||||
|   "packages": { | ||||
|     "": { | ||||
|       "name": "@jackyzha0/quartz", | ||||
|       "version": "4.0.10", | ||||
|       "version": "4.0.11", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@clack/prompts": "^0.6.3", | ||||
| @@ -45,6 +45,7 @@ | ||||
|         "rehype-raw": "^6.1.1", | ||||
|         "rehype-slug": "^5.1.0", | ||||
|         "remark": "^14.0.2", | ||||
|         "remark-breaks": "^3.0.3", | ||||
|         "remark-frontmatter": "^4.0.1", | ||||
|         "remark-gfm": "^3.0.1", | ||||
|         "remark-math": "^5.1.1", | ||||
| @@ -55,6 +56,7 @@ | ||||
|         "serve-handler": "^6.1.5", | ||||
|         "source-map-support": "^0.5.21", | ||||
|         "to-vfile": "^7.2.4", | ||||
|         "toml": "^3.0.0", | ||||
|         "unified": "^10.1.2", | ||||
|         "unist-util-visit": "^4.1.2", | ||||
|         "vfile": "^5.3.7", | ||||
| @@ -3809,6 +3811,19 @@ | ||||
|         "url": "https://opencollective.com/unified" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/mdast-util-newline-to-break": { | ||||
|       "version": "1.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-1.0.0.tgz", | ||||
|       "integrity": "sha512-491LcYv3gbGhhCrLoeALncQmega2xPh+m3gbsIhVsOX4sw85+ShLFPvPyibxc1Swx/6GtzxgVodq+cGa/47ULg==", | ||||
|       "dependencies": { | ||||
|         "@types/mdast": "^3.0.0", | ||||
|         "mdast-util-find-and-replace": "^2.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
|         "url": "https://opencollective.com/unified" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/mdast-util-phrasing": { | ||||
|       "version": "3.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz", | ||||
| @@ -4902,6 +4917,20 @@ | ||||
|         "url": "https://opencollective.com/unified" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/remark-breaks": { | ||||
|       "version": "3.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-3.0.3.tgz", | ||||
|       "integrity": "sha512-C7VkvcUp1TPUc2eAYzsPdaUh8Xj4FSbQnYA5A9f80diApLZscTDeG7efiWP65W8hV2sEy3JuGVU0i6qr5D8Hug==", | ||||
|       "dependencies": { | ||||
|         "@types/mdast": "^3.0.0", | ||||
|         "mdast-util-newline-to-break": "^1.0.0", | ||||
|         "unified": "^10.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
|         "url": "https://opencollective.com/unified" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/remark-frontmatter": { | ||||
|       "version": "4.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-4.0.1.tgz", | ||||
| @@ -5548,6 +5577,11 @@ | ||||
|         "url": "https://opencollective.com/unified" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/toml": { | ||||
|       "version": "3.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", | ||||
|       "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" | ||||
|     }, | ||||
|     "node_modules/tough-cookie": { | ||||
|       "version": "4.1.3", | ||||
|       "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|   "name": "@jackyzha0/quartz", | ||||
|   "description": "🌱 publish your digital garden and notes as a website", | ||||
|   "private": true, | ||||
|   "version": "4.0.10", | ||||
|   "version": "4.1.1", | ||||
|   "type": "module", | ||||
|   "author": "jackyzha0 <j.zhao2k19@gmail.com>", | ||||
|   "license": "MIT", | ||||
| @@ -19,6 +19,7 @@ | ||||
|     "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1" | ||||
|   }, | ||||
|   "engines": { | ||||
|     "npm": ">=9.3.1", | ||||
|     "node": ">=18.14" | ||||
|   }, | ||||
|   "keywords": [ | ||||
| @@ -69,6 +70,7 @@ | ||||
|     "rehype-raw": "^6.1.1", | ||||
|     "rehype-slug": "^5.1.0", | ||||
|     "remark": "^14.0.2", | ||||
|     "remark-breaks": "^3.0.3", | ||||
|     "remark-frontmatter": "^4.0.1", | ||||
|     "remark-gfm": "^3.0.1", | ||||
|     "remark-math": "^5.1.1", | ||||
| @@ -79,6 +81,7 @@ | ||||
|     "serve-handler": "^6.1.5", | ||||
|     "source-map-support": "^0.5.21", | ||||
|     "to-vfile": "^7.2.4", | ||||
|     "toml": "^3.0.0", | ||||
|     "unified": "^10.1.2", | ||||
|     "unist-util-visit": "^4.1.2", | ||||
|     "vfile": "^5.3.7", | ||||
|   | ||||
| @@ -69,6 +69,7 @@ const config: QuartzConfig = { | ||||
|       }), | ||||
|       Plugin.Assets(), | ||||
|       Plugin.Static(), | ||||
|       Plugin.NotFoundPage(), | ||||
|     ], | ||||
|   }, | ||||
| } | ||||
|   | ||||
| @@ -41,7 +41,8 @@ const graph_cfg = { | ||||
|   }, | ||||
| }; | ||||
| export const defaultContentPageLayout: PageLayout = { | ||||
|   beforeBody: [Component.ArticleTitle(), Component.ContentMeta(), /*Component.TagList()*/], | ||||
|   beforeBody: [Component.Breadcrumbs(), | ||||
|   Component.ArticleTitle(), Component.ContentMeta(), /*Component.TagList()*/], | ||||
|   left: [ | ||||
|     Component.PageTitle(), | ||||
|     Component.MobileOnly(Component.Spacer()), | ||||
| @@ -62,6 +63,4 @@ export const defaultListPageLayout: PageLayout = { | ||||
|     Component.Darkmode(), | ||||
|   ], | ||||
|   right: [], | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
| @@ -1,550 +1,39 @@ | ||||
| #!/usr/bin/env node | ||||
| import { promises, readFileSync } from "fs" | ||||
| import yargs from "yargs" | ||||
| import path from "path" | ||||
| import { hideBin } from "yargs/helpers" | ||||
| import esbuild from "esbuild" | ||||
| import chalk from "chalk" | ||||
| import { sassPlugin } from "esbuild-sass-plugin" | ||||
| import fs from "fs" | ||||
| import { intro, isCancel, 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" | ||||
|  | ||||
| const ORIGIN_NAME = "origin" | ||||
| const UPSTREAM_NAME = "upstream" | ||||
| const QUARTZ_SOURCE_BRANCH = "v4" | ||||
| const cwd = process.cwd() | ||||
| const cacheDir = path.join(cwd, ".quartz-cache") | ||||
| const cacheFile = "./.quartz-cache/transpiled-build.mjs" | ||||
| const fp = "./quartz/build.ts" | ||||
| const { version } = JSON.parse(readFileSync("./package.json").toString()) | ||||
| const contentCacheFolder = path.join(cacheDir, "content-cache") | ||||
|  | ||||
| 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", | ||||
|   }, | ||||
| } | ||||
|  | ||||
| const SyncArgv = { | ||||
|   ...CommonArgv, | ||||
|   commit: { | ||||
|     boolean: true, | ||||
|     default: true, | ||||
|     describe: "create a git commit for your unsaved changes", | ||||
|   }, | ||||
|   push: { | ||||
|     boolean: true, | ||||
|     default: true, | ||||
|     describe: "push updates to your Quartz fork", | ||||
|   }, | ||||
|   pull: { | ||||
|     boolean: true, | ||||
|     default: true, | ||||
|     describe: "pull updates from your Quartz fork", | ||||
|   }, | ||||
| } | ||||
|  | ||||
| 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", | ||||
|   }, | ||||
|   baseDir: { | ||||
|     string: true, | ||||
|     default: "", | ||||
|     describe: "base path to serve your local server on", | ||||
|   }, | ||||
|   port: { | ||||
|     number: true, | ||||
|     default: 8080, | ||||
|     describe: "port to serve Quartz on", | ||||
|   }, | ||||
|   bundleInfo: { | ||||
|     boolean: true, | ||||
|     default: false, | ||||
|     describe: "show detailed bundle information", | ||||
|   }, | ||||
|   concurrency: { | ||||
|     number: true, | ||||
|     describe: "how many threads to use to parse notes", | ||||
|   }, | ||||
| } | ||||
|  | ||||
| function escapePath(fp) { | ||||
|   return fp | ||||
|     .replace(/\\ /g, " ") // unescape spaces | ||||
|     .replace(/^".*"$/, "$1") | ||||
|     .replace(/^'.*"$/, "$1") | ||||
|     .trim() | ||||
| } | ||||
|  | ||||
| function exitIfCancel(val) { | ||||
|   if (isCancel(val)) { | ||||
|     outro(chalk.red("Exiting")) | ||||
|     process.exit(0) | ||||
|   } else { | ||||
|     return val | ||||
|   } | ||||
| } | ||||
|  | ||||
| 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 }) | ||||
| } | ||||
|  | ||||
| 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 }) | ||||
| } | ||||
|  | ||||
| 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(`Error while pulling updates: ${out.stderr}`) | ||||
|   } | ||||
| } | ||||
| import { | ||||
|   handleBuild, | ||||
|   handleCreate, | ||||
|   handleUpdate, | ||||
|   handleRestore, | ||||
|   handleSync, | ||||
| } from "./cli/handlers.js" | ||||
| import { CommonArgv, BuildArgv, CreateArgv, SyncArgv } from "./cli/args.js" | ||||
| import { version } from "./cli/constants.js" | ||||
|  | ||||
| yargs(hideBin(process.argv)) | ||||
|   .scriptName("quartz") | ||||
|   .version(version) | ||||
|   .usage("$0 <cmd> [args]") | ||||
|   .command("create", "Initialize Quartz", CommonArgv, async (argv) => { | ||||
|     console.log() | ||||
|     intro(chalk.bgGreen.black(` Quartz v${version} `)) | ||||
|     const contentFolder = path.join(cwd, argv.directory) | ||||
|     const 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) | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     await fs.promises.unlink(path.join(contentFolder, ".gitkeep")) | ||||
|     if (setupStrategy === "copy" || setupStrategy === "symlink") { | ||||
|       const 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"), | ||||
|         `--- | ||||
| title: Welcome to Quartz | ||||
| --- | ||||
|  | ||||
| This is a blank Quartz installation. | ||||
| See the [documentation](https://quartz.jzhao.xyz) for how to get started. | ||||
| `, | ||||
|       ) | ||||
|     } | ||||
|  | ||||
|     // get a preferred link resolution strategy | ||||
|     const linkResolutionStrategy = exitIfCancel( | ||||
|       await select({ | ||||
|         message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`, | ||||
|         options: [ | ||||
|           { | ||||
|             value: "absolute", | ||||
|             label: "Treat links as absolute path", | ||||
|             hint: "for content made for Quartz 3 and Hugo", | ||||
|           }, | ||||
|           { | ||||
|             value: "shortest", | ||||
|             label: "Treat links as shortest path", | ||||
|             hint: "for most Obsidian vaults", | ||||
|           }, | ||||
|           { | ||||
|             value: "relative", | ||||
|             label: "Treat links as relative paths", | ||||
|             hint: "for just normal Markdown files", | ||||
|           }, | ||||
|         ], | ||||
|       }), | ||||
|     ) | ||||
|  | ||||
|     // 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) | ||||
|  | ||||
|     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) | ||||
| `) | ||||
|   .command("create", "Initialize Quartz", CreateArgv, async (argv) => { | ||||
|     await handleCreate(argv) | ||||
|   }) | ||||
|   .command("update", "Get the latest Quartz updates", CommonArgv, async (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.", | ||||
|     ) | ||||
|     gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH) | ||||
|     await popContentFolder(contentFolder) | ||||
|     console.log("Ensuring dependencies are up to date") | ||||
|     spawnSync("npm", ["i"], { stdio: "inherit" }) | ||||
|     console.log(chalk.green("Done!")) | ||||
|     await handleUpdate(argv) | ||||
|   }) | ||||
|   .command( | ||||
|     "restore", | ||||
|     "Try to restore your content folder from the cache", | ||||
|     CommonArgv, | ||||
|     async (argv) => { | ||||
|       const contentFolder = path.join(cwd, argv.directory) | ||||
|       await popContentFolder(contentFolder) | ||||
|       await handleRestore(argv) | ||||
|     }, | ||||
|   ) | ||||
|   .command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (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", | ||||
|       }) | ||||
|       spawnSync("git", ["add", "."], { stdio: "inherit" }) | ||||
|       spawnSync("git", ["commit", "-m", `Quartz sync: ${currentTimestamp}`], { 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.", | ||||
|       ) | ||||
|       gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH) | ||||
|     } | ||||
|  | ||||
|     await popContentFolder(contentFolder) | ||||
|     if (argv.push) { | ||||
|       console.log("Pushing your changes") | ||||
|       spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" }) | ||||
|     } | ||||
|  | ||||
|     console.log(chalk.green("Done!")) | ||||
|     await handleSync(argv) | ||||
|   }) | ||||
|   .command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => { | ||||
|     console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) | ||||
|     const ctx = await esbuild.context({ | ||||
|       entryPoints: [fp], | ||||
|       outfile: path.join("quartz", 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, | ||||
|                 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()}`) | ||||
|       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: "**/*.html", | ||||
|                 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: 3001 }) | ||||
|       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() | ||||
|     } | ||||
|     await handleBuild(argv) | ||||
|   }) | ||||
|   .showHelpOnFail(false) | ||||
|   .help() | ||||
|   | ||||
| @@ -12,6 +12,10 @@ export type Analytics = | ||||
|       provider: "google" | ||||
|       tagId: string | ||||
|     } | ||||
|   | { | ||||
|       provider: "umami" | ||||
|       websiteId: string | ||||
|     } | ||||
|  | ||||
| export interface GlobalConfiguration { | ||||
|   pageTitle: string | ||||
|   | ||||
							
								
								
									
										103
									
								
								quartz/cli/args.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								quartz/cli/args.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | ||||
| 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", | ||||
|   }, | ||||
| } | ||||
|  | ||||
| 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", | ||||
|   }, | ||||
| } | ||||
|  | ||||
| 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", | ||||
|   }, | ||||
| } | ||||
|  | ||||
| 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", | ||||
|   }, | ||||
|   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", | ||||
|   }, | ||||
| } | ||||
							
								
								
									
										15
									
								
								quartz/cli/constants.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								quartz/cli/constants.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| 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") | ||||
							
								
								
									
										512
									
								
								quartz/cli/handlers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										512
									
								
								quartz/cli/handlers.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,512 @@ | ||||
| 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 { | ||||
|   exitIfCancel, | ||||
|   escapePath, | ||||
|   gitPull, | ||||
|   popContentFolder, | ||||
|   stashContentFolder, | ||||
| } from "./helpers.js" | ||||
| import { | ||||
|   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) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   await fs.promises.unlink(path.join(contentFolder, ".gitkeep")) | ||||
|   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"), | ||||
|       `--- | ||||
| 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. You can change this later in \`quartz.config.ts\`.`, | ||||
|         options: [ | ||||
|           { | ||||
|             value: "absolute", | ||||
|             label: "Treat links as absolute path", | ||||
|             hint: "for content made for Quartz 3 and Hugo", | ||||
|           }, | ||||
|           { | ||||
|             value: "shortest", | ||||
|             label: "Treat links as shortest path", | ||||
|             hint: "for most Obsidian vaults", | ||||
|           }, | ||||
|           { | ||||
|             value: "relative", | ||||
|             label: "Treat links as relative paths", | ||||
|             hint: "for just normal Markdown files", | ||||
|           }, | ||||
|         ], | ||||
|       }), | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   // 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) | ||||
|  | ||||
|   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) | ||||
| `) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Handles `npx quartz build` | ||||
|  * @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, | ||||
|               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: "**/*.html", | ||||
|               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() | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Handles `npx quartz update` | ||||
|  * @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.", | ||||
|   ) | ||||
|   gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH) | ||||
|   await popContentFolder(contentFolder) | ||||
|   console.log("Ensuring dependencies are up to date") | ||||
|   spawnSync("npm", ["i"], { stdio: "inherit" }) | ||||
|   console.log(chalk.green("Done!")) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Handles `npx quartz restore` | ||||
|  * @param {*} argv arguments for `restore` | ||||
|  */ | ||||
| export async function handleRestore(argv) { | ||||
|   const contentFolder = path.join(cwd, argv.directory) | ||||
|   await popContentFolder(contentFolder) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Handles `npx quartz sync` | ||||
|  * @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.", | ||||
|     ) | ||||
|     gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH) | ||||
|   } | ||||
|  | ||||
|   await popContentFolder(contentFolder) | ||||
|   if (argv.push) { | ||||
|     console.log("Pushing your changes") | ||||
|     spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" }) | ||||
|   } | ||||
|  | ||||
|   console.log(chalk.green("Done!")) | ||||
| } | ||||
							
								
								
									
										52
									
								
								quartz/cli/helpers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								quartz/cli/helpers.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| 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() | ||||
| } | ||||
|  | ||||
| export function exitIfCancel(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 }) | ||||
| } | ||||
|  | ||||
| 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(`Error while pulling updates: ${out.stderr}`) | ||||
|   } | ||||
| } | ||||
|  | ||||
| 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 }) | ||||
| } | ||||
| @@ -1,9 +1,9 @@ | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
|  | ||||
| function ArticleTitle({ fileData }: QuartzComponentProps) { | ||||
| function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) { | ||||
|   const title = fileData.frontmatter?.title | ||||
|   if (title) { | ||||
|     return <h1 class="article-title">{title}</h1> | ||||
|     return <h1 class={`article-title ${displayClass ?? ""}`}>{title}</h1> | ||||
|   } else { | ||||
|     return null | ||||
|   } | ||||
|   | ||||
| @@ -2,11 +2,11 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
| import style from "./styles/backlinks.scss" | ||||
| import { resolveRelative, simplifySlug } from "../util/path" | ||||
|  | ||||
| function Backlinks({ fileData, allFiles }: QuartzComponentProps) { | ||||
| function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) { | ||||
|   const slug = simplifySlug(fileData.slug!) | ||||
|   const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug)) | ||||
|   return ( | ||||
|     <div class="backlinks"> | ||||
|     <div class={`backlinks ${displayClass ?? ""}`}> | ||||
|       <h3>Backlinks</h3> | ||||
|       <ul class="overflow"> | ||||
|         {backlinkFiles.length > 0 ? ( | ||||
|   | ||||
							
								
								
									
										116
									
								
								quartz/components/Breadcrumbs.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								quartz/components/Breadcrumbs.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
| import breadcrumbsStyle from "./styles/breadcrumbs.scss" | ||||
| import { FullSlug, SimpleSlug, resolveRelative } from "../util/path" | ||||
| import { QuartzPluginData } from "../plugins/vfile" | ||||
|  | ||||
| type CrumbData = { | ||||
|   displayName: string | ||||
|   path: string | ||||
| } | ||||
|  | ||||
| interface BreadcrumbOptions { | ||||
|   /** | ||||
|    * Symbol between crumbs | ||||
|    */ | ||||
|   spacerSymbol: string | ||||
|   /** | ||||
|    * Name of first crumb | ||||
|    */ | ||||
|   rootName: string | ||||
|   /** | ||||
|    * wether to look up frontmatter title for folders (could cause performance problems with big vaults) | ||||
|    */ | ||||
|   resolveFrontmatterTitle: boolean | ||||
|   /** | ||||
|    * Wether to display breadcrumbs on root `index.md` | ||||
|    */ | ||||
|   hideOnRoot: boolean | ||||
| } | ||||
|  | ||||
| const defaultOptions: BreadcrumbOptions = { | ||||
|   spacerSymbol: "❯", | ||||
|   rootName: "Home", | ||||
|   resolveFrontmatterTitle: true, | ||||
|   hideOnRoot: true, | ||||
| } | ||||
|  | ||||
| function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData { | ||||
|   return { | ||||
|     displayName: displayName.replaceAll("-", " "), | ||||
|     path: resolveRelative(baseSlug, currentSlug), | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default ((opts?: Partial<BreadcrumbOptions>) => { | ||||
|   // Merge options with defaults | ||||
|   const options: BreadcrumbOptions = { ...defaultOptions, ...opts } | ||||
|  | ||||
|   // computed index of folder name to its associated file data | ||||
|   let folderIndex: Map<string, QuartzPluginData> | undefined | ||||
|  | ||||
|   function Breadcrumbs({ fileData, allFiles, displayClass }: QuartzComponentProps) { | ||||
|     // Hide crumbs on root if enabled | ||||
|     if (options.hideOnRoot && fileData.slug === "index") { | ||||
|       return <></> | ||||
|     } | ||||
|  | ||||
|     // Format entry for root element | ||||
|     const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug) | ||||
|     const crumbs: CrumbData[] = [firstEntry] | ||||
|  | ||||
|     if (!folderIndex && options.resolveFrontmatterTitle) { | ||||
|       folderIndex = new Map() | ||||
|       // construct the index for the first time | ||||
|       for (const file of allFiles) { | ||||
|         if (file.slug?.endsWith("index")) { | ||||
|           const folderParts = file.filePath?.split("/") | ||||
|           if (folderParts) { | ||||
|             const folderName = folderParts[folderParts?.length - 2] | ||||
|             folderIndex.set(folderName, file) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Split slug into hierarchy/parts | ||||
|     const slugParts = fileData.slug?.split("/") | ||||
|     if (slugParts) { | ||||
|       // full path until current part | ||||
|       let currentPath = "" | ||||
|       for (let i = 0; i < slugParts.length - 1; i++) { | ||||
|         let curPathSegment = slugParts[i] | ||||
|  | ||||
|         // Try to resolve frontmatter folder title | ||||
|         const currentFile = folderIndex?.get(curPathSegment) | ||||
|         if (currentFile) { | ||||
|           curPathSegment = currentFile.frontmatter!.title | ||||
|         } | ||||
|  | ||||
|         // Add current slug to full path | ||||
|         currentPath += slugParts[i] + "/" | ||||
|  | ||||
|         // Format and add current crumb | ||||
|         const crumb = formatCrumb(curPathSegment, fileData.slug!, currentPath as SimpleSlug) | ||||
|         crumbs.push(crumb) | ||||
|       } | ||||
|  | ||||
|       // Add current file to crumb (can directly use frontmatter title) | ||||
|       crumbs.push({ | ||||
|         displayName: fileData.frontmatter!.title, | ||||
|         path: "", | ||||
|       }) | ||||
|     } | ||||
|     return ( | ||||
|       <nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs"> | ||||
|         {crumbs.map((crumb, index) => ( | ||||
|           <div class="breadcrumb-element"> | ||||
|             <a href={crumb.path}>{crumb.displayName}</a> | ||||
|             {index !== crumbs.length - 1 && <p>{` ${options.spacerSymbol} `}</p>} | ||||
|           </div> | ||||
|         ))} | ||||
|       </nav> | ||||
|     ) | ||||
|   } | ||||
|   Breadcrumbs.css = breadcrumbsStyle | ||||
|   return Breadcrumbs | ||||
| }) satisfies QuartzComponentConstructor | ||||
| @@ -3,7 +3,7 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
| import readingTime from "reading-time" | ||||
|  | ||||
| export default (() => { | ||||
|   function ContentMetadata({ cfg, fileData }: QuartzComponentProps) { | ||||
|   function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) { | ||||
|     const text = fileData.text | ||||
|     if (text) { | ||||
|       const segments: string[] = [] | ||||
| @@ -14,7 +14,7 @@ export default (() => { | ||||
|       } | ||||
|  | ||||
|       segments.push(timeTaken) | ||||
|       return <p class="content-meta">{segments.join(", ")}</p> | ||||
|       return <p class={`content-meta ${displayClass ?? ""}`}>{segments.join(", ")}</p> | ||||
|     } else { | ||||
|       return null | ||||
|     } | ||||
|   | ||||
| @@ -3,11 +3,11 @@ | ||||
| // see: https://v8.dev/features/modules#defer | ||||
| import darkmodeScript from "./scripts/darkmode.inline" | ||||
| import styles from "./styles/darkmode.scss" | ||||
| import { QuartzComponentConstructor } from "./types" | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
|  | ||||
| function Darkmode() { | ||||
| function Darkmode({ displayClass }: QuartzComponentProps) { | ||||
|   return ( | ||||
|     <div class="darkmode"> | ||||
|     <div class={`darkmode ${displayClass ?? ""}`}> | ||||
|       <input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} /> | ||||
|       <label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}> | ||||
|         <svg | ||||
|   | ||||
							
								
								
									
										126
									
								
								quartz/components/Explorer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								quartz/components/Explorer.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,126 @@ | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
| import explorerStyle from "./styles/explorer.scss" | ||||
|  | ||||
| // @ts-ignore | ||||
| import script from "./scripts/explorer.inline" | ||||
| import { ExplorerNode, FileNode, Options } from "./ExplorerNode" | ||||
| import { QuartzPluginData } from "../plugins/vfile" | ||||
|  | ||||
| // Options interface defined in `ExplorerNode` to avoid circular dependency | ||||
| const defaultOptions = { | ||||
|   title: "Explorer", | ||||
|   folderClickBehavior: "collapse", | ||||
|   folderDefaultState: "collapsed", | ||||
|   useSavedState: true, | ||||
|   sortFn: (a, b) => { | ||||
|     // Sort order: folders first, then files. Sort folders and files alphabetically | ||||
|     if ((!a.file && !b.file) || (a.file && b.file)) { | ||||
|       // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" | ||||
|       // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A | ||||
|       return a.displayName.localeCompare(b.displayName, undefined, { | ||||
|         numeric: true, | ||||
|         sensitivity: "base", | ||||
|       }) | ||||
|     } | ||||
|     if (a.file && !b.file) { | ||||
|       return 1 | ||||
|     } else { | ||||
|       return -1 | ||||
|     } | ||||
|   }, | ||||
|   filterFn: (node) => node.name !== "tags", | ||||
|   order: ["filter", "map", "sort"], | ||||
| } satisfies Options | ||||
|  | ||||
| export default ((userOpts?: Partial<Options>) => { | ||||
|   // Parse config | ||||
|   const opts: Options = { ...defaultOptions, ...userOpts } | ||||
|  | ||||
|   // memoized | ||||
|   let fileTree: FileNode | ||||
|   let jsonTree: string | ||||
|  | ||||
|   function constructFileTree(allFiles: QuartzPluginData[]) { | ||||
|     if (!fileTree) { | ||||
|       // Construct tree from allFiles | ||||
|       fileTree = new FileNode("") | ||||
|       allFiles.forEach((file) => fileTree.add(file, 1)) | ||||
|  | ||||
|       /** | ||||
|        * Keys of this object must match corresponding function name of `FileNode`, | ||||
|        * while values must be the argument that will be passed to the function. | ||||
|        * | ||||
|        * e.g. entry for FileNode.sort: `sort: opts.sortFn` (value is sort function from options) | ||||
|        */ | ||||
|       const functions = { | ||||
|         map: opts.mapFn, | ||||
|         sort: opts.sortFn, | ||||
|         filter: opts.filterFn, | ||||
|       } | ||||
|  | ||||
|       // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied) | ||||
|       if (opts.order) { | ||||
|         // Order is important, use loop with index instead of order.map() | ||||
|         for (let i = 0; i < opts.order.length; i++) { | ||||
|           const functionName = opts.order[i] | ||||
|           if (functions[functionName]) { | ||||
|             // for every entry in order, call matching function in FileNode and pass matching argument | ||||
|             // e.g. i = 0; functionName = "filter" | ||||
|             // converted to: (if opts.filterFn) => fileTree.filter(opts.filterFn) | ||||
|  | ||||
|             // @ts-ignore | ||||
|             // typescript cant statically check these dynamic references, so manually make sure reference is valid and ignore warning | ||||
|             fileTree[functionName].call(fileTree, functions[functionName]) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       // Get all folders of tree. Initialize with collapsed state | ||||
|       const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") | ||||
|  | ||||
|       // Stringify to pass json tree as data attribute ([data-tree]) | ||||
|       jsonTree = JSON.stringify(folders) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { | ||||
|     constructFileTree(allFiles) | ||||
|     return ( | ||||
|       <div class={`explorer ${displayClass ?? ""}`}> | ||||
|         <button | ||||
|           type="button" | ||||
|           id="explorer" | ||||
|           data-behavior={opts.folderClickBehavior} | ||||
|           data-collapsed={opts.folderDefaultState} | ||||
|           data-savestate={opts.useSavedState} | ||||
|           data-tree={jsonTree} | ||||
|         > | ||||
|           <h1>{opts.title}</h1> | ||||
|           <svg | ||||
|             xmlns="http://www.w3.org/2000/svg" | ||||
|             width="14" | ||||
|             height="14" | ||||
|             viewBox="5 8 14 8" | ||||
|             fill="none" | ||||
|             stroke="currentColor" | ||||
|             stroke-width="2" | ||||
|             stroke-linecap="round" | ||||
|             stroke-linejoin="round" | ||||
|             class="fold" | ||||
|           > | ||||
|             <polyline points="6 9 12 15 18 9"></polyline> | ||||
|           </svg> | ||||
|         </button> | ||||
|         <div id="explorer-content"> | ||||
|           <ul class="overflow" id="explorer-ul"> | ||||
|             <ExplorerNode node={fileTree} opts={opts} fileData={fileData} /> | ||||
|             <li id="explorer-end" /> | ||||
|           </ul> | ||||
|         </div> | ||||
|       </div> | ||||
|     ) | ||||
|   } | ||||
|   Explorer.css = explorerStyle | ||||
|   Explorer.afterDOMLoaded = script | ||||
|   return Explorer | ||||
| }) satisfies QuartzComponentConstructor | ||||
							
								
								
									
										224
									
								
								quartz/components/ExplorerNode.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								quartz/components/ExplorerNode.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,224 @@ | ||||
| // @ts-ignore | ||||
| import { QuartzPluginData } from "../plugins/vfile" | ||||
| import { resolveRelative } from "../util/path" | ||||
|  | ||||
| type OrderEntries = "sort" | "filter" | "map" | ||||
|  | ||||
| export interface Options { | ||||
|   title: string | ||||
|   folderDefaultState: "collapsed" | "open" | ||||
|   folderClickBehavior: "collapse" | "link" | ||||
|   useSavedState: boolean | ||||
|   sortFn: (a: FileNode, b: FileNode) => number | ||||
|   filterFn?: (node: FileNode) => boolean | ||||
|   mapFn?: (node: FileNode) => void | ||||
|   order?: OrderEntries[] | ||||
| } | ||||
|  | ||||
| type DataWrapper = { | ||||
|   file: QuartzPluginData | ||||
|   path: string[] | ||||
| } | ||||
|  | ||||
| export type FolderState = { | ||||
|   path: string | ||||
|   collapsed: boolean | ||||
| } | ||||
|  | ||||
| // Structure to add all files into a tree | ||||
| export class FileNode { | ||||
|   children: FileNode[] | ||||
|   name: string | ||||
|   displayName: string | ||||
|   file: QuartzPluginData | null | ||||
|   depth: number | ||||
|  | ||||
|   constructor(name: string, file?: QuartzPluginData, depth?: number) { | ||||
|     this.children = [] | ||||
|     this.name = name | ||||
|     this.displayName = name | ||||
|     this.file = file ? structuredClone(file) : null | ||||
|     this.depth = depth ?? 0 | ||||
|   } | ||||
|  | ||||
|   private insert(file: DataWrapper) { | ||||
|     if (file.path.length === 1) { | ||||
|       if (file.path[0] !== "index.md") { | ||||
|         this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1)) | ||||
|       } else { | ||||
|         const title = file.file.frontmatter?.title | ||||
|         if (title && title !== "index" && file.path[0] === "index.md") { | ||||
|           this.displayName = title | ||||
|         } | ||||
|       } | ||||
|     } else { | ||||
|       const next = file.path[0] | ||||
|       file.path = file.path.splice(1) | ||||
|       for (const child of this.children) { | ||||
|         if (child.name === next) { | ||||
|           child.insert(file) | ||||
|           return | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       const newChild = new FileNode(next, undefined, this.depth + 1) | ||||
|       newChild.insert(file) | ||||
|       this.children.push(newChild) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Add new file to tree | ||||
|   add(file: QuartzPluginData, splice: number = 0) { | ||||
|     this.insert({ file, path: file.filePath!.split("/").splice(splice) }) | ||||
|   } | ||||
|  | ||||
|   // Print tree structure (for debugging) | ||||
|   print(depth: number = 0) { | ||||
|     let folderChar = "" | ||||
|     if (!this.file) folderChar = "|" | ||||
|     console.log("-".repeat(depth), folderChar, this.name, this.depth) | ||||
|     this.children.forEach((e) => e.print(depth + 1)) | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place | ||||
|    * @param filterFn function to filter tree with | ||||
|    */ | ||||
|   filter(filterFn: (node: FileNode) => boolean) { | ||||
|     this.children = this.children.filter(filterFn) | ||||
|     this.children.forEach((child) => child.filter(filterFn)) | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place | ||||
|    * @param mapFn function to use for mapping over tree | ||||
|    */ | ||||
|   map(mapFn: (node: FileNode) => void) { | ||||
|     mapFn(this) | ||||
|  | ||||
|     this.children.forEach((child) => child.map(mapFn)) | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get folder representation with state of tree. | ||||
|    * Intended to only be called on root node before changes to the tree are made | ||||
|    * @param collapsed default state of folders (collapsed by default or not) | ||||
|    * @returns array containing folder state for tree | ||||
|    */ | ||||
|   getFolderPaths(collapsed: boolean): FolderState[] { | ||||
|     const folderPaths: FolderState[] = [] | ||||
|  | ||||
|     const traverse = (node: FileNode, currentPath: string) => { | ||||
|       if (!node.file) { | ||||
|         const folderPath = currentPath + (currentPath ? "/" : "") + node.name | ||||
|         if (folderPath !== "") { | ||||
|           folderPaths.push({ path: folderPath, collapsed }) | ||||
|         } | ||||
|         node.children.forEach((child) => traverse(child, folderPath)) | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     traverse(this, "") | ||||
|  | ||||
|     return folderPaths | ||||
|   } | ||||
|  | ||||
|   // Sort order: folders first, then files. Sort folders and files alphabetically | ||||
|   /** | ||||
|    * Sorts tree according to sort/compare function | ||||
|    * @param sortFn compare function used for `.sort()`, also used recursively for children | ||||
|    */ | ||||
|   sort(sortFn: (a: FileNode, b: FileNode) => number) { | ||||
|     this.children = this.children.sort(sortFn) | ||||
|     this.children.forEach((e) => e.sort(sortFn)) | ||||
|   } | ||||
| } | ||||
|  | ||||
| type ExplorerNodeProps = { | ||||
|   node: FileNode | ||||
|   opts: Options | ||||
|   fileData: QuartzPluginData | ||||
|   fullPath?: string | ||||
| } | ||||
|  | ||||
| export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) { | ||||
|   // Get options | ||||
|   const folderBehavior = opts.folderClickBehavior | ||||
|   const isDefaultOpen = opts.folderDefaultState === "open" | ||||
|  | ||||
|   // Calculate current folderPath | ||||
|   let pathOld = fullPath ? fullPath : "" | ||||
|   let folderPath = "" | ||||
|   if (node.name !== "") { | ||||
|     folderPath = `${pathOld}/${node.name}` | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <li> | ||||
|       {node.file ? ( | ||||
|         // Single file node | ||||
|         <li key={node.file.slug}> | ||||
|           <a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}> | ||||
|             {node.displayName} | ||||
|           </a> | ||||
|         </li> | ||||
|       ) : ( | ||||
|         <div> | ||||
|           {node.name !== "" && ( | ||||
|             // Node with entire folder | ||||
|             // Render svg button + folder name, then children | ||||
|             <div class="folder-container"> | ||||
|               <svg | ||||
|                 xmlns="http://www.w3.org/2000/svg" | ||||
|                 width="12" | ||||
|                 height="12" | ||||
|                 viewBox="5 8 14 8" | ||||
|                 fill="none" | ||||
|                 stroke="currentColor" | ||||
|                 stroke-width="2" | ||||
|                 stroke-linecap="round" | ||||
|                 stroke-linejoin="round" | ||||
|                 class="folder-icon" | ||||
|               > | ||||
|                 <polyline points="6 9 12 15 18 9"></polyline> | ||||
|               </svg> | ||||
|               {/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */} | ||||
|               <div key={node.name} data-folderpath={folderPath}> | ||||
|                 {folderBehavior === "link" ? ( | ||||
|                   <a href={`${folderPath}`} data-for={node.name} class="folder-title"> | ||||
|                     {node.displayName} | ||||
|                   </a> | ||||
|                 ) : ( | ||||
|                   <button class="folder-button"> | ||||
|                     <p class="folder-title">{node.displayName}</p> | ||||
|                   </button> | ||||
|                 )} | ||||
|               </div> | ||||
|             </div> | ||||
|           )} | ||||
|           {/* Recursively render children of folder */} | ||||
|           <div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}> | ||||
|             <ul | ||||
|               // Inline style for left folder paddings | ||||
|               style={{ | ||||
|                 paddingLeft: node.name !== "" ? "1.4rem" : "0", | ||||
|               }} | ||||
|               class="content" | ||||
|               data-folderul={folderPath} | ||||
|             > | ||||
|               {node.children.map((childNode, i) => ( | ||||
|                 <ExplorerNode | ||||
|                   node={childNode} | ||||
|                   key={i} | ||||
|                   opts={opts} | ||||
|                   fullPath={folderPath} | ||||
|                   fileData={fileData} | ||||
|                 /> | ||||
|               ))} | ||||
|             </ul> | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|     </li> | ||||
|   ) | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { QuartzComponentConstructor } from "./types" | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
| import style from "./styles/footer.scss" | ||||
| import { version } from "../../package.json" | ||||
|  | ||||
| @@ -7,11 +7,11 @@ interface Options { | ||||
| } | ||||
|  | ||||
| export default ((opts?: Options) => { | ||||
|   function Footer() { | ||||
|   function Footer({ displayClass }: QuartzComponentProps) { | ||||
|     const year = new Date().getFullYear() | ||||
|     const links = opts?.links ?? [] | ||||
|     return ( | ||||
|       <footer> | ||||
|       <footer class={`${displayClass ?? ""}`}> | ||||
|         <hr /> | ||||
|         <p> | ||||
|           Created with <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, © {year} | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { QuartzComponentConstructor } from "./types" | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
| // @ts-ignore | ||||
| import script from "./scripts/graph.inline" | ||||
| import style from "./styles/graph.scss" | ||||
| @@ -13,6 +13,8 @@ export interface D3Config { | ||||
|   linkDistance: number | ||||
|   fontSize: number | ||||
|   opacityScale: number | ||||
|   removeTags: string[] | ||||
|   showTags: boolean | ||||
| } | ||||
|  | ||||
| interface GraphOptions { | ||||
| @@ -31,6 +33,8 @@ const defaultOptions: GraphOptions = { | ||||
|     linkDistance: 30, | ||||
|     fontSize: 0.6, | ||||
|     opacityScale: 1, | ||||
|     showTags: true, | ||||
|     removeTags: [], | ||||
|   }, | ||||
|   globalGraph: { | ||||
|     drag: true, | ||||
| @@ -42,15 +46,17 @@ const defaultOptions: GraphOptions = { | ||||
|     linkDistance: 30, | ||||
|     fontSize: 0.6, | ||||
|     opacityScale: 1, | ||||
|     showTags: true, | ||||
|     removeTags: [], | ||||
|   }, | ||||
| } | ||||
|  | ||||
| export default ((opts?: GraphOptions) => { | ||||
|   function Graph() { | ||||
|   function Graph({ displayClass }: QuartzComponentProps) { | ||||
|     const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph } | ||||
|     const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph } | ||||
|     return ( | ||||
|       <div class="graph"> | ||||
|       <div class={`graph ${displayClass ?? ""}`}> | ||||
|         <h3>Graph View</h3> | ||||
|         <div class="graph-outer"> | ||||
|           <div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div> | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { joinSegments, pathToRoot } from "../util/path" | ||||
| import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path" | ||||
| import { JSResourceToScriptElement } from "../util/resources" | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
|  | ||||
| @@ -7,7 +7,11 @@ export default (() => { | ||||
|     const title = fileData.frontmatter?.title ?? "Untitled" | ||||
|     const description = fileData.description?.trim() ?? "No description provided" | ||||
|     const { css, js } = externalResources | ||||
|     const baseDir = pathToRoot(fileData.slug!) | ||||
|  | ||||
|     const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) | ||||
|     const path = url.pathname as FullSlug | ||||
|     const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!) | ||||
|  | ||||
|     const iconPath = joinSegments(baseDir, "static/icon.png") | ||||
|     const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png` | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import { pathToRoot } from "../util/path" | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
|  | ||||
| function PageTitle({ fileData, cfg }: QuartzComponentProps) { | ||||
| function PageTitle({ fileData, cfg, displayClass }: QuartzComponentProps) { | ||||
|   const title = cfg?.pageTitle ?? "Untitled Quartz" | ||||
|   const baseDir = pathToRoot(fileData.slug!) | ||||
|   return ( | ||||
|     <h1 class="page-title"> | ||||
|     <h1 class={`page-title ${displayClass ?? ""}`}> | ||||
|       <a href={baseDir}>{title}</a> | ||||
|     </h1> | ||||
|   ) | ||||
|   | ||||
| @@ -23,13 +23,12 @@ const defaultOptions = (cfg: GlobalConfiguration): Options => ({ | ||||
| }) | ||||
|  | ||||
| export default ((userOpts?: Partial<Options>) => { | ||||
|   function RecentNotes(props: QuartzComponentProps) { | ||||
|     const { allFiles, fileData, displayClass, cfg } = props | ||||
|   function RecentNotes({ allFiles, fileData, displayClass, cfg }: QuartzComponentProps) { | ||||
|     const opts = { ...defaultOptions(cfg), ...userOpts } | ||||
|     const pages = allFiles.filter(opts.filter).sort(opts.sort) | ||||
|     const remaining = Math.max(0, pages.length - opts.limit) | ||||
|     return ( | ||||
|       <div class={`recent-notes ${displayClass}`}> | ||||
|       <div class={`recent-notes ${displayClass ?? ""}`}> | ||||
|         <h3>{opts.title}</h3> | ||||
|         <ul class="recent-ul"> | ||||
|           {pages.slice(0, opts.limit).map((page) => { | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import { QuartzComponentConstructor } from "./types" | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
| import style from "./styles/search.scss" | ||||
| // @ts-ignore | ||||
| import script from "./scripts/search.inline" | ||||
|  | ||||
| export default (() => { | ||||
|   function Search() { | ||||
|   function Search({ displayClass }: QuartzComponentProps) { | ||||
|     return ( | ||||
|       <div class="search"> | ||||
|       <div class={`search ${displayClass ?? ""}`}> | ||||
|         <div id="search-icon"> | ||||
|           <p>Search</p> | ||||
|           <div></div> | ||||
|   | ||||
| @@ -1,8 +1,7 @@ | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
|  | ||||
| function Spacer({ displayClass }: QuartzComponentProps) { | ||||
|   const className = displayClass ? `spacer ${displayClass}` : "spacer" | ||||
|   return <div class={className}></div> | ||||
|   return <div class={`spacer ${displayClass ?? ""}`}></div> | ||||
| } | ||||
|  | ||||
| export default (() => Spacer) satisfies QuartzComponentConstructor | ||||
|   | ||||
| @@ -19,8 +19,8 @@ function TableOfContents({ fileData, displayClass }: QuartzComponentProps) { | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div class={`toc ${displayClass}`}> | ||||
|       <button type="button" id="toc"> | ||||
|     <div class={`toc ${displayClass ?? ""}`}> | ||||
|       <button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}> | ||||
|         <h3>Table of Contents</h3> | ||||
|         <svg | ||||
|           xmlns="http://www.w3.org/2000/svg" | ||||
| @@ -60,7 +60,7 @@ function LegacyTableOfContents({ fileData }: QuartzComponentProps) { | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <details id="toc" open> | ||||
|     <details id="toc" open={!fileData.collapseToc}> | ||||
|       <summary> | ||||
|         <h3>Table of Contents</h3> | ||||
|       </summary> | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import { pathToRoot, slugTag } from "../util/path" | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "./types" | ||||
|  | ||||
| function TagList({ fileData }: QuartzComponentProps) { | ||||
| function TagList({ fileData, displayClass }: QuartzComponentProps) { | ||||
|   const tags = fileData.frontmatter?.tags | ||||
|   const baseDir = pathToRoot(fileData.slug!) | ||||
|   if (tags && tags.length > 0) { | ||||
|     return ( | ||||
|       <ul class="tags"> | ||||
|       <ul class={`tags ${displayClass ?? ""}`}> | ||||
|         {tags.map((tag) => { | ||||
|           const display = `#${tag}` | ||||
|           const linkDest = baseDir + `/tags/${slugTag(tag)}` | ||||
| @@ -32,6 +32,12 @@ TagList.css = ` | ||||
|   padding-left: 0; | ||||
|   gap: 0.4rem; | ||||
|   margin: 1rem 0; | ||||
|   flex-wrap: wrap; | ||||
|   justify-self: end; | ||||
| } | ||||
|  | ||||
| .section-li > .section > .tags { | ||||
|   justify-content: flex-end; | ||||
| } | ||||
|    | ||||
| .tags > li { | ||||
| @@ -41,7 +47,7 @@ TagList.css = ` | ||||
|   overflow-wrap: normal; | ||||
| } | ||||
|  | ||||
| a.tag-link { | ||||
| a.internal.tag-link { | ||||
|   border-radius: 8px; | ||||
|   background-color: var(--highlight); | ||||
|   padding: 0.2rem 0.4rem; | ||||
|   | ||||
| @@ -1,13 +1,15 @@ | ||||
| import ArticleTitle from "./ArticleTitle" | ||||
| import Content from "./pages/Content" | ||||
| import TagContent from "./pages/TagContent" | ||||
| import FolderContent from "./pages/FolderContent" | ||||
| import NotFound from "./pages/404" | ||||
| import ArticleTitle from "./ArticleTitle" | ||||
| import Darkmode from "./Darkmode" | ||||
| import Head from "./Head" | ||||
| import PageTitle from "./PageTitle" | ||||
| import ContentMeta from "./ContentMeta" | ||||
| import Spacer from "./Spacer" | ||||
| import TableOfContents from "./TableOfContents" | ||||
| import Explorer from "./Explorer" | ||||
| import TagList from "./TagList" | ||||
| import Graph from "./Graph" | ||||
| import Backlinks from "./Backlinks" | ||||
| @@ -16,6 +18,7 @@ import Footer from "./Footer" | ||||
| import DesktopOnly from "./DesktopOnly" | ||||
| import MobileOnly from "./MobileOnly" | ||||
| import RecentNotes from "./RecentNotes" | ||||
| import Breadcrumbs from "./Breadcrumbs" | ||||
|  | ||||
| export { | ||||
|   ArticleTitle, | ||||
| @@ -28,6 +31,7 @@ export { | ||||
|   ContentMeta, | ||||
|   Spacer, | ||||
|   TableOfContents, | ||||
|   Explorer, | ||||
|   TagList, | ||||
|   Graph, | ||||
|   Backlinks, | ||||
| @@ -36,4 +40,6 @@ export { | ||||
|   DesktopOnly, | ||||
|   MobileOnly, | ||||
|   RecentNotes, | ||||
|   NotFound, | ||||
|   Breadcrumbs, | ||||
| } | ||||
|   | ||||
							
								
								
									
										12
									
								
								quartz/components/pages/404.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								quartz/components/pages/404.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { QuartzComponentConstructor } from "../types" | ||||
|  | ||||
| function NotFound() { | ||||
|   return ( | ||||
|     <article class="popover-hint"> | ||||
|       <h1>404</h1> | ||||
|       <p>Either this page is private or doesn't exist.</p> | ||||
|     </article> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default (() => NotFound) satisfies QuartzComponentConstructor | ||||
| @@ -1,10 +1,8 @@ | ||||
| import { htmlToJsx } from "../../util/jsx" | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "../types" | ||||
| import { Fragment, jsx, jsxs } from "preact/jsx-runtime" | ||||
| import { toJsxRuntime } from "hast-util-to-jsx-runtime" | ||||
|  | ||||
| function Content({ tree }: QuartzComponentProps) { | ||||
|   // @ts-ignore (preact makes it angry) | ||||
|   const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" }) | ||||
| function Content({ fileData, tree }: QuartzComponentProps) { | ||||
|   const content = htmlToJsx(fileData.filePath!, tree) | ||||
|   return <article class="popover-hint">{content}</article> | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "../types" | ||||
| import { Fragment, jsx, jsxs } from "preact/jsx-runtime" | ||||
| import { toJsxRuntime } from "hast-util-to-jsx-runtime" | ||||
| import path from "path" | ||||
|  | ||||
| import style from "../styles/listPage.scss" | ||||
| import { PageList } from "../PageList" | ||||
| import { _stripSlashes, simplifySlug } from "../../util/path" | ||||
| import { Root } from "hast" | ||||
| import { pluralize } from "../../util/lang" | ||||
| import { htmlToJsx } from "../../util/jsx" | ||||
|  | ||||
| function FolderContent(props: QuartzComponentProps) { | ||||
|   const { tree, fileData, allFiles } = props | ||||
| @@ -28,15 +28,14 @@ function FolderContent(props: QuartzComponentProps) { | ||||
|   const content = | ||||
|     (tree as Root).children.length === 0 | ||||
|       ? fileData.description | ||||
|       : // @ts-ignore | ||||
|         toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" }) | ||||
|       : htmlToJsx(fileData.filePath!, tree) | ||||
|  | ||||
|   return ( | ||||
|     <div class="popover-hint"> | ||||
|       <article> | ||||
|         <p>{content}</p> | ||||
|       </article> | ||||
|       <p>{allPagesInFolder.length} items under this folder.</p> | ||||
|       <p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p> | ||||
|       <div> | ||||
|         <PageList {...listProps} /> | ||||
|       </div> | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import { QuartzComponentConstructor, QuartzComponentProps } from "../types" | ||||
| import { Fragment, jsx, jsxs } from "preact/jsx-runtime" | ||||
| import { toJsxRuntime } from "hast-util-to-jsx-runtime" | ||||
| import style from "../styles/listPage.scss" | ||||
| import { PageList } from "../PageList" | ||||
| import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path" | ||||
| import { QuartzPluginData } from "../../plugins/vfile" | ||||
| import { Root } from "hast" | ||||
| import { pluralize } from "../../util/lang" | ||||
| import { htmlToJsx } from "../../util/jsx" | ||||
|  | ||||
| const numPages = 10 | ||||
| function TagContent(props: QuartzComponentProps) { | ||||
| @@ -25,8 +25,7 @@ function TagContent(props: QuartzComponentProps) { | ||||
|   const content = | ||||
|     (tree as Root).children.length === 0 | ||||
|       ? fileData.description | ||||
|       : // @ts-ignore | ||||
|         toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" }) | ||||
|       : htmlToJsx(fileData.filePath!, tree) | ||||
|  | ||||
|   if (tag === "") { | ||||
|     const tags = [...new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))] | ||||
| @@ -54,13 +53,13 @@ function TagContent(props: QuartzComponentProps) { | ||||
|             return ( | ||||
|               <div> | ||||
|                 <h2> | ||||
|                   <a class="internal tag-link" href={`./${tag}`}> | ||||
|                   <a class="internal tag-link" href={`../tags/${tag}`}> | ||||
|                     #{tag} | ||||
|                   </a> | ||||
|                 </h2> | ||||
|                 {content && <p>{content}</p>} | ||||
|                 <p> | ||||
|                   {pages.length} items with this tag.{" "} | ||||
|                   {pluralize(pages.length, "item")} with this tag.{" "} | ||||
|                   {pages.length > numPages && `Showing first ${numPages}.`} | ||||
|                 </p> | ||||
|                 <PageList limit={numPages} {...listProps} /> | ||||
| @@ -80,7 +79,7 @@ function TagContent(props: QuartzComponentProps) { | ||||
|     return ( | ||||
|       <div class="popover-hint"> | ||||
|         <article>{content}</article> | ||||
|         <p>{pages.length} items with this tag.</p> | ||||
|         <p>{pluralize(pages.length, "item")} with this tag.</p> | ||||
|         <div> | ||||
|           <PageList {...listProps} /> | ||||
|         </div> | ||||
|   | ||||
| @@ -3,7 +3,9 @@ import { QuartzComponent, QuartzComponentProps } from "./types" | ||||
| import HeaderConstructor from "./Header" | ||||
| import BodyConstructor from "./Body" | ||||
| import { JSResourceToScriptElement, StaticResources } from "../util/resources" | ||||
| import { FullSlug, joinSegments, pathToRoot } from "../util/path" | ||||
| import { FullSlug, RelativeURL, joinSegments } from "../util/path" | ||||
| import { visit } from "unist-util-visit" | ||||
| import { Root, Element, ElementContent } from "hast" | ||||
|  | ||||
| interface RenderComponents { | ||||
|   head: QuartzComponent | ||||
| @@ -15,9 +17,10 @@ interface RenderComponents { | ||||
|   footer: QuartzComponent | ||||
| } | ||||
|  | ||||
| export function pageResources(slug: FullSlug, staticResources: StaticResources): StaticResources { | ||||
|   const baseDir = pathToRoot(slug) | ||||
|  | ||||
| export function pageResources( | ||||
|   baseDir: FullSlug | RelativeURL, | ||||
|   staticResources: StaticResources, | ||||
| ): StaticResources { | ||||
|   const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") | ||||
|   const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())` | ||||
|  | ||||
| @@ -52,6 +55,99 @@ export function renderPage( | ||||
|   components: RenderComponents, | ||||
|   pageResources: StaticResources, | ||||
| ): string { | ||||
|   // process transcludes in componentData | ||||
|   visit(componentData.tree as Root, "element", (node, _index, _parent) => { | ||||
|     if (node.tagName === "blockquote") { | ||||
|       const classNames = (node.properties?.className ?? []) as string[] | ||||
|       if (classNames.includes("transclude")) { | ||||
|         const inner = node.children[0] as Element | ||||
|         const transcludeTarget = inner.properties?.["data-slug"] as FullSlug | ||||
|  | ||||
|         // TODO: avoid this expensive find operation and construct an index ahead of time | ||||
|         const page = componentData.allFiles.find((f) => f.slug === transcludeTarget) | ||||
|         if (!page) { | ||||
|           return | ||||
|         } | ||||
|  | ||||
|         let blockRef = node.properties?.dataBlock as string | undefined | ||||
|         if (blockRef?.startsWith("^")) { | ||||
|           // block transclude | ||||
|           blockRef = blockRef.slice(1) | ||||
|           let blockNode = page.blocks?.[blockRef] | ||||
|           if (blockNode) { | ||||
|             if (blockNode.tagName === "li") { | ||||
|               blockNode = { | ||||
|                 type: "element", | ||||
|                 tagName: "ul", | ||||
|                 children: [blockNode], | ||||
|               } | ||||
|             } | ||||
|  | ||||
|             node.children = [ | ||||
|               blockNode, | ||||
|               { | ||||
|                 type: "element", | ||||
|                 tagName: "a", | ||||
|                 properties: { href: inner.properties?.href, class: ["internal"] }, | ||||
|                 children: [{ type: "text", value: `Link to original` }], | ||||
|               }, | ||||
|             ] | ||||
|           } | ||||
|         } else if (blockRef?.startsWith("#") && page.htmlAst) { | ||||
|           // header transclude | ||||
|           blockRef = blockRef.slice(1) | ||||
|           let startIdx = undefined | ||||
|           let endIdx = undefined | ||||
|           for (const [i, el] of page.htmlAst.children.entries()) { | ||||
|             if (el.type === "element" && el.tagName.match(/h[1-6]/)) { | ||||
|               if (endIdx) { | ||||
|                 break | ||||
|               } | ||||
|  | ||||
|               if (startIdx) { | ||||
|                 endIdx = i | ||||
|               } else if (el.properties?.id === blockRef) { | ||||
|                 startIdx = i | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|  | ||||
|           if (!startIdx) { | ||||
|             return | ||||
|           } | ||||
|  | ||||
|           node.children = [ | ||||
|             ...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]), | ||||
|             { | ||||
|               type: "element", | ||||
|               tagName: "a", | ||||
|               properties: { href: inner.properties?.href, class: ["internal"] }, | ||||
|               children: [{ type: "text", value: `Link to original` }], | ||||
|             }, | ||||
|           ] | ||||
|         } else if (page.htmlAst) { | ||||
|           // page transclude | ||||
|           node.children = [ | ||||
|             { | ||||
|               type: "element", | ||||
|               tagName: "h1", | ||||
|               children: [ | ||||
|                 { type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` }, | ||||
|               ], | ||||
|             }, | ||||
|             ...(page.htmlAst.children as ElementContent[]), | ||||
|             { | ||||
|               type: "element", | ||||
|               tagName: "a", | ||||
|               properties: { href: inner.properties?.href, class: ["internal"] }, | ||||
|               children: [{ type: "text", value: `Link to original` }], | ||||
|             }, | ||||
|           ] | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
|  | ||||
|   const { | ||||
|     head: Head, | ||||
|     header, | ||||
|   | ||||
| @@ -20,4 +20,13 @@ document.addEventListener("nav", () => { | ||||
|   if (currentTheme === "dark") { | ||||
|     toggleSwitch.checked = true | ||||
|   } | ||||
|  | ||||
|   // Listen for changes in prefers-color-scheme | ||||
|   const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)") | ||||
|   colorSchemeMediaQuery.addEventListener("change", (e) => { | ||||
|     const newTheme = e.matches ? "dark" : "light" | ||||
|     document.documentElement.setAttribute("saved-theme", newTheme) | ||||
|     localStorage.setItem("theme", newTheme) | ||||
|     toggleSwitch.checked = e.matches | ||||
|   }) | ||||
| }) | ||||
|   | ||||
							
								
								
									
										164
									
								
								quartz/components/scripts/explorer.inline.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								quartz/components/scripts/explorer.inline.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,164 @@ | ||||
| import { FolderState } from "../ExplorerNode" | ||||
|  | ||||
| // Current state of folders | ||||
| let explorerState: FolderState[] | ||||
|  | ||||
| const observer = new IntersectionObserver((entries) => { | ||||
|   // If last element is observed, remove gradient of "overflow" class so element is visible | ||||
|   const explorer = document.getElementById("explorer-ul") | ||||
|   for (const entry of entries) { | ||||
|     if (entry.isIntersecting) { | ||||
|       explorer?.classList.add("no-background") | ||||
|     } else { | ||||
|       explorer?.classList.remove("no-background") | ||||
|     } | ||||
|   } | ||||
| }) | ||||
|  | ||||
| function toggleExplorer(this: HTMLElement) { | ||||
|   // Toggle collapsed state of entire explorer | ||||
|   this.classList.toggle("collapsed") | ||||
|   const content = this.nextElementSibling as HTMLElement | ||||
|   content.classList.toggle("collapsed") | ||||
|   content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px" | ||||
| } | ||||
|  | ||||
| function toggleFolder(evt: MouseEvent) { | ||||
|   evt.stopPropagation() | ||||
|  | ||||
|   // Element that was clicked | ||||
|   const target = evt.target as HTMLElement | ||||
|  | ||||
|   // Check if target was svg icon or button | ||||
|   const isSvg = target.nodeName === "svg" | ||||
|  | ||||
|   // corresponding <ul> element relative to clicked button/folder | ||||
|   let childFolderContainer: HTMLElement | ||||
|  | ||||
|   // <li> element of folder (stores folder-path dataset) | ||||
|   let currentFolderParent: HTMLElement | ||||
|  | ||||
|   // Get correct relative container and toggle collapsed class | ||||
|   if (isSvg) { | ||||
|     childFolderContainer = target.parentElement?.nextSibling as HTMLElement | ||||
|     currentFolderParent = target.nextElementSibling as HTMLElement | ||||
|  | ||||
|     childFolderContainer.classList.toggle("open") | ||||
|   } else { | ||||
|     childFolderContainer = target.parentElement?.parentElement?.nextElementSibling as HTMLElement | ||||
|     currentFolderParent = target.parentElement as HTMLElement | ||||
|  | ||||
|     childFolderContainer.classList.toggle("open") | ||||
|   } | ||||
|   if (!childFolderContainer) return | ||||
|  | ||||
|   // Collapse folder container | ||||
|   const isCollapsed = childFolderContainer.classList.contains("open") | ||||
|   setFolderState(childFolderContainer, !isCollapsed) | ||||
|  | ||||
|   // Save folder state to localStorage | ||||
|   const clickFolderPath = currentFolderParent.dataset.folderpath as string | ||||
|  | ||||
|   // Remove leading "/" | ||||
|   const fullFolderPath = clickFolderPath.substring(1) | ||||
|   toggleCollapsedByPath(explorerState, fullFolderPath) | ||||
|  | ||||
|   const stringifiedFileTree = JSON.stringify(explorerState) | ||||
|   localStorage.setItem("fileTree", stringifiedFileTree) | ||||
| } | ||||
|  | ||||
| function setupExplorer() { | ||||
|   // Set click handler for collapsing entire explorer | ||||
|   const explorer = document.getElementById("explorer") | ||||
|  | ||||
|   // Get folder state from local storage | ||||
|   const storageTree = localStorage.getItem("fileTree") | ||||
|  | ||||
|   // Convert to bool | ||||
|   const useSavedFolderState = explorer?.dataset.savestate === "true" | ||||
|  | ||||
|   if (explorer) { | ||||
|     // Get config | ||||
|     const collapseBehavior = explorer.dataset.behavior | ||||
|  | ||||
|     // Add click handlers for all folders (click handler on folder "label") | ||||
|     if (collapseBehavior === "collapse") { | ||||
|       Array.prototype.forEach.call( | ||||
|         document.getElementsByClassName("folder-button"), | ||||
|         function (item) { | ||||
|           item.removeEventListener("click", toggleFolder) | ||||
|           item.addEventListener("click", toggleFolder) | ||||
|         }, | ||||
|       ) | ||||
|     } | ||||
|  | ||||
|     // Add click handler to main explorer | ||||
|     explorer.removeEventListener("click", toggleExplorer) | ||||
|     explorer.addEventListener("click", toggleExplorer) | ||||
|   } | ||||
|  | ||||
|   // Set up click handlers for each folder (click handler on folder "icon") | ||||
|   Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) { | ||||
|     item.removeEventListener("click", toggleFolder) | ||||
|     item.addEventListener("click", toggleFolder) | ||||
|   }) | ||||
|  | ||||
|   if (storageTree && useSavedFolderState) { | ||||
|     // Get state from localStorage and set folder state | ||||
|     explorerState = JSON.parse(storageTree) | ||||
|     explorerState.map((folderUl) => { | ||||
|       // grab <li> element for matching folder path | ||||
|       const folderLi = document.querySelector( | ||||
|         `[data-folderpath='/${folderUl.path}']`, | ||||
|       ) as HTMLElement | ||||
|  | ||||
|       // Get corresponding content <ul> tag and set state | ||||
|       if (folderLi) { | ||||
|         const folderUL = folderLi.parentElement?.nextElementSibling | ||||
|         if (folderUL) { | ||||
|           setFolderState(folderUL as HTMLElement, folderUl.collapsed) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|   } else { | ||||
|     // If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset | ||||
|     explorerState = JSON.parse(explorer?.dataset.tree as string) | ||||
|   } | ||||
| } | ||||
|  | ||||
| window.addEventListener("resize", setupExplorer) | ||||
| document.addEventListener("nav", () => { | ||||
|   setupExplorer() | ||||
|  | ||||
|   const explorerContent = document.getElementById("explorer-ul") | ||||
|   // select pseudo element at end of list | ||||
|   const lastItem = document.getElementById("explorer-end") | ||||
|  | ||||
|   observer.disconnect() | ||||
|   observer.observe(lastItem as Element) | ||||
| }) | ||||
|  | ||||
| /** | ||||
|  * Toggles the state of a given folder | ||||
|  * @param folderElement <div class="folder-outer"> Element of folder (parent) | ||||
|  * @param collapsed if folder should be set to collapsed or not | ||||
|  */ | ||||
| function setFolderState(folderElement: HTMLElement, collapsed: boolean) { | ||||
|   if (collapsed) { | ||||
|     folderElement?.classList.remove("open") | ||||
|   } else { | ||||
|     folderElement?.classList.add("open") | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Toggles visibility of a folder | ||||
|  * @param array array of FolderState (`fileTree`, either get from local storage or data attribute) | ||||
|  * @param path path to folder (e.g. 'advanced/more/more2') | ||||
|  */ | ||||
| function toggleCollapsedByPath(array: FolderState[], path: string) { | ||||
|   const entry = array.find((item) => item.path === path) | ||||
|   if (entry) { | ||||
|     entry.collapsed = !entry.collapsed | ||||
|   } | ||||
| } | ||||
| @@ -42,19 +42,38 @@ async function renderGraph(container: string, fullSlug: FullSlug) { | ||||
|     linkDistance, | ||||
|     fontSize, | ||||
|     opacityScale, | ||||
|     removeTags, | ||||
|     showTags, | ||||
|   } = JSON.parse(graph.dataset["cfg"]!) | ||||
|  | ||||
|   const data = await fetchData | ||||
|  | ||||
|   const links: LinkData[] = [] | ||||
|   const tags: SimpleSlug[] = [] | ||||
|  | ||||
|   const validLinks = new Set(Object.keys(data).map((slug) => simplifySlug(slug as FullSlug))) | ||||
|  | ||||
|   for (const [src, details] of Object.entries<ContentDetails>(data)) { | ||||
|     const source = simplifySlug(src as FullSlug) | ||||
|     const outgoing = details.links ?? [] | ||||
|  | ||||
|     for (const dest of outgoing) { | ||||
|       if (dest in data) { | ||||
|       if (validLinks.has(dest)) { | ||||
|         links.push({ source, target: dest }) | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (showTags) { | ||||
|       const localTags = details.tags | ||||
|         .filter((tag) => !removeTags.includes(tag)) | ||||
|         .map((tag) => simplifySlug(("tags/" + tag) as FullSlug)) | ||||
|  | ||||
|       tags.push(...localTags.filter((tag) => !tags.includes(tag))) | ||||
|  | ||||
|       for (const tag of localTags) { | ||||
|         links.push({ source, target: tag }) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const neighbourhood = new Set<SimpleSlug>() | ||||
| @@ -75,14 +94,18 @@ async function renderGraph(container: string, fullSlug: FullSlug) { | ||||
|     } | ||||
|   } else { | ||||
|     Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug))) | ||||
|     if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) | ||||
|   } | ||||
|  | ||||
|   const graphData: { nodes: NodeData[]; links: LinkData[] } = { | ||||
|     nodes: [...neighbourhood].map((url) => ({ | ||||
|       id: url, | ||||
|       text: data[url]?.title ?? url, | ||||
|       tags: data[url]?.tags ?? [], | ||||
|     })), | ||||
|     nodes: [...neighbourhood].map((url) => { | ||||
|       const text = url.startsWith("tags/") ? "#" + url.substring(5) : data[url]?.title ?? url | ||||
|       return { | ||||
|         id: url, | ||||
|         text: text, | ||||
|         tags: data[url]?.tags ?? [], | ||||
|       } | ||||
|     }), | ||||
|     links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)), | ||||
|   } | ||||
|  | ||||
| @@ -126,7 +149,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) { | ||||
|     const isCurrent = d.id === slug | ||||
|     if (isCurrent) { | ||||
|       return "var(--secondary)" | ||||
|     } else if (visited.has(d.id)) { | ||||
|     } else if (visited.has(d.id) || d.id.startsWith("tags/")) { | ||||
|       return "var(--tertiary)" | ||||
|     } else { | ||||
|       return "var(--gray)" | ||||
| @@ -230,9 +253,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) { | ||||
|     .attr("dx", 0) | ||||
|     .attr("dy", (d) => -nodeRadius(d) + "px") | ||||
|     .attr("text-anchor", "middle") | ||||
|     .text( | ||||
|       (d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "), | ||||
|     ) | ||||
|     .text((d) => d.text) | ||||
|     .style("opacity", (opacityScale - 1) / 3.75) | ||||
|     .style("pointer-events", "none") | ||||
|     .style("font-size", fontSize + "em") | ||||
|   | ||||
| @@ -28,8 +28,11 @@ async function mouseEnterHandler( | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   const hasAlreadyBeenFetched = () => | ||||
|     [...link.children].some((child) => child.classList.contains("popover")) | ||||
|  | ||||
|   // dont refetch if there's already a popover | ||||
|   if ([...link.children].some((child) => child.classList.contains("popover"))) { | ||||
|   if (hasAlreadyBeenFetched()) { | ||||
|     return setPosition(link.lastChild as HTMLElement) | ||||
|   } | ||||
|  | ||||
| @@ -49,6 +52,11 @@ async function mouseEnterHandler( | ||||
|       console.error(err) | ||||
|     }) | ||||
|  | ||||
|   // bailout if another popover exists | ||||
|   if (hasAlreadyBeenFetched()) { | ||||
|     return | ||||
|   } | ||||
|  | ||||
|   if (!contents) return | ||||
|   const html = p.parseFromString(contents, "text/html") | ||||
|   normalizeRelativeURLs(html, targetUrl) | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Document } from "flexsearch" | ||||
| import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch" | ||||
| import { ContentDetails } from "../../plugins/emitters/contentIndex" | ||||
| import { registerEscapeHandler, removeAllChildren } from "./util" | ||||
| import { FullSlug, resolveRelative } from "../../util/path" | ||||
| @@ -8,12 +8,20 @@ interface Item { | ||||
|   slug: FullSlug | ||||
|   title: string | ||||
|   content: string | ||||
|   tags: string[] | ||||
| } | ||||
|  | ||||
| let index: Document<Item> | undefined = undefined | ||||
|  | ||||
| // Can be expanded with things like "term" in the future | ||||
| type SearchType = "basic" | "tags" | ||||
|  | ||||
| // Current searchType | ||||
| let searchType: SearchType = "basic" | ||||
|  | ||||
| const contextWindowWords = 30 | ||||
| const numSearchResults = 5 | ||||
| const numTagResults = 3 | ||||
| function highlight(searchTerm: string, text: string, trim?: boolean) { | ||||
|   // try to highlight longest tokens first | ||||
|   const tokenizedTerms = searchTerm | ||||
| @@ -74,6 +82,7 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|   const searchIcon = document.getElementById("search-icon") | ||||
|   const searchBar = document.getElementById("search-bar") as HTMLInputElement | null | ||||
|   const results = document.getElementById("results-container") | ||||
|   const resultCards = document.getElementsByClassName("result-card") | ||||
|   const idDataMap = Object.keys(data) as FullSlug[] | ||||
|  | ||||
|   function hideSearch() { | ||||
| @@ -87,9 +96,12 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|     if (results) { | ||||
|       removeAllChildren(results) | ||||
|     } | ||||
|  | ||||
|     searchType = "basic" // reset search type after closing | ||||
|   } | ||||
|  | ||||
|   function showSearch() { | ||||
|   function showSearch(searchTypeNew: SearchType) { | ||||
|     searchType = searchTypeNew | ||||
|     if (sidebar) { | ||||
|       sidebar.style.zIndex = "1" | ||||
|     } | ||||
| @@ -98,36 +110,123 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|   } | ||||
|  | ||||
|   function shortcutHandler(e: HTMLElementEventMap["keydown"]) { | ||||
|     if (e.key === "k" && (e.ctrlKey || e.metaKey)) { | ||||
|     if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { | ||||
|       e.preventDefault() | ||||
|       const searchBarOpen = container?.classList.contains("active") | ||||
|       searchBarOpen ? hideSearch() : showSearch() | ||||
|       searchBarOpen ? hideSearch() : showSearch("basic") | ||||
|     } else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { | ||||
|       // Hotkey to open tag search | ||||
|       e.preventDefault() | ||||
|       const searchBarOpen = container?.classList.contains("active") | ||||
|       searchBarOpen ? hideSearch() : showSearch("tags") | ||||
|  | ||||
|       // add "#" prefix for tag search | ||||
|       if (searchBar) searchBar.value = "#" | ||||
|     } else if (e.key === "Enter") { | ||||
|       const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null | ||||
|       if (anchor) { | ||||
|         anchor.click() | ||||
|       // If result has focus, navigate to that one, otherwise pick first result | ||||
|       if (results?.contains(document.activeElement)) { | ||||
|         const active = document.activeElement as HTMLInputElement | ||||
|         active.click() | ||||
|       } else { | ||||
|         const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null | ||||
|         anchor?.click() | ||||
|       } | ||||
|     } else if (e.key === "ArrowDown") { | ||||
|       e.preventDefault() | ||||
|       // When first pressing ArrowDown, results wont contain the active element, so focus first element | ||||
|       if (!results?.contains(document.activeElement)) { | ||||
|         const firstResult = resultCards[0] as HTMLInputElement | null | ||||
|         firstResult?.focus() | ||||
|       } else { | ||||
|         // If an element in results-container already has focus, focus next one | ||||
|         const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null | ||||
|         nextResult?.focus() | ||||
|       } | ||||
|     } else if (e.key === "ArrowUp") { | ||||
|       e.preventDefault() | ||||
|       if (results?.contains(document.activeElement)) { | ||||
|         // If an element in results-container already has focus, focus previous one | ||||
|         const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null | ||||
|         prevResult?.focus() | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function trimContent(content: string) { | ||||
|     // works without escaping html like in `description.ts` | ||||
|     const sentences = content.replace(/\s+/g, " ").split(".") | ||||
|     let finalDesc = "" | ||||
|     let sentenceIdx = 0 | ||||
|  | ||||
|     // Roughly estimate characters by (words * 5). Matches description length in `description.ts`. | ||||
|     const len = contextWindowWords * 5 | ||||
|     while (finalDesc.length < len) { | ||||
|       const sentence = sentences[sentenceIdx] | ||||
|       if (!sentence) break | ||||
|       finalDesc += sentence + "." | ||||
|       sentenceIdx++ | ||||
|     } | ||||
|  | ||||
|     // If more content would be available, indicate it by finishing with "..." | ||||
|     if (finalDesc.length < content.length) { | ||||
|       finalDesc += ".." | ||||
|     } | ||||
|  | ||||
|     return finalDesc | ||||
|   } | ||||
|  | ||||
|   const formatForDisplay = (term: string, id: number) => { | ||||
|     const slug = idDataMap[id] | ||||
|     return { | ||||
|       id, | ||||
|       slug, | ||||
|       title: highlight(term, data[slug].title ?? ""), | ||||
|       content: highlight(term, data[slug].content ?? "", true), | ||||
|       title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""), | ||||
|       // if searchType is tag, display context from start of file and trim, otherwise use regular highlight | ||||
|       content: | ||||
|         searchType === "tags" | ||||
|           ? trimContent(data[slug].content) | ||||
|           : highlight(term, data[slug].content ?? "", true), | ||||
|       tags: highlightTags(term, data[slug].tags), | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const resultToHTML = ({ slug, title, content }: Item) => { | ||||
|   function highlightTags(term: string, tags: string[]) { | ||||
|     if (tags && searchType === "tags") { | ||||
|       // Find matching tags | ||||
|       const termLower = term.toLowerCase() | ||||
|       let matching = tags.filter((str) => str.includes(termLower)) | ||||
|  | ||||
|       // Substract matching from original tags, then push difference | ||||
|       if (matching.length > 0) { | ||||
|         let difference = tags.filter((x) => !matching.includes(x)) | ||||
|  | ||||
|         // Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`) | ||||
|         matching = matching.map((tag) => `<li><p class="match-tag">#${tag}</p></li>`) | ||||
|         difference = difference.map((tag) => `<li><p>#${tag}</p></li>`) | ||||
|         matching.push(...difference) | ||||
|       } | ||||
|  | ||||
|       // Only allow max of `numTagResults` in preview | ||||
|       if (tags.length > numTagResults) { | ||||
|         matching.splice(numTagResults) | ||||
|       } | ||||
|  | ||||
|       return matching | ||||
|     } else { | ||||
|       return [] | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const resultToHTML = ({ slug, title, content, tags }: Item) => { | ||||
|     const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : `` | ||||
|     const button = document.createElement("button") | ||||
|     button.classList.add("result-card") | ||||
|     button.id = slug | ||||
|     button.innerHTML = `<h3>${title}</h3><p>${content}</p>` | ||||
|     button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>` | ||||
|     button.addEventListener("click", () => { | ||||
|       const targ = resolveRelative(currentSlug, slug) | ||||
|       window.spaNavigate(new URL(targ, window.location.toString())) | ||||
|       hideSearch() | ||||
|     }) | ||||
|     return button | ||||
|   } | ||||
| @@ -147,15 +246,45 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|   } | ||||
|  | ||||
|   async function onType(e: HTMLElementEventMap["input"]) { | ||||
|     const term = (e.target as HTMLInputElement).value | ||||
|     const searchResults = (await index?.searchAsync(term, numSearchResults)) ?? [] | ||||
|     let term = (e.target as HTMLInputElement).value | ||||
|     let searchResults: SimpleDocumentSearchResultSetUnit[] | ||||
|  | ||||
|     if (term.toLowerCase().startsWith("#")) { | ||||
|       searchType = "tags" | ||||
|     } else { | ||||
|       searchType = "basic" | ||||
|     } | ||||
|  | ||||
|     switch (searchType) { | ||||
|       case "tags": { | ||||
|         term = term.substring(1) | ||||
|         searchResults = | ||||
|           (await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ?? | ||||
|           [] | ||||
|         break | ||||
|       } | ||||
|       case "basic": | ||||
|       default: { | ||||
|         searchResults = | ||||
|           (await index?.searchAsync({ | ||||
|             query: term, | ||||
|             limit: numSearchResults, | ||||
|             index: ["title", "content"], | ||||
|           })) ?? [] | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const getByField = (field: string): number[] => { | ||||
|       const results = searchResults.filter((x) => x.field === field) | ||||
|       return results.length === 0 ? [] : ([...results[0].result] as number[]) | ||||
|     } | ||||
|  | ||||
|     // order titles ahead of content | ||||
|     const allIds: Set<number> = new Set([...getByField("title"), ...getByField("content")]) | ||||
|     const allIds: Set<number> = new Set([ | ||||
|       ...getByField("title"), | ||||
|       ...getByField("content"), | ||||
|       ...getByField("tags"), | ||||
|     ]) | ||||
|     const finalResults = [...allIds].map((id) => formatForDisplay(term, id)) | ||||
|     displayResults(finalResults) | ||||
|   } | ||||
| @@ -166,15 +295,14 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|  | ||||
|   document.addEventListener("keydown", shortcutHandler) | ||||
|   prevShortcutHandler = shortcutHandler | ||||
|   searchIcon?.removeEventListener("click", showSearch) | ||||
|   searchIcon?.addEventListener("click", showSearch) | ||||
|   searchIcon?.removeEventListener("click", () => showSearch("basic")) | ||||
|   searchIcon?.addEventListener("click", () => showSearch("basic")) | ||||
|   searchBar?.removeEventListener("input", onType) | ||||
|   searchBar?.addEventListener("input", onType) | ||||
|  | ||||
|   // setup index if it hasn't been already | ||||
|   if (!index) { | ||||
|     index = new Document({ | ||||
|       cache: true, | ||||
|       charset: "latin:extra", | ||||
|       optimize: true, | ||||
|       encode: encoder, | ||||
| @@ -189,22 +317,36 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|             field: "content", | ||||
|             tokenize: "reverse", | ||||
|           }, | ||||
|           { | ||||
|             field: "tags", | ||||
|             tokenize: "reverse", | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }) | ||||
|  | ||||
|     let id = 0 | ||||
|     for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { | ||||
|       await index.addAsync(id, { | ||||
|         id, | ||||
|         slug: slug as FullSlug, | ||||
|         title: fileData.title, | ||||
|         content: fileData.content, | ||||
|       }) | ||||
|       id++ | ||||
|     } | ||||
|     fillDocument(index, data) | ||||
|   } | ||||
|  | ||||
|   // register handlers | ||||
|   registerEscapeHandler(container, hideSearch) | ||||
| }) | ||||
|  | ||||
| /** | ||||
|  * Fills flexsearch document with data | ||||
|  * @param index index to fill | ||||
|  * @param data data to fill index with | ||||
|  */ | ||||
| async function fillDocument(index: Document<Item, false>, data: any) { | ||||
|   let id = 0 | ||||
|   for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { | ||||
|     await index.addAsync(id, { | ||||
|       id, | ||||
|       slug: slug as FullSlug, | ||||
|       title: fileData.title, | ||||
|       content: fileData.content, | ||||
|       tags: fileData.tags, | ||||
|     }) | ||||
|     id++ | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import micromorph from "micromorph" | ||||
| import { FullSlug, RelativeURL, getFullSlug } from "../../util/path" | ||||
| import { normalizeRelativeURLs } from "./popover.inline" | ||||
|  | ||||
| // adapted from `micromorph` | ||||
| // https://github.com/natemoo-re/micromorph | ||||
| @@ -18,8 +19,15 @@ const isLocalUrl = (href: string) => { | ||||
|   return false | ||||
| } | ||||
|  | ||||
| const isSamePage = (url: URL): boolean => { | ||||
|   const sameOrigin = url.origin === window.location.origin | ||||
|   const samePath = url.pathname === window.location.pathname | ||||
|   return sameOrigin && samePath | ||||
| } | ||||
|  | ||||
| const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => { | ||||
|   if (!isElement(target)) return | ||||
|   if (target.attributes.getNamedItem("target")?.value === "_blank") return | ||||
|   const a = target.closest("a") | ||||
|   if (!a) return | ||||
|   if ("routerIgnore" in a.dataset) return | ||||
| @@ -45,6 +53,8 @@ async function navigate(url: URL, isBack: boolean = false) { | ||||
|   if (!contents) return | ||||
|  | ||||
|   const html = p.parseFromString(contents, "text/html") | ||||
|   normalizeRelativeURLs(html, url) | ||||
|  | ||||
|   let title = html.querySelector("title")?.textContent | ||||
|   if (title) { | ||||
|     document.title = title | ||||
| @@ -92,8 +102,16 @@ function createRouter() { | ||||
|   if (typeof window !== "undefined") { | ||||
|     window.addEventListener("click", async (event) => { | ||||
|       const { url } = getOpts(event) ?? {} | ||||
|       if (!url) return | ||||
|       // dont hijack behaviour, just let browser act normally | ||||
|       if (!url || event.ctrlKey || event.metaKey) return | ||||
|       event.preventDefault() | ||||
|  | ||||
|       if (isSamePage(url) && url.hash) { | ||||
|         const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) | ||||
|         el?.scrollIntoView() | ||||
|         return | ||||
|       } | ||||
|  | ||||
|       try { | ||||
|         navigate(url, false) | ||||
|       } catch (e) { | ||||
|   | ||||
| @@ -24,8 +24,9 @@ function toggleToc(this: HTMLElement) { | ||||
| function setupToc() { | ||||
|   const toc = document.getElementById("toc") | ||||
|   if (toc) { | ||||
|     const collapsed = toc.classList.contains("collapsed") | ||||
|     const content = toc.nextElementSibling as HTMLElement | ||||
|     content.style.maxHeight = content.scrollHeight + "px" | ||||
|     content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px" | ||||
|     toc.removeEventListener("click", toggleToc) | ||||
|     toc.addEventListener("click", toggleToc) | ||||
|   } | ||||
|   | ||||
							
								
								
									
										22
									
								
								quartz/components/styles/breadcrumbs.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								quartz/components/styles/breadcrumbs.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| .breadcrumb-container { | ||||
|   margin: 0; | ||||
|   margin-top: 0.75rem; | ||||
|   padding: 0; | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   flex-wrap: wrap; | ||||
|   gap: 0.5rem; | ||||
| } | ||||
|  | ||||
| .breadcrumb-element { | ||||
|   p { | ||||
|     margin: 0; | ||||
|     margin-left: 0.5rem; | ||||
|     padding: 0; | ||||
|     line-height: normal; | ||||
|   } | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
| } | ||||
| @@ -10,7 +10,6 @@ | ||||
|   background-color: var(--light); | ||||
|   border: 1px solid; | ||||
|   border-radius: 5px; | ||||
|   z-index: 1; | ||||
|   opacity: 0; | ||||
|   transition: 0.2s; | ||||
|  | ||||
|   | ||||
| @@ -21,6 +21,14 @@ | ||||
|   } | ||||
| } | ||||
|  | ||||
| :root[saved-theme="dark"] { | ||||
|   color-scheme: dark; | ||||
| } | ||||
|  | ||||
| :root[saved-theme="light"] { | ||||
|   color-scheme: light; | ||||
| } | ||||
|  | ||||
| :root[saved-theme="dark"] .toggle ~ label { | ||||
|   & > #dayIcon { | ||||
|     opacity: 0; | ||||
|   | ||||
							
								
								
									
										146
									
								
								quartz/components/styles/explorer.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								quartz/components/styles/explorer.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,146 @@ | ||||
| button#explorer { | ||||
|   all: unset; | ||||
|   background-color: transparent; | ||||
|   border: none; | ||||
|   text-align: left; | ||||
|   cursor: pointer; | ||||
|   padding: 0; | ||||
|   color: var(--dark); | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|  | ||||
|   & h1 { | ||||
|     font-size: 1rem; | ||||
|     display: inline-block; | ||||
|     margin: 0; | ||||
|   } | ||||
|  | ||||
|   & .fold { | ||||
|     margin-left: 0.5rem; | ||||
|     transition: transform 0.3s ease; | ||||
|     opacity: 0.8; | ||||
|   } | ||||
|  | ||||
|   &.collapsed .fold { | ||||
|     transform: rotateZ(-90deg); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .folder-outer { | ||||
|   display: grid; | ||||
|   grid-template-rows: 0fr; | ||||
|   transition: grid-template-rows 0.3s ease-in-out; | ||||
| } | ||||
|  | ||||
| .folder-outer.open { | ||||
|   grid-template-rows: 1fr; | ||||
| } | ||||
|  | ||||
| .folder-outer > ul { | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| #explorer-content { | ||||
|   list-style: none; | ||||
|   overflow: hidden; | ||||
|   max-height: none; | ||||
|   transition: max-height 0.35s ease; | ||||
|   margin-top: 0.5rem; | ||||
|  | ||||
|   &.collapsed > .overflow::after { | ||||
|     opacity: 0; | ||||
|   } | ||||
|  | ||||
|   & ul { | ||||
|     list-style: none; | ||||
|     margin: 0.08rem 0; | ||||
|     padding: 0; | ||||
|     transition: | ||||
|       max-height 0.35s ease, | ||||
|       transform 0.35s ease, | ||||
|       opacity 0.2s ease; | ||||
|     & li > a { | ||||
|       color: var(--dark); | ||||
|       opacity: 0.75; | ||||
|       pointer-events: all; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| svg { | ||||
|   pointer-events: all; | ||||
|  | ||||
|   & > polyline { | ||||
|     pointer-events: none; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .folder-container { | ||||
|   flex-direction: row; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   user-select: none; | ||||
|  | ||||
|   & div > a { | ||||
|     color: var(--secondary); | ||||
|     font-family: var(--headerFont); | ||||
|     font-size: 0.95rem; | ||||
|     font-weight: 600; | ||||
|     line-height: 1.5rem; | ||||
|     display: inline-block; | ||||
|   } | ||||
|  | ||||
|   & div > a:hover { | ||||
|     color: var(--tertiary); | ||||
|   } | ||||
|  | ||||
|   & div > button { | ||||
|     color: var(--dark); | ||||
|     background-color: transparent; | ||||
|     border: none; | ||||
|     text-align: left; | ||||
|     cursor: pointer; | ||||
|     padding-left: 0; | ||||
|     padding-right: 0; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     font-family: var(--headerFont); | ||||
|  | ||||
|     & p { | ||||
|       font-size: 0.95rem; | ||||
|       display: inline-block; | ||||
|       color: var(--secondary); | ||||
|       font-weight: 600; | ||||
|       margin: 0; | ||||
|       line-height: 1.5rem; | ||||
|       pointer-events: none; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .folder-icon { | ||||
|   margin-right: 5px; | ||||
|   color: var(--secondary); | ||||
|   cursor: pointer; | ||||
|   transition: transform 0.3s ease; | ||||
|   backface-visibility: visible; | ||||
| } | ||||
|  | ||||
| div:has(> .folder-outer:not(.open)) > .folder-container > svg { | ||||
|   transform: rotate(-90deg); | ||||
| } | ||||
|  | ||||
| .folder-icon:hover { | ||||
|   color: var(--tertiary); | ||||
| } | ||||
|  | ||||
| .no-background::after { | ||||
|   background: none !important; | ||||
| } | ||||
|  | ||||
| #explorer-end { | ||||
|   // needs height so IntersectionObserver gets triggered | ||||
|   height: 4px; | ||||
|   // remove default margin from li | ||||
|   margin: 0; | ||||
| } | ||||
| @@ -19,11 +19,6 @@ li.section-li { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     & > .tags { | ||||
|       justify-self: end; | ||||
|       margin-left: 1rem; | ||||
|     } | ||||
|  | ||||
|     & > .desc > h3 > a { | ||||
|       background-color: transparent; | ||||
|     } | ||||
|   | ||||
| @@ -130,6 +130,44 @@ | ||||
|             margin: 0; | ||||
|           } | ||||
|  | ||||
|           & > ul > li { | ||||
|             margin: 0; | ||||
|             display: inline-block; | ||||
|             white-space: nowrap; | ||||
|             margin: 0; | ||||
|             overflow-wrap: normal; | ||||
|           } | ||||
|  | ||||
|           & > ul { | ||||
|             list-style: none; | ||||
|             display: flex; | ||||
|             padding-left: 0; | ||||
|             gap: 0.4rem; | ||||
|             margin: 0; | ||||
|             margin-top: 0.45rem; | ||||
|             // Offset border radius | ||||
|             margin-left: -2px; | ||||
|             overflow: hidden; | ||||
|             background-clip: border-box; | ||||
|           } | ||||
|  | ||||
|           & > ul > li > p { | ||||
|             border-radius: 8px; | ||||
|             background-color: var(--highlight); | ||||
|             overflow: hidden; | ||||
|             background-clip: border-box; | ||||
|             padding: 0.03rem 0.4rem; | ||||
|             margin: 0; | ||||
|             color: var(--secondary); | ||||
|             opacity: 0.85; | ||||
|           } | ||||
|  | ||||
|           & > ul > li > .match-tag { | ||||
|             color: var(--tertiary); | ||||
|             font-weight: bold; | ||||
|             opacity: 1; | ||||
|           } | ||||
|  | ||||
|           & > p { | ||||
|             margin-bottom: 0; | ||||
|           } | ||||
|   | ||||
							
								
								
									
										59
									
								
								quartz/plugins/emitters/404.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								quartz/plugins/emitters/404.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| import { QuartzEmitterPlugin } from "../types" | ||||
| import { QuartzComponentProps } from "../../components/types" | ||||
| import BodyConstructor from "../../components/Body" | ||||
| import { pageResources, renderPage } from "../../components/renderPage" | ||||
| import { FullPageLayout } from "../../cfg" | ||||
| import { FilePath, FullSlug } from "../../util/path" | ||||
| import { sharedPageComponents } from "../../../quartz.layout" | ||||
| import { NotFound } from "../../components" | ||||
| import { defaultProcessedContent } from "../vfile" | ||||
|  | ||||
| export const NotFoundPage: QuartzEmitterPlugin = () => { | ||||
|   const opts: FullPageLayout = { | ||||
|     ...sharedPageComponents, | ||||
|     pageBody: NotFound(), | ||||
|     beforeBody: [], | ||||
|     left: [], | ||||
|     right: [], | ||||
|   } | ||||
|  | ||||
|   const { head: Head, pageBody, footer: Footer } = opts | ||||
|   const Body = BodyConstructor() | ||||
|  | ||||
|   return { | ||||
|     name: "404Page", | ||||
|     getQuartzComponents() { | ||||
|       return [Head, Body, pageBody, Footer] | ||||
|     }, | ||||
|     async emit(ctx, _content, resources, emit): Promise<FilePath[]> { | ||||
|       const cfg = ctx.cfg.configuration | ||||
|       const slug = "404" as FullSlug | ||||
|  | ||||
|       const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) | ||||
|       const path = url.pathname as FullSlug | ||||
|       const externalResources = pageResources(path, resources) | ||||
|       const [tree, vfile] = defaultProcessedContent({ | ||||
|         slug, | ||||
|         text: "Not Found", | ||||
|         description: "Not Found", | ||||
|         frontmatter: { title: "Not Found", tags: [] }, | ||||
|       }) | ||||
|       const componentData: QuartzComponentProps = { | ||||
|         fileData: vfile.data, | ||||
|         externalResources, | ||||
|         cfg, | ||||
|         children: [], | ||||
|         tree, | ||||
|         allFiles: [], | ||||
|       } | ||||
|  | ||||
|       return [ | ||||
|         await emit({ | ||||
|           content: renderPage(slug, componentData, opts, externalResources), | ||||
|           slug, | ||||
|           ext: ".html", | ||||
|         }), | ||||
|       ] | ||||
|     }, | ||||
|   } | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { FilePath, FullSlug, resolveRelative, simplifySlug } from "../../util/path" | ||||
| import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path" | ||||
| import { QuartzEmitterPlugin } from "../types" | ||||
| import path from "path" | ||||
|  | ||||
| @@ -12,15 +12,25 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({ | ||||
|  | ||||
|     for (const [_tree, file] of content) { | ||||
|       const ogSlug = simplifySlug(file.data.slug!) | ||||
|       const dir = path.posix.relative(argv.directory, file.dirname ?? argv.directory) | ||||
|       const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!)) | ||||
|  | ||||
|       let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? [] | ||||
|       if (typeof aliases === "string") { | ||||
|         aliases = [aliases] | ||||
|       } | ||||
|  | ||||
|       for (const alias of aliases) { | ||||
|         const slug = path.posix.join(dir, alias) as FullSlug | ||||
|       const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug) | ||||
|       const permalink = file.data.frontmatter?.permalink | ||||
|       if (typeof permalink === "string") { | ||||
|         slugs.push(permalink as FullSlug) | ||||
|       } | ||||
|  | ||||
|       for (let slug of slugs) { | ||||
|         // fix any slugs that have trailing slash | ||||
|         if (slug.endsWith("/")) { | ||||
|           slug = joinSegments(slug, "index") as FullSlug | ||||
|         } | ||||
|  | ||||
|         const redirUrl = resolveRelative(slug, file.data.slug!) | ||||
|         const fp = await emit({ | ||||
|           content: ` | ||||
|   | ||||
| @@ -7,7 +7,7 @@ import spaRouterScript from "../../components/scripts/spa.inline" | ||||
| import plausibleScript from "../../components/scripts/plausible.inline" | ||||
| // @ts-ignore | ||||
| import popoverScript from "../../components/scripts/popover.inline" | ||||
| import styles from "../../styles/base.scss" | ||||
| import styles from "../../styles/custom.scss" | ||||
| import popoverStyle from "../../components/styles/popover.scss" | ||||
| import { BuildCtx } from "../../util/ctx" | ||||
| import { StaticResources } from "../../util/resources" | ||||
| @@ -96,6 +96,15 @@ function addGlobalPageResources( | ||||
|       });`) | ||||
|   } else if (cfg.analytics?.provider === "plausible") { | ||||
|     componentResources.afterDOMLoaded.push(plausibleScript) | ||||
|   } else if (cfg.analytics?.provider === "umami") { | ||||
|     componentResources.afterDOMLoaded.push(` | ||||
|       const umamiScript = document.createElement("script") | ||||
|       umamiScript.src = "https://analytics.umami.is/script.js" | ||||
|       umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}") | ||||
|       umamiScript.async = true | ||||
|    | ||||
|       document.head.appendChild(umamiScript) | ||||
|     `) | ||||
|   } | ||||
|  | ||||
|   if (cfg.enableSPA) { | ||||
| @@ -107,12 +116,18 @@ function addGlobalPageResources( | ||||
|         document.dispatchEvent(event)`) | ||||
|   } | ||||
|  | ||||
|   let wsUrl = `ws://localhost:${ctx.argv.wsPort}` | ||||
|  | ||||
|   if (ctx.argv.remoteDevHost) { | ||||
|     wsUrl = `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}` | ||||
|   } | ||||
|  | ||||
|   if (reloadScript) { | ||||
|     staticResources.js.push({ | ||||
|       loadTime: "afterDOMReady", | ||||
|       contentType: "inline", | ||||
|       script: ` | ||||
|           const socket = new WebSocket('ws://localhost:3001') | ||||
|           const socket = new WebSocket('${wsUrl}') | ||||
|           socket.addEventListener('message', () => document.location.reload()) | ||||
|         `, | ||||
|     }) | ||||
| @@ -149,7 +164,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial< | ||||
|  | ||||
|       addGlobalPageResources(ctx, resources, componentResources) | ||||
|  | ||||
|       const stylesheet = joinStyles(ctx.cfg.configuration.theme, styles, ...componentResources.css) | ||||
|       const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles) | ||||
|       const prescript = joinScripts(componentResources.beforeDOMLoaded) | ||||
|       const postscript = joinScripts(componentResources.afterDOMLoaded) | ||||
|       const fps = await Promise.all([ | ||||
|   | ||||
| @@ -1,7 +1,10 @@ | ||||
| import { Root } from "hast" | ||||
| import { GlobalConfiguration } from "../../cfg" | ||||
| import { getDate } from "../../components/Date" | ||||
| import { escapeHTML } from "../../util/escape" | ||||
| import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path" | ||||
| import { QuartzEmitterPlugin } from "../types" | ||||
| import { toHtml } from "hast-util-to-html" | ||||
| import path from "path" | ||||
|  | ||||
| export type ContentIndex = Map<FullSlug, ContentDetails> | ||||
| @@ -10,6 +13,7 @@ export type ContentDetails = { | ||||
|   links: SimpleSlug[] | ||||
|   tags: string[] | ||||
|   content: string | ||||
|   richContent?: string | ||||
|   date?: Date | ||||
|   description?: string | ||||
| } | ||||
| @@ -17,19 +21,23 @@ export type ContentDetails = { | ||||
| interface Options { | ||||
|   enableSiteMap: boolean | ||||
|   enableRSS: boolean | ||||
|   rssLimit?: number | ||||
|   rssFullHtml: boolean | ||||
|   includeEmptyFiles: boolean | ||||
| } | ||||
|  | ||||
| const defaultOptions: Options = { | ||||
|   enableSiteMap: true, | ||||
|   enableRSS: true, | ||||
|   rssLimit: 10, | ||||
|   rssFullHtml: false, | ||||
|   includeEmptyFiles: true, | ||||
| } | ||||
|  | ||||
| function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { | ||||
|   const base = cfg.baseUrl ?? "" | ||||
|   const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url> | ||||
|     <loc>https://${base}/${slug}</loc> | ||||
|     <loc>https://${base}/${encodeURI(slug)}</loc> | ||||
|     <lastmod>${content.date?.toISOString()}</lastmod> | ||||
|   </url>` | ||||
|   const urls = Array.from(idx) | ||||
| @@ -38,27 +46,42 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { | ||||
|   return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>` | ||||
| } | ||||
|  | ||||
| function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { | ||||
| function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string { | ||||
|   const base = cfg.baseUrl ?? "" | ||||
|   const root = `https://${base}` | ||||
|  | ||||
|   const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item> | ||||
|     <title>${content.title}</title> | ||||
|     <link>${root}/${slug}</link> | ||||
|     <guid>${root}/${slug}</guid> | ||||
|     <description>${content.description}</description> | ||||
|     <title>${escapeHTML(content.title)}</title> | ||||
|     <link>${root}/${encodeURI(slug)}</link> | ||||
|     <guid>${root}/${encodeURI(slug)}</guid> | ||||
|     <description>${content.richContent ?? content.description}</description> | ||||
|     <pubDate>${content.date?.toUTCString()}</pubDate> | ||||
|   </item>` | ||||
|  | ||||
|   const items = Array.from(idx) | ||||
|     .sort(([_, f1], [__, f2]) => { | ||||
|       if (f1.date && f2.date) { | ||||
|         return f2.date.getTime() - f1.date.getTime() | ||||
|       } else if (f1.date && !f2.date) { | ||||
|         return -1 | ||||
|       } else if (!f1.date && f2.date) { | ||||
|         return 1 | ||||
|       } | ||||
|  | ||||
|       return f1.title.localeCompare(f2.title) | ||||
|     }) | ||||
|     .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) | ||||
|     .slice(0, limit ?? idx.size) | ||||
|     .join("") | ||||
|  | ||||
|   return `<?xml version="1.0" encoding="UTF-8" ?> | ||||
| <rss version="2.0"> | ||||
|     <channel> | ||||
|       <title>${cfg.pageTitle}</title> | ||||
|       <title>${escapeHTML(cfg.pageTitle)}</title> | ||||
|       <link>${root}</link> | ||||
|       <description>Recent content on ${cfg.pageTitle}</description> | ||||
|       <description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML( | ||||
|         cfg.pageTitle, | ||||
|       )}</description> | ||||
|       <generator>Quartz -- quartz.jzhao.xyz</generator> | ||||
|       ${items} | ||||
|     </channel> | ||||
| @@ -73,7 +96,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => { | ||||
|       const cfg = ctx.cfg.configuration | ||||
|       const emitted: FilePath[] = [] | ||||
|       const linkIndex: ContentIndex = new Map() | ||||
|       for (const [_tree, file] of content) { | ||||
|       for (const [tree, file] of content) { | ||||
|         const slug = file.data.slug! | ||||
|         const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() | ||||
|         if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { | ||||
| @@ -82,6 +105,9 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => { | ||||
|             links: file.data.links ?? [], | ||||
|             tags: file.data.frontmatter?.tags ?? [], | ||||
|             content: file.data.text ?? "", | ||||
|             richContent: opts?.rssFullHtml | ||||
|               ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) | ||||
|               : undefined, | ||||
|             date: date, | ||||
|             description: file.data.description ?? "", | ||||
|           }) | ||||
| @@ -101,7 +127,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => { | ||||
|       if (opts?.enableRSS) { | ||||
|         emitted.push( | ||||
|           await emit({ | ||||
|             content: generateRSSFeed(cfg, linkIndex), | ||||
|             content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), | ||||
|             slug: "index" as FullSlug, | ||||
|             ext: ".xml", | ||||
|           }), | ||||
|   | ||||
| @@ -4,9 +4,10 @@ import HeaderConstructor from "../../components/Header" | ||||
| import BodyConstructor from "../../components/Body" | ||||
| import { pageResources, renderPage } from "../../components/renderPage" | ||||
| import { FullPageLayout } from "../../cfg" | ||||
| import { FilePath } from "../../util/path" | ||||
| import { FilePath, pathToRoot } from "../../util/path" | ||||
| import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" | ||||
| import { Content } from "../../components" | ||||
| import chalk from "chalk" | ||||
|  | ||||
| export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => { | ||||
|   const opts: FullPageLayout = { | ||||
| @@ -29,9 +30,15 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp | ||||
|       const cfg = ctx.cfg.configuration | ||||
|       const fps: FilePath[] = [] | ||||
|       const allFiles = content.map((c) => c[1].data) | ||||
|  | ||||
|       let containsIndex = false | ||||
|       for (const [tree, file] of content) { | ||||
|         const slug = file.data.slug! | ||||
|         const externalResources = pageResources(slug, resources) | ||||
|         if (slug === "index") { | ||||
|           containsIndex = true | ||||
|         } | ||||
|  | ||||
|         const externalResources = pageResources(pathToRoot(slug), resources) | ||||
|         const componentData: QuartzComponentProps = { | ||||
|           fileData: file.data, | ||||
|           externalResources, | ||||
| @@ -50,6 +57,15 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp | ||||
|  | ||||
|         fps.push(fp) | ||||
|       } | ||||
|  | ||||
|       if (!containsIndex) { | ||||
|         console.log( | ||||
|           chalk.yellow( | ||||
|             `\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder. This may cause errors when deploying.`, | ||||
|           ), | ||||
|         ) | ||||
|       } | ||||
|  | ||||
|       return fps | ||||
|     }, | ||||
|   } | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import { | ||||
|   SimpleSlug, | ||||
|   _stripSlashes, | ||||
|   joinSegments, | ||||
|   pathToRoot, | ||||
|   simplifySlug, | ||||
| } from "../../util/path" | ||||
| import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" | ||||
| @@ -69,7 +70,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => { | ||||
|  | ||||
|       for (const folder of folders) { | ||||
|         const slug = joinSegments(folder, "index") as FullSlug | ||||
|         const externalResources = pageResources(slug, resources) | ||||
|         const externalResources = pageResources(pathToRoot(slug), resources) | ||||
|         const [tree, file] = folderDescriptions[folder] | ||||
|         const componentData: QuartzComponentProps = { | ||||
|           fileData: file.data, | ||||
|   | ||||
| @@ -6,3 +6,4 @@ export { AliasRedirects } from "./aliases" | ||||
| export { Assets } from "./assets" | ||||
| export { Static } from "./static" | ||||
| export { ComponentResources } from "./componentResources" | ||||
| export { NotFoundPage } from "./404" | ||||
|   | ||||
| @@ -5,7 +5,13 @@ import BodyConstructor from "../../components/Body" | ||||
| import { pageResources, renderPage } from "../../components/renderPage" | ||||
| import { ProcessedContent, defaultProcessedContent } from "../vfile" | ||||
| import { FullPageLayout } from "../../cfg" | ||||
| import { FilePath, FullSlug, getAllSegmentPrefixes, joinSegments } from "../../util/path" | ||||
| import { | ||||
|   FilePath, | ||||
|   FullSlug, | ||||
|   getAllSegmentPrefixes, | ||||
|   joinSegments, | ||||
|   pathToRoot, | ||||
| } from "../../util/path" | ||||
| import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" | ||||
| import { TagContent } from "../../components" | ||||
|  | ||||
| @@ -62,7 +68,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => { | ||||
|  | ||||
|       for (const tag of tags) { | ||||
|         const slug = joinSegments("tags", tag) as FullSlug | ||||
|         const externalResources = pageResources(slug, resources) | ||||
|         const externalResources = pageResources(pathToRoot(slug), resources) | ||||
|         const [tree, file] = tagDescriptions[tag] | ||||
|         const componentData: QuartzComponentProps = { | ||||
|           fileData: file.data, | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { Root as HTMLRoot } from "hast" | ||||
| import { toString } from "hast-util-to-string" | ||||
| import { QuartzTransformerPlugin } from "../types" | ||||
| import { escapeHTML } from "../../util/escape" | ||||
|  | ||||
| export interface Options { | ||||
|   descriptionLength: number | ||||
| @@ -10,15 +11,6 @@ const defaultOptions: Options = { | ||||
|   descriptionLength: 150, | ||||
| } | ||||
|  | ||||
| const escapeHTML = (unsafe: string) => { | ||||
|   return unsafe | ||||
|     .replaceAll("&", "&") | ||||
|     .replaceAll("<", "<") | ||||
|     .replaceAll(">", ">") | ||||
|     .replaceAll('"', """) | ||||
|     .replaceAll("'", "'") | ||||
| } | ||||
|  | ||||
| export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { | ||||
|   const opts = { ...defaultOptions, ...userOpts } | ||||
|   return { | ||||
|   | ||||
| @@ -2,14 +2,17 @@ import matter from "gray-matter" | ||||
| import remarkFrontmatter from "remark-frontmatter" | ||||
| import { QuartzTransformerPlugin } from "../types" | ||||
| import yaml from "js-yaml" | ||||
| import toml from "toml" | ||||
| import { slugTag } from "../../util/path" | ||||
|  | ||||
| export interface Options { | ||||
|   delims: string | string[] | ||||
|   language: "yaml" | "toml" | ||||
| } | ||||
|  | ||||
| const defaultOptions: Options = { | ||||
|   delims: "---", | ||||
|   language: "yaml", | ||||
| } | ||||
|  | ||||
| export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { | ||||
| @@ -18,13 +21,14 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> | ||||
|     name: "FrontMatter", | ||||
|     markdownPlugins() { | ||||
|       return [ | ||||
|         remarkFrontmatter, | ||||
|         [remarkFrontmatter, ["yaml", "toml"]], | ||||
|         () => { | ||||
|           return (_, file) => { | ||||
|             const { data } = matter(file.value, { | ||||
|               ...opts, | ||||
|               engines: { | ||||
|                 yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object, | ||||
|                 toml: (s) => toml.parse(s) as object, | ||||
|               }, | ||||
|             }) | ||||
|  | ||||
| @@ -33,6 +37,11 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> | ||||
|               data.tags = data.tag | ||||
|             } | ||||
|  | ||||
|             // coerce title to string | ||||
|             if (data.title) { | ||||
|               data.title = data.title.toString() | ||||
|             } | ||||
|  | ||||
|             if (data.tags && !Array.isArray(data.tags)) { | ||||
|               data.tags = data.tags | ||||
|                 .toString() | ||||
|   | ||||
| @@ -5,5 +5,7 @@ export { Latex } from "./latex" | ||||
| export { Description } from "./description" | ||||
| export { CrawlLinks } from "./links" | ||||
| export { ObsidianFlavoredMarkdown } from "./ofm" | ||||
| export { OxHugoFlavouredMarkdown } from "./oxhugofm" | ||||
| export { SyntaxHighlighting } from "./syntax" | ||||
| export { TableOfContents } from "./toc" | ||||
| export { HardLineBreaks } from "./linebreaks" | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import fs from "fs" | ||||
| import path from "path" | ||||
| import { Repository } from "@napi-rs/simple-git" | ||||
| import { QuartzTransformerPlugin } from "../types" | ||||
| import chalk from "chalk" | ||||
|  | ||||
| export interface Options { | ||||
|   priority: ("frontmatter" | "git" | "filesystem")[] | ||||
| @@ -11,6 +12,20 @@ const defaultOptions: Options = { | ||||
|   priority: ["frontmatter", "git", "filesystem"], | ||||
| } | ||||
|  | ||||
| function coerceDate(fp: string, d: any): Date { | ||||
|   const dt = new Date(d) | ||||
|   const invalidDate = isNaN(dt.getTime()) || dt.getTime() === 0 | ||||
|   if (invalidDate && d !== undefined) { | ||||
|     console.log( | ||||
|       chalk.yellow( | ||||
|         `\nWarning: found invalid date "${d}" in \`${fp}\`. Supported formats: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format`, | ||||
|       ), | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   return invalidDate ? new Date() : dt | ||||
| } | ||||
|  | ||||
| type MaybeDate = undefined | string | number | ||||
| export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = ( | ||||
|   userOpts, | ||||
| @@ -27,10 +42,11 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und | ||||
|             let modified: MaybeDate = undefined | ||||
|             let published: MaybeDate = undefined | ||||
|  | ||||
|             const fp = path.posix.join(file.cwd, file.data.filePath as string) | ||||
|             const fp = file.data.filePath! | ||||
|             const fullFp = path.posix.join(file.cwd, fp) | ||||
|             for (const source of opts.priority) { | ||||
|               if (source === "filesystem") { | ||||
|                 const st = await fs.promises.stat(fp) | ||||
|                 const st = await fs.promises.stat(fullFp) | ||||
|                 created ||= st.birthtimeMs | ||||
|                 modified ||= st.mtimeMs | ||||
|               } else if (source === "frontmatter" && file.data.frontmatter) { | ||||
| @@ -49,9 +65,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und | ||||
|             } | ||||
|  | ||||
|             file.data.dates = { | ||||
|               created: created ? new Date(created) : new Date(), | ||||
|               modified: modified ? new Date(modified) : new Date(), | ||||
|               published: published ? new Date(published) : new Date(), | ||||
|               created: coerceDate(fp, created), | ||||
|               modified: coerceDate(fp, modified), | ||||
|               published: coerceDate(fp, published), | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|   | ||||
							
								
								
									
										11
									
								
								quartz/plugins/transformers/linebreaks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								quartz/plugins/transformers/linebreaks.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import { QuartzTransformerPlugin } from "../types" | ||||
| import remarkBreaks from "remark-breaks" | ||||
|  | ||||
| export const HardLineBreaks: QuartzTransformerPlugin = () => { | ||||
|   return { | ||||
|     name: "HardLineBreaks", | ||||
|     markdownPlugins() { | ||||
|       return [remarkBreaks] | ||||
|     }, | ||||
|   } | ||||
| } | ||||
| @@ -5,7 +5,6 @@ import { | ||||
|   SimpleSlug, | ||||
|   TransformOptions, | ||||
|   _stripSlashes, | ||||
|   joinSegments, | ||||
|   simplifySlug, | ||||
|   splitAnchor, | ||||
|   transformLink, | ||||
| @@ -19,11 +18,13 @@ interface Options { | ||||
|   markdownLinkResolution: TransformOptions["strategy"] | ||||
|   /** Strips folders from a link so that it looks nice */ | ||||
|   prettyLinks: boolean | ||||
|   openLinksInNewTab: boolean | ||||
| } | ||||
|  | ||||
| const defaultOptions: Options = { | ||||
|   markdownLinkResolution: "absolute", | ||||
|   prettyLinks: true, | ||||
|   openLinksInNewTab: false, | ||||
| } | ||||
|  | ||||
| export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { | ||||
| @@ -53,8 +54,13 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = | ||||
|                 node.properties.className ??= [] | ||||
|                 node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal") | ||||
|  | ||||
|                 if (opts.openLinksInNewTab) { | ||||
|                   node.properties.target = "_blank" | ||||
|                 } | ||||
|  | ||||
|                 // don't process external links or intra-document anchors | ||||
|                 if (!(isAbsoluteUrl(dest) || dest.startsWith("#"))) { | ||||
|                 const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#")) | ||||
|                 if (isInternal) { | ||||
|                   dest = node.properties.href = transformLink( | ||||
|                     file.data.slug!, | ||||
|                     dest, | ||||
| @@ -72,11 +78,13 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = | ||||
|                     simplifySlug(destCanonical as FullSlug), | ||||
|                   ) as SimpleSlug | ||||
|                   outgoing.add(simple) | ||||
|                   node.properties["data-slug"] = simple | ||||
|                 } | ||||
|  | ||||
|                 // rewrite link internals if prettylinks is on | ||||
|                 if ( | ||||
|                   opts.prettyLinks && | ||||
|                   isInternal && | ||||
|                   node.children.length === 1 && | ||||
|                   node.children[0].type === "text" && | ||||
|                   !node.children[0].value.startsWith("#") | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { PluggableList } from "unified" | ||||
| import { QuartzTransformerPlugin } from "../types" | ||||
| import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast" | ||||
| import { Element, Literal, Root as HtmlRoot } from "hast" | ||||
| import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" | ||||
| import { slug as slugAnchor } from "github-slugger" | ||||
| import rehypeRaw from "rehype-raw" | ||||
| @@ -13,6 +14,7 @@ import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path" | ||||
| import { toHast } from "mdast-util-to-hast" | ||||
| import { toHtml } from "hast-util-to-html" | ||||
| import { PhrasingContent } from "mdast-util-find-and-replace/lib" | ||||
| import { capitalize } from "../../util/lang" | ||||
|  | ||||
| export interface Options { | ||||
|   comments: boolean | ||||
| @@ -21,6 +23,7 @@ export interface Options { | ||||
|   callouts: boolean | ||||
|   mermaid: boolean | ||||
|   parseTags: boolean | ||||
|   parseBlockReferences: boolean | ||||
|   enableInHtmlEmbed: boolean | ||||
| } | ||||
|  | ||||
| @@ -31,6 +34,7 @@ const defaultOptions: Options = { | ||||
|   callouts: true, | ||||
|   mermaid: true, | ||||
|   parseTags: true, | ||||
|   parseBlockReferences: true, | ||||
|   enableInHtmlEmbed: false, | ||||
| } | ||||
|  | ||||
| @@ -69,6 +73,8 @@ const callouts = { | ||||
| const calloutMapping: Record<string, keyof typeof callouts> = { | ||||
|   note: "note", | ||||
|   abstract: "abstract", | ||||
|   summary: "abstract", | ||||
|   tldr: "abstract", | ||||
|   info: "info", | ||||
|   todo: "todo", | ||||
|   tip: "tip", | ||||
| @@ -96,11 +102,7 @@ const calloutMapping: Record<string, keyof typeof callouts> = { | ||||
|  | ||||
| function canonicalizeCallout(calloutName: string): keyof typeof callouts { | ||||
|   let callout = calloutName.toLowerCase() as keyof typeof calloutMapping | ||||
|   return calloutMapping[callout] ?? calloutName | ||||
| } | ||||
|  | ||||
| const capitalize = (s: string): string => { | ||||
|   return s.substring(0, 1).toUpperCase() + s.substring(1) | ||||
|   return calloutMapping[callout] ?? "note" | ||||
| } | ||||
|  | ||||
| // !?               -> optional embedding | ||||
| @@ -109,14 +111,17 @@ const capitalize = (s: string): string => { | ||||
| // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) | ||||
| // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias) | ||||
| const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g") | ||||
| const highlightRegex = new RegExp(/==(.+)==/, "g") | ||||
| const highlightRegex = new RegExp(/==([^=]+)==/, "g") | ||||
| const commentRegex = new RegExp(/%%(.+)%%/, "g") | ||||
| // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts | ||||
| const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/) | ||||
| const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm") | ||||
| // (?:^| )   -> non-capturing group, tag should start be separated by a space or be the start of the line | ||||
| // #(\w+)    -> tag itself is # followed by a string of alpha-numeric characters | ||||
| const tagRegex = new RegExp(/(?:^| )#(\p{L}+)/, "gu") | ||||
| // (?:^| )              -> non-capturing group, tag should start be separated by a space or be the start of the line | ||||
| // #(...)               -> capturing group, tag itself must start with # | ||||
| // (?:[-_\p{L}])+       -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters, hyphens and/or underscores | ||||
| // (?:\/[-_\p{L}]+)*)   -> non-capturing group, matches an arbitrary number of tag strings separated by "/" | ||||
| const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu") | ||||
| const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g") | ||||
|  | ||||
| export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = ( | ||||
|   userOpts, | ||||
| @@ -230,8 +235,16 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | ||||
|                     value: `<iframe src="${url}"></iframe>`, | ||||
|                   } | ||||
|                 } else if (ext === "") { | ||||
|                   // TODO: note embed | ||||
|                   const block = anchor | ||||
|                   return { | ||||
|                     type: "html", | ||||
|                     data: { hProperties: { transclude: true } }, | ||||
|                     value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${ | ||||
|                       url + anchor | ||||
|                     }" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`, | ||||
|                   } | ||||
|                 } | ||||
|  | ||||
|                 // otherwise, fall through to regular link | ||||
|               } | ||||
|  | ||||
| @@ -320,7 +333,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | ||||
|  | ||||
|                 const titleHtml: HTML = { | ||||
|                   type: "html", | ||||
|                   value: `<div  | ||||
|                   value: `<div | ||||
|                   class="callout-title" | ||||
|                 > | ||||
|                   <div class="callout-icon">${callouts[calloutType]}</div> | ||||
| @@ -383,13 +396,18 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | ||||
|           return (tree: Root, file) => { | ||||
|             const base = pathToRoot(file.data.slug!) | ||||
|             findAndReplace(tree, tagRegex, (_value: string, tag: string) => { | ||||
|               // Check if the tag only includes numbers | ||||
|               if (/^\d+$/.test(tag)) { | ||||
|                 return false | ||||
|               } | ||||
|               tag = slugTag(tag) | ||||
|               if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) { | ||||
|                 file.data.frontmatter.tags.push(tag) | ||||
|               } | ||||
|  | ||||
|               return { | ||||
|                 type: "link", | ||||
|                 url: base + `/tags/${slugTag(tag)}`, | ||||
|                 url: base + `/tags/${tag}`, | ||||
|                 data: { | ||||
|                   hProperties: { | ||||
|                     className: ["tag-link"], | ||||
| @@ -406,11 +424,64 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | ||||
|           } | ||||
|         }) | ||||
|       } | ||||
|  | ||||
|       return plugins | ||||
|     }, | ||||
|     htmlPlugins() { | ||||
|       return [rehypeRaw] | ||||
|       const plugins = [rehypeRaw] | ||||
|  | ||||
|       if (opts.parseBlockReferences) { | ||||
|         plugins.push(() => { | ||||
|           const inlineTagTypes = new Set(["p", "li"]) | ||||
|           const blockTagTypes = new Set(["blockquote"]) | ||||
|           return (tree, file) => { | ||||
|             file.data.blocks = {} | ||||
|             file.data.htmlAst = tree | ||||
|  | ||||
|             visit(tree, "element", (node, index, parent) => { | ||||
|               if (blockTagTypes.has(node.tagName)) { | ||||
|                 const nextChild = parent?.children.at(index! + 2) as Element | ||||
|                 if (nextChild && nextChild.tagName === "p") { | ||||
|                   const text = nextChild.children.at(0) as Literal | ||||
|                   if (text && text.value && text.type === "text") { | ||||
|                     const matches = text.value.match(blockReferenceRegex) | ||||
|                     if (matches && matches.length >= 1) { | ||||
|                       parent!.children.splice(index! + 2, 1) | ||||
|                       const block = matches[0].slice(1) | ||||
|  | ||||
|                       if (!Object.keys(file.data.blocks!).includes(block)) { | ||||
|                         node.properties = { | ||||
|                           ...node.properties, | ||||
|                           id: block, | ||||
|                         } | ||||
|                         file.data.blocks![block] = node | ||||
|                       } | ||||
|                     } | ||||
|                   } | ||||
|                 } | ||||
|               } else if (inlineTagTypes.has(node.tagName)) { | ||||
|                 const last = node.children.at(-1) as Literal | ||||
|                 if (last && last.value && typeof last.value === "string") { | ||||
|                   const matches = last.value.match(blockReferenceRegex) | ||||
|                   if (matches && matches.length >= 1) { | ||||
|                     last.value = last.value.slice(0, -matches[0].length) | ||||
|                     const block = matches[0].slice(1) | ||||
|  | ||||
|                     if (!Object.keys(file.data.blocks!).includes(block)) { | ||||
|                       node.properties = { | ||||
|                         ...node.properties, | ||||
|                         id: block, | ||||
|                       } | ||||
|                       file.data.blocks![block] = node | ||||
|                     } | ||||
|                   } | ||||
|                 } | ||||
|               } | ||||
|             }) | ||||
|           } | ||||
|         }) | ||||
|       } | ||||
|  | ||||
|       return plugins | ||||
|     }, | ||||
|     externalResources() { | ||||
|       const js: JSResource[] = [] | ||||
| @@ -428,7 +499,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | ||||
|           script: ` | ||||
|           import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs'; | ||||
|           const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark' | ||||
|           mermaid.initialize({  | ||||
|           mermaid.initialize({ | ||||
|             startOnLoad: false, | ||||
|             securityLevel: 'loose', | ||||
|             theme: darkMode ? 'dark' : 'default' | ||||
| @@ -449,3 +520,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | ||||
|     }, | ||||
|   } | ||||
| } | ||||
|  | ||||
| declare module "vfile" { | ||||
|   interface DataMap { | ||||
|     blocks: Record<string, Element> | ||||
|     htmlAst: HtmlRoot | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										108
									
								
								quartz/plugins/transformers/oxhugofm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								quartz/plugins/transformers/oxhugofm.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| import { QuartzTransformerPlugin } from "../types" | ||||
|  | ||||
| export interface Options { | ||||
|   /** Replace {{ relref }} with quartz wikilinks []() */ | ||||
|   wikilinks: boolean | ||||
|   /** Remove pre-defined anchor (see https://ox-hugo.scripter.co/doc/anchors/) */ | ||||
|   removePredefinedAnchor: boolean | ||||
|   /** Remove hugo shortcode syntax */ | ||||
|   removeHugoShortcode: boolean | ||||
|   /** Replace <figure/> with ![]() */ | ||||
|   replaceFigureWithMdImg: boolean | ||||
|  | ||||
|   /** Replace org latex fragments with $ and $$ */ | ||||
|   replaceOrgLatex: boolean | ||||
| } | ||||
|  | ||||
| const defaultOptions: Options = { | ||||
|   wikilinks: true, | ||||
|   removePredefinedAnchor: true, | ||||
|   removeHugoShortcode: true, | ||||
|   replaceFigureWithMdImg: true, | ||||
|   replaceOrgLatex: true, | ||||
| } | ||||
|  | ||||
| const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g") | ||||
| const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g") | ||||
| const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g") | ||||
| const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "g") | ||||
| // \\\\\( -> matches \\( | ||||
| // (.+?) -> Lazy match for capturing the equation | ||||
| // \\\\\) -> matches \\) | ||||
| const inlineLatexRegex = new RegExp(/\\\\\((.+?)\\\\\)/, "g") | ||||
| // (?:\\begin{equation}|\\\\\(|\\\\\[) -> start of equation | ||||
| // ([\s\S]*?) -> Matches the block equation | ||||
| // (?:\\\\\]|\\\\\)|\\end{equation}) -> end of equation | ||||
| const blockLatexRegex = new RegExp( | ||||
|   /(?:\\begin{equation}|\\\\\(|\\\\\[)([\s\S]*?)(?:\\\\\]|\\\\\)|\\end{equation})/, | ||||
|   "g", | ||||
| ) | ||||
| // \$\$[\s\S]*?\$\$ -> Matches block equations | ||||
| // \$.*?\$ -> Matches inline equations | ||||
| const quartzLatexRegex = new RegExp(/\$\$[\s\S]*?\$\$|\$.*?\$/, "g") | ||||
|  | ||||
| /** | ||||
|  * ox-hugo is an org exporter backend that exports org files to hugo-compatible | ||||
|  * markdown in an opinionated way. This plugin adds some tweaks to the generated | ||||
|  * markdown to make it compatible with quartz but the list of changes applied it | ||||
|  * is not exhaustive. | ||||
|  * */ | ||||
| export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = ( | ||||
|   userOpts, | ||||
| ) => { | ||||
|   const opts = { ...defaultOptions, ...userOpts } | ||||
|   return { | ||||
|     name: "OxHugoFlavouredMarkdown", | ||||
|     textTransform(_ctx, src) { | ||||
|       if (opts.wikilinks) { | ||||
|         src = src.toString() | ||||
|         src = src.replaceAll(relrefRegex, (value, ...capture) => { | ||||
|           const [text, link] = capture | ||||
|           return `[${text}](${link})` | ||||
|         }) | ||||
|       } | ||||
|  | ||||
|       if (opts.removePredefinedAnchor) { | ||||
|         src = src.toString() | ||||
|         src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => { | ||||
|           const [headingText] = capture | ||||
|           return headingText | ||||
|         }) | ||||
|       } | ||||
|  | ||||
|       if (opts.removeHugoShortcode) { | ||||
|         src = src.toString() | ||||
|         src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => { | ||||
|           const [scContent] = capture | ||||
|           return scContent | ||||
|         }) | ||||
|       } | ||||
|  | ||||
|       if (opts.replaceFigureWithMdImg) { | ||||
|         src = src.toString() | ||||
|         src = src.replaceAll(figureTagRegex, (value, ...capture) => { | ||||
|           const [src] = capture | ||||
|           return `` | ||||
|         }) | ||||
|       } | ||||
|  | ||||
|       if (opts.replaceOrgLatex) { | ||||
|         src = src.toString() | ||||
|         src = src.replaceAll(inlineLatexRegex, (value, ...capture) => { | ||||
|           const [eqn] = capture | ||||
|           return `$${eqn}$` | ||||
|         }) | ||||
|         src = src.replaceAll(blockLatexRegex, (value, ...capture) => { | ||||
|           const [eqn] = capture | ||||
|           return `$$${eqn}$$` | ||||
|         }) | ||||
|  | ||||
|         // ox-hugo escapes _ as \_ | ||||
|         src = src.replaceAll(quartzLatexRegex, (value) => { | ||||
|           return value.replaceAll("\\_", "_") | ||||
|         }) | ||||
|       } | ||||
|       return src | ||||
|     }, | ||||
|   } | ||||
| } | ||||
| @@ -8,12 +8,14 @@ export interface Options { | ||||
|   maxDepth: 1 | 2 | 3 | 4 | 5 | 6 | ||||
|   minEntries: 1 | ||||
|   showByDefault: boolean | ||||
|   collapseByDefault: boolean | ||||
| } | ||||
|  | ||||
| const defaultOptions: Options = { | ||||
|   maxDepth: 3, | ||||
|   minEntries: 1, | ||||
|   showByDefault: true, | ||||
|   collapseByDefault: false, | ||||
| } | ||||
|  | ||||
| interface TocEntry { | ||||
| @@ -54,6 +56,7 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin | ||||
|                   ...entry, | ||||
|                   depth: entry.depth - highestDepth, | ||||
|                 })) | ||||
|                 file.data.collapseToc = opts.collapseByDefault | ||||
|               } | ||||
|             } | ||||
|           } | ||||
| @@ -66,5 +69,6 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin | ||||
| declare module "vfile" { | ||||
|   interface DataMap { | ||||
|     toc: TocEntry[] | ||||
|     collapseToc: boolean | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| @use "./custom.scss"; | ||||
| @use "./syntax.scss"; | ||||
| @use "./callouts.scss"; | ||||
| @use "./variables.scss" as *; | ||||
| @@ -65,7 +64,7 @@ a { | ||||
|     color: var(--tertiary) !important; | ||||
|   } | ||||
|  | ||||
|   &.internal { | ||||
|   &.internal:not(:has(> img)) { | ||||
|     text-decoration: none; | ||||
|     background-color: var(--highlight); | ||||
|     padding: 0 0.1rem; | ||||
| @@ -95,6 +94,8 @@ a { | ||||
|   } | ||||
|  | ||||
|   & article { | ||||
|     position: relative; | ||||
|  | ||||
|     & > h1 { | ||||
|       font-size: 2rem; | ||||
|     } | ||||
| @@ -389,23 +390,33 @@ p { | ||||
|   line-height: 1.6rem; | ||||
| } | ||||
|  | ||||
| table { | ||||
|   margin: 1rem; | ||||
|   padding: 1.5rem; | ||||
|   border-collapse: collapse; | ||||
|   & > * { | ||||
|     line-height: 2rem; | ||||
| .table-container { | ||||
|   overflow-x: auto; | ||||
|  | ||||
|   & > table { | ||||
|     margin: 1rem; | ||||
|     padding: 1.5rem; | ||||
|     border-collapse: collapse; | ||||
|  | ||||
|     th, | ||||
|     td { | ||||
|       min-width: 75px; | ||||
|     } | ||||
|  | ||||
|     & > * { | ||||
|       line-height: 2rem; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| th { | ||||
|   text-align: left; | ||||
|   padding: 0.4rem 1rem; | ||||
|   padding: 0.4rem 0.7rem; | ||||
|   border-bottom: 2px solid var(--gray); | ||||
| } | ||||
|  | ||||
| td { | ||||
|   padding: 0.2rem 1rem; | ||||
|   padding: 0.2rem 0.7rem; | ||||
| } | ||||
|  | ||||
| tr { | ||||
| @@ -446,7 +457,7 @@ video { | ||||
|  | ||||
| ul.overflow, | ||||
| ol.overflow { | ||||
|   height: 300px; | ||||
|   max-height: 400; | ||||
|   overflow-y: auto; | ||||
|  | ||||
|   // clearfix | ||||
| @@ -454,7 +465,7 @@ ol.overflow { | ||||
|   clear: both; | ||||
|  | ||||
|   & > li:last-of-type { | ||||
|     margin-bottom: 50px; | ||||
|     margin-bottom: 30px; | ||||
|   } | ||||
|  | ||||
|   &:after { | ||||
| @@ -470,3 +481,9 @@ ol.overflow { | ||||
|     background: linear-gradient(transparent 0px, var(--light)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .transclude { | ||||
|   ul { | ||||
|     padding-left: 1rem; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -82,7 +82,6 @@ | ||||
|  | ||||
| .callout-title { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 5px; | ||||
|   padding: 1rem 0; | ||||
|   color: var(--color); | ||||
| @@ -103,6 +102,8 @@ | ||||
| .callout-icon { | ||||
|   width: 18px; | ||||
|   height: 18px; | ||||
|   flex: 0 0 18px; | ||||
|   padding-top: 4px; | ||||
| } | ||||
|  | ||||
| .callout-title-inner { | ||||
|   | ||||
| @@ -10,4 +10,7 @@ | ||||
|     --outlinegray: #dadada; | ||||
|     --million-progress-bar-color: var(--secondary); | ||||
|     --highlighted: #f5dfaf88; | ||||
|   } | ||||
|   } | ||||
| @use "./base.scss"; | ||||
|  | ||||
| // put your custom CSS here! | ||||
|   | ||||
| @@ -7,6 +7,8 @@ export interface Argv { | ||||
|   output: string | ||||
|   serve: boolean | ||||
|   port: number | ||||
|   wsPort: number | ||||
|   remoteDevHost?: string | ||||
|   concurrency?: number | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										8
									
								
								quartz/util/escape.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								quartz/util/escape.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| export const escapeHTML = (unsafe: string) => { | ||||
|   return unsafe | ||||
|     .replaceAll("&", "&") | ||||
|     .replaceAll("<", "<") | ||||
|     .replaceAll(">", ">") | ||||
|     .replaceAll('"', """) | ||||
|     .replaceAll("'", "'") | ||||
| } | ||||
							
								
								
									
										28
									
								
								quartz/util/jsx.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								quartz/util/jsx.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import { Components, Jsx, toJsxRuntime } from "hast-util-to-jsx-runtime" | ||||
| import { QuartzPluginData } from "../plugins/vfile" | ||||
| import { Node, Root } from "hast" | ||||
| import { Fragment, jsx, jsxs } from "preact/jsx-runtime" | ||||
| import { trace } from "./trace" | ||||
| import { type FilePath } from "./path" | ||||
|  | ||||
| const customComponents: Components = { | ||||
|   table: (props) => ( | ||||
|     <div class="table-container"> | ||||
|       <table {...props} /> | ||||
|     </div> | ||||
|   ), | ||||
| } | ||||
|  | ||||
| export function htmlToJsx(fp: FilePath, tree: Node<QuartzPluginData>) { | ||||
|   try { | ||||
|     return toJsxRuntime(tree as Root, { | ||||
|       Fragment, | ||||
|       jsx: jsx as Jsx, | ||||
|       jsxs: jsxs as Jsx, | ||||
|       elementAttributeNameCase: "html", | ||||
|       components: customComponents, | ||||
|     }) | ||||
|   } catch (e) { | ||||
|     trace(`Failed to parse Markdown in \`${fp}\` into JSX`, e as Error) | ||||
|   } | ||||
| } | ||||
							
								
								
									
										11
									
								
								quartz/util/lang.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								quartz/util/lang.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export function pluralize(count: number, s: string): string { | ||||
|   if (count === 1) { | ||||
|     return `1 ${s}` | ||||
|   } else { | ||||
|     return `${count} ${s}s` | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function capitalize(s: string): string { | ||||
|   return s.substring(0, 1).toUpperCase() + s.substring(1) | ||||
| } | ||||
| @@ -52,7 +52,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { | ||||
|  | ||||
|   let slug = withoutFileExt | ||||
|     .split("/") | ||||
|     .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent")) // slugify all segments | ||||
|     .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments | ||||
|     .join("/") // always use / as sep | ||||
|     .replace(/\/$/, "") // remove trailing slash | ||||
|  | ||||
| @@ -123,7 +123,10 @@ export function slugTag(tag: string) { | ||||
| } | ||||
|  | ||||
| export function joinSegments(...args: string[]): string { | ||||
|   return args.filter((segment) => segment !== "").join("/") | ||||
|   return args | ||||
|     .filter((segment) => segment !== "") | ||||
|     .join("/") | ||||
|     .replace(/\/\/+/g, "/") | ||||
| } | ||||
|  | ||||
| export function getAllSegmentPrefixes(tags: string): string[] { | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import { isMainThread } from "workerpool" | ||||
|  | ||||
| const rootFile = /.*at file:/ | ||||
| export function trace(msg: string, err: Error) { | ||||
|   const stack = err.stack | ||||
|   let stack = err.stack ?? "" | ||||
|  | ||||
|   const lines: string[] = [] | ||||
|  | ||||
| @@ -12,15 +12,11 @@ export function trace(msg: string, err: Error) { | ||||
|   lines.push( | ||||
|     "\n" + | ||||
|       chalk.bgRed.black.bold(" ERROR ") + | ||||
|       "\n" + | ||||
|       "\n\n" + | ||||
|       chalk.red(` ${msg}`) + | ||||
|       (err.message.length > 0 ? `: ${err.message}` : ""), | ||||
|   ) | ||||
|  | ||||
|   if (!stack) { | ||||
|     return | ||||
|   } | ||||
|  | ||||
|   let reachedEndOfLegibleTrace = false | ||||
|   for (const line of stack.split("\n").slice(1)) { | ||||
|     if (reachedEndOfLegibleTrace) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user