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", `a`, "![a](https://cdn.example.com/a.png)"}, + {"relative image", `chart`, "![chart](https://go.dev/blog/simd/chart.svg)"}, + {"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) + } + } +}