From e7237bf56ca8e81fafe3a402942b5d347c71853b Mon Sep 17 00:00:00 2001 From: Rex P Date: Wed, 16 Sep 2026 12:00:53 +1000 Subject: [PATCH 1/3] An attempt --- .../src/go/templates/vulnerability.html | 59 +---- gcp/website/frontend3/src/styles.scss | 15 ++ go/internal/website/vulnerability.go | 1 + go/internal/website/vulnerability_helpers.go | 210 +++++++++++++++--- .../website/vulnerability_helpers_test.go | 198 +++++++++++++++++ go/internal/website/vulnerability_models.go | 1 + 6 files changed, 393 insertions(+), 91 deletions(-) create mode 100644 go/internal/website/vulnerability_helpers_test.go diff --git a/gcp/website/frontend3/src/go/templates/vulnerability.html b/gcp/website/frontend3/src/go/templates/vulnerability.html index 8acfee4ebb6..e7c10050da6 100644 --- a/gcp/website/frontend3/src/go/templates/vulnerability.html +++ b/gcp/website/frontend3/src/go/templates/vulnerability.html @@ -61,27 +61,15 @@

{{ end }} {{ if .UpstreamHierarchy }}
Upstream
-
{{ .UpstreamHierarchy }}
+
{{ .UpstreamHierarchy }}
{{ end }} {{ if .DownstreamHierarchy }}
Downstream
-
{{ .DownstreamHierarchy }}
+
{{ .DownstreamHierarchy }}
{{ end }} - {{ if .Vulnerability.Related }} + {{ if .RelatedHTML }}
Related
-
-
    - {{ range $related := .Vulnerability.Related }} -
  • - {{ if $.IsKnownID $related }} - {{ $related }} - {{ else }} - {{ $related }} - {{ end }} -
  • - {{ end }} -
-
+ {{ end }} {{ if .FormattedWithdrawn }}
@@ -569,45 +557,6 @@

