// 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" ) // Charset values that are compatible with UTF-8, lowercased. var utfCharsets = []string{"", "utf-8", "us-ascii"} // 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 mediatype != "text/gemini" && mediatype != "text/plain" { // Amfora doesn't support other filetypes return false } for _, charset := range utfCharsets { if strings.ToLower(params["charset"]) == charset { return true // Supported } } return false } // 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. 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") }