Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,26 @@ A Markdown previewer that shows markdown files in your browser.

## Description

`mdp` converts a markdown file to HTML and opens it in your browser. It supports GitHub Flavored Markdown and custom themes.
`mdp` converts markdown files to HTML and opens them in your browser. It supports GitHub Flavored Markdown and custom themes.

```console
$ mdp README.md
Generated: /Users/you/.mdp/README.html
```

You can pass more than one file. Each file is converted and opened in its own tab, and `--watch` regenerates whichever file changes.

```console
$ mdp --watch README.md CHANGELOG.md
Generated: /Users/you/.mdp/README.html
Generated: /Users/you/.mdp/CHANGELOG.html
Watching for changes... (Ctrl+C to stop)
```

## Synopsis

```
mdp [options] <markdown-file>
mdp [options] <markdown-file>...
```

## Options
Expand Down
103 changes: 83 additions & 20 deletions e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,41 @@ FAILED=0
tmpdir=$(mktemp -d)
trap "rm -rf $tmpdir" EXIT

# generated_path prints where mdp writes the HTML for an absolute markdown path
generated_path() {
local output_dir="$1" abs_md_path="$2"
local path_without_ext="${abs_md_path%.md}"
echo "$output_dir/${path_without_ext#/}/index.html"
}

# check_output compares a generated HTML file with its expected HTML.
# Prints PASS/FAIL with the given label and returns non-zero on failure.
check_output() {
local label="$1" expected_file="$2" generated_file="$3" work_dir="$4"

if [[ ! -f "$generated_file" ]]; then
echo "FAIL: $label (output not found)"
return 1
fi

# Expected html uses placeholders for the directories in file:// image URLs
local resolved_expected_file="$work_dir/expected.html"
sed -e "s|__TESTDATA_DIR__|$TESTDATA_DIR|g" -e "s|__E2E_DIR__|$SCRIPT_DIR|g" "$expected_file" > "$resolved_expected_file"

if diff -q "$resolved_expected_file" "$generated_file" > /dev/null 2>&1; then
echo "PASS: $label"
return 0
fi

echo "FAIL: $label (content mismatch)"
echo "--- Expected ---"
cat "$resolved_expected_file"
echo "--- Actual ---"
cat "$generated_file"
echo "----------------"
return 1
}

