When CLI options --port/--ws-port were not provided, nil values were being explicitly passed to Server.start_link, which caused Keyword.get to return nil instead of the default. Now we only include port options in the keyword list if they have actual values.
389 lines
12 KiB
Elixir
389 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_all(notes_dir, output_dir)
|
|
run_pipeline(content_dir, pipeline_opts)
|
|
generate_index(content_dir)
|
|
|
|
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")}")
|
|
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_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} =
|
|
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 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 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
|