internal/fetch: add explicit package file links to doc tab

Add a new section for package files containing links to the source code
files. These file links are displayed in at most 3 columns for easier
readability.

Fixes golang/go#37863

Change-Id: Ia70b891a49fc3e27ece655bf895eb18e4c8b2373
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/240007
Reviewed-by: Julie Qiu <julie@golang.org>
Reviewed-by: Andrew Bonventre <andybons@golang.org>
diff --git a/content/static/css/stylesheet.css b/content/static/css/stylesheet.css
index 6647a3f..c96bb3d 100644
--- a/content/static/css/stylesheet.css
+++ b/content/static/css/stylesheet.css
@@ -966,6 +966,7 @@
 .Documentation-variablesHeader,
 .Documentation-examplesHeader,
 .Documentation-examplesPlay,
+.Documentation-filesHeader,
 .Documentation-functionHeader,
 .Documentation-typeHeader,
 .Documentation-typeMethodHeader,
@@ -992,6 +993,13 @@
   padding-top: 0.5rem;
   text-decoration: none;
 }
+.Documentation-files {
+  display: none;
+}
+.Documentation-filesList {
+  column-count: 3;
+  column-width: 12.5rem;
+}
 .Documentation-build {
   color: var(--gray-3);
   font-size: 0.875rem;
diff --git a/internal/fetch/dochtml/dochtml.go b/internal/fetch/dochtml/dochtml.go
index daf10f8..906fb64 100644
--- a/internal/fetch/dochtml/dochtml.go
+++ b/internal/fetch/dochtml/dochtml.go
@@ -17,6 +17,7 @@
 	"go/ast"
 	"go/printer"
 	"go/token"
+	"html"
 	"html/template"
 	pathpkg "path"
 	"sort"
@@ -34,6 +35,13 @@
 
 // RenderOptions are options for Render.
 type RenderOptions struct {
+	// FileLinkFunc optionally specifies a function that
+	// returns a URL where file should be linked to.
+	// file is the name component of a .go file in the package,
+	// including the .go qualifier.
+	// As a special case, FileLinkFunc may return the empty
+	// string to indicate that a given file should not be linked.
+	FileLinkFunc   func(file string) (url string)
 	SourceLinkFunc func(ast.Node) string
 	PlayURLFunc    func(*doc.Example) string // If set, returns the Go playground URL for the example
 	Limit          int64                     // If zero, a default limit of 10 megabytes is used.
@@ -81,6 +89,9 @@
 		DisableHotlinking: true,
 	})
 
+	fileLink := func(name string) template.HTML {
+		return fileLinkHTML(name, opt.FileLinkFunc(name))
+	}
 	sourceLink := func(name string, node ast.Node) template.HTML {
 		link := opt.SourceLinkFunc(node)
 		if link == "" {
@@ -104,6 +115,7 @@
 		"render_doc":            r.DocHTML,
 		"render_decl":           r.DeclHTML,
 		"render_code":           r.CodeHTML,
+		"file_link":             fileLink,
 		"source_link":           sourceLink,
 		"play_url":              playURLFunc,
 	}).Execute(buf, struct {
@@ -123,6 +135,17 @@
 	return buf.B.String(), nil
 }
 
+// fileLinkHTML returns an HTML-formatted file name linked to the source URL.
+// If link is the empty string, the file name is not linked.
+func fileLinkHTML(name, link string) template.HTML {
+	name = html.EscapeString(name)
+	if link == "" {
+		return template.HTML(name)
+	}
+	link = html.EscapeString(link)
+	return template.HTML(fmt.Sprintf(`<a class="Documentation-file" href="%s">%s</a>`, link, name))
+}
+
 // examples is an internal representation of all package examples.
 type examples struct {
 	List []*example            // sorted by ParentID
diff --git a/internal/fetch/dochtml/dochtml_test.go b/internal/fetch/dochtml/dochtml_test.go
index c1455ca..41af635 100644
--- a/internal/fetch/dochtml/dochtml_test.go
+++ b/internal/fetch/dochtml/dochtml_test.go
@@ -8,6 +8,7 @@
 	"go/ast"
 	"go/parser"
 	"go/token"
+	"html/template"
 	"io/ioutil"
 	"path/filepath"
 	"strings"
@@ -23,6 +24,7 @@
 	fset, d := mustLoadPackage("everydecl")
 
 	rawDoc, err := Render(fset, d, RenderOptions{
+		FileLinkFunc:   func(string) string { return "file" },
 		SourceLinkFunc: func(ast.Node) string { return "src" },
 	})
 	if err != nil {
@@ -42,6 +44,54 @@
 	})
 }
 
