diff --git a/flake.nix b/flake.nix index 2eff1848f..5bcb123c0 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Quartz org-roam dev shell and build app"; + description = "Quartz org-roam — org notes to website"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; @@ -11,95 +11,31 @@ flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; - fs = pkgs.lib.fileset; - orgGardenApp = org-garden.packages.${system}.default; + # Re-export org-garden's packages + orgGardenPkgs = org-garden.packages.${system}; - # Pre-fetched npm dependency tree (node_modules). - # src is filtered to only package.json + package-lock.json so that - # edits to Quartz source files do not invalidate this derivation. - quartzDeps = pkgs.buildNpmPackage { - pname = "quartz-deps"; - version = "4.5.2"; - src = fs.toSource { - root = ./.; - fileset = fs.unions [ - ./package.json - ./package-lock.json - ]; - }; - npmDepsHash = "sha256-7u+VlIx44B3/ivM9vLMIOn+e4TL4eS6B682vhS+Ikb4="; - dontBuild = true; - installPhase = '' - mkdir -p $out - cp -r node_modules $out/node_modules - ''; - }; + # Convenience aliases + orgGardenApp = orgGardenPkgs.default; - # The build application wrapper script (one-shot build) - buildApp = pkgs.writeShellApplication { - name = "build"; - runtimeInputs = [ pkgs.nodejs_22 ]; - text = '' - NOTES_DIR="''${1:?Usage: build }" - NOTES_DIR=$(realpath "$NOTES_DIR") - ORIG_CWD=$(pwd) - - # Set up a writable working copy of the repo in a temp dir - WORK=$(mktemp -d) - trap 'rm -rf "$WORK"' EXIT - cp -r ${self}/. "$WORK/repo" - chmod -R u+w "$WORK/repo" - - # Drop in pre-built node_modules - ln -s ${quartzDeps}/node_modules "$WORK/repo/node_modules" - - # Pass paths via environment for org-garden - export QUARTZ_PATH="$WORK/repo" - export NODE_PATH="${pkgs.nodejs_22}/bin/node" - - # Run org-garden build (org → md → static site) - ${orgGardenApp}/bin/org-garden build "$NOTES_DIR" \ - --output "$WORK/repo" \ - --content-dir "$WORK/repo/content" - - # Copy public output to caller's cwd - cp -r "$WORK/repo/public" "$ORIG_CWD/public" - ''; - }; - - # Development server with watch + live reload - notesApp = pkgs.writeShellApplication { - name = "notes"; - runtimeInputs = [ pkgs.nodejs_22 orgGardenApp ]; - text = '' - NOTES_DIR="''${1:?Usage: notes }" - NOTES_DIR=$(realpath "$NOTES_DIR") - - # Set up writable working copy - WORK=$(mktemp -d) - trap 'rm -rf "$WORK"' EXIT - cp -r ${self}/. "$WORK/repo" - chmod -R u+w "$WORK/repo" - ln -s ${quartzDeps}/node_modules "$WORK/repo/node_modules" - - # Pass paths via environment - export QUARTZ_PATH="$WORK/repo" - export NODE_PATH="${pkgs.nodejs_22}/bin/node" - - # org-garden reads these internally - org-garden serve "$NOTES_DIR" \ - --output "$WORK/repo" \ - --content-dir "$WORK/repo/content" - ''; - }; in { + # All packages come from org-garden + packages = orgGardenPkgs // { + default = orgGardenApp; + }; + + # Apps + apps = { + default = { type = "app"; program = "${orgGardenApp}/bin/org-garden"; }; + org-garden = { type = "app"; program = "${orgGardenApp}/bin/org-garden"; }; + }; + + # Dev shell for working on the repo devShells.default = pkgs.mkShell { buildInputs = [ pkgs.nodejs_22 pkgs.elixir - pkgs.mcp-nixos ]; shellHook = '' @@ -107,14 +43,5 @@ elixir --version 2>/dev/null | head -1 || true ''; }; - - packages.default = buildApp; - packages.build = buildApp; - packages.notes = notesApp; - packages.org-garden = orgGardenApp; - - apps.default = { type = "app"; program = "${buildApp}/bin/build"; }; - apps.build = { type = "app"; program = "${buildApp}/bin/build"; }; - apps.notes = { type = "app"; program = "${notesApp}/bin/notes"; }; }); } diff --git a/org-garden/flake.nix b/org-garden/flake.nix index 469f93786..bb23d6be0 100644 --- a/org-garden/flake.nix +++ b/org-garden/flake.nix @@ -12,14 +12,18 @@ pkgs = import nixpkgs { inherit system; }; fs = pkgs.lib.fileset; - # Emacs with ox-hugo — needed at runtime by the escript - # (export_org_files calls `emacs --batch` with ox-hugo). + # ========================================================================= + # Emacs with ox-hugo + # ========================================================================= + # Needed at runtime by the escript (export calls `emacs --batch` with ox-hugo) emacsWithOxHugo = (pkgs.emacsPackagesFor pkgs.emacs-nox).emacsWithPackages (epkgs: [ epkgs.ox-hugo ]); - # Pre-fetched Hex/Mix dependencies. - # src is filtered to mix.exs + mix.lock so source edits don't - # invalidate this derivation. + # ========================================================================= + # Elixir Pipeline + # ========================================================================= + + # Pre-fetched Hex/Mix dependencies mixDeps = pkgs.beamPackages.fetchMixDeps { pname = "org-garden-mix-deps"; version = "0.1.0"; @@ -33,32 +37,103 @@ sha256 = "sha256-si7JAomY1HZ33m6ihUJP5i6PO39CE1clYvuMtn0CbPU="; }; - # Compiled org-garden escript (without runtime wrappers). - # Note: escript name is org_garden (from app: :org_garden in mix.exs) + # Compiled org-garden escript orgGardenEscript = pkgs.beamPackages.mixRelease { pname = "org-garden"; version = "0.1.0"; - src = ./.; - + src = fs.toSource { + root = ./.; + fileset = fs.unions [ + ./mix.exs + ./mix.lock + ./lib + ]; + }; escriptBinName = "org_garden"; mixFodDeps = mixDeps; - stripDebug = true; }; - # Wrapped org-garden that puts emacs (with ox-hugo) on PATH so - # the escript's System.cmd("emacs", ...) calls succeed. + # ========================================================================= + # Quartz (fetched from upstream, patched) + # ========================================================================= + + # Pin to specific upstream commit + quartzVersion = "4.5.2"; + quartzRev = "ec00a40aefca73596ab76e3ebe3a8e1129b43688"; + + # Fetch upstream Quartz source + quartzSrc = pkgs.fetchFromGitHub { + owner = "jackyzha0"; + repo = "quartz"; + rev = quartzRev; + hash = "sha256-HdtQB5+SRWiypOvAJuJa3Nodl4JHehp2Mz6Rj5gOG0w="; + }; + + # Apply our patches to Quartz + quartzPatched = pkgs.runCommand "quartz-patched-${quartzVersion}" { + src = quartzSrc; + } '' + cp -r $src $out + chmod -R u+w $out + cd $out + patch -p1 < ${./patches/01-glob-gitignore.patch} + patch -p1 < ${./patches/02-build-gitignore.patch} + patch -p1 < ${./patches/03-static-hugo.patch} + patch -p1 < ${./patches/04-oxhugofm-figure.patch} + ''; + + # Pre-fetch Quartz npm dependencies + quartzDeps = pkgs.buildNpmPackage { + pname = "org-garden-quartz-deps"; + version = quartzVersion; + src = quartzPatched; + npmDepsHash = "sha256-7u+VlIx44B3/ivM9vLMIOn+e4TL4eS6B682vhS+Ikb4="; + dontBuild = true; + installPhase = '' + mkdir -p $out + cp -r node_modules $out/node_modules + ''; + }; + + # ========================================================================= + # Combined Application + # ========================================================================= + + # Wrapped org-garden with Quartz bundled orgGardenApp = pkgs.writeShellApplication { name = "org-garden"; runtimeInputs = [ emacsWithOxHugo pkgs.inotify-tools pkgs.nodejs_22 ]; text = '' + # Set up Quartz working directory + QUARTZ_WORK=$(mktemp -d) + trap 'rm -rf "$QUARTZ_WORK"' EXIT + + # Copy patched Quartz source + cp -r ${quartzPatched}/. "$QUARTZ_WORK/" + chmod -R u+w "$QUARTZ_WORK" + + # Copy default config files + cp ${./quartz-config/quartz.config.ts} "$QUARTZ_WORK/" + cp ${./quartz-config/quartz.layout.ts} "$QUARTZ_WORK/" + cp ${./quartz-config/globals.d.ts} "$QUARTZ_WORK/" + cp ${./quartz-config/index.d.ts} "$QUARTZ_WORK/" + + # Link pre-built node_modules + ln -s ${quartzDeps}/node_modules "$QUARTZ_WORK/node_modules" + + export QUARTZ_PATH="$QUARTZ_WORK" + export NODE_PATH="${pkgs.nodejs_22}/bin/node" + exec ${orgGardenEscript}/bin/org_garden "$@" ''; }; + in { packages.default = orgGardenApp; packages.escript = orgGardenEscript; + packages.quartz-patched = quartzPatched; devShells.default = pkgs.mkShell { buildInputs = [ diff --git a/org-garden/patches/01-glob-gitignore.patch b/org-garden/patches/01-glob-gitignore.patch new file mode 100644 index 000000000..5ccbf47bd --- /dev/null +++ b/org-garden/patches/01-glob-gitignore.patch @@ -0,0 +1,19 @@ +diff --git a/quartz/util/glob.ts b/quartz/util/glob.ts +index 7a71160..91fbaa7 100644 +--- a/quartz/util/glob.ts ++++ b/quartz/util/glob.ts +@@ -10,12 +10,13 @@ export async function glob( + pattern: string, + cwd: string, + ignorePatterns: string[], ++ respectGitignore: boolean = true, + ): Promise { + const fps = ( + await globby(pattern, { + cwd, + ignore: ignorePatterns, +- gitignore: true, ++ gitignore: respectGitignore, + }) + ).map(toPosixPath) + return fps as FilePath[] diff --git a/org-garden/patches/02-build-gitignore.patch b/org-garden/patches/02-build-gitignore.patch new file mode 100644 index 000000000..1517ec62a --- /dev/null +++ b/org-garden/patches/02-build-gitignore.patch @@ -0,0 +1,13 @@ +diff --git a/quartz/build.ts b/quartz/build.ts +index b98f4a8..3166a06 100644 +--- a/quartz/build.ts ++++ b/quartz/build.ts +@@ -71,7 +71,7 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { + console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`) + + perf.addEvent("glob") +- const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns) ++ const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns, false) + const markdownPaths = allFiles.filter((fp) => fp.endsWith(".md")).sort() + console.log( + `Found ${markdownPaths.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, diff --git a/org-garden/patches/03-static-hugo.patch b/org-garden/patches/03-static-hugo.patch new file mode 100644 index 000000000..ee0114ac6 --- /dev/null +++ b/org-garden/patches/03-static-hugo.patch @@ -0,0 +1,34 @@ +diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts +index 0b45290..8b34049 100644 +--- a/quartz/plugins/emitters/static.ts ++++ b/quartz/plugins/emitters/static.ts +@@ -7,6 +7,7 @@ import { dirname } from "path" + export const Static: QuartzEmitterPlugin = () => ({ + name: "Static", + async *emit({ argv, cfg }) { ++ // Copy Quartz's own internal static assets (quartz/static/) → output/static/ + const staticPath = joinSegments(QUARTZ, "static") + const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) + const outputStaticPath = joinSegments(argv.output, "static") +@@ -18,6 +19,21 @@ export const Static: QuartzEmitterPlugin = () => ({ + await fs.promises.copyFile(src, dest) + yield dest + } ++ ++ // Copy user-facing static assets (static/) → output/ preserving paths. ++ // This mirrors Hugo's convention: static/ox-hugo/foo.png is served at /ox-hugo/foo.png, ++ // which matches the src="/ox-hugo/..." paths that ox-hugo writes into exported markdown. ++ const userStaticPath = "static" ++ if (fs.existsSync(userStaticPath)) { ++ const userFps = await glob("**", userStaticPath, cfg.configuration.ignorePatterns, false) ++ for (const fp of userFps) { ++ const src = joinSegments(userStaticPath, fp) as FilePath ++ const dest = joinSegments(argv.output, fp) as FilePath ++ await fs.promises.mkdir(dirname(dest), { recursive: true }) ++ await fs.promises.copyFile(src, dest) ++ yield dest ++ } ++ } + }, + async *partialEmit() {}, + }) diff --git a/org-garden/patches/04-oxhugofm-figure.patch b/org-garden/patches/04-oxhugofm-figure.patch new file mode 100644 index 000000000..9b5435c0c --- /dev/null +++ b/org-garden/patches/04-oxhugofm-figure.patch @@ -0,0 +1,44 @@ +diff --git a/quartz/plugins/transformers/oxhugofm.ts b/quartz/plugins/transformers/oxhugofm.ts +index 303566e..4fb5e2c 100644 +--- a/quartz/plugins/transformers/oxhugofm.ts ++++ b/quartz/plugins/transformers/oxhugofm.ts +@@ -27,7 +27,10 @@ const defaultOptions: Options = { + const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g") + const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g") + const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g") +-const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "g") ++// Matches the full Hugo {{< figure src="..." ... >}} shortcode and captures src. ++// Must run before the generic shortcode stripper to avoid partial-match issues ++// with captions that contain HTML (e.g. ). ++const figureShortcodeRegex = new RegExp(/{{<\s*figure\b[^}]*\bsrc="([^"]*)"[^}]*>}}/, "g") + // \\\\\( -> matches \\( + // (.+?) -> Lazy match for capturing the equation + // \\\\\) -> matches \\) +@@ -70,19 +73,19 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin> + }) + } + +- if (opts.removeHugoShortcode) { ++ if (opts.replaceFigureWithMdImg) { + src = src.toString() +- src = src.replaceAll(hugoShortcodeRegex, (_value, ...capture) => { +- const [scContent] = capture +- return scContent ++ src = src.replaceAll(figureShortcodeRegex, (_value, ...capture) => { ++ const [imgSrc] = capture ++ return `![](${imgSrc})` + }) + } + +- if (opts.replaceFigureWithMdImg) { ++ if (opts.removeHugoShortcode) { + src = src.toString() +- src = src.replaceAll(figureTagRegex, (_value, ...capture) => { +- const [src] = capture +- return `![](${src})` ++ src = src.replaceAll(hugoShortcodeRegex, (_value, ...capture) => { ++ const [scContent] = capture ++ return scContent + }) + } + diff --git a/org-garden/quartz-config/globals.d.ts b/org-garden/quartz-config/globals.d.ts new file mode 100644 index 000000000..6cf30f8a1 --- /dev/null +++ b/org-garden/quartz-config/globals.d.ts @@ -0,0 +1,17 @@ +export declare global { + interface Document { + addEventListener( + type: K, + listener: (this: Document, ev: CustomEventMap[K]) => void, + ): void + removeEventListener( + type: K, + listener: (this: Document, ev: CustomEventMap[K]) => void, + ): void + dispatchEvent(ev: CustomEventMap[K] | UIEvent): void + } + interface Window { + spaNavigate(url: URL, isBack: boolean = false) + addCleanup(fn: (...args: any[]) => void) + } +} diff --git a/org-garden/quartz-config/index.d.ts b/org-garden/quartz-config/index.d.ts new file mode 100644 index 000000000..9011ee38f --- /dev/null +++ b/org-garden/quartz-config/index.d.ts @@ -0,0 +1,15 @@ +declare module "*.scss" { + const content: string + export = content +} + +// dom custom event +interface CustomEventMap { + prenav: CustomEvent<{}> + nav: CustomEvent<{ url: FullSlug }> + themechange: CustomEvent<{ theme: "light" | "dark" }> + readermodechange: CustomEvent<{ mode: "on" | "off" }> +} + +type ContentIndex = Record +declare const fetchData: Promise diff --git a/org-garden/quartz-config/quartz.config.ts b/org-garden/quartz-config/quartz.config.ts new file mode 100644 index 000000000..a8540f766 --- /dev/null +++ b/org-garden/quartz-config/quartz.config.ts @@ -0,0 +1,101 @@ +import { QuartzConfig } from "./quartz/cfg" +import * as Plugin from "./quartz/plugins" + +/** + * Quartz 4 Configuration + * + * See https://quartz.jzhao.xyz/configuration for more information. + */ +const config: QuartzConfig = { + configuration: { + pageTitle: "Quartz 4", + pageTitleSuffix: "", + enableSPA: true, + enablePopovers: true, + analytics: { + provider: "plausible", + }, + locale: "en-US", + baseUrl: "quartz.jzhao.xyz", + ignorePatterns: ["private", "templates", ".obsidian"], + defaultDateType: "modified", + theme: { + fontOrigin: "googleFonts", + cdnCaching: true, + typography: { + header: "Schibsted Grotesk", + body: "Source Sans Pro", + code: "IBM Plex Mono", + }, + colors: { + lightMode: { + light: "#faf8f8", + lightgray: "#e5e5e5", + gray: "#b8b8b8", + darkgray: "#4e4e4e", + dark: "#2b2b2b", + secondary: "#284b63", + tertiary: "#84a59d", + highlight: "rgba(143, 159, 169, 0.15)", + textHighlight: "#fff23688", + }, + darkMode: { + light: "#161618", + lightgray: "#393639", + gray: "#646464", + darkgray: "#d4d4d4", + dark: "#ebebec", + secondary: "#7b97aa", + tertiary: "#84a59d", + highlight: "rgba(143, 159, 169, 0.15)", + textHighlight: "#b3aa0288", + }, + }, + }, + }, + plugins: { + transformers: [ + Plugin.FrontMatter({ delimiters: "+++", language: "toml" }), + Plugin.CreatedModifiedDate({ + priority: ["frontmatter", "git", "filesystem"], + }), + Plugin.SyntaxHighlighting({ + theme: { + light: "github-light", + dark: "github-dark", + }, + keepBackground: false, + }), + // OxHugoFlavouredMarkdown must come before GitHubFlavoredMarkdown. + // Note: not compatible with ObsidianFlavoredMarkdown — use one or the other. + // If ox-hugo exports TOML frontmatter, change FrontMatter to: + // Plugin.FrontMatter({ delims: "+++", language: "toml" }) + Plugin.OxHugoFlavouredMarkdown(), + Plugin.GitHubFlavoredMarkdown(), + Plugin.TableOfContents(), + Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), + Plugin.Description(), + Plugin.Latex({ renderEngine: "katex" }), + ], + filters: [Plugin.RemoveDrafts()], + emitters: [ + Plugin.AliasRedirects(), + Plugin.ComponentResources(), + Plugin.ContentPage(), + Plugin.FolderPage(), + Plugin.TagPage(), + Plugin.ContentIndex({ + enableSiteMap: true, + enableRSS: true, + }), + Plugin.Assets(), + Plugin.Static(), + Plugin.Favicon(), + Plugin.NotFoundPage(), + // Comment out CustomOgImages to speed up build time + Plugin.CustomOgImages(), + ], + }, +} + +export default config diff --git a/org-garden/quartz-config/quartz.layout.ts b/org-garden/quartz-config/quartz.layout.ts new file mode 100644 index 000000000..970a5be34 --- /dev/null +++ b/org-garden/quartz-config/quartz.layout.ts @@ -0,0 +1,68 @@ +import { PageLayout, SharedLayout } from "./quartz/cfg" +import * as Component from "./quartz/components" + +// components shared across all pages +export const sharedPageComponents: SharedLayout = { + head: Component.Head(), + header: [], + afterBody: [], + footer: Component.Footer({ + links: { + GitHub: "https://github.com/jackyzha0/quartz", + "Discord Community": "https://discord.gg/cRFFHYye7t", + }, + }), +} + +// components for pages that display a single page (e.g. a single note) +export const defaultContentPageLayout: PageLayout = { + beforeBody: [ + Component.ConditionalRender({ + component: Component.Breadcrumbs(), + condition: (page) => page.fileData.slug !== "index", + }), + Component.ArticleTitle(), + Component.ContentMeta(), + Component.TagList(), + ], + left: [ + Component.PageTitle(), + Component.MobileOnly(Component.Spacer()), + Component.Flex({ + components: [ + { + Component: Component.Search(), + grow: true, + }, + { Component: Component.Darkmode() }, + { Component: Component.ReaderMode() }, + ], + }), + Component.Explorer(), + ], + right: [ + Component.Graph(), + Component.DesktopOnly(Component.TableOfContents()), + Component.Backlinks(), + ], +} + +// components for pages that display lists of pages (e.g. tags or folders) +export const defaultListPageLayout: PageLayout = { + beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()], + left: [ + Component.PageTitle(), + Component.MobileOnly(Component.Spacer()), + Component.Flex({ + components: [ + { + Component: Component.Search(), + grow: true, + }, + { Component: Component.Darkmode() }, + ], + }), + Component.Explorer(), + ], + right: [], +}