phase5: cli subcommand with file/stdin input and output options
This commit is contained in:
+11
-13
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"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/converter"
|
||||||
"github.com/fserg/md-to-html/internal/server"
|
"github.com/fserg/md-to-html/internal/server"
|
||||||
"github.com/fserg/md-to-html/internal/version"
|
"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 {
|
func runCLI(args []string, stdout, stderr io.Writer) int {
|
||||||
fs := flag.NewFlagSet("cli", flag.ContinueOnError)
|
err := internalcli.Run(context.Background(), args, os.Stdin, stdout, stderr)
|
||||||
fs.SetOutput(io.Discard)
|
if err == nil {
|
||||||
if err := fs.Parse(args); err != nil {
|
return 0
|
||||||
|
}
|
||||||
|
if errors.Is(err, internalcli.ErrUsage) {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
if fs.NArg() != 1 {
|
return 1
|
||||||
fmt.Fprintln(stderr, "usage: md-to-html cli <file.md>")
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(stdout, "cli not implemented yet: %s\n", fs.Arg(0))
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runVersion(args []string, stdout, stderr io.Writer) int {
|
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) {
|
func printUsage(w io.Writer) {
|
||||||
fmt.Fprint(w, `Usage:
|
fmt.Fprint(w, `Usage:
|
||||||
md-to-html serve
|
md-to-html serve
|
||||||
md-to-html cli <file.md>
|
md-to-html cli [--stdin|-|<file.md>] [--output path] [--title str]
|
||||||
md-to-html version
|
md-to-html version
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
serve Start the HTTP server
|
serve Start the HTTP server
|
||||||
cli Convert a Markdown file stub
|
cli Convert Markdown from a file or stdin
|
||||||
version Print the build version
|
version Print the build version
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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|-|<file.md>] [--output path] [--title str]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--stdin Read markdown from stdin
|
||||||
|
-o, --output Output file path (default: stdout for stdin, <input>.html for file)
|
||||||
|
--title Fallback title if markdown has no headings
|
||||||
|
-h, --help Show this help
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -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("<!DOCTYPE html>")) {
|
||||||
|
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(), "<!DOCTYPE html>") {
|
||||||
|
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(), "<title>Custom</title>") {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user