diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 90abf6797..731395d38 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,9 @@ name: Build and Test on: + pull_request: + branches: + - v4 push: branches: - v4 diff --git a/.gitignore b/.gitignore index fd96fec90..25d07db1c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ tsconfig.tsbuildinfo .obsidian .quartz-cache private/ +.replit +replit.nix diff --git a/docs/authoring content.md b/docs/authoring content.md index 7aa8d6299..fa6eea258 100644 --- a/docs/authoring content.md +++ b/docs/authoring content.md @@ -34,7 +34,7 @@ Some common frontmatter fields that are natively supported by Quartz: ## Syncing your Content -When you're Quartz is at a point you're happy with, you can save your changes to GitHub by doing `npx quartz sync`. +When your Quartz is at a point you're happy with, you can save your changes to GitHub by doing `npx quartz sync`. > [!hint] Flags and options > For full help options, you can run `npx quartz sync --help`. diff --git a/docs/configuration.md b/docs/configuration.md index 763a27a92..047f6ca6b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,6 +31,7 @@ This part of the configuration concerns anything that can affect the whole site. - This should also include the subpath if you are [[hosting]] on GitHub pages without a custom domain. For example, if my repository is `jackyzha0/quartz`, GitHub pages would deploy to `https://jackyzha0.github.io/quartz` and the `baseUrl` would be `jackyzha0.github.io/quartz` - Note that Quartz 4 will avoid using this as much as possible and use relative URLs whenever it can to make sure your site works no matter _where_ you end up actually deploying it. - `ignorePatterns`: a list of [glob]() patterns that Quartz should ignore and not search through when looking for files inside the `content` folder. See [[private pages]] for more details. +- `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings. - `theme`: configure how the site looks. - `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here. - `header`: Font to use for headers diff --git a/docs/features/OxHugo compatibility.md b/docs/features/OxHugo compatibility.md new file mode 100644 index 000000000..7801f0c25 --- /dev/null +++ b/docs/features/OxHugo compatibility.md @@ -0,0 +1,38 @@ +--- +tags: + - plugin/transformer +--- + +[org-roam](https://www.orgroam.com/) is a plain-text(`org`) personal knowledge management system for [emacs](https://en.wikipedia.org/wiki/Emacs). [ox-hugo](https://github.com/kaushalmodi/ox-hugo) is org exporter backend that exports `org-mode` files to [Hugo](https://gohugo.io/) compatible Markdown. + +Because the Markdown generated by ox-hugo is not pure Markdown but Hugo specific, we need to transform it to fit into Quartz. This is done by `Plugin.OxHugoFlavouredMarkdown`. Even though this [[making plugins|plugin]] was written with `ox-hugo` in mind, it should work for any Hugo specific Markdown. + +```typescript title="quartz.config.ts" +plugins: { + transformers: [ + Plugin.FrontMatter({ delims: "+++", language: "toml" }), // if toml frontmatter + // ... + Plugin.OxHugoFlavouredMarkdown(), + Plugin.GitHubFlavoredMarkdown(), + // ... + ], +}, +``` + +## Usage + +Quartz by default doesn't understand `org-roam` files as they aren't Markdown. You're responsible for using an external tool like `ox-hugo` to export the `org-roam` files as Markdown content to Quartz and managing the static assets so that they're available in the final output. + +## Configuration + +- Link resolution + - `wikilinks`: Whether to replace `{{ relref }}` with Quartz [[wikilinks]] + - `removePredefinedAnchor`: Whether to remove [pre-defined anchor set by ox-hugo](https://ox-hugo.scripter.co/doc/anchors/). +- Image handling + - `replaceFigureWithMdImg`: Whether to replace `
` with `![]()` +- Formatting + - `removeHugoShortcode`: Whether to remove hugo shortcode syntax (`{{}}`) + +> [!warning] +> +> While you can use `Plugin.OxHugoFlavoredMarkdown` and `Plugin.ObsidianFlavoredMarkdown` together, it's not recommended because it might mutate the file in unexpected ways. Use with caution. diff --git a/docs/showcase.md b/docs/showcase.md index d4a9da2b9..d2282be25 100644 --- a/docs/showcase.md +++ b/docs/showcase.md @@ -16,5 +16,6 @@ Want to see what Quartz can do? Here are some cool community gardens: - [Abhijeet's Math Wiki](https://abhmul.github.io/quartz/Math-Wiki/) - [Mike's AI Garden 🤖🪴](https://mwalton.me/) - [Matt Dunn's Second Brain](https://mattdunn.info/) +- [Pelayo Arbues' Notes](https://pelayoarbues.github.io/) If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/content/showcase.md)! diff --git a/package-lock.json b/package-lock.json index 480d89185..9246cc992 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackyzha0/quartz", - "version": "4.0.9", + "version": "4.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackyzha0/quartz", - "version": "4.0.9", + "version": "4.0.10", "license": "MIT", "dependencies": { "@clack/prompts": "^0.6.3", @@ -55,6 +55,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", @@ -5548,6 +5549,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", diff --git a/package.json b/package.json index b65ed858f..6ed52d602 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@jackyzha0/quartz", "description": "🌱 publish your digital garden and notes as a website", "private": true, - "version": "4.0.9", + "version": "4.0.10", "type": "module", "author": "jackyzha0 ", "license": "MIT", @@ -79,6 +79,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", diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index b9733171f..35d06af77 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -1,548 +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 [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 () => { - 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}`)) - } - - 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() diff --git a/quartz/build.ts b/quartz/build.ts index 58137d178..22288acc1 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -45,7 +45,7 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { perf.addEvent("glob") const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns) - const fps = allFiles.filter((fp) => fp.endsWith(".md")) + const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort() console.log( `Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, ) diff --git a/quartz/cfg.ts b/quartz/cfg.ts index e3fee360f..21e03016a 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -1,3 +1,4 @@ +import { ValidDateType } from "./components/Date" import { QuartzComponent } from "./components/types" import { PluginTypes } from "./plugins/types" import { Theme } from "./util/theme" @@ -22,6 +23,8 @@ export interface GlobalConfiguration { analytics: Analytics /** Glob patterns to not search */ ignorePatterns: string[] + /** Whether to use created, modified, or published as the default type of date */ + defaultDateType: ValidDateType /** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL. * Quartz will avoid using this as much as possible and use relative URLs most of the time */ diff --git a/quartz/cli/args.js b/quartz/cli/args.js new file mode 100644 index 000000000..3543e2e89 --- /dev/null +++ b/quartz/cli/args.js @@ -0,0 +1,98 @@ +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", + }, + 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", + }, +} diff --git a/quartz/cli/constants.js b/quartz/cli/constants.js new file mode 100644 index 000000000..f4a9ce52b --- /dev/null +++ b/quartz/cli/constants.js @@ -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") diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js new file mode 100644 index 000000000..bc3da73f3 --- /dev/null +++ b/quartz/cli/handlers.js @@ -0,0 +1,511 @@ +import { promises, readFileSync } 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", + }) + 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!")) +} diff --git a/quartz/cli/helpers.js b/quartz/cli/helpers.js new file mode 100644 index 000000000..b07d19e3c --- /dev/null +++ b/quartz/cli/helpers.js @@ -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 }) +} diff --git a/quartz/components/ContentMeta.tsx b/quartz/components/ContentMeta.tsx index 715c0f469..3e1b70112 100644 --- a/quartz/components/ContentMeta.tsx +++ b/quartz/components/ContentMeta.tsx @@ -1,15 +1,16 @@ -import { formatDate } from "./Date" +import { formatDate, getDate } from "./Date" import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import readingTime from "reading-time" export default (() => { - function ContentMetadata({ fileData }: QuartzComponentProps) { + function ContentMetadata({ cfg, fileData }: QuartzComponentProps) { const text = fileData.text if (text) { const segments: string[] = [] const { text: timeTaken, words: _words } = readingTime(text) - if (fileData.dates?.modified) { - segments.push(formatDate(fileData.dates.modified)) + + if (fileData.dates) { + segments.push(formatDate(getDate(cfg, fileData)!)) } segments.push(timeTaken) diff --git a/quartz/components/Date.tsx b/quartz/components/Date.tsx index f4b284af9..8713cfd3c 100644 --- a/quartz/components/Date.tsx +++ b/quartz/components/Date.tsx @@ -1,7 +1,21 @@ +import { GlobalConfiguration } from "../cfg" +import { QuartzPluginData } from "../plugins/vfile" + interface Props { date: Date } +export type ValidDateType = keyof Required["dates"] + +export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date | undefined { + if (!cfg.defaultDateType) { + throw new Error( + `Field 'defaultDateType' was not set in the configuration object of quartz.config.ts. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`, + ) + } + return data.dates?.[cfg.defaultDateType] +} + export function formatDate(d: Date): string { return d.toLocaleDateString("en-US", { year: "numeric", diff --git a/quartz/components/PageList.tsx b/quartz/components/PageList.tsx index c55b53478..eb34f02f7 100644 --- a/quartz/components/PageList.tsx +++ b/quartz/components/PageList.tsx @@ -1,31 +1,36 @@ import { FullSlug, resolveRelative } from "../util/path" import { QuartzPluginData } from "../plugins/vfile" -import { Date } from "./Date" +import { Date, getDate } from "./Date" import { QuartzComponentProps } from "./types" +import { GlobalConfiguration } from "../cfg" -export function byDateAndAlphabetical(f1: QuartzPluginData, f2: QuartzPluginData): number { - if (f1.dates && f2.dates) { - // sort descending by last modified - return f2.dates.modified.getTime() - f1.dates.modified.getTime() - } else if (f1.dates && !f2.dates) { - // prioritize files with dates - return -1 - } else if (!f1.dates && f2.dates) { - return 1 +export function byDateAndAlphabetical( + cfg: GlobalConfiguration, +): (f1: QuartzPluginData, f2: QuartzPluginData) => number { + return (f1, f2) => { + if (f1.dates && f2.dates) { + // sort descending + return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime() + } else if (f1.dates && !f2.dates) { + // prioritize files with dates + return -1 + } else if (!f1.dates && f2.dates) { + return 1 + } + + // otherwise, sort lexographically by title + const f1Title = f1.frontmatter?.title.toLowerCase() ?? "" + const f2Title = f2.frontmatter?.title.toLowerCase() ?? "" + return f1Title.localeCompare(f2Title) } - - // otherwise, sort lexographically by title - const f1Title = f1.frontmatter?.title.toLowerCase() ?? "" - const f2Title = f2.frontmatter?.title.toLowerCase() ?? "" - return f1Title.localeCompare(f2Title) } type Props = { limit?: number } & QuartzComponentProps -export function PageList({ fileData, allFiles, limit }: Props) { - let list = allFiles.sort(byDateAndAlphabetical) +export function PageList({ cfg, fileData, allFiles, limit }: Props) { + let list = allFiles.sort(byDateAndAlphabetical(cfg)) if (limit) { list = list.slice(0, limit) } @@ -41,7 +46,7 @@ export function PageList({ fileData, allFiles, limit }: Props) {
{page.dates && (

- +

)}
diff --git a/quartz/components/RecentNotes.tsx b/quartz/components/RecentNotes.tsx index 2b61b39cb..cb14b3343 100644 --- a/quartz/components/RecentNotes.tsx +++ b/quartz/components/RecentNotes.tsx @@ -3,7 +3,8 @@ import { FullSlug, SimpleSlug, resolveRelative } from "../util/path" import { QuartzPluginData } from "../plugins/vfile" import { byDateAndAlphabetical } from "./PageList" import style from "./styles/recentNotes.scss" -import { Date } from "./Date" +import { Date, getDate } from "./Date" +import { GlobalConfiguration } from "../cfg" interface Options { title: string @@ -13,18 +14,18 @@ interface Options { sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number } -const defaultOptions: Options = { +const defaultOptions = (cfg: GlobalConfiguration): Options => ({ title: "Recent Notes", limit: 3, linkToMore: false, filter: () => true, - sort: byDateAndAlphabetical, -} + sort: byDateAndAlphabetical(cfg), +}) export default ((userOpts?: Partial) => { - const opts = { ...defaultOptions, ...userOpts } function RecentNotes(props: QuartzComponentProps) { - const { allFiles, fileData, displayClass } = props + const { allFiles, fileData, displayClass, cfg } = props + const opts = { ...defaultOptions(cfg), ...userOpts } const pages = allFiles.filter(opts.filter).sort(opts.sort) const remaining = Math.max(0, pages.length - opts.limit) return ( @@ -47,7 +48,7 @@ export default ((userOpts?: Partial) => {
{page.dates && (

- +

)}
    diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index e589217f2..d72b297bf 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -231,7 +231,9 @@ async function renderGraph(container: string, fullSlug: FullSlug) { .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("-", " "), + (d) => + data[d.id]?.title || + (d.id.charAt(0).toUpperCase() + d.id.slice(1, d.id.length - 1)).replace("-", " "), ) .style("opacity", (opacityScale - 1) / 3.75) .style("pointer-events", "none") diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index adcd06abc..4b9e372bc 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -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 | 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 @@ -87,9 +95,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,10 +109,18 @@ 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) { @@ -110,24 +129,81 @@ document.addEventListener("nav", async (e: unknown) => { } } + 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) => `
  • #${tag}

  • `) + difference = difference.map((tag) => `
  • #${tag}

  • `) + matching.push(...difference) + } + + // Only allow max of `numTagResults` in preview + if (tags.length > numTagResults) { + matching.splice(numTagResults) + } + + return matching + } else { + return [] + } + } + + const resultToHTML = ({ slug, title, content, tags }: Item) => { + const htmlTags = tags.length > 0 ? `
      ${tags.join("")}
    ` : `` const button = document.createElement("button") button.classList.add("result-card") button.id = slug - button.innerHTML = `

    ${title}

    ${content}

    ` + button.innerHTML = `

    ${title}

    ${htmlTags}

    ${content}

    ` button.addEventListener("click", () => { const targ = resolveRelative(currentSlug, slug) window.spaNavigate(new URL(targ, window.location.toString())) + hideSearch() }) return button } @@ -147,15 +223,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 = new Set([...getByField("title"), ...getByField("content")]) + const allIds: Set = new Set([ + ...getByField("title"), + ...getByField("content"), + ...getByField("tags"), + ]) const finalResults = [...allIds].map((id) => formatForDisplay(term, id)) displayResults(finalResults) } @@ -166,8 +272,8 @@ 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) @@ -189,22 +295,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(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, data: any) { + let id = 0 + for (const [slug, fileData] of Object.entries(data)) { + await index.addAsync(id, { + id, + slug: slug as FullSlug, + title: fileData.title, + content: fileData.content, + tags: fileData.tags, + }) + id++ + } +} diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss index 4d5ad95cd..66f809f97 100644 --- a/quartz/components/styles/search.scss +++ b/quartz/components/styles/search.scss @@ -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; } diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts index c7294a343..942412e9d 100644 --- a/quartz/plugins/emitters/aliases.ts +++ b/quartz/plugins/emitters/aliases.ts @@ -12,15 +12,20 @@ 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 (const slug of slugs) { const redirUrl = resolveRelative(slug, file.data.slug!) const fp = await emit({ content: ` diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index a62bc382b..c52a3a20e 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -107,12 +107,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()) `, }) diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index ed9c4e210..0d5207672 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -1,4 +1,5 @@ import { GlobalConfiguration } from "../../cfg" +import { getDate } from "../../components/Date" import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import path from "path" @@ -74,7 +75,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { const linkIndex: ContentIndex = new Map() for (const [_tree, file] of content) { const slug = file.data.slug! - const date = file.data.dates?.modified ?? new Date() + const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { linkIndex.set(slug, { title: file.data.frontmatter?.title!, diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 3f55b9cb6..571aa04d0 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -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 | undefined> = (userOpts) => { @@ -18,13 +21,14 @@ export const FrontMatter: QuartzTransformerPlugin | 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, }, }) diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 1be94d11f..9470026e3 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -5,6 +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 { Remark42 } from "./remark42" diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index bed6f622c..8c8da67bc 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -109,14 +109,16 @@ 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") export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin | undefined> = ( userOpts, @@ -320,7 +322,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin const titleHtml: HTML = { type: "html", - value: `
    ${callouts[calloutType]}
    @@ -383,13 +385,14 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin return (tree: Root, file) => { const base = pathToRoot(file.data.slug!) findAndReplace(tree, tagRegex, (_value: string, tag: string) => { + 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"], @@ -428,7 +431,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin 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' diff --git a/quartz/plugins/transformers/oxhugofm.ts b/quartz/plugins/transformers/oxhugofm.ts new file mode 100644 index 000000000..0d7b9199a --- /dev/null +++ b/quartz/plugins/transformers/oxhugofm.ts @@ -0,0 +1,73 @@ +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
    with ![]() */ + replaceFigureWithMdImg: boolean +} + +const defaultOptions: Options = { + wikilinks: true, + removePredefinedAnchor: true, + removeHugoShortcode: true, + replaceFigureWithMdImg: true, +} + +const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g") +const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g") +const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g") +const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "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 | 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 `![](${src})` + }) + } + return src + }, + } +} diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 16a0de3a6..34def8783 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -27,6 +27,11 @@ section { border-radius: 5px; } +::selection { + background: color-mix(in srgb, var(--tertiary) 75%, transparent); + color: var(--darkgray); +} + p, ul, text, diff --git a/quartz/styles/callouts.scss b/quartz/styles/callouts.scss index ad991658d..703bd67f6 100644 --- a/quartz/styles/callouts.scss +++ b/quartz/styles/callouts.scss @@ -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 { diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts index d30339190..13e0bf864 100644 --- a/quartz/util/ctx.ts +++ b/quartz/util/ctx.ts @@ -7,6 +7,8 @@ export interface Argv { output: string serve: boolean port: number + wsPort: number + remoteDevHost?: string concurrency?: number }