diff --git a/CLAUDE.md b/CLAUDE.md
index ffe104c..6a15a87 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -31,7 +31,7 @@ Requires Go (see `go.mod`/`mise.toml` for version) and, for packaging, `just`, `
Everything happens in `main()` in `main.go`, in a straight-line pipeline:
-1. **Flag parsing** — `-o`, `-v/-version`, `-h/-help`, `-b/-bare`. First positional arg is the input markdown file; a single `-` reads the markdown from stdin instead, in which case `baseDir` for image resolution is the working directory rather than the input file's directory.
+1. **Flag parsing** (`parseFlags`) — `-o`, `-v/-version`, `-h/-help` (prints usage to stdout, exits 0), `-b/-bare`. Flags work before or after the filename (`--` ends flag parsing). Exactly one positional arg is accepted: the input markdown file. A single `-` reads the markdown from stdin instead (and fails if stdin is a terminal), in which case `baseDir` for image resolution is the working directory rather than the input file's directory.
2. **Image inlining** (`newMarkdown` → `imageTransformer` + `htmlImageRenderer` → `embedImage`/`imageToDataURI`) — rewrites relative image references into base64 `data:` URIs so the output HTML is fully self-contained/offline-viewable. Markdown images (inline and reference-style) are rewritten by an AST transformer, so images inside code spans and code blocks are left alone. Raw `
` tags (`src` and `srcset`) are rewritten by a custom renderer for `HTMLBlock`/`RawHTML` nodes. `embedImage` strips query/fragment and decodes percent-encoding. `imageToDataURI` only reads files with an allowlisted image extension (`getMimeType`), resolves symlinks before checking the path (at most 3 `..` levels above the base directory), accepts only regular files, and caps images at 10MB.
3. **Markdown parsing** via Goldmark (built by `newMarkdown`), configured with:
- `extension.GFM` (tables, task lists, strikethrough, etc.)
diff --git a/main.go b/main.go
index f089a79..3b48418 100644
--- a/main.go
+++ b/main.go
@@ -65,28 +65,46 @@ func main() {
flag.BoolVar(helpPtr, "h", false, "Prints mdview help message.")
flag.BoolVar(barePtr, "b", false, "Bare HTML with no style applied.")
- flag.Parse()
- inputFilename := flag.Arg(0)
+ flag.Usage = func() { printUsage(os.Stderr) }
+ args, err := parseFlags(flag.CommandLine, os.Args[1:])
+ if err != nil {
+ os.Exit(2)
+ }
if *versionPtr {
fmt.Println(appVersion)
os.Exit(0)
}
- if inputFilename == "" || *helpPtr {
- os.Stderr.WriteString("Usage:\nmdview [options] \nFormats markdown and launches it in a browser.\nUse - as the filename to read markdown from standard input.\nIf the environment variable MDVIEW_DIR is set, the temporary file will be written there.\n")
- flag.PrintDefaults()
+ if *helpPtr {
+ printUsage(os.Stdout)
+ os.Exit(0)
+ }
+
+ if len(args) == 0 {
+ printUsage(os.Stderr)
os.Exit(1)
}
+ if len(args) > 1 {
+ fmt.Fprintf(os.Stderr, "mdview accepts one filename, but received %d: %s\n\n", len(args), strings.Join(args, " "))
+ printUsage(os.Stderr)
+ os.Exit(1)
+ }
+
+ inputFilename := args[0]
+
// A filename of - means read the markdown from standard input. Relative image
// paths are then resolved against the working directory, as there is no input
// file to anchor them to.
var dat []byte
- var err error
baseDir := "."
if inputFilename == "-" {
+ if stdinIsTerminal() {
+ fmt.Fprintln(os.Stderr, "mdview: no markdown on standard input. Pipe markdown into mdview or pass a filename.")
+ os.Exit(1)
+ }
dat, err = io.ReadAll(os.Stdin)
check(err)
} else {
@@ -137,6 +155,40 @@ func main() {
check(err)
}
+// printUsage writes the usage text and the option defaults to w
+func printUsage(w io.Writer) {
+ fmt.Fprint(w, "Usage:\nmdview [options] \nFormats markdown and launches it in a browser.\nUse - as the filename to read markdown from standard input.\nIf the environment variable MDVIEW_DIR is set, the temporary file will be written there.\n")
+ flag.CommandLine.SetOutput(w)
+ flag.PrintDefaults()
+}
+
+// parseFlags parses args and returns the positional arguments. Unlike FlagSet.Parse
+// alone, it accepts flags before, between and after the positional arguments.
+// Everything after a -- argument is positional.
+func parseFlags(fs *flag.FlagSet, args []string) ([]string, error) {
+ var positional []string
+ for {
+ if err := fs.Parse(args); err != nil {
+ return nil, err
+ }
+ consumed := len(args) - fs.NArg()
+ if consumed > 0 && args[consumed-1] == "--" {
+ return append(positional, fs.Args()...), nil
+ }
+ if fs.NArg() == 0 {
+ return positional, nil
+ }
+ positional = append(positional, fs.Arg(0))
+ args = fs.Args()[1:]
+ }
+}
+
+// stdinIsTerminal reports whether standard input is an interactive terminal
+func stdinIsTerminal() bool {
+ info, err := os.Stdin.Stat()
+ return err == nil && info.Mode()&os.ModeCharDevice != 0
+}
+
func tempFileName(prefix, suffix string) string {
randBytes := make([]byte, 16)
rand.Read(randBytes)
diff --git a/main_test.go b/main_test.go
index 4228e7c..4d4c620 100644
--- a/main_test.go
+++ b/main_test.go
@@ -2,10 +2,12 @@ package main
import (
"bytes"
+ "flag"
"fmt"
stdhtml "html"
"os"
"path/filepath"
+ "reflect"
"strings"
"testing"
@@ -234,3 +236,41 @@ func TestTitleIsEscaped(t *testing.T) {
})
}
}
+
+func TestParseFlags(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ wantPos []string
+ wantOut string
+ wantBare bool
+ }{
+ {"no arguments", nil, nil, "", false},
+ {"flags first", []string{"-o", "x.html", "a.md"}, []string{"a.md"}, "x.html", false},
+ {"flags after filename", []string{"a.md", "-o", "x.html", "-b"}, []string{"a.md"}, "x.html", true},
+ {"flags on both sides", []string{"-b", "a.md", "-o", "x.html"}, []string{"a.md"}, "x.html", true},
+ {"stdin marker", []string{"-", "-o", "x.html"}, []string{"-"}, "x.html", false},
+ {"double dash", []string{"-b", "--", "-weird.md"}, []string{"-weird.md"}, "", true},
+ {"flags after double dash are positional", []string{"--", "a.md", "-b"}, []string{"a.md", "-b"}, "", false},
+ {"several files", []string{"a.md", "b.md"}, []string{"a.md", "b.md"}, "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fs := flag.NewFlagSet("mdview", flag.ContinueOnError)
+ out := fs.String("o", "", "")
+ bare := fs.Bool("b", false, "")
+
+ got, err := parseFlags(fs, tt.args)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, tt.wantPos) {
+ t.Errorf("positional = %q, want %q", got, tt.wantPos)
+ }
+ if *out != tt.wantOut || *bare != tt.wantBare {
+ t.Errorf("o = %q, b = %v; want o = %q, b = %v", *out, *bare, tt.wantOut, tt.wantBare)
+ }
+ })
+ }
+}