Skip to content

Instantly share code, notes, and snippets.

@johnmpost
Last active July 11, 2025 04:41
Show Gist options
  • Save johnmpost/5e7588f34d781e642a60cb2946159950 to your computer and use it in GitHub Desktop.
Save johnmpost/5e7588f34d781e642a60cb2946159950 to your computer and use it in GitHub Desktop.
static site hledger reports
  • i use a bash run style script in my hledger journal repo, so that's part of why it is the way it is (https://james-forbes.com/posts/alternative-to-npm-scripts). you don't have to do that for this to work
  • basically, the idea is that since the canonical state of my journal is in git anyway, and i only need read access, not write, then i don't need a fancy server to see my hledger reports. i can just generate static files based on the latest push to main
  • these can be whatever you want, even just text output, but hledger's latest html output format does a lot of heavy lifting
  • the bash script has a build() function, which calls a bunch of functions to generate files and tweak them for use as a static website. you can look through the bash code for the details but at a high level:
  • i generate the reports i want and output them
  • i generate a register view of each account so i can dig down to individual txns if i want to
  • i generate an index page which links to each report from the site root
  • i also hijack the feature noted here which is intended to allow the reports to link out to register views of an hledger-web server. i let it generate those links, then replace the links with the paths to my own generated register pages
  • all of this gets organized into a directory tree to be served as a static website. cloudflare workers lets you easily host static assets, and you only need like a 4-line wrangler file for something like what we want. you get a dev server too (which you could replace with something quicker if you wanted).
  • when you set up the cloudflare workers project, you'll be able to connect the github repo with your journal in it, and it will auto-deploy each time new code is pushed.
  • the final step is auth. this is a little more config-heavy, but it's pretty much all explained here. essentially, you can use cloudflare access to put an auth portal in front of your public app, then configure any identity provider to let desired individuals access your site. since you're hosting on cloudflare workers, you won't have to do any custom code on your site server to reject non-authed traffic.
  • it's all free! cloudflare workers has a free plan, and cloudflare access requires a payment method but it also has a free tier
