internal/pkgdoc: take on doc-specific parts of internal/web

Not all the package docs-specific parts of the server moved into
internal/pkgdoc before. Finish the job. Now the API for pkgdoc
is like the API for codewalk: just a NewServer that returns a handler.

Speaking of codewalk, unexport the Server type to match the
trimmed-down pkgdoc.

Change-Id: I19ba7351d55fb5d23d551a0296bb89d8abac6e9b
Reviewed-on: https://go-review.googlesource.com/c/website/+/328212
Trust: Russ Cox <rsc@golang.org>
Run-TryBot: Russ Cox <rsc@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Website-Publish: Russ Cox <rsc@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/_content/lib/godoc/example.html b/_content/lib/godoc/example.html
index aef6edb..be4d1b1 100644
--- a/_content/lib/godoc/example.html
+++ b/_content/lib/godoc/example.html
@@ -1,10 +1,10 @@
 {{with .Data}}
 <div id="example_{{.Name}}" class="toggle">
   <div class="collapsed">
-    <p class="exampleHeading toggleButton">▹ <span class="text">Example{{example_suffix .Name}}</span></p>
+    <p class="exampleHeading toggleButton">▹ <span class="text">Example{{.Page.ExampleSuffix .Name}}</span></p>
   </div>
   <div class="expanded">
-    <p class="exampleHeading toggleButton">▾ <span class="text">Example{{example_suffix .Name}}</span></p>
+    <p class="exampleHeading toggleButton">▾ <span class="text">Example{{.Page.ExampleSuffix .Name}}</span></p>
     {{with .Doc}}<p>{{.}}</p>{{end}}
     {{$output := .Output}}
     {{with .Play}}
diff --git a/_content/lib/godoc/package.html b/_content/lib/godoc/package.html
index 7efdea1..9189dcc 100644
--- a/_content/lib/godoc/package.html
+++ b/_content/lib/godoc/package.html
@@ -13,7 +13,7 @@
 {{with $pkg.PDoc}}
 	{{if $pkg.IsMain}}
 		{{/* command documentation */}}
-		{{$.Comment .Doc}}
+		{{$pkg.Comment .Doc}}
 	{{else}}
 		{{/* package documentation */}}
 		<div id="short-nav">
@@ -38,8 +38,8 @@
 			</div>
 			<div class="expanded">
 				<h2 class="toggleButton" title="Click to hide Overview section">Overview ▾</h2>
-				{{$.Comment .Doc}}
-				{{$.Example ""}}
+				{{$pkg.Comment .Doc}}
+				{{range $pkg.FmtExamples ""}}{{$.Invoke "example.html" .}}{{end}}
 			</div>
 		</div>
 
@@ -60,16 +60,16 @@
 				<dd><a href="#pkg-variables">Variables</a></dd>
 			{{end}}
 			{{range .Funcs}}
-				<dd><a href="#{{.Name}}">{{$.NodeTOC .Decl}}</a></dd>
+				<dd><a href="#{{.Name}}">{{$pkg.NodeTOC .Decl}}</a></dd>
 			{{end}}
 			{{range .Types}}
 				{{$typeName := .Name}}
 				<dd><a href="#{{.Name}}">type {{.Name}}</a></dd>
 				{{range .Funcs}}
-					<dd>&nbsp; &nbsp; <a href="#{{.Name}}">{{$.NodeTOC .Decl}}</a></dd>
+					<dd>&nbsp; &nbsp; <a href="#{{.Name}}">{{$pkg.NodeTOC .Decl}}</a></dd>
 				{{end}}
 				{{range .Methods}}
-					<dd>&nbsp; &nbsp; <a href="#{{$typeName}}.{{.Name}}">{{$.NodeTOC .Decl}}</a></dd>
+					<dd>&nbsp; &nbsp; <a href="#{{$typeName}}.{{.Name}}">{{$pkg.NodeTOC .Decl}}</a></dd>
 				{{end}}
 			{{end}}
 			{{if $pkg.Bugs}}
@@ -84,7 +84,7 @@
 			<div class="js-expandAll expandAll collapsed">(Expand All)</div>
 			<dl>
 			{{range $pkg.Examples}}
-			<dd><a class="exampleLink" href="#example_{{.Name}}">{{example_name .Name}}</a></dd>
+			<dd><a class="exampleLink" href="#example_{{.Name}}">{{$pkg.ExampleName .Name}}</a></dd>
 			{{end}}
 			</dl>
 		</div>
@@ -106,70 +106,70 @@
 		{{with .Consts}}
 			<h2 id="pkg-constants">Constants</h2>
 			{{range .}}
-				{{$.Comment .Doc}}
-				<pre>{{$.Node .Decl}}</pre>
+				{{$pkg.Comment .Doc}}
+				<pre>{{$pkg.Node .Decl}}</pre>
 			{{end}}
 		{{end}}
 		{{with .Vars}}
 			<h2 id="pkg-variables">Variables</h2>
 			{{range .}}
-				{{$.Comment .Doc}}
-				<pre>{{$.Node .Decl}}</pre>
+				{{$pkg.Comment .Doc}}
+				<pre>{{$pkg.Node .Decl}}</pre>
 			{{end}}
 		{{end}}
 		{{range .Funcs}}
 			{{/* Name is a string - no need for FSet */}}
-			<h2 id="{{.Name}}">func <a href="{{$.SrcPosLink .Decl}}">{{.Name}}</a>
+			<h2 id="{{.Name}}">func <a href="{{$pkg.SrcPosLink .Decl}}">{{.Name}}</a>
 				<a class="permalink" href="#{{.Name}}">&#xb6;</a>
-				{{$since := $.Since "func" "" .Name}}
+				{{$since := $pkg.Since "func" "" .Name}}
 				{{if $since}}<span title="Added in Go {{$since}}">{{$since}}</span>{{end}}
 			</h2>
-			<pre>{{$.Node .Decl}}</pre>
-			{{$.Comment .Doc}}
-			{{$.Example .Name}}
+			<pre>{{$pkg.Node .Decl}}</pre>
+			{{$pkg.Comment .Doc}}
+			{{range $pkg.FmtExamples .Name}}{{$.Invoke "example.html" .}}{{end}}
 		{{end}}
 		{{range .Types}}
 			{{$typeName := .Name}}
-			<h2 id="{{.Name}}">type <a href="{{$.SrcPosLink .Decl}}">{{$typeName}}</a>
+			<h2 id="{{.Name}}">type <a href="{{$pkg.SrcPosLink .Decl}}">{{$typeName}}</a>
 				<a class="permalink" href="#{{.Name}}">&#xb6;</a>
-				{{$since := $.Since "type" "" .Name}}
+				{{$since := $pkg.Since "type" "" .Name}}
 				{{if $since}}<span title="Added in Go {{$since}}">{{$since}}</span>{{end}}
 			</h2>
-			{{$.Comment .Doc}}
-			<pre>{{$.Node .Decl}}</pre>
+			{{$pkg.Comment .Doc}}
+			<pre>{{$pkg.Node .Decl}}</pre>
 
 			{{range .Consts}}
-				{{$.Comment .Doc}}
-				<pre>{{$.Node .Decl}}</pre>
+				{{$pkg.Comment .Doc}}
+				<pre>{{$pkg.Node .Decl}}</pre>
 			{{end}}
 
 			{{range .Vars}}
-				{{$.Comment .Doc}}
-				<pre>{{$.Node .Decl}}</pre>
+				{{$pkg.Comment .Doc}}
+				<pre>{{$pkg.Node .Decl}}</pre>
 			{{end}}
 
-			{{$.Example .Name}}
+			{{range $pkg.FmtExamples .Name}}{{$.Invoke "example.html" .}}{{end}}
 
 			{{range .Funcs}}
-				<h3 id="{{.Name}}">func <a href="{{$.SrcPosLink .Decl}}">{{.Name}}</a>
+				<h3 id="{{.Name}}">func <a href="{{$pkg.SrcPosLink .Decl}}">{{.Name}}</a>
 					<a class="permalink" href="#{{.Name}}">&#xb6;</a>
-					{{$since := $.Since "func" "" .Name}}
+					{{$since := $pkg.Since "func" "" .Name}}
 					{{if $since}}<span title="Added in Go {{$since}}">{{$since}}</span>{{end}}
 				</h3>
-				<pre>{{$.Node .Decl}}</pre>
-				{{$.Comment .Doc}}
-				{{$.Example .Name}}
+				<pre>{{$pkg.Node .Decl}}</pre>
+				{{$pkg.Comment .Doc}}
+				{{range $pkg.FmtExamples .Name}}{{$.Invoke "example.html" .}}{{end}}
 			{{end}}
 
 			{{range .Methods}}
-				<h3 id="{{$typeName}}.{{.Name}}">func ({{html .Recv}}) <a href="{{$.SrcPosLink .Decl}}">{{.Name}}</a>
+				<h3 id="{{$typeName}}.{{.Name}}">func ({{html .Recv}}) <a href="{{$pkg.SrcPosLink .Decl}}">{{.Name}}</a>
 					<a class="permalink" href="#{{$typeName}}.{{.Name}}">&#xb6;</a>
-					{{$since := $.Since "method" .Recv .Name}}
+					{{$since := $pkg.Since "method" .Recv .Name}}
 					{{if $since}}<span title="Added in Go {{$since}}">{{$since}}</span>{{end}}
 				</h3>
-				<pre>{{$.Node .Decl}}</pre>
-				{{$.Comment .Doc}}
-				{{$.Example (printf "%s_%s" $typeName .Name)}}
+				<pre>{{$pkg.Node .Decl}}</pre>
+				{{$pkg.Comment .Doc}}
+				{{range $pkg.FmtExamples (printf "%s_%s" $typeName .Name)}}{{$.Invoke "example.html" .}}{{end}}
 			{{end}}
 		{{end}}
 	{{end}}
@@ -178,7 +178,7 @@
 		<h2 id="pkg-note-BUG">Bugs</h2>
 		<ul style="list-style: none; padding: 0;">
 		{{range .}}
-		<li><a href="{{$.SrcPosLink .}}" style="float: left;">&#x261e;</a> {{$.Comment .Body}}</li>
+		<li><a href="{{$pkg.SrcPosLink .}}" style="float: left;">&#x261e;</a> {{$pkg.Comment .Body}}</li>
 		{{end}}
 		</ul>
 	{{end}}
@@ -202,17 +202,17 @@
 			</tr>
 			{{end}}
 
-			{{range .List}}
+			{{range .}}
 				<tr>
 				{{if $pkg.DirFlat}}
 					{{if .HasPkg}}
 						<td class="pkg-name">
-							<a href="{{.Path}}/{{$.ModeQuery}}">{{.Path}}</a>
+							<a href="{{.Path}}/{{$pkg.ModeQuery}}">{{.Path}}</a>
 						</td>
 					{{end}}
 				{{else}}
 					<td class="pkg-name" style="padding-left: {{multiply .Depth 20}}px;">
-						<a href="{{.Path}}/{{$.ModeQuery}}">{{.Name}}</a>
+						<a href="{{.Path}}/{{$pkg.ModeQuery}}">{{.Name}}</a>
 					</td>
 				{{end}}
 					<td class="pkg-synopsis">
