Completed markdown and HTML conversion.
continuous-integration/drone/push Build is passing Details

This commit is contained in:
tjpcc 2023-01-15 19:59:58 -07:00
parent cec3718bdd
commit 4c2630752f
6 changed files with 357 additions and 56 deletions

20
examples/gmi2html/main.go Normal file
View File

@ -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)
}
}

View File

@ -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 }}
`))

View File

@ -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())
}

View File

@ -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())
}

View File

@ -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 }}
`))

View File

@ -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:]))