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 `` 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 @@
+



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
+
+
+
+
+
+
+
+
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 

`,
+ 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 protocol-relative URL untouched",
+ in: `
`,
+ want: `
`,
+ },
+ {
+ name: "leaves empty src untouched",
+ in: `
`,
+ want: `
`,
+ },
+ {
+ name: "leaves whitespace-only src untouched",
+ in: `
`,
+ want: `
`,
+ },
+ {
+ name: "rewrites src regardless of attribute order",
+ in: `
`,
+ want: `
`,
+ },
+ {
+ 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: "text
more
text
more
`,
+ want: `
`,
+ },
+ }
+
+ 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}}