commit dc94f9d5bc66fd379891fa29ad6e6f8215af87b7 Author: padreug Date: Thu Jan 15 12:31:12 2026 +0100 Initial commit: Quartz NixOS module as flake - quartz.nix: NixOS module for Quartz static site hosting - flake.nix: Flake wrapper exposing nixosModules.quartz 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2be92b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +result diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..67f52b8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This repository contains a single NixOS module (`quartz.nix`) that provides a service for hosting Quartz static sites. Quartz is a fast, batteries-included static site generator that transforms Markdown content into full featured websites. + +## Architecture + +The module creates a complete deployment system: + +1. **Git-based content management** - Clones and pulls content from a configurable git repository +2. **Build service** (`quartz-pull-and-build.service`) - Handles npm install and `npx quartz build` +3. **Timer** - Periodic rebuilds (default: every 15 minutes) as fallback +4. **Webhook server** (optional) - Listens for Forgejo/GitHub push events to trigger immediate rebuilds +5. **nginx vhost** - Serves the built static files with ACME/SSL + +Key assumption: nginx and ACME must be configured elsewhere in the NixOS configuration. + +## Testing Changes + +To test this module, include it in a NixOS configuration: + +```nix +{ pkgs, ... }: +{ + imports = [ ./quartz.nix ]; + + services.quartz = { + enable = true; + domain = "docs.example.com"; + gitRepo = "https://git.example.com/user/quartz-notes.git"; + }; +} +``` + +Validate syntax with: `nix-instantiate --parse quartz.nix` diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4b5cbfc --- /dev/null +++ b/flake.nix @@ -0,0 +1,10 @@ +{ + description = "Quartz static site NixOS module"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: { + nixosModules.quartz = import ./quartz.nix; + nixosModules.default = self.nixosModules.quartz; + }; +} diff --git a/quartz.nix b/quartz.nix new file mode 100644 index 0000000..a88ca69 --- /dev/null +++ b/quartz.nix @@ -0,0 +1,267 @@ +# Quartz NixOS module for static site generation +# Builds Quartz from a git repository and serves via nginx +# Supports webhook-triggered rebuilds from Forgejo/GitHub +# +# Note: This module assumes nginx and ACME are already configured elsewhere +{ config, pkgs, lib, ... }: + +let + cfg = config.services.quartz; + + # Compute actual output path: custom if set, otherwise public/ as sibling to content dir + # e.g., if contentPath is /var/lib/quartz/content, output is /var/lib/quartz/public + actualOutputPath = if cfg.outputPath != "" + then cfg.outputPath + else builtins.dirOf cfg.contentPath + "/public"; + + # Build location inside repo (Quartz default) + buildOutputDir = "${cfg.contentPath}/public"; + + # Webhook configuration file for the webhook server + webhookConfig = pkgs.writeText "quartz-webhook.json" (builtins.toJSON [ + { + id = "quartz-rebuild"; + execute-command = "${pkgs.systemd}/bin/systemctl"; + command-working-directory = "/tmp"; + pass-arguments-to-command = [ + { source = "string"; name = "start"; } + { source = "string"; name = "quartz-pull-and-build.service"; } + ]; + trigger-rule = { + match = { + type = "value"; + value = "{{webhook-secret}}"; + parameter = { + source = "header"; + name = "X-Forgejo-Signature"; + }; + }; + }; + } + ]); +in +{ + options.services.quartz = { + enable = lib.mkEnableOption "Quartz static site generator"; + + domain = lib.mkOption { + type = lib.types.str; + description = "Domain name for the Quartz site"; + example = "docs.example.com"; + }; + + gitRepo = lib.mkOption { + type = lib.types.str; + description = "Git repository URL for Quartz content"; + example = "https://git.example.com/user/quartz-notes.git"; + }; + + gitBranch = lib.mkOption { + type = lib.types.str; + default = "main"; + description = "Git branch to track"; + }; + + contentPath = lib.mkOption { + type = lib.types.str; + default = "/var/lib/quartz/content"; + description = "Path to Quartz content (cloned from git)"; + }; + + outputPath = lib.mkOption { + type = lib.types.str; + default = ""; # Empty means use Quartz default (contentPath/public) + description = "Path for built static files. Empty uses Quartz default (public/ inside content dir)"; + }; + + rebuildInterval = lib.mkOption { + type = lib.types.str; + default = "*:0/15"; + description = "Systemd calendar expression for rebuild timer (default: every 15 min)"; + }; + + webhook = { + enable = lib.mkEnableOption "webhook receiver for auto-rebuild on git push"; + + port = lib.mkOption { + type = lib.types.port; + default = 9000; + description = "Port for webhook receiver"; + }; + + secretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Path to file containing webhook secret"; + example = "/run/secrets/quartz-webhook-secret"; + }; + }; + }; + + config = lib.mkIf cfg.enable { + # Systemd service to clone/pull and build Quartz + systemd.services.quartz-pull-and-build = { + description = "Pull latest Quartz content and rebuild"; + after = [ "network.target" ]; + path = [ pkgs.nodejs_22 pkgs.git pkgs.bash pkgs.coreutils pkgs.rsync ]; + + serviceConfig = { + Type = "oneshot"; + User = "quartz"; + Group = "quartz"; + }; + + script = '' + set -euo pipefail + + CONTENT_PATH="${cfg.contentPath}" + BUILD_DIR="${buildOutputDir}" + SERVE_DIR="${actualOutputPath}" + NEEDS_BUILD=0 + + # Clone if not exists + if [ ! -d "$CONTENT_PATH/.git" ]; then + echo "Cloning repository..." + git clone --branch ${cfg.gitBranch} ${cfg.gitRepo} "$CONTENT_PATH" + cd "$CONTENT_PATH" + NEEDS_BUILD=1 + else + cd "$CONTENT_PATH" + + # Check for changes before pulling + git fetch origin + LOCAL=$(git rev-parse HEAD) + REMOTE=$(git rev-parse origin/${cfg.gitBranch}) + + if [ "$LOCAL" != "$REMOTE" ]; then + echo "Changes detected, pulling..." + git reset --hard origin/${cfg.gitBranch} + NEEDS_BUILD=1 + fi + fi + + # Force build if node_modules missing or serve dir empty + if [ ! -d "node_modules" ]; then + echo "node_modules missing, forcing build..." + NEEDS_BUILD=1 + fi + + if [ ! -d "$SERVE_DIR" ] || [ -z "$(ls -A $SERVE_DIR 2>/dev/null)" ]; then + echo "Serve directory empty, forcing build..." + NEEDS_BUILD=1 + fi + + if [ "$NEEDS_BUILD" = "0" ]; then + echo "No changes detected, skipping build." + exit 0 + fi + + # Install dependencies if needed or if package.json changed + if [ ! -d "node_modules" ] || [ "package.json" -nt "node_modules" ]; then + echo "Installing dependencies..." + npm ci + fi + + # Build the site to default location (inside repo) + echo "Building Quartz..." + npx quartz build + + # Sync to serving directory (avoids EBUSY since we don't delete the dir) + echo "Syncing to serve directory..." + mkdir -p "$SERVE_DIR" + rsync -a --delete "$BUILD_DIR/" "$SERVE_DIR/" + + echo "Quartz build complete: $(date)" + ''; + }; + + # Timer for periodic rebuilds (fallback if webhook misses) + systemd.timers.quartz-pull-and-build = { + description = "Periodically pull and rebuild Quartz site"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = cfg.rebuildInterval; + Persistent = true; + RandomizedDelaySec = "1min"; + }; + }; + + # Webhook receiver for Forgejo/GitHub push events + systemd.services.quartz-webhook = lib.mkIf cfg.webhook.enable { + description = "Webhook receiver for Quartz rebuilds"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = "quartz"; + Group = "quartz"; + Restart = "always"; + RestartSec = "5s"; + }; + + script = '' + # Read the webhook secret + SECRET=$(cat ${cfg.webhook.secretFile}) + + # Create webhook config with actual secret + CONFIG=$(mktemp) + ${pkgs.gnused}/bin/sed "s/{{webhook-secret}}/$SECRET/" ${webhookConfig} > "$CONFIG" + + # Start webhook server + exec ${pkgs.webhook}/bin/webhook \ + -hooks "$CONFIG" \ + -port ${toString cfg.webhook.port} \ + -verbose + ''; + }; + + # Create quartz user and group + users.users.quartz = { + isSystemUser = true; + group = "quartz"; + home = "/var/lib/quartz"; + createHome = true; + homeMode = "755"; # Allow nginx to traverse for serving files + }; + users.groups.quartz = {}; + + # Ensure directories exist with correct permissions + systemd.tmpfiles.rules = [ + "d ${cfg.contentPath} 0755 quartz quartz -" + "d ${actualOutputPath} 0755 quartz quartz -" + ]; + + # nginx virtual host (assumes nginx is already enabled elsewhere) + services.nginx.virtualHosts.${cfg.domain} = { + forceSSL = true; + enableACME = true; + root = actualOutputPath; + + locations."/" = { + tryFiles = "$uri $uri/ $uri.html =404"; + extraConfig = '' + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + ''; + }; + + # Custom 404 page (Quartz generates one) + extraConfig = '' + error_page 404 /404.html; + ''; + + # Webhook endpoint (proxied to webhook server) + locations."/webhook" = lib.mkIf cfg.webhook.enable { + proxyPass = "http://127.0.0.1:${toString cfg.webhook.port}"; + extraConfig = '' + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + ''; + }; + }; + }; +}