Skip to content
Open
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 17 additions & 3 deletions internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)),
Expand All @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions internal/commands/commands_test.go
Original file line number Diff line number Diff line change
@@ -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", `<a href="/doc/">docs</a>`, "[docs](https://go.dev/doc/)"},
{"path-relative link", `<a href="next">next</a>`, "[next](https://go.dev/blog/simd/next)"},
{"parent link", `<a href="../">blog</a>`, "[blog](https://go.dev/blog/)"},
{"fragment", `<a href="#bench">bench</a>`, "[bench](https://go.dev/blog/simd/#bench)"},
{"scheme-relative image", `<img src="//cdn.example.com/a.png" alt="a">`, "![a](https://cdn.example.com/a.png)"},
{"relative image", `<img src="chart.svg" alt="chart">`, "![chart](https://go.dev/blog/simd/chart.svg)"},
{"absolute link untouched", `<a href="http://example.com/x">x</a>`, "[x](http://example.com/x)"},
{"mailto untouched", `<a href="mailto:a@example.com">mail</a>`, "[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(`<a href="/doc/">docs</a>`, base)
if !strings.Contains(got, "[docs](/doc/)") {
t.Errorf("base %q: got %q, want the link left as it was", base, got)
}
}
}