internal/frontend: render doc

If the frontend-render-doc experiment is set, render documentation on
the frontend instead of using the HTML in the database.

Wrote an integration test that tests the entire round trip from
worker fetch to frontend render.

For golang/go#40807

Change-Id: Id0458b31592a431a440f86262f5fd0a3cc107f25
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/258877
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/internal/experiment.go b/internal/experiment.go
index c5b37dc..28cbee7 100644
--- a/internal/experiment.go
+++ b/internal/experiment.go
@@ -8,6 +8,7 @@
 const (
 	ExperimentAltRequeue          = "alt-requeue"
 	ExperimentAutocomplete        = "autocomplete"
+	ExperimentFrontendRenderDoc   = "frontend-render-doc"
 	ExperimentInsertPackageSource = "insert-package-source"
 	ExperimentRemoveUnusedAST     = "remove-unused-ast"
 	ExperimentSidenav             = "sidenav"
@@ -19,6 +20,7 @@
 var Experiments = map[string]string{
 	ExperimentAltRequeue:          "Requeue modules for reprocessing in a different order.",
 	ExperimentAutocomplete:        "Enable autocomplete with search.",
+	ExperimentFrontendRenderDoc:   "Render documentation on the frontend if possible.",
 	ExperimentInsertPackageSource: "Insert the source code of a package in the database.",
 	ExperimentRemoveUnusedAST:     "Prune AST prior to rendering documentation HTML.",
 	ExperimentSidenav:             "Display documentation index on the left sidenav.",
diff --git a/internal/frontend/doc.go b/internal/frontend/doc.go
index 73dd9fe..4533e2f 100644
--- a/internal/frontend/doc.go
+++ b/internal/frontend/doc.go
@@ -8,9 +8,12 @@
 	"context"
 	"fmt"
 	"strings"
+	"time"
 
 	"github.com/google/safehtml"
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/experiment"
+	"golang.org/x/pkgsite/internal/godoc"
 	"golang.org/x/pkgsite/internal/log"
 	"golang.org/x/pkgsite/internal/stdlib"
 )
@@ -28,6 +31,15 @@
 	if err != nil {
 		return nil, err
 	}
+	if experiment.IsActive(ctx, internal.ExperimentFrontendRenderDoc) && len(u.Documentation.Source) > 0 {
+		dd, err := renderDoc(ctx, u)
+		if err != nil {
+			log.Errorf(ctx, "render doc failed: %v", err)
+			// Fall through to use stored doc.
+		} else {
+			return dd, nil
+		}
+	}
 	return &DocumentationDetails{
 		GOOS:          u.Documentation.GOOS,
 		GOARCH:        u.Documentation.GOARCH,
@@ -35,6 +47,35 @@
 	}, nil
 }
 
+func renderDoc(ctx context.Context, u *internal.Unit) (*DocumentationDetails, error) {
+	start := time.Now()
+	docPkg, err := godoc.DecodePackage(u.Documentation.Source)
+	if err != nil {
+		return nil, err
+	}
+	modInfo := &godoc.ModuleInfo{
+		ModulePath:      u.ModulePath,
+		ResolvedVersion: u.Version,
+		ModulePackages:  nil, // will be provided by docPkg
+	}
+	var innerPath string
+	if u.ModulePath == stdlib.ModulePath {
+		innerPath = u.Path
+	} else if u.Path != u.ModulePath {
+		innerPath = u.Path[len(u.ModulePath)+1:]
+	}
+	_, _, html, err := docPkg.Render(ctx, innerPath, u.SourceInfo, modInfo, "", "")
+	if err != nil {
+		return nil, err
+	}
+	log.Infof(ctx, "rendered doc for %s@%s in %s", u.Path, u.Version, time.Since(start))
+	return &DocumentationDetails{
+		GOOS:          docPkg.GOOS,
+		GOARCH:        docPkg.GOARCH,
+		Documentation: html,
+	}, nil
+}
+
 // fileSource returns the original filepath in the module zip where the given
 // filePath can be found. For std, the corresponding URL in
 // go.google.source.com/go is returned.
diff --git a/internal/testing/integration/frontend_doc_render_test.go b/internal/testing/integration/frontend_doc_render_test.go
new file mode 100644
index 0000000..502a3c7
--- /dev/null
+++ b/internal/testing/integration/frontend_doc_render_test.go
@@ -0,0 +1,67 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package integration
+
+import (
+	"context"
+	"net/http"
+	"testing"
+
+	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/experiment"
+	"golang.org/x/pkgsite/internal/postgres"
+	"golang.org/x/pkgsite/internal/proxy"
+	hc "golang.org/x/pkgsite/internal/testing/htmlcheck"
+	"golang.org/x/pkgsite/internal/testing/testhelper"
+)
+
+// Test that the worker saves the information needed to render
+// doc on the frontend.
+func TestFrontendDocRender(t *testing.T) {
+	defer postgres.ResetTestDB(testDB, t)
+
+	ctx := experiment.NewContext(context.Background(),
+		internal.ExperimentRemoveUnusedAST,
+		internal.ExperimentInsertPackageSource,
+		internal.ExperimentFrontendRenderDoc)
+	m := &proxy.Module{
+		ModulePath: "github.com/golang/fdoc",
+		Version:    "v1.2.3",
+		Files: map[string]string{
+			"go.mod":  "module github.com/golang/fdoc",
+			"LICENSE": testhelper.MITLicense,
+			"file.go": `
+					// Package fdoc is a test of frontend doc rendering.
+					package fdoc
+
+					// C is a constant.
+					const C = 123
+
+					// F is a function.
+					func F() {
+						// lots of code
+						print(1, 2, 3)
+						_ = C
+						// more
+					}
+			`,
+		},
+	}
+
+	ts := setupFrontend(ctx, t, nil)
+	processVersions(ctx, t, []*proxy.Module{m})
+
+	validateResponse(t, http.MethodGet, ts.URL+"/"+m.ModulePath, 200,
+		hc.In(".Documentation-content",
+			hc.In(".Documentation-overview", hc.HasText("Package fdoc is a test of frontend doc rendering.")),
+			hc.In(".Documentation-constants", hc.HasText("C is a constant.")),
+			hc.In(".Documentation-functions",
+				hc.In("a",
+					hc.HasHref("https://github.com/golang/fdoc/blob/v1.2.3/file.go#L9"),
+					hc.HasText("F")),
+				hc.In("pre", hc.HasExactText("func F()")),
+				hc.In("p", hc.HasText("F is a function.")))))
+
+}
diff --git a/internal/testing/integration/frontend_test.go b/internal/testing/integration/frontend_test.go
index c51b95f..c9ac5f2 100644
--- a/internal/testing/integration/frontend_test.go
+++ b/internal/testing/integration/frontend_test.go
@@ -266,6 +266,7 @@
 	if resp.StatusCode != wantCode {
 		t.Fatalf("%q request to %q returned status %d, want %d", method, testURL, resp.StatusCode, wantCode)
 	}
+
 	if wantHTML != nil {
 		if err := htmlcheck.Run(resp.Body, wantHTML); err != nil {
 			t.Fatal(err)