initial: org-garden
This commit is contained in:
189
lib/org_garden.ex
Normal file
189
lib/org_garden.ex
Normal file
@@ -0,0 +1,189 @@
|
||||
defmodule OrgGarden do
|
||||
@moduledoc """
|
||||
Org-roam to website publishing pipeline.
|
||||
|
||||
Orchestrates:
|
||||
1. Org → Markdown export (via Emacs + ox-hugo)
|
||||
2. Markdown transforms (citations, etc.)
|
||||
3. Markdown → HTML + serving (via Quartz)
|
||||
|
||||
## Usage
|
||||
|
||||
opts = %{
|
||||
zotero_url: "http://localhost:23119",
|
||||
bibtex_file: System.get_env("BIBTEX_FILE"),
|
||||
citation_mode: :warn # :silent | :warn | :strict
|
||||
}
|
||||
|
||||
# Batch: all .md files in a directory
|
||||
OrgGarden.run(content_dir, [OrgGarden.Transforms.Citations], opts)
|
||||
|
||||
# Targeted: specific files only
|
||||
OrgGarden.run_on_files(["content/foo.md"], [OrgGarden.Transforms.Citations], opts)
|
||||
|
||||
# With pre-initialized transforms (for watch mode, avoids re-init)
|
||||
initialized = OrgGarden.init_transforms([OrgGarden.Transforms.Citations], opts)
|
||||
OrgGarden.run_on_files_with(["content/foo.md"], initialized, opts)
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@type transform :: module()
|
||||
@type initialized_transform :: {module(), term()}
|
||||
@type opts :: map()
|
||||
|
||||
@doc "One-shot build: org files → static site"
|
||||
def build(notes_dir, opts \\ []) do
|
||||
OrgGarden.CLI.handle_build([notes_dir | opts_to_args(opts)])
|
||||
end
|
||||
|
||||
@doc "Development server: watch + live reload"
|
||||
def serve(notes_dir, opts \\ []) do
|
||||
OrgGarden.CLI.handle_serve([notes_dir | opts_to_args(opts)])
|
||||
end
|
||||
|
||||
@doc "Export only: org files → markdown (no Quartz)"
|
||||
def export(notes_dir, opts \\ []) do
|
||||
OrgGarden.CLI.handle_export([notes_dir | opts_to_args(opts)])
|
||||
end
|
||||
|
||||
defp opts_to_args(opts) do
|
||||
Enum.flat_map(opts, fn
|
||||
{:output, v} -> ["--output", v]
|
||||
{:port, v} -> ["--port", to_string(v)]
|
||||
{:ws_port, v} -> ["--ws-port", to_string(v)]
|
||||
{:watch, true} -> ["--watch"]
|
||||
{:watch, false} -> []
|
||||
_ -> []
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Initialize transform modules. Returns a list of `{module, state}` tuples.
|
||||
|
||||
Call this once and reuse the result with `run_on_files_with/3` to avoid
|
||||
re-initializing transforms on every file change (e.g., in watch mode).
|
||||
"""
|
||||
@spec init_transforms([transform()], opts()) :: [initialized_transform()]
|
||||
def init_transforms(transforms, opts) do
|
||||
Enum.map(transforms, fn mod ->
|
||||
state = mod.init(opts)
|
||||
{mod, state}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Tear down previously initialized transforms, releasing any resources.
|
||||
"""
|
||||
@spec teardown_transforms([initialized_transform()]) :: :ok
|
||||
def teardown_transforms(initialized) do
|
||||
Enum.each(initialized, fn {mod, state} ->
|
||||
if function_exported?(mod, :teardown, 1) do
|
||||
mod.teardown(state)
|
||||
end
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Run all transforms over every `.md` file under `content_dir`.
|
||||
|
||||
Initializes and tears down transforms automatically.
|
||||
Returns `{:ok, stats}` where stats maps each transform to a count of files it changed.
|
||||
"""
|
||||
@spec run(String.t(), [transform()], opts()) :: {:ok, map()}
|
||||
def run(content_dir, transforms, opts \\ %{}) do
|
||||
md_files =
|
||||
content_dir
|
||||
|> Path.join("**/*.md")
|
||||
|> Path.wildcard()
|
||||
|
||||
if md_files == [] do
|
||||
Logger.warning("OrgGarden: no .md files found in #{content_dir}")
|
||||
{:ok, %{}}
|
||||
else
|
||||
Logger.info(
|
||||
"OrgGarden: processing #{length(md_files)} markdown files " <>
|
||||
"with #{length(transforms)} transform(s)"
|
||||
)
|
||||
|
||||
initialized = init_transforms(transforms, opts)
|
||||
stats = apply_transforms(md_files, initialized, opts)
|
||||
teardown_transforms(initialized)
|
||||
{:ok, stats}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Run all transforms over specific `.md` files only.
|
||||
|
||||
Initializes and tears down transforms automatically.
|
||||
Files that don't exist are silently skipped.
|
||||
"""
|
||||
@spec run_on_files([String.t()], [transform()], opts()) :: {:ok, map()}
|
||||
def run_on_files(file_paths, transforms, opts \\ %{}) do
|
||||
existing = Enum.filter(file_paths, &File.exists?/1)
|
||||
|
||||
if existing == [] do
|
||||
Logger.debug("OrgGarden: no files to process")
|
||||
{:ok, %{}}
|
||||
else
|
||||
Logger.info("OrgGarden: processing #{length(existing)} file(s)")
|
||||
initialized = init_transforms(transforms, opts)
|
||||
stats = apply_transforms(existing, initialized, opts)
|
||||
teardown_transforms(initialized)
|
||||
{:ok, stats}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Run pre-initialized transforms over specific `.md` files.
|
||||
|
||||
Does NOT call `init` or `teardown` — the caller manages the transform
|
||||
lifecycle. Use this in watch mode to avoid re-initializing on every change.
|
||||
"""
|
||||
@spec run_on_files_with([String.t()], [initialized_transform()], opts()) :: {:ok, map()}
|
||||
def run_on_files_with(file_paths, initialized, opts) do
|
||||
existing = Enum.filter(file_paths, &File.exists?/1)
|
||||
|
||||
if existing == [] do
|
||||
Logger.debug("OrgGarden: no files to process")
|
||||
{:ok, %{}}
|
||||
else
|
||||
stats = apply_transforms(existing, initialized, opts)
|
||||
{:ok, stats}
|
||||
end
|
||||
end
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Private
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
defp apply_transforms(md_files, initialized, opts) do
|
||||
Enum.reduce(md_files, %{}, fn path, acc ->
|
||||
original = File.read!(path)
|
||||
|
||||
{transformed, file_stats} =
|
||||
Enum.reduce(initialized, {original, %{}}, fn {mod, state}, {content, fstats} ->
|
||||
result = mod.apply(content, state, opts)
|
||||
changed = result != content
|
||||
|
||||
{result,
|
||||
Map.update(
|
||||
fstats,
|
||||
mod,
|
||||
if(changed, do: 1, else: 0),
|
||||
&(&1 + if(changed, do: 1, else: 0))
|
||||
)}
|
||||
end)
|
||||
|
||||
if transformed != original do
|
||||
File.write!(path, transformed)
|
||||
Logger.debug("OrgGarden: updated #{Path.relative_to_cwd(path)}")
|
||||
end
|
||||
|
||||
Map.merge(acc, file_stats, fn _k, a, b -> a + b end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user