2020-06-18 23:54:48 +03:00
|
|
|
package display
|
|
|
|
|
|
|
|
import (
|
2020-07-01 20:39:13 +03:00
|
|
|
"errors"
|
2020-06-18 23:54:48 +03:00
|
|
|
"net/url"
|
|
|
|
"os/exec"
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
"github.com/makeworld-the-better-one/amfora/cache"
|
|
|
|
"github.com/makeworld-the-better-one/amfora/client"
|
|
|
|
"github.com/makeworld-the-better-one/amfora/renderer"
|
|
|
|
"github.com/makeworld-the-better-one/amfora/structs"
|
|
|
|
"github.com/makeworld-the-better-one/amfora/webbrowser"
|
|
|
|
"github.com/makeworld-the-better-one/go-gemini"
|
|
|
|
"github.com/spf13/viper"
|
|
|
|
"gitlab.com/tslocum/cview"
|
|
|
|
)
|
|
|
|
|
|
|
|
// This file contains the functions that aren't part of the public API.
|
|
|
|
|
2020-07-08 04:13:45 +03:00
|
|
|
// isValidTab indicates whether the passed tab is still being used, even if it's not currently displayed.
|
|
|
|
func isValidTab(t *tab) bool {
|
|
|
|
tempTabs := tabs
|
|
|
|
for i := range tempTabs {
|
|
|
|
if tempTabs[i] == t {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false
|
2020-06-29 22:01:41 +03:00
|
|
|
}
|
|
|
|
|
2020-06-21 23:53:12 +03:00
|
|
|
func leftMargin() int {
|
|
|
|
return int(float64(termW) * viper.GetFloat64("a-general.left_margin"))
|
|
|
|
}
|
|
|
|
|
|
|
|
func textWidth() int {
|
|
|
|
if termW <= 0 {
|
|
|
|
// This prevent a flash of 1-column text on startup, when the terminal
|
|
|
|
// width hasn't been initialized.
|
|
|
|
return viper.GetInt("a-general.max_width")
|
|
|
|
}
|
|
|
|
|
|
|
|
rightMargin := leftMargin()
|
|
|
|
if leftMargin() > 10 {
|
|
|
|
// 10 is the max right margin
|
|
|
|
rightMargin = 10
|
|
|
|
}
|
|
|
|
|
|
|
|
max := termW - leftMargin() - rightMargin
|
|
|
|
if max < viper.GetInt("a-general.max_width") {
|
|
|
|
return max
|
|
|
|
}
|
|
|
|
return viper.GetInt("a-general.max_width")
|
|
|
|
}
|
|
|
|
|
2020-07-08 04:13:45 +03:00
|
|
|
// queryEscape is the same as url.PathEscape, but it also replaces the +.
|
|
|
|
// This is because Gemini requires percent-escaping for queries.
|
|
|
|
func queryEscape(path string) string {
|
2020-06-21 00:17:34 +03:00
|
|
|
return strings.ReplaceAll(url.PathEscape(path), "+", "%2B")
|
|
|
|
}
|
|
|
|
|
2020-07-01 20:39:13 +03:00
|
|
|
// resolveRelLink returns an absolute link for the given absolute link and relative one.
|
|
|
|
// It also returns an error if it could not resolve the links, which should be displayed
|
|
|
|
// to the user.
|
2020-07-08 04:13:45 +03:00
|
|
|
func resolveRelLink(t *tab, prev, next string) (string, error) {
|
|
|
|
if !t.hasContent() {
|
2020-07-01 20:39:13 +03:00
|
|
|
return next, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
prevParsed, _ := url.Parse(prev)
|
|
|
|
nextParsed, err := url.Parse(next)
|
|
|
|
if err != nil {
|
|
|
|
return "", errors.New("link URL could not be parsed")
|
|
|
|
}
|
|
|
|
return prevParsed.ResolveReference(nextParsed).String(), nil
|
|
|
|
}
|
|
|
|
|
2020-06-18 23:54:48 +03:00
|
|
|
// followLink should be used when the user "clicks" a link on a page.
|
|
|
|
// Not when a URL is opened on a new tab for the first time.
|
2020-07-08 04:13:45 +03:00
|
|
|
// It will handle setting the bottomBar.
|
|
|
|
func followLink(t *tab, prev, next string) {
|
2020-06-24 03:07:25 +03:00
|
|
|
|
|
|
|
// Copied from URL()
|
|
|
|
if next == "about:bookmarks" {
|
2020-07-08 04:13:45 +03:00
|
|
|
Bookmarks(t)
|
|
|
|
t.addToHistory("about:bookmarks")
|
2020-06-24 03:07:25 +03:00
|
|
|
return
|
|
|
|
}
|
|
|
|
if strings.HasPrefix(next, "about:") {
|
|
|
|
Error("Error", "Not a valid 'about:' URL for linking")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-07-08 04:13:45 +03:00
|
|
|
if t.hasContent() {
|
|
|
|
t.saveScroll() // Likely called later on, it's here just in case
|
|
|
|
nextURL, err := resolveRelLink(t, prev, next)
|
2020-06-24 03:07:25 +03:00
|
|
|
if err != nil {
|
2020-07-01 20:39:13 +03:00
|
|
|
Error("URL Error", err.Error())
|
2020-06-24 03:07:25 +03:00
|
|
|
return
|
|
|
|
}
|
2020-07-08 04:13:45 +03:00
|
|
|
go goURL(t, nextURL)
|
2020-06-24 03:07:25 +03:00
|
|
|
return
|
|
|
|
}
|
|
|
|
// No content on current tab, so the "prev" URL is not valid.
|
|
|
|
// An example is the about:newtab page
|
|
|
|
_, err := url.Parse(next)
|
2020-06-18 23:54:48 +03:00
|
|
|
if err != nil {
|
2020-06-19 21:05:05 +03:00
|
|
|
Error("URL Error", "Link URL could not be parsed")
|
2020-06-18 23:54:48 +03:00
|
|
|
return
|
|
|
|
}
|
2020-07-08 04:13:45 +03:00
|
|
|
go goURL(t, next)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
|
|
|
|
2020-07-03 06:55:24 +03:00
|
|
|
// reformatPage will take the raw page content and reformat it according to the current terminal dimensions.
|
|
|
|
// It should be called when the terminal size changes.
|
|
|
|
// It will not waste resources if the passed page is already fitted to the current terminal width, and can be
|
|
|
|
// called safely even when the page might be already formatted properly.
|
|
|
|
func reformatPage(p *structs.Page) {
|
|
|
|
if p.Width == termW {
|
|
|
|
// No changes to make
|
|
|
|
return
|
2020-06-22 00:15:21 +03:00
|
|
|
}
|
2020-07-04 00:28:56 +03:00
|
|
|
|
|
|
|
var rendered string
|
|
|
|
if p.Mediatype == structs.TextGemini {
|
|
|
|
// Links are not recorded because they won't change
|
|
|
|
rendered, _ = renderer.RenderGemini(p.Raw, textWidth(), leftMargin())
|
|
|
|
} else if p.Mediatype == structs.TextPlain {
|
|
|
|
rendered = renderer.RenderPlainText(p.Raw, leftMargin())
|
2020-07-11 00:45:14 +03:00
|
|
|
} else if p.Mediatype == structs.TextAnsi {
|
|
|
|
rendered = renderer.RenderANSI(p.Raw, leftMargin())
|
2020-07-04 00:28:56 +03:00
|
|
|
} else {
|
|
|
|
// Rendering this type is not implemented
|
|
|
|
return
|
|
|
|
}
|
2020-07-03 06:55:24 +03:00
|
|
|
p.Content = rendered
|
|
|
|
p.Width = termW
|
|
|
|
}
|
|
|
|
|
2020-07-04 18:32:11 +03:00
|
|
|
// reformatPageAndSetView is for reformatting a page that is already being displayed.
|
2020-07-03 06:55:24 +03:00
|
|
|
// setPage should be used when a page is being loaded for the first time.
|
2020-07-08 04:13:45 +03:00
|
|
|
func reformatPageAndSetView(t *tab, p *structs.Page) {
|
|
|
|
t.saveScroll()
|
2020-07-03 19:22:44 +03:00
|
|
|
reformatPage(p)
|
2020-07-08 04:13:45 +03:00
|
|
|
t.view.SetText(p.Content)
|
|
|
|
t.applyScroll() // Go back to where you were, roughly
|
2020-06-22 00:15:21 +03:00
|
|
|
}
|
|
|
|
|
2020-07-08 04:13:45 +03:00
|
|
|
// setPage displays a Page on the passed tab number.
|
|
|
|
// The bottomBar is not actually changed in this func
|
|
|
|
func setPage(t *tab, p *structs.Page) {
|
|
|
|
if !isValidTab(t) {
|
|
|
|
// Don't waste time reformatting an invalid tab
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
t.saveScroll() // Save the scroll of the previous page
|
2020-06-18 23:54:48 +03:00
|
|
|
|
2020-07-03 06:55:24 +03:00
|
|
|
// Make sure the page content is fitted to the terminal every time it's displayed
|
|
|
|
reformatPage(p)
|
2020-06-29 01:09:52 +03:00
|
|
|
|
2020-06-21 23:53:12 +03:00
|
|
|
// Change page on screen
|
2020-07-08 04:13:45 +03:00
|
|
|
t.page = p
|
|
|
|
t.view.SetText(p.Content)
|
|
|
|
t.view.Highlight("") // Turn off highlights, other funcs may restore if necessary
|
|
|
|
t.view.ScrollToBeginning()
|
2020-06-18 23:54:48 +03:00
|
|
|
|
|
|
|
// Setup display
|
2020-07-08 04:13:45 +03:00
|
|
|
App.SetFocus(t.view)
|
|
|
|
|
2020-07-10 21:37:18 +03:00
|
|
|
// Save bottom bar for the tab - other funcs will apply/display it
|
2020-07-08 04:13:45 +03:00
|
|
|
t.barLabel = ""
|
|
|
|
t.barText = p.Url
|
|
|
|
}
|
|
|
|
|
2020-07-08 04:51:20 +03:00
|
|
|
// handleHTTP is used by handleURL.
|
|
|
|
// It opens HTTP links and displays Info and Error modals.
|
|
|
|
func handleHTTP(u string, showInfo bool) {
|
|
|
|
switch strings.TrimSpace(viper.GetString("a-general.http")) {
|
|
|
|
case "", "off":
|
|
|
|
Error("HTTP Error", "Opening HTTP URLs is turned off.")
|
|
|
|
case "default":
|
|
|
|
s, err := webbrowser.Open(u)
|
|
|
|
if err != nil {
|
|
|
|
Error("Webbrowser Error", err.Error())
|
|
|
|
} else if showInfo {
|
|
|
|
Info(s)
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
// The config has a custom command to execute for HTTP URLs
|
|
|
|
fields := strings.Fields(viper.GetString("a-general.http"))
|
|
|
|
err := exec.Command(fields[0], append(fields[1:], u)...).Start()
|
|
|
|
if err != nil {
|
|
|
|
Error("HTTP Error", "Error executing custom browser command: "+err.Error())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
App.Draw()
|
|
|
|
}
|
|
|
|
|
2020-07-08 04:13:45 +03:00
|
|
|
// goURL is like handleURL, but takes care of history and the bottomBar.
|
|
|
|
// It should be preferred over handleURL in most cases.
|
|
|
|
// It has no return values to be processed.
|
|
|
|
//
|
|
|
|
// It should be called in a goroutine.
|
|
|
|
func goURL(t *tab, u string) {
|
|
|
|
final, displayed := handleURL(t, u)
|
|
|
|
if displayed {
|
|
|
|
t.addToHistory(final)
|
|
|
|
}
|
|
|
|
if t == tabs[curTab] {
|
|
|
|
// Display the bottomBar state that handleURL set
|
|
|
|
t.applyBottomBar()
|
|
|
|
}
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// handleURL displays whatever action is needed for the provided URL,
|
|
|
|
// and applies it to the current tab.
|
|
|
|
// It loads documents, handles errors, brings up a download prompt, etc.
|
|
|
|
//
|
|
|
|
// The string returned is the final URL, if redirects were involved.
|
|
|
|
// In most cases it will be the same as the passed URL.
|
|
|
|
// If there is some error, it will return "".
|
|
|
|
// The second returned item is a bool indicating if page content was displayed.
|
|
|
|
// It returns false for Errors, other protocols, etc.
|
2020-07-08 04:13:45 +03:00
|
|
|
//
|
|
|
|
// The bottomBar is not actually changed in this func, except during loading.
|
|
|
|
// The func that calls this one should apply the bottomBar values if necessary.
|
|
|
|
func handleURL(t *tab, u string) (string, bool) {
|
2020-06-22 03:37:27 +03:00
|
|
|
defer App.Draw() // Just in case
|
2020-06-18 23:54:48 +03:00
|
|
|
|
2020-07-08 04:13:45 +03:00
|
|
|
// Save for resetting on error
|
|
|
|
oldLable := t.barLabel
|
|
|
|
oldText := t.barText
|
|
|
|
|
|
|
|
// Custom return function
|
|
|
|
ret := func(s string, b bool) (string, bool) {
|
|
|
|
if !b {
|
|
|
|
// Reset bottomBar if page wasn't loaded
|
|
|
|
t.barLabel = oldLable
|
|
|
|
t.barText = oldText
|
|
|
|
}
|
|
|
|
t.mode = tabModeDone
|
|
|
|
return s, b
|
|
|
|
}
|
|
|
|
|
|
|
|
t.barLabel = ""
|
|
|
|
bottomBar.SetLabel("")
|
|
|
|
|
|
|
|
App.SetFocus(t.view)
|
2020-06-22 06:56:25 +03:00
|
|
|
|
2020-06-24 03:07:25 +03:00
|
|
|
// To allow linking to the bookmarks page, and history browsing
|
|
|
|
if u == "about:bookmarks" {
|
2020-07-08 04:13:45 +03:00
|
|
|
Bookmarks(t)
|
|
|
|
return ret("about:bookmarks", true)
|
2020-06-24 03:07:25 +03:00
|
|
|
}
|
2020-06-18 23:54:48 +03:00
|
|
|
|
|
|
|
u = normalizeURL(u)
|
|
|
|
|
|
|
|
parsed, err := url.Parse(u)
|
|
|
|
if err != nil {
|
|
|
|
Error("URL Error", err.Error())
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
if strings.HasPrefix(u, "http") {
|
2020-07-08 04:51:20 +03:00
|
|
|
handleHTTP(u, true)
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
|
|
|
if !strings.HasPrefix(u, "gemini") {
|
2020-06-22 03:37:27 +03:00
|
|
|
Error("Protocol Error", "Only gemini and HTTP are supported. URL was "+u)
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
|
|
|
// Gemini URL
|
|
|
|
|
|
|
|
// Load page from cache if possible
|
|
|
|
page, ok := cache.Get(u)
|
|
|
|
if ok {
|
2020-07-08 04:13:45 +03:00
|
|
|
setPage(t, page)
|
|
|
|
return ret(u, true)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
|
|
|
// Otherwise download it
|
|
|
|
bottomBar.SetText("Loading...")
|
2020-07-08 04:13:45 +03:00
|
|
|
t.barText = "Loading..." // Save it too, in case the tab switches during loading
|
|
|
|
t.mode = tabModeLoading
|
2020-06-18 23:54:48 +03:00
|
|
|
App.Draw()
|
|
|
|
|
|
|
|
res, err := client.Fetch(u)
|
2020-07-08 04:13:45 +03:00
|
|
|
|
|
|
|
// Loading may have taken a while, make sure tab is still valid
|
|
|
|
if !isValidTab(t) {
|
|
|
|
return ret("", false)
|
|
|
|
}
|
|
|
|
|
2020-06-22 03:37:27 +03:00
|
|
|
if err == client.ErrTofu {
|
2020-07-11 01:59:51 +03:00
|
|
|
if Tofu(parsed.Host, client.GetExpiry(parsed.Hostname(), parsed.Port())) {
|
2020-06-22 03:37:27 +03:00
|
|
|
// They want to continue anyway
|
2020-06-24 20:31:01 +03:00
|
|
|
client.ResetTofuEntry(parsed.Hostname(), parsed.Port(), res.Cert)
|
|
|
|
// Response can be used further down, no need to reload
|
2020-06-24 19:06:44 +03:00
|
|
|
} else {
|
|
|
|
// They don't want to continue
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-22 03:37:27 +03:00
|
|
|
}
|
|
|
|
} else if err != nil {
|
2020-06-19 21:05:05 +03:00
|
|
|
Error("URL Fetch Error", err.Error())
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
|
|
|
if renderer.CanDisplay(res) {
|
2020-07-03 06:55:24 +03:00
|
|
|
page, err := renderer.MakePage(u, res, textWidth(), leftMargin())
|
2020-07-08 04:13:45 +03:00
|
|
|
// Rendering may have taken a while, make sure tab is still valid
|
|
|
|
if !isValidTab(t) {
|
|
|
|
return ret("", false)
|
|
|
|
}
|
|
|
|
|
2020-07-10 22:33:39 +03:00
|
|
|
// Make new request for downloading purposes
|
|
|
|
res, clientErr := client.Fetch(u)
|
|
|
|
if clientErr != nil && clientErr != client.ErrTofu {
|
|
|
|
Error("URL Fetch Error", err.Error())
|
|
|
|
return ret("", false)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err == renderer.ErrTooLarge {
|
|
|
|
go dlChoice("That page is too large. What would you like to do?", u, res)
|
|
|
|
return ret("", false)
|
|
|
|
}
|
|
|
|
if err == renderer.ErrTimedOut {
|
|
|
|
go dlChoice("Loading that page timed out. What would you like to do?", u, res)
|
|
|
|
return ret("", false)
|
|
|
|
}
|
2020-06-18 23:54:48 +03:00
|
|
|
if err != nil {
|
2020-06-19 21:05:05 +03:00
|
|
|
Error("Page Error", "Issuing creating page: "+err.Error())
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
2020-07-10 22:33:39 +03:00
|
|
|
|
|
|
|
page.Width = termW
|
2020-07-08 04:13:45 +03:00
|
|
|
go cache.Add(page)
|
|
|
|
setPage(t, page)
|
|
|
|
return ret(u, true)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
|
|
|
// Not displayable
|
|
|
|
// Could be a non 20 (or 21) status code, or a different kind of document
|
|
|
|
|
|
|
|
// Handle each status code
|
|
|
|
switch gemini.SimplifyStatus(res.Status) {
|
|
|
|
case 10:
|
|
|
|
userInput, ok := Input(res.Meta)
|
|
|
|
if ok {
|
|
|
|
// Make another request with the query string added
|
|
|
|
// + chars are replaced because PathEscape doesn't do that
|
2020-07-08 04:13:45 +03:00
|
|
|
parsed.RawQuery = queryEscape(userInput)
|
2020-06-29 21:25:25 +03:00
|
|
|
if len(parsed.String()) > 1024 {
|
|
|
|
// 1024 is the max size for URLs in the spec
|
|
|
|
Error("Input Error", "URL for that input would be too long.")
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-29 21:25:25 +03:00
|
|
|
}
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret(handleURL(t, parsed.String()))
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
case 30:
|
|
|
|
parsedMeta, err := url.Parse(res.Meta)
|
|
|
|
if err != nil {
|
|
|
|
Error("Redirect Error", "Invalid URL: "+err.Error())
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
|
|
|
redir := parsed.ResolveReference(parsedMeta).String()
|
|
|
|
if YesNo("Follow redirect?\n" + redir) {
|
2020-07-08 04:13:45 +03:00
|
|
|
return handleURL(t, redir)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
case 40:
|
2020-07-08 04:13:45 +03:00
|
|
|
Error("Temporary Failure", cview.Escape(res.Meta))
|
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
case 50:
|
|
|
|
Error("Permanent Failure", cview.Escape(res.Meta))
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
case 60:
|
|
|
|
Info("The server requested a certificate. Cert handling is coming to Amfora soon!")
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
|
|
|
// Status code 20, but not a document that can be displayed
|
2020-07-10 22:33:39 +03:00
|
|
|
go dlChoice("That file could not be displayed. What would you like to do?", u, res)
|
2020-07-08 04:13:45 +03:00
|
|
|
return ret("", false)
|
2020-06-18 23:54:48 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// normalizeURL attempts to make URLs that are different strings
|
|
|
|
// but point to the same place all look the same.
|
|
|
|
//
|
|
|
|
// Example: gemini://gus.guru:1965/ and //gus.guru/.
|
|
|
|
// This function will take both output the same URL each time.
|
|
|
|
//
|
|
|
|
// The string passed must already be confirmed to be a URL.
|
|
|
|
// Detection of a search string vs. a URL must happen elsewhere.
|
|
|
|
//
|
|
|
|
// It only works with absolute URLs.
|
|
|
|
func normalizeURL(u string) string {
|
|
|
|
parsed, err := url.Parse(u)
|
|
|
|
if err != nil {
|
|
|
|
return u
|
|
|
|
}
|
|
|
|
|
|
|
|
if !strings.Contains(u, "://") && !strings.HasPrefix(u, "//") {
|
|
|
|
// No scheme at all in the URL
|
|
|
|
parsed, err = url.Parse("gemini://" + u)
|
|
|
|
if err != nil {
|
|
|
|
return u
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if parsed.Scheme == "" {
|
|
|
|
// Always add scheme
|
|
|
|
parsed.Scheme = "gemini"
|
|
|
|
} else if parsed.Scheme != "gemini" {
|
|
|
|
// Not a gemini URL, nothing to do
|
|
|
|
return u
|
|
|
|
}
|
|
|
|
|
|
|
|
parsed.User = nil // No passwords in Gemini
|
|
|
|
parsed.Fragment = "" // No fragments either
|
|
|
|
if parsed.Port() == "1965" {
|
|
|
|
// Always remove default port
|
|
|
|
parsed.Host = parsed.Hostname()
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add slash to the end of a URL with just a domain
|
|
|
|
// gemini://example.com -> gemini://example.com/
|
|
|
|
if parsed.Path == "" {
|
|
|
|
parsed.Path = "/"
|
|
|
|
}
|
|
|
|
|
|
|
|
return parsed.String()
|
|
|
|
}
|