Files
mopac-pdf/internal/cli/cli.go
T
mrcharles 10e930e46d mopac-pdf v0: markdown to beautiful PDFs via digest-pinned typst
Go CLI that turns the markdown currency of this stack (Redmine notes,
Discourse posts, briefing output) into typeset PDFs: two embedded typst
templates (report with title page/TOC/headers, dense brief), front-matter
(title/subtitle/author/date/classification/template), GFM tables, and
bar charts rendered in pure Go (go-chart) from fenced chart data blocks.
Engine is a prebuilt typst 0.15.1 container pinned by digest and run
--network none; PDF bytes to stdout or -o. Exit codes 0/1/2.

All dev in docker (dev.sh/Makefile); unit tests + golden typst fixtures
plus a host-side smoke against the real engine container.

Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-29 05:44:24 -05:00

226 lines
5.6 KiB
Go

// Package cli wires the mopac-pdf pipeline: front-matter + markdown in,
// typst template composition, pure-Go chart assets, digest-pinned engine
// container, PDF bytes out.
//
// Exit codes: 0 ok, 1 usage/input error, 2 engine error.
package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"ukrrs.com/mopac/pdf/internal/chart"
"ukrrs.com/mopac/pdf/internal/engine"
"ukrrs.com/mopac/pdf/internal/frontmatter"
"ukrrs.com/mopac/pdf/internal/markdown"
"ukrrs.com/mopac/pdf/internal/pdfinfo"
"ukrrs.com/mopac/pdf/internal/typdoc"
"ukrrs.com/mopac/pdf/templates"
)
// Version is the CLI version.
const Version = "0.1.0"
const usage = `mopac-pdf (v` + Version + `) — markdown + front-matter in, beautiful PDF out
Usage:
mopac-pdf [flags] [INPUT.md] (default input: stdin)
Flags:
-o FILE write PDF to FILE (default: stdout)
-t NAME template: report | brief (default: front-matter
"template" key, else "report")
-T DIR template directory override (DIR/NAME.typ wins over the
embedded shipped templates)
-V print version and exit
-pages FILE print the page count of an existing PDF and exit
(verification helper; tiny /Pages /Count parser)
Input is markdown with optional front-matter (title, subtitle, author, date,
classification, template). Fenced blocks tagged "chart" render as figures:
` + "```" + `chart
type: bar
title: Revenue by quarter
unit: $K
Q1: 120
Q2: 180
` + "```" + `
Engine: digest-pinned typst container (override: MOPAC_PDF_ENGINE; docker
binary override: MOPAC_PDF_DOCKER; keep compile root: MOPAC_PDF_KEEP=1).
Exit codes: 0 ok, 1 usage/input error, 2 engine error.
`
// Run is the CLI entry point; returns the process exit code.
func Run(argv []string, stdout, stderr io.Writer) int {
args := argv
var outPath, tmplName, tmplDir, pagesPath string
var showVersion bool
rest := []string{}
for i := 0; i < len(args); i++ {
switch args[i] {
case "-o":
if i+1 >= len(args) {
fmt.Fprintln(stderr, "mopac-pdf: -o needs a value")
return 1
}
i++
outPath = args[i]
case "-t":
if i+1 >= len(args) {
fmt.Fprintln(stderr, "mopac-pdf: -t needs a value")
return 1
}
i++
tmplName = args[i]
case "-T":
if i+1 >= len(args) {
fmt.Fprintln(stderr, "mopac-pdf: -T needs a value")
return 1
}
i++
tmplDir = args[i]
case "-pages":
if i+1 >= len(args) {
fmt.Fprintln(stderr, "mopac-pdf: -pages needs a value")
return 1
}
i++
pagesPath = args[i]
case "-V", "--version":
showVersion = true
case "-h", "--help":
fmt.Fprint(stdout, usage)
return 0
case "help":
fmt.Fprint(stdout, usage)
return 0
default:
if strings.HasPrefix(args[i], "-") && args[i] != "-" {
fmt.Fprintf(stderr, "mopac-pdf: unknown flag %q\n\n%s", args[i], usage)
return 1
}
rest = append(rest, args[i])
}
}
if showVersion {
fmt.Fprintf(stdout, "mopac-pdf %s\n", Version)
return 0
}
if len(rest) > 1 {
fmt.Fprintf(stderr, "mopac-pdf: unexpected argument %q (one input file)\n\n%s", rest[1], usage)
return 1
}
inPath := "-"
if len(rest) == 1 {
inPath = rest[0]
}
if pagesPath != "" {
data, err := os.ReadFile(pagesPath)
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
n, err := pdfinfo.Pages(data)
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "%d\n", n)
return 0
}
// Input.
var input []byte
var err error
baseDir := "."
if inPath == "-" && pagesPath == "" {
input, err = io.ReadAll(os.Stdin)
} else if inPath != "-" {
input, err = os.ReadFile(inPath)
baseDir = filepath.Dir(inPath)
}
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: read input: %v\n", err)
return 1
}
meta, body, err := frontmatter.Split(string(input))
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
if tmplName == "" {
tmplName = meta.Template
}
if tmplName == "" {
tmplName = "report"
}
tmplSrc, err := templates.Source(tmplDir, tmplName)
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
// Markdown -> typst (+ assets).
blocks := markdown.Parse(body)
assets := typdoc.Assets{}
rendered, err := typdoc.RenderBody(blocks, assets, typdoc.Funcs{
RenderChart: func(c *markdown.Chart, _ string) ([]byte, error) { return chart.Bar(c) },
StageImage: func(path, _ string) ([]byte, error) {
return os.ReadFile(filepath.Join(baseDir, path))
},
})
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
doc := typdoc.Compose(tmplName, meta, rendered)
// Compile root + engine.
root, err := os.MkdirTemp("", "mopac-pdf-")
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
if os.Getenv("MOPAC_PDF_KEEP") == "1" {
fmt.Fprintf(stderr, "mopac-pdf: compile root kept at %s\n", root)
} else {
defer os.RemoveAll(root)
}
if err := os.WriteFile(filepath.Join(root, "template.typ"), []byte(tmplSrc), 0o644); err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
if err := os.WriteFile(filepath.Join(root, "doc.typ"), []byte(doc), 0o644); err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
pdf, err := engine.New().Compile(root, "doc.typ", assets)
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 2
}
// Output.
if outPath != "" {
if err := os.WriteFile(outPath, pdf, 0o644); err != nil {
fmt.Fprintf(stderr, "mopac-pdf: write output: %v\n", err)
return 1
}
} else {
if _, err := stdout.Write(pdf); err != nil {
fmt.Fprintf(stderr, "mopac-pdf: write output: %v\n", err)
return 1
}
}
return 0
}