diff --git a/_content/lib/godoc/packageroot.html b/_content/lib/godoc/packageroot.html
index 218c845..e2041b9 100644
--- a/_content/lib/godoc/packageroot.html
+++ b/_content/lib/godoc/packageroot.html
@@ -39,17 +39,17 @@
 							<th class="pkg-synopsis">Synopsis</th>
 						</tr>
 
-						{{range .List}}
+						{{range .}}
 							<tr>
 							{{if $pkg.DirFlat}}
 								{{if .HasPkg}}
 										<td class="pkg-name">
-											<a href="{{.Path}}/{{$.ModeQuery}}">{{.Path}}</a>
+											<a href="{{.Path}}/{{$pkg.ModeQuery}}">{{.Path}}</a>
 										</td>
 								{{end}}
 							{{else}}
 									<td class="pkg-name" style="padding-left: {{multiply .Depth 20}}px;">
-										<a href="{{.Path}}/{{$.ModeQuery}}">{{.Name}}</a>
+										<a href="{{.Path}}/{{$pkg.ModeQuery}}">{{.Name}}</a>
 									</td>
 							{{end}}
 							<td class="pkg-synopsis">
diff --git a/cmd/golangorg/server.go b/cmd/golangorg/server.go
index dbb8b63..81b3df1 100644
--- a/cmd/golangorg/server.go
+++ b/cmd/golangorg/server.go
@@ -23,6 +23,7 @@
 
 	"cloud.google.com/go/datastore"
 	"golang.org/x/build/repos"
+	"golang.org/x/tools/playground"
 	"golang.org/x/website"
 	"golang.org/x/website/internal/backport/archive/zip"
 	"golang.org/x/website/internal/backport/html/template"
@@ -32,16 +33,12 @@
 	"golang.org/x/website/internal/dl"
 	"golang.org/x/website/internal/env"
 	"golang.org/x/website/internal/memcache"
+	"golang.org/x/website/internal/pkgdoc"
 	"golang.org/x/website/internal/proxy"
 	"golang.org/x/website/internal/redirect"
 	"golang.org/x/website/internal/short"
 	"golang.org/x/website/internal/web"
 	"golang.org/x/website/internal/webtest"
-
-	// Registers "/compile" handler that redirects to play.golang.org/compile.
-	// If we are in prod we will register "golang.org/compile" separately,
-	// which will get used instead.
-	_ "golang.org/x/tools/playground"
 )
 
 var (
@@ -144,6 +141,15 @@
 
 	mux := http.NewServeMux()
 	mux.Handle("/", site)
+
+	docs, err := pkgdoc.NewServer(fsys, site)
+	if err != nil {
+		log.Fatal(err)
+	}
+	mux.Handle("/cmd/", docs)
+	mux.Handle("/pkg/", docs)
+
+	mux.Handle("/compile", playground.Proxy())
 	mux.Handle("/doc/codewalk/", codewalk.NewServer(fsys, site))
 	mux.Handle("/fmt", http.HandlerFunc(fmtHandler))
 	mux.Handle("/x/", http.HandlerFunc(xHandler))
diff --git a/internal/codewalk/codewalk.go b/internal/codewalk/codewalk.go
index e4cb711..cefe691 100644
--- a/internal/codewalk/codewalk.go
+++ b/internal/codewalk/codewalk.go
@@ -2,14 +2,14 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+// Package codewalk implements support for codewalk documents.
+//
 // The /doc/codewalk/ tree is synthesized from codewalk descriptions,
 // files named _content/doc/codewalk/*.xml.
 // For an example and a description of the format, see
-// http://golang.org/doc/codewalk/codewalk or run godoc -http=:6060
-// and see http://localhost:6060/doc/codewalk/codewalk .
+// https://golang.org/doc/codewalk/codewalk.
 // That page is itself a codewalk; the source code for it is
 // _content/doc/codewalk/codewalk.xml.
-
 package codewalk
 
 import (
@@ -32,17 +32,18 @@
 	"golang.org/x/website/internal/web"
 )
 
-type Server struct {
+type server struct {
 	fsys fs.FS
 	site *web.Site
 }
 
-func NewServer(fsys fs.FS, site *web.Site) *Server {
-	return &Server{fsys, site}
+// NewServer returns a new server handling codewalk documents.
+func NewServer(fsys fs.FS, site *web.Site) http.Handler {
+	return &server{fsys, site}
 }
 
 // Handler for /doc/codewalk/ and below.
-func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	relpath := path.Clean(r.URL.Path[1:])
 
 	r.ParseForm()
@@ -143,7 +144,7 @@
 }
 
 // loadCodewalk reads a codewalk from the named XML file.
-func (s *Server) loadCodewalk(filename string) (*codewalk, error) {
+func (s *server) loadCodewalk(filename string) (*codewalk, error) {
 	f, err := s.fsys.Open(filename)
 	if err != nil {
 		return nil, err
@@ -206,7 +207,7 @@
 // codewalkDir serves the codewalk directory listing.
 // It scans the directory for subdirectories or files named *.xml
 // and prepares a table.
-func (s *Server) codewalkDir(w http.ResponseWriter, r *http.Request, relpath string) {
+func (s *server) codewalkDir(w http.ResponseWriter, r *http.Request, relpath string) {
 	type elem struct {
 		Name  string
 		Title string
@@ -245,7 +246,7 @@
 // in the response.  This format is used for the middle window pane
 // of the codewalk pages.  It is a separate iframe and does not get
 // the usual godoc HTML wrapper.
-func (s *Server) codewalkFileprint(w http.ResponseWriter, r *http.Request, f string) {
+func (s *server) codewalkFileprint(w http.ResponseWriter, r *http.Request, f string) {
 	relpath := strings.Trim(path.Clean(f), "/")
 	data, err := fs.ReadFile(s.fsys, relpath)
 	if err != nil {
diff --git a/internal/pkgdoc/dir.go b/internal/pkgdoc/dir.go
index 5ff4814..d71a0cc 100644
--- a/internal/pkgdoc/dir.go
+++ b/internal/pkgdoc/dir.go
@@ -31,10 +31,6 @@
 	return path.Base(d.Path)
 }
 
-type DirList struct {
-	List []DirEntry
-}
-
 // DirEntry describes a directory entry.
 // The Depth gives the directory depth relative to the overall list,
 // for use in presenting a hierarchical directory entry.
@@ -49,19 +45,18 @@
 	return path.Base(d.Path)
 }
 
-// Lookup looks for the *Directory for a given named path, relative to dir.
-func (dir *Dir) Lookup(name string) *Dir {
-	name = path.Join(dir.Path, name)
-	if name == dir.Path {
-		return dir
+// lookup looks for the *Dir for a given named path, relative to d.
+func (d *Dir) lookup(name string) *Dir {
+	name = path.Join(d.Path, name)
+	if name == d.Path {
+		return d
 	}
-	if dir.Path != "." {
-		if !strings.HasPrefix(name, dir.Path) || name[len(dir.Path)] != '/' {
+	if d.Path != "." {
+		if !strings.HasPrefix(name, d.Path) || name[len(d.Path)] != '/' {
 			return nil
 		}
-		name = name[len(dir.Path)+1:]
+		name = name[len(d.Path)+1:]
 	}
-	d := dir
 Walk:
 	for i := 0; i <= len(name); i++ {
 		if i == len(name) || name[i] == '/' {
@@ -78,25 +73,24 @@
 	return d
 }
 
-// List creates a (linear) directory List from a directory tree.
-// If skipRoot is set, the root directory itself is excluded from the list.
+// list creates a (linear) directory list from a directory tree.
 // If filter is set, only the directory entries whose paths match the filter
 // are included.
-//
-func (dir *Dir) List(filter func(string) bool) *DirList {
-	if dir == nil {
+func (d *Dir) list(filter func(string) bool) []DirEntry {
+	if d == nil {
 		return nil
 	}
 
+	root := d
 	var list []DirEntry
-	dir.walk(func(d *Dir, depth int) {
+	root.walk(func(d *Dir, depth int) {
 		if depth == 0 || filter != nil && !filter(d.Path) {
 			return
 		}
 		// the path is relative to root.Path - remove the root.Path
 		// prefix (the prefix should always be present but avoid
 		// crashes and check)
-		path := strings.TrimPrefix(d.Path, dir.Path)
+		path := strings.TrimPrefix(d.Path, root.Path)
 		// remove leading separator if any - path must be relative
 		path = strings.TrimPrefix(path, "/")
 		list = append(list, DirEntry{
@@ -110,7 +104,7 @@
 	if len(list) == 0 {
 		return nil
 	}
-	return &DirList{list}
+	return list
 }
 
 func newDir(fsys fs.FS, fset *token.FileSet, dirpath string) *Dir {
@@ -129,16 +123,16 @@
 	var dirchs []chan *Dir
 	var dirs []*Dir
 
-	for _, d := range list {
-		filename := path.Join(dirpath, d.Name())
+	for _, de := range list {
+		filename := path.Join(dirpath, de.Name())
 		switch {
-		case isPkgDir(d):
-			dir := newDir(fsys, fset, filename)
-			if dir != nil {
-				dirs = append(dirs, dir)
+		case isPkgDir(de):
+			d := newDir(fsys, fset, filename)
+			if d != nil {
+				dirs = append(dirs, d)
 			}
 
-		case !haveSummary && isPkgFile(d):
+		case !haveSummary && isPkgFile(de):
 			// looks like a package file, but may just be a file ending in ".go";
 			// don't just count it yet (otherwise we may end up with hasPkgFiles even
 			// though the directory doesn't contain any real package files - was bug)
@@ -219,11 +213,11 @@
 		len(name) > 0 && name[0] != '_' && name[0] != '.' // ignore _files and .files
 }
 
-// walk calls f(d, depth) for each directory d in the tree rooted at dir, including dir itself.
-// The depth argument specifies the depth of d in the tree.
-// The depth of dir itself is 0.
-func (dir *Dir) walk(f func(d *Dir, depth int)) {
-	walkDirs(f, dir, 0)
+// walk calls f for each directory in the tree rooted at d, including d itself.
+// The depth argument specifies the depth of the directory in the tree.
+// The depth of d itself is 0.
+func (d *Dir) walk(f func(d *Dir, depth int)) {
+	walkDirs(f, d, 0)
 }
 
 func walkDirs(f func(d *Dir, depth int), d *Dir, depth int) {
diff --git a/internal/pkgdoc/dir_test.go b/internal/pkgdoc/dir_test.go
index 0faa961..bcbcb69 100644
--- a/internal/pkgdoc/dir_test.go
+++ b/internal/pkgdoc/dir_test.go
@@ -15,16 +15,16 @@
 )
 
 func TestNewDirTree(t *testing.T) {
-	dir := newDir(osfs.DirFS(runtime.GOROOT()), token.NewFileSet(), "src")
-	processDir(t, dir)
+	d := newDir(osfs.DirFS(runtime.GOROOT()), token.NewFileSet(), "src")
+	processDir(t, d)
 }
 
-func processDir(t *testing.T, dir *Dir) {
+func processDir(t *testing.T, d *Dir) {
 	var list []string
-	for _, d := range dir.Dirs {
-		list = append(list, d.Name())
+	for _, child := range d.Dirs {
+		list = append(list, child.Name())
 		// recursively process the lower level
-		processDir(t, d)
+		processDir(t, child)
 	}
 
 	if sort.StringsAreSorted(list) == false {
@@ -44,9 +44,9 @@
 `)},
 	}
 
-	dir := newDir(fs, token.NewFileSet(), "src/index/suffixarray")
-	if got, want := dir.Synopsis, "P0: directory name matches package name"; got != want {
-		t.Errorf("dir.Synopsis = %q; want %q", got, want)
+	d := newDir(fs, token.NewFileSet(), "src/index/suffixarray")
+	if got, want := d.Synopsis, "P0: directory name matches package name"; got != want {
+		t.Errorf("d.Synopsis = %q; want %q", got, want)
 	}
 }
 
diff --git a/internal/pkgdoc/doc.go b/internal/pkgdoc/doc.go
index 6dd86e8..b414f29 100644
--- a/internal/pkgdoc/doc.go
+++ b/internal/pkgdoc/doc.go
@@ -2,6 +2,11 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+// Package pkgdoc serves package documentation.
+//
+// The only API for Go programs is NewServer.
+// The exported data structures are consumed by the templates
+// in _content/lib/godoc/package*.html.
 package pkgdoc
 
 import (
@@ -13,6 +18,8 @@
 	"io"
 	"io/ioutil"
 	"log"
+	"net/http"
+	"net/url"
 	"os"
 	"path"
 	"path/filepath"
@@ -21,34 +28,54 @@
 	"unicode"
 	"unicode/utf8"
 
+	"golang.org/x/website/internal/api"
 	"golang.org/x/website/internal/backport/io/fs"
+	"golang.org/x/website/internal/web"
 )
 
-type Docs struct {
+type docs struct {
 	fs   fs.FS
+	api  api.DB
+	site *web.Site
 	root *Dir
 }
 
-func NewDocs(fsys fs.FS) *Docs {
+// NewServer returns an HTTP handler serving package docs
+// for packages loaded from fsys (a tree in GOROOT layout),
+// styled according to site.
+func NewServer(fsys fs.FS, site *web.Site) (http.Handler, error) {
+	apiDB, err := api.Load(fsys)
+	if err != nil {
+		return nil, err
+	}
 	src := newDir(fsys, token.NewFileSet(), "src")
 	root := &Dir{
 		Path: ".",
 		Dirs: []*Dir{src},
 	}
-	return &Docs{
+	docs := &docs{
 		fs:   fsys,
+		api:  apiDB,
+		site: site,
 		root: root,
 	}
+	return docs, nil
 }
 
 type Page struct {
+	docs *docs // outer doc collection
+
+	Web *web.Page // filled in by caller
+
+	OldDocs bool // use ?m=old in doc links
+
 	Dirname string // directory containing the package
 	Err     error  // error or nil
 
-	Mode Mode // display metadata from query string
+	mode mode // display metadata from query string
 
 	// package info
-	FSet       *token.FileSet // nil if no package documentation
+	fset       *token.FileSet // nil if no package documentation
 	PDoc       *doc.Package   // nil if no package documentation
 	Examples   []*doc.Example // nil if no example code
 	Bugs       []*doc.Note    // nil if no BUG comments
@@ -56,25 +83,25 @@
 	IsFiltered bool           // true if results were filtered
 
 	// directory info
-	Dirs    *DirList // nil if no directory information
-	DirFlat bool     // if set, show directory in a flat (non-indented) manner
+	Dirs    []DirEntry // nil if no directory information
+	DirFlat bool       // if set, show directory in a flat (non-indented) manner
 }
 
-func (info *Page) IsEmpty() bool {
-	return info.Err != nil || info.PDoc == nil && info.Dirs == nil
+func (p *Page) SetWebPage(w *web.Page) {
+	p.Web = w
 }
 
-type Mode uint
+type mode uint
 
 const (
-	ModeAll     Mode = 1 << iota // do not filter exports
-	ModeFlat                     // show directory in a flat (non-indented) manner
-	ModeMethods                  // show all embedded methods
-	ModeOld                      // do not redirect to pkg.go.dev
-	ModeBuiltin                  // don't associate consts, vars, and factory functions with types (not exposed via ?m= query parameter, used for package builtin, see issue 6645)
+	modeAll     mode = 1 << iota // do not filter exports
+	modeFlat                     // show directory in a flat (non-indented) manner
+	modeMethods                  // show all embedded methods
+	modeOld                      // do not redirect to pkg.go.dev
+	modeBuiltin                  // don't associate consts, vars, and factory functions with types (not exposed via ?m= query parameter, used for package builtin, see issue 6645)
 )
 
-// modeNames defines names for each PageInfoMode flag.
+// modeNames defines names for each mode flag.
 // The order here must match the order of the constants above.
 var modeNames = []string{
 	"all",
@@ -83,8 +110,8 @@
 	"old",
 }
 
-// generate a query string for persisting PageInfoMode between pages.
-func (m Mode) String() string {
+// generate a query string for persisting the mode m between pages.
+func (m mode) String() string {
 	s := ""
 	for i, name := range modeNames {
 		if m&(1<<i) != 0 && name != "" {
@@ -97,10 +124,10 @@
 	return s
 }
 
-// ParseMode computes the PageInfoMode flags by analyzing the request
-// URL form value "m". It is value is a comma-separated list of mode names (for example, "all,flat").
-func ParseMode(text string) Mode {
-	var mode Mode
+// parseMode computes the mode flags by analyzing the request URL form value "m".
+// Its value is a comma-separated list of mode names (for example, "all,flat").
+func parseMode(text string) mode {
+	var mode mode
 	for _, k := range strings.Split(text, ",") {
 		k = strings.TrimSpace(k)
 		for i, name := range modeNames {
@@ -112,15 +139,15 @@
 	return mode
 }
 
-// Doc returns the Page for a package directory dir.
+// open 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, dir string, mode Mode, goos, goarch string) *Page {
+func (d *docs) open(dir string, mode mode, goos, goarch string) *Page {
 	dir = path.Clean(dir)
-	info := &Page{Dirname: dir, Mode: mode}
+	info := &Page{docs: d, 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
@@ -138,7 +165,7 @@
 		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" {
+			if mode&modeAll != 0 || i.Name() != "internal" {
 				info, err := i.Info()
 				if err == nil {
 					filtered = append(filtered, info)
@@ -204,18 +231,18 @@
 		pkg, _ := ast.NewPackage(fset, files, simpleImporter, nil)
 
 		// extract package documentation
-		info.FSet = fset
+		info.fset = fset
 		info.IsMain = pkgname == "main"
 		// show extracted documentation
 		var m doc.Mode
-		if mode&ModeAll != 0 {
+		if mode&modeAll != 0 {
 			m |= doc.AllDecls
 		}
-		if mode&ModeMethods != 0 {
+		if mode&modeMethods != 0 {
 			m |= doc.AllMethods
 		}
 		info.PDoc = doc.New(pkg, strings.TrimPrefix(dir, "src/"), m)
-		if mode&ModeBuiltin != 0 {
+		if mode&modeBuiltin != 0 {
 			for _, t := range info.PDoc.Types {
 				info.PDoc.Consts = append(info.PDoc.Consts, t.Consts...)
 				info.PDoc.Vars = append(info.PDoc.Vars, t.Vars...)
@@ -239,15 +266,15 @@
 		info.Bugs = info.PDoc.Notes["BUG"]
 	}
 
-	info.Dirs = d.root.Lookup(dir).List(func(path string) bool { return d.includePath(path, mode) })
-	info.DirFlat = mode&ModeFlat != 0
+	info.Dirs = d.root.lookup(dir).list(func(path string) bool { return d.includePath(path, mode) })
+	info.DirFlat = mode&modeFlat != 0
 
 	return info
 }
 
-func (d *Docs) includePath(path string, mode Mode) (r bool) {
+func (d *docs) includePath(path string, mode mode) (r bool) {
 	// if the path includes 'internal', don't list unless we are in the NoFiltering mode.
-	if mode&ModeAll != 0 {
+	if mode&modeAll != 0 {
 		return true
 	}
 	if strings.Contains(path, "internal") || strings.Contains(path, "vendor") {
@@ -308,7 +335,7 @@
 	var examples []*doc.Example
 	globals := globalNames(pkg)
 	for _, e := range doc.Examples(files...) {
-		name := TrimExampleSuffix(e.Name)
+		name := trimExampleSuffix(e.Name)
 		if name == "" || globals[name] {
 			examples = append(examples, e)
 		}
@@ -360,7 +387,7 @@
 	}
 }
 
-func SplitExampleName(s string) (name, suffix string) {
+func splitExampleName(s string) (name, suffix string) {
 	i := strings.LastIndex(s, "_")
 	if 0 <= i && i < len(s)-1 && !startsWithUppercase(s[i+1:]) {
 		name = s[:i]
@@ -371,9 +398,9 @@
 	return
 }
 
-// TrimExampleSuffix strips lowercase braz in Foo_braz or Foo_Bar_braz from name
+// trimExampleSuffix strips lowercase braz in Foo_braz or Foo_Bar_braz from name
 // while keeping uppercase Braz in Foo_Braz.
-func TrimExampleSuffix(name string) string {
+func trimExampleSuffix(name string) string {
 	if i := strings.LastIndex(name, "_"); i != -1 {
 		if i < len(name)-1 && !startsWithUppercase(name[i+1:]) {
 			name = name[:i]
@@ -386,3 +413,123 @@
 	r, _ := utf8.DecodeRuneInString(s)
 	return unicode.IsUpper(r)
 }
+
+func (d *docs) 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, "/")
+
+	mode := parseMode(r.FormValue("m"))
+
+	// Redirect to pkg.go.dev.
+	// We provide two overrides for the redirect.
+	// First, the request can set ?m=old to get the old pages.
+	// Second, the request can come from China:
+	// since pkg.go.dev is not available in China, we serve the docs directly.
+	if mode&modeOld == 0 && !web.GoogleCN(r) {
+		if relpath == "" {
+			relpath = "std"
+		}
+		suffix := ""
+		if r.Host == "tip.golang.org" {
+			suffix = "@master"
+		}
+		if goos, goarch := r.FormValue("GOOS"), r.FormValue("GOARCH"); goos != "" || goarch != "" {
+			suffix += "?"
+			if goos != "" {
+				suffix += "GOOS=" + url.QueryEscape(goos)
+			}
+			if goarch != "" {
+				if goos != "" {
+					suffix += "&"
+				}
+				suffix += "GOARCH=" + url.QueryEscape(goarch)
+			}
+		}
+		http.Redirect(w, r, "https://pkg.go.dev/"+relpath+suffix, http.StatusTemporaryRedirect)
+		return
+	}
+
+	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 |= modeAll | modeBuiltin
+	}
+	info := d.open("src/"+relpath, mode, r.FormValue("GOOS"), r.FormValue("GOARCH"))
+	if info.Err != nil {
+		log.Print(info.Err)
+		d.site.ServeError(w, r, info.Err)
+		return
+	}
+	info.OldDocs = mode&modeOld != 0
+
+	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"
+	}
+	d.site.ServePage(w, r, web.Page{
+		Title:    title,
+		TabTitle: tabtitle,
+		Subtitle: subtitle,
+		Template: name,
+		Data:     info,
+	})
+}
+
+// ModeQuery returns the "?m=..." query for the current page.
+func (p *Page) ModeQuery() string {
+	m := p.mode
+	s := m.String()
+	if s == "" {
+		return ""
+	}
+	return "?m=" + s
+}
+
+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
+}
diff --git a/internal/pkgdoc/doc_test.go b/internal/pkgdoc/doc_test.go
index ca5e23f..ad1eaa1 100644
--- a/internal/pkgdoc/doc_test.go
+++ b/internal/pkgdoc/doc_test.go
@@ -8,6 +8,7 @@
 	"testing"
 
 	"golang.org/x/website/internal/backport/testing/fstest"
+	"golang.org/x/website/internal/web"
 )
 
 // TestIgnoredGoFiles tests the scenario where a folder has no .go or .c files,
@@ -17,13 +18,22 @@
 	packageComment := "main is documented in an ignored .go file"
 
 	fs := fstest.MapFS{
+		"lib/godoc/x.html": {},
 		"src/" + packagePath + "/ignored.go": {Data: []byte(`// +build ignore
 
 // ` + packageComment + `
 package main`)},
 	}
-	d := NewDocs(fs)
-	pInfo := Doc(d, "src/"+packagePath, ModeAll, "linux", "amd64")
+	site, err := web.NewSite(fs)
+	if err != nil {
+		t.Fatal(err)
+	}
+	h, err := NewServer(fs, site)
+	if err != nil {
+		t.Fatal(err)
+	}
+	d := h.(*docs)
+	pInfo := d.open("src/"+packagePath, modeAll, "linux", "amd64")
 
 	if pInfo.PDoc == nil {
 		t.Error("pInfo.PDoc = nil; want non-nil.")
@@ -38,14 +48,15 @@
 			t.Errorf("pInfo.PDoc.ImportPath = %q; want %q.", got, want)
 		}
 	}
-	if pInfo.FSet == nil {
-		t.Error("pInfo.FSet = nil; want non-nil.")
+	if pInfo.fset == nil {
+		t.Error("pInfo.fset = nil; want non-nil.")
 	}
 }
 
 func TestIssue5247(t *testing.T) {
 	const packagePath = "example.com/p"
 	fs := fstest.MapFS{
+		"lib/godoc/x.html": {},
 		"src/" + packagePath + "/p.go": {Data: []byte(`package p
 
 //line notgen.go:3
@@ -55,8 +66,17 @@
 //line foo.go:100`)}, // No newline at end to check corner cases.
 	}
 
-	d := NewDocs(fs)
-	pInfo := Doc(d, "src/"+packagePath, 0, "linux", "amd64")
+	site, err := web.NewSite(fs)
+	if err != nil {
+		t.Fatal(err)
+	}
+	h, err := NewServer(fs, site)
+	if err != nil {
+		t.Fatal(err)
+	}
+	d := h.(*docs)
+
+	pInfo := d.open("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/pkgdoc/funcs.go b/internal/pkgdoc/funcs.go
new file mode 100644
index 0000000..01305d0
--- /dev/null
+++ b/internal/pkgdoc/funcs.go
@@ -0,0 +1,467 @@
+// 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.
+
+package pkgdoc
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"go/ast"
+	"go/doc"
+	"go/format"
+	"go/printer"
+	"go/token"
+	"io"
+	"log"
+	"path"
+	"regexp"
+	"strings"
+	"unicode"
+	"unicode/utf8"
+
+	"golang.org/x/website/internal/api"
+	"golang.org/x/website/internal/backport/html/template"
+	"golang.org/x/website/internal/texthtml"
+)
+
+var slashSlash = []byte("//")
+
+// Node formats the given AST node as HTML.
+// Identifiers in the rendered node
+// are turned into links to their documentation.
+func (p *Page) Node(node interface{}) template.HTML {
+	var buf1 bytes.Buffer
+	p.docs.writeNode(&buf1, p, p.fset, node)
+
+	var buf2 bytes.Buffer
+	n, _ := node.(ast.Node)
+	buf2.Write(texthtml.Format(buf1.Bytes(), texthtml.Config{
+		AST:        n,
+		GoComments: true,
+		OldDocs:    p.OldDocs,
+	}))
+	return template.HTML(buf2.String())
+}
+
+// NodeTOC formats the given AST node as HTML
+// for inclusion in the table of contents.
+func (p *Page) NodeTOC(node interface{}) template.HTML {
+	var buf1 bytes.Buffer
+	p.docs.writeNode(&buf1, p, p.fset, node)
+
+	var buf2 bytes.Buffer
+	buf2.Write(texthtml.Format(buf1.Bytes(), texthtml.Config{
+		GoComments: true,
+		OldDocs:    p.OldDocs,
+	}))
+
+	return sanitize(template.HTML(buf2.String()))
+}
+
+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 (d *docs) writeNode(w io.Writer, pageInfo *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
+	// the right number of spaces is much harder)
+	//
+	// TODO(gri) rethink printer flags - perhaps tconv can be eliminated
+	//           with an another printer mode (which is more efficiently
+	//           implemented in the printer than here with another layer)
+
+	var pkgName, structName string
+	var apiInfo api.PkgDB
+	if gd, ok := x.(*ast.GenDecl); ok && pageInfo != nil && pageInfo.PDoc != nil &&
+		gd.Tok == token.TYPE && len(gd.Specs) != 0 {
+		pkgName = pageInfo.PDoc.ImportPath
+		if ts, ok := gd.Specs[0].(*ast.TypeSpec); ok {
+			if _, ok := ts.Type.(*ast.StructType); ok {
+				structName = ts.Name.Name
+			}
+		}
+		apiInfo = d.api[pkgName]
+	}
+
+	var out = w
+	var buf bytes.Buffer
+	if structName != "" {
+		out = &buf
+	}
+
+	mode := printer.TabIndent | printer.UseSpaces
+	err := (&printer.Config{Mode: mode, Tabwidth: tabWidth}).Fprint(tabSpacer(out, tabWidth), fset, x)
+	if err != nil {
+		log.Print(err)
+	}
+
+	// Add comments to struct fields saying which Go version introduced them.
+	if structName != "" {
+		fieldSince := apiInfo.Field[structName]
+		typeSince := apiInfo.Type[structName]
+		// Add/rewrite comments on struct fields to note which Go version added them.
+		var buf2 bytes.Buffer
+		buf2.Grow(buf.Len() + len(" // Added in Go 1.n")*10)
+		bs := bufio.NewScanner(&buf)
+		for bs.Scan() {
+			line := bs.Bytes()
+			field := firstIdent(line)
+			var since string
+			if field != "" {
+				since = fieldSince[field]
+				if since != "" && since == typeSince {
+					// Don't highlight field versions if they were the
+					// same as the struct itself.
+					since = ""
+				}
+			}
+			if since == "" {
+				buf2.Write(line)
+			} else {
+				if bytes.Contains(line, slashSlash) {
+					line = bytes.TrimRight(line, " \t.")
+					buf2.Write(line)
+					buf2.WriteString("; added in Go ")
+				} else {
+					buf2.Write(line)
+					buf2.WriteString(" // Go ")
+				}
+				buf2.WriteString(since)
+			}
+			buf2.WriteByte('\n')
+		}
+		w.Write(buf2.Bytes())
+	}
+}
+
+// firstIdent returns the first identifier in x.
+// This actually parses "identifiers" that begin with numbers too, but we
+// never feed it such input, so it's fine.
+func firstIdent(x []byte) string {
+	x = bytes.TrimSpace(x)
+	i := bytes.IndexFunc(x, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) })
+	if i == -1 {
+		return string(x)
+	}
+	return string(x[:i])
+}
+
+// Comment formats the given documentation comment as HTML.
+func (p *Page) Comment(comment string) template.HTML {
+	var buf bytes.Buffer
+	// TODO(gri) Provide list of words (e.g. function parameters)
+	//           to be emphasized by ToHTML.
+	doc.ToHTML(&buf, comment, nil) // does html-escaping
+	return template.HTML(buf.String())
+}
+
+// sanitize sanitizes the argument src by replacing newlines with
+// blanks, removing extra blanks, and by removing trailing whitespace
+// and commas before closing parentheses.
+func sanitize(src template.HTML) template.HTML {
+	buf := make([]byte, len(src))
+	j := 0      // buf index
+	comma := -1 // comma index if >= 0
+	for i := 0; i < len(src); i++ {
+		ch := src[i]
+		switch ch {
+		case '\t', '\n', ' ':
+			// ignore whitespace at the beginning, after a blank, or after opening parentheses
+			if j == 0 {
+				continue
+			}
+			if p := buf[j-1]; p == ' ' || p == '(' || p == '{' || p == '[' {
+				continue
+			}
+			// replace all whitespace with blanks
+			ch = ' '
+		case ',':
+			comma = j
+		case ')', '}', ']':
+			// remove any trailing comma
+			if comma >= 0 {
+				j = comma
+			}
+			// remove any trailing whitespace
+			if j > 0 && buf[j-1] == ' ' {
+				j--
+			}
+		default:
+			comma = -1
+		}
+		buf[j] = ch
+		j++
+	}
+	// remove trailing blank, if any
+	if j > 0 && buf[j-1] == ' ' {
+		j--
+	}
+	return template.HTML(buf[:j])
+}
+
+// Since reports the Go version that introduced the API feature
+// identified by kind, reeciver, name.
+func (p *Page) Since(kind, receiver, name string) string {
+	pkg := p.PDoc.ImportPath
+	return p.docs.api.Func(pkg, kind, receiver, name)
+}
+
+type Example struct {
+	Page   *Page
+	Name   string
+	Doc    string
+	Code   template.HTML
+	Play   string
+	Output string
+}
+
+// Example renders the examples for the given function name as HTML.
+func (p *Page) FmtExamples(funcName string) []*Example {
+	var list []*Example
+	for _, eg := range p.Examples {
+		name := trimExampleSuffix(eg.Name)
+
+		if name != funcName {
+			continue
+		}
+
+		// print code
+		cnode := &printer.CommentedNode{Node: eg.Code, Comments: eg.Comments}
+		code := p.Node(cnode)
+		out := eg.Output
+		wholeFile := true
+
+		// Additional formatting if this is a function body.
+		if n := len(code); n >= 2 && code[0] == '{' && code[n-1] == '}' {
+			wholeFile = false
+			// remove surrounding braces
+			code = code[1 : n-1]
+			// unindent
+			code = template.HTML(replaceLeadingIndentation(string(code), strings.Repeat(" ", tabWidth), ""))
+			// remove output comment
+			if loc := exampleOutputRx.FindStringIndex(string(code)); loc != nil {
+				code = template.HTML(strings.TrimSpace(string(code)[:loc[0]]))
+			}
+		}
+
+		// Write out the playground code in standard Go style
+		// (use tabs, no comment highlight, etc).
+		play := ""
+		if eg.Play != nil {
+			var buf bytes.Buffer
+			eg.Play.Comments = filterOutBuildAnnotations(eg.Play.Comments)
+			if err := format.Node(&buf, p.fset, eg.Play); err != nil {
+				log.Print(err)
+			} else {
+				play = buf.String()
+			}
+		}
+
+		// Drop output, as the output comment will appear in the code.
+		if wholeFile && play == "" {
+			out = ""
+		}
+
+		list = append(list, &Example{
+			Page:   p,
+			Name:   eg.Name,
+			Doc:    eg.Doc,
+			Code:   code,
+			Play:   play,
+			Output: out,
+		})
+	}
+	return list
+}
+
+// replaceLeadingIndentation replaces oldIndent at the beginning of each line
+// with newIndent. This is used for formatting examples. Raw strings that
+// span multiple lines are handled specially: oldIndent is not removed (since
+// go/printer will not add any indentation there), but newIndent is added
+// (since we may still want leading indentation).
+func replaceLeadingIndentation(body, oldIndent, newIndent string) string {
+	// Handle indent at the beginning of the first line. After this, we handle
+	// indentation only after a newline.
+	var buf bytes.Buffer
+	if strings.HasPrefix(body, oldIndent) {
+		buf.WriteString(newIndent)
+		body = body[len(oldIndent):]
+	}
+
+	// Use a state machine to keep track of whether we're in a string or
+	// rune literal while we process the rest of the code.
+	const (
+		codeState = iota
+		runeState
+		interpretedStringState
+		rawStringState
+	)
+	searchChars := []string{
+		"'\"`\n", // codeState
+		`\'`,     // runeState
+		`\"`,     // interpretedStringState
+		"`\n",    // rawStringState
+		// newlineState does not need to search
+	}
+	state := codeState
+	for {
+		i := strings.IndexAny(body, searchChars[state])
+		if i < 0 {
+			buf.WriteString(body)
+			break
+		}
+		c := body[i]
+		buf.WriteString(body[:i+1])
+		body = body[i+1:]
+		switch state {
+		case codeState:
+			switch c {
+			case '\'':
+				state = runeState
+			case '"':
+				state = interpretedStringState
+			case '`':
+				state = rawStringState
+			case '\n':
+				if strings.HasPrefix(body, oldIndent) {
+					buf.WriteString(newIndent)
+					body = body[len(oldIndent):]
+				}
+			}
+
+		case runeState:
+			switch c {
+			case '\\':
+				r, size := utf8.DecodeRuneInString(body)
+				buf.WriteRune(r)
+				body = body[size:]
+			case '\'':
+				state = codeState
+			}
+
+		case interpretedStringState:
+			switch c {
+			case '\\':
+				r, size := utf8.DecodeRuneInString(body)
+				buf.WriteRune(r)
+				body = body[size:]
+			case '"':
+				state = codeState
+			}
+
+		case rawStringState:
+			switch c {
+			case '`':
+				state = codeState
+			case '\n':
+				buf.WriteString(newIndent)
+			}
+		}
+	}
+	return buf.String()
+}
+
+var exampleOutputRx = regexp.MustCompile(`(?i)//[[:space:]]*(unordered )?output:`)
+
+func filterOutBuildAnnotations(cg []*ast.CommentGroup) []*ast.CommentGroup {
+	if len(cg) == 0 {
+		return cg
+	}
+
+	for i := range cg {
+		if !strings.HasPrefix(cg[i].Text(), "+build ") {
+			// Found the first non-build tag, return from here until the end
+			// of the slice.
+			return cg[i:]
+		}
+	}
+
+	// There weren't any non-build tags, return an empty slice.
+	return []*ast.CommentGroup{}
+}
+
+// ExampleName takes an example function name and returns its display
+// name. For example, "Foo_Bar_quux" becomes "Foo.Bar (Quux)".
+func (*Page) ExampleName(s string) string {
+	name, suffix := 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
+}
+
+// ExampleSuffix takes an example function name and returns its suffix in
+// parenthesized form. For example, "Foo_Bar_quux" becomes " (Quux)".
+func (*Page) ExampleSuffix(name string) string {
+	_, suffix := splitExampleName(name)
+	return suffix
+}
+
+// SrcPosLink returns a link to the specific source code position containing n,
+// which must be either an ast.Node or a *doc.Note.
+func (p *Page) SrcPosLink(n interface{}) template.HTML {
+	// n must be an ast.Node or a *doc.Note
+	var pos, end token.Pos
+
+	switch n := n.(type) {
+	case ast.Node:
+		pos = n.Pos()
+		end = n.End()
+	case *doc.Note:
+		pos = n.Pos
+		end = n.End
+	default:
+		panic(fmt.Sprintf("wrong type for SrcPosLink template formatter: %T", n))
+	}
+
+	var relpath string
+	var line int
+	var low, high int // selection offset range
+
+	if pos.IsValid() {
+		xp := p.fset.Position(pos)
+		relpath = xp.Filename
+		line = xp.Line
+		low = xp.Offset
+	}
+	if end.IsValid() {
+		high = p.fset.Position(end).Offset
+	}
+
+	return srcPosLink(relpath, line, low, high)
+}
+
+func srcPosLink(s string, line, low, high int) template.HTML {
+	s = path.Clean("/" + s)
+	if !strings.HasPrefix(s, "/src/") {
+		s = "/src" + s
+	}
+	var buf bytes.Buffer
+	template.HTMLEscape(&buf, []byte(s))
+	// selection ranges are of form "s=low:high"
+	if low < high {
+		fmt.Fprintf(&buf, "?s=%d:%d", low, high) // no need for URL escaping
+		// if we have a selection, position the page
+		// such that the selection is a bit below the top
+		line -= 10
+		if line < 1 {
+			line = 1
+		}
+	}
+	// line id's in html-printed source are of the
+	// form "L%d" where %d stands for the line number
+	if line > 0 {
+		fmt.Fprintf(&buf, "#L%d", line) // no need for URL escaping
+	}
+	return template.HTML(buf.String())
+}
diff --git a/internal/pkgdoc/html_test.go b/internal/pkgdoc/html_test.go
new file mode 100644
index 0000000..3b02145
--- /dev/null
+++ b/internal/pkgdoc/html_test.go
@@ -0,0 +1,284 @@
+// 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.
+
+package pkgdoc
+
+import (
+	"bytes"
+	"fmt"
+	"go/parser"
+	"go/token"
+	"strings"
+	"testing"
+
+	"golang.org/x/website/internal/backport/html/template"
+	"golang.org/x/website/internal/web"
+)
+
+func TestSrcPosLink(t *testing.T) {
+	for _, tc := range []struct {
+		src  string
+		line int
+		low  int
+		high int
+		want template.HTML
+	}{
+		{"/src/fmt/print.go", 42, 30, 50, "/src/fmt/print.go?s=30:50#L32"},
+		{"/src/fmt/print.go", 2, 1, 5, "/src/fmt/print.go?s=1:5#L1"},
+		{"/src/fmt/print.go", 2, 0, 0, "/src/fmt/print.go#L2"},
+		{"/src/fmt/print.go", 0, 0, 0, "/src/fmt/print.go"},
+		{"/src/fmt/print.go", 0, 1, 5, "/src/fmt/print.go?s=1:5#L1"},
+		{"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 := 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)
+		}
+	}
+}
+
+func TestSanitize(t *testing.T) {
+	for _, tc := range []struct {
+		src  template.HTML
+		want template.HTML
+	}{
+		{},
+		{"foo", "foo"},
+		{"func   f()", "func f()"},
+		{"func f(a int,)", "func f(a int)"},
+		{"func f(a int,\n)", "func f(a int)"},
+		{"func f(\n\ta int,\n\tb int,\n\tc int,\n)", "func f(a int, b int, c int)"},
+		{"  (   a,   b,  c  )  ", "(a, b, c)"},
+		{"(  a,  b, c    int, foo   bar  ,  )", "(a, b, c int, foo bar)"},
+		{"{   a,   b}", "{a, b}"},
+		{"[   a,   b]", "[a, b]"},
+	} {
+		if got := sanitize(tc.src); got != tc.want {
+			t.Errorf("sanitize(%v) = %v; want %v", tc.src, got, tc.want)
+		}
+	}
+}
+
+// Test that we add <span id="StructName.FieldName"> elements
+// to the HTML of struct fields.
+func TestStructFieldsIDAttributes(t *testing.T) {
+	got := linkifySource(t, []byte(`
+package foo
+
+type T struct {
+	NoDoc string
+
+	// Doc has a comment.
+	Doc string
+
+	// Opt, if non-nil, is an option.
+	Opt *int
+
+	// Опция - другое поле.
+	Опция bool
+}
+`))
+	want := `type T struct {
+<span id="T.NoDoc"></span>    NoDoc <a href="/pkg/builtin/#string">string</a>
+
+<span id="T.Doc"></span>    <span class="comment">// Doc has a comment.</span>
+    Doc <a href="/pkg/builtin/#string">string</a>
+
+<span id="T.Opt"></span>    <span class="comment">// Opt, if non-nil, is an option.</span>
+    Opt *<a href="/pkg/builtin/#int">int</a>
+
+<span id="T.Опция"></span>    <span class="comment">// Опция - другое поле.</span>
+    Опция <a href="/pkg/builtin/#bool">bool</a>
+}`
+	if got != want {
+		t.Errorf("got: %s\n\nwant: %s\n", got, want)
+	}
+}
+
+// Test that we add <span id="ConstName"> elements to the HTML
+// of definitions in const and var specs.
+func TestValueSpecIDAttributes(t *testing.T) {
+	got := linkifySource(t, []byte(`
+package foo
+
+const (
+	NoDoc string = "NoDoc"
+
+	// Doc has a comment
+	Doc = "Doc"
+
+	NoVal
+)`))
+	want := `const (
+    <span id="NoDoc">NoDoc</span> <a href="/pkg/builtin/#string">string</a> = &#34;NoDoc&#34;
+
+    <span class="comment">// Doc has a comment</span>
+    <span id="Doc">Doc</span> = &#34;Doc&#34;
+
+    <span id="NoVal">NoVal</span>
+)`
+	if got != want {
+		t.Errorf("got: %s\n\nwant: %s\n", got, want)
+	}
+}
+
+func TestCompositeLitLinkFields(t *testing.T) {
+	got := linkifySource(t, []byte(`
+package foo
+
+type T struct {
+	X int
+}
+
+var S T = T{X: 12}`))
+	want := `type T struct {
+<span id="T.X"></span>    X <a href="/pkg/builtin/#int">int</a>
+}
+var <span id="S">S</span> <a href="#T">T</a> = <a href="#T">T</a>{<a href="#T.X">X</a>: 12}`
+	if got != want {
+		t.Errorf("got: %s\n\nwant: %s\n", got, want)
+	}
+}
+
+func TestFuncDeclNotLink(t *testing.T) {
+	// Function.
+	got := linkifySource(t, []byte(`
+package http
+
+func Get(url string) (resp *Response, err error)`))
+	want := `func Get(url <a href="/pkg/builtin/#string">string</a>) (resp *<a href="#Response">Response</a>, err <a href="/pkg/builtin/#error">error</a>)`
+	if got != want {
+		t.Errorf("got: %s\n\nwant: %s\n", got, want)
+	}
+
+	// Method.
+	got = linkifySource(t, []byte(`
+package http
+
+func (h Header) Get(key string) string`))
+	want = `func (h <a href="#Header">Header</a>) Get(key <a href="/pkg/builtin/#string">string</a>) <a href="/pkg/builtin/#string">string</a>`
+	if got != want {
+		t.Errorf("got: %s\n\nwant: %s\n", got, want)
+	}
+}
+
+func linkifySource(t *testing.T, src []byte) string {
+	site := &web.Site{}
+	fset := token.NewFileSet()
+	af, err := parser.ParseFile(fset, "foo.go", src, parser.ParseComments)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var buf bytes.Buffer
+	pi := &Page{
+		fset: fset,
+	}
+	pg := &web.Page{
+		Data: pi,
+		Site: site,
+	}
+	pi.SetWebPage(pg)
+	sep := ""
+	for _, decl := range af.Decls {
+		buf.WriteString(sep)
+		sep = "\n"
+		buf.WriteString(string(pi.Node(decl)))
+	}
+	return buf.String()
+}
+
+func TestReplaceLeadingIndentation(t *testing.T) {
+	oldIndent := strings.Repeat(" ", 2)
+	newIndent := strings.Repeat(" ", 4)
+	tests := []struct {
+		src, want string
+	}{
+		{"  foo\n    bar\n  baz", "    foo\n      bar\n    baz"},
+		{"  '`'\n  '`'\n", "    '`'\n    '`'\n"},
+		{"  '\\''\n  '`'\n", "    '\\''\n    '`'\n"},
+		{"  \"`\"\n  \"`\"\n", "    \"`\"\n    \"`\"\n"},
+		{"  `foo\n  bar`", "    `foo\n      bar`"},
+		{"  `foo\\`\n  bar", "    `foo\\`\n    bar"},
+		{"  '\\`'`foo\n  bar", "    '\\`'`foo\n      bar"},
+		{
+			"  if true {\n    foo := `One\n    \tTwo\nThree`\n  }\n",
+			"    if true {\n      foo := `One\n        \tTwo\n    Three`\n    }\n",
+		},
+	}
+	for _, tc := range tests {
+		if got := replaceLeadingIndentation(tc.src, oldIndent, newIndent); got != tc.want {
+			t.Errorf("replaceLeadingIndentation:\n%v\n---\nhave:\n%v\n---\nwant:\n%v\n",
+				tc.src, got, tc.want)
+		}
+	}
+}
+
+func TestFilterOutBuildAnnotations(t *testing.T) {
+	// TODO: simplify this by using a multiline string once we stop
+	// using go vet from 1.10 on the build dashboard.
+	// https://golang.org/issue/26627
+	src := []byte("// +build !foo\n" +
+		"// +build !anothertag\n" +
+		"\n" +
+		"// non-tag comment\n" +
+		"\n" +
+		"package foo\n" +
+		"\n" +
+		"func bar() int {\n" +
+		"	return 42\n" +
+		"}\n")
+
+	fset := token.NewFileSet()
+	af, err := parser.ParseFile(fset, "foo.go", src, parser.ParseComments)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	var found bool
+	for _, cg := range af.Comments {
+		if strings.HasPrefix(cg.Text(), "+build ") {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Errorf("TestFilterOutBuildAnnotations is broken: missing build tag in test input")
+	}
+
+	found = false
+	for _, cg := range filterOutBuildAnnotations(af.Comments) {
+		if strings.HasPrefix(cg.Text(), "+build ") {
+			t.Errorf("filterOutBuildAnnotations failed to filter build tag")
+		}
+
+		if strings.Contains(cg.Text(), "non-tag comment") {
+			found = true
+		}
+	}
+	if !found {
+		t.Errorf("filterOutBuildAnnotations should not remove non-build tag comment")
+	}
+}
+
+// Verify that scanIdentifier isn't quadratic.
+// This doesn't actually measure and fail on its own, but it was previously
+// very obvious when running by hand.
+//
+// TODO: if there's a reliable and non-flaky way to test this, do so.
+// Maybe count user CPU time instead of wall time? But that's not easy
+// to do portably in Go.
+func TestStructField(t *testing.T) {
+	for _, n := range []int{10, 100, 1000, 10000} {
+		n := n
+		t.Run(fmt.Sprint(n), func(t *testing.T) {
+			var buf bytes.Buffer
+			fmt.Fprintf(&buf, "package foo\n\ntype T struct {\n")
+			for i := 0; i < n; i++ {
+				fmt.Fprintf(&buf, "\t// Field%d is foo.\n\tField%d int\n\n", i, i)
+			}
+			fmt.Fprintf(&buf, "}\n")
+			linkifySource(t, buf.Bytes())
+		})
+	}
+}
diff --git a/internal/web/tab.go b/internal/pkgdoc/tab.go
similarity index 98%
rename from internal/web/tab.go
rename to internal/pkgdoc/tab.go
index 88cb4f7..3a8dfbc 100644
--- a/internal/web/tab.go
+++ b/internal/pkgdoc/tab.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package web
+package pkgdoc
 
 import "io"
 
diff --git a/internal/web/astfuncs.go b/internal/web/astfuncs.go
deleted file mode 100644
index 4fbe5f7..0000000
--- a/internal/web/astfuncs.go
+++ /dev/null
@@ -1,212 +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.
-
-package web
-
-import (
-	"bufio"
-	"bytes"
-	"go/ast"
-	"go/doc"
-	"go/printer"
-	"go/token"
-	"io"
-	"log"
-	"unicode"
-
-	"golang.org/x/website/internal/api"
-	"golang.org/x/website/internal/backport/html/template"
-	"golang.org/x/website/internal/pkgdoc"
-	"golang.org/x/website/internal/texthtml"
-)
-
-var slashSlash = []byte("//")
-
-// Node formats the given AST node as HTML.
-// Identifiers in the rendered node
-// are turned into links to their documentation.
-func (p *Page) Node(node interface{}) template.HTML {
-	info := p.Data.(*pkgdoc.Page)
-	var buf1 bytes.Buffer
-	p.site.writeNode(&buf1, info, info.FSet, node)
-
-	var buf2 bytes.Buffer
-	n, _ := node.(ast.Node)
-	buf2.Write(texthtml.Format(buf1.Bytes(), texthtml.Config{
-		AST:        n,
-		GoComments: true,
-		OldDocs:    p.OldDocs,
-	}))
-	return template.HTML(buf2.String())
-}
-
-// NodeTOC formats the given AST node as HTML
-// for inclusion in the table of contents.
-func (p *Page) NodeTOC(node interface{}) template.HTML {
-	info := p.Data.(*pkgdoc.Page)
-	var buf1 bytes.Buffer
-	p.site.writeNode(&buf1, info, info.FSet, node)
-
-	var buf2 bytes.Buffer
-	buf2.Write(texthtml.Format(buf1.Bytes(), texthtml.Config{
-		GoComments: true,
-		OldDocs:    p.OldDocs,
-	}))
-
-	return sanitize(template.HTML(buf2.String()))
-}
-
-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 (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
-	// the right number of spaces is much harder)
-	//
-	// TODO(gri) rethink printer flags - perhaps tconv can be eliminated
-	//           with an another printer mode (which is more efficiently
-	//           implemented in the printer than here with another layer)
-
-	var pkgName, structName string
-	var apiInfo api.PkgDB
-	if gd, ok := x.(*ast.GenDecl); ok && pageInfo != nil && pageInfo.PDoc != nil &&
-		gd.Tok == token.TYPE && len(gd.Specs) != 0 {
-		pkgName = pageInfo.PDoc.ImportPath
-		if ts, ok := gd.Specs[0].(*ast.TypeSpec); ok {
-			if _, ok := ts.Type.(*ast.StructType); ok {
-				structName = ts.Name.Name
-			}
-		}
-		apiInfo = s.api[pkgName]
-	}
-
-	var out = w
-	var buf bytes.Buffer
-	if structName != "" {
-		out = &buf
-	}
-
-	mode := printer.TabIndent | printer.UseSpaces
-	err := (&printer.Config{Mode: mode, Tabwidth: tabWidth}).Fprint(tabSpacer(out, tabWidth), fset, x)
-	if err != nil {
-		log.Print(err)
-	}
-
-	// Add comments to struct fields saying which Go version introduced them.
-	if structName != "" {
-		fieldSince := apiInfo.Field[structName]
-		typeSince := apiInfo.Type[structName]
-		// Add/rewrite comments on struct fields to note which Go version added them.
-		var buf2 bytes.Buffer
-		buf2.Grow(buf.Len() + len(" // Added in Go 1.n")*10)
-		bs := bufio.NewScanner(&buf)
-		for bs.Scan() {
-			line := bs.Bytes()
-			field := firstIdent(line)
-			var since string
-			if field != "" {
-				since = fieldSince[field]
-				if since != "" && since == typeSince {
-					// Don't highlight field versions if they were the
-					// same as the struct itself.
-					since = ""
-				}
-			}
-			if since == "" {
-				buf2.Write(line)
-			} else {
-				if bytes.Contains(line, slashSlash) {
-					line = bytes.TrimRight(line, " \t.")
-					buf2.Write(line)
-					buf2.WriteString("; added in Go ")
-				} else {
-					buf2.Write(line)
-					buf2.WriteString(" // Go ")
-				}
-				buf2.WriteString(since)
-			}
-			buf2.WriteByte('\n')
-		}
-		w.Write(buf2.Bytes())
-	}
-}
-
-// firstIdent returns the first identifier in x.
-// This actually parses "identifiers" that begin with numbers too, but we
-// never feed it such input, so it's fine.
-func firstIdent(x []byte) string {
-	x = bytes.TrimSpace(x)
-	i := bytes.IndexFunc(x, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) })
-	if i == -1 {
-		return string(x)
-	}
-	return string(x[:i])
-}
-
-// Comment formats the given documentation comment as HTML.
-func (p *Page) Comment(comment string) template.HTML {
-	var buf bytes.Buffer
-	// TODO(gri) Provide list of words (e.g. function parameters)
-	//           to be emphasized by ToHTML.
-	doc.ToHTML(&buf, comment, nil) // does html-escaping
-	return template.HTML(buf.String())
-}
-
-// sanitize sanitizes the argument src by replacing newlines with
-// blanks, removing extra blanks, and by removing trailing whitespace
-// and commas before closing parentheses.
-func sanitize(src template.HTML) template.HTML {
-	buf := make([]byte, len(src))
-	j := 0      // buf index
-	comma := -1 // comma index if >= 0
-	for i := 0; i < len(src); i++ {
-		ch := src[i]
-		switch ch {
-		case '\t', '\n', ' ':
-			// ignore whitespace at the beginning, after a blank, or after opening parentheses
-			if j == 0 {
-				continue
-			}
-			if p := buf[j-1]; p == ' ' || p == '(' || p == '{' || p == '[' {
-				continue
-			}
-			// replace all whitespace with blanks
-			ch = ' '
-		case ',':
-			comma = j
-		case ')', '}', ']':
-			// remove any trailing comma
-			if comma >= 0 {
-				j = comma
-			}
-			// remove any trailing whitespace
-			if j > 0 && buf[j-1] == ' ' {
-				j--
-			}
-		default:
-			comma = -1
-		}
-		buf[j] = ch
-		j++
-	}
-	// remove trailing blank, if any
-	if j > 0 && buf[j-1] == ' ' {
-		j--
-	}
-	return template.HTML(buf[:j])
-}
-
-// Since reports the Go version that introduced the API feature
-// identified by kind, reeciver, name.
-// 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.site.api.Func(pkg, kind, receiver, name)
-}
diff --git a/internal/web/examplefuncs.go b/internal/web/examplefuncs.go
deleted file mode 100644
index a47dc27..0000000
--- a/internal/web/examplefuncs.go
+++ /dev/null
@@ -1,194 +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.
-
-package web
-
-import (
-	"bytes"
-	"go/ast"
-	"go/format"
-	"go/printer"
-	"log"
-	"regexp"
-	"strings"
-	"unicode/utf8"
-
-	"golang.org/x/website/internal/backport/html/template"
-	"golang.org/x/website/internal/pkgdoc"
-)
-
-// Example renders the examples for the given function name as HTML.
-// The current package is deduced from p.Data, which must be a *pkgdoc.Page.
-func (p *Page) Example(funcName string) template.HTML {
-	info := p.Data.(*pkgdoc.Page)
-	var buf bytes.Buffer
-	for _, eg := range info.Examples {
-		name := pkgdoc.TrimExampleSuffix(eg.Name)
-
-		if name != funcName {
-			continue
-		}
-
-		// print code
-		cnode := &printer.CommentedNode{Node: eg.Code, Comments: eg.Comments}
-		code := string(p.Node(cnode))
-		out := eg.Output
-		wholeFile := true
-
-		// Additional formatting if this is a function body.
-		if n := len(code); n >= 2 && code[0] == '{' && code[n-1] == '}' {
-			wholeFile = false
-			// remove surrounding braces
-			code = code[1 : n-1]
-			// unindent
-			code = replaceLeadingIndentation(code, strings.Repeat(" ", tabWidth), "")
-			// remove output comment
-			if loc := exampleOutputRx.FindStringIndex(code); loc != nil {
-				code = strings.TrimSpace(code[:loc[0]])
-			}
-		}
-
-		// Write out the playground code in standard Go style
-		// (use tabs, no comment highlight, etc).
-		play := ""
-		if eg.Play != nil {
-			var buf bytes.Buffer
-			eg.Play.Comments = filterOutBuildAnnotations(eg.Play.Comments)
-			if err := format.Node(&buf, info.FSet, eg.Play); err != nil {
-				log.Print(err)
-			} else {
-				play = buf.String()
-			}
-		}
-
-		// Drop output, as the output comment will appear in the code.
-		if wholeFile && play == "" {
-			out = ""
-		}
-
-		t := p.site.Templates.Lookup("example.html")
-		if t == nil {
-			return ""
-		}
-
-		newPage := *p
-		newPage.Data = struct {
-			Name, Doc, Play, Output string
-			Code                    template.HTML
-		}{
-			eg.Name, eg.Doc, play, out, template.HTML(code),
-		}
-		err := t.Execute(&buf, &newPage)
-		if err != nil {
-			log.Print(err)
-		}
-	}
-	return template.HTML(buf.String())
-}
-
-// replaceLeadingIndentation replaces oldIndent at the beginning of each line
-// with newIndent. This is used for formatting examples. Raw strings that
-// span multiple lines are handled specially: oldIndent is not removed (since
-// go/printer will not add any indentation there), but newIndent is added
-// (since we may still want leading indentation).
-func replaceLeadingIndentation(body, oldIndent, newIndent string) string {
-	// Handle indent at the beginning of the first line. After this, we handle
-	// indentation only after a newline.
-	var buf bytes.Buffer
-	if strings.HasPrefix(body, oldIndent) {
-		buf.WriteString(newIndent)
-		body = body[len(oldIndent):]
-	}
-
-	// Use a state machine to keep track of whether we're in a string or
-	// rune literal while we process the rest of the code.
-	const (
-		codeState = iota
-		runeState
-		interpretedStringState
-		rawStringState
-	)
-	searchChars := []string{
-		"'\"`\n", // codeState
-		`\'`,     // runeState
-		`\"`,     // interpretedStringState
-		"`\n",    // rawStringState
-		// newlineState does not need to search
-	}
-	state := codeState
-	for {
-		i := strings.IndexAny(body, searchChars[state])
-		if i < 0 {
-			buf.WriteString(body)
-			break
-		}
-		c := body[i]
-		buf.WriteString(body[:i+1])
-		body = body[i+1:]
-		switch state {
-		case codeState:
-			switch c {
-			case '\'':
-				state = runeState
-			case '"':
-				state = interpretedStringState
-			case '`':
-				state = rawStringState
-			case '\n':
-				if strings.HasPrefix(body, oldIndent) {
-					buf.WriteString(newIndent)
-					body = body[len(oldIndent):]
-				}
-			}
-
-		case runeState:
-			switch c {
-			case '\\':
-				r, size := utf8.DecodeRuneInString(body)
-				buf.WriteRune(r)
-				body = body[size:]
-			case '\'':
-				state = codeState
-			}
-
-		case interpretedStringState:
-			switch c {
-			case '\\':
-				r, size := utf8.DecodeRuneInString(body)
-				buf.WriteRune(r)
-				body = body[size:]
-			case '"':
-				state = codeState
-			}
-
-		case rawStringState:
-			switch c {
-			case '`':
-				state = codeState
-			case '\n':
-				buf.WriteString(newIndent)
-			}
-		}
-	}
-	return buf.String()
-}
-
-var exampleOutputRx = regexp.MustCompile(`(?i)//[[:space:]]*(unordered )?output:`)
-
-func filterOutBuildAnnotations(cg []*ast.CommentGroup) []*ast.CommentGroup {
-	if len(cg) == 0 {
-		return cg
-	}
-
-	for i := range cg {
-		if !strings.HasPrefix(cg[i].Text(), "+build ") {
-			// Found the first non-build tag, return from here until the end
-			// of the slice.
-			return cg[i:]
-		}
-	}
-
-	// There weren't any non-build tags, return an empty slice.
-	return []*ast.CommentGroup{}
-}
diff --git a/internal/web/pkgdoc.go b/internal/web/pkgdoc.go
deleted file mode 100644
index 21dc1cd..0000000
--- a/internal/web/pkgdoc.go
+++ /dev/null
@@ -1,128 +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.
-
-package web
-
-import (
-	"log"
-	"net/http"
-	"net/url"
-	"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, "/")
-
-	mode := pkgdoc.ParseMode(r.FormValue("m"))
-
-	// Redirect to pkg.go.dev.
-	// We provide two overrides for the redirect.
-	// First, the request can set ?m=old to get the old pages.
-	// Second, the request can come from China:
-	// since pkg.go.dev is not available in China, we serve the docs directly.
-	if mode&pkgdoc.ModeOld == 0 && !GoogleCN(r) {
-		if relpath == "" {
-			relpath = "std"
-		}
-		suffix := ""
-		if r.Host == "tip.golang.org" {
-			suffix = "@master"
-		}
-		if goos, goarch := r.FormValue("GOOS"), r.FormValue("GOARCH"); goos != "" || goarch != "" {
-			suffix += "?"
-			if goos != "" {
-				suffix += "GOOS=" + url.QueryEscape(goos)
-			}
-			if goarch != "" {
-				if goos != "" {
-					suffix += "&"
-				}
-				suffix += "GOARCH=" + url.QueryEscape(goarch)
-			}
-		}
-		http.Redirect(w, r, "https://pkg.go.dev/"+relpath+suffix, http.StatusTemporaryRedirect)
-		return
-	}
-
-	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, "src/"+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,
-		OldDocs:  mode&pkgdoc.ModeOld != 0,
-	})
-}
-
-// 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
index 96da21c..c4d9a30 100644
--- a/internal/web/site.go
+++ b/internal/web/site.go
@@ -17,19 +17,16 @@
 	"strconv"
 	"strings"
 
-	"golang.org/x/website/internal/api"
 	"golang.org/x/website/internal/backport/html/template"
 	"golang.org/x/website/internal/backport/httpfs"
 	"golang.org/x/website/internal/backport/io/fs"
-	"golang.org/x/website/internal/pkgdoc"
 	"golang.org/x/website/internal/spec"
 	"golang.org/x/website/internal/texthtml"
 )
 
 // Site is a website served from a file system.
 type Site struct {
-	fs  fs.FS
-	api api.DB
+	fs fs.FS
 
 	mux        *http.ServeMux
 	fileServer http.Handler
@@ -45,22 +42,11 @@
 
 // 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(httpfs.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()
 
@@ -91,6 +77,9 @@
 // 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)
+	if d, ok := page.Data.(interface{ SetWebPage(*Page) }); ok {
+		d.SetWebPage(&page)
+	}
 	applyTemplateToResponseWriter(w, s.Templates.Lookup("site.html"), &page)
 }
 
@@ -117,15 +106,11 @@
 	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 for document rendering
-	OldDocs bool // use ?m=old in doc links
-
 	// Filled in automatically by ServePage
 	GoogleCN        bool   // served on golang.google.cn
 	GoogleAnalytics string // Google Analytics tag
-	Version         string // current Go version
-
-	site *Site
+	Version         string
+	Site            *Site
 }
 
 // fullPage returns a copy of page with the “automatic” fields filled in.
@@ -136,14 +121,14 @@
 	page.Version = runtime.Version()
 	page.GoogleCN = GoogleCN(r)
 	page.GoogleAnalytics = s.GoogleAnalytics
-	page.site = s
+	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)
+	t := p.Site.Templates.Lookup(name)
 	var buf bytes.Buffer
 	p1 := *p
 	p1.Data = data
diff --git a/internal/web/sitefuncs.go b/internal/web/sitefuncs.go
index 02d093c..747973b 100644
--- a/internal/web/sitefuncs.go
+++ b/internal/web/sitefuncs.go
@@ -7,49 +7,21 @@
 import (
 	"bytes"
 	"fmt"
-	"go/ast"
-	"go/doc"
-	"go/token"
 	"html"
 	"path"
 	"strings"
 
 	"golang.org/x/website/internal/backport/html/template"
-	"golang.org/x/website/internal/pkgdoc"
 )
 
 var siteFuncs = template.FuncMap{
 	// various helpers
 	"basename": path.Base,
 
-	// formatting of Examples
-	"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
@@ -100,64 +72,3 @@
 	buf.WriteString(`</span>`)
 	return template.HTML(buf.String())
 }
-
-// SrcPosLink returns a link to the specific source code position containing n,
-// which must be either an ast.Node or a *doc.Note.
-// The current package is deduced from p.Data, which must be a *pkgdoc.Page.
-func (p *Page) SrcPosLink(n interface{}) template.HTML {
-	info := p.Data.(*pkgdoc.Page)
-	// n must be an ast.Node or a *doc.Note
-	var pos, end token.Pos
-
-	switch n := n.(type) {
-	case ast.Node:
-		pos = n.Pos()
-		end = n.End()
-	case *doc.Note:
-		pos = n.Pos
-		end = n.End
-	default:
-		panic(fmt.Sprintf("wrong type for SrcPosLink template formatter: %T", n))
-	}
-
-	var relpath string
-	var line int
-	var low, high int // selection offset range
-
-	if pos.IsValid() {
-		p := info.FSet.Position(pos)
-		relpath = p.Filename
-		line = p.Line
-		low = p.Offset
-	}
-	if end.IsValid() {
-		high = info.FSet.Position(end).Offset
-	}
-
-	return srcPosLink(relpath, line, low, high)
-}
-
-func srcPosLink(s string, line, low, high int) template.HTML {
-	s = path.Clean("/" + s)
-	if !strings.HasPrefix(s, "/src/") {
-		s = "/src" + s
-	}
-	var buf bytes.Buffer
-	template.HTMLEscape(&buf, []byte(s))
-	// selection ranges are of form "s=low:high"
-	if low < high {
-		fmt.Fprintf(&buf, "?s=%d:%d", low, high) // no need for URL escaping
-		// if we have a selection, position the page
-		// such that the selection is a bit below the top
-		line -= 10
-		if line < 1 {
-			line = 1
-		}
-	}
-	// line id's in html-printed source are of the
-	// form "L%d" where %d stands for the line number
-	if line > 0 {
-		fmt.Fprintf(&buf, "#L%d", line) // no need for URL escaping
-	}
-	return template.HTML(buf.String())
-}
diff --git a/internal/web/template_test.go b/internal/web/template_test.go
index c9fbb84..0c8371a 100644
--- a/internal/web/template_test.go
+++ b/internal/web/template_test.go
@@ -5,15 +5,9 @@
 package web
 
 import (
-	"bytes"
-	"fmt"
-	"go/parser"
-	"go/token"
-	"strings"
 	"testing"
 
 	"golang.org/x/website/internal/backport/html/template"
-	"golang.org/x/website/internal/pkgdoc"
 )
 
 func TestSrcToPkg(t *testing.T) {
@@ -34,203 +28,6 @@
 	}
 }
 
-func TestSrcPosLinkFunc(t *testing.T) {
-	for _, tc := range []struct {
-		src  string
-		line int
-		low  int
-		high int
-		want template.HTML
-	}{
-		{"/src/fmt/print.go", 42, 30, 50, "/src/fmt/print.go?s=30:50#L32"},
-		{"/src/fmt/print.go", 2, 1, 5, "/src/fmt/print.go?s=1:5#L1"},
-		{"/src/fmt/print.go", 2, 0, 0, "/src/fmt/print.go#L2"},
-		{"/src/fmt/print.go", 0, 0, 0, "/src/fmt/print.go"},
-		{"/src/fmt/print.go", 0, 1, 5, "/src/fmt/print.go?s=1:5#L1"},
-		{"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 := 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)
-		}
-	}
-}
-
-func TestSanitize(t *testing.T) {
-	for _, tc := range []struct {
-		src  template.HTML
-		want template.HTML
-	}{
-		{},
-		{"foo", "foo"},
-		{"func   f()", "func f()"},
-		{"func f(a int,)", "func f(a int)"},
-		{"func f(a int,\n)", "func f(a int)"},
-		{"func f(\n\ta int,\n\tb int,\n\tc int,\n)", "func f(a int, b int, c int)"},
-		{"  (   a,   b,  c  )  ", "(a, b, c)"},
-		{"(  a,  b, c    int, foo   bar  ,  )", "(a, b, c int, foo bar)"},
-		{"{   a,   b}", "{a, b}"},
-		{"[   a,   b]", "[a, b]"},
-	} {
-		if got := sanitize(tc.src); got != tc.want {
-			t.Errorf("sanitize(%v) = %v; want %v", tc.src, got, tc.want)
-		}
-	}
-}
-
-// Test that we add <span id="StructName.FieldName"> elements
-// to the HTML of struct fields.
-func TestStructFieldsIDAttributes(t *testing.T) {
-	got := linkifySource(t, []byte(`
-package foo
-
-type T struct {
-	NoDoc string
-
-	// Doc has a comment.
-	Doc string
-
-	// Opt, if non-nil, is an option.
-	Opt *int
-
-	// Опция - другое поле.
-	Опция bool
-}
-`))
-	want := `type T struct {
-<span id="T.NoDoc"></span>    NoDoc <a href="/pkg/builtin/#string">string</a>
-
-<span id="T.Doc"></span>    <span class="comment">// Doc has a comment.</span>
-    Doc <a href="/pkg/builtin/#string">string</a>
-
-<span id="T.Opt"></span>    <span class="comment">// Opt, if non-nil, is an option.</span>
-    Opt *<a href="/pkg/builtin/#int">int</a>
-
-<span id="T.Опция"></span>    <span class="comment">// Опция - другое поле.</span>
-    Опция <a href="/pkg/builtin/#bool">bool</a>
-}`
-	if got != want {
-		t.Errorf("got: %s\n\nwant: %s\n", got, want)
-	}
-}
-
-// Test that we add <span id="ConstName"> elements to the HTML
-// of definitions in const and var specs.
-func TestValueSpecIDAttributes(t *testing.T) {
-	got := linkifySource(t, []byte(`
-package foo
-
-const (
-	NoDoc string = "NoDoc"
-
-	// Doc has a comment
-	Doc = "Doc"
-
-	NoVal
-)`))
-	want := `const (
-    <span id="NoDoc">NoDoc</span> <a href="/pkg/builtin/#string">string</a> = &#34;NoDoc&#34;
-
-    <span class="comment">// Doc has a comment</span>
-    <span id="Doc">Doc</span> = &#34;Doc&#34;
-
-    <span id="NoVal">NoVal</span>
-)`
-	if got != want {
-		t.Errorf("got: %s\n\nwant: %s\n", got, want)
-	}
-}
-
-func TestCompositeLitLinkFields(t *testing.T) {
-	got := linkifySource(t, []byte(`
-package foo
-
-type T struct {
-	X int
-}
-
-var S T = T{X: 12}`))
-	want := `type T struct {
-<span id="T.X"></span>    X <a href="/pkg/builtin/#int">int</a>
-}
-var <span id="S">S</span> <a href="#T">T</a> = <a href="#T">T</a>{<a href="#T.X">X</a>: 12}`
-	if got != want {
-		t.Errorf("got: %s\n\nwant: %s\n", got, want)
-	}
-}
-
-func TestFuncDeclNotLink(t *testing.T) {
-	// Function.
-	got := linkifySource(t, []byte(`
-package http
-
-func Get(url string) (resp *Response, err error)`))
-	want := `func Get(url <a href="/pkg/builtin/#string">string</a>) (resp *<a href="#Response">Response</a>, err <a href="/pkg/builtin/#error">error</a>)`
-	if got != want {
-		t.Errorf("got: %s\n\nwant: %s\n", got, want)
-	}
-
-	// Method.
-	got = linkifySource(t, []byte(`
-package http
-
-func (h Header) Get(key string) string`))
-	want = `func (h <a href="#Header">Header</a>) Get(key <a href="/pkg/builtin/#string">string</a>) <a href="/pkg/builtin/#string">string</a>`
-	if got != want {
-		t.Errorf("got: %s\n\nwant: %s\n", got, want)
-	}
-}
-
-func linkifySource(t *testing.T, src []byte) string {
-	p := &Site{}
-	fset := token.NewFileSet()
-	af, err := parser.ParseFile(fset, "foo.go", src, parser.ParseComments)
-	if err != nil {
-		t.Fatal(err)
-	}
-	var buf bytes.Buffer
-	pi := &pkgdoc.Page{
-		FSet: fset,
-	}
-	pg := &Page{
-		site: p,
-		Data: pi,
-	}
-	sep := ""
-	for _, decl := range af.Decls {
-		buf.WriteString(sep)
-		sep = "\n"
-		buf.WriteString(string(pg.Node(decl)))
-	}
-	return buf.String()
-}
-
-func TestReplaceLeadingIndentation(t *testing.T) {
-	oldIndent := strings.Repeat(" ", 2)
-	newIndent := strings.Repeat(" ", 4)
-	tests := []struct {
-		src, want string
-	}{
-		{"  foo\n    bar\n  baz", "    foo\n      bar\n    baz"},
-		{"  '`'\n  '`'\n", "    '`'\n    '`'\n"},
-		{"  '\\''\n  '`'\n", "    '\\''\n    '`'\n"},
-		{"  \"`\"\n  \"`\"\n", "    \"`\"\n    \"`\"\n"},
-		{"  `foo\n  bar`", "    `foo\n      bar`"},
-		{"  `foo\\`\n  bar", "    `foo\\`\n    bar"},
-		{"  '\\`'`foo\n  bar", "    '\\`'`foo\n      bar"},
-		{
-			"  if true {\n    foo := `One\n    \tTwo\nThree`\n  }\n",
-			"    if true {\n      foo := `One\n        \tTwo\n    Three`\n    }\n",
-		},
-	}
-	for _, tc := range tests {
-		if got := replaceLeadingIndentation(tc.src, oldIndent, newIndent); got != tc.want {
-			t.Errorf("replaceLeadingIndentation:\n%v\n---\nhave:\n%v\n---\nwant:\n%v\n",
-				tc.src, got, tc.want)
-		}
-	}
-}
-
 func TestSrcBreadcrumbFunc(t *testing.T) {
 	for _, tc := range []struct {
 		path string
@@ -261,72 +58,3 @@
 		}
 	}
 }
-
-func TestFilterOutBuildAnnotations(t *testing.T) {
-	// TODO: simplify this by using a multiline string once we stop
-	// using go vet from 1.10 on the build dashboard.
-	// https://golang.org/issue/26627
-	src := []byte("// +build !foo\n" +
-		"// +build !anothertag\n" +
-		"\n" +
-		"// non-tag comment\n" +
-		"\n" +
-		"package foo\n" +
-		"\n" +
-		"func bar() int {\n" +
-		"	return 42\n" +
-		"}\n")
-
-	fset := token.NewFileSet()
-	af, err := parser.ParseFile(fset, "foo.go", src, parser.ParseComments)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	var found bool
-	for _, cg := range af.Comments {
-		if strings.HasPrefix(cg.Text(), "+build ") {
-			found = true
-			break
-		}
-	}
-	if !found {
-		t.Errorf("TestFilterOutBuildAnnotations is broken: missing build tag in test input")
-	}
-
-	found = false
-	for _, cg := range filterOutBuildAnnotations(af.Comments) {
-		if strings.HasPrefix(cg.Text(), "+build ") {
-			t.Errorf("filterOutBuildAnnotations failed to filter build tag")
-		}
-
-		if strings.Contains(cg.Text(), "non-tag comment") {
-			found = true
-		}
-	}
-	if !found {
-		t.Errorf("filterOutBuildAnnotations should not remove non-build tag comment")
-	}
-}
-
-// Verify that scanIdentifier isn't quadratic.
-// This doesn't actually measure and fail on its own, but it was previously
-// very obvious when running by hand.
-//
-// TODO: if there's a reliable and non-flaky way to test this, do so.
-// Maybe count user CPU time instead of wall time? But that's not easy
-// to do portably in Go.
-func TestStructField(t *testing.T) {
-	for _, n := range []int{10, 100, 1000, 10000} {
-		n := n
-		t.Run(fmt.Sprint(n), func(t *testing.T) {
-			var buf bytes.Buffer
-			fmt.Fprintf(&buf, "package foo\n\ntype T struct {\n")
-			for i := 0; i < n; i++ {
-				fmt.Fprintf(&buf, "\t// Field%d is foo.\n\tField%d int\n\n", i, i)
-			}
-			fmt.Fprintf(&buf, "}\n")
-			linkifySource(t, buf.Bytes())
-		})
-	}
-}