~whereswaldon/gio-x

e0acbf68bc236bce23cef7179dc21b421a90630f — Chris Waldon 2 years ago 47f01e1 markdown2
markdown: add markdown package

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
3 files changed, 340 insertions(+), 0 deletions(-)

M go.mod
M go.sum
A markdown/markdown.go
M go.mod => go.mod +1 -0
@@ 4,6 4,7 @@ go 1.16

require (
	gioui.org v0.0.0-20210729070555-8cec7e04eb71
	github.com/yuin/goldmark v1.4.0
	golang.org/x/exp v0.0.0-20210722180016-6781d3edade3
	golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d
)

M go.sum => go.sum +2 -0
@@ 240,6 240,8 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.0 h1:OtISOGfH6sOWa1/qXqqAiOIAO6Z5J3AEAE18WAq6BiQ=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=

A markdown/markdown.go => markdown/markdown.go +337 -0
@@ 0,0 1,337 @@
// SPDX-License-Identifier: Unlicense OR MIT

/*
Package markdown transforms markdown text into gio richtext.
*/
package markdown

import (
	"bytes"
	"fmt"
	"io/ioutil"
	"regexp"
	"strings"

	"gioui.org/text"
	"gioui.org/widget/material"
	"gioui.org/x/richtext"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/renderer"
	"github.com/yuin/goldmark/util"
)

// gioNodeRenderer transforms AST nodes into gio's richtext types
type gioNodeRenderer struct {
	TextObjects []richtext.SpanStyle

	Current      richtext.SpanStyle
	Theme        *material.Theme
	OrderedList  bool
	OrderedIndex int
}

func newNodeRenderer() *gioNodeRenderer {
	return &gioNodeRenderer{}
}

func (g *gioNodeRenderer) CommitCurrent() {
	g.TextObjects = append(g.TextObjects, g.Current.DeepCopy())
}

func (g *gioNodeRenderer) UpdateCurrent(l material.LabelStyle) {
	g.Current.Font = l.Font
	g.Current.Color = l.Color
	g.Current.Size = l.TextSize
}

func (g *gioNodeRenderer) AppendNewline() {
	if len(g.TextObjects) < 1 {
		return
	}
	g.TextObjects[len(g.TextObjects)-1].Content += "\n"
}

func (g *gioNodeRenderer) EnsureSeparationFromPrevious() {
	if len(g.TextObjects) < 1 {
		return
	}
	last := g.TextObjects[len(g.TextObjects)-1]
	if !strings.HasSuffix(last.Content, "\n\n") {
		if strings.HasSuffix(last.Content, "\n") {
			g.Current.Content = "\n"
		} else {
			g.Current.Content = "\n\n"
		}
		g.CommitCurrent()
	}
}

func (g *gioNodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
	// blocks
	//
	reg.Register(ast.KindDocument, g.renderDocument)
	reg.Register(ast.KindHeading, g.renderHeading)
	reg.Register(ast.KindBlockquote, g.renderBlockquote)
	reg.Register(ast.KindCodeBlock, g.renderCodeBlock)
	reg.Register(ast.KindFencedCodeBlock, g.renderFencedCodeBlock)
	reg.Register(ast.KindHTMLBlock, g.renderHTMLBlock)
	reg.Register(ast.KindList, g.renderList)
	reg.Register(ast.KindListItem, g.renderListItem)
	reg.Register(ast.KindParagraph, g.renderParagraph)
	reg.Register(ast.KindTextBlock, g.renderTextBlock)
	reg.Register(ast.KindThematicBreak, g.renderThematicBreak)
	//
	//	// inlines
	//
	reg.Register(ast.KindAutoLink, g.renderAutoLink)
	reg.Register(ast.KindCodeSpan, g.renderCodeSpan)
	reg.Register(ast.KindEmphasis, g.renderEmphasis)
	reg.Register(ast.KindImage, g.renderImage)
	reg.Register(ast.KindLink, g.renderLink)
	reg.Register(ast.KindRawHTML, g.renderRawHTML)
	reg.Register(ast.KindText, g.renderText)
	reg.Register(ast.KindString, g.renderString)
}

func (g *gioNodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	return ast.WalkContinue, nil
}

func (g *gioNodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	n := node.(*ast.Heading)
	if entering {
		g.EnsureSeparationFromPrevious()
		var l material.LabelStyle
		switch n.Level {
		case 1:
			l = material.H1(g.Theme, "")
		case 2:
			l = material.H2(g.Theme, "")
		case 3:
			l = material.H3(g.Theme, "")
		case 4:
			l = material.H4(g.Theme, "")
		case 5:
			l = material.H5(g.Theme, "")
		case 6:
			l = material.H6(g.Theme, "")
		}
		g.UpdateCurrent(l)
	} else {
		l := material.Body1(g.Theme, "")
		g.UpdateCurrent(l)
	}
	return ast.WalkContinue, nil
}

func (g *gioNodeRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	return ast.WalkContinue, nil
}

func (g *gioNodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		g.EnsureSeparationFromPrevious()
		g.Current.Font.Variant = "Mono"
	} else {
		g.Current.Font.Variant = ""
	}
	return ast.WalkContinue, nil
}

