diff --git a/go.mod b/go.mod
index 7241946..5dd2a2a 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.24.0
require (
github.com/JohannesKaufmann/html-to-markdown v1.6.0
+ github.com/PuerkitoBio/goquery v1.11.0
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v1.2.4
github.com/charmbracelet/glamour v0.10.0
@@ -18,7 +19,6 @@ require (
)
require (
- github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
diff --git a/internal/commands/commands.go b/internal/commands/commands.go
index a7467e7..b0c15e3 100644
--- a/internal/commands/commands.go
+++ b/internal/commands/commands.go
@@ -16,6 +16,7 @@ import (
"time"
md "github.com/JohannesKaufmann/html-to-markdown"
+ "github.com/PuerkitoBio/goquery"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
@@ -375,7 +376,7 @@ func glamouriseItem(item store.Item, theme config.Theme) (string, error) {
mdown += "\n\n"
mdown += item.Link
mdown += "\n\n"
- mdown += htmlToMd(item.Content)
+ mdown += htmlToMd(item.Content, item.Link)
r, _ := glamour.NewTermRenderer(
glamour.WithStyles(getStyleConfigWithOverrides(theme)),
@@ -389,8 +390,21 @@ func glamouriseItem(item store.Item, theme config.Theme) (string, error) {
return out, nil
}
-func htmlToMd(html string) string {
- converter := md.NewConverter("", true, nil)
+// htmlToMd converts an article's HTML to Markdown. Relative links and image
+// sources are resolved against base, the article's own address, so that
+// "/blog/x" or "img.png" point where the page they came from points.
+func htmlToMd(html string, base string) string {
+ opts := &md.Options{}
+ if b, err := url.Parse(base); err == nil && b.IsAbs() {
+ opts.GetAbsoluteURL = func(_ *goquery.Selection, raw string, _ string) string {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return raw
+ }
+ return b.ResolveReference(u).String()
+ }
+ }
+ converter := md.NewConverter("", true, opts)
mdown, err := converter.ConvertString(html)
if err != nil {
diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go
new file mode 100644
index 0000000..ff7681d
--- /dev/null
+++ b/internal/commands/commands_test.go
@@ -0,0 +1,43 @@
+package commands
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestHtmlToMdResolvesRelativeURLs(t *testing.T) {
+ const base = "https://go.dev/blog/simd/"
+
+ tests := []struct {
+ name string
+ html string
+ want string
+ }{
+ {"root-relative link", `docs`, "[docs](https://go.dev/doc/)"},
+ {"path-relative link", `next`, "[next](https://go.dev/blog/simd/next)"},
+ {"parent link", `blog`, "[blog](https://go.dev/blog/)"},
+ {"fragment", `bench`, "[bench](https://go.dev/blog/simd/#bench)"},
+ {"scheme-relative image", `
`, ""},
+ {"relative image", `
`, ""},
+ {"absolute link untouched", `x`, "[x](http://example.com/x)"},
+ {"mailto untouched", `mail`, "[mail](mailto:a@example.com)"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := htmlToMd(tt.html, base)
+ if !strings.Contains(got, tt.want) {
+ t.Errorf("htmlToMd(%q) = %q, want it to contain %q", tt.html, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestHtmlToMdWithoutBaseKeepsURLs(t *testing.T) {
+ for _, base := range []string{"", "not a url", "/relative/only"} {
+ got := htmlToMd(`docs`, base)
+ if !strings.Contains(got, "[docs](/doc/)") {
+ t.Errorf("base %q: got %q, want the link left as it was", base, got)
+ }
+ }
+}