https://github.com/64bitpandas/garden
Summary
This page tracks all of steps I’ve taken to deploy this website.
Installing and deploying quartz
Quartz is at https://github.com/jackyzha0/quartz.
Run:
git clone https://github.com/jackyzha0/quartz garden
cd garden
rm -rf .git
git init
git remote add origin https://github.com/64bitpandas/garden
git add . && git commit -m "initial commit"
git push --set-upstream origin
Basic Configuration
Some trivial edits to customize metadata.
- Edit the name, description, repository url, and author fields in
package.json
- Set
pageTitle
,baseUrl
, andignorePatterns
inquartz.config.ts
- Replace
icon.png
andog-image.png
instatic/
Optional:
- Update
LICENSE.txt
with my name and current date (but copy the original toLICENSE-quartz.txt
or something) - Delete
.github/FUNDING.yml
and.github/ISSUE_TEMPLATE/
Making quartz work with submodules
I’d like to keep my Quartz deployment open source while still allowing some of my notes to remain private. The solution I’m using is to include my Obsidian vault as a git submodule within the content/vsh
folder.
While this generally works well, I’ve needed to make a few tweaks to get things running smoothly.
Configuring github actions
workflow
Add this file to the repo: https://quartz.jzhao.xyz/hosting#github-pages
- I had to edit to include a
submodules: 'true'
.
name: Deploy to GitHub Pages
on:
push:
branches:
- main
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for git info
submodules: 'true'
token: ${{ secrets.PAT_FOR_PRIVATE_REPOS }}
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Dependencies
run: npm ci
- name: Build Quartz
run: npx quartz build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: public
deploy:
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Private submodule access
- Go to https://github.com/settings/tokens and create a new PAT (classic).
- Give it full repo access.
- Go to the repo settings → Secrets and variables → Actions → New repository secret. Paste in the PAT and name it something like
PAT_FOR_PRIVATE_REPOS
. (It can’t start withGITHUB_
) - Add a line under
actions/checkout.with
like this:token: ${{ secrets.PAT_FOR_PRIVATE_REPOS }}
DNS
- Add a new verified domain: https://github.com/settings/pages_verified_domains/new (type in something like
garden.bencuan.me
) - Follow the instructions to create a TXT record for verification.
- Go to your repo settings → Pages → type in your custom domain (like
garden.bencuan.me
) - Follow the instructions to create a CNAME record.
- Wait a few minutes for the DNS check to become successful, then refresh the page and check the “Enforce HTTPS” box.
- If you added
deploy.yml
successfully, you should get a banner like this, and your site should be deployed!
Change default content path
Quartz takes in a -d
argument to specify where content should be served from.
For convenience, I updated the default value in cli/args.js
to content/vsh
so I didn’t have to remember to pass in -d
every time.
(Alternatively, I could also make a npm script in package.json
with the right args.)
Infer dates from submodule
Out of the box, Quartz will spam you with warnings like Warning: content/vsh/meta/Build Log.md isn't yet tracked by git, dates will be inaccurate
and misreport the creation/update dates of all pages in submodules.
I updated the CreatedModifiedDate
plugin in lastmod.ts
with the following. It’s hard-coded to content/vsh
, so
// try to get dates from the submodule
const submodulePath = "content/vsh"
if (fullFp.includes(submodulePath)) {
// Try to discover the submodule repository
const submoduleRepo = Repository.discover(path.join(ctx.argv.directory))
// Get path relative to the submodule root
const submoduleRelativePath = fullFp.replace(/^.*?content\/vsh\//, '')
modified ||= await submoduleRepo.getFileLatestModifiedDateAsync(submoduleRelativePath)
}
GARDEN_DEV=1
There are a few things I want in a development environment but don’t want in production, so I run npm run dev
, which is set to GARDEN_DEV=1 npx quartz build --serve
.
Disable CustomOgImages
The CustomOgImages
emitter is slow on rebuild, so disable it in dev by replacing it in quartz.config.ts
with this line:
...(process.env.GARDEN_DEV ? [] : [Plugin.CustomOgImages()]),
Build docs
I moved the docs/
folder into content/vsh
so I could have it around while developing.
I add this to ignorePatterns
so it doesn’t get included in the final build:
...(process.env.GARDEN_DEV ? [] : ["quartz docs"])
Styles and Cosmetics
Custom image banner
Edit the PageTitle.tsx
component to be an image instead of a h2
:
const PageTitle: QuartzComponent = ({ fileData, cfg, displayClass }: QuartzComponentProps) => {
const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
const baseDir = pathToRoot(fileData.slug!)
const bannerPath = `${baseDir}/static/gardenbanner.png`
return (
<div class={classNames(displayClass, "page-title")}>
<a href={baseDir}>
<img src={bannerPath} alt={title} class="page-title-banner" />
</a>
</div>
)
}
Layout
- Always show the Graph View/Backlinks on the bottom of the page by moving them to
afterBody
instead ofright
. - Show the graph view and backlinks on the same row with a
Component.Flex()
on desktop; stack them on mobile. - Move the
afterBody: [
Component.DesktopOnly(Component.Flex({
components: [
{ Component: Component.Backlinks(), grow: false },
{ Component: Component.Graph(), grow: true },
],
direction: "row",
gap: "2rem",
})),
Component.MobileOnly(Component.Backlinks()),
Component.MobileOnly(Component.Graph()),
],
Code blocks
Custom title:
// code block border around title
figure[data-rehype-pretty-code-figure] {
border: 1px solid var(--rose);
border-radius: 5px;
figcaption[data-rehype-pretty-code-title] {
border: none;
padding: 0.25em 1em;
border-radius: 0;
color: var(--highlight-low);
background-color: var(--rose);
width: auto;
}
pre[data-language] {
margin: 0;
border: none;
border-radius: 0px;
padding-top: 0.5em;
}
}
Set a custom theme in quartz.config.ts
:
- Use Rosè Pine as a color theme.
Plugin.SyntaxHighlighting({
theme: {
light: "rose-pine-dawn",
dark: "rose-pine-moon",
},
keepBackground: false,
}),
Fonts
Fonts
Some of my fonts are not FOSS, so I license them for personal use. Typically, these licenses don’t include redistribution rights so I need to be careful not to publish their source files in the garden
repo.
All of my fonts live in a separate private fonts
repo, which is included as a submodule:
git submodule add https://github.com/64bitpandas/fonts static/fonts
I then declare custom font-faces in _fonts.scss
, then import it with @use fonts;
in custom.scss
. An example is below:
@font-face {
font-family: "The Cats Whiskers";
font-style: normal;
font-weight: normal;
font-display: swap;
src: url("/static/fonts/TheCatsWhiskers.woff2") format("woff2");
}
Then, set the font settings in quartz.config.ts
:
fontOrigin: "local",
cdnCaching: true,
typography: {
header: "Charter",
body: "Atkinson Hyperlegible Next",
code: "Fira Code",
},
(see anti font piracy for more thoughts on this topic! Or, see Garden Design to browse the full font selection.)
To set the font sizes:
- Edit headers in
base.scss
f
Colors
Quartz offers some color configs in quartz.config.ts
. To figure out what each of them actually does, I turned them all into very ugly default colors!
Here is the annotated color config:
lightMode: {
light: "#faf4ed", // background color
lightgray: "red", // search bar background, <hr/> default color, explorer alignment rules, graph edges, code background
gray: "orange", // subheading, graph forward links
darkgray: "yellow", // default text color and icons
dark: "green", // explorer subheadings, titles, link icons, code text
secondary: "blue", // graph current page, explorer main headings, links
tertiary: "purple", // text highlight/hover, graph backlinks, current explorer page
highlight: "gold", // internal link background
textHighlight: "pink", // idk??
},
I also turned off dark mode so I only need to deal with one color scheme for now. That may change in the future when I stop being lazy (probably never).
Here’s the final version. See Color Palette for more details.
Custom Transformers
Custom transformers preprocess markdown as they get converted into HTML. They live in the quartz/plugins/transformers
folder and are enabled in quartz.config.ts
.
Emoji Transformer
https://github.com/64bitpandas/garden/blob/main/quartz/plugins/transformers/emojiInline.ts
The custom emoji transformer enables a few neat tricks:
- Standardize everyone’s emoji experience to Noto Color Emoji which is a lot nicer to look at than many default system fonts
- Allow me to easily add custom emojis like these:
by dropping square images into the
static/emoji/custom
folder
There are a few shortcomings of the emoji transformer still to be addressed:
- It doesn’t properly do replacements for some of the other transformers (like the table of contents)
- Right now all of my custom emojis are
.png
, but if I ever wanted to introduce svg’s or other image extensions there’s no great way to address that. - Performance is not great; would be better to spritesheetify everything and/or reduce the image sizes. Emojis don’t need to be 512x512!!
Starbits
Out of the box, the Quartz build pipeline looks like this (source):
Quartz also has support for custom components like the table of contents or graph view. However, using these requires going through the Quartz layout system.
I’d like to access reusable React components from inside of a Markdown file, Astro-style. It would be awesome to inline common interactive bits without needing to copy-paste them every time!
As a simple example, here’s a custom divider:

The original component looks something like this:
interface Options {
height?: string
className?: string
}
export default function Divider(props: Options) {
return (
<div className={props.className ?? "content-divider"}>
<img
src="/static/divider.png"
alt="Decorative divider"
style={{
height: props.height,
width: "auto",
display: "block",
margin: "1.5rem auto"
}}
/>
</div>
)
}
Starbits are implemented as a custom transformer (starbits.ts) that matches anything in the form of :::COMPONENT_NAME:::
, with three :
colons before and after a desired component name. It also allows json props as a suffix, like:
:::COMPONENT_NAME::: {prop1: foo}
To register a starbit, create a new
Custom ContentDetails
ContentDetails are used in various components and transformers that iterate over every page (like the Explorer view and Sitemap). By default it comes with a few basic properties (like title
and tags
). I added a few extra (stage
, certainty
, customIcon
, featured
, etc) so they could be accessed in the explorer for better styling.
To do so, edit the ContentDetails
type at the top of contentIndex.tsx
:
export type ContentDetails = {
slug: FullSlug
filePath: FilePath
title: string
links: SimpleSlug[]
tags: string[]
content: string
richContent?: string
date?: Date
description?: string
stage?: number
certainty?: number
// add more custom types here
}
Then, edit the ContentIndex
function to fetch the values:
export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
opts = { ...defaultOptions, ...opts }
return {
...
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
linkIndex.set(slug, {
slug,
filePath: file.data.relativePath!,
title: file.data.frontmatter?.title!,
links: file.data.links ?? [],
tags: file.data.frontmatter?.tags ?? [],
stage: file.data.frontmatter?.stage ?? 0,
certainty: file.data.frontmatter?.certainty ?? 0,
content: file.data.text ?? "",
richContent: opts?.rssFullHtml
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
: undefined,
date: date,
description: file.data.description ?? "",
})
}
}
...
If you’re fetching this from the frontmatter, you’ll also need to edit the bottom of frontmatter.ts
to get proper type resolution:
declare module "vfile" {
interface DataMap {
aliases: FullSlug[]
frontmatter: { [key: string]: unknown } & {
title: string
} & Partial<{
tags: string[]
aliases: string[]
modified: string
created: string
published: string
description: string
socialDescription: string
publish: boolean | string
draft: boolean | string
lang: string
enableToc: string
cssclasses: string[]
socialImage: string
comments: boolean | string
stage: number
certainty: number
// Add more custom types here
}>
}
}
Homepage
The homepage is the single most customized page in the garden. Here’s what went into it.
Custom Homepage Emitter
By default, Quartz handles the root homepage in the same way as any other content page. In order to fully customize it, I created a new emitter plugin homePage.tsx that matches only the root /index
page.
- This uses a custom homepage layout in
quartz.layout.ts
that omits the graph, table of contents, and backlinks.
Live API integrations
I have several endpoints hosted on api.bencuan.me
for various live bits.
/get-claps
powers the ’ people were here before you’ button. This was stolen from bencuan.me v6./spotify
returns information about what song I’m currently listening to on Spotify./weather
connects to the OpenWeatherMap API to return the current weather in San Mateo County. It’s cached in-memory with a TTL of 15 minutes, so spam-refreshing the page won’t exhaust my quota./status
queries the TurtleNet status page It returns ‘OK’ if the page contains the text “All Systems Operational”, and ‘Not OK’ otherwise.
All endpoints have a global ratelimit, so they should be immune from spam. Since I don’t really host anything of value to potential exploiters, I don’t think too hard about security and just let this + Cloudflare deal with the low-level issues.
Featured and Latest lists
The lists of most-recently featured and updated pages are driven on the Obsidian side by Obsidian Dataview Serializer. By the time they hit Quartz, they’re already compiled into Markdown tables.
The Dataview queries are as follows:
TABLE dateformat(file.mtime, "yyyy-MM-dd") as "Modified"
FROM -"daily" AND -"private" AND -"quartz docs" AND -"blog" AND -"about/templates"
SORT file.mtime DESC
WHERE file.name != this.file.name AND draft != true AND private != true AND graphExclude != true
LIMIT 3
dateformat(featured, "yyyy-MM-dd") as "Modified" FROM -"daily" AND -"private" AND -"quartz docs" AND -"blog" AND -"about/templates" SORT file.mtime DESC WHERE file.name != this.file.name AND file.draft != "true" AND file.private != "true" AND featured LIMIT 3
Guestbook
The guestbook is built on my self-hosted Isso instance.
Custom opengraph image
https://quartz.jzhao.xyz/plugins/CustomOgImages
By default, pages contain an auto-generated OpenGraph image based on its contents. It looks something like this:
In order to override this for the homepage specifically, I had to set the frontmatter cover
to og-image.png
(this is relative to static/
) and the description
to a custom value. Now it looks like this:
Bugfixes
Ignore file move errors on auto-relink
Whenever I move a file that’s linked in another file, Obsidian auto-rewrites the link before moving it. However, this introduces some race condition where npx quartz build --serve
crashes with an exception like
Failed to process markdown
content/vsh/meta/garden build log.md
: ENOENT: no such file or directory, open ‘/Users/bencuan/Library/Mobile Documents/iCloud~md~obsidian/Documents/garden/content/vsh/meta/garden build log.md’
To make Quartz silently eat the error instead of crashing, I wrapped the const file = await read(fp)
line in createFileParser
of parse.ts
in a try/catch:
export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
...
let file
try {
file = await read(fp)
} catch (err) {
if (argv.verbose) {
console.log(chalk.yellow(`[warning] Skipping file that doesn't exist: ${fp}`))
}
continue
}
...
}
And also await fs.promises.unlink(dest)
in assets.ts
:
try {
await fs.promises.unlink(dest)
} catch(err) {
console.log(chalk.yellow(`Failed to delete file ${dest}: ${err}`))
}
The texture size is empty
The graph view seems to have a bug that causes these warnings:
The texture size ([Extent3D width:0, height:500, depthOrArrayLayers:1]) or mipLevelCount (1) is empty.
at ValidateTextureDescriptor (../../third_party/dawn/src/dawn/native/Texture.cpp:740)
Could not create a swapchain texture of size 0.
I’m too lazy to debug the real issue for now, so I made this hack. It mostly works, but the graph width is still sometimes smaller than the actual bounding box (probably some strange interaction with the layout plugin’s grow
option)
async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
const slug = simplifySlug(fullSlug)
const visited = getVisited()
removeAllChildren(graph)
graph.style.visibility = "visible" // offsetWidth is always 0 when element is hidden
let width = graph.parentElement?.offsetWidth || 300
let height = graph.parentElement?.offsetHeight || 250 // this seems to always be 0
// this function seems to be getting called twice, once with the correct dimensions and once without
if (!width && !height) {
return () => {}
}
Explorer sometimes emits private pages
It seems like this only happens on hot reload, but it’s slightly annoying to deal with during development.
The solution is to add the private
field as a custom ContentDetails field, then refer to it in Explorer.tsx
:
filterFn: (node) => node.slugSegment !== "tags" && !node.data?.private,
Fixing ‘remote end hung up unexpectedly’
git push
seems to throw 400’s sometimes:
❯ git push
Enumerating objects: 142, done.
Counting objects: 100% (142/142), done.
Delta compression using up to 8 threads
Compressing objects: 100% (133/133), done.
error: RPC failed; HTTP 400 curl 22 The requested URL returned error: 400
send-pack: unexpected disconnect while reading sideband packet
Writing objects: 100% (138/138), 11.96 MiB | 4.54 MiB/s, done.
Total 138 (delta 7), reused 0 (delta 0), pack-reused 0
fatal: the remote end hung up unexpectedly
Everything up-to-date
The solution is to run this command:
git config --global http.postBuffer 52428800
Credit: https://stackoverflow.com/questions/15240815/git-fatal-the-remote-end-hung-up-unexpectedly
Isso disappeared when clicking on links
All of the Isso links (like ‘Reply’ or the upvote button) caused the guestbook to disappear when clicked. This is because Quartz treats them like internal links (due to href="#"
) and attempts to reload the page.
This inline function fixes that issue, once run on load or nav change:
// Isso links have an href="#" that causes a page reload when clicked. This disables that behavior.
function setupIssoComments() {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "childList") {
const issoLinks = document.querySelectorAll(
".isso-reply, .isso-edit, .isso-delete, .isso-upvote, .isso-downvote",
)
issoLinks.forEach((link) => {
if (!link.hasAttribute("data-fixed")) {
link.setAttribute("data-fixed", "true")
link.addEventListener("click", (e) => {
e.preventDefault()
e.stopPropagation()
// The link's onclick handler will still run
})
}
})
}
}
})
// Start observing the isso thread for changes
const issoThread = document.getElementById("isso-thread")
if (issoThread) {
observer.observe(issoThread, { childList: true, subtree: true })
}
}
Things that didn’t work
iCloud sync
See recovering a bricked icloud. Something in my vault destroyed iCloud during development, and caused it to hang indefinitely. I had to move away from icloud entirely for sync.
netlify
Netlify doesn’t work because it only allows you to generate 1 deploy key, but since I have multiple private submodules I need multiple deploy keys. Using GitHub Actions is nicer because my Github account already has access to my private repos without any extra configuration.
Here are some steps I initially took to set up netlify: https://quartz.jzhao.xyz/hosting#netlify
- Set the build command to
npx quartz build
and the publish directory topublic
.
Configure the custom domain to point garden.bencuan.me
to the right place: https://app.netlify.com/sites/bencuan-garden/domain-management
- Add a
netlify-challenge
TXT record to verify the domain. - Add a
garden
CNAME record to point tobencuan-garden.netlify.app.
- Wait a few minutes for the DNS to propagate and for the SSL cert to provision.
For one private submodule: https://docs.netlify.com/git/repo-permissions-linking/#deploy-keys
- Go to Netlify’s Project configuration page, then click “Generate public deploy key”
- Go to the GitHub repo you want to authenticate to, click on the Settings, then go to Deploy keys. Paste the Netlify public key into the box.