mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-11-04 12:53:43 +01:00 
			
		
		
		
	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:
		
							parent
							
								
									c1c07e533c
								
							
						
					
					
						commit
						ac88f21ecc
					
				@ -27,13 +27,6 @@ import (
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
type ASTTransformer struct{}
 | 
			
		||||
 | 
			
		||||
@ -42,12 +35,13 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
 | 
			
		||||
	metaData := meta.GetItems(pc)
 | 
			
		||||
	firstChild := node.FirstChild()
 | 
			
		||||
	createTOC := false
 | 
			
		||||
	toc := []Header{}
 | 
			
		||||
	ctx := pc.Get(renderContextKey).(*markup.RenderContext)
 | 
			
		||||
	rc := &RenderConfig{
 | 
			
		||||
		Meta: "table",
 | 
			
		||||
		Icon: "table",
 | 
			
		||||
		Lang: "",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if metaData != nil {
 | 
			
		||||
		rc.ToRenderConfig(metaData)
 | 
			
		||||
 | 
			
		||||
@ -56,7 +50,7 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
 | 
			
		||||
			node.InsertBefore(node, firstChild, metaNode)
 | 
			
		||||
		}
 | 
			
		||||
		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) {
 | 
			
		||||
@ -66,23 +60,20 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
 | 
			
		||||
 | 
			
		||||
		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)
 | 
			
		||||
			} else {
 | 
			
		||||
				for _, attr := range v.Attributes() {
 | 
			
		||||
					if _, ok := attr.Value.([]byte); !ok {
 | 
			
		||||
						v.SetAttribute(attr.Name, []byte(fmt.Sprintf("%v", attr.Value)))
 | 
			
		||||
					}
 | 
			
		||||
			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:
 | 
			
		||||
			//
 | 
			
		||||
@ -199,12 +190,12 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
 | 
			
		||||
		return ast.WalkContinue, nil
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if createTOC && len(toc) > 0 {
 | 
			
		||||
	if createTOC && len(ctx.TableOfContents) > 0 {
 | 
			
		||||
		lang := rc.Lang
 | 
			
		||||
		if len(lang) == 0 {
 | 
			
		||||
			lang = setting.Langs[0]
 | 
			
		||||
		}
 | 
			
		||||
		tocNode := createTOCNode(toc, lang)
 | 
			
		||||
		tocNode := createTOCNode(ctx.TableOfContents, lang)
 | 
			
		||||
		if tocNode != nil {
 | 
			
		||||
			node.InsertBefore(node, firstChild, tocNode)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -34,9 +34,10 @@ var (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	urlPrefixKey   = parser.NewContextKey()
 | 
			
		||||
	isWikiKey      = parser.NewContextKey()
 | 
			
		||||
	renderMetasKey = parser.NewContextKey()
 | 
			
		||||
	urlPrefixKey     = parser.NewContextKey()
 | 
			
		||||
	isWikiKey        = parser.NewContextKey()
 | 
			
		||||
	renderMetasKey   = parser.NewContextKey()
 | 
			
		||||
	renderContextKey = parser.NewContextKey()
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type limitWriter struct {
 | 
			
		||||
@ -67,6 +68,7 @@ func newParserContext(ctx *markup.RenderContext) parser.Context {
 | 
			
		||||
	pc.Set(urlPrefixKey, ctx.URLPrefix)
 | 
			
		||||
	pc.Set(isWikiKey, ctx.IsWiki)
 | 
			
		||||
	pc.Set(renderMetasKey, ctx.Metas)
 | 
			
		||||
	pc.Set(renderContextKey, ctx)
 | 
			
		||||
	return pc
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -8,12 +8,13 @@ import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/url"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/modules/markup"
 | 
			
		||||
	"code.gitea.io/gitea/modules/translation/i18n"
 | 
			
		||||
 | 
			
		||||
	"github.com/yuin/goldmark/ast"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func createTOCNode(toc []Header, lang string) ast.Node {
 | 
			
		||||
func createTOCNode(toc []markup.Header, lang string) ast.Node {
 | 
			
		||||
	details := NewDetails()
 | 
			
		||||
	summary := NewSummary()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
type RenderContext struct {
 | 
			
		||||
	Ctx           context.Context
 | 
			
		||||
	Filename      string
 | 
			
		||||
	Type          string
 | 
			
		||||
	IsWiki        bool
 | 
			
		||||
	URLPrefix     string
 | 
			
		||||
	Metas         map[string]string
 | 
			
		||||
	DefaultLink   string
 | 
			
		||||
	GitRepo       *git.Repository
 | 
			
		||||
	ShaExistCache map[string]bool
 | 
			
		||||
	cancelFn      func()
 | 
			
		||||
	Ctx             context.Context
 | 
			
		||||
	Filename        string
 | 
			
		||||
	Type            string
 | 
			
		||||
	IsWiki          bool
 | 
			
		||||
	URLPrefix       string
 | 
			
		||||
	Metas           map[string]string
 | 
			
		||||
	DefaultLink     string
 | 
			
		||||
	GitRepo         *git.Repository
 | 
			
		||||
	ShaExistCache   map[string]bool
 | 
			
		||||
	cancelFn        func()
 | 
			
		||||
	TableOfContents []Header
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Cancel runs any cleanup functions that have been registered for this Ctx
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,7 @@ import (
 | 
			
		||||
	"reflect"
 | 
			
		||||
	"regexp"
 | 
			
		||||
	"runtime"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	texttmpl "text/template"
 | 
			
		||||
	"time"
 | 
			
		||||
@ -390,6 +391,66 @@ func NewFuncMap() []template.FuncMap {
 | 
			
		||||
		"Join":        strings.Join,
 | 
			
		||||
		"QueryEscape": url.QueryEscape,
 | 
			
		||||
		"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
 | 
			
		||||
		},
 | 
			
		||||
	}}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -280,6 +280,8 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
 | 
			
		||||
		ctx.Data["footerPresent"] = false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ctx.Data["toc"] = rctx.TableOfContents
 | 
			
		||||
 | 
			
		||||
	// get commit count - wiki revisions
 | 
			
		||||
	commitsCount, _ := wikiRepo.FileCommitsCount("master", pageFilename)
 | 
			
		||||
	ctx.Data["CommitCount"] = commitsCount
 | 
			
		||||
 | 
			
		||||
@ -64,20 +64,39 @@
 | 
			
		||||
				<p>{{.FormatWarning}}</p>
 | 
			
		||||
			</div>
 | 
			
		||||
		{{end}}
 | 
			
		||||
		<div class="ui {{if .sidebarPresent}}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}}grid equal width{{end}}" style="margin-top: 1rem;">
 | 
			
		||||
			<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" $}}
 | 
			
		||||
				{{.content | Safe}}
 | 
			
		||||
			</div>
 | 
			
		||||
			{{if .sidebarPresent}}
 | 
			
		||||
			{{if or .sidebarPresent .toc}}
 | 
			
		||||
			<div class="column" style="padding-top: 0;">
 | 
			
		||||
				<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>
 | 
			
		||||
				{{if .toc}}
 | 
			
		||||
					<div class="ui segment wiki-content-toc">
 | 
			
		||||
						<details open>
 | 
			
		||||
							<summary>
 | 
			
		||||
								<div class="ui header">{{.i18n.Tr "toc"}}</div>
 | 
			
		||||
							</summary>
 | 
			
		||||
							{{$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>
 | 
			
		||||
			{{end}}
 | 
			
		||||
		</div>
 | 
			
		||||
 | 
			
		||||
@ -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 */
 | 
			
		||||
.ui.buttons .unescape-button {
 | 
			
		||||
  border-top-right-radius: .28571429rem;
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user