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 → PDF

Command 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 exit

Scaffolding 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 templates

The 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 list collides with the glu init list subcommand. 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.mdstory.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.json

If the output path has no extension, .pdf is appended.

Logging

By default glu writes a log file next to the PDF (build.pdfbuild.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 entry

JSON 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:

  1. Pass 1: typeset, write aux data to <output>-aux.json.
  2. If the aux file changed, glu starts pass 2 with a fresh Lua state and the new aux loaded.
  3. 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 CSS

The watch set is computed once at startup and contains:

  • the input file (e.g. story.md);
  • the companion <stem>.lua if it exists next to the input;
  • the --css argument (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, and debug standard libraries are not loaded.
  • loadfile, dofile, load, loadstring, and collectgarbage are removed from the base library.
  • package.searchers is reduced to a single closure that looks only in _LOADED, so require() resolves the glu modules but cannot read arbitrary .lua files 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.md

Manifest 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.md

Small 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.fish

The 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-format only accepts text or json, 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-html

The script search order is:

  1. The current working directory (./foproc.lua).
  2. 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.