Automatically render wiki TOC (#19873)

Automatically add sidebar in the wiki view containing a TOC for the wiki page.
Make the TOC collapsable

Signed-off-by: Andrew Thornton <art27@cantab.net>
This commit is contained in:
zeripath 2022-06-08 09:59:16 +01:00 committed by GitHub
parent c1c07e533c
commit ac88f21ecc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 146 additions and 50 deletions

View file

@ -27,13 +27,6 @@ import (
var byteMailto = []byte("mailto:") var byteMailto = []byte("mailto:")
// 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. // ASTTransformer is a default transformer of the goldmark tree.
type ASTTransformer struct{} type ASTTransformer struct{}
@ -42,12 +35,13 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
metaData := meta.GetItems(pc) metaData := meta.GetItems(pc)
firstChild := node.FirstChild() firstChild := node.FirstChild()
createTOC := false createTOC := false
toc := []Header{} ctx := pc.Get(renderContextKey).(*markup.RenderContext)
rc := &RenderConfig{ rc := &RenderConfig{
Meta: "table", Meta: "table",
Icon: "table", Icon: "table",
Lang: "", Lang: "",
} }
if metaData != nil { if metaData != nil {
rc.ToRenderConfig(metaData) rc.ToRenderConfig(metaData)
@ -56,7 +50,7 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
node.InsertBefore(node, firstChild, metaNode) node.InsertBefore(node, firstChild, metaNode)
} }
createTOC = rc.TOC createTOC = rc.TOC
toc = make([]Header, 0, 100) ctx.TableOfContents = make([]markup.Header, 0, 100)
} }
_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
@ -66,23 +60,20 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
switch v := n.(type) { switch v := n.(type) {
case *ast.Heading: case *ast.Heading:
if createTOC { for _, attr := range v.Attributes() {
text := n.Text(reader.Source()) if _, ok := attr.Value.([]byte); !ok {
header := Header{ v.SetAttribute(attr.Name, []byte(fmt.Sprintf("%v", attr.Value)))
Text: util.BytesToReadOnlyString(text),
Level: v.Level,
}
if id, found := v.AttributeString("id"); found {
header.ID = util.BytesToReadOnlyString(id.([]byte))
}
toc = append(toc, header)
} else {
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: case *ast.Image:
// Images need two things: // Images need two things:
// //
@ -199,12 +190,12 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
return ast.WalkContinue, nil return ast.WalkContinue, nil
}) })
if createTOC && len(toc) > 0 { if createTOC && len(ctx.TableOfContents) > 0 {
lang := rc.Lang lang := rc.Lang
if len(lang) == 0 { if len(lang) == 0 {
lang = setting.Langs[0] lang = setting.Langs[0]
} }
tocNode := createTOCNode(toc, lang) tocNode := createTOCNode(ctx.TableOfContents, lang)
if tocNode != nil { if tocNode != nil {
node.InsertBefore(node, firstChild, tocNode) node.InsertBefore(node, firstChild, tocNode)
} }

View file

@ -34,9 +34,10 @@ var (
) )
var ( var (
urlPrefixKey = parser.NewContextKey() urlPrefixKey = parser.NewContextKey()
isWikiKey = parser.NewContextKey() isWikiKey = parser.NewContextKey()
renderMetasKey = parser.NewContextKey() renderMetasKey = parser.NewContextKey()
renderContextKey = parser.NewContextKey()
) )
type limitWriter struct { type limitWriter struct {
@ -67,6 +68,7 @@ func newParserContext(ctx *markup.RenderContext) parser.Context {
pc.Set(urlPrefixKey, ctx.URLPrefix) pc.Set(urlPrefixKey, ctx.URLPrefix)
pc.Set(isWikiKey, ctx.IsWiki) pc.Set(isWikiKey, ctx.IsWiki)
pc.Set(renderMetasKey, ctx.Metas) pc.Set(renderMetasKey, ctx.Metas)
pc.Set(renderContextKey, ctx)
return pc return pc
} }

View file

@ -8,12 +8,13 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/translation/i18n" "code.gitea.io/gitea/modules/translation/i18n"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
) )
func createTOCNode(toc []Header, lang string) ast.Node { func createTOCNode(toc []markup.Header, lang string) ast.Node {
details := NewDetails() details := NewDetails()
summary := NewSummary() summary := NewSummary()

View file

@ -33,18 +33,26 @@ func Init() {
} }
} }
// Header holds the data about a header.
type Header struct {
Level int
Text string
ID string
}
// RenderContext represents a render context // RenderContext represents a render context
type RenderContext struct { type RenderContext struct {
Ctx context.Context Ctx context.Context
Filename string Filename string
Type string Type string
IsWiki bool IsWiki bool
URLPrefix string URLPrefix string
Metas map[string]string Metas map[string]string
DefaultLink string DefaultLink string
GitRepo *git.Repository GitRepo *git.Repository
ShaExistCache map[string]bool ShaExistCache map[string]bool
cancelFn func() cancelFn func()
TableOfContents []Header
} }
// Cancel runs any cleanup functions that have been registered for this Ctx // Cancel runs any cleanup functions that have been registered for this Ctx

View file

