internal/frontend: show module readme links in sub-units

Show a module's README links on all units within the module.

For golang/go#42968

Change-Id: I8701a04a16e1dd766b1b23ce12d52d01190840fd
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/275276
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/content/static/html/helpers/_unit_meta.tmpl b/content/static/html/helpers/_unit_meta.tmpl
index 21ed428..3ddc1b9 100644
--- a/content/static/html/helpers/_unit_meta.tmpl
+++ b/content/static/html/helpers/_unit_meta.tmpl
@@ -15,13 +15,15 @@
     {{else}}
       Repository URL not available.
     {{end}}
-    {{range .Details.ReadmeLinks}}
-      <div class="UnitMeta-repo">{{.Body}}</div>
-      <a href="{{.Href}}" title="{{.Href}}" target="_blank" rel="noopener">{{.Href}}</a>
-    {{end}}
-    {{range .Details.DocLinks}}
-      <div class="UnitMeta-repo">{{.Body}}</div>
-      <a href="{{.Href}}" title="{{.Href}}" target="_blank" rel="noopener">{{.Href}}</a>
-    {{end}}
+    {{template "unit_meta_links" .Details.ReadmeLinks}}
+    {{template "unit_meta_links" .Details.DocLinks}}
+    {{template "unit_meta_links" .Details.ModuleReadmeLinks}}
   </div>
 {{end}}
+
+{{define "unit_meta_links"}}
+  {{range .}}
+    <div class="UnitMeta-repo">{{.Body}}</div>
+    <a href="{{.Href}}" title="{{.Href}}" target="_blank" rel="noopener">{{.Href}}</a>
+  {{end}}
+{{end}}
\ No newline at end of file
diff --git a/internal/datasource.go b/internal/datasource.go
index 0747af4..6f886c1 100644
--- a/internal/datasource.go
+++ b/internal/datasource.go
@@ -21,4 +21,6 @@
 	GetUnit(ctx context.Context, pathInfo *UnitMeta, fields FieldSet) (_ *Unit, err error)
 	// GetUnitMeta returns information about a path.
 	GetUnitMeta(ctx context.Context, path, requestedModulePath, requestedVersion string) (_ *UnitMeta, err error)
+	// GetModuleReadme gets the readme for the module.
+	GetModuleReadme(ctx context.Context, modulePath, resolvedVersion string) (*Readme, error)
 }
diff --git a/internal/frontend/readme.go b/internal/frontend/readme.go
index 09b1de8..b2871e0 100644
--- a/internal/frontend/readme.go
+++ b/internal/frontend/readme.go
@@ -24,6 +24,7 @@
 	"github.com/yuin/goldmark/util"
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/derrors"
+	"golang.org/x/pkgsite/internal/source"
 )
 
 // Heading holds data about a heading within a readme used in the