for md_file in "$TESTDATA_DIR"/*.md; do
name=$(basename "$md_file" .md)
expected_file="$TESTDATA_DIR/$name.html"
Expand Down Expand Up @@ -65,31 +100,59 @@ EOF
continue
fi

path_without_ext="${abs_md_path%.md}"
relative_path="${path_without_ext#/}"
generated_file="$output_dir/$relative_path/index.html"

if [[ ! -f "$generated_file" ]]; then
echo "FAIL: $name (output not found)"
generated_file=$(generated_path "$output_dir" "$abs_md_path")
if ! check_output "$name" "$expected_file" "$generated_file" "$test_dir"; then
FAILED=1
continue
fi
done

# Expected html uses placeholders for the directories in file:// image URLs
resolved_expected_file="$test_dir/expected.html"
sed -e "s|__TESTDATA_DIR__|$TESTDATA_DIR|g" -e "s|__E2E_DIR__|$SCRIPT_DIR|g" "$expected_file" > "$resolved_expected_file"
# Multiple files: every file is converted and the browser is opened once per file
multi_name="multiple-files"
multi_dir="$tmpdir/$multi_name"
mkdir -p "$multi_dir"
multi_config="$multi_dir/config.yaml"
multi_output="$multi_dir/output"

# The browser command records every path it is asked to open
multi_opened_log="$multi_dir/opened.log"
multi_browser="$multi_dir/browser.sh"
cat > "$multi_browser" <<EOF
#!/bin/bash
echo "\$1" >> "$multi_opened_log"
EOF
chmod +x "$multi_browser"

if diff -q "$resolved_expected_file" "$generated_file" > /dev/null 2>&1; then
echo "PASS: $name"
else
echo "FAIL: $name (content mismatch)"
echo "--- Expected ---"
cat "$resolved_expected_file"
echo "--- Actual ---"
cat "$generated_file"
echo "----------------"
cat > "$multi_config" <<EOF
output_dir: $multi_output
browser_command: $multi_browser
EOF

multi_files=("$TESTDATA_DIR/simple.md" "$TESTDATA_DIR/gfm.md")

if ! "$MDP_BIN" --config "$multi_config" "${multi_files[@]}" > /dev/null 2>&1; then
echo "FAIL: $multi_name (command failed)"
FAILED=1
else
touch "$multi_opened_log"
opened_total=$(wc -l < "$multi_opened_log" | tr -d ' ')
if [[ "$opened_total" != "${#multi_files[@]}" ]]; then
echo "FAIL: $multi_name (browser opened $opened_total times, want ${#multi_files[@]})"
FAILED=1
fi
done
for md_file in "${multi_files[@]}"; do
name=$(basename "$md_file" .md)
generated_file=$(generated_path "$multi_output" "$md_file")
if ! check_output "$multi_name ($name)" "$TESTDATA_DIR/$name.html" "$generated_file" "$multi_dir"; then
FAILED=1
continue
fi

opened=$(grep -Fxc "$generated_file" "$multi_opened_log" || true)
if [[ "$opened" != "1" ]]; then
echo "FAIL: $multi_name ($name) (browser opened $opened times, want 1)"
FAILED=1
fi
done
fi

exit $FAILED
4 changes: 2 additions & 2 deletions internal/cli/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ var errHelp = errors.New("help requested")

type parsedArgs struct {
configPath string
filePath string
filePaths []string
showList bool
showVersion bool
watchMode bool
Expand Down Expand Up @@ -60,7 +60,7 @@ func parseArgs(args []string) (*parsedArgs, error) {

return &parsedArgs{
configPath: *configPath,
filePath: fs.Arg(0),
filePaths: fs.Args(),
watchMode: *watchMode,
}, nil
}
29 changes: 23 additions & 6 deletions internal/cli/args_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"slices"
"strings"
"testing"
)
Expand All @@ -17,15 +18,31 @@ func TestParseArgs(t *testing.T) {
args: []string{"test.md"},
wantArgs: &parsedArgs{
configPath: "",
filePath: "test.md",
filePaths: []string{"test.md"},
},
},
{
name: "config flag with value",
args: []string{"--config", "/path/to/config.yaml", "test.md"},
wantArgs: &parsedArgs{
configPath: "/path/to/config.yaml",
filePath: "test.md",
filePaths: []string{"test.md"},
},
},
{
name: "multiple files",
args: []string{"a.md", "b.md", "c.md"},
wantArgs: &parsedArgs{
filePaths: []string{"a.md", "b.md", "c.md"},
},
},
{
name: "multiple files with flags",
args: []string{"--watch", "--config", "config.yaml", "a.md", "b.md"},
wantArgs: &parsedArgs{
filePaths: []string{"a.md", "b.md"},
configPath: "config.yaml",
watchMode: true,
},
},
{
Expand Down Expand Up @@ -82,15 +99,15 @@ func TestParseArgs(t *testing.T) {
name: "watch flag",
args: []string{"--watch", "test.md"},
wantArgs: &parsedArgs{
filePath: "test.md",
filePaths: []string{"test.md"},
watchMode: true,
},
},
{
name: "watch flag with config",
args: []string{"--watch", "--config", "config.yaml", "test.md"},
wantArgs: &parsedArgs{
filePath: "test.md",
filePaths: []string{"test.md"},
configPath: "config.yaml",
watchMode: true,
},
Expand All @@ -117,8 +134,8 @@ func TestParseArgs(t *testing.T) {
if got.configPath != tt.wantArgs.configPath {
t.Errorf("parseArgs() configPath = %v, want %v", got.configPath, tt.wantArgs.configPath)
}
if got.filePath != tt.wantArgs.filePath {
t.Errorf("parseArgs() filePath = %v, want %v", got.filePath, tt.wantArgs.filePath)
if !slices.Equal(got.filePaths, tt.wantArgs.filePaths) {
t.Errorf("parseArgs() filePaths = %v, want %v", got.filePaths, tt.wantArgs.filePaths)
}
if got.showList != tt.wantArgs.showList {
t.Errorf("parseArgs() showList = %v, want %v", got.showList, tt.wantArgs.showList)
Expand Down
81 changes: 64 additions & 17 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,8 @@ func (c *cli) errorf(format string, args ...any) {
_, _ = fmt.Fprintf(c.errWriter, format, args...)
}

func (c *cli) run(filePath string, watchMode bool, cfg *config.Config) int {
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.errorf("error: file not found: %s\n", filePath)
return 1
}

absPath, err := filepath.Abs(filePath)
func (c *cli) run(filePaths []string, watchMode bool, cfg *config.Config) int {
absPaths, err := resolveFilePaths(filePaths)
if err != nil {
c.errorf("error: %v\n", err)
return 1
Expand All @@ -43,34 +38,86 @@ func (c *cli) run(filePath string, watchMode bool, cfg *config.Config) int {
}

writer := output.NewWriter(cfg.OutputDir)
outputPath, err := c.convert(absPath, r, writer)
if err != nil {
if err := checkOutputPaths(absPaths, writer); err != nil {
c.errorf("error: %v\n", err)
return 1
}

_, _ = fmt.Fprintf(c.outWriter, "Generated: %s\n", outputPath)
// Convert every file before opening any of them so that a failure
// leaves no half-opened browser tabs
outputPaths := make([]string, 0, len(absPaths))
for _, absPath := range absPaths {
outputPath, err := c.convert(absPath, r, writer)
if err != nil {
c.errorf("error: %v\n", err)
return 1
}
_, _ = fmt.Fprintf(c.outWriter, "Generated: %s\n", outputPath)
outputPaths = append(outputPaths, outputPath)
}

opener := browser.NewOpener(cfg.BrowserCommand)
if err := opener.Open(outputPath); err != nil {
c.errorf("error: failed to open browser: %v\n", err)
return 1
for _, outputPath := range outputPaths {
if err := opener.Open(outputPath); err != nil {
c.errorf("error: failed to open browser: %v\n", err)
return 1
}
}

// If watch mode is enabled, start the watch loop
if watchMode {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
return c.runWatchLoop(absPath, r, writer, sigChan)
return c.runWatchLoop(absPaths, r, writer, sigChan)
}

return 0
}

// resolveFilePaths checks that every file exists and returns their absolute
// paths in the given order, dropping duplicates.
func resolveFilePaths(filePaths []string) ([]string, error) {
absPaths := make([]string, 0, len(filePaths))
seen := make(map[string]bool, len(filePaths))
for _, filePath := range filePaths {
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", filePath)
}
return nil, err
}

absPath, err := filepath.Abs(filePath)
if err != nil {
return nil, err
}
if seen[absPath] {
continue
}
seen[absPath] = true
absPaths = append(absPaths, absPath)
}
return absPaths, nil
}

// checkOutputPaths rejects inputs that would be written to the same output
// path, such as notes.md and notes.markdown.
func checkOutputPaths(absPaths []string, w *output.Writer) error {
sources := make(map[string]string, len(absPaths))
for _, absPath := range absPaths {
outputPath := w.BuildOutputPath(absPath)
if other, ok := sources[outputPath]; ok {
return fmt.Errorf("%s and %s would be written to the same output path: %s", other, absPath, outputPath)
}
sources[outputPath] = absPath
}
return nil
}

// runWatchLoop watches for file changes and regenerates HTML.
func (c *cli) runWatchLoop(filePath string, r *renderer.Renderer, w *output.Writer, sigChan <-chan os.Signal) int {
func (c *cli) runWatchLoop(filePaths []string, r *renderer.Renderer, w *output.Writer, sigChan <-chan os.Signal) int {
// Create watcher
fileWatcher, err := watcher.New(filePath)
fileWatcher, err := watcher.New(filePaths...)
if err != nil {
c.errorf("error: failed to start watcher: %v\n", err)
return 1
Expand All @@ -82,7 +129,7 @@ func (c *cli) runWatchLoop(filePath string, r *renderer.Renderer, w *output.Writ

for {
select {
case <-fileWatcher.Events():
case filePath := <-fileWatcher.Events():
outputPath, err := c.convert(filePath, r, w)
if err != nil {
c.errorf("error: %v\n", err)
Expand Down
Loading