hmn/src/parsing/bbcode.go

405 lines
10 KiB
Go

package parsing
import (
"bytes"
"regexp"
"strings"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/alecthomas/chroma"
chromahtml "github.com/alecthomas/chroma/formatters/html"
"github.com/alecthomas/chroma/lexers"
"github.com/alecthomas/chroma/styles"
"github.com/frustra/bbcode"
"github.com/yuin/goldmark"
gast "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"
)
var BBCodePriority = 1
var reOpenTag = regexp.MustCompile(`^\[\s*(?P<name>[a-zA-Z0-9]+)`)
var reTag = regexp.MustCompile(`\[\s*(?P<opentagname>[a-zA-Z0-9]+)|\[\s*\/\s*(?P<closetagname>[a-zA-Z0-9]+)\s*\]`)
var previewBBCodeCompiler = bbcode.NewCompiler(false, false)
var realBBCodeCompiler = bbcode.NewCompiler(false, false)
var eduPreviewBBCodeCompiler = bbcode.NewCompiler(false, false)
var eduRealBBCodeCompiler = bbcode.NewCompiler(false, false)
var REYoutubeVidOnly = regexp.MustCompile(`^[a-zA-Z0-9_-]{11}$`)
func init() {
all := []bbcode.Compiler{previewBBCodeCompiler, realBBCodeCompiler, eduPreviewBBCodeCompiler, eduRealBBCodeCompiler}
real := []bbcode.Compiler{realBBCodeCompiler, eduRealBBCodeCompiler}
preview := []bbcode.Compiler{previewBBCodeCompiler, eduPreviewBBCodeCompiler}
education := []bbcode.Compiler{eduPreviewBBCodeCompiler, eduRealBBCodeCompiler}
addSimpleTag(all, "h1", "h1", false, nil)
addSimpleTag(all, "h2", "h3", false, nil)
addSimpleTag(all, "h3", "h3", false, nil)
addSimpleTag(all, "m", "span", false, attrs{"class": "monospace"})
addSimpleTag(all, "ol", "ol", true, nil)
addSimpleTag(all, "ul", "ul", true, nil)
addSimpleTag(all, "li", "li", false, nil)
addSimpleTag(all, "spoiler", "span", false, attrs{"class": "spoiler"})
addSimpleTag(all, "table", "table", true, nil)
addSimpleTag(all, "tr", "tr", true, nil)
addSimpleTag(all, "th", "th", false, nil)
addSimpleTag(all, "td", "td", false, nil)
addTag(all, "quote", func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) {
cite := bn.GetOpeningTag().Value
if cite == "" {
return htm("blockquote", nil), true
} else {
return htm("blockquote", attrs{"cite": cite},
htm("a", attrs{"href": hmnurl.BuildUserProfile(cite), "class": "quotewho"},
bbcode.NewHTMLTag(cite),
),
htm("br", nil),
), true
}
})
addTag(all, "code", func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) {
lang := ""
if tagvalue := bn.GetOpeningTag().Value; tagvalue != "" {
lang = tagvalue
} else if arglang, ok := bn.GetOpeningTag().Args["language"]; ok {
lang = arglang
}
text := bbcode.CompileText(bn)
text = strings.TrimPrefix(text, "\n")
var lexer chroma.Lexer
if lang != "" {
lexer = lexers.Get(lang)
}
if lexer == nil {
lexer = lexers.Analyse(text)
}
if lexer == nil {
lexer = lexers.Fallback
}
iterator, err := lexer.Tokenise(nil, text)
if err != nil {
panic(oops.New(err, "failed to tokenize bbcode"))
}
var result bytes.Buffer
formatter := chromahtml.New(HMNChromaOptions...)
formatter.Format(&result, styles.Monokai, iterator)
formatted := result.String()
out := bbcode.NewHTMLTag("")
out.Name = "pre"
out.Attrs["class"] = "hmn-code"
child := bbcode.NewHTMLTag(formatted)
child.Raw = true
out.AppendChild(child)
return out, false
})
addTag(preview, "youtube", makeYoutubeBBCodeFunc(true))
addTag(real, "youtube", makeYoutubeBBCodeFunc(false))
addTag(education, "glossary", func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) {
term := bn.GetOpeningTag().Value
return htm("a", attrs{"href": hmnurl.BuildEducationGlossary(term), "class": "glossary-term", "data-term": term}), true
})
addSimpleTag(education, "note", "div", false, attrs{"class": "education-note"})
}
type attrs map[string]string
// Generates a "simple" tag, i.e. one that is more or less just HTML.
//
// name is the bbcode tag name.
// tag is the HTML tag to generate.
// notext prevents the tag from having any direct text children (useful for e.g. [ol] and [ul])
// attrs are any HTML attributes to add to the tag.
func addSimpleTag(compilers []bbcode.Compiler, name, tag string, notext bool, attributes attrs) {
var tagFunc bbcode.TagCompilerFunc = func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) {
if notext {
var newChildren []*bbcode.BBCodeNode
for _, child := range bn.Children {
if child.ID != bbcode.TEXT {
newChildren = append(newChildren, child)
}
}
bn.Children = newChildren
}
out := bbcode.NewHTMLTag("")
out.Name = tag
out.Attrs = attributes
return out, true
}
for _, compiler := range compilers {
compiler.SetTag(name, tagFunc)
}
}
func addTag(compilers []bbcode.Compiler, name string, f bbcode.TagCompilerFunc) {
for _, compiler := range compilers {
compiler.SetTag(name, f)
}
}
// html was taken
func htm(tag string, attributes attrs, children ...*bbcode.HTMLTag) *bbcode.HTMLTag {
res := bbcode.NewHTMLTag("")
res.Name = tag
res.Attrs = attributes
for _, child := range children {
res.AppendChild(child)
}
return res
}
func makeYoutubeBBCodeFunc(preview bool) bbcode.TagCompilerFunc {
return func(bn *bbcode.BBCodeNode) (*bbcode.HTMLTag, bool) {
contents := bbcode.CompileText(bn)
if contents == "" {
return bbcode.NewHTMLTag("<missing video URL>"), false
}
vid := ""
if m := REYoutubeLong.FindStringSubmatch(contents); m != nil {
vid = m[REYoutubeLong.SubexpIndex("vid")]
} else if m := REYoutubeShort.FindStringSubmatch(contents); m != nil {
vid = m[REYoutubeShort.SubexpIndex("vid")]
} else if m := REYoutubeVidOnly.MatchString(contents); m {
vid = contents
}
if vid == "" {
return bbcode.NewHTMLTag("<bad video URL>"), false
}
if preview {
/*
<div class="mw6">
<img src="https://img.youtube.com/vi/` + vid + `/hqdefault.jpg">
</div>
*/
out := bbcode.NewHTMLTag("")
out.Name = "div"
out.Attrs["class"] = "mw6"
img := bbcode.NewHTMLTag("")
img.Name = "img"
img.Attrs = map[string]string{
"src": "https://img.youtube.com/vi/" + vid + "/hqdefault.jpg",
}
out.AppendChild(img)
return out, false
} else {
/*
<div class="mw6">
<div class="aspect-ratio aspect-ratio--16x9">
<iframe class="aspect-ratio--object" src="https://www.youtube-nocookie.com/embed/` + vid + `" frameborder="0" allowfullscreen></iframe>
</div>
</div>
*/
out := bbcode.NewHTMLTag("")
out.Name = "div"
out.Attrs["class"] = "mw6"
aspect := bbcode.NewHTMLTag("")
aspect.Name = "div"
aspect.Attrs["class"] = "aspect-ratio aspect-ratio--16x9"
iframe := bbcode.NewHTMLTag("")
iframe.Name = "iframe"
iframe.Attrs = map[string]string{
"class": "aspect-ratio--object",
"src": "https://www.youtube-nocookie.com/embed/" + vid,
"frameborder": "0",
"allowfullscreen": "",
}
iframe.AppendChild(nil) // render a closing tag lol
aspect.AppendChild(iframe)
out.AppendChild(aspect)
return out, false
}
}
}
// ----------------------
// Parser and delimiters
// ----------------------
type bbcodeParser struct {
Preview bool
Education bool
}
var _ parser.InlineParser = &bbcodeParser{}
func (s bbcodeParser) Trigger() []byte {
return []byte{'['}
}
func (s bbcodeParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node {
_, pos := block.Position()
restOfSource := block.Source()[pos.Start:]
openMatch := reOpenTag.FindSubmatch(restOfSource)
if openMatch == nil {
// not a bbcode tag
return nil
}
otIndex := reTag.SubexpIndex("opentagname")
ctIndex := reTag.SubexpIndex("closetagname")
tagName := string(openMatch[reOpenTag.SubexpIndex("name")])
depth := 0
endIndex := -1
searchStartIndex := 0
for {
searchText := restOfSource[searchStartIndex:]
match := reTag.FindSubmatchIndex(searchText)
if match == nil {
// no more tags
break
}
if openName := extractStringBySubmatchIndices(searchText, match, otIndex); openName == tagName {
depth++
} else if closeName := extractStringBySubmatchIndices(searchText, match, ctIndex); closeName == tagName {
depth--
if depth == 0 {
// We have balanced out!
endIndex = searchStartIndex + match[1] // the end index of this closing tag (exclusive)
break
}
}
searchStartIndex = searchStartIndex + match[1]
}
if endIndex < 0 {
// Unbalanced, too many opening tags
return nil
}
unparsedBBCode := restOfSource[:endIndex]
block.Advance(len(unparsedBBCode))
var compiler bbcode.Compiler
if s.Preview && s.Education {
compiler = eduPreviewBBCodeCompiler
} else if s.Preview && !s.Education {
compiler = previewBBCodeCompiler
} else if !s.Preview && s.Education {
compiler = eduRealBBCodeCompiler
} else {
compiler = realBBCodeCompiler
}
return NewBBCode(compiler.Compile(string(unparsedBBCode)))
}
func extractStringBySubmatchIndices(src []byte, m []int, subexpIndex int) string {
srcIndices := m[2*subexpIndex : 2*subexpIndex+1+1]
if srcIndices[0] < 0 {
return ""
}
return string(src[srcIndices[0]:srcIndices[1]])
}
// ----------------------
// AST node
// ----------------------
type BBCodeNode struct {
gast.BaseInline
HTML string
}
var _ gast.Node = &BBCodeNode{}
func (n *BBCodeNode) Dump(source []byte, level int) {
gast.DumpHelper(n, source, level, nil, nil)
}
var KindBBCode = gast.NewNodeKind("BBCode")
func (n *BBCodeNode) Kind() gast.NodeKind {
return KindBBCode
}
func NewBBCode(html string) gast.Node {
return &BBCodeNode{
HTML: html,
}
}
// ----------------------
// Renderer
// ----------------------
type BBCodeHTMLRenderer struct {
html.Config
}
func NewBBCodeHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
r := &BBCodeHTMLRenderer{
Config: html.NewConfig(),
}
for _, opt := range opts {
opt.SetHTMLOption(&r.Config)
}
return r
}
func (r *BBCodeHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(KindBBCode, r.renderBBCode)
}
func (r *BBCodeHTMLRenderer) renderBBCode(w util.BufWriter, source []byte, n gast.Node, entering bool) (gast.WalkStatus, error) {
if entering {
w.WriteString(n.(*BBCodeNode).HTML)
}
return gast.WalkContinue, nil
}
// ----------------------
// Extension
// ----------------------
type BBCodeExtension struct {
Preview bool
Education bool
}
func (e BBCodeExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(bbcodeParser{Preview: e.Preview, Education: e.Education}, BBCodePriority),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(NewBBCodeHTMLRenderer(), BBCodePriority),
))
}