diff --git a/README.md b/README.md index 8868467..287a3be 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ make sudo install mdp /usr/local/bin/ ``` +## Images + +`mdp` rewrites relative image paths into `file://` URLs based on the directory of the markdown file, so the browser can show images next to the markdown without copying them. This works for both `![alt](path)` and raw `` tags (with `unsafe: true`). URLs that already have a scheme, such as `https://` or `data:`, are not changed. + ## Configuration Configuration file is loaded from the following locations in order of priority: diff --git a/e2e/run.sh b/e2e/run.sh index 1b5a15a..bf1c935 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -75,12 +75,16 @@ EOF continue fi - if diff -q "$expected_file" "$generated_file" > /dev/null 2>&1; then + # 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" + + 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 "$expected_file" + cat "$resolved_expected_file" echo "--- Actual ---" cat "$generated_file" echo "----------------" diff --git a/e2e/testdata/image.config.yaml b/e2e/testdata/image.config.yaml new file mode 100644 index 0000000..80dcd18 --- /dev/null +++ b/e2e/testdata/image.config.yaml @@ -0,0 +1 @@ +unsafe: true diff --git a/e2e/testdata/image.html b/e2e/testdata/image.html new file mode 100644 index 0000000..66e26cc --- /dev/null +++ b/e2e/testdata/image.html @@ -0,0 +1,5 @@ +

Image Test

+

relative

+

parent

+

absolute

+ diff --git a/e2e/testdata/image.md b/e2e/testdata/image.md new file mode 100644 index 0000000..aa83831 --- /dev/null +++ b/e2e/testdata/image.md @@ -0,0 +1,9 @@ +# Image Test + +![relative](images/a.png) + +![parent](../shared/b.png) + +![absolute](https://example.com/c.png) + + diff --git a/go.mod b/go.mod index f2021e4..f703182 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,10 @@ require ( github.com/fsnotify/fsnotify v1.10.1 github.com/yuin/goldmark v1.8.5 github.com/yuin/goldmark-meta v1.1.0 + golang.org/x/net v0.58.0 ) require ( - golang.org/x/sys v0.13.0 // indirect + golang.org/x/sys v0.47.0 // indirect gopkg.in/yaml.v2 v2.3.0 // indirect ) diff --git a/go.sum b/go.sum index a48bd13..a37d49b 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,10 @@ github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a9f67f9..de411af 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -107,7 +107,7 @@ func (c *cli) convert(filePath string, r *renderer.Renderer, w *output.Writer) ( } // Render markdown to HTML - html, err := r.Render(markdown) + html, err := r.Render(markdown, filepath.Dir(filePath)) if err != nil { return "", fmt.Errorf("failed to render: %w", err) } diff --git a/internal/renderer/image.go b/internal/renderer/image.go new file mode 100644 index 0000000..342634c --- /dev/null +++ b/internal/renderer/image.go @@ -0,0 +1,90 @@ +package renderer + +import ( + "bytes" + "net/url" + "strings" + + "golang.org/x/net/html" +) + +// resolveImageSources rewrites the src attribute of every in the +// rendered HTML. A src without a scheme becomes a file:// URL under baseDir. +// Every other token is copied through unchanged. +func resolveImageSources(rendered []byte, baseDir string) []byte { + var out bytes.Buffer + z := html.NewTokenizer(bytes.NewReader(rendered)) + + for { + tt := z.Next() + if tt == html.ErrorToken { + // io.EOF is the only error a byte reader can produce + out.Write(z.Raw()) + return out.Bytes() + } + + if tt != html.StartTagToken && tt != html.SelfClosingTagToken { + out.Write(z.Raw()) + continue + } + + raw := z.Raw() + token := z.Token() + if token.Data != "img" { + out.Write(raw) + continue + } + + changed := false + for i, attr := range token.Attr { + if attr.Key != "src" { + continue + } + resolved, ok := resolveImageSource(attr.Val, baseDir) + if ok { + token.Attr[i].Val = resolved + changed = true + } + } + + if !changed { + out.Write(raw) + continue + } + writeTag(&out, token, tt == html.SelfClosingTagToken) + } +} + +// resolveImageSource returns the src resolved against baseDir as a file:// URL. +// It reports false when src should stay as is: empty, unparsable, or +// already having a scheme or host. +func resolveImageSource(src, baseDir string) (string, bool) { + // Browsers strip surrounding whitespace before resolving the URL + src = strings.TrimSpace(src) + if src == "" { + return src, false + } + ref, err := url.Parse(src) + if err != nil || ref.Scheme != "" || ref.Host != "" { + return src, false + } + base := &url.URL{Scheme: "file", Path: strings.TrimSuffix(baseDir, "/") + "/"} + return base.ResolveReference(ref).String(), true +} + +func writeTag(out *bytes.Buffer, token html.Token, selfClosing bool) { + out.WriteString("<") + out.WriteString(token.Data) + for _, attr := range token.Attr { + out.WriteString(" ") + out.WriteString(attr.Key) + out.WriteString(`="`) + out.WriteString(html.EscapeString(attr.Val)) + out.WriteString(`"`) + } + if selfClosing { + out.WriteString(" />") + return + } + out.WriteString(">") +} diff --git a/internal/renderer/image_test.go b/internal/renderer/image_test.go new file mode 100644 index 0000000..94d6f1b --- /dev/null +++ b/internal/renderer/image_test.go @@ -0,0 +1,143 @@ +package renderer + +import ( + "testing" +) + +func TestResolveImageSources(t *testing.T) { + const baseDir = "/Users/you/docs" + + tests := []struct { + name string + in string + want string + }{ + { + name: "resolves relative path", + in: `

