cmd/golangorg, internal/godoc: switch from vfs to io/fs

Change-Id: Idf55bca5c9ee20d6612e463ad7fc59ef212171e6
Reviewed-on: https://go-review.googlesource.com/c/website/+/293489
Trust: Russ Cox <rsc@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/cmd/golangorg/codewalk.go b/cmd/golangorg/codewalk.go
index a7fdafa..b571343 100644
--- a/cmd/golangorg/codewalk.go
+++ b/cmd/golangorg/codewalk.go
@@ -21,6 +21,7 @@
 	"errors"
 	"fmt"
 	"io"
+	"io/fs"
 	"log"
 	"net/http"
 	"os"
@@ -33,7 +34,6 @@
 	"unicode/utf8"
 
 	"golang.org/x/website/internal/godoc"
-	"golang.org/x/website/internal/godoc/vfs"
 )
 
 var codewalkHTML, codewalkdirHTML *template.Template
@@ -50,7 +50,7 @@
 	}
 
 	// If directory exists, serve list of code walks.
-	dir, err := fs.Lstat(abspath)
+	dir, err := fs.Stat(fsys, toFS(abspath))
 	if err == nil && dir.IsDir() {
 		codewalkDir(w, r, relpath, abspath)
 		return
@@ -146,7 +146,7 @@
 
 // loadCodewalk reads a codewalk from the named XML file.
 func loadCodewalk(filename string) (*Codewalk, error) {
-	f, err := fs.Open(filename)
+	f, err := fsys.Open(toFS(filename))
 	if err != nil {
 		return nil, err
 	}
@@ -167,7 +167,7 @@
 			i = len(st.Src)
 		}
 		filename := st.Src[0:i]
-		data, err := vfs.ReadFile(fs, filename)
+		data, err := fs.ReadFile(fsys, toFS(filename))
 		if err != nil {
 			st.Err = err
 			continue
@@ -214,7 +214,7 @@
 		Title string
 	}
 
-	dir, err := fs.ReadDir(abspath)
+	dir, err := fs.ReadDir(fsys, toFS(abspath))
 	if err != nil {
 		log.Print(err)
 		pres.ServeError(w, r, relpath, err)
@@ -248,7 +248,7 @@
 // the usual godoc HTML wrapper.
 func codewalkFileprint(w http.ResponseWriter, r *http.Request, f string) {
 	abspath := f
-	data, err := vfs.ReadFile(fs, abspath)
+	data, err := fs.ReadFile(fsys, toFS(abspath))
 	if err != nil {
 		log.Print(err)
 		pres.ServeError(w, r, f, err)
diff --git a/cmd/golangorg/handlers.go b/cmd/golangorg/handlers.go
index c1919c9..c7125c0 100644
--- a/cmd/golangorg/handlers.go
+++ b/cmd/golangorg/handlers.go
@@ -10,23 +10,32 @@
 import (
 	"encoding/json"
 	"go/format"
+	"io/fs"
 	"log"
 	"net/http"
+	pathpkg "path"
 	"strings"
 	"text/template"
 
 	"golang.org/x/website/internal/env"
 	"golang.org/x/website/internal/godoc"
-	"golang.org/x/website/internal/godoc/vfs"
 	"golang.org/x/website/internal/history"
 	"golang.org/x/website/internal/redirect"
 )
 
 var (
 	pres *godoc.Presentation
-	fs   = vfs.NameSpace{}
+	fsys fs.FS
 )
 
+// toFS returns the io/fs name for path (no leading slash).
+func toFS(path string) string {
+	if path == "/" {
+		return "."
+	}
+	return pathpkg.Clean(strings.TrimPrefix(path, "/"))
+}
+
 // hostEnforcerHandler redirects requests to "http://foo.golang.org/bar"
 // to "https://golang.org/bar".
 // It permits requests to the host "godoc-test.golang.org" for testing and
@@ -100,7 +109,7 @@
 
 	// use underlying file system fs to read the template file
 	// (cannot use template ParseFile functions directly)
-	data, err := vfs.ReadFile(fs, path)
+	data, err := fs.ReadFile(fsys, toFS(path))
 	if err != nil {
 		log.Fatal("readTemplate: ", err)
 	}
diff --git a/cmd/golangorg/main.go b/cmd/golangorg/main.go
index 60f0294..14abaf6 100644
--- a/cmd/golangorg/main.go
+++ b/cmd/golangorg/main.go
@@ -23,6 +23,7 @@
 import (
 	"flag"
 	"fmt"
+	"io/fs"
 	"log"
 	"net/http"
 	"os"
@@ -31,8 +32,6 @@
 
 	"golang.org/x/website"
 	"golang.org/x/website/internal/godoc"
-	"golang.org/x/website/internal/godoc/vfs"
-	"golang.org/x/website/internal/godoc/vfs/gatefs"
 )
 
 var (
@@ -75,23 +74,16 @@
 		usage()
 	}
 
-	fsGate := make(chan bool, 20)
-
-	// Determine file system to use.
-	rootfs := gatefs.New(vfs.OS(*goroot), fsGate)
-	fs.Bind("/", rootfs, "/", vfs.BindReplace)
-
-	// Try serving files from _content before trying the main
-	// go repository. This lets us update some documentation outside the
-	// Go release cycle. This includes root.html, which redirects to "/".
-	// See golang.org/issue/29206.
+	// Serve files from _content, falling back to GOROOT.
+	var content fs.FS
 	if *templateDir != "" {
-		fs.Bind("/", vfs.OS(*templateDir), "/", vfs.BindBefore)
+		content = os.DirFS(*templateDir)
 	} else {
-		fs.Bind("/", vfs.FromFS(website.Content), "/", vfs.BindBefore)
+		content = website.Content
 	}
+	fsys = unionFS{content, os.DirFS(*goroot)}
 
-	corpus := godoc.NewCorpus(fs)
+	corpus := godoc.NewCorpus(fsys)
 	corpus.Verbose = *verbose
 	if err := corpus.Init(); err != nil {
 		log.Fatal(err)
@@ -118,7 +110,6 @@
 		log.Printf("\tversion = %s", runtime.Version())
 		log.Printf("\taddress = %s", *httpAddr)
 		log.Printf("\tgoroot = %s", *goroot)
-		fs.Fprint(os.Stderr)
 		handler = loggingHandler(handler)
 	}
 
@@ -128,3 +119,65 @@
 		log.Fatalf("ListenAndServe %s: %v", *httpAddr, err)
 	}
 }
+
+var _ fs.ReadDirFS = unionFS{}
+
+// A unionFS is an FS presenting the union of the file systems in the slice.
+// If multiple file systems provide a particular file, Open uses the FS listed earlier in the slice.
+// If multiple file systems provide a particular directory, ReadDir presents the
+// concatenation of all the directories listed in the slice (with duplicates removed).
+type unionFS []fs.FS
+
+func (fsys unionFS) Open(name string) (fs.File, error) {
+	var errOut error
+	for _, sub := range fsys {
+		f, err := sub.Open(name)
+		if err == nil {
+			// Note: Should technically check for directory
+			// and return a synthetic directory that merges
+			// reads from all the matching directories,
+			// but all the directory reads in internal/godoc
+			// come from fsys.ReadDir, which does that for us.
+			// So we can ignore direct f.ReadDir calls.
+			return f, nil
+		}
+		if errOut == nil {
+			errOut = err
+		}
+	}
+	return nil, errOut
+}
+
+func (fsys unionFS) ReadDir(name string) ([]fs.DirEntry, error) {
+	var all []fs.DirEntry
+	var seen map[string]bool // seen[name] is true if name is listed in all; lazily initialized
+	var errOut error
+	for _, sub := range fsys {
+		list, err := fs.ReadDir(sub, toFS(name))
+		if err != nil {
+			errOut = err
+		}
+		if len(all) == 0 {
+			all = append(all, list...)
+		} else {
+			if seen == nil {
+				// Initialize seen only after we get two different directory listings.
+				seen = make(map[string]bool)
+				for _, d := range all {
+					seen[d.Name()] = true
+				}
+			}
+			for _, d := range list {
+				name := d.Name()
+				if !seen[name] {
+					seen[name] = true
+					all = append(all, d)
+				}
+			}
+		}
+	}
+	if len(all) > 0 {
+		return all, nil
+	}
+	return nil, errOut
+}
diff --git a/cmd/golangorg/project.go b/cmd/golangorg/project.go
index 06f605e..3b6d121 100644
--- a/cmd/golangorg/project.go
+++ b/cmd/golangorg/project.go
@@ -11,12 +11,12 @@
 	"bytes"
 	"fmt"
 	"html/template"
+	"io/fs"
 	"log"
 	"net/http"
 	"sort"
 
 	"golang.org/x/website/internal/godoc"
-	"golang.org/x/website/internal/godoc/vfs"
 	"golang.org/x/website/internal/history"
 )
 
@@ -33,7 +33,7 @@
 
 	const relPath = "doc/contrib.html"
 
-	src, err := vfs.ReadFile(fs, relPath)
+	src, err := fs.ReadFile(fsys, toFS(relPath))
 	if err != nil {
 		log.Printf("reading template %s: %v", relPath, err)
 		pres.ServeError(w, req, relPath, err)
diff --git a/cmd/golangorg/release.go b/cmd/golangorg/release.go
index 198b786..2438178 100644
--- a/cmd/golangorg/release.go
+++ b/cmd/golangorg/release.go
@@ -12,13 +12,13 @@
 	"fmt"
 	"html"
 	"html/template"
+	"io/fs"
 	"log"
 	"net/http"
 	"sort"
 	"strings"
 
 	"golang.org/x/website/internal/godoc"
-	"golang.org/x/website/internal/godoc/vfs"
 	"golang.org/x/website/internal/history"
 )
 
@@ -30,7 +30,7 @@
 func (h releaseHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 	const relPath = "doc/devel/release.html"
 
-	src, err := vfs.ReadFile(fs, relPath)
+	src, err := fs.ReadFile(fsys, toFS(relPath))
 	if err != nil {
 		log.Printf("reading template %s: %v", relPath, err)
 		pres.ServeError(w, req, relPath, err)
diff --git a/cmd/golangorg/release_test.go b/cmd/golangorg/release_test.go
index 80e543d..5b2270b 100644
--- a/cmd/golangorg/release_test.go
+++ b/cmd/golangorg/release_test.go
@@ -15,7 +15,6 @@
 
 	"golang.org/x/website"
 	"golang.org/x/website/internal/godoc"
-	"golang.org/x/website/internal/godoc/vfs"
 )
 
 // Test that the release history page includes expected entries.
@@ -25,11 +24,10 @@
 // It can be relaxed whenever the presentation of the release history
 // page needs to be changed.
 func TestReleaseHistory(t *testing.T) {
-	origFS, origPres := fs, pres
-	defer func() { fs, pres = origFS, origPres }()
-	fs = vfs.NameSpace{}
-	fs.Bind("/", vfs.FromFS(website.Content), "/", vfs.BindReplace)
-	pres = godoc.NewPresentation(godoc.NewCorpus(fs))
+	origFS, origPres := fsys, pres
+	defer func() { fsys, pres = origFS, origPres }()
+	fsys = website.Content
+	pres = godoc.NewPresentation(godoc.NewCorpus(fsys))
 	readTemplates(pres)
 	mux := registerHandlers(pres)
 
diff --git a/internal/godoc/corpus.go b/internal/godoc/corpus.go
index 74a2488..ddfd2e9 100644
--- a/internal/godoc/corpus.go
+++ b/internal/godoc/corpus.go
@@ -2,15 +2,18 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 import (
 	"errors"
+	"io/fs"
 	"sync"
 	"time"
 
 	"golang.org/x/website/internal/godoc/util"
-	"golang.org/x/website/internal/godoc/vfs"
 )
 
 // A Corpus holds all the state related to serving and indexing a
@@ -19,7 +22,7 @@
 // Construct a new Corpus with NewCorpus, then modify options,
 // then call its Init method.
 type Corpus struct {
-	fs vfs.FileSystem
+	fs fs.FS
 
 	// Verbose logging.
 	Verbose bool
@@ -58,9 +61,9 @@
 // NewCorpus returns a new Corpus from a filesystem.
 // The returned corpus has all indexing enabled and MaxResults set to 1000.
 // Change or set any options on Corpus before calling the Corpus.Init method.
-func NewCorpus(fs vfs.FileSystem) *Corpus {
+func NewCorpus(fsys fs.FS) *Corpus {
 	c := &Corpus{
-		fs:                    fs,
+		fs:                    fsys,
 		refreshMetadataSignal: make(chan bool, 1),
 	}
 	return c
diff --git a/internal/godoc/dirtrees.go b/internal/godoc/dirtrees.go
index 387d09c..5c0897c 100644
--- a/internal/godoc/dirtrees.go
+++ b/internal/godoc/dirtrees.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 // This file contains the code dealing with package directory trees.
 
 package godoc
@@ -10,14 +13,12 @@
 	"go/doc"
 	"go/parser"
 	"go/token"
+	"io/fs"
 	"log"
-	"os"
 	pathpkg "path"
 	"runtime"
 	"sort"
 	"strings"
-
-	"golang.org/x/website/internal/godoc/vfs"
 )
 
 // Conventional name for directories containing test data.
@@ -35,19 +36,24 @@
 	Dirs     []*Directory // subdirectories
 }
 
-func isGoFile(fi os.FileInfo) bool {
+type dirEntryOrFileInfo interface {
+	Name() string
+	IsDir() bool
+}
+
+func isGoFile(fi dirEntryOrFileInfo) bool {
 	name := fi.Name()
 	return !fi.IsDir() &&
 		len(name) > 0 && name[0] != '.' && // ignore .files
 		pathpkg.Ext(name) == ".go"
 }
 
-func isPkgFile(fi os.FileInfo) bool {
+func isPkgFile(fi dirEntryOrFileInfo) bool {
 	return isGoFile(fi) &&
 		!strings.HasSuffix(fi.Name(), "_test.go") // ignore test files
 }
 
-func isPkgDir(fi os.FileInfo) bool {
+func isPkgDir(fi dirEntryOrFileInfo) bool {
 	name := fi.Name()
 	return fi.IsDir() && len(name) > 0 &&
 		name[0] != '_' && name[0] != '.' // ignore _files and .files
@@ -100,7 +106,7 @@
 	}
 
 	ioGate <- struct{}{}
-	list, err := b.c.fs.ReadDir(path)
+	list, err := fs.ReadDir(b.c.fs, toFS(path))
 	<-ioGate
 	if err != nil {
 		// TODO: propagate more. See golang.org/issue/14252.
@@ -208,11 +214,19 @@
 	}
 }
 
-func isGOROOT(fs vfs.FileSystem) bool {
-	_, err := fs.Lstat("src/math/abs.go")
+func isGOROOT(fsys fs.FS) bool {
+	_, err := fs.Stat(fsys, "src/math/abs.go")
 	return err == nil
 }
 
+// toFS returns the io/fs name for path (no leading slash).
+func toFS(path string) string {
+	if path == "/" {
+		return "."
+	}
+	return pathpkg.Clean(strings.TrimPrefix(path, "/"))
+}
+
 // newDirectory creates a new package directory tree with at most maxDepth
 // levels, anchored at root. The result tree is pruned such that it only
 // contains directories that contain package files or that contain
@@ -225,7 +239,7 @@
 //
 func (c *Corpus) newDirectory(root string, maxDepth int) *Directory {
 	// The root could be a symbolic link so use Stat not Lstat.
-	d, err := c.fs.Stat(root)
+	d, err := fs.Stat(c.fs, toFS(root))
 	// If we fail here, report detailed error messages; otherwise
 	// is is hard to see why a directory tree was not built.
 	switch {
diff --git a/internal/godoc/dirtrees_test.go b/internal/godoc/dirtrees_test.go
index 552fe86..766bc55 100644
--- a/internal/godoc/dirtrees_test.go
+++ b/internal/godoc/dirtrees_test.go
@@ -2,26 +2,20 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 import (
-	"go/build"
-	"path/filepath"
+	"os"
 	"runtime"
 	"sort"
 	"testing"
-
-	"golang.org/x/website/internal/godoc/vfs"
-	"golang.org/x/website/internal/godoc/vfs/gatefs"
 )
 
 func TestNewDirTree(t *testing.T) {
-	fsGate := make(chan bool, 20)
-	rootfs := gatefs.New(vfs.OS(runtime.GOROOT()), fsGate)
-	fs := vfs.NameSpace{}
-	fs.Bind("/", rootfs, "/", vfs.BindReplace)
-
-	c := NewCorpus(fs)
+	c := NewCorpus(os.DirFS(runtime.GOROOT()))
 	// 3 levels deep is enough for testing
 	dir := c.newDirectory("/", 3)
 
@@ -46,15 +40,8 @@
 		b.Skip("not running tests requiring large file scan in short mode")
 	}
 
-	fsGate := make(chan bool, 20)
+	fs := os.DirFS(runtime.GOROOT())
 
-	goroot := runtime.GOROOT()
-	rootfs := gatefs.New(vfs.OS(goroot), fsGate)
-	fs := vfs.NameSpace{}
-	fs.Bind("/", rootfs, "/", vfs.BindReplace)
-	for _, p := range filepath.SplitList(build.Default.GOPATH) {
-		fs.Bind("/src/golang.org", gatefs.New(vfs.OS(p), fsGate), "/src/golang.org", vfs.BindAfter)
-	}
 	b.ResetTimer()
 	b.ReportAllocs()
 	for tries := 0; tries < b.N; tries++ {
@@ -73,7 +60,7 @@
 	}
 
 	for _, item := range tests {
-		fs := vfs.OS(item.path)
+		fs := os.DirFS(item.path)
 		if isGOROOT(fs) != item.isGOROOT {
 			t.Errorf("%s: isGOROOT = %v, want %v", item.path, !item.isGOROOT, item.isGOROOT)
 		}
diff --git a/internal/godoc/format.go b/internal/godoc/format.go
index 3e8c867..e005dcb 100644
--- a/internal/godoc/format.go
+++ b/internal/godoc/format.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 // This file implements FormatSelections and FormatText.
 // FormatText is used to HTML-format Go and non-Go source
 // text with line numbers and highlighted sections. It is
diff --git a/internal/godoc/godoc.go b/internal/godoc/godoc.go
index faec584..c23d549 100644
--- a/internal/godoc/godoc.go
+++ b/internal/godoc/godoc.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 // Package godoc is a work-in-progress (2013-07-17) package to
 // begin splitting up the godoc binary into multiple pieces.
 //
diff --git a/internal/godoc/godoc_test.go b/internal/godoc/godoc_test.go
index 2719ccc..fd65c7e 100644
--- a/internal/godoc/godoc_test.go
+++ b/internal/godoc/godoc_test.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 import (
diff --git a/internal/godoc/linkify.go b/internal/godoc/linkify.go
index e4add22..3f44ee6 100644
--- a/internal/godoc/linkify.go
+++ b/internal/godoc/linkify.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 // This file implements LinkifyText which introduces
 // links for identifiers pointing to their declarations.
 // The approach does not cover all cases because godoc
diff --git a/internal/godoc/markdown.go b/internal/godoc/markdown.go
index fd61aa5..9149bca 100644
--- a/internal/godoc/markdown.go
+++ b/internal/godoc/markdown.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 import (
diff --git a/internal/godoc/meta.go b/internal/godoc/meta.go
index c180254..da5d633 100644
--- a/internal/godoc/meta.go
+++ b/internal/godoc/meta.go
@@ -2,19 +2,21 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 import (
 	"bytes"
 	"encoding/json"
 	"errors"
+	"io/fs"
 	"log"
 	"os"
 	pathpkg "path"
 	"strings"
 	"time"
-
-	"golang.org/x/website/internal/godoc/vfs"
 )
 
 var (
@@ -67,7 +69,7 @@
 	metadata := make(map[string]*Metadata)
 	var scan func(string) // scan is recursive
 	scan = func(dir string) {
-		fis, err := c.fs.ReadDir(dir)
+		fis, err := fs.ReadDir(c.fs, toFS(dir))
 		if err != nil {
 			if dir == "/doc" && errors.Is(err, os.ErrNotExist) {
 				// Be quiet during tests that don't have a /doc tree.
@@ -86,7 +88,7 @@
 				continue
 			}
 			// Extract metadata from the file.
-			b, err := vfs.ReadFile(c.fs, name)
+			b, err := fs.ReadFile(c.fs, toFS(name))
 			if err != nil {
 				log.Printf("updateMetadata %s: %v", name, err)
 				continue
diff --git a/internal/godoc/page.go b/internal/godoc/page.go
index 279d803..d6cc640 100644
--- a/internal/godoc/page.go
+++ b/internal/godoc/page.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 import (
diff --git a/internal/godoc/parser.go b/internal/godoc/parser.go
index 8712918..f742596 100644
--- a/internal/godoc/parser.go
+++ b/internal/godoc/parser.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 // This file contains support functions for parsing .go files
 // accessed via godoc's file system fs.
 
@@ -12,9 +15,8 @@
 	"go/ast"
 	"go/parser"
 	"go/token"
+	"io/fs"
 	pathpkg "path"
-
-	"golang.org/x/website/internal/godoc/vfs"
 )
 
 var linePrefix = []byte("//line ")
@@ -47,7 +49,7 @@
 }
 
 func (c *Corpus) parseFile(fset *token.FileSet, filename string, mode parser.Mode) (*ast.File, error) {
-	src, err := vfs.ReadFile(c.fs, filename)
+	src, err := fs.ReadFile(c.fs, toFS(filename))
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/godoc/pres.go b/internal/godoc/pres.go
index cc01204..e4734bf 100644
--- a/internal/godoc/pres.go
+++ b/internal/godoc/pres.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 import (
@@ -9,8 +12,6 @@
 	"regexp"
 	"sync"
 	"text/template"
-
-	"golang.org/x/website/internal/godoc/vfs/httpfs"
 )
 
 // Presentation generates output from a corpus.
@@ -84,7 +85,7 @@
 	p := &Presentation{
 		Corpus:     c,
 		mux:        http.NewServeMux(),
-		fileServer: http.FileServer(httpfs.New(c.fs)),
+		fileServer: http.FileServer(http.FS(c.fs)),
 
 		TabWidth:  4,
 		DeclLinks: true,
diff --git a/internal/godoc/server.go b/internal/godoc/server.go
index 52178ed..3360048 100644
--- a/internal/godoc/server.go
+++ b/internal/godoc/server.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 import (
@@ -15,6 +18,7 @@
 	"go/token"
 	htmlpkg "html"
 	"io"
+	"io/fs"
 	"io/ioutil"
 	"log"
 	"net/http"
@@ -27,7 +31,6 @@
 	"time"
 
 	"golang.org/x/website/internal/godoc/util"
-	"golang.org/x/website/internal/godoc/vfs"
 )
 
 // handlerServer is a migration from an old godoc http Handler type.
@@ -65,21 +68,24 @@
 	ctxt := build.Default
 	ctxt.IsAbsPath = pathpkg.IsAbs
 	ctxt.IsDir = func(path string) bool {
-		fi, err := h.c.fs.Stat(filepath.ToSlash(path))
+		fi, err := fs.Stat(h.c.fs, toFS(filepath.ToSlash(path)))
 		return err == nil && fi.IsDir()
 	}
 	ctxt.ReadDir = func(dir string) ([]os.FileInfo, error) {
-		f, err := h.c.fs.ReadDir(filepath.ToSlash(dir))
+		f, err := fs.ReadDir(h.c.fs, toFS(filepath.ToSlash(dir)))
 		filtered := make([]os.FileInfo, 0, len(f))
 		for _, i := range f {
 			if mode&NoFiltering != 0 || i.Name() != "internal" {
-				filtered = append(filtered, i)
+				info, err := i.Info()
+				if err == nil {
+					filtered = append(filtered, info)
+				}
 			}
 		}
 		return filtered, err
 	}
 	ctxt.OpenFile = func(name string) (r io.ReadCloser, err error) {
-		data, err := vfs.ReadFile(h.c.fs, filepath.ToSlash(name))
+		data, err := fs.ReadFile(h.c.fs, toFS(filepath.ToSlash(name)))
 		if err != nil {
 			return nil, err
 		}
@@ -552,7 +558,7 @@
 }
 
 func (p *Presentation) serveTextFile(w http.ResponseWriter, r *http.Request, abspath, relpath, title string) {
-	src, err := vfs.ReadFile(p.Corpus.fs, abspath)
+	src, err := fs.ReadFile(p.Corpus.fs, toFS(abspath))
 	if err != nil {
 		log.Printf("ReadFile: %s", err)
 		p.ServeError(w, r, relpath, err)
@@ -636,17 +642,25 @@
 		return
 	}
 
-	list, err := p.Corpus.fs.ReadDir(abspath)
+	list, err := fs.ReadDir(p.Corpus.fs, toFS(abspath))
 	if err != nil {
 		p.ServeError(w, r, relpath, err)
 		return
 	}
 
+	var info []fs.FileInfo
+	for _, d := range list {
+		i, err := d.Info()
+		if err == nil {
+			info = append(info, i)
+		}
+	}
+
 	p.ServePage(w, Page{
 		Title:    "Directory",
 		SrcPath:  relpath,
 		Tabtitle: relpath,
-		Body:     applyTemplate(p.DirlistHTML, "dirlistHTML", list),
+		Body:     applyTemplate(p.DirlistHTML, "dirlistHTML", info),
 		GoogleCN: googleCN(r),
 	})
 }
@@ -654,9 +668,9 @@
 func (p *Presentation) ServeHTMLDoc(w http.ResponseWriter, r *http.Request, abspath, relpath string) {
 	// get HTML body contents
 	isMarkdown := false
-	src, err := vfs.ReadFile(p.Corpus.fs, abspath)
+	src, err := fs.ReadFile(p.Corpus.fs, toFS(abspath))
 	if err != nil && strings.HasSuffix(abspath, ".html") {
-		if md, errMD := vfs.ReadFile(p.Corpus.fs, strings.TrimSuffix(abspath, ".html")+".md"); errMD == nil {
+		if md, errMD := fs.ReadFile(p.Corpus.fs, toFS(strings.TrimSuffix(abspath, ".html")+".md")); errMD == nil {
 			src = md
 			isMarkdown = true
 			err = nil
@@ -764,19 +778,20 @@
 		return
 	}
 
-	dir, err := p.Corpus.fs.Lstat(abspath)
+	dir, err := fs.Stat(p.Corpus.fs, toFS(abspath))
 	if err != nil {
 		log.Print(err)
 		p.ServeError(w, r, relpath, err)
 		return
 	}
 
+	fsPath := toFS(abspath)
 	if dir != nil && dir.IsDir() {
 		if redirect(w, r) {
 			return
 		}
-		index := pathpkg.Join(abspath, "index.html")
-		if util.IsTextFile(p.Corpus.fs, index) || util.IsTextFile(p.Corpus.fs, pathpkg.Join(abspath, "index.md")) {
+		index := pathpkg.Join(fsPath, "index.html")
+		if util.IsTextFile(p.Corpus.fs, index) || util.IsTextFile(p.Corpus.fs, pathpkg.Join(fsPath, "index.md")) {
 			p.ServeHTMLDoc(w, r, index, index)
 			return
 		}
@@ -784,7 +799,7 @@
 		return
 	}
 
-	if util.IsTextFile(p.Corpus.fs, abspath) {
+	if util.IsTextFile(p.Corpus.fs, fsPath) {
 		if redirectFile(w, r) {
 			return
 		}
diff --git a/internal/godoc/server_test.go b/internal/godoc/server_test.go
index 7a446f9..ea3f8fb 100644
--- a/internal/godoc/server_test.go
+++ b/internal/godoc/server_test.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 import (
@@ -10,9 +13,8 @@
 	"net/url"
 	"strings"
 	"testing"
+	"testing/fstest"
 	"text/template"
-
-	"golang.org/x/website/internal/godoc/vfs/mapfs"
 )
 
 // TestIgnoredGoFiles tests the scenario where a folder has no .go or .c files,
@@ -21,11 +23,12 @@
 	packagePath := "github.com/package"
 	packageComment := "main is documented in an ignored .go file"
 
-	c := NewCorpus(mapfs.New(map[string]string{
-		"src/" + packagePath + "/ignored.go": `// +build ignore
+	c := NewCorpus(fstest.MapFS{
+		"src/" + packagePath + "/ignored.go": {Data: []byte(`// +build ignore
 
 // ` + packageComment + `
-package main`}))
+package main`)},
+	})
 	srv := &handlerServer{
 		p: &Presentation{
 			Corpus: c,
@@ -54,14 +57,15 @@
 
 func TestIssue5247(t *testing.T) {
 	const packagePath = "example.com/p"
-	c := NewCorpus(mapfs.New(map[string]string{
-		"src/" + packagePath + "/p.go": `package p
+	c := NewCorpus(fstest.MapFS{
+		"src/" + packagePath + "/p.go": {Data: []byte(`package p
 
 //line notgen.go:3
 // F doc //line 1 should appear
 // line 2 should appear
 func F()
-//line foo.go:100`})) // No newline at end to check corner cases.
+//line foo.go:100`)}, // No newline at end to check corner cases.
+	})
 
 	srv := &handlerServer{
 		p: &Presentation{Corpus: c},
@@ -85,14 +89,15 @@
 }
 
 func TestRedirectAndMetadata(t *testing.T) {
-	c := NewCorpus(mapfs.New(map[string]string{
-		"doc/y/index.html": "Hello, y.",
-		"doc/x/index.html": `<!--{
+	c := NewCorpus(fstest.MapFS{
+		"doc/y/index.html": {Data: []byte("Hello, y.")},
+		"doc/x/index.html": {Data: []byte(`<!--{
 		"Path": "/doc/x/"
 }-->
 
 Hello, x.
-`}))
+`)},
+	})
 	c.updateMetadata()
 	p := &Presentation{
 		Corpus:    c,
@@ -118,10 +123,10 @@
 
 func TestMarkdown(t *testing.T) {
 	p := &Presentation{
-		Corpus: NewCorpus(mapfs.New(map[string]string{
-			"doc/test.md":  "**bold**",
-			"doc/test2.md": `{{"*template*"}}`,
-		})),
+		Corpus: NewCorpus(fstest.MapFS{
+			"doc/test.md":  {Data: []byte("**bold**")},
+			"doc/test2.md": {Data: []byte(`{{"*template*"}}`)},
+		}),
 		GodocHTML: template.Must(template.New("").Parse(`{{printf "%s" .Body}}`)),
 	}
 
diff --git a/internal/godoc/spec.go b/internal/godoc/spec.go
index 9ec9427..2c2db4b 100644
--- a/internal/godoc/spec.go
+++ b/internal/godoc/spec.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 // This file contains the mechanism to "linkify" html source
diff --git a/internal/godoc/spec_test.go b/internal/godoc/spec_test.go
index c016516..77373f1 100644
--- a/internal/godoc/spec_test.go
+++ b/internal/godoc/spec_test.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 import (
diff --git a/internal/godoc/tab.go b/internal/godoc/tab.go
index d314ac7..944074e 100644
--- a/internal/godoc/tab.go
+++ b/internal/godoc/tab.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 // TODO(bradfitz,adg): move to util
 
 package godoc
diff --git a/internal/godoc/template.go b/internal/godoc/template.go
index 9b99e7e..3e52453 100644
--- a/internal/godoc/template.go
+++ b/internal/godoc/template.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 // Template support for writing HTML documents.
 // Documents that include Template: true in their
 // metadata are executed as input to text/template.
@@ -34,11 +37,10 @@
 import (
 	"bytes"
 	"fmt"
+	"io/fs"
 	"log"
 	"regexp"
 	"strings"
-
-	"golang.org/x/website/internal/godoc/vfs"
 )
 
 // Functions in this file panic on error, but the panic is recovered
@@ -47,7 +49,7 @@
 // contents reads and returns the content of the named file
 // (from the virtual file system, so for example /doc refers to $GOROOT/doc).
 func (c *Corpus) contents(name string) string {
-	file, err := vfs.ReadFile(c.fs, name)
+	file, err := fs.ReadFile(c.fs, toFS(name))
 	if err != nil {
 		log.Panic(err)
 	}
diff --git a/internal/godoc/util/util.go b/internal/godoc/util/util.go
index b15fd5c..2397241 100644
--- a/internal/godoc/util/util.go
+++ b/internal/godoc/util/util.go
@@ -2,16 +2,18 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 // Package util contains utility types and functions for godoc.
 package util // import "golang.org/x/website/internal/godoc/util"
 
 import (
+	"io/fs"
 	pathpkg "path"
 	"sync"
 	"time"
 	"unicode/utf8"
-
-	"golang.org/x/website/internal/godoc/vfs"
 )
 
 // An RWValue wraps a value and permits mutually exclusive
@@ -66,7 +68,7 @@
 // a text file, or if a significant chunk of the specified file looks like
 // correct UTF-8; that is, if it is likely that the file contains human-
 // readable text.
-func IsTextFile(fs vfs.Opener, filename string) bool {
+func IsTextFile(fsys fs.FS, filename string) bool {
 	// if the extension is known, use it for decision making
 	if isText, found := textExt[pathpkg.Ext(filename)]; found {
 		return isText
@@ -74,7 +76,7 @@
 
 	// the extension is not known; read an initial chunk
 	// of the file and check if it looks like text
-	f, err := fs.Open(filename)
+	f, err := fsys.Open(filename)
 	if err != nil {
 		return false
 	}
diff --git a/internal/godoc/versions.go b/internal/godoc/versions.go
index 7342858..e0a16f7 100644
--- a/internal/godoc/versions.go
+++ b/internal/godoc/versions.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 // This file caches information about which standard library types, methods,
 // and functions appeared in what version of Go
 
diff --git a/internal/godoc/versions_test.go b/internal/godoc/versions_test.go
index bfc05f6..1504b61 100644
--- a/internal/godoc/versions_test.go
+++ b/internal/godoc/versions_test.go
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build go1.16
+// +build go1.16
+
 package godoc
 
 import (