amfora/renderer/renderer.go

239 lines
7.2 KiB
Go

// Package renderer provides functions to convert various data into a cview primitive.
// Example objects include a Gemini response, and an error.
//
// Rendered lines always end with \r\n, in an effort to be Window compatible.
package renderer
import (
"errors"
"io/ioutil"
"mime"
urlPkg "net/url"
"strconv"
"strings"
"github.com/makeworld-the-better-one/amfora/config"
"github.com/makeworld-the-better-one/amfora/structs"
"github.com/makeworld-the-better-one/go-gemini"
"github.com/spf13/viper"
"gitlab.com/tslocum/cview"
)
// CanDisplay returns true if the response is supported by Amfora
// for displaying on the screen.
// It also doubles as a function to detect whether something can be stored in a Page struct.
func CanDisplay(res *gemini.Response) bool {
if gemini.SimplifyStatus(res.Status) != 20 {
// No content
return false
}
mediatype, params, err := mime.ParseMediaType(res.Meta)
if err != nil {
return false
}
if strings.ToLower(params["charset"]) != "utf-8" && strings.ToLower(params["charset"]) != "us-ascii" && params["charset"] != "" {
// Amfora doesn't support other charsets
return false
}
if mediatype != "text/gemini" && mediatype != "text/plain" {
// Amfora doesn't support other filetypes
return false
}
return true
}
// convertRegularGemini converts non-preformatted blocks of text/gemini
// into a cview-compatible format.
// It also returns a slice of link URLs.
// numLinks is the number of links that exist so far.
//
// Since this only works on non-preformatted blocks, renderGemini
// should always be used instead.
//
// TODO: Style cross-protocol links differently
//
func convertRegularGemini(s string, numLinks int) (string, []string) {
links := make([]string, 0)
lines := strings.Split(s, "\n")
wrappedLines := make([]string, 0) // Final result
for i := range lines {
lines[i] = strings.TrimRight(lines[i], " \r\t\n")
if strings.HasPrefix(lines[i], "#") && viper.GetBool("a-general.color") {
// Headings
if strings.HasPrefix(lines[i], "###") {
lines[i] = "[fuchsia::b]" + lines[i] + "[-::-]"
}
if strings.HasPrefix(lines[i], "##") {
lines[i] = "[lime::b]" + lines[i] + "[-::-]"
}
if strings.HasPrefix(lines[i], "#") {
lines[i] = "[red::b]" + lines[i] + "[-::-]"
}
// Links
} else if strings.HasPrefix(lines[i], "=>") && len([]rune(lines[i])) >= 3 {
// Trim whitespace and separate link from link text
lines[i] = strings.Trim(lines[i][2:], " \t") // Remove `=>` part too
delim := strings.IndexAny(lines[i], " \t") // Whitespace between link and link text
var url string
var linkText string
if delim == -1 {
// No link text
url = lines[i]
linkText = url
} else {
// There is link text
url = lines[i][:delim]
linkText = strings.Trim(lines[i][delim:], " \t")
}
if strings.TrimSpace(lines[i]) == "" || strings.TrimSpace(url) == "" {
// Link was just whitespace, reset it and move on
lines[i] = "=>"
wrappedLines = append(wrappedLines, lines[i])
continue
}
links = append(links, url)
if viper.GetBool("a-general.color") {
if pU, _ := urlPkg.Parse(url); pU.Scheme == "" || pU.Scheme == "gemini" {
// A gemini link
// Add the link text in blue (in a region), and a gray link number to the left of it
lines[i] = `[silver::b][` + strconv.Itoa(numLinks+len(links)) + "[]" + "[-::-] " +
`[dodgerblue]["` + strconv.Itoa(numLinks+len(links)-1) + `"]` + linkText + `[""][-]`
} else {
// Not a gemini link, use purple instead
lines[i] = `[silver::b][` + strconv.Itoa(numLinks+len(links)) + "[]" + "[-::-] " +
`[#8700d7]["` + strconv.Itoa(numLinks+len(links)-1) + `"]` + linkText + `[""][-]`
}
} else {
// No colours allowed
lines[i] = `[::b][` + strconv.Itoa(numLinks+len(links)) + "[] " +
`["` + strconv.Itoa(numLinks+len(links)-1) + `"]` + linkText + `[""][-]`
}
// Lists
} else if strings.HasPrefix(lines[i], "* ") {
if viper.GetBool("a-general.bullets") {
lines[i] = " 🞄" + lines[i][1:]
}
// Optionally list lines could be colored here too, if color is enabled
}
// Final processing of each line: wrapping
if strings.TrimSpace(lines[i]) == "" {
// Just add empty line without processing
wrappedLines = append(wrappedLines, "")
} else {
if (strings.HasPrefix(lines[i], "[silver::b]") && viper.GetBool("a-general.color")) || strings.HasPrefix(lines[i], "[::b]") {
// It's a link line, so don't wrap it
wrappedLines = append(wrappedLines, lines[i])
} else if strings.HasPrefix(lines[i], ">") {
// It's a quote line, add extra quote symbols to the start of each wrapped line
// Remove beginning quote and maybe space
lines[i] = strings.TrimPrefix(lines[i], ">")
lines[i] = strings.TrimPrefix(lines[i], " ")
temp := cview.WordWrap(lines[i], config.GetWrapWidth())
for i := range temp {
temp[i] = "> " + temp[i]
}
wrappedLines = append(wrappedLines, temp...)
} else {
wrappedLines = append(wrappedLines, cview.WordWrap(lines[i], config.GetWrapWidth())...)
}
}
}
return strings.Join(wrappedLines, "\r\n"), links
}
// renderGemini converts text/gemini into a cview displayable format.
// It also returns a slice of link URLs.
func RenderGemini(s string) (string, []string) {
s = cview.Escape(s)
if viper.GetBool("a-general.color") {
s = cview.TranslateANSI(s)
}
lines := strings.Split(s, "\n")
links := make([]string, 0)
// Process and wrap non preformatted lines
rendered := "" // Final result
pre := false
buf := "" // Block of regular or preformatted lines
for i := range lines {
if strings.HasPrefix(lines[i], "```") {
if pre {
// In a preformatted block, so add the text as is
// Don't add the current line with backticks
rendered += buf
} else {
// Not preformatted, regular text
ren, lks := convertRegularGemini(buf, len(links))
links = append(links, lks...)
rendered += ren
}
buf = "" // Clear buffer for next block
pre = !pre
continue
}
// Lines always end with \r\n for Windows compatibility
buf += strings.TrimSuffix(lines[i], "\r") + "\r\n"
}
// Gone through all the lines, but there still is likely a block in the buffer
if pre {
// File ended without closing the preformatted block
rendered += buf
} else {
// Not preformatted, regular text
// Same code as in the loop above
ren, lks := convertRegularGemini(buf, len(links))
links = append(links, lks...)
rendered += ren
}
return rendered, links
}
func MakePage(url string, res *gemini.Response) (*structs.Page, error) {
if !CanDisplay(res) {
return nil, errors.New("not valid content for a Page")
}
content, err := ioutil.ReadAll(res.Body) // TODO: Don't use all memory on large pages
if err != nil {
return nil, err
}
res.Body.Close()
mediatype, _, _ := mime.ParseMediaType(res.Meta)
if mediatype == "text/plain" {
return &structs.Page{
Url: url,
Content: string(content),
Links: []string{}, // Plaintext has no links
}, nil
}
if mediatype == "text/gemini" {
rendered, links := RenderGemini(string(content))
return &structs.Page{
Url: url,
Content: rendered,
Links: links,
}, nil
}
return nil, errors.New("displayable mediatype is not handled in the code, implementation error")
}