diff --git a/cmd/md-to-html/main.go b/cmd/md-to-html/main.go index ace3c45..c5ae54b 100644 --- a/cmd/md-to-html/main.go +++ b/cmd/md-to-html/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "flag" "fmt" "io" @@ -9,6 +10,7 @@ import ( "os/signal" "syscall" + internalcli "github.com/fserg/md-to-html/internal/cli" "github.com/fserg/md-to-html/internal/converter" "github.com/fserg/md-to-html/internal/server" "github.com/fserg/md-to-html/internal/version" @@ -85,19 +87,15 @@ func runServe(args []string, stdout, stderr io.Writer) int { } func runCLI(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("cli", flag.ContinueOnError) - fs.SetOutput(io.Discard) - if err := fs.Parse(args); err != nil { + err := internalcli.Run(context.Background(), args, os.Stdin, stdout, stderr) + if err == nil { + return 0 + } + if errors.Is(err, internalcli.ErrUsage) { return 2 } - - if fs.NArg() != 1 { - fmt.Fprintln(stderr, "usage: md-to-html cli ") - return 2 - } - - fmt.Fprintf(stdout, "cli not implemented yet: %s\n", fs.Arg(0)) - return 0 + fmt.Fprintln(stderr, err) + return 1 } func runVersion(args []string, stdout, stderr io.Writer) int { @@ -119,12 +117,12 @@ func runVersion(args []string, stdout, stderr io.Writer) int { func printUsage(w io.Writer) { fmt.Fprint(w, `Usage: md-to-html serve - md-to-html cli + md-to-html cli [--stdin|-|] [--output path] [--title str] md-to-html version Commands: serve Start the HTTP server - cli Convert a Markdown file stub + cli Convert Markdown from a file or stdin version Print the build version `) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..56236a4 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,171 @@ +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/fserg/md-to-html/internal/converter" + webtemplate "github.com/fserg/md-to-html/web/template" +) + +var ErrUsage = errors.New("cli usage error") + +func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + if ctx == nil { + ctx = context.Background() + } + + if wantsHelp(args) { + printUsage(stdout) + return nil + } + + normalized, err := normalizeArgs(args) + if err != nil { + printUsage(stderr) + return fmt.Errorf("%w: %v", ErrUsage, err) + } + + fs := flag.NewFlagSet("cli", flag.ContinueOnError) + fs.SetOutput(stderr) + + var ( + output string + title string + useStdin bool + ) + + fs.StringVar(&output, "output", "", "output file path") + fs.StringVar(&output, "o", "", "output file path") + fs.StringVar(&title, "title", "", "fallback title if markdown has no headings") + fs.BoolVar(&useStdin, "stdin", false, "read markdown from stdin") + + if err := fs.Parse(normalized); err != nil { + printUsage(stderr) + return fmt.Errorf("%w: %v", ErrUsage, err) + } + if err := ctx.Err(); err != nil { + return err + } + + positional := fs.Args() + if len(positional) > 1 { + printUsage(stderr) + return fmt.Errorf("%w: expected a single input file or '-'", ErrUsage) + } + + conv, err := converter.New(webtemplate.FS) + if err != nil { + return fmt.Errorf("init converter: %w", err) + } + + var ( + markdown []byte + fallbackTitle = title + outputPath = output + writeToStdout bool + ) + + switch { + case useStdin || (len(positional) == 1 && positional[0] == "-"): + markdown, err = io.ReadAll(stdin) + if err != nil { + return fmt.Errorf("read stdin: %w", err) + } + if fallbackTitle == "" { + fallbackTitle = "Document" + } + writeToStdout = outputPath == "" + case len(positional) == 1: + inputPath := positional[0] + markdown, err = os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("read %s: %w", inputPath, err) + } + if fallbackTitle == "" { + fallbackTitle = strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath)) + } + if outputPath == "" { + outputPath = strings.TrimSuffix(inputPath, filepath.Ext(inputPath)) + ".html" + } + default: + printUsage(stderr) + return fmt.Errorf("%w: no input specified", ErrUsage) + } + + result, err := conv.Convert(markdown, fallbackTitle) + if err != nil { + return fmt.Errorf("convert markdown: %w", err) + } + + if writeToStdout { + _, err = stdout.Write(result.HTML) + if err != nil { + return fmt.Errorf("write stdout: %w", err) + } + return nil + } + + if err := os.WriteFile(outputPath, result.HTML, 0o644); err != nil { + return fmt.Errorf("write %s: %w", outputPath, err) + } + + return nil +} + +func normalizeArgs(args []string) ([]string, error) { + flags := make([]string, 0, len(args)) + positionals := make([]string, 0, 1) + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positionals = append(positionals, args[i+1:]...) + return append(flags, positionals...), nil + case arg == "-": + positionals = append(positionals, arg) + case !strings.HasPrefix(arg, "-"): + positionals = append(positionals, arg) + case strings.HasPrefix(arg, "--output="), strings.HasPrefix(arg, "--title="), strings.HasPrefix(arg, "-o="): + flags = append(flags, arg) + case arg == "--output" || arg == "-o" || arg == "--title": + if i+1 >= len(args) { + return nil, fmt.Errorf("flag needs an argument: %s", arg) + } + flags = append(flags, arg, args[i+1]) + i++ + default: + flags = append(flags, arg) + } + } + + return append(flags, positionals...), nil +} + +func wantsHelp(args []string) bool { + for _, arg := range args { + switch arg { + case "-h", "--help", "-help": + return true + } + } + return false +} + +func printUsage(w io.Writer) { + fmt.Fprint(w, `Usage: md-to-html cli [--stdin|-|] [--output path] [--title str] + +Options: + --stdin Read markdown from stdin + -o, --output Output file path (default: stdout for stdin, .html for file) + --title Fallback title if markdown has no headings + -h, --help Show this help +`) +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..4b01cf6 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,124 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCLIFileToFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + inputPath := filepath.Join(dir, "example.md") + if err := os.WriteFile(inputPath, []byte("# Hello\n\nBody"), 0o644); err != nil { + t.Fatalf("write input: %v", err) + } + + var stdout, stderr bytes.Buffer + if err := Run(context.Background(), []string{inputPath}, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatalf("run: %v", err) + } + + outputPath := filepath.Join(dir, "example.html") + got, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output: %v", err) + } + if !bytes.Contains(got, []byte("")) { + t.Fatalf("output missing doctype: %s", got) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestCLIStdin(t *testing.T) { + t.Parallel() + + stdin := strings.NewReader("# Привет\n\nТекст") + var stdout, stderr bytes.Buffer + + if err := Run(context.Background(), []string{"--stdin"}, stdin, &stdout, &stderr); err != nil { + t.Fatalf("run: %v", err) + } + + if !strings.Contains(stdout.String(), "") { + t.Fatalf("stdout missing doctype: %s", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestCLIOutputFlag(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + inputPath := filepath.Join(dir, "example.md") + outputPath := filepath.Join(dir, "custom.html") + if err := os.WriteFile(inputPath, []byte("Plain text"), 0o644); err != nil { + t.Fatalf("write input: %v", err) + } + + var stdout, stderr bytes.Buffer + if err := Run(context.Background(), []string{inputPath, "-o", outputPath}, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatalf("run: %v", err) + } + + if _, err := os.Stat(outputPath); err != nil { + t.Fatalf("stat output: %v", err) + } +} + +func TestCLITitle(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + if err := Run(context.Background(), []string{"--stdin", "--title", "Custom"}, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatalf("run: %v", err) + } + + if !strings.Contains(stdout.String(), "Custom") { + t.Fatalf("stdout missing title: %s", stdout.String()) + } +} + +func TestCLINoInput(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + err := Run(context.Background(), nil, strings.NewReader(""), &stdout, &stderr) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, ErrUsage) { + t.Fatalf("error = %v, want ErrUsage", err) + } + if !strings.Contains(stderr.String(), "Usage: md-to-html cli") { + t.Fatalf("stderr missing usage: %s", stderr.String()) + } +} + +func TestCLIMissingFile(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + err := Run(context.Background(), []string{"missing.md"}, strings.NewReader(""), &stdout, &stderr) + if err == nil { + t.Fatal("expected error, got nil") + } + if errors.Is(err, ErrUsage) { + t.Fatalf("error = %v, did not want ErrUsage", err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +}