internal/godoc: refactor into internal/web [generated]

What's left of internal/godoc is mainly a simple web server
for static content (plus the package docs provided by internal/pkgdoc)
and bears little resemblance to the original godoc.

Revamp the API and retire the name, moving to internal/web.

CL generated by the script below.

[git-generate]
cd internal/godoc
mv util.go istext.go
mv godoc_test.go template_test.go
mv server_test.go site_test.go

rf '
	mv TabWidth tabWidth
	mv IsText isText
	mv TabSpacer tabSpacer

	mv Presentation Site
	mv NewPresentation NewSite
	mv writerCapturesErr writeErrorSaver
	rm marshalJSON
	mv redirect maybeRedirect
	mv redirectFile maybeRedirectFile
	mv Site.serveText Site.serveRawText
	mv Site.serveTextFile Site.serveText
	mv Site.serveDirectory Site.serveDir
	mv Site.initFuncMap Site.initDocFuncs

	mv \
		toFS \
		Site \
		NewSite \
		Site.ServeError \
		Site.ServeHTTP \
		Site.ServePage \
		Page \
		Site.fullPage \
		Page.Invoke \
		writeErrorSaver \
		writeErrorSaver.Write \
		applyTemplateToResponseWriter \
		Site.serveFile \
		maybeRedirect \
		maybeRedirectFile \
		doctype \
		Site.serveHTML \
		Site.serveDir \
		Site.serveText \
		selRx \
		rangeSelection \
		Site.serveRawText \
		Site.googleCN \
		site.go

	mv example_nameFunc example_name
	mv example_suffixFunc example_suffix
	mv srcPosLinkFunc srcPosLink

	mv \
		siteFuncs \
		example_name \
		example_suffix \
		srcToPkg \
		Page.SrcPkgLink \
		Page.SrcBreadcrumb \
		Page.SrcPosLink \
		srcPosLink \
		sitefuncs.go

	mv \
		docServer \
		docServer.ServeHTTP \
		Page.ModeQuery \
		pkgdoc.go

	mv metaJSON fileJSON
	mv extractMetadata parseFile
	mv \
		file \
		fileJSON \
		join \
		open \
		jsonStart \
		parseFile \
		file.go

	mv \
		Site.initDocFuncs \
		Site.code \
		Site.contents \
		stringFor \
		Site.oneLine \
		Site.multipleLines \
		parseArg \
		match \
		docfuncs.go

	mv Site.ServeError.p Site.ServeError.s
	mv Site.ServeHTTP.p Site.ServeHTTP.s
	mv Site.ServePage.p Site.ServePage.s
	mv Site.code.p Site.code.s
	mv Site.contents.p Site.contents.s
	mv Site.fullPage.p Site.fullPage.s
	mv Site.googleCN.p Site.googleCN.s
	mv Site.initDocFuncs.p Site.initDocFuncs.s
	mv Site.multipleLines.p Site.multipleLines.s
	mv Site.oneLine.p Site.oneLine.s
	mv Site.serveDir.p Site.serveDir.s
	mv Site.serveFile.p Site.serveFile.s
	mv Site.serveHTML.p Site.serveHTML.s
	mv Site.serveRawText.p Site.serveRawText.s
	mv Site.serveText.p Site.serveText.s
	mv Site.writeNode.p Site.writeNode.s

	mv Page.pres Page.site

	mv astfuncs.go docfuncs.go examplefuncs.go \
		file.go istext.go markdown.go pkgdoc.go \
		site.go site_test.go sitefuncs.go \
		tab.go template_test.go \
		golang.org/x/website/internal/web
'
rm godoc.go meta.go page.go pres.go server.go template.go

cd ../../cmd/golangorg
rf '
	mv pres site
'

Change-Id: Ic03a2dbe14f74c60bd6a5a86ba4d3f36d8c5bea8
Reviewed-on: https://go-review.googlesource.com/c/website/+/317656
Trust: Russ Cox <rsc@golang.org>
Run-TryBot: 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 d8d2284..52deb29 100644
--- a/cmd/golangorg/codewalk.go
+++ b/cmd/golangorg/codewalk.go
@@ -32,7 +32,7 @@
 	"strings"
 	"unicode/utf8"
 
-	"golang.org/x/website/internal/godoc"
+	"golang.org/x/website/internal/web"
 )
 
 // Handler for /doc/codewalk/ and below.
@@ -55,7 +55,7 @@
 
 	// If file exists, serve using standard file server.
 	if err == nil {
-		pres.ServeHTTP(w, r)
+		site.ServeHTTP(w, r)
 		return
 	}
 
@@ -66,7 +66,7 @@
 	cw, err := loadCodewalk(abspath + ".xml")
 	if err != nil {
 		log.Print(err)
-		pres.ServeError(w, r, err)
+		site.ServeError(w, r, err)
 		return
 	}
 
@@ -75,7 +75,7 @@
 		return
 	}
 
-	pres.ServePage(w, r, godoc.Page{
+	site.ServePage(w, r, web.Page{
 		Title:    "Codewalk: " + cw.Title,
 		TabTitle: cw.Title,
 		Template: "codewalk.html",
@@ -211,7 +211,7 @@
 	dir, err := fs.ReadDir(fsys, toFS(abspath))
 	if err != nil {
 		log.Print(err)
-		pres.ServeError(w, r, err)
+		site.ServeError(w, r, err)
 		return
 	}
 	var v []interface{}
@@ -228,7 +228,7 @@
 		}
 	}
 
-	pres.ServePage(w, r, godoc.Page{
+	site.ServePage(w, r, web.Page{
 		Title:    "Codewalks",
 		Template: "codewalkdir.html",
 		Data:     v,
@@ -246,7 +246,7 @@
 	data, err := fs.ReadFile(fsys, toFS(abspath))
 	if err != nil {
 		log.Print(err)
-		pres.ServeError(w, r, err)
+		site.ServeError(w, r, err)
 		return
 	}
 	lo, _ := strconv.Atoi(r.FormValue("lo"))
diff --git a/cmd/golangorg/handlers.go b/cmd/golangorg/handlers.go
index 1ee843d..1ea8478 100644
--- a/cmd/golangorg/handlers.go
+++ b/cmd/golangorg/handlers.go
@@ -16,12 +16,12 @@
 	"strings"
 
 	"golang.org/x/website/internal/env"
-	"golang.org/x/website/internal/godoc"
 	"golang.org/x/website/internal/redirect"
+	"golang.org/x/website/internal/web"
 )
 
 var (
-	pres *godoc.Presentation
+	site *web.Site
 	fsys fs.FS
 )
 
@@ -76,7 +76,7 @@
 	return false
 }
 
-func registerHandlers(pres *godoc.Presentation) *http.ServeMux {
+func registerHandlers(pres *web.Site) *http.ServeMux {
 	if pres == nil {
 		panic("nil Presentation")
 	}
diff --git a/cmd/golangorg/main.go b/cmd/golangorg/main.go
index 9c20031..7e832d5 100644
--- a/cmd/golangorg/main.go
+++ b/cmd/golangorg/main.go
@@ -30,7 +30,7 @@
 	"runtime"
 
 	"golang.org/x/website"
-	"golang.org/x/website/internal/godoc"
+	"golang.org/x/website/internal/web"
 )
 
 var (
@@ -79,13 +79,13 @@
 	fsys = unionFS{content, os.DirFS(*goroot)}
 
 	var err error
-	pres, err = godoc.NewPresentation(fsys)
+	site, err = web.NewSite(fsys)
 	if err != nil {
 		log.Fatal(err)
 	}
-	pres.GoogleCN = googleCN
+	site.GoogleCN = googleCN
 
-	mux := registerHandlers(pres)
+	mux := registerHandlers(site)
 	lateSetup(mux)
 
 	var handler http.Handler = http.DefaultServeMux
diff --git a/cmd/golangorg/release_test.go b/cmd/golangorg/release_test.go
index 5c6a574..9d44ad7 100644
--- a/cmd/golangorg/release_test.go
+++ b/cmd/golangorg/release_test.go
@@ -14,7 +14,7 @@
 	"testing"
 
 	"golang.org/x/website"
-	"golang.org/x/website/internal/godoc"
+	"golang.org/x/website/internal/web"
 )
 
 // Test that the release history page includes expected entries.
@@ -24,15 +24,15 @@
 // It can be relaxed whenever the presentation of the release history
 // page needs to be changed.
 func TestReleaseHistory(t *testing.T) {
-	origFS, origPres := fsys, pres
-	defer func() { fsys, pres = origFS, origPres }()
+	origFS, origPres := fsys, site
+	defer func() { fsys, site = origFS, origPres }()
 	fsys = website.Content
 	var err error
-	pres, err = godoc.NewPresentation(fsys)
+	site, err = web.NewSite(fsys)
 	if err != nil {
 		t.Fatal(err)
 	}
-	mux := registerHandlers(pres)
+	mux := registerHandlers(site)
 
 	req := httptest.NewRequest(http.MethodGet, "/doc/devel/release", nil)
 	rr := httptest.NewRecorder()
diff --git a/internal/godoc/page.go b/internal/godoc/page.go
deleted file mode 100644
index 9426dd7..0000000
--- a/internal/godoc/page.go
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright 2009 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.
-
-//go:build go1.16
-// +build go1.16
-
-package godoc
-
-import (
-	"net/http"
-	"runtime"
-)
-
-// A Page describes the contents of a webpage to be served.
-//
-// A Page's Methods are for use by the templates rendering the page.
-type Page struct {
-	Title    string // <h1>
-	TabTitle string // prefix in <title>; defaults to Title
-	Subtitle string // subtitle (date for spec, memory model)
-	SrcPath  string // path to file in /src for text view
-
-	// Template and Data describe the data to be
-	// rendered into the overall site frame template.
-	// If Template is empty, then Data should be a template.HTML
-	// holding raw HTML to render into the site frame.
-	// Otherwise, Template should be the name of a template file
-	// in _content/lib/godoc (for example, "package.html"),
-	// and that template will be executed
-	// (with the *Page as its data argument) to produce HTML.
-	//
-	// The overall site template site.html is also invoked with
-	// the *Page as its data argument. It is what arranges to call Template.
-	Template string      // template to apply to data (empty string when Data is raw template.HTML)
-	Data     interface{} // data to be rendered into page frame
-
-	// Filled in automatically by ServePage
-	GoogleCN        bool   // page is being served from golang.google.cn
-	GoogleAnalytics string // Google Analytics tag
-	Version         string // current Go version
-
-	pres *Presentation
-}
-
-// fullPage returns a copy of page with the “automatic” fields filled in.
-func (p *Presentation) fullPage(r *http.Request, page Page) Page {
-	if page.TabTitle == "" {
-		page.TabTitle = page.Title
-	}
-	page.Version = runtime.Version()
-	page.GoogleCN = p.googleCN(r)
-	page.GoogleAnalytics = p.GoogleAnalytics
-	page.pres = p
-	return page
-}
-
-// ServePage responds to the request with the content described by page.
-func (p *Presentation) ServePage(w http.ResponseWriter, r *http.Request, page Page) {
-	page = p.fullPage(r, page)
-	applyTemplateToResponseWriter(w, p.Templates.Lookup("site.html"), &page)
-}
-
-// ServeError responds to the request with the given error.
-func (p *Presentation) ServeError(w http.ResponseWriter, r *http.Request, err error) {
-	w.WriteHeader(http.StatusNotFound)
-	p.ServePage(w, r, Page{
-		Title:    r.URL.Path,
-		Template: "error.html",
-		Data:     err,
-	})
-}
diff --git a/internal/godoc/pres.go b/internal/godoc/pres.go
deleted file mode 100644
index 51928c4..0000000
--- a/internal/godoc/pres.go
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright 2013 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.
-
-//go:build go1.16
-// +build go1.16
-
-package godoc
-
-import (
-	"html/template"
-	"io/fs"
-	"net/http"
-
-	"golang.org/x/website/internal/api"
-	"golang.org/x/website/internal/pkgdoc"
-)
-
-// Presentation is a website served from a file system.
-type Presentation struct {
-	fs  fs.FS
-	api api.DB
-
-	mux        *http.ServeMux
-	fileServer http.Handler
-
-	Templates *template.Template
-
-	// GoogleCN reports whether this request should be marked GoogleCN.
-	// If the function is nil, no requests are marked GoogleCN.
-	GoogleCN func(*http.Request) bool
-
-	// GoogleAnalytics optionally adds Google Analytics via the provided
-	// tracking ID to each page.
-	GoogleAnalytics string
-
-	docFuncs template.FuncMap
-}
-
-// NewPresentation returns a new Presentation from a file system.
-func NewPresentation(fsys fs.FS) (*Presentation, error) {
-	apiDB, err := api.Load(fsys)
-	if err != nil {
-		return nil, err
-	}
-	p := &Presentation{
-		fs:         fsys,
-		api:        apiDB,
-		mux:        http.NewServeMux(),
-		fileServer: http.FileServer(http.FS(fsys)),
-	}
-	docs := &docServer{
-		p: p,
-		d: pkgdoc.NewDocs(fsys),
-	}
-	p.mux.Handle("/cmd/", docs)
-	p.mux.Handle("/pkg/", docs)
-	p.mux.HandleFunc("/", p.serveFile)
-	p.initFuncMap()
-
-	t, err := template.New("").Funcs(siteFuncs).ParseFS(fsys, "lib/godoc/*.html")
-	if err != nil {
-		return nil, err
-	}
-	p.Templates = t
-
-	return p, nil
-}
-
-// ServeHTTP implements http.Handler, dispatching the request appropriately.
-func (p *Presentation) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	p.mux.ServeHTTP(w, r)
-}
-
-func (p *Presentation) googleCN(r *http.Request) bool {
-	return p.GoogleCN != nil && p.GoogleCN(r)
-}
diff --git a/internal/godoc/server.go b/internal/godoc/server.go
deleted file mode 100644
index 90b119f..0000000
--- a/internal/godoc/server.go
+++ /dev/null
@@ -1,409 +0,0 @@
-// Copyright 2013 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.
-
-//go:build go1.16
-// +build go1.16
-
-package godoc
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	htmlpkg "html"
-	"html/template"
-	"io"
-	"io/fs"
-	"log"
-	"net/http"
-	"path"
-	"regexp"
-	"strconv"
-	"strings"
-
-	"golang.org/x/website/internal/pkgdoc"
-	"golang.org/x/website/internal/spec"
-	"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, "/"))
-}
-
-// docServer serves a package doc tree (/cmd or /pkg).
-type docServer struct {
-	p *Presentation
-	d *pkgdoc.Docs
-}
-
-func (h *docServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	if redirect(w, r) {
-		return
-	}
-
-	// TODO(rsc): URL should be clean already.
-	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,
-		// but we want to show them. Also, disable type association,
-		// 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"))
-	if info.Err != nil {
-		log.Print(info.Err)
-		h.p.ServeError(w, r, info.Err)
-		return
-	}
-
-	var tabtitle, title, subtitle string
-	switch {
-	case info.PDoc != nil:
-		tabtitle = info.PDoc.Name
-	default:
-		tabtitle = info.Dirname
-		title = "Directory "
-	}
-	if title == "" {
-		if info.IsMain {
-			// assume that the directory name is the command name
-			_, tabtitle = path.Split(relpath)
-			title = "Command "
-		} else {
-			title = "Package "
-		}
-	}
-	title += tabtitle
-
-	// special cases for top-level package/command directories
-	switch tabtitle {
-	case "/src":
-		title = "Packages"
-		tabtitle = "Packages"
-	case "/src/cmd":
-		title = "Commands"
-		tabtitle = "Commands"
-	}
-
-	name := "package.html"
-	if info.Dirname == "/src" {
-		name = "packageroot.html"
-	}
-	h.p.ServePage(w, r, Page{
-		Title:    title,
-		TabTitle: tabtitle,
-		Subtitle: subtitle,
-		Template: name,
-		Data:     info,
-	})
-}
-
-// ModeQuery returns the "?m=..." query for the current page.
-// The page's Data must be a *pkgdoc.Page (to find the mode).
-func (p *Page) ModeQuery() string {
-	m := p.Data.(*pkgdoc.Page).Mode
-	s := m.String()
-	if s == "" {
-		return ""
-	}
-	return "?m=" + s
-}
-
-// Invoke invokes the template with the given name on
-// a copy of p with .Data set to data, returning the resulting HTML.
-func (p *Page) Invoke(name string, data interface{}) template.HTML {
-	t := p.pres.Templates.Lookup(name)
-	var buf bytes.Buffer
-	p1 := *p
-	p1.Data = data
-	if err := t.Execute(&buf, &p1); err != nil {
-		log.Printf("%s.Execute: %s", t.Name(), err)
-	}
-	return template.HTML(buf.String())
-}
-
-type writerCapturesErr struct {
-	w   io.Writer
-	err error
-}
-
-func (w *writerCapturesErr) Write(p []byte) (int, error) {
-	n, err := w.w.Write(p)
-	if err != nil {
-		w.err = err
-	}
-	return n, err
-}
-
-// applyTemplateToResponseWriter uses an http.ResponseWriter as the io.Writer
-// for the call to template.Execute.  It uses an io.Writer wrapper to capture
-// errors from the underlying http.ResponseWriter.  Errors are logged only when
-// they come from the template processing and not the Writer; this avoid
-// polluting log files with error messages due to networking issues, such as
-// client disconnects and http HEAD protocol violations.
-func applyTemplateToResponseWriter(rw http.ResponseWriter, t *template.Template, data interface{}) {
-	w := &writerCapturesErr{w: rw}
-	err := t.Execute(w, data)
-	// There are some cases where template.Execute does not return an error when
-	// rw returns an error, and some where it does.  So check w.err first.
-	if w.err == nil && err != nil {
-		// Log template errors.
-		log.Printf("%s.Execute: %s", t.Name(), err)
-	}
-}
-
-func redirect(w http.ResponseWriter, r *http.Request) (redirected bool) {
-	canonical := path.Clean(r.URL.Path)
-	if !strings.HasSuffix(canonical, "/") {
-		canonical += "/"
-	}
-	if r.URL.Path != canonical {
-		url := *r.URL
-		url.Path = canonical
-		http.Redirect(w, r, url.String(), http.StatusMovedPermanently)
-		redirected = true
-	}
-	return
-}
-
-func redirectFile(w http.ResponseWriter, r *http.Request) (redirected bool) {
-	c := path.Clean(r.URL.Path)
-	c = strings.TrimRight(c, "/")
-	if r.URL.Path != c {
-		url := *r.URL
-		url.Path = c
-		http.Redirect(w, r, url.String(), http.StatusMovedPermanently)
-		redirected = true
-	}
-	return
-}
-
-var selRx = regexp.MustCompile(`^([0-9]+):([0-9]+)`)
-
-// rangeSelection computes the Selection for a text range described
-// by the argument str, of the form Start:End, where Start and End
-// are decimal byte offsets.
-func rangeSelection(str string) texthtml.Selection {
-	m := selRx.FindStringSubmatch(str)
-	if len(m) >= 2 {
-		from, _ := strconv.Atoi(m[1])
-		to, _ := strconv.Atoi(m[2])
-		if from < to {
-			return texthtml.Spans(texthtml.Span{Start: from, End: to})
-		}
-	}
-	return nil
-}
-
-func (p *Presentation) serveTextFile(w http.ResponseWriter, r *http.Request, abspath, relpath string) {
-	src, err := fs.ReadFile(p.fs, toFS(abspath))
-	if err != nil {
-		log.Printf("ReadFile: %s", err)
-		p.ServeError(w, r, err)
-		return
-	}
-
-	if r.FormValue("m") == "text" {
-		p.serveText(w, src)
-		return
-	}
-
-	cfg := texthtml.Config{
-		GoComments: path.Ext(abspath) == ".go",
-		Highlight:  r.FormValue("h"),
-		Selection:  rangeSelection(r.FormValue("s")),
-		Line:       1,
-	}
-
-	var buf bytes.Buffer
-	buf.WriteString("<pre>")
-	buf.Write(texthtml.Format(src, cfg))
-	buf.WriteString("</pre>")
-
-	fmt.Fprintf(&buf, `<p><a href="/%s?m=text">View as plain text</a></p>`, htmlpkg.EscapeString(relpath))
-
-	title := "Text file"
-	if strings.HasSuffix(relpath, ".go") {
-		title = "Source file"
-	}
-	p.ServePage(w, r, Page{
-		Title:    title,
-		SrcPath:  relpath,
-		TabTitle: relpath,
-		Data:     template.HTML(buf.String()),
-	})
-}
-
-func (p *Presentation) serveDirectory(w http.ResponseWriter, r *http.Request, abspath, relpath string) {
-	if redirect(w, r) {
-		return
-	}
-
-	list, err := fs.ReadDir(p.fs, toFS(abspath))
-	if err != nil {
-		p.ServeError(w, r, err)
-		return
-	}
-
-	var info []fs.FileInfo
-	for _, d := range list {
-		i, err := d.Info()
-		if err == nil {
-			info = append(info, i)
-		}
-	}
-
-	p.ServePage(w, r, Page{
-		Title:    "Directory",
-		SrcPath:  relpath,
-		TabTitle: relpath,
-		Template: "dirlist.html",
-		Data:     info,
-	})
-}
-
-var doctype = []byte("<!DOCTYPE ")
-
-func (p *Presentation) serveHTML(w http.ResponseWriter, r *http.Request, f *file) {
-	src := f.Body
-	isMarkdown := strings.HasSuffix(f.FilePath, ".md")
-
-	// if it begins with "<!DOCTYPE " assume it is standalone
-	// html that doesn't need the template wrapping.
-	if bytes.HasPrefix(src, doctype) {
-		w.Write(src)
-		return
-	}
-
-	page := Page{
-		Title:    f.Title,
-		Subtitle: f.Subtitle,
-	}
-
-	// evaluate as template if indicated
-	if f.Template {
-		page = p.fullPage(r, page)
-		tmpl, err := template.New("main").Funcs(p.docFuncs).Parse(string(src))
-		if err != nil {
-			log.Printf("parsing template %s: %v", f.Path, err)
-			p.ServeError(w, r, err)
-			return
-		}
-		var buf bytes.Buffer
-		if err := tmpl.Execute(&buf, page); err != nil {
-			log.Printf("executing template %s: %v", f.Path, err)
-			p.ServeError(w, r, err)
-			return
-		}
-		src = buf.Bytes()
-	}
-
-	// Apply markdown as indicated.
-	// (Note template applies before Markdown.)
-	if isMarkdown {
-		html, err := renderMarkdown(src)
-		if err != nil {
-			log.Printf("executing markdown %s: %v", f.Path, err)
-			p.ServeError(w, r, err)
-			return
-		}
-		src = html
-	}
-
-	// if it's the language spec, add tags to EBNF productions
-	if strings.HasSuffix(f.FilePath, "go_spec.html") {
-		var buf bytes.Buffer
-		spec.Linkify(&buf, src)
-		src = buf.Bytes()
-	}
-
-	page.Data = template.HTML(src)
-	p.ServePage(w, r, page)
-}
-
-func (p *Presentation) serveFile(w http.ResponseWriter, r *http.Request) {
-	if strings.HasSuffix(r.URL.Path, "/index.html") {
-		// We'll show index.html for the directory.
-		// Use the dir/ version as canonical instead of dir/index.html.
-		http.Redirect(w, r, r.URL.Path[0:len(r.URL.Path)-len("index.html")], http.StatusMovedPermanently)
-		return
-	}
-
-	// Check to see if we need to redirect or serve another file.
-	abspath := r.URL.Path
-	if f := open(p.fs, abspath); f != nil {
-		if f.Path != abspath {
-			// Redirect to canonical path.
-			http.Redirect(w, r, f.Path, http.StatusMovedPermanently)
-			return
-		}
-		// Serve from the actual filesystem path.
-		p.serveHTML(w, r, f)
-		return
-	}
-
-	relpath := abspath[1:] // strip leading slash
-
-	dir, err := fs.Stat(p.fs, toFS(abspath))
-	if err != nil {
-		// Check for spurious trailing slash.
-		if strings.HasSuffix(abspath, "/") {
-			trimmed := abspath[:len(abspath)-1]
-			if _, err := fs.Stat(p.fs, toFS(trimmed)); err == nil ||
-				open(p.fs, trimmed) != nil {
-				http.Redirect(w, r, trimmed, http.StatusMovedPermanently)
-				return
-			}
-		}
-		p.ServeError(w, r, err)
-		return
-	}
-
-	fsPath := toFS(abspath)
-	if dir != nil && dir.IsDir() {
-		if redirect(w, r) {
-			return
-		}
-		p.serveDirectory(w, r, abspath, relpath)
-		return
-	}
-
-	if isTextFile(p.fs, fsPath) {
-		if redirectFile(w, r) {
-			return
-		}
-		p.serveTextFile(w, r, abspath, relpath)
-		return
-	}
-
-	p.fileServer.ServeHTTP(w, r)
-}
-
-func (p *Presentation) serveText(w http.ResponseWriter, text []byte) {
-	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
-	w.Write(text)
-}
-
-func marshalJSON(x interface{}) []byte {
-	var data []byte
-	var err error
-	const indentJSON = false // for easier debugging
-	if indentJSON {
-		data, err = json.MarshalIndent(x, "", "    ")
-	} else {
-		data, err = json.Marshal(x)
-	}
-	if err != nil {
-		panic(fmt.Sprintf("json.Marshal failed: %s", err))
-	}
-	return data
-}
diff --git a/internal/godoc/astfuncs.go b/internal/web/astfuncs.go
similarity index 92%
rename from internal/godoc/astfuncs.go
rename to internal/web/astfuncs.go
index e21b52a..3e7a499 100644
--- a/internal/godoc/astfuncs.go
+++ b/internal/web/astfuncs.go
@@ -5,7 +5,7 @@
 //go:build go1.16
 // +build go1.16
 
-package godoc
+package web
 
 import (
 	"bufio"
@@ -32,7 +32,7 @@
 func (p *Page) Node(node interface{}) template.HTML {
 	info := p.Data.(*pkgdoc.Page)
 	var buf1 bytes.Buffer
-	p.pres.writeNode(&buf1, info, info.FSet, node)
+	p.site.writeNode(&buf1, info, info.FSet, node)
 
 	var buf2 bytes.Buffer
 	n, _ := node.(ast.Node)
@@ -48,7 +48,7 @@
 func (p *Page) NodeTOC(node interface{}) template.HTML {
 	info := p.Data.(*pkgdoc.Page)
 	var buf1 bytes.Buffer
-	p.pres.writeNode(&buf1, info, info.FSet, node)
+	p.site.writeNode(&buf1, info, info.FSet, node)
 
 	var buf2 bytes.Buffer
 	buf2.Write(texthtml.Format(buf1.Bytes(), texthtml.Config{
@@ -58,14 +58,14 @@
 	return sanitize(template.HTML(buf2.String()))
 }
 
-const TabWidth = 4
+const tabWidth = 4
 
 // writeNode writes the AST node x to w.
 //
 // The provided fset must be non-nil. The pageInfo is optional. If
 // present, the pageInfo is used to add comments to struct fields to
 // say which version of Go introduced them.
-func (p *Presentation) writeNode(w io.Writer, pageInfo *pkgdoc.Page, fset *token.FileSet, x interface{}) {
+func (s *Site) writeNode(w io.Writer, pageInfo *pkgdoc.Page, fset *token.FileSet, x interface{}) {
 	// convert trailing tabs into spaces using a tconv filter
 	// to ensure a good outcome in most browsers (there may still
 	// be tabs in comments and strings, but converting those into
@@ -85,7 +85,7 @@
 				structName = ts.Name.Name
 			}
 		}
-		apiInfo = p.api[pkgName]
+		apiInfo = s.api[pkgName]
 	}
 
 	var out = w
@@ -95,7 +95,7 @@
 	}
 
 	mode := printer.TabIndent | printer.UseSpaces
-	err := (&printer.Config{Mode: mode, Tabwidth: TabWidth}).Fprint(TabSpacer(out, TabWidth), fset, x)
+	err := (&printer.Config{Mode: mode, Tabwidth: tabWidth}).Fprint(tabSpacer(out, tabWidth), fset, x)
 	if err != nil {
 		log.Print(err)
 	}
@@ -209,5 +209,5 @@
 // The current package is deduced from p.Data, which must be a *pkgdoc.Page.
 func (p *Page) Since(kind, receiver, name string) string {
 	pkg := p.Data.(*pkgdoc.Page).PDoc.ImportPath
-	return p.pres.api.Func(pkg, kind, receiver, name)
+	return p.site.api.Func(pkg, kind, receiver, name)
 }
diff --git a/internal/godoc/template.go b/internal/web/docfuncs.go
similarity index 70%
rename from internal/godoc/template.go
rename to internal/web/docfuncs.go
index 214a159..a11190f 100644
--- a/internal/godoc/template.go
+++ b/internal/web/docfuncs.go
@@ -1,38 +1,11 @@
-// Copyright 2011 The Go Authors. All rights reserved.
+// Copyright 2013 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.
 
 //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.
-//
-// This file defines functions for those templates to invoke.
-
-// The template uses the function "code" to inject program
-// source into the output by extracting code from files and
-// injecting them as HTML-escaped <pre> blocks.
-//
-// The syntax is simple: 1, 2, or 3 space-separated arguments:
-//
-// Whole file:
-//	{{code "foo.go"}}
-// One line (here the signature of main):
-//	{{code "foo.go" `/^func.main/`}}
-// Block of text, determined by start and end (here the body of main):
-//	{{code "foo.go" `/^func.main/` `/^}/`
-//
-// Patterns can be `/regular expression/`, a decimal number, or "$"
-// to signify the end of the file. In multi-line matches,
-// lines that end with the four characters
-//	OMIT
-// are omitted from the output, making it easy to provide marker
-// lines in the input that will not appear in the output but are easy
-// to identify by pattern.
-
-package godoc
+package web
 
 import (
 	"bytes"
@@ -43,16 +16,58 @@
 	"regexp"
 	"strings"
 
+	"golang.org/x/website/internal/history"
 	"golang.org/x/website/internal/texthtml"
 )
 
+func (s *Site) initDocFuncs() {
+	s.docFuncs = template.FuncMap{
+		"code":     s.code,
+		"releases": func() []*history.Major { return history.Majors },
+	}
+}
+
+func (s *Site) code(file string, arg ...interface{}) (_ template.HTML, err error) {
+	defer func() {
+		if r := recover(); r != nil {
+			err = fmt.Errorf("%v", r)
+		}
+	}()
+
+	text := s.contents(file)
+	var command string
+	switch len(arg) {
+	case 0:
+		// text is already whole file.
+		command = fmt.Sprintf("code %q", file)
+	case 1:
+		command = fmt.Sprintf("code %q %s", file, stringFor(arg[0]))
+		text = s.oneLine(file, text, arg[0])
+	case 2:
+		command = fmt.Sprintf("code %q %s %s", file, stringFor(arg[0]), stringFor(arg[1]))
+		text = s.multipleLines(file, text, arg[0], arg[1])
+	default:
+		return "", fmt.Errorf("incorrect code invocation: code %q [%v, ...] (%d arguments)", file, arg[0], len(arg))
+	}
+	// Trim spaces from output.
+	text = strings.Trim(text, "\n")
+	// Replace tabs by spaces, which work better in HTML.
+	text = strings.Replace(text, "\t", "    ", -1)
+	var buf bytes.Buffer
+	// HTML-escape text and syntax-color comments like elsewhere.
+	buf.Write(texthtml.Format([]byte(text), texthtml.Config{GoComments: true}))
+	// Include the command as a comment.
+	text = fmt.Sprintf("<pre><!--{{%s}}\n-->%s</pre>", command, buf.Bytes())
+	return template.HTML(text), nil
+}
+
 // 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 (p *Presentation) contents(name string) string {
-	file, err := fs.ReadFile(p.fs, toFS(name))
+func (s *Site) contents(name string) string {
+	file, err := fs.ReadFile(s.fs, toFS(name))
 	if err != nil {
 		log.Panic(err)
 	}
@@ -75,58 +90,9 @@
 	return ""
 }
 
-func (p *Presentation) code(file string, arg ...interface{}) (_ template.HTML, err error) {
-	defer func() {
-		if r := recover(); r != nil {
-			err = fmt.Errorf("%v", r)
-		}
-	}()
-
-	text := p.contents(file)
-	var command string
-	switch len(arg) {
-	case 0:
-		// text is already whole file.
-		command = fmt.Sprintf("code %q", file)
-	case 1:
-		command = fmt.Sprintf("code %q %s", file, stringFor(arg[0]))
-		text = p.oneLine(file, text, arg[0])
-	case 2:
-		command = fmt.Sprintf("code %q %s %s", file, stringFor(arg[0]), stringFor(arg[1]))
-		text = p.multipleLines(file, text, arg[0], arg[1])
-	default:
-		return "", fmt.Errorf("incorrect code invocation: code %q [%v, ...] (%d arguments)", file, arg[0], len(arg))
-	}
-	// Trim spaces from output.
-	text = strings.Trim(text, "\n")
-	// Replace tabs by spaces, which work better in HTML.
-	text = strings.Replace(text, "\t", "    ", -1)
-	var buf bytes.Buffer
-	// HTML-escape text and syntax-color comments like elsewhere.
-	buf.Write(texthtml.Format([]byte(text), texthtml.Config{GoComments: true}))
-	// Include the command as a comment.
-	text = fmt.Sprintf("<pre><!--{{%s}}\n-->%s</pre>", command, buf.Bytes())
-	return template.HTML(text), nil
-}
-
-// parseArg returns the integer or string value of the argument and tells which it is.
-func parseArg(arg interface{}, file string, max int) (ival int, sval string, isInt bool) {
-	switch n := arg.(type) {
-	case int:
-		if n <= 0 || n > max {
-			log.Panicf("%q:%d is out of range", file, n)
-		}
-		return n, "", true
-	case string:
-		return 0, n, false
-	}
-	log.Panicf("unrecognized argument %v type %T", arg, arg)
-	return
-}
-
 // oneLine returns the single line generated by a two-argument code invocation.
-func (p *Presentation) oneLine(file, text string, arg interface{}) string {
-	lines := strings.SplitAfter(p.contents(file), "\n")
+func (s *Site) oneLine(file, text string, arg interface{}) string {
+	lines := strings.SplitAfter(s.contents(file), "\n")
 	line, pattern, isInt := parseArg(arg, file, len(lines))
 	if isInt {
 		return lines[line-1]
@@ -135,8 +101,8 @@
 }
 
 // multipleLines returns the text generated by a three-argument code invocation.
-func (p *Presentation) multipleLines(file, text string, arg1, arg2 interface{}) string {
-	lines := strings.SplitAfter(p.contents(file), "\n")
+func (s *Site) multipleLines(file, text string, arg1, arg2 interface{}) string {
+	lines := strings.SplitAfter(s.contents(file), "\n")
 	line1, pattern1, isInt1 := parseArg(arg1, file, len(lines))
 	line2, pattern2, isInt2 := parseArg(arg2, file, len(lines))
 	if !isInt1 {
@@ -155,6 +121,21 @@
 	return strings.Join(lines[line1-1:line2], "")
 }
 
+// parseArg returns the integer or string value of the argument and tells which it is.
+func parseArg(arg interface{}, file string, max int) (ival int, sval string, isInt bool) {
+	switch n := arg.(type) {
+	case int:
+		if n <= 0 || n > max {
+			log.Panicf("%q:%d is out of range", file, n)
+		}
+		return n, "", true
+	case string:
+		return 0, n, false
+	}
+	log.Panicf("unrecognized argument %v type %T", arg, arg)
+	return
+}
+
 // match identifies the input line that matches the pattern in a code invocation.
 // If start>0, match lines starting there rather than at the beginning.
 // The return value is 1-indexed.
diff --git a/internal/godoc/examplefuncs.go b/internal/web/examplefuncs.go
similarity index 85%
rename from internal/godoc/examplefuncs.go
rename to internal/web/examplefuncs.go
index 67099ec..bfc88da 100644
--- a/internal/godoc/examplefuncs.go
+++ b/internal/web/examplefuncs.go
@@ -5,7 +5,7 @@
 //go:build go1.16
 // +build go1.16
 
-package godoc
+package web
 
 import (
 	"bytes"
@@ -45,7 +45,7 @@
 			// remove surrounding braces
 			code = code[1 : n-1]
 			// unindent
-			code = replaceLeadingIndentation(code, strings.Repeat(" ", TabWidth), "")
+			code = replaceLeadingIndentation(code, strings.Repeat(" ", tabWidth), "")
 			// remove output comment
 			if loc := exampleOutputRx.FindStringIndex(code); loc != nil {
 				code = strings.TrimSpace(code[:loc[0]])
@@ -70,7 +70,7 @@
 			out = ""
 		}
 
-		t := p.pres.Templates.Lookup("example.html")
+		t := p.site.Templates.Lookup("example.html")
 		if t == nil {
 			return ""
 		}
@@ -194,23 +194,3 @@
 	// There weren't any non-build tags, return an empty slice.
 	return []*ast.CommentGroup{}
 }
-
-// example_nameFunc takes an example function name and returns its display
-// name. For example, "Foo_Bar_quux" becomes "Foo.Bar (Quux)".
-func example_nameFunc(s string) string {
-	name, suffix := pkgdoc.SplitExampleName(s)
-	// replace _ with . for method names
-	name = strings.Replace(name, "_", ".", 1)
-	// use "Package" if no name provided
-	if name == "" {
-		name = "Package"
-	}
-	return name + suffix
-}
-
-// example_suffixFunc takes an example function name and returns its suffix in
-// parenthesized form. For example, "Foo_Bar_quux" becomes " (Quux)".
-func example_suffixFunc(name string) string {
-	_, suffix := pkgdoc.SplitExampleName(name)
-	return suffix
-}
diff --git a/internal/godoc/meta.go b/internal/web/file.go
similarity index 93%
rename from internal/godoc/meta.go
rename to internal/web/file.go
index 27f4def..f2ff605 100644
--- a/internal/godoc/meta.go
+++ b/internal/web/file.go
@@ -5,7 +5,7 @@
 //go:build go1.16
 // +build go1.16
 
-package godoc
+package web
 
 import (
 	"bytes"
@@ -16,11 +16,6 @@
 	"strings"
 )
 
-var (
-	jsonStart = []byte("<!--{")
-	jsonEnd   = []byte("}-->")
-)
-
 type file struct {
 	// Copied from document metadata directives
 	Title    string
@@ -32,33 +27,13 @@
 	Body     []byte // content after metadata
 }
 
-type metaJSON struct {
+type fileJSON struct {
 	Title    string
 	Subtitle string
 	Template bool
 	Redirect string // if set, redirect to other URL
 }
 
-// extractMetadata extracts the metaJSON from a byte slice.
-// It returns the metadata and the remaining text.
-// If no metadata is present, it returns an empty metaJSON and the original text.
-func extractMetadata(b []byte) (meta metaJSON, tail []byte, err error) {
-	tail = b
-	if !bytes.HasPrefix(b, jsonStart) {
-		return
-	}
-	end := bytes.Index(b, jsonEnd)
-	if end < 0 {
-		return
-	}
-	b = b[len(jsonStart)-1 : end+1] // drop leading <!-- and include trailing }
-	if err = json.Unmarshal(b, &meta); err != nil {
-		return
-	}
-	tail = tail[end+len(jsonEnd):]
-	return
-}
-
 var join = path.Join
 
 // open returns the file for a given absolute path or nil if none exists.
@@ -112,7 +87,7 @@
 		path = filePath[:strings.LastIndex(filePath, "/")+1]
 	}
 
-	js, body, err := extractMetadata(b)
+	js, body, err := parseFile(b)
 	if err != nil {
 		log.Printf("extractMetadata %s: %v", path, err)
 		return nil
@@ -133,3 +108,28 @@
 
 	return f
 }
+
+var (
+	jsonStart = []byte("<!--{")
+	jsonEnd   = []byte("}-->")
+)
+
+// parseFile extracts the metaJSON from a byte slice.
+// It returns the metadata and the remaining text.
+// If no metadata is present, it returns an empty metaJSON and the original text.
+func parseFile(b []byte) (meta fileJSON, tail []byte, err error) {
+	tail = b
+	if !bytes.HasPrefix(b, jsonStart) {
+		return
+	}
+	end := bytes.Index(b, jsonEnd)
+	if end < 0 {
+		return
+	}
+	b = b[len(jsonStart)-1 : end+1] // drop leading <!-- and include trailing }
+	if err = json.Unmarshal(b, &meta); err != nil {
+		return
+	}
+	tail = tail[end+len(jsonEnd):]
+	return
+}
diff --git a/internal/godoc/util.go b/internal/web/istext.go
similarity index 91%
rename from internal/godoc/util.go
rename to internal/web/istext.go
index ba062a6..c584540 100644
--- a/internal/godoc/util.go
+++ b/internal/web/istext.go
@@ -5,7 +5,7 @@
 //go:build go1.16
 // +build go1.16
 
-package godoc
+package web
 
 import (
 	"io/fs"
@@ -14,9 +14,9 @@
 	"unicode/utf8"
 )
 
-// IsText reports whether a significant prefix of s looks like correct UTF-8;
+// isText reports whether a significant prefix of s looks like correct UTF-8;
 // that is, if it is likely that s is human-readable text.
-func IsText(s []byte) bool {
+func isText(s []byte) bool {
 	const max = 1024 // at least utf8.UTFMax
 	if len(s) > max {
 		s = s[0:max]
@@ -62,5 +62,5 @@
 		return false
 	}
 
-	return IsText(buf[0:n])
+	return isText(buf[0:n])
 }
diff --git a/internal/godoc/markdown.go b/internal/web/markdown.go
similarity index 98%
rename from internal/godoc/markdown.go
rename to internal/web/markdown.go
index 9149bca..1ca78c6 100644
--- a/internal/godoc/markdown.go
+++ b/internal/web/markdown.go
@@ -5,7 +5,7 @@
 //go:build go1.16
 // +build go1.16
 
-package godoc
+package web
 
 import (
 	"bytes"
diff --git a/internal/web/pkgdoc.go b/internal/web/pkgdoc.go
new file mode 100644
index 0000000..c0b5e91
--- /dev/null
+++ b/internal/web/pkgdoc.go
@@ -0,0 +1,100 @@
+// Copyright 2013 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.
+
+//go:build go1.16
+// +build go1.16
+
+package web
+
+import (
+	"log"
+	"net/http"
+	"path"
+	"strings"
+
+	"golang.org/x/website/internal/pkgdoc"
+)
+
+// docServer serves a package doc tree (/cmd or /pkg).
+type docServer struct {
+	p *Site
+	d *pkgdoc.Docs
+}
+
+func (h *docServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if maybeRedirect(w, r) {
+		return
+	}
+
+	// TODO(rsc): URL should be clean already.
+	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,
+		// but we want to show them. Also, disable type association,
+		// 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"))
+	if info.Err != nil {
+		log.Print(info.Err)
+		h.p.ServeError(w, r, info.Err)
+		return
+	}
+
+	var tabtitle, title, subtitle string
+	switch {
+	case info.PDoc != nil:
+		tabtitle = info.PDoc.Name
+	default:
+		tabtitle = info.Dirname
+		title = "Directory "
+	}
+	if title == "" {
+		if info.IsMain {
+			// assume that the directory name is the command name
+			_, tabtitle = path.Split(relpath)
+			title = "Command "
+		} else {
+			title = "Package "
+		}
+	}
+	title += tabtitle
+
+	// special cases for top-level package/command directories
+	switch tabtitle {
+	case "/src":
+		title = "Packages"
+		tabtitle = "Packages"
+	case "/src/cmd":
+		title = "Commands"
+		tabtitle = "Commands"
+	}
+
+	name := "package.html"
+	if info.Dirname == "/src" {
+		name = "packageroot.html"
+	}
+	h.p.ServePage(w, r, Page{
+		Title:    title,
+		TabTitle: tabtitle,
+		Subtitle: subtitle,
+		Template: name,
+		Data:     info,
+	})
+}
+
+// ModeQuery returns the "?m=..." query for the current page.
+// The page's Data must be a *pkgdoc.Page (to find the mode).
+func (p *Page) ModeQuery() string {
+	m := p.Data.(*pkgdoc.Page).Mode
+	s := m.String()
+	if s == "" {
+		return ""
+	}
+	return "?m=" + s
+}
diff --git a/internal/web/site.go b/internal/web/site.go
new file mode 100644
index 0000000..5756714
--- /dev/null
+++ b/internal/web/site.go
@@ -0,0 +1,431 @@
+// Copyright 2013 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.
+
+//go:build go1.16
+// +build go1.16
+
+package web
+
+import (
+	"bytes"
+	"fmt"
+	"html"
+	"html/template"
+	"io"
+	"io/fs"
+	"log"
+	"net/http"
+	"path"
+	"regexp"
+	"runtime"
+	"strconv"
+	"strings"
+
+	"golang.org/x/website/internal/api"
+	"golang.org/x/website/internal/pkgdoc"
+	"golang.org/x/website/internal/spec"
+	"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
+	api api.DB
+
+	mux        *http.ServeMux
+	fileServer http.Handler
+
+	Templates *template.Template
+
+	// GoogleCN reports whether this request should be marked GoogleCN.
+	// If the function is nil, no requests are marked GoogleCN.
+	GoogleCN func(*http.Request) bool
+
+	// GoogleAnalytics optionally adds Google Analytics via the provided
+	// tracking ID to each page.
+	GoogleAnalytics string
+
+	docFuncs template.FuncMap
+}
+
+// NewSite returns a new Presentation from a file system.
+func NewSite(fsys fs.FS) (*Site, error) {
+	apiDB, err := api.Load(fsys)
+	if err != nil {
+		return nil, err
+	}
+	p := &Site{
+		fs:         fsys,
+		api:        apiDB,
+		mux:        http.NewServeMux(),
+		fileServer: http.FileServer(http.FS(fsys)),
+	}
+	docs := &docServer{
+		p: p,
+		d: pkgdoc.NewDocs(fsys),
+	}
+	p.mux.Handle("/cmd/", docs)
+	p.mux.Handle("/pkg/", docs)
+	p.mux.HandleFunc("/", p.serveFile)
+	p.initDocFuncs()
+
+	t, err := template.New("").Funcs(siteFuncs).ParseFS(fsys, "lib/godoc/*.html")
+	if err != nil {
+		return nil, err
+	}
+	p.Templates = t
+
+	return p, nil
+}
+
+// ServeError responds to the request with the given error.
+func (s *Site) ServeError(w http.ResponseWriter, r *http.Request, err error) {
+	w.WriteHeader(http.StatusNotFound)
+	s.ServePage(w, r, Page{
+		Title:    r.URL.Path,
+		Template: "error.html",
+		Data:     err,
+	})
+}
+
+// ServeHTTP implements http.Handler, dispatching the request appropriately.
+func (s *Site) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	s.mux.ServeHTTP(w, r)
+}
+
+// ServePage responds to the request with the content described by page.
+func (s *Site) ServePage(w http.ResponseWriter, r *http.Request, page Page) {
+	page = s.fullPage(r, page)
+	applyTemplateToResponseWriter(w, s.Templates.Lookup("site.html"), &page)
+}
+
+// A Page describes the contents of a webpage to be served.
+//
+// A Page's Methods are for use by the templates rendering the page.
+type Page struct {
+	Title    string // <h1>
+	TabTitle string // prefix in <title>; defaults to Title
+	Subtitle string // subtitle (date for spec, memory model)
+	SrcPath  string // path to file in /src for text view
+
+	// Template and Data describe the data to be
+	// rendered into the overall site frame template.
+	// If Template is empty, then Data should be a template.HTML
+	// holding raw HTML to render into the site frame.
+	// Otherwise, Template should be the name of a template file
+	// in _content/lib/godoc (for example, "package.html"),
+	// and that template will be executed
+	// (with the *Page as its data argument) to produce HTML.
+	//
+	// The overall site template site.html is also invoked with
+	// the *Page as its data argument. It is what arranges to call Template.
+	Template string      // template to apply to data (empty string when Data is raw template.HTML)
+	Data     interface{} // data to be rendered into page frame
+
+	// Filled in automatically by ServePage
+	GoogleCN        bool   // page is being served from golang.google.cn
+	GoogleAnalytics string // Google Analytics tag
+	Version         string // current Go version
+
+	site *Site
+}
+
+// fullPage returns a copy of page with the “automatic” fields filled in.
+func (s *Site) fullPage(r *http.Request, page Page) Page {
+	if page.TabTitle == "" {
+		page.TabTitle = page.Title
+	}
+	page.Version = runtime.Version()
+	page.GoogleCN = s.googleCN(r)
+	page.GoogleAnalytics = s.GoogleAnalytics
+	page.site = s
+	return page
+}
+
+// Invoke invokes the template with the given name on
+// a copy of p with .Data set to data, returning the resulting HTML.
+func (p *Page) Invoke(name string, data interface{}) template.HTML {
+	t := p.site.Templates.Lookup(name)
+	var buf bytes.Buffer
+	p1 := *p
+	p1.Data = data
+	if err := t.Execute(&buf, &p1); err != nil {
+		log.Printf("%s.Execute: %s", t.Name(), err)
+	}
+	return template.HTML(buf.String())
+}
+
+type writeErrorSaver struct {
+	w   io.Writer
+	err error
+}
+
+func (w *writeErrorSaver) Write(p []byte) (int, error) {
+	n, err := w.w.Write(p)
+	if err != nil {
+		w.err = err
+	}
+	return n, err
+}
+
+// applyTemplateToResponseWriter uses an http.ResponseWriter as the io.Writer
+// for the call to template.Execute.  It uses an io.Writer wrapper to capture
+// errors from the underlying http.ResponseWriter.  Errors are logged only when
+// they come from the template processing and not the Writer; this avoid
+// polluting log files with error messages due to networking issues, such as
+// client disconnects and http HEAD protocol violations.
+func applyTemplateToResponseWriter(rw http.ResponseWriter, t *template.Template, data interface{}) {
+	w := &writeErrorSaver{w: rw}
+	err := t.Execute(w, data)
+	// There are some cases where template.Execute does not return an error when
+	// rw returns an error, and some where it does.  So check w.err first.
+	if w.err == nil && err != nil {
+		// Log template errors.
+		log.Printf("%s.Execute: %s", t.Name(), err)
+	}
+}
+
+func (s *Site) serveFile(w http.ResponseWriter, r *http.Request) {
+	if strings.HasSuffix(r.URL.Path, "/index.html") {
+		// We'll show index.html for the directory.
+		// Use the dir/ version as canonical instead of dir/index.html.
+		http.Redirect(w, r, r.URL.Path[0:len(r.URL.Path)-len("index.html")], http.StatusMovedPermanently)
+		return
+	}
+
+	// Check to see if we need to redirect or serve another file.
+	abspath := r.URL.Path
+	if f := open(s.fs, abspath); f != nil {
+		if f.Path != abspath {
+			// Redirect to canonical path.
+			http.Redirect(w, r, f.Path, http.StatusMovedPermanently)
+			return
+		}
+		// Serve from the actual filesystem path.
+		s.serveHTML(w, r, f)
+		return
+	}
+
+	relpath := abspath[1:] // strip leading slash
+
+	dir, err := fs.Stat(s.fs, toFS(abspath))
+	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 ||
+				open(s.fs, trimmed) != nil {
+				http.Redirect(w, r, trimmed, http.StatusMovedPermanently)
+				return
+			}
+		}
+		s.ServeError(w, r, err)
+		return
+	}
+
+	fsPath := toFS(abspath)
+	if dir != nil && dir.IsDir() {
+		if maybeRedirect(w, r) {
+			return
+		}
+		s.serveDir(w, r, abspath, relpath)
+		return
+	}
+
+	if isTextFile(s.fs, fsPath) {
+		if maybeRedirectFile(w, r) {
+			return
+		}
+		s.serveText(w, r, abspath, relpath)
+		return
+	}
+
+	s.fileServer.ServeHTTP(w, r)
+}
+
+func maybeRedirect(w http.ResponseWriter, r *http.Request) (redirected bool) {
+	canonical := path.Clean(r.URL.Path)
+	if !strings.HasSuffix(canonical, "/") {
+		canonical += "/"
+	}
+	if r.URL.Path != canonical {
+		url := *r.URL
+		url.Path = canonical
+		http.Redirect(w, r, url.String(), http.StatusMovedPermanently)
+		redirected = true
+	}
+	return
+}
+
+func maybeRedirectFile(w http.ResponseWriter, r *http.Request) (redirected bool) {
+	c := path.Clean(r.URL.Path)
+	c = strings.TrimRight(c, "/")
+	if r.URL.Path != c {
+		url := *r.URL
+		url.Path = c
+		http.Redirect(w, r, url.String(), http.StatusMovedPermanently)
+		redirected = true
+	}
+	return
+}
+
+var doctype = []byte("<!DOCTYPE ")
+
+func (s *Site) serveHTML(w http.ResponseWriter, r *http.Request, f *file) {
+	src := f.Body
+	isMarkdown := strings.HasSuffix(f.FilePath, ".md")
+
+	// if it begins with "<!DOCTYPE " assume it is standalone
+	// html that doesn't need the template wrapping.
+	if bytes.HasPrefix(src, doctype) {
+		w.Write(src)
+		return
+	}
+
+	page := Page{
+		Title:    f.Title,
+		Subtitle: f.Subtitle,
+	}
+
+	// evaluate as template if indicated
+	if f.Template {
+		page = s.fullPage(r, page)
+		tmpl, err := template.New("main").Funcs(s.docFuncs).Parse(string(src))
+		if err != nil {
+			log.Printf("parsing template %s: %v", f.Path, err)
+			s.ServeError(w, r, err)
+			return
+		}
+		var buf bytes.Buffer
+		if err := tmpl.Execute(&buf, page); err != nil {
+			log.Printf("executing template %s: %v", f.Path, err)
+			s.ServeError(w, r, err)
+			return
+		}
+		src = buf.Bytes()
+	}
+
+	// Apply markdown as indicated.
+	// (Note template applies before Markdown.)
+	if isMarkdown {
+		html, err := renderMarkdown(src)
+		if err != nil {
+			log.Printf("executing markdown %s: %v", f.Path, err)
+			s.ServeError(w, r, err)
+			return
+		}
+		src = html
+	}
+
+	// if it's the language spec, add tags to EBNF productions
+	if strings.HasSuffix(f.FilePath, "go_spec.html") {
+		var buf bytes.Buffer
+		spec.Linkify(&buf, src)
+		src = buf.Bytes()
+	}
+
+	page.Data = template.HTML(src)
+	s.ServePage(w, r, page)
+}
+
+func (s *Site) serveDir(w http.ResponseWriter, r *http.Request, abspath, relpath string) {
+	if maybeRedirect(w, r) {
+		return
+	}
+
+	list, err := fs.ReadDir(s.fs, toFS(abspath))
+	if err != nil {
+		s.ServeError(w, r, err)
+		return
+	}
+
+	var info []fs.FileInfo
+	for _, d := range list {
+		i, err := d.Info()
+		if err == nil {
+			info = append(info, i)
+		}
+	}
+
+	s.ServePage(w, r, Page{
+		Title:    "Directory",
+		SrcPath:  relpath,
+		TabTitle: relpath,
+		Template: "dirlist.html",
+		Data:     info,
+	})
+}
+
+func (s *Site) serveText(w http.ResponseWriter, r *http.Request, abspath, relpath string) {
+	src, err := fs.ReadFile(s.fs, toFS(abspath))
+	if err != nil {
+		log.Printf("ReadFile: %s", err)
+		s.ServeError(w, r, err)
+		return
+	}
+
+	if r.FormValue("m") == "text" {
+		s.serveRawText(w, src)
+		return
+	}
+
+	cfg := texthtml.Config{
+		GoComments: path.Ext(abspath) == ".go",
+		Highlight:  r.FormValue("h"),
+		Selection:  rangeSelection(r.FormValue("s")),
+		Line:       1,
+	}
+
+	var buf bytes.Buffer
+	buf.WriteString("<pre>")
+	buf.Write(texthtml.Format(src, cfg))
+	buf.WriteString("</pre>")
+
+	fmt.Fprintf(&buf, `<p><a href="/%s?m=text">View as plain text</a></p>`, html.EscapeString(relpath))
+
+	title := "Text file"
+	if strings.HasSuffix(relpath, ".go") {
+		title = "Source file"
+	}
+	s.ServePage(w, r, Page{
+		Title:    title,
+		SrcPath:  relpath,
+		TabTitle: relpath,
+		Data:     template.HTML(buf.String()),
+	})
+}
+
+var selRx = regexp.MustCompile(`^([0-9]+):([0-9]+)`)
+
+// rangeSelection computes the Selection for a text range described
+// by the argument str, of the form Start:End, where Start and End
+// are decimal byte offsets.
+func rangeSelection(str string) texthtml.Selection {
+	m := selRx.FindStringSubmatch(str)
+	if len(m) >= 2 {
+		from, _ := strconv.Atoi(m[1])
+		to, _ := strconv.Atoi(m[2])
+		if from < to {
+			return texthtml.Spans(texthtml.Span{Start: from, End: to})
+		}
+	}
+	return nil
+}
+
+func (s *Site) serveRawText(w http.ResponseWriter, text []byte) {
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	w.Write(text)
+}
+
+func (s *Site) googleCN(r *http.Request) bool {
+	return s.GoogleCN != nil && s.GoogleCN(r)
+}
diff --git a/internal/godoc/server_test.go b/internal/web/site_test.go
similarity index 90%
rename from internal/godoc/server_test.go
rename to internal/web/site_test.go
index 1829036..798ac2f 100644
--- a/internal/godoc/server_test.go
+++ b/internal/web/site_test.go
@@ -5,7 +5,7 @@
 //go:build go1.16
 // +build go1.16
 
-package godoc
+package web
 
 import (
 	"net/http"
@@ -16,7 +16,7 @@
 	"testing/fstest"
 )
 
-func testServeBody(t *testing.T, p *Presentation, path, body string) {
+func testServeBody(t *testing.T, p *Site, path, body string) {
 	t.Helper()
 	r := &http.Request{URL: &url.URL{Path: path}}
 	rw := httptest.NewRecorder()
@@ -32,7 +32,7 @@
 		"doc/x/index.html":    {Data: []byte("Hello, x.")},
 		"lib/godoc/site.html": {Data: []byte(`{{.Data}}`)},
 	}
-	p, err := NewPresentation(fsys)
+	p, err := NewSite(fsys)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -53,7 +53,7 @@
 }
 
 func TestMarkdown(t *testing.T) {
-	p, err := NewPresentation(fstest.MapFS{
+	p, err := NewSite(fstest.MapFS{
 		"doc/test.md":         {Data: []byte("**bold**")},
 		"doc/test2.md":        {Data: []byte(`{{"*template*"}}`)},
 		"lib/godoc/site.html": {Data: []byte(`{{.Data}}`)},
diff --git a/internal/godoc/godoc.go b/internal/web/sitefuncs.go
similarity index 80%
rename from internal/godoc/godoc.go
rename to internal/web/sitefuncs.go
index 7e6f989..04f1ad7 100644
--- a/internal/godoc/godoc.go
+++ b/internal/web/sitefuncs.go
@@ -5,7 +5,7 @@
 //go:build go1.16
 // +build go1.16
 
-package godoc
+package web
 
 import (
 	"bytes"
@@ -18,29 +18,41 @@
 	"path"
 	"strings"
 
-	"golang.org/x/website/internal/history"
 	"golang.org/x/website/internal/pkgdoc"
 )
 
-func (p *Presentation) initFuncMap() {
-	p.docFuncs = template.FuncMap{
-		"code":     p.code,
-		"releases": func() []*history.Major { return history.Majors },
-	}
-}
-
 var siteFuncs = template.FuncMap{
 	// various helpers
 	"basename": path.Base,
 
 	// formatting of Examples
-	"example_name":   example_nameFunc,
-	"example_suffix": example_suffixFunc,
+	"example_name":   example_name,
+	"example_suffix": example_suffix,
 
 	// Number operation
 	"multiply": func(a, b int) int { return a * b },
 }
 
+// example_name takes an example function name and returns its display
+// name. For example, "Foo_Bar_quux" becomes "Foo.Bar (Quux)".
+func example_name(s string) string {
+	name, suffix := pkgdoc.SplitExampleName(s)
+	// replace _ with . for method names
+	name = strings.Replace(name, "_", ".", 1)
+	// use "Package" if no name provided
+	if name == "" {
+		name = "Package"
+	}
+	return name + suffix
+}
+
+// example_suffix takes an example function name and returns its suffix in
+// parenthesized form. For example, "Foo_Bar_quux" becomes " (Quux)".
+func example_suffix(name string) string {
+	_, suffix := pkgdoc.SplitExampleName(name)
+	return suffix
+}
+
 func srcToPkg(path string) string {
 	// because of the irregular mapping under goroot
 	// we need to correct certain relative paths
@@ -125,10 +137,10 @@
 		high = info.FSet.Position(end).Offset
 	}
 
-	return srcPosLinkFunc(relpath, line, low, high)
+	return srcPosLink(relpath, line, low, high)
 }
 
-func srcPosLinkFunc(s string, line, low, high int) template.HTML {
+func srcPosLink(s string, line, low, high int) template.HTML {
 	s = path.Clean("/" + s)
 	if !strings.HasPrefix(s, "/src/") {
 		s = "/src" + s
diff --git a/internal/godoc/tab.go b/internal/web/tab.go
similarity index 92%
rename from internal/godoc/tab.go
rename to internal/web/tab.go
index 17fe293..e84aa8e 100644
--- a/internal/godoc/tab.go
+++ b/internal/web/tab.go
@@ -5,7 +5,7 @@
 //go:build go1.16
 // +build go1.16
 
-package godoc
+package web
 
 import "io"
 
@@ -16,9 +16,9 @@
 	collecting
 )
 
-// TabSpacer returns a writer that passes writes through to w,
+// tabSpacer returns a writer that passes writes through to w,
 // expanding tabs to one or more spaces ending at a width-spaces-aligned boundary.
-func TabSpacer(w io.Writer, width int) io.Writer {
+func tabSpacer(w io.Writer, width int) io.Writer {
 	return &tconv{output: w, tabWidth: width}
 }
 
diff --git a/internal/godoc/godoc_test.go b/internal/web/template_test.go
similarity index 98%
rename from internal/godoc/godoc_test.go
rename to internal/web/template_test.go
index eba1bd0..3ca8bbd 100644
--- a/internal/godoc/godoc_test.go
+++ b/internal/web/template_test.go
@@ -5,7 +5,7 @@
 //go:build go1.16
 // +build go1.16
 
-package godoc
+package web
 
 import (
 	"bytes"
@@ -53,7 +53,7 @@
 		{"fmt/print.go", 0, 0, 0, "/src/fmt/print.go"},
 		{"fmt/print.go", 0, 1, 5, "/src/fmt/print.go?s=1:5#L1"},
 	} {
-		if got := srcPosLinkFunc(tc.src, tc.line, tc.low, tc.high); got != tc.want {
+		if got := srcPosLink(tc.src, tc.line, tc.low, tc.high); got != tc.want {
 			t.Errorf("srcPosLink(%v, %v, %v, %v) = %v; want %v", tc.src, tc.line, tc.low, tc.high, got, tc.want)
 		}
 	}
@@ -185,7 +185,7 @@
 }
 
 func linkifySource(t *testing.T, src []byte) string {
-	p := &Presentation{}
+	p := &Site{}
 	fset := token.NewFileSet()
 	af, err := parser.ParseFile(fset, "foo.go", src, parser.ParseComments)
 	if err != nil {
@@ -196,7 +196,7 @@
 		FSet: fset,
 	}
 	pg := &Page{
-		pres: p,
+		site: p,
 		Data: pi,
 	}
 	sep := ""