- Add org-id resolution for [[id:...]] links by building ID locations database once before parallel export - Handle broken file links gracefully with org-export-with-broken-links - Fix race condition in parallel exports by pre-building ID cache - Fix Quartz config not being applied: cp was preserving nix store hash prefix in filename instead of using explicit destination filename - Continue pipeline even when some exports fail, reporting failures - Improve error handling and logging throughout export pipeline
404 lines
12 KiB
Elixir
404 lines
12 KiB
Elixir
defmodule OrgGarden.CLI do
|
|
@moduledoc """
|
|
Escript entry point for the org-garden pipeline.
|
|
|
|
## Commands
|
|
|
|
org-garden serve <notes-dir> [--port 8080] [--ws-port 3001]
|
|
org-garden build <notes-dir> [--output <path>]
|
|
org-garden export <notes-dir> [--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 <path> Output root directory (used as ox-hugo base dir).
|
|
Defaults to the `OUTPUT_DIR` env var, or the current
|
|
working directory.
|
|
--content-dir <p> Output directory for exported Markdown. Defaults to
|
|
`<output>/content`.
|
|
--port <n> HTTP server port (default: 8080). Only for `serve`.
|
|
--ws-port <n> 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
|
|
|
|
alias OrgGarden.Config
|
|
|
|
@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 <serve|build|export> <notes-dir> [options]")
|
|
end
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command: serve
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def handle_serve(argv) do
|
|
require_quartz_env()
|
|
{notes_dir, output_dir, content_dir, opts} = parse_serve_args(argv)
|
|
|
|
IO.puts("==> Starting development server...")
|
|
|
|
server_opts =
|
|
[
|
|
notes_dir: notes_dir,
|
|
output_dir: output_dir,
|
|
content_dir: content_dir
|
|
]
|
|
|> maybe_put(:port, opts[:port])
|
|
|> maybe_put(:ws_port, opts[:ws_port])
|
|
|
|
case OrgGarden.Server.start_link(server_opts) do
|
|
{:ok, pid} ->
|
|
IO.puts("==> Server running at http://localhost:#{opts[:port] || Config.get(:http_port, 8080)}")
|
|
IO.puts("==> Watching #{notes_dir} for changes (Ctrl+C to stop)")
|
|
|
|
# Wait for server to exit
|
|
ref = Process.monitor(pid)
|
|
|
|
receive do
|
|
{:DOWN, ^ref, :process, ^pid, reason} ->
|
|
case reason do
|
|
:normal -> :ok
|
|
:shutdown -> :ok
|
|
{:shutdown, _} -> :ok
|
|
_ -> abort("Server crashed: #{inspect(reason)}")
|
|
end
|
|
end
|
|
|
|
{:error, reason} ->
|
|
abort("Failed to start server: #{inspect(reason)}")
|
|
end
|
|
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 = Config.pipeline_opts()
|
|
|
|
# Full batch export
|
|
wipe(content_dir)
|
|
export_result = export_all(notes_dir, output_dir)
|
|
run_pipeline(content_dir, pipeline_opts)
|
|
generate_index(content_dir)
|
|
|
|
# Track if we had export failures
|
|
had_export_failures = match?({:error, _}, export_result)
|
|
|
|
node_path = Config.get(: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")}")
|
|
|
|
# Exit with error if there were export failures
|
|
if had_export_failures do
|
|
System.halt(1)
|
|
end
|
|
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 = Config.pipeline_opts()
|
|
|
|
# Phase 1-4: full batch export
|
|
wipe(content_dir)
|
|
export_result = 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}")
|
|
|
|
# Exit with error if there were export failures (unless in watch mode)
|
|
case {export_result, watch?} do
|
|
{{:error, _}, false} -> System.halt(1)
|
|
_ -> :ok
|
|
end
|
|
|
|
# Phase 5: optional watch mode
|
|
if watch? do
|
|
IO.puts("==> Watching #{notes_dir} for .org changes... (Ctrl+C to stop)")
|
|
|
|
{:ok, pid} =
|
|
DynamicSupervisor.start_child(
|
|
OrgGarden.DynamicSupervisor,
|
|
{OrgGarden.Watcher,
|
|
notes_dir: notes_dir,
|
|
output_dir: output_dir,
|
|
content_dir: content_dir,
|
|
pipeline_opts: pipeline_opts,
|
|
transforms: @transforms}
|
|
)
|
|
|
|
# Wait for watcher to exit
|
|
ref = Process.monitor(pid)
|
|
|
|
receive do
|
|
{:DOWN, ^ref, :process, ^pid, reason} ->
|
|
case reason do
|
|
:normal -> :ok
|
|
:shutdown -> :ok
|
|
{:shutdown, _} -> :ok
|
|
_ -> abort("Watcher crashed: #{inspect(reason)}")
|
|
end
|
|
end
|
|
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
|
|
|
|
[] ->
|
|
Config.get(:notes_dir) ||
|
|
abort("Usage: org-garden #{command} <notes-dir> [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] || Config.get(: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")
|
|
:ok
|
|
|
|
{:ok, count, []} ->
|
|
IO.puts(" exported #{count} file(s)")
|
|
:ok
|
|
|
|
{:ok, count, failures} ->
|
|
IO.puts(" exported #{count} file(s), #{length(failures)} failed")
|
|
|
|
Enum.each(failures, fn {f, {:error, {:emacs_exit, code}}} ->
|
|
IO.puts(:stderr, " failed: #{f} (emacs exit code #{code})")
|
|
end)
|
|
|
|
{:error, length(failures)}
|
|
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 Config.get(: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 -- <notes-dir> # for serve
|
|
nix run .#build -- <notes-dir> # 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 <notes-dir> [--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 abort(message) do
|
|
IO.puts(:stderr, message)
|
|
System.halt(1)
|
|
end
|
|
|
|
defp maybe_put(keyword, _key, nil), do: keyword
|
|
defp maybe_put(keyword, key, value), do: Keyword.put(keyword, key, value)
|
|
end
|