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())
+ }
+}