From ae729cc28b16a059f2543bbd93fa2798c3b888ca Mon Sep 17 00:00:00 2001 From: bfahrenfort <59982409+bfahrenfort@users.noreply.github.com> Date: Thu, 24 Aug 2023 00:57:49 -0500 Subject: [PATCH 01/29] Revert contentIndex to RSS 2.0 (#407) --- quartz/plugins/emitters/contentIndex.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index a18e54e3e..4610cd410 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -41,26 +41,26 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" const root = `https://${base}` - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` + const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` ${content.title} ${root}/${slug} ${root}/${slug} ${content.description} ${content.date?.toUTCString()} - ` + ` const items = Array.from(idx) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .join("") - return ` + return ` + ${cfg.pageTitle} ${root} Recent content on ${cfg.pageTitle} Quartz -- quartz.jzhao.xyz - + ${items} - ${items} ` } From 09e767221162152235aa8f64f524f7db453c8f36 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Thu, 24 Aug 2023 17:14:52 +0200 Subject: [PATCH 02/29] docs: fix typo in `authoring content.md` (#408) --- docs/authoring content.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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`. From 9ff82b498359118bb00194bf76e5ba13a6172176 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 24 Aug 2023 08:31:06 -0700 Subject: [PATCH 03/29] fix: lock to never read when site is building --- quartz/bootstrap-cli.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index b9733171f..b191b49c8 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -457,6 +457,7 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started. 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, @@ -471,6 +472,7 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started. 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) => { From ca718fa3d7debfa37ba97f7acbc8192382da9021 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 24 Aug 2023 08:56:40 -0700 Subject: [PATCH 04/29] feat: add defaultDateType config --- docs/configuration.md | 1 + quartz.config.ts | 1 + quartz/cfg.ts | 3 ++ quartz/components/ContentMeta.tsx | 9 +++--- quartz/components/Date.tsx | 9 ++++++ quartz/components/PageList.tsx | 41 ++++++++++++++----------- quartz/components/RecentNotes.tsx | 15 ++++----- quartz/plugins/emitters/contentIndex.ts | 3 +- 8 files changed, 52 insertions(+), 30 deletions(-) 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/quartz.config.ts b/quartz.config.ts index 447039d62..64e86dce0 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -11,6 +11,7 @@ const config: QuartzConfig = { }, baseUrl: "quartz.jzhao.xyz", ignorePatterns: ["private", "templates"], + defaultDateType: "created", theme: { typography: { header: "Schibsted Grotesk", 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/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..0530a373a 100644 --- a/quartz/components/Date.tsx +++ b/quartz/components/Date.tsx @@ -1,7 +1,16 @@ +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 { + 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..673d08458 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/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 4610cd410..1c7feaea2 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!, From 84d4636b07a602d7a11b5d41cee1dacd37e717c6 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 24 Aug 2023 09:05:19 -0700 Subject: [PATCH 05/29] version bump to 4.0.10 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 480d89185..d94d6cf7a 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", diff --git a/package.json b/package.json index b65ed858f..25d3d22d7 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", From 7e537a83cbad67e4c361e2e4b235d693ee68cd5d Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 24 Aug 2023 09:17:43 -0700 Subject: [PATCH 06/29] fix: add better warning when defaultDateType is not set due to upgrade --- quartz/components/Date.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/quartz/components/Date.tsx b/quartz/components/Date.tsx index 0530a373a..1432255cf 100644 --- a/quartz/components/Date.tsx +++ b/quartz/components/Date.tsx @@ -8,6 +8,9 @@ interface Props { 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] } From 9973f77c5b80db824dc81dff8055d483ab5050ec Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 24 Aug 2023 09:38:00 -0700 Subject: [PATCH 07/29] fix: ensure recentnotes uses proper date --- quartz/components/RecentNotes.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/components/RecentNotes.tsx b/quartz/components/RecentNotes.tsx index 673d08458..cb14b3343 100644 --- a/quartz/components/RecentNotes.tsx +++ b/quartz/components/RecentNotes.tsx @@ -48,7 +48,7 @@ export default ((userOpts?: Partial) => {
{page.dates && (

- +

)}
    From 9707aadf11cf825f00691b7e6732e246d068161c Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 24 Aug 2023 10:03:14 -0700 Subject: [PATCH 08/29] format --- quartz/components/Date.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/quartz/components/Date.tsx b/quartz/components/Date.tsx index 1432255cf..8713cfd3c 100644 --- a/quartz/components/Date.tsx +++ b/quartz/components/Date.tsx @@ -9,7 +9,9 @@ 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.`) + 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] } From d75cf0b3546bb22862862c827c4b6b7da0eeb39a Mon Sep 17 00:00:00 2001 From: Zero King Date: Fri, 25 Aug 2023 02:41:20 +0800 Subject: [PATCH 09/29] feat: reproducible build (#412) for sitemap, RSS and contentIndex.json. --- quartz/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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")}`, ) From 818fa95f0f13486c5bbb1c57b699f0ade5fba668 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Thu, 24 Aug 2023 21:28:06 +0200 Subject: [PATCH 10/29] style: integrate tertiary color to text-select (#413) --- quartz/styles/base.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 16a0de3a6..aa7fce482 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, From 477922e5f032bb23b7880ac540883be9d3a06c08 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 24 Aug 2023 12:31:15 -0700 Subject: [PATCH 11/29] format, ensure ci runs on prs --- .github/workflows/ci.yaml | 3 +++ quartz/styles/base.scss | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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/quartz/styles/base.scss b/quartz/styles/base.scss index aa7fce482..34def8783 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -29,7 +29,7 @@ section { ::selection { background: color-mix(in srgb, var(--tertiary) 75%, transparent); - color: var(--darkgray) + color: var(--darkgray); } p, From 6bcae12f6dcb191bfc609f848548766998180a83 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Fri, 25 Aug 2023 18:03:49 +0200 Subject: [PATCH 12/29] feat(consistency): Add `.obsidian` to ignorePatterns (#420) --- quartz.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz.config.ts b/quartz.config.ts index 64e86dce0..31d5bcfea 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -10,7 +10,7 @@ const config: QuartzConfig = { provider: "plausible", }, baseUrl: "quartz.jzhao.xyz", - ignorePatterns: ["private", "templates"], + ignorePatterns: ["private", "templates", ".obsidian"], defaultDateType: "created", theme: { typography: { From da64b9be3a5de52464f0d1e084fca3bba964bba7 Mon Sep 17 00:00:00 2001 From: Hrishikesh Barman Date: Fri, 25 Aug 2023 22:55:46 +0530 Subject: [PATCH 13/29] feat(plugins): add toml support for frontmatter (#418) * feat(plugins): add toml support for frontmatter Currently frontmatter is expected to be yaml, with delimiter set to "---". This might not always be the case, for example ox-hugo(a hugo exporter for org-mode files) exports in toml format with the delimiter set to "+++" by default. With this change, the users will be able use frontmatter plugin to support this toml frontmatter format. Example usage: `Plugin.FrontMatter({delims: "+++", language: 'toml'})` - [0] https://ox-hugo.scripter.co/doc/org-meta-data-to-hugo-front-matter/ * fixup! feat(plugins): add toml support for frontmatter --- package-lock.json | 6 ++++++ package.json | 1 + quartz/plugins/transformers/frontmatter.ts | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/package-lock.json b/package-lock.json index d94d6cf7a..9246cc992 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 25d3d22d7..6ed52d602 100644 --- a/package.json +++ b/package.json @@ -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/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 3f55b9cb6..a7249c19e 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) => { @@ -25,6 +28,7 @@ export const FrontMatter: QuartzTransformerPlugin | undefined> ...opts, engines: { yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object, + toml: (s) => toml.parse(s) as object, }, }) From 1de352dc114beb1d5a4abcbd654abc829ce924fd Mon Sep 17 00:00:00 2001 From: Hrishikesh Barman Date: Sat, 26 Aug 2023 11:22:23 +0530 Subject: [PATCH 14/29] feat(plugins): add OxHugoFlavouredMarkdown (#419) * feat(plugins): add OxHugoFlavouredMarkdown 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 extensive. In the future however, we could leapfrog ox-hugo altogether and create a quartz site directly out of org-roam files. That way we won't have to do all the ritual dancing that this plugin has to perform. See https://github.com/k2052/org-to-markdown * fix: add toml to remarkFrontmatter configuration * docs: add docs for OxHugoFlavouredMarkdown * fixup! docs: add docs for OxHugoFlavouredMarkdown --- docs/features/OxHugo compatibility.md | 38 +++++++++++ quartz/plugins/transformers/frontmatter.ts | 2 +- quartz/plugins/transformers/index.ts | 1 + quartz/plugins/transformers/oxhugofm.ts | 73 ++++++++++++++++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 docs/features/OxHugo compatibility.md create mode 100644 quartz/plugins/transformers/oxhugofm.ts diff --git a/docs/features/OxHugo compatibility.md b/docs/features/OxHugo compatibility.md new file mode 100644 index 000000000..12774e725 --- /dev/null +++ b/docs/features/OxHugo compatibility.md @@ -0,0 +1,38 @@ +--- +tags: + - plugin/transformer +--- + +Quartz is a static-site generator that transforms markdown content into web pages. [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/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index a7249c19e..571aa04d0 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -21,7 +21,7 @@ export const FrontMatter: QuartzTransformerPlugin | undefined> name: "FrontMatter", markdownPlugins() { return [ - remarkFrontmatter, + [remarkFrontmatter, ["yaml", "toml"]], () => { return (_, file) => { const { data } = matter(file.value, { diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 8013ab7cc..d9f2854c0 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -5,5 +5,6 @@ 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" 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 + }, + } +} From 6e8de47b7e664f91416deffabdc867ca02cf52be Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sat, 26 Aug 2023 10:42:55 -0700 Subject: [PATCH 15/29] docs: simplify oxhugo page --- docs/features/OxHugo compatibility.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/OxHugo compatibility.md b/docs/features/OxHugo compatibility.md index 12774e725..7801f0c25 100644 --- a/docs/features/OxHugo compatibility.md +++ b/docs/features/OxHugo compatibility.md @@ -3,9 +3,9 @@ tags: - plugin/transformer --- -Quartz is a static-site generator that transforms markdown content into web pages. [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. +[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. +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: { From 29c4087deae6f38775b8f4ea685b4cf4b593bbf0 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sat, 26 Aug 2023 10:48:34 -0700 Subject: [PATCH 16/29] style: fix mulitline callout styling --- package-lock.json | 1 + quartz/styles/callouts.scss | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 9246cc992..09488c422 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,6 +112,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { 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 { From 0688a2415f57fd3eebac1fb2f1ab85702957d252 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Sat, 26 Aug 2023 22:21:44 +0200 Subject: [PATCH 17/29] feat: support CLI arguments for `npx quartz create` (#421) * feat(cli): add new args for content + link resolve * feat(cli): validate cmd args * feat(cli): add chalk + error code to errors * feat(cli): support for setup/link via args * refactor(cli): use yargs choices instead of manual Scrap manual check if arguments are valid, use yargs "choices" field instead. * feat(cli): add in-dir argument+ handle errors add new "in-directory" argument, used if "setup" is "copy" or "symlink" to determine source. add error handling for invalid permutations of arguments or non existent path * feat(cli): dynamically use cli or provided args use "in-directory" arg as `originalFolder` if available, otherwise get it from manual cli process * run format * fix: use process.exit instead of return * refactor: split CommonArgv and CreateArgv * refactor(cli): rename create args, use ${} syntax * fix(cli): fix link resolution strategy arg * format * feat(consistency): allow partial cmd args --- quartz/bootstrap-cli.mjs | 188 +++++++++++++++++++++++++++------------ 1 file changed, 133 insertions(+), 55 deletions(-) diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index b191b49c8..1deb18fe6 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -43,6 +43,27 @@ const CommonArgv = { }, } +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", + }, +} + const SyncArgv = { ...CommonArgv, commit: { @@ -147,24 +168,73 @@ yargs(hideBin(process.argv)) .scriptName("quartz") .version(version) .usage("$0 [args]") - .command("create", "Initialize Quartz", CommonArgv, async (argv) => { + .command("create", "Initialize Quartz", CreateArgv, 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!", - }, - ], - }), - ) + 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) @@ -177,23 +247,28 @@ yargs(hideBin(process.argv)) 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" - } - }, - }), - ), - ) + 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") { @@ -217,29 +292,32 @@ 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", - }, - ], - }), - ) + // 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") From 3233d5b08a9a3cfb4558b848ec51c178f682b689 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Sun, 27 Aug 2023 02:19:45 +0200 Subject: [PATCH 18/29] Fix search bar after navigate (#424) --- quartz/components/scripts/search.inline.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index adcd06abc..ef26ba380 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -128,6 +128,7 @@ document.addEventListener("nav", async (e: unknown) => { button.addEventListener("click", () => { const targ = resolveRelative(currentSlug, slug) window.spaNavigate(new URL(targ, window.location.toString())) + hideSearch() }) return button } From d4187034c27b41cda72c0bb1fff5aa47faf029bd Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sun, 27 Aug 2023 12:27:42 -0700 Subject: [PATCH 19/29] fix: slugify tag on page before adding (closes #411) --- quartz/plugins/transformers/ofm.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index bed6f622c..4d1586f93 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -383,13 +383,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"], From 78eb93eb850277301a451fa1d7af2661085781cf Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Mon, 28 Aug 2023 00:59:51 +0200 Subject: [PATCH 20/29] cleanup: rework cli to allow invoking create and build outside of cli (#428) * refactor: move `bootstrap-cli.mjs` tp cli also update reference in docs * refactor(cli): move build handler to `cli-functions` * refactor(cli): move create to handler + helpers * refactor(cli): extract arg definitions * refactor: rename handlers and helpers * refactor(cli): move update, await handlers * refactor(cli): create constants, migrate to helpers * refactor(cli): migrate `restore` * refactor(cli): migrate `sync` * format * refactor(cli): remove old imports/functions * refactor(cli): remove unused imports + format * chore: remove old log statement * fix: fix imports, clean duplicate code * fix: relative import * fix: simplified cacheFile path * fix: update cacheFile import path * refactor: move bootstrap-cli to quartz * format * revert: revert path to bootstrap-cli * ci: re-run * ci: fix execution permission --- package-lock.json | 1 - quartz/bootstrap-cli.mjs | 617 +-------------------------------------- quartz/cli/args.js | 88 ++++++ quartz/cli/constants.js | 15 + quartz/cli/handlers.js | 511 ++++++++++++++++++++++++++++++++ quartz/cli/helpers.js | 52 ++++ 6 files changed, 680 insertions(+), 604 deletions(-) create mode 100644 quartz/cli/args.js create mode 100644 quartz/cli/constants.js create mode 100644 quartz/cli/handlers.js create mode 100644 quartz/cli/helpers.js diff --git a/package-lock.json b/package-lock.json index 09488c422..9246cc992 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,7 +112,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index 1deb18fe6..35d06af77 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -1,628 +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 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", - }, -} - -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", CreateArgv, async (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) -`) + 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() diff --git a/quartz/cli/args.js b/quartz/cli/args.js new file mode 100644 index 000000000..4f330cd9e --- /dev/null +++ b/quartz/cli/args.js @@ -0,0 +1,88 @@ +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", + }, + 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..cba0ceb26 --- /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: 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() + } +} + +/** + * 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 }) +} From ef762f28cd851fa0692138aa5efe2bd5384d8b82 Mon Sep 17 00:00:00 2001 From: Jeremy Press Date: Sun, 27 Aug 2023 17:39:42 -0700 Subject: [PATCH 21/29] feat: support configurable ws port and remote development (#429) Co-authored-by: Jeremy Press Co-authored-by: Jacky Zhao --- .gitignore | 2 ++ quartz/cli/args.js | 10 ++++++++++ quartz/cli/handlers.js | 2 +- quartz/plugins/emitters/componentResources.ts | 8 +++++++- quartz/util/ctx.ts | 2 ++ 5 files changed, 22 insertions(+), 2 deletions(-) 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/quartz/cli/args.js b/quartz/cli/args.js index 4f330cd9e..3543e2e89 100644 --- a/quartz/cli/args.js +++ b/quartz/cli/args.js @@ -76,6 +76,16 @@ export const BuildArgv = { 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, diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js index cba0ceb26..bc3da73f3 100644 --- a/quartz/cli/handlers.js +++ b/quartz/cli/handlers.js @@ -402,7 +402,7 @@ export async function handleBuild(argv) { return serve() }) server.listen(argv.port) - const wss = new WebSocketServer({ port: 3001 }) + const wss = new WebSocketServer({ port: argv.wsPort }) wss.on("connection", (ws) => connections.push(ws)) console.log( chalk.cyan( diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index a62bc382b..61409cc57 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/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 } From 43eeb8f4e72b3717dac5a6369e5aee045b19acd6 Mon Sep 17 00:00:00 2001 From: Jeremy Press Date: Sun, 27 Aug 2023 20:57:19 -0700 Subject: [PATCH 22/29] Fix typo :) (#430) --- quartz/plugins/emitters/componentResources.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 61409cc57..c52a3a20e 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -118,7 +118,7 @@ function addGlobalPageResources( loadTime: "afterDOMReady", contentType: "inline", script: ` - const socket = new WebSocket('${wsUrl}'') + const socket = new WebSocket('${wsUrl}') socket.addEventListener('message', () => document.location.reload()) `, }) From e81525651777833527dd069a2283e2869815514c Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Mon, 28 Aug 2023 19:00:49 +0200 Subject: [PATCH 23/29] fix: correct graph labels for `index.md` nodes (#431) --- quartz/components/scripts/graph.inline.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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") From 683c40a0ac5e4c14aaa7c1d78d7ce4043d70a144 Mon Sep 17 00:00:00 2001 From: Jeffrey Fabian Date: Tue, 29 Aug 2023 13:14:54 -0400 Subject: [PATCH 24/29] feat: support kebab-case and nested tags in Obsidian-flavored Markdown tag-in-content parsing (#425) * enhancement: support kebab-case and nested tags in ofm transformer * update regex/capture groups to allow for (arbitrarily) nested values and tags of only -/_ * Update quartz/plugins/transformers/ofm.ts --------- Co-authored-by: Jacky Zhao --- quartz/plugins/transformers/ofm.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 4d1586f93..2e8fadb22 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -114,9 +114,11 @@ 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]}
    @@ -429,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' From 67451fa0fd87965ce6c6ad0ef28dec76bd3f7b95 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Tue, 29 Aug 2023 10:37:00 -0700 Subject: [PATCH 25/29] fix: aliasredirects not using full path, add permalink support --- quartz/plugins/emitters/aliases.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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: ` From f06440806fd6ea80f49e49024acd5d10e6506181 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Thu, 31 Aug 2023 20:55:04 +0200 Subject: [PATCH 26/29] fix: regex for matching highlights (closes #437) (#438) * fix: regex for matching highlights * fix: regex for empty highlights --- quartz/plugins/transformers/ofm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 2e8fadb22..8c8da67bc 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -109,7 +109,7 @@ 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+)\]([+-]?)/) From b9be4e470c68197bdcee6e700a7acf6c3c7dd0f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pelayo=20Arbu=C3=A9s?= Date: Thu, 31 Aug 2023 21:12:06 +0200 Subject: [PATCH 27/29] Adds Pelayo Arbues to showcase (#435) --- docs/showcase.md | 1 + 1 file changed, 1 insertion(+) 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)! From fa0629716f154216985d3bb484cb13d9f8f746ca Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Fri, 1 Sep 2023 19:09:58 +0200 Subject: [PATCH 28/29] feat: Implement search for tags (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Quartz sync: Aug 29, 2023, 10:17 PM * style: add basic style to tags in search * feat: add SearchType + tags to search preview * feat: support multiple matches * style(search): add style to matching tags * feat(search): add content to preview for tag search * fix: only display tags on tag search * feat: support basic + tag search * refactor: extract common `fillDocument`, format * feat: add hotkey to search for tags * chore: remove logs * fix: dont render empty `
      ` if tags not present * fix(search-tag): make case insensitive * refactor: clean `hideSearch` and `showSearch` * feat: trim content similar to `description.ts` * fix(search-tag): hotkey for windows * perf: re-use main index for tag search --- quartz/components/scripts/search.inline.ts | 163 ++++++++++++++++++--- quartz/components/styles/search.scss | 38 +++++ 2 files changed, 179 insertions(+), 22 deletions(-) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index ef26ba380..806a746e6 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,21 +129,77 @@ 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), + // 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())) @@ -148,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) } @@ -167,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) @@ -190,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; } From 53dd86727bc0b0a5cb26604ebee8ffb491ef93d1 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Fri, 1 Sep 2023 23:12:32 +0200 Subject: [PATCH 29/29] fix(search): matches getting highlighted in title (#440) --- quartz/components/scripts/search.inline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 806a746e6..4b9e372bc 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -157,7 +157,7 @@ document.addEventListener("nav", async (e: unknown) => { return { id, slug, - title: highlight(term, data[slug].title ?? ""), + 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"