+func TestFileLinkHTML(t *testing.T) {
+	for _, test := range []struct {
+		name string
+		file string
+		link string
+		want template.HTML
+	}{
+		{
+			name: "file name is escaped",
+			file: `"File & name" <'file@name.com>`,
+			link: "",
+			want: `&#34;File &amp; name&#34; &lt;&#39;file@name.com&gt;`,
+		},
+		{
+			name: "link is escaped",
+			file: "file.go",
+			link: `"abc@go's.com"`,
+			want: `<a class="Documentation-file" href="&#34;abc@go&#39;s.com&#34;">file.go</a>`,
+		},
+		{
+			name: "file name and link are escaped",
+			file: `"a's.com@/`,
+			link: `"x@go's.com"`,
+			want: `<a class="Documentation-file" href="&#34;x@go&#39;s.com&#34;">&#34;a&#39;s.com@/</a>`,
+		},
+		{
+			name: "HTML injection escaped",
+			file: `<a href="gfr.con"></a>`,
+			link: `a.com`,
+			want: `<a class="Documentation-file" href="a.com">&lt;a href=&#34;gfr.con&#34;&gt;&lt;/a&gt;</a>`,
+		},
+		{
+			name: "regular file name and link are rendered",
+			file: `escape.go`,
+			link: `https://golang.org/src/html/escape.go`,
+			want: `<a class="Documentation-file" href="https://golang.org/src/html/escape.go">escape.go</a>`,
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			got := fileLinkHTML(test.file, test.link)
+			diff := cmp.Diff(test.want, got)
+			if diff != "" {
+				t.Errorf("mismatch (-want, +got):\n%s", diff)
+			}
+		})
+	}
+}
+
 func testDuplicateIDs(t *testing.T, htmlDoc *html.Node) {
 	idCounts := map[string]int{}
 	walk(htmlDoc, func(n *html.Node) {
diff --git a/internal/fetch/dochtml/template.go b/internal/fetch/dochtml/template.go
index aa44e68..1c1c50c 100644
--- a/internal/fetch/dochtml/template.go
+++ b/internal/fetch/dochtml/template.go
@@ -30,6 +30,7 @@
 		"render_doc":            (*render.Renderer)(nil).DocHTML,
 		"render_decl":           (*render.Renderer)(nil).DeclHTML,
 		"render_code":           (*render.Renderer)(nil).CodeHTML,
+		"file_link":             func() string { return "" },
 		"source_link":           func() string { return "" },
 		"play_url":              func(*doc.Example) string { return "" },
 	},
@@ -186,6 +187,15 @@
 	</section>
 	{{- end -}}
 
+	<section class="Documentation-files">
+		<h3 id="pkg-files" class="Documentation-filesHeader">Package Files <a href="#pkg-files">¶</a></h3>
+		<ul class="Documentation-filesList">
+			{{- range .Filenames -}}
+				<li>{{file_link .}}</li>
+			{{- end -}}
+		</ul>
+	</section>
+
 	{{- if .Consts -}}
 	<section class="Documentation-constants">
 		<h3 id="pkg-constants" class="Documentation-constantsHeader">Constants <a href="#pkg-constants">¶</a></h3>{{"\n"}}
diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go
index 38b248f..41f6a1c 100644
--- a/internal/fetch/fetch.go
+++ b/internal/fetch/fetch.go
@@ -628,6 +628,12 @@
 		}
 		return sourceInfo.LineURL(path.Join(innerPath, p.Filename), p.Line)
 	}
+	fileLinkFunc := func(filename string) string {
+		if sourceInfo == nil {
+			return ""
+		}
+		return sourceInfo.FileURL(path.Join(innerPath, filename))
+	}
 
 	// Fetch Go playground URLs for examples.
 	playURLs := make(map[*doc.Example]string)
@@ -656,6 +662,7 @@
 	}
 
 	docHTML, err := dochtml.Render(fset, d, dochtml.RenderOptions{
+		FileLinkFunc:   fileLinkFunc,
 		SourceLinkFunc: sourceLinkFunc,
 		PlayURLFunc:    playURLFunc,
 		Limit:          int64(MaxDocumentationHTML),