#!/bin/bash
# should be called just `run` but .sh extension for github syntax highlighting. you get the idea
# install hledger locally
# i do this for the build pipeline but also because nixos doesn't have the latest version of hledger available lol.
# you can probably get away with your regular installed version as long as it's new enough to support
# html output format for the reports you want
install() {
if [ ! -x ./bin/hledger/hledger ]; then
mkdir -p ./bin/hledger
curl -L https://github.com/simonmichael/hledger/releases/download/1.43.2/hledger-linux-x64.tar.gz | tar -xz -C ./bin/hledger
fi
}
# any hledger command on main journal
hl() {
./bin/hledger/hledger -f my.journal "$@"
}
# balance sheet equity
bse() {
hl bse --end=today --alias '/^(revenues)\b/=equity:revenues' --alias '/^(expenses)\b/=equity:expenses' --drop=1 --tree "$@"
}
# monthly ending balances
meb() {
hl bal -M --end=today --cumulative assets liabilities revenues expenses "$@"
}
# income statement per month
ism() {
hl is -M "$@"
}
# income statement ytd
isy() {
hl is "$@"
}
# balance sheet budget
bsb() {
hl bse --end=today budget cash payable --alias '/^(budget)\b/=equity:budget' --drop=1 --tree "$@"
}
# monthly ending balances for budgets
mebb() {
hl bal budget -M --end=today --cumulative --no-total --invert "$@"
}
# income statement per month for budgets
isb() {
hl bal -M budget --no-total --invert amt:'<0' "$@"
hl bal -M budget --no-total --invert amt:'>0' "$@"
}
# register this month
regm() {
hl reg --period=thismonth "$@"
}
build() {
rm -r dist/
mkdir -p dist/
mkdir -p dist/registers
generate_reports_css > dist/hledger.css
touch dist/registers/hledger.css
generate_datafiles
add_utf8_meta
generate_index
}
generate_reports_css() {
cat <<EOF
a {
text-decoration: none;
color: blue;
}
a:hover {
text-decoration: underline;
}
EOF
}
generate_all_registers() {
mkdir -p dist/registers
hl accounts | while read -r acct; do
safe_name=$(echo "$acct" | tr ':' '_' | tr -cd '[:alnum:]_.-')
if [[ "$acct" == budget:* || "$acct" == expenses:* || "$acct" == revenues:* ]]; then
hl reg --invert "$acct" -o "dist/registers/${safe_name}.html"
else
hl reg "$acct" -o "dist/registers/${safe_name}.html"
fi
done
generate_registers_index
}
generate_registers_index() {
local dir="dist/registers"
local output="$dir/index.html"
{
echo '<!DOCTYPE html>'
echo '<html>'
echo '<head><meta charset="UTF-8"><title>Registers</title></head>'
echo '<body>'
echo '<h1>Account Registers</h1>'
echo '<ul>'
for f in "$dir"/*.html; do
name=$(basename "$f")
[[ "$name" == "index.html" ]] && continue
echo "<li><a href=\"$name\">${name%.html}</a></li>"
done
echo '</ul>'
echo '</body>'
echo '</html>'
} > "$output"
}
generate_datafiles() {
bsb --base-url="" -o dist/budget-balances.html
mebb --base-url="" -o dist/budget-monthly-ending-balances.html
isb --base-url="" -O html > dist/budget-allocation-and-spending.html
bse --base-url="" -o dist/real-balances.html
meb --base-url="" -o dist/real-monthly-ending-balances.html
ism --base-url="" -o dist/real-earnings-and-expenses-monthly.html
isy --base-url="" -o dist/real-earnings-and-expenses-ytd.html
hl print -o dist/_all-transactions.html
hl print | awk 'BEGIN{RS=""; ORS="\n\n"} {blocks[NR]=$0} END{for(i=NR;i>0;i--) print blocks[i]}' > dist/_all-transactions-descending.txt
generate_all_registers
for f in dist/*.html; do
transform_register_links "$f"
done
}
transform_register_links() {
local file="$1"
# Step 1: Replace aliases
sed -i -E \
-e 's/inacct:equity:budget([^+"]*)/inacct:budget\1/g' \
-e 's/inacct:equity:revenues([^+"]*)/inacct:revenues\1/g' \
-e 's/inacct:equity:expenses([^+"]*)/inacct:expenses\1/g' \
"$file"
# Step 2: Convert account names to file-safe format and rewrite hrefs
perl -i -pe '
s{
href="register\?q=inacct:([^\+">]+) # capture full acct name
}{
my $acct = $1;
$acct =~ s/:/_/g;
qq{href="registers/$acct.html"};
}gex
' "$file"
}
add_utf8_meta() {
for f in dist/*.html; do
[ -f "$f" ] || continue
grep -qi '<meta.*charset' "$f" && continue
sed -i '1s;^;<meta charset="UTF-8">\n;' "$f"
done
}
generate_index() {
local dir="dist"
local output="$dir/index.html"
{
echo '<!DOCTYPE html>'
echo '<html>'
echo '<head><meta charset="UTF-8"><title>Finances</title></head>'
echo '<body>'
echo '<h1>Reports</h1>'
echo '<ul>'
for f in $(find "$dir" -maxdepth 1 -type f \( -name '*.html' -o -name '*.txt' \) ! -name 'index.html' | sort); do
name=$(basename "$f")
echo "<li><a href=\"$name\">$name</a></li>"
done
echo '<li><a href="registers/index.html">registers/</a></li>'
echo '</ul>'
echo "<p>Built from git commit: $(git rev-parse --short HEAD) on $(TZ=America/Chicago date +"%Y-%m-%d %I:%M %p %Z")</p>"
echo '</body>'
echo '</html>'
} > "$output"
}
serve() {
npx wrangler dev
}
reserve() {
build
serve
}
eval "$@"
name = "whatever-you-want"
compatibility_date = "2025-07-02" # probably good to use any latest version
[assets]
directory = "./dist"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment