banner

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, and ignorePatterns in quartz.config.ts
  • Replace icon.png and og-image.png in static/

Optional:

  • Update LICENSE.txt with my name and current date (but copy the original to LICENSE-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'.
.github/workflows/actions.yml
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

  1. Go to https://github.com/settings/tokens and create a new PAT (classic).
    1. Give it full repo access.
  2. 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 with GITHUB_)
  3. Add a line under actions/checkout.with like this: token: ${{ secrets.PAT_FOR_PRIVATE_REPOS }}

DNS

  1. Add a new verified domain: https://github.com/settings/pages_verified_domains/new (type in something like garden.bencuan.me)
  2. Follow the instructions to create a TXT record for verification.
  3. Go to your repo settings Pages type in your custom domain (like garden.bencuan.me)
  4. Follow the instructions to create a CNAME record.
  5. Wait a few minutes for the DNS check to become successful, then refresh the page and check the “Enforce HTTPS” box.
  6. 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:

quartz.config.ts
...(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:

quartz.config.ts
...(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 of right.
  • 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:

custom.scss for cool code block titles!
// 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:

  1. Standardize everyone’s emoji experience to Noto Color Emoji which is a lot nicer to look at than many default system fonts
  2. Allow me to easily add custom emojis like these: evergreen panda 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:

Decorative divider

The original component looks something like this:

divider.tsx
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:

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:

contentIndex.tsx
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:

frontmatter.ts
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.

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:

latest_dataview
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

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)

graph.inline.ts
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:

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

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:

homepage.inline.ts
// 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 to public.

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 to bencuan-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

  1. Go to Netlify’s Project configuration page, then click “Generate public deploy key”
  2. 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.