if (document.querySelector('.vulnerability-packages.force-collapse')) { setupVerticalLayout(); } - - /** - * Sets up "Show More" / "Show Less" functionality for lists that might have - * many items. You can enable this feature by adding an 'expandible-list' or - * 'expandible-hierarchy-list' class to lists with long value. - * @param {string} listSelector The CSS selector for the list container. - * @param {string} itemSelector The tag name of the items within the list. - */ - function setupExpandibleList(listSelector, itemSelector) { - const EXPANDIBLE_LIST_DEFAULT_LENGTH = 6; - const lists = document.querySelectorAll(`${listSelector}:not(.expanded):not([data-has-toggle-btn])`); - lists.forEach((list) => { - const items = list.getElementsByTagName(itemSelector); - if (items.length <= EXPANDIBLE_LIST_DEFAULT_LENGTH) return; - - const expandibleItems = [...items].slice(EXPANDIBLE_LIST_DEFAULT_LENGTH); - expandibleItems.forEach((item) => { - item.style.display = 'none'; - }); - - const toggleButton = document.createElement('span'); - toggleButton.classList.add('showmore'); - toggleButton.textContent = 'Show More'; - list.append(toggleButton); - list.setAttribute('data-has-toggle-btn', 'true'); - - toggleButton.addEventListener('click', function() { - const isExpanded = list.classList.contains('expanded'); - expandibleItems.forEach((item) => { - item.style.display = isExpanded ? 'none' : 'block'; - }); - toggleButton.textContent = isExpanded ? 'Show More' : 'Show Less'; - list.classList.toggle('expanded'); - }); - }); - } - - setupExpandibleList('.expandible-hierarchy-list', 'ul'); - setupExpandibleList('.expandible-list', 'li'); }); {{ end }} diff --git a/gcp/website/frontend3/src/styles.scss b/gcp/website/frontend3/src/styles.scss index b9cc361177b..4303ffa502c 100644 --- a/gcp/website/frontend3/src/styles.scss +++ b/gcp/website/frontend3/src/styles.scss @@ -1007,6 +1007,21 @@ dl.vulnerability-details, padding: 0; } + .prefix-group { + margin-bottom: 4px; + + summary.prefix-header { + cursor: pointer; + font-weight: bold; + user-select: none; + padding: 2px 0; + } + + ul.aliases { + padding-inline-start: 20px; + } + } + ul.substream { padding-bottom: 2px; padding-top: 2px; diff --git a/go/internal/website/vulnerability.go b/go/internal/website/vulnerability.go index c12d8e431ee..109b49e04e4 100644 --- a/go/internal/website/vulnerability.go +++ b/go/internal/website/vulnerability.go @@ -186,6 +186,7 @@ func (s *Server) handleVulnerabilityDetails(w http.ResponseWriter, r *http.Reque KnownIDs: knownIDs, UpstreamHierarchy: ConstructHierarchyHTML(vuln.GetId(), upstreamHierarchy, knownIDs), DownstreamHierarchy: ConstructHierarchyHTML(vuln.GetId(), downstreamHierarchy, knownIDs), + RelatedHTML: ConstructRelatedHTML(vuln.GetId(), vuln.GetRelated(), knownIDs), } s.render(w, r, "vulnerability.html", http.StatusOK, &data) diff --git a/go/internal/website/vulnerability_helpers.go b/go/internal/website/vulnerability_helpers.go index 3527a855cc2..971e5ce9163 100644 --- a/go/internal/website/vulnerability_helpers.go +++ b/go/internal/website/vulnerability_helpers.go @@ -110,61 +110,199 @@ func ParseDatabaseSpecificKVs(s *structpb.Struct) []DatabaseSpecificKV { return kvs } -// ConstructHierarchyHTML formats a models.Hierarchy into a template.HTML tree string. -func ConstructHierarchyHTML(targetID string, hierarchy *models.Hierarchy, knownIDs map[string]struct{}) template.HTML { - if hierarchy == nil || len(hierarchy.Roots) == 0 { - return "" +// ExtractPrefix returns the prefix of an ID (everything before the first '-'). +// If the ID does not contain a '-', or starts with '-', it returns the entire ID. +func ExtractPrefix(id string) string { + prefix, _, found := strings.Cut(id, "-") + if !found || prefix == "" { + return id } - var sb strings.Builder - visited := make(map[string]bool) + return prefix +} - var printSubtree func(vulnID string) - printSubtree = func(vulnID string) { - if visited[vulnID] { - return +func sortPrefixes(prefixes []string) { + slices.SortFunc(prefixes, func(a, b string) int { + if c := strings.Compare(strings.ToLower(a), strings.ToLower(b)); c != 0 { + return c } - visited[vulnID] = true - defer func() { - delete(visited, vulnID) - }() - - if vulnID != targetID { - escapedID := template.HTMLEscapeString(vulnID) - if _, known := knownIDs[vulnID]; known { - fmt.Fprintf(&sb, `
  • %s
  • `, escapedID, escapedID) - } else { - fmt.Fprintf(&sb, "
  • %s
  • ", escapedID) + + return strings.Compare(a, b) + }) +} + +func sortIDs(ids []string) { + slices.SortFunc(ids, func(a, b string) int { + if c := strings.Compare(strings.ToLower(a), strings.ToLower(b)); c != 0 { + return c + } + + return strings.Compare(a, b) + }) +} + +// VulnTree represents a hierarchical group of vulnerability IDs, +// where top-level keys are prefixes (everything before the first '-'), +// second-level keys are root vulnerability IDs, +// and further levels are recursive child vulnerability IDs. +type VulnTree map[string]VulnTree + +// BuildVulnTree builds a VulnTree grouped by prefix (everything before the first '-') +// at the top level, with root vulnerability IDs at the second level, and recursive +// child IDs underneath based on the provided graph. +// targetID is excluded from roots and children. +func BuildVulnTree(targetID string, roots []string, graph map[string][]string) VulnTree { + if len(roots) == 0 { + return nil + } + + seenRoots := make(map[string]struct{}, len(roots)) + var validRoots []string + for _, r := range roots { + if r == "" || r == targetID { + continue + } + if _, seen := seenRoots[r]; !seen { + seenRoots[r] = struct{}{} + validRoots = append(validRoots, r) + } + } + if len(validRoots) == 0 { + return nil + } + + var buildSubtree func(id string, visited map[string]bool) VulnTree + buildSubtree = func(id string, visited map[string]bool) VulnTree { + children := VulnTree{} + if graph == nil { + return children + } + for _, child := range graph[id] { + if child == "" || child == targetID || visited[child] { + continue + } + if _, exists := children[child]; exists { + continue } + visited[child] = true + children[child] = buildSubtree(child, visited) + delete(visited, child) } - if children, exists := hierarchy.Graph[vulnID]; exists && len(children) > 0 { - sortedChildren := slices.Clone(children) - slices.Sort(sortedChildren) + return children + } + + tree := VulnTree{} + for _, root := range validRoots { + p := ExtractPrefix(root) + if tree[p] == nil { + tree[p] = VulnTree{} + } + visited := map[string]bool{root: true} + tree[p][root] = buildSubtree(root, visited) + } - for _, child := range sortedChildren { - if child != targetID && !visited[child] { - sb.WriteString(`
      `) - printSubtree(child) - sb.WriteString(`
    `) - } + if len(tree) == 0 { + return nil + } + + return tree +} + +// RenderVulnTreeHTML converts a VulnTree nested map into formatted template.HTML, +// grouping entries with >= 2 roots per prefix into collapsible
    elements. +func RenderVulnTreeHTML(tree VulnTree, knownIDs map[string]struct{}) template.HTML { + if len(tree) == 0 { + return "" + } + + sortedPrefixes := make([]string, 0, len(tree)) + for p := range tree { + sortedPrefixes = append(sortedPrefixes, p) + } + sortPrefixes(sortedPrefixes) + + var sb strings.Builder + + var renderNode func(id string, children VulnTree) + renderNode = func(id string, children VulnTree) { + escapedID := template.HTMLEscapeString(id) + if _, known := knownIDs[id]; known { + fmt.Fprintf(&sb, `
  • %s
  • `, escapedID, escapedID) + } else { + fmt.Fprintf(&sb, "
  • %s
  • ", escapedID) + } + + if len(children) > 0 { + childIDs := make([]string, 0, len(children)) + for cID := range children { + childIDs = append(childIDs, cID) + } + sortIDs(childIDs) + + for _, cID := range childIDs { + sb.WriteString(`
      `) + renderNode(cID, children[cID]) + sb.WriteString(`
    `) } } } - sortedRoots := slices.Clone(hierarchy.Roots) - slices.Sort(sortedRoots) + var inLooseList bool + for _, p := range sortedPrefixes { + roots := tree[p] + if len(roots) == 0 { + continue + } + + rootIDs := make([]string, 0, len(roots)) + for id := range roots { + rootIDs = append(rootIDs, id) + } + sortIDs(rootIDs) - for _, root := range sortedRoots { - sb.WriteString(`
      `) - printSubtree(root) + if len(rootIDs) >= 2 { + if inLooseList { + sb.WriteString(`
    `) + inLooseList = false + } + fmt.Fprintf(&sb, `
    %s (%d)
      `, template.HTMLEscapeString(p), len(rootIDs)) + for _, rootID := range rootIDs { + renderNode(rootID, roots[rootID]) + } + sb.WriteString(`
    `) + } else { + if !inLooseList { + sb.WriteString(`
      `) + inLooseList = true + } + for _, rootID := range rootIDs { + renderNode(rootID, roots[rootID]) + } + } + } + if inLooseList { sb.WriteString(`
    `) } - //nolint:gosec // Hierarchy IDs are explicitly HTML escaped via template.HTMLEscapeString + //nolint:gosec // IDs and prefixes are explicitly HTML escaped via template.HTMLEscapeString return template.HTML(sb.String()) } +// ConstructHierarchyHTML formats a models.Hierarchy into a template.HTML tree string. +func ConstructHierarchyHTML(targetID string, hierarchy *models.Hierarchy, knownIDs map[string]struct{}) template.HTML { + if hierarchy == nil { + return "" + } + + return RenderVulnTreeHTML(BuildVulnTree(targetID, hierarchy.Roots, hierarchy.Graph), knownIDs) +} + +// ConstructRelatedHTML formats a list of related vulnerability IDs into a template.HTML string. +func ConstructRelatedHTML(targetID string, related []string, knownIDs map[string]struct{}) template.HTML { + return RenderVulnTreeHTML(BuildVulnTree(targetID, related, nil), knownIDs) +} + // GitCommitLink converts a repository URL and commit hash or tag into a web viewer URL. func GitCommitLink(repoURL, commit string) string { if repoURL == "" || commit == "" || commit == "0" { diff --git a/go/internal/website/vulnerability_helpers_test.go b/go/internal/website/vulnerability_helpers_test.go new file mode 100644 index 00000000000..19232e57018 --- /dev/null +++ b/go/internal/website/vulnerability_helpers_test.go @@ -0,0 +1,198 @@ +package website + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestExtractPrefix(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"CVE-2024-1234", "CVE"}, + {"GHSA-pwqr-wmgm-9rr8", "GHSA"}, + {"CGA-22r2-246h-mrq8", "CGA"}, + {"openSUSE-SU-2024:1234", "openSUSE"}, + {"NO_HYPHEN", "NO_HYPHEN"}, + {"-LEADING", "-LEADING"}, + {"", ""}, + } + + for _, tt := range tests { + got := ExtractPrefix(tt.input) + if got != tt.want { + t.Errorf("ExtractPrefix(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestBuildVulnTree(t *testing.T) { + tests := []struct { + name string + targetID string + roots []string + graph map[string][]string + want VulnTree + }{ + { + name: "nil roots", + targetID: "CVE-1", + roots: nil, + want: nil, + }, + { + name: "empty roots", + targetID: "CVE-1", + roots: []string{}, + want: nil, + }, + { + name: "only targetID in roots", + targetID: "CVE-1", + roots: []string{"CVE-1"}, + want: nil, + }, + { + name: "only empty strings in roots", + targetID: "CVE-1", + roots: []string{"", ""}, + want: nil, + }, + { + name: "single root loose item", + targetID: "CVE-1", + roots: []string{"GHSA-1234"}, + want: VulnTree{ + "GHSA": { + "GHSA-1234": {}, + }, + }, + }, + { + name: "multiple roots under same prefix", + targetID: "CVE-1", + roots: []string{"CGA-2", "CGA-1", "CGA-3"}, + want: VulnTree{ + "CGA": { + "CGA-1": {}, + "CGA-2": {}, + "CGA-3": {}, + }, + }, + }, + { + name: "deduplicates root IDs", + targetID: "CVE-1", + roots: []string{"CGA-1", "CGA-1"}, + want: VulnTree{ + "CGA": { + "CGA-1": {}, + }, + }, + }, + { + name: "multiple prefixes", + targetID: "TARGET", + roots: []string{"GHSA-1", "CGA-1", "ALAS-1", "GHSA-2"}, + want: VulnTree{ + "ALAS": { + "ALAS-1": {}, + }, + "CGA": { + "CGA-1": {}, + }, + "GHSA": { + "GHSA-1": {}, + "GHSA-2": {}, + }, + }, + }, + { + name: "hierarchy with child nodes in graph", + targetID: "CVE-1", + roots: []string{"CGA-1", "CGA-2"}, + graph: map[string][]string{ + "CGA-1": {"CHILD-1", "CHILD-2"}, + "CHILD-1": {"GRANDCHILD-1"}, + }, + want: VulnTree{ + "CGA": { + "CGA-1": { + "CHILD-1": { + "GRANDCHILD-1": {}, + }, + "CHILD-2": {}, + }, + "CGA-2": {}, + }, + }, + }, + { + name: "filters targetID from roots and graph", + targetID: "TARGET", + roots: []string{"TARGET", "CGA-1"}, + graph: map[string][]string{ + "CGA-1": {"TARGET", "CHILD-1"}, + }, + want: VulnTree{ + "CGA": { + "CGA-1": { + "CHILD-1": {}, + }, + }, + }, + }, + { + name: "handles cycles in graph gracefully", + targetID: "TARGET", + roots: []string{"A-1"}, + graph: map[string][]string{ + "A-1": {"B-1"}, + "B-1": {"A-1"}, + }, + want: VulnTree{ + "A": { + "A-1": { + "B-1": {}, + }, + }, + }, + }, + { + name: "deduplicates child nodes in graph", + targetID: "TARGET", + roots: []string{"A-1"}, + graph: map[string][]string{ + "A-1": {"B-1", "B-1"}, + }, + want: VulnTree{ + "A": { + "A-1": { + "B-1": {}, + }, + }, + }, + }, + { + name: "id with no hyphen", + targetID: "TARGET", + roots: []string{"NO_HYPHEN"}, + want: VulnTree{ + "NO_HYPHEN": { + "NO_HYPHEN": {}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildVulnTree(tt.targetID, tt.roots, tt.graph) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("BuildVulnTree() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/go/internal/website/vulnerability_models.go b/go/internal/website/vulnerability_models.go index bd5a3f71251..0da3899e1af 100644 --- a/go/internal/website/vulnerability_models.go +++ b/go/internal/website/vulnerability_models.go @@ -85,4 +85,5 @@ type VulnerabilityPageData struct { KnownIDs map[string]struct{} UpstreamHierarchy template.HTML DownstreamHierarchy template.HTML + RelatedHTML template.HTML } From d48eb86f430c970f4f81a2ff148c75e3d34c9b29 Mon Sep 17 00:00:00 2001 From: Rex P Date: Wed, 16 Sep 2026 13:14:25 +1000 Subject: [PATCH 2/3] Collapse everything --- go/internal/website/vulnerability_helpers.go | 60 ++++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/go/internal/website/vulnerability_helpers.go b/go/internal/website/vulnerability_helpers.go index 971e5ce9163..44f4576c624 100644 --- a/go/internal/website/vulnerability_helpers.go +++ b/go/internal/website/vulnerability_helpers.go @@ -209,13 +209,22 @@ func BuildVulnTree(targetID string, roots []string, graph map[string][]string) V return tree } -// RenderVulnTreeHTML converts a VulnTree nested map into formatted template.HTML, -// grouping entries with >= 2 roots per prefix into collapsible
    elements. +// RenderVulnTreeHTML converts a VulnTree nested map into formatted template.HTML. +// If any prefix has >= 2 roots, all prefixes are rendered as collapsible
    elements. +// Otherwise, all entries are rendered as loose list items. func RenderVulnTreeHTML(tree VulnTree, knownIDs map[string]struct{}) template.HTML { if len(tree) == 0 { return "" } + shouldCollapseAll := false + for _, roots := range tree { + if len(roots) >= 2 { + shouldCollapseAll = true + break + } + } + sortedPrefixes := make([]string, 0, len(tree)) for p := range tree { sortedPrefixes = append(sortedPrefixes, p) @@ -248,40 +257,43 @@ func RenderVulnTreeHTML(tree VulnTree, knownIDs map[string]struct{}) template.HT } } - var inLooseList bool - for _, p := range sortedPrefixes { - roots := tree[p] - if len(roots) == 0 { - continue - } - - rootIDs := make([]string, 0, len(roots)) - for id := range roots { - rootIDs = append(rootIDs, id) - } - sortIDs(rootIDs) + if shouldCollapseAll { + for _, p := range sortedPrefixes { + roots := tree[p] + if len(roots) == 0 { + continue + } - if len(rootIDs) >= 2 { - if inLooseList { - sb.WriteString(``) - inLooseList = false + rootIDs := make([]string, 0, len(roots)) + for id := range roots { + rootIDs = append(rootIDs, id) } + sortIDs(rootIDs) + fmt.Fprintf(&sb, `
    %s (%d)
      `, template.HTMLEscapeString(p), len(rootIDs)) for _, rootID := range rootIDs { renderNode(rootID, roots[rootID]) } sb.WriteString(`
    `) - } else { - if !inLooseList { - sb.WriteString(`
      `) - inLooseList = true + } + } else { + sb.WriteString(`
        `) + for _, p := range sortedPrefixes { + roots := tree[p] + if len(roots) == 0 { + continue } + + rootIDs := make([]string, 0, len(roots)) + for id := range roots { + rootIDs = append(rootIDs, id) + } + sortIDs(rootIDs) + for _, rootID := range rootIDs { renderNode(rootID, roots[rootID]) } } - } - if inLooseList { sb.WriteString(`
      `) } From 2d9da0db28b3881bdc92808ef9d06c1bc7902212 Mon Sep 17 00:00:00 2001 From: Rex P Date: Wed, 16 Sep 2026 14:40:53 +1000 Subject: [PATCH 3/3] Make some fixes --- gcp/website/frontend3/src/styles.scss | 9 +++ go/internal/website/vulnerability_helpers.go | 74 ++++++++------------ 2 files changed, 40 insertions(+), 43 deletions(-) diff --git a/gcp/website/frontend3/src/styles.scss b/gcp/website/frontend3/src/styles.scss index 4303ffa502c..1cad9a90032 100644 --- a/gcp/website/frontend3/src/styles.scss +++ b/gcp/website/frontend3/src/styles.scss @@ -1015,6 +1015,15 @@ dl.vulnerability-details, font-weight: bold; user-select: none; padding: 2px 0; + + &:hover { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $osv-accent-color; + outline-offset: 2px; + } } ul.aliases { diff --git a/go/internal/website/vulnerability_helpers.go b/go/internal/website/vulnerability_helpers.go index 44f4576c624..8c68015b7a2 100644 --- a/go/internal/website/vulnerability_helpers.go +++ b/go/internal/website/vulnerability_helpers.go @@ -121,18 +121,8 @@ func ExtractPrefix(id string) string { return prefix } -func sortPrefixes(prefixes []string) { - slices.SortFunc(prefixes, func(a, b string) int { - if c := strings.Compare(strings.ToLower(a), strings.ToLower(b)); c != 0 { - return c - } - - return strings.Compare(a, b) - }) -} - -func sortIDs(ids []string) { - slices.SortFunc(ids, func(a, b string) int { +func sortStringsCaseInsensitive(s []string) { + slices.SortFunc(s, func(a, b string) int { if c := strings.Compare(strings.ToLower(a), strings.ToLower(b)); c != 0 { return c } @@ -229,7 +219,7 @@ func RenderVulnTreeHTML(tree VulnTree, knownIDs map[string]struct{}) template.HT for p := range tree { sortedPrefixes = append(sortedPrefixes, p) } - sortPrefixes(sortedPrefixes) + sortStringsCaseInsensitive(sortedPrefixes) var sb strings.Builder @@ -237,7 +227,7 @@ func RenderVulnTreeHTML(tree VulnTree, knownIDs map[string]struct{}) template.HT renderNode = func(id string, children VulnTree) { escapedID := template.HTMLEscapeString(id) if _, known := knownIDs[id]; known { - fmt.Fprintf(&sb, `
    • %s
    • `, escapedID, escapedID) + fmt.Fprintf(&sb, `
    • %s
    • `, url.PathEscape(id), escapedID) } else { fmt.Fprintf(&sb, "
    • %s
    • ", escapedID) } @@ -247,7 +237,7 @@ func RenderVulnTreeHTML(tree VulnTree, knownIDs map[string]struct{}) template.HT for cID := range children { childIDs = append(childIDs, cID) } - sortIDs(childIDs) + sortStringsCaseInsensitive(childIDs) for _, cID := range childIDs { sb.WriteString(`
        `) @@ -257,41 +247,39 @@ func RenderVulnTreeHTML(tree VulnTree, knownIDs map[string]struct{}) template.HT } } - if shouldCollapseAll { - for _, p := range sortedPrefixes { - roots := tree[p] - if len(roots) == 0 { - continue - } + type prefixGroup struct { + prefix string + rootIDs []string + } - rootIDs := make([]string, 0, len(roots)) - for id := range roots { - rootIDs = append(rootIDs, id) - } - sortIDs(rootIDs) + groups := make([]prefixGroup, 0, len(sortedPrefixes)) + for _, p := range sortedPrefixes { + roots := tree[p] + if len(roots) == 0 { + continue + } + + rootIDs := make([]string, 0, len(roots)) + for id := range roots { + rootIDs = append(rootIDs, id) + } + sortStringsCaseInsensitive(rootIDs) + groups = append(groups, prefixGroup{prefix: p, rootIDs: rootIDs}) + } - fmt.Fprintf(&sb, `
        %s (%d)
          `, template.HTMLEscapeString(p), len(rootIDs)) - for _, rootID := range rootIDs { - renderNode(rootID, roots[rootID]) + if shouldCollapseAll { + for _, g := range groups { + fmt.Fprintf(&sb, `
          %s (%d)
            `, template.HTMLEscapeString(g.prefix), len(g.rootIDs)) + for _, rootID := range g.rootIDs { + renderNode(rootID, tree[g.prefix][rootID]) } sb.WriteString(`
          `) } } else { sb.WriteString(`
            `) - for _, p := range sortedPrefixes { - roots := tree[p] - if len(roots) == 0 { - continue - } - - rootIDs := make([]string, 0, len(roots)) - for id := range roots { - rootIDs = append(rootIDs, id) - } - sortIDs(rootIDs) - - for _, rootID := range rootIDs { - renderNode(rootID, roots[rootID]) + for _, g := range groups { + for _, rootID := range g.rootIDs { + renderNode(rootID, tree[g.prefix][rootID]) } } sb.WriteString(`
          `)