mdtool renders Markdown-based documents with Go templates, generates Markdown TOCs, and converts Markdown or HTML into PDF output. HTML output remains available as a debug path.
This repository is pinned for mise:
mise install
The repo sets GOPRIVATE=gitlab.com/pidrakin/* in .mise.toml and .envrc, and pins the local GoReleaser and SBOM tooling used by the release-check workflow.
Common development entrypoints are exposed as mise tasks:
mise run build
mise run vet
mise run test
mise run test-race
mise run release-check
mise run build writes the local binary to ./.tmp/mdtool with the same version/build-time linker variables used for local development.
Installation
Native binaries are released for:
linux/amd64
linux/arm64
darwin/arm64
windows/amd64
Homebrew uses the shared tap that also hosts dotfiles:
brew tap pidrakin/dotfiles
brew install --cask pidrakin/dotfiles/mdtool
If you previously installed the old formula, migrate with:
brew uninstall mdtool
brew install --cask pidrakin/dotfiles/mdtool
Container images are Linux-only multi-arch:
Pull the version tag you want:
docker pull registry.gitlab.com/pidrakin/mdtool:<tag>
Commands
# parse a template into markdown
mdtool template example.md
# render template output directly to HTML
mdtool template example.md --convert --html
# add or update a markdown TOC
mdtool toc example.md
# convert markdown or HTML to PDF
mdtool convert example.md
# eject the project config template into ./config.yaml
mdtool eject config.yaml
Default output files:
template example.md writes example.parsed.md
toc example.md writes example.toc.md
convert example.md writes example.pdf
convert example.md --html writes example.html
--dry-run is a strict no-write mode for mutating commands. It prints the planned action and does not emit rendered payloads.
Configuration
mdtool resolves configuration in this order:
./config.yaml
$XDG_CONFIG_HOME/mdtool/config.yaml
- legacy
$HOME/.config/mdtool.yaml
/etc/mdtool/config.yaml
- embedded defaults
Config keys:
logTarget: log destination
browserBinary: optional explicit Chromium/Chrome binary path
To start from the embedded project config:
mdtool eject config.yaml
Template and TOC behavior
include is sandboxed to the root document directory tree. Absolute paths and path escapes outside that tree are rejected.
--template-data key=value splits on the first = only, so values may contain spaces.
template --toc fails if the source does not contain a [[TOC]] directive.
- TOC directives document
fromLevel and toLevel; the legacy fromHeading and toHeading spellings are still accepted.
PDF and HTML rendering
convert --html always emits one complete HTML document.
- PDF generation uses a Chromium renderer driven from Go via CDP.
- Math is hydrated in-browser with an embedded KaTeX runtime before HTML/PDF output is finalized.
- Binary releases are built for
linux/amd64, linux/arm64, darwin/arm64, and windows/amd64.
- Container images bundle Chromium, required fonts, Java, and PlantUML for the supported Linux target and run as an unprivileged
mdtool user by default.
- Raw binaries expect a compatible Chrome/Chromium installation on the host.
- The Homebrew delivery path is an unsigned cask with a postflight
xattr -dr com.apple.quarantine workaround generated into the tap.
- On Windows, browser auto-discovery checks standard Chrome and Edge install locations first and then falls back to
PATH.
- Documents that contain
plantuml code fences also require the plantuml executable and a Java runtime in PATH; if they are missing, mdtool exits with a clear error.
MDTOOL_BROWSER or browserBinary can point to an explicit browser binary when auto-discovery is not sufficient.
The bounded renderer replacement evaluation for this cycle is documented in docs/renderer-feasibility.md.
Container runtime
Recommended secure invocation when the document only uses local assets:
mkdir -p out
docker run --rm \
--user "$(id -u):$(id -g)" \
--read-only \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--pids-limit=256 \
--tmpfs /tmp:rw,nosuid,nodev,size=1g \
--network=none \
--mount type=bind,src="$PWD",dst=/src,ro \
--mount type=bind,src="$PWD/out",dst=/out \
registry.gitlab.com/pidrakin/mdtool:<tag> \
convert /src/example.md --outfile /out/example.pdf
Runtime notes:
--tmpfs /tmp is required if you use --read-only, because mdtool stages source files and the Chromium profile under /tmp.
--network=none is appropriate only when the document does not intentionally load remote assets.
--user "$(id -u):$(id -g)" keeps output file ownership aligned with the calling user on bind mounts.
- In containers,
mdtool disables Chromium's internal sandbox and relies on the container runtime hardening flags instead. Keep --cap-drop=ALL, --security-opt=no-new-privileges, --read-only, and --network=none unless the document truly needs more access.
- When the configured file log target is not writable under
--read-only, mdtool falls back to stderr logging instead of exiting.
- Files written under
/tmp do not survive container exit when /tmp is a tmpfs. Use a separate writable bind mount or volume for output if you need the PDF after the process exits.
- If you need tighter filesystem separation, mount sources read-only and mount a separate writable output directory or volume.
Release workflow
beta is the integration branch.
- GitLab CI runs
go test ./..., go vet ./..., go test -race ./..., goreleaser check, and a snapshot GoReleaser build.
- Non-tag commits on release branches run
semantic-release to update CHANGELOG.md and create tags.
- Tag pipelines run the pinned GoReleaser release job and publish the native binaries, the Linux multi-arch container image, archive SBOMs, and the generated Homebrew cask artifact.
- Tag pipelines prepare
buildx plus binfmt so one Linux runner can publish linux/amd64 and linux/arm64 image variants plus the manifest tag.
- The Homebrew cask is published to the shared
pidrakin/homebrew-dotfiles tap after a successful tagged release. That step requires BREW_TAP_GITHUB_TOKEN.
- The old formula in the tap should be manually disabled with
replacement_cask: "mdtool" during the migration so existing users get a clear upgrade hint.
- The
bump job expects a protected, masked CI variable named SEMANTIC_RELEASE_PK containing the base64-encoded private SSH deploy key used to push the release commit and tag.
- The corresponding public deploy key must be added to the project as a
read-write deploy key, and it must be allowed to push to beta if beta is protected.
For a local release check:
bin/build.sh
That script starts a disposable local docker:dind daemon, runs the pinned GoReleaser container against it, executes goreleaser check, and then performs a snapshot release build with the same dockers_v2/SBOM path used in CI.