package main import ( "bytes" "fmt" "mime" "net/http" "net/url" "strings" "github.com/charmbracelet/lipgloss" "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gemini" "tildegit.org/tjp/sliderule/gemini/gemtext" "tildegit.org/tjp/sliderule/gopher" "tildegit.org/tjp/sliderule/gopher/gophermap" "tildegit.org/tjp/sliderule/spartan" ) func docType(u *url.URL, response *sliderule.Response) string { _, gopherType := gopherURL(u) switch gopherType { case gopher.MenuType, gopher.SearchType: return "text/x-gophermap" case gopher.TextFileType, gopher.ErrorType, gopher.InfoMessageType: return "text/plain" case gopher.MacBinHexType, gopher.DosBinType, gopher.BinaryFileType, gopher.ImageFileType, gopher.MovieFileType, gopher.SoundFileType, gopher.DocumentType: return "application/octet-stream" case gopher.UuencodedType: return "text/x-uuencode" case gopher.GifFileType: return "image/gif" case gopher.BitmapType: return "image/bmp" case gopher.PngImageFileType: return "image/png" case gopher.HTMLType: return "text/html" case gopher.RtfDocumentType: return "application/rtf" case gopher.WavSoundFileType: return "audio/wav" case gopher.PdfDocumentType: return "application/pdf" case gopher.XmlDocumentType: return "application/xml" } if metaIsMediaType(u, response) { mtype, _, err := mime.ParseMediaType(response.Meta.(string)) if err == nil { return mtype } } if u.Scheme == "http" || u.Scheme == "https" { resp := response.Meta.(*http.Response) mtype, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) if err == nil { return mtype } } return "text/plain" } func metaIsMediaType(u *url.URL, response *sliderule.Response) bool { if u.Scheme == "gemini" && response.Status == gemini.StatusSuccess { return true } if u.Scheme == "spartan" && response.Status == spartan.StatusSuccess { return true } return false } func parseDoc(doctype string, body []byte, conf *Config) (string, []Link, error) { switch doctype { case "text/x-gophermap": return parseGophermapDoc(body, conf.SoftWrap) case "text/gemini": return parseGemtextDoc(body, conf.SoftWrap) } return string(body), nil, nil } func parseGophermapDoc(body []byte, softWrap int) (string, []Link, error) { var b strings.Builder var l []Link mapdoc, err := gophermap.Parse(bytes.NewBuffer(body)) if err != nil { return "", nil, err } links := 0 for _, item := range mapdoc { if item.Type != gopher.InfoMessageType { links += 1 } } width := numberWidth(links - 1) infopad := string(bytes.Repeat([]byte{' '}, width+3)) i := 0 for _, item := range mapdoc { switch item.Type { case gopher.InfoMessageType: for _, line := range fold(item.Display, softWrap) { if _, err := b.WriteString(infopad + line + "\n"); err != nil { return "", nil, err } } default: l = append(l, Link{ Text: item.Display, Target: fmtGopherURL(item.Type, item.Selector, item.Hostname, item.Port), }) if _, err := b.WriteString(fmt.Sprintf("[%d]%s %s\n", i, padding(i, width), linkStyle.Render(item.Display))); err != nil { return "", nil, err } i += 1 } } return b.String(), l, nil } var ( linkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("33")) promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) quoteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Italic(true) rawStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("249")) h1Style = lipgloss.NewStyle().Foreground(lipgloss.Color("154")).Bold(true).Underline(true) h2Style = lipgloss.NewStyle().Foreground(lipgloss.Color("50")).Underline(true) h3Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Underline(true) listStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) ) func parseGemtextDoc(body []byte, softWrap int) (string, []Link, error) { var b strings.Builder var l []Link gemdoc, err := gemtext.Parse(bytes.NewBuffer(body)) if err != nil { return "", nil, err } links := 0 for _, item := range gemdoc { if item.Type() == gemtext.LineTypeLink || item.Type() == gemtext.LineTypePrompt { links += 1 } } width := numberWidth(links - 1) textpad := string(bytes.Repeat([]byte{' '}, width+3)) i := 0 for _, item := range gemdoc { isPrompt := false hLevel := 0 switch item.Type() { case gemtext.LineTypePrompt: isPrompt = true fallthrough case gemtext.LineTypeLink: ll := item.(gemtext.LinkLine) u, err := url.Parse(ll.URL()) if err != nil { return "", nil, err } l = append(l, Link{ Text: ll.Label(), Target: u, Prompt: isPrompt, }) label := ll.Label() if len(label) == 0 { label = ll.URL() } for j, line := range fold(label, softWrap) { var prefix string if j == 0 { prefix = fmt.Sprintf("[%d]%s ", i, padding(i, width)) } else { prefix = strings.Repeat(" ", width+3) } if _, err := fmt.Fprintf(&b, "%s%s\n", prefix, linkStyle.Render(line)); err != nil { return "", nil, err } } i += 1 case gemtext.LineTypeQuote: q := item.(gemtext.QuoteLine) for _, line := range fold(q.Body(), softWrap-1) { line = strings.TrimSpace(line) if _, err := b.WriteString(textpad + "> " + quoteStyle.Render(line) + "\n"); err != nil { return "", nil, err } } case gemtext.LineTypePreformatToggle: case gemtext.LineTypePreformattedText: for _, line := range fold(item.String(), softWrap) { if _, err := b.WriteString(textpad + rawStyle.Render(line) + "\n"); err != nil { return "", nil, err } } case gemtext.LineTypeHeading3: hLevel += 1 fallthrough case gemtext.LineTypeHeading2: hLevel += 1 fallthrough case gemtext.LineTypeHeading1: hLevel += 1 var style lipgloss.Style switch hLevel { case 1: style = h1Style case 2: style = h2Style case 3: style = h3Style } for _, line := range fold(item.String(), softWrap) { line = strings.TrimRight(line, "\r\n") if _, err := b.WriteString(textpad + style.Render(line) + "\n"); err != nil { return "", nil, err } } case gemtext.LineTypeListItem: li := item.(gemtext.ListItemLine) for i, line := range fold(li.Body(), softWrap-2) { lpad := " " if i == 0 { lpad = "* " } if _, err := b.WriteString(textpad + lpad + listStyle.Render(line) + "\n"); err != nil { return "", nil, err } } default: for _, line := range fold(item.String(), softWrap) { if _, err := b.WriteString(textpad + line + "\n"); err != nil { return "", nil, err } } } } return b.String(), l, nil } func fold(line string, width int) []string { rs := []rune(strings.TrimSuffix(line, "\n")) if len(rs) == 0 { return []string{""} } var b []string outer: for len(rs) > 0 { if len(rs) <= width { b = append(b, string(rs)) break } w := width for rs[w] != ' ' && w > 0 { w -= 1 } if w == 0 { for i := width + 1; i < len(rs); i += 1 { if rs[i] == ' ' { b = append(b, string(rs[:i])) rs = rs[i+1:] continue outer } } b = append(b, string(rs)) break outer } b = append(b, string(rs[:w])) rs = rs[w+1:] } return b } func fmtGopherURL(itemtype sliderule.Status, selector, hostname, port string) *url.URL { if port != "70" { hostname += ":" + port } return &url.URL{ Scheme: "gopher", Host: hostname, Path: "/" + string(byte(itemtype)) + selector, } } func numberWidth(i int) int { n := 1 for i > 9 { i /= 10 n += 1 } return n } func padding(num int, width int) string { return strings.Repeat(" ", width-numberWidth(num)) }