Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
19fa68a
feat: add TOC sidebar for README and Markdown files
hamkido Dec 26, 2025
098321f
fix: use CSS variable for font-weight in TOC sidebar
hamkido Dec 26, 2025
f543ab2
Merge branch 'main' into main
hamkido Dec 27, 2025
1348cb0
Merge branch 'go-gitea:main' into main
hamkido Dec 28, 2025
2d7b11e
feat: add TOC sidebar for README and Markdown files
hamkido Dec 28, 2025
e7fb9af
refactor: enhance TOC position updates with ResizeObserver and Inters…
hamkido Dec 28, 2025
7386cc0
refactor: rename TOC to sidebar and update related functionality
hamkido Jan 1, 2026
694d551
refactor: improve sidebar positioning and visibility logic
hamkido Jan 1, 2026
ed9eb57
feat: introduce SidebarTocHeaders for improved sidebar TOC generation
hamkido Jan 1, 2026
b92be9c
refactor: streamline sidebar positioning logic
hamkido Jan 1, 2026
1aa08e2
refactor: enhance sidebar height and visibility logic
hamkido Jan 15, 2026
4687803
fix: use requestAnimationFrame for smooth sidebar scroll tracking
hamkido Jan 15, 2026
55e2cd3
fix: use correct segment selector for TOC sidebar positioning
hamkido Jan 15, 2026
cbbbf09
fix: lower TOC sidebar breakpoint to 768px for tablet support
hamkido Jan 15, 2026
ba42ce0
fix: use btn-octicon style for TOC toggle button
hamkido Jan 15, 2026
548e7ba
fix: remove explicit icon size for TOC button to match other btn-octicon
hamkido Jan 15, 2026
4267f69
fix: reset button default styles for btn-octicon
hamkido Jan 15, 2026
3c5621c
refactor: replace scroll event with IntersectionObserver for sidebar …
hamkido Jan 15, 2026
fd754aa
refactor: clean up sidebar TOC structure comments
hamkido Jan 15, 2026
dc873a7
style: enhance sidebar toggle button appearance
hamkido Jan 15, 2026
c5a65b9
refactor: remove deprecated SidebarTocNode and clean up sidebar TOC r…
hamkido Jan 18, 2026
1aea6c4
Update modules/markup/sidebar_toc.go
hamkido Jan 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions modules/markup/markdown/goldmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
tocNode := createTOCNode(tocList, rc.Lang, nil)
node.InsertBefore(node, firstChild, tocNode)
} else {
tocNode := createTOCNode(tocList, rc.Lang, map[string]string{"open": "open"})
ctx.SidebarTocNode = tocNode
ctx.SidebarTocHeaders = make([]markup.Header, len(tocList))
for i, h := range tocList {
ctx.SidebarTocHeaders[i] = markup.Header{Level: h.Level, Text: h.Text, ID: h.ID}
}
}
}

Expand Down
42 changes: 41 additions & 1 deletion modules/markup/orgmode/orgmode.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,54 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
htmlWriter.ExtendingWriter = w

res, err := org.New().Silent().Parse(input, "").Write(w)
// Parse the document first to extract outline for TOC
doc := org.New().Silent().Parse(input, "")
if doc.Error != nil {
return fmt.Errorf("orgmode.Parse failed: %w", doc.Error)
}

// Extract headers from the document outline for sidebar TOC
ctx.SidebarTocHeaders = extractHeadersFromOutline(doc.Outline)

res, err := doc.Write(w)
if err != nil {
return fmt.Errorf("orgmode.Render failed: %w", err)
}
_, err = io.Copy(output, strings.NewReader(res))
return err
}

// extractHeadersFromOutline recursively extracts headers from org document outline
func extractHeadersFromOutline(outline org.Outline) []markup.Header {
var headers []markup.Header
collectHeaders(outline.Section, &headers)
return headers
}

// collectHeaders recursively collects headers from sections
func collectHeaders(section *org.Section, headers *[]markup.Header) {
if section == nil {
return
}

// Process current section's headline
if section.Headline != nil {
h := section.Headline
// Convert headline title nodes to plain text
titleText := org.String(h.Title...)
*headers = append(*headers, markup.Header{
Level: h.Lvl,
Text: titleText,
ID: h.ID(),
})
}

// Process child sections
for _, child := range section.Children {
collectHeaders(child, headers)
}
}

