all: remove toFS usage

The toFS calls were a stop-gap to convert from old code that wasn't
strict about path forms to the io/fs routines that are more strict.

Arrange to pass io/fs-compatible paths everywhere and remove toFS.

Change-Id: Id69c0f23074ebd3a6dfef2255b2f8185ad1d1249
Reviewed-on: https://go-review.googlesource.com/c/website/+/317659
Trust: Russ Cox <rsc@golang.org>
Run-TryBot: Russ Cox <rsc@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/_content/lib/godoc/package.html b/_content/lib/godoc/package.html
index 3d6e8ba..7efdea1 100644
--- a/_content/lib/godoc/package.html
+++ b/_content/lib/godoc/package.html
@@ -95,7 +95,7 @@
 			<p>
 			<span style="font-size:90%">
 			{{range .}}
-				<a href="/src/{{.}}">{{basename .}}</a>
+				<a href="/{{.}}">{{basename .}}</a>
 			{{end}}
 			</span>
 			</p>
diff --git a/cmd/golangorg/codewalk.go b/cmd/golangorg/codewalk.go
index 52deb29..d3e5cbd 100644
--- a/cmd/golangorg/codewalk.go
+++ b/cmd/golangorg/codewalk.go
@@ -25,6 +25,7 @@
 	"log"
 	"net/http"
 	"os"
+	"path"
 	pathpkg "path"
 	"regexp"
 	"sort"
