From 4c2630752f7367fff491a6ba53303e9102da7da0 Mon Sep 17 00:00:00 2001 From: tjpcc Date: Sun, 15 Jan 2023 19:59:58 -0700 Subject: Completed markdown and HTML conversion. --- gemtext/htmlconv/convert.go | 86 ++++++++++++++++++++++ gemtext/htmlconv/convert_test.go | 46 ++++++++++++ gemtext/internal/templates.go | 150 +++++++++++++++++++++++++++++++++++++++ gemtext/mdconv/convert.go | 104 +++++++++++++-------------- gemtext/mdconv/convert_test.go | 7 +- 5 files changed, 337 insertions(+), 56 deletions(-) create mode 100644 gemtext/htmlconv/convert.go create mode 100644 gemtext/htmlconv/convert_test.go create mode 100644 gemtext/internal/templates.go (limited to 'gemtext') 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" }}{{ end }} +{{ define "textline" }}{{ if ne .String "\n" }}

{{ . }}

{{ end }}{{ end }} +{{ define "linkline" -}} +

=> {{ if eq .Label "" -}} + {{ .URL }} + {{- else -}} + {{ .Label }} + {{- end -}} +

+{{- end }} +{{ define "preformattedtextlines" -}} +
+	{{- range . -}}
+		{{ . }}
+	{{- end -}}
+	
+{{- end }} +{{ define "heading1line" }}

{{ .Body }}

{{ end }} +{{ define "heading2line" }}

{{ .Body }}

{{ end }} +{{ define "heading3line" }}

{{ .Body }}

{{ end }} +{{ define "listitemlines" -}} + +{{- end }} +{{ define "quoteline" }}
{{ .Body }}
{{ end }} +{{ define "footer" }}{{ 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 := ` +

top-level header line

subtitle

This is some non-blank regular text. +

=> as if

=> https://google.com/

this is a quote
-tjp
doc := gemtext.Parse(req.Body)
+
`[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:])) -- cgit v1.2.3