@ -18,6 +18,7 @@ import (
"reflect" "reflect"
"regexp" "regexp"
"runtime" "runtime"
"strconv"
"strings" "strings"
texttmpl "text/template" texttmpl "text/template"
"time" "time"
@ -390,6 +391,66 @@ func NewFuncMap() []template.FuncMap {
"Join": strings.Join, "Join": strings.Join,
"QueryEscape": url.QueryEscape, "QueryEscape": url.QueryEscape,
"DotEscape": DotEscape, "DotEscape": DotEscape,
"Iterate": func(arg interface{}) (items []uint64) {
count := uint64(0)
switch val := arg.(type) {
case uint64:
count = val
case *uint64:
count = *val
case int64:
if val < 0 {
val = 0
}
count = uint64(val)
case *int64:
if *val < 0 {
*val = 0
}
count = uint64(*val)
case int:
if val < 0 {
val = 0
}
count = uint64(val)
case *int:
if *val < 0 {
*val = 0
}
count = uint64(*val)
case uint:
count = uint64(val)
case *uint:
count = uint64(*val)
case int32:
if val < 0 {
val = 0
}
count = uint64(val)
case *int32:
if *val < 0 {
*val = 0
}
count = uint64(*val)
case uint32:
count = uint64(val)
case *uint32:
count = uint64(*val)
case string:
cnt, _ := strconv.ParseInt(val, 10, 64)
if cnt < 0 {
cnt = 0
}
count = uint64(cnt)
}
if count <= 0 {
return items
}
for i := uint64(0); i < count; i++ {
items = append(items, i)
}
return items
},
}} }}
} }

View file

@ -280,6 +280,8 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
ctx.Data["footerPresent"] = false ctx.Data["footerPresent"] = false
} }
ctx.Data["toc"] = rctx.TableOfContents
// get commit count - wiki revisions // get commit count - wiki revisions
commitsCount, _ := wikiRepo.FileCommitsCount("master", pageFilename) commitsCount, _ := wikiRepo.FileCommitsCount("master", pageFilename)
ctx.Data["CommitCount"] = commitsCount ctx.Data["CommitCount"] = commitsCount

View file

@ -64,20 +64,39 @@
<p>{{.FormatWarning}}</p> <p>{{.FormatWarning}}</p>
</div> </div>
{{end}} {{end}}
<div class="ui {{if .sidebarPresent}}grid equal width{{end}}" style="margin-top: 1rem;"> <div class="ui {{if or .sidebarPresent .toc}}grid equal width{{end}}" style="margin-top: 1rem;">
<div class="ui {{if .sidebarPresent}}eleven wide column{{end}} segment markup wiki-content-main"> <div class="ui {{if or .sidebarPresent .toc}}eleven wide column{{end}} segment markup wiki-content-main">
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}} {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}}
{{.content | Safe}} {{.content | Safe}}
</div> </div>
{{if .sidebarPresent}} {{if or .sidebarPresent .toc}}
<div class="column" style="padding-top: 0;"> <div class="column" style="padding-top: 0;">
<div class="ui segment wiki-content-sidebar"> {{if .toc}}
{{if and .CanWriteWiki (not .Repository.IsMirror)}} <div class="ui segment wiki-content-toc">
<a class="ui right floated muted" href="{{.RepoLink}}/wiki/_Sidebar?action=_edit" aria-label="{{.i18n.Tr "repo.wiki.edit_page_button"}}">{{svg "octicon-pencil"}}</a> <details open>
{{end}} <summary>
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .sidebarEscapeStatus "root" $}} <div class="ui header">{{.i18n.Tr "toc"}}</div>
{{.sidebarContent | Safe}} </summary>
</div> {{$level := 0}}
{{range .toc}}
{{if lt $level .Level}}{{range Iterate (Subtract .Level $level)}}<ul>{{end}}{{end}}
{{if gt $level .Level}}{{range Iterate (Subtract $level .Level)}}</ul>{{end}}{{end}}
{{$level = .Level}}
<li><a href="#{{.ID}}">{{.Text}}</a></li>
{{end}}
{{range Iterate $level}}</ul>{{end}}
</details>
</div>
{{end}}
{{if .sidebarPresent}}
<div class="ui segment wiki-content-sidebar">
{{if and .CanWriteWiki (not .Repository.IsMirror)}}
<a class="ui right floated muted" href="{{.RepoLink}}/wiki/_Sidebar?action=_edit" aria-label="{{.i18n.Tr "repo.wiki.edit_page_button"}}">{{svg "octicon-pencil"}}</a>
{{end}}
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .sidebarEscapeStatus "root" $}}
{{.sidebarContent | Safe}}
</div>
{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>

View file

@ -3088,6 +3088,18 @@ td.blob-excerpt {
} }
} }
.wiki-content-toc {
> ul > li {
margin-bottom: 4px;
}
ul {
margin: 0;
list-style: none;
padding-left: 1em;
}
}
/* fomantic's last-child selector does not work with hidden last child */ /* fomantic's last-child selector does not work with hidden last child */
.ui.buttons .unescape-button { .ui.buttons .unescape-button {
border-top-right-radius: .28571429rem; border-top-right-radius: .28571429rem;