summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--examples/gmi2html/main.go20
-rw-r--r--gemtext/htmlconv/convert.go86
-rw-r--r--gemtext/htmlconv/convert_test.go46
-rw-r--r--gemtext/internal/templates.go150
-rw-r--r--gemtext/mdconv/convert.go104
-rw-r--r--gemtext/mdconv/convert_test.go7
6 files changed, 357 insertions, 56 deletions
diff --git a/examples/gmi2html/main.go b/examples/gmi2html/main.go
new file mode 100644
index 0000000..f6131f3
--- /dev/null
+++ b/examples/gmi2html/main.go
@@ -0,0 +1,20 @@
+package main
+
+import (
+ "log"
+ "os"
+
+ "tildegit.org/tjp/gus/gemtext"
+ "tildegit.org/tjp/gus/gemtext/htmlconv"
+)
+
+func main() {
+ gmiDoc, err := gemtext.Parse(os.Stdin)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if err := htmlconv.Convert(os.Stdout, gmiDoc, nil); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/gemtext/htmlconv/convert.go b/gemtext/htmlconv/convert.go
new file mode 100644
index 0000000..c703211
--- /dev/null
+++ b/gemtext/htmlconv/convert.go
@@ -0,0 +1,86 @@
+package htmlconv
+
+import (
+ "html/template"
+ "io"
+
+ "tildegit.org/tjp/gus/gemtext"
+ "tildegit.org/tjp/gus/gemtext/internal"
+)
+
+// Convert writes markdown to a writer from the provided gemtext document.
+//
+// Templates can be provided to override the output for different line types.
+// The templates supported are:
+// - "header" is called before any lines and is passed the full Document.
+// - "footer" is called after the lines and is passed the full Document.
+// - "textline" is called once per line of text and is passed a gemtext.TextLine.
+// - "linkline" is called once per link line and is passed an object which wraps
+// a gemtext.LinkLine but also supports a ValidatedURL() method returning a
+// string which html/template will always allow as href attributes.
+// - "preformattedtextlines" is called once for a block of preformatted text and is
+// passed a slice of gemtext.PreformattedTextLines.
+// - "heading1line" is called once per h1 line and is passed a gemtext.Heading1Line.
+// - "heading2line" is called once per h2 line and is passed a gemtext.Heading2Line.
+// - "heading3line" is called once per h3 line and is passed a gemtext.Heading3Line.
+// - "listitemlines" is called once for a block of contiguous list item lines and
+// is passed a slice of gemtext.ListItemLines.
+// - "quoteline" is passed once per blockquote line and is passed a gemtext.QuoteLine.
+//
+// There exist default implementations of each of these templates, so the "overrides"
+// argument can be nil.
+func Convert(wr io.Writer, doc gemtext.Document, overrides *template.Template) error {
+ if err := internal.ValidateLinks(doc); err != nil {
+ return err
+ }
+
+ tmpl, err := baseTmpl.Clone()
+ if err != nil {
+ return err
+ }
+
+ tmpl, err = internal.AddHTMLTemplates(tmpl, overrides)
+ if err != nil {
+ return err
+ }
+
+ for _, item := range internal.RenderItems(doc) {
+ if err := tmpl.ExecuteTemplate(wr, item.Template, item.Object); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+var baseTmpl = template.Must(template.New("htmlconv").Parse(`
+{{ define "header" }}<html><body>{{ end }}
+{{ define "textline" }}{{ if ne .String "\n" }}<p>{{ . }}</p>{{ end }}{{ end }}
+{{ define "linkline" -}}
+ <p>=> <a href="{{ .ValidatedURL }}">{{ if eq .Label "" -}}
+ {{ .URL }}
+ {{- else -}}
+ {{ .Label }}
+ {{- end -}}
+ </a></p>
+{{- end }}
+{{ define "preformattedtextlines" -}}
+ <pre>
+ {{- range . -}}
+ {{ . }}
+ {{- end -}}
+ </pre>
+{{- end }}
+{{ define "heading1line" }}<h1>{{ .Body }}</h1>{{ end }}
+{{ define "heading2line" }}<h2>{{ .Body }}</h2>{{ end }}
+{{ define "heading3line" }}<h3>{{ .Body }}</h3>{{ end }}
+{{ define "listitemlines" -}}
+ <ul>
+ {{- range . -}}
+ <li>{{ .Body }}</li>
+ {{- end -}}
+ </ul>
+{{- end }}
+{{ define "quoteline" }}<blockquote>{{ .Body }}</blockquote>{{ end }}
+{{ define "footer" }}</body></html>{{ end }}
+`))
diff --git a/gemtext/htmlconv/convert_test.go b/gemtext/htmlconv/convert_test.go
new file mode 100644
index 0000000..967cece
--- /dev/null
+++ b/gemtext/htmlconv/convert_test.go
@@ -0,0 +1,46 @@
+package htmlconv_test
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "tildegit.org/tjp/gus/gemtext"
+ "tildegit.org/tjp/gus/gemtext/htmlconv"
+)
+
+var gmiDoc = `
+# top-level header line
+
+## subtitle
+
+This is some non-blank regular text.
+
+* an
+* unordered
+* list
+
+=> gemini://google.com/ as if
+=> https://google.com/
+
+> this is a quote
+> -tjp
+
+`[1:] + "```pre-formatted code\ndoc := gemtext.Parse(req.Body)\n```ignored closing alt-text\n"
+
+func TestConvert(t *testing.T) {
+ htmlDoc := `
+<html><body><h1>top-level header line</h1><h2>subtitle</h2><p>This is some non-blank regular text.
+</p><ul><li>an</li><li>unordered</li><li>list</li></ul><p>=> <a href="gemini://google.com/">as if</a></p><p>=> <a href="https://google.com/">https://google.com/</a></p><blockquote> this is a quote</blockquote><blockquote> -tjp</blockquote><pre>doc := gemtext.Parse(req.Body)
+</pre></body></html>`[1:]
+
+ doc, err := gemtext.Parse(bytes.NewBufferString(gmiDoc))
+ require.Nil(t, err)
+
+ buf := &bytes.Buffer{}
+ require.Nil(t, htmlconv.Convert(buf, doc, nil))
+
+ assert.Equal(t, htmlDoc, buf.String())
+}
diff --git a/gemtext/internal/templates.go b/gemtext/internal/templates.go
new file mode 100644
index 0000000..dea528a
--- /dev/null
+++ b/gemtext/internal/templates.go
@@ -0,0 +1,150 @@
+package internal
+
+import (
+ htemplate "html/template"
+ "net/url"
+ "text/template"
+
+ "tildegit.org/tjp/gus/gemtext"
+)
+
+var Renderers = map[gemtext.LineType]string{
+ gemtext.LineTypeText: "textline",
+ gemtext.LineTypeLink: "linkline",
+ gemtext.LineTypeHeading1: "heading1line",
+ gemtext.LineTypeHeading2: "heading2line",
+ gemtext.LineTypeHeading3: "heading3line",
+ gemtext.LineTypeQuote: "quoteline",
+}
+
+func AddAllTemplates(base *template.Template, additions *template.Template) (*template.Template, error) {
+ if additions == nil {
+ return base, nil
+ }
+
+ tmpl := base
+ var err error
+ for _, addition := range additions.Templates() {
+ tmpl, err = tmpl.AddParseTree(addition.Name(), addition.Tree)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return tmpl, nil
+}
+
+func AddHTMLTemplates(base *htemplate.Template, additions *htemplate.Template) (*htemplate.Template, error) {
+ if additions == nil {
+ return base, nil
+ }
+
+ tmpl := base
+ var err error
+ for _, addition := range additions.Templates() {
+ tmpl, err = tmpl.AddParseTree(addition.Name(), addition.Tree)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return tmpl, nil
+}
+
+func ValidateLinks(doc gemtext.Document) error {
+ for _, line := range doc {
+ if linkLine, ok := line.(gemtext.LinkLine); ok {
+ _, err := url.Parse(linkLine.URL())
+ if err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+type RenderItem struct {
+ Template string
+ Object any
+}
+
+func RenderItems(doc gemtext.Document) []RenderItem {
+ out := make([]RenderItem, 0, len(doc))
+ out = append(out, RenderItem{
+ Template: "header",
+ Object: doc,
+ })
+
+ inUL := false
+ ulStart := 0
+ inPF := false
+ pfStart := 0
+
+ for i, line := range doc {
+ switch line.Type() {
+ case gemtext.LineTypeListItem:
+ if !inUL {
+ inUL = true
+ ulStart = i
+ }
+ case gemtext.LineTypePreformatToggle:
+ if inUL {
+ inUL = false
+ out = append(out, RenderItem{
+ Template: "listitemlines",
+ Object: doc[ulStart:i],
+ })
+ }
+ if !inPF {
+ inPF = true
+ pfStart = i
+ } else {
+ inPF = false
+ out = append(out, RenderItem{
+ Template: "preformattedtextlines",
+ Object: doc[pfStart+1 : i],
+ })
+ }
+ case gemtext.LineTypePreformattedText:
+ default:
+ if inUL {
+ inUL = false
+ out = append(out, RenderItem{
+ Template: "listitemlines",
+ Object: doc[ulStart:i],
+ })
+ }
+
+ if linkLine, ok := line.(gemtext.LinkLine); ok {
+ line = validatedLinkLine{linkLine}
+ }
+
+ out = append(out, RenderItem{
+ Template: Renderers[line.Type()],
+ Object: line,
+ })
+ }
+ }
+
+ if inUL {
+ out = append(out, RenderItem{
+ Template: "listitemlines",
+ Object: doc[ulStart:],
+ })
+ }
+
+ out = append(out, RenderItem{
+ Template: "footer",
+ Object: doc,
+ })
+
+ return out
+}
+
+type validatedLinkLine struct {
+ gemtext.LinkLine
+}
+
+func (vll validatedLinkLine) ValidatedURL() htemplate.URL {
+ return htemplate.URL(vll.URL())
+}
diff --git a/gemtext/mdconv/convert.go b/gemtext/mdconv/convert.go
index 0c92f9f..9371d92 100644
--- a/gemtext/mdconv/convert.go
+++ b/gemtext/mdconv/convert.go
@@ -1,72 +1,72 @@
package mdconv
import (
- "fmt"
"io"
"text/template"
"tildegit.org/tjp/gus/gemtext"
+ "tildegit.org/tjp/gus/gemtext/internal"
)
+// Convert writes markdown to a writer from the provided gemtext document.
+//
+// Templates can be provided to override the output for different line types.
+// The templates supported are:
+// - "header" is called before any lines and is passed the full Document.
+// - "footer" is called after the lines and is passed the full Document.
+// - "textline" is called once per line of text and is passed a gemtext.TextLine.
+// - "linkline" is called once per link line and is passed a gemtext.LinkLine.
+// - "preformattedtextlines" is called once for a block of preformatted text and is
+// passed a slice of gemtext.PreformattedTextLines.
+// - "heading1line" is called once per h1 line and is passed a gemtext.Heading1Line.
+// - "heading2line" is called once per h2 line and is passed a gemtext.Heading2Line.
+// - "heading3line" is called once per h3 line and is passed a gemtext.Heading3Line.
+// - "listitemlines" is called once for a block of contiguous list item lines and
+// is passed a slice of gemtext.ListItemLines.
+// - "quoteline" is passed once per blockquote line and is passed a gemtext.QuoteLine.
+//
+// There exist default implementations of each of these templates, so the "overrides"
+// argument can be nil.
func Convert(wr io.Writer, doc gemtext.Document, overrides *template.Template) error {
+ if err := internal.ValidateLinks(doc); err != nil {
+ return err
+ }
+
tmpl, err := baseTmpl.Clone()
if err != nil {
return err
}
- if overrides != nil {
- for _, override := range overrides.Templates() {
- tmpl, err = tmpl.AddParseTree(override.Name(), override.Tree)
- if err != nil {
- return err
- }
+ tmpl, err = internal.AddAllTemplates(tmpl, overrides)
+ if err != nil {
+ return err
+ }
+
+ for _, item := range internal.RenderItems(doc) {
+ if err := tmpl.ExecuteTemplate(wr, item.Template, item.Object); err != nil {
+ return err
}
}
- return tmpl.ExecuteTemplate(wr, "mdconv", doc)
+ return nil
}
-var baseTmpl = template.Must(template.New("mdconv").Parse(fmt.Sprintf((`
-{{block "header" .}}{{end -}}
-{{range . -}}
-{{if .Type | eq %d}}{{block "textline" . -}}
- {{. -}}
-{{end -}}
-{{else if .Type | eq %d}}{{block "linkline" . -}}
- => [{{if eq .Label ""}}{{.URL}}{{else}}{{.Label}}{{end}}]({{.URL}})
-{{end -}}
-{{else if .Type | eq %d}}{{block "preformattoggleline" . -}}
- ` + "```" + `
-{{end -}}
-{{else if .Type | eq %d}}{{block "preformattedtextline" . -}}
- {{. -}}
-{{end -}}
-{{else if .Type | eq %d}}{{block "heading1line" . -}}
- # {{.Body}}
-{{end -}}
-{{else if .Type | eq %d}}{{block "heading2line" . -}}
- ## {{.Body}}
-{{end -}}
-{{else if .Type | eq %d}}{{block "heading3line" . -}}
- ### {{.Body}}
-{{end -}}
-{{else if .Type | eq %d}}{{block "listitemline" . -}}
- * {{.Body}}
-{{end -}}
-{{else if .Type | eq %d}}{{block "quoteline" . -}}
- > {{.Body}}
-{{end -}}
-{{end -}}
-{{end -}}
-{{block "footer" .}}{{end -}}
-`)[1:],
- gemtext.LineTypeText,
- gemtext.LineTypeLink,
- gemtext.LineTypePreformatToggle,
- gemtext.LineTypePreformattedText,
- gemtext.LineTypeHeading1,
- gemtext.LineTypeHeading2,
- gemtext.LineTypeHeading3,
- gemtext.LineTypeListItem,
- gemtext.LineTypeQuote,
-)))
+var baseTmpl = template.Must(template.New("mdconv").Parse(`
+{{ define "header" }}{{ end }}
+{{ define "textline" }}{{ . }}{{ end }}
+{{ define "linkline" -}}
+ => [{{ if eq .Label "" }}{{ .URL }}{{ else }}{{ .Label }}{{ end }}]({{ .URL }})
+{{ end }}
+{{ define "preformattedtextlines" }}` + "```\n" + `{{ range . }}{{ . }}{{ end }}` + "```\n" + `{{ end }}
+{{ define "heading1line" }}# {{ .Body }}
+{{ end }}
+{{ define "heading2line" }}## {{ .Body }}
+{{ end }}
+{{ define "heading3line" }}### {{ .Body }}
+{{ end }}
+{{ define "listitemlines" }}{{ range . }}* {{ .Body }}
+{{ end }}{{ end }}
+{{ define "quoteline" }}> {{ .Body }}
+{{ end }}
+{{ define "footer" }}{{ end }}
+`))
diff --git a/gemtext/mdconv/convert_test.go b/gemtext/mdconv/convert_test.go
index 6cde08b..010ddce 100644
--- a/gemtext/mdconv/convert_test.go
+++ b/gemtext/mdconv/convert_test.go
@@ -78,17 +78,16 @@ text:
> quote: this is a quote
> quote: -tjp
text:
-`[1:] + "pftoggle: ```\npf: doc := gemtext.Parse(req.Body)\npftoggle: ```\n"
+`[1:] + "```\npf: doc := gemtext.Parse(req.Body)\n```\n"
overrides := template.Must(template.New("overrides").Parse((`
{{define "textline"}}text: {{.}}{{end}}
{{define "linkline"}}=> link: [{{if eq .Label ""}}{{.URL}}{{else}}{{.Label}}{{end}}]({{.URL}})` + "\n" + `{{end}}
- {{define "preformattoggleline"}}pftoggle: ` + "```\n" + `{{end}}
- {{define "preformattedtextline"}}pf: {{.}}{{end}}
+ {{define "preformattedtextlines"}}` + "```\n" + `{{range . }}pf: {{.}}{{end}}` + "```\n" + `{{end}}
{{define "heading1line"}}# h1: {{.Body}}` + "\n" + `{{end}}
{{define "heading2line"}}## h2: {{.Body}}` + "\n" + `{{end}}
{{define "heading3line"}}### h3: {{.Body}}` + "\n" + `{{end}}
- {{define "listitemline"}}* li: {{.Body}}` + "\n" + `{{end}}
+ {{define "listitemlines"}}{{range .}}* li: {{.Body}}` + "\n" + `{{end}}{{end}}
{{define "quoteline"}}> quote: {{.Body}}` + "\n" + `{{end}}
`)[1:]))