defmodule OrgGarden.CLI do @moduledoc """ Escript entry point for the org-garden pipeline. ## Commands org-garden serve [--port 8080] [--ws-port 3001] org-garden build [--output ] org-garden export [--watch] ### serve Development server with watch + live reload. Starts both the org→md watcher and Quartz in serve mode. ### build One-shot build for CI/production. Exports org files, runs transforms, then builds static site with Quartz. ### export Just export org→md (current pipeline behavior). Use --watch for incremental re-export on file changes. ## Arguments notes-dir Path to the directory containing `.org` notes (required). Also accepts the `NOTES_DIR` env var. ## Options --output Output root directory (used as ox-hugo base dir). Defaults to the `OUTPUT_DIR` env var, or the current working directory. --content-dir

Output directory for exported Markdown. Defaults to `/content`. --port HTTP server port (default: 8080). Only for `serve`. --ws-port WebSocket hot reload port (default: 3001). Only for `serve`. --watch After initial batch, watch notes-dir for changes and incrementally re-export affected files. Only for `export`. ## Environment Variables BIBTEX_FILE Path to a `.bib` file used as citation fallback. ZOTERO_URL Zotero Better BibTeX base URL (default: http://localhost:23119). CITATION_MODE silent | warn (default) | strict. QUARTZ_PATH Path to quartz directory (required for serve/build). NODE_PATH Node.js executable (default: node). """ require Logger @transforms [OrgGarden.Transforms.Citations] def main(argv) do Application.ensure_all_started(:org_garden) case argv do ["serve" | rest] -> handle_serve(rest) ["build" | rest] -> handle_build(rest) ["export" | rest] -> handle_export(rest) # Legacy: treat bare args as export command for backward compatibility [_ | _] -> handle_export(argv) _ -> abort("Usage: org-garden [options]") end end # --------------------------------------------------------------------------- # Command: serve # --------------------------------------------------------------------------- def handle_serve(argv) do require_quartz_env() {notes_dir, output_dir, content_dir, opts} = parse_serve_args(argv) pipeline_opts = build_pipeline_opts() # Initial batch export wipe(content_dir) export_all(notes_dir, output_dir) run_pipeline(content_dir, pipeline_opts) generate_index(content_dir) IO.puts("==> Starting development server...") {:ok, _pid} = OrgGarden.Supervisor.start_link( notes_dir: notes_dir, output_dir: output_dir, content_dir: content_dir, pipeline_opts: pipeline_opts, transforms: @transforms, port: opts[:port] || 8080, ws_port: opts[:ws_port] || 3001 ) IO.puts("==> Server running at http://localhost:#{opts[:port] || 8080}") IO.puts("==> Watching #{notes_dir} for changes (Ctrl+C to stop)") Process.sleep(:infinity) end defp parse_serve_args(argv) do {opts, positional, _invalid} = OptionParser.parse(argv, strict: [ output: :string, content_dir: :string, port: :integer, ws_port: :integer ] ) notes_dir = extract_notes_dir(positional, "serve") output_dir = extract_output_dir(opts) content_dir = extract_content_dir(opts, output_dir) {notes_dir, output_dir, content_dir, opts} end # --------------------------------------------------------------------------- # Command: build # --------------------------------------------------------------------------- def handle_build(argv) do quartz_path = require_quartz_env() {notes_dir, output_dir, content_dir, _opts} = parse_build_args(argv) pipeline_opts = build_pipeline_opts() # Full batch export wipe(content_dir) export_all(notes_dir, output_dir) run_pipeline(content_dir, pipeline_opts) generate_index(content_dir) node_path = System.get_env("NODE_PATH", "node") IO.puts("==> Building static site with Quartz...") {output, status} = System.cmd( node_path, [ Path.join(quartz_path, "quartz/bootstrap-cli.mjs"), "build", "--directory", content_dir, "--output", Path.join(output_dir, "public") ], cd: quartz_path, stderr_to_stdout: true ) IO.puts(output) if status != 0 do abort("Quartz build failed with status #{status}") end IO.puts("==> Build complete. Output: #{Path.join(output_dir, "public")}") end defp parse_build_args(argv) do {opts, positional, _invalid} = OptionParser.parse(argv, strict: [output: :string, content_dir: :string] ) notes_dir = extract_notes_dir(positional, "build") output_dir = extract_output_dir(opts) content_dir = extract_content_dir(opts, output_dir) {notes_dir, output_dir, content_dir, opts} end # --------------------------------------------------------------------------- # Command: export (original pipeline behavior) # --------------------------------------------------------------------------- def handle_export(argv) do {notes_dir, output_dir, content_dir, watch?} = parse_export_args(argv) pipeline_opts = build_pipeline_opts() # Phase 1-4: full batch export wipe(content_dir) export_all(notes_dir, output_dir) run_pipeline(content_dir, pipeline_opts) generate_index(content_dir) md_count = content_dir |> Path.join("**/*.md") |> Path.wildcard() |> length() IO.puts("==> Done. #{md_count} markdown files in #{content_dir}") # Phase 5: optional watch mode if watch? do IO.puts("==> Watching #{notes_dir} for .org changes... (Ctrl+C to stop)") {:ok, _pid} = OrgGarden.Watcher.start_link( notes_dir: notes_dir, output_dir: output_dir, content_dir: content_dir, pipeline_opts: pipeline_opts, transforms: @transforms ) Process.sleep(:infinity) end end defp parse_export_args(argv) do {opts, positional, _invalid} = OptionParser.parse(argv, strict: [output: :string, content_dir: :string, watch: :boolean] ) notes_dir = extract_notes_dir(positional, "export") output_dir = extract_output_dir(opts) content_dir = extract_content_dir(opts, output_dir) watch? = Keyword.get(opts, :watch, false) {notes_dir, output_dir, content_dir, watch?} end # --------------------------------------------------------------------------- # Shared argument extraction # --------------------------------------------------------------------------- defp extract_notes_dir(positional, command) do notes_dir = case positional do [dir | _] -> dir [] -> System.get_env("NOTES_DIR") || abort("Usage: org-garden #{command} [options]") end notes_dir = Path.expand(notes_dir) unless File.dir?(notes_dir) do abort("Error: notes directory does not exist: #{notes_dir}") end notes_dir end defp extract_output_dir(opts) do (opts[:output] || System.get_env("OUTPUT_DIR") || File.cwd!()) |> Path.expand() end defp extract_content_dir(opts, output_dir) do (opts[:content_dir] || Path.join(output_dir, "content")) |> Path.expand() end # --------------------------------------------------------------------------- # Phase 1: Wipe content/ # --------------------------------------------------------------------------- defp wipe(content_dir) do IO.puts("==> Wiping #{content_dir}") File.mkdir_p!(content_dir) content_dir |> File.ls!() |> Enum.reject(&(&1 == ".gitkeep")) |> Enum.each(fn entry -> Path.join(content_dir, entry) |> File.rm_rf!() end) end # --------------------------------------------------------------------------- # Phase 2: Export org files via Emacs + ox-hugo # --------------------------------------------------------------------------- defp export_all(notes_dir, output_dir) do IO.puts("==> Exporting org files from #{notes_dir}") case OrgGarden.Export.export_all(notes_dir, output_dir) do {:ok, 0} -> IO.puts("No .org files found in #{notes_dir}") System.halt(0) {:ok, count} -> IO.puts(" exported #{count} file(s)") {:error, failures} -> IO.puts(:stderr, "\nFailed to export #{length(failures)} file(s):") Enum.each(failures, fn {f, {:error, reason}} -> IO.puts(:stderr, " #{f}: #{inspect(reason)}") end) System.halt(1) end end # --------------------------------------------------------------------------- # Phase 3: Markdown transformation pipeline # --------------------------------------------------------------------------- defp run_pipeline(content_dir, pipeline_opts) do IO.puts("==> Running markdown pipeline") {:ok, stats} = OrgGarden.run(content_dir, @transforms, pipeline_opts) Enum.each(stats, fn {mod, count} -> IO.puts(" #{inspect(mod)}: #{count} file(s) modified") end) end # --------------------------------------------------------------------------- # Phase 4: Generate default index.md if none was exported # --------------------------------------------------------------------------- defp generate_index(content_dir) do IO.puts("==> Generating index") OrgGarden.Index.generate(content_dir) end # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- defp require_quartz_env do case System.get_env("QUARTZ_PATH") do nil -> abort(""" Error: QUARTZ_PATH environment variable not set. The 'serve' and 'build' commands require Quartz to be available. Use the wrapper scripts that set up the environment: nix run .#notes -- # for serve nix run .#build -- # for build Or set QUARTZ_PATH manually to point to a quartz-org-roam checkout with node_modules installed. For export-only mode (no Quartz), use: org-garden export [--watch] """) path -> unless File.exists?(Path.join(path, "quartz/bootstrap-cli.mjs")) do abort("Error: QUARTZ_PATH=#{path} does not contain quartz/bootstrap-cli.mjs") end path end end defp build_pipeline_opts do %{ zotero_url: System.get_env("ZOTERO_URL", "http://localhost:23119"), bibtex_file: System.get_env("BIBTEX_FILE"), citation_mode: case System.get_env("CITATION_MODE", "warn") do "silent" -> :silent "strict" -> :strict _ -> :warn end } end defp abort(message) do IO.puts(:stderr, message) System.halt(1) end end