* Add control for the rendering of the frontmatter * Add control to include a TOC * Add control to set language - allows control of ToC header and CJK glyph choice. Signed-off-by: Andrew Thornton art27@cantab.nettags/v1.21.12.1
@@ -124,6 +124,7 @@ require ( | |||
gopkg.in/ini.v1 v1.52.0 | |||
gopkg.in/ldap.v3 v3.0.2 | |||
gopkg.in/testfixtures.v2 v2.5.0 | |||
gopkg.in/yaml.v2 v2.2.8 | |||
mvdan.cc/xurls/v2 v2.1.0 | |||
strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251 | |||
xorm.io/builder v0.3.7 | |||
@@ -351,6 +351,27 @@ func (ctx *postProcessCtx) visitNode(node *html.Node, visitText bool) { | |||
visitText = false | |||
} else if node.Data == "code" || node.Data == "pre" { | |||
return | |||
} else if node.Data == "i" { | |||
for _, attr := range node.Attr { | |||
if attr.Key != "class" { | |||
continue | |||
} | |||
classes := strings.Split(attr.Val, " ") | |||
for i, class := range classes { | |||
if class == "icon" { | |||
classes[0], classes[i] = classes[i], classes[0] | |||
attr.Val = strings.Join(classes, " ") | |||
// Remove all children of icons | |||
child := node.FirstChild | |||
for child != nil { | |||
node.RemoveChild(child) | |||
child = node.FirstChild | |||
} | |||
break | |||
} | |||
} | |||
} | |||
} | |||
for n := node.FirstChild; n != nil; n = n.NextSibling { | |||
ctx.visitNode(n, visitText) | |||
@@ -0,0 +1,107 @@ | |||
// Copyright 2020 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 "github.com/yuin/goldmark/ast" | |||
// Details is a block that contains Summary and details | |||
type Details struct { | |||
ast.BaseBlock | |||
} | |||
// Dump implements Node.Dump . | |||
func (n *Details) Dump(source []byte, level int) { | |||
ast.DumpHelper(n, source, level, nil, nil) | |||
} | |||
// KindDetails is the NodeKind for Details | |||
var KindDetails = ast.NewNodeKind("Details") | |||
// Kind implements Node.Kind. | |||
func (n *Details) Kind() ast.NodeKind { | |||
return KindDetails | |||
} | |||
// NewDetails returns a new Paragraph node. | |||
func NewDetails() *Details { | |||
return &Details{ | |||
BaseBlock: ast.BaseBlock{}, | |||
} | |||
} | |||
// IsDetails returns true if the given node implements the Details interface, | |||
// otherwise false. | |||
func IsDetails(node ast.Node) bool { | |||
_, ok := node.(*Details) | |||
return ok | |||
} | |||
// Summary is a block that contains the summary of details block | |||
type Summary struct { | |||
ast.BaseBlock | |||
} | |||
// Dump implements Node.Dump . | |||
func (n *Summary) Dump(source []byte, level int) { | |||
ast.DumpHelper(n, source, level, nil, nil) | |||
} | |||
// KindSummary is the NodeKind for Summary | |||
var KindSummary = ast.NewNodeKind("Summary") | |||
// Kind implements Node.Kind. | |||
func (n *Summary) Kind() ast.NodeKind { | |||
return KindSummary | |||
} | |||
// NewSummary returns a new Summary node. | |||
func NewSummary() *Summary { | |||
return &Summary{ | |||
BaseBlock: ast.BaseBlock{}, | |||
} | |||
} | |||
// IsSummary returns true if the given node implements the Summary interface, | |||
// otherwise false. | |||
func IsSummary(node ast.Node) bool { | |||
_, ok := node.(*Summary) | |||
return ok | |||
} | |||
// Icon is an inline for a fomantic icon | |||
type Icon struct { | |||
ast.BaseInline | |||
Name []byte | |||
} | |||
// Dump implements Node.Dump . | |||
func (n *Icon) Dump(source []byte, level int) { | |||
m := map[string]string{} | |||
m["Name"] = string(n.Name) | |||
ast.DumpHelper(n, source, level, m, nil) | |||
} | |||
// KindIcon is the NodeKind for Icon | |||
var KindIcon = ast.NewNodeKind("Icon") | |||
// Kind implements Node.Kind. | |||
func (n *Icon) Kind() ast.NodeKind { | |||
return KindIcon | |||
} | |||
// NewIcon returns a new Paragraph node. | |||
func NewIcon(name string) *Icon { | |||
return &Icon{ | |||
BaseInline: ast.BaseInline{}, | |||
Name: []byte(name), | |||
} | |||
} | |||
// IsIcon returns true if the given node implements the Icon interface, | |||
// otherwise false. | |||
func IsIcon(node ast.Node) bool { | |||
_, ok := node.(*Icon) | |||
return ok | |||
} |
@@ -7,12 +7,16 @@ package markdown | |||
import ( | |||
"bytes" | |||
"fmt" | |||
"regexp" | |||
"strings" | |||
"code.gitea.io/gitea/modules/log" | |||
"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" | |||
meta "github.com/yuin/goldmark-meta" | |||
"github.com/yuin/goldmark/ast" | |||
east "github.com/yuin/goldmark/extension/ast" | |||
"github.com/yuin/goldmark/parser" | |||
@@ -24,17 +28,56 @@ import ( | |||
var byteMailto = []byte("mailto:") | |||
// GiteaASTTransformer is a default transformer of the goldmark tree. | |||
type GiteaASTTransformer struct{} | |||
// Header holds the data about a header. | |||
type Header struct { | |||
Level int | |||
Text string | |||
ID string | |||
} | |||
// ASTTransformer is a default transformer of the goldmark tree. | |||
type ASTTransformer struct{} | |||
// Transform transforms the given AST tree. | |||
func (g *GiteaASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { | |||
func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { | |||
metaData := meta.GetItems(pc) | |||
firstChild := node.FirstChild() | |||
createTOC := false | |||
var toc = []Header{} | |||
rc := &RenderConfig{ | |||
Meta: "table", | |||
Icon: "table", | |||
Lang: "", | |||
} | |||
if metaData != nil { | |||
rc.ToRenderConfig(metaData) | |||
metaNode := rc.toMetaNode(metaData) | |||
if metaNode != nil { | |||
node.InsertBefore(node, firstChild, metaNode) | |||
} | |||
createTOC = rc.TOC | |||
toc = make([]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: | |||
if createTOC { | |||
text := n.Text(reader.Source()) | |||
header := Header{ | |||
Text: util.BytesToReadOnlyString(text), | |||
Level: v.Level, | |||
} | |||
if id, found := v.AttributeString("id"); found { | |||
header.ID = util.BytesToReadOnlyString(id.([]byte)) | |||
} | |||
toc = append(toc, header) | |||
} | |||
case *ast.Image: | |||
// Images need two things: | |||
// | |||
@@ -91,6 +134,21 @@ func (g *GiteaASTTransformer) Transform(node *ast.Document, reader text.Reader, | |||
} | |||
return ast.WalkContinue, nil | |||
}) | |||
if createTOC && len(toc) > 0 { | |||
lang := rc.Lang | |||
if len(lang) == 0 { | |||
lang = setting.Langs[0] | |||
} | |||
tocNode := createTOCNode(toc, lang) | |||
if tocNode != nil { | |||
node.InsertBefore(node, firstChild, tocNode) | |||
} | |||
} | |||
if len(rc.Lang) > 0 { | |||
node.SetAttributeString("lang", []byte(rc.Lang)) | |||
} | |||
} | |||
type prefixedIDs struct { | |||
@@ -139,10 +197,10 @@ func newPrefixedIDs() *prefixedIDs { | |||
} | |||
} | |||
// NewTaskCheckBoxHTMLRenderer creates a TaskCheckBoxHTMLRenderer to render tasklists | |||
// NewHTMLRenderer creates a HTMLRenderer to render | |||
// in the gitea form. | |||
func NewTaskCheckBoxHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { | |||
r := &TaskCheckBoxHTMLRenderer{ | |||
func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { | |||
r := &HTMLRenderer{ | |||
Config: html.NewConfig(), | |||
} | |||
for _, opt := range opts { | |||
@@ -151,19 +209,109 @@ func NewTaskCheckBoxHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { | |||
return r | |||
} | |||
// TaskCheckBoxHTMLRenderer is a renderer.NodeRenderer implementation that | |||
// renders checkboxes in list items. | |||
// Overrides the default goldmark one to present the gitea format | |||
type TaskCheckBoxHTMLRenderer struct { | |||
// HTMLRenderer is a renderer.NodeRenderer implementation that | |||
// renders gitea specific features. | |||
type HTMLRenderer struct { | |||
html.Config | |||
} | |||
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. | |||
func (r *TaskCheckBoxHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { | |||
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(east.KindTaskCheckBox, r.renderTaskCheckBox) | |||
} | |||
func (r *TaskCheckBoxHTMLRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { | |||
func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { | |||
log.Info("renderDocument %v", node) | |||
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) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { | |||
if !entering { | |||
return ast.WalkContinue, nil | |||
} | |||
@@ -54,13 +54,13 @@ func RenderRaw(body []byte, urlPrefix string, wikiMarkdown bool) []byte { | |||
extension.Ellipsis: nil, | |||
}), | |||
), | |||
meta.New(meta.WithTable()), | |||
meta.Meta, | |||
), | |||
goldmark.WithParserOptions( | |||
parser.WithAttribute(), | |||
parser.WithAutoHeadingID(), | |||
parser.WithASTTransformers( | |||
util.Prioritized(&GiteaASTTransformer{}, 10000), | |||
util.Prioritized(&ASTTransformer{}, 10000), | |||
), | |||
), | |||
goldmark.WithRendererOptions( | |||
@@ -71,7 +71,7 @@ func RenderRaw(body []byte, urlPrefix string, wikiMarkdown bool) []byte { | |||
// Override the original Tasklist renderer! | |||
converter.Renderer().AddOptions( | |||
renderer.WithNodeRenderers( | |||
util.Prioritized(NewTaskCheckBoxHTMLRenderer(), 1000), | |||
util.Prioritized(NewHTMLRenderer(), 10), | |||
), | |||
) | |||
@@ -85,7 +85,6 @@ func RenderRaw(body []byte, urlPrefix string, wikiMarkdown bool) []byte { | |||
if err := converter.Convert(giteautil.NormalizeEOL(body), &buf, parser.WithContext(pc)); err != nil { | |||
log.Error("Unable to render: %v", err) | |||
} | |||
return markup.SanitizeReader(&buf).Bytes() | |||
} | |||
@@ -0,0 +1,163 @@ | |||
// Copyright 2020 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 ( | |||
"fmt" | |||
"strings" | |||
"github.com/yuin/goldmark/ast" | |||
east "github.com/yuin/goldmark/extension/ast" | |||
"gopkg.in/yaml.v2" | |||
) | |||
// RenderConfig represents rendering configuration for this file | |||
type RenderConfig struct { | |||
Meta string | |||
Icon string | |||
TOC bool | |||
Lang string | |||
} | |||
// ToRenderConfig converts a yaml.MapSlice to a RenderConfig | |||
func (rc *RenderConfig) ToRenderConfig(meta yaml.MapSlice) { | |||
if meta == nil { | |||
return | |||
} | |||
found := false | |||
var giteaMetaControl yaml.MapItem | |||
for _, item := range meta { | |||
strKey, ok := item.Key.(string) | |||
if !ok { | |||
continue | |||
} | |||
strKey = strings.TrimSpace(strings.ToLower(strKey)) | |||
switch strKey { | |||
case "gitea": | |||
giteaMetaControl = item | |||
found = true | |||
case "include_toc": | |||
val, ok := item.Value.(bool) | |||
if !ok { | |||
continue | |||
} | |||
rc.TOC = val | |||
case "lang": | |||
val, ok := item.Value.(string) | |||
if !ok { | |||
continue | |||
} | |||
val = strings.TrimSpace(val) | |||
if len(val) == 0 { | |||
continue | |||
} | |||
rc.Lang = val | |||
} | |||
} | |||
if found { | |||
switch v := giteaMetaControl.Value.(type) { | |||
case string: | |||
switch v { | |||
case "none": | |||
rc.Meta = "none" | |||
case "table": | |||
rc.Meta = "table" | |||
default: // "details" | |||
rc.Meta = "details" | |||
} | |||
case yaml.MapSlice: | |||
for _, item := range v { | |||
strKey, ok := item.Key.(string) | |||
if !ok { | |||
continue | |||
} | |||
strKey = strings.TrimSpace(strings.ToLower(strKey)) | |||
switch strKey { | |||
case "meta": | |||
val, ok := item.Value.(string) | |||
if !ok { | |||
continue | |||
} | |||
switch strings.TrimSpace(strings.ToLower(val)) { | |||
case "none": | |||
rc.Meta = "none" | |||
case "table": | |||
rc.Meta = "table" | |||
default: // "details" | |||
rc.Meta = "details" | |||
} | |||
case "details_icon": | |||
val, ok := item.Value.(string) | |||
if !ok { | |||
continue | |||
} | |||
rc.Icon = strings.TrimSpace(strings.ToLower(val)) | |||
case "include_toc": | |||
val, ok := item.Value.(bool) | |||
if !ok { | |||
continue | |||
} | |||
rc.TOC = val | |||
case "lang": | |||
val, ok := item.Value.(string) | |||
if !ok { | |||
continue | |||
} | |||
val = strings.TrimSpace(val) | |||
if len(val) == 0 { | |||
continue | |||
} | |||
rc.Lang = val | |||
} | |||
} | |||
} | |||
} | |||
} | |||
func (rc *RenderConfig) toMetaNode(meta yaml.MapSlice) ast.Node { | |||
switch rc.Meta { | |||
case "table": | |||
return metaToTable(meta) | |||
case "details": | |||
return metaToDetails(meta, rc.Icon) | |||
default: | |||
return nil | |||
} | |||
} | |||
func metaToTable(meta yaml.MapSlice) ast.Node { | |||
table := east.NewTable() | |||
alignments := []east.Alignment{} | |||
for range meta { | |||
alignments = append(alignments, east.AlignNone) | |||
} | |||
row := east.NewTableRow(alignments) | |||
for _, item := range meta { | |||
cell := east.NewTableCell() | |||
cell.AppendChild(cell, ast.NewString([]byte(fmt.Sprintf("%v", item.Key)))) | |||
row.AppendChild(row, cell) | |||
} | |||
table.AppendChild(table, east.NewTableHeader(row)) | |||
row = east.NewTableRow(alignments) | |||
for _, item := range meta { | |||
cell := east.NewTableCell() | |||
cell.AppendChild(cell, ast.NewString([]byte(fmt.Sprintf("%v", item.Value)))) | |||
row.AppendChild(row, cell) | |||
} | |||
table.AppendChild(table, row) | |||
return table | |||
} | |||
func metaToDetails(meta yaml.MapSlice, icon string) ast.Node { | |||
details := NewDetails() | |||
summary := NewSummary() | |||
summary.AppendChild(summary, NewIcon(icon)) | |||
details.AppendChild(details, summary) | |||
details.AppendChild(details, metaToTable(meta)) | |||
return details | |||
} |
@@ -0,0 +1,49 @@ | |||
// Copyright 2020 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 ( | |||
"fmt" | |||
"net/url" | |||
"github.com/unknwon/i18n" | |||
"github.com/yuin/goldmark/ast" | |||
) | |||
func createTOCNode(toc []Header, lang string) ast.Node { | |||
details := NewDetails() | |||
summary := NewSummary() | |||
summary.AppendChild(summary, ast.NewString([]byte(i18n.Tr(lang, "toc")))) | |||
details.AppendChild(details, summary) | |||
ul := ast.NewList('-') | |||
details.AppendChild(details, ul) | |||
currentLevel := 6 | |||
for _, header := range toc { | |||
if header.Level < currentLevel { | |||
currentLevel = header.Level | |||
} | |||
} | |||
for _, header := range toc { | |||
for currentLevel > header.Level { | |||
ul = ul.Parent().(*ast.List) | |||
currentLevel-- | |||
} | |||
for currentLevel < header.Level { | |||
newL := ast.NewList('-') | |||
ul.AppendChild(ul, newL) | |||
currentLevel++ | |||
ul = newL | |||
} | |||
li := ast.NewListItem(currentLevel * 2) | |||
a := ast.NewLink() | |||
a.Destination = []byte(fmt.Sprintf("#%s", url.PathEscape(header.ID))) | |||
a.AppendChild(a, ast.NewString([]byte(header.Text))) | |||
li.AppendChild(li, a) | |||
ul.AppendChild(ul, li) | |||
} | |||
return details | |||
} |
@@ -56,6 +56,9 @@ func ReplaceSanitizer() { | |||
// Allow classes for task lists | |||
sanitizer.policy.AllowAttrs("class").Matching(regexp.MustCompile(`task-list`)).OnElements("ul") | |||
// Allow icons | |||
sanitizer.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^icon(\s+[\p{L}\p{N}_-]+)+$`)).OnElements("i", "span") | |||
// Allow generally safe attributes | |||
generalSafeAttrs := []string{"abbr", "accept", "accept-charset", | |||
"accesskey", "action", "align", "alt", | |||
@@ -19,6 +19,7 @@ create_new = Create… | |||
user_profile_and_more = Profile and Settings… | |||
signed_in_as = Signed in as | |||
enable_javascript = This website works better with JavaScript. | |||
toc = Table of Contents | |||
username = Username | |||
email = Email Address | |||
@@ -844,6 +844,7 @@ gopkg.in/toqueteos/substring.v1 | |||
# gopkg.in/warnings.v0 v0.1.2 | |||
gopkg.in/warnings.v0 | |||
# gopkg.in/yaml.v2 v2.2.8 | |||
## explicit | |||
gopkg.in/yaml.v2 | |||
# mvdan.cc/xurls/v2 v2.1.0 | |||
## explicit | |||