a

`, + want: `

a

`, + }, + { + name: "resolves dot-slash path", + in: ``, + want: ``, + }, + { + name: "resolves parent directory reference", + in: ``, + want: ``, + }, + { + name: "resolves root-relative path", + in: ``, + want: ``, + }, + { + name: "keeps percent-encoded characters", + in: ``, + want: ``, + }, + { + name: "keeps query and fragment", + in: ``, + want: ``, + }, + { + name: "leaves http URL untouched", + in: ``, + want: ``, + }, + { + name: "leaves https URL untouched", + in: ``, + want: ``, + }, + { + name: "leaves data URL untouched", + in: ``, + want: ``, + }, + { + name: "leaves file URL untouched", + in: ``, + want: ``, + }, + { + name: "leaves protocol-relative URL untouched", + in: ``, + want: ``, + }, + { + name: "leaves empty src untouched", + in: ``, + want: ``, + }, + { + name: "leaves img without src untouched", + in: ``, + want: ``, + }, + { + name: "trims surrounding whitespace like browsers do", + in: ``, + want: ``, + }, + { + name: "leaves whitespace-only src untouched", + in: ``, + want: ``, + }, + { + name: "leaves unparsable src untouched", + in: ``, + want: ``, + }, + { + name: "rewrites raw html img with extra attributes", + in: ``, + want: ``, + }, + { + name: "rewrites src regardless of attribute order", + in: `x`, + want: `x`, + }, + { + name: "does not touch other elements", + in: `link`, + want: `link`, + }, + { + name: "does not touch escaped img in code block", + in: `
<img src="a.png">
`, + want: `
<img src="a.png">
`, + }, + { + name: "preserves surrounding content", + in: "

Title

\n

text \"\" more

\n", + want: "

Title

\n

text \"\" more

\n", + }, + { + name: "escapes special characters in attributes", + in: `<x>`, + want: `<x>`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveImageSources([]byte(tt.in), baseDir) + if string(got) != tt.want { + t.Errorf("resolveImageSources() =\n%s\nwant:\n%s", got, tt.want) + } + }) + } + + t.Run("encodes special characters in base directory", func(t *testing.T) { + got := resolveImageSources([]byte(``), "/Users/you/my docs") + want := `` + if string(got) != want { + t.Errorf("resolveImageSources() = %s, want %s", got, want) + } + }) +} diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index ad1e566..492ef4c 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -69,7 +69,9 @@ func NewRenderer(configDir string, themeName string, opts Options) (*Renderer, e } // Render converts Markdown to HTML, applying the theme template if configured. -func (r *Renderer) Render(markdown []byte) ([]byte, error) { +// Relative image paths in the content resolve against baseDir, +// the directory of the source markdown file. +func (r *Renderer) Render(markdown []byte, baseDir string) ([]byte, error) { context := parser.NewContext() // パースは1回だけ @@ -87,7 +89,8 @@ func (r *Renderer) Render(markdown []byte) ([]byte, error) { return nil, err } - html := buf.Bytes() + // Rewrite before applying the theme so the template's own img tags stay as they are + html := resolveImageSources(buf.Bytes(), baseDir) if r.tmpl == nil { return html, nil diff --git a/internal/renderer/renderer_test.go b/internal/renderer/renderer_test.go index 9ef310a..72cd9d3 100644 --- a/internal/renderer/renderer_test.go +++ b/internal/renderer/renderer_test.go @@ -13,6 +13,27 @@ const testTitleTemplate = ` {{.Content}} ` +// newThemedRenderer creates a Renderer whose theme template is the given content. +func newThemedRenderer(t *testing.T, themeContent string) *Renderer { + t.Helper() + tmpDir := t.TempDir() + themesDir := filepath.Join(tmpDir, "themes") + if err := os.MkdirAll(themesDir, 0755); err != nil { //nolint:gosec // G301: test directory + t.Fatal(err) + } + themeFile := filepath.Join(themesDir, "test-theme.html") + //nolint:gosec // G306: test file + if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil { + t.Fatal(err) + } + + r, err := NewRenderer(tmpDir, "test-theme", Options{}) + if err != nil { + t.Fatalf("NewRenderer() returned error: %v", err) + } + return r +} + func TestNewRenderer(t *testing.T) { t.Run("returns renderer without template when themeName is empty", func(t *testing.T) { r, err := NewRenderer("", "", Options{}) @@ -80,7 +101,7 @@ func TestRender(t *testing.T) { t.Fatalf("NewRenderer() returned error: %v", err) } - html, err := r.Render([]byte("# Hello")) + html, err := r.Render([]byte("# Hello"), "") if err != nil { t.Fatalf("Render() returned error: %v", err) } @@ -108,7 +129,7 @@ func TestRender(t *testing.T) { t.Fatalf("NewRenderer() returned error: %v", err) } - html, err := r.Render([]byte("# Hello")) + html, err := r.Render([]byte("# Hello"), "") if err != nil { t.Fatalf("Render() returned error: %v", err) } @@ -139,7 +160,7 @@ func TestRender(t *testing.T) { t.Fatalf("NewRenderer() returned error: %v", err) } - html, err := r.Render([]byte("**bold**")) + html, err := r.Render([]byte("**bold**"), "") if err != nil { t.Fatalf("Render() returned error: %v", err) } @@ -180,7 +201,7 @@ title: Front-matter Title Content here. `) - html, err := r.Render(markdown) + html, err := r.Render(markdown, "") if err != nil { t.Fatalf("Render() returned error: %v", err) } @@ -217,7 +238,7 @@ Some content. More content. `) - html, err := r.Render(markdown) + html, err := r.Render(markdown, "") if err != nil { t.Fatalf("Render() returned error: %v", err) } @@ -236,7 +257,7 @@ More content. markdown := []byte("This has a footnote[^1].\n\n[^1]: Footnote content.\n") - html, err := r.Render(markdown) + html, err := r.Render(markdown, "") if err != nil { t.Fatalf("Render() returned error: %v", err) } @@ -272,7 +293,7 @@ More content. More text here. `) - html, err := r.Render(markdown) + html, err := r.Render(markdown, "") if err != nil { t.Fatalf("Render() returned error: %v", err) } @@ -289,7 +310,7 @@ More text here. t.Fatalf("NewRenderer() returned error: %v", err) } - html, err := r.Render([]byte("line1\nline2\n")) + html, err := r.Render([]byte("line1\nline2\n"), "") if err != nil { t.Fatalf("Render() returned error: %v", err) } @@ -306,7 +327,7 @@ More text here. t.Fatalf("NewRenderer() returned error: %v", err) } - html, err := r.Render([]byte("line1\nline2\n")) + html, err := r.Render([]byte("line1\nline2\n"), "") if err != nil { t.Fatalf("Render() returned error: %v", err) } @@ -323,7 +344,7 @@ More text here. t.Fatalf("NewRenderer() returned error: %v", err) } - html, err := r.Render([]byte("
text
\n")) + html, err := r.Render([]byte("
text
\n"), "") if err != nil { t.Fatalf("Render() returned error: %v", err) } @@ -340,7 +361,7 @@ More text here. t.Fatalf("NewRenderer() returned error: %v", err) } - html, err := r.Render([]byte("
text
\n")) + html, err := r.Render([]byte("
text
\n"), "") if err != nil { t.Fatalf("Render() returned error: %v", err) } @@ -353,4 +374,57 @@ More text here. t.Errorf("Render() without Unsafe should contain raw HTML omitted comment, got %q", result) } }) + + t.Run("resolves relative image path against baseDir", func(t *testing.T) { + r, err := NewRenderer("", "", Options{}) + if err != nil { + t.Fatalf("NewRenderer() returned error: %v", err) + } + + html, err := r.Render([]byte("![alt](images/a.png)\n"), "/Users/you/docs") + if err != nil { + t.Fatalf("Render() returned error: %v", err) + } + + result := string(html) + expected := `alt` + if !strings.Contains(result, expected) { + t.Errorf("Render() = %q, want to contain %q", result, expected) + } + }) + + t.Run("resolves raw HTML img when unsafe is enabled", func(t *testing.T) { + r, err := NewRenderer("", "", Options{Unsafe: true}) + if err != nil { + t.Fatalf("NewRenderer() returned error: %v", err) + } + + html, err := r.Render([]byte("\n"), "/Users/you/docs") + if err != nil { + t.Fatalf("Render() returned error: %v", err) + } + + result := string(html) + expected := `` + if !strings.Contains(result, expected) { + t.Errorf("Render() = %q, want to contain %q", result, expected) + } + }) + + t.Run("does not resolve image path in theme template", func(t *testing.T) { + r := newThemedRenderer(t, `{{.Content}}`) + + html, err := r.Render([]byte("![alt](a.png)\n"), "/Users/you/docs") + if err != nil { + t.Fatalf("Render() returned error: %v", err) + } + + result := string(html) + if !strings.Contains(result, ``) { + t.Errorf("Render() should leave theme img untouched, got %q", result) + } + if !strings.Contains(result, `alt`) { + t.Errorf("Render() should resolve content img, got %q", result) + } + }) }