12 KiB
AGENTS.md - Coding Agent Instructions
This document provides essential information for AI coding agents working in this repository.
Project Overview
Quartz is a static site generator for publishing digital gardens and notes as websites. Built with TypeScript, Preact, and unified/remark/rehype for markdown processing.
| Stack | Technology |
|---|---|
| Language | TypeScript 5.x (strict mode) |
| Runtime | Node.js >=22 (v22.16.0 pinned) |
| Package Mgr | npm >=10.9.2 |
| Module System | ES Modules ("type": "module") |
| UI Framework | Preact 10.x (JSX with react-jsx pragma) |
| Build Tool | esbuild |
| Styling | SCSS via esbuild-sass-plugin |
Environment
This is a Nix project. Use the provided flake.nix to enter a dev shell with Node.js 22 and npm:
nix develop
All npm commands below must be run inside the dev shell.
Build, Lint, and Test Commands
# Type check and format check (CI validation)
npm run check
# Auto-format code with Prettier
npm run format
# Run all tests
npm run test
# Run a single test file
npx tsx --test quartz/util/path.test.ts
# Run tests matching a pattern (use --test-name-pattern)
npx tsx --test --test-name-pattern="typeguards" quartz/util/path.test.ts
# Build the static site
npx quartz build
# Build and serve with hot reload
npx quartz build --serve
# Profile build performance
npm run profile
Test Files Location
Tests use Node.js native test runner via tsx. Test files follow the *.test.ts pattern:
quartz/util/path.test.tsquartz/util/fileTrie.test.tsquartz/components/scripts/search.test.ts
Code Style Guidelines
Prettier Configuration (.prettierrc)
{
"printWidth": 100,
"tabWidth": 2,
"semi": false,
"trailingComma": "all",
"quoteProps": "as-needed"
}
No ESLint - only Prettier for formatting. Run npm run format before committing.
TypeScript Configuration
- Strict mode enabled (
strict: true) noUnusedLocals: true- no unused variablesnoUnusedParameters: true- no unused function parameters- JSX configured for Preact (
jsxImportSource: "preact")
Import Conventions
// 1. External packages first
import { PluggableList } from "unified"
import { visit } from "unist-util-visit"
// 2. Internal utilities/types (relative paths)
import { QuartzTransformerPlugin } from "../types"
import { FilePath, slugifyFilePath } from "../../util/path"
import { i18n } from "../../i18n"
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Files (utils) | camelCase | path.ts, fileTrie.ts |
| Files (comps) | PascalCase | TableOfContents.tsx, Search.tsx |
| Types/Interfaces | PascalCase | QuartzComponent, FullSlug |
| Type Guards | is* prefix |
isFilePath(), isFullSlug() |
| Constants | UPPER_CASE | QUARTZ, UPSTREAM_NAME |
| Options types | Options |
interface Options { ... } |
Branded Types Pattern
This codebase uses branded types for type-safe path handling:
type SlugLike<T> = string & { __brand: T }
export type FilePath = SlugLike<"filepath">
export type FullSlug = SlugLike<"full">
export type SimpleSlug = SlugLike<"simple">
// Always validate with type guards before using
export function isFilePath(s: string): s is FilePath { ... }
Component Pattern (Preact)
Components use a factory function pattern with attached static properties:
export default ((userOpts?: Partial<Options>) => {
const opts: Options = { ...defaultOptions, ...userOpts }
const ComponentName: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
return <div class={classNames(displayClass, "component-name")}>...</div>
}
ComponentName.css = style // SCSS styles
ComponentName.afterDOMLoaded = script // Client-side JS
return ComponentName
}) satisfies QuartzComponentConstructor
Plugin Pattern
Three plugin types: transformers, filters, and emitters.
export const PluginName: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "PluginName",
markdownPlugins(ctx) { return [...] },
htmlPlugins(ctx) { return [...] },
externalResources(ctx) { return { js: [], css: [] } },
}
}
Testing Pattern
Use Node.js native test runner with assert:
import test, { describe, beforeEach } from "node:test"
import assert from "node:assert"
describe("FeatureName", () => {
test("should do something", () => {
assert.strictEqual(actual, expected)
assert.deepStrictEqual(actualObj, expectedObj)
assert(condition) // truthy assertion
assert(!condition) // falsy assertion
})
})
Error Handling
- Use
try/catchfor critical operations (file I/O, parsing) - Custom
traceutility for error reporting with stack traces process.exit(1)for fatal errorsconsole.warn()for non-fatal issues
Async Patterns
- Prefer
async/awaitover raw promises - Use async generators (
async *emit()) for streaming file output - Use
async-mutexfor concurrent build protection
Project Structure
quartz/
├── bootstrap-cli.mjs # CLI entry point
├── build.ts # Build orchestration
├── cfg.ts # Configuration types
├── components/ # Preact UI components
│ ├── *.tsx # Components
│ ├── scripts/ # Client-side scripts (*.inline.ts)
│ └── styles/ # Component SCSS
├── plugins/
│ ├── transformers/ # Markdown AST transformers
│ ├── filters/ # Content filters
│ ├── emitters/ # Output generators
│ └── types.ts # Plugin type definitions
├── processors/ # Build pipeline (parse/filter/emit)
├── util/ # Utility functions
└── i18n/ # Internationalization (30+ locales)
Branch Workflow
This is a fork of jackyzha0/quartz with org-roam customizations.
| Branch | Purpose |
|---|---|
main |
Clean mirror of upstream quartz — no custom code |
org-roam |
Default branch — all customizations live here |
feature/* |
Short-lived branches off org-roam |
Pulling Upstream Updates
git checkout main
git fetch upstream
git merge upstream/main
git checkout org-roam
git merge main
# Resolve conflicts if any, then commit
Working on Features
git checkout org-roam
git checkout -b feature/my-feature
# ... work ...
git checkout org-roam
git merge feature/my-feature
git branch -d feature/my-feature
Merge direction: upstream → main → org-roam → feature/*
Org-Roam Workflow
Notes live in a separate directory outside this repo. The export pipeline converts them to Markdown via ox-hugo, applies post-processing transforms, then Quartz builds the site.
Tooling
The dev shell (nix develop) provides:
nodejs_22— Quartz buildelixir— runs the export script and pipelineemacs+ox-hugo— performs the org → markdown conversion
Export and build
# Export only (wipes content/, exports all .org files, runs pipeline)
NOTES_DIR=/path/to/notes npm run export
# Export then build the site
NOTES_DIR=/path/to/notes npm run build:notes
# Positional arg also works
elixir scripts/export.exs /path/to/notes
Optional env vars for the pipeline:
| Var | Default | Purpose |
|---|---|---|
BIBTEX_FILE |
— | Path to .bib file for citation fallback |
ZOTERO_URL |
http://localhost:23119 |
Zotero Better BibTeX base URL |
CITATION_MODE |
warn |
silent / warn / strict |
Export pipeline phases
scripts/export.exs runs four phases in sequence:
- Wipe
content/(preserving.gitkeep) - Export each
.orgfile viaemacs --batch+ox-hugo→content/**/*.md - Pipeline — run Elixir transform modules over every
.mdfile - Index — generate a fallback
content/index.mdif none was exported
The export uses TOML frontmatter (+++) and per-file mode (not per-subtree).
Markdown pipeline (scripts/pipeline/)
A standalone Mix project that post-processes content/*.md after ox-hugo.
It is compiled automatically on first run; subsequent runs use the _build/
cache and are fast.
Architecture:
scripts/pipeline/
├── mix.exs # deps: req, jason
└── lib/
├── pipeline.ex # Generic runner (fold transforms over .md files)
├── pipeline/
│ ├── application.ex # OTP app — starts Finch HTTP pool
│ ├── transform.ex # Behaviour: init/1, apply/3, teardown/1
│ ├── transforms/
│ │ └── citations.ex # Resolves cite:key → [Label](url)
│ └── resolvers/
│ ├── zotero.ex # JSON-RPC to Zotero Better BibTeX
│ ├── bibtex.ex # Parses local .bib file
│ └── doi.ex # Bare-key fallback (always succeeds)
Adding a new transform:
- Create
scripts/pipeline/lib/pipeline/transforms/my_transform.ex - Implement the
Pipeline.Transformbehaviour (init/1,apply/3) - Append the module to
transformsinscripts/export.exs
transforms = [
Pipeline.Transforms.Citations,
Pipeline.Transforms.MyTransform, # new
]
Citation resolution (Pipeline.Transforms.Citations)
Handles org-citar syntax that passes through ox-hugo unchanged:
| Syntax | Example |
|---|---|
| org-cite / citar | [cite:@key] |
| multiple keys | [cite:@key1;@key2] |
| bare (legacy) | cite:key |
Resolution chain (first success wins):
- Zotero — JSON-RPC to
localhost:23119/better-bibtex/json-rpc- Calls
item.searchto find the item, thenitem.attachmentsto get the PDF link (zotero://open-pdf/library/items/KEY) - Falls back to
zotero://select/library/items/KEYif no PDF attachment - Probe uses a JSON-RPC call, not
/better-bibtex/cayw(that endpoint blocks waiting for interactive input)
- Calls
- BibTeX — parses
BIBTEX_FILE; extracts authors, year, DOI/URL - DOI fallback — always succeeds; renders bare key or
https://doi.org/...
Zotero JSON-RPC gotcha: Req 0.5 does not allow combining :finch and
:connect_options in the same call. Use :receive_timeout only.
Important Notes
- Client-side scripts: Use
.inline.tssuffix, bundled via esbuild - Isomorphic code:
quartz/util/path.tsmust not use Node.js APIs - Incremental builds: Plugins can implement
partialEmitfor efficiency - Markdown flavors: Supports Obsidian (
ofm.ts) and Roam (roam.ts) syntax - Pipeline build artifacts:
scripts/pipeline/_build/andscripts/pipeline/deps/are gitignored — runmix deps.getinsidescripts/pipeline/after a fresh clone