@@ -61,12 +62,16 @@
 // This function is exported for use by external tools.
 func ProcessReadme(ctx context.Context, u *internal.Unit) (_ *Readme, err error) {
 	defer derrors.Wrap(&err, "ProcessReadme(%q, %q, %q)", u.Path, u.ModulePath, u.Version)
-	if u.Readme == nil || u.Readme.Contents == "" {
+	return processReadme(u.Readme, u.SourceInfo)
+}
+
+func processReadme(readme *internal.Readme, sourceInfo *source.Info) (_ *Readme, err error) {
+	if readme == nil || readme.Contents == "" {
 		return &Readme{}, nil
 	}
-	if !isMarkdown(u.Readme.Filepath) {
+	if !isMarkdown(readme.Filepath) {
 		t := template.Must(template.New("").Parse(`<pre class="readme">{{.}}</pre>`))
-		h, err := t.ExecuteToHTML(u.Readme.Contents)
+		h, err := t.ExecuteToHTML(readme.Contents)
 		if err != nil {
 			return nil, err
 		}
@@ -93,8 +98,8 @@
 			// before it is rendered.
 			parser.WithASTTransformers(
 				util.Prioritized(&astTransformer{
-					info:   u.SourceInfo,
-					readme: u.Readme,
+					info:   sourceInfo,
+					readme: readme,
 				}, astTransformerPriority),
 				// Extract links after we have transformed the URLs.
 				util.Prioritized(el, astTransformerPriority+1),
@@ -110,10 +115,10 @@
 	)
 	gdMarkdown.Renderer().AddOptions(
 		renderer.WithNodeRenderers(
-			util.Prioritized(newHTMLRenderer(u.SourceInfo, u.Readme), 100),
+			util.Prioritized(newHTMLRenderer(sourceInfo, readme), 100),
 		),
 	)
-	contents := []byte(u.Readme.Contents)
+	contents := []byte(readme.Contents)
 	gdParser := gdMarkdown.Parser()
 	reader := gmtext.NewReader(contents)
 	pctx := parser.NewContext(parser.WithIDs(newIDs()))
diff --git a/internal/frontend/server_test.go b/internal/frontend/server_test.go
index 6af263c..707d1e9 100644
--- a/internal/frontend/server_test.go
+++ b/internal/frontend/server_test.go
@@ -81,7 +81,7 @@
 // Units with this prefix will be marked as excluded.
 const excludedModulePath = "github.com/module/excluded"
 
-const linksReadme = `
+const moduleLinksReadme = `
 # Heading
 Stuff
 
@@ -90,6 +90,11 @@
 - [title2](javascript://pwned)
 `
 
+const packageLinksReadme = `
+# Links
+- [pkg title](http://url2)
+`
+
 var testModules = []testModule{
 	{
 		// An ordinary module, with three versions.
@@ -160,15 +165,29 @@
 	},
 	{
 		// A module with links.
-		path:            "github.com/links",
+		path:            "github.com/links/mod",
 		redistributable: true,
 		versions:        []string{"v1.0.0"},
 		packages: []testPackage{
 			{
-				suffix:         "pkg",
+				suffix:         "",
+				readmeFilePath: sample.ReadmeFilePath, // required
+				readmeContents: moduleLinksReadme,
+			},
+			{
+				// This package has no readme, just links in its godoc. So the
+				// UnitMeta (right sidebar) links will be from the godoc and
+				// module readme.
+				suffix: "no_readme",
+				doc:    sample.DocumentationHTML.String(),
+			},
+			{
+				// This package has a readme as well as godoc links, so the
+				// UnitMeta links will be from all three places.
+				suffix:         "has_readme",
+				readmeFilePath: "has_readme/README.md", // required
+				readmeContents: packageLinksReadme,
 				doc:            sample.DocumentationHTML.String(),
-				readmeContents: linksReadme,
-				readmeFilePath: sample.ReadmeFilePath,
 			},
 		},
 	},
@@ -680,6 +699,54 @@
 	}
 }
 
+func checkLink(n int, title, url, body string) htmlcheck.Checker {
+	// The first div under .UnitMeta is "Links" and the second is the
+	// repository, so div numbers below start with 3. There is no "a" for
+	// "Links", so the "a" numbers are off by one.
+	return in("",
+		in(fmt.Sprintf("div:nth-of-type(%d)", n+2), hasText(title)),
+		in(fmt.Sprintf("a:nth-of-type(%d)", n+1), href(url), hasText(body)))
+}
+
+var linksTestCases = []serverTestCase{
+	{
+		name:           "module links",
+		urlPath:        "/github.com/links/mod",
+		wantStatusCode: http.StatusOK,
+		want: in(".UnitMeta",
+			// Module readme links.
+			checkLink(1, "title1", "http://url1", "http://url1"),
+			checkLink(2, "title2", "about:invalid#zGoSafez", "javascript://pwned"),
+		),
+	},
+	{
+		name:           "no_readme package links",
+		urlPath:        "/github.com/links/mod/no_readme",
+		wantStatusCode: http.StatusOK,
+		want: in(".UnitMeta",
+			// Package doc links are first.
+			checkLink(1, "pkg.go.dev", "https://pkg.go.dev", "https://pkg.go.dev"),
+			// Then module readmes.
+			checkLink(2, "title1", "http://url1", "http://url1"),
+			checkLink(3, "title2", "about:invalid#zGoSafez", "javascript://pwned"),
+		),
+	},
+	{
+		name:           "has_readme package links",
+		urlPath:        "/github.com/links/mod/has_readme",
+		wantStatusCode: http.StatusOK,
+		want: in(".UnitMeta",
+			// Package readme links are first.
+			checkLink(1, "pkg title", "http://url2", "http://url2"),
+			// Package doc links are second.
+			checkLink(2, "pkg.go.dev", "https://pkg.go.dev", "https://pkg.go.dev"),
+			// Module readme links are third.
+			checkLink(3, "title1", "http://url1", "http://url1"),
+			checkLink(4, "title2", "about:invalid#zGoSafez", "javascript://pwned"),
+		),
+	},
+}
+
 // TestServer checks the contents of served pages by looking for
 // strings and elements in the parsed HTML response body.
 //
@@ -705,21 +772,7 @@
 		{
 			name: "goldmark and readme outline experiments",
 			testCasesFunc: func() []serverTestCase {
-				return append(serverTestCases(), serverTestCase{
-					name:           "links",
-					urlPath:        "/github.com/links/pkg",
-					wantStatusCode: http.StatusOK,
-					// There is one div before the actual links appear, hence
-					// the numbers are off by one.
-					want: in(".UnitMeta",
-						in("div:nth-of-type(3)", hasText("title1")),
-						in("a:nth-of-type(2)", href("http://url1"), hasText("http://url1")),
-						in("div:nth-of-type(4)", hasText("title2")),
-						in("a:nth-of-type(3)", href("about:invalid#zGoSafez"), hasText("javascript://pwned")),
-						in("div:nth-of-type(5)", hasText("pkg.go.dev")),
-						in("a:nth-of-type(4)", href("https://pkg.go.dev"), hasText("https://pkg.go.dev")),
-					),
-				})
+				return append(serverTestCases(), linksTestCases...)
 			},
 			experiments: []string{internal.ExperimentReadmeOutline, internal.ExperimentGoldmark},
 		},
diff --git a/internal/frontend/unit_main.go b/internal/frontend/unit_main.go
index 4ad91da..42468b4 100644
--- a/internal/frontend/unit_main.go
+++ b/internal/frontend/unit_main.go
@@ -51,14 +51,19 @@
 	// used to render the readme outline in the sidebar.
 	ReadmeOutline []*Heading
 
-	// ReadmeLinks are from the "Links" section of the readme file,
-	// and are displayed on the right sidebar.
+	// ReadmeLinks are from the "Links" section of this unit's readme file, and
+	// are displayed on the right sidebar.
 	ReadmeLinks []link
 
 	// DocLinks are from the "Links" section of the Go package documentation,
 	// and are displayed on the right sidebar.
 	DocLinks []link
 
+	// ModuleReadmeLinks are from the "Links" section of this unit's module, if
+	// the unit is not itself a module. They are displayed on the right sidebar.
+	// See https://golang.org/issue/42968.
+	ModuleReadmeLinks []link
+
 	// ImportedByCount is the number of packages that import this path.
 	// When the count is > limit it will read as 'limit+'. This field
 	// is not supported when using a datasource proxy.
@@ -127,9 +132,9 @@
 		return nil, err
 	}
 	var (
-		docParts = &dochtml.Parts{}
-		docLinks []link
-		files    []*File
+		docParts           = &dochtml.Parts{}
+		docLinks, modLinks []link
+		files              []*File
 	)
 	if unit.Documentation != nil {
 		end := middleware.ElapsedStat(ctx, "DecodePackage")
@@ -156,25 +161,42 @@
 		files = sourceFiles(unit, docPkg)
 		end()
 	}
+	// If the unit is not a module, fetch the module readme to extract its
+	// links.
+	// In the unlikely event that the module is redistributable but the unit is
+	// not, we will not show the module links on the unit page.
+	if unit.Path != unit.ModulePath && unit.IsRedistributable && experiment.IsActive(ctx, internal.ExperimentGoldmark) {
+		modReadme, err := ds.GetModuleReadme(ctx, unit.ModulePath, unit.Version)
+		if err != nil {
+			return nil, err
+		}
+		rm, err := processReadme(modReadme, um.SourceInfo)
+		if err != nil {
+			return nil, err
+		}
+		modLinks = rm.Links
+	}
+
 	return &MainDetails{
-		ExpandReadme:    expandReadme,
-		NestedModules:   nestedModules,
-		Subdirectories:  subdirectories,
-		Licenses:        transformLicenseMetadata(um.Licenses),
-		CommitTime:      absoluteTime(um.CommitTime),
-		Readme:          readme.HTML,
-		ReadmeOutline:   readme.Outline,
-		ReadmeLinks:     readme.Links,
-		DocLinks:        docLinks,
-		DocOutline:      docParts.Outline,
-		DocBody:         docParts.Body,
-		SourceFiles:     files,
-		RepositoryURL:   um.SourceInfo.RepoURL(),
-		SourceURL:       um.SourceInfo.DirectoryURL(internal.Suffix(um.Path, um.ModulePath)),
-		MobileOutline:   docParts.MobileOutline,
-		NumImports:      unit.NumImports,
-		ImportedByCount: importedByCount,
-		IsPackage:       unit.IsPackage(),
+		ExpandReadme:      expandReadme,
+		NestedModules:     nestedModules,
+		Subdirectories:    subdirectories,
+		Licenses:          transformLicenseMetadata(um.Licenses),
+		CommitTime:        absoluteTime(um.CommitTime),
+		Readme:            readme.HTML,
+		ReadmeOutline:     readme.Outline,
+		ReadmeLinks:       readme.Links,
+		DocLinks:          docLinks,
+		ModuleReadmeLinks: modLinks,
+		DocOutline:        docParts.Outline,
+		DocBody:           docParts.Body,
+		SourceFiles:       files,
+		RepositoryURL:     um.SourceInfo.RepoURL(),
+		SourceURL:         um.SourceInfo.DirectoryURL(internal.Suffix(um.Path, um.ModulePath)),
+		MobileOutline:     docParts.MobileOutline,
+		NumImports:        unit.NumImports,
+		ImportedByCount:   importedByCount,
+		IsPackage:         unit.IsPackage(),
 	}, nil
 }
 
diff --git a/internal/localdatasource/datasource.go b/internal/localdatasource/datasource.go
index 116b74b..94ee63e 100644
--- a/internal/localdatasource/datasource.go
+++ b/internal/localdatasource/datasource.go
@@ -181,3 +181,8 @@
 func (ds *DataSource) GetNestedModules(ctx context.Context, modulePath string) ([]*internal.ModuleInfo, error) {
 	return nil, nil
 }
+
+// GetModuleReadme is not implemented.
+func (*DataSource) GetModuleReadme(ctx context.Context, modulePath, resolvedVersion string) (*internal.Readme, error) {
+	return nil, nil
+}
diff --git a/internal/postgres/unit.go b/internal/postgres/unit.go
index 49e9a15..8a2fec9 100644
--- a/internal/postgres/unit.go
+++ b/internal/postgres/unit.go
@@ -404,3 +404,28 @@
 	}
 	return paths, nil
 }
+
+// GetModuleReadme returns the README corresponding to the modulePath and version.
+func (db *DB) GetModuleReadme(ctx context.Context, modulePath, resolvedVersion string) (_ *internal.Readme, err error) {
+	defer derrors.Wrap(&err, "GetModuleReadme(ctx, %q, %q)", modulePath, resolvedVersion)
+	var readme internal.Readme
+	err = db.db.QueryRow(ctx, `
+		SELECT file_path, contents
+		FROM modules m
+		INNER JOIN units u
+		ON u.module_id = m.id
+		INNER JOIN readmes r
+		ON u.id = r.unit_id
+		WHERE
+		    m.module_path=$1
+			AND m.version=$2
+			AND m.module_path=u.path`, modulePath, resolvedVersion).Scan(&readme.Filepath, &readme.Contents)
+	switch err {
+	case sql.ErrNoRows:
+		return nil, derrors.NotFound
+	case nil:
+		return &readme, nil
+	default:
+		return nil, err
+	}
+}
diff --git a/internal/proxydatasource/details.go b/internal/proxydatasource/details.go
index 37da084..6171df1 100644
--- a/internal/proxydatasource/details.go
+++ b/internal/proxydatasource/details.go
@@ -71,3 +71,8 @@
 func (ds *DataSource) GetNestedModules(ctx context.Context, modulePath string) (_ []*internal.ModuleInfo, err error) {
 	return nil, nil
 }
+
+// GetModuleReadme is unimplemented.
+func (ds *DataSource) GetModuleReadme(ctx context.Context, modulePath, resolvedVersion string) (*internal.Readme, error) {
+	return nil, nil
+}