forked from github/quartz
Compare commits
4 Commits
0ea5808cd2
...
feature/ox
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
678fb315d3 | ||
|
|
a4582230b5 | ||
|
|
1076bf31ed | ||
|
|
dc348185a7 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ scripts/pipeline/erl_crash.dump
|
||||
# Test helpers (not needed in production)
|
||||
scripts/test.bib
|
||||
scripts/test_pipeline.exs
|
||||
/org-garden/deps/
|
||||
/org-garden/_build/
|
||||
/org-garden/result
|
||||
|
||||
107
AGENTS.md
107
AGENTS.md
@@ -244,113 +244,6 @@ git branch -d feature/my-feature
|
||||
|
||||
**Merge direction:** `upstream → main → org-roam → feature/*`
|
||||
|
||||
## Org-Roam Workflow
|
||||
|
||||
Notes live in a **separate directory** outside this repo. The export pipeline
|
||||
converts them to Markdown via ox-hugo, applies post-processing transforms, then
|
||||
Quartz builds the site.
|
||||
|
||||
### Tooling
|
||||
|
||||
The dev shell (`nix develop`) provides:
|
||||
|
||||
- `nodejs_22` — Quartz build
|
||||
- `elixir` — runs the export script and pipeline
|
||||
- `emacs` + `ox-hugo` — performs the org → markdown conversion
|
||||
|
||||
### Export and build
|
||||
|
||||
```bash
|
||||
# Export only (wipes content/, exports all .org files, runs pipeline)
|
||||
NOTES_DIR=/path/to/notes npm run export
|
||||
|
||||
# Export then build the site
|
||||
NOTES_DIR=/path/to/notes npm run build:notes
|
||||
|
||||
# Positional arg also works
|
||||
elixir scripts/export.exs /path/to/notes
|
||||
```
|
||||
|
||||
Optional env vars for the pipeline:
|
||||
|
||||
| Var | Default | Purpose |
|
||||
| --------------- | ------------------------ | ----------------------------------------- |
|
||||
| `BIBTEX_FILE` | — | Path to `.bib` file for citation fallback |
|
||||
| `ZOTERO_URL` | `http://localhost:23119` | Zotero Better BibTeX base URL |
|
||||
| `CITATION_MODE` | `warn` | `silent` / `warn` / `strict` |
|
||||
|
||||
### Export pipeline phases
|
||||
|
||||
`scripts/export.exs` runs four phases in sequence:
|
||||
|
||||
1. **Wipe** `content/` (preserving `.gitkeep`)
|
||||
2. **Export** each `.org` file via `emacs --batch` + `ox-hugo` → `content/**/*.md`
|
||||
3. **Pipeline** — run Elixir transform modules over every `.md` file
|
||||
4. **Index** — generate a fallback `content/index.md` if none was exported
|
||||
|
||||
The export uses TOML frontmatter (`+++`) and per-file mode (not per-subtree).
|
||||
|
||||
### Markdown pipeline (`scripts/pipeline/`)
|
||||
|
||||
A standalone Mix project that post-processes `content/*.md` after ox-hugo.
|
||||
It is compiled automatically on first run; subsequent runs use the `_build/`
|
||||
cache and are fast.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
scripts/pipeline/
|
||||
├── mix.exs # deps: req, jason
|
||||
└── lib/
|
||||
├── pipeline.ex # Generic runner (fold transforms over .md files)
|
||||
├── pipeline/
|
||||
│ ├── application.ex # OTP app — starts Finch HTTP pool
|
||||
│ ├── transform.ex # Behaviour: init/1, apply/3, teardown/1
|
||||
│ ├── transforms/
|
||||
│ │ └── citations.ex # Resolves cite:key → [Label](url)
|
||||
│ └── resolvers/
|
||||
│ ├── zotero.ex # JSON-RPC to Zotero Better BibTeX
|
||||
│ ├── bibtex.ex # Parses local .bib file
|
||||
│ └── doi.ex # Bare-key fallback (always succeeds)
|
||||
```
|
||||
|
||||
**Adding a new transform:**
|
||||
|
||||
1. Create `scripts/pipeline/lib/pipeline/transforms/my_transform.ex`
|
||||
2. Implement the `Pipeline.Transform` behaviour (`init/1`, `apply/3`)
|
||||
3. Append the module to `transforms` in `scripts/export.exs`
|
||||
|
||||
```elixir
|
||||
transforms = [
|
||||
Pipeline.Transforms.Citations,
|
||||
Pipeline.Transforms.MyTransform, # new
|
||||
]
|
||||
```
|
||||
|
||||
### Citation resolution (`Pipeline.Transforms.Citations`)
|
||||
|
||||
Handles org-citar syntax that passes through ox-hugo unchanged:
|
||||
|
||||
| Syntax | Example |
|
||||
| ---------------- | -------------------- |
|
||||
| org-cite / citar | `[cite:@key]` |
|
||||
| multiple keys | `[cite:@key1;@key2]` |
|
||||
| bare (legacy) | `cite:key` |
|
||||
|
||||
Resolution chain (first success wins):
|
||||
|
||||
1. **Zotero** — JSON-RPC to `localhost:23119/better-bibtex/json-rpc`
|
||||
- Calls `item.search` to find the item, then `item.attachments` to get
|
||||
the PDF link (`zotero://open-pdf/library/items/KEY`)
|
||||
- Falls back to `zotero://select/library/items/KEY` if no PDF attachment
|
||||
- Probe uses a JSON-RPC call, **not** `/better-bibtex/cayw`
|
||||
(that endpoint blocks waiting for interactive input)
|
||||
2. **BibTeX** — parses `BIBTEX_FILE`; extracts authors, year, DOI/URL
|
||||
3. **DOI fallback** — always succeeds; renders bare key or `https://doi.org/...`
|
||||
|
||||
**Zotero JSON-RPC gotcha:** `Req 0.5` does not allow combining `:finch` and
|
||||
`:connect_options` in the same call. Use `:receive_timeout` only.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Client-side scripts**: Use `.inline.ts` suffix, bundled via esbuild
|
||||
|
||||
67
flake.lock
generated
67
flake.lock
generated
@@ -18,6 +18,24 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771008912,
|
||||
@@ -34,10 +52,42 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1771369470,
|
||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"org-garden": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"path": "./org-garden",
|
||||
"type": "path"
|
||||
},
|
||||
"original": {
|
||||
"path": "./org-garden",
|
||||
"type": "path"
|
||||
},
|
||||
"parent": []
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
"nixpkgs": "nixpkgs",
|
||||
"org-garden": "org-garden"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
@@ -54,6 +104,21 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
90
flake.nix
90
flake.nix
@@ -1,99 +1,47 @@
|
||||
{
|
||||
description = "Quartz org-roam dev shell and build app";
|
||||
description = "Quartz org-roam — org notes to website";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
org-garden.url = "path:./org-garden";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
outputs = { self, nixpkgs, flake-utils, org-garden }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
# Emacs with ox-hugo — shared between devShell and buildApp
|
||||
emacsWithOxHugo = (pkgs.emacsPackagesFor pkgs.emacs-nox).emacsWithPackages
|
||||
(epkgs: [ epkgs.ox-hugo ]);
|
||||
# Re-export org-garden's packages
|
||||
orgGardenPkgs = org-garden.packages.${system};
|
||||
|
||||
# Pre-fetched npm dependency tree (node_modules)
|
||||
quartzDeps = pkgs.buildNpmPackage {
|
||||
pname = "quartz-deps";
|
||||
version = "4.5.2";
|
||||
src = ./.;
|
||||
npmDepsHash = "sha256-7u+VlIx44B3/ivM9vLMIOn+e4TL4eS6B682vhS+Ikb4=";
|
||||
dontBuild = true;
|
||||
installPhase = ''
|
||||
mkdir -p $out
|
||||
cp -r node_modules $out/node_modules
|
||||
'';
|
||||
};
|
||||
# Convenience aliases
|
||||
orgGardenApp = orgGardenPkgs.default;
|
||||
|
||||
# Pre-fetched Hex/Mix dependencies for scripts/pipeline
|
||||
pipelineMixDeps = pkgs.beamPackages.fetchMixDeps {
|
||||
pname = "pipeline-mix-deps";
|
||||
version = "0.1.0";
|
||||
src = ./scripts/pipeline;
|
||||
sha256 = "sha256-E79X+nUy86G1Jrwv3T7dXekoGv8Hd14ZgJSKWjvlmAw=";
|
||||
};
|
||||
|
||||
# The build application wrapper script
|
||||
buildApp = pkgs.writeShellApplication {
|
||||
name = "build";
|
||||
runtimeInputs = [ pkgs.nodejs_22 pkgs.elixir emacsWithOxHugo ];
|
||||
text = ''
|
||||
NOTES_DIR="''${1:?Usage: build <path-to-notes-dir>}"
|
||||
NOTES_DIR=$(realpath "$NOTES_DIR")
|
||||
ORIG_CWD=$(pwd)
|
||||
|
||||
# Set up a writable working copy of the repo in a temp dir
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
cp -r ${self}/. "$WORK/repo"
|
||||
chmod -R u+w "$WORK/repo"
|
||||
|
||||
# Drop in pre-built node_modules
|
||||
ln -s ${quartzDeps}/node_modules "$WORK/repo/node_modules"
|
||||
|
||||
# Drop in pre-fetched Mix deps so mix compile runs offline
|
||||
cp -r ${pipelineMixDeps} "$WORK/repo/scripts/pipeline/deps"
|
||||
chmod -R u+w "$WORK/repo/scripts/pipeline/deps"
|
||||
|
||||
# ox-hugo requires static/ to exist before it can copy image assets
|
||||
mkdir -p "$WORK/repo/static"
|
||||
|
||||
# Run the export pipeline (org → md, citations transform)
|
||||
NOTES_DIR="$NOTES_DIR" elixir "$WORK/repo/scripts/export.exs"
|
||||
|
||||
# Build the static site from within the repo copy so relative paths
|
||||
# (e.g. ./package.json in constants.js) resolve correctly.
|
||||
# --output is absolute so the result lands in the caller's cwd.
|
||||
cd "$WORK/repo"
|
||||
node quartz/bootstrap-cli.mjs build \
|
||||
--directory "$WORK/repo/content" \
|
||||
--output "$ORIG_CWD/public"
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
# All packages come from org-garden
|
||||
packages = orgGardenPkgs // {
|
||||
default = orgGardenApp;
|
||||
};
|
||||
|
||||
# Apps
|
||||
apps = {
|
||||
default = { type = "app"; program = "${orgGardenApp}/bin/org-garden"; };
|
||||
org-garden = { type = "app"; program = "${orgGardenApp}/bin/org-garden"; };
|
||||
};
|
||||
|
||||
# Dev shell for working on the repo
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.nodejs_22
|
||||
pkgs.elixir
|
||||
emacsWithOxHugo
|
||||
pkgs.mcp-nixos
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "Node $(node --version) / npm $(npm --version)"
|
||||
elixir --version 2>/dev/null | head -1 || true
|
||||
echo "Emacs $(emacs --version | head -1)"
|
||||
'';
|
||||
};
|
||||
|
||||
packages.default = buildApp;
|
||||
packages.build = buildApp;
|
||||
|
||||
apps.default = { type = "app"; program = "${buildApp}/bin/build"; };
|
||||
apps.build = { type = "app"; program = "${buildApp}/bin/build"; };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ The methodology described in [cite:@podlovics2021journalArticle] provides a
|
||||
useful framework for analysis.
|
||||
|
||||
Multiple citations can appear together:
|
||||
[cite:@podlovics2021journalArticle]
|
||||
[cite:@podlovics2021journalArticle;@petersen2022book]
|
||||
|
||||
Older bare-cite style (org-roam v1 / older citar) also works:
|
||||
cite:podlovics2021journalArticle
|
||||
cite:@podlovics2021journalArticle
|
||||
|
||||
61
org-garden/flake.lock
generated
Normal file
61
org-garden/flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771369470,
|
||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
147
org-garden/flake.nix
Normal file
147
org-garden/flake.nix
Normal file
@@ -0,0 +1,147 @@
|
||||
{
|
||||
description = "Org-garden — org-roam to website publishing pipeline";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
fs = pkgs.lib.fileset;
|
||||
|
||||
# =========================================================================
|
||||
# Emacs with ox-hugo
|
||||
# =========================================================================
|
||||
# Needed at runtime by the escript (export calls `emacs --batch` with ox-hugo)
|
||||
emacsWithOxHugo = (pkgs.emacsPackagesFor pkgs.emacs-nox).emacsWithPackages
|
||||
(epkgs: [ epkgs.ox-hugo ]);
|
||||
|
||||
# =========================================================================
|
||||
# Elixir Pipeline
|
||||
# =========================================================================
|
||||
|
||||
# Pre-fetched Hex/Mix dependencies
|
||||
mixDeps = pkgs.beamPackages.fetchMixDeps {
|
||||
pname = "org-garden-mix-deps";
|
||||
version = "0.1.0";
|
||||
src = fs.toSource {
|
||||
root = ./.;
|
||||
fileset = fs.unions [
|
||||
./mix.exs
|
||||
./mix.lock
|
||||
];
|
||||
};
|
||||
sha256 = "sha256-si7JAomY1HZ33m6ihUJP5i6PO39CE1clYvuMtn0CbPU=";
|
||||
};
|
||||
|
||||
# Compiled org-garden escript
|
||||
orgGardenEscript = pkgs.beamPackages.mixRelease {
|
||||
pname = "org-garden";
|
||||
version = "0.1.0";
|
||||
src = fs.toSource {
|
||||
root = ./.;
|
||||
fileset = fs.unions [
|
||||
./mix.exs
|
||||
./mix.lock
|
||||
./lib
|
||||
];
|
||||
};
|
||||
escriptBinName = "org_garden";
|
||||
mixFodDeps = mixDeps;
|
||||
stripDebug = true;
|
||||
};
|
||||
|
||||
# =========================================================================
|
||||
# Quartz (fetched from upstream, patched)
|
||||
# =========================================================================
|
||||
|
||||
# Pin to specific upstream commit
|
||||
quartzVersion = "4.5.2";
|
||||
quartzRev = "ec00a40aefca73596ab76e3ebe3a8e1129b43688";
|
||||
|
||||
# Fetch upstream Quartz source
|
||||
quartzSrc = pkgs.fetchFromGitHub {
|
||||
owner = "jackyzha0";
|
||||
repo = "quartz";
|
||||
rev = quartzRev;
|
||||
hash = "sha256-HdtQB5+SRWiypOvAJuJa3Nodl4JHehp2Mz6Rj5gOG0w=";
|
||||
};
|
||||
|
||||
# Apply our patches to Quartz
|
||||
quartzPatched = pkgs.runCommand "quartz-patched-${quartzVersion}" {
|
||||
src = quartzSrc;
|
||||
} ''
|
||||
cp -r $src $out
|
||||
chmod -R u+w $out
|
||||
cd $out
|
||||
patch -p1 < ${./patches/01-glob-gitignore.patch}
|
||||
patch -p1 < ${./patches/02-build-gitignore.patch}
|
||||
patch -p1 < ${./patches/03-static-hugo.patch}
|
||||
patch -p1 < ${./patches/04-oxhugofm-figure.patch}
|
||||
'';
|
||||
|
||||
# Pre-fetch Quartz npm dependencies
|
||||
quartzDeps = pkgs.buildNpmPackage {
|
||||
pname = "org-garden-quartz-deps";
|
||||
version = quartzVersion;
|
||||
src = quartzPatched;
|
||||
npmDepsHash = "sha256-7u+VlIx44B3/ivM9vLMIOn+e4TL4eS6B682vhS+Ikb4=";
|
||||
dontBuild = true;
|
||||
installPhase = ''
|
||||
mkdir -p $out
|
||||
cp -r node_modules $out/node_modules
|
||||
'';
|
||||
};
|
||||
|
||||
# =========================================================================
|
||||
# Combined Application
|
||||
# =========================================================================
|
||||
|
||||
# Wrapped org-garden with Quartz bundled
|
||||
orgGardenApp = pkgs.writeShellApplication {
|
||||
name = "org-garden";
|
||||
runtimeInputs = [ emacsWithOxHugo pkgs.inotify-tools pkgs.nodejs_22 ];
|
||||
text = ''
|
||||
# Set up Quartz working directory
|
||||
QUARTZ_WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$QUARTZ_WORK"' EXIT
|
||||
|
||||
# Copy patched Quartz source
|
||||
cp -r ${quartzPatched}/. "$QUARTZ_WORK/"
|
||||
chmod -R u+w "$QUARTZ_WORK"
|
||||
|
||||
# Copy default config files
|
||||
cp ${./quartz-config/quartz.config.ts} "$QUARTZ_WORK/"
|
||||
cp ${./quartz-config/quartz.layout.ts} "$QUARTZ_WORK/"
|
||||
cp ${./quartz-config/globals.d.ts} "$QUARTZ_WORK/"
|
||||
cp ${./quartz-config/index.d.ts} "$QUARTZ_WORK/"
|
||||
|
||||
# Link pre-built node_modules
|
||||
ln -s ${quartzDeps}/node_modules "$QUARTZ_WORK/node_modules"
|
||||
|
||||
export QUARTZ_PATH="$QUARTZ_WORK"
|
||||
export NODE_PATH="${pkgs.nodejs_22}/bin/node"
|
||||
|
||||
exec ${orgGardenEscript}/bin/org_garden "$@"
|
||||
'';
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
packages.default = orgGardenApp;
|
||||
packages.escript = orgGardenEscript;
|
||||
packages.quartz-patched = quartzPatched;
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.elixir
|
||||
pkgs.inotify-tools
|
||||
emacsWithOxHugo
|
||||
pkgs.nodejs_22
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
189
org-garden/lib/org_garden.ex
Normal file
189
org-garden/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
|
||||
@@ -1,14 +1,14 @@
|
||||
defmodule Pipeline.Application do
|
||||
defmodule OrgGarden.Application do
|
||||
@moduledoc false
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
{Finch, name: Pipeline.Finch}
|
||||
{Finch, name: OrgGarden.Finch}
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: Pipeline.Supervisor]
|
||||
opts = [strategy: :one_for_one, name: OrgGarden.AppSupervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
end
|
||||
375
org-garden/lib/org_garden/cli.ex
Normal file
375
org-garden/lib/org_garden/cli.ex
Normal file
@@ -0,0 +1,375 @@
|
||||
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
|
||||
|
||||
@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)
|
||||
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} <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] || 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 -- <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 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
|
||||
135
org-garden/lib/org_garden/export.ex
Normal file
135
org-garden/lib/org_garden/export.ex
Normal file
@@ -0,0 +1,135 @@
|
||||
defmodule OrgGarden.Export do
|
||||
@moduledoc """
|
||||
Org-to-Markdown export via Emacs batch + ox-hugo.
|
||||
|
||||
Provides both single-file and batch export, plus a helper to compute
|
||||
the expected `.md` output path for a given `.org` source file.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Export a single `.org` file to Markdown via `emacs --batch` + ox-hugo.
|
||||
|
||||
Returns `{:ok, exit_code}` with the emacs exit code (0 = success),
|
||||
or `{:error, reason}` if the command could not be executed.
|
||||
"""
|
||||
@spec export_file(String.t(), String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, term()}
|
||||
def export_file(orgfile, notes_dir, output_dir) do
|
||||
section =
|
||||
orgfile
|
||||
|> Path.dirname()
|
||||
|> Path.relative_to(notes_dir)
|
||||
|
||||
# ox-hugo requires static/ to exist for image asset copying
|
||||
File.mkdir_p!(Path.join(output_dir, "static"))
|
||||
|
||||
{output, exit_code} =
|
||||
System.cmd(
|
||||
"emacs",
|
||||
[
|
||||
"--batch",
|
||||
"--eval", "(require 'ox-hugo)",
|
||||
"--eval", """
|
||||
(org-cite-register-processor 'passthrough
|
||||
:export-citation
|
||||
(lambda (citation _style _backend _info)
|
||||
(let ((keys (mapcar (lambda (ref)
|
||||
(concat "@" (org-element-property :key ref)))
|
||||
(org-cite-get-references citation))))
|
||||
(format "[cite:%s]" (string-join keys ";")))))
|
||||
""",
|
||||
"--eval", "(setq org-cite-export-processors '((t passthrough)))",
|
||||
"--eval", ~s[(setq org-hugo-base-dir "#{output_dir}")],
|
||||
"--eval", ~s[(setq org-hugo-default-section-directory "#{section}")],
|
||||
"--visit", orgfile,
|
||||
"--funcall", "org-hugo-export-to-md"
|
||||
],
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
|
||||
filtered =
|
||||
output
|
||||
|> String.split("\n")
|
||||
|> Enum.reject(&String.match?(&1, ~r/^Loading|^ad-handle|^For information/))
|
||||
|> Enum.join("\n")
|
||||
|
||||
if filtered != "", do: Logger.info("emacs: #{filtered}")
|
||||
|
||||
if exit_code == 0 do
|
||||
{:ok, exit_code}
|
||||
else
|
||||
{:error, {:emacs_exit, exit_code, filtered}}
|
||||
end
|
||||
rescue
|
||||
e -> {:error, e}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Export all `.org` files found under `notes_dir`.
|
||||
|
||||
Returns `{:ok, count}` where `count` is the number of successfully
|
||||
exported files, or `{:error, failures}` if any files failed.
|
||||
"""
|
||||
@spec export_all(String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, list()}
|
||||
def export_all(notes_dir, output_dir) do
|
||||
org_files =
|
||||
Path.join(notes_dir, "**/*.org")
|
||||
|> Path.wildcard()
|
||||
|
||||
if org_files == [] do
|
||||
Logger.warning("No .org files found in #{notes_dir}")
|
||||
{:ok, 0}
|
||||
else
|
||||
Logger.info("Exporting #{length(org_files)} org file(s) from #{notes_dir}")
|
||||
|
||||
results =
|
||||
Enum.map(org_files, fn orgfile ->
|
||||
IO.puts(" exporting: #{orgfile}")
|
||||
{orgfile, export_file(orgfile, notes_dir, output_dir)}
|
||||
end)
|
||||
|
||||
failures =
|
||||
Enum.filter(results, fn
|
||||
{_, {:ok, _}} -> false
|
||||
{_, {:error, _}} -> true
|
||||
end)
|
||||
|
||||
if failures == [] do
|
||||
{:ok, length(results)}
|
||||
else
|
||||
{:error, failures}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Compute the expected `.md` path for a given `.org` file.
|
||||
|
||||
Uses the same section-mapping logic as ox-hugo: the relative directory
|
||||
of the `.org` file within `notes_dir` becomes the section directory
|
||||
under `content_dir`.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> OrgGarden.Export.expected_md_path("/notes/bus/emt.org", "/notes", "/out/content")
|
||||
"/out/content/bus/emt.md"
|
||||
|
||||
iex> OrgGarden.Export.expected_md_path("/notes/top-level.org", "/notes", "/out/content")
|
||||
"/out/content/top-level.md"
|
||||
"""
|
||||
@spec expected_md_path(String.t(), String.t(), String.t()) :: String.t()
|
||||
def expected_md_path(orgfile, notes_dir, content_dir) do
|
||||
section =
|
||||
orgfile
|
||||
|> Path.dirname()
|
||||
|> Path.relative_to(notes_dir)
|
||||
|
||||
basename = Path.basename(orgfile, ".org") <> ".md"
|
||||
|
||||
case section do
|
||||
"." -> Path.join(content_dir, basename)
|
||||
_ -> Path.join([content_dir, section, basename])
|
||||
end
|
||||
end
|
||||
end
|
||||
83
org-garden/lib/org_garden/index.ex
Normal file
83
org-garden/lib/org_garden/index.ex
Normal file
@@ -0,0 +1,83 @@
|
||||
defmodule OrgGarden.Index do
|
||||
@moduledoc """
|
||||
Generates a fallback `index.md` in the content directory if none was
|
||||
exported from an `.org` file.
|
||||
|
||||
The generated index lists all markdown pages alphabetically with links.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Generate `content_dir/index.md` if it does not already exist.
|
||||
|
||||
If an `index.md` was already created by ox-hugo (from an `index.org`),
|
||||
it is left untouched.
|
||||
"""
|
||||
@spec generate(String.t()) :: :ok
|
||||
def generate(content_dir) do
|
||||
index_path = Path.join(content_dir, "index.md")
|
||||
|
||||
unless File.exists?(index_path) do
|
||||
IO.puts(" generating default index.md")
|
||||
|
||||
pages =
|
||||
Path.join(content_dir, "**/*.md")
|
||||
|> Path.wildcard()
|
||||
|> Enum.map(fn path ->
|
||||
slug = Path.relative_to(path, content_dir) |> Path.rootname()
|
||||
|
||||
title =
|
||||
path
|
||||
|> File.read!()
|
||||
|> then(fn content ->
|
||||
case Regex.run(~r/^title\s*=\s*"(.+)"/m, content) do
|
||||
[_, t] -> t
|
||||
_ -> slug
|
||||
end
|
||||
end)
|
||||
|
||||
{slug, title}
|
||||
end)
|
||||
|> Enum.sort_by(fn {_, title} -> title end)
|
||||
|> Enum.map(fn {slug, title} -> "- [#{title}](#{slug})" end)
|
||||
|> Enum.join("\n")
|
||||
|
||||
File.write!(index_path, """
|
||||
---
|
||||
title: Index
|
||||
---
|
||||
|
||||
#{pages}
|
||||
""")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Regenerate the index by removing any previously generated one first.
|
||||
|
||||
Only removes the index if it was generated by us (contains `title: Index`).
|
||||
User-exported index files (from `index.org`) are left untouched.
|
||||
"""
|
||||
@spec regenerate(String.t()) :: :ok
|
||||
def regenerate(content_dir) do
|
||||
index_path = Path.join(content_dir, "index.md")
|
||||
|
||||
if File.exists?(index_path) do
|
||||
content = File.read!(index_path)
|
||||
|
||||
if generated_index?(content) do
|
||||
File.rm!(index_path)
|
||||
end
|
||||
end
|
||||
|
||||
generate(content_dir)
|
||||
end
|
||||
|
||||
defp generated_index?(content) do
|
||||
# Our generated index uses "title: Index" in YAML frontmatter.
|
||||
# ox-hugo uses TOML frontmatter (title = "..."), so this won't
|
||||
# match user-exported files.
|
||||
String.contains?(content, "title: Index")
|
||||
end
|
||||
end
|
||||
118
org-garden/lib/org_garden/quartz.ex
Normal file
118
org-garden/lib/org_garden/quartz.ex
Normal file
@@ -0,0 +1,118 @@
|
||||
defmodule OrgGarden.Quartz do
|
||||
@moduledoc """
|
||||
Manages Quartz Node.js process as an Erlang Port.
|
||||
|
||||
Required environment:
|
||||
- QUARTZ_PATH: path to quartz repo (with node_modules)
|
||||
- NODE_PATH: path to node executable (default: "node")
|
||||
|
||||
Starts Quartz in serve mode (`npx quartz build --serve`) and forwards
|
||||
all stdout/stderr output to the Logger with a `[quartz]` prefix.
|
||||
|
||||
If Quartz exits, this GenServer will stop, which triggers the supervisor
|
||||
to restart the entire supervision tree (strategy: :one_for_all).
|
||||
"""
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
defstruct [:port, :quartz_path, :content_dir, :http_port, :ws_port]
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Client API
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Start the Quartz process as a linked GenServer.
|
||||
|
||||
## Options
|
||||
|
||||
* `:content_dir` — directory where markdown files are located (required)
|
||||
* `:port` — HTTP server port (default: 8080)
|
||||
* `:ws_port` — WebSocket hot reload port (default: 3001)
|
||||
"""
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# GenServer callbacks
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
quartz_path =
|
||||
System.get_env("QUARTZ_PATH") ||
|
||||
raise "QUARTZ_PATH environment variable not set"
|
||||
|
||||
node_path = System.get_env("NODE_PATH", "node")
|
||||
|
||||
content_dir = Keyword.fetch!(opts, :content_dir)
|
||||
http_port = Keyword.get(opts, :port, 8080)
|
||||
ws_port = Keyword.get(opts, :ws_port, 3001)
|
||||
|
||||
cli_path = Path.join(quartz_path, "quartz/bootstrap-cli.mjs")
|
||||
|
||||
unless File.exists?(cli_path) do
|
||||
raise "Quartz CLI not found at #{cli_path}. Check QUARTZ_PATH."
|
||||
end
|
||||
|
||||
args = [
|
||||
cli_path,
|
||||
"build",
|
||||
"--serve",
|
||||
"--directory", content_dir,
|
||||
"--port", to_string(http_port),
|
||||
"--wsPort", to_string(ws_port)
|
||||
]
|
||||
|
||||
Logger.info("[quartz] Starting: #{node_path} #{Enum.join(args, " ")}")
|
||||
Logger.info("[quartz] Working directory: #{quartz_path}")
|
||||
|
||||
port =
|
||||
Port.open({:spawn_executable, node_path}, [
|
||||
:binary,
|
||||
:exit_status,
|
||||
:stderr_to_stdout,
|
||||
args: args,
|
||||
cd: quartz_path,
|
||||
env: [{~c"NODE_NO_WARNINGS", ~c"1"}]
|
||||
])
|
||||
|
||||
state = %__MODULE__{
|
||||
port: port,
|
||||
quartz_path: quartz_path,
|
||||
content_dir: content_dir,
|
||||
http_port: http_port,
|
||||
ws_port: ws_port
|
||||
}
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({port, {:data, data}}, %{port: port} = state) do
|
||||
data
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.each(&Logger.info("[quartz] #{&1}"))
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({port, {:exit_status, status}}, %{port: port} = state) do
|
||||
Logger.error("[quartz] Process exited with status #{status}")
|
||||
{:stop, {:quartz_exit, status}, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, %{port: port}) when is_port(port) do
|
||||
# Attempt graceful shutdown
|
||||
Port.close(port)
|
||||
:ok
|
||||
rescue
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
def terminate(_reason, _state), do: :ok
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule Pipeline.Resolvers.BibTeX do
|
||||
defmodule OrgGarden.Resolvers.BibTeX do
|
||||
@moduledoc """
|
||||
Resolves citation keys from a local BibTeX (.bib) file.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule Pipeline.Resolvers.DOI do
|
||||
defmodule OrgGarden.Resolvers.DOI do
|
||||
@moduledoc """
|
||||
Last-resort citation resolver — always succeeds.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule Pipeline.Resolvers.Zotero do
|
||||
defmodule OrgGarden.Resolvers.Zotero do
|
||||
@moduledoc """
|
||||
Resolves citation keys via Zotero Better BibTeX's JSON-RPC API.
|
||||
|
||||
@@ -39,7 +39,7 @@ defmodule Pipeline.Resolvers.Zotero do
|
||||
body: payload,
|
||||
headers: [{"content-type", "application/json"}],
|
||||
receive_timeout: 5_000,
|
||||
finch: Pipeline.Finch
|
||||
finch: OrgGarden.Finch
|
||||
) do
|
||||
{:ok, %{status: 200, body: body}} ->
|
||||
parse_response(body, key, base_url)
|
||||
@@ -100,7 +100,7 @@ defmodule Pipeline.Resolvers.Zotero do
|
||||
body: payload,
|
||||
headers: [{"content-type", "application/json"}],
|
||||
receive_timeout: 5_000,
|
||||
finch: Pipeline.Finch
|
||||
finch: OrgGarden.Finch
|
||||
) do
|
||||
{:ok, %{status: 200, body: %{"result" => attachments}}} when is_list(attachments) ->
|
||||
attachments
|
||||
40
org-garden/lib/org_garden/supervisor.ex
Normal file
40
org-garden/lib/org_garden/supervisor.ex
Normal file
@@ -0,0 +1,40 @@
|
||||
defmodule OrgGarden.Supervisor do
|
||||
@moduledoc """
|
||||
Supervises development server components.
|
||||
|
||||
Strategy: :one_for_all
|
||||
If either child fails, restart both to ensure consistent state.
|
||||
|
||||
Children:
|
||||
1. OrgGarden.Watcher - watches .org files for changes
|
||||
2. OrgGarden.Quartz - runs Quartz Node.js server
|
||||
|
||||
## Usage
|
||||
|
||||
OrgGarden.Supervisor.start_link(
|
||||
notes_dir: "/path/to/notes",
|
||||
output_dir: "/path/to/output",
|
||||
content_dir: "/path/to/output/content",
|
||||
pipeline_opts: %{zotero_url: "...", ...},
|
||||
transforms: [OrgGarden.Transforms.Citations],
|
||||
port: 8080,
|
||||
ws_port: 3001
|
||||
)
|
||||
"""
|
||||
use Supervisor
|
||||
|
||||
def start_link(opts) do
|
||||
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
children = [
|
||||
{OrgGarden.Watcher,
|
||||
Keyword.take(opts, [:notes_dir, :output_dir, :content_dir, :pipeline_opts, :transforms])},
|
||||
{OrgGarden.Quartz, Keyword.take(opts, [:content_dir, :port, :ws_port])}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_all)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule Pipeline.Transform do
|
||||
defmodule OrgGarden.Transform do
|
||||
@moduledoc """
|
||||
Behaviour that all markdown transform modules must implement.
|
||||
|
||||
@@ -12,7 +12,7 @@ defmodule Pipeline.Transform do
|
||||
## Example
|
||||
|
||||
defmodule MyTransform do
|
||||
@behaviour Pipeline.Transform
|
||||
@behaviour OrgGarden.Transform
|
||||
|
||||
@impl true
|
||||
def init(opts), do: %{some_state: opts[:value]}
|
||||
@@ -37,9 +37,9 @@ defmodule Pipeline.Transform do
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
@behaviour Pipeline.Transform
|
||||
@behaviour OrgGarden.Transform
|
||||
|
||||
@impl Pipeline.Transform
|
||||
@impl OrgGarden.Transform
|
||||
def init(opts), do: opts
|
||||
|
||||
defoverridable init: 1
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule Pipeline.Transforms.Citations do
|
||||
defmodule OrgGarden.Transforms.Citations do
|
||||
@moduledoc """
|
||||
Markdown transform: resolves org-citar citation keys to hyperlinks.
|
||||
|
||||
@@ -37,22 +37,22 @@ defmodule Pipeline.Transforms.Citations do
|
||||
and probes Zotero availability, emitting warnings as appropriate.
|
||||
"""
|
||||
|
||||
@behaviour Pipeline.Transform
|
||||
@behaviour OrgGarden.Transform
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pipeline.Resolvers.Zotero
|
||||
alias Pipeline.Resolvers.BibTeX
|
||||
alias Pipeline.Resolvers.DOI
|
||||
alias OrgGarden.Resolvers.Zotero
|
||||
alias OrgGarden.Resolvers.BibTeX
|
||||
alias OrgGarden.Resolvers.DOI
|
||||
|
||||
# Match [cite:@key] and [cite:@key1;@key2;...] (org-cite / citar style)
|
||||
@cite_bracket_regex ~r/\[cite:(@[^\]]+)\]/
|
||||
|
||||
# Match bare cite:key (older roam style, no brackets, no @ prefix)
|
||||
@cite_bare_regex ~r/(?<![(\[])cite:([a-zA-Z0-9_:-]+)/
|
||||
# Match bare cite:key or cite:@key (older roam style, no brackets, optional @ prefix)
|
||||
@cite_bare_regex ~r/(?<![(\[])cite:@?([a-zA-Z0-9_:-]+)/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pipeline callbacks
|
||||
# OrgGarden callbacks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
@@ -202,7 +202,7 @@ defmodule Pipeline.Transforms.Citations do
|
||||
body: payload,
|
||||
headers: [{"content-type", "application/json"}],
|
||||
receive_timeout: 3_000,
|
||||
finch: Pipeline.Finch
|
||||
finch: OrgGarden.Finch
|
||||
)
|
||||
rescue
|
||||
e -> {:error, e}
|
||||
236
org-garden/lib/org_garden/watcher.ex
Normal file
236
org-garden/lib/org_garden/watcher.ex
Normal file
@@ -0,0 +1,236 @@
|
||||
defmodule OrgGarden.Watcher do
|
||||
@moduledoc """
|
||||
File-watching GenServer that detects `.org` file changes and triggers
|
||||
incremental export + transform for only the affected files.
|
||||
|
||||
Uses the `file_system` package (inotify on Linux, fsevents on macOS)
|
||||
to watch the notes directory. Events are debounced per-file (500ms)
|
||||
to coalesce rapid writes (e.g., Emacs auto-save).
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Started dynamically by `OrgGarden.CLI` after the initial batch export.
|
||||
Transforms are initialized once at startup and reused across all
|
||||
incremental rebuilds to avoid repeated Zotero probes and BibTeX loads.
|
||||
|
||||
## Usage
|
||||
|
||||
OrgGarden.Watcher.start_link(
|
||||
notes_dir: "/path/to/notes",
|
||||
output_dir: "/path/to/output",
|
||||
content_dir: "/path/to/output/content",
|
||||
pipeline_opts: %{zotero_url: "...", ...},
|
||||
transforms: [OrgGarden.Transforms.Citations]
|
||||
)
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
@debounce_ms 500
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Client API
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Start the watcher as a linked process.
|
||||
|
||||
## Options
|
||||
|
||||
* `:notes_dir` — directory to watch for `.org` changes (required)
|
||||
* `:output_dir` — ox-hugo base dir (required)
|
||||
* `:content_dir` — directory where `.md` files are written (required)
|
||||
* `:pipeline_opts` — opts map passed to transforms (required)
|
||||
* `:transforms` — list of transform modules (default: `[OrgGarden.Transforms.Citations]`)
|
||||
"""
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# GenServer callbacks
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
notes_dir = Keyword.fetch!(opts, :notes_dir)
|
||||
output_dir = Keyword.fetch!(opts, :output_dir)
|
||||
content_dir = Keyword.fetch!(opts, :content_dir)
|
||||
pipeline_opts = Keyword.fetch!(opts, :pipeline_opts)
|
||||
transforms = Keyword.get(opts, :transforms, [OrgGarden.Transforms.Citations])
|
||||
|
||||
# Initialize transforms once — reused for all incremental rebuilds
|
||||
initialized_transforms = OrgGarden.init_transforms(transforms, pipeline_opts)
|
||||
|
||||
# Start the file system watcher
|
||||
{:ok, watcher_pid} = FileSystem.start_link(dirs: [notes_dir], recursive: true)
|
||||
FileSystem.subscribe(watcher_pid)
|
||||
|
||||
Logger.info("Watcher: monitoring #{notes_dir} for .org changes")
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
notes_dir: notes_dir,
|
||||
output_dir: output_dir,
|
||||
content_dir: content_dir,
|
||||
pipeline_opts: pipeline_opts,
|
||||
watcher_pid: watcher_pid,
|
||||
initialized_transforms: initialized_transforms,
|
||||
pending: %{}
|
||||
}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:file_event, _pid, {path, events}}, state) do
|
||||
path = to_string(path)
|
||||
|
||||
if org_file?(path) and not temporary_file?(path) do
|
||||
event_type = classify_events(events)
|
||||
Logger.debug("Watcher: #{event_type} event for #{path}")
|
||||
{:noreply, schedule_debounce(path, event_type, state)}
|
||||
else
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:file_event, _pid, :stop}, state) do
|
||||
Logger.warning("Watcher: file system monitor stopped unexpectedly")
|
||||
{:stop, :watcher_stopped, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:debounced, path, event_type}, state) do
|
||||
state = %{state | pending: Map.delete(state.pending, path)}
|
||||
|
||||
case event_type do
|
||||
:deleted ->
|
||||
handle_delete(path, state)
|
||||
|
||||
_created_or_modified ->
|
||||
handle_change(path, state)
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, state) do
|
||||
OrgGarden.teardown_transforms(state.initialized_transforms)
|
||||
:ok
|
||||
end
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Event handling
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
defp handle_change(orgfile, state) do
|
||||
%{
|
||||
notes_dir: notes_dir,
|
||||
output_dir: output_dir,
|
||||
content_dir: content_dir,
|
||||
pipeline_opts: pipeline_opts,
|
||||
initialized_transforms: initialized_transforms
|
||||
} = state
|
||||
|
||||
md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir)
|
||||
IO.puts("==> Changed: #{Path.relative_to(orgfile, notes_dir)}")
|
||||
|
||||
case OrgGarden.Export.export_file(orgfile, notes_dir, output_dir) do
|
||||
{:ok, _} ->
|
||||
IO.puts(" exported: #{Path.relative_to(md_path, content_dir)}")
|
||||
|
||||
{:ok, stats} = OrgGarden.run_on_files_with([md_path], initialized_transforms, pipeline_opts)
|
||||
|
||||
Enum.each(stats, fn {mod, count} ->
|
||||
if count > 0, do: IO.puts(" #{inspect(mod)}: #{count} file(s) modified")
|
||||
end)
|
||||
|
||||
regenerate_index(content_dir)
|
||||
IO.puts("==> Done")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Watcher: export failed for #{orgfile}: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_delete(orgfile, state) do
|
||||
%{notes_dir: notes_dir, content_dir: content_dir} = state
|
||||
|
||||
md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir)
|
||||
IO.puts("==> Deleted: #{Path.relative_to(orgfile, notes_dir)}")
|
||||
|
||||
if File.exists?(md_path) do
|
||||
File.rm!(md_path)
|
||||
IO.puts(" removed: #{Path.relative_to(md_path, content_dir)}")
|
||||
|
||||
# Clean up empty parent directories left behind
|
||||
cleanup_empty_dirs(Path.dirname(md_path), content_dir)
|
||||
end
|
||||
|
||||
regenerate_index(content_dir)
|
||||
IO.puts("==> Done")
|
||||
end
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Index generation
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
defp regenerate_index(content_dir) do
|
||||
OrgGarden.Index.regenerate(content_dir)
|
||||
end
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
defp schedule_debounce(path, event_type, state) do
|
||||
# Cancel any existing timer for this path
|
||||
case Map.get(state.pending, path) do
|
||||
nil -> :ok
|
||||
old_ref -> Process.cancel_timer(old_ref)
|
||||
end
|
||||
|
||||
ref = Process.send_after(self(), {:debounced, path, event_type}, @debounce_ms)
|
||||
%{state | pending: Map.put(state.pending, path, ref)}
|
||||
end
|
||||
|
||||
defp org_file?(path), do: String.ends_with?(path, ".org")
|
||||
|
||||
defp temporary_file?(path) do
|
||||
basename = Path.basename(path)
|
||||
# Emacs creates temp files like .#file.org and #file.org#
|
||||
String.starts_with?(basename, ".#") or
|
||||
(String.starts_with?(basename, "#") and String.ends_with?(basename, "#"))
|
||||
end
|
||||
|
||||
defp classify_events(events) do
|
||||
cond do
|
||||
:removed in events or :deleted in events -> :deleted
|
||||
:created in events -> :created
|
||||
:modified in events or :changed in events -> :modified
|
||||
# renamed can mean created or deleted depending on context;
|
||||
# if the file exists it was renamed into the watched dir
|
||||
:renamed in events -> :modified
|
||||
true -> :modified
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_empty_dirs(dir, stop_at) do
|
||||
dir = Path.expand(dir)
|
||||
stop_at = Path.expand(stop_at)
|
||||
|
||||
if dir != stop_at and File.dir?(dir) do
|
||||
case File.ls!(dir) do
|
||||
[] ->
|
||||
File.rmdir!(dir)
|
||||
cleanup_empty_dirs(Path.dirname(dir), stop_at)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
34
org-garden/mix.exs
Normal file
34
org-garden/mix.exs
Normal file
@@ -0,0 +1,34 @@
|
||||
defmodule OrgGarden.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :org_garden,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.17",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps(),
|
||||
escript: escript()
|
||||
]
|
||||
end
|
||||
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger],
|
||||
mod: {OrgGarden.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
defp escript do
|
||||
[main_module: OrgGarden.CLI]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[
|
||||
{:finch, "~> 0.19"},
|
||||
{:req, "~> 0.5"},
|
||||
{:jason, "~> 1.4"},
|
||||
{:file_system, "~> 1.0"}
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,5 @@
|
||||
%{
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
19
org-garden/patches/01-glob-gitignore.patch
Normal file
19
org-garden/patches/01-glob-gitignore.patch
Normal file
@@ -0,0 +1,19 @@
|
||||
diff --git a/quartz/util/glob.ts b/quartz/util/glob.ts
|
||||
index 7a71160..91fbaa7 100644
|
||||
--- a/quartz/util/glob.ts
|
||||
+++ b/quartz/util/glob.ts
|
||||
@@ -10,12 +10,13 @@ export async function glob(
|
||||
pattern: string,
|
||||
cwd: string,
|
||||
ignorePatterns: string[],
|
||||
+ respectGitignore: boolean = true,
|
||||
): Promise<FilePath[]> {
|
||||
const fps = (
|
||||
await globby(pattern, {
|
||||
cwd,
|
||||
ignore: ignorePatterns,
|
||||
- gitignore: true,
|
||||
+ gitignore: respectGitignore,
|
||||
})
|
||||
).map(toPosixPath)
|
||||
return fps as FilePath[]
|
||||
13
org-garden/patches/02-build-gitignore.patch
Normal file
13
org-garden/patches/02-build-gitignore.patch
Normal file
@@ -0,0 +1,13 @@
|
||||
diff --git a/quartz/build.ts b/quartz/build.ts
|
||||
index b98f4a8..3166a06 100644
|
||||
--- a/quartz/build.ts
|
||||
+++ b/quartz/build.ts
|
||||
@@ -71,7 +71,7 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
||||
console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`)
|
||||
|
||||
perf.addEvent("glob")
|
||||
- const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns)
|
||||
+ const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns, false)
|
||||
const markdownPaths = allFiles.filter((fp) => fp.endsWith(".md")).sort()
|
||||
console.log(
|
||||
`Found ${markdownPaths.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
|
||||
34
org-garden/patches/03-static-hugo.patch
Normal file
34
org-garden/patches/03-static-hugo.patch
Normal file
@@ -0,0 +1,34 @@
|
||||
diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts
|
||||
index 0b45290..8b34049 100644
|
||||
--- a/quartz/plugins/emitters/static.ts
|
||||
+++ b/quartz/plugins/emitters/static.ts
|
||||
@@ -7,6 +7,7 @@ import { dirname } from "path"
|
||||
export const Static: QuartzEmitterPlugin = () => ({
|
||||
name: "Static",
|
||||
async *emit({ argv, cfg }) {
|
||||
+ // Copy Quartz's own internal static assets (quartz/static/) → output/static/
|
||||
const staticPath = joinSegments(QUARTZ, "static")
|
||||
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
|
||||
const outputStaticPath = joinSegments(argv.output, "static")
|
||||
@@ -18,6 +19,21 @@ export const Static: QuartzEmitterPlugin = () => ({
|
||||
await fs.promises.copyFile(src, dest)
|
||||
yield dest
|
||||
}
|
||||
+
|
||||
+ // Copy user-facing static assets (static/) → output/ preserving paths.
|
||||
+ // This mirrors Hugo's convention: static/ox-hugo/foo.png is served at /ox-hugo/foo.png,
|
||||
+ // which matches the src="/ox-hugo/..." paths that ox-hugo writes into exported markdown.
|
||||
+ const userStaticPath = "static"
|
||||
+ if (fs.existsSync(userStaticPath)) {
|
||||
+ const userFps = await glob("**", userStaticPath, cfg.configuration.ignorePatterns, false)
|
||||
+ for (const fp of userFps) {
|
||||
+ const src = joinSegments(userStaticPath, fp) as FilePath
|
||||
+ const dest = joinSegments(argv.output, fp) as FilePath
|
||||
+ await fs.promises.mkdir(dirname(dest), { recursive: true })
|
||||
+ await fs.promises.copyFile(src, dest)
|
||||
+ yield dest
|
||||
+ }
|
||||
+ }
|
||||
},
|
||||
async *partialEmit() {},
|
||||
})
|
||||
44
org-garden/patches/04-oxhugofm-figure.patch
Normal file
44
org-garden/patches/04-oxhugofm-figure.patch
Normal file
@@ -0,0 +1,44 @@
|
||||
diff --git a/quartz/plugins/transformers/oxhugofm.ts b/quartz/plugins/transformers/oxhugofm.ts
|
||||
index 303566e..4fb5e2c 100644
|
||||
--- a/quartz/plugins/transformers/oxhugofm.ts
|
||||
+++ b/quartz/plugins/transformers/oxhugofm.ts
|
||||
@@ -27,7 +27,10 @@ const defaultOptions: Options = {
|
||||
const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g")
|
||||
const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g")
|
||||
const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g")
|
||||
-const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "g")
|
||||
+// Matches the full Hugo {{< figure src="..." ... >}} shortcode and captures src.
|
||||
+// Must run before the generic shortcode stripper to avoid partial-match issues
|
||||
+// with captions that contain HTML (e.g. <span class="figure-number">).
|
||||
+const figureShortcodeRegex = new RegExp(/{{<\s*figure\b[^}]*\bsrc="([^"]*)"[^}]*>}}/, "g")
|
||||
// \\\\\( -> matches \\(
|
||||
// (.+?) -> Lazy match for capturing the equation
|
||||
// \\\\\) -> matches \\)
|
||||
@@ -70,19 +73,19 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
||||
})
|
||||
}
|
||||
|
||||
- if (opts.removeHugoShortcode) {
|
||||
+ if (opts.replaceFigureWithMdImg) {
|
||||
src = src.toString()
|
||||
- src = src.replaceAll(hugoShortcodeRegex, (_value, ...capture) => {
|
||||
- const [scContent] = capture
|
||||
- return scContent
|
||||
+ src = src.replaceAll(figureShortcodeRegex, (_value, ...capture) => {
|
||||
+ const [imgSrc] = capture
|
||||
+ return ``
|
||||
})
|
||||
}
|
||||
|
||||
- if (opts.replaceFigureWithMdImg) {
|
||||
+ if (opts.removeHugoShortcode) {
|
||||
src = src.toString()
|
||||
- src = src.replaceAll(figureTagRegex, (_value, ...capture) => {
|
||||
- const [src] = capture
|
||||
- return ``
|
||||
+ src = src.replaceAll(hugoShortcodeRegex, (_value, ...capture) => {
|
||||
+ const [scContent] = capture
|
||||
+ return scContent
|
||||
})
|
||||
}
|
||||
|
||||
17
org-garden/quartz-config/globals.d.ts
vendored
Normal file
17
org-garden/quartz-config/globals.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
export declare global {
|
||||
interface Document {
|
||||
addEventListener<K extends keyof CustomEventMap>(
|
||||
type: K,
|
||||
listener: (this: Document, ev: CustomEventMap[K]) => void,
|
||||
): void
|
||||
removeEventListener<K extends keyof CustomEventMap>(
|
||||
type: K,
|
||||
listener: (this: Document, ev: CustomEventMap[K]) => void,
|
||||
): void
|
||||
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K] | UIEvent): void
|
||||
}
|
||||
interface Window {
|
||||
spaNavigate(url: URL, isBack: boolean = false)
|
||||
addCleanup(fn: (...args: any[]) => void)
|
||||
}
|
||||
}
|
||||
15
org-garden/quartz-config/index.d.ts
vendored
Normal file
15
org-garden/quartz-config/index.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
declare module "*.scss" {
|
||||
const content: string
|
||||
export = content
|
||||
}
|
||||
|
||||
// dom custom event
|
||||
interface CustomEventMap {
|
||||
prenav: CustomEvent<{}>
|
||||
nav: CustomEvent<{ url: FullSlug }>
|
||||
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
||||
readermodechange: CustomEvent<{ mode: "on" | "off" }>
|
||||
}
|
||||
|
||||
type ContentIndex = Record<FullSlug, ContentDetails>
|
||||
declare const fetchData: Promise<ContentIndex>
|
||||
101
org-garden/quartz-config/quartz.config.ts
Normal file
101
org-garden/quartz-config/quartz.config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { QuartzConfig } from "./quartz/cfg"
|
||||
import * as Plugin from "./quartz/plugins"
|
||||
|
||||
/**
|
||||
* Quartz 4 Configuration
|
||||
*
|
||||
* See https://quartz.jzhao.xyz/configuration for more information.
|
||||
*/
|
||||
const config: QuartzConfig = {
|
||||
configuration: {
|
||||
pageTitle: "Quartz 4",
|
||||
pageTitleSuffix: "",
|
||||
enableSPA: true,
|
||||
enablePopovers: true,
|
||||
analytics: {
|
||||
provider: "plausible",
|
||||
},
|
||||
locale: "en-US",
|
||||
baseUrl: "quartz.jzhao.xyz",
|
||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||
defaultDateType: "modified",
|
||||
theme: {
|
||||
fontOrigin: "googleFonts",
|
||||
cdnCaching: true,
|
||||
typography: {
|
||||
header: "Schibsted Grotesk",
|
||||
body: "Source Sans Pro",
|
||||
code: "IBM Plex Mono",
|
||||
},
|
||||
colors: {
|
||||
lightMode: {
|
||||
light: "#faf8f8",
|
||||
lightgray: "#e5e5e5",
|
||||
gray: "#b8b8b8",
|
||||
darkgray: "#4e4e4e",
|
||||
dark: "#2b2b2b",
|
||||
secondary: "#284b63",
|
||||
tertiary: "#84a59d",
|
||||
highlight: "rgba(143, 159, 169, 0.15)",
|
||||
textHighlight: "#fff23688",
|
||||
},
|
||||
darkMode: {
|
||||
light: "#161618",
|
||||
lightgray: "#393639",
|
||||
gray: "#646464",
|
||||
darkgray: "#d4d4d4",
|
||||
dark: "#ebebec",
|
||||
secondary: "#7b97aa",
|
||||
tertiary: "#84a59d",
|
||||
highlight: "rgba(143, 159, 169, 0.15)",
|
||||
textHighlight: "#b3aa0288",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
transformers: [
|
||||
Plugin.FrontMatter({ delimiters: "+++", language: "toml" }),
|
||||
Plugin.CreatedModifiedDate({
|
||||
priority: ["frontmatter", "git", "filesystem"],
|
||||
}),
|
||||
Plugin.SyntaxHighlighting({
|
||||
theme: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
keepBackground: false,
|
||||
}),
|
||||
// OxHugoFlavouredMarkdown must come before GitHubFlavoredMarkdown.
|
||||
// Note: not compatible with ObsidianFlavoredMarkdown — use one or the other.
|
||||
// If ox-hugo exports TOML frontmatter, change FrontMatter to:
|
||||
// Plugin.FrontMatter({ delims: "+++", language: "toml" })
|
||||
Plugin.OxHugoFlavouredMarkdown(),
|
||||
Plugin.GitHubFlavoredMarkdown(),
|
||||
Plugin.TableOfContents(),
|
||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||
Plugin.Description(),
|
||||
Plugin.Latex({ renderEngine: "katex" }),
|
||||
],
|
||||
filters: [Plugin.RemoveDrafts()],
|
||||
emitters: [
|
||||
Plugin.AliasRedirects(),
|
||||
Plugin.ComponentResources(),
|
||||
Plugin.ContentPage(),
|
||||
Plugin.FolderPage(),
|
||||
Plugin.TagPage(),
|
||||
Plugin.ContentIndex({
|
||||
enableSiteMap: true,
|
||||
enableRSS: true,
|
||||
}),
|
||||
Plugin.Assets(),
|
||||
Plugin.Static(),
|
||||
Plugin.Favicon(),
|
||||
Plugin.NotFoundPage(),
|
||||
// Comment out CustomOgImages to speed up build time
|
||||
Plugin.CustomOgImages(),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
68
org-garden/quartz-config/quartz.layout.ts
Normal file
68
org-garden/quartz-config/quartz.layout.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { PageLayout, SharedLayout } from "./quartz/cfg"
|
||||
import * as Component from "./quartz/components"
|
||||
|
||||
// components shared across all pages
|
||||
export const sharedPageComponents: SharedLayout = {
|
||||
head: Component.Head(),
|
||||
header: [],
|
||||
afterBody: [],
|
||||
footer: Component.Footer({
|
||||
links: {
|
||||
GitHub: "https://github.com/jackyzha0/quartz",
|
||||
"Discord Community": "https://discord.gg/cRFFHYye7t",
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
// components for pages that display a single page (e.g. a single note)
|
||||
export const defaultContentPageLayout: PageLayout = {
|
||||
beforeBody: [
|
||||
Component.ConditionalRender({
|
||||
component: Component.Breadcrumbs(),
|
||||
condition: (page) => page.fileData.slug !== "index",
|
||||
}),
|
||||
Component.ArticleTitle(),
|
||||
Component.ContentMeta(),
|
||||
Component.TagList(),
|
||||
],
|
||||
left: [
|
||||
Component.PageTitle(),
|
||||
Component.MobileOnly(Component.Spacer()),
|
||||
Component.Flex({
|
||||
components: [
|
||||
{
|
||||
Component: Component.Search(),
|
||||
grow: true,
|
||||
},
|
||||
{ Component: Component.Darkmode() },
|
||||
{ Component: Component.ReaderMode() },
|
||||
],
|
||||
}),
|
||||
Component.Explorer(),
|
||||
],
|
||||
right: [
|
||||
Component.Graph(),
|
||||
Component.DesktopOnly(Component.TableOfContents()),
|
||||
Component.Backlinks(),
|
||||
],
|
||||
}
|
||||
|
||||
// components for pages that display lists of pages (e.g. tags or folders)
|
||||
export const defaultListPageLayout: PageLayout = {
|
||||
beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
|
||||
left: [
|
||||
Component.PageTitle(),
|
||||
Component.MobileOnly(Component.Spacer()),
|
||||
Component.Flex({
|
||||
components: [
|
||||
{
|
||||
Component: Component.Search(),
|
||||
grow: true,
|
||||
},
|
||||
{ Component: Component.Darkmode() },
|
||||
],
|
||||
}),
|
||||
Component.Explorer(),
|
||||
],
|
||||
right: [],
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
#!/usr/bin/env elixir
|
||||
# Export org-roam notes (per-file) to content/ via ox-hugo,
|
||||
# then run the markdown transformation pipeline (citations, etc.).
|
||||
#
|
||||
# Usage:
|
||||
# NOTES_DIR=~/notes elixir scripts/export.exs
|
||||
# elixir scripts/export.exs /path/to/notes
|
||||
#
|
||||
# Optional env vars:
|
||||
# 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
|
||||
#
|
||||
# The positional argument takes precedence over the NOTES_DIR env var.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load the pipeline Mix project so its modules are available in this script.
|
||||
# ---------------------------------------------------------------------------
|
||||
repo_root = __DIR__ |> Path.join("..") |> Path.expand()
|
||||
pipeline_dir = Path.join(repo_root, "scripts/pipeline")
|
||||
|
||||
# Compile and load the pipeline project's modules into this runtime.
|
||||
# Mix.install is NOT used here because we have a local Mix project — instead
|
||||
# we compile it and push its beam files onto the code path.
|
||||
#
|
||||
# This runs `mix deps.get` + `mix compile` the first time; subsequent runs
|
||||
# use the compiled artifacts from _build/ (fast, same as Mix caching).
|
||||
{_, 0} =
|
||||
System.cmd("mix", ["deps.get", "--quiet"],
|
||||
cd: pipeline_dir,
|
||||
env: [{"MIX_ENV", "prod"}],
|
||||
into: IO.stream()
|
||||
)
|
||||
|
||||
{_, 0} =
|
||||
System.cmd("mix", ["compile", "--quiet"],
|
||||
cd: pipeline_dir,
|
||||
env: [{"MIX_ENV", "prod"}],
|
||||
into: IO.stream()
|
||||
)
|
||||
|
||||
# Add compiled beam files to the load path so we can call pipeline modules.
|
||||
pipeline_build = Path.join(pipeline_dir, "_build/prod/lib")
|
||||
|
||||
pipeline_build
|
||||
|> File.ls!()
|
||||
|> Enum.each(fn app ->
|
||||
ebin = Path.join([pipeline_build, app, "ebin"])
|
||||
if File.dir?(ebin), do: Code.prepend_path(ebin)
|
||||
end)
|
||||
|
||||
# Start the pipeline OTP application (which starts Finch for HTTP).
|
||||
Application.ensure_all_started(:pipeline)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument / env resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
notes_dir =
|
||||
case System.argv() do
|
||||
[dir | _] -> dir
|
||||
[] ->
|
||||
System.get_env("NOTES_DIR") ||
|
||||
(IO.puts(:stderr, "Usage: NOTES_DIR=/path/to/notes elixir scripts/export.exs")
|
||||
System.halt(1))
|
||||
end
|
||||
|
||||
notes_dir = Path.expand(notes_dir)
|
||||
content_dir = Path.join(repo_root, "content")
|
||||
|
||||
unless File.dir?(notes_dir) do
|
||||
IO.puts(:stderr, "Error: notes directory does not exist: #{notes_dir}")
|
||||
System.halt(1)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1: Wipe content/
|
||||
# ---------------------------------------------------------------------------
|
||||
IO.puts("==> Wiping #{content_dir}")
|
||||
|
||||
content_dir
|
||||
|> File.ls!()
|
||||
|> Enum.reject(&(&1 == ".gitkeep"))
|
||||
|> Enum.each(fn entry ->
|
||||
Path.join(content_dir, entry) |> File.rm_rf!()
|
||||
end)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2: Export org files via Emacs + ox-hugo
|
||||
# ---------------------------------------------------------------------------
|
||||
IO.puts("==> Exporting org files from #{notes_dir}")
|
||||
|
||||
org_files =
|
||||
Path.join(notes_dir, "**/*.org")
|
||||
|> Path.wildcard()
|
||||
|
||||
if org_files == [] do
|
||||
IO.puts("No .org files found in #{notes_dir}")
|
||||
System.halt(0)
|
||||
end
|
||||
|
||||
results =
|
||||
Enum.map(org_files, fn orgfile ->
|
||||
IO.puts(" exporting: #{orgfile}")
|
||||
|
||||
section =
|
||||
orgfile
|
||||
|> Path.dirname()
|
||||
|> Path.relative_to(notes_dir)
|
||||
|
||||
{output, exit_code} =
|
||||
System.cmd(
|
||||
"emacs",
|
||||
[
|
||||
"--batch",
|
||||
"--eval", "(require 'ox-hugo)",
|
||||
"--eval", ~s[(setq org-hugo-base-dir "#{repo_root}")],
|
||||
"--eval", ~s[(setq org-hugo-default-section-directory "#{section}")],
|
||||
"--visit", orgfile,
|
||||
"--funcall", "org-hugo-export-to-md"
|
||||
],
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
|
||||
filtered =
|
||||
output
|
||||
|> String.split("\n")
|
||||
|> Enum.reject(&String.match?(&1, ~r/^Loading|^ad-handle|^For information/))
|
||||
|> Enum.join("\n")
|
||||
|
||||
if filtered != "", do: IO.puts(filtered)
|
||||
|
||||
{orgfile, exit_code}
|
||||
end)
|
||||
|
||||
failures = Enum.filter(results, fn {_, code} -> code != 0 end)
|
||||
|
||||
if failures != [] do
|
||||
IO.puts(:stderr, "\nFailed to export #{length(failures)} file(s):")
|
||||
Enum.each(failures, fn {f, code} -> IO.puts(:stderr, " [exit #{code}] #{f}") end)
|
||||
System.halt(1)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3: Markdown transformation pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
IO.puts("==> Running markdown pipeline")
|
||||
|
||||
pipeline_opts = %{
|
||||
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
|
||||
}
|
||||
|
||||
transforms = [Pipeline.Transforms.Citations]
|
||||
|
||||
case Pipeline.run(content_dir, transforms, pipeline_opts) do
|
||||
{:ok, stats} ->
|
||||
Enum.each(stats, fn {mod, count} ->
|
||||
IO.puts(" #{inspect(mod)}: #{count} file(s) modified")
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
IO.puts(:stderr, "Pipeline error: #{inspect(reason)}")
|
||||
System.halt(1)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 4: Generate default index.md if none was exported
|
||||
# ---------------------------------------------------------------------------
|
||||
md_count =
|
||||
Path.join(content_dir, "**/*.md")
|
||||
|> Path.wildcard()
|
||||
|> length()
|
||||
|
||||
index_path = Path.join(content_dir, "index.md")
|
||||
|
||||
unless File.exists?(index_path) do
|
||||
IO.puts("==> Generating default index.md")
|
||||
|
||||
pages =
|
||||
Path.join(content_dir, "**/*.md")
|
||||
|> Path.wildcard()
|
||||
|> Enum.map(fn path ->
|
||||
slug = Path.relative_to(path, content_dir) |> Path.rootname()
|
||||
|
||||
title =
|
||||
path
|
||||
|> File.read!()
|
||||
|> then(fn content ->
|
||||
case Regex.run(~r/^title\s*=\s*"(.+)"/m, content) do
|
||||
[_, t] -> t
|
||||
_ -> slug
|
||||
end
|
||||
end)
|
||||
|
||||
{slug, title}
|
||||
end)
|
||||
|> Enum.sort_by(fn {_, title} -> title end)
|
||||
|> Enum.map(fn {slug, title} -> "- [#{title}](#{slug})" end)
|
||||
|> Enum.join("\n")
|
||||
|
||||
File.write!(index_path, """
|
||||
---
|
||||
title: Index
|
||||
---
|
||||
|
||||
#{pages}
|
||||
""")
|
||||
end
|
||||
|
||||
IO.puts("==> Done. #{md_count} markdown files in #{content_dir}")
|
||||
@@ -1,83 +0,0 @@
|
||||
defmodule Pipeline do
|
||||
@moduledoc """
|
||||
Post-export markdown transformation pipeline.
|
||||
|
||||
Applies a list of transform modules sequentially over every .md file
|
||||
in a content directory. Each transform module must implement:
|
||||
|
||||
apply(content :: String.t(), opts :: map()) :: String.t()
|
||||
|
||||
Transforms are applied in the order given. A file is rewritten only
|
||||
when at least one transform mutates its content (checked via equality).
|
||||
|
||||
## Usage
|
||||
|
||||
opts = %{
|
||||
zotero_url: "http://localhost:23119",
|
||||
bibtex_file: System.get_env("BIBTEX_FILE"),
|
||||
citation_mode: :warn # :silent | :warn | :strict
|
||||
}
|
||||
|
||||
Pipeline.run(content_dir, [Pipeline.Transforms.Citations], opts)
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@type transform :: module()
|
||||
@type opts :: map()
|
||||
|
||||
@doc """
|
||||
Run all transforms over every .md file under `content_dir`.
|
||||
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("Pipeline: no .md files found in #{content_dir}")
|
||||
{:ok, %{}}
|
||||
else
|
||||
Logger.info("Pipeline: processing #{length(md_files)} markdown files with #{length(transforms)} transform(s)")
|
||||
|
||||
# Initialise transforms (allows them to perform setup such as loading a .bib file).
|
||||
# Each transform module must implement the Pipeline.Transform behaviour.
|
||||
initialized =
|
||||
Enum.map(transforms, fn mod ->
|
||||
state = mod.init(opts)
|
||||
{mod, state}
|
||||
end)
|
||||
|
||||
stats =
|
||||
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("Pipeline: updated #{Path.relative_to_cwd(path)}")
|
||||
end
|
||||
|
||||
Map.merge(acc, file_stats, fn _k, a, b -> a + b end)
|
||||
end)
|
||||
|
||||
Enum.each(initialized, fn {mod, state} ->
|
||||
# teardown/1 is optional in the behaviour
|
||||
if function_exported?(mod, :teardown, 1) do
|
||||
mod.teardown(state)
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, stats}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,27 +0,0 @@
|
||||
defmodule Pipeline.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :pipeline,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.15",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps()
|
||||
]
|
||||
end
|
||||
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger, :inets, :ssl],
|
||||
mod: {Pipeline.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[
|
||||
{:req, "~> 0.5"},
|
||||
{:jason, "~> 1.4"}
|
||||
]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user