func (g *gioNodeRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	n := node.(*ast.FencedCodeBlock)
	if entering {
		g.EnsureSeparationFromPrevious()
		g.Current.Font.Variant = "Mono"
		lines := n.Lines()
		for i := 0; i < lines.Len(); i++ {
			line := lines.At(i)
			g.Current.Content = string(line.Value(source))
			g.CommitCurrent()
		}
	} else {
		g.Current.Font.Variant = ""
	}
	return ast.WalkContinue, nil
}

func (g *gioNodeRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		g.EnsureSeparationFromPrevious()
		g.Current.Font.Variant = "Mono"
	} else {
		g.Current.Font.Variant = ""
	}
	return ast.WalkContinue, nil
}

func (g *gioNodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	n := node.(*ast.List)
	if entering {
		g.EnsureSeparationFromPrevious()
		g.OrderedList = n.IsOrdered()
		g.OrderedIndex = 1
	} else {
	}
	return ast.WalkContinue, nil
}

func (g *gioNodeRenderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		if g.OrderedList {
			g.Current.Content = fmt.Sprintf(" %d. ", g.OrderedIndex)
			g.OrderedIndex++
		} else {
			g.Current.Content = " • "
		}
		g.CommitCurrent()
	} else if len(g.TextObjects) > 0 {
		g.AppendNewline()
	}

	return ast.WalkContinue, nil
}
func (g *gioNodeRenderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		g.EnsureSeparationFromPrevious()
	}
	return ast.WalkContinue, nil
}
func (g *gioNodeRenderer) renderTextBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	return ast.WalkContinue, nil
}
func (g *gioNodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	return ast.WalkContinue, nil
}
func (g *gioNodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	n := node.(*ast.AutoLink)
	if entering {
		url := string(n.URL(source))
		g.Current.Set(MetadataURL, url)
		g.Current.Color = g.Theme.ContrastBg
		g.Current.Content = url
		g.CommitCurrent()
	} else {
		g.Current.Set(MetadataURL, "")
		g.Current.Color = g.Theme.Fg
	}
	return ast.WalkContinue, nil
}
func (g *gioNodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		g.Current.Font.Variant = "Mono"
	} else {
		g.Current.Font.Variant = ""
	}
	return ast.WalkContinue, nil
}
func (g *gioNodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	n := node.(*ast.Emphasis)

	if entering {
		if n.Level == 2 {
			g.Current.Font.Weight = text.Bold
		} else {
			g.Current.Font.Style = text.Italic
		}
	} else {
		g.Current.Font.Style = text.Regular
		g.Current.Font.Weight = text.Normal
	}
	return ast.WalkContinue, nil
}
func (g *gioNodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	return ast.WalkContinue, nil
}

const MetadataURL = "url"

func (g *gioNodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	n := node.(*ast.Link)
	if entering {
		g.Current.Color = g.Theme.ContrastBg
		g.Current.Interactive = true
		g.Current.Set(MetadataURL, string(n.Destination))
	} else {
		g.Current.Color = g.Theme.Fg
		g.Current.Interactive = false
		g.Current.Set(MetadataURL, "")
	}
	return ast.WalkContinue, nil
}
func (g *gioNodeRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	return ast.WalkContinue, nil
}
func (g *gioNodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if !entering {
		return ast.WalkContinue, nil
	}
	n := node.(*ast.Text)
	segment := n.Segment
	content := segment.Value(source)
	g.Current.Content = string(content)
	g.CommitCurrent()

	return ast.WalkContinue, nil
}
func (g *gioNodeRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if !entering {
		return ast.WalkContinue, nil
	}
	n := node.(*ast.String)
	g.Current.Content = string(n.Value)
	g.CommitCurrent()
	return ast.WalkContinue, nil
}

func (g *gioNodeRenderer) Result() []richtext.SpanStyle {
	o := g.TextObjects
	g.TextObjects = nil
	return o
}

// Renderer can transform source markdown into Gio richtext.
type Renderer struct {
	md goldmark.Markdown
	nr *gioNodeRenderer
}

// NewRenderer creates a ready-to-use markdown renderer.
func NewRenderer() *Renderer {
	nr := newNodeRenderer()
	md := goldmark.New(
		goldmark.WithRenderer(
			renderer.NewRenderer(
				renderer.WithNodeRenderers(
					util.PrioritizedValue{Value: nr, Priority: 0},
				),
			),
		),
	)
	return &Renderer{md: md, nr: nr}
}

const NonParenBracketAndSpace = `[^([\s]`

// this regex matches a :// with one or more character that isn't whitespace
// a square bracket, or a parentheses on either side. It seems to reliably
// detect content that should be hyperlinked without actually matching
// markdown link syntax.
var urlExp = regexp.MustCompile(`(^|\s)([^([\s]+://[^)\]\s]+)`)

// Render transforms the provided src markdown into gio richtext using the
// fonts and styles defined by the given theme.
func (r *Renderer) Render(th *material.Theme, src []byte) ([]richtext.SpanStyle, error) {
	if bytes.Contains(src, []byte("://")) {
		src = urlExp.ReplaceAll(src, []byte("$1[$2]($2)"))
	}
	l := material.Body1(th, "")
	r.nr.Theme = th
	r.nr.UpdateCurrent(l)
	if err := r.md.Convert(src, ioutil.Discard); err != nil {
		return nil, err
	}
	return r.nr.Result(), nil
}