88c2e24360
This PR adds mathematical rendering with KaTeX. The first step is to add a Goldmark extension that detects the latex (and tex) mathematics delimiters. The second step to make this extension only run if math support is enabled. The second step is to then add KaTeX CSS and JS to the head which will load after the dom is rendered. Fix #3445 Signed-off-by: Andrew Thornton <art27@cantab.net> Signed-off-by: Andrew Thornton <art27@cantab.net> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
398 lines
10 KiB
Go
398 lines
10 KiB
Go
// Copyright 2019 The Gitea Authors. All rights reserved.
|
|
// Use of this source code is governed by a MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package markdown
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"code.gitea.io/gitea/modules/markup"
|
|
"code.gitea.io/gitea/modules/markup/common"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
giteautil "code.gitea.io/gitea/modules/util"
|
|
|
|
"github.com/yuin/goldmark/ast"
|
|
east "github.com/yuin/goldmark/extension/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 byteMailto = []byte("mailto:")
|
|
|
|
// ASTTransformer is a default transformer of the goldmark tree.
|
|
type ASTTransformer struct{}
|
|
|
|
// Transform transforms the given AST tree.
|
|
func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
|
|
firstChild := node.FirstChild()
|
|
createTOC := false
|
|
ctx := pc.Get(renderContextKey).(*markup.RenderContext)
|
|
rc := pc.Get(renderConfigKey).(*RenderConfig)
|
|
if rc.yamlNode != nil {
|
|
metaNode := rc.toMetaNode()
|
|
if metaNode != nil {
|
|
node.InsertBefore(node, firstChild, metaNode)
|
|
}
|
|
createTOC = rc.TOC
|
|
ctx.TableOfContents = make([]markup.Header, 0, 100)
|
|
}
|
|
|
|
_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
if !entering {
|
|
return ast.WalkContinue, nil
|
|
}
|
|
|
|
switch v := n.(type) {
|
|
case *ast.Heading:
|
|
for _, attr := range v.Attributes() {
|
|
if _, ok := attr.Value.([]byte); !ok {
|
|
v.SetAttribute(attr.Name, []byte(fmt.Sprintf("%v", attr.Value)))
|
|
}
|
|
}
|
|
text := n.Text(reader.Source())
|
|
header := markup.Header{
|
|
Text: util.BytesToReadOnlyString(text),
|
|
Level: v.Level,
|
|
}
|
|
if id, found := v.AttributeString("id"); found {
|
|
header.ID = util.BytesToReadOnlyString(id.([]byte))
|
|
}
|
|
ctx.TableOfContents = append(ctx.TableOfContents, header)
|
|
case *ast.Image:
|
|
// Images need two things:
|
|
//
|
|
// 1. Their src needs to munged to be a real value
|
|
// 2. If they're not wrapped with a link they need a link wrapper
|
|
|
|
// Check if the destination is a real link
|
|
link := v.Destination
|
|
if len(link) > 0 && !markup.IsLink(link) {
|
|
prefix := pc.Get(urlPrefixKey).(string)
|
|
if pc.Get(isWikiKey).(bool) {
|
|
prefix = giteautil.URLJoin(prefix, "wiki", "raw")
|
|
}
|
|
prefix = strings.Replace(prefix, "/src/", "/media/", 1)
|
|
|
|
lnk := strings.TrimLeft(string(link), "/")
|
|
|
|
lnk = giteautil.URLJoin(prefix, lnk)
|
|
link = []byte(lnk)
|
|
}
|
|
v.Destination = link
|
|
|
|
parent := n.Parent()
|
|
// Create a link around image only if parent is not already a link
|
|
if _, ok := parent.(*ast.Link); !ok && parent != nil {
|
|
next := n.NextSibling()
|
|
|
|
// Create a link wrapper
|
|
wrap := ast.NewLink()
|
|
wrap.Destination = link
|
|
wrap.Title = v.Title
|
|
wrap.SetAttributeString("target", []byte("_blank"))
|
|
|
|
// Duplicate the current image node
|
|
image := ast.NewImage(ast.NewLink())
|
|
image.Destination = link
|
|
image.Title = v.Title
|
|
for _, attr := range v.Attributes() {
|
|
image.SetAttribute(attr.Name, attr.Value)
|
|
}
|
|
for child := v.FirstChild(); child != nil; {
|
|
next := child.NextSibling()
|
|
image.AppendChild(image, child)
|
|
child = next
|
|
}
|
|
|
|
// Append our duplicate image to the wrapper link
|
|
wrap.AppendChild(wrap, image)
|
|
|
|
// Wire in the next sibling
|
|
wrap.SetNextSibling(next)
|
|
|
|
// Replace the current node with the wrapper link
|
|
parent.ReplaceChild(parent, n, wrap)
|
|
|
|
// But most importantly ensure the next sibling is still on the old image too
|
|
v.SetNextSibling(next)
|
|
}
|
|
case *ast.Link:
|
|
// Links need their href to munged to be a real value
|
|
link := v.Destination
|
|
if len(link) > 0 && !markup.IsLink(link) &&
|
|
link[0] != '#' && !bytes.HasPrefix(link, byteMailto) {
|
|
// special case: this is not a link, a hash link or a mailto:, so it's a
|
|
// relative URL
|
|
lnk := string(link)
|
|
if pc.Get(isWikiKey).(bool) {
|
|
lnk = giteautil.URLJoin("wiki", lnk)
|
|
}
|
|
link = []byte(giteautil.URLJoin(pc.Get(urlPrefixKey).(string), lnk))
|
|
}
|
|
if len(link) > 0 && link[0] == '#' {
|
|
link = []byte("#user-content-" + string(link)[1:])
|
|
}
|
|
v.Destination = link
|
|
case *ast.List:
|
|
if v.HasChildren() {
|
|
children := make([]ast.Node, 0, v.ChildCount())
|
|
child := v.FirstChild()
|
|
for child != nil {
|
|
children = append(children, child)
|
|
child = child.NextSibling()
|
|
}
|
|
v.RemoveChildren(v)
|
|
|
|
for _, child := range children {
|
|
listItem := child.(*ast.ListItem)
|
|
if !child.HasChildren() || !child.FirstChild().HasChildren() {
|
|
v.AppendChild(v, child)
|
|
continue
|
|
}
|
|
taskCheckBox, ok := child.FirstChild().FirstChild().(*east.TaskCheckBox)
|
|
if !ok {
|
|
v.AppendChild(v, child)
|
|
continue
|
|
}
|
|
newChild := NewTaskCheckBoxListItem(listItem)
|
|
newChild.IsChecked = taskCheckBox.IsChecked
|
|
newChild.SetAttributeString("class", []byte("task-list-item"))
|
|
v.AppendChild(v, newChild)
|
|
}
|
|
}
|
|
case *ast.Text:
|
|
if v.SoftLineBreak() && !v.HardLineBreak() {
|
|
renderMetas := pc.Get(renderMetasKey).(map[string]string)
|
|
mode := renderMetas["mode"]
|
|
if mode != "document" {
|
|
v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInComments)
|
|
} else {
|
|
v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInDocuments)
|
|
}
|
|
}
|
|
}
|
|
return ast.WalkContinue, nil
|
|
})
|
|
|
|
if createTOC && len(ctx.TableOfContents) > 0 {
|
|
lang := rc.Lang
|
|
if len(lang) == 0 {
|
|
lang = setting.Langs[0]
|
|
}
|
|
tocNode := createTOCNode(ctx.TableOfContents, lang)
|
|
if tocNode != nil {
|
|
node.InsertBefore(node, firstChild, tocNode)
|
|
}
|
|
}
|
|
|
|
if len(rc.Lang) > 0 {
|
|
node.SetAttributeString("lang", []byte(rc.Lang))
|
|
}
|
|
}
|
|
|
|
type prefixedIDs struct {
|
|
values map[string]bool
|
|
}
|
|
|
|
// Generate generates a new element id.
|
|
func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
|
|
dft := []byte("id")
|
|
if kind == ast.KindHeading {
|
|
dft = []byte("heading")
|
|
}
|
|
return p.GenerateWithDefault(value, dft)
|
|
}
|
|
|
|
// Generate generates a new element id.
|
|
func (p *prefixedIDs) GenerateWithDefault(value, dft []byte) []byte {
|
|
result := common.CleanValue(value)
|
|
if len(result) == 0 {
|
|
result = dft
|
|
}
|
|
if !bytes.HasPrefix(result, []byte("user-content-")) {
|
|
result = append([]byte("user-content-"), result...)
|
|
}
|
|
if _, ok := p.values[util.BytesToReadOnlyString(result)]; !ok {
|
|
p.values[util.BytesToReadOnlyString(result)] = true
|
|
return result
|
|
}
|
|
for i := 1; ; i++ {
|
|
newResult := fmt.Sprintf("%s-%d", result, i)
|
|
if _, ok := p.values[newResult]; !ok {
|
|
p.values[newResult] = true
|
|
return []byte(newResult)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Put puts a given element id to the used ids table.
|
|
func (p *prefixedIDs) Put(value []byte) {
|
|
p.values[util.BytesToReadOnlyString(value)] = true
|
|
}
|
|
|
|
func newPrefixedIDs() *prefixedIDs {
|
|
return &prefixedIDs{
|
|
values: map[string]bool{},
|
|
}
|
|
}
|
|
|
|
// NewHTMLRenderer creates a HTMLRenderer to render
|
|
// in the gitea form.
|
|
func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
|
|
r := &HTMLRenderer{
|
|
Config: html.NewConfig(),
|
|
}
|
|
for _, opt := range opts {
|
|
opt.SetHTMLOption(&r.Config)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// HTMLRenderer is a renderer.NodeRenderer implementation that
|
|
// renders gitea specific features.
|
|
type HTMLRenderer struct {
|
|
html.Config
|
|
}
|
|
|
|
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
|
|
func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
|
reg.Register(ast.KindDocument, r.renderDocument)
|
|
reg.Register(KindDetails, r.renderDetails)
|
|
reg.Register(KindSummary, r.renderSummary)
|
|
reg.Register(KindIcon, r.renderIcon)
|
|
reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
|
|
reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
|
|
}
|
|
|
|
func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
n := node.(*ast.Document)
|
|
|
|
if val, has := n.AttributeString("lang"); has {
|
|
var err error
|
|
if entering {
|
|
_, err = w.WriteString("<div")
|
|
if err == nil {
|
|
_, err = w.WriteString(fmt.Sprintf(` lang=%q`, val))
|
|
}
|
|
if err == nil {
|
|
_, err = w.WriteRune('>')
|
|
}
|
|
} else {
|
|
_, err = w.WriteString("</div>")
|
|
}
|
|
|
|
if err != nil {
|
|
return ast.WalkStop, err
|
|
}
|
|
}
|
|
|
|
return ast.WalkContinue, nil
|
|
}
|
|
|
|
func (r *HTMLRenderer) renderDetails(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
var err error
|
|
if entering {
|
|
_, err = w.WriteString("<details>")
|
|
} else {
|
|
_, err = w.WriteString("</details>")
|
|
}
|
|
|
|
if err != nil {
|
|
return ast.WalkStop, err
|
|
}
|
|
|
|
return ast.WalkContinue, nil
|
|
}
|
|
|
|
func (r *HTMLRenderer) renderSummary(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
var err error
|
|
if entering {
|
|
_, err = w.WriteString("<summary>")
|
|
} else {
|
|
_, err = w.WriteString("</summary>")
|
|
}
|
|
|
|
if err != nil {
|
|
return ast.WalkStop, err
|
|
}
|
|
|
|
return ast.WalkContinue, nil
|
|
}
|
|
|
|
var validNameRE = regexp.MustCompile("^[a-z ]+$")
|
|
|
|
func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
if !entering {
|
|
return ast.WalkContinue, nil
|
|
}
|
|
|
|
n := node.(*Icon)
|
|
|
|
name := strings.TrimSpace(strings.ToLower(string(n.Name)))
|
|
|
|
if len(name) == 0 {
|
|
// skip this
|
|
return ast.WalkContinue, nil
|
|
}
|
|
|
|
if !validNameRE.MatchString(name) {
|
|
// skip this
|
|
return ast.WalkContinue, nil
|
|
}
|
|
|
|
var err error
|
|
_, err = w.WriteString(fmt.Sprintf(`<i class="icon %s"></i>`, name))
|
|
|
|
if err != nil {
|
|
return ast.WalkStop, err
|
|
}
|
|
|
|
return ast.WalkContinue, nil
|
|
}
|
|
|
|
func (r *HTMLRenderer) renderTaskCheckBoxListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
n := node.(*TaskCheckBoxListItem)
|
|
if entering {
|
|
if n.Attributes() != nil {
|
|
_, _ = w.WriteString("<li")
|
|
html.RenderAttributes(w, n, html.ListItemAttributeFilter)
|
|
_ = w.WriteByte('>')
|
|
} else {
|
|
_, _ = w.WriteString("<li>")
|
|
}
|
|
_, _ = w.WriteString(`<input type="checkbox" disabled=""`)
|
|
segments := node.FirstChild().Lines()
|
|
if segments.Len() > 0 {
|
|
segment := segments.At(0)
|
|
_, _ = w.WriteString(fmt.Sprintf(` data-source-position="%d"`, segment.Start))
|
|
}
|
|
if n.IsChecked {
|
|
_, _ = w.WriteString(` checked=""`)
|
|
}
|
|
if r.XHTML {
|
|
_, _ = w.WriteString(` />`)
|
|
} else {
|
|
_ = w.WriteByte('>')
|
|
}
|
|
fc := n.FirstChild()
|
|
if fc != nil {
|
|
if _, ok := fc.(*ast.TextBlock); !ok {
|
|
_ = w.WriteByte('\n')
|
|
}
|
|
}
|
|
} else {
|
|
_, _ = w.WriteString("</li>\n")
|
|
}
|
|
return ast.WalkContinue, nil
|
|
}
|
|
|
|
func (r *HTMLRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
return ast.WalkContinue, nil
|
|
}
|