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( ``, hmnurl.BuildEducationGlossary(term), term, )) } else { c.W.WriteString("") } return nil }, }, "note": { Filter: ggcodeFilterEdu, Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error { if entering { c.W.WriteString(``) } else { c.W.WriteString(``) } return nil }, }, "figure": { Filter: ggcodeFilterEdu, Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error { if entering { c.W.WriteString(`
`) 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(``, srcAttr, altAttr)) } c.W.WriteString(`
`) } else { c.W.WriteString(`
`) c.W.WriteString(`
`) } 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[a-zA-Z0-9-_]+)(\{(?P.*?)\})?$`) var reGGCodeInline = regexp.MustCompile(`^!!(?P[a-zA-Z0-9-_]+)(\{(?P.*?)\})?(\((?P.*?)\))?`) var reGGCodeArgs = regexp.MustCompile(`(?P[a-zA-Z0-9-_]+)="(?P.*?)"`) // 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), )) }