many: cmd/pkgsite embeds static assets

The `pkgsite` command now embeds all the static assets it needs, so it
need not be told the location of the static/ directory with a
flag. The binary is self-contained and can be placed and invoked from
anywhere.

This required embedding the static/ and third_party/
directories. Since //go:embed cannot reference files outside the
containing package's tree, we had to add trivial Go packages in
static/ and third_party/ to construct `embed.FS`s for those
directories.

Also, the frontend needed to accept `fs.FS` values where it
previously took paths, and `template.TrustedFS` values where it
previously used `template.TrustedSources`. We ended up clumsily
requiring four separate config values:

- A TrustedFS to load templates.

- Two fs.FSs, one for static and one for third_party, to load other
  assets.

- The path to the static directory as a string, solely to support
  dynamic loading in dev mode.

For golang/go#48410

Change-Id: I9eeb351b1c6f23444b9e65b60f2a1d3905d59ef9
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/359395
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
Reviewed-by: Jamal Carvalho <jamal@golang.org>
diff --git a/cmd/frontend/main.go b/cmd/frontend/main.go
index d13e70e..47d7e09 100644
--- a/cmd/frontend/main.go
+++ b/cmd/frontend/main.go
@@ -34,7 +34,7 @@
 var (
 	queueName      = config.GetEnv("GO_DISCOVERY_FRONTEND_TASK_QUEUE", "")
 	workers        = flag.Int("workers", 10, "number of concurrent requests to the fetch service, when running locally")
-	_              = flag.String("static", "static", "path to folder containing static files served")
+	staticFlag     = flag.String("static", "static", "path to folder containing static files served")
 	thirdPartyPath = flag.String("third_party", "third_party", "path to folder containing third-party libraries")
 	devMode        = flag.Bool("dev", false, "enable developer mode (reload templates on each page load, serve non-minified JS/CSS, etc.)")
 	disableCSP     = flag.Bool("nocsp", false, "disable Content Security Policy")
@@ -111,12 +111,15 @@
 	if err != nil {
 		log.Fatalf(ctx, "vulndbc.NewClient: %v", err)
 	}
+	staticSource := template.TrustedSourceFromFlag(flag.Lookup("static").Value)
 	server, err := frontend.NewServer(frontend.ServerConfig{
 		DataSourceGetter:     dsg,
 		Queue:                fetchQueue,
 		TaskIDChangeInterval: config.TaskIDChangeIntervalFrontend,
-		StaticPath:           template.TrustedSourceFromFlag(flag.Lookup("static").Value),
-		ThirdPartyPath:       *thirdPartyPath,
+		TemplateFS:           template.TrustedFSFromTrustedSource(staticSource),
+		StaticFS:             os.DirFS(*staticFlag),
+		StaticPath:           *staticFlag,
+		ThirdPartyFS:         os.DirFS(*thirdPartyPath),
 		DevMode:              *devMode,
 		AppVersionLabel:      cfg.AppVersionLabel(),
 		GoogleTagManagerID:   cfg.GoogleTagManagerID,
diff --git a/cmd/pkgsite/main.go b/cmd/pkgsite/main.go
index 31e55c8..d54a9dc 100644
--- a/cmd/pkgsite/main.go
+++ b/cmd/pkgsite/main.go
@@ -6,29 +6,28 @@
 // It runs as a web server and presents the documentation as a
 // web page.
 //
-// After running `go install ./cmd/pkgsite` from the pkgsite repo root, you can
-// run `pkgsite` from anywhere, but if you don't run it from the pkgsite repo
-// root you must specify the location of the static assets with -static.
+// To install, run `go install ./cmd/pkgsite` from the pkgsite repo root.
 //
-// With just -static, pkgsite will serve docs for the module in the current
+// With no arguments, pkgsite will serve docs for the module in the current
 // directory, which must have a go.mod file:
 //
-//   cd ~/repos/cue && pkgsite -static ~/repos/pkgsite/static
+//   cd ~/repos/cue && pkgsite
 //
 // You can also serve docs from your module cache, directly from the proxy
 // (it uses the GOPROXY environment variable), or both:
 //
-//   pkgsite -static ~/repos/pkgsite/static -cache -proxy
+//   pkgsite -cache -proxy
 //
-// With either -cache or -proxy, it won't look for a module in the current directory.
-// You can still provide modules on the local filesystem by listing their paths:
+// With either -cache or -proxy, pkgsite won't look for a module in the current
+// directory. You can still provide modules on the local filesystem by listing
+// their paths:
 //
-//   pkgsite -static ~/repos/pkgsite/static -cache -proxy ~/repos/cue some/other/module
+//   pkgsite -cache -proxy ~/repos/cue some/other/module
 //
 // Although standard library packages will work by default, the docs can take a
 // while to appear the first time because the Go repo must be cloned and
 // processed. If you clone the repo yourself (https://go.googlesource.com/go),
-// provide its location with the -gorepo flag to save a little time.
+// you can provide its location with the -gorepo flag to save a little time.
 package main
 
 import (
@@ -51,12 +50,14 @@
 	"golang.org/x/pkgsite/internal/proxy"
 	"golang.org/x/pkgsite/internal/source"
 	"golang.org/x/pkgsite/internal/stdlib"
+	"golang.org/x/pkgsite/static"
+	thirdparty "golang.org/x/pkgsite/third_party"
 )
 
 const defaultAddr = "localhost:8080" // default webserver address
 
 var (
-	_          = flag.String("static", "static", "path to folder containing static files served")
+	staticFlag = flag.String("static", "", "OBSOLETE - DO NOT USE")
 	gopathMode = flag.Bool("gopath_mode", false, "assume that local modules' paths are relative to GOPATH/src")
 	httpAddr   = flag.String("http", defaultAddr, "HTTP service address to listen for incoming requests on")
 	useCache   = flag.Bool("cache", false, "fetch from the module cache")
@@ -76,6 +77,10 @@
 	flag.Parse()
 	ctx := context.Background()
 
+	if *staticFlag != "" {
+		fmt.Fprintf(os.Stderr, "-static is ignored. It is obsolete and may be removed in a future version.\n")
+	}
+
 	paths := collectPaths(flag.Args())
 	if len(paths) == 0 && !*useCache && !*useProxy {
 		paths = []string{"."}
@@ -118,7 +123,7 @@
 
 	server, err := newServer(ctx, paths, *gopathMode, modCacheDir, prox)
 	if err != nil {
-		die("%s\nMaybe you need to provide the location of static assets with -static.", err)
+		die("%s", err)
 	}
 	router := http.NewServeMux()
 	server.Install(router.Handle, nil, nil)
@@ -160,7 +165,9 @@
 	}.New()
 	server, err := frontend.NewServer(frontend.ServerConfig{
 		DataSourceGetter: func(context.Context) internal.DataSource { return lds },
-		StaticPath:       template.TrustedSourceFromFlag(flag.Lookup("static").Value),
+		TemplateFS:       template.TrustedFSFromEmbed(static.FS),
+		StaticFS:         static.FS,
+		ThirdPartyFS:     thirdparty.FS,
 	})
 	if err != nil {
 		return nil, err
diff --git a/cmd/pkgsite/main_test.go b/cmd/pkgsite/main_test.go
index a938eb3..9a84ee4 100644
--- a/cmd/pkgsite/main_test.go
+++ b/cmd/pkgsite/main_test.go
@@ -6,7 +6,6 @@
 
 import (
 	"context"
-	"flag"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -45,7 +44,6 @@
 
 	localModule := repoPath("internal/fetch/testdata/has_go_mod")
 	cacheDir := repoPath("internal/fetch/testdata/modcache")
-	flag.Set("static", repoPath("static"))
 	testModules := proxytest.LoadTestModules(repoPath("internal/proxy/testdata"))
 	prox, teardown := proxytest.SetupTestClient(t, testModules)
 	defer teardown()
diff --git a/go.sum b/go.sum
index b3f3947..d40b9b9 100644
--- a/go.sum
+++ b/go.sum
@@ -288,7 +288,6 @@
 github.com/google/pprof v0.0.0-20200905233945-acf8798be1f7 h1:k+KkMRk8mGOu1xG38StS7dQ+Z6oW1i9n3dgrAVU9Q/E=
 github.com/google/pprof v0.0.0-20200905233945-acf8798be1f7/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/safehtml v0.0.2 h1:ZOt2VXg4x24bW0m2jtzAOkhoXV0iM8vNKc0paByCZqM=
 github.com/google/safehtml v0.0.2/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU=
 github.com/google/safehtml v0.0.3-0.20211026203422-d6f0e11a5516 h1:pSEdbeokt55L2hwtWo6A2k7u5SG08rmw0LhWEyrdWgk=
 github.com/google/safehtml v0.0.3-0.20211026203422-d6f0e11a5516/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU=
diff --git a/internal/fetch/fetch_test.go b/internal/fetch/fetch_test.go
index a5a4b3d..166d8c3 100644
--- a/internal/fetch/fetch_test.go
+++ b/internal/fetch/fetch_test.go
@@ -34,14 +34,14 @@
 var testTimeout = 30 * time.Second
 
 var (
-	templateSource = template.TrustedSourceFromConstant("../../static/doc")
-	testModules    []*proxytest.Module
+	templateFS  = template.TrustedFSFromTrustedSource(template.TrustedSourceFromConstant("../../static"))
+	testModules []*proxytest.Module
 )
 
 type fetchFunc func(t *testing.T, withLicenseDetector bool, ctx context.Context, mod *proxytest.Module, fetchVersion string) (*FetchResult, *licenses.Detector)
 
 func TestMain(m *testing.M) {
-	dochtml.LoadTemplates(templateSource)
+	dochtml.LoadTemplates(templateFS)
 	testModules = proxytest.LoadTestModules("../proxy/testdata")
 	licenses.OmitExceptions = true
 	os.Exit(m.Run())
diff --git a/internal/fetchdatasource/fetchdatasource_test.go b/internal/fetchdatasource/fetchdatasource_test.go
index efa1894..e433428 100644
--- a/internal/fetchdatasource/fetchdatasource_test.go
+++ b/internal/fetchdatasource/fetchdatasource_test.go
@@ -36,7 +36,7 @@
 )
 
 func TestMain(m *testing.M) {
-	dochtml.LoadTemplates(template.TrustedSourceFromConstant("../../static/doc"))
+	dochtml.LoadTemplates(template.TrustedFSFromTrustedSource(template.TrustedSourceFromConstant("../../static")))
 	defaultTestModules = proxytest.LoadTestModules("../proxy/testdata")
 	var cleanup func()
 	localGetters, cleanup = buildLocalGetters()
diff --git a/internal/frontend/badge.go b/internal/frontend/badge.go
index f0d4877..d385239 100644
--- a/internal/frontend/badge.go
+++ b/internal/frontend/badge.go
@@ -5,7 +5,6 @@
 package frontend
 
 import (
-	"fmt"
 	"net/http"
 	"strings"
 )
@@ -23,7 +22,7 @@
 func (s *Server) badgeHandler(w http.ResponseWriter, r *http.Request) {
 	path := strings.TrimPrefix(r.URL.Path, "/badge/")
 	if path != "" {
-		http.ServeFile(w, r, fmt.Sprintf("%s/frontend/badge/badge.svg", s.staticPath))
+		serveFileFS(w, r, s.staticFS, "frontend/badge/badge.svg")
 		return
 	}
 
diff --git a/internal/frontend/server.go b/internal/frontend/server.go
index e51f901..225aaff 100644
--- a/internal/frontend/server.go
+++ b/internal/frontend/server.go
@@ -14,6 +14,7 @@
 	"io/fs"
 	"net/http"
 	"net/url"
+	"path"
 	"strings"
 	"sync"
 	"time"
@@ -42,10 +43,11 @@
 	getDataSource        func(context.Context) internal.DataSource
 	queue                queue.Queue
 	taskIDChangeInterval time.Duration
-	staticPath           template.TrustedSource
-	thirdPartyPath       string
-	templateDir          template.TrustedSource
+	templateFS           template.TrustedFS
+	staticFS             fs.FS
+	thirdPartyFS         fs.FS
 	devMode              bool
+	staticPath           string // used only for dynamic loading in dev mode
 	errorPage            []byte
 	appVersionLabel      string
 	googleTagManagerID   string
@@ -65,9 +67,11 @@
 	DataSourceGetter     func(context.Context) internal.DataSource
 	Queue                queue.Queue
 	TaskIDChangeInterval time.Duration
-	StaticPath           template.TrustedSource
-	ThirdPartyPath       string
+	TemplateFS           template.TrustedFS // for loading templates safely
+	StaticFS             fs.FS              // for static/ directory
+	ThirdPartyFS         fs.FS              // for third_party/ directory
 	DevMode              bool
+	StaticPath           string // used only for dynamic loading in dev mode
 	AppVersionLabel      string
 	GoogleTagManagerID   string
 	ServeStats           bool
@@ -78,20 +82,19 @@
 // NewServer creates a new Server for the given database and template directory.
 func NewServer(scfg ServerConfig) (_ *Server, err error) {
 	defer derrors.Wrap(&err, "NewServer(...)")
-	templateDir := template.TrustedSourceJoin(scfg.StaticPath)
-	ts, err := parsePageTemplates(templateDir)
+	ts, err := parsePageTemplates(scfg.TemplateFS)
 	if err != nil {
 		return nil, fmt.Errorf("error parsing templates: %v", err)
 	}
-	docTemplateDir := template.TrustedSourceJoin(templateDir, template.TrustedSourceFromConstant("doc"))
-	dochtml.LoadTemplates(docTemplateDir)
+	dochtml.LoadTemplates(scfg.TemplateFS)
 	s := &Server{
 		getDataSource:        scfg.DataSourceGetter,
 		queue:                scfg.Queue,
-		staticPath:           scfg.StaticPath,
-		thirdPartyPath:       scfg.ThirdPartyPath,
-		templateDir:          templateDir,
+		templateFS:           scfg.TemplateFS,
+		staticFS:             scfg.StaticFS,
+		thirdPartyFS:         scfg.ThirdPartyFS,
 		devMode:              scfg.DevMode,
+		staticPath:           scfg.StaticPath,
 		templates:            ts,
 		taskIDChangeInterval: scfg.TaskIDChangeInterval,
 		appVersionLabel:      scfg.AppVersionLabel,
@@ -134,10 +137,11 @@
 		log.Infof(r.Context(), "Request made to %q", r.URL.Path)
 	}))
 	handle("/static/", s.staticHandler())
-	handle("/third_party/", http.StripPrefix("/third_party", http.FileServer(http.Dir(s.thirdPartyPath))))
+	handle("/third_party/", http.StripPrefix("/third_party", http.FileServer(http.FS(s.thirdPartyFS))))
 	handle("/favicon.ico", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		http.ServeFile(w, r, fmt.Sprintf("%s/shared/icon/favicon.ico", http.Dir(s.staticPath.String())))
+		serveFileFS(w, r, s.staticFS, "shared/icon/favicon.ico")
 	}))
+
 	handle("/sitemap/", http.StripPrefix("/sitemap/", http.FileServer(http.Dir("private/sitemap"))))
 	handle("/mod/", http.HandlerFunc(s.handleModuleDetailsRedirect))
 	handle("/pkg/", http.HandlerFunc(s.handlePackageDetailsRedirect))
@@ -539,7 +543,7 @@
 		s.mu.Lock()
 		defer s.mu.Unlock()
 		var err error
-		s.templates, err = parsePageTemplates(s.templateDir)
+		s.templates, err = parsePageTemplates(s.templateFS)
 		if err != nil {
 			return nil, fmt.Errorf("error parsing templates: %v", err)
 		}
@@ -584,69 +588,73 @@
 	return url
 }
 
-// parsePageTemplates parses html templates contained in the given base
-// directory in order to generate a map of Name->*template.Template.
+// parsePageTemplates parses html templates contained in the given filesystem in
+// order to generate a map of Name->*template.Template.
 //
 // Separate templates are used so that certain contextual functions (e.g.
 // templateName) can be bound independently for each page.
 //
 // Templates in directories prefixed with an underscore are considered helper
 // templates and parsed together with the files in each base directory.
-func parsePageTemplates(base template.TrustedSource) (map[string]*template.Template, error) {
-	tsc := template.TrustedSourceFromConstant
-	join := template.TrustedSourceJoin
-
+func parsePageTemplates(fsys template.TrustedFS) (map[string]*template.Template, error) {
 	templates := make(map[string]*template.Template)
-	htmlSets := [][]template.TrustedSource{
-		{tsc("badge")},
-		{tsc("error")},
-		{tsc("fetch")},
-		{tsc("homepage")},
-		{tsc("legacy_search")},
-		{tsc("license-policy")},
-		{tsc("search")},
-		{tsc("search-help")},
-		{tsc("styleguide")},
-		{tsc("subrepo")},
-		{tsc("unit/importedby"), tsc("unit")},
-		{tsc("unit/imports"), tsc("unit")},
-		{tsc("unit/licenses"), tsc("unit")},
-		{tsc("unit/main"), tsc("unit")},
-		{tsc("unit/versions"), tsc("unit")},
+	htmlSets := [][]string{
+		{"badge"},
+		{"error"},
+		{"fetch"},
+		{"homepage"},
+		{"legacy_search"},
+		{"license-policy"},
+		{"search"},
+		{"search-help"},
+		{"styleguide"},
+		{"subrepo"},
+		{"unit/importedby", "unit"},
+		{"unit/imports", "unit"},
+		{"unit/licenses", "unit"},
+		{"unit/main", "unit"},
+		{"unit/versions", "unit"},
 	}
 
 	for _, set := range htmlSets {
-		t, err := template.New("frontend.tmpl").Funcs(templateFuncs).ParseGlobFromTrustedSource(join(base, tsc("frontend/*.tmpl")))
+		t, err := template.New("frontend.tmpl").Funcs(templateFuncs).ParseFS(fsys, "frontend/*.tmpl")
 		if err != nil {
-			return nil, fmt.Errorf("ParseFilesFromTrustedSources: %v", err)
+			return nil, fmt.Errorf("ParseFS: %v", err)
 		}
-		helperGlob := join(base, tsc("shared/*/*.tmpl"))
-		if _, err := t.ParseGlobFromTrustedSource(helperGlob); err != nil {
-			return nil, fmt.Errorf("ParseGlobFromTrustedSource(%q): %v", helperGlob, err)
+		helperGlob := "shared/*/*.tmpl"
+		if _, err := t.ParseFS(fsys, helperGlob); err != nil {
+			return nil, fmt.Errorf("ParseFS(%q): %v", helperGlob, err)
 		}
-		var files []template.TrustedSource
 		for _, f := range set {
-			if _, err := t.ParseGlobFromTrustedSource(join(base, tsc("frontend"), f, tsc("*.tmpl"))); err != nil {
-				return nil, fmt.Errorf("ParseGlobFromTrustedSource(%v): %v", files, err)
+			if _, err := t.ParseFS(fsys, path.Join("frontend", f, "*.tmpl")); err != nil {
+				return nil, fmt.Errorf("ParseFS(%v): %v", f, err)
 			}
 		}
-		templates[set[0].String()] = t
+		templates[set[0]] = t
 	}
 
 	return templates, nil
 }
 
 func (s *Server) staticHandler() http.Handler {
-	staticPath := s.staticPath.String()
-
 	// In dev mode compile TypeScript files into minified JavaScript files
 	// and rebuild them on file changes.
 	if s.devMode {
+		if s.staticPath == "" {
+			panic("staticPath is empty in dev mode; cannot rebuild static files")
+		}
 		ctx := context.Background()
-		_, err := static.Build(static.Config{EntryPoint: staticPath + "/frontend", Watch: true, Bundle: true})
+		_, err := static.Build(static.Config{EntryPoint: s.staticPath + "/frontend", Watch: true, Bundle: true})
 		if err != nil {
 			log.Error(ctx, err)
 		}
 	}
-	return http.StripPrefix("/static/", http.FileServer(http.Dir(staticPath)))
+	return http.StripPrefix("/static/", http.FileServer(http.FS(s.staticFS)))
+}
+
+// serveFileFS serves a file from the given filesystem.
+func serveFileFS(w http.ResponseWriter, r *http.Request, fsys fs.FS, name string) {
+	fs := http.FileServer(http.FS(fsys))
+	r.URL.Path = name
+	fs.ServeHTTP(w, r)
 }
diff --git a/internal/frontend/server_test.go b/internal/frontend/server_test.go
index 02fc123..f4bd6f4 100644
--- a/internal/frontend/server_test.go
+++ b/internal/frontend/server_test.go
@@ -35,6 +35,8 @@
 	"golang.org/x/pkgsite/internal/testing/pagecheck"
 	"golang.org/x/pkgsite/internal/testing/sample"
 	"golang.org/x/pkgsite/internal/version"
+	"golang.org/x/pkgsite/static"
+	thirdparty "golang.org/x/pkgsite/third_party"
 )
 
 const testTimeout = 5 * time.Second
@@ -677,7 +679,7 @@
 			name:           "static",
 			urlPath:        "/static/",
 			wantStatusCode: http.StatusOK,
-			want:           in("", hasText("doc"), hasText("frontend"), hasText("markdown.ts"), hasText("shared"), hasText("worker")),
+			want:           in("", hasText("doc"), hasText("frontend"), hasText("shared"), hasText("worker")),
 		},
 		{
 			name:           "license policy",
@@ -1541,9 +1543,13 @@
 		DataSourceGetter:     func(context.Context) internal.DataSource { return testDB },
 		Queue:                q,
 		TaskIDChangeInterval: 10 * time.Minute,
-		StaticPath:           template.TrustedSourceFromConstant("../../static"),
-		ThirdPartyPath:       "../../third_party",
-		AppVersionLabel:      "",
+		TemplateFS:           template.TrustedFSFromEmbed(static.FS),
+		// Use the embedded FSs here to make sure they're tested.
+		// Integration tests will use the actual directories.
+		StaticFS:        static.FS,
+		ThirdPartyFS:    thirdparty.FS,
+		StaticPath:      "../../static",
+		AppVersionLabel: "",
 	})
 	if err != nil {
 		t.Fatal(err)
@@ -1568,8 +1574,8 @@
 
 func TestCheckTemplates(t *testing.T) {
 	// Perform additional checks on parsed templates.
-	staticPath := template.TrustedSourceFromConstant("../../static")
-	templates, err := parsePageTemplates(staticPath)
+	staticFS := template.TrustedFSFromEmbed(static.FS)
+	templates, err := parsePageTemplates(staticFS)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/frontend/styleguide.go b/internal/frontend/styleguide.go
index 557b326..bfd3a97 100644
--- a/internal/frontend/styleguide.go
+++ b/internal/frontend/styleguide.go
@@ -8,9 +8,9 @@
 	"bytes"
 	"context"
 	"html"
-	"io/ioutil"
+	"io/fs"
 	"net/http"
-	"os"
+	"path"
 	"path/filepath"
 	"strings"
 
@@ -35,7 +35,7 @@
 	if !experiment.IsActive(ctx, internal.ExperimentStyleGuide) {
 		return &serverError{status: http.StatusNotFound}
 	}
-	page, err := styleGuide(ctx, s.staticPath.String())
+	page, err := styleGuide(ctx, s.staticFS)
 	page.basePage = s.newBasePage(r, "")
 	page.AllowWideContent = true
 	page.UseResponsiveLayout = true
@@ -54,18 +54,18 @@
 	Outline  []*Heading
 }
 
-// styleGuide collects the paths to the markdown files in static/shared,
+// styleGuide collects the paths to the markdown files in staticFS,
 // renders them into sections for the styleguide, and merges the document
 // outlines into a single page outline.
-func styleGuide(ctx context.Context, staticPath string) (_ *styleGuidePage, err error) {
-	defer derrors.WrapStack(&err, "styleGuide(%q)", staticPath)
-	files, err := markdownFiles(staticPath)
+func styleGuide(ctx context.Context, staticFS fs.FS) (_ *styleGuidePage, err error) {
+	defer derrors.WrapStack(&err, "styleGuide)")
+	files, err := markdownFiles(staticFS)
 	if err != nil {
 		return nil, err
 	}
 	var sections []*StyleSection
 	for _, f := range files {
-		doc, err := styleSection(ctx, f)
+		doc, err := styleSection(ctx, staticFS, f)
 		if err != nil {
 			return nil, err
 		}
@@ -99,10 +99,10 @@
 
 // styleSection uses goldmark to parse a markdown file and render
 // a section of the styleguide.
-func styleSection(ctx context.Context, filename string) (_ *StyleSection, err error) {
+func styleSection(ctx context.Context, fsys fs.FS, filename string) (_ *StyleSection, err error) {
 	defer derrors.WrapStack(&err, "styleSection(%q)", filename)
 	var buf bytes.Buffer
-	source, err := ioutil.ReadFile(filename)
+	source, err := fs.ReadFile(fsys, filename)
 	if err != nil {
 		return nil, err
 	}
@@ -189,21 +189,16 @@
 	reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
 }
 
-// markdownFiles walks the static/shared directory and collects
+// markdownFiles walks the shared directory of fsys and collects
 // the paths to markdown files.
-func markdownFiles(dir string) ([]string, error) {
+func markdownFiles(fsys fs.FS) ([]string, error) {
 	var matches []string
-	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+	err := fs.WalkDir(fsys, "shared", func(filepath string, d fs.DirEntry, err error) error {
 		if err != nil {
 			return err
 		}
-		if info.IsDir() {
-			return nil
-		}
-		if matched, err := filepath.Match("*.md", filepath.Base(path)); err != nil {
-			return err
-		} else if matched {
-			matches = append(matches, path)
+		if path.Ext(filepath) == ".md" {
+			matches = append(matches, filepath)
 		}
 		return nil
 	})
diff --git a/internal/godoc/dochtml/dochtml_test.go b/internal/godoc/dochtml/dochtml_test.go
index dead71a..98a3a21 100644
--- a/internal/godoc/dochtml/dochtml_test.go
+++ b/internal/godoc/dochtml/dochtml_test.go
@@ -31,7 +31,7 @@
 	"golang.org/x/pkgsite/internal/testing/testhelper"
 )
 
-var templateSource = template.TrustedSourceFromConstant("../../../static/doc")
+var templateFS = template.TrustedFSFromTrustedSource(template.TrustedSourceFromConstant("../../../static"))
 
 var update = flag.Bool("update", false, "update goldens instead of checking against them")
 
@@ -42,7 +42,7 @@
 }
 
 func TestCheckTemplates(t *testing.T) {
-	LoadTemplates(templateSource)
+	LoadTemplates(templateFS)
 	for _, tm := range []*template.Template{bodyTemplate, outlineTemplate, sidenavTemplate} {
 		if err := templatecheck.CheckSafe(tm, templateData{}); err != nil {
 			t.Fatal(err)
@@ -52,7 +52,7 @@
 
 func TestRender(t *testing.T) {
 	ctx := context.Background()
-	LoadTemplates(templateSource)
+	LoadTemplates(templateFS)
 	fset, d := mustLoadPackage("everydecl")
 	parts, err := Render(ctx, fset, d, testRenderOptions)
 	if err != nil {
@@ -114,7 +114,7 @@
 }
 
 func TestExampleRender(t *testing.T) {
-	LoadTemplates(templateSource)
+	LoadTemplates(templateFS)
 	ctx := context.Background()
 	fset, d := mustLoadPackage("example_test")
 
diff --git a/internal/godoc/dochtml/symbol_test.go b/internal/godoc/dochtml/symbol_test.go
index 4e43cbd..6f9c558 100644
--- a/internal/godoc/dochtml/symbol_test.go
+++ b/internal/godoc/dochtml/symbol_test.go
@@ -12,7 +12,7 @@
 )
 
 func TestGetSymbols(t *testing.T) {
-	LoadTemplates(templateSource)
+	LoadTemplates(templateFS)
 	fset,
 		d := mustLoadPackage("symbols")
 	got,
diff --git a/internal/godoc/dochtml/template.go b/internal/godoc/dochtml/template.go
index ca8c964..fcfad0c 100644
--- a/internal/godoc/dochtml/template.go
+++ b/internal/godoc/dochtml/template.go
@@ -6,6 +6,7 @@
 
 import (
 	"context"
+	"path"
 	"reflect"
 	"sync"
 
@@ -26,29 +27,27 @@
 )
 
 // LoadTemplates reads and parses the templates used to generate documentation.
-func LoadTemplates(dir template.TrustedSource) {
+func LoadTemplates(fsys template.TrustedFS) {
+	const dir = "doc"
 	loadOnce.Do(func() {
-		join := template.TrustedSourceJoin
-		tc := template.TrustedSourceFromConstant
-
 		bodyTemplate = template.Must(template.New("body.tmpl").
 			Funcs(tmpl).
-			ParseFilesFromTrustedSources(
-				join(dir, tc("body.tmpl")),
-				join(dir, tc("declaration.tmpl")),
-				join(dir, tc("example.tmpl"))))
+			ParseFS(fsys,
+				path.Join(dir, "body.tmpl"),
+				path.Join(dir, "declaration.tmpl"),
+				path.Join(dir, "example.tmpl")))
 		if experiment.IsActive(context.Background(), internal.ExperimentNewUnitLayout) {
 			outlineTemplate = template.Must(template.New("outline.tmpl").
 				Funcs(tmpl).
-				ParseFilesFromTrustedSources(join(dir, tc("outline.tmpl"))))
+				ParseFS(fsys, path.Join(dir, "outline.tmpl")))
 		} else {
 			outlineTemplate = template.Must(template.New("legacy-outline.tmpl").
 				Funcs(tmpl).
-				ParseFilesFromTrustedSources(join(dir, tc("legacy-outline.tmpl"))))
+				ParseFS(fsys, path.Join(dir, "legacy-outline.tmpl")))
 		}
 		sidenavTemplate = template.Must(template.New("sidenav-mobile.tmpl").
 			Funcs(tmpl).
-			ParseFilesFromTrustedSources(join(dir, tc("sidenav-mobile.tmpl"))))
+			ParseFS(fsys, path.Join(dir, "sidenav-mobile.tmpl")))
 	})
 }
 
diff --git a/internal/godoc/render_test.go b/internal/godoc/render_test.go
index d2405e7..ea56a4e 100644
--- a/internal/godoc/render_test.go
+++ b/internal/godoc/render_test.go
@@ -18,7 +18,7 @@
 	"golang.org/x/pkgsite/internal/testing/htmlcheck"
 )
 
-var templateSource = template.TrustedSourceFromConstant("../../static/doc")
+var templateFS = template.TrustedFSFromTrustedSource(template.TrustedSourceFromConstant("../../static"))
 
 var (
 	in      = htmlcheck.In
@@ -26,7 +26,7 @@
 )
 
 func TestDocInfo(t *testing.T) {
-	dochtml.LoadTemplates(templateSource)
+	dochtml.LoadTemplates(templateFS)
 	ctx := context.Background()
 	si := source.NewGitHubInfo("a.com/M", "", "abcde")
 	mi := &ModuleInfo{
@@ -85,7 +85,7 @@
 }
 
 func TestRenderParts_SinceVersion(t *testing.T) {
-	dochtml.LoadTemplates(templateSource)
+	dochtml.LoadTemplates(templateFS)
 	ctx := context.Background()
 	si := source.NewGitHubInfo("a.com/M", "", "abcde")
 	mi := &ModuleInfo{
diff --git a/internal/testing/integration/frontend_test.go b/internal/testing/integration/frontend_test.go
index ce4eafd..3ac9a17 100644
--- a/internal/testing/integration/frontend_test.go
+++ b/internal/testing/integration/frontend_test.go
@@ -8,6 +8,7 @@
 	"context"
 	"net/http"
 	"net/http/httptest"
+	"os"
 	"testing"
 	"time"
 
@@ -30,11 +31,13 @@
 // duplication
 func setupFrontend(ctx context.Context, t *testing.T, q queue.Queue, rc *redis.Client) *httptest.Server {
 	t.Helper()
+	const staticDir = "../../../static"
 	s, err := frontend.NewServer(frontend.ServerConfig{
 		DataSourceGetter:     func(context.Context) internal.DataSource { return testDB },
 		TaskIDChangeInterval: 10 * time.Minute,
-		StaticPath:           template.TrustedSourceFromConstant("../../../static"),
-		ThirdPartyPath:       "../../../third_party",
+		TemplateFS:           template.TrustedFSFromTrustedSource(template.TrustedSourceFromConstant(staticDir)),
+		StaticFS:             os.DirFS(staticDir),
+		ThirdPartyFS:         os.DirFS("../../../third_party"),
 		AppVersionLabel:      "",
 		Queue:                q,
 		ServeStats:           true,
diff --git a/internal/testing/integration/integration_test.go b/internal/testing/integration/integration_test.go
index 4a5be8f..b414227 100644
--- a/internal/testing/integration/integration_test.go
+++ b/internal/testing/integration/integration_test.go
@@ -32,7 +32,7 @@
 )
 
 func TestMain(m *testing.M) {
-	dochtml.LoadTemplates(template.TrustedSourceFromConstant("../../../static/doc"))
+	dochtml.LoadTemplates(template.TrustedFSFromTrustedSource(template.TrustedSourceFromConstant("../../../static")))
 	testModules = proxytest.LoadTestModules("../../proxy/testdata")
 	postgres.RunDBTests("discovery_integration_test", m, &testDB)
 }
diff --git a/internal/worker/server.go b/internal/worker/server.go
index 8051007..354b8ed 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -87,7 +87,9 @@
 	if err != nil {
 		return nil, err
 	}
-	dochtml.LoadTemplates(template.TrustedSourceJoin(scfg.StaticPath, template.TrustedSourceFromConstant("doc")))
+	ts := template.TrustedSourceJoin(scfg.StaticPath, template.TrustedSourceFromConstant("doc"))
+	tfs := template.TrustedFSFromTrustedSource(ts)
+	dochtml.LoadTemplates(tfs)
 	templates := map[string]*template.Template{
 		indexTemplate:    t1,
 		versionsTemplate: t2,
diff --git a/internal/worker/server_test.go b/internal/worker/server_test.go
index 6708806..96a01b3 100644
--- a/internal/worker/server_test.go
+++ b/internal/worker/server_test.go
@@ -40,7 +40,7 @@
 
 func TestMain(m *testing.M) {
 	httpClient = &http.Client{Transport: fakeTransport{}}
-	dochtml.LoadTemplates(template.TrustedSourceFromConstant("../../static/doc"))
+	dochtml.LoadTemplates(template.TrustedFSFromTrustedSource(template.TrustedSourceFromConstant("../../static")))
 	testModules = proxytest.LoadTestModules("../proxy/testdata")
 	postgres.RunDBTests("discovery_worker_test", m, &testDB)
 }
diff --git a/static/fs.go b/static/fs.go
new file mode 100644
index 0000000..72e6a87
--- /dev/null
+++ b/static/fs.go
@@ -0,0 +1,14 @@
+// Copyright 2021 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 static
+
+import "embed"
+
+// Until https://golang.org/issue/43854 is implemented, all directories
+// containing files beginning with an underscore must be specified explicitly.
+
+//go:embed doc/* frontend/* shared/* worker/*
+//go:embed frontend/unit/* frontend/unit/main/* frontend/legacy_search/*
+var FS embed.FS
diff --git a/static/fs_test.go b/static/fs_test.go
new file mode 100644
index 0000000..0bed26c
--- /dev/null
+++ b/static/fs_test.go
@@ -0,0 +1,22 @@
+// Copyright 2021 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 static
+
+import "testing"
+
+func TestFS(t *testing.T) {
+	for _, f := range []string{
+		"shared/reset.css",
+		"frontend/_modals.tmpl",
+		"frontend/homepage/homepage.tmpl",
+		"shared/logo/go-blue.svg",
+		"frontend/unit/unit.css",
+		"frontend/unit/_header.tmpl",
+	} {
+		if _, err := FS.Open(f); err != nil {
+			t.Errorf("%s: %v", f, err)
+		}
+	}
+}
diff --git a/third_party/README.md b/third_party/README.md
new file mode 100644
index 0000000..7938213
--- /dev/null
+++ b/third_party/README.md
@@ -0,0 +1,7 @@
+This directory holds non-Go libraries that were not developed
+as part of the pkgsite project.
+
+Some of the libraries are used by the frontend UI; others are for testing.
+
+To add a library here, place it in a subdirectory. If the frontend UI needs it,
+then add the subdirectory to the `//go:embed` line in `fs.go`.
diff --git a/third_party/fs.go b/third_party/fs.go
new file mode 100644
index 0000000..57dfbc8
--- /dev/null
+++ b/third_party/fs.go
@@ -0,0 +1,10 @@
+// Copyright 2021 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 thirdparty
+
+import "embed"
+
+//go:embed autoComplete.js/* dialog-polyfill/*
+var FS embed.FS
diff --git a/third_party/fs_test.go b/third_party/fs_test.go
new file mode 100644
index 0000000..21de480
--- /dev/null
+++ b/third_party/fs_test.go
@@ -0,0 +1,18 @@
+// Copyright 2021 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 thirdparty
+
+import "testing"
+
+func TestFS(t *testing.T) {
+	for _, f := range []string{
+		"autoComplete.js/autoComplete.min.js",
+		"dialog-polyfill/dialog-polyfill.js",
+	} {
+		if _, err := FS.Open(f); err != nil {
+			t.Errorf("%s: %v", f, err)
+		}
+	}
+}