@@ -37,8 +38,7 @@
 
 // Handler for /doc/codewalk/ and below.
 func codewalk(w http.ResponseWriter, r *http.Request) {
-	relpath := r.URL.Path[len("/doc/codewalk/"):]
-	abspath := r.URL.Path
+	relpath := path.Clean(r.URL.Path[1:])
 
 	r.ParseForm()
 	if f := r.FormValue("fileprint"); f != "" {
@@ -47,9 +47,9 @@
 	}
 
 	// If directory exists, serve list of code walks.
-	dir, err := fs.Stat(fsys, toFS(abspath))
+	dir, err := fs.Stat(fsys, relpath)
 	if err == nil && dir.IsDir() {
-		codewalkDir(w, r, relpath, abspath)
+		codewalkDir(w, r, relpath)
 		return
 	}
 
@@ -62,8 +62,7 @@
 	// Otherwise append .xml and hope to find
 	// a codewalk description, but before trim
 	// the trailing /.
-	abspath = strings.TrimRight(abspath, "/")
-	cw, err := loadCodewalk(abspath + ".xml")
+	cw, err := loadCodewalk(relpath + ".xml")
 	if err != nil {
 		log.Print(err)
 		site.ServeError(w, r, err)
@@ -140,7 +139,7 @@
 
 // loadCodewalk reads a codewalk from the named XML file.
 func loadCodewalk(filename string) (*Codewalk, error) {
-	f, err := fsys.Open(toFS(filename))
+	f, err := fsys.Open(filename)
 	if err != nil {
 		return nil, err
 	}
@@ -161,7 +160,7 @@
 			i = len(st.Src)
 		}
 		filename := st.Src[0:i]
-		data, err := fs.ReadFile(fsys, toFS(filename))
+		data, err := fs.ReadFile(fsys, filename)
 		if err != nil {
 			st.Err = err
 			continue
@@ -202,13 +201,13 @@
 // codewalkDir serves the codewalk directory listing.
 // It scans the directory for subdirectories or files named *.xml
 // and prepares a table.
-func codewalkDir(w http.ResponseWriter, r *http.Request, relpath, abspath string) {
+func codewalkDir(w http.ResponseWriter, r *http.Request, relpath string) {
 	type elem struct {
 		Name  string
 		Title string
 	}
 
-	dir, err := fs.ReadDir(fsys, toFS(abspath))
+	dir, err := fs.ReadDir(fsys, relpath)
 	if err != nil {
 		log.Print(err)
 		site.ServeError(w, r, err)
@@ -220,7 +219,7 @@
 		if fi.IsDir() {
 			v = append(v, &elem{name + "/", ""})
 		} else if strings.HasSuffix(name, ".xml") {
-			cw, err := loadCodewalk(abspath + "/" + name)
+			cw, err := loadCodewalk(relpath + "/" + name)
 			if err != nil {
 				continue
 			}
@@ -242,8 +241,8 @@
 // of the codewalk pages.  It is a separate iframe and does not get
 // the usual godoc HTML wrapper.
 func codewalkFileprint(w http.ResponseWriter, r *http.Request, f string) {
-	abspath := f
-	data, err := fs.ReadFile(fsys, toFS(abspath))
+	relpath := strings.Trim(path.Clean(f), "/")
+	data, err := fs.ReadFile(fsys, relpath)
 	if err != nil {
 		log.Print(err)
 		site.ServeError(w, r, err)
diff --git a/cmd/golangorg/handlers.go b/cmd/golangorg/handlers.go
index 1ea8478..ee2cb77 100644
--- a/cmd/golangorg/handlers.go
+++ b/cmd/golangorg/handlers.go
@@ -12,7 +12,6 @@
 	"go/format"
 	"io/fs"
 	"net/http"
-	pathpkg "path"
 	"strings"
 
 	"golang.org/x/website/internal/env"
@@ -25,14 +24,6 @@
 	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
diff --git a/cmd/golangorg/main.go b/cmd/golangorg/main.go
index 7e832d5..37d9d8b 100644
--- a/cmd/golangorg/main.go
+++ b/cmd/golangorg/main.go
@@ -137,7 +137,7 @@
 	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))
+		list, err := fs.ReadDir(sub, name)
 		if err != nil {
 			errOut = err
 		}
diff --git a/internal/pkgdoc/dir.go b/internal/pkgdoc/dir.go
index e8b2c61..fe995e2 100644
--- a/internal/pkgdoc/dir.go
+++ b/internal/pkgdoc/dir.go
@@ -22,14 +22,6 @@
 	"strings"
 )
 
-// toFS returns the io/fs name for path (no leading slash).
-func toFS(name string) string {
-	if name == "/" {
-		return "."
-	}
-	return path.Clean(strings.TrimPrefix(name, "/"))
-}
-
 type Dir struct {
 	Path     string // directory path
 	HasPkg   bool   // true if the directory contains at least one package
@@ -65,17 +57,15 @@
 	if name == dir.Path {
 		return dir
 	}
-	dirPathLen := len(dir.Path)
-	if dir.Path == "/" {
-		dirPathLen = 0 // so path[dirPathLen] is a slash
-	}
-	if !strings.HasPrefix(name, dir.Path) || name[dirPathLen] != '/' {
-		println("NO", name, dir.Path)
-		return nil
+	if dir.Path != "." {
+		if !strings.HasPrefix(name, dir.Path) || name[len(dir.Path)] != '/' {
+			return nil
+		}
+		name = name[len(dir.Path)+1:]
 	}
 	d := dir
 Walk:
-	for i := dirPathLen + 1; i <= len(name); i++ {
+	for i := 0; i <= len(name); i++ {
 		if i == len(name) || name[i] == '/' {
 			// Find next child along path.
 			for _, sub := range d.Dirs {
@@ -84,7 +74,6 @@
 					continue Walk
 				}
 			}
-			println("LOST", name[:i])
 			return nil
 		}
 	}
@@ -126,16 +115,16 @@
 	return &DirList{list}
 }
 
-func newDir(fsys fs.FS, fset *token.FileSet, abspath string) *Dir {
+func newDir(fsys fs.FS, fset *token.FileSet, dirpath string) *Dir {
 	var synopses [3]string // prioritized package documentation (0 == highest priority)
 
 	hasPkgFiles := false
 	haveSummary := false
 
-	list, err := fs.ReadDir(fsys, toFS(abspath))
+	list, err := fs.ReadDir(fsys, dirpath)
 	if err != nil {
 		// TODO: propagate more. See golang.org/issue/14252.
-		log.Printf("newDirTree reading %s: %v", abspath, err)
+		log.Printf("newDirTree reading %s: %v", dirpath, err)
 	}
 
 	// determine number of subdirectories and if there are package files
@@ -143,7 +132,7 @@
 	var dirs []*Dir
 
 	for _, d := range list {
-		filename := path.Join(abspath, d.Name())
+		filename := path.Join(dirpath, d.Name())
 		switch {
 		case isPkgDir(d):
 			dir := newDir(fsys, fset, filename)
@@ -168,7 +157,7 @@
 				// prioritize documentation
 				i := -1
 				switch file.Name.Name {
-				case path.Base(abspath):
+				case path.Base(dirpath):
 					i = 0 // normal case: directory name matches package name
 				case "main":
 					i = 1 // directory contains a main package
@@ -211,7 +200,7 @@
 	}
 
 	return &Dir{
-		Path:     abspath,
+		Path:     dirpath,
 		HasPkg:   hasPkgFiles,
 		Synopsis: synopsis,
 		Dirs:     dirs,
@@ -247,7 +236,7 @@
 }
 
 func parseFile(fsys fs.FS, fset *token.FileSet, filename string, mode parser.Mode) (*ast.File, error) {
-	src, err := fs.ReadFile(fsys, toFS(filename))
+	src, err := fs.ReadFile(fsys, filename)
 	if err != nil {
 		return nil, err
 	}
@@ -259,15 +248,15 @@
 	return parser.ParseFile(fset, filename, src, mode)
 }
 
-func parseFiles(fsys fs.FS, fset *token.FileSet, relpath string, abspath string, localnames []string) (map[string]*ast.File, error) {
+func parseFiles(fsys fs.FS, fset *token.FileSet, dirname string, localnames []string) (map[string]*ast.File, error) {
 	files := make(map[string]*ast.File)
 	for _, f := range localnames {
-		absname := path.Join(abspath, f)
-		file, err := parseFile(fsys, fset, absname, parser.ParseComments)
+		filename := path.Join(dirname, f)
+		file, err := parseFile(fsys, fset, filename, parser.ParseComments)
 		if err != nil {
 			return nil, err
 		}
-		files[path.Join(relpath, f)] = file
+		files[filename] = file
 	}
 
 	return files, nil
diff --git a/internal/pkgdoc/dir_test.go b/internal/pkgdoc/dir_test.go
index 9441274..db1c782 100644
--- a/internal/pkgdoc/dir_test.go
+++ b/internal/pkgdoc/dir_test.go
@@ -17,7 +17,7 @@
 )
 
 func TestNewDirTree(t *testing.T) {
-	dir := newDir(os.DirFS(runtime.GOROOT()), token.NewFileSet(), "/src")
+	dir := newDir(os.DirFS(runtime.GOROOT()), token.NewFileSet(), "src")
 	processDir(t, dir)
 }
 
@@ -62,6 +62,6 @@
 	b.ResetTimer()
 	b.ReportAllocs()
 	for tries := 0; tries < b.N; tries++ {
-		newDir(fs, token.NewFileSet(), "/src")
+		newDir(fs, token.NewFileSet(), "src")
 	}
 }
diff --git a/internal/pkgdoc/doc.go b/internal/pkgdoc/doc.go
index 02e7b5b..218228e 100644
--- a/internal/pkgdoc/doc.go
+++ b/internal/pkgdoc/doc.go
@@ -32,9 +32,9 @@
 }
 
 func NewDocs(fsys fs.FS) *Docs {
-	src := newDir(fsys, token.NewFileSet(), "/src")
+	src := newDir(fsys, token.NewFileSet(), "src")
 	root := &Dir{
-		Path: "/",
+		Path: ".",
 		Dirs: []*Dir{src},
 	}
 	return &Docs{
@@ -112,14 +112,15 @@
 	return mode
 }
 
-// Doc returns the Page for a package directory abspath.
+// Doc returns the Page for a package directory dir.
 // Package documentation (Page.PDoc) is extracted from the AST.
 // If there is no corresponding package in the
 // directory, Page.PDoc is nil. If there are no sub-
 // directories, Page.Dirs is nil. If an error occurred, PageInfo.Err is
 // set to the respective error but the error is not logged.
-func Doc(d *Docs, abspath, relpath string, mode Mode, goos, goarch string) *Page {
-	info := &Page{Dirname: abspath, Mode: mode}
+func Doc(d *Docs, dir string, mode Mode, goos, goarch string) *Page {
+	dir = path.Clean(dir)
+	info := &Page{Dirname: dir, Mode: mode}
 
 	// Restrict to the package files that would be used when building
 	// the package on this system.  This makes sure that if there are
@@ -130,11 +131,11 @@
 	ctxt := build.Default
 	ctxt.IsAbsPath = path.IsAbs
 	ctxt.IsDir = func(path string) bool {
-		fi, err := fs.Stat(d.fs, toFS(filepath.ToSlash(path)))
+		fi, err := fs.Stat(d.fs, filepath.ToSlash(path))
 		return err == nil && fi.IsDir()
 	}
 	ctxt.ReadDir = func(dir string) ([]os.FileInfo, error) {
-		f, err := fs.ReadDir(d.fs, toFS(filepath.ToSlash(dir)))
+		f, err := fs.ReadDir(d.fs, filepath.ToSlash(dir))
 		filtered := make([]os.FileInfo, 0, len(f))
 		for _, i := range f {
 			if mode&ModeAll != 0 || i.Name() != "internal" {
@@ -147,7 +148,7 @@
 		return filtered, err
 	}
 	ctxt.OpenFile = func(name string) (r io.ReadCloser, err error) {
-		data, err := fs.ReadFile(d.fs, toFS(filepath.ToSlash(name)))
+		data, err := fs.ReadFile(d.fs, filepath.ToSlash(name))
 		if err != nil {
 			return nil, err
 		}
@@ -159,7 +160,7 @@
 	// linux/amd64 means the wasm syscall/js package was blank.
 	// And you can't run godoc on js/wasm anyway, so host defaults
 	// don't make sense here.
-	if goos == "" && goarch == "" && relpath == "syscall/js" {
+	if goos == "" && goarch == "" && dir == "syscall/js" {
 		goos, goarch = "js", "wasm"
 	}
 	if goos != "" {
@@ -169,7 +170,7 @@
 		ctxt.GOARCH = goarch
 	}
 
-	pkginfo, err := ctxt.ImportDir(abspath, 0)
+	pkginfo, err := ctxt.ImportDir(dir, 0)
 	// continue if there are no Go source files; we still want the directory info
 	if _, nogo := err.(*build.NoGoError); err != nil && !nogo {
 		info.Err = err
@@ -193,7 +194,7 @@
 	if len(pkgfiles) > 0 {
 		// build package AST
 		fset := token.NewFileSet()
-		files, err := parseFiles(d.fs, fset, relpath, abspath, pkgfiles)
+		files, err := parseFiles(d.fs, fset, dir, pkgfiles)
 		if err != nil {
 			info.Err = err
 			return info
@@ -213,7 +214,7 @@
 		if mode&ModeMethods != 0 {
 			m |= doc.AllMethods
 		}
-		info.PDoc = doc.New(pkg, path.Clean(relpath), m) // no trailing '/' in importpath
+		info.PDoc = doc.New(pkg, strings.TrimPrefix(dir, "src/"), m)
 		if mode&ModeBuiltin != 0 {
 			for _, t := range info.PDoc.Types {
 				info.PDoc.Consts = append(info.PDoc.Consts, t.Consts...)
@@ -230,7 +231,7 @@
 
 		// collect examples
 		testfiles := append(pkginfo.TestGoFiles, pkginfo.XTestGoFiles...)
-		files, err = parseFiles(d.fs, fset, relpath, abspath, testfiles)
+		files, err = parseFiles(d.fs, fset, dir, testfiles)
 		if err != nil {
 			log.Println("parsing examples:", err)
 		}
@@ -238,7 +239,7 @@
 		info.Bugs = info.PDoc.Notes["BUG"]
 	}
 
-	info.Dirs = d.root.Lookup(abspath).List(func(path string) bool { return d.includePath(path, mode) })
+	info.Dirs = d.root.Lookup(dir).List(func(path string) bool { return d.includePath(path, mode) })
 	info.DirFlat = mode&ModeFlat != 0
 
 	return info
diff --git a/internal/pkgdoc/doc_test.go b/internal/pkgdoc/doc_test.go
index c82d042..1ac4f0a 100644
--- a/internal/pkgdoc/doc_test.go
+++ b/internal/pkgdoc/doc_test.go
@@ -25,7 +25,7 @@
 package main`)},
 	}
 	d := NewDocs(fs)
-	pInfo := Doc(d, "/src/"+packagePath, packagePath, ModeAll, "linux", "amd64")
+	pInfo := Doc(d, "src/"+packagePath, ModeAll, "linux", "amd64")
 
 	if pInfo.PDoc == nil {
 		t.Error("pInfo.PDoc = nil; want non-nil.")
@@ -58,7 +58,7 @@
 	}
 
 	d := NewDocs(fs)
-	pInfo := Doc(d, "/src/"+packagePath, packagePath, 0, "linux", "amd64")
+	pInfo := Doc(d, "src/"+packagePath, 0, "linux", "amd64")
 	if got, want := pInfo.PDoc.Funcs[0].Doc, "F doc //line 1 should appear\nline 2 should appear\n"; got != want {
 		t.Errorf("pInfo.PDoc.Funcs[0].Doc = %q; want %q", got, want)
 	}
diff --git a/internal/web/docfuncs.go b/internal/web/docfuncs.go
index a11190f..1f7850e 100644
--- a/internal/web/docfuncs.go
+++ b/internal/web/docfuncs.go
@@ -13,6 +13,7 @@
 	"html/template"
 	"io/fs"
 	"log"
+	"path"
 	"regexp"
 	"strings"
 
@@ -34,7 +35,12 @@
 		}
 	}()
 
-	text := s.contents(file)
+	file = path.Clean(strings.TrimPrefix(file, "/"))
+	btext, err := fs.ReadFile(s.fs, file)
+	if err != nil {
+		return "", err
+	}
+	text := string(btext)
 	var command string
 	switch len(arg) {
 	case 0:
@@ -64,16 +70,6 @@
 // Functions in this file panic on error, but the panic is recovered
 // to an error by 'code'.
 
-// contents reads and returns the content of the named file
-// (from the virtual file system, so for example /doc refers to $GOROOT/doc).
-func (s *Site) contents(name string) string {
-	file, err := fs.ReadFile(s.fs, toFS(name))
-	if err != nil {
-		log.Panic(err)
-	}
-	return string(file)
-}
-
 // stringFor returns a textual representation of the arg, formatted according to its nature.
 func stringFor(arg interface{}) string {
 	switch arg := arg.(type) {
@@ -92,7 +88,7 @@
 
 // oneLine returns the single line generated by a two-argument code invocation.
 func (s *Site) oneLine(file, text string, arg interface{}) string {
-	lines := strings.SplitAfter(s.contents(file), "\n")
+	lines := strings.SplitAfter(text, "\n")
 	line, pattern, isInt := parseArg(arg, file, len(lines))
 	if isInt {
 		return lines[line-1]
@@ -102,7 +98,7 @@
 
 // multipleLines returns the text generated by a three-argument code invocation.
 func (s *Site) multipleLines(file, text string, arg1, arg2 interface{}) string {
-	lines := strings.SplitAfter(s.contents(file), "\n")
+	lines := strings.SplitAfter(text, "\n")
 	line1, pattern1, isInt1 := parseArg(arg1, file, len(lines))
 	line2, pattern2, isInt2 := parseArg(arg2, file, len(lines))
 	if !isInt1 {
@@ -111,7 +107,7 @@
 	if !isInt2 {
 		line2 = match(file, line1, lines, pattern2)
 	} else if line2 < line1 {
-		log.Panicf("lines out of order for %q: %d %d", text, line1, line2)
+		log.Panicf("lines out of order for %q: %d %d", file, line1, line2)
 	}
 	for k := line1 - 1; k < line2; k++ {
 		if strings.HasSuffix(lines[k], "OMIT\n") {
diff --git a/internal/web/file.go b/internal/web/file.go
index f2ff605..c82ff85 100644
--- a/internal/web/file.go
+++ b/internal/web/file.go
@@ -34,25 +34,23 @@
 	Redirect string // if set, redirect to other URL
 }
 
-var join = path.Join
-
-// open returns the file for a given absolute path or nil if none exists.
-func open(fsys fs.FS, path string) *file {
+// open returns the *file for a given relative path or nil if none exists.
+func open(fsys fs.FS, relpath string) *file {
 	// Strip trailing .html or .md or /; it all names the same page.
-	if strings.HasSuffix(path, ".html") {
-		path = strings.TrimSuffix(path, ".html")
-	} else if strings.HasSuffix(path, ".md") {
-		path = strings.TrimSuffix(path, ".md")
-	} else if path != "/" && strings.HasSuffix(path, "/") {
-		path = strings.TrimSuffix(path, "/")
+	if strings.HasSuffix(relpath, ".html") {
+		relpath = strings.TrimSuffix(relpath, ".html")
+	} else if strings.HasSuffix(relpath, ".md") {
+		relpath = strings.TrimSuffix(relpath, ".md")
+	} else if strings.HasSuffix(relpath, "/") {
+		relpath = strings.TrimSuffix(relpath, "/")
 	}
 
-	files := []string{path + ".html", path + ".md", join(path, "index.html"), join(path, "index.md")}
+	files := []string{relpath + ".html", relpath + ".md", path.Join(relpath, "index.html"), path.Join(relpath, "index.md")}
 	var filePath string
 	var b []byte
 	var err error
 	for _, filePath = range files {
-		b, err = fs.ReadFile(fsys, toFS(filePath))
+		b, err = fs.ReadFile(fsys, filePath)
 		if err == nil {
 			break
 		}
@@ -60,14 +58,14 @@
 
 	// Special case for memory model and spec, which live
 	// in the main Go repo's doc directory and therefore have not
-	// been renamed to their serving paths.
+	// been renamed to their serving relpaths.
 	// We wait until the ReadFiles above have failed so that the
 	// code works if these are ever moved to /ref/spec and /ref/mem.
-	if err != nil && path == "/ref/spec" {
-		return open(fsys, "/doc/go_spec")
+	if err != nil && relpath == "ref/spec" {
+		return open(fsys, "doc/go_spec")
 	}
-	if err != nil && path == "/ref/mem" {
-		return open(fsys, "/doc/go_mem")
+	if err != nil && relpath == "ref/mem" {
+		return open(fsys, "doc/go_mem")
 	}
 
 	if err != nil {
@@ -75,21 +73,21 @@
 	}
 
 	// Special case for memory model and spec, continued.
-	switch path {
-	case "/doc/go_spec":
-		path = "/ref/spec"
-	case "/doc/go_mem":
-		path = "/ref/mem"
+	switch relpath {
+	case "doc/go_spec":
+		relpath = "ref/spec"
+	case "doc/go_mem":
+		relpath = "ref/mem"
 	}
 
-	// If we read an index.md or index.html, the canonical path is without the index.md/index.html suffix.
-	if strings.HasSuffix(filePath, "/index.md") || strings.HasSuffix(filePath, "/index.html") {
-		path = filePath[:strings.LastIndex(filePath, "/")+1]
+	// If we read an index.md or index.html, the canonical relpath is without the index.md/index.html suffix.
+	if name := path.Base(filePath); name == "index.html" || name == "index.md" {
+		relpath, _ = path.Split(filePath)
 	}
 
 	js, body, err := parseFile(b)
 	if err != nil {
-		log.Printf("extractMetadata %s: %v", path, err)
+		log.Printf("extractMetadata %s: %v", relpath, err)
 		return nil
 	}
 
@@ -97,7 +95,7 @@
 		Title:    js.Title,
 		Subtitle: js.Subtitle,
 		Template: js.Template,
-		Path:     path,
+		Path:     "/" + relpath,
 		FilePath: filePath,
 		Body:     body,
 	}
diff --git a/internal/web/pkgdoc.go b/internal/web/pkgdoc.go
index c0b5e91..32949f4 100644
--- a/internal/web/pkgdoc.go
+++ b/internal/web/pkgdoc.go
@@ -31,7 +31,6 @@
 	relpath := path.Clean(strings.TrimPrefix(r.URL.Path, "/pkg"))
 	relpath = strings.TrimPrefix(relpath, "/")
 
-	abspath := path.Join("/src", relpath)
 	mode := pkgdoc.ParseMode(r.FormValue("m"))
 	if relpath == "builtin" {
 		// The fake built-in package contains unexported identifiers,
@@ -39,7 +38,7 @@
 		// since it's not helpful for this fake package (see issue 6645).
 		mode |= pkgdoc.ModeAll | pkgdoc.ModeBuiltin
 	}
-	info := pkgdoc.Doc(h.d, abspath, relpath, mode, r.FormValue("GOOS"), r.FormValue("GOARCH"))
+	info := pkgdoc.Doc(h.d, "src/"+relpath, mode, r.FormValue("GOOS"), r.FormValue("GOARCH"))
 	if info.Err != nil {
 		log.Print(info.Err)
 		h.p.ServeError(w, r, info.Err)
@@ -76,7 +75,7 @@
 	}
 
 	name := "package.html"
-	if info.Dirname == "/src" {
+	if info.Dirname == "src" {
 		name = "packageroot.html"
 	}
 	h.p.ServePage(w, r, Page{
diff --git a/internal/web/site.go b/internal/web/site.go
index 5756714..1ad8948 100644
--- a/internal/web/site.go
+++ b/internal/web/site.go
@@ -28,14 +28,6 @@
 	"golang.org/x/website/internal/texthtml"
 )
 
-// toFS returns the io/fs name for path (no leading slash).
-func toFS(name string) string {
-	if name == "/" {
-		return "."
-	}
-	return path.Clean(strings.TrimPrefix(name, "/"))
-}
-
 // Site is a website served from a file system.
 type Site struct {
 	fs  fs.FS
@@ -204,7 +196,8 @@
 
 	// Check to see if we need to redirect or serve another file.
 	abspath := r.URL.Path
-	if f := open(s.fs, abspath); f != nil {
+	relpath := path.Clean(strings.TrimPrefix(abspath, "/"))
+	if f := open(s.fs, relpath); f != nil {
 		if f.Path != abspath {
 			// Redirect to canonical path.
 			http.Redirect(w, r, f.Path, http.StatusMovedPermanently)
@@ -215,16 +208,14 @@
 		return
 	}
 
-	relpath := abspath[1:] // strip leading slash
-
-	dir, err := fs.Stat(s.fs, toFS(abspath))
+	dir, err := fs.Stat(s.fs, relpath)
 	if err != nil {
 		// Check for spurious trailing slash.
 		if strings.HasSuffix(abspath, "/") {
-			trimmed := abspath[:len(abspath)-1]
-			if _, err := fs.Stat(s.fs, toFS(trimmed)); err == nil ||
+			trimmed := relpath[:len(relpath)-1]
+			if _, err := fs.Stat(s.fs, trimmed); err == nil ||
 				open(s.fs, trimmed) != nil {
-				http.Redirect(w, r, trimmed, http.StatusMovedPermanently)
+				http.Redirect(w, r, "/"+trimmed, http.StatusMovedPermanently)
 				return
 			}
 		}
@@ -232,20 +223,19 @@
 		return
 	}
 
-	fsPath := toFS(abspath)
 	if dir != nil && dir.IsDir() {
 		if maybeRedirect(w, r) {
 			return
 		}
-		s.serveDir(w, r, abspath, relpath)
+		s.serveDir(w, r, relpath)
 		return
 	}
 
-	if isTextFile(s.fs, fsPath) {
+	if isTextFile(s.fs, relpath) {
 		if maybeRedirectFile(w, r) {
 			return
 		}
-		s.serveText(w, r, abspath, relpath)
+		s.serveText(w, r, relpath)
 		return
 	}
 
@@ -337,12 +327,12 @@
 	s.ServePage(w, r, page)
 }
 
-func (s *Site) serveDir(w http.ResponseWriter, r *http.Request, abspath, relpath string) {
+func (s *Site) serveDir(w http.ResponseWriter, r *http.Request, relpath string) {
 	if maybeRedirect(w, r) {
 		return
 	}
 
-	list, err := fs.ReadDir(s.fs, toFS(abspath))
+	list, err := fs.ReadDir(s.fs, relpath)
 	if err != nil {
 		s.ServeError(w, r, err)
 		return
@@ -365,8 +355,8 @@
 	})
 }
 
-func (s *Site) serveText(w http.ResponseWriter, r *http.Request, abspath, relpath string) {
-	src, err := fs.ReadFile(s.fs, toFS(abspath))
+func (s *Site) serveText(w http.ResponseWriter, r *http.Request, relpath string) {
+	src, err := fs.ReadFile(s.fs, relpath)
 	if err != nil {
 		log.Printf("ReadFile: %s", err)
 		s.ServeError(w, r, err)
@@ -379,7 +369,7 @@
 	}
 
 	cfg := texthtml.Config{
-		GoComments: path.Ext(abspath) == ".go",
+		GoComments: path.Ext(relpath) == ".go",
 		Highlight:  r.FormValue("h"),
 		Selection:  rangeSelection(r.FormValue("s")),
 		Line:       1,