Browse Source

Use markdown frontmatter to provide Table of contents, language and frontmatter rendering (#11047)

* 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.net
tags/v1.21.12.1
zeripath GitHub 5 years ago
parent
commit
812cfd0ad9
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 509 additions and 16 deletions
  1. +1
    -0
      go.mod
  2. +21
    -0
      modules/markup/html.go
  3. +107
    -0
      modules/markup/markdown/ast.go
  4. +160
    -12
      modules/markup/markdown/goldmark.go
  5. +3
    -4
      modules/markup/markdown/markdown.go
  6. +163
    -0
      modules/markup/markdown/renderconfig.go
  7. +49
    -0
      modules/markup/markdown/toc.go
  8. +3
    -0
      modules/markup/sanitizer.go
  9. +1
    -0
      options/locale/locale_en-US.ini
  10. +1
    -0
      vendor/modules.txt

+ 1
- 0
go.mod View File

@@ -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


+ 21
- 0
modules/markup/html.go View File

@@ -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)


+ 107
- 0
modules/markup/markdown/ast.go View File

@@ -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
}

+ 160
- 12
modules/markup/markdown/goldmark.go View File

@@ -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
}


+ 3
- 4
modules/markup/markdown/markdown.go View File

@@ -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()
}



+ 163
- 0
modules/markup/markdown/renderconfig.go View File

@@ -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
}

+ 49
- 0
modules/markup/markdown/toc.go View File

@@ -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
}

+ 3
- 0
modules/markup/sanitizer.go View File

@@ -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",


+ 1
- 0
options/locale/locale_en-US.ini View File

@@ -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


+ 1
- 0
vendor/modules.txt View File

@@ -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


Loading…
Cancel
Save