Using glu
glu converts Markdown or HTML files to PDF, or executes Lua scripts for programmatic typesetting. The mode is determined by the file extension:
glu document.md # Markdown → PDF
glu page.html # HTML → PDF
glu script.lua # Lua script → PDFCommand line reference
glu [options] <filename.md|filename.html|filename.lua>
glu [options] --input-format md -o out.pdf - # read from stdin
glu [-w | --watch] <input> # re-render on change
glu doctor # environment checks
glu completion bash|zsh|fish # shell completion
glu init [DIR] [TEMPLATE] # scaffold a project
glu init list # list templates
Output and logging:
-o, --output FILE Write PDF to FILE (default: <input>.pdf)
--max-passes N Maximum auto-rerun passes (default: 3)
--log-file FILE Override log path; '-' disables the log file
--log-format FMT Log output: 'text' (default) or 'json'
--loglevel LVL debug, info (default), warn, error
-q, --quiet Suppress console output
--manifest FILE Write JSON sidecar (pages, passes, duration, headings)
--source-date-epoch S Fix PDF dates (also reads $SOURCE_DATE_EPOCH)
Pipeline:
-w, --watch Re-render on input / companion / CSS changes
--safe Lua sandbox: no io/os/debug, no file loaders
--template Apply Go template expansion (Markdown mode)
--css FILE Additional CSS file
--input-format FMT Required for stdin input: md, html, or lua
--markdown Print expanded Markdown to stdout (debug)
--html Print generated HTML to stdout (debug)
--clean Remove the aux file before processing
--cpuprofile FILE Write CPU profile to FILE
Other:
-v, --version Print version and exit
-h, --help Show this help
Subcommands:
doctor Run environment self-checks
completion SHELL Print shell completion script
init [DIR] [TEMPLATE] Scaffold a new project
help Show the help message
version Print version and exitScaffolding a project
glu init lays down a starter project from one of three embedded templates. The grammar is positional:
glu init # 'report' template in cwd
glu init mydoc # 'report' template in ./mydoc/
glu init mydoc bare # 'bare' template in ./mydoc/
glu init list # print available templatesThe templates follow a staged-difficulty arc, each adding exactly one concept:
| Template | What’s in it |
|---|---|
bare |
index.md only — minimal Markdown |
report |
index.md + style.css — Markdown + CSS; page numbers via @bottom-center |
pdfua |
index.md only — Markdown pre-configured for format: PDF/UA with all structural elements (headings, lists, table, image with alt) exercised |
Templates ship inside the glu binary (via go:embed), so there’s nothing to download or configure. glu init refuses to overwrite any existing file — if a target file already exists, you’ll see an io error: refusing to overwrite … and an exit code 3.
Lua companion files are not part of any default template. The report template’s “Next steps” bullet points users at adding an index.lua themselves when they want lifecycle callbacks; see Callbacks.
Edge case: a target directory literally named
listcollides with theglu init listsubcommand. Pass./list(or any path with a separator) in that case.
Output paths
By default the PDF is written next to the input file with the same stem (story.md → story.pdf). --output redirects everything — the log file, aux file, and any manifest follow the same stem:
glu -o /tmp/build.pdf story.md
# writes /tmp/build.pdf, /tmp/build.log, /tmp/build-aux.jsonIf the output path has no extension, .pdf is appended.
Logging
By default glu writes a log file next to the PDF (build.pdf → build.log) and mirrors it to the console.
glu --quiet story.md # log file only, no console output
glu --log-file=- story.md # console only, no log file
glu --log-file=/tmp/x.log story.md # explicit log path
glu --log-format=json story.md # one JSON record per log entryJSON logging emits one slog-style record per line — suitable for CI ingestion or pipelines that parse log output:
{"time":"2026-05-12T13:45:01Z","level":"INFO","msg":"PDF written","file":"story.pdf","pages":12}Auto-rerun for forward references
Markdown and HTML documents that depend on forward references (total page count via counter(pages), table of contents from collected headings, etc.) need to be typeset more than once. glu drives this loop automatically:
- Pass 1: typeset, write aux data to
<output>-aux.json. - If the aux file changed, glu starts pass 2 with a fresh Lua state and the new aux loaded.
- The loop ends when the aux is stable or
--max-passes(default 3) is exhausted.
If the aux file oscillates between two states (a sign of non-deterministic Lua code), glu aborts the loop early with exit code 6 rather than wasting more passes.
--clean deletes the aux file before the first pass, useful when an aux file from an earlier crashed run is poisoning the rerun loop.
Watch mode
glu --watch <input> (or -w) keeps glu running and re-renders whenever a watched file changes. This is the authoring loop for Markdown documents — edit, save, see the rebuilt PDF.
glu --watch story.md # rebuild on any change to story.md, story.lua, or CSSThe watch set is computed once at startup and contains:
- the input file (e.g.
story.md); - the companion
<stem>.luaif it exists next to the input; - the
--cssargument (the CLI flag value); - the
css:field from the Markdown frontmatter, resolved cwd-relative first, then relative to the input directory.
The set is fixed at watch start — editing css: a.css to css: b.css inside the frontmatter mid-session won’t pick up b.css until you restart glu --watch. Restart in that case.
Events are debounced (200 ms) so a burst of editor-save events (most editors fire CREATE + RENAME + WRITE within ~50 ms) collapses into one rebuild. Builds during a rebuild are coalesced: if you save while a build is in flight, exactly one more build fires after the current one finishes, regardless of how many saves happened during it.
Errors during a rebuild are logged but never exit the loop — you can fix the typo and save again, no need to restart. Ctrl-C exits cleanly.
--watch is incompatible with stdin input (no file to watch) and with --markdown / --html debug modes (they print and exit).
Reading from stdin
Use - as the input filename to read from standard input. Because there is no file extension to dispatch on, you must pass --input-format:
cat story.md | glu --input-format=md -o out.pdf -
pandoc foo.docx -t markdown | glu --input-format=md -o foo.pdf -Stdin input requires -o (the PDF can’t be written next to a stream). Companion .lua files are not loaded for stdin input — the temp filename used internally has no matching sibling.
Safe mode
--safe runs Lua in a hardened sandbox:
- The
io,os, anddebugstandard libraries are not loaded. loadfile,dofile,load,loadstring, andcollectgarbageare removed from the base library.package.searchersis reduced to a single closure that looks only in_LOADED, sorequire()resolves the glu modules but cannot read arbitrary.luafiles from disk.
Use this when rendering Markdown from untrusted sources (e.g. user-submitted content in a documentation CI). Sandboxing is defence-in-depth, not a hermetic seal — the glu.frontend API and friends can still write to disk because they live outside the Lua VM. Combine with file-system restrictions or container isolation for the full picture.
glu --safe untrusted.mdManifest output
--manifest FILE writes a JSON sidecar with everything useful for downstream tooling:
glu --manifest=build.json story.md{
"schema_version": "1",
"glu_version": "1.0.0",
"input": "story.md",
"output": "story.pdf",
"pages": 12,
"passes": 2,
"duration_ms": 482,
"generated_at": "2026-05-12T13:45:01Z",
"headings": [
{ "level": "h1", "text": "Introduction", "page": 1 },
{ "level": "h2", "text": "Background", "page": 2 }
]
}The schema_version field is stable — consumers can pin to it.
Reproducible builds
Setting SOURCE_DATE_EPOCH (the reproducible-builds.org convention) fixes the PDF’s CreationDate, XMP timestamps, and document UUIDs to deterministic values derived from the given epoch second. The flag --source-date-epoch overrides the environment variable.
SOURCE_DATE_EPOCH=1700000000 glu story.md
glu --source-date-epoch=1700000000 story.mdSmall documents become byte-identical across runs with the same input and the same epoch. Larger documents may still drift by a handful of bytes inside the embedded font subset — that’s a known limitation tracked in the toolchain, not a glu bug.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Generic / unknown error |
| 2 | Usage error (bad flags, missing arguments) |
| 3 | IO error (file not found, permission denied) |
| 4 | Lua error (syntax or runtime in {lua} block or companion) |
| 5 | Typesetting error (boxesandglue or htmlbag failure) |
| 6 | Aux file did not converge after --max-passes |
CI pipelines can dispatch on these codes — for example treating aux non-convergence (6) as a soft warning and only failing on 3-5.
Environment self-checks
glu doctor runs a series of checks and reports the result:
== Build info ==
[OK] glu version=1.0.0
[OK] Go runtime=go1.26.0 darwin/arm64
[OK] vcs revision=a974523 time=2026-05-12T12:57:04Z
== Filesystem ==
[OK] cwd /Users/patrick/work is writable
== Hyphenation patterns ==
[OK] supported: en de fr es it nl pl pt ru cs hu tr
[WARN] no-op (no patterns, hyphenation disabled): ar he zh ja ko
== Font directories ==
[OK] /System/Library/Fonts — 370 font file(s)
[OK] /Library/Fonts — 59 font file(s)
[OK] /Users/patrick/Library/Fonts — 50 font file(s)
== External tools (optional) ==
[OK] qpdf at /opt/homebrew/bin/qpdf
[OK] pdfinfo at /opt/homebrew/bin/pdfinfo
[WARN] verapdf not on PATH (PDF/UA conformance validation)
12 check(s) OK, 1 warning(s).Exit code equals the number of [FAIL] lines (warnings don’t count). Useful as the first step in a bug report.
Shell completion
glu completion <shell> prints a completion script for bash, zsh, or fish to stdout. Pipe it into the right location for your shell:
# bash
glu completion bash > /etc/bash_completion.d/glu
# zsh — put the file somewhere on your fpath
glu completion zsh > ~/.zfunc/_glu
# ... and ensure ~/.zfunc is in fpath before `autoload -Uz compinit`
# fish
glu completion fish > ~/.config/fish/completions/glu.fishThe generated script offers tab completion for:
- All registered short and long options (e.g.
-o,--max-passes,--log-format). - Subcommands (
doctor,completion,help,version) when typing the first positional argument. - File completion as a best-effort default for options that take a value —
glu --css <TAB>lists CSS files in the current directory,glu -o <TAB>offers any path. The parser doesn’t know that--log-formatonly acceptstextorjson, so file completion is the fallback.
Regenerate the script after upgrading glu — new options or subcommands only appear in completion if the script is re-installed.
Markdown mode
When you pass a .md file, glu converts it to PDF automatically. See Markdown mode for the full feature set (frontmatter, Lua blocks, custom CSS).
HTML mode
When you pass an .html or .htm file, glu converts it directly to PDF. Unlike Markdown mode, no default stylesheet is applied — styling comes from <style> tags, <link> stylesheets, or the --css flag. See HTML mode for details.
Lua script mode
When you pass a .lua file, glu executes it as a Lua script. The script has full control over the PDF output using the glu.frontend and other modules. See Lua script mode for the hello world example and available modules.
Multi-call binary (symlinks)
When glu is invoked under any name other than glu — typically by creating a symlink — it looks for a Lua script named after the symlink and runs it with all positional arguments forwarded. This turns Lua-script-based tools into first-class commands.
ln -s /usr/local/bin/glu /usr/local/bin/foproc
foproc input.fo # runs foproc.lua with arg[1] = input.fo
foproc input.fo keep-html # runs foproc.lua with arg[1] = input.fo, arg[2] = keep-htmlThe script search order is:
- The current working directory (
./foproc.lua). - The directory of the symlink itself (e.g.
/usr/local/bin/foproc.lua).
If neither location contains the script, glu reports a multi-call error.
Glu’s own flags (--loglevel, --css, --quiet, --version, --help, --clean, --cpuprofile, --output, --safe, --max-passes, --log-file, --log-format, --manifest, --source-date-epoch) are still parsed up front and behave exactly as in normal glu mode. They are not forwarded to the Lua script’s arg[] table — only the positional arguments are. This means a script can rely on arg[] being free of ---flags, but it also means the script cannot define its own ---flags; if a script wants subcommand-like behaviour, use a positional keyword (e.g. foproc input.fo keep-html).
The glu binary itself behaves unchanged when invoked under that name; the multi-call dispatch only triggers for any other name.