// RenderString renders orgmode string to HTML string
func RenderString(ctx *markup.RenderContext, content string) (string, error) {
var buf strings.Builder
Expand Down
10 changes: 8 additions & 2 deletions modules/markup/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"

"github.com/yuin/goldmark/ast"
"golang.org/x/sync/errgroup"
)

Expand All @@ -36,6 +35,13 @@ var RenderBehaviorForTesting struct {
DisableAdditionalAttributes bool
}

// Header holds the data about a header for generating TOC
type Header struct {
Level int
Text string
ID string
}

type RenderOptions struct {
UseAbsoluteLink bool

Expand Down Expand Up @@ -63,7 +69,7 @@ type RenderContext struct {
// the context might be used by the "render" function, but it might also be used by "postProcess" function
usedByRender bool

SidebarTocNode ast.Node
SidebarTocHeaders []Header // Headers for generating sidebar TOC

RenderHelper RenderHelper
RenderOptions RenderOptions
Expand Down
77 changes: 77 additions & 0 deletions modules/markup/sidebar_toc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package markup

import (
"html"
"html/template"
"net/url"
"strings"

"code.gitea.io/gitea/modules/translation"
)

// RenderSidebarTocHTML renders a list of headers into HTML for sidebar TOC display.
// It generates a <details> element with nested <ul> lists representing the header hierarchy.
func RenderSidebarTocHTML(headers []Header, lang string) template.HTML {
if len(headers) == 0 {
return ""
}

var sb strings.Builder

// Start with <details open>
sb.WriteString(`<details open>`)
sb.WriteString(`<summary>`)
sb.WriteString(html.EscapeString(translation.NewLocale(lang).TrString("toc")))
sb.WriteString(`</summary>`)

// Find the minimum level to start with
minLevel := 6
for _, header := range headers {
if header.Level < minLevel {
minLevel = header.Level
}
}

// Build nested list structure
currentLevel := minLevel
sb.WriteString(`<ul>`)
openLists := 1

for _, header := range headers {
// Close lists if we need to go up levels
for currentLevel > header.Level {
sb.WriteString(`</ul>`)
openLists--
currentLevel--
}

// Open new lists if we need to go down levels
for currentLevel < header.Level {
sb.WriteString(`<ul>`)
openLists++
currentLevel++
}

// Write the list item with link
sb.WriteString(`<li>`)
sb.WriteString(`<a href="#`)
sb.WriteString(url.QueryEscape(header.ID))
sb.WriteString(`">`)
sb.WriteString(html.EscapeString(header.Text))
sb.WriteString(`</a>`)
sb.WriteString(`</li>`)
}

// Close all remaining open lists
for openLists > 0 {
sb.WriteString(`</ul>`)
openLists--
}

sb.WriteString(`</details>`)

return template.HTML(sb.String())
}
7 changes: 7 additions & 0 deletions routers/web/repo/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,13 @@ func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input i
return escaped, output, err
}

func renderSidebarTocHTML(rctx *markup.RenderContext, lang string) template.HTML {
if len(rctx.SidebarTocHeaders) > 0 {
return markup.RenderSidebarTocHTML(rctx.SidebarTocHeaders, lang)
}
return ""
}

func checkHomeCodeViewable(ctx *context.Context) {
if ctx.Repo.HasUnits() {
if ctx.Repo.Repository.IsBeingCreated() {
Expand Down
2 changes: 2 additions & 0 deletions routers/web/repo/view_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ func handleFileViewRenderMarkup(ctx *context.Context, filename string, sniffedTy
ctx.ServerError("Render", err)
return true
}

ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx, ctx.Locale.Language())
return true
}

Expand Down
2 changes: 2 additions & 0 deletions routers/web/repo/view_readme.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil
log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err)
delete(ctx.Data, "IsMarkup")
}

ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx, ctx.Locale.Language())
}

if ctx.Data["IsMarkup"] != true {
Expand Down
9 changes: 3 additions & 6 deletions routers/web/repo/wiki.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,12 +277,9 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
return nil, nil
}

