329 lines
8.6 KiB
Go
329 lines
8.6 KiB
Go
package parsing
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"regexp"
|
|
|
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/ast"
|
|
"github.com/yuin/goldmark/parser"
|
|
"github.com/yuin/goldmark/renderer"
|
|
"github.com/yuin/goldmark/renderer/html"
|
|
"github.com/yuin/goldmark/text"
|
|
"github.com/yuin/goldmark/util"
|
|
)
|
|
|
|
// NOTE(ben): ggcode is my cute name for our custom extension syntax because I got fed up with
|
|
// bbcode. It's designed to be a more natural fit for Goldmark's method of parsing, while still
|
|
// being a general-purpose tag-like syntax that's easy for us to add instances of without writing
|
|
// new Goldmark parsers.
|
|
//
|
|
// Inline ggcode is delimited by two exclamation marks. Block ggcode is delimited by three. Inline
|
|
// ggcode uses parentheses to delimit the start and end of the affected content. Block ggcode is
|
|
// like a fenced code block and ends with !!!. ggcode sections can optionally have named string
|
|
// arguments inside braces. Quotes around the value are mandatory.
|
|
//
|
|
// Inline example:
|
|
//
|
|
// See our article on !!glossary{slug="tcp"}(TCP) for more details.
|
|
//
|
|
// Block example:
|
|
//
|
|
// !!!resource{name="Beej's Guide to Network Programming" url="https://beej.us/guide/bgnet/html/"}
|
|
// This is a _fantastic_ resource on network programming, suitable for beginners.
|
|
// !!!
|
|
//
|
|
|
|
var ggcodeTags = map[string]ggcodeTag{
|
|
"glossary": {
|
|
Filter: ggcodeFilterEdu,
|
|
Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error {
|
|
if entering {
|
|
term := n.Args["term"]
|
|
c.W.WriteString(fmt.Sprintf(
|
|
`<a href="%s" class="glossary-term" data-term="%s">`,
|
|
hmnurl.BuildEducationGlossary(term),
|
|
term,
|
|
))
|
|
} else {
|
|
c.W.WriteString("</a>")
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
"note": {
|
|
Filter: ggcodeFilterEdu,
|
|
Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error {
|
|
if entering {
|
|
c.W.WriteString(`<span class="note">`)
|
|
} else {
|
|
c.W.WriteString(`</span>`)
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
"figure": {
|
|
Filter: ggcodeFilterEdu,
|
|
Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error {
|
|
if entering {
|
|
c.W.WriteString(`<figure>`)
|
|
|
|
src := n.Args["src"]
|
|
alt := n.Args["alt"]
|
|
if embedHTML, _, canEmbed := htmlForURLEmbed(src, c.Opts.Previews); canEmbed {
|
|
c.W.WriteString(embedHTML)
|
|
} else {
|
|
var srcAttr, altAttr string
|
|
if src != "" {
|
|
srcAttr = fmt.Sprintf(` src="%s"`, src)
|
|
}
|
|
if alt != "" {
|
|
altAttr = fmt.Sprintf(` alt="%s"`, alt)
|
|
}
|
|
c.W.WriteString(fmt.Sprintf(`<img%s%s>`, srcAttr, altAttr))
|
|
}
|
|
|
|
c.W.WriteString(`<figcaption>`)
|
|
} else {
|
|
c.W.WriteString(`</figcaption>`)
|
|
c.W.WriteString(`</figure>`)
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
}
|
|
|
|
// ----------------------
|
|
// Types
|
|
// ----------------------
|
|
|
|
type ggcodeRendererContext struct {
|
|
W util.BufWriter
|
|
Source []byte
|
|
Opts MarkdownOptions
|
|
}
|
|
|
|
type ggcodeTagFilter func(opts MarkdownOptions) bool
|
|
type ggcodeRenderer func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error
|
|
|
|
type ggcodeTag struct {
|
|
Filter ggcodeTagFilter
|
|
Renderer ggcodeRenderer
|
|
}
|
|
|
|
var ggcodeFilterEdu ggcodeTagFilter = func(opts MarkdownOptions) bool {
|
|
return opts.Education
|
|
}
|
|
|
|
// ----------------------
|
|
// Parsers and delimiters
|
|
// ----------------------
|
|
|
|
var reGGCodeBlockOpen = regexp.MustCompile(`^!!!(?P<name>[a-zA-Z0-9-_]+)(\{(?P<args>.*?)\})?$`)
|
|
var reGGCodeInline = regexp.MustCompile(`^!!(?P<name>[a-zA-Z0-9-_]+)(\{(?P<args>.*?)\})?(\((?P<content>.*?)\))?`)
|
|
var reGGCodeArgs = regexp.MustCompile(`(?P<arg>[a-zA-Z0-9-_]+)="(?P<val>.*?)"`)
|
|
|
|
// Block parser stuff
|
|
|
|
type ggcodeBlockParser struct{}
|
|
|
|
var _ parser.BlockParser = ggcodeBlockParser{}
|
|
|
|
func (s ggcodeBlockParser) Trigger() []byte {
|
|
return []byte("!")
|
|
}
|
|
|
|
func (s ggcodeBlockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
|
|
restOfLine, _ := reader.PeekLine()
|
|
|
|
if match := extractMap(reGGCodeBlockOpen, bytes.TrimSpace(restOfLine)); match != nil {
|
|
name := string(match["name"])
|
|
var args map[string]string
|
|
if argsMatch := extractAllMap(reGGCodeArgs, match["args"]); argsMatch != nil {
|
|
args = make(map[string]string)
|
|
for i := range argsMatch["arg"] {
|
|
arg := string(argsMatch["arg"][i])
|
|
val := string(argsMatch["val"][i])
|
|
args[arg] = val
|
|
}
|
|
}
|
|
|
|
reader.Advance(len(restOfLine))
|
|
return &ggcodeNode{
|
|
Name: name,
|
|
Args: args,
|
|
}, parser.Continue | parser.HasChildren
|
|
} else {
|
|
return nil, parser.NoChildren
|
|
}
|
|
}
|
|
|
|
func (s ggcodeBlockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
|
|
line, _ := reader.PeekLine()
|
|
if string(bytes.TrimSpace(line)) == "!!!" {
|
|
reader.Advance(3)
|
|
return parser.Close
|
|
}
|
|
return parser.Continue | parser.HasChildren
|
|
}
|
|
|
|
func (s ggcodeBlockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {}
|
|
|
|
func (s ggcodeBlockParser) CanInterruptParagraph() bool {
|
|
return false
|
|
}
|
|
|
|
func (s ggcodeBlockParser) CanAcceptIndentedLine() bool {
|
|
return false
|
|
}
|
|
|
|
// Inline parser stuff
|
|
|
|
type ggcodeInlineParser struct{}
|
|
|
|
var _ parser.InlineParser = ggcodeInlineParser{}
|
|
|
|
func (s ggcodeInlineParser) Trigger() []byte {
|
|
return []byte("!()")
|
|
}
|
|
|
|
func (s ggcodeInlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
|
restOfLine, segment := block.PeekLine() // Gets the rest of the line (starting at the current parser cursor index), and the segment representing the indices in the source text.
|
|
if match := extractMap(reGGCodeInline, restOfLine); match != nil {
|
|
name := string(match["name"])
|
|
var args map[string]string
|
|
if argsMatch := extractAllMap(reGGCodeArgs, match["args"]); argsMatch != nil {
|
|
args = make(map[string]string)
|
|
for i := range argsMatch["arg"] {
|
|
arg := string(argsMatch["arg"][i])
|
|
val := string(argsMatch["val"][i])
|
|
args[arg] = val
|
|
}
|
|
}
|
|
|
|
node := &ggcodeNode{
|
|
Name: name,
|
|
Args: args,
|
|
}
|
|
contentLength := len(match["content"])
|
|
if contentLength > 0 {
|
|
contentSegmentStart := segment.Start + len(match["all"]) - (contentLength + 1) // the 1 is for the end parenthesis
|
|
contentSegmentEnd := contentSegmentStart + contentLength
|
|
contentSegment := text.NewSegment(contentSegmentStart, contentSegmentEnd)
|
|
node.AppendChild(node, ast.NewTextSegment(contentSegment))
|
|
}
|
|
|
|
block.Advance(len(match["all"]))
|
|
return node
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
type ggcodeDelimiterParser struct {
|
|
Node *ggcodeNode // We need to pass this through 🙄
|
|
}
|
|
|
|
func (p ggcodeDelimiterParser) IsDelimiter(b byte) bool {
|
|
return b == '(' || b == ')'
|
|
}
|
|
|
|
func (p ggcodeDelimiterParser) CanOpenCloser(opener, closer *parser.Delimiter) bool {
|
|
return opener.Char == '(' && closer.Char == ')'
|
|
}
|
|
|
|
func (p ggcodeDelimiterParser) OnMatch(consumes int) ast.Node {
|
|
return p.Node
|
|
}
|
|
|
|
// ----------------------
|
|
// AST node
|
|
// ----------------------
|
|
|
|
type ggcodeNode struct {
|
|
ast.BaseBlock
|
|
Name string
|
|
Args map[string]string
|
|
}
|
|
|
|
var _ ast.Node = &ggcodeNode{}
|
|
|
|
func (n *ggcodeNode) Dump(source []byte, level int) {
|
|
ast.DumpHelper(n, source, level, n.Args, nil)
|
|
}
|
|
|
|
var kindGGCode = ast.NewNodeKind("ggcode")
|
|
|
|
func (n *ggcodeNode) Kind() ast.NodeKind {
|
|
return kindGGCode
|
|
}
|
|
|
|
// ----------------------
|
|
// Renderer
|
|
// ----------------------
|
|
|
|
type ggcodeHTMLRenderer struct {
|
|
html.Config
|
|
Opts MarkdownOptions
|
|
}
|
|
|
|
func newGGCodeHTMLRenderer(markdownOpts MarkdownOptions, opts ...html.Option) renderer.NodeRenderer {
|
|
r := &ggcodeHTMLRenderer{
|
|
Opts: markdownOpts,
|
|
Config: html.NewConfig(),
|
|
}
|
|
for _, opt := range opts {
|
|
opt.SetHTMLOption(&r.Config)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (r *ggcodeHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
|
reg.Register(kindGGCode, r.render)
|
|
}
|
|
|
|
func (r *ggcodeHTMLRenderer) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
node := n.(*ggcodeNode)
|
|
var renderer ggcodeRenderer = defaultGGCodeRenderer
|
|
if tag, ok := ggcodeTags[node.Name]; ok {
|
|
if tag.Filter == nil || tag.Filter(r.Opts) {
|
|
renderer = tag.Renderer
|
|
}
|
|
}
|
|
err := renderer(ggcodeRendererContext{
|
|
W: w,
|
|
Source: source,
|
|
Opts: r.Opts,
|
|
}, node, entering)
|
|
return ast.WalkContinue, err
|
|
}
|
|
|
|
func defaultGGCodeRenderer(c ggcodeRendererContext, n *ggcodeNode, entering bool) error {
|
|
if entering {
|
|
c.W.WriteString("[unknown ggcode tag]")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ----------------------
|
|
// Extension
|
|
// ----------------------
|
|
|
|
type ggcodeExtension struct {
|
|
Opts MarkdownOptions
|
|
}
|
|
|
|
func (e ggcodeExtension) Extend(m goldmark.Markdown) {
|
|
m.Parser().AddOptions(parser.WithBlockParsers(
|
|
util.Prioritized(ggcodeBlockParser{}, 500),
|
|
))
|
|
m.Parser().AddOptions(parser.WithInlineParsers(
|
|
util.Prioritized(ggcodeInlineParser{}, 500),
|
|
))
|
|
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
|
util.Prioritized(newGGCodeHTMLRenderer(e.Opts), 500),
|
|
))
|
|
}
|