- 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
Last active
July 11, 2025 04:41
-
-
Save johnmpost/5e7588f34d781e642a60cb2946159950 to your computer and use it in GitHub Desktop.
static site hledger reports
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 "$@" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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