if rctx.SidebarTocNode != nil {
sb := strings.Builder{}
if err = markdown.SpecializedMarkdown(rctx).Renderer().Render(&sb, nil, rctx.SidebarTocNode); err != nil {
log.Error("Failed to render wiki sidebar TOC: %v", err)
}
ctx.Data["WikiSidebarTocHTML"] = templates.SanitizeHTML(sb.String())
// Render sidebar TOC
if len(rctx.SidebarTocHeaders) > 0 {
ctx.Data["WikiSidebarTocHTML"] = markup.RenderSidebarTocHTML(rctx.SidebarTocHeaders, ctx.Locale.Language())
}

if !isSideBar {
Expand Down
8 changes: 8 additions & 0 deletions templates/repo/view_file.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
{{end}}
</div>
<div class="file-header-right file-actions flex-text-block tw-flex-wrap">
{{if .FileSidebarHTML}}
<button class="btn-octicon" id="toggle-sidebar-btn" data-tooltip-content="{{ctx.Locale.Tr "toc"}}">{{svg "octicon-list-unordered" 15}}</button>
{{end}}
{{/* this componment is also controlled by frontend plugin renders */}}
<div class="ui compact icon buttons file-view-toggle-buttons {{Iif .HasSourceRenderedToggle "" "tw-hidden"}}">
{{if .IsRepresentableAsText}}
Expand Down Expand Up @@ -139,6 +142,11 @@
</div>
{{end}}
</div>
{{if .FileSidebarHTML}}
<div class="file-view-sidebar markup sidebar-panel-hidden">
{{.FileSidebarHTML}}
</div>
{{end}}

<div class="code-line-menu tippy-target">
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}
Expand Down
3 changes: 3 additions & 0 deletions web_src/css/repo.css
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ td .commit-summary {
padding: 8px;
vertical-align: middle;
color: var(--color-text);
background: none;
border: none;
cursor: pointer;
}

.non-diff-file-content .header .file-actions .btn-octicon:hover {
Expand Down
103 changes: 103 additions & 0 deletions web_src/css/repo/home.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@
.repo-view-content {
flex: 1;
min-width: 0;
transition: margin-right 0.2s ease;
}

/* When sidebar is visible, reserve space on the right (only for file view, not home page) */
.repo-view-content.sidebar-visible {
margin-right: 270px;
}

.language-stats {
Expand Down Expand Up @@ -105,3 +111,100 @@
padding: 0 0.5em; /* make the UI look better for narrow (mobile) view */
text-decoration: none;
}

/* File view sidebar panel (e.g., TOC for markdown files) */
.file-view-sidebar {
position: fixed;
top: 120px; /* Will be adjusted by JS to align with file content */
right: 0.5rem;
width: 260px;
max-height: calc(100vh - 140px);
overflow-y: auto;
padding: 0.75rem;
background: var(--color-box-body);
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
z-index: 50;
opacity: 0; /* Hidden until JS positions it */
transition: opacity 0.15s ease;
}

/* Show sidebar after JS has positioned it */
.file-view-sidebar.sidebar-positioned {
opacity: 1;
}

/* Hidden state - using custom class to avoid Tailwind conflicts */
.file-view-sidebar.sidebar-panel-hidden {
display: none;
}

.file-view-sidebar details {
font-size: 13px;
}

.file-view-sidebar summary {
font-weight: var(--font-weight-semibold);
cursor: pointer;
padding: 8px 0;
color: var(--color-text);
border-bottom: 1px solid var(--color-secondary);
margin-bottom: 8px;
}

.file-view-sidebar ul {
margin: 0;
list-style: none;
padding: 5px 0 5px 1em;
}

.file-view-sidebar ul ul {
border-left: 1px dashed var(--color-secondary);
}

.file-view-sidebar a {
display: block;
padding: 6px 10px;
color: var(--color-text);
text-decoration: none;
font-size: 13px;
line-height: 1.4;
border-radius: var(--border-radius);
}

.file-view-sidebar a:hover {
color: var(--color-primary);
background: var(--color-hover);
}

/* Sidebar toggle button styling for file view (not readme) - add border to match other buttons */
.file-header-right #toggle-sidebar-btn {
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
}

.file-header-right #toggle-sidebar-btn:hover {
border-color: var(--color-primary);
}

/* Sidebar toggle button active state - when sidebar is visible */
#toggle-sidebar-btn.active {
color: var(--color-primary);
border-color: var(--color-primary);
}

/* Hide sidebar on small screens (phones) */
@media (max-width: 768px) {
.file-view-sidebar {
display: none !important;
}

#toggle-sidebar-btn {
display: none;
}

/* Don't reserve space for sidebar on small screens */
.repo-view-content.sidebar-visible {
margin-right: 0;
}
}
Loading