From b8f1e92bfc9e690a318c9adc96370d60bbcdedd7 Mon Sep 17 00:00:00 2001
From: tjpcc <tjp@ctrl-c.club>
Date: Fri, 1 Sep 2023 08:14:13 -0600
Subject: gophermap->html conversion with overridable templates

---
 gopher/gophermap/htmlconv/convert.go      | 196 ++++++++++++++++++++++++++++++
 gopher/gophermap/htmlconv/convert_test.go |  59 +++++++++
 2 files changed, 255 insertions(+)
 create mode 100644 gopher/gophermap/htmlconv/convert.go
 create mode 100644 gopher/gophermap/htmlconv/convert_test.go

(limited to 'gopher')

diff --git a/gopher/gophermap/htmlconv/convert.go b/gopher/gophermap/htmlconv/convert.go
new file mode 100644
index 0000000..a669601
--- /dev/null
+++ b/gopher/gophermap/htmlconv/convert.go
@@ -0,0 +1,196 @@
+package htmlconv
+
+import (
+	"bytes"
+	"html/template"
+	"io"
+	"strings"
+
+	"tildegit.org/tjp/sliderule/gopher"
+	"tildegit.org/tjp/sliderule/internal/types"
+)
+
+// Convert writes html to a writer  from the provided gophermap document.
+//
+// Templates can be provided to override the output for different line types.
+// The supported templates are:
+//   - "header' is called before any lines and is passed the full document.
+//   - "footer" is called after all lines and is passed the full document.
+//   - "message" is called for every group of consecutive info-message lines. It is
+//     passed a string of all the display components of the included lines, joined by
+//     newline characters.
+//   - "image" is called for all lines of any of the following types: GifFileType,
+//     ImageFileType, BitmapType, PngImageFileType. It is passed the gopher.MapItem.
+//   - "link" is called for all lines of any other type not yet mentioned, and is passed
+//     the gopher.MapItem.
+//
+// There are already default implementations of each of these templates, so the "overrides"
+// argument can be nil or include just a subset of the supported templates.
+func Convert(wr io.Writer, doc gopher.MapDocument, overrides *template.Template) error {
+	tmpl, err := baseTmpl.Clone()
+	if err != nil {
+		return err
+	}
+
+	tmpl, err = addOverrides(tmpl, overrides)
+	if err != nil {
+		return err
+	}
+
+	for _, item := range renderItems(doc) {
+		if err := tmpl.ExecuteTemplate(wr, item.template, item.object); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+type renderItem struct {
+	template string
+	object   any
+}
+
+type renderRef struct {
+	Type     types.Status
+	Display  string
+	Selector template.URL
+	Hostname string
+	Port     string
+}
+
+func refItem(item gopher.MapItem) renderRef {
+	return renderRef{
+		Type:     item.Type,
+		Display:  item.Display,
+		Selector: template.URL(item.Selector),
+		Hostname: item.Hostname,
+		Port:     item.Port,
+	}
+}
+
+func renderItems(doc gopher.MapDocument) []renderItem {
+	out := make([]renderItem, 0, len(doc))
+	out = append(out, renderItem{
+		template: "header",
+		object:   doc,
+	})
+	inMsg := false
+	msg := &bytes.Buffer{}
+	var currentHost, currentPort string
+
+	for _, mapItem := range doc {
+		switch mapItem.Type {
+		case gopher.InfoMessageType:
+			if inMsg {
+				_, _ = msg.WriteString("\n")
+			} else {
+				msg.Reset()
+			}
+			_, _ = msg.WriteString(mapItem.Display)
+			inMsg = true
+
+			if currentHost == "" {
+				currentHost = mapItem.Hostname
+			}
+			if currentPort == "" {
+				currentPort = mapItem.Port
+			}
+		case gopher.GifFileType, gopher.ImageFileType, gopher.BitmapType, gopher.PngImageFileType:
+			if inMsg {
+				out = append(out, renderItem{
+					template: "message",
+					object:   msg.String(),
+				})
+				inMsg = false
+			}
+			out = append(out, renderItem{
+				template: "image",
+				object:   refItem(mapItem),
+			})
+		default:
+			if inMsg {
+				out = append(out, renderItem{
+					template: "message",
+					object:   msg.String(),
+				})
+				inMsg = false
+			}
+			out = append(out, renderItem{
+				template: "link",
+				object:   refItem(mapItem),
+			})
+		}
+	}
+
+	if inMsg {
+		out = append(out, renderItem{
+			template: "message",
+			object:   msg.String(),
+		})
+	}
+
+	simplifyLinks(out, currentHost, currentPort)
+
+	return append(out, renderItem{
+		template: "footer",
+		object:   doc,
+	})
+}
+
+func simplifyLinks(items []renderItem, currentHost, currentPort string) {
+	for i, item := range items {
+		if item.template != "link" && item.template != "image" {
+			continue
+		}
+
+		m := item.object.(renderRef)
+		if m.Hostname == currentHost && m.Port == currentPort {
+			m.Hostname = ""
+			m.Port = ""
+			m.Selector = template.URL(strings.TrimPrefix(string(m.Selector), "URL:"))
+			items[i].object = m
+		}
+	}
+}
+
+func addOverrides(base *template.Template, overrides *template.Template) (*template.Template, error) {
+	if overrides == nil {
+		return base, nil
+	}
+
+	tmpl := base
+	var err error
+	for _, override := range overrides.Templates() {
+		tmpl, err = tmpl.AddParseTree(override.Name(), override.Tree)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return tmpl, nil
+}
+
+var baseTmpl = template.Must(template.New("htmlconv").Parse(`
+{{ define "header" -}}
+<!DOCTYPE html>
+<html><body class="gophermap">
+{{- end }}
+
+{{ define "message" -}}
+<pre class="gophermap">{{.}}
+</pre>
+{{- end }}
+
+{{ define "link" -}}
+<p class="gophermap"><a class="gophermap" {{ if .Hostname | and .Port }}href="gopher://{{.Hostname}}:{{.Port}}{{.Selector}}"{{ else }}href="{{.Selector}}"{{ end }}>{{.Display}}</a></p>
+{{- end }}
+
+{{ define "image" -}}
+<p class="gophermap"><img class="gophermap" {{ if .Hostname | and .Port }}src="gopher://{{.Hostname}}:{{.Port}}{{.Selector}}"{{ else }}src="{{.Selector}}"{{ end }} alt="{{.Display}}"/></p>
+{{- end }}
+
+{{ define "footer" -}}
+</body></html>
+{{ end }}
+`))
diff --git a/gopher/gophermap/htmlconv/convert_test.go b/gopher/gophermap/htmlconv/convert_test.go
new file mode 100644
index 0000000..1cdc8e2
--- /dev/null
+++ b/gopher/gophermap/htmlconv/convert_test.go
@@ -0,0 +1,59 @@
+package htmlconv
+
+import (
+	"bytes"
+	"fmt"
+	"strings"
+	"testing"
+
+	"tildegit.org/tjp/sliderule/gopher/gophermap"
+)
+
+func TestConvert(t *testing.T) {
+	tests := []struct {
+		name   string
+		input  string
+		output string
+	}{
+		{
+			name: "basic doc",
+			input: strings.ReplaceAll(`
+iI am informational text		localhost	70
+icontinued on this line		localhost	70
+i		localhost	70
+0this is my text file	/file.txt	localhost	70
+i		localhost	70
+1here's a sub-menu	/sub/	localhost	70
+.
+`[1:], "\n", "\r\n"),
+			output: `
+<!DOCTYPE html>
+<html><body class="gophermap"><pre class="gophermap">I am informational text
+continued on this line
+
+</pre><p class="gophermap"><a class="gophermap" href="/file.txt">this is my text file</a></p><pre class="gophermap">
+</pre><p class="gophermap"><a class="gophermap" href="/sub/">here&#39;s a sub-menu</a></p></body></html>
+`[1:],
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			doc, err := gophermap.Parse(bytes.NewBufferString(test.input))
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			buf := &bytes.Buffer{}
+			if err := Convert(buf, doc, nil); err != nil {
+				t.Fatal(err)
+			}
+
+			if buf.String() != test.output {
+				fmt.Println(test.output)
+				fmt.Println(buf.String())
+				t.Fatal("html body mismatch")
+			}
+		})
+	}
+}
-- 
cgit v1.2.3