mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00: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:
		| @@ -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() { | ||||||
|  | 				if _, ok := attr.Value.([]byte); !ok { | ||||||
|  | 					v.SetAttribute(attr.Name, []byte(fmt.Sprintf("%v", attr.Value))) | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
| 			text := n.Text(reader.Source()) | 			text := n.Text(reader.Source()) | ||||||
| 				header := Header{ | 			header := markup.Header{ | ||||||
| 				Text:  util.BytesToReadOnlyString(text), | 				Text:  util.BytesToReadOnlyString(text), | ||||||
| 				Level: v.Level, | 				Level: v.Level, | ||||||
| 			} | 			} | ||||||
| 			if id, found := v.AttributeString("id"); found { | 			if id, found := v.AttributeString("id"); found { | ||||||
| 				header.ID = util.BytesToReadOnlyString(id.([]byte)) | 				header.ID = util.BytesToReadOnlyString(id.([]byte)) | ||||||
| 			} | 			} | ||||||
| 				toc = append(toc, header) | 			ctx.TableOfContents = append(ctx.TableOfContents, header) | ||||||
| 			} else { |  | ||||||
| 				for _, attr := range v.Attributes() { |  | ||||||
| 					if _, ok := attr.Value.([]byte); !ok { |  | ||||||
| 						v.SetAttribute(attr.Name, []byte(fmt.Sprintf("%v", attr.Value))) |  | ||||||
| 					} |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		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) | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -37,6 +37,7 @@ 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 | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -33,6 +33,13 @@ 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 | ||||||
| @@ -45,6 +52,7 @@ type RenderContext struct { | |||||||
| 	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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|  | 		}, | ||||||
| 	}} | 	}} | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -64,13 +64,31 @@ | |||||||
| 				<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;"> | ||||||
|  | 				{{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"> | 					<div class="ui segment wiki-content-sidebar"> | ||||||
| 						{{if and .CanWriteWiki (not .Repository.IsMirror)}} | 						{{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> | 							<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> | ||||||
| @@ -78,6 +96,7 @@ | |||||||
| 						{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .sidebarEscapeStatus "root" $}} | 						{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .sidebarEscapeStatus "root" $}} | ||||||
| 						{{.sidebarContent | Safe}} | 						{{.sidebarContent | Safe}} | ||||||
| 					</div> | 					</div> | ||||||
|  | 				{{end}} | ||||||
| 			</div> | 			</div> | ||||||
| 			{{end}} | 			{{end}} | ||||||
| 		</div> | 		</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 */ | /* 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; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